Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/vs/code/electron-main/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ import { IEncryptionMainService } from '../../platform/encryption/common/encrypt
import { EncryptionMainService } from '../../platform/encryption/electron-main/encryptionMainService.js';
import { NativeBrowserElementsMainService, INativeBrowserElementsMainService } from '../../platform/browserElements/electron-main/nativeBrowserElementsMainService.js';
import { ipcBrowserViewChannelName } from '../../platform/browserView/common/browserView.js';
import { ipcBrowserViewGroupChannelName } from '../../platform/browserView/common/browserViewGroup.js';
import { BrowserViewMainService, IBrowserViewMainService } from '../../platform/browserView/electron-main/browserViewMainService.js';
import { BrowserViewGroupMainService, IBrowserViewGroupMainService } from '../../platform/browserView/electron-main/browserViewGroupMainService.js';
import { BrowserViewCDPProxyServer, IBrowserViewCDPProxyServer } from '../../platform/browserView/electron-main/browserViewCDPProxyServer.js';
import { NativeParsedArgs } from '../../platform/environment/common/argv.js';
import { IEnvironmentMainService } from '../../platform/environment/electron-main/environmentMainService.js';
Expand Down Expand Up @@ -1043,6 +1045,7 @@ export class CodeApplication extends Disposable {
// Browser View
services.set(IBrowserViewCDPProxyServer, new SyncDescriptor(BrowserViewCDPProxyServer, undefined, true));
services.set(IBrowserViewMainService, new SyncDescriptor(BrowserViewMainService, undefined, false /* proxied to other processes */));
services.set(IBrowserViewGroupMainService, new SyncDescriptor(BrowserViewGroupMainService, undefined, false /* proxied to other processes */));

// Keyboard Layout
services.set(IKeyboardLayoutMainService, new SyncDescriptor(KeyboardLayoutMainService));
Expand Down Expand Up @@ -1206,6 +1209,11 @@ export class CodeApplication extends Disposable {
mainProcessElectronServer.registerChannel(ipcBrowserViewChannelName, browserViewChannel);
sharedProcessClient.then(client => client.registerChannel(ipcBrowserViewChannelName, browserViewChannel));

// Browser View Group
const browserViewGroupChannel = ProxyChannel.fromService(accessor.get(IBrowserViewGroupMainService), disposables);
mainProcessElectronServer.registerChannel(ipcBrowserViewGroupChannelName, browserViewGroupChannel);
sharedProcessClient.then(client => client.registerChannel(ipcBrowserViewGroupChannelName, browserViewGroupChannel));

// Signing
const signChannel = ProxyChannel.fromService(accessor.get(ISignService), disposables);
mainProcessElectronServer.registerChannel('sign', signChannel);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ import { IMeteredConnectionService } from '../../../platform/meteredConnection/c
import { MeteredConnectionChannelClient, METERED_CONNECTION_CHANNEL } from '../../../platform/meteredConnection/common/meteredConnectionIpc.js';
import { IPlaywrightService } from '../../../platform/browserView/common/playwrightService.js';
import { PlaywrightService } from '../../../platform/browserView/node/playwrightService.js';
import { IBrowserViewGroupRemoteService, BrowserViewGroupRemoteService } from '../../../platform/browserView/node/browserViewGroupRemoteService.js';

class SharedProcessMain extends Disposable implements IClientConnectionFilter {

Expand Down Expand Up @@ -404,6 +405,7 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter {
services.set(ISharedWebContentExtractorService, new SyncDescriptor(SharedWebContentExtractorService));

// Playwright
services.set(IBrowserViewGroupRemoteService, new SyncDescriptor(BrowserViewGroupRemoteService));
services.set(IPlaywrightService, new SyncDescriptor(PlaywrightService));

return new InstantiationService(services);
Expand Down
5 changes: 0 additions & 5 deletions src/vs/platform/browserView/common/browserView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,9 +275,4 @@ export interface IBrowserViewService {
* @param id The browser view identifier
*/
clearStorage(id: string): Promise<void>;

/**
* Get a CDP WebSocket endpoint URL.
*/
getDebugWebSocketEndpoint(): Promise<string>;
}
86 changes: 86 additions & 0 deletions src/vs/platform/browserView/common/browserViewGroup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*---------------------------------------------------------------------------------------------
* 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 { IDisposable } from '../../../base/common/lifecycle.js';

export const ipcBrowserViewGroupChannelName = 'browserViewGroup';

/**
* Fired when a browser view is added to or removed from a group.
*/
export interface IBrowserViewGroupViewEvent {
/** The ID of the browser view that was added or removed. */
readonly viewId: string;
}

/**
* A browser view group - an isolated collection of browser views.
*
* This interface is shared between the main-process entity and remote proxies.
*/
export interface IBrowserViewGroup extends IDisposable {
readonly id: string;

readonly onDidAddView: Event<IBrowserViewGroupViewEvent>;
readonly onDidRemoveView: Event<IBrowserViewGroupViewEvent>;
readonly onDidDestroy: Event<void>;

addView(viewId: string): Promise<void>;
removeView(viewId: string): Promise<void>;
getDebugWebSocketEndpoint(): Promise<string>;
}

/**
* Common service for managing browser view groups across processes.
*
* A browser view group is an isolated collection of browser views that can be
* independently exposed to different services or CDP clients.
*
* This interface is consumed via {@link ProxyChannel}.
* The main-process implementation is {@link BrowserViewGroupMainService}.
*/
export interface IBrowserViewGroupService {

// Dynamic events - one per group instance, keyed by group ID.
onDynamicDidAddView(groupId: string): Event<IBrowserViewGroupViewEvent>;
onDynamicDidRemoveView(groupId: string): Event<IBrowserViewGroupViewEvent>;
onDynamicDidDestroy(groupId: string): Event<void>;

/**
* Create a new browser view group.
* @returns The id of the newly created group.
*/
createGroup(): Promise<string>;

/**
* Destroy a browser view group.
* Views in the group are **not** destroyed - they are simply detached.
* @param groupId The group identifier.
*/
destroyGroup(groupId: string): Promise<void>;

/**
* Add a browser view to a group.
* A view can belong to multiple groups simultaneously.
* @param groupId The group identifier.
* @param viewId The browser view identifier.
*/
addViewToGroup(groupId: string, viewId: string): Promise<void>;

/**
* Remove a browser view from a group.
* @param groupId The group identifier.
* @param viewId The browser view identifier.
*/
removeViewFromGroup(groupId: string, viewId: string): Promise<void>;

/**
* Get a short-lived CDP WebSocket endpoint URL for a specific group.
* The returned URL contains a single-use token.
* @param groupId The group identifier.
*/
getDebugWebSocketEndpoint(groupId: string): Promise<string>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,44 +22,60 @@ export interface IBrowserViewCDPProxyServer {
readonly _serviceBrand: undefined;

/**
* Returns a debug endpoint with a short-lived, single-use token.
* Returns a debug endpoint with a short-lived, single-use token for a specific browser target.
*/
getWebSocketEndpoint(): Promise<string>;
getWebSocketEndpointForTarget(target: ICDPBrowserTarget): Promise<string>;

/**
* Unregister a previously registered browser target.
*/
removeTarget(target: ICDPBrowserTarget): Promise<void>;
}

/**
* WebSocket server that provides CDP debugging for browser views.
*
* Manages a registry of {@link ICDPBrowserTarget} instances, each reachable
* at its own `/devtools/browser/{id}` WebSocket endpoint.
*/
export class BrowserViewCDPProxyServer extends Disposable implements IBrowserViewCDPProxyServer {
declare readonly _serviceBrand: undefined;

private server: http.Server | undefined;
private port: number | undefined;
private readonly tokens: TokenManager;

private readonly tokens = this._register(new TokenManager<string>());
private readonly targets = new Map<string, ICDPBrowserTarget>();

constructor(
private readonly browserTarget: ICDPBrowserTarget,
@ILogService private readonly logService: ILogService
) {
super();

this.tokens = this._register(new TokenManager());
}

/**
* Returns a debug endpoint with a short-lived, single-use token in the
* WebSocket URL. The token is revoked once a WebSocket connection is made
* or after 30 seconds, whichever comes first.
* Register a browser target and return a WebSocket endpoint URL for it.
* The target is reachable at `/devtools/browser/{targetId}`.
*/
async getWebSocketEndpoint(): Promise<string> {
async getWebSocketEndpointForTarget(target: ICDPBrowserTarget): Promise<string> {
await this.ensureServerStarted();

const token = await this.tokens.issueToken();
return this.getWebSocketUrl(token);
const targetInfo = await target.getTargetInfo();
const targetId = targetInfo.targetId;

// Register (or re-register) the target
Comment thread
kycutler marked this conversation as resolved.
this.targets.set(targetId, target);

const token = await this.tokens.issueToken(targetId);
return `ws://localhost:${this.port}/devtools/browser/${targetId}?token=${token}`;
}

private getWebSocketUrl(token: string): string {
return `ws://localhost:${this.port}/devtools/browser?token=${token}`;
/**
* Unregister a previously registered browser target.
*/
async removeTarget(target: ICDPBrowserTarget): Promise<void> {
const targetInfo = await target.getTargetInfo();
this.targets.delete(targetInfo.targetId);
}

private async ensureServerStarted(): Promise<void> {
Expand Down Expand Up @@ -93,19 +109,30 @@ export class BrowserViewCDPProxyServer extends Disposable implements IBrowserVie
private handleWebSocketUpgrade(req: http.IncomingMessage, socket: Socket): void {
const [pathname, params] = (req.url || '').split('?');

const token = new URLSearchParams(params).get('token');
if (!token || !this.tokens.consumeToken(token)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
const browserMatch = pathname.match(/^\/devtools\/browser\/([^/?]+)$/);
Comment thread
kycutler marked this conversation as resolved.

this.logService.debug(`[BrowserViewDebugProxy] WebSocket upgrade requested: ${pathname}`);

if (!browserMatch) {
this.logService.warn(`[BrowserViewDebugProxy] Rejecting WebSocket on unknown path: ${pathname}`);
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
socket.end();
return;
}

const browserMatch = pathname.match(/^\/devtools\/browser(\/.*)?$/);
const targetId = browserMatch[1];

this.logService.debug(`[BrowserViewDebugProxy] WebSocket upgrade requested: ${pathname}`);
const token = new URLSearchParams(params).get('token');
const tokenTargetId = token && this.tokens.consumeToken(token);
if (!tokenTargetId || tokenTargetId !== targetId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.end();
return;
}

if (!browserMatch) {
this.logService.warn(`[BrowserViewDebugProxy] Rejecting WebSocket on unknown path: ${pathname}`);
const target = this.targets.get(targetId);
if (!target) {
this.logService.warn(`[BrowserViewDebugProxy] Browser target not found: ${targetId}`);
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
socket.end();
return;
Expand All @@ -122,7 +149,7 @@ export class BrowserViewCDPProxyServer extends Disposable implements IBrowserVie
return;
}

const proxy = new CDPBrowserProxy(this.browserTarget);
const proxy = new CDPBrowserProxy(target);
const disposables = this.wireWebSocket(upgraded, proxy);
this._register(disposables);
this._register(upgraded);
Expand Down Expand Up @@ -200,31 +227,35 @@ export class BrowserViewCDPProxyServer extends Disposable implements IBrowserVie
}
}

class TokenManager extends Disposable {
/** Map of currently valid single-use tokens. Each expires after 30 seconds. */
private readonly tokens = new Map<string, { expiresAt: number }>();
class TokenManager<TDetails> extends Disposable {
/** Map of currently valid single-use tokens to their associated details. */
private readonly tokens = new Map<string, { details: TDetails; expiresAt: number }>();

/**
* Creates a short-lived, single-use token.
* Creates a short-lived, single-use token bound to a specific target.
* The token is revoked once consumed or after 30 seconds.
*/
async issueToken(): Promise<string> {
async issueToken(details: TDetails): Promise<string> {
const token = this.makeToken();
this.tokens.set(token, { expiresAt: Date.now() + 30_000 });
this.tokens.set(token, { details: Object.freeze(details), expiresAt: Date.now() + 30_000 });
this._register(disposableTimeout(() => this.tokens.delete(token), 30_000));
return token;
}

consumeToken(token: string): boolean {
/**
* Consume a token. Returns the details it was issued with, or
* `undefined` if the token is invalid or expired.
*/
consumeToken(token: string): TDetails | undefined {
if (!token) {
return false;
return undefined;
}
const info = this.tokens.get(token);
if (!info) {
return false;
return undefined;
}
this.tokens.delete(token);
return Date.now() <= info.expiresAt;
return Date.now() <= info.expiresAt ? info.details : undefined;
}

private makeToken(): string {
Expand Down
Loading
Loading