EmulationManager.ts (13909B)
1 /** 2 * @license 3 * Copyright 2017 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 import type {Protocol} from 'devtools-protocol'; 7 8 import {type CDPSession, CDPSessionEvent} from '../api/CDPSession.js'; 9 import type {GeolocationOptions, MediaFeature} from '../api/Page.js'; 10 import {debugError} from '../common/util.js'; 11 import type {Viewport} from '../common/Viewport.js'; 12 import {assert} from '../util/assert.js'; 13 import {invokeAtMostOnceForArguments} from '../util/decorators.js'; 14 import {isErrorLike} from '../util/ErrorLike.js'; 15 16 interface ViewportState { 17 viewport?: Viewport; 18 active: boolean; 19 } 20 21 interface IdleOverridesState { 22 overrides?: { 23 isUserActive: boolean; 24 isScreenUnlocked: boolean; 25 }; 26 active: boolean; 27 } 28 29 interface TimezoneState { 30 timezoneId?: string; 31 active: boolean; 32 } 33 34 interface VisionDeficiencyState { 35 visionDeficiency?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type']; 36 active: boolean; 37 } 38 39 interface CpuThrottlingState { 40 factor?: number; 41 active: boolean; 42 } 43 44 interface MediaFeaturesState { 45 mediaFeatures?: MediaFeature[]; 46 active: boolean; 47 } 48 49 interface MediaTypeState { 50 type?: string; 51 active: boolean; 52 } 53 54 interface GeoLocationState { 55 geoLocation?: GeolocationOptions; 56 active: boolean; 57 } 58 59 interface DefaultBackgroundColorState { 60 color?: Protocol.DOM.RGBA; 61 active: boolean; 62 } 63 64 interface JavascriptEnabledState { 65 javaScriptEnabled: boolean; 66 active: boolean; 67 } 68 69 /** 70 * @internal 71 */ 72 export interface ClientProvider { 73 clients(): CDPSession[]; 74 registerState(state: EmulatedState<any>): void; 75 } 76 77 /** 78 * @internal 79 */ 80 export class EmulatedState<T extends {active: boolean}> { 81 #state: T; 82 #clientProvider: ClientProvider; 83 #updater: (client: CDPSession, state: T) => Promise<void>; 84 85 constructor( 86 initialState: T, 87 clientProvider: ClientProvider, 88 updater: (client: CDPSession, state: T) => Promise<void>, 89 ) { 90 this.#state = initialState; 91 this.#clientProvider = clientProvider; 92 this.#updater = updater; 93 this.#clientProvider.registerState(this); 94 } 95 96 async setState(state: T): Promise<void> { 97 this.#state = state; 98 await this.sync(); 99 } 100 101 get state(): T { 102 return this.#state; 103 } 104 105 async sync(): Promise<void> { 106 await Promise.all( 107 this.#clientProvider.clients().map(client => { 108 return this.#updater(client, this.#state); 109 }), 110 ); 111 } 112 } 113 114 /** 115 * @internal 116 */ 117 export class EmulationManager implements ClientProvider { 118 #client: CDPSession; 119 120 #emulatingMobile = false; 121 #hasTouch = false; 122 123 #states: Array<EmulatedState<any>> = []; 124 125 #viewportState = new EmulatedState<ViewportState>( 126 { 127 active: false, 128 }, 129 this, 130 this.#applyViewport, 131 ); 132 #idleOverridesState = new EmulatedState<IdleOverridesState>( 133 { 134 active: false, 135 }, 136 this, 137 this.#emulateIdleState, 138 ); 139 #timezoneState = new EmulatedState<TimezoneState>( 140 { 141 active: false, 142 }, 143 this, 144 this.#emulateTimezone, 145 ); 146 #visionDeficiencyState = new EmulatedState<VisionDeficiencyState>( 147 { 148 active: false, 149 }, 150 this, 151 this.#emulateVisionDeficiency, 152 ); 153 #cpuThrottlingState = new EmulatedState<CpuThrottlingState>( 154 { 155 active: false, 156 }, 157 this, 158 this.#emulateCpuThrottling, 159 ); 160 #mediaFeaturesState = new EmulatedState<MediaFeaturesState>( 161 { 162 active: false, 163 }, 164 this, 165 this.#emulateMediaFeatures, 166 ); 167 #mediaTypeState = new EmulatedState<MediaTypeState>( 168 { 169 active: false, 170 }, 171 this, 172 this.#emulateMediaType, 173 ); 174 #geoLocationState = new EmulatedState<GeoLocationState>( 175 { 176 active: false, 177 }, 178 this, 179 this.#setGeolocation, 180 ); 181 #defaultBackgroundColorState = new EmulatedState<DefaultBackgroundColorState>( 182 { 183 active: false, 184 }, 185 this, 186 this.#setDefaultBackgroundColor, 187 ); 188 #javascriptEnabledState = new EmulatedState<JavascriptEnabledState>( 189 { 190 javaScriptEnabled: true, 191 active: false, 192 }, 193 this, 194 this.#setJavaScriptEnabled, 195 ); 196 197 #secondaryClients = new Set<CDPSession>(); 198 199 constructor(client: CDPSession) { 200 this.#client = client; 201 } 202 203 updateClient(client: CDPSession): void { 204 this.#client = client; 205 this.#secondaryClients.delete(client); 206 } 207 208 registerState(state: EmulatedState<any>): void { 209 this.#states.push(state); 210 } 211 212 clients(): CDPSession[] { 213 return [this.#client, ...Array.from(this.#secondaryClients)]; 214 } 215 216 async registerSpeculativeSession(client: CDPSession): Promise<void> { 217 this.#secondaryClients.add(client); 218 client.once(CDPSessionEvent.Disconnected, () => { 219 this.#secondaryClients.delete(client); 220 }); 221 // We don't await here because we want to register all state changes before 222 // the target is unpaused. 223 void Promise.all( 224 this.#states.map(s => { 225 return s.sync().catch(debugError); 226 }), 227 ); 228 } 229 230 get javascriptEnabled(): boolean { 231 return this.#javascriptEnabledState.state.javaScriptEnabled; 232 } 233 234 async emulateViewport(viewport: Viewport | null): Promise<boolean> { 235 const currentState = this.#viewportState.state; 236 if (!viewport && !currentState.active) { 237 return false; 238 } 239 await this.#viewportState.setState( 240 viewport 241 ? { 242 viewport, 243 active: true, 244 } 245 : { 246 active: false, 247 }, 248 ); 249 250 const mobile = viewport?.isMobile || false; 251 const hasTouch = viewport?.hasTouch || false; 252 const reloadNeeded = 253 this.#emulatingMobile !== mobile || this.#hasTouch !== hasTouch; 254 this.#emulatingMobile = mobile; 255 this.#hasTouch = hasTouch; 256 257 return reloadNeeded; 258 } 259 260 @invokeAtMostOnceForArguments 261 async #applyViewport( 262 client: CDPSession, 263 viewportState: ViewportState, 264 ): Promise<void> { 265 if (!viewportState.viewport) { 266 await Promise.all([ 267 client.send('Emulation.clearDeviceMetricsOverride'), 268 client.send('Emulation.setTouchEmulationEnabled', { 269 enabled: false, 270 }), 271 ]).catch(debugError); 272 return; 273 } 274 const {viewport} = viewportState; 275 const mobile = viewport.isMobile || false; 276 const width = viewport.width; 277 const height = viewport.height; 278 const deviceScaleFactor = viewport.deviceScaleFactor ?? 1; 279 const screenOrientation: Protocol.Emulation.ScreenOrientation = 280 viewport.isLandscape 281 ? {angle: 90, type: 'landscapePrimary'} 282 : {angle: 0, type: 'portraitPrimary'}; 283 const hasTouch = viewport.hasTouch || false; 284 285 await Promise.all([ 286 client 287 .send('Emulation.setDeviceMetricsOverride', { 288 mobile, 289 width, 290 height, 291 deviceScaleFactor, 292 screenOrientation, 293 }) 294 .catch(err => { 295 if ( 296 err.message.includes('Target does not support metrics override') 297 ) { 298 debugError(err); 299 return; 300 } 301 throw err; 302 }), 303 client.send('Emulation.setTouchEmulationEnabled', { 304 enabled: hasTouch, 305 }), 306 ]); 307 } 308 309 async emulateIdleState(overrides?: { 310 isUserActive: boolean; 311 isScreenUnlocked: boolean; 312 }): Promise<void> { 313 await this.#idleOverridesState.setState({ 314 active: true, 315 overrides, 316 }); 317 } 318 319 @invokeAtMostOnceForArguments 320 async #emulateIdleState( 321 client: CDPSession, 322 idleStateState: IdleOverridesState, 323 ): Promise<void> { 324 if (!idleStateState.active) { 325 return; 326 } 327 if (idleStateState.overrides) { 328 await client.send('Emulation.setIdleOverride', { 329 isUserActive: idleStateState.overrides.isUserActive, 330 isScreenUnlocked: idleStateState.overrides.isScreenUnlocked, 331 }); 332 } else { 333 await client.send('Emulation.clearIdleOverride'); 334 } 335 } 336 337 @invokeAtMostOnceForArguments 338 async #emulateTimezone( 339 client: CDPSession, 340 timezoneState: TimezoneState, 341 ): Promise<void> { 342 if (!timezoneState.active) { 343 return; 344 } 345 try { 346 await client.send('Emulation.setTimezoneOverride', { 347 timezoneId: timezoneState.timezoneId || '', 348 }); 349 } catch (error) { 350 if (isErrorLike(error) && error.message.includes('Invalid timezone')) { 351 throw new Error(`Invalid timezone ID: ${timezoneState.timezoneId}`); 352 } 353 throw error; 354 } 355 } 356 357 async emulateTimezone(timezoneId?: string): Promise<void> { 358 await this.#timezoneState.setState({ 359 timezoneId, 360 active: true, 361 }); 362 } 363 364 @invokeAtMostOnceForArguments 365 async #emulateVisionDeficiency( 366 client: CDPSession, 367 visionDeficiency: VisionDeficiencyState, 368 ): Promise<void> { 369 if (!visionDeficiency.active) { 370 return; 371 } 372 await client.send('Emulation.setEmulatedVisionDeficiency', { 373 type: visionDeficiency.visionDeficiency || 'none', 374 }); 375 } 376 377 async emulateVisionDeficiency( 378 type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'], 379 ): Promise<void> { 380 const visionDeficiencies = new Set< 381 Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] 382 >([ 383 'none', 384 'achromatopsia', 385 'blurredVision', 386 'deuteranopia', 387 'protanopia', 388 'reducedContrast', 389 'tritanopia', 390 ]); 391 assert( 392 !type || visionDeficiencies.has(type), 393 `Unsupported vision deficiency: ${type}`, 394 ); 395 await this.#visionDeficiencyState.setState({ 396 active: true, 397 visionDeficiency: type, 398 }); 399 } 400 401 @invokeAtMostOnceForArguments 402 async #emulateCpuThrottling( 403 client: CDPSession, 404 state: CpuThrottlingState, 405 ): Promise<void> { 406 if (!state.active) { 407 return; 408 } 409 await client.send('Emulation.setCPUThrottlingRate', { 410 rate: state.factor ?? 1, 411 }); 412 } 413 414 async emulateCPUThrottling(factor: number | null): Promise<void> { 415 assert( 416 factor === null || factor >= 1, 417 'Throttling rate should be greater or equal to 1', 418 ); 419 await this.#cpuThrottlingState.setState({ 420 active: true, 421 factor: factor ?? undefined, 422 }); 423 } 424 425 @invokeAtMostOnceForArguments 426 async #emulateMediaFeatures( 427 client: CDPSession, 428 state: MediaFeaturesState, 429 ): Promise<void> { 430 if (!state.active) { 431 return; 432 } 433 await client.send('Emulation.setEmulatedMedia', { 434 features: state.mediaFeatures, 435 }); 436 } 437 438 async emulateMediaFeatures(features?: MediaFeature[]): Promise<void> { 439 if (Array.isArray(features)) { 440 for (const mediaFeature of features) { 441 const name = mediaFeature.name; 442 assert( 443 /^(?:prefers-(?:color-scheme|reduced-motion)|color-gamut)$/.test( 444 name, 445 ), 446 'Unsupported media feature: ' + name, 447 ); 448 } 449 } 450 await this.#mediaFeaturesState.setState({ 451 active: true, 452 mediaFeatures: features, 453 }); 454 } 455 456 @invokeAtMostOnceForArguments 457 async #emulateMediaType( 458 client: CDPSession, 459 state: MediaTypeState, 460 ): Promise<void> { 461 if (!state.active) { 462 return; 463 } 464 await client.send('Emulation.setEmulatedMedia', { 465 media: state.type || '', 466 }); 467 } 468 469 async emulateMediaType(type?: string): Promise<void> { 470 assert( 471 type === 'screen' || 472 type === 'print' || 473 (type ?? undefined) === undefined, 474 'Unsupported media type: ' + type, 475 ); 476 await this.#mediaTypeState.setState({ 477 type, 478 active: true, 479 }); 480 } 481 482 @invokeAtMostOnceForArguments 483 async #setGeolocation( 484 client: CDPSession, 485 state: GeoLocationState, 486 ): Promise<void> { 487 if (!state.active) { 488 return; 489 } 490 await client.send( 491 'Emulation.setGeolocationOverride', 492 state.geoLocation 493 ? { 494 longitude: state.geoLocation.longitude, 495 latitude: state.geoLocation.latitude, 496 accuracy: state.geoLocation.accuracy, 497 } 498 : undefined, 499 ); 500 } 501 502 async setGeolocation(options: GeolocationOptions): Promise<void> { 503 const {longitude, latitude, accuracy = 0} = options; 504 if (longitude < -180 || longitude > 180) { 505 throw new Error( 506 `Invalid longitude "${longitude}": precondition -180 <= LONGITUDE <= 180 failed.`, 507 ); 508 } 509 if (latitude < -90 || latitude > 90) { 510 throw new Error( 511 `Invalid latitude "${latitude}": precondition -90 <= LATITUDE <= 90 failed.`, 512 ); 513 } 514 if (accuracy < 0) { 515 throw new Error( 516 `Invalid accuracy "${accuracy}": precondition 0 <= ACCURACY failed.`, 517 ); 518 } 519 await this.#geoLocationState.setState({ 520 active: true, 521 geoLocation: { 522 longitude, 523 latitude, 524 accuracy, 525 }, 526 }); 527 } 528 529 @invokeAtMostOnceForArguments 530 async #setDefaultBackgroundColor( 531 client: CDPSession, 532 state: DefaultBackgroundColorState, 533 ): Promise<void> { 534 if (!state.active) { 535 return; 536 } 537 await client.send('Emulation.setDefaultBackgroundColorOverride', { 538 color: state.color, 539 }); 540 } 541 542 /** 543 * Resets default white background 544 */ 545 async resetDefaultBackgroundColor(): Promise<void> { 546 await this.#defaultBackgroundColorState.setState({ 547 active: true, 548 color: undefined, 549 }); 550 } 551 552 /** 553 * Hides default white background 554 */ 555 async setTransparentBackgroundColor(): Promise<void> { 556 await this.#defaultBackgroundColorState.setState({ 557 active: true, 558 color: {r: 0, g: 0, b: 0, a: 0}, 559 }); 560 } 561 562 @invokeAtMostOnceForArguments 563 async #setJavaScriptEnabled( 564 client: CDPSession, 565 state: JavascriptEnabledState, 566 ): Promise<void> { 567 if (!state.active) { 568 return; 569 } 570 await client.send('Emulation.setScriptExecutionDisabled', { 571 value: !state.javaScriptEnabled, 572 }); 573 } 574 575 async setJavaScriptEnabled(enabled: boolean): Promise<void> { 576 await this.#javascriptEnabledState.setState({ 577 active: true, 578 javaScriptEnabled: enabled, 579 }); 580 } 581 }