Bug 1539764 - Add a targetFront attribute to the Front class to retrieve the target...
[gecko.git] / devtools / shared / fronts / targets / target-mixin.js
blob4eedea7f0a83edd1476050ca78fbfd2ba522b506
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 "use strict";
7 // We are requiring a module from client whereas this module is from shared.
8 // This shouldn't happen, but Fronts should rather be part of client anyway.
9 // Otherwise gDevTools is only used for local tabs and should propably only
10 // used by a subclass, specific to local tabs.
11 loader.lazyRequireGetter(
12   this,
13   "gDevTools",
14   "devtools/client/framework/devtools",
15   true
17 loader.lazyRequireGetter(
18   this,
19   "TargetFactory",
20   "devtools/client/framework/target",
21   true
23 loader.lazyRequireGetter(
24   this,
25   "ThreadClient",
26   "devtools/shared/client/deprecated-thread-client"
28 loader.lazyRequireGetter(this, "getFront", "devtools/shared/protocol", true);
30 /**
31  * A Target represents a debuggable context. It can be a browser tab, a tab on
32  * a remote device, like a tab on Firefox for Android. But it can also be an add-on,
33  * as well as firefox parent process, or just one of its content process.
34  * A Target is related to a given TargetActor, for which we derive this class.
35  *
36  * Providing a generalized abstraction of a web-page or web-browser (available
37  * either locally or remotely) is beyond the scope of this class (and maybe
38  * also beyond the scope of this universe) However Target does attempt to
39  * abstract some common events and read-only properties common to many Tools.
40  *
41  * Supported read-only properties:
42  * - name, url
43  *
44  * Target extends EventEmitter and provides support for the following events:
45  * - close: The target window has been closed. All tools attached to this
46  *          target should close. This event is not currently cancelable.
47  *
48  * Optional events only dispatched by BrowsingContextTarget:
49  * - will-navigate: The target window will navigate to a different URL
50  * - navigate: The target window has navigated to a different URL
51  */
52 function TargetMixin(parentClass) {
53   class Target extends parentClass {
54     constructor(client, form) {
55       super(client, form);
57       this._forceChrome = false;
59       this.destroy = this.destroy.bind(this);
60       this._onNewSource = this._onNewSource.bind(this);
62       this.activeConsole = null;
63       this.threadFront = null;
65       this._client = client;
67       // Cache of already created targed-scoped fronts
68       // [typeName:string => Front instance]
69       this.fronts = new Map();
70       // Temporary fix for bug #1493131 - inspector has a different life cycle
71       // than most other fronts because it is closely related to the toolbox.
72       // TODO: remove once inspector is separated from the toolbox
73       this._inspector = null;
75       this._setupRemoteListeners();
76     }
78     attachTab(tab) {
79       // When debugging local tabs, we also have a reference to the Firefox tab
80       // This is used to:
81       // * distinguish local tabs from remote (see target.isLocalTab)
82       // * being able to hookup into Firefox UI (see Hosts)
83       this._tab = tab;
84       this._setupListeners();
85     }
87     /**
88      * Returns a promise for the protocol description from the root actor. Used
89      * internally with `target.actorHasMethod`. Takes advantage of caching if
90      * definition was fetched previously with the corresponding actor information.
91      * Actors are lazily loaded, so not only must the tool using a specific actor
92      * be in use, the actors are only registered after invoking a method (for
93      * performance reasons, added in bug 988237), so to use these actor detection
94      * methods, one must already be communicating with a specific actor of that
95      * type.
96      *
97      * @return {Promise}
98      * {
99      *   "category": "actor",
100      *   "typeName": "longstractor",
101      *   "methods": [{
102      *     "name": "substring",
103      *     "request": {
104      *       "type": "substring",
105      *       "start": {
106      *         "_arg": 0,
107      *         "type": "primitive"
108      *       },
109      *       "end": {
110      *         "_arg": 1,
111      *         "type": "primitive"
112      *       }
113      *     },
114      *     "response": {
115      *       "substring": {
116      *         "_retval": "primitive"
117      *       }
118      *     }
119      *   }],
120      *  "events": {}
121      * }
122      */
123     async getActorDescription(actorName) {
124       if (
125         this._protocolDescription &&
126         this._protocolDescription.types[actorName]
127       ) {
128         return this._protocolDescription.types[actorName];
129       }
130       const description = await this.client.mainRoot.protocolDescription();
131       this._protocolDescription = description;
132       return description.types[actorName];
133     }
135     /**
136      * Returns a boolean indicating whether or not the specific actor
137      * type exists.
138      *
139      * @param {String} actorName
140      * @return {Boolean}
141      */
142     hasActor(actorName) {
143       if (this.targetForm) {
144         return !!this.targetForm[actorName + "Actor"];
145       }
146       return false;
147     }
149     /**
150      * Queries the protocol description to see if an actor has
151      * an available method. The actor must already be lazily-loaded (read
152      * the restrictions in the `getActorDescription` comments),
153      * so this is for use inside of tool. Returns a promise that
154      * resolves to a boolean.
155      *
156      * @param {String} actorName
157      * @param {String} methodName
158      * @return {Promise}
159      */
160     actorHasMethod(actorName, methodName) {
161       return this.getActorDescription(actorName).then(desc => {
162         if (desc && desc.methods) {
163           return !!desc.methods.find(method => method.name === methodName);
164         }
165         return false;
166       });
167     }
169     /**
170      * Returns a trait from the root actor.
171      *
172      * @param {String} traitName
173      * @return {Mixed}
174      */
175     getTrait(traitName) {
176       // If the targeted actor exposes traits and has a defined value for this
177       // traits, override the root actor traits
178       if (this.targetForm.traits && traitName in this.targetForm.traits) {
179         return this.targetForm.traits[traitName];
180       }
182       return this.client.traits[traitName];
183     }
185     get tab() {
186       return this._tab;
187     }
189     // Get a promise of the RootActor's form
190     get root() {
191       return this.client.mainRoot.rootForm;
192     }
194     // Temporary fix for bug #1493131 - inspector has a different life cycle
195     // than most other fronts because it is closely related to the toolbox.
196     // TODO: remove once inspector is separated from the toolbox
197     async getInspector() {
198       // the front might have been destroyed and no longer have an actor ID
199       if (this._inspector && this._inspector.actorID) {
200         return this._inspector;
201       }
202       this._inspector = await getFront(
203         this.client,
204         "inspector",
205         this.targetForm,
206         this
207       );
208       this.emit("inspector", this._inspector);
209       return this._inspector;
210     }
212     // Run callback on every front of this type that currently exists, and on every
213     // instantiation of front type in the future.
214     onFront(typeName, callback) {
215       const front = this.fronts.get(typeName);
216       if (front) {
217         return callback(front);
218       }
219       return this.on(typeName, callback);
220     }
222     // Get a Front for a target-scoped actor.
223     // i.e. an actor served by RootActor.listTabs or RootActorActor.getTab requests
224     async getFront(typeName) {
225       let front = this.fronts.get(typeName);
226       // the front might have been destroyed and no longer have an actor ID
227       if (
228         (front && front.actorID) ||
229         (front && typeof front.then === "function")
230       ) {
231         return front;
232       }
233       front = getFront(this.client, typeName, this.targetForm, this);
234       this.fronts.set(typeName, front);
235       // replace the placeholder with the instance of the front once it has loaded
236       front = await front;
237       this.emit(typeName, front);
238       this.fronts.set(typeName, front);
239       return front;
240     }
242     getCachedFront(typeName) {
243       // do not wait for async fronts;
244       const front = this.fronts.get(typeName);
245       // ensure that the front is a front, and not async front
246       if (front && front.actorID) {
247         return front;
248       }
249       return null;
250     }
252     get client() {
253       return this._client;
254     }
256     // Tells us if we are debugging content document
257     // or if we are debugging chrome stuff.
258     // Allows to controls which features are available against
259     // a chrome or a content document.
260     get chrome() {
261       return (
262         this.isAddon ||
263         this.isContentProcess ||
264         this.isParentProcess ||
265         this.isWindowTarget ||
266         this._forceChrome
267       );
268     }
270     forceChrome() {
271       this._forceChrome = true;
272     }
274     // Tells us if the related actor implements BrowsingContextTargetActor
275     // interface and requires to call `attach` request before being used and
276     // `detach` during cleanup.
277     get isBrowsingContext() {
278       return this.typeName === "browsingContextTarget";
279     }
281     get name() {
282       if (this.isAddon) {
283         return this.targetForm.name;
284       }
285       return this.title;
286     }
288     get title() {
289       return this._title || this.url;
290     }
292     get url() {
293       return this._url;
294     }
296     get isAddon() {
297       return this.isLegacyAddon || this.isWebExtension;
298     }
300     get isWorkerTarget() {
301       return this.typeName === "workerTarget";
302     }
304     get isLegacyAddon() {
305       return !!(
306         this.targetForm &&
307         this.targetForm.actor &&
308         this.targetForm.actor.match(/conn\d+\.addon(Target)?\d+/)
309       );
310     }
312     get isWebExtension() {
313       return !!(
314         this.targetForm &&
315         this.targetForm.actor &&
316         (this.targetForm.actor.match(/conn\d+\.webExtension(Target)?\d+/) ||
317           this.targetForm.actor.match(/child\d+\/webExtension(Target)?\d+/))
318       );
319     }
321     get isContentProcess() {
322       // browser content toolbox's form will be of the form:
323       //   server0.conn0.content-process0/contentProcessTarget7
324       // while xpcshell debugging will be:
325       //   server1.conn0.contentProcessTarget7
326       return !!(
327         this.targetForm &&
328         this.targetForm.actor &&
329         this.targetForm.actor.match(
330           /conn\d+\.(content-process\d+\/)?contentProcessTarget\d+/
331         )
332       );
333     }
335     get isParentProcess() {
336       return !!(
337         this.targetForm &&
338         this.targetForm.actor &&
339         this.targetForm.actor.match(/conn\d+\.parentProcessTarget\d+/)
340       );
341     }
343     get isWindowTarget() {
344       return !!(
345         this.targetForm &&
346         this.targetForm.actor &&
347         this.targetForm.actor.match(/conn\d+\.chromeWindowTarget\d+/)
348       );
349     }
351     get isLocalTab() {
352       return !!this._tab;
353     }
355     get isMultiProcess() {
356       return !this.window;
357     }
359     get canRewind() {
360       return this.traits.canRewind;
361     }
363     isReplayEnabled() {
364       return this.canRewind && this.isLocalTab;
365     }
367     getExtensionPathName(url) {
368       // Return the url if the target is not a webextension.
369       if (!this.isWebExtension) {
370         throw new Error("Target is not a WebExtension");
371       }
373       try {
374         const parsedURL = new URL(url);
375         // Only moz-extension URL should be shortened into the URL pathname.
376         if (parsedURL.protocol !== "moz-extension:") {
377           return url;
378         }
379         return parsedURL.pathname;
380       } catch (e) {
381         // Return the url if unable to resolve the pathname.
382         return url;
383       }
384     }
386     /**
387      * For local tabs, returns the tab's contentPrincipal, which can be used as a
388      * `triggeringPrincipal` when opening links.  However, this is a hack as it is not
389      * correct for subdocuments and it won't work for remote debugging.  Bug 1467945 hopes
390      * to devise a better approach.
391      */
392     get contentPrincipal() {
393       if (!this.isLocalTab) {
394         return null;
395       }
396       return this.tab.linkedBrowser.contentPrincipal;
397     }
399     /**
400      * Similar to the above get contentPrincipal(), the get csp()
401      * returns the CSP which should be used for opening links.
402      */
403     get csp() {
404       if (!this.isLocalTab) {
405         return null;
406       }
407       return this.tab.linkedBrowser.csp;
408     }
410     // Attach the console actor
411     async attachConsole() {
412       this.activeConsole = await this.getFront("console");
413       await this.activeConsole.startListeners([]);
415       this._onInspectObject = packet => this.emit("inspect-object", packet);
416       this.activeConsole.on("inspectObject", this._onInspectObject);
417     }
419     /**
420      * Attach to thread actor.
421      *
422      * This depends on having the sub-class to set the thread actor ID in `_threadActor`.
423      *
424      * @param object options
425      *        Configuration options.
426      */
427     async attachThread(options = {}) {
428       if (!this._threadActor) {
429         throw new Error(
430           "TargetMixin sub class should set _threadActor before calling " +
431             "attachThread"
432         );
433       }
434       if (this.getTrait("hasThreadFront")) {
435         this.threadFront = await this.getFront("thread");
436       } else {
437         // Backwards compat for Firefox 68
438         // mimics behavior of a front
439         this.threadFront = new ThreadClient(this._client, this._threadActor);
440         this.fronts.set("thread", this.threadFront);
441         this.threadFront.actorID = this._threadActor;
442         this.manage(this.threadFront);
443       }
444       const result = await this.threadFront.attach(options);
446       this.threadFront.on("newSource", this._onNewSource);
448       return [result, this.threadFront];
449     }
451     // Listener for "newSource" event fired by the thread actor
452     _onNewSource(packet) {
453       this.emit("source-updated", packet);
454     }
456     /**
457      * Listen to the different events.
458      */
459     _setupListeners() {
460       this.tab.addEventListener("TabClose", this);
461       this.tab.ownerDocument.defaultView.addEventListener("unload", this);
462       this.tab.addEventListener("TabRemotenessChange", this);
463     }
465     /**
466      * Teardown event listeners.
467      */
468     _teardownListeners() {
469       if (this._tab.ownerDocument.defaultView) {
470         this._tab.ownerDocument.defaultView.removeEventListener("unload", this);
471       }
472       this._tab.removeEventListener("TabClose", this);
473       this._tab.removeEventListener("TabRemotenessChange", this);
474     }
476     /**
477      * Setup listeners for remote debugging, updating existing ones as necessary.
478      */
479     _setupRemoteListeners() {
480       this.client.on("closed", this.destroy);
482       this.on("tabDetached", this.destroy);
483     }
485     /**
486      * Teardown listeners for remote debugging.
487      */
488     _teardownRemoteListeners() {
489       // Remove listeners set in _setupRemoteListeners
490       if (this.client) {
491         this.client.off("closed", this.destroy);
492       }
493       this.off("tabDetached", this.destroy);
495       // Remove listeners set in attachThread
496       if (this.threadFront) {
497         this.threadFront.off("newSource", this._onNewSource);
498       }
500       // Remove listeners set in attachConsole
501       if (this.activeConsole && this._onInspectObject) {
502         this.activeConsole.off("inspectObject", this._onInspectObject);
503       }
504     }
506     /**
507      * Handle tabs events.
508      */
509     handleEvent(event) {
510       switch (event.type) {
511         case "TabClose":
512         case "unload":
513           this.destroy();
514           break;
515         case "TabRemotenessChange":
516           this.onRemotenessChange();
517           break;
518       }
519     }
521     /**
522      * Automatically respawn the toolbox when the tab changes between being
523      * loaded within the parent process and loaded from a content process.
524      * Process change can go in both ways.
525      */
526     onRemotenessChange() {
527       // Responsive design do a crazy dance around tabs and triggers
528       // remotenesschange events. But we should ignore them as at the end
529       // the content doesn't change its remoteness.
530       if (this._tab.isResponsiveDesignMode) {
531         return;
532       }
534       // Save a reference to the tab as it will be nullified on destroy
535       const tab = this._tab;
536       const onToolboxDestroyed = async target => {
537         if (target != this) {
538           return;
539         }
540         gDevTools.off("toolbox-destroyed", target);
542         // Recreate a fresh target instance as the current one is now destroyed
543         const newTarget = await TargetFactory.forTab(tab);
544         gDevTools.showToolbox(newTarget);
545       };
546       gDevTools.on("toolbox-destroyed", onToolboxDestroyed);
547     }
549     /**
550      * Target is not alive anymore.
551      */
552     destroy() {
553       // If several things call destroy then we give them all the same
554       // destruction promise so we're sure to destroy only once
555       if (this._destroyer) {
556         return this._destroyer;
557       }
559       this._destroyer = (async () => {
560         // Before taking any action, notify listeners that destruction is imminent.
561         this.emit("close");
563         for (let [, front] of this.fronts) {
564           front = await front;
565           await front.destroy();
566         }
568         if (this._tab) {
569           this._teardownListeners();
570         }
572         this._teardownRemoteListeners();
574         this.threadFront = null;
576         if (this.isLocalTab) {
577           // We started with a local tab and created the client ourselves, so we
578           // should close it. Ignore any errors while closing, since there is
579           // not much that can be done at this point.
580           try {
581             await this._client.close();
582           } catch (e) {
583             console.warn(`Error while closing client: ${e.message}`);
584           }
586           // Not all targets supports attach/detach. For example content process doesn't.
587           // Also ensure that the front is still active before trying to do the request.
588         } else if (this.detach && this.actorID) {
589           // The client was handed to us, so we are not responsible for closing
590           // it. We just need to detach from the tab, if already attached.
591           // |detach| may fail if the connection is already dead, so proceed with
592           // cleanup directly after this.
593           try {
594             await this.detach();
595           } catch (e) {
596             console.warn(`Error while detaching target: ${e.message}`);
597           }
598         }
600         // Do that very last in order to let a chance to dispatch `detach` requests.
601         super.destroy();
603         this._cleanup();
604       })();
606       return this._destroyer;
607     }
609     /**
610      * Clean up references to what this target points to.
611      */
612     _cleanup() {
613       this.activeConsole = null;
614       this.threadFront = null;
615       this._client = null;
616       this._tab = null;
618       // All target front subclasses set this variable in their `attach` method.
619       // None of them overload destroy, so clean this up from here.
620       this._attach = null;
622       this._title = null;
623       this._url = null;
624     }
626     toString() {
627       const id = this._tab
628         ? this._tab
629         : this.targetForm && this.targetForm.actor;
630       return `Target:${id}`;
631     }
633     /**
634      * Log an error of some kind to the tab's console.
635      *
636      * @param {String} text
637      *                 The text to log.
638      * @param {String} category
639      *                 The category of the message.  @see nsIScriptError.
640      * @returns {Promise}
641      */
642     logErrorInPage(text, category) {
643       if (this.traits.logInPage) {
644         const errorFlag = 0;
645         return this.logInPage({ text, category, flags: errorFlag });
646       }
647       return Promise.resolve();
648     }
650     /**
651      * Log a warning of some kind to the tab's console.
652      *
653      * @param {String} text
654      *                 The text to log.
655      * @param {String} category
656      *                 The category of the message.  @see nsIScriptError.
657      * @returns {Promise}
658      */
659     logWarningInPage(text, category) {
660       if (this.traits.logInPage) {
661         const warningFlag = 1;
662         return this.logInPage({ text, category, flags: warningFlag });
663       }
664       return Promise.resolve();
665     }
666   }
667   return Target;
669 exports.TargetMixin = TargetMixin;