gecko-dev/toolkit/modules/RemotePageManager.jsm

527 lines
16 KiB
JavaScript

/* 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() {
this._browser.removeEventListener("SwapDocShells", this.swapBrowsers, false);
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");