summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/ConduitsParent.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/extensions/ConduitsParent.sys.mjs')
-rw-r--r--toolkit/components/extensions/ConduitsParent.sys.mjs471
1 files changed, 471 insertions, 0 deletions
diff --git a/toolkit/components/extensions/ConduitsParent.sys.mjs b/toolkit/components/extensions/ConduitsParent.sys.mjs
new file mode 100644
index 0000000000..5b307797e2
--- /dev/null
+++ b/toolkit/components/extensions/ConduitsParent.sys.mjs
@@ -0,0 +1,471 @@
+/* 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/. */
+
+/**
+ * This @file implements the parent side of Conduits, an abstraction over
+ * Fission IPC for extension Contexts, API managers, Ports/Messengers, and
+ * other types of "subjects" participating in implementation of extension APIs.
+ *
+ * Additionally, knowledge about conduits from all child processes is gathered
+ * here, and used together with the full CanonicalBrowsingContext tree to route
+ * IPC messages and queries directly to the right subjects.
+ *
+ * Each Conduit is tied to one subject, attached to a ConduitAddress descriptor,
+ * and exposes an API for sending/receiving via an actor, or multiple actors in
+ * case of the parent process.
+ *
+ * @typedef {number|string} ConduitID
+ *
+ * @typedef {object} ConduitAddress
+ * @property {ConduitID} id Globally unique across all processes.
+ * @property {string[]} [recv]
+ * @property {string[]} [send]
+ * @property {string[]} [query]
+ * @property {string[]} [cast]
+ * Lists of recvX, sendX, queryX and castX methods this subject will use.
+ *
+ * @typedef {"messenger"|"port"|"tab"} BroadcastKind
+ * Kinds of broadcast targeting filters.
+ *
+ * @example
+ * ```js
+ * {
+ * init(actor) {
+ * this.conduit = actor.openConduit(this, {
+ * id: this.id,
+ * recv: ["recvAddNumber"],
+ * send: ["sendNumberUpdate"],
+ * });
+ * },
+ *
+ * recvAddNumber({ num }, { actor, sender }) {
+ * num += 1;
+ * this.conduit.sendNumberUpdate(sender.id, { num });
+ * }
+ * }
+ * ```
+ */
+
+import { BaseConduit } from "resource://gre/modules/ConduitsChild.sys.mjs";
+import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
+import { WebNavigationFrames } from "resource://gre/modules/WebNavigationFrames.sys.mjs";
+
+const { DefaultWeakMap, ExtensionError } = ExtensionUtils;
+
+const BATCH_TIMEOUT_MS = 250;
+const ADDON_ENV = new Set(["addon_child", "devtools_child"]);
+
+/**
+ * Internal, keeps track of all parent and remote (child) conduits.
+ */
+const Hub = {
+ /** @type {Map<ConduitID, ConduitAddress>} Info about all child conduits. */
+ remotes: new Map(),
+
+ /** @type {Map<ConduitID, BroadcastConduit>} All open parent conduits. */
+ conduits: new Map(),
+
+ /** @type {Map<string, BroadcastConduit>} Parent conduits by recvMethod. */
+ byMethod: new Map(),
+
+ /** @type {WeakMap<ConduitsParent, Set<ConduitAddress>>} Conduits by actor. */
+ byActor: new DefaultWeakMap(() => new Set()),
+
+ /** @type {Map<string, BroadcastConduit>} */
+ reportOnClosed: new Map(),
+
+ /**
+ * Save info about a new parent conduit, register it as a global listener.
+ *
+ * @param {BroadcastConduit} conduit
+ */
+ openConduit(conduit) {
+ this.conduits.set(conduit.id, conduit);
+ for (let name of conduit.address.recv || []) {
+ if (this.byMethod.get(name)) {
+ // For now, we only allow one parent conduit handling each recv method.
+ throw new Error(`Duplicate BroadcastConduit method name recv${name}`);
+ }
+ this.byMethod.set(name, conduit);
+ }
+ },
+
+ /**
+ * Cleanup.
+ *
+ * @param {BroadcastConduit} conduit
+ */
+ closeConduit({ id, address }) {
+ this.conduits.delete(id);
+ for (let name of address.recv || []) {
+ this.byMethod.remove(name);
+ }
+ },
+
+ /**
+ * Confirm that a remote conduit comes from an extension background
+ * service worker.
+ *
+ * @see ExtensionPolicyService::CheckParentFrames
+ * @param {ConduitAddress} remote
+ * @returns {boolean}
+ */
+ verifyWorkerEnv({ actor, extensionId, workerScriptURL }) {
+ const addonPolicy = WebExtensionPolicy.getByID(extensionId);
+ if (!addonPolicy) {
+ throw new Error(`No WebExtensionPolicy found for ${extensionId}`);
+ }
+ if (actor.manager.remoteType !== addonPolicy.extension.remoteType) {
+ throw new Error(
+ `Bad ${extensionId} process: ${actor.manager.remoteType}`
+ );
+ }
+ if (!addonPolicy.isManifestBackgroundWorker(workerScriptURL)) {
+ throw new Error(
+ `Bad ${extensionId} background service worker script url: ${workerScriptURL}`
+ );
+ }
+ return true;
+ },
+
+ /**
+ * Confirm that a remote conduit comes from an extension page or
+ * an extension background service worker.
+ *
+ * @see ExtensionPolicyService::CheckParentFrames
+ * @param {ConduitAddress} remote
+ * @returns {boolean}
+ */
+ verifyEnv({ actor, envType, extensionId, ...rest }) {
+ if (!extensionId || !ADDON_ENV.has(envType)) {
+ return false;
+ }
+
+ // ProcessConduit related to a background service worker context.
+ if (actor.manager && actor.manager instanceof Ci.nsIDOMProcessParent) {
+ return this.verifyWorkerEnv({ actor, envType, extensionId, ...rest });
+ }
+
+ let windowGlobal = actor.manager;
+
+ while (windowGlobal) {
+ let { browsingContext: bc, documentPrincipal: prin } = windowGlobal;
+
+ if (prin.addonId !== extensionId) {
+ throw new Error(`Bad ${extensionId} principal: ${prin.URI.spec}`);
+ }
+ if (bc.currentRemoteType !== prin.addonPolicy.extension.remoteType) {
+ throw new Error(`Bad ${extensionId} process: ${bc.currentRemoteType}`);
+ }
+
+ if (!bc.parent) {
+ return true;
+ }
+ windowGlobal = bc.embedderWindowGlobal;
+ }
+ throw new Error(`Missing WindowGlobalParent for ${extensionId}`);
+ },
+
+ /**
+ * Fill in common address fields knowable from the parent process.
+ *
+ * @param {ConduitAddress} address
+ * @param {ConduitsParent} actor
+ */
+ fillInAddress(address, actor) {
+ address.actor = actor;
+ address.verified = this.verifyEnv(address);
+ if (JSWindowActorParent.isInstance(actor)) {
+ address.frameId = WebNavigationFrames.getFrameId(actor.browsingContext);
+ address.url = actor.manager.documentURI?.spec;
+ } else {
+ // Background service worker contexts do not have an associated frame
+ // and there is no browsingContext to retrieve the expected url from.
+ //
+ // WorkerContextChild sent in the address part of the ConduitOpened request
+ // the worker script URL as address.workerScriptURL, and so we can use that
+ // as the address.url too.
+ address.frameId = -1;
+ address.url = address.workerScriptURL;
+ }
+ },
+
+ /**
+ * Save info about a new remote conduit.
+ *
+ * @param {ConduitAddress} address
+ * @param {ConduitsParent} actor
+ */
+ recvConduitOpened(address, actor) {
+ this.fillInAddress(address, actor);
+ this.remotes.set(address.id, address);
+ this.byActor.get(actor).add(address);
+ },
+
+ /**
+ * Notifies listeners and cleans up after the remote conduit is closed.
+ *
+ * @param {ConduitAddress} remote
+ */
+ recvConduitClosed(remote) {
+ this.remotes.delete(remote.id);
+ this.byActor.get(remote.actor).delete(remote);
+
+ remote.actor = null;
+ for (let [key, conduit] of Hub.reportOnClosed.entries()) {
+ if (remote[key]) {
+ conduit.subject.recvConduitClosed(remote);
+ }
+ }
+ },
+
+ /**
+ * Close all remote conduits when the actor goes away.
+ *
+ * @param {ConduitsParent} actor
+ */
+ actorClosed(actor) {
+ for (let remote of this.byActor.get(actor)) {
+ // When a Port is closed, we notify the other side, but it might share
+ // an actor, so we shouldn't sendQeury() in that case (see bug 1623976).
+ this.remotes.delete(remote.id);
+ }
+ for (let remote of this.byActor.get(actor)) {
+ this.recvConduitClosed(remote);
+ }
+ this.byActor.delete(actor);
+ },
+};
+
+/**
+ * Parent side conduit, registers as a global listeners for certain messages,
+ * and can target specific child conduits when sending.
+ */
+export class BroadcastConduit extends BaseConduit {
+ /**
+ * @param {object} subject
+ * @param {ConduitAddress} address
+ */
+ constructor(subject, address) {
+ super(subject, address);
+
+ // Create conduit.castX() bidings.
+ for (let name of address.cast || []) {
+ this[`cast${name}`] = this._cast.bind(this, name);
+ }
+
+ // Wants to know when conduits with a specific attribute are closed.
+ // `subject.recvConduitClosed(address)` method will be called.
+ if (address.reportOnClosed) {
+ Hub.reportOnClosed.set(address.reportOnClosed, this);
+ }
+
+ this.open = true;
+ Hub.openConduit(this);
+ }
+
+ /**
+ * Internal, sends a message to a specific conduit, used by sendX stubs.
+ *
+ * @param {string} method
+ * @param {boolean} query
+ * @param {ConduitID} target
+ * @param {object?} arg
+ * @returns {Promise<any>}
+ */
+ _send(method, query, target, arg = {}) {
+ if (!this.open) {
+ throw new Error(`send${method} on closed conduit ${this.id}`);
+ }
+
+ let sender = this.id;
+ let { actor } = Hub.remotes.get(target);
+
+ if (method === "RunListener" && arg.path.startsWith("webRequest.")) {
+ return actor.batch(method, { target, arg, query, sender });
+ }
+ return super._send(method, query, actor, { target, arg, query, sender });
+ }
+
+ /**
+ * Broadcasts a method call to all conduits of kind that satisfy filtering by
+ * kind-specific properties from arg, returns an array of response promises.
+ *
+ * @param {string} method
+ * @param {BroadcastKind} kind
+ * @param {object} arg
+ * @returns {Promise[]}
+ */
+ _cast(method, kind, arg) {
+ let filters = {
+ // Target Ports by portId and side (connect caller/onConnect receiver).
+ port: remote =>
+ remote.portId === arg.portId &&
+ (arg.source == null || remote.source === arg.source),
+
+ // Target Messengers in extension pages by extensionId and envType.
+ messenger: r =>
+ r.verified &&
+ r.id !== arg.sender.contextId &&
+ r.extensionId === arg.extensionId &&
+ r.recv.includes(method) &&
+ // TODO: Bug 1453343 - get rid of this:
+ (r.envType === "addon_child" || arg.sender.envType !== "content_child"),
+
+ // Target Messengers by extensionId, tabId (topBC) and frameId.
+ tab: remote =>
+ remote.extensionId === arg.extensionId &&
+ remote.actor.manager.browsingContext?.top.id === arg.topBC &&
+ (arg.frameId == null || remote.frameId === arg.frameId) &&
+ remote.recv.includes(method),
+
+ // Target Messengers by extensionId.
+ extension: remote => remote.instanceId === arg.instanceId,
+ };
+
+ let targets = Array.from(Hub.remotes.values()).filter(filters[kind]);
+ let promises = targets.map(c => this._send(method, true, c.id, arg));
+
+ return arg.firstResponse
+ ? this._raceResponses(promises)
+ : Promise.allSettled(promises);
+ }
+
+ /**
+ * Custom Promise.race() function that ignores certain resolutions and errors.
+ *
+ * @param {Promise<response>[]} promises
+ * @returns {Promise<response?>}
+ */
+ _raceResponses(promises) {
+ return new Promise((resolve, reject) => {
+ let result;
+ promises.map(p =>
+ p
+ .then(value => {
+ if (value.response) {
+ // We have an explicit response, resolve immediately.
+ resolve(value);
+ } else if (value.received) {
+ // Message was received, but no response.
+ // Resolve with this only if there is no other explicit response.
+ result = value;
+ }
+ })
+ .catch(err => {
+ // Forward errors that are exposed to extension, but ignore
+ // internal errors such as actor destruction and DataCloneError.
+ if (err instanceof ExtensionError || err?.mozWebExtLocation) {
+ reject(err);
+ } else {
+ Cu.reportError(err);
+ }
+ })
+ );
+ // Ensure resolving when there are no responses.
+ Promise.allSettled(promises).then(() => resolve(result));
+ });
+ }
+
+ async close() {
+ this.open = false;
+ Hub.closeConduit(this);
+ }
+}
+
+/**
+ * Implements the parent side of the Conduits actor.
+ */
+export class ConduitsParent extends JSWindowActorParent {
+ constructor() {
+ super();
+ this.batchData = [];
+ this.batchPromise = null;
+ this.batchResolve = null;
+ this.timerActive = false;
+ }
+
+ /**
+ * Group webRequest events to send them as a batch, reducing IPC overhead.
+ *
+ * @param {string} name
+ * @param {MessageData} data
+ * @returns {Promise<object>}
+ */
+ batch(name, data) {
+ let pos = this.batchData.length;
+ this.batchData.push(data);
+
+ let sendNow = idleDispatch => {
+ if (this.batchData.length && this.manager) {
+ this.batchResolve(this.sendQuery(name, this.batchData));
+ } else {
+ this.batchResolve([]);
+ }
+ this.batchData = [];
+ this.timerActive = !idleDispatch;
+ };
+
+ if (!pos) {
+ this.batchPromise = new Promise(r => (this.batchResolve = r));
+ if (!this.timerActive) {
+ ChromeUtils.idleDispatch(sendNow, { timeout: BATCH_TIMEOUT_MS });
+ this.timerActive = true;
+ }
+ }
+
+ if (data.arg.urgentSend) {
+ // If this is an urgent blocking event, run this batch right away.
+ sendNow(false);
+ }
+
+ return this.batchPromise.then(results => results[pos]);
+ }
+
+ /**
+ * JSWindowActor method, routes the message to the target subject.
+ *
+ * @param {object} options
+ * @param {string} options.name
+ * @param {MessageData} options.data
+ * @returns {Promise?}
+ */
+ async receiveMessage({ name, data: { arg, query, sender } }) {
+ if (name === "ConduitOpened") {
+ return Hub.recvConduitOpened(arg, this);
+ }
+
+ sender = Hub.remotes.get(sender);
+ if (!sender || sender.actor !== this) {
+ throw new Error(`Unknown sender or wrong actor for recv${name}`);
+ }
+
+ if (name === "ConduitClosed") {
+ return Hub.recvConduitClosed(sender);
+ }
+
+ let conduit = Hub.byMethod.get(name);
+ if (!conduit) {
+ throw new Error(`Parent conduit for recv${name} not found`);
+ }
+
+ return conduit._recv(name, arg, { actor: this, query, sender });
+ }
+
+ /**
+ * JSWindowActor method, ensure cleanup.
+ */
+ didDestroy() {
+ Hub.actorClosed(this);
+ }
+}
+
+/**
+ * Parent side of the Conduits process actor. Same code as JSWindowActor.
+ */
+export class ProcessConduitsParent extends JSProcessActorParent {
+ receiveMessage = ConduitsParent.prototype.receiveMessage;
+ willDestroy = ConduitsParent.prototype.willDestroy;
+ didDestroy = ConduitsParent.prototype.didDestroy;
+}