/* -*- 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(), }, }; } };