diff options
Diffstat (limited to 'comm/mail/modules/ExtensionsUI.jsm')
-rw-r--r-- | comm/mail/modules/ExtensionsUI.jsm | 1461 |
1 files changed, 1461 insertions, 0 deletions
diff --git a/comm/mail/modules/ExtensionsUI.jsm b/comm/mail/modules/ExtensionsUI.jsm new file mode 100644 index 0000000000..cc969f2d5a --- /dev/null +++ b/comm/mail/modules/ExtensionsUI.jsm @@ -0,0 +1,1461 @@ +/* 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; +} |