/* -*- 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(); } let hasHost = !!options.displayURI; 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. 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 ( // Use a install prompt message when the only addon being installed is a SitePerms addon // (NOTE: AOM doesn't support anymore installing multiple addons at the same time anymore, // and so a sitepermission addon type is expected to be always the only entry in installInfo.installs). installInfo.installs.every( ({ addon }) => addon?.type === lazy.SITEPERMS_ADDON_TYPE ) ) { message.textContent = gNavigatorBundle.getFormattedString( "sitePermissionsInstallPromptMessage.message", [options.name] ); } 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 learnMore = doc.getElementById("addon-install-blocked-info"); learnMore.textContent = gNavigatorBundle.getString( "xpinstallPromptMessage.learnMore" ); learnMore.setAttribute( "href", Services.urlFormatter.formatURLPref("app.support.baseURL") + "unlisted-extensions-risks" ); }; 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 neverAllowAction = { label: gNavigatorBundle.getString( "xpinstallPromptMessage.neverAllow" ), accessKey: gNavigatorBundle.getString( "xpinstallPromptMessage.neverAllow.accesskey" ), callback: () => { 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(); } }, }; secHistogram.add( Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED ); let popup = PopupNotifications.show( browser, notificationID, messageString, gUnifiedExtensions.getPopupAnchorID(browser, window), action, [dontAllowAction, neverAllowAction], 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 }, }); }, }; /** * The `unified-extensions-item` custom element is used to manage an extension * in the list of extensions, which is displayed when users click the unified * extensions (toolbar) button. * * This custom element must be initialized with `setAddon()`: * * ``` * let item = document.createElement("unified-extensions-item"); * item.setAddon(addon); * document.body.appendChild(item); * ``` */ customElements.define( "unified-extensions-item", class extends HTMLElement { /** * Set the add-on for this item. The item will be populated based on the * add-on when it is rendered into the DOM. * * @param {AddonWrapper} addon The add-on to use. */ setAddon(addon) { this.addon = addon; } connectedCallback() { if (this._openMenuButton) { return; } const template = document.getElementById( "unified-extensions-item-template" ); this.appendChild(template.content.cloneNode(true)); this._actionButton = this.querySelector( ".unified-extensions-item-action" ); this._openMenuButton = this.querySelector( ".unified-extensions-item-open-menu" ); this._openMenuButton.addEventListener("blur", this); this._openMenuButton.addEventListener("focus", this); this.addEventListener("command", this); this.addEventListener("mouseout", this); this.addEventListener("mouseover", this); this.render(); } handleEvent(event) { const { target } = event; switch (event.type) { case "command": if (target === this._openMenuButton) { const popup = target.ownerDocument.getElementById( "unified-extensions-context-menu" ); popup.openPopup( target, "after_end", 0, 0, true /* isContextMenu */, false /* attributesOverride */, event ); } else if (target === this._actionButton) { const extension = WebExtensionPolicy.getByID(this.addon.id) ?.extension; if (!extension) { return; } const win = event.target.ownerGlobal; const tab = win.gBrowser.selectedTab; extension.tabManager.addActiveTabPermission(tab); extension.tabManager.activateScripts(tab); } break; case "blur": case "mouseout": if (target === this._openMenuButton) { this.removeAttribute("secondary-button-hovered"); } else if (target === this._actionButton) { this._updateStateMessage(); } break; case "focus": case "mouseover": if (target === this._openMenuButton) { this.setAttribute("secondary-button-hovered", true); } else if (target === this._actionButton) { this._updateStateMessage({ hover: true }); } break; } } async _updateStateMessage({ hover = false } = {}) { const policy = WebExtensionPolicy.getByID(this.addon.id); const messages = lazy.OriginControls.getStateMessageIDs( policy, this.ownerGlobal.gBrowser.currentURI ); if (!messages) { return; } const messageElement = this.querySelector( ".unified-extensions-item-message-default" ); // We only want to adjust the height of an item in the panel when we // first draw it, and not on hover (even if the hover message is longer, // which shouldn't happen in practice but even if it was, we don't want // to change the height on hover). let adjustMinHeight = false; if (hover && messages.onHover) { this.ownerDocument.l10n.setAttributes(messageElement, messages.onHover); } else if (messages.default) { this.ownerDocument.l10n.setAttributes(messageElement, messages.default); adjustMinHeight = true; } await document.l10n.translateElements([messageElement]); if (adjustMinHeight) { const contentsElement = this.querySelector( ".unified-extensions-item-contents" ); const { height } = getComputedStyle(contentsElement); contentsElement.style.minHeight = height; } } _hasAction() { const policy = WebExtensionPolicy.getByID(this.addon.id); const state = lazy.OriginControls.getState( policy, this.ownerGlobal.gBrowser.currentURI ); return state && state.whenClicked && !state.hasAccess; } render() { if (!this.addon) { throw new Error( "unified-extensions-item requires an add-on, forgot to call setAddon()?" ); } this.setAttribute("extension-id", this.addon.id); let policy = WebExtensionPolicy.getByID(this.addon.id); this.toggleAttribute( "attention", lazy.OriginControls.getAttention(policy, this.ownerGlobal) ); this.querySelector( ".unified-extensions-item-name" ).textContent = this.addon.name; const iconURL = AddonManager.getPreferredIconURL(this.addon, 32, window); if (iconURL) { this.querySelector(".unified-extensions-item-icon").setAttribute( "src", iconURL ); } this._actionButton.disabled = !this._hasAction(); this._openMenuButton.dataset.extensionId = this.addon.id; this._openMenuButton.setAttribute( "data-l10n-args", JSON.stringify({ extensionName: this.addon.name }) ); this._updateStateMessage(); } } ); // We must declare `gUnifiedExtensions` using `var` below to avoid a // "redeclaration" syntax error. var gUnifiedExtensions = { _initialized: false, 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); } this._initialized = true; }, uninit() { if (this.permListener) { lazy.ExtensionPermissions.removeListener(this.permListener); this.permListener = null; } }, 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. async updateAttention() { for (let addon of await this.getActiveExtensions()) { let policy = WebExtensionPolicy.getByID(addon.id); 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)) { this.button.toggleAttribute("attention", true); return; } } } this.button.toggleAttribute("attention", false); }, 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 AddonWrapper instances of type "extension", sorted * alphabetically based on add-on's names. * * @return {Array} An array of active extensions. */ async getActiveExtensions() { // TODO: Bug 1778682 - Use a new method on `AddonManager` so that we get // the same list of extensions as the one in `about:addons`. // We only want to display active and visible extensions, and we want to // list them alphabetically. let addons = await AddonManager.getAddonsByTypes(["extension"]); addons = addons.filter(addon => !addon.hidden && addon.isActive); addons.sort((a1, a2) => a1.name.localeCompare(a2.name)); return addons; }, handleEvent(event) { switch (event.type) { case "ViewShowing": { this.onPanelViewShowing(event.target); break; } case "ViewHiding": { this.onPanelViewHiding(event.target); } } }, async onPanelViewShowing(panelview) { const list = panelview.querySelector(".unified-extensions-list"); const extensions = await this.getActiveExtensions(); for (const extension of extensions) { const item = document.createElement("unified-extensions-item"); item.setAddon(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"); CustomizableUI.addPanelCloseListeners(this._panel); } return this._panel; }, async togglePanel(aEvent) { if (!CustomizationHandler.isCustomizing()) { if (aEvent && aEvent.button !== 0) { return; } let panel = this.panel; // The button should directly open `about:addons` when there is no active // extension to show in the panel. if ((await this.getActiveExtensions()).length === 0) { await BrowserOpenAddonsMgr("addons://discover/"); return; } if (!this._listView) { this._listView = PanelMultiView.getViewNode( document, "unified-extensions-view" ); this._listView.addEventListener("ViewShowing", this); this._listView.addEventListener("ViewHiding", this); // Lazy-load the l10n strings. 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"); }); } 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 removeButton = menu.querySelector( ".unified-extensions-context-menu-remove-extension" ); const reportButton = menu.querySelector( ".unified-extensions-context-menu-report-extension" ); 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); } }, 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 this.togglePanel(); await BrowserAddonUI.manageAddon(id, "unifiedExtensions"); }, async removeExtension(menu) { const id = this._getExtensionId(menu); await this.togglePanel(); await BrowserAddonUI.removeAddon(id, "unifiedExtensions"); }, async reportExtension(menu) { const id = this._getExtensionId(menu); await this.togglePanel(); await BrowserAddonUI.reportAddon(id, "unified_context_menu"); }, _getExtensionId(menu) { const { triggerNode } = menu; return triggerNode.dataset.extensionId; }, };