/* 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, { AMTelemetry: "resource://gre/modules/AddonManager.sys.mjs", AddonManager: "resource://gre/modules/AddonManager.sys.mjs", AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs", ExtensionData: "resource://gre/modules/Extension.sys.mjs", ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", SITEPERMS_ADDON_TYPE: "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs", }); const { PERMISSIONS_WITH_MESSAGE, PERMISSION_L10N } = ChromeUtils.importESModule( "resource://gre/modules/ExtensionPermissionMessages.sys.mjs" ); // Add the Thunderbird specific permission description locale file, to allow // Extension.sys.mjs to resolve our permissions strings. PERMISSION_L10N.addResourceIds(["messenger/extensionPermissions.ftl"]); XPCOMUtils.defineLazyGetter( lazy, "l10n", () => new Localization( [ "branding/brand.ftl", "messenger/extensionsUI.ftl", "messenger/addonNotifications.ftl", ], true ) ); const DEFAULT_EXTENSION_ICON = "chrome://mozapps/skin/extensions/extensionGeneric.svg"; const HTML_NS = "http://www.w3.org/1999/xhtml"; const THUNDERBIRD_ANCHOR_ID = "addons-notification-icon"; // Thunderbird shim of PopupNotifications for usage in this module. var PopupNotifications = { get isPanelOpen() { return getTopWindow().PopupNotifications.isPanelOpen; }, getNotification(id, browser) { return getTopWindow().PopupNotifications.getNotification(id, browser); }, remove(notification, isCancel) { return getTopWindow().PopupNotifications.remove(notification, isCancel); }, show(browser, id, message, anchorID, mainAction, secondaryActions, options) { let notifications = getTopWindow().PopupNotifications; if (options.popupIconURL == "chrome://browser/content/extension.svg") { options.popupIconURL = DEFAULT_EXTENSION_ICON; } return notifications.show( browser, id, message, anchorID, mainAction, secondaryActions, options ); }, }; function getTopWindow() { return Services.wm.getMostRecentWindow("mail:3pane"); } function getTabBrowser(browser) { while (browser.ownerGlobal.docShell.itemType !== Ci.nsIDocShell.typeChrome) { browser = browser.ownerGlobal.docShell.chromeEventHandler; } if (browser.getAttribute("webextension-view-type") == "popup") { browser = browser.ownerGlobal.gBrowser.selectedBrowser; } return { browser, window: browser.ownerGlobal }; } // Removes a doorhanger notification if all of the installs it was notifying // about have ended in some way. function removeNotificationOnEnd(notification, installs) { let count = installs.length; function maybeRemove(install) { install.removeListener(this); if (--count == 0) { // Check that the notification is still showing let current = PopupNotifications.getNotification( notification.id, notification.browser ); if (current === notification) { notification.remove(); } } } for (let install of installs) { install.addListener({ onDownloadCancelled: maybeRemove, onDownloadFailed: maybeRemove, onInstallFailed: maybeRemove, onInstallEnded: maybeRemove, }); } } // Copied from browser/base/content/browser-addons.js function buildNotificationAction(msg, callback) { let label = ""; let accessKey = ""; for (let { name, value } of msg.attributes) { switch (name) { case "label": label = value; break; case "accesskey": accessKey = value; break; } } return { label, accessKey, callback }; } /** * Mapping of error code -> [error-id, local-error-id] * * error-id is used for errors in DownloadedAddonInstall, * local-error-id for errors in LocalAddonInstall. * * The error codes are defined in AddonManager's _errors Map. * Not all error codes listed there are translated, * since errors that are only triggered during updates * will never reach this code. * * @see browser/base/content/browser-addons.js (where this is copied from) */ const ERROR_L10N_IDS = new Map([ [ -1, [ "addon-install-error-network-failure", "addon-local-install-error-network-failure", ], ], [ -2, [ "addon-install-error-incorrect-hash", "addon-local-install-error-incorrect-hash", ], ], [ -3, [ "addon-install-error-corrupt-file", "addon-local-install-error-corrupt-file", ], ], [ -4, [ "addon-install-error-file-access", "addon-local-install-error-file-access", ], ], [ -5, ["addon-install-error-not-signed", "addon-local-install-error-not-signed"], ], [-8, ["addon-install-error-invalid-domain"]], ]); // Add Thunderbird specific permissions so localization will work. Add entries // to PERMISSION_L10N_ID_OVERRIDES here in case a permission string needs to be // overridden. for (let perm of [ "accountsFolders", "accountsIdentities", "accountsRead", "addressBooks", "compose", "compose-send", "compose-save", "experiment", "messagesImport", "messagesModify", "messagesMove", "messagesDelete", "messagesRead", "messagesTags", "sensitiveDataUpload", ]) { PERMISSIONS_WITH_MESSAGE.add(perm); } /** * This object is Thunderbird's version of the same object in * browser/base/content/browser-addons.js. Firefox has one of these objects * per window but Thunderbird has only one total, because we simply pick the * most recent window for notifications, rather than the window related to a * particular tab. */ var gXPInstallObserver = { pendingInstalls: new WeakMap(), // Themes do not have a permission prompt and instead call for an install // confirmation. showInstallConfirmation(browser, installInfo, height = undefined) { let document = getTopWindow().document; // If the confirmation notification is already open cache the installInfo // and the new confirmation will be shown later if ( PopupNotifications.getNotification("addon-install-confirmation", browser) ) { let pending = this.pendingInstalls.get(browser); if (pending) { pending.push(installInfo); } else { this.pendingInstalls.set(browser, [installInfo]); } return; } let showNextConfirmation = () => { let pending = this.pendingInstalls.get(browser); if (pending && pending.length) { this.showInstallConfirmation(browser, pending.shift()); } }; // If all installs have already been cancelled in some way then just show // the next confirmation. if ( installInfo.installs.every( i => i.state != lazy.AddonManager.STATE_DOWNLOADED ) ) { showNextConfirmation(); return; } // Make notifications persistent var options = { displayURI: installInfo.originatingURI, persistent: true, hideClose: true, popupOptions: { position: "bottomright topright", }, }; let acceptInstallation = () => { for (let install of installInfo.installs) { install.install(); } installInfo = null; Services.telemetry .getHistogramById("SECURITY_UI") .add( Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL_CLICK_THROUGH ); }; let cancelInstallation = () => { if (installInfo) { for (let install of installInfo.installs) { // The notification may have been closed because the add-ons got // cancelled elsewhere, only try to cancel those that are still // pending install. if (install.state != lazy.AddonManager.STATE_CANCELLED) { install.cancel(); } } } showNextConfirmation(); }; options.eventCallback = event => { switch (event) { case "removed": cancelInstallation(); break; case "shown": let addonList = document.getElementById( "addon-install-confirmation-content" ); while (addonList.lastChild) { addonList.lastChild.remove(); } for (let install of installInfo.installs) { let container = document.createXULElement("hbox"); let name = document.createXULElement("label"); name.setAttribute("value", install.addon.name); name.setAttribute("class", "addon-install-confirmation-name"); container.appendChild(name); addonList.appendChild(container); } break; } }; let msgId; let notification = document.getElementById( "addon-install-confirmation-notification" ); msgId = "addon-confirm-install-message"; notification.removeAttribute("warning"); options.learnMoreURL = "https://support.thunderbird.net/kb/installing-addon-thunderbird"; const addonCount = installInfo.installs.length; const messageString = lazy.l10n.formatValueSync(msgId, { addonCount }); const [acceptMsg, cancelMsg] = lazy.l10n.formatMessagesSync([ "addon-install-accept-button", "addon-install-cancel-button", ]); const action = buildNotificationAction(acceptMsg, acceptInstallation); const secondaryAction = buildNotificationAction(cancelMsg, () => {}); if (height) { notification.style.minHeight = height + "px"; } let popup = PopupNotifications.show( browser, "addon-install-confirmation", messageString, THUNDERBIRD_ANCHOR_ID, action, [secondaryAction], options ); removeNotificationOnEnd(popup, installInfo.installs); Services.telemetry .getHistogramById("SECURITY_UI") .add(Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL); }, // IDs of addon install related notifications NOTIFICATION_IDS: [ "addon-install-blocked", "addon-install-confirmation", "addon-install-failed", "addon-install-origin-blocked", "addon-install-webapi-blocked", "addon-install-policy-blocked", "addon-progress", "addon-webext-permissions", "xpinstall-disabled", ], /** * Remove all opened addon installation notifications * * @param {*} browser - Browser to remove notifications for * @returns {boolean} - true if notifications have been removed. */ removeAllNotifications(browser) { let notifications = this.NOTIFICATION_IDS.map(id => PopupNotifications.getNotification(id, browser) ).filter(notification => notification != null); PopupNotifications.remove(notifications, true); return !!notifications.length; }, async observe(aSubject, aTopic, aData) { let installInfo = aSubject.wrappedJSObject; let browser = installInfo.browser; // Make notifications persistent let options = { displayURI: installInfo.originatingURI, persistent: true, hideClose: true, timeout: Date.now() + 30000, popupOptions: { position: "bottomright topright", }, }; switch (aTopic) { case "addon-install-disabled": { let msgId, action, secondaryActions; if (Services.prefs.prefIsLocked("xpinstall.enabled")) { msgId = "xpinstall-disabled-locked"; action = null; secondaryActions = null; } else { msgId = "xpinstall-disabled"; const [disabledMsg, cancelMsg] = await lazy.l10n.formatMessages([ "xpinstall-disabled-button", "addon-install-cancel-button", ]); action = buildNotificationAction(disabledMsg, () => { Services.prefs.setBoolPref("xpinstall.enabled", true); }); secondaryActions = [buildNotificationAction(cancelMsg, () => {})]; } PopupNotifications.show( browser, "xpinstall-disabled", await lazy.l10n.formatValue(msgId), THUNDERBIRD_ANCHOR_ID, action, secondaryActions, options ); break; } case "addon-install-fullscreen-blocked": { // AddonManager denied installation because we are in DOM fullscreen this.logWarningFullScreenInstallBlocked(); break; } case "addon-install-webapi-blocked": case "addon-install-policy-blocked": case "addon-install-origin-blocked": { const msgId = aTopic == "addon-install-policy-blocked" ? "addon-domain-blocked-by-policy" : "xpinstall-prompt"; let messageString = await lazy.l10n.formatValue(msgId); if (Services.policies) { let extensionSettings = Services.policies.getExtensionSettings("*"); if ( extensionSettings && "blocked_install_message" in extensionSettings ) { messageString += " " + extensionSettings.blocked_install_message; } } options.removeOnDismissal = true; options.persistent = false; let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI"); secHistogram.add( Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED ); let popup = PopupNotifications.show( browser, aTopic, messageString, THUNDERBIRD_ANCHOR_ID, null, null, options ); removeNotificationOnEnd(popup, installInfo.installs); break; } case "addon-install-blocked": { let window = getTopWindow(); await window.ensureCustomElements("moz-support-link"); // 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 = PopupNotifications.getNotification( "addon-progress", browser ); if (progressNotification) { progressNotification.remove(); } // The informational content differs somewhat for site permission // add-ons. AOM no longer supports installing multiple addons, // so the array handling here is vestigial. let isSitePermissionAddon = installInfo.installs.every( ({ addon }) => addon?.type === lazy.SITEPERMS_ADDON_TYPE ); let hasHost = false; let headerId, msgId; if (isSitePermissionAddon) { // At present, WebMIDI is the only consumer of the site permission // add-on infrastructure, and so we can hard-code a midi string here. // If and when we use it for other things, we'll need to plumb that // information through. See bug 1826747. headerId = "site-permission-install-first-prompt-midi-header"; msgId = "site-permission-install-first-prompt-midi-message"; } else if (options.displayURI) { // PopupNotifications.show replaces <> with options.name. headerId = { id: "xpinstall-prompt-header", args: { host: "<>" } }; // getLocalizedFragment replaces %1$S with options.name. msgId = { id: "xpinstall-prompt-message", args: { host: "%1$S" } }; options.name = options.displayURI.displayHost; hasHost = true; } else { headerId = "xpinstall-prompt-header-unknown"; msgId = "xpinstall-prompt-message-unknown"; } const [headerString, msgString] = await lazy.l10n.formatValues([ headerId, msgId, ]); // displayURI becomes it's own label, so we unset it for this panel. It will become part of the // messageString above. let displayURI = options.displayURI; options.displayURI = undefined; options.eventCallback = topic => { if (topic !== "showing") { return; } let doc = browser.ownerDocument; let message = doc.getElementById("addon-install-blocked-message"); // We must remove any prior use of this panel message in this window. while (message.firstChild) { message.firstChild.remove(); } if (!hasHost) { message.textContent = msgString; } else { let b = doc.createElementNS("http://www.w3.org/1999/xhtml", "b"); b.textContent = options.name; let fragment = getLocalizedFragment(doc, msgString, b); message.appendChild(fragment); } let article = isSitePermissionAddon ? "site-permission-addons" : "unlisted-extensions-risks"; let learnMore = doc.getElementById("addon-install-blocked-info"); learnMore.setAttribute("support-page", article); }; let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI"); secHistogram.add( Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED ); const [ installMsg, dontAllowMsg, neverAllowMsg, neverAllowAndReportMsg, ] = await lazy.l10n.formatMessages([ "xpinstall-prompt-install", "xpinstall-prompt-dont-allow", "xpinstall-prompt-never-allow", "xpinstall-prompt-never-allow-and-report", ]); const action = buildNotificationAction(installMsg, () => { secHistogram.add( Ci.nsISecurityUITelemetry .WARNING_ADDON_ASKING_PREVENTED_CLICK_THROUGH ); installInfo.install(); }); const neverAllowCallback = () => { // SitePermissions is browser/ only. // lazy.SitePermissions.setForPrincipal( // browser.contentPrincipal, // "install", // lazy.SitePermissions.BLOCK // ); for (let install of installInfo.installs) { if (install.state != lazy.AddonManager.STATE_CANCELLED) { install.cancel(); } } if (installInfo.cancel) { installInfo.cancel(); } }; const declineActions = [ buildNotificationAction(dontAllowMsg, () => { for (let install of installInfo.installs) { if (install.state != lazy.AddonManager.STATE_CANCELLED) { install.cancel(); } } if (installInfo.cancel) { installInfo.cancel(); } }), buildNotificationAction(neverAllowMsg, neverAllowCallback), ]; if (isSitePermissionAddon) { // Restrict this to site permission add-ons for now pending a decision // from product about how to approach this for extensions. declineActions.push( buildNotificationAction(neverAllowAndReportMsg, () => { lazy.AMTelemetry.recordEvent({ method: "reportSuspiciousSite", object: "suspiciousSite", value: displayURI?.displayHost ?? "(unknown)", extra: {}, }); neverAllowCallback(); }) ); } let popup = PopupNotifications.show( browser, aTopic, headerString, THUNDERBIRD_ANCHOR_ID, action, declineActions, options ); removeNotificationOnEnd(popup, installInfo.installs); break; } case "addon-install-started": { // If all installs have already been downloaded then there is no need to // show the download progress if ( installInfo.installs.every( aInstall => aInstall.state == lazy.AddonManager.STATE_DOWNLOADED ) ) { return; } const messageString = lazy.l10n.formatValueSync( "addon-downloading-and-verifying", { addonCount: installInfo.installs.length } ); options.installs = installInfo.installs; options.contentWindow = browser.contentWindow; options.sourceURI = browser.currentURI; options.eventCallback = function (aEvent) { switch (aEvent) { case "removed": options.contentWindow = null; options.sourceURI = null; break; } }; const [acceptMsg, cancelMsg] = lazy.l10n.formatMessagesSync([ "addon-install-accept-button", "addon-install-cancel-button", ]); const action = buildNotificationAction(acceptMsg, () => {}); action.disabled = true; const secondaryAction = buildNotificationAction(cancelMsg, () => { for (let install of installInfo.installs) { if (install.state != lazy.AddonManager.STATE_CANCELLED) { install.cancel(); } } }); let notification = PopupNotifications.show( browser, "addon-progress", messageString, THUNDERBIRD_ANCHOR_ID, action, [secondaryAction], options ); notification._startTime = Date.now(); break; } case "addon-install-failed": { options.removeOnDismissal = true; options.persistent = false; // TODO This isn't terribly ideal for the multiple failure case for (let install of installInfo.installs) { let host; try { host = options.displayURI.host; } catch (e) { // displayURI might be missing or 'host' might throw for non-nsStandardURL nsIURIs. } if (!host) { host = install.sourceURI instanceof Ci.nsIStandardURL && install.sourceURI.host; } let messageString; if ( install.addon && !Services.policies.mayInstallAddon(install.addon) ) { messageString = lazy.l10n.formatValueSync( "addon-install-blocked-by-policy", { addonName: install.name, addonId: install.addon.id } ); let extensionSettings = Services.policies.getExtensionSettings( install.addon.id ); if ( extensionSettings && "blocked_install_message" in extensionSettings ) { messageString += " " + extensionSettings.blocked_install_message; } } else { // TODO bug 1834484: simplify computation of isLocal. const isLocal = !host; let errorId = ERROR_L10N_IDS.get(install.error)?.[isLocal ? 1 : 0]; const args = { addonName: install.name }; if (!errorId) { if ( install.addon.blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED ) { errorId = "addon-install-error-blocklisted"; } else { errorId = "addon-install-error-incompatible"; args.appVersion = Services.appinfo.version; } } messageString = lazy.l10n.formatValueSync(errorId, args); } // Add Learn More link when refusing to install an unsigned add-on if (install.error == lazy.AddonManager.ERROR_SIGNEDSTATE_REQUIRED) { options.learnMoreURL = Services.urlFormatter.formatURLPref("app.support.baseURL") + "unsigned-addons"; } PopupNotifications.show( browser, aTopic, messageString, THUNDERBIRD_ANCHOR_ID, null, null, options ); // Can't have multiple notifications with the same ID, so stop here. break; } this._removeProgressNotification(browser); break; } case "addon-install-confirmation": { let showNotification = () => { let height; if (PopupNotifications.isPanelOpen) { let rect = getTopWindow() .document.getElementById("addon-progress-notification") .getBoundingClientRect(); height = rect.height; } this._removeProgressNotification(browser); this.showInstallConfirmation(browser, installInfo, height); }; let progressNotification = PopupNotifications.getNotification( "addon-progress", browser ); if (progressNotification) { let downloadDuration = Date.now() - progressNotification._startTime; let securityDelay = Services.prefs.getIntPref("security.dialog_enable_delay") - downloadDuration; if (securityDelay > 0) { getTopWindow().setTimeout(() => { // The download may have been cancelled during the security delay if ( PopupNotifications.getNotification("addon-progress", browser) ) { showNotification(); } }, securityDelay); break; } } showNotification(); } } }, _removeProgressNotification(aBrowser) { let notification = PopupNotifications.getNotification( "addon-progress", aBrowser ); if (notification) { notification.remove(); } }, }; Services.obs.addObserver(gXPInstallObserver, "addon-install-disabled"); Services.obs.addObserver(gXPInstallObserver, "addon-install-origin-blocked"); Services.obs.addObserver(gXPInstallObserver, "addon-install-policy-blocked"); Services.obs.addObserver(gXPInstallObserver, "addon-install-webapi-blocked"); Services.obs.addObserver(gXPInstallObserver, "addon-install-blocked"); Services.obs.addObserver(gXPInstallObserver, "addon-install-started"); Services.obs.addObserver(gXPInstallObserver, "addon-install-failed"); Services.obs.addObserver(gXPInstallObserver, "addon-install-confirmation"); /** * This object is Thunderbird's version of the same object in * browser/modules/ExtensionsUI.jsm */ var ExtensionsUI = { sideloaded: new Set(), updates: new Set(), sideloadListener: null, pendingNotifications: new WeakMap(), async init() { 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("mail:3pane").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) { // This is for compatibility. Thunderbird just shows the prompt. return this.showPermissionsPrompt(tabbrowser, strings, icon); }, 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).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 ) { await 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).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(); } ); }, async 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(); } let strings = this._buildStrings(info); let data = new lazy.ExtensionData(info.addon.getResourceURI()); await data.loadManifest(); if (data.manifest.experiment_apis) { // Add the experiment permission text and use the header for // extensions with permissions. let [experimentWarning] = await lazy.l10n.formatValues([ "webext-experiment-warning", ]); let [header, msg] = await PERMISSION_L10N.formatValues([ { id: "webext-perms-header-with-perms", args: { extension: "<>" }, }, "webext-perms-description-experiment", ]); strings.header = header; strings.msgs = [msg]; if (info.source != "AMO") { strings.experimentWarning = experimentWarning; } } // Thunderbird doesn't care about signing and does not check // info.addon.signedState as Firefox is doing it. info.unsigned = false; // If this is an update with no promptable permissions, just apply it. Skip // prompts also, if this add-on already has full access via experiment_apis. if (info.type == "update") { let extension = lazy.ExtensionParent.GlobalManager.getExtension( info.addon.id ); if ( !strings.msgs.length || (extension && extension.manifest.experiment_apis) ) { info.resolve(); return; } } let icon = info.unsigned ? "chrome://global/skin/icons/warning.svg" : info.icon; 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, }); } // Reject add-ons using the legacy API. We cannot use the general "ignore // unknown APIs" policy, as add-ons using the Legacy API from TB68 will // not do anything, confusing the user. if (data.manifest.legacy) { let subject = { wrappedJSObject: { browser, originatingURI: null, installs: [ { addon: info.addon, name: info.addon.name, error: 0, }, ], install: null, cancel: null, }, }; Services.obs.notifyObservers(subject, "addon-install-failed"); info.reject(); return; } this.showPermissionsPrompt(browser, strings, icon).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. Skip prompts // also, if this add-on already has full access via experiment_apis. let extension = lazy.ExtensionParent.GlobalManager.getExtension( info.addon.id ); if ( !strings.msgs.length || (extension && extension.manifest.experiment_apis) ) { 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 = getTopWindow().document.getElementById("tabmail").selectedBrowser; let { 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; // FIXME: These only exist in mozilla/browser/locales/en-US/browser/extensionsUI.ftl. const [searchDesc, searchYes, searchNo] = lazy.l10n.formatMessagesSync([ { id: "webext-default-search-description", args: { addonName: "<>", currentEngine, newEngine }, }, "webext-default-search-yes", "webext-default-search-no", ]); const strings = { addonName: name, text: searchDesc.value }; for (let attr of searchYes.attributes) { if (attr.name === "label") { strings.acceptText = attr.value; } else if (attr.name === "accesskey") { strings.acceptKey = attr.value; } } for (let attr of searchNo.attributes) { if (attr.name === "label") { strings.cancelText = attr.value; } else if (attr.name === "accesskey") { strings.cancelKey = attr.value; } } this.showDefaultSearchPrompt(browser, strings, icon).then(respond); } }, // Create a set of formatted strings for a permission prompt _buildStrings(info) { const strings = lazy.ExtensionData.formatPermissionStrings(info, { collapseOrigins: true, }); strings.addonName = info.addon.name; strings.learnMore = lazy.l10n.formatValueSync("webext-perms-learn-more"); return strings; }, async showPermissionsPrompt(target, strings, icon) { let { browser } = 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; // By default, multiline strings don't get formatted properly. These // are presently only used in site permission add-ons, so we treat it // as a special case to avoid unintended effects on other things. let isMultiline = strings.text.includes("\n\n"); textEl.classList.toggle( "addon-webext-perm-text-multiline", isMultiline ); 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; } let experimentsEl = doc.getElementById( "addon-webext-experiment-warning" ); experimentsEl.textContent = strings.experimentWarning; experimentsEl.hidden = !strings.experimentWarning; } 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, popupOptions: { position: "bottomright topright", }, }; // 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: () => { resolve(true); }, }; let secondaryActions = [ { label: strings.cancelText, accessKey: strings.cancelKey, callback: () => { resolve(false); }, }, ]; PopupNotifications.show( browser, "addon-webext-permissions", strings.header, THUNDERBIRD_ANCHOR_ID, 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 } = getTabBrowser(target); PopupNotifications.show( browser, "addon-webext-defaultsearch", strings.text, THUNDERBIRD_ANCHOR_ID, action, secondaryActions, options ); }); }, async showInstallNotification(target, addon) { let { browser, window } = getTabBrowser(target); const message = await lazy.l10n.formatValue("addon-post-install-message", { addonName: "<>", }); let icon = addon.isWebExtension ? lazy.AddonManager.getPreferredIconURL(addon, 32, window) || DEFAULT_EXTENSION_ICON : "chrome://messenger/skin/addons/addon-install-installed.svg"; let options = { hideClose: true, timeout: Date.now() + 30000, popupIconURL: icon, name: addon.name, }; return PopupNotifications.show( browser, "addon-installed", message, THUNDERBIRD_ANCHOR_ID, null, null, options ); }, }; EventEmitter.decorate(ExtensionsUI); /** * Generate a document fragment for a localized string that has DOM * node replacements. This avoids using getFormattedString followed * by assigning to innerHTML. Fluent can probably replace this when * it is in use everywhere. * * Lifted from BrowserUIUtils.jsm. * * @param {Document} doc * @param {string} msg * The string to put replacements in. Fetch from * a stringbundle using getString or GetStringFromName, * or even an inserted dtd string. * @param {Node | string} nodesOrStrings * The replacement items. Can be a mix of Nodes * and Strings. However, for correct behaviour, the * number of items provided needs to exactly match * the number of replacement strings in the l10n string. * @returns {DocumentFragment} * A document fragment. In the trivial case (no * replacements), this will simply be a fragment with 1 * child, a text node containing the localized string. */ function getLocalizedFragment(doc, msg, ...nodesOrStrings) { // Ensure replacement points are indexed: for (let i = 1; i <= nodesOrStrings.length; i++) { if (!msg.includes("%" + i + "$S")) { msg = msg.replace(/%S/, "%" + i + "$S"); } } let numberOfInsertionPoints = msg.match(/%\d+\$S/g).length; if (numberOfInsertionPoints != nodesOrStrings.length) { console.error( `Message has ${numberOfInsertionPoints} insertion points, ` + `but got ${nodesOrStrings.length} replacement parameters!` ); } let fragment = doc.createDocumentFragment(); let parts = [msg]; let insertionPoint = 1; for (let replacement of nodesOrStrings) { let insertionString = "%" + insertionPoint++ + "$S"; let partIndex = parts.findIndex( part => typeof part == "string" && part.includes(insertionString) ); if (partIndex == -1) { fragment.appendChild(doc.createTextNode(msg)); return fragment; } if (typeof replacement == "string") { parts[partIndex] = parts[partIndex].replace(insertionString, replacement); } else { let [firstBit, lastBit] = parts[partIndex].split(insertionString); parts.splice(partIndex, 1, firstBit, replacement, lastBit); } } // Put everything in a document fragment: for (let part of parts) { if (typeof part == "string") { if (part) { fragment.appendChild(doc.createTextNode(part)); } } else { fragment.appendChild(part); } } return fragment; }