summaryrefslogtreecommitdiffstats
path: root/browser/components/extensions/ExtensionControlledPopup.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/extensions/ExtensionControlledPopup.sys.mjs')
-rw-r--r--browser/components/extensions/ExtensionControlledPopup.sys.mjs433
1 files changed, 433 insertions, 0 deletions
diff --git a/browser/components/extensions/ExtensionControlledPopup.sys.mjs b/browser/components/extensions/ExtensionControlledPopup.sys.mjs
new file mode 100644
index 0000000000..024235e8bc
--- /dev/null
+++ b/browser/components/extensions/ExtensionControlledPopup.sys.mjs
@@ -0,0 +1,433 @@
+/* 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";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "BrowserUIUtils",
+ "resource:///modules/BrowserUIUtils.jsm"
+);
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.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;
+
+XPCOMUtils.defineLazyGetter(lazy, "strBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://global/locale/extensions.properties"
+ );
+});
+
+const PREF_BRANCH_INSTALLED_ADDON = "extensions.installedDistroAddon.";
+
+XPCOMUtils.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.learnMoreMessageId
+ * The message id to be used for the text of a "learn more" link which
+ * will be placed after 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.learnMoreMessageId = opts.learnMoreMessageId;
+ 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, topic, data) {
+ // 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;
+ }
+
+ // 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.createXULElement("label", { is: "text-link" });
+ link.setAttribute("class", "learnMore");
+ link.href =
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ this.learnMoreLink;
+ link.textContent = lazy.strBundle.GetStringFromName(
+ this.learnMoreMessageId
+ );
+ 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.contentViewer.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");
+ }
+}