summaryrefslogtreecommitdiffstats
path: root/browser/base/content/browser-addons.js
diff options
context:
space:
mode:
Diffstat (limited to 'browser/base/content/browser-addons.js')
-rw-r--r--browser/base/content/browser-addons.js1639
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();
+ },
+};