Bumping manifests a=b2g-bump
[gecko.git] / browser / modules / UITour.jsm
blob63906479ef6591e51d1dd2af817fdc8087ce3e03
1 // This Source Code Form is subject to the terms of the Mozilla Public
2 // License, v. 2.0. If a copy of the MPL was not distributed with this
3 // file, You can obtain one at http://mozilla.org/MPL/2.0/.
5 "use strict";
7 this.EXPORTED_SYMBOLS = ["UITour", "UITourMetricsProvider"];
9 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
11 Cu.import("resource://gre/modules/Services.jsm");
12 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
13 Cu.import("resource://gre/modules/Promise.jsm");
14 Cu.import("resource://gre/modules/Task.jsm");
16 XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager",
17   "resource://gre/modules/LightweightThemeManager.jsm");
18 XPCOMUtils.defineLazyModuleGetter(this, "PermissionsUtils",
19   "resource://gre/modules/PermissionsUtils.jsm");
20 XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
21   "resource:///modules/CustomizableUI.jsm");
22 XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry",
23   "resource://gre/modules/UITelemetry.jsm");
24 XPCOMUtils.defineLazyModuleGetter(this, "BrowserUITelemetry",
25   "resource:///modules/BrowserUITelemetry.jsm");
26 XPCOMUtils.defineLazyModuleGetter(this, "Metrics",
27   "resource://gre/modules/Metrics.jsm");
29 const UITOUR_PERMISSION   = "uitour";
30 const PREF_PERM_BRANCH    = "browser.uitour.";
31 const PREF_SEENPAGEIDS    = "browser.uitour.seenPageIDs";
32 const MAX_BUTTONS         = 4;
34 const BUCKET_NAME         = "UITour";
35 const BUCKET_TIMESTEPS    = [
36   1 * 60 * 1000, // Until 1 minute after tab is closed/inactive.
37   3 * 60 * 1000, // Until 3 minutes after tab is closed/inactive.
38   10 * 60 * 1000, // Until 10 minutes after tab is closed/inactive.
39   60 * 60 * 1000, // Until 1 hour after tab is closed/inactive.
42 // Time after which seen Page IDs expire.
43 const SEENPAGEID_EXPIRY  = 8 * 7 * 24 * 60 * 60 * 1000; // 8 weeks.
45 // Prefix for any target matching a search engine.
46 const TARGET_SEARCHENGINE_PREFIX = "searchEngine-";
49 this.UITour = {
50   url: null,
51   seenPageIDs: null,
52   pageIDSourceTabs: new WeakMap(),
53   pageIDSourceWindows: new WeakMap(),
54   /* Map from browser windows to a set of tabs in which a tour is open */
55   originTabs: new WeakMap(),
56   /* Map from browser windows to a set of pinned tabs opened by (a) tour(s) */
57   pinnedTabs: new WeakMap(),
58   urlbarCapture: new WeakMap(),
59   appMenuOpenForAnnotation: new Set(),
60   availableTargetsCache: new WeakMap(),
62   _detachingTab: false,
63   _annotationPanelMutationObservers: new WeakMap(),
64   _queuedEvents: [],
65   _pendingDoc: null,
67   highlightEffects: ["random", "wobble", "zoom", "color"],
68   targets: new Map([
69     ["accountStatus", {
70       query: (aDocument) => {
71         let statusButton = aDocument.getElementById("PanelUI-fxa-status");
72         return aDocument.getAnonymousElementByAttribute(statusButton,
73                                                         "class",
74                                                         "toolbarbutton-icon");
75       },
76       widgetName: "PanelUI-fxa-status",
77     }],
78     ["addons",      {query: "#add-ons-button"}],
79     ["appMenu",     {
80       addTargetListener: (aDocument, aCallback) => {
81         let panelPopup = aDocument.getElementById("PanelUI-popup");
82         panelPopup.addEventListener("popupshown", aCallback);
83       },
84       query: "#PanelUI-button",
85       removeTargetListener: (aDocument, aCallback) => {
86         let panelPopup = aDocument.getElementById("PanelUI-popup");
87         panelPopup.removeEventListener("popupshown", aCallback);
88       },
89     }],
90     ["backForward", {
91       query: "#back-button",
92       widgetName: "urlbar-container",
93     }],
94     ["bookmarks",   {query: "#bookmarks-menu-button"}],
95     ["customize",   {
96       query: (aDocument) => {
97         let customizeButton = aDocument.getElementById("PanelUI-customize");
98         return aDocument.getAnonymousElementByAttribute(customizeButton,
99                                                         "class",
100                                                         "toolbarbutton-icon");
101       },
102       widgetName: "PanelUI-customize",
103     }],
104     ["help",        {query: "#PanelUI-help"}],
105     ["home",        {query: "#home-button"}],
106     ["loop",        {query: "#loop-button-throttled"}],
107     ["forget", {
108       query: "#panic-button",
109       widgetName: "panic-button",
110       allowAdd: true }],
111     ["privateWindow",  {query: "#privatebrowsing-button"}],
112     ["quit",        {query: "#PanelUI-quit"}],
113     ["search",      {
114       query: "#searchbar",
115       widgetName: "search-container",
116     }],
117     ["searchProvider", {
118       query: (aDocument) => {
119         let searchbar = aDocument.getElementById("searchbar");
120         if (searchbar.hasAttribute("oneoffui")) {
121           return null;
122         }
123         return aDocument.getAnonymousElementByAttribute(searchbar,
124                                                         "anonid",
125                                                         "searchbar-engine-button");
126       },
127       widgetName: "search-container",
128     }],
129     ["searchIcon", {
130       query: (aDocument) => {
131         let searchbar = aDocument.getElementById("searchbar");
132         if (!searchbar.hasAttribute("oneoffui")) {
133           return null;
134         }
135         return aDocument.getAnonymousElementByAttribute(searchbar,
136                                                         "anonid",
137                                                         "searchbar-search-button");
138       },
139       widgetName: "search-container",
140     }],
141     ["searchPrefsLink", {
142       query: (aDocument) => {
143         let element = null;
144         let searchbar = aDocument.getElementById("searchbar");
145         if (searchbar.hasAttribute("oneoffui")) {
146           let popup = aDocument.getElementById("PopupSearchAutoComplete");
147           if (popup.state != "open")
148             return null;
149           element = aDocument.getAnonymousElementByAttribute(popup,
150                                                              "anonid",
151                                                              "search-settings");
152         } else {
153           element = aDocument.getAnonymousElementByAttribute(searchbar,
154                                                              "anonid",
155                                                              "open-engine-manager");
156         }
157         if (!element || !UITour.isElementVisible(element)) {
158           return null;
159         }
160         return element;
161       },
162     }],
163     ["selectedTabIcon", {
164       query: (aDocument) => {
165         let selectedtab = aDocument.defaultView.gBrowser.selectedTab;
166         let element = aDocument.getAnonymousElementByAttribute(selectedtab,
167                                                                "anonid",
168                                                                "tab-icon-image");
169         if (!element || !UITour.isElementVisible(element)) {
170           return null;
171         }
172         return element;
173       },
174     }],
175     ["urlbar",      {
176       query: "#urlbar",
177       widgetName: "urlbar-container",
178     }],
179   ]),
181   init: function() {
182     // Lazy getter is initialized here so it can be replicated any time
183     // in a test.
184     delete this.seenPageIDs;
185     Object.defineProperty(this, "seenPageIDs", {
186       get: this.restoreSeenPageIDs.bind(this),
187       configurable: true,
188     });
190     delete this.url;
191     XPCOMUtils.defineLazyGetter(this, "url", function () {
192       return Services.urlFormatter.formatURLPref("browser.uitour.url");
193     });
195     // Clear the availableTargetsCache on widget changes.
196     let listenerMethods = [
197       "onWidgetAdded",
198       "onWidgetMoved",
199       "onWidgetRemoved",
200       "onWidgetReset",
201       "onAreaReset",
202     ];
203     CustomizableUI.addListener(listenerMethods.reduce((listener, method) => {
204       listener[method] = () => this.availableTargetsCache.clear();
205       return listener;
206     }, {}));
207   },
209   restoreSeenPageIDs: function() {
210     delete this.seenPageIDs;
212     if (UITelemetry.enabled) {
213       let dateThreshold = Date.now() - SEENPAGEID_EXPIRY;
215       try {
216         let data = Services.prefs.getCharPref(PREF_SEENPAGEIDS);
217         data = new Map(JSON.parse(data));
219         for (let [pageID, details] of data) {
221           if (typeof pageID != "string" ||
222               typeof details != "object" ||
223               typeof details.lastSeen != "number" ||
224               details.lastSeen < dateThreshold) {
226             data.delete(pageID);
227           }
228         }
230         this.seenPageIDs = data;
231       } catch (e) {}
232     }
234     if (!this.seenPageIDs)
235       this.seenPageIDs = new Map();
237     this.persistSeenIDs();
239     return this.seenPageIDs;
240   },
242   addSeenPageID: function(aPageID) {
243     if (!UITelemetry.enabled)
244       return;
246     this.seenPageIDs.set(aPageID, {
247       lastSeen: Date.now(),
248     });
250     this.persistSeenIDs();
251   },
253   persistSeenIDs: function() {
254     if (this.seenPageIDs.size === 0) {
255       Services.prefs.clearUserPref(PREF_SEENPAGEIDS);
256       return;
257     }
259     Services.prefs.setCharPref(PREF_SEENPAGEIDS,
260                                JSON.stringify([...this.seenPageIDs]));
261   },
263   onPageEvent: function(aEvent) {
264     let contentDocument = null;
265     if (aEvent.target instanceof Ci.nsIDOMHTMLDocument)
266       contentDocument = aEvent.target;
267     else if (aEvent.target instanceof Ci.nsIDOMHTMLElement)
268       contentDocument = aEvent.target.ownerDocument;
269     else
270       return false;
272     // Ignore events if they're not from a trusted origin.
273     if (!this.ensureTrustedOrigin(contentDocument))
274       return false;
276     if (typeof aEvent.detail != "object")
277       return false;
279     let action = aEvent.detail.action;
280     if (typeof action != "string" || !action)
281       return false;
283     let data = aEvent.detail.data;
284     if (typeof data != "object")
285       return false;
287     let window = this.getChromeWindow(contentDocument);
288     // Do this before bailing if there's no tab, so later we can pick up the pieces:
289     window.gBrowser.tabContainer.addEventListener("TabSelect", this);
290     let tab = window.gBrowser._getTabForContentWindow(contentDocument.defaultView);
291     if (!tab) {
292       // This should only happen while detaching a tab:
293       if (this._detachingTab) {
294         this._queuedEvents.push(aEvent);
295         this._pendingDoc = Cu.getWeakReference(contentDocument);
296         return;
297       }
298       Cu.reportError("Discarding tabless UITour event (" + action + ") while not detaching a tab." +
299                      "This shouldn't happen!");
300       return;
301     }
303     switch (action) {
304       case "registerPageID": {
305         // This is only relevant if Telemtry is enabled.
306         if (!UITelemetry.enabled)
307           break;
309         // We don't want to allow BrowserUITelemetry.BUCKET_SEPARATOR in the
310         // pageID, as it could make parsing the telemetry bucket name difficult.
311         if (typeof data.pageID == "string" &&
312             !data.pageID.contains(BrowserUITelemetry.BUCKET_SEPARATOR)) {
313           this.addSeenPageID(data.pageID);
315           // Store tabs and windows separately so we don't need to loop over all
316           // tabs when a window is closed.
317           this.pageIDSourceTabs.set(tab, data.pageID);
318           this.pageIDSourceWindows.set(window, data.pageID);
320           this.setTelemetryBucket(data.pageID);
321         }
322         break;
323       }
325       case "showHighlight": {
326         let targetPromise = this.getTarget(window, data.target);
327         targetPromise.then(target => {
328           if (!target.node) {
329             Cu.reportError("UITour: Target could not be resolved: " + data.target);
330             return;
331           }
332           let effect = undefined;
333           if (this.highlightEffects.indexOf(data.effect) !== -1) {
334             effect = data.effect;
335           }
336           this.showHighlight(target, effect);
337         }).then(null, Cu.reportError);
338         break;
339       }
341       case "hideHighlight": {
342         this.hideHighlight(window);
343         break;
344       }
346       case "showInfo": {
347         let targetPromise = this.getTarget(window, data.target, true);
348         targetPromise.then(target => {
349           if (!target.node) {
350             Cu.reportError("UITour: Target could not be resolved: " + data.target);
351             return;
352           }
354           let iconURL = null;
355           if (typeof data.icon == "string")
356             iconURL = this.resolveURL(contentDocument, data.icon);
358           let buttons = [];
359           if (Array.isArray(data.buttons) && data.buttons.length > 0) {
360             for (let buttonData of data.buttons) {
361               if (typeof buttonData == "object" &&
362                   typeof buttonData.label == "string" &&
363                   typeof buttonData.callbackID == "string") {
364                 let button = {
365                   label: buttonData.label,
366                   callbackID: buttonData.callbackID,
367                 };
369                 if (typeof buttonData.icon == "string")
370                   button.iconURL = this.resolveURL(contentDocument, buttonData.icon);
372                 if (typeof buttonData.style == "string")
373                   button.style = buttonData.style;
375                 buttons.push(button);
377                 if (buttons.length == MAX_BUTTONS)
378                   break;
379               }
380             }
381           }
383           let infoOptions = {};
385           if (typeof data.closeButtonCallbackID == "string")
386             infoOptions.closeButtonCallbackID = data.closeButtonCallbackID;
387           if (typeof data.targetCallbackID == "string")
388             infoOptions.targetCallbackID = data.targetCallbackID;
390           this.showInfo(contentDocument, target, data.title, data.text, iconURL, buttons, infoOptions);
391         }).then(null, Cu.reportError);
392         break;
393       }
395       case "hideInfo": {
396         this.hideInfo(window);
397         break;
398       }
400       case "previewTheme": {
401         this.previewTheme(data.theme);
402         break;
403       }
405       case "resetTheme": {
406         this.resetTheme();
407         break;
408       }
410       case "addPinnedTab": {
411         this.ensurePinnedTab(window, true);
412         break;
413       }
415       case "removePinnedTab": {
416         this.removePinnedTab(window);
417         break;
418       }
420       case "showMenu": {
421         this.showMenu(window, data.name, () => {
422           if (typeof data.showCallbackID == "string")
423             this.sendPageCallback(contentDocument, data.showCallbackID);
424         });
425         break;
426       }
428       case "hideMenu": {
429         this.hideMenu(window, data.name);
430         break;
431       }
433       case "startUrlbarCapture": {
434         if (typeof data.text != "string" || !data.text ||
435             typeof data.url != "string" || !data.url) {
436           return false;
437         }
439         let uri = null;
440         try {
441           uri = Services.io.newURI(data.url, null, null);
442         } catch (e) {
443           return false;
444         }
446         let secman = Services.scriptSecurityManager;
447         let principal = contentDocument.nodePrincipal;
448         let flags = secman.DISALLOW_INHERIT_PRINCIPAL;
449         try {
450           secman.checkLoadURIWithPrincipal(principal, uri, flags);
451         } catch (e) {
452           return false;
453         }
455         this.startUrlbarCapture(window, data.text, data.url);
456         break;
457       }
459       case "endUrlbarCapture": {
460         this.endUrlbarCapture(window);
461         break;
462       }
464       case "getConfiguration": {
465         if (typeof data.configuration != "string") {
466           return false;
467         }
469         this.getConfiguration(contentDocument, data.configuration, data.callbackID);
470         break;
471       }
473       case "showFirefoxAccounts": {
474         // 'signup' is the only action that makes sense currently, so we don't
475         // accept arbitrary actions just to be safe...
476         // We want to replace the current tab.
477         contentDocument.location.href = "about:accounts?action=signup";
478         break;
479       }
481       case "addNavBarWidget": {
482         // Add a widget to the toolbar
483         let targetPromise = this.getTarget(window, data.name);
484         targetPromise.then(target => {
485           this.addNavBarWidget(target, contentDocument, data.callbackID);
486         }).then(null, Cu.reportError);
487         break;
488       }
490       case "setDefaultSearchEngine": {
491         let enginePromise = this.selectSearchEngine(data.identifier);
492         enginePromise.catch(Cu.reportError);
493         break;
494       }
496       case "setTreatmentTag": {
497         let name = data.name;
498         let value = data.value;
499         let string = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString);
500         string.data = value;
501         Services.prefs.setComplexValue("browser.uitour.treatment." + name,
502                                        Ci.nsISupportsString, string);
503         UITourHealthReport.recordTreatmentTag(name, value);
504         break;
505       }
507       case "getTreatmentTag": {
508         let name = data.name;
509         let value;
510         try {
511           value = Services.prefs.getComplexValue("browser.uitour.treatment." + name,
512                                                  Ci.nsISupportsString).data;
513         } catch (ex) {}
514         this.sendPageCallback(contentDocument, data.callbackID, { value: value });
515         break;
516       }
518       case "setSearchTerm": {
519         let targetPromise = this.getTarget(window, "search");
520         targetPromise.then(target => {
521           let searchbar = target.node;
522           searchbar.value = data.term;
523           searchbar.inputChanged();
524         }).then(null, Cu.reportError);
525         break;
526       }
528       case "openSearchPanel": {
529         let targetPromise = this.getTarget(window, "search");
530         targetPromise.then(target => {
531           let searchbar = target.node;
533           if (searchbar.textbox.open) {
534             this.sendPageCallback(contentDocument, data.callbackID);
535           } else {
536             let onPopupShown = () => {
537               searchbar.textbox.popup.removeEventListener("popupshown", onPopupShown);
538               this.sendPageCallback(contentDocument, data.callbackID);
539             };
541             searchbar.textbox.popup.addEventListener("popupshown", onPopupShown);
542             searchbar.openSuggestionsPanel();
543           }
544         }).then(null, Cu.reportError);
545         break;
546       }
547     }
549     if (!this.originTabs.has(window))
550       this.originTabs.set(window, new Set());
552     this.originTabs.get(window).add(tab);
553     tab.addEventListener("TabClose", this);
554     tab.addEventListener("TabBecomingWindow", this);
555     window.addEventListener("SSWindowClosing", this);
557     return true;
558   },
560   handleEvent: function(aEvent) {
561     switch (aEvent.type) {
562       case "pagehide": {
563         let window = this.getChromeWindow(aEvent.target);
564         this.teardownTour(window);
565         break;
566       }
568       case "TabBecomingWindow":
569         this._detachingTab = true;
570         // Fall through
571       case "TabClose": {
572         let tab = aEvent.target;
573         if (this.pageIDSourceTabs.has(tab)) {
574           let pageID = this.pageIDSourceTabs.get(tab);
576           // Delete this from the window cache, so if the window is closed we
577           // don't expire this page ID twice.
578           let window = tab.ownerDocument.defaultView;
579           if (this.pageIDSourceWindows.get(window) == pageID)
580             this.pageIDSourceWindows.delete(window);
582           this.setExpiringTelemetryBucket(pageID, "closed");
583         }
585         let window = tab.ownerDocument.defaultView;
586         this.teardownTour(window);
587         break;
588       }
590       case "TabSelect": {
591         if (aEvent.detail && aEvent.detail.previousTab) {
592           let previousTab = aEvent.detail.previousTab;
594           if (this.pageIDSourceTabs.has(previousTab)) {
595             let pageID = this.pageIDSourceTabs.get(previousTab);
596             this.setExpiringTelemetryBucket(pageID, "inactive");
597           }
598         }
600         let window = aEvent.target.ownerDocument.defaultView;
601         let selectedTab = window.gBrowser.selectedTab;
602         let pinnedTab = this.pinnedTabs.get(window);
603         if (pinnedTab && pinnedTab.tab == selectedTab)
604           break;
605         let originTabs = this.originTabs.get(window);
606         if (originTabs && originTabs.has(selectedTab))
607           break;
609         let pendingDoc;
610         if (this._detachingTab && this._pendingDoc && (pendingDoc = this._pendingDoc.get())) {
611           if (selectedTab.linkedBrowser.contentDocument == pendingDoc) {
612             if (!this.originTabs.get(window)) {
613               this.originTabs.set(window, new Set());
614             }
615             this.originTabs.get(window).add(selectedTab);
616             this.pendingDoc = null;
617             this._detachingTab = false;
618             while (this._queuedEvents.length) {
619               try {
620                 this.onPageEvent(this._queuedEvents.shift());
621               } catch (ex) {
622                 Cu.reportError(ex);
623               }
624             }
625             break;
626           }
627         }
629         this.teardownTour(window);
630         break;
631       }
633       case "SSWindowClosing": {
634         let window = aEvent.target;
635         if (this.pageIDSourceWindows.has(window)) {
636           let pageID = this.pageIDSourceWindows.get(window);
637           this.setExpiringTelemetryBucket(pageID, "closed");
638         }
640         this.teardownTour(window, true);
641         break;
642       }
644       case "input": {
645         if (aEvent.target.id == "urlbar") {
646           let window = aEvent.target.ownerDocument.defaultView;
647           this.handleUrlbarInput(window);
648         }
649         break;
650       }
651     }
652   },
654   setTelemetryBucket: function(aPageID) {
655     let bucket = BUCKET_NAME + BrowserUITelemetry.BUCKET_SEPARATOR + aPageID;
656     BrowserUITelemetry.setBucket(bucket);
657   },
659   setExpiringTelemetryBucket: function(aPageID, aType) {
660     let bucket = BUCKET_NAME + BrowserUITelemetry.BUCKET_SEPARATOR + aPageID +
661                  BrowserUITelemetry.BUCKET_SEPARATOR + aType;
663     BrowserUITelemetry.setExpiringBucket(bucket,
664                                          BUCKET_TIMESTEPS);
665   },
667   // This is registered with UITelemetry by BrowserUITelemetry, so that UITour
668   // can remain lazy-loaded on-demand.
669   getTelemetry: function() {
670     return {
671       seenPageIDs: [...this.seenPageIDs.keys()],
672     };
673   },
675   teardownTour: function(aWindow, aWindowClosing = false) {
676     aWindow.gBrowser.tabContainer.removeEventListener("TabSelect", this);
677     aWindow.PanelUI.panel.removeEventListener("popuphiding", this.hidePanelAnnotations);
678     aWindow.PanelUI.panel.removeEventListener("ViewShowing", this.hidePanelAnnotations);
679     aWindow.removeEventListener("SSWindowClosing", this);
681     let originTabs = this.originTabs.get(aWindow);
682     if (originTabs) {
683       for (let tab of originTabs) {
684         tab.removeEventListener("TabClose", this);
685         tab.removeEventListener("TabBecomingWindow", this);
686       }
687     }
688     this.originTabs.delete(aWindow);
690     if (!aWindowClosing) {
691       this.hideHighlight(aWindow);
692       this.hideInfo(aWindow);
693       // Ensure the menu panel is hidden before calling recreatePopup so popup events occur.
694       this.hideMenu(aWindow, "appMenu");
695     }
697     this.endUrlbarCapture(aWindow);
698     this.removePinnedTab(aWindow);
699     this.resetTheme();
700   },
702   getChromeWindow: function(aContentDocument) {
703     return aContentDocument.defaultView
704                            .window
705                            .QueryInterface(Ci.nsIInterfaceRequestor)
706                            .getInterface(Ci.nsIWebNavigation)
707                            .QueryInterface(Ci.nsIDocShellTreeItem)
708                            .rootTreeItem
709                            .QueryInterface(Ci.nsIInterfaceRequestor)
710                            .getInterface(Ci.nsIDOMWindow)
711                            .wrappedJSObject;
712   },
714   importPermissions: function() {
715     try {
716       PermissionsUtils.importFromPrefs(PREF_PERM_BRANCH, UITOUR_PERMISSION);
717     } catch (e) {
718       Cu.reportError(e);
719     }
720   },
722   ensureTrustedOrigin: function(aDocument) {
723     if (aDocument.defaultView.top != aDocument.defaultView)
724       return false;
726     let uri = aDocument.documentURIObject;
728     if (uri.schemeIs("chrome"))
729       return true;
731     if (!this.isSafeScheme(uri))
732       return false;
734     this.importPermissions();
735     let permission = Services.perms.testPermission(uri, UITOUR_PERMISSION);
736     return permission == Services.perms.ALLOW_ACTION;
737   },
739   isSafeScheme: function(aURI) {
740     let allowedSchemes = new Set(["https", "about"]);
741     if (!Services.prefs.getBoolPref("browser.uitour.requireSecure"))
742       allowedSchemes.add("http");
744     if (!allowedSchemes.has(aURI.scheme))
745       return false;
747     return true;
748   },
750   resolveURL: function(aDocument, aURL) {
751     try {
752       let uri = Services.io.newURI(aURL, null, aDocument.documentURIObject);
754       if (!this.isSafeScheme(uri))
755         return null;
757       return uri.spec;
758     } catch (e) {}
760     return null;
761   },
763   sendPageCallback: function(aDocument, aCallbackID, aData = {}) {
765     let detail = {data: aData, callbackID: aCallbackID};
766     detail = Cu.cloneInto(detail, aDocument.defaultView);
767     let event = new aDocument.defaultView.CustomEvent("mozUITourResponse", {
768       bubbles: true,
769       detail: detail
770     });
772     aDocument.dispatchEvent(event);
773   },
775   isElementVisible: function(aElement) {
776     let targetStyle = aElement.ownerDocument.defaultView.getComputedStyle(aElement);
777     return (targetStyle.display != "none" && targetStyle.visibility == "visible");
778   },
780   getTarget: function(aWindow, aTargetName, aSticky = false) {
781     let deferred = Promise.defer();
782     if (typeof aTargetName != "string" || !aTargetName) {
783       deferred.reject("Invalid target name specified");
784       return deferred.promise;
785     }
787     if (aTargetName == "pinnedTab") {
788       deferred.resolve({
789           targetName: aTargetName,
790           node: this.ensurePinnedTab(aWindow, aSticky)
791       });
792       return deferred.promise;
793     }
795     if (aTargetName.startsWith(TARGET_SEARCHENGINE_PREFIX)) {
796       let engineID = aTargetName.slice(TARGET_SEARCHENGINE_PREFIX.length);
797       return this.getSearchEngineTarget(aWindow, engineID);
798     }
800     let targetObject = this.targets.get(aTargetName);
801     if (!targetObject) {
802       deferred.reject("The specified target name is not in the allowed set");
803       return deferred.promise;
804     }
806     let targetQuery = targetObject.query;
807     aWindow.PanelUI.ensureReady().then(() => {
808       let node;
809       if (typeof targetQuery == "function") {
810         try {
811           node = targetQuery(aWindow.document);
812         } catch (ex) {
813           node = null;
814         }
815       } else {
816         node = aWindow.document.querySelector(targetQuery);
817       }
819       deferred.resolve({
820         addTargetListener: targetObject.addTargetListener,
821         node: node,
822         removeTargetListener: targetObject.removeTargetListener,
823         targetName: aTargetName,
824         widgetName: targetObject.widgetName,
825         allowAdd: targetObject.allowAdd,
826       });
827     }).then(null, Cu.reportError);
828     return deferred.promise;
829   },
831   targetIsInAppMenu: function(aTarget) {
832     let placement = CustomizableUI.getPlacementOfWidget(aTarget.widgetName || aTarget.node.id);
833     if (placement && placement.area == CustomizableUI.AREA_PANEL) {
834       return true;
835     }
837     let targetElement = aTarget.node;
838     // Use the widget for filtering if it exists since the target may be the icon inside.
839     if (aTarget.widgetName) {
840       targetElement = aTarget.node.ownerDocument.getElementById(aTarget.widgetName);
841     }
843     // Handle the non-customizable buttons at the bottom of the menu which aren't proper widgets.
844     return targetElement.id.startsWith("PanelUI-")
845              && targetElement.id != "PanelUI-button";
846   },
848   /**
849    * Called before opening or after closing a highlight or info panel to see if
850    * we need to open or close the appMenu to see the annotation's anchor.
851    */
852   _setAppMenuStateForAnnotation: function(aWindow, aAnnotationType, aShouldOpenForHighlight, aCallback = null) {
853     // If the panel is in the desired state, we're done.
854     let panelIsOpen = aWindow.PanelUI.panel.state != "closed";
855     if (aShouldOpenForHighlight == panelIsOpen) {
856       if (aCallback)
857         aCallback();
858       return;
859     }
861     // Don't close the menu if it wasn't opened by us (e.g. via showmenu instead).
862     if (!aShouldOpenForHighlight && !this.appMenuOpenForAnnotation.has(aAnnotationType)) {
863       if (aCallback)
864         aCallback();
865       return;
866     }
868     if (aShouldOpenForHighlight) {
869       this.appMenuOpenForAnnotation.add(aAnnotationType);
870     } else {
871       this.appMenuOpenForAnnotation.delete(aAnnotationType);
872     }
874     // Actually show or hide the menu
875     if (this.appMenuOpenForAnnotation.size) {
876       this.showMenu(aWindow, "appMenu", aCallback);
877     } else {
878       this.hideMenu(aWindow, "appMenu");
879       if (aCallback)
880         aCallback();
881     }
883   },
885   previewTheme: function(aTheme) {
886     let origin = Services.prefs.getCharPref("browser.uitour.themeOrigin");
887     let data = LightweightThemeManager.parseTheme(aTheme, origin);
888     if (data)
889       LightweightThemeManager.previewTheme(data);
890   },
892   resetTheme: function() {
893     LightweightThemeManager.resetPreview();
894   },
896   ensurePinnedTab: function(aWindow, aSticky = false) {
897     let tabInfo = this.pinnedTabs.get(aWindow);
899     if (tabInfo) {
900       tabInfo.sticky = tabInfo.sticky || aSticky;
901     } else {
902       let url = Services.urlFormatter.formatURLPref("browser.uitour.pinnedTabUrl");
904       let tab = aWindow.gBrowser.addTab(url);
905       aWindow.gBrowser.pinTab(tab);
906       tab.addEventListener("TabClose", () => {
907         this.pinnedTabs.delete(aWindow);
908       });
910       tabInfo = {
911         tab: tab,
912         sticky: aSticky
913       };
914       this.pinnedTabs.set(aWindow, tabInfo);
915     }
917     return tabInfo.tab;
918   },
920   removePinnedTab: function(aWindow) {
921     let tabInfo = this.pinnedTabs.get(aWindow);
922     if (tabInfo)
923       aWindow.gBrowser.removeTab(tabInfo.tab);
924   },
926   /**
927    * @param aTarget    The element to highlight.
928    * @param aEffect    (optional) The effect to use from UITour.highlightEffects or "none".
929    * @see UITour.highlightEffects
930    */
931   showHighlight: function(aTarget, aEffect = "none") {
932     let window = aTarget.node.ownerDocument.defaultView;
934     function showHighlightPanel() {
935       if (aTarget.targetName.startsWith(TARGET_SEARCHENGINE_PREFIX)) {
936         // This won't affect normal higlights done via the panel, so we need to
937         // manually hide those.
938         this.hideHighlight(window);
939         aTarget.node.setAttribute("_moz-menuactive", true);
940         return;
941       }
943       // Conversely, highlights for search engines are highlighted via CSS
944       // rather than a panel, so need to be manually removed.
945       this._hideSearchEngineHighlight(window);
947       let highlighter = aTarget.node.ownerDocument.getElementById("UITourHighlight");
949       let effect = aEffect;
950       if (effect == "random") {
951         // Exclude "random" from the randomly selected effects.
952         let randomEffect = 1 + Math.floor(Math.random() * (this.highlightEffects.length - 1));
953         if (randomEffect == this.highlightEffects.length)
954           randomEffect--; // On the order of 1 in 2^62 chance of this happening.
955         effect = this.highlightEffects[randomEffect];
956       }
957       // Toggle the effect attribute to "none" and flush layout before setting it so the effect plays.
958       highlighter.setAttribute("active", "none");
959       aTarget.node.ownerDocument.defaultView.getComputedStyle(highlighter).animationName;
960       highlighter.setAttribute("active", effect);
961       highlighter.parentElement.setAttribute("targetName", aTarget.targetName);
962       highlighter.parentElement.hidden = false;
964       let highlightAnchor;
965       // If the target is in the overflow panel, just highlight the overflow button.
966       if (aTarget.node.getAttribute("overflowedItem")) {
967         let doc = aTarget.node.ownerDocument;
968         let placement = CustomizableUI.getPlacementOfWidget(aTarget.widgetName || aTarget.node.id);
969         let areaNode = doc.getElementById(placement.area);
970         highlightAnchor = areaNode.overflowable._chevron;
971       } else {
972         highlightAnchor = aTarget.node;
973       }
974       let targetRect = highlightAnchor.getBoundingClientRect();
975       let highlightHeight = targetRect.height;
976       let highlightWidth = targetRect.width;
977       let minDimension = Math.min(highlightHeight, highlightWidth);
978       let maxDimension = Math.max(highlightHeight, highlightWidth);
980       // If the dimensions are within 200% of each other (to include the bookmarks button),
981       // make the highlight a circle with the largest dimension as the diameter.
982       if (maxDimension / minDimension <= 3.0) {
983         highlightHeight = highlightWidth = maxDimension;
984         highlighter.style.borderRadius = "100%";
985       } else {
986         highlighter.style.borderRadius = "";
987       }
989       highlighter.style.height = highlightHeight + "px";
990       highlighter.style.width = highlightWidth + "px";
992       // Close a previous highlight so we can relocate the panel.
993       if (highlighter.parentElement.state == "showing" || highlighter.parentElement.state == "open") {
994         highlighter.parentElement.hidePopup();
995       }
996       /* The "overlap" position anchors from the top-left but we want to centre highlights at their
997          minimum size. */
998       let highlightWindow = aTarget.node.ownerDocument.defaultView;
999       let containerStyle = highlightWindow.getComputedStyle(highlighter.parentElement);
1000       let paddingTopPx = 0 - parseFloat(containerStyle.paddingTop);
1001       let paddingLeftPx = 0 - parseFloat(containerStyle.paddingLeft);
1002       let highlightStyle = highlightWindow.getComputedStyle(highlighter);
1003       let highlightHeightWithMin = Math.max(highlightHeight, parseFloat(highlightStyle.minHeight));
1004       let highlightWidthWithMin = Math.max(highlightWidth, parseFloat(highlightStyle.minWidth));
1005       let offsetX = paddingTopPx
1006                       - (Math.max(0, highlightWidthWithMin - targetRect.width) / 2);
1007       let offsetY = paddingLeftPx
1008                       - (Math.max(0, highlightHeightWithMin - targetRect.height) / 2);
1009       this._addAnnotationPanelMutationObserver(highlighter.parentElement);
1010       highlighter.parentElement.openPopup(highlightAnchor, "overlap", offsetX, offsetY);
1011     }
1013     // Prevent showing a panel at an undefined position.
1014     if (!this.isElementVisible(aTarget.node))
1015       return;
1017     this._setAppMenuStateForAnnotation(aTarget.node.ownerDocument.defaultView, "highlight",
1018                                        this.targetIsInAppMenu(aTarget),
1019                                        showHighlightPanel.bind(this));
1020   },
1022   hideHighlight: function(aWindow) {
1023     let tabData = this.pinnedTabs.get(aWindow);
1024     if (tabData && !tabData.sticky)
1025       this.removePinnedTab(aWindow);
1027     let highlighter = aWindow.document.getElementById("UITourHighlight");
1028     this._removeAnnotationPanelMutationObserver(highlighter.parentElement);
1029     highlighter.parentElement.hidePopup();
1030     highlighter.removeAttribute("active");
1032     this._setAppMenuStateForAnnotation(aWindow, "highlight", false);
1033     this._hideSearchEngineHighlight(aWindow);
1034   },
1036   _hideSearchEngineHighlight: function(aWindow) {
1037     // We special case highlighting items in the search engines dropdown,
1038     // so just blindly remove any highlight there.
1039     let searchMenuBtn = null;
1040     try {
1041       searchMenuBtn = this.targets.get("searchProvider").query(aWindow.document);
1042     } catch (e) { /* This is ok to fail. */ }
1043     if (searchMenuBtn) {
1044       let searchPopup = aWindow.document
1045                                .getAnonymousElementByAttribute(searchMenuBtn,
1046                                                                "anonid",
1047                                                                "searchbar-popup");
1048       for (let menuItem of searchPopup.children)
1049         menuItem.removeAttribute("_moz-menuactive");
1050     }
1051   },
1053   /**
1054    * Show an info panel.
1055    *
1056    * @param {Document} aContentDocument
1057    * @param {Node}     aAnchor
1058    * @param {String}   [aTitle=""]
1059    * @param {String}   [aDescription=""]
1060    * @param {String}   [aIconURL=""]
1061    * @param {Object[]} [aButtons=[]]
1062    * @param {Object}   [aOptions={}]
1063    * @param {String}   [aOptions.closeButtonCallbackID]
1064    */
1065   showInfo: function(aContentDocument, aAnchor, aTitle = "", aDescription = "", aIconURL = "",
1066                      aButtons = [], aOptions = {}) {
1067     function showInfoPanel(aAnchorEl) {
1068       aAnchorEl.focus();
1070       let document = aAnchorEl.ownerDocument;
1071       let tooltip = document.getElementById("UITourTooltip");
1072       let tooltipTitle = document.getElementById("UITourTooltipTitle");
1073       let tooltipDesc = document.getElementById("UITourTooltipDescription");
1074       let tooltipIcon = document.getElementById("UITourTooltipIcon");
1075       let tooltipButtons = document.getElementById("UITourTooltipButtons");
1077       if (tooltip.state == "showing" || tooltip.state == "open") {
1078         tooltip.hidePopup();
1079       }
1081       tooltipTitle.textContent = aTitle || "";
1082       tooltipDesc.textContent = aDescription || "";
1083       tooltipIcon.src = aIconURL || "";
1084       tooltipIcon.hidden = !aIconURL;
1086       while (tooltipButtons.firstChild)
1087         tooltipButtons.firstChild.remove();
1089       for (let button of aButtons) {
1090         let el = document.createElement("button");
1091         el.setAttribute("label", button.label);
1092         if (button.iconURL)
1093           el.setAttribute("image", button.iconURL);
1095         if (button.style == "link")
1096           el.setAttribute("class", "button-link");
1098         if (button.style == "primary")
1099           el.setAttribute("class", "button-primary");
1101         let callbackID = button.callbackID;
1102         el.addEventListener("command", event => {
1103           tooltip.hidePopup();
1104           this.sendPageCallback(aContentDocument, callbackID);
1105         });
1107         tooltipButtons.appendChild(el);
1108       }
1110       tooltipButtons.hidden = !aButtons.length;
1112       let tooltipClose = document.getElementById("UITourTooltipClose");
1113       let closeButtonCallback = (event) => {
1114         this.hideInfo(document.defaultView);
1115         if (aOptions && aOptions.closeButtonCallbackID)
1116           this.sendPageCallback(aContentDocument, aOptions.closeButtonCallbackID);
1117       };
1118       tooltipClose.addEventListener("command", closeButtonCallback);
1120       let targetCallback = (event) => {
1121         let details = {
1122           target: aAnchor.targetName,
1123           type: event.type,
1124         };
1125         this.sendPageCallback(aContentDocument, aOptions.targetCallbackID, details);
1126       };
1127       if (aOptions.targetCallbackID && aAnchor.addTargetListener) {
1128         aAnchor.addTargetListener(document, targetCallback);
1129       }
1131       tooltip.addEventListener("popuphiding", function tooltipHiding(event) {
1132         tooltip.removeEventListener("popuphiding", tooltipHiding);
1133         tooltipClose.removeEventListener("command", closeButtonCallback);
1134         if (aOptions.targetCallbackID && aAnchor.removeTargetListener) {
1135           aAnchor.removeTargetListener(document, targetCallback);
1136         }
1137       });
1139       tooltip.setAttribute("targetName", aAnchor.targetName);
1140       tooltip.hidden = false;
1141       let xOffset = 0, yOffset = 0;
1142       let alignment = "bottomcenter topright";
1143       if (aAnchor.targetName == "search") {
1144         alignment = "after_start";
1145         xOffset = 18;
1146       }
1147       this._addAnnotationPanelMutationObserver(tooltip);
1148       tooltip.openPopup(aAnchorEl, alignment, xOffset, yOffset);
1149     }
1151     // Prevent showing a panel at an undefined position.
1152     if (!this.isElementVisible(aAnchor.node))
1153       return;
1155     // Due to a platform limitation, we can't anchor a panel to an element in a
1156     // <menupopup>. So we can't support showing info panels for search engines.
1157     if (aAnchor.targetName.startsWith(TARGET_SEARCHENGINE_PREFIX))
1158       return;
1160     this._setAppMenuStateForAnnotation(aAnchor.node.ownerDocument.defaultView, "info",
1161                                        this.targetIsInAppMenu(aAnchor),
1162                                        showInfoPanel.bind(this, aAnchor.node));
1163   },
1165   hideInfo: function(aWindow) {
1166     let document = aWindow.document;
1168     let tooltip = document.getElementById("UITourTooltip");
1169     this._removeAnnotationPanelMutationObserver(tooltip);
1170     tooltip.hidePopup();
1171     this._setAppMenuStateForAnnotation(aWindow, "info", false);
1173     let tooltipButtons = document.getElementById("UITourTooltipButtons");
1174     while (tooltipButtons.firstChild)
1175       tooltipButtons.firstChild.remove();
1176   },
1178   showMenu: function(aWindow, aMenuName, aOpenCallback = null) {
1179     function openMenuButton(aMenuBtn) {
1180       if (!aMenuBtn || !aMenuBtn.boxObject || aMenuBtn.open) {
1181         if (aOpenCallback)
1182           aOpenCallback();
1183         return;
1184       }
1185       if (aOpenCallback)
1186         aMenuBtn.addEventListener("popupshown", onPopupShown);
1187       aMenuBtn.boxObject.QueryInterface(Ci.nsIMenuBoxObject).openMenu(true);
1188     }
1189     function onPopupShown(event) {
1190       this.removeEventListener("popupshown", onPopupShown);
1191       aOpenCallback(event);
1192     }
1194     if (aMenuName == "appMenu") {
1195       aWindow.PanelUI.panel.setAttribute("noautohide", "true");
1196       // If the popup is already opened, don't recreate the widget as it may cause a flicker.
1197       if (aWindow.PanelUI.panel.state != "open") {
1198         this.recreatePopup(aWindow.PanelUI.panel);
1199       }
1200       aWindow.PanelUI.panel.addEventListener("popuphiding", this.hidePanelAnnotations);
1201       aWindow.PanelUI.panel.addEventListener("ViewShowing", this.hidePanelAnnotations);
1202       if (aOpenCallback) {
1203         aWindow.PanelUI.panel.addEventListener("popupshown", onPopupShown);
1204       }
1205       aWindow.PanelUI.show();
1206     } else if (aMenuName == "bookmarks") {
1207       let menuBtn = aWindow.document.getElementById("bookmarks-menu-button");
1208       openMenuButton(menuBtn);
1209     } else if (aMenuName == "searchEngines") {
1210       this.getTarget(aWindow, "searchProvider").then(target => {
1211         openMenuButton(target.node);
1212       }).catch(Cu.reportError);
1213     }
1214   },
1216   hideMenu: function(aWindow, aMenuName) {
1217     function closeMenuButton(aMenuBtn) {
1218       if (aMenuBtn && aMenuBtn.boxObject)
1219         aMenuBtn.boxObject.QueryInterface(Ci.nsIMenuBoxObject).openMenu(false);
1220     }
1222     if (aMenuName == "appMenu") {
1223       aWindow.PanelUI.panel.removeAttribute("noautohide");
1224       aWindow.PanelUI.hide();
1225       this.recreatePopup(aWindow.PanelUI.panel);
1226     } else if (aMenuName == "bookmarks") {
1227       let menuBtn = aWindow.document.getElementById("bookmarks-menu-button");
1228       closeMenuButton(menuBtn);
1229     } else if (aMenuName == "searchEngines") {
1230       let menuBtn = this.targets.get("searchProvider").query(aWindow.document);
1231       closeMenuButton(menuBtn);
1232     }
1233   },
1235   hidePanelAnnotations: function(aEvent) {
1236     let win = aEvent.target.ownerDocument.defaultView;
1237     let annotationElements = new Map([
1238       // [annotationElement (panel), method to hide the annotation]
1239       [win.document.getElementById("UITourHighlightContainer"), UITour.hideHighlight.bind(UITour)],
1240       [win.document.getElementById("UITourTooltip"), UITour.hideInfo.bind(UITour)],
1241     ]);
1242     annotationElements.forEach((hideMethod, annotationElement) => {
1243       if (annotationElement.state != "closed") {
1244         let targetName = annotationElement.getAttribute("targetName");
1245         UITour.getTarget(win, targetName).then((aTarget) => {
1246           // Since getTarget is async, we need to make sure that the target hasn't
1247           // changed since it may have just moved to somewhere outside of the app menu.
1248           if (annotationElement.getAttribute("targetName") != aTarget.targetName ||
1249               annotationElement.state == "closed" ||
1250               !UITour.targetIsInAppMenu(aTarget)) {
1251             return;
1252           }
1253           hideMethod(win);
1254         }).then(null, Cu.reportError);
1255       }
1256     });
1257     UITour.appMenuOpenForAnnotation.clear();
1258   },
1260   recreatePopup: function(aPanel) {
1261     // After changing popup attributes that relate to how the native widget is created
1262     // (e.g. @noautohide) we need to re-create the frame/widget for it to take effect.
1263     if (aPanel.hidden) {
1264       // If the panel is already hidden, we don't need to recreate it but flush
1265       // in case someone just hid it.
1266       aPanel.clientWidth; // flush
1267       return;
1268     }
1269     aPanel.hidden = true;
1270     aPanel.clientWidth; // flush
1271     aPanel.hidden = false;
1272   },
1274   startUrlbarCapture: function(aWindow, aExpectedText, aUrl) {
1275     let urlbar = aWindow.document.getElementById("urlbar");
1276     this.urlbarCapture.set(aWindow, {
1277       expected: aExpectedText.toLocaleLowerCase(),
1278       url: aUrl
1279     });
1280     urlbar.addEventListener("input", this);
1281   },
1283   endUrlbarCapture: function(aWindow) {
1284     let urlbar = aWindow.document.getElementById("urlbar");
1285     urlbar.removeEventListener("input", this);
1286     this.urlbarCapture.delete(aWindow);
1287   },
1289   handleUrlbarInput: function(aWindow) {
1290     if (!this.urlbarCapture.has(aWindow))
1291       return;
1293     let urlbar = aWindow.document.getElementById("urlbar");
1295     let {expected, url} = this.urlbarCapture.get(aWindow);
1297     if (urlbar.value.toLocaleLowerCase().localeCompare(expected) != 0)
1298       return;
1300     urlbar.handleRevert();
1302     let tab = aWindow.gBrowser.addTab(url, {
1303       owner: aWindow.gBrowser.selectedTab,
1304       relatedToCurrent: true
1305     });
1306     aWindow.gBrowser.selectedTab = tab;
1307   },
1309   getConfiguration: function(aContentDocument, aConfiguration, aCallbackID) {
1310     switch (aConfiguration) {
1311       case "availableTargets":
1312         this.getAvailableTargets(aContentDocument, aCallbackID);
1313         break;
1314       case "sync":
1315         this.sendPageCallback(aContentDocument, aCallbackID, {
1316           setup: Services.prefs.prefHasUserValue("services.sync.username"),
1317         });
1318         break;
1319       case "selectedSearchEngine":
1320         Services.search.init(rv => {
1321           let engine;
1322           if (Components.isSuccessCode(rv)) {
1323             engine = Services.search.defaultEngine;
1324           } else {
1325             engine = { identifier: "" };
1326           }
1327           this.sendPageCallback(aContentDocument, aCallbackID, {
1328             searchEngineIdentifier: engine.identifier
1329           });
1330         });
1331         break;
1332       default:
1333         Cu.reportError("getConfiguration: Unknown configuration requested: " + aConfiguration);
1334         break;
1335     }
1336   },
1338   getAvailableTargets: function(aContentDocument, aCallbackID) {
1339     Task.spawn(function*() {
1340       let window = this.getChromeWindow(aContentDocument);
1341       let data = this.availableTargetsCache.get(window);
1342       if (data) {
1343         this.sendPageCallback(aContentDocument, aCallbackID, data);
1344         return;
1345       }
1347       let promises = [];
1348       for (let targetName of this.targets.keys()) {
1349         promises.push(this.getTarget(window, targetName));
1350       }
1351       let targetObjects = yield Promise.all(promises);
1353       let targetNames = [
1354         "pinnedTab",
1355       ];
1357       for (let targetObject of targetObjects) {
1358         if (targetObject.node)
1359           targetNames.push(targetObject.targetName);
1360       }
1362       targetNames = targetNames.concat(
1363         yield this.getAvailableSearchEngineTargets(window)
1364       );
1366       data = {
1367         targets: targetNames,
1368       };
1369       this.availableTargetsCache.set(window, data);
1370       this.sendPageCallback(aContentDocument, aCallbackID, data);
1371     }.bind(this)).catch(err => {
1372       Cu.reportError(err);
1373       this.sendPageCallback(aContentDocument, aCallbackID, {
1374         targets: [],
1375       });
1376     });
1377   },
1379   addNavBarWidget: function (aTarget, aContentDocument, aCallbackID) {
1380     if (aTarget.node) {
1381       Cu.reportError("UITour: can't add a widget already present: " + data.target);
1382       return;
1383     }
1384     if (!aTarget.allowAdd) {
1385       Cu.reportError("UITour: not allowed to add this widget: " + data.target);
1386       return;
1387     }
1388     if (!aTarget.widgetName) {
1389       Cu.reportError("UITour: can't add a widget without a widgetName property: " + data.target);
1390       return;
1391     }
1393     CustomizableUI.addWidgetToArea(aTarget.widgetName, CustomizableUI.AREA_NAVBAR);
1394     this.sendPageCallback(aContentDocument, aCallbackID);
1395   },
1397   _addAnnotationPanelMutationObserver: function(aPanelEl) {
1398 #ifdef XP_LINUX
1399     let observer = this._annotationPanelMutationObservers.get(aPanelEl);
1400     if (observer) {
1401       return;
1402     }
1403     let win = aPanelEl.ownerDocument.defaultView;
1404     observer = new win.MutationObserver(this._annotationMutationCallback);
1405     this._annotationPanelMutationObservers.set(aPanelEl, observer);
1406     let observerOptions = {
1407       attributeFilter: ["height", "width"],
1408       attributes: true,
1409     };
1410     observer.observe(aPanelEl, observerOptions);
1411 #endif
1412   },
1414   _removeAnnotationPanelMutationObserver: function(aPanelEl) {
1415 #ifdef XP_LINUX
1416     let observer = this._annotationPanelMutationObservers.get(aPanelEl);
1417     if (observer) {
1418       observer.disconnect();
1419       this._annotationPanelMutationObservers.delete(aPanelEl);
1420     }
1421 #endif
1422   },
1425  * Workaround for Ubuntu panel craziness in bug 970788 where incorrect sizes get passed to
1426  * nsXULPopupManager::PopupResized and lead to incorrect width and height attributes getting
1427  * set on the panel.
1428  */
1429   _annotationMutationCallback: function(aMutations) {
1430     for (let mutation of aMutations) {
1431       // Remove both attributes at once and ignore remaining mutations to be proccessed.
1432       mutation.target.removeAttribute("width");
1433       mutation.target.removeAttribute("height");
1434       return;
1435     }
1436   },
1438   selectSearchEngine(aID) {
1439     return new Promise((resolve, reject) => {
1440       Services.search.init((rv) => {
1441         if (!Components.isSuccessCode(rv)) {
1442           reject("selectSearchEngine: search service init failed: " + rv);
1443           return;
1444         }
1446         let engines = Services.search.getVisibleEngines();
1447         for (let engine of engines) {
1448           if (engine.identifier == aID) {
1449             Services.search.defaultEngine = engine;
1450             return resolve();
1451           }
1452         }
1453         reject("selectSearchEngine could not find engine with given ID");
1454       });
1455     });
1456   },
1458   getAvailableSearchEngineTargets(aWindow) {
1459     return new Promise(resolve => {
1460       this.getTarget(aWindow, "search").then(searchTarget => {
1461         if (!searchTarget.node || this.targetIsInAppMenu(searchTarget))
1462           return resolve([]);
1464         Services.search.init(() => {
1465           let engines = Services.search.getVisibleEngines();
1466           resolve([TARGET_SEARCHENGINE_PREFIX + engine.identifier
1467                    for (engine of engines)
1468                    if (engine.identifier)]);
1469         });
1470       }).catch(() => resolve([]));
1471     });
1472   },
1474   // We only allow matching based on a search engine's identifier - this gives
1475   // us a non-changing ID and guarentees we only match against app-provided
1476   // engines.
1477   getSearchEngineTarget(aWindow, aIdentifier) {
1478     return new Promise((resolve, reject) => {
1479       Task.spawn(function*() {
1480         let searchTarget = yield this.getTarget(aWindow, "search");
1481         // We're not supporting having the searchbar in the app-menu, because
1482         // popups within popups gets crazy. This restriction should be lifted
1483         // once bug 988151 is implemented, as the page can then be responsible
1484         // for opening each menu when appropriate.
1485         if (!searchTarget.node || this.targetIsInAppMenu(searchTarget))
1486           return reject("Search engine not available");
1488         yield Services.search.init();
1490         let searchPopup = searchTarget.node._popup;
1491         for (let engineNode of searchPopup.children) {
1492           let engine = engineNode.engine;
1493           if (engine && engine.identifier == aIdentifier) {
1494             return resolve({
1495               targetName: TARGET_SEARCHENGINE_PREFIX + engine.identifier,
1496               node: engineNode,
1497             });
1498           }
1499         }
1500         reject("Search engine not available");
1501       }.bind(this)).catch(() => {
1502         reject("Search engine not available");
1503       });
1504     });
1505   }
1508 this.UITour.init();
1511  * UITour Health Report
1512  */
1513 const DAILY_DISCRETE_TEXT_FIELD = Metrics.Storage.FIELD_DAILY_DISCRETE_TEXT;
1516  * Public API to be called by the UITour code
1517  */
1518 const UITourHealthReport = {
1519   recordTreatmentTag: function(tag, value) {
1520 #ifdef MOZ_SERVICES_HEALTHREPORT
1521     Task.spawn(function*() {
1522       let reporter = Cc["@mozilla.org/datareporting/service;1"]
1523                        .getService()
1524                        .wrappedJSObject
1525                        .healthReporter;
1527       // This can happen if the FHR component of the data reporting service is
1528       // disabled. This is controlled by a pref that most will never use.
1529       if (!reporter) {
1530         return;
1531       }
1533       yield reporter.onInit();
1535       // Get the UITourMetricsProvider instance from the Health Reporter
1536       reporter.getProvider("org.mozilla.uitour").recordTreatmentTag(tag, value);
1537     });
1538 #endif
1539   }
1542 this.UITourMetricsProvider = function() {
1543   Metrics.Provider.call(this);
1546 UITourMetricsProvider.prototype = Object.freeze({
1547   __proto__: Metrics.Provider.prototype,
1549   name: "org.mozilla.uitour",
1551   measurementTypes: [
1552     UITourTreatmentMeasurement1,
1553   ],
1555   recordTreatmentTag: function(tag, value) {
1556     let m = this.getMeasurement(UITourTreatmentMeasurement1.prototype.name,
1557                                 UITourTreatmentMeasurement1.prototype.version);
1558     let field = tag;
1560     if (this.storage.hasFieldFromMeasurement(m.id, field,
1561                                              DAILY_DISCRETE_TEXT_FIELD)) {
1562       let fieldID = this.storage.fieldIDFromMeasurement(m.id, field);
1563       return this.enqueueStorageOperation(function recordKnownField() {
1564         return this.storage.addDailyDiscreteTextFromFieldID(fieldID, value);
1565       }.bind(this));
1566     }
1568     // Otherwise, we first need to create the field.
1569     return this.enqueueStorageOperation(function recordField() {
1570       // This function has to return a promise.
1571       return Task.spawn(function () {
1572         let fieldID = yield this.storage.registerField(m.id, field,
1573                                                        DAILY_DISCRETE_TEXT_FIELD);
1574         yield this.storage.addDailyDiscreteTextFromFieldID(fieldID, value);
1575       }.bind(this));
1576     }.bind(this));
1577   },
1580 function UITourTreatmentMeasurement1() {
1581   Metrics.Measurement.call(this);
1583   this._serializers = {};
1584   this._serializers[this.SERIALIZE_JSON] = {
1585     //singular: We don't need a singular serializer because we have none of this data
1586     daily: this._serializeJSONDaily.bind(this)
1587   };
1591 UITourTreatmentMeasurement1.prototype = Object.freeze({
1592   __proto__: Metrics.Measurement.prototype,
1594   name: "treatment",
1595   version: 1,
1597   // our fields are dynamic
1598   fields: { },
1600   // We need a custom serializer because the default one doesn't accept unknown fields
1601   _serializeJSONDaily: function(data) {
1602     let result = {_v: this.version };
1604     for (let [field, data] of data) {
1605       result[field] = data;
1606     }
1608     return result;
1609   }