Source code

Revision control

Copy as Markdown

Other Tools

/* -*- 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.sys.mjs and ExtensionChild.sys.mjs.
*/
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
/** @type {Lazy} */
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");
export 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.
*/
export 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 {object} _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
*/
export 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;
this.browserId = 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.
*
* @type {ConduitGen}
*/
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.browserId = contentWindow.browsingContext?.browserId;
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) {
this.logConsoleScriptError({
message,
fileName,
lineNumber,
columnNumber,
});
}
// Also report the original error object (because it also includes
// the full error stack).
Cu.reportError(e);
}
}
}
logConsoleScriptError({
message,
fileName,
lineNumber,
columnNumber,
flags = Ci.nsIScriptError.errorFlag,
innerWindowID = this.innerWindowID,
}) {
if (innerWindowID) {
Services.console.logMessage(
new ScriptError(
message,
fileName,
lineNumber,
columnNumber,
flags,
"content javascript",
innerWindowID
)
);
} else {
Cu.reportError(new Error(message));
}
}
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 {nsIStackFrame?} [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 {nsIStackFrame?} 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 {nsIStackFrame?}
*/
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.sys.mjs.
*
* @interface
*/
export 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) {
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").SchemaInject} [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.
*