summaryrefslogtreecommitdiffstats
path: root/browser/components/extensions/parent/ext-menus.js
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/extensions/parent/ext-menus.js')
-rw-r--r--browser/components/extensions/parent/ext-menus.js1471
1 files changed, 1471 insertions, 0 deletions
diff --git a/browser/components/extensions/parent/ext-menus.js b/browser/components/extensions/parent/ext-menus.js
new file mode 100644
index 0000000000..74ce398b48
--- /dev/null
+++ b/browser/components/extensions/parent/ext-menus.js
@@ -0,0 +1,1471 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+var { DefaultMap, ExtensionError, parseMatchPatterns } = ExtensionUtils;
+
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+var { IconDetails, StartupCache } = ExtensionParent;
+
+const ACTION_MENU_TOP_LEVEL_LIMIT = 6;
+
+// Map[Extension -> Map[ID -> MenuItem]]
+// Note: we want to enumerate all the menu items so
+// this cannot be a weak map.
+var gMenuMap = new Map();
+
+// Map[Extension -> Map[ID -> MenuCreateProperties]]
+// The map object for each extension is a reference to the same
+// object in StartupCache.menus. This provides a non-async
+// getter for that object.
+var gStartupCache = new Map();
+
+// Map[Extension -> MenuItem]
+var gRootItems = new Map();
+
+// Map[Extension -> ID[]]
+// Menu IDs that were eligible for being shown in the current menu.
+var gShownMenuItems = new DefaultMap(() => []);
+
+// Map[Extension -> Set[Contexts]]
+// A DefaultMap (keyed by extension) which keeps track of the
+// contexts with a subscribed onShown event listener.
+var gOnShownSubscribers = new DefaultMap(() => new Set());
+
+// If id is not specified for an item we use an integer.
+var gNextMenuItemID = 0;
+
+// Used to assign unique names to radio groups.
+var gNextRadioGroupID = 0;
+
+// The max length of a menu item's label.
+var gMaxLabelLength = 64;
+
+var gMenuBuilder = {
+ // When a new menu is opened, this function is called and
+ // we populate the |xulMenu| with all the items from extensions
+ // to be displayed. We always clear all the items again when
+ // popuphidden fires.
+ build(contextData) {
+ contextData = this.maybeOverrideContextData(contextData);
+ let xulMenu = contextData.menu;
+ xulMenu.addEventListener("popuphidden", this);
+ this.xulMenu = xulMenu;
+ for (let [, root] of gRootItems) {
+ this.createAndInsertTopLevelElements(root, contextData, null);
+ }
+ this.afterBuildingMenu(contextData);
+
+ if (
+ contextData.webExtContextData &&
+ !contextData.webExtContextData.showDefaults
+ ) {
+ // Wait until nsContextMenu.js has toggled the visibility of the default
+ // menu items before hiding the default items.
+ Promise.resolve().then(() => this.hideDefaultMenuItems());
+ }
+ },
+
+ maybeOverrideContextData(contextData) {
+ let { webExtContextData } = contextData;
+ if (!webExtContextData || !webExtContextData.overrideContext) {
+ return contextData;
+ }
+ let contextDataBase = {
+ menu: contextData.menu,
+ // eslint-disable-next-line no-use-before-define
+ originalViewType: getContextViewType(contextData),
+ originalViewUrl: contextData.inFrame
+ ? contextData.frameUrl
+ : contextData.pageUrl,
+ webExtContextData,
+ };
+ if (webExtContextData.overrideContext === "bookmark") {
+ return {
+ ...contextDataBase,
+ bookmarkId: webExtContextData.bookmarkId,
+ onBookmark: true,
+ };
+ }
+ if (webExtContextData.overrideContext === "tab") {
+ // TODO: Handle invalid tabs more gracefully (instead of throwing).
+ let tab = tabTracker.getTab(webExtContextData.tabId);
+ return {
+ ...contextDataBase,
+ tab,
+ pageUrl: tab.linkedBrowser.currentURI.spec,
+ onTab: true,
+ };
+ }
+ throw new Error(
+ `Unexpected overrideContext: ${webExtContextData.overrideContext}`
+ );
+ },
+
+ canAccessContext(extension, contextData) {
+ if (!extension.privateBrowsingAllowed) {
+ let nativeTab = contextData.tab;
+ if (
+ nativeTab &&
+ PrivateBrowsingUtils.isBrowserPrivate(nativeTab.linkedBrowser)
+ ) {
+ return false;
+ } else if (
+ PrivateBrowsingUtils.isWindowPrivate(contextData.menu.ownerGlobal)
+ ) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ createAndInsertTopLevelElements(root, contextData, nextSibling) {
+ let rootElements;
+ if (!this.canAccessContext(root.extension, contextData)) {
+ return;
+ }
+ if (
+ contextData.onAction ||
+ contextData.onBrowserAction ||
+ contextData.onPageAction
+ ) {
+ if (contextData.extension.id !== root.extension.id) {
+ return;
+ }
+ rootElements = this.buildTopLevelElements(
+ root,
+ contextData,
+ ACTION_MENU_TOP_LEVEL_LIMIT,
+ false
+ );
+
+ // Action menu items are prepended to the menu, followed by a separator.
+ nextSibling = nextSibling || this.xulMenu.firstElementChild;
+ if (rootElements.length && !this.itemsToCleanUp.has(nextSibling)) {
+ rootElements.push(
+ this.xulMenu.ownerDocument.createXULElement("menuseparator")
+ );
+ }
+ } else if (contextData.webExtContextData) {
+ let { extensionId, showDefaults, overrideContext } =
+ contextData.webExtContextData;
+ if (extensionId === root.extension.id) {
+ rootElements = this.buildTopLevelElements(
+ root,
+ contextData,
+ Infinity,
+ false
+ );
+ if (!nextSibling) {
+ // The extension menu should be rendered at the top. If we use
+ // a navigation group (on non-macOS), the extension menu should
+ // come after that to avoid styling issues.
+ if (AppConstants.platform == "macosx") {
+ nextSibling = this.xulMenu.firstElementChild;
+ } else {
+ nextSibling = this.xulMenu.querySelector(
+ ":scope > #context-sep-navigation + *"
+ );
+ }
+ }
+ if (
+ rootElements.length &&
+ showDefaults &&
+ !this.itemsToCleanUp.has(nextSibling)
+ ) {
+ rootElements.push(
+ this.xulMenu.ownerDocument.createXULElement("menuseparator")
+ );
+ }
+ } else if (!showDefaults && !overrideContext) {
+ // When the default menu items should be hidden, menu items from other
+ // extensions should be hidden too.
+ return;
+ }
+ // Fall through to show default extension menu items.
+ }
+ if (!rootElements) {
+ rootElements = this.buildTopLevelElements(root, contextData, 1, true);
+ if (
+ rootElements.length &&
+ !this.itemsToCleanUp.has(this.xulMenu.lastElementChild)
+ ) {
+ // All extension menu items are appended at the end.
+ // Prepend separator if this is the first extension menu item.
+ rootElements.unshift(
+ this.xulMenu.ownerDocument.createXULElement("menuseparator")
+ );
+ }
+ }
+
+ if (!rootElements.length) {
+ return;
+ }
+
+ if (nextSibling) {
+ nextSibling.before(...rootElements);
+ } else {
+ this.xulMenu.append(...rootElements);
+ }
+ for (let item of rootElements) {
+ this.itemsToCleanUp.add(item);
+ }
+ },
+
+ buildElementWithChildren(item, contextData) {
+ const element = this.buildSingleElement(item, contextData);
+ const children = this.buildChildren(item, contextData);
+ if (children.length) {
+ element.firstElementChild.append(...children);
+ }
+ return element;
+ },
+
+ buildChildren(item, contextData) {
+ let groupName;
+ let children = [];
+ for (let child of item.children) {
+ if (child.type == "radio" && !child.groupName) {
+ if (!groupName) {
+ groupName = `webext-radio-group-${gNextRadioGroupID++}`;
+ }
+ child.groupName = groupName;
+ } else {
+ groupName = null;
+ }
+
+ if (child.enabledForContext(contextData)) {
+ children.push(this.buildElementWithChildren(child, contextData));
+ }
+ }
+ return children;
+ },
+
+ buildTopLevelElements(root, contextData, maxCount, forceManifestIcons) {
+ let children = this.buildChildren(root, contextData);
+
+ // TODO: Fix bug 1492969 and remove this whole if block.
+ if (
+ children.length === 1 &&
+ maxCount === 1 &&
+ forceManifestIcons &&
+ AppConstants.platform === "linux" &&
+ children[0].getAttribute("type") === "checkbox"
+ ) {
+ // Keep single checkbox items in the submenu on Linux since
+ // the extension icon overlaps the checkbox otherwise.
+ maxCount = 0;
+ }
+
+ if (children.length > maxCount) {
+ // Move excess items into submenu.
+ let rootElement = this.buildSingleElement(root, contextData);
+ rootElement.setAttribute("ext-type", "top-level-menu");
+ rootElement.firstElementChild.append(...children.splice(maxCount - 1));
+ children.push(rootElement);
+ }
+
+ if (forceManifestIcons) {
+ for (let rootElement of children) {
+ // Display the extension icon on the root element.
+ if (
+ root.extension.manifest.icons &&
+ rootElement.getAttribute("type") !== "checkbox"
+ ) {
+ this.setMenuItemIcon(
+ rootElement,
+ root.extension,
+ contextData,
+ root.extension.manifest.icons
+ );
+ } else {
+ this.removeMenuItemIcon(rootElement);
+ }
+ }
+ }
+ return children;
+ },
+
+ buildSingleElement(item, contextData) {
+ let doc = contextData.menu.ownerDocument;
+ let element;
+ if (item.children.length) {
+ element = this.createMenuElement(doc, item);
+ } else if (item.type == "separator") {
+ element = doc.createXULElement("menuseparator");
+ } else {
+ element = doc.createXULElement("menuitem");
+ }
+
+ return this.customizeElement(element, item, contextData);
+ },
+
+ createMenuElement(doc, item) {
+ let element = doc.createXULElement("menu");
+ // Menu elements need to have a menupopup child for its menu items.
+ let menupopup = doc.createXULElement("menupopup");
+ element.appendChild(menupopup);
+ return element;
+ },
+
+ customizeElement(element, item, contextData) {
+ let label = item.title;
+ if (label) {
+ let accessKey;
+ label = label.replace(/&([\S\s]|$)/g, (_, nextChar, i) => {
+ if (nextChar === "&") {
+ return "&";
+ }
+ if (accessKey === undefined) {
+ if (nextChar === "%" && label.charAt(i + 2) === "s") {
+ accessKey = "";
+ } else {
+ accessKey = nextChar;
+ }
+ }
+ return nextChar;
+ });
+ element.setAttribute("accesskey", accessKey || "");
+
+ if (contextData.isTextSelected && label.indexOf("%s") > -1) {
+ let selection = contextData.selectionText.trim();
+ // The rendering engine will truncate the title if it's longer than 64 characters.
+ // But if it makes sense let's try truncate selection text only, to handle cases like
+ // 'look up "%s" in MyDictionary' more elegantly.
+
+ let codePointsToRemove = 0;
+
+ let selectionArray = Array.from(selection);
+
+ let completeLabelLength = label.length - 2 + selectionArray.length;
+ if (completeLabelLength > gMaxLabelLength) {
+ codePointsToRemove = completeLabelLength - gMaxLabelLength;
+ }
+
+ if (codePointsToRemove) {
+ let ellipsis = "\u2026";
+ try {
+ ellipsis = Services.prefs.getComplexValue(
+ "intl.ellipsis",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ } catch (e) {}
+ codePointsToRemove += 1;
+ selection =
+ selectionArray.slice(0, -codePointsToRemove).join("") + ellipsis;
+ }
+
+ label = label.replace(/%s/g, selection);
+ }
+
+ element.setAttribute("label", label);
+ }
+
+ element.setAttribute("id", item.elementId);
+
+ if ("icons" in item) {
+ if (item.icons) {
+ this.setMenuItemIcon(element, item.extension, contextData, item.icons);
+ } else {
+ this.removeMenuItemIcon(element);
+ }
+ }
+
+ if (item.type == "checkbox") {
+ element.setAttribute("type", "checkbox");
+ if (item.checked) {
+ element.setAttribute("checked", "true");
+ }
+ } else if (item.type == "radio") {
+ element.setAttribute("type", "radio");
+ element.setAttribute("name", item.groupName);
+ if (item.checked) {
+ element.setAttribute("checked", "true");
+ }
+ }
+
+ if (!item.enabled) {
+ element.setAttribute("disabled", "true");
+ }
+
+ element.addEventListener(
+ "command",
+ event => {
+ if (event.target !== event.currentTarget) {
+ return;
+ }
+ const wasChecked = item.checked;
+ if (item.type == "checkbox") {
+ item.checked = !item.checked;
+ } else if (item.type == "radio") {
+ // Deselect all radio items in the current radio group.
+ for (let child of item.parent.children) {
+ if (child.type == "radio" && child.groupName == item.groupName) {
+ child.checked = false;
+ }
+ }
+ // Select the clicked radio item.
+ item.checked = true;
+ }
+
+ let { webExtContextData } = contextData;
+ if (
+ contextData.tab &&
+ // If the menu context was overridden by the extension, do not grant
+ // activeTab since the extension also controls the tabId.
+ (!webExtContextData ||
+ webExtContextData.extensionId !== item.extension.id)
+ ) {
+ item.tabManager.addActiveTabPermission(contextData.tab);
+ }
+
+ let info = item.getClickInfo(contextData, wasChecked);
+ info.modifiers = clickModifiersFromEvent(event);
+
+ info.button = event.button;
+
+ let _execute_action =
+ item.extension.manifestVersion < 3
+ ? "_execute_browser_action"
+ : "_execute_action";
+
+ // Allow menus to open various actions supported in webext prior
+ // to notifying onclicked.
+ let actionFor = {
+ [_execute_action]: global.browserActionFor,
+ _execute_page_action: global.pageActionFor,
+ _execute_sidebar_action: global.sidebarActionFor,
+ }[item.command];
+ if (actionFor) {
+ let win = event.target.ownerGlobal;
+ actionFor(item.extension).triggerAction(win);
+ return;
+ }
+
+ item.extension.emit(
+ "webext-menu-menuitem-click",
+ info,
+ contextData.tab
+ );
+ },
+ { once: true }
+ );
+
+ // Don't publish the ID of the root because the root element is
+ // auto-generated.
+ if (item.parent) {
+ gShownMenuItems.get(item.extension).push(item.id);
+ }
+
+ return element;
+ },
+
+ setMenuItemIcon(element, extension, contextData, icons) {
+ let parentWindow = contextData.menu.ownerGlobal;
+
+ let { icon } = IconDetails.getPreferredIcon(
+ icons,
+ extension,
+ 16 * parentWindow.devicePixelRatio
+ );
+
+ // The extension icons in the manifest are not pre-resolved, since
+ // they're sometimes used by the add-on manager when the extension is
+ // not enabled, and its URLs are not resolvable.
+ let resolvedURL = extension.baseURI.resolve(icon);
+
+ if (element.localName == "menu") {
+ element.setAttribute("class", "menu-iconic");
+ } else if (element.localName == "menuitem") {
+ element.setAttribute("class", "menuitem-iconic");
+ }
+
+ element.setAttribute("image", resolvedURL);
+ },
+
+ // Undo changes from setMenuItemIcon.
+ removeMenuItemIcon(element) {
+ element.removeAttribute("class");
+ element.removeAttribute("image");
+ },
+
+ rebuildMenu(extension) {
+ let { contextData } = this;
+ if (!contextData) {
+ // This happens if the menu is not visible.
+ return;
+ }
+
+ // Find the group of existing top-level items (usually 0 or 1 items)
+ // and remember its position for when the new items are inserted.
+ let elementIdPrefix = `${makeWidgetId(extension.id)}-menuitem-`;
+ let nextSibling = null;
+ for (let item of this.itemsToCleanUp) {
+ if (item.id && item.id.startsWith(elementIdPrefix)) {
+ nextSibling = item.nextSibling;
+ item.remove();
+ this.itemsToCleanUp.delete(item);
+ }
+ }
+
+ let root = gRootItems.get(extension);
+ if (root) {
+ this.createAndInsertTopLevelElements(root, contextData, nextSibling);
+ }
+
+ this.xulMenu.showHideSeparators?.();
+ },
+
+ // This should be called once, after constructing the top-level menus, if any.
+ afterBuildingMenu(contextData) {
+ let dispatchOnShownEvent = extension => {
+ if (!this.canAccessContext(extension, contextData)) {
+ return;
+ }
+
+ // Note: gShownMenuItems is a DefaultMap, so .get(extension) causes the
+ // extension to be stored in the map even if there are currently no
+ // shown menu items. This ensures that the onHidden event can be fired
+ // when the menu is closed.
+ let menuIds = gShownMenuItems.get(extension);
+ extension.emit("webext-menu-shown", menuIds, contextData);
+ };
+
+ if (
+ contextData.onAction ||
+ contextData.onBrowserAction ||
+ contextData.onPageAction
+ ) {
+ dispatchOnShownEvent(contextData.extension);
+ } else {
+ for (const extension of gOnShownSubscribers.keys()) {
+ dispatchOnShownEvent(extension);
+ }
+ }
+
+ this.contextData = contextData;
+ },
+
+ hideDefaultMenuItems() {
+ for (let item of this.xulMenu.children) {
+ if (!this.itemsToCleanUp.has(item)) {
+ item.hidden = true;
+ }
+ }
+
+ if (this.xulMenu.showHideSeparators) {
+ this.xulMenu.showHideSeparators();
+ }
+ },
+
+ handleEvent(event) {
+ if (this.xulMenu != event.target || event.type != "popuphidden") {
+ return;
+ }
+
+ delete this.xulMenu;
+ delete this.contextData;
+
+ let target = event.target;
+ target.removeEventListener("popuphidden", this);
+ for (let item of this.itemsToCleanUp) {
+ item.remove();
+ }
+ this.itemsToCleanUp.clear();
+ for (let extension of gShownMenuItems.keys()) {
+ extension.emit("webext-menu-hidden");
+ }
+ gShownMenuItems.clear();
+ },
+
+ itemsToCleanUp: new Set(),
+};
+
+// Called from pageAction or browserAction popup.
+global.actionContextMenu = function (contextData) {
+ contextData.tab = tabTracker.activeTab;
+ contextData.pageUrl = contextData.tab.linkedBrowser.currentURI.spec;
+ gMenuBuilder.build(contextData);
+};
+
+const contextsMap = {
+ onAudio: "audio",
+ onEditable: "editable",
+ inFrame: "frame",
+ onImage: "image",
+ onLink: "link",
+ onPassword: "password",
+ isTextSelected: "selection",
+ onVideo: "video",
+
+ onBookmark: "bookmark",
+ onAction: "action",
+ onBrowserAction: "browser_action",
+ onPageAction: "page_action",
+ onTab: "tab",
+ inToolsMenu: "tools_menu",
+};
+
+const getMenuContexts = contextData => {
+ let contexts = new Set();
+
+ for (const [key, value] of Object.entries(contextsMap)) {
+ if (contextData[key]) {
+ contexts.add(value);
+ }
+ }
+
+ if (contexts.size === 0) {
+ contexts.add("page");
+ }
+
+ // New non-content contexts supported in Firefox are not part of "all".
+ if (
+ !contextData.onBookmark &&
+ !contextData.onTab &&
+ !contextData.inToolsMenu
+ ) {
+ contexts.add("all");
+ }
+
+ return contexts;
+};
+
+function getContextViewType(contextData) {
+ if ("originalViewType" in contextData) {
+ return contextData.originalViewType;
+ }
+ if (
+ contextData.webExtBrowserType === "popup" ||
+ contextData.webExtBrowserType === "sidebar"
+ ) {
+ return contextData.webExtBrowserType;
+ }
+ if (contextData.tab && contextData.menu.id === "contentAreaContextMenu") {
+ return "tab";
+ }
+ return undefined;
+}
+
+function addMenuEventInfo(info, contextData, extension, includeSensitiveData) {
+ info.viewType = getContextViewType(contextData);
+ if (contextData.onVideo) {
+ info.mediaType = "video";
+ } else if (contextData.onAudio) {
+ info.mediaType = "audio";
+ } else if (contextData.onImage) {
+ info.mediaType = "image";
+ }
+ if (contextData.frameId !== undefined) {
+ info.frameId = contextData.frameId;
+ }
+ if (contextData.onBookmark) {
+ info.bookmarkId = contextData.bookmarkId;
+ }
+ info.editable = contextData.onEditable || false;
+ if (includeSensitiveData) {
+ // menus.getTargetElement requires the "menus" permission, so do not set
+ // targetElementId for extensions with only the "contextMenus" permission.
+ if (contextData.timeStamp && extension.hasPermission("menus")) {
+ // Convert to integer, in case the DOMHighResTimeStamp has a fractional part.
+ info.targetElementId = Math.floor(contextData.timeStamp);
+ }
+ if (contextData.onLink) {
+ info.linkText = contextData.linkText;
+ info.linkUrl = contextData.linkUrl;
+ }
+ if (contextData.onAudio || contextData.onImage || contextData.onVideo) {
+ info.srcUrl = contextData.srcUrl;
+ }
+ if (!contextData.onBookmark) {
+ info.pageUrl = contextData.pageUrl;
+ }
+ if (contextData.inFrame) {
+ info.frameUrl = contextData.frameUrl;
+ }
+ if (contextData.isTextSelected) {
+ info.selectionText = contextData.selectionText;
+ }
+ }
+ // If the context was overridden, then frameUrl should be the URL of the
+ // document in which the menu was opened (instead of undefined, even if that
+ // document is not in a frame).
+ if (contextData.originalViewUrl) {
+ info.frameUrl = contextData.originalViewUrl;
+ }
+}
+
+class MenuItem {
+ constructor(extension, createProperties, isRoot = false) {
+ this.extension = extension;
+ this.children = [];
+ this.parent = null;
+ this.tabManager = extension.tabManager;
+
+ this.setDefaults();
+ this.setProps(createProperties);
+
+ if (!this.hasOwnProperty("_id")) {
+ this.id = gNextMenuItemID++;
+ }
+ // If the item is not the root and has no parent
+ // it must be a child of the root.
+ if (!isRoot && !this.parent) {
+ this.root.addChild(this);
+ }
+ }
+
+ static mergeProps(obj, properties) {
+ for (let propName in properties) {
+ if (properties[propName] === null) {
+ // Omitted optional argument.
+ continue;
+ }
+ obj[propName] = properties[propName];
+ }
+
+ if ("icons" in properties && properties.icons === null && obj.icons) {
+ obj.icons = null;
+ }
+ }
+
+ setProps(createProperties) {
+ MenuItem.mergeProps(this, createProperties);
+
+ if (createProperties.documentUrlPatterns != null) {
+ this.documentUrlMatchPattern = parseMatchPatterns(
+ this.documentUrlPatterns,
+ {
+ restrictSchemes: this.extension.restrictSchemes,
+ }
+ );
+ }
+
+ if (createProperties.targetUrlPatterns != null) {
+ this.targetUrlMatchPattern = parseMatchPatterns(this.targetUrlPatterns, {
+ // restrictSchemes default to false when matching links instead of pages
+ // (see Bug 1280370 for a rationale).
+ restrictSchemes: false,
+ });
+ }
+
+ // If a child MenuItem does not specify any contexts, then it should
+ // inherit the contexts specified from its parent.
+ if (createProperties.parentId && !createProperties.contexts) {
+ this.contexts = this.parent.contexts;
+ }
+ }
+
+ setDefaults() {
+ this.setProps({
+ type: "normal",
+ checked: false,
+ contexts: ["all"],
+ enabled: true,
+ visible: true,
+ });
+ }
+
+ set id(id) {
+ if (this.hasOwnProperty("_id")) {
+ throw new ExtensionError("ID of a MenuItem cannot be changed");
+ }
+ let isIdUsed = gMenuMap.get(this.extension).has(id);
+ if (isIdUsed) {
+ throw new ExtensionError(`ID already exists: ${id}`);
+ }
+ this._id = id;
+ }
+
+ get id() {
+ return this._id;
+ }
+
+ get elementId() {
+ let id = this.id;
+ // If the ID is an integer, it is auto-generated and globally unique.
+ // If the ID is a string, it is only unique within one extension and the
+ // ID needs to be concatenated with the extension ID.
+ if (typeof id !== "number") {
+ // To avoid collisions with numeric IDs, add a prefix to string IDs.
+ id = `_${id}`;
+ }
+ return `${makeWidgetId(this.extension.id)}-menuitem-${id}`;
+ }
+
+ ensureValidParentId(parentId) {
+ if (parentId === undefined) {
+ return;
+ }
+ let menuMap = gMenuMap.get(this.extension);
+ if (!menuMap.has(parentId)) {
+ throw new ExtensionError(
+ `Could not find any MenuItem with id: ${parentId}`
+ );
+ }
+ for (let item = menuMap.get(parentId); item; item = item.parent) {
+ if (item === this) {
+ throw new ExtensionError(
+ "MenuItem cannot be an ancestor (or self) of its new parent."
+ );
+ }
+ }
+ }
+
+ /**
+ * When updating menu properties we need to ensure parents exist
+ * in the cache map before children. That allows the menus to be
+ * created in the correct sequence on startup. This reparents the
+ * tree starting from this instance of MenuItem.
+ */
+ reparentInCache() {
+ let { id, extension } = this;
+ let cachedMap = gStartupCache.get(extension);
+ let createProperties = cachedMap.get(id);
+ cachedMap.delete(id);
+ cachedMap.set(id, createProperties);
+
+ for (let child of this.children) {
+ child.reparentInCache();
+ }
+ }
+
+ set parentId(parentId) {
+ this.ensureValidParentId(parentId);
+
+ if (this.parent) {
+ this.parent.detachChild(this);
+ }
+
+ if (parentId === undefined) {
+ this.root.addChild(this);
+ } else {
+ let menuMap = gMenuMap.get(this.extension);
+ menuMap.get(parentId).addChild(this);
+ }
+ }
+
+ get parentId() {
+ return this.parent ? this.parent.id : undefined;
+ }
+
+ addChild(child) {
+ if (child.parent) {
+ throw new Error("Child MenuItem already has a parent.");
+ }
+ this.children.push(child);
+ child.parent = this;
+ }
+
+ detachChild(child) {
+ let idx = this.children.indexOf(child);
+ if (idx < 0) {
+ throw new Error("Child MenuItem not found, it cannot be removed.");
+ }
+ this.children.splice(idx, 1);
+ child.parent = null;
+ }
+
+ get root() {
+ let extension = this.extension;
+ if (!gRootItems.has(extension)) {
+ let root = new MenuItem(
+ extension,
+ { title: extension.name },
+ /* isRoot = */ true
+ );
+ gRootItems.set(extension, root);
+ }
+
+ return gRootItems.get(extension);
+ }
+
+ remove() {
+ if (this.parent) {
+ this.parent.detachChild(this);
+ }
+ let children = this.children.slice(0);
+ for (let child of children) {
+ child.remove();
+ }
+
+ let menuMap = gMenuMap.get(this.extension);
+ menuMap.delete(this.id);
+ // Menu items are saved if !extension.persistentBackground.
+ if (gStartupCache.get(this.extension)?.delete(this.id)) {
+ StartupCache.save();
+ }
+ if (this.root == this) {
+ gRootItems.delete(this.extension);
+ }
+ }
+
+ getClickInfo(contextData, wasChecked) {
+ let info = {
+ menuItemId: this.id,
+ };
+ if (this.parent) {
+ info.parentMenuItemId = this.parentId;
+ }
+
+ addMenuEventInfo(info, contextData, this.extension, true);
+
+ if (this.type === "checkbox" || this.type === "radio") {
+ info.checked = this.checked;
+ info.wasChecked = wasChecked;
+ }
+
+ return info;
+ }
+
+ enabledForContext(contextData) {
+ if (!this.visible) {
+ return false;
+ }
+ let contexts = getMenuContexts(contextData);
+ if (!this.contexts.some(n => contexts.has(n))) {
+ return false;
+ }
+
+ if (
+ this.viewTypes &&
+ !this.viewTypes.includes(getContextViewType(contextData))
+ ) {
+ return false;
+ }
+
+ let docPattern = this.documentUrlMatchPattern;
+ // When viewTypes is specified, the menu item is expected to be restricted
+ // to documents. So let documentUrlPatterns always apply to the URL of the
+ // document in which the menu was opened. When maybeOverrideContextData
+ // changes the context, contextData.pageUrl does not reflect that URL any
+ // more, so use contextData.originalViewUrl instead.
+ if (docPattern && this.viewTypes && contextData.originalViewUrl) {
+ if (
+ !docPattern.matches(Services.io.newURI(contextData.originalViewUrl))
+ ) {
+ return false;
+ }
+ docPattern = null; // Null it so that it won't be used with pageURI below.
+ }
+
+ if (contextData.onBookmark) {
+ return this.extension.hasPermission("bookmarks");
+ }
+
+ let pageURI = Services.io.newURI(
+ contextData[contextData.inFrame ? "frameUrl" : "pageUrl"]
+ );
+ if (docPattern && !docPattern.matches(pageURI)) {
+ return false;
+ }
+
+ let targetPattern = this.targetUrlMatchPattern;
+ if (targetPattern) {
+ let targetURIs = [];
+ if (contextData.onImage || contextData.onAudio || contextData.onVideo) {
+ // TODO: double check if srcUrl is always set when we need it
+ targetURIs.push(Services.io.newURI(contextData.srcUrl));
+ }
+ // contextData.linkURI may be null despite contextData.onLink, when
+ // contextData.linkUrl is an invalid URL.
+ if (contextData.onLink && contextData.linkURI) {
+ targetURIs.push(contextData.linkURI);
+ }
+ if (!targetURIs.some(targetURI => targetPattern.matches(targetURI))) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
+
+// windowTracker only looks as browser windows, but we're also interested in
+// the Library window. Helper for menuTracker below.
+const libraryTracker = {
+ libraryWindowType: "Places:Organizer",
+
+ isLibraryWindow(window) {
+ let winType = window.document.documentElement.getAttribute("windowtype");
+ return winType === this.libraryWindowType;
+ },
+
+ init(listener) {
+ this._listener = listener;
+ Services.ww.registerNotification(this);
+
+ // See WindowTrackerBase#*browserWindows in ext-tabs-base.js for why we
+ // can't use the enumerator's windowtype filter.
+ for (let window of Services.wm.getEnumerator("")) {
+ if (window.document.readyState === "complete") {
+ if (this.isLibraryWindow(window)) {
+ this.notify(window);
+ }
+ } else {
+ window.addEventListener("load", this, { once: true });
+ }
+ }
+ },
+
+ // cleanupWindow is called on any library window that's open.
+ uninit(cleanupWindow) {
+ Services.ww.unregisterNotification(this);
+
+ for (let window of Services.wm.getEnumerator("")) {
+ window.removeEventListener("load", this);
+ try {
+ if (this.isLibraryWindow(window)) {
+ cleanupWindow(window);
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+ },
+
+ // Gets notifications from Services.ww.registerNotification.
+ // Defer actually doing anything until the window's loaded, though.
+ observe(window, topic) {
+ if (topic === "domwindowopened") {
+ window.addEventListener("load", this, { once: true });
+ }
+ },
+
+ // Gets the load event for new windows(registered in observe()).
+ handleEvent(event) {
+ let window = event.target.defaultView;
+ if (this.isLibraryWindow(window)) {
+ this.notify(window);
+ }
+ },
+
+ notify(window) {
+ try {
+ this._listener.call(null, window);
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ },
+};
+
+// While any extensions are active, this Tracker registers to observe/listen
+// for menu events from both Tools and context menus, both content and chrome.
+const menuTracker = {
+ menuIds: ["placesContext", "menu_ToolsPopup", "tabContextMenu"],
+
+ register() {
+ Services.obs.addObserver(this, "on-build-contextmenu");
+ for (const window of windowTracker.browserWindows()) {
+ this.onWindowOpen(window);
+ }
+ windowTracker.addOpenListener(this.onWindowOpen);
+ libraryTracker.init(this.onLibraryOpen);
+ },
+
+ unregister() {
+ Services.obs.removeObserver(this, "on-build-contextmenu");
+ for (const window of windowTracker.browserWindows()) {
+ this.cleanupWindow(window);
+ }
+ windowTracker.removeOpenListener(this.onWindowOpen);
+ libraryTracker.uninit(this.cleanupLibrary);
+ },
+
+ observe(subject, topic, data) {
+ subject = subject.wrappedJSObject;
+ gMenuBuilder.build(subject);
+ },
+
+ async onWindowOpen(window) {
+ for (const id of menuTracker.menuIds) {
+ const menu = window.document.getElementById(id);
+ menu.addEventListener("popupshowing", menuTracker);
+ }
+
+ const sidebarHeader = window.document.getElementById(
+ "sidebar-switcher-target"
+ );
+ sidebarHeader.addEventListener("SidebarShown", menuTracker.onSidebarShown);
+
+ await window.SidebarUI.promiseInitialized;
+
+ if (
+ !window.closed &&
+ window.SidebarUI.currentID === "viewBookmarksSidebar"
+ ) {
+ menuTracker.onSidebarShown({ currentTarget: sidebarHeader });
+ }
+ },
+
+ cleanupWindow(window) {
+ for (const id of this.menuIds) {
+ const menu = window.document.getElementById(id);
+ menu.removeEventListener("popupshowing", this);
+ }
+
+ const sidebarHeader = window.document.getElementById(
+ "sidebar-switcher-target"
+ );
+ sidebarHeader.removeEventListener("SidebarShown", this.onSidebarShown);
+
+ if (window.SidebarUI.currentID === "viewBookmarksSidebar") {
+ let sidebarBrowser = window.SidebarUI.browser;
+ sidebarBrowser.removeEventListener("load", this.onSidebarShown);
+ const menu =
+ sidebarBrowser.contentDocument.getElementById("placesContext");
+ menu.removeEventListener("popupshowing", this.onBookmarksContextMenu);
+ }
+ },
+
+ onSidebarShown(event) {
+ // The event target is an element in a browser window, so |window| will be
+ // the browser window that contains the sidebar.
+ const window = event.currentTarget.ownerGlobal;
+ if (window.SidebarUI.currentID === "viewBookmarksSidebar") {
+ let sidebarBrowser = window.SidebarUI.browser;
+ if (sidebarBrowser.contentDocument.readyState !== "complete") {
+ // SidebarUI.currentID may be updated before the bookmark sidebar's
+ // document has finished loading. This sometimes happens when the
+ // sidebar is automatically shown when a new window is opened.
+ sidebarBrowser.addEventListener("load", menuTracker.onSidebarShown, {
+ once: true,
+ });
+ return;
+ }
+ const menu =
+ sidebarBrowser.contentDocument.getElementById("placesContext");
+ menu.addEventListener("popupshowing", menuTracker.onBookmarksContextMenu);
+ }
+ },
+
+ onLibraryOpen(window) {
+ const menu = window.document.getElementById("placesContext");
+ menu.addEventListener("popupshowing", menuTracker.onBookmarksContextMenu);
+ },
+
+ cleanupLibrary(window) {
+ const menu = window.document.getElementById("placesContext");
+ menu.removeEventListener(
+ "popupshowing",
+ menuTracker.onBookmarksContextMenu
+ );
+ },
+
+ handleEvent(event) {
+ const menu = event.target;
+
+ if (menu.id === "placesContext") {
+ const trigger = menu.triggerNode;
+ if (!trigger._placesNode?.bookmarkGuid) {
+ return;
+ }
+
+ gMenuBuilder.build({
+ menu,
+ bookmarkId: trigger._placesNode.bookmarkGuid,
+ onBookmark: true,
+ });
+ }
+ if (menu.id === "menu_ToolsPopup") {
+ const tab = tabTracker.activeTab;
+ const pageUrl = tab.linkedBrowser.currentURI.spec;
+ gMenuBuilder.build({ menu, tab, pageUrl, inToolsMenu: true });
+ }
+ if (menu.id === "tabContextMenu") {
+ const tab = menu.ownerGlobal.TabContextMenu.contextTab;
+ const pageUrl = tab.linkedBrowser.currentURI.spec;
+ gMenuBuilder.build({ menu, tab, pageUrl, onTab: true });
+ }
+ },
+
+ onBookmarksContextMenu(event) {
+ const menu = event.target;
+ const tree = menu.triggerNode.parentElement;
+ const cell = tree.getCellAt(event.x, event.y);
+ const node = tree.view.nodeForTreeIndex(cell.row);
+ const bookmarkId = node && PlacesUtils.getConcreteItemGuid(node);
+
+ if (!bookmarkId || PlacesUtils.isVirtualLeftPaneItem(bookmarkId)) {
+ return;
+ }
+
+ gMenuBuilder.build({ menu, bookmarkId, onBookmark: true });
+ },
+};
+
+this.menusInternal = class extends ExtensionAPIPersistent {
+ constructor(extension) {
+ super(extension);
+
+ if (!gMenuMap.size) {
+ menuTracker.register();
+ }
+ gMenuMap.set(extension, new Map());
+ }
+
+ restoreFromCache() {
+ let { extension } = this;
+ // ensure extension has not shutdown
+ if (!this.extension) {
+ return;
+ }
+ for (let createProperties of gStartupCache.get(extension).values()) {
+ // The order of menu creation is significant, see reparentInCache.
+ let menuItem = new MenuItem(extension, createProperties);
+ gMenuMap.get(extension).set(menuItem.id, menuItem);
+ }
+ // Used for testing
+ extension.emit("webext-menus-created", gMenuMap.get(extension));
+ }
+
+ async onStartup() {
+ let { extension } = this;
+ if (extension.persistentBackground) {
+ return;
+ }
+ // Using the map retains insertion order.
+ let cachedMenus = await StartupCache.menus.get(extension.id, () => {
+ return new Map();
+ });
+ gStartupCache.set(extension, cachedMenus);
+ if (!cachedMenus.size) {
+ return;
+ }
+
+ this.restoreFromCache();
+ }
+
+ onShutdown() {
+ let { extension } = this;
+
+ if (gMenuMap.has(extension)) {
+ gMenuMap.delete(extension);
+ gRootItems.delete(extension);
+ gShownMenuItems.delete(extension);
+ gStartupCache.delete(extension);
+ gOnShownSubscribers.delete(extension);
+ if (!gMenuMap.size) {
+ menuTracker.unregister();
+ }
+ }
+ }
+
+ PERSISTENT_EVENTS = {
+ onShown({ fire }) {
+ let { extension } = this;
+ let listener = (event, menuIds, contextData) => {
+ let info = {
+ menuIds,
+ contexts: Array.from(getMenuContexts(contextData)),
+ };
+
+ let nativeTab = contextData.tab;
+
+ // The menus.onShown event is fired before the user has consciously
+ // interacted with an extension, so we require permissions before
+ // exposing sensitive contextual data.
+ let contextUrl = contextData.inFrame
+ ? contextData.frameUrl
+ : contextData.pageUrl;
+ let includeSensitiveData =
+ (nativeTab &&
+ extension.tabManager.hasActiveTabPermission(nativeTab)) ||
+ (contextUrl && extension.allowedOrigins.matches(contextUrl));
+
+ addMenuEventInfo(info, contextData, extension, includeSensitiveData);
+
+ let tab = nativeTab && extension.tabManager.convert(nativeTab);
+ fire.sync(info, tab);
+ };
+ gOnShownSubscribers.get(extension).add(listener);
+ extension.on("webext-menu-shown", listener);
+ return {
+ unregister() {
+ const listeners = gOnShownSubscribers.get(extension);
+ listeners.delete(listener);
+ if (listeners.size === 0) {
+ gOnShownSubscribers.delete(extension);
+ }
+ extension.off("webext-menu-shown", listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onHidden({ fire }) {
+ let { extension } = this;
+ let listener = () => {
+ fire.sync();
+ };
+ extension.on("webext-menu-hidden", listener);
+ return {
+ unregister() {
+ extension.off("webext-menu-hidden", listener);
+ },
+ convert(_fire) {
+ fire = _fire;
+ },
+ };
+ },
+ onClicked({ context, fire }) {
+ let { extension } = this;
+ let listener = async (event, info, nativeTab) => {
+ let { linkedBrowser } = nativeTab || tabTracker.activeTab;
+ let tab = nativeTab && extension.tabManager.convert(nativeTab);
+ if (fire.wakeup) {
+ // force the wakeup, thus the call to convert to get the context.
+ await fire.wakeup();
+ // If while waiting the tab disappeared we bail out.
+ if (
+ !linkedBrowser.ownerGlobal.gBrowser.getTabForBrowser(linkedBrowser)
+ ) {
+ Cu.reportError(
+ `menus.onClicked: target tab closed during background startup.`
+ );
+ return;
+ }
+ }
+ context.withPendingBrowser(linkedBrowser, () => fire.sync(info, tab));
+ };
+
+ extension.on("webext-menu-menuitem-click", listener);
+ return {
+ unregister() {
+ extension.off("webext-menu-menuitem-click", listener);
+ },
+ convert(_fire, _context) {
+ fire = _fire;
+ context = _context;
+ },
+ };
+ },
+ };
+
+ getAPI(context) {
+ let { extension } = context;
+
+ const menus = {
+ refresh() {
+ gMenuBuilder.rebuildMenu(extension);
+ },
+
+ onShown: new EventManager({
+ context,
+ module: "menusInternal",
+ event: "onShown",
+ name: "menus.onShown",
+ extensionApi: this,
+ }).api(),
+ onHidden: new EventManager({
+ context,
+ module: "menusInternal",
+ event: "onHidden",
+ name: "menus.onHidden",
+ extensionApi: this,
+ }).api(),
+ };
+
+ return {
+ contextMenus: menus,
+ menus,
+ menusInternal: {
+ create(createProperties) {
+ // event pages require id
+ if (!extension.persistentBackground) {
+ if (!createProperties.id) {
+ throw new ExtensionError(
+ "menus.create requires an id for non-persistent background scripts."
+ );
+ }
+ if (gMenuMap.get(extension).has(createProperties.id)) {
+ throw new ExtensionError(
+ `The menu id ${createProperties.id} already exists in menus.create.`
+ );
+ }
+ }
+
+ // Note that the id is required by the schema. If the addon did not set
+ // it, the implementation of menus.create in the child will add it for
+ // extensions with persistent backgrounds, but not otherwise.
+ let menuItem = new MenuItem(extension, createProperties);
+ gMenuMap.get(extension).set(menuItem.id, menuItem);
+ if (!extension.persistentBackground) {
+ // Only cache properties that are necessary.
+ let cached = {};
+ MenuItem.mergeProps(cached, createProperties);
+ gStartupCache.get(extension).set(menuItem.id, cached);
+ StartupCache.save();
+ }
+ },
+
+ update(id, updateProperties) {
+ let menuItem = gMenuMap.get(extension).get(id);
+ if (!menuItem) {
+ return;
+ }
+ menuItem.setProps(updateProperties);
+
+ // Update the startup cache for non-persistent extensions.
+ if (extension.persistentBackground) {
+ return;
+ }
+
+ let cached = gStartupCache.get(extension).get(id);
+ let reparent =
+ updateProperties.parentId != null &&
+ cached.parentId != updateProperties.parentId;
+ MenuItem.mergeProps(cached, updateProperties);
+ if (reparent) {
+ // The order of menu creation is significant, see reparentInCache.
+ menuItem.reparentInCache();
+ }
+ StartupCache.save();
+ },
+
+ remove(id) {
+ let menuItem = gMenuMap.get(extension).get(id);
+ if (menuItem) {
+ menuItem.remove();
+ }
+ },
+
+ removeAll() {
+ let root = gRootItems.get(extension);
+ if (root) {
+ root.remove();
+ }
+ // Should be empty, just extra assurance.
+ if (!extension.persistentBackground) {
+ let cached = gStartupCache.get(extension);
+ if (cached.size) {
+ cached.clear();
+ StartupCache.save();
+ }
+ }
+ },
+
+ onClicked: new EventManager({
+ context,
+ module: "menusInternal",
+ event: "onClicked",
+ name: "menus.onClicked",
+ extensionApi: this,
+ }).api(),
+ },
+ };
+ }
+};