summaryrefslogtreecommitdiffstats
path: root/toolkit/components/webextensions/ExtensionCommon.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/webextensions/ExtensionCommon.jsm')
-rw-r--r--toolkit/components/webextensions/ExtensionCommon.jsm679
1 files changed, 679 insertions, 0 deletions
diff --git a/toolkit/components/webextensions/ExtensionCommon.jsm b/toolkit/components/webextensions/ExtensionCommon.jsm
new file mode 100644
index 000000000..9ec84b5c7
--- /dev/null
+++ b/toolkit/components/webextensions/ExtensionCommon.jsm
@@ -0,0 +1,679 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+/**
+ * This 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.
+ */
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+/* exported ExtensionCommon */
+
+this.EXPORTED_SYMBOLS = ["ExtensionCommon"];
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "MessageChannel",
+ "resource://gre/modules/MessageChannel.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
+ "resource://gre/modules/Schemas.jsm");
+
+Cu.import("resource://gre/modules/ExtensionUtils.jsm");
+
+var {
+ EventEmitter,
+ ExtensionError,
+ SpreadArgs,
+ getConsole,
+ getInnerWindowID,
+ getUniqueId,
+ runSafeSync,
+ runSafeSyncWithoutClone,
+ instanceOf,
+} = ExtensionUtils;
+
+XPCOMUtils.defineLazyGetter(this, "console", getConsole);
+
+class BaseContext {
+ 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.jsonSandbox = null;
+ this.active = true;
+ this.incognito = null;
+ this.messageManager = null;
+ this.docShell = null;
+ this.contentWindow = null;
+ this.innerWindowID = 0;
+ }
+
+ setContentWindow(contentWindow) {
+ let {document} = contentWindow;
+ let docShell = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDocShell);
+
+ this.innerWindowID = getInnerWindowID(contentWindow);
+ this.messageManager = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIContentFrameMessageManager);
+
+ if (this.incognito == null) {
+ this.incognito = PrivateBrowsingUtils.isContentWindowPrivate(contentWindow);
+ }
+
+ MessageChannel.setupMessageManagers([this.messageManager]);
+
+ let onPageShow = event => {
+ if (!event || event.target === document) {
+ this.docShell = docShell;
+ this.contentWindow = contentWindow;
+ this.active = true;
+ }
+ };
+ let onPageHide = event => {
+ if (!event || event.target === document) {
+ // Put this off until the next tick.
+ Promise.resolve().then(() => {
+ this.docShell = null;
+ this.contentWindow = null;
+ this.active = false;
+ });
+ }
+ };
+
+ onPageShow();
+ contentWindow.addEventListener("pagehide", onPageHide, true);
+ contentWindow.addEventListener("pageshow", onPageShow, true);
+ this.callOnClose({
+ close: () => {
+ onPageHide();
+ if (this.active) {
+ contentWindow.removeEventListener("pagehide", onPageHide, true);
+ contentWindow.removeEventListener("pageshow", onPageShow, true);
+ }
+ },
+ });
+ }
+
+ get cloneScope() {
+ throw new Error("Not implemented");
+ }
+
+ get principal() {
+ throw new Error("Not implemented");
+ }
+
+ runSafe(...args) {
+ if (this.unloaded) {
+ Cu.reportError("context.runSafe called after context unloaded");
+ } else if (!this.active) {
+ Cu.reportError("context.runSafe called while context is inactive");
+ } else {
+ return runSafeSync(this, ...args);
+ }
+ }
+
+ runSafeWithoutClone(...args) {
+ if (this.unloaded) {
+ Cu.reportError("context.runSafeWithoutClone called after context unloaded");
+ } else if (!this.active) {
+ Cu.reportError("context.runSafeWithoutClone called while context is inactive");
+ } else {
+ return runSafeSyncWithoutClone(...args);
+ }
+ }
+
+ checkLoadURL(url, 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.checkLoadURIStrWithPrincipal(this.principal, url, flags);
+ } catch (e) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Safely call JSON.stringify() on an object that comes from an
+ * extension.
+ *
+ * @param {array<any>} args Arguments 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);
+ }
+
+ /**
+ * A wrapper around MessageChannel.sendMessage which adds the extension ID
+ * to the recipient object, and ensures replies are not processed after the
+ * context has been unloaded.
+ *
+ * @param {nsIMessageManager} target
+ * @param {string} messageName
+ * @param {object} data
+ * @param {object} [options]
+ * @param {object} [options.sender]
+ * @param {object} [options.recipient]
+ *
+ * @returns {Promise}
+ */
+ sendMessage(target, messageName, data, options = {}) {
+ options.recipient = Object.assign({extensionId: this.extension.id}, options.recipient);
+ options.sender = options.sender || {};
+
+ options.sender.extensionId = this.extension.id;
+ options.sender.contextId = this.contextId;
+
+ return MessageChannel.sendMessage(target, messageName, data, options);
+ }
+
+ 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
+ * @returns {Error}
+ */
+ normalizeError(error) {
+ if (error instanceof this.cloneScope.Error) {
+ return error;
+ }
+ let message;
+ if (instanceOf(error, "Object") || error instanceof ExtensionError) {
+ message = error.message;
+ } else if (typeof error == "object" &&
+ this.principal.subsumes(Cu.getObjectPrincipal(error))) {
+ message = error.message;
+ } else {
+ Cu.reportError(error);
+ }
+ message = message || "An unexpected error occurred";
+ return new this.cloneScope.Error(message);
+ }
+
+ /**
+ * 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 {function} callback The callback to call.
+ * @returns {*} The return value of callback.
+ */
+ withLastError(error, callback) {
+ this.lastError = this.normalizeError(error);
+ try {
+ return callback();
+ } finally {
+ if (!this.checkedLastError) {
+ Cu.reportError(`Unchecked lastError value: ${this.lastError}`);
+ }
+ this.lastError = null;
+ }
+ }
+
+ /**
+ * 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 runSafe = this.runSafe.bind(this);
+ if (promise instanceof this.cloneScope.Promise) {
+ runSafe = this.runSafeWithoutClone.bind(this);
+ }
+
+ if (callback) {
+ promise.then(
+ args => {
+ if (this.unloaded) {
+ dump(`Promise resolved after context unloaded\n`);
+ } else if (!this.active) {
+ dump(`Promise resolved while context is inactive\n`);
+ } else if (args instanceof SpreadArgs) {
+ runSafe(callback, ...args);
+ } else {
+ runSafe(callback, args);
+ }
+ },
+ error => {
+ this.withLastError(error, () => {
+ if (this.unloaded) {
+ dump(`Promise rejected after context unloaded\n`);
+ } else if (!this.active) {
+ dump(`Promise rejected while context is inactive\n`);
+ } else {
+ this.runSafeWithoutClone(callback);
+ }
+ });
+ });
+ } else {
+ return new this.cloneScope.Promise((resolve, reject) => {
+ promise.then(
+ value => {
+ if (this.unloaded) {
+ dump(`Promise resolved after context unloaded\n`);
+ } else if (!this.active) {
+ dump(`Promise resolved while context is inactive\n`);
+ } else if (value instanceof SpreadArgs) {
+ runSafe(resolve, value.length == 1 ? value[0] : value);
+ } else {
+ runSafe(resolve, value);
+ }
+ },
+ value => {
+ if (this.unloaded) {
+ dump(`Promise rejected after context unloaded: ${value && value.message}\n`);
+ } else if (!this.active) {
+ dump(`Promise rejected while context is inactive: ${value && value.message}\n`);
+ } else {
+ this.runSafeWithoutClone(reject, this.normalizeError(value));
+ }
+ });
+ });
+ }
+ }
+
+ unload() {
+ this.unloaded = true;
+
+ MessageChannel.abortResponses({
+ extensionId: this.extension.id,
+ contextId: this.contextId,
+ });
+
+ for (let obj of this.onClose) {
+ obj.close();
+ }
+ }
+
+ /**
+ * 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 {function(*)} [callback] The callback to be called when the function
+ * completes.
+ * @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) {
+ 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");
+ }
+}
+
+/**
+ * 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;
+ }
+
+ callFunction(args) {
+ return this.pathObj[this.name](...args);
+ }
+
+ callFunctionNoReturn(args) {
+ this.pathObj[this.name](...args);
+ }
+
+ callAsyncFunction(args, callback) {
+ let promise;
+ try {
+ 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);
+ }
+}
+
+/**
+ * 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.
+ */
+ constructor(processType) {
+ super();
+ this.processType = processType;
+ this.global = this._createExtGlobal();
+ this._scriptScopes = [];
+ this._schemaApis = {
+ addon_parent: [],
+ addon_child: [],
+ content_parent: [],
+ content_child: [],
+ };
+ }
+
+ /**
+ * 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,
+ sandboxName: `Namespace of ext-*.js scripts for ${this.processType}`,
+ });
+
+ Object.assign(global, {global, Cc, Ci, Cu, Cr, XPCOMUtils, extensions: this});
+
+ XPCOMUtils.defineLazyGetter(global, "console", getConsole);
+
+ XPCOMUtils.defineLazyModuleGetter(global, "require",
+ "resource://devtools/shared/Loader.jsm");
+
+ return global;
+ }
+
+ /**
+ * 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, "UTF-8");
+
+ // Save the scope to avoid it being garbage collected.
+ this._scriptScopes.push(scope);
+ }
+
+ /**
+ * Called by an ext-*.js script to register an API.
+ *
+ * @param {string} namespace The API namespace.
+ * Intended to match the namespace of the generated API, but not used at
+ * the moment - see bugzil.la/1295774.
+ * @param {string} envType Restricts the API to contexts that run in the
+ * given environment. Must be one of the following:
+ * - "addon_parent" - addon APIs that runs in the main process.
+ * - "addon_child" - addon APIs that runs in an addon process.
+ * - "content_parent" - content script APIs that runs in the main process.
+ * - "content_child" - content script APIs that runs in a content process.
+ * @param {function(BaseContext)} getAPI A function that returns an object
+ * that will be merged with |chrome| and |browser|. The next example adds
+ * the create, update and remove methods to the tabs API.
+ *
+ * registerSchemaAPI("tabs", "addon_parent", (context) => ({
+ * tabs: { create, update },
+ * }));
+ * registerSchemaAPI("tabs", "addon_parent", (context) => ({
+ * tabs: { remove },
+ * }));
+ */
+ registerSchemaAPI(namespace, envType, getAPI) {
+ this._schemaApis[envType].push({namespace, getAPI});
+ }
+
+ /**
+ * Exports all registered scripts to `obj`.
+ *
+ * @param {BaseContext} context The context for which the API bindings are
+ * generated.
+ * @param {object} obj The destination of the API.
+ */
+ generateAPIs(context, obj) {
+ let apis = this._schemaApis[context.envType];
+ if (!apis) {
+ Cu.reportError(`No APIs have been registered for ${context.envType}`);
+ return;
+ }
+ SchemaAPIManager.generateAPIs(context, apis, obj);
+ }
+
+ /**
+ * Mash together all the APIs from `apis` into `obj`.
+ *
+ * @param {BaseContext} context The context for which the API bindings are
+ * generated.
+ * @param {Array} apis A list of objects, see `registerSchemaAPI`.
+ * @param {object} obj The destination of the API.
+ */
+ static generateAPIs(context, apis, obj) {
+ // Recursively copy properties from source to dest.
+ function copy(dest, source) {
+ for (let prop in source) {
+ let desc = Object.getOwnPropertyDescriptor(source, prop);
+ if (typeof(desc.value) == "object") {
+ if (!(prop in dest)) {
+ dest[prop] = {};
+ }
+ copy(dest[prop], source[prop]);
+ } else {
+ Object.defineProperty(dest, prop, desc);
+ }
+ }
+ }
+
+ for (let api of apis) {
+ if (Schemas.checkPermissions(api.namespace, context.extension)) {
+ api = api.getAPI(context);
+ copy(obj, api);
+ }
+ }
+ }
+}
+
+const ExtensionCommon = {
+ BaseContext,
+ LocalAPIImplementation,
+ SchemaAPIInterface,
+ SchemaAPIManager,
+};