tor-browser

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

ExtensionControlledPopup.sys.mjs (16282B)


      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 /**
      6 * @file
      7 * This module exports a class that can be used to handle displaying a popup
      8 * doorhanger with a primary action to not show a popup for this extension again
      9 * and a secondary action disables the addon, or brings the user to their settings.
     10 *
     11 * The original purpose of the popup was to notify users of an extension that has
     12 * changed the New Tab or homepage. Users would see this popup the first time they
     13 * view those pages after a change to the setting in each session until they confirm
     14 * the change by triggering the primary action.
     15 */
     16 
     17 import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs";
     18 
     19 const lazy = {};
     20 
     21 ChromeUtils.defineESModuleGetters(lazy, {
     22  AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
     23  BrowserUIUtils: "resource:///modules/BrowserUIUtils.sys.mjs",
     24  CustomizableUI:
     25    "moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs",
     26  ExtensionSettingsStore:
     27    "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
     28  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
     29 });
     30 
     31 let { makeWidgetId } = ExtensionCommon;
     32 
     33 ChromeUtils.defineLazyGetter(lazy, "strBundle", function () {
     34  return Services.strings.createBundle(
     35    "chrome://global/locale/extensions.properties"
     36  );
     37 });
     38 
     39 const PREF_BRANCH_INSTALLED_ADDON = "extensions.installedDistroAddon.";
     40 
     41 ChromeUtils.defineLazyGetter(lazy, "distributionAddonsList", function () {
     42  let addonList = Services.prefs
     43    .getChildList(PREF_BRANCH_INSTALLED_ADDON)
     44    .map(id => id.replace(PREF_BRANCH_INSTALLED_ADDON, ""));
     45  return new Set(addonList);
     46 });
     47 
     48 export class ExtensionControlledPopup {
     49  /**
     50   * Provide necessary options for the popup.
     51   *
     52   * @param {object} opts Options for configuring popup.
     53   * @param {string} opts.confirmedType
     54   *                 The type to use for storing a user's confirmation in
     55   *                 ExtensionSettingsStore.
     56   * @param {string} opts.observerTopic
     57   *                 An observer topic to trigger the popup on with Services.obs. If the
     58   *                 doorhanger should appear on a specific window include it as the
     59   *                 subject in the observer event.
     60   * @param {string} opts.popupnotificationId
     61   *                 The id for the popupnotification element in the markup. This
     62   *                 element should be defined in panelUI.inc.xhtml.
     63   * @param {string} opts.settingType
     64   *                 The setting type to check in ExtensionSettingsStore to retrieve
     65   *                 the controlling extension.
     66   * @param {string} opts.settingKey
     67   *                 The setting key to check in ExtensionSettingsStore to retrieve
     68   *                 the controlling extension.
     69   * @param {string} opts.descriptionId
     70   *                 The id of the element where the description should be displayed.
     71   * @param {string} opts.descriptionMessageId
     72   *                 The message id to be used for the description. The translated
     73   *                 string will have the add-on's name and icon injected into it.
     74   * @param {string} opts.getLocalizedDescription
     75   *                 A function to get the localized message string. This
     76   *                 function is passed doc, message and addonDetails (the
     77   *                 add-on's icon and name). If not provided, then the add-on's
     78   *                 icon and name are added to the description.
     79   * @param {string} opts.learnMoreLink
     80   *                 The name of the SUMO page to link to, this is added to
     81   *                 app.support.baseURL.
     82   * @param {string} [opts.preferencesLocation]
     83   *                 If included, the name of the preferences tab that will be opened
     84   *                 by the secondary action. If not included, the secondary option will
     85   *                 disable the addon.
     86   * @param {string} [opts.preferencesEntrypoint]
     87   *                 The entrypoint to pass to preferences telemetry.
     88   * @param {Function} opts.onObserverAdded
     89   *                   A callback that is triggered when an observer is registered to
     90   *                   trigger the popup on the next observerTopic.
     91   * @param {Function} opts.onObserverRemoved
     92   *                   A callback that is triggered when the observer is removed,
     93   *                   either because the popup is opening or it was explicitly
     94   *                   cancelled by calling removeObserver.
     95   * @param {Function} opts.beforeDisableAddon
     96   *                   A function that is called before disabling an extension when the
     97   *                   user decides to disable the extension. If this function is async
     98   *                   then the extension won't be disabled until it is fulfilled.
     99   *                   This function gets two arguments, the ExtensionControlledPopup
    100   *                   instance for the panel and the window that the popup appears on.
    101   */
    102  constructor(opts) {
    103    this.confirmedType = opts.confirmedType;
    104    this.observerTopic = opts.observerTopic;
    105    this.popupnotificationId = opts.popupnotificationId;
    106    this.settingType = opts.settingType;
    107    this.settingKey = opts.settingKey;
    108    this.descriptionId = opts.descriptionId;
    109    this.descriptionMessageId = opts.descriptionMessageId;
    110    this.getLocalizedDescription = opts.getLocalizedDescription;
    111    this.learnMoreLink = opts.learnMoreLink;
    112    this.preferencesLocation = opts.preferencesLocation;
    113    this.preferencesEntrypoint = opts.preferencesEntrypoint;
    114    this.onObserverAdded = opts.onObserverAdded;
    115    this.onObserverRemoved = opts.onObserverRemoved;
    116    this.beforeDisableAddon = opts.beforeDisableAddon;
    117    this.observerRegistered = false;
    118  }
    119 
    120  get topWindow() {
    121    return Services.wm.getMostRecentWindow("navigator:browser");
    122  }
    123 
    124  userHasConfirmed(id) {
    125    // We don't show a doorhanger for distribution installed add-ons.
    126    if (lazy.distributionAddonsList.has(id)) {
    127      return true;
    128    }
    129    let setting = lazy.ExtensionSettingsStore.getSetting(
    130      this.confirmedType,
    131      id
    132    );
    133    return !!(setting && setting.value);
    134  }
    135 
    136  async setConfirmation(id) {
    137    await lazy.ExtensionSettingsStore.initialize();
    138    return lazy.ExtensionSettingsStore.addSetting(
    139      id,
    140      this.confirmedType,
    141      id,
    142      true,
    143      () => false
    144    );
    145  }
    146 
    147  async clearConfirmation(id) {
    148    await lazy.ExtensionSettingsStore.initialize();
    149    return lazy.ExtensionSettingsStore.removeSetting(
    150      id,
    151      this.confirmedType,
    152      id
    153    );
    154  }
    155 
    156  observe(subject) {
    157    // Remove the observer here so we don't get multiple open() calls if we get
    158    // multiple observer events in quick succession.
    159    this.removeObserver();
    160 
    161    let targetWindow;
    162    // Some notifications (e.g. browser-open-newtab-start) do not have a window subject.
    163    if (subject && subject.document) {
    164      targetWindow = subject;
    165    }
    166 
    167    // Do this work in an idle callback to avoid interfering with new tab performance tracking.
    168    this.topWindow.requestIdleCallback(() => this.open(targetWindow));
    169  }
    170 
    171  removeObserver() {
    172    if (this.observerRegistered) {
    173      Services.obs.removeObserver(this, this.observerTopic);
    174      this.observerRegistered = false;
    175      if (this.onObserverRemoved) {
    176        this.onObserverRemoved();
    177      }
    178    }
    179  }
    180 
    181  async addObserver(extensionId) {
    182    await lazy.ExtensionSettingsStore.initialize();
    183 
    184    if (!this.observerRegistered && !this.userHasConfirmed(extensionId)) {
    185      Services.obs.addObserver(this, this.observerTopic);
    186      this.observerRegistered = true;
    187      if (this.onObserverAdded) {
    188        this.onObserverAdded();
    189      }
    190    }
    191  }
    192 
    193  // The extensionId will be looked up in ExtensionSettingsStore if it is not
    194  // provided using this.settingType and this.settingKey.
    195  async open(targetWindow, extensionId) {
    196    await lazy.ExtensionSettingsStore.initialize();
    197 
    198    // Remove the observer since it would open the same dialog again the next time
    199    // the observer event fires.
    200    this.removeObserver();
    201 
    202    if (!extensionId) {
    203      let item = lazy.ExtensionSettingsStore.getSetting(
    204        this.settingType,
    205        this.settingKey
    206      );
    207      extensionId = item && item.id;
    208    }
    209 
    210    let win = targetWindow || this.topWindow;
    211    let isPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(win);
    212    if (
    213      isPrivate &&
    214      extensionId &&
    215      !WebExtensionPolicy.getByID(extensionId).privateBrowsingAllowed
    216    ) {
    217      return;
    218    }
    219 
    220    // The item should have an extension and the user shouldn't have confirmed
    221    // the change here, but just to be sure check that it is still controlled
    222    // and the user hasn't already confirmed the change.
    223    // If there is no id, then the extension is no longer in control.
    224    if (!extensionId || this.userHasConfirmed(extensionId)) {
    225      return;
    226    }
    227 
    228    // If the window closes while waiting for focus, this might reject/throw,
    229    // and we should stop trying to show the popup.
    230    try {
    231      await this._ensureWindowReady(win);
    232    } catch (ex) {
    233      return;
    234    }
    235 
    236    // Find the elements we need.
    237    let doc = win.document;
    238    let panel = ExtensionControlledPopup._getAndMaybeCreatePanel(doc);
    239    let popupnotification = doc.getElementById(this.popupnotificationId);
    240    let urlBarWasFocused = win.gURLBar.focused;
    241 
    242    if (!popupnotification) {
    243      throw new Error(
    244        `No popupnotification found for id "${this.popupnotificationId}"`
    245      );
    246    }
    247 
    248    let elementsToTranslate = panel.querySelectorAll("[data-lazy-l10n-id]");
    249    if (elementsToTranslate.length) {
    250      win.MozXULElement.insertFTLIfNeeded("browser/appMenuNotifications.ftl");
    251      for (let el of elementsToTranslate) {
    252        el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id"));
    253        el.removeAttribute("data-lazy-l10n-id");
    254      }
    255      await win.document.l10n.translateFragment(panel);
    256    }
    257    let addon = await lazy.AddonManager.getAddonByID(extensionId);
    258    this.populateDescription(doc, addon);
    259 
    260    // Setup the buttoncommand handler.
    261    let handleButtonCommand = async event => {
    262      event.preventDefault();
    263      panel.hidePopup();
    264 
    265      // Main action is to keep changes.
    266      await this.setConfirmation(extensionId);
    267 
    268      // If the page this is appearing on is the New Tab page then the URL bar may
    269      // have been focused when the doorhanger stole focus away from it. Once an
    270      // action is taken the focus state should be restored to what the user was
    271      // expecting.
    272      if (urlBarWasFocused) {
    273        win.gURLBar.focus();
    274      }
    275    };
    276    let handleSecondaryButtonCommand = async event => {
    277      event.preventDefault();
    278      panel.hidePopup();
    279 
    280      if (this.preferencesLocation) {
    281        // Secondary action opens Preferences, if a preferencesLocation option is included.
    282        let options = this.Entrypoint
    283          ? { urlParams: { entrypoint: this.Entrypoint } }
    284          : {};
    285        win.openPreferences(this.preferencesLocation, options);
    286      } else {
    287        // Secondary action is to restore settings.
    288        if (this.beforeDisableAddon) {
    289          await this.beforeDisableAddon(this, win);
    290        }
    291        await addon.disable();
    292      }
    293 
    294      if (urlBarWasFocused) {
    295        win.gURLBar.focus();
    296      }
    297    };
    298    panel.addEventListener("buttoncommand", handleButtonCommand);
    299    panel.addEventListener(
    300      "secondarybuttoncommand",
    301      handleSecondaryButtonCommand
    302    );
    303    panel.addEventListener(
    304      "popuphidden",
    305      () => {
    306        popupnotification.hidden = true;
    307        panel.removeEventListener("buttoncommand", handleButtonCommand);
    308        panel.removeEventListener(
    309          "secondarybuttoncommand",
    310          handleSecondaryButtonCommand
    311        );
    312      },
    313      { once: true }
    314    );
    315 
    316    // Look for a browserAction on the toolbar.
    317    let action = lazy.CustomizableUI.getWidget(
    318      `${makeWidgetId(extensionId)}-browser-action`
    319    );
    320    if (action) {
    321      action =
    322        action.areaType == "toolbar" &&
    323        action
    324          .forWindow(win)
    325          .node.querySelector(".unified-extensions-item-action-button");
    326    }
    327 
    328    // Anchor to a toolbar browserAction if found, otherwise use the extensions
    329    // button.
    330    const anchor = action || doc.getElementById("unified-extensions-button");
    331 
    332    if (this.learnMoreLink) {
    333      const learnMoreURL =
    334        Services.urlFormatter.formatURLPref("app.support.baseURL") +
    335        this.learnMoreLink;
    336      popupnotification.setAttribute("learnmoreurl", learnMoreURL);
    337    } else {
    338      // In practice this isn't really needed because each of the
    339      // controlled popups use its own popupnotification instance
    340      // and they always have an learnMoreURL.
    341      popupnotification.removeAttribute("learnmoreurl");
    342    }
    343    popupnotification.show();
    344    if (anchor?.id == "unified-extensions-button") {
    345      const { gUnifiedExtensions } = anchor.ownerGlobal;
    346      gUnifiedExtensions.recordButtonTelemetry("extension_controlled_setting");
    347      gUnifiedExtensions.ensureButtonShownBeforeAttachingPanel(panel);
    348    }
    349    panel.openPopup(anchor);
    350  }
    351 
    352  getAddonDetails(doc, addon) {
    353    const defaultIcon = "chrome://mozapps/skin/extensions/extensionGeneric.svg";
    354 
    355    let image = doc.createXULElement("image");
    356    image.setAttribute("src", addon.iconURL || defaultIcon);
    357    image.classList.add("extension-controlled-icon");
    358 
    359    let addonDetails = doc.createDocumentFragment();
    360    addonDetails.appendChild(image);
    361    addonDetails.appendChild(doc.createTextNode(" " + addon.name));
    362 
    363    return addonDetails;
    364  }
    365 
    366  populateDescription(doc, addon) {
    367    let description = doc.getElementById(this.descriptionId);
    368    description.textContent = "";
    369 
    370    let addonDetails = this.getAddonDetails(doc, addon);
    371    let message = lazy.strBundle.GetStringFromName(this.descriptionMessageId);
    372    if (this.getLocalizedDescription) {
    373      description.appendChild(
    374        this.getLocalizedDescription(doc, message, addonDetails)
    375      );
    376    } else {
    377      description.appendChild(
    378        lazy.BrowserUIUtils.getLocalizedFragment(doc, message, addonDetails)
    379      );
    380    }
    381  }
    382 
    383  async _ensureWindowReady(win) {
    384    if (win.closed) {
    385      throw new Error("window is closed");
    386    }
    387    let promises = [];
    388    let listenersToRemove = [];
    389    function promiseEvent(type) {
    390      promises.push(
    391        new Promise(resolve => {
    392          let listener = () => {
    393            win.removeEventListener(type, listener);
    394            resolve();
    395          };
    396          win.addEventListener(type, listener);
    397          listenersToRemove.push([type, listener]);
    398        })
    399      );
    400    }
    401    let { focusedWindow, activeWindow } = Services.focus;
    402    if (activeWindow != win) {
    403      promiseEvent("activate");
    404    }
    405    if (focusedWindow) {
    406      // We may have focused a non-remote child window, find the browser window:
    407      let { rootTreeItem } = focusedWindow.docShell;
    408      rootTreeItem.QueryInterface(Ci.nsIDocShell);
    409      focusedWindow = rootTreeItem.docViewer.DOMDocument.defaultView;
    410    }
    411    if (focusedWindow != win) {
    412      promiseEvent("focus");
    413    }
    414    if (promises.length) {
    415      let unloadListener;
    416      let unloadPromise = new Promise((resolve, reject) => {
    417        unloadListener = () => {
    418          for (let [type, listener] of listenersToRemove) {
    419            win.removeEventListener(type, listener);
    420          }
    421          reject(new Error("window unloaded"));
    422        };
    423        win.addEventListener("unload", unloadListener, { once: true });
    424      });
    425      try {
    426        let allPromises = Promise.all(promises);
    427        await Promise.race([allPromises, unloadPromise]);
    428      } finally {
    429        win.removeEventListener("unload", unloadListener);
    430      }
    431    }
    432  }
    433 
    434  static _getAndMaybeCreatePanel(doc) {
    435    // // Lazy load the extension-notification panel the first time we need to display it.
    436    let template = doc.getElementById("extensionNotificationTemplate");
    437    if (template) {
    438      template.replaceWith(template.content);
    439    }
    440 
    441    return doc.getElementById("extension-notification-panel");
    442  }
    443 }