/* 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"; loader.lazyRequireGetter( this, "getFront", "resource://devtools/shared/protocol.js", true ); loader.lazyRequireGetter( this, "getThreadOptions", "resource://devtools/client/shared/thread-utils.js", true ); /** * A Target represents a debuggable context. It can be a browser tab, a tab on * a remote device, like a tab on Firefox for Android. But it can also be an add-on, * as well as firefox parent process, or just one of its content process. * A Target is related to a given TargetActor, for which we derive this class. * * Providing a generalized abstraction of a web-page or web-browser (available * either locally or remotely) is beyond the scope of this class (and maybe * also beyond the scope of this universe) However Target does attempt to * abstract some common events and read-only properties common to many Tools. * * Supported read-only properties: * - name, url * * Target extends EventEmitter and provides support for the following events: * - close: The target window has been closed. All tools attached to this * target should close. This event is not currently cancelable. * * Optional events only dispatched by WindowGlobalTarget: * - will-navigate: The target window will navigate to a different URL * - navigate: The target window has navigated to a different URL */ function TargetMixin(parentClass) { class Target extends parentClass { constructor(client, targetFront, parentFront) { super(client, targetFront, parentFront); // TargetCommand._onTargetAvailable will set this public attribute. // This is a reference to the related `commands` object and helps all fronts // easily call any command method. Without this bit of magic, Fronts wouldn't // be able to interact with any commands while it is frequently useful. this.commands = null; this.destroy = this.destroy.bind(this); this.threadFront = null; this._client = client; // Cache of already created targed-scoped fronts // [typeName:string => Front instance] this.fronts = new Map(); // `resource-available-form` and `resource-updated-form` events can be emitted // by target actors before the ResourceCommand could add event listeners. // The target front will cache those events until the ResourceCommand has // added the listeners. this._resourceCache = {}; // In order to avoid destroying the `_resourceCache[event]`, we need to call `super.on()` // instead of `this.on()`. const offResourceAvailable = super.on( "resource-available-form", this._onResourceEvent.bind(this, "resource-available-form") ); const offResourceUpdated = super.on( "resource-updated-form", this._onResourceEvent.bind(this, "resource-updated-form") ); this._offResourceEvent = new Map([ ["resource-available-form", offResourceAvailable], ["resource-updated-form", offResourceUpdated], ]); // Expose a promise that is resolved once the target front is usable // i.e. once attachAndInitThread has been called and resolved. this.initialized = new Promise(resolve => { this._onInitialized = resolve; }); } on(eventName, listener) { if (this._offResourceEvent && this._offResourceEvent.has(eventName)) { // If a callsite sets an event listener for resource-(available|update)-form: // we want to remove the listener we set here in the constructor… const off = this._offResourceEvent.get(eventName); this._offResourceEvent.delete(eventName); off(); // …and call the new listener with the resources that were put in the cache. if (this._resourceCache[eventName]) { for (const cache of this._resourceCache[eventName]) { listener(cache); } delete this._resourceCache[eventName]; } } return super.on(eventName, listener); } /** * Boolean flag to help distinguish Target Fronts from other Fronts. * As we are using a Mixin, we can't easily distinguish these fronts via instanceof(). */ get isTargetFront() { return true; } get targetType() { return this._targetType; } get isTopLevel() { // We can't use `getTrait` here as this might be called from a destroyed target (e.g. // from an onTargetDestroyed callback that was triggered by a legacy listener), which // means `this.client` would be null, which would make `getTrait` throw (See Bug 1714974) if (!this.targetForm.hasOwnProperty("isTopLevelTarget")) { return !!this._isTopLevel; } return this.targetForm.isTopLevelTarget; } setTargetType(type) { this._targetType = type; } setIsTopLevel(isTopLevel) { if (!this.getTrait("supportsTopLevelTargetFlag")) { this._isTopLevel = isTopLevel; } } /** * Get the immediate parent target for this target. * * @return {TargetMixin} the parent target. */ async getParentTarget() { return this.commands.targetCommand.getParentTarget(this); } /** * Returns a Promise that resolves to a boolean indicating if the provided target is * an ancestor of this instance. * * @param {TargetFront} target: The possible ancestor target. * @returns Promise */ async isTargetAnAncestor(target) { const parentTargetFront = await this.getParentTarget(); if (!parentTargetFront) { return false; } if (parentTargetFront == target) { return true; } return parentTargetFront.isTargetAnAncestor(target); } /** * Get the target for the given Browsing Context ID. * * @return {TargetMixin} the requested target. */ async getWindowGlobalTarget(browsingContextID) { // Just for sanity as commands attribute is set late from TargetCommand._onTargetAvailable // but ideally target front should be used before this happens. if (!this.commands) { return null; } // Tab and Process Descriptors expose a Watcher, which is creating the // targets and should be used to fetch any. const { watcherFront } = this.commands; if (watcherFront) { // Safety check, in theory all watcher should support frames. if (watcherFront.traits.frame) { return watcherFront.getWindowGlobalTarget(browsingContextID); } return null; } // For descriptors which don't expose a watcher (e.g. WebExtension) // we used to call RootActor::getBrowsingContextDescriptor, but it was // removed in FF77. // Support for watcher in WebExtension descriptors is Bug 1644341. throw new Error( `Unable to call getWindowGlobalTarget for ${this.actorID}` ); } /** * Returns a boolean indicating whether or not the specific actor * type exists. * * @param {String} actorName * @return {Boolean} */ hasActor(actorName) { if (this.targetForm) { return !!this.targetForm[actorName + "Actor"]; } return false; } /** * Returns a trait from the target actor if it exists, * if not it will fallback to that on the root actor. * * @param {String} traitName * @return {Mixed} */ getTrait(traitName) { // If the targeted actor exposes traits and has a defined value for this // traits, override the root actor traits if (this.targetForm.traits && traitName in this.targetForm.traits) { return this.targetForm.traits[traitName]; } return this.client.traits[traitName]; } // Get a Front for a target-scoped actor. // i.e. an actor served by RootActor.listTabs or RootActorActor.getTab requests async getFront(typeName) { if (this.isDestroyed()) { throw new Error( "Target already destroyed, unable to fetch children fronts" ); } let front = this.fronts.get(typeName); if (front) { // XXX: This is typically the kind of spot where switching to // `isDestroyed()` is complicated, because `front` is not necessarily a // Front... const isFrontInitializing = typeof front.then === "function"; const isFrontAlive = !isFrontInitializing && !front.isDestroyed(); if (isFrontInitializing || isFrontAlive) { return front; } } front = getFront(this.client, typeName, this.targetForm, this); this.fronts.set(typeName, front); // replace the placeholder with the instance of the front once it has loaded front = await front; this.fronts.set(typeName, front); return front; } getCachedFront(typeName) { // do not wait for async fronts; const front = this.fronts.get(typeName); // ensure that the front is a front, and not async front if (front?.actorID) { return front; } return null; } get client() { return this._client; } // Tells us if the related actor implements WindowGlobalTargetActor // interface and requires to call `attach` request before being used and // `detach` during cleanup. get isBrowsingContext() { return this.typeName === "windowGlobalTarget"; } get name() { if (this.isWebExtension || this.isContentProcess) { return this.targetForm.name; } return this.title; } get title() { return this._title || this.url; } get url() { return this._url; } get isWorkerTarget() { // XXX Remove the check on `workerDescriptor` as part of Bug 1667404. return ( this.typeName === "workerTarget" || this.typeName === "workerDescriptor" ); } get isWebExtension() { return !!( this.targetForm && this.targetForm.actor && (this.targetForm.actor.match(/conn\d+\.webExtension(Target)?\d+/) || this.targetForm.actor.match(/child\d+\/webExtension(Target)?\d+/)) ); } get isContentProcess() { // browser content toolbox's form will be of the form: // server0.conn0.content-process0/contentProcessTarget7 // while xpcshell debugging will be: // server1.conn0.contentProcessTarget7 return !!( this.targetForm && this.targetForm.actor && this.targetForm.actor.match( /conn\d+\.(content-process\d+\/)?contentProcessTarget\d+/ ) ); } get isParentProcess() { return !!( this.targetForm && this.targetForm.actor && this.targetForm.actor.match(/conn\d+\.parentProcessTarget\d+/) ); } getExtensionPathName(url) { // Return the url if the target is not a webextension. if (!this.isWebExtension) { throw new Error("Target is not a WebExtension"); } try { const parsedURL = new URL(url); // Only moz-extension URL should be shortened into the URL pathname. if (parsedURL.protocol !== "moz-extension:") { return url; } return parsedURL.pathname; } catch (e) { // Return the url if unable to resolve the pathname. return url; } } /** * This method attaches the target and then attaches its related thread, sending it * the options it needs (e.g. breakpoints, pause on exception setting, …). * This function can be called multiple times, it will only perform the actual * initialization process once; on subsequent call the original promise (_onThreadInitialized) * will be returned. * * @param {TargetCommand} targetCommand * @returns {Promise} A promise that resolves once the thread is attached and resumed. */ attachAndInitThread(targetCommand) { if (this._onThreadInitialized) { return this._onThreadInitialized; } this._onThreadInitialized = this._attachAndInitThread(targetCommand); // Resolve the `initialized` promise, while ignoring errors // The empty function passed to catch will avoid spawning a new possibly rejected promise this._onThreadInitialized.catch(() => {}).then(this._onInitialized); return this._onThreadInitialized; } /** * This method attach the target and then attach its related thread, sending it the * options it needs (e.g. breakpoints, pause on exception setting, …) * * @private * @param {TargetCommand} targetCommand * @returns {Promise} A promise that resolves once the thread is attached and resumed. */ async _attachAndInitThread(targetCommand) { // If the target is destroyed or soon will be, don't go further if (this.isDestroyedOrBeingDestroyed()) { return; } // The current class we have is actually the WorkerDescriptorFront, // which will morph into a target by fetching the underlying target's form. // Ideally, worker targets would be spawn by the server, and we would no longer // have the hybrid descriptor/target class which brings lots of complexity and confusion. // To be removed in bug 1651522. if (this.morphWorkerDescriptorIntoWorkerTarget) { await this.morphWorkerDescriptorIntoWorkerTarget(); } const isBrowserToolbox = targetCommand.descriptorFront.isBrowserProcessDescriptor; const isNonTopLevelFrameTarget = !this.isTopLevel && this.targetType === targetCommand.TYPES.FRAME; if (isBrowserToolbox && isNonTopLevelFrameTarget) { // In the BrowserToolbox, non-top-level frame targets are already // debugged via content-process targets. // Do not attach the thread here, as it was already done by the // corresponding content-process target. return; } // Avoid attaching any thread actor in the browser console or in // webextension commands in order to avoid triggering any type of // breakpoint. if (targetCommand.descriptorFront.doNotAttachThreadActor) { return; } const options = await getThreadOptions(); // If the target is destroyed or soon will be, don't go further if (this.isDestroyedOrBeingDestroyed()) { return; } await this.attachThread(options); } async attachThread(options = {}) { if (!this.targetForm || !this.targetForm.threadActor) { throw new Error( "TargetMixin sub class should set targetForm.threadActor before calling " + "attachThread" ); } this.threadFront = await this.getFront("thread"); // Avoid attaching if the thread actor was already attached on target creation from the server side. // This doesn't include: // * targets that aren't yet supported by the Watcher (like web extensions), // * workers, which still use a unique codepath for thread actor attach // * all targets when connecting to an older server // If all targets are supported by watcher actor, and workers no longer use // its unique attach sequence, we can assume the thread front is always attached. const isAttached = await this.threadFront.isAttached(); const isDestroyed = this.isDestroyedOrBeingDestroyed() || this.threadFront.isDestroyed(); if (!isAttached && !isDestroyed) { await this.threadFront.attach(options); } return this.threadFront; } isDestroyedOrBeingDestroyed() { return this.isDestroyed() || this._destroyer; } /** * Target is not alive anymore. */ destroy() { // If several things call destroy then we give them all the same // destruction promise so we're sure to destroy only once if (this._destroyer) { return this._destroyer; } // This pattern allows to immediately return the destroyer promise. // See Bug 1602727 for more details. let destroyerResolve; this._destroyer = new Promise(r => (destroyerResolve = r)); this._destroyTarget().then(destroyerResolve); return this._destroyer; } async _destroyTarget() { // If the target is being attached, try to wait until it's done, to prevent having // pending connection to the server when the toolbox is destroyed. if (this._onThreadInitialized) { try { await this._onThreadInitialized; } catch (e) { // We might still get into cases where attaching fails (e.g. the worker we're // trying to attach to is already closed). Since the target is being destroyed, // we don't need to do anything special here. } } for (let [name, front] of this.fronts) { try { // If a Front with an async initialize method is still being instantiated, // we should wait for completion before trying to destroy it. if (front instanceof Promise) { front = await front; } front.destroy(); } catch (e) { console.warn("Error while destroying front:", name, e); } } this.fronts.clear(); this.threadFront = null; this._offResourceEvent = null; // This event should be emitted before calling super.destroy(), because // super.destroy() will remove all event listeners attached to this front. this.emit("target-destroyed"); // Not all targets supports attach/detach. For example content process doesn't. // Also ensure that the front is still active before trying to do the request. if (this.detach && !this.isDestroyed()) { // The client was handed to us, so we are not responsible for closing // it. We just need to detach from the tab, if already attached. // |detach| may fail if the connection is already dead, so proceed with // cleanup directly after this. try { await this.detach(); } catch (e) { this.logDetachError(e); } } // Do that very last in order to let a chance to dispatch `detach` requests. super.destroy(); this._cleanup(); } /** * Detach can fail under regular circumstances, if the target was already * destroyed on the server side. All target fronts should handle detach * error logging in similar ways so this might be used by subclasses * with custom detach() implementations. * * @param {Error} e * The real error object. * @param {String} targetType * The type of the target front ("worker", "browsing-context", ...) */ logDetachError(e, targetType) { const ignoredError = e?.message.includes("noSuchActor") || e?.message.includes("Connection closed"); // Silence exceptions for already destroyed actors and fronts: // - "noSuchActor" errors from the server // - "Connection closed" errors from the client, when purging requests if (ignoredError) { return; } // Properly log any other error. const message = targetType ? `Error while detaching the ${targetType} target:` : "Error while detaching target:"; console.warn(message, e); } /** * Clean up references to what this target points to. */ _cleanup() { this.threadFront = null; this._client = null; this._title = null; this._url = null; } _onResourceEvent(eventName, resources) { if (!this._resourceCache[eventName]) { this._resourceCache[eventName] = []; } this._resourceCache[eventName].push(resources); } toString() { const id = this.targetForm ? this.targetForm.actor : null; return `Target:${id}`; } dumpPools() { // NOTE: dumpPools is defined in the Thread actor to avoid // adding it to multiple target specs and actors. return this.threadFront.dumpPools(); } /** * Log an error of some kind to the tab's console. * * @param {String} text * The text to log. * @param {String} category * The category of the message. @see nsIScriptError. * @returns {Promise} */ logErrorInPage(text, category) { if (this.getTrait("logInPage")) { const errorFlag = 0; return this.logInPage({ text, category, flags: errorFlag }); } return Promise.resolve(); } /** * Log a warning of some kind to the tab's console. * * @param {String} text * The text to log. * @param {String} category * The category of the message. @see nsIScriptError. * @returns {Promise} */ logWarningInPage(text, category) { if (this.getTrait("logInPage")) { const warningFlag = 1; return this.logInPage({ text, category, flags: warningFlag }); } return Promise.resolve(); } } return Target; } exports.TargetMixin = TargetMixin;