--- a/browser/components/extensions/test/mochitest/test_ext_all_apis.html
+++ b/browser/components/extensions/test/mochitest/test_ext_all_apis.html
@@ -8,16 +8,17 @@
<script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
</head>
<body>
<script>
"use strict";
/* exported expectedContentApisTargetSpecific, expectedBackgroundApisTargetSpecific */
let expectedContentApisTargetSpecific = [
+ "userScripts.setScriptAPIs",
];
let expectedBackgroundApisTargetSpecific = [
"tabs.MutedInfoReason",
"tabs.TAB_ID_NONE",
"tabs.TabStatus",
"tabs.UpdatePropertyName",
"tabs.WindowType",
--- a/mobile/android/components/extensions/test/mochitest/test_ext_all_apis.html
+++ b/mobile/android/components/extensions/test/mochitest/test_ext_all_apis.html
@@ -8,16 +8,17 @@
<script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
</head>
<body>
<script>
"use strict";
/* exported expectedContentApisTargetSpecific, expectedBackgroundApisTargetSpecific */
let expectedContentApisTargetSpecific = [
+ "userScripts.setScriptAPIs",
];
let expectedBackgroundApisTargetSpecific = [
"tabs.MutedInfoReason",
"tabs.TAB_ID_NONE",
"tabs.TabStatus",
"tabs.WindowType",
"tabs.ZoomSettingsMode",
--- a/toolkit/components/extensions/ExtensionChild.jsm
+++ b/toolkit/components/extensions/ExtensionChild.jsm
@@ -1059,16 +1059,22 @@ class ChildAPIManager {
}
// Do not generate devtools APIs, unless explicitly allowed.
if (this.context.envType !== "devtools_child" &&
allowedContexts.includes("devtools_only")) {
return false;
}
+ // Do not generate content_only APIs, unless explicitly allowed.
+ if (this.context.envType !== "content_child" &&
+ allowedContexts.includes("content_only")) {
+ return false;
+ }
+
return true;
}
getImplementation(namespace, name) {
this.apiCan.findAPIPath(`${namespace}.${name}`);
let obj = this.apiCan.findAPIPath(namespace);
if (obj && name in obj) {
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -34,16 +34,17 @@ ChromeUtils.import("resource://gre/modul
ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm");
ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
XPCOMUtils.defineLazyGlobalGetters(this, ["crypto", "TextEncoder"]);
const {
DefaultMap,
DefaultWeakMap,
+ ExtensionError,
getInnerWindowID,
getWinUtils,
promiseDocumentIdle,
promiseDocumentLoaded,
promiseDocumentReady,
} = ExtensionUtils;
const {
@@ -491,17 +492,16 @@ class Script {
}
await cssPromise;
return result;
}
async awaitCompiledScripts(context) {
let scriptPromises = this.compileScripts();
-
let scripts = scriptPromises.map(promise => promise.script);
// If not all scripts are already available in the cache, block
// parsing and wait all promises to resolve.
if (!scripts.every(script => script)) {
let promise = Promise.all(scriptPromises);
// If we're supposed to inject at the start of the document load,
@@ -525,52 +525,85 @@ class UserScript extends Script {
* @param {BrowserExtensionContent} extension
* @param {WebExtensionContentScript|object} matcher
* An object with a "matchesWindow" method and content script execution
* details.
*/
constructor(extension, matcher) {
super(extension, matcher);
+ // This is an opaque object that the extension provides, it is associated to
+ // the particular userScript and it is passed as a parameter to the custom
+ // userScripts APIs defined by the extension.
+ this.scriptMetadata = matcher.userScriptOptions.scriptMetadata;
+ this.apiScriptURL = extension.manifest.userScripts && extension.manifest.userScripts.apiScript;
+
+ this.promiseAPIScript = null;
this.scriptPromises = null;
// WeakMap<ContentScriptContextChild, Sandbox>
this.sandboxes = new DefaultWeakMap((context) => {
return this.createSandbox(context);
});
}
compileScripts() {
+ if (this.apiScriptURL && !this.promiseAPIScript) {
+ this.promiseAPIScript = this.scriptCache.get(this.apiScriptURL);
+ }
+
if (!this.scriptPromises) {
this.scriptPromises = this.js.map(url => this.scriptCache.get(url));
}
+ if (this.promiseAPIScript) {
+ return [this.promiseAPIScript, ...this.scriptPromises];
+ }
+
return this.scriptPromises;
}
async inject(context) {
DocumentManager.lazyInit();
- let sandboxScripts = await this.awaitCompiledScripts(context);
+ let scripts = await this.awaitCompiledScripts(context);
+
+ let apiScript, sandboxScripts;
+
+ if (this.promiseAPIScript) {
+ [apiScript, ...sandboxScripts] = scripts;
+ } else {
+ sandboxScripts = scripts;
+ }
+
+ // Load and execute the API script once per context.
+ if (apiScript) {
+ context.executeAPIScript(apiScript);
+ }
// The evaluations below may throw, in which case the promise will be
// automatically rejected.
TelemetryStopwatch.start(USER_SCRIPT_INJECTION_HISTOGRAM, context);
try {
let userScriptSandbox = this.sandboxes.get(context);
context.callOnClose({
close: () => {
// Destroy the userScript sandbox when the related ContentScriptContextChild instance
// is being closed.
this.sandboxes.delete(context);
Cu.nukeSandbox(userScriptSandbox);
},
});
+ // Inject the custom API registered by the extension API script.
+ if (apiScript) {
+ this.injectAPIs(userScriptSandbox, context);
+ }
+
for (let script of sandboxScripts) {
script.executeInGlobal(userScriptSandbox);
}
} finally {
TelemetryStopwatch.finish(USER_SCRIPT_INJECTION_HISTOGRAM, context);
}
}
@@ -596,16 +629,80 @@ class UserScript extends Script {
metadata: {
"inner-window-id": context.innerWindowID,
addonId: this.extension.policy.id,
},
});
return sandbox;
}
+
+ injectAPIs(sandbox, context) {
+ const {extension, scriptMetadata} = this;
+ const {userScriptAPIs, cloneScope} = context;
+
+ if (!userScriptAPIs) {
+ return;
+ }
+
+ const clonedMetadata = scriptMetadata ?
+ Cu.cloneInto(scriptMetadata, cloneScope) : undefined;
+ const SandboxError = sandbox.Error;
+
+ function safeReturnCloned(res) {
+ try {
+ return Cu.cloneInto(res, sandbox);
+ } catch (err) {
+ Cu.reportError(
+ `userScripts API method wrapper for ${extension.policy.debugName}: ${err}`
+ );
+ throw new SandboxError("Unable to clone object in the userScript sandbox");
+ }
+ }
+
+ function wrapUserScriptAPIMethod(fn) {
+ return Cu.exportFunction(function(...args) {
+ let fnArgs;
+
+ try {
+ fnArgs = Cu.cloneInto(args, cloneScope, {cloneFunctions: true});
+ } catch (err) {
+ Cu.reportError(`Error cloning userScriptAPIMethod parameters: ${err}`);
+ throw new SandboxError("Only serializable parameters are supported");
+ }
+
+ const res = runSafeSyncWithoutClone(fn, fnArgs, clonedMetadata, sandbox);
+
+ if (res instanceof cloneScope.Promise) {
+ return sandbox.Promise.resolve().then(async () => {
+ let value;
+ try {
+ value = await res;
+ } catch (err) {
+ if (err instanceof cloneScope.Error) {
+ throw new SandboxError(err.message);
+ } else {
+ throw safeReturnCloned(err);
+ }
+ }
+ return safeReturnCloned(value, sandbox);
+ });
+ }
+
+ return safeReturnCloned(res, sandbox);
+ }, sandbox);
+ }
+
+ for (let key of Object.keys(userScriptAPIs)) {
+ Schemas.exportLazyGetter(sandbox, key, () => {
+ // Wrap the custom API methods exported to the userScript sandbox.
+ return wrapUserScriptAPIMethod(userScriptAPIs[key]);
+ });
+ }
+ }
}
/**
* An execution context for semi-privileged extension content scripts.
*
* This is the child side of the ContentScriptContextParent class
* defined in ExtensionParent.jsm.
*/
@@ -700,16 +797,23 @@ class ContentScriptContextChild extends
let chromeObj = Cu.createObjectIn(this.sandbox);
this.childManager.inject(chromeObj);
return chromeObj;
});
Schemas.exportLazyGetter(this.sandbox, "browser", () => this.chromeObj);
Schemas.exportLazyGetter(this.sandbox, "chrome", () => this.chromeObj);
+
+ // A set of exported API methods provided by the extension to the userScripts sandboxes.
+ this.userScriptAPIs = null;
+
+ // Keep track of the API scripts already executed in this context.
+ // WeakSet<PreloadedScript>
+ this.apiScripts = new WeakSet();
}
injectAPI() {
if (!this.isExtensionPage) {
throw new Error("Cannot inject extension API into non-extension window");
}
// This is an iframe with content script API enabled (See Bug 1214658)
@@ -718,16 +822,32 @@ class ContentScriptContextChild extends
Schemas.exportLazyGetter(this.contentWindow,
"chrome", () => this.chromeObj);
}
get cloneScope() {
return this.sandbox;
}
+ setUserScriptAPIs(extCustomAPIs) {
+ if (this.userScriptAPIs) {
+ throw new ExtensionError("userScripts APIs may only be set once");
+ }
+
+ this.userScriptAPIs = extCustomAPIs;
+ }
+
+ async executeAPIScript(apiScript) {
+ // Execute the apiScripts only once.
+ if (apiScript && !this.apiScripts.has(apiScript)) {
+ apiScript.executeInGlobal(this.cloneScope);
+ this.apiScripts.add(apiScript);
+ }
+ }
+
addScript(script) {
if (script.requiresCleanup) {
this.scripts.push(script);
}
}
close() {
super.unload();
--- a/toolkit/components/extensions/child/ext-toolkit.js
+++ b/toolkit/components/extensions/child/ext-toolkit.js
@@ -62,17 +62,17 @@ extensions.registerModules({
url: "chrome://extensions/content/child/ext-test.js",
scopes: ["addon_child", "content_child", "devtools_child", "proxy_script"],
paths: [
["test"],
],
},
userScripts: {
url: "chrome://extensions/content/child/ext-userScripts.js",
- scopes: ["addon_child"],
+ scopes: ["addon_child", "content_child"],
paths: [
["userScripts"],
],
},
webRequest: {
url: "chrome://extensions/content/child/ext-webRequest.js",
scopes: ["addon_child"],
paths: [
--- a/toolkit/components/extensions/child/ext-userScripts.js
+++ b/toolkit/components/extensions/child/ext-userScripts.js
@@ -145,12 +145,15 @@ this.userScripts = class extends Extensi
}));
const scriptId = await context.childManager.callParentAsyncFunction(
"userScripts.register", [options]);
return convertToAPIObject(scriptId, options);
});
},
+ setScriptAPIs(exportedAPIMethods) {
+ context.setUserScriptAPIs(exportedAPIMethods);
+ },
},
};
}
};
--- a/toolkit/components/extensions/ext-toolkit.json
+++ b/toolkit/components/extensions/ext-toolkit.json
@@ -187,17 +187,17 @@
"scopes": ["addon_parent"],
"paths": [
["topSites"]
]
},
"userScripts": {
"url": "chrome://extensions/content/parent/ext-userScripts.js",
"schema": "chrome://extensions/content/schemas/user_scripts.json",
- "scopes": ["addon_parent"],
+ "scopes": ["addon_parent", "content_parent"],
"paths": [
["userScripts"]
]
},
"webNavigation": {
"url": "chrome://extensions/content/parent/ext-webNavigation.js",
"schema": "chrome://extensions/content/schemas/web_navigation.json",
"scopes": ["addon_parent"],
--- a/toolkit/components/extensions/schemas/user_scripts.json
+++ b/toolkit/components/extensions/schemas/user_scripts.json
@@ -1,17 +1,43 @@
/* 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/. */
[
{
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "userScripts": {
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "apiScript": { "$ref": "manifest.ExtensionURL"}
+ }
+
+ }
+
+ }
+ }
+ ]
+ },
+ {
"namespace": "userScripts",
+ "allowedContexts": ["content"],
"types": [
{
+ "id": "ExportedAPIMethods",
+ "type": "object",
+ "description": "A set of API methods provided by the extensions to its userScripts",
+ "additionalProperties": { "type": "function" }
+ },
+ {
"id": "UserScriptOptions",
"type": "object",
"description": "Details of a user script",
"properties": {
"js": {
"type": "array",
"optional": true,
"description": "The list of JS files to inject",
@@ -87,12 +113,24 @@
"description": "Register a user script programmatically given its $(ref:userScripts.UserScriptOptions), and resolves to a $(ref:userScripts.RegisteredUserScript) instance",
"async": true,
"parameters": [
{
"name": "userScriptOptions",
"$ref": "UserScriptOptions"
}
]
+ },
+ {
+ "name": "setScriptAPIs",
+ "allowedContexts": ["content", "content_only"],
+ "type": "function",
+ "description": "Provides a set of custom API methods available to the registered userScripts",
+ "parameters": [
+ {
+ "name": "exportedAPIMethods",
+ "$ref": "ExportedAPIMethods"
+ }
+ ]
}
]
}
]
--- a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js
@@ -196,8 +196,138 @@ add_task(async function test_userScripts
Assert.deepEqual(reply2, {
textContent: "userScript loaded - undefined",
url: url2,
}, "The userScript executed on the expected url and no access to the WebExtensions APIs");
await contentPage2.close();
await extension.unload();
});
+
+add_task(async function test_userScripts_exported_APIs() {
+ async function background() {
+ const matches = ["http://localhost/*/file_sample.html"];
+
+ await browser.runtime.onMessage.addListener(async (msg, sender) => {
+ return {bgPageReply: true};
+ });
+
+ async function userScript() {
+ // Explicitly retrieve the custom exported API methods
+ // to prevent eslint to raise a no-undef validation
+ // error for them.
+ const {
+ US_sync_api,
+ US_async_api_with_callback,
+ US_send_api_results,
+ } = this;
+ this.userScriptGlobalVar = "global-sandbox-value";
+
+ const syncAPIResult = US_sync_api("param1", "param2");
+ const asyncAPIResult = await US_async_api_with_callback("param3", (cbParam) => {
+ return `callback param: ${JSON.stringify(cbParam)}`;
+ });
+
+
+ let expectedError;
+
+ // This is expect to raise an exception due to the window parameter which can't
+ // be cloned.
+ try {
+ US_sync_api(window);
+ } catch (err) {
+ expectedError = err.message;
+ }
+
+ US_send_api_results({syncAPIResult, asyncAPIResult, expectedError});
+ }
+
+ await browser.userScripts.register({
+ js: [{
+ code: `(${userScript})();`,
+ }],
+ runAt: "document_end",
+ matches,
+ scriptMetadata: {
+ name: "test-user-script-exported-apis",
+ },
+ });
+
+ browser.test.sendMessage("background-ready");
+ }
+
+ function apiScript() {
+ browser.userScripts.setScriptAPIs({
+ US_sync_api([param1, param2], scriptMetadata, scriptGlobal) {
+ browser.test.assertEq("test-user-script-exported-apis", scriptMetadata.name);
+
+ browser.test.assertEq("param1", param1, "Got the expected parameter value");
+ browser.test.assertEq("param2", param2, "Got the expected parameter value");
+
+ browser.test.sendMessage("US_sync_api", {param1, param2});
+
+ return "returned_value";
+ },
+ async US_async_api_with_callback([param, cb], scriptMetadata, scriptGlobal) {
+ browser.test.assertEq("function", typeof cb, "Got a callback function parameter");
+
+ browser.runtime.sendMessage({param}).then(bgPageRes => {
+ // eslint-disable-next-line no-undef
+ const cbResult = cb(cloneInto(bgPageRes, scriptGlobal));
+ browser.test.sendMessage("US_async_api_with_callback", cbResult);
+ });
+
+ return "resolved_value";
+ },
+ async US_send_api_results([results], scriptMetadata, scriptGlobal) {
+ browser.test.sendMessage("US_send_api_results", results);
+ },
+ });
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: [
+ "http://localhost/*/file_sample.html",
+ ],
+ userScripts: {
+ apiScript: "api-script.js",
+ },
+ },
+ background,
+ files: {
+ "api-script.js": apiScript,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ // Ensure that a content page running in a content process and which has been
+ // already loaded when the content scripts has been registered, it has received
+ // and registered the expected content scripts.
+ let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
+
+ await extension.startup();
+
+ await extension.awaitMessage("background-ready");
+
+ await contentPage.loadURL(`${BASE_URL}/file_sample.html`);
+
+ info("Wait the userScript to call the exported US_sync_api method");
+ await extension.awaitMessage("US_sync_api");
+
+ info("Wait the userScript to call the exported US_async_api_with_callback method");
+ const userScriptCallbackResult = await extension.awaitMessage("US_async_api_with_callback");
+ equal(userScriptCallbackResult, `callback param: {"bgPageReply":true}`,
+ "Got the expected results when the userScript callback has been called");
+
+ info("Wait the userScript to call the exported US_send_api_results method");
+ const userScriptsAPIResults = await extension.awaitMessage("US_send_api_results");
+ Assert.deepEqual(userScriptsAPIResults, {
+ syncAPIResult: "returned_value",
+ asyncAPIResult: "resolved_value",
+ expectedError: "Only serializable parameters are supported",
+ }, "Got the expected userScript API results");
+
+ await extension.unload();
+
+ await contentPage.close();
+});