Bug 1291737 - Implements the devtools_page context. r=kmag
☠☠ backed out by eee33eb6c7fd ☠ ☠
authorLuca Greco <lgreco@mozilla.com>
Wed, 18 Jan 2017 15:55:21 +0100
changeset 375170 71414e9dc3ee0be23d25b476282f5ba10248a9d1
parent 375169 235ba91834d5e71c5bd19d9a2e0c12cd05086cab
child 375171 6ff304ff85180a803ed57c560f8ff1b72c7af657
push id6996
push userjlorenzo@mozilla.com
push dateMon, 06 Mar 2017 20:48:21 +0000
treeherdermozilla-beta@d89512dab048 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerskmag
bugs1291737
milestone53.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1291737 - Implements the devtools_page context. r=kmag 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
@@ -801,15 +801,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,
 };