tor-browser

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

DeviceRequestPrompt.ts (7457B)


      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 {CDPSession} from '../api/CDPSession.js';
     10 import type {WaitTimeoutOptions} from '../api/Page.js';
     11 import type {TimeoutSettings} from '../common/TimeoutSettings.js';
     12 import {assert} from '../util/assert.js';
     13 import {Deferred} from '../util/Deferred.js';
     14 
     15 /**
     16 * Device in a request prompt.
     17 *
     18 * @public
     19 */
     20 export class DeviceRequestPromptDevice {
     21  /**
     22   * Device id during a prompt.
     23   */
     24  id: string;
     25 
     26  /**
     27   * Device name as it appears in a prompt.
     28   */
     29  name: string;
     30 
     31  /**
     32   * @internal
     33   */
     34  constructor(id: string, name: string) {
     35    this.id = id;
     36    this.name = name;
     37  }
     38 }
     39 
     40 /**
     41 * Device request prompts let you respond to the page requesting for a device
     42 * through an API like WebBluetooth.
     43 *
     44 * @remarks
     45 * `DeviceRequestPrompt` instances are returned via the
     46 * {@link Page.waitForDevicePrompt} method.
     47 *
     48 * @example
     49 *
     50 * ```ts
     51 * const [devicePrompt] = Promise.all([
     52 *   page.waitForDevicePrompt(),
     53 *   page.click('#connect-bluetooth'),
     54 * ]);
     55 * await devicePrompt.select(
     56 *   await devicePrompt.waitForDevice(({name}) => name.includes('My Device')),
     57 * );
     58 * ```
     59 *
     60 * @public
     61 */
     62 export class DeviceRequestPrompt {
     63  #client: CDPSession | null;
     64  #timeoutSettings: TimeoutSettings;
     65  #id: string;
     66  #handled = false;
     67  #updateDevicesHandle = this.#updateDevices.bind(this);
     68  #waitForDevicePromises = new Set<{
     69    filter: (device: DeviceRequestPromptDevice) => boolean;
     70    promise: Deferred<DeviceRequestPromptDevice>;
     71  }>();
     72 
     73  /**
     74   * Current list of selectable devices.
     75   */
     76  devices: DeviceRequestPromptDevice[] = [];
     77 
     78  /**
     79   * @internal
     80   */
     81  constructor(
     82    client: CDPSession,
     83    timeoutSettings: TimeoutSettings,
     84    firstEvent: Protocol.DeviceAccess.DeviceRequestPromptedEvent,
     85  ) {
     86    this.#client = client;
     87    this.#timeoutSettings = timeoutSettings;
     88    this.#id = firstEvent.id;
     89 
     90    this.#client.on(
     91      'DeviceAccess.deviceRequestPrompted',
     92      this.#updateDevicesHandle,
     93    );
     94    this.#client.on('Target.detachedFromTarget', () => {
     95      this.#client = null;
     96    });
     97 
     98    this.#updateDevices(firstEvent);
     99  }
    100 
    101  #updateDevices(event: Protocol.DeviceAccess.DeviceRequestPromptedEvent) {
    102    if (event.id !== this.#id) {
    103      return;
    104    }
    105 
    106    for (const rawDevice of event.devices) {
    107      if (
    108        this.devices.some(device => {
    109          return device.id === rawDevice.id;
    110        })
    111      ) {
    112        continue;
    113      }
    114 
    115      const newDevice = new DeviceRequestPromptDevice(
    116        rawDevice.id,
    117        rawDevice.name,
    118      );
    119      this.devices.push(newDevice);
    120 
    121      for (const waitForDevicePromise of this.#waitForDevicePromises) {
    122        if (waitForDevicePromise.filter(newDevice)) {
    123          waitForDevicePromise.promise.resolve(newDevice);
    124        }
    125      }
    126    }
    127  }
    128 
    129  /**
    130   * Resolve to the first device in the prompt matching a filter.
    131   */
    132  async waitForDevice(
    133    filter: (device: DeviceRequestPromptDevice) => boolean,
    134    options: WaitTimeoutOptions = {},
    135  ): Promise<DeviceRequestPromptDevice> {
    136    for (const device of this.devices) {
    137      if (filter(device)) {
    138        return device;
    139      }
    140    }
    141 
    142    const {timeout = this.#timeoutSettings.timeout()} = options;
    143    const deferred = Deferred.create<DeviceRequestPromptDevice>({
    144      message: `Waiting for \`DeviceRequestPromptDevice\` failed: ${timeout}ms exceeded`,
    145      timeout,
    146    });
    147 
    148    if (options.signal) {
    149      options.signal.addEventListener(
    150        'abort',
    151        () => {
    152          deferred.reject(options.signal?.reason);
    153        },
    154        {once: true},
    155      );
    156    }
    157 
    158    const handle = {filter, promise: deferred};
    159    this.#waitForDevicePromises.add(handle);
    160    try {
    161      return await deferred.valueOrThrow();
    162    } finally {
    163      this.#waitForDevicePromises.delete(handle);
    164    }
    165  }
    166 
    167  /**
    168   * Select a device in the prompt's list.
    169   */
    170  async select(device: DeviceRequestPromptDevice): Promise<void> {
    171    assert(
    172      this.#client !== null,
    173      'Cannot select device through detached session!',
    174    );
    175    assert(this.devices.includes(device), 'Cannot select unknown device!');
    176    assert(
    177      !this.#handled,
    178      'Cannot select DeviceRequestPrompt which is already handled!',
    179    );
    180    this.#client.off(
    181      'DeviceAccess.deviceRequestPrompted',
    182      this.#updateDevicesHandle,
    183    );
    184    this.#handled = true;
    185    return await this.#client.send('DeviceAccess.selectPrompt', {
    186      id: this.#id,
    187      deviceId: device.id,
    188    });
    189  }
    190 
    191  /**
    192   * Cancel the prompt.
    193   */
    194  async cancel(): Promise<void> {
    195    assert(
    196      this.#client !== null,
    197      'Cannot cancel prompt through detached session!',
    198    );
    199    assert(
    200      !this.#handled,
    201      'Cannot cancel DeviceRequestPrompt which is already handled!',
    202    );
    203    this.#client.off(
    204      'DeviceAccess.deviceRequestPrompted',
    205      this.#updateDevicesHandle,
    206    );
    207    this.#handled = true;
    208    return await this.#client.send('DeviceAccess.cancelPrompt', {id: this.#id});
    209  }
    210 }
    211 
    212 /**
    213 * @internal
    214 */
    215 export class DeviceRequestPromptManager {
    216  #client: CDPSession | null;
    217  #timeoutSettings: TimeoutSettings;
    218  #deviceRequestPromptDeferreds = new Set<Deferred<DeviceRequestPrompt>>();
    219 
    220  /**
    221   * @internal
    222   */
    223  constructor(client: CDPSession, timeoutSettings: TimeoutSettings) {
    224    this.#client = client;
    225    this.#timeoutSettings = timeoutSettings;
    226 
    227    this.#client.on('DeviceAccess.deviceRequestPrompted', event => {
    228      this.#onDeviceRequestPrompted(event);
    229    });
    230    this.#client.on('Target.detachedFromTarget', () => {
    231      this.#client = null;
    232    });
    233  }
    234 
    235  /**
    236   * Wait for device prompt created by an action like calling WebBluetooth's
    237   * requestDevice.
    238   */
    239  async waitForDevicePrompt(
    240    options: WaitTimeoutOptions = {},
    241  ): Promise<DeviceRequestPrompt> {
    242    assert(
    243      this.#client !== null,
    244      'Cannot wait for device prompt through detached session!',
    245    );
    246    const needsEnable = this.#deviceRequestPromptDeferreds.size === 0;
    247    let enablePromise: Promise<void> | undefined;
    248    if (needsEnable) {
    249      enablePromise = this.#client.send('DeviceAccess.enable');
    250    }
    251 
    252    const {timeout = this.#timeoutSettings.timeout()} = options;
    253    const deferred = Deferred.create<DeviceRequestPrompt>({
    254      message: `Waiting for \`DeviceRequestPrompt\` failed: ${timeout}ms exceeded`,
    255      timeout,
    256    });
    257    if (options.signal) {
    258      options.signal.addEventListener(
    259        'abort',
    260        () => {
    261          deferred.reject(options.signal?.reason);
    262        },
    263        {once: true},
    264      );
    265    }
    266 
    267    this.#deviceRequestPromptDeferreds.add(deferred);
    268 
    269    try {
    270      const [result] = await Promise.all([
    271        deferred.valueOrThrow(),
    272        enablePromise,
    273      ]);
    274      return result;
    275    } finally {
    276      this.#deviceRequestPromptDeferreds.delete(deferred);
    277    }
    278  }
    279 
    280  /**
    281   * @internal
    282   */
    283  #onDeviceRequestPrompted(
    284    event: Protocol.DeviceAccess.DeviceRequestPromptedEvent,
    285  ) {
    286    if (!this.#deviceRequestPromptDeferreds.size) {
    287      return;
    288    }
    289 
    290    assert(this.#client !== null);
    291    const devicePrompt = new DeviceRequestPrompt(
    292      this.#client,
    293      this.#timeoutSettings,
    294      event,
    295    );
    296    for (const promise of this.#deviceRequestPromptDeferreds) {
    297      promise.resolve(devicePrompt);
    298    }
    299    this.#deviceRequestPromptDeferreds.clear();
    300  }
    301 }