diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index e5167429902..0ba8a2d2999 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -7,6 +7,7 @@ "enabledApiProposals": [ "activeComment", "authSession", + "browser", "environmentPower", "chatParticipantPrivate", "chatPromptFiles", diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts new file mode 100644 index 00000000000..fc0bcbb66bf --- /dev/null +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts @@ -0,0 +1,190 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { window, ViewColumn } from 'vscode'; +import { assertNoRpc, closeAllEditors } from '../utils'; + +(vscode.env.uiKind === vscode.UIKind.Web ? suite.skip : suite)('vscode API - browser', () => { + + teardown(async function () { + assertNoRpc(); + await closeAllEditors(); + }); + + // #region window.browserTabs / activeBrowserTab + + test('browserTabs is an array', () => { + assert.ok(Array.isArray(window.browserTabs)); + }); + + test('activeBrowserTab is undefined when no browser tab is open', () => { + assert.strictEqual(window.activeBrowserTab, undefined); + }); + + // #endregion + + // #region openBrowserTab + + test('openBrowserTab returns a BrowserTab with url, title, and icon', async () => { + const tab = await window.openBrowserTab('about:blank'); + + assert.ok(tab); + assert.strictEqual(tab.url, 'about:blank'); + assert.ok(tab.title); + assert.ok(tab.icon); + }); + + test('openBrowserTab adds tab to browserTabs', async () => { + const before = window.browserTabs.length; + await window.openBrowserTab('about:blank'); + assert.strictEqual(window.browserTabs.length, before + 1); + }); + + test('openBrowserTab with viewColumn.Beside', async () => { + const tab = await window.openBrowserTab('about:blank', { viewColumn: ViewColumn.Beside }); + assert.ok(tab); + assert.strictEqual(tab.url, 'about:blank'); + }); + + test('openBrowserTab with preserveFocus', async () => { + const tab = await window.openBrowserTab('about:blank', { preserveFocus: true }); + assert.ok(tab); + }); + + test('openBrowserTab with background', async () => { + const tab = await window.openBrowserTab('about:blank', { background: true }); + assert.ok(tab); + }); + + // #endregion + + // #region BrowserTab.close + + test('BrowserTab.close removes the tab from browserTabs', async () => { + const tab = await window.openBrowserTab('about:blank'); + const countBefore = window.browserTabs.length; + + await tab.close(); + + assert.strictEqual(window.browserTabs.length, countBefore - 1); + }); + + // #endregion + + // #region onDidOpenBrowserTab + + test('onDidOpenBrowserTab fires when a tab is opened', async () => { + const opened = new Promise(resolve => { + const disposable = window.onDidOpenBrowserTab(tab => { + disposable.dispose(); + resolve(tab); + }); + }); + + const tab = await window.openBrowserTab('about:blank'); + const firedTab = await opened; + assert.strictEqual(firedTab.url, tab.url); + }); + + // #endregion + + // #region onDidCloseBrowserTab + + test('onDidCloseBrowserTab fires when a tab is closed', async () => { + const tab = await window.openBrowserTab('about:blank'); + + const closed = new Promise(resolve => { + const disposable = window.onDidCloseBrowserTab(t => { + disposable.dispose(); + resolve(t); + }); + }); + + await tab.close(); + const firedTab = await closed; + assert.ok(firedTab); + }); + + // #endregion + + // #region activeBrowserTab / onDidChangeActiveBrowserTab + + test('activeBrowserTab is set after opening a tab', async () => { + await window.openBrowserTab('about:blank'); + assert.ok(window.activeBrowserTab); + }); + + test('onDidChangeActiveBrowserTab fires when active tab changes', async () => { + const changed = new Promise(resolve => { + const disposable = window.onDidChangeActiveBrowserTab(tab => { + disposable.dispose(); + resolve(tab); + }); + }); + + await window.openBrowserTab('about:blank'); + const activeTab = await changed; + assert.ok(activeTab); + }); + + // #endregion + + // #region CDP sessions + + test('startCDPSession returns a session with expected API', async () => { + const tab = await window.openBrowserTab('about:blank'); + const session = await tab.startCDPSession(); + + assert.ok(session); + assert.ok(session.onDidReceiveMessage); + assert.ok(session.onDidClose); + assert.ok(typeof session.sendMessage === 'function'); + assert.ok(typeof session.close === 'function'); + + await session.close(); + }); + + test('CDP sendMessage and onDidReceiveMessage round-trip', async () => { + const tab = await window.openBrowserTab('about:blank'); + const session = await tab.startCDPSession(); + + const response = new Promise(resolve => { + const disposable = session.onDidReceiveMessage((msg: any) => { + if (msg.id === 1) { + disposable.dispose(); + resolve(msg); + } + }); + }); + + await session.sendMessage({ id: 1, method: 'Target.getTargets' }); + const msg = await response; + assert.ok(msg.result); + const targets: any[] = msg.result.targetInfos; + assert.equal(targets.length, 1); + assert.equal(targets[0].url, 'about:blank'); + + await session.close(); + }); + + test('CDP session.close fires onDidClose', async () => { + const tab = await window.openBrowserTab('about:blank'); + const session = await tab.startCDPSession(); + + const closed = new Promise(resolve => { + const disposable = session.onDidClose(() => { + disposable.dispose(); + resolve(); + }); + }); + + await session.close(); + await closed; + }); + + // #endregion +}); diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 445951e0c29..d8a2d96987c 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -641,7 +641,9 @@ export class BrowserView extends Disposable implements ICDPTarget { this._onDidClose.fire(); // Clean up the view and all its event listeners - this._view.webContents.close({ waitForBeforeUnload: false }); + if (!this._view.webContents.isDestroyed()) { + this._view.webContents.close({ waitForBeforeUnload: false }); + } super.dispose(); } diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 65fd42574b0..37e1901f0a7 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -37,6 +37,9 @@ const _allApiProposals = { authenticationChallenges: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authenticationChallenges.d.ts', }, + browser: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.browser.d.ts', + }, canonicalUriProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.canonicalUriProvider.d.ts', }, diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 051a9f6f85d..b8ad97532e2 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -100,6 +100,7 @@ import './mainThreadChatSessions.js'; import './mainThreadDataChannels.js'; import './mainThreadMeteredConnection.js'; import './mainThreadGitExtensionService.js'; +import './mainThreadBrowsers.js'; export class ExtensionPoints implements IWorkbenchContribution { diff --git a/src/vs/workbench/api/browser/mainThreadBrowsers.ts b/src/vs/workbench/api/browser/mainThreadBrowsers.ts new file mode 100644 index 00000000000..933fdc2e1dd --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadBrowsers.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { IEditorService } from '../../services/editor/common/editorService.js'; +import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; +import { BrowserTabDto, ExtHostBrowsersShape, ExtHostContext, MainContext, MainThreadBrowsersShape } from '../common/extHost.protocol.js'; +import { IBrowserViewCDPService } from '../../contrib/browserView/common/browserView.js'; +import { BrowserViewUri } from '../../../platform/browserView/common/browserViewUri.js'; +import { EditorGroupColumn, columnToEditorGroup } from '../../services/editor/common/editorGroupColumn.js'; +import { IEditorGroupsService } from '../../services/editor/common/editorGroupsService.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { IEditorOptions } from '../../../platform/editor/common/editor.js'; +import { CDPRequest } from '../../../platform/browserView/common/cdp/types.js'; +import { BrowserEditorInput } from '../../contrib/browserView/common/browserEditorInput.js'; + +@extHostNamedCustomer(MainContext.MainThreadBrowsers) +export class MainThreadBrowsers extends Disposable implements MainThreadBrowsersShape { + + private readonly _proxy: ExtHostBrowsersShape; + + private readonly _cdpSessions = this._register(new DisposableMap()); + private readonly _knownBrowsers = this._register(new DisposableMap()); + + constructor( + extHostContext: IExtHostContext, + @IEditorService private readonly editorService: IEditorService, + @IBrowserViewCDPService private readonly cdpService: IBrowserViewCDPService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IConfigurationService private readonly configurationService: IConfigurationService, + ) { + super(); + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostBrowsers); + + // Track open browser editors + this._register(this.editorService.onWillOpenEditor((e) => { + if (e.editor instanceof BrowserEditorInput) { + this._track(e.editor); + } + })); + this._register(this.editorService.onDidCloseEditor(e => { + if (e.editor instanceof BrowserEditorInput) { + this._knownBrowsers.deleteAndDispose(e.editor.id); + } + })); + this._register(this.editorService.onDidActiveEditorChange(() => this._syncActiveBrowserTab())); + + // Initial sync + for (const input of this.editorService.editors) { + if (input instanceof BrowserEditorInput) { + this._track(input); + } + } + this._syncActiveBrowserTab(); + } + + // #region Browser tab open + + async $openBrowserTab(url: string, viewColumn?: EditorGroupColumn, options?: IEditorOptions): Promise { + const browserUri = BrowserViewUri.forUrl(url); + const parsed = BrowserViewUri.parse(browserUri)!; + + await this.editorService.openEditor( + { + resource: browserUri, + options + }, + columnToEditorGroup(this.editorGroupsService, this.configurationService, viewColumn), + ); + const known = this._knownBrowsers.get(parsed.id); + if (!known) { + throw new Error('Failed to open browser tab'); + } + + return this._toDto(known.input); + } + + // #endregion + + // #region Browser tab tracking + + private async _syncActiveBrowserTab(): Promise { + const active = this.editorService.activeEditorPane?.input; + if (active instanceof BrowserEditorInput) { + this._proxy.$onDidChangeActiveBrowserTab(this._toDto(active)); + } else { + this._proxy.$onDidChangeActiveBrowserTab(undefined); + } + } + + private _track(input: BrowserEditorInput): void { + if (this._knownBrowsers.has(input.id)) { + return; + } + const disposables = new DisposableStore(); + + // Track property changes. Currently all the tracked properties are covered under the `onDidChangeLabel` event. + disposables.add(input.onDidChangeLabel(() => { + this._proxy.$onDidChangeBrowserTabState(input.id, this._toDto(input)); + })); + disposables.add(input.onWillDispose(() => { + this._proxy.$onDidCloseBrowserTab(input.id); + this._knownBrowsers.deleteAndDispose(input.id); + })); + + this._knownBrowsers.set(input.id, { input, dispose: () => disposables.dispose() }); + this._proxy.$onDidOpenBrowserTab(this._toDto(input)); + } + + private _toDto(input: BrowserEditorInput): BrowserTabDto { + return { + id: input.id, + url: input.url || 'about:blank', + title: input.getTitle(), + favicon: input.favicon, + }; + } + + // #endregion + + // #region CDP session management + + async $startCDPSession(sessionId: string, browserId: string): Promise { + const known = this._knownBrowsers.get(browserId); + if (!known) { + throw new Error(`Unknown browser id: ${browserId}`); + } + + // Before starting a session, resolve the input to ensure the underlying web contents exist and can be attached. + await known.input.resolve(); + + const groupId = await this.cdpService.createSessionGroup(browserId); + const disposables = new DisposableStore(); + + // Wire CDP messages from main process back to ext host + disposables.add(this.cdpService.onCDPMessage(groupId)(message => { + this._proxy.$onCDPSessionMessage(sessionId, message); + })); + disposables.add(this.cdpService.onDidDestroy(groupId)(() => { + this._cdpSessions.deleteAndDispose(sessionId); + })); + disposables.add(toDisposable(() => { + this.cdpService.destroySessionGroup(groupId).catch(() => { }); + this._proxy.$onCDPSessionClosed(sessionId); + })); + + this._cdpSessions.set(sessionId, { groupId, dispose: () => disposables.dispose() }); + } + + async $closeCDPSession(sessionId: string): Promise { + this._cdpSessions.deleteAndDispose(sessionId); + } + + async $sendCDPMessage(sessionId: string, message: CDPRequest): Promise { + const session = this._cdpSessions.get(sessionId); + if (session) { + await this.cdpService.sendCDPMessage(session.groupId, message); + } + } + + async $closeBrowserTab(browserId: string): Promise { + const known = this._knownBrowsers.get(browserId); + if (!known) { + throw new Error(`Unknown browser id: ${browserId}`); + } + known.input.dispose(); + } + + // #endregion +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index ee4a9fe5a2d..2d2d7f8fce7 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -92,6 +92,7 @@ import { IExtHostSearch } from './extHostSearch.js'; import { IExtHostSecretState } from './extHostSecretState.js'; import { ExtHostShare } from './extHostShare.js'; import { ExtHostSpeech } from './extHostSpeech.js'; +import { ExtHostBrowsers } from './extHostBrowsers.js'; import { ExtHostStatusBar } from './extHostStatusBar.js'; import { IExtHostStorage } from './extHostStorage.js'; import { IExtensionStoragePaths } from './extHostStoragePaths.js'; @@ -247,6 +248,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostStatusBar = rpcProtocol.set(ExtHostContext.ExtHostStatusBar, new ExtHostStatusBar(rpcProtocol, extHostCommands.converter)); const extHostSpeech = rpcProtocol.set(ExtHostContext.ExtHostSpeech, new ExtHostSpeech(rpcProtocol)); const extHostEmbeddings = rpcProtocol.set(ExtHostContext.ExtHostEmbeddings, new ExtHostEmbeddings(rpcProtocol)); + const extHostBrowsers = rpcProtocol.set(ExtHostContext.ExtHostBrowsers, new ExtHostBrowsers(rpcProtocol)); rpcProtocol.set(ExtHostContext.ExtHostMcp, accessor.get(IExtHostMpcService)); @@ -1058,6 +1060,34 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatParticipantPrivate'); return _asExtensionEvent(extHostChatAgents2.onDidChangeActiveChatPanelSessionResource)(listeners, thisArgs, disposables); }, + get browserTabs() { + checkProposedApiEnabled(extension, 'browser'); + return extHostBrowsers.browserTabs; + }, + onDidOpenBrowserTab(listener, thisArg?, disposables?) { + checkProposedApiEnabled(extension, 'browser'); + return _asExtensionEvent(extHostBrowsers.onDidOpenBrowserTab)(listener, thisArg, disposables); + }, + onDidCloseBrowserTab(listener, thisArg?, disposables?) { + checkProposedApiEnabled(extension, 'browser'); + return _asExtensionEvent(extHostBrowsers.onDidCloseBrowserTab)(listener, thisArg, disposables); + }, + get activeBrowserTab() { + checkProposedApiEnabled(extension, 'browser'); + return extHostBrowsers.activeBrowserTab; + }, + onDidChangeActiveBrowserTab(listener, thisArg?, disposables?) { + checkProposedApiEnabled(extension, 'browser'); + return _asExtensionEvent(extHostBrowsers.onDidChangeActiveBrowserTab)(listener, thisArg, disposables); + }, + onDidChangeBrowserTabState(listener, thisArg?, disposables?) { + checkProposedApiEnabled(extension, 'browser'); + return _asExtensionEvent(extHostBrowsers.onDidChangeBrowserTabState)(listener, thisArg, disposables); + }, + openBrowserTab(url: string, options?: vscode.BrowserTabShowOptions) { + checkProposedApiEnabled(extension, 'browser'); + return extHostBrowsers.openBrowserTab(url, options); + }, }; // namespace: workspace diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 85c532b6d4a..cd07cdf94ad 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -34,6 +34,7 @@ import { IAccessibilityInformation } from '../../../platform/accessibility/commo import { ILocalizedString } from '../../../platform/action/common/action.js'; import { ConfigurationTarget, IConfigurationChange, IConfigurationData, IConfigurationOverrides } from '../../../platform/configuration/common/configuration.js'; import { ConfigurationScope } from '../../../platform/configuration/common/configurationRegistry.js'; +import { IEditorOptions } from '../../../platform/editor/common/editor.js'; import { IExtensionIdWithVersion } from '../../../platform/extensionManagement/common/extensionStorage.js'; import { ExtensionIdentifier, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import * as files from '../../../platform/files/common/files.js'; @@ -99,6 +100,7 @@ import { IExtHostDocumentSaveDelegate } from './extHostDocumentData.js'; import { TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js'; import * as tasks from './shared/tasks.js'; import { PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; +import { CDPEvent, CDPRequest, CDPResponse } from '../../../platform/browserView/common/cdp/types.js'; export type IconPathDto = | UriComponents @@ -1351,6 +1353,30 @@ export interface ExtHostSpeechShape { $cancelKeywordRecognitionSession(session: number): Promise; } +export interface BrowserTabDto { + id: string; + url: string; + title: string; + favicon: string | undefined; +} + +export interface MainThreadBrowsersShape extends IDisposable { + $openBrowserTab(url: string, viewColumn?: EditorGroupColumn, options?: IEditorOptions): Promise; + $closeBrowserTab(browserId: string): Promise; + $startCDPSession(sessionId: string, browserId: string): Promise; + $closeCDPSession(sessionId: string): Promise; + $sendCDPMessage(sessionId: string, message: CDPRequest): Promise; +} + +export interface ExtHostBrowsersShape { + $onDidOpenBrowserTab(browser: BrowserTabDto): void; + $onDidCloseBrowserTab(browserId: string): void; + $onDidChangeActiveBrowserTab(browser: BrowserTabDto | undefined): void; + $onDidChangeBrowserTabState(browserId: string, data: BrowserTabDto): void; + $onCDPSessionMessage(sessionId: string, message: CDPResponse | CDPEvent): void; + $onCDPSessionClosed(sessionId: string): void; +} + export interface MainThreadLanguageModelsShape extends IDisposable { $registerLanguageModelProvider(vendor: string): void; $onLMProviderChange(vendor: string): void; @@ -3765,6 +3791,7 @@ export const MainContext = { MainThreadChatOutputRenderer: createProxyIdentifier('MainThreadChatOutputRenderer'), MainThreadChatContext: createProxyIdentifier('MainThreadChatContext'), MainThreadChatDebug: createProxyIdentifier('MainThreadChatDebug'), + MainThreadBrowsers: createProxyIdentifier('MainThreadBrowsers'), }; export const ExtHostContext = { @@ -3845,4 +3872,5 @@ export const ExtHostContext = { ExtHostDataChannels: createProxyIdentifier('ExtHostDataChannels'), ExtHostChatSessions: createProxyIdentifier('ExtHostChatSessions'), ExtHostGitExtension: createProxyIdentifier('ExtHostGitExtension'), + ExtHostBrowsers: createProxyIdentifier('ExtHostBrowsers'), }; diff --git a/src/vs/workbench/api/common/extHostBrowsers.ts b/src/vs/workbench/api/common/extHostBrowsers.ts new file mode 100644 index 00000000000..a66e3113409 --- /dev/null +++ b/src/vs/workbench/api/common/extHostBrowsers.ts @@ -0,0 +1,271 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import type * as vscode from 'vscode'; +import { BrowserTabDto, ExtHostBrowsersShape, IMainContext, MainContext, MainThreadBrowsersShape } from './extHost.protocol.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import * as extHostTypes from './extHostTypes.js'; +import * as typeConverters from './extHostTypeConverters.js'; +import { CDPEvent, CDPRequest, CDPResponse } from '../../../platform/browserView/common/cdp/types.js'; + +// #region Internal browser tab object + +class ExtHostBrowserTab { + private _url: string; + private _title: string; + private _favicon: string | undefined; + + readonly value: vscode.BrowserTab; + + constructor( + readonly id: string, + private readonly _proxy: MainThreadBrowsersShape, + private readonly _sessions: DisposableMap, + data: BrowserTabDto, + ) { + this._url = data.url; + this._title = data.title; + this._favicon = data.favicon; + + const that = this; + this.value = { + get url(): string { return that._url; }, + get title(): string { return that._title; }, + get icon(): vscode.IconPath { + return that._favicon + ? URI.parse(that._favicon) + : new extHostTypes.ThemeIcon(Codicon.globe.id) as vscode.ThemeIcon; + }, + startCDPSession(): Promise { + return that._startCDPSession(); + }, + close(): Promise { + return that._close(); + } + }; + } + + update(data: BrowserTabDto): boolean { + let changed = false; + if (data.url !== this._url) { + this._url = data.url; + changed = true; + } + if (data.title !== this._title) { + this._title = data.title; + changed = true; + } + if (data.favicon !== this._favicon) { + this._favicon = data.favicon; + changed = true; + } + return changed; + } + + private async _startCDPSession(): Promise { + const sessionId = generateUuid(); + await this._proxy.$startCDPSession(sessionId, this.id); + const session = new ExtHostBrowserCDPSession(sessionId, this._proxy); + this._sessions.set(sessionId, session); + return session.value; + } + + private async _close(): Promise { + await this._proxy.$closeBrowserTab(this.id); + } +} + +// #endregion + +// #region CDP Session + +class ExtHostBrowserCDPSession { + private readonly _onDidReceiveMessage = new Emitter(); + private readonly _onDidClose = new Emitter(); + + private _closed = false; + + readonly value: vscode.BrowserCDPSession; + + constructor( + readonly id: string, + private readonly _proxy: MainThreadBrowsersShape, + ) { + const that = this; + this.value = { + get onDidReceiveMessage(): Event { return that._onDidReceiveMessage.event; }, + get onDidClose(): Event { return that._onDidClose.event; }, + sendMessage(message: unknown): Promise { + return that._sendMessage(message as CDPRequest); + }, + close(): Promise { + return that._close(); + } + }; + } + + dispose(): void { + this._onDidReceiveMessage.dispose(); + this._onDidClose.dispose(); + } + + private async _sendMessage(message: CDPRequest): Promise { + if (this._closed) { + throw new Error('Session is closed'); + } + if (!message || typeof message !== 'object') { + throw new Error('Message must be an object'); + } + if (typeof message.id !== 'number') { + throw new Error('Message must have a numeric id'); + } + if (typeof message.method !== 'string') { + throw new Error('Message must have a method string'); + } + if (message.params !== undefined && typeof message.params !== 'object') { + throw new Error('Message params must be an object'); + } + if (message.sessionId !== undefined && typeof message.sessionId !== 'string') { + throw new Error('Message sessionId must be a string'); + } + await this._proxy.$sendCDPMessage(this.id, { id: message.id, method: message.method, params: message.params, sessionId: message.sessionId }); + } + + private async _close(): Promise { + this._closed = true; + await this._proxy.$closeCDPSession(this.id); + } + + // Called from main thread + _acceptMessage(message: unknown): void { + this._onDidReceiveMessage.fire(message); + } + + _acceptClosed(): void { + this._closed = true; + this._onDidClose.fire(); + } +} + +// #endregion + +export class ExtHostBrowsers extends Disposable implements ExtHostBrowsersShape { + private readonly _proxy: MainThreadBrowsersShape; + private readonly _browserTabs = new Map(); + private readonly _sessions = this._register(new DisposableMap()); + + private _activeBrowserTabId: string | undefined; + + private readonly _onDidOpenBrowserTab = this._register(new Emitter()); + readonly onDidOpenBrowserTab: Event = this._onDidOpenBrowserTab.event; + + private readonly _onDidCloseBrowserTab = this._register(new Emitter()); + readonly onDidCloseBrowserTab: Event = this._onDidCloseBrowserTab.event; + + private readonly _onDidChangeActiveBrowserTab = this._register(new Emitter()); + readonly onDidChangeActiveBrowserTab: Event = this._onDidChangeActiveBrowserTab.event; + + private readonly _onDidChangeBrowserTabState = this._register(new Emitter()); + readonly onDidChangeBrowserTabState: Event = this._onDidChangeBrowserTabState.event; + + constructor(mainContext: IMainContext) { + super(); + this._proxy = mainContext.getProxy(MainContext.MainThreadBrowsers); + } + + // #region Public API (called from extension code) + + get browserTabs(): readonly vscode.BrowserTab[] { + return [...this._browserTabs.values()].map(t => t.value); + } + + get activeBrowserTab(): vscode.BrowserTab | undefined { + if (this._activeBrowserTabId) { + return this._browserTabs.get(this._activeBrowserTabId)?.value; + } + return undefined; + } + + async openBrowserTab(url: string, options?: vscode.BrowserTabShowOptions): Promise { + const viewColumn = typeConverters.ViewColumn.from(options?.viewColumn); + const dto = await this._proxy.$openBrowserTab(url, viewColumn, { + preserveFocus: options?.preserveFocus, + inactive: options?.background, + }); + + return this._getOrCreateTab(dto).value; + } + + // #endregion + + // #region Internal helpers + + private _getOrCreateTab(dto: BrowserTabDto): ExtHostBrowserTab { + let tab = this._browserTabs.get(dto.id); + if (!tab) { + tab = new ExtHostBrowserTab(dto.id, this._proxy, this._sessions, dto); + this._browserTabs.set(dto.id, tab); + this._onDidOpenBrowserTab.fire(tab.value); + } else { + tab.update(dto); + } + return tab; + } + + // #endregion + + // #region Main thread callbacks + + $onDidOpenBrowserTab(dto: BrowserTabDto): void { + this._getOrCreateTab(dto); + } + + $onDidCloseBrowserTab(browserId: string): void { + const tab = this._browserTabs.get(browserId); + if (tab) { + this._browserTabs.delete(browserId); + if (this._activeBrowserTabId === browserId) { + this._activeBrowserTabId = undefined; + } + this._onDidCloseBrowserTab.fire(tab.value); + } + } + + $onDidChangeActiveBrowserTab(dto: BrowserTabDto | undefined): void { + this._activeBrowserTabId = dto?.id; + if (dto) { + this._getOrCreateTab(dto); + } + this._onDidChangeActiveBrowserTab.fire(this.activeBrowserTab); + } + + $onDidChangeBrowserTabState(browserId: string, data: BrowserTabDto): void { + const tab = this._browserTabs.get(browserId); + if (tab && tab.update(data)) { + this._onDidChangeBrowserTabState.fire(tab.value); + } + } + + $onCDPSessionMessage(sessionId: string, message: CDPResponse | CDPEvent): void { + const session = this._sessions.get(sessionId); + if (session) { + session._acceptMessage(message); + } + } + + $onCDPSessionClosed(sessionId: string): void { + const session = this._sessions.get(sessionId); + if (session) { + session._acceptClosed(); + this._sessions.deleteAndDispose(sessionId); + } + } + + // #endregion +} diff --git a/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts b/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts new file mode 100644 index 00000000000..e05e040cf70 --- /dev/null +++ b/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts @@ -0,0 +1,428 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import assert from 'assert'; +import { mock } from '../../../../base/test/common/mock.js'; +import { BrowserTabDto, MainThreadBrowsersShape } from '../../common/extHost.protocol.js'; +import { ExtHostBrowsers } from '../../common/extHostBrowsers.js'; +import { SingleProxyRPCProtocol } from '../common/testRPCProtocol.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; + +suite('ExtHostBrowsers', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const defaultDto: BrowserTabDto = { + id: 'browser-1', + url: 'https://example.com', + title: 'Example', + favicon: undefined, + }; + + function createDto(overrides?: Partial): BrowserTabDto { + return { ...defaultDto, ...overrides }; + } + + function createExtHostBrowsers(overrides?: Partial): ExtHostBrowsers { + const proxy = new class extends mock() { + override $openBrowserTab(): Promise { return Promise.resolve(createDto()); } + override $startCDPSession(): Promise { return Promise.resolve(); } + override $closeCDPSession(): Promise { return Promise.resolve(); } + override $sendCDPMessage(): Promise { return Promise.resolve(); } + override $closeBrowserTab(): Promise { return Promise.resolve(); } + }; + if (overrides) { + Object.assign(proxy, overrides); + } + return store.add(new ExtHostBrowsers(SingleProxyRPCProtocol(proxy))); + } + + // #region browserTabs + + test('browserTabs populates from $onDidOpenBrowserTab', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://one.com', title: 'One' })); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b2', url: 'https://two.com', title: 'Two' })); + + const tabs = extHost.browserTabs; + assert.strictEqual(tabs.length, 2); + assert.strictEqual(tabs[0].url, 'https://one.com'); + assert.strictEqual(tabs[1].url, 'https://two.com'); + }); + + test('browserTabs returns a snapshot, not a live array', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' })); + const snapshot1 = extHost.browserTabs; + + extHost.$onDidOpenBrowserTab(createDto({ id: 'b2' })); + const snapshot2 = extHost.browserTabs; + + assert.notStrictEqual(snapshot1, snapshot2); + assert.strictEqual(snapshot1.length, 1); + assert.strictEqual(snapshot2.length, 2); + }); + + // #endregion + + // #region activeBrowserTab + + test('activeBrowserTab updates via $onDidChangeActiveBrowserTab', () => { + const extHost = createExtHostBrowsers(); + const dto = createDto({ id: 'b1', url: 'https://active.com' }); + extHost.$onDidOpenBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab(dto); + + assert.strictEqual(extHost.activeBrowserTab?.url, 'https://active.com'); + }); + + test('activeBrowserTab becomes undefined when cleared', () => { + const extHost = createExtHostBrowsers(); + const dto = createDto({ id: 'b1' }); + extHost.$onDidOpenBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab(dto); + assert.ok(extHost.activeBrowserTab); + + extHost.$onDidChangeActiveBrowserTab(undefined); + assert.strictEqual(extHost.activeBrowserTab, undefined); + }); + + test('$onDidChangeActiveBrowserTab with unknown tab creates it and fires open event', () => { + const extHost = createExtHostBrowsers(); + const opened: vscode.BrowserTab[] = []; + store.add(extHost.onDidOpenBrowserTab(tab => opened.push(tab))); + + extHost.$onDidChangeActiveBrowserTab(createDto({ id: 'new-tab', url: 'https://new.com' })); + + assert.strictEqual(extHost.activeBrowserTab?.url, 'https://new.com'); + assert.strictEqual(extHost.browserTabs.length, 1); + assert.strictEqual(opened.length, 1, 'onDidOpenBrowserTab should fire for the new tab'); + }); + + // #endregion + + // #region openBrowserTab + + test('openBrowserTab returns a BrowserTab with correct properties', async () => { + const dto = createDto({ id: 'opened', url: 'https://opened.com', title: 'Opened' }); + const extHost = createExtHostBrowsers({ + $openBrowserTab: () => Promise.resolve(dto), + }); + + const tab = await extHost.openBrowserTab('https://opened.com'); + assert.strictEqual(tab.url, 'https://opened.com'); + assert.strictEqual(tab.title, 'Opened'); + }); + + test('openBrowserTab fires onDidOpenBrowserTab for new tabs', async () => { + const extHost = createExtHostBrowsers({ + $openBrowserTab: () => Promise.resolve(createDto({ id: 'new-tab' })), + }); + const opened: vscode.BrowserTab[] = []; + store.add(extHost.onDidOpenBrowserTab(tab => opened.push(tab))); + + await extHost.openBrowserTab('https://example.com'); + + assert.strictEqual(opened.length, 1); + assert.strictEqual(opened[0].url, 'https://example.com'); + }); + + test('openBrowserTab reuses existing tab when IDs match', async () => { + const extHost = createExtHostBrowsers({ + $openBrowserTab: () => Promise.resolve(createDto({ id: 'same', url: 'https://updated.com' })), + }); + + extHost.$onDidOpenBrowserTab(createDto({ id: 'same', url: 'https://original.com' })); + const tab = await extHost.openBrowserTab('https://updated.com'); + + assert.strictEqual(extHost.browserTabs.length, 1); + assert.strictEqual(tab.url, 'https://updated.com'); + }); + + test('openBrowserTab forwards options to proxy', async () => { + let capturedViewColumn: number | undefined; + let capturedOptions: { preserveFocus?: boolean; inactive?: boolean } | undefined; + const extHost = createExtHostBrowsers({ + $openBrowserTab: (_url: string, viewColumn?: number, options?: { preserveFocus?: boolean; inactive?: boolean }) => { + capturedViewColumn = viewColumn; + capturedOptions = options; + return Promise.resolve(createDto({ id: 'opts' })); + }, + }); + + await extHost.openBrowserTab('https://example.com', { viewColumn: 2, preserveFocus: true, background: true }); + + // ViewColumn.from converts API viewColumn (1-based) to EditorGroupColumn (0-based) + assert.strictEqual(capturedViewColumn, 1); + assert.strictEqual(capturedOptions?.preserveFocus, true); + assert.strictEqual(capturedOptions?.inactive, true); + }); + + // #endregion + + // #region $onDidOpenBrowserTab + + test('$onDidOpenBrowserTab fires event', () => { + const extHost = createExtHostBrowsers(); + const opened: vscode.BrowserTab[] = []; + store.add(extHost.onDidOpenBrowserTab(tab => opened.push(tab))); + + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://opened.com' })); + + assert.strictEqual(opened.length, 1); + assert.strictEqual(opened[0].url, 'https://opened.com'); + }); + + // #endregion + + // #region $onDidCloseBrowserTab + + test('$onDidCloseBrowserTab removes tab and fires event', () => { + const extHost = createExtHostBrowsers(); + const changes: vscode.BrowserTab[] = []; + store.add(extHost.onDidChangeBrowserTabState(tab => changes.push(tab))); + + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://old.com' })); + extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', url: 'https://new.com' })); + + assert.strictEqual(changes.length, 1); + assert.strictEqual(changes[0].url, 'https://new.com'); + }); + + test('$onDidChangeBrowserTabState does not fire when data is unchanged', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://example.com', title: 'Old Title' })); + + extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', url: 'https://example.com', title: 'New Title' })); + + assert.strictEqual(extHost.browserTabs[0].url, 'https://example.com'); + assert.strictEqual(extHost.browserTabs[0].title, 'New Title'); + }); + + // #endregion + + // #region $onDidChangeActiveBrowserTab event + + test('$onDidChangeActiveBrowserTab fires event', () => { + const extHost = createExtHostBrowsers(); + const activeChanges: (string | undefined)[] = []; + store.add(extHost.onDidChangeActiveBrowserTab(tab => activeChanges.push(tab?.url))); + + const dto = createDto({ id: 'b1' }); + extHost.$onDidOpenBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab(undefined); + + assert.deepStrictEqual(activeChanges, ['https://example.com', undefined]); + }); + + // #endregion + + // #region BrowserTab icon + + test('icon is globe ThemeIcon when no favicon', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: undefined })); + + assert.strictEqual((extHost.browserTabs[0].icon as { id: string }).id, 'globe'); + }); + + test('icon is URI when favicon is provided', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: 'https://example.com/favicon.ico' })); + + assert.strictEqual(String(extHost.browserTabs[0].icon), 'https://example.com/favicon.ico'); + }); + + test('icon updates when favicon changes', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: undefined })); + assert.strictEqual((extHost.browserTabs[0].icon as { id: string }).id, 'globe'); + + extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', favicon: 'https://example.com/new.ico' })); + assert.strictEqual(String(extHost.browserTabs[0].icon), 'https://example.com/new.ico'); + }); + + test('icon reverts to globe when favicon is cleared', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: 'https://example.com/icon.ico' })); + assert.strictEqual(String(extHost.browserTabs[0].icon), 'https://example.com/icon.ico'); + + extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', favicon: undefined })); + assert.strictEqual((extHost.browserTabs[0].icon as { id: string }).id, 'globe'); + }); + + // #endregion + + // #region BrowserTab readonly properties + + test('tab properties are not directly writable', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://example.com', title: 'Title' })); + const tab = extHost.browserTabs[0]; + + // Attempting to assign to getter-only properties should either throw or be silently ignored + assert.throws(() => { (tab as unknown as Record).url = 'https://hacked.com'; }); + assert.throws(() => { (tab as unknown as Record).title = 'Hacked'; }); + assert.strictEqual(tab.url, 'https://example.com'); + assert.strictEqual(tab.title, 'Title'); + }); + + test('startCDPSession calls $startCDPSession on proxy', async () => { + let capturedBrowserId: string | undefined; + const extHost = createExtHostBrowsers({ + $startCDPSession: (_sessionId: string, browserId: string) => { + capturedBrowserId = browserId; + return Promise.resolve(); + }, + }); + + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' })); + const session = await extHost.browserTabs[0].startCDPSession(); + + assert.ok(session); + assert.strictEqual(capturedBrowserId, 'b1'); + }); + + test('sendMessage validates message structure', async () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' })); + const session = await extHost.browserTabs[0].startCDPSession(); + + // Valid message succeeds + await session.sendMessage({ id: 1, method: 'Page.enable' }); + + // Invalid messages are rejected + await assert.rejects(Promise.resolve().then(() => session.sendMessage(null as never)), /must be an object/); + await assert.rejects(Promise.resolve().then(() => session.sendMessage({ method: 'Foo' } as never)), /numeric id/); + await assert.rejects(Promise.resolve().then(() => session.sendMessage({ id: 1 } as never)), /method string/); + }); + + test('sendMessage forwards valid message to proxy', async () => { + const sentMessages: unknown[] = []; + const extHost = createExtHostBrowsers({ + $sendCDPMessage: (_sid: string, message: unknown) => { + sentMessages.push(message); + return Promise.resolve(); + }, + }); + + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' })); + const session = await extHost.browserTabs[0].startCDPSession(); + await session.sendMessage({ id: 1, method: 'Page.enable', params: {} }); + + assert.strictEqual(sentMessages.length, 1); + assert.deepStrictEqual(sentMessages[0], { id: 1, method: 'Page.enable', params: {}, sessionId: undefined }); + }); + + test('sendMessage rejects after session is closed', async () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' })); + const session = await extHost.browserTabs[0].startCDPSession(); + + await session.close(); + await assert.rejects(Promise.resolve().then(() => session.sendMessage({ id: 1, method: 'Foo' })), /closed/); + }); + + test('$onCDPSessionMessage delivers to correct session', async () => { + const capturedIds: string[] = []; + const extHost = createExtHostBrowsers({ + $startCDPSession: (sessionId: string) => { + capturedIds.push(sessionId); + return Promise.resolve(); + }, + }); + + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' })); + const session1 = await extHost.browserTabs[0].startCDPSession(); + const session2 = await extHost.browserTabs[0].startCDPSession(); + + const received1: unknown[] = []; + const received2: unknown[] = []; + store.add(session1.onDidReceiveMessage(m => received1.push(m))); + store.add(session2.onDidReceiveMessage(m => received2.push(m))); + + extHost.$onCDPSessionMessage(capturedIds[1], { id: 1, result: { data: 'hello' } }); + + assert.deepStrictEqual(received1, []); + assert.deepStrictEqual(received2, [{ id: 1, result: { data: 'hello' } }]); + }); + + test('$onCDPSessionClosed fires onDidClose', async () => { + const capturedIds: string[] = []; + const extHost = createExtHostBrowsers({ + $startCDPSession: (sessionId: string) => { + capturedIds.push(sessionId); + return Promise.resolve(); + }, + }); + + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' })); + const session = await extHost.browserTabs[0].startCDPSession(); + + let closeFired = false; + store.add(session.onDidClose(() => { closeFired = true; })); + + extHost.$onCDPSessionClosed(capturedIds[0]); + assert.ok(closeFired); + }); + + // #endregion + + // #region Reference stability + + test('tab object reference is stable across updates', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://old.com', title: 'Old' })); + const tabBefore = extHost.browserTabs[0]; + + extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', url: 'https://new.com', title: 'New' })); + const tabAfter = extHost.browserTabs[0]; + + assert.strictEqual(tabBefore, tabAfter); + assert.strictEqual(tabAfter.url, 'https://new.com'); + }); + + test('openBrowserTab returns same reference as browserTabs entry', async () => { + const extHost = createExtHostBrowsers({ + $openBrowserTab: () => Promise.resolve(createDto({ id: 'ref-test' })), + }); + + const returned = await extHost.openBrowserTab('https://example.com'); + const fromArray = extHost.browserTabs[0]; + + assert.strictEqual(returned, fromArray); + }); + + // #endregion + + // #region Multiple tabs tracked independently + + test('closing one tab does not affect others', () => { + const extHost = createExtHostBrowsers(); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://one.com' })); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b2', url: 'https://two.com' })); + extHost.$onDidOpenBrowserTab(createDto({ id: 'b3', url: 'https://three.com' })); + + extHost.$onDidCloseBrowserTab('b2'); + + assert.strictEqual(extHost.browserTabs.length, 2); + assert.deepStrictEqual(extHost.browserTabs.map(t => t.url), ['https://one.com', 'https://three.com']); + }); + + test('closing active tab clears activeBrowserTab', () => { + const extHost = createExtHostBrowsers(); + const dto = createDto({ id: 'b1' }); + extHost.$onDidOpenBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab(dto); + assert.ok(extHost.activeBrowserTab); + + extHost.$onDidCloseBrowserTab('b1'); + assert.strictEqual(extHost.activeBrowserTab, undefined); + }); + + // #endregion +}); diff --git a/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts new file mode 100644 index 00000000000..a16c153f57e --- /dev/null +++ b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; +import { IBrowserViewWorkbenchService, IBrowserViewCDPService, IBrowserViewModel } from '../common/browserView.js'; +import { Event } from '../../../../base/common/event.js'; +import { CDPEvent, CDPRequest, CDPResponse } from '../../../../platform/browserView/common/cdp/types.js'; + +class WebBrowserViewWorkbenchService implements IBrowserViewWorkbenchService { + declare readonly _serviceBrand: undefined; + + async getOrCreateBrowserViewModel(_id: string): Promise { + throw new Error('Integrated Browser is not available in web.'); + } + + async getBrowserViewModel(_id: string): Promise { + throw new Error('Integrated Browser is not available in web.'); + } + + async clearGlobalStorage(): Promise { } + async clearWorkspaceStorage(): Promise { } +} + +class WebBrowserViewCDPService implements IBrowserViewCDPService { + declare readonly _serviceBrand: undefined; + + async createSessionGroup(_browserId: string): Promise { + throw new Error('Integrated Browser is not available in web.'); + } + + async destroySessionGroup(_groupId: string): Promise { } + + async sendCDPMessage(_groupId: string, _message: CDPRequest): Promise { } + + onCDPMessage(_groupId: string): Event { + return Event.None; + } + + onDidDestroy(_groupId: string): Event { + return Event.None; + } +} + +registerSingleton(IBrowserViewWorkbenchService, WebBrowserViewWorkbenchService, InstantiationType.Delayed); +registerSingleton(IBrowserViewCDPService, WebBrowserViewCDPService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts similarity index 96% rename from src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts rename to src/vs/workbench/contrib/browserView/common/browserEditorInput.ts index 9a7f53e85ed..0bb4c51cbc6 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditorInput.ts +++ b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts @@ -18,7 +18,6 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { IBrowserViewWorkbenchService, IBrowserViewModel } from '../common/browserView.js'; import { hasKey } from '../../../../base/common/types.js'; import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; -import { BrowserEditor } from './browserEditor.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; @@ -48,6 +47,7 @@ export interface IBrowserEditorInputData { export class BrowserEditorInput extends EditorInput { static readonly ID = 'workbench.editorinputs.browser'; + static readonly EDITOR_ID = 'workbench.editor.browser'; private static readonly DEFAULT_LABEL = localize('browser.editorLabel', "Browser"); private readonly _id: string; @@ -84,6 +84,16 @@ export class BrowserEditorInput extends EditorInput { return this._id; } + get url(): string | undefined { + // Use model URL if available, otherwise fall back to initial data + return this._model ? this._model.url : this._initialData.url; + } + + get favicon(): string | undefined { + // Use model favicon if available, otherwise fall back to initial data + return this._model ? this._model.favicon : this._initialData.favicon; + } + override async resolve(): Promise { if (!this._model && !this._modelPromise) { this._modelPromise = (async () => { @@ -122,7 +132,7 @@ export class BrowserEditorInput extends EditorInput { } override get editorId(): string { - return BrowserEditor.ID; + return BrowserEditorInput.EDITOR_ID; } override get capabilities(): EditorInputCapabilities { @@ -182,8 +192,7 @@ export class BrowserEditorInput extends EditorInput { } override getDescription(): string | undefined { - // Use model URL if available, otherwise fall back to initial data - return this._model ? this._model.url : this._initialData.url; + return this.url; } override canReopen(): boolean { diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 6910a4b80b7..6b3804b5651 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -7,6 +7,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; +import { CDPEvent, CDPRequest, CDPResponse } from '../../../../platform/browserView/common/cdp/types.js'; import { IPlaywrightService } from '../../../../platform/browserView/common/playwrightService.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -107,6 +108,36 @@ export interface IBrowserViewWorkbenchService { clearWorkspaceStorage(): Promise; } +export const IBrowserViewCDPService = createDecorator('browserViewCDPService'); + +/** + * Workbench-level service for managing CDP (Chrome DevTools Protocol) sessions + * against browser views. Handles group lifecycle and window ID resolution. + */ +export interface IBrowserViewCDPService { + readonly _serviceBrand: undefined; + + /** + * Create a new CDP group for a browser view. + * The window ID is resolved from the editor group containing the browser. + * @param browserId The browser view identifier. + * @returns The ID of the newly created group. + */ + createSessionGroup(browserId: string): Promise; + + /** Destroy a CDP group. */ + destroySessionGroup(groupId: string): Promise; + + /** Send a CDP message to a group. */ + sendCDPMessage(groupId: string, message: CDPRequest): Promise; + + /** Fires when a CDP message is received. */ + onCDPMessage(groupId: string): Event; + + /** Fires when a CDP group is destroyed. */ + onDidDestroy(groupId: string): Event; +} + /** * A browser view model that represents a single browser view instance in the workbench. diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index ced81f81333..0843a2f6a62 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -16,7 +16,7 @@ import { ServiceCollection } from '../../../../platform/instantiation/common/ser import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; -import { BrowserEditorInput } from './browserEditorInput.js'; +import { BrowserEditorInput } from '../common/browserEditorInput.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; import { IBrowserViewModel } from '../../browserView/common/browserView.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; @@ -232,8 +232,6 @@ class BrowserNavigationBar extends Disposable { } export class BrowserEditor extends EditorPane { - static readonly ID = 'workbench.editor.browser'; - private _overlayVisible = false; private _editorVisible = false; private _currentKeyDownEvent: IBrowserViewKeyDownEvent | undefined; @@ -281,7 +279,7 @@ export class BrowserEditor extends EditorPane { @IConfigurationService private readonly configurationService: IConfigurationService, @ILayoutService private readonly layoutService: ILayoutService ) { - super(BrowserEditor.ID, group, telemetryService, themeService, storageService); + super(BrowserEditorInput.EDITOR_ID, group, telemetryService, themeService, storageService); } protected override createEditor(parent: HTMLElement): void { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index 5eb52a1277a..4925689c0fc 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -9,7 +9,7 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js'; import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor.js'; import { BrowserEditor } from './browserEditor.js'; -import { BrowserEditorInput, BrowserEditorSerializer } from './browserEditorInput.js'; +import { BrowserEditorInput, BrowserEditorSerializer } from '../common/browserEditorInput.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; @@ -18,8 +18,9 @@ import { workbenchConfigurationNodeBase } from '../../../common/configuration.js import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { Schemas } from '../../../../base/common/network.js'; -import { IBrowserViewWorkbenchService } from '../common/browserView.js'; +import { IBrowserViewWorkbenchService, IBrowserViewCDPService } from '../common/browserView.js'; import { BrowserViewWorkbenchService } from './browserViewWorkbenchService.js'; +import { BrowserViewCDPService } from './browserViewCDPService.js'; import { BrowserZoomService, IBrowserZoomService, MATCH_WINDOW_ZOOM_LABEL } from '../common/browserZoomService.js'; import { browserZoomFactors, BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; import { IExternalOpener, IOpenerService } from '../../../../platform/opener/common/opener.js'; @@ -43,7 +44,7 @@ import './tools/browserTools.contribution.js'; Registry.as(EditorExtensions.EditorPane).registerEditorPane( EditorPaneDescriptor.create( BrowserEditor, - BrowserEditor.ID, + BrowserEditorInput.EDITOR_ID, localize('browser.editorLabel', "Browser") ), [ @@ -168,6 +169,7 @@ class WindowZoomSynchronizer extends Disposable implements IWorkbenchContributio registerWorkbenchContribution2(WindowZoomSynchronizer.ID, WindowZoomSynchronizer, WorkbenchPhase.Eventually); registerSingleton(IBrowserViewWorkbenchService, BrowserViewWorkbenchService, InstantiationType.Delayed); +registerSingleton(IBrowserViewCDPService, BrowserViewCDPService, InstantiationType.Delayed); registerSingleton(IBrowserZoomService, BrowserZoomService, InstantiationType.Delayed); Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index 75dca6c5ccc..14f4857deeb 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -20,9 +20,10 @@ import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; +import { BrowserEditorInput } from '../common/browserEditorInput.js'; // Context key expression to check if browser editor is active -const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditor.ID); +const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditorInput.EDITOR_ID); const BrowserCategory = localize2('browserCategory', "Browser"); const ActionGroupTabs = '1_tabs'; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewCDPService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewCDPService.ts new file mode 100644 index 00000000000..7770b21efb4 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewCDPService.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { CDPEvent, CDPRequest, CDPResponse } from '../../../../platform/browserView/common/cdp/types.js'; +import { IBrowserViewGroupService, ipcBrowserViewGroupChannelName } from '../../../../platform/browserView/common/browserViewGroup.js'; +import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; +import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; +import { IBrowserViewCDPService } from '../common/browserView.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; + +export class BrowserViewCDPService extends Disposable implements IBrowserViewCDPService { + declare readonly _serviceBrand: undefined; + + private readonly _groupService: IBrowserViewGroupService; + + constructor( + @IMainProcessService mainProcessService: IMainProcessService, + @IEditorService private readonly editorService: IEditorService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + ) { + super(); + const channel = mainProcessService.getChannel(ipcBrowserViewGroupChannelName); + this._groupService = ProxyChannel.toService(channel); + } + + async createSessionGroup(browserId: string): Promise { + const windowId = this._getWindowIdForBrowser(browserId); + const groupId = await this._groupService.createGroup(windowId); + await this._groupService.addViewToGroup(groupId, browserId); + return groupId; + } + + async destroySessionGroup(groupId: string): Promise { + await this._groupService.destroyGroup(groupId); + } + + async sendCDPMessage(groupId: string, message: CDPRequest): Promise { + await this._groupService.sendCDPMessage(groupId, message); + } + + onCDPMessage(groupId: string): Event { + return this._groupService.onDynamicCDPMessage(groupId); + } + + onDidDestroy(groupId: string): Event { + return this._groupService.onDynamicDidDestroy(groupId); + } + + private _getWindowIdForBrowser(browserId: string): number { + const browserUri = BrowserViewUri.forUrl(undefined, browserId); + const editors = this.editorService.findEditors(browserUri); + if (editors.length > 0) { + const group = this.editorGroupsService.getGroup(editors[0].groupId); + if (group) { + return group.windowId; + } + } + // Fall back to main window + return this.editorGroupsService.mainPart.windowId; + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts index ee655808073..b113304a284 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts @@ -13,7 +13,7 @@ import { registerWorkbenchContribution2, WorkbenchPhase, type IWorkbenchContribu import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IChatContextService } from '../../../chat/browser/contextContrib/chatContextService.js'; import { ILanguageModelToolsService, ToolDataSource, ToolSet } from '../../../chat/common/tools/languageModelToolsService.js'; -import { BrowserEditorInput } from '../browserEditorInput.js'; +import { BrowserEditorInput } from '../../common/browserEditorInput.js'; import { ClickBrowserTool, ClickBrowserToolData } from './clickBrowserTool.js'; import { DragElementTool, DragElementToolData } from './dragElementTool.js'; import { HandleDialogBrowserTool, HandleDialogBrowserToolData } from './handleDialogBrowserTool.js'; diff --git a/src/vs/workbench/workbench.web.main.ts b/src/vs/workbench/workbench.web.main.ts index aee7366ed53..939822a241a 100644 --- a/src/vs/workbench/workbench.web.main.ts +++ b/src/vs/workbench/workbench.web.main.ts @@ -174,4 +174,7 @@ import './contrib/remote/browser/remoteStartEntry.contribution.js'; // Process Explorer import './contrib/processExplorer/browser/processExplorer.web.contribution.js'; +// Browser View +import './contrib/browserView/browser/browserView.contribution.js'; + //#endregion diff --git a/src/vscode-dts/vscode.proposed.browser.d.ts b/src/vscode-dts/vscode.proposed.browser.d.ts new file mode 100644 index 00000000000..a15d432b3b7 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.browser.d.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // @kycutler https://github.com/microsoft/vscode/issues/300319 + + /** + * An integrated browser page displayed in an editor tab. + */ + export interface BrowserTab { + /** The current URL of the page. */ + readonly url: string; + + /** The current page title. */ + readonly title: string; + + /** The page icon (favicon or a default globe icon). */ + readonly icon: IconPath; + + /** Create a new CDP session that exposes this browser tab. */ + startCDPSession(): Thenable; + + /** Close this browser tab. */ + close(): Thenable; + } + + /** + * A CDP (Chrome DevTools Protocol) session that provides a bidirectional message channel. + * + * Create a session via {@link BrowserTab.startCDPSession}. + */ + export interface BrowserCDPSession { + /** Fires when a CDP message is received from an attached target. */ + readonly onDidReceiveMessage: Event; + + /** Fires when this session is closed. */ + readonly onDidClose: Event; + + /** Send a CDP request message to an attached target. */ + sendMessage(message: unknown): Thenable; + + /** Close this session and detach all targets. */ + close(): Thenable; + } + + /** Options for {@link window.openBrowserTab}. */ + export interface BrowserTabShowOptions { + /** + * The view column to show the browser in. Defaults to {@link ViewColumn.Active}. + * Use {@linkcode ViewColumn.Beside} to open next to the current editor. + */ + viewColumn?: ViewColumn; + + /** When `true`, the browser tab will not take focus. */ + preserveFocus?: boolean; + + /** When `true`, the browser tab will open in the background. */ + background?: boolean; + } + + export namespace window { + /** The currently open browser tabs. */ + export const browserTabs: readonly BrowserTab[]; + + /** Fires when a browser tab is opened. */ + export const onDidOpenBrowserTab: Event; + + /** Fires when a browser tab is closed. */ + export const onDidCloseBrowserTab: Event; + + /** The currently active browser tab. */ + export const activeBrowserTab: BrowserTab | undefined; + + /** Fires when {@link activeBrowserTab} changes. */ + export const onDidChangeActiveBrowserTab: Event; + + /** Fires when a browser tab's state (url, title, or icon) changes. */ + export const onDidChangeBrowserTabState: Event; + + /** + * Open a browser tab at the given URL. + * + * @param url The URL to navigate to. + * @param options Controls where and how the browser tab is shown. + * @returns The {@link BrowserTab} representing the opened page. + */ + export function openBrowserTab(url: string, options?: BrowserTabShowOptions): Thenable; + } +}