tor-browser

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

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 }