From 36d22d82aa202bb199967e9512281e9a53db42c9 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 21:33:14 +0200 Subject: Adding upstream version 115.7.0esr. Signed-off-by: Daniel Baumann --- .../server/actors/watcher/SessionDataHelpers.jsm | 206 ++++++++++ .../server/actors/watcher/WatcherRegistry.sys.mjs | 379 ++++++++++++++++++ .../watcher/browsing-context-helpers.sys.mjs | 428 +++++++++++++++++++++ devtools/server/actors/watcher/moz.build | 16 + devtools/server/actors/watcher/session-context.js | 215 +++++++++++ .../actors/watcher/target-helpers/frame-helper.js | 322 ++++++++++++++++ .../server/actors/watcher/target-helpers/moz.build | 11 + .../watcher/target-helpers/process-helper.js | 380 ++++++++++++++++++ .../actors/watcher/target-helpers/worker-helper.js | 128 ++++++ 9 files changed, 2085 insertions(+) create mode 100644 devtools/server/actors/watcher/SessionDataHelpers.jsm create mode 100644 devtools/server/actors/watcher/WatcherRegistry.sys.mjs create mode 100644 devtools/server/actors/watcher/browsing-context-helpers.sys.mjs create mode 100644 devtools/server/actors/watcher/moz.build create mode 100644 devtools/server/actors/watcher/session-context.js create mode 100644 devtools/server/actors/watcher/target-helpers/frame-helper.js create mode 100644 devtools/server/actors/watcher/target-helpers/moz.build create mode 100644 devtools/server/actors/watcher/target-helpers/process-helper.js create mode 100644 devtools/server/actors/watcher/target-helpers/worker-helper.js (limited to 'devtools/server/actors/watcher') diff --git a/devtools/server/actors/watcher/SessionDataHelpers.jsm b/devtools/server/actors/watcher/SessionDataHelpers.jsm new file mode 100644 index 0000000000..9d448d7bc4 --- /dev/null +++ b/devtools/server/actors/watcher/SessionDataHelpers.jsm @@ -0,0 +1,206 @@ +/* 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"; + +/** + * Helper module alongside WatcherRegistry, which focus on updating the "sessionData" object. + * This object is shared across processes and threads and have to be maintained in all these runtimes. + */ + +var EXPORTED_SYMBOLS = ["SessionDataHelpers"]; + +const lazy = {}; + +if (typeof module == "object") { + // Allow this JSM to also be loaded as a CommonJS module + // Because this module is used from the worker thread, + // (via target-actor-mixin), and workers can't load JSMs via ChromeUtils.import. + loader.lazyRequireGetter( + lazy, + "validateBreakpointLocation", + "resource://devtools/shared/validate-breakpoint.jsm", + true + ); + + loader.lazyRequireGetter( + lazy, + "validateEventBreakpoint", + "resource://devtools/server/actors/utils/event-breakpoints.js", + true + ); +} else { + const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" + ); + // Ignore the "duplicate" definitions here as this are also defined + // in the if block above. + // eslint-disable-next-line mozilla/valid-lazy + XPCOMUtils.defineLazyGetter(lazy, "validateBreakpointLocation", () => { + return ChromeUtils.import( + "resource://devtools/shared/validate-breakpoint.jsm" + ).validateBreakpointLocation; + }); + // eslint-disable-next-line mozilla/valid-lazy + XPCOMUtils.defineLazyGetter(lazy, "validateEventBreakpoint", () => { + const { loader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + return loader.require( + "resource://devtools/server/actors/utils/event-breakpoints.js" + ).validateEventBreakpoint; + }); +} + +// List of all arrays stored in `sessionData`, which are replicated across processes and threads +const SUPPORTED_DATA = { + BLACKBOXING: "blackboxing", + BREAKPOINTS: "breakpoints", + XHR_BREAKPOINTS: "xhr-breakpoints", + EVENT_BREAKPOINTS: "event-breakpoints", + RESOURCES: "resources", + TARGET_CONFIGURATION: "target-configuration", + THREAD_CONFIGURATION: "thread-configuration", + TARGETS: "targets", +}; + +// Optional function, if data isn't a primitive data type in order to produce a key +// for the given data entry +const DATA_KEY_FUNCTION = { + [SUPPORTED_DATA.BLACKBOXING]({ url, range }) { + return ( + url + + (range + ? `:${range.start.line}:${range.start.column}-${range.end.line}:${range.end.column}` + : "") + ); + }, + [SUPPORTED_DATA.BREAKPOINTS]({ location }) { + lazy.validateBreakpointLocation(location); + const { sourceUrl, sourceId, line, column } = location; + return `${sourceUrl}:${sourceId}:${line}:${column}`; + }, + [SUPPORTED_DATA.TARGET_CONFIGURATION]({ key }) { + // Configuration data entries are { key, value } objects, `key` can be used + // as the unique identifier for the entry. + return key; + }, + [SUPPORTED_DATA.THREAD_CONFIGURATION]({ key }) { + // See target configuration comment + return key; + }, + [SUPPORTED_DATA.XHR_BREAKPOINTS]({ path, method }) { + if (typeof path != "string") { + throw new Error( + `XHR Breakpoints expect to have path string, got ${typeof path} instead.` + ); + } + if (typeof method != "string") { + throw new Error( + `XHR Breakpoints expect to have method string, got ${typeof method} instead.` + ); + } + return `${path}:${method}`; + }, + [SUPPORTED_DATA.EVENT_BREAKPOINTS](id) { + if (typeof id != "string") { + throw new Error( + `Event Breakpoints expect the id to be a string , got ${typeof id} instead.` + ); + } + if (!lazy.validateEventBreakpoint(id)) { + throw new Error( + `The id string should be a valid event breakpoint id, ${id} is not.` + ); + } + return id; + }, +}; + +function idFunction(v) { + if (typeof v != "string") { + throw new Error( + `Expect data entry values to be string, or be using custom data key functions. Got ${typeof v} type instead.` + ); + } + return v; +} + +const SessionDataHelpers = { + SUPPORTED_DATA, + + /** + * Add new values to the shared "sessionData" object. + * + * @param Object sessionData + * The data object to update. + * @param string type + * The type of data to be added + * @param Array entries + * The values to be added to this type of data + */ + addSessionDataEntry(sessionData, type, entries) { + const toBeAdded = []; + const keyFunction = DATA_KEY_FUNCTION[type] || idFunction; + for (const entry of entries) { + if (!sessionData[type]) { + sessionData[type] = []; + } + const existingIndex = sessionData[type].findIndex(existingEntry => { + return keyFunction(existingEntry) === keyFunction(entry); + }); + if (existingIndex === -1) { + // New entry. + toBeAdded.push(entry); + } else { + // Existing entry, update the value. This is relevant if the data-entry + // is not a primitive data-type, and the value can change for the same + // key. + sessionData[type][existingIndex] = entry; + } + } + sessionData[type].push(...toBeAdded); + }, + + /** + * Remove values from the shared "sessionData" object. + * + * @param Object sessionData + * The data object to update. + * @param string type + * The type of data to be remove + * @param Array entries + * The values to be removed from this type of data + * @return Boolean + * True, if at least one entries existed and has been removed. + * False, if none of the entries existed and none has been removed. + */ + removeSessionDataEntry(sessionData, type, entries) { + let includesAtLeastOne = false; + const keyFunction = DATA_KEY_FUNCTION[type] || idFunction; + for (const entry of entries) { + const idx = sessionData[type] + ? sessionData[type].findIndex(existingEntry => { + return keyFunction(existingEntry) === keyFunction(entry); + }) + : -1; + if (idx !== -1) { + sessionData[type].splice(idx, 1); + includesAtLeastOne = true; + } + } + if (!includesAtLeastOne) { + return false; + } + + return true; + }, +}; + +// Allow this JSM to also be loaded as a CommonJS module +// Because this module is used from the worker thread, +// (via target-actor-mixin), and workers can't load JSMs. +if (typeof module == "object") { + module.exports.SessionDataHelpers = SessionDataHelpers; +} diff --git a/devtools/server/actors/watcher/WatcherRegistry.sys.mjs b/devtools/server/actors/watcher/WatcherRegistry.sys.mjs new file mode 100644 index 0000000000..1c8870506a --- /dev/null +++ b/devtools/server/actors/watcher/WatcherRegistry.sys.mjs @@ -0,0 +1,379 @@ +/* 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/. */ + +/** + * Helper module around `sharedData` object that helps storing the state + * of all observed Targets and Resources, that, for all DevTools connections. + * Here is a few words about the C++ implementation of sharedData: + * https://searchfox.org/mozilla-central/rev/bc3600def806859c31b2c7ac06e3d69271052a89/dom/ipc/SharedMap.h#30-55 + * + * We may have more than one DevToolsServer and one server may have more than one + * client. This module will be the single source of truth in the parent process, + * in order to know which targets/resources are currently observed. It will also + * be used to declare when something starts/stops being observed. + * + * `sharedData` is a platform API that helps sharing JS Objects across processes. + * We use it in order to communicate to the content process which targets and resources + * should be observed. Content processes read this data only once, as soon as they are created. + * It isn't used beyond this point. Content processes are not going to update it. + * We will notify about changes in observed targets and resources for already running + * processes by some other means. (Via JS Window Actor queries "DevTools:(un)watch(Resources|Target)") + * This means that only this module will update the "DevTools:watchedPerWatcher" value. + * From the parent process, we should be going through this module to fetch the data, + * while from the content process, we will read `sharedData` directly. + */ + +import { ActorManagerParent } from "resource://gre/modules/ActorManagerParent.sys.mjs"; + +const { SessionDataHelpers } = ChromeUtils.import( + "resource://devtools/server/actors/watcher/SessionDataHelpers.jsm" +); + +const { SUPPORTED_DATA } = SessionDataHelpers; +const SUPPORTED_DATA_TYPES = Object.values(SUPPORTED_DATA); + +// Define the Map that will be saved in `sharedData`. +// It is keyed by WatcherActor ID and values contains following attributes: +// - targets: Set of strings, refering to target types to be listened to +// - resources: Set of strings, refering to resource types to be observed +// - sessionContext Object, The Session Context to help know what is debugged. +// See devtools/server/actors/watcher/session-context.js +// - connectionPrefix: The DevToolsConnection prefix of the watcher actor. Used to compute new actor ID in the content processes. +// +// Unfortunately, `sharedData` is subject to race condition and may have side effect +// when read/written from multiple places in the same process, +// which is why this map should be considered as the single source of truth. +const sessionDataByWatcherActor = new Map(); + +// In parallel to the previous map, keep all the WatcherActor keyed by the same WatcherActor ID, +// the WatcherActor ID. We don't (and can't) propagate the WatcherActor instances to the content +// processes, but still would like to match them by their ID. +const watcherActors = new Map(); + +// Name of the attribute into which we save this Map in `sharedData` object. +const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher"; + +/** + * Use `sharedData` to allow processes, early during their creation, + * to know which resources should be listened to. This will be read + * from the Target actor, when it gets created early during process start, + * in order to start listening to the expected resource types. + */ +function persistMapToSharedData() { + Services.ppmm.sharedData.set(SHARED_DATA_KEY_NAME, sessionDataByWatcherActor); + // Request to immediately flush the data to the content processes in order to prevent + // races (bug 1644649). Otherwise content process may have outdated sharedData + // and try to create targets for Watcher actor that already stopped watching for targets. + Services.ppmm.sharedData.flush(); +} + +export const WatcherRegistry = { + /** + * Tells if a given watcher currently watches for a given target type. + * + * @param WatcherActor watcher + * The WatcherActor which should be listening. + * @param string targetType + * The new target type to query. + * @return boolean + * Returns true if already watching. + */ + isWatchingTargets(watcher, targetType) { + const sessionData = this.getSessionData(watcher); + return !!sessionData?.targets?.includes(targetType); + }, + + /** + * Retrieve the data saved into `sharedData` that is used to know + * about which type of targets and resources we care listening about. + * `sessionDataByWatcherActor` is saved into `sharedData` after each mutation, + * but `sessionDataByWatcherActor` is the source of truth. + * + * @param WatcherActor watcher + * The related WatcherActor which starts/stops observing. + * @param object options (optional) + * A dictionary object with `createData` boolean attribute. + * If this attribute is set to true, we create the data structure in the Map + * if none exists for this prefix. + */ + getSessionData(watcher, { createData = false } = {}) { + // Use WatcherActor ID as a key as we may have multiple clients willing to watch for targets. + // For example, a Browser Toolbox debugging everything and a Content Toolbox debugging + // just one tab. We might also have multiple watchers, on the same connection when using about:debugging. + const watcherActorID = watcher.actorID; + let sessionData = sessionDataByWatcherActor.get(watcherActorID); + if (!sessionData && createData) { + sessionData = { + // The "session context" object help understand what should be debugged and which target should be created. + // See WatcherActor constructor for more info. + sessionContext: watcher.sessionContext, + // The DevToolsServerConnection prefix will be used to compute actor IDs created in the content process + connectionPrefix: watcher.conn.prefix, + }; + sessionDataByWatcherActor.set(watcherActorID, sessionData); + watcherActors.set(watcherActorID, watcher); + } + return sessionData; + }, + + /** + * Given a Watcher Actor ID, return the related Watcher Actor instance. + * + * @param String actorID + * The Watcher Actor ID to search for. + * @return WatcherActor + * The Watcher Actor instance. + */ + getWatcher(actorID) { + return watcherActors.get(actorID); + }, + + /** + * Return an array of the watcher actors that match the passed browserId + * + * @param {Number} browserId + * @returns {Array} An array of the matching watcher actors + */ + getWatchersForBrowserId(browserId) { + const watchers = []; + for (const watcherActor of watcherActors.values()) { + if ( + watcherActor.sessionContext.type == "browser-element" && + watcherActor.sessionContext.browserId === browserId + ) { + watchers.push(watcherActor); + } + } + + return watchers; + }, + + /** + * Notify that a given watcher added an entry in a given data type. + * + * @param WatcherActor watcher + * The WatcherActor which starts observing. + * @param string type + * The type of data to be added + * @param Array entries + * The values to be added to this type of data + */ + addSessionDataEntry(watcher, type, entries) { + const sessionData = this.getSessionData(watcher, { + createData: true, + }); + + if (!SUPPORTED_DATA_TYPES.includes(type)) { + throw new Error(`Unsupported session data type: ${type}`); + } + + SessionDataHelpers.addSessionDataEntry(sessionData, type, entries); + + // Register the JS Window Actor the first time we start watching for something (e.g. resource, target, …). + registerJSWindowActor(); + + persistMapToSharedData(); + }, + + /** + * Notify that a given watcher removed an entry in a given data type. + * + * @param WatcherActor watcher + * The WatcherActor which stops observing. + * @param string type + * The type of data to be removed + * @param Array entries + * The values to be removed to this type of data + * @params {Object} options + * @params {Boolean} options.isModeSwitching: Set to true true when this is called as the + * result of a change to the devtools.browsertoolbox.scope pref. + * + * @return boolean + * True if we such entry was already registered, for this watcher actor. + */ + removeSessionDataEntry(watcher, type, entries, options) { + const sessionData = this.getSessionData(watcher); + if (!sessionData) { + return false; + } + + if (!SUPPORTED_DATA_TYPES.includes(type)) { + throw new Error(`Unsupported session data type: ${type}`); + } + + if ( + !SessionDataHelpers.removeSessionDataEntry(sessionData, type, entries) + ) { + return false; + } + + const isWatchingSomething = SUPPORTED_DATA_TYPES.some( + dataType => sessionData[dataType] && !!sessionData[dataType].length + ); + + // Remove the watcher reference if it's not watching for anything anymore, unless we're + // doing a mode switch; in such case we don't mean to end the DevTools session, so we + // still want to have access to the underlying data (furthermore, such case should only + // happen in tests, in a regular workflow we'd still be watching for resources). + if (!isWatchingSomething && !options?.isModeSwitching) { + sessionDataByWatcherActor.delete(watcher.actorID); + watcherActors.delete(watcher.actorID); + } + + persistMapToSharedData(); + + return true; + }, + + /** + * Cleanup everything about a given watcher actor. + * Remove it from any registry so that we stop interacting with it. + * + * The watcher would be automatically unregistered from removeWatcherEntry, + * if we remove all entries. But we aren't removing all breakpoints. + * So here, we force clearing any reference to the watcher actor when it destroys. + */ + unregisterWatcher(watcher) { + sessionDataByWatcherActor.delete(watcher.actorID); + watcherActors.delete(watcher.actorID); + this.maybeUnregisteringJSWindowActor(); + }, + + /** + * Notify that a given watcher starts observing a new target type. + * + * @param WatcherActor watcher + * The WatcherActor which starts observing. + * @param string targetType + * The new target type to start listening to. + */ + watchTargets(watcher, targetType) { + this.addSessionDataEntry(watcher, SUPPORTED_DATA.TARGETS, [targetType]); + }, + + /** + * Notify that a given watcher stops observing a given target type. + * + * @param WatcherActor watcher + * The WatcherActor which stops observing. + * @param string targetType + * The new target type to stop listening to. + * @params {Object} options + * @params {Boolean} options.isModeSwitching: Set to true true when this is called as the + * result of a change to the devtools.browsertoolbox.scope pref. + * @return boolean + * True if we were watching for this target type, for this watcher actor. + */ + unwatchTargets(watcher, targetType, options) { + return this.removeSessionDataEntry( + watcher, + SUPPORTED_DATA.TARGETS, + [targetType], + options + ); + }, + + /** + * Notify that a given watcher starts observing new resource types. + * + * @param WatcherActor watcher + * The WatcherActor which starts observing. + * @param Array resourceTypes + * The new resource types to start listening to. + */ + watchResources(watcher, resourceTypes) { + this.addSessionDataEntry(watcher, SUPPORTED_DATA.RESOURCES, resourceTypes); + }, + + /** + * Notify that a given watcher stops observing given resource types. + * + * See `watchResources` for argument definition. + * + * @return boolean + * True if we were watching for this resource type, for this watcher actor. + */ + unwatchResources(watcher, resourceTypes) { + return this.removeSessionDataEntry( + watcher, + SUPPORTED_DATA.RESOURCES, + resourceTypes + ); + }, + + /** + * Unregister the JS Window Actor if there is no more DevTools code observing any target/resource. + */ + maybeUnregisteringJSWindowActor() { + if (sessionDataByWatcherActor.size == 0) { + unregisterJSWindowActor(); + } + }, +}; + +// Boolean flag to know if the DevToolsFrame JS Window Actor is currently registered +let isJSWindowActorRegistered = false; + +/** + * Register the JSWindowActor pair "DevToolsFrame". + * + * We should call this method before we try to use this JS Window Actor from the parent process + * (via `WindowGlobal.getActor("DevToolsFrame")` or `WindowGlobal.getActor("DevToolsWorker")`). + * Also, registering it will automatically force spawing the content process JSWindow Actor + * anytime a new document is opened (via DOMWindowCreated event). + */ + +const JSWindowActorsConfig = { + DevToolsFrame: { + parent: { + esModuleURI: + "resource://devtools/server/connectors/js-window-actor/DevToolsFrameParent.sys.mjs", + }, + child: { + esModuleURI: + "resource://devtools/server/connectors/js-window-actor/DevToolsFrameChild.sys.mjs", + events: { + DOMWindowCreated: {}, + DOMDocElementInserted: {}, + pageshow: {}, + pagehide: {}, + }, + }, + allFrames: true, + }, + DevToolsWorker: { + parent: { + esModuleURI: + "resource://devtools/server/connectors/js-window-actor/DevToolsWorkerParent.sys.mjs", + }, + child: { + esModuleURI: + "resource://devtools/server/connectors/js-window-actor/DevToolsWorkerChild.sys.mjs", + events: { + DOMWindowCreated: {}, + }, + }, + allFrames: true, + }, +}; + +function registerJSWindowActor() { + if (isJSWindowActorRegistered) { + return; + } + isJSWindowActorRegistered = true; + ActorManagerParent.addJSWindowActors(JSWindowActorsConfig); +} + +function unregisterJSWindowActor() { + if (!isJSWindowActorRegistered) { + return; + } + isJSWindowActorRegistered = false; + + for (const JSWindowActorName of Object.keys(JSWindowActorsConfig)) { + // ActorManagerParent doesn't expose a "removeActors" method, but it would be equivalent to that: + ChromeUtils.unregisterWindowActor(JSWindowActorName); + } +} diff --git a/devtools/server/actors/watcher/browsing-context-helpers.sys.mjs b/devtools/server/actors/watcher/browsing-context-helpers.sys.mjs new file mode 100644 index 0000000000..d52cbc5708 --- /dev/null +++ b/devtools/server/actors/watcher/browsing-context-helpers.sys.mjs @@ -0,0 +1,428 @@ +/* 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/. */ + +const isEveryFrameTargetEnabled = Services.prefs.getBoolPref( + "devtools.every-frame-target.enabled", + false +); + +const WEBEXTENSION_FALLBACK_DOC_URL = + "chrome://devtools/content/shared/webextension-fallback.html"; + +/** + * Retrieve the addon id corresponding to a given window global. + * This is usually extracted from the principal, but in case we are dealing + * with a DevTools webextension fallback window, the addon id will be available + * in the URL. + * + * @param {WindowGlobalChild|WindowGlobalParent} windowGlobal + * The WindowGlobal from which we want to extract the addonId. Either a + * WindowGlobalParent or a WindowGlobalChild depending on where this + * helper is used from. + * @return {String} Returns the addon id if any could found, null otherwise. + */ +export function getAddonIdForWindowGlobal(windowGlobal) { + const browsingContext = windowGlobal.browsingContext; + const isParent = CanonicalBrowsingContext.isInstance(browsingContext); + // documentPrincipal is only exposed on WindowGlobalParent, + // use a fallback for WindowGlobalChild. + const principal = isParent + ? windowGlobal.documentPrincipal + : browsingContext.window.document.nodePrincipal; + + // On Android we can get parent process windows where `documentPrincipal` and + // `documentURI` are both unavailable. Bail out early. + if (!principal) { + return null; + } + + // Most webextension documents are loaded from moz-extension://{addonId} and + // the principal provides the addon id. + if (principal.addonId) { + return principal.addonId; + } + + // If no addon id was available on the principal, check if the window is the + // DevTools fallback window and extract the addon id from the URL. + const href = isParent + ? windowGlobal.documentURI?.displaySpec + : browsingContext.window.document.location.href; + + if (href && href.startsWith(WEBEXTENSION_FALLBACK_DOC_URL)) { + const [, addonId] = href.split("#"); + return addonId; + } + + return null; +} + +/** + * 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 + * The Session Context to help know what is debugged. + * See devtools/server/actors/watcher/session-context.js + * @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 {Boolean} options.acceptInitialDocument + * By default, we ignore initial about:blank documents/WindowGlobals. + * But some code cares about all the WindowGlobals, this flag allows to also accept them. + * (Used by _validateWindowGlobal) + * @param {Boolean} options.acceptSameProcessIframes + * If true, we will accept WindowGlobal that runs in the same process as their parent document. + * That, even when EFT is disabled. + * (Used by _validateWindowGlobal) + * @param {Boolean} options.acceptNoWindowGlobal + * By default, we will reject BrowsingContext that don't have any WindowGlobal, + * either retrieved via BrowsingContext.currentWindowGlobal in the parent process, + * or via the options.windowGlobal argument. + * But in some case, we are processing BrowsingContext very early, before any + * WindowGlobal has been created for it. But they are still relevant BrowsingContexts + * to debug. + * @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. + */ +// The goal of this method is to gather all checks done against BrowsingContext and WindowGlobal interfaces +// which leads it to be a lengthy method. So disable the complexity rule which is counter productive here. +// eslint-disable-next-line complexity +export function isBrowsingContextPartOfContext( + browsingContext, + sessionContext, + options = {} +) { + let { + forceAcceptTopLevelTarget = false, + acceptNoWindowGlobal = false, + windowGlobal, + } = options; + + // 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 ( + CanonicalBrowsingContext.isInstance(browsingContext) && + !browsingContext.isContent + ) { + return false; + } + + if (!windowGlobal) { + // When we are in the parent process, WindowGlobal can be retrieved from the BrowsingContext, + // while in the content process, the callsites have to pass it manually as an argument + if (CanonicalBrowsingContext.isInstance(browsingContext)) { + windowGlobal = browsingContext.currentWindowGlobal; + } else if (!windowGlobal && !acceptNoWindowGlobal) { + throw new Error( + "isBrowsingContextPartOfContext expect a windowGlobal argument when called from the content process" + ); + } + } + // If we have a WindowGlobal, there is some additional checks we can do + if ( + windowGlobal && + !_validateWindowGlobal(windowGlobal, sessionContext, options) + ) { + return false; + } + // Loading or destroying BrowsingContext won't have any associated WindowGlobal. + // Ignore them by default. They should be either handled via DOMWindowCreated event or JSWindowActor destroy + if (!windowGlobal && !acceptNoWindowGlobal) { + return false; + } + + // Now do the checks specific to each session context type + if (sessionContext.type == "all") { + return true; + } + if (sessionContext.type == "browser-element") { + // Check if the document is: + // - part of the Browser element, or, + // - a popup originating from the browser element (the popup being loaded in a distinct browser element) + const isMatchingTheBrowserElement = + browsingContext.browserId == sessionContext.browserId; + if ( + !isMatchingTheBrowserElement && + !isPopupToDebug(browsingContext, sessionContext) + ) { + 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; + } + return true; + } + + if (sessionContext.type == "webextension") { + // Next and last check expects a WindowGlobal. + // As we have no way to really know if this BrowsingContext is related to this add-on, + // ignore it. Even if callsite accepts browsing context without a window global. + if (!windowGlobal) { + return false; + } + + return getAddonIdForWindowGlobal(windowGlobal) == sessionContext.addonId; + } + throw new Error("Unsupported session context type: " + sessionContext.type); +} + +/** + * Return true for popups to debug when debugging a browser-element. + * + * @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. + */ +function isPopupToDebug(browsingContext, sessionContext) { + // If enabled, create targets for popups (i.e. window.open() calls). + // If the opener is the tab we are currently debugging, accept the WindowGlobal and create a target for it. + // + // Note that it is important to do this check *after* the isInitialDocument one. + // Popups end up involving three WindowGlobals: + // - a first WindowGlobal loading an initial about:blank document (so isInitialDocument is true) + // - a second WindowGlobal which looks exactly as the first one + // - a final WindowGlobal which loads the URL passed to window.open() (so isInitialDocument is false) + // + // For now, we only instantiate a target for the last WindowGlobal. + return ( + sessionContext.isPopupDebuggingEnabled && + browsingContext.opener && + browsingContext.opener.browserId == sessionContext.browserId + ); +} + +/** + * Helper function of isBrowsingContextPartOfContext to execute all checks + * against WindowGlobal interface which aren't specific to a given SessionContext type + * + * @param {WindowGlobalParent|WindowGlobalChild} windowGlobal + * The WindowGlobal we want to check if it is part of debugged context + * @param {Object} sessionContext + * The Session Context to help know what is debugged. + * See devtools/server/actors/watcher/session-context.js + * @param {Object} options + * Optional arguments passed via a dictionary. + * See `isBrowsingContextPartOfContext` jsdoc. + */ +function _validateWindowGlobal( + windowGlobal, + sessionContext, + { acceptInitialDocument, acceptSameProcessIframes } +) { + // 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. + const isInitialDocument = + windowGlobal.isInitialDocument || + windowGlobal.browsingContext.window?.document.isInitialDocument; + if (isInitialDocument && !acceptInitialDocument) { + 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. + const isSameProcessIframe = !windowGlobal.isProcessRoot; + if ( + isSameProcessIframe && + !acceptSameProcessIframes && + !isEveryFrameTargetEnabled + ) { + return false; + } + + return true; +} + +/** + * 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 + * The Session Context to help know what is debugged. + * See devtools/server/actors/watcher/session-context.js + * @param {Object} options + * Optional arguments passed via a dictionary. + * See `isBrowsingContextPartOfContext` jsdoc. + */ +export function isWindowGlobalPartOfContext( + windowGlobal, + sessionContext, + options +) { + return isBrowsingContextPartOfContext( + windowGlobal.browsingContext, + sessionContext, + { + ...options, + windowGlobal, + } + ); +} + +/** + * Get all the BrowsingContexts that should be debugged by the given session context. + * Consider using WatcherActor.getAllBrowsingContexts(options) which will automatically pass the right sessionContext. + * + * 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 + * The Session Context to help know what is debugged. + * See devtools/server/actors/watcher/session-context.js + * @param {Object} options + * Optional arguments passed via a dictionary. + * @param {Boolean} options.acceptSameProcessIframes + * If true, we will accept WindowGlobal that runs in the same process as their parent document. + * That, even when EFT is disabled. + */ +export function getAllBrowsingContextsForContext( + sessionContext, + { acceptSameProcessIframes = false } = {} +) { + 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[type="content"]` + )) { + 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 + ); + // topBrowsingContext can be null if getCurrentTopByBrowserId is called for a tab that is unloaded. + if (topBrowsingContext) { + // 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 /