ext-browserAction.js (34047B)
1 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ 2 /* vim: set sts=2 sw=2 et tw=80: */ 3 /* This Source Code Form is subject to the terms of the Mozilla Public 4 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 5 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 7 "use strict"; 8 9 ChromeUtils.defineESModuleGetters(this, { 10 BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs", 11 CustomizableUI: 12 "moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs", 13 ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs", 14 OriginControls: "resource://gre/modules/ExtensionPermissions.sys.mjs", 15 ViewPopup: "resource:///modules/ExtensionPopups.sys.mjs", 16 clearTimeout: "resource://gre/modules/Timer.sys.mjs", 17 setTimeout: "resource://gre/modules/Timer.sys.mjs", 18 }); 19 20 var { DefaultWeakMap, ExtensionError } = ExtensionUtils; 21 22 var { ExtensionParent } = ChromeUtils.importESModule( 23 "resource://gre/modules/ExtensionParent.sys.mjs" 24 ); 25 var { BrowserActionBase } = ChromeUtils.importESModule( 26 "resource://gre/modules/ExtensionActions.sys.mjs" 27 ); 28 29 var { IconDetails, StartupCache } = ExtensionParent; 30 31 const POPUP_PRELOAD_TIMEOUT_MS = 200; 32 33 // WeakMap[Extension -> BrowserAction] 34 const browserActionMap = new WeakMap(); 35 36 ChromeUtils.defineLazyGetter(this, "browserAreas", () => { 37 return { 38 navbar: CustomizableUI.AREA_NAVBAR, 39 menupanel: CustomizableUI.AREA_ADDONS, 40 tabstrip: CustomizableUI.AREA_TABSTRIP, 41 personaltoolbar: CustomizableUI.AREA_BOOKMARKS, 42 }; 43 }); 44 45 function actionWidgetId(widgetId) { 46 return `${widgetId}-browser-action`; 47 } 48 49 class BrowserAction extends BrowserActionBase { 50 constructor(extension, buttonDelegate) { 51 let tabContext = new TabContext(target => { 52 let window = target.ownerGlobal; 53 if (target === window) { 54 return this.getContextData(null); 55 } 56 return tabContext.get(window); 57 }); 58 super(tabContext, extension); 59 this.buttonDelegate = buttonDelegate; 60 } 61 62 updateOnChange(target) { 63 if (target) { 64 let window = target.ownerGlobal; 65 if (target === window || target.selected) { 66 this.buttonDelegate.updateWindow(window); 67 } 68 } else { 69 for (let window of windowTracker.browserWindows()) { 70 this.buttonDelegate.updateWindow(window); 71 } 72 } 73 } 74 75 getTab(tabId) { 76 if (tabId !== null) { 77 return tabTracker.getTab(tabId); 78 } 79 return null; 80 } 81 82 getWindow(windowId) { 83 if (windowId !== null) { 84 return windowTracker.getWindow(windowId); 85 } 86 return null; 87 } 88 89 dispatchClick(tab, clickInfo) { 90 this.buttonDelegate.emit("click", tab, clickInfo); 91 } 92 } 93 94 this.browserAction = class extends ExtensionAPIPersistent { 95 static for(extension) { 96 return browserActionMap.get(extension); 97 } 98 99 async onManifestEntry() { 100 let { extension } = this; 101 102 let options = 103 extension.manifest.browser_action || extension.manifest.action; 104 105 this.action = new BrowserAction(extension, this); 106 await this.action.loadIconData(); 107 108 this.iconData = new DefaultWeakMap(icons => this.getIconData(icons)); 109 this.iconData.set( 110 this.action.getIcon(), 111 await StartupCache.get( 112 extension, 113 ["browserAction", "default_icon_data"], 114 () => this.getIconData(this.action.getIcon()) 115 ) 116 ); 117 118 let widgetId = makeWidgetId(extension.id); 119 this.id = actionWidgetId(widgetId); 120 this.viewId = `PanelUI-webext-${widgetId}-BAV`; 121 this.widget = null; 122 123 this.pendingPopup = null; 124 this.pendingPopupTimeout = null; 125 this.eventQueue = []; 126 127 this.tabManager = extension.tabManager; 128 this.browserStyle = options.browser_style; 129 130 browserActionMap.set(extension, this); 131 132 this.build(); 133 } 134 135 static onUpdate(id, manifest) { 136 if (!("browser_action" in manifest || "action" in manifest)) { 137 // If the new version has no browser action then mark this widget as 138 // hidden in the telemetry. If it is already marked hidden then this will 139 // do nothing. 140 BrowserUsageTelemetry.recordWidgetChange( 141 actionWidgetId(makeWidgetId(id)), 142 null, 143 "addon" 144 ); 145 } 146 } 147 148 static onDisable(id) { 149 BrowserUsageTelemetry.recordWidgetChange( 150 actionWidgetId(makeWidgetId(id)), 151 null, 152 "addon" 153 ); 154 } 155 156 static onUninstall(id) { 157 // If the telemetry already has this widget as hidden then this will not 158 // record anything. 159 BrowserUsageTelemetry.recordWidgetChange( 160 actionWidgetId(makeWidgetId(id)), 161 null, 162 "addon" 163 ); 164 } 165 166 onShutdown() { 167 browserActionMap.delete(this.extension); 168 this.action.onShutdown(); 169 170 CustomizableUI.destroyWidget(this.id); 171 172 this.clearPopup(); 173 } 174 175 build() { 176 let { extension } = this; 177 let widgetId = makeWidgetId(extension.id); 178 let widget = CustomizableUI.createWidget({ 179 id: this.id, 180 viewId: this.viewId, 181 type: "custom", 182 webExtension: true, 183 removable: true, 184 label: this.action.getProperty(null, "title"), 185 tooltiptext: this.action.getProperty(null, "title"), 186 defaultArea: browserAreas[this.action.getDefaultArea()], 187 showInPrivateBrowsing: extension.privateBrowsingAllowed, 188 disallowSubView: true, 189 190 // Don't attempt to load properties from the built-in widget string 191 // bundle. 192 localized: false, 193 194 // Build a custom widget that looks like a `unified-extensions-item` 195 // custom element. 196 onBuild(document) { 197 let viewId = widgetId + "-BAP"; 198 let button = document.createXULElement("toolbarbutton"); 199 button.setAttribute("id", viewId); 200 // Ensure the extension context menuitems are available by setting this 201 // on all button children and the item. 202 button.setAttribute("data-extensionid", extension.id); 203 button.classList.add("unified-extensions-item-action-button"); 204 205 let contents = document.createXULElement("vbox"); 206 contents.classList.add("unified-extensions-item-contents"); 207 contents.setAttribute("move-after-stack", "true"); 208 209 let name = document.createXULElement("label"); 210 name.classList.add("unified-extensions-item-name"); 211 contents.appendChild(name); 212 213 // This deck (and its labels) should be kept in sync with 214 // `browser/base/content/unified-extensions-viewcache.inc.xhtml`. 215 let deck = document.createXULElement("deck"); 216 deck.classList.add("unified-extensions-item-message-deck"); 217 218 let messageDefault = document.createXULElement("label"); 219 messageDefault.classList.add( 220 "unified-extensions-item-message", 221 "unified-extensions-item-message-default" 222 ); 223 deck.appendChild(messageDefault); 224 225 let messageHover = document.createXULElement("label"); 226 messageHover.classList.add( 227 "unified-extensions-item-message", 228 "unified-extensions-item-message-hover" 229 ); 230 deck.appendChild(messageHover); 231 232 let messageHoverForMenuButton = document.createXULElement("label"); 233 messageHoverForMenuButton.classList.add( 234 "unified-extensions-item-message", 235 "unified-extensions-item-message-hover-menu-button" 236 ); 237 document.l10n.setAttributes( 238 messageHoverForMenuButton, 239 "unified-extensions-item-message-manage" 240 ); 241 deck.appendChild(messageHoverForMenuButton); 242 243 contents.appendChild(deck); 244 245 button.appendChild(contents); 246 247 let menuButton = document.createXULElement("toolbarbutton"); 248 menuButton.classList.add( 249 "toolbarbutton-1", 250 "unified-extensions-item-menu-button" 251 ); 252 253 document.l10n.setAttributes( 254 menuButton, 255 "unified-extensions-item-open-menu" 256 ); 257 // Allow the users to quickly move between extension items using 258 // the arrow keys, see: `PanelMultiView.#isNavigableWithTabOnly()`. 259 menuButton.setAttribute("data-navigable-with-tab-only", true); 260 261 menuButton.setAttribute("data-extensionid", extension.id); 262 menuButton.setAttribute("closemenu", "none"); 263 264 let node = document.createXULElement("toolbaritem"); 265 node.classList.add( 266 "toolbaritem-combined-buttons", 267 "unified-extensions-item" 268 ); 269 node.setAttribute("view-button-id", viewId); 270 node.setAttribute("data-extensionid", extension.id); 271 272 let rowWrapper = document.createXULElement("box"); 273 rowWrapper.classList.add("unified-extensions-item-row-wrapper"); 274 rowWrapper.append(button, menuButton); 275 276 let messagebarWrapper = document.createElement( 277 "unified-extensions-item-messagebar-wrapper" 278 ); 279 messagebarWrapper.extensionId = extension.id; 280 281 node.append(rowWrapper, messagebarWrapper); 282 node.viewButton = button; 283 284 if (extension.isNoScript) { 285 // Hide NoScript by default. 286 // See tor-browser#41581. 287 const HIDE_NO_SCRIPT_PREF = "extensions.hideNoScript"; 288 const changeNoScriptVisibility = () => { 289 node.hidden = Services.prefs.getBoolPref(HIDE_NO_SCRIPT_PREF, true); 290 }; 291 // Since we expect the NoScript widget to only be destroyed on exit, 292 // we do not set up to remove the observer. 293 Services.prefs.addObserver( 294 HIDE_NO_SCRIPT_PREF, 295 changeNoScriptVisibility 296 ); 297 changeNoScriptVisibility(); 298 } 299 300 return node; 301 }, 302 303 onBeforeCreated: document => { 304 let view = document.createXULElement("panelview"); 305 view.id = this.viewId; 306 view.setAttribute("flex", "1"); 307 view.setAttribute("extension", true); 308 view.setAttribute("neverhidden", true); 309 view.setAttribute("disallowSubView", true); 310 311 document.getElementById("appMenu-viewCache").appendChild(view); 312 313 if ( 314 this.extension.hasPermission("menus") || 315 this.extension.hasPermission("contextMenus") 316 ) { 317 document.addEventListener("popupshowing", this); 318 } 319 }, 320 321 onDestroyed: document => { 322 document.removeEventListener("popupshowing", this); 323 324 let view = document.getElementById(this.viewId); 325 if (view) { 326 this.clearPopup(); 327 CustomizableUI.hidePanelForNode(view); 328 view.remove(); 329 } 330 }, 331 332 onCreated: node => { 333 let actionButton = node.querySelector( 334 ".unified-extensions-item-action-button" 335 ); 336 actionButton.classList.add("panel-no-padding"); 337 actionButton.classList.add("webextension-browser-action"); 338 actionButton.setAttribute("badged", "true"); 339 actionButton.setAttribute("constrain-size", "true"); 340 actionButton.setAttribute("data-extensionid", this.extension.id); 341 342 actionButton.onmousedown = event => this.handleEvent(event); 343 actionButton.onmouseover = event => this.handleEvent(event); 344 actionButton.onmouseout = event => this.handleEvent(event); 345 actionButton.onauxclick = event => this.handleEvent(event); 346 347 const menuButton = node.querySelector( 348 ".unified-extensions-item-menu-button" 349 ); 350 node.ownerDocument.l10n.setAttributes( 351 menuButton, 352 "unified-extensions-item-open-menu", 353 { extensionName: this.extension.name } 354 ); 355 356 menuButton.onblur = event => this.handleMenuButtonEvent(event); 357 menuButton.onfocus = event => this.handleMenuButtonEvent(event); 358 menuButton.onmouseout = event => this.handleMenuButtonEvent(event); 359 menuButton.onmouseover = event => this.handleMenuButtonEvent(event); 360 361 actionButton.onblur = event => this.handleEvent(event); 362 actionButton.onfocus = event => this.handleEvent(event); 363 364 this.updateButton( 365 node, 366 this.action.getContextData(null), 367 /* sync */ true 368 ); 369 }, 370 371 onBeforeCommand: event => { 372 this.lastClickInfo = { 373 button: event.button || 0, 374 modifiers: clickModifiersFromEvent(event), 375 }; 376 377 // The openPopupWithoutUserInteraction flag may be set by openPopup. 378 this.openPopupWithoutUserInteraction = 379 event.detail?.openPopupWithoutUserInteraction === true; 380 381 if ( 382 event.target.classList.contains( 383 "unified-extensions-item-action-button" 384 ) 385 ) { 386 return "view"; 387 } else if ( 388 event.target.classList.contains("unified-extensions-item-menu-button") 389 ) { 390 return "command"; 391 } 392 }, 393 394 onCommand: event => { 395 const { target } = event; 396 397 if (event.button !== 0) { 398 return; 399 } 400 401 // Open the unified extensions context menu. 402 const popup = target.ownerDocument.getElementById( 403 "unified-extensions-context-menu" 404 ); 405 // Anchor to the visible part of the button. 406 const anchor = target.firstElementChild; 407 popup.openPopup( 408 anchor, 409 "after_end", 410 0, 411 0, 412 true /* isContextMenu */, 413 false /* attributesOverride */, 414 event 415 ); 416 }, 417 418 onViewShowing: async event => { 419 const { extension } = this; 420 421 ExtensionTelemetry.browserActionPopupOpen.stopwatchStart( 422 extension, 423 this 424 ); 425 let document = event.target.ownerDocument; 426 let tabbrowser = document.defaultView.gBrowser; 427 428 let tab = tabbrowser.selectedTab; 429 430 let popupURL = !this.openPopupWithoutUserInteraction 431 ? this.action.triggerClickOrPopup(tab, this.lastClickInfo) 432 : this.action.getPopupUrl(tab); 433 434 if (popupURL) { 435 try { 436 let popup = this.getPopup(document.defaultView, popupURL); 437 let attachPromise = popup.attach(event.target); 438 event.detail.addBlocker(attachPromise); 439 await attachPromise; 440 ExtensionTelemetry.browserActionPopupOpen.stopwatchFinish( 441 extension, 442 this 443 ); 444 if (this.eventQueue.length) { 445 ExtensionTelemetry.browserActionPreloadResult.histogramAdd({ 446 category: "popupShown", 447 extension, 448 }); 449 this.eventQueue = []; 450 } 451 } catch (e) { 452 ExtensionTelemetry.browserActionPopupOpen.stopwatchCancel( 453 extension, 454 this 455 ); 456 Cu.reportError(e); 457 event.preventDefault(); 458 } 459 } else { 460 ExtensionTelemetry.browserActionPopupOpen.stopwatchCancel( 461 extension, 462 this 463 ); 464 // This isn't not a hack, but it seems to provide the correct behavior 465 // with the fewest complications. 466 event.preventDefault(); 467 // Ensure we close any popups this node was in: 468 CustomizableUI.hidePanelForNode(event.target); 469 } 470 }, 471 }); 472 473 if (this.extension.startupReason != "APP_STARTUP") { 474 // Make sure the browser telemetry has the correct state for this widget. 475 // Defer loading BrowserUsageTelemetry until after startup is complete. 476 ExtensionParent.browserStartupPromise.then(() => { 477 let placement = CustomizableUI.getPlacementOfWidget(widget.id); 478 BrowserUsageTelemetry.recordWidgetChange( 479 widget.id, 480 placement?.area || null, 481 "addon" 482 ); 483 }); 484 } 485 486 this.widget = widget; 487 } 488 489 /** 490 * Shows the popup. The caller is expected to check if a popup is set before 491 * this is called. 492 * 493 * @param {Window} window Window to show the popup for 494 * @param {boolean} openPopupWithoutUserInteraction 495 * If the popup was opened without user interaction 496 */ 497 async openPopup(window, openPopupWithoutUserInteraction = false) { 498 const widgetForWindow = this.widget.forWindow(window); 499 500 if (!widgetForWindow.node) { 501 return; 502 } 503 504 // We want to focus hidden or minimized windows (both for the API, and to 505 // avoid an issue where showing the popup in a non-focused window 506 // immediately triggers a popuphidden event) 507 window.focus(); 508 509 const toolbarButton = widgetForWindow.node.querySelector( 510 ".unified-extensions-item-action-button" 511 ); 512 513 if (toolbarButton.open) { 514 return; 515 } 516 517 if (this.widget.areaType == CustomizableUI.TYPE_PANEL) { 518 await window.gUnifiedExtensions.openPanel( 519 null, 520 "extension_browser_action_popup" 521 ); 522 } 523 524 // This should already have been checked by callers, but acts as an 525 // an additional safeguard. It also makes sure we don't dispatch a click 526 // if the URL is removed while waiting for the overflow to show above. 527 if (!this.action.getPopupUrl(window.gBrowser.selectedTab)) { 528 return; 529 } 530 531 const event = new window.CustomEvent("command", { 532 bubbles: true, 533 cancelable: true, 534 detail: { 535 openPopupWithoutUserInteraction, 536 }, 537 }); 538 toolbarButton.dispatchEvent(event); 539 } 540 541 /** 542 * Triggers this browser action for the given window, with the same effects as 543 * if it were clicked by a user. 544 * 545 * This has no effect if the browser action is disabled for, or not 546 * present in, the given window. 547 * 548 * @param {Window} window 549 */ 550 triggerAction(window) { 551 let popup = ViewPopup.for(this.extension, window); 552 if (!this.pendingPopup && popup) { 553 popup.closePopup(); 554 return; 555 } 556 557 let tab = window.gBrowser.selectedTab; 558 559 let popupUrl = this.action.triggerClickOrPopup(tab, { 560 button: 0, 561 modifiers: [], 562 }); 563 if (popupUrl) { 564 this.openPopup(window); 565 } 566 } 567 568 /** 569 * Handles events on the (secondary) menu/cog button in an extension widget. 570 * 571 * @param {Event} event 572 */ 573 handleMenuButtonEvent(event) { 574 let window = event.target.ownerGlobal; 575 let { node } = window.gBrowser && this.widget.forWindow(window); 576 let messageDeck = node?.querySelector( 577 ".unified-extensions-item-message-deck" 578 ); 579 580 switch (event.type) { 581 case "focus": 582 case "mouseover": { 583 if (messageDeck) { 584 messageDeck.selectedIndex = 585 window.gUnifiedExtensions.MESSAGE_DECK_INDEX_MENU_HOVER; 586 } 587 break; 588 } 589 590 case "blur": 591 case "mouseout": { 592 if (messageDeck) { 593 messageDeck.selectedIndex = 594 window.gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT; 595 } 596 break; 597 } 598 } 599 } 600 601 handleEvent(event) { 602 // This button is the action/primary button in the custom widget. 603 let button = event.target; 604 let window = button.ownerGlobal; 605 606 switch (event.type) { 607 case "mousedown": 608 if (event.button == 0) { 609 let tab = window.gBrowser.selectedTab; 610 611 // Begin pre-loading the browser for the popup, so it's more likely to 612 // be ready by the time we get a complete click. 613 let popupURL = this.action.getPopupUrl(tab); 614 if ( 615 popupURL && 616 (this.pendingPopup || !ViewPopup.for(this.extension, window)) 617 ) { 618 // Add permission for the active tab so it will exist for the popup. 619 this.action.setActiveTabForPreload(tab); 620 this.eventQueue.push("Mousedown"); 621 this.pendingPopup = this.getPopup(window, popupURL); 622 window.addEventListener("mouseup", this, true); 623 } else { 624 this.clearPopup(); 625 } 626 } 627 break; 628 629 case "mouseup": 630 if (event.button == 0) { 631 this.clearPopupTimeout(); 632 // If we have a pending pre-loaded popup, cancel it after we've waited 633 // long enough that we can be relatively certain it won't be opening. 634 if (this.pendingPopup) { 635 let node = window.gBrowser && this.widget.forWindow(window).node; 636 if (node && node.contains(event.originalTarget)) { 637 this.pendingPopupTimeout = setTimeout( 638 () => this.clearPopup(), 639 POPUP_PRELOAD_TIMEOUT_MS 640 ); 641 } else { 642 this.clearPopup(); 643 } 644 } 645 } 646 break; 647 648 case "focus": 649 case "mouseover": { 650 let tab = window.gBrowser.selectedTab; 651 let popupURL = this.action.getPopupUrl(tab); 652 653 let { node } = window.gBrowser && this.widget.forWindow(window); 654 if (node) { 655 node.querySelector( 656 ".unified-extensions-item-message-deck" 657 ).selectedIndex = window.gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER; 658 } 659 660 // We don't want to preload the popup on focus (for now). 661 if (event.type === "focus") { 662 break; 663 } 664 665 // Begin pre-loading the browser for the popup, so it's more likely to 666 // be ready by the time we get a complete click. 667 if ( 668 popupURL && 669 (this.pendingPopup || !ViewPopup.for(this.extension, window)) 670 ) { 671 this.eventQueue.push("Hover"); 672 this.pendingPopup = this.getPopup(window, popupURL, true); 673 } 674 break; 675 } 676 677 case "blur": 678 case "mouseout": { 679 let { node } = window.gBrowser && this.widget.forWindow(window); 680 if (node) { 681 node.querySelector( 682 ".unified-extensions-item-message-deck" 683 ).selectedIndex = 684 window.gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT; 685 } 686 687 // We don't want to clear the popup on blur for now. 688 if (event.type === "blur") { 689 break; 690 } 691 692 if (this.pendingPopup) { 693 if (this.eventQueue.length) { 694 ExtensionTelemetry.browserActionPreloadResult.histogramAdd({ 695 category: `clearAfter${this.eventQueue.pop()}`, 696 extension: this.extension, 697 }); 698 this.eventQueue = []; 699 } 700 this.clearPopup(); 701 } 702 break; 703 } 704 705 case "popupshowing": { 706 const menu = event.target; 707 const trigger = menu.triggerNode; 708 const node = window.document.getElementById(this.id); 709 const contexts = [ 710 "toolbar-context-menu", 711 "customizationPanelItemContextMenu", 712 ]; 713 714 if (contexts.includes(menu.id) && node && node.contains(trigger)) { 715 this.updateContextMenu(menu); 716 } 717 break; 718 } 719 720 case "auxclick": { 721 if (event.button !== 1) { 722 return; 723 } 724 725 let tab = window.gBrowser.selectedTab; 726 if (this.action.getProperty(tab, "enabled")) { 727 this.action.setActiveTabForPreload(null); 728 this.tabManager.addActiveTabPermission(tab); 729 this.action.dispatchClick(tab, { 730 button: 1, 731 modifiers: clickModifiersFromEvent(event), 732 }); 733 // Ensure we close any popups this node was in: 734 CustomizableUI.hidePanelForNode(event.target); 735 } 736 break; 737 } 738 } 739 } 740 741 /** 742 * Updates the given context menu with the extension's actions. 743 * 744 * @param {Element} menu 745 * The context menu element that should be updated. 746 */ 747 updateContextMenu(menu) { 748 const action = 749 this.extension.manifestVersion < 3 ? "onBrowserAction" : "onAction"; 750 751 if ( 752 this.extension.hasPermission("contextMenus") || 753 this.extension.hasPermission("menus") 754 ) { 755 global.actionContextMenu({ 756 extension: this.extension, 757 [action]: true, 758 menu, 759 }); 760 } 761 } 762 763 /** 764 * Returns a potentially pre-loaded popup for the given URL in the given 765 * window. If a matching pre-load popup already exists, returns that. 766 * Otherwise, initializes a new one. 767 * 768 * If a pre-load popup exists which does not match, it is destroyed before a 769 * new one is created. 770 * 771 * @param {Window} window 772 * The browser window in which to create the popup. 773 * @param {string} popupURL 774 * The URL to load into the popup. 775 * @param {boolean} [blockParser = false] 776 * True if the HTML parser should initially be blocked. 777 * @returns {ViewPopup} 778 */ 779 getPopup(window, popupURL, blockParser = false) { 780 this.clearPopupTimeout(); 781 let { pendingPopup } = this; 782 this.pendingPopup = null; 783 784 if (pendingPopup) { 785 if ( 786 pendingPopup.window === window && 787 pendingPopup.popupURL === popupURL 788 ) { 789 if (!blockParser) { 790 pendingPopup.unblockParser(); 791 } 792 793 return pendingPopup; 794 } 795 pendingPopup.destroy(); 796 } 797 798 return new ViewPopup( 799 this.extension, 800 window, 801 popupURL, 802 this.browserStyle, 803 false, 804 blockParser 805 ); 806 } 807 808 /** 809 * Clears any pending pre-loaded popup and related timeouts. 810 */ 811 clearPopup() { 812 this.clearPopupTimeout(); 813 this.action.setActiveTabForPreload(null); 814 if (this.pendingPopup) { 815 this.pendingPopup.destroy(); 816 this.pendingPopup = null; 817 } 818 } 819 820 /** 821 * Clears any pending timeouts to clear stale, pre-loaded popups. 822 */ 823 clearPopupTimeout() { 824 if (this.pendingPopup) { 825 this.pendingPopup.window.removeEventListener("mouseup", this, true); 826 } 827 828 if (this.pendingPopupTimeout) { 829 clearTimeout(this.pendingPopupTimeout); 830 this.pendingPopupTimeout = null; 831 } 832 } 833 834 // Update the toolbar button |node| with the tab context data 835 // in |tabData|. 836 updateButton( 837 node, 838 tabData, 839 sync = false, 840 attention = false, 841 quarantined = false 842 ) { 843 // This is the primary/action button in the custom widget. 844 let button = node.querySelector(".unified-extensions-item-action-button"); 845 let extensionTitle = tabData.title || this.extension.name; 846 847 let policy = WebExtensionPolicy.getByID(this.extension.id); 848 let messages = OriginControls.getStateMessageIDs({ 849 policy, 850 tab: node.ownerGlobal.gBrowser.selectedTab, 851 isAction: true, 852 hasPopup: !!tabData.popup, 853 }); 854 855 let callback = () => { 856 // This is set on the node so that it looks good in the toolbar. 857 node.toggleAttribute("attention", attention); 858 859 let msgId = "origin-controls-toolbar-button"; 860 if (attention) { 861 msgId = quarantined 862 ? "origin-controls-toolbar-button-quarantined" 863 : "origin-controls-toolbar-button-permission-needed"; 864 } 865 node.ownerDocument.l10n.setAttributes(button, msgId, { extensionTitle }); 866 867 button.querySelector(".unified-extensions-item-name").textContent = 868 this.extension?.name; 869 870 if (messages) { 871 const messageDefaultElement = button.querySelector( 872 ".unified-extensions-item-message-default" 873 ); 874 node.ownerDocument.l10n.setAttributes( 875 messageDefaultElement, 876 messages.default 877 ); 878 879 const messageHoverElement = button.querySelector( 880 ".unified-extensions-item-message-hover" 881 ); 882 node.ownerDocument.l10n.setAttributes( 883 messageHoverElement, 884 messages.onHover || messages.default 885 ); 886 } 887 888 if (tabData.badgeText) { 889 button.setAttribute("badge", tabData.badgeText); 890 } else { 891 button.removeAttribute("badge"); 892 } 893 894 if (tabData.enabled) { 895 button.removeAttribute("disabled"); 896 } else { 897 button.setAttribute("disabled", "true"); 898 } 899 900 let serializeColor = ([r, g, b, a]) => 901 `rgba(${r}, ${g}, ${b}, ${a / 255})`; 902 button.setAttribute( 903 "badgeStyle", 904 [ 905 `background-color: ${serializeColor(tabData.badgeBackgroundColor)}`, 906 `color: ${serializeColor(this.action.getTextColor(tabData))}`, 907 ].join("; ") 908 ); 909 910 let style = this.iconData.get(tabData.icon); 911 button.setAttribute("style", style); 912 913 // Refresh the unified extensions panel item messagebar 914 // (e.g. in response to blocklistState changes). 915 const messagebarWrapper = node.querySelector( 916 "unified-extensions-item-messagebar-wrapper" 917 ); 918 // NOTE: if the refresh() method isn't found, that's because the 919 // custom element has not been loaded yet. When the custom element 920 // is loaded and registered, connectedCallback() will call refresh() 921 // internally. 922 messagebarWrapper.refresh?.(); 923 }; 924 if (sync) { 925 callback(); 926 } else { 927 node.ownerGlobal.requestAnimationFrame(callback); 928 } 929 } 930 931 getIconData(icons) { 932 const getIcon = (icon, theme) => { 933 if (typeof icon === "object") { 934 return IconDetails.escapeUrl(icon[theme]); 935 } 936 return IconDetails.escapeUrl(icon); 937 }; 938 939 const getBackgroundImage = (icon1x, icon2x = icon1x) => { 940 const image1x = `url("${icon1x}")`; 941 if (icon2x === icon1x) { 942 return image1x; 943 } 944 945 const image2x = `url("${icon2x}")`; 946 return `image-set(${image1x} 1dppx, ${image2x} 2dppx);`; 947 }; 948 949 const getStyle = (cssVarName, icon1x, icon2x) => { 950 return `${cssVarName}: ${getBackgroundImage( 951 getIcon(icon1x, "light"), 952 getIcon(icon2x, "light") 953 )}; 954 ${cssVarName}-dark: ${getBackgroundImage( 955 getIcon(icon1x, "dark"), 956 getIcon(icon2x, "dark") 957 )};`; 958 }; 959 960 const icon16 = IconDetails.getPreferredIcon(icons, this.extension, 16).icon; 961 const icon32 = IconDetails.getPreferredIcon(icons, this.extension, 32).icon; 962 const icon64 = IconDetails.getPreferredIcon(icons, this.extension, 64).icon; 963 964 return ` 965 ${getStyle("--webextension-menupanel-image", icon32, icon64)} 966 ${getStyle("--webextension-toolbar-image", icon16, icon32)} 967 `; 968 } 969 970 /** 971 * Update the toolbar button for a given window. 972 * 973 * @param {ChromeWindow} window 974 * Browser chrome window. 975 */ 976 updateWindow(window) { 977 let node = this.widget.forWindow(window).node; 978 if (node) { 979 let tab = window.gBrowser.selectedTab; 980 let { attention, quarantined } = OriginControls.getAttentionState( 981 this.extension.policy, 982 window 983 ); 984 985 this.updateButton( 986 node, 987 this.action.getContextData(tab), 988 /* sync */ false, 989 attention, 990 quarantined 991 ); 992 } 993 } 994 995 PERSISTENT_EVENTS = { 996 onClicked({ context, fire }) { 997 const { extension } = this; 998 const { tabManager } = extension; 999 async function listener(_event, tab, clickInfo) { 1000 if (fire.wakeup) { 1001 await fire.wakeup(); 1002 } 1003 // TODO: we should double-check if the tab is already being closed by the time 1004 // the background script got started and we converted the primed listener. 1005 context?.withPendingBrowser(tab.linkedBrowser, () => 1006 fire.sync(tabManager.convert(tab), clickInfo) 1007 ); 1008 } 1009 this.on("click", listener); 1010 return { 1011 unregister: () => { 1012 this.off("click", listener); 1013 }, 1014 convert(newFire, extContext) { 1015 fire = newFire; 1016 context = extContext; 1017 }, 1018 }; 1019 }, 1020 onUserSettingsChanged({ fire }) { 1021 let listener = { 1022 onWidgetRemoved: (widgetId, oldArea) => { 1023 if (widgetId !== this.id) { 1024 return; 1025 } 1026 1027 if (oldArea === CustomizableUI.AREA_ADDONS) { 1028 fire.async({ isOnToolbar: true }); 1029 } 1030 }, 1031 onWidgetAdded: (widgetId, newArea) => { 1032 if (widgetId !== this.id) { 1033 return; 1034 } 1035 1036 if (newArea === CustomizableUI.AREA_ADDONS) { 1037 fire.async({ isOnToolbar: false }); 1038 } 1039 }, 1040 }; 1041 CustomizableUI.addListener(listener); 1042 return { 1043 unregister: () => { 1044 CustomizableUI.removeListener(listener); 1045 }, 1046 convert(newFire) { 1047 fire = newFire; 1048 }, 1049 }; 1050 }, 1051 }; 1052 1053 getAPI(context) { 1054 let { extension } = context; 1055 let { action } = this; 1056 let namespace = extension.manifestVersion < 3 ? "browserAction" : "action"; 1057 1058 return { 1059 [namespace]: { 1060 ...action.api(context), 1061 1062 onClicked: new EventManager({ 1063 context, 1064 // module name is "browserAction" because it the name used in the 1065 // ext-browser.json, independently from the manifest version. 1066 module: "browserAction", 1067 event: "onClicked", 1068 inputHandling: true, 1069 extensionApi: this, 1070 }).api(), 1071 1072 onUserSettingsChanged: new EventManager({ 1073 context, 1074 // module name is "browserAction" because it the name used in the 1075 // ext-browser.json, independently from the manifest version. 1076 module: "browserAction", 1077 event: "onUserSettingsChanged", 1078 extensionApi: this, 1079 }).api(), 1080 1081 getUserSettings: () => { 1082 let { area } = CustomizableUI.getPlacementOfWidget( 1083 action.buttonDelegate.id 1084 ); 1085 return { isOnToolbar: area !== CustomizableUI.AREA_ADDONS }; 1086 }, 1087 openPopup: async options => { 1088 const isHandlingUserInput = 1089 context.callContextData?.isHandlingUserInput; 1090 1091 if ( 1092 !Services.prefs.getBoolPref( 1093 "extensions.openPopupWithoutUserGesture.enabled" 1094 ) && 1095 !isHandlingUserInput 1096 ) { 1097 throw new ExtensionError("openPopup requires a user gesture"); 1098 } 1099 1100 const window = 1101 typeof options?.windowId === "number" 1102 ? windowTracker.getWindow(options.windowId, context) 1103 : windowTracker.getTopNormalWindow(context); 1104 1105 if (this.action.getPopupUrl(window.gBrowser.selectedTab, true)) { 1106 await this.openPopup(window, !isHandlingUserInput); 1107 } 1108 }, 1109 }, 1110 }; 1111 } 1112 }; 1113 1114 global.browserActionFor = this.browserAction.for;