Bug 1302702 - Provide a DebugUtils object from ExtensionParent.jsm. draft
authorLuca Greco <lgreco@mozilla.com>
Tue, 21 Mar 2017 16:24:13 +0100
changeset 579747 58b64a81294ed57da51113f3daee5fe7247f4b2a
parent 579463 6e3ca5b38f7173b214b10de49e58cb01890bf39d
child 579748 7bddadb79113a19eb345873bf1af3c5c9e156e83
push id59363
push userluca.greco@alcacoop.it
push dateWed, 17 May 2017 19:17:20 +0000
bugs1302702
milestone55.0a1
Bug 1302702 - Provide a DebugUtils object from ExtensionParent.jsm. This change prepare the WebExtensions internals to the changes applied to the addon debugging facilities in the other patches from this queue. In ExtensionParent.jsm, a HiddenXULWindow helper class has been refactored out of the HiddenExtensionPage and then reused by both HiddenExtensionPage and the new DebugUtils object. The DebugUtils object provides the utility methods used by the devtools actors related to the addon debugging, which are used to retrieve an "extension process browser XUL element" (a XUL browser element that has been configured by DebugUtils to be used to connect the devtools parent actor to the process where the target extension is running), and then release it when it is not needed anymore (because the developer toolbox has been closed and all the devtools actors destroyed). The DebugUtils object used the HiddenXULWindow class to lazily create an hidden XUL window to contain the "extension process browser XUL elements" described above (and the HiddenXULWindow istance is then destroyed when there is no devtools actor that is using it anymore). MozReview-Commit-ID: 31RYQk1DMvE
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionParent.jsm
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -9,16 +9,30 @@ this.EXPORTED_SYMBOLS = ["Extension", "E
 /* globals Extension ExtensionData */
 
 /*
  * This file is the main entry point for extensions. When an extension
  * loads, its bootstrap.js file creates a Extension instance
  * and calls .startup() on it. It calls .shutdown() when the extension
  * unloads. Extension manages any extension-specific state in
  * the chrome process.
+ *
+ * TODO(rpl): we are current restricting the extensions to a single process
+ * (set as the current default value of the "dom.ipc.processCount.extension"
+ * preference), if we switch to use more than one extension process, we have to
+ * be sure that all the browser's frameLoader are associated to the same process,
+ * e.g. by using the `sameProcessAsFrameLoader` property.
+ * (http://searchfox.org/mozilla-central/source/dom/interfaces/base/nsIBrowser.idl)
+ *
+ * At that point we are going to keep track of the existing browsers associated to
+ * a webextension to ensure that they are all running in the same process (and we
+ * are also going to do the same with the browser element provided to the
+ * addon debugging Remote Debugging actor, e.g. because the addon has been
+ * reloaded by the user, we have to  ensure that the new extension pages are going
+ * to run in the same process of the existing addon debugging browser element).
  */
 
 const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cu = Components.utils;
 const Cr = Components.results;
 
 Cu.importGlobalProperties(["TextEncoder"]);
--- a/toolkit/components/extensions/ExtensionParent.jsm
+++ b/toolkit/components/extensions/ExtensionParent.jsm
@@ -40,16 +40,17 @@ Cu.import("resource://gre/modules/Extens
 
 var {
   BaseContext,
   CanOfAPIs,
   SchemaAPIManager,
 } = ExtensionCommon;
 
 var {
+  DefaultWeakMap,
   MessageManagerProxy,
   SpreadArgs,
   defineLazyGetter,
   promiseDocumentLoaded,
   promiseEvent,
   promiseObserved,
 } = ExtensionUtils;
 
@@ -688,169 +689,322 @@ ParentAPIManager = {
     }
     return context;
   },
 };
 
 ParentAPIManager.init();
 
 /**
+ * This utility class is used to create hidden XUL windows, which are used to
+ * contains the extension pages that are not visible (e.g. the background page and
+ * the devtools page), and it is also used by the ExtensionDebuggingUtils to
+ * contains the browser elements that are used by the addon debugger to be able
+ * to connect to the devtools actors running in the same process of the target
+ * extension (and be able to stay connected across the addon reloads).
+ */
+class HiddenXULWindow {
+  constructor() {
+    this._windowlessBrowser = null;
+    this.waitInitialized = this.initWindowlessBrowser();
+  }
+
+  shutdown() {
+    if (this.unloaded) {
+      throw new Error("Unable to shutdown an unloaded HiddenXULWindow instance");
+    }
+
+    this.unloaded = true;
+
+    this.chromeShell = null;
+    this.waitInitialized = null;
+
+    this._windowlessBrowser.close();
+    this._windowlessBrowser = null;
+  }
+
+  get chromeDocument() {
+    return this._windowlessBrowser.document;
+  }
+
+  /**
+   * Private helper that create a XULDocument in a windowless browser.
+   *
+   * @returns {Promise<XULDocument>}
+   *          A promise which resolves to the newly created XULDocument.
+   */
+  async initWindowlessBrowser() {
+    if (this.waitInitialized) {
+      throw new Error("HiddenXULWindow already initialized");
+    }
+
+    // The invisible page is currently wrapped in a XUL window to fix an issue
+    // with using the canvas API from a background page (See Bug 1274775).
+    let windowlessBrowser = Services.appShell.createWindowlessBrowser(true);
+    this._windowlessBrowser = windowlessBrowser;
+
+    // The windowless browser is a thin wrapper around a docShell that keeps
+    // its related resources alive. It implements nsIWebNavigation and
+    // forwards its methods to the underlying docShell, but cannot act as a
+    // docShell itself. Calling `getInterface(nsIDocShell)` gives us the
+    // underlying docShell, and `QueryInterface(nsIWebNavigation)` gives us
+    // access to the webNav methods that are already available on the
+    // windowless browser, but contrary to appearances, they are not the same
+    // object.
+    this.chromeShell = this._windowlessBrowser
+                           .QueryInterface(Ci.nsIInterfaceRequestor)
+                           .getInterface(Ci.nsIDocShell)
+                           .QueryInterface(Ci.nsIWebNavigation);
+
+    if (PrivateBrowsingUtils.permanentPrivateBrowsing) {
+      let attrs = this.chromeShell.getOriginAttributes();
+      attrs.privateBrowsingId = 1;
+      this.chromeShell.setOriginAttributes(attrs);
+    }
+
+    let system = Services.scriptSecurityManager.getSystemPrincipal();
+    this.chromeShell.createAboutBlankContentViewer(system);
+    this.chromeShell.useGlobalHistory = false;
+    this.chromeShell.loadURI(XUL_URL, 0, null, null, null);
+
+    await promiseObserved("chrome-document-global-created",
+                          win => win.document == this.chromeShell.document);
+    return promiseDocumentLoaded(windowlessBrowser.document);
+  }
+
+  /**
+   * Creates the browser XUL element that will contain the WebExtension Page.
+   *
+   * @param {Object} xulAttributes
+   *        An object that contains the xul attributes to set of the newly
+   *        created browser XUL element.
+   *
+   * @returns {Promise<XULElement>}
+   *          A Promise which resolves to the newly created browser XUL element.
+   */
+  async createBrowserElement(xulAttributes) {
+    if (!xulAttributes || Object.keys(xulAttributes).length === 0) {
+      throw new Error("missing mandatory xulAttributes parameter");
+    }
+
+    await this.waitInitialized;
+
+    const chromeDoc = this.chromeDocument;
+
+    const browser = chromeDoc.createElement("browser");
+    browser.setAttribute("type", "content");
+    browser.setAttribute("disableglobalhistory", "true");
+
+    for (const [name, value] of Object.entries(xulAttributes)) {
+      if (value != null) {
+        browser.setAttribute(name, value);
+      }
+    }
+
+    let awaitFrameLoader = Promise.resolve();
+
+    if (browser.getAttribute("remote") === "true") {
+      awaitFrameLoader = promiseEvent(browser, "XULFrameLoaderCreated");
+    }
+
+    chromeDoc.documentElement.appendChild(browser);
+    await awaitFrameLoader;
+
+    return browser;
+  }
+}
+
+
+/**
  * This is a base class used by the ext-backgroundPage and ext-devtools API implementations
  * to inherits the shared boilerplate code needed to create a parent document for the hidden
  * extension pages (e.g. the background page, the devtools page) in the BackgroundPage and
  * DevToolsPage classes.
  *
  * @param {Extension} extension
- *   the Extension which owns the hidden extension page created (used to decide
- *   if the hidden extension page parent doc is going to be a windowlessBrowser or
- *   a visible XUL window)
+ *        The Extension which owns the hidden extension page created (used to decide
+ *        if the hidden extension page parent doc is going to be a windowlessBrowser or
+ *        a visible XUL window).
  * @param {string} viewType
- *  the viewType of the WebExtension page that is going to be loaded
- *  in the created browser element (e.g. "background" or "devtools_page").
- *
+ *        The viewType of the WebExtension page that is going to be loaded
+ *        in the created browser element (e.g. "background" or "devtools_page").
  */
-class HiddenExtensionPage {
+class HiddenExtensionPage extends HiddenXULWindow {
   constructor(extension, viewType) {
     if (!extension || !viewType) {
       throw new Error("extension and viewType parameters are mandatory");
     }
+
+    super();
     this.extension = extension;
     this.viewType = viewType;
-    this.parentWindow = null;
-    this.windowlessBrowser = null;
     this.browser = null;
   }
 
   /**
    * Destroy the created parent document.
    */
   shutdown() {
     if (this.unloaded) {
       throw new Error("Unable to shutdown an unloaded HiddenExtensionPage instance");
     }
 
-    this.unloaded = true;
-
     if (this.browser) {
       this.browser.remove();
       this.browser = null;
     }
 
-    // Navigate away from the background page to invalidate any
-    // setTimeouts or other callbacks.
-    if (this.webNav) {
-      this.webNav.loadURI("about:blank", 0, null, null, null);
-      this.webNav = null;
-    }
-
-    if (this.parentWindow) {
-      this.parentWindow.close();
-      this.parentWindow = null;
-    }
-
-    if (this.windowlessBrowser) {
-      this.windowlessBrowser.loadURI("about:blank", 0, null, null, null);
-      this.windowlessBrowser.close();
-      this.windowlessBrowser = null;
-    }
+    super.shutdown();
   }
 
   /**
    * Creates the browser XUL element that will contain the WebExtension Page.
    *
    * @returns {Promise<XULElement>}
-   *   a Promise which resolves to the newly created browser XUL element.
+   *          A Promise which resolves to the newly created browser XUL element.
    */
   async createBrowserElement() {
     if (this.browser) {
       throw new Error("createBrowserElement called twice");
     }
 
-    let chromeDoc = await this.createWindowlessBrowser();
+    this.browser = await super.createBrowserElement({
+      "webextension-view-type": this.viewType,
+      "remote": this.extension.remote ? "true" : null,
+      "remoteType": this.extension.remote ?
+        E10SUtils.EXTENSION_REMOTE_TYPE : null,
+    });
+
+    return this.browser;
+  }
+}
 
-    const browser = this.browser = chromeDoc.createElement("browser");
-    browser.setAttribute("type", "content");
-    browser.setAttribute("disableglobalhistory", "true");
-    browser.setAttribute("webextension-view-type", this.viewType);
+/**
+ * This object provides utility functions needed by the devtools actors to
+ * be able to connect and debug an extension (which can run in the main or in
+ * a child extension process).
+ */
+const DebugUtils = {
+  // A lazily created hidden XUL window, which contains the browser elements
+  // which are used to connect the webextension patent actor to the extension process.
+  hiddenXULWindow: null,
 
-    let awaitFrameLoader = Promise.resolve();
+  // Map<extensionId, Promise<XULElement>>
+  debugBrowserPromises: new Map(),
+  // DefaultWeakMap<Promise<browser XULElement>, Set<WebExtensionParentActor>>
+  debugActors: new DefaultWeakMap(() => new Set()),
 
-    if (this.extension.remote) {
-      browser.setAttribute("remote", "true");
-      browser.setAttribute("remoteType", E10SUtils.EXTENSION_REMOTE_TYPE);
-      awaitFrameLoader = promiseEvent(browser, "XULFrameLoaderCreated");
-    }
+  _extensionUpdatedWatcher: null,
+  watchExtensionUpdated() {
+    if (!this._extensionUpdatedWatcher) {
+      // Watch the updated extension objects.
+      this._extensionUpdatedWatcher = async (evt, extension) => {
+        const browserPromise = this.debugBrowserPromises.get(extension.id);
+        if (browserPromise) {
+          const browser = await browserPromise;
+          if (browser.isRemoteBrowser !== extension.remote &&
+              this.debugBrowserPromises.get(extension.id) === browserPromise) {
+            // If the cached browser element is not anymore of the same
+            // remote type of the extension, remove it.
+            this.debugBrowserPromises.delete(extension.id);
+            browser.remove();
+          }
+        }
+      };
 
-    chromeDoc.documentElement.appendChild(browser);
-    await awaitFrameLoader;
+      apiManager.on("ready", this._extensionUpdatedWatcher);
+    }
+  },
 
-    return browser;
-  }
+  unwatchExtensionUpdated() {
+    if (this._extensionUpdatedWatcher) {
+      apiManager.off("ready", this._extensionUpdatedWatcher);
+      delete this._extensionUpdatedWatcher;
+    }
+  },
+
 
   /**
-   * Private helper that create a XULDocument in a windowless browser.
-   *
-   * An hidden extension page (e.g. a background page or devtools page) is usually
-   * loaded into a windowless browser, with no on-screen representation or graphical
-   * display abilities.
+   * Retrieve a XUL browser element which has been configured to be able to connect
+   * the devtools actor with the process where the extension is running.
    *
-   * This currently does not support remote browsers, and therefore cannot
-   * be used with out-of-process extensions.
+   * @param {WebExtensionParentActor} webExtensionParentActor
+   *        The devtools actor that is retrieving the browser element.
    *
-   * @returns {Promise<XULDocument>}
-   *   a promise which resolves to the newly created XULDocument.
+   * @returns {Promise<XULElement>}
+   *          A promise which resolves to the configured browser XUL element.
    */
-  createWindowlessBrowser() {
-    // The invisible page is currently wrapped in a XUL window to fix an issue
-    // with using the canvas API from a background page (See Bug 1274775).
-    let windowlessBrowser = Services.appShell.createWindowlessBrowser(true);
-    this.windowlessBrowser = windowlessBrowser;
+  async getExtensionProcessBrowser(webExtensionParentActor) {
+    const extensionId = webExtensionParentActor.addonId;
+    const extension = GlobalManager.getExtension(extensionId);
+    if (!extension) {
+      throw new Error(`Extension not found: ${extensionId}`);
+    }
+
+    const createBrowser = () => {
+      if (!this.hiddenXULWindow) {
+        this.hiddenXULWindow = new HiddenXULWindow();
+        this.watchExtensionUpdated();
+      }
 
-    // The windowless browser is a thin wrapper around a docShell that keeps
-    // its related resources alive. It implements nsIWebNavigation and
-    // forwards its methods to the underlying docShell, but cannot act as a
-    // docShell itself. Calling `getInterface(nsIDocShell)` gives us the
-    // underlying docShell, and `QueryInterface(nsIWebNavigation)` gives us
-    // access to the webNav methods that are already available on the
-    // windowless browser, but contrary to appearances, they are not the same
-    // object.
-    let chromeShell = windowlessBrowser.QueryInterface(Ci.nsIInterfaceRequestor)
-                                       .getInterface(Ci.nsIDocShell)
-                                       .QueryInterface(Ci.nsIWebNavigation);
+      return this.hiddenXULWindow.createBrowserElement({
+        "webextension-addon-debug-target": extensionId,
+        "remote": extension.remote ? "true" : null,
+        "remoteType": extension.remote ?
+          E10SUtils.EXTENSION_REMOTE_TYPE : null,
+      });
+    };
+
+    let browserPromise = this.debugBrowserPromises.get(extensionId);
 
-    return this.initParentWindow(chromeShell).then(() => {
-      return promiseDocumentLoaded(windowlessBrowser.document);
-    });
-  }
+    // Create a new promise if there is no cached one in the map.
+    if (!browserPromise) {
+      browserPromise = createBrowser();
+      this.debugBrowserPromises.set(extensionId, browserPromise);
+      browserPromise.catch(() => {
+        this.debugBrowserPromises.delete(extensionId);
+      });
+    }
+
+    this.debugActors.get(browserPromise).add(webExtensionParentActor);
+
+    return browserPromise;
+  },
+
 
   /**
-   * Private helper that initialize the created parent document.
-   *
-   * @param {nsIDocShell} chromeShell
-   *   the docShell related to initialize.
+   * Given the devtools actor that has retrieved an addon debug browser element,
+   * it destroys the XUL browser element, and it also destroy the hidden XUL window
+   * if it is not currently needed.
    *
-   * @returns {Promise<nsIXULDocument>}
-   *   the initialized parent chrome document.
+   * @param {WebExtensionParentActor} webExtensionParentActor
+   *        The devtools actor that has retrieved an addon debug browser element.
    */
-  initParentWindow(chromeShell) {
-    if (PrivateBrowsingUtils.permanentPrivateBrowsing) {
-      let attrs = chromeShell.getOriginAttributes();
-      attrs.privateBrowsingId = 1;
-      chromeShell.setOriginAttributes(attrs);
+  async releaseExtensionProcessBrowser(webExtensionParentActor) {
+    const extensionId = webExtensionParentActor.addonId;
+    const browserPromise = this.debugBrowserPromises.get(extensionId);
+
+    if (browserPromise) {
+      const actorsSet = this.debugActors.get(browserPromise);
+      actorsSet.delete(webExtensionParentActor);
+      if (actorsSet.size === 0) {
+        this.debugActors.delete(browserPromise);
+        this.debugBrowserPromises.delete(extensionId);
+        await browserPromise.then((browser) => browser.remove());
+      }
     }
 
-    let system = Services.scriptSecurityManager.getSystemPrincipal();
-    chromeShell.createAboutBlankContentViewer(system);
-    chromeShell.useGlobalHistory = false;
-    chromeShell.loadURI(XUL_URL, 0, null, null, null);
+    if (this.debugBrowserPromises.size === 0 && this.hiddenXULWindow) {
+      this.hiddenXULWindow.shutdown();
+      this.hiddenXULWindow = null;
+      this.unwatchExtensionUpdated();
+    }
+  },
+};
 
-    return promiseObserved("chrome-document-global-created",
-                           win => win.document == chromeShell.document);
-  }
-}
 
 function promiseExtensionViewLoaded(browser) {
   return new Promise(resolve => {
     browser.messageManager.addMessageListener("Extension:ExtensionViewLoaded", function onLoad({data}) {
       browser.messageManager.removeMessageListener("Extension:ExtensionViewLoaded", onLoad);
       resolve(data.childId && ParentAPIManager.getContextById(data.childId));
     });
   });
@@ -858,27 +1012,27 @@ function promiseExtensionViewLoaded(brow
 
 /**
  * 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.
+ *        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").
+ *        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.
+ *        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)`);
  *
- * @param {Function} onExtensionProxyContextLoaded
- *  the callback that is called when a new context has been loaded (as `callback(context)`);
- *
- * @returns {Function}
- *   Unsubscribe the listener.
+ * @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) {
@@ -912,9 +1066,10 @@ const ExtensionParent = {
       throw new Error("Unable to find base manifest properties");
     }
 
     gBaseManifestProperties = Object.getOwnPropertyNames(manifest.properties);
     return gBaseManifestProperties;
   },
   promiseExtensionViewLoaded,
   watchExtensionProxyContextLoad,
+  DebugUtils,
 };