зеркало из https://github.com/mozilla/gecko-dev.git
453 строки
16 KiB
JavaScript
453 строки
16 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/. */
|
|
/* exported ExtensionControlledPopup */
|
|
|
|
"use strict";
|
|
|
|
/*
|
|
* @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.
|
|
*/
|
|
|
|
var EXPORTED_SYMBOLS = ["ExtensionControlledPopup"];
|
|
|
|
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
|
|
const { ExtensionCommon } = ChromeUtils.import(
|
|
"resource://gre/modules/ExtensionCommon.jsm"
|
|
);
|
|
const { XPCOMUtils } = ChromeUtils.import(
|
|
"resource://gre/modules/XPCOMUtils.jsm"
|
|
);
|
|
|
|
ChromeUtils.defineModuleGetter(
|
|
this,
|
|
"AddonManager",
|
|
"resource://gre/modules/AddonManager.jsm"
|
|
);
|
|
ChromeUtils.defineModuleGetter(
|
|
this,
|
|
"BrowserUIUtils",
|
|
"resource:///modules/BrowserUIUtils.jsm"
|
|
);
|
|
ChromeUtils.defineModuleGetter(
|
|
this,
|
|
"CustomizableUI",
|
|
"resource:///modules/CustomizableUI.jsm"
|
|
);
|
|
ChromeUtils.defineModuleGetter(
|
|
this,
|
|
"ExtensionSettingsStore",
|
|
"resource://gre/modules/ExtensionSettingsStore.jsm"
|
|
);
|
|
ChromeUtils.defineModuleGetter(
|
|
this,
|
|
"PrivateBrowsingUtils",
|
|
"resource://gre/modules/PrivateBrowsingUtils.jsm"
|
|
);
|
|
|
|
let { makeWidgetId } = ExtensionCommon;
|
|
|
|
XPCOMUtils.defineLazyGetter(this, "strBundle", function() {
|
|
return Services.strings.createBundle(
|
|
"chrome://global/locale/extensions.properties"
|
|
);
|
|
});
|
|
|
|
const PREF_BRANCH_INSTALLED_ADDON = "extensions.installedDistroAddon.";
|
|
|
|
XPCOMUtils.defineLazyGetter(this, "distributionAddonsList", function() {
|
|
let addonList = Services.prefs
|
|
.getChildList(PREF_BRANCH_INSTALLED_ADDON)
|
|
.map(id => id.replace(PREF_BRANCH_INSTALLED_ADDON, ""));
|
|
return new Set(addonList);
|
|
});
|
|
|
|
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 (distributionAddonsList.has(id)) {
|
|
return true;
|
|
}
|
|
let setting = ExtensionSettingsStore.getSetting(this.confirmedType, id);
|
|
return !!(setting && setting.value);
|
|
}
|
|
|
|
async setConfirmation(id) {
|
|
await ExtensionSettingsStore.initialize();
|
|
return ExtensionSettingsStore.addSetting(
|
|
id,
|
|
this.confirmedType,
|
|
id,
|
|
true,
|
|
() => false
|
|
);
|
|
}
|
|
|
|
async clearConfirmation(id) {
|
|
await ExtensionSettingsStore.initialize();
|
|
return 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 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 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 = ExtensionSettingsStore.getSetting(
|
|
this.settingType,
|
|
this.settingKey
|
|
);
|
|
extensionId = item && item.id;
|
|
}
|
|
|
|
let win = targetWindow || this.topWindow;
|
|
let isPrivate = 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 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 = CustomizableUI.getWidget(
|
|
`${makeWidgetId(extensionId)}-browser-action`
|
|
);
|
|
if (action) {
|
|
action = action.areaType == "toolbar" && action.forWindow(win).node;
|
|
}
|
|
|
|
// 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 = strBundle.GetStringFromName(this.descriptionMessageId);
|
|
if (this.getLocalizedDescription) {
|
|
description.appendChild(
|
|
this.getLocalizedDescription(doc, message, addonDetails)
|
|
);
|
|
} else {
|
|
description.appendChild(
|
|
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 = 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");
|
|
}
|
|
let unloadListener;
|
|
// eslint-disable-next-line no-async-promise-executor
|
|
return new Promise(async (resolve, reject) => {
|
|
if (promises.length) {
|
|
unloadListener = () => {
|
|
for (let [type, listener] of listenersToRemove) {
|
|
win.removeEventListener(type, listener);
|
|
}
|
|
reject();
|
|
};
|
|
win.addEventListener("unload", unloadListener, { once: true });
|
|
}
|
|
let error;
|
|
try {
|
|
await Promise.all(promises);
|
|
} catch (ex) {
|
|
error = ex;
|
|
}
|
|
if (unloadListener) {
|
|
win.removeEventListener("unload", unloadListener);
|
|
}
|
|
if (error) {
|
|
reject(new Error("window unloaded"));
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|