tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

TargetManager.ts (14681B)


      1 /**
      2 * @license
      3 * Copyright 2022 Google Inc.
      4 * SPDX-License-Identifier: Apache-2.0
      5 */
      6 
      7 import type {Protocol} from 'devtools-protocol';
      8 
      9 import type {TargetFilterCallback} from '../api/Browser.js';
     10 import {CDPSession, CDPSessionEvent} from '../api/CDPSession.js';
     11 import {EventEmitter} from '../common/EventEmitter.js';
     12 import {debugError} from '../common/util.js';
     13 import {assert} from '../util/assert.js';
     14 import {Deferred} from '../util/Deferred.js';
     15 
     16 import {CdpCDPSession} from './CdpSession.js';
     17 import type {Connection} from './Connection.js';
     18 import {CdpTarget, InitializationStatus} from './Target.js';
     19 import type {TargetManagerEvents} from './TargetManageEvents.js';
     20 import {TargetManagerEvent} from './TargetManageEvents.js';
     21 
     22 /**
     23 * @internal
     24 */
     25 export type TargetFactory = (
     26  targetInfo: Protocol.Target.TargetInfo,
     27  session?: CdpCDPSession,
     28  parentSession?: CdpCDPSession,
     29 ) => CdpTarget;
     30 
     31 function isPageTargetBecomingPrimary(
     32  target: CdpTarget,
     33  newTargetInfo: Protocol.Target.TargetInfo,
     34 ): boolean {
     35  return Boolean(target._subtype()) && !newTargetInfo.subtype;
     36 }
     37 
     38 /**
     39 * TargetManager encapsulates all interactions with CDP targets and is
     40 * responsible for coordinating the configuration of targets with the rest of
     41 * Puppeteer. Code outside of this class should not subscribe `Target.*` events
     42 * and only use the TargetManager events.
     43 *
     44 * TargetManager uses the CDP's auto-attach mechanism to intercept
     45 * new targets and allow the rest of Puppeteer to configure listeners while
     46 * the target is paused.
     47 *
     48 * @internal
     49 */
     50 export class TargetManager
     51  extends EventEmitter<TargetManagerEvents>
     52  implements TargetManager
     53 {
     54  #connection: Connection;
     55  /**
     56   * Keeps track of the following events: 'Target.targetCreated',
     57   * 'Target.targetDestroyed', 'Target.targetInfoChanged'.
     58   *
     59   * A target becomes discovered when 'Target.targetCreated' is received.
     60   * A target is removed from this map once 'Target.targetDestroyed' is
     61   * received.
     62   *
     63   * `targetFilterCallback` has no effect on this map.
     64   */
     65  #discoveredTargetsByTargetId = new Map<string, Protocol.Target.TargetInfo>();
     66  /**
     67   * A target is added to this map once TargetManager has created
     68   * a Target and attached at least once to it.
     69   */
     70  #attachedTargetsByTargetId = new Map<string, CdpTarget>();
     71  /**
     72   * Tracks which sessions attach to which target.
     73   */
     74  #attachedTargetsBySessionId = new Map<string, CdpTarget>();
     75  /**
     76   * If a target was filtered out by `targetFilterCallback`, we still receive
     77   * events about it from CDP, but we don't forward them to the rest of Puppeteer.
     78   */
     79  #ignoredTargets = new Set<string>();
     80  #targetFilterCallback: TargetFilterCallback | undefined;
     81  #targetFactory: TargetFactory;
     82 
     83  #attachedToTargetListenersBySession = new WeakMap<
     84    CDPSession | Connection,
     85    (event: Protocol.Target.AttachedToTargetEvent) => void
     86  >();
     87  #detachedFromTargetListenersBySession = new WeakMap<
     88    CDPSession | Connection,
     89    (event: Protocol.Target.DetachedFromTargetEvent) => void
     90  >();
     91 
     92  #initializeDeferred = Deferred.create<void>();
     93  #targetsIdsForInit = new Set<string>();
     94  #waitForInitiallyDiscoveredTargets = true;
     95 
     96  #discoveryFilter: Protocol.Target.FilterEntry[] = [{}];
     97 
     98  constructor(
     99    connection: Connection,
    100    targetFactory: TargetFactory,
    101    targetFilterCallback?: TargetFilterCallback,
    102    waitForInitiallyDiscoveredTargets = true,
    103  ) {
    104    super();
    105    this.#connection = connection;
    106    this.#targetFilterCallback = targetFilterCallback;
    107    this.#targetFactory = targetFactory;
    108    this.#waitForInitiallyDiscoveredTargets = waitForInitiallyDiscoveredTargets;
    109 
    110    this.#connection.on('Target.targetCreated', this.#onTargetCreated);
    111    this.#connection.on('Target.targetDestroyed', this.#onTargetDestroyed);
    112    this.#connection.on('Target.targetInfoChanged', this.#onTargetInfoChanged);
    113    this.#connection.on(
    114      CDPSessionEvent.SessionDetached,
    115      this.#onSessionDetached,
    116    );
    117    this.#setupAttachmentListeners(this.#connection);
    118  }
    119 
    120  #storeExistingTargetsForInit = () => {
    121    if (!this.#waitForInitiallyDiscoveredTargets) {
    122      return;
    123    }
    124    for (const [
    125      targetId,
    126      targetInfo,
    127    ] of this.#discoveredTargetsByTargetId.entries()) {
    128      const targetForFilter = new CdpTarget(
    129        targetInfo,
    130        undefined,
    131        undefined,
    132        this,
    133        undefined,
    134      );
    135      // Only wait for pages and frames (except those from extensions)
    136      // to auto-attach.
    137      const isPageOrFrame =
    138        targetInfo.type === 'page' || targetInfo.type === 'iframe';
    139      const isExtension = targetInfo.url.startsWith('chrome-extension://');
    140      if (
    141        (!this.#targetFilterCallback ||
    142          this.#targetFilterCallback(targetForFilter)) &&
    143        isPageOrFrame &&
    144        !isExtension
    145      ) {
    146        this.#targetsIdsForInit.add(targetId);
    147      }
    148    }
    149  };
    150 
    151  async initialize(): Promise<void> {
    152    await this.#connection.send('Target.setDiscoverTargets', {
    153      discover: true,
    154      filter: this.#discoveryFilter,
    155    });
    156 
    157    this.#storeExistingTargetsForInit();
    158 
    159    await this.#connection.send('Target.setAutoAttach', {
    160      waitForDebuggerOnStart: true,
    161      flatten: true,
    162      autoAttach: true,
    163      filter: [
    164        {
    165          type: 'page',
    166          exclude: true,
    167        },
    168        ...this.#discoveryFilter,
    169      ],
    170    });
    171    this.#finishInitializationIfReady();
    172    await this.#initializeDeferred.valueOrThrow();
    173  }
    174 
    175  getChildTargets(target: CdpTarget): ReadonlySet<CdpTarget> {
    176    return target._childTargets();
    177  }
    178 
    179  dispose(): void {
    180    this.#connection.off('Target.targetCreated', this.#onTargetCreated);
    181    this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed);
    182    this.#connection.off('Target.targetInfoChanged', this.#onTargetInfoChanged);
    183    this.#connection.off(
    184      CDPSessionEvent.SessionDetached,
    185      this.#onSessionDetached,
    186    );
    187 
    188    this.#removeAttachmentListeners(this.#connection);
    189  }
    190 
    191  getAvailableTargets(): ReadonlyMap<string, CdpTarget> {
    192    return this.#attachedTargetsByTargetId;
    193  }
    194 
    195  #setupAttachmentListeners(session: CDPSession | Connection): void {
    196    const listener = (event: Protocol.Target.AttachedToTargetEvent) => {
    197      void this.#onAttachedToTarget(session, event);
    198    };
    199    assert(!this.#attachedToTargetListenersBySession.has(session));
    200    this.#attachedToTargetListenersBySession.set(session, listener);
    201    session.on('Target.attachedToTarget', listener);
    202 
    203    const detachedListener = (
    204      event: Protocol.Target.DetachedFromTargetEvent,
    205    ) => {
    206      return this.#onDetachedFromTarget(session, event);
    207    };
    208    assert(!this.#detachedFromTargetListenersBySession.has(session));
    209    this.#detachedFromTargetListenersBySession.set(session, detachedListener);
    210    session.on('Target.detachedFromTarget', detachedListener);
    211  }
    212 
    213  #removeAttachmentListeners(session: CDPSession | Connection): void {
    214    const listener = this.#attachedToTargetListenersBySession.get(session);
    215    if (listener) {
    216      session.off('Target.attachedToTarget', listener);
    217      this.#attachedToTargetListenersBySession.delete(session);
    218    }
    219 
    220    if (this.#detachedFromTargetListenersBySession.has(session)) {
    221      session.off(
    222        'Target.detachedFromTarget',
    223        this.#detachedFromTargetListenersBySession.get(session)!,
    224      );
    225      this.#detachedFromTargetListenersBySession.delete(session);
    226    }
    227  }
    228 
    229  #onSessionDetached = (session: CDPSession) => {
    230    this.#removeAttachmentListeners(session);
    231  };
    232 
    233  #onTargetCreated = async (event: Protocol.Target.TargetCreatedEvent) => {
    234    this.#discoveredTargetsByTargetId.set(
    235      event.targetInfo.targetId,
    236      event.targetInfo,
    237    );
    238 
    239    this.emit(TargetManagerEvent.TargetDiscovered, event.targetInfo);
    240 
    241    // The connection is already attached to the browser target implicitly,
    242    // therefore, no new CDPSession is created and we have special handling
    243    // here.
    244    if (event.targetInfo.type === 'browser' && event.targetInfo.attached) {
    245      if (this.#attachedTargetsByTargetId.has(event.targetInfo.targetId)) {
    246        return;
    247      }
    248      const target = this.#targetFactory(event.targetInfo, undefined);
    249      target._initialize();
    250      this.#attachedTargetsByTargetId.set(event.targetInfo.targetId, target);
    251    }
    252  };
    253 
    254  #onTargetDestroyed = (event: Protocol.Target.TargetDestroyedEvent) => {
    255    const targetInfo = this.#discoveredTargetsByTargetId.get(event.targetId);
    256    this.#discoveredTargetsByTargetId.delete(event.targetId);
    257    this.#finishInitializationIfReady(event.targetId);
    258    if (
    259      targetInfo?.type === 'service_worker' &&
    260      this.#attachedTargetsByTargetId.has(event.targetId)
    261    ) {
    262      // Special case for service workers: report TargetGone event when
    263      // the worker is destroyed.
    264      const target = this.#attachedTargetsByTargetId.get(event.targetId);
    265      if (target) {
    266        this.emit(TargetManagerEvent.TargetGone, target);
    267        this.#attachedTargetsByTargetId.delete(event.targetId);
    268      }
    269    }
    270  };
    271 
    272  #onTargetInfoChanged = (event: Protocol.Target.TargetInfoChangedEvent) => {
    273    this.#discoveredTargetsByTargetId.set(
    274      event.targetInfo.targetId,
    275      event.targetInfo,
    276    );
    277 
    278    if (
    279      this.#ignoredTargets.has(event.targetInfo.targetId) ||
    280      !this.#attachedTargetsByTargetId.has(event.targetInfo.targetId) ||
    281      !event.targetInfo.attached
    282    ) {
    283      return;
    284    }
    285 
    286    const target = this.#attachedTargetsByTargetId.get(
    287      event.targetInfo.targetId,
    288    );
    289    if (!target) {
    290      return;
    291    }
    292    const previousURL = target.url();
    293    const wasInitialized =
    294      target._initializedDeferred.value() === InitializationStatus.SUCCESS;
    295 
    296    if (isPageTargetBecomingPrimary(target, event.targetInfo)) {
    297      const session = target?._session();
    298      assert(
    299        session,
    300        'Target that is being activated is missing a CDPSession.',
    301      );
    302      session.parentSession()?.emit(CDPSessionEvent.Swapped, session);
    303    }
    304 
    305    target._targetInfoChanged(event.targetInfo);
    306 
    307    if (wasInitialized && previousURL !== target.url()) {
    308      this.emit(TargetManagerEvent.TargetChanged, {
    309        target,
    310        wasInitialized,
    311        previousURL,
    312      });
    313    }
    314  };
    315 
    316  #onAttachedToTarget = async (
    317    parentSession: Connection | CDPSession,
    318    event: Protocol.Target.AttachedToTargetEvent,
    319  ) => {
    320    const targetInfo = event.targetInfo;
    321    const session = this.#connection._session(event.sessionId);
    322    if (!session) {
    323      throw new Error(`Session ${event.sessionId} was not created.`);
    324    }
    325 
    326    const silentDetach = async () => {
    327      await session.send('Runtime.runIfWaitingForDebugger').catch(debugError);
    328      // We don't use `session.detach()` because that dispatches all commands on
    329      // the connection instead of the parent session.
    330      await parentSession
    331        .send('Target.detachFromTarget', {
    332          sessionId: session.id(),
    333        })
    334        .catch(debugError);
    335    };
    336 
    337    if (!this.#connection.isAutoAttached(targetInfo.targetId)) {
    338      return;
    339    }
    340 
    341    // Special case for service workers: being attached to service workers will
    342    // prevent them from ever being destroyed. Therefore, we silently detach
    343    // from service workers unless the connection was manually created via
    344    // `page.worker()`. To determine this, we use
    345    // `this.#connection.isAutoAttached(targetInfo.targetId)`. In the future, we
    346    // should determine if a target is auto-attached or not with the help of
    347    // CDP.
    348    if (targetInfo.type === 'service_worker') {
    349      this.#finishInitializationIfReady(targetInfo.targetId);
    350      await silentDetach();
    351      if (this.#attachedTargetsByTargetId.has(targetInfo.targetId)) {
    352        return;
    353      }
    354      const target = this.#targetFactory(targetInfo);
    355      target._initialize();
    356      this.#attachedTargetsByTargetId.set(targetInfo.targetId, target);
    357      this.emit(TargetManagerEvent.TargetAvailable, target);
    358      return;
    359    }
    360 
    361    const isExistingTarget = this.#attachedTargetsByTargetId.has(
    362      targetInfo.targetId,
    363    );
    364 
    365    const target = isExistingTarget
    366      ? this.#attachedTargetsByTargetId.get(targetInfo.targetId)!
    367      : this.#targetFactory(
    368          targetInfo,
    369          session,
    370          parentSession instanceof CdpCDPSession ? parentSession : undefined,
    371        );
    372 
    373    if (this.#targetFilterCallback && !this.#targetFilterCallback(target)) {
    374      this.#ignoredTargets.add(targetInfo.targetId);
    375      this.#finishInitializationIfReady(targetInfo.targetId);
    376      await silentDetach();
    377      return;
    378    }
    379 
    380    this.#setupAttachmentListeners(session);
    381 
    382    if (isExistingTarget) {
    383      session.setTarget(target);
    384      this.#attachedTargetsBySessionId.set(
    385        session.id(),
    386        this.#attachedTargetsByTargetId.get(targetInfo.targetId)!,
    387      );
    388    } else {
    389      target._initialize();
    390      this.#attachedTargetsByTargetId.set(targetInfo.targetId, target);
    391      this.#attachedTargetsBySessionId.set(session.id(), target);
    392    }
    393 
    394    const parentTarget =
    395      parentSession instanceof CDPSession
    396        ? (parentSession as CdpCDPSession).target()
    397        : null;
    398    parentTarget?._addChildTarget(target);
    399 
    400    parentSession.emit(CDPSessionEvent.Ready, session);
    401 
    402    this.#targetsIdsForInit.delete(target._targetId);
    403    if (!isExistingTarget) {
    404      this.emit(TargetManagerEvent.TargetAvailable, target);
    405    }
    406    this.#finishInitializationIfReady();
    407 
    408    // TODO: the browser might be shutting down here. What do we do with the
    409    // error?
    410    await Promise.all([
    411      session.send('Target.setAutoAttach', {
    412        waitForDebuggerOnStart: true,
    413        flatten: true,
    414        autoAttach: true,
    415        filter: this.#discoveryFilter,
    416      }),
    417      session.send('Runtime.runIfWaitingForDebugger'),
    418    ]).catch(debugError);
    419  };
    420 
    421  #finishInitializationIfReady(targetId?: string): void {
    422    if (targetId !== undefined) {
    423      this.#targetsIdsForInit.delete(targetId);
    424    }
    425    if (this.#targetsIdsForInit.size === 0) {
    426      this.#initializeDeferred.resolve();
    427    }
    428  }
    429 
    430  #onDetachedFromTarget = (
    431    parentSession: Connection | CDPSession,
    432    event: Protocol.Target.DetachedFromTargetEvent,
    433  ) => {
    434    const target = this.#attachedTargetsBySessionId.get(event.sessionId);
    435 
    436    this.#attachedTargetsBySessionId.delete(event.sessionId);
    437 
    438    if (!target) {
    439      return;
    440    }
    441 
    442    if (parentSession instanceof CDPSession) {
    443      (parentSession as CdpCDPSession).target()._removeChildTarget(target);
    444    }
    445    this.#attachedTargetsByTargetId.delete(target._targetId);
    446    this.emit(TargetManagerEvent.TargetGone, target);
    447  };
    448 }