Bug 1325814 - Add extension API to find menu target draft
authorRob Wu <rob@robwu.nl>
Sat, 04 Aug 2018 18:09:49 +0200
changeset 828254 27072c31532d536dac4ec7d343a759bde75df575
parent 827545 fcda6b3f90d5f599d7629ddd388d781ad5bc8d8a
child 828255 8530d690e417965b653e97ec006966b7869ba06b
child 828257 a81162b7ec2530c7024c3a1d88f40c68f55f07bf
child 828266 efaa0db71538c4e222ce4afe50882582f664457f
push id118656
push userbmo:rob@robwu.nl
push dateFri, 10 Aug 2018 15:25:50 +0000
bugs1325814
milestone63.0a1
Bug 1325814 - Add extension API to find menu target - Add info.targetElementId to menus.onShown event. - Add info.targetElementId to menus.onClicked event. - Add menus.getTargetElement API that is available to all contexts, including content scripts, which allows extensions to get the DOM element for a given targetElementId. - Add new schema instead of re-using schemas/menus.json to avoid sending too much schema data (of the existing menus API) to content processes. MozReview-Commit-ID: 6Onf7jZlIho
browser/base/content/nsContextMenu.js
browser/components/extensions/child/ext-browser-content-only.js
browser/components/extensions/child/ext-browser.js
browser/components/extensions/child/ext-menus-child.js
browser/components/extensions/ext-browser.json
browser/components/extensions/extensions-browser.manifest
browser/components/extensions/jar.mn
browser/components/extensions/parent/ext-menus.js
browser/components/extensions/schemas/jar.mn
browser/components/extensions/schemas/menus.json
browser/components/extensions/schemas/menus_child.json
browser/modules/ContextMenu.jsm
toolkit/components/extensions/Schemas.jsm
--- a/browser/base/content/nsContextMenu.js
+++ b/browser/base/content/nsContextMenu.js
@@ -103,16 +103,17 @@ nsContextMenu.prototype = {
                                     this.browser, aXulMenu);
       } else {
         this.hasPageMenu = PageMenuParent.buildAndAddToPopup(this.target, aXulMenu);
       }
 
       let subject = {
         menu: aXulMenu,
         tab: gBrowser ? gBrowser.getTabForBrowser(this.browser) : undefined,
+        timeStamp: this.timeStamp,
         isContentSelected: this.isContentSelected,
         inFrame: this.inFrame,
         isTextSelected: this.isTextSelected,
         onTextInput: this.onTextInput,
         onLink: this.onLink,
         onImage: this.onImage,
         onVideo: this.onVideo,
         onAudio: this.onAudio,
@@ -158,16 +159,17 @@ nsContextMenu.prototype = {
 
     if (gContextMenuContentData) {
       context = gContextMenuContentData.context;
       gContextMenuContentData.context = null;
       this.isRemote = gContextMenuContentData.isRemote;
     }
 
     this.shouldDisplay = context.shouldDisplay;
+    this.timeStamp = context.timeStamp;
 
     // Assign what's _possibly_ needed from `context` sent by ContextMenu.jsm
     // Keep this consistent with the similar code in ContextMenu's _setContext
     this.bgImageURL          = context.bgImageURL;
     this.imageDescURL        = context.imageDescURL;
     this.imageInfo           = context.imageInfo;
     this.mediaURL            = context.mediaURL;
     this.webExtBrowserType   = context.webExtBrowserType;
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/child/ext-browser-content-only.js
@@ -0,0 +1,11 @@
+"use strict";
+
+extensions.registerModules({
+  menusChild: {
+    url: "chrome://browser/content/child/ext-menus-child.js",
+    scopes: ["content_child"],
+    paths: [
+      ["menus"],
+    ],
+  },
+});
--- a/browser/components/extensions/child/ext-browser.js
+++ b/browser/components/extensions/child/ext-browser.js
@@ -33,16 +33,23 @@ extensions.registerModules({
   menusInternal: {
     url: "chrome://browser/content/child/ext-menus.js",
     scopes: ["addon_child"],
     paths: [
       ["contextMenus"],
       ["menus"],
     ],
   },
+  menusChild: {
+    url: "chrome://browser/content/child/ext-menus-child.js",
+    scopes: ["addon_child", "devtools_child"],
+    paths: [
+      ["menus"],
+    ],
+  },
   omnibox: {
     url: "chrome://browser/content/child/ext-omnibox.js",
     scopes: ["addon_child"],
     paths: [
       ["omnibox"],
     ],
   },
   tabs: {
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/child/ext-menus-child.js
@@ -0,0 +1,26 @@
+"use strict";
+
+this.menusChild = class extends ExtensionAPI {
+  getAPI(context) {
+    return {
+      menus: {
+        getTargetElement(targetElementId) {
+          let tabChildGlobal = context.messageManager;
+          let {contextMenu} = tabChildGlobal;
+          let element;
+          if (contextMenu) {
+            let {lastMenuTarget} = contextMenu;
+            if (lastMenuTarget && Math.floor(lastMenuTarget.timeStamp) === targetElementId) {
+              element = lastMenuTarget.targetRef.get();
+            }
+          }
+          // TODO: Support shadow DOM (now we return null).
+          if (element && context.contentWindow.document.contains(element)) {
+            return element;
+          }
+          return null;
+        },
+      },
+    };
+  }
+};
--- a/browser/components/extensions/ext-browser.json
+++ b/browser/components/extensions/ext-browser.json
@@ -94,16 +94,20 @@
   "identity": {
     "url": "chrome://extensions/content/parent/ext-identity.js",
     "schema": "chrome://extensions/content/schemas/identity.json",
     "scopes": ["addon_parent"],
     "paths": [
       ["identity"]
     ]
   },
+  "menusChild": {
+    "schema": "chrome://browser/content/schemas/menus_child.json",
+    "scopes": ["addon_child", "content_child", "devtools_child"]
+  },
   "menusInternal": {
     "url": "chrome://browser/content/parent/ext-menus.js",
     "schema": "chrome://browser/content/schemas/menus.json",
     "scopes": ["addon_parent"],
     "paths": [
       ["contextMenus"],
       ["menus"],
       ["menusInternal"]
--- a/browser/components/extensions/extensions-browser.manifest
+++ b/browser/components/extensions/extensions-browser.manifest
@@ -1,5 +1,6 @@
 category webextension-modules browser chrome://browser/content/ext-browser.json
 
 category webextension-scripts c-browser chrome://browser/content/parent/ext-browser.js
+category webextension-scripts-content browser chrome://browser/content/child/ext-browser-content-only.js
 category webextension-scripts-devtools browser chrome://browser/content/child/ext-browser.js
 category webextension-scripts-addon browser chrome://browser/content/child/ext-browser.js
--- a/browser/components/extensions/jar.mn
+++ b/browser/components/extensions/jar.mn
@@ -32,15 +32,17 @@ browser.jar:
     content/browser/parent/ext-pkcs11.js (parent/ext-pkcs11.js)
     content/browser/parent/ext-search.js (parent/ext-search.js)
     content/browser/parent/ext-sessions.js (parent/ext-sessions.js)
     content/browser/parent/ext-sidebarAction.js (parent/ext-sidebarAction.js)
     content/browser/parent/ext-tabs.js (parent/ext-tabs.js)
     content/browser/parent/ext-url-overrides.js (parent/ext-url-overrides.js)
     content/browser/parent/ext-windows.js (parent/ext-windows.js)
     content/browser/child/ext-browser.js (child/ext-browser.js)
+    content/browser/child/ext-browser-content-only.js (child/ext-browser-content-only.js)
     content/browser/child/ext-devtools-inspectedWindow.js (child/ext-devtools-inspectedWindow.js)
     content/browser/child/ext-devtools-network.js (child/ext-devtools-network.js)
     content/browser/child/ext-devtools-panels.js (child/ext-devtools-panels.js)
     content/browser/child/ext-devtools.js (child/ext-devtools.js)
     content/browser/child/ext-menus.js (child/ext-menus.js)
+    content/browser/child/ext-menus-child.js (child/ext-menus-child.js)
     content/browser/child/ext-omnibox.js (child/ext-omnibox.js)
     content/browser/child/ext-tabs.js (child/ext-tabs.js)
--- a/browser/components/extensions/parent/ext-menus.js
+++ b/browser/components/extensions/parent/ext-menus.js
@@ -465,32 +465,38 @@ const getMenuContexts = contextData => {
   // 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 addMenuEventInfo(info, contextData, includeSensitiveData) {
+function addMenuEventInfo(info, contextData, extension, includeSensitiveData) {
   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) {
@@ -667,17 +673,17 @@ MenuItem.prototype = {
   getClickInfo(contextData, wasChecked) {
     let info = {
       menuItemId: this.id,
     };
     if (this.parent) {
       info.parentMenuItemId = this.parentId;
     }
 
-    addMenuEventInfo(info, contextData, true);
+    addMenuEventInfo(info, contextData, this.extension, true);
 
     if ((this.type === "checkbox") || (this.type === "radio")) {
       info.checked = this.checked;
       info.wasChecked = wasChecked;
     }
 
     return info;
   },
@@ -828,17 +834,17 @@ this.menusInternal = class extends Exten
             // 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.whiteListedHosts.matches(contextUrl));
 
-            addMenuEventInfo(info, contextData, includeSensitiveData);
+            addMenuEventInfo(info, contextData, extension, includeSensitiveData);
 
             let tab = nativeTab && extension.tabManager.convert(nativeTab);
             fire.sync(info, tab);
           };
           gOnShownSubscribers.add(extension);
           extension.on("webext-menu-shown", listener);
           return () => {
             gOnShownSubscribers.delete(extension);
--- a/browser/components/extensions/schemas/jar.mn
+++ b/browser/components/extensions/schemas/jar.mn
@@ -11,16 +11,17 @@ browser.jar:
     content/browser/schemas/devtools.json
     content/browser/schemas/devtools_inspected_window.json
     content/browser/schemas/devtools_network.json
     content/browser/schemas/devtools_panels.json
     content/browser/schemas/find.json
     content/browser/schemas/geckoProfiler.json
     content/browser/schemas/history.json
     content/browser/schemas/menus.json
+    content/browser/schemas/menus_child.json
     content/browser/schemas/omnibox.json
     content/browser/schemas/page_action.json
     content/browser/schemas/pkcs11.json
     content/browser/schemas/search.json
     content/browser/schemas/sessions.json
     content/browser/schemas/sidebar_action.json
     content/browser/schemas/tabs.json
     content/browser/schemas/url_overrides.json
--- a/browser/components/extensions/schemas/menus.json
+++ b/browser/components/extensions/schemas/menus.json
@@ -130,16 +130,21 @@
           },
           "modifiers": {
             "type": "array",
             "items": {
               "type": "string",
               "enum": ["Shift", "Alt", "Command", "Ctrl", "MacCtrl"]
             },
             "description": "An array of keyboard modifiers that were held while the menu item was clicked."
+          },
+          "targetElementId": {
+            "type": "integer",
+            "optional": true,
+            "description": "An identifier of the clicked element, if any. Use menus.getTargetElement in the page to find the corresponding element."
           }
         }
       }
     ],
     "functions": [
       {
         "name": "create",
         "type": "function",
@@ -452,16 +457,20 @@
               },
               "frameUrl": {
                 "type": "string",
                 "optional": true
               },
               "selectionText": {
                 "type": "string",
                 "optional": true
+              },
+              "targetElementId": {
+                "type": "integer",
+                "optional": true
               }
             }
           },
           {
             "name": "tab",
             "$ref": "tabs.Tab",
             "description": "The details of the tab where the menu was opened."
           }
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/schemas/menus_child.json
@@ -0,0 +1,29 @@
+[
+  {
+    "namespace": "menus",
+    "permissions": ["menus"],
+    "allowedContexts": ["content", "devtools"],
+    "description": "The part of the menus API that is available in all extension contexts, including content scripts.",
+    "functions": [
+      {
+        "name": "getTargetElement",
+        "type": "function",
+        "allowedContexts": ["content", "devtools"],
+        "description": "Retrieve the element that was associated with a recent contextmenu event.",
+        "parameters": [
+          {
+            "type": "integer",
+            "description": "The identifier of the clicked element, available as info.targetElementId in the menus.onShown, onClicked or onclick event.",
+            "name": "targetElementId"
+          }
+        ],
+        "returns": {
+          "type": "object",
+          "optional": true,
+          "isInstanceOf": "Element",
+          "additionalProperties": { "type": "any" }
+        }
+      }
+    ]
+  }
+]
--- a/browser/modules/ContextMenu.jsm
+++ b/browser/modules/ContextMenu.jsm
@@ -223,16 +223,17 @@ const messageListeners = {
 
 class ContextMenu {
   // PUBLIC
   constructor(global) {
     this.target = null;
     this.context = null;
     this.global = global;
     this.content = global.content;
+    this.lastMenuTarget = null;
 
     Object.keys(messageListeners).forEach(key =>
       global.addMessageListener(key, messageListeners[key].bind(this))
     );
   }
 
   /**
    * Returns the event target of the context menu, using a locally stored
@@ -665,16 +666,17 @@ class ContextMenu {
 
     delete context.linkURI;
   }
 
   _setContext(aEvent) {
     this.context = Object.create(null);
     const context = this.context;
 
+    context.timeStamp = aEvent.timeStamp;
     context.screenX = aEvent.screenX;
     context.screenY = aEvent.screenY;
     context.mozInputSource = aEvent.mozInputSource;
 
     const node = aEvent.composedTarget;
 
     const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
 
@@ -743,16 +745,23 @@ class ContextMenu {
     context.inSyntheticDoc = context.target.ownerDocument.mozSyntheticDocument;
 
     context.shouldInitInlineSpellCheckerUINoChildren = false;
     context.shouldInitInlineSpellCheckerUIWithChildren = false;
 
     let editFlags = SpellCheckHelper.isEditable(context.target, this.content);
     this._setContextForNodesNoChildren(editFlags);
     this._setContextForNodesWithChildren(editFlags);
+
+    this.lastMenuTarget = {
+      // Remember the node for extensions.
+      targetRef: Cu.getWeakReference(node),
+      // The timestamp is used to verify that the target wasn't changed since the observed menu event.
+      timeStamp: context.timeStamp,
+    };
   }
 
   /**
    * Sets up the parts of the context menu for when when nodes have no children.
    *
    * @param {Integer} editFlags The edit flags for the node. See SpellCheckHelper
    *                            for the details.
    */
--- a/toolkit/components/extensions/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -1734,17 +1734,18 @@ class ObjectType extends Type {
           if (Object.keys(this.properties).length ||
               this.patternProperties.length ||
               !(this.additionalProperties instanceof AnyType)) {
             throw new Error("InternalError: isInstanceOf can only be used " +
                             "with objects that are otherwise unrestricted");
           }
         }
 
-        if (ChromeUtils.getClassName(value) !== this.isInstanceOf) {
+        if (ChromeUtils.getClassName(value) !== this.isInstanceOf &&
+            (this.isInstanceOf !== "Element" || value.nodeType !== 1)) {
           return context.error(`Object must be an instance of ${this.isInstanceOf}`,
                                `be an instance of ${this.isInstanceOf}`);
         }
 
         // This is kind of a hack, but we can't normalize things that
         // aren't JSON, so we just return them.
         return this.postprocess({value}, context);
       }