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";
9 ChromeUtils.defineESModuleGetters(lazy, {
10 AMBrowserExtensionsImport: "resource://gre/modules/AddonManager.sys.mjs",
11 AMTelemetry: "resource://gre/modules/AddonManager.sys.mjs",
12 AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
13 AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
14 AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs",
15 ExtensionData: "resource://gre/modules/Extension.sys.mjs",
16 ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
17 OriginControls: "resource://gre/modules/ExtensionPermissions.sys.mjs",
18 QuarantinedDomains: "resource://gre/modules/ExtensionPermissions.sys.mjs",
21 ChromeUtils.defineLazyGetter(
25 new Localization(["browser/extensionsUI.ftl", "branding/brand.ftl"], true)
28 const DEFAULT_EXTENSION_ICON =
29 "chrome://mozapps/skin/extensions/extensionGeneric.svg";
31 const HTML_NS = "http://www.w3.org/1999/xhtml";
33 function getTabBrowser(browser) {
34 while (browser.ownerGlobal.docShell.itemType !== Ci.nsIDocShell.typeChrome) {
35 browser = browser.ownerGlobal.docShell.chromeEventHandler;
37 let window = browser.ownerGlobal;
38 let viewType = browser.getAttribute("webextension-view-type");
39 if (viewType == "sidebar") {
40 window = window.browsingContext.topChromeWindow;
42 if (viewType == "popup" || viewType == "sidebar") {
43 browser = window.gBrowser.selectedBrowser;
45 return { browser, window };
48 export var ExtensionsUI = {
49 sideloaded: new Set(),
51 sideloadListener: null,
53 pendingNotifications: new WeakMap(),
56 Services.obs.addObserver(this, "webextension-permission-prompt");
57 Services.obs.addObserver(this, "webextension-update-permissions");
58 Services.obs.addObserver(this, "webextension-install-notify");
59 Services.obs.addObserver(this, "webextension-optional-permission-prompt");
60 Services.obs.addObserver(this, "webextension-defaultsearch-prompt");
61 Services.obs.addObserver(this, "webextension-imported-addons-cancelled");
62 Services.obs.addObserver(this, "webextension-imported-addons-complete");
63 Services.obs.addObserver(this, "webextension-imported-addons-pending");
65 await Services.wm.getMostRecentWindow("navigator:browser")
66 .delayedStartupPromise;
68 this._checkForSideloaded();
71 async _checkForSideloaded() {
72 let sideloaded = await lazy.AddonManagerPrivate.getNewSideloads();
74 if (!sideloaded.length) {
75 // No new side-loads. We're done.
79 // The ordering shouldn't matter, but tests depend on notifications
80 // happening in a specific order.
81 sideloaded.sort((a, b) => a.id.localeCompare(b.id));
83 if (!this.sideloadListener) {
84 this.sideloadListener = {
86 if (!this.sideloaded.has(addon)) {
90 this.sideloaded.delete(addon);
91 this._updateNotifications();
93 if (this.sideloaded.size == 0) {
94 lazy.AddonManager.removeAddonListener(this.sideloadListener);
95 this.sideloadListener = null;
99 lazy.AddonManager.addAddonListener(this.sideloadListener);
102 for (let addon of sideloaded) {
103 this.sideloaded.add(addon);
105 this._updateNotifications();
108 _updateNotifications() {
109 const { sideloaded, updates } = this;
110 const { importedAddonIDs } = lazy.AMBrowserExtensionsImport;
112 if (importedAddonIDs.length + sideloaded.size + updates.size == 0) {
113 lazy.AppMenuNotifications.removeNotification("addon-alert");
115 lazy.AppMenuNotifications.showBadgeOnlyNotification("addon-alert");
120 showAddonsManager(tabbrowser, strings, icon) {
121 let global = tabbrowser.selectedBrowser.ownerGlobal;
123 .BrowserOpenAddonsMgr("addons://list/extension")
125 let aomBrowser = aomWin.docShell.chromeEventHandler;
126 return this.showPermissionsPrompt(aomBrowser, strings, icon);
130 showSideloaded(tabbrowser, addon) {
132 this.sideloaded.delete(addon);
133 this._updateNotifications();
135 let strings = this._buildStrings({
137 permissions: addon.userPermissions,
141 lazy.AMTelemetry.recordManageEvent(addon, "sideload_prompt", {
142 num_strings: strings.msgs.length,
145 this.showAddonsManager(tabbrowser, strings, addon.iconURL).then(
148 await addon.enable();
150 this._updateNotifications();
152 // The user has just enabled a sideloaded extension, if the permission
153 // can be changed for the extension, show the post-install panel to
154 // give the user that opportunity.
157 lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
159 this.showInstallNotification(tabbrowser.selectedBrowser, addon);
162 this.emit("sideload-response");
167 showUpdate(browser, info) {
168 lazy.AMTelemetry.recordInstallEvent(info.install, {
169 step: "permissions_prompt",
170 num_strings: info.strings.msgs.length,
173 this.showAddonsManager(browser, info.strings, info.addon.iconURL).then(
180 // At the moment, this prompt will re-appear next time we do an update
181 // check. See bug 1332360 for proposal to avoid this.
182 this.updates.delete(info);
183 this._updateNotifications();
188 observe(subject, topic, data) {
189 if (topic == "webextension-permission-prompt") {
190 let { target, info } = subject.wrappedJSObject;
192 let { browser, window } = getTabBrowser(target);
194 // Dismiss the progress notification. Note that this is bad if
195 // there are multiple simultaneous installs happening, see
196 // bug 1329884 for a longer explanation.
197 let progressNotification = window.PopupNotifications.getNotification(
201 if (progressNotification) {
202 progressNotification.remove();
206 info.addon.signedState <= lazy.AddonManager.SIGNEDSTATE_MISSING;
210 Services.prefs.getBoolPref("extensions.ui.ignoreUnsigned", false)
212 info.unsigned = false;
215 let strings = this._buildStrings(info);
217 // If this is an update with no promptable permissions, just apply it
218 if (info.type == "update" && !strings.msgs.length) {
223 let icon = info.unsigned
224 ? "chrome://global/skin/icons/warning.svg"
227 if (info.type == "sideload") {
228 lazy.AMTelemetry.recordManageEvent(info.addon, "sideload_prompt", {
229 num_strings: strings.msgs.length,
232 lazy.AMTelemetry.recordInstallEvent(info.install, {
233 step: "permissions_prompt",
234 num_strings: strings.msgs.length,
238 this.showPermissionsPrompt(browser, strings, icon).then(answer => {
245 } else if (topic == "webextension-update-permissions") {
246 let info = subject.wrappedJSObject;
247 info.type = "update";
248 let strings = this._buildStrings(info);
250 // If we don't prompt for any new permissions, just apply it
251 if (!strings.msgs.length) {
258 permissions: info.permissions,
259 install: info.install,
261 resolve: info.resolve,
265 this.updates.add(update);
266 this._updateNotifications();
267 } else if (topic == "webextension-install-notify") {
268 let { target, addon, callback } = subject.wrappedJSObject;
269 this.showInstallNotification(target, addon).then(() => {
274 } else if (topic == "webextension-optional-permission-prompt") {
275 let { browser, name, icon, permissions, resolve } =
276 subject.wrappedJSObject;
277 let strings = this._buildStrings({
283 // If we don't have any promptable permissions, just proceed
284 if (!strings.msgs.length) {
288 resolve(this.showPermissionsPrompt(browser, strings, icon));
289 } else if (topic == "webextension-defaultsearch-prompt") {
290 let { browser, name, icon, respond, currentEngine, newEngine } =
291 subject.wrappedJSObject;
293 const [searchDesc, searchYes, searchNo] = lazy.l10n.formatMessagesSync([
295 id: "webext-default-search-description",
296 args: { addonName: "<>", currentEngine, newEngine },
298 "webext-default-search-yes",
299 "webext-default-search-no",
302 const strings = { addonName: name, text: searchDesc.value };
303 for (let attr of searchYes.attributes) {
304 if (attr.name === "label") {
305 strings.acceptText = attr.value;
306 } else if (attr.name === "accesskey") {
307 strings.acceptKey = attr.value;
310 for (let attr of searchNo.attributes) {
311 if (attr.name === "label") {
312 strings.cancelText = attr.value;
313 } else if (attr.name === "accesskey") {
314 strings.cancelKey = attr.value;
318 this.showDefaultSearchPrompt(browser, strings, icon).then(respond);
321 "webextension-imported-addons-cancelled",
322 "webextension-imported-addons-complete",
323 "webextension-imported-addons-pending",
326 this._updateNotifications();
330 // Create a set of formatted strings for a permission prompt
331 _buildStrings(info) {
332 const strings = lazy.ExtensionData.formatPermissionStrings(info, {
333 collapseOrigins: true,
335 strings.addonName = info.addon.name;
339 async showPermissionsPrompt(target, strings, icon) {
340 let { browser, window } = getTabBrowser(target);
342 await window.ensureCustomElements("moz-support-link");
344 // Wait for any pending prompts to complete before showing the next one.
346 while ((pending = this.pendingNotifications.get(browser))) {
350 let promise = new Promise(resolve => {
351 function eventCallback(topic) {
352 let doc = this.browser.ownerDocument;
353 if (topic == "showing") {
354 let textEl = doc.getElementById("addon-webext-perm-text");
355 textEl.textContent = strings.text;
356 textEl.hidden = !strings.text;
358 // By default, multiline strings don't get formatted properly. These
359 // are presently only used in site permission add-ons, so we treat it
360 // as a special case to avoid unintended effects on other things.
361 let isMultiline = strings.text.includes("\n\n");
362 textEl.classList.toggle(
363 "addon-webext-perm-text-multiline",
367 let listIntroEl = doc.getElementById("addon-webext-perm-intro");
368 listIntroEl.textContent = strings.listIntro;
369 listIntroEl.hidden = !strings.msgs.length || !strings.listIntro;
371 let listInfoEl = doc.getElementById("addon-webext-perm-info");
372 listInfoEl.hidden = !strings.msgs.length;
374 let list = doc.getElementById("addon-webext-perm-list");
375 while (list.firstChild) {
376 list.firstChild.remove();
378 let singleEntryEl = doc.getElementById(
379 "addon-webext-perm-single-entry"
381 singleEntryEl.textContent = "";
382 singleEntryEl.hidden = true;
385 if (strings.msgs.length === 1) {
386 singleEntryEl.textContent = strings.msgs[0];
387 singleEntryEl.hidden = false;
388 } else if (strings.msgs.length) {
389 for (let msg of strings.msgs) {
390 let item = doc.createElementNS(HTML_NS, "li");
391 item.textContent = msg;
392 list.appendChild(item);
396 } else if (topic == "swapping") {
399 if (topic == "removed") {
400 Services.tm.dispatchToMainThread(() => {
409 popupIconURL: icon || DEFAULT_EXTENSION_ICON,
410 popupIconClass: icon ? "" : "addon-warning-icon",
413 removeOnDismissal: true,
415 position: "bottomright topright",
418 // The prompt/notification machinery has a special affordance wherein
419 // certain subsets of the header string can be designated "names", and
420 // referenced symbolically as "<>" and "{}" to receive special formatting.
421 // That code assumes that the existence of |name| and |secondName| in the
422 // options object imply the presence of "<>" and "{}" (respectively) in
425 // At present, WebExtensions use this affordance while SitePermission
426 // add-ons don't, so we need to conditionally set the |name| field.
428 // NB: This could potentially be cleaned up, see bug 1799710.
429 if (strings.header.includes("<>")) {
430 options.name = strings.addonName;
434 label: strings.acceptText,
435 accessKey: strings.acceptKey,
440 let secondaryActions = [
442 label: strings.cancelText,
443 accessKey: strings.cancelKey,
450 window.PopupNotifications.show(
452 "addon-webext-permissions",
454 browser.ownerGlobal.gUnifiedExtensions.getPopupAnchorID(
464 this.pendingNotifications.set(browser, promise);
465 promise.finally(() => this.pendingNotifications.delete(browser));
469 showDefaultSearchPrompt(target, strings, icon) {
470 return new Promise(resolve => {
473 popupIconURL: icon || DEFAULT_EXTENSION_ICON,
475 removeOnDismissal: true,
476 eventCallback(topic) {
477 if (topic == "removed") {
481 name: strings.addonName,
485 label: strings.acceptText,
486 accessKey: strings.acceptKey,
491 let secondaryActions = [
493 label: strings.cancelText,
494 accessKey: strings.cancelKey,
501 let { browser, window } = getTabBrowser(target);
503 window.PopupNotifications.show(
505 "addon-webext-defaultsearch",
507 "addons-notification-icon",
515 async showInstallNotification(target, addon) {
516 let { window } = getTabBrowser(target);
518 const message = await lazy.l10n.formatValue("addon-post-install-message", {
521 const permissionName = "internal:privateBrowsingAllowed";
522 const { permissions } = await lazy.ExtensionPermissions.get(addon.id);
523 const hasIncognito = permissions.includes(permissionName);
525 return new Promise(resolve => {
526 // Show or hide private permission ui based on the pref.
527 function setCheckbox(win) {
528 let checkbox = win.document.getElementById("addon-incognito-checkbox");
529 checkbox.checked = hasIncognito;
532 lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
536 async function actionResolve(win) {
537 let checkbox = win.document.getElementById("addon-incognito-checkbox");
539 if (checkbox.checked == hasIncognito) {
544 let incognitoPermission = {
545 permissions: [permissionName],
549 // The checkbox has been changed at this point, otherwise we would
550 // have exited early above.
551 if (checkbox.checked) {
552 await lazy.ExtensionPermissions.add(addon.id, incognitoPermission);
553 } else if (hasIncognito) {
554 await lazy.ExtensionPermissions.remove(addon.id, incognitoPermission);
556 // Reload the extension if it is already enabled. This ensures any change
557 // on the private browsing permission is properly handled.
558 if (addon.isActive) {
559 await addon.reload();
566 callback: actionResolve,
569 let icon = addon.isWebExtension
570 ? lazy.AddonManager.getPreferredIconURL(addon, 32, window) ||
571 DEFAULT_EXTENSION_ICON
572 : "chrome://browser/skin/addons/addon-install-installed.svg";
577 onRefresh: setCheckbox,
578 onDismissed: win => {
579 lazy.AppMenuNotifications.removeNotification("addon-installed");
583 lazy.AppMenuNotifications.showNotification(
592 async showQuarantineConfirmation(browser, policy) {
593 let [title, line1, line2, allow, deny] = await lazy.l10n.formatMessages([
595 id: "webext-quarantine-confirmation-title",
596 args: { addonName: "<>" },
598 "webext-quarantine-confirmation-line-1",
599 "webext-quarantine-confirmation-line-2",
600 "webext-quarantine-confirmation-allow",
601 "webext-quarantine-confirmation-deny",
604 let attr = (msg, name) => msg.attributes.find(a => a.name === name)?.value;
607 addonName: policy.name,
609 text: line1.value + "\n\n" + line2.value,
611 acceptText: attr(allow, "label"),
612 acceptKey: attr(allow, "accesskey"),
613 cancelText: attr(deny, "label"),
614 cancelKey: attr(deny, "accesskey"),
617 let icon = policy.extension?.getPreferredIcon(32);
619 if (await ExtensionsUI.showPermissionsPrompt(browser, strings, icon)) {
620 lazy.QuarantinedDomains.setUserAllowedAddonIdPref(policy.id, true);
624 // Populate extension toolbar popup menu with origin controls.
625 originControlsMenu(popup, extensionId) {
626 let policy = WebExtensionPolicy.getByID(extensionId);
628 let win = popup.ownerGlobal;
629 let doc = popup.ownerDocument;
630 let tab = win.gBrowser.selectedTab;
631 let uri = tab.linkedBrowser?.currentURI;
632 let state = lazy.OriginControls.getState(policy, tab);
634 let headerItem = doc.createXULElement("menuitem");
635 headerItem.setAttribute("disabled", true);
636 let items = [headerItem];
638 // MV2 normally don't have controls, but we show the quarantined state.
639 if (!policy?.extension.originControls && !state.quarantined) {
643 if (state.noAccess) {
644 doc.l10n.setAttributes(headerItem, "origin-controls-no-access");
646 doc.l10n.setAttributes(headerItem, "origin-controls-options");
649 if (state.quarantined) {
650 doc.l10n.setAttributes(headerItem, "origin-controls-quarantined-status");
652 let allowQuarantined = doc.createXULElement("menuitem");
653 doc.l10n.setAttributes(
655 "origin-controls-quarantined-allow"
657 allowQuarantined.addEventListener("command", () => {
658 this.showQuarantineConfirmation(tab.linkedBrowser, policy);
660 items.push(allowQuarantined);
663 if (state.allDomains) {
664 let allDomains = doc.createXULElement("menuitem");
665 allDomains.setAttribute("type", "radio");
666 allDomains.setAttribute("checked", state.hasAccess);
667 doc.l10n.setAttributes(allDomains, "origin-controls-option-all-domains");
668 items.push(allDomains);
671 if (state.whenClicked) {
672 let whenClicked = doc.createXULElement("menuitem");
673 whenClicked.setAttribute("type", "radio");
674 whenClicked.setAttribute("checked", !state.hasAccess);
675 doc.l10n.setAttributes(
677 "origin-controls-option-when-clicked"
679 whenClicked.addEventListener("command", async () => {
680 await lazy.OriginControls.setWhenClicked(policy, uri);
681 win.gUnifiedExtensions.updateAttention();
683 items.push(whenClicked);
686 if (state.alwaysOn) {
687 let alwaysOn = doc.createXULElement("menuitem");
688 alwaysOn.setAttribute("type", "radio");
689 alwaysOn.setAttribute("checked", state.hasAccess);
690 doc.l10n.setAttributes(alwaysOn, "origin-controls-option-always-on", {
693 alwaysOn.addEventListener("command", async () => {
694 await lazy.OriginControls.setAlwaysOn(policy, uri);
695 win.gUnifiedExtensions.updateAttention();
697 items.push(alwaysOn);
700 items.push(doc.createXULElement("menuseparator"));
702 // Insert all items before Pin to toolbar OR Manage Extension, but after
703 // any extension's menu items.
705 popup.querySelector(".customize-context-manageExtension") ||
706 popup.querySelector(".unified-extensions-context-menu-pin-to-toolbar");
707 items.forEach(item => item && popup.insertBefore(item, manageItem));
710 if (e.target === popup) {
711 items.forEach(item => item?.remove());
712 popup.removeEventListener("popuphidden", cleanup);
715 popup.addEventListener("popuphidden", cleanup);
719 EventEmitter.decorate(ExtensionsUI);