tor-browser

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

ext-menus.js (43116B)


      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  ExtensionMenus: "resource://gre/modules/ExtensionMenus.sys.mjs",
     11  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
     12  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
     13 });
     14 
     15 var { DefaultMap, ExtensionError, parseMatchPatterns } = ExtensionUtils;
     16 
     17 var { ExtensionParent } = ChromeUtils.importESModule(
     18  "resource://gre/modules/ExtensionParent.sys.mjs"
     19 );
     20 
     21 var { IconDetails } = ExtensionParent;
     22 
     23 const ACTION_MENU_TOP_LEVEL_LIMIT = 6;
     24 
     25 // Map[Extension -> Map[ID -> MenuItem]]
     26 // Note: we want to enumerate all the menu items so
     27 // this cannot be a weak map.
     28 var gMenuMap = new Map();
     29 
     30 // Map[Extension -> MenuItem]
     31 var gRootItems = new Map();
     32 
     33 // Map[Extension -> ID[]]
     34 // Menu IDs that were eligible for being shown in the current menu.
     35 var gShownMenuItems = new DefaultMap(() => []);
     36 
     37 // Map[Extension -> Set[Contexts]]
     38 // A DefaultMap (keyed by extension) which keeps track of the
     39 // contexts with a subscribed onShown event listener.
     40 var gOnShownSubscribers = new DefaultMap(() => new Set());
     41 
     42 // If id is not specified for an item we use an integer.
     43 var gNextMenuItemID = 0;
     44 
     45 // Used to assign unique names to radio groups.
     46 var gNextRadioGroupID = 0;
     47 
     48 // The max length of a menu item's label.
     49 var gMaxLabelLength = 64;
     50 
     51 var gMenuBuilder = {
     52  // When a new menu is opened, this function is called and
     53  // we populate the |xulMenu| with all the items from extensions
     54  // to be displayed. We always clear all the items again when
     55  // popuphidden fires.
     56  build(contextData) {
     57    contextData = this.maybeOverrideContextData(contextData);
     58    let xulMenu = contextData.menu;
     59    xulMenu.addEventListener("popuphidden", this);
     60    this.xulMenu = xulMenu;
     61    for (let [, root] of gRootItems) {
     62      this.createAndInsertTopLevelElements(root, contextData, null);
     63    }
     64    this.afterBuildingMenu(contextData);
     65 
     66    if (
     67      contextData.webExtContextData &&
     68      !contextData.webExtContextData.showDefaults
     69    ) {
     70      // Wait until nsContextMenu.js has toggled the visibility of the default
     71      // menu items before hiding the default items.
     72      Promise.resolve().then(() => this.hideDefaultMenuItems());
     73    }
     74  },
     75 
     76  maybeOverrideContextData(contextData) {
     77    let { webExtContextData } = contextData;
     78    if (!webExtContextData || !webExtContextData.overrideContext) {
     79      return contextData;
     80    }
     81    let contextDataBase = {
     82      menu: contextData.menu,
     83      // eslint-disable-next-line no-use-before-define
     84      originalViewType: getContextViewType(contextData),
     85      originalViewUrl: contextData.inFrame
     86        ? contextData.frameUrl
     87        : contextData.pageUrl,
     88      webExtContextData,
     89    };
     90    if (webExtContextData.overrideContext === "bookmark") {
     91      return {
     92        ...contextDataBase,
     93        bookmarkId: webExtContextData.bookmarkId,
     94        onBookmark: true,
     95      };
     96    }
     97    if (webExtContextData.overrideContext === "tab") {
     98      // TODO: Handle invalid tabs more gracefully (instead of throwing).
     99      let tab = tabTracker.getTab(webExtContextData.tabId);
    100      return {
    101        ...contextDataBase,
    102        tab,
    103        pageUrl: tab.linkedBrowser.currentURI.spec,
    104        onTab: true,
    105      };
    106    }
    107    throw new Error(
    108      `Unexpected overrideContext: ${webExtContextData.overrideContext}`
    109    );
    110  },
    111 
    112  canAccessContext(extension, contextData) {
    113    if (!extension.privateBrowsingAllowed) {
    114      let nativeTab = contextData.tab;
    115      if (
    116        nativeTab &&
    117        PrivateBrowsingUtils.isBrowserPrivate(nativeTab.linkedBrowser)
    118      ) {
    119        return false;
    120      } else if (
    121        PrivateBrowsingUtils.isWindowPrivate(contextData.menu.ownerGlobal)
    122      ) {
    123        return false;
    124      }
    125    }
    126    return true;
    127  },
    128 
    129  createAndInsertTopLevelElements(root, contextData, nextSibling) {
    130    let rootElements;
    131    if (!this.canAccessContext(root.extension, contextData)) {
    132      return;
    133    }
    134    if (
    135      contextData.onAction ||
    136      contextData.onBrowserAction ||
    137      contextData.onPageAction
    138    ) {
    139      if (contextData.extension.id !== root.extension.id) {
    140        return;
    141      }
    142      rootElements = this.buildTopLevelElements(
    143        root,
    144        contextData,
    145        ACTION_MENU_TOP_LEVEL_LIMIT,
    146        false
    147      );
    148 
    149      // Action menu items are prepended to the menu, followed by a separator.
    150      nextSibling = nextSibling || this.xulMenu.firstElementChild;
    151      if (rootElements.length && !this.itemsToCleanUp.has(nextSibling)) {
    152        rootElements.push(
    153          this.xulMenu.ownerDocument.createXULElement("menuseparator")
    154        );
    155      }
    156    } else if (contextData.webExtContextData) {
    157      let { extensionId, showDefaults, overrideContext } =
    158        contextData.webExtContextData;
    159      if (extensionId === root.extension.id) {
    160        rootElements = this.buildTopLevelElements(
    161          root,
    162          contextData,
    163          Infinity,
    164          false
    165        );
    166        if (!nextSibling) {
    167          // The extension menu should be rendered at the top. If we use
    168          // a navigation group (on non-macOS), the extension menu should
    169          // come after that to avoid styling issues.
    170          if (AppConstants.platform == "macosx") {
    171            nextSibling = this.xulMenu.firstElementChild;
    172          } else {
    173            nextSibling = this.xulMenu.querySelector(
    174              ":scope > #context-sep-navigation + *"
    175            );
    176          }
    177        }
    178        if (
    179          rootElements.length &&
    180          showDefaults &&
    181          !this.itemsToCleanUp.has(nextSibling)
    182        ) {
    183          rootElements.push(
    184            this.xulMenu.ownerDocument.createXULElement("menuseparator")
    185          );
    186        }
    187      } else if (!showDefaults && !overrideContext) {
    188        // When the default menu items should be hidden, menu items from other
    189        // extensions should be hidden too.
    190        return;
    191      }
    192      // Fall through to show default extension menu items.
    193    }
    194    if (!rootElements) {
    195      rootElements = this.buildTopLevelElements(root, contextData, 1, true);
    196      if (
    197        rootElements.length &&
    198        !this.itemsToCleanUp.has(this.xulMenu.lastElementChild)
    199      ) {
    200        // All extension menu items are appended at the end.
    201        // Prepend separator if this is the first extension menu item.
    202        rootElements.unshift(
    203          this.xulMenu.ownerDocument.createXULElement("menuseparator")
    204        );
    205      }
    206    }
    207 
    208    if (!rootElements.length) {
    209      return;
    210    }
    211 
    212    if (nextSibling) {
    213      nextSibling.before(...rootElements);
    214    } else {
    215      this.xulMenu.append(...rootElements);
    216    }
    217    for (let item of rootElements) {
    218      this.itemsToCleanUp.add(item);
    219    }
    220  },
    221 
    222  buildElementWithChildren(item, contextData) {
    223    const element = this.buildSingleElement(item, contextData);
    224    const children = this.buildChildren(item, contextData);
    225    if (children.length) {
    226      element.firstElementChild.append(...children);
    227    }
    228    return element;
    229  },
    230 
    231  buildChildren(item, contextData) {
    232    let groupName;
    233    let children = [];
    234    for (let child of item.children) {
    235      if (child.type == "radio" && !child.groupName) {
    236        if (!groupName) {
    237          groupName = `webext-radio-group-${gNextRadioGroupID++}`;
    238        }
    239        child.groupName = groupName;
    240      } else {
    241        groupName = null;
    242      }
    243 
    244      if (child.enabledForContext(contextData)) {
    245        children.push(this.buildElementWithChildren(child, contextData));
    246      }
    247    }
    248    return children;
    249  },
    250 
    251  buildTopLevelElements(root, contextData, maxCount, forceManifestIcons) {
    252    let children = this.buildChildren(root, contextData);
    253 
    254    // TODO: Fix bug 1492969 and remove this whole if block.
    255    if (
    256      children.length === 1 &&
    257      maxCount === 1 &&
    258      forceManifestIcons &&
    259      AppConstants.platform === "linux" &&
    260      children[0].getAttribute("type") === "checkbox"
    261    ) {
    262      // Keep single checkbox items in the submenu on Linux since
    263      // the extension icon overlaps the checkbox otherwise.
    264      maxCount = 0;
    265    }
    266 
    267    if (children.length > maxCount) {
    268      // Move excess items into submenu.
    269      let rootElement = this.buildSingleElement(root, contextData);
    270      rootElement.setAttribute("ext-type", "top-level-menu");
    271      rootElement.firstElementChild.append(...children.splice(maxCount - 1));
    272      children.push(rootElement);
    273    }
    274 
    275    if (forceManifestIcons) {
    276      for (let rootElement of children) {
    277        // Display the extension icon on the root element.
    278        if (
    279          root.extension.manifest.icons &&
    280          rootElement.getAttribute("type") !== "checkbox"
    281        ) {
    282          this.setMenuItemIcon(
    283            rootElement,
    284            root.extension,
    285            contextData,
    286            root.extension.manifest.icons
    287          );
    288        } else {
    289          this.removeMenuItemIcon(rootElement);
    290        }
    291      }
    292    }
    293    return children;
    294  },
    295 
    296  buildSingleElement(item, contextData) {
    297    let doc = contextData.menu.ownerDocument;
    298    let element;
    299    if (item.children.length) {
    300      element = this.createMenuElement(doc, item);
    301    } else if (item.type == "separator") {
    302      element = doc.createXULElement("menuseparator");
    303    } else {
    304      element = doc.createXULElement("menuitem");
    305    }
    306 
    307    return this.customizeElement(element, item, contextData);
    308  },
    309 
    310  createMenuElement(doc) {
    311    let element = doc.createXULElement("menu");
    312    // Menu elements need to have a menupopup child for its menu items.
    313    let menupopup = doc.createXULElement("menupopup");
    314    element.appendChild(menupopup);
    315    return element;
    316  },
    317 
    318  customizeElement(element, item, contextData) {
    319    let label = item.title;
    320    if (label) {
    321      let accessKey;
    322      label = label.replace(/&([\S\s]|$)/g, (_, nextChar, i) => {
    323        if (nextChar === "&") {
    324          return "&";
    325        }
    326        if (accessKey === undefined) {
    327          if (nextChar === "%" && label.charAt(i + 2) === "s") {
    328            accessKey = "";
    329          } else {
    330            accessKey = nextChar;
    331          }
    332        }
    333        return nextChar;
    334      });
    335      element.setAttribute("accesskey", accessKey || "");
    336 
    337      if (contextData.isTextSelected && label.indexOf("%s") > -1) {
    338        let selection = contextData.selectionText.trim();
    339        // The rendering engine will truncate the title if it's longer than 64 characters.
    340        // But if it makes sense let's try truncate selection text only, to handle cases like
    341        // 'look up "%s" in MyDictionary' more elegantly.
    342 
    343        let codePointsToRemove = 0;
    344 
    345        let selectionArray = Array.from(selection);
    346 
    347        let completeLabelLength = label.length - 2 + selectionArray.length;
    348        if (completeLabelLength > gMaxLabelLength) {
    349          codePointsToRemove = completeLabelLength - gMaxLabelLength;
    350        }
    351 
    352        if (codePointsToRemove) {
    353          codePointsToRemove += 1;
    354          selection =
    355            selectionArray.slice(0, -codePointsToRemove).join("") +
    356            Services.locale.ellipsis;
    357        }
    358 
    359        label = label.replace(/%s/g, selection);
    360      }
    361 
    362      element.setAttribute("label", label);
    363    }
    364 
    365    element.setAttribute("id", item.elementId);
    366 
    367    if ("icons" in item) {
    368      if (item.icons) {
    369        this.setMenuItemIcon(element, item.extension, contextData, item.icons);
    370      } else {
    371        this.removeMenuItemIcon(element);
    372      }
    373    }
    374 
    375    if (item.type == "checkbox") {
    376      element.setAttribute("type", "checkbox");
    377      if (item.checked) {
    378        element.setAttribute("checked", "true");
    379      }
    380    } else if (item.type == "radio") {
    381      element.setAttribute("type", "radio");
    382      element.setAttribute("name", item.groupName);
    383      if (item.checked) {
    384        element.setAttribute("checked", "true");
    385      }
    386    }
    387 
    388    if (!item.enabled) {
    389      element.setAttribute("disabled", "true");
    390    }
    391 
    392    element.addEventListener(
    393      "command",
    394      event => {
    395        if (event.target !== event.currentTarget) {
    396          return;
    397        }
    398        const wasChecked = item.checked;
    399        if (item.type == "checkbox") {
    400          item.checked = !item.checked;
    401        } else if (item.type == "radio") {
    402          // Deselect all radio items in the current radio group.
    403          for (let child of item.parent.children) {
    404            if (child.type == "radio" && child.groupName == item.groupName) {
    405              child.checked = false;
    406            }
    407          }
    408          // Select the clicked radio item.
    409          item.checked = true;
    410        }
    411 
    412        let { webExtContextData } = contextData;
    413        if (
    414          contextData.tab &&
    415          // If the menu context was overridden by the extension, do not grant
    416          // activeTab since the extension also controls the tabId.
    417          (!webExtContextData ||
    418            webExtContextData.extensionId !== item.extension.id)
    419        ) {
    420          item.tabManager.addActiveTabPermission(contextData.tab);
    421        }
    422 
    423        let info = item.getClickInfo(contextData, wasChecked);
    424        info.modifiers = clickModifiersFromEvent(event);
    425 
    426        info.button = event.button;
    427 
    428        let _execute_action =
    429          item.extension.manifestVersion < 3
    430            ? "_execute_browser_action"
    431            : "_execute_action";
    432 
    433        // Allow menus to open various actions supported in webext prior
    434        // to notifying onclicked.
    435        let actionFor = {
    436          [_execute_action]: global.browserActionFor,
    437          _execute_page_action: global.pageActionFor,
    438          _execute_sidebar_action: global.sidebarActionFor,
    439        }[item.command];
    440        if (actionFor) {
    441          let win = event.target.ownerGlobal;
    442          actionFor(item.extension).triggerAction(win);
    443          return;
    444        }
    445 
    446        item.extension.emit(
    447          "webext-menu-menuitem-click",
    448          info,
    449          contextData.tab
    450        );
    451      },
    452      { once: true }
    453    );
    454 
    455    // Don't publish the ID of the root because the root element is
    456    // auto-generated.
    457    if (item.parent) {
    458      gShownMenuItems.get(item.extension).push(item.id);
    459    }
    460 
    461    return element;
    462  },
    463 
    464  setMenuItemIcon(element, extension, contextData, icons) {
    465    let parentWindow = contextData.menu.ownerGlobal;
    466 
    467    let { icon } = IconDetails.getPreferredIcon(
    468      icons,
    469      extension,
    470      16 * parentWindow.devicePixelRatio
    471    );
    472 
    473    // The extension icons in the manifest are not pre-resolved, since
    474    // they're sometimes used by the add-on manager when the extension is
    475    // not enabled, and its URLs are not resolvable.
    476    let resolvedURL = extension.baseURI.resolve(icon);
    477 
    478    if (element.localName == "menu") {
    479      element.setAttribute("class", "menu-iconic");
    480    } else if (element.localName == "menuitem") {
    481      element.setAttribute("class", "menuitem-iconic");
    482    }
    483 
    484    element.setAttribute("image", ChromeUtils.encodeURIForSrcset(resolvedURL));
    485  },
    486 
    487  // Undo changes from setMenuItemIcon.
    488  removeMenuItemIcon(element) {
    489    element.removeAttribute("class");
    490    element.removeAttribute("image");
    491  },
    492 
    493  rebuildMenu(extension) {
    494    let { contextData } = this;
    495    if (!contextData) {
    496      // This happens if the menu is not visible.
    497      return;
    498    }
    499 
    500    // Find the group of existing top-level items (usually 0 or 1 items)
    501    // and remember its position for when the new items are inserted.
    502    let elementIdPrefix = `${makeWidgetId(extension.id)}-menuitem-`;
    503    let nextSibling = null;
    504    for (let item of this.itemsToCleanUp) {
    505      if (item.id && item.id.startsWith(elementIdPrefix)) {
    506        nextSibling = item.nextSibling;
    507        item.remove();
    508        this.itemsToCleanUp.delete(item);
    509      }
    510    }
    511 
    512    let root = gRootItems.get(extension);
    513    if (root) {
    514      this.createAndInsertTopLevelElements(root, contextData, nextSibling);
    515    }
    516 
    517    this.xulMenu.showHideSeparators?.();
    518  },
    519 
    520  // This should be called once, after constructing the top-level menus, if any.
    521  afterBuildingMenu(contextData) {
    522    let dispatchOnShownEvent = extension => {
    523      if (!this.canAccessContext(extension, contextData)) {
    524        return;
    525      }
    526 
    527      // Note: gShownMenuItems is a DefaultMap, so .get(extension) causes the
    528      // extension to be stored in the map even if there are currently no
    529      // shown menu items. This ensures that the onHidden event can be fired
    530      // when the menu is closed.
    531      let menuIds = gShownMenuItems.get(extension);
    532      extension.emit("webext-menu-shown", menuIds, contextData);
    533    };
    534 
    535    if (
    536      contextData.onAction ||
    537      contextData.onBrowserAction ||
    538      contextData.onPageAction
    539    ) {
    540      dispatchOnShownEvent(contextData.extension);
    541    } else {
    542      for (const extension of gOnShownSubscribers.keys()) {
    543        dispatchOnShownEvent(extension);
    544      }
    545    }
    546 
    547    this.contextData = contextData;
    548  },
    549 
    550  hideDefaultMenuItems() {
    551    for (let item of this.xulMenu.children) {
    552      if (!this.itemsToCleanUp.has(item)) {
    553        item.hidden = true;
    554      }
    555    }
    556 
    557    if (this.xulMenu.showHideSeparators) {
    558      this.xulMenu.showHideSeparators();
    559    }
    560  },
    561 
    562  handleEvent(event) {
    563    if (this.xulMenu != event.target || event.type != "popuphidden") {
    564      return;
    565    }
    566 
    567    delete this.xulMenu;
    568    delete this.contextData;
    569 
    570    let target = event.target;
    571    target.removeEventListener("popuphidden", this);
    572    for (let item of this.itemsToCleanUp) {
    573      item.remove();
    574    }
    575    this.itemsToCleanUp.clear();
    576    for (let extension of gShownMenuItems.keys()) {
    577      extension.emit("webext-menu-hidden");
    578    }
    579    gShownMenuItems.clear();
    580  },
    581 
    582  itemsToCleanUp: new Set(),
    583 };
    584 
    585 // Called from pageAction or browserAction popup.
    586 global.actionContextMenu = function (contextData) {
    587  contextData.tab = tabTracker.activeTab;
    588  contextData.pageUrl = contextData.tab.linkedBrowser.currentURI.spec;
    589  gMenuBuilder.build(contextData);
    590 };
    591 
    592 const contextsMap = {
    593  onAudio: "audio",
    594  onEditable: "editable",
    595  inFrame: "frame",
    596  onImage: "image",
    597  onLink: "link",
    598  onPassword: "password",
    599  isTextSelected: "selection",
    600  onVideo: "video",
    601 
    602  onBookmark: "bookmark",
    603  onAction: "action",
    604  onBrowserAction: "browser_action",
    605  onPageAction: "page_action",
    606  onTab: "tab",
    607  inToolsMenu: "tools_menu",
    608 };
    609 
    610 const getMenuContexts = contextData => {
    611  let contexts = new Set();
    612 
    613  for (const [key, value] of Object.entries(contextsMap)) {
    614    if (contextData[key]) {
    615      contexts.add(value);
    616    }
    617  }
    618 
    619  if (contexts.size === 0) {
    620    contexts.add("page");
    621  }
    622 
    623  // New non-content contexts supported in Firefox are not part of "all".
    624  if (
    625    !contextData.onBookmark &&
    626    !contextData.onTab &&
    627    !contextData.inToolsMenu
    628  ) {
    629    contexts.add("all");
    630  }
    631 
    632  return contexts;
    633 };
    634 
    635 function getContextViewType(contextData) {
    636  if ("originalViewType" in contextData) {
    637    return contextData.originalViewType;
    638  }
    639  if (
    640    contextData.webExtBrowserType === "popup" ||
    641    contextData.webExtBrowserType === "sidebar"
    642  ) {
    643    return contextData.webExtBrowserType;
    644  }
    645  if (contextData.tab && contextData.menu.id === "contentAreaContextMenu") {
    646    return "tab";
    647  }
    648  return undefined;
    649 }
    650 
    651 function addMenuEventInfo(info, contextData, extension, includeSensitiveData) {
    652  info.viewType = getContextViewType(contextData);
    653  if (contextData.onVideo) {
    654    info.mediaType = "video";
    655  } else if (contextData.onAudio) {
    656    info.mediaType = "audio";
    657  } else if (contextData.onImage) {
    658    info.mediaType = "image";
    659  }
    660  if (contextData.frameId !== undefined) {
    661    info.frameId = contextData.frameId;
    662  }
    663  if (contextData.onBookmark) {
    664    info.bookmarkId = contextData.bookmarkId;
    665  }
    666  info.editable = contextData.onEditable || false;
    667  if (includeSensitiveData) {
    668    // menus.getTargetElement requires the "menus" permission, so do not set
    669    // targetElementId for extensions with only the "contextMenus" permission.
    670    if (contextData.timeStamp && extension.hasPermission("menus")) {
    671      // Convert to integer, in case the DOMHighResTimeStamp has a fractional part.
    672      info.targetElementId = Math.floor(contextData.timeStamp);
    673    }
    674    if (contextData.onLink) {
    675      info.linkText = contextData.linkText;
    676      info.linkUrl = contextData.linkUrl;
    677    }
    678    if (contextData.onAudio || contextData.onImage || contextData.onVideo) {
    679      info.srcUrl = contextData.srcUrl;
    680    }
    681    if (!contextData.onBookmark) {
    682      info.pageUrl = contextData.pageUrl;
    683    }
    684    if (contextData.inFrame) {
    685      info.frameUrl = contextData.frameUrl;
    686    }
    687    if (contextData.isTextSelected) {
    688      info.selectionText = contextData.selectionText;
    689    }
    690  }
    691  // If the context was overridden, then frameUrl should be the URL of the
    692  // document in which the menu was opened (instead of undefined, even if that
    693  // document is not in a frame).
    694  if (contextData.originalViewUrl) {
    695    info.frameUrl = contextData.originalViewUrl;
    696  }
    697 }
    698 
    699 class MenuItem {
    700  constructor(extension, createProperties, isRoot = false) {
    701    this.extension = extension;
    702    this.children = [];
    703    this.parent = null;
    704    this.tabManager = extension.tabManager;
    705 
    706    this.setDefaults();
    707    this.setProps(createProperties);
    708 
    709    if (!this.hasOwnProperty("_id")) {
    710      this.id = gNextMenuItemID++;
    711    }
    712    // If the item is not the root and has no parent
    713    // it must be a child of the root.
    714    if (!isRoot && !this.parent) {
    715      this.root.addChild(this);
    716    }
    717  }
    718 
    719  setProps(createProperties) {
    720    ExtensionMenus.mergeMenuProperties(this, createProperties);
    721 
    722    if (createProperties.documentUrlPatterns != null) {
    723      this.documentUrlMatchPattern = parseMatchPatterns(
    724        this.documentUrlPatterns,
    725        {
    726          restrictSchemes: this.extension.restrictSchemes,
    727        }
    728      );
    729    }
    730 
    731    if (createProperties.targetUrlPatterns != null) {
    732      this.targetUrlMatchPattern = parseMatchPatterns(this.targetUrlPatterns, {
    733        // restrictSchemes default to false when matching links instead of pages
    734        // (see Bug 1280370 for a rationale).
    735        restrictSchemes: false,
    736      });
    737    }
    738 
    739    // If a child MenuItem does not specify any contexts, then it should
    740    // inherit the contexts specified from its parent.
    741    if (createProperties.parentId && !createProperties.contexts) {
    742      this.contexts = this.parent.contexts;
    743    }
    744  }
    745 
    746  setDefaults() {
    747    this.setProps({
    748      type: "normal",
    749      checked: false,
    750      contexts: ["all"],
    751      enabled: true,
    752      visible: true,
    753    });
    754  }
    755 
    756  set id(id) {
    757    if (this.hasOwnProperty("_id")) {
    758      throw new ExtensionError("ID of a MenuItem cannot be changed");
    759    }
    760    let isIdUsed = gMenuMap.get(this.extension).has(id);
    761    if (isIdUsed) {
    762      throw new ExtensionError(`ID already exists: ${id}`);
    763    }
    764    this._id = id;
    765  }
    766 
    767  get id() {
    768    return this._id;
    769  }
    770 
    771  get elementId() {
    772    let id = this.id;
    773    // If the ID is an integer, it is auto-generated and globally unique.
    774    // If the ID is a string, it is only unique within one extension and the
    775    // ID needs to be concatenated with the extension ID.
    776    if (typeof id !== "number") {
    777      // To avoid collisions with numeric IDs, add a prefix to string IDs.
    778      id = `_${id}`;
    779    }
    780    return `${makeWidgetId(this.extension.id)}-menuitem-${id}`;
    781  }
    782 
    783  ensureValidParentId(parentId) {
    784    if (parentId === undefined) {
    785      return;
    786    }
    787    let menuMap = gMenuMap.get(this.extension);
    788    if (!menuMap.has(parentId)) {
    789      throw new ExtensionError(`Cannot find menu item with id ${parentId}`);
    790    }
    791    for (let item = menuMap.get(parentId); item; item = item.parent) {
    792      if (item === this) {
    793        throw new ExtensionError(
    794          "MenuItem cannot be an ancestor (or self) of its new parent."
    795        );
    796      }
    797    }
    798  }
    799 
    800  set parentId(parentId) {
    801    this.ensureValidParentId(parentId);
    802 
    803    if (this.parent) {
    804      this.parent.detachChild(this);
    805    }
    806 
    807    if (parentId === undefined) {
    808      this.root.addChild(this);
    809    } else {
    810      let menuMap = gMenuMap.get(this.extension);
    811      menuMap.get(parentId).addChild(this);
    812    }
    813  }
    814 
    815  get parentId() {
    816    return this.parent ? this.parent.id : undefined;
    817  }
    818 
    819  addChild(child) {
    820    if (child.parent) {
    821      throw new Error("Child MenuItem already has a parent.");
    822    }
    823    this.children.push(child);
    824    child.parent = this;
    825  }
    826 
    827  detachChild(child) {
    828    let idx = this.children.indexOf(child);
    829    if (idx < 0) {
    830      throw new Error("Child MenuItem not found, it cannot be removed.");
    831    }
    832    this.children.splice(idx, 1);
    833    child.parent = null;
    834  }
    835 
    836  get root() {
    837    let extension = this.extension;
    838    if (!gRootItems.has(extension)) {
    839      let root = new MenuItem(
    840        extension,
    841        { title: extension.name },
    842        /* isRoot = */ true
    843      );
    844      gRootItems.set(extension, root);
    845    }
    846 
    847    return gRootItems.get(extension);
    848  }
    849 
    850  get descendantIds() {
    851    return this.children
    852      ? this.children.flatMap(m => [m.id, ...m.descendantIds])
    853      : [];
    854  }
    855 
    856  remove() {
    857    if (this.parent) {
    858      this.parent.detachChild(this);
    859    }
    860    let children = this.children.slice(0);
    861    for (let child of children) {
    862      child.remove();
    863    }
    864 
    865    let menuMap = gMenuMap.get(this.extension);
    866    menuMap.delete(this.id);
    867    if (this.root == this) {
    868      gRootItems.delete(this.extension);
    869    }
    870  }
    871 
    872  getClickInfo(contextData, wasChecked) {
    873    let info = {
    874      menuItemId: this.id,
    875    };
    876    if (this.parent) {
    877      info.parentMenuItemId = this.parentId;
    878    }
    879 
    880    addMenuEventInfo(info, contextData, this.extension, true);
    881 
    882    if (this.type === "checkbox" || this.type === "radio") {
    883      info.checked = this.checked;
    884      info.wasChecked = wasChecked;
    885    }
    886 
    887    return info;
    888  }
    889 
    890  enabledForContext(contextData) {
    891    if (!this.visible) {
    892      return false;
    893    }
    894    let contexts = getMenuContexts(contextData);
    895    if (!this.contexts.some(n => contexts.has(n))) {
    896      return false;
    897    }
    898 
    899    if (
    900      this.viewTypes &&
    901      !this.viewTypes.includes(getContextViewType(contextData))
    902    ) {
    903      return false;
    904    }
    905 
    906    let docPattern = this.documentUrlMatchPattern;
    907    // When viewTypes is specified, the menu item is expected to be restricted
    908    // to documents. So let documentUrlPatterns always apply to the URL of the
    909    // document in which the menu was opened. When maybeOverrideContextData
    910    // changes the context, contextData.pageUrl does not reflect that URL any
    911    // more, so use contextData.originalViewUrl instead.
    912    if (docPattern && this.viewTypes && contextData.originalViewUrl) {
    913      if (
    914        !docPattern.matches(Services.io.newURI(contextData.originalViewUrl))
    915      ) {
    916        return false;
    917      }
    918      docPattern = null; // Null it so that it won't be used with pageURI below.
    919    }
    920 
    921    if (contextData.onBookmark) {
    922      return this.extension.hasPermission("bookmarks");
    923    }
    924 
    925    let pageURI = Services.io.newURI(
    926      contextData[contextData.inFrame ? "frameUrl" : "pageUrl"]
    927    );
    928    if (docPattern && !docPattern.matches(pageURI)) {
    929      return false;
    930    }
    931 
    932    let targetPattern = this.targetUrlMatchPattern;
    933    if (targetPattern) {
    934      let targetURIs = [];
    935      if (contextData.onImage || contextData.onAudio || contextData.onVideo) {
    936        // TODO: double check if srcUrl is always set when we need it
    937        targetURIs.push(Services.io.newURI(contextData.srcUrl));
    938      }
    939      // contextData.linkURI may be null despite contextData.onLink, when
    940      // contextData.linkUrl is an invalid URL.
    941      if (contextData.onLink && contextData.linkURI) {
    942        targetURIs.push(contextData.linkURI);
    943      }
    944      if (!targetURIs.some(targetURI => targetPattern.matches(targetURI))) {
    945        return false;
    946      }
    947    }
    948 
    949    return true;
    950  }
    951 }
    952 
    953 // windowTracker only looks as browser windows, but we're also interested in
    954 // the Library window.  Helper for menuTracker below.
    955 const libraryTracker = {
    956  libraryWindowType: "Places:Organizer",
    957 
    958  isLibraryWindow(window) {
    959    let winType = window.document.documentElement.getAttribute("windowtype");
    960    return winType === this.libraryWindowType;
    961  },
    962 
    963  init(listener) {
    964    this._listener = listener;
    965    Services.ww.registerNotification(this);
    966 
    967    // See WindowTrackerBase#*browserWindows in ext-tabs-base.js for why we
    968    // can't use the enumerator's windowtype filter.
    969    for (let window of Services.wm.getEnumerator("")) {
    970      if (windowTracker.isBrowserWindowInitialized(window)) {
    971        if (this.isLibraryWindow(window)) {
    972          this.notify(window);
    973        }
    974      } else {
    975        window.addEventListener("load", this, { once: true });
    976      }
    977    }
    978  },
    979 
    980  // cleanupWindow is called on any library window that's open.
    981  uninit(cleanupWindow) {
    982    Services.ww.unregisterNotification(this);
    983 
    984    for (let window of Services.wm.getEnumerator("")) {
    985      window.removeEventListener("load", this);
    986      try {
    987        if (this.isLibraryWindow(window)) {
    988          cleanupWindow(window);
    989        }
    990      } catch (e) {
    991        Cu.reportError(e);
    992      }
    993    }
    994  },
    995 
    996  // Gets notifications from Services.ww.registerNotification.
    997  // Defer actually doing anything until the window's loaded, though.
    998  observe(window, topic) {
    999    if (topic === "domwindowopened") {
   1000      window.addEventListener("load", this, { once: true });
   1001    }
   1002  },
   1003 
   1004  // Gets the load event for new windows(registered in observe()).
   1005  handleEvent(event) {
   1006    let window = event.target.defaultView;
   1007    if (this.isLibraryWindow(window)) {
   1008      this.notify(window);
   1009    }
   1010  },
   1011 
   1012  notify(window) {
   1013    try {
   1014      this._listener.call(null, window);
   1015    } catch (e) {
   1016      Cu.reportError(e);
   1017    }
   1018  },
   1019 };
   1020 
   1021 // While any extensions are active, this Tracker registers to observe/listen
   1022 // for menu events from both Tools and context menus, both content and chrome.
   1023 const menuTracker = {
   1024  menuIds: ["placesContext", "menu_ToolsPopup", "tabContextMenu"],
   1025 
   1026  register() {
   1027    Services.obs.addObserver(this, "on-build-contextmenu");
   1028    for (const window of windowTracker.browserWindows()) {
   1029      this.onWindowOpen(window);
   1030    }
   1031    windowTracker.addOpenListener(this.onWindowOpen);
   1032    libraryTracker.init(this.onLibraryOpen);
   1033  },
   1034 
   1035  unregister() {
   1036    Services.obs.removeObserver(this, "on-build-contextmenu");
   1037    for (const window of windowTracker.browserWindows()) {
   1038      this.cleanupWindow(window);
   1039    }
   1040    windowTracker.removeOpenListener(this.onWindowOpen);
   1041    libraryTracker.uninit(this.cleanupLibrary);
   1042  },
   1043 
   1044  observe(subject) {
   1045    subject = subject.wrappedJSObject;
   1046    gMenuBuilder.build(subject);
   1047  },
   1048 
   1049  async onWindowOpen(window) {
   1050    for (const id of menuTracker.menuIds) {
   1051      const menu = window.document.getElementById(id);
   1052      menu.addEventListener("popupshowing", menuTracker);
   1053    }
   1054 
   1055    const sidebarHeader = window.document.getElementById(
   1056      "sidebar-switcher-target"
   1057    );
   1058    sidebarHeader.addEventListener("SidebarShown", menuTracker.onSidebarShown);
   1059 
   1060    await window.SidebarController.promiseInitialized;
   1061 
   1062    if (
   1063      !window.closed &&
   1064      window.SidebarController.currentID === "viewBookmarksSidebar"
   1065    ) {
   1066      menuTracker.onSidebarShown({ currentTarget: sidebarHeader });
   1067    }
   1068  },
   1069 
   1070  cleanupWindow(window) {
   1071    for (const id of this.menuIds) {
   1072      const menu = window.document.getElementById(id);
   1073      menu.removeEventListener("popupshowing", this);
   1074    }
   1075 
   1076    const sidebarHeader = window.document.getElementById(
   1077      "sidebar-switcher-target"
   1078    );
   1079    sidebarHeader.removeEventListener("SidebarShown", this.onSidebarShown);
   1080 
   1081    if (window.SidebarController.currentID === "viewBookmarksSidebar") {
   1082      let sidebarBrowser = window.SidebarController.browser;
   1083      sidebarBrowser.removeEventListener("load", this.onSidebarShown);
   1084      const menu =
   1085        sidebarBrowser.contentDocument.getElementById("placesContext");
   1086      menu.removeEventListener("popupshowing", this.onBookmarksContextMenu);
   1087    }
   1088  },
   1089 
   1090  onSidebarShown(event) {
   1091    // The event target is an element in a browser window, so |window| will be
   1092    // the browser window that contains the sidebar.
   1093    const window = event.currentTarget.ownerGlobal;
   1094    if (window.SidebarController.currentID === "viewBookmarksSidebar") {
   1095      let sidebarBrowser = window.SidebarController.browser;
   1096      if (sidebarBrowser.contentDocument.readyState !== "complete") {
   1097        // SidebarController.currentID may be updated before the bookmark sidebar's
   1098        // document has finished loading. This sometimes happens when the
   1099        // sidebar is automatically shown when a new window is opened.
   1100        sidebarBrowser.addEventListener("load", menuTracker.onSidebarShown, {
   1101          once: true,
   1102        });
   1103        return;
   1104      }
   1105      const menu =
   1106        sidebarBrowser.contentDocument.getElementById("placesContext");
   1107      menu.addEventListener("popupshowing", menuTracker.onBookmarksContextMenu);
   1108    }
   1109  },
   1110 
   1111  onLibraryOpen(window) {
   1112    const menu = window.document.getElementById("placesContext");
   1113    menu.addEventListener("popupshowing", menuTracker.onBookmarksContextMenu);
   1114  },
   1115 
   1116  cleanupLibrary(window) {
   1117    const menu = window.document.getElementById("placesContext");
   1118    menu.removeEventListener(
   1119      "popupshowing",
   1120      menuTracker.onBookmarksContextMenu
   1121    );
   1122  },
   1123 
   1124  handleEvent(event) {
   1125    const menu = event.target;
   1126 
   1127    if (menu.id === "placesContext") {
   1128      const trigger = menu.triggerNode;
   1129      if (!trigger._placesNode?.bookmarkGuid) {
   1130        return;
   1131      }
   1132 
   1133      gMenuBuilder.build({
   1134        menu,
   1135        bookmarkId: trigger._placesNode.bookmarkGuid,
   1136        onBookmark: true,
   1137      });
   1138    }
   1139    if (menu.id === "menu_ToolsPopup") {
   1140      const tab = tabTracker.activeTab;
   1141      const pageUrl = tab.linkedBrowser.currentURI.spec;
   1142      gMenuBuilder.build({ menu, tab, pageUrl, inToolsMenu: true });
   1143    }
   1144    if (menu.id === "tabContextMenu") {
   1145      const tab = menu.ownerGlobal.TabContextMenu.contextTab;
   1146      const pageUrl = tab.linkedBrowser.currentURI.spec;
   1147      gMenuBuilder.build({ menu, tab, pageUrl, onTab: true });
   1148    }
   1149  },
   1150 
   1151  onBookmarksContextMenu(event) {
   1152    const menu = event.target;
   1153    const tree = menu.triggerNode.parentElement;
   1154    const cell = tree.getCellAt(event.x, event.y);
   1155    const node = tree.view.nodeForTreeIndex(cell.row);
   1156    const bookmarkId = node && PlacesUtils.getConcreteItemGuid(node);
   1157 
   1158    if (!bookmarkId || PlacesUtils.isVirtualLeftPaneItem(bookmarkId)) {
   1159      return;
   1160    }
   1161 
   1162    gMenuBuilder.build({ menu, bookmarkId, onBookmark: true });
   1163  },
   1164 };
   1165 
   1166 this.menusInternal = class extends ExtensionAPIPersistent {
   1167  #promiseInitialized = null;
   1168 
   1169  constructor(extension) {
   1170    super(extension);
   1171 
   1172    if (!gMenuMap.size) {
   1173      menuTracker.register();
   1174    }
   1175    gMenuMap.set(extension, new Map());
   1176  }
   1177 
   1178  async initExtensionMenus() {
   1179    let { extension } = this;
   1180 
   1181    await ExtensionMenus.asyncInitForExtension(extension);
   1182 
   1183    if (
   1184      extension.hasShutdown ||
   1185      !ExtensionMenus.shouldPersistMenus(extension)
   1186    ) {
   1187      return;
   1188    }
   1189 
   1190    // Used for testing
   1191    const notifyMenusCreated = () =>
   1192      extension.emit("webext-menus-created", gMenuMap.get(extension));
   1193 
   1194    const menus = ExtensionMenus.getMenus(extension);
   1195    if (!menus.size) {
   1196      notifyMenusCreated();
   1197      return;
   1198    }
   1199 
   1200    let createErrorMenuIds = [];
   1201    for (let createProperties of menus.values()) {
   1202      // The order of menu creation is significant:
   1203      // When creating and reparenting the menu we ensure parents exist
   1204      // in the persisted menus map before children.  That allows the
   1205      // menus to be recreated in the correct sequence on startup.
   1206      //
   1207      // For details, see ExtensionMenusManager's updateMenus in
   1208      // ExtensionMenus.sys.mjs
   1209      try {
   1210        let menuItem = new MenuItem(extension, createProperties);
   1211        gMenuMap.get(extension).set(menuItem.id, menuItem);
   1212      } catch (err) {
   1213        Cu.reportError(
   1214          `Unexpected error on recreating persisted menu ${createProperties?.id} for ${extension.id}: ${err}`
   1215        );
   1216        createErrorMenuIds.push(createProperties.id);
   1217      }
   1218    }
   1219 
   1220    if (createErrorMenuIds.length) {
   1221      ExtensionMenus.deleteMenus(extension, createErrorMenuIds);
   1222    }
   1223 
   1224    notifyMenusCreated();
   1225  }
   1226 
   1227  onStartup() {
   1228    this.#promiseInitialized = this.initExtensionMenus();
   1229  }
   1230 
   1231  onShutdown() {
   1232    let { extension } = this;
   1233 
   1234    if (gMenuMap.has(extension)) {
   1235      gMenuMap.delete(extension);
   1236      gRootItems.delete(extension);
   1237      gShownMenuItems.delete(extension);
   1238      gOnShownSubscribers.delete(extension);
   1239      if (!gMenuMap.size) {
   1240        menuTracker.unregister();
   1241      }
   1242    }
   1243  }
   1244 
   1245  PERSISTENT_EVENTS = {
   1246    onShown({ fire }) {
   1247      let { extension } = this;
   1248      let listener = (event, menuIds, contextData) => {
   1249        let info = {
   1250          menuIds,
   1251          contexts: Array.from(getMenuContexts(contextData)),
   1252        };
   1253 
   1254        let nativeTab = contextData.tab;
   1255 
   1256        // The menus.onShown event is fired before the user has consciously
   1257        // interacted with an extension, so we require permissions before
   1258        // exposing sensitive contextual data.
   1259        let contextUrl = contextData.inFrame
   1260          ? contextData.frameUrl
   1261          : contextData.pageUrl;
   1262        let includeSensitiveData =
   1263          (nativeTab &&
   1264            extension.tabManager.hasActiveTabPermission(nativeTab)) ||
   1265          (contextUrl && extension.allowedOrigins.matches(contextUrl));
   1266 
   1267        addMenuEventInfo(info, contextData, extension, includeSensitiveData);
   1268 
   1269        let tab = nativeTab && extension.tabManager.convert(nativeTab);
   1270        fire.sync(info, tab);
   1271      };
   1272      gOnShownSubscribers.get(extension).add(listener);
   1273      extension.on("webext-menu-shown", listener);
   1274      return {
   1275        unregister() {
   1276          const listeners = gOnShownSubscribers.get(extension);
   1277          listeners.delete(listener);
   1278          if (listeners.size === 0) {
   1279            gOnShownSubscribers.delete(extension);
   1280          }
   1281          extension.off("webext-menu-shown", listener);
   1282        },
   1283        convert(_fire) {
   1284          fire = _fire;
   1285        },
   1286      };
   1287    },
   1288    onHidden({ fire }) {
   1289      let { extension } = this;
   1290      let listener = () => {
   1291        fire.sync();
   1292      };
   1293      extension.on("webext-menu-hidden", listener);
   1294      return {
   1295        unregister() {
   1296          extension.off("webext-menu-hidden", listener);
   1297        },
   1298        convert(_fire) {
   1299          fire = _fire;
   1300        },
   1301      };
   1302    },
   1303    onClicked({ context, fire }) {
   1304      let { extension } = this;
   1305      let listener = async (event, info, nativeTab) => {
   1306        let { linkedBrowser } = nativeTab || tabTracker.activeTab;
   1307        let tab = nativeTab && extension.tabManager.convert(nativeTab);
   1308        if (fire.wakeup) {
   1309          // force the wakeup, thus the call to convert to get the context.
   1310          await fire.wakeup();
   1311          // If while waiting the tab disappeared we bail out.
   1312          if (
   1313            !linkedBrowser.ownerGlobal.gBrowser.getTabForBrowser(linkedBrowser)
   1314          ) {
   1315            Cu.reportError(
   1316              `menus.onClicked: target tab closed during background startup.`
   1317            );
   1318            return;
   1319          }
   1320        }
   1321        context.withPendingBrowser(linkedBrowser, () => fire.sync(info, tab));
   1322      };
   1323 
   1324      extension.on("webext-menu-menuitem-click", listener);
   1325      return {
   1326        unregister() {
   1327          extension.off("webext-menu-menuitem-click", listener);
   1328        },
   1329        convert(_fire, _context) {
   1330          fire = _fire;
   1331          context = _context;
   1332        },
   1333      };
   1334    },
   1335  };
   1336 
   1337  getAPI(context) {
   1338    let { extension } = context;
   1339 
   1340    const menus = {
   1341      refresh() {
   1342        gMenuBuilder.rebuildMenu(extension);
   1343      },
   1344 
   1345      onShown: new EventManager({
   1346        context,
   1347        module: "menusInternal",
   1348        event: "onShown",
   1349        name: "menus.onShown",
   1350        extensionApi: this,
   1351      }).api(),
   1352      onHidden: new EventManager({
   1353        context,
   1354        module: "menusInternal",
   1355        event: "onHidden",
   1356        name: "menus.onHidden",
   1357        extensionApi: this,
   1358      }).api(),
   1359    };
   1360 
   1361    return {
   1362      contextMenus: menus,
   1363      menus,
   1364      menusInternal: {
   1365        create: async createProperties => {
   1366          await this.#promiseInitialized;
   1367          if (extension.hasShutdown) {
   1368            return;
   1369          }
   1370 
   1371          // event pages require id
   1372          if (ExtensionMenus.shouldPersistMenus(extension)) {
   1373            if (!createProperties.id) {
   1374              throw new ExtensionError(
   1375                "menus.create requires an id for non-persistent background scripts."
   1376              );
   1377            }
   1378            if (gMenuMap.get(extension).has(createProperties.id)) {
   1379              throw new ExtensionError(
   1380                `The menu id ${createProperties.id} already exists in menus.create.`
   1381              );
   1382            }
   1383          }
   1384 
   1385          // Note that the id is required by the schema. If the addon did not set
   1386          // it, the implementation of menus.create in the child will add it for
   1387          // extensions with persistent backgrounds, but not otherwise.
   1388 
   1389          let menuItem = new MenuItem(extension, createProperties);
   1390          ExtensionMenus.addMenu(extension, createProperties);
   1391          gMenuMap.get(extension).set(menuItem.id, menuItem);
   1392        },
   1393 
   1394        update: async (id, updateProperties) => {
   1395          await this.#promiseInitialized;
   1396          if (extension.hasShutdown) {
   1397            return;
   1398          }
   1399 
   1400          let menuItem = gMenuMap.get(extension).get(id);
   1401          if (!menuItem) {
   1402            throw new ExtensionError(`Cannot find menu item with id ${id}`);
   1403          }
   1404 
   1405          menuItem.setProps(updateProperties);
   1406          ExtensionMenus.updateMenu(extension, id, updateProperties);
   1407        },
   1408 
   1409        remove: async id => {
   1410          await this.#promiseInitialized;
   1411          if (extension.hasShutdown) {
   1412            return;
   1413          }
   1414 
   1415          let menuItem = gMenuMap.get(extension).get(id);
   1416          if (!menuItem) {
   1417            throw new ExtensionError(`Cannot find menu item with id ${id}`);
   1418          }
   1419 
   1420          const menuIds = [menuItem.id, ...menuItem.descendantIds];
   1421          menuItem.remove();
   1422          ExtensionMenus.deleteMenus(extension, menuIds);
   1423        },
   1424 
   1425        removeAll: async () => {
   1426          await this.#promiseInitialized;
   1427          if (extension.hasShutdown) {
   1428            return;
   1429          }
   1430 
   1431          let root = gRootItems.get(extension);
   1432          if (root) {
   1433            root.remove();
   1434          }
   1435          ExtensionMenus.deleteAllMenus(extension);
   1436        },
   1437 
   1438        onClicked: new EventManager({
   1439          context,
   1440          module: "menusInternal",
   1441          event: "onClicked",
   1442          name: "menus.onClicked",
   1443          extensionApi: this,
   1444        }).api(),
   1445      },
   1446    };
   1447  }
   1448 };