/* -*- 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 */ 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; } const anchorID = "addons-notification-icon"; // Make notifications persistent var options = { displayURI: installInfo.originatingURI, persistent: true, hideClose: true, }; 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, anchorID, 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-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; } const anchorID = "addons-notification-icon"; 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, }; 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, anchorID, action, secondaryActions, options ); break; } case "addon-install-fullscreen-blocked": { // AddonManager denied installation because we are in DOM fullscreen this.logWarningFullScreenInstallBlocked(); break; } case "addon-install-origin-blocked": { 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, anchorID, null, null, options ); removeNotificationOnEnd(popup, installInfo.installs); break; } case "addon-install-blocked": { 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 (hasHost) { let text = gNavigatorBundle.getString( "xpinstallPromptMessage.message" ); let b = doc.createElementNS("http://www.w3.org/1999/xhtml", "b"); b.textContent = options.name; let fragment = BrowserUtils.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(); } } }, }; 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(); } } }, }; secHistogram.add( Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED ); let popup = PopupNotifications.show( browser, notificationID, messageString, anchorID, 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, anchorID, 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 error = host || install.error == 0 ? "addonInstallError" : "addonLocalInstallError"; let args; if (install.error < 0) { 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, anchorID, 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, anchorID, 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("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"; 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 = { promptRemoveExtension(addon) { let { name } = addon; let brand = document .getElementById("bundle_brand") .getString("brandShorterName"); let { getFormattedString, getString } = gNavigatorBundle; let title = getFormattedString("webext.remove.confirmation.title", [name]); let message = getFormattedString("webext.remove.confirmation.message", [ name, brand, ]); 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 = getFormattedString( "webext.remove.abuseReportCheckbox.message", [document.getElementById("bundle_brand").getString("vendorShortName")] ); } const result = confirmEx( null, title, message, btnFlags, btnTitle, null, null, checkboxMessage, checkboxState ); return { remove: result === 0, report: checkboxState.value }; }, async reportAddon(addonId, reportEntryPoint) { 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 } = 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"); } } }, };