--- a/browser/components/extensions/ext-menus.js
+++ b/browser/components/extensions/ext-menus.js
@@ -142,16 +142,25 @@ var gMenuBuilder = {
this.itemsToCleanUp.add(separator);
this.xulMenu.append(separator);
}
this.xulMenu.appendChild(rootElement);
this.itemsToCleanUp.add(rootElement);
},
+ removeSeparatorIfNoTopLevelItems() {
+ if (this.itemsToCleanUp.size === 1) {
+ // Remove the separator if all extension menu items have disappeared.
+ const separator = this.itemsToCleanUp.values().next().value;
+ separator.remove();
+ this.itemsToCleanUp.clear();
+ }
+ },
+
removeTopLevelMenuIfNeeded(element) {
// If there is only one visible top level element we don't need the
// root menu element for the extension.
let menuPopup = element.firstChild;
if (menuPopup && menuPopup.childNodes.length == 1) {
let onlyChild = menuPopup.firstChild;
// Keep single checkbox items in the submenu on Linux since
@@ -298,39 +307,98 @@ var gMenuBuilder = {
element.setAttribute("class", "menu-iconic");
} else if (element.localName == "menuitem") {
element.setAttribute("class", "menuitem-iconic");
}
element.setAttribute("image", resolvedURL);
},
+ rebuildMenu(extension) {
+ let {contextData} = this;
+ if (!contextData) {
+ // This happens if the menu is not visible.
+ // This also happens if there are no visible extension menu items.
+ return;
+ }
+
+ if (!gShownMenuItems.has(extension)) {
+ // The onShown event was not fired for the extension, so the extension
+ // does not know that a menu is being shown, and therefore they should
+ // not care whether the extension menu is updated.
+ return;
+ }
+
+ if (contextData.onBrowserAction || contextData.onPageAction) {
+ // The action menu can only have items from one extension, so remove all
+ // items (including the separator) and rebuild the action menu (if any).
+ for (let item of this.itemsToCleanUp) {
+ item.remove();
+ }
+ this.itemsToCleanUp.clear();
+ this.buildActionContextMenu(contextData);
+ return;
+ }
+
+ // First find the one and only top-level menu item for the extension.
+ let elementIdPrefix = `${makeWidgetId(extension.id)}-menuitem-`;
+ let oldRoot = null;
+ for (let item = this.xulMenu.lastElementChild; item !== null; item = item.previousElementSibling) {
+ if (item.id && item.id.startsWith(elementIdPrefix)) {
+ oldRoot = item;
+ this.itemsToCleanUp.delete(oldRoot);
+ break;
+ }
+ }
+
+ let root = gRootItems.get(extension);
+ let newRoot = root && this.createTopLevelElement(root, contextData);
+ if (newRoot) {
+ this.itemsToCleanUp.add(newRoot);
+ if (oldRoot) {
+ oldRoot.replaceWith(newRoot);
+ } else {
+ this.appendTopLevelElement(newRoot);
+ }
+ } else if (oldRoot) {
+ oldRoot.remove();
+ this.removeSeparatorIfNoTopLevelItems();
+ }
+ },
+
afterBuildingMenu(contextData) {
- if (gShownMenuItems.size === 0) {
+ // Left-hand side is an optimization: No need to construct event details if
+ // nobody is subscribing to the event.
+ // Right-hand side is to prevent onShown from being fired when a menu is
+ // updated.
+ if (gShownMenuItems.size === 0 || this.contextData) {
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);
}
+
+ this.contextData = contextData;
},
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");
@@ -688,16 +756,20 @@ this.menusInternal = class extends Exten
}
}
}
getAPI(context) {
let {extension} = context;
const menus = {
+ refresh() {
+ gMenuBuilder.rebuildMenu(extension);
+ },
+
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);
};
--- a/browser/components/extensions/schemas/menus.json
+++ b/browser/components/extensions/schemas/menus.json
@@ -365,16 +365,23 @@
{
"type": "function",
"name": "callback",
"optional": true,
"parameters": [],
"description": "Called when removal is complete."
}
]
+ },
+ {
+ "name": "refresh",
+ "type": "function",
+ "description": "Updates the extension items in the shown menu, including changes that have been made since the menu was shown. Has no effect if the menu is hidden. Rebuilding a shown menu is an expensive operation, only invoke this method when necessary.",
+ "async": true,
+ "parameters": []
}
],
"events": [
{
"name": "onClicked",
"type": "function",
"description": "Fired when a context menu item is clicked.",
"parameters": [
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_menus_refresh.js
@@ -0,0 +1,290 @@
+/* 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";
+
+// Load an extension that has the "menus" permission. The returned Extension
+// instance has a `callMenuApi` method to easily call a browser.menus method
+// and wait for its result. It also emits the "onShown fired" message whenever
+// the menus.onShown event is fired.
+// The `getXULElementByMenuId` method returns the XUL element that corresponds
+// to the menu item ID from the browser.menus API (if existent, null otherwise).
+function loadExtensionWithMenusApi() {
+ async function background() {
+ browser.menus.onShown.addListener(() => {
+ browser.test.sendMessage("onShown fired");
+ });
+ browser.test.onMessage.addListener((method, ...params) => {
+ let result;
+ if (method === "create") {
+ result = new Promise(resolve => {
+ browser.menus.create(params[0], resolve);
+ });
+ } else {
+ result = browser.menus[method](...params);
+ }
+ result.then(() => {
+ browser.test.sendMessage(`${method}-result`);
+ });
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ browser_action: {},
+ permissions: ["menus"],
+ },
+ });
+
+ extension.callMenuApi = async function(method, ...params) {
+ info(`Calling ${method}(${JSON.stringify(params)})`);
+ extension.sendMessage(method, ...params);
+ return extension.awaitMessage(`${method}-result`);
+ };
+
+ extension.getXULElementByMenuId = id => {
+ // Same implementation as elementId getter in ext-menus.js
+ if (typeof id != "number") {
+ id = `_${id}`;
+ }
+ let xulId = `${makeWidgetId(extension.id)}-menuitem-${id}`;
+ return document.getElementById(xulId);
+ };
+
+ return extension;
+}
+
+// Tests whether browser.menus.refresh works as expected with respect to the
+// menu items that are added/updated/removed before/during/after opening a menu:
+// - browser.refresh before a menu is shown should not have any effect.
+// - browser.refresh while a menu is shown should update the menu.
+// - browser.refresh after a menu is hidden should not have any effect.
+async function testRefreshMenusWhileVisible({contexts, doOpenMenu, doCloseMenu,
+ }) {
+ let extension = loadExtensionWithMenusApi();
+ await extension.startup();
+ await extension.callMenuApi("create", {
+ id: "abc",
+ title: "first",
+ contexts,
+ });
+ let elem = extension.getXULElementByMenuId("abc");
+ is(elem, null, "Menu item should not be visible");
+
+ // Refresh before a menu is shown - should be noop.
+ await extension.callMenuApi("refresh");
+ elem = extension.getXULElementByMenuId("abc");
+ is(elem, null, "Menu item should still not be visible");
+
+ // Open menu and expect menu to be rendered.
+ await doOpenMenu(extension);
+ elem = extension.getXULElementByMenuId("abc");
+ is(elem.getAttribute("label"), "first", "expected label");
+
+ await extension.awaitMessage("onShown fired");
+
+ // Add new menus, but don't expect them to be rendered yet.
+ await extension.callMenuApi("update", "abc", {title: "updated first"});
+ await extension.callMenuApi("create", {
+ id: "def",
+ title: "second",
+ contexts,
+ });
+
+ elem = extension.getXULElementByMenuId("abc");
+ is(elem.getAttribute("label"), "first", "expected unchanged label");
+ elem = extension.getXULElementByMenuId("def");
+ is(elem, null, "Second menu item should not be visible");
+
+ // Refresh while a menu is shown - should be updated.
+ await extension.callMenuApi("refresh");
+
+ elem = extension.getXULElementByMenuId("abc");
+ is(elem.getAttribute("label"), "updated first", "expected updated label");
+ elem = extension.getXULElementByMenuId("def");
+ is(elem.getAttribute("label"), "second", "expected second label");
+
+ // Update the two menu items again.
+ await extension.callMenuApi("update", "abc", {enabled: false});
+ await extension.callMenuApi("update", "def", {enabled: false});
+ await extension.callMenuApi("refresh");
+ elem = extension.getXULElementByMenuId("abc");
+ is(elem.getAttribute("disabled"), "true", "1st menu item should be disabled");
+ elem = extension.getXULElementByMenuId("def");
+ is(elem.getAttribute("disabled"), "true", "2nd menu item should be disabled");
+
+ // Remove one.
+ await extension.callMenuApi("remove", "abc");
+ await extension.callMenuApi("refresh");
+ elem = extension.getXULElementByMenuId("def");
+ is(elem.getAttribute("label"), "second", "other menu item should exist");
+ elem = extension.getXULElementByMenuId("abc");
+ is(elem, null, "removed menu item should be gone");
+
+ // Remove the last one.
+ await extension.callMenuApi("removeAll");
+ await extension.callMenuApi("refresh");
+ elem = extension.getXULElementByMenuId("def");
+ is(elem, null, "all menu items should be gone");
+
+ // At this point all menu items have been removed. Create a new menu item so
+ // we can confirm that browser.menus.refresh() does not render the menu item
+ // after the menu has been hidden.
+ await extension.callMenuApi("create", {
+ // The menu item with ID "abc" was removed before, so re-using the ID should
+ // not cause any issues:
+ id: "abc",
+ title: "re-used",
+ contexts,
+ });
+ await extension.callMenuApi("refresh");
+ elem = extension.getXULElementByMenuId("abc");
+ is(elem.getAttribute("label"), "re-used", "menu item should be created");
+
+ await doCloseMenu();
+
+ elem = extension.getXULElementByMenuId("abc");
+ is(elem, null, "menu item must be gone");
+
+ // Refresh after menu was hidden - should be noop.
+ await extension.callMenuApi("refresh");
+ elem = extension.getXULElementByMenuId("abc");
+ is(elem, null, "menu item must still be gone");
+
+ await extension.unload();
+}
+
+add_task(async function refresh_menus_with_browser_action() {
+ await testRefreshMenusWhileVisible({
+ contexts: ["browser_action"],
+ async doOpenMenu(extension) {
+ await openActionContextMenu(extension, "browser");
+ },
+ async doCloseMenu() {
+ await closeActionContextMenu();
+ },
+ });
+});
+
+add_task(async function refresh_menus_with_tab() {
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE);
+ await testRefreshMenusWhileVisible({
+ contexts: ["tab"],
+ async doOpenMenu() {
+ await openTabContextMenu();
+ },
+ async doCloseMenu() {
+ await closeTabContextMenu();
+ },
+ });
+ await BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function refresh_menus_with_tools_menu() {
+ await testRefreshMenusWhileVisible({
+ contexts: ["tools_menu"],
+ async doOpenMenu() {
+ await openToolsMenu();
+ },
+ async doCloseMenu() {
+ await closeToolsMenu();
+ },
+ });
+});
+
+add_task(async function refresh_menus_with_page() {
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE);
+ await testRefreshMenusWhileVisible({
+ contexts: ["page"],
+ async doOpenMenu() {
+ await openContextMenu("body");
+ },
+ async doCloseMenu() {
+ await closeExtensionContextMenu();
+ },
+ });
+ await BrowserTestUtils.removeTab(tab);
+});
+
+// refresh() only works for extensions with at least one visible menu item.
+// This test will fail if we ever add support for adding new menu items even if
+// the extension has no existing menu item in the shown menu.
+add_task(async function refresh_without_menus_at_onShown() {
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE);
+ let extension = loadExtensionWithMenusApi();
+ await extension.startup();
+
+ const doOpenMenu = () => openContextMenu("body");
+ const doCloseMenu = () => closeExtensionContextMenu();
+
+ await doOpenMenu();
+ await extension.callMenuApi("create", {
+ id: "too late",
+ title: "created after shown",
+ });
+ await extension.callMenuApi("refresh");
+ let elem = extension.getXULElementByMenuId("too late");
+ is(elem, null, "extension without visible menu items cannot add new items");
+
+ await extension.callMenuApi("update", "too late", {title: "the menu item"});
+ await extension.callMenuApi("refresh");
+ elem = extension.getXULElementByMenuId("too late");
+ is(elem, null, "updated menu item should still be invisible");
+
+ // The previously created menu item should be visible if the menu is closed
+ // and re-opened.
+ await doCloseMenu();
+ await doOpenMenu();
+ await extension.awaitMessage("onShown fired");
+ elem = extension.getXULElementByMenuId("too late");
+ is(elem.getAttribute("label"), "the menu item", "previously registered item");
+ await doCloseMenu();
+
+ await extension.unload();
+ await BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function refresh_menus_with_browser_action() {
+ let extension = loadExtensionWithMenusApi();
+ let other_extension = loadExtensionWithMenusApi();
+ await extension.startup();
+ await other_extension.startup();
+
+ await extension.callMenuApi("create", {
+ id: "action_item",
+ title: "visible menu item",
+ contexts: ["browser_action"],
+ });
+
+ await other_extension.callMenuApi("create", {
+ id: "action_item",
+ title: "other menu item",
+ contexts: ["browser_action"],
+ });
+
+ await openActionContextMenu(extension, "browser");
+ await extension.awaitMessage("onShown fired");
+
+ let elem = extension.getXULElementByMenuId("action_item");
+ is(elem.getAttribute("label"), "visible menu item", "extension menu shown");
+ elem = other_extension.getXULElementByMenuId("action_item");
+ is(elem, null, "other extension's menu should be hidden");
+
+ await extension.callMenuApi("update", "action_item", {title: "changed"});
+ await other_extension.callMenuApi("update", "action_item", {title: "foo"});
+ await other_extension.callMenuApi("refresh");
+
+ // refreshing the menu of an unrelated extension should not affect the menu
+ // of another extension.
+ elem = extension.getXULElementByMenuId("action_item");
+ is(elem.getAttribute("label"), "visible menu item", "extension menu shown");
+ elem = other_extension.getXULElementByMenuId("action_item");
+ is(elem, null, "other extension's menu should be hidden");
+
+ await closeActionContextMenu();
+ await extension.unload();
+ await other_extension.unload();
+});