forked from mirrors/gecko-dev
		
	 9691ab4a5f
			
		
	
	
		9691ab4a5f
		
	
	
	
	
		
			
			Depends on D175553 Differential Revision: https://phabricator.services.mozilla.com/D176005
		
			
				
	
	
		
			1471 lines
		
	
	
	
		
			43 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			1471 lines
		
	
	
	
		
			43 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /* -*- 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(),
 | |
|       },
 | |
|     };
 | |
|   }
 | |
| };
 |