diff options
Diffstat (limited to 'browser/modules/ExtensionsUI.jsm')
-rw-r--r-- | browser/modules/ExtensionsUI.jsm | 705 |
1 files changed, 705 insertions, 0 deletions
diff --git a/browser/modules/ExtensionsUI.jsm b/browser/modules/ExtensionsUI.jsm new file mode 100644 index 0000000000..d9327f09f6 --- /dev/null +++ b/browser/modules/ExtensionsUI.jsm @@ -0,0 +1,705 @@ +/* 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/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["ExtensionsUI"]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { EventEmitter } = ChromeUtils.importESModule( + "resource://gre/modules/EventEmitter.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.jsm", + AddonManagerPrivate: "resource://gre/modules/AddonManager.jsm", + AMTelemetry: "resource://gre/modules/AddonManager.jsm", + ExtensionData: "resource://gre/modules/Extension.jsm", + ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.jsm", + OriginControls: "resource://gre/modules/ExtensionPermissions.jsm", +}); + +const DEFAULT_EXTENSION_ICON = + "chrome://mozapps/skin/extensions/extensionGeneric.svg"; + +const BROWSER_PROPERTIES = "chrome://browser/locale/browser.properties"; +const BRAND_PROPERTIES = "chrome://branding/locale/brand.properties"; + +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +function getTabBrowser(browser) { + while (browser.ownerGlobal.docShell.itemType !== Ci.nsIDocShell.typeChrome) { + browser = browser.ownerGlobal.docShell.chromeEventHandler; + } + let window = browser.ownerGlobal; + let viewType = browser.getAttribute("webextension-view-type"); + if (viewType == "sidebar") { + window = window.browsingContext.topChromeWindow; + } + if (viewType == "popup" || viewType == "sidebar") { + browser = window.gBrowser.selectedBrowser; + } + return { browser, window }; +} + +var ExtensionsUI = { + sideloaded: new Set(), + updates: new Set(), + sideloadListener: null, + histogram: null, + + pendingNotifications: new WeakMap(), + + async init() { + this.histogram = Services.telemetry.getHistogramById( + "EXTENSION_INSTALL_PROMPT_RESULT" + ); + + Services.obs.addObserver(this, "webextension-permission-prompt"); + Services.obs.addObserver(this, "webextension-update-permissions"); + Services.obs.addObserver(this, "webextension-install-notify"); + Services.obs.addObserver(this, "webextension-optional-permission-prompt"); + Services.obs.addObserver(this, "webextension-defaultsearch-prompt"); + + await Services.wm.getMostRecentWindow("navigator:browser") + .delayedStartupPromise; + + this._checkForSideloaded(); + }, + + async _checkForSideloaded() { + let sideloaded = await lazy.AddonManagerPrivate.getNewSideloads(); + + if (!sideloaded.length) { + // No new side-loads. We're done. + return; + } + + // The ordering shouldn't matter, but tests depend on notifications + // happening in a specific order. + sideloaded.sort((a, b) => a.id.localeCompare(b.id)); + + if (!this.sideloadListener) { + this.sideloadListener = { + onEnabled: addon => { + if (!this.sideloaded.has(addon)) { + return; + } + + this.sideloaded.delete(addon); + this._updateNotifications(); + + if (this.sideloaded.size == 0) { + lazy.AddonManager.removeAddonListener(this.sideloadListener); + this.sideloadListener = null; + } + }, + }; + lazy.AddonManager.addAddonListener(this.sideloadListener); + } + + for (let addon of sideloaded) { + this.sideloaded.add(addon); + } + this._updateNotifications(); + }, + + _updateNotifications() { + if (this.sideloaded.size + this.updates.size == 0) { + lazy.AppMenuNotifications.removeNotification("addon-alert"); + } else { + lazy.AppMenuNotifications.showBadgeOnlyNotification("addon-alert"); + } + this.emit("change"); + }, + + showAddonsManager(tabbrowser, strings, icon, histkey) { + let global = tabbrowser.selectedBrowser.ownerGlobal; + return global + .BrowserOpenAddonsMgr("addons://list/extension") + .then(aomWin => { + let aomBrowser = aomWin.docShell.chromeEventHandler; + return this.showPermissionsPrompt(aomBrowser, strings, icon, histkey); + }); + }, + + showSideloaded(tabbrowser, addon) { + addon.markAsSeen(); + this.sideloaded.delete(addon); + this._updateNotifications(); + + let strings = this._buildStrings({ + addon, + permissions: addon.userPermissions, + type: "sideload", + }); + + lazy.AMTelemetry.recordManageEvent(addon, "sideload_prompt", { + num_strings: strings.msgs.length, + }); + + this.showAddonsManager(tabbrowser, strings, addon.iconURL, "sideload").then( + async answer => { + if (answer) { + await addon.enable(); + + this._updateNotifications(); + + // The user has just enabled a sideloaded extension, if the permission + // can be changed for the extension, show the post-install panel to + // give the user that opportunity. + if ( + addon.permissions & + lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS + ) { + this.showInstallNotification(tabbrowser.selectedBrowser, addon); + } + } + this.emit("sideload-response"); + } + ); + }, + + showUpdate(browser, info) { + lazy.AMTelemetry.recordInstallEvent(info.install, { + step: "permissions_prompt", + num_strings: info.strings.msgs.length, + }); + + this.showAddonsManager( + browser, + info.strings, + info.addon.iconURL, + "update" + ).then(answer => { + if (answer) { + info.resolve(); + } else { + info.reject(); + } + // At the moment, this prompt will re-appear next time we do an update + // check. See bug 1332360 for proposal to avoid this. + this.updates.delete(info); + this._updateNotifications(); + }); + }, + + observe(subject, topic, data) { + if (topic == "webextension-permission-prompt") { + let { target, info } = subject.wrappedJSObject; + + let { browser, window } = getTabBrowser(target); + + // Dismiss the progress notification. Note that this is bad if + // there are multiple simultaneous installs happening, see + // bug 1329884 for a longer explanation. + let progressNotification = window.PopupNotifications.getNotification( + "addon-progress", + browser + ); + if (progressNotification) { + progressNotification.remove(); + } + + info.unsigned = + info.addon.signedState <= lazy.AddonManager.SIGNEDSTATE_MISSING; + if ( + info.unsigned && + Cu.isInAutomation && + Services.prefs.getBoolPref("extensions.ui.ignoreUnsigned", false) + ) { + info.unsigned = false; + } + + let strings = this._buildStrings(info); + + // If this is an update with no promptable permissions, just apply it + if (info.type == "update" && !strings.msgs.length) { + info.resolve(); + return; + } + + let icon = info.unsigned + ? "chrome://global/skin/icons/warning.svg" + : info.icon; + + let histkey; + if (info.type == "sideload") { + histkey = "sideload"; + } else if (info.type == "update") { + histkey = "update"; + } else if (info.source == "AMO") { + histkey = "installAmo"; + } else if (info.source == "local") { + histkey = "installLocal"; + } else { + histkey = "installWeb"; + } + + if (info.type == "sideload") { + lazy.AMTelemetry.recordManageEvent(info.addon, "sideload_prompt", { + num_strings: strings.msgs.length, + }); + } else { + lazy.AMTelemetry.recordInstallEvent(info.install, { + step: "permissions_prompt", + num_strings: strings.msgs.length, + }); + } + + this.showPermissionsPrompt(browser, strings, icon, histkey).then( + answer => { + if (answer) { + info.resolve(); + } else { + info.reject(); + } + } + ); + } else if (topic == "webextension-update-permissions") { + let info = subject.wrappedJSObject; + info.type = "update"; + let strings = this._buildStrings(info); + + // If we don't prompt for any new permissions, just apply it + if (!strings.msgs.length) { + info.resolve(); + return; + } + + let update = { + strings, + permissions: info.permissions, + install: info.install, + addon: info.addon, + resolve: info.resolve, + reject: info.reject, + }; + + this.updates.add(update); + this._updateNotifications(); + } else if (topic == "webextension-install-notify") { + let { target, addon, callback } = subject.wrappedJSObject; + this.showInstallNotification(target, addon).then(() => { + if (callback) { + callback(); + } + }); + } else if (topic == "webextension-optional-permission-prompt") { + let { + browser, + name, + icon, + permissions, + resolve, + } = subject.wrappedJSObject; + let strings = this._buildStrings({ + type: "optional", + addon: { name }, + permissions, + }); + + // If we don't have any promptable permissions, just proceed + if (!strings.msgs.length) { + resolve(true); + return; + } + resolve(this.showPermissionsPrompt(browser, strings, icon)); + } else if (topic == "webextension-defaultsearch-prompt") { + let { + browser, + name, + icon, + respond, + currentEngine, + newEngine, + } = subject.wrappedJSObject; + + let bundle = Services.strings.createBundle(BROWSER_PROPERTIES); + + let strings = {}; + strings.acceptText = bundle.GetStringFromName( + "webext.defaultSearchYes.label" + ); + strings.acceptKey = bundle.GetStringFromName( + "webext.defaultSearchYes.accessKey" + ); + strings.cancelText = bundle.GetStringFromName( + "webext.defaultSearchNo.label" + ); + strings.cancelKey = bundle.GetStringFromName( + "webext.defaultSearchNo.accessKey" + ); + strings.addonName = name; + strings.text = bundle.formatStringFromName( + "webext.defaultSearch.description", + ["<>", currentEngine, newEngine] + ); + + this.showDefaultSearchPrompt(browser, strings, icon).then(respond); + } + }, + + // Create a set of formatted strings for a permission prompt + _buildStrings(info) { + let bundle = Services.strings.createBundle(BROWSER_PROPERTIES); + let brandBundle = Services.strings.createBundle(BRAND_PROPERTIES); + let appName = brandBundle.GetStringFromName("brandShortName"); + let info2 = Object.assign({ appName }, info); + + let strings = lazy.ExtensionData.formatPermissionStrings(info2, bundle, { + collapseOrigins: true, + }); + strings.addonName = info.addon.name; + strings.learnMore = bundle.GetStringFromName("webextPerms.learnMore2"); + return strings; + }, + + async showPermissionsPrompt(target, strings, icon, histkey) { + let { browser, window } = getTabBrowser(target); + + // Wait for any pending prompts to complete before showing the next one. + let pending; + while ((pending = this.pendingNotifications.get(browser))) { + await pending; + } + + let promise = new Promise(resolve => { + function eventCallback(topic) { + let doc = this.browser.ownerDocument; + if (topic == "showing") { + let textEl = doc.getElementById("addon-webext-perm-text"); + textEl.textContent = strings.text; + textEl.hidden = !strings.text; + + let listIntroEl = doc.getElementById("addon-webext-perm-intro"); + listIntroEl.textContent = strings.listIntro; + listIntroEl.hidden = !strings.msgs.length || !strings.listIntro; + + let listInfoEl = doc.getElementById("addon-webext-perm-info"); + listInfoEl.textContent = strings.learnMore; + listInfoEl.href = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "extension-permissions"; + listInfoEl.hidden = !strings.msgs.length; + + let list = doc.getElementById("addon-webext-perm-list"); + while (list.firstChild) { + list.firstChild.remove(); + } + let singleEntryEl = doc.getElementById( + "addon-webext-perm-single-entry" + ); + singleEntryEl.textContent = ""; + singleEntryEl.hidden = true; + list.hidden = true; + + if (strings.msgs.length === 1) { + singleEntryEl.textContent = strings.msgs[0]; + singleEntryEl.hidden = false; + } else if (strings.msgs.length) { + for (let msg of strings.msgs) { + let item = doc.createElementNS(HTML_NS, "li"); + item.textContent = msg; + list.appendChild(item); + } + list.hidden = false; + } + } else if (topic == "swapping") { + return true; + } + if (topic == "removed") { + Services.tm.dispatchToMainThread(() => { + resolve(false); + }); + } + return false; + } + + let options = { + hideClose: true, + popupIconURL: icon || DEFAULT_EXTENSION_ICON, + popupIconClass: icon ? "" : "addon-warning-icon", + persistent: true, + eventCallback, + removeOnDismissal: true, + }; + // The prompt/notification machinery has a special affordance wherein + // certain subsets of the header string can be designated "names", and + // referenced symbolically as "<>" and "{}" to receive special formatting. + // That code assumes that the existence of |name| and |secondName| in the + // options object imply the presence of "<>" and "{}" (respectively) in + // in the string. + // + // At present, WebExtensions use this affordance while SitePermission + // add-ons don't, so we need to conditionally set the |name| field. + // + // NB: This could potentially be cleaned up, see bug 1799710. + if (strings.header.includes("<>")) { + options.name = strings.addonName; + } + + let action = { + label: strings.acceptText, + accessKey: strings.acceptKey, + callback: () => { + if (histkey) { + this.histogram.add(histkey + "Accepted"); + } + resolve(true); + }, + }; + let secondaryActions = [ + { + label: strings.cancelText, + accessKey: strings.cancelKey, + callback: () => { + if (histkey) { + this.histogram.add(histkey + "Rejected"); + } + resolve(false); + }, + }, + ]; + + if (browser.ownerGlobal.gUnifiedExtensions.isEnabled) { + options.popupOptions = { + position: "bottomright topright", + }; + } + + window.PopupNotifications.show( + browser, + "addon-webext-permissions", + strings.header, + browser.ownerGlobal.gUnifiedExtensions.getPopupAnchorID( + browser, + window + ), + action, + secondaryActions, + options + ); + }); + + this.pendingNotifications.set(browser, promise); + promise.finally(() => this.pendingNotifications.delete(browser)); + return promise; + }, + + showDefaultSearchPrompt(target, strings, icon) { + return new Promise(resolve => { + let options = { + hideClose: true, + popupIconURL: icon || DEFAULT_EXTENSION_ICON, + persistent: true, + removeOnDismissal: true, + eventCallback(topic) { + if (topic == "removed") { + resolve(false); + } + }, + name: strings.addonName, + }; + + let action = { + label: strings.acceptText, + accessKey: strings.acceptKey, + callback: () => { + resolve(true); + }, + }; + let secondaryActions = [ + { + label: strings.cancelText, + accessKey: strings.cancelKey, + callback: () => { + resolve(false); + }, + }, + ]; + + let { browser, window } = getTabBrowser(target); + + window.PopupNotifications.show( + browser, + "addon-webext-defaultsearch", + strings.text, + "addons-notification-icon", + action, + secondaryActions, + options + ); + }); + }, + + async showInstallNotification(target, addon) { + let { window } = getTabBrowser(target); + let bundle = window.gNavigatorBundle; + + let message = bundle.getFormattedString("addonPostInstall.message3", [ + "<>", + ]); + const permissionName = "internal:privateBrowsingAllowed"; + const { permissions } = await lazy.ExtensionPermissions.get(addon.id); + const hasIncognito = permissions.includes(permissionName); + + return new Promise(resolve => { + // Show or hide private permission ui based on the pref. + function setCheckbox(win) { + let checkbox = win.document.getElementById("addon-incognito-checkbox"); + checkbox.checked = hasIncognito; + checkbox.hidden = !( + addon.permissions & + lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS + ); + } + + async function actionResolve(win) { + let checkbox = win.document.getElementById("addon-incognito-checkbox"); + + if (checkbox.checked == hasIncognito) { + resolve(); + return; + } + + let incognitoPermission = { + permissions: [permissionName], + origins: [], + }; + + let value; + // The checkbox has been changed at this point, otherwise we would + // have exited early above. + if (checkbox.checked) { + await lazy.ExtensionPermissions.add(addon.id, incognitoPermission); + value = "on"; + } else if (hasIncognito) { + await lazy.ExtensionPermissions.remove(addon.id, incognitoPermission); + value = "off"; + } + if (value !== undefined) { + lazy.AMTelemetry.recordActionEvent({ + addon, + object: "doorhanger", + action: "privateBrowsingAllowed", + view: "postInstall", + value, + }); + } + // Reload the extension if it is already enabled. This ensures any change + // on the private browsing permission is properly handled. + if (addon.isActive) { + await addon.reload(); + } + + resolve(); + } + + let action = { + callback: actionResolve, + }; + + let icon = addon.isWebExtension + ? lazy.AddonManager.getPreferredIconURL(addon, 32, window) || + DEFAULT_EXTENSION_ICON + : "chrome://browser/skin/addons/addon-install-installed.svg"; + let options = { + name: addon.name, + message, + popupIconURL: icon, + onRefresh: setCheckbox, + onDismissed: win => { + lazy.AppMenuNotifications.removeNotification("addon-installed"); + actionResolve(win); + }, + }; + lazy.AppMenuNotifications.showNotification( + "addon-installed", + action, + null, + options + ); + }); + }, + + // Populate extension toolbar popup menu with origin controls. + originControlsMenu(popup, extensionId) { + let policy = WebExtensionPolicy.getByID(extensionId); + if (!policy?.extension.originControls) { + return; + } + + let win = popup.ownerGlobal; + let uri = win.gBrowser.currentURI; + let state = lazy.OriginControls.getState(policy, uri); + + let doc = popup.ownerDocument; + let whenClicked, alwaysOn, allDomains; + let separator = doc.createXULElement("menuseparator"); + + let headerItem = doc.createXULElement("menuitem"); + headerItem.setAttribute("disabled", true); + + if (state.noAccess) { + doc.l10n.setAttributes(headerItem, "origin-controls-no-access"); + } else { + doc.l10n.setAttributes(headerItem, "origin-controls-options"); + } + + if (state.allDomains) { + allDomains = doc.createXULElement("menuitem"); + allDomains.setAttribute("type", "radio"); + allDomains.setAttribute("checked", state.hasAccess); + doc.l10n.setAttributes(allDomains, "origin-controls-option-all-domains"); + } + + if (state.whenClicked) { + whenClicked = doc.createXULElement("menuitem"); + whenClicked.setAttribute("type", "radio"); + whenClicked.setAttribute("checked", !state.hasAccess); + doc.l10n.setAttributes( + whenClicked, + "origin-controls-option-when-clicked" + ); + whenClicked.addEventListener("command", async () => { + await lazy.OriginControls.setWhenClicked(policy, uri); + win.gUnifiedExtensions.updateAttention(); + }); + } + + if (state.alwaysOn) { + alwaysOn = doc.createXULElement("menuitem"); + alwaysOn.setAttribute("type", "radio"); + alwaysOn.setAttribute("checked", state.hasAccess); + doc.l10n.setAttributes(alwaysOn, "origin-controls-option-always-on", { + domain: uri.host, + }); + alwaysOn.addEventListener("command", async () => { + await lazy.OriginControls.setAlwaysOn(policy, uri); + win.gUnifiedExtensions.updateAttention(); + }); + } + + // Insert all before Pin to toolbar OR Manage Extension, after any + // extension's menu items. + let items = [headerItem, whenClicked, alwaysOn, allDomains, separator]; + let manageItem = + popup.querySelector(".customize-context-manageExtension") || + popup.querySelector(".unified-extensions-context-menu-pin-to-toolbar"); + items.forEach(item => item && popup.insertBefore(item, manageItem)); + + let cleanup = () => items.forEach(item => item?.remove()); + popup.addEventListener("popuphidden", cleanup, { once: true }); + }, +}; + +EventEmitter.decorate(ExtensionsUI); |