summaryrefslogtreecommitdiffstats
path: root/devtools/client/fronts/targets/target-mixin.js
blob: 157e83e73ae3e9a01c6d5a7543204ac24dd1ce86 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
/* 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<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;
      }

      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;