Bug 1263011: Part 2 - Implement WebExtensions Experiments prototype. r?aswan draft
authorKris Maglione <maglione.k@gmail.com>
Thu, 16 Jun 2016 16:13:55 +0100
changeset 380944 29c3ca317a16096ba63042abb5cf4d4da8355880
parent 379838 c312504c605dd19210fefbf8e619dae722291572
child 523851 7cf89b5c6fc7a43a304b3d79ccfd77f1b17a986e
push id21360
push usermaglione.k@gmail.com
push dateThu, 23 Jun 2016 19:21:06 +0000
reviewersaswan
bugs1263011
milestone50.0a1
Bug 1263011: Part 2 - Implement WebExtensions Experiments prototype. r?aswan MozReview-Commit-ID: 4KO4cCLRsLf
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionAPI.jsm
toolkit/components/extensions/ExtensionManagement.jsm
toolkit/components/extensions/Schemas.jsm
toolkit/components/extensions/moz.build
toolkit/components/extensions/schemas/experiments.json
toolkit/components/extensions/schemas/jar.mn
toolkit/components/extensions/test/xpcshell/head.js
toolkit/components/extensions/test/xpcshell/test_ext_experiments.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
toolkit/mozapps/extensions/internal/APIExtensionBootstrap.js
toolkit/mozapps/extensions/internal/XPIProvider.jsm
toolkit/mozapps/extensions/internal/moz.build
toolkit/mozapps/extensions/test/xpcshell/test_webextension.js
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -21,46 +21,48 @@ const Cc = Components.classes;
 const Cu = Components.utils;
 const Cr = Components.results;
 
 Cu.importGlobalProperties(["TextEncoder"]);
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
+                                  "resource://gre/modules/AppConstants.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
+                                  "resource://gre/modules/AddonManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter",
                                   "resource://devtools/shared/event-emitter.js");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionAPIs",
+                                  "resource://gre/modules/ExtensionAPI.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");
 XPCOMUtils.defineLazyModuleGetter(this, "MatchGlobs",
                                   "resource://gre/modules/MatchPattern.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MatchPattern",
                                   "resource://gre/modules/MatchPattern.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
+                                  "resource://gre/modules/MessageChannel.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
-                                  "resource://gre/modules/FileUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                   "resource://gre/modules/osfile.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
                                   "resource://gre/modules/PrivateBrowsingUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Preferences",
                                   "resource://gre/modules/Preferences.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                   "resource://gre/modules/Schemas.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Task",
                                   "resource://gre/modules/Task.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
-                                  "resource://gre/modules/AppConstants.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
-                                  "resource://gre/modules/MessageChannel.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
-                                  "resource://gre/modules/AddonManager.jsm");
 
 Cu.import("resource://gre/modules/ExtensionManagement.jsm");
 
 // Register built-in parts of the API. Other parts may be registered
 // in browser/, mobile/, or b2g/.
 ExtensionManagement.registerScript("chrome://extensions/content/ext-alarms.js");
 ExtensionManagement.registerScript("chrome://extensions/content/ext-backgroundPage.js");
 ExtensionManagement.registerScript("chrome://extensions/content/ext-cookies.js");
@@ -87,16 +89,20 @@ ExtensionManagement.registerSchema("chro
 ExtensionManagement.registerSchema("chrome://extensions/content/schemas/notifications.json");
 ExtensionManagement.registerSchema("chrome://extensions/content/schemas/runtime.json");
 ExtensionManagement.registerSchema("chrome://extensions/content/schemas/storage.json");
 ExtensionManagement.registerSchema("chrome://extensions/content/schemas/test.json");
 ExtensionManagement.registerSchema("chrome://extensions/content/schemas/events.json");
 ExtensionManagement.registerSchema("chrome://extensions/content/schemas/web_navigation.json");
 ExtensionManagement.registerSchema("chrome://extensions/content/schemas/web_request.json");
 
+if (!AppConstants.RELEASE_BUILD) {
+  ExtensionManagement.registerSchema("chrome://extensions/content/schemas/experiments.json");
+}
+
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   BaseContext,
   LocaleData,
   Messenger,
   injectAPI,
   instanceOf,
   flushJarCache,
@@ -211,16 +217,20 @@ var Management = {
           continue;
         }
       }
 
       api = api.api(extension, context);
       copy(obj, api);
     }
 
+    for (let api of extension.apis) {
+      copy(obj, api.getAPI(context));
+    }
+
     return obj;
   },
 
   // The ext-*.js scripts can ask to be notified for certain hooks.
   on(hook, callback) {
     this.emitter.on(hook, callback);
   },
 
@@ -715,16 +725,20 @@ this.ExtensionData = class {
     this.rootURI = rootURI;
 
     this.manifest = null;
     this.id = null;
     this.uuid = null;
     this.localeData = null;
     this._promiseLocales = null;
 
+    this.apiNames = new Set();
+    this.dependencies = new Set();
+    this.permissions = new Set();
+
     this.errors = [];
   }
 
   get builtinMessages() {
     return null;
   }
 
   get logger() {
@@ -894,16 +908,35 @@ this.ExtensionData = class {
       }
 
       try {
         this.id = this.manifest.applications.gecko.id;
       } catch (e) {
         // Errors are handled by the type checks above.
       }
 
+      let permissions = this.manifest.permissions || [];
+
+      let whitelist = [];
+      for (let perm of permissions) {
+        this.permissions.add(perm);
+
+        let match = /^(\w+)(?:\.(\w+)(?:\.\w+)*)?$/.exec(perm);
+        if (!match) {
+          whitelist.push(perm);
+        } else if (match[1] == "experiments" && match[2]) {
+          this.apiNames.add(match[2]);
+        }
+      }
+      this.whiteListedHosts = new MatchPattern(whitelist);
+
+      for (let api of this.apiNames) {
+        this.dependencies.add(`${api}@experiments.addons.mozilla.org`);
+      }
+
       return this.manifest;
     });
   }
 
   localizeMessage(...args) {
     return this.localeData.localizeMessage(...args);
   }
 
@@ -1121,17 +1154,17 @@ this.Extension = class extends Extension
 
     this.onStartup = null;
 
     this.hasShutdown = false;
     this.onShutdown = new Set();
 
     this.uninstallURL = null;
 
-    this.permissions = new Set();
+    this.apis = [];
     this.whiteListedHosts = null;
     this.webAccessibleResources = null;
 
     this.emitter = new EventEmitter();
   }
 
   /**
    * This code is designed to make it easy to test a WebExtension
@@ -1197,20 +1230,24 @@ this.Extension = class extends Extension
       let bgScript = uuidGenerator.generateUUID().number + ".js";
 
       provide(manifest, ["background", "scripts"], [bgScript], true);
       files[bgScript] = data.background;
     }
 
     provide(files, ["manifest.json"], manifest);
 
+    return this.generateZipFile(files);
+  }
+
+  static generateZipFile(files, baseName = "generated-extension.xpi") {
     let ZipWriter = Components.Constructor("@mozilla.org/zipwriter;1", "nsIZipWriter");
     let zipW = new ZipWriter();
 
-    let file = FileUtils.getFile("TmpD", ["generated-extension.xpi"]);
+    let file = FileUtils.getFile("TmpD", [baseName]);
     file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
 
     const MODE_WRONLY = 0x02;
     const MODE_TRUNCATE = 0x20;
     zipW.open(file, MODE_WRONLY | MODE_TRUNCATE);
 
     // Needs to be in microseconds for some reason.
     let time = Date.now() * 1000;
@@ -1225,17 +1262,17 @@ this.Extension = class extends Extension
         }
       }
     }
 
     for (let filename in files) {
       let script = files[filename];
       if (typeof(script) == "function") {
         script = "(" + script.toString() + ")()";
-      } else if (instanceOf(script, "Object")) {
+      } else if (instanceOf(script, "Object") || instanceOf(script, "Array")) {
         script = JSON.stringify(script);
       }
 
       if (!instanceOf(script, "ArrayBuffer")) {
         script = new TextEncoder("utf-8").encode(script).buffer;
       }
 
       let stream = Cc["@mozilla.org/io/arraybuffer-input-stream;1"].createInstance(Ci.nsIArrayBufferInputStream);
@@ -1302,16 +1339,35 @@ this.Extension = class extends Extension
   // Checks that the given URL is a child of our baseURI.
   isExtensionURL(url) {
     let uri = Services.io.newURI(url, null, null);
 
     let common = this.baseURI.getCommonBaseSpec(uri);
     return common == this.baseURI.spec;
   }
 
+  readManifest() {
+    return super.readManifest().then(manifest => {
+      if (AppConstants.RELEASE_BUILD) {
+        return manifest;
+      }
+
+      // Load Experiments APIs that this extension depends on.
+      return Promise.all(
+        Array.from(this.apiNames, api => ExtensionAPIs.load(api))
+      ).then(apis => {
+        for (let API of apis) {
+          this.apis.push(new API(this));
+        }
+
+        return manifest;
+      });
+    });
+  }
+
   // Representation of the extension to send to content
   // processes. This should include anything the content process might
   // need.
   serialize() {
     return {
       id: this.id,
       uuid: this.uuid,
       manifest: this.manifest,
@@ -1339,27 +1395,16 @@ this.Extension = class extends Extension
           resolve();
         }
       });
       Services.ppmm.broadcastAsyncMessage(msg, data);
     });
   }
 
   runManifest(manifest) {
-    let permissions = manifest.permissions || [];
-
-    let whitelist = [];
-    for (let perm of permissions) {
-      this.permissions.add(perm);
-      if (!/^\w+(\.\w+)*$/.test(perm)) {
-        whitelist.push(perm);
-      }
-    }
-    this.whiteListedHosts = new MatchPattern(whitelist);
-
     // Strip leading slashes from web_accessible_resources.
     let strippedWebAccessibleResources = [];
     if (manifest.web_accessible_resources) {
       strippedWebAccessibleResources = manifest.web_accessible_resources.map(path => path.replace(/^\/+/, ""));
     }
 
     this.webAccessibleResources = new MatchGlobs(strippedWebAccessibleResources);
 
@@ -1490,16 +1535,20 @@ this.Extension = class extends Extension
     for (let view of this.views) {
       view.shutdown();
     }
 
     for (let obj of this.onShutdown) {
       obj.close();
     }
 
+    for (let api of this.apis) {
+      api.destroy();
+    }
+
     Management.emit("shutdown", this);
 
     Services.ppmm.broadcastAsyncMessage("Extension:Shutdown", {id: this.id});
 
     MessageChannel.abortResponses({extensionId: this.id});
 
     ExtensionManagement.shutdownExtension(this.uuid);
 
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionAPI.jsm
@@ -0,0 +1,81 @@
+/* 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 = ["ExtensionAPI", "ExtensionAPIs"];
+
+/* exported ExtensionAPIs */
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/ExtensionManagement.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter",
+                                  "resource://devtools/shared/event-emitter.js");
+XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
+                                  "resource://gre/modules/Schemas.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+                                  "resource://gre/modules/Task.jsm");
+
+const global = this;
+
+class ExtensionAPI {
+  constructor(extension) {
+    this.extension = extension;
+  }
+
+  destroy() {
+  }
+
+  getAPI(context) {
+    throw new Error("Not Implemented");
+  }
+}
+
+var ExtensionAPIs = {
+  apis: ExtensionManagement.APIs.apis,
+
+  load(apiName) {
+    let api = this.apis.get(apiName);
+
+    if (api.loadPromise) {
+      return api.loadPromise;
+    }
+
+    let {script, schema} = api;
+
+    let addonId = `${api}@experiments.addons.mozilla.org`;
+    api.sandbox = Cu.Sandbox(global, {
+      wantXrays: false,
+      sandboxName: script,
+      addonId,
+      metadata: {addonID: addonId},
+    });
+
+    api.sandbox.ExtensionAPI = ExtensionAPI;
+
+    Services.scriptloader.loadSubScript(script, api.sandbox, "UTF-8");
+
+    api.loadPromise = Schemas.load(schema).then(() => {
+      return Cu.evalInSandbox("API", api.sandbox);
+    });
+
+    return api.loadPromise;
+  },
+
+  unload(apiName) {
+    let api = this.apis.get(apiName);
+
+    let {schema} = api;
+
+    Schemas.unload(schema);
+    Cu.nukeSandbox(api.sandbox);
+
+    api.sandbox = null;
+    api.loadPromise = null;
+  },
+};
--- a/toolkit/components/extensions/ExtensionManagement.jsm
+++ b/toolkit/components/extensions/ExtensionManagement.jsm
@@ -106,16 +106,36 @@ var Schemas = {
     this.schemas.add(schema);
   },
 
   getSchemas() {
     return this.schemas;
   },
 };
 
+var APIs = {
+  apis: new Map(),
+
+  register(namespace, schema, script) {
+    if (this.apis.has(namespace)) {
+      throw new Error(`API namespace already exists: ${namespace}`);
+    }
+
+    this.apis.set(namespace, {schema, script});
+  },
+
+  unregister(namespace) {
+    if (!this.apis.has(namespace)) {
+      throw new Error(`API namespace does not exist: ${namespace}`);
+    }
+
+    this.apis.delete(namespace);
+  },
+};
+
 // This object manages various platform-level issues related to
 // moz-extension:// URIs. It lives here so that it can be used in both
 // the parent and child processes.
 //
 // moz-extension URIs have the form moz-extension://uuid/path. Each
 // extension has its own UUID, unique to the machine it's installed
 // on. This is easier and more secure than using the extension ID,
 // since it makes it slightly harder to fingerprint for extensions if
@@ -284,16 +304,21 @@ this.ExtensionManagement = {
   shutdownExtension: Service.shutdownExtension.bind(Service),
 
   registerScript: Scripts.register.bind(Scripts),
   getScripts: Scripts.getScripts.bind(Scripts),
 
   registerSchema: Schemas.register.bind(Schemas),
   getSchemas: Schemas.getSchemas.bind(Schemas),
 
+  registerAPI: APIs.register.bind(APIs),
+  unregisterAPI: APIs.unregister.bind(APIs),
+
   getFrameId: Frames.getId.bind(Frames),
   getParentFrameId: Frames.getParentId.bind(Frames),
 
   // exported API Level Helpers
   getAddonIdForWindow,
   getAPILevelForWindow,
   API_LEVELS,
+
+  APIs,
 };
--- a/toolkit/components/extensions/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -1239,19 +1239,16 @@ class Event extends CallEntry {
     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(),
 
@@ -1539,78 +1536,108 @@ this.Schemas = {
     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);
     }
+
+    this.flushSchemas();
   },
 
   receiveMessage(msg) {
     switch (msg.name) {
       case "Schema:Add":
         this.schemaJSON.set(msg.data.url, msg.data.schema);
+        this.flushSchemas();
         break;
+
+      case "Schema:Delete":
+        this.schemaJSON.delete(msg.data.url);
+        this.flushSchemas();
+        break;
+    }
+  },
+
+  flushSchemas() {
+    XPCOMUtils.defineLazyGetter(this, "namespaces",
+                                () => this.parseSchemas());
+  },
+
+  parseSchemas() {
+    Object.defineProperty(this, "namespaces", {
+      enumerable: true,
+      configurable: true,
+      value: new Map(),
+    });
+
+    for (let json of this.schemaJSON.values()) {
+      this.parseSchema(json);
+    }
+
+    return this.namespaces;
+  },
+
+  parseSchema(json) {
+    for (let namespace of json) {
+      let name = namespace.namespace;
+
+      let types = namespace.types || [];
+      for (let type of types) {
+        this.loadType(name, type);
+      }
+
+      let properties = namespace.properties || {};
+      for (let propertyName of Object.keys(properties)) {
+        this.loadProperty(name, propertyName, properties[propertyName]);
+      }
+
+      let functions = namespace.functions || [];
+      for (let fun of functions) {
+        this.loadFunction(name, fun);
+      }
+
+      let events = namespace.events || [];
+      for (let event of events) {
+        this.loadEvent(name, event);
+      }
+
+      if (namespace.permissions) {
+        let ns = this.namespaces.get(name);
+        ns.permissions = namespace.permissions;
+      }
     }
   },
 
   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);
-        }
-
-        let properties = namespace.properties || {};
-        for (let propertyName of Object.keys(properties)) {
-          this.loadProperty(name, propertyName, properties[propertyName]);
-        }
-
-        let functions = namespace.functions || [];
-        for (let fun of functions) {
-          this.loadFunction(name, fun);
-        }
-
-        let events = namespace.events || [];
-        for (let event of events) {
-          this.loadEvent(name, event);
-        }
-
-        if (namespace.permissions) {
-          let ns = this.namespaces.get(name);
-          ns.permissions = namespace.permissions;
-        }
-      }
-    };
-
     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);
+        this.flushSchemas();
       });
-    } else {
-      if (this.loadedUrls.has(url)) {
-        return;
-      }
-      this.loadedUrls.add(url);
+    }
+  },
+
+  unload(url) {
+    this.schemaJSON.delete(url);
 
-      let schema = this.schemaJSON.get(url);
-      loadFromJSON(schema);
-    }
+    let data = Services.ppmm.initialProcessData;
+    data["Extension:Schemas"] = this.schemaJSON;
+
+    Services.ppmm.broadcastAsyncMessage("Schema:Delete", {url});
+
+    this.flushSchemas();
   },
 
   inject(dest, wrapperFuncs) {
     let context = new Context(wrapperFuncs);
 
     for (let [namespace, ns] of this.namespaces) {
       if (ns.permissions && !ns.permissions.some(perm => context.hasPermission(perm))) {
         continue;
--- a/toolkit/components/extensions/moz.build
+++ b/toolkit/components/extensions/moz.build
@@ -1,16 +1,17 @@
 # -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # 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/.
 
 EXTRA_JS_MODULES += [
     'Extension.jsm',
+    'ExtensionAPI.jsm',
     'ExtensionContent.jsm',
     'ExtensionManagement.jsm',
     'ExtensionStorage.jsm',
     'ExtensionUtils.jsm',
     'MessageChannel.jsm',
     'NativeMessaging.jsm',
     'Schemas.jsm',
 ]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/schemas/experiments.json
@@ -0,0 +1,16 @@
+[
+  {
+    "namespace": "manifest",
+    "types": [
+      {
+        "$extend": "Permission",
+        "choices": [
+          {
+            "type": "string",
+            "pattern": "^experiments(\\.\\w+)+$"
+          }
+        ]
+      }
+    ]
+  }
+]
--- a/toolkit/components/extensions/schemas/jar.mn
+++ b/toolkit/components/extensions/schemas/jar.mn
@@ -3,16 +3,17 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 toolkit.jar:
 % content extensions %content/extensions/
     content/extensions/schemas/alarms.json
     content/extensions/schemas/cookies.json
     content/extensions/schemas/downloads.json
     content/extensions/schemas/events.json
+    content/extensions/schemas/experiments.json
     content/extensions/schemas/extension.json
     content/extensions/schemas/extension_types.json
     content/extensions/schemas/i18n.json
     content/extensions/schemas/idle.json
     content/extensions/schemas/manifest.json
     content/extensions/schemas/native_host_manifest.json
     content/extensions/schemas/notifications.json
     content/extensions/schemas/runtime.json
--- a/toolkit/components/extensions/test/xpcshell/head.js
+++ b/toolkit/components/extensions/test/xpcshell/head.js
@@ -3,24 +3,26 @@
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Extension",
                                   "resource://gre/modules/Extension.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionData",
                                   "resource://gre/modules/Extension.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
+                                  "resource://gre/modules/FileUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                   "resource://gre/modules/Schemas.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
 
-/* exported normalizeManifest */
+/* exported normalizeManifest, startAddonManager */
 
 let BASE_MANIFEST = {
   "applications": {"gecko": {"id": "test@web.ext"}},
 
   "manifest_version": 2,
 
   "name": "name",
   "version": "0",
@@ -44,8 +46,50 @@ function* normalizeManifest(manifest, ba
 
   manifest = Object.assign({}, baseManifest, manifest);
 
   let normalized = Schemas.normalize(manifest, "manifest.WebExtensionManifest", context);
   normalized.errors = errors;
 
   return normalized;
 }
+
+function* startAddonManager() {
+  let tmpD = do_get_profile().clone();
+  tmpD.append("tmp");
+  tmpD.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+
+  let dirProvider = {
+    getFile: function(prop, persistent) {
+      persistent.value = false;
+      if (prop == "TmpD") {
+        return tmpD.clone();
+      }
+      return null;
+    },
+
+    QueryInterface: XPCOMUtils.generateQI([Ci.nsIDirectoryServiceProvider]),
+  };
+  Services.dirsvc.registerProvider(dirProvider);
+
+
+  do_register_cleanup(() => {
+    tmpD.remove(true);
+    Services.dirsvc.unregisterProvider(dirProvider);
+  });
+
+
+  let appInfo = {};
+  Cu.import("resource://testing-common/AppInfo.jsm", appInfo);
+
+  appInfo.updateAppInfo({
+    ID: "xpcshell@tests.mozilla.org",
+    name: "XPCShell",
+    version: "48",
+    platformVersion: "48",
+  });
+
+
+  let manager = Cc["@mozilla.org/addons/integration;1"].getService(Ci.nsIObserver)
+                                                       .QueryInterface(Ci.nsITimerCallback);
+  manager.observe(null, "addons-startup", null);
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js
@@ -0,0 +1,163 @@
+"use strict";
+
+/* globals browser */
+
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
+                                  "resource://gre/modules/AddonManager.jsm");
+
+function promiseAddonStartup() {
+  const {Management} = Cu.import("resource://gre/modules/Extension.jsm");
+
+  return new Promise(resolve => {
+    let listener = (extension) => {
+      Management.off("startup", listener);
+      resolve(extension);
+    };
+
+    Management.on("startup", listener);
+  });
+}
+
+add_task(function* setup() {
+  yield startAddonManager();
+});
+
+add_task(function* test_experiments_api() {
+  let apiAddonFile = Extension.generateZipFile({
+    "install.rdf": `<?xml version="1.0" encoding="UTF-8"?>
+      <RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+           xmlns:em="http://www.mozilla.org/2004/em-rdf#">
+          <Description about="urn:mozilla:install-manifest"
+              em:id="meh@experiments.addons.mozilla.org"
+              em:name="Meh Experiment"
+              em:type="256"
+              em:version="0.1"
+              em:description="Meh experiment"
+              em:creator="Mozilla">
+
+              <em:targetApplication>
+                  <Description
+                      em:id="xpcshell@tests.mozilla.org"
+                      em:minVersion="48"
+                      em:maxVersion="*"/>
+              </em:targetApplication>
+          </Description>
+      </RDF>
+    `,
+
+    "api.js": String.raw`
+      Components.utils.import("resource://gre/modules/Services.jsm");
+
+      Services.obs.notifyObservers(null, "webext-api-loaded", "");
+
+      class API extends ExtensionAPI {
+        getAPI(context) {
+          return {
+            meh: {
+              hello(text) {
+                Services.obs.notifyObservers(null, "webext-api-hello", text);
+              }
+            }
+          }
+        }
+      }
+    `,
+
+    "schema.json": [
+      {
+        "namespace": "meh",
+        "description": "All full of meh.",
+        "permissions": ["experiments.meh"],
+        "functions": [
+          {
+            "name": "hello",
+            "type": "function",
+            "description": "Hates you. This is all.",
+            "parameters": [
+              {"type": "string", "name": "text"},
+            ],
+          },
+        ],
+      },
+    ],
+  });
+
+  let addonFile = Extension.generateXPI("meh@web.extension", {
+    manifest: {
+      permissions: ["experiments.meh"],
+    },
+
+    background() {
+      browser.meh.hello("Here I am");
+    },
+  });
+
+  let boringAddonFile = Extension.generateXPI("boring@web.extension", {
+    background() {
+      if (browser.meh) {
+        browser.meh.hello("Here I should not be");
+      }
+    },
+  });
+
+  do_register_cleanup(() => {
+    for (let file of [apiAddonFile, addonFile, boringAddonFile]) {
+      Services.obs.notifyObservers(file, "flush-cache-entry", null);
+      file.remove(false);
+    }
+  });
+
+
+  let resolveHello;
+  let observer = (subject, topic, data) => {
+    if (topic == "webext-api-loaded") {
+      ok(!!resolveHello, "Should not see API loaded until dependent extension loads");
+    } else if (topic == "webext-api-hello") {
+      resolveHello(data);
+    }
+  };
+
+  Services.obs.addObserver(observer, "webext-api-loaded", false);
+  Services.obs.addObserver(observer, "webext-api-hello", false);
+  do_register_cleanup(() => {
+    Services.obs.removeObserver(observer, "webext-api-loaded");
+    Services.obs.removeObserver(observer, "webext-api-hello");
+  });
+
+
+  // Install API add-on.
+  let apiAddon = yield AddonManager.installTemporaryAddon(apiAddonFile);
+
+  let {APIs} = Cu.import("resource://gre/modules/ExtensionManagement.jsm", {});
+  ok(APIs.apis.has("meh"), "Should have meh API.");
+
+
+  // Install boring WebExtension add-on.
+  let boringAddon = yield AddonManager.installTemporaryAddon(boringAddonFile);
+  yield promiseAddonStartup();
+
+
+  // Install interesting WebExtension add-on.
+  let promise = new Promise(resolve => {
+    resolveHello = resolve;
+  });
+
+  let addon = yield AddonManager.installTemporaryAddon(addonFile);
+  yield promiseAddonStartup();
+
+  let hello = yield promise;
+  equal(hello, "Here I am", "Should get hello from add-on");
+
+
+  // Cleanup.
+  apiAddon.uninstall();
+
+  boringAddon.userDisabled = true;
+  yield new Promise(do_execute_soon);
+
+  equal(addon.appDisabled, true, "Add-on should be app-disabled after its dependency is removed.");
+
+  addon.uninstall();
+  boringAddon.uninstall();
+});
+
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -4,15 +4,17 @@ tail =
 firefox-appdir = browser
 skip-if = toolkit == 'gonk' || appname == "thunderbird"
 
 [test_csp_custom_policies.js]
 [test_csp_validator.js]
 [test_locale_data.js]
 [test_locale_converter.js]
 [test_ext_contexts.js]
+[test_ext_experiments.js]
+skip-if = release_build
 [test_ext_json_parser.js]
 [test_ext_manifest_content_security_policy.js]
 [test_ext_manifest_incognito.js]
 [test_ext_schemas.js]
 [test_getAPILevelForWindow.js]
 [test_native_messaging.js]
 skip-if = os == "android"
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/APIExtensionBootstrap.js
@@ -0,0 +1,39 @@
+/* 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";
+
+Components.utils.import("resource://gre/modules/ExtensionManagement.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
+
+var namespace;
+var resource;
+var resProto;
+
+function install(data, reason) {
+}
+
+function startup(data, reason) {
+  namespace = data.id.replace(/@.*/, "");
+  resource = `extension-${namespace}-api`;
+
+  resProto = Services.io.getProtocolHandler("resource")
+                     .QueryInterface(Components.interfaces.nsIResProtocolHandler);
+
+  resProto.setSubstitution(resource, data.resourceURI);
+
+  ExtensionManagement.registerAPI(
+    namespace,
+    `resource://${resource}/schema.json`,
+    `resource://${resource}/api.js`);
+}
+
+function shutdown(data, reason) {
+  resProto.setSubstitution(resource, null);
+
+  ExtensionManagement.unregisterAPI(namespace);
+}
+
+function uninstall(data, reason) {
+}
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -214,39 +214,45 @@ const TYPES = {
   extension: 2,
   theme: 4,
   locale: 8,
   multipackage: 32,
   dictionary: 64,
   experiment: 128,
 };
 
+if (!AppConstants.RELEASE_BUILD)
+  TYPES.apiextension = 256;
+
 // Some add-on types that we track internally are presented as other types
 // externally
 const TYPE_ALIASES = {
   "webextension": "extension",
+  "apiextension": "extension",
 };
 
 const CHROME_TYPES = new Set([
   "extension",
   "locale",
   "experiment",
 ]);
 
 const RESTARTLESS_TYPES = new Set([
   "webextension",
   "dictionary",
   "experiment",
   "locale",
+  "apiextension",
 ]);
 
 const SIGNED_TYPES = new Set([
   "webextension",
   "extension",
   "experiment",
+  "apiextension",
 ]);
 
 // This is a random number array that can be used as "salt" when generating
 // an automatic ID based on the directory path of an add-on. It will prevent
 // someone from creating an ID for a permanent add-on that could be replaced
 // by a temporary add-on (because that would be confusing, I guess).
 const TEMP_INSTALL_ID_GEN_SESSION =
   new Uint8Array(Float64Array.of(Math.random()).buffer);
@@ -924,16 +930,17 @@ var loadManifestFromWebManifest = Task.a
   addon.hasBinaryComponents = false;
   addon.multiprocessCompatible = true;
   addon.internalName = null;
   addon.updateURL = bss.update_url;
   addon.updateKey = null;
   addon.optionsURL = null;
   addon.optionsType = null;
   addon.aboutURL = null;
+  addon.dependencies = Object.freeze(Array.from(extension.dependencies));
 
   if (manifest.options_ui) {
     addon.optionsURL = extension.getURL(manifest.options_ui.page);
     if (manifest.options_ui.open_in_tab)
       addon.optionsType = AddonManager.OPTIONS_TYPE_TAB;
     else
       addon.optionsType = AddonManager.OPTIONS_TYPE_INLINE_BROWSER;
   }
@@ -4681,16 +4688,18 @@ this.XPIProvider = {
       return;
     }
 
     let uri = getURIForResourceInFile(aFile, "bootstrap.js").spec;
     if (aType == "dictionary")
       uri = "resource://gre/modules/addons/SpellCheckDictionaryBootstrap.js"
     else if (aType == "webextension")
       uri = "resource://gre/modules/addons/WebExtensionBootstrap.js"
+    else if (aType == "apiextension")
+      uri = "resource://gre/modules/addons/APIExtensionBootstrap.js"
 
     activeAddon.bootstrapScope =
       new Cu.Sandbox(principal, { sandboxName: uri,
                                   wantGlobalProperties: ["indexedDB"],
                                   addonId: aId,
                                   metadata: { addonID: aId, URI: uri } });
 
     let loader = Cc["@mozilla.org/moz/jssubscript-loader;1"].
--- a/toolkit/mozapps/extensions/internal/moz.build
+++ b/toolkit/mozapps/extensions/internal/moz.build
@@ -4,16 +4,17 @@
 # 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/.
 
 EXTRA_JS_MODULES.addons += [
     'AddonLogging.jsm',
     'AddonRepository.jsm',
     'AddonRepository_SQLiteMigrator.jsm',
     'AddonUpdateChecker.jsm',
+    'APIExtensionBootstrap.js',
     'Content.js',
     'GMPProvider.jsm',
     'LightweightThemeImageOptimizer.jsm',
     'ProductAddonChecker.jsm',
     'SpellCheckDictionaryBootstrap.js',
     'WebExtensionBootstrap.js',
     'XPIProvider.jsm',
     'XPIProviderUtils.js',
--- a/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js
@@ -1,12 +1,14 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/
  */
 
+Components.utils.import("resource://gre/modules/AppConstants.jsm");
+
 const ID = "webextension1@tests.mozilla.org";
 
 const PREF_SELECTED_LOCALE = "general.useragent.locale";
 
 const profileDir = gProfD.clone();
 profileDir.append("extensions");
 
 createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
@@ -289,8 +291,60 @@ add_task(function* test_options_ui() {
   equal(addon.optionsType, AddonManager.OPTIONS_TYPE_TAB,
         "Addon should have a TAB options type");
 
   ok(OPTIONS_RE.test(addon.optionsURL),
      "Addon should have a moz-extension: options URL for /options.html");
 
   addon.uninstall();
 });
+
+// Test that experiments permissions add the appropriate dependencies.
+add_task(function* test_experiments_dependencies() {
+  if (AppConstants.RELEASE_BUILD)
+    // Experiments are not enabled on release builds.
+    return;
+
+  let addonFile = createTempWebExtensionFile({
+    id: "meh@experiment",
+    manifest: {
+      "permissions": ["experiments.meh"],
+    },
+  });
+
+  yield promiseInstallAllFiles([addonFile]);
+
+  let addon = yield new Promise(resolve => AddonManager.getAddonByID("meh@experiment", resolve));
+
+  deepEqual(addon.dependencies, ["meh@experiments.addons.mozilla.org"],
+            "Addon should have the expected dependencies");
+
+  equal(addon.appDisabled, true, "Add-on should be app disabled due to missing dependencies");
+
+  addon.uninstall();
+});
+
+// Test that experiments API extensions install correctly.
+add_task(function* test_experiments_api() {
+  if (AppConstants.RELEASE_BUILD)
+    // Experiments are not enabled on release builds.
+    return;
+
+  const ID = "meh@experiments.addons.mozilla.org";
+
+  let addonFile = createTempXPIFile({
+    id: ID,
+    type: 256,
+    version: "0.1",
+    name: "Meh API",
+  });
+
+  yield promiseInstallAllFiles([addonFile]);
+
+  let addons = yield new Promise(resolve => AddonManager.getAddonsByTypes(["apiextension"], resolve));
+  let addon = addons.pop();
+  equal(addon.id, ID, "Add-on should be installed as an API extension");
+
+  addons = yield new Promise(resolve => AddonManager.getAddonsByTypes(["extension"], resolve));
+  equal(addons.pop().id, ID, "Add-on type should be aliased to extension");
+
+  addon.uninstall();
+});