diff options
Diffstat (limited to 'browser/base/content/browser-addons.js')
-rw-r--r-- | browser/base/content/browser-addons.js | 1639 |
1 files changed, 1639 insertions, 0 deletions
diff --git a/browser/base/content/browser-addons.js b/browser/base/content/browser-addons.js new file mode 100644 index 0000000000..f37428aa5c --- /dev/null +++ b/browser/base/content/browser-addons.js @@ -0,0 +1,1639 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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/. */ + +// This file is loaded into the browser window scope. +/* eslint-env mozilla/browser-window */ + +const lazy = {}; + +ChromeUtils.defineModuleGetter( + lazy, + "ExtensionParent", + "resource://gre/modules/ExtensionParent.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "OriginControls", + "resource://gre/modules/ExtensionPermissions.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "ExtensionPermissions", + "resource://gre/modules/ExtensionPermissions.jsm" +); +ChromeUtils.defineESModuleGetters(lazy, { + SITEPERMS_ADDON_TYPE: + "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs", +}); + +customElements.define( + "addon-progress-notification", + class MozAddonProgressNotification extends customElements.get( + "popupnotification" + ) { + show() { + super.show(); + this.progressmeter = document.getElementById( + "addon-progress-notification-progressmeter" + ); + + this.progresstext = document.getElementById( + "addon-progress-notification-progresstext" + ); + + if (!this.notification) { + return; + } + + this.notification.options.installs.forEach(function(aInstall) { + aInstall.addListener(this); + }, this); + + // Calling updateProgress can sometimes cause this notification to be + // removed in the middle of refreshing the notification panel which + // makes the panel get refreshed again. Just initialise to the + // undetermined state and then schedule a proper check at the next + // opportunity + this.setProgress(0, -1); + this._updateProgressTimeout = setTimeout( + this.updateProgress.bind(this), + 0 + ); + } + + disconnectedCallback() { + this.destroy(); + } + + destroy() { + if (!this.notification) { + return; + } + this.notification.options.installs.forEach(function(aInstall) { + aInstall.removeListener(this); + }, this); + + clearTimeout(this._updateProgressTimeout); + } + + setProgress(aProgress, aMaxProgress) { + if (aMaxProgress == -1) { + this.progressmeter.removeAttribute("value"); + } else { + this.progressmeter.setAttribute( + "value", + (aProgress * 100) / aMaxProgress + ); + } + + let now = Date.now(); + + if (!this.notification.lastUpdate) { + this.notification.lastUpdate = now; + this.notification.lastProgress = aProgress; + return; + } + + let delta = now - this.notification.lastUpdate; + if (delta < 400 && aProgress < aMaxProgress) { + return; + } + + // Set min. time delta to avoid division by zero in the upcoming speed calculation + delta = Math.max(delta, 400); + delta /= 1000; + + // This algorithm is the same used by the downloads code. + let speed = (aProgress - this.notification.lastProgress) / delta; + if (this.notification.speed) { + speed = speed * 0.9 + this.notification.speed * 0.1; + } + + this.notification.lastUpdate = now; + this.notification.lastProgress = aProgress; + this.notification.speed = speed; + + let status = null; + [status, this.notification.last] = DownloadUtils.getDownloadStatus( + aProgress, + aMaxProgress, + speed, + this.notification.last + ); + this.progresstext.setAttribute("value", status); + this.progresstext.setAttribute("tooltiptext", status); + } + + cancel() { + let installs = this.notification.options.installs; + installs.forEach(function(aInstall) { + try { + aInstall.cancel(); + } catch (e) { + // Cancel will throw if the download has already failed + } + }, this); + + PopupNotifications.remove(this.notification); + } + + updateProgress() { + if (!this.notification) { + return; + } + + let downloadingCount = 0; + let progress = 0; + let maxProgress = 0; + + this.notification.options.installs.forEach(function(aInstall) { + if (aInstall.maxProgress == -1) { + maxProgress = -1; + } + progress += aInstall.progress; + if (maxProgress >= 0) { + maxProgress += aInstall.maxProgress; + } + if (aInstall.state < AddonManager.STATE_DOWNLOADED) { + downloadingCount++; + } + }); + + if (downloadingCount == 0) { + this.destroy(); + this.progressmeter.removeAttribute("value"); + let status = gNavigatorBundle.getString("addonDownloadVerifying"); + this.progresstext.setAttribute("value", status); + this.progresstext.setAttribute("tooltiptext", status); + } else { + this.setProgress(progress, maxProgress); + } + } + + onDownloadProgress() { + this.updateProgress(); + } + + onDownloadFailed() { + this.updateProgress(); + } + + onDownloadCancelled() { + this.updateProgress(); + } + + onDownloadEnded() { + this.updateProgress(); + } + } +); + +// 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, + }); + } +} + +var gXPInstallObserver = { + _findChildShell(aDocShell, aSoughtShell) { + if (aDocShell == aSoughtShell) { + return aDocShell; + } + + var node = aDocShell.QueryInterface(Ci.nsIDocShellTreeItem); + for (var i = 0; i < node.childCount; ++i) { + var docShell = node.getChildAt(i); + docShell = this._findChildShell(docShell, aSoughtShell); + if (docShell == aSoughtShell) { + return docShell; + } + } + return null; + }, + + _getBrowser(aDocShell) { + for (let browser of gBrowser.browsers) { + if (this._findChildShell(browser.docShell, aDocShell)) { + return browser; + } + } + return null; + }, + + pendingInstalls: new WeakMap(), + + showInstallConfirmation(browser, installInfo, height = undefined) { + // 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 = () => { + // Make sure the browser is still alive. + if (!gBrowser.browsers.includes(browser)) { + return; + } + + 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 != AddonManager.STATE_DOWNLOADED) + ) { + showNextConfirmation(); + return; + } + + // Make notifications persistent + var options = { + displayURI: installInfo.originatingURI, + persistent: true, + hideClose: true, + }; + + if (gUnifiedExtensions.isEnabled) { + options.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 != AddonManager.STATE_CANCELLED) { + install.cancel(); + } + } + } + + showNextConfirmation(); + }; + + let unsigned = installInfo.installs.filter( + i => i.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING + ); + let someUnsigned = + !!unsigned.length && unsigned.length < installInfo.installs.length; + + options.eventCallback = aEvent => { + switch (aEvent) { + case "removed": + cancelInstallation(); + break; + case "shown": + let addonList = document.getElementById( + "addon-install-confirmation-content" + ); + while (addonList.firstChild) { + addonList.firstChild.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); + + if ( + someUnsigned && + install.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING + ) { + let unsignedLabel = document.createXULElement("label"); + unsignedLabel.setAttribute( + "value", + gNavigatorBundle.getString("addonInstall.unsigned") + ); + unsignedLabel.setAttribute( + "class", + "addon-install-confirmation-unsigned" + ); + container.appendChild(unsignedLabel); + } + + addonList.appendChild(container); + } + break; + } + }; + + options.learnMoreURL = Services.urlFormatter.formatURLPref( + "app.support.baseURL" + ); + + let messageString; + let notification = document.getElementById( + "addon-install-confirmation-notification" + ); + if (unsigned.length == installInfo.installs.length) { + // None of the add-ons are verified + messageString = gNavigatorBundle.getString( + "addonConfirmInstallUnsigned.message" + ); + notification.setAttribute("warning", "true"); + options.learnMoreURL += "unsigned-addons"; + } else if (!unsigned.length) { + // All add-ons are verified or don't need to be verified + messageString = gNavigatorBundle.getString("addonConfirmInstall.message"); + notification.removeAttribute("warning"); + options.learnMoreURL += "find-and-install-add-ons"; + } else { + // Some of the add-ons are unverified, the list of names will indicate + // which + messageString = gNavigatorBundle.getString( + "addonConfirmInstallSomeUnsigned.message" + ); + notification.setAttribute("warning", "true"); + options.learnMoreURL += "unsigned-addons"; + } + + let brandBundle = document.getElementById("bundle_brand"); + let brandShortName = brandBundle.getString("brandShortName"); + + messageString = PluralForm.get(installInfo.installs.length, messageString); + messageString = messageString.replace("#1", brandShortName); + messageString = messageString.replace("#2", installInfo.installs.length); + + let action = { + label: gNavigatorBundle.getString("addonInstall.acceptButton2.label"), + accessKey: gNavigatorBundle.getString( + "addonInstall.acceptButton2.accesskey" + ), + callback: acceptInstallation, + }; + + let secondaryAction = { + label: gNavigatorBundle.getString("addonInstall.cancelButton.label"), + accessKey: gNavigatorBundle.getString( + "addonInstall.cancelButton.accesskey" + ), + callback: () => {}, + }; + + if (height) { + notification.style.minHeight = height + "px"; + } + + let tab = gBrowser.getTabForBrowser(browser); + if (tab) { + gBrowser.selectedTab = tab; + } + + let popup = PopupNotifications.show( + browser, + "addon-install-confirmation", + messageString, + gUnifiedExtensions.getPopupAnchorID(browser, window), + 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-complete", + "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; + }, + + logWarningFullScreenInstallBlocked() { + // If notifications have been removed, log a warning to the website console + let consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + let message = gBrowserBundle.GetStringFromName( + "addonInstallFullScreenBlocked" + ); + consoleMsg.initWithWindowID( + message, + gBrowser.currentURI.spec, + null, + 0, + 0, + Ci.nsIScriptError.warningFlag, + "FullScreen", + gBrowser.selectedBrowser.innerWindowID + ); + Services.console.logMessage(consoleMsg); + }, + + observe(aSubject, aTopic, aData) { + var brandBundle = document.getElementById("bundle_brand"); + var installInfo = aSubject.wrappedJSObject; + var browser = installInfo.browser; + + // Make sure the browser is still alive. + if (!browser || !gBrowser.browsers.includes(browser)) { + return; + } + + var messageString, action; + var brandShortName = brandBundle.getString("brandShortName"); + + var notificationID = aTopic; + // Make notifications persistent + var options = { + displayURI: installInfo.originatingURI, + persistent: true, + hideClose: true, + timeout: Date.now() + 30000, + }; + + if (gUnifiedExtensions.isEnabled) { + options.popupOptions = { + position: "bottomright topright", + }; + } + + switch (aTopic) { + case "addon-install-disabled": { + notificationID = "xpinstall-disabled"; + let secondaryActions = null; + + if (Services.prefs.prefIsLocked("xpinstall.enabled")) { + messageString = gNavigatorBundle.getString( + "xpinstallDisabledMessageLocked" + ); + } else { + messageString = gNavigatorBundle.getString( + "xpinstallDisabledMessage" + ); + + action = { + label: gNavigatorBundle.getString("xpinstallDisabledButton"), + accessKey: gNavigatorBundle.getString( + "xpinstallDisabledButton.accesskey" + ), + callback: function editPrefs() { + Services.prefs.setBoolPref("xpinstall.enabled", true); + }, + }; + + secondaryActions = [ + { + label: gNavigatorBundle.getString( + "addonInstall.cancelButton.label" + ), + accessKey: gNavigatorBundle.getString( + "addonInstall.cancelButton.accesskey" + ), + callback: () => {}, + }, + ]; + } + + PopupNotifications.show( + browser, + notificationID, + messageString, + gUnifiedExtensions.getPopupAnchorID(browser, window), + 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": { + if (aTopic == "addon-install-policy-blocked") { + messageString = gNavigatorBundle.getString( + "addonDomainBlockedByPolicy" + ); + } else { + messageString = gNavigatorBundle.getFormattedString( + "xpinstallPromptMessage", + [brandShortName] + ); + } + + 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, + notificationID, + messageString, + gUnifiedExtensions.getPopupAnchorID(browser, window), + null, + null, + options + ); + removeNotificationOnEnd(popup, installInfo.installs); + break; + } + case "addon-install-blocked": { + // 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 = !!options.displayURI; + + if (isSitePermissionAddon) { + messageString = gNavigatorBundle.getString( + "sitePermissionInstallFirstPrompt.header" + ); + } else if (hasHost) { + messageString = gNavigatorBundle.getFormattedString( + "xpinstallPromptMessage.header", + ["<>"] + ); + options.name = options.displayURI.displayHost; + } else { + messageString = gNavigatorBundle.getString( + "xpinstallPromptMessage.header.unknown" + ); + } + // 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 (isSitePermissionAddon) { + message.textContent = gNavigatorBundle.getString( + "sitePermissionInstallFirstPrompt.message" + ); + } else if (hasHost) { + let text = gNavigatorBundle.getString( + "xpinstallPromptMessage.message" + ); + let b = doc.createElementNS("http://www.w3.org/1999/xhtml", "b"); + b.textContent = options.name; + let fragment = BrowserUIUtils.getLocalizedFragment(doc, text, b); + message.appendChild(fragment); + } else { + message.textContent = gNavigatorBundle.getString( + "xpinstallPromptMessage.message.unknown" + ); + } + + let article = isSitePermissionAddon + ? "site-permission-addons" + : "unlisted-extensions-risks"; + let learnMore = doc.getElementById("addon-install-blocked-info"); + learnMore.textContent = gNavigatorBundle.getString( + "xpinstallPromptMessage.learnMore" + ); + learnMore.setAttribute( + "href", + Services.urlFormatter.formatURLPref("app.support.baseURL") + article + ); + }; + + let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI"); + action = { + label: gNavigatorBundle.getString("xpinstallPromptMessage.install"), + accessKey: gNavigatorBundle.getString( + "xpinstallPromptMessage.install.accesskey" + ), + callback() { + secHistogram.add( + Ci.nsISecurityUITelemetry + .WARNING_ADDON_ASKING_PREVENTED_CLICK_THROUGH + ); + installInfo.install(); + }, + }; + let dontAllowAction = { + label: gNavigatorBundle.getString("xpinstallPromptMessage.dontAllow"), + accessKey: gNavigatorBundle.getString( + "xpinstallPromptMessage.dontAllow.accesskey" + ), + callback: () => { + for (let install of installInfo.installs) { + if (install.state != AddonManager.STATE_CANCELLED) { + install.cancel(); + } + } + if (installInfo.cancel) { + installInfo.cancel(); + } + }, + }; + + let neverAllowCallback = () => { + SitePermissions.setForPrincipal( + browser.contentPrincipal, + "install", + SitePermissions.BLOCK + ); + for (let install of installInfo.installs) { + if (install.state != AddonManager.STATE_CANCELLED) { + install.cancel(); + } + } + if (installInfo.cancel) { + installInfo.cancel(); + } + }; + + let neverAllowAction = { + label: gNavigatorBundle.getString( + "xpinstallPromptMessage.neverAllow" + ), + accessKey: gNavigatorBundle.getString( + "xpinstallPromptMessage.neverAllow.accesskey" + ), + callback: neverAllowCallback, + }; + + let neverAllowAndReportAction = { + label: gNavigatorBundle.getString( + "xpinstallPromptMessage.neverAllowAndReport" + ), + accessKey: gNavigatorBundle.getString( + "xpinstallPromptMessage.neverAllowAndReport.accesskey" + ), + callback: () => { + AMTelemetry.recordEvent({ + method: "reportSuspiciousSite", + object: "suspiciousSite", + value: displayURI?.displayHost ?? "(unknown)", + extra: {}, + }); + neverAllowCallback(); + }, + }; + + secHistogram.add( + Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED + ); + + let declineActions = [dontAllowAction, neverAllowAction]; + 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(neverAllowAndReportAction); + } + let popup = PopupNotifications.show( + browser, + notificationID, + messageString, + gUnifiedExtensions.getPopupAnchorID(browser, window), + action, + declineActions, + options + ); + removeNotificationOnEnd(popup, installInfo.installs); + break; + } + case "addon-install-started": { + let needsDownload = function needsDownload(aInstall) { + return aInstall.state != AddonManager.STATE_DOWNLOADED; + }; + // If all installs have already been downloaded then there is no need to + // show the download progress + if (!installInfo.installs.some(needsDownload)) { + return; + } + notificationID = "addon-progress"; + messageString = gNavigatorBundle.getString( + "addonDownloadingAndVerifying" + ); + messageString = PluralForm.get( + installInfo.installs.length, + messageString + ); + messageString = messageString.replace( + "#1", + 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; + } + }; + action = { + label: gNavigatorBundle.getString("addonInstall.acceptButton2.label"), + accessKey: gNavigatorBundle.getString( + "addonInstall.acceptButton2.accesskey" + ), + disabled: true, + callback: () => {}, + }; + let secondaryAction = { + label: gNavigatorBundle.getString("addonInstall.cancelButton.label"), + accessKey: gNavigatorBundle.getString( + "addonInstall.cancelButton.accesskey" + ), + callback: () => { + for (let install of installInfo.installs) { + if (install.state != AddonManager.STATE_CANCELLED) { + install.cancel(); + } + } + }, + }; + let notification = PopupNotifications.show( + browser, + notificationID, + messageString, + gUnifiedExtensions.getPopupAnchorID(browser, window), + 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; + } + + // Construct the l10n ID for the error, e.g. "addonInstallError-3" + let error = + host || install.error == 0 + ? "addonInstallError" + : "addonLocalInstallError"; + let args; + if (install.error < 0) { + // Append the error code for the installation failure to get the + // matching translation of the error. The error code is 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. + error += install.error; + args = [brandShortName, install.name]; + } else if ( + install.addon.blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED + ) { + error += "Blocklisted"; + args = [install.name]; + } else { + error += "Incompatible"; + args = [brandShortName, Services.appinfo.version, install.name]; + } + + if ( + install.addon && + !Services.policies.mayInstallAddon(install.addon) + ) { + error = "addonInstallBlockedByPolicy"; + let extensionSettings = Services.policies.getExtensionSettings( + install.addon.id + ); + let message = ""; + if ( + extensionSettings && + "blocked_install_message" in extensionSettings + ) { + message = " " + extensionSettings.blocked_install_message; + } + args = [install.name, install.addon.id, message]; + } + + // Add Learn More link when refusing to install an unsigned add-on + if (install.error == AddonManager.ERROR_SIGNEDSTATE_REQUIRED) { + options.learnMoreURL = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "unsigned-addons"; + } + + messageString = gNavigatorBundle.getFormattedString(error, args); + + PopupNotifications.show( + browser, + notificationID, + messageString, + gUnifiedExtensions.getPopupAnchorID(browser, window), + action, + 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 = undefined; + + if (PopupNotifications.isPanelOpen) { + let rect = 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) { + setTimeout(() => { + // The download may have been cancelled during the security delay + if ( + PopupNotifications.getNotification("addon-progress", browser) + ) { + showNotification(); + } + }, securityDelay); + break; + } + } + showNotification(); + break; + } + case "addon-install-complete": { + let secondaryActions = null; + let numAddons = installInfo.installs.length; + + if (numAddons == 1) { + messageString = gNavigatorBundle.getFormattedString( + "addonInstalled", + [installInfo.installs[0].name] + ); + } else { + messageString = gNavigatorBundle.getString("addonsGenericInstalled"); + messageString = PluralForm.get(numAddons, messageString); + messageString = messageString.replace("#1", numAddons); + } + action = null; + + options.removeOnDismissal = true; + options.persistent = false; + + PopupNotifications.show( + browser, + notificationID, + messageString, + gUnifiedExtensions.getPopupAnchorID(browser, window), + action, + secondaryActions, + options + ); + break; + } + } + }, + _removeProgressNotification(aBrowser) { + let notification = PopupNotifications.getNotification( + "addon-progress", + aBrowser + ); + if (notification) { + notification.remove(); + } + }, +}; + +var gExtensionsNotifications = { + initialized: false, + init() { + this.updateAlerts(); + this.boundUpdate = this.updateAlerts.bind(this); + ExtensionsUI.on("change", this.boundUpdate); + this.initialized = true; + }, + + uninit() { + // uninit() can race ahead of init() in some cases, if that happens, + // we have no handler to remove. + if (!this.initialized) { + return; + } + ExtensionsUI.off("change", this.boundUpdate); + }, + + _createAddonButton(text, icon, callback) { + let button = document.createXULElement("toolbarbutton"); + button.setAttribute("wrap", "true"); + button.setAttribute("label", text); + button.setAttribute("tooltiptext", text); + const DEFAULT_EXTENSION_ICON = + "chrome://mozapps/skin/extensions/extensionGeneric.svg"; + button.setAttribute("image", icon || DEFAULT_EXTENSION_ICON); + button.className = "addon-banner-item subviewbutton"; + + button.addEventListener("command", callback); + PanelUI.addonNotificationContainer.appendChild(button); + }, + + updateAlerts() { + let sideloaded = ExtensionsUI.sideloaded; + let updates = ExtensionsUI.updates; + + let container = PanelUI.addonNotificationContainer; + + while (container.firstChild) { + container.firstChild.remove(); + } + + let items = 0; + for (let update of updates) { + if (++items > 4) { + break; + } + let text = gNavigatorBundle.getFormattedString( + "webextPerms.updateMenuItem", + [update.addon.name] + ); + this._createAddonButton(text, update.addon.iconURL, evt => { + ExtensionsUI.showUpdate(gBrowser, update); + }); + } + + let appName; + for (let addon of sideloaded) { + if (++items > 4) { + break; + } + if (!appName) { + let brandBundle = document.getElementById("bundle_brand"); + appName = brandBundle.getString("brandShortName"); + } + + let text = gNavigatorBundle.getFormattedString( + "webextPerms.sideloadMenuItem", + [addon.name, appName] + ); + this._createAddonButton(text, addon.iconURL, evt => { + // We need to hide the main menu manually because the toolbarbutton is + // removed immediately while processing this event, and PanelUI is + // unable to identify which panel should be closed automatically. + PanelUI.hide(); + ExtensionsUI.showSideloaded(gBrowser, addon); + }); + } + }, +}; + +var BrowserAddonUI = { + async promptRemoveExtension(addon) { + let { name } = addon; + let title = await document.l10n.formatValue("addon-removal-title", { + name, + }); + let { getFormattedString, getString } = gNavigatorBundle; + let btnTitle = getString("webext.remove.confirmation.button"); + let { + BUTTON_TITLE_IS_STRING: titleString, + BUTTON_TITLE_CANCEL: titleCancel, + BUTTON_POS_0, + BUTTON_POS_1, + confirmEx, + } = Services.prompt; + let btnFlags = BUTTON_POS_0 * titleString + BUTTON_POS_1 * titleCancel; + let checkboxState = { value: false }; + let checkboxMessage = null; + + // Enable abuse report checkbox in the remove extension dialog, + // if enabled by the about:config prefs and the addon type + // is currently supported. + if ( + gAddonAbuseReportEnabled && + ["extension", "theme"].includes(addon.type) + ) { + checkboxMessage = await document.l10n.formatValue( + "addon-removal-abuse-report-checkbox" + ); + } + + let message = null; + + if (!Services.prefs.getBoolPref("prompts.windowPromptSubDialog", false)) { + message = getFormattedString("webext.remove.confirmation.message", [ + name, + document.getElementById("bundle_brand").getString("brandShorterName"), + ]); + } + + let result = confirmEx( + window, + title, + message, + btnFlags, + btnTitle, + /* button1 */ null, + /* button2 */ null, + checkboxMessage, + checkboxState + ); + + return { remove: result === 0, report: checkboxState.value }; + }, + + async reportAddon(addonId, reportEntryPoint) { + let addon = addonId && (await AddonManager.getAddonByID(addonId)); + if (!addon) { + return; + } + + const win = await BrowserOpenAddonsMgr("addons://list/extension"); + + win.openAbuseReport({ addonId, reportEntryPoint }); + }, + + async removeAddon(addonId, eventObject) { + let addon = addonId && (await AddonManager.getAddonByID(addonId)); + if (!addon || !(addon.permissions & AddonManager.PERM_CAN_UNINSTALL)) { + return; + } + + let { remove, report } = await this.promptRemoveExtension(addon); + + AMTelemetry.recordActionEvent({ + object: eventObject, + action: "uninstall", + value: remove ? "accepted" : "cancelled", + extra: { addonId }, + }); + + if (remove) { + // Leave the extension in pending uninstall if we are also reporting the + // add-on. + await addon.uninstall(report); + + if (report) { + await this.reportAddon(addon.id, "uninstall"); + } + } + }, + + async manageAddon(addonId, eventObject) { + let addon = addonId && (await AddonManager.getAddonByID(addonId)); + if (!addon) { + return; + } + + BrowserOpenAddonsMgr("addons://detail/" + encodeURIComponent(addon.id)); + AMTelemetry.recordActionEvent({ + object: eventObject, + action: "manage", + extra: { addonId: addon.id }, + }); + }, +}; + +// We must declare `gUnifiedExtensions` using `var` below to avoid a +// "redeclaration" syntax error. +var gUnifiedExtensions = { + _initialized: false, + + // We use a `<deck>` in the extension items to show/hide messages below each + // extension name. We have a default message for origin controls, and + // optionally a second message shown on hover, which describes the action + // (when clicking on the action button). We have another message shown when + // the menu button is hovered/focused. The constants below define the indexes + // of each message in the `<deck>`. + MESSAGE_DECK_INDEX_DEFAULT: 0, + MESSAGE_DECK_INDEX_HOVER: 1, + MESSAGE_DECK_INDEX_MENU_HOVER: 2, + + init() { + if (this._initialized) { + return; + } + + if (this.isEnabled) { + this._button = document.getElementById("unified-extensions-button"); + // TODO: Bug 1778684 - Auto-hide button when there is no active extension. + this._button.hidden = false; + + document + .getElementById("nav-bar") + .setAttribute("unifiedextensionsbuttonshown", true); + + gBrowser.addTabsProgressListener(this); + window.addEventListener("TabSelect", () => this.updateAttention()); + + this.permListener = () => this.updateAttention(); + lazy.ExtensionPermissions.addListener(this.permListener); + + gNavToolbox.addEventListener("customizationstarting", this); + } + + this._initialized = true; + }, + + uninit() { + if (this.permListener) { + lazy.ExtensionPermissions.removeListener(this.permListener); + this.permListener = null; + } + gNavToolbox.removeEventListener("customizationstarting", this); + }, + + get isEnabled() { + return Services.prefs.getBoolPref( + "extensions.unifiedExtensions.enabled", + false + ); + }, + + onLocationChange(browser, webProgress, _request, _uri, flags) { + // Only update on top-level cross-document navigations in the selected tab. + if ( + webProgress.isTopLevel && + browser === gBrowser.selectedBrowser && + !(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) + ) { + this.updateAttention(); + } + }, + + // Update the attention indicator for the whole unified extensions button. + updateAttention() { + let attention = false; + for (let policy of this.getActivePolicies()) { + let widget = this.browserActionFor(policy)?.widget; + + // Only show for extensions which are not already visible in the toolbar. + if (!widget || widget.areaType !== CustomizableUI.TYPE_TOOLBAR) { + if (lazy.OriginControls.getAttention(policy, window)) { + attention = true; + break; + } + } + } + this.button.toggleAttribute("attention", attention); + this.button.ownerDocument.l10n.setAttributes( + this.button, + attention + ? "unified-extensions-button-permissions-needed" + : "unified-extensions-button" + ); + }, + + getPopupAnchorID(aBrowser, aWindow) { + if (this.isEnabled) { + const anchorID = "unified-extensions-button"; + const attr = anchorID + "popupnotificationanchor"; + + if (!aBrowser[attr]) { + // A hacky way of setting the popup anchor outside the usual url bar + // icon box, similar to how it was done for CFR. + // See: https://searchfox.org/mozilla-central/rev/c5c002f81f08a73e04868e0c2bf0eb113f200b03/toolkit/modules/PopupNotifications.sys.mjs#40 + aBrowser[attr] = aWindow.document.getElementById( + anchorID + // Anchor on the toolbar icon to position the popup right below the + // button. + ).firstElementChild; + } + + return anchorID; + } + + return "addons-notification-icon"; + }, + + get button() { + return this._button; + }, + + /** + * Gets a list of active WebExtensionPolicy instances of type "extension", + * sorted alphabetically based on add-on's names. Optionally, filter out + * extensions with browser action. + * + * @param {bool} all When set to true (the default), return the list of all + * active policies, including the ones that have a + * browser action. Otherwise, extensions with browser + * action are filtered out. + * @returns {Array<WebExtensionPolicy>} An array of active policies. + */ + getActivePolicies(all = true) { + let policies = WebExtensionPolicy.getActiveExtensions(); + policies = policies.filter(policy => { + let { extension } = policy; + if (!policy.active || extension?.type !== "extension") { + return false; + } + + // Ignore hidden and extensions that cannot access the current window + // (because of PB mode when we are in a private window), since users + // cannot do anything with those extensions anyway. + if (extension.isHidden || !policy.canAccessWindow(window)) { + return false; + } + + return all || !extension.hasBrowserActionUI; + }); + + policies.sort((a, b) => a.name.localeCompare(b.name)); + return policies; + }, + + /** + * Returns true when there are active extensions listed/shown in the unified + * extensions panel, and false otherwise (e.g. when extensions are pinned in + * the toolbar OR there are 0 active extensions). + * + * @returns {boolean} Whether there are extensions listed in the panel. + */ + hasExtensionsInPanel() { + const policies = this.getActivePolicies(); + + return !!policies + .map(policy => this.browserActionFor(policy)?.widget) + .filter(widget => { + return ( + !widget || + widget?.areaType !== CustomizableUI.TYPE_TOOLBAR || + widget?.forWindow(window).overflowed + ); + }).length; + }, + + handleEvent(event) { + switch (event.type) { + case "ViewShowing": + this.onPanelViewShowing(event.target); + break; + + case "ViewHiding": + this.onPanelViewHiding(event.target); + break; + + case "customizationstarting": + this.panel.hidePopup(); + break; + } + }, + + onPanelViewShowing(panelview) { + const list = panelview.querySelector(".unified-extensions-list"); + // Only add extensions that do not have a browser action in this list since + // the extensions with browser action have CUI widgets and will appear in + // the panel (or toolbar) via the CUI mechanism. + const policies = this.getActivePolicies(/* all */ false); + + for (const policy of policies) { + const item = document.createElement("unified-extensions-item"); + item.setExtension(policy.extension); + list.appendChild(item); + } + }, + + onPanelViewHiding(panelview) { + const list = panelview.querySelector(".unified-extensions-list"); + while (list.lastChild) { + list.lastChild.remove(); + } + }, + + _panel: null, + get panel() { + // Lazy load the unified-extensions-panel panel the first time we need to + // display it. + if (!this._panel) { + let template = document.getElementById( + "unified-extensions-panel-template" + ); + template.replaceWith(template.content); + this._panel = document.getElementById("unified-extensions-panel"); + let customizationArea = this._panel.querySelector( + "#unified-extensions-area" + ); + CustomizableUI.registerPanelNode( + customizationArea, + CustomizableUI.AREA_ADDONS + ); + CustomizableUI.addPanelCloseListeners(this._panel); + + // Lazy-load the l10n strings. Those strings are used for the CUI and + // non-CUI extensions in the unified extensions panel. + document + .getElementById("unified-extensions-context-menu") + .querySelectorAll("[data-lazy-l10n-id]") + .forEach(el => { + el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id")); + el.removeAttribute("data-lazy-l10n-id"); + }); + } + return this._panel; + }, + + async togglePanel(aEvent) { + if (!CustomizationHandler.isCustomizing()) { + if (aEvent) { + if ( + // On MacOS, ctrl-click will send a context menu event from the + // widget, so we don't want to bring up the panel when ctrl key is + // pressed. + (aEvent.type == "mousedown" && + (aEvent.button !== 0 || + (AppConstants.platform === "macosx" && aEvent.ctrlKey))) || + (aEvent.type === "keypress" && + aEvent.charCode !== KeyEvent.DOM_VK_SPACE && + aEvent.keyCode !== KeyEvent.DOM_VK_RETURN) + ) { + return; + } + + // The button should directly open `about:addons` when the user does not + // have any active extensions listed in the unified extensions panel. + if (!this.hasExtensionsInPanel()) { + await BrowserOpenAddonsMgr("addons://discover/"); + return; + } + } + + let panel = this.panel; + + if (!this._listView) { + this._listView = PanelMultiView.getViewNode( + document, + "unified-extensions-view" + ); + this._listView.addEventListener("ViewShowing", this); + this._listView.addEventListener("ViewHiding", this); + } + + if (this._button.open) { + PanelMultiView.hidePopup(panel); + this._button.open = false; + } else { + panel.hidden = false; + PanelMultiView.openPopup(panel, this._button, { + triggerEvent: aEvent, + }); + } + } + + // We always dispatch an event (useful for testing purposes). + window.dispatchEvent(new CustomEvent("UnifiedExtensionsTogglePanel")); + }, + + async updateContextMenu(menu, event) { + // When the context menu is open, `onpopupshowing` is called when menu + // items open sub-menus. We don't want to update the context menu in this + // case. + if (event.target.id !== "unified-extensions-context-menu") { + return; + } + + const id = this._getExtensionId(menu); + const addon = await AddonManager.getAddonByID(id); + const widgetId = this._getWidgetId(menu); + const forBrowserAction = !!widgetId; + + const pinButton = menu.querySelector( + ".unified-extensions-context-menu-pin-to-toolbar" + ); + const removeButton = menu.querySelector( + ".unified-extensions-context-menu-remove-extension" + ); + const reportButton = menu.querySelector( + ".unified-extensions-context-menu-report-extension" + ); + const menuSeparator = menu.querySelector( + ".unified-extensions-context-menu-management-separator" + ); + + menuSeparator.hidden = !forBrowserAction; + pinButton.hidden = !forBrowserAction; + + if (forBrowserAction) { + let area = CustomizableUI.getPlacementOfWidget(widgetId).area; + let inToolbar = area != CustomizableUI.AREA_ADDONS; + pinButton.setAttribute("checked", inToolbar); + } + + reportButton.hidden = !gAddonAbuseReportEnabled; + removeButton.disabled = !( + addon.permissions & AddonManager.PERM_CAN_UNINSTALL + ); + + ExtensionsUI.originControlsMenu(menu, id); + + const browserAction = this.browserActionFor(WebExtensionPolicy.getByID(id)); + if (browserAction) { + browserAction.updateContextMenu(menu); + } + }, + + // This is registered on the top-level unified extensions context menu. + onContextMenuCommand(menu, event) { + this.togglePanel(); + }, + + browserActionFor(policy) { + // Ideally, we wouldn't do that because `browserActionFor()` will only be + // defined in `global` when at least one extension has required loading the + // `ext-browserAction` code. + let method = lazy.ExtensionParent.apiManager.global.browserActionFor; + return method?.(policy?.extension); + }, + + async manageExtension(menu) { + const id = this._getExtensionId(menu); + + await BrowserAddonUI.manageAddon(id, "unifiedExtensions"); + }, + + async removeExtension(menu) { + const id = this._getExtensionId(menu); + + await BrowserAddonUI.removeAddon(id, "unifiedExtensions"); + }, + + async reportExtension(menu) { + const id = this._getExtensionId(menu); + + await BrowserAddonUI.reportAddon(id, "unified_context_menu"); + }, + + _getExtensionId(menu) { + const { triggerNode } = menu; + return triggerNode.dataset.extensionid; + }, + + _getWidgetId(menu) { + const { triggerNode } = menu; + return triggerNode.closest(".unified-extensions-item")?.id; + }, + + async onPinToToolbarChange(menu, event) { + let shouldPinToToolbar = event.target.getAttribute("checked") == "true"; + // Revert the checkbox back to its original state. This is because the + // addon context menu handlers are asynchronous, and there seems to be + // a race where the checkbox state won't get set in time to show the + // right state. So we err on the side of caution, and presume that future + // attempts to open this context menu on an extension button will show + // the same checked state that we started in. + event.target.setAttribute("checked", !shouldPinToToolbar); + + let widgetId = this._getWidgetId(menu); + if (!widgetId) { + return; + } + + this.pinToToolbar(widgetId, shouldPinToToolbar); + }, + + pinToToolbar(widgetId, shouldPinToToolbar) { + let newArea = shouldPinToToolbar + ? CustomizableUI.AREA_NAVBAR + : CustomizableUI.AREA_ADDONS; + let newPosition = shouldPinToToolbar ? undefined : 0; + + CustomizableUI.addWidgetToArea(widgetId, newArea, newPosition); + + this.updateAttention(); + }, +}; |