Source code

Revision control

Copy as Markdown

Other Tools

/* -*- 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, {
});
var { DefaultMap, ExtensionError, parseMatchPatterns } = ExtensionUtils;
var { ExtensionParent } = ChromeUtils.importESModule(
);
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) {
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) {
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(),
},
};
}
};