Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/vs/platform/actions/common/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ export class MenuId {
static readonly DiffEditorSelectionToolbar = new MenuId('DiffEditorSelectionToolbar');
static readonly BrowserNavigationToolbar = new MenuId('BrowserNavigationToolbar');
static readonly BrowserActionsToolbar = new MenuId('BrowserActionsToolbar');
static readonly BrowserEmulationToolbar = new MenuId('BrowserEmulationToolbar');
static readonly AgentSessionsViewerFilterSubMenu = new MenuId('AgentSessionsViewerFilterSubMenu');
static readonly AgentSessionsContext = new MenuId('AgentSessionsContext');
static readonly AgentSessionSectionContext = new MenuId('AgentSessionSectionContext');
Expand Down
31 changes: 31 additions & 0 deletions src/vs/platform/browserView/common/browserView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ export interface IBrowserViewBounds {
height: number;
zoomFactor: number;
cornerRadius: number;
emulation?: {
viewportWidth: number;
viewportHeight: number;
scale: number;
};
}

export interface IBrowserViewCaptureScreenshotOptions {
Expand Down Expand Up @@ -156,6 +161,7 @@ export interface IBrowserViewState {
storageScope: BrowserViewStorageScope;
browserZoomIndex: number;
isElementSelectionActive: boolean;
device: IBrowserDeviceProfile | undefined;
}

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

/**
* The "device" half of browser emulation: characteristics the page sees as
* intrinsic to the device (touch / mobile media features, DPR, UA string).
*/
export interface IBrowserDeviceProfile {
readonly mobile?: boolean;
readonly userAgent?: string;
readonly deviceScaleFactor?: number;
}

/**
* The "screen" half of browser emulation: the desired viewport size and zoom.
*
* `undefined` values mean the view should be sized to fit the container.
*/
export interface IBrowserScreenProfile {
readonly width?: number;
readonly height?: number;
readonly scale?: number;
}

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

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

/** Set or clear the active device profile for a browser view. */
setDeviceEmulation(id: string, device: IBrowserDeviceProfile | 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
11 changes: 10 additions & 1 deletion src/vs/platform/browserView/electron-main/browserView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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 { BrowserViewEmulator } from './browserViewEmulator.js';
import { BrowserViewInspector } from './browserViewInspector.js';
import { IWindowsMainService } from '../../windows/electron-main/windows.js';
import { ICodeWindow, LoadReason } from '../../window/electron-main/window.js';
Expand Down Expand Up @@ -41,6 +42,7 @@ export class BrowserView extends Disposable {
private _browserZoomIndex: number = browserZoomDefaultIndex;

readonly debugger: BrowserViewDebugger;
readonly emulator: BrowserViewEmulator;
readonly inspector: BrowserViewInspector;

private _ownerWindow: ICodeWindow;
Expand Down Expand Up @@ -191,6 +193,7 @@ export class BrowserView extends Disposable {
});

this.debugger = new BrowserViewDebugger(this, this.logService);
this.emulator = this._register(new BrowserViewEmulator(this, this.logService));
this.inspector = this._register(new BrowserViewInspector(this));

this.setupEventListeners();
Expand Down Expand Up @@ -472,7 +475,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,
device: this.emulator.device
};
}

Expand All @@ -497,6 +501,11 @@ export class BrowserView extends Disposable {
}

this._view.setBorderRadius(Math.round(bounds.cornerRadius * bounds.zoomFactor));

if (bounds.emulation) {
this.emulator.applyScreenEmulation(bounds.emulation.viewportWidth, bounds.emulation.viewportHeight, bounds.emulation.scale, bounds.zoomFactor);
}

this._view.setBounds({
x: Math.round(bounds.x * bounds.zoomFactor),
y: Math.round(bounds.y * bounds.zoomFactor),
Expand Down
112 changes: 112 additions & 0 deletions src/vs/platform/browserView/electron-main/browserViewEmulator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Disposable, toDisposable } from '../../../base/common/lifecycle.js';
import { Emitter, Event } from '../../../base/common/event.js';
import { IBrowserDeviceProfile } from '../common/browserView.js';
import { ILogService } from '../../log/common/log.js';
import type { BrowserView } from './browserView.js';

/**
* Manages device emulation for a browser view. The renderer is authoritative
* for layout (it computes the on-screen size and emulation scale); this class
* just forwards values to `webContents.enableDeviceEmulation` and manages the
* touch / media / user-agent overrides that have no native Electron equivalent.
*/
export class BrowserViewEmulator extends Disposable {

private _device: IBrowserDeviceProfile | undefined;
private readonly _defaultUserAgent: string;
private _lastApplied: { viewportWidth: number; viewportHeight: number; scale: number; hostZoom: number } | undefined;

private readonly _onDidChange = this._register(new Emitter<IBrowserDeviceProfile | undefined>());
readonly onDidChange: Event<IBrowserDeviceProfile | undefined> = this._onDidChange.event;

constructor(
private readonly browser: BrowserView,
@ILogService private readonly logService: ILogService,
) {
super();
this._defaultUserAgent = this.browser.webContents.getUserAgent();

// Chromium may reset emulation on cross-process navigation.
const onNavigate = () => {
if (this._device) {
void this._applyTouchAndMedia();
this._lastApplied = undefined;
}
};
this.browser.webContents.on('did-navigate', onNavigate);
this._register(toDisposable(() => this.browser.webContents.removeListener('did-navigate', onNavigate)));
}

get device(): IBrowserDeviceProfile | undefined {
return this._device;
}

async setDevice(device: IBrowserDeviceProfile | undefined): Promise<void> {
const prev = this._device;
this._device = device;

const nextUA = device?.userAgent;
if (prev?.userAgent !== nextUA) {
this.browser.webContents.setUserAgent(nextUA ?? this._defaultUserAgent);
}

const mobileChanged = !!prev?.mobile !== !!device?.mobile;
const toggled = !!prev !== !!device;
if (mobileChanged || toggled) {
await this._applyTouchAndMedia();
}

this._lastApplied = undefined;
if (!device) {
this.browser.webContents.disableDeviceEmulation();
}

this._onDidChange.fire(device);
}

/**
* Apply viewport + scale via Chromium's emulation API. `hostZoom` is the host
* window's CSS-to-screen zoom factor: bounds in main are multiplied by it,
* so the emulation scale must be too or the emulated viewport won't fill
* the WebContentsView when the workbench is zoomed.
*/
applyScreenEmulation(viewportWidth: number, viewportHeight: number, scale: number, hostZoom: number): void {
if (!this._device) {
return;
}
const w = Math.max(1, Math.round(viewportWidth));
const h = Math.max(1, Math.round(viewportHeight));
const z = Math.max(0.01, hostZoom);
const s = Math.max(0.01, scale);
const last = this._lastApplied;
if (last && last.viewportWidth === w && last.viewportHeight === h
&& Math.abs(last.scale - s) < 0.0001 && Math.abs(last.hostZoom - z) < 0.0001) {
return;
}
this._lastApplied = { viewportWidth: w, viewportHeight: h, scale: s, hostZoom: z };
this.browser.webContents.enableDeviceEmulation({
screenPosition: this._device.mobile ? 'mobile' : 'desktop',
screenSize: { width: w, height: h },
viewSize: { width: w, height: h },
deviceScaleFactor: this._device.deviceScaleFactor ?? 0,
viewPosition: { x: 0, y: 0 },
scale: s * z,
});
}

private async _applyTouchAndMedia(): Promise<void> {
const mobile = !!this._device?.mobile;
try {
await this.browser.debugger.sendCommand('Emulation.setTouchEmulationEnabled', { enabled: mobile, maxTouchPoints: mobile ? 5 : 1 });
await this.browser.debugger.sendCommand('Emulation.setEmulatedMedia', { features: this._device ? [{ name: 'pointer', value: mobile ? 'coarse' : 'fine' }] : [] });
await this.browser.debugger.sendCommand('Emulation.setEmitTouchEventsForMouse', { enabled: mobile });
} catch (err) {
this.logService.error('[BrowserViewEmulator] _applyTouchAndMedia failed', err);
}
}
}
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, IBrowserDeviceProfile } 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).emulator.onDidChange;
}

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: IBrowserDeviceProfile | undefined): Promise<void> {
return this._getBrowserView(id).emulator.setDevice(device);
}

async trustCertificate(id: string, host: string, fingerprint: string): Promise<void> {
return this._getBrowserView(id).trustCertificate(host, fingerprint);
}
Expand Down
26 changes: 21 additions & 5 deletions src/vs/workbench/contrib/browserView/common/browserView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ import {
IBrowserViewOwner,
browserZoomDefaultIndex,
browserZoomFactors,
IBrowserViewState
IBrowserViewState,
IBrowserDeviceProfile
} from '../../../../platform/browserView/common/browserView.js';
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDomains.js';
Expand Down Expand Up @@ -213,6 +214,7 @@ export interface IBrowserViewModel extends IDisposable {
readonly canZoomIn: boolean;
readonly canZoomOut: boolean;
readonly isElementSelectionActive: boolean;
readonly device: IBrowserDeviceProfile | undefined;

readonly onDidChangeSharingState: Event<BrowserViewSharingState>;
readonly onDidChangeZoom: Event<void>;
Expand All @@ -229,6 +231,7 @@ export interface IBrowserViewModel extends IDisposable {
readonly onWillDispose: Event<void>;
readonly onDidSelectElement: Event<IElementData>;
readonly onDidChangeElementSelectionActive: Event<boolean>;
readonly onDidChangeDevice: Event<IBrowserDeviceProfile | undefined>;

layout(bounds: IBrowserViewBounds): Promise<void>;
setVisible(visible: boolean): Promise<void>;
Expand All @@ -251,6 +254,7 @@ export interface IBrowserViewModel extends IDisposable {
resetZoom(): Promise<void>;
getConsoleLogs(): Promise<string>;
toggleElementSelection(enabled?: boolean): Promise<void>;
setDevice(device: IBrowserDeviceProfile | undefined): Promise<void>;
}

export class BrowserViewModel extends Disposable implements IBrowserViewModel {
Expand All @@ -272,6 +276,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
private _sharedWithAgent: boolean = false;
private _browserZoomIndex: number = browserZoomDefaultIndex;
private _isElementSelectionActive: boolean = false;
private _device: IBrowserDeviceProfile | undefined;

private readonly _onDidChangeSharingState = this._register(new Emitter<BrowserViewSharingState>());
readonly onDidChangeSharingState: Event<BrowserViewSharingState> = this._onDidChangeSharingState.event;
Expand Down Expand Up @@ -314,6 +319,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
this._storageScope = initialState.storageScope;
this._browserZoomIndex = initialState.browserZoomIndex;
this._isElementSelectionActive = initialState.isElementSelectionActive;
this._device = initialState.device;
this._isEphemeral = this._storageScope === BrowserViewStorageScope.Ephemeral;
this._zoomHost = parseZoomHost(this._url);

Expand Down Expand Up @@ -387,6 +393,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
this._visible = visible;
}));

this._register(this.onDidChangeDevice(device => {
this._device = device;
}));

this._register(this.onDidChangeElementSelectionActive(active => {
if (active) {
this.telemetryService.publicLog2<IntegratedBrowserAddElementToChatStartEvent, IntegratedBrowserAddElementToChatStartClassification>('integratedBrowser.addElementToChat.start', {});
Expand Down Expand Up @@ -425,6 +435,8 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
get zoomFactor(): number { return browserZoomFactors[this._browserZoomIndex]; }
get canZoomIn(): boolean { return this._browserZoomIndex < browserZoomFactors.length - 1; }
get canZoomOut(): boolean { return this._browserZoomIndex > 0; }
get isElementSelectionActive(): boolean { return this._isElementSelectionActive; }
get device(): IBrowserDeviceProfile | undefined { return this._device; }

get onDidNavigate(): Event<IBrowserViewNavigationEvent> {
return this.browserViewService.onDynamicDidNavigate(this.id);
Expand Down Expand Up @@ -462,6 +474,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
return this.browserViewService.onDynamicDidChangeVisibility(this.id);
}

get onDidChangeDevice(): Event<IBrowserDeviceProfile | undefined> {
return this.browserViewService.onDynamicDidChangeDeviceEmulation(this.id);
}

get onDidClose(): Event<void> {
return this.browserViewService.onDynamicDidClose(this.id);
}
Expand Down Expand Up @@ -583,10 +599,6 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
return this.browserViewService.getConsoleLogs(this.id);
}

get isElementSelectionActive(): boolean {
return this._isElementSelectionActive;
}

async toggleElementSelection(enabled?: boolean): Promise<void> {
return this.browserViewService.toggleElementSelection(this.id, enabled);
}
Expand All @@ -599,6 +611,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
return this.browserViewService.onDynamicDidChangeElementSelectionActive(this.id);
}

async setDevice(device: IBrowserDeviceProfile | undefined): Promise<void> {
return this.browserViewService.setDeviceEmulation(this.id, device);
}

private static readonly SHARE_DONT_ASK_KEY = 'browserView.shareWithAgent.dontAskAgain';

async setSharedWithAgent(shared: boolean): Promise<boolean> {
Expand Down
Loading
Loading