Bug 1197346 - Allow browser.storage to be used from content scripts (r=kmag)
authorBill McCloskey <billm@mozilla.com>
Tue, 05 Apr 2016 14:44:07 -0700
changeset 332567 2ce33bb34122b1862be894f8d93708dbd9916881
parent 332566 7c6d8e1ab93734c711f1e2f8e0080bf7c1def5ef
child 332568 4c4d7a9fc371393fa3f448737375fa4b171d014d
push id6048
push userkmoir@mozilla.com
push dateMon, 06 Jun 2016 19:02:08 +0000
treeherdermozilla-beta@46d72a56c57d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerskmag
bugs1197346
milestone48.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 1197346 - Allow browser.storage to be used from content scripts (r=kmag)
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionContent.jsm
toolkit/components/extensions/ExtensionStorage.jsm
toolkit/components/extensions/ExtensionUtils.jsm
toolkit/components/extensions/Schemas.jsm
toolkit/components/extensions/test/mochitest/mochitest.ini
toolkit/components/extensions/test/mochitest/test_ext_storage_content.html
toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -179,17 +179,17 @@ var Management = {
   },
 
   registerSchemaAPI(namespace, permission, api) {
     this.schemaApis.push({namespace, permission, api});
   },
 
   // Mash together into a single object all the APIs registered by the
   // functions above. Return the merged object.
-  generateAPIs(extension, context, apis) {
+  generateAPIs(extension, context, apis, namespaces = null) {
     let obj = {};
 
     // Recursively copy properties from source to dest.
     function copy(dest, source) {
       for (let prop in source) {
         let desc = Object.getOwnPropertyDescriptor(source, prop);
         if (typeof(desc.value) == "object") {
           if (!(prop in dest)) {
@@ -198,16 +198,19 @@ var Management = {
           copy(dest[prop], source[prop]);
         } else {
           Object.defineProperty(dest, prop, desc);
         }
       }
     }
 
     for (let api of apis) {
+      if (namespaces && !namespaces.includes(api.namespace)) {
+        continue;
+      }
       if (api.permission) {
         if (!extension.hasPermission(api.permission)) {
           continue;
         }
       }
 
       api = api.api(extension, context);
       copy(obj, api);
@@ -239,17 +242,17 @@ var Management = {
 // |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).
 // |incognito| is the content running in a private context (default: false).
 ExtensionContext = class extends BaseContext {
   constructor(extension, params) {
-    super();
+    super(extension.id);
 
     let {type, contentWindow, uri} = params;
     this.extension = extension;
     this.type = type;
     this.contentWindow = contentWindow || null;
     this.uri = uri || extension.baseURI;
     this.incognito = params.incognito || false;
 
@@ -264,27 +267,33 @@ ExtensionContext = class extends BaseCon
     };
     Management.emit("page-load", this, params, sender, delegate);
 
     // Properties in |filter| must match those in the |recipient|
     // parameter of sendMessage.
     let filter = {extensionId: extension.id};
     this.messenger = new Messenger(this, [Services.mm, Services.ppmm], sender, filter, delegate);
 
-    this.extension.views.add(this);
+    if (this.externallyVisible) {
+      this.extension.views.add(this);
+    }
   }
 
   get cloneScope() {
     return this.contentWindow;
   }
 
   get principal() {
     return this.contentWindow.document.nodePrincipal;
   }
 
+  get externallyVisible() {
+    return true;
+  }
+
   // Called when the extension shuts down.
   shutdown() {
     Management.emit("page-shutdown", this);
     this.unload();
   }
 
   // This method is called when an extension page navigates away or
   // its tab is closed.
@@ -295,20 +304,182 @@ ExtensionContext = class extends BaseCon
     if (this.unloaded) {
       return;
     }
 
     super.unload();
 
     Management.emit("page-unload", this);
 
-    this.extension.views.delete(this);
+    if (this.externallyVisible) {
+      this.extension.views.delete(this);
+    }
   }
 };
 
+class ProxyContext extends ExtensionContext {
+  constructor(extension, params, messageManager, principal) {
+    params.contentWindow = null;
+    params.uri = NetUtil.newURI(params.url);
+
+    super(extension, params);
+    this.messageManager = messageManager;
+    this.principal_ = principal;
+
+    this.apiObj = {};
+    GlobalManager.injectInObject(extension, this, null, this.apiObj, ["storage", "test"]);
+
+    this.listenerProxies = new Map();
+
+    this.sandbox = Cu.Sandbox(principal, {});
+  }
+
+  get principal() {
+    return this.principal_;
+  }
+
+  get cloneScope() {
+    return this.sandbox;
+  }
+
+  get externallyVisible() {
+    return false;
+  }
+}
+
+function findPathInObject(obj, path) {
+  for (let elt of path) {
+    obj = obj[elt];
+  }
+  return obj;
+}
+
+let 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);
+    Services.mm.addMessageListener("API:AddListener", this);
+    Services.mm.addMessageListener("API:RemoveListener", this);
+  },
+
+  // "message-manager-close" observer.
+  observe(subject, topic, data) {
+    let mm = subject;
+    for (let [childId, context] of this.proxyContexts) {
+      if (context.messageManager == mm) {
+        this.closeProxyContext(childId);
+      }
+    }
+  },
+
+  receiveMessage({name, data, target}) {
+    switch (name) {
+      case "API:CreateProxyContext":
+        this.createProxyContext(data, target);
+        break;
+
+      case "API:CloseProxyContext":
+        this.closeProxyContext(data.childId);
+        break;
+
+      case "API:Call":
+        this.call(data, target);
+        break;
+
+      case "API:AddListener":
+        this.addListener(data, target);
+        break;
+
+      case "API:RemoveListener":
+        this.removeListener(data);
+        break;
+    }
+  },
+
+  createProxyContext(data, target) {
+    let {extensionId, childId, principal} = data;
+    let extension = GlobalManager.getExtension(extensionId);
+
+    let context = new ProxyContext(extension, data, target.messageManager, principal);
+    this.proxyContexts.set(childId, context);
+  },
+
+  closeProxyContext(childId) {
+    if (!this.proxyContexts.has(childId)) {
+      return;
+    }
+    let context = this.proxyContexts.get(childId);
+    context.unload();
+    this.proxyContexts.delete(childId);
+  },
+
+  call(data, target) {
+    let context = this.proxyContexts.get(data.childId);
+    function callback(...cbArgs) {
+      let lastError = context.lastError;
+
+      target.messageManager.sendAsyncMessage("API:CallResult", {
+        childId: data.childId,
+        callId: data.callId,
+        args: cbArgs,
+        lastError: lastError ? lastError.message : null,
+      });
+    }
+
+    let args = data.args;
+    args = Cu.cloneInto(args, context.sandbox);
+    if (data.callId) {
+      args = args.concat(callback);
+    }
+    try {
+      findPathInObject(context.apiObj, data.path)[data.name](...args);
+    } catch (e) {
+      let msg = e.message || "API failed";
+      target.messageManager.sendAsyncMessage("API:CallResult", {
+        childId: data.childId,
+        callId: data.callId,
+        lastError: msg,
+      });
+    }
+  },
+
+  addListener(data, target) {
+    let context = this.proxyContexts.get(data.childId);
+
+    function listener(...listenerArgs) {
+      target.messageManager.sendAsyncMessage("API:RunListener", {
+        childId: data.childId,
+        path: data.path,
+        name: data.name,
+        args: listenerArgs,
+      });
+    }
+
+    let ref = data.path.concat(data.name).join(".");
+    context.listenerProxies.set(ref, listener);
+
+    let args = Cu.cloneInto(data.args, context.sandbox);
+    findPathInObject(context.apiObj, data.path)[data.name].addListener(listener, ...args);
+  },
+
+  removeListener(data) {
+    let context = this.proxyContexts.get(data.childId);
+    let ref = data.path.concat(data.name).join(".");
+    let listener = context.listenerProxies.get(ref);
+    findPathInObject(context.apiObj, data.path)[data.name].removeListener(listener);
+  },
+};
+
+ParentAPIManager.init();
+
 // For extensions that have called setUninstallURL(), send an event
 // so the browser can display the URL.
 let UninstallObserver = {
   init: function() {
     AddonManager.addAddonListener(this);
   },
 
   onUninstalling: function(addon) {
@@ -351,102 +522,102 @@ GlobalManager = {
       if (extension == data.extension) {
         this.docShells.delete(docShell);
       }
     }
 
     this.extensionMap.delete(extension.id);
   },
 
+  getExtension(extensionId) {
+    return this.extensionMap.get(extensionId);
+  },
+
   injectInDocShell(docShell, extension, context) {
     this.docShells.set(docShell, {extension, context});
   },
 
+  injectInObject(extension, context, defaultCallback, dest, namespaces = null) {
+    let api = Management.generateAPIs(extension, context, Management.apis, namespaces);
+    injectAPI(api, dest);
+
+    let schemaApi = Management.generateAPIs(extension, context, Management.schemaApis, namespaces);
+
+    // Add in any extra API namespaces which do not have implementations
+    // outside of their schema file.
+    schemaApi.extensionTypes = {};
+
+    let schemaWrapper = {
+      get cloneScope() {
+        return context.cloneScope;
+      },
+
+      callFunction(path, name, args) {
+        return findPathInObject(schemaApi, path)[name](...args);
+      },
+
+      callFunctionNoReturn(path, name, args) {
+        return findPathInObject(schemaApi, path)[name](...args);
+      },
+
+      callAsyncFunction(path, name, args, callback) {
+        // We pass an empty stub function as a default callback for
+        // the `chrome` API, so promise objects are not returned,
+        // and lastError values are reported immediately.
+        if (callback === null) {
+          callback = defaultCallback;
+        }
+
+        let promise;
+        try {
+          promise = findPathInObject(schemaApi, path)[name](...args);
+        } catch (e) {
+          promise = Promise.reject(e);
+        }
+
+        return context.wrapPromise(promise || Promise.resolve(), callback);
+      },
+
+      shouldInject(namespace, name) {
+        if (namespaces && namespaces.indexOf(namespace) == -1) {
+          return false;
+        }
+        return findPathInObject(schemaApi, [namespace]) != null;
+      },
+
+      getProperty(path, name) {
+        return findPathInObject(schemaApi, path)[name];
+      },
+
+      setProperty(path, name, value) {
+        findPathInObject(schemaApi, path)[name] = value;
+      },
+
+      addListener(path, name, listener, args) {
+        return findPathInObject(schemaApi, path)[name].addListener.call(null, listener, ...args);
+      },
+      removeListener(path, name, listener) {
+        return findPathInObject(schemaApi, path)[name].removeListener.call(null, listener);
+      },
+      hasListener(path, name, listener) {
+        return findPathInObject(schemaApi, path)[name].hasListener.call(null, listener);
+      },
+    };
+    Schemas.inject(dest, schemaWrapper);
+  },
+
   observe(contentWindow, topic, data) {
     let inject = (extension, context) => {
       // We create two separate sets of bindings, one for the `chrome`
       // global, and one for the `browser` global. The latter returns
       // Promise objects if a callback is not passed, while the former
       // does not.
       let injectObject = (name, defaultCallback) => {
         let browserObj = Cu.createObjectIn(contentWindow, {defineAs: name});
-
-        let api = Management.generateAPIs(extension, context, Management.apis);
-        injectAPI(api, browserObj);
-
-        let schemaApi = Management.generateAPIs(extension, context, Management.schemaApis);
-
-        // Add in any extra API namespaces which do not have implementations
-        // outside of their schema file.
-        schemaApi.extensionTypes = {};
-
-        function findPath(path) {
-          let obj = schemaApi;
-          for (let elt of path) {
-            if (!(elt in obj)) {
-              return null;
-            }
-            obj = obj[elt];
-          }
-          return obj;
-        }
-        let schemaWrapper = {
-          get cloneScope() {
-            return context.cloneScope;
-          },
-
-          callFunction(path, name, args) {
-            return findPath(path)[name](...args);
-          },
-
-          callFunctionNoReturn(path, name, args) {
-            return findPath(path)[name](...args);
-          },
-
-          callAsyncFunction(path, name, args, callback) {
-            // We pass an empty stub function as a default callback for
-            // the `chrome` API, so promise objects are not returned,
-            // and lastError values are reported immediately.
-            if (callback === null) {
-              callback = defaultCallback;
-            }
-
-            let promise;
-            try {
-              promise = findPath(path)[name](...args);
-            } catch (e) {
-              promise = Promise.reject(e);
-            }
-
-            return context.wrapPromise(promise || Promise.resolve(), callback);
-          },
-
-          shouldInject(path, name) {
-            return findPath(path) != null;
-          },
-
-          getProperty(path, name) {
-            return findPath(path)[name];
-          },
-
-          setProperty(path, name, value) {
-            findPath(path)[name] = value;
-          },
-
-          addListener(path, name, listener, args) {
-            return findPath(path)[name].addListener.call(null, listener, ...args);
-          },
-          removeListener(path, name, listener) {
-            return findPath(path)[name].removeListener.call(null, listener);
-          },
-          hasListener(path, name, listener) {
-            return findPath(path)[name].hasListener.call(null, listener);
-          },
-        };
-        Schemas.inject(browserObj, schemaWrapper);
+        this.injectInObject(extension, context, defaultCallback, browserObj);
       };
 
       injectObject("browser", null);
       injectObject("chrome", () => {});
     };
 
     let id = ExtensionManagement.getAddonIdForWindow(contentWindow);
 
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -26,35 +26,38 @@ Cu.import("resource://gre/modules/AppCon
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionManagement",
                                   "resource://gre/modules/ExtensionManagement.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "LanguageDetector",
                                   "resource:///modules/translation/LanguageDetector.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MatchPattern",
                                   "resource://gre/modules/MatchPattern.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MatchGlobs",
                                   "resource://gre/modules/MatchPattern.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
+                                  "resource://gre/modules/MessageChannel.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
                                   "resource://gre/modules/PrivateBrowsingUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils",
                                   "resource://gre/modules/PromiseUtils.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
-                                  "resource://gre/modules/MessageChannel.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
+                                  "resource://gre/modules/Schemas.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "WebNavigationFrames",
                                   "resource://gre/modules/WebNavigationFrames.jsm");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   runSafeSyncWithoutClone,
   BaseContext,
   LocaleData,
   Messenger,
   injectAPI,
   flushJarCache,
   detectLanguage,
   promiseDocumentReady,
+  ChildAPIManager,
 } = ExtensionUtils;
 
 function isWhenBeforeOrSame(when1, when2) {
   let table = {"document_start": 0,
                "document_end": 1,
                "document_idle": 2};
   return table[when1] <= table[when2];
 }
@@ -276,17 +279,17 @@ function getWindowMessageManager(content
 var DocumentManager;
 var ExtensionManager;
 
 // Scope in which extension content script code can run. It uses
 // Cu.Sandbox to run the code. There is a separate scope for each
 // frame.
 class ExtensionContext extends BaseContext {
   constructor(extensionId, contentWindow, contextOptions = {}) {
-    super();
+    super(extensionId);
 
     let {isExtensionPage} = contextOptions;
 
     this.isExtensionPage = isExtensionPage;
     this.extension = ExtensionManager.get(extensionId);
     this.extensionId = extensionId;
     this.contentWindow = contentWindow;
 
@@ -363,16 +366,33 @@ class ExtensionContext extends BaseConte
     this.messenger = new Messenger(this, [mm], sender, filter, delegate);
 
     this.chromeObj = Cu.createObjectIn(this.sandbox, {defineAs: "browser"});
 
     // 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 apis = {
+      "storage": "chrome://extensions/content/schemas/storage.json",
+      "test": "chrome://extensions/content/schemas/test.json",
+    };
+
+    let incognito = PrivateBrowsingUtils.isContentWindowPrivate(this.contentWindow);
+    this.childManager = new ChildAPIManager(this, mm, Object.keys(apis), {
+      type: "content_script",
+      url,
+      incognito,
+    });
+
+    for (let api in apis) {
+      Schemas.load(apis[api]);
+    }
+    Schemas.inject(this.chromeObj, this.childManager);
+
     injectAPI(api(this), this.chromeObj);
 
     // This is an iframe with content script API enabled. (See Bug 1214658 for rationale)
     if (isExtensionPage) {
       Cu.waiveXrays(this.contentWindow).chrome = this.chromeObj;
       Cu.waiveXrays(this.contentWindow).browser = this.chromeObj;
     }
   }
@@ -404,16 +424,18 @@ class ExtensionContext extends BaseConte
       // Don't bother saving scripts after document_idle.
       this.scripts.length = 0;
     }
   }
 
   close() {
     super.unload();
 
+    this.childManager.close();
+
     // Overwrite the content script APIs with an empty object if the APIs objects are still
     // defined in the content window (See Bug 1214658 for rationale).
     if (this.isExtensionPage && !Cu.isDeadWrapper(this.contentWindow) &&
         Cu.waiveXrays(this.contentWindow).browser === this.chromeObj) {
       Cu.createObjectIn(this.contentWindow, {defineAs: "browser"});
       Cu.createObjectIn(this.contentWindow, {defineAs: "chrome"});
     }
     Cu.nukeSandbox(this.sandbox);
@@ -710,16 +732,18 @@ BrowserExtensionContent.prototype = {
   },
 };
 
 ExtensionManager = {
   // Map[extensionId, BrowserExtensionContent]
   extensions: new Map(),
 
   init() {
+    Schemas.init();
+
     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) {
         this.extensions.set(data.id, new BrowserExtensionContent(data));
--- a/toolkit/components/extensions/ExtensionStorage.jsm
+++ b/toolkit/components/extensions/ExtensionStorage.jsm
@@ -66,27 +66,20 @@ this.ExtensionStorage = {
   extensionDir: Path.join(profileDir, "browser-extension-data"),
 
   /**
    * Sanitizes the given value, and returns a JSON-compatible
    * representation of it, based on the privileges of the given global.
    */
   sanitize(value, global) {
     // We can't trust that the global has privileges to access this
-    // value enough to clone it using a privileged JSON object. And JSON
-    // objects don't support X-ray wrappers, so we can't use the JSON
-    // object from the unprivileged global directly, either.
-    //
-    // So, instead, we create a new one, which we know is clean,
-    // belonging to the same principal as the unprivileged scope, and
-    // use that instead.
-    let JSON_ = Cu.waiveXrays(Cu.Sandbox(global).JSON);
+    // value enough to clone it using a privileged JSON object.
+    let JSON_ = Cu.waiveXrays(global.JSON);
 
     let json = JSON_.stringify(value, jsonReplacer);
-
     return JSON.parse(json);
   },
 
   getExtensionDir(extensionId) {
     return Path.join(this.extensionDir, extensionId);
   },
 
   getStorageFile(extensionId) {
--- a/toolkit/components/extensions/ExtensionUtils.jsm
+++ b/toolkit/components/extensions/ExtensionUtils.jsm
@@ -19,16 +19,18 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "LanguageDetector",
                                   "resource:///modules/translation/LanguageDetector.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Locale",
                                   "resource://gre/modules/Locale.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
                                   "resource://gre/modules/MessageChannel.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Preferences",
                                   "resource://gre/modules/Preferences.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils",
+                                  "resource://gre/modules/PromiseUtils.jsm");
 
 function filterStack(error) {
   return String(error.stack).replace(/(^.*(Task\.jsm|Promise-backend\.js).*\n)+/gm, "<Promise Chain>\n");
 }
 
 // Run a function and report exceptions.
 function runSafeSyncWithoutClone(f, ...args) {
   try {
@@ -134,22 +136,23 @@ class SpreadArgs extends Array {
     super();
     this.push(...args);
   }
 }
 
 let gContextId = 0;
 
 class BaseContext {
-  constructor() {
+  constructor(extensionId) {
     this.onClose = new Set();
     this.checkedLastError = false;
     this._lastError = null;
     this.contextId = ++gContextId;
     this.unloaded = false;
+    this.extensionId = extensionId;
   }
 
   get cloneScope() {
     throw new Error("Not implemented");
   }
 
   get principal() {
     throw new Error("Not implemented");
@@ -342,17 +345,17 @@ class BaseContext {
       });
     }
   }
 
   unload() {
     this.unloaded = true;
 
     MessageChannel.abortResponses({
-      extensionId: this.extension.id,
+      extensionId: this.extensionId,
       contextId: this.contextId,
     });
 
     for (let obj of this.onClose) {
       obj.close();
     }
   }
 }
@@ -1035,16 +1038,160 @@ function detectLanguage(text) {
       return {
         language: lang.languageCode,
         percentage: lang.percent,
       };
     }),
   }));
 }
 
+let nextId = 1;
+
+// We create one instance of this class for every extension context
+// that needs to use remote APIs. It uses the message manager to
+// communicate with the ParentAPIManager singleton in
+// Extension.jsm. It handles asynchronous function calls as well as
+// event listeners.
+class ChildAPIManager {
+  constructor(context, messageManager, namespaces, contextData) {
+    this.context = context;
+    this.messageManager = messageManager;
+    this.namespaces = namespaces;
+
+    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();
+  }
+
+  receiveMessage({name, data}) {
+    if (data.childId != this.id) {
+      return;
+    }
+
+    switch (name) {
+      case "API:RunListener":
+        let ref = data.path.concat(data.name).join(".");
+        let listeners = this.listeners.get(ref);
+        for (let callback of listeners) {
+          runSafe(this.context, callback, ...data.args);
+        }
+        break;
+
+      case "API:CallResult":
+        let deferred = this.callPromises.get(data.callId);
+        if (data.lastError) {
+          deferred.reject({message: data.lastError});
+        } else {
+          deferred.resolve(new SpreadArgs(data.args));
+        }
+        this.callPromises.delete(data.callId);
+        break;
+    }
+  }
+
+  close() {
+    this.messageManager.sendAsyncMessage("Extension:CloseProxyContext", {childId: this.id});
+  }
+
+  get cloneScope() {
+    return this.context.cloneScope;
+  }
+
+  callFunction(path, name, args) {
+    throw new Error("Not implemented");
+  }
+
+  callFunctionNoReturn(path, name, args) {
+    this.messageManager.sendAsyncMessage("API:Call", {
+      childId: this.id,
+      path, name, args,
+    });
+  }
+
+  callAsyncFunction(path, name, args, callback) {
+    let callId = nextId++;
+    let deferred = PromiseUtils.defer();
+    this.callPromises.set(callId, deferred);
+
+    this.messageManager.sendAsyncMessage("API:Call", {
+      childId: this.id,
+      callId,
+      path, name, args,
+    });
+
+    return this.context.wrapPromise(deferred.promise, callback);
+  }
+
+  shouldInject(namespace, name) {
+    return this.namespaces.includes(namespace);
+  }
+
+  getProperty(path, name) {
+    throw new Error("Not implemented");
+  }
+
+  setProperty(path, name, value) {
+    throw new Error("Not implemented");
+  }
+
+  addListener(path, name, listener, args) {
+    let ref = path.concat(name).join(".");
+    let set;
+    if (this.listeners.has(ref)) {
+      set = this.listeners.get(ref);
+    } else {
+      set = new Set();
+      this.listeners.set(ref, set);
+    }
+
+    set.add(listener);
+
+    if (set.size == 1) {
+      args = args.slice(1);
+
+      this.messageManager.sendAsyncMessage("API:AddListener", {
+        childId: this.id,
+        path, name, args,
+      });
+    }
+  }
+
+  removeListener(path, name, listener) {
+    let ref = path.concat(name).join(".");
+    let set = this.listeners.get(ref) || new Set();
+    set.remove(listener);
+
+    if (set.size == 0) {
+      this.messageManager.sendAsyncMessage("Extension:RemoveListener", {
+        childId: this.id,
+        path, name,
+      });
+    }
+  }
+
+  hasListener(path, name, listener) {
+    let ref = path.concat(name).join(".");
+    let set = this.listeners.get(ref) || new Set();
+    return set.has(listener);
+  }
+}
+
 this.ExtensionUtils = {
   detectLanguage,
   extend,
   flushJarCache,
   ignoreEvent,
   injectAPI,
   instanceOf,
   promiseDocumentReady,
@@ -1055,9 +1202,10 @@ this.ExtensionUtils = {
   BaseContext,
   DefaultWeakMap,
   EventManager,
   LocaleData,
   Messenger,
   PlatformInfo,
   SingletonEventManager,
   SpreadArgs,
+  ChildAPIManager,
 };
--- a/toolkit/components/extensions/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -19,19 +19,19 @@ var {
 this.EXPORTED_SYMBOLS = ["Schemas"];
 
 /* globals Schemas, URL */
 
 Cu.import("resource://gre/modules/NetUtil.jsm");
 
 Cu.importGlobalProperties(["URL"]);
 
-function readJSON(uri) {
+function readJSON(url) {
   return new Promise((resolve, reject) => {
-    NetUtil.asyncFetch({uri, loadUsingSystemPrincipal: true}, (inputStream, status) => {
+    NetUtil.asyncFetch({uri: url, loadUsingSystemPrincipal: true}, (inputStream, status) => {
       if (!Components.isSuccessCode(status)) {
         reject(new Error(status));
         return;
       }
       try {
         let text = NetUtil.readInputStreamToString(inputStream, inputStream.available());
 
         // Chrome JSON files include a license comment that we need to
@@ -84,22 +84,28 @@ class Context {
 
     this.path = [];
     this.preprocessors = {
       localize(value, context) {
         return value;
       },
     };
 
-    let props = ["addListener", "callFunction",
-                 "callFunctionNoReturn", "callAsyncFunction",
-                 "hasListener", "removeListener",
-                 "getProperty", "setProperty",
-                 "checkLoadURL", "logError",
-                 "preprocessors"];
+    let methods = ["addListener", "callFunction",
+                   "callFunctionNoReturn", "callAsyncFunction",
+                   "hasListener", "removeListener",
+                   "getProperty", "setProperty",
+                   "checkLoadURL", "logError"];
+    for (let method of methods) {
+      if (method in params) {
+        this[method] = params[method].bind(params);
+      }
+    }
+
+    let props = ["preprocessors"];
     for (let prop of props) {
       if (prop in params) {
         if (prop in this && typeof this[prop] == "object") {
           Object.assign(this[prop], params[prop]);
         } else {
           this[prop] = params[prop];
         }
       }
@@ -1060,16 +1066,25 @@ class Event extends CallEntry {
     let obj = Cu.createObjectIn(dest, {defineAs: name});
     Cu.exportFunction(addStub, obj, {defineAs: "addListener"});
     Cu.exportFunction(removeStub, obj, {defineAs: "removeListener"});
     Cu.exportFunction(hasStub, obj, {defineAs: "hasListener"});
   }
 }
 
 this.Schemas = {
+  initialized: false,
+
+  // Set of URLs that we have loaded via the load() method.
+  loadedUrls: new Set(),
+
+  // Maps a schema URL to the JSON contained in that schema file. This
+  // is useful for sending the JSON across processes.
+  schemaJSON: new Map(),
+
   // Map[<schema-name> -> Map[<symbol-name> -> Entry]]
   // This keeps track of all the schemas that have been loaded so far.
   namespaces: new Map(),
 
   register(namespaceName, symbol, value) {
     let ns = this.namespaces.get(namespaceName);
     if (!ns) {
       ns = new Map();
@@ -1334,18 +1349,42 @@ this.Schemas = {
                               ["name", "unsupported",
                                "extraParameters", "returns", "filters"]);
 
     let e = new Event(event, [namespaceName], event.name, type, extras,
                       event.unsupported || false);
     this.register(namespaceName, event.name, e);
   },
 
-  load(uri) {
-    return readJSON(uri).then(json => {
+  init() {
+    if (this.initialized) {
+      return;
+    }
+    this.initialized = true;
+
+    if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) {
+      let data = Services.cpmm.initialProcessData;
+      let schemas = data["Extension:Schemas"];
+      if (schemas) {
+        this.schemaJSON = schemas;
+      }
+      Services.cpmm.addMessageListener("Schema:Add", this);
+    }
+  },
+
+  receiveMessage(msg) {
+    switch (msg.name) {
+      case "Schema:Add":
+        this.schemaJSON.set(msg.data.url, msg.data.schema);
+        break;
+    }
+  },
+
+  load(url) {
+    let loadFromJSON = json => {
       for (let namespace of json) {
         let name = namespace.namespace;
 
         let types = namespace.types || [];
         for (let type of types) {
           this.loadType(name, type);
         }
 
@@ -1359,24 +1398,45 @@ this.Schemas = {
           this.loadFunction(name, fun);
         }
 
         let events = namespace.events || [];
         for (let event of events) {
           this.loadEvent(name, event);
         }
       }
-    });
+    };
+
+    if (Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_CONTENT) {
+      return readJSON(url).then(json => {
+        this.schemaJSON.set(url, json);
+
+        let data = Services.ppmm.initialProcessData;
+        data["Extension:Schemas"] = this.schemaJSON;
+
+        Services.ppmm.broadcastAsyncMessage("Schema:Add", {url, schema: json});
+
+        loadFromJSON(json);
+      });
+    } else {
+      if (this.loadedUrls.has(url)) {
+        return;
+      }
+      this.loadedUrls.add(url);
+
+      let schema = this.schemaJSON.get(url);
+      loadFromJSON(schema);
+    }
   },
 
   inject(dest, wrapperFuncs) {
     for (let [namespace, ns] of this.namespaces) {
       let obj = Cu.createObjectIn(dest, {defineAs: namespace});
       for (let [name, entry] of ns) {
-        if (wrapperFuncs.shouldInject([namespace], name)) {
+        if (wrapperFuncs.shouldInject(namespace, name)) {
           entry.inject([namespace], name, obj, new Context(wrapperFuncs));
         }
       }
 
       if (!Object.keys(obj).length) {
         delete dest[namespace];
       }
     }
--- a/toolkit/components/extensions/test/mochitest/mochitest.ini
+++ b/toolkit/components/extensions/test/mochitest/mochitest.ini
@@ -63,16 +63,17 @@ skip-if = (os == 'android' || buildapp =
 [test_ext_sandbox_var.html]
 [test_ext_sendmessage_reply.html]
 skip-if = (os == 'android' || buildapp == 'b2g') # sender.tab is undefined on b2g. Bug 1258975 on android.
 [test_ext_sendmessage_reply2.html]
 skip-if = (os == 'android' || buildapp == 'b2g') # sender.tab is undefined on b2g. Bug 1258975 on android.
 [test_ext_sendmessage_doublereply.html]
 skip-if = (os == 'android' || buildapp == 'b2g') # sender.tab is undefined on b2g. Bug 1258975 on android.
 [test_ext_storage.html]
+[test_ext_storage_content.html]
 [test_ext_storage_tab.html]
 skip-if = os == 'android' # Android does not currently support tabs.
 [test_ext_background_runtime_connect_params.html]
 [test_ext_cookies.html]
 [test_ext_cookies_expiry.html]
 skip-if = e10s || buildapp == 'b2g' # Uses cookie service via SpecialPowers.Services, which does not support e10s.
 [test_ext_cookies_permissions.html]
 skip-if = e10s || buildapp == 'b2g' # Uses cookie service via SpecialPowers.Services, which does not support e10s.
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_content.html
@@ -0,0 +1,204 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>WebExtension test</title>
+  <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
+  <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+  <script type="text/javascript" src="head.js"></script>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="application/javascript">
+"use strict";
+
+function contentScript() {
+  function set(items) {
+    return new Promise(resolve => {
+      browser.storage.local.set(items, resolve);
+    });
+  }
+
+  function get(items) {
+    return new Promise(resolve => {
+      browser.storage.local.get(items, resolve);
+    });
+  }
+
+  function remove(items) {
+    return new Promise(resolve => {
+      browser.storage.local.remove(items, resolve);
+    });
+  }
+
+  function clear(items) {
+    return new Promise(resolve => {
+      browser.storage.local.clear(resolve);
+    });
+  }
+
+  function check(prop, value) {
+    return get(null).then(data => {
+      browser.test.assertEq(data[prop], value, "null getter worked for " + prop);
+      return get(prop);
+    }).then(data => {
+      browser.test.assertEq(data[prop], value, "string getter worked for " + prop);
+      return get([prop]);
+    }).then(data => {
+      browser.test.assertEq(data[prop], value, "array getter worked for " + prop);
+      return get({[prop]: undefined});
+    }).then(data => {
+      browser.test.assertEq(data[prop], value, "object getter worked for " + prop);
+    });
+  }
+
+  let globalChanges = {};
+
+  browser.storage.onChanged.addListener((changes, storage) => {
+    browser.test.assertEq(storage, "local", "storage is local");
+    Object.assign(globalChanges, changes);
+  });
+
+  function checkChanges(changes) {
+    function checkSub(obj1, obj2) {
+      for (let prop in obj1) {
+        browser.test.assertEq(obj1[prop].oldValue, obj2[prop].oldValue);
+        browser.test.assertEq(obj1[prop].newValue, obj2[prop].newValue);
+      }
+    }
+
+    checkSub(changes, globalChanges);
+    checkSub(globalChanges, changes);
+    globalChanges = {};
+  }
+
+  /* eslint-disable dot-notation */
+
+  // Set some data and then test getters.
+  set({"test-prop1": "value1", "test-prop2": "value2"}).then(() => {
+    checkChanges({"test-prop1": {newValue: "value1"}, "test-prop2": {newValue: "value2"}});
+    return check("test-prop1", "value1");
+  }).then(() => {
+    return check("test-prop2", "value2");
+  }).then(() => {
+    return get({"test-prop1": undefined, "test-prop2": undefined, "other": "default"});
+  }).then(data => {
+    browser.test.assertEq(data["test-prop1"], "value1", "prop1 correct");
+    browser.test.assertEq(data["test-prop2"], "value2", "prop2 correct");
+    browser.test.assertEq(data["other"], "default", "other correct");
+    return get(["test-prop1", "test-prop2", "other"]);
+  }).then(data => {
+    browser.test.assertEq(data["test-prop1"], "value1", "prop1 correct");
+    browser.test.assertEq(data["test-prop2"], "value2", "prop2 correct");
+    browser.test.assertFalse("other" in data, "other correct");
+
+  // Remove data in various ways.
+  }).then(() => {
+    return remove("test-prop1");
+  }).then(() => {
+    checkChanges({"test-prop1": {oldValue: "value1"}});
+    return get(["test-prop1", "test-prop2"]);
+  }).then(data => {
+    browser.test.assertFalse("test-prop1" in data, "prop1 absent");
+    browser.test.assertTrue("test-prop2" in data, "prop2 present");
+
+    return set({"test-prop1": "value1"});
+  }).then(() => {
+    checkChanges({"test-prop1": {newValue: "value1"}});
+    return get(["test-prop1", "test-prop2"]);
+  }).then(data => {
+    browser.test.assertEq(data["test-prop1"], "value1", "prop1 correct");
+    browser.test.assertEq(data["test-prop2"], "value2", "prop2 correct");
+  }).then(() => {
+    return remove(["test-prop1", "test-prop2"]);
+  }).then(() => {
+    checkChanges({"test-prop1": {oldValue: "value1"}, "test-prop2": {oldValue: "value2"}});
+    return get(["test-prop1", "test-prop2"]);
+  }).then(data => {
+    browser.test.assertFalse("test-prop1" in data, "prop1 absent");
+    browser.test.assertFalse("test-prop2" in data, "prop2 absent");
+
+  // test storage.clear
+  }).then(() => {
+    return set({"test-prop1": "value1", "test-prop2": "value2"});
+  }).then(() => {
+    return clear();
+  }).then(() => {
+    checkChanges({"test-prop1": {oldValue: "value1"}, "test-prop2": {oldValue: "value2"}});
+    return get(["test-prop1", "test-prop2"]);
+  }).then(data => {
+    browser.test.assertFalse("test-prop1" in data, "prop1 absent");
+    browser.test.assertFalse("test-prop2" in data, "prop2 absent");
+
+  // Test cache invalidation.
+  }).then(() => {
+    return set({"test-prop1": "value1", "test-prop2": "value2"});
+  }).then(() => {
+    globalChanges = {};
+    browser.test.sendMessage("invalidate");
+    return new Promise(resolve => browser.test.onMessage.addListener(resolve));
+  }).then(() => {
+    return check("test-prop1", "value1");
+  }).then(() => {
+    return check("test-prop2", "value2");
+
+  // Make sure we can store complex JSON data.
+  }).then(() => {
+    return set({"test-prop1": {str: "hello", bool: true, undef: undefined, obj: {}, arr: [1, 2]}});
+  }).then(() => {
+    browser.test.assertEq(globalChanges["test-prop1"].oldValue, "value1", "oldValue correct");
+    browser.test.assertEq(typeof(globalChanges["test-prop1"].newValue), "object", "newValue is obj");
+    globalChanges = {};
+    return get({"test-prop1": undefined});
+  }).then(data => {
+    let obj = data["test-prop1"];
+
+    browser.test.assertEq(obj.str, "hello", "string part correct");
+    browser.test.assertEq(obj.bool, true, "bool part correct");
+    browser.test.assertEq(obj.undef, undefined, "undefined part correct");
+    browser.test.assertEq(typeof(obj.obj), "object", "object part correct");
+    browser.test.assertTrue(Array.isArray(obj.arr), "array part present");
+    browser.test.assertEq(obj.arr[0], 1, "arr[0] part correct");
+    browser.test.assertEq(obj.arr[1], 2, "arr[1] part correct");
+    browser.test.assertEq(obj.arr.length, 2, "arr.length part correct");
+  }).then(() => {
+    browser.test.notifyPass("storage");
+  });
+}
+
+let extensionData = {
+  manifest: {
+    content_scripts: [{
+      "matches": ["http://mochi.test/*/file_sample.html"],
+      "js": ["content_script.js"],
+      "run_at": "document_idle",
+    }],
+
+    permissions: ["storage"],
+  },
+
+  files: {
+    "content_script.js": "(" + contentScript.toString() + ")()",
+  },
+};
+
+add_task(function* test_contentscript() {
+  let win = window.open("file_sample.html");
+  yield waitForLoad(win);
+
+  let extension = ExtensionTestUtils.loadExtension(extensionData);
+  yield Promise.all([extension.startup(), extension.awaitMessage("invalidate")]);
+  SpecialPowers.invalidateExtensionStorageCache();
+  extension.sendMessage("invalidated");
+  yield extension.awaitFinish("storage");
+  yield extension.unload();
+  info("extension unloaded");
+
+  win.close();
+});
+
+</script>
+
+</body>
+</html>
--- a/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
@@ -370,18 +370,17 @@ let wrapper = {
     tally("call", ns, name, args);
   },
 
   callFunctionNoReturn(path, name, args) {
     let ns = path.join(".");
     tally("call", ns, name, args);
   },
 
-  shouldInject(path) {
-    let ns = path.join(".");
+  shouldInject(ns) {
     return ns != "do-not-inject";
   },
 
   getProperty(path, name) {
     let ns = path.join(".");
     tally("get", ns, name);
   },
 
@@ -401,18 +400,17 @@ let wrapper = {
   hasListener(path, name, listener) {
     let ns = path.join(".");
     tally("hasListener", ns, name, [listener]);
   },
 };
 
 add_task(function* () {
   let url = "data:," + JSON.stringify(json);
-  let uri = BrowserUtils.makeURI(url);
-  yield Schemas.load(uri);
+  yield Schemas.load(url);
 
   let root = {};
   Schemas.inject(root, wrapper);
 
   do_check_eq(root.testing.PROP1, 20, "simple value property");
   do_check_eq(root.testing.type1.VALUE1, "value1", "enum type");
   do_check_eq(root.testing.type1.VALUE2, "value2", "enum type");
 
@@ -837,18 +835,17 @@ let deprecatedJson = [
        deprecated: "This event does not work",
      },
    ],
   },
 ];
 
 add_task(function* testDeprecation() {
   let url = "data:," + JSON.stringify(deprecatedJson);
-  let uri = BrowserUtils.makeURI(url);
-  yield Schemas.load(uri);
+  yield Schemas.load(url);
 
   let root = {};
   Schemas.inject(root, wrapper);
 
   talliedErrors.length = 0;
 
 
   root.deprecated.property({foo: "bar", xxx: "any", yyy: "property"});