tor-browser

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

ExtensionsUI.sys.mjs (27958B)


      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 import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
      7 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      8 
      9 const lazy = {};
     10 
     11 ChromeUtils.defineESModuleGetters(lazy, {
     12  AMBrowserExtensionsImport: "resource://gre/modules/AddonManager.sys.mjs",
     13  AMTelemetry: "resource://gre/modules/AddonManager.sys.mjs",
     14  AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
     15  AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
     16  AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs",
     17  ExtensionData: "resource://gre/modules/Extension.sys.mjs",
     18  ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
     19  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
     20  OriginControls: "resource://gre/modules/ExtensionPermissions.sys.mjs",
     21  QuarantinedDomains: "resource://gre/modules/ExtensionPermissions.sys.mjs",
     22 });
     23 
     24 ChromeUtils.defineLazyGetter(
     25  lazy,
     26  "l10n",
     27  () =>
     28    new Localization(["browser/extensionsUI.ftl", "branding/brand.ftl"], true)
     29 );
     30 
     31 ChromeUtils.defineLazyGetter(lazy, "logConsole", () =>
     32  console.createInstance({
     33    prefix: "ExtensionsUI",
     34    maxLogLevelPref: "extensions.webextensions.log.level",
     35  })
     36 );
     37 
     38 XPCOMUtils.defineLazyPreferenceGetter(
     39  lazy,
     40  "dataCollectionPermissionsEnabled",
     41  "extensions.dataCollectionPermissions.enabled",
     42  false
     43 );
     44 
     45 const DEFAULT_EXTENSION_ICON =
     46  "chrome://mozapps/skin/extensions/extensionGeneric.svg";
     47 
     48 function getTabBrowser(browser) {
     49  while (browser.ownerGlobal.docShell.itemType !== Ci.nsIDocShell.typeChrome) {
     50    browser = browser.ownerGlobal.docShell.chromeEventHandler;
     51  }
     52  let window = browser.ownerGlobal;
     53  let viewType = browser.getAttribute("webextension-view-type");
     54  if (viewType == "sidebar") {
     55    window = window.browsingContext.topChromeWindow;
     56  }
     57  if (viewType == "popup" || viewType == "sidebar") {
     58    browser = window.gBrowser.selectedBrowser;
     59  }
     60  return { browser, window };
     61 }
     62 
     63 export var ExtensionsUI = {
     64  sideloaded: new Set(),
     65  updates: new Set(),
     66  sideloadListener: null,
     67 
     68  pendingNotifications: new WeakMap(),
     69 
     70  async init() {
     71    Services.obs.addObserver(this, "webextension-permission-prompt");
     72    Services.obs.addObserver(this, "webextension-update-permission-prompt");
     73    Services.obs.addObserver(this, "webextension-install-notify");
     74    Services.obs.addObserver(this, "webextension-optional-permission-prompt");
     75    Services.obs.addObserver(this, "webextension-defaultsearch-prompt");
     76    Services.obs.addObserver(this, "webextension-imported-addons-cancelled");
     77    Services.obs.addObserver(this, "webextension-imported-addons-complete");
     78    Services.obs.addObserver(this, "webextension-imported-addons-pending");
     79 
     80    await Services.wm.getMostRecentWindow("navigator:browser")
     81      .delayedStartupPromise;
     82 
     83    this._checkForSideloaded();
     84  },
     85 
     86  async _checkForSideloaded() {
     87    let sideloaded = await lazy.AddonManagerPrivate.getNewSideloads();
     88 
     89    if (!sideloaded.length) {
     90      // No new side-loads. We're done.
     91      return;
     92    }
     93 
     94    // The ordering shouldn't matter, but tests depend on notifications
     95    // happening in a specific order.
     96    sideloaded.sort((a, b) => a.id.localeCompare(b.id));
     97 
     98    if (!this.sideloadListener) {
     99      this.sideloadListener = {
    100        onEnabled: addon => {
    101          if (!this.sideloaded.has(addon)) {
    102            return;
    103          }
    104 
    105          this.sideloaded.delete(addon);
    106          this._updateNotifications();
    107 
    108          if (this.sideloaded.size == 0) {
    109            lazy.AddonManager.removeAddonListener(this.sideloadListener);
    110            this.sideloadListener = null;
    111          }
    112        },
    113      };
    114      lazy.AddonManager.addAddonListener(this.sideloadListener);
    115    }
    116 
    117    for (let addon of sideloaded) {
    118      this.sideloaded.add(addon);
    119    }
    120    this._updateNotifications();
    121  },
    122 
    123  _updateNotifications() {
    124    const { sideloaded, updates } = this;
    125    const { importedAddonIDs } = lazy.AMBrowserExtensionsImport;
    126 
    127    if (importedAddonIDs.length + sideloaded.size + updates.size == 0) {
    128      lazy.AppMenuNotifications.removeNotification("addon-alert");
    129    } else {
    130      lazy.AppMenuNotifications.showBadgeOnlyNotification("addon-alert");
    131    }
    132    this.emit("change");
    133  },
    134 
    135  showAddonsManager(
    136    tabbrowser,
    137    strings,
    138    icon,
    139    {
    140      addon = undefined,
    141      shouldShowIncognitoCheckbox = false,
    142      shouldShowTechnicalAndInteractionCheckbox = false,
    143    } = {}
    144  ) {
    145    let global = tabbrowser.selectedBrowser.ownerGlobal;
    146    return global.BrowserAddonUI.openAddonsMgr("addons://list/extension").then(
    147      aomWin => {
    148        let aomBrowser = aomWin.docShell.chromeEventHandler;
    149        return this.showPermissionsPrompt(aomBrowser, strings, icon, {
    150          addon,
    151          shouldShowIncognitoCheckbox,
    152          shouldShowTechnicalAndInteractionCheckbox,
    153        });
    154      }
    155    );
    156  },
    157 
    158  showSideloaded(tabbrowser, addon) {
    159    addon.markAsSeen();
    160    this.sideloaded.delete(addon);
    161    this._updateNotifications();
    162 
    163    let strings = this._buildStrings({
    164      addon,
    165      permissions: addon.installPermissions,
    166      type: "sideload",
    167    });
    168 
    169    lazy.AMTelemetry.recordManageEvent(addon, "sideload_prompt", {
    170      num_strings: strings.msgs.length,
    171    });
    172 
    173    this.showAddonsManager(tabbrowser, strings, addon.iconURL, {
    174      addon,
    175      shouldShowIncognitoCheckbox: true,
    176      shouldShowTechnicalAndInteractionCheckbox:
    177        lazy.dataCollectionPermissionsEnabled,
    178    }).then(async answer => {
    179      if (answer) {
    180        await addon.enable();
    181 
    182        this._updateNotifications();
    183      }
    184      this.emit("sideload-response");
    185    });
    186  },
    187 
    188  showUpdate(browser, info) {
    189    lazy.AMTelemetry.recordInstallEvent(info.install, {
    190      step: "permissions_prompt",
    191      num_strings: info.strings.msgs.length,
    192    });
    193 
    194    this.showAddonsManager(browser, info.strings, info.addon.iconURL).then(
    195      answer => {
    196        if (answer) {
    197          info.resolve();
    198        } else {
    199          info.reject();
    200        }
    201        // At the moment, this prompt will re-appear next time we do an update
    202        // check.  See bug 1332360 for proposal to avoid this.
    203        this.updates.delete(info);
    204        this._updateNotifications();
    205      }
    206    );
    207  },
    208 
    209  observe(subject, topic) {
    210    if (topic == "webextension-permission-prompt") {
    211      let { target, info } = subject.wrappedJSObject;
    212 
    213      let { browser, window } = getTabBrowser(target);
    214 
    215      // Dismiss the progress notification.  Note that this is bad if
    216      // there are multiple simultaneous installs happening, see
    217      // bug 1329884 for a longer explanation.
    218      let progressNotification = window.PopupNotifications.getNotification(
    219        "addon-progress",
    220        browser
    221      );
    222      if (progressNotification) {
    223        progressNotification.remove();
    224      }
    225 
    226      info.unsigned =
    227        info.addon.signedState <= lazy.AddonManager.SIGNEDSTATE_MISSING;
    228      // In local builds (or automation), when this pref is set, pretend the
    229      // file is correctly signed even if it isn't so that the UI looks like
    230      // what users would normally see.
    231      if (
    232        info.unsigned &&
    233        (Cu.isInAutomation || !AppConstants.MOZILLA_OFFICIAL) &&
    234        Services.prefs.getBoolPref(
    235          "extensions.ui.disableUnsignedWarnings",
    236          false
    237        )
    238      ) {
    239        info.unsigned = false;
    240        lazy.logConsole.warn(
    241          `Add-on ${info.addon.id} is unsigned (${info.addon.signedState}), pretending that it *is* signed because of the extensions.ui.disableUnsignedWarnings pref.`
    242        );
    243      }
    244 
    245      let strings = this._buildStrings(info);
    246 
    247      // If this is an update with no promptable permissions, just apply it
    248      if (
    249        info.type == "update" &&
    250        !strings.msgs.length &&
    251        !strings.dataCollectionPermissions?.msg
    252      ) {
    253        info.resolve();
    254        return;
    255      }
    256 
    257      let icon = info.unsigned
    258        ? "chrome://global/skin/icons/warning.svg"
    259        : info.icon;
    260 
    261      if (info.type == "sideload") {
    262        lazy.AMTelemetry.recordManageEvent(info.addon, "sideload_prompt", {
    263          num_strings: strings.msgs.length,
    264        });
    265      } else {
    266        lazy.AMTelemetry.recordInstallEvent(info.install, {
    267          step: "permissions_prompt",
    268          num_strings: strings.msgs.length,
    269        });
    270      }
    271 
    272      // We don't want to show the incognito checkbox in the update prompt or
    273      // optional prompt (which shouldn't be possible in this case), but it's
    274      // fine for installs (including sideload).
    275      const isInstallDialog = !info.type || info.type === "sideload";
    276      const shouldShowIncognitoCheckbox = isInstallDialog;
    277      // Same for the data collection checkbox.
    278      const shouldShowTechnicalAndInteractionCheckbox =
    279        lazy.dataCollectionPermissionsEnabled && isInstallDialog;
    280 
    281      this.showPermissionsPrompt(browser, strings, icon, {
    282        addon: info.addon,
    283        shouldShowIncognitoCheckbox,
    284        shouldShowTechnicalAndInteractionCheckbox,
    285      }).then(answer => {
    286        if (answer) {
    287          info.resolve();
    288        } else {
    289          info.reject();
    290        }
    291      });
    292    } else if (topic == "webextension-update-permission-prompt") {
    293      let info = subject.wrappedJSObject;
    294      info.type = "update";
    295      let strings = this._buildStrings(info);
    296 
    297      // If we don't prompt for any new permissions, just apply it
    298      if (!strings.msgs.length && !strings.dataCollectionPermissions?.msg) {
    299        info.resolve();
    300        return;
    301      }
    302 
    303      let update = {
    304        strings,
    305        permissions: info.permissions,
    306        install: info.install,
    307        addon: info.addon,
    308        resolve: info.resolve,
    309        reject: info.reject,
    310      };
    311 
    312      this.updates.add(update);
    313      this._updateNotifications();
    314    } else if (topic == "webextension-install-notify") {
    315      let { target, addon, callback } = subject.wrappedJSObject;
    316      this.showInstallNotification(target, addon).then(() => {
    317        if (callback) {
    318          callback();
    319        }
    320      });
    321    } else if (topic == "webextension-optional-permission-prompt") {
    322      let { browser, name, icon, permissions, resolve } =
    323        subject.wrappedJSObject;
    324      let strings = this._buildStrings({
    325        type: "optional",
    326        addon: { name },
    327        permissions,
    328      });
    329 
    330      // If we don't have any promptable permissions, just proceed
    331      if (!strings.msgs.length && !strings.dataCollectionPermissions?.msg) {
    332        resolve(true);
    333        return;
    334      }
    335      // "userScripts" is an OptionalOnlyPermission, which means that it can
    336      // only be requested through the permissions.request() API, without other
    337      // permissions in the same request.
    338      let isUserScriptsRequest =
    339        permissions.permissions.length === 1 &&
    340        permissions.permissions[0] === "userScripts";
    341      resolve(
    342        this.showPermissionsPrompt(browser, strings, icon, {
    343          shouldShowIncognitoCheckbox: false,
    344          shouldShowTechnicalAndInteractionCheckbox: false,
    345          isUserScriptsRequest,
    346        })
    347      );
    348    } else if (topic == "webextension-defaultsearch-prompt") {
    349      let { browser, name, icon, respond, currentEngine, newEngine } =
    350        subject.wrappedJSObject;
    351 
    352      const [searchDesc, searchYes, searchNo] = lazy.l10n.formatMessagesSync([
    353        {
    354          id: "webext-default-search-description",
    355          args: { addonName: "<>", currentEngine, newEngine },
    356        },
    357        "webext-default-search-yes",
    358        "webext-default-search-no",
    359      ]);
    360 
    361      const strings = { addonName: name, text: searchDesc.value };
    362      for (let attr of searchYes.attributes) {
    363        if (attr.name === "label") {
    364          strings.acceptText = attr.value;
    365        } else if (attr.name === "accesskey") {
    366          strings.acceptKey = attr.value;
    367        }
    368      }
    369      for (let attr of searchNo.attributes) {
    370        if (attr.name === "label") {
    371          strings.cancelText = attr.value;
    372        } else if (attr.name === "accesskey") {
    373          strings.cancelKey = attr.value;
    374        }
    375      }
    376 
    377      this.showDefaultSearchPrompt(browser, strings, icon).then(respond);
    378    } else if (
    379      [
    380        "webextension-imported-addons-cancelled",
    381        "webextension-imported-addons-complete",
    382        "webextension-imported-addons-pending",
    383      ].includes(topic)
    384    ) {
    385      this._updateNotifications();
    386    }
    387  },
    388 
    389  // Create a set of formatted strings for a permission prompt
    390  _buildStrings(info) {
    391    const strings = lazy.ExtensionData.formatPermissionStrings(info, {
    392      fullDomainsList: true,
    393    });
    394    strings.addonName = info.addon.name;
    395    return strings;
    396  },
    397 
    398  async showPermissionsPrompt(
    399    target,
    400    strings,
    401    icon,
    402    {
    403      addon = undefined,
    404      shouldShowIncognitoCheckbox = false,
    405      shouldShowTechnicalAndInteractionCheckbox = false,
    406      isUserScriptsRequest = false,
    407    } = {}
    408  ) {
    409    let { browser, window } = getTabBrowser(target);
    410 
    411    let showIncognitoCheckbox = shouldShowIncognitoCheckbox;
    412    if (showIncognitoCheckbox) {
    413      showIncognitoCheckbox = !!(
    414        addon.permissions &
    415        lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
    416      );
    417    }
    418 
    419    let showTechnicalAndInteractionCheckbox =
    420      shouldShowTechnicalAndInteractionCheckbox &&
    421      !!strings.dataCollectionPermissions?.collectsTechnicalAndInteractionData;
    422 
    423    const incognitoPermissionName = "internal:privateBrowsingAllowed";
    424    let grantPrivateBrowsingAllowed =
    425      lazy.PrivateBrowsingUtils.permanentPrivateBrowsing;
    426    if (
    427      showIncognitoCheckbox &&
    428      // Usually false, unless the user tries to install a XPI file whose ID
    429      // matches an already-installed add-on.
    430      (await lazy.AddonManager.getAddonByID(addon.id))
    431    ) {
    432      let { permissions } = await lazy.ExtensionPermissions.get(addon.id);
    433      grantPrivateBrowsingAllowed = permissions.includes(
    434        incognitoPermissionName
    435      );
    436    }
    437 
    438    const technicalAndInteractionDataName = "technicalAndInteraction";
    439    // This is an opt-out setting.
    440    let grantTechnicalAndInteractionDataCollection = true;
    441 
    442    // Wait for any pending prompts to complete before showing the next one.
    443    let pending;
    444    while ((pending = this.pendingNotifications.get(browser))) {
    445      await pending;
    446    }
    447 
    448    let promise = new Promise(resolve => {
    449      function eventCallback(topic) {
    450        if (topic == "swapping") {
    451          return true;
    452        }
    453        if (topic == "removed") {
    454          Services.tm.dispatchToMainThread(() => {
    455            resolve(false);
    456          });
    457        }
    458        return false;
    459      }
    460 
    461      // Show the SUMO link already part of the popupnotification by setting
    462      // learnMoreURL option if there are permissions to be granted to the
    463      // addon being installed, or if the private browsing checkbox is shown,
    464      // or if the data collection checkbox is shown.
    465      const learnMoreURL =
    466        strings.msgs.length ||
    467        strings.dataCollectionPermissions?.msg ||
    468        showIncognitoCheckbox ||
    469        showTechnicalAndInteractionCheckbox
    470          ? Services.urlFormatter.formatURLPref("app.support.baseURL") +
    471            "extension-permissions"
    472          : undefined;
    473 
    474      let options = {
    475        hideClose: true,
    476        popupIconURL: icon || DEFAULT_EXTENSION_ICON,
    477        popupIconClass: icon ? "" : "addon-warning-icon",
    478        learnMoreURL,
    479        persistent: true,
    480        eventCallback,
    481        removeOnDismissal: true,
    482        popupOptions: {
    483          position: "bottomright topright",
    484        },
    485        // Pass additional options used internally by the
    486        // addon-webext-permissions-notification custom element
    487        // (defined and registered by browser-addons.js).
    488        customElementOptions: {
    489          strings,
    490          showIncognitoCheckbox,
    491          grantPrivateBrowsingAllowed,
    492          onPrivateBrowsingAllowedChanged(value) {
    493            grantPrivateBrowsingAllowed = value;
    494          },
    495          showTechnicalAndInteractionCheckbox,
    496          grantTechnicalAndInteractionDataCollection,
    497          onTechnicalAndInteractionDataChanged(value) {
    498            grantTechnicalAndInteractionDataCollection = value;
    499          },
    500          isUserScriptsRequest,
    501        },
    502      };
    503      // The prompt/notification machinery has a special affordance wherein
    504      // certain subsets of the header string can be designated "names", and
    505      // referenced symbolically as "<>" and "{}" to receive special formatting.
    506      // That code assumes that the existence of |name| and |secondName| in the
    507      // options object imply the presence of "<>" and "{}" (respectively) in
    508      // in the string.
    509      //
    510      // At present, WebExtensions use this affordance while SitePermission
    511      // add-ons don't, so we need to conditionally set the |name| field.
    512      //
    513      // NB: This could potentially be cleaned up, see bug 1799710.
    514      if (strings.header.includes("<>")) {
    515        options.name = strings.addonName;
    516      }
    517 
    518      let action = {
    519        label: strings.acceptText,
    520        accessKey: strings.acceptKey,
    521        callback: () => {
    522          resolve(true);
    523        },
    524      };
    525      let secondaryActions = [
    526        {
    527          label: strings.cancelText,
    528          accessKey: strings.cancelKey,
    529          callback: () => {
    530            resolve(false);
    531          },
    532        },
    533      ];
    534 
    535      window.PopupNotifications.show(
    536        browser,
    537        "addon-webext-permissions",
    538        strings.header,
    539        browser.ownerGlobal.gUnifiedExtensions.getPopupAnchorID(
    540          browser,
    541          window
    542        ),
    543        action,
    544        secondaryActions,
    545        options
    546      );
    547    });
    548 
    549    this.pendingNotifications.set(browser, promise);
    550    promise.finally(() => this.pendingNotifications.delete(browser));
    551    // NOTE: this method is also called from showQuarantineConfirmation and some of its
    552    // related test cases (from browser_ext_originControls.js) seem to be hitting a race
    553    // if the promise returned requires an additional tick to be resolved.
    554    // Look more into the failure and determine a better option to avoid those failures.
    555    if (!showIncognitoCheckbox && !showTechnicalAndInteractionCheckbox) {
    556      return promise;
    557    }
    558 
    559    return promise.then(continueInstall => {
    560      if (!continueInstall) {
    561        return continueInstall;
    562      }
    563 
    564      const permsToUpdate = [];
    565      if (showIncognitoCheckbox) {
    566        permsToUpdate.push([
    567          incognitoPermissionName,
    568          "permissions",
    569          grantPrivateBrowsingAllowed,
    570        ]);
    571      }
    572      if (showTechnicalAndInteractionCheckbox) {
    573        permsToUpdate.push([
    574          technicalAndInteractionDataName,
    575          "data_collection",
    576          grantTechnicalAndInteractionDataCollection,
    577        ]);
    578      }
    579      // We need two update promises because the checkboxes are independent
    580      // from each other, and one can add its permission while the other could
    581      // remove its corresponding permission.
    582      const promises = permsToUpdate.map(([name, key, value]) => {
    583        const perms = { permissions: [], origins: [], data_collection: [] };
    584        perms[key] = [name];
    585 
    586        if (value) {
    587          return lazy.ExtensionPermissions.add(addon.id, perms).catch(err =>
    588            lazy.logConsole.warn(
    589              `Error on adding "${name}" permission to addon id "${addon.id}`,
    590              err
    591            )
    592          );
    593        }
    594        return lazy.ExtensionPermissions.remove(addon.id, perms).catch(err =>
    595          lazy.logConsole.warn(
    596            `Error on removing "${name}" permission from addon id "${addon.id}`,
    597            err
    598          )
    599        );
    600      });
    601      return Promise.all(promises).then(() => continueInstall);
    602    });
    603  },
    604 
    605  showDefaultSearchPrompt(target, strings, icon) {
    606    return new Promise(resolve => {
    607      let options = {
    608        hideClose: true,
    609        popupIconURL: icon || DEFAULT_EXTENSION_ICON,
    610        persistent: true,
    611        removeOnDismissal: true,
    612        eventCallback(topic) {
    613          if (topic == "removed") {
    614            resolve(false);
    615          }
    616        },
    617        name: strings.addonName,
    618      };
    619 
    620      let action = {
    621        label: strings.acceptText,
    622        accessKey: strings.acceptKey,
    623        callback: () => {
    624          resolve(true);
    625        },
    626      };
    627      let secondaryActions = [
    628        {
    629          label: strings.cancelText,
    630          accessKey: strings.cancelKey,
    631          callback: () => {
    632            resolve(false);
    633          },
    634        },
    635      ];
    636 
    637      let { browser, window } = getTabBrowser(target);
    638 
    639      window.PopupNotifications.show(
    640        browser,
    641        "addon-webext-defaultsearch",
    642        strings.text,
    643        "addons-notification-icon",
    644        action,
    645        secondaryActions,
    646        options
    647      );
    648    });
    649  },
    650 
    651  async showInstallNotification(target, addon) {
    652    let { window } = getTabBrowser(target);
    653 
    654    const message = await lazy.l10n.formatValue("addon-post-install-message", {
    655      addonName: "<>",
    656    });
    657 
    658    return new Promise(resolve => {
    659      let icon = addon.isWebExtension
    660        ? lazy.AddonManager.getPreferredIconURL(addon, 32, window) ||
    661          DEFAULT_EXTENSION_ICON
    662        : "chrome://browser/skin/addons/addon-install-installed.svg";
    663 
    664      if (addon.type == "theme") {
    665        const { previousActiveThemeID } = addon;
    666 
    667        async function themeActionUndo() {
    668          try {
    669            // Undoing a theme install means re-enabling the previous active theme
    670            // ID, and uninstalling the theme that was just installed
    671            const theme = await lazy.AddonManager.getAddonByID(
    672              previousActiveThemeID
    673            );
    674 
    675            if (theme) {
    676              await theme.enable();
    677            }
    678 
    679            // `addon` is the theme that was just installed
    680            await addon.uninstall();
    681          } finally {
    682            resolve();
    683          }
    684        }
    685 
    686        let themePrimaryAction = { callback: resolve };
    687 
    688        // Show the undo button if previousActiveThemeID is set.
    689        let themeSecondaryAction = previousActiveThemeID
    690          ? { callback: themeActionUndo }
    691          : null;
    692 
    693        let options = {
    694          name: addon.name,
    695          message,
    696          popupIconURL: icon,
    697          onDismissed: () => {
    698            lazy.AppMenuNotifications.removeNotification("theme-installed");
    699            resolve();
    700          },
    701        };
    702        lazy.AppMenuNotifications.showNotification(
    703          "theme-installed",
    704          themePrimaryAction,
    705          themeSecondaryAction,
    706          options
    707        );
    708      } else {
    709        let action = {
    710          callback: resolve,
    711        };
    712 
    713        let options = {
    714          name: addon.name,
    715          message,
    716          popupIconURL: icon,
    717          onDismissed: () => {
    718            lazy.AppMenuNotifications.removeNotification("addon-installed");
    719            resolve();
    720          },
    721          customElementOptions: {
    722            addonId: addon.id,
    723          },
    724        };
    725        lazy.AppMenuNotifications.showNotification(
    726          "addon-installed",
    727          action,
    728          null,
    729          options
    730        );
    731      }
    732    });
    733  },
    734 
    735  async showQuarantineConfirmation(browser, policy) {
    736    let [title, line1, line2, allow, deny] = await lazy.l10n.formatMessages([
    737      {
    738        id: "webext-quarantine-confirmation-title",
    739        args: { addonName: "<>" },
    740      },
    741      "webext-quarantine-confirmation-line-1",
    742      "webext-quarantine-confirmation-line-2",
    743      "webext-quarantine-confirmation-allow",
    744      "webext-quarantine-confirmation-deny",
    745    ]);
    746 
    747    let attr = (msg, name) => msg.attributes.find(a => a.name === name)?.value;
    748 
    749    let strings = {
    750      addonName: policy.name,
    751      header: title.value,
    752      text: line1.value + "\n\n" + line2.value,
    753      msgs: [],
    754      acceptText: attr(allow, "label"),
    755      acceptKey: attr(allow, "accesskey"),
    756      cancelText: attr(deny, "label"),
    757      cancelKey: attr(deny, "accesskey"),
    758    };
    759 
    760    let icon = policy.extension?.getPreferredIcon(32);
    761 
    762    if (await ExtensionsUI.showPermissionsPrompt(browser, strings, icon)) {
    763      lazy.QuarantinedDomains.setUserAllowedAddonIdPref(policy.id, true);
    764    }
    765  },
    766 
    767  // Populate extension toolbar popup menu with origin controls.
    768  originControlsMenu(popup, extensionId) {
    769    let policy = WebExtensionPolicy.getByID(extensionId);
    770 
    771    let win = popup.ownerGlobal;
    772    let doc = popup.ownerDocument;
    773    let tab = win.gBrowser.selectedTab;
    774    let uri = tab.linkedBrowser?.currentURI;
    775    let state = lazy.OriginControls.getState(policy, tab);
    776 
    777    let headerItem = doc.createXULElement("menuitem");
    778    headerItem.setAttribute("disabled", true);
    779    let items = [headerItem];
    780 
    781    // MV2 normally don't have controls, but we show the quarantined state.
    782    if (!policy?.extension.originControls && !state.quarantined) {
    783      return;
    784    }
    785 
    786    if (state.noAccess) {
    787      doc.l10n.setAttributes(headerItem, "origin-controls-no-access");
    788    } else {
    789      doc.l10n.setAttributes(headerItem, "origin-controls-options");
    790    }
    791 
    792    if (state.quarantined) {
    793      doc.l10n.setAttributes(headerItem, "origin-controls-quarantined-status");
    794 
    795      let allowQuarantined = doc.createXULElement("menuitem");
    796      doc.l10n.setAttributes(
    797        allowQuarantined,
    798        "origin-controls-quarantined-allow"
    799      );
    800      allowQuarantined.addEventListener("command", () => {
    801        this.showQuarantineConfirmation(tab.linkedBrowser, policy);
    802      });
    803      items.push(allowQuarantined);
    804    }
    805 
    806    if (state.allDomains) {
    807      let allDomains = doc.createXULElement("menuitem");
    808      allDomains.setAttribute("type", "radio");
    809      allDomains.toggleAttribute("checked", state.hasAccess);
    810      doc.l10n.setAttributes(allDomains, "origin-controls-option-all-domains");
    811      items.push(allDomains);
    812    }
    813 
    814    if (state.whenClicked) {
    815      let whenClicked = doc.createXULElement("menuitem");
    816      whenClicked.setAttribute("type", "radio");
    817      whenClicked.toggleAttribute("checked", !state.hasAccess);
    818      doc.l10n.setAttributes(
    819        whenClicked,
    820        "origin-controls-option-when-clicked"
    821      );
    822      whenClicked.addEventListener("command", async () => {
    823        await lazy.OriginControls.setWhenClicked(policy, uri);
    824        win.gUnifiedExtensions.updateAttention();
    825      });
    826      items.push(whenClicked);
    827    }
    828 
    829    if (state.alwaysOn) {
    830      let alwaysOn = doc.createXULElement("menuitem");
    831      alwaysOn.setAttribute("type", "radio");
    832      alwaysOn.toggleAttribute("checked", state.hasAccess);
    833      doc.l10n.setAttributes(alwaysOn, "origin-controls-option-always-on", {
    834        domain: uri.host,
    835      });
    836      alwaysOn.addEventListener("command", async () => {
    837        await lazy.OriginControls.setAlwaysOn(policy, uri);
    838        win.gUnifiedExtensions.updateAttention();
    839      });
    840      items.push(alwaysOn);
    841    }
    842 
    843    items.push(doc.createXULElement("menuseparator"));
    844 
    845    // Insert all items before Pin to toolbar OR Manage Extension, but after
    846    // any extension's menu items.
    847    let manageItem =
    848      popup.querySelector(".customize-context-manageExtension") ||
    849      popup.querySelector(".unified-extensions-context-menu-pin-to-toolbar");
    850    items.forEach(item => item && popup.insertBefore(item, manageItem));
    851 
    852    let cleanup = e => {
    853      if (e.target === popup) {
    854        items.forEach(item => item?.remove());
    855        popup.removeEventListener("popuphidden", cleanup);
    856      }
    857    };
    858    popup.addEventListener("popuphidden", cleanup);
    859  },
    860 };
    861 
    862 EventEmitter.decorate(ExtensionsUI);