mirror of
				https://github.com/mozilla/gecko-dev.git
				synced 2025-11-04 02:09:05 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			421 lines
		
	
	
	
		
			15 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			421 lines
		
	
	
	
		
			15 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
/* This Source Code Form is subject to the terms of the Mozilla Public
 | 
						|
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 | 
						|
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 | 
						|
 | 
						|
/*
 | 
						|
 * @fileOverview
 | 
						|
 * This module exports a class that can be used to handle displaying a popup
 | 
						|
 * doorhanger with a primary action to not show a popup for this extension again
 | 
						|
 * and a secondary action disables the addon, or brings the user to their settings.
 | 
						|
 *
 | 
						|
 * The original purpose of the popup was to notify users of an extension that has
 | 
						|
 * changed the New Tab or homepage. Users would see this popup the first time they
 | 
						|
 * view those pages after a change to the setting in each session until they confirm
 | 
						|
 * the change by triggering the primary action.
 | 
						|
 */
 | 
						|
 | 
						|
import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs";
 | 
						|
 | 
						|
const lazy = {};
 | 
						|
 | 
						|
ChromeUtils.defineESModuleGetters(lazy, {
 | 
						|
  AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
 | 
						|
  BrowserUIUtils: "resource:///modules/BrowserUIUtils.sys.mjs",
 | 
						|
  CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
 | 
						|
  ExtensionSettingsStore:
 | 
						|
    "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
 | 
						|
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
 | 
						|
});
 | 
						|
 | 
						|
let { makeWidgetId } = ExtensionCommon;
 | 
						|
 | 
						|
ChromeUtils.defineLazyGetter(lazy, "strBundle", function () {
 | 
						|
  return Services.strings.createBundle(
 | 
						|
    "chrome://global/locale/extensions.properties"
 | 
						|
  );
 | 
						|
});
 | 
						|
 | 
						|
const PREF_BRANCH_INSTALLED_ADDON = "extensions.installedDistroAddon.";
 | 
						|
 | 
						|
ChromeUtils.defineLazyGetter(lazy, "distributionAddonsList", function () {
 | 
						|
  let addonList = Services.prefs
 | 
						|
    .getChildList(PREF_BRANCH_INSTALLED_ADDON)
 | 
						|
    .map(id => id.replace(PREF_BRANCH_INSTALLED_ADDON, ""));
 | 
						|
  return new Set(addonList);
 | 
						|
});
 | 
						|
 | 
						|
export class ExtensionControlledPopup {
 | 
						|
  /* Provide necessary options for the popup.
 | 
						|
   *
 | 
						|
   * @param {object} opts Options for configuring popup.
 | 
						|
   * @param {string} opts.confirmedType
 | 
						|
   *                 The type to use for storing a user's confirmation in
 | 
						|
   *                 ExtensionSettingsStore.
 | 
						|
   * @param {string} opts.observerTopic
 | 
						|
   *                 An observer topic to trigger the popup on with Services.obs. If the
 | 
						|
   *                 doorhanger should appear on a specific window include it as the
 | 
						|
   *                 subject in the observer event.
 | 
						|
   * @param {string} opts.anchorId
 | 
						|
   *                 The id to anchor the popupnotification on. If it is not provided
 | 
						|
   *                 then it will anchor to a browser action or the app menu.
 | 
						|
   * @param {string} opts.popupnotificationId
 | 
						|
   *                 The id for the popupnotification element in the markup. This
 | 
						|
   *                 element should be defined in panelUI.inc.xhtml.
 | 
						|
   * @param {string} opts.settingType
 | 
						|
   *                 The setting type to check in ExtensionSettingsStore to retrieve
 | 
						|
   *                 the controlling extension.
 | 
						|
   * @param {string} opts.settingKey
 | 
						|
   *                 The setting key to check in ExtensionSettingsStore to retrieve
 | 
						|
   *                 the controlling extension.
 | 
						|
   * @param {string} opts.descriptionId
 | 
						|
   *                 The id of the element where the description should be displayed.
 | 
						|
   * @param {string} opts.descriptionMessageId
 | 
						|
   *                 The message id to be used for the description. The translated
 | 
						|
   *                 string will have the add-on's name and icon injected into it.
 | 
						|
   * @param {string} opts.getLocalizedDescription
 | 
						|
   *                 A function to get the localized message string. This
 | 
						|
   *                 function is passed doc, message and addonDetails (the
 | 
						|
   *                 add-on's icon and name). If not provided, then the add-on's
 | 
						|
   *                 icon and name are added to the description.
 | 
						|
   * @param {string} opts.learnMoreLink
 | 
						|
   *                 The name of the SUMO page to link to, this is added to
 | 
						|
   *                 app.support.baseURL.
 | 
						|
   * @param optional {string} opts.preferencesLocation
 | 
						|
   *                 If included, the name of the preferences tab that will be opened
 | 
						|
   *                 by the secondary action. If not included, the secondary option will
 | 
						|
   *                 disable the addon.
 | 
						|
   * @param optional {string} opts.preferencesEntrypoint
 | 
						|
   *                 The entrypoint to pass to preferences telemetry.
 | 
						|
   * @param {function} opts.onObserverAdded
 | 
						|
   *                   A callback that is triggered when an observer is registered to
 | 
						|
   *                   trigger the popup on the next observerTopic.
 | 
						|
   * @param {function} opts.onObserverRemoved
 | 
						|
   *                   A callback that is triggered when the observer is removed,
 | 
						|
   *                   either because the popup is opening or it was explicitly
 | 
						|
   *                   cancelled by calling removeObserver.
 | 
						|
   * @param {function} opts.beforeDisableAddon
 | 
						|
   *                   A function that is called before disabling an extension when the
 | 
						|
   *                   user decides to disable the extension. If this function is async
 | 
						|
   *                   then the extension won't be disabled until it is fulfilled.
 | 
						|
   *                   This function gets two arguments, the ExtensionControlledPopup
 | 
						|
   *                   instance for the panel and the window that the popup appears on.
 | 
						|
   */
 | 
						|
  constructor(opts) {
 | 
						|
    this.confirmedType = opts.confirmedType;
 | 
						|
    this.observerTopic = opts.observerTopic;
 | 
						|
    this.anchorId = opts.anchorId;
 | 
						|
    this.popupnotificationId = opts.popupnotificationId;
 | 
						|
    this.settingType = opts.settingType;
 | 
						|
    this.settingKey = opts.settingKey;
 | 
						|
    this.descriptionId = opts.descriptionId;
 | 
						|
    this.descriptionMessageId = opts.descriptionMessageId;
 | 
						|
    this.getLocalizedDescription = opts.getLocalizedDescription;
 | 
						|
    this.learnMoreLink = opts.learnMoreLink;
 | 
						|
    this.preferencesLocation = opts.preferencesLocation;
 | 
						|
    this.preferencesEntrypoint = opts.preferencesEntrypoint;
 | 
						|
    this.onObserverAdded = opts.onObserverAdded;
 | 
						|
    this.onObserverRemoved = opts.onObserverRemoved;
 | 
						|
    this.beforeDisableAddon = opts.beforeDisableAddon;
 | 
						|
    this.observerRegistered = false;
 | 
						|
  }
 | 
						|
 | 
						|
  get topWindow() {
 | 
						|
    return Services.wm.getMostRecentWindow("navigator:browser");
 | 
						|
  }
 | 
						|
 | 
						|
  userHasConfirmed(id) {
 | 
						|
    // We don't show a doorhanger for distribution installed add-ons.
 | 
						|
    if (lazy.distributionAddonsList.has(id)) {
 | 
						|
      return true;
 | 
						|
    }
 | 
						|
    let setting = lazy.ExtensionSettingsStore.getSetting(
 | 
						|
      this.confirmedType,
 | 
						|
      id
 | 
						|
    );
 | 
						|
    return !!(setting && setting.value);
 | 
						|
  }
 | 
						|
 | 
						|
  async setConfirmation(id) {
 | 
						|
    await lazy.ExtensionSettingsStore.initialize();
 | 
						|
    return lazy.ExtensionSettingsStore.addSetting(
 | 
						|
      id,
 | 
						|
      this.confirmedType,
 | 
						|
      id,
 | 
						|
      true,
 | 
						|
      () => false
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  async clearConfirmation(id) {
 | 
						|
    await lazy.ExtensionSettingsStore.initialize();
 | 
						|
    return lazy.ExtensionSettingsStore.removeSetting(
 | 
						|
      id,
 | 
						|
      this.confirmedType,
 | 
						|
      id
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  observe(subject) {
 | 
						|
    // Remove the observer here so we don't get multiple open() calls if we get
 | 
						|
    // multiple observer events in quick succession.
 | 
						|
    this.removeObserver();
 | 
						|
 | 
						|
    let targetWindow;
 | 
						|
    // Some notifications (e.g. browser-open-newtab-start) do not have a window subject.
 | 
						|
    if (subject && subject.document) {
 | 
						|
      targetWindow = subject;
 | 
						|
    }
 | 
						|
 | 
						|
    // Do this work in an idle callback to avoid interfering with new tab performance tracking.
 | 
						|
    this.topWindow.requestIdleCallback(() => this.open(targetWindow));
 | 
						|
  }
 | 
						|
 | 
						|
  removeObserver() {
 | 
						|
    if (this.observerRegistered) {
 | 
						|
      Services.obs.removeObserver(this, this.observerTopic);
 | 
						|
      this.observerRegistered = false;
 | 
						|
      if (this.onObserverRemoved) {
 | 
						|
        this.onObserverRemoved();
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  async addObserver(extensionId) {
 | 
						|
    await lazy.ExtensionSettingsStore.initialize();
 | 
						|
 | 
						|
    if (!this.observerRegistered && !this.userHasConfirmed(extensionId)) {
 | 
						|
      Services.obs.addObserver(this, this.observerTopic);
 | 
						|
      this.observerRegistered = true;
 | 
						|
      if (this.onObserverAdded) {
 | 
						|
        this.onObserverAdded();
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  // The extensionId will be looked up in ExtensionSettingsStore if it is not
 | 
						|
  // provided using this.settingType and this.settingKey.
 | 
						|
  async open(targetWindow, extensionId) {
 | 
						|
    await lazy.ExtensionSettingsStore.initialize();
 | 
						|
 | 
						|
    // Remove the observer since it would open the same dialog again the next time
 | 
						|
    // the observer event fires.
 | 
						|
    this.removeObserver();
 | 
						|
 | 
						|
    if (!extensionId) {
 | 
						|
      let item = lazy.ExtensionSettingsStore.getSetting(
 | 
						|
        this.settingType,
 | 
						|
        this.settingKey
 | 
						|
      );
 | 
						|
      extensionId = item && item.id;
 | 
						|
    }
 | 
						|
 | 
						|
    let win = targetWindow || this.topWindow;
 | 
						|
    let isPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(win);
 | 
						|
    if (
 | 
						|
      isPrivate &&
 | 
						|
      extensionId &&
 | 
						|
      !WebExtensionPolicy.getByID(extensionId).privateBrowsingAllowed
 | 
						|
    ) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // The item should have an extension and the user shouldn't have confirmed
 | 
						|
    // the change here, but just to be sure check that it is still controlled
 | 
						|
    // and the user hasn't already confirmed the change.
 | 
						|
    // If there is no id, then the extension is no longer in control.
 | 
						|
    if (!extensionId || this.userHasConfirmed(extensionId)) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    // If the window closes while waiting for focus, this might reject/throw,
 | 
						|
    // and we should stop trying to show the popup.
 | 
						|
    try {
 | 
						|
      await this._ensureWindowReady(win);
 | 
						|
    } catch (ex) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    win.ownerGlobal.ensureCustomElements("moz-support-link");
 | 
						|
 | 
						|
    // Find the elements we need.
 | 
						|
    let doc = win.document;
 | 
						|
    let panel = ExtensionControlledPopup._getAndMaybeCreatePanel(doc);
 | 
						|
    let popupnotification = doc.getElementById(this.popupnotificationId);
 | 
						|
    let urlBarWasFocused = win.gURLBar.focused;
 | 
						|
 | 
						|
    if (!popupnotification) {
 | 
						|
      throw new Error(
 | 
						|
        `No popupnotification found for id "${this.popupnotificationId}"`
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    let elementsToTranslate = panel.querySelectorAll("[data-lazy-l10n-id]");
 | 
						|
    if (elementsToTranslate.length) {
 | 
						|
      win.MozXULElement.insertFTLIfNeeded("browser/appMenuNotifications.ftl");
 | 
						|
      for (let el of elementsToTranslate) {
 | 
						|
        el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id"));
 | 
						|
        el.removeAttribute("data-lazy-l10n-id");
 | 
						|
      }
 | 
						|
      await win.document.l10n.translateFragment(panel);
 | 
						|
    }
 | 
						|
    let addon = await lazy.AddonManager.getAddonByID(extensionId);
 | 
						|
    this.populateDescription(doc, addon);
 | 
						|
 | 
						|
    // Setup the command handler.
 | 
						|
    let handleCommand = async event => {
 | 
						|
      panel.hidePopup();
 | 
						|
      if (event.originalTarget == popupnotification.button) {
 | 
						|
        // Main action is to keep changes.
 | 
						|
        await this.setConfirmation(extensionId);
 | 
						|
      } else if (this.preferencesLocation) {
 | 
						|
        // Secondary action opens Preferences, if a preferencesLocation option is included.
 | 
						|
        let options = this.Entrypoint
 | 
						|
          ? { urlParams: { entrypoint: this.Entrypoint } }
 | 
						|
          : {};
 | 
						|
        win.openPreferences(this.preferencesLocation, options);
 | 
						|
      } else {
 | 
						|
        // Secondary action is to restore settings.
 | 
						|
        if (this.beforeDisableAddon) {
 | 
						|
          await this.beforeDisableAddon(this, win);
 | 
						|
        }
 | 
						|
        await addon.disable();
 | 
						|
      }
 | 
						|
 | 
						|
      // If the page this is appearing on is the New Tab page then the URL bar may
 | 
						|
      // have been focused when the doorhanger stole focus away from it. Once an
 | 
						|
      // action is taken the focus state should be restored to what the user was
 | 
						|
      // expecting.
 | 
						|
      if (urlBarWasFocused) {
 | 
						|
        win.gURLBar.focus();
 | 
						|
      }
 | 
						|
    };
 | 
						|
    panel.addEventListener("command", handleCommand);
 | 
						|
    panel.addEventListener(
 | 
						|
      "popuphidden",
 | 
						|
      () => {
 | 
						|
        popupnotification.hidden = true;
 | 
						|
        panel.removeEventListener("command", handleCommand);
 | 
						|
      },
 | 
						|
      { once: true }
 | 
						|
    );
 | 
						|
 | 
						|
    let anchorButton;
 | 
						|
    if (this.anchorId) {
 | 
						|
      // If there's an anchorId, use that right away.
 | 
						|
      anchorButton = doc.getElementById(this.anchorId);
 | 
						|
    } else {
 | 
						|
      // Look for a browserAction on the toolbar.
 | 
						|
      let action = lazy.CustomizableUI.getWidget(
 | 
						|
        `${makeWidgetId(extensionId)}-browser-action`
 | 
						|
      );
 | 
						|
      if (action) {
 | 
						|
        action =
 | 
						|
          action.areaType == "toolbar" &&
 | 
						|
          action.forWindow(win).node.firstElementChild;
 | 
						|
      }
 | 
						|
 | 
						|
      // Anchor to a toolbar browserAction if found, otherwise use the menu button.
 | 
						|
      anchorButton = action || doc.getElementById("PanelUI-menu-button");
 | 
						|
    }
 | 
						|
    let anchor = anchorButton.icon;
 | 
						|
    popupnotification.show();
 | 
						|
    panel.openPopup(anchor);
 | 
						|
  }
 | 
						|
 | 
						|
  getAddonDetails(doc, addon) {
 | 
						|
    const defaultIcon = "chrome://mozapps/skin/extensions/extensionGeneric.svg";
 | 
						|
 | 
						|
    let image = doc.createXULElement("image");
 | 
						|
    image.setAttribute("src", addon.iconURL || defaultIcon);
 | 
						|
    image.classList.add("extension-controlled-icon");
 | 
						|
 | 
						|
    let addonDetails = doc.createDocumentFragment();
 | 
						|
    addonDetails.appendChild(image);
 | 
						|
    addonDetails.appendChild(doc.createTextNode(" " + addon.name));
 | 
						|
 | 
						|
    return addonDetails;
 | 
						|
  }
 | 
						|
 | 
						|
  populateDescription(doc, addon) {
 | 
						|
    let description = doc.getElementById(this.descriptionId);
 | 
						|
    description.textContent = "";
 | 
						|
 | 
						|
    let addonDetails = this.getAddonDetails(doc, addon);
 | 
						|
    let message = lazy.strBundle.GetStringFromName(this.descriptionMessageId);
 | 
						|
    if (this.getLocalizedDescription) {
 | 
						|
      description.appendChild(
 | 
						|
        this.getLocalizedDescription(doc, message, addonDetails)
 | 
						|
      );
 | 
						|
    } else {
 | 
						|
      description.appendChild(
 | 
						|
        lazy.BrowserUIUtils.getLocalizedFragment(doc, message, addonDetails)
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    let link = doc.createElement("a", { is: "moz-support-link" });
 | 
						|
    link.setAttribute("class", "learnMore");
 | 
						|
    link.setAttribute("support-page", this.learnMoreLink);
 | 
						|
    description.appendChild(link);
 | 
						|
  }
 | 
						|
 | 
						|
  async _ensureWindowReady(win) {
 | 
						|
    if (win.closed) {
 | 
						|
      throw new Error("window is closed");
 | 
						|
    }
 | 
						|
    let promises = [];
 | 
						|
    let listenersToRemove = [];
 | 
						|
    function promiseEvent(type) {
 | 
						|
      promises.push(
 | 
						|
        new Promise(resolve => {
 | 
						|
          let listener = () => {
 | 
						|
            win.removeEventListener(type, listener);
 | 
						|
            resolve();
 | 
						|
          };
 | 
						|
          win.addEventListener(type, listener);
 | 
						|
          listenersToRemove.push([type, listener]);
 | 
						|
        })
 | 
						|
      );
 | 
						|
    }
 | 
						|
    let { focusedWindow, activeWindow } = Services.focus;
 | 
						|
    if (activeWindow != win) {
 | 
						|
      promiseEvent("activate");
 | 
						|
    }
 | 
						|
    if (focusedWindow) {
 | 
						|
      // We may have focused a non-remote child window, find the browser window:
 | 
						|
      let { rootTreeItem } = focusedWindow.docShell;
 | 
						|
      rootTreeItem.QueryInterface(Ci.nsIDocShell);
 | 
						|
      focusedWindow = rootTreeItem.docViewer.DOMDocument.defaultView;
 | 
						|
    }
 | 
						|
    if (focusedWindow != win) {
 | 
						|
      promiseEvent("focus");
 | 
						|
    }
 | 
						|
    if (promises.length) {
 | 
						|
      let unloadListener;
 | 
						|
      let unloadPromise = new Promise((resolve, reject) => {
 | 
						|
        unloadListener = () => {
 | 
						|
          for (let [type, listener] of listenersToRemove) {
 | 
						|
            win.removeEventListener(type, listener);
 | 
						|
          }
 | 
						|
          reject(new Error("window unloaded"));
 | 
						|
        };
 | 
						|
        win.addEventListener("unload", unloadListener, { once: true });
 | 
						|
      });
 | 
						|
      try {
 | 
						|
        let allPromises = Promise.all(promises);
 | 
						|
        await Promise.race([allPromises, unloadPromise]);
 | 
						|
      } finally {
 | 
						|
        win.removeEventListener("unload", unloadListener);
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  static _getAndMaybeCreatePanel(doc) {
 | 
						|
    // // Lazy load the extension-notification panel the first time we need to display it.
 | 
						|
    let template = doc.getElementById("extensionNotificationTemplate");
 | 
						|
    if (template) {
 | 
						|
      template.replaceWith(template.content);
 | 
						|
    }
 | 
						|
 | 
						|
    return doc.getElementById("extension-notification-panel");
 | 
						|
  }
 | 
						|
}
 |