Bug 1793629 - Implement attention indicator for the unified extensions button, r...
[gecko.git] / browser / base / content / browser-addons.js
blobdcb3ce0bbe0b7dbff5db0b2555788826fcdc8b21
1 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
2  * This Source Code Form is subject to the terms of the Mozilla Public
3  * License, v. 2.0. If a copy of the MPL was not distributed with this
4  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 // This file is loaded into the browser window scope.
7 /* eslint-env mozilla/browser-window */
9 const lazy = {};
11 ChromeUtils.defineModuleGetter(
12   lazy,
13   "ExtensionParent",
14   "resource://gre/modules/ExtensionParent.jsm"
16 ChromeUtils.defineModuleGetter(
17   lazy,
18   "OriginControls",
19   "resource://gre/modules/ExtensionPermissions.jsm"
21 ChromeUtils.defineModuleGetter(
22   lazy,
23   "ExtensionPermissions",
24   "resource://gre/modules/ExtensionPermissions.jsm"
27 customElements.define(
28   "addon-progress-notification",
29   class MozAddonProgressNotification extends customElements.get(
30     "popupnotification"
31   ) {
32     show() {
33       super.show();
34       this.progressmeter = document.getElementById(
35         "addon-progress-notification-progressmeter"
36       );
38       this.progresstext = document.getElementById(
39         "addon-progress-notification-progresstext"
40       );
42       if (!this.notification) {
43         return;
44       }
46       this.notification.options.installs.forEach(function(aInstall) {
47         aInstall.addListener(this);
48       }, this);
50       // Calling updateProgress can sometimes cause this notification to be
51       // removed in the middle of refreshing the notification panel which
52       // makes the panel get refreshed again. Just initialise to the
53       // undetermined state and then schedule a proper check at the next
54       // opportunity
55       this.setProgress(0, -1);
56       this._updateProgressTimeout = setTimeout(
57         this.updateProgress.bind(this),
58         0
59       );
60     }
62     disconnectedCallback() {
63       this.destroy();
64     }
66     destroy() {
67       if (!this.notification) {
68         return;
69       }
70       this.notification.options.installs.forEach(function(aInstall) {
71         aInstall.removeListener(this);
72       }, this);
74       clearTimeout(this._updateProgressTimeout);
75     }
77     setProgress(aProgress, aMaxProgress) {
78       if (aMaxProgress == -1) {
79         this.progressmeter.removeAttribute("value");
80       } else {
81         this.progressmeter.setAttribute(
82           "value",
83           (aProgress * 100) / aMaxProgress
84         );
85       }
87       let now = Date.now();
89       if (!this.notification.lastUpdate) {
90         this.notification.lastUpdate = now;
91         this.notification.lastProgress = aProgress;
92         return;
93       }
95       let delta = now - this.notification.lastUpdate;
96       if (delta < 400 && aProgress < aMaxProgress) {
97         return;
98       }
100       // Set min. time delta to avoid division by zero in the upcoming speed calculation
101       delta = Math.max(delta, 400);
102       delta /= 1000;
104       // This algorithm is the same used by the downloads code.
105       let speed = (aProgress - this.notification.lastProgress) / delta;
106       if (this.notification.speed) {
107         speed = speed * 0.9 + this.notification.speed * 0.1;
108       }
110       this.notification.lastUpdate = now;
111       this.notification.lastProgress = aProgress;
112       this.notification.speed = speed;
114       let status = null;
115       [status, this.notification.last] = DownloadUtils.getDownloadStatus(
116         aProgress,
117         aMaxProgress,
118         speed,
119         this.notification.last
120       );
121       this.progresstext.setAttribute("value", status);
122       this.progresstext.setAttribute("tooltiptext", status);
123     }
125     cancel() {
126       let installs = this.notification.options.installs;
127       installs.forEach(function(aInstall) {
128         try {
129           aInstall.cancel();
130         } catch (e) {
131           // Cancel will throw if the download has already failed
132         }
133       }, this);
135       PopupNotifications.remove(this.notification);
136     }
138     updateProgress() {
139       if (!this.notification) {
140         return;
141       }
143       let downloadingCount = 0;
144       let progress = 0;
145       let maxProgress = 0;
147       this.notification.options.installs.forEach(function(aInstall) {
148         if (aInstall.maxProgress == -1) {
149           maxProgress = -1;
150         }
151         progress += aInstall.progress;
152         if (maxProgress >= 0) {
153           maxProgress += aInstall.maxProgress;
154         }
155         if (aInstall.state < AddonManager.STATE_DOWNLOADED) {
156           downloadingCount++;
157         }
158       });
160       if (downloadingCount == 0) {
161         this.destroy();
162         this.progressmeter.removeAttribute("value");
163         let status = gNavigatorBundle.getString("addonDownloadVerifying");
164         this.progresstext.setAttribute("value", status);
165         this.progresstext.setAttribute("tooltiptext", status);
166       } else {
167         this.setProgress(progress, maxProgress);
168       }
169     }
171     onDownloadProgress() {
172       this.updateProgress();
173     }
175     onDownloadFailed() {
176       this.updateProgress();
177     }
179     onDownloadCancelled() {
180       this.updateProgress();
181     }
183     onDownloadEnded() {
184       this.updateProgress();
185     }
186   }
189 // Removes a doorhanger notification if all of the installs it was notifying
190 // about have ended in some way.
191 function removeNotificationOnEnd(notification, installs) {
192   let count = installs.length;
194   function maybeRemove(install) {
195     install.removeListener(this);
197     if (--count == 0) {
198       // Check that the notification is still showing
199       let current = PopupNotifications.getNotification(
200         notification.id,
201         notification.browser
202       );
203       if (current === notification) {
204         notification.remove();
205       }
206     }
207   }
209   for (let install of installs) {
210     install.addListener({
211       onDownloadCancelled: maybeRemove,
212       onDownloadFailed: maybeRemove,
213       onInstallFailed: maybeRemove,
214       onInstallEnded: maybeRemove,
215     });
216   }
219 var gXPInstallObserver = {
220   _findChildShell(aDocShell, aSoughtShell) {
221     if (aDocShell == aSoughtShell) {
222       return aDocShell;
223     }
225     var node = aDocShell.QueryInterface(Ci.nsIDocShellTreeItem);
226     for (var i = 0; i < node.childCount; ++i) {
227       var docShell = node.getChildAt(i);
228       docShell = this._findChildShell(docShell, aSoughtShell);
229       if (docShell == aSoughtShell) {
230         return docShell;
231       }
232     }
233     return null;
234   },
236   _getBrowser(aDocShell) {
237     for (let browser of gBrowser.browsers) {
238       if (this._findChildShell(browser.docShell, aDocShell)) {
239         return browser;
240       }
241     }
242     return null;
243   },
245   pendingInstalls: new WeakMap(),
247   showInstallConfirmation(browser, installInfo, height = undefined) {
248     // If the confirmation notification is already open cache the installInfo
249     // and the new confirmation will be shown later
250     if (
251       PopupNotifications.getNotification("addon-install-confirmation", browser)
252     ) {
253       let pending = this.pendingInstalls.get(browser);
254       if (pending) {
255         pending.push(installInfo);
256       } else {
257         this.pendingInstalls.set(browser, [installInfo]);
258       }
259       return;
260     }
262     let showNextConfirmation = () => {
263       // Make sure the browser is still alive.
264       if (!gBrowser.browsers.includes(browser)) {
265         return;
266       }
268       let pending = this.pendingInstalls.get(browser);
269       if (pending && pending.length) {
270         this.showInstallConfirmation(browser, pending.shift());
271       }
272     };
274     // If all installs have already been cancelled in some way then just show
275     // the next confirmation
276     if (
277       installInfo.installs.every(i => i.state != AddonManager.STATE_DOWNLOADED)
278     ) {
279       showNextConfirmation();
280       return;
281     }
283     // Make notifications persistent
284     var options = {
285       displayURI: installInfo.originatingURI,
286       persistent: true,
287       hideClose: true,
288     };
290     if (gUnifiedExtensions.isEnabled) {
291       options.popupOptions = {
292         position: "bottomright topright",
293       };
294     }
296     let acceptInstallation = () => {
297       for (let install of installInfo.installs) {
298         install.install();
299       }
300       installInfo = null;
302       Services.telemetry
303         .getHistogramById("SECURITY_UI")
304         .add(
305           Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL_CLICK_THROUGH
306         );
307     };
309     let cancelInstallation = () => {
310       if (installInfo) {
311         for (let install of installInfo.installs) {
312           // The notification may have been closed because the add-ons got
313           // cancelled elsewhere, only try to cancel those that are still
314           // pending install.
315           if (install.state != AddonManager.STATE_CANCELLED) {
316             install.cancel();
317           }
318         }
319       }
321       showNextConfirmation();
322     };
324     let unsigned = installInfo.installs.filter(
325       i => i.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING
326     );
327     let someUnsigned =
328       !!unsigned.length && unsigned.length < installInfo.installs.length;
330     options.eventCallback = aEvent => {
331       switch (aEvent) {
332         case "removed":
333           cancelInstallation();
334           break;
335         case "shown":
336           let addonList = document.getElementById(
337             "addon-install-confirmation-content"
338           );
339           while (addonList.firstChild) {
340             addonList.firstChild.remove();
341           }
343           for (let install of installInfo.installs) {
344             let container = document.createXULElement("hbox");
346             let name = document.createXULElement("label");
347             name.setAttribute("value", install.addon.name);
348             name.setAttribute("class", "addon-install-confirmation-name");
349             container.appendChild(name);
351             if (
352               someUnsigned &&
353               install.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING
354             ) {
355               let unsignedLabel = document.createXULElement("label");
356               unsignedLabel.setAttribute(
357                 "value",
358                 gNavigatorBundle.getString("addonInstall.unsigned")
359               );
360               unsignedLabel.setAttribute(
361                 "class",
362                 "addon-install-confirmation-unsigned"
363               );
364               container.appendChild(unsignedLabel);
365             }
367             addonList.appendChild(container);
368           }
369           break;
370       }
371     };
373     options.learnMoreURL = Services.urlFormatter.formatURLPref(
374       "app.support.baseURL"
375     );
377     let messageString;
378     let notification = document.getElementById(
379       "addon-install-confirmation-notification"
380     );
381     if (unsigned.length == installInfo.installs.length) {
382       // None of the add-ons are verified
383       messageString = gNavigatorBundle.getString(
384         "addonConfirmInstallUnsigned.message"
385       );
386       notification.setAttribute("warning", "true");
387       options.learnMoreURL += "unsigned-addons";
388     } else if (!unsigned.length) {
389       // All add-ons are verified or don't need to be verified
390       messageString = gNavigatorBundle.getString("addonConfirmInstall.message");
391       notification.removeAttribute("warning");
392       options.learnMoreURL += "find-and-install-add-ons";
393     } else {
394       // Some of the add-ons are unverified, the list of names will indicate
395       // which
396       messageString = gNavigatorBundle.getString(
397         "addonConfirmInstallSomeUnsigned.message"
398       );
399       notification.setAttribute("warning", "true");
400       options.learnMoreURL += "unsigned-addons";
401     }
403     let brandBundle = document.getElementById("bundle_brand");
404     let brandShortName = brandBundle.getString("brandShortName");
406     messageString = PluralForm.get(installInfo.installs.length, messageString);
407     messageString = messageString.replace("#1", brandShortName);
408     messageString = messageString.replace("#2", installInfo.installs.length);
410     let action = {
411       label: gNavigatorBundle.getString("addonInstall.acceptButton2.label"),
412       accessKey: gNavigatorBundle.getString(
413         "addonInstall.acceptButton2.accesskey"
414       ),
415       callback: acceptInstallation,
416     };
418     let secondaryAction = {
419       label: gNavigatorBundle.getString("addonInstall.cancelButton.label"),
420       accessKey: gNavigatorBundle.getString(
421         "addonInstall.cancelButton.accesskey"
422       ),
423       callback: () => {},
424     };
426     if (height) {
427       notification.style.minHeight = height + "px";
428     }
430     let tab = gBrowser.getTabForBrowser(browser);
431     if (tab) {
432       gBrowser.selectedTab = tab;
433     }
435     let popup = PopupNotifications.show(
436       browser,
437       "addon-install-confirmation",
438       messageString,
439       gUnifiedExtensions.getPopupAnchorID(browser, window),
440       action,
441       [secondaryAction],
442       options
443     );
445     removeNotificationOnEnd(popup, installInfo.installs);
447     Services.telemetry
448       .getHistogramById("SECURITY_UI")
449       .add(Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL);
450   },
452   // IDs of addon install related notifications
453   NOTIFICATION_IDS: [
454     "addon-install-blocked",
455     "addon-install-complete",
456     "addon-install-confirmation",
457     "addon-install-failed",
458     "addon-install-origin-blocked",
459     "addon-install-webapi-blocked",
460     "addon-install-policy-blocked",
461     "addon-progress",
462     "addon-webext-permissions",
463     "xpinstall-disabled",
464   ],
466   /**
467    * Remove all opened addon installation notifications
468    *
469    * @param {*} browser - Browser to remove notifications for
470    * @returns {boolean} - true if notifications have been removed.
471    */
472   removeAllNotifications(browser) {
473     let notifications = this.NOTIFICATION_IDS.map(id =>
474       PopupNotifications.getNotification(id, browser)
475     ).filter(notification => notification != null);
477     PopupNotifications.remove(notifications, true);
479     return !!notifications.length;
480   },
482   logWarningFullScreenInstallBlocked() {
483     // If notifications have been removed, log a warning to the website console
484     let consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance(
485       Ci.nsIScriptError
486     );
487     let message = gBrowserBundle.GetStringFromName(
488       "addonInstallFullScreenBlocked"
489     );
490     consoleMsg.initWithWindowID(
491       message,
492       gBrowser.currentURI.spec,
493       null,
494       0,
495       0,
496       Ci.nsIScriptError.warningFlag,
497       "FullScreen",
498       gBrowser.selectedBrowser.innerWindowID
499     );
500     Services.console.logMessage(consoleMsg);
501   },
503   observe(aSubject, aTopic, aData) {
504     var brandBundle = document.getElementById("bundle_brand");
505     var installInfo = aSubject.wrappedJSObject;
506     var browser = installInfo.browser;
508     // Make sure the browser is still alive.
509     if (!browser || !gBrowser.browsers.includes(browser)) {
510       return;
511     }
513     var messageString, action;
514     var brandShortName = brandBundle.getString("brandShortName");
516     var notificationID = aTopic;
517     // Make notifications persistent
518     var options = {
519       displayURI: installInfo.originatingURI,
520       persistent: true,
521       hideClose: true,
522       timeout: Date.now() + 30000,
523     };
525     if (gUnifiedExtensions.isEnabled) {
526       options.popupOptions = {
527         position: "bottomright topright",
528       };
529     }
531     switch (aTopic) {
532       case "addon-install-disabled": {
533         notificationID = "xpinstall-disabled";
534         let secondaryActions = null;
536         if (Services.prefs.prefIsLocked("xpinstall.enabled")) {
537           messageString = gNavigatorBundle.getString(
538             "xpinstallDisabledMessageLocked"
539           );
540         } else {
541           messageString = gNavigatorBundle.getString(
542             "xpinstallDisabledMessage"
543           );
545           action = {
546             label: gNavigatorBundle.getString("xpinstallDisabledButton"),
547             accessKey: gNavigatorBundle.getString(
548               "xpinstallDisabledButton.accesskey"
549             ),
550             callback: function editPrefs() {
551               Services.prefs.setBoolPref("xpinstall.enabled", true);
552             },
553           };
555           secondaryActions = [
556             {
557               label: gNavigatorBundle.getString(
558                 "addonInstall.cancelButton.label"
559               ),
560               accessKey: gNavigatorBundle.getString(
561                 "addonInstall.cancelButton.accesskey"
562               ),
563               callback: () => {},
564             },
565           ];
566         }
568         PopupNotifications.show(
569           browser,
570           notificationID,
571           messageString,
572           gUnifiedExtensions.getPopupAnchorID(browser, window),
573           action,
574           secondaryActions,
575           options
576         );
577         break;
578       }
579       case "addon-install-fullscreen-blocked": {
580         // AddonManager denied installation because we are in DOM fullscreen
581         this.logWarningFullScreenInstallBlocked();
582         break;
583       }
584       case "addon-install-webapi-blocked":
585       case "addon-install-policy-blocked":
586       case "addon-install-origin-blocked": {
587         if (aTopic == "addon-install-policy-blocked") {
588           messageString = gNavigatorBundle.getString(
589             "addonDomainBlockedByPolicy"
590           );
591         } else {
592           messageString = gNavigatorBundle.getFormattedString(
593             "xpinstallPromptMessage",
594             [brandShortName]
595           );
596         }
598         if (Services.policies) {
599           let extensionSettings = Services.policies.getExtensionSettings("*");
600           if (
601             extensionSettings &&
602             "blocked_install_message" in extensionSettings
603           ) {
604             messageString += " " + extensionSettings.blocked_install_message;
605           }
606         }
608         options.removeOnDismissal = true;
609         options.persistent = false;
611         let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI");
612         secHistogram.add(
613           Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED
614         );
615         let popup = PopupNotifications.show(
616           browser,
617           notificationID,
618           messageString,
619           gUnifiedExtensions.getPopupAnchorID(browser, window),
620           null,
621           null,
622           options
623         );
624         removeNotificationOnEnd(popup, installInfo.installs);
625         break;
626       }
627       case "addon-install-blocked": {
628         // Dismiss the progress notification.  Note that this is bad if
629         // there are multiple simultaneous installs happening, see
630         // bug 1329884 for a longer explanation.
631         let progressNotification = PopupNotifications.getNotification(
632           "addon-progress",
633           browser
634         );
635         if (progressNotification) {
636           progressNotification.remove();
637         }
639         let hasHost = !!options.displayURI;
640         if (hasHost) {
641           messageString = gNavigatorBundle.getFormattedString(
642             "xpinstallPromptMessage.header",
643             ["<>"]
644           );
645           options.name = options.displayURI.displayHost;
646         } else {
647           messageString = gNavigatorBundle.getString(
648             "xpinstallPromptMessage.header.unknown"
649           );
650         }
651         // displayURI becomes it's own label, so we unset it for this panel. It will become part of the
652         // messageString above.
653         options.displayURI = undefined;
655         options.eventCallback = topic => {
656           if (topic !== "showing") {
657             return;
658           }
659           let doc = browser.ownerDocument;
660           let message = doc.getElementById("addon-install-blocked-message");
661           // We must remove any prior use of this panel message in this window.
662           while (message.firstChild) {
663             message.firstChild.remove();
664           }
665           if (hasHost) {
666             let text = gNavigatorBundle.getString(
667               "xpinstallPromptMessage.message"
668             );
669             let b = doc.createElementNS("http://www.w3.org/1999/xhtml", "b");
670             b.textContent = options.name;
671             let fragment = BrowserUIUtils.getLocalizedFragment(doc, text, b);
672             message.appendChild(fragment);
673           } else {
674             message.textContent = gNavigatorBundle.getString(
675               "xpinstallPromptMessage.message.unknown"
676             );
677           }
678           let learnMore = doc.getElementById("addon-install-blocked-info");
679           learnMore.textContent = gNavigatorBundle.getString(
680             "xpinstallPromptMessage.learnMore"
681           );
682           learnMore.setAttribute(
683             "href",
684             Services.urlFormatter.formatURLPref("app.support.baseURL") +
685               "unlisted-extensions-risks"
686           );
687         };
689         let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI");
690         action = {
691           label: gNavigatorBundle.getString("xpinstallPromptMessage.install"),
692           accessKey: gNavigatorBundle.getString(
693             "xpinstallPromptMessage.install.accesskey"
694           ),
695           callback() {
696             secHistogram.add(
697               Ci.nsISecurityUITelemetry
698                 .WARNING_ADDON_ASKING_PREVENTED_CLICK_THROUGH
699             );
700             installInfo.install();
701           },
702         };
703         let dontAllowAction = {
704           label: gNavigatorBundle.getString("xpinstallPromptMessage.dontAllow"),
705           accessKey: gNavigatorBundle.getString(
706             "xpinstallPromptMessage.dontAllow.accesskey"
707           ),
708           callback: () => {
709             for (let install of installInfo.installs) {
710               if (install.state != AddonManager.STATE_CANCELLED) {
711                 install.cancel();
712               }
713             }
714             if (installInfo.cancel) {
715               installInfo.cancel();
716             }
717           },
718         };
719         let neverAllowAction = {
720           label: gNavigatorBundle.getString(
721             "xpinstallPromptMessage.neverAllow"
722           ),
723           accessKey: gNavigatorBundle.getString(
724             "xpinstallPromptMessage.neverAllow.accesskey"
725           ),
726           callback: () => {
727             SitePermissions.setForPrincipal(
728               browser.contentPrincipal,
729               "install",
730               SitePermissions.BLOCK
731             );
732             for (let install of installInfo.installs) {
733               if (install.state != AddonManager.STATE_CANCELLED) {
734                 install.cancel();
735               }
736             }
737             if (installInfo.cancel) {
738               installInfo.cancel();
739             }
740           },
741         };
743         secHistogram.add(
744           Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED
745         );
746         let popup = PopupNotifications.show(
747           browser,
748           notificationID,
749           messageString,
750           gUnifiedExtensions.getPopupAnchorID(browser, window),
751           action,
752           [dontAllowAction, neverAllowAction],
753           options
754         );
755         removeNotificationOnEnd(popup, installInfo.installs);
756         break;
757       }
758       case "addon-install-started": {
759         let needsDownload = function needsDownload(aInstall) {
760           return aInstall.state != AddonManager.STATE_DOWNLOADED;
761         };
762         // If all installs have already been downloaded then there is no need to
763         // show the download progress
764         if (!installInfo.installs.some(needsDownload)) {
765           return;
766         }
767         notificationID = "addon-progress";
768         messageString = gNavigatorBundle.getString(
769           "addonDownloadingAndVerifying"
770         );
771         messageString = PluralForm.get(
772           installInfo.installs.length,
773           messageString
774         );
775         messageString = messageString.replace(
776           "#1",
777           installInfo.installs.length
778         );
779         options.installs = installInfo.installs;
780         options.contentWindow = browser.contentWindow;
781         options.sourceURI = browser.currentURI;
782         options.eventCallback = function(aEvent) {
783           switch (aEvent) {
784             case "removed":
785               options.contentWindow = null;
786               options.sourceURI = null;
787               break;
788           }
789         };
790         action = {
791           label: gNavigatorBundle.getString("addonInstall.acceptButton2.label"),
792           accessKey: gNavigatorBundle.getString(
793             "addonInstall.acceptButton2.accesskey"
794           ),
795           disabled: true,
796           callback: () => {},
797         };
798         let secondaryAction = {
799           label: gNavigatorBundle.getString("addonInstall.cancelButton.label"),
800           accessKey: gNavigatorBundle.getString(
801             "addonInstall.cancelButton.accesskey"
802           ),
803           callback: () => {
804             for (let install of installInfo.installs) {
805               if (install.state != AddonManager.STATE_CANCELLED) {
806                 install.cancel();
807               }
808             }
809           },
810         };
811         let notification = PopupNotifications.show(
812           browser,
813           notificationID,
814           messageString,
815           gUnifiedExtensions.getPopupAnchorID(browser, window),
816           action,
817           [secondaryAction],
818           options
819         );
820         notification._startTime = Date.now();
822         break;
823       }
824       case "addon-install-failed": {
825         options.removeOnDismissal = true;
826         options.persistent = false;
828         // TODO This isn't terribly ideal for the multiple failure case
829         for (let install of installInfo.installs) {
830           let host;
831           try {
832             host = options.displayURI.host;
833           } catch (e) {
834             // displayURI might be missing or 'host' might throw for non-nsStandardURL nsIURIs.
835           }
837           if (!host) {
838             host =
839               install.sourceURI instanceof Ci.nsIStandardURL &&
840               install.sourceURI.host;
841           }
843           // Construct the l10n ID for the error, e.g. "addonInstallError-3"
844           let error =
845             host || install.error == 0
846               ? "addonInstallError"
847               : "addonLocalInstallError";
848           let args;
849           if (install.error < 0) {
850             // Append the error code for the installation failure to get the
851             // matching translation of the error. The error code is defined in
852             // AddonManager's _errors Map. Not all error codes listed there are
853             // translated, since errors that are only triggered during updates
854             // will never reach this code.
855             error += install.error;
856             args = [brandShortName, install.name];
857           } else if (
858             install.addon.blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED
859           ) {
860             error += "Blocklisted";
861             args = [install.name];
862           } else {
863             error += "Incompatible";
864             args = [brandShortName, Services.appinfo.version, install.name];
865           }
867           if (
868             install.addon &&
869             !Services.policies.mayInstallAddon(install.addon)
870           ) {
871             error = "addonInstallBlockedByPolicy";
872             let extensionSettings = Services.policies.getExtensionSettings(
873               install.addon.id
874             );
875             let message = "";
876             if (
877               extensionSettings &&
878               "blocked_install_message" in extensionSettings
879             ) {
880               message = " " + extensionSettings.blocked_install_message;
881             }
882             args = [install.name, install.addon.id, message];
883           }
885           // Add Learn More link when refusing to install an unsigned add-on
886           if (install.error == AddonManager.ERROR_SIGNEDSTATE_REQUIRED) {
887             options.learnMoreURL =
888               Services.urlFormatter.formatURLPref("app.support.baseURL") +
889               "unsigned-addons";
890           }
892           messageString = gNavigatorBundle.getFormattedString(error, args);
894           PopupNotifications.show(
895             browser,
896             notificationID,
897             messageString,
898             gUnifiedExtensions.getPopupAnchorID(browser, window),
899             action,
900             null,
901             options
902           );
904           // Can't have multiple notifications with the same ID, so stop here.
905           break;
906         }
907         this._removeProgressNotification(browser);
908         break;
909       }
910       case "addon-install-confirmation": {
911         let showNotification = () => {
912           let height = undefined;
914           if (PopupNotifications.isPanelOpen) {
915             let rect = document
916               .getElementById("addon-progress-notification")
917               .getBoundingClientRect();
918             height = rect.height;
919           }
921           this._removeProgressNotification(browser);
922           this.showInstallConfirmation(browser, installInfo, height);
923         };
925         let progressNotification = PopupNotifications.getNotification(
926           "addon-progress",
927           browser
928         );
929         if (progressNotification) {
930           let downloadDuration = Date.now() - progressNotification._startTime;
931           let securityDelay =
932             Services.prefs.getIntPref("security.dialog_enable_delay") -
933             downloadDuration;
934           if (securityDelay > 0) {
935             setTimeout(() => {
936               // The download may have been cancelled during the security delay
937               if (
938                 PopupNotifications.getNotification("addon-progress", browser)
939               ) {
940                 showNotification();
941               }
942             }, securityDelay);
943             break;
944           }
945         }
946         showNotification();
947         break;
948       }
949       case "addon-install-complete": {
950         let secondaryActions = null;
951         let numAddons = installInfo.installs.length;
953         if (numAddons == 1) {
954           messageString = gNavigatorBundle.getFormattedString(
955             "addonInstalled",
956             [installInfo.installs[0].name]
957           );
958         } else {
959           messageString = gNavigatorBundle.getString("addonsGenericInstalled");
960           messageString = PluralForm.get(numAddons, messageString);
961           messageString = messageString.replace("#1", numAddons);
962         }
963         action = null;
965         options.removeOnDismissal = true;
966         options.persistent = false;
968         PopupNotifications.show(
969           browser,
970           notificationID,
971           messageString,
972           gUnifiedExtensions.getPopupAnchorID(browser, window),
973           action,
974           secondaryActions,
975           options
976         );
977         break;
978       }
979     }
980   },
981   _removeProgressNotification(aBrowser) {
982     let notification = PopupNotifications.getNotification(
983       "addon-progress",
984       aBrowser
985     );
986     if (notification) {
987       notification.remove();
988     }
989   },
992 var gExtensionsNotifications = {
993   initialized: false,
994   init() {
995     this.updateAlerts();
996     this.boundUpdate = this.updateAlerts.bind(this);
997     ExtensionsUI.on("change", this.boundUpdate);
998     this.initialized = true;
999   },
1001   uninit() {
1002     // uninit() can race ahead of init() in some cases, if that happens,
1003     // we have no handler to remove.
1004     if (!this.initialized) {
1005       return;
1006     }
1007     ExtensionsUI.off("change", this.boundUpdate);
1008   },
1010   _createAddonButton(text, icon, callback) {
1011     let button = document.createXULElement("toolbarbutton");
1012     button.setAttribute("wrap", "true");
1013     button.setAttribute("label", text);
1014     button.setAttribute("tooltiptext", text);
1015     const DEFAULT_EXTENSION_ICON =
1016       "chrome://mozapps/skin/extensions/extensionGeneric.svg";
1017     button.setAttribute("image", icon || DEFAULT_EXTENSION_ICON);
1018     button.className = "addon-banner-item subviewbutton";
1020     button.addEventListener("command", callback);
1021     PanelUI.addonNotificationContainer.appendChild(button);
1022   },
1024   updateAlerts() {
1025     let sideloaded = ExtensionsUI.sideloaded;
1026     let updates = ExtensionsUI.updates;
1028     let container = PanelUI.addonNotificationContainer;
1030     while (container.firstChild) {
1031       container.firstChild.remove();
1032     }
1034     let items = 0;
1035     for (let update of updates) {
1036       if (++items > 4) {
1037         break;
1038       }
1039       let text = gNavigatorBundle.getFormattedString(
1040         "webextPerms.updateMenuItem",
1041         [update.addon.name]
1042       );
1043       this._createAddonButton(text, update.addon.iconURL, evt => {
1044         ExtensionsUI.showUpdate(gBrowser, update);
1045       });
1046     }
1048     let appName;
1049     for (let addon of sideloaded) {
1050       if (++items > 4) {
1051         break;
1052       }
1053       if (!appName) {
1054         let brandBundle = document.getElementById("bundle_brand");
1055         appName = brandBundle.getString("brandShortName");
1056       }
1058       let text = gNavigatorBundle.getFormattedString(
1059         "webextPerms.sideloadMenuItem",
1060         [addon.name, appName]
1061       );
1062       this._createAddonButton(text, addon.iconURL, evt => {
1063         // We need to hide the main menu manually because the toolbarbutton is
1064         // removed immediately while processing this event, and PanelUI is
1065         // unable to identify which panel should be closed automatically.
1066         PanelUI.hide();
1067         ExtensionsUI.showSideloaded(gBrowser, addon);
1068       });
1069     }
1070   },
1073 var BrowserAddonUI = {
1074   async promptRemoveExtension(addon) {
1075     let { name } = addon;
1076     let title = await document.l10n.formatValue("addon-removal-title", {
1077       name,
1078     });
1079     let { getFormattedString, getString } = gNavigatorBundle;
1080     let btnTitle = getString("webext.remove.confirmation.button");
1081     let {
1082       BUTTON_TITLE_IS_STRING: titleString,
1083       BUTTON_TITLE_CANCEL: titleCancel,
1084       BUTTON_POS_0,
1085       BUTTON_POS_1,
1086       confirmEx,
1087     } = Services.prompt;
1088     let btnFlags = BUTTON_POS_0 * titleString + BUTTON_POS_1 * titleCancel;
1089     let checkboxState = { value: false };
1090     let checkboxMessage = null;
1092     // Enable abuse report checkbox in the remove extension dialog,
1093     // if enabled by the about:config prefs and the addon type
1094     // is currently supported.
1095     if (
1096       gAddonAbuseReportEnabled &&
1097       ["extension", "theme"].includes(addon.type)
1098     ) {
1099       checkboxMessage = await document.l10n.formatValue(
1100         "addon-removal-abuse-report-checkbox"
1101       );
1102     }
1104     let message = null;
1106     if (!Services.prefs.getBoolPref("prompts.windowPromptSubDialog", false)) {
1107       message = getFormattedString("webext.remove.confirmation.message", [
1108         name,
1109         document.getElementById("bundle_brand").getString("brandShorterName"),
1110       ]);
1111     }
1113     let result = confirmEx(
1114       window,
1115       title,
1116       message,
1117       btnFlags,
1118       btnTitle,
1119       /* button1 */ null,
1120       /* button2 */ null,
1121       checkboxMessage,
1122       checkboxState
1123     );
1125     return { remove: result === 0, report: checkboxState.value };
1126   },
1128   async reportAddon(addonId, reportEntryPoint) {
1129     let addon = addonId && (await AddonManager.getAddonByID(addonId));
1130     if (!addon) {
1131       return;
1132     }
1134     const win = await BrowserOpenAddonsMgr("addons://list/extension");
1136     win.openAbuseReport({ addonId, reportEntryPoint });
1137   },
1139   async removeAddon(addonId, eventObject) {
1140     let addon = addonId && (await AddonManager.getAddonByID(addonId));
1141     if (!addon || !(addon.permissions & AddonManager.PERM_CAN_UNINSTALL)) {
1142       return;
1143     }
1145     let { remove, report } = await this.promptRemoveExtension(addon);
1147     AMTelemetry.recordActionEvent({
1148       object: eventObject,
1149       action: "uninstall",
1150       value: remove ? "accepted" : "cancelled",
1151       extra: { addonId },
1152     });
1154     if (remove) {
1155       // Leave the extension in pending uninstall if we are also reporting the
1156       // add-on.
1157       await addon.uninstall(report);
1159       if (report) {
1160         await this.reportAddon(addon.id, "uninstall");
1161       }
1162     }
1163   },
1165   async manageAddon(addonId, eventObject) {
1166     let addon = addonId && (await AddonManager.getAddonByID(addonId));
1167     if (!addon) {
1168       return;
1169     }
1171     BrowserOpenAddonsMgr("addons://detail/" + encodeURIComponent(addon.id));
1172     AMTelemetry.recordActionEvent({
1173       object: eventObject,
1174       action: "manage",
1175       extra: { addonId: addon.id },
1176     });
1177   },
1181  * The `unified-extensions-item` custom element is used to manage an extension
1182  * in the list of extensions, which is displayed when users click the unified
1183  * extensions (toolbar) button.
1185  * This custom element must be initialized with `setAddon()`:
1187  * ```
1188  * let item = document.createElement("unified-extensions-item");
1189  * item.setAddon(addon);
1190  * document.body.appendChild(item);
1191  * ```
1192  */
1193 customElements.define(
1194   "unified-extensions-item",
1195   class extends HTMLElement {
1196     /**
1197      * Set the add-on for this item. The item will be populated based on the
1198      * add-on when it is rendered into the DOM.
1199      *
1200      * @param {AddonWrapper} addon The add-on to use.
1201      */
1202     setAddon(addon) {
1203       this.addon = addon;
1204     }
1206     connectedCallback() {
1207       if (this._openMenuButton) {
1208         return;
1209       }
1211       const template = document.getElementById(
1212         "unified-extensions-item-template"
1213       );
1214       this.appendChild(template.content.cloneNode(true));
1216       this._actionButton = this.querySelector(
1217         ".unified-extensions-item-action"
1218       );
1219       this._openMenuButton = this.querySelector(
1220         ".unified-extensions-item-open-menu"
1221       );
1223       this._openMenuButton.addEventListener("blur", this);
1224       this._openMenuButton.addEventListener("focus", this);
1226       this.addEventListener("command", this);
1227       this.addEventListener("mouseout", this);
1228       this.addEventListener("mouseover", this);
1230       this.render();
1231     }
1233     handleEvent(event) {
1234       const { target } = event;
1236       switch (event.type) {
1237         case "command":
1238           if (target === this._openMenuButton) {
1239             const popup = target.ownerDocument.getElementById(
1240               "unified-extensions-context-menu"
1241             );
1242             popup.openPopup(
1243               target,
1244               "after_end",
1245               0,
1246               0,
1247               true /* isContextMenu */,
1248               false /* attributesOverride */,
1249               event
1250             );
1251           } else if (target === this._actionButton) {
1252             const extension = WebExtensionPolicy.getByID(this.addon.id)
1253               ?.extension;
1254             if (!extension) {
1255               return;
1256             }
1258             const win = event.target.ownerGlobal;
1259             const tab = win.gBrowser.selectedTab;
1261             extension.tabManager.addActiveTabPermission(tab);
1262             extension.tabManager.activateScripts(tab);
1263           }
1264           break;
1266         case "blur":
1267         case "mouseout":
1268           if (target === this._openMenuButton) {
1269             this.removeAttribute("secondary-button-hovered");
1270           } else if (target === this._actionButton) {
1271             this._updateStateMessage();
1272           }
1273           break;
1275         case "focus":
1276         case "mouseover":
1277           if (target === this._openMenuButton) {
1278             this.setAttribute("secondary-button-hovered", true);
1279           } else if (target === this._actionButton) {
1280             this._updateStateMessage({ hover: true });
1281           }
1282           break;
1283       }
1284     }
1286     async _updateStateMessage({ hover = false } = {}) {
1287       const policy = WebExtensionPolicy.getByID(this.addon.id);
1289       const messages = lazy.OriginControls.getStateMessageIDs(
1290         policy,
1291         this.ownerGlobal.gBrowser.currentURI
1292       );
1293       if (!messages) {
1294         return;
1295       }
1297       const messageElement = this.querySelector(
1298         ".unified-extensions-item-message-default"
1299       );
1301       // We only want to adjust the height of an item in the panel when we
1302       // first draw it, and not on hover (even if the hover message is longer,
1303       // which shouldn't happen in practice but even if it was, we don't want
1304       // to change the height on hover).
1305       let adjustMinHeight = false;
1306       if (hover && messages.onHover) {
1307         this.ownerDocument.l10n.setAttributes(messageElement, messages.onHover);
1308       } else if (messages.default) {
1309         this.ownerDocument.l10n.setAttributes(messageElement, messages.default);
1310         adjustMinHeight = true;
1311       }
1313       await document.l10n.translateElements([messageElement]);
1315       if (adjustMinHeight) {
1316         const contentsElement = this.querySelector(
1317           ".unified-extensions-item-contents"
1318         );
1319         const { height } = getComputedStyle(contentsElement);
1320         contentsElement.style.minHeight = height;
1321       }
1322     }
1324     _hasAction() {
1325       const policy = WebExtensionPolicy.getByID(this.addon.id);
1326       const state = lazy.OriginControls.getState(
1327         policy,
1328         this.ownerGlobal.gBrowser.currentURI
1329       );
1331       return state && state.whenClicked && !state.hasAccess;
1332     }
1334     render() {
1335       if (!this.addon) {
1336         throw new Error(
1337           "unified-extensions-item requires an add-on, forgot to call setAddon()?"
1338         );
1339       }
1341       this.setAttribute("extension-id", this.addon.id);
1343       let policy = WebExtensionPolicy.getByID(this.addon.id);
1344       this.setAttribute(
1345         "attention",
1346         lazy.OriginControls.getAttention(policy, this.ownerGlobal)
1347       );
1349       this.querySelector(
1350         ".unified-extensions-item-name"
1351       ).textContent = this.addon.name;
1353       const iconURL = AddonManager.getPreferredIconURL(this.addon, 32, window);
1354       if (iconURL) {
1355         this.querySelector(".unified-extensions-item-icon").setAttribute(
1356           "src",
1357           iconURL
1358         );
1359       }
1361       this._actionButton.disabled = !this._hasAction();
1363       this._openMenuButton.dataset.extensionId = this.addon.id;
1364       this._openMenuButton.setAttribute(
1365         "data-l10n-args",
1366         JSON.stringify({ extensionName: this.addon.name })
1367       );
1369       this._updateStateMessage();
1370     }
1371   }
1374 // We must declare `gUnifiedExtensions` using `var` below to avoid a
1375 // "redeclaration" syntax error.
1376 var gUnifiedExtensions = {
1377   _initialized: false,
1379   init() {
1380     if (this._initialized) {
1381       return;
1382     }
1384     if (this.isEnabled) {
1385       MozXULElement.insertFTLIfNeeded("preview/originControls.ftl");
1386       MozXULElement.insertFTLIfNeeded("preview/unifiedExtensions.ftl");
1388       this._button = document.getElementById("unified-extensions-button");
1389       // TODO: Bug 1778684 - Auto-hide button when there is no active extension.
1390       this._button.hidden = false;
1392       document
1393         .getElementById("nav-bar")
1394         .setAttribute("unifiedextensionsbuttonshown", true);
1396       gBrowser.addTabsProgressListener(this);
1397       window.addEventListener("TabSelect", () => this.updateAttention());
1399       this.permListener = () => this.updateAttention();
1400       lazy.ExtensionPermissions.addListener(this.permListener);
1401     }
1403     this._initialized = true;
1404   },
1406   uninit() {
1407     if (this.permListener) {
1408       lazy.ExtensionPermissions.removeListener(this.permListener);
1409       this.permListener = null;
1410     }
1411   },
1413   get isEnabled() {
1414     return Services.prefs.getBoolPref(
1415       "extensions.unifiedExtensions.enabled",
1416       false
1417     );
1418   },
1420   onLocationChange(browser, webProgress, _request, _uri, flags) {
1421     // Only update on top-level cross-document navigations in the selected tab.
1422     if (
1423       webProgress.isTopLevel &&
1424       browser === gBrowser.selectedBrowser &&
1425       !(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)
1426     ) {
1427       this.updateAttention();
1428     }
1429   },
1431   // Update the attention indicator for the whole unified extensions button.
1432   async updateAttention() {
1433     for (let addon of await this.getActiveExtensions()) {
1434       let policy = WebExtensionPolicy.getByID(addon.id);
1435       let widget = this.browserActionFor(policy)?.widget;
1437       // Only show for extensions which are not already visible in the toolbar.
1438       if (!widget || widget.areaType !== CustomizableUI.TYPE_TOOLBAR) {
1439         if (lazy.OriginControls.getAttention(policy, window)) {
1440           this.button.setAttribute("attention", true);
1441           return;
1442         }
1443       }
1444     }
1445     this.button.setAttribute("attention", false);
1446   },
1448   getPopupAnchorID(aBrowser, aWindow) {
1449     if (this.isEnabled) {
1450       const anchorID = "unified-extensions-button";
1451       const attr = anchorID + "popupnotificationanchor";
1453       if (!aBrowser[attr]) {
1454         // A hacky way of setting the popup anchor outside the usual url bar
1455         // icon box, similar to how it was done for CFR.
1456         // See: https://searchfox.org/mozilla-central/rev/847b64cc28b74b44c379f9bff4f415b97da1c6d7/toolkit/modules/PopupNotifications.jsm#42
1457         aBrowser[attr] = aWindow.document.getElementById(
1458           anchorID
1459           // Anchor on the toolbar icon to position the popup right below the
1460           // button.
1461         ).firstElementChild;
1462       }
1464       return anchorID;
1465     }
1467     return "addons-notification-icon";
1468   },
1470   get button() {
1471     return this._button;
1472   },
1474   /**
1475    * Gets a list of active AddonWrapper instances of type "extension", sorted
1476    * alphabetically based on add-on's names.
1477    *
1478    * @return {Array<AddonWrapper>} An array of active extensions.
1479    */
1480   async getActiveExtensions() {
1481     // TODO: Bug 1778682 - Use a new method on `AddonManager` so that we get
1482     // the same list of extensions as the one in `about:addons`.
1484     // We only want to display active and visible extensions, and we want to
1485     // list them alphabetically.
1486     let addons = await AddonManager.getAddonsByTypes(["extension"]);
1487     addons = addons.filter(addon => !addon.hidden && addon.isActive);
1488     addons.sort((a1, a2) => a1.name.localeCompare(a2.name));
1490     return addons;
1491   },
1493   handleEvent(event) {
1494     switch (event.type) {
1495       case "ViewShowing": {
1496         this.onPanelViewShowing(event.target);
1497         break;
1498       }
1499       case "ViewHiding": {
1500         this.onPanelViewHiding(event.target);
1501       }
1502     }
1503   },
1505   async onPanelViewShowing(panelview) {
1506     const list = panelview.querySelector(".unified-extensions-list");
1507     const extensions = await this.getActiveExtensions();
1509     for (const extension of extensions) {
1510       const item = document.createElement("unified-extensions-item");
1511       item.setAddon(extension);
1512       list.appendChild(item);
1513     }
1514   },
1516   onPanelViewHiding(panelview) {
1517     const list = panelview.querySelector(".unified-extensions-list");
1518     while (list.lastChild) {
1519       list.lastChild.remove();
1520     }
1521   },
1523   async togglePanel(aEvent) {
1524     if (!CustomizationHandler.isCustomizing()) {
1525       // The button should directly open `about:addons` when there is no active
1526       // extension to show in the panel.
1527       if ((await this.getActiveExtensions()).length === 0) {
1528         await BrowserOpenAddonsMgr("addons://discover/");
1529         return;
1530       }
1532       if (!this._listView) {
1533         this._listView = PanelMultiView.getViewNode(
1534           document,
1535           "unified-extensions-view"
1536         );
1537         this._listView.addEventListener("ViewShowing", this);
1538         this._listView.addEventListener("ViewHiding", this);
1540         // Lazy-load the l10n strings.
1541         document
1542           .getElementById("unified-extensions-context-menu")
1543           .querySelectorAll("[data-lazy-l10n-id]")
1544           .forEach(el => {
1545             el.setAttribute(
1546               "data-l10n-id",
1547               el.getAttribute("data-lazy-l10n-id")
1548             );
1549             el.removeAttribute("data-lazy-l10n-id");
1550           });
1551       }
1553       if (this._button.open) {
1554         PanelMultiView.hidePopup(this._listView.closest("panel"));
1555         this._button.open = false;
1556       } else {
1557         PanelUI.showSubView("unified-extensions-view", this._button, aEvent);
1558       }
1559     }
1561     // We always dispatch an event (useful for testing purposes).
1562     window.dispatchEvent(new CustomEvent("UnifiedExtensionsTogglePanel"));
1563   },
1565   async updateContextMenu(menu, event) {
1566     // When the context menu is open, `onpopupshowing` is called when menu
1567     // items open sub-menus. We don't want to update the context menu in this
1568     // case.
1569     if (event.target.id !== "unified-extensions-context-menu") {
1570       return;
1571     }
1573     const id = this._getExtensionId(menu);
1574     const addon = await AddonManager.getAddonByID(id);
1576     const removeButton = menu.querySelector(
1577       ".unified-extensions-context-menu-remove-extension"
1578     );
1579     const reportButton = menu.querySelector(
1580       ".unified-extensions-context-menu-report-extension"
1581     );
1583     reportButton.hidden = !gAddonAbuseReportEnabled;
1584     removeButton.disabled = !(
1585       addon.permissions & AddonManager.PERM_CAN_UNINSTALL
1586     );
1588     ExtensionsUI.originControlsMenu(menu, id);
1590     const browserAction = this.browserActionFor(WebExtensionPolicy.getByID(id));
1591     if (browserAction) {
1592       browserAction.updateContextMenu(menu);
1593     }
1594   },
1596   browserActionFor(policy) {
1597     // Ideally, we wouldn't do that because `browserActionFor()` will only be
1598     // defined in `global` when at least one extension has required loading the
1599     // `ext-browserAction` code.
1600     let method = lazy.ExtensionParent.apiManager.global.browserActionFor;
1601     return method?.(policy?.extension);
1602   },
1604   async manageExtension(menu) {
1605     const id = this._getExtensionId(menu);
1607     await this.togglePanel();
1608     await BrowserAddonUI.manageAddon(id, "unifiedExtensions");
1609   },
1611   async removeExtension(menu) {
1612     const id = this._getExtensionId(menu);
1614     await this.togglePanel();
1615     await BrowserAddonUI.removeAddon(id, "unifiedExtensions");
1616   },
1618   async reportExtension(menu) {
1619     const id = this._getExtensionId(menu);
1621     await this.togglePanel();
1622     await BrowserAddonUI.reportAddon(id, "unified_context_menu");
1623   },
1625   _getExtensionId(menu) {
1626     const { triggerNode } = menu;
1627     return triggerNode.dataset.extensionId;
1628   },