diff options
Diffstat (limited to 'toolkit/components/webextensions/ExtensionChild.jsm')
-rw-r--r-- | toolkit/components/webextensions/ExtensionChild.jsm | 1058 |
1 files changed, 1058 insertions, 0 deletions
diff --git a/toolkit/components/webextensions/ExtensionChild.jsm b/toolkit/components/webextensions/ExtensionChild.jsm new file mode 100644 index 000000000..5dc4e2277 --- /dev/null +++ b/toolkit/components/webextensions/ExtensionChild.jsm @@ -0,0 +1,1058 @@ +/* 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.EXPORTED_SYMBOLS = ["ExtensionChild"]; + +/* + * This file handles addon logic that is independent of the chrome process. + * When addons run out-of-process, this is the main entry point. + * Its primary function is managing addon globals. + * + * Don't put contentscript logic here, use ExtensionContent.jsm instead. + */ + +const Ci = Components.interfaces; +const Cc = Components.classes; +const Cu = Components.utils; +const Cr = Components.results; + +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, "NativeApp", + "resource://gre/modules/NativeMessaging.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils", + "resource://gre/modules/PromiseUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Schemas", + "resource://gre/modules/Schemas.jsm"); + +const CATEGORY_EXTENSION_SCRIPTS_ADDON = "webextension-scripts-addon"; + +Cu.import("resource://gre/modules/ExtensionCommon.jsm"); +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); + +const { + DefaultMap, + EventManager, + SingletonEventManager, + SpreadArgs, + defineLazyGetter, + getInnerWindowID, + getMessageManager, + getUniqueId, + injectAPI, +} = ExtensionUtils; + +const { + BaseContext, + LocalAPIImplementation, + SchemaAPIInterface, + SchemaAPIManager, +} = ExtensionCommon; + +var ExtensionChild; + +/** + * Abstraction for a Port object in the extension API. + * + * @param {BaseContext} context The context that owns this port. + * @param {nsIMessageSender} senderMM The message manager to send messages to. + * @param {Array<nsIMessageListenerManager>} receiverMMs Message managers to + * listen on. + * @param {string} name Arbitrary port name as defined by the addon. + * @param {string} id An ID that uniquely identifies this port's channel. + * @param {object} sender The `port.sender` property. + * @param {object} recipient The recipient of messages sent from this port. + */ +class Port { + constructor(context, senderMM, receiverMMs, name, id, sender, recipient) { + this.context = context; + this.senderMM = senderMM; + this.receiverMMs = receiverMMs; + this.name = name; + this.id = id; + this.sender = sender; + this.recipient = recipient; + this.disconnected = false; + this.disconnectListeners = new Set(); + this.unregisterMessageFuncs = new Set(); + + // Common options for onMessage and onDisconnect. + this.handlerBase = { + messageFilterStrict: {portId: id}, + + filterMessage: (sender, recipient) => { + return sender.contextId !== this.context.contextId; + }, + }; + + this.disconnectHandler = Object.assign({ + receiveMessage: ({data}) => this.disconnectByOtherEnd(data), + }, this.handlerBase); + + MessageChannel.addListener(this.receiverMMs, "Extension:Port:Disconnect", this.disconnectHandler); + + this.context.callOnClose(this); + } + + api() { + let portObj = Cu.createObjectIn(this.context.cloneScope); + + let portError = null; + let publicAPI = { + name: this.name, + + disconnect: () => { + this.disconnect(); + }, + + postMessage: json => { + this.postMessage(json); + }, + + onDisconnect: new EventManager(this.context, "Port.onDisconnect", fire => { + return this.registerOnDisconnect(error => { + portError = error && this.context.normalizeError(error); + fire.withoutClone(portObj); + }); + }).api(), + + onMessage: new EventManager(this.context, "Port.onMessage", fire => { + return this.registerOnMessage(msg => { + msg = Cu.cloneInto(msg, this.context.cloneScope); + fire.withoutClone(msg, portObj); + }); + }).api(), + + get error() { + return portError; + }, + }; + + if (this.sender) { + publicAPI.sender = this.sender; + } + + injectAPI(publicAPI, portObj); + return portObj; + } + + postMessage(json) { + if (this.disconnected) { + throw new this.context.cloneScope.Error("Attempt to postMessage on disconnected port"); + } + + this._sendMessage("Extension:Port:PostMessage", json); + } + + /** + * Register a callback that is called when the port is disconnected by the + * *other* end. The callback is automatically unregistered when the port or + * context is closed. + * + * @param {function} callback Called when the other end disconnects the port. + * If the disconnect is caused by an error, the first parameter is an + * object with a "message" string property that describes the cause. + * @returns {function} Function to unregister the listener. + */ + registerOnDisconnect(callback) { + let listener = error => { + if (this.context.active && !this.disconnected) { + callback(error); + } + }; + this.disconnectListeners.add(listener); + return () => { + this.disconnectListeners.delete(listener); + }; + } + + /** + * Register a callback that is called when a message is received. The callback + * is automatically unregistered when the port or context is closed. + * + * @param {function} callback Called when a message is received. + * @returns {function} Function to unregister the listener. + */ + registerOnMessage(callback) { + let handler = Object.assign({ + receiveMessage: ({data}) => { + if (this.context.active && !this.disconnected) { + callback(data); + } + }, + }, this.handlerBase); + + let unregister = () => { + this.unregisterMessageFuncs.delete(unregister); + MessageChannel.removeListener(this.receiverMMs, "Extension:Port:PostMessage", handler); + }; + MessageChannel.addListener(this.receiverMMs, "Extension:Port:PostMessage", handler); + this.unregisterMessageFuncs.add(unregister); + return unregister; + } + + _sendMessage(message, data) { + let options = { + recipient: Object.assign({}, this.recipient, {portId: this.id}), + responseType: MessageChannel.RESPONSE_NONE, + }; + + return this.context.sendMessage(this.senderMM, message, data, options); + } + + handleDisconnection() { + MessageChannel.removeListener(this.receiverMMs, "Extension:Port:Disconnect", this.disconnectHandler); + for (let unregister of this.unregisterMessageFuncs) { + unregister(); + } + this.context.forgetOnClose(this); + this.disconnected = true; + } + + /** + * Disconnect the port from the other end (which may not even exist). + * + * @param {Error|{message: string}} [error] The reason for disconnecting, + * if it is an abnormal disconnect. + */ + disconnectByOtherEnd(error = null) { + if (this.disconnected) { + return; + } + + for (let listener of this.disconnectListeners) { + listener(error); + } + + this.handleDisconnection(); + } + + /** + * Disconnect the port from this end. + * + * @param {Error|{message: string}} [error] The reason for disconnecting, + * if it is an abnormal disconnect. + */ + disconnect(error = null) { + if (this.disconnected) { + // disconnect() may be called without side effects even after the port is + // closed - https://developer.chrome.com/extensions/runtime#type-Port + return; + } + this.handleDisconnection(); + if (error) { + error = {message: this.context.normalizeError(error).message}; + } + this._sendMessage("Extension:Port:Disconnect", error); + } + + close() { + this.disconnect(); + } +} + +class NativePort extends Port { + postMessage(data) { + data = NativeApp.encodeMessage(this.context, data); + + return super.postMessage(data); + } +} + +/** + * Each extension context gets its own Messenger object. It handles the + * basics of sendMessage, onMessage, connect and onConnect. + * + * @param {BaseContext} context The context to which this Messenger is tied. + * @param {Array<nsIMessageListenerManager>} messageManagers + * The message managers used to receive messages (e.g. onMessage/onConnect + * requests). + * @param {object} sender Describes this sender to the recipient. This object + * is extended further by BaseContext's sendMessage method and appears as + * the `sender` object to `onConnect` and `onMessage`. + * Do not set the `extensionId`, `contextId` or `tab` properties. The former + * two are added by BaseContext's sendMessage, while `sender.tab` is set by + * the ProxyMessenger in the main process. + * @param {object} filter A recipient filter to apply to incoming messages from + * the broker. Messages are only handled by this Messenger if all key-value + * pairs match the `recipient` as specified by the sender of the message. + * In other words, this filter defines the required fields of `recipient`. + * @param {object} [optionalFilter] An additional filter to apply to incoming + * messages. Unlike `filter`, the keys from `optionalFilter` are allowed to + * be omitted from `recipient`. Only keys that are present in both + * `optionalFilter` and `recipient` are applied to filter incoming messages. + */ +class Messenger { + constructor(context, messageManagers, sender, filter, optionalFilter) { + this.context = context; + this.messageManagers = messageManagers; + this.sender = sender; + this.filter = filter; + this.optionalFilter = optionalFilter; + } + + _sendMessage(messageManager, message, data, recipient) { + let options = { + recipient, + sender: this.sender, + responseType: MessageChannel.RESPONSE_FIRST, + }; + + return this.context.sendMessage(messageManager, message, data, options); + } + + sendMessage(messageManager, msg, recipient, responseCallback) { + let promise = this._sendMessage(messageManager, "Extension:Message", msg, recipient) + .catch(error => { + if (error.result == MessageChannel.RESULT_NO_HANDLER) { + return Promise.reject({message: "Could not establish connection. Receiving end does not exist."}); + } else if (error.result != MessageChannel.RESULT_NO_RESPONSE) { + return Promise.reject({message: error.message}); + } + }); + + return this.context.wrapPromise(promise, responseCallback); + } + + sendNativeMessage(messageManager, msg, recipient, responseCallback) { + msg = NativeApp.encodeMessage(this.context, msg); + return this.sendMessage(messageManager, msg, recipient, responseCallback); + } + + _onMessage(name, filter) { + return new SingletonEventManager(this.context, name, callback => { + let listener = { + messageFilterPermissive: this.optionalFilter, + messageFilterStrict: this.filter, + + filterMessage: (sender, recipient) => { + // Ignore the message if it was sent by this Messenger. + return (sender.contextId !== this.context.contextId && + filter(sender, recipient)); + }, + + receiveMessage: ({target, data: message, sender, recipient}) => { + if (!this.context.active) { + return; + } + + let sendResponse; + let response = undefined; + let promise = new Promise(resolve => { + sendResponse = value => { + resolve(value); + response = promise; + }; + }); + + message = Cu.cloneInto(message, this.context.cloneScope); + sender = Cu.cloneInto(sender, this.context.cloneScope); + sendResponse = Cu.exportFunction(sendResponse, this.context.cloneScope); + + // Note: We intentionally do not use runSafe here so that any + // errors are propagated to the message sender. + let result = callback(message, sender, sendResponse); + if (result instanceof this.context.cloneScope.Promise) { + return result; + } else if (result === true) { + return promise; + } + return response; + }, + }; + + MessageChannel.addListener(this.messageManagers, "Extension:Message", listener); + return () => { + MessageChannel.removeListener(this.messageManagers, "Extension:Message", listener); + }; + }).api(); + } + + onMessage(name) { + return this._onMessage(name, sender => sender.id === this.sender.id); + } + + onMessageExternal(name) { + return this._onMessage(name, sender => sender.id !== this.sender.id); + } + + _connect(messageManager, port, recipient) { + let msg = { + name: port.name, + portId: port.id, + }; + + this._sendMessage(messageManager, "Extension:Connect", msg, recipient).catch(error => { + if (error.result === MessageChannel.RESULT_NO_HANDLER) { + error = {message: "Could not establish connection. Receiving end does not exist."}; + } else if (error.result === MessageChannel.RESULT_DISCONNECTED) { + error = null; + } + port.disconnectByOtherEnd(error); + }); + + return port.api(); + } + + connect(messageManager, name, recipient) { + let portId = getUniqueId(); + + let port = new Port(this.context, messageManager, this.messageManagers, name, portId, null, recipient); + + return this._connect(messageManager, port, recipient); + } + + connectNative(messageManager, name, recipient) { + let portId = getUniqueId(); + + let port = new NativePort(this.context, messageManager, this.messageManagers, name, portId, null, recipient); + + return this._connect(messageManager, port, recipient); + } + + _onConnect(name, filter) { + return new SingletonEventManager(this.context, name, callback => { + let listener = { + messageFilterPermissive: this.optionalFilter, + messageFilterStrict: this.filter, + + filterMessage: (sender, recipient) => { + // Ignore the port if it was created by this Messenger. + return (sender.contextId !== this.context.contextId && + filter(sender, recipient)); + }, + + receiveMessage: ({target, data: message, sender}) => { + let {name, portId} = message; + let mm = getMessageManager(target); + let recipient = Object.assign({}, sender); + if (recipient.tab) { + recipient.tabId = recipient.tab.id; + delete recipient.tab; + } + let port = new Port(this.context, mm, this.messageManagers, name, portId, sender, recipient); + this.context.runSafeWithoutClone(callback, port.api()); + return true; + }, + }; + + MessageChannel.addListener(this.messageManagers, "Extension:Connect", listener); + return () => { + MessageChannel.removeListener(this.messageManagers, "Extension:Connect", listener); + }; + }).api(); + } + + onConnect(name) { + return this._onConnect(name, sender => sender.id === this.sender.id); + } + + onConnectExternal(name) { + return this._onConnect(name, sender => sender.id !== this.sender.id); + } +} + +var apiManager = new class extends SchemaAPIManager { + constructor() { + super("addon"); + this.initialized = false; + } + + generateAPIs(...args) { + if (!this.initialized) { + this.initialized = true; + for (let [/* name */, value] of XPCOMUtils.enumerateCategoryEntries(CATEGORY_EXTENSION_SCRIPTS_ADDON)) { + this.loadScript(value); + } + } + return super.generateAPIs(...args); + } + + registerSchemaAPI(namespace, envType, getAPI) { + if (envType == "addon_child") { + super.registerSchemaAPI(namespace, envType, getAPI); + } + } +}(); + +/** + * An object that runs an remote implementation of an API. + */ +class ProxyAPIImplementation extends SchemaAPIInterface { + /** + * @param {string} namespace The full path to the namespace that contains the + * `name` member. This may contain dots, e.g. "storage.local". + * @param {string} name The name of the method or property. + * @param {ChildAPIManager} childApiManager The owner of this implementation. + */ + constructor(namespace, name, childApiManager) { + super(); + this.path = `${namespace}.${name}`; + this.childApiManager = childApiManager; + } + + callFunctionNoReturn(args) { + this.childApiManager.callParentFunctionNoReturn(this.path, args); + } + + callAsyncFunction(args, callback) { + return this.childApiManager.callParentAsyncFunction(this.path, args, callback); + } + + addListener(listener, args) { + let map = this.childApiManager.listeners.get(this.path); + + if (map.listeners.has(listener)) { + // TODO: Called with different args? + return; + } + + let id = getUniqueId(); + + map.ids.set(id, listener); + map.listeners.set(listener, id); + + this.childApiManager.messageManager.sendAsyncMessage("API:AddListener", { + childId: this.childApiManager.id, + listenerId: id, + path: this.path, + args, + }); + } + + removeListener(listener) { + let map = this.childApiManager.listeners.get(this.path); + + if (!map.listeners.has(listener)) { + return; + } + + let id = map.listeners.get(listener); + map.listeners.delete(listener); + map.ids.delete(id); + + this.childApiManager.messageManager.sendAsyncMessage("API:RemoveListener", { + childId: this.childApiManager.id, + listenerId: id, + path: this.path, + }); + } + + hasListener(listener) { + let map = this.childApiManager.listeners.get(this.path); + return map.listeners.has(listener); + } +} + +// We create one instance of this class for every extension context that +// needs to use remote APIs. It uses the message manager to communicate +// with the ParentAPIManager singleton in ExtensionParent.jsm. It +// handles asynchronous function calls as well as event listeners. +class ChildAPIManager { + constructor(context, messageManager, localApis, contextData) { + this.context = context; + this.messageManager = messageManager; + this.url = contextData.url; + + // The root namespace of all locally implemented APIs. If an extension calls + // an API that does not exist in this object, then the implementation is + // delegated to the ParentAPIManager. + this.localApis = localApis; + + this.id = `${context.extension.id}.${context.contextId}`; + + MessageChannel.addListener(messageManager, "API:RunListener", this); + messageManager.addMessageListener("API:CallResult", this); + + this.messageFilterStrict = {childId: this.id}; + + this.listeners = new DefaultMap(() => ({ + ids: new Map(), + listeners: new Map(), + })); + + // Map[callId -> Deferred] + this.callPromises = new Map(); + + let params = { + childId: this.id, + extensionId: context.extension.id, + principal: context.principal, + }; + Object.assign(params, contextData); + + this.messageManager.sendAsyncMessage("API:CreateProxyContext", params); + } + + receiveMessage({name, messageName, data}) { + if (data.childId != this.id) { + return; + } + + switch (name || messageName) { + case "API:RunListener": + let map = this.listeners.get(data.path); + let listener = map.ids.get(data.listenerId); + + if (listener) { + return this.context.runSafe(listener, ...data.args); + } + + Cu.reportError(`Unknown listener at childId=${data.childId} path=${data.path} listenerId=${data.listenerId}\n`); + break; + + case "API:CallResult": + let deferred = this.callPromises.get(data.callId); + if ("error" in data) { + deferred.reject(data.error); + } else { + deferred.resolve(new SpreadArgs(data.result)); + } + this.callPromises.delete(data.callId); + break; + } + } + + /** + * Call a function in the parent process and ignores its return value. + * + * @param {string} path The full name of the method, e.g. "tabs.create". + * @param {Array} args The parameters for the function. + */ + callParentFunctionNoReturn(path, args) { + this.messageManager.sendAsyncMessage("API:Call", { + childId: this.id, + path, + args, + }); + } + + /** + * Calls a function in the parent process and returns its result + * asynchronously. + * + * @param {string} path The full name of the method, e.g. "tabs.create". + * @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. + */ + callParentAsyncFunction(path, args, callback) { + let callId = getUniqueId(); + let deferred = PromiseUtils.defer(); + this.callPromises.set(callId, deferred); + + this.messageManager.sendAsyncMessage("API:Call", { + childId: this.id, + callId, + path, + args, + }); + + return this.context.wrapPromise(deferred.promise, callback); + } + + /** + * Create a proxy for an event in the parent process. The returned event + * object shares its internal state with other instances. For instance, if + * `removeListener` is used on a listener that was added on another object + * through `addListener`, then the event is unregistered. + * + * @param {string} path The full name of the event, e.g. "tabs.onCreated". + * @returns {object} An object with the addListener, removeListener and + * hasListener methods. See SchemaAPIInterface for documentation. + */ + getParentEvent(path) { + path = path.split("."); + + let name = path.pop(); + let namespace = path.join("."); + + let impl = new ProxyAPIImplementation(namespace, name, this); + return { + addListener: (listener, ...args) => impl.addListener(listener, args), + removeListener: (listener) => impl.removeListener(listener), + hasListener: (listener) => impl.hasListener(listener), + }; + } + + close() { + this.messageManager.sendAsyncMessage("API:CloseProxyContext", {childId: this.id}); + } + + get cloneScope() { + return this.context.cloneScope; + } + + get principal() { + return this.context.principal; + } + + shouldInject(namespace, name, allowedContexts) { + // Do not generate content script APIs, unless explicitly allowed. + if (this.context.envType === "content_child" && + !allowedContexts.includes("content")) { + return false; + } + if (allowedContexts.includes("addon_parent_only")) { + return false; + } + return true; + } + + getImplementation(namespace, name) { + let obj = namespace.split(".").reduce( + (object, prop) => object && object[prop], + this.localApis); + + if (obj && name in obj) { + return new LocalAPIImplementation(obj, name, this.context); + } + + return this.getFallbackImplementation(namespace, name); + } + + getFallbackImplementation(namespace, name) { + // No local API found, defer implementation to the parent. + return new ProxyAPIImplementation(namespace, name, this); + } + + hasPermission(permission) { + return this.context.extension.hasPermission(permission); + } +} + +class ExtensionPageContextChild extends BaseContext { + /** + * This ExtensionPageContextChild represents a privileged addon + * execution environment that has full access to the WebExtensions + * APIs (provided that the correct permissions have been requested). + * + * This is the child side of the ExtensionPageContextParent class + * defined in ExtensionParent.jsm. + * + * @param {BrowserExtensionContent} extension This context's owner. + * @param {object} params + * @param {nsIDOMWindow} params.contentWindow The window where the addon runs. + * @param {string} params.viewType One of "background", "popup" or "tab". + * "background" and "tab" are used by `browser.extension.getViews`. + * "popup" is only used internally to identify page action and browser + * action popups and options_ui pages. + * @param {number} [params.tabId] This tab's ID, used if viewType is "tab". + */ + constructor(extension, params) { + super("addon_child", extension); + if (Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_DEFAULT) { + // This check is temporary. It should be removed once the proxy creation + // is asynchronous. + throw new Error("ExtensionPageContextChild cannot be created in child processes"); + } + + let {viewType, uri, contentWindow, tabId} = params; + this.viewType = viewType; + this.uri = uri || extension.baseURI; + + this.setContentWindow(contentWindow); + + // This is the MessageSender property passed to extension. + // It can be augmented by the "page-open" hook. + let sender = {id: extension.id}; + if (viewType == "tab") { + sender.tabId = tabId; + this.tabId = tabId; + } + if (uri) { + sender.url = uri.spec; + } + this.sender = sender; + + Schemas.exportLazyGetter(contentWindow, "browser", () => { + let browserObj = Cu.createObjectIn(contentWindow); + Schemas.inject(browserObj, this.childManager); + return browserObj; + }); + + Schemas.exportLazyGetter(contentWindow, "chrome", () => { + let chromeApiWrapper = Object.create(this.childManager); + chromeApiWrapper.isChromeCompat = true; + + let chromeObj = Cu.createObjectIn(contentWindow); + Schemas.inject(chromeObj, chromeApiWrapper); + return chromeObj; + }); + + this.extension.views.add(this); + } + + get cloneScope() { + return this.contentWindow; + } + + get principal() { + return this.contentWindow.document.nodePrincipal; + } + + get windowId() { + if (this.viewType == "tab" || this.viewType == "popup") { + let globalView = ExtensionChild.contentGlobals.get(this.messageManager); + return globalView ? globalView.windowId : -1; + } + } + + // Called when the extension shuts down. + shutdown() { + this.unload(); + } + + // This method is called when an extension page navigates away or + // its tab is closed. + unload() { + // Note that without this guard, we end up running unload code + // multiple times for tab pages closed by the "page-unload" handlers + // triggered below. + if (this.unloaded) { + return; + } + + if (this.contentWindow) { + this.contentWindow.close(); + } + + super.unload(); + this.extension.views.delete(this); + } +} + +defineLazyGetter(ExtensionPageContextChild.prototype, "messenger", function() { + let filter = {extensionId: this.extension.id}; + let optionalFilter = {}; + // Addon-generated messages (not necessarily from the same process as the + // addon itself) are sent to the main process, which forwards them via the + // parent process message manager. Specific replies can be sent to the frame + // message manager. + return new Messenger(this, [Services.cpmm, this.messageManager], this.sender, + filter, optionalFilter); +}); + +defineLazyGetter(ExtensionPageContextChild.prototype, "childManager", function() { + let localApis = {}; + apiManager.generateAPIs(this, localApis); + + if (this.viewType == "background") { + apiManager.global.initializeBackgroundPage(this.contentWindow); + } + + let childManager = new ChildAPIManager(this, this.messageManager, localApis, { + envType: "addon_parent", + viewType: this.viewType, + url: this.uri.spec, + incognito: this.incognito, + }); + + this.callOnClose(childManager); + + return childManager; +}); + +// All subframes in a tab, background page, popup, etc. have the same view type. +// This class keeps track of such global state. +// Note that this is created even for non-extension tabs because at present we +// do not have a way to distinguish regular tabs from extension tabs at the +// initialization of a frame script. +class ContentGlobal { + /** + * @param {nsIContentFrameMessageManager} global The frame script's global. + */ + constructor(global) { + this.global = global; + // Unless specified otherwise assume that the extension page is in a tab, + // because the majority of all class instances are going to be a tab. Any + // special views (background page, extension popup) will immediately send an + // Extension:InitExtensionView message to change the viewType. + this.viewType = "tab"; + this.tabId = -1; + this.windowId = -1; + this.initialized = false; + this.global.addMessageListener("Extension:InitExtensionView", this); + this.global.addMessageListener("Extension:SetTabAndWindowId", this); + + this.initialDocuments = new WeakSet(); + } + + uninit() { + this.global.removeMessageListener("Extension:InitExtensionView", this); + this.global.removeMessageListener("Extension:SetTabAndWindowId", this); + this.global.removeEventListener("DOMContentLoaded", this); + } + + ensureInitialized() { + if (!this.initialized) { + // Request tab and window ID in case "Extension:InitExtensionView" is not + // sent (e.g. when `viewType` is "tab"). + let reply = this.global.sendSyncMessage("Extension:GetTabAndWindowId"); + this.handleSetTabAndWindowId(reply[0] || {}); + } + return this; + } + + receiveMessage({name, data}) { + switch (name) { + case "Extension:InitExtensionView": + // The view type is initialized once and then fixed. + this.global.removeMessageListener("Extension:InitExtensionView", this); + let {viewType, url} = data; + this.viewType = viewType; + this.global.addEventListener("DOMContentLoaded", this); + if (url) { + // TODO(robwu): Remove this check. It is only here because the popup + // implementation does not always load a URL at the initialization, + // and the logic is too complex to fix at once. + let {document} = this.global.content; + this.initialDocuments.add(document); + document.location.replace(url); + } + /* Falls through to allow these properties to be initialized at once */ + case "Extension:SetTabAndWindowId": + this.handleSetTabAndWindowId(data); + break; + } + } + + handleSetTabAndWindowId(data) { + let {tabId, windowId} = data; + if (tabId) { + // Tab IDs are not expected to change. + if (this.tabId !== -1 && tabId !== this.tabId) { + throw new Error("Attempted to change a tabId after it was set"); + } + this.tabId = tabId; + } + if (windowId !== undefined) { + // Window IDs may change if a tab is moved to a different location. + // Note: This is the ID of the browser window for the extension API. + // Do not confuse it with the innerWindowID of DOMWindows! + this.windowId = windowId; + } + this.initialized = true; + } + + // "DOMContentLoaded" event. + handleEvent(event) { + let {document} = this.global.content; + if (event.target === document) { + // If the document was still being loaded at the time of navigation, then + // the DOMContentLoaded event is fired for the old document. Ignore it. + if (this.initialDocuments.has(document)) { + this.initialDocuments.delete(document); + return; + } + this.global.removeEventListener("DOMContentLoaded", this); + this.global.sendAsyncMessage("Extension:ExtensionViewLoaded"); + } + } +} + +ExtensionChild = { + // Map<nsIContentFrameMessageManager, ContentGlobal> + contentGlobals: new Map(), + + // Map<innerWindowId, ExtensionPageContextChild> + extensionContexts: new Map(), + + initOnce() { + // This initializes the default message handler for messages targeted at + // an addon process, in case the addon process receives a message before + // its Messenger has been instantiated. For example, if a content script + // sends a message while there is no background page. + MessageChannel.setupMessageManagers([Services.cpmm]); + }, + + init(global) { + this.contentGlobals.set(global, new ContentGlobal(global)); + }, + + uninit(global) { + this.contentGlobals.get(global).uninit(); + this.contentGlobals.delete(global); + }, + + /** + * Create a privileged context at document-element-inserted. + * + * @param {BrowserExtensionContent} extension + * The extension for which the context should be created. + * @param {nsIDOMWindow} contentWindow The global of the page. + */ + createExtensionContext(extension, contentWindow) { + let windowId = getInnerWindowID(contentWindow); + let context = this.extensionContexts.get(windowId); + if (context) { + if (context.extension !== extension) { + // Oops. This should never happen. + Cu.reportError("A different extension context already exists in this frame!"); + } else { + // This should not happen either. + Cu.reportError("The extension context was already initialized in this frame."); + } + return; + } + + let mm = contentWindow + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIContentFrameMessageManager); + let {viewType, tabId} = this.contentGlobals.get(mm).ensureInitialized(); + + let uri = contentWindow.document.documentURIObject; + + context = new ExtensionPageContextChild(extension, {viewType, contentWindow, uri, tabId}); + this.extensionContexts.set(windowId, context); + }, + + /** + * Close the ExtensionPageContextChild belonging to the given window, if any. + * + * @param {number} windowId The inner window ID of the destroyed context. + */ + destroyExtensionContext(windowId) { + let context = this.extensionContexts.get(windowId); + if (context) { + context.unload(); + this.extensionContexts.delete(windowId); + } + }, + + shutdownExtension(extensionId) { + for (let [windowId, context] of this.extensionContexts) { + if (context.extension.id == extensionId) { + context.shutdown(); + this.extensionContexts.delete(windowId); + } + } + }, +}; + +// TODO(robwu): Change this condition when addons move to a separate process. +if (Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_DEFAULT) { + Object.keys(ExtensionChild).forEach(function(key) { + if (typeof ExtensionChild[key] == "function") { + // :/ + ExtensionChild[key] = () => {}; + } + }); +} + +Object.assign(ExtensionChild, { + ChildAPIManager, + Messenger, + Port, +}); + |