diff options
Diffstat (limited to 'devtools/client/responsive.html/browser/tunnel.js')
-rw-r--r-- | devtools/client/responsive.html/browser/tunnel.js | 619 |
1 files changed, 619 insertions, 0 deletions
diff --git a/devtools/client/responsive.html/browser/tunnel.js b/devtools/client/responsive.html/browser/tunnel.js new file mode 100644 index 000000000..fdbfe8918 --- /dev/null +++ b/devtools/client/responsive.html/browser/tunnel.js @@ -0,0 +1,619 @@ +/* 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"; + +const { Ci } = require("chrome"); +const Services = require("Services"); +const { Task } = require("devtools/shared/task"); +const { BrowserElementWebNavigation } = require("./web-navigation"); +const { getStack } = require("devtools/shared/platform/stack"); + +// A symbol used to hold onto the frame loader from the outer browser while tunneling. +const FRAME_LOADER = Symbol("devtools/responsive/frame-loader"); + +function debug(msg) { + // console.log(msg); +} + +/** + * Properties swapped between browsers by browser.xml's `swapDocShells`. See also the + * list at /devtools/client/responsive.html/docs/browser-swap.md. + */ +const SWAPPED_BROWSER_STATE = [ + "_remoteFinder", + "_securityUI", + "_documentURI", + "_documentContentType", + "_contentTitle", + "_characterSet", + "_contentPrincipal", + "_imageDocument", + "_fullZoom", + "_textZoom", + "_isSyntheticDocument", + "_innerWindowID", + "_manifestURI", +]; + +/** + * This module takes an "outer" <xul:browser> from a browser tab as described by + * Firefox's tabbrowser.xml and wires it up to an "inner" <iframe mozbrowser> + * browser element containing arbitrary page content of interest. + * + * The inner <iframe mozbrowser> element is _just_ the page content. It is not + * enough to to replace <xul:browser> on its own. <xul:browser> comes along + * with lots of associated functionality via XBL bindings defined for such + * elements in browser.xml and remote-browser.xml, and the Firefox UI depends on + * these various things to make the UI function. + * + * By mapping various methods, properties, and messages from the outer browser + * to the inner browser, we can control the content inside the inner browser + * using the standard Firefox UI elements for navigation, reloading, and more. + * + * The approaches used in this module were chosen to avoid needing changes to + * the core browser for this specialized use case. If we start to increase + * usage of <iframe mozbrowser> in the core browser, we should avoid this module + * and instead refactor things to work with mozbrowser directly. + * + * For the moment though, this serves as a sufficient path to connect the + * Firefox UI to a mozbrowser. + * + * @param outer + * A <xul:browser> from a regular browser tab. + * @param inner + * A <iframe mozbrowser> containing page content to be wired up to the + * primary browser UI via the outer browser. + */ +function tunnelToInnerBrowser(outer, inner) { + let browserWindow = outer.ownerDocument.defaultView; + let gBrowser = browserWindow.gBrowser; + let mmTunnel; + + return { + + start: Task.async(function* () { + if (outer.isRemoteBrowser) { + throw new Error("The outer browser must be non-remote."); + } + if (!inner.isRemoteBrowser) { + throw new Error("The inner browser must be remote."); + } + + // Various browser methods access the `frameLoader` property, including: + // * `saveBrowser` from contentAreaUtils.js + // * `docShellIsActive` from remote-browser.xml + // * `hasContentOpener` from remote-browser.xml + // * `preserveLayers` from remote-browser.xml + // * `receiveMessage` from SessionStore.jsm + // In general, these methods are interested in the `frameLoader` for the content, + // so we redirect them to the inner browser's `frameLoader`. + outer[FRAME_LOADER] = outer.frameLoader; + Object.defineProperty(outer, "frameLoader", { + get() { + let stack = getStack(); + // One exception is `receiveMessage` from SessionStore.jsm. SessionStore + // expects data updates to come in as messages targeted to a <xul:browser>. + // In addition, it verifies[1] correctness by checking that the received + // message's `targetFrameLoader` property matches the `frameLoader` of the + // <xul:browser>. To keep SessionStore functioning as expected, we give it the + // outer `frameLoader` as if nothing has changed. + // [1]: https://dxr.mozilla.org/mozilla-central/rev/b1b18f25c0ea69d9ee57c4198d577dfcd0129ce1/browser/components/sessionstore/SessionStore.jsm#716 + if (stack.caller.filename.endsWith("SessionStore.jsm")) { + return outer[FRAME_LOADER]; + } + return inner.frameLoader; + }, + configurable: true, + enumerable: true, + }); + + // The `outerWindowID` of the content is used by browser actions like view source + // and print. They send the ID down to the client to find the right content frame + // to act on. + Object.defineProperty(outer, "outerWindowID", { + get() { + return inner.outerWindowID; + }, + configurable: true, + enumerable: true, + }); + + // The `permanentKey` property on a <xul:browser> is used to index into various maps + // held by the session store. When you swap content around with + // `_swapBrowserDocShells`, these keys are also swapped so they follow the content. + // This means the key that matches the content is on the inner browser. Since we + // want the browser UI to believe the page content is part of the outer browser, we + // copy the content's `permanentKey` up to the outer browser. + debug("Copy inner permanentKey to outer browser"); + outer.permanentKey = inner.permanentKey; + + // Replace the outer browser's native messageManager with a message manager tunnel + // which we can use to route messages of interest to the inner browser instead. + // Note: The _actual_ messageManager accessible from + // `browser.frameLoader.messageManager` is not overridable and is left unchanged. + // Only the XBL getter `browser.messageManager` is overridden. Browser UI code + // always uses this getter instead of `browser.frameLoader.messageManager` directly, + // so this has the effect of overriding the message manager for browser UI code. + mmTunnel = new MessageManagerTunnel(outer, inner); + + // We are tunneling to an inner browser with a specific remoteness, so it is simpler + // for the logic of the browser UI to assume this tab has taken on that remoteness, + // even though it's not true. Since the actions the browser UI performs are sent + // down to the inner browser by this tunnel, the tab's remoteness effectively is the + // remoteness of the inner browser. + outer.setAttribute("remote", "true"); + + // Clear out any cached state that references the current non-remote XBL binding, + // such as form fill controllers. Otherwise they will remain in place and leak the + // outer docshell. + outer.destroy(); + // The XBL binding for remote browsers uses the message manager for many actions in + // the UI and that works well here, since it gives us one main thing we need to + // route to the inner browser (the messages), instead of having to tweak many + // different browser properties. It is safe to alter a XBL binding dynamically. + // The content within is not reloaded. + outer.style.MozBinding = "url(chrome://browser/content/tabbrowser.xml" + + "#tabbrowser-remote-browser)"; + + // The constructor of the new XBL binding is run asynchronously and there is no + // event to signal its completion. Spin an event loop to watch for properties that + // are set by the contructor. + while (!outer._remoteWebNavigation) { + Services.tm.currentThread.processNextEvent(true); + } + + // Replace the `webNavigation` object with our own version which tries to use + // mozbrowser APIs where possible. This replaces the webNavigation object that the + // remote-browser.xml binding creates. We do not care about it's original value + // because stop() will remove the remote-browser.xml binding and these will no + // longer be used. + let webNavigation = new BrowserElementWebNavigation(inner); + webNavigation.copyStateFrom(inner._remoteWebNavigationImpl); + outer._remoteWebNavigation = webNavigation; + outer._remoteWebNavigationImpl = webNavigation; + + // Now that we've flipped to the remote browser XBL binding, add `progressListener` + // onto the remote version of `webProgress`. Normally tabbrowser.xml does this step + // when it creates a new browser, etc. Since we manually changed the XBL binding + // above, it caused a fresh webProgress object to be created which does not have any + // listeners added. So, we get the listener that gBrowser is using for the tab and + // reattach it here. + let tab = gBrowser.getTabForBrowser(outer); + let filteredProgressListener = gBrowser._tabFilters.get(tab); + outer.webProgress.addProgressListener(filteredProgressListener); + + // Add the inner browser to tabbrowser's WeakMap from browser to tab. This assists + // with tabbrowser's processing of some events such as MozLayerTreeReady which + // bubble up from the remote content frame and trigger tabbrowser to lookup the tab + // associated with the browser that triggered the event. + gBrowser._tabForBrowser.set(inner, tab); + + // All of the browser state from content was swapped onto the inner browser. Pull + // this state up to the outer browser. + for (let property of SWAPPED_BROWSER_STATE) { + outer[property] = inner[property]; + } + + // Expose `PopupNotifications` on the content's owner global. + // This is used by PermissionUI.jsm for permission doorhangers. + // Note: This pollutes the responsive.html tool UI's global. + Object.defineProperty(inner.ownerGlobal, "PopupNotifications", { + get() { + return outer.ownerGlobal.PopupNotifications; + }, + configurable: true, + enumerable: true, + }); + + // Expose `whereToOpenLink` on the content's owner global. + // This is used by ContentClick.jsm when opening links in ways other than just + // navigating the viewport. + // Note: This pollutes the responsive.html tool UI's global. + Object.defineProperty(inner.ownerGlobal, "whereToOpenLink", { + get() { + return outer.ownerGlobal.whereToOpenLink; + }, + configurable: true, + enumerable: true, + }); + + // Add mozbrowser event handlers + inner.addEventListener("mozbrowseropenwindow", this); + }), + + handleEvent(event) { + if (event.type != "mozbrowseropenwindow") { + return; + } + + // Minimal support for <a target/> and window.open() which just ensures we at + // least open them somewhere (in a new tab). The following things are ignored: + // * Specific target names (everything treated as _blank) + // * Window features + // * window.opener + // These things are deferred for now, since content which does depend on them seems + // outside the main focus of RDM. + let { detail } = event; + event.preventDefault(); + let uri = Services.io.newURI(detail.url, null, null); + // This API is used mainly because it's near the path used for <a target/> with + // regular browser tabs (which calls `openURIInFrame`). The more elaborate APIs + // that support openers, window features, etc. didn't seem callable from JS and / or + // this event doesn't give enough info to use them. + browserWindow.browserDOMWindow + .openURI(uri, null, Ci.nsIBrowserDOMWindow.OPEN_NEWTAB, + Ci.nsIBrowserDOMWindow.OPEN_NEW); + }, + + stop() { + let tab = gBrowser.getTabForBrowser(outer); + let filteredProgressListener = gBrowser._tabFilters.get(tab); + + // The browser's state has changed over time while the tunnel was active. Push the + // the current state down to the inner browser, so that it follows the content in + // case that browser will be swapped elsewhere. + for (let property of SWAPPED_BROWSER_STATE) { + inner[property] = outer[property]; + } + + // Remove the inner browser from the WeakMap from browser to tab. + gBrowser._tabForBrowser.delete(inner); + + // Remove the progress listener we added manually. + outer.webProgress.removeProgressListener(filteredProgressListener); + + // Reset the XBL binding back to the default. + outer.destroy(); + outer.style.MozBinding = ""; + + // Reset @remote since this is now back to a regular, non-remote browser + outer.setAttribute("remote", "false"); + + // Delete browser window properties exposed on content's owner global + delete inner.ownerGlobal.PopupNotifications; + delete inner.ownerGlobal.whereToOpenLink; + + // Remove mozbrowser event handlers + inner.removeEventListener("mozbrowseropenwindow", this); + + mmTunnel.destroy(); + mmTunnel = null; + + // Reset overridden XBL properties and methods. Deleting the override + // means it will fallback to the original XBL binding definitions which + // are on the prototype. + delete outer.frameLoader; + delete outer[FRAME_LOADER]; + delete outer.outerWindowID; + + // Invalidate outer's permanentKey so that SessionStore stops associating + // things that happen to the outer browser with the content inside in the + // inner browser. + outer.permanentKey = { id: "zombie" }; + + browserWindow = null; + gBrowser = null; + }, + + }; +} + +exports.tunnelToInnerBrowser = tunnelToInnerBrowser; + +/** + * This module allows specific messages of interest to be directed from the + * outer browser to the inner browser (and vice versa) in a targetted fashion + * without having to touch the original code paths that use them. + */ +function MessageManagerTunnel(outer, inner) { + if (outer.isRemoteBrowser) { + throw new Error("The outer browser must be non-remote."); + } + this.outer = outer; + this.inner = inner; + this.tunneledMessageNames = new Set(); + this.init(); +} + +MessageManagerTunnel.prototype = { + + /** + * Most message manager methods are left alone and are just passed along to + * the outer browser's real message manager. + */ + PASS_THROUGH_METHODS: [ + "killChild", + "assertPermission", + "assertContainApp", + "assertAppHasPermission", + "assertAppHasStatus", + "removeDelayedFrameScript", + "getDelayedFrameScripts", + "loadProcessScript", + "removeDelayedProcessScript", + "getDelayedProcessScripts", + "addWeakMessageListener", + "removeWeakMessageListener", + ], + + /** + * The following methods are overridden with special behavior while tunneling. + */ + OVERRIDDEN_METHODS: [ + "loadFrameScript", + "addMessageListener", + "removeMessageListener", + "sendAsyncMessage", + ], + + OUTER_TO_INNER_MESSAGES: [ + // Messages sent from remote-browser.xml + "Browser:PurgeSessionHistory", + "InPermitUnload", + "PermitUnload", + // Messages sent from browser.js + "Browser:Reload", + // Messages sent from SelectParentHelper.jsm + "Forms:DismissedDropDown", + "Forms:MouseOut", + "Forms:MouseOver", + "Forms:SelectDropDownItem", + // Messages sent from SessionStore.jsm + "SessionStore:flush", + ], + + INNER_TO_OUTER_MESSAGES: [ + // Messages sent to RemoteWebProgress.jsm + "Content:LoadURIResult", + "Content:LocationChange", + "Content:ProgressChange", + "Content:SecurityChange", + "Content:StateChange", + "Content:StatusChange", + // Messages sent to remote-browser.xml + "DOMTitleChanged", + "ImageDocumentLoaded", + "Forms:ShowDropDown", + "Forms:HideDropDown", + "InPermitUnload", + "PermitUnload", + // Messages sent to tabbrowser.xml + "contextmenu", + // Messages sent to SelectParentHelper.jsm + "Forms:UpdateDropDown", + // Messages sent to browser.js + "PageVisibility:Hide", + "PageVisibility:Show", + // Messages sent to SessionStore.jsm + "SessionStore:update", + // Messages sent to BrowserTestUtils.jsm + "browser-test-utils:loadEvent", + ], + + OUTER_TO_INNER_MESSAGE_PREFIXES: [ + // Messages sent from nsContextMenu.js + "ContextMenu:", + // Messages sent from DevTools + "debug:", + // Messages sent from findbar.xml + "Findbar:", + // Messages sent from RemoteFinder.jsm + "Finder:", + // Messages sent from InlineSpellChecker.jsm + "InlineSpellChecker:", + // Messages sent from pageinfo.js + "PageInfo:", + // Messages sent from printUtils.js + "Printing:", + // Messages sent from browser-social.js + "Social:", + "PageMetadata:", + // Messages sent from viewSourceUtils.js + "ViewSource:", + ], + + INNER_TO_OUTER_MESSAGE_PREFIXES: [ + // Messages sent to nsContextMenu.js + "ContextMenu:", + // Messages sent to DevTools + "debug:", + // Messages sent to findbar.xml + "Findbar:", + // Messages sent to RemoteFinder.jsm + "Finder:", + // Messages sent to pageinfo.js + "PageInfo:", + // Messages sent to printUtils.js + "Printing:", + // Messages sent to browser-social.js + "Social:", + "PageMetadata:", + // Messages sent to viewSourceUtils.js + "ViewSource:", + ], + + OUTER_TO_INNER_FRAME_SCRIPTS: [ + // DevTools server for OOP frames + "resource://devtools/server/child.js" + ], + + get outerParentMM() { + if (!this.outer[FRAME_LOADER]) { + return null; + } + return this.outer[FRAME_LOADER].messageManager; + }, + + get outerChildMM() { + // This is only possible because we require the outer browser to be + // non-remote, so we're able to reach into its window and use the child + // side message manager there. + let docShell = this.outer[FRAME_LOADER].docShell; + return docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIContentFrameMessageManager); + }, + + get innerParentMM() { + if (!this.inner.frameLoader) { + return null; + } + return this.inner.frameLoader.messageManager; + }, + + init() { + for (let method of this.PASS_THROUGH_METHODS) { + // Workaround bug 449811 to ensure a fresh binding each time through the loop + let _method = method; + this[_method] = (...args) => { + if (!this.outerParentMM) { + return null; + } + return this.outerParentMM[_method](...args); + }; + } + + for (let name of this.INNER_TO_OUTER_MESSAGES) { + this.innerParentMM.addMessageListener(name, this); + this.tunneledMessageNames.add(name); + } + + Services.obs.addObserver(this, "message-manager-close", false); + + // Replace the outer browser's messageManager with this tunnel + Object.defineProperty(this.outer, "messageManager", { + value: this, + writable: false, + configurable: true, + enumerable: true, + }); + }, + + destroy() { + if (this.destroyed) { + return; + } + this.destroyed = true; + debug("Destroy tunnel"); + + // Watch for the messageManager to close. In most cases, the caller will stop the + // tunnel gracefully before this, but when the browser window closes or application + // exits, we may not see the high-level close events. + Services.obs.removeObserver(this, "message-manager-close"); + + // Reset the messageManager. Deleting the override means it will fallback to the + // original XBL binding definitions which are on the prototype. + delete this.outer.messageManager; + + for (let name of this.tunneledMessageNames) { + this.innerParentMM.removeMessageListener(name, this); + } + + // Some objects may have cached this tunnel as the messageManager for a frame. To + // ensure it keeps working after tunnel close, rewrite the overidden methods as pass + // through methods. + for (let method of this.OVERRIDDEN_METHODS) { + // Workaround bug 449811 to ensure a fresh binding each time through the loop + let _method = method; + this[_method] = (...args) => { + if (!this.outerParentMM) { + return null; + } + return this.outerParentMM[_method](...args); + }; + } + }, + + observe(subject, topic, data) { + if (topic != "message-manager-close") { + return; + } + if (subject == this.innerParentMM) { + debug("Inner messageManager has closed"); + this.destroy(); + } + if (subject == this.outerParentMM) { + debug("Outer messageManager has closed"); + this.destroy(); + } + }, + + loadFrameScript(url, ...args) { + debug(`Calling loadFrameScript for ${url}`); + + if (!this.OUTER_TO_INNER_FRAME_SCRIPTS.includes(url)) { + debug(`Should load ${url} into inner?`); + this.outerParentMM.loadFrameScript(url, ...args); + return; + } + + debug(`Load ${url} into inner`); + this.innerParentMM.loadFrameScript(url, ...args); + }, + + addMessageListener(name, ...args) { + debug(`Calling addMessageListener for ${name}`); + + debug(`Add outer listener for ${name}`); + // Add an outer listener, just like a simple pass through + this.outerParentMM.addMessageListener(name, ...args); + + // If the message name is part of a prefix we're tunneling, we also need to add the + // tunnel as an inner listener. + if (this.INNER_TO_OUTER_MESSAGE_PREFIXES.some(prefix => name.startsWith(prefix))) { + debug(`Add inner listener for ${name}`); + this.innerParentMM.addMessageListener(name, this); + this.tunneledMessageNames.add(name); + } + }, + + removeMessageListener(name, ...args) { + debug(`Calling removeMessageListener for ${name}`); + + debug(`Remove outer listener for ${name}`); + // Remove an outer listener, just like a simple pass through + this.outerParentMM.removeMessageListener(name, ...args); + + // Leave the tunnel as an inner listener for the case of prefix messages to avoid + // tracking counts of add calls. The inner listener will get removed on destroy. + }, + + sendAsyncMessage(name, ...args) { + debug(`Calling sendAsyncMessage for ${name}`); + + if (!this._shouldTunnelOuterToInner(name)) { + debug(`Should ${name} go to inner?`); + this.outerParentMM.sendAsyncMessage(name, ...args); + return; + } + + debug(`${name} outer -> inner`); + this.innerParentMM.sendAsyncMessage(name, ...args); + }, + + receiveMessage({ name, data, objects, principal, sync }) { + if (!this._shouldTunnelInnerToOuter(name)) { + debug(`Received unexpected message ${name}`); + return undefined; + } + + debug(`${name} inner -> outer, sync: ${sync}`); + if (sync) { + return this.outerChildMM.sendSyncMessage(name, data, objects, principal); + } + this.outerChildMM.sendAsyncMessage(name, data, objects, principal); + return undefined; + }, + + _shouldTunnelOuterToInner(name) { + return this.OUTER_TO_INNER_MESSAGES.includes(name) || + this.OUTER_TO_INNER_MESSAGE_PREFIXES.some(prefix => name.startsWith(prefix)); + }, + + _shouldTunnelInnerToOuter(name) { + return this.INNER_TO_OUTER_MESSAGES.includes(name) || + this.INNER_TO_OUTER_MESSAGE_PREFIXES.some(prefix => name.startsWith(prefix)); + }, + +}; |