summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/child
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/extensions/child')
-rw-r--r--toolkit/components/extensions/child/.eslintrc.js11
-rw-r--r--toolkit/components/extensions/child/ext-backgroundPage.js36
-rw-r--r--toolkit/components/extensions/child/ext-contentScripts.js76
-rw-r--r--toolkit/components/extensions/child/ext-declarativeNetRequest.js35
-rw-r--r--toolkit/components/extensions/child/ext-extension.js78
-rw-r--r--toolkit/components/extensions/child/ext-identity.js84
-rw-r--r--toolkit/components/extensions/child/ext-runtime.js143
-rw-r--r--toolkit/components/extensions/child/ext-scripting.js49
-rw-r--r--toolkit/components/extensions/child/ext-storage.js368
-rw-r--r--toolkit/components/extensions/child/ext-test.js371
-rw-r--r--toolkit/components/extensions/child/ext-toolkit.js89
-rw-r--r--toolkit/components/extensions/child/ext-userScripts-content.js408
-rw-r--r--toolkit/components/extensions/child/ext-userScripts.js192
-rw-r--r--toolkit/components/extensions/child/ext-webRequest.js119
14 files changed, 2059 insertions, 0 deletions
diff --git a/toolkit/components/extensions/child/.eslintrc.js b/toolkit/components/extensions/child/.eslintrc.js
new file mode 100644
index 0000000000..01f6e45d35
--- /dev/null
+++ b/toolkit/components/extensions/child/.eslintrc.js
@@ -0,0 +1,11 @@
+/* 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";
+
+module.exports = {
+ globals: {
+ EventManager: true,
+ },
+};
diff --git a/toolkit/components/extensions/child/ext-backgroundPage.js b/toolkit/components/extensions/child/ext-backgroundPage.js
new file mode 100644
index 0000000000..06af794afc
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-backgroundPage.js
@@ -0,0 +1,36 @@
+/* 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.backgroundPage = class extends ExtensionAPI {
+ getAPI(context) {
+ function getBackgroundPage() {
+ for (let view of context.extension.views) {
+ if (
+ view.viewType == "background" &&
+ context.principal.subsumes(view.principal)
+ ) {
+ return view.contentWindow;
+ }
+ }
+ return null;
+ }
+ return {
+ extension: {
+ getBackgroundPage,
+ },
+
+ runtime: {
+ getBackgroundPage() {
+ return context.childManager
+ .callParentAsyncFunction("runtime.internalWakeupBackground", [])
+ .then(() => {
+ return context.cloneScope.Promise.resolve(getBackgroundPage());
+ });
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/child/ext-contentScripts.js b/toolkit/components/extensions/child/ext-contentScripts.js
new file mode 100644
index 0000000000..338374cde6
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-contentScripts.js
@@ -0,0 +1,76 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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";
+
+var { ExtensionError } = ExtensionUtils;
+
+/**
+ * Represents (in the child extension process) a content script registered
+ * programmatically (instead of being included in the addon manifest).
+ *
+ * @param {ExtensionPageContextChild} context
+ * The extension context which has registered the content script.
+ * @param {string} scriptId
+ * An unique id that represents the registered content script
+ * (generated and used internally to identify it across the different processes).
+ */
+class ContentScriptChild {
+ constructor(context, scriptId) {
+ this.context = context;
+ this.scriptId = scriptId;
+ this.unregistered = false;
+ }
+
+ async unregister() {
+ if (this.unregistered) {
+ throw new ExtensionError("Content script already unregistered");
+ }
+
+ this.unregistered = true;
+
+ await this.context.childManager.callParentAsyncFunction(
+ "contentScripts.unregister",
+ [this.scriptId]
+ );
+
+ this.context = null;
+ }
+
+ api() {
+ const { context } = this;
+
+ // TODO(rpl): allow to read the options related to the registered content script?
+ return {
+ unregister: () => {
+ return context.wrapPromise(this.unregister());
+ },
+ };
+ }
+}
+
+this.contentScripts = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ contentScripts: {
+ register(options) {
+ return context.cloneScope.Promise.resolve().then(async () => {
+ const scriptId = await context.childManager.callParentAsyncFunction(
+ "contentScripts.register",
+ [options]
+ );
+
+ const registeredScript = new ContentScriptChild(context, scriptId);
+
+ return Cu.cloneInto(registeredScript.api(), context.cloneScope, {
+ cloneFunctions: true,
+ });
+ });
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/child/ext-declarativeNetRequest.js b/toolkit/components/extensions/child/ext-declarativeNetRequest.js
new file mode 100644
index 0000000000..82028c6105
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-declarativeNetRequest.js
@@ -0,0 +1,35 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs",
+});
+
+this.declarativeNetRequest = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ declarativeNetRequest: {
+ get GUARANTEED_MINIMUM_STATIC_RULES() {
+ return ExtensionDNRLimits.GUARANTEED_MINIMUM_STATIC_RULES;
+ },
+ get MAX_NUMBER_OF_STATIC_RULESETS() {
+ return ExtensionDNRLimits.MAX_NUMBER_OF_STATIC_RULESETS;
+ },
+ get MAX_NUMBER_OF_ENABLED_STATIC_RULESETS() {
+ return ExtensionDNRLimits.MAX_NUMBER_OF_ENABLED_STATIC_RULESETS;
+ },
+ get MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES() {
+ return ExtensionDNRLimits.MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES;
+ },
+ get MAX_NUMBER_OF_REGEX_RULES() {
+ return ExtensionDNRLimits.MAX_NUMBER_OF_REGEX_RULES;
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/child/ext-extension.js b/toolkit/components/extensions/child/ext-extension.js
new file mode 100644
index 0000000000..f4024086e4
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-extension.js
@@ -0,0 +1,78 @@
+/* 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.extension = class extends ExtensionAPI {
+ getAPI(context) {
+ let api = {
+ getURL(url) {
+ return context.extension.baseURI.resolve(url);
+ },
+
+ get lastError() {
+ return context.lastError;
+ },
+
+ get inIncognitoContext() {
+ return context.incognito;
+ },
+ };
+
+ if (context.envType === "addon_child") {
+ api.getViews = function (fetchProperties) {
+ let result = Cu.cloneInto([], context.cloneScope);
+
+ for (let view of context.extension.views) {
+ if (!view.active) {
+ continue;
+ }
+ if (!context.principal.subsumes(view.principal)) {
+ continue;
+ }
+
+ if (fetchProperties !== null) {
+ if (
+ fetchProperties.type !== null &&
+ view.viewType != fetchProperties.type
+ ) {
+ continue;
+ }
+
+ if (fetchProperties.windowId !== null) {
+ let bc = view.contentWindow?.docShell?.browserChild;
+ let windowId =
+ view.viewType !== "background"
+ ? bc?.chromeOuterWindowID ?? -1
+ : -1;
+ if (windowId !== fetchProperties.windowId) {
+ continue;
+ }
+ }
+
+ if (
+ fetchProperties.tabId !== null &&
+ view.tabId != fetchProperties.tabId
+ ) {
+ continue;
+ }
+ }
+
+ // Do not include extension popups contexts while their document
+ // is blocked on parsing during its preloading state
+ // (See Bug 1748808).
+ if (context.extension.hasContextBlockedParsingDocument(view)) {
+ continue;
+ }
+
+ result.push(view.contentWindow);
+ }
+
+ return result;
+ };
+ }
+
+ return { extension: api };
+ }
+};
diff --git a/toolkit/components/extensions/child/ext-identity.js b/toolkit/components/extensions/child/ext-identity.js
new file mode 100644
index 0000000000..9218065322
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-identity.js
@@ -0,0 +1,84 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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";
+
+var { Constructor: CC } = Components;
+
+ChromeUtils.defineESModuleGetters(this, {
+ CommonUtils: "resource://services-common/utils.sys.mjs",
+});
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "redirectDomain",
+ "extensions.webextensions.identity.redirectDomain"
+);
+
+let CryptoHash = CC(
+ "@mozilla.org/security/hash;1",
+ "nsICryptoHash",
+ "initWithString"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL", "TextEncoder"]);
+
+const computeHash = str => {
+ let byteArr = new TextEncoder().encode(str);
+ let hash = new CryptoHash("sha1");
+ hash.update(byteArr, byteArr.length);
+ return CommonUtils.bytesAsHex(hash.finish(false));
+};
+
+this.identity = class extends ExtensionAPI {
+ getAPI(context) {
+ let { extension } = context;
+ return {
+ identity: {
+ getRedirectURL: function (path = "") {
+ let hash = computeHash(extension.id);
+ let url = new URL(`https://${hash}.${redirectDomain}/`);
+ url.pathname = path;
+ return url.href;
+ },
+ launchWebAuthFlow: function (details) {
+ // Validate the url and retreive redirect_uri if it was provided.
+ let url, redirectURI;
+ let baseRedirectURL = this.getRedirectURL();
+
+ // Allow using loopback address for native OAuth flows as some
+ // providers do not accept the URL provided by getRedirectURL.
+ // For more context, see bug 1635344.
+ let loopbackURL = `http://127.0.0.1/mozoauth2/${computeHash(
+ extension.id
+ )}`;
+ try {
+ url = new URL(details.url);
+ } catch (e) {
+ return Promise.reject({ message: "details.url is invalid" });
+ }
+ try {
+ redirectURI = new URL(
+ url.searchParams.get("redirect_uri") || baseRedirectURL
+ );
+ if (
+ !redirectURI.href.startsWith(baseRedirectURL) &&
+ !redirectURI.href.startsWith(loopbackURL)
+ ) {
+ return Promise.reject({ message: "redirect_uri not allowed" });
+ }
+ } catch (e) {
+ return Promise.reject({ message: "redirect_uri is invalid" });
+ }
+
+ return context.childManager.callParentAsyncFunction(
+ "identity.launchWebAuthFlowInParent",
+ [details, redirectURI.href]
+ );
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/child/ext-runtime.js b/toolkit/components/extensions/child/ext-runtime.js
new file mode 100644
index 0000000000..8cf5c445e3
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-runtime.js
@@ -0,0 +1,143 @@
+/* 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";
+
+ChromeUtils.defineESModuleGetters(this, {
+ WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs",
+});
+
+/* eslint-disable jsdoc/check-param-names */
+/**
+ * With optional arguments on both ends, this case is ambiguous:
+ * runtime.sendMessage("string", {} or nullish)
+ *
+ * Sending a message within the extension is more common than sending
+ * an empty object to another extension, so we prefer that conclusion.
+ *
+ * @param {string?} [extensionId]
+ * @param {any} message
+ * @param {object?} [options]
+ * @param {Function} [callback]
+ * @returns {{extensionId: string?, message: any, callback: Function?}}
+ */
+/* eslint-enable jsdoc/check-param-names */
+function parseBonkersArgs(...args) {
+ let Error = ExtensionUtils.ExtensionError;
+ let callback = typeof args[args.length - 1] === "function" && args.pop();
+
+ // We don't support any options anymore, so only an empty object is valid.
+ function validOptions(v) {
+ return v == null || (typeof v === "object" && !Object.keys(v).length);
+ }
+
+ if (args.length === 1 || (args.length === 2 && validOptions(args[1]))) {
+ // Interpret as passing null for extensionId (message within extension).
+ args.unshift(null);
+ }
+ let [extensionId, message, options] = args;
+
+ if (!args.length) {
+ throw new Error("runtime.sendMessage's message argument is missing");
+ } else if (!validOptions(options)) {
+ throw new Error("runtime.sendMessage's options argument is invalid");
+ } else if (args.length === 4 && args[3] && !callback) {
+ throw new Error("runtime.sendMessage's last argument is not a function");
+ } else if (args[3] != null || args.length > 4) {
+ throw new Error("runtime.sendMessage received too many arguments");
+ } else if (extensionId && typeof extensionId !== "string") {
+ throw new Error("runtime.sendMessage's extensionId argument is invalid");
+ }
+ return { extensionId, message, callback };
+}
+
+this.runtime = class extends ExtensionAPI {
+ getAPI(context) {
+ let { extension } = context;
+
+ return {
+ runtime: {
+ onConnect: context.messenger.onConnect.api(),
+ onMessage: context.messenger.onMessage.api(),
+
+ onConnectExternal: context.messenger.onConnectEx.api(),
+ onMessageExternal: context.messenger.onMessageEx.api(),
+
+ connect(extensionId, options) {
+ let name = options?.name ?? "";
+ return context.messenger.connect({ name, extensionId });
+ },
+
+ sendMessage(...args) {
+ let arg = parseBonkersArgs(...args);
+ return context.messenger.sendRuntimeMessage(arg);
+ },
+
+ connectNative(name) {
+ return context.messenger.connect({ name, native: true });
+ },
+
+ sendNativeMessage(nativeApp, message) {
+ return context.messenger.sendNativeMessage(nativeApp, message);
+ },
+
+ get lastError() {
+ return context.lastError;
+ },
+
+ getManifest() {
+ return Cu.cloneInto(extension.manifest, context.cloneScope);
+ },
+
+ id: extension.id,
+
+ getURL(url) {
+ return extension.baseURI.resolve(url);
+ },
+
+ getFrameId(target) {
+ let frameId = WebNavigationFrames.getFromWindow(target);
+ if (frameId >= 0) {
+ return frameId;
+ }
+ // Not a WindowProxy, perhaps an embedder element?
+
+ let type;
+ try {
+ type = Cu.getClassName(target, true);
+ } catch (e) {
+ // Not a valid object, will throw below.
+ }
+
+ const embedderTypes = [
+ "HTMLIFrameElement",
+ "HTMLFrameElement",
+ "HTMLEmbedElement",
+ "HTMLObjectElement",
+ ];
+
+ if (embedderTypes.includes(type)) {
+ if (!target.browsingContext) {
+ return -1;
+ }
+ return WebNavigationFrames.getFrameId(target.browsingContext);
+ }
+
+ throw new ExtensionUtils.ExtensionError("Invalid argument");
+ },
+ },
+ };
+ }
+
+ getAPIObjectForRequest(context, request) {
+ if (request.apiObjectType === "Port") {
+ const port = context.messenger.getPortById(request.apiObjectId);
+ if (!port) {
+ throw new Error(`Port API object not found: ${request}`);
+ }
+ return port.api;
+ }
+
+ throw new Error(`Unexpected apiObjectType: ${request}`);
+ }
+};
diff --git a/toolkit/components/extensions/child/ext-scripting.js b/toolkit/components/extensions/child/ext-scripting.js
new file mode 100644
index 0000000000..cce587227f
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-scripting.js
@@ -0,0 +1,49 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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";
+
+var { ExtensionError } = ExtensionUtils;
+
+this.scripting = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ scripting: {
+ executeScript: async details => {
+ let { func, args, ...parentDetails } = details;
+
+ if (details.files) {
+ if (details.args) {
+ throw new ExtensionError(
+ "'args' may not be used with file injections."
+ );
+ }
+ }
+ // `files` and `func` are mutually exclusive but that is checked in
+ // the parent (in `execute()`).
+ if (func) {
+ try {
+ const serializedArgs = args
+ ? JSON.stringify(args).slice(1, -1)
+ : "";
+ // This is a prop that we compute here and pass to the parent.
+ parentDetails.func = `(${func.toString()})(${serializedArgs});`;
+ } catch (e) {
+ throw new ExtensionError("Unserializable arguments.");
+ }
+ } else {
+ parentDetails.func = null;
+ }
+
+ return context.childManager.callParentAsyncFunction(
+ "scripting.executeScriptInternal",
+ [parentDetails]
+ );
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/child/ext-storage.js b/toolkit/components/extensions/child/ext-storage.js
new file mode 100644
index 0000000000..a83f44cb00
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-storage.js
@@ -0,0 +1,368 @@
+/* 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";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionStorage: "resource://gre/modules/ExtensionStorage.sys.mjs",
+ ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.sys.mjs",
+ ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.sys.mjs",
+});
+
+// Wrap a storage operation in a TelemetryStopWatch.
+async function measureOp(telemetryMetric, extension, fn) {
+ const stopwatchKey = {};
+ telemetryMetric.stopwatchStart(extension, stopwatchKey);
+ try {
+ let result = await fn();
+ telemetryMetric.stopwatchFinish(extension, stopwatchKey);
+ return result;
+ } catch (err) {
+ telemetryMetric.stopwatchCancel(extension, stopwatchKey);
+ throw err;
+ }
+}
+
+this.storage = class extends ExtensionAPI {
+ getLocalFileBackend(context, { deserialize, serialize }) {
+ return {
+ get(keys) {
+ return measureOp(
+ ExtensionTelemetry.storageLocalGetJSON,
+ context.extension,
+ () => {
+ return context.childManager
+ .callParentAsyncFunction("storage.local.JSONFileBackend.get", [
+ serialize(keys),
+ ])
+ .then(deserialize);
+ }
+ );
+ },
+ set(items) {
+ return measureOp(
+ ExtensionTelemetry.storageLocalSetJSON,
+ context.extension,
+ () => {
+ return context.childManager.callParentAsyncFunction(
+ "storage.local.JSONFileBackend.set",
+ [serialize(items)]
+ );
+ }
+ );
+ },
+ remove(keys) {
+ return context.childManager.callParentAsyncFunction(
+ "storage.local.JSONFileBackend.remove",
+ [serialize(keys)]
+ );
+ },
+ clear() {
+ return context.childManager.callParentAsyncFunction(
+ "storage.local.JSONFileBackend.clear",
+ []
+ );
+ },
+ };
+ }
+
+ getLocalIDBBackend(context, { fireOnChanged, serialize, storagePrincipal }) {
+ let dbPromise;
+ async function getDB() {
+ if (dbPromise) {
+ return dbPromise;
+ }
+
+ const persisted = context.extension.hasPermission("unlimitedStorage");
+ dbPromise = ExtensionStorageIDB.open(storagePrincipal, persisted).catch(
+ err => {
+ // Reset the cached promise if it has been rejected, so that the next
+ // API call is going to retry to open the DB.
+ dbPromise = null;
+ throw err;
+ }
+ );
+
+ return dbPromise;
+ }
+
+ return {
+ get(keys) {
+ return measureOp(
+ ExtensionTelemetry.storageLocalGetIDB,
+ context.extension,
+ async () => {
+ const db = await getDB();
+ return db.get(keys);
+ }
+ );
+ },
+ set(items) {
+ function serialize(name, anonymizedName, value) {
+ return ExtensionStorage.serialize(
+ `set/${context.extension.id}/${name}`,
+ `set/${context.extension.id}/${anonymizedName}`,
+ value
+ );
+ }
+
+ return measureOp(
+ ExtensionTelemetry.storageLocalSetIDB,
+ context.extension,
+ async () => {
+ const db = await getDB();
+ const changes = await db.set(items, {
+ serialize,
+ });
+
+ if (changes) {
+ fireOnChanged(changes);
+ }
+ }
+ );
+ },
+ async remove(keys) {
+ const db = await getDB();
+ const changes = await db.remove(keys);
+
+ if (changes) {
+ fireOnChanged(changes);
+ }
+ },
+ async clear() {
+ const db = await getDB();
+ const changes = await db.clear(context.extension);
+
+ if (changes) {
+ fireOnChanged(changes);
+ }
+ },
+ };
+ }
+
+ getAPI(context) {
+ const { extension } = context;
+ const serialize = ExtensionStorage.serializeForContext.bind(null, context);
+ const deserialize = ExtensionStorage.deserializeForContext.bind(
+ null,
+ context
+ );
+
+ // onChangedName is "storage.onChanged", "storage.sync.onChanged", etc.
+ function makeOnChangedEventTarget(onChangedName) {
+ return new EventManager({
+ context,
+ name: onChangedName,
+ register: fire => {
+ let onChanged = (data, area) => {
+ let changes = new context.cloneScope.Object();
+ for (let [key, value] of Object.entries(data)) {
+ changes[key] = deserialize(value);
+ }
+ if (area) {
+ // storage.onChanged includes the area.
+ fire.raw(changes, area);
+ } else {
+ // StorageArea.onChanged doesn't include the area.
+ fire.raw(changes);
+ }
+ };
+
+ let parent = context.childManager.getParentEvent(onChangedName);
+ parent.addListener(onChanged);
+ return () => {
+ parent.removeListener(onChanged);
+ };
+ },
+ }).api();
+ }
+
+ function sanitize(items) {
+ // The schema validator already takes care of arrays (which are only allowed
+ // to contain strings). Strings and null are safe values.
+ if (typeof items != "object" || items === null || Array.isArray(items)) {
+ return items;
+ }
+ // If we got here, then `items` is an object generated by `ObjectType`'s
+ // `normalize` method from Schemas.jsm. The object returned by `normalize`
+ // lives in this compartment, while the values live in compartment of
+ // `context.contentWindow`. The `sanitize` method runs with the principal
+ // of `context`, so we cannot just use `ExtensionStorage.sanitize` because
+ // it is not allowed to access properties of `items`.
+ // So we enumerate all properties and sanitize each value individually.
+ let sanitized = {};
+ for (let [key, value] of Object.entries(items)) {
+ sanitized[key] = ExtensionStorage.sanitize(value, context);
+ }
+ return sanitized;
+ }
+
+ function fireOnChanged(changes) {
+ // This call is used (by the storage.local API methods for the IndexedDB backend) to fire a storage.onChanged event,
+ // it uses the underlying message manager since the child context (or its ProxyContentParent counterpart
+ // running in the main process) may be gone by the time we call this, and so we can't use the childManager
+ // abstractions (e.g. callParentAsyncFunction or callParentFunctionNoReturn).
+ Services.cpmm.sendAsyncMessage(
+ `Extension:StorageLocalOnChanged:${extension.uuid}`,
+ changes
+ );
+ }
+
+ // If the selected backend for the extension is not known yet, we have to lazily detect it
+ // by asking to the main process (as soon as the storage.local API has been accessed for
+ // the first time).
+ const getStorageLocalBackend = async () => {
+ const { backendEnabled, storagePrincipal } =
+ await ExtensionStorageIDB.selectBackend(context);
+
+ if (!backendEnabled) {
+ return this.getLocalFileBackend(context, { deserialize, serialize });
+ }
+
+ return this.getLocalIDBBackend(context, {
+ storagePrincipal,
+ fireOnChanged,
+ serialize,
+ });
+ };
+
+ // Synchronously select the backend if it is already known.
+ let selectedBackend;
+
+ const useStorageIDBBackend = extension.getSharedData("storageIDBBackend");
+ if (useStorageIDBBackend === false) {
+ selectedBackend = this.getLocalFileBackend(context, {
+ deserialize,
+ serialize,
+ });
+ } else if (useStorageIDBBackend === true) {
+ selectedBackend = this.getLocalIDBBackend(context, {
+ storagePrincipal: extension.getSharedData("storageIDBPrincipal"),
+ fireOnChanged,
+ serialize,
+ });
+ }
+
+ let promiseStorageLocalBackend;
+
+ // Generate the backend-agnostic local API wrapped methods.
+ const local = {
+ onChanged: makeOnChangedEventTarget("storage.local.onChanged"),
+ };
+ for (let method of ["get", "set", "remove", "clear"]) {
+ local[method] = async function (...args) {
+ try {
+ // Discover the selected backend if it is not known yet.
+ if (!selectedBackend) {
+ if (!promiseStorageLocalBackend) {
+ promiseStorageLocalBackend = getStorageLocalBackend().catch(
+ err => {
+ // Clear the cached promise if it has been rejected.
+ promiseStorageLocalBackend = null;
+ throw err;
+ }
+ );
+ }
+
+ // If the storage.local method is not 'get' (which doesn't change any of the stored data),
+ // fall back to call the method in the parent process, so that it can be completed even
+ // if this context has been destroyed in the meantime.
+ if (method !== "get") {
+ // Let the outer try to catch rejections returned by the backend methods.
+ try {
+ const result =
+ await context.childManager.callParentAsyncFunction(
+ "storage.local.callMethodInParentProcess",
+ [method, args]
+ );
+ return result;
+ } catch (err) {
+ // Just return the rejection as is, the error has been normalized in the
+ // parent process by callMethodInParentProcess and the original error
+ // logged in the browser console.
+ return Promise.reject(err);
+ }
+ }
+
+ // Get the selected backend and cache it for the next API calls from this context.
+ selectedBackend = await promiseStorageLocalBackend;
+ }
+
+ // Let the outer try to catch rejections returned by the backend methods.
+ const result = await selectedBackend[method](...args);
+ return result;
+ } catch (err) {
+ throw ExtensionStorageIDB.normalizeStorageError({
+ error: err,
+ extensionId: extension.id,
+ storageMethod: method,
+ });
+ }
+ };
+ }
+
+ return {
+ storage: {
+ local,
+
+ session: {
+ async get(keys) {
+ return deserialize(
+ await context.childManager.callParentAsyncFunction(
+ "storage.session.get",
+ [serialize(keys)]
+ )
+ );
+ },
+ set(items) {
+ return context.childManager.callParentAsyncFunction(
+ "storage.session.set",
+ [serialize(items)]
+ );
+ },
+ onChanged: makeOnChangedEventTarget("storage.session.onChanged"),
+ },
+
+ sync: {
+ get(keys) {
+ keys = sanitize(keys);
+ return context.childManager.callParentAsyncFunction(
+ "storage.sync.get",
+ [keys]
+ );
+ },
+ set(items) {
+ items = sanitize(items);
+ return context.childManager.callParentAsyncFunction(
+ "storage.sync.set",
+ [items]
+ );
+ },
+ onChanged: makeOnChangedEventTarget("storage.sync.onChanged"),
+ },
+
+ managed: {
+ get(keys) {
+ return context.childManager
+ .callParentAsyncFunction("storage.managed.get", [serialize(keys)])
+ .then(deserialize);
+ },
+ set(items) {
+ return Promise.reject({ message: "storage.managed is read-only" });
+ },
+ remove(keys) {
+ return Promise.reject({ message: "storage.managed is read-only" });
+ },
+ clear() {
+ return Promise.reject({ message: "storage.managed is read-only" });
+ },
+
+ onChanged: makeOnChangedEventTarget("storage.managed.onChanged"),
+ },
+
+ onChanged: makeOnChangedEventTarget("storage.onChanged"),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/child/ext-test.js b/toolkit/components/extensions/child/ext-test.js
new file mode 100644
index 0000000000..98dca726bb
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-test.js
@@ -0,0 +1,371 @@
+/* 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";
+
+XPCOMUtils.defineLazyGetter(this, "isXpcshell", function () {
+ return Services.env.exists("XPCSHELL_TEST_PROFILE_DIR");
+});
+
+/**
+ * Checks whether the given error matches the given expectations.
+ *
+ * @param {*} error
+ * The error to check.
+ * @param {string | RegExp | Function | null} expectedError
+ * The expectation to check against. If this parameter is:
+ *
+ * - a string, the error message must exactly equal the string.
+ * - a regular expression, it must match the error message.
+ * - a function, it is called with the error object and its
+ * return value is returned.
+ * @param {BaseContext} context
+ *
+ * @returns {boolean}
+ * True if the error matches the expected error.
+ */
+const errorMatches = (error, expectedError, context) => {
+ if (
+ typeof error === "object" &&
+ error !== null &&
+ !context.principal.subsumes(Cu.getObjectPrincipal(error))
+ ) {
+ Cu.reportError("Error object belongs to the wrong scope.");
+ return false;
+ }
+
+ if (typeof expectedError === "function") {
+ return context.runSafeWithoutClone(expectedError, error);
+ }
+
+ if (
+ typeof error !== "object" ||
+ error == null ||
+ typeof error.message !== "string"
+ ) {
+ return false;
+ }
+
+ if (typeof expectedError === "string") {
+ return error.message === expectedError;
+ }
+
+ try {
+ return expectedError.test(error.message);
+ } catch (e) {
+ Cu.reportError(e);
+ }
+
+ return false;
+};
+
+// Checks whether |v| should use string serialization instead of JSON.
+function useStringInsteadOfJSON(v) {
+ return (
+ // undefined to string, or else it is omitted from object after stringify.
+ v === undefined ||
+ // Values that would have become null.
+ (typeof v === "number" && (isNaN(v) || !isFinite(v)))
+ );
+}
+
+// A very strict deep equality comparator that throws for unsupported values.
+// For context, see https://bugzilla.mozilla.org/show_bug.cgi?id=1782816#c2
+function deepEquals(a, b) {
+ // Some values don't have a JSON representation. To disambiguate from null or
+ // regular strings, we prepend this prefix instead.
+ const NON_JSON_PREFIX = "#NOT_JSON_SERIALIZABLE#";
+
+ function replacer(key, value) {
+ if (typeof value == "object" && value !== null && !Array.isArray(value)) {
+ const cls = ChromeUtils.getClassName(value);
+ if (cls === "Object") {
+ // Return plain object with keys sorted in a predictable order.
+ return Object.fromEntries(
+ Object.keys(value)
+ .sort()
+ .map(k => [k, value[k]])
+ );
+ }
+ // Just throw to avoid potentially inaccurate serializations (e.g. {}).
+ throw new ExtensionUtils.ExtensionError(`Unsupported obj type: ${cls}`);
+ }
+
+ if (useStringInsteadOfJSON(value)) {
+ return `${NON_JSON_PREFIX}${value}`;
+ }
+ return value;
+ }
+ return JSON.stringify(a, replacer) === JSON.stringify(b, replacer);
+}
+
+/**
+ * Serializes the given value for use in informative assertion messages.
+ *
+ * @param {*} value
+ * @returns {string}
+ */
+const toSource = value => {
+ function cannotJSONserialize(v) {
+ return (
+ useStringInsteadOfJSON(v) ||
+ // Not a plain object. E.g. [object X], /regexp/, etc.
+ (typeof v == "object" &&
+ v !== null &&
+ !Array.isArray(v) &&
+ ChromeUtils.getClassName(v) !== "Object")
+ );
+ }
+ try {
+ if (cannotJSONserialize(value)) {
+ return String(value);
+ }
+
+ const replacer = (k, v) => (cannotJSONserialize(v) ? String(v) : v);
+ return JSON.stringify(value, replacer);
+ } catch (e) {
+ return "<unknown>";
+ }
+};
+
+this.test = class extends ExtensionAPI {
+ getAPI(context) {
+ const { extension } = context;
+
+ function getStack(savedFrame = null) {
+ if (savedFrame) {
+ return ChromeUtils.createError("", savedFrame).stack.replace(
+ /^/gm,
+ " "
+ );
+ }
+ return new context.Error().stack.replace(/^/gm, " ");
+ }
+
+ function assertTrue(value, msg) {
+ extension.emit(
+ "test-result",
+ Boolean(value),
+ String(msg),
+ getStack(context.getCaller())
+ );
+ }
+
+ class TestEventManager extends EventManager {
+ constructor(...args) {
+ super(...args);
+
+ // A map to keep track of the listeners wrappers being added in
+ // addListener (the wrapper will be needed to be able to remove
+ // the listener from this EventManager instance if the extension
+ // does call test.onMessage.removeListener).
+ this._listenerWrappers = new Map();
+ context.callOnClose({
+ close: () => this._listenerWrappers.clear(),
+ });
+ }
+
+ addListener(callback, ...args) {
+ const listenerWrapper = function (...args) {
+ try {
+ callback.call(this, ...args);
+ } catch (e) {
+ assertTrue(false, `${e}\n${e.stack}`);
+ }
+ };
+ super.addListener(listenerWrapper, ...args);
+ this._listenerWrappers.set(callback, listenerWrapper);
+ }
+
+ removeListener(callback) {
+ if (!this._listenerWrappers.has(callback)) {
+ return;
+ }
+
+ super.removeListener(this._listenerWrappers.get(callback));
+ this._listenerWrappers.delete(callback);
+ }
+ }
+
+ if (!Cu.isInAutomation && !isXpcshell) {
+ return { test: {} };
+ }
+
+ return {
+ test: {
+ withHandlingUserInput(callback) {
+ // TODO(Bug 1598804): remove this once we don't expose anymore the
+ // entire test API namespace based on an environment variable.
+ if (!Cu.isInAutomation) {
+ // This dangerous method should only be available if the
+ // automation pref is set, which is the case in browser tests.
+ throw new ExtensionUtils.ExtensionError(
+ "withHandlingUserInput can only be called in automation"
+ );
+ }
+ ExtensionCommon.withHandlingUserInput(
+ context.contentWindow,
+ callback
+ );
+ },
+
+ sendMessage(...args) {
+ extension.emit("test-message", ...args);
+ },
+
+ notifyPass(msg) {
+ extension.emit("test-done", true, msg, getStack(context.getCaller()));
+ },
+
+ notifyFail(msg) {
+ extension.emit(
+ "test-done",
+ false,
+ msg,
+ getStack(context.getCaller())
+ );
+ },
+
+ log(msg) {
+ extension.emit("test-log", true, msg, getStack(context.getCaller()));
+ },
+
+ fail(msg) {
+ assertTrue(false, msg);
+ },
+
+ succeed(msg) {
+ assertTrue(true, msg);
+ },
+
+ assertTrue(value, msg) {
+ assertTrue(value, msg);
+ },
+
+ assertFalse(value, msg) {
+ assertTrue(!value, msg);
+ },
+
+ assertDeepEq(expected, actual, msg) {
+ // The bindings generated by Schemas.jsm accepts any input, but the
+ // WebIDL-generated binding expects a structurally cloneable input.
+ // To ensure consistent behavior regardless of which mechanism was
+ // used, verify that the inputs are structurally cloneable.
+ // These will throw if the values cannot be cloned.
+ function ensureStructurallyCloneable(v) {
+ if (typeof v == "object" && v !== null) {
+ // Waive xrays to unhide callable members, so that cloneInto will
+ // throw if needed.
+ v = ChromeUtils.waiveXrays(v);
+ }
+ new StructuredCloneHolder("test.assertEq", null, v, globalThis);
+ }
+ // When WebIDL bindings are used, the objects are already cloned
+ // structurally, so we don't need to check again.
+ if (!context.useWebIDLBindings) {
+ ensureStructurallyCloneable(expected);
+ ensureStructurallyCloneable(actual);
+ }
+
+ extension.emit(
+ "test-eq",
+ deepEquals(actual, expected),
+ String(msg),
+ toSource(expected),
+ toSource(actual),
+ getStack(context.getCaller())
+ );
+ },
+
+ assertEq(expected, actual, msg) {
+ let equal = expected === actual;
+
+ expected = String(expected);
+ actual = String(actual);
+
+ if (!equal && expected === actual) {
+ actual += " (different)";
+ }
+ extension.emit(
+ "test-eq",
+ equal,
+ String(msg),
+ expected,
+ actual,
+ getStack(context.getCaller())
+ );
+ },
+
+ assertRejects(promise, expectedError, msg) {
+ // Wrap in a native promise for consistency.
+ promise = Promise.resolve(promise);
+
+ return promise.then(
+ result => {
+ let message = `Promise resolved, expected rejection '${toSource(
+ expectedError
+ )}'`;
+ if (msg) {
+ message += `: ${msg}`;
+ }
+ assertTrue(false, message);
+ },
+ error => {
+ let expected = toSource(expectedError);
+ let message = `got '${toSource(error)}'`;
+ if (msg) {
+ message += `: ${msg}`;
+ }
+
+ assertTrue(
+ errorMatches(error, expectedError, context),
+ `Promise rejected, expecting rejection to match '${expected}', ${message}`
+ );
+ }
+ );
+ },
+
+ assertThrows(func, expectedError, msg) {
+ try {
+ func();
+
+ let message = `Function did not throw, expected error '${toSource(
+ expectedError
+ )}'`;
+ if (msg) {
+ message += `: ${msg}`;
+ }
+ assertTrue(false, message);
+ } catch (error) {
+ let expected = toSource(expectedError);
+ let message = `got '${toSource(error)}'`;
+ if (msg) {
+ message += `: ${msg}`;
+ }
+
+ assertTrue(
+ errorMatches(error, expectedError, context),
+ `Function threw, expecting error to match '${expected}', ${message}`
+ );
+ }
+ },
+
+ onMessage: new TestEventManager({
+ context,
+ name: "test.onMessage",
+ register: fire => {
+ let handler = (event, ...args) => {
+ fire.async(...args);
+ };
+
+ extension.on("test-harness-message", handler);
+ return () => {
+ extension.off("test-harness-message", handler);
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/child/ext-toolkit.js b/toolkit/components/extensions/child/ext-toolkit.js
new file mode 100644
index 0000000000..64963b6df6
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-toolkit.js
@@ -0,0 +1,89 @@
+/* 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";
+
+// These are defined on "global" which is used for the same scopes as the other
+// ext-c-*.js files.
+/* exported EventManager */
+/* global EventManager: false */
+
+global.EventManager = ExtensionCommon.EventManager;
+
+extensions.registerModules({
+ backgroundPage: {
+ url: "chrome://extensions/content/child/ext-backgroundPage.js",
+ scopes: ["addon_child"],
+ manifest: ["background"],
+ paths: [
+ ["extension", "getBackgroundPage"],
+ ["runtime", "getBackgroundPage"],
+ ],
+ },
+ contentScripts: {
+ url: "chrome://extensions/content/child/ext-contentScripts.js",
+ scopes: ["addon_child"],
+ paths: [["contentScripts"]],
+ },
+ declarativeNetRequest: {
+ url: "chrome://extensions/content/child/ext-declarativeNetRequest.js",
+ scopes: ["addon_child"],
+ paths: [["declarativeNetRequest"]],
+ },
+ extension: {
+ url: "chrome://extensions/content/child/ext-extension.js",
+ scopes: ["addon_child", "content_child", "devtools_child"],
+ paths: [["extension"]],
+ },
+ i18n: {
+ url: "chrome://extensions/content/parent/ext-i18n.js",
+ scopes: ["addon_child", "content_child", "devtools_child"],
+ paths: [["i18n"]],
+ },
+ runtime: {
+ url: "chrome://extensions/content/child/ext-runtime.js",
+ scopes: ["addon_child", "content_child", "devtools_child"],
+ paths: [["runtime"]],
+ },
+ scripting: {
+ url: "chrome://extensions/content/child/ext-scripting.js",
+ scopes: ["addon_child"],
+ paths: [["scripting"]],
+ },
+ storage: {
+ url: "chrome://extensions/content/child/ext-storage.js",
+ scopes: ["addon_child", "content_child", "devtools_child"],
+ paths: [["storage"]],
+ },
+ test: {
+ url: "chrome://extensions/content/child/ext-test.js",
+ scopes: ["addon_child", "content_child", "devtools_child"],
+ paths: [["test"]],
+ },
+ userScripts: {
+ url: "chrome://extensions/content/child/ext-userScripts.js",
+ scopes: ["addon_child"],
+ paths: [["userScripts"]],
+ },
+ userScriptsContent: {
+ url: "chrome://extensions/content/child/ext-userScripts-content.js",
+ scopes: ["content_child"],
+ paths: [["userScripts", "onBeforeScript"]],
+ },
+ webRequest: {
+ url: "chrome://extensions/content/child/ext-webRequest.js",
+ scopes: ["addon_child"],
+ paths: [["webRequest"]],
+ },
+});
+
+if (AppConstants.MOZ_BUILD_APP === "browser") {
+ extensions.registerModules({
+ identity: {
+ url: "chrome://extensions/content/child/ext-identity.js",
+ scopes: ["addon_child"],
+ paths: [["identity"]],
+ },
+ });
+}
diff --git a/toolkit/components/extensions/child/ext-userScripts-content.js b/toolkit/components/extensions/child/ext-userScripts-content.js
new file mode 100644
index 0000000000..ee1a1b7a8f
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-userScripts-content.js
@@ -0,0 +1,408 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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";
+
+var USERSCRIPT_PREFNAME = "extensions.webextensions.userScripts.enabled";
+var USERSCRIPT_DISABLED_ERRORMSG = `userScripts APIs are currently experimental and must be enabled with the ${USERSCRIPT_PREFNAME} preference.`;
+
+ChromeUtils.defineESModuleGetters(this, {
+ Schemas: "resource://gre/modules/Schemas.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "userScriptsEnabled",
+ USERSCRIPT_PREFNAME,
+ false
+);
+
+var { ExtensionError } = ExtensionUtils;
+
+const TYPEOF_PRIMITIVES = ["bigint", "boolean", "number", "string", "symbol"];
+
+/**
+ * Represents a user script in the child content process.
+ *
+ * This class implements the API object that is passed as a parameter to the
+ * browser.userScripts.onBeforeScript API Event.
+ *
+ * @param {object} params
+ * @param {ContentScriptContextChild} params.context
+ * The context which has registered the userScripts.onBeforeScript listener.
+ * @param {PlainJSONValue} params.metadata
+ * An opaque user script metadata value (as set in userScripts.register).
+ * @param {Sandbox} params.scriptSandbox
+ * The Sandbox object of the userScript.
+ */
+class UserScript {
+ constructor({ context, metadata, scriptSandbox }) {
+ this.context = context;
+ this.extension = context.extension;
+ this.apiSandbox = context.cloneScope;
+ this.metadata = metadata;
+ this.scriptSandbox = scriptSandbox;
+
+ this.ScriptError = scriptSandbox.Error;
+ this.ScriptPromise = scriptSandbox.Promise;
+ }
+
+ /**
+ * Returns the API object provided to the userScripts.onBeforeScript listeners.
+ *
+ * @returns {object}
+ * The API object with the properties and methods to export
+ * to the extension code.
+ */
+ api() {
+ return {
+ metadata: this.metadata,
+ defineGlobals: sourceObject => this.defineGlobals(sourceObject),
+ export: value => this.export(value),
+ };
+ }
+
+ /**
+ * Define all the properties of a given plain object as lazy getters of the
+ * userScript global object.
+ *
+ * @param {object} sourceObject
+ * A set of objects and methods to export into the userScript scope as globals.
+ *
+ * @throws {context.Error}
+ * Throws an apiScript error when sourceObject is not a plain object.
+ */
+ defineGlobals(sourceObject) {
+ let className;
+ try {
+ className = ChromeUtils.getClassName(sourceObject, true);
+ } catch (e) {
+ // sourceObject is not an object;
+ }
+
+ if (className !== "Object") {
+ throw new this.context.Error(
+ "Invalid sourceObject type, plain object expected."
+ );
+ }
+
+ this.exportLazyGetters(sourceObject, this.scriptSandbox);
+ }
+
+ /**
+ * Convert a given value to make it accessible to the userScript code.
+ *
+ * - any property value that is already accessible to the userScript code is returned unmodified by
+ * the lazy getter
+ * - any apiScript's Function is wrapped using the `wrapFunction` method
+ * - any apiScript's Object is lazily exported (and the same wrappers are lazily applied to its
+ * properties).
+ *
+ * @param {any} valueToExport
+ * A value to convert into an object accessible to the userScript.
+ *
+ * @param {object} privateOptions
+ * A set of options used when this method is called internally (not exposed in the
+ * api object exported to the onBeforeScript listeners).
+ * @param {Error} privateOptions.Error
+ * The Error constructor to use to report errors (defaults to the apiScript context's Error
+ * when missing).
+ * @param {Error} privateOptions.errorMessage
+ * A custom error message to report exporting error on values not allowed.
+ *
+ * @returns {any}
+ * The resulting userScript object.
+ *
+ * @throws {context.Error | privateOptions.Error}
+ * Throws an error when the value is not allowed and it can't be exported into an allowed one.
+ */
+ export(valueToExport, privateOptions = {}) {
+ const ExportError = privateOptions.Error || this.context.Error;
+
+ if (this.canAccess(valueToExport, this.scriptSandbox)) {
+ // Return the value unmodified if the userScript principal is already allowed
+ // to access it.
+ return valueToExport;
+ }
+
+ let className;
+
+ try {
+ className = ChromeUtils.getClassName(valueToExport, true);
+ } catch (e) {
+ // sourceObject is not an object;
+ }
+
+ if (className === "Function") {
+ return this.wrapFunction(valueToExport);
+ }
+
+ if (className === "Object") {
+ return this.exportLazyGetters(valueToExport);
+ }
+
+ if (className === "Array") {
+ return this.exportArray(valueToExport);
+ }
+
+ let valueType = className || typeof valueToExport;
+ throw new ExportError(
+ privateOptions.errorMessage ||
+ `${valueType} cannot be exported to the userScript`
+ );
+ }
+
+ /**
+ * Export all the elements of the `srcArray` into a newly created userScript array.
+ *
+ * @param {Array} srcArray
+ * The apiScript array to export to the userScript code.
+ *
+ * @returns {Array}
+ * The resulting userScript array.
+ *
+ * @throws {UserScriptError}
+ * Throws an error when the array can't be exported successfully.
+ */
+ exportArray(srcArray) {
+ const destArray = Cu.cloneInto([], this.scriptSandbox);
+
+ for (let [idx, value] of this.shallowCloneEntries(srcArray)) {
+ destArray[idx] = this.export(value, {
+ errorMessage: `Error accessing disallowed element at index "${idx}"`,
+ Error: this.UserScriptError,
+ });
+ }
+
+ return destArray;
+ }
+
+ /**
+ * Export all the properties of the `src` plain object as lazy getters on the `dest` object,
+ * or in a newly created userScript object if `dest` is `undefined`.
+ *
+ * @param {object} src
+ * A set of properties to define on a `dest` object as lazy getters.
+ * @param {object} [dest]
+ * An optional `dest` object (a new userScript object is created by default when not specified).
+ *
+ * @returns {object}
+ * The resulting userScript object.
+ */
+ exportLazyGetters(src, dest = undefined) {
+ dest = dest || Cu.createObjectIn(this.scriptSandbox);
+
+ for (let [key, value] of this.shallowCloneEntries(src)) {
+ Schemas.exportLazyGetter(dest, key, () => {
+ return this.export(value, {
+ // Lazy properties will raise an error for properties with not allowed
+ // values to the userScript scope, and so we have to raise an userScript
+ // Error here.
+ Error: this.ScriptError,
+ errorMessage: `Error accessing disallowed property "${key}"`,
+ });
+ });
+ }
+
+ return dest;
+ }
+
+ /**
+ * Export and wrap an apiScript function to provide the following behaviors:
+ * - errors throws from an exported function are checked by `handleAPIScriptError`
+ * - returned apiScript's Promises (not accessible to the userScript) are converted into a
+ * userScript's Promise
+ * - check if the returned or resolved value is accessible to the userScript code
+ * (and raise a userScript error if it is not)
+ *
+ * @param {Function} fn
+ * The apiScript function to wrap
+ *
+ * @returns {object}
+ * The resulting userScript function.
+ */
+ wrapFunction(fn) {
+ return Cu.exportFunction((...args) => {
+ let res;
+ try {
+ // Checks that all the elements in the `...args` array are allowed to be
+ // received from the apiScript.
+ for (let arg of args) {
+ if (!this.canAccess(arg, this.apiSandbox)) {
+ throw new this.ScriptError(
+ `Parameter not accessible to the userScript API`
+ );
+ }
+ }
+
+ res = fn(...args);
+ } catch (err) {
+ this.handleAPIScriptError(err);
+ }
+
+ // Prevent execution of proxy traps while checking if the return value is a Promise.
+ if (!Cu.isProxy(res) && res instanceof this.context.Promise) {
+ return this.ScriptPromise.resolve().then(async () => {
+ let value;
+
+ try {
+ value = await res;
+ } catch (err) {
+ this.handleAPIScriptError(err);
+ }
+
+ return this.ensureAccessible(value);
+ });
+ }
+
+ return this.ensureAccessible(res);
+ }, this.scriptSandbox);
+ }
+
+ /**
+ * Shallow clone the source object and iterate over its Object properties (or Array elements),
+ * which allow us to safely iterate over all its properties (including callable objects that
+ * would be hidden by the xrays vision, but excluding any property that could be tricky, e.g.
+ * getters).
+ *
+ * @param {object | Array} obj
+ * The Object or Array object to shallow clone and iterate over.
+ */
+ *shallowCloneEntries(obj) {
+ const clonedObj = ChromeUtils.shallowClone(obj);
+
+ for (let entry of Object.entries(clonedObj)) {
+ yield entry;
+ }
+ }
+
+ /**
+ * Check if the given value is accessible to the targetScope.
+ *
+ * @param {any} val
+ * The value to check.
+ * @param {Sandbox} targetScope
+ * The targetScope that should be able to access the value.
+ *
+ * @returns {boolean}
+ */
+ canAccess(val, targetScope) {
+ if (val == null || TYPEOF_PRIMITIVES.includes(typeof val)) {
+ return true;
+ }
+
+ // Disallow objects that are coming from principals that are not
+ // subsumed by the targetScope's principal.
+ try {
+ const targetPrincipal = Cu.getObjectPrincipal(targetScope);
+ if (!targetPrincipal.subsumes(Cu.getObjectPrincipal(val))) {
+ return false;
+ }
+ } catch (err) {
+ Cu.reportError(err);
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Check if the value returned (or resolved) from an apiScript method is accessible
+ * to the userScript code, and throw a userScript Error if it is not allowed.
+ *
+ * @param {any} res
+ * The value to return/resolve.
+ *
+ * @returns {any}
+ * The exported value.
+ *
+ * @throws {Error}
+ * Throws a userScript error when the value is not accessible to the userScript scope.
+ */
+ ensureAccessible(res) {
+ if (this.canAccess(res, this.scriptSandbox)) {
+ return res;
+ }
+
+ throw new this.ScriptError("Return value not accessible to the userScript");
+ }
+
+ /**
+ * Handle the error raised (and rejected promise returned) from apiScript functions exported to the
+ * userScript.
+ *
+ * @param {any} err
+ * The value to return/resolve.
+ *
+ * @throws {any}
+ * This method is expected to throw:
+ * - any value that is already accessible to the userScript code is forwarded unmodified
+ * - any value that is not accessible to the userScript code is logged in the console
+ * (to make it easier to investigate the underlying issue) and converted into a
+ * userScript Error (with the generic "An unexpected apiScript error occurred" error
+ * message accessible to the userScript)
+ */
+ handleAPIScriptError(err) {
+ if (this.canAccess(err, this.scriptSandbox)) {
+ throw err;
+ }
+
+ // Log the actual error on the console and raise a generic userScript Error
+ // on error objects that can't be accessed by the UserScript principal.
+ try {
+ const debugName = this.extension.policy.debugName;
+ Cu.reportError(
+ `An unexpected apiScript error occurred for '${debugName}': ${err} :: ${err.stack}`
+ );
+ } catch (e) {}
+
+ throw new this.ScriptError(`An unexpected apiScript error occurred`);
+ }
+}
+
+this.userScriptsContent = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ userScripts: {
+ onBeforeScript: new EventManager({
+ context,
+ name: "userScripts.onBeforeScript",
+ register: fire => {
+ if (!userScriptsEnabled) {
+ throw new ExtensionError(USERSCRIPT_DISABLED_ERRORMSG);
+ }
+
+ let handler = (event, metadata, scriptSandbox, eventResult) => {
+ const us = new UserScript({
+ context,
+ metadata,
+ scriptSandbox,
+ });
+
+ const apiObj = Cu.cloneInto(us.api(), context.cloneScope, {
+ cloneFunctions: true,
+ });
+
+ Object.defineProperty(apiObj, "global", {
+ value: scriptSandbox,
+ enumerable: true,
+ configurable: true,
+ writable: true,
+ });
+
+ fire.raw(apiObj);
+ };
+
+ context.userScriptsEvents.on("on-before-script", handler);
+ return () => {
+ context.userScriptsEvents.off("on-before-script", handler);
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/child/ext-userScripts.js b/toolkit/components/extensions/child/ext-userScripts.js
new file mode 100644
index 0000000000..66cfeb0906
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-userScripts.js
@@ -0,0 +1,192 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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";
+
+var USERSCRIPT_PREFNAME = "extensions.webextensions.userScripts.enabled";
+var USERSCRIPT_DISABLED_ERRORMSG = `userScripts APIs are currently experimental and must be enabled with the ${USERSCRIPT_PREFNAME} preference.`;
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "userScriptsEnabled",
+ USERSCRIPT_PREFNAME,
+ false
+);
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+Cu.importGlobalProperties(["crypto", "TextEncoder"]);
+
+var { DefaultMap, ExtensionError, getUniqueId } = ExtensionUtils;
+
+/**
+ * Represents a registered userScript in the child extension process.
+ *
+ * @param {ExtensionPageContextChild} context
+ * The extension context which has registered the user script.
+ * @param {string} scriptId
+ * An unique id that represents the registered user script
+ * (generated and used internally to identify it across the different processes).
+ */
+class UserScriptChild {
+ constructor({ context, scriptId, onScriptUnregister }) {
+ this.context = context;
+ this.scriptId = scriptId;
+ this.onScriptUnregister = onScriptUnregister;
+ this.unregistered = false;
+ }
+
+ async unregister() {
+ if (this.unregistered) {
+ throw new ExtensionError("User script already unregistered");
+ }
+
+ this.unregistered = true;
+
+ await this.context.childManager.callParentAsyncFunction(
+ "userScripts.unregister",
+ [this.scriptId]
+ );
+
+ this.context = null;
+
+ this.onScriptUnregister();
+ }
+
+ api() {
+ const { context } = this;
+
+ // Returns the RegisteredUserScript API object.
+ return {
+ unregister: () => {
+ return context.wrapPromise(this.unregister());
+ },
+ };
+ }
+}
+
+this.userScripts = class extends ExtensionAPI {
+ getAPI(context) {
+ // Cache of the script code already converted into blob urls:
+ // Map<textHash, blobURLs>
+ const blobURLsByHash = new Map();
+
+ // Keep track of the userScript that are sharing the same blob urls,
+ // so that we can revoke any blob url that is not used by a registered
+ // userScripts:
+ // Map<blobURL, Set<scriptId>>
+ const userScriptsByBlobURL = new DefaultMap(() => new Set());
+
+ function revokeBlobURLs(scriptId, options) {
+ let revokedUrls = new Set();
+
+ for (let url of options.js) {
+ if (userScriptsByBlobURL.has(url)) {
+ let scriptIds = userScriptsByBlobURL.get(url);
+ scriptIds.delete(scriptId);
+
+ if (scriptIds.size === 0) {
+ revokedUrls.add(url);
+ userScriptsByBlobURL.delete(url);
+ context.cloneScope.URL.revokeObjectURL(url);
+ }
+ }
+ }
+
+ // Remove all the removed urls from the map of known computed hashes.
+ for (let [hash, url] of blobURLsByHash) {
+ if (revokedUrls.has(url)) {
+ blobURLsByHash.delete(hash);
+ }
+ }
+ }
+
+ // Convert a script code string into a blob URL (and use a cached one
+ // if the script hash is already associated to a blob URL).
+ const getBlobURL = async (text, scriptId) => {
+ // Compute the hash of the js code string and reuse the blob url if we already have
+ // for the same hash.
+ const buffer = await crypto.subtle.digest(
+ "SHA-1",
+ new TextEncoder().encode(text)
+ );
+ const hash = String.fromCharCode(...new Uint16Array(buffer));
+
+ let blobURL = blobURLsByHash.get(hash);
+
+ if (blobURL) {
+ userScriptsByBlobURL.get(blobURL).add(scriptId);
+ return blobURL;
+ }
+
+ const blob = new context.cloneScope.Blob([text], {
+ type: "text/javascript",
+ });
+ blobURL = context.cloneScope.URL.createObjectURL(blob);
+
+ // Start to track this blob URL.
+ userScriptsByBlobURL.get(blobURL).add(scriptId);
+
+ blobURLsByHash.set(hash, blobURL);
+
+ return blobURL;
+ };
+
+ function convertToAPIObject(scriptId, options) {
+ const registeredScript = new UserScriptChild({
+ context,
+ scriptId,
+ onScriptUnregister: () => revokeBlobURLs(scriptId, options),
+ });
+
+ const scriptAPI = Cu.cloneInto(
+ registeredScript.api(),
+ context.cloneScope,
+ { cloneFunctions: true }
+ );
+ return scriptAPI;
+ }
+
+ // Revoke all the created blob urls once the context is destroyed.
+ context.callOnClose({
+ close() {
+ if (!context.cloneScope) {
+ return;
+ }
+
+ for (let blobURL of blobURLsByHash.values()) {
+ context.cloneScope.URL.revokeObjectURL(blobURL);
+ }
+ },
+ });
+
+ return {
+ userScripts: {
+ register(options) {
+ if (!userScriptsEnabled) {
+ throw new ExtensionError(USERSCRIPT_DISABLED_ERRORMSG);
+ }
+
+ let scriptId = getUniqueId();
+ return context.cloneScope.Promise.resolve().then(async () => {
+ options.scriptId = scriptId;
+ options.js = await Promise.all(
+ options.js.map(js => {
+ return js.file || getBlobURL(js.code, scriptId);
+ })
+ );
+
+ await context.childManager.callParentAsyncFunction(
+ "userScripts.register",
+ [options]
+ );
+
+ return convertToAPIObject(scriptId, options);
+ });
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/child/ext-webRequest.js b/toolkit/components/extensions/child/ext-webRequest.js
new file mode 100644
index 0000000000..49bdd3f232
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-webRequest.js
@@ -0,0 +1,119 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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";
+
+var { ExtensionError } = ExtensionUtils;
+
+this.webRequest = class extends ExtensionAPI {
+ STREAM_FILTER_INACTIVE_STATUSES = ["closed", "disconnected", "failed"];
+
+ hasActiveStreamFilter(filtersWeakSet) {
+ const iter = ChromeUtils.nondeterministicGetWeakSetKeys(filtersWeakSet);
+ for (let filter of iter) {
+ if (!this.STREAM_FILTER_INACTIVE_STATUSES.includes(filter.status)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ watchStreamFilterSuspendCancel({
+ context,
+ filters,
+ onSuspend,
+ onSuspendCanceled,
+ }) {
+ if (
+ !context.isBackgroundContext ||
+ context.extension.persistentBackground !== false
+ ) {
+ return;
+ }
+
+ const { extension } = context;
+ const cancelSuspendOnActiveStreamFilter = () =>
+ this.hasActiveStreamFilter(filters);
+ context.callOnClose({
+ close() {
+ extension.off(
+ "internal:stream-filter-suspend-cancel",
+ cancelSuspendOnActiveStreamFilter
+ );
+ extension.off("background-script-suspend", onSuspend);
+ extension.off("background-script-suspend-canceled", onSuspend);
+ },
+ });
+ extension.on(
+ "internal:stream-filter-suspend-cancel",
+ cancelSuspendOnActiveStreamFilter
+ );
+ extension.on("background-script-suspend", onSuspend);
+ extension.on("background-script-suspend-canceled", onSuspendCanceled);
+ }
+
+ getAPI(context) {
+ let filters = new WeakSet();
+
+ context.callOnClose({
+ close() {
+ for (let filter of ChromeUtils.nondeterministicGetWeakSetKeys(
+ filters
+ )) {
+ try {
+ filter.disconnect();
+ } catch (e) {
+ // Ignore.
+ }
+ }
+ },
+ });
+
+ let isSuspending = false;
+ this.watchStreamFilterSuspendCancel({
+ context,
+ filters,
+ onSuspend: () => (isSuspending = true),
+ onSuspendCanceled: () => (isSuspending = false),
+ });
+
+ function filterResponseData(requestId) {
+ if (isSuspending) {
+ throw new ExtensionError(
+ "filterResponseData method calls forbidden while background extension global is suspending"
+ );
+ }
+ requestId = parseInt(requestId, 10);
+
+ let streamFilter = context.cloneScope.StreamFilter.create(
+ requestId,
+ context.extension.id
+ );
+
+ filters.add(streamFilter);
+ return streamFilter;
+ }
+
+ const webRequest = {};
+
+ // For extensions with manifest_version >= 3, an additional webRequestFilterResponse permission
+ // is required to get access to the webRequest.filterResponseData API method.
+ if (
+ context.extension.manifestVersion < 3 ||
+ context.extension.hasPermission("webRequestFilterResponse")
+ ) {
+ webRequest.filterResponseData = filterResponseData;
+ } else {
+ webRequest.filterResponseData = () => {
+ throw new ExtensionError(
+ 'Missing required "webRequestFilterResponse" permission'
+ );
+ };
+ }
+
+ return { webRequest };
+ }
+};