diff options
Diffstat (limited to 'devtools/client/fronts/targets/target-mixin.js')
-rw-r--r-- | devtools/client/fronts/targets/target-mixin.js | 750 |
1 files changed, 750 insertions, 0 deletions
diff --git a/devtools/client/fronts/targets/target-mixin.js b/devtools/client/fronts/targets/target-mixin.js new file mode 100644 index 0000000000..937eabdbd1 --- /dev/null +++ b/devtools/client/fronts/targets/target-mixin.js @@ -0,0 +1,750 @@ +/* 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", "devtools/shared/protocol", true); +loader.lazyRequireGetter( + this, + "getThreadOptions", + "devtools/client/shared/thread-utils", + 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 BrowsingContextTarget: + * - 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); + + this._forceChrome = false; + + this.destroy = this.destroy.bind(this); + + this.threadFront = null; + + // This flag will be set to true from: + // - TabDescriptorFront getTarget(), for local tab targets + // - targetFromURL(), for local targets (about:debugging) + // - initToolbox(), for some test-only targets + this.shouldCloseClient = false; + + this._client = client; + + // Cache of already created targed-scoped fronts + // [typeName:string => Front instance] + this.fronts = new Map(); + + // `resource-available-form` events can be emitted by target actors before the + // ResourceWatcher could add event listeners. The target front will cache those + // events until the ResourceWatcher has added the listeners. + this._resourceCache = []; + this._onResourceAvailable = this._onResourceAvailable.bind(this); + // In order to avoid destroying the `_resourceCache`, we need to call `super.on()` + // instead of `this.on()`. + super.on("resource-available-form", this._onResourceAvailable); + + this._addListeners(); + } + + on(eventName, listener) { + if (eventName === "resource-available-form" && this._resourceCache) { + this.off("resource-available-form", this._onResourceAvailable); + for (const cache of this._resourceCache) { + listener(cache); + } + this._resourceCache = null; + } + + 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 the descriptor front for this target. + * + * TODO: Should be removed. This is misleading as only the top level target should have a descriptor. + * This will return null for targets created by the Watcher actor and will still be defined + * by targets created by RootActor methods (listSomething methods). + */ + get descriptorFront() { + if (this.isDestroyed()) { + // If the target was already destroyed, parentFront will be null. + return null; + } + + if (this.parentFront.typeName.endsWith("Descriptor")) { + return this.parentFront; + } + return null; + } + + get targetType() { + return this._targetType; + } + + get isTopLevel() { + return this._isTopLevel; + } + + setTargetType(type) { + this._targetType = type; + } + + setIsTopLevel(isTopLevel) { + this._isTopLevel = isTopLevel; + } + + /** + * Get the top level WatcherFront for this target. + * + * The targets should all ultimately be managed by a unique Watcher actor, + * created from the unique Descriptor actor which is passed to the Toolbox. + * For now, the top level target is still created by the top level Descriptor, + * but it is also meant to be created by the Watcher. + * + * @return {TargetMixin} the parent target. + */ + getWatcherFront() { + // All additional frame targets are spawn by the WatcherActor and are managed by it. + if (this.parentFront.typeName == "watcher") { + return this.parentFront; + } + + // Otherwise, for top level targets, the parent front is a Descriptor, from which we can retrieve the Watcher. + // TODO: top level target should also be exposed by the Watcher actor, like any target. + if ( + this.parentFront.typeName.endsWith("Descriptor") && + this.parentFront.traits && + this.parentFront.traits.watcher + ) { + return this.parentFront.getWatcher(); + } + + // For WebExtension, the descriptor doesn't expose a watcher yet (See Bug 1675456). + return null; + } + + /** + * Get the immediate parent target for this target. + * + * @return {TargetMixin} the parent target. + */ + async getParentTarget() { + // We now support frames watching via watchTargets for Tab and Process descriptors. + const watcherFront = await this.getWatcherFront(); + if (watcherFront) { + // Safety check, in theory all watcher should support frames. We should be able + // to remove this as part of Bug 1680280. + if (watcherFront.traits.frame) { + // Retrieve the Watcher, which manage all the targets and should already have a reference to + // to the parent target. + return watcherFront.getParentBrowsingContextTarget( + this.browsingContextID + ); + } + return null; + } + + if (this.parentFront.getParentTarget) { + return this.parentFront.getParentTarget(); + } + + // Other targets, like WebExtensions, don't have a Watcher yet, nor do expose `getParentTarget`. + // We can't fetch parent target yet for these targets. + return null; + } + + /** + * Get the target for the given Browsing Context ID. + * + * @return {TargetMixin} the requested target. + */ + async getBrowsingContextTarget(browsingContextID) { + // Tab and Process Descriptors expose a Watcher, which is creating the + // targets and should be used to fetch any. + const watcherFront = await this.getWatcherFront(); + if (watcherFront) { + // Safety check, in theory all watcher should support frames. + if (watcherFront.traits.frame) { + return watcherFront.getBrowsingContextTarget(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 getBrowsingContextTarget 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 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 isLocalTab() { + return !!this.descriptorFront?.isLocalTab; + } + + get localTab() { + return this.descriptorFront?.localTab || null; + } + + // Get a promise of the RootActor's form + get root() { + return this.client.mainRoot.rootForm; + } + + // Get a Front for a target-scoped actor. + // i.e. an actor served by RootActor.listTabs or RootActorActor.getTab requests + async getFront(typeName) { + 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 we are debugging content document + // or if we are debugging chrome stuff. + // Allows to controls which features are available against + // a chrome or a content document. + get chrome() { + return ( + this.isAddon || + this.isContentProcess || + this.isParentProcess || + this.isWindowTarget || + this._forceChrome + ); + } + + forceChrome() { + this._forceChrome = true; + } + + // Tells us if the related actor implements BrowsingContextTargetActor + // interface and requires to call `attach` request before being used and + // `detach` during cleanup. + get isBrowsingContext() { + return this.typeName === "browsingContextTarget"; + } + + get name() { + if (this.isAddon || this.isContentProcess) { + return this.targetForm.name; + } + return this.title; + } + + get title() { + return this._title || this.url; + } + + get url() { + return this._url; + } + + get isAddon() { + return this.isLegacyAddon || this.isWebExtension; + } + + get isWorkerTarget() { + // XXX Remove the check on `workerDescriptor` as part of Bug 1667404. + return ( + this.typeName === "workerTarget" || this.typeName === "workerDescriptor" + ); + } + + get isLegacyAddon() { + return !!( + this.targetForm && + this.targetForm.actor && + this.targetForm.actor.match(/conn\d+\.addon(Target)?\d+/) + ); + } + + 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+/) + ); + } + + get isWindowTarget() { + return !!( + this.targetForm && + this.targetForm.actor && + this.targetForm.actor.match(/conn\d+\.chromeWindowTarget\d+/) + ); + } + + get isMultiProcess() { + return !this.window; + } + + 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; + } + } + + // Attach the console actor + async attachConsole() { + const consoleFront = await this.getFront("console"); + + if (this.isDestroyedOrBeingDestroyed()) { + return; + } + + // Calling startListeners will populate the traits as it's the first request we + // make to the front. + await consoleFront.startListeners([]); + + this._onInspectObject = packet => this.emit("inspect-object", packet); + this.removeOnInspectObjectListener = consoleFront.on( + "inspectObject", + this._onInspectObject + ); + } + + /** + * 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 {TargetList} targetList + * @returns {Promise} A promise that resolves once the thread is attached and resumed. + */ + attachAndInitThread(targetList) { + if (this._onThreadInitialized) { + return this._onThreadInitialized; + } + + this._onThreadInitialized = this._attachAndInitThread(targetList); + 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 {TargetList} targetList + * @returns {Promise} A promise that resolves once the thread is attached and resumed. + */ + async _attachAndInitThread(targetList) { + // If the target is destroyed or soon will be, don't go further + if (this.isDestroyedOrBeingDestroyed()) { + return; + } + + // WorkerTargetFront don't have an attach function as the related console and thread + // actors are created right away (from devtools/server/startup/worker.js) + if (this.attach) { + await this.attach(); + } + + const isBrowserToolbox = targetList.targetFront.isParentProcess; + const isNonTopLevelFrameTarget = + !this.isTopLevel && this.targetType === targetList.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; + } + + const options = await getThreadOptions(); + // If the target is destroyed or soon will be, don't go further + if (this.isDestroyedOrBeingDestroyed()) { + return; + } + const threadFront = await this.attachThread(options); + + // @backward-compat { version 86 } ThreadActor.attach no longer pause the thread, + // so that we no longer have to resume. + // Once 86 is in release, we can remove the rest of this method. + if (this.getTrait("noPauseOnThreadActorAttach")) { + return; + } + try { + if (this.isDestroyedOrBeingDestroyed() || threadFront.isDestroyed()) { + return; + } + await threadFront.resume(); + } catch (ex) { + if (ex.error === "wrongOrder") { + targetList.emit("target-thread-wrong-order-on-resume"); + } else { + throw ex; + } + } + } + + 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"); + if ( + this.isDestroyedOrBeingDestroyed() || + this.threadFront.isDestroyed() + ) { + return this.threadFront; + } + + await this.threadFront.attach(options); + + return this.threadFront; + } + + /** + * Setup listeners. + */ + _addListeners() { + this.client.on("closed", this.destroy); + + // `tabDetached` is sent by all target targets types: frame, process and workers. + // This is sent when the target is destroyed: + // * the target context destroys itself (the tab closes for ex, or the worker shuts down) + // in this case, it may be the connector that send this event in the name of the target actor + // * the target actor is destroyed, but the target context stays up and running (for ex, when we call Watcher.unwatchTargets) + // * the DevToolsServerConnection closes (client closes the connection) + this.on("tabDetached", this.destroy); + } + + /** + * Teardown listeners. + */ + _removeListeners() { + // Remove listeners set in _addListeners + if (this.client) { + this.client.off("closed", this.destroy); + } + this.off("tabDetached", this.destroy); + + // Remove listeners set in attachConsole + if (this.removeOnInspectObjectListener) { + this.removeOnInspectObjectListener(); + this.removeOnInspectObjectListener = null; + } + } + + 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() { + // Before taking any action, notify listeners that destruction is imminent. + this.emit("close"); + + // 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._removeListeners(); + + this.threadFront = null; + + if (this.shouldCloseClient) { + try { + await this._client.close(); + } catch (e) { + // Ignore any errors while closing, since there is not much that can be done + // at this point. + console.warn("Error while closing client:", e); + } + + // 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. + } else 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); + } + } + + // 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"); + + // 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; + + // All target front subclasses set this variable in their `attach` method. + // None of them overload destroy, so clean this up from here. + this._attach = null; + + this._title = null; + this._url = null; + } + + _onResourceAvailable(resources) { + if (this._resourceCache) { + this._resourceCache.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.traits.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.traits.logInPage) { + const warningFlag = 1; + return this.logInPage({ text, category, flags: warningFlag }); + } + return Promise.resolve(); + } + } + return Target; +} +exports.TargetMixin = TargetMixin; |