Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/browserView/electron-main/browserView.ts
5221 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { WebContentsView, webContents } from 'electron';
7
import { FileAccess } from '../../../base/common/network.js';
8
import { Disposable } from '../../../base/common/lifecycle.js';
9
import { Emitter, Event } from '../../../base/common/event.js';
10
import { VSBuffer } from '../../../base/common/buffer.js';
11
import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, BrowserNewPageLocation, browserViewIsolatedWorldId } from '../common/browserView.js';
12
import { EVENT_KEY_CODE_MAP, KeyCode, KeyMod, SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js';
13
import { IWindowsMainService } from '../../windows/electron-main/windows.js';
14
import { IBaseWindow, ICodeWindow } from '../../window/electron-main/window.js';
15
import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js';
16
import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js';
17
import { isMacintosh } from '../../../base/common/platform.js';
18
import { BrowserViewUri } from '../common/browserViewUri.js';
19
20
/** Key combinations that are used in system-level shortcuts. */
21
const nativeShortcuts = new Set([
22
KeyMod.CtrlCmd | KeyCode.KeyA,
23
KeyMod.CtrlCmd | KeyCode.KeyC,
24
KeyMod.CtrlCmd | KeyCode.KeyV,
25
KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyV,
26
KeyMod.CtrlCmd | KeyCode.KeyX,
27
...(isMacintosh ? [] : [KeyMod.CtrlCmd | KeyCode.KeyY]),
28
KeyMod.CtrlCmd | KeyCode.KeyZ,
29
KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyZ
30
]);
31
32
/**
33
* Represents a single browser view instance with its WebContentsView and all associated logic.
34
* This class encapsulates all operations and events for a single browser view.
35
*/
36
export class BrowserView extends Disposable {
37
private readonly _view: WebContentsView;
38
private readonly _faviconRequestCache = new Map<string, Promise<string>>();
39
40
private _lastScreenshot: VSBuffer | undefined = undefined;
41
private _lastFavicon: string | undefined = undefined;
42
private _lastError: IBrowserViewLoadError | undefined = undefined;
43
private _lastUserGestureTimestamp: number = -Infinity;
44
45
private _window: IBaseWindow | undefined;
46
private _isSendingKeyEvent = false;
47
48
private readonly _onDidNavigate = this._register(new Emitter<IBrowserViewNavigationEvent>());
49
readonly onDidNavigate: Event<IBrowserViewNavigationEvent> = this._onDidNavigate.event;
50
51
private readonly _onDidChangeLoadingState = this._register(new Emitter<IBrowserViewLoadingEvent>());
52
readonly onDidChangeLoadingState: Event<IBrowserViewLoadingEvent> = this._onDidChangeLoadingState.event;
53
54
private readonly _onDidChangeFocus = this._register(new Emitter<IBrowserViewFocusEvent>());
55
readonly onDidChangeFocus: Event<IBrowserViewFocusEvent> = this._onDidChangeFocus.event;
56
57
private readonly _onDidChangeVisibility = this._register(new Emitter<IBrowserViewVisibilityEvent>());
58
readonly onDidChangeVisibility: Event<IBrowserViewVisibilityEvent> = this._onDidChangeVisibility.event;
59
60
private readonly _onDidChangeDevToolsState = this._register(new Emitter<IBrowserViewDevToolsStateEvent>());
61
readonly onDidChangeDevToolsState: Event<IBrowserViewDevToolsStateEvent> = this._onDidChangeDevToolsState.event;
62
63
private readonly _onDidKeyCommand = this._register(new Emitter<IBrowserViewKeyDownEvent>());
64
readonly onDidKeyCommand: Event<IBrowserViewKeyDownEvent> = this._onDidKeyCommand.event;
65
66
private readonly _onDidChangeTitle = this._register(new Emitter<IBrowserViewTitleChangeEvent>());
67
readonly onDidChangeTitle: Event<IBrowserViewTitleChangeEvent> = this._onDidChangeTitle.event;
68
69
private readonly _onDidChangeFavicon = this._register(new Emitter<IBrowserViewFaviconChangeEvent>());
70
readonly onDidChangeFavicon: Event<IBrowserViewFaviconChangeEvent> = this._onDidChangeFavicon.event;
71
72
private readonly _onDidRequestNewPage = this._register(new Emitter<IBrowserViewNewPageRequest>());
73
readonly onDidRequestNewPage: Event<IBrowserViewNewPageRequest> = this._onDidRequestNewPage.event;
74
75
private readonly _onDidFindInPage = this._register(new Emitter<IBrowserViewFindInPageResult>());
76
readonly onDidFindInPage: Event<IBrowserViewFindInPageResult> = this._onDidFindInPage.event;
77
78
private readonly _onDidClose = this._register(new Emitter<void>());
79
readonly onDidClose: Event<void> = this._onDidClose.event;
80
81
constructor(
82
public readonly id: string,
83
private readonly viewSession: Electron.Session,
84
private readonly storageScope: BrowserViewStorageScope,
85
createChildView: (options?: Electron.WebContentsViewConstructorOptions) => BrowserView,
86
options: Electron.WebContentsViewConstructorOptions | undefined,
87
@IWindowsMainService private readonly windowsMainService: IWindowsMainService,
88
@IAuxiliaryWindowsMainService private readonly auxiliaryWindowsMainService: IAuxiliaryWindowsMainService
89
) {
90
super();
91
92
const webPreferences: Electron.WebPreferences & { type: ReturnType<Electron.WebContents['getType']> } = {
93
...options?.webPreferences,
94
95
nodeIntegration: false,
96
contextIsolation: true,
97
sandbox: true,
98
webviewTag: false,
99
session: viewSession,
100
preload: FileAccess.asFileUri('vs/platform/browserView/electron-browser/preload-browserView.js').fsPath,
101
102
// TODO@kycutler: Remove this once https://github.com/electron/electron/issues/42578 is fixed
103
type: 'browserView'
104
};
105
106
this._view = new WebContentsView({
107
webPreferences,
108
// Passing an `undefined` webContents triggers an error in Electron.
109
...(options?.webContents ? { webContents: options.webContents } : {})
110
});
111
this._view.setBackgroundColor('#FFFFFF');
112
113
this._view.webContents.setWindowOpenHandler((details) => {
114
const location = (() => {
115
switch (details.disposition) {
116
case 'background-tab': return BrowserNewPageLocation.Background;
117
case 'foreground-tab': return BrowserNewPageLocation.Foreground;
118
case 'new-window': return BrowserNewPageLocation.NewWindow;
119
default: return undefined;
120
}
121
})();
122
123
if (!location || !this.consumePopupPermission(location)) {
124
// Eventually we may want to surface this. For now, just silently block it.
125
return { action: 'deny' };
126
}
127
128
return {
129
action: 'allow',
130
createWindow: (options) => {
131
const childView = createChildView(options);
132
const resource = BrowserViewUri.forUrl(details.url, childView.id);
133
134
// Fire event for the workbench to open this view
135
this._onDidRequestNewPage.fire({
136
resource,
137
location,
138
position: { x: options.x, y: options.y, width: options.width, height: options.height }
139
});
140
141
// Return the webContents so Electron can complete the window.open() call
142
return childView.webContents;
143
}
144
};
145
});
146
147
this._view.webContents.on('destroyed', () => {
148
this._onDidClose.fire();
149
});
150
151
this.setupEventListeners();
152
}
153
154
private setupEventListeners(): void {
155
const webContents = this._view.webContents;
156
157
// DevTools state events
158
webContents.on('devtools-opened', () => {
159
this._onDidChangeDevToolsState.fire({ isDevToolsOpen: true });
160
});
161
162
webContents.on('devtools-closed', () => {
163
this._onDidChangeDevToolsState.fire({ isDevToolsOpen: false });
164
});
165
166
// Favicon events
167
webContents.on('page-favicon-updated', async (_event, favicons) => {
168
if (!favicons || favicons.length === 0) {
169
return;
170
}
171
172
const found = favicons.find(f => this._faviconRequestCache.get(f));
173
if (found) {
174
// already have a cached request for this favicon, use it
175
this._lastFavicon = await this._faviconRequestCache.get(found)!;
176
this._onDidChangeFavicon.fire({ favicon: this._lastFavicon });
177
return;
178
}
179
180
// try each url in order until one works
181
for (const url of favicons) {
182
const request = (async () => {
183
const response = await webContents.session.fetch(url, {
184
cache: 'force-cache'
185
});
186
const type = await response.headers.get('content-type');
187
const buffer = await response.arrayBuffer();
188
189
return `data:${type};base64,${Buffer.from(buffer).toString('base64')}`;
190
})();
191
192
this._faviconRequestCache.set(url, request);
193
194
try {
195
this._lastFavicon = await request;
196
this._onDidChangeFavicon.fire({ favicon: this._lastFavicon });
197
// On success, leave the promise in the cache and stop looping
198
return;
199
} catch (e) {
200
this._faviconRequestCache.delete(url);
201
// On failure, try the next one
202
}
203
}
204
});
205
206
// Title events
207
webContents.on('page-title-updated', (_event, title) => {
208
this._onDidChangeTitle.fire({ title });
209
});
210
211
const fireNavigationEvent = () => {
212
this._onDidNavigate.fire({
213
url: webContents.getURL(),
214
canGoBack: webContents.navigationHistory.canGoBack(),
215
canGoForward: webContents.navigationHistory.canGoForward()
216
});
217
};
218
219
const fireLoadingEvent = (loading: boolean) => {
220
this._onDidChangeLoadingState.fire({ loading, error: this._lastError });
221
};
222
223
// Loading state events
224
webContents.on('did-start-loading', () => {
225
this._lastError = undefined;
226
fireLoadingEvent(true);
227
});
228
webContents.on('did-stop-loading', () => fireLoadingEvent(false));
229
webContents.on('did-fail-load', (e, errorCode, errorDescription, validatedURL, isMainFrame) => {
230
if (isMainFrame) {
231
// Ignore ERR_ABORTED (-3) which is the expected error when user stops a page load.
232
if (errorCode === -3) {
233
fireLoadingEvent(false);
234
return;
235
}
236
237
this._lastError = {
238
url: validatedURL,
239
errorCode,
240
errorDescription
241
};
242
243
fireLoadingEvent(false);
244
this._onDidNavigate.fire({
245
url: validatedURL,
246
canGoBack: webContents.navigationHistory.canGoBack(),
247
canGoForward: webContents.navigationHistory.canGoForward()
248
});
249
}
250
});
251
webContents.on('did-finish-load', () => fireLoadingEvent(false));
252
253
webContents.on('render-process-gone', (_event, details) => {
254
this._lastError = {
255
url: webContents.getURL(),
256
errorCode: details.exitCode,
257
errorDescription: `Render process gone: ${details.reason}`
258
};
259
260
fireLoadingEvent(false);
261
});
262
263
// Navigation events (when URL actually changes)
264
webContents.on('did-navigate', fireNavigationEvent);
265
webContents.on('did-navigate-in-page', fireNavigationEvent);
266
267
// Focus events
268
webContents.on('focus', () => {
269
this._onDidChangeFocus.fire({ focused: true });
270
});
271
272
webContents.on('blur', () => {
273
this._onDidChangeFocus.fire({ focused: false });
274
});
275
276
// Key down events - listen for raw key input events
277
webContents.on('before-input-event', async (event, input) => {
278
if (input.type === 'keyDown' && !this._isSendingKeyEvent) {
279
if (this.tryHandleCommand(input)) {
280
event.preventDefault();
281
}
282
}
283
});
284
285
// Track user gestures for popup blocking logic.
286
// Roughly based on https://html.spec.whatwg.org/multipage/interaction.html#tracking-user-activation.
287
webContents.on('input-event', (_event, input) => {
288
switch (input.type) {
289
case 'rawKeyDown':
290
case 'keyDown':
291
case 'mouseDown':
292
case 'pointerDown':
293
case 'pointerUp':
294
case 'touchEnd':
295
this._lastUserGestureTimestamp = Date.now();
296
}
297
});
298
299
// For now, always prevent sites from blocking unload.
300
// In the future we may want to show a dialog to ask the user,
301
// with heavy restrictions regarding interaction and repeated prompts.
302
webContents.on('will-prevent-unload', (e) => {
303
e.preventDefault();
304
});
305
306
// Find in page events
307
webContents.on('found-in-page', (_event, result) => {
308
this._onDidFindInPage.fire({
309
activeMatchOrdinal: result.activeMatchOrdinal,
310
matches: result.matches,
311
selectionArea: result.selectionArea,
312
finalUpdate: result.finalUpdate
313
});
314
});
315
}
316
317
private consumePopupPermission(location: BrowserNewPageLocation): boolean {
318
switch (location) {
319
case BrowserNewPageLocation.Foreground:
320
case BrowserNewPageLocation.Background:
321
return true;
322
case BrowserNewPageLocation.NewWindow:
323
// Each user gesture allows one popup window within 1 second
324
if (this._lastUserGestureTimestamp > Date.now() - 1000) {
325
this._lastUserGestureTimestamp = -Infinity;
326
return true;
327
}
328
329
return false;
330
}
331
}
332
333
get webContents(): Electron.WebContents {
334
return this._view.webContents;
335
}
336
337
/**
338
* Get the current state of this browser view
339
*/
340
getState(): IBrowserViewState {
341
const webContents = this._view.webContents;
342
return {
343
url: webContents.getURL(),
344
title: webContents.getTitle(),
345
canGoBack: webContents.navigationHistory.canGoBack(),
346
canGoForward: webContents.navigationHistory.canGoForward(),
347
loading: webContents.isLoading(),
348
focused: webContents.isFocused(),
349
visible: this._view.getVisible(),
350
isDevToolsOpen: webContents.isDevToolsOpened(),
351
lastScreenshot: this._lastScreenshot,
352
lastFavicon: this._lastFavicon,
353
lastError: this._lastError,
354
storageScope: this.storageScope
355
};
356
}
357
358
/**
359
* Toggle developer tools for this browser view.
360
*/
361
toggleDevTools(): void {
362
this._view.webContents.toggleDevTools();
363
}
364
365
/**
366
* Update the layout bounds of this view
367
*/
368
layout(bounds: IBrowserViewBounds): void {
369
if (this._window?.win?.id !== bounds.windowId) {
370
const newWindow = this.windowById(bounds.windowId);
371
if (newWindow) {
372
this._window?.win?.contentView.removeChildView(this._view);
373
this._window = newWindow;
374
newWindow.win?.contentView.addChildView(this._view);
375
}
376
}
377
378
this._view.webContents.setZoomFactor(bounds.zoomFactor);
379
this._view.setBounds({
380
x: Math.round(bounds.x * bounds.zoomFactor),
381
y: Math.round(bounds.y * bounds.zoomFactor),
382
width: Math.round(bounds.width * bounds.zoomFactor),
383
height: Math.round(bounds.height * bounds.zoomFactor)
384
});
385
}
386
387
/**
388
* Set the visibility of this view
389
*/
390
setVisible(visible: boolean): void {
391
if (this._view.getVisible() === visible) {
392
return;
393
}
394
395
// If the view is focused, pass focus back to the window when hiding
396
if (!visible && this._view.webContents.isFocused()) {
397
this._window?.win?.webContents.focus();
398
}
399
400
this._view.setVisible(visible);
401
this._onDidChangeVisibility.fire({ visible });
402
}
403
404
/**
405
* Load a URL in this view
406
*/
407
async loadURL(url: string): Promise<void> {
408
await this._view.webContents.loadURL(url);
409
}
410
411
/**
412
* Get the current URL
413
*/
414
getURL(): string {
415
return this._view.webContents.getURL();
416
}
417
418
/**
419
* Navigate back in history
420
*/
421
goBack(): void {
422
if (this._view.webContents.navigationHistory.canGoBack()) {
423
this._view.webContents.navigationHistory.goBack();
424
}
425
}
426
427
/**
428
* Navigate forward in history
429
*/
430
goForward(): void {
431
if (this._view.webContents.navigationHistory.canGoForward()) {
432
this._view.webContents.navigationHistory.goForward();
433
}
434
}
435
436
/**
437
* Reload the current page
438
*/
439
reload(): void {
440
this._view.webContents.reload();
441
}
442
443
/**
444
* Check if the view can navigate back
445
*/
446
canGoBack(): boolean {
447
return this._view.webContents.navigationHistory.canGoBack();
448
}
449
450
/**
451
* Check if the view can navigate forward
452
*/
453
canGoForward(): boolean {
454
return this._view.webContents.navigationHistory.canGoForward();
455
}
456
457
/**
458
* Capture a screenshot of this view
459
*/
460
async captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise<VSBuffer> {
461
const quality = options?.quality ?? 80;
462
const image = await this._view.webContents.capturePage(options?.rect, {
463
stayHidden: true,
464
stayAwake: true
465
});
466
const buffer = image.toJPEG(quality);
467
const screenshot = VSBuffer.wrap(buffer);
468
// Only update _lastScreenshot if capturing the full view
469
if (!options?.rect) {
470
this._lastScreenshot = screenshot;
471
}
472
return screenshot;
473
}
474
475
/**
476
* Dispatch a keyboard event to this view
477
*/
478
async dispatchKeyEvent(keyEvent: IBrowserViewKeyDownEvent): Promise<void> {
479
const event: Electron.KeyboardInputEvent = {
480
type: 'keyDown',
481
keyCode: keyEvent.key,
482
modifiers: []
483
};
484
if (keyEvent.ctrlKey) {
485
event.modifiers!.push('control');
486
}
487
if (keyEvent.shiftKey) {
488
event.modifiers!.push('shift');
489
}
490
if (keyEvent.altKey) {
491
event.modifiers!.push('alt');
492
}
493
if (keyEvent.metaKey) {
494
event.modifiers!.push('meta');
495
}
496
this._isSendingKeyEvent = true;
497
try {
498
await this._view.webContents.sendInputEvent(event);
499
} finally {
500
this._isSendingKeyEvent = false;
501
}
502
}
503
504
/**
505
* Set the zoom factor of this view
506
*/
507
async setZoomFactor(zoomFactor: number): Promise<void> {
508
await this._view.webContents.setZoomFactor(zoomFactor);
509
}
510
511
/**
512
* Focus this view
513
*/
514
async focus(): Promise<void> {
515
this._view.webContents.focus();
516
}
517
518
/**
519
* Find text in the page
520
*/
521
async findInPage(text: string, options?: IBrowserViewFindInPageOptions): Promise<void> {
522
this._view.webContents.findInPage(text, {
523
matchCase: options?.matchCase ?? false,
524
forward: options?.forward ?? true,
525
526
// `findNext` is not very clearly named. From Electron docs: `Whether to begin a new text finding session with this request`.
527
// It needs to be set to `true` if we want a new search to be performed, such as when the text changes.
528
// We name it `recompute` in our internal options to better reflect its purpose / behavior.
529
findNext: options?.recompute ?? false
530
});
531
}
532
533
/**
534
* Stop finding in page
535
*/
536
async stopFindInPage(keepSelection?: boolean): Promise<void> {
537
this._view.webContents.stopFindInPage(keepSelection ? 'keepSelection' : 'clearSelection');
538
}
539
540
/**
541
* Get the currently selected text in the browser view.
542
* Returns immediately with empty string if the page is still loading.
543
*/
544
async getSelectedText(): Promise<string> {
545
// we don't want to wait for the page to finish loading, which executeJavaScript normally does.
546
if (this._view.webContents.isLoading()) {
547
return '';
548
}
549
try {
550
// Uses our preloaded contextBridge-exposed API.
551
return await this._view.webContents.executeJavaScriptInIsolatedWorld(browserViewIsolatedWorldId, [{ code: 'window.browserViewAPI?.getSelectedText?.() ?? ""' }]);
552
} catch {
553
return '';
554
}
555
}
556
557
/**
558
* Clear all storage data for this browser view's session
559
*/
560
async clearStorage(): Promise<void> {
561
await this.viewSession.clearData();
562
}
563
564
/**
565
* Get the underlying WebContentsView
566
*/
567
getWebContentsView(): WebContentsView {
568
return this._view;
569
}
570
571
override dispose(): void {
572
// Remove from parent window
573
this._window?.win?.contentView.removeChildView(this._view);
574
575
// Clean up the view and all its event listeners
576
// Note: webContents.close() automatically removes all event listeners
577
this._view.webContents.close({ waitForBeforeUnload: false });
578
579
super.dispose();
580
}
581
582
/**
583
* Potentially handle an input event as a VS Code command.
584
* Returns `true` if the event was forwarded to VS Code and should not be handled natively.
585
*/
586
private tryHandleCommand(input: Electron.Input): boolean {
587
const eventKeyCode = SCAN_CODE_STR_TO_EVENT_KEY_CODE[input.code] || 0;
588
const keyCode = EVENT_KEY_CODE_MAP[eventKeyCode] || KeyCode.Unknown;
589
590
const isArrowKey = keyCode >= KeyCode.LeftArrow && keyCode <= KeyCode.DownArrow;
591
const isNonEditingKey =
592
keyCode === KeyCode.Escape ||
593
keyCode >= KeyCode.F1 && keyCode <= KeyCode.F24 ||
594
keyCode >= KeyCode.AudioVolumeMute;
595
596
// Ignore most Alt-only inputs (often used for accented characters or menu accelerators)
597
const isAltOnlyInput = input.alt && !input.control && !input.meta;
598
if (isAltOnlyInput && !isNonEditingKey && !isArrowKey) {
599
return false;
600
}
601
602
// Only reroute if there's a command modifier or it's a non-editing key
603
const hasCommandModifier = input.control || input.alt || input.meta;
604
if (!hasCommandModifier && !isNonEditingKey) {
605
return false;
606
}
607
608
// Ignore Ctrl/Cmd + [A,C,V,X,Z] shortcuts to allow native handling (e.g. copy/paste)
609
const isControlInput = isMacintosh ? input.meta : input.control;
610
const modifiedKeyCode = keyCode |
611
(isControlInput ? KeyMod.CtrlCmd : 0) |
612
(input.shift ? KeyMod.Shift : 0) |
613
(input.alt ? KeyMod.Alt : 0);
614
if (nativeShortcuts.has(modifiedKeyCode)) {
615
return false;
616
}
617
618
this._onDidKeyCommand.fire({
619
key: input.key,
620
keyCode: eventKeyCode,
621
code: input.code,
622
ctrlKey: input.control || false,
623
shiftKey: input.shift || false,
624
altKey: input.alt || false,
625
metaKey: input.meta || false,
626
repeat: input.isAutoRepeat || false
627
});
628
return true;
629
}
630
631
private windowById(windowId: number | undefined): ICodeWindow | IAuxiliaryWindow | undefined {
632
return this.codeWindowById(windowId) ?? this.auxiliaryWindowById(windowId);
633
}
634
635
private codeWindowById(windowId: number | undefined): ICodeWindow | undefined {
636
if (typeof windowId !== 'number') {
637
return undefined;
638
}
639
640
return this.windowsMainService.getWindowById(windowId);
641
}
642
643
private auxiliaryWindowById(windowId: number | undefined): IAuxiliaryWindow | undefined {
644
if (typeof windowId !== 'number') {
645
return undefined;
646
}
647
648
const contents = webContents.fromId(windowId);
649
if (!contents) {
650
return undefined;
651
}
652
653
return this.auxiliaryWindowsMainService.getWindowByWebContents(contents);
654
}
655
}
656
657