tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

UITour.sys.mjs (62987B)


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