Path: blob/main/src/vs/platform/browserView/electron-main/browserView.ts
5221 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 { FileAccess } from '../../../base/common/network.js';7import { Disposable } from '../../../base/common/lifecycle.js';8import { Emitter, Event } from '../../../base/common/event.js';9import { VSBuffer } from '../../../base/common/buffer.js';10import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, BrowserNewPageLocation, browserViewIsolatedWorldId } from '../common/browserView.js';11import { EVENT_KEY_CODE_MAP, KeyCode, KeyMod, SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.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 { isMacintosh } from '../../../base/common/platform.js';17import { BrowserViewUri } from '../common/browserViewUri.js';1819/** Key combinations that are used in system-level shortcuts. */20const nativeShortcuts = new Set([21KeyMod.CtrlCmd | KeyCode.KeyA,22KeyMod.CtrlCmd | KeyCode.KeyC,23KeyMod.CtrlCmd | KeyCode.KeyV,24KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyV,25KeyMod.CtrlCmd | KeyCode.KeyX,26...(isMacintosh ? [] : [KeyMod.CtrlCmd | KeyCode.KeyY]),27KeyMod.CtrlCmd | KeyCode.KeyZ,28KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyZ29]);3031/**32* Represents a single browser view instance with its WebContentsView and all associated logic.33* This class encapsulates all operations and events for a single browser view.34*/35export class BrowserView extends Disposable {36private readonly _view: WebContentsView;37private readonly _faviconRequestCache = new Map<string, Promise<string>>();3839private _lastScreenshot: VSBuffer | undefined = undefined;40private _lastFavicon: string | undefined = undefined;41private _lastError: IBrowserViewLoadError | undefined = undefined;42private _lastUserGestureTimestamp: number = -Infinity;4344private _window: IBaseWindow | undefined;45private _isSendingKeyEvent = false;4647private readonly _onDidNavigate = this._register(new Emitter<IBrowserViewNavigationEvent>());48readonly onDidNavigate: Event<IBrowserViewNavigationEvent> = this._onDidNavigate.event;4950private readonly _onDidChangeLoadingState = this._register(new Emitter<IBrowserViewLoadingEvent>());51readonly onDidChangeLoadingState: Event<IBrowserViewLoadingEvent> = this._onDidChangeLoadingState.event;5253private readonly _onDidChangeFocus = this._register(new Emitter<IBrowserViewFocusEvent>());54readonly onDidChangeFocus: Event<IBrowserViewFocusEvent> = this._onDidChangeFocus.event;5556private readonly _onDidChangeVisibility = this._register(new Emitter<IBrowserViewVisibilityEvent>());57readonly onDidChangeVisibility: Event<IBrowserViewVisibilityEvent> = this._onDidChangeVisibility.event;5859private readonly _onDidChangeDevToolsState = this._register(new Emitter<IBrowserViewDevToolsStateEvent>());60readonly onDidChangeDevToolsState: Event<IBrowserViewDevToolsStateEvent> = this._onDidChangeDevToolsState.event;6162private readonly _onDidKeyCommand = this._register(new Emitter<IBrowserViewKeyDownEvent>());63readonly onDidKeyCommand: Event<IBrowserViewKeyDownEvent> = this._onDidKeyCommand.event;6465private readonly _onDidChangeTitle = this._register(new Emitter<IBrowserViewTitleChangeEvent>());66readonly onDidChangeTitle: Event<IBrowserViewTitleChangeEvent> = this._onDidChangeTitle.event;6768private readonly _onDidChangeFavicon = this._register(new Emitter<IBrowserViewFaviconChangeEvent>());69readonly onDidChangeFavicon: Event<IBrowserViewFaviconChangeEvent> = this._onDidChangeFavicon.event;7071private readonly _onDidRequestNewPage = this._register(new Emitter<IBrowserViewNewPageRequest>());72readonly onDidRequestNewPage: Event<IBrowserViewNewPageRequest> = this._onDidRequestNewPage.event;7374private readonly _onDidFindInPage = this._register(new Emitter<IBrowserViewFindInPageResult>());75readonly onDidFindInPage: Event<IBrowserViewFindInPageResult> = this._onDidFindInPage.event;7677private readonly _onDidClose = this._register(new Emitter<void>());78readonly onDidClose: Event<void> = this._onDidClose.event;7980constructor(81public readonly id: string,82private readonly viewSession: Electron.Session,83private readonly storageScope: BrowserViewStorageScope,84createChildView: (options?: Electron.WebContentsViewConstructorOptions) => BrowserView,85options: Electron.WebContentsViewConstructorOptions | undefined,86@IWindowsMainService private readonly windowsMainService: IWindowsMainService,87@IAuxiliaryWindowsMainService private readonly auxiliaryWindowsMainService: IAuxiliaryWindowsMainService88) {89super();9091const webPreferences: Electron.WebPreferences & { type: ReturnType<Electron.WebContents['getType']> } = {92...options?.webPreferences,9394nodeIntegration: false,95contextIsolation: true,96sandbox: true,97webviewTag: false,98session: viewSession,99preload: FileAccess.asFileUri('vs/platform/browserView/electron-browser/preload-browserView.js').fsPath,100101// TODO@kycutler: Remove this once https://github.com/electron/electron/issues/42578 is fixed102type: 'browserView'103};104105this._view = new WebContentsView({106webPreferences,107// Passing an `undefined` webContents triggers an error in Electron.108...(options?.webContents ? { webContents: options.webContents } : {})109});110this._view.setBackgroundColor('#FFFFFF');111112this._view.webContents.setWindowOpenHandler((details) => {113const location = (() => {114switch (details.disposition) {115case 'background-tab': return BrowserNewPageLocation.Background;116case 'foreground-tab': return BrowserNewPageLocation.Foreground;117case 'new-window': return BrowserNewPageLocation.NewWindow;118default: return undefined;119}120})();121122if (!location || !this.consumePopupPermission(location)) {123// Eventually we may want to surface this. For now, just silently block it.124return { action: 'deny' };125}126127return {128action: 'allow',129createWindow: (options) => {130const childView = createChildView(options);131const resource = BrowserViewUri.forUrl(details.url, childView.id);132133// Fire event for the workbench to open this view134this._onDidRequestNewPage.fire({135resource,136location,137position: { x: options.x, y: options.y, width: options.width, height: options.height }138});139140// Return the webContents so Electron can complete the window.open() call141return childView.webContents;142}143};144});145146this._view.webContents.on('destroyed', () => {147this._onDidClose.fire();148});149150this.setupEventListeners();151}152153private setupEventListeners(): void {154const webContents = this._view.webContents;155156// DevTools state events157webContents.on('devtools-opened', () => {158this._onDidChangeDevToolsState.fire({ isDevToolsOpen: true });159});160161webContents.on('devtools-closed', () => {162this._onDidChangeDevToolsState.fire({ isDevToolsOpen: false });163});164165// Favicon events166webContents.on('page-favicon-updated', async (_event, favicons) => {167if (!favicons || favicons.length === 0) {168return;169}170171const found = favicons.find(f => this._faviconRequestCache.get(f));172if (found) {173// already have a cached request for this favicon, use it174this._lastFavicon = await this._faviconRequestCache.get(found)!;175this._onDidChangeFavicon.fire({ favicon: this._lastFavicon });176return;177}178179// try each url in order until one works180for (const url of favicons) {181const request = (async () => {182const response = await webContents.session.fetch(url, {183cache: 'force-cache'184});185const type = await response.headers.get('content-type');186const buffer = await response.arrayBuffer();187188return `data:${type};base64,${Buffer.from(buffer).toString('base64')}`;189})();190191this._faviconRequestCache.set(url, request);192193try {194this._lastFavicon = await request;195this._onDidChangeFavicon.fire({ favicon: this._lastFavicon });196// On success, leave the promise in the cache and stop looping197return;198} catch (e) {199this._faviconRequestCache.delete(url);200// On failure, try the next one201}202}203});204205// Title events206webContents.on('page-title-updated', (_event, title) => {207this._onDidChangeTitle.fire({ title });208});209210const fireNavigationEvent = () => {211this._onDidNavigate.fire({212url: webContents.getURL(),213canGoBack: webContents.navigationHistory.canGoBack(),214canGoForward: webContents.navigationHistory.canGoForward()215});216};217218const fireLoadingEvent = (loading: boolean) => {219this._onDidChangeLoadingState.fire({ loading, error: this._lastError });220};221222// Loading state events223webContents.on('did-start-loading', () => {224this._lastError = undefined;225fireLoadingEvent(true);226});227webContents.on('did-stop-loading', () => fireLoadingEvent(false));228webContents.on('did-fail-load', (e, errorCode, errorDescription, validatedURL, isMainFrame) => {229if (isMainFrame) {230// Ignore ERR_ABORTED (-3) which is the expected error when user stops a page load.231if (errorCode === -3) {232fireLoadingEvent(false);233return;234}235236this._lastError = {237url: validatedURL,238errorCode,239errorDescription240};241242fireLoadingEvent(false);243this._onDidNavigate.fire({244url: validatedURL,245canGoBack: webContents.navigationHistory.canGoBack(),246canGoForward: webContents.navigationHistory.canGoForward()247});248}249});250webContents.on('did-finish-load', () => fireLoadingEvent(false));251252webContents.on('render-process-gone', (_event, details) => {253this._lastError = {254url: webContents.getURL(),255errorCode: details.exitCode,256errorDescription: `Render process gone: ${details.reason}`257};258259fireLoadingEvent(false);260});261262// Navigation events (when URL actually changes)263webContents.on('did-navigate', fireNavigationEvent);264webContents.on('did-navigate-in-page', fireNavigationEvent);265266// Focus events267webContents.on('focus', () => {268this._onDidChangeFocus.fire({ focused: true });269});270271webContents.on('blur', () => {272this._onDidChangeFocus.fire({ focused: false });273});274275// Key down events - listen for raw key input events276webContents.on('before-input-event', async (event, input) => {277if (input.type === 'keyDown' && !this._isSendingKeyEvent) {278if (this.tryHandleCommand(input)) {279event.preventDefault();280}281}282});283284// Track user gestures for popup blocking logic.285// Roughly based on https://html.spec.whatwg.org/multipage/interaction.html#tracking-user-activation.286webContents.on('input-event', (_event, input) => {287switch (input.type) {288case 'rawKeyDown':289case 'keyDown':290case 'mouseDown':291case 'pointerDown':292case 'pointerUp':293case 'touchEnd':294this._lastUserGestureTimestamp = Date.now();295}296});297298// For now, always prevent sites from blocking unload.299// In the future we may want to show a dialog to ask the user,300// with heavy restrictions regarding interaction and repeated prompts.301webContents.on('will-prevent-unload', (e) => {302e.preventDefault();303});304305// Find in page events306webContents.on('found-in-page', (_event, result) => {307this._onDidFindInPage.fire({308activeMatchOrdinal: result.activeMatchOrdinal,309matches: result.matches,310selectionArea: result.selectionArea,311finalUpdate: result.finalUpdate312});313});314}315316private consumePopupPermission(location: BrowserNewPageLocation): boolean {317switch (location) {318case BrowserNewPageLocation.Foreground:319case BrowserNewPageLocation.Background:320return true;321case BrowserNewPageLocation.NewWindow:322// Each user gesture allows one popup window within 1 second323if (this._lastUserGestureTimestamp > Date.now() - 1000) {324this._lastUserGestureTimestamp = -Infinity;325return true;326}327328return false;329}330}331332get webContents(): Electron.WebContents {333return this._view.webContents;334}335336/**337* Get the current state of this browser view338*/339getState(): IBrowserViewState {340const webContents = this._view.webContents;341return {342url: webContents.getURL(),343title: webContents.getTitle(),344canGoBack: webContents.navigationHistory.canGoBack(),345canGoForward: webContents.navigationHistory.canGoForward(),346loading: webContents.isLoading(),347focused: webContents.isFocused(),348visible: this._view.getVisible(),349isDevToolsOpen: webContents.isDevToolsOpened(),350lastScreenshot: this._lastScreenshot,351lastFavicon: this._lastFavicon,352lastError: this._lastError,353storageScope: this.storageScope354};355}356357/**358* Toggle developer tools for this browser view.359*/360toggleDevTools(): void {361this._view.webContents.toggleDevTools();362}363364/**365* Update the layout bounds of this view366*/367layout(bounds: IBrowserViewBounds): void {368if (this._window?.win?.id !== bounds.windowId) {369const newWindow = this.windowById(bounds.windowId);370if (newWindow) {371this._window?.win?.contentView.removeChildView(this._view);372this._window = newWindow;373newWindow.win?.contentView.addChildView(this._view);374}375}376377this._view.webContents.setZoomFactor(bounds.zoomFactor);378this._view.setBounds({379x: Math.round(bounds.x * bounds.zoomFactor),380y: Math.round(bounds.y * bounds.zoomFactor),381width: Math.round(bounds.width * bounds.zoomFactor),382height: Math.round(bounds.height * bounds.zoomFactor)383});384}385386/**387* Set the visibility of this view388*/389setVisible(visible: boolean): void {390if (this._view.getVisible() === visible) {391return;392}393394// If the view is focused, pass focus back to the window when hiding395if (!visible && this._view.webContents.isFocused()) {396this._window?.win?.webContents.focus();397}398399this._view.setVisible(visible);400this._onDidChangeVisibility.fire({ visible });401}402403/**404* Load a URL in this view405*/406async loadURL(url: string): Promise<void> {407await this._view.webContents.loadURL(url);408}409410/**411* Get the current URL412*/413getURL(): string {414return this._view.webContents.getURL();415}416417/**418* Navigate back in history419*/420goBack(): void {421if (this._view.webContents.navigationHistory.canGoBack()) {422this._view.webContents.navigationHistory.goBack();423}424}425426/**427* Navigate forward in history428*/429goForward(): void {430if (this._view.webContents.navigationHistory.canGoForward()) {431this._view.webContents.navigationHistory.goForward();432}433}434435/**436* Reload the current page437*/438reload(): void {439this._view.webContents.reload();440}441442/**443* Check if the view can navigate back444*/445canGoBack(): boolean {446return this._view.webContents.navigationHistory.canGoBack();447}448449/**450* Check if the view can navigate forward451*/452canGoForward(): boolean {453return this._view.webContents.navigationHistory.canGoForward();454}455456/**457* Capture a screenshot of this view458*/459async captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise<VSBuffer> {460const quality = options?.quality ?? 80;461const image = await this._view.webContents.capturePage(options?.rect, {462stayHidden: true,463stayAwake: true464});465const buffer = image.toJPEG(quality);466const screenshot = VSBuffer.wrap(buffer);467// Only update _lastScreenshot if capturing the full view468if (!options?.rect) {469this._lastScreenshot = screenshot;470}471return screenshot;472}473474/**475* Dispatch a keyboard event to this view476*/477async dispatchKeyEvent(keyEvent: IBrowserViewKeyDownEvent): Promise<void> {478const event: Electron.KeyboardInputEvent = {479type: 'keyDown',480keyCode: keyEvent.key,481modifiers: []482};483if (keyEvent.ctrlKey) {484event.modifiers!.push('control');485}486if (keyEvent.shiftKey) {487event.modifiers!.push('shift');488}489if (keyEvent.altKey) {490event.modifiers!.push('alt');491}492if (keyEvent.metaKey) {493event.modifiers!.push('meta');494}495this._isSendingKeyEvent = true;496try {497await this._view.webContents.sendInputEvent(event);498} finally {499this._isSendingKeyEvent = false;500}501}502503/**504* Set the zoom factor of this view505*/506async setZoomFactor(zoomFactor: number): Promise<void> {507await this._view.webContents.setZoomFactor(zoomFactor);508}509510/**511* Focus this view512*/513async focus(): Promise<void> {514this._view.webContents.focus();515}516517/**518* Find text in the page519*/520async findInPage(text: string, options?: IBrowserViewFindInPageOptions): Promise<void> {521this._view.webContents.findInPage(text, {522matchCase: options?.matchCase ?? false,523forward: options?.forward ?? true,524525// `findNext` is not very clearly named. From Electron docs: `Whether to begin a new text finding session with this request`.526// It needs to be set to `true` if we want a new search to be performed, such as when the text changes.527// We name it `recompute` in our internal options to better reflect its purpose / behavior.528findNext: options?.recompute ?? false529});530}531532/**533* Stop finding in page534*/535async stopFindInPage(keepSelection?: boolean): Promise<void> {536this._view.webContents.stopFindInPage(keepSelection ? 'keepSelection' : 'clearSelection');537}538539/**540* Get the currently selected text in the browser view.541* Returns immediately with empty string if the page is still loading.542*/543async getSelectedText(): Promise<string> {544// we don't want to wait for the page to finish loading, which executeJavaScript normally does.545if (this._view.webContents.isLoading()) {546return '';547}548try {549// Uses our preloaded contextBridge-exposed API.550return await this._view.webContents.executeJavaScriptInIsolatedWorld(browserViewIsolatedWorldId, [{ code: 'window.browserViewAPI?.getSelectedText?.() ?? ""' }]);551} catch {552return '';553}554}555556/**557* Clear all storage data for this browser view's session558*/559async clearStorage(): Promise<void> {560await this.viewSession.clearData();561}562563/**564* Get the underlying WebContentsView565*/566getWebContentsView(): WebContentsView {567return this._view;568}569570override dispose(): void {571// Remove from parent window572this._window?.win?.contentView.removeChildView(this._view);573574// Clean up the view and all its event listeners575// Note: webContents.close() automatically removes all event listeners576this._view.webContents.close({ waitForBeforeUnload: false });577578super.dispose();579}580581/**582* Potentially handle an input event as a VS Code command.583* Returns `true` if the event was forwarded to VS Code and should not be handled natively.584*/585private tryHandleCommand(input: Electron.Input): boolean {586const eventKeyCode = SCAN_CODE_STR_TO_EVENT_KEY_CODE[input.code] || 0;587const keyCode = EVENT_KEY_CODE_MAP[eventKeyCode] || KeyCode.Unknown;588589const isArrowKey = keyCode >= KeyCode.LeftArrow && keyCode <= KeyCode.DownArrow;590const isNonEditingKey =591keyCode === KeyCode.Escape ||592keyCode >= KeyCode.F1 && keyCode <= KeyCode.F24 ||593keyCode >= KeyCode.AudioVolumeMute;594595// Ignore most Alt-only inputs (often used for accented characters or menu accelerators)596const isAltOnlyInput = input.alt && !input.control && !input.meta;597if (isAltOnlyInput && !isNonEditingKey && !isArrowKey) {598return false;599}600601// Only reroute if there's a command modifier or it's a non-editing key602const hasCommandModifier = input.control || input.alt || input.meta;603if (!hasCommandModifier && !isNonEditingKey) {604return false;605}606607// Ignore Ctrl/Cmd + [A,C,V,X,Z] shortcuts to allow native handling (e.g. copy/paste)608const isControlInput = isMacintosh ? input.meta : input.control;609const modifiedKeyCode = keyCode |610(isControlInput ? KeyMod.CtrlCmd : 0) |611(input.shift ? KeyMod.Shift : 0) |612(input.alt ? KeyMod.Alt : 0);613if (nativeShortcuts.has(modifiedKeyCode)) {614return false;615}616617this._onDidKeyCommand.fire({618key: input.key,619keyCode: eventKeyCode,620code: input.code,621ctrlKey: input.control || false,622shiftKey: input.shift || false,623altKey: input.alt || false,624metaKey: input.meta || false,625repeat: input.isAutoRepeat || false626});627return true;628}629630private windowById(windowId: number | undefined): ICodeWindow | IAuxiliaryWindow | undefined {631return this.codeWindowById(windowId) ?? this.auxiliaryWindowById(windowId);632}633634private codeWindowById(windowId: number | undefined): ICodeWindow | undefined {635if (typeof windowId !== 'number') {636return undefined;637}638639return this.windowsMainService.getWindowById(windowId);640}641642private auxiliaryWindowById(windowId: number | undefined): IAuxiliaryWindow | undefined {643if (typeof windowId !== 'number') {644return undefined;645}646647const contents = webContents.fromId(windowId);648if (!contents) {649return undefined;650}651652return this.auxiliaryWindowsMainService.getWindowByWebContents(contents);653}654}655656657