Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/vs/platform/browserView/common/browserView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export interface IBrowserViewState {
storageScope: BrowserViewStorageScope;
browserZoomIndex: number;
isElementSelectionActive: boolean;
deviceEmulation: IBrowserDeviceEmulation | undefined;
}

export interface IBrowserViewNavigationEvent {
Expand Down Expand Up @@ -255,6 +256,70 @@ export function browserZoomAccessibilityLabel(zoomFactor: number): string {
return localize('browserZoomAccessibilityLabel', "Page Zoom: {0}%", Math.round(zoomFactor * 100));
}

/**
* Device emulation profile applied via CDP. When set on a browser view, the
* page is rendered with the device's metrics, user agent, touch, and media
* features regardless of the actual container size.
*/
export interface IBrowserDeviceEmulation {
readonly id: string;
readonly label: string;
readonly width: number;
readonly height: number;
readonly deviceScaleFactor: number;
readonly mobile: boolean;
readonly userAgent?: string;
readonly hasTouch?: boolean;
}

/**
* Curated set of device presets surfaced in the device picker. Order matters
* (most-common-first). Kept short on purpose — additional sizes are rarely
* useful in practice.
*/
export const BROWSER_DEVICE_PRESETS: readonly IBrowserDeviceEmulation[] = [
{
id: 'iphone-15-pro',
label: 'iPhone 15 Pro',
width: 393,
height: 852,
deviceScaleFactor: 3,
mobile: true,
hasTouch: true,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
},
{
id: 'iphone-se',
label: 'iPhone SE',
width: 375,
height: 667,
deviceScaleFactor: 2,
mobile: true,
hasTouch: true,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
},
{
id: 'pixel-8',
label: 'Pixel 8',
width: 412,
height: 915,
deviceScaleFactor: 2.625,
mobile: true,
hasTouch: true,
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
},
{
id: 'ipad-mini',
label: 'iPad Mini',
width: 768,
height: 1024,
deviceScaleFactor: 2,
mobile: true,
hasTouch: true,
userAgent: 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
},
];

/**
* This should match the isolated world ID defined in `preload-browserView.ts`.
*/
Expand All @@ -281,6 +346,7 @@ export interface IBrowserViewService {
onDynamicDidClose(id: string): Event<void>;
onDynamicDidSelectElement(id: string): Event<IElementData>;
onDynamicDidChangeElementSelectionActive(id: string): Event<boolean>;
onDynamicDidChangeDeviceEmulation(id: string): Event<IBrowserDeviceEmulation | undefined>;

/**
* Get all known browser views with their ownership and state information.
Expand Down Expand Up @@ -431,6 +497,13 @@ export interface IBrowserViewService {
/** Set the browser zoom index (independent from VS Code zoom). */
setBrowserZoomIndex(id: string, zoomIndex: number): Promise<void>;

/**
* Set or clear a device emulation profile for a browser view. Passing
* `undefined` returns the view to the default "Responsive" behavior where
* the page mirrors the container size.
*/
setDeviceEmulation(id: string, device: IBrowserDeviceEmulation | undefined): Promise<void>;

/**
* Trust a certificate for a given host in the browser view's session.
* The page will be automatically reloaded after trusting.
Expand Down
189 changes: 179 additions & 10 deletions src/vs/platform/browserView/electron-main/browserView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { WebContentsView, webContents } from 'electron';
import { Disposable } from '../../../base/common/lifecycle.js';
import { Emitter, Event } from '../../../base/common/event.js';
import { VSBuffer } from '../../../base/common/buffer.js';
import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IBrowserViewOwner, IBrowserViewOpenOptions } from '../common/browserView.js';
import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IBrowserViewOwner, IBrowserViewOpenOptions, IBrowserDeviceEmulation } from '../common/browserView.js';
import { BrowserViewElementInspector } from './browserViewElementInspector.js';
import { IWindowsMainService } from '../../windows/electron-main/windows.js';
import { ICodeWindow, LoadReason } from '../../window/electron-main/window.js';
Expand Down Expand Up @@ -80,6 +80,11 @@ export class BrowserView extends Disposable {
private readonly _onDidClose = this._register(new Emitter<void>());
readonly onDidClose: Event<void> = this._onDidClose.event;

private readonly _onDidChangeDeviceEmulation = this._register(new Emitter<IBrowserDeviceEmulation | undefined>());
readonly onDidChangeDeviceEmulation: Event<IBrowserDeviceEmulation | undefined> = this._onDidChangeDeviceEmulation.event;

private _deviceEmulation: IBrowserDeviceEmulation | undefined;

constructor(
public readonly id: string,
public readonly owner: IBrowserViewOwner,
Expand Down Expand Up @@ -273,13 +278,17 @@ export class BrowserView extends Disposable {
// Loading state events
webContents.on('did-start-loading', () => {
this._lastError = undefined;
this.traceDeviceEmulation('did-start-loading', webContents.getURL());

// Don't fire loading events for e.g. same-document navigations
if (webContents.isLoadingMainFrame()) {
fireLoadingEvent(true);
}
});
webContents.on('did-stop-loading', () => fireLoadingEvent(false));
webContents.on('did-stop-loading', () => {
this.traceDeviceEmulation('did-stop-loading');
fireLoadingEvent(false);
});
webContents.on('did-fail-load', (e, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (isMainFrame) {
// Ignore ERR_ABORTED (-3) which is the expected error when user stops a page load.
Expand All @@ -306,7 +315,10 @@ export class BrowserView extends Disposable {
});
}
});
webContents.on('did-finish-load', () => fireLoadingEvent(false));
webContents.on('did-finish-load', () => {
this.traceDeviceEmulation('did-finish-load', webContents.getURL());
fireLoadingEvent(false);
});

this.session.trust.installCertErrorHandler(webContents);

Expand All @@ -324,12 +336,36 @@ export class BrowserView extends Disposable {
webContents.on('did-navigate', fireNavigationEvent);
webContents.on('did-navigate-in-page', fireNavigationEvent);

webContents.on('did-start-navigation', (_e, url, isInPlace, isMainFrame) => {
if (isMainFrame) {
this.traceDeviceEmulation('did-start-navigation', `${isInPlace ? 'in-page' : 'document'} ${url}`);
}
});
webContents.on('did-redirect-navigation', (_e, url, _isInPlace, isMainFrame) => {
if (isMainFrame) {
this.traceDeviceEmulation('did-redirect-navigation', url);
}
});
webContents.on('did-navigate', (_e, url) => this.traceDeviceEmulation('did-navigate', url));
webContents.on('did-navigate-in-page', (_e, url, isMainFrame) => {
if (isMainFrame) {
this.traceDeviceEmulation('did-navigate-in-page', url);
}
});

webContents.on('did-navigate', () => {
// Chromium resets the zoom factor to its per-origin default (100%) when
// navigating to a new document. Re-apply our stored zoom to override it.
this._consoleLogs.length = 0; // Clear console logs on navigation since they are per-page
this._view.webContents.setZoomFactor(browserZoomFactors[this._browserZoomIndex]);

// CDP overrides can be reset by Chromium on cross-process navigation; reapply
// unless we initiated this navigation ourselves and already applied them.
if (this._deviceEmulation && !this._skipNextNavigateEmulationReapply) {
void this.applyDeviceEmulation();
}
this._skipNextNavigateEmulationReapply = false;
Comment on lines +363 to +367

// Enable pinch-to-zoom
void this._view.webContents.setVisualZoomLevelLimits(1, 3).catch(error => {
this.logService.error('Failed to set visual zoom level limits for browser view webContents.', error);
Expand Down Expand Up @@ -472,7 +508,8 @@ export class BrowserView extends Disposable {
certificateError: this.session.trust.getCertificateError(url),
storageScope: this.session.storageScope,
browserZoomIndex: this._browserZoomIndex,
isElementSelectionActive: this.inspector.isElementSelectionActive
isElementSelectionActive: this.inspector.isElementSelectionActive,
deviceEmulation: this._deviceEmulation
};
}

Expand All @@ -497,12 +534,144 @@ export class BrowserView extends Disposable {
}

this._view.setBorderRadius(Math.round(bounds.cornerRadius * bounds.zoomFactor));
this._view.setBounds({
x: Math.round(bounds.x * bounds.zoomFactor),
y: Math.round(bounds.y * bounds.zoomFactor),
width: Math.round(bounds.width * bounds.zoomFactor),
height: Math.round(bounds.height * bounds.zoomFactor)
});

// When a device profile is active, clamp the view to the device dimensions and
// center it inside the pane so the editor background fills the empty area instead
// of the WebContentsView painting an opaque (black) backdrop.
const z = bounds.zoomFactor;
let viewWidth = Math.round(bounds.width * z);
let viewHeight = Math.round(bounds.height * z);
let viewX = Math.round(bounds.x * z);
const viewY = Math.round(bounds.y * z);
const device = this._deviceEmulation;
if (device) {
const deviceWidth = Math.round(device.width * z);
const deviceHeight = Math.round(device.height * z);
if (deviceWidth < viewWidth) {
viewX += Math.round((viewWidth - deviceWidth) / 2);
viewWidth = deviceWidth;
}
if (deviceHeight < viewHeight) {
viewHeight = deviceHeight;
}
}
this._view.setBounds({ x: viewX, y: viewY, width: viewWidth, height: viewHeight });

this._lastBounds = bounds;
this.syncDeviceMetrics(bounds);
}

private _lastBounds: IBrowserViewBounds | undefined;
private _lastDeviceMetrics: { width: number; height: number } | undefined;
private _skipNextNavigateEmulationReapply = false;
private _deviceEmulationTraceStart = 0;

private traceDeviceEmulation(event: string, extra?: string): void {
if (!this._deviceEmulationTraceStart) {
return;
}
const dt = Date.now() - this._deviceEmulationTraceStart;
this.logService.info(`[BrowserView][trace +${dt}ms] ${event}${extra ? ' ' + extra : ''}`);
Comment on lines +569 to +574
}

// Mirror container size to the page's layout viewport so CSS media queries see the actual width.
// Skipped when an explicit device profile is active — that profile owns the viewport.
private syncDeviceMetrics(bounds: IBrowserViewBounds): void {
if (this._deviceEmulation) {
return;
}
const width = Math.max(1, Math.round(bounds.width));
const height = Math.max(1, Math.round(bounds.height));
if (this._lastDeviceMetrics?.width === width && this._lastDeviceMetrics.height === height) {
return;
}
this._lastDeviceMetrics = { width, height };

this.debugger.sendCommand('Emulation.setDeviceMetricsOverride', {
width,
height,
deviceScaleFactor: 0,
mobile: false,
}).catch(err => this.logService.warn('[BrowserView] setDeviceMetricsOverride failed', err));
}

get deviceEmulation(): IBrowserDeviceEmulation | undefined {
return this._deviceEmulation;
}

async setDeviceEmulation(device: IBrowserDeviceEmulation | undefined): Promise<void> {
const previous = this._deviceEmulation;
this._deviceEmulation = device;
this._lastDeviceMetrics = undefined;

// Trace window: keep tracing webContents/network events for 5s after a device switch
// so we can see exactly what the page does (client-side nav, refetches, etc).
this._deviceEmulationTraceStart = Date.now();
setTimeout(() => { this._deviceEmulationTraceStart = 0; }, 5000);
this.traceDeviceEmulation('setDeviceEmulation:start', device ? `${device.id} ${device.width}x${device.height}` : 'off');
Comment on lines +607 to +611

// Use Electron's native API for UA. CDP's setUserAgentOverride doesn't reliably
// stick across webContents.reload() and is also scoped to the current target only.
this._view.webContents.setUserAgent(device?.userAgent ?? '');
this.traceDeviceEmulation('setUserAgent', `"${(device?.userAgent ?? '').slice(0, 60)}…"`);
Comment on lines +613 to +616

this.logService.info('[BrowserView] setDeviceEmulation', device ? `${device.id} (${device.width}x${device.height})` : 'off');

// Resize the WebContentsView BEFORE applying CDP overrides so the view is the
// right size when the page's viewport shrinks — avoids a one-frame flash where
// the page paints at device width inside the still-full-pane-sized view.
if (this._lastBounds) {
this.layout(this._lastBounds);
this.traceDeviceEmulation('clampedLayout');
}

await this.applyDeviceEmulation();
this.traceDeviceEmulation('applyDeviceEmulation:done');

// Restore the responsive mirror when going back to default so CSS media queries
// continue to reflect the actual pane width.
if (!device && this._lastBounds) {
this.syncDeviceMetrics(this._lastBounds);
}

// Do not reload here. Matches Chrome DevTools Device Mode: metrics/touch/media apply
// instantly to the current page, and the new UA takes effect on the next navigation.
// Sites that serve different HTML for mobile UAs will only switch once the user reloads.
void previous;

this._onDidChangeDeviceEmulation.fire(device);
}

private async applyDeviceEmulation(): Promise<void> {
const device = this._deviceEmulation;
try {
if (device) {
await this.debugger.sendCommand('Emulation.setDeviceMetricsOverride', {
width: device.width,
height: device.height,
deviceScaleFactor: device.deviceScaleFactor,
mobile: device.mobile,
screenOrientation: device.mobile
? { type: 'portraitPrimary', angle: 0 }
: undefined,
});
await this.debugger.sendCommand('Emulation.setTouchEmulationEnabled', {
enabled: !!device.hasTouch,
maxTouchPoints: device.hasTouch ? 5 : 0,
});
await this.debugger.sendCommand('Emulation.setEmulatedMedia', {
features: [{ name: 'pointer', value: device.hasTouch ? 'coarse' : 'fine' }],
});
this.logService.info('[BrowserView] device emulation applied');
} else {
await this.debugger.sendCommand('Emulation.clearDeviceMetricsOverride', {});
await this.debugger.sendCommand('Emulation.setTouchEmulationEnabled', { enabled: false, maxTouchPoints: 1 });
await this.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [] });
this.logService.info('[BrowserView] device emulation cleared');
}
} catch (err) {
this.logService.error('[BrowserView] applyDeviceEmulation failed', err);
}
}

setBrowserZoomIndex(zoomIndex: number): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,6 @@ export class BrowserViewDebugger extends Disposable {
* Send a CDP command. Handles Electron-specific workarounds in a single place.
*/
sendCommand(method: string, params?: unknown, sessionId?: string): Promise<unknown> {
// This crashes Electron. Don't pass it through.
if (method === 'Emulation.setDeviceMetricsOverride') {
return Promise.resolve({});
}

this.ensureAttached();
const resultPromise = this._electronDebugger.sendCommand(method, params, sessionId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { Emitter, Event } from '../../../base/common/event.js';
import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js';
import { VSBuffer } from '../../../base/common/buffer.js';
import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewOpenOptions, IBrowserViewCreateOptions, IBrowserViewTheme, IBrowserViewConfiguration } from '../common/browserView.js';
import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewOpenOptions, IBrowserViewCreateOptions, IBrowserViewTheme, IBrowserViewConfiguration, IBrowserDeviceEmulation } from '../common/browserView.js';
import { clipboard, Menu, MenuItem } from 'electron';
import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js';
import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js';
Expand Down Expand Up @@ -187,6 +187,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa
return this._getBrowserView(id).inspector.onDidChangeElementSelectionActive;
}

onDynamicDidChangeDeviceEmulation(id: string) {
return this._getBrowserView(id).onDidChangeDeviceEmulation;
}

async getState(id: string): Promise<IBrowserViewState> {
return this._getBrowserView(id).getState();
}
Expand Down Expand Up @@ -263,6 +267,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa
return this._getBrowserView(id).setBrowserZoomIndex(zoomIndex);
}

async setDeviceEmulation(id: string, device: IBrowserDeviceEmulation | undefined): Promise<void> {
return this._getBrowserView(id).setDeviceEmulation(device);
}

async trustCertificate(id: string, host: string, fingerprint: string): Promise<void> {
return this._getBrowserView(id).trustCertificate(host, fingerprint);
}
Expand Down
Loading
Loading