Bug 1287007 - Use IPC to share viewType, tabId and windowId draft
authorRob Wu <rob@robwu.nl>
Tue, 06 Sep 2016 15:25:10 -0700
changeset 428432 9a72feb1575e564a58c78c2f62fc9e090c65f711
parent 428431 be221f14b23df3749dbea8fafee2d0d07cbd3fae
child 428433 88fe80a6f328e5f7fdd75de4a50aa704d3cc6a84
push id33305
push userbmo:rob@robwu.nl
push dateSun, 23 Oct 2016 20:56:25 +0000
bugs1287007
milestone52.0a1
Bug 1287007 - Use IPC to share viewType, tabId and windowId Accessing <browser> in ContentChild does not work when extensions run in a separate process. MozReview-Commit-ID: EK0aOYeGaZ5
browser/components/extensions/ext-tabs.js
browser/components/extensions/ext-utils.js
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionChild.jsm
toolkit/components/extensions/ExtensionContent.jsm
toolkit/components/extensions/ext-backgroundPage.js
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -48,72 +48,44 @@ function getSender(extension, target, se
       }
     }
   }
 }
 
 // Used by Extension.jsm
 global.tabGetSender = getSender;
 
-function getDocShellOwner(docShell) {
-  let browser = docShell.chromeEventHandler;
-
-  let xulWindow = browser.ownerGlobal;
-
-  let {gBrowser} = xulWindow;
-  if (gBrowser) {
-    let tab = gBrowser.getTabForBrowser(browser);
-
-    return {xulWindow, tab};
-  }
-
-  return {};
-}
-
 /* eslint-disable mozilla/balanced-listeners */
-// This listener fires whenever an extension page opens in a tab
-// (either initiated by the extension or the user). Its job is to fill
-// in some tab-specific details and keep data around about the
-// ExtensionContext.
-extensions.on("page-load", (type, context, params, sender) => {
-  if (params.viewType == "tab" || params.viewType == "popup") {
-    let {xulWindow, tab} = getDocShellOwner(params.docShell);
-
-    // FIXME: Handle tabs being moved between windows.
-    context.windowId = WindowManager.getId(xulWindow);
-    if (tab) {
-      sender.tabId = TabManager.getId(tab);
-      context.tabId = TabManager.getId(tab);
-    }
-  }
-});
 
 extensions.on("page-shutdown", (type, context) => {
   if (context.viewType == "tab") {
-    let {xulWindow, tab} = getDocShellOwner(context.docShell);
-    if (tab) {
-      xulWindow.gBrowser.removeTab(tab);
+    let {gBrowser} = context.xulBrowser.ownerGlobal;
+    if (gBrowser) {
+      let tab = gBrowser.getTabForBrowser(context.xulBrowser);
+      if (tab) {
+        gBrowser.removeTab(tab);
+      }
     }
   }
 });
 
 extensions.on("fill-browser-data", (type, browser, data, result) => {
   let tabId = TabManager.getBrowserId(browser);
   if (tabId == -1) {
     result.cancel = true;
     return;
   }
 
   data.tabId = tabId;
 });
 /* eslint-enable mozilla/balanced-listeners */
 
 global.currentWindow = function(context) {
-  let {xulWindow} = getDocShellOwner(context.docShell);
-  if (xulWindow) {
+  let {xulWindow} = context;
+  if (xulWindow && context.viewType != "background") {
     return xulWindow;
   }
   return WindowManager.topWindow;
 };
 
 let tabListener = {
   init() {
     if (this.initialized) {
--- a/browser/components/extensions/ext-utils.js
+++ b/browser/components/extensions/ext-utils.js
@@ -220,32 +220,39 @@ class BasePopup {
 
   createBrowser(viewNode, popupURL = null) {
     let document = viewNode.ownerDocument;
     this.browser = document.createElementNS(XUL_NS, "browser");
     this.browser.setAttribute("type", "content");
     this.browser.setAttribute("disableglobalhistory", "true");
     this.browser.setAttribute("transparent", "true");
     this.browser.setAttribute("class", "webextension-popup-browser");
-    this.browser.setAttribute("webextension-view-type", "popup");
     this.browser.setAttribute("tooltip", "aHTMLTooltip");
 
     // We only need flex sizing for the sake of the slide-in sub-views of the
     // main menu panel, so that the browser occupies the full width of the view,
     // and also takes up any extra height that's available to it.
     this.browser.setAttribute("flex", "1");
 
     // Note: When using noautohide panels, the popup manager will add width and
     // height attributes to the panel, breaking our resize code, if the browser
     // starts out smaller than 30px by 10px. This isn't an issue now, but it
     // will be if and when we popup debugging.
 
     viewNode.appendChild(this.browser);
 
     extensions.emit("extension-browser-inserted", this.browser);
+    let windowId = WindowManager.getId(this.browser.ownerGlobal);
+    this.browser.messageManager.sendAsyncMessage("Extension:InitExtensionView", {
+      viewType: "popup",
+      windowId,
+    });
+    // TODO(robwu): Rework this to use the Extension:ExtensionViewLoaded message
+    // to detect loads and so on. And definitely move this content logic inside
+    // a file in the child process.
 
     let initBrowser = browser => {
       let mm = browser.messageManager;
       mm.addMessageListener("DOMTitleChanged", this);
       mm.addMessageListener("Extension:BrowserBackgroundChanged", this);
       mm.addMessageListener("Extension:BrowserContentLoaded", this);
       mm.addMessageListener("Extension:BrowserResized", this);
       mm.addMessageListener("Extension:DOMWindowClose", this, true);
@@ -656,16 +663,38 @@ ExtensionTabManager.prototype = {
 
   getTabs(window) {
     return Array.from(window.gBrowser.tabs)
                 .filter(tab => !tab.closing)
                 .map(tab => this.convert(tab));
   },
 };
 
+// Sends the tab and windowId upon request. This is primarily used to support
+// the synchronous `browser.extension.getViews` API.
+let onGetTabAndWindowId = {
+  receiveMessage({name, target, sync}) {
+    let {gBrowser} = target.ownerGlobal;
+    let tab = gBrowser && gBrowser.getTabForBrowser(target);
+    if (tab) {
+      let reply = {
+        tabId: TabManager.getId(tab),
+        windowId: WindowManager.getId(tab.ownerGlobal),
+      };
+      if (sync) {
+        return reply;
+      }
+      target.messageManager.sendAsyncMessage("Extension:SetTabAndWindowId", reply);
+    }
+  },
+};
+/* eslint-disable mozilla/balanced-listeners */
+Services.mm.addMessageListener("Extension:GetTabAndWindowId", onGetTabAndWindowId);
+/* eslint-enable mozilla/balanced-listeners */
+
 
 // Manages global mappings between XUL tabs and extension tab IDs.
 global.TabManager = {
   _tabs: new WeakMap(),
   _nextId: 1,
   _initialized: false,
 
   // We begin listening for TabOpen and TabClose events once we've started
@@ -684,26 +713,35 @@ global.TabManager = {
   },
 
   handleEvent(event) {
     if (event.type == "TabOpen") {
       let {adoptedTab} = event.detail;
       if (adoptedTab) {
         // This tab is being created to adopt a tab from a different window.
         // Copy the ID from the old tab to the new.
-        this._tabs.set(event.target, this.getId(adoptedTab));
+        let tab = event.target;
+        this._tabs.set(tab, this.getId(adoptedTab));
+
+        tab.linkedBrowser.messageManager.sendAsyncMessage("Extension:SetTabAndWindowId", {
+          windowId: WindowManager.getId(tab.ownerGlobal),
+        });
       }
     } else if (event.type == "TabClose") {
       let {adoptedBy} = event.detail;
       if (adoptedBy) {
         // This tab is being closed because it was adopted by a new window.
         // Copy its ID to the new tab, in case it was created as the first tab
         // of a new window, and did not have an `adoptedTab` detail when it was
         // opened.
         this._tabs.set(adoptedBy, this.getId(event.target));
+
+        adoptedBy.linkedBrowser.messageManager.sendAsyncMessage("Extension:SetTabAndWindowId", {
+          windowId: WindowManager.getId(adoptedBy),
+        });
       }
     }
   },
 
   handleWindowOpen(window) {
     if (window.arguments && window.arguments[0] instanceof window.XULElement) {
       // If the first window argument is a XUL element, it means the
       // window is about to adopt a tab from another window to replace its
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -255,16 +255,20 @@ class ProxyContext extends BaseContext {
   get principal() {
     return this.principal_;
   }
 
   get cloneScope() {
     return this.sandbox;
   }
 
+  shutdown() {
+    this.unload();
+  }
+
   unload() {
     if (this.unloaded) {
       return;
     }
     super.unload();
     Management.emit("proxy-context-unload", this);
   }
 }
@@ -299,16 +303,21 @@ class ExtensionChildProxyContext extends
   get tabId() {
     if (!Management.global.TabManager) {
       return;  // Not yet supported on Android.
     }
     let {gBrowser} = this.xulBrowser.ownerGlobal;
     let tab = gBrowser && gBrowser.getTabForBrowser(this.xulBrowser);
     return tab && Management.global.TabManager.getId(tab);
   }
+
+  shutdown() {
+    Management.emit("page-shutdown", this);
+    super.shutdown();
+  }
 }
 
 function findPathInObject(obj, path, printErrors = true) {
   for (let elt of path.split(".")) {
     // If we get a null object before reaching the requested path
     // (e.g. the API object is returned only on particular kind of contexts instead
     // of based on WebExtensions permissions, like it happens for the devtools APIs),
     // stop searching and return undefined.
@@ -352,16 +361,25 @@ var ParentAPIManager = {
     let mm = subject;
     for (let [childId, context] of this.proxyContexts) {
       if (context.messageManager == mm) {
         this.closeProxyContext(childId);
       }
     }
   },
 
+  shutdownExtension(extensionId) {
+    for (let [childId, context] of this.proxyContexts) {
+      if (context.extension.id == extensionId) {
+        context.shutdown();
+        this.proxyContexts.delete(childId);
+      }
+    }
+  },
+
   receiveMessage({name, data, target}) {
     switch (name) {
       case "API:CreateProxyContext":
         this.createProxyContext(data, target);
         break;
 
       case "API:CloseProxyContext":
         this.closeProxyContext(data.childId);
@@ -1579,28 +1597,26 @@ this.Extension = class extends Extension
       ExtensionManagement.shutdownExtension(this.uuid);
 
       this.cleanupGeneratedFile();
       return;
     }
 
     GlobalManager.uninit(this);
 
-    for (let view of this.views) {
-      view.shutdown();
-    }
-
     for (let obj of this.onShutdown) {
       obj.close();
     }
 
     for (let api of this.apis) {
       api.destroy();
     }
 
+    ParentAPIManager.shutdownExtension(this.id);
+
     Management.emit("shutdown", this);
 
     Services.ppmm.broadcastAsyncMessage("Extension:Shutdown", {id: this.id});
 
     MessageChannel.abortResponses({extensionId: this.id});
 
     ExtensionManagement.shutdownExtension(this.uuid);
 
--- a/toolkit/components/extensions/ExtensionChild.jsm
+++ b/toolkit/components/extensions/ExtensionChild.jsm
@@ -41,18 +41,16 @@ var {
 
 // There is a circular dependency between Extension.jsm and us.
 // Long-term this file should not reference Extension.jsm (because they would
 // live in different processes), but for now use lazy getters.
 XPCOMUtils.defineLazyGetter(this, "findPathInObject",
   () => Cu.import("resource://gre/modules/Extension.jsm", {}).findPathInObject);
 XPCOMUtils.defineLazyGetter(this, "GlobalManager",
   () => Cu.import("resource://gre/modules/Extension.jsm", {}).GlobalManager);
-XPCOMUtils.defineLazyGetter(this, "Management",
-  () => Cu.import("resource://gre/modules/Extension.jsm", {}).Management);
 XPCOMUtils.defineLazyGetter(this, "ParentAPIManager",
   () => Cu.import("resource://gre/modules/Extension.jsm", {}).ParentAPIManager);
 
 var apiManager = new class extends SchemaAPIManager {
   constructor() {
     super("addon");
     this.initialized = false;
   }
@@ -131,39 +129,42 @@ class WannabeChildAPIManager extends Chi
 // that runs in the chrome process. It's used for background pages
 // (viewType="background"), popups (viewType="popup"), and any extension
 // content loaded into browser tabs (viewType="tab").
 //
 // |params| is an object with the following properties:
 // |viewType| is one of "background", "popup", or "tab".
 // |contentWindow| is the DOM window the content runs in.
 // |uri| is the URI of the content (optional).
-// |docShell| is the docshell the content runs in (optional).
+// |tabId| is the tab's ID, used if viewType is "tab".
 class ExtensionContext extends BaseContext {
   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("ExtensionContext cannot be created in child processes");
     }
 
-    let {viewType, uri, contentWindow} = params;
+    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.uuid};
+    if (viewType == "tab") {
+      sender.tabId = tabId;
+      this.tabId = tabId;
+    }
     if (uri) {
       sender.url = uri.spec;
     }
-    Management.emit("page-load", this, params, sender);
 
     let filter = {extensionId: 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.
     this.messenger = new Messenger(this, [Services.cpmm, this.messageManager], sender, filter, optionalFilter);
@@ -195,23 +196,29 @@ class ExtensionContext extends BaseConte
   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;
+    }
+  }
+
   get externallyVisible() {
     return true;
   }
 
   // Called when the extension shuts down.
   shutdown() {
-    Management.emit("page-shutdown", this);
     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
@@ -224,28 +231,139 @@ class ExtensionContext extends BaseConte
     this.childManager.close();
 
     if (this.externallyVisible) {
       this.extension.views.delete(this);
     }
   }
 }
 
+// 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");
+    }
+  }
+}
+
+
 this.ExtensionChild = {
+  // Map<nsIContentFrameMessageManager, ContentGlobal>
+  contentGlobals: new Map(),
+
   // Map<innerWindowId, ExtensionContext>
   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 {Extension|BrowserExtensionContent} extension
    *     The extension for which the context should be created.
    * @param {nsIDOMWindow} contentWindow The global of the page.
    */
   createExtensionContext(extension, contentWindow) {
@@ -261,42 +379,26 @@ this.ExtensionChild = {
         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 docShell = contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
-                                .getInterface(Ci.nsIDocShell);
-
-    let parentDocument = docShell.parent.QueryInterface(Ci.nsIDocShell)
-                                 .contentViewer.DOMDocument;
-
-    let browser = docShell.chromeEventHandler;
-    // If this is a sub-frame of the add-on manager, use that <browser>
-    // element rather than the top-level chrome event handler.
-    if (contentWindow.frameElement && parentDocument.documentURI == "about:addons") {
-      browser = contentWindow.frameElement;
-    }
-
-    let viewType = "tab";
-    if (browser.hasAttribute("webextension-view-type")) {
-      viewType = browser.getAttribute("webextension-view-type");
-    } else if (browser.classList.contains("inline-options-browser")) {
-      // Options pages are currently displayed inline, but in Chrome
-      // and in our UI mock-ups for a later milestone, they're
-      // pop-ups.
-      viewType = "popup";
-    }
+    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 ExtensionContext(extension, {viewType, contentWindow, uri, docShell});
+    context = new ExtensionContext(extension, {viewType, contentWindow, uri, tabId});
     this.extensionContexts.set(windowId, context);
   },
 
   /**
    * Close the ExtensionContext belonging to the given window, if any.
    *
    * @param {number} windowId The inner window ID of the destroyed context.
    */
--- a/toolkit/components/extensions/ExtensionContent.jsm
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -903,19 +903,21 @@ class ExtensionGlobal {
   }
 }
 
 this.ExtensionContent = {
   globals: new Map(),
 
   init(global) {
     this.globals.set(global, new ExtensionGlobal(global));
+    ExtensionChild.init(global);
   },
 
   uninit(global) {
+    ExtensionChild.uninit(global);
     this.globals.get(global).uninit();
     this.globals.delete(global);
   },
 
   // This helper is exported to be integrated in the devtools RDP actors,
   // that can use it to retrieve the existent WebExtensions ContentScripts
   // of a target window and be able to show the ContentScripts source in the
   // DevTools Debugger panel.
--- a/toolkit/components/extensions/ext-backgroundPage.js
+++ b/toolkit/components/extensions/ext-backgroundPage.js
@@ -71,29 +71,29 @@ BackgroundPage.prototype = {
     yield promiseObserved("chrome-document-global-created",
                           win => win.document == chromeShell.document);
 
     let chromeDoc = yield promiseDocumentLoaded(chromeShell.document);
 
     let browser = chromeDoc.createElement("browser");
     browser.setAttribute("type", "content");
     browser.setAttribute("disableglobalhistory", "true");
-    browser.setAttribute("webextension-view-type", "background");
-    browser.setAttribute("src", url);
     chromeDoc.documentElement.appendChild(browser);
 
     extensions.emit("extension-browser-inserted", browser);
+    browser.messageManager.sendAsyncMessage("Extension:InitExtensionView", {
+      viewType: "background",
+      url,
+    });
 
     yield new Promise(resolve => {
-      browser.addEventListener("load", function onLoad(event) {
-        if (event.target === browser.contentDocument) {
-          browser.removeEventListener("load", onLoad, true);
-          resolve();
-        }
-      }, true);
+      browser.messageManager.addMessageListener("Extension:ExtensionViewLoaded", function onLoad() {
+        browser.messageManager.removeMessageListener("Extension:ExtensionViewLoaded", onLoad);
+        resolve();
+      });
     });
 
     this.webNav = browser.docShell.QueryInterface(Ci.nsIWebNavigation);
 
     let window = this.webNav.document.defaultView;
     this.contentWindow = window;