toolkit/modules/RemotePageManager.jsm
author Bill McCloskey <billm@mozilla.com>
Tue, 03 May 2016 20:08:33 -0700
changeset 296143 e930d26502a6cae04fe86939637d0348a86fcf10
parent 275642 312c5fa54cb9e371dded556b1168b70dcf0d3bcc
child 312833 13a0934e2cc9625db2aff4a17f301e89bce4997d
permissions -rw-r--r--
Bug 1269961 - Remove listeners from RemotePageManager before invoking Unload (r=mconley)

/* 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");