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 }