Bug 1291737 - Implements the devtools_page context. draft
authorLuca Greco <lgreco@mozilla.com>
Wed, 18 Jan 2017 15:55:21 +0100
changeset 463729 876d9795c390aa7435eb0eb973f4132c814c0eca
parent 463728 f9277a6231f63b64b378edeb846f3a89974142dd
child 463730 b92c3c3b679d8308d02ffd67aa49a36ddf76b25c
push id42159
push userluca.greco@alcacoop.it
push dateThu, 19 Jan 2017 17:57:06 +0000
bugs1291737
milestone53.0a1
Bug 1291737 - Implements the devtools_page context. MozReview-Commit-ID: CxS5e101C3z
browser/components/extensions/ext-devtools.js
browser/components/extensions/extensions-browser.manifest
browser/components/extensions/jar.mn
browser/components/extensions/schemas/devtools.json
browser/components/extensions/schemas/jar.mn
browser/components/extensions/test/browser/browser-common.ini
browser/components/extensions/test/browser/browser_ext_devtools_page.js
toolkit/components/extensions/ExtensionParent.jsm
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/ext-devtools.js
@@ -0,0 +1,299 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+/* global getTargetTabIdForToolbox */
+
+/**
+ * This module provides helpers used by the other specialized `ext-devtools-*.js` modules
+ * and the implementation of the `devtools_page`.
+ */
+
+XPCOMUtils.defineLazyModuleGetter(this, "gDevTools",
+                                  "resource://devtools/client/framework/gDevTools.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+                                  "resource://gre/modules/Task.jsm");
+
+Cu.import("resource://gre/modules/ExtensionParent.jsm");
+
+const {
+  HiddenExtensionPage,
+  watchExtensionProxyContextLoad,
+} = ExtensionParent;
+
+// Map[extension -> DevToolsPageDefinition]
+let devtoolsPageDefinitionMap = new Map();
+
+/**
+ * Retrieve the devtools target for the devtools extension proxy context
+ * (lazily cloned from the target of the toolbox associated to the context
+ * the first time that it is accessed).
+ *
+ * @param {DevToolsExtensionPageContextParent} context
+ *   A devtools extension proxy context.
+ *
+ * @returns {Promise<TabTarget>}
+ *   The cloned devtools target associated to the context.
+ */
+global.getDevToolsTargetForContext = (context) => {
+  return Task.spawn(function* asyncGetTabTarget() {
+    if (context.devToolsTarget) {
+      return context.devToolsTarget;
+    }
+
+    if (!context.devToolsToolbox || !context.devToolsToolbox.target) {
+      throw new Error("Unable to get a TabTarget for a context not associated to any toolbox");
+    }
+
+    if (!context.devToolsToolbox.target.isLocalTab) {
+      throw new Error("Unexpected target type: only local tabs are currently supported.");
+    }
+
+    const {TabTarget} = require("devtools/client/framework/target");
+
+    context.devToolsTarget = new TabTarget(context.devToolsToolbox.target.tab);
+    yield context.devToolsTarget.makeRemote();
+
+    return context.devToolsTarget;
+  });
+};
+
+/**
+ * Retrieve the devtools target for the devtools extension proxy context
+ * (lazily cloned from the target of the toolbox associated to the context
+ * the first time that it is accessed).
+ *
+ * @param {Toolbox} toolbox
+ *   A devtools toolbox instance.
+ *
+ * @returns {number}
+ *   The corresponding WebExtensions tabId.
+ */
+global.getTargetTabIdForToolbox = (toolbox) => {
+  let {target} = toolbox;
+
+  if (!target.isLocalTab) {
+    throw new Error("Unexpected target type: only local tabs are currently supported.");
+  }
+
+  let parentWindow = target.tab.linkedBrowser.ownerDocument.defaultView;
+  let tab = parentWindow.gBrowser.getTabForBrowser(target.tab.linkedBrowser);
+
+  return TabManager.getId(tab);
+};
+
+/**
+ * The DevToolsPage represents the "devtools_page" related to a particular
+ * Toolbox and WebExtension.
+ *
+ * The devtools_page contexts are invisible WebExtensions contexts, similar to the
+ * background page, associated to a single developer toolbox (e.g. If an add-on
+ * registers a devtools_page and the user opens 3 developer toolbox in 3 webpages,
+ * 3 devtools_page contexts will be created for that add-on).
+ *
+ * @param {Extension}              extension
+ *   The extension that owns the devtools_page.
+ * @param {Object}                 options
+ * @param {Toolbox}                options.toolbox
+ *   The developer toolbox instance related to this devtools_page.
+ * @param {string}                 options.url
+ *   The path to the devtools page html page relative to the extension base URL.
+ * @param {DevToolsPageDefinition} options.devToolsPageDefinition
+ *   The instance of the devToolsPageDefinition class related to this DevToolsPage.
+ */
+class DevToolsPage extends HiddenExtensionPage {
+  constructor(extension, options) {
+    super(extension, "devtools_page");
+
+    this.url = extension.baseURI.resolve(options.url);
+    this.toolbox = options.toolbox;
+    this.devToolsPageDefinition = options.devToolsPageDefinition;
+
+    this.unwatchExtensionProxyContextLoad = null;
+
+    this.waitForTopLevelContext = new Promise(resolve => {
+      this.resolveTopLevelContext = resolve;
+    });
+  }
+
+  build() {
+    return Task.spawn(function* () {
+      yield this.createBrowserElement();
+
+      // Listening to new proxy contexts.
+      this.unwatchExtensionProxyContextLoad = watchExtensionProxyContextLoad(this, context => {
+        // Keep track of the toolbox and target associated to the context, which is
+        // needed by the API methods implementation.
+        context.devToolsToolbox = this.toolbox;
+
+        if (!this.topLevelContext) {
+          this.topLevelContext = context;
+
+          // Ensure this devtools page is destroyed, when the top level context proxy is
+          // closed.
+          this.topLevelContext.callOnClose(this);
+
+          this.resolveTopLevelContext(context);
+        }
+      });
+
+      extensions.emit("extension-browser-inserted", this.browser, {
+        devtoolsToolboxInfo: {
+          inspectedWindowTabId: getTargetTabIdForToolbox(this.toolbox),
+        },
+      });
+
+      this.browser.loadURI(this.url);
+
+      yield this.waitForTopLevelContext;
+    }.bind(this));
+  }
+
+  close() {
+    if (this.closed) {
+      throw new Error("Unable to shutdown a closed DevToolsPage instance");
+    }
+
+    this.closed = true;
+
+    // Unregister the devtools page instance from the devtools page definition.
+    this.devToolsPageDefinition.forgetForTarget(this.toolbox.target);
+
+    // Unregister it from the resources to cleanup when the context has been closed.
+    if (this.topLevelContext) {
+      this.topLevelContext.forgetOnClose(this);
+    }
+
+    // Stop watching for any new proxy contexts from the devtools page.
+    if (this.unwatchExtensionProxyContextLoad) {
+      this.unwatchExtensionProxyContextLoad();
+      this.unwatchExtensionProxyContextLoad = null;
+    }
+
+    super.shutdown();
+  }
+}
+
+/**
+ * The DevToolsPageDefinitions class represents the "devtools_page" manifest property
+ * of a WebExtension.
+ *
+ * A DevToolsPageDefinition instance is created automatically when a WebExtension
+ * which contains the "devtools_page" manifest property has been loaded, and it is
+ * automatically destroyed when the related WebExtension has been unloaded,
+ * and so there will be at most one DevtoolsPageDefinition per add-on.
+ *
+ * Every time a developer tools toolbox is opened, the DevToolsPageDefinition creates
+ * and keep track of a DevToolsPage instance (which represents the actual devtools_page
+ * instance related to that particular toolbox).
+ *
+ * @param {Extension} extension
+ *   The extension that owns the devtools_page.
+ * @param {string}    url
+ *   The path to the devtools page html page relative to the extension base URL.
+ */
+class DevToolsPageDefinition {
+  constructor(extension, url) {
+    this.url = url;
+    this.extension = extension;
+
+    // Map[TabTarget -> DevToolsPage]
+    this.devtoolsPageForTarget = new Map();
+  }
+
+  buildForToolbox(toolbox) {
+    if (this.devtoolsPageForTarget.has(toolbox.target)) {
+      return Promise.reject(new Error("DevtoolsPage has been already created for this toolbox"));
+    }
+
+    const devtoolsPage = new DevToolsPage(this.extension, {
+      toolbox, url: this.url, devToolsPageDefinition: this,
+    });
+    this.devtoolsPageForTarget.set(toolbox.target, devtoolsPage);
+
+    return devtoolsPage.build();
+  }
+
+  shutdownForTarget(target) {
+    if (this.devtoolsPageForTarget.has(target)) {
+      const devtoolsPage = this.devtoolsPageForTarget.get(target);
+      devtoolsPage.close();
+
+      // `devtoolsPage.close()` should remove the instance from the map,
+      // raise an exception if it is still there.
+      if (this.devtoolsPageForTarget.has(target)) {
+        throw new Error(`Leaked DevToolsPage instance for target "${target.toString()}"`);
+      }
+    }
+  }
+
+  forgetForTarget(target) {
+    this.devtoolsPageForTarget.delete(target);
+  }
+
+  shutdown() {
+    for (let target of this.devtoolsPageForTarget.keys()) {
+      this.shutdownForTarget(target);
+    }
+
+    if (this.devtoolsPageForTarget.size > 0) {
+      throw new Error(
+        `Leaked ${this.devtoolsPageForTarget.size} DevToolsPage instances in devtoolsPageForTarget Map`
+      );
+    }
+  }
+}
+
+/* eslint-disable mozilla/balanced-listeners */
+
+// Create a devtools page context for a new opened toolbox,
+// based on the registered devtools_page definitions.
+gDevTools.on("toolbox-created", (evt, toolbox) => {
+  if (!toolbox.target.isLocalTab) {
+    // Only local tabs are currently supported (See Bug 1304378 for additional details
+    // related to remote targets support).
+    let msg = `Ignoring DevTools Toolbox for target "${toolbox.target.toString()}": ` +
+              `"${toolbox.target.name}" ("${toolbox.target.url}"). ` +
+              "Only local tab are currently supported by the WebExtensions DevTools API.";
+    let scriptError = Cc["@mozilla.org/scripterror;1"].createInstance(Ci.nsIScriptError);
+    scriptError.init(msg, null, null, null, null, Ci.nsIScriptError.warningFlag, "content javascript");
+    let consoleService = Cc["@mozilla.org/consoleservice;1"].getService(Ci.nsIConsoleService);
+    consoleService.logMessage(scriptError);
+
+    return;
+  }
+
+  for (let devtoolsPage of devtoolsPageDefinitionMap.values()) {
+    devtoolsPage.buildForToolbox(toolbox);
+  }
+});
+
+// Destroy a devtools page context for a destroyed toolbox,
+// based on the registered devtools_page definitions.
+gDevTools.on("toolbox-destroy", (evt, target) => {
+  if (!target.isLocalTab) {
+    // Only local tabs are currently supported (See Bug 1304378 for additional details
+    // related to remote targets support).
+    return;
+  }
+
+  for (let devtoolsPageDefinition of devtoolsPageDefinitionMap.values()) {
+    devtoolsPageDefinition.shutdownForTarget(target);
+  }
+});
+
+// Create and register a new devtools_page definition as specified in the
+// "devtools_page" property in the extension manifest.
+extensions.on("manifest_devtools_page", (type, directive, extension, manifest) => {
+  let devtoolsPageDefinition = new DevToolsPageDefinition(extension, manifest[directive]);
+  devtoolsPageDefinitionMap.set(extension, devtoolsPageDefinition);
+});
+
+// Destroy the registered devtools_page definition on extension shutdown.
+extensions.on("shutdown", (type, extension) => {
+  if (devtoolsPageDefinitionMap.has(extension)) {
+    devtoolsPageDefinitionMap.get(extension).shutdown();
+    devtoolsPageDefinitionMap.delete(extension);
+  }
+});
+/* eslint-enable mozilla/balanced-listeners */
--- a/browser/components/extensions/extensions-browser.manifest
+++ b/browser/components/extensions/extensions-browser.manifest
@@ -1,15 +1,16 @@
 # scripts
 category webextension-scripts bookmarks chrome://browser/content/ext-bookmarks.js
 category webextension-scripts browserAction chrome://browser/content/ext-browserAction.js
 category webextension-scripts browsingData chrome://browser/content/ext-browsingData.js
 category webextension-scripts commands chrome://browser/content/ext-commands.js
 category webextension-scripts contextMenus chrome://browser/content/ext-contextMenus.js
 category webextension-scripts desktop-runtime chrome://browser/content/ext-desktop-runtime.js
+category webextension-scripts devtools chrome://browser/content/ext-devtools.js
 category webextension-scripts history chrome://browser/content/ext-history.js
 category webextension-scripts omnibox chrome://browser/content/ext-omnibox.js
 category webextension-scripts pageAction chrome://browser/content/ext-pageAction.js
 category webextension-scripts sessions chrome://browser/content/ext-sessions.js
 category webextension-scripts tabs chrome://browser/content/ext-tabs.js
 category webextension-scripts theme chrome://browser/content/ext-theme.js
 category webextension-scripts utils chrome://browser/content/ext-utils.js
 category webextension-scripts windows chrome://browser/content/ext-windows.js
@@ -21,15 +22,16 @@ category webextension-scripts-addon tabs
 
 # schemas
 category webextension-schemas bookmarks chrome://browser/content/schemas/bookmarks.json
 category webextension-schemas browser_action chrome://browser/content/schemas/browser_action.json
 category webextension-schemas browsing_data chrome://browser/content/schemas/browsing_data.json
 category webextension-schemas commands chrome://browser/content/schemas/commands.json
 category webextension-schemas context_menus chrome://browser/content/schemas/context_menus.json
 category webextension-schemas context_menus_internal chrome://browser/content/schemas/context_menus_internal.json
+category webextension-schemas devtools chrome://browser/content/schemas/devtools.json
 category webextension-schemas history chrome://browser/content/schemas/history.json
 category webextension-schemas omnibox chrome://browser/content/schemas/omnibox.json
 category webextension-schemas page_action chrome://browser/content/schemas/page_action.json
 category webextension-schemas sessions chrome://browser/content/schemas/sessions.json
 category webextension-schemas tabs chrome://browser/content/schemas/tabs.json
 category webextension-schemas theme chrome://browser/content/schemas/theme.json
 category webextension-schemas windows chrome://browser/content/schemas/windows.json
--- a/browser/components/extensions/jar.mn
+++ b/browser/components/extensions/jar.mn
@@ -13,16 +13,17 @@ browser.jar:
 #endif
     content/browser/extension.svg
     content/browser/ext-bookmarks.js
     content/browser/ext-browserAction.js
     content/browser/ext-browsingData.js
     content/browser/ext-commands.js
     content/browser/ext-contextMenus.js
     content/browser/ext-desktop-runtime.js
+    content/browser/ext-devtools.js
     content/browser/ext-history.js
     content/browser/ext-omnibox.js
     content/browser/ext-pageAction.js
     content/browser/ext-sessions.js
     content/browser/ext-tabs.js
     content/browser/ext-theme.js
     content/browser/ext-utils.js
     content/browser/ext-windows.js
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/schemas/devtools.json
@@ -0,0 +1,16 @@
+[
+  {
+    "namespace": "manifest",
+    "types": [
+      {
+        "$extend": "WebExtensionManifest",
+        "properties": {
+          "devtools_page": {
+            "$ref": "ExtensionURL",
+            "optional": true
+          }
+        }
+      }
+    ]
+  }
+]
--- a/browser/components/extensions/schemas/jar.mn
+++ b/browser/components/extensions/schemas/jar.mn
@@ -4,15 +4,16 @@
 
 browser.jar:
     content/browser/schemas/bookmarks.json
     content/browser/schemas/browser_action.json
     content/browser/schemas/browsing_data.json
     content/browser/schemas/commands.json
     content/browser/schemas/context_menus.json
     content/browser/schemas/context_menus_internal.json
+    content/browser/schemas/devtools.json
     content/browser/schemas/history.json
     content/browser/schemas/omnibox.json
     content/browser/schemas/page_action.json
     content/browser/schemas/sessions.json
     content/browser/schemas/tabs.json
     content/browser/schemas/theme.json
     content/browser/schemas/windows.json
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -37,16 +37,17 @@ support-files =
 [browser_ext_contextMenus_checkboxes.js]
 [browser_ext_contextMenus_chrome.js]
 [browser_ext_contextMenus_icons.js]
 [browser_ext_contextMenus_onclick.js]
 [browser_ext_contextMenus_radioGroups.js]
 [browser_ext_contextMenus_uninstall.js]
 [browser_ext_contextMenus_urlPatterns.js]
 [browser_ext_currentWindow.js]
+[browser_ext_devtools_page.js]
 [browser_ext_getViews.js]
 [browser_ext_incognito_views.js]
 [browser_ext_incognito_popup.js]
 [browser_ext_lastError.js]
 [browser_ext_omnibox.js]
 [browser_ext_optionsPage_privileges.js]
 [browser_ext_pageAction_context.js]
 [browser_ext_pageAction_popup.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_devtools_page.js
@@ -0,0 +1,84 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+XPCOMUtils.defineLazyModuleGetter(this, "gDevTools",
+                                  "resource://devtools/client/framework/gDevTools.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "devtools",
+                                  "resource://devtools/shared/Loader.jsm");
+
+/**
+ * This test file ensures that:
+ *
+ * - the devtools_page property creates a new WebExtensions context
+ * - the devtools_page can exchange messages with the background page
+ */
+
+add_task(function* test_devtools_page_runtime_api_messaging() {
+  let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "http://mochi.test:8888/");
+
+  function background() {
+    browser.runtime.onConnect.addListener((port) => {
+      let portMessageReceived = false;
+
+      port.onDisconnect.addListener(() => {
+        browser.test.assertTrue(portMessageReceived,
+                                "Got a port message before the port disconnect event");
+        browser.test.notifyPass("devtools_page_connect.done");
+      });
+
+      port.onMessage.addListener((msg) => {
+        portMessageReceived = true;
+        browser.test.assertEq("devtools -> background port message", msg,
+                              "Got the expected message from the devtools page");
+        port.postMessage("background -> devtools port message");
+      });
+    });
+  }
+
+  function devtools_page() {
+    const port = browser.runtime.connect();
+    port.onMessage.addListener((msg) => {
+      browser.test.assertEq("background -> devtools port message", msg,
+                            "Got the expected message from the background page");
+      port.disconnect();
+    });
+    port.postMessage("devtools -> background port message");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background,
+    manifest: {
+      devtools_page: "devtools_page.html",
+    },
+    files: {
+      "devtools_page.html": `<!DOCTYPE html>
+      <html>
+       <head>
+         <meta charset="utf-8">
+       </head>
+       <body>
+         <script src="devtools_page.js"></script>
+       </body>
+      </html>`,
+      "devtools_page.js": devtools_page,
+    },
+  });
+
+  yield extension.startup();
+
+  let target = devtools.TargetFactory.forTab(tab);
+
+  yield gDevTools.showToolbox(target, "webconsole");
+  info("developer toolbox opened");
+
+  yield extension.awaitFinish("devtools_page_connect.done");
+
+  yield gDevTools.closeToolbox(target);
+
+  yield target.destroy();
+
+  yield extension.unload();
+
+  yield BrowserTestUtils.removeTab(tab);
+});
--- a/toolkit/components/extensions/ExtensionParent.jsm
+++ b/toolkit/components/extensions/ExtensionParent.jsm
@@ -831,15 +831,53 @@ function promiseExtensionViewLoaded(brow
   return new Promise(resolve => {
     browser.messageManager.addMessageListener("Extension:ExtensionViewLoaded", function onLoad() {
       browser.messageManager.removeMessageListener("Extension:ExtensionViewLoaded", onLoad);
       resolve();
     });
   });
 }
 
+/**
+ * This helper is used to subscribe a listener (e.g. in the ext-devtools API implementation)
+ * to be called for every ExtensionProxyContext created for an extension page given
+ * its related extension, viewType and browser element (both the top level context and any context
+ * created for the extension urls running into its iframe descendants).
+ *
+ * @param {object} params.extension
+ *   the Extension on which we are going to listen for the newly created ExtensionProxyContext.
+ * @param {string} params.viewType
+ *  the viewType of the WebExtension page that we are watching (e.g. "background" or "devtools_page").
+ * @param {XULElement} params.browser
+ *  the browser element of the WebExtension page that we are watching.
+ *
+ * @param {Function} onExtensionProxyContextLoaded
+ *  the callback that is called when a new context has been loaded (as `callback(context)`);
+ *
+ * @returns {Function}
+ *   Unsubscribe the listener.
+ */
+function watchExtensionProxyContextLoad({extension, viewType, browser}, onExtensionProxyContextLoaded) {
+  if (typeof onExtensionProxyContextLoaded !== "function") {
+    throw new Error("Missing onExtensionProxyContextLoaded handler");
+  }
+
+  const listener = (event, context) => {
+    if (context.viewType == viewType && context.xulBrowser == browser) {
+      onExtensionProxyContextLoaded(context);
+    }
+  };
+
+  extension.on("extension-proxy-context-load", listener);
+
+  return () => {
+    extension.off("extension-proxy-context-load", listener);
+  };
+}
+
 const ExtensionParent = {
   GlobalManager,
   HiddenExtensionPage,
   ParentAPIManager,
   apiManager,
   promiseExtensionViewLoaded,
+  watchExtensionProxyContextLoad,
 };