Bug 1287007 - Introduce ChildAPIManager to addon code draft
authorRob Wu <rob@robwu.nl>
Mon, 05 Sep 2016 18:57:38 -0700
changeset 428426 3e1ff77c2b8dd907eed9f11c026ac84d408a2fb3
parent 428425 df56da06ad5af935c8c014ad1c4159cb19831d9a
child 428427 b8990fffd3c578867cec20a91404533bbc3c7a78
push id33305
push userbmo:rob@robwu.nl
push dateSun, 23 Oct 2016 20:56:25 +0000
bugs1287007
milestone52.0a1
Bug 1287007 - Introduce ChildAPIManager to addon code This is the bare minimum to separate the generation of addon_parent and addon_child APIs. Now it is possible to have methods with the same name but different implementations in the parent and child. Many APIs are not compatible with the proxied API implementation, so they temporarily fall back to directly invoking the parent API, just as before this commit. MozReview-Commit-ID: fwuZUvD8tY
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionChild.jsm
toolkit/components/extensions/ExtensionContent.jsm
toolkit/components/extensions/ExtensionUtils.jsm
toolkit/components/extensions/extensions-toolkit.manifest
toolkit/components/extensions/test/mochitest/file_teardown_test.js
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -68,17 +68,16 @@ Cu.import("resource://gre/modules/Extens
 
 XPCOMUtils.defineLazyServiceGetter(this, "uuidGen",
                                    "@mozilla.org/uuid-generator;1",
                                    "nsIUUIDGenerator");
 
 const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json";
 const CATEGORY_EXTENSION_SCHEMAS = "webextension-schemas";
 const CATEGORY_EXTENSION_SCRIPTS = "webextension-scripts";
-const CATEGORY_EXTENSION_SCRIPTS_ADDON = "webextension-scripts-addon";
 
 let schemaURLs = new Set();
 
 if (!AppConstants.RELEASE_OR_BETA) {
   schemaURLs.add("chrome://extensions/content/schemas/experiments.json");
 }
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
@@ -138,38 +137,24 @@ var Management = new class extends Schem
       }
       return Promise.all(promises);
     });
 
     for (let [/* name */, value] of XPCOMUtils.enumerateCategoryEntries(CATEGORY_EXTENSION_SCRIPTS)) {
       this.loadScript(value);
     }
 
-    // TODO(robwu): This should move to its own instances of SchemaAPIManager,
-    // because the above scripts run in the chrome process whereas the following
-    // scripts runs in the addon process.
-    for (let [/* name */, value] of XPCOMUtils.enumerateCategoryEntries(CATEGORY_EXTENSION_SCRIPTS_ADDON)) {
-      this.loadScript(value);
-    }
-
     this.initialized = promise;
     return this.initialized;
   }
 
   registerSchemaAPI(namespace, envType, getAPI) {
     if (envType == "addon_parent" || envType == "content_parent") {
       super.registerSchemaAPI(namespace, envType, getAPI);
     }
-    if (envType === "addon_child") {
-      // TODO(robwu): Remove this. It is a temporary hack to ease the transition
-      // from ext-*.js running in the parent to APIs running in a child process.
-      // This can be removed once there is a dedicated ExtensionContext with type
-      // "addon_child".
-      super.registerSchemaAPI(namespace, "addon_parent", getAPI);
-    }
   }
 }();
 
 // Subscribes to messages related to the extension messaging API and forwards it
 // to the relevant message manager. The "sender" field for the `onMessage` and
 // `onConnect` events are updated if needed.
 let ProxyMessenger = {
   _initialized: false,
@@ -246,20 +231,18 @@ let ProxyMessenger = {
 
     // Note: No special handling for sendNativeMessage / connectNative because
     // native messaging runs in the chrome process, so it never needs a proxy.
     return null;
   },
 };
 
 class ProxyContext extends BaseContext {
-  constructor(extension, params, messageManager, principal) {
-    // TODO(robwu): Let callers specify the environment type once we start
-    // re-using this implementation for addon_parent.
-    super("content_parent", extension);
+  constructor(envType, extension, params, messageManager, principal) {
+    super(envType, extension);
 
     this.uri = NetUtil.newURI(params.url);
 
     this.messageManager = messageManager;
     this.principal_ = principal;
 
     this.apiObj = {};
     GlobalManager.injectInObject(this, false, this.apiObj);
@@ -283,16 +266,53 @@ class ProxyContext extends BaseContext {
     if (this.unloaded) {
       return;
     }
     super.unload();
     Management.emit("proxy-context-unload", this);
   }
 }
 
+// The parent ProxyContext of an ExtensionContext in ExtensionChild.jsm.
+class ExtensionChildProxyContext extends ProxyContext {
+  constructor(envType, extension, params, xulBrowser) {
+    super(envType, extension, params, xulBrowser.messageManager, extension.principal);
+
+    this.viewType = params.viewType;
+    this.xulBrowser = xulBrowser;
+
+    // TODO(robwu): Remove this once all APIs can run in a separate process.
+    if (params.cloneScopeInProcess) {
+      this.sandbox = params.cloneScopeInProcess;
+    }
+  }
+
+  // The window that contains this context. This may change due to moving tabs.
+  get xulWindow() {
+    return this.xulBrowser.ownerGlobal;
+  }
+
+  get windowId() {
+    if (!Management.global.WindowManager || this.viewType == "background") {
+      return;
+    }
+    // viewType popup or tab:
+    return Management.global.WindowManager.getId(this.xulWindow);
+  }
+
+  get tabId() {
+    if (!Management.global.TabManager) {
+      return;  // Not yet supported on Android.
+    }
+    let {gBrowser} = this.xulBrowser.ownerGlobal;
+    let tab = gBrowser && gBrowser.getTabForBrowser(this.xulBrowser);
+    return tab && Management.global.TabManager.getId(tab);
+  }
+}
+
 function findPathInObject(obj, path, printErrors = true) {
   for (let elt of path.split(".")) {
     // If we get a null object before reaching the requested path
     // (e.g. the API object is returned only on particular kind of contexts instead
     // of based on WebExtensions permissions, like it happens for the devtools APIs),
     // stop searching and return undefined.
     // TODO(robwu): This should never be reached. If an API is not available for
     // a context, it should be declared as such in the schema and enforced by
@@ -311,17 +331,17 @@ function findPathInObject(obj, path, pri
     }
 
     obj = obj[elt];
   }
 
   return obj;
 }
 
-let ParentAPIManager = {
+var ParentAPIManager = {
   proxyContexts: new Map(),
 
   init() {
     Services.obs.addObserver(this, "message-manager-close", false);
 
     Services.mm.addMessageListener("API:CreateProxyContext", this);
     Services.mm.addMessageListener("API:CloseProxyContext", this, true);
     Services.mm.addMessageListener("API:Call", this);
@@ -359,24 +379,43 @@ let ParentAPIManager = {
 
       case "API:RemoveListener":
         this.removeListener(data);
         break;
     }
   },
 
   createProxyContext(data, target) {
-    let {extensionId, childId, principal} = data;
+    let {envType, extensionId, childId, principal} = data;
     if (this.proxyContexts.has(childId)) {
       Cu.reportError("A WebExtension context with the given ID already exists!");
       return;
     }
     let extension = GlobalManager.getExtension(extensionId);
+    if (!extension) {
+      Cu.reportError(`No WebExtension found with ID ${extensionId}`);
+      return;
+    }
 
-    let context = new ProxyContext(extension, data, target.messageManager, principal);
+    let context;
+    if (envType == "addon_parent") {
+      // Privileged addon contexts can only be loaded in documents whose main
+      // frame is also the same addon.
+      if (principal.URI.prePath != extension.baseURI.prePath ||
+          !target.contentPrincipal.subsumes(principal)) {
+        Cu.reportError(`Refused to create privileged WebExtension context for ${principal.URI.spec}`);
+        return;
+      }
+      context = new ExtensionChildProxyContext(envType, extension, data, target);
+    } else if (envType == "content_parent") {
+      context = new ProxyContext(envType, extension, data, target.messageManager, principal);
+    } else {
+      Cu.reportError(`Invalid WebExtension context envType: ${envType}`);
+      return;
+    }
     this.proxyContexts.set(childId, context);
   },
 
   closeProxyContext(childId) {
     let context = this.proxyContexts.get(childId);
     if (!context) {
       return;
     }
@@ -639,26 +678,16 @@ GlobalManager = {
   },
 
   observe(document, topic, data) {
     let contentWindow = document.defaultView;
     if (!contentWindow) {
       return;
     }
 
-    let inject = context => {
-      let injectObject = (name, isChromeCompat) => {
-        let browserObj = Cu.createObjectIn(contentWindow, {defineAs: name});
-        this.injectInObject(context, isChromeCompat, browserObj);
-      };
-
-      injectObject("browser", false);
-      injectObject("chrome", true);
-    };
-
     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;
     }
 
@@ -690,17 +719,16 @@ GlobalManager = {
       // pop-ups.
       type = "popup";
     }
 
     let extension = this.extensionMap.get(id);
     let uri = document.documentURIObject;
 
     let context = new ExtensionContext(extension, {type, contentWindow, uri, docShell});
-    inject(context);
     if (type == "background") {
       this._initializeBackgroundPage(contentWindow);
     }
 
     let innerWindowID = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
       .getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID;
 
     let onUnload = subject => {
--- a/toolkit/components/extensions/ExtensionChild.jsm
+++ b/toolkit/components/extensions/ExtensionChild.jsm
@@ -17,49 +17,140 @@ 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, "Schemas",
+                                  "resource://gre/modules/Schemas.jsm");
+
+const CATEGORY_EXTENSION_SCRIPTS_ADDON = "webextension-scripts-addon";
+
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   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, "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");
+    this.initialized = false;
+  }
+
+  generateAPIs(...args) {
+    if (!this.initialized) {
+      this.initialized = true;
+      for (let [/* name */, value] of XPCOMUtils.enumerateCategoryEntries(CATEGORY_EXTENSION_SCRIPTS_ADDON)) {
+        this.loadScript(value);
+      }
+    }
+    return super.generateAPIs(...args);
+  }
+
+  registerSchemaAPI(namespace, envType, getAPI) {
+    if (envType == "addon_child") {
+      super.registerSchemaAPI(namespace, envType, getAPI);
+    }
+  }
+}();
+
+// A class that behaves identical to a ChildAPIManager, except
+// 1) creation of the ProxyContext in the parent is synchronous, and
+// 2) APIs without a local implementation and marked as incompatible with the
+//    out-of-process model fall back to directly invoking the parent methods.
+// TODO(robwu): Remove this when all APIs have migrated.
+class WannabeChildAPIManager extends ChildAPIManager {
+  createProxyContextInConstructor(data) {
+    // Create a structured clone to simulate IPC.
+    data = Object.assign({}, data);
+    let {principal} = data;  // Not structurally cloneable.
+    delete data.principal;
+    data = Cu.cloneInto(data, {});
+    data.principal = principal;
+    data.cloneScopeInProcess = this.context.cloneScope;
+    let name = "API:CreateProxyContext";
+    // The <browser> that receives messages from `this.messageManager`.
+    let target = this.context.contentWindow
+      .QueryInterface(Ci.nsIInterfaceRequestor)
+      .getInterface(Ci.nsIDocShell)
+      .chromeEventHandler;
+    ParentAPIManager.receiveMessage({name, data, target});
+
+    let proxyContext = ParentAPIManager.proxyContexts.get(this.id);
+    // Many APIs rely on this, so temporarily add it to keep the commit small.
+    proxyContext.setContentWindow(this.context.contentWindow);
+
+    // Synchronously unload the ProxyContext because we synchronously create it.
+    this.context.callOnClose({close: proxyContext.unload.bind(proxyContext)});
+  }
+
+  getFallbackImplementation(namespace, name) {
+    // This is gross and should be removed ASAP.
+    let shouldSynchronouslyUseParentAPI = true;
+    // The test API is known to be fully compatible with webext-oop,
+    // except for events due to bugzil.la/1300234
+    if (namespace == "test" && name != "onMessage") {
+      shouldSynchronouslyUseParentAPI = false;
+    }
+    if (shouldSynchronouslyUseParentAPI) {
+      let proxyContext = ParentAPIManager.proxyContexts.get(this.id);
+      let apiObj = findPathInObject(proxyContext.apiObj, namespace, false);
+      if (apiObj && name in apiObj) {
+        return new LocalAPIImplementation(apiObj, name, this.context);
+      }
+      // If we got here, then it means that the JSON schema claimed that the API
+      // will be available, but no actual implementation is given.
+      // You should either provide an implementation or rewrite the JSON schema.
+    }
+
+    return super.getFallbackImplementation(namespace, name);
+  }
+}
 
 // An extension page is an execution context for any extension content
 // that runs in the chrome process. It's used for background pages
 // (type="background"), popups (type="popup"), and any extension
 // content loaded into browser tabs (type="tab").
 //
 // |params| is an object with the following properties:
 // |type| 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 {
   constructor(extension, params) {
-    // TODO(robwu): This should be addon_child once all ext- files are split.
-    // There should be a new ProxyContext instance with the "addon_parent" type.
-    super("addon_parent", extension);
+    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");
+    }
 
-    let {type, uri} = params;
+    let {type, uri, contentWindow} = params;
     this.type = type;
     this.uri = uri || extension.baseURI;
 
-    this.setContentWindow(params.contentWindow);
+    this.setContentWindow(contentWindow);
 
     // This is the MessageSender property passed to extension.
     // It can be augmented by the "page-open" hook.
     let sender = {id: extension.uuid};
     if (uri) {
       sender.url = uri.spec;
     }
     Management.emit("page-load", this, params, sender);
@@ -67,16 +158,31 @@ this.ExtensionContext = class extends Ba
     let filter = {extensionId: extension.id};
     let optionalFilter = {};
     // Addon-generated messages (not necessarily from the same process as the
     // addon itself) are sent to the main process, which forwards them via the
     // parent process message manager. Specific replies can be sent to the frame
     // message manager.
     this.messenger = new Messenger(this, [Services.cpmm, this.messageManager], sender, filter, optionalFilter);
 
+    let localApis = {};
+    apiManager.generateAPIs(this, localApis);
+    this.childManager = new WannabeChildAPIManager(this, this.messageManager, localApis, {
+      envType: "addon_parent",
+      viewType: type,
+      url: uri.spec,
+    });
+    let chromeApiWrapper = Object.create(this.childManager);
+    chromeApiWrapper.isChromeCompat = true;
+
+    let browserObj = Cu.createObjectIn(contentWindow, {defineAs: "browser"});
+    let chromeObj = Cu.createObjectIn(contentWindow, {defineAs: "chrome"});
+    Schemas.inject(browserObj, this.childManager);
+    Schemas.inject(chromeObj, chromeApiWrapper);
+
     if (this.externallyVisible) {
       this.extension.views.add(this);
     }
   }
 
   get cloneScope() {
     return this.contentWindow;
   }
@@ -101,18 +207,17 @@ this.ExtensionContext = class extends Ba
     // Note that without this guard, we end up running unload code
     // multiple times for tab pages closed by the "page-unload" handlers
     // triggered below.
     if (this.unloaded) {
       return;
     }
 
     super.unload();
-
-    Management.emit("page-unload", this);
+    this.childManager.close();
 
     if (this.externallyVisible) {
       this.extension.views.delete(this);
     }
   }
 };
 
 
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -337,17 +337,17 @@ class ExtensionContext extends BaseConte
 
     // Sandboxes don't get Xrays for some weird compatibility
     // reason. However, we waive here anyway in case that changes.
     Cu.waiveXrays(this.sandbox).chrome = this.chromeObj;
 
     let localApis = {};
     apiManager.generateAPIs(this, localApis);
     this.childManager = new ChildAPIManager(this, this.messageManager, localApis, {
-      type: "content_script",
+      envType: "content_parent",
       url,
     });
 
     Schemas.inject(this.chromeObj, this.childManager);
 
     // This is an iframe with content script API enabled. (See Bug 1214658 for rationale)
     if (isExtensionPage) {
       Cu.waiveXrays(this.contentWindow).chrome = this.chromeObj;
--- a/toolkit/components/extensions/ExtensionUtils.jsm
+++ b/toolkit/components/extensions/ExtensionUtils.jsm
@@ -1768,27 +1768,32 @@ class ChildAPIManager {
     // delegated to the ParentAPIManager.
     this.localApis = localApis;
 
     let id = String(context.extension.id) + "." + String(context.contextId);
     this.id = id;
 
     let data = {childId: id, extensionId: context.extension.id, principal: context.principal};
     Object.assign(data, contextData);
-    messageManager.sendAsyncMessage("API:CreateProxyContext", data);
 
     messageManager.addMessageListener("API:RunListener", this);
     messageManager.addMessageListener("API:CallResult", this);
 
     // Map[path -> Set[listener]]
     // path is, e.g., "runtime.onMessage".
     this.listeners = new Map();
 
     // Map[callId -> Deferred]
     this.callPromises = new Map();
+
+    this.createProxyContextInConstructor(data);
+  }
+
+  createProxyContextInConstructor(data) {
+    this.messageManager.sendAsyncMessage("API:CreateProxyContext", data);
   }
 
   receiveMessage({name, data}) {
     if (data.childId != this.id) {
       return;
     }
 
     switch (name) {
@@ -1873,22 +1878,26 @@ class ChildAPIManager {
           break;
         }
       }
       if (pathObj && name in pathObj) {
         return new LocalAPIImplementation(pathObj, name, this.context);
       }
     }
 
+    return this.getFallbackImplementation(namespace, name);
+  }
+
+  getFallbackImplementation(namespace, name) {
     // No local API found, defer implementation to the parent.
     return new ProxyAPIImplementation(namespace, name, this);
   }
 
   hasPermission(permission) {
-    return this.context.extension.permissions.has(permission);
+    return this.context.extension.hasPermission(permission);
   }
 }
 
 /**
  * This object loads the ext-*.js scripts that define the extension API.
  *
  * This class instance is shared with the scripts that it loads, so that the
  * ext-*.js scripts and the instantiator can communicate with each other.
--- a/toolkit/components/extensions/extensions-toolkit.manifest
+++ b/toolkit/components/extensions/extensions-toolkit.manifest
@@ -21,20 +21,18 @@ category webextension-scripts-content i1
 category webextension-scripts-content runtime chrome://extensions/content/ext-c-runtime.js
 category webextension-scripts-content test chrome://extensions/content/ext-c-test.js
 category webextension-scripts-content storage chrome://extensions/content/ext-c-storage.js
 
 # scripts that must run in the same process as addon code.
 category webextension-scripts-addon extension chrome://extensions/content/ext-c-extension.js
 category webextension-scripts-addon i18n chrome://extensions/content/ext-i18n.js
 category webextension-scripts-addon runtime chrome://extensions/content/ext-c-runtime.js
-# Uncomment when the child code and parent code are isolated from each other.
-# Currently they are aliases of each other (see "addon_parent" and "addon_child" in Extension.jsm).
-# category webextension-scripts-addon test chrome://extensions/content/ext-c-test.js
-# category webextension-scripts-addon storage chrome://extensions/content/ext-c-storage.js
+category webextension-scripts-addon test chrome://extensions/content/ext-c-test.js
+category webextension-scripts-addon storage chrome://extensions/content/ext-c-storage.js
 
 # schemas
 category webextension-schemas alarms chrome://extensions/content/schemas/alarms.json
 category webextension-schemas cookies chrome://extensions/content/schemas/cookies.json
 category webextension-schemas downloads chrome://extensions/content/schemas/downloads.json
 category webextension-schemas events chrome://extensions/content/schemas/events.json
 category webextension-schemas extension chrome://extensions/content/schemas/extension.json
 category webextension-schemas extension_types chrome://extensions/content/schemas/extension_types.json
--- a/toolkit/components/extensions/test/mochitest/file_teardown_test.js
+++ b/toolkit/components/extensions/test/mochitest/file_teardown_test.js
@@ -1,27 +1,23 @@
 "use strict";
 
 /* globals addMessageListener */
 let {Management} = Components.utils.import("resource://gre/modules/Extension.jsm", {});
 let events = [];
 function record(type, extensionContext) {
-  let eventType = type == "page-load" || type == "proxy-context-load" ? "load" : "unload";
+  let eventType = type == "proxy-context-load" ? "load" : "unload";
   let url = extensionContext.uri.spec;
   let extensionId = extensionContext.extension.id;
   events.push({eventType, url, extensionId});
 }
 
-Management.on("page-load", record);
-Management.on("page-unload", record);
 Management.on("proxy-context-load", record);
 Management.on("proxy-context-unload", record);
 addMessageListener("cleanup", () => {
-  Management.off("page-load", record);
-  Management.off("page-unload", record);
   Management.off("proxy-context-load", record);
   Management.off("proxy-context-unload", record);
 });
 
 addMessageListener("get-context-events", extensionId => {
   sendAsyncMessage("context-events", events);
   events = [];
 });