diff options
Diffstat (limited to 'devtools/server/actors/targets')
18 files changed, 3537 insertions, 0 deletions
diff --git a/devtools/server/actors/targets/base-target-actor.js b/devtools/server/actors/targets/base-target-actor.js new file mode 100644 index 0000000000..f3fc2a89e7 --- /dev/null +++ b/devtools/server/actors/targets/base-target-actor.js @@ -0,0 +1,214 @@ +/* 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 { Actor } = require("resource://devtools/shared/protocol.js"); +const { + TYPES, + getResourceWatcher, +} = require("resource://devtools/server/actors/resources/index.js"); +const Targets = require("devtools/server/actors/targets/index"); + +loader.lazyRequireGetter( + this, + "SessionDataProcessors", + "resource://devtools/server/actors/targets/session-data-processors/index.js", + true +); + +class BaseTargetActor extends Actor { + constructor(conn, targetType, spec) { + super(conn, spec); + + /** + * Type of target, a string of Targets.TYPES. + * @return {string} + */ + this.targetType = targetType; + } + + /** + * Process a new data entry, which can be watched resources, breakpoints, ... + * + * @param string type + * The type of data to be added + * @param Array<Object> entries + * The values to be added to this type of data + * @param Boolean isDocumentCreation + * Set to true if this function is called just after a new document (and its + * associated target) is created. + * @param String updateType + * "add" will only add the new entries in the existing data set. + * "set" will update the data set with the new entries. + */ + async addOrSetSessionDataEntry( + type, + entries, + isDocumentCreation = false, + updateType + ) { + const processor = SessionDataProcessors[type]; + if (processor) { + await processor.addOrSetSessionDataEntry( + this, + entries, + isDocumentCreation, + updateType + ); + } + } + + /** + * Remove data entries that have been previously added via addOrSetSessionDataEntry + * + * See addOrSetSessionDataEntry for argument description. + */ + removeSessionDataEntry(type, entries) { + const processor = SessionDataProcessors[type]; + if (processor) { + processor.removeSessionDataEntry(this, entries); + } + } + + /** + * Called by Resource Watchers, when new resources are available, updated or destroyed. + * + * @param String updateType + * Can be "available", "updated" or "destroyed" + * @param Array<json> resources + * List of all resource's form. A resource is a JSON object piped over to the client. + * It can contain actor IDs, actor forms, to be manually marshalled by the client. + */ + notifyResources(updateType, resources) { + if (resources.length === 0 || this.isDestroyed()) { + // Don't try to emit if the resources array is empty or the actor was + // destroyed. + return; + } + + if (this.devtoolsSpawnedBrowsingContextForWebExtension) { + this.overrideResourceBrowsingContextForWebExtension(resources); + } + + this.emit(`resource-${updateType}-form`, resources); + } + + /** + * For WebExtension, we have to hack all resource's browsingContextID + * in order to ensure emitting them with the fixed, original browsingContextID + * related to the fallback document created by devtools which always exists. + * The target's form will always be relating to that BrowsingContext IDs (browsing context ID and inner window id). + * Even if the target switches internally to another document via WindowGlobalTargetActor._setWindow. + * + * @param {Array<Objects>} List of resources + */ + overrideResourceBrowsingContextForWebExtension(resources) { + const browsingContextID = + this.devtoolsSpawnedBrowsingContextForWebExtension.id; + resources.forEach( + resource => (resource.browsingContextID = browsingContextID) + ); + } + + // List of actor prefixes (string) which have already been instantiated via getTargetScopedActor method. + #instantiatedTargetScopedActors = new Set(); + + /** + * Try to return any target scoped actor instance, if it exists. + * They are lazily instantiated and so will only be available + * if the client called at least one of their method. + * + * @param {String} prefix + * Prefix for the actor we would like to retrieve. + * Defined in devtools/server/actors/utils/actor-registry.js + */ + getTargetScopedActor(prefix) { + if (this.isDestroyed()) { + return null; + } + const form = this.form(); + this.#instantiatedTargetScopedActors.add(prefix); + return this.conn._getOrCreateActor(form[prefix + "Actor"]); + } + + /** + * Returns true, if the related target scoped actor has already been queried + * and instantiated via `getTargetScopedActor` method. + * + * @param {String} prefix + * See getTargetScopedActor definition + * @return Boolean + * True, if the actor has already been instantiated. + */ + hasTargetScopedActor(prefix) { + return this.#instantiatedTargetScopedActors.has(prefix); + } + + /** + * Apply target-specific options. + * + * This will be called by the watcher when the DevTools target-configuration + * is updated, or when a target is created via JSWindowActors. + * + * @param {JSON} options + * Configuration object provided by the client. + * See target-configuration actor. + * @param {Boolean} calledFromDocumentCreate + * True, when this is called with initial configuration when the related target + * actor is instantiated. + */ + updateTargetConfiguration(options = {}, calledFromDocumentCreation = false) { + // If there is some tracer options, we should start tracing, otherwise we should stop (if we were) + if (options.tracerOptions) { + // Ignore the SessionData update if the user requested to start the tracer on next page load and: + // - we apply it to an already loaded WindowGlobal, + // - the target isn't the top level one. + if ( + options.tracerOptions.traceOnNextLoad && + (!calledFromDocumentCreation || !this.isTopLevelTarget) + ) { + if (this.isTopLevelTarget) { + const consoleMessageWatcher = getResourceWatcher( + this, + TYPES.CONSOLE_MESSAGE + ); + if (consoleMessageWatcher) { + consoleMessageWatcher.emitMessages([ + { + arguments: [ + "Waiting for next navigation or page reload before starting tracing", + ], + styles: [], + level: "jstracer", + chromeContext: false, + timeStamp: ChromeUtils.dateNow(), + }, + ]); + } + } + return; + } + // Bug 1874204: For now, in the browser toolbox, only frame and workers are traced. + // Content process targets are ignored as they would also include each document/frame target. + // This would require some work to ignore FRAME targets from here, only in case of browser toolbox, + // and also handle all content process documents for DOM Event logging. + // + // Bug 1874219: Also ignore extensions for now as they are all running in the same process, + // whereas we can only spawn one tracer per thread. + if ( + this.targetType == Targets.TYPES.PROCESS || + this.url?.startsWith("moz-extension://") + ) { + return; + } + const tracerActor = this.getTargetScopedActor("tracer"); + tracerActor.startTracing(options.tracerOptions); + } else if (this.hasTargetScopedActor("tracer")) { + const tracerActor = this.getTargetScopedActor("tracer"); + tracerActor.stopTracing(); + } + } +} +exports.BaseTargetActor = BaseTargetActor; diff --git a/devtools/server/actors/targets/content-process.js b/devtools/server/actors/targets/content-process.js new file mode 100644 index 0000000000..56b1934ef1 --- /dev/null +++ b/devtools/server/actors/targets/content-process.js @@ -0,0 +1,265 @@ +/* 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"; + +/* + * Target actor for all resources in a content process of Firefox (chrome sandboxes, frame + * scripts, documents, etc.) + * + * See devtools/docs/backend/actor-hierarchy.md for more details. + */ + +const { ThreadActor } = require("resource://devtools/server/actors/thread.js"); +const { + WebConsoleActor, +} = require("resource://devtools/server/actors/webconsole.js"); +const makeDebugger = require("resource://devtools/server/actors/utils/make-debugger.js"); +const { Pool } = require("resource://devtools/shared/protocol.js"); +const { assert } = require("resource://devtools/shared/DevToolsUtils.js"); +const { + SourcesManager, +} = require("resource://devtools/server/actors/utils/sources-manager.js"); +const { + contentProcessTargetSpec, +} = require("resource://devtools/shared/specs/targets/content-process.js"); +const Targets = require("resource://devtools/server/actors/targets/index.js"); +const Resources = require("resource://devtools/server/actors/resources/index.js"); +const { + BaseTargetActor, +} = require("resource://devtools/server/actors/targets/base-target-actor.js"); +const { TargetActorRegistry } = ChromeUtils.importESModule( + "resource://devtools/server/actors/targets/target-actor-registry.sys.mjs", + { + loadInDevToolsLoader: false, + } +); + +loader.lazyRequireGetter( + this, + "WorkerDescriptorActorList", + "resource://devtools/server/actors/worker/worker-descriptor-actor-list.js", + true +); +loader.lazyRequireGetter( + this, + "MemoryActor", + "resource://devtools/server/actors/memory.js", + true +); +loader.lazyRequireGetter( + this, + "TracerActor", + "resource://devtools/server/actors/tracer.js", + true +); + +class ContentProcessTargetActor extends BaseTargetActor { + constructor(conn, { isXpcShellTarget = false, sessionContext } = {}) { + super(conn, Targets.TYPES.PROCESS, contentProcessTargetSpec); + + this.threadActor = null; + this.isXpcShellTarget = isXpcShellTarget; + this.sessionContext = sessionContext; + + // Use a see-everything debugger + this.makeDebugger = makeDebugger.bind(null, { + findDebuggees: dbg => + dbg.findAllGlobals().map(g => g.unsafeDereference()), + shouldAddNewGlobalAsDebuggee: global => true, + }); + + const sandboxPrototype = { + get tabs() { + return Array.from( + Services.ww.getWindowEnumerator(), + win => win.docShell.messageManager + ); + }, + }; + + // Scope into which the webconsole executes: + // A sandbox with chrome privileges with a `tabs` getter. + const systemPrincipal = Cc["@mozilla.org/systemprincipal;1"].createInstance( + Ci.nsIPrincipal + ); + const sandbox = Cu.Sandbox(systemPrincipal, { + sandboxPrototype, + wantGlobalProperties: ["ChromeUtils"], + }); + this._consoleScope = sandbox; + + this._workerList = null; + this._workerDescriptorActorPool = null; + this._onWorkerListChanged = this._onWorkerListChanged.bind(this); + + // Try to destroy the Content Process Target when the content process shuts down. + // The parent process can't communicate during shutdown as the communication channel + // is already down (message manager or JS Window Actor API). + // So that we have to observe to some event fired from this process. + // While such cleanup doesn't sound ultimately necessary (the process will be completely destroyed) + // mochitests are asserting that there is no leaks during process shutdown. + // Do not override destroy as Protocol.js may override it when calling destroy, + // and we won't be able to call removeObserver correctly. + this.destroyObserver = this.destroy.bind(this); + Services.obs.addObserver(this.destroyObserver, "xpcom-shutdown"); + if (this.isXpcShellTarget) { + TargetActorRegistry.registerXpcShellTargetActor(this); + } + } + + get isRootActor() { + return true; + } + + get url() { + return undefined; + } + + get window() { + return this._consoleScope; + } + + get sourcesManager() { + if (!this._sourcesManager) { + assert( + this.threadActor, + "threadActor should exist when creating SourcesManager." + ); + this._sourcesManager = new SourcesManager(this.threadActor); + } + return this._sourcesManager; + } + + /* + * Return a Debugger instance or create one if there is none yet + */ + get dbg() { + if (!this._dbg) { + this._dbg = this.makeDebugger(); + } + return this._dbg; + } + + form() { + if (!this._consoleActor) { + this._consoleActor = new WebConsoleActor(this.conn, this); + this.manage(this._consoleActor); + } + + if (!this.threadActor) { + this.threadActor = new ThreadActor(this, null); + this.manage(this.threadActor); + } + if (!this.memoryActor) { + this.memoryActor = new MemoryActor(this.conn, this); + this.manage(this.memoryActor); + } + if (!this.tracerActor) { + this.tracerActor = new TracerActor(this.conn, this); + this.manage(this.tracerActor); + } + + return { + actor: this.actorID, + isXpcShellTarget: this.isXpcShellTarget, + processID: Services.appinfo.processID, + remoteType: Services.appinfo.remoteType, + + consoleActor: this._consoleActor.actorID, + memoryActor: this.memoryActor.actorID, + threadActor: this.threadActor.actorID, + tracerActor: this.tracerActor.actorID, + + traits: { + networkMonitor: false, + // See trait description in browsing-context.js + supportsTopLevelTargetFlag: false, + }, + }; + } + + ensureWorkerList() { + if (!this._workerList) { + this._workerList = new WorkerDescriptorActorList(this.conn, {}); + } + return this._workerList; + } + + listWorkers() { + return this.ensureWorkerList() + .getList() + .then(actors => { + const pool = new Pool(this.conn, "workers"); + for (const actor of actors) { + pool.manage(actor); + } + + // Do not destroy the pool before transfering ownership to the newly created + // pool, so that we do not accidentally destroy actors that are still in use. + if (this._workerDescriptorActorPool) { + this._workerDescriptorActorPool.destroy(); + } + + this._workerDescriptorActorPool = pool; + this._workerList.onListChanged = this._onWorkerListChanged; + + return { workers: actors }; + }); + } + + _onWorkerListChanged() { + this.conn.send({ from: this.actorID, type: "workerListChanged" }); + this._workerList.onListChanged = null; + } + + pauseMatchingServiceWorkers(request) { + this.ensureWorkerList().workerPauser.setPauseServiceWorkers(request.origin); + } + + destroy() { + // Avoid reentrancy. We will destroy the Transport when emitting "destroyed", + // which will force destroying all actors. + if (this.destroying) { + return; + } + this.destroying = true; + + // Unregistering watchers first is important + // otherwise you might have leaks reported when running browser_browser_toolbox_netmonitor.js in debug builds + Resources.unwatchAllResources(this); + + this.emit("destroyed"); + + super.destroy(); + + if (this.threadActor) { + this.threadActor = null; + } + + // Tell the live lists we aren't watching any more. + if (this._workerList) { + this._workerList.destroy(); + this._workerList = null; + } + + if (this._sourcesManager) { + this._sourcesManager.destroy(); + this._sourcesManager = null; + } + + if (this._dbg) { + this._dbg.disable(); + this._dbg = null; + } + + Services.obs.removeObserver(this.destroyObserver, "xpcom-shutdown"); + + if (this.isXpcShellTarget) { + TargetActorRegistry.unregisterXpcShellTargetActor(this); + } + } +} + +exports.ContentProcessTargetActor = ContentProcessTargetActor; diff --git a/devtools/server/actors/targets/index.js b/devtools/server/actors/targets/index.js new file mode 100644 index 0000000000..61501d37e8 --- /dev/null +++ b/devtools/server/actors/targets/index.js @@ -0,0 +1,14 @@ +/* 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 TYPES = { + FRAME: "frame", + PROCESS: "process", + WORKER: "worker", + SERVICE_WORKER: "service_worker", + SHARED_WORKER: "shared_worker", +}; +exports.TYPES = TYPES; diff --git a/devtools/server/actors/targets/moz.build b/devtools/server/actors/targets/moz.build new file mode 100644 index 0000000000..f4d44ae669 --- /dev/null +++ b/devtools/server/actors/targets/moz.build @@ -0,0 +1,20 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DIRS += [ + "session-data-processors", +] + +DevToolsModules( + "base-target-actor.js", + "content-process.js", + "index.js", + "parent-process.js", + "target-actor-registry.sys.mjs", + "webextension.js", + "window-global.js", + "worker.js", +) diff --git a/devtools/server/actors/targets/parent-process.js b/devtools/server/actors/targets/parent-process.js new file mode 100644 index 0000000000..4b7da5e9a4 --- /dev/null +++ b/devtools/server/actors/targets/parent-process.js @@ -0,0 +1,167 @@ +/* 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"; + +/* + * Target actor for the entire parent process. + * + * This actor extends WindowGlobalTargetActor. + * This actor is extended by WebExtensionTargetActor. + * + * See devtools/docs/backend/actor-hierarchy.md for more details. + */ + +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); +const { + getChildDocShells, + WindowGlobalTargetActor, +} = require("resource://devtools/server/actors/targets/window-global.js"); +const makeDebugger = require("resource://devtools/server/actors/utils/make-debugger.js"); + +const { + parentProcessTargetSpec, +} = require("resource://devtools/shared/specs/targets/parent-process.js"); + +class ParentProcessTargetActor extends WindowGlobalTargetActor { + /** + * Creates a target actor for debugging all the chrome content in the parent process. + * Most of the implementation is inherited from WindowGlobalTargetActor. + * ParentProcessTargetActor is a child of RootActor, it can be instantiated via + * RootActor.getProcess request. ParentProcessTargetActor exposes all target-scoped actors + * via its form() request, like WindowGlobalTargetActor. + * + * @param conn DevToolsServerConnection + * The connection to the client. + * @param {Object} options + * - isTopLevelTarget: {Boolean} flag to indicate if this is the top + * level target of the DevTools session + * - sessionContext Object + * The Session Context to help know what is debugged. + * See devtools/server/actors/watcher/session-context.js + * - customSpec Object + * WebExtensionTargetActor inherits from ParentProcessTargetActor + * and has to use its own protocol.js specification object. + */ + constructor( + conn, + { isTopLevelTarget, sessionContext, customSpec = parentProcessTargetSpec } + ) { + super(conn, { + isTopLevelTarget, + sessionContext, + customSpec, + }); + + // This creates a Debugger instance for chrome debugging all globals. + this.makeDebugger = makeDebugger.bind(null, { + findDebuggees: dbg => + dbg.findAllGlobals().map(g => g.unsafeDereference()), + shouldAddNewGlobalAsDebuggee: () => true, + }); + + // Ensure catching the creation of any new content docshell + this.watchNewDocShells = true; + + this.isRootActor = true; + + // Listen for any new/destroyed chrome docshell + Services.obs.addObserver(this, "chrome-webnavigation-create"); + Services.obs.addObserver(this, "chrome-webnavigation-destroy"); + + // If we are the parent process target actor and not a subclass + // (i.e. if we aren't the webext target actor) + // set the parent process docshell: + if (customSpec == parentProcessTargetSpec) { + this.setDocShell(this._getInitialDocShell()); + } + } + + // Overload setDocShell in order to observe all the docshells. + // WindowGlobalTargetActor only observes the top level one, + // but we also need to observe all of them for WebExtensionTargetActor subclass. + setDocShell(initialDocShell) { + super.setDocShell(initialDocShell); + + // Iterate over all top-level windows. + for (const { docShell } of Services.ww.getWindowEnumerator()) { + if (docShell == this.docShell) { + continue; + } + this._progressListener.watch(docShell); + } + } + + _getInitialDocShell() { + // Defines the default docshell selected for the target actor + let window = Services.wm.getMostRecentWindow( + DevToolsServer.chromeWindowType + ); + + // Default to any available top level window if there is no expected window + // eg when running ./mach run --chrome chrome://browser/content/aboutTabCrashed.xhtml --jsdebugger + if (!window) { + window = Services.wm.getMostRecentWindow(null); + } + + // We really want _some_ window at least, so fallback to the hidden window if + // there's nothing else (such as during early startup). + if (!window) { + window = Services.appShell.hiddenDOMWindow; + } + return window.docShell; + } + + /** + * Getter for the list of all docshells in this targetActor + * @return {Array} + */ + get docShells() { + // Iterate over all top-level windows and all their docshells. + let docShells = []; + for (const { docShell } of Services.ww.getWindowEnumerator()) { + docShells = docShells.concat(getChildDocShells(docShell)); + } + + return docShells; + } + + observe(subject, topic, data) { + super.observe(subject, topic, data); + if (this.isDestroyed()) { + return; + } + + subject.QueryInterface(Ci.nsIDocShell); + + if (topic == "chrome-webnavigation-create") { + this._onDocShellCreated(subject); + } else if (topic == "chrome-webnavigation-destroy") { + this._onDocShellDestroy(subject); + } + } + + _detach() { + if (this.isDestroyed()) { + return false; + } + + Services.obs.removeObserver(this, "chrome-webnavigation-create"); + Services.obs.removeObserver(this, "chrome-webnavigation-destroy"); + + // Iterate over all top-level windows. + for (const { docShell } of Services.ww.getWindowEnumerator()) { + if (docShell == this.docShell) { + continue; + } + this._progressListener.unwatch(docShell); + } + + return super._detach(); + } +} + +exports.ParentProcessTargetActor = ParentProcessTargetActor; diff --git a/devtools/server/actors/targets/session-data-processors/blackboxing.js b/devtools/server/actors/targets/session-data-processors/blackboxing.js new file mode 100644 index 0000000000..70f4397a72 --- /dev/null +++ b/devtools/server/actors/targets/session-data-processors/blackboxing.js @@ -0,0 +1,28 @@ +/* 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"; + +module.exports = { + async addOrSetSessionDataEntry( + targetActor, + entries, + isDocumentCreation, + updateType + ) { + const { sourcesManager } = targetActor; + if (updateType == "set") { + sourcesManager.clearAllBlackBoxing(); + } + for (const { url, range } of entries) { + sourcesManager.blackBox(url, range); + } + }, + + removeSessionDataEntry(targetActor, entries, isDocumentCreation) { + for (const { url, range } of entries) { + targetActor.sourcesManager.unblackBox(url, range); + } + }, +}; diff --git a/devtools/server/actors/targets/session-data-processors/breakpoints.js b/devtools/server/actors/targets/session-data-processors/breakpoints.js new file mode 100644 index 0000000000..ff7cb7ec0a --- /dev/null +++ b/devtools/server/actors/targets/session-data-processors/breakpoints.js @@ -0,0 +1,45 @@ +/* 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 { + STATES: THREAD_STATES, +} = require("resource://devtools/server/actors/thread.js"); + +module.exports = { + async addOrSetSessionDataEntry( + targetActor, + entries, + isDocumentCreation, + updateType + ) { + const { threadActor } = targetActor; + if (updateType == "set") { + threadActor.removeAllBreakpoints(); + } + const isTargetCreation = threadActor.state == THREAD_STATES.DETACHED; + if (isTargetCreation && !targetActor.targetType.endsWith("worker")) { + // If addOrSetSessionDataEntry is called during target creation, attach the + // thread actor automatically and pass the initial breakpoints. + // However, do not attach the thread actor for Workers. They use a codepath + // which releases the worker on `attach`. For them, the client will call `attach`. (bug 1691986) + await threadActor.attach({ breakpoints: entries }); + } else { + // If addOrSetSessionDataEntry is called for an existing target, set the new + // breakpoints on the already running thread actor. + await Promise.all( + entries.map(({ location, options }) => + threadActor.setBreakpoint(location, options) + ) + ); + } + }, + + removeSessionDataEntry(targetActor, entries, isDocumentCreation) { + for (const { location } of entries) { + targetActor.threadActor.removeBreakpoint(location); + } + }, +}; diff --git a/devtools/server/actors/targets/session-data-processors/event-breakpoints.js b/devtools/server/actors/targets/session-data-processors/event-breakpoints.js new file mode 100644 index 0000000000..c0a2fb7ffe --- /dev/null +++ b/devtools/server/actors/targets/session-data-processors/event-breakpoints.js @@ -0,0 +1,36 @@ +/* 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 { + STATES: THREAD_STATES, +} = require("resource://devtools/server/actors/thread.js"); + +module.exports = { + async addOrSetSessionDataEntry( + targetActor, + entries, + isDocumentCreation, + updateType + ) { + const { threadActor } = targetActor; + // Same as comments for XHR breakpoints. See lines 117-118 + if ( + threadActor.state == THREAD_STATES.DETACHED && + !targetActor.targetType.endsWith("worker") + ) { + threadActor.attach(); + } + if (updateType == "set") { + threadActor.setActiveEventBreakpoints(entries); + } else { + threadActor.addEventBreakpoints(entries); + } + }, + + removeSessionDataEntry(targetActor, entries, isDocumentCreation) { + targetActor.threadActor.removeEventBreakpoints(entries); + }, +}; diff --git a/devtools/server/actors/targets/session-data-processors/index.js b/devtools/server/actors/targets/session-data-processors/index.js new file mode 100644 index 0000000000..19b7d69302 --- /dev/null +++ b/devtools/server/actors/targets/session-data-processors/index.js @@ -0,0 +1,50 @@ +/* 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 { + SessionDataHelpers, +} = require("resource://devtools/server/actors/watcher/SessionDataHelpers.jsm"); +const { SUPPORTED_DATA } = SessionDataHelpers; + +const SessionDataProcessors = {}; + +loader.lazyRequireGetter( + SessionDataProcessors, + SUPPORTED_DATA.BLACKBOXING, + "resource://devtools/server/actors/targets/session-data-processors/blackboxing.js" +); +loader.lazyRequireGetter( + SessionDataProcessors, + SUPPORTED_DATA.BREAKPOINTS, + "resource://devtools/server/actors/targets/session-data-processors/breakpoints.js" +); +loader.lazyRequireGetter( + SessionDataProcessors, + SUPPORTED_DATA.EVENT_BREAKPOINTS, + "resource://devtools/server/actors/targets/session-data-processors/event-breakpoints.js" +); +loader.lazyRequireGetter( + SessionDataProcessors, + SUPPORTED_DATA.RESOURCES, + "resource://devtools/server/actors/targets/session-data-processors/resources.js" +); +loader.lazyRequireGetter( + SessionDataProcessors, + SUPPORTED_DATA.TARGET_CONFIGURATION, + "resource://devtools/server/actors/targets/session-data-processors/target-configuration.js" +); +loader.lazyRequireGetter( + SessionDataProcessors, + SUPPORTED_DATA.THREAD_CONFIGURATION, + "resource://devtools/server/actors/targets/session-data-processors/thread-configuration.js" +); +loader.lazyRequireGetter( + SessionDataProcessors, + SUPPORTED_DATA.XHR_BREAKPOINTS, + "resource://devtools/server/actors/targets/session-data-processors/xhr-breakpoints.js" +); + +exports.SessionDataProcessors = SessionDataProcessors; diff --git a/devtools/server/actors/targets/session-data-processors/moz.build b/devtools/server/actors/targets/session-data-processors/moz.build new file mode 100644 index 0000000000..ea924d7d79 --- /dev/null +++ b/devtools/server/actors/targets/session-data-processors/moz.build @@ -0,0 +1,16 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + "blackboxing.js", + "breakpoints.js", + "event-breakpoints.js", + "index.js", + "resources.js", + "target-configuration.js", + "thread-configuration.js", + "xhr-breakpoints.js", +) diff --git a/devtools/server/actors/targets/session-data-processors/resources.js b/devtools/server/actors/targets/session-data-processors/resources.js new file mode 100644 index 0000000000..8f33ba8e0f --- /dev/null +++ b/devtools/server/actors/targets/session-data-processors/resources.js @@ -0,0 +1,25 @@ +/* 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 Resources = require("resource://devtools/server/actors/resources/index.js"); + +module.exports = { + async addOrSetSessionDataEntry( + targetActor, + entries, + isDocumentCreation, + updateType + ) { + if (updateType == "set") { + Resources.unwatchAllResources(targetActor); + } + await Resources.watchResources(targetActor, entries); + }, + + removeSessionDataEntry(targetActor, entries, isDocumentCreation) { + Resources.unwatchResources(targetActor, entries); + }, +}; diff --git a/devtools/server/actors/targets/session-data-processors/target-configuration.js b/devtools/server/actors/targets/session-data-processors/target-configuration.js new file mode 100644 index 0000000000..f68e82d69f --- /dev/null +++ b/devtools/server/actors/targets/session-data-processors/target-configuration.js @@ -0,0 +1,32 @@ +/* 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"; + +module.exports = { + async addOrSetSessionDataEntry( + targetActor, + entries, + isDocumentCreation, + updateType + ) { + // Only WindowGlobalTargetActor implements updateTargetConfiguration, + // skip targetActor data entry update for other targets. + if (typeof targetActor.updateTargetConfiguration == "function") { + const options = {}; + for (const { key, value } of entries) { + options[key] = value; + } + // Regarding `updateType`, `entries` is always a partial set of configurations. + // We will acknowledge the passed attribute, but if we had set some other attributes + // before this call, they will stay as-is. + // So it is as if this session data was also using "add" updateType. + targetActor.updateTargetConfiguration(options, isDocumentCreation); + } + }, + + removeSessionDataEntry(targetActor, entries, isDocumentCreation) { + // configuration data entries are always added/updated, never removed. + }, +}; diff --git a/devtools/server/actors/targets/session-data-processors/thread-configuration.js b/devtools/server/actors/targets/session-data-processors/thread-configuration.js new file mode 100644 index 0000000000..716d2a9b21 --- /dev/null +++ b/devtools/server/actors/targets/session-data-processors/thread-configuration.js @@ -0,0 +1,41 @@ +/* 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 { + STATES: THREAD_STATES, +} = require("resource://devtools/server/actors/thread.js"); + +module.exports = { + async addOrSetSessionDataEntry( + targetActor, + entries, + isDocumentCreation, + updateType + ) { + const threadOptions = {}; + + for (const { key, value } of entries) { + threadOptions[key] = value; + } + + if ( + !targetActor.targetType.endsWith("worker") && + targetActor.threadActor.state == THREAD_STATES.DETACHED + ) { + await targetActor.threadActor.attach(threadOptions); + } else { + // Regarding `updateType`, `entries` is always a partial set of configurations. + // We will acknowledge the passed attribute, but if we had set some other attributes + // before this call, they will stay as-is. + // So it is as if this session data was also using "add" updateType. + await targetActor.threadActor.reconfigure(threadOptions); + } + }, + + removeSessionDataEntry(targetActor, entries, isDocumentCreation) { + // configuration data entries are always added/updated, never removed. + }, +}; diff --git a/devtools/server/actors/targets/session-data-processors/xhr-breakpoints.js b/devtools/server/actors/targets/session-data-processors/xhr-breakpoints.js new file mode 100644 index 0000000000..7a0fd815aa --- /dev/null +++ b/devtools/server/actors/targets/session-data-processors/xhr-breakpoints.js @@ -0,0 +1,44 @@ +/* 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 { + STATES: THREAD_STATES, +} = require("resource://devtools/server/actors/thread.js"); + +module.exports = { + async addOrSetSessionDataEntry( + targetActor, + entries, + isDocumentCreation, + updateType + ) { + const { threadActor } = targetActor; + if (updateType == "set") { + threadActor.removeAllXHRBreakpoints(); + } + + // The thread actor has to be initialized in order to correctly + // retrieve the stack trace when hitting an XHR + if ( + threadActor.state == THREAD_STATES.DETACHED && + !targetActor.targetType.endsWith("worker") + ) { + await threadActor.attach(); + } + + await Promise.all( + entries.map(({ path, method }) => + threadActor.setXHRBreakpoint(path, method) + ) + ); + }, + + removeSessionDataEntry(targetActor, entries, isDocumentCreation) { + for (const { path, method } of entries) { + targetActor.threadActor.removeXHRBreakpoint(path, method); + } + }, +}; diff --git a/devtools/server/actors/targets/target-actor-registry.sys.mjs b/devtools/server/actors/targets/target-actor-registry.sys.mjs new file mode 100644 index 0000000000..4cb6d13868 --- /dev/null +++ b/devtools/server/actors/targets/target-actor-registry.sys.mjs @@ -0,0 +1,82 @@ +/* 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/. */ + +// Keep track of all WindowGlobal target actors. +// This is especially used to track the actors using Message manager connector, +// or the ones running in the parent process. +// Top level actors, like tab's top level target or parent process target +// are still using message manager in order to avoid being destroyed on navigation. +// And because of this, these actors aren't using JS Window Actor. +const windowGlobalTargetActors = new Set(); +let xpcShellTargetActor = null; + +export var TargetActorRegistry = { + registerTargetActor(targetActor) { + windowGlobalTargetActors.add(targetActor); + }, + + unregisterTargetActor(targetActor) { + windowGlobalTargetActors.delete(targetActor); + }, + + registerXpcShellTargetActor(targetActor) { + xpcShellTargetActor = targetActor; + }, + + unregisterXpcShellTargetActor(targetActor) { + xpcShellTargetActor = null; + }, + + get xpcShellTargetActor() { + return xpcShellTargetActor; + }, + + /** + * Return the target actors matching the passed browser element id. + * In some scenarios, the registry can have multiple target actors for a given + * browserId (e.g. the regular DevTools content toolbox + DevTools WebExtensions targets). + * + * @param {Object} sessionContext: The Session Context to help know what is debugged. + * See devtools/server/actors/watcher/session-context.js + * @param {String} connectionPrefix: DevToolsServerConnection's prefix, in order to select only actor + * related to the same connection. i.e. the same client. + * @returns {Array<TargetActor>} + */ + getTargetActors(sessionContext, connectionPrefix) { + const actors = []; + for (const actor of windowGlobalTargetActors) { + const isMatchingPrefix = actor.actorID.startsWith(connectionPrefix); + const isMatchingContext = + sessionContext.type == "all" || + (sessionContext.type == "browser-element" && + (actor.browserId == sessionContext.browserId || + actor.openerBrowserId == sessionContext.browserId)) || + (sessionContext.type == "webextension" && + actor.addonId == sessionContext.addonId); + if (isMatchingPrefix && isMatchingContext) { + actors.push(actor); + } + } + return actors; + }, + + /** + * Helper for tests to help track the number of targets created for a given tab. + * (Used by browser_ext_devtools_inspectedWindow.js) + * + * @param {Number} browserId: ID for the tab + * + * @returns {Number} Number of targets for this tab. + */ + + getTargetActorsCountForBrowserElement(browserId) { + let count = 0; + for (const actor of windowGlobalTargetActors) { + if (actor.browserId == browserId) { + count++; + } + } + return count; + }, +}; diff --git a/devtools/server/actors/targets/webextension.js b/devtools/server/actors/targets/webextension.js new file mode 100644 index 0000000000..c717b53011 --- /dev/null +++ b/devtools/server/actors/targets/webextension.js @@ -0,0 +1,374 @@ +/* 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"; + +/* + * Target actor for a WebExtension add-on. + * + * This actor extends ParentProcessTargetActor. + * + * See devtools/docs/backend/actor-hierarchy.md for more details. + */ + +const { + ParentProcessTargetActor, +} = require("resource://devtools/server/actors/targets/parent-process.js"); +const makeDebugger = require("resource://devtools/server/actors/utils/make-debugger.js"); +const { + webExtensionTargetSpec, +} = require("resource://devtools/shared/specs/targets/webextension.js"); + +const { + getChildDocShells, +} = require("resource://devtools/server/actors/targets/window-global.js"); + +loader.lazyRequireGetter( + this, + "unwrapDebuggerObjectGlobal", + "resource://devtools/server/actors/thread.js", + true +); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + getAddonIdForWindowGlobal: + "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs", +}); + +const FALLBACK_DOC_URL = + "chrome://devtools/content/shared/webextension-fallback.html"; + +class WebExtensionTargetActor extends ParentProcessTargetActor { + /** + * Creates a target actor for debugging all the contexts associated to a target + * WebExtensions add-on running in a child extension process. Most of the implementation + * is inherited from ParentProcessTargetActor (which inherits most of its implementation + * from WindowGlobalTargetActor). + * + * WebExtensionTargetActor is created by a WebExtensionActor counterpart, when its + * parent actor's `connect` method has been called (on the listAddons RDP package), + * it runs in the same process that the extension is running into (which can be the main + * process if the extension is running in non-oop mode, or the child extension process + * if the extension is running in oop-mode). + * + * A WebExtensionTargetActor contains all target-scoped actors, like a regular + * ParentProcessTargetActor or WindowGlobalTargetActor. + * + * History lecture: + * - The add-on actors used to not inherit WindowGlobalTargetActor because of the + * different way the add-on APIs where exposed to the add-on itself, and for this reason + * the Addon Debugger has only a sub-set of the feature available in the Tab or in the + * Browser Toolbox. + * - In a WebExtensions add-on all the provided contexts (background, popups etc.), + * besides the Content Scripts which run in the content process, hooked to an existent + * tab, by creating a new WebExtensionActor which inherits from + * ParentProcessTargetActor, we can provide a full features Addon Toolbox (which is + * basically like a BrowserToolbox which filters the visible sources and frames to the + * one that are related to the target add-on). + * - When the WebExtensions OOP mode has been introduced, this actor has been refactored + * and moved from the main process to the new child extension process. + * + * @param {DevToolsServerConnection} conn + * The connection to the client. + * @param {nsIMessageSender} chromeGlobal. + * The chromeGlobal where this actor has been injected by the + * frame-connector.js connectToFrame method. + * @param {Object} options + * - addonId: {String} the addonId of the target WebExtension. + * - addonBrowsingContextGroupId: {String} the BrowsingContextGroupId used by this addon. + * - chromeGlobal: {nsIMessageSender} The chromeGlobal where this actor + * has been injected by the frame-connector.js connectToFrame method. + * - isTopLevelTarget: {Boolean} flag to indicate if this is the top + * level target of the DevTools session + * - prefix: {String} the custom RDP prefix to use. + * - sessionContext Object + * The Session Context to help know what is debugged. + * See devtools/server/actors/watcher/session-context.js + */ + constructor( + conn, + { + addonId, + addonBrowsingContextGroupId, + chromeGlobal, + isTopLevelTarget, + prefix, + sessionContext, + } + ) { + super(conn, { + isTopLevelTarget, + sessionContext, + customSpec: webExtensionTargetSpec, + }); + + this.addonId = addonId; + this.addonBrowsingContextGroupId = addonBrowsingContextGroupId; + this._chromeGlobal = chromeGlobal; + this._prefix = prefix; + + // Expose the BrowsingContext of the fallback document, + // which is the one this target actor will always refer to via its form() + // and all resources should be related to this one as we currently spawn + // only just this one target actor to debug all webextension documents. + this.devtoolsSpawnedBrowsingContextForWebExtension = + chromeGlobal.browsingContext; + + // Redefine the messageManager getter to return the chromeGlobal + // as the messageManager for this actor (which is the browser XUL + // element used by the parent actor running in the main process to + // connect to the extension process). + Object.defineProperty(this, "messageManager", { + enumerable: true, + configurable: true, + get: () => { + return this._chromeGlobal; + }, + }); + + this._onParentExit = this._onParentExit.bind(this); + + this._chromeGlobal.addMessageListener( + "debug:webext_parent_exit", + this._onParentExit + ); + + // Set the consoleAPIListener filtering options + // (retrieved and used in the related webconsole child actor). + this.consoleAPIListenerOptions = { + addonId: this.addonId, + }; + + // This creates a Debugger instance for debugging all the add-on globals. + this.makeDebugger = makeDebugger.bind(null, { + findDebuggees: dbg => { + return dbg + .findAllGlobals() + .filter(this._shouldAddNewGlobalAsDebuggee) + .map(g => g.unsafeDereference()); + }, + shouldAddNewGlobalAsDebuggee: + this._shouldAddNewGlobalAsDebuggee.bind(this), + }); + + // NOTE: This is needed to catch in the webextension webconsole all the + // errors raised by the WebExtension internals that are not currently + // associated with any window. + this.isRootActor = true; + + // Try to discovery an existent extension page to attach (which will provide the initial + // URL shown in the window tittle when the addon debugger is opened). + const extensionWindow = this._searchForExtensionWindow(); + this.setDocShell(extensionWindow.docShell); + } + + // Override the ParentProcessTargetActor's override in order to only iterate + // over the docshells specific to this add-on + get docShells() { + // Iterate over all top-level windows and all their docshells. + let docShells = []; + for (const window of Services.ww.getWindowEnumerator(null)) { + docShells = docShells.concat(getChildDocShells(window.docShell)); + } + // Then filter out the ones specific to the add-on + return docShells.filter(docShell => { + return this.isExtensionWindowDescendent(docShell.domWindow); + }); + } + + /** + * Called when the actor is removed from the connection. + */ + destroy() { + if (this._chromeGlobal) { + const chromeGlobal = this._chromeGlobal; + this._chromeGlobal = null; + + chromeGlobal.removeMessageListener( + "debug:webext_parent_exit", + this._onParentExit + ); + + chromeGlobal.sendAsyncMessage("debug:webext_child_exit", { + actor: this.actorID, + }); + } + + if (this.fallbackWindow) { + this.fallbackWindow = null; + } + + this.addon = null; + this.addonId = null; + + return super.destroy(); + } + + // Private helpers. + + _searchFallbackWindow() { + if (this.fallbackWindow) { + // Skip if there is already an existent fallback window. + return this.fallbackWindow; + } + + // Set and initialize the fallbackWindow (which initially is a empty + // about:blank browser), this window is related to a XUL browser element + // specifically created for the devtools server and it is never used + // or navigated anywhere else. + this.fallbackWindow = this._chromeGlobal.content; + + // Add the addonId in the URL to retrieve this information in other devtools + // helpers. The addonId is usually populated in the principal, but this will + // not be the case for the fallback window because it is loaded from chrome:// + // instead of moz-extension://${addonId} + this.fallbackWindow.document.location.href = `${FALLBACK_DOC_URL}#${this.addonId}`; + + return this.fallbackWindow; + } + + // Discovery an extension page to use as a default target window. + // NOTE: This currently fail to discovery an extension page running in a + // windowless browser when running in non-oop mode, and the background page + // is set later using _onNewExtensionWindow. + _searchForExtensionWindow() { + // Looks if there is any top level add-on document: + // (we do not want to pass any nested add-on iframe) + const docShell = this.docShells.find(d => + this.isTopLevelExtensionWindow(d.domWindow) + ); + if (docShell) { + return docShell.domWindow; + } + + return this._searchFallbackWindow(); + } + + // Customized ParentProcessTargetActor/WindowGlobalTargetActor hooks. + + _onDocShellCreated(docShell) { + // Compare against the BrowsingContext's group ID as the document's principal addonId + // won't be set yet for freshly created docshells. It will be later set, when loading the addon URL. + // But right now, it is still on the initial about:blank document and the principal isn't related to the add-on. + if (docShell.browsingContext.group.id != this.addonBrowsingContextGroupId) { + return; + } + super._onDocShellCreated(docShell); + } + + _onDocShellDestroy(docShell) { + if (docShell.browsingContext.group.id != this.addonBrowsingContextGroupId) { + return; + } + // Stop watching this docshell (the unwatch() method will check if we + // started watching it before). + this._unwatchDocShell(docShell); + + // Let the _onDocShellDestroy notify that the docShell has been destroyed. + const webProgress = docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + this._notifyDocShellDestroy(webProgress); + + // If the destroyed docShell: + // * was the current docShell, + // * the actor is not destroyed, + // * isn't the background page, as it means the addon is being shutdown or reloaded + // and the target would be replaced by a new one to come, or everything is closing. + // => switch to the fallback window + if ( + !this.isDestroyed() && + docShell == this.docShell && + !docShell.domWindow.location.href.includes( + "_generated_background_page.html" + ) + ) { + this._changeTopLevelDocument(this._searchForExtensionWindow()); + } + } + + _onNewExtensionWindow(window) { + if (!this.window || this.window === this.fallbackWindow) { + this._changeTopLevelDocument(window); + // For new extension windows, the BrowsingContext group id might have + // changed, for instance when reloading the addon. + this.addonBrowsingContextGroupId = + window.docShell.browsingContext.group.id; + } + } + + isTopLevelExtensionWindow(window) { + const { docShell } = window; + const isTopLevel = docShell.sameTypeRootTreeItem == docShell; + // Note: We are not using getAddonIdForWindowGlobal here because the + // fallback window should not be considered as a top level extension window. + return isTopLevel && window.document.nodePrincipal.addonId == this.addonId; + } + + isExtensionWindowDescendent(window) { + // Check if the source is coming from a descendant docShell of an extension window. + // We may have an iframe that loads http content which won't use the add-on principal. + const rootWin = window.docShell.sameTypeRootTreeItem.domWindow; + const addonId = lazy.getAddonIdForWindowGlobal(rootWin.windowGlobalChild); + return addonId == this.addonId; + } + + /** + * Return true if the given global is associated with this addon and should be + * added as a debuggee, false otherwise. + */ + _shouldAddNewGlobalAsDebuggee(newGlobal) { + const global = unwrapDebuggerObjectGlobal(newGlobal); + + if (global instanceof Ci.nsIDOMWindow) { + try { + global.document; + } catch (e) { + // The global might be a sandbox with a window object in its proto chain. If the + // window navigated away since the sandbox was created, it can throw a security + // exception during this property check as the sandbox no longer has access to + // its own proto. + return false; + } + // When `global` is a sandbox it may be a nsIDOMWindow object, + // but won't be the real Window object. Retrieve it via document's ownerGlobal. + const window = global.document.ownerGlobal; + if (!window) { + return false; + } + + // Change top level document as a simulated frame switching. + if (this.isTopLevelExtensionWindow(window)) { + this._onNewExtensionWindow(window); + } + + return this.isExtensionWindowDescendent(window); + } + + try { + // This will fail for non-Sandbox objects, hence the try-catch block. + const metadata = Cu.getSandboxMetadata(global); + if (metadata) { + return metadata.addonID === this.addonId; + } + } catch (e) { + // Unable to retrieve the sandbox metadata. + } + + return false; + } + + // Handlers for the messages received from the parent actor. + + _onParentExit(msg) { + if (msg.json.actor !== this.actorID) { + return; + } + + this.destroy(); + } +} + +exports.WebExtensionTargetActor = WebExtensionTargetActor; diff --git a/devtools/server/actors/targets/window-global.js b/devtools/server/actors/targets/window-global.js new file mode 100644 index 0000000000..5d2bb10164 --- /dev/null +++ b/devtools/server/actors/targets/window-global.js @@ -0,0 +1,1935 @@ +/* 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"; + +// protocol.js uses objects as exceptions in order to define +// error packets. +/* eslint-disable no-throw-literal */ + +/* + * WindowGlobalTargetActor is an abstract class used by target actors that hold + * documents, such as frames, chrome windows, etc. + * + * This class is extended by ParentProcessTargetActor, itself being extented by WebExtensionTargetActor. + * + * See devtools/docs/backend/actor-hierarchy.md for more details. + * + * For performance matters, this file should only be loaded in the targeted context's + * process. For example, it shouldn't be evaluated in the parent process until we try to + * debug a document living in the parent process. + */ + +var { + ActorRegistry, +} = require("resource://devtools/server/actors/utils/actor-registry.js"); +var DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); +var { assert } = DevToolsUtils; +var { + SourcesManager, +} = require("resource://devtools/server/actors/utils/sources-manager.js"); +var makeDebugger = require("resource://devtools/server/actors/utils/make-debugger.js"); +const Targets = require("resource://devtools/server/actors/targets/index.js"); +const { TargetActorRegistry } = ChromeUtils.importESModule( + "resource://devtools/server/actors/targets/target-actor-registry.sys.mjs", + { + loadInDevToolsLoader: false, + } +); +const { PrivateBrowsingUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PrivateBrowsingUtils.sys.mjs" +); + +const EXTENSION_CONTENT_SYS_MJS = + "resource://gre/modules/ExtensionContent.sys.mjs"; + +const { Pool } = require("resource://devtools/shared/protocol.js"); +const { + LazyPool, + createExtraActors, +} = require("resource://devtools/shared/protocol/lazy-pool.js"); +const { + windowGlobalTargetSpec, +} = require("resource://devtools/shared/specs/targets/window-global.js"); +const Resources = require("resource://devtools/server/actors/resources/index.js"); +const { + BaseTargetActor, +} = require("resource://devtools/server/actors/targets/base-target-actor.js"); + +loader.lazyRequireGetter( + this, + ["ThreadActor", "unwrapDebuggerObjectGlobal"], + "resource://devtools/server/actors/thread.js", + true +); +loader.lazyRequireGetter( + this, + "WorkerDescriptorActorList", + "resource://devtools/server/actors/worker/worker-descriptor-actor-list.js", + true +); +loader.lazyRequireGetter( + this, + "StyleSheetsManager", + "resource://devtools/server/actors/utils/stylesheets-manager.js", + true +); +const lazy = {}; +loader.lazyGetter(lazy, "ExtensionContent", () => { + return ChromeUtils.importESModule(EXTENSION_CONTENT_SYS_MJS, { + // ExtensionContent.sys.mjs is a singleton and must be loaded through the + // main loader. Note that the user of lazy.ExtensionContent elsewhere in + // this file (at webextensionsContentScriptGlobals) looks up the module + // via Cu.isESModuleLoaded, which also uses the main loader as desired. + loadInDevToolsLoader: false, + }).ExtensionContent; +}); + +loader.lazyRequireGetter( + this, + "TouchSimulator", + "resource://devtools/server/actors/emulation/touch-simulator.js", + true +); + +function getWindowID(window) { + return window.windowGlobalChild.innerWindowId; +} + +function getDocShellChromeEventHandler(docShell) { + let handler = docShell.chromeEventHandler; + if (!handler) { + try { + // Toplevel xul window's docshell doesn't have chromeEventHandler + // attribute. The chrome event handler is just the global window object. + handler = docShell.domWindow; + } catch (e) { + // ignore + } + } + return handler; +} + +/** + * Helper to retrieve all children docshells of a given docshell. + * + * Given that docshell interfaces can only be used within the same process, + * this only returns docshells for children documents that runs in the same process + * as the given docshell. + */ +function getChildDocShells(parentDocShell) { + return parentDocShell.browsingContext + .getAllBrowsingContextsInSubtree() + .filter(browsingContext => { + // Filter out browsingContext which don't expose any docshell (e.g. remote frame) + return browsingContext.docShell; + }) + .map(browsingContext => { + // Map BrowsingContext to DocShell + return browsingContext.docShell; + }); +} + +exports.getChildDocShells = getChildDocShells; + +/** + * Browser-specific actors. + */ + +function getInnerId(window) { + return window.windowGlobalChild.innerWindowId; +} + +class WindowGlobalTargetActor extends BaseTargetActor { + /** + * WindowGlobalTargetActor is the target actor to debug (HTML) documents. + * + * WindowGlobal's are the Gecko representation for a given document's window object. + * It relates to a given nsGlobalWindowInner instance. + * + * The main goal of this class is to expose the target-scoped actors being registered + * via `ActorRegistry.registerModule` and manage their lifetimes. In addition, this + * class also tracks the lifetime of the targeted window global. + * + * ### Main requests: + * + * `detach`: + * Stop document watching and cleanup everything that the target and its children actors created. + * It ultimately lead to destroy the target actor. + * `switchToFrame`: + * Change the targeted document of the whole actor, and its child target-scoped actors + * to an iframe or back to its original document. + * + * Most properties (like `chromeEventHandler` or `docShells`) are meant to be + * used by the various child target actors. + * + * ### RDP events: + * + * - `tabNavigated`: + * Sent when the window global is about to navigate or has just navigated + * to a different document. + * This event contains the following attributes: + * * url (string) + * The new URI being loaded. + * * state (string) + * `start` if we just start requesting the new URL + * `stop` if the new URL is done loading + * * isFrameSwitching (boolean) + * Indicates the event is dispatched when switching the actor context to a + * different frame. When we switch to an iframe, there is no document + * load. The targeted document is most likely going to be already done + * loading. + * * title (string) + * The document title being loaded. (sent only on state=stop) + * + * - `frameUpdate`: + * Sent when there was a change in the child frames contained in the document + * or when the actor's context was switched to another frame. + * This event can have four different forms depending on the type of change: + * * One or many frames are updated: + * { frames: [{ id, url, title, parentID }, ...] } + * * One frame got destroyed: + * { frames: [{ id, destroy: true }]} + * * All frames got destroyed: + * { destroyAll: true } + * * We switched the context of the actor to a specific frame: + * { selected: #id } + * + * ### Internal, non-rdp events: + * + * Various events are also dispatched on the actor itself without being sent to + * the client. They all relate to the documents tracked by this target actor + * (its main targeted document, but also any of its iframes): + * - will-navigate + * This event fires once navigation starts. All pending user prompts are + * dealt with, but it is fired before the first request starts. + * - navigate + * This event is fired once the document's readyState is "complete". + * - window-ready + * This event is fired in various distinct scenarios: + * * When a new Window object is crafted, equivalent of `DOMWindowCreated`. + * It is dispatched before any page script is executed. + * * We will have already received a window-ready event for this window + * when it was created, but we received a window-destroyed event when + * it was frozen into the bfcache, and now the user navigated back to + * this page, so it's now live again and we should resume handling it. + * * For each existing document, when an `attach` request is received. + * At this point scripts in the page will be already loaded. + * * When `swapFrameLoaders` is used, such as with moving window globals + * between windows or toggling Responsive Design Mode. + * - window-destroyed + * This event is fired in two cases: + * * When the window object is destroyed, i.e. when the related document + * is garbage collected. This can happen when the window global is + * closed or the iframe is removed from the DOM. + * It is equivalent of `inner-window-destroyed` event. + * * When the page goes into the bfcache and gets frozen. + * The equivalent of `pagehide`. + * - changed-toplevel-document + * This event fires when we switch the actor's targeted document + * to one of its iframes, or back to its original top document. + * It is dispatched between window-destroyed and window-ready. + * + * Note that *all* these events are dispatched in the following order + * when we switch the context of the actor to a given iframe: + * - will-navigate + * - window-destroyed + * - changed-toplevel-document + * - window-ready + * - navigate + * + * This class is subclassed by ParentProcessTargetActor and others. + * Subclasses are expected to implement a getter for the docShell property. + * + * @param conn DevToolsServerConnection + * The conection to the client. + * @param options Object + * Object with following attributes: + * - docShell nsIDocShell + * The |docShell| for the debugged frame. + * - followWindowGlobalLifeCycle Boolean + * If true, the target actor will only inspect the current WindowGlobal (and its children windows). + * But won't inspect next document loaded in the same BrowsingContext. + * The actor will behave more like a WindowGlobalTarget rather than a BrowsingContextTarget. + * This is always true for Tab debugging, but not yet for parent process/web extension. + * - isTopLevelTarget Boolean + * Should be set to true for all top-level targets. A top level target + * is the topmost target of a DevTools "session". For instance for a local + * tab toolbox, the WindowGlobalTargetActor for the content page is the top level target. + * For the Multiprocess Browser Toolbox, the parent process target is the top level + * target. + * At the moment this only impacts the WindowGlobalTarget `reconfigure` + * implementation. But for server-side target switching this flag will be exposed + * to the client and should be available for all target actor classes. It will be + * used to detect target switching. (Bug 1644397) + * - ignoreSubFrames Boolean + * If true, the actor will only focus on the passed docShell and not on the whole + * docShell tree. This should be enabled when we have targets for all documents. + * - sessionContext Object + * The Session Context to help know what is debugged. + * See devtools/server/actors/watcher/session-context.js + */ + constructor( + conn, + { + docShell, + followWindowGlobalLifeCycle, + isTopLevelTarget, + ignoreSubFrames, + sessionContext, + customSpec = windowGlobalTargetSpec, + } + ) { + super(conn, Targets.TYPES.FRAME, customSpec); + + this.followWindowGlobalLifeCycle = followWindowGlobalLifeCycle; + this.isTopLevelTarget = !!isTopLevelTarget; + this.ignoreSubFrames = ignoreSubFrames; + this.sessionContext = sessionContext; + + // A map of actor names to actor instances provided by extensions. + this._extraActors = {}; + this._sourcesManager = null; + + this._shouldAddNewGlobalAsDebuggee = + this._shouldAddNewGlobalAsDebuggee.bind(this); + + this.makeDebugger = makeDebugger.bind(null, { + findDebuggees: () => { + const result = []; + const inspectUAWidgets = Services.prefs.getBoolPref( + "devtools.inspector.showAllAnonymousContent", + false + ); + for (const win of this.windows) { + result.push(win); + // Only expose User Agent internal (like <video controls>) when the + // related pref is set. + if (inspectUAWidgets) { + const principal = win.document.nodePrincipal; + // We don't use UA widgets for the system principal. + if (!principal.isSystemPrincipal) { + result.push(Cu.getUAWidgetScope(principal)); + } + } + } + return result.concat(this.webextensionsContentScriptGlobals); + }, + shouldAddNewGlobalAsDebuggee: this._shouldAddNewGlobalAsDebuggee, + }); + + // Flag eventually overloaded by sub classes in order to watch new docshells + // Used by the ParentProcessTargetActor to list all frames in the Browser Toolbox + this.watchNewDocShells = false; + + this._workerDescriptorActorList = null; + this._workerDescriptorActorPool = null; + this._onWorkerDescriptorActorListChanged = + this._onWorkerDescriptorActorListChanged.bind(this); + + this._onConsoleApiProfilerEvent = + this._onConsoleApiProfilerEvent.bind(this); + Services.obs.addObserver( + this._onConsoleApiProfilerEvent, + "console-api-profiler" + ); + + // Start observing navigations as well as sub documents. + // (This is probably meant to disappear once EFT is the only supported codepath) + this._progressListener = new DebuggerProgressListener(this); + + TargetActorRegistry.registerTargetActor(this); + + if (docShell) { + this.setDocShell(docShell); + } + } + + /** + * Define the initial docshell. + * + * This is called from the constructor for WindowGlobalTargetActor, + * or from sub class constructors: WebExtensionTargetActor and ParentProcessTargetActor. + * + * This is to circumvent the fact that sub classes need to call inner method + * to compute the initial docshell and we can't call inner methods before calling + * the base class constructor... + */ + setDocShell(docShell) { + Object.defineProperty(this, "docShell", { + value: docShell, + configurable: true, + writable: true, + }); + + // Save references to the original document we attached to + this._originalWindow = this.window; + + // Update isPrivate as window is based on docShell + this.isPrivate = PrivateBrowsingUtils.isContentWindowPrivate(this.window); + + // Instantiate the Thread Actor immediately. + // This is the only one actor instantiated right away by the target actor. + // All the others are instantiated lazily on first request made the client, + // via LazyPool API. + this._createThreadActor(); + + // Ensure notifying about the target actor first + // before notifying about new docshells. + // Otherwise we would miss these RDP event as the client hasn't + // yet received the target actor's form. + // (This is also probably meant to disappear once EFT is the only supported codepath) + this._docShellsObserved = false; + DevToolsUtils.executeSoon(() => this._watchDocshells()); + } + + get docShell() { + throw new Error( + "A docShell should be provided as constructor argument of WindowGlobalTargetActor, or redefined by the subclass" + ); + } + + // Optional console API listener options (e.g. used by the WebExtensionActor to + // filter console messages by addonID), set to an empty (no options) object by default. + consoleAPIListenerOptions = {}; + + /* + * Return a Debugger instance or create one if there is none yet + */ + get dbg() { + if (!this._dbg) { + this._dbg = this.makeDebugger(); + } + return this._dbg; + } + + /** + * Try to locate the console actor if it exists. + */ + get _consoleActor() { + if (this.isDestroyed()) { + return null; + } + const form = this.form(); + return this.conn._getOrCreateActor(form.consoleActor); + } + + get _memoryActor() { + if (this.isDestroyed()) { + return null; + } + const form = this.form(); + return this.conn._getOrCreateActor(form.memoryActor); + } + + _targetScopedActorPool = null; + + /** + * An object on which listen for DOMWindowCreated and pageshow events. + */ + get chromeEventHandler() { + return getDocShellChromeEventHandler(this.docShell); + } + + /** + * Getter for the nsIMessageManager associated to the window global. + */ + get messageManager() { + try { + return this.docShell.messageManager; + } catch (e) { + // In some cases we can't get a docshell. We just have no message manager + // then, + return null; + } + } + + /** + * Getter for the list of all `docShell`s in the window global. + * @return {Array} + */ + get docShells() { + if (this.ignoreSubFrames) { + return [this.docShell]; + } + + return getChildDocShells(this.docShell); + } + + /** + * Getter for the window global's current DOM window. + */ + get window() { + return this.docShell && !this.docShell.isBeingDestroyed() + ? this.docShell.domWindow + : null; + } + + get outerWindowID() { + if (this.docShell) { + return this.docShell.outerWindowID; + } + return null; + } + + get browsingContext() { + return this.docShell?.browsingContext; + } + + get browsingContextID() { + return this.browsingContext?.id; + } + + get browserId() { + return this.browsingContext?.browserId; + } + + get openerBrowserId() { + return this.browsingContext?.opener?.browserId; + } + + /** + * Getter for the WebExtensions ContentScript globals related to the + * window global's current DOM window. + */ + get webextensionsContentScriptGlobals() { + // Only retrieve the content scripts globals if the ExtensionContent JSM module + // has been already loaded (which is true if the WebExtensions internals have already + // been loaded in the same content process). + if (Cu.isESModuleLoaded(EXTENSION_CONTENT_SYS_MJS)) { + return lazy.ExtensionContent.getContentScriptGlobals(this.window); + } + + return []; + } + + /** + * Getter for the list of all content DOM windows in the window global. + * @return {Array} + */ + get windows() { + return this.docShells.map(docShell => { + return docShell.domWindow; + }); + } + + /** + * Getter for the original docShell this actor got attached to in the first + * place. + * Note that your actor should normally *not* rely on this top level docShell + * if you want it to show information relative to the iframe that's currently + * being inspected in the toolbox. + */ + get originalDocShell() { + if (!this._originalWindow) { + return this.docShell; + } + + return this._originalWindow.docShell; + } + + /** + * Getter for the original window this actor got attached to in the first + * place. + * Note that your actor should normally *not* rely on this top level window if + * you want it to show information relative to the iframe that's currently + * being inspected in the toolbox. + */ + get originalWindow() { + return this._originalWindow || this.window; + } + + /** + * Getter for the nsIWebProgress for watching this window. + */ + get webProgress() { + return this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + } + + /** + * Getter for the nsIWebNavigation for the target. + */ + get webNavigation() { + return this.docShell.QueryInterface(Ci.nsIWebNavigation); + } + + /** + * Getter for the window global's document. + */ + get contentDocument() { + return this.webNavigation.document; + } + + /** + * Getter for the window global's title. + */ + get title() { + return this.contentDocument.title; + } + + /** + * Getter for the window global's URL. + */ + get url() { + if (this.webNavigation.currentURI) { + return this.webNavigation.currentURI.spec; + } + // Abrupt closing of the browser window may leave callbacks without a + // currentURI. + return null; + } + + get sourcesManager() { + if (!this._sourcesManager) { + this._sourcesManager = new SourcesManager(this.threadActor); + } + return this._sourcesManager; + } + + getStyleSheetsManager() { + if (!this._styleSheetsManager) { + this._styleSheetsManager = new StyleSheetsManager(this); + } + return this._styleSheetsManager; + } + + _createExtraActors() { + // Always use the same Pool, so existing actor instances + // (created in createExtraActors) are not lost. + if (!this._targetScopedActorPool) { + this._targetScopedActorPool = new LazyPool(this.conn); + } + + // Walk over target-scoped actor factories and make sure they are all + // instantiated and added into the Pool. + return createExtraActors( + ActorRegistry.targetScopedActorFactories, + this._targetScopedActorPool, + this + ); + } + + form() { + assert( + !this.isDestroyed(), + "form() shouldn't be called on destroyed browser actor." + ); + assert(this.actorID, "Actor should have an actorID."); + + // Note that we don't want the iframe dropdown to change our BrowsingContext.id/innerWindowId + // We only want to refer to the topmost original window we attached to + // as that's the one top document this target actor really represent. + // The iframe dropdown is just a hack that temporarily focus the scope + // of the target actor to a children iframe document. + // + // Also, for WebExtension, we want the target to represent the <browser> element + // created by DevTools, which always exists and help better connect resources to the target + // in the frontend. Otherwise all other <browser> element of webext may be reloaded or go away + // and then we would have troubles matching targets for resources. + const originalBrowsingContext = this + .devtoolsSpawnedBrowsingContextForWebExtension + ? this.devtoolsSpawnedBrowsingContextForWebExtension + : this.originalDocShell.browsingContext; + const browsingContextID = originalBrowsingContext.id; + const innerWindowId = + originalBrowsingContext.currentWindowContext.innerWindowId; + const parentInnerWindowId = + originalBrowsingContext.parent?.currentWindowContext.innerWindowId; + // Doesn't only check `!!opener` as some iframe might have an opener + // if their location was loaded via `window.open(url, "iframe-name")`. + // So also ensure that the document is opened in a distinct tab. + const isPopup = + !!originalBrowsingContext.opener && + originalBrowsingContext.browserId != + originalBrowsingContext.opener.browserId; + + const response = { + actor: this.actorID, + browsingContextID, + processID: Services.appinfo.processID, + // True for targets created by JSWindowActors, see constructor JSDoc. + followWindowGlobalLifeCycle: this.followWindowGlobalLifeCycle, + innerWindowId, + parentInnerWindowId, + topInnerWindowId: this.browsingContext.topWindowContext.innerWindowId, + isTopLevelTarget: this.isTopLevelTarget, + ignoreSubFrames: this.ignoreSubFrames, + isPopup, + isPrivate: this.isPrivate, + traits: { + // @backward-compat { version 64 } Exposes a new trait to help identify + // BrowsingContextActor's inherited actors from the client side. + isBrowsingContext: true, + // Browsing context targets can compute the isTopLevelTarget flag on the + // server. But other target actors don't support this yet. See Bug 1709314. + supportsTopLevelTargetFlag: true, + // Supports frame listing via `listFrames` request and `frameUpdate` events + // as well as frame switching via `switchToFrame` request + frames: true, + // Supports the logInPage request. + logInPage: true, + // Supports watchpoints in the server. We need to keep this trait because target + // actors that don't extend WindowGlobalTargetActor (Worker, ContentProcess, …) + // might not support watchpoints. + watchpoints: true, + // Supports back and forward navigation + navigation: true, + }, + }; + + // We may try to access window while the document is closing, then accessing window + // throws. + if (!this.docShell.isBeingDestroyed()) { + response.title = this.title; + response.url = this.url; + response.outerWindowID = this.outerWindowID; + } + + const actors = this._createExtraActors(); + Object.assign(response, actors); + + // The thread actor is the only actor manually created by the target actor. + // It is not registered in targetScopedActorFactories and therefore needs + // to be added here manually. + if (this.threadActor) { + Object.assign(response, { + threadActor: this.threadActor.actorID, + }); + } + + return response; + } + + /** + * Called when the actor is removed from the connection. + * + * @params {Object} options + * @params {Boolean} options.isTargetSwitching: Set to true when this is called during + * a target switch. + * @params {Boolean} options.isModeSwitching: Set to true true when this is called as the + * result of a change to the devtools.browsertoolbox.scope pref. + */ + destroy({ isTargetSwitching = false, isModeSwitching = false } = {}) { + // Avoid reentrancy. We will destroy the Transport when emitting "destroyed", + // which will force destroying all actors. + if (this.destroying) { + return; + } + this.destroying = true; + + // Tell the thread actor that the window global is closed, so that it may terminate + // instead of resuming the debuggee script. + // TODO: Bug 997119: Remove this coupling with thread actor + if (this.threadActor) { + this.threadActor._parentClosed = true; + } + + if (this._touchSimulator) { + this._touchSimulator.stop(); + this._touchSimulator = null; + } + + // Check for `docShell` availability, as it can be already gone during + // Firefox shutdown. + if (this.docShell) { + this._unwatchDocShell(this.docShell); + + // If this target is being destroyed as part of a target switch or a mode switch, + // we don't need to restore the configuration (this might cause the content page to + // be focused again, causing issues in tests and disturbing the user when switching modes). + if (!isTargetSwitching && !isModeSwitching) { + this._restoreTargetConfiguration(); + } + } + this._unwatchDocshells(); + + this._destroyThreadActor(); + + if (this._styleSheetsManager) { + this._styleSheetsManager.destroy(); + this._styleSheetsManager = null; + } + + // Shut down actors that belong to this target's pool. + if (this._targetScopedActorPool) { + this._targetScopedActorPool.destroy(); + this._targetScopedActorPool = null; + } + + // Make sure that no more workerListChanged notifications are sent. + if (this._workerDescriptorActorList !== null) { + this._workerDescriptorActorList.destroy(); + this._workerDescriptorActorList = null; + } + + if (this._workerDescriptorActorPool !== null) { + this._workerDescriptorActorPool.destroy(); + this._workerDescriptorActorPool = null; + } + + if (this._dbg) { + this._dbg.disable(); + this._dbg = null; + } + + // Emit a last event before calling Actor.destroy + // which will destroy the EventEmitter API + this.emit("destroyed", { isTargetSwitching, isModeSwitching }); + + // Destroy BaseTargetActor before nullifying docShell in case any child actor queries the window/docShell. + super.destroy(); + + this.docShell = null; + this._extraActors = null; + + Services.obs.removeObserver( + this._onConsoleApiProfilerEvent, + "console-api-profiler" + ); + + TargetActorRegistry.unregisterTargetActor(this); + Resources.unwatchAllResources(this); + } + + /** + * Return true if the given global is associated with this window global and should + * be added as a debuggee, false otherwise. + */ + _shouldAddNewGlobalAsDebuggee(wrappedGlobal) { + // Otherwise, check if it is a WebExtension content script sandbox + const global = unwrapDebuggerObjectGlobal(wrappedGlobal); + if (!global) { + return false; + } + + // Check if the global is a sdk page-mod sandbox. + let metadata = {}; + let id = ""; + try { + id = getInnerId(this.window); + metadata = Cu.getSandboxMetadata(global); + } catch (e) { + // ignore + } + if (metadata?.["inner-window-id"] && metadata["inner-window-id"] == id) { + return true; + } + + return false; + } + + _watchDocshells() { + // If for some unexpected reason, the actor is immediately destroyed, + // avoid registering leaking observer listener. + if (this.isDestroyed()) { + return; + } + + // In child processes, we watch all docshells living in the process. + Services.obs.addObserver(this, "webnavigation-create"); + Services.obs.addObserver(this, "webnavigation-destroy"); + this._docShellsObserved = true; + + // We watch for all child docshells under the current document, + this._progressListener.watch(this.docShell); + + // And list all already existing ones. + this._updateChildDocShells(); + } + + _unwatchDocshells() { + if (this._progressListener) { + this._progressListener.destroy(); + this._progressListener = null; + this._originalWindow = null; + } + + // Removes the observers being set in _watchDocshells, but only + // if _watchDocshells has been called. The target actor may be immediately destroyed + // and doesn't have time to register them. + // (Calling removeObserver without having called addObserver throws) + if (this._docShellsObserved) { + Services.obs.removeObserver(this, "webnavigation-create"); + Services.obs.removeObserver(this, "webnavigation-destroy"); + this._docShellsObserved = false; + } + } + + _unwatchDocShell(docShell) { + if (this._progressListener) { + this._progressListener.unwatch(docShell); + } + } + + switchToFrame(request) { + const windowId = request.windowId; + let win; + + try { + win = Services.wm.getOuterWindowWithId(windowId); + } catch (e) { + // ignore + } + if (!win) { + throw { + error: "noWindow", + message: "The related docshell is destroyed or not found", + }; + } else if (win == this.window) { + return {}; + } + + // Reply first before changing the document + DevToolsUtils.executeSoon(() => this._changeTopLevelDocument(win)); + + return {}; + } + + listFrames(request) { + const windows = this._docShellsToWindows(this.docShells); + return { frames: windows }; + } + + ensureWorkerDescriptorActorList() { + if (this._workerDescriptorActorList === null) { + this._workerDescriptorActorList = new WorkerDescriptorActorList( + this.conn, + { + type: Ci.nsIWorkerDebugger.TYPE_DEDICATED, + window: this.window, + } + ); + } + return this._workerDescriptorActorList; + } + + pauseWorkersUntilAttach(shouldPause) { + this.ensureWorkerDescriptorActorList().workerPauser.setPauseMatching( + shouldPause + ); + } + + listWorkers(request) { + return this.ensureWorkerDescriptorActorList() + .getList() + .then(actors => { + const pool = new Pool(this.conn, "worker-targets"); + for (const actor of actors) { + pool.manage(actor); + } + + // Do not destroy the pool before transfering ownership to the newly created + // pool, so that we do not accidently destroy actors that are still in use. + if (this._workerDescriptorActorPool) { + this._workerDescriptorActorPool.destroy(); + } + + this._workerDescriptorActorPool = pool; + this._workerDescriptorActorList.onListChanged = + this._onWorkerDescriptorActorListChanged; + + return { + workers: actors, + }; + }); + } + + logInPage(request) { + const { text, category, flags } = request; + const scriptErrorClass = Cc["@mozilla.org/scripterror;1"]; + const scriptError = scriptErrorClass.createInstance(Ci.nsIScriptError); + scriptError.initWithWindowID( + text, + null, + null, + 0, + 0, + flags, + category, + getInnerId(this.window) + ); + Services.console.logMessage(scriptError); + return {}; + } + + _onWorkerDescriptorActorListChanged() { + this._workerDescriptorActorList.onListChanged = null; + this.emit("workerListChanged"); + } + + _onConsoleApiProfilerEvent(subject, topic, data) { + // TODO: We will receive console-api-profiler events for any browser running + // in the same process as this target. We should filter irrelevant events, + // but console-api-profiler currently doesn't emit any information to identify + // the origin of the event. See Bug 1731033. + + // The new performance panel is not compatible with console.profile(). + const warningFlag = 1; + this.logInPage({ + text: + "console.profile is not compatible with the new Performance recorder. " + + "See https://bugzilla.mozilla.org/show_bug.cgi?id=1730896", + category: "console.profile unavailable", + flags: warningFlag, + }); + } + + observe(subject, topic, data) { + // Ignore any event that comes before/after the actor is attached. + // That typically happens during Firefox shutdown. + if (this.isDestroyed()) { + return; + } + + subject.QueryInterface(Ci.nsIDocShell); + + if (topic == "webnavigation-create") { + this._onDocShellCreated(subject); + } else if (topic == "webnavigation-destroy") { + this._onDocShellDestroy(subject); + } + } + + _onDocShellCreated(docShell) { + // (chrome-)webnavigation-create is fired very early during docshell + // construction. In new root docshells within child processes, involving + // BrowserChild, this event is from within this call: + // https://hg.mozilla.org/mozilla-central/annotate/74d7fb43bb44/dom/ipc/TabChild.cpp#l912 + // whereas the chromeEventHandler (and most likely other stuff) is set + // later: + // https://hg.mozilla.org/mozilla-central/annotate/74d7fb43bb44/dom/ipc/TabChild.cpp#l944 + // So wait a tick before watching it: + DevToolsUtils.executeSoon(() => { + // Bug 1142752: sometimes, the docshell appears to be immediately + // destroyed, bailout early to prevent random exceptions. + if (docShell.isBeingDestroyed()) { + return; + } + + // In child processes, we have new root docshells, + // let's watch them and all their child docshells. + if (this._isRootDocShell(docShell) && this.watchNewDocShells) { + this._progressListener.watch(docShell); + } + this._notifyDocShellsUpdate([docShell]); + }); + } + + _onDocShellDestroy(docShell) { + // Stop watching this docshell (the unwatch() method will check if we + // started watching it before). + this._unwatchDocShell(docShell); + + const webProgress = docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + this._notifyDocShellDestroy(webProgress); + + if (webProgress.DOMWindow == this._originalWindow) { + // If the original top level document we connected to is removed, + // we try to switch to any other top level document + const rootDocShells = this.docShells.filter(d => { + // Ignore docshells without a working DOM Window. + // When we close firefox we have a chrome://extensions/content/dummy.xhtml + // which is in process of being destroyed and we might try to fallback to it. + // Unfortunately docshell.isBeingDestroyed() doesn't return true... + return d != this.docShell && this._isRootDocShell(d) && d.DOMWindow; + }); + if (rootDocShells.length) { + const newRoot = rootDocShells[0]; + this._originalWindow = newRoot.DOMWindow; + this._changeTopLevelDocument(this._originalWindow); + } else { + // If for some reason (typically during Firefox shutdown), the original + // document is destroyed, and there is no other top level docshell, + // we detach the actor to unregister all listeners and prevent any + // exception. + this.destroy(); + } + return; + } + + // If the currently targeted window global is destroyed, and we aren't on + // the top-level document, we have to switch to the top-level one. + if ( + webProgress.DOMWindow == this.window && + this.window != this._originalWindow + ) { + this._changeTopLevelDocument(this._originalWindow); + } + } + + _isRootDocShell(docShell) { + // Should report as root docshell: + // - New top level window's docshells, when using ParentProcessTargetActor against a + // process. It allows tracking iframes of the newly opened windows + // like Browser console or new browser windows. + // - MozActivities or window.open frames on B2G, where a new root docshell + // is spawn in the child process of the app. + return !docShell.parent; + } + + _docShellToWindow(docShell) { + const webProgress = docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + const window = webProgress.DOMWindow; + const id = docShell.outerWindowID; + let parentID = undefined; + // Ignore the parent of the original document on non-e10s firefox, + // as we get the xul window as parent and don't care about it. + // Furthermore, ignore setting parentID when parent window is same as + // current window in order to deal with front end. e.g. toolbox will be fall + // into infinite loop due to recursive search with by using parent id. + if ( + window.parent && + window.parent != window && + window != this._originalWindow + ) { + parentID = window.parent.docShell.outerWindowID; + } + + return { + id, + parentID, + isTopLevel: window == this.originalWindow && this.isTopLevelTarget, + url: window.location.href, + title: window.document.title, + }; + } + + // Convert docShell list to windows objects list being sent to the client + _docShellsToWindows(docshells) { + return docshells + .filter(docShell => { + // Ensure docShell.document is available. + docShell.QueryInterface(Ci.nsIWebNavigation); + + // don't include transient about:blank documents + if (docShell.document.isInitialDocument) { + return false; + } + + return true; + }) + .map(docShell => this._docShellToWindow(docShell)); + } + + _notifyDocShellsUpdate(docshells) { + // Only top level target uses frameUpdate in order to update the iframe dropdown. + // This may eventually be replaced by Target listening and target switching. + if (!this.isTopLevelTarget) { + return; + } + + const windows = this._docShellsToWindows(docshells); + + // Do not send the `frameUpdate` event if the windows array is empty. + if (!windows.length) { + return; + } + + this.emit("frameUpdate", { + frames: windows, + }); + } + + _updateChildDocShells() { + this._notifyDocShellsUpdate(this.docShells); + } + + _notifyDocShellDestroy(webProgress) { + // Only top level target uses frameUpdate in order to update the iframe dropdown. + // This may eventually be replaced by Target listening and target switching. + if (!this.isTopLevelTarget) { + return; + } + + webProgress = webProgress.QueryInterface(Ci.nsIWebProgress); + const id = webProgress.DOMWindow.docShell.outerWindowID; + this.emit("frameUpdate", { + frames: [ + { + id, + destroy: true, + }, + ], + }); + } + + /** + * Creates and manages the thread actor as part of the Browsing Context Target pool. + * This sets up the content window for being debugged + */ + _createThreadActor() { + this.threadActor = new ThreadActor(this, this.window); + this.manage(this.threadActor); + } + + /** + * Exits the current thread actor and removes it from the Browsing Context Target pool. + * The content window is no longer being debugged after this call. + */ + _destroyThreadActor() { + if (this.threadActor) { + this.threadActor.destroy(); + this.threadActor = null; + } + + if (this._sourcesManager) { + this._sourcesManager.destroy(); + this._sourcesManager = null; + } + } + + // Protocol Request Handlers + + detach(request) { + // Destroy the actor in the next event loop in order + // to ensure responding to the `detach` request. + DevToolsUtils.executeSoon(() => { + this.destroy(); + }); + + return {}; + } + + /** + * Bring the window global's window to front. + */ + focus() { + if (this.window) { + this.window.focus(); + } + return {}; + } + + goForward() { + // Wait a tick so that the response packet can be dispatched before the + // subsequent navigation event packet. + Services.tm.dispatchToMainThread( + DevToolsUtils.makeInfallible(() => { + // This won't work while the browser is shutting down and we don't really + // care. + if (Services.startup.shuttingDown) { + return; + } + + this.webNavigation.goForward(); + }, "WindowGlobalTargetActor.prototype.goForward's delayed body") + ); + + return {}; + } + + goBack() { + // Wait a tick so that the response packet can be dispatched before the + // subsequent navigation event packet. + Services.tm.dispatchToMainThread( + DevToolsUtils.makeInfallible(() => { + // This won't work while the browser is shutting down and we don't really + // care. + if (Services.startup.shuttingDown) { + return; + } + + this.webNavigation.goBack(); + }, "WindowGlobalTargetActor.prototype.goBack's delayed body") + ); + + return {}; + } + + /** + * Reload the page in this window global. + * + * @backward-compat { legacy } + * reload is preserved for third party tools. See Bug 1717837. + * DevTools should use Descriptor::reloadDescriptor instead. + */ + reload(request) { + const force = request?.options?.force; + // Wait a tick so that the response packet can be dispatched before the + // subsequent navigation event packet. + Services.tm.dispatchToMainThread( + DevToolsUtils.makeInfallible(() => { + // This won't work while the browser is shutting down and we don't really + // care. + if (Services.startup.shuttingDown) { + return; + } + + this.webNavigation.reload( + force + ? Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE + : Ci.nsIWebNavigation.LOAD_FLAGS_NONE + ); + }, "WindowGlobalTargetActor.prototype.reload's delayed body") + ); + return {}; + } + + /** + * Navigate this window global to a new location + */ + navigateTo(request) { + // Wait a tick so that the response packet can be dispatched before the + // subsequent navigation event packet. + Services.tm.dispatchToMainThread( + DevToolsUtils.makeInfallible(() => { + this.window.location = request.url; + }, "WindowGlobalTargetActor.prototype.navigateTo's delayed body:" + request.url) + ); + return {}; + } + + /** + * For browsing-context targets which can't use the watcher configuration + * actor (eg webextension targets), the client directly calls `reconfigure`. + * Once all targets support the watcher, this method can be removed. + */ + reconfigure(request) { + const options = request.options || {}; + return this.updateTargetConfiguration(options); + } + + /** + * Apply target-specific options. + * + * This will be called by the watcher when the DevTools target-configuration + * is updated, or when a target is created via JSWindowActors. + */ + updateTargetConfiguration(options = {}, calledFromDocumentCreation = false) { + if (!this.docShell) { + // The window global is already closed. + return; + } + + // Also update configurations which applies to all target types + super.updateTargetConfiguration(options, calledFromDocumentCreation); + + let reload = false; + if (typeof options.touchEventsOverride !== "undefined") { + const enableTouchSimulator = options.touchEventsOverride === "enabled"; + + this.docShell.metaViewportOverride = enableTouchSimulator + ? Ci.nsIDocShell.META_VIEWPORT_OVERRIDE_ENABLED + : Ci.nsIDocShell.META_VIEWPORT_OVERRIDE_NONE; + + // We want to reload the document if it's an "existing" top level target on which + // the touch simulator will be toggled and the user has turned the + // "reload on touch simulation" setting on. + if ( + enableTouchSimulator !== this.touchSimulator.enabled && + options.reloadOnTouchSimulationToggle === true && + this.isTopLevelTarget && + !calledFromDocumentCreation + ) { + reload = true; + } + + if (enableTouchSimulator) { + this.touchSimulator.start(); + } else { + this.touchSimulator.stop(); + } + } + + if (typeof options.customFormatters !== "undefined") { + this.customFormatters = options.customFormatters; + } + + if (typeof options.useSimpleHighlightersForReducedMotion == "boolean") { + this._useSimpleHighlightersForReducedMotion = + options.useSimpleHighlightersForReducedMotion; + this.emit("use-simple-highlighters-updated"); + } + + if (!this.isTopLevelTarget) { + // Following DevTools target options should only apply to the top target and be + // propagated through the window global tree via the platform. + return; + } + if (typeof options.restoreFocus == "boolean") { + this._restoreFocus = options.restoreFocus; + } + if (typeof options.recordAllocations == "object") { + const actor = this._memoryActor; + if (options.recordAllocations == null) { + actor.stopRecordingAllocations(); + } else { + actor.attach(); + actor.startRecordingAllocations(options.recordAllocations); + } + } + + if (reload) { + this.webNavigation.reload(Ci.nsIWebNavigation.LOAD_FLAGS_NONE); + } + } + + get touchSimulator() { + if (!this._touchSimulator) { + this._touchSimulator = new TouchSimulator(this.chromeEventHandler); + } + + return this._touchSimulator; + } + + /** + * Opposite of the updateTargetConfiguration method, that resets document + * state when closing the toolbox. + */ + _restoreTargetConfiguration() { + if (this._restoreFocus && this.browsingContext?.isActive) { + this.window.focus(); + } + } + + _changeTopLevelDocument(window) { + // In case of WebExtension, still using one WindowGlobalTarget instance for many document, + // when reloading the add-on we might not destroy the previous target and wait for the next + // one to come and destroy it. + if (this.window) { + // Fake a will-navigate on the previous document + // to let a chance to unregister it + this._willNavigate({ + window: this.window, + newURI: window.location.href, + request: null, + isFrameSwitching: true, + navigationStart: Date.now(), + }); + + this._windowDestroyed(this.window, { + isFrozen: true, + isFrameSwitching: true, + }); + } + + // Immediately change the window as this window, if in process of unload + // may already be non working on the next cycle and start throwing + this._setWindow(window); + + DevToolsUtils.executeSoon(() => { + // No need to do anything more if the actor is destroyed. + // e.g. the client has been closed and the actors destroyed in the meantime. + if (this.isDestroyed()) { + return; + } + + // Then fake window-ready and navigate on the given document + this._windowReady(window, { isFrameSwitching: true }); + DevToolsUtils.executeSoon(() => { + this._navigate(window, true); + }); + }); + } + + _setWindow(window) { + // Here is the very important call where we switch the currently targeted + // window global (it will indirectly update this.window and many other + // attributes defined from docShell). + this.docShell = window.docShell; + this.emit("changed-toplevel-document"); + this.emit("frameUpdate", { + selected: this.outerWindowID, + }); + } + + /** + * Handle location changes, by clearing the previous debuggees and enabling + * debugging, which may have been disabled temporarily by the + * DebuggerProgressListener. + */ + _windowReady(window, { isFrameSwitching, isBFCache } = {}) { + if (this.ignoreSubFrames) { + return; + } + const isTopLevel = window == this.window; + + // We just reset iframe list on WillNavigate, so we now list all existing + // frames when we load a new document in the original window + if (window == this._originalWindow && !isFrameSwitching) { + this._updateChildDocShells(); + } + + // If this follows WindowGlobal lifecycle, a new Target actor will be spawn for the top level + // target document. Only notify about in-process iframes. + // Note that OOP iframes won't emit window-ready and will also have their dedicated target. + // Also, we allow window-ready to be fired for iframe switching of top level documents, + // otherwise the iframe dropdown no longer works with server side targets. + if (this.followWindowGlobalLifeCycle && isTopLevel && !isFrameSwitching) { + return; + } + + this.emit("window-ready", { + window, + isTopLevel, + isBFCache, + id: getWindowID(window), + isFrameSwitching, + }); + } + + _windowDestroyed( + window, + { id = null, isFrozen = false, isFrameSwitching = false } + ) { + if (this.ignoreSubFrames) { + return; + } + const isTopLevel = window == this.window; + + // If this follows WindowGlobal lifecycle, this target will be destroyed, alongside its top level document. + // Only notify about in-process iframes. + // Note that OOP iframes won't emit window-ready and will also have their dedicated target. + // Also, we allow window-destroyed to be fired for iframe switching of top level documents, + // otherwise the iframe dropdown no longer works with server side targets. + if (this.followWindowGlobalLifeCycle && isTopLevel && !isFrameSwitching) { + return; + } + + this.emit("window-destroyed", { + window, + isTopLevel, + id: id || getWindowID(window), + isFrozen, + }); + } + + /** + * Start notifying server and client about a new document being loaded in the + * currently targeted window global. + */ + _willNavigate({ + window, + newURI, + request, + isFrameSwitching = false, + navigationStart, + }) { + if (this.ignoreSubFrames) { + return; + } + let isTopLevel = window == this.window; + + let reset = false; + if (window == this._originalWindow && !isFrameSwitching) { + // If the top level document changes and we are targeting an iframe, we + // need to reset to the upcoming new top level document. But for this + // will-navigate event, we will dispatch on the old window. (The inspector + // codebase expect to receive will-navigate for the currently displayed + // document in order to cleanup the markup view) + if (this.window != this._originalWindow) { + reset = true; + window = this.window; + isTopLevel = true; + } + } + + // will-navigate event needs to be dispatched synchronously, by calling the + // listeners in the order or registration. This event fires once navigation + // starts, (all pending user prompts are dealt with), but before the first + // request starts. + this.emit("will-navigate", { + window, + isTopLevel, + newURI, + request, + navigationStart, + isFrameSwitching, + }); + + // We don't do anything for inner frames here. + // (we will only update thread actor on window-ready) + if (!isTopLevel) { + return; + } + + // When the actor acts as a WindowGlobalTarget, will-navigate won't fired. + // Instead we will receive a new top level target with isTargetSwitching=true. + if (!this.followWindowGlobalLifeCycle) { + this.emit("tabNavigated", { + url: newURI, + state: "start", + isFrameSwitching, + }); + } + + if (reset) { + this._setWindow(this._originalWindow); + } + } + + /** + * Notify server and client about a new document done loading in the current + * targeted window global. + */ + _navigate(window, isFrameSwitching = false) { + if (this.ignoreSubFrames) { + return; + } + const isTopLevel = window == this.window; + + // navigate event needs to be dispatched synchronously, + // by calling the listeners in the order or registration. + // This event is fired once the document is loaded, + // after the load event, it's document ready-state is 'complete'. + this.emit("navigate", { + window, + isTopLevel, + }); + + // We don't do anything for inner frames here. + // (we will only update thread actor on window-ready) + if (!isTopLevel) { + return; + } + + // We may still significate when the document is done loading, via navigate. + // But as we no longer fire the "will-navigate", may be it is better to find + // other ways to get to our means. + // Listening to "navigate" is misleading as the document may already be loaded + // if we just opened the DevTools. So it is better to use "watch" pattern + // and instead have the actor either emit immediately resources as they are + // already available, or later on as the load progresses. + if (this.followWindowGlobalLifeCycle) { + return; + } + + this.emit("tabNavigated", { + url: this.url, + title: this.title, + state: "stop", + isFrameSwitching, + }); + } + + removeActorByName(name) { + if (name in this._extraActors) { + const actor = this._extraActors[name]; + if (this._targetScopedActorPool.has(actor)) { + this._targetScopedActorPool.removeActor(actor); + } + delete this._extraActors[name]; + } + } +} + +exports.WindowGlobalTargetActor = WindowGlobalTargetActor; + +class DebuggerProgressListener { + /** + * The DebuggerProgressListener class is an nsIWebProgressListener which + * handles onStateChange events for the targeted window global. If the user + * tries to navigate away from a paused page, the listener makes sure that the + * debuggee is resumed before the navigation begins. + * + * @param WindowGlobalTargetActor targetActor + * The window global target actor associated with this listener. + */ + constructor(targetActor) { + this._targetActor = targetActor; + this._onWindowCreated = this.onWindowCreated.bind(this); + this._onWindowHidden = this.onWindowHidden.bind(this); + + // Watch for windows destroyed (global observer that will need filtering) + Services.obs.addObserver(this, "inner-window-destroyed"); + + // XXX: for now we maintain the list of windows we know about in this instance + // so that we can discriminate windows we care about when observing + // inner-window-destroyed events. Bug 1016952 would remove the need for this. + this._knownWindowIDs = new Map(); + + this._watchedDocShells = new WeakSet(); + } + + QueryInterface = ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]); + + destroy() { + Services.obs.removeObserver(this, "inner-window-destroyed"); + this._knownWindowIDs.clear(); + this._knownWindowIDs = null; + } + + watch(docShell) { + // Add the docshell to the watched set. We're actually adding the window, + // because docShell objects are not wrappercached and would be rejected + // by the WeakSet. + const docShellWindow = docShell.domWindow; + this._watchedDocShells.add(docShellWindow); + + const webProgress = docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW | + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT + ); + + const handler = getDocShellChromeEventHandler(docShell); + handler.addEventListener("DOMWindowCreated", this._onWindowCreated, true); + handler.addEventListener("pageshow", this._onWindowCreated, true); + handler.addEventListener("pagehide", this._onWindowHidden, true); + + // Dispatch the _windowReady event on the targetActor for pre-existing windows + const windows = this._targetActor.ignoreSubFrames + ? [docShellWindow] + : this._getWindowsInDocShell(docShell); + for (const win of windows) { + this._targetActor._windowReady(win); + this._knownWindowIDs.set(getWindowID(win), win); + } + + // The `watchedByDevTools` enables gecko behavior tied to this flag, such as: + // - reporting the contents of HTML loaded in the docshells, + // - or capturing stacks for the network monitor. + // + // This flag is also set in frame-helper but in the case of the browser toolbox, we + // don't have the watcher enabled by default yet, and as a result we need to set it + // here for the parent process window global. + // This should be removed as part of Bug 1709529. + if (this._targetActor.typeName === "parentProcessTarget") { + docShell.browsingContext.watchedByDevTools = true; + } + // Immediately enable CSS error reports on new top level docshells, if this was already enabled. + // This is specific to MBT and WebExtension targets (so the isRootActor check). + if ( + this._targetActor.isRootActor && + this._targetActor.docShell.cssErrorReportingEnabled + ) { + docShell.cssErrorReportingEnabled = true; + } + } + + unwatch(docShell) { + const docShellWindow = docShell.domWindow; + if (!this._watchedDocShells.has(docShellWindow)) { + return; + } + this._watchedDocShells.delete(docShellWindow); + + const webProgress = docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + // During process shutdown, the docshell may already be cleaned up and throw + try { + webProgress.removeProgressListener(this); + } catch (e) { + // ignore + } + + const handler = getDocShellChromeEventHandler(docShell); + handler.removeEventListener( + "DOMWindowCreated", + this._onWindowCreated, + true + ); + handler.removeEventListener("pageshow", this._onWindowCreated, true); + handler.removeEventListener("pagehide", this._onWindowHidden, true); + + const windows = this._targetActor.ignoreSubFrames + ? [docShellWindow] + : this._getWindowsInDocShell(docShell); + for (const win of windows) { + this._knownWindowIDs.delete(getWindowID(win)); + } + + // We only reset it for parent process target actor as the flag should be set in parent + // process, and thus is set elsewhere for other type of BrowsingContextActor. + if (this._targetActor.typeName === "parentProcessTarget") { + docShell.browsingContext.watchedByDevTools = false; + } + } + + _getWindowsInDocShell(docShell) { + return getChildDocShells(docShell).map(d => { + return d.domWindow; + }); + } + + onWindowCreated = DevToolsUtils.makeInfallible(function (evt) { + if (this._targetActor.isDestroyed()) { + return; + } + + // If we're in a frame swap (which occurs when toggling RDM, for example), then we can + // ignore this event, as the window never really went anywhere for our purposes. + if (evt.inFrameSwap) { + return; + } + + const window = evt.target.defaultView; + if (!window) { + // Some old UIs might emit unrelated events called pageshow/pagehide on + // elements which are not documents. Bail in this case. See Bug 1669666. + return; + } + + const innerID = getWindowID(window); + + // This handler is called for two events: "DOMWindowCreated" and "pageshow". + // Bail out if we already processed this window. + if (this._knownWindowIDs.has(innerID)) { + return; + } + this._knownWindowIDs.set(innerID, window); + + // For a regular page navigation, "DOMWindowCreated" is fired before + // "pageshow". If the current event is "pageshow" but we have not processed + // the window yet, it means this is a BF cache navigation. In theory, + // `event.persisted` should be set for BF cache navigation events, but it is + // not always available, so we fallback on checking if "pageshow" is the + // first event received for a given window (see Bug 1378133). + const isBFCache = evt.type == "pageshow"; + + this._targetActor._windowReady(window, { isBFCache }); + }, "DebuggerProgressListener.prototype.onWindowCreated"); + + onWindowHidden = DevToolsUtils.makeInfallible(function (evt) { + if (this._targetActor.isDestroyed()) { + return; + } + + // If we're in a frame swap (which occurs when toggling RDM, for example), then we can + // ignore this event, as the window isn't really going anywhere for our purposes. + if (evt.inFrameSwap) { + return; + } + + // Only act as if the window has been destroyed if the 'pagehide' event + // was sent for a persisted window (persisted is set when the page is put + // and frozen in the bfcache). If the page isn't persisted, the observer's + // inner-window-destroyed event will handle it. + if (!evt.persisted) { + return; + } + + const window = evt.target.defaultView; + if (!window) { + // Some old UIs might emit unrelated events called pageshow/pagehide on + // elements which are not documents. Bail in this case. See Bug 1669666. + return; + } + + this._targetActor._windowDestroyed(window, { isFrozen: true }); + this._knownWindowIDs.delete(getWindowID(window)); + }, "DebuggerProgressListener.prototype.onWindowHidden"); + + observe = DevToolsUtils.makeInfallible(function (subject, topic) { + if (this._targetActor.isDestroyed()) { + return; + } + + // Because this observer will be called for all inner-window-destroyed in + // the application, we need to filter out events for windows we are not + // watching + const innerID = subject.QueryInterface(Ci.nsISupportsPRUint64).data; + const window = this._knownWindowIDs.get(innerID); + if (window) { + this._knownWindowIDs.delete(innerID); + this._targetActor._windowDestroyed(window, { id: innerID }); + } + + // Bug 1598364: when debugging browser.xhtml from the Browser Toolbox + // the DOMWindowCreated/pageshow/pagehide event listeners have to be + // re-registered against the next document when we reload browser.html + // (or navigate to another doc). + // That's because we registered the listener on docShell.domWindow as + // top level windows don't have a chromeEventHandler. + if ( + this._watchedDocShells.has(window) && + !window.docShell.chromeEventHandler + ) { + // First cleanup all the existing listeners + this.unwatch(window.docShell); + // Re-register new ones. The docShell is already referencing the new document. + this.watch(window.docShell); + } + }, "DebuggerProgressListener.prototype.observe"); + + onStateChange = DevToolsUtils.makeInfallible(function ( + progress, + request, + flag, + status + ) { + if (this._targetActor.isDestroyed()) { + return; + } + progress.QueryInterface(Ci.nsIDocShell); + if (progress.isBeingDestroyed()) { + return; + } + + const isStart = flag & Ci.nsIWebProgressListener.STATE_START; + const isStop = flag & Ci.nsIWebProgressListener.STATE_STOP; + const isDocument = flag & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT; + const isWindow = flag & Ci.nsIWebProgressListener.STATE_IS_WINDOW; + + // Ideally, we would fetch navigationStart from window.performance.timing.navigationStart + // but as WindowGlobal isn't instantiated yet we don't have access to it. + // This is ultimately handed over to DocumentEventListener, which uses this. + // See its comment about WILL_NAVIGATE_TIME_SHIFT for more details about the related workaround. + const navigationStart = Date.now(); + + // Catch any iframe location change + if (isDocument && isStop) { + // Watch document stop to ensure having the new iframe url. + this._targetActor._notifyDocShellsUpdate([progress]); + } + + const window = progress.DOMWindow; + if (isDocument && isStart) { + // One of the earliest events that tells us a new URI + // is being loaded in this window. + const newURI = request instanceof Ci.nsIChannel ? request.URI.spec : null; + this._targetActor._willNavigate({ + window, + newURI, + request, + isFrameSwitching: false, + navigationStart, + }); + } + if (isWindow && isStop) { + // Don't dispatch "navigate" event just yet when there is a redirect to + // about:neterror page. + // Navigating to about:neterror will make `status` be something else than NS_OK. + // But for some error like NS_BINDING_ABORTED we don't want to emit any `navigate` + // event as the page load has been cancelled and the related page document is going + // to be a dead wrapper. + if ( + request.status != Cr.NS_OK && + request.status != Cr.NS_BINDING_ABORTED + ) { + // Instead, listen for DOMContentLoaded as about:neterror is loaded + // with LOAD_BACKGROUND flags and never dispatches load event. + // That may be the same reason why there is no onStateChange event + // for about:neterror loads. + const handler = getDocShellChromeEventHandler(progress); + const onLoad = evt => { + // Ignore events from iframes + if (evt.target === window.document) { + handler.removeEventListener("DOMContentLoaded", onLoad, true); + this._targetActor._navigate(window); + } + }; + handler.addEventListener("DOMContentLoaded", onLoad, true); + } else { + // Somewhat equivalent of load event. + // (window.document.readyState == complete) + this._targetActor._navigate(window); + } + } + }, + "DebuggerProgressListener.prototype.onStateChange"); +} diff --git a/devtools/server/actors/targets/worker.js b/devtools/server/actors/targets/worker.js new file mode 100644 index 0000000000..cf5f7b83c9 --- /dev/null +++ b/devtools/server/actors/targets/worker.js @@ -0,0 +1,149 @@ +/* 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 { + workerTargetSpec, +} = require("resource://devtools/shared/specs/targets/worker.js"); + +const { + WebConsoleActor, +} = require("resource://devtools/server/actors/webconsole.js"); +const { ThreadActor } = require("resource://devtools/server/actors/thread.js"); +const { TracerActor } = require("resource://devtools/server/actors/tracer.js"); +const { + ObjectsManagerActor, +} = require("resource://devtools/server/actors/objects-manager.js"); + +const Targets = require("resource://devtools/server/actors/targets/index.js"); + +const makeDebuggerUtil = require("resource://devtools/server/actors/utils/make-debugger.js"); +const { + SourcesManager, +} = require("resource://devtools/server/actors/utils/sources-manager.js"); + +const { + BaseTargetActor, +} = require("resource://devtools/server/actors/targets/base-target-actor.js"); + +class WorkerTargetActor extends BaseTargetActor { + /** + * Target actor for a worker in the content process. + * + * @param {DevToolsServerConnection} conn: The connection to the client. + * @param {WorkerGlobalScope} workerGlobal: The worker global. + * @param {Object} workerDebuggerData: The worker debugger information + * @param {String} workerDebuggerData.id: The worker debugger id + * @param {String} workerDebuggerData.url: The worker debugger url + * @param {String} workerDebuggerData.type: The worker debugger type + * @param {Boolean} workerDebuggerData.workerConsoleApiMessagesDispatchedToMainThread: + * Value of the dom.worker.console.dispatch_events_to_main_thread pref + * @param {Object} sessionContext: The Session Context to help know what is debugged. + * See devtools/server/actors/watcher/session-context.js + */ + constructor(conn, workerGlobal, workerDebuggerData, sessionContext) { + super(conn, Targets.TYPES.WORKER, workerTargetSpec); + + // workerGlobal is needed by the console actor for evaluations. + this.workerGlobal = workerGlobal; + this.sessionContext = sessionContext; + + // We don't have access to Ci from worker thread + // 2 == nsIWorkerDebugger.TYPE_SERVICE + // 1 == nsIWorkerDebugger.TYPE_SHARED + if (workerDebuggerData.type == 2) { + this.targetType = Targets.TYPES.SERVICE_WORKER; + } else if (workerDebuggerData.type == 1) { + this.targetType = Targets.TYPES.SHARED_WORKER; + } + + this._workerDebuggerData = workerDebuggerData; + this._sourcesManager = null; + this.workerConsoleApiMessagesDispatchedToMainThread = + workerDebuggerData.workerConsoleApiMessagesDispatchedToMainThread; + + this.makeDebugger = makeDebuggerUtil.bind(null, { + findDebuggees: () => { + return [workerGlobal]; + }, + shouldAddNewGlobalAsDebuggee: () => true, + }); + + // needed by the console actor + this.threadActor = new ThreadActor(this, this.workerGlobal); + + // needed by the thread actor to communicate with the console when evaluating logpoints. + this._consoleActor = new WebConsoleActor(this.conn, this); + + this.tracerActor = new TracerActor(this.conn, this); + this.objectsManagerActor = new ObjectsManagerActor(this.conn, this); + + this.manage(this.threadActor); + this.manage(this._consoleActor); + this.manage(this.tracerActor); + this.manage(this.objectsManagerActor); + } + + // Expose the worker URL to the thread actor. + // so that it can easily know what is the base URL of all worker scripts. + get workerUrl() { + return this._workerDebuggerData.url; + } + + form() { + return { + actor: this.actorID, + + consoleActor: this._consoleActor?.actorID, + threadActor: this.threadActor?.actorID, + tracerActor: this.tracerActor?.actorID, + objectsManagerActor: this.objectsManagerActor?.actorID, + + id: this._workerDebuggerData.id, + type: this._workerDebuggerData.type, + url: this._workerDebuggerData.url, + traits: { + // See trait description in browsing-context.js + supportsTopLevelTargetFlag: false, + }, + }; + } + + get dbg() { + if (!this._dbg) { + this._dbg = this.makeDebugger(); + } + return this._dbg; + } + + get sourcesManager() { + if (this._sourcesManager === null) { + this._sourcesManager = new SourcesManager(this.threadActor); + } + + return this._sourcesManager; + } + + // This is called from the ThreadActor#onAttach method + onThreadAttached() { + // This isn't an RDP event and is only listened to from startup/worker.js. + this.emit("worker-thread-attached"); + } + + destroy() { + super.destroy(); + + if (this._sourcesManager) { + this._sourcesManager.destroy(); + this._sourcesManager = null; + } + + this.workerGlobal = null; + this._dbg = null; + this._consoleActor = null; + this.threadActor = null; + } +} +exports.WorkerTargetActor = WorkerTargetActor; |