Path: blob/main/src/vs/platform/browserView/electron-main/browserView.ts
4776 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { WebContentsView, webContents } from 'electron';6import { Disposable } from '../../../base/common/lifecycle.js';7import { Emitter, Event } from '../../../base/common/event.js';8import { VSBuffer } from '../../../base/common/buffer.js';9import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions } from '../common/browserView.js';10import { EVENT_KEY_CODE_MAP, KeyCode, SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js';11import { IThemeMainService } from '../../theme/electron-main/themeMainService.js';12import { IWindowsMainService } from '../../windows/electron-main/windows.js';13import { IBaseWindow, ICodeWindow } from '../../window/electron-main/window.js';14import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js';15import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js';16import { ILogService } from '../../log/common/log.js';17import { isMacintosh } from '../../../base/common/platform.js';1819const nativeShortcutKeys = new Set(['KeyA', 'KeyC', 'KeyV', 'KeyX', 'KeyZ']);20function shouldIgnoreNativeShortcut(input: Electron.Input): boolean {21const isControlInput = isMacintosh ? input.meta : input.control;22const isAltOnlyInput = input.alt && !input.control && !input.meta;2324// Ignore Alt-only inputs (often used for accented characters or menu accelerators)25if (isAltOnlyInput) {26return true;27}2829// Ignore Ctrl/Cmd + [A,C,V,X,Z] shortcuts to allow native handling (e.g. copy/paste)30return isControlInput && nativeShortcutKeys.has(input.code);31}3233/**34* Represents a single browser view instance with its WebContentsView and all associated logic.35* This class encapsulates all operations and events for a single browser view.36*/37export class BrowserView extends Disposable {38private readonly _view: WebContentsView;39private readonly _faviconRequestCache = new Map<string, Promise<string>>();4041private _lastScreenshot: VSBuffer | undefined = undefined;42private _lastFavicon: string | undefined = undefined;43private _lastError: IBrowserViewLoadError | undefined = undefined;4445private _window: IBaseWindow | undefined;46private _isSendingKeyEvent = false;4748private readonly _onDidNavigate = this._register(new Emitter<IBrowserViewNavigationEvent>());49readonly onDidNavigate: Event<IBrowserViewNavigationEvent> = this._onDidNavigate.event;5051private readonly _onDidChangeLoadingState = this._register(new Emitter<IBrowserViewLoadingEvent>());52readonly onDidChangeLoadingState: Event<IBrowserViewLoadingEvent> = this._onDidChangeLoadingState.event;5354private readonly _onDidChangeFocus = this._register(new Emitter<IBrowserViewFocusEvent>());55readonly onDidChangeFocus: Event<IBrowserViewFocusEvent> = this._onDidChangeFocus.event;5657private readonly _onDidChangeDevToolsState = this._register(new Emitter<IBrowserViewDevToolsStateEvent>());58readonly onDidChangeDevToolsState: Event<IBrowserViewDevToolsStateEvent> = this._onDidChangeDevToolsState.event;5960private readonly _onDidKeyCommand = this._register(new Emitter<IBrowserViewKeyDownEvent>());61readonly onDidKeyCommand: Event<IBrowserViewKeyDownEvent> = this._onDidKeyCommand.event;6263private readonly _onDidChangeTitle = this._register(new Emitter<IBrowserViewTitleChangeEvent>());64readonly onDidChangeTitle: Event<IBrowserViewTitleChangeEvent> = this._onDidChangeTitle.event;6566private readonly _onDidChangeFavicon = this._register(new Emitter<IBrowserViewFaviconChangeEvent>());67readonly onDidChangeFavicon: Event<IBrowserViewFaviconChangeEvent> = this._onDidChangeFavicon.event;6869private readonly _onDidRequestNewPage = this._register(new Emitter<IBrowserViewNewPageRequest>());70readonly onDidRequestNewPage: Event<IBrowserViewNewPageRequest> = this._onDidRequestNewPage.event;7172private readonly _onDidClose = this._register(new Emitter<void>());73readonly onDidClose: Event<void> = this._onDidClose.event;7475constructor(76viewSession: Electron.Session,77private readonly storageScope: BrowserViewStorageScope,78@IThemeMainService private readonly themeMainService: IThemeMainService,79@IWindowsMainService private readonly windowsMainService: IWindowsMainService,80@IAuxiliaryWindowsMainService private readonly auxiliaryWindowsMainService: IAuxiliaryWindowsMainService,81@ILogService private readonly logService: ILogService82) {83super();8485this._view = new WebContentsView({86webPreferences: {87nodeIntegration: false,88contextIsolation: true,89sandbox: true,90webviewTag: false,91session: viewSession92}93});9495this._view.webContents.setWindowOpenHandler((details) => {96// For new tab requests, fire event for workbench to handle97if (details.disposition === 'background-tab' || details.disposition === 'foreground-tab') {98this._onDidRequestNewPage.fire({99url: details.url,100name: details.frameName || undefined,101background: details.disposition === 'background-tab'102});103return { action: 'deny' }; // Deny the default browser behavior since we're handling it104}105106// Deny other requests like new windows.107return { action: 'deny' };108});109110this._view.webContents.on('destroyed', () => {111this._onDidClose.fire();112});113114this.setupEventListeners();115116// Create and register plugins for this web contents117this._register(new ThemePlugin(this._view, this.themeMainService, this.logService));118}119120private setupEventListeners(): void {121const webContents = this._view.webContents;122123// DevTools state events124webContents.on('devtools-opened', () => {125this._onDidChangeDevToolsState.fire({ isDevToolsOpen: true });126});127128webContents.on('devtools-closed', () => {129this._onDidChangeDevToolsState.fire({ isDevToolsOpen: false });130});131132// Favicon events133webContents.on('page-favicon-updated', async (_event, favicons) => {134if (!favicons || favicons.length === 0) {135return;136}137138const found = favicons.find(f => this._faviconRequestCache.get(f));139if (found) {140// already have a cached request for this favicon, use it141this._lastFavicon = await this._faviconRequestCache.get(found)!;142this._onDidChangeFavicon.fire({ favicon: this._lastFavicon });143return;144}145146// try each url in order until one works147for (const url of favicons) {148const request = (async () => {149const response = await webContents.session.fetch(url, {150cache: 'force-cache'151});152const type = await response.headers.get('content-type');153const buffer = await response.arrayBuffer();154155return `data:${type};base64,${Buffer.from(buffer).toString('base64')}`;156})();157158this._faviconRequestCache.set(url, request);159160try {161this._lastFavicon = await request;162this._onDidChangeFavicon.fire({ favicon: this._lastFavicon });163// On success, leave the promise in the cache and stop looping164return;165} catch (e) {166this._faviconRequestCache.delete(url);167// On failure, try the next one168}169}170});171172// Title events173webContents.on('page-title-updated', (_event, title) => {174this._onDidChangeTitle.fire({ title });175});176177const fireNavigationEvent = () => {178this._onDidNavigate.fire({179url: webContents.getURL(),180canGoBack: webContents.navigationHistory.canGoBack(),181canGoForward: webContents.navigationHistory.canGoForward()182});183};184185const fireLoadingEvent = (loading: boolean) => {186this._onDidChangeLoadingState.fire({ loading, error: this._lastError });187};188189// Loading state events190webContents.on('did-start-loading', () => {191this._lastError = undefined;192fireLoadingEvent(true);193});194webContents.on('did-stop-loading', () => fireLoadingEvent(false));195webContents.on('did-fail-load', (e, errorCode, errorDescription, validatedURL, isMainFrame) => {196if (isMainFrame) {197this._lastError = {198url: validatedURL,199errorCode,200errorDescription201};202203fireLoadingEvent(false);204this._onDidNavigate.fire({205url: validatedURL,206canGoBack: webContents.navigationHistory.canGoBack(),207canGoForward: webContents.navigationHistory.canGoForward()208});209}210});211webContents.on('did-finish-load', () => fireLoadingEvent(false));212213webContents.on('render-process-gone', (_event, details) => {214this._lastError = {215url: webContents.getURL(),216errorCode: details.exitCode,217errorDescription: `Render process gone: ${details.reason}`218};219220fireLoadingEvent(false);221});222223// Navigation events (when URL actually changes)224webContents.on('did-navigate', fireNavigationEvent);225webContents.on('did-navigate-in-page', fireNavigationEvent);226227// Focus events228webContents.on('focus', () => {229this._onDidChangeFocus.fire({ focused: true });230});231232webContents.on('blur', () => {233this._onDidChangeFocus.fire({ focused: false });234});235236// Key down events - listen for raw key input events237webContents.on('before-input-event', async (event, input) => {238if (input.type === 'keyDown' && !this._isSendingKeyEvent) {239if (shouldIgnoreNativeShortcut(input)) {240return;241}242const eventKeyCode = SCAN_CODE_STR_TO_EVENT_KEY_CODE[input.code] || 0;243const keyCode = EVENT_KEY_CODE_MAP[eventKeyCode] || KeyCode.Unknown;244const hasCommandModifier = input.control || input.alt || input.meta;245const isNonEditingKey =246keyCode >= KeyCode.F1 && keyCode <= KeyCode.F24 ||247keyCode >= KeyCode.AudioVolumeMute;248249if (hasCommandModifier || isNonEditingKey) {250event.preventDefault();251this._onDidKeyCommand.fire({252key: input.key,253keyCode: eventKeyCode,254code: input.code,255ctrlKey: input.control || false,256shiftKey: input.shift || false,257altKey: input.alt || false,258metaKey: input.meta || false,259repeat: input.isAutoRepeat || false260});261}262}263});264265// For now, always prevent sites from blocking unload.266// In the future we may want to show a dialog to ask the user,267// with heavy restrictions regarding interaction and repeated prompts.268webContents.on('will-prevent-unload', (e) => {269e.preventDefault();270});271}272273/**274* Get the current state of this browser view275*/276getState(): IBrowserViewState {277const webContents = this._view.webContents;278return {279url: webContents.getURL(),280title: webContents.getTitle(),281canGoBack: webContents.navigationHistory.canGoBack(),282canGoForward: webContents.navigationHistory.canGoForward(),283loading: webContents.isLoading(),284isDevToolsOpen: webContents.isDevToolsOpened(),285lastScreenshot: this._lastScreenshot,286lastFavicon: this._lastFavicon,287lastError: this._lastError,288storageScope: this.storageScope289};290}291292/**293* Toggle developer tools for this browser view.294*/295toggleDevTools(): void {296this._view.webContents.toggleDevTools();297}298299/**300* Update the layout bounds of this view301*/302layout(bounds: IBrowserViewBounds): void {303if (this._window?.win?.id !== bounds.windowId) {304const newWindow = this.windowById(bounds.windowId);305if (newWindow) {306this._window?.win?.contentView.removeChildView(this._view);307this._window = newWindow;308newWindow.win?.contentView.addChildView(this._view);309}310}311312this._view.webContents.setZoomFactor(bounds.zoomFactor);313this._view.setBounds({314x: Math.round(bounds.x * bounds.zoomFactor),315y: Math.round(bounds.y * bounds.zoomFactor),316width: Math.round(bounds.width * bounds.zoomFactor),317height: Math.round(bounds.height * bounds.zoomFactor)318});319}320321/**322* Set the visibility of this view323*/324setVisible(visible: boolean): void {325// If the view is focused, pass focus back to the window when hiding326if (!visible && this._view.webContents.isFocused()) {327this._window?.win?.webContents.focus();328}329330this._view.setVisible(visible);331}332333/**334* Load a URL in this view335*/336async loadURL(url: string): Promise<void> {337await this._view.webContents.loadURL(url);338}339340/**341* Get the current URL342*/343getURL(): string {344return this._view.webContents.getURL();345}346347/**348* Navigate back in history349*/350goBack(): void {351if (this._view.webContents.navigationHistory.canGoBack()) {352this._view.webContents.navigationHistory.goBack();353}354}355356/**357* Navigate forward in history358*/359goForward(): void {360if (this._view.webContents.navigationHistory.canGoForward()) {361this._view.webContents.navigationHistory.goForward();362}363}364365/**366* Reload the current page367*/368reload(): void {369this._view.webContents.reload();370}371372/**373* Check if the view can navigate back374*/375canGoBack(): boolean {376return this._view.webContents.navigationHistory.canGoBack();377}378379/**380* Check if the view can navigate forward381*/382canGoForward(): boolean {383return this._view.webContents.navigationHistory.canGoForward();384}385386/**387* Capture a screenshot of this view388*/389async captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise<VSBuffer> {390const quality = options?.quality ?? 80;391const image = await this._view.webContents.capturePage(options?.rect, {392stayHidden: true,393stayAwake: true394});395const buffer = image.toJPEG(quality);396const screenshot = VSBuffer.wrap(buffer);397// Only update _lastScreenshot if capturing the full view398if (!options?.rect) {399this._lastScreenshot = screenshot;400}401return screenshot;402}403404/**405* Dispatch a keyboard event to this view406*/407async dispatchKeyEvent(keyEvent: IBrowserViewKeyDownEvent): Promise<void> {408const event: Electron.KeyboardInputEvent = {409type: 'keyDown',410keyCode: keyEvent.key,411modifiers: []412};413if (keyEvent.ctrlKey) {414event.modifiers!.push('control');415}416if (keyEvent.shiftKey) {417event.modifiers!.push('shift');418}419if (keyEvent.altKey) {420event.modifiers!.push('alt');421}422if (keyEvent.metaKey) {423event.modifiers!.push('meta');424}425this._isSendingKeyEvent = true;426try {427await this._view.webContents.sendInputEvent(event);428} finally {429this._isSendingKeyEvent = false;430}431}432433/**434* Set the zoom factor of this view435*/436async setZoomFactor(zoomFactor: number): Promise<void> {437await this._view.webContents.setZoomFactor(zoomFactor);438}439440/**441* Focus this view442*/443async focus(): Promise<void> {444this._view.webContents.focus();445}446447/**448* Get the underlying WebContentsView449*/450getWebContentsView(): WebContentsView {451return this._view;452}453454override dispose(): void {455// Remove from parent window456this._window?.win?.contentView.removeChildView(this._view);457458// Clean up the view and all its event listeners459// Note: webContents.close() automatically removes all event listeners460this._view.webContents.close({ waitForBeforeUnload: false });461462super.dispose();463}464465466private windowById(windowId: number | undefined): ICodeWindow | IAuxiliaryWindow | undefined {467return this.codeWindowById(windowId) ?? this.auxiliaryWindowById(windowId);468}469470private codeWindowById(windowId: number | undefined): ICodeWindow | undefined {471if (typeof windowId !== 'number') {472return undefined;473}474475return this.windowsMainService.getWindowById(windowId);476}477478private auxiliaryWindowById(windowId: number | undefined): IAuxiliaryWindow | undefined {479if (typeof windowId !== 'number') {480return undefined;481}482483const contents = webContents.fromId(windowId);484if (!contents) {485return undefined;486}487488return this.auxiliaryWindowsMainService.getWindowByWebContents(contents);489}490}491492export class ThemePlugin extends Disposable {493private readonly _webContents: Electron.WebContents;494private _injectedCSSKey?: string;495496constructor(497private readonly _view: Electron.WebContentsView,498private readonly themeMainService: IThemeMainService,499private readonly logService: ILogService500) {501super();502this._webContents = _view.webContents;503504// Set view background to match editor background505this.applyBackgroundColor();506507// Apply theme when page loads508this._webContents.on('did-finish-load', () => this.applyTheme());509510// Update theme when VS Code theme changes511this._register(this.themeMainService.onDidChangeColorScheme(() => {512this.applyBackgroundColor();513this.applyTheme();514}));515}516517private applyBackgroundColor(): void {518const backgroundColor = this.themeMainService.getBackgroundColor();519this._view.setBackgroundColor(backgroundColor);520}521522private async applyTheme(): Promise<void> {523if (this._webContents.isDestroyed()) {524return;525}526527const colorScheme = this.themeMainService.getColorScheme().dark ? 'dark' : 'light';528529try {530// Remove previous theme CSS if it exists531if (this._injectedCSSKey) {532await this._webContents.removeInsertedCSS(this._injectedCSSKey);533}534535// Insert new theme CSS536this._injectedCSSKey = await this._webContents.insertCSS(`537/* VS Code theme override */538:root {539color-scheme: ${colorScheme};540}541`);542} catch (error) {543this.logService.error('ThemePlugin: Failed to inject CSS', error);544}545}546}547548549