Bug 1291737 - Added a new helper to create windowless extension pages. draft
authorLuca Greco <lgreco@mozilla.com>
Wed, 18 Jan 2017 15:14:53 +0100
changeset 463725 7eb393c077f74369177b103c91bfc3de57b3fde6
parent 463681 a3978751f45108ff1ae002ecebdc0fa23fc52b84
child 463726 f18ead0c27bc0b4b8581c2bcc5120c54e5f5f717
push id42159
push userluca.greco@alcacoop.it
push dateThu, 19 Jan 2017 17:57:06 +0000
bugs1291737
milestone53.0a1
Bug 1291737 - Added a new helper to create windowless extension pages. MozReview-Commit-ID: CqpWgFGmJAt
toolkit/components/extensions/ExtensionParent.jsm
toolkit/components/extensions/ext-backgroundPage.js
--- a/toolkit/components/extensions/ExtensionParent.jsm
+++ b/toolkit/components/extensions/ExtensionParent.jsm
@@ -11,52 +11,62 @@
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
 /* exported ExtensionParent */
 
 this.EXPORTED_SYMBOLS = ["ExtensionParent"];
 
 Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
                                   "resource://gre/modules/AddonManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
                                   "resource://gre/modules/AppConstants.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "E10SUtils",
                                   "resource:///modules/E10SUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
                                   "resource://gre/modules/MessageChannel.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NativeApp",
                                   "resource://gre/modules/NativeMessaging.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
+                                  "resource://gre/modules/PrivateBrowsingUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                   "resource://gre/modules/Schemas.jsm");
 
 Cu.import("resource://gre/modules/ExtensionCommon.jsm");
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 
 var {
   BaseContext,
   SchemaAPIManager,
 } = ExtensionCommon;
 
 var {
   MessageManagerProxy,
   SpreadArgs,
   defineLazyGetter,
   findPathInObject,
+  promiseDocumentLoaded,
+  promiseEvent,
+  promiseObserved,
 } = ExtensionUtils;
 
 const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json";
 const CATEGORY_EXTENSION_SCHEMAS = "webextension-schemas";
 const CATEGORY_EXTENSION_SCRIPTS = "webextension-scripts";
 
+const XUL_URL = "data:application/vnd.mozilla.xul+xml;charset=utf-8," + encodeURI(
+  `<?xml version="1.0"?>
+  <window id="documentElement"/>`);
+
 let schemaURLs = new Set();
 
 if (!AppConstants.RELEASE_OR_BETA) {
   schemaURLs.add("chrome://extensions/content/schemas/experiments.json");
 }
 
 let GlobalManager;
 let ParentAPIManager;
@@ -572,14 +582,216 @@ ParentAPIManager = {
       throw new Error("WebExtension context not found!");
     }
     return context;
   },
 };
 
 ParentAPIManager.init();
 
+/**
+ * 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)
+ * @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").
+ *
+ */
+class HiddenExtensionPage {
+  constructor(extension, viewType) {
+    if (!extension || !viewType) {
+      throw new Error("extension and viewType parameters are mandatory");
+    }
+    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;
+    }
+  }
+
+  /**
+   * 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.
+   */
+  createBrowserElement() {
+    if (this.browser) {
+      throw new Error("createBrowserElement called twice");
+    }
+
+    let waitForParentDocument;
+    if (this.extension.remote) {
+      waitForParentDocument = this.createWindowedBrowser();
+    } else {
+      waitForParentDocument = this.createWindowlessBrowser();
+    }
+
+    return waitForParentDocument.then(chromeDoc => {
+      const browser = this.browser = chromeDoc.createElement("browser");
+      browser.setAttribute("type", "content");
+      browser.setAttribute("disableglobalhistory", "true");
+      browser.setAttribute("webextension-view-type", this.viewType);
+
+      let awaitFrameLoader = Promise.resolve();
+
+      if (this.extension.remote) {
+        browser.setAttribute("remote", "true");
+        browser.setAttribute("remoteType", E10SUtils.EXTENSION_REMOTE_TYPE);
+        awaitFrameLoader = promiseEvent(browser, "XULFrameLoaderCreated");
+      }
+
+      chromeDoc.documentElement.appendChild(browser);
+      return awaitFrameLoader.then(() => browser);
+    });
+  }
+
+  /**
+   * 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.
+   *
+   * This currently does not support remote browsers, and therefore cannot
+   * be used with out-of-process extensions.
+   *
+   * @returns {Promise<XULDocument>}
+   *   a promise which resolves to the newly created XULDocument.
+   */
+  createWindowlessBrowser() {
+    return Task.spawn(function* () {
+      // 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.
+      let chromeShell = windowlessBrowser.QueryInterface(Ci.nsIInterfaceRequestor)
+                                         .getInterface(Ci.nsIDocShell)
+                                         .QueryInterface(Ci.nsIWebNavigation);
+
+      yield this.initParentWindow(chromeShell);
+
+      return promiseDocumentLoaded(windowlessBrowser.document);
+    }.bind(this));
+  }
+
+  /**
+   * Private helper that create a XULDocument in a visible dialog window.
+   *
+   * Using this helper, the extension page is loaded into a visible dialog window.
+   * Only to be used for debugging, and in temporary, test-only use for
+   * out-of-process extensions.
+   *
+   * @returns {Promise<XULDocument>}
+   *   a promise which resolves to the newly created XULDocument.
+   */
+  createWindowedBrowser() {
+    return Task.spawn(function* () {
+      let window = Services.ww.openWindow(null, "about:blank", "_blank",
+                                          "chrome,alwaysLowered,dialog", null);
+
+      this.parentWindow = window;
+
+      let chromeShell = window.QueryInterface(Ci.nsIInterfaceRequestor)
+                              .getInterface(Ci.nsIDocShell)
+                              .QueryInterface(Ci.nsIWebNavigation);
+
+
+      yield this.initParentWindow(chromeShell);
+
+      window.minimize();
+
+      return promiseDocumentLoaded(window.document);
+    }.bind(this));
+  }
+
+  /**
+   * Private helper that initialize the created parent document.
+   *
+   * @param {nsIDocShell} chromeShell
+   *   the docShell related to initialize.
+   *
+   * @returns {Promise<nsIXULDocument>}
+   *   the initialized parent chrome document.
+   */
+  initParentWindow(chromeShell) {
+    if (PrivateBrowsingUtils.permanentPrivateBrowsing) {
+      let attrs = chromeShell.getOriginAttributes();
+      attrs.privateBrowsingId = 1;
+      chromeShell.setOriginAttributes(attrs);
+    }
+
+    let system = Services.scriptSecurityManager.getSystemPrincipal();
+    chromeShell.createAboutBlankContentViewer(system);
+    chromeShell.useGlobalHistory = false;
+    chromeShell.loadURI(XUL_URL, 0, null, null, null);
+
+    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() {
+      browser.messageManager.removeMessageListener("Extension:ExtensionViewLoaded", onLoad);
+      resolve();
+    });
+  });
+}
 
 const ExtensionParent = {
   GlobalManager,
+  HiddenExtensionPage,
   ParentAPIManager,
   apiManager,
+  promiseExtensionViewLoaded,
 };
--- a/toolkit/components/extensions/ext-backgroundPage.js
+++ b/toolkit/components/extensions/ext-backgroundPage.js
@@ -2,234 +2,86 @@
 
 var {interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
                                   "resource://gre/modules/AddonManager.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "E10SUtils",
-                                  "resource:///modules/E10SUtils.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
-                                  "resource://gre/modules/PrivateBrowsingUtils.jsm");
 
-Cu.import("resource://gre/modules/ExtensionUtils.jsm");
+Cu.import("resource://gre/modules/ExtensionParent.jsm");
 const {
-  promiseDocumentLoaded,
-  promiseEvent,
-  promiseObserved,
-} = ExtensionUtils;
-
-const XUL_URL = "data:application/vnd.mozilla.xul+xml;charset=utf-8," + encodeURI(
-  `<?xml version="1.0"?>
-  <window id="documentElement"/>`);
+  HiddenExtensionPage,
+  promiseExtensionViewLoaded,
+} = ExtensionParent;
 
 // WeakMap[Extension -> BackgroundPage]
 var backgroundPagesMap = new WeakMap();
 
 // Responsible for the background_page section of the manifest.
-class BackgroundPageBase {
-  constructor(options, extension) {
-    this.extension = extension;
+class BackgroundPage extends HiddenExtensionPage {
+  constructor(extension, options) {
+    super(extension, "background");
+
     this.page = options.page || null;
     this.isGenerated = !!options.scripts;
     this.webNav = null;
+
+    if (this.page) {
+      this.url = this.extension.baseURI.resolve(this.page);
+    } else if (this.isGenerated) {
+      this.url = this.extension.baseURI.resolve("_generated_background_page.html");
+    }
+
+    if (!this.extension.isExtensionURL(this.url)) {
+      this.extension.manifestError("Background page must be a file within the extension");
+      this.url = this.extension.baseURI.resolve("_blank.html");
+    }
   }
 
   build() {
     return Task.spawn(function* () {
-      let url;
-      if (this.page) {
-        url = this.extension.baseURI.resolve(this.page);
-      } else if (this.isGenerated) {
-        url = this.extension.baseURI.resolve("_generated_background_page.html");
-      }
+      yield this.createBrowserElement();
 
-      if (!this.extension.isExtensionURL(url)) {
-        this.extension.manifestError("Background page must be a file within the extension");
-        url = this.extension.baseURI.resolve("_blank.html");
-      }
+      extensions.emit("extension-browser-inserted", this.browser);
 
-      let chromeDoc = yield this.getParentDocument();
-
-      let browser = chromeDoc.createElement("browser");
-      browser.setAttribute("type", "content");
-      browser.setAttribute("disableglobalhistory", "true");
-      browser.setAttribute("webextension-view-type", "background");
+      this.browser.loadURI(this.url);
 
-      let awaitFrameLoader;
-      if (this.extension.remote) {
-        browser.setAttribute("remote", "true");
-        browser.setAttribute("remoteType", E10SUtils.EXTENSION_REMOTE_TYPE);
-        awaitFrameLoader = promiseEvent(browser, "XULFrameLoaderCreated");
-      }
-
-      chromeDoc.documentElement.appendChild(browser);
-      yield awaitFrameLoader;
-
-      this.browser = browser;
-
-      extensions.emit("extension-browser-inserted", browser);
+      yield promiseExtensionViewLoaded(this.browser);
 
-      browser.loadURI(url);
-
-      yield new Promise(resolve => {
-        browser.messageManager.addMessageListener("Extension:ExtensionViewLoaded", function onLoad() {
-          browser.messageManager.removeMessageListener("Extension:ExtensionViewLoaded", onLoad);
-          resolve();
-        });
-      });
-
-      if (browser.docShell) {
-        this.webNav = browser.docShell.QueryInterface(Ci.nsIWebNavigation);
+      if (this.browser.docShell) {
+        this.webNav = this.browser.docShell.QueryInterface(Ci.nsIWebNavigation);
         let window = this.webNav.document.defaultView;
 
-
         // Set the add-on's main debugger global, for use in the debugger
         // console.
         if (this.extension.addonData.instanceID) {
           AddonManager.getAddonByInstanceID(this.extension.addonData.instanceID)
                       .then(addon => addon.setDebugGlobal(window));
         }
       }
 
       this.extension.emit("startup");
     }.bind(this));
   }
 
-  initParentWindow(chromeShell) {
-    if (PrivateBrowsingUtils.permanentPrivateBrowsing) {
-      let attrs = chromeShell.getOriginAttributes();
-      attrs.privateBrowsingId = 1;
-      chromeShell.setOriginAttributes(attrs);
-    }
-
-    let system = Services.scriptSecurityManager.getSystemPrincipal();
-    chromeShell.createAboutBlankContentViewer(system);
-    chromeShell.useGlobalHistory = false;
-    chromeShell.loadURI(XUL_URL, 0, null, null, null);
-
-    return promiseObserved("chrome-document-global-created",
-                           win => win.document == chromeShell.document);
-  }
-
   shutdown() {
     if (this.extension.addonData.instanceID) {
       AddonManager.getAddonByInstanceID(this.extension.addonData.instanceID)
                   .then(addon => addon.setDebugGlobal(null));
     }
 
-    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;
-    }
-  }
-}
-
-/**
- * A background page loaded into a windowless browser, with no on-screen
- * representation or graphical display abilities.
- *
- * This currently does not support remote browsers, and therefore cannot
- * be used with out-of-process extensions.
- */
-class WindowlessBackgroundPage extends BackgroundPageBase {
-  constructor(options, extension) {
-    super(options, extension);
-    this.windowlessBrowser = null;
-  }
-
-  getParentDocument() {
-    return Task.spawn(function* () {
-      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.
-      let chromeShell = windowlessBrowser.QueryInterface(Ci.nsIInterfaceRequestor)
-                                         .getInterface(Ci.nsIDocShell)
-                                         .QueryInterface(Ci.nsIWebNavigation);
-
-      yield this.initParentWindow(chromeShell);
-
-      return promiseDocumentLoaded(windowlessBrowser.document);
-    }.bind(this));
-  }
-
-  shutdown() {
     super.shutdown();
-
-    this.windowlessBrowser.loadURI("about:blank", 0, null, null, null);
-    this.windowlessBrowser.close();
-    this.windowlessBrowser = null;
-  }
-}
-
-/**
- * A background page loaded into a visible dialog window. Only to be
- * used for debugging, and in temporary, test-only use for
- * out-of-process extensions.
- */
-class WindowedBackgroundPage extends BackgroundPageBase {
-  constructor(options, extension) {
-    super(options, extension);
-    this.parentWindow = null;
-  }
-
-  getParentDocument() {
-    return Task.spawn(function* () {
-      let window = Services.ww.openWindow(null, "about:blank", "_blank",
-                                          "chrome,alwaysLowered,dialog", null);
-
-      this.parentWindow = window;
-
-      let chromeShell = window.QueryInterface(Ci.nsIInterfaceRequestor)
-                              .getInterface(Ci.nsIDocShell)
-                              .QueryInterface(Ci.nsIWebNavigation);
-
-      yield this.initParentWindow(chromeShell);
-
-      window.minimize();
-
-      return promiseDocumentLoaded(window.document);
-    }.bind(this));
-  }
-
-  shutdown() {
-    super.shutdown();
-
-    if (this.parentWindow) {
-      this.parentWindow.close();
-      this.parentWindow = null;
-    }
   }
 }
 
 /* eslint-disable mozilla/balanced-listeners */
 extensions.on("manifest_background", (type, directive, extension, manifest) => {
-  let bgPage;
-  if (extension.remote) {
-    bgPage = new WindowedBackgroundPage(manifest.background, extension);
-  } else {
-    bgPage = new WindowlessBackgroundPage(manifest.background, extension);
-  }
+  let bgPage = new BackgroundPage(extension, manifest.background);
 
   backgroundPagesMap.set(extension, bgPage);
   return bgPage.build();
 });
 
 extensions.on("shutdown", (type, extension) => {
   if (backgroundPagesMap.has(extension)) {
     backgroundPagesMap.get(extension).shutdown();