--- 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();
+});