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
--- 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;