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/.
4
5"use strict";
6
7var EXPORTED_SYMBOLS = ["UITour"];
8
9ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
10ChromeUtils.import("resource://gre/modules/Services.jsm");
11ChromeUtils.import("resource://gre/modules/TelemetryController.jsm");
12ChromeUtils.import("resource://gre/modules/Timer.jsm");
13ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
14
15Cu.importGlobalProperties(["URL"]);
16
17ChromeUtils.defineModuleGetter(this, "BrowserUITelemetry",
18  "resource:///modules/BrowserUITelemetry.jsm");
19ChromeUtils.defineModuleGetter(this, "CustomizableUI",
20  "resource:///modules/CustomizableUI.jsm");
21ChromeUtils.defineModuleGetter(this, "FxAccounts",
22  "resource://gre/modules/FxAccounts.jsm");
23ChromeUtils.defineModuleGetter(this, "LightweightThemeManager",
24  "resource://gre/modules/LightweightThemeManager.jsm");
25ChromeUtils.defineModuleGetter(this, "PageActions",
26  "resource:///modules/PageActions.jsm");
27ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils",
28  "resource://gre/modules/PrivateBrowsingUtils.jsm");
29ChromeUtils.defineModuleGetter(this, "ProfileAge",
30  "resource://gre/modules/ProfileAge.jsm");
31ChromeUtils.defineModuleGetter(this, "ReaderParent",
32  "resource:///modules/ReaderParent.jsm");
33ChromeUtils.defineModuleGetter(this, "ResetProfile",
34  "resource://gre/modules/ResetProfile.jsm");
35ChromeUtils.defineModuleGetter(this, "UITelemetry",
36  "resource://gre/modules/UITelemetry.jsm");
37ChromeUtils.defineModuleGetter(this, "UpdateUtils",
38  "resource://gre/modules/UpdateUtils.jsm");
39
40// See LOG_LEVELS in Console.jsm. Common examples: "All", "Info", "Warn", & "Error".
41const PREF_LOG_LEVEL      = "browser.uitour.loglevel";
42const PREF_SEENPAGEIDS    = "browser.uitour.seenPageIDs";
43
44const BACKGROUND_PAGE_ACTIONS_ALLOWED = new Set([
45  "forceShowReaderIcon",
46  "getConfiguration",
47  "getTreatmentTag",
48  "hideHighlight",
49  "hideInfo",
50  "hideMenu",
51  "ping",
52  "registerPageID",
53  "setConfiguration",
54  "setTreatmentTag",
55]);
56const MAX_BUTTONS         = 4;
57
58const BUCKET_NAME         = "UITour";
59const BUCKET_TIMESTEPS    = [
60  1 * 60 * 1000, // Until 1 minute after tab is closed/inactive.
61  3 * 60 * 1000, // Until 3 minutes after tab is closed/inactive.
62  10 * 60 * 1000, // Until 10 minutes after tab is closed/inactive.
63  60 * 60 * 1000, // Until 1 hour after tab is closed/inactive.
64];
65
66// Time after which seen Page IDs expire.
67const SEENPAGEID_EXPIRY  = 8 * 7 * 24 * 60 * 60 * 1000; // 8 weeks.
68
69// Prefix for any target matching a search engine.
70const TARGET_SEARCHENGINE_PREFIX = "searchEngine-";
71
72// Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref.
73XPCOMUtils.defineLazyGetter(this, "log", () => {
74  let ConsoleAPI = ChromeUtils.import("resource://gre/modules/Console.jsm", {}).ConsoleAPI;
75  let consoleOptions = {
76    maxLogLevelPref: PREF_LOG_LEVEL,
77    prefix: "UITour",
78  };
79  return new ConsoleAPI(consoleOptions);
80});
81
82var UITour = {
83  url: null,
84  seenPageIDs: null,
85  // This map is not persisted and is used for
86  // building the content source of a potential tour.
87  pageIDsForSession: new Map(),
88  pageIDSourceBrowsers: new WeakMap(),
89  /* Map from browser chrome windows to a Set of <browser>s in which a tour is open (both visible and hidden) */
90  tourBrowsersByWindow: new WeakMap(),
91  // Menus opened by api users explictly through `Mozilla.UITour.showMenu` call
92  noautohideMenus: new Set(),
93  availableTargetsCache: new WeakMap(),
94  clearAvailableTargetsCache() {
95    this.availableTargetsCache = new WeakMap();
96  },
97
98  _annotationPanelMutationObservers: new WeakMap(),
99
100  highlightEffects: ["random", "wobble", "zoom", "color"],
101  targets: new Map([
102    ["accountStatus", {
103      query: (aDocument) => {
104        // If the user is logged in, use the avatar element.
105        let fxAFooter = aDocument.getElementById("appMenu-fxa-container");
106        if (fxAFooter.getAttribute("fxastatus")) {
107          return aDocument.getElementById("appMenu-fxa-avatar");
108        }
109
110        // Otherwise use the sync setup icon.
111        let statusButton = aDocument.getElementById("appMenu-fxa-label");
112        return aDocument.getAnonymousElementByAttribute(statusButton,
113                                                        "class",
114                                                        "toolbarbutton-icon");
115      },
116      // This is a fake widgetName starting with the "appMenu-" prefix so we know
117      // to automatically open the appMenu when annotating this target.
118      widgetName: "appMenu-fxa-label",
119    }],
120    ["addons",      {query: "#appMenu-addons-button"}],
121    ["appMenu",     {
122      addTargetListener: (aDocument, aCallback) => {
123        let panelPopup = aDocument.defaultView.PanelUI.panel;
124        panelPopup.addEventListener("popupshown", aCallback);
125      },
126      query: "#PanelUI-button",
127      removeTargetListener: (aDocument, aCallback) => {
128        let panelPopup = aDocument.defaultView.PanelUI.panel;
129        panelPopup.removeEventListener("popupshown", aCallback);
130      },
131    }],
132    ["backForward", {query: "#back-button"}],
133    ["bookmarks",   {query: "#bookmarks-menu-button"}],
134    ["controlCenter-trackingUnblock", controlCenterTrackingToggleTarget(true)],
135    ["controlCenter-trackingBlock", controlCenterTrackingToggleTarget(false)],
136    ["customize",   {
137      query: "#appMenu-customize-button",
138      widgetName: "appMenu-customize-button",
139    }],
140    ["devtools",    {
141      query: "#appMenu-developer-button",
142      widgetName: "appMenu-developer-button",
143    }],
144    ["forget", {
145      allowAdd: true,
146      query: "#panic-button",
147      widgetName: "panic-button",
148    }],
149    ["help",        {query: "#appMenu-help-button"}],
150    ["home",        {query: "#home-button"}],
151    ["library",     {query: "#appMenu-library-button"}],
152    ["pocket", {
153      allowAdd: true,
154      query: (aDocument) => {
155        // The pocket's urlbar page action button is pre-defined in the DOM.
156        // It would be hidden if toggled off from the urlbar.
157        let node = aDocument.getElementById("pocket-button-box");
158        if (node && !node.hidden) {
159          return node;
160        }
161        return aDocument.getElementById("pageAction-panel-pocket");
162      },
163    }],
164    ["privateWindow", {query: "#appMenu-private-window-button"}],
165    ["quit",        {query: "#appMenu-quit-button"}],
166    ["readerMode-urlBar", {query: "#reader-mode-button"}],
167    ["search",      {
168      infoPanelOffsetX: 18,
169      infoPanelPosition: "after_start",
170      query: "#searchbar",
171      widgetName: "search-container",
172    }],
173    ["searchIcon", {
174      query: (aDocument) => {
175        let searchbar = aDocument.getElementById("searchbar");
176        return aDocument.getAnonymousElementByAttribute(searchbar,
177                                                        "anonid",
178                                                        "searchbar-search-button");
179      },
180      widgetName: "search-container",
181    }],
182    ["searchPrefsLink", {
183      query: (aDocument) => {
184        let element = null;
185        let popup = aDocument.getElementById("PopupSearchAutoComplete");
186        if (popup.state != "open")
187          return null;
188        element = aDocument.getAnonymousElementByAttribute(popup,
189                                                           "anonid",
190                                                           "search-settings");
191        if (!element || !UITour.isElementVisible(element)) {
192          return null;
193        }
194        return element;
195      },
196    }],
197    ["selectedTabIcon", {
198      query: (aDocument) => {
199        let selectedtab = aDocument.defaultView.gBrowser.selectedTab;
200        let element = aDocument.getAnonymousElementByAttribute(selectedtab,
201                                                               "anonid",
202                                                               "tab-icon-image");
203        if (!element || !UITour.isElementVisible(element)) {
204          return null;
205        }
206        return element;
207      },
208    }],
209    ["trackingProtection", {
210      query: "#tracking-protection-icon",
211    }],
212    ["urlbar",      {
213      query: "#urlbar",
214      widgetName: "urlbar-container",
215    }],
216    ["pageActionButton", {
217      query: "#pageActionButton"
218    }],
219    ["pageAction-bookmark", {
220      query: (aDocument) => {
221        // The bookmark's urlbar page action button is pre-defined in the DOM.
222        // It would be hidden if toggled off from the urlbar.
223        let node = aDocument.getElementById("star-button-box");
224        if (node && !node.hidden) {
225          return node;
226        }
227        return aDocument.getElementById("pageAction-panel-bookmark");
228      },
229    }],
230    ["pageAction-copyURL", {
231      query: (aDocument) => {
232        return aDocument.getElementById("pageAction-urlbar-copyURL") ||
233               aDocument.getElementById("pageAction-panel-copyURL");
234      },
235    }],
236    ["pageAction-emailLink", {
237      query: (aDocument) => {
238        return aDocument.getElementById("pageAction-urlbar-emailLink") ||
239               aDocument.getElementById("pageAction-panel-emailLink");
240      },
241    }],
242    ["pageAction-sendToDevice", {
243      query: (aDocument) => {
244        return aDocument.getElementById("pageAction-urlbar-sendToDevice") ||
245               aDocument.getElementById("pageAction-panel-sendToDevice");
246      },
247    }],
248    ["screenshots", {
249      query: (aDocument) => {
250        return aDocument.getElementById("pageAction-urlbar-screenshots") ||
251               aDocument.getElementById("pageAction-panel-screenshots");
252      },
253    }]
254  ]),
255
256  init() {
257    log.debug("Initializing UITour");
258    // Lazy getter is initialized here so it can be replicated any time
259    // in a test.
260    delete this.seenPageIDs;
261    Object.defineProperty(this, "seenPageIDs", {
262      get: this.restoreSeenPageIDs.bind(this),
263      configurable: true,
264    });
265
266    delete this.url;
267    XPCOMUtils.defineLazyGetter(this, "url", function() {
268      return Services.urlFormatter.formatURLPref("browser.uitour.url");
269    });
270
271    // Clear the availableTargetsCache on widget changes.
272    let listenerMethods = [
273      "onWidgetAdded",
274      "onWidgetMoved",
275      "onWidgetRemoved",
276      "onWidgetReset",
277      "onAreaReset",
278    ];
279    CustomizableUI.addListener(listenerMethods.reduce((listener, method) => {
280      listener[method] = () => this.clearAvailableTargetsCache();
281      return listener;
282    }, {}));
283  },
284
285  restoreSeenPageIDs() {
286    delete this.seenPageIDs;
287
288    if (UITelemetry.enabled) {
289      let dateThreshold = Date.now() - SEENPAGEID_EXPIRY;
290
291      try {
292        let data = Services.prefs.getCharPref(PREF_SEENPAGEIDS);
293        data = new Map(JSON.parse(data));
294
295        for (let [pageID, details] of data) {
296
297          if (typeof pageID != "string" ||
298              typeof details != "object" ||
299              typeof details.lastSeen != "number" ||
300              details.lastSeen < dateThreshold) {
301
302            data.delete(pageID);
303          }
304        }
305
306        this.seenPageIDs = data;
307      } catch (e) {}
308    }
309
310    if (!this.seenPageIDs)
311      this.seenPageIDs = new Map();
312
313    this.persistSeenIDs();
314
315    return this.seenPageIDs;
316  },
317
318  addSeenPageID(aPageID) {
319    if (!UITelemetry.enabled)
320      return;
321
322    this.seenPageIDs.set(aPageID, {
323      lastSeen: Date.now(),
324    });
325
326    this.persistSeenIDs();
327  },
328
329  persistSeenIDs() {
330    if (this.seenPageIDs.size === 0) {
331      Services.prefs.clearUserPref(PREF_SEENPAGEIDS);
332      return;
333    }
334
335    Services.prefs.setCharPref(PREF_SEENPAGEIDS,
336                               JSON.stringify([...this.seenPageIDs]));
337  },
338
339  onPageEvent(aMessage, aEvent) {
340    let browser = aMessage.target;
341    let window = browser.ownerGlobal;
342
343    // Does the window have tabs? We need to make sure since windowless browsers do
344    // not have tabs.
345    if (!window.gBrowser) {
346      // When using windowless browsers we don't have a valid |window|. If that's the case,
347      // use the most recent window as a target for UITour functions (see Bug 1111022).
348      window = Services.wm.getMostRecentWindow("navigator:browser");
349    }
350
351    let messageManager = browser.messageManager;
352
353    log.debug("onPageEvent:", aEvent.detail, aMessage);
354
355    if (typeof aEvent.detail != "object") {
356      log.warn("Malformed event - detail not an object");
357      return false;
358    }
359
360    let action = aEvent.detail.action;
361    if (typeof action != "string" || !action) {
362      log.warn("Action not defined");
363      return false;
364    }
365
366    let data = aEvent.detail.data;
367    if (typeof data != "object") {
368      log.warn("Malformed event - data not an object");
369      return false;
370    }
371
372    if ((aEvent.pageVisibilityState == "hidden" ||
373         aEvent.pageVisibilityState == "unloaded") &&
374        !BACKGROUND_PAGE_ACTIONS_ALLOWED.has(action)) {
375      log.warn("Ignoring disallowed action from a hidden page:", action);
376      return false;
377    }
378
379    switch (action) {
380      case "registerPageID": {
381        if (typeof data.pageID != "string") {
382          log.warn("registerPageID: pageID must be a string");
383          break;
384        }
385
386        this.pageIDsForSession.set(data.pageID, {lastSeen: Date.now()});
387
388        // The rest is only relevant if Telemetry is enabled.
389        if (!UITelemetry.enabled) {
390          log.debug("registerPageID: Telemetry disabled, not doing anything");
391          break;
392        }
393
394        // We don't want to allow BrowserUITelemetry.BUCKET_SEPARATOR in the
395        // pageID, as it could make parsing the telemetry bucket name difficult.
396        if (data.pageID.includes(BrowserUITelemetry.BUCKET_SEPARATOR)) {
397          log.warn("registerPageID: Invalid page ID specified");
398          break;
399        }
400
401        this.addSeenPageID(data.pageID);
402        this.pageIDSourceBrowsers.set(browser, data.pageID);
403        this.setTelemetryBucket(data.pageID);
404
405        break;
406      }
407
408      case "showHighlight": {
409        let targetPromise = this.getTarget(window, data.target);
410        targetPromise.then(target => {
411          if (!target.node) {
412            log.error("UITour: Target could not be resolved: " + data.target);
413            return;
414          }
415          let effect = undefined;
416          if (this.highlightEffects.includes(data.effect)) {
417            effect = data.effect;
418          }
419          this.showHighlight(window, target, effect);
420        }).catch(log.error);
421        break;
422      }
423
424      case "hideHighlight": {
425        this.hideHighlight(window);
426        break;
427      }
428
429      case "showInfo": {
430        let targetPromise = this.getTarget(window, data.target, true);
431        targetPromise.then(target => {
432          if (!target.node) {
433            log.error("UITour: Target could not be resolved: " + data.target);
434            return;
435          }
436
437          let iconURL = null;
438          if (typeof data.icon == "string")
439            iconURL = this.resolveURL(browser, data.icon);
440
441          let buttons = [];
442          if (Array.isArray(data.buttons) && data.buttons.length > 0) {
443            for (let buttonData of data.buttons) {
444              if (typeof buttonData == "object" &&
445                  typeof buttonData.label == "string" &&
446                  typeof buttonData.callbackID == "string") {
447                let callback = buttonData.callbackID;
448                let button = {
449                  label: buttonData.label,
450                  callback: event => {
451                    this.sendPageCallback(messageManager, callback);
452                  },
453                };
454
455                if (typeof buttonData.icon == "string")
456                  button.iconURL = this.resolveURL(browser, buttonData.icon);
457
458                if (typeof buttonData.style == "string")
459                  button.style = buttonData.style;
460
461                buttons.push(button);
462
463                if (buttons.length == MAX_BUTTONS) {
464                  log.warn("showInfo: Reached limit of allowed number of buttons");
465                  break;
466                }
467              }
468            }
469          }
470
471          let infoOptions = {};
472          if (typeof data.closeButtonCallbackID == "string") {
473            infoOptions.closeButtonCallback = () => {
474              this.sendPageCallback(messageManager, data.closeButtonCallbackID);
475            };
476          }
477          if (typeof data.targetCallbackID == "string") {
478            infoOptions.targetCallback = details => {
479              this.sendPageCallback(messageManager, data.targetCallbackID, details);
480            };
481          }
482
483          this.showInfo(window, target, data.title, data.text, iconURL, buttons, infoOptions);
484        }).catch(log.error);
485        break;
486      }
487
488      case "hideInfo": {
489        this.hideInfo(window);
490        break;
491      }
492
493      case "previewTheme": {
494        this.previewTheme(data.theme);
495        break;
496      }
497
498      case "resetTheme": {
499        this.resetTheme();
500        break;
501      }
502
503      case "showMenu": {
504        this.noautohideMenus.add(data.name);
505        this.showMenu(window, data.name, () => {
506          if (typeof data.showCallbackID == "string")
507            this.sendPageCallback(messageManager, data.showCallbackID);
508        });
509        break;
510      }
511
512      case "hideMenu": {
513        this.noautohideMenus.delete(data.name);
514        this.hideMenu(window, data.name);
515        break;
516      }
517
518      case "showNewTab": {
519        this.showNewTab(window, browser);
520        break;
521      }
522
523      case "getConfiguration": {
524        if (typeof data.configuration != "string") {
525          log.warn("getConfiguration: No configuration option specified");
526          return false;
527        }
528
529        this.getConfiguration(messageManager, window, data.configuration, data.callbackID);
530        break;
531      }
532
533      case "setConfiguration": {
534        if (typeof data.configuration != "string") {
535          log.warn("setConfiguration: No configuration option specified");
536          return false;
537        }
538
539        this.setConfiguration(window, data.configuration, data.value);
540        break;
541      }
542
543      case "openPreferences": {
544        if (typeof data.pane != "string" && typeof data.pane != "undefined") {
545          log.warn("openPreferences: Invalid pane specified");
546          return false;
547        }
548        window.openPreferences(data.pane, { origin: "UITour" });
549        break;
550      }
551
552      case "showFirefoxAccounts": {
553        Promise.resolve().then(() => {
554          return data.email ? FxAccounts.config.promiseEmailURI(data.email, "uitour") :
555                              FxAccounts.config.promiseSignUpURI("uitour");
556        }).then(uri => {
557          const url = new URL(uri);
558          // Call our helper to validate extraURLCampaignParams and populate URLSearchParams
559          if (!this._populateCampaignParams(url, data.extraURLCampaignParams)) {
560            log.warn("showFirefoxAccounts: invalid campaign args specified");
561            return;
562          }
563
564          // We want to replace the current tab.
565          browser.loadURI(url.href);
566        });
567        break;
568      }
569
570      case "showConnectAnotherDevice": {
571        FxAccounts.config.promiseConnectDeviceURI("uitour").then(uri => {
572          const url = new URL(uri);
573          // Call our helper to validate extraURLCampaignParams and populate URLSearchParams
574          if (!this._populateCampaignParams(url, data.extraURLCampaignParams)) {
575            log.warn("showConnectAnotherDevice: invalid campaign args specified");
576            return;
577          }
578
579          // We want to replace the current tab.
580          browser.loadURI(url.href);
581        });
582        break;
583      }
584
585      case "resetFirefox": {
586        // Open a reset profile dialog window.
587        if (ResetProfile.resetSupported()) {
588          ResetProfile.openConfirmationDialog(window);
589        }
590        break;
591      }
592
593      case "addNavBarWidget": {
594        // Add a widget to the toolbar
595        let targetPromise = this.getTarget(window, data.name);
596        targetPromise.then(target => {
597          this.addNavBarWidget(target, messageManager, data.callbackID);
598        }).catch(log.error);
599        break;
600      }
601
602      case "setDefaultSearchEngine": {
603        let enginePromise = this.selectSearchEngine(data.identifier);
604        enginePromise.catch(Cu.reportError);
605        break;
606      }
607
608      case "setTreatmentTag": {
609        let name = data.name;
610        let value = data.value;
611        Services.prefs.setStringPref("browser.uitour.treatment." + name, value);
612        // The notification is only meant to be used in tests.
613        UITourHealthReport.recordTreatmentTag(name, value)
614                          .then(() => this.notify("TreatmentTag:TelemetrySent"));
615        break;
616      }
617
618      case "getTreatmentTag": {
619        let name = data.name;
620        let value;
621        try {
622          value = Services.prefs.getStringPref("browser.uitour.treatment." + name);
623        } catch (ex) {}
624        this.sendPageCallback(messageManager, data.callbackID, { value });
625        break;
626      }
627
628      case "setSearchTerm": {
629        let targetPromise = this.getTarget(window, "search");
630        targetPromise.then(target => {
631          let searchbar = target.node;
632          searchbar.value = data.term;
633          searchbar.updateGoButtonVisibility();
634        });
635        break;
636      }
637
638      case "openSearchPanel": {
639        let targetPromise = this.getTarget(window, "search");
640        targetPromise.then(target => {
641          let searchbar = target.node;
642
643          if (searchbar.textbox.open) {
644            this.sendPageCallback(messageManager, data.callbackID);
645          } else {
646            let onPopupShown = () => {
647              searchbar.textbox.popup.removeEventListener("popupshown", onPopupShown);
648              this.sendPageCallback(messageManager, data.callbackID);
649            };
650
651            searchbar.textbox.popup.addEventListener("popupshown", onPopupShown);
652            searchbar.openSuggestionsPanel();
653          }
654        }).catch(Cu.reportError);
655        break;
656      }
657
658      case "ping": {
659        if (typeof data.callbackID == "string")
660          this.sendPageCallback(messageManager, data.callbackID);
661        break;
662      }
663
664      case "forceShowReaderIcon": {
665        ReaderParent.forceShowReaderIcon(browser);
666        break;
667      }
668
669      case "toggleReaderMode": {
670        let targetPromise = this.getTarget(window, "readerMode-urlBar");
671        targetPromise.then(target => {
672          ReaderParent.toggleReaderMode({target: target.node});
673        });
674        break;
675      }
676
677      case "closeTab": {
678        // Find the <tabbrowser> element of the <browser> for which the event
679        // was generated originally. If the browser where the UI tour is loaded
680        // is windowless, just ignore the request to close the tab. The request
681        // is also ignored if this is the only tab in the window.
682        let tabBrowser = browser.ownerGlobal.gBrowser;
683        if (tabBrowser && tabBrowser.browsers.length > 1) {
684          tabBrowser.removeTab(tabBrowser.getTabForBrowser(browser));
685        }
686        break;
687      }
688    }
689
690    // For performance reasons, only call initForBrowser if we did something
691    // that will require a teardownTourForBrowser call later.
692    // getConfiguration (called from about:home) doesn't require any future
693    // uninitialization.
694    if (action != "getConfiguration")
695      this.initForBrowser(browser, window);
696
697    return true;
698  },
699
700  initForBrowser(aBrowser, window) {
701    let gBrowser = window.gBrowser;
702
703    if (gBrowser) {
704        gBrowser.tabContainer.addEventListener("TabSelect", this);
705    }
706
707    if (!this.tourBrowsersByWindow.has(window)) {
708      this.tourBrowsersByWindow.set(window, new Set());
709    }
710    this.tourBrowsersByWindow.get(window).add(aBrowser);
711
712    Services.obs.addObserver(this, "message-manager-close");
713
714    window.addEventListener("SSWindowClosing", this);
715  },
716
717  handleEvent(aEvent) {
718    log.debug("handleEvent: type =", aEvent.type, "event =", aEvent);
719    switch (aEvent.type) {
720      case "TabSelect": {
721        let window = aEvent.target.ownerGlobal;
722
723        // Teardown the browser of the tab we just switched away from.
724        if (aEvent.detail && aEvent.detail.previousTab) {
725          let previousTab = aEvent.detail.previousTab;
726          let openTourWindows = this.tourBrowsersByWindow.get(window);
727          if (openTourWindows.has(previousTab.linkedBrowser)) {
728            this.teardownTourForBrowser(window, previousTab.linkedBrowser, false);
729          }
730        }
731
732        break;
733      }
734
735      case "SSWindowClosing": {
736        let window = aEvent.target;
737        this.teardownTourForWindow(window);
738        break;
739      }
740    }
741  },
742
743  observe(aSubject, aTopic, aData) {
744    log.debug("observe: aTopic =", aTopic);
745    switch (aTopic) {
746      // The browser message manager is disconnected when the <browser> is
747      // destroyed and we want to teardown at that point.
748      case "message-manager-close": {
749        let winEnum = Services.wm.getEnumerator("navigator:browser");
750        while (winEnum.hasMoreElements()) {
751          let window = winEnum.getNext();
752          if (window.closed)
753            continue;
754
755          let tourBrowsers = this.tourBrowsersByWindow.get(window);
756          if (!tourBrowsers)
757            continue;
758
759          for (let browser of tourBrowsers) {
760            let messageManager = browser.messageManager;
761            if (aSubject != messageManager) {
762              continue;
763            }
764
765            this.teardownTourForBrowser(window, browser, true);
766            return;
767          }
768        }
769        break;
770      }
771    }
772  },
773
774  // Given a string that is a JSONified represenation of an object with
775  // additional utm_* URL params that should be appended, validate and append
776  // them to the passed URL object. Returns true if the params
777  // were validated and appended, and false if the request should be ignored.
778  _populateCampaignParams(url, extraURLCampaignParams) {
779    // We are extra paranoid about what params we allow to be appended.
780    if (typeof extraURLCampaignParams == "undefined") {
781      // no params, so it's all good.
782      return true;
783    }
784    if (typeof extraURLCampaignParams != "string") {
785      log.warn("_populateCampaignParams: extraURLCampaignParams is not a string");
786      return false;
787    }
788    let campaignParams;
789    try {
790      if (extraURLCampaignParams) {
791        campaignParams = JSON.parse(extraURLCampaignParams);
792        if (typeof campaignParams != "object") {
793          log.warn("_populateCampaignParams: extraURLCampaignParams is not a stringified object");
794          return false;
795        }
796      }
797    } catch (ex) {
798      log.warn("_populateCampaignParams: extraURLCampaignParams is not a JSON object");
799      return false;
800    }
801    if (campaignParams) {
802      // The regex that the name of each param must match - there's no
803      // character restriction on the value - they will be escaped as necessary.
804      let reSimpleString = /^[-_a-zA-Z0-9]*$/;
805      for (let name in campaignParams) {
806        let value = campaignParams[name];
807        if (typeof name != "string" || typeof value != "string" ||
808            !name.startsWith("utm_") ||
809            value.length == 0 ||
810            !reSimpleString.test(name)) {
811          log.warn("_populateCampaignParams: invalid campaign param specified");
812          return false;
813        }
814        url.searchParams.append(name, value);
815      }
816    }
817    return true;
818  },
819
820  setTelemetryBucket(aPageID) {
821    let bucket = BUCKET_NAME + BrowserUITelemetry.BUCKET_SEPARATOR + aPageID;
822    BrowserUITelemetry.setBucket(bucket);
823  },
824
825  setExpiringTelemetryBucket(aPageID, aType) {
826    let bucket = BUCKET_NAME + BrowserUITelemetry.BUCKET_SEPARATOR + aPageID +
827                 BrowserUITelemetry.BUCKET_SEPARATOR + aType;
828
829    BrowserUITelemetry.setExpiringBucket(bucket,
830                                         BUCKET_TIMESTEPS);
831  },
832
833  // This is registered with UITelemetry by BrowserUITelemetry, so that UITour
834  // can remain lazy-loaded on-demand.
835  getTelemetry() {
836    return {
837      seenPageIDs: [...this.seenPageIDs.keys()],
838    };
839  },
840
841  /**
842   * Tear down a tour from a tab e.g. upon switching/closing tabs.
843   */
844  async teardownTourForBrowser(aWindow, aBrowser, aTourPageClosing = false) {
845    log.debug("teardownTourForBrowser: aBrowser = ", aBrowser, aTourPageClosing);
846
847    if (this.pageIDSourceBrowsers.has(aBrowser)) {
848      let pageID = this.pageIDSourceBrowsers.get(aBrowser);
849      this.setExpiringTelemetryBucket(pageID, aTourPageClosing ? "closed" : "inactive");
850    }
851
852    let openTourBrowsers = this.tourBrowsersByWindow.get(aWindow);
853    if (aTourPageClosing && openTourBrowsers) {
854      openTourBrowsers.delete(aBrowser);
855    }
856
857    this.hideHighlight(aWindow);
858    this.hideInfo(aWindow);
859
860    let panels = [
861      {
862        name: "appMenu",
863        node: aWindow.PanelUI.panel,
864        events: [
865          [ "popuphidden", this.onPanelHidden ],
866          [ "popuphiding", this.onAppMenuHiding ],
867          [ "ViewShowing", this.onAppMenuSubviewShowing ]
868        ]
869      },
870      {
871        name: "pageActionPanel",
872        node: aWindow.BrowserPageActions.panelNode,
873        events: [
874          [ "popuphidden", this.onPanelHidden ],
875          [ "popuphiding", this.onPageActionPanelHiding ],
876          [ "ViewShowing", this.onPageActionPanelSubviewShowing ]
877        ]
878      },
879      {
880        name: "controlCenter",
881        node: aWindow.gIdentityHandler._identityPopup,
882        events: [
883          [ "popuphidden", this.onPanelHidden ],
884          [ "popuphiding", this.onControlCenterHiding ]
885        ]
886      },
887    ];
888    for (let panel of panels) {
889      // Ensure the menu panel is hidden and clean up panel listeners after calling hideMenu.
890      if (panel.node.state != "closed") {
891        await new Promise(resolve => {
892          panel.node.addEventListener("popuphidden", resolve, { once: true });
893          this.hideMenu(aWindow, panel.name);
894        });
895      }
896      for (let [ name, listener ] of panel.events) {
897        panel.node.removeEventListener(name, listener);
898      }
899    }
900
901    this.noautohideMenus.clear();
902    this.resetTheme();
903
904    // If there are no more tour tabs left in the window, teardown the tour for the whole window.
905    if (!openTourBrowsers || openTourBrowsers.size == 0) {
906      this.teardownTourForWindow(aWindow);
907    }
908  },
909
910  /**
911   * Tear down all tours for a ChromeWindow.
912   */
913  teardownTourForWindow(aWindow) {
914    log.debug("teardownTourForWindow");
915    aWindow.gBrowser.tabContainer.removeEventListener("TabSelect", this);
916    aWindow.removeEventListener("SSWindowClosing", this);
917
918    let openTourBrowsers = this.tourBrowsersByWindow.get(aWindow);
919    if (openTourBrowsers) {
920      for (let browser of openTourBrowsers) {
921        if (this.pageIDSourceBrowsers.has(browser)) {
922          let pageID = this.pageIDSourceBrowsers.get(browser);
923          this.setExpiringTelemetryBucket(pageID, "closed");
924        }
925      }
926    }
927
928    this.tourBrowsersByWindow.delete(aWindow);
929  },
930
931  // This function is copied to UITourListener.
932  isSafeScheme(aURI) {
933    let allowedSchemes = new Set(["https", "about"]);
934    if (!Services.prefs.getBoolPref("browser.uitour.requireSecure"))
935      allowedSchemes.add("http");
936
937    if (!allowedSchemes.has(aURI.scheme)) {
938      log.error("Unsafe scheme:", aURI.scheme);
939      return false;
940    }
941
942    return true;
943  },
944
945  resolveURL(aBrowser, aURL) {
946    try {
947      let uri = Services.io.newURI(aURL, null, aBrowser.currentURI);
948
949      if (!this.isSafeScheme(uri))
950        return null;
951
952      return uri.spec;
953    } catch (e) {}
954
955    return null;
956  },
957
958  sendPageCallback(aMessageManager, aCallbackID, aData = {}) {
959    let detail = {data: aData, callbackID: aCallbackID};
960    log.debug("sendPageCallback", detail);
961    aMessageManager.sendAsyncMessage("UITour:SendPageCallback", detail);
962  },
963
964  isElementVisible(aElement) {
965    let targetStyle = aElement.ownerGlobal.getComputedStyle(aElement);
966    return !aElement.ownerDocument.hidden &&
967             targetStyle.display != "none" &&
968             targetStyle.visibility == "visible";
969  },
970
971  getTarget(aWindow, aTargetName, aSticky = false) {
972    log.debug("getTarget:", aTargetName);
973    if (typeof aTargetName != "string" || !aTargetName) {
974      log.warn("getTarget: Invalid target name specified");
975      return Promise.reject("Invalid target name specified");
976    }
977
978    let targetObject = this.targets.get(aTargetName);
979    if (!targetObject) {
980      log.warn("getTarget: The specified target name is not in the allowed set");
981      return Promise.reject("The specified target name is not in the allowed set");
982    }
983
984    return new Promise(resolve => {
985      let targetQuery = targetObject.query;
986      aWindow.PanelUI.ensureReady().then(() => {
987        let node;
988        if (typeof targetQuery == "function") {
989          try {
990            node = targetQuery(aWindow.document);
991          } catch (ex) {
992            log.warn("getTarget: Error running target query:", ex);
993            node = null;
994          }
995        } else {
996          node = aWindow.document.querySelector(targetQuery);
997        }
998
999        resolve({
1000          addTargetListener: targetObject.addTargetListener,
1001          infoPanelOffsetX: targetObject.infoPanelOffsetX,
1002          infoPanelOffsetY: targetObject.infoPanelOffsetY,
1003          infoPanelPosition: targetObject.infoPanelPosition,
1004          node,
1005          removeTargetListener: targetObject.removeTargetListener,
1006          targetName: aTargetName,
1007          widgetName: targetObject.widgetName,
1008          allowAdd: targetObject.allowAdd,
1009        });
1010      }).catch(log.error);
1011    });
1012  },
1013
1014  targetIsInAppMenu(aTarget) {
1015    let targetElement = aTarget.node;
1016    // Use the widget for filtering if it exists since the target may be the icon inside.
1017    if (aTarget.widgetName) {
1018      targetElement = aTarget.node.ownerDocument.getElementById(aTarget.widgetName);
1019    }
1020
1021    return targetElement.id.startsWith("appMenu-");
1022  },
1023
1024  targetIsInPageActionPanel(aTarget) {
1025    return aTarget.node.id.startsWith("pageAction-panel-");
1026  },
1027
1028  /**
1029   * Called before opening or after closing a highlight or an info tooltip to see if
1030   * we need to open or close the menu to see the annotation's anchor.
1031   *
1032   * @param {ChromeWindow} aWindow the chrome window
1033   * @param {bool} aShouldOpen true means we should open the menu, otherwise false
1034   * @param {String} aMenuName "appMenu" or "pageActionPanel"
1035   */
1036  _setMenuStateForAnnotation(aWindow, aShouldOpen, aMenuName) {
1037    log.debug("_setMenuStateForAnnotation: Menu is ", aMenuName);
1038    log.debug("_setMenuStateForAnnotation: Menu is expected to be:", aShouldOpen ? "open" : "closed");
1039    let menu = aMenuName == "appMenu" ? aWindow.PanelUI.panel : aWindow.BrowserPageActions.panelNode;
1040
1041    // If the panel is in the desired state, we're done.
1042    let panelIsOpen = menu.state != "closed";
1043    if (aShouldOpen == panelIsOpen) {
1044      log.debug("_setMenuStateForAnnotation: Menu already in expected state");
1045      return Promise.resolve();
1046    }
1047
1048    // Actually show or hide the menu
1049    let promise = null;
1050    if (aShouldOpen) {
1051      log.debug("_setMenuStateForAnnotation: Opening the menu");
1052      promise = new Promise(resolve => {
1053        this.showMenu(aWindow, aMenuName, resolve);
1054      });
1055    } else if (!this.noautohideMenus.has(aMenuName)) {
1056      // If the menu was opened explictly by api user through `Mozilla.UITour.showMenu`,
1057      // it should be closed explictly by api user through `Mozilla.UITour.hideMenu`.
1058      // So we shouldn't get to here to close it for the highlight/info annotation.
1059      log.debug("_setMenuStateForAnnotation: Closing the menu");
1060      promise = new Promise(resolve => {
1061        menu.addEventListener("popuphidden", resolve, { once: true });
1062        this.hideMenu(aWindow, aMenuName);
1063      });
1064    }
1065    return promise;
1066  },
1067
1068  /**
1069   * Ensure the target's visibility and the open/close states of menus for the target.
1070   *
1071   * @param {ChromeWindow} aChromeWindow The chrome window
1072   * @param {Object} aTarget The target on which we show highlight or show info.
1073   */
1074  async _ensureTarget(aChromeWindow, aTarget) {
1075    let shouldOpenAppMenu = false;
1076    let shouldOpenPageActionPanel = false;
1077    if (this.targetIsInAppMenu(aTarget)) {
1078      shouldOpenAppMenu = true;
1079    } else if (this.targetIsInPageActionPanel(aTarget)) {
1080      shouldOpenPageActionPanel = true;
1081      // Ensure the panel visibility so as to ensure the visibility of the target
1082      // element inside the panel otherwise we would be rejected in the below
1083      // `isElementVisible` checking.
1084      aChromeWindow.BrowserPageActions.panelNode.hidden = false;
1085    }
1086
1087    // Prevent showing a panel at an undefined position, but when it's tucked
1088    // away inside a panel, we skip this check.
1089    if (!aTarget.node.closest("panelview") && !this.isElementVisible(aTarget.node)) {
1090      return Promise.reject(`_ensureTarget: Reject the ${aTarget.name || aTarget.targetName} target since it isn't visible.`);
1091    }
1092
1093    let menuToOpen = null;
1094    let menuClosePromises = [];
1095    if (shouldOpenAppMenu) {
1096      menuToOpen = "appMenu";
1097      menuClosePromises.push(this._setMenuStateForAnnotation(aChromeWindow, false, "pageActionPanel"));
1098    } else if (shouldOpenPageActionPanel) {
1099      menuToOpen = "pageActionPanel";
1100      menuClosePromises.push(this._setMenuStateForAnnotation(aChromeWindow, false, "appMenu"));
1101    } else {
1102      menuClosePromises.push(this._setMenuStateForAnnotation(aChromeWindow, false, "appMenu"));
1103      menuClosePromises.push(this._setMenuStateForAnnotation(aChromeWindow, false, "pageActionPanel"));
1104    }
1105
1106    let promise = Promise.all(menuClosePromises);
1107    await promise;
1108    if (menuToOpen) {
1109      promise = this._setMenuStateForAnnotation(aChromeWindow, true, menuToOpen);
1110    }
1111    return promise;
1112  },
1113
1114  previewTheme(aTheme) {
1115    let origin = Services.prefs.getCharPref("browser.uitour.themeOrigin");
1116    let data = LightweightThemeManager.parseTheme(aTheme, origin);
1117    if (data)
1118      LightweightThemeManager.previewTheme(data);
1119  },
1120
1121  resetTheme() {
1122    LightweightThemeManager.resetPreview();
1123  },
1124
1125  /**
1126   * The node to which a highlight or notification(-popup) is anchored is sometimes
1127   * obscured because it may be inside an overflow menu. This function should figure
1128   * that out and offer the overflow chevron as an alternative.
1129   *
1130   * @param {ChromeWindow} aChromeWindow The chrome window
1131   * @param {Object} aTarget The target object whose node is supposed to be the anchor
1132   * @type {Node}
1133   */
1134  async _correctAnchor(aChromeWindow, aTarget) {
1135    // PanelMultiView's like the AppMenu might shuffle the DOM, which might result
1136    // in our anchor being invalidated if it was anonymous content (since the XBL
1137    // binding it belonged to got destroyed). We work around this by re-querying for
1138    // the node and stuffing it into the old anchor structure.
1139    let refreshedTarget = await this.getTarget(aChromeWindow, aTarget.targetName);
1140    let node = aTarget.node = refreshedTarget.node;
1141    // If the target is in the overflow panel, just return the overflow button.
1142    if (node.closest("#widget-overflow-mainView")) {
1143      return CustomizableUI.getWidget(node.id).forWindow(aChromeWindow).anchor;
1144    }
1145    return node;
1146  },
1147
1148  /**
1149   * @param aChromeWindow The chrome window that the highlight is in. Necessary since some targets
1150   *                      are in a sub-frame so the defaultView is not the same as the chrome
1151   *                      window.
1152   * @param aTarget    The element to highlight.
1153   * @param aEffect    (optional) The effect to use from UITour.highlightEffects or "none".
1154   * @see UITour.highlightEffects
1155   */
1156  async showHighlight(aChromeWindow, aTarget, aEffect = "none") {
1157    let showHighlightElement = (aAnchorEl) => {
1158      let highlighter = aChromeWindow.document.getElementById("UITourHighlight");
1159
1160      let effect = aEffect;
1161      if (effect == "random") {
1162        // Exclude "random" from the randomly selected effects.
1163        let randomEffect = 1 + Math.floor(Math.random() * (this.highlightEffects.length - 1));
1164        if (randomEffect == this.highlightEffects.length)
1165          randomEffect--; // On the order of 1 in 2^62 chance of this happening.
1166        effect = this.highlightEffects[randomEffect];
1167      }
1168      // Toggle the effect attribute to "none" and flush layout before setting it so the effect plays.
1169      highlighter.setAttribute("active", "none");
1170      aChromeWindow.getComputedStyle(highlighter).animationName;
1171      highlighter.setAttribute("active", effect);
1172      highlighter.parentElement.setAttribute("targetName", aTarget.targetName);
1173      highlighter.parentElement.hidden = false;
1174
1175      let highlightAnchor = aAnchorEl;
1176      let targetRect = highlightAnchor.getBoundingClientRect();
1177      let highlightHeight = targetRect.height;
1178      let highlightWidth = targetRect.width;
1179
1180      if (this.targetIsInAppMenu(aTarget) || this.targetIsInPageActionPanel(aTarget)) {
1181        highlighter.classList.remove("rounded-highlight");
1182      } else {
1183        highlighter.classList.add("rounded-highlight");
1184      }
1185      if (highlightAnchor.classList.contains("toolbarbutton-1") &&
1186          highlightAnchor.getAttribute("cui-areatype") === "toolbar" &&
1187          highlightAnchor.getAttribute("overflowedItem") !== "true") {
1188        // A toolbar button in navbar has its clickable area an
1189        // inner-contained square while the button component itself is a tall
1190        // rectangle. We adjust the highlight area to a square as well.
1191        highlightHeight = highlightWidth;
1192      }
1193
1194      highlighter.style.height = highlightHeight + "px";
1195      highlighter.style.width = highlightWidth + "px";
1196
1197      // Close a previous highlight so we can relocate the panel.
1198      if (highlighter.parentElement.state == "showing" || highlighter.parentElement.state == "open") {
1199        log.debug("showHighlight: Closing previous highlight first");
1200        highlighter.parentElement.hidePopup();
1201      }
1202      /* The "overlap" position anchors from the top-left but we want to centre highlights at their
1203         minimum size. */
1204      let highlightWindow = aChromeWindow;
1205      let highlightStyle = highlightWindow.getComputedStyle(highlighter);
1206      let highlightHeightWithMin = Math.max(highlightHeight, parseFloat(highlightStyle.minHeight));
1207      let highlightWidthWithMin = Math.max(highlightWidth, parseFloat(highlightStyle.minWidth));
1208      let offsetX = (targetRect.width - highlightWidthWithMin) / 2;
1209      let offsetY = (targetRect.height - highlightHeightWithMin) / 2;
1210      this._addAnnotationPanelMutationObserver(highlighter.parentElement);
1211      highlighter.parentElement.openPopup(highlightAnchor, "overlap", offsetX, offsetY);
1212    };
1213
1214    try {
1215      await this._ensureTarget(aChromeWindow, aTarget);
1216      let anchorEl = await this._correctAnchor(aChromeWindow, aTarget);
1217      showHighlightElement(anchorEl);
1218    } catch (e) {
1219      log.warn(e);
1220    }
1221  },
1222
1223  _hideHighlightElement(aWindow) {
1224    let highlighter = aWindow.document.getElementById("UITourHighlight");
1225    this._removeAnnotationPanelMutationObserver(highlighter.parentElement);
1226    highlighter.parentElement.hidePopup();
1227    highlighter.removeAttribute("active");
1228  },
1229
1230  hideHighlight(aWindow) {
1231    this._hideHighlightElement(aWindow);
1232    this._setMenuStateForAnnotation(aWindow, false, "appMenu");
1233    this._setMenuStateForAnnotation(aWindow, false, "pageActionPanel");
1234  },
1235
1236  /**
1237   * Show an info panel.
1238   *
1239   * @param {ChromeWindow} aChromeWindow
1240   * @param {Node}     aAnchor
1241   * @param {String}   [aTitle=""]
1242   * @param {String}   [aDescription=""]
1243   * @param {String}   [aIconURL=""]
1244   * @param {Object[]} [aButtons=[]]
1245   * @param {Object}   [aOptions={}]
1246   * @param {String}   [aOptions.closeButtonCallback]
1247   * @param {String}   [aOptions.targetCallback]
1248   */
1249  async showInfo(aChromeWindow, aAnchor, aTitle = "", aDescription = "",
1250           aIconURL = "", aButtons = [], aOptions = {}) {
1251    let showInfoElement = (aAnchorEl) => {
1252      aAnchorEl.focus();
1253
1254      let document = aChromeWindow.document;
1255      let tooltip = document.getElementById("UITourTooltip");
1256      let tooltipTitle = document.getElementById("UITourTooltipTitle");
1257      let tooltipDesc = document.getElementById("UITourTooltipDescription");
1258      let tooltipIcon = document.getElementById("UITourTooltipIcon");
1259      let tooltipButtons = document.getElementById("UITourTooltipButtons");
1260
1261      if (tooltip.state == "showing" || tooltip.state == "open") {
1262        tooltip.hidePopup();
1263      }
1264
1265      tooltipTitle.textContent = aTitle || "";
1266      tooltipDesc.textContent = aDescription || "";
1267      tooltipIcon.src = aIconURL || "";
1268      tooltipIcon.hidden = !aIconURL;
1269
1270      while (tooltipButtons.firstChild)
1271        tooltipButtons.firstChild.remove();
1272
1273      for (let button of aButtons) {
1274        let isButton = button.style != "text";
1275        let el = document.createElement(isButton ? "button" : "label");
1276        el.setAttribute(isButton ? "label" : "value", button.label);
1277
1278        if (isButton) {
1279          if (button.iconURL)
1280            el.setAttribute("image", button.iconURL);
1281
1282          if (button.style == "link")
1283            el.setAttribute("class", "button-link");
1284
1285          if (button.style == "primary")
1286            el.setAttribute("class", "button-primary");
1287
1288          // Don't close the popup or call the callback for style=text as they
1289          // aren't links/buttons.
1290          let callback = button.callback;
1291          el.addEventListener("command", event => {
1292            tooltip.hidePopup();
1293            callback(event);
1294          });
1295        }
1296
1297        tooltipButtons.appendChild(el);
1298      }
1299
1300      tooltipButtons.hidden = !aButtons.length;
1301
1302      let tooltipClose = document.getElementById("UITourTooltipClose");
1303      let closeButtonCallback = (event) => {
1304        this.hideInfo(document.defaultView);
1305        if (aOptions && aOptions.closeButtonCallback) {
1306          aOptions.closeButtonCallback();
1307        }
1308      };
1309      tooltipClose.addEventListener("command", closeButtonCallback);
1310
1311      let targetCallback = (event) => {
1312        let details = {
1313          target: aAnchor.targetName,
1314          type: event.type,
1315        };
1316        aOptions.targetCallback(details);
1317      };
1318      if (aOptions.targetCallback && aAnchor.addTargetListener) {
1319        aAnchor.addTargetListener(document, targetCallback);
1320      }
1321
1322      tooltip.addEventListener("popuphiding", function(event) {
1323        tooltipClose.removeEventListener("command", closeButtonCallback);
1324        if (aOptions.targetCallback && aAnchor.removeTargetListener) {
1325          aAnchor.removeTargetListener(document, targetCallback);
1326        }
1327      }, {once: true});
1328
1329      tooltip.setAttribute("targetName", aAnchor.targetName);
1330      tooltip.hidden = false;
1331      let alignment = "bottomcenter topright";
1332      if (aAnchor.infoPanelPosition) {
1333        alignment = aAnchor.infoPanelPosition;
1334      }
1335
1336      let { infoPanelOffsetX: xOffset, infoPanelOffsetY: yOffset } = aAnchor;
1337
1338      this._addAnnotationPanelMutationObserver(tooltip);
1339      tooltip.openPopup(aAnchorEl, alignment, xOffset || 0, yOffset || 0);
1340      if (tooltip.state == "closed") {
1341        document.defaultView.addEventListener("endmodalstate", function() {
1342          tooltip.openPopup(aAnchorEl, alignment);
1343        }, {once: true});
1344      }
1345    };
1346
1347    try {
1348      await this._ensureTarget(aChromeWindow, aAnchor);
1349      let anchorEl = await this._correctAnchor(aChromeWindow, aAnchor);
1350      showInfoElement(anchorEl);
1351    } catch (e) {
1352      log.warn(e);
1353    }
1354  },
1355
1356  isInfoOnTarget(aChromeWindow, aTargetName) {
1357    let document = aChromeWindow.document;
1358    let tooltip = document.getElementById("UITourTooltip");
1359    return tooltip.getAttribute("targetName") == aTargetName && tooltip.state != "closed";
1360  },
1361
1362  _hideInfoElement(aWindow) {
1363    let document = aWindow.document;
1364    let tooltip = document.getElementById("UITourTooltip");
1365    this._removeAnnotationPanelMutationObserver(tooltip);
1366    tooltip.hidePopup();
1367    let tooltipButtons = document.getElementById("UITourTooltipButtons");
1368    while (tooltipButtons.firstChild)
1369      tooltipButtons.firstChild.remove();
1370  },
1371
1372  hideInfo(aWindow) {
1373    this._hideInfoElement(aWindow);
1374    this._setMenuStateForAnnotation(aWindow, false, "appMenu");
1375    this._setMenuStateForAnnotation(aWindow, false, "pageActionPanel");
1376  },
1377
1378  showMenu(aWindow, aMenuName, aOpenCallback = null) {
1379    log.debug("showMenu:", aMenuName);
1380    function openMenuButton(aMenuBtn) {
1381      if (!aMenuBtn || !aMenuBtn.boxObject || aMenuBtn.open) {
1382        if (aOpenCallback)
1383          aOpenCallback();
1384        return;
1385      }
1386      if (aOpenCallback)
1387        aMenuBtn.addEventListener("popupshown", aOpenCallback, { once: true });
1388      aMenuBtn.boxObject.openMenu(true);
1389    }
1390
1391    if (aMenuName == "appMenu" || aMenuName == "pageActionPanel") {
1392      let menu = {
1393        onPanelHidden: this.onPanelHidden
1394      };
1395      if (aMenuName == "appMenu") {
1396        menu.node = aWindow.PanelUI.panel;
1397        menu.onPopupHiding = this.onAppMenuHiding;
1398        menu.onViewShowing = this.onAppMenuSubviewShowing;
1399        menu.show = () => aWindow.PanelUI.show();
1400      } else {
1401        menu.node = aWindow.BrowserPageActions.panelNode;
1402        menu.onPopupHiding = this.onPageActionPanelHiding;
1403        menu.onViewShowing = this.onPageActionPanelSubviewShowing;
1404        menu.show = () => aWindow.BrowserPageActions.showPanel();
1405      }
1406
1407      menu.node.setAttribute("noautohide", "true");
1408      // If the popup is already opened, don't recreate the widget as it may cause a flicker.
1409      if (menu.node.state != "open") {
1410        this.recreatePopup(menu.node);
1411      }
1412      if (aOpenCallback) {
1413        menu.node.addEventListener("popupshown", aOpenCallback, { once: true });
1414      }
1415      menu.node.addEventListener("popuphidden", menu.onPanelHidden);
1416      menu.node.addEventListener("popuphiding", menu.onPopupHiding);
1417      menu.node.addEventListener("ViewShowing", menu.onViewShowing);
1418      menu.show();
1419    } else if (aMenuName == "bookmarks") {
1420      let menuBtn = aWindow.document.getElementById("bookmarks-menu-button");
1421      openMenuButton(menuBtn);
1422    } else if (aMenuName == "controlCenter") {
1423      let popup = aWindow.gIdentityHandler._identityPopup;
1424
1425      // Add the listener even if the panel is already open since it will still
1426      // only get registered once even if it was UITour that opened it.
1427      popup.addEventListener("popuphiding", this.onControlCenterHiding);
1428      popup.addEventListener("popuphidden", this.onPanelHidden);
1429
1430      popup.setAttribute("noautohide", "true");
1431      this.clearAvailableTargetsCache();
1432
1433      if (popup.state == "open") {
1434        if (aOpenCallback) {
1435          aOpenCallback();
1436        }
1437        return;
1438      }
1439
1440      this.recreatePopup(popup);
1441
1442      // Open the control center
1443      if (aOpenCallback) {
1444        popup.addEventListener("popupshown", aOpenCallback, { once: true });
1445      }
1446      aWindow.document.getElementById("identity-box").click();
1447    } else if (aMenuName == "pocket") {
1448      let pageAction = PageActions.actionForID("pocket");
1449      if (!pageAction) {
1450        log.error("Can't open the pocket menu without a page action");
1451        return;
1452      }
1453      pageAction.doCommand(aWindow);
1454    } else if (aMenuName == "urlbar") {
1455      this.getTarget(aWindow, "urlbar").then(target => {
1456        let urlbar = target.node;
1457        if (aOpenCallback) {
1458          urlbar.popup.addEventListener("popupshown", aOpenCallback, { once: true });
1459        }
1460        urlbar.focus();
1461        // To demonstrate the ability of searching, we type "Firefox" in advance
1462        // for URLBar's dropdown. To limit the search results on browser-related
1463        // items, we use "Firefox" hard-coded rather than l10n brandShortName
1464        // entity to avoid unrelated or unpredicted results for, like, Nightly
1465        // or translated entites.
1466        const SEARCH_STRING = "Firefox";
1467        urlbar.value = SEARCH_STRING;
1468        urlbar.select();
1469        urlbar.controller.startSearch(SEARCH_STRING);
1470      }).catch(Cu.reportError);
1471    }
1472  },
1473
1474  hideMenu(aWindow, aMenuName) {
1475    log.debug("hideMenu:", aMenuName);
1476    function closeMenuButton(aMenuBtn) {
1477      if (aMenuBtn && aMenuBtn.boxObject)
1478        aMenuBtn.boxObject.openMenu(false);
1479    }
1480
1481    if (aMenuName == "appMenu") {
1482      aWindow.PanelUI.hide();
1483    } else if (aMenuName == "bookmarks") {
1484      let menuBtn = aWindow.document.getElementById("bookmarks-menu-button");
1485      closeMenuButton(menuBtn);
1486    } else if (aMenuName == "controlCenter") {
1487      let panel = aWindow.gIdentityHandler._identityPopup;
1488      panel.hidePopup();
1489    } else if (aMenuName == "urlbar") {
1490      aWindow.gURLBar.closePopup();
1491    } else if (aMenuName == "pageActionPanel") {
1492      aWindow.BrowserPageActions.panelNode.hidePopup();
1493    }
1494  },
1495
1496  showNewTab(aWindow, aBrowser) {
1497    aWindow.openLinkIn("about:newtab", "current", {targetBrowser: aBrowser});
1498    aWindow.gURLBar.focus();
1499  },
1500
1501  _hideAnnotationsForPanel(aEvent, aShouldClosePanel, aTargetPositionCallback) {
1502    let win = aEvent.target.ownerGlobal;
1503    let hideHighlightMethod = null;
1504    let hideInfoMethod = null;
1505    if (aShouldClosePanel) {
1506      hideHighlightMethod = aWin => this.hideHighlight(aWin);
1507      hideInfoMethod = aWin => this.hideInfo(aWin);
1508    } else {
1509      // Don't have to close panel, let's only hide annotation elements
1510      hideHighlightMethod = aWin => this._hideHighlightElement(aWin);
1511      hideInfoMethod = aWin => this._hideInfoElement(aWin);
1512    }
1513    let annotationElements = new Map([
1514      // [annotationElement (panel), method to hide the annotation]
1515      [win.document.getElementById("UITourHighlightContainer"), hideHighlightMethod],
1516      [win.document.getElementById("UITourTooltip"), hideInfoMethod],
1517    ]);
1518    annotationElements.forEach((hideMethod, annotationElement) => {
1519      if (annotationElement.state != "closed") {
1520        let targetName = annotationElement.getAttribute("targetName");
1521        UITour.getTarget(win, targetName).then((aTarget) => {
1522          // Since getTarget is async, we need to make sure that the target hasn't
1523          // changed since it may have just moved to somewhere outside of the app menu.
1524          if (annotationElement.getAttribute("targetName") != aTarget.targetName ||
1525              annotationElement.state == "closed" ||
1526              !aTargetPositionCallback(aTarget)) {
1527            return;
1528          }
1529          hideMethod(win);
1530        }).catch(log.error);
1531      }
1532    });
1533  },
1534
1535  onAppMenuHiding(aEvent) {
1536    UITour._hideAnnotationsForPanel(aEvent, true, UITour.targetIsInAppMenu);
1537  },
1538
1539  onAppMenuSubviewShowing(aEvent) {
1540    UITour._hideAnnotationsForPanel(aEvent, false, UITour.targetIsInAppMenu);
1541  },
1542
1543  onPageActionPanelHiding(aEvent) {
1544    UITour._hideAnnotationsForPanel(aEvent, true, UITour.targetIsInPageActionPanel);
1545  },
1546
1547  onPageActionPanelSubviewShowing(aEvent) {
1548    UITour._hideAnnotationsForPanel(aEvent, false, UITour.targetIsInPageActionPanel);
1549  },
1550
1551  onControlCenterHiding(aEvent) {
1552    UITour._hideAnnotationsForPanel(aEvent, true, (aTarget) => {
1553      return aTarget.targetName.startsWith("controlCenter-");
1554    });
1555  },
1556
1557  onPanelHidden(aEvent) {
1558    aEvent.target.removeAttribute("noautohide");
1559    UITour.recreatePopup(aEvent.target);
1560    UITour.clearAvailableTargetsCache();
1561  },
1562
1563  recreatePopup(aPanel) {
1564    // After changing popup attributes that relate to how the native widget is created
1565    // (e.g. @noautohide) we need to re-create the frame/widget for it to take effect.
1566    if (aPanel.hidden) {
1567      // If the panel is already hidden, we don't need to recreate it but flush
1568      // in case someone just hid it.
1569      aPanel.clientWidth; // flush
1570      return;
1571    }
1572    aPanel.hidden = true;
1573    aPanel.clientWidth; // flush
1574    aPanel.hidden = false;
1575  },
1576
1577  getConfiguration(aMessageManager, aWindow, aConfiguration, aCallbackID) {
1578    switch (aConfiguration) {
1579      case "appinfo":
1580        this.getAppInfo(aMessageManager, aWindow, aCallbackID);
1581        break;
1582      case "availableTargets":
1583        this.getAvailableTargets(aMessageManager, aWindow, aCallbackID);
1584        break;
1585      case "search":
1586      case "selectedSearchEngine":
1587        Services.search.init(rv => {
1588          let data;
1589          if (Components.isSuccessCode(rv)) {
1590            let engines = Services.search.getVisibleEngines();
1591            data = {
1592              searchEngineIdentifier: Services.search.defaultEngine.identifier,
1593              engines: engines.filter((engine) => engine.identifier)
1594                              .map((engine) => TARGET_SEARCHENGINE_PREFIX + engine.identifier)
1595            };
1596          } else {
1597            data = {engines: [], searchEngineIdentifier: ""};
1598          }
1599          this.sendPageCallback(aMessageManager, aCallbackID, data);
1600        });
1601        break;
1602      case "sync":
1603        this.sendPageCallback(aMessageManager, aCallbackID, {
1604          setup: Services.prefs.prefHasUserValue("services.sync.username"),
1605          desktopDevices: Services.prefs.getIntPref("services.sync.clients.devices.desktop", 0),
1606          mobileDevices: Services.prefs.getIntPref("services.sync.clients.devices.mobile", 0),
1607          totalDevices: Services.prefs.getIntPref("services.sync.numClients", 0),
1608        });
1609        break;
1610      case "canReset":
1611        this.sendPageCallback(aMessageManager, aCallbackID, ResetProfile.resetSupported());
1612        break;
1613      default:
1614        log.error("getConfiguration: Unknown configuration requested: " + aConfiguration);
1615        break;
1616    }
1617  },
1618
1619  setConfiguration(aWindow, aConfiguration, aValue) {
1620    switch (aConfiguration) {
1621      case "defaultBrowser":
1622        // Ignore aValue in this case because the default browser can only
1623        // be set, not unset.
1624        try {
1625          let shell = aWindow.getShellService();
1626          if (shell) {
1627            shell.setDefaultBrowser(true, false);
1628          }
1629        } catch (e) {}
1630        break;
1631      default:
1632        log.error("setConfiguration: Unknown configuration requested: " + aConfiguration);
1633        break;
1634    }
1635  },
1636
1637  getAppInfo(aMessageManager, aWindow, aCallbackID) {
1638    (async () => {
1639      let appinfo = {version: Services.appinfo.version};
1640
1641      // Identifier of the partner repack, as stored in preference "distribution.id"
1642      // and included in Firefox and other update pings. Note this is not the same as
1643      // Services.appinfo.distributionID (value of MOZ_DISTRIBUTION_ID is set at build time).
1644      let distribution =
1645          Services.prefs.getDefaultBranch("distribution.").getCharPref("id", "default");
1646      appinfo.distribution = distribution;
1647
1648      // Update channel, in a way that preserves 'beta' for RC beta builds:
1649      appinfo.defaultUpdateChannel = UpdateUtils.getUpdateChannel(false /* no partner ID */);
1650
1651      let isDefaultBrowser = null;
1652      try {
1653        let shell = aWindow.getShellService();
1654        if (shell) {
1655          isDefaultBrowser = shell.isDefaultBrowser(false);
1656        }
1657      } catch (e) {}
1658      appinfo.defaultBrowser = isDefaultBrowser;
1659
1660      let canSetDefaultBrowserInBackground = true;
1661      if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2") ||
1662          AppConstants.isPlatformAndVersionAtLeast("macosx", "10.10")) {
1663        canSetDefaultBrowserInBackground = false;
1664      } else if (AppConstants.platform == "linux") {
1665        // The ShellService may not exist on some versions of Linux.
1666        try {
1667          aWindow.getShellService();
1668        } catch (e) {
1669          canSetDefaultBrowserInBackground = null;
1670        }
1671      }
1672
1673      appinfo.canSetDefaultBrowserInBackground =
1674        canSetDefaultBrowserInBackground;
1675
1676      // Expose Profile creation and last reset dates in weeks.
1677      const ONE_WEEK = 7 * 24 * 60 * 60 * 1000;
1678      let profileAge = new ProfileAge(null, null);
1679      let createdDate = await profileAge.created;
1680      let resetDate = await profileAge.reset;
1681      let createdWeeksAgo = Math.floor((Date.now() - createdDate) / ONE_WEEK);
1682      let resetWeeksAgo = null;
1683      if (resetDate) {
1684        resetWeeksAgo = Math.floor((Date.now() - resetDate) / ONE_WEEK);
1685      }
1686      appinfo.profileCreatedWeeksAgo = createdWeeksAgo;
1687      appinfo.profileResetWeeksAgo = resetWeeksAgo;
1688
1689      this.sendPageCallback(aMessageManager, aCallbackID, appinfo);
1690    })().catch(err => {
1691      log.error(err);
1692      this.sendPageCallback(aMessageManager, aCallbackID, {});
1693    });
1694  },
1695
1696  getAvailableTargets(aMessageManager, aChromeWindow, aCallbackID) {
1697    (async () => {
1698      let window = aChromeWindow;
1699      let data = this.availableTargetsCache.get(window);
1700      if (data) {
1701        log.debug("getAvailableTargets: Using cached targets list", data.targets.join(","));
1702        this.sendPageCallback(aMessageManager, aCallbackID, data);
1703        return;
1704      }
1705
1706      let promises = [];
1707      for (let targetName of this.targets.keys()) {
1708        promises.push(this.getTarget(window, targetName));
1709      }
1710      let targetObjects = await Promise.all(promises);
1711
1712      let targetNames = [];
1713      for (let targetObject of targetObjects) {
1714        if (targetObject.node)
1715          targetNames.push(targetObject.targetName);
1716      }
1717
1718      data = {
1719        targets: targetNames,
1720      };
1721      this.availableTargetsCache.set(window, data);
1722      this.sendPageCallback(aMessageManager, aCallbackID, data);
1723    })().catch(err => {
1724      log.error(err);
1725      this.sendPageCallback(aMessageManager, aCallbackID, {
1726        targets: [],
1727      });
1728    });
1729  },
1730
1731  addNavBarWidget(aTarget, aMessageManager, aCallbackID) {
1732    if (aTarget.node) {
1733      log.error("addNavBarWidget: can't add a widget already present:", aTarget);
1734      return;
1735    }
1736    if (!aTarget.allowAdd) {
1737      log.error("addNavBarWidget: not allowed to add this widget:", aTarget);
1738      return;
1739    }
1740    if (!aTarget.widgetName) {
1741      log.error("addNavBarWidget: can't add a widget without a widgetName property:", aTarget);
1742      return;
1743    }
1744
1745    CustomizableUI.addWidgetToArea(aTarget.widgetName, CustomizableUI.AREA_NAVBAR);
1746    this.sendPageCallback(aMessageManager, aCallbackID);
1747  },
1748
1749  _addAnnotationPanelMutationObserver(aPanelEl) {
1750    if (AppConstants.platform == "linux") {
1751      let observer = this._annotationPanelMutationObservers.get(aPanelEl);
1752      if (observer) {
1753        return;
1754      }
1755      let win = aPanelEl.ownerGlobal;
1756      observer = new win.MutationObserver(this._annotationMutationCallback);
1757      this._annotationPanelMutationObservers.set(aPanelEl, observer);
1758      let observerOptions = {
1759        attributeFilter: ["height", "width"],
1760        attributes: true,
1761      };
1762      observer.observe(aPanelEl, observerOptions);
1763    }
1764  },
1765
1766  _removeAnnotationPanelMutationObserver(aPanelEl) {
1767    if (AppConstants.platform == "linux") {
1768      let observer = this._annotationPanelMutationObservers.get(aPanelEl);
1769      if (observer) {
1770        observer.disconnect();
1771        this._annotationPanelMutationObservers.delete(aPanelEl);
1772      }
1773    }
1774  },
1775
1776/**
1777 * Workaround for Ubuntu panel craziness in bug 970788 where incorrect sizes get passed to
1778 * nsXULPopupManager::PopupResized and lead to incorrect width and height attributes getting
1779 * set on the panel.
1780 */
1781  _annotationMutationCallback(aMutations) {
1782    for (let mutation of aMutations) {
1783      // Remove both attributes at once and ignore remaining mutations to be proccessed.
1784      mutation.target.removeAttribute("width");
1785      mutation.target.removeAttribute("height");
1786      return;
1787    }
1788  },
1789
1790  selectSearchEngine(aID) {
1791    return new Promise((resolve, reject) => {
1792      Services.search.init((rv) => {
1793        if (!Components.isSuccessCode(rv)) {
1794          reject("selectSearchEngine: search service init failed: " + rv);
1795          return;
1796        }
1797
1798        let engines = Services.search.getVisibleEngines();
1799        for (let engine of engines) {
1800          if (engine.identifier == aID) {
1801            Services.search.defaultEngine = engine;
1802            resolve();
1803            return;
1804          }
1805        }
1806        reject("selectSearchEngine could not find engine with given ID");
1807      });
1808    });
1809  },
1810
1811  notify(eventName, params) {
1812    let winEnum = Services.wm.getEnumerator("navigator:browser");
1813    while (winEnum.hasMoreElements()) {
1814      let window = winEnum.getNext();
1815      if (window.closed)
1816        continue;
1817
1818      let openTourBrowsers = this.tourBrowsersByWindow.get(window);
1819      if (!openTourBrowsers)
1820        continue;
1821
1822      for (let browser of openTourBrowsers) {
1823        let messageManager = browser.messageManager;
1824        if (!messageManager) {
1825          log.error("notify: Trying to notify a browser without a messageManager", browser);
1826          continue;
1827        }
1828        let detail = {
1829          event: eventName,
1830          params,
1831        };
1832        messageManager.sendAsyncMessage("UITour:SendPageNotification", detail);
1833      }
1834    }
1835  },
1836};
1837
1838function controlCenterTrackingToggleTarget(aUnblock) {
1839  return {
1840    infoPanelPosition: "rightcenter topleft",
1841    query(aDocument) {
1842      let popup = aDocument.defaultView.gIdentityHandler._identityPopup;
1843      if (popup.state != "open") {
1844        return null;
1845      }
1846      let buttonId = null;
1847      if (aUnblock) {
1848        if (PrivateBrowsingUtils.isWindowPrivate(aDocument.defaultView)) {
1849          buttonId = "tracking-action-unblock-private";
1850        } else {
1851          buttonId = "tracking-action-unblock";
1852        }
1853      } else {
1854        buttonId = "tracking-action-block";
1855      }
1856      let element = aDocument.getElementById(buttonId);
1857      return UITour.isElementVisible(element) ? element : null;
1858    },
1859  };
1860}
1861
1862this.UITour.init();
1863
1864/**
1865 * UITour Health Report
1866 */
1867/**
1868 * Public API to be called by the UITour code
1869 */
1870const UITourHealthReport = {
1871  recordTreatmentTag(tag, value) {
1872    return TelemetryController.submitExternalPing("uitour-tag",
1873      {
1874        version: 1,
1875        tagName: tag,
1876        tagValue: value,
1877      },
1878      {
1879        addClientId: true,
1880        addEnvironment: true,
1881      });
1882  }
1883};
1884