Source code

Revision control

Copy as Markdown

Other Tools

/* 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",
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<Boolean>
*/
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;
}
// If the target is destroyed or soon will be, don't go further
if (this.isDestroyedOrBeingDestroyed()) {
return;
}
if (!this.targetForm || !this.targetForm.threadActor) {
throw new Error(
"TargetMixin sub class should set targetForm.threadActor before calling attachAndInitThread"
);
}
this.threadFront = await this.getFront("thread");
}
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;