Bug 1263011: Part 2 - Implement WebExtensions Experiments prototype. r=aswan

MozReview-Commit-ID: 4KO4cCLRsLf

--HG--
extra : rebase_source : 40e5ec808e557e845a771bb21e8863a8edcd4faf
This commit is contained in:
Kris Maglione 2016-08-05 14:20:54 -07:00
Родитель b022382989
Коммит 73b1b5f221
13 изменённых файлов: 534 добавлений и 61 удалений

Просмотреть файл

@ -26,6 +26,14 @@ Cu.importGlobalProperties(["TextEncoder"]);
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
"resource://gre/modules/AddonManager.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
"resource://gre/modules/AppConstants.jsm");
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",
@ -34,10 +42,10 @@ 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",
@ -48,12 +56,6 @@ 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");
XPCOMUtils.defineLazyGetter(this, "require", () => {
let obj = {};
@ -68,6 +70,12 @@ const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json";
const CATEGORY_EXTENSION_SCHEMAS = "webextension-schemas";
const CATEGORY_EXTENSION_SCRIPTS = "webextension-scripts";
let schemaURLs = new Set();
if (!AppConstants.RELEASE_BUILD) {
schemaURLs.add("chrome://extensions/content/schemas/experiments.json");
}
Cu.import("resource://gre/modules/ExtensionUtils.jsm");
var {
BaseContext,
@ -115,8 +123,11 @@ var Management = {
// extended by other schemas, so needs to be loaded first.
let promise = Schemas.load(BASE_SCHEMA).then(() => {
let promises = [];
for (let [/* name */, value] of XPCOMUtils.enumerateCategoryEntries(CATEGORY_EXTENSION_SCHEMAS)) {
promises.push(Schemas.load(value));
for (let [/* name */, url] of XPCOMUtils.enumerateCategoryEntries(CATEGORY_EXTENSION_SCHEMAS)) {
promises.push(Schemas.load(url));
}
for (let url of schemaURLs) {
promises.push(Schemas.load(url));
}
return Promise.all(promises);
});
@ -196,6 +207,10 @@ var Management = {
copy(obj, api);
}
for (let api of extension.apis) {
copy(obj, api.getAPI(context));
}
return obj;
},
@ -741,6 +756,10 @@ this.ExtensionData = class {
this.localeData = null;
this._promiseLocales = null;
this.apiNames = new Set();
this.dependencies = new Set();
this.permissions = new Set();
this.errors = [];
}
@ -923,6 +942,25 @@ this.ExtensionData = class {
// 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;
});
}
@ -1173,7 +1211,7 @@ this.Extension = class extends ExtensionData {
this.uninstallURL = null;
this.permissions = new Set();
this.apis = [];
this.whiteListedHosts = null;
this.webAccessibleResources = null;
@ -1249,10 +1287,14 @@ this.Extension = class extends ExtensionData {
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;
@ -1277,7 +1319,7 @@ this.Extension = class extends ExtensionData {
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);
}
@ -1355,6 +1397,25 @@ this.Extension = class extends ExtensionData {
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.
@ -1388,17 +1449,6 @@ this.Extension = class extends ExtensionData {
}
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) {
@ -1544,6 +1594,10 @@ this.Extension = class extends ExtensionData {
obj.close();
}
for (let api of this.apis) {
api.destroy();
}
Management.emit("shutdown", this);
Services.ppmm.broadcastAsyncMessage("Extension:Shutdown", {id: this.id});

Просмотреть файл

@ -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;
},
};

Просмотреть файл

@ -85,6 +85,26 @@ var Frames = {
};
Frames.init();
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.
@ -274,6 +294,9 @@ this.ExtensionManagement = {
startupExtension: Service.startupExtension.bind(Service),
shutdownExtension: Service.shutdownExtension.bind(Service),
registerAPI: APIs.register.bind(APIs),
unregisterAPI: APIs.unregister.bind(APIs),
getFrameId: Frames.getId.bind(Frames),
getParentFrameId: Frames.getParentId.bind(Frames),
@ -281,4 +304,6 @@ this.ExtensionManagement = {
getAddonIdForWindow,
getAPILevelForWindow,
API_LEVELS,
APIs,
};

Просмотреть файл

@ -1247,9 +1247,6 @@ class Event extends CallEntry {
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(),
@ -1545,50 +1542,77 @@ this.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;
}
},
load(url) {
let loadFromJSON = json => {
for (let namespace of json) {
let name = namespace.namespace;
flushSchemas() {
XPCOMUtils.defineLazyGetter(this, "namespaces",
() => this.parseSchemas());
},
let types = namespace.types || [];
for (let type of types) {
this.loadType(name, type);
}
parseSchemas() {
Object.defineProperty(this, "namespaces", {
enumerable: true,
configurable: true,
value: new Map(),
});
let properties = namespace.properties || {};
for (let propertyName of Object.keys(properties)) {
this.loadProperty(name, propertyName, properties[propertyName]);
}
for (let json of this.schemaJSON.values()) {
this.parseSchema(json);
}
let functions = namespace.functions || [];
for (let fun of functions) {
this.loadFunction(name, fun);
}
return this.namespaces;
},
let events = namespace.events || [];
for (let event of events) {
this.loadEvent(name, event);
}
parseSchema(json) {
for (let namespace of json) {
let name = namespace.namespace;
if (namespace.permissions) {
let ns = this.namespaces.get(name);
ns.permissions = namespace.permissions;
}
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) {
if (Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_CONTENT) {
let result = readJSON(url).then(json => {
return readJSON(url).then(json => {
this.schemaJSON.set(url, json);
let data = Services.ppmm.initialProcessData;
@ -1596,17 +1620,20 @@ this.Schemas = {
Services.ppmm.broadcastAsyncMessage("Schema:Add", {url, schema: json});
loadFromJSON(json);
this.flushSchemas();
});
return result;
}
if (this.loadedUrls.has(url)) {
return;
}
this.loadedUrls.add(url);
},
let schema = this.schemaJSON.get(url);
loadFromJSON(schema);
unload(url) {
this.schemaJSON.delete(url);
let data = Services.ppmm.initialProcessData;
data["Extension:Schemas"] = this.schemaJSON;
Services.ppmm.broadcastAsyncMessage("Schema:Delete", {url});
this.flushSchemas();
},
inject(dest, wrapperFuncs) {

Просмотреть файл

@ -6,6 +6,7 @@
EXTRA_JS_MODULES += [
'Extension.jsm',
'ExtensionAPI.jsm',
'ExtensionContent.jsm',
'ExtensionManagement.jsm',
'ExtensionStorage.jsm',

Просмотреть файл

@ -0,0 +1,16 @@
[
{
"namespace": "manifest",
"types": [
{
"$extend": "Permission",
"choices": [
{
"type": "string",
"pattern": "^experiments(\\.\\w+)+$"
}
]
}
]
}
]

Просмотреть файл

@ -8,6 +8,7 @@ toolkit.jar:
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

Просмотреть файл

@ -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 ExtensionTestUtils.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();
});

Просмотреть файл

@ -26,6 +26,8 @@ skip-if = os == "android"
skip-if = os == "android"
[test_ext_downloads_search.js]
skip-if = os == "android"
[test_ext_experiments.js]
skip-if = release_build
[test_ext_extension.js]
[test_ext_idle.js]
[test_ext_json_parser.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) {
}

Просмотреть файл

@ -224,10 +224,14 @@ const TYPES = {
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([
@ -241,12 +245,14 @@ const RESTARTLESS_TYPES = new Set([
"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
@ -949,6 +955,7 @@ var loadManifestFromWebManifest = Task.async(function*(aUri) {
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);
@ -4711,6 +4718,8 @@ this.XPIProvider = {
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,

Просмотреть файл

@ -9,6 +9,7 @@ EXTRA_JS_MODULES.addons += [
'AddonRepository.jsm',
'AddonRepository_SQLiteMigrator.jsm',
'AddonUpdateChecker.jsm',
'APIExtensionBootstrap.js',
'Content.js',
'E10SAddonsRollout.jsm',
'GMPProvider.jsm',

Просмотреть файл

@ -2,6 +2,8 @@
* 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";
@ -295,3 +297,55 @@ add_task(function* test_options_ui() {
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();
});