tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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;