Backed out changeset 496886cb30a5 (bug 1867152) for bc failures on browser_user_input...
[gecko.git] / browser / modules / ExtensionsUI.sys.mjs
blob0b113f87ce1f48eec8668b5f357007416bafd4e0
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";
7 const lazy = {};
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",
19 });
21 ChromeUtils.defineLazyGetter(
22   lazy,
23   "l10n",
24   () =>
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;
36   }
37   let window = browser.ownerGlobal;
38   let viewType = browser.getAttribute("webextension-view-type");
39   if (viewType == "sidebar") {
40     window = window.browsingContext.topChromeWindow;
41   }
42   if (viewType == "popup" || viewType == "sidebar") {
43     browser = window.gBrowser.selectedBrowser;
44   }
45   return { browser, window };
48 export var ExtensionsUI = {
49   sideloaded: new Set(),
50   updates: new Set(),
51   sideloadListener: null,
53   pendingNotifications: new WeakMap(),
55   async init() {
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();
69   },
71   async _checkForSideloaded() {
72     let sideloaded = await lazy.AddonManagerPrivate.getNewSideloads();
74     if (!sideloaded.length) {
75       // No new side-loads. We're done.
76       return;
77     }
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 = {
85         onEnabled: addon => {
86           if (!this.sideloaded.has(addon)) {
87             return;
88           }
90           this.sideloaded.delete(addon);
91           this._updateNotifications();
93           if (this.sideloaded.size == 0) {
94             lazy.AddonManager.removeAddonListener(this.sideloadListener);
95             this.sideloadListener = null;
96           }
97         },
98       };
99       lazy.AddonManager.addAddonListener(this.sideloadListener);
100     }
102     for (let addon of sideloaded) {
103       this.sideloaded.add(addon);
104     }
105     this._updateNotifications();
106   },
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");
114     } else {
115       lazy.AppMenuNotifications.showBadgeOnlyNotification("addon-alert");
116     }
117     this.emit("change");
118   },
120   showAddonsManager(tabbrowser, strings, icon) {
121     let global = tabbrowser.selectedBrowser.ownerGlobal;
122     return global
123       .BrowserOpenAddonsMgr("addons://list/extension")
124       .then(aomWin => {
125         let aomBrowser = aomWin.docShell.chromeEventHandler;
126         return this.showPermissionsPrompt(aomBrowser, strings, icon);
127       });
128   },
130   showSideloaded(tabbrowser, addon) {
131     addon.markAsSeen();
132     this.sideloaded.delete(addon);
133     this._updateNotifications();
135     let strings = this._buildStrings({
136       addon,
137       permissions: addon.userPermissions,
138       type: "sideload",
139     });
141     lazy.AMTelemetry.recordManageEvent(addon, "sideload_prompt", {
142       num_strings: strings.msgs.length,
143     });
145     this.showAddonsManager(tabbrowser, strings, addon.iconURL).then(
146       async answer => {
147         if (answer) {
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.
155           if (
156             addon.permissions &
157             lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
158           ) {
159             this.showInstallNotification(tabbrowser.selectedBrowser, addon);
160           }
161         }
162         this.emit("sideload-response");
163       }
164     );
165   },
167   showUpdate(browser, info) {
168     lazy.AMTelemetry.recordInstallEvent(info.install, {
169       step: "permissions_prompt",
170       num_strings: info.strings.msgs.length,
171     });
173     this.showAddonsManager(browser, info.strings, info.addon.iconURL).then(
174       answer => {
175         if (answer) {
176           info.resolve();
177         } else {
178           info.reject();
179         }
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();
184       }
185     );
186   },
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(
198         "addon-progress",
199         browser
200       );
201       if (progressNotification) {
202         progressNotification.remove();
203       }
205       info.unsigned =
206         info.addon.signedState <= lazy.AddonManager.SIGNEDSTATE_MISSING;
207       if (
208         info.unsigned &&
209         Cu.isInAutomation &&
210         Services.prefs.getBoolPref("extensions.ui.ignoreUnsigned", false)
211       ) {
212         info.unsigned = false;
213       }
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) {
219         info.resolve();
220         return;
221       }
223       let icon = info.unsigned
224         ? "chrome://global/skin/icons/warning.svg"
225         : info.icon;
227       if (info.type == "sideload") {
228         lazy.AMTelemetry.recordManageEvent(info.addon, "sideload_prompt", {
229           num_strings: strings.msgs.length,
230         });
231       } else {
232         lazy.AMTelemetry.recordInstallEvent(info.install, {
233           step: "permissions_prompt",
234           num_strings: strings.msgs.length,
235         });
236       }
238       this.showPermissionsPrompt(browser, strings, icon).then(answer => {
239         if (answer) {
240           info.resolve();
241         } else {
242           info.reject();
243         }
244       });
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) {
252         info.resolve();
253         return;
254       }
256       let update = {
257         strings,
258         permissions: info.permissions,
259         install: info.install,
260         addon: info.addon,
261         resolve: info.resolve,
262         reject: info.reject,
263       };
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(() => {
270         if (callback) {
271           callback();
272         }
273       });
274     } else if (topic == "webextension-optional-permission-prompt") {
275       let { browser, name, icon, permissions, resolve } =
276         subject.wrappedJSObject;
277       let strings = this._buildStrings({
278         type: "optional",
279         addon: { name },
280         permissions,
281       });
283       // If we don't have any promptable permissions, just proceed
284       if (!strings.msgs.length) {
285         resolve(true);
286         return;
287       }
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([
294         {
295           id: "webext-default-search-description",
296           args: { addonName: "<>", currentEngine, newEngine },
297         },
298         "webext-default-search-yes",
299         "webext-default-search-no",
300       ]);
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;
308         }
309       }
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;
315         }
316       }
318       this.showDefaultSearchPrompt(browser, strings, icon).then(respond);
319     } else if (
320       [
321         "webextension-imported-addons-cancelled",
322         "webextension-imported-addons-complete",
323         "webextension-imported-addons-pending",
324       ].includes(topic)
325     ) {
326       this._updateNotifications();
327     }
328   },
330   // Create a set of formatted strings for a permission prompt
331   _buildStrings(info) {
332     const strings = lazy.ExtensionData.formatPermissionStrings(info, {
333       collapseOrigins: true,
334     });
335     strings.addonName = info.addon.name;
336     return strings;
337   },
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.
345     let pending;
346     while ((pending = this.pendingNotifications.get(browser))) {
347       await pending;
348     }
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",
364             isMultiline
365           );
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();
377           }
378           let singleEntryEl = doc.getElementById(
379             "addon-webext-perm-single-entry"
380           );
381           singleEntryEl.textContent = "";
382           singleEntryEl.hidden = true;
383           list.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);
393             }
394             list.hidden = false;
395           }
396         } else if (topic == "swapping") {
397           return true;
398         }
399         if (topic == "removed") {
400           Services.tm.dispatchToMainThread(() => {
401             resolve(false);
402           });
403         }
404         return false;
405       }
407       let options = {
408         hideClose: true,
409         popupIconURL: icon || DEFAULT_EXTENSION_ICON,
410         popupIconClass: icon ? "" : "addon-warning-icon",
411         persistent: true,
412         eventCallback,
413         removeOnDismissal: true,
414         popupOptions: {
415           position: "bottomright topright",
416         },
417       };
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
423       // in the string.
424       //
425       // At present, WebExtensions use this affordance while SitePermission
426       // add-ons don't, so we need to conditionally set the |name| field.
427       //
428       // NB: This could potentially be cleaned up, see bug 1799710.
429       if (strings.header.includes("<>")) {
430         options.name = strings.addonName;
431       }
433       let action = {
434         label: strings.acceptText,
435         accessKey: strings.acceptKey,
436         callback: () => {
437           resolve(true);
438         },
439       };
440       let secondaryActions = [
441         {
442           label: strings.cancelText,
443           accessKey: strings.cancelKey,
444           callback: () => {
445             resolve(false);
446           },
447         },
448       ];
450       window.PopupNotifications.show(
451         browser,
452         "addon-webext-permissions",
453         strings.header,
454         browser.ownerGlobal.gUnifiedExtensions.getPopupAnchorID(
455           browser,
456           window
457         ),
458         action,
459         secondaryActions,
460         options
461       );
462     });
464     this.pendingNotifications.set(browser, promise);
465     promise.finally(() => this.pendingNotifications.delete(browser));
466     return promise;
467   },
469   showDefaultSearchPrompt(target, strings, icon) {
470     return new Promise(resolve => {
471       let options = {
472         hideClose: true,
473         popupIconURL: icon || DEFAULT_EXTENSION_ICON,
474         persistent: true,
475         removeOnDismissal: true,
476         eventCallback(topic) {
477           if (topic == "removed") {
478             resolve(false);
479           }
480         },
481         name: strings.addonName,
482       };
484       let action = {
485         label: strings.acceptText,
486         accessKey: strings.acceptKey,
487         callback: () => {
488           resolve(true);
489         },
490       };
491       let secondaryActions = [
492         {
493           label: strings.cancelText,
494           accessKey: strings.cancelKey,
495           callback: () => {
496             resolve(false);
497           },
498         },
499       ];
501       let { browser, window } = getTabBrowser(target);
503       window.PopupNotifications.show(
504         browser,
505         "addon-webext-defaultsearch",
506         strings.text,
507         "addons-notification-icon",
508         action,
509         secondaryActions,
510         options
511       );
512     });
513   },
515   async showInstallNotification(target, addon) {
516     let { window } = getTabBrowser(target);
518     const message = await lazy.l10n.formatValue("addon-post-install-message", {
519       addonName: "<>",
520     });
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;
530         checkbox.hidden = !(
531           addon.permissions &
532           lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
533         );
534       }
536       async function actionResolve(win) {
537         let checkbox = win.document.getElementById("addon-incognito-checkbox");
539         if (checkbox.checked == hasIncognito) {
540           resolve();
541           return;
542         }
544         let incognitoPermission = {
545           permissions: [permissionName],
546           origins: [],
547         };
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);
555         }
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();
560         }
562         resolve();
563       }
565       let action = {
566         callback: actionResolve,
567       };
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";
573       let options = {
574         name: addon.name,
575         message,
576         popupIconURL: icon,
577         onRefresh: setCheckbox,
578         onDismissed: win => {
579           lazy.AppMenuNotifications.removeNotification("addon-installed");
580           actionResolve(win);
581         },
582       };
583       lazy.AppMenuNotifications.showNotification(
584         "addon-installed",
585         action,
586         null,
587         options
588       );
589     });
590   },
592   async showQuarantineConfirmation(browser, policy) {
593     let [title, line1, line2, allow, deny] = await lazy.l10n.formatMessages([
594       {
595         id: "webext-quarantine-confirmation-title",
596         args: { addonName: "<>" },
597       },
598       "webext-quarantine-confirmation-line-1",
599       "webext-quarantine-confirmation-line-2",
600       "webext-quarantine-confirmation-allow",
601       "webext-quarantine-confirmation-deny",
602     ]);
604     let attr = (msg, name) => msg.attributes.find(a => a.name === name)?.value;
606     let strings = {
607       addonName: policy.name,
608       header: title.value,
609       text: line1.value + "\n\n" + line2.value,
610       msgs: [],
611       acceptText: attr(allow, "label"),
612       acceptKey: attr(allow, "accesskey"),
613       cancelText: attr(deny, "label"),
614       cancelKey: attr(deny, "accesskey"),
615     };
617     let icon = policy.extension?.getPreferredIcon(32);
619     if (await ExtensionsUI.showPermissionsPrompt(browser, strings, icon)) {
620       lazy.QuarantinedDomains.setUserAllowedAddonIdPref(policy.id, true);
621     }
622   },
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) {
640       return;
641     }
643     if (state.noAccess) {
644       doc.l10n.setAttributes(headerItem, "origin-controls-no-access");
645     } else {
646       doc.l10n.setAttributes(headerItem, "origin-controls-options");
647     }
649     if (state.quarantined) {
650       doc.l10n.setAttributes(headerItem, "origin-controls-quarantined-status");
652       let allowQuarantined = doc.createXULElement("menuitem");
653       doc.l10n.setAttributes(
654         allowQuarantined,
655         "origin-controls-quarantined-allow"
656       );
657       allowQuarantined.addEventListener("command", () => {
658         this.showQuarantineConfirmation(tab.linkedBrowser, policy);
659       });
660       items.push(allowQuarantined);
661     }
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);
669     }
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(
676         whenClicked,
677         "origin-controls-option-when-clicked"
678       );
679       whenClicked.addEventListener("command", async () => {
680         await lazy.OriginControls.setWhenClicked(policy, uri);
681         win.gUnifiedExtensions.updateAttention();
682       });
683       items.push(whenClicked);
684     }
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", {
691         domain: uri.host,
692       });
693       alwaysOn.addEventListener("command", async () => {
694         await lazy.OriginControls.setAlwaysOn(policy, uri);
695         win.gUnifiedExtensions.updateAttention();
696       });
697       items.push(alwaysOn);
698     }
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.
704     let manageItem =
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));
709     let cleanup = e => {
710       if (e.target === popup) {
711         items.forEach(item => item?.remove());
712         popup.removeEventListener("popuphidden", cleanup);
713       }
714     };
715     popup.addEventListener("popuphidden", cleanup);
716   },
719 EventEmitter.decorate(ExtensionsUI);