/* -*- 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.i
d}: ${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(),
},
};
}
};