Bug 1287007 - Move extension context initialization to ExtensionContent draft
authorRob Wu <rob@robwu.nl>
Mon, 05 Sep 2016 23:50:11 -0700
changeset 428430 e78174dc7c7595e70a514ba50dddb901fe74aa44
parent 428429 2956bd9354eba1b49e30a7b07ca41c19920b1659
child 428431 be221f14b23df3749dbea8fafee2d0d07cbd3fae
push id33305
push userbmo:rob@robwu.nl
push dateSun, 23 Oct 2016 20:56:25 +0000
bugs1287007
milestone52.0a1
Bug 1287007 - Move extension context initialization to ExtensionContent This is a simple move of ExtensionContext creation logic to ExtensionChild. Before the change, ExtensionContext was initialized as follows: 1. (ext-backgroundPage.js) Create background page 2. (Extension.jsm) document-element-inserted observed. 3. (Extension.jsm) new ExtensionContext + unload observer. After this commit: 1. (ext-backgroundPage.js) Create background page 2. (ext-backgroundPage.js) emit extension-browser-inserted event 3. (Extension.jsm) Pass global to ExtensionContent + unload listener. 4. (ExtensionContent.jsm) document-element-inserted observed. 5. (ExtensionChild.jsm) new ExtensionContext The next step is to use frame scripts and synchronize state. MozReview-Commit-ID: K6mPdq7KQ2T
browser/components/extensions/ext-utils.js
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionChild.jsm
toolkit/components/extensions/ExtensionContent.jsm
toolkit/components/extensions/ext-backgroundPage.js
--- a/browser/components/extensions/ext-utils.js
+++ b/browser/components/extensions/ext-utils.js
@@ -235,16 +235,18 @@ class BasePopup {
 
     // Note: When using noautohide panels, the popup manager will add width and
     // height attributes to the panel, breaking our resize code, if the browser
     // starts out smaller than 30px by 10px. This isn't an issue now, but it
     // will be if and when we popup debugging.
 
     viewNode.appendChild(this.browser);
 
+    extensions.emit("extension-browser-inserted", this.browser);
+
     let initBrowser = browser => {
       let mm = browser.messageManager;
       mm.addMessageListener("DOMTitleChanged", this);
       mm.addMessageListener("Extension:BrowserBackgroundChanged", this);
       mm.addMessageListener("Extension:BrowserContentLoaded", this);
       mm.addMessageListener("Extension:BrowserResized", this);
       mm.addMessageListener("Extension:DOMWindowClose", this, true);
     };
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -27,18 +27,16 @@ Cu.import("resource://gre/modules/XPCOMU
 Cu.import("resource://gre/modules/Services.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
                                   "resource://gre/modules/AddonManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
                                   "resource://gre/modules/AppConstants.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionAPIs",
                                   "resource://gre/modules/ExtensionAPI.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "ExtensionContext",
-                                  "resource://gre/modules/ExtensionChild.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorage",
                                   "resource://gre/modules/ExtensionStorage.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
                                   "resource://gre/modules/FileUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Locale",
                                   "resource://gre/modules/Locale.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Log",
                                   "resource://gre/modules/Log.jsm");
@@ -599,40 +597,47 @@ var UninstallObserver = {
 GlobalManager = {
   // Map[extension ID -> Extension]. Determines which extension is
   // responsible for content under a particular extension ID.
   extensionMap: new Map(),
   initialized: false,
 
   init(extension) {
     if (this.extensionMap.size == 0) {
-      Services.obs.addObserver(this, "document-element-inserted", false);
       UninstallObserver.init();
       ProxyMessenger.init();
-      // This initializes the default message handler for messages targeted at
-      // an addon process, in case the addon process receives a message before
-      // its Messenger has been instantiated. For example, if a content script
-      // sends a message while there is no background page.
-      // TODO(robwu): Move this to the addon process once we have one.
-      MessageChannel.setupMessageManagers([Services.cpmm]);
+      Management.on("extension-browser-inserted", this._onExtensionBrowser);
       this.initialized = true;
     }
 
     this.extensionMap.set(extension.id, extension);
   },
 
   uninit(extension) {
     this.extensionMap.delete(extension.id);
 
     if (this.extensionMap.size == 0 && this.initialized) {
-      Services.obs.removeObserver(this, "document-element-inserted");
+      Management.off("extension-browser-inserted", this._onExtensionBrowser);
       this.initialized = false;
     }
   },
 
+  _onExtensionBrowser(type, browser) {
+    // TODO(robwu): Move this logic inside a frame script.
+    let global = browser.docShell
+      .QueryInterface(Ci.nsIInterfaceRequestor)
+      .getInterface(Ci.nsIContentFrameMessageManager);
+    ExtensionContent.init(global);
+    /* eslint-disable mozilla/balanced-listeners */
+    global.addEventListener("unload", function() {
+      ExtensionContent.uninit(this);
+    });
+    /* eslint-enable mozilla/balanced-listeners */
+  },
+
   getExtension(extensionId) {
     return this.extensionMap.get(extensionId);
   },
 
   injectInObject(context, isChromeCompat, dest) {
     let apis = {
       extensionTypes: {},
     };
@@ -671,77 +676,16 @@ GlobalManager = {
 
       getImplementation(namespace, name) {
         let pathObj = findPathInObject(apis, namespace);
         return new LocalAPIImplementation(pathObj, name, context);
       },
     };
     Schemas.inject(dest, schemaWrapper);
   },
-
-  observe(document, topic, data) {
-    let contentWindow = document.defaultView;
-    if (!contentWindow) {
-      return;
-    }
-
-    let id = ExtensionManagement.getAddonIdForWindow(contentWindow);
-
-    // We don't inject privileged APIs into sub-frames of a UI page.
-    const {FULL_PRIVILEGES} = ExtensionManagement.API_LEVELS;
-    if (ExtensionManagement.getAPILevelForWindow(contentWindow, id) !== FULL_PRIVILEGES) {
-      return;
-    }
-
-    // We don't inject privileged APIs if the addonId is null
-    // or doesn't exist.
-    if (!this.extensionMap.has(id)) {
-      return;
-    }
-
-    let docShell = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
-                                .getInterface(Ci.nsIDocShell);
-
-    let parentDocument = docShell.parent.QueryInterface(Ci.nsIDocShell)
-                                 .contentViewer.DOMDocument;
-
-    let browser = docShell.chromeEventHandler;
-    // If this is a sub-frame of the add-on manager, use that <browser>
-    // element rather than the top-level chrome event handler.
-    if (contentWindow.frameElement && parentDocument.documentURI == "about:addons") {
-      browser = contentWindow.frameElement;
-    }
-
-    let viewType = "tab";
-    if (browser.hasAttribute("webextension-view-type")) {
-      viewType = browser.getAttribute("webextension-view-type");
-    } else if (browser.classList.contains("inline-options-browser")) {
-      // Options pages are currently displayed inline, but in Chrome
-      // and in our UI mock-ups for a later milestone, they're
-      // pop-ups.
-      viewType = "popup";
-    }
-
-    let extension = this.extensionMap.get(id);
-    let uri = document.documentURIObject;
-
-    let context = new ExtensionContext(extension, {viewType, contentWindow, uri, docShell});
-
-    let innerWindowID = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
-      .getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID;
-
-    let onUnload = subject => {
-      let windowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
-      if (windowId == innerWindowID) {
-        Services.obs.removeObserver(onUnload, "inner-window-destroyed");
-        context.unload();
-      }
-    };
-    Services.obs.addObserver(onUnload, "inner-window-destroyed", false);
-  },
 };
 
 // Represents the data contained in an extension, contained either
 // in a directory or a zip file, which may or may not be installed.
 // This class implements the functionality of the Extension class,
 // primarily related to manifest parsing and localization, which is
 // useful prior to extension installation or initialization.
 //
--- a/toolkit/components/extensions/ExtensionChild.jsm
+++ b/toolkit/components/extensions/ExtensionChild.jsm
@@ -1,15 +1,15 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
-this.EXPORTED_SYMBOLS = ["ExtensionContext"];
+this.EXPORTED_SYMBOLS = ["ExtensionChild"];
 
 /*
  * This file handles addon logic that is independent of the chrome process.
  * When addons run out-of-process, this is the main entry point.
  * Its primary function is managing addon globals.
  *
  * Don't put contentscript logic here, use ExtensionContent.jsm instead.
  */
@@ -17,35 +17,40 @@ this.EXPORTED_SYMBOLS = ["ExtensionConte
 const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cu = Components.utils;
 const Cr = Components.results;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
+                                  "resource://gre/modules/MessageChannel.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                   "resource://gre/modules/Schemas.jsm");
 
 const CATEGORY_EXTENSION_SCRIPTS_ADDON = "webextension-scripts-addon";
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
+  getInnerWindowID,
   BaseContext,
   ChildAPIManager,
   LocalAPIImplementation,
   Messenger,
   SchemaAPIManager,
 } = ExtensionUtils;
 
 // There is a circular dependency between Extension.jsm and us.
 // Long-term this file should not reference Extension.jsm (because they would
 // live in different processes), but for now use lazy getters.
 XPCOMUtils.defineLazyGetter(this, "findPathInObject",
   () => Cu.import("resource://gre/modules/Extension.jsm", {}).findPathInObject);
+XPCOMUtils.defineLazyGetter(this, "GlobalManager",
+  () => Cu.import("resource://gre/modules/Extension.jsm", {}).GlobalManager);
 XPCOMUtils.defineLazyGetter(this, "Management",
   () => Cu.import("resource://gre/modules/Extension.jsm", {}).Management);
 XPCOMUtils.defineLazyGetter(this, "ParentAPIManager",
   () => Cu.import("resource://gre/modules/Extension.jsm", {}).ParentAPIManager);
 
 var apiManager = new class extends SchemaAPIManager {
   constructor() {
     super("addon");
@@ -127,17 +132,17 @@ class WannabeChildAPIManager extends Chi
 // (viewType="background"), popups (viewType="popup"), and any extension
 // content loaded into browser tabs (viewType="tab").
 //
 // |params| is an object with the following properties:
 // |viewType| is one of "background", "popup", or "tab".
 // |contentWindow| is the DOM window the content runs in.
 // |uri| is the URI of the content (optional).
 // |docShell| is the docshell the content runs in (optional).
-this.ExtensionContext = class extends BaseContext {
+class ExtensionContext extends BaseContext {
   constructor(extension, params) {
     super("addon_child", extension);
     if (Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_DEFAULT) {
       // This check is temporary. It should be removed once the proxy creation
       // is asynchronous.
       throw new Error("ExtensionContext cannot be created in child processes");
     }
 
@@ -217,11 +222,107 @@ this.ExtensionContext = class extends Ba
 
     super.unload();
     this.childManager.close();
 
     if (this.externallyVisible) {
       this.extension.views.delete(this);
     }
   }
+}
+
+this.ExtensionChild = {
+  // Map<innerWindowId, ExtensionContext>
+  extensionContexts: new Map(),
+
+  initOnce() {
+    // This initializes the default message handler for messages targeted at
+    // an addon process, in case the addon process receives a message before
+    // its Messenger has been instantiated. For example, if a content script
+    // sends a message while there is no background page.
+    MessageChannel.setupMessageManagers([Services.cpmm]);
+  },
+
+  /**
+   * Create a privileged context at document-element-inserted.
+   *
+   * @param {Extension|BrowserExtensionContent} extension
+   *     The extension for which the context should be created.
+   * @param {nsIDOMWindow} contentWindow The global of the page.
+   */
+  createExtensionContext(extension, contentWindow) {
+    // TODO(robwu): Remove dependencies on the bloated Extension from
+    // Extension.jsm and use the thin BrowserExtensionContent from
+    // ExtensionContent.jsm instead.
+    extension = GlobalManager.extensionMap.get(extension.id);
+    let windowId = getInnerWindowID(contentWindow);
+    let context = this.extensionContexts.get(windowId);
+    if (context) {
+      if (context.extension !== extension) {
+        // Oops. This should never happen.
+        Cu.reportError("A different extension context already exists in this frame!");
+      } else {
+        // This should not happen either.
+        Cu.reportError("The extension context was already initialized in this frame.");
+      }
+      return;
+    }
+
+    let docShell = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+                                .getInterface(Ci.nsIDocShell);
+
+    let parentDocument = docShell.parent.QueryInterface(Ci.nsIDocShell)
+                                 .contentViewer.DOMDocument;
+
+    let browser = docShell.chromeEventHandler;
+    // If this is a sub-frame of the add-on manager, use that <browser>
+    // element rather than the top-level chrome event handler.
+    if (contentWindow.frameElement && parentDocument.documentURI == "about:addons") {
+      browser = contentWindow.frameElement;
+    }
+
+    let viewType = "tab";
+    if (browser.hasAttribute("webextension-view-type")) {
+      viewType = browser.getAttribute("webextension-view-type");
+    } else if (browser.classList.contains("inline-options-browser")) {
+      // Options pages are currently displayed inline, but in Chrome
+      // and in our UI mock-ups for a later milestone, they're
+      // pop-ups.
+      viewType = "popup";
+    }
+
+    let uri = contentWindow.document.documentURIObject;
+
+    context = new ExtensionContext(extension, {viewType, contentWindow, uri, docShell});
+    this.extensionContexts.set(windowId, context);
+  },
+
+  /**
+   * Close the ExtensionContext belonging to the given window, if any.
+   *
+   * @param {number} windowId The inner window ID of the destroyed context.
+   */
+  destroyExtensionContext(windowId) {
+    let context = this.extensionContexts.get(windowId);
+    if (context) {
+      context.unload();
+      this.extensionContexts.delete(windowId);
+    }
+  },
+
+  shutdownExtension(extensionId) {
+    for (let [windowId, context] of this.extensionContexts) {
+      if (context.extension.id == extensionId) {
+        context.shutdown();
+        this.extensionContexts.delete(windowId);
+      }
+    }
+  },
 };
 
-
+// TODO(robwu): Change this condition when addons move to a separate process.
+if (Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_DEFAULT) {
+  Object.keys(ExtensionChild).forEach(function(key) {
+    if (typeof ExtensionChild[key] == "function") {
+      ExtensionChild[key] = () => {};
+    }
+  });
+}
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -7,16 +7,20 @@
 this.EXPORTED_SYMBOLS = ["ExtensionContent"];
 
 /* globals ExtensionContent */
 
 /*
  * This file handles the content process side of extensions. It mainly
  * takes care of content script injection, content script APIs, and
  * messaging.
+ *
+ * This file is also the initial entry point for addon processes.
+ * ExtensionChild.jsm is responsible for functionality specific to addon
+ * processes.
  */
 
 const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cu = Components.utils;
 const Cr = Components.results;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
@@ -35,16 +39,18 @@ XPCOMUtils.defineLazyModuleGetter(this, 
                                   "resource://gre/modules/MessageChannel.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils",
                                   "resource://gre/modules/PromiseUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                   "resource://gre/modules/Schemas.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "WebNavigationFrames",
                                   "resource://gre/modules/WebNavigationFrames.jsm");
 
+Cu.import("resource://gre/modules/ExtensionChild.jsm");
+
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   runSafeSyncWithoutClone,
   BaseContext,
   LocaleData,
   Messenger,
   flushJarCache,
   getInnerWindowID,
@@ -452,23 +458,32 @@ DocumentManager = {
       // was called on (i.e., not frames for social or sidebars).
       let mm = getWindowMessageManager(window);
       if (!mm || !ExtensionContent.globals.has(mm)) {
         return;
       }
 
       // Enable the content script APIs should be available in subframes' window
       // if it is recognized as a valid addon id (see Bug 1214658 for rationale).
-      const {CONTENTSCRIPT_PRIVILEGES} = ExtensionManagement.API_LEVELS;
+      const {
+        NO_PRIVILEGES,
+        CONTENTSCRIPT_PRIVILEGES,
+        FULL_PRIVILEGES,
+      } = ExtensionManagement.API_LEVELS;
       let extensionId = ExtensionManagement.getAddonIdForWindow(window);
+      let apiLevel = ExtensionManagement.getAPILevelForWindow(window, extensionId);
 
-      if (ExtensionManagement.getAPILevelForWindow(window, extensionId) == CONTENTSCRIPT_PRIVILEGES) {
+      if (apiLevel != NO_PRIVILEGES) {
         let extension = ExtensionManager.get(extensionId);
         if (extension) {
-          DocumentManager.getExtensionPageContext(extension, window);
+          if (apiLevel == CONTENTSCRIPT_PRIVILEGES) {
+            DocumentManager.getExtensionPageContext(extension, window);
+          } else if (apiLevel == FULL_PRIVILEGES) {
+            ExtensionChild.createExtensionContext(extension, window);
+          }
         }
       }
 
       this.trigger("document_start", window);
       /* eslint-disable mozilla/balanced-listeners */
       window.addEventListener("DOMContentLoaded", this, true);
       window.addEventListener("load", this, true);
       /* eslint-enable mozilla/balanced-listeners */
@@ -488,16 +503,18 @@ DocumentManager = {
       }
 
       // Close any existent iframe extension page context for the destroyed window.
       if (this.extensionPageWindows.has(windowId)) {
         let context = this.extensionPageWindows.get(windowId);
         context.close();
         this.extensionPageWindows.delete(windowId);
       }
+
+      ExtensionChild.destroyExtensionContext(windowId);
     }
   },
 
   handleEvent: function(event) {
     let window = event.currentTarget;
     if (event.target != window.document) {
       // We use capturing listeners so we have precedence over content script
       // listeners, but only care about events targeted to the element we're
@@ -634,16 +651,18 @@ DocumentManager = {
     // Clean up iframe extension page contexts on extension shutdown.
     for (let [winId, context] of this.extensionPageWindows) {
       if (context.extension.id == extensionId) {
         context.close();
         this.extensionPageWindows.delete(winId);
       }
     }
 
+    ExtensionChild.shutdownExtension(extensionId);
+
     MessageChannel.abortResponses({extensionId});
 
     this.extensionCount--;
     if (this.extensionCount == 0) {
       this.uninit();
     }
   },
 
@@ -712,16 +731,17 @@ BrowserExtensionContent.prototype = {
 };
 
 ExtensionManager = {
   // Map[extensionId, BrowserExtensionContent]
   extensions: new Map(),
 
   init() {
     Schemas.init();
+    ExtensionChild.initOnce();
 
     Services.cpmm.addMessageListener("Extension:Startup", this);
     Services.cpmm.addMessageListener("Extension:Shutdown", this);
     Services.cpmm.addMessageListener("Extension:FlushJarCache", this);
 
     if (Services.cpmm.initialProcessData && "Extension:Extensions" in Services.cpmm.initialProcessData) {
       let extensions = Services.cpmm.initialProcessData["Extension:Extensions"];
       for (let data of extensions) {
--- a/toolkit/components/extensions/ext-backgroundPage.js
+++ b/toolkit/components/extensions/ext-backgroundPage.js
@@ -75,16 +75,17 @@ BackgroundPage.prototype = {
 
     let browser = chromeDoc.createElement("browser");
     browser.setAttribute("type", "content");
     browser.setAttribute("disableglobalhistory", "true");
     browser.setAttribute("webextension-view-type", "background");
     browser.setAttribute("src", url);
     chromeDoc.documentElement.appendChild(browser);
 
+    extensions.emit("extension-browser-inserted", browser);
 
     yield new Promise(resolve => {
       browser.addEventListener("load", function onLoad(event) {
         if (event.target === browser.contentDocument) {
           browser.removeEventListener("load", onLoad, true);
           resolve();
         }
       }, true);