UNPKG

chrome-devtools-frontend

Version:
577 lines (506 loc) • 22.3 kB
// Copyright 2017 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as Common from '../common/common.js'; import type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js'; import * as Protocol from '../../generated/protocol.js'; import {CSSModel} from './CSSModel.js'; import {MultitargetNetworkManager} from './NetworkManager.js'; import {Events, OverlayModel} from './OverlayModel.js'; import {Capability, type Target} from './Target.js'; import {SDKModel} from './SDKModel.js'; export class EmulationModel extends SDKModel<void> { readonly #emulationAgent: ProtocolProxyApi.EmulationApi; readonly #deviceOrientationAgent: ProtocolProxyApi.DeviceOrientationApi; #cssModel: CSSModel|null; readonly #overlayModelInternal: OverlayModel|null; readonly #mediaConfiguration: Map<string, string>; #touchEnabled: boolean; #touchMobile: boolean; #touchEmulationAllowed: boolean; #customTouchEnabled: boolean; #touchConfiguration: { enabled: boolean, configuration: Protocol.Emulation.SetEmitTouchEventsForMouseRequestConfiguration, }; constructor(target: Target) { super(target); this.#emulationAgent = target.emulationAgent(); this.#deviceOrientationAgent = target.deviceOrientationAgent(); this.#cssModel = target.model(CSSModel); this.#overlayModelInternal = target.model(OverlayModel); if (this.#overlayModelInternal) { this.#overlayModelInternal.addEventListener(Events.InspectModeWillBeToggled, () => { void this.updateTouch(); }, this); } const disableJavascriptSetting = Common.Settings.Settings.instance().moduleSetting('javaScriptDisabled'); disableJavascriptSetting.addChangeListener( async () => await this.#emulationAgent.invoke_setScriptExecutionDisabled({value: disableJavascriptSetting.get()})); if (disableJavascriptSetting.get()) { void this.#emulationAgent.invoke_setScriptExecutionDisabled({value: true}); } const touchSetting = Common.Settings.Settings.instance().moduleSetting('emulation.touch'); touchSetting.addChangeListener(() => { const settingValue = touchSetting.get(); void this.overrideEmulateTouch(settingValue === 'force'); }); const idleDetectionSetting = Common.Settings.Settings.instance().moduleSetting('emulation.idleDetection'); idleDetectionSetting.addChangeListener(async () => { const settingValue = idleDetectionSetting.get(); if (settingValue === 'none') { await this.clearIdleOverride(); return; } const emulationParams = (JSON.parse(settingValue) as { isUserActive: boolean, isScreenUnlocked: boolean, }); await this.setIdleOverride(emulationParams); }); const mediaTypeSetting = Common.Settings.Settings.instance().moduleSetting<string>('emulatedCSSMedia'); const mediaFeatureColorGamutSetting = Common.Settings.Settings.instance().moduleSetting<string>('emulatedCSSMediaFeatureColorGamut'); const mediaFeaturePrefersColorSchemeSetting = Common.Settings.Settings.instance().moduleSetting<string>('emulatedCSSMediaFeaturePrefersColorScheme'); const mediaFeatureForcedColorsSetting = Common.Settings.Settings.instance().moduleSetting('emulatedCSSMediaFeatureForcedColors'); const mediaFeaturePrefersContrastSetting = Common.Settings.Settings.instance().moduleSetting<string>('emulatedCSSMediaFeaturePrefersContrast'); const mediaFeaturePrefersReducedDataSetting = Common.Settings.Settings.instance().moduleSetting<string>('emulatedCSSMediaFeaturePrefersReducedData'); const mediaFeaturePrefersReducedMotionSetting = Common.Settings.Settings.instance().moduleSetting<string>('emulatedCSSMediaFeaturePrefersReducedMotion'); // Note: this uses a different format than what the CDP API expects, // because we want to update these values per media type/feature // without having to search the `features` array (inefficient) or // hardcoding the indices (not readable/maintainable). this.#mediaConfiguration = new Map([ ['type', mediaTypeSetting.get()], ['color-gamut', mediaFeatureColorGamutSetting.get()], ['prefers-color-scheme', mediaFeaturePrefersColorSchemeSetting.get()], ['forced-colors', mediaFeatureForcedColorsSetting.get()], ['prefers-contrast', mediaFeaturePrefersContrastSetting.get()], ['prefers-reduced-data', mediaFeaturePrefersReducedDataSetting.get()], ['prefers-reduced-motion', mediaFeaturePrefersReducedMotionSetting.get()], ]); mediaTypeSetting.addChangeListener(() => { this.#mediaConfiguration.set('type', mediaTypeSetting.get()); void this.updateCssMedia(); }); mediaFeatureColorGamutSetting.addChangeListener(() => { this.#mediaConfiguration.set('color-gamut', mediaFeatureColorGamutSetting.get()); void this.updateCssMedia(); }); mediaFeaturePrefersColorSchemeSetting.addChangeListener(() => { this.#mediaConfiguration.set('prefers-color-scheme', mediaFeaturePrefersColorSchemeSetting.get()); void this.updateCssMedia(); }); mediaFeatureForcedColorsSetting.addChangeListener(() => { this.#mediaConfiguration.set('forced-colors', mediaFeatureForcedColorsSetting.get()); void this.updateCssMedia(); }); mediaFeaturePrefersContrastSetting.addChangeListener(() => { this.#mediaConfiguration.set('prefers-contrast', mediaFeaturePrefersContrastSetting.get()); void this.updateCssMedia(); }); mediaFeaturePrefersReducedDataSetting.addChangeListener(() => { this.#mediaConfiguration.set('prefers-reduced-data', mediaFeaturePrefersReducedDataSetting.get()); void this.updateCssMedia(); }); mediaFeaturePrefersReducedMotionSetting.addChangeListener(() => { this.#mediaConfiguration.set('prefers-reduced-motion', mediaFeaturePrefersReducedMotionSetting.get()); void this.updateCssMedia(); }); void this.updateCssMedia(); const autoDarkModeSetting = Common.Settings.Settings.instance().moduleSetting('emulateAutoDarkMode'); autoDarkModeSetting.addChangeListener(() => { const enabled = autoDarkModeSetting.get(); mediaFeaturePrefersColorSchemeSetting.setDisabled(enabled); mediaFeaturePrefersColorSchemeSetting.set(enabled ? 'dark' : ''); void this.emulateAutoDarkMode(enabled); }); if (autoDarkModeSetting.get()) { mediaFeaturePrefersColorSchemeSetting.setDisabled(true); mediaFeaturePrefersColorSchemeSetting.set('dark'); void this.emulateAutoDarkMode(true); } const visionDeficiencySetting = Common.Settings.Settings.instance().moduleSetting('emulatedVisionDeficiency'); visionDeficiencySetting.addChangeListener(() => this.emulateVisionDeficiency(visionDeficiencySetting.get())); if (visionDeficiencySetting.get()) { void this.emulateVisionDeficiency(visionDeficiencySetting.get()); } const localFontsDisabledSetting = Common.Settings.Settings.instance().moduleSetting('localFontsDisabled'); localFontsDisabledSetting.addChangeListener(() => this.setLocalFontsDisabled(localFontsDisabledSetting.get())); if (localFontsDisabledSetting.get()) { this.setLocalFontsDisabled(localFontsDisabledSetting.get()); } const avifFormatDisabledSetting = Common.Settings.Settings.instance().moduleSetting('avifFormatDisabled'); const webpFormatDisabledSetting = Common.Settings.Settings.instance().moduleSetting('webpFormatDisabled'); const updateDisabledImageFormats = (): void => { const types = []; if (avifFormatDisabledSetting.get()) { types.push(Protocol.Emulation.DisabledImageType.Avif); } if (webpFormatDisabledSetting.get()) { types.push(Protocol.Emulation.DisabledImageType.Webp); } this.setDisabledImageTypes(types); }; avifFormatDisabledSetting.addChangeListener(updateDisabledImageFormats); webpFormatDisabledSetting.addChangeListener(updateDisabledImageFormats); if (avifFormatDisabledSetting.get() || webpFormatDisabledSetting.get()) { updateDisabledImageFormats(); } this.#touchEmulationAllowed = true; this.#touchEnabled = false; this.#touchMobile = false; this.#customTouchEnabled = false; this.#touchConfiguration = { enabled: false, configuration: Protocol.Emulation.SetEmitTouchEventsForMouseRequestConfiguration.Mobile, }; } setTouchEmulationAllowed(touchEmulationAllowed: boolean): void { this.#touchEmulationAllowed = touchEmulationAllowed; } supportsDeviceEmulation(): boolean { return this.target().hasAllCapabilities(Capability.DeviceEmulation); } async resetPageScaleFactor(): Promise<void> { await this.#emulationAgent.invoke_resetPageScaleFactor(); } async emulateDevice(metrics: Protocol.Page.SetDeviceMetricsOverrideRequest|null): Promise<void> { if (metrics) { await this.#emulationAgent.invoke_setDeviceMetricsOverride(metrics); } else { await this.#emulationAgent.invoke_clearDeviceMetricsOverride(); } } overlayModel(): OverlayModel|null { return this.#overlayModelInternal; } async emulateLocation(location: Location|null): Promise<void> { if (!location || location.error) { await Promise.all([ this.#emulationAgent.invoke_clearGeolocationOverride(), this.#emulationAgent.invoke_setTimezoneOverride({timezoneId: ''}), this.#emulationAgent.invoke_setLocaleOverride({locale: ''}), this.#emulationAgent.invoke_setUserAgentOverride( {userAgent: MultitargetNetworkManager.instance().currentUserAgent()}), ]); } else { function processEmulationResult(errorType: string, result: Protocol.ProtocolResponseWithError): Promise<void> { const errorMessage = result.getError(); if (errorMessage) { return Promise.reject({ type: errorType, message: errorMessage, }); } return Promise.resolve(); } await Promise.all([ this.#emulationAgent .invoke_setGeolocationOverride({ latitude: location.latitude, longitude: location.longitude, accuracy: Location.defaultGeoMockAccuracy, }) .then(result => processEmulationResult('emulation-set-location', result)), this.#emulationAgent .invoke_setTimezoneOverride({ timezoneId: location.timezoneId, }) .then(result => processEmulationResult('emulation-set-timezone', result)), this.#emulationAgent .invoke_setLocaleOverride({ locale: location.locale, }) .then(result => processEmulationResult('emulation-set-locale', result)), this.#emulationAgent .invoke_setUserAgentOverride({ userAgent: MultitargetNetworkManager.instance().currentUserAgent(), acceptLanguage: location.locale, }) .then(result => processEmulationResult('emulation-set-user-agent', result)), ]); } } async emulateDeviceOrientation(deviceOrientation: DeviceOrientation|null): Promise<void> { if (deviceOrientation) { await this.#deviceOrientationAgent.invoke_setDeviceOrientationOverride( {alpha: deviceOrientation.alpha, beta: deviceOrientation.beta, gamma: deviceOrientation.gamma}); } else { await this.#deviceOrientationAgent.invoke_clearDeviceOrientationOverride(); } } async setIdleOverride(emulationParams: { isUserActive: boolean, isScreenUnlocked: boolean, }): Promise<void> { await this.#emulationAgent.invoke_setIdleOverride(emulationParams); } async clearIdleOverride(): Promise<void> { await this.#emulationAgent.invoke_clearIdleOverride(); } private async emulateCSSMedia(type: string, features: { name: string, value: string, }[]): Promise<void> { await this.#emulationAgent.invoke_setEmulatedMedia({media: type, features}); if (this.#cssModel) { this.#cssModel.mediaQueryResultChanged(); } } private async emulateAutoDarkMode(enabled: boolean): Promise<void> { if (enabled) { this.#mediaConfiguration.set('prefers-color-scheme', 'dark'); await this.updateCssMedia(); } // We never send `enabled: false` since that would explicitly disable // autodark mode. We either enable it or clear any existing override. await this.#emulationAgent.invoke_setAutoDarkModeOverride({enabled: enabled || undefined}); } private async emulateVisionDeficiency(type: Protocol.Emulation.SetEmulatedVisionDeficiencyRequestType): Promise<void> { await this.#emulationAgent.invoke_setEmulatedVisionDeficiency({type}); } private setLocalFontsDisabled(disabled: boolean): void { if (!this.#cssModel) { return; } void this.#cssModel.setLocalFontsEnabled(!disabled); } private setDisabledImageTypes(imageTypes: Protocol.Emulation.DisabledImageType[]): void { void this.#emulationAgent.invoke_setDisabledImageTypes({imageTypes}); } async setCPUThrottlingRate(rate: number): Promise<void> { await this.#emulationAgent.invoke_setCPUThrottlingRate({rate}); } async setHardwareConcurrency(hardwareConcurrency: number): Promise<void> { if (hardwareConcurrency < 1) { throw new Error('hardwareConcurrency must be a positive value'); } await this.#emulationAgent.invoke_setHardwareConcurrencyOverride({hardwareConcurrency}); } async emulateTouch(enabled: boolean, mobile: boolean): Promise<void> { this.#touchEnabled = enabled && this.#touchEmulationAllowed; this.#touchMobile = mobile && this.#touchEmulationAllowed; await this.updateTouch(); } async overrideEmulateTouch(enabled: boolean): Promise<void> { this.#customTouchEnabled = enabled && this.#touchEmulationAllowed; await this.updateTouch(); } private async updateTouch(): Promise<void> { let configuration = { enabled: this.#touchEnabled, configuration: this.#touchMobile ? Protocol.Emulation.SetEmitTouchEventsForMouseRequestConfiguration.Mobile : Protocol.Emulation.SetEmitTouchEventsForMouseRequestConfiguration.Desktop, }; if (this.#customTouchEnabled) { configuration = { enabled: true, configuration: Protocol.Emulation.SetEmitTouchEventsForMouseRequestConfiguration.Mobile, }; } if (this.#overlayModelInternal && this.#overlayModelInternal.inspectModeEnabled()) { configuration = { enabled: false, configuration: Protocol.Emulation.SetEmitTouchEventsForMouseRequestConfiguration.Mobile, }; } if (!this.#touchConfiguration.enabled && !configuration.enabled) { return; } if (this.#touchConfiguration.enabled && configuration.enabled && this.#touchConfiguration.configuration === configuration.configuration) { return; } this.#touchConfiguration = configuration; await this.#emulationAgent.invoke_setTouchEmulationEnabled({enabled: configuration.enabled, maxTouchPoints: 1}); await this.#emulationAgent.invoke_setEmitTouchEventsForMouse( {enabled: configuration.enabled, configuration: configuration.configuration}); } private async updateCssMedia(): Promise<void> { // See the note above, where this.#mediaConfiguration is defined. const type = this.#mediaConfiguration.get('type') ?? ''; const features = [ { name: 'color-gamut', value: this.#mediaConfiguration.get('color-gamut') ?? '', }, { name: 'prefers-color-scheme', value: this.#mediaConfiguration.get('prefers-color-scheme') ?? '', }, { name: 'forced-colors', value: this.#mediaConfiguration.get('forced-colors') ?? '', }, { name: 'prefers-contrast', value: this.#mediaConfiguration.get('prefers-contrast') ?? '', }, { name: 'prefers-reduced-data', value: this.#mediaConfiguration.get('prefers-reduced-data') ?? '', }, { name: 'prefers-reduced-motion', value: this.#mediaConfiguration.get('prefers-reduced-motion') ?? '', }, ]; return this.emulateCSSMedia(type, features); } } export class Location { latitude: number; longitude: number; timezoneId: string; locale: string; error: boolean; constructor(latitude: number, longitude: number, timezoneId: string, locale: string, error: boolean) { this.latitude = latitude; this.longitude = longitude; this.timezoneId = timezoneId; this.locale = locale; this.error = error; } static parseSetting(value: string): Location { if (value) { const [position, timezoneId, locale, error] = value.split(':'); const [latitude, longitude] = position.split('@'); return new Location(parseFloat(latitude), parseFloat(longitude), timezoneId, locale, Boolean(error)); } return new Location(0, 0, '', '', false); } static parseUserInput(latitudeString: string, longitudeString: string, timezoneId: string, locale: string): Location |null { if (!latitudeString && !longitudeString) { return null; } const {valid: isLatitudeValid} = Location.latitudeValidator(latitudeString); const {valid: isLongitudeValid} = Location.longitudeValidator(longitudeString); if (!isLatitudeValid && !isLongitudeValid) { return null; } const latitude = isLatitudeValid ? parseFloat(latitudeString) : -1; const longitude = isLongitudeValid ? parseFloat(longitudeString) : -1; return new Location(latitude, longitude, timezoneId, locale, false); } static latitudeValidator(value: string): { valid: boolean, errorMessage: (string|undefined), } { const numValue = parseFloat(value); const valid = /^([+-]?[\d]+(\.\d+)?|[+-]?\.\d+)$/.test(value) && numValue >= -90 && numValue <= 90; return {valid, errorMessage: undefined}; } static longitudeValidator(value: string): { valid: boolean, errorMessage: (string|undefined), } { const numValue = parseFloat(value); const valid = /^([+-]?[\d]+(\.\d+)?|[+-]?\.\d+)$/.test(value) && numValue >= -180 && numValue <= 180; return {valid, errorMessage: undefined}; } static timezoneIdValidator(value: string): { valid: boolean, errorMessage: (string|undefined), } { // Chromium uses ICU's timezone implementation, which is very // liberal in what it accepts. ICU does not simply use an allowlist // but instead tries to make sense of the input, even for // weird-looking timezone IDs. There's not much point in validating // the input other than checking if it contains at least one alphabet. // The empty string resets the override, and is accepted as well. const valid = value === '' || /[a-zA-Z]/.test(value); return {valid, errorMessage: undefined}; } static localeValidator(value: string): { valid: boolean, errorMessage: (string|undefined), } { // Similarly to timezone IDs, there's not much point in validating // input locales other than checking if it contains at least two // alphabetic characters. // https://unicode.org/reports/tr35/#Unicode_language_identifier // The empty string resets the override, and is accepted as // well. const valid = value === '' || /[a-zA-Z]{2}/.test(value); return {valid, errorMessage: undefined}; } toSetting(): string { return `${this.latitude}@${this.longitude}:${this.timezoneId}:${this.locale}:${this.error || ''}`; } static defaultGeoMockAccuracy = 150; } export class DeviceOrientation { alpha: number; beta: number; gamma: number; constructor(alpha: number, beta: number, gamma: number) { this.alpha = alpha; this.beta = beta; this.gamma = gamma; } static parseSetting(value: string): DeviceOrientation { if (value) { const jsonObject = JSON.parse(value); return new DeviceOrientation(jsonObject.alpha, jsonObject.beta, jsonObject.gamma); } return new DeviceOrientation(0, 0, 0); } static parseUserInput(alphaString: string, betaString: string, gammaString: string): DeviceOrientation|null { if (!alphaString && !betaString && !gammaString) { return null; } const {valid: isAlphaValid} = DeviceOrientation.alphaAngleValidator(alphaString); const {valid: isBetaValid} = DeviceOrientation.betaAngleValidator(betaString); const {valid: isGammaValid} = DeviceOrientation.gammaAngleValidator(gammaString); if (!isAlphaValid && !isBetaValid && !isGammaValid) { return null; } const alpha = isAlphaValid ? parseFloat(alphaString) : -1; const beta = isBetaValid ? parseFloat(betaString) : -1; const gamma = isGammaValid ? parseFloat(gammaString) : -1; return new DeviceOrientation(alpha, beta, gamma); } static angleRangeValidator(value: string, interval: { minimum: number, maximum: number, }): { valid: boolean, errorMessage: undefined, } { const numValue = parseFloat(value); const valid = /^([+-]?[\d]+(\.\d+)?|[+-]?\.\d+)$/.test(value) && numValue >= interval.minimum && numValue < interval.maximum; return {valid, errorMessage: undefined}; } static alphaAngleValidator(value: string): { valid: boolean, errorMessage: (string|undefined), } { return DeviceOrientation.angleRangeValidator(value, {minimum: -180, maximum: 180}); } static betaAngleValidator(value: string): { valid: boolean, errorMessage: (string|undefined), } { return DeviceOrientation.angleRangeValidator(value, {minimum: -180, maximum: 180}); } static gammaAngleValidator(value: string): { valid: boolean, errorMessage: (string|undefined), } { return DeviceOrientation.angleRangeValidator(value, {minimum: -90, maximum: 90}); } toSetting(): string { return JSON.stringify(this); } } SDKModel.register(EmulationModel, {capabilities: Capability.Emulation, autostart: true});