Bug 1215376 - Add onShown and onHidden to contextMenus/menus API
This commit adds the bare minimum to support the onShown and onHidden
methods in the contextMenus and menus API (plus tests).
The onShown event data may be extended in a separate commit, to make it
easier to see how the additional properties are reflected in the tests.
MozReview-Commit-ID: 20VJS2YLhTN
--- a/browser/components/extensions/ext-browser.json
+++ b/browser/components/extensions/ext-browser.json
@@ -97,16 +97,18 @@
["identity"]
]
},
"menusInternal": {
"url": "chrome://browser/content/ext-menus.js",
"schema": "chrome://browser/content/schemas/menus.json",
"scopes": ["addon_parent"],
"paths": [
+ ["contextMenus"],
+ ["menus"],
["menusInternal"]
]
},
"omnibox": {
"url": "chrome://browser/content/ext-omnibox.js",
"schema": "chrome://browser/content/schemas/omnibox.json",
"scopes": ["addon_parent"],
"manifest": ["omnibox"],
--- a/browser/components/extensions/ext-menus.js
+++ b/browser/components/extensions/ext-menus.js
@@ -3,16 +3,17 @@
"use strict";
// The ext-* files are imported into the same scopes.
/* import-globals-from ext-browser.js */
Cu.import("resource://gre/modules/Services.jsm");
var {
+ DefaultMap,
ExtensionError,
} = ExtensionUtils;
Cu.import("resource://gre/modules/ExtensionParent.jsm");
var {
IconDetails,
} = ExtensionParent;
@@ -22,16 +23,20 @@ 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(() => []);
+
// 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;
@@ -66,16 +71,17 @@ var gMenuBuilder = {
const separator = xulMenu.ownerDocument.createElement("menuseparator");
this.itemsToCleanUp.add(separator);
xulMenu.append(separator);
}
xulMenu.appendChild(rootElement);
this.itemsToCleanUp.add(rootElement);
}
+ this.afterBuildingMenu(contextData);
},
// Builds a context menu for browserAction and pageAction buttons.
buildActionContextMenu(contextData) {
const {menu} = contextData;
contextData.tab = tabTracker.activeTab;
contextData.pageUrl = contextData.tab.linkedBrowser.currentURI.spec;
@@ -96,16 +102,17 @@ var gMenuBuilder = {
menu.insertBefore(separator, menu.firstChild);
this.itemsToCleanUp.add(separator);
for (const child of visible) {
this.itemsToCleanUp.add(child);
menu.insertBefore(child, separator);
}
}
+ this.afterBuildingMenu(contextData);
},
buildElementWithChildren(item, contextData) {
const element = this.buildSingleElement(item, contextData);
const children = this.buildChildren(item, contextData);
if (children.length) {
element.firstChild.append(...children);
}
@@ -274,16 +281,22 @@ var gMenuBuilder = {
if (actionFor) {
let win = event.target.ownerGlobal;
actionFor(item.extension).triggerAction(win);
}
item.extension.emit("webext-menu-menuitem-click", info, tab);
});
+ // 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);
@@ -297,28 +310,49 @@ var gMenuBuilder = {
element.setAttribute("class", "menu-iconic");
} else if (element.localName == "menuitem") {
element.setAttribute("class", "menuitem-iconic");
}
element.setAttribute("image", resolvedURL);
},
+ afterBuildingMenu(contextData) {
+ if (gShownMenuItems.size === 0) {
+ return;
+ }
+ let commonMenuInfo = {
+ contexts: Array.from(getMenuContexts(contextData)),
+ };
+ // TODO(robwu): Add more contextual information.
+ // The menus.onShown event is fired before the user has consciously
+ // interacted with an extension, so beware of privacy implications of
+ // sharing event data without permission checks.
+ for (let [extension, menuIds] of gShownMenuItems.entries()) {
+ let info = Object.assign({menuIds}, commonMenuInfo);
+ extension.emit("webext-menu-shown", info);
+ }
+ },
+
handleEvent(event) {
if (this.xulMenu != event.target || event.type != "popuphidden") {
return;
}
delete this.xulMenu;
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) {
gMenuBuilder.buildActionContextMenu(contextData);
@@ -661,26 +695,50 @@ this.menusInternal = class extends Exten
}
onShutdown(reason) {
let {extension} = this;
if (gMenuMap.has(extension)) {
gMenuMap.delete(extension);
gRootItems.delete(extension);
+ gShownMenuItems.delete(extension);
if (!gMenuMap.size) {
menuTracker.unregister();
}
}
}
getAPI(context) {
let {extension} = context;
+ const menus = {
+ onShown: new EventManager(context, "menus.onShown", fire => {
+ let listener = (event, data) => {
+ fire.sync(data);
+ };
+ extension.on("webext-menu-shown", listener);
+ return () => {
+ extension.off("webext-menu-shown", listener);
+ };
+ }).api(),
+ onHidden: new EventManager(context, "menus.onHidden", fire => {
+ let listener = () => {
+ fire.sync();
+ };
+ extension.on("webext-menu-hidden", listener);
+ return () => {
+ extension.off("webext-menu-hidden", listener);
+ };
+ }).api(),
+ };
+
return {
+ contextMenus: menus,
+ menus,
menusInternal: {
create: function(createProperties) {
// Note that the id is required by the schema. If the addon did not set
// it, the implementation of menus.create in the child should
// have added it.
let menuItem = new MenuItem(extension, createProperties);
gMenuMap.get(extension).set(menuItem.id, menuItem);
},
--- a/browser/components/extensions/schemas/menus.json
+++ b/browser/components/extensions/schemas/menus.json
@@ -389,12 +389,47 @@
},
{
"name": "tab",
"$ref": "tabs.Tab",
"description": "The details of the tab where the click took place. If the click did not take place in a tab, this parameter will be missing.",
"optional": true
}
]
+ },
+ {
+ "name": "onShown",
+ "type": "function",
+ "description": "Fired when a menu is shown that contains a menu item that was created by the extension.",
+ "parameters": [
+ {
+ "name": "info",
+ "type": "object",
+ "description": "Information about the context of the menu action and the created menu items",
+ "properties": {
+ "menuIds": {
+ "description": "A list of IDs of the menu items that were shown.",
+ "type": "array",
+ "items": {
+ "choices": [
+ { "type": "integer" },
+ { "type": "string" }
+ ]
+ }
+ },
+ "contexts": {
+ "description": "A list of all contexts that apply to the menu.",
+ "type": "array",
+ "items": {"$ref": "ContextType"}
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "onHidden",
+ "type": "function",
+ "description": "Fired when a menu is hidden. This event is only fired if the menu contained a menu item that was created by the extension.",
+ "parameters": []
}
]
}
]
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -89,16 +89,17 @@ skip-if = (os == 'win' && ccov) # Bug 14
[browser_ext_geckoProfiler_symbolicate.js]
[browser_ext_getViews.js]
[browser_ext_history_redirect.js]
[browser_ext_identity_indication.js]
[browser_ext_incognito_views.js]
[browser_ext_incognito_popup.js]
[browser_ext_lastError.js]
[browser_ext_menus.js]
+[browser_ext_menus_events.js]
[browser_ext_omnibox.js]
[browser_ext_openPanel.js]
[browser_ext_optionsPage_browser_style.js]
[browser_ext_optionsPage_modals.js]
[browser_ext_optionsPage_privileges.js]
[browser_ext_pageAction_context.js]
[browser_ext_pageAction_contextMenu.js]
[browser_ext_pageAction_popup.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_menus_events.js
@@ -0,0 +1,308 @@
+/* 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";
+
+const PAGE = "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html";
+
+// Registers a context menu using menus.create(menuCreateParams) and checks
+// whether the menus.onShown and menus.onHidden events are fired as expected.
+// doOpenMenu must open the menu and its returned promise must resolve after the
+// menu is shown. Similarly, doCloseMenu must hide the menu.
+async function testShowHideEvent({menuCreateParams, doOpenMenu, doCloseMenu,
+ expectedShownEvent}) {
+ async function background() {
+ function awaitMessage(expectedId) {
+ return new Promise(resolve => {
+ browser.test.log(`Waiting for message: ${expectedId}`);
+ browser.test.onMessage.addListener(function listener(id, msg) {
+ browser.test.assertEq(expectedId, id, "Expected message");
+ browser.test.onMessage.removeListener(listener);
+ resolve(msg);
+ });
+ });
+ }
+
+ let menuCreateParams = await awaitMessage("create-params");
+
+ let shownEvents = [];
+ let hiddenEvents = [];
+
+ browser.menus.onShown.addListener(event => shownEvents.push(event));
+ browser.menus.onHidden.addListener(event => hiddenEvents.push(event));
+
+ const [tab] = await browser.tabs.query({active: true});
+ await browser.pageAction.show(tab.id);
+
+ let menuId;
+ await new Promise(resolve => {
+ menuId = browser.menus.create(menuCreateParams, resolve);
+ });
+ browser.test.assertEq(0, shownEvents.length, "no onShown before menu");
+ browser.test.assertEq(0, hiddenEvents.length, "no onHidden before menu");
+ browser.test.sendMessage("menu-registered", menuId);
+
+ await awaitMessage("assert-menu-shown");
+ browser.test.assertEq(1, shownEvents.length, "expected onShown");
+ browser.test.assertEq(0, hiddenEvents.length, "no onHidden before closing");
+ browser.test.sendMessage("onShown-event-data", shownEvents[0]);
+
+ await awaitMessage("assert-menu-hidden");
+ browser.test.assertEq(1, shownEvents.length, "expected no more onShown");
+ browser.test.assertEq(1, hiddenEvents.length, "expected onHidden");
+ browser.test.sendMessage("onHidden-event-data", hiddenEvents[0]);
+ }
+
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ page_action: {},
+ browser_action: {},
+ permissions: ["menus"],
+ },
+ });
+ await extension.startup();
+ extension.sendMessage("create-params", menuCreateParams);
+ let menuId = await extension.awaitMessage("menu-registered");
+
+ await doOpenMenu(extension);
+ extension.sendMessage("assert-menu-shown");
+ let shownEvent = await extension.awaitMessage("onShown-event-data");
+
+ // menuCreateParams.id is not set, therefore a numeric ID is generated.
+ expectedShownEvent.menuIds = [menuId];
+ Assert.deepEqual(shownEvent, expectedShownEvent, "expected onShown info");
+
+ await doCloseMenu();
+ extension.sendMessage("assert-menu-hidden");
+ let hiddenEvent = await extension.awaitMessage("onHidden-event-data");
+ is(hiddenEvent, undefined, "expected no event data for onHidden event");
+
+ await extension.unload();
+ await BrowserTestUtils.removeTab(tab);
+}
+
+add_task(async function test_no_show_hide_without_menu_item() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ let events = [];
+ browser.menus.onShown.addListener(data => events.push(data));
+ browser.menus.onHidden.addListener(() => events.push("onHidden"));
+ browser.test.onMessage.addListener(() => {
+ browser.test.assertEq("[]", JSON.stringify(events),
+ "Should not have any events when the context menu does not match");
+ browser.test.notifyPass("done listening to menu events");
+ });
+
+ browser.menus.create({
+ title: "never shown",
+ documentUrlPatterns: ["*://url-pattern-that-never-matches/*"],
+ contexts: ["all"],
+ });
+ },
+ manifest: {
+ permissions: ["menus"],
+ },
+ });
+
+ await extension.startup();
+
+ // Run another context menu test where onShown/onHidden will fire.
+ await testShowHideEvent({
+ menuCreateParams: {
+ title: "any menu item",
+ contexts: ["all"],
+ },
+ expectedShownEvent: {
+ contexts: ["page", "all"],
+ },
+ async doOpenMenu() {
+ await openContextMenu("body");
+ },
+ async doCloseMenu() {
+ await closeExtensionContextMenu();
+ },
+ });
+
+ // Now the menu has been shown and hidden, and in another extension the
+ // onShown/onHidden events have been dispatched.
+ // If the the first extension has not received any events by now, we can be
+ // confident that the onShown/onHidden events are not unexpectedly triggered.
+ extension.sendMessage("check menu events");
+ await extension.awaitFinish("done listening to menu events");
+ await extension.unload();
+});
+
+add_task(async function test_show_hide_pageAction() {
+ await testShowHideEvent({
+ menuCreateParams: {
+ title: "pageAction item",
+ contexts: ["page_action"],
+ },
+ expectedShownEvent: {
+ contexts: ["page_action", "all"],
+ },
+ async doOpenMenu(extension) {
+ await openActionContextMenu(extension, "page");
+ },
+ async doCloseMenu() {
+ await closeActionContextMenu(null, "page");
+ },
+ });
+});
+
+add_task(async function test_show_hide_browserAction() {
+ await testShowHideEvent({
+ menuCreateParams: {
+ title: "browserAction item",
+ contexts: ["browser_action"],
+ },
+ expectedShownEvent: {
+ contexts: ["browser_action", "all"],
+ },
+ async doOpenMenu(extension) {
+ await openActionContextMenu(extension, "browser");
+ },
+ async doCloseMenu() {
+ await closeActionContextMenu();
+ },
+ });
+});
+
+add_task(async function test_show_hide_tab() {
+ await testShowHideEvent({
+ menuCreateParams: {
+ title: "tab menu item",
+ contexts: ["tab"],
+ },
+ expectedShownEvent: {
+ contexts: ["tab"],
+ },
+ async doOpenMenu() {
+ await openTabContextMenu();
+ },
+ async doCloseMenu() {
+ await closeTabContextMenu();
+ },
+ });
+});
+
+add_task(async function test_show_hide_tools_menu() {
+ await testShowHideEvent({
+ menuCreateParams: {
+ title: "menu item",
+ contexts: ["tools_menu"],
+ },
+ expectedShownEvent: {
+ contexts: ["tools_menu"],
+ },
+ async doOpenMenu() {
+ await openToolsMenu();
+ },
+ async doCloseMenu() {
+ await closeToolsMenu();
+ },
+ });
+});
+
+add_task(async function test_show_hide_page() {
+ await testShowHideEvent({
+ menuCreateParams: {
+ title: "page menu item",
+ contexts: ["page"],
+ },
+ expectedShownEvent: {
+ contexts: ["page", "all"],
+ },
+ async doOpenMenu() {
+ await openContextMenu("body");
+ },
+ async doCloseMenu() {
+ await closeExtensionContextMenu();
+ },
+ });
+});
+
+add_task(async function test_show_hide_frame() {
+ await testShowHideEvent({
+ menuCreateParams: {
+ title: "subframe menu item",
+ contexts: ["frame"],
+ },
+ expectedShownEvent: {
+ contexts: ["frame", "all"],
+ },
+ async doOpenMenu() {
+ await openContextMenuInFrame("frame");
+ },
+ async doCloseMenu() {
+ await closeExtensionContextMenu();
+ },
+ });
+});
+
+add_task(async function test_show_hide_password() {
+ await testShowHideEvent({
+ menuCreateParams: {
+ title: "password item",
+ contexts: ["password"],
+ },
+ expectedShownEvent: {
+ contexts: ["password", "all"],
+ },
+ async doOpenMenu() {
+ await openContextMenu("#password");
+ },
+ async doCloseMenu() {
+ await closeExtensionContextMenu();
+ },
+ });
+});
+
+add_task(async function test_show_hide_image_link() {
+ await testShowHideEvent({
+ menuCreateParams: {
+ title: "image item",
+ contexts: ["image"],
+ },
+ expectedShownEvent: {
+ contexts: ["image", "link", "all"],
+ },
+ async doOpenMenu() {
+ await openContextMenu("#img-wrapped-in-link");
+ },
+ async doCloseMenu() {
+ await closeExtensionContextMenu();
+ },
+ });
+});
+
+add_task(async function test_show_hide_editable_selection() {
+ await testShowHideEvent({
+ menuCreateParams: {
+ title: "editable item",
+ contexts: ["editable"],
+ },
+ expectedShownEvent: {
+ contexts: ["editable", "selection", "all"],
+ },
+ async doOpenMenu() {
+ // Select lots of text in the test page before opening the menu.
+ await ContentTask.spawn(gBrowser.selectedBrowser, {}, function() {
+ let node = content.document.getElementById("editabletext");
+ node.select();
+ node.focus();
+ });
+
+ await openContextMenu("#editabletext");
+ },
+ async doCloseMenu() {
+ await closeExtensionContextMenu();
+ },
+ });
+});
+
+// TODO(robwu): Add test coverage for contexts audio, video (bug 1398542).
+