1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
10 ChromeUtils.defineESModuleGetters(lazy, {
11 AMBrowserExtensionsImport: "resource://gre/modules/AddonManager.sys.mjs",
12 AMTelemetry: "resource://gre/modules/AddonManager.sys.mjs",
13 AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
14 AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
15 AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs",
16 ExtensionData: "resource://gre/modules/Extension.sys.mjs",
17 ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
18 OriginControls: "resource://gre/modules/ExtensionPermissions.sys.mjs",
19 QuarantinedDomains: "resource://gre/modules/ExtensionPermissions.sys.mjs",
22 ChromeUtils.defineLazyGetter(
26 new Localization(["browser/extensionsUI.ftl", "branding/brand.ftl"], true)
29 ChromeUtils.defineLazyGetter(lazy, "logConsole", () =>
30 console.createInstance({
31 prefix: "ExtensionsUI",
32 maxLogLevelPref: "extensions.webextensions.log.level",
36 XPCOMUtils.defineLazyPreferenceGetter(
38 "POSTINSTALL_PRIVATEBROWSING_CHECKBOX",
39 "extensions.ui.postInstallPrivateBrowsingCheckbox",
43 XPCOMUtils.defineLazyPreferenceGetter(
45 "SHOW_FULL_DOMAINS_LIST",
46 "extensions.ui.installDialogFullDomains",
50 const DEFAULT_EXTENSION_ICON =
51 "chrome://mozapps/skin/extensions/extensionGeneric.svg";
53 function getTabBrowser(browser) {
54 while (browser.ownerGlobal.docShell.itemType !== Ci.nsIDocShell.typeChrome) {
55 browser = browser.ownerGlobal.docShell.chromeEventHandler;
57 let window = browser.ownerGlobal;
58 let viewType = browser.getAttribute("webextension-view-type");
59 if (viewType == "sidebar") {
60 window = window.browsingContext.topChromeWindow;
62 if (viewType == "popup" || viewType == "sidebar") {
63 browser = window.gBrowser.selectedBrowser;
65 return { browser, window };
68 export var ExtensionsUI = {
69 sideloaded: new Set(),
71 sideloadListener: null,
73 pendingNotifications: new WeakMap(),
75 get SHOW_FULL_DOMAINS_LIST() {
76 return lazy.SHOW_FULL_DOMAINS_LIST;
79 get POSTINSTALL_PRIVATEBROWSING_CHECKBOX() {
80 return lazy.POSTINSTALL_PRIVATEBROWSING_CHECKBOX;
84 Services.obs.addObserver(this, "webextension-permission-prompt");
85 Services.obs.addObserver(this, "webextension-update-permissions");
86 Services.obs.addObserver(this, "webextension-install-notify");
87 Services.obs.addObserver(this, "webextension-optional-permission-prompt");
88 Services.obs.addObserver(this, "webextension-defaultsearch-prompt");
89 Services.obs.addObserver(this, "webextension-imported-addons-cancelled");
90 Services.obs.addObserver(this, "webextension-imported-addons-complete");
91 Services.obs.addObserver(this, "webextension-imported-addons-pending");
93 await Services.wm.getMostRecentWindow("navigator:browser")
94 .delayedStartupPromise;
96 this._checkForSideloaded();
99 async _checkForSideloaded() {
100 let sideloaded = await lazy.AddonManagerPrivate.getNewSideloads();
102 if (!sideloaded.length) {
103 // No new side-loads. We're done.
107 // The ordering shouldn't matter, but tests depend on notifications
108 // happening in a specific order.
109 sideloaded.sort((a, b) => a.id.localeCompare(b.id));
111 if (!this.sideloadListener) {
112 this.sideloadListener = {
113 onEnabled: addon => {
114 if (!this.sideloaded.has(addon)) {
118 this.sideloaded.delete(addon);
119 this._updateNotifications();
121 if (this.sideloaded.size == 0) {
122 lazy.AddonManager.removeAddonListener(this.sideloadListener);
123 this.sideloadListener = null;
127 lazy.AddonManager.addAddonListener(this.sideloadListener);
130 for (let addon of sideloaded) {
131 this.sideloaded.add(addon);
133 this._updateNotifications();
136 _updateNotifications() {
137 const { sideloaded, updates } = this;
138 const { importedAddonIDs } = lazy.AMBrowserExtensionsImport;
140 if (importedAddonIDs.length + sideloaded.size + updates.size == 0) {
141 lazy.AppMenuNotifications.removeNotification("addon-alert");
143 lazy.AppMenuNotifications.showBadgeOnlyNotification("addon-alert");
153 shouldShowIncognitoCheckbox = false
155 let global = tabbrowser.selectedBrowser.ownerGlobal;
156 return global.BrowserAddonUI.openAddonsMgr("addons://list/extension").then(
158 let aomBrowser = aomWin.docShell.chromeEventHandler;
159 return this.showPermissionsPrompt(
164 shouldShowIncognitoCheckbox
170 showSideloaded(tabbrowser, addon) {
172 this.sideloaded.delete(addon);
173 this._updateNotifications();
175 let strings = this._buildStrings({
177 permissions: addon.installPermissions,
181 lazy.AMTelemetry.recordManageEvent(addon, "sideload_prompt", {
182 num_strings: strings.msgs.length,
185 this.showAddonsManager(
190 true /* shouldShowIncognitoCheckbox */
191 ).then(async answer => {
193 await addon.enable();
195 this._updateNotifications();
197 // The user has just enabled a sideloaded extension, if the permission
198 // can be changed for the extension, show the post-install panel to
199 // give the user that opportunity.
201 ExtensionsUI.POSTINSTALL_PRIVATEBROWSING_CHECKBOX &&
203 lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
205 this.showInstallNotification(tabbrowser.selectedBrowser, addon);
208 this.emit("sideload-response");
212 showUpdate(browser, info) {
213 lazy.AMTelemetry.recordInstallEvent(info.install, {
214 step: "permissions_prompt",
215 num_strings: info.strings.msgs.length,
218 this.showAddonsManager(browser, info.strings, info.addon.iconURL).then(
225 // At the moment, this prompt will re-appear next time we do an update
226 // check. See bug 1332360 for proposal to avoid this.
227 this.updates.delete(info);
228 this._updateNotifications();
233 observe(subject, topic) {
234 if (topic == "webextension-permission-prompt") {
235 let { target, info } = subject.wrappedJSObject;
237 let { browser, window } = getTabBrowser(target);
239 // Dismiss the progress notification. Note that this is bad if
240 // there are multiple simultaneous installs happening, see
241 // bug 1329884 for a longer explanation.
242 let progressNotification = window.PopupNotifications.getNotification(
246 if (progressNotification) {
247 progressNotification.remove();
251 info.addon.signedState <= lazy.AddonManager.SIGNEDSTATE_MISSING;
255 Services.prefs.getBoolPref(
256 "extensions.ui.showAddonIconForUnsigned",
260 info.unsigned = false;
263 let strings = this._buildStrings(info);
265 // If this is an update with no promptable permissions, just apply it
266 if (info.type == "update" && !strings.msgs.length) {
271 let icon = info.unsigned
272 ? "chrome://global/skin/icons/warning.svg"
275 if (info.type == "sideload") {
276 lazy.AMTelemetry.recordManageEvent(info.addon, "sideload_prompt", {
277 num_strings: strings.msgs.length,
280 lazy.AMTelemetry.recordInstallEvent(info.install, {
281 step: "permissions_prompt",
282 num_strings: strings.msgs.length,
286 this.showPermissionsPrompt(
291 true /* shouldShowIncognitoCheckbox */
299 } else if (topic == "webextension-update-permissions") {
300 let info = subject.wrappedJSObject;
301 info.type = "update";
302 let strings = this._buildStrings(info);
304 // If we don't prompt for any new permissions, just apply it
305 if (!strings.msgs.length) {
312 permissions: info.permissions,
313 install: info.install,
315 resolve: info.resolve,
319 this.updates.add(update);
320 this._updateNotifications();
321 } else if (topic == "webextension-install-notify") {
322 let { target, addon, callback } = subject.wrappedJSObject;
323 this.showInstallNotification(target, addon).then(() => {
328 } else if (topic == "webextension-optional-permission-prompt") {
329 let { browser, name, icon, permissions, resolve } =
330 subject.wrappedJSObject;
331 let strings = this._buildStrings({
337 // If we don't have any promptable permissions, just proceed
338 if (!strings.msgs.length) {
342 // "userScripts" is an OptionalOnlyPermission, which means that it can
343 // only be requested through the permissions.request() API, without other
344 // permissions in the same request.
345 let isUserScriptsRequest =
346 permissions.permissions.length === 1 &&
347 permissions.permissions[0] === "userScripts";
349 this.showPermissionsPrompt(
353 /* addon */ undefined,
354 /* shouldShowIncognitoCheckbox */ false,
358 } else if (topic == "webextension-defaultsearch-prompt") {
359 let { browser, name, icon, respond, currentEngine, newEngine } =
360 subject.wrappedJSObject;
362 const [searchDesc, searchYes, searchNo] = lazy.l10n.formatMessagesSync([
364 id: "webext-default-search-description",
365 args: { addonName: "<>", currentEngine, newEngine },
367 "webext-default-search-yes",
368 "webext-default-search-no",
371 const strings = { addonName: name, text: searchDesc.value };
372 for (let attr of searchYes.attributes) {
373 if (attr.name === "label") {
374 strings.acceptText = attr.value;
375 } else if (attr.name === "accesskey") {
376 strings.acceptKey = attr.value;
379 for (let attr of searchNo.attributes) {
380 if (attr.name === "label") {
381 strings.cancelText = attr.value;
382 } else if (attr.name === "accesskey") {
383 strings.cancelKey = attr.value;
387 this.showDefaultSearchPrompt(browser, strings, icon).then(respond);
390 "webextension-imported-addons-cancelled",
391 "webextension-imported-addons-complete",
392 "webextension-imported-addons-pending",
395 this._updateNotifications();
399 // Create a set of formatted strings for a permission prompt
400 _buildStrings(info) {
401 const strings = lazy.ExtensionData.formatPermissionStrings(
403 this.SHOW_FULL_DOMAINS_LIST
404 ? { fullDomainsList: true }
405 : { collapseOrigins: true }
407 strings.addonName = info.addon.name;
411 async showPermissionsPrompt(
416 shouldShowIncognitoCheckbox = false,
417 isUserScriptsRequest = false
419 let { browser, window } = getTabBrowser(target);
421 let showIncognitoCheckbox =
422 shouldShowIncognitoCheckbox && !lazy.POSTINSTALL_PRIVATEBROWSING_CHECKBOX;
424 if (showIncognitoCheckbox) {
425 showIncognitoCheckbox = !!(
427 lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
431 const incognitoPermissionName = "internal:privateBrowsingAllowed";
432 let grantPrivateBrowsingAllowed = false;
433 if (showIncognitoCheckbox) {
434 const { permissions } = await lazy.ExtensionPermissions.get(addon.id);
435 grantPrivateBrowsingAllowed = permissions.includes(
436 incognitoPermissionName
440 // Wait for any pending prompts to complete before showing the next one.
442 while ((pending = this.pendingNotifications.get(browser))) {
446 let promise = new Promise(resolve => {
447 function eventCallback(topic) {
448 if (topic == "swapping") {
451 if (topic == "removed") {
452 Services.tm.dispatchToMainThread(() => {
459 // Show the SUMO link already part of the popupnotification by
460 // setting learnMoreURL option if there are permissions to be
461 // granted to the addon being installed (or if the private
462 // browsing checkbox is shown).
464 strings.msgs.length || showIncognitoCheckbox
465 ? Services.urlFormatter.formatURLPref("app.support.baseURL") +
466 "extension-permissions"
471 popupIconURL: icon || DEFAULT_EXTENSION_ICON,
472 popupIconClass: icon ? "" : "addon-warning-icon",
476 removeOnDismissal: true,
478 position: "bottomright topright",
480 // Pass additional options used internally by the
481 // addon-webext-permissions-notification custom element
482 // (defined and registered by browser-addons.js).
483 customElementOptions: {
485 showIncognitoCheckbox,
486 grantPrivateBrowsingAllowed,
487 onPrivateBrowsingAllowedChanged(value) {
488 grantPrivateBrowsingAllowed = value;
490 isUserScriptsRequest,
493 // The prompt/notification machinery has a special affordance wherein
494 // certain subsets of the header string can be designated "names", and
495 // referenced symbolically as "<>" and "{}" to receive special formatting.
496 // That code assumes that the existence of |name| and |secondName| in the
497 // options object imply the presence of "<>" and "{}" (respectively) in
500 // At present, WebExtensions use this affordance while SitePermission
501 // add-ons don't, so we need to conditionally set the |name| field.
503 // NB: This could potentially be cleaned up, see bug 1799710.
504 if (strings.header.includes("<>")) {
505 options.name = strings.addonName;
509 label: strings.acceptText,
510 accessKey: strings.acceptKey,
515 let secondaryActions = [
517 label: strings.cancelText,
518 accessKey: strings.cancelKey,
525 window.PopupNotifications.show(
527 "addon-webext-permissions",
529 browser.ownerGlobal.gUnifiedExtensions.getPopupAnchorID(
539 this.pendingNotifications.set(browser, promise);
540 promise.finally(() => this.pendingNotifications.delete(browser));
541 // NOTE: this method is also called from showQuarantineConfirmation and some of its
542 // related test cases (from browser_ext_originControls.js) seem to be hitting a race
543 // if the promise returned requires an additional tick to be resolved.
544 // Look more into the failure and determine a better option to avoid those failures.
545 if (!showIncognitoCheckbox) {
548 return promise.then(continueInstall => {
549 if (!continueInstall) {
550 return continueInstall;
552 const incognitoPermission = {
553 permissions: [incognitoPermissionName],
556 let permUpdatePromise;
557 if (grantPrivateBrowsingAllowed) {
558 permUpdatePromise = lazy.ExtensionPermissions.add(
562 lazy.logConsole.warn(
563 `Error on adding "${incognitoPermissionName}" permission to addon id "${addon.id}`,
568 permUpdatePromise = lazy.ExtensionPermissions.remove(
572 lazy.logConsole.warn(
573 `Error on removing "${incognitoPermissionName}" permission to addon id "${addon.id}`,
578 return permUpdatePromise.then(() => continueInstall);
582 showDefaultSearchPrompt(target, strings, icon) {
583 return new Promise(resolve => {
586 popupIconURL: icon || DEFAULT_EXTENSION_ICON,
588 removeOnDismissal: true,
589 eventCallback(topic) {
590 if (topic == "removed") {
594 name: strings.addonName,
598 label: strings.acceptText,
599 accessKey: strings.acceptKey,
604 let secondaryActions = [
606 label: strings.cancelText,
607 accessKey: strings.cancelKey,
614 let { browser, window } = getTabBrowser(target);
616 window.PopupNotifications.show(
618 "addon-webext-defaultsearch",
620 "addons-notification-icon",
628 async showInstallNotification(target, addon) {
629 let { window } = getTabBrowser(target);
631 const message = await lazy.l10n.formatValue("addon-post-install-message", {
635 const hideIncognitoCheckbox = !lazy.POSTINSTALL_PRIVATEBROWSING_CHECKBOX;
636 const permissionName = "internal:privateBrowsingAllowed";
637 const { permissions } = await lazy.ExtensionPermissions.get(addon.id);
638 const hasIncognito = permissions.includes(permissionName);
640 return new Promise(resolve => {
641 let icon = addon.isWebExtension
642 ? lazy.AddonManager.getPreferredIconURL(addon, 32, window) ||
643 DEFAULT_EXTENSION_ICON
644 : "chrome://browser/skin/addons/addon-install-installed.svg";
646 if (addon.type == "theme") {
647 const { previousActiveThemeID } = addon;
649 async function themeActionUndo() {
651 // Undoing a theme install means re-enabling the previous active theme
652 // ID, and uninstalling the theme that was just installed
653 const theme = await lazy.AddonManager.getAddonByID(
654 previousActiveThemeID
658 await theme.enable();
661 // `addon` is the theme that was just installed
662 await addon.uninstall();
668 let themePrimaryAction = { callback: resolve };
670 // Show the undo button if previousActiveThemeID is set.
671 let themeSecondaryAction = previousActiveThemeID
672 ? { callback: themeActionUndo }
680 lazy.AppMenuNotifications.removeNotification("theme-installed");
684 lazy.AppMenuNotifications.showNotification(
687 themeSecondaryAction,
691 // Show or hide private permission ui based on the pref.
692 function setCheckbox(win) {
693 let checkbox = win.document.getElementById(
694 "addon-incognito-checkbox"
696 checkbox.checked = hasIncognito;
698 hideIncognitoCheckbox ||
701 lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
705 async function actionResolve(win) {
706 let checkbox = win.document.getElementById(
707 "addon-incognito-checkbox"
710 if (hideIncognitoCheckbox || checkbox.checked == hasIncognito) {
715 let incognitoPermission = {
716 permissions: [permissionName],
720 // The checkbox has been changed at this point, otherwise we would
721 // have exited early above.
722 if (checkbox.checked) {
723 await lazy.ExtensionPermissions.add(addon.id, incognitoPermission);
724 } else if (hasIncognito) {
725 await lazy.ExtensionPermissions.remove(
730 // Reload the extension if it is already enabled. This ensures any change
731 // on the private browsing permission is properly handled.
732 if (addon.isActive) {
733 await addon.reload();
740 callback: actionResolve,
747 onRefresh: setCheckbox,
748 onDismissed: win => {
749 lazy.AppMenuNotifications.removeNotification("addon-installed");
753 lazy.AppMenuNotifications.showNotification(
763 async showQuarantineConfirmation(browser, policy) {
764 let [title, line1, line2, allow, deny] = await lazy.l10n.formatMessages([
766 id: "webext-quarantine-confirmation-title",
767 args: { addonName: "<>" },
769 "webext-quarantine-confirmation-line-1",
770 "webext-quarantine-confirmation-line-2",
771 "webext-quarantine-confirmation-allow",
772 "webext-quarantine-confirmation-deny",
775 let attr = (msg, name) => msg.attributes.find(a => a.name === name)?.value;
778 addonName: policy.name,
780 text: line1.value + "\n\n" + line2.value,
782 acceptText: attr(allow, "label"),
783 acceptKey: attr(allow, "accesskey"),
784 cancelText: attr(deny, "label"),
785 cancelKey: attr(deny, "accesskey"),
788 let icon = policy.extension?.getPreferredIcon(32);
790 if (await ExtensionsUI.showPermissionsPrompt(browser, strings, icon)) {
791 lazy.QuarantinedDomains.setUserAllowedAddonIdPref(policy.id, true);
795 // Populate extension toolbar popup menu with origin controls.
796 originControlsMenu(popup, extensionId) {
797 let policy = WebExtensionPolicy.getByID(extensionId);
799 let win = popup.ownerGlobal;
800 let doc = popup.ownerDocument;
801 let tab = win.gBrowser.selectedTab;
802 let uri = tab.linkedBrowser?.currentURI;
803 let state = lazy.OriginControls.getState(policy, tab);
805 let headerItem = doc.createXULElement("menuitem");
806 headerItem.setAttribute("disabled", true);
807 let items = [headerItem];
809 // MV2 normally don't have controls, but we show the quarantined state.
810 if (!policy?.extension.originControls && !state.quarantined) {
814 if (state.noAccess) {
815 doc.l10n.setAttributes(headerItem, "origin-controls-no-access");
817 doc.l10n.setAttributes(headerItem, "origin-controls-options");
820 if (state.quarantined) {
821 doc.l10n.setAttributes(headerItem, "origin-controls-quarantined-status");
823 let allowQuarantined = doc.createXULElement("menuitem");
824 doc.l10n.setAttributes(
826 "origin-controls-quarantined-allow"
828 allowQuarantined.addEventListener("command", () => {
829 this.showQuarantineConfirmation(tab.linkedBrowser, policy);
831 items.push(allowQuarantined);
834 if (state.allDomains) {
835 let allDomains = doc.createXULElement("menuitem");
836 allDomains.setAttribute("type", "radio");
837 allDomains.setAttribute("checked", state.hasAccess);
838 doc.l10n.setAttributes(allDomains, "origin-controls-option-all-domains");
839 items.push(allDomains);
842 if (state.whenClicked) {
843 let whenClicked = doc.createXULElement("menuitem");
844 whenClicked.setAttribute("type", "radio");
845 whenClicked.setAttribute("checked", !state.hasAccess);
846 doc.l10n.setAttributes(
848 "origin-controls-option-when-clicked"
850 whenClicked.addEventListener("command", async () => {
851 await lazy.OriginControls.setWhenClicked(policy, uri);
852 win.gUnifiedExtensions.updateAttention();
854 items.push(whenClicked);
857 if (state.alwaysOn) {
858 let alwaysOn = doc.createXULElement("menuitem");
859 alwaysOn.setAttribute("type", "radio");
860 alwaysOn.setAttribute("checked", state.hasAccess);
861 doc.l10n.setAttributes(alwaysOn, "origin-controls-option-always-on", {
864 alwaysOn.addEventListener("command", async () => {
865 await lazy.OriginControls.setAlwaysOn(policy, uri);
866 win.gUnifiedExtensions.updateAttention();
868 items.push(alwaysOn);
871 items.push(doc.createXULElement("menuseparator"));
873 // Insert all items before Pin to toolbar OR Manage Extension, but after
874 // any extension's menu items.
876 popup.querySelector(".customize-context-manageExtension") ||
877 popup.querySelector(".unified-extensions-context-menu-pin-to-toolbar");
878 items.forEach(item => item && popup.insertBefore(item, manageItem));
881 if (e.target === popup) {
882 items.forEach(item => item?.remove());
883 popup.removeEventListener("popuphidden", cleanup);
886 popup.addEventListener("popuphidden", cleanup);
890 EventEmitter.decorate(ExtensionsUI);