1454 lines
42 KiB
JavaScript
1454 lines
42 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, {
|
|
ExtensionMenus: "resource://gre/modules/ExtensionMenus.sys.mjs",
|
|
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 } = 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 -> 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) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
setProps(createProperties) {
|
|
ExtensionMenus.mergeMenuProperties(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(`Cannot find menu item 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."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
get descendantIds() {
|
|
return this.children
|
|
? this.children.flatMap(m => [m.id, ...m.descendantIds])
|
|
: [];
|
|
}
|
|
|
|
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);
|
|
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) {
|
|
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.SidebarController.promiseInitialized;
|
|
|
|
if (
|
|
!window.closed &&
|
|
window.SidebarController.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.SidebarController.currentID === "viewBookmarksSidebar") {
|
|
let sidebarBrowser = window.SidebarController.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.SidebarController.currentID === "viewBookmarksSidebar") {
|
|
let sidebarBrowser = window.SidebarController.browser;
|
|
if (sidebarBrowser.contentDocument.readyState !== "complete") {
|
|
// SidebarController.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 {
|
|
#promiseInitialized = null;
|
|
|
|
constructor(extension) {
|
|
super(extension);
|
|
|
|
if (!gMenuMap.size) {
|
|
menuTracker.register();
|
|
}
|
|
gMenuMap.set(extension, new Map());
|
|
}
|
|
|
|
async initExtensionMenus() {
|
|
let { extension } = this;
|
|
|
|
await ExtensionMenus.asyncInitForExtension(extension);
|
|
|
|
if (
|
|
extension.hasShutdown ||
|
|
!ExtensionMenus.shouldPersistMenus(extension)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Used for testing
|
|
const notifyMenusCreated = () =>
|
|
extension.emit("webext-menus-created", gMenuMap.get(extension));
|
|
|
|
const menus = ExtensionMenus.getMenus(extension);
|
|
if (!menus.size) {
|
|
notifyMenusCreated();
|
|
return;
|
|
}
|
|
|
|
let createErrorMenuIds = [];
|
|
for (let createProperties of menus.values()) {
|
|
// The order of menu creation is significant:
|
|
// When creating and reparenting the menu we ensure parents exist
|
|
// in the persisted menus map before children. That allows the
|
|
// menus to be recreated in the correct sequence on startup.
|
|
//
|
|
// For details, see ExtensionMenusManager's updateMenus in
|
|
// ExtensionMenus.sys.mjs
|
|
try {
|
|
let menuItem = new MenuItem(extension, createProperties);
|
|
gMenuMap.get(extension).set(menuItem.id, menuItem);
|
|
} catch (err) {
|
|
Cu.reportError(
|
|
`Unexpected error on recreating persisted menu ${createProperties?.id} for ${extension.id}: ${err}`
|
|
);
|
|
createErrorMenuIds.push(createProperties.id);
|
|
}
|
|
}
|
|
|
|
if (createErrorMenuIds.length) {
|
|
ExtensionMenus.deleteMenus(extension, createErrorMenuIds);
|
|
}
|
|
|
|
notifyMenusCreated();
|
|
}
|
|
|
|
onStartup() {
|
|
this.#promiseInitialized = this.initExtensionMenus();
|
|
}
|
|
|
|
onShutdown() {
|
|
let { extension } = this;
|
|
|
|
if (gMenuMap.has(extension)) {
|
|
gMenuMap.delete(extension);
|
|
gRootItems.delete(extension);
|
|
gShownMenuItems.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: async createProperties => {
|
|
await this.#promiseInitialized;
|
|
if (extension.hasShutdown) {
|
|
return;
|
|
}
|
|
|
|
// event pages require id
|
|
if (ExtensionMenus.shouldPersistMenus(extension)) {
|
|
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);
|
|
ExtensionMenus.addMenu(extension, createProperties);
|
|
gMenuMap.get(extension).set(menuItem.id, menuItem);
|
|
},
|
|
|
|
update: async (id, updateProperties) => {
|
|
await this.#promiseInitialized;
|
|
if (extension.hasShutdown) {
|
|
return;
|
|
}
|
|
|
|
let menuItem = gMenuMap.get(extension).get(id);
|
|
if (!menuItem) {
|
|
throw new ExtensionError(`Cannot find menu item with id ${id}`);
|
|
}
|
|
|
|
menuItem.setProps(updateProperties);
|
|
ExtensionMenus.updateMenu(extension, id, updateProperties);
|
|
},
|
|
|
|
remove: async id => {
|
|
await this.#promiseInitialized;
|
|
if (extension.hasShutdown) {
|
|
return;
|
|
}
|
|
|
|
let menuItem = gMenuMap.get(extension).get(id);
|
|
if (!menuItem) {
|
|
throw new ExtensionError(`Cannot find menu item with id ${id}`);
|
|
}
|
|
|
|
const menuIds = [menuItem.id, ...menuItem.descendantIds];
|
|
menuItem.remove();
|
|
ExtensionMenus.deleteMenus(extension, menuIds);
|
|
},
|
|
|
|
removeAll: async () => {
|
|
await this.#promiseInitialized;
|
|
if (extension.hasShutdown) {
|
|
return;
|
|
}
|
|
|
|
let root = gRootItems.get(extension);
|
|
if (root) {
|
|
root.remove();
|
|
}
|
|
ExtensionMenus.deleteAllMenus(extension);
|
|
},
|
|
|
|
onClicked: new EventManager({
|
|
context,
|
|
module: "menusInternal",
|
|
event: "onClicked",
|
|
name: "menus.onClicked",
|
|
extensionApi: this,
|
|
}).api(),
|
|
},
|
|
};
|
|
}
|
|
};
|