diff options
Diffstat (limited to 'toolkit/modules/RemotePageManager.jsm')
-rw-r--r-- | toolkit/modules/RemotePageManager.jsm | 534 |
1 files changed, 534 insertions, 0 deletions
diff --git a/toolkit/modules/RemotePageManager.jsm b/toolkit/modules/RemotePageManager.jsm new file mode 100644 index 000000000..a3fea49e6 --- /dev/null +++ b/toolkit/modules/RemotePageManager.jsm @@ -0,0 +1,534 @@ +/* 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 = ["RemotePages", "RemotePageManager", "PageListener"]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +function MessageListener() { + this.listeners = new Map(); +} + +MessageListener.prototype = { + keys: function() { + return this.listeners.keys(); + }, + + has: function(name) { + return this.listeners.has(name); + }, + + callListeners: function(message) { + let listeners = this.listeners.get(message.name); + if (!listeners) { + return; + } + + for (let listener of listeners.values()) { + try { + listener(message); + } + catch (e) { + Cu.reportError(e); + } + } + }, + + addMessageListener: function(name, callback) { + if (!this.listeners.has(name)) + this.listeners.set(name, new Set([callback])); + else + this.listeners.get(name).add(callback); + }, + + removeMessageListener: function(name, callback) { + if (!this.listeners.has(name)) + return; + + this.listeners.get(name).delete(callback); + }, +} + + +/** + * Creates a RemotePages object which listens for new remote pages of a + * particular URL. A "RemotePage:Init" message will be dispatched to this object + * for every page loaded. Message listeners added to this object receive + * messages from all loaded pages from the requested url. + */ +this.RemotePages = function(url) { + this.url = url; + this.messagePorts = new Set(); + this.listener = new MessageListener(); + this.destroyed = false; + + RemotePageManager.addRemotePageListener(url, this.portCreated.bind(this)); + this.portMessageReceived = this.portMessageReceived.bind(this); +} + +RemotePages.prototype = { + url: null, + messagePorts: null, + listener: null, + destroyed: null, + + destroy: function() { + RemotePageManager.removeRemotePageListener(this.url); + + for (let port of this.messagePorts.values()) { + this.removeMessagePort(port); + } + + this.messagePorts = null; + this.listener = null; + this.destroyed = true; + }, + + // Called when a page matching the url has loaded in a frame. + portCreated: function(port) { + this.messagePorts.add(port); + + port.addMessageListener("RemotePage:Unload", this.portMessageReceived); + + for (let name of this.listener.keys()) { + this.registerPortListener(port, name); + } + + this.listener.callListeners({ target: port, name: "RemotePage:Init" }); + }, + + // A message has been received from one of the pages + portMessageReceived: function(message) { + if (message.name == "RemotePage:Unload") + this.removeMessagePort(message.target); + + this.listener.callListeners(message); + }, + + // A page has closed + removeMessagePort: function(port) { + for (let name of this.listener.keys()) { + port.removeMessageListener(name, this.portMessageReceived); + } + + port.removeMessageListener("RemotePage:Unload", this.portMessageReceived); + this.messagePorts.delete(port); + }, + + registerPortListener: function(port, name) { + port.addMessageListener(name, this.portMessageReceived); + }, + + // Sends a message to all known pages + sendAsyncMessage: function(name, data = null) { + for (let port of this.messagePorts.values()) { + port.sendAsyncMessage(name, data); + } + }, + + addMessageListener: function(name, callback) { + if (this.destroyed) { + throw new Error("RemotePages has been destroyed"); + } + + if (!this.listener.has(name)) { + for (let port of this.messagePorts.values()) { + this.registerPortListener(port, name) + } + } + + this.listener.addMessageListener(name, callback); + }, + + removeMessageListener: function(name, callback) { + if (this.destroyed) { + throw new Error("RemotePages has been destroyed"); + } + + this.listener.removeMessageListener(name, callback); + }, + + portsForBrowser: function(browser) { + return [...this.messagePorts].filter(port => port.browser == browser); + }, +}; + + +// Only exposes the public properties of the MessagePort +function publicMessagePort(port) { + let properties = ["addMessageListener", "removeMessageListener", + "sendAsyncMessage", "destroy"]; + + let clean = {}; + for (let property of properties) { + clean[property] = port[property].bind(port); + } + + if (port instanceof ChromeMessagePort) { + Object.defineProperty(clean, "browser", { + get: function() { + return port.browser; + } + }); + } + + return clean; +} + + +/* + * A message port sits on each side of the process boundary for every remote + * page. Each has a port ID that is unique to the message manager it talks + * through. + * + * We roughly implement the same contract as nsIMessageSender and + * nsIMessageListenerManager + */ +function MessagePort(messageManager, portID) { + this.messageManager = messageManager; + this.portID = portID; + this.destroyed = false; + this.listener = new MessageListener(); + + this.message = this.message.bind(this); + this.messageManager.addMessageListener("RemotePage:Message", this.message); +} + +MessagePort.prototype = { + messageManager: null, + portID: null, + destroyed: null, + listener: null, + _browser: null, + remotePort: null, + + // Called when the message manager used to connect to the other process has + // changed, i.e. when a tab is detached. + swapMessageManager: function(messageManager) { + this.messageManager.removeMessageListener("RemotePage:Message", this.message); + + this.messageManager = messageManager; + + this.messageManager.addMessageListener("RemotePage:Message", this.message); + }, + + /* Adds a listener for messages. Many callbacks can be registered for the + * same message if necessary. An attempt to register the same callback for the + * same message twice will be ignored. When called the callback is passed an + * object with these properties: + * target: This message port + * name: The message name + * data: Any data sent with the message + */ + addMessageListener: function(name, callback) { + if (this.destroyed) { + throw new Error("Message port has been destroyed"); + } + + this.listener.addMessageListener(name, callback); + }, + + /* + * Removes a listener for messages. + */ + removeMessageListener: function(name, callback) { + if (this.destroyed) { + throw new Error("Message port has been destroyed"); + } + + this.listener.removeMessageListener(name, callback); + }, + + // Sends a message asynchronously to the other process + sendAsyncMessage: function(name, data = null) { + if (this.destroyed) { + throw new Error("Message port has been destroyed"); + } + + this.messageManager.sendAsyncMessage("RemotePage:Message", { + portID: this.portID, + name: name, + data: data, + }); + }, + + // Called to destroy this port + destroy: function() { + try { + // This can fail in the child process if the tab has already been closed + this.messageManager.removeMessageListener("RemotePage:Message", this.message); + } + catch (e) { } + this.messageManager = null; + this.destroyed = true; + this.portID = null; + this.listener = null; + }, +}; + + +// The chome side of a message port +function ChromeMessagePort(browser, portID) { + MessagePort.call(this, browser.messageManager, portID); + + this._browser = browser; + this._permanentKey = browser.permanentKey; + + Services.obs.addObserver(this, "message-manager-disconnect", false); + this.publicPort = publicMessagePort(this); + + this.swapBrowsers = this.swapBrowsers.bind(this); + this._browser.addEventListener("SwapDocShells", this.swapBrowsers, false); +} + +ChromeMessagePort.prototype = Object.create(MessagePort.prototype); + +Object.defineProperty(ChromeMessagePort.prototype, "browser", { + get: function() { + return this._browser; + } +}); + +// Called when the docshell is being swapped with another browser. We have to +// update to use the new browser's message manager +ChromeMessagePort.prototype.swapBrowsers = function({ detail: newBrowser }) { + // We can see this event for the new browser before the swap completes so + // check that the browser we're tracking has our permanentKey. + if (this._browser.permanentKey != this._permanentKey) + return; + + this._browser.removeEventListener("SwapDocShells", this.swapBrowsers, false); + + this._browser = newBrowser; + this.swapMessageManager(newBrowser.messageManager); + + this._browser.addEventListener("SwapDocShells", this.swapBrowsers, false); +} + +// Called when a message manager has been disconnected indicating that the +// tab has closed or crashed +ChromeMessagePort.prototype.observe = function(messageManager) { + if (messageManager != this.messageManager) + return; + + this.listener.callListeners({ + target: this.publicPort, + name: "RemotePage:Unload", + data: null, + }); + this.destroy(); +}; + +// Called when a message is received from the message manager. This could +// have come from any port in the message manager so verify the port ID. +ChromeMessagePort.prototype.message = function({ data: messagedata }) { + if (this.destroyed || (messagedata.portID != this.portID)) { + return; + } + + let message = { + target: this.publicPort, + name: messagedata.name, + data: messagedata.data, + }; + this.listener.callListeners(message); + + if (messagedata.name == "RemotePage:Unload") + this.destroy(); +}; + +ChromeMessagePort.prototype.destroy = function() { + try { + this._browser.removeEventListener( + "SwapDocShells", this.swapBrowsers, false); + } + catch (e) { + // It's possible the browser instance is already dead so we can just ignore + // this error. + } + + this._browser = null; + Services.obs.removeObserver(this, "message-manager-disconnect"); + MessagePort.prototype.destroy.call(this); +}; + + +// The content side of a message port +function ChildMessagePort(contentFrame, window) { + let portID = Services.appinfo.processID + ":" + ChildMessagePort.prototype.nextPortID++; + MessagePort.call(this, contentFrame, portID); + + this.window = window; + + // Add functionality to the content page + Cu.exportFunction(this.sendAsyncMessage.bind(this), window, { + defineAs: "sendAsyncMessage", + }); + Cu.exportFunction(this.addMessageListener.bind(this), window, { + defineAs: "addMessageListener", + allowCallbacks: true, + }); + Cu.exportFunction(this.removeMessageListener.bind(this), window, { + defineAs: "removeMessageListener", + allowCallbacks: true, + }); + + // Send a message for load events + let loadListener = () => { + this.sendAsyncMessage("RemotePage:Load"); + window.removeEventListener("load", loadListener, false); + }; + window.addEventListener("load", loadListener, false); + + // Destroy the port when the window is unloaded + window.addEventListener("unload", () => { + try { + this.sendAsyncMessage("RemotePage:Unload"); + } + catch (e) { + // If the tab has been closed the frame message manager has already been + // destroyed + } + this.destroy(); + }, false); + + // Tell the main process to set up its side of the message pipe. + this.messageManager.sendAsyncMessage("RemotePage:InitPort", { + portID: portID, + url: window.document.documentURI.replace(/[\#|\?].*$/, ""), + }); +} + +ChildMessagePort.prototype = Object.create(MessagePort.prototype); + +ChildMessagePort.prototype.nextPortID = 0; + +// Called when a message is received from the message manager. This could +// have come from any port in the message manager so verify the port ID. +ChildMessagePort.prototype.message = function({ data: messagedata }) { + if (this.destroyed || (messagedata.portID != this.portID)) { + return; + } + + let message = { + name: messagedata.name, + data: messagedata.data, + }; + this.listener.callListeners(Cu.cloneInto(message, this.window)); +}; + +ChildMessagePort.prototype.destroy = function() { + this.window = null; + MessagePort.prototype.destroy.call(this); +} + +// Allows callers to register to connect to specific content pages. Registration +// is done through the addRemotePageListener method +var RemotePageManagerInternal = { + // The currently registered remote pages + pages: new Map(), + + // Initialises all the needed listeners + init: function() { + Services.ppmm.addMessageListener("RemotePage:InitListener", this.initListener.bind(this)); + Services.mm.addMessageListener("RemotePage:InitPort", this.initPort.bind(this)); + }, + + // Registers interest in a remote page. A callback is called with a port for + // the new page when loading begins (i.e. the page hasn't actually loaded yet). + // Only one callback can be registered per URL. + addRemotePageListener: function(url, callback) { + if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) + throw new Error("RemotePageManager can only be used in the main process."); + + if (this.pages.has(url)) { + throw new Error("Remote page already registered: " + url); + } + + this.pages.set(url, callback); + + // Notify all the frame scripts of the new registration + Services.ppmm.broadcastAsyncMessage("RemotePage:Register", { urls: [url] }); + }, + + // Removes any interest in a remote page. + removeRemotePageListener: function(url) { + if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) + throw new Error("RemotePageManager can only be used in the main process."); + + if (!this.pages.has(url)) { + throw new Error("Remote page is not registered: " + url); + } + + // Notify all the frame scripts of the removed registration + Services.ppmm.broadcastAsyncMessage("RemotePage:Unregister", { urls: [url] }); + this.pages.delete(url); + }, + + // A listener is requesting the list of currently registered urls + initListener: function({ target: messageManager }) { + messageManager.sendAsyncMessage("RemotePage:Register", { urls: Array.from(this.pages.keys()) }) + }, + + // A remote page has been created and a port is ready in the content side + initPort: function({ target: browser, data: { url, portID } }) { + let callback = this.pages.get(url); + if (!callback) { + Cu.reportError("Unexpected remote page load: " + url); + return; + } + + let port = new ChromeMessagePort(browser, portID); + callback(port.publicPort); + } +}; + +if (Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) + RemotePageManagerInternal.init(); + +// The public API for the above object +this.RemotePageManager = { + addRemotePageListener: RemotePageManagerInternal.addRemotePageListener.bind(RemotePageManagerInternal), + removeRemotePageListener: RemotePageManagerInternal.removeRemotePageListener.bind(RemotePageManagerInternal), +}; + +// Listen for pages in any process we're loaded in +var registeredURLs = new Set(); + +var observer = (window) => { + // Strip the hash from the URL, because it's not part of the origin. + let url = window.document.documentURI.replace(/[\#|\?].*$/, ""); + if (!registeredURLs.has(url)) + return; + + // Get the frame message manager for this window so we can associate this + // page with a browser element + let messageManager = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell) + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIContentFrameMessageManager); + // Set up the child side of the message port + let port = new ChildMessagePort(messageManager, window); +}; +Services.obs.addObserver(observer, "chrome-document-global-created", false); +Services.obs.addObserver(observer, "content-document-global-created", false); + +// A message from chrome telling us what pages to listen for +Services.cpmm.addMessageListener("RemotePage:Register", ({ data }) => { + for (let url of data.urls) + registeredURLs.add(url); +}); + +// A message from chrome telling us what pages to stop listening for +Services.cpmm.addMessageListener("RemotePage:Unregister", ({ data }) => { + for (let url of data.urls) + registeredURLs.delete(url); +}); + +Services.cpmm.sendAsyncMessage("RemotePage:InitListener"); |