Bug 1323845: Part 5a - Allow extensions to bundle experiment API modules. r?aswan draft
authorKris Maglione <maglione.k@gmail.com>
Tue, 09 Jan 2018 16:28:36 -0800
changeset 718302 3dfb9326d77eabf51697ae1744f884e3a34a0fa5
parent 718301 becb0b9cc67b75c4d269fd1ce462c2af222cac8a
child 718303 cee6f35737290fa7270b37d07987679051710d46
push id94869
push usermaglione.k@gmail.com
push dateWed, 10 Jan 2018 01:49:31 +0000
reviewersaswan
bugs1323845
milestone59.0a1
Bug 1323845: Part 5a - Allow extensions to bundle experiment API modules. r?aswan MozReview-Commit-ID: 5suo2MqM51V
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionChild.jsm
toolkit/components/extensions/ExtensionCommon.jsm
toolkit/components/extensions/Schemas.jsm
toolkit/components/extensions/schemas/experiments.json
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -558,16 +558,31 @@ this.ExtensionData = class {
     }
 
     let apiNames = new Set();
     let dependencies = new Set();
     let originPermissions = new Set();
     let permissions = new Set();
     let webAccessibleResources = [];
 
+    let schemaPromises = new Map();
+
+    let result = {
+      apiNames,
+      dependencies,
+      id,
+      manifest,
+      modules: null,
+      originPermissions,
+      permissions,
+      schemaURLs: null,
+      type: this.type,
+      webAccessibleResources,
+    };
+
     if (this.type === "extension") {
       if (this.manifest.devtools_page) {
         permissions.add("devtools");
       }
 
       for (let perm of manifest.permissions) {
         if (perm === "geckoProfiler" && !this.isPrivileged) {
           const acceptedExtensions = Services.prefs.getStringPref("extensions.geckoProfiler.acceptedExtensionIds", "");
@@ -604,16 +619,54 @@ this.ExtensionData = class {
           originPermissions.add(origin);
         }
       }
 
       for (let api of apiNames) {
         dependencies.add(`${api}@experiments.addons.mozilla.org`);
       }
 
+      let moduleData = data => ({
+        url: this.rootURI.resolve(data.script),
+        events: data.events,
+        paths: data.paths,
+        scopes: data.scopes,
+      });
+
+      let computeModuleInit = (scope, modules) => {
+        let manager = new ExtensionCommon.SchemaAPIManager(scope);
+        return manager.initModuleJSON([modules]);
+      };
+
+      if (manifest.experiment_apis) {
+        let parentModules = {};
+        let childModules = {};
+
+        for (let [name, data] of Object.entries(manifest.experiment_apis)) {
+          let schema = this.getURL(data.schema);
+
+          if (!schemaPromises.has(schema)) {
+            schemaPromises.set(schema, this.readJSON(data.schema).then(json => Schemas.processSchema(json)));
+          }
+
+          if (data.parent) {
+            parentModules[name] = moduleData(data.parent);
+          }
+
+          if (data.child) {
+            childModules[name] = moduleData(data.child);
+          }
+        }
+
+        result.modules = {
+          child: computeModuleInit("addon_child", childModules),
+          parent: computeModuleInit("addon_parent", parentModules),
+        };
+      }
+
       // Normalize all patterns to contain a single leading /
       if (manifest.web_accessible_resources) {
         webAccessibleResources = manifest.web_accessible_resources
           .map(path => path.replace(/^\/*/, "/"));
       }
     } else if (this.type == "langpack") {
       // Compute the chrome resources to be registered for this langpack
       // and stash them in startupData
@@ -629,18 +682,25 @@ this.ExtensionData = class {
             chromeEntries.push(["locale", alias, language, path[platform]]);
           }
         }
       }
 
       this.startupData = {chromeEntries};
     }
 
-    return {apiNames, dependencies, originPermissions, id, manifest, permissions,
-            webAccessibleResources, type: this.type};
+    if (schemaPromises.size) {
+      let schemas = new Map();
+      for (let [url, promise] of schemaPromises) {
+        schemas.set(url, await promise);
+      }
+      result.schemaURLs = schemas;
+    }
+
+    return result;
   }
 
   // Reads the extension's |manifest.json| file, and stores its
   // parsed contents in |this.manifest|.
   async loadManifest() {
     let [manifestData] = await Promise.all([
       this.parseManifest(),
       Management.lazyInit(),
@@ -654,25 +714,47 @@ this.ExtensionData = class {
     if (!this.id) {
       this.id = manifestData.id;
     }
 
     this.manifest = manifestData.manifest;
     this.apiNames = manifestData.apiNames;
     this.dependencies = manifestData.dependencies;
     this.permissions = manifestData.permissions;
+    this.schemaURLs = manifestData.schemaURLs;
     this.type = manifestData.type;
 
+    this.modules = manifestData.modules;
+
+    this.apiManager = this.getAPIManager();
     await this.apiManager.lazyInit();
+
     this.webAccessibleResources = manifestData.webAccessibleResources.map(res => new MatchGlob(res));
     this.whiteListedHosts = new MatchPatternSet(manifestData.originPermissions);
 
     return this.manifest;
   }
 
+  getAPIManager() {
+    let apiManagers = [Management];
+
+    if (this.modules) {
+      this.experimentAPIManager =
+        new ExtensionCommon.LazyAPIManager("main", this.modules.parent, this.schemaURLs);
+
+      apiManagers.push(this.experimentAPIManager);
+    }
+
+    if (apiManagers.length == 1) {
+      return apiManagers[0];
+    }
+
+    return new ExtensionCommon.MultiAPIManager("main", apiManagers.reverse());
+  }
+
   localizeMessage(...args) {
     return this.localeData.localizeMessage(...args);
   }
 
   localize(...args) {
     return this.localeData.localize(...args);
   }
 
@@ -1021,18 +1103,16 @@ class LangpackBootstrapScope {
 }
 
 // We create one instance of this class per extension. |addonData|
 // comes directly from bootstrap.js when initializing.
 this.Extension = class extends ExtensionData {
   constructor(addonData, startupReason) {
     super(addonData.resourceURI);
 
-    this.apiManager = Management;
-
     this.uuid = UUIDMap.get(addonData.id);
     this.instanceId = getUniqueId();
 
     this.MESSAGE_EMIT_EVENT = `Extension:EmitEvent:${this.instanceId}`;
     Services.ppmm.addMessageListener(this.MESSAGE_EMIT_EVENT, this);
 
     if (addonData.cleanupFile) {
       Services.obs.addObserver(this, "xpcom-shutdown");
@@ -1288,19 +1368,21 @@ this.Extension = class extends Extension
       manifest: this.manifest,
       resourceURL: this.resourceURL,
       baseURL: this.baseURI.spec,
       contentScripts: this.contentScripts,
       registeredContentScripts: new Map(),
       webAccessibleResources: this.webAccessibleResources.map(res => res.glob),
       whiteListedHosts: this.whiteListedHosts.patterns.map(pat => pat.pattern),
       localeData: this.localeData.serialize(),
+      childModules: this.modules && this.modules.child,
       permissions: this.permissions,
       principal: this.principal,
       optionalPermissions: this.manifest.optional_permissions,
+      schemaURLs: this.schemaURLs,
     };
   }
 
   get contentScripts() {
     return this.manifest.content_scripts || [];
   }
 
   broadcast(msg, data) {
--- a/toolkit/components/extensions/ExtensionChild.jsm
+++ b/toolkit/components/extensions/ExtensionChild.jsm
@@ -555,30 +555,33 @@ class BrowserExtensionContent extends Ev
   constructor(data) {
     super();
 
     this.data = data;
     this.id = data.id;
     this.uuid = data.uuid;
     this.instanceId = data.instanceId;
 
+    this.childModules = data.childModules;
+    this.schemaURLs = data.schemaURLs;
+
     this.MESSAGE_EMIT_EVENT = `Extension:EmitEvent:${this.instanceId}`;
     Services.cpmm.addMessageListener(this.MESSAGE_EMIT_EVENT, this);
 
     defineLazyGetter(this, "scripts", () => {
       return data.contentScripts.map(scriptData => new ExtensionContent.Script(this, scriptData));
     });
 
     this.webAccessibleResources = data.webAccessibleResources.map(res => new MatchGlob(res));
     this.whiteListedHosts = new MatchPatternSet(data.whiteListedHosts, {ignorePath: true});
     this.permissions = data.permissions;
     this.optionalPermissions = data.optionalPermissions;
     this.principal = data.principal;
 
-    this.apiManager = ExtensionPageChild.apiManager;
+    this.apiManager = this.getAPIManager();
 
     this.localeData = new LocaleData(data.localeData);
 
     this.manifest = data.manifest;
     this.baseURL = data.baseURL;
     this.baseURI = Services.io.newURI(data.baseURL);
 
     // Only used in addon processes.
@@ -629,16 +632,33 @@ class BrowserExtensionContent extends Ev
         this.policy.allowedOrigins = this.whiteListedHosts;
       }
     });
     /* eslint-enable mozilla/balanced-listeners */
 
     ExtensionManager.extensions.set(this.id, this);
   }
 
+  getAPIManager() {
+    let apiManagers = [ExtensionPageChild.apiManager];
+
+    if (this.childModules) {
+      this.experimentAPIManager =
+        new ExtensionCommon.LazyAPIManager("addon", this.childModules, this.schemaURLs);
+
+      apiManagers.push(this.experimentAPIManager);
+    }
+
+    if (apiManagers.length == 1) {
+      return apiManagers[0];
+    }
+
+    return new ExtensionCommon.MultiAPIManager("addon", apiManagers.reverse());
+  }
+
   shutdown() {
     ExtensionManager.extensions.delete(this.id);
     ExtensionContent.shutdownExtension(this);
     Services.cpmm.removeMessageListener(this.MESSAGE_EMIT_EVENT, this);
     if (isContentProcess) {
       MessageChannel.abortResponses({extensionId: this.id});
     }
   }
--- a/toolkit/components/extensions/ExtensionCommon.jsm
+++ b/toolkit/components/extensions/ExtensionCommon.jsm
@@ -22,16 +22,17 @@ Cu.importGlobalProperties(["fetch"]);
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   ConsoleAPI: "resource://gre/modules/Console.jsm",
   MessageChannel: "resource://gre/modules/MessageChannel.jsm",
   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
   Schemas: "resource://gre/modules/Schemas.jsm",
+  SchemaRoot: "resource://gre/modules/Schemas.jsm",
 });
 
 XPCOMUtils.defineLazyServiceGetter(this, "styleSheetService",
                                    "@mozilla.org/content/style-sheet-service;1",
                                    "nsIStyleSheetService");
 
 const global = Cu.getGlobalForObject(this);
 
@@ -736,16 +737,27 @@ function getChild(map, key) {
 
 function getPath(map, path) {
   for (let key of path) {
     map = getChild(map, key);
   }
   return map;
 }
 
+function mergePaths(dest, source) {
+  for (let name of source.modules) {
+    dest.modules.add(name);
+  }
+
+  for (let [name, child] of source.children.entries()) {
+    mergePaths(getChild(dest, name),
+               child);
+  }
+}
+
 /**
  * Manages loading and accessing a set of APIs for a specific extension
  * context.
  *
  * @param {BaseContext} context
  *        The context to manage APIs for.
  * @param {SchemaAPIManager} apiManager
  *        The API manager holding the APIs to manage.
@@ -949,17 +961,19 @@ class SchemaAPIManager extends EventEmit
    *     "content" - A content process.
    *     "devtools" - A devtools process.
    *     "proxy" - A proxy script process.
    * @param {SchemaRoot} schema
    */
   constructor(processType, schema) {
     super();
     this.processType = processType;
-    this.schema = schema;
+    if (schema) {
+      this.schema = schema;
+    }
 
     this.modules = new Map();
     this.modulePaths = {children: new Map(), modules: new Set()};
     this.manifestKeys = new Map();
     this.eventModules = new DefaultMap(() => new Set());
 
     this._modulesJSONLoaded = false;
 
@@ -979,21 +993,23 @@ class SchemaAPIManager extends EventEmit
         }
       }));
     }
 
     return Promise.all(promises);
   }
 
   async loadModuleJSON(urls) {
-    function fetchJSON(url) {
-      return fetch(url).then(resp => resp.json());
-    }
+    let promises = urls.map(url => fetch(url).then(resp => resp.json()));
 
-    for (let json of await Promise.all(urls.map(fetchJSON))) {
+    return this.initModuleJSON(await Promise.all(promises));
+  }
+
+  initModuleJSON(blobs) {
+    for (let json of blobs) {
       this.registerModules(json);
     }
 
     this._modulesJSONLoaded = true;
 
     return new StructuredCloneHolder({
       modules: this.modules,
       modulePaths: this.modulePaths,
@@ -1226,33 +1242,37 @@ class SchemaAPIManager extends EventEmit
       module.loaded = true;
 
       return this.global[name];
     });
 
     return module.asyncLoaded;
   }
 
+  getModule(name) {
+    return this.modules.get(name);
+  }
+
   /**
    * Checks whether the given API module may be loaded for the given
    * extension, in the given scope.
    *
    * @param {string} name
    *        The name of the API module to check.
    * @param {Extension} extension
    *        The extension for which to check the API.
    * @param {string} [scope = null]
    *        The scope type for which to check the API, or null if not
    *        being checked for a particular scope.
    *
    * @returns {boolean}
    *        Whether the module may be loaded.
    */
   _checkGetAPI(name, extension, scope = null) {
-    let module = this.modules.get(name);
+    let module = this.getModule(name);
 
     if (module.permissions && !module.permissions.some(perm => extension.hasPermission(perm))) {
       return false;
     }
 
     if (!scope) {
       return true;
     }
@@ -1363,16 +1383,117 @@ class SchemaAPIManager extends EventEmit
       if (Schemas.checkPermissions(api.namespace, {hasPermission})) {
         api = api.getAPI(context);
         deepCopy(obj, api);
       }
     }
   }
 }
 
+class LazyAPIManager extends SchemaAPIManager {
+  constructor(processType, moduleData, schemaURLs) {
+    super(processType);
+
+    this.initialized = false;
+
+    this.initModuleData(moduleData);
+
+    this._schema = null;
+    this.schemaURLs = schemaURLs;
+  }
+
+  get schema() {
+    if (!this._schema) {
+      this._schema = new SchemaRoot(Schemas.rootSchema, this.schemaURLs);
+      this._schema.parseSchemas();
+    }
+    return this._schema;
+  }
+}
+
+class MultiAPIManager extends SchemaAPIManager {
+  constructor(processType, children) {
+    super(processType);
+
+    this.initialized = false;
+
+    this._schema = null;
+
+    this.children = children;
+  }
+
+  async lazyInit() {
+    if (!this.initialized) {
+      this.initialized = true;
+
+      for (let child of this.children) {
+        if (child.lazyInit) {
+          let res = child.lazyInit();
+          if (res && typeof res.then === "function") {
+            await res;
+          }
+        }
+
+        mergePaths(this.modulePaths, child.modulePaths);
+      }
+    }
+  }
+
+  get schema() {
+    if (!this._schema) {
+      let bases = this.children.map(child => child.schema);
+
+      // All API manager schema roots should derive from the global schema root,
+      // so it doesn't need its own entry.
+      if (bases[bases.length - 1] === Schemas) {
+        bases.pop();
+      }
+
+      if (bases.length === 1) {
+        bases = bases[0];
+      }
+      this._schema = new SchemaRoot(bases, new Map());
+    }
+    return this._schema;
+  }
+
+  onStartup(extension) {
+    let promises = [];
+    for (let child of this.children) {
+      promises.push(child.onStartup(extension));
+    }
+    return Promise.all(promises);
+  }
+
+  getModule(name) {
+    for (let child of this.children) {
+      if (child.modules.has(name)) {
+        return child.modules.get(name);
+      }
+    }
+  }
+
+  loadModule(name) {
+    for (let child of this.children) {
+      if (child.modules.has(name)) {
+        return child.loadModule(name);
+      }
+    }
+  }
+
+  asyncLoadModule(name) {
+    for (let child of this.children) {
+      if (child.modules.has(name)) {
+        return child.asyncLoadModule(name);
+      }
+    }
+  }
+}
+
+
 function LocaleData(data) {
   this.defaultLocale = data.defaultLocale;
   this.selectedLocale = data.selectedLocale;
   this.locales = data.locales || new Map();
   this.warnedMissingKeys = new Set();
 
   // Map(locale-name -> Map(message-key -> localized-string))
   //
@@ -1725,9 +1846,12 @@ ExtensionCommon = {
   LocalAPIImplementation,
   LocaleData,
   NoCloneSpreadArgs,
   SchemaAPIInterface,
   SchemaAPIManager,
   SpreadArgs,
   ignoreEvent,
   stylesheetMap,
+
+  MultiAPIManager,
+  LazyAPIManager,
 };
--- a/toolkit/components/extensions/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -93,27 +93,31 @@ function stripDescriptions(json, stripTh
     } else {
       result[key] = json[key];
     }
   }
 
   return result;
 }
 
-async function readJSONAndBlobbify(url) {
-  let json = await readJSON(url);
-
+function blobbify(json) {
   // We don't actually use descriptions at runtime, and they make up about a
   // third of the size of our structured clone data, so strip them before
   // blobbifying.
   json = stripDescriptions(json);
 
   return new StructuredCloneHolder(json);
 }
 
+async function readJSONAndBlobbify(url) {
+  let json = await readJSON(url);
+
+  return blobbify(json);
+}
+
 /**
  * Defines a lazy getter for the given property on the given object. Any
  * security wrappers are waived on the object before the property is
  * defined, and the getter and setter methods are wrapped for the target
  * scope.
  *
  * The given getter function is guaranteed to be called only once, even
  * if the target scope retrieves the wrapped getter from the property
@@ -912,23 +916,26 @@ const FORMATS = {
 
     if (!context.checkLoadURL(url)) {
       throw new Error(`Access denied for URL ${url}`);
     }
     return url;
   },
 
   strictRelativeUrl(string, context) {
-    // Do not accept a string which resolves as an absolute URL, or any
-    // protocol-relative URL.
+    void FORMATS.unresolvedRelativeUrl(string, context);
+    return FORMATS.relativeUrl(string, context);
+  },
+
+  unresolvedRelativeUrl(string, context) {
     if (!string.startsWith("//")) {
       try {
         new URL(string);
       } catch (e) {
-        return FORMATS.relativeUrl(string, context);
+        return string;
       }
     }
 
     throw new SyntaxError(`String ${JSON.stringify(string)} must be a relative URL`);
   },
 
   imageDataOrStrictRelativeUrl(string, context) {
     // Do not accept a string which resolves as an absolute URL, or any
@@ -2925,16 +2932,24 @@ this.Schemas = {
       Services.ppmm.broadcastAsyncMessage("Schema:Add", [[url, schema]]);
     } else if (this.schemaHook) {
       this.schemaHook([[url, schema]]);
     }
 
     this.flushSchemas();
   },
 
+  fetch(url) {
+    return readJSONAndBlobbify(url);
+  },
+
+  processSchema(json) {
+    return blobbify(json);
+  },
+
   async load(url, content = false) {
     if (!isParentProcess) {
       return;
     }
 
     let schemaCache = await this.loadCachedSchemas();
 
     let blob = (schemaCache.get(url) ||
--- a/toolkit/components/extensions/schemas/experiments.json
+++ b/toolkit/components/extensions/schemas/experiments.json
@@ -5,12 +5,128 @@
       {
         "$extend": "Permission",
         "choices": [
           {
             "type": "string",
             "pattern": "^experiments(\\.\\w+)+$"
           }
         ]
+      },
+      {
+        "$extend": "WebExtensionManifest",
+        "properties": {
+          "experiment_apis": {
+            "type": "object",
+            "additionalProperties": {"$ref": "experiments.ExperimentAPI"},
+            "optional": true
+          }
+        }
+      }
+    ]
+  },
+  {
+    "namespace": "experiments",
+    "types": [
+      {
+        "id": "ExperimentAPI",
+        "type": "object",
+        "properties": {
+          "schema": {"$ref": "ExperimentURL"},
+
+          "parent": {
+            "type": "object",
+            "properties": {
+              "events": {
+                "$ref": "APIEvents",
+                "optional": true,
+                "default": []
+              },
+
+              "paths": {
+                "$ref": "APIPaths",
+                "optional": true,
+                "default": []
+              },
+
+              "script": {"$ref": "ExperimentURL"},
+
+              "scopes": {
+                "type": "array",
+                "items": {"$ref": "APIParentScope"},
+                "onerror": "warn",
+                "optional": true,
+                "default": []
+              }
+            },
+            "optional": true
+          },
+
+          "child": {
+            "type": "object",
+            "properties": {
+              "paths": {"$ref": "APIPaths"},
+
+              "script": {"$ref": "ExperimentURL"},
+
+              "scopes": {
+                "type": "array",
+                "minItems": 1,
+                "items": {"$ref": "APIChildScope"},
+                "onerror": "warn"
+              }
+            },
+            "optional": true
+          }
+        }
+      },
+      {
+        "id": "ExperimentURL",
+        "type": "string",
+        "format": "unresolvedRelativeUrl"
+      },
+      {
+        "id": "APIPaths",
+        "type": "array",
+        "items": {"$ref": "APIPath"},
+        "minItems": 1
+      },
+      {
+        "id": "APIPath",
+        "type": "array",
+        "items": {"type": "string"},
+        "minItems": 1
+      },
+      {
+        "id": "APIEvents",
+        "type": "array",
+        "items": {"$ref": "APIEvent"},
+        "onerror": "warn"
+      },
+      {
+        "id": "APIEvent",
+        "type": "string",
+        "enum": [
+          "startup"
+        ]
+      },
+      {
+        "id": "APIParentScope",
+        "type": "string",
+        "enum": [
+          "addon_parent",
+          "content_parent",
+          "devtools_parent",
+          "proxy_script"
+        ]
+      },
+      {
+        "id": "APIChildScope",
+        "type": "string",
+        "enum": [
+          "addon_child",
+          "content_child",
+          "devtools_child"
+        ]
       }
     ]
   }
 ]