tor-browser

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

FrameManager.ts (17982B)


      1 /**
      2 * @license
      3 * Copyright 2017 Google Inc.
      4 * SPDX-License-Identifier: Apache-2.0
      5 */
      6 
      7 import type {Protocol} from 'devtools-protocol';
      8 
      9 import {type CDPSession, CDPSessionEvent} from '../api/CDPSession.js';
     10 import {FrameEvent} from '../api/Frame.js';
     11 import type {NewDocumentScriptEvaluation} from '../api/Page.js';
     12 import {EventEmitter} from '../common/EventEmitter.js';
     13 import type {TimeoutSettings} from '../common/TimeoutSettings.js';
     14 import {debugError, PuppeteerURL, UTILITY_WORLD_NAME} from '../common/util.js';
     15 import {assert} from '../util/assert.js';
     16 import {Deferred} from '../util/Deferred.js';
     17 import {disposeSymbol} from '../util/disposable.js';
     18 import {isErrorLike} from '../util/ErrorLike.js';
     19 
     20 import type {Binding} from './Binding.js';
     21 import {CdpPreloadScript} from './CdpPreloadScript.js';
     22 import type {CdpCDPSession} from './CdpSession.js';
     23 import {isTargetClosedError} from './Connection.js';
     24 import {DeviceRequestPromptManager} from './DeviceRequestPrompt.js';
     25 import {ExecutionContext} from './ExecutionContext.js';
     26 import {CdpFrame} from './Frame.js';
     27 import type {FrameManagerEvents} from './FrameManagerEvents.js';
     28 import {FrameManagerEvent} from './FrameManagerEvents.js';
     29 import {FrameTree} from './FrameTree.js';
     30 import type {IsolatedWorld} from './IsolatedWorld.js';
     31 import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js';
     32 import {NetworkManager} from './NetworkManager.js';
     33 import type {CdpPage} from './Page.js';
     34 import type {CdpTarget} from './Target.js';
     35 
     36 const TIME_FOR_WAITING_FOR_SWAP = 100; // ms.
     37 
     38 /**
     39 * A frame manager manages the frames for a given {@link Page | page}.
     40 *
     41 * @internal
     42 */
     43 export class FrameManager extends EventEmitter<FrameManagerEvents> {
     44  #page: CdpPage;
     45  #networkManager: NetworkManager;
     46  #timeoutSettings: TimeoutSettings;
     47  #isolatedWorlds = new Set<string>();
     48  #client: CdpCDPSession;
     49  #scriptsToEvaluateOnNewDocument = new Map<string, CdpPreloadScript>();
     50  #bindings = new Set<Binding>();
     51 
     52  _frameTree = new FrameTree<CdpFrame>();
     53 
     54  /**
     55   * Set of frame IDs stored to indicate if a frame has received a
     56   * frameNavigated event so that frame tree responses could be ignored as the
     57   * frameNavigated event usually contains the latest information.
     58   */
     59  #frameNavigatedReceived = new Set<string>();
     60 
     61  #deviceRequestPromptManagerMap = new WeakMap<
     62    CDPSession,
     63    DeviceRequestPromptManager
     64  >();
     65 
     66  #frameTreeHandled?: Deferred<void>;
     67 
     68  get timeoutSettings(): TimeoutSettings {
     69    return this.#timeoutSettings;
     70  }
     71 
     72  get networkManager(): NetworkManager {
     73    return this.#networkManager;
     74  }
     75 
     76  get client(): CdpCDPSession {
     77    return this.#client;
     78  }
     79 
     80  constructor(
     81    client: CdpCDPSession,
     82    page: CdpPage,
     83    timeoutSettings: TimeoutSettings,
     84  ) {
     85    super();
     86    this.#client = client;
     87    this.#page = page;
     88    this.#networkManager = new NetworkManager(this);
     89    this.#timeoutSettings = timeoutSettings;
     90    this.setupEventListeners(this.#client);
     91    client.once(CDPSessionEvent.Disconnected, () => {
     92      this.#onClientDisconnect().catch(debugError);
     93    });
     94  }
     95 
     96  /**
     97   * Called when the frame's client is disconnected. We don't know if the
     98   * disconnect means that the frame is removed or if it will be replaced by a
     99   * new frame. Therefore, we wait for a swap event.
    100   */
    101  async #onClientDisconnect() {
    102    const mainFrame = this._frameTree.getMainFrame();
    103    if (!mainFrame) {
    104      return;
    105    }
    106 
    107    if (!this.#page.browser().connected) {
    108      // If the browser is not connected we know
    109      // that activation will not happen
    110      this.#removeFramesRecursively(mainFrame);
    111      return;
    112    }
    113 
    114    for (const child of mainFrame.childFrames()) {
    115      this.#removeFramesRecursively(child);
    116    }
    117    const swapped = Deferred.create<void>({
    118      timeout: TIME_FOR_WAITING_FOR_SWAP,
    119      message: 'Frame was not swapped',
    120    });
    121    mainFrame.once(FrameEvent.FrameSwappedByActivation, () => {
    122      swapped.resolve();
    123    });
    124    try {
    125      await swapped.valueOrThrow();
    126    } catch {
    127      this.#removeFramesRecursively(mainFrame);
    128    }
    129  }
    130 
    131  /**
    132   * When the main frame is replaced by another main frame,
    133   * we maintain the main frame object identity while updating
    134   * its frame tree and ID.
    135   */
    136  async swapFrameTree(client: CdpCDPSession): Promise<void> {
    137    this.#client = client;
    138    const frame = this._frameTree.getMainFrame();
    139    if (frame) {
    140      this.#frameNavigatedReceived.add(this.#client.target()._targetId);
    141      this._frameTree.removeFrame(frame);
    142      frame.updateId(this.#client.target()._targetId);
    143      this._frameTree.addFrame(frame);
    144      frame.updateClient(client);
    145    }
    146    this.setupEventListeners(client);
    147    client.once(CDPSessionEvent.Disconnected, () => {
    148      this.#onClientDisconnect().catch(debugError);
    149    });
    150    await this.initialize(client, frame);
    151    await this.#networkManager.addClient(client);
    152    if (frame) {
    153      frame.emit(FrameEvent.FrameSwappedByActivation, undefined);
    154    }
    155  }
    156 
    157  async registerSpeculativeSession(client: CdpCDPSession): Promise<void> {
    158    await this.#networkManager.addClient(client);
    159  }
    160 
    161  private setupEventListeners(session: CDPSession) {
    162    session.on('Page.frameAttached', async event => {
    163      await this.#frameTreeHandled?.valueOrThrow();
    164      this.#onFrameAttached(session, event.frameId, event.parentFrameId);
    165    });
    166    session.on('Page.frameNavigated', async event => {
    167      this.#frameNavigatedReceived.add(event.frame.id);
    168      await this.#frameTreeHandled?.valueOrThrow();
    169      void this.#onFrameNavigated(event.frame, event.type);
    170    });
    171    session.on('Page.navigatedWithinDocument', async event => {
    172      await this.#frameTreeHandled?.valueOrThrow();
    173      this.#onFrameNavigatedWithinDocument(event.frameId, event.url);
    174    });
    175    session.on(
    176      'Page.frameDetached',
    177      async (event: Protocol.Page.FrameDetachedEvent) => {
    178        await this.#frameTreeHandled?.valueOrThrow();
    179        this.#onFrameDetached(
    180          event.frameId,
    181          event.reason as Protocol.Page.FrameDetachedEventReason,
    182        );
    183      },
    184    );
    185    session.on('Page.frameStartedLoading', async event => {
    186      await this.#frameTreeHandled?.valueOrThrow();
    187      this.#onFrameStartedLoading(event.frameId);
    188    });
    189    session.on('Page.frameStoppedLoading', async event => {
    190      await this.#frameTreeHandled?.valueOrThrow();
    191      this.#onFrameStoppedLoading(event.frameId);
    192    });
    193    session.on('Runtime.executionContextCreated', async event => {
    194      await this.#frameTreeHandled?.valueOrThrow();
    195      this.#onExecutionContextCreated(event.context, session);
    196    });
    197    session.on('Page.lifecycleEvent', async event => {
    198      await this.#frameTreeHandled?.valueOrThrow();
    199      this.#onLifecycleEvent(event);
    200    });
    201  }
    202 
    203  async initialize(client: CDPSession, frame?: CdpFrame | null): Promise<void> {
    204    try {
    205      this.#frameTreeHandled?.resolve();
    206      this.#frameTreeHandled = Deferred.create();
    207      // We need to schedule all these commands while the target is paused,
    208      // therefore, it needs to happen synchronously. At the same time we
    209      // should not start processing execution context and frame events before
    210      // we received the initial information about the frame tree.
    211      await Promise.all([
    212        this.#networkManager.addClient(client),
    213        client.send('Page.enable'),
    214        client.send('Page.getFrameTree').then(({frameTree}) => {
    215          this.#handleFrameTree(client, frameTree);
    216          this.#frameTreeHandled?.resolve();
    217        }),
    218        client.send('Page.setLifecycleEventsEnabled', {enabled: true}),
    219        client.send('Runtime.enable').then(() => {
    220          return this.#createIsolatedWorld(client, UTILITY_WORLD_NAME);
    221        }),
    222        ...(frame
    223          ? Array.from(this.#scriptsToEvaluateOnNewDocument.values())
    224          : []
    225        ).map(script => {
    226          return frame?.addPreloadScript(script);
    227        }),
    228        ...(frame ? Array.from(this.#bindings.values()) : []).map(binding => {
    229          return frame?.addExposedFunctionBinding(binding);
    230        }),
    231      ]);
    232    } catch (error) {
    233      this.#frameTreeHandled?.resolve();
    234      // The target might have been closed before the initialization finished.
    235      if (isErrorLike(error) && isTargetClosedError(error)) {
    236        return;
    237      }
    238 
    239      throw error;
    240    }
    241  }
    242 
    243  page(): CdpPage {
    244    return this.#page;
    245  }
    246 
    247  mainFrame(): CdpFrame {
    248    const mainFrame = this._frameTree.getMainFrame();
    249    assert(mainFrame, 'Requesting main frame too early!');
    250    return mainFrame;
    251  }
    252 
    253  frames(): CdpFrame[] {
    254    return Array.from(this._frameTree.frames());
    255  }
    256 
    257  frame(frameId: string): CdpFrame | null {
    258    return this._frameTree.getById(frameId) || null;
    259  }
    260 
    261  async addExposedFunctionBinding(binding: Binding): Promise<void> {
    262    this.#bindings.add(binding);
    263    await Promise.all(
    264      this.frames().map(async frame => {
    265        return await frame.addExposedFunctionBinding(binding);
    266      }),
    267    );
    268  }
    269 
    270  async removeExposedFunctionBinding(binding: Binding): Promise<void> {
    271    this.#bindings.delete(binding);
    272    await Promise.all(
    273      this.frames().map(async frame => {
    274        return await frame.removeExposedFunctionBinding(binding);
    275      }),
    276    );
    277  }
    278 
    279  async evaluateOnNewDocument(
    280    source: string,
    281  ): Promise<NewDocumentScriptEvaluation> {
    282    const {identifier} = await this.mainFrame()
    283      ._client()
    284      .send('Page.addScriptToEvaluateOnNewDocument', {
    285        source,
    286      });
    287 
    288    const preloadScript = new CdpPreloadScript(
    289      this.mainFrame(),
    290      identifier,
    291      source,
    292    );
    293 
    294    this.#scriptsToEvaluateOnNewDocument.set(identifier, preloadScript);
    295 
    296    await Promise.all(
    297      this.frames().map(async frame => {
    298        return await frame.addPreloadScript(preloadScript);
    299      }),
    300    );
    301 
    302    return {identifier};
    303  }
    304 
    305  async removeScriptToEvaluateOnNewDocument(identifier: string): Promise<void> {
    306    const preloadScript = this.#scriptsToEvaluateOnNewDocument.get(identifier);
    307    if (!preloadScript) {
    308      throw new Error(
    309        `Script to evaluate on new document with id ${identifier} not found`,
    310      );
    311    }
    312 
    313    this.#scriptsToEvaluateOnNewDocument.delete(identifier);
    314 
    315    await Promise.all(
    316      this.frames().map(frame => {
    317        const identifier = preloadScript.getIdForFrame(frame);
    318        if (!identifier) {
    319          return;
    320        }
    321        return frame
    322          ._client()
    323          .send('Page.removeScriptToEvaluateOnNewDocument', {
    324            identifier,
    325          })
    326          .catch(debugError);
    327      }),
    328    );
    329  }
    330 
    331  onAttachedToTarget(target: CdpTarget): void {
    332    if (target._getTargetInfo().type !== 'iframe') {
    333      return;
    334    }
    335 
    336    const frame = this.frame(target._getTargetInfo().targetId);
    337    if (frame) {
    338      frame.updateClient(target._session()!);
    339    }
    340    this.setupEventListeners(target._session()!);
    341    void this.initialize(target._session()!, frame);
    342  }
    343 
    344  _deviceRequestPromptManager(client: CDPSession): DeviceRequestPromptManager {
    345    let manager = this.#deviceRequestPromptManagerMap.get(client);
    346    if (manager === undefined) {
    347      manager = new DeviceRequestPromptManager(client, this.#timeoutSettings);
    348      this.#deviceRequestPromptManagerMap.set(client, manager);
    349    }
    350    return manager;
    351  }
    352 
    353  #onLifecycleEvent(event: Protocol.Page.LifecycleEventEvent): void {
    354    const frame = this.frame(event.frameId);
    355    if (!frame) {
    356      return;
    357    }
    358    frame._onLifecycleEvent(event.loaderId, event.name);
    359    this.emit(FrameManagerEvent.LifecycleEvent, frame);
    360    frame.emit(FrameEvent.LifecycleEvent, undefined);
    361  }
    362 
    363  #onFrameStartedLoading(frameId: string): void {
    364    const frame = this.frame(frameId);
    365    if (!frame) {
    366      return;
    367    }
    368    frame._onLoadingStarted();
    369  }
    370 
    371  #onFrameStoppedLoading(frameId: string): void {
    372    const frame = this.frame(frameId);
    373    if (!frame) {
    374      return;
    375    }
    376    frame._onLoadingStopped();
    377    this.emit(FrameManagerEvent.LifecycleEvent, frame);
    378    frame.emit(FrameEvent.LifecycleEvent, undefined);
    379  }
    380 
    381  #handleFrameTree(
    382    session: CDPSession,
    383    frameTree: Protocol.Page.FrameTree,
    384  ): void {
    385    if (frameTree.frame.parentId) {
    386      this.#onFrameAttached(
    387        session,
    388        frameTree.frame.id,
    389        frameTree.frame.parentId,
    390      );
    391    }
    392    if (!this.#frameNavigatedReceived.has(frameTree.frame.id)) {
    393      void this.#onFrameNavigated(frameTree.frame, 'Navigation');
    394    } else {
    395      this.#frameNavigatedReceived.delete(frameTree.frame.id);
    396    }
    397 
    398    if (!frameTree.childFrames) {
    399      return;
    400    }
    401 
    402    for (const child of frameTree.childFrames) {
    403      this.#handleFrameTree(session, child);
    404    }
    405  }
    406 
    407  #onFrameAttached(
    408    session: CDPSession,
    409    frameId: string,
    410    parentFrameId: string,
    411  ): void {
    412    let frame = this.frame(frameId);
    413    if (frame) {
    414      const parentFrame = this.frame(parentFrameId);
    415      if (session && parentFrame && frame.client !== parentFrame?.client) {
    416        // If an OOP iframes becomes a normal iframe
    417        // again it is first attached to the parent frame before the
    418        // target is removed.
    419        frame.updateClient(session);
    420      }
    421      return;
    422    }
    423 
    424    frame = new CdpFrame(this, frameId, parentFrameId, session);
    425    this._frameTree.addFrame(frame);
    426    this.emit(FrameManagerEvent.FrameAttached, frame);
    427  }
    428 
    429  async #onFrameNavigated(
    430    framePayload: Protocol.Page.Frame,
    431    navigationType: Protocol.Page.NavigationType,
    432  ): Promise<void> {
    433    const frameId = framePayload.id;
    434    const isMainFrame = !framePayload.parentId;
    435 
    436    let frame = this._frameTree.getById(frameId);
    437 
    438    // Detach all child frames first.
    439    if (frame) {
    440      for (const child of frame.childFrames()) {
    441        this.#removeFramesRecursively(child);
    442      }
    443    }
    444 
    445    // Update or create main frame.
    446    if (isMainFrame) {
    447      if (frame) {
    448        // Update frame id to retain frame identity on cross-process navigation.
    449        this._frameTree.removeFrame(frame);
    450        frame._id = frameId;
    451      } else {
    452        // Initial main frame navigation.
    453        frame = new CdpFrame(this, frameId, undefined, this.#client);
    454      }
    455      this._frameTree.addFrame(frame);
    456    }
    457 
    458    frame = await this._frameTree.waitForFrame(frameId);
    459    frame._navigated(framePayload);
    460    this.emit(FrameManagerEvent.FrameNavigated, frame);
    461    frame.emit(FrameEvent.FrameNavigated, navigationType);
    462  }
    463 
    464  async #createIsolatedWorld(session: CDPSession, name: string): Promise<void> {
    465    const key = `${session.id()}:${name}`;
    466 
    467    if (this.#isolatedWorlds.has(key)) {
    468      return;
    469    }
    470 
    471    await session.send('Page.addScriptToEvaluateOnNewDocument', {
    472      source: `//# sourceURL=${PuppeteerURL.INTERNAL_URL}`,
    473      worldName: name,
    474    });
    475 
    476    await Promise.all(
    477      this.frames()
    478        .filter(frame => {
    479          return frame.client === session;
    480        })
    481        .map(frame => {
    482          // Frames might be removed before we send this, so we don't want to
    483          // throw an error.
    484          return session
    485            .send('Page.createIsolatedWorld', {
    486              frameId: frame._id,
    487              worldName: name,
    488              grantUniveralAccess: true,
    489            })
    490            .catch(debugError);
    491        }),
    492    );
    493 
    494    this.#isolatedWorlds.add(key);
    495  }
    496 
    497  #onFrameNavigatedWithinDocument(frameId: string, url: string): void {
    498    const frame = this.frame(frameId);
    499    if (!frame) {
    500      return;
    501    }
    502    frame._navigatedWithinDocument(url);
    503    this.emit(FrameManagerEvent.FrameNavigatedWithinDocument, frame);
    504    frame.emit(FrameEvent.FrameNavigatedWithinDocument, undefined);
    505    this.emit(FrameManagerEvent.FrameNavigated, frame);
    506    frame.emit(FrameEvent.FrameNavigated, 'Navigation');
    507  }
    508 
    509  #onFrameDetached(
    510    frameId: string,
    511    reason: Protocol.Page.FrameDetachedEventReason,
    512  ): void {
    513    const frame = this.frame(frameId);
    514    if (!frame) {
    515      return;
    516    }
    517    switch (reason) {
    518      case 'remove':
    519        // Only remove the frame if the reason for the detached event is
    520        // an actual removement of the frame.
    521        // For frames that become OOP iframes, the reason would be 'swap'.
    522        this.#removeFramesRecursively(frame);
    523        break;
    524      case 'swap':
    525        this.emit(FrameManagerEvent.FrameSwapped, frame);
    526        frame.emit(FrameEvent.FrameSwapped, undefined);
    527        break;
    528    }
    529  }
    530 
    531  #onExecutionContextCreated(
    532    contextPayload: Protocol.Runtime.ExecutionContextDescription,
    533    session: CDPSession,
    534  ): void {
    535    const auxData = contextPayload.auxData as {frameId?: string} | undefined;
    536    const frameId = auxData && auxData.frameId;
    537    const frame = typeof frameId === 'string' ? this.frame(frameId) : undefined;
    538    let world: IsolatedWorld | undefined;
    539    if (frame) {
    540      // Only care about execution contexts created for the current session.
    541      if (frame.client !== session) {
    542        return;
    543      }
    544      if (contextPayload.auxData && contextPayload.auxData['isDefault']) {
    545        world = frame.worlds[MAIN_WORLD];
    546      } else if (contextPayload.name === UTILITY_WORLD_NAME) {
    547        // In case of multiple sessions to the same target, there's a race between
    548        // connections so we might end up creating multiple isolated worlds.
    549        // We can use either.
    550        world = frame.worlds[PUPPETEER_WORLD];
    551      }
    552    }
    553    // If there is no world, the context is not meant to be handled by us.
    554    if (!world) {
    555      return;
    556    }
    557    const context = new ExecutionContext(
    558      frame?.client || this.#client,
    559      contextPayload,
    560      world,
    561    );
    562    world.setContext(context);
    563  }
    564 
    565  #removeFramesRecursively(frame: CdpFrame): void {
    566    for (const child of frame.childFrames()) {
    567      this.#removeFramesRecursively(child);
    568    }
    569    frame[disposeSymbol]();
    570    this._frameTree.removeFrame(frame);
    571    this.emit(FrameManagerEvent.FrameDetached, frame);
    572    frame.emit(FrameEvent.FrameDetached, frame);
    573  }
    574 }