diff options
Diffstat (limited to '')
-rw-r--r-- | toolkit/components/extensions/ExtensionCommon.sys.mjs | 3082 |
1 files changed, 3082 insertions, 0 deletions
diff --git a/toolkit/components/extensions/ExtensionCommon.sys.mjs b/toolkit/components/extensions/ExtensionCommon.sys.mjs new file mode 100644 index 0000000000..86c99042b6 --- /dev/null +++ b/toolkit/components/extensions/ExtensionCommon.sys.mjs @@ -0,0 +1,3082 @@ +/* -*- 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/. */ + +/** + * This module contains utilities and base classes for logic which is + * common between the parent and child process, and in particular + * between ExtensionParent.jsm and ExtensionChild.jsm. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ConsoleAPI: "resource://gre/modules/Console.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SchemaRoot: "resource://gre/modules/Schemas.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "styleSheetService", + "@mozilla.org/content/style-sheet-service;1", + "nsIStyleSheetService" +); + +const ScriptError = Components.Constructor( + "@mozilla.org/scripterror;1", + "nsIScriptError", + "initWithWindowID" +); + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +var { + DefaultMap, + DefaultWeakMap, + ExtensionError, + filterStack, + getInnerWindowID, + getUniqueId, +} = ExtensionUtils; + +function getConsole() { + return new lazy.ConsoleAPI({ + maxLogLevelPref: "extensions.webextensions.log.level", + prefix: "WebExtensions", + }); +} + +// Run a function and report exceptions. +function runSafeSyncWithoutClone(f, ...args) { + try { + return f(...args); + } catch (e) { + // This method is called with `this` unbound and it doesn't have + // access to a BaseContext instance and so we can't check if `e` + // is an instance of the extension context's Error constructor + // (like we do in BaseContext applySafeWithoutClone method). + dump( + `Extension error: ${e} ${e?.fileName} ${ + e?.lineNumber + }\n[[Exception stack\n${ + e?.stack ? filterStack(e) : undefined + }Current stack\n${filterStack(Error())}]]\n` + ); + Cu.reportError(e); + } +} + +// Return true if the given value is an instance of the given +// native type. +function instanceOf(value, type) { + return ( + value && + typeof value === "object" && + ChromeUtils.getClassName(value) === type + ); +} + +/** + * Convert any of several different representations of a date/time to a Date object. + * Accepts several formats: + * a Date object, an ISO8601 string, or a number of milliseconds since the epoch as + * either a number or a string. + * + * @param {Date|string|number} date + * The date to convert. + * @returns {Date} + * A Date object + */ +function normalizeTime(date) { + // Of all the formats we accept the "number of milliseconds since the epoch as a string" + // is an outlier, everything else can just be passed directly to the Date constructor. + return new Date( + typeof date == "string" && /^\d+$/.test(date) ? parseInt(date, 10) : date + ); +} + +function withHandlingUserInput(window, callable) { + let handle = window.windowUtils.setHandlingUserInput(true); + try { + return callable(); + } finally { + handle.destruct(); + } +} + +/** + * Defines a lazy getter for the given property on the given object. The + * first time the property is accessed, the return value of the getter + * is defined on the current `this` object with the given property name. + * Importantly, this means that a lazy getter defined on an object + * prototype will be invoked separately for each object instance that + * it's accessed on. + * + * Note: for better type inference, prefer redefineGetter() below. + * + * @param {object} object + * The prototype object on which to define the getter. + * @param {string | symbol} prop + * The property name for which to define the getter. + * @param {callback} getter + * The function to call in order to generate the final property + * value. + */ +function defineLazyGetter(object, prop, getter) { + Object.defineProperty(object, prop, { + enumerable: true, + configurable: true, + get() { + return redefineGetter(this, prop, getter.call(this), true); + }, + set(value) { + redefineGetter(this, prop, value, true); + }, + }); +} + +/** + * A more type-inference friendly version of defineLazyGetter() above. + * Call it from a real getter (and setter) for your class or object. + * On first run, it will redefine the property with the final value. + * + * @template Value + * @param {object} object + * @param {string | symbol} key + * @param {Value} value + * @returns {Value} + */ +function redefineGetter(object, key, value, writable = false) { + Object.defineProperty(object, key, { + enumerable: true, + configurable: true, + writable, + value, + }); + return value; +} + +function checkLoadURI(uri, principal, options) { + let ssm = Services.scriptSecurityManager; + + let flags = ssm.STANDARD; + if (!options.allowScript) { + flags |= ssm.DISALLOW_SCRIPT; + } + if (!options.allowInheritsPrincipal) { + flags |= ssm.DISALLOW_INHERIT_PRINCIPAL; + } + if (options.dontReportErrors) { + flags |= ssm.DONT_REPORT_ERRORS; + } + + try { + ssm.checkLoadURIWithPrincipal(principal, uri, flags); + } catch (e) { + return false; + } + return true; +} + +function checkLoadURL(url, principal, options) { + try { + return checkLoadURI(Services.io.newURI(url), principal, options); + } catch (e) { + return false; // newURI threw. + } +} + +function makeWidgetId(id) { + id = id.toLowerCase(); + // FIXME: This allows for collisions. + return id.replace(/[^a-z0-9_-]/g, "_"); +} + +/** + * A sentinel class to indicate that an array of values should be + * treated as an array when used as a promise resolution value, but as a + * spread expression (...args) when passed to a callback. + */ +class SpreadArgs extends Array { + constructor(args) { + super(); + this.push(...args); + } +} + +/** + * Like SpreadArgs, but also indicates that the array values already + * belong to the target compartment, and should not be cloned before + * being passed. + * + * The `unwrappedValues` property contains an Array object which belongs + * to the target compartment, and contains the same unwrapped values + * passed the NoCloneSpreadArgs constructor. + */ +class NoCloneSpreadArgs { + constructor(args) { + this.unwrappedValues = args; + } + + [Symbol.iterator]() { + return this.unwrappedValues[Symbol.iterator](); + } +} + +const LISTENERS = Symbol("listeners"); +const ONCE_MAP = Symbol("onceMap"); + +class EventEmitter { + constructor() { + this[LISTENERS] = new Map(); + this[ONCE_MAP] = new WeakMap(); + } + + /** + * Checks whether there is some listener for the given event. + * + * @param {string} event + * The name of the event to listen for. + * @returns {boolean} + */ + has(event) { + return this[LISTENERS].has(event); + } + + /** + * Adds the given function as a listener for the given event. + * + * The listener function may optionally return a Promise which + * resolves when it has completed all operations which event + * dispatchers may need to block on. + * + * @param {string} event + * The name of the event to listen for. + * @param {function(string, ...any): any} listener + * The listener to call when events are emitted. + */ + on(event, listener) { + let listeners = this[LISTENERS].get(event); + if (!listeners) { + listeners = new Set(); + this[LISTENERS].set(event, listeners); + } + + listeners.add(listener); + } + + /** + * Removes the given function as a listener for the given event. + * + * @param {string} event + * The name of the event to stop listening for. + * @param {function(string, ...any): any} listener + * The listener function to remove. + */ + off(event, listener) { + let set = this[LISTENERS].get(event); + if (set) { + set.delete(listener); + set.delete(this[ONCE_MAP].get(listener)); + if (!set.size) { + this[LISTENERS].delete(event); + } + } + } + + /** + * Adds the given function as a listener for the given event once. + * + * @param {string} event + * The name of the event to listen for. + * @param {function(string, ...any): any} listener + * The listener to call when events are emitted. + */ + once(event, listener) { + let wrapper = (event, ...args) => { + this.off(event, wrapper); + this[ONCE_MAP].delete(listener); + + return listener(event, ...args); + }; + this[ONCE_MAP].set(listener, wrapper); + + this.on(event, wrapper); + } + + /** + * Triggers all listeners for the given event. If any listeners return + * a value, returns a promise which resolves when all returned + * promises have resolved. Otherwise, returns undefined. + * + * @param {string} event + * The name of the event to emit. + * @param {any} args + * Arbitrary arguments to pass to the listener functions, after + * the event name. + * @returns {Promise?} + */ + emit(event, ...args) { + let listeners = this[LISTENERS].get(event); + + if (listeners) { + let promises = []; + + for (let listener of listeners) { + try { + let result = listener(event, ...args); + if (result !== undefined) { + promises.push(result); + } + } catch (e) { + Cu.reportError(e); + } + } + + if (promises.length) { + return Promise.all(promises); + } + } + } +} + +/** + * Base class for WebExtension APIs. Each API creates a new class + * that inherits from this class, the derived class is instantiated + * once for each extension that uses the API. + */ +class ExtensionAPI extends EventEmitter { + constructor(extension) { + super(); + + this.extension = extension; + + extension.once("shutdown", (what, isAppShutdown) => { + if (this.onShutdown) { + this.onShutdown(isAppShutdown); + } + this.extension = null; + }); + } + + destroy() {} + + /** @param {string} entryName */ + onManifestEntry(entryName) {} + + /** @param {boolean} isAppShutdown */ + onShutdown(isAppShutdown) {} + + /** @param {BaseContext} context */ + getAPI(context) { + throw new Error("Not Implemented"); + } + + /** @param {string} id */ + static onDisable(id) {} + + /** @param {string} id */ + static onUninstall(id) {} + + /** + * @param {string} id + * @param {Record<string, JSONValue>} manifest + */ + static onUpdate(id, manifest) {} +} + +/** + * Subclass to add APIs commonly used with persistent events. + * If a namespace uses events, it should use this subclass. + * + * this.apiNamespace = class extends ExtensionAPIPersistent {}; + */ +class ExtensionAPIPersistent extends ExtensionAPI { + /** @type {Record<string, callback>} */ + PERSISTENT_EVENTS; + + /** + * Check for event entry. + * + * @param {string} event The event name e.g. onStateChanged + * @returns {boolean} + */ + hasEventRegistrar(event) { + return ( + this.PERSISTENT_EVENTS && Object.hasOwn(this.PERSISTENT_EVENTS, event) + ); + } + + /** + * Get the event registration fuction + * + * @param {string} event The event name e.g. onStateChanged + * @returns {Function} register is used to start the listener + * register returns an object containing + * a convert and unregister function. + */ + getEventRegistrar(event) { + if (this.hasEventRegistrar(event)) { + return this.PERSISTENT_EVENTS[event].bind(this); + } + } + + /** + * Used when instantiating an EventManager instance to register the listener. + * + * @param {object} options Options used for event registration + * @param {BaseContext} options.context Extension Context passed when creating an EventManager instance. + * @param {string} options.event The eAPI vent name. + * @param {Function} options.fire The function passed to the listener to fire the event. + * @param {Array<any>} params An optional array of parameters received along with the + * addListener request. + * @returns {Function} The unregister function used in the EventManager. + */ + registerEventListener(options, params) { + const apiRegistar = this.getEventRegistrar(options.event); + return apiRegistar?.(options, params).unregister; + } + + /** + * Used to prime a listener for when the background script is not running. + * + * @param {string} event The event name e.g. onStateChanged or captiveURL.onChange. + * @param {Function} fire The function passed to the listener to fire the event. + * @param {Array} params Params passed to the event listener. + * @param {boolean} isInStartup unused here but passed for subclass use. + * @returns {object} the unregister and convert functions used in the EventManager. + */ + primeListener(event, fire, params, isInStartup) { + const apiRegistar = this.getEventRegistrar(event); + return apiRegistar?.({ fire, isInStartup }, params); + } +} + +/** + * This class contains the information we have about an individual + * extension. It is never instantiated directly, instead subclasses + * for each type of process extend this class and add members that are + * relevant for that process. + * + * @abstract + */ +class BaseContext { + /** @type {boolean} */ + isTopContext; + /** @type {string} */ + viewType; + + constructor(envType, extension) { + this.envType = envType; + this.onClose = new Set(); + this.checkedLastError = false; + this._lastError = null; + this.contextId = getUniqueId(); + this.unloaded = false; + this.extension = extension; + this.manifestVersion = extension.manifestVersion; + this.jsonSandbox = null; + this.active = true; + this.incognito = null; + this.messageManager = null; + this.contentWindow = null; + this.innerWindowID = 0; + + // These two properties are assigned in ContentScriptContextChild subclass + // to keep a copy of the content script sandbox Error and Promise globals + // (which are used by the WebExtensions internals) before any extension + // content script code had any chance to redefine them. + this.cloneScopeError = null; + this.cloneScopePromise = null; + } + + get isProxyContextParent() { + return false; + } + + get Error() { + // Return the copy stored in the context instance (when the context is an instance of + // ContentScriptContextChild or the global from extension page window otherwise). + return this.cloneScopeError || this.cloneScope.Error; + } + + get Promise() { + // Return the copy stored in the context instance (when the context is an instance of + // ContentScriptContextChild or the global from extension page window otherwise). + return this.cloneScopePromise || this.cloneScope.Promise; + } + + get privateBrowsingAllowed() { + return this.extension.privateBrowsingAllowed; + } + + get isBackgroundContext() { + if (this.viewType === "background") { + if (this.isProxyContextParent) { + return !!this.isTopContext; // Set in ExtensionPageContextParent. + } + const { contentWindow } = this; + return !!contentWindow && contentWindow.top === contentWindow; + } + return this.viewType === "background_worker"; + } + + /** + * Whether the extension context is using the WebIDL bindings for the + * WebExtensions APIs. + * To be overridden in subclasses (e.g. WorkerContextChild) and to be + * optionally used in ExtensionAPI classes to customize the behavior of the + * API when the calls to the extension API are originated from the WebIDL + * bindings. + */ + get useWebIDLBindings() { + return false; + } + + canAccessWindow(window) { + return this.extension.canAccessWindow(window); + } + + canAccessContainer(userContextId) { + return this.extension.canAccessContainer(userContextId); + } + + /** + * Opens a conduit linked to this context, populating related address fields. + * Only available in child contexts with an associated contentWindow. + * + * @param {object} subject + * @param {ConduitAddress} address + * @returns {import("ConduitsChild.sys.mjs").PointConduit} + * @type {ConduitOpen} + */ + openConduit(subject, address) { + let wgc = this.contentWindow.windowGlobalChild; + let conduit = wgc.getActor("Conduits").openConduit(subject, { + id: subject.id || getUniqueId(), + extensionId: this.extension.id, + envType: this.envType, + ...address, + }); + this.callOnClose(conduit); + conduit.setCloseCallback(() => { + this.forgetOnClose(conduit); + }); + return conduit; + } + + setContentWindow(contentWindow) { + if (!this.canAccessWindow(contentWindow)) { + throw new Error( + "BaseContext attempted to load when extension is not allowed due to incognito settings." + ); + } + + this.innerWindowID = getInnerWindowID(contentWindow); + this.messageManager = contentWindow.docShell.messageManager; + + if (this.incognito == null) { + this.incognito = + lazy.PrivateBrowsingUtils.isContentWindowPrivate(contentWindow); + } + + let wgc = contentWindow.windowGlobalChild; + Object.defineProperty(this, "active", { + configurable: true, + enumerable: true, + get: () => wgc.isCurrentGlobal && !wgc.windowContext.isInBFCache, + }); + Object.defineProperty(this, "contentWindow", { + configurable: true, + enumerable: true, + get: () => (this.active ? wgc.browsingContext.window : null), + }); + this.callOnClose({ + close: () => { + // Allow other "close" handlers to use these properties, until the next tick. + Promise.resolve().then(() => { + Object.defineProperty(this, "contentWindow", { value: null }); + Object.defineProperty(this, "active", { value: false }); + wgc = null; + }); + }, + }); + } + + // All child contexts must implement logActivity. This is handled if the child + // context subclasses ExtensionBaseContextChild. ProxyContextParent overrides + // this with a noop for parent contexts. + logActivity(type, name, data) { + throw new Error(`Not implemented for ${this.envType}`); + } + + /** @type {object} */ + get cloneScope() { + throw new Error("Not implemented"); + } + + /** @type {nsIPrincipal} */ + get principal() { + throw new Error("Not implemented"); + } + + runSafe(callback, ...args) { + return this.applySafe(callback, args); + } + + runSafeWithoutClone(callback, ...args) { + return this.applySafeWithoutClone(callback, args); + } + + applySafe(callback, args, caller) { + if (this.unloaded) { + Cu.reportError("context.runSafe called after context unloaded", caller); + } else if (!this.active) { + Cu.reportError( + "context.runSafe called while context is inactive", + caller + ); + } else { + try { + let { cloneScope } = this; + args = args.map(arg => Cu.cloneInto(arg, cloneScope)); + } catch (e) { + Cu.reportError(e); + dump( + `runSafe failure: cloning into ${ + this.cloneScope + }: ${e}\n\n${filterStack(Error())}` + ); + } + + return this.applySafeWithoutClone(callback, args, caller); + } + } + + applySafeWithoutClone(callback, args, caller) { + if (this.unloaded) { + Cu.reportError( + "context.runSafeWithoutClone called after context unloaded", + caller + ); + } else if (!this.active) { + Cu.reportError( + "context.runSafeWithoutClone called while context is inactive", + caller + ); + } else { + try { + return Reflect.apply(callback, null, args); + } catch (e) { + // An extension listener may as well be throwing an object that isn't + // an instance of Error, in that case we have to use fallbacks for the + // error message, fileName, lineNumber and columnNumber properties. + const isError = e instanceof this.Error; + let message; + let fileName; + let lineNumber; + let columnNumber; + + if (isError) { + message = `${e.name}: ${e.message}`; + lineNumber = e.lineNumber; + columnNumber = e.columnNumber; + fileName = e.fileName; + } else { + message = `uncaught exception: ${e}`; + + try { + // TODO(Bug 1810582): the following fallback logic may go away once + // we introduced a better way to capture and log the exception in + // the right window and in all cases (included when the extension + // code is raising undefined or an object that isn't an instance of + // the Error constructor). + // + // Fallbacks for the error location: + // - the callback location if it is registered directly from the + // extension code (and not wrapped by the child/ext-APINAMe.js + // implementation, like e.g. browser.storage, browser.devtools.network + // are doing and browser.menus). + // - if the location of the extension callback is not directly + // available (e.g. browser.storage onChanged events, and similarly + // for browser.devtools.network and browser.menus events): + // - the extension page url if the context is an extension page + // - the extension base url if the context is a content script + const cbLoc = Cu.getFunctionSourceLocation(callback); + fileName = cbLoc.filename; + lineNumber = cbLoc.lineNumber ?? lineNumber; + + const extBaseUrl = this.extension.baseURI.resolve("/"); + if (fileName.startsWith(extBaseUrl)) { + fileName = cbLoc.filename; + lineNumber = cbLoc.lineNumber ?? lineNumber; + } else { + fileName = this.contentWindow?.location?.href; + if (!fileName || !fileName.startsWith(extBaseUrl)) { + fileName = extBaseUrl; + } + } + } catch { + // Ignore errors on retrieving the callback source location. + } + } + + dump( + `Extension error: ${message} ${fileName} ${lineNumber}\n[[Exception stack\n${ + isError ? filterStack(e) : undefined + }Current stack\n${filterStack(Error())}]]\n` + ); + + // If the error is coming from an extension context associated + // to a window (e.g. an extension page or extension content script). + // + // TODO(Bug 1810574): for the background service worker we will need to do + // something similar, but not tied to the innerWindowID because there + // wouldn't be one set for extension contexts related to the + // background service worker. + // + // TODO(Bug 1810582): change the error associated to the innerWindowID to also + // include a full stack from the original error. + if (!this.isProxyContextParent && this.contentWindow) { + Services.console.logMessage( + new ScriptError( + message, + fileName, + null, + lineNumber, + columnNumber, + Ci.nsIScriptError.errorFlag, + "content javascript", + this.innerWindowID + ) + ); + } + // Also report the original error object (because it also includes + // the full error stack). + Cu.reportError(e); + } + } + } + + checkLoadURL(url, options = {}) { + // As an optimization, f the URL starts with the extension's base URL, + // don't do any further checks. It's always allowed to load it. + if (url.startsWith(this.extension.baseURL)) { + return true; + } + + return checkLoadURL(url, this.principal, options); + } + + /** + * Safely call JSON.stringify() on an object that comes from an + * extension. + * + * @param {[any, callback?, number?]} args for JSON.stringify() + * @returns {string} The stringified representation of obj + */ + jsonStringify(...args) { + if (!this.jsonSandbox) { + this.jsonSandbox = Cu.Sandbox(this.principal, { + sameZoneAs: this.cloneScope, + wantXrays: false, + }); + } + + return Cu.waiveXrays(this.jsonSandbox.JSON).stringify(...args); + } + + callOnClose(obj) { + this.onClose.add(obj); + } + + forgetOnClose(obj) { + this.onClose.delete(obj); + } + + get lastError() { + this.checkedLastError = true; + return this._lastError; + } + + set lastError(val) { + this.checkedLastError = false; + this._lastError = val; + } + + /** + * Normalizes the given error object for use by the target scope. If + * the target is an error object which belongs to that scope, it is + * returned as-is. If it is an ordinary object with a `message` + * property, it is converted into an error belonging to the target + * scope. If it is an Error object which does *not* belong to the + * clone scope, it is reported, and converted to an unexpected + * exception error. + * + * @param {Error|object} error + * @param {SavedFrame?} [caller] + * @returns {Error} + */ + normalizeError(error, caller) { + if (error instanceof this.Error) { + return error; + } + let message, fileName; + if (error && typeof error === "object") { + const isPlain = ChromeUtils.getClassName(error) === "Object"; + if (isPlain && error.mozWebExtLocation) { + caller = error.mozWebExtLocation; + } + if (isPlain && caller && (error.mozWebExtLocation || !error.fileName)) { + caller = Cu.cloneInto(caller, this.cloneScope); + return ChromeUtils.createError(error.message, caller); + } + + if ( + isPlain || + error instanceof ExtensionError || + this.principal.subsumes(Cu.getObjectPrincipal(error)) + ) { + message = error.message; + fileName = error.fileName; + } + } + + if (!message) { + Cu.reportError(error); + message = "An unexpected error occurred"; + } + return new this.Error(message, fileName); + } + + /** + * Sets the value of `.lastError` to `error`, calls the given + * callback, and reports an error if the value has not been checked + * when the callback returns. + * + * @param {object} error An object with a `message` property. May + * optionally be an `Error` object belonging to the target scope. + * @param {SavedFrame?} caller + * The optional caller frame which triggered this callback, to be used + * in error reporting. + * @param {Function} callback The callback to call. + * @returns {*} The return value of callback. + */ + withLastError(error, caller, callback) { + this.lastError = this.normalizeError(error); + try { + return callback(); + } finally { + if (!this.checkedLastError) { + Cu.reportError(`Unchecked lastError value: ${this.lastError}`, caller); + } + this.lastError = null; + } + } + + /** + * Captures the most recent stack frame which belongs to the extension. + * + * @returns {SavedFrame?} + */ + getCaller() { + return ChromeUtils.getCallerLocation(this.principal); + } + + /** + * Wraps the given promise so it can be safely returned to extension + * code in this context. + * + * If `callback` is provided, however, it is used as a completion + * function for the promise, and no promise is returned. In this case, + * the callback is called when the promise resolves or rejects. In the + * latter case, `lastError` is set to the rejection value, and the + * callback function must check `browser.runtime.lastError` or + * `extension.runtime.lastError` in order to prevent it being reported + * to the console. + * + * @param {Promise} promise The promise with which to wrap the + * callback. May resolve to a `SpreadArgs` instance, in which case + * each element will be used as a separate argument. + * + * Unless the promise object belongs to the cloneScope global, its + * resolution value is cloned into cloneScope prior to calling the + * `callback` function or resolving the wrapped promise. + * + * @param {Function} [callback] The callback function to wrap + * + * @returns {Promise|undefined} If callback is null, a promise object + * belonging to the target scope. Otherwise, undefined. + */ + wrapPromise(promise, callback = null) { + let caller = this.getCaller(); + let applySafe = this.applySafe.bind(this); + if (Cu.getGlobalForObject(promise) === this.cloneScope) { + applySafe = this.applySafeWithoutClone.bind(this); + } + + if (callback) { + promise.then( + args => { + if (this.unloaded) { + Cu.reportError(`Promise resolved after context unloaded\n`, caller); + } else if (!this.active) { + Cu.reportError( + `Promise resolved while context is inactive\n`, + caller + ); + } else if (args instanceof NoCloneSpreadArgs) { + this.applySafeWithoutClone(callback, args.unwrappedValues, caller); + } else if (args instanceof SpreadArgs) { + applySafe(callback, args, caller); + } else { + applySafe(callback, [args], caller); + } + }, + error => { + this.withLastError(error, caller, () => { + if (this.unloaded) { + Cu.reportError( + `Promise rejected after context unloaded\n`, + caller + ); + } else if (!this.active) { + Cu.reportError( + `Promise rejected while context is inactive\n`, + caller + ); + } else { + this.applySafeWithoutClone(callback, [], caller); + } + }); + } + ); + } else { + return new this.Promise((resolve, reject) => { + promise.then( + value => { + if (this.unloaded) { + Cu.reportError( + `Promise resolved after context unloaded\n`, + caller + ); + } else if (!this.active) { + Cu.reportError( + `Promise resolved while context is inactive\n`, + caller + ); + } else if (value instanceof NoCloneSpreadArgs) { + let values = value.unwrappedValues; + this.applySafeWithoutClone( + resolve, + values.length == 1 ? [values[0]] : [values], + caller + ); + } else if (value instanceof SpreadArgs) { + applySafe(resolve, value.length == 1 ? value : [value], caller); + } else { + applySafe(resolve, [value], caller); + } + }, + value => { + if (this.unloaded) { + Cu.reportError( + `Promise rejected after context unloaded: ${ + value && value.message + }\n`, + caller + ); + } else if (!this.active) { + Cu.reportError( + `Promise rejected while context is inactive: ${ + value && value.message + }\n`, + caller + ); + } else { + this.applySafeWithoutClone( + reject, + [this.normalizeError(value, caller)], + caller + ); + } + } + ); + }); + } + } + + unload() { + this.unloaded = true; + + for (let obj of this.onClose) { + obj.close(); + } + this.onClose.clear(); + } + + /** + * A simple proxy for unload(), for use with callOnClose(). + */ + close() { + this.unload(); + } +} + +/** + * An object that runs the implementation of a schema API. Instantiations of + * this interfaces are used by Schemas.jsm. + * + * @interface + */ +class SchemaAPIInterface { + /** + * Calls this as a function that returns its return value. + * + * @abstract + * @param {Array} args The parameters for the function. + * @returns {*} The return value of the invoked function. + */ + callFunction(args) { + throw new Error("Not implemented"); + } + + /** + * Calls this as a function and ignores its return value. + * + * @abstract + * @param {Array} args The parameters for the function. + */ + callFunctionNoReturn(args) { + throw new Error("Not implemented"); + } + + /** + * Calls this as a function that completes asynchronously. + * + * @abstract + * @param {Array} args The parameters for the function. + * @param {callback} [callback] The callback to be called when the function + * completes. + * @param {boolean} [requireUserInput=false] If true, the function should + * fail if the browser is not currently handling user input. + * @returns {Promise|undefined} Must be void if `callback` is set, and a + * promise otherwise. The promise is resolved when the function completes. + */ + callAsyncFunction(args, callback, requireUserInput = false) { + throw new Error("Not implemented"); + } + + /** + * Retrieves the value of this as a property. + * + * @abstract + * @returns {*} The value of the property. + */ + getProperty() { + throw new Error("Not implemented"); + } + + /** + * Assigns the value to this as property. + * + * @abstract + * @param {string} value The new value of the property. + */ + setProperty(value) { + throw new Error("Not implemented"); + } + + /** + * Registers a `listener` to this as an event. + * + * @abstract + * @param {Function} listener The callback to be called when the event fires. + * @param {Array} args Extra parameters for EventManager.addListener. + * @see EventManager.addListener + */ + addListener(listener, args) { + throw new Error("Not implemented"); + } + + /** + * Checks whether `listener` is listening to this as an event. + * + * @abstract + * @param {Function} listener The event listener. + * @returns {boolean} Whether `listener` is registered with this as an event. + * @see EventManager.hasListener + */ + hasListener(listener) { + throw new Error("Not implemented"); + } + + /** + * Unregisters `listener` from this as an event. + * + * @abstract + * @param {Function} listener The event listener. + * @see EventManager.removeListener + */ + removeListener(listener) { + throw new Error("Not implemented"); + } + + /** + * Revokes the implementation object, and prevents any further method + * calls from having external effects. + * + * @abstract + */ + revoke() { + throw new Error("Not implemented"); + } +} + +/** + * An object that runs a locally implemented API. + */ +class LocalAPIImplementation extends SchemaAPIInterface { + /** + * Constructs an implementation of the `name` method or property of `pathObj`. + * + * @param {object} pathObj The object containing the member with name `name`. + * @param {string} name The name of the implemented member. + * @param {BaseContext} context The context in which the schema is injected. + */ + constructor(pathObj, name, context) { + super(); + this.pathObj = pathObj; + this.name = name; + this.context = context; + } + + revoke() { + if (this.pathObj[this.name][lazy.Schemas.REVOKE]) { + this.pathObj[this.name][lazy.Schemas.REVOKE](); + } + + this.pathObj = null; + this.name = null; + this.context = null; + } + + callFunction(args) { + try { + return this.pathObj[this.name](...args); + } catch (e) { + throw this.context.normalizeError(e); + } + } + + callFunctionNoReturn(args) { + try { + this.pathObj[this.name](...args); + } catch (e) { + throw this.context.normalizeError(e); + } + } + + callAsyncFunction(args, callback, requireUserInput) { + let promise; + try { + if (requireUserInput) { + if (!this.context.contentWindow.windowUtils.isHandlingUserInput) { + throw new ExtensionError( + `${this.name} may only be called from a user input handler` + ); + } + } + promise = this.pathObj[this.name](...args) || Promise.resolve(); + } catch (e) { + promise = Promise.reject(e); + } + return this.context.wrapPromise(promise, callback); + } + + getProperty() { + return this.pathObj[this.name]; + } + + setProperty(value) { + this.pathObj[this.name] = value; + } + + addListener(listener, args) { + try { + this.pathObj[this.name].addListener.call(null, listener, ...args); + } catch (e) { + throw this.context.normalizeError(e); + } + } + + hasListener(listener) { + return this.pathObj[this.name].hasListener.call(null, listener); + } + + removeListener(listener) { + this.pathObj[this.name].removeListener.call(null, listener); + } +} + +// Recursively copy properties from source to dest. +function deepCopy(dest, source) { + for (let prop in source) { + let desc = Object.getOwnPropertyDescriptor(source, prop); + if (typeof desc.value == "object") { + if (!(prop in dest)) { + dest[prop] = {}; + } + deepCopy(dest[prop], source[prop]); + } else { + Object.defineProperty(dest, prop, desc); + } + } +} + +function getChild(map, key) { + let child = map.children.get(key); + if (!child) { + child = { + modules: new Set(), + children: new Map(), + }; + + map.children.set(key, child); + } + return child; +} + +function getPath(map, path) { + for (let key of path) { + map = getChild(map, key); + } + return map; +} + +function mergePaths(dest, source) { + for (let name of source.modules) { + dest.modules.add(name); + } + + for (let [name, child] of source.children.entries()) { + mergePaths(getChild(dest, name), child); + } +} + +/** + * Manages loading and accessing a set of APIs for a specific extension + * context. + * + * @param {BaseContext} context + * The context to manage APIs for. + * @param {SchemaAPIManager} apiManager + * The API manager holding the APIs to manage. + * @param {object} root + * The root object into which APIs will be injected. + */ +class CanOfAPIs { + constructor(context, apiManager, root) { + this.context = context; + this.scopeName = context.envType; + this.apiManager = apiManager; + this.root = root; + + this.apiPaths = new Map(); + + this.apis = new Map(); + } + + /** + * Synchronously loads and initializes an ExtensionAPI instance. + * + * @param {string} name + * The name of the API to load. + */ + loadAPI(name) { + if (this.apis.has(name)) { + return; + } + + let { extension } = this.context; + + let api = this.apiManager.getAPI(name, extension, this.scopeName); + if (!api) { + return; + } + + this.apis.set(name, api); + + deepCopy(this.root, api.getAPI(this.context)); + } + + /** + * Asynchronously loads and initializes an ExtensionAPI instance. + * + * @param {string} name + * The name of the API to load. + */ + async asyncLoadAPI(name) { + if (this.apis.has(name)) { + return; + } + + let { extension } = this.context; + if (!lazy.Schemas.checkPermissions(name, extension)) { + return; + } + + let api = await this.apiManager.asyncGetAPI( + name, + extension, + this.scopeName + ); + // Check again, because async; + if (this.apis.has(name)) { + return; + } + + this.apis.set(name, api); + + deepCopy(this.root, api.getAPI(this.context)); + } + + /** + * Finds the API at the given path from the root object, and + * synchronously loads the API that implements it if it has not + * already been loaded. + * + * @param {string} path + * The "."-separated path to find. + * @returns {*} + */ + findAPIPath(path) { + if (this.apiPaths.has(path)) { + return this.apiPaths.get(path); + } + + let obj = this.root; + let modules = this.apiManager.modulePaths; + + let parts = path.split("."); + for (let [i, key] of parts.entries()) { + if (!obj) { + return; + } + modules = getChild(modules, key); + + for (let name of modules.modules) { + if (!this.apis.has(name)) { + this.loadAPI(name); + } + } + + if (!(key in obj) && i < parts.length - 1) { + obj[key] = {}; + } + obj = obj[key]; + } + + this.apiPaths.set(path, obj); + return obj; + } + + /** + * Finds the API at the given path from the root object, and + * asynchronously loads the API that implements it if it has not + * already been loaded. + * + * @param {string} path + * The "."-separated path to find. + * @returns {Promise<*>} + */ + async asyncFindAPIPath(path) { + if (this.apiPaths.has(path)) { + return this.apiPaths.get(path); + } + + let obj = this.root; + let modules = this.apiManager.modulePaths; + + let parts = path.split("."); + for (let [i, key] of parts.entries()) { + if (!obj) { + return; + } + modules = getChild(modules, key); + + for (let name of modules.modules) { + if (!this.apis.has(name)) { + await this.asyncLoadAPI(name); + } + } + + if (!(key in obj) && i < parts.length - 1) { + obj[key] = {}; + } + + if (typeof obj[key] === "function") { + obj = obj[key].bind(obj); + } else { + obj = obj[key]; + } + } + + this.apiPaths.set(path, obj); + return obj; + } +} + +/** + * @class APIModule + * @abstract + * + * @property {string} url + * The URL of the script which contains the module's + * implementation. This script must define a global property + * matching the modules name, which must be a class constructor + * which inherits from {@link ExtensionAPI}. + * + * @property {string} schema + * The URL of the JSON schema which describes the module's API. + * + * @property {Array<string>} scopes + * The list of scope names into which the API may be loaded. + * + * @property {Array<string>} manifest + * The list of top-level manifest properties which will trigger + * the module to be loaded, and its `onManifestEntry` method to be + * called. + * + * @property {Array<string>} events + * The list events which will trigger the module to be loaded, and + * its appropriate event handler method to be called. Currently + * only accepts "startup". + * + * @property {Array<string>} permissions + * An optional list of permissions, any of which must be present + * in order for the module to load. + * + * @property {Array<Array<string>>} paths + * A list of paths from the root API object which, when accessed, + * will cause the API module to be instantiated and injected. + */ + +/** + * This object loads the ext-*.js scripts that define the extension API. + * + * This class instance is shared with the scripts that it loads, so that the + * ext-*.js scripts and the instantiator can communicate with each other. + */ +class SchemaAPIManager extends EventEmitter { + /** + * @param {string} processType + * "main" - The main, one and only chrome browser process. + * "addon" - An addon process. + * "content" - A content process. + * "devtools" - A devtools process. + * @param {import("Schemas.sys.mjs").SchemaRoot} [schema] + */ + constructor(processType, schema) { + super(); + this.processType = processType; + this.global = null; + if (schema) { + this.schema = schema; + } + + this.modules = new Map(); + this.modulePaths = { children: new Map(), modules: new Set() }; + this.manifestKeys = new Map(); + this.eventModules = new DefaultMap(() => new Set()); + this.settingsModules = new Set(); + + this._modulesJSONLoaded = false; + + this.schemaURLs = new Map(); + + this.apis = new DefaultWeakMap(() => new Map()); + + this._scriptScopes = []; + } + + onStartup(extension) { + let promises = []; + for (let apiName of this.eventModules.get("startup")) { + promises.push( + extension.apiManager.asyncGetAPI(apiName, extension).then(api => { + if (api) { + api.onStartup(); + } + }) + ); + } + + return Promise.all(promises); + } + + async loadModuleJSON(urls) { + let promises = urls.map(url => fetch(url).then(resp => resp.json())); + + return this.initModuleJSON(await Promise.all(promises)); + } + + initModuleJSON(blobs) { + for (let json of blobs) { + this.registerModules(json); + } + + this._modulesJSONLoaded = true; + + return new StructuredCloneHolder("SchemaAPIManager/initModuleJSON", null, { + modules: this.modules, + modulePaths: this.modulePaths, + manifestKeys: this.manifestKeys, + eventModules: this.eventModules, + settingsModules: this.settingsModules, + schemaURLs: this.schemaURLs, + }); + } + + initModuleData(moduleData) { + if (!this._modulesJSONLoaded) { + let data = moduleData.deserialize({}, true); + + this.modules = data.modules; + this.modulePaths = data.modulePaths; + this.manifestKeys = data.manifestKeys; + this.eventModules = new DefaultMap(() => new Set(), data.eventModules); + this.settingsModules = new Set(data.settingsModules); + this.schemaURLs = data.schemaURLs; + } + + this._modulesJSONLoaded = true; + } + + /** + * Registers a set of ExtensionAPI modules to be lazily loaded and + * managed by this manager. + * + * @param {object} obj + * An object containing property for eacy API module to be + * registered. Each value should be an object implementing the + * APIModule interface. + */ + registerModules(obj) { + for (let [name, details] of Object.entries(obj)) { + details.namespaceName = name; + + if (this.modules.has(name)) { + throw new Error(`Module '${name}' already registered`); + } + this.modules.set(name, details); + + if (details.schema) { + let content = + details.scopes && + (details.scopes.includes("content_parent") || + details.scopes.includes("content_child")); + this.schemaURLs.set(details.schema, { content }); + } + + for (let event of details.events || []) { + this.eventModules.get(event).add(name); + } + + if (details.settings) { + this.settingsModules.add(name); + } + + for (let key of details.manifest || []) { + if (this.manifestKeys.has(key)) { + throw new Error( + `Manifest key '${key}' already registered by '${this.manifestKeys.get( + key + )}'` + ); + } + + this.manifestKeys.set(key, name); + } + + for (let path of details.paths || []) { + getPath(this.modulePaths, path).modules.add(name); + } + } + } + + /** + * Emits an `onManifestEntry` event for the top-level manifest entry + * on all relevant {@link ExtensionAPI} instances for the given + * extension. + * + * The API modules will be synchronously loaded if they have not been + * loaded already. + * + * @param {Extension} extension + * The extension for which to emit the events. + * @param {string} entry + * The name of the top-level manifest entry. + * + * @returns {*} + */ + emitManifestEntry(extension, entry) { + let apiName = this.manifestKeys.get(entry); + if (apiName) { + let api = extension.apiManager.getAPI(apiName, extension); + return api.onManifestEntry(entry); + } + } + /** + * Emits an `onManifestEntry` event for the top-level manifest entry + * on all relevant {@link ExtensionAPI} instances for the given + * extension. + * + * The API modules will be asynchronously loaded if they have not been + * loaded already. + * + * @param {Extension} extension + * The extension for which to emit the events. + * @param {string} entry + * The name of the top-level manifest entry. + * + * @returns {Promise<*>} + */ + async asyncEmitManifestEntry(extension, entry) { + let apiName = this.manifestKeys.get(entry); + if (apiName) { + let api = await extension.apiManager.asyncGetAPI(apiName, extension); + return api.onManifestEntry(entry); + } + } + + /** + * Returns the {@link ExtensionAPI} instance for the given API module, + * for the given extension, in the given scope, synchronously loading + * and instantiating it if necessary. + * + * @param {string} name + * The name of the API module to load. + * @param {Extension} extension + * The extension for which to load the API. + * @param {string} [scope = null] + * The scope type for which to retrieve the API, or null if not + * being retrieved for a particular scope. + * + * @returns {ExtensionAPI?} + */ + getAPI(name, extension, scope = null) { + if (!this._checkGetAPI(name, extension, scope)) { + return; + } + + let apis = this.apis.get(extension); + if (apis.has(name)) { + return apis.get(name); + } + + let module = this.loadModule(name); + + let api = new module(extension); + apis.set(name, api); + return api; + } + /** + * Returns the {@link ExtensionAPI} instance for the given API module, + * for the given extension, in the given scope, asynchronously loading + * and instantiating it if necessary. + * + * @param {string} name + * The name of the API module to load. + * @param {Extension} extension + * The extension for which to load the API. + * @param {string} [scope = null] + * The scope type for which to retrieve the API, or null if not + * being retrieved for a particular scope. + * + * @returns {Promise<ExtensionAPI>?} + */ + async asyncGetAPI(name, extension, scope = null) { + if (!this._checkGetAPI(name, extension, scope)) { + return; + } + + let apis = this.apis.get(extension); + if (apis.has(name)) { + return apis.get(name); + } + + let module = await this.asyncLoadModule(name); + + // Check again, because async. + if (apis.has(name)) { + return apis.get(name); + } + + let api = new module(extension); + apis.set(name, api); + return api; + } + + /** + * Synchronously loads an API module, if not already loaded, and + * returns its ExtensionAPI constructor. + * + * @param {string} name + * The name of the module to load. + * @returns {typeof ExtensionAPI} + */ + loadModule(name) { + let module = this.modules.get(name); + if (module.loaded) { + return this.global[name]; + } + + this._checkLoadModule(module, name); + + this.initGlobal(); + + Services.scriptloader.loadSubScript(module.url, this.global); + + module.loaded = true; + + return this.global[name]; + } + /** + * aSynchronously loads an API module, if not already loaded, and + * returns its ExtensionAPI constructor. + * + * @param {string} name + * The name of the module to load. + * + * @returns {Promise<typeof ExtensionAPI>} + */ + asyncLoadModule(name) { + let module = this.modules.get(name); + if (module.loaded) { + return Promise.resolve(this.global[name]); + } + if (module.asyncLoaded) { + return module.asyncLoaded; + } + + this._checkLoadModule(module, name); + + module.asyncLoaded = ChromeUtils.compileScript(module.url).then(script => { + this.initGlobal(); + script.executeInGlobal(this.global); + + module.loaded = true; + + return this.global[name]; + }); + + return module.asyncLoaded; + } + + asyncLoadSettingsModules() { + return Promise.all( + Array.from(this.settingsModules).map(apiName => + this.asyncLoadModule(apiName) + ) + ); + } + + getModule(name) { + return this.modules.get(name); + } + + /** + * Checks whether the given API module may be loaded for the given + * extension, in the given scope. + * + * @param {string} name + * The name of the API module to check. + * @param {Extension} extension + * The extension for which to check the API. + * @param {string} [scope = null] + * The scope type for which to check the API, or null if not + * being checked for a particular scope. + * + * @returns {boolean} + * Whether the module may be loaded. + */ + _checkGetAPI(name, extension, scope = null) { + let module = this.getModule(name); + if (!module) { + // A module may not exist for a particular manifest version, but + // we allow keys in the manifest. An example is pageAction. + return false; + } + + if ( + module.permissions && + !module.permissions.some(perm => extension.hasPermission(perm)) + ) { + return false; + } + + if (!scope) { + return true; + } + + if (!module.scopes.includes(scope)) { + return false; + } + + if (!lazy.Schemas.checkPermissions(module.namespaceName, extension)) { + return false; + } + + return true; + } + + _checkLoadModule(module, name) { + if (!module) { + throw new Error(`Module '${name}' does not exist`); + } + if (module.asyncLoaded) { + throw new Error(`Module '${name}' currently being lazily loaded`); + } + if (this.global && this.global[name]) { + throw new Error( + `Module '${name}' conflicts with existing global property` + ); + } + } + + /** + * Create a global object that is used as the shared global for all ext-*.js + * scripts that are loaded via `loadScript`. + * + * @returns {object} A sandbox that is used as the global by `loadScript`. + */ + _createExtGlobal() { + let global = Cu.Sandbox( + Services.scriptSecurityManager.getSystemPrincipal(), + { + wantXrays: false, + wantGlobalProperties: ["ChromeUtils"], + sandboxName: `Namespace of ext-*.js scripts for ${this.processType} (from: resource://gre/modules/ExtensionCommon.jsm)`, + } + ); + + Object.assign(global, { + AppConstants, + Cc, + ChromeWorker, + Ci, + Cr, + Cu, + ExtensionAPI, + ExtensionAPIPersistent, + ExtensionCommon, + IOUtils, + MatchGlob, + MatchPattern, + MatchPatternSet, + PathUtils, + Services, + StructuredCloneHolder, + WebExtensionPolicy, + XPCOMUtils, + extensions: this, + global, + }); + + ChromeUtils.defineLazyGetter(global, "console", getConsole); + // eslint-disable-next-line mozilla/lazy-getter-object-name + ChromeUtils.defineESModuleGetters(global, { + ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs", + }); + + return global; + } + + initGlobal() { + if (!this.global) { + this.global = this._createExtGlobal(); + } + } + + /** + * Load an ext-*.js script. The script runs in its own scope, if it wishes to + * share state with another script it can assign to the `global` variable. If + * it wishes to communicate with this API manager, use `extensions`. + * + * @param {string} scriptUrl The URL of the ext-*.js script. + */ + loadScript(scriptUrl) { + // Create the object in the context of the sandbox so that the script runs + // in the sandbox's context instead of here. + let scope = Cu.createObjectIn(this.global); + + Services.scriptloader.loadSubScript(scriptUrl, scope); + + // Save the scope to avoid it being garbage collected. + this._scriptScopes.push(scope); + } +} + +class LazyAPIManager extends SchemaAPIManager { + constructor(processType, moduleData, schemaURLs) { + super(processType); + + /** @type {Promise | boolean} */ + this.initialized = false; + + this.initModuleData(moduleData); + + this.schemaURLs = schemaURLs; + } + + lazyInit() {} +} + +defineLazyGetter(LazyAPIManager.prototype, "schema", function () { + let root = new lazy.SchemaRoot(lazy.Schemas.rootSchema, this.schemaURLs); + root.parseSchemas(); + return root; +}); + +class MultiAPIManager extends SchemaAPIManager { + constructor(processType, children) { + super(processType); + + this.initialized = false; + + this.children = children; + } + + async lazyInit() { + if (!this.initialized) { + this.initialized = true; + + for (let child of this.children) { + if (child.lazyInit) { + let res = child.lazyInit(); + if (res && typeof res.then === "function") { + await res; + } + } + + mergePaths(this.modulePaths, child.modulePaths); + } + } + } + + onStartup(extension) { + return Promise.all(this.children.map(child => child.onStartup(extension))); + } + + getModule(name) { + for (let child of this.children) { + if (child.modules.has(name)) { + return child.modules.get(name); + } + } + } + + loadModule(name) { + for (let child of this.children) { + if (child.modules.has(name)) { + return child.loadModule(name); + } + } + } + + asyncLoadModule(name) { + for (let child of this.children) { + if (child.modules.has(name)) { + return child.asyncLoadModule(name); + } + } + } +} + +defineLazyGetter(MultiAPIManager.prototype, "schema", function () { + let bases = this.children.map(child => child.schema); + + // All API manager schema roots should derive from the global schema root, + // so it doesn't need its own entry. + if (bases[bases.length - 1] === lazy.Schemas) { + bases.pop(); + } + + if (bases.length === 1) { + bases = bases[0]; + } + return new lazy.SchemaRoot(bases, new Map()); +}); + +export function LocaleData(data) { + this.defaultLocale = data.defaultLocale; + this.selectedLocale = data.selectedLocale; + this.locales = data.locales || new Map(); + this.warnedMissingKeys = new Set(); + + // Map(locale-name -> Map(message-key -> localized-string)) + // + // Contains a key for each loaded locale, each of which is a + // Map of message keys to their localized strings. + this.messages = data.messages || new Map(); + + if (data.builtinMessages) { + this.messages.set(this.BUILTIN, data.builtinMessages); + } +} + +LocaleData.prototype = { + // Representation of the object to send to content processes. This + // should include anything the content process might need. + serialize() { + return { + defaultLocale: this.defaultLocale, + selectedLocale: this.selectedLocale, + messages: this.messages, + locales: this.locales, + }; + }, + + BUILTIN: "@@BUILTIN_MESSAGES", + + has(locale) { + return this.messages.has(locale); + }, + + // https://developer.chrome.com/extensions/i18n + localizeMessage(message, substitutions = [], options = {}) { + let defaultOptions = { + defaultValue: "", + cloneScope: null, + }; + + let locales = this.availableLocales; + if (options.locale) { + locales = new Set( + [this.BUILTIN, options.locale, this.defaultLocale].filter(locale => + this.messages.has(locale) + ) + ); + } + + options = Object.assign(defaultOptions, options); + + // Message names are case-insensitive, so normalize them to lower-case. + message = message.toLowerCase(); + for (let locale of locales) { + let messages = this.messages.get(locale); + if (messages.has(message)) { + let str = messages.get(message); + + if (!str.includes("$")) { + return str; + } + + if (!Array.isArray(substitutions)) { + substitutions = [substitutions]; + } + + let replacer = (matched, index, dollarSigns) => { + if (index) { + // This is not quite Chrome-compatible. Chrome consumes any number + // of digits following the $, but only accepts 9 substitutions. We + // accept any number of substitutions. + index = parseInt(index, 10) - 1; + return index in substitutions ? substitutions[index] : ""; + } + // For any series of contiguous `$`s, the first is dropped, and + // the rest remain in the output string. + return dollarSigns; + }; + return str.replace(/\$(?:([1-9]\d*)|(\$+))/g, replacer); + } + } + + // Check for certain pre-defined messages. + if (message == "@@ui_locale") { + return this.uiLocale; + } else if (message.startsWith("@@bidi_")) { + let rtl = Services.locale.isAppLocaleRTL; + + if (message == "@@bidi_dir") { + return rtl ? "rtl" : "ltr"; + } else if (message == "@@bidi_reversed_dir") { + return rtl ? "ltr" : "rtl"; + } else if (message == "@@bidi_start_edge") { + return rtl ? "right" : "left"; + } else if (message == "@@bidi_end_edge") { + return rtl ? "left" : "right"; + } + } + + if (!this.warnedMissingKeys.has(message)) { + let error = `Unknown localization message ${message}`; + if (options.cloneScope) { + error = new options.cloneScope.Error(error); + } + Cu.reportError(error); + this.warnedMissingKeys.add(message); + } + return options.defaultValue; + }, + + // Localize a string, replacing all |__MSG_(.*)__| tokens with the + // matching string from the current locale, as determined by + // |this.selectedLocale|. + // + // This may not be called before calling either |initLocale| or + // |initAllLocales|. + localize(str, locale = this.selectedLocale) { + if (!str) { + return str; + } + + return str.replace(/__MSG_([A-Za-z0-9@_]+?)__/g, (matched, message) => { + return this.localizeMessage(message, [], { + locale, + defaultValue: matched, + }); + }); + }, + + // Validates the contents of a locale JSON file, normalizes the + // messages into a Map of message key -> localized string pairs. + addLocale(locale, messages, extension) { + let result = new Map(); + + let isPlainObject = obj => + obj && + typeof obj === "object" && + ChromeUtils.getClassName(obj) === "Object"; + + // Chrome does not document the semantics of its localization + // system very well. It handles replacements by pre-processing + // messages, replacing |$[a-zA-Z0-9@_]+$| tokens with the value of their + // replacements. Later, it processes the resulting string for + // |$[0-9]| replacements. + // + // Again, it does not document this, but it accepts any number + // of sequential |$|s, and replaces them with that number minus + // 1. It also accepts |$| followed by any number of sequential + // digits, but refuses to process a localized string which + // provides more than 9 substitutions. + if (!isPlainObject(messages)) { + extension.packagingError(`Invalid locale data for ${locale}`); + return result; + } + + for (let key of Object.keys(messages)) { + let msg = messages[key]; + + if (!isPlainObject(msg) || typeof msg.message != "string") { + extension.packagingError( + `Invalid locale message data for ${locale}, message ${JSON.stringify( + key + )}` + ); + continue; + } + + // Substitutions are case-insensitive, so normalize all of their names + // to lower-case. + let placeholders = new Map(); + if ("placeholders" in msg && isPlainObject(msg.placeholders)) { + for (let key of Object.keys(msg.placeholders)) { + placeholders.set(key.toLowerCase(), msg.placeholders[key]); + } + } + + let replacer = (match, name) => { + let replacement = placeholders.get(name.toLowerCase()); + if (isPlainObject(replacement) && "content" in replacement) { + return replacement.content; + } + return ""; + }; + + let value = msg.message.replace(/\$([A-Za-z0-9@_]+)\$/g, replacer); + + // Message names are also case-insensitive, so normalize them to lower-case. + result.set(key.toLowerCase(), value); + } + + this.messages.set(locale, result); + return result; + }, + + get acceptLanguages() { + let result = Services.prefs.getComplexValue( + "intl.accept_languages", + Ci.nsIPrefLocalizedString + ).data; + return result.split(/\s*,\s*/g); + }, + + get uiLocale() { + return Services.locale.appLocaleAsBCP47; + }, + + get availableLocales() { + const locales = [this.BUILTIN, this.selectedLocale, this.defaultLocale]; + const value = new Set(locales.filter(locale => this.messages.has(locale))); + return redefineGetter(this, "availableLocales", value); + }, +}; + +/** + * This is a generic class for managing event listeners. + * + * @example + * new EventManager({ + * context, + * name: "api.subAPI", + * register: fire => { + * let listener = (...) => { + * // Fire any listeners registered with addListener. + * fire.async(arg1, arg2); + * }; + * // Register the listener. + * SomehowRegisterListener(listener); + * return () => { + * // Return a way to unregister the listener. + * SomehowUnregisterListener(listener); + * }; + * } + * }).api() + * + * The result is an object with addListener, removeListener, and + * hasListener methods. `context` is an add-on scope (either an + * ExtensionContext in the chrome process or ExtensionContext in a + * content process). + */ +class EventManager { + /* + * A persistent event must provide module and name. Additionally the + * module must implement primeListeners in the ExtensionAPI class. + * + * A startup blocking event must also add the startupBlocking flag in + * ext-toolkit.json or ext-browser.json. + * + * Listeners synchronously added from a background extension context + * will be persisted, for a persistent background script only the + * "startup blocking" events will be persisted. + * + * EventManager instances created in a child process can't persist any listener. + * + * @param {object} params + * Parameters that control this EventManager. + * @param {BaseContext} params.context + * An object representing the extension instance using this event. + * @param {string} params.module + * The API module name, required for persistent events. + * @param {string} params.event + * The API event name, required for persistent events. + * @param {ExtensionAPI} params.extensionApi + * The API intance. If the API uses the ExtensionAPIPersistent class, some simplification is + * possible by passing the api (self or this) and the internal register function will be used. + * @param {string} [params.name] + * A name used only for debugging. If not provided, name is built from module and event. + * @param {functon} params.register + * A function called whenever a new listener is added. + * @param {boolean} [params.inputHandling=false] + * If true, the "handling user input" flag is set while handlers + * for this event are executing. + */ + constructor(params) { + let { + context, + module, + event, + name, + register, + extensionApi, + inputHandling = false, + resetIdleOnEvent = true, + } = params; + this.context = context; + this.module = module; + this.event = event; + this.name = name; + this.register = register; + this.inputHandling = inputHandling; + this.resetIdleOnEvent = resetIdleOnEvent; + + const isBackgroundParent = + this.context.envType === "addon_parent" && + this.context.isBackgroundContext; + + // TODO(Bug 1844041): ideally we should restrict resetIdleOnEvent to + // EventManager instances that belongs to the event page, but along + // with that we should consider if calling sendMessage from an event + // page should also reset idle timer, and so in the shorter term + // here we are allowing listeners from other extension pages to + // also reset the idle timer. + const isAddonContext = ["addon_parent", "addon_child"].includes( + this.context.envType + ); + + // Avoid resetIdleOnEvent overhead by only consider it when applicable. + if (!isAddonContext || context.extension.persistentBackground) { + this.resetIdleOnEvent = false; + } + + if (!name) { + this.name = `${module}.${event}`; + } + + if (!this.register && extensionApi instanceof ExtensionAPIPersistent) { + this.register = (fire, ...params) => { + return extensionApi.registerEventListener( + { context, event, fire }, + params + ); + }; + } + if (!this.register) { + throw new Error( + `EventManager requires register method for ${this.name}.` + ); + } + + this.canPersistEvents = module && event && isBackgroundParent; + + if (this.canPersistEvents) { + let { extension } = context; + if (extension.persistentBackground) { + // Persistent backgrounds will only persist startup blocking APIs. + let api_module = extension.apiManager.getModule(this.module); + if (!api_module?.startupBlocking) { + this.canPersistEvents = false; + } + } else { + // Event pages will persist all APIs that implement primeListener. + // The api is already loaded so this does not have performance effect. + let api = extension.apiManager.getAPI( + this.module, + extension, + "addon_parent" + ); + + // If the api doesn't implement primeListener we do not persist the events. + if (!api?.primeListener) { + this.canPersistEvents = false; + } + } + } + + this.unregister = new Map(); + this.remove = new Map(); + } + + /* + * Information about listeners to persistent events is associated with + * the extension to which they belong. Any extension thas has such + * listeners has a property called `persistentListeners` that is a + * 3-level Map: + * + * - the first 2 keys are the module name (e.g., webRequest) + * and the name of the event within the module (e.g., onBeforeRequest). + * + * - the third level of the map is used to track multiple listeners for + * the same event, these listeners are distinguished by the extra arguments + * passed to addListener() + * + * - for quick lookups, the key to the third Map is the result of calling + * uneval() on the array of extra arguments. + * + * - the value stored in the Map or persistent listeners we keep in memory + * is a plain object with: + * - a property called `params` that is the original (ie, not uneval()ed) + * extra arguments to addListener() + * - and a property called `listeners` that is an array of plain object + * each representing a listener to be primed and a `primeId` autoincremented + * integer that represents each of the primed listeners that belongs to the + * group listeners with the same set of extra params. + * - a `nextPrimeId` property keeps track of the numeric primeId that should + * be assigned to new persistent listeners added for the same event and + * same set of extra params. + * + * For a primed listener (i.e., the stub listener created during browser startup + * before the extension background page is started, and after an event page is + * suspended on idle), the object will be later populated (by the callers of + * EventManager.primeListeners) with an additional `primed` property that serves + * as a placeholder listener, collecting all events that got emitted while the + * background page was not yet started, and eventually replaced by a callback + * registered from the extension code, once the background page scripts have been + * executed (or dropped if the background page scripts do not register the same + * listener anymore). + * + * @param {Extension} extension + * @returns {boolean} True if the extension had any persistent listeners. + */ + static _initPersistentListeners(extension) { + if (extension.persistentListeners) { + return !!extension.persistentListeners.size; + } + + let listeners = new DefaultMap(() => new DefaultMap(() => new Map())); + extension.persistentListeners = listeners; + + let persistentListeners = extension.startupData?.persistentListeners; + if (!persistentListeners) { + return false; + } + + let found = false; + for (let [module, savedModuleEntry] of Object.entries( + persistentListeners + )) { + for (let [event, savedEventEntry] of Object.entries(savedModuleEntry)) { + for (let paramList of savedEventEntry) { + /* Before Bug 1795801 (Firefox < 113) each entry was related to a listener + * registered with a different set of extra params (and so only one listener + * could be persisted for the same set of extra params) + * + * After Bug 1795801 (Firefox >= 113) each entry still represents a listener + * registered for that event, but multiple listeners registered with the same + * set of extra params will be captured as multiple entries in the + * paramsList array. + * + * NOTE: persisted listeners are stored in the startupData part of the Addon DB + * and are expected to be preserved across Firefox and Addons upgrades and downgrades + * (unlike the WebExtensions startupCache data which is cleared when Firefox or the + * addon is updated) and so we are taking special care about forward and backward + * compatibility of the persistentListeners on-disk format: + * + * - forward compatibility: when this new version of this startupData loading logic + * is loading the old persistentListeners on-disk format: + * - on the first run only one listener will be primed for each of the extra params + * recorded in the startupData (same as in older Firefox versions) + * and Bug 1795801 will still be hit, but once the background + * context is started once the startupData will be updated to + * include each of the listeners (indipendently if the set of + * extra params is the same as another listener already been + * persisted). + * - after the first run, all listeners will be primed separately, even if the extra + * params are the same as other listeners already primed, and so + * each of the listener will receive the pending events collected + * by their related primed listener and Bug 1795801 not to be hit anymore. + * + * - backward compatibility: when the old version of this startupData loading logic + * (https://searchfox.org/mozilla-central/rev/cd2121e7d8/toolkit/components/extensions/ExtensionCommon.jsm#2360-2371) + * is loading the new persistentListeners on-disk format, the last + * entry with the same set of extra params will be eventually overwritting the + * entry for another primed listener with the same extra params, Bug 1795801 will still + * be hit, but no actual change in behavior is expected. + */ + let key = uneval(paramList); + const eventEntry = listeners.get(module).get(event); + + if (eventEntry.has(key)) { + const keyEntry = eventEntry.get(key); + let primeId = keyEntry.nextPrimeId; + keyEntry.listeners.push({ primeId }); + keyEntry.nextPrimeId++; + } else { + eventEntry.set(key, { + params: paramList, + nextPrimeId: 1, + listeners: [{ primeId: 0 }], + }); + } + found = true; + } + } + } + return found; + } + + // Extract just the information needed at startup for all persistent + // listeners, and arrange for it to be saved. This should be called + // whenever the set of persistent listeners for an extension changes. + static _writePersistentListeners(extension) { + let startupListeners = {}; + for (let [module, moduleEntry] of extension.persistentListeners) { + startupListeners[module] = {}; + for (let [event, eventEntry] of moduleEntry) { + // Turn the per-event entries from the format they are being kept + // in memory: + // + // [ + // { params: paramList1, listeners: [listener1, listener2, ...] }, + // { params: paramList2, listeners: [listener3, listener3, ...] }, + // ... + // ] + // + // into the format used for storing them on disk (in the startupData), + // which is an array of the params for each listener (with the param list + // included as many times as many listeners are persisted for the same + // set of params): + // + // [paramList1, paramList1, ..., paramList2, paramList2, ...] + // + // This format will also work as expected on older Firefox versions where + // only one listener was being persisted for each set of params. + startupListeners[module][event] = Array.from( + eventEntry.values() + ).flatMap(keyEntry => keyEntry.listeners.map(() => keyEntry.params)); + } + } + + extension.startupData.persistentListeners = startupListeners; + extension.saveStartupData(); + } + + // Set up "primed" event listeners for any saved event listeners + // in an extension's startup data. + // This function is only called during browser startup, it stores details + // about all primed listeners in the extension's persistentListeners Map. + static primeListeners(extension, isInStartup = false) { + if (!EventManager._initPersistentListeners(extension)) { + return; + } + + for (let [module, moduleEntry] of extension.persistentListeners) { + // If we're in startup, we only want to continue attempting to prime a + // subset of events that should be startup blocking. + if (isInStartup) { + let api_module = extension.apiManager.getModule(module); + if (!api_module.startupBlocking) { + continue; + } + } + + let api = extension.apiManager.getAPI(module, extension, "addon_parent"); + + // If an extension is upgraded and a permission, such as webRequest, is + // removed, we will have been called but the API is no longer available. + if (!api?.primeListener) { + // The runtime module no longer implements primed listeners, drop them. + extension.persistentListeners.delete(module); + EventManager._writePersistentListeners(extension); + continue; + } + for (let [event, eventEntry] of moduleEntry) { + for (let [key, { params, listeners }] of eventEntry) { + for (let listener of listeners) { + // Reset the `listener.added` flag by setting it to `false` while + // re-priming the listeners because the event page has suspended + // and the previous converted listener is no longer listening. + const listenerWasAdded = listener.added; + listener.added = false; + listener.params = params; + let primed = { pendingEvents: [] }; + + let fireEvent = (...args) => + new Promise((resolve, reject) => { + if (!listener.primed) { + reject( + new Error( + `primed listener ${module}.${event} not re-registered` + ) + ); + return; + } + primed.pendingEvents.push({ args, resolve, reject }); + extension.emit("background-script-event"); + }); + + let fire = { + wakeup: () => extension.wakeupBackground(), + sync: fireEvent, + async: fireEvent, + // fire.async for ProxyContextParent is already not cloning. + raw: fireEvent, + }; + + try { + let handler = api.primeListener( + event, + fire, + listener.params, + isInStartup + ); + if (handler) { + listener.primed = primed; + Object.assign(primed, handler); + } + } catch (e) { + Cu.reportError( + `Error priming listener ${module}.${event}: ${e} :: ${e.stack}` + ); + // Force this listener to be cleared. + listener.error = true; + } + + // If an attempt to prime a listener failed, ensure it is cleared now. + // If a module is a startup blocking module, not all listeners may + // get primed during early startup. For that reason, we don't clear + // persisted listeners during early startup. At the end of background + // execution any listeners that were not renewed will be cleared. + // + // TODO(Bug 1797474): consider priming runtime.onStartup and + // avoid to special handling it here. + if ( + listener.error || + (!isInStartup && + !( + (`${module}.${event}` === "runtime.onStartup" && + listenerWasAdded) || + listener.primed + )) + ) { + EventManager.clearPersistentListener( + extension, + module, + event, + key, + listener.primeId + ); + } + } + } + } + } + } + + /** + * This is called as a result of background script startup-finished and shutdown. + * + * After startup, it removes any remaining primed listeners. These exist if the + * listener was not renewed during startup. In this case the persisted listener + * data is also removed. + * + * During shutdown, care should be taken to set clearPersistent to false. + * persisted listener data should NOT be cleared during shutdown. + * + * @param {Extension} extension + * @param {boolean} clearPersistent whether the persisted listener data should be cleared. + */ + static clearPrimedListeners(extension, clearPersistent = true) { + if (!extension.persistentListeners) { + return; + } + + for (let [module, moduleEntry] of extension.persistentListeners) { + for (let [event, eventEntry] of moduleEntry) { + for (let [key, { listeners }] of eventEntry) { + for (let listener of listeners) { + let { primed, added, primeId } = listener; + // When a primed listener is added or renewed during initial + // background execution we set an added flag. If it was primed + // when added, primed is set to null. + if (added) { + continue; + } + + if (primed) { + // When a primed listener was not renewed, primed will still be truthy. + // These need to be cleared on shutdown (important for event pages), but + // we only clear the persisted listener data after the startup of a background. + // Release any pending events and unregister the primed handler. + listener.primed = null; + + for (let evt of primed.pendingEvents) { + evt.reject(new Error("listener not re-registered")); + } + primed.unregister(); + } + + // Clear any persisted events that were not renewed, should typically + // only be done at the end of the background page load. + if (clearPersistent) { + EventManager.clearPersistentListener( + extension, + module, + event, + key, + primeId + ); + } + } + } + } + } + } + + // Record the fact that there is a listener for the given event in + // the given extension. `args` is an Array containing any extra + // arguments that were passed to addListener(). + static savePersistentListener(extension, module, event, args = []) { + EventManager._initPersistentListeners(extension); + let key = uneval(args); + const eventEntry = extension.persistentListeners.get(module).get(event); + + let primeId; + if (!eventEntry.has(key)) { + // when writing, only args are written, other properties are dropped + primeId = 0; + eventEntry.set(key, { + params: args, + listeners: [{ added: true, primeId }], + nextPrimeId: 1, + }); + } else { + const keyEntry = eventEntry.get(key); + primeId = keyEntry.nextPrimeId; + keyEntry.listeners.push({ added: true, primeId }); + keyEntry.nextPrimeId = primeId + 1; + } + + EventManager._writePersistentListeners(extension); + return [module, event, key, primeId]; + } + + // Remove the record for the given event listener from the extension's + // startup data. `key` must be a string, the result of calling uneval() + // on the array of extra arguments originally passed to addListener(). + static clearPersistentListener( + extension, + module, + event, + key = uneval([]), + primeId = undefined + ) { + let eventEntry = extension.persistentListeners.get(module).get(event); + + let keyEntry = eventEntry.get(key); + + if (primeId != undefined && keyEntry) { + keyEntry.listeners = keyEntry.listeners.filter( + listener => listener.primeId !== primeId + ); + } + + if (primeId == undefined || keyEntry?.listeners.length === 0) { + eventEntry.delete(key); + if (eventEntry.size == 0) { + let moduleEntry = extension.persistentListeners.get(module); + moduleEntry.delete(event); + if (moduleEntry.size == 0) { + extension.persistentListeners.delete(module); + } + } + } + + EventManager._writePersistentListeners(extension); + } + + addListener(callback, ...args) { + if (this.unregister.has(callback)) { + return; + } + this.context.logActivity("api_call", `${this.name}.addListener`, { args }); + + let shouldFire = () => { + if (this.context.unloaded) { + dump(`${this.name} event fired after context unloaded.\n`); + } else if (!this.context.active) { + dump(`${this.name} event fired while context is inactive.\n`); + } else if (this.unregister.has(callback)) { + return true; + } + return false; + }; + + let { extension } = this.context; + const resetIdle = () => { + if (this.resetIdleOnEvent) { + extension?.emit("background-script-reset-idle", { + reason: "event", + eventName: this.name, + }); + } + }; + + let fire = { + // Bug 1754866 fire.sync doesn't match documentation. + sync: (...args) => { + if (shouldFire()) { + resetIdle(); + let result = this.context.applySafe(callback, args); + this.context.logActivity("api_event", this.name, { args, result }); + return result; + } + }, + async: (...args) => { + return Promise.resolve().then(() => { + if (shouldFire()) { + resetIdle(); + let result = this.context.applySafe(callback, args); + this.context.logActivity("api_event", this.name, { args, result }); + return result; + } + }); + }, + raw: (...args) => { + if (!shouldFire()) { + throw new Error("Called raw() on unloaded/inactive context"); + } + resetIdle(); + let result = Reflect.apply(callback, null, args); + this.context.logActivity("api_event", this.name, { args, result }); + return result; + }, + asyncWithoutClone: (...args) => { + return Promise.resolve().then(() => { + if (shouldFire()) { + resetIdle(); + let result = this.context.applySafeWithoutClone(callback, args); + this.context.logActivity("api_event", this.name, { args, result }); + return result; + } + }); + }, + }; + + let { module, event } = this; + + let unregister = null; + let recordStartupData = false; + + // If this is a persistent event, check for a listener that was already + // created during startup. If there is one, use it and don't create a + // new one. + if (this.canPersistEvents) { + // Once a background is started, listenerPromises is set to null. At + // that point, we stop recording startup data. + recordStartupData = !!this.context.listenerPromises; + + let key = uneval(args); + EventManager._initPersistentListeners(extension); + let keyEntry = extension.persistentListeners + .get(module) + .get(event) + .get(key); + + // Get the first persistent listener which matches the module, event and extra arguments + // and not added back by the extension yet, the persistent listener found may be either + // primed or not (in particular API Events that belongs to APIs that should not be blocking + // startup may have persistent listeners that are not primed during the first execution + // of the background context happening as part of the applications startup, whereas they + // will be primed when the background context will be suspended on the idle timeout). + let listener = keyEntry?.listeners.find(listener => !listener.added); + if (listener) { + // During startup only a subset of persisted listeners are primed. As + // well, each API determines whether to prime a specific listener. + let { primed } = listener; + if (primed) { + listener.primed = null; + + primed.convert(fire, this.context); + unregister = primed.unregister; + + for (let evt of primed.pendingEvents) { + evt.resolve(fire.async(...evt.args)); + } + } + listener.added = true; + + recordStartupData = false; + this.remove.set(callback, () => { + EventManager.clearPersistentListener( + extension, + module, + event, + uneval(args), + listener.primeId + ); + }); + } + } + + if (!unregister) { + unregister = this.register(fire, ...args); + } + + this.unregister.set(callback, unregister); + this.context.callOnClose(this); + + // If this is a new listener for a persistent event, record + // the details for subsequent startups. + if (recordStartupData) { + const [, , , /* _module */ /* _event */ /* _key */ primeId] = + EventManager.savePersistentListener(extension, module, event, args); + this.remove.set(callback, () => { + EventManager.clearPersistentListener( + extension, + module, + event, + uneval(args), + primeId + ); + }); + } + } + + removeListener(callback, clearPersistentListener = true) { + if (!this.unregister.has(callback)) { + return; + } + this.context.logActivity("api_call", `${this.name}.removeListener`, { + args: [], + }); + + let unregister = this.unregister.get(callback); + this.unregister.delete(callback); + try { + unregister(); + } catch (e) { + Cu.reportError(e); + } + + if (clearPersistentListener && this.remove.has(callback)) { + let cleanup = this.remove.get(callback); + this.remove.delete(callback); + cleanup(); + } + + if (this.unregister.size == 0) { + this.context.forgetOnClose(this); + } + } + + hasListener(callback) { + return this.unregister.has(callback); + } + + revoke() { + for (let callback of this.unregister.keys()) { + this.removeListener(callback, false); + } + } + + close() { + this.revoke(); + } + + api() { + return { + addListener: (...args) => this.addListener(...args), + removeListener: (...args) => this.removeListener(...args), + hasListener: (...args) => this.hasListener(...args), + setUserInput: this.inputHandling, + [lazy.Schemas.REVOKE]: () => this.revoke(), + }; + } +} + +// Simple API for event listeners where events never fire. +function ignoreEvent(context, name) { + return { + addListener: function (callback) { + let id = context.extension.id; + let frame = Components.stack.caller; + let msg = `In add-on ${id}, attempting to use listener "${name}", which is unimplemented.`; + let scriptError = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + scriptError.init( + msg, + frame.filename, + null, + frame.lineNumber, + frame.columnNumber, + Ci.nsIScriptError.warningFlag, + "content javascript" + ); + Services.console.logMessage(scriptError); + }, + removeListener: function (callback) {}, + hasListener: function (callback) {}, + }; +} + +const stylesheetMap = new DefaultMap(url => { + let uri = Services.io.newURI(url); + return lazy.styleSheetService.preloadSheet( + uri, + // Note: keep in sync with ext-browser-content.js. This used to be + // AGENT_SHEET, but changed to AUTHOR_SHEET, see bug 1873024. + lazy.styleSheetService.AUTHOR_SHEET + ); +}); + +/** + * Updates the in-memory representation of extension host permissions, i.e. + * policy.allowedOrigins. + * + * @param {WebExtensionPolicy} policy + * A policy. All MatchPattern instances in policy.allowedOrigins are + * expected to have been constructed with ignorePath: true. + * @param {string[]} origins + * A list of already-normalized origins, equivalent to using the + * MatchPattern constructor with ignorePath: true. + * @param {boolean} isAdd + * Whether to add instead of removing the host permissions. + */ +function updateAllowedOrigins(policy, origins, isAdd) { + if (!origins.length) { + // Nothing to modify. + return; + } + let patternMap = new Map(); + for (let pattern of policy.allowedOrigins.patterns) { + patternMap.set(pattern.pattern, pattern); + } + if (!isAdd) { + for (let origin of origins) { + patternMap.delete(origin); + } + } else { + // In the parent process, policy.extension.restrictSchemes is available. + // In the content process, we need to check the mozillaAddons permission, + // which is only available if approved by the parent. + const restrictSchemes = + policy.extension?.restrictSchemes ?? + policy.hasPermission("mozillaAddons"); + for (let origin of origins) { + if (patternMap.has(origin)) { + continue; + } + patternMap.set( + origin, + new MatchPattern(origin, { restrictSchemes, ignorePath: true }) + ); + } + } + // patternMap contains only MatchPattern instances, so we don't need to set + // the options parameter (with restrictSchemes, etc.) since that is only used + // if the input is a string. + policy.allowedOrigins = new MatchPatternSet(Array.from(patternMap.values())); +} + +export var ExtensionCommon = { + BaseContext, + CanOfAPIs, + EventManager, + ExtensionAPI, + ExtensionAPIPersistent, + EventEmitter, + LocalAPIImplementation, + LocaleData, + NoCloneSpreadArgs, + SchemaAPIInterface, + SchemaAPIManager, + SpreadArgs, + checkLoadURI, + checkLoadURL, + defineLazyGetter, + redefineGetter, + getConsole, + ignoreEvent, + instanceOf, + makeWidgetId, + normalizeTime, + runSafeSyncWithoutClone, + stylesheetMap, + updateAllowedOrigins, + withHandlingUserInput, + + MultiAPIManager, + LazyAPIManager, +}; |