/* 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 { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; import * as Loader from "resource://devtools/shared/loader/Loader.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { isWindowGlobalPartOfContext: "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs", releaseDistinctSystemPrincipalLoader: "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs", TargetActorRegistry: "resource://devtools/server/actors/targets/target-actor-registry.sys.mjs", useDistinctSystemPrincipalLoader: "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs", WindowGlobalLogger: "resource://devtools/server/connectors/js-window-actor/WindowGlobalLogger.sys.mjs", }); const isEveryFrameTargetEnabled = Services.prefs.getBoolPref( "devtools.every-frame-target.enabled", false ); // Name of the attribute into which we save data in `sharedData` object. const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher"; // If true, log info about WindowGlobal's being created. const DEBUG = false; function logWindowGlobal(windowGlobal, message) { if (!DEBUG) { return; } lazy.WindowGlobalLogger.logWindowGlobal(windowGlobal, message); } export class DevToolsFrameChild extends JSWindowActorChild { constructor() { super(); // The map is indexed by the Watcher Actor ID. // The values are objects containing the following properties: // - connection: the DevToolsServerConnection itself // - actor: the WindowGlobalTargetActor instance this._connections = new Map(); EventEmitter.decorate(this); // Set the following preference on the constructor, 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. DevToolsFrameParent/DevToolsFrameChild). // 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( this, "isBfcacheInParentEnabled", () => Services.appinfo.sessionHistoryInParent && Services.prefs.getBoolPref("fission.bfcacheInParent", false) ); } /** * Try to instantiate new target actors for the current WindowGlobal, and that, * for all the currently registered Watcher actors. * * Read the `sharedData` to get metadata about all registered watcher actors. * If these watcher actors are interested in the current WindowGlobal, * instantiate a new dedicated target actor for each of the watchers. * * @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. */ instantiate({ isBFCache = false, ignoreIfExisting = false } = {}) { const { sharedData } = Services.cpmm; const sessionDataByWatcherActor = sharedData.get(SHARED_DATA_KEY_NAME); if (!sessionDataByWatcherActor) { throw new Error( "Request to instantiate the target(s) for the BrowsingContext, but `sharedData` is empty about watched targets" ); } // Create one Target actor for each prefix/client which listen to frames for (const [watcherActorID, sessionData] of sessionDataByWatcherActor) { const { connectionPrefix, sessionContext } = sessionData; // 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 one such actor is bound to a unique DocShell. const forceAcceptTopLevelTarget = isBFCache && this.isBfcacheInParentEnabled; if ( sessionData.targets?.includes("frame") && lazy.isWindowGlobalPartOfContext(this.manager, 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 = this._findTargetActor({ watcherActorID, sessionContext, browsingContextId: this.manager.browsingContext.id, }); // See comment in handleEvent(DOMDocElementInserted) to know why we try to // create targets if none already exists 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 }); } this._createTargetActor({ watcherActorID, parentConnectionPrefix: connectionPrefix, sessionData, isDocumentCreation: true, }); } } } /** * Instantiate a new WindowGlobalTarget for the given connection. * * @param Object options * @param String options.watcherActorID * The ID of the WatcherActor who requested to observe and create these target actors. * @param String options.parentConnectionPrefix * The prefix of the DevToolsServerConnection of the Watcher Actor. * This is used to compute a unique ID for the target actor. * @param Object options.sessionData * All data managed by the Watcher Actor and WatcherRegistry.jsm, containing * target types, resources types to be listened as well as breakpoints and any * other data meant to be shared across processes and threads. * @param Boolean options.isDocumentCreation * Set to true if the function is called from `instantiate`, i.e. when we're * handling a new document being created. * @param Boolean options.fromInstantiateAlreadyAvailable * Set to true if the function is called from handling `DevToolsFrameParent:instantiate-already-available` * query. */ _createTargetActor({ watcherActorID, parentConnectionPrefix, sessionData, isDocumentCreation, fromInstantiateAlreadyAvailable, }) { if (this._connections.get(watcherActorID)) { // If this function is called as a result of a `DevToolsFrameParent:instantiate-already-available` // message, we might have a legitimate race condition: // In frame-helper, we want to create the initial targets for a given browser element. // It might happen that the `DevToolsFrameParent:instantiate-already-available` is // aborted if the page navigates (and the document is destroyed) while the query is still pending. // In such case, frame-helper will try to send a new message. In the meantime, // the DevToolsFrameChild `DOMWindowCreated` handler may already have been called and // the new target already created. // We don't want to throw in such case, as our end-goal, having a target for the document, // is achieved. if (fromInstantiateAlreadyAvailable) { return; } throw new Error( "DevToolsFrameChild _createTargetActor was called more than once" + ` for the same Watcher (Actor ID: "${watcherActorID}")` ); } // Compute a unique prefix, just for this WindowGlobal, // which will be used to create a JSWindowActorTransport pair between content and parent processes. // This is slightly hacky as we typicaly compute Prefix and Actor ID via `DevToolsServerConnection.allocID()`, // but here, we can't have access to any DevTools connection as we are really early in the content process startup // XXX: WindowGlobal's innerWindowId should be unique across processes, I think. So that should be safe? // (this.manager == WindowGlobalChild interface) const forwardingPrefix = parentConnectionPrefix + "windowGlobal" + this.manager.innerWindowId; logWindowGlobal( this.manager, "Instantiate WindowGlobalTarget with prefix: " + forwardingPrefix ); const { connection, targetActor } = this._createConnectionAndActor( forwardingPrefix, sessionData ); const form = targetActor.form(); // Ensure unregistering and destroying the related DevToolsServerConnection+Transport // on both content and parent process JSWindowActors. targetActor.once("destroyed", options => { // This will destroy the content process one this._destroyTargetActor(watcherActorID, options); // And this will destroy the parent process one try { this.sendAsyncMessage("DevToolsFrameChild:destroy", { actors: [ { watcherActorID, form, }, ], options, }); } catch (e) { // Ignore exception when the JSWindowActorChild has already been destroyed. // We often try to emit this message while the WindowGlobal is in the process of being // destroyed. We eagerly destroy the target actor during reloads, // just before the WindowGlobal starts destroying, but sendAsyncMessage // doesn't have time to complete and throws. if ( !e.message.includes("JSWindowActorChild cannot send at the moment") ) { throw e; } } }); this._connections.set(watcherActorID, { connection, actor: targetActor, }); // Immediately queue a message for the parent process, // in order to ensure that the JSWindowActorTransport is instantiated // before any packet is sent from the content process. // As the order of messages is guaranteed to be delivered in the order they // were queued, we don't have to wait for anything around this sendAsyncMessage call. // In theory, the WindowGlobalTargetActor may emit events in its constructor. // If it does, such RDP packets may be lost. // The important point here is to send this message before processing the sessionData, // which will start the Watcher and start emitting resources on the target actor. this.sendAsyncMessage("DevToolsFrameChild:connectFromContent", { watcherActorID, forwardingPrefix, actor: targetActor.form(), }); // Pass initialization data to the target actor for (const type in sessionData) { // `sessionData` will also contain `browserId` as well as entries with empty arrays, // which shouldn't be processed. const entries = sessionData[type]; if (!Array.isArray(entries) || !entries.length) { continue; } targetActor.addOrSetSessionDataEntry( type, entries, isDocumentCreation, "set" ); } } /** * @param {string} watcherActorID * @param {object} options * @param {boolean} options.isModeSwitching * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref */ _destroyTargetActor(watcherActorID, options) { const connectionInfo = this._connections.get(watcherActorID); // This connection has already been cleaned? if (!connectionInfo) { throw new Error( `Trying to destroy a target actor that doesn't exists, or has already been destroyed. Watcher Actor ID:${watcherActorID}` ); } connectionInfo.connection.close(options); this._connections.delete(watcherActorID); if (this._connections.size == 0) { this.didDestroy(options); } } _createConnectionAndActor(forwardingPrefix, sessionData) { this.useCustomLoader = this.document.nodePrincipal.isSystemPrincipal; if (!this.loader) { // When debugging chrome pages, use a new dedicated loader, using a distinct chrome compartment. this.loader = this.useCustomLoader ? lazy.useDistinctSystemPrincipalLoader(this) : Loader; } const { DevToolsServer } = this.loader.require( "resource://devtools/server/devtools-server.js" ); const { WindowGlobalTargetActor } = this.loader.require( "resource://devtools/server/actors/targets/window-global.js" ); DevToolsServer.init(); // We want a special server without any root actor and only target-scoped actors. // We are going to spawn a WindowGlobalTargetActor instance in the next few lines, // it is going to act like a root actor without being one. DevToolsServer.registerActors({ target: true }); const connection = DevToolsServer.connectToParentWindowActor( this, forwardingPrefix ); // 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 browsingContext = this.manager.browsingContext; const isTopLevelTarget = !browsingContext.parent && browsingContext.browserId == sessionData.sessionContext.browserId; // Create the actual target actor. const targetActor = new WindowGlobalTargetActor(connection, { docShell: this.docShell, // Targets created from the server side, via Watcher actor and DevToolsFrame JSWindow // actor pair are following WindowGlobal lifecycle. i.e. will be destroyed on any // type of navigation/reload. followWindowGlobalLifeCycle: true, isTopLevelTarget, ignoreSubFrames: isEveryFrameTargetEnabled, sessionContext: sessionData.sessionContext, }); targetActor.manage(targetActor); targetActor.createdFromJsWindowActor = true; return { connection, targetActor }; } /** * Supported Queries */ sendPacket(packet, prefix) { this.sendAsyncMessage("DevToolsFrameChild:packet", { packet, prefix }); } /** * JsWindowActor API */ async sendQuery(msg, args) { try { const res = await super.sendQuery(msg, args); return res; } catch (e) { console.error("Failed to sendQuery in DevToolsFrameChild", msg); console.error(e.toString()); throw e; } } receiveMessage(message) { // Assert that the message is intended for this window global, // except for "packet" messages which use a dedicated routing if ( message.name != "DevToolsFrameParent:packet" && message.data.sessionContext.type == "browser-element" ) { const { browserId } = message.data.sessionContext; // Re-check here, just to ensure that both parent and content processes agree // on what should or should not be watched. if ( this.manager.browsingContext.browserId != browserId && !lazy.isWindowGlobalPartOfContext( this.manager, message.data.sessionContext, { forceAcceptTopLevelTarget: true, } ) ) { throw new Error( "Mismatch between DevToolsFrameParent and DevToolsFrameChild " + (this.manager.browsingContext.browserId == browserId ? "window global shouldn't be notified (isWindowGlobalPartOfContext mismatch)" : `expected browsing context with browserId ${browserId}, but got ${this.manager.browsingContext.browserId}`) ); } } switch (message.name) { case "DevToolsFrameParent:instantiate-already-available": { const { watcherActorID, connectionPrefix, sessionData } = message.data; return this._createTargetActor({ watcherActorID, parentConnectionPrefix: connectionPrefix, sessionData, fromInstantiateAlreadyAvailable: true, }); } case "DevToolsFrameParent:destroy": { const { watcherActorID, options } = message.data; return this._destroyTargetActor(watcherActorID, options); } case "DevToolsFrameParent:addOrSetSessionDataEntry": { const { watcherActorID, sessionContext, type, entries, updateType } = message.data; return this._addOrSetSessionDataEntry( watcherActorID, sessionContext, type, entries, updateType ); } case "DevToolsFrameParent:removeSessionDataEntry": { const { watcherActorID, sessionContext, type, entries } = message.data; return this._removeSessionDataEntry( watcherActorID, sessionContext, type, entries ); } case "DevToolsFrameParent:packet": return this.emit("packet-received", message); default: throw new Error( "Unsupported message in DevToolsFrameParent: " + message.name ); } } /** * Return an existing target given a WatcherActor id, a browserId and an optional * browsing context id. * /!\ Note that we may have multiple targets for a given (watcherActorId, browserId) couple, * for example if we have 2 remote iframes sharing the same origin, which is why you * might want to pass a specific browsing context id to filter the list down. * * @param {Object} options * @param {Object} options.watcherActorID * @param {Object} options.sessionContext * @param {Object} options.browsingContextId: Optional browsing context id to narrow the * search to a specific browsing context. * * @returns {WindowGlobalTargetActor|null} */ _findTargetActor({ watcherActorID, sessionContext, browsingContextId }) { // First let's check if a target was created for this watcher actor in this specific // DevToolsFrameChild instance. const connectionInfo = this._connections.get(watcherActorID); const targetActor = connectionInfo ? connectionInfo.actor : null; if (targetActor) { return targetActor; } // If we couldn't find such target, we want to see if a target was created for this // (watcherActorId,browserId, {browsingContextId}) in another DevToolsFrameChild instance. // This might be the case if we're navigating to a new page with server side target // enabled and we want to retrieve the target of the page we're navigating from. if ( lazy.isWindowGlobalPartOfContext(this.manager, sessionContext, { forceAcceptTopLevelTarget: true, }) ) { // 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 = watcherActorID.replace(/watcher\d+$/, ""); const targetActors = lazy.TargetActorRegistry.getTargetActors( sessionContext, connectionPrefix ); if (!browsingContextId) { return targetActors[0] || null; } return targetActors.find( actor => actor.browsingContextID === browsingContextId ); } return null; } _addOrSetSessionDataEntry( watcherActorID, sessionContext, type, entries, updateType ) { // /!\ We may have an issue here as there could be multiple targets for a given // (watcherActorID,browserId) pair. // This should be clarified as part of Bug 1725623. const targetActor = this._findTargetActor({ watcherActorID, sessionContext, }); if (!targetActor) { throw new Error( `No target actor for this Watcher Actor ID:"${watcherActorID}" / BrowserId:${sessionContext.browserId}` ); } return targetActor.addOrSetSessionDataEntry( type, entries, false, updateType ); } _removeSessionDataEntry(watcherActorID, sessionContext, type, entries) { // /!\ We may have an issue here as there could be multiple targets for a given // (watcherActorID,browserId) pair. // This should be clarified as part of Bug 1725623. const targetActor = this._findTargetActor({ watcherActorID, sessionContext, }); // By the time we are calling this, the target may already have been destroyed. if (targetActor) { return targetActor.removeSessionDataEntry(type, entries); } return null; } handleEvent({ type, persisted, target }) { // Ignore any event that may fire for children WindowGlobals/documents if (target != this.document) { return; } // DOMWindowCreated is registered from FrameWatcher via `ActorManagerParent.addJSWindowActors` // as a DOM event to be listened to and so is fired by JS Window Actor code platform code. if (type == "DOMWindowCreated") { this.instantiate(); 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 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. There is a DOMDocElementInserted fired just after, that we can catch // to create a target for same-process iframes. // 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) if (type == "DOMDocElementInserted") { this.instantiate({ ignoreIfExisting: true }); return; } // 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. this.sendAsyncMessage("DevToolsFrameChild:bf-cache-navigation-pageshow"); // 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. this.instantiate({ isBFCache: true, }); return; } 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. this.sendAsyncMessage("DevToolsFrameChild:bf-cache-navigation-pagehide"); // 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. const { sharedData } = Services.cpmm; const sessionDataByWatcherActor = sharedData.get(SHARED_DATA_KEY_NAME); if (!sessionDataByWatcherActor) { throw new Error( "Request to instantiate the target(s) for the BrowsingContext, but `sharedData` is empty about watched targets" ); } const actors = []; // A flag to know if the following for loop ended up destroying all the actors. // It may not be the case if one Watcher isn't having server target switching enabled. let allActorsAreDestroyed = true; for (const [watcherActorID, sessionData] of sessionDataByWatcherActor) { const { sessionContext } = sessionData; // /!\ We may have an issue here as there could be multiple targets for a given // (watcherActorID,browserId) pair. // This should be clarified as part of Bug 1725623. const existingTarget = this._findTargetActor({ watcherActorID, sessionContext, }); if (!existingTarget) { continue; } // Use `originalWindow` as `window` can be set when a document was selected from // the iframe picker in the toolbox toolbar. if (existingTarget.originalWindow.document != target) { throw new Error("Existing target actor is for a distinct document"); } // 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 ( !this.isBfcacheInParentEnabled && !sessionContext.isServerTargetSwitchingEnabled ) { allActorsAreDestroyed = false; continue; } actors.push({ watcherActorID, form: existingTarget.form(), }); existingTarget.destroy(); } if (actors.length) { // The most important is to unregister the actor from TargetActorRegistry, // so that it is no longer present in the list when new DOMWindowCreated fires. // This will also help notify the client that the target has been destroyed. // And if we navigate back to this target, the client will receive the same target actor ID, // so that it is really important to destroy it correctly on both server and client. this.sendAsyncMessage("DevToolsFrameChild:destroy", { actors }); } if (allActorsAreDestroyed) { // Completely clear this JSWindow Actor. // Do this after having called _findTargetActor, // as it would clear the registered target actors. this.didDestroy(); } } } didDestroy(options) { logWindowGlobal(this.manager, "Destroy WindowGlobalTarget"); for (const [, connectionInfo] of this._connections) { connectionInfo.connection.close(options); } this._connections.clear(); if (this.loader) { if (this.useCustomLoader) { lazy.releaseDistinctSystemPrincipalLoader(this); } this.loader = null; } } }