browser-addons.js (116026B)
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/. */ 5 6 var { XPCOMUtils } = ChromeUtils.importESModule( 7 "resource://gre/modules/XPCOMUtils.sys.mjs" 8 ); 9 10 const lazy = {}; 11 12 ChromeUtils.defineESModuleGetters(lazy, { 13 AddonManager: "resource://gre/modules/AddonManager.sys.mjs", 14 AMBrowserExtensionsImport: "resource://gre/modules/AddonManager.sys.mjs", 15 AbuseReporter: "resource://gre/modules/AbuseReporter.sys.mjs", 16 ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", 17 ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", 18 ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs", 19 OriginControls: "resource://gre/modules/ExtensionPermissions.sys.mjs", 20 PERMISSION_L10N: "resource://gre/modules/ExtensionPermissionMessages.sys.mjs", 21 SITEPERMS_ADDON_TYPE: 22 "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs", 23 }); 24 ChromeUtils.defineLazyGetter(lazy, "l10n", function () { 25 return new Localization( 26 ["browser/addonNotifications.ftl", "branding/brand.ftl"], 27 true 28 ); 29 }); 30 31 const HIDE_NO_SCRIPT_PREF = "extensions.hideNoScript"; 32 const HIDE_UNIFIED_WHEN_EMPTY_PREF = "extensions.hideUnifiedWhenEmpty"; 33 34 /** 35 * Mapping of error code -> [ 36 * error-id, 37 * local-error-id, 38 * (optional) error-id-when-addon-name-is-missing, 39 * (optional) local-error-id-when-addon-name-is-missing, 40 * ] 41 * 42 * error-id is used for errors in DownloadedAddonInstall, 43 * local-error-id for errors in LocalAddonInstall. 44 * 45 * The error codes are defined in AddonManager's _errors Map. 46 * Not all error codes listed there are translated, 47 * since errors that are only triggered during updates 48 * will never reach this code. 49 */ 50 const ERROR_L10N_IDS = new Map([ 51 [ 52 -1, 53 [ 54 "addon-install-error-network-failure", 55 "addon-local-install-error-network-failure", 56 ], 57 ], 58 [ 59 -2, 60 [ 61 "addon-install-error-incorrect-hash", 62 "addon-local-install-error-incorrect-hash", 63 ], 64 ], 65 [ 66 -3, 67 [ 68 "addon-install-error-corrupt-file", 69 "addon-local-install-error-corrupt-file", 70 ], 71 ], 72 [ 73 -4, 74 [ 75 "addon-install-error-file-access", 76 "addon-local-install-error-file-access", 77 "addon-install-error-no-addon-name-file-access", 78 "addon-local-install-error-no-addon-name-file-access", 79 ], 80 ], 81 [ 82 -5, 83 ["addon-install-error-not-signed", "addon-local-install-error-not-signed"], 84 ], 85 [-8, ["addon-install-error-invalid-domain"]], 86 [ 87 -10, 88 ["addon-install-error-hard-blocked", "addon-install-error-hard-blocked"], 89 ], 90 [ 91 -11, 92 ["addon-install-error-incompatible", "addon-install-error-incompatible"], 93 ], 94 [ 95 -13, 96 [ 97 "addon-install-error-admin-install-only", 98 "addon-install-error-admin-install-only", 99 ], 100 ], 101 [ 102 -14, 103 ["addon-install-error-soft-blocked2", "addon-install-error-soft-blocked2"], 104 ], 105 ]); 106 107 customElements.define( 108 "addon-notification-blocklist-url", 109 class MozAddonNotificationBlocklistURL extends HTMLAnchorElement { 110 connectedCallback() { 111 this.addEventListener("click", this); 112 } 113 114 disconnectedCallback() { 115 this.removeEventListener("click", this); 116 } 117 118 handleEvent(e) { 119 if (e.type == "click") { 120 e.preventDefault(); 121 window.openTrustedLinkIn(this.href, "tab", { 122 // Make sure the newly open tab is going to be focused, independently 123 // from general user prefs. 124 forceForeground: true, 125 }); 126 } 127 } 128 }, 129 { extends: "a" } 130 ); 131 132 customElements.define( 133 "addon-webext-permissions-notification", 134 class MozAddonPermissionsNotification extends customElements.get( 135 "popupnotification" 136 ) { 137 show() { 138 super.show(); 139 140 if (!this.notification) { 141 return; 142 } 143 144 if (!this.notification.options?.customElementOptions) { 145 throw new Error( 146 "Mandatory customElementOptions property missing from notification options" 147 ); 148 } 149 150 this.textEl = this.querySelector("#addon-webext-perm-text"); 151 this.introEl = this.querySelector("#addon-webext-perm-intro"); 152 this.permsTitleEl = this.querySelector( 153 "#addon-webext-perm-title-required" 154 ); 155 this.permsListEl = this.querySelector("#addon-webext-perm-list-required"); 156 this.permsTitleDataCollectionEl = this.querySelector( 157 "#addon-webext-perm-title-data-collection" 158 ); 159 this.permsListDataCollectionEl = this.querySelector( 160 "#addon-webext-perm-list-data-collection" 161 ); 162 this.permsTitleOptionalEl = this.querySelector( 163 "#addon-webext-perm-title-optional" 164 ); 165 this.permsListOptionalEl = this.querySelector( 166 "#addon-webext-perm-list-optional" 167 ); 168 169 this.render(); 170 } 171 172 get hasNoPermissions() { 173 const { 174 strings, 175 showIncognitoCheckbox, 176 showTechnicalAndInteractionCheckbox, 177 } = this.notification.options.customElementOptions; 178 179 return !( 180 strings.msgs.length || 181 this.#dataCollectionPermissions?.msg || 182 showIncognitoCheckbox || 183 showTechnicalAndInteractionCheckbox 184 ); 185 } 186 187 get domainsSet() { 188 if (!this.notification?.options?.customElementOptions) { 189 return undefined; 190 } 191 const { strings } = this.notification.options.customElementOptions; 192 return strings.fullDomainsList?.domainsSet; 193 } 194 195 get hasFullDomainsList() { 196 return this.domainsSet?.size; 197 } 198 199 #isFullDomainsListEntryIndex(idx) { 200 if (!this.hasFullDomainsList) { 201 return false; 202 } 203 const { strings } = this.notification.options.customElementOptions; 204 return strings.fullDomainsList.msgIdIndex === idx; 205 } 206 207 /** 208 * @returns {{idx: number, collectsTechnicalAndInteractionData: boolean}} 209 * An object with information about data collection permissions for the UI. 210 */ 211 get #dataCollectionPermissions() { 212 if (!this.notification?.options?.customElementOptions) { 213 return undefined; 214 } 215 const { strings } = this.notification.options.customElementOptions; 216 return strings.dataCollectionPermissions; 217 } 218 219 render() { 220 const { 221 strings, 222 showIncognitoCheckbox, 223 showTechnicalAndInteractionCheckbox, 224 isUserScriptsRequest, 225 } = this.notification.options.customElementOptions; 226 227 const { 228 textEl, 229 introEl, 230 permsTitleEl, 231 permsListEl, 232 permsTitleDataCollectionEl, 233 permsListDataCollectionEl, 234 permsTitleOptionalEl, 235 permsListOptionalEl, 236 } = this; 237 238 const HTML_NS = "http://www.w3.org/1999/xhtml"; 239 const doc = this.ownerDocument; 240 241 this.#clearChildElements(); 242 // Re-enable "Allow" button if it was disabled by a previous request with 243 // isUserScriptsRequest=true. 244 this.#setAllowButtonEnabled(true); 245 246 if (strings.text) { 247 textEl.textContent = strings.text; 248 // By default, multiline strings don't get formatted properly. These 249 // are presently only used in site permission add-ons, so we treat it 250 // as a special case to avoid unintended effects on other things. 251 if (strings.text.includes("\n\n")) { 252 textEl.classList.add("addon-webext-perm-text-multiline"); 253 } 254 textEl.hidden = false; 255 } 256 257 if (strings.listIntro) { 258 introEl.textContent = strings.listIntro; 259 introEl.hidden = false; 260 } 261 262 // "sitepermission" add-ons don't have section headers. 263 if (strings.sectionHeaders) { 264 const { required, dataCollection, optional } = strings.sectionHeaders; 265 266 permsTitleEl.textContent = required; 267 permsTitleDataCollectionEl.textContent = dataCollection; 268 permsTitleOptionalEl.textContent = optional; 269 } 270 271 // Return earlier if there are no permissions to list. 272 if (this.hasNoPermissions) { 273 return; 274 } 275 276 // We only expect a single permission for a userScripts request per 277 // https://searchfox.org/mozilla-central/rev/5fb48bf50516ed2529d533e5dfe49b4752efb8b8/browser/modules/ExtensionsUI.sys.mjs#308-313. 278 if (isUserScriptsRequest) { 279 // The "userScripts" permission cannot be granted until the user has 280 // confirmed again in the notification's content, as described at 281 // https://bugzilla.mozilla.org/show_bug.cgi?id=1917000#c1 282 283 let { checkboxEl, warningEl } = this.#createUserScriptsPermissionItems( 284 // "userScripts" can only be requested with "permissions.request()", 285 // which enforces that it is the only permission in the request. 286 strings.msgs[0] 287 ); 288 289 this.#setAllowButtonEnabled(false); 290 291 let item = doc.createElementNS(HTML_NS, "li"); 292 item.append(checkboxEl, warningEl); 293 item.classList.add("webext-perm-optional"); 294 permsListEl.append(item); 295 296 permsTitleEl.hidden = false; 297 permsListEl.hidden = false; 298 } else { 299 if (strings.msgs.length) { 300 for (let [idx, msg] of strings.msgs.entries()) { 301 let item = doc.createElementNS(HTML_NS, "li"); 302 item.classList.add("webext-perm-granted"); 303 if ( 304 this.hasFullDomainsList && 305 this.#isFullDomainsListEntryIndex(idx) 306 ) { 307 item.append(this.#createFullDomainsListFragment(msg)); 308 } else { 309 item.textContent = msg; 310 } 311 permsListEl.appendChild(item); 312 } 313 314 permsTitleEl.hidden = false; 315 permsListEl.hidden = false; 316 } 317 318 if (this.#dataCollectionPermissions?.msg) { 319 let item = doc.createElementNS(HTML_NS, "li"); 320 item.classList.add( 321 "webext-perm-granted", 322 "webext-data-collection-perm-granted" 323 ); 324 item.textContent = this.#dataCollectionPermissions.msg; 325 permsListDataCollectionEl.appendChild(item); 326 permsTitleDataCollectionEl.hidden = false; 327 permsListDataCollectionEl.hidden = false; 328 } 329 330 // Add a checkbox for the "technicalAndInteraction" optional data 331 // collection permission. 332 if (showTechnicalAndInteractionCheckbox) { 333 let item = doc.createElementNS(HTML_NS, "li"); 334 item.classList.add( 335 "webext-perm-optional", 336 "webext-data-collection-perm-optional" 337 ); 338 item.appendChild(this.#createTechnicalAndInteractionDataCheckbox()); 339 permsListOptionalEl.appendChild(item); 340 permsTitleOptionalEl.hidden = false; 341 permsListOptionalEl.hidden = false; 342 } 343 344 if (showIncognitoCheckbox) { 345 let item = doc.createElementNS(HTML_NS, "li"); 346 item.classList.add( 347 "webext-perm-optional", 348 "webext-perm-privatebrowsing" 349 ); 350 item.appendChild(this.#createPrivateBrowsingCheckbox()); 351 permsListOptionalEl.appendChild(item); 352 permsTitleOptionalEl.hidden = false; 353 permsListOptionalEl.hidden = false; 354 } 355 } 356 } 357 358 #createFullDomainsListFragment(msg) { 359 const HTML_NS = "http://www.w3.org/1999/xhtml"; 360 const doc = this.ownerDocument; 361 const label = doc.createXULElement("label"); 362 label.value = msg; 363 const domainsList = doc.createElementNS(HTML_NS, "ul"); 364 domainsList.classList.add("webext-perm-domains-list"); 365 366 // Enforce max-height and ensure the domains list is 367 // scrollable when there are more than 5 domains. 368 if (this.domainsSet.size > 5) { 369 domainsList.classList.add("scrollable-domains-list"); 370 } 371 372 for (const domain of this.domainsSet) { 373 let domainItem = doc.createElementNS(HTML_NS, "li"); 374 domainItem.textContent = domain; 375 domainsList.appendChild(domainItem); 376 } 377 const { DocumentFragment } = this.ownerGlobal; 378 const fragment = new DocumentFragment(); 379 fragment.append(label); 380 fragment.append(domainsList); 381 return fragment; 382 } 383 384 #clearChildElements() { 385 const { 386 textEl, 387 introEl, 388 permsTitleEl, 389 permsListEl, 390 permsTitleDataCollectionEl, 391 permsListDataCollectionEl, 392 permsTitleOptionalEl, 393 permsListOptionalEl, 394 } = this; 395 396 // Clear all changes to the child elements that may have been changed 397 // by a previous call of the render method. 398 textEl.textContent = ""; 399 textEl.hidden = true; 400 textEl.classList.remove("addon-webext-perm-text-multiline"); 401 402 introEl.textContent = ""; 403 introEl.hidden = true; 404 405 for (const title of [ 406 permsTitleEl, 407 permsTitleOptionalEl, 408 permsTitleDataCollectionEl, 409 ]) { 410 title.hidden = true; 411 } 412 413 for (const list of [ 414 permsListEl, 415 permsListDataCollectionEl, 416 permsListOptionalEl, 417 ]) { 418 list.textContent = ""; 419 list.hidden = true; 420 } 421 } 422 423 #createUserScriptsPermissionItems(userScriptsPermissionMessage) { 424 let checkboxEl = this.ownerDocument.createElement("moz-checkbox"); 425 checkboxEl.label = userScriptsPermissionMessage; 426 checkboxEl.checked = false; 427 checkboxEl.addEventListener("change", () => { 428 // The main "Allow" button is disabled until the checkbox is checked. 429 this.#setAllowButtonEnabled(checkboxEl.checked); 430 }); 431 432 let warningEl = this.ownerDocument.createElement("moz-message-bar"); 433 warningEl.setAttribute("type", "warning"); 434 warningEl.setAttribute( 435 "message", 436 lazy.PERMISSION_L10N.formatValueSync( 437 "webext-perms-extra-warning-userScripts-short" 438 ) 439 ); 440 441 return { checkboxEl, warningEl }; 442 } 443 444 #setAllowButtonEnabled(allowed) { 445 let disabled = !allowed; 446 // "mainactiondisabled" mirrors the "disabled" boolean attribute of the 447 // "Allow" button. 448 this.toggleAttribute("mainactiondisabled", disabled); 449 450 // The "mainactiondisabled" attribute may also be toggled by the 451 // PopupNotifications._setNotificationUIState() method, which can be 452 // called as a side effect of toggling a checkbox within the notification 453 // (via PopupNotifications._onCommand). 454 // 455 // To prevent PopupNotifications._setNotificationUIState() from setting 456 // the "mainactiondisabled" attribute to a different state, also set the 457 // "invalidselection" attribute, since _setNotificationUIState() mirrors 458 // its value to "mainactiondisabled". 459 // 460 // TODO bug 1938623: Remove this when a better alternative exists. 461 this.toggleAttribute("invalidselection", disabled); 462 } 463 464 #createPrivateBrowsingCheckbox() { 465 const { grantPrivateBrowsingAllowed } = 466 this.notification.options.customElementOptions; 467 468 let checkboxEl = this.ownerDocument.createElement("moz-checkbox"); 469 checkboxEl.checked = grantPrivateBrowsingAllowed; 470 checkboxEl.addEventListener("change", () => { 471 // NOTE: the popupnotification instances will be reused 472 // and so the callback function is destructured here to 473 // avoid this custom element to prevent it from being 474 // garbage collected. 475 const { onPrivateBrowsingAllowedChanged } = 476 this.notification.options.customElementOptions; 477 onPrivateBrowsingAllowedChanged?.(checkboxEl.checked); 478 }); 479 this.ownerDocument.l10n.setAttributes( 480 checkboxEl, 481 "popup-notification-addon-privatebrowsing-checkbox2" 482 ); 483 return checkboxEl; 484 } 485 486 #createTechnicalAndInteractionDataCheckbox() { 487 const { grantTechnicalAndInteractionDataCollection } = 488 this.notification.options.customElementOptions; 489 490 const checkboxEl = this.ownerDocument.createElement("moz-checkbox"); 491 this.ownerDocument.l10n.setAttributes( 492 checkboxEl, 493 "popup-notification-addon-technical-and-interaction-checkbox" 494 ); 495 checkboxEl.checked = grantTechnicalAndInteractionDataCollection; 496 checkboxEl.addEventListener("change", () => { 497 // NOTE: the popupnotification instances will be reused 498 // and so the callback function is destructured here to 499 // avoid this custom element to prevent it from being 500 // garbage collected. 501 const { onTechnicalAndInteractionDataChanged } = 502 this.notification.options.customElementOptions; 503 onTechnicalAndInteractionDataChanged?.(checkboxEl.checked); 504 }); 505 506 return checkboxEl; 507 } 508 }, 509 { extends: "popupnotification" } 510 ); 511 512 customElements.define( 513 "addon-progress-notification", 514 class MozAddonProgressNotification extends customElements.get( 515 "popupnotification" 516 ) { 517 show() { 518 super.show(); 519 this.progressmeter = document.getElementById( 520 "addon-progress-notification-progressmeter" 521 ); 522 523 this.progresstext = document.getElementById( 524 "addon-progress-notification-progresstext" 525 ); 526 527 if (!this.notification) { 528 return; 529 } 530 531 this.notification.options.installs.forEach(function (aInstall) { 532 aInstall.addListener(this); 533 }, this); 534 535 // Calling updateProgress can sometimes cause this notification to be 536 // removed in the middle of refreshing the notification panel which 537 // makes the panel get refreshed again. Just initialise to the 538 // undetermined state and then schedule a proper check at the next 539 // opportunity 540 this.setProgress(0, -1); 541 this._updateProgressTimeout = setTimeout( 542 this.updateProgress.bind(this), 543 0 544 ); 545 } 546 547 disconnectedCallback() { 548 this.destroy(); 549 } 550 551 destroy() { 552 if (!this.notification) { 553 return; 554 } 555 this.notification.options.installs.forEach(function (aInstall) { 556 aInstall.removeListener(this); 557 }, this); 558 559 clearTimeout(this._updateProgressTimeout); 560 } 561 562 setProgress(aProgress, aMaxProgress) { 563 if (aMaxProgress == -1) { 564 this.progressmeter.removeAttribute("value"); 565 } else { 566 this.progressmeter.setAttribute( 567 "value", 568 (aProgress * 100) / aMaxProgress 569 ); 570 } 571 572 let now = Date.now(); 573 574 if (!this.notification.lastUpdate) { 575 this.notification.lastUpdate = now; 576 this.notification.lastProgress = aProgress; 577 return; 578 } 579 580 let delta = now - this.notification.lastUpdate; 581 if (delta < 400 && aProgress < aMaxProgress) { 582 return; 583 } 584 585 // Set min. time delta to avoid division by zero in the upcoming speed calculation 586 delta = Math.max(delta, 400); 587 delta /= 1000; 588 589 // This algorithm is the same used by the downloads code. 590 let speed = (aProgress - this.notification.lastProgress) / delta; 591 if (this.notification.speed) { 592 speed = speed * 0.9 + this.notification.speed * 0.1; 593 } 594 595 this.notification.lastUpdate = now; 596 this.notification.lastProgress = aProgress; 597 this.notification.speed = speed; 598 599 let status = null; 600 [status, this.notification.last] = DownloadUtils.getDownloadStatus( 601 aProgress, 602 aMaxProgress, 603 speed, 604 this.notification.last 605 ); 606 this.progresstext.setAttribute("value", status); 607 this.progresstext.setAttribute("tooltiptext", status); 608 } 609 610 cancel() { 611 let installs = this.notification.options.installs; 612 installs.forEach(function (aInstall) { 613 try { 614 aInstall.cancel(); 615 } catch (e) { 616 // Cancel will throw if the download has already failed 617 } 618 }, this); 619 620 PopupNotifications.remove(this.notification); 621 } 622 623 updateProgress() { 624 if (!this.notification) { 625 return; 626 } 627 628 let downloadingCount = 0; 629 let progress = 0; 630 let maxProgress = 0; 631 632 this.notification.options.installs.forEach(function (aInstall) { 633 if (aInstall.maxProgress == -1) { 634 maxProgress = -1; 635 } 636 progress += aInstall.progress; 637 if (maxProgress >= 0) { 638 maxProgress += aInstall.maxProgress; 639 } 640 if (aInstall.state < AddonManager.STATE_DOWNLOADED) { 641 downloadingCount++; 642 } 643 }); 644 645 if (downloadingCount == 0) { 646 this.destroy(); 647 this.progressmeter.removeAttribute("value"); 648 const status = lazy.l10n.formatValueSync("addon-download-verifying"); 649 this.progresstext.setAttribute("value", status); 650 this.progresstext.setAttribute("tooltiptext", status); 651 } else { 652 this.setProgress(progress, maxProgress); 653 } 654 } 655 656 onDownloadProgress() { 657 this.updateProgress(); 658 } 659 660 onDownloadFailed() { 661 this.updateProgress(); 662 } 663 664 onDownloadCancelled() { 665 this.updateProgress(); 666 } 667 668 onDownloadEnded() { 669 this.updateProgress(); 670 } 671 }, 672 { extends: "popupnotification" } 673 ); 674 675 // This custom element wraps the messagebar shown in the extensions panel 676 // and used in both ext-browserAction.js and browser-unified-extensions.js 677 customElements.define( 678 "unified-extensions-item-messagebar-wrapper", 679 class extends HTMLElement { 680 get extensionPolicy() { 681 return WebExtensionPolicy.getByID(this.extensionId); 682 } 683 684 get extensionName() { 685 return this.extensionPolicy?.name; 686 } 687 688 get isSoftBlocked() { 689 return this.extensionPolicy?.extension?.isSoftBlocked; 690 } 691 692 connectedCallback() { 693 this.messagebar = document.createElement("moz-message-bar"); 694 this.messagebar.classList.add("unified-extensions-item-messagebar"); 695 this.append(this.messagebar); 696 this.refresh(); 697 } 698 699 disconnectedCallback() { 700 this.messagebar?.remove(); 701 } 702 703 async refresh() { 704 if (!this.messagebar) { 705 // Nothing to refresh, the custom element has not been 706 // connected to the DOM yet. 707 return; 708 } 709 if (!customElements.get("moz-message-bar")) { 710 document.createElement("moz-message-bar"); 711 await customElements.whenDefined("moz-message-bar"); 712 } 713 const { messagebar } = this; 714 if (this.isSoftBlocked) { 715 const SOFTBLOCK_FLUENTID = 716 "unified-extensions-item-messagebar-softblocked2"; 717 if ( 718 messagebar.messageL10nId === SOFTBLOCK_FLUENTID && 719 messagebar.messageL10nArgs?.extensionName === this.extensionName 720 ) { 721 // nothing to refresh. 722 return; 723 } 724 messagebar.removeAttribute("hidden"); 725 messagebar.setAttribute("type", "warning"); 726 messagebar.messageL10nId = SOFTBLOCK_FLUENTID; 727 messagebar.messageL10nArgs = { 728 extensionName: this.extensionName, 729 }; 730 } else { 731 if (messagebar.hasAttribute("hidden")) { 732 // nothing to refresh. 733 return; 734 } 735 messagebar.setAttribute("hidden", "true"); 736 messagebar.messageL10nId = null; 737 messagebar.messageL10nArgs = null; 738 } 739 messagebar.requestUpdate(); 740 } 741 } 742 ); 743 744 class BrowserActionWidgetObserver { 745 #connected = false; 746 /** 747 * @param {string} addonId The ID of the extension 748 * @param {function()} onButtonAreaChanged Callback that is called whenever 749 * the observer detects the presence, absence or relocation of the browser 750 * action button for the given extension. 751 */ 752 constructor(addonId, onButtonAreaChanged) { 753 this.addonId = addonId; 754 // The expected ID of the browserAction widget. Keep in sync with 755 // actionWidgetId logic in ext-browserAction.js. 756 this.widgetId = `${lazy.ExtensionCommon.makeWidgetId(addonId)}-browser-action`; 757 this.onButtonAreaChanged = onButtonAreaChanged; 758 } 759 760 startObserving() { 761 if (this.#connected) { 762 return; 763 } 764 this.#connected = true; 765 CustomizableUI.addListener(this); 766 window.addEventListener("unload", this); 767 } 768 769 stopObserving() { 770 if (!this.#connected) { 771 return; 772 } 773 this.#connected = false; 774 CustomizableUI.removeListener(this); 775 window.removeEventListener("unload", this); 776 } 777 778 hasBrowserActionUI() { 779 const policy = WebExtensionPolicy.getByID(this.addonId); 780 if (!policy?.canAccessWindow(window)) { 781 // Add-on is not an extension, or extension has not started yet. Or it 782 // was uninstalled/disabled. Or disabled in current (private) window. 783 return false; 784 } 785 if (!gUnifiedExtensions.browserActionFor(policy)) { 786 // Does not have a browser action button. 787 return false; 788 } 789 return true; 790 } 791 792 onWidgetCreated(aWidgetId) { 793 // This is triggered as soon as ext-browserAction registers the button, 794 // shortly after hasBrowserActionUI() above can return true for the first 795 // time since add-on installation. 796 if (aWidgetId === this.widgetId) { 797 this.onButtonAreaChanged(); 798 } 799 } 800 801 onWidgetAdded(aWidgetId) { 802 if (aWidgetId === this.widgetId) { 803 this.onButtonAreaChanged(); 804 } 805 } 806 807 onWidgetMoved(aWidgetId) { 808 if (aWidgetId === this.widgetId) { 809 this.onButtonAreaChanged(); 810 } 811 } 812 813 handleEvent(event) { 814 if (event.type === "unload") { 815 this.stopObserving(); 816 } 817 } 818 } 819 820 customElements.define( 821 "addon-installed-notification", 822 class MozAddonInstalledNotification extends customElements.get( 823 "popupnotification" 824 ) { 825 #shouldIgnoreCheckboxStateChangeEvent = false; 826 #browserActionWidgetObserver; 827 connectedCallback() { 828 this.descriptionEl = this.querySelector("#addon-install-description"); 829 this.pinExtensionEl = this.querySelector( 830 "#addon-pin-toolbarbutton-checkbox" 831 ); 832 833 this.addEventListener("click", this); 834 this.pinExtensionEl.addEventListener("CheckboxStateChange", this); 835 this.#browserActionWidgetObserver?.startObserving(); 836 } 837 838 disconnectedCallback() { 839 this.removeEventListener("click", this); 840 this.pinExtensionEl.removeEventListener("CheckboxStateChange", this); 841 this.#browserActionWidgetObserver?.stopObserving(); 842 } 843 844 get #settingsLinkId() { 845 return "addon-install-settings-link"; 846 } 847 848 handleEvent(event) { 849 const { target } = event; 850 851 switch (event.type) { 852 case "click": { 853 if (target.id === this.#settingsLinkId) { 854 const { addonId } = this.notification.options.customElementOptions; 855 BrowserAddonUI.openAddonsMgr( 856 "addons://detail/" + encodeURIComponent(addonId) 857 ); 858 // The settings link element has its href set to "#" to be 859 // accessible with keyboard navigation, and so we call 860 // preventDefault to avoid the "#" href to be implicitly 861 // added to the browser chrome window url (See Bug 1983869 862 // for more details of the regression that the implicit 863 // change to the chrome window urls triggers). 864 event.preventDefault(); 865 } 866 break; 867 } 868 case "CheckboxStateChange": 869 // CheckboxStateChange fires whenever the checked value changes. 870 // Ignore the event if triggered by us instead of the user. 871 if (!this.#shouldIgnoreCheckboxStateChangeEvent) { 872 this.#handlePinnedCheckboxStateChange(); 873 } 874 break; 875 } 876 } 877 878 show() { 879 super.show(); 880 881 if (!this.notification) { 882 return; 883 } 884 885 if (!this.notification.options?.customElementOptions) { 886 throw new Error( 887 "Mandatory customElementOptions property missing from notification options" 888 ); 889 } 890 891 this.#browserActionWidgetObserver?.stopObserving(); 892 this.#browserActionWidgetObserver = new BrowserActionWidgetObserver( 893 this.notification.options.customElementOptions.addonId, 894 () => this.#renderPinToolbarButtonCheckbox() 895 ); 896 897 this.render(); 898 if (this.isConnected) { 899 this.#browserActionWidgetObserver.startObserving(); 900 } 901 } 902 903 render() { 904 let fluentId = "appmenu-addon-post-install-message3"; 905 906 this.ownerDocument.l10n.setAttributes(this.descriptionEl, null); 907 this.querySelector(`#${this.#settingsLinkId}`)?.remove(); 908 909 if (this.#dataCollectionPermissionsEnabled) { 910 const HTML_NS = "http://www.w3.org/1999/xhtml"; 911 const link = document.createElementNS(HTML_NS, "a"); 912 link.setAttribute("id", this.#settingsLinkId); 913 link.setAttribute("data-l10n-name", "settings-link"); 914 // Make the link both accessible and keyboard-friendly. 915 link.href = "#"; 916 this.descriptionEl.append(link); 917 918 fluentId = "appmenu-addon-post-install-message-with-data-collection"; 919 } 920 921 this.ownerDocument.l10n.setAttributes(this.descriptionEl, fluentId); 922 this.#renderPinToolbarButtonCheckbox(); 923 } 924 925 get #dataCollectionPermissionsEnabled() { 926 return Services.prefs.getBoolPref( 927 "extensions.dataCollectionPermissions.enabled", 928 false 929 ); 930 } 931 932 #renderPinToolbarButtonCheckbox() { 933 // If the extension has a browser action, show the checkbox to allow the 934 // user to customize its location. Hide by default until we know for 935 // certain that the conditions have been met. 936 this.pinExtensionEl.hidden = true; 937 938 if (!this.#browserActionWidgetObserver.hasBrowserActionUI()) { 939 return; 940 } 941 const widgetId = this.#browserActionWidgetObserver.widgetId; 942 943 // Extension buttons appear in AREA_ADDONS by default. There are several 944 // ways for the default to differ for a specific add-on, including the 945 // extension specifying default_area in its manifest.json file, an 946 // enterprise policy having been configured, or the user having moved the 947 // button someplace else. We only show the checkbox if it is either in 948 // AREA_ADDONS or in the toolbar. This covers almost all common cases. 949 const area = CustomizableUI.getPlacementOfWidget(widgetId)?.area; 950 let shouldPinToToolbar = area !== CustomizableUI.AREA_ADDONS; 951 if (shouldPinToToolbar && area !== CustomizableUI.AREA_NAVBAR) { 952 // We only support AREA_ADDONS and AREA_NAVBAR for now. 953 return; 954 } 955 this.#shouldIgnoreCheckboxStateChangeEvent = true; 956 this.pinExtensionEl.checked = shouldPinToToolbar; 957 this.#shouldIgnoreCheckboxStateChangeEvent = false; 958 this.pinExtensionEl.hidden = false; 959 } 960 961 #handlePinnedCheckboxStateChange() { 962 if (!this.#browserActionWidgetObserver.hasBrowserActionUI()) { 963 // Unexpected. #renderPinToolbarButtonCheckbox() should have hidden 964 // the checkbox if there is no widget. 965 const { addonId } = this.notification.options.customElementOptions; 966 throw new Error(`No browser action widget found for ${addonId}!`); 967 } 968 const widgetId = this.#browserActionWidgetObserver.widgetId; 969 const shouldPinToToolbar = this.pinExtensionEl.checked; 970 if (shouldPinToToolbar) { 971 gUnifiedExtensions._maybeMoveWidgetNodeBack(widgetId); 972 } 973 gUnifiedExtensions.pinToToolbar(widgetId, shouldPinToToolbar); 974 } 975 }, 976 { extends: "popupnotification" } 977 ); 978 979 // Removes a doorhanger notification if all of the installs it was notifying 980 // about have ended in some way. 981 function removeNotificationOnEnd(notification, installs) { 982 let count = installs.length; 983 984 function maybeRemove(install) { 985 install.removeListener(this); 986 987 if (--count == 0) { 988 // Check that the notification is still showing 989 let current = PopupNotifications.getNotification( 990 notification.id, 991 notification.browser 992 ); 993 if (current === notification) { 994 notification.remove(); 995 } 996 } 997 } 998 999 for (let install of installs) { 1000 install.addListener({ 1001 onDownloadCancelled: maybeRemove, 1002 onDownloadFailed: maybeRemove, 1003 onInstallFailed: maybeRemove, 1004 onInstallEnded: maybeRemove, 1005 }); 1006 } 1007 } 1008 1009 function buildNotificationAction(msg, callback) { 1010 let label = ""; 1011 let accessKey = ""; 1012 for (let { name, value } of msg.attributes) { 1013 switch (name) { 1014 case "label": 1015 label = value; 1016 break; 1017 case "accesskey": 1018 accessKey = value; 1019 break; 1020 } 1021 } 1022 return { label, accessKey, callback }; 1023 } 1024 1025 var gXPInstallObserver = { 1026 pendingInstalls: new WeakMap(), 1027 1028 showInstallConfirmation(browser, installInfo, height = undefined) { 1029 // If the confirmation notification is already open cache the installInfo 1030 // and the new confirmation will be shown later 1031 if ( 1032 PopupNotifications.getNotification("addon-install-confirmation", browser) 1033 ) { 1034 let pending = this.pendingInstalls.get(browser); 1035 if (pending) { 1036 pending.push(installInfo); 1037 } else { 1038 this.pendingInstalls.set(browser, [installInfo]); 1039 } 1040 return; 1041 } 1042 1043 let showNextConfirmation = () => { 1044 // Make sure the browser is still alive. 1045 if (!gBrowser.browsers.includes(browser)) { 1046 return; 1047 } 1048 1049 let pending = this.pendingInstalls.get(browser); 1050 if (pending && pending.length) { 1051 this.showInstallConfirmation(browser, pending.shift()); 1052 } 1053 }; 1054 1055 // If all installs have already been cancelled in some way then just show 1056 // the next confirmation 1057 if ( 1058 installInfo.installs.every(i => i.state != AddonManager.STATE_DOWNLOADED) 1059 ) { 1060 showNextConfirmation(); 1061 return; 1062 } 1063 1064 // Make notifications persistent 1065 var options = { 1066 displayURI: installInfo.originatingURI, 1067 persistent: true, 1068 hideClose: true, 1069 popupOptions: { 1070 position: "bottomright topright", 1071 }, 1072 }; 1073 1074 let acceptInstallation = () => { 1075 for (let install of installInfo.installs) { 1076 install.install(); 1077 } 1078 installInfo = null; 1079 1080 Glean.securityUi.events.accumulateSingleSample( 1081 Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL_CLICK_THROUGH 1082 ); 1083 }; 1084 1085 let cancelInstallation = () => { 1086 if (installInfo) { 1087 for (let install of installInfo.installs) { 1088 // The notification may have been closed because the add-ons got 1089 // cancelled elsewhere, only try to cancel those that are still 1090 // pending install. 1091 if (install.state != AddonManager.STATE_CANCELLED) { 1092 install.cancel(); 1093 } 1094 } 1095 } 1096 1097 showNextConfirmation(); 1098 }; 1099 1100 let unsigned = installInfo.installs.filter( 1101 i => i.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING 1102 ); 1103 let someUnsigned = 1104 !!unsigned.length && unsigned.length < installInfo.installs.length; 1105 1106 options.eventCallback = aEvent => { 1107 switch (aEvent) { 1108 case "removed": 1109 cancelInstallation(); 1110 break; 1111 case "shown": { 1112 let addonList = document.getElementById( 1113 "addon-install-confirmation-content" 1114 ); 1115 while (addonList.firstChild) { 1116 addonList.firstChild.remove(); 1117 } 1118 1119 for (let install of installInfo.installs) { 1120 let container = document.createXULElement("hbox"); 1121 1122 let name = document.createXULElement("label"); 1123 name.setAttribute("value", install.addon.name); 1124 name.setAttribute("class", "addon-install-confirmation-name"); 1125 container.appendChild(name); 1126 1127 if ( 1128 someUnsigned && 1129 install.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING 1130 ) { 1131 let unsignedLabel = document.createXULElement("label"); 1132 document.l10n.setAttributes( 1133 unsignedLabel, 1134 "popup-notification-addon-install-unsigned" 1135 ); 1136 unsignedLabel.setAttribute( 1137 "class", 1138 "addon-install-confirmation-unsigned" 1139 ); 1140 container.appendChild(unsignedLabel); 1141 } 1142 1143 addonList.appendChild(container); 1144 } 1145 break; 1146 } 1147 } 1148 }; 1149 1150 options.learnMoreURL = Services.urlFormatter.formatURLPref( 1151 "app.support.baseURL" 1152 ); 1153 1154 let msgId; 1155 let notification = document.getElementById( 1156 "addon-install-confirmation-notification" 1157 ); 1158 if (unsigned.length == installInfo.installs.length) { 1159 // None of the add-ons are verified 1160 msgId = "addon-confirm-install-unsigned-message"; 1161 notification.setAttribute("warning", "true"); 1162 options.learnMoreURL += "unsigned-addons"; 1163 } else if (!unsigned.length) { 1164 // All add-ons are verified or don't need to be verified 1165 msgId = "addon-confirm-install-message"; 1166 notification.removeAttribute("warning"); 1167 options.learnMoreURL += "find-and-install-add-ons"; 1168 } else { 1169 // Some of the add-ons are unverified, the list of names will indicate 1170 // which 1171 msgId = "addon-confirm-install-some-unsigned-message"; 1172 notification.setAttribute("warning", "true"); 1173 options.learnMoreURL += "unsigned-addons"; 1174 } 1175 const addonCount = installInfo.installs.length; 1176 const messageString = lazy.l10n.formatValueSync(msgId, { addonCount }); 1177 1178 const [acceptMsg, cancelMsg] = lazy.l10n.formatMessagesSync([ 1179 "addon-install-accept-button", 1180 "addon-install-cancel-button", 1181 ]); 1182 const action = buildNotificationAction(acceptMsg, acceptInstallation); 1183 const secondaryAction = buildNotificationAction(cancelMsg, () => {}); 1184 1185 if (height) { 1186 notification.style.minHeight = height + "px"; 1187 } 1188 1189 let tab = gBrowser.getTabForBrowser(browser); 1190 if (tab) { 1191 gBrowser.selectedTab = tab; 1192 } 1193 1194 let popup = PopupNotifications.show( 1195 browser, 1196 "addon-install-confirmation", 1197 messageString, 1198 gUnifiedExtensions.getPopupAnchorID(browser, window), 1199 action, 1200 [secondaryAction], 1201 options 1202 ); 1203 1204 removeNotificationOnEnd(popup, installInfo.installs); 1205 1206 Glean.securityUi.events.accumulateSingleSample( 1207 Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL 1208 ); 1209 }, 1210 1211 // IDs of addon install related notifications, passed by this file 1212 // (browser-addons.js) to PopupNotifications.show(). The only exception is 1213 // "addon-webext-permissions" (from browser/modules/ExtensionsUI.sys.mjs), 1214 // which can not only be triggered during add-on installation, but also 1215 // later, when the extension uses the browser.permissions.request() API. 1216 NOTIFICATION_IDS: [ 1217 "addon-install-blocked", 1218 "addon-install-confirmation", 1219 "addon-install-failed", 1220 "addon-install-origin-blocked", 1221 "addon-install-webapi-blocked", 1222 "addon-install-policy-blocked", 1223 "addon-progress", 1224 "addon-webext-permissions", 1225 "xpinstall-disabled", 1226 ], 1227 1228 /** 1229 * Remove all opened addon installation notifications 1230 * 1231 * @param {*} browser - Browser to remove notifications for 1232 * @returns {boolean} - true if notifications have been removed. 1233 */ 1234 removeAllNotifications(browser) { 1235 let notifications = this.NOTIFICATION_IDS.map(id => 1236 PopupNotifications.getNotification(id, browser) 1237 ).filter(notification => notification != null); 1238 1239 PopupNotifications.remove(notifications, true); 1240 1241 return !!notifications.length; 1242 }, 1243 1244 logWarningFullScreenInstallBlocked() { 1245 // If notifications have been removed, log a warning to the website console 1246 let consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance( 1247 Ci.nsIScriptError 1248 ); 1249 const message = lazy.l10n.formatValueSync( 1250 "addon-install-full-screen-blocked" 1251 ); 1252 consoleMsg.initWithWindowID( 1253 message, 1254 gBrowser.currentURI.spec, 1255 0, 1256 0, 1257 Ci.nsIScriptError.warningFlag, 1258 "FullScreen", 1259 gBrowser.selectedBrowser.innerWindowID 1260 ); 1261 Services.console.logMessage(consoleMsg); 1262 }, 1263 1264 async observe(aSubject, aTopic) { 1265 var installInfo = aSubject.wrappedJSObject; 1266 var browser = installInfo.browser; 1267 1268 // Make sure the browser is still alive. 1269 if (!browser || !gBrowser.browsers.includes(browser)) { 1270 return; 1271 } 1272 1273 // Make notifications persistent 1274 var options = { 1275 displayURI: installInfo.originatingURI, 1276 persistent: true, 1277 hideClose: true, 1278 timeout: Date.now() + 30000, 1279 popupOptions: { 1280 position: "bottomright topright", 1281 }, 1282 }; 1283 1284 switch (aTopic) { 1285 case "addon-install-disabled": { 1286 let msgId, action, secondaryActions; 1287 if (Services.prefs.prefIsLocked("xpinstall.enabled")) { 1288 msgId = "xpinstall-disabled-by-policy"; 1289 action = null; 1290 secondaryActions = null; 1291 } else { 1292 msgId = "xpinstall-disabled"; 1293 const [disabledMsg, cancelMsg] = await lazy.l10n.formatMessages([ 1294 "xpinstall-disabled-button", 1295 "addon-install-cancel-button", 1296 ]); 1297 action = buildNotificationAction(disabledMsg, () => { 1298 Services.prefs.setBoolPref("xpinstall.enabled", true); 1299 }); 1300 secondaryActions = [buildNotificationAction(cancelMsg, () => {})]; 1301 } 1302 1303 PopupNotifications.show( 1304 browser, 1305 "xpinstall-disabled", 1306 await lazy.l10n.formatValue(msgId), 1307 gUnifiedExtensions.getPopupAnchorID(browser, window), 1308 action, 1309 secondaryActions, 1310 options 1311 ); 1312 break; 1313 } 1314 case "addon-install-fullscreen-blocked": { 1315 // AddonManager denied installation because we are in DOM fullscreen 1316 this.logWarningFullScreenInstallBlocked(); 1317 break; 1318 } 1319 case "addon-install-webapi-blocked": 1320 case "addon-install-policy-blocked": 1321 case "addon-install-origin-blocked": { 1322 const msgId = 1323 aTopic == "addon-install-policy-blocked" 1324 ? "addon-install-domain-blocked-by-policy" 1325 : "xpinstall-prompt"; 1326 let messageString = await lazy.l10n.formatValue(msgId); 1327 if (Services.policies) { 1328 let extensionSettings = Services.policies.getExtensionSettings("*"); 1329 if ( 1330 extensionSettings && 1331 "blocked_install_message" in extensionSettings 1332 ) { 1333 messageString += " " + extensionSettings.blocked_install_message; 1334 } 1335 } 1336 1337 options.removeOnDismissal = true; 1338 options.persistent = false; 1339 Glean.securityUi.events.accumulateSingleSample( 1340 Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED 1341 ); 1342 let popup = PopupNotifications.show( 1343 browser, 1344 aTopic, 1345 messageString, 1346 gUnifiedExtensions.getPopupAnchorID(browser, window), 1347 null, 1348 null, 1349 options 1350 ); 1351 removeNotificationOnEnd(popup, installInfo.installs); 1352 break; 1353 } 1354 case "addon-install-blocked": { 1355 // Dismiss the progress notification. Note that this is bad if 1356 // there are multiple simultaneous installs happening, see 1357 // bug 1329884 for a longer explanation. 1358 let progressNotification = PopupNotifications.getNotification( 1359 "addon-progress", 1360 browser 1361 ); 1362 if (progressNotification) { 1363 progressNotification.remove(); 1364 } 1365 1366 // The informational content differs somewhat for site permission 1367 // add-ons. AOM no longer supports installing multiple addons, 1368 // so the array handling here is vestigial. 1369 let isSitePermissionAddon = installInfo.installs.every( 1370 ({ addon }) => addon?.type === lazy.SITEPERMS_ADDON_TYPE 1371 ); 1372 let hasHost = false; 1373 let headerId, msgId; 1374 if (isSitePermissionAddon) { 1375 // At present, WebMIDI is the only consumer of the site permission 1376 // add-on infrastructure, and so we can hard-code a midi string here. 1377 // If and when we use it for other things, we'll need to plumb that 1378 // information through. See bug 1826747. 1379 headerId = "site-permission-install-first-prompt-midi-header"; 1380 msgId = "site-permission-install-first-prompt-midi-message"; 1381 } else if (options.displayURI) { 1382 // PopupNotifications.show replaces <> with options.name. 1383 headerId = { id: "xpinstall-prompt-header", args: { host: "<>" } }; 1384 // BrowserUIUtils.getLocalizedFragment replaces %1$S with options.name. 1385 msgId = { id: "xpinstall-prompt-message", args: { host: "%1$S" } }; 1386 options.name = options.displayURI.displayHost; 1387 hasHost = true; 1388 } else { 1389 headerId = "xpinstall-prompt-header-unknown"; 1390 msgId = "xpinstall-prompt-message-unknown"; 1391 } 1392 const [headerString, msgString] = await lazy.l10n.formatValues([ 1393 headerId, 1394 msgId, 1395 ]); 1396 1397 // displayURI becomes it's own label, so we unset it for this panel. It will become part of the 1398 // messageString above. 1399 let displayURI = options.displayURI; 1400 options.displayURI = undefined; 1401 1402 options.eventCallback = topic => { 1403 if (topic !== "showing") { 1404 return; 1405 } 1406 let doc = browser.ownerDocument; 1407 let message = doc.getElementById("addon-install-blocked-message"); 1408 // We must remove any prior use of this panel message in this window. 1409 while (message.firstChild) { 1410 message.firstChild.remove(); 1411 } 1412 1413 if (!hasHost) { 1414 message.textContent = msgString; 1415 } else { 1416 let b = doc.createElementNS("http://www.w3.org/1999/xhtml", "b"); 1417 b.textContent = options.name; 1418 let fragment = BrowserUIUtils.getLocalizedFragment( 1419 doc, 1420 msgString, 1421 b 1422 ); 1423 message.appendChild(fragment); 1424 } 1425 1426 let article = isSitePermissionAddon 1427 ? "site-permission-addons" 1428 : "unlisted-extensions-risks"; 1429 let learnMore = doc.getElementById("addon-install-blocked-info"); 1430 learnMore.setAttribute("support-page", article); 1431 }; 1432 Glean.securityUi.events.accumulateSingleSample( 1433 Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED 1434 ); 1435 1436 const [ 1437 installMsg, 1438 dontAllowMsg, 1439 neverAllowMsg, 1440 neverAllowAndReportMsg, 1441 ] = await lazy.l10n.formatMessages([ 1442 "xpinstall-prompt-install", 1443 "xpinstall-prompt-dont-allow", 1444 "xpinstall-prompt-never-allow", 1445 "xpinstall-prompt-never-allow-and-report", 1446 ]); 1447 1448 const action = buildNotificationAction(installMsg, () => { 1449 Glean.securityUi.events.accumulateSingleSample( 1450 Ci.nsISecurityUITelemetry 1451 .WARNING_ADDON_ASKING_PREVENTED_CLICK_THROUGH 1452 ); 1453 installInfo.install(); 1454 }); 1455 1456 const neverAllowCallback = () => { 1457 SitePermissions.setForPrincipal( 1458 browser.contentPrincipal, 1459 "install", 1460 SitePermissions.BLOCK 1461 ); 1462 for (let install of installInfo.installs) { 1463 if (install.state != AddonManager.STATE_CANCELLED) { 1464 install.cancel(); 1465 } 1466 } 1467 if (installInfo.cancel) { 1468 installInfo.cancel(); 1469 } 1470 }; 1471 1472 const declineActions = [ 1473 buildNotificationAction(dontAllowMsg, () => { 1474 for (let install of installInfo.installs) { 1475 if (install.state != AddonManager.STATE_CANCELLED) { 1476 install.cancel(); 1477 } 1478 } 1479 if (installInfo.cancel) { 1480 installInfo.cancel(); 1481 } 1482 }), 1483 buildNotificationAction(neverAllowMsg, neverAllowCallback), 1484 ]; 1485 1486 if (isSitePermissionAddon) { 1487 // Restrict this to site permission add-ons for now pending a decision 1488 // from product about how to approach this for extensions. 1489 declineActions.push( 1490 buildNotificationAction(neverAllowAndReportMsg, () => { 1491 AMTelemetry.recordSuspiciousSiteEvent({ displayURI }); 1492 neverAllowCallback(); 1493 }) 1494 ); 1495 } 1496 1497 let popup = PopupNotifications.show( 1498 browser, 1499 aTopic, 1500 headerString, 1501 gUnifiedExtensions.getPopupAnchorID(browser, window), 1502 action, 1503 declineActions, 1504 options 1505 ); 1506 removeNotificationOnEnd(popup, installInfo.installs); 1507 break; 1508 } 1509 case "addon-install-started": { 1510 // If all installs have already been downloaded then there is no need to 1511 // show the download progress 1512 if ( 1513 installInfo.installs.every( 1514 aInstall => aInstall.state == AddonManager.STATE_DOWNLOADED 1515 ) 1516 ) { 1517 return; 1518 } 1519 1520 const messageString = lazy.l10n.formatValueSync( 1521 "addon-downloading-and-verifying", 1522 { addonCount: installInfo.installs.length } 1523 ); 1524 options.installs = installInfo.installs; 1525 options.contentWindow = browser.contentWindow; 1526 options.sourceURI = browser.currentURI; 1527 options.eventCallback = function (aEvent) { 1528 switch (aEvent) { 1529 case "removed": 1530 options.contentWindow = null; 1531 options.sourceURI = null; 1532 break; 1533 } 1534 }; 1535 1536 const [acceptMsg, cancelMsg] = lazy.l10n.formatMessagesSync([ 1537 "addon-install-accept-button", 1538 "addon-install-cancel-button", 1539 ]); 1540 1541 const action = buildNotificationAction(acceptMsg, () => {}); 1542 action.disabled = true; 1543 1544 const secondaryAction = buildNotificationAction(cancelMsg, () => { 1545 for (let install of installInfo.installs) { 1546 if (install.state != AddonManager.STATE_CANCELLED) { 1547 install.cancel(); 1548 } 1549 } 1550 }); 1551 1552 let notification = PopupNotifications.show( 1553 browser, 1554 "addon-progress", 1555 messageString, 1556 gUnifiedExtensions.getPopupAnchorID(browser, window), 1557 action, 1558 [secondaryAction], 1559 options 1560 ); 1561 notification._startTime = Date.now(); 1562 1563 break; 1564 } 1565 case "addon-install-failed": { 1566 options.removeOnDismissal = true; 1567 options.persistent = false; 1568 1569 // TODO This isn't terribly ideal for the multiple failure case 1570 for (let install of installInfo.installs) { 1571 let host; 1572 try { 1573 host = options.displayURI.host; 1574 } catch (e) { 1575 // displayURI might be missing or 'host' might throw for non-nsStandardURL nsIURIs. 1576 } 1577 1578 if (!host) { 1579 host = 1580 install.sourceURI instanceof Ci.nsIStandardURL && 1581 install.sourceURI.host; 1582 } 1583 1584 let messageString; 1585 if ( 1586 install.addon && 1587 !Services.policies.mayInstallAddon(install.addon) 1588 ) { 1589 messageString = lazy.l10n.formatValueSync( 1590 "addon-installation-blocked-by-policy", 1591 { addonName: install.name, addonId: install.addon.id } 1592 ); 1593 let extensionSettings = Services.policies.getExtensionSettings( 1594 install.addon.id 1595 ); 1596 if ( 1597 extensionSettings && 1598 "blocked_install_message" in extensionSettings 1599 ) { 1600 messageString += " " + extensionSettings.blocked_install_message; 1601 } 1602 } else { 1603 // TODO bug 1834484: simplify computation of isLocal. 1604 const isLocal = !host; 1605 const fluentIds = ERROR_L10N_IDS.get(install.error); 1606 // We need to find the group of fluent IDs to use (error-id, local-error-id), 1607 // depending on whether we have the add-on name or not. 1608 const offset = fluentIds?.length === 4 && !install.name ? 2 : 0; 1609 let errorId = fluentIds?.[offset + isLocal ? 1 : 0]; 1610 const args = { 1611 addonName: install.name, 1612 appVersion: Services.appinfo.version, 1613 }; 1614 // TODO: Bug 1846725 - when there is no error ID (which shouldn't 1615 // happen but... we never know) we use the "incompatible" error 1616 // message for now but we should have a better error message 1617 // instead. 1618 if (!errorId) { 1619 errorId = "addon-install-error-incompatible"; 1620 } 1621 messageString = lazy.l10n.formatValueSync(errorId, args); 1622 } 1623 1624 // Add Learn More link when refusing to install an unsigned add-on 1625 if (install.error == AddonManager.ERROR_SIGNEDSTATE_REQUIRED) { 1626 options.learnMoreURL = 1627 Services.urlFormatter.formatURLPref("app.support.baseURL") + 1628 "unsigned-addons"; 1629 } 1630 1631 let notificationId = aTopic; 1632 1633 const isBlocklistError = [ 1634 AddonManager.ERROR_BLOCKLISTED, 1635 AddonManager.ERROR_SOFT_BLOCKED, 1636 ].includes(install.error); 1637 1638 // On blocklist-related install failures: 1639 // - use "addon-install-failed-blocklist" as the notificationId 1640 // (which will use the popupnotification with id 1641 // "addon-install-failed-blocklist-notification" defined 1642 // in popup-notification.inc) 1643 // - add an eventCallback that will take care of filling in the 1644 // blocklistURL into the href attribute of the link element 1645 // with id "addon-install-failed-blocklist-info" 1646 if (isBlocklistError) { 1647 const blocklistURL = await install.addon?.getBlocklistURL(); 1648 notificationId = `${aTopic}-blocklist`; 1649 options.eventCallback = topic => { 1650 if (topic !== "showing") { 1651 return; 1652 } 1653 let doc = browser.ownerDocument; 1654 let blocklistURLEl = doc.getElementById( 1655 "addon-install-failed-blocklist-info" 1656 ); 1657 if (blocklistURL) { 1658 blocklistURLEl.setAttribute("href", blocklistURL); 1659 } else { 1660 blocklistURLEl.removeAttribute("href"); 1661 } 1662 }; 1663 } 1664 1665 PopupNotifications.show( 1666 browser, 1667 notificationId, 1668 messageString, 1669 gUnifiedExtensions.getPopupAnchorID(browser, window), 1670 null, 1671 null, 1672 options 1673 ); 1674 1675 // Can't have multiple notifications with the same ID, so stop here. 1676 break; 1677 } 1678 this._removeProgressNotification(browser); 1679 break; 1680 } 1681 case "addon-install-confirmation": { 1682 let showNotification = () => { 1683 let height = undefined; 1684 1685 if (PopupNotifications.isPanelOpen) { 1686 let rect = window.windowUtils.getBoundsWithoutFlushing( 1687 document.getElementById("addon-progress-notification") 1688 ); 1689 height = rect.height; 1690 } 1691 1692 this._removeProgressNotification(browser); 1693 this.showInstallConfirmation(browser, installInfo, height); 1694 }; 1695 1696 let progressNotification = PopupNotifications.getNotification( 1697 "addon-progress", 1698 browser 1699 ); 1700 if (progressNotification) { 1701 let downloadDuration = Date.now() - progressNotification._startTime; 1702 let securityDelay = 1703 Services.prefs.getIntPref("security.dialog_enable_delay") - 1704 downloadDuration; 1705 if (securityDelay > 0) { 1706 setTimeout(() => { 1707 // The download may have been cancelled during the security delay 1708 if ( 1709 PopupNotifications.getNotification("addon-progress", browser) 1710 ) { 1711 showNotification(); 1712 } 1713 }, securityDelay); 1714 break; 1715 } 1716 } 1717 showNotification(); 1718 break; 1719 } 1720 } 1721 }, 1722 _removeProgressNotification(aBrowser) { 1723 let notification = PopupNotifications.getNotification( 1724 "addon-progress", 1725 aBrowser 1726 ); 1727 if (notification) { 1728 notification.remove(); 1729 } 1730 }, 1731 }; 1732 1733 var gExtensionsNotifications = { 1734 initialized: false, 1735 init() { 1736 this.updateAlerts(); 1737 this.boundUpdate = this.updateAlerts.bind(this); 1738 ExtensionsUI.on("change", this.boundUpdate); 1739 this.initialized = true; 1740 }, 1741 1742 uninit() { 1743 // uninit() can race ahead of init() in some cases, if that happens, 1744 // we have no handler to remove. 1745 if (!this.initialized) { 1746 return; 1747 } 1748 ExtensionsUI.off("change", this.boundUpdate); 1749 }, 1750 1751 _createAddonButton(l10nId, addon, callback) { 1752 let text = addon 1753 ? lazy.l10n.formatValueSync(l10nId, { addonName: addon.name }) 1754 : lazy.l10n.formatValueSync(l10nId); 1755 let button = document.createXULElement("toolbarbutton"); 1756 button.setAttribute("id", l10nId); 1757 button.setAttribute("wrap", "true"); 1758 button.setAttribute("label", text); 1759 button.setAttribute("tooltiptext", text); 1760 const DEFAULT_EXTENSION_ICON = 1761 "chrome://mozapps/skin/extensions/extensionGeneric.svg"; 1762 button.setAttribute("image", addon?.iconURL || DEFAULT_EXTENSION_ICON); 1763 button.className = "addon-banner-item subviewbutton"; 1764 1765 button.addEventListener("command", callback); 1766 PanelUI.addonNotificationContainer.appendChild(button); 1767 }, 1768 1769 updateAlerts() { 1770 let sideloaded = ExtensionsUI.sideloaded; 1771 let updates = ExtensionsUI.updates; 1772 1773 let container = PanelUI.addonNotificationContainer; 1774 1775 while (container.firstChild) { 1776 container.firstChild.remove(); 1777 } 1778 1779 let items = 0; 1780 if (lazy.AMBrowserExtensionsImport.canCompleteOrCancelInstalls) { 1781 this._createAddonButton("webext-imported-addons", null, () => { 1782 lazy.AMBrowserExtensionsImport.completeInstalls(); 1783 }); 1784 items++; 1785 } 1786 1787 for (let update of updates) { 1788 if (++items > 4) { 1789 break; 1790 } 1791 this._createAddonButton( 1792 "webext-perms-update-menu-item", 1793 update.addon, 1794 () => { 1795 ExtensionsUI.showUpdate(gBrowser, update); 1796 } 1797 ); 1798 } 1799 1800 for (let addon of sideloaded) { 1801 if (++items > 4) { 1802 break; 1803 } 1804 this._createAddonButton("webext-perms-sideload-menu-item", addon, () => { 1805 // We need to hide the main menu manually because the toolbarbutton is 1806 // removed immediately while processing this event, and PanelUI is 1807 // unable to identify which panel should be closed automatically. 1808 PanelUI.hide(); 1809 ExtensionsUI.showSideloaded(gBrowser, addon); 1810 }); 1811 } 1812 }, 1813 }; 1814 1815 var BrowserAddonUI = { 1816 async promptRemoveExtension(addon) { 1817 let { name } = addon; 1818 let [title, btnTitle] = await lazy.l10n.formatValues([ 1819 { id: "addon-removal-title", args: { name } }, 1820 { id: "addon-removal-button" }, 1821 ]); 1822 1823 let { 1824 BUTTON_TITLE_IS_STRING: titleString, 1825 BUTTON_TITLE_CANCEL: titleCancel, 1826 BUTTON_POS_0, 1827 BUTTON_POS_1, 1828 confirmEx, 1829 } = Services.prompt; 1830 let btnFlags = BUTTON_POS_0 * titleString + BUTTON_POS_1 * titleCancel; 1831 1832 // Enable abuse report checkbox in the remove extension dialog, 1833 // if enabled by the about:config prefs and the addon type 1834 // is currently supported. 1835 let checkboxMessage = null; 1836 if ( 1837 gAddonAbuseReportEnabled && 1838 ["extension", "theme"].includes(addon.type) 1839 ) { 1840 checkboxMessage = await lazy.l10n.formatValue( 1841 "addon-removal-abuse-report-checkbox" 1842 ); 1843 } 1844 1845 // If the prompt is being used for ML model removal, use a body message 1846 let body = null; 1847 if (addon.type === "mlmodel") { 1848 body = await lazy.l10n.formatValue("addon-mlmodel-removal-body"); 1849 } 1850 1851 let checkboxState = { value: false }; 1852 let result = confirmEx( 1853 window, 1854 title, 1855 body, 1856 btnFlags, 1857 btnTitle, 1858 /* button1 */ null, 1859 /* button2 */ null, 1860 checkboxMessage, 1861 checkboxState 1862 ); 1863 1864 return { remove: result === 0, report: checkboxState.value }; 1865 }, 1866 1867 async reportAddon(addonId, _reportEntryPoint) { 1868 let addon = addonId && (await AddonManager.getAddonByID(addonId)); 1869 if (!addon) { 1870 return; 1871 } 1872 1873 const amoUrl = lazy.AbuseReporter.getAMOFormURL({ addonId }); 1874 window.openTrustedLinkIn(amoUrl, "tab", { 1875 // Make sure the newly open tab is going to be focused, independently 1876 // from general user prefs. 1877 forceForeground: true, 1878 }); 1879 }, 1880 1881 async removeAddon(addonId) { 1882 let addon = addonId && (await AddonManager.getAddonByID(addonId)); 1883 if (!addon || !(addon.permissions & AddonManager.PERM_CAN_UNINSTALL)) { 1884 return; 1885 } 1886 1887 let { remove, report } = await this.promptRemoveExtension(addon); 1888 1889 if (remove) { 1890 // Leave the extension in pending uninstall if we are also reporting the 1891 // add-on. 1892 await addon.uninstall(report); 1893 1894 if (report) { 1895 await this.reportAddon(addon.id, "uninstall"); 1896 } 1897 } 1898 }, 1899 1900 async manageAddon(addonId) { 1901 let addon = addonId && (await AddonManager.getAddonByID(addonId)); 1902 if (!addon) { 1903 return; 1904 } 1905 1906 this.openAddonsMgr("addons://detail/" + encodeURIComponent(addon.id)); 1907 }, 1908 1909 /** 1910 * Open about:addons page by given view id. 1911 * 1912 * @param {string} aView 1913 * View id of page that will open. 1914 * e.g. "addons://discover/" 1915 * @param {object} options 1916 * { 1917 * selectTabByViewId: If true, if there is the tab opening page having 1918 * same view id, select the tab. Else if the current 1919 * page is blank, load on it. Otherwise, open a new 1920 * tab, then load on it. 1921 * If false, if there is the tab opening 1922 * about:addoons page, select the tab and load page 1923 * for view id on it. Otherwise, leave the loading 1924 * behavior to switchToTabHavingURI(). 1925 * If no options, handles as false. 1926 * } 1927 * @returns {Promise} When the Promise resolves, returns window object loaded the 1928 * view id. 1929 */ 1930 openAddonsMgr(aView, { selectTabByViewId = false } = {}) { 1931 return new Promise(resolve => { 1932 let emWindow; 1933 let browserWindow; 1934 1935 const receivePong = function (aSubject) { 1936 const browserWin = aSubject.browsingContext.topChromeWindow; 1937 if (!emWindow || browserWin == window /* favor the current window */) { 1938 if ( 1939 selectTabByViewId && 1940 aSubject.gViewController.currentViewId !== aView 1941 ) { 1942 return; 1943 } 1944 1945 emWindow = aSubject; 1946 browserWindow = browserWin; 1947 } 1948 }; 1949 Services.obs.addObserver(receivePong, "EM-pong"); 1950 Services.obs.notifyObservers(null, "EM-ping"); 1951 Services.obs.removeObserver(receivePong, "EM-pong"); 1952 1953 if (emWindow) { 1954 if (aView && !selectTabByViewId) { 1955 emWindow.loadView(aView); 1956 } 1957 let tab = browserWindow.gBrowser.getTabForBrowser( 1958 emWindow.docShell.chromeEventHandler 1959 ); 1960 browserWindow.gBrowser.selectedTab = tab; 1961 emWindow.focus(); 1962 resolve(emWindow); 1963 return; 1964 } 1965 1966 if (selectTabByViewId) { 1967 const target = isBlankPageURL(gBrowser.currentURI.spec) 1968 ? "current" 1969 : "tab"; 1970 openTrustedLinkIn("about:addons", target); 1971 } else { 1972 // This must be a new load, else the ping/pong would have 1973 // found the window above. 1974 switchToTabHavingURI("about:addons", true); 1975 } 1976 1977 Services.obs.addObserver(function observer(aSubject, aTopic) { 1978 Services.obs.removeObserver(observer, aTopic); 1979 if (aView) { 1980 aSubject.loadView(aView); 1981 } 1982 aSubject.focus(); 1983 resolve(aSubject); 1984 }, "EM-loaded"); 1985 }); 1986 }, 1987 }; 1988 1989 // We must declare `gUnifiedExtensions` using `var` below to avoid a 1990 // "redeclaration" syntax error. 1991 var gUnifiedExtensions = { 1992 _initialized: false, 1993 // buttonAlwaysVisible: true, -- based on pref, declared later. 1994 _buttonShownBeforeButtonOpen: null, 1995 _buttonBarHasMouse: false, 1996 1997 // We use a `<deck>` in the extension items to show/hide messages below each 1998 // extension name. We have a default message for origin controls, and 1999 // optionally a second message shown on hover, which describes the action 2000 // (when clicking on the action button). We have another message shown when 2001 // the menu button is hovered/focused. The constants below define the indexes 2002 // of each message in the `<deck>`. 2003 MESSAGE_DECK_INDEX_DEFAULT: 0, 2004 MESSAGE_DECK_INDEX_HOVER: 1, 2005 MESSAGE_DECK_INDEX_MENU_HOVER: 2, 2006 2007 init() { 2008 if (this._initialized) { 2009 return; 2010 } 2011 2012 // Button is hidden by default, declared in navigator-toolbox.inc.xhtml. 2013 this._button = document.getElementById("unified-extensions-button"); 2014 this._navbar = document.getElementById("nav-bar"); 2015 this.updateButtonVisibility(); 2016 this._buttonAttrObs = new MutationObserver(() => this.onButtonOpenChange()); 2017 this._buttonAttrObs.observe(this._button, { attributeFilter: ["open"] }); 2018 this._button.addEventListener("PopupNotificationsBeforeAnchor", this); 2019 this._updateButtonBarListeners(); 2020 2021 gBrowser.addTabsProgressListener(this); 2022 window.addEventListener("TabSelect", () => this.updateAttention()); 2023 window.addEventListener("toolbarvisibilitychange", this); 2024 2025 this.permListener = () => this.updateAttention(); 2026 lazy.ExtensionPermissions.addListener(this.permListener); 2027 2028 this.onAppMenuShowing = this.onAppMenuShowing.bind(this); 2029 PanelUI.mainView.addEventListener("ViewShowing", this.onAppMenuShowing); 2030 gNavToolbox.addEventListener("customizationstarting", this); 2031 gNavToolbox.addEventListener("aftercustomization", this); 2032 CustomizableUI.addListener(this); 2033 AddonManager.addManagerListener(this); 2034 2035 Glean.extensionsButton.prefersHiddenButton.set(!this.buttonAlwaysVisible); 2036 2037 // Listen out for changes in extensions.hideNoScript and 2038 // extension.hideUnifiedWhenEmpty, which can effect the visibility of the 2039 // unified-extensions-button. 2040 // See tor-browser#41581. 2041 this._hideNoScriptObserver = () => this._updateHideEmpty(); 2042 Services.prefs.addObserver(HIDE_NO_SCRIPT_PREF, this._hideNoScriptObserver); 2043 Services.prefs.addObserver( 2044 HIDE_UNIFIED_WHEN_EMPTY_PREF, 2045 this._hideNoScriptObserver 2046 ); 2047 this._updateHideEmpty(); // Will trigger updateButtonVisibility; 2048 2049 this._initialized = true; 2050 }, 2051 2052 uninit() { 2053 if (!this._initialized) { 2054 return; 2055 } 2056 2057 this._buttonAttrObs.disconnect(); 2058 this._button.removeEventListener("PopupNotificationsBeforeAnchor", this); 2059 2060 window.removeEventListener("toolbarvisibilitychange", this); 2061 2062 lazy.ExtensionPermissions.removeListener(this.permListener); 2063 this.permListener = null; 2064 2065 PanelUI.mainView.removeEventListener("ViewShowing", this.onAppMenuShowing); 2066 gNavToolbox.removeEventListener("customizationstarting", this); 2067 gNavToolbox.removeEventListener("aftercustomization", this); 2068 CustomizableUI.removeListener(this); 2069 AddonManager.removeManagerListener(this); 2070 2071 Services.prefs.removeObserver( 2072 HIDE_NO_SCRIPT_PREF, 2073 this._hideNoScriptObserver 2074 ); 2075 Services.prefs.removeObserver( 2076 HIDE_UNIFIED_WHEN_EMPTY_PREF, 2077 this._hideNoScriptObserver 2078 ); 2079 }, 2080 2081 _updateButtonBarListeners() { 2082 // Called from init() and when the buttonAlwaysVisible flag changes. 2083 // 2084 // We don't expect the user to be interacting with the Extensions Button or 2085 // the navbar when the buttonAlwaysVisible flag changes. Still, we reset 2086 // the _buttonBarHasMouse flag to false to make sure that the button can be 2087 // hidden eventually if there are no other triggers: 2088 // - on registration, we don't know whether the mouse is on the navbar. 2089 // - after unregistration, the flag is no longer maintained, and false is a 2090 // safe default value. 2091 this._buttonBarHasMouse = false; 2092 // We need mouse listeners on _navbar to maintain _buttonBarHasMouse, 2093 // but only if the button is conditionally visible/hidden. 2094 if (this.buttonAlwaysVisible) { 2095 this._navbar.removeEventListener("mouseover", this); 2096 this._navbar.removeEventListener("mouseout", this); 2097 } else { 2098 this._navbar.addEventListener("mouseover", this); 2099 this._navbar.addEventListener("mouseout", this); 2100 } 2101 }, 2102 2103 onBlocklistAttentionUpdated() { 2104 this.updateAttention(); 2105 }, 2106 2107 onAppMenuShowing() { 2108 // Only show the extension menu item if the extension button is not pinned 2109 // and the extension popup is not empty. 2110 // NOTE: This condition is different than _shouldShowButton. 2111 const hideExtensionItem = this.buttonAlwaysVisible || this._hideEmpty; 2112 document.getElementById("appMenu-extensions-themes-button").hidden = 2113 !hideExtensionItem; 2114 document.getElementById("appMenu-unified-extensions-button").hidden = 2115 hideExtensionItem; 2116 }, 2117 2118 onLocationChange(browser, webProgress, _request, _uri, flags) { 2119 // Only update on top-level cross-document navigations in the selected tab. 2120 if ( 2121 webProgress.isTopLevel && 2122 browser === gBrowser.selectedBrowser && 2123 !(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) 2124 ) { 2125 this.updateAttention(); 2126 } 2127 }, 2128 2129 updateButtonVisibility() { 2130 if (this._hideEmpty === null) { 2131 return; 2132 } 2133 // TODO: Bug 1778684 - Auto-hide button when there is no active extension. 2134 // Hide the extension button when it is empty. See tor-browser#41581. 2135 // Likely will conflict with mozilla's Bug 1778684. See tor-browser#42635. 2136 let shouldShowButton = 2137 this._shouldShowButton || 2138 // If anything is anchored to the button, keep it visible. 2139 this._button.open || 2140 // Button will be open soon - see ensureButtonShownBeforeAttachingPanel. 2141 this._buttonShownBeforeButtonOpen || 2142 // Items in the toolbar shift when the button hides. To prevent the user 2143 // from clicking on something different than they intended, never hide an 2144 // already-visible button while the mouse is still in the toolbar. 2145 (!this.button.hidden && this._buttonBarHasMouse) || 2146 // Attention dot - see comment at buttonIgnoresAttention. 2147 (!this.buttonIgnoresAttention && this.button.hasAttribute("attention")) || 2148 // Always show when customizing, because even if the button should mostly 2149 // be hidden, the user should be able to specify the desired location for 2150 // cases where the button is forcibly shown. 2151 CustomizationHandler.isCustomizing(); 2152 2153 if (shouldShowButton) { 2154 this._button.hidden = false; 2155 this._navbar.setAttribute("unifiedextensionsbuttonshown", true); 2156 } else { 2157 this._button.hidden = true; 2158 this._navbar.removeAttribute("unifiedextensionsbuttonshown"); 2159 } 2160 }, 2161 2162 ensureButtonShownBeforeAttachingPanel(panel) { 2163 if (!this._shouldShowButton && !this._button.open) { 2164 // When the panel is anchored to the button, its "open" attribute will be 2165 // set, which visually renders as a "button pressed". Until we get there, 2166 // we need to make sure that the button is visible so that it can serve 2167 // as anchor. 2168 this._buttonShownBeforeButtonOpen = panel; 2169 this.updateButtonVisibility(); 2170 } 2171 }, 2172 2173 onButtonOpenChange() { 2174 if (this._button.open) { 2175 this._buttonShownBeforeButtonOpen = false; 2176 } 2177 if (!this._shouldShowButton && !this._button.open) { 2178 this.updateButtonVisibility(); 2179 } 2180 }, 2181 2182 // Update the attention indicator for the whole unified extensions button. 2183 updateAttention() { 2184 let permissionsAttention = false; 2185 let quarantinedAttention = false; 2186 let blocklistAttention = AddonManager.shouldShowBlocklistAttention(); 2187 2188 // Computing the OriginControls state for all active extensions is potentially 2189 // more expensive, and so we don't compute it if we have already determined that 2190 // there is a blocklist attention to be shown. 2191 if (!blocklistAttention) { 2192 for (let policy of this.getActivePolicies()) { 2193 let widget = this.browserActionFor(policy)?.widget; 2194 2195 // Only show for extensions which are not already visible in the toolbar. 2196 if (!widget || widget.areaType !== CustomizableUI.TYPE_TOOLBAR) { 2197 if (lazy.OriginControls.getAttentionState(policy, window).attention) { 2198 permissionsAttention = true; 2199 break; 2200 } 2201 } 2202 } 2203 2204 // If the domain is quarantined and we have extensions not allowed, we'll 2205 // show a notification in the panel so we want to let the user know about 2206 // it. 2207 quarantinedAttention = this._shouldShowQuarantinedNotification(); 2208 } 2209 2210 this.button.toggleAttribute( 2211 "attention", 2212 quarantinedAttention || permissionsAttention || blocklistAttention 2213 ); 2214 let msgId = permissionsAttention 2215 ? "unified-extensions-button-permissions-needed" 2216 : "unified-extensions-button"; 2217 // Quarantined state takes precedence over anything else. 2218 if (quarantinedAttention) { 2219 msgId = "unified-extensions-button-quarantined"; 2220 } 2221 // blocklistAttention state takes precedence over the other ones 2222 // because it is dismissible and, once dismissed, the tooltip will 2223 // show one of the other messages if appropriate. 2224 if (blocklistAttention) { 2225 msgId = "unified-extensions-button-blocklisted"; 2226 } 2227 this.button.ownerDocument.l10n.setAttributes(this.button, msgId); 2228 if (!this.buttonAlwaysVisible && !this.buttonIgnoresAttention) { 2229 if (blocklistAttention) { 2230 this.recordButtonTelemetry("attention_blocklist"); 2231 } else if (permissionsAttention || quarantinedAttention) { 2232 this.recordButtonTelemetry("attention_permission_denied"); 2233 } 2234 this.updateButtonVisibility(); 2235 } 2236 }, 2237 2238 // Get the anchor to use with PopupNotifications.show(). If you add a new use 2239 // of this method, make sure to update gXPInstallObserver.NOTIFICATION_IDS! 2240 // If the new ID is not added in NOTIFICATION_IDS, consider handling the case 2241 // in the "PopupNotificationsBeforeAnchor" handler elsewhere in this file. 2242 getPopupAnchorID(aBrowser, aWindow) { 2243 const anchorID = "unified-extensions-button"; 2244 const attr = anchorID + "popupnotificationanchor"; 2245 2246 if (!aBrowser[attr]) { 2247 // A hacky way of setting the popup anchor outside the usual url bar 2248 // icon box, similar to how it was done for CFR. 2249 // See: https://searchfox.org/mozilla-central/rev/c5c002f81f08a73e04868e0c2bf0eb113f200b03/toolkit/modules/PopupNotifications.sys.mjs#40 2250 aBrowser[attr] = aWindow.document.getElementById( 2251 anchorID 2252 // Anchor on the toolbar icon to position the popup right below the 2253 // button. 2254 ).firstElementChild; 2255 } 2256 2257 return anchorID; 2258 }, 2259 2260 get button() { 2261 return this._button; 2262 }, 2263 2264 /** 2265 * Gets a list of active WebExtensionPolicy instances of type "extension", 2266 * excluding hidden extensions, available to this window. 2267 * 2268 * @param {boolean} skipPBMCheck When false (the default), the result 2269 * excludes extensions that cannot access the current window 2270 * due to the window being a private browsing window that 2271 * the extension is not allowed to access. 2272 * @returns {Array<WebExtensionPolicy>} An array of active policies. 2273 */ 2274 getActivePolicies(skipPBMCheck = false) { 2275 let policies = WebExtensionPolicy.getActiveExtensions(); 2276 policies = policies.filter(policy => { 2277 let { extension } = policy; 2278 if (extension?.type !== "extension") { 2279 // extension can only be null due to bugs (bug 1642012). 2280 // Exclude non-extension types such as themes, dictionaries, etc. 2281 return false; 2282 } 2283 2284 // When an extensions is about to be removed, it may still appear in 2285 // getActiveExtensions. 2286 // This is needed for hasExtensionsInPanel, when called through 2287 // onWidgetDestroy when an extension is being removed. 2288 // See tor-browser#41581. 2289 if (extension.hasShutdown) { 2290 return false; 2291 } 2292 2293 // Ignore hidden and extensions that cannot access the current window 2294 // (because of PB mode when we are in a private window), since users 2295 // cannot do anything with those extensions anyway. 2296 if ( 2297 extension.isHidden || 2298 // NOTE: policy.canAccessWindow() sounds generic, but it really only 2299 // enforces private browsing access. 2300 (!skipPBMCheck && !policy.canAccessWindow(window)) 2301 ) { 2302 return false; 2303 } 2304 2305 return true; 2306 }); 2307 2308 return policies; 2309 }, 2310 2311 /** 2312 * Whether the extension button should be hidden because it is empty. Or 2313 * `null` when uninitialised. 2314 * 2315 * @type {?boolean} 2316 */ 2317 _hideEmpty: null, 2318 2319 /** 2320 * Update the _hideEmpty attribute when the preference or hasExtensionsInPanel 2321 * value may have changed. 2322 */ 2323 _updateHideEmpty() { 2324 const prevHideEmpty = this._hideEmpty; 2325 this._hideEmpty = 2326 Services.prefs.getBoolPref(HIDE_UNIFIED_WHEN_EMPTY_PREF, true) && 2327 !this.hasExtensionsInPanel(); 2328 if (this._hideEmpty !== prevHideEmpty) { 2329 this.updateButtonVisibility(); 2330 } 2331 }, 2332 2333 /** 2334 * Whether we should show the extension button, regardless of whether it is 2335 * needed as a popup anchor, etc. 2336 * 2337 * @type {boolean} 2338 */ 2339 get _shouldShowButton() { 2340 return this.buttonAlwaysVisible && !this._hideEmpty; 2341 }, 2342 2343 /** 2344 * Returns true when there are active extensions listed/shown in the unified 2345 * extensions panel, and false otherwise (e.g. when extensions are pinned in 2346 * the toolbar OR there are 0 active extensions). 2347 * 2348 * @param {Array<WebExtensionPolicy> [policies] The list of extensions to 2349 * evaluate. Defaults to the active extensions with access to this window 2350 * (see getActivePolicies). 2351 * @returns {boolean} Whether there are extensions listed in the panel. 2352 */ 2353 hasExtensionsInPanel(policies = this.getActivePolicies()) { 2354 const hideNoScript = Services.prefs.getBoolPref(HIDE_NO_SCRIPT_PREF, true); 2355 return policies.some(policy => { 2356 if (hideNoScript && policy.extension?.isNoScript) { 2357 return false; 2358 } 2359 let widget = this.browserActionFor(policy)?.widget; 2360 return ( 2361 !widget || 2362 widget.areaType !== CustomizableUI.TYPE_TOOLBAR || 2363 widget.forWindow(window).overflowed 2364 ); 2365 }); 2366 }, 2367 2368 isPrivateWindowMissingExtensionsWithoutPBMAccess() { 2369 if (!PrivateBrowsingUtils.isWindowPrivate(window)) { 2370 return false; 2371 } 2372 const policies = this.getActivePolicies(/* skipPBMCheck */ true); 2373 return policies.some(p => !p.privateBrowsingAllowed); 2374 }, 2375 2376 /** 2377 * Returns whether there is any active extension without private browsing 2378 * access, for which the user can toggle the "Run in Private Windows" option. 2379 * This complements the isPrivateWindowMissingExtensionsWithoutPBMAccess() 2380 * method, by distinguishing cases where the user can enable any extension 2381 * in the private window, vs cases where the user cannot. 2382 * 2383 * @returns {Promise<boolean>} Whether there is any "Run in Private Windows" 2384 * option that is Off and can be set to On. 2385 */ 2386 async isAtLeastOneExtensionWithPBMOptIn() { 2387 const addons = await AddonManager.getAddonsByTypes(["extension"]); 2388 return addons.some(addon => { 2389 if ( 2390 // We only care about extensions shown in the panel and about:addons. 2391 addon.hidden || 2392 // We only care about extensions whose PBM access can be toggled. 2393 !( 2394 addon.permissions & 2395 lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS 2396 ) 2397 ) { 2398 return false; 2399 } 2400 const policy = WebExtensionPolicy.getByID(addon.id); 2401 // policy can be null if the extension is not active. 2402 return policy && !policy.privateBrowsingAllowed; 2403 }); 2404 }, 2405 2406 async getDisabledExtensionsInfo() { 2407 let addons = await AddonManager.getAddonsByTypes(["extension"]); 2408 addons = addons.filter(a => !a.hidden && !a.isActive); 2409 const isAnyDisabled = !!addons.length; 2410 const isAnyEnableable = addons.some( 2411 a => a.permissions & lazy.AddonManager.PERM_CAN_ENABLE 2412 ); 2413 return { isAnyDisabled, isAnyEnableable }; 2414 }, 2415 2416 handleEvent(event) { 2417 switch (event.type) { 2418 case "ViewShowing": 2419 this.onPanelViewShowing(event.target); 2420 break; 2421 2422 case "ViewHiding": 2423 this.onPanelViewHiding(event.target); 2424 break; 2425 2426 case "PopupNotificationsBeforeAnchor": 2427 { 2428 const popupnotification = PopupNotifications.panel.firstElementChild; 2429 const popupid = popupnotification?.getAttribute("popupid"); 2430 if (popupid === "addon-webext-permissions") { 2431 // "addon-webext-permissions" is also in NOTIFICATION_IDS, but to 2432 // distinguish it from other cases, give it a separate reason. 2433 this.recordButtonTelemetry("extension_permission_prompt"); 2434 } else if (gXPInstallObserver.NOTIFICATION_IDS.includes(popupid)) { 2435 this.recordButtonTelemetry("addon_install_doorhanger"); 2436 } else { 2437 console.error(`Unrecognized notification ID: ${popupid}`); 2438 } 2439 this.ensureButtonShownBeforeAttachingPanel(PopupNotifications.panel); 2440 } 2441 break; 2442 2443 case "mouseover": 2444 this._buttonBarHasMouse = true; 2445 break; 2446 2447 case "mouseout": 2448 if ( 2449 this._buttonBarHasMouse && 2450 !this._navbar.contains(event.relatedTarget) 2451 ) { 2452 this._buttonBarHasMouse = false; 2453 this.updateButtonVisibility(); 2454 } 2455 break; 2456 2457 case "customizationstarting": 2458 this.panel.hidePopup(); 2459 this.recordButtonTelemetry("customize"); 2460 this.updateButtonVisibility(); 2461 break; 2462 2463 case "aftercustomization": 2464 this.updateButtonVisibility(); 2465 break; 2466 2467 case "toolbarvisibilitychange": 2468 this.onToolbarVisibilityChange(event.target.id, event.detail.visible); 2469 break; 2470 } 2471 }, 2472 2473 onPanelViewShowing(panelview) { 2474 const policies = this.getActivePolicies(); 2475 2476 // Only add extensions that do not have a browser action in this list since 2477 // the extensions with browser action have CUI widgets and will appear in 2478 // the panel (or toolbar) via the CUI mechanism. 2479 const policiesForList = policies.filter( 2480 p => !p.extension.hasBrowserActionUI 2481 ); 2482 policiesForList.sort((a, b) => a.name.localeCompare(b.name)); 2483 2484 const list = panelview.querySelector(".unified-extensions-list"); 2485 for (const policy of policiesForList) { 2486 const item = document.createElement("unified-extensions-item"); 2487 item.setExtension(policy.extension); 2488 list.appendChild(item); 2489 } 2490 2491 const emptyStateBox = panelview.querySelector( 2492 "#unified-extensions-empty-state" 2493 ); 2494 if (this.hasExtensionsInPanel(policies)) { 2495 // Any of the extension lists are non-empty. 2496 emptyStateBox.hidden = true; 2497 } else if (this.isPrivateWindowMissingExtensionsWithoutPBMAccess()) { 2498 document.l10n.setAttributes( 2499 emptyStateBox.querySelector("h2"), 2500 "unified-extensions-empty-reason-private-browsing-not-allowed" 2501 ); 2502 document.l10n.setAttributes( 2503 emptyStateBox.querySelector("description"), 2504 "unified-extensions-empty-content-explain-enable2" 2505 ); 2506 emptyStateBox.hidden = false; 2507 this.isAtLeastOneExtensionWithPBMOptIn().then(result => { 2508 // The "enable" message is somewhat misleading when the user cannot 2509 // enable the extension, show a generic message instead (bug 1992179). 2510 if (!result) { 2511 document.l10n.setAttributes( 2512 emptyStateBox.querySelector("description"), 2513 "unified-extensions-empty-content-explain-manage2" 2514 ); 2515 } 2516 }); 2517 } else { 2518 emptyStateBox.hidden = true; 2519 this.getDisabledExtensionsInfo().then(disabledExtensionsInfo => { 2520 if (disabledExtensionsInfo.isAnyDisabled) { 2521 document.l10n.setAttributes( 2522 emptyStateBox.querySelector("h2"), 2523 "unified-extensions-empty-reason-extension-not-enabled" 2524 ); 2525 document.l10n.setAttributes( 2526 emptyStateBox.querySelector("description"), 2527 disabledExtensionsInfo.isAnyEnableable 2528 ? "unified-extensions-empty-content-explain-enable2" 2529 : "unified-extensions-empty-content-explain-manage2" 2530 ); 2531 emptyStateBox.hidden = false; 2532 } else if (!policies.length) { 2533 document.l10n.setAttributes( 2534 emptyStateBox.querySelector("h2"), 2535 "unified-extensions-empty-reason-zero-extensions-onboarding" 2536 ); 2537 document.l10n.setAttributes( 2538 emptyStateBox.querySelector("description"), 2539 "unified-extensions-empty-content-explain-extensions-onboarding" 2540 ); 2541 emptyStateBox.hidden = false; 2542 2543 // Replace the "Manage Extensions" button with "Discover Extensions". 2544 // We add the "Discover Extensions" button, and "Manage Extensions" 2545 // button (#unified-extensions-manage-extensions) is hidden by CSS. 2546 const discoverButton = this._createDiscoverButton(panelview); 2547 2548 const manageExtensionsButton = panelview.querySelector( 2549 "#unified-extensions-manage-extensions" 2550 ); 2551 // Insert before toolbarseparator, to make it easier to hide the 2552 // toolbarseparator and manageExtensionsButton with CSS. 2553 manageExtensionsButton.previousElementSibling.before(discoverButton); 2554 } 2555 }); 2556 } 2557 2558 const container = panelview.querySelector( 2559 "#unified-extensions-messages-container" 2560 ); 2561 2562 if (Services.appinfo.inSafeMode) { 2563 this._messageBarSafemode ??= this._makeMessageBar({ 2564 messageBarFluentId: "unified-extensions-notice-safe-mode", 2565 supportPage: "diagnose-firefox-issues-using-troubleshoot-mode", 2566 type: "info", 2567 }); 2568 container.prepend(this._messageBarSafemode); 2569 } // No "else" case; inSafeMode flag is fixed at browser startup. 2570 2571 if (this.blocklistAttentionInfo?.shouldShow) { 2572 this._messageBarBlocklist = this._createBlocklistMessageBar(container); 2573 } else { 2574 this._messageBarBlocklist?.remove(); 2575 this._messageBarBlocklist = null; 2576 } 2577 2578 const shouldShowQuarantinedNotification = 2579 this._shouldShowQuarantinedNotification(); 2580 if (shouldShowQuarantinedNotification) { 2581 if (!this._messageBarQuarantinedDomain) { 2582 this._messageBarQuarantinedDomain = this._makeMessageBar({ 2583 messageBarFluentId: 2584 "unified-extensions-mb-quarantined-domain-message-3", 2585 supportPage: "quarantined-domains", 2586 supportPageFluentId: 2587 "unified-extensions-mb-quarantined-domain-learn-more", 2588 dismissible: false, 2589 }); 2590 this._messageBarQuarantinedDomain 2591 .querySelector("a") 2592 .addEventListener("click", () => { 2593 this.togglePanel(); 2594 }); 2595 } 2596 2597 container.appendChild(this._messageBarQuarantinedDomain); 2598 } else if ( 2599 !shouldShowQuarantinedNotification && 2600 this._messageBarQuarantinedDomain && 2601 container.contains(this._messageBarQuarantinedDomain) 2602 ) { 2603 container.removeChild(this._messageBarQuarantinedDomain); 2604 this._messageBarQuarantinedDomain = null; 2605 } 2606 }, 2607 2608 onPanelViewHiding(panelview) { 2609 if (window.closed) { 2610 return; 2611 } 2612 const list = panelview.querySelector(".unified-extensions-list"); 2613 while (list.lastChild) { 2614 list.lastChild.remove(); 2615 } 2616 panelview 2617 .querySelector("#unified-extensions-discover-extensions") 2618 ?.remove(); 2619 2620 // If temporary access was granted, (maybe) clear attention indicator. 2621 requestAnimationFrame(() => this.updateAttention()); 2622 }, 2623 2624 onToolbarVisibilityChange(toolbarId, isVisible) { 2625 // A list of extension widget IDs (possibly empty). 2626 let widgetIDs; 2627 2628 try { 2629 widgetIDs = CustomizableUI.getWidgetIdsInArea(toolbarId).filter( 2630 CustomizableUI.isWebExtensionWidget 2631 ); 2632 } catch { 2633 // Do nothing if the area does not exist for some reason. 2634 return; 2635 } 2636 2637 // The list of overflowed extensions in the extensions panel. 2638 const overflowedExtensionsList = this.panel.querySelector( 2639 "#overflowed-extensions-list" 2640 ); 2641 2642 // We are going to move all the extension widgets via DOM manipulation 2643 // *only* so that it looks like these widgets have moved (and users will 2644 // see that) but CUI still thinks the widgets haven't been moved. 2645 // 2646 // We can move the extension widgets either from the toolbar to the 2647 // extensions panel OR the other way around (when the toolbar becomes 2648 // visible again). 2649 for (const widgetID of widgetIDs) { 2650 const widget = CustomizableUI.getWidget(widgetID); 2651 if (!widget) { 2652 continue; 2653 } 2654 2655 if (isVisible) { 2656 this._maybeMoveWidgetNodeBack(widget.id); 2657 } else { 2658 const { node } = widget.forWindow(window); 2659 // Artificially overflow the extension widget in the extensions panel 2660 // when the toolbar is hidden. 2661 node.setAttribute("overflowedItem", true); 2662 node.setAttribute("artificallyOverflowed", true); 2663 // This attribute forces browser action popups to be anchored to the 2664 // extensions button. 2665 node.setAttribute("cui-anchorid", "unified-extensions-button"); 2666 overflowedExtensionsList.appendChild(node); 2667 2668 this._updateWidgetClassName(widgetID, /* inPanel */ true); 2669 } 2670 } 2671 }, 2672 2673 _maybeMoveWidgetNodeBack(widgetID) { 2674 const widget = CustomizableUI.getWidget(widgetID); 2675 if (!widget) { 2676 return; 2677 } 2678 2679 // We only want to move back widget nodes that have been manually moved 2680 // previously via `onToolbarVisibilityChange()`. 2681 const { node } = widget.forWindow(window); 2682 if (!node.hasAttribute("artificallyOverflowed")) { 2683 return; 2684 } 2685 2686 const { area, position } = CustomizableUI.getPlacementOfWidget(widgetID); 2687 2688 // This is where we are going to re-insert the extension widgets (DOM 2689 // nodes) but we need to account for some hidden DOM nodes already present 2690 // in this container when determining where to put the nodes back. 2691 const container = CustomizableUI.getCustomizationTarget( 2692 document.getElementById(area) 2693 ); 2694 2695 let moved = false; 2696 let currentPosition = 0; 2697 2698 for (const child of container.childNodes) { 2699 const isSkipToolbarset = child.getAttribute("skipintoolbarset") == "true"; 2700 if (isSkipToolbarset && child !== container.lastChild) { 2701 continue; 2702 } 2703 2704 if (currentPosition === position) { 2705 child.before(node); 2706 moved = true; 2707 break; 2708 } 2709 2710 if (child === container.lastChild) { 2711 child.after(node); 2712 moved = true; 2713 break; 2714 } 2715 2716 currentPosition++; 2717 } 2718 2719 if (moved) { 2720 // Remove the attribute set when we artificially overflow the widget. 2721 node.removeAttribute("overflowedItem"); 2722 node.removeAttribute("artificallyOverflowed"); 2723 node.removeAttribute("cui-anchorid"); 2724 2725 this._updateWidgetClassName(widgetID, /* inPanel */ false); 2726 } 2727 }, 2728 2729 _panel: null, 2730 get panel() { 2731 // Lazy load the unified-extensions-panel panel the first time we need to 2732 // display it. 2733 if (!this._panel) { 2734 let template = document.getElementById( 2735 "unified-extensions-panel-template" 2736 ); 2737 template.replaceWith(template.content); 2738 this._panel = document.getElementById("unified-extensions-panel"); 2739 let customizationArea = this._panel.querySelector( 2740 "#unified-extensions-area" 2741 ); 2742 CustomizableUI.registerPanelNode( 2743 customizationArea, 2744 CustomizableUI.AREA_ADDONS 2745 ); 2746 CustomizableUI.addPanelCloseListeners(this._panel); 2747 2748 this._panel 2749 .querySelector("#unified-extensions-manage-extensions") 2750 .addEventListener("command", () => { 2751 BrowserAddonUI.openAddonsMgr("addons://list/extension"); 2752 }); 2753 2754 // Lazy-load the l10n strings. Those strings are used for the CUI and 2755 // non-CUI extensions in the unified extensions panel. 2756 document 2757 .getElementById("unified-extensions-context-menu") 2758 .querySelectorAll("[data-lazy-l10n-id]") 2759 .forEach(el => { 2760 el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id")); 2761 el.removeAttribute("data-lazy-l10n-id"); 2762 }); 2763 } 2764 return this._panel; 2765 }, 2766 2767 // `aEvent` and `reason` are optional. If `reason` is specified, it should be 2768 // a valid argument to gUnifiedExtensions.recordButtonTelemetry(). 2769 async togglePanel(aEvent, reason) { 2770 if (!CustomizationHandler.isCustomizing()) { 2771 if (aEvent) { 2772 if ( 2773 // On MacOS, ctrl-click will send a context menu event from the 2774 // widget, so we don't want to bring up the panel when ctrl key is 2775 // pressed. 2776 (aEvent.type == "mousedown" && 2777 (aEvent.button !== 0 || 2778 (AppConstants.platform === "macosx" && aEvent.ctrlKey))) || 2779 (aEvent.type === "keypress" && 2780 aEvent.charCode !== KeyEvent.DOM_VK_SPACE && 2781 aEvent.keyCode !== KeyEvent.DOM_VK_RETURN) 2782 ) { 2783 return; 2784 } 2785 2786 // The button should directly open `about:addons` when the user does not 2787 // have any active extensions listed in the unified extensions panel, 2788 // and no alternative content is available for display in the panel. 2789 const policies = this.getActivePolicies(); 2790 if ( 2791 policies.length && 2792 !this.hasExtensionsInPanel(policies) && 2793 !this.isPrivateWindowMissingExtensionsWithoutPBMAccess() && 2794 !(await this.getDisabledExtensionsInfo()).isAnyDisabled 2795 ) { 2796 // This may happen if the user has pinned all of their extensions. 2797 // In that case, the extensions panel is empty. 2798 await BrowserAddonUI.openAddonsMgr("addons://list/extension"); 2799 return; 2800 } 2801 } 2802 2803 this.blocklistAttentionInfo = 2804 await AddonManager.getBlocklistAttentionInfo(); 2805 2806 let panel = this.panel; 2807 2808 if (!this._listView) { 2809 this._listView = PanelMultiView.getViewNode( 2810 document, 2811 "unified-extensions-view" 2812 ); 2813 this._listView.addEventListener("ViewShowing", this); 2814 this._listView.addEventListener("ViewHiding", this); 2815 } 2816 2817 if (this._button.open) { 2818 PanelMultiView.hidePopup(panel); 2819 this._button.open = false; 2820 } else { 2821 // Overflow extensions placed in collapsed toolbars, if any. 2822 for (const toolbarId of CustomizableUI.getCollapsedToolbarIds(window)) { 2823 // We pass `false` because all these toolbars are collapsed. 2824 this.onToolbarVisibilityChange(toolbarId, /* isVisible */ false); 2825 } 2826 2827 panel.hidden = false; 2828 this.recordButtonTelemetry(reason || "extensions_panel_showing"); 2829 this.ensureButtonShownBeforeAttachingPanel(panel); 2830 PanelMultiView.openPopup(panel, this._button, { 2831 position: "bottomright topright", 2832 triggerEvent: aEvent, 2833 }); 2834 } 2835 } 2836 2837 // We always dispatch an event (useful for testing purposes). 2838 window.dispatchEvent(new CustomEvent("UnifiedExtensionsTogglePanel")); 2839 }, 2840 2841 async openPanel(event, reason) { 2842 if (this._button.open) { 2843 throw new Error("Tried to open panel whilst a panel was already open!"); 2844 } 2845 if (CustomizationHandler.isCustomizing()) { 2846 throw new Error("Cannot open panel while in Customize mode!"); 2847 } 2848 2849 if (event?.sourceEvent?.target.id === "appMenu-unified-extensions-button") { 2850 Glean.extensionsButton.openViaAppMenu.record({ 2851 is_extensions_panel_empty: !this.hasExtensionsInPanel(), 2852 is_extensions_button_visible: !this._button.hidden, 2853 }); 2854 } 2855 2856 await this.togglePanel(event, reason); 2857 }, 2858 2859 updateContextMenu(menu, event) { 2860 // When the context menu is open, `onpopupshowing` is called when menu 2861 // items open sub-menus. We don't want to update the context menu in this 2862 // case. 2863 if (event.target.id !== "unified-extensions-context-menu") { 2864 return; 2865 } 2866 2867 const id = this._getExtensionId(menu); 2868 const widgetId = this._getWidgetId(menu); 2869 const forBrowserAction = !!widgetId; 2870 2871 const pinButton = menu.querySelector( 2872 ".unified-extensions-context-menu-pin-to-toolbar" 2873 ); 2874 const removeButton = menu.querySelector( 2875 ".unified-extensions-context-menu-remove-extension" 2876 ); 2877 const reportButton = menu.querySelector( 2878 ".unified-extensions-context-menu-report-extension" 2879 ); 2880 const menuSeparator = menu.querySelector( 2881 ".unified-extensions-context-menu-management-separator" 2882 ); 2883 const moveUp = menu.querySelector( 2884 ".unified-extensions-context-menu-move-widget-up" 2885 ); 2886 const moveDown = menu.querySelector( 2887 ".unified-extensions-context-menu-move-widget-down" 2888 ); 2889 2890 for (const element of [menuSeparator, pinButton, moveUp, moveDown]) { 2891 element.hidden = !forBrowserAction; 2892 } 2893 2894 reportButton.hidden = !gAddonAbuseReportEnabled; 2895 // We use this syntax instead of async/await to not block this method that 2896 // updates the context menu. This avoids the context menu to be out of sync 2897 // on macOS. 2898 AddonManager.getAddonByID(id).then(addon => { 2899 removeButton.disabled = !( 2900 addon.permissions & AddonManager.PERM_CAN_UNINSTALL 2901 ); 2902 }); 2903 2904 if (forBrowserAction) { 2905 let area = CustomizableUI.getPlacementOfWidget(widgetId).area; 2906 let inToolbar = area != CustomizableUI.AREA_ADDONS; 2907 pinButton.toggleAttribute("checked", inToolbar); 2908 2909 const placement = CustomizableUI.getPlacementOfWidget(widgetId); 2910 const notInPanel = placement?.area !== CustomizableUI.AREA_ADDONS; 2911 // We rely on the DOM nodes because CUI widgets will always exist but 2912 // not necessarily with DOM nodes created depending on the window. For 2913 // example, in PB mode, not all extensions will be listed in the panel 2914 // but the CUI widgets may be all created. 2915 if ( 2916 notInPanel || 2917 document.querySelector("#unified-extensions-area > :first-child") 2918 ?.id === widgetId 2919 ) { 2920 moveUp.hidden = true; 2921 } 2922 2923 if ( 2924 notInPanel || 2925 document.querySelector("#unified-extensions-area > :last-child")?.id === 2926 widgetId 2927 ) { 2928 moveDown.hidden = true; 2929 } 2930 } 2931 2932 ExtensionsUI.originControlsMenu(menu, id); 2933 2934 const browserAction = this.browserActionFor(WebExtensionPolicy.getByID(id)); 2935 if (browserAction) { 2936 browserAction.updateContextMenu(menu); 2937 } 2938 }, 2939 2940 // This is registered on the top-level unified extensions context menu. 2941 onContextMenuCommand(menu, event) { 2942 // Do not close the extensions panel automatically when we move extension 2943 // widgets. 2944 const { classList } = event.target; 2945 if ( 2946 classList.contains("unified-extensions-context-menu-move-widget-up") || 2947 classList.contains("unified-extensions-context-menu-move-widget-down") 2948 ) { 2949 return; 2950 } 2951 2952 this.togglePanel(); 2953 }, 2954 2955 browserActionFor(policy) { 2956 // Ideally, we wouldn't do that because `browserActionFor()` will only be 2957 // defined in `global` when at least one extension has required loading the 2958 // `ext-browserAction` code. 2959 let method = lazy.ExtensionParent.apiManager.global.browserActionFor; 2960 return method?.(policy?.extension); 2961 }, 2962 2963 async manageExtension(menu) { 2964 const id = this._getExtensionId(menu); 2965 2966 await BrowserAddonUI.manageAddon(id, "unifiedExtensions"); 2967 }, 2968 2969 async removeExtension(menu) { 2970 const id = this._getExtensionId(menu); 2971 2972 await BrowserAddonUI.removeAddon(id, "unifiedExtensions"); 2973 }, 2974 2975 async reportExtension(menu) { 2976 const id = this._getExtensionId(menu); 2977 2978 await BrowserAddonUI.reportAddon(id, "unified_context_menu"); 2979 }, 2980 2981 _getExtensionId(menu) { 2982 const { triggerNode } = menu; 2983 return triggerNode 2984 .closest(".unified-extensions-item") 2985 ?.querySelector("toolbarbutton")?.dataset.extensionid; 2986 }, 2987 2988 _getWidgetId(menu) { 2989 const { triggerNode } = menu; 2990 return triggerNode.closest(".unified-extensions-item")?.id; 2991 }, 2992 2993 async onPinToToolbarChange(menu, event) { 2994 let shouldPinToToolbar = event.target.hasAttribute("checked"); 2995 // Revert the checkbox back to its original state. This is because the 2996 // addon context menu handlers are asynchronous, and there seems to be 2997 // a race where the checkbox state won't get set in time to show the 2998 // right state. So we err on the side of caution, and presume that future 2999 // attempts to open this context menu on an extension button will show 3000 // the same checked state that we started in. 3001 event.target.toggleAttribute("checked", !shouldPinToToolbar); 3002 3003 let widgetId = this._getWidgetId(menu); 3004 if (!widgetId) { 3005 return; 3006 } 3007 3008 // We artificially overflow extension widgets that are placed in collapsed 3009 // toolbars and CUI does not know about it. For end users, these widgets 3010 // appear in the list of overflowed extensions in the panel. When we unpin 3011 // and then pin one of these extensions to the toolbar, we need to first 3012 // move the DOM node back to where it was (i.e. in the collapsed toolbar) 3013 // so that CUI can retrieve the DOM node and do the pinning correctly. 3014 if (shouldPinToToolbar) { 3015 this._maybeMoveWidgetNodeBack(widgetId); 3016 } 3017 3018 this.pinToToolbar(widgetId, shouldPinToToolbar); 3019 }, 3020 3021 pinToToolbar(widgetId, shouldPinToToolbar) { 3022 let newArea = shouldPinToToolbar 3023 ? CustomizableUI.AREA_NAVBAR 3024 : CustomizableUI.AREA_ADDONS; 3025 let newPosition = shouldPinToToolbar ? undefined : 0; 3026 3027 CustomizableUI.addWidgetToArea(widgetId, newArea, newPosition); 3028 // addWidgetToArea() will trigger onWidgetAdded or onWidgetMoved as needed, 3029 // and our handlers will call updateAttention() as needed. 3030 }, 3031 3032 async moveWidget(menu, direction) { 3033 // We'll move the widgets based on the DOM node positions. This is because 3034 // in PB mode (for example), we might not have the same extensions listed 3035 // in the panel but CUI does not know that. As far as CUI is concerned, all 3036 // extensions will likely have widgets. 3037 const node = menu.triggerNode.closest(".unified-extensions-item"); 3038 3039 // Find the element that is before or after the current widget/node to 3040 // move. `element` might be `null`, e.g. if the current node is the first 3041 // one listed in the panel (though it shouldn't be possible to call this 3042 // method in this case). 3043 let element; 3044 if (direction === "up" && node.previousElementSibling) { 3045 element = node.previousElementSibling; 3046 } else if (direction === "down" && node.nextElementSibling) { 3047 element = node.nextElementSibling; 3048 } 3049 3050 // Now we need to retrieve the position of the CUI placement. 3051 const placement = CustomizableUI.getPlacementOfWidget(element?.id); 3052 if (placement) { 3053 let newPosition = placement.position; 3054 // That, I am not sure why this is required but it looks like we need to 3055 // always add one to the current position if we want to move a widget 3056 // down in the list. 3057 if (direction === "down") { 3058 newPosition += 1; 3059 } 3060 3061 CustomizableUI.moveWidgetWithinArea(node.id, newPosition); 3062 } 3063 }, 3064 3065 onWidgetRemoved() { 3066 // hasExtensionsInPanel may have changed. 3067 this._updateHideEmpty(); 3068 }, 3069 3070 onWidgetDestroyed() { 3071 // hasExtensionsInPanel may have changed. 3072 this._updateHideEmpty(); 3073 }, 3074 3075 onWidgetAdded(aWidgetId, aArea) { 3076 // hasExtensionsInPanel may have changed. 3077 this._updateHideEmpty(); 3078 3079 if (CustomizableUI.isWebExtensionWidget(aWidgetId)) { 3080 this.updateAttention(); 3081 } 3082 3083 // When we pin a widget to the toolbar from a narrow window, the widget 3084 // will be overflowed directly. In this case, we do not want to change the 3085 // class name since it is going to be changed by `onWidgetOverflow()` 3086 // below. 3087 if (CustomizableUI.getWidget(aWidgetId)?.forWindow(window)?.overflowed) { 3088 return; 3089 } 3090 3091 const inPanel = 3092 CustomizableUI.getAreaType(aArea) !== CustomizableUI.TYPE_TOOLBAR; 3093 3094 this._updateWidgetClassName(aWidgetId, inPanel); 3095 }, 3096 3097 onWidgetMoved(aWidgetId) { 3098 if (CustomizableUI.isWebExtensionWidget(aWidgetId)) { 3099 this.updateAttention(); 3100 } 3101 }, 3102 3103 onWidgetOverflow(aNode) { 3104 // hasExtensionsInPanel may have changed. 3105 this._updateHideEmpty(); 3106 3107 // We register a CUI listener for each window so we make sure that we 3108 // handle the event for the right window here. 3109 if (window !== aNode.ownerGlobal) { 3110 return; 3111 } 3112 3113 this._updateWidgetClassName(aNode.getAttribute("widget-id"), true); 3114 }, 3115 3116 onWidgetUnderflow(aNode) { 3117 // hasExtensionsInPanel may have changed. 3118 this._updateHideEmpty(); 3119 3120 // We register a CUI listener for each window so we make sure that we 3121 // handle the event for the right window here. 3122 if (window !== aNode.ownerGlobal) { 3123 return; 3124 } 3125 3126 this._updateWidgetClassName(aNode.getAttribute("widget-id"), false); 3127 }, 3128 3129 onAreaNodeRegistered(aArea, aContainer) { 3130 // We register a CUI listener for each window so we make sure that we 3131 // handle the event for the right window here. 3132 if (window !== aContainer.ownerGlobal) { 3133 return; 3134 } 3135 3136 const inPanel = 3137 CustomizableUI.getAreaType(aArea) !== CustomizableUI.TYPE_TOOLBAR; 3138 3139 for (const widgetId of CustomizableUI.getWidgetIdsInArea(aArea)) { 3140 this._updateWidgetClassName(widgetId, inPanel); 3141 } 3142 }, 3143 3144 // This internal method is used to change some CSS classnames on the action 3145 // and menu buttons of an extension (CUI) widget. When the widget is placed 3146 // in the panel, the action and menu buttons should have the `.subviewbutton` 3147 // class and not the `.toolbarbutton-1` one. When NOT placed in the panel, 3148 // it is the other way around. 3149 _updateWidgetClassName(aWidgetId, inPanel) { 3150 if (!CustomizableUI.isWebExtensionWidget(aWidgetId)) { 3151 return; 3152 } 3153 3154 const node = CustomizableUI.getWidget(aWidgetId)?.forWindow(window)?.node; 3155 const actionButton = node?.querySelector( 3156 ".unified-extensions-item-action-button" 3157 ); 3158 if (actionButton) { 3159 actionButton.classList.toggle("subviewbutton", inPanel); 3160 actionButton.classList.toggle("subviewbutton-iconic", inPanel); 3161 actionButton.classList.toggle("toolbarbutton-1", !inPanel); 3162 } 3163 const menuButton = node?.querySelector( 3164 ".unified-extensions-item-menu-button" 3165 ); 3166 if (menuButton) { 3167 menuButton.classList.toggle("subviewbutton", inPanel); 3168 menuButton.classList.toggle("subviewbutton-iconic", inPanel); 3169 menuButton.classList.toggle("toolbarbutton-1", !inPanel); 3170 } 3171 }, 3172 3173 _createBlocklistMessageBar(container) { 3174 if (!this.blocklistAttentionInfo) { 3175 return null; 3176 } 3177 3178 const { addons, extensionsCount, hasHardBlocked } = 3179 this.blocklistAttentionInfo; 3180 const type = hasHardBlocked ? "error" : "warning"; 3181 3182 let messageBarFluentId; 3183 let extensionName; 3184 if (extensionsCount === 1) { 3185 extensionName = addons[0].name; 3186 messageBarFluentId = hasHardBlocked 3187 ? "unified-extensions-mb-blocklist-error-single" 3188 : "unified-extensions-mb-blocklist-warning-single2"; 3189 } else { 3190 messageBarFluentId = hasHardBlocked 3191 ? "unified-extensions-mb-blocklist-error-multiple" 3192 : "unified-extensions-mb-blocklist-warning-multiple2"; 3193 } 3194 3195 const messageBarBlocklist = this._makeMessageBar({ 3196 dismissible: true, 3197 linkToAboutAddons: true, 3198 messageBarFluentId, 3199 messageBarFluentArgs: { 3200 extensionsCount, 3201 extensionName, 3202 }, 3203 type, 3204 }); 3205 3206 messageBarBlocklist.addEventListener( 3207 "message-bar:user-dismissed", 3208 () => { 3209 if (messageBarBlocklist === this._messageBarBlocklist) { 3210 this._messageBarBlocklist = null; 3211 } 3212 this.blocklistAttentionInfo?.dismiss(); 3213 }, 3214 { once: true } 3215 ); 3216 3217 if ( 3218 this._messageBarBlocklist && 3219 container.contains(this._messageBarBlocklist) 3220 ) { 3221 container.replaceChild(messageBarBlocklist, this._messageBarBlocklist); 3222 } else if (container.contains(this._messageBarQuarantinedDomain)) { 3223 container.insertBefore( 3224 messageBarBlocklist, 3225 this._messageBarQuarantinedDomain 3226 ); 3227 } else { 3228 container.appendChild(messageBarBlocklist); 3229 } 3230 3231 return messageBarBlocklist; 3232 }, 3233 3234 _makeMessageBar({ 3235 dismissible = false, 3236 messageBarFluentId, 3237 messageBarFluentArgs, 3238 supportPage = null, 3239 supportPageFluentId, 3240 linkToAboutAddons = false, 3241 type = "warning", 3242 }) { 3243 const messageBar = document.createElement("moz-message-bar"); 3244 messageBar.setAttribute("type", type); 3245 messageBar.classList.add("unified-extensions-message-bar"); 3246 3247 if (dismissible) { 3248 // NOTE: the moz-message-bar is currently expected to be called `dismissable`. 3249 messageBar.setAttribute("dismissable", dismissible); 3250 } 3251 3252 if (linkToAboutAddons) { 3253 const linkToAboutAddonsEl = document.createElement("a"); 3254 linkToAboutAddonsEl.setAttribute( 3255 "class", 3256 "unified-extensions-link-to-aboutaddons" 3257 ); 3258 linkToAboutAddonsEl.setAttribute("slot", "support-link"); 3259 linkToAboutAddonsEl.addEventListener("click", () => { 3260 BrowserAddonUI.openAddonsMgr("addons://list/extension"); 3261 this.togglePanel(); 3262 }); 3263 document.l10n.setAttributes( 3264 linkToAboutAddonsEl, 3265 "unified-extensions-mb-about-addons-link" 3266 ); 3267 messageBar.append(linkToAboutAddonsEl); 3268 } 3269 3270 document.l10n.setAttributes( 3271 messageBar, 3272 messageBarFluentId, 3273 messageBarFluentArgs 3274 ); 3275 3276 if (supportPage) { 3277 const supportUrl = document.createElement("a", { 3278 is: "moz-support-link", 3279 }); 3280 supportUrl.setAttribute("support-page", supportPage); 3281 if (supportPageFluentId) { 3282 document.l10n.setAttributes(supportUrl, supportPageFluentId); 3283 } 3284 supportUrl.setAttribute("slot", "support-link"); 3285 3286 messageBar.append(supportUrl); 3287 } 3288 3289 return messageBar; 3290 }, 3291 3292 _createDiscoverButton() { 3293 const discoverButton = document.createElement("moz-button"); 3294 discoverButton.id = "unified-extensions-discover-extensions"; 3295 discoverButton.type = "primary"; 3296 discoverButton.className = "subviewbutton panel-subview-footer-button"; 3297 document.l10n.setAttributes( 3298 discoverButton, 3299 "unified-extensions-discover-extensions" 3300 ); 3301 3302 discoverButton.addEventListener("click", () => { 3303 if ( 3304 // The "Discover Extensions" button is only shown if the user has not 3305 // installed any extension. In that case, we direct to the discopane 3306 // in about:addons. If the discopane is disabled, open the default 3307 // view (Extensions list) instead. This view shows a link to AMO when 3308 // the user does not have any extensions installed. 3309 Services.prefs.getBoolPref("extensions.getAddons.showPane", true) 3310 ) { 3311 BrowserAddonUI.openAddonsMgr("addons://list/discover"); 3312 } else { 3313 BrowserAddonUI.openAddonsMgr("addons://list/extension"); 3314 } 3315 // Close panel. 3316 this.togglePanel(); 3317 }); 3318 3319 return discoverButton; 3320 }, 3321 3322 _shouldShowQuarantinedNotification() { 3323 const { currentURI, selectedTab } = window.gBrowser; 3324 // We should show the quarantined notification when the domain is in the 3325 // list of quarantined domains and we have at least one extension 3326 // quarantined. In addition, we check that we have extensions in the panel 3327 // until Bug 1778684 is resolved. 3328 return ( 3329 WebExtensionPolicy.isQuarantinedURI(currentURI) && 3330 this.hasExtensionsInPanel() && 3331 this.getActivePolicies().some( 3332 policy => lazy.OriginControls.getState(policy, selectedTab).quarantined 3333 ) 3334 ); 3335 }, 3336 3337 // Records telemetry when the button is about to temporarily be shown, 3338 // provided that the button is hidden at the time of invocation. 3339 // 3340 // `reason` is one of the labels in extensions_button.temporarily_unhidden 3341 // in browser/components/extensions/metrics.yaml. 3342 // 3343 // This is usually immediately before a updateButtonVisibility() call, 3344 // sometimes a bit earlier (if the updateButtonVisibility() call is indirect). 3345 recordButtonTelemetry(reason) { 3346 if (!this.buttonAlwaysVisible && this._button.hidden) { 3347 Glean.extensionsButton.temporarilyUnhidden[reason].add(); 3348 } 3349 }, 3350 3351 hideExtensionsButtonFromToolbar() { 3352 // All browser windows will observe this and call updateButtonVisibility(). 3353 Services.prefs.setBoolPref( 3354 "extensions.unifiedExtensions.button.always_visible", 3355 false 3356 ); 3357 ConfirmationHint.show( 3358 document.getElementById("PanelUI-menu-button"), 3359 "confirmation-hint-extensions-button-hidden" 3360 ); 3361 Glean.extensionsButton.toggleVisibility.record({ 3362 is_customizing: CustomizationHandler.isCustomizing(), 3363 is_extensions_panel_empty: !this.hasExtensionsInPanel(), 3364 // After setting the above pref to false, the button should hide 3365 // immediately. If this was not the case, then something caused the 3366 // button to be shown temporarily. 3367 is_temporarily_shown: !this._button.hidden, 3368 should_hide: true, 3369 }); 3370 }, 3371 3372 showExtensionsButtonInToolbar() { 3373 let wasShownBefore = !this.buttonAlwaysVisible && !this._button.hidden; 3374 // All browser windows will observe this and call updateButtonVisibility(). 3375 Services.prefs.setBoolPref( 3376 "extensions.unifiedExtensions.button.always_visible", 3377 true 3378 ); 3379 Glean.extensionsButton.toggleVisibility.record({ 3380 is_customizing: CustomizationHandler.isCustomizing(), 3381 is_extensions_panel_empty: !this.hasExtensionsInPanel(), 3382 is_temporarily_shown: wasShownBefore, 3383 should_hide: false, 3384 }); 3385 }, 3386 }; 3387 XPCOMUtils.defineLazyPreferenceGetter( 3388 gUnifiedExtensions, 3389 "buttonAlwaysVisible", 3390 "extensions.unifiedExtensions.button.always_visible", 3391 true, 3392 (prefName, oldValue, newValue) => { 3393 if (gUnifiedExtensions._initialized) { 3394 gUnifiedExtensions._updateButtonBarListeners(); 3395 gUnifiedExtensions.updateButtonVisibility(); 3396 Glean.extensionsButton.prefersHiddenButton.set(!newValue); 3397 } 3398 } 3399 ); 3400 // With button.always_visible is false, we still show the button in specific 3401 // cases when needed. The user is always empowered to dismiss the specific 3402 // trigger that causes the button to be shown. The attention dot is the 3403 // exception, where the button cannot easily be hidden. Users who willingly 3404 // want to ignore the attention dot can set this preference to keep the button 3405 // hidden even if attention is requested. 3406 XPCOMUtils.defineLazyPreferenceGetter( 3407 gUnifiedExtensions, 3408 "buttonIgnoresAttention", 3409 "extensions.unifiedExtensions.button.ignore_attention", 3410 false, 3411 () => { 3412 if ( 3413 gUnifiedExtensions._initialized && 3414 !gUnifiedExtensions.buttonAlwaysVisible 3415 ) { 3416 gUnifiedExtensions.updateButtonVisibility(); 3417 } 3418 } 3419 );