--- 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"
+ ]
}
]
}
]