/* 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/. */ import { ContentProcessWatcherRegistry } from "resource://devtools/server/connectors/js-process-actor/ContentProcessWatcherRegistry.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters( lazy, { HTMLSourcesCache: "resource://devtools/server/actors/utils/HTMLSourcesCache.sys.mjs", isWindowGlobalPartOfContext: "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs", WEBEXTENSION_FALLBACK_DOC_URL: "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs", }, { global: "contextual" } ); // TargetActorRegistery has to be shared between all devtools instances // and so is loaded into the shared global. ChromeUtils.defineESModuleGetters( lazy, { TargetActorRegistry: "resource://devtools/server/actors/targets/target-actor-registry.sys.mjs", }, { global: "shared" } ); function watch() { // Set the following preference in this function, so that we can easily // toggle these preferences on and off from tests and have the new value being picked up. // bfcache-in-parent changes significantly how navigation behaves. // We may start reusing previously existing WindowGlobal and so reuse // previous set of JSWindowActor pairs (i.e. DevToolsProcessParent/DevToolsProcessChild). // When enabled, regular navigations may also change and spawn new BrowsingContexts. // If the page we navigate from supports being stored in bfcache, // the navigation will use a new BrowsingContext. And so force spawning // a new top-level target. ChromeUtils.defineLazyGetter( lazy, "isBfcacheInParentEnabled", () => Services.appinfo.sessionHistoryInParent && Services.prefs.getBoolPref("fission.bfcacheInParent", false) ); // Observe for all necessary event to track new and destroyed WindowGlobals. Services.obs.addObserver(observe, "content-document-global-created"); Services.obs.addObserver(observe, "chrome-document-global-created"); Services.obs.addObserver(observe, "content-page-shown"); Services.obs.addObserver(observe, "chrome-page-shown"); Services.obs.addObserver(observe, "content-page-hidden"); Services.obs.addObserver(observe, "chrome-page-hidden"); Services.obs.addObserver(observe, "inner-window-destroyed"); Services.obs.addObserver(observe, "initial-document-element-inserted"); } function unwatch() { // Observe for all necessary event to track new and destroyed WindowGlobals. Services.obs.removeObserver(observe, "content-document-global-created"); Services.obs.removeObserver(observe, "chrome-document-global-created"); Services.obs.removeObserver(observe, "content-page-shown"); Services.obs.removeObserver(observe, "chrome-page-shown"); Services.obs.removeObserver(observe, "content-page-hidden"); Services.obs.removeObserver(observe, "chrome-page-hidden"); Services.obs.removeObserver(observe, "inner-window-destroyed"); Services.obs.removeObserver(observe, "initial-document-element-inserted"); } function createTargetsForWatcher(watcherDataObject, isProcessActorStartup) { const { sessionContext } = watcherDataObject; // Bug 1785266 - For now, in browser, when debugging the parent process (childID == 0), // we spawn only the ParentProcessTargetActor, which will debug all the BrowsingContext running in the process. // So that we have to avoid instantiating any here. if ( sessionContext.type == "all" && ChromeUtils.domProcessChild.childID === 0 ) { return; } function lookupForTargets(window) { // Do not only track top level BrowsingContext in this content process, // but also any nested iframe which may be running in the same process. for (const browsingContext of window.docShell.browsingContext.getAllBrowsingContextsInSubtree()) { const { currentWindowContext } = browsingContext; // Only consider Window Global which are running in this process if (!currentWindowContext || !currentWindowContext.isInProcess) { continue; } // WindowContext's windowGlobalChild should be defined for WindowGlobal running in this process const { windowGlobalChild } = currentWindowContext; // getWindowEnumerator will expose somewhat unexpected WindowGlobal when a tab navigated. // This will expose WindowGlobals of past navigations. Document which are in the bfcache // and aren't the current WindowGlobal of their BrowsingContext. if (!windowGlobalChild.isCurrentGlobal) { continue; } // Accept the initial about:blank document: // - only from createTargetsForWatcher, when instantiating the target for the already existing WindowGlobals, // - when we do that on toolbox opening, to prevent creating one when the process is starting. // // This is to allow debugging blank tabs, which are on an initial about:blank document. // // We want to avoid creating transient targets for initial about blank when a new WindowGlobal // just get created as it will most likely navigate away just after and confuse the frontend with short lived target. const acceptInitialDocument = !isProcessActorStartup; if ( lazy.isWindowGlobalPartOfContext(windowGlobalChild, sessionContext, { acceptInitialDocument, }) ) { createWindowGlobalTargetActor(watcherDataObject, windowGlobalChild); } else if ( !browsingContext.parent && sessionContext.browserId && browsingContext.browserId == sessionContext.browserId && browsingContext.window.document.isInitialDocument ) { // In order to succesfully get the devtools-html-content event in SourcesManager, // we have to ensure flagging the initial about:blank document... // While we don't create a target for it, we need to set this flag for this event to be emitted. browsingContext.watchedByDevTools = true; } } } const topWindows = []; // Do not use a for loop on `windowEnumerator` as underlying call to `getNext` may throw on some windows const windowEnumerator = Services.ww.getWindowEnumerator(); while (windowEnumerator.hasMoreElements()) { try { const window = windowEnumerator.getNext(); topWindows.push(window); } catch (e) { console.error("Failed to process a top level window", e); } } // When debugging an extension, we have to ensure create the top level target first. // The top level target is the one for the fallback document. if (sessionContext.type == "webextension") { const fallbackWindowIndex = topWindows.findIndex(window => window.location.href.startsWith(lazy.WEBEXTENSION_FALLBACK_DOC_URL) ); if (fallbackWindowIndex != -1) { const [fallbackWindow] = topWindows.splice(fallbackWindowIndex, 1); topWindows.unshift(fallbackWindow); } } for (const window of topWindows) { lookupForTargets(window); // `lookupForTargets` uses `getAllBrowsingContextsInSubTree`, but this will ignore browser elements // using type="content". So manually retrieve the windows for these browser elements, // in case we have tabs opened on document loaded in the same process. // This codepath is meant when we are in the parent process, with browser.xhtml having these // elements for tabs. for (const browser of window.document.querySelectorAll( `browser[type="content"]` )) { // Bug 1947777 - the browser context may not have an active DOM Window const childWindow = browser.browsingContext?.window; // If the tab isn't on a document loaded in the parent process, // the window will be null. if (childWindow) { lookupForTargets(childWindow); } } } // Utility class to watch for HTML Sources text content emitted by the HTML Parser. // As this can come from the previous WindowGlobal, we need this logic to be running outside // of individual WindowGlobal targets. if (sessionContext.type != "all") { lazy.HTMLSourcesCache.watch(sessionContext.browserId); } } function destroyTargetsForWatcher(watcherDataObject, options) { // Unregister and destroy the existing target actors for this target type const actorsToDestroy = watcherDataObject.actors.filter( actor => actor.targetType == "frame" ); watcherDataObject.actors = watcherDataObject.actors.filter( actor => actor.targetType != "frame" ); for (const actor of actorsToDestroy) { ContentProcessWatcherRegistry.destroyTargetActor( watcherDataObject, actor, options ); } if (watcherDataObject.sessionContext.type != "all") { lazy.HTMLSourcesCache.unwatch(watcherDataObject.sessionContext.browserId); } } /** * Called whenever a new WindowGlobal is instantiated either: * - when navigating to a new page (DOMWindowCreated) * - by a bfcache navigation (pageshow) * * @param {Window} window * @param {Object} options * @param {Boolean} options.isBFCache * True, if the request to instantiate a new target comes from a bfcache navigation. * i.e. when we receive a pageshow event with persisted=true. * This will be true regardless of bfcacheInParent being enabled or disabled. * @param {Boolean} options.ignoreIfExisting * By default to false. If true is passed, we avoid instantiating a target actor * if one already exists for this windowGlobal. */ function onWindowGlobalCreated( window, { isBFCache = false, ignoreIfExisting = false } = {} ) { try { const windowGlobal = window.windowGlobalChild; // For bfcache navigations, we only create new targets when bfcacheInParent is enabled, // as this would be the only case where new DocShells will be created. This requires us to spawn a // new WindowGlobalTargetActor as such actor is bound to a unique DocShell. const forceAcceptTopLevelTarget = isBFCache && lazy.isBfcacheInParentEnabled; for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects( "frame" )) { const { sessionContext } = watcherDataObject; /* try { windowGlobal.browsingContext.watchedByDevTools = true; } catch (e) {} try { windowGlobal.browsingContext.top.watchedByDevTools = true; } catch (e) {} */ if ( lazy.isWindowGlobalPartOfContext(windowGlobal, sessionContext, { forceAcceptTopLevelTarget, }) ) { // If this was triggered because of a navigation, we want to retrieve the existing // target we were debugging so we can destroy it before creating the new target. // This is important because we had cases where the destruction of an old target // was unsetting a flag on the **new** target document, breaking the toolbox (See Bug 1721398). // We're checking for an existing target given a watcherActorID + browserId + browsingContext. // Note that a target switch might create a new browsing context, so we wouldn't // retrieve the existing target here. We are okay with this as: // - this shouldn't happen much // - in such case we weren't seeing the issue of Bug 1721398 (the old target can't access the new document) const existingTarget = findTargetActor({ watcherDataObject, innerWindowId: windowGlobal.innerWindowId, // Also use a loose match per browsing context's ID instead of only window global's innerWindowId // as we would like to destroy eagerly the previous target actor, // which will anyway be wiped by the frontend as soon as we notify it about this new target. // TargetCommand wipes all existing targets as soon as we receive a new Top Level target. // // Matching only per innerWindowId was making browser_webconsole_message_categories.js test to fail // because some very early message resource was attached to this `existingTarget` actor. // The `existingTarget` actor is actually destroyed and their related resource ignored. // Instead we bind these early resources to the new target actor. browsingContextID: windowGlobal.browsingContext.id, }); // See comment in `observe()` method and `DOMDocElementInserted` condition to know why we sometime // ignore this method call if a target actor already exists. // It means that we got a previous DOMWindowCreated event, related to a non-about:blank document, // and we should ignore the DOMDocElementInserted. // In any other scenario, destroy the already existing target and re-create a new one. if (existingTarget && ignoreIfExisting) { continue; } // Bail if there is already an existing WindowGlobalTargetActor which wasn't // created from a JSWIndowActor. // This means we are reloading or navigating (same-process) a Target // which has not been created using the Watcher, but from the client (most likely // the initial target of a local-tab toolbox). // However, we force overriding the first message manager based target in case of // BFCache navigations. if ( existingTarget && !existingTarget.createdFromJsWindowActor && !isBFCache ) { continue; } // If we decide to instantiate a new target and there was one before, // first destroy the previous one. // Otherwise its destroy sequence will be executed *after* the new one // is being initialized and may easily revert changes made against platform API. // (typically toggle platform boolean attributes back to default…) if (existingTarget) { existingTarget.destroy({ isTargetSwitching: true }); } // When navigating to another process, the Watcher Actor won't have sent any query // to the new process JS Actor as the debugged tab was on another process before navigation. // But `sharedData` will have data about all the current watchers. // Here we have to ensure calling watchTargetsForWatcher in order to populate #connections // for the currently processed watcher actor and start listening for future targets. if ( !ContentProcessWatcherRegistry.has(watcherDataObject.watcherActorID) ) { throw new Error("Watcher data seems out of sync"); } createWindowGlobalTargetActor(watcherDataObject, windowGlobal, true); } } } catch (e) { // Ensure logging exception as they are silently ignore otherwise dump( " Exception while observing a new window: " + e + "\n" + e.stack + "\n" ); } } /** * Called whenever a WindowGlobal just got destroyed, when closing the tab, or navigating to another one. * * @param {innerWindowId} innerWindowId * The WindowGlobal's unique identifier. */ function onWindowGlobalDestroyed(innerWindowId) { for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects( "frame" )) { const existingTarget = findTargetActor({ watcherDataObject, innerWindowId, }); if (!existingTarget) { continue; } // Do not do anything if both bfcache in parent and server targets are disabled // As history navigations will be handled within the same DocShell and by the // same WindowGlobalTargetActor. The actor will listen to pageshow/pagehide by itself. // We should not destroy any target. if ( !lazy.isBfcacheInParentEnabled && !watcherDataObject.sessionContext.isServerTargetSwitchingEnabled ) { continue; } // If the target actor isn't in watcher data object, it is a top level actor // instantiated via a Descriptor's getTarget method. It isn't registered into Watcher objects. // But we still want to destroy such target actor, and need to manually emit the targetDestroyed to the parent process. // Hopefully bug 1754452 should allow us to get rid of this workaround by making the top level actor // be created and managed by the watcher universe, like all the others. const isTopLevelActorRegisteredOutsideOfWatcherActor = !watcherDataObject.actors.find( actor => actor.innerWindowId == innerWindowId ); const targetActorForm = isTopLevelActorRegisteredOutsideOfWatcherActor ? existingTarget.form() : null; existingTarget.destroy(); if (isTopLevelActorRegisteredOutsideOfWatcherActor) { watcherDataObject.jsProcessActor.sendAsyncMessage( "DevToolsProcessChild:targetDestroyed", { actors: [ { watcherActorID: watcherDataObject.watcherActorID, targetActorForm, }, ], options: {}, } ); } } } /** * Instantiate a WindowGlobal target actor for a given browsing context * and for a given watcher actor. * * @param {Object} watcherDataObject * @param {BrowsingContext} windowGlobalChild * @param {Boolean} isDocumentCreation */ function createWindowGlobalTargetActor( watcherDataObject, windowGlobalChild, isDocumentCreation = false ) { // When debugging privileged pages running a the shared system compartment, and we aren't in the browser toolbox (which already uses a distinct loader), // we have to use the distinct loader in order to ensure running DevTools in a distinct compartment than the page we are about to debug // Such page could be about:addons, chrome://browser/content/browser.xhtml,... const { browsingContext } = windowGlobalChild; const useDistinctLoader = browsingContext.associatedWindow.document.nodePrincipal.isSystemPrincipal; const { connection, loader } = ContentProcessWatcherRegistry.getOrCreateConnectionForWatcher( watcherDataObject.watcherActorID, useDistinctLoader ); const { WindowGlobalTargetActor } = loader.require( "devtools/server/actors/targets/window-global" ); // In the case of the browser toolbox, tab's BrowsingContext don't have // any parent BC and shouldn't be considered as top-level. // This is why we check for browserId's. const { sessionContext } = watcherDataObject; let isTopLevelTarget = !browsingContext.parent && browsingContext.browserId == sessionContext.browserId; // Web Extension are many of many "top level" browsing context. // i.e. browsing context with no parent. // The background page and popup top browsing context have no parent. // Given that none of these browsing context are guaranteed to be active, // not guarantee to be always active while debugging an add-on, // the WebExtension Descriptor Actor will spawn an "fallback document", // which is guaranteed to be always active and dedicated to eachd debugged addon. // Thus, allowing us to have an identifier top level document to be provided // to an unique top level target actor. if (sessionContext.type == "webextension") { isTopLevelTarget = browsingContext.window.location.href.startsWith( lazy.WEBEXTENSION_FALLBACK_DOC_URL ); } // Create the actual target actor. const targetActor = new WindowGlobalTargetActor(connection, { docShell: browsingContext.docShell, // Targets created from the server side, via Watcher actor and DevToolsProcess JSWindow // actor pairs are following WindowGlobal lifecycle. i.e. will be destroyed on any // type of navigation/reload. followWindowGlobalLifeCycle: true, isTopLevelTarget, ignoreSubFrames: true, sessionContext, }); targetActor.createdFromJsWindowActor = true; ContentProcessWatcherRegistry.onNewTargetActor( watcherDataObject, targetActor, isDocumentCreation ); } /** * Observer service notification handler. * * @param {DOMWindow|Document} subject * A window for *-document-global-created * A document for *-page-{shown|hide} * @param {String} topic */ function observe(subject, topic) { if ( topic == "content-document-global-created" || topic == "chrome-document-global-created" ) { onWindowGlobalCreated(subject); } else if (topic == "inner-window-destroyed") { const innerWindowId = subject.QueryInterface(Ci.nsISupportsPRUint64).data; onWindowGlobalDestroyed(innerWindowId); } else if (topic == "content-page-shown" || topic == "chrome-page-shown") { // The observer service notification doesn't receive the "persisted" DOM Event attribute, // but thanksfully is fired just before the dispatching of that DOM event. subject.defaultView.addEventListener("pageshow", handleEvent, { capture: true, once: true, }); } else if (topic == "content-page-hidden" || topic == "chrome-page-hidden") { // Same as previous elseif branch subject.defaultView.addEventListener("pagehide", handleEvent, { capture: true, once: true, }); } else if (topic == "initial-document-element-inserted") { // We may be notified about SVG documents which we don't care about here. if (!subject.location || !subject.defaultView) { return; } // We might have ignored the DOMWindowCreated event because it was the initial about:blank document. // But when loading same-process iframes, we reuse the WindowGlobal of the previously ignored about:bank document // to load the actual URL loaded in the iframe. This means we won't have a new DOMWindowCreated // for the actual document. But there is a DOMDocElementInserted fired just after, that we are processing here // to create a target for same-process iframes. We only have to tell onWindowGlobalCreated to ignore // the call if a target was created on the DOMWindowCreated event (if that was a non-about:blank document). // // All this means that we still do not create any target for the initial documents. // It is complex to instantiate targets for initial documents because: // - it would mean spawning two targets for the same WindowGlobal and sharing the same innerWindowId // - or have WindowGlobalTargets to handle more than one document (it would mean reusing will-navigate/window-ready events // both on client and server) onWindowGlobalCreated(subject.defaultView, { ignoreIfExisting: true }); } } /** * DOM Event handler. * * @param {String} type * DOM event name * @param {Boolean} persisted * A flag set to true in cache of BFCache navigation * @param {Document} target * The navigating document */ function handleEvent({ type, persisted, target }) { // If persisted=true, this is a BFCache navigation. // // With Fission enabled and bfcacheInParent, BFCache navigations will spawn a new DocShell // in the same process: // * the previous page won't be destroyed, its JSWindowActor will stay alive (`didDestroy` won't be called) // and a 'pagehide' with persisted=true will be emitted on it. // * the new page page won't emit any DOMWindowCreated, but instead a pageshow with persisted=true // will be emitted. if (type === "pageshow" && persisted) { // Notify all bfcache navigations, even the one for which we don't create a new target // as that's being useful for parent process storage resource watchers. for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects()) { watcherDataObject.jsProcessActor.sendAsyncMessage( "DevToolsProcessChild:bf-cache-navigation-pageshow", { browsingContextId: target.defaultView.browsingContext.id, } ); } // Here we are going to re-instantiate a target that got destroyed before while processing a pagehide event. // We force instantiating a new top level target, within `instantiate()` even if server targets are disabled. // But we only do that if bfcacheInParent is enabled. Otherwise for same-process, same-docshell bfcache navigation, // we don't want to spawn new targets. onWindowGlobalCreated(target.defaultView, { isBFCache: true, }); } if (type === "pagehide" && persisted) { // Notify all bfcache navigations, even the one for which we don't create a new target // as that's being useful for parent process storage resource watchers. for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects()) { watcherDataObject.jsProcessActor.sendAsyncMessage( "DevToolsProcessChild:bf-cache-navigation-pagehide", { browsingContextId: target.defaultView.browsingContext.id, } ); } // We might navigate away for the first top level target, // which isn't using JSWindowActor (it still uses messages manager and is created by the client, via TabDescriptor.getTarget). // We have to unregister it from the TargetActorRegistry, otherwise, // if we navigate back to it, the next DOMWindowCreated won't create a new target for it. onWindowGlobalDestroyed(target.defaultView.windowGlobalChild.innerWindowId); } } /** * Return an existing Window Global target for given a WatcherActor * and against a given WindowGlobal. * * @param {Object} options * @param {String} options.watcherDataObject * @param {Number} options.innerWindowId * The WindowGlobal inner window ID. * * @returns {WindowGlobalTargetActor|null} */ function findTargetActor({ watcherDataObject, innerWindowId, browsingContextID, }) { // First let's check if a target was created for this watcher actor in this specific // DevToolsProcessChild instance. // // And start by checking if there is a perfect match first by doing a WindowGlobal / innerWindowId lookup, // before falling back to a BrowsingContext / browsingContextID lookup. let targetActor = watcherDataObject.actors.find( actor => actor.innerWindowId == innerWindowId ); if (!targetActor && browsingContextID) { targetActor = watcherDataObject.actors.find( actor => actor.browsingContextID == browsingContextID ); } if (targetActor) { return targetActor; } // Ensure retrieving the one target actor related to this connection. // This allows to distinguish actors created for various toolboxes. // For ex, regular toolbox versus browser console versus browser toolbox const connectionPrefix = watcherDataObject.watcherActorID.replace( /watcher\d+$/, "" ); const targetActors = lazy.TargetActorRegistry.getTargetActors( watcherDataObject.sessionContext, connectionPrefix ); return targetActors.find(actor => actor.innerWindowId == innerWindowId); } export const WindowGlobalTargetWatcher = { watch, unwatch, createTargetsForWatcher, destroyTargetsForWatcher, };