Bug 1215376 - Add menus.refresh to update menus draft
authorRob Wu <rob@robwu.nl>
Wed, 13 Sep 2017 03:14:00 +0200
changeset 666895 e193167e7fe99c3e7a296f569072a5ce68eb9d8e
parent 666894 c018e89114442c3d6b7c91f97d255171a44a9ebd
child 666896 2bb7ae90dbd92f3dbaa3baf31476caf5b80fcdc2
child 670562 59f9eda63c3d81534abe0558f4a90e634d2e967f
push id80539
push userbmo:rob@robwu.nl
push dateTue, 19 Sep 2017 10:50:55 +0000
bugs1215376
milestone57.0a1
Bug 1215376 - Add menus.refresh to update menus Together with the previous patches that introduced menus.onShown, this enabled extensions to update/remove menu items after a menu has been shown. At this point, it is not yet possible to add a menu item if an extension has not shown a menu item before; this can be added later. MozReview-Commit-ID: E0JrE9gd4gS
browser/components/extensions/ext-menus.js
browser/components/extensions/schemas/menus.json
browser/components/extensions/test/browser/browser-common.ini
browser/components/extensions/test/browser/browser_ext_menus_refresh.js
--- 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": [
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -82,16 +82,17 @@ skip-if = os == 'mac' # Bug 1374749 will
 [browser_ext_geckoProfiler_symbolicate.js]
 [browser_ext_getViews.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_menus_refresh.js]
 [browser_ext_omnibox.js]
 skip-if = debug || asan # Bug 1354681
 [browser_ext_openPanel.js]
 [browser_ext_optionsPage_browser_style.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_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();
+});