diff --git a/devtools/server/actors/resources/parent-process-document-event.js b/devtools/server/actors/resources/parent-process-document-event.js index 190a04f64598..055c4a1d802c 100644 --- a/devtools/server/actors/resources/parent-process-document-event.js +++ b/devtools/server/actors/resources/parent-process-document-event.js @@ -15,8 +15,8 @@ const isEveryFrameTargetEnabled = Services.prefs.getBoolPref( false ); const { - getAllRemoteBrowsingContexts, -} = require("devtools/server/actors/watcher/target-helpers/utils.js"); + getAllBrowsingContextsForContext, +} = require("devtools/server/actors/watcher/browsing-context-helpers.jsm"); const { WILL_NAVIGATE_TIME_SHIFT, } = require("devtools/server/actors/webconsole/listeners/document-events"); @@ -54,9 +54,9 @@ class ParentProcessDocumentEventWatcher { this._onceWillNavigate = new Map(); // Filter browsing contexts to only have the top BrowsingContext of each tree of BrowsingContexts… - const topLevelBrowsingContexts = this.getAllBrowsingContexts().filter( - browsingContext => browsingContext.top == browsingContext - ); + const topLevelBrowsingContexts = getAllBrowsingContextsForContext( + this.watcherActor.sessionContext + ).filter(browsingContext => browsingContext.top == browsingContext); // Only register one WebProgressListener per BrowsingContext tree. // We will be notified about children BrowsingContext navigations/state changes via the top level BrowsingContextWebProgressListener, @@ -79,29 +79,6 @@ class ParentProcessDocumentEventWatcher { }); } - getAllBrowsingContexts() { - if (this.watcherActor.sessionContext.type == "browser-element") { - const browsingContext = this.watcherActor.browserElement.browsingContext; - return browsingContext.getAllBrowsingContextsInSubtree(); - } - - if (this.watcherActor.sessionContext.type == "all") { - return getAllRemoteBrowsingContexts(); - } - - if (this.watcherActor.sessionContext.type == "webextension") { - return getAllRemoteBrowsingContexts().filter( - bc => - bc.currentWindowGlobal.documentPrincipal.addonId == - this.watcherActor.sessionContext.addonId - ); - } - throw new Error( - "Unsupported session context type=" + - this.watcherActor.sessionContext.type - ); - } - /** * Wait for the emission of will-navigate for a given WindowGlobal * diff --git a/devtools/server/actors/watcher.js b/devtools/server/actors/watcher.js index bfc7a69554f9..67fa40b8cffe 100644 --- a/devtools/server/actors/watcher.js +++ b/devtools/server/actors/watcher.js @@ -14,6 +14,9 @@ const { WatcherRegistry, } = require("devtools/server/actors/watcher/WatcherRegistry.jsm"); const Targets = require("devtools/server/actors/targets/index"); +const { + getAllBrowsingContextsForContext, +} = require("devtools/server/actors/watcher/browsing-context-helpers.jsm"); const TARGET_HELPERS = {}; loader.lazyRequireGetter( @@ -143,6 +146,10 @@ exports.WatcherActor = protocol.ActorClassWithSpec(watcherSpec, { return this._browserElement; }, + getAllBrowsingContexts(options) { + return getAllBrowsingContextsForContext(this.sessionContext, options); + }, + /** * Helper to know if the context we are debugging has been already destroyed */ diff --git a/devtools/server/actors/watcher/browsing-context-helpers.jsm b/devtools/server/actors/watcher/browsing-context-helpers.jsm new file mode 100644 index 000000000000..2302fe6473c6 --- /dev/null +++ b/devtools/server/actors/watcher/browsing-context-helpers.jsm @@ -0,0 +1,297 @@ +/* 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 EXPORTED_SYMBOLS = [ + "isBrowsingContextPartOfContext", + "isWindowGlobalPartOfContext", + "getAllBrowsingContextsForContext", +]; + +let Services; +if (typeof module == "object") { + // Allow this JSM to also be loaded as a CommonJS module + Services = require("Services"); +} else { + ({ Services } = ChromeUtils.import("resource://gre/modules/Services.jsm")); +} + +const isEveryFrameTargetEnabled = Services.prefs.getBoolPref( + "devtools.every-frame-target.enabled", + false +); + +/** + * Helper function to know if a given BrowsingContext should be debugged by scope + * described by the given session context. + * + * @param {BrowsingContext} browsingContext + * The browsing context we want to check if it is part of debugged context + * @param {Object} sessionContext + * WatcherActor's session context. This helps know what is the overall debugged scope. + * See watcher actor constructor for more info. + * @param {Object} options + * Optional arguments passed via a dictionary. + * @param {Boolean} options.forceAcceptTopLevelTarget + * If true, we will accept top level browsing context even when server target switching + * is disabled. In case of client side target switching, the top browsing context + * is debugged via a target actor that is being instantiated manually by the frontend. + * And this target actor isn't created, nor managed by the watcher actor. + * @param {WindowGlobal} options.windowGlobal + * When we are in the content process, we can't easily retrieve the WindowGlobal + * for a given BrowsingContext. So allow to pass it via this argument. + * Also, there is some race conditions where browsingContext.currentWindowGlobal + * is null, while the callsite may have a reference to the WindowGlobal. + */ +function isBrowsingContextPartOfContext( + browsingContext, + sessionContext, + { forceAcceptTopLevelTarget = false, windowGlobal } = {} +) { + // When we are in the parent process, we have some parent-process only checks, + // made possible thanks to the extra attributes available on CanonicalBrowsingContext interface. + if (browsingContext instanceof CanonicalBrowsingContext) { + windowGlobal = browsingContext.currentWindowGlobal; + // Loading or destroying BrowsingContext won't have any associated WindowGlobal. + // Ignore them. They should be either handled via DOMWindowCreated event or JSWindowActor destroy + if (!windowGlobal) { + return false; + } + + // For now, reject debugging chrome BrowsingContext. + // This is for example top level chrome windows like browser.xhtml or webconsole/index.html (only the browser console) + // + // Tab and WebExtension debugging shouldn't target any such privileged document. + // All their document should be of type "content". + // + // This may only be an issue for the Browser Toolbox. + // For now, we expect the ParentProcessTargetActor to debug these. + // Note that we should probably revisit that, and have each WindowGlobal be debugged + // by one dedicated WindowGlobalTargetActor (bug 1685500). This requires some tweaks, at least in console-message + // resource watcher, which makes the ParentProcessTarget's console message resource watcher watch + // for all documents messages. It should probably only care about window-less messages and have one target per window global, + // each target fetching one window global messages. + // + // Such project would be about applying "EFT" to the browser toolbox and non-content documents + if (!browsingContext.isContent) { + return false; + } + } else if (!windowGlobal) { + throw new Error( + "isBrowsingContextPartOfContext expect a windowGlobal argument when called from the content process" + ); + } + + if (sessionContext.type == "all") { + // see browser-element jsdoc + if (!isEveryFrameTargetEnabled && !windowGlobal.isProcessRoot) { + return false; + } + return true; + } + if (sessionContext.type == "browser-element") { + if (browsingContext.browserId != sessionContext.browserId) { + return false; + } + // For client-side target switching, only mention the "remote frames". + // i.e. the frames which are in a distinct process compared to their parent document + // If there is no parent, this is most likely the top level document which we want to ignore. + // + // `forceAcceptTopLevelTarget` is set: + // * when navigating to and from pages in the bfcache, we ignore client side target + // and start emitting top level target from the server. + // * when the callsite care about all the debugged browsing contexts, + // no matter if their related targets are created by client or server. + const isClientSideTargetSwitching = !sessionContext.isServerTargetSwitchingEnabled; + const isTopLevelBrowsingContext = !browsingContext.parent; + if ( + isClientSideTargetSwitching && + !forceAcceptTopLevelTarget && + isTopLevelBrowsingContext + ) { + return false; + } + // We may process an iframe that runs in the same process as its parent and we don't want + // to create targets for them if same origin targets (=EFT) are not enabled. + // Instead the WindowGlobalTargetActor will inspect these children document via docShell tree + // (typically via `docShells` or `windows` getters). + // This is quite common when Fission is off as any iframe will run in same process + // as their parent document. But it can also happen with Fission enabled if iframes have + // children iframes using the same origin. + if (!isEveryFrameTargetEnabled && !windowGlobal.isProcessRoot) { + return false; + } + return true; + } + if (sessionContext.type == "webextension") { + // documentPrincipal is only exposed on WindowGlobalParent, + // use a fallback for WindowGlobalChild. + const principal = + windowGlobal.documentPrincipal || + browsingContext.window.document.nodePrincipal; + return principal.addonId == sessionContext.addonId; + } + throw new Error("Unsupported session context type: " + sessionContext.type); +} + +/** + * Helper function to know if a given WindowGlobal should be debugged by scope + * described by the given session context. This method could be called from any process + * as so accept either WindowGlobalParent or WindowGlobalChild instances. + * + * @param {WindowGlobalParent|WindowGlobalChild} windowGlobal + * The WindowGlobal we want to check if it is part of debugged context + * @param {Object} sessionContext + * WatcherActor's session context. This helps know what is the overall debugged scope. + * See watcher actor constructor for more info. + * @param {Object} options + * Optional arguments passed via a dictionary. + * See `isBrowsingContextPartOfContext` jsdoc. + */ +function isWindowGlobalPartOfContext( + windowGlobal, + sessionContext, + { forceAcceptTopLevelTarget = false, acceptInitialDocument = false } = {} +) { + const window = Services.wm.getCurrentInnerWindowWithId( + windowGlobal.innerWindowId + ); + + // We sometimes process WindowGlobal's that run in another content process. + // (It is a bit surprising to have a JSWindowActorChild to be instantiated for + // WindowGlobals that aren't from this process??) + // And filtering them out is hard because: + // * `isInProcess` is always false, even if the window runs in the same process. + // It will only become true for pages in parent process. But the WindowGlobal + // may run in another content process. + // * `osPid` attribute is not set on WindowGlobalChild + // + // So it is hard to guess if the given WindowGlobal runs in this process or not, + // which is what we want to know here. Here is a workaround way to know it :/ + if (Cu.isRemoteProxy(window)) { + return false; + } + + // By default, before loading the actual document (even an about:blank document), + // we do load immediately "the initial about:blank document". + // This is expected by the spec. Typically when creating a new BrowsingContext/DocShell/iframe, + // we would have such transient initial document. + // `Document.isInitialDocument` helps identify this transient document, which + // we want to ignore as it would instantiate a very short lived target which + // confuses many tests and triggers race conditions by spamming many targets. + // + // We also ignore some other transient empty documents created while using `window.open()` + // When using this API with cross process loads, we may create up to three documents/WindowGlobals. + // We get a first initial about:blank document, and a second document created + // for moving the document in the right principal. + // The third document will be the actual document we expect to debug. + // The second document is an implementation artifact which ideally wouldn't exist + // and isn't expected by the spec. + // Note that `window.print` and print preview are using `window.open` and are going through this. + // + // WindowGlobalParent will have `isInitialDocument` attribute, while we have to go through the Document for WindowGlobalChild. + if ( + (windowGlobal.isInitialDocument || window?.document.isInitialDocument) && + !acceptInitialDocument + ) { + return false; + } + return isBrowsingContextPartOfContext( + windowGlobal.browsingContext, + sessionContext, + { + forceAcceptTopLevelTarget, + windowGlobal, + } + ); +} + +/** + * Get all the BrowsingContexts that should be debugged by the given session context. + * + * Really all of them: + * - For all the privileged windows (browser.xhtml, browser console, ...) + * - For all chrome *and* content contexts (privileged windows, as well as elements and their inner content documents) + * - For all nested browsing context. We fetch the contexts recursively. + * + * @param {Object} sessionContext + * WatcherActor's session context. This helps know what is the overall debugged scope. + * See watcher actor constructor for more info. + */ +function getAllBrowsingContextsForContext(sessionContext) { + const browsingContexts = []; + + // For a given BrowsingContext, add the `browsingContext` + // all of its children, that, recursively. + function walk(browsingContext) { + if (browsingContexts.includes(browsingContext)) { + return; + } + browsingContexts.push(browsingContext); + + for (const child of browsingContext.children) { + walk(child); + } + + if ( + (sessionContext.type == "all" || sessionContext.type == "webextension") && + browsingContext.window + ) { + // If the document is in the parent process, also iterate over each 's browsing context. + // BrowsingContext.children doesn't cross chrome to content boundaries, + // so we have to cross these boundaries by ourself. + // (This is also the reason why we aren't using BrowsingContext.getAllBrowsingContextsInSubtree()) + for (const browser of browsingContext.window.document.querySelectorAll( + `browser[remote="true"]` + )) { + walk(browser.browsingContext); + } + } + } + + // If target a single browser element, only walk through its BrowsingContext + if (sessionContext.type == "browser-element") { + const topBrowsingContext = BrowsingContext.getCurrentTopByBrowserId( + sessionContext.browserId + ); + // Unfortunately, getCurrentTopByBrowserId is subject to race conditions and may refer to a BrowsingContext + // that already navigated away. + // Query the current "live" BrowsingContext by going through the embedder element (i.e. the /