From 22f51304a26bcaca031f52270f0b2ceb68467fb2 Mon Sep 17 00:00:00 2001 From: Afshawn Lotfi Date: Sun, 9 Mar 2025 20:11:52 +0000 Subject: [PATCH 1/4] Add support for remote browser connection and settings --- src/core/webview/ClineProvider.ts | 7 +++ src/services/browser/BrowserSession.ts | 47 ++++++++++++++++++- src/shared/ExtensionMessage.ts | 1 + src/shared/WebviewMessage.ts | 1 + src/shared/globalState.ts | 1 + .../components/settings/BrowserSettings.tsx | 33 ++++++++++++- .../src/components/settings/SettingsView.tsx | 3 ++ 7 files changed, 90 insertions(+), 3 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 657e4a9ab6f..3e8ed516687 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1262,6 +1262,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("browserViewportSize", browserViewportSize) await this.postStateToWebview() break + case "remoteBrowserHost": + await this.updateGlobalState("remoteBrowserHost", message.text) + await this.postStateToWebview() + break case "fuzzyMatchThreshold": await this.updateGlobalState("fuzzyMatchThreshold", message.value) await this.postStateToWebview() @@ -2188,6 +2192,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { soundVolume, browserViewportSize, screenshotQuality, + remoteBrowserHost, preferredLanguage, writeDelayMs, terminalOutputLimit, @@ -2246,6 +2251,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { soundVolume: soundVolume ?? 0.5, browserViewportSize: browserViewportSize ?? "900x600", screenshotQuality: screenshotQuality ?? 75, + remoteBrowserHost, preferredLanguage: preferredLanguage ?? "English", writeDelayMs: writeDelayMs ?? 1000, terminalOutputLimit: terminalOutputLimit ?? TERMINAL_OUTPUT_LIMIT, @@ -2399,6 +2405,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { soundVolume: stateValues.soundVolume, browserViewportSize: stateValues.browserViewportSize ?? "900x600", screenshotQuality: stateValues.screenshotQuality ?? 75, + remoteBrowserHost: stateValues.remoteBrowserHost, fuzzyMatchThreshold: stateValues.fuzzyMatchThreshold ?? 1.0, writeDelayMs: stateValues.writeDelayMs ?? 1000, terminalOutputLimit: stateValues.terminalOutputLimit ?? TERMINAL_OUTPUT_LIMIT, diff --git a/src/services/browser/BrowserSession.ts b/src/services/browser/BrowserSession.ts index bed03322446..6fe25ddf5b9 100644 --- a/src/services/browser/BrowserSession.ts +++ b/src/services/browser/BrowserSession.ts @@ -1,11 +1,12 @@ import * as vscode from "vscode" import * as fs from "fs/promises" import * as path from "path" -import { Browser, Page, ScreenshotOptions, TimeoutError, launch } from "puppeteer-core" +import { Browser, Page, ScreenshotOptions, TimeoutError, launch, connect } from "puppeteer-core" // @ts-ignore import PCR from "puppeteer-chromium-resolver" import pWaitFor from "p-wait-for" import delay from "delay" +import axios from "axios" import { fileExistsAtPath } from "../../utils/fs" import { BrowserActionResult } from "../../shared/ExtensionMessage" @@ -52,6 +53,41 @@ export class BrowserSession { await this.closeBrowser() // this may happen when the model launches a browser again after having used it already before } + const remoteBrowserHost = this.context.globalState.get("remoteBrowserHost") as string | undefined + + if (remoteBrowserHost) { + console.log(`Attempting to connect to remote browser at ${remoteBrowserHost}`) + try { + // Fetch the WebSocket endpoint from the Chrome DevTools Protocol + const versionUrl = `${remoteBrowserHost.replace(/\/$/, "")}/json/version` + console.log(`Fetching WebSocket endpoint from ${versionUrl}`) + + const response = await axios.get(versionUrl) + const browserWSEndpoint = response.data.webSocketDebuggerUrl + + if (!browserWSEndpoint) { + throw new Error("Could not find webSocketDebuggerUrl in the response") + } + + console.log(`Found WebSocket endpoint: ${browserWSEndpoint}`) + + this.browser = await connect({ + browserWSEndpoint, + defaultViewport: (() => { + const size = + (this.context.globalState.get("browserViewportSize") as string | undefined) || "900x600" + const [width, height] = size.split("x").map(Number) + return { width, height } + })(), + }) + this.page = await this.browser?.newPage() + return + } catch (error) { + console.error(`Failed to connect to remote browser: ${error}`) + // Fall back to local browser if remote connection fails + } + } + const stats = await this.ensureChromiumExists() this.browser = await stats.puppeteer.launch({ args: [ @@ -72,7 +108,14 @@ export class BrowserSession { async closeBrowser(): Promise { if (this.browser || this.page) { console.log("closing browser...") - await this.browser?.close().catch(() => {}) + + const remoteBrowserHost = this.context.globalState.get("remoteBrowserHost") as string | undefined + if (remoteBrowserHost && this.browser) { + await this.browser.disconnect().catch(() => {}) + } else { + await this.browser?.close().catch(() => {}) + } + this.browser = undefined this.page = undefined this.currentMousePosition = undefined diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index b7e3d850cfd..5b638e6534e 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -123,6 +123,7 @@ export interface ExtensionState { checkpointStorage: CheckpointStorage browserViewportSize?: string screenshotQuality?: number + remoteBrowserHost?: string fuzzyMatchThreshold?: number preferredLanguage: string writeDelayMs: number diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 216c7588d72..df5c7d29f91 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -57,6 +57,7 @@ export interface WebviewMessage { | "checkpointStorage" | "browserViewportSize" | "screenshotQuality" + | "remoteBrowserHost" | "openMcpSettings" | "restartMcpServer" | "toggleToolAlwaysAllow" diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index 35e53bbe9c6..05f54bfb8b6 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -66,6 +66,7 @@ export const GLOBAL_STATE_KEYS = [ "checkpointStorage", "browserViewportSize", "screenshotQuality", + "remoteBrowserHost", "fuzzyMatchThreshold", "preferredLanguage", // Language setting for Cline's communication "writeDelayMs", diff --git a/webview-ui/src/components/settings/BrowserSettings.tsx b/webview-ui/src/components/settings/BrowserSettings.tsx index 1ff0a36b9cf..ab4a88113a0 100644 --- a/webview-ui/src/components/settings/BrowserSettings.tsx +++ b/webview-ui/src/components/settings/BrowserSettings.tsx @@ -12,13 +12,17 @@ type BrowserSettingsProps = HTMLAttributes & { browserToolEnabled?: boolean browserViewportSize?: string screenshotQuality?: number - setCachedStateField: SetCachedStateField<"browserToolEnabled" | "browserViewportSize" | "screenshotQuality"> + remoteBrowserHost?: string + setCachedStateField: SetCachedStateField< + "browserToolEnabled" | "browserViewportSize" | "screenshotQuality" | "remoteBrowserHost" + > } export const BrowserSettings = ({ browserToolEnabled, browserViewportSize, screenshotQuality, + remoteBrowserHost, setCachedStateField, ...props }: BrowserSettingsProps) => { @@ -96,6 +100,33 @@ export const BrowserSettings = ({ screenshots but increase token usage.

+
+ + + setCachedStateField("remoteBrowserHost", e.target.value || undefined) + } + /> +

+ Connect to a remote Chrome browser by providing the DevTools Protocol host address. + Roo will automatically fetch the WebSocket endpoint from this address. If provided, + Roo will use this browser instead of launching a local one. Leave empty to use the + built-in browser. +

+
)} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 7cafbd6663d..0b8cce9b877 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -77,6 +77,7 @@ const SettingsView = forwardRef(({ onDone }, mcpEnabled, rateLimitSeconds, requestDelaySeconds, + remoteBrowserHost, screenshotQuality, soundEnabled, soundVolume, @@ -172,6 +173,7 @@ const SettingsView = forwardRef(({ onDone }, vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints }) vscode.postMessage({ type: "checkpointStorage", text: checkpointStorage }) vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize }) + vscode.postMessage({ type: "remoteBrowserHost", text: remoteBrowserHost }) vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 }) vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs }) vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 }) @@ -378,6 +380,7 @@ const SettingsView = forwardRef(({ onDone }, browserToolEnabled={browserToolEnabled} browserViewportSize={browserViewportSize} screenshotQuality={screenshotQuality} + remoteBrowserHost={remoteBrowserHost} setCachedStateField={setCachedStateField} /> From 66e3b9610c3ee8658e2d0c2d6cfa3cb8fd5bd40b Mon Sep 17 00:00:00 2001 From: Afshawn Lotfi Date: Mon, 10 Mar 2025 03:41:38 +0000 Subject: [PATCH 2/4] Add remote browser connection support and related state management --- src/core/webview/ClineProvider.ts | 100 +++++++ .../webview/__tests__/ClineProvider.test.ts | 223 +++++++++++++-- src/services/browser/BrowserSession.ts | 123 +++++++-- src/services/browser/browserDiscovery.ts | 253 ++++++++++++++++++ src/shared/ExtensionMessage.ts | 5 + src/shared/WebviewMessage.ts | 4 + src/shared/globalState.ts | 1 + .../components/settings/BrowserSettings.tsx | 166 ++++++++++-- .../src/components/settings/SettingsView.tsx | 3 + .../src/context/ExtensionStateContext.tsx | 3 + 10 files changed, 810 insertions(+), 71 deletions(-) create mode 100644 src/services/browser/browserDiscovery.ts diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 3e8ed516687..9267682eb5d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -30,6 +30,8 @@ import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker" import { McpHub } from "../../services/mcp/McpHub" import { McpServerManager } from "../../services/mcp/McpServerManager" import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService" +import { BrowserSession } from "../../services/browser/BrowserSession" +import { discoverChromeInstances } from "../../services/browser/browserDiscovery" import { fileExistsAtPath } from "../../utils/fs" import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound" import { singleCompletionHandler } from "../../utils/single-completion-handler" @@ -1266,6 +1268,101 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("remoteBrowserHost", message.text) await this.postStateToWebview() break + case "remoteBrowserEnabled": + // Store the preference in global state + // remoteBrowserEnabled now means "enable remote browser connection" + await this.updateGlobalState("remoteBrowserEnabled", message.bool ?? false) + // If disabling remote browser connection, clear the remoteBrowserHost + if (!message.bool) { + await this.updateGlobalState("remoteBrowserHost", undefined) + } + await this.postStateToWebview() + break + case "testBrowserConnection": + try { + const browserSession = new BrowserSession(this.context) + // If no text is provided, try auto-discovery + if (!message.text) { + try { + const discoveredHost = await discoverChromeInstances() + if (discoveredHost) { + // Test the connection to the discovered host + const result = await browserSession.testConnection(discoveredHost) + // Send the result back to the webview + await this.postMessageToWebview({ + type: "browserConnectionResult", + success: result.success, + text: `Auto-discovered and tested connection to Chrome at ${discoveredHost}: ${result.message}`, + values: { endpoint: result.endpoint }, + }) + } else { + await this.postMessageToWebview({ + type: "browserConnectionResult", + success: false, + text: "No Chrome instances found on the network. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).", + }) + } + } catch (error) { + await this.postMessageToWebview({ + type: "browserConnectionResult", + success: false, + text: `Error during auto-discovery: ${error instanceof Error ? error.message : String(error)}`, + }) + } + } else { + // Test the provided URL + const result = await browserSession.testConnection(message.text) + + // Send the result back to the webview + await this.postMessageToWebview({ + type: "browserConnectionResult", + success: result.success, + text: result.message, + values: { endpoint: result.endpoint }, + }) + } + } catch (error) { + await this.postMessageToWebview({ + type: "browserConnectionResult", + success: false, + text: `Error testing connection: ${error instanceof Error ? error.message : String(error)}`, + }) + } + break + case "discoverBrowser": + try { + const discoveredHost = await discoverChromeInstances() + + if (discoveredHost) { + // Don't update the remoteBrowserHost state when auto-discovering + // This way we don't override the user's preference + + // Test the connection to get the endpoint + const browserSession = new BrowserSession(this.context) + const result = await browserSession.testConnection(discoveredHost) + + // Send the result back to the webview + await this.postMessageToWebview({ + type: "browserConnectionResult", + success: true, + text: `Successfully discovered and connected to Chrome at ${discoveredHost}`, + values: { endpoint: result.endpoint }, + }) + } else { + await this.postMessageToWebview({ + type: "browserConnectionResult", + success: false, + text: "No Chrome instances found on the network. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).", + }) + } + } catch (error) { + await this.postMessageToWebview({ + type: "browserConnectionResult", + success: false, + text: `Error discovering browser: ${error instanceof Error ? error.message : String(error)}`, + }) + } + break case "fuzzyMatchThreshold": await this.updateGlobalState("fuzzyMatchThreshold", message.value) await this.postStateToWebview() @@ -2193,6 +2290,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { browserViewportSize, screenshotQuality, remoteBrowserHost, + remoteBrowserEnabled, preferredLanguage, writeDelayMs, terminalOutputLimit, @@ -2252,6 +2350,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { browserViewportSize: browserViewportSize ?? "900x600", screenshotQuality: screenshotQuality ?? 75, remoteBrowserHost, + remoteBrowserEnabled: remoteBrowserEnabled ?? false, preferredLanguage: preferredLanguage ?? "English", writeDelayMs: writeDelayMs ?? 1000, terminalOutputLimit: terminalOutputLimit ?? TERMINAL_OUTPUT_LIMIT, @@ -2406,6 +2505,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { browserViewportSize: stateValues.browserViewportSize ?? "900x600", screenshotQuality: stateValues.screenshotQuality ?? 75, remoteBrowserHost: stateValues.remoteBrowserHost, + remoteBrowserEnabled: stateValues.remoteBrowserEnabled ?? false, fuzzyMatchThreshold: stateValues.fuzzyMatchThreshold ?? 1.0, writeDelayMs: stateValues.writeDelayMs ?? 1000, terminalOutputLimit: stateValues.terminalOutputLimit ?? TERMINAL_OUTPUT_LIMIT, diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index f9fc5d3ece5..9a6c2c28f3f 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -55,6 +55,34 @@ jest.mock("../../contextProxy", () => { // Mock dependencies jest.mock("vscode") jest.mock("delay") + +// Mock BrowserSession +jest.mock("../../../services/browser/BrowserSession", () => ({ + BrowserSession: jest.fn().mockImplementation(() => ({ + testConnection: jest.fn().mockImplementation(async (url) => { + if (url === "http://localhost:9222") { + return { + success: true, + message: "Successfully connected to Chrome", + endpoint: "ws://localhost:9222/devtools/browser/123", + } + } else { + return { + success: false, + message: "Failed to connect to Chrome", + endpoint: undefined, + } + } + }), + })), +})) + +// Mock browserDiscovery +jest.mock("../../../services/browser/browserDiscovery", () => ({ + discoverChromeInstances: jest.fn().mockImplementation(async () => { + return "http://localhost:9222" + }), +})) jest.mock( "@modelcontextprotocol/sdk/types.js", () => ({ @@ -94,31 +122,7 @@ jest.mock("delay", () => { return delayFn }) -// Mock MCP-related modules -jest.mock( - "@modelcontextprotocol/sdk/types.js", - () => ({ - CallToolResultSchema: {}, - ListResourcesResultSchema: {}, - ListResourceTemplatesResultSchema: {}, - ListToolsResultSchema: {}, - ReadResourceResultSchema: {}, - ErrorCode: { - InvalidRequest: "InvalidRequest", - MethodNotFound: "MethodNotFound", - InternalError: "InternalError", - }, - McpError: class McpError extends Error { - code: string - constructor(code: string, message: string) { - super(message) - this.code = code - this.name = "McpError" - } - }, - }), - { virtual: true }, -) +// MCP-related modules are mocked once above (lines 87-109) jest.mock( "@modelcontextprotocol/sdk/client/index.js", @@ -598,7 +602,7 @@ describe("ClineProvider", () => { expect(mockPostMessage).toHaveBeenCalled() }) - test("requestDelaySeconds defaults to 5 seconds", async () => { + test("requestDelaySeconds defaults to 10 seconds", async () => { // Mock globalState.get to return undefined for requestDelaySeconds ;(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => { if (key === "requestDelaySeconds") { @@ -1591,6 +1595,173 @@ describe("ClineProvider", () => { ]) }) }) + + describe("browser connection features", () => { + beforeEach(async () => { + // Reset mocks + jest.clearAllMocks() + await provider.resolveWebviewView(mockWebviewView) + }) + + // Mock BrowserSession and discoverChromeInstances + jest.mock("../../../services/browser/BrowserSession", () => ({ + BrowserSession: jest.fn().mockImplementation(() => ({ + testConnection: jest.fn().mockImplementation(async (url) => { + if (url === "http://localhost:9222") { + return { + success: true, + message: "Successfully connected to Chrome", + endpoint: "ws://localhost:9222/devtools/browser/123", + } + } else { + return { + success: false, + message: "Failed to connect to Chrome", + endpoint: undefined, + } + } + }), + })), + })) + + jest.mock("../../../services/browser/browserDiscovery", () => ({ + discoverChromeInstances: jest.fn().mockImplementation(async () => { + return "http://localhost:9222" + }), + })) + + test("handles testBrowserConnection with provided URL", async () => { + // Get the message handler + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + // Test with valid URL + await messageHandler({ + type: "testBrowserConnection", + text: "http://localhost:9222", + }) + + // Verify postMessage was called with success result + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "browserConnectionResult", + success: true, + text: expect.stringContaining("Successfully connected to Chrome"), + }), + ) + + // Reset mock + mockPostMessage.mockClear() + + // Test with invalid URL + await messageHandler({ + type: "testBrowserConnection", + text: "http://inlocalhost:9222", + }) + + // Verify postMessage was called with failure result + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "browserConnectionResult", + success: false, + text: expect.stringContaining("Failed to connect to Chrome"), + }), + ) + }) + + test("handles testBrowserConnection with auto-discovery", async () => { + // Get the message handler + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + // Test auto-discovery (no URL provided) + await messageHandler({ + type: "testBrowserConnection", + }) + + // Verify discoverChromeInstances was called + const { discoverChromeInstances } = require("../../../services/browser/browserDiscovery") + expect(discoverChromeInstances).toHaveBeenCalled() + + // Verify postMessage was called with success result + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "browserConnectionResult", + success: true, + text: expect.stringContaining("Auto-discovered and tested connection to Chrome"), + }), + ) + }) + + test("handles discoverBrowser message", async () => { + // Get the message handler + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + // Test browser discovery + await messageHandler({ + type: "discoverBrowser", + }) + + // Verify discoverChromeInstances was called + const { discoverChromeInstances } = require("../../../services/browser/browserDiscovery") + expect(discoverChromeInstances).toHaveBeenCalled() + + // Verify postMessage was called with success result + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "browserConnectionResult", + success: true, + text: expect.stringContaining("Successfully discovered and connected to Chrome"), + }), + ) + }) + + test("handles errors during browser discovery", async () => { + // Mock discoverChromeInstances to throw an error + const { discoverChromeInstances } = require("../../../services/browser/browserDiscovery") + discoverChromeInstances.mockImplementationOnce(() => { + throw new Error("Discovery error") + }) + + // Get the message handler + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + // Test browser discovery with error + await messageHandler({ + type: "discoverBrowser", + }) + + // Verify postMessage was called with error result + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "browserConnectionResult", + success: false, + text: expect.stringContaining("Error discovering browser"), + }), + ) + }) + + test("handles case when no browsers are discovered", async () => { + // Mock discoverChromeInstances to return null (no browsers found) + const { discoverChromeInstances } = require("../../../services/browser/browserDiscovery") + discoverChromeInstances.mockImplementationOnce(() => null) + + // Get the message handler + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0] + + // Test browser discovery with no browsers found + await messageHandler({ + type: "discoverBrowser", + }) + + // Verify postMessage was called with failure result + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "browserConnectionResult", + success: false, + text: expect.stringContaining("No Chrome instances found"), + }), + ) + }) + }) }) describe("ContextProxy integration", () => { diff --git a/src/services/browser/BrowserSession.ts b/src/services/browser/BrowserSession.ts index 6fe25ddf5b9..5c5f59ffebf 100644 --- a/src/services/browser/BrowserSession.ts +++ b/src/services/browser/BrowserSession.ts @@ -9,6 +9,7 @@ import delay from "delay" import axios from "axios" import { fileExistsAtPath } from "../../utils/fs" import { BrowserActionResult } from "../../shared/ExtensionMessage" +import { discoverChromeInstances, testBrowserConnection } from "./browserDiscovery" interface PCRStats { puppeteer: { launch: typeof launch } @@ -20,11 +21,20 @@ export class BrowserSession { private browser?: Browser private page?: Page private currentMousePosition?: string + private cachedWebSocketEndpoint?: string + private lastConnectionAttempt: number = 0 constructor(context: vscode.ExtensionContext) { this.context = context } + /** + * Test connection to a remote browser + */ + async testConnection(host: string): Promise<{ success: boolean; message: string; endpoint?: string }> { + return testBrowserConnection(host) + } + private async ensureChromiumExists(): Promise { const globalStoragePath = this.context?.globalStorageUri?.fsPath if (!globalStoragePath) { @@ -53,9 +63,59 @@ export class BrowserSession { await this.closeBrowser() // this may happen when the model launches a browser again after having used it already before } - const remoteBrowserHost = this.context.globalState.get("remoteBrowserHost") as string | undefined + // Function to get viewport size + const getViewport = () => { + const size = (this.context.globalState.get("browserViewportSize") as string | undefined) || "900x600" + const [width, height] = size.split("x").map(Number) + return { width, height } + } + + // Check if remote browser connection is enabled + const remoteBrowserEnabled = this.context.globalState.get("remoteBrowserEnabled") as boolean | undefined + + // If remote browser connection is not enabled, use local browser + if (!remoteBrowserEnabled) { + console.log("Remote browser connection is disabled, using local browser") + const stats = await this.ensureChromiumExists() + this.browser = await stats.puppeteer.launch({ + args: [ + "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + ], + executablePath: stats.executablePath, + defaultViewport: getViewport(), + // headless: false, + }) + this.page = await this.browser?.newPage() + return + } + // Remote browser connection is enabled + let remoteBrowserHost = this.context.globalState.get("remoteBrowserHost") as string | undefined + let browserWSEndpoint: string | undefined = this.cachedWebSocketEndpoint + let reconnectionAttempted = false - if (remoteBrowserHost) { + // Try to connect with cached endpoint first if it exists and is recent (less than 1 hour old) + if (browserWSEndpoint && Date.now() - this.lastConnectionAttempt < 3600000) { + try { + console.log(`Attempting to connect using cached WebSocket endpoint: ${browserWSEndpoint}`) + this.browser = await connect({ + browserWSEndpoint, + defaultViewport: getViewport(), + }) + this.page = await this.browser?.newPage() + return + } catch (error) { + console.log(`Failed to connect using cached endpoint: ${error}`) + // Clear the cached endpoint since it's no longer valid + this.cachedWebSocketEndpoint = undefined + // User wants to give up after one reconnection attempt + if (remoteBrowserHost) { + reconnectionAttempted = true + } + } + } + + // If user provided a remote browser host, try to connect to it + if (remoteBrowserHost && !reconnectionAttempted) { console.log(`Attempting to connect to remote browser at ${remoteBrowserHost}`) try { // Fetch the WebSocket endpoint from the Chrome DevTools Protocol @@ -63,7 +123,7 @@ export class BrowserSession { console.log(`Fetching WebSocket endpoint from ${versionUrl}`) const response = await axios.get(versionUrl) - const browserWSEndpoint = response.data.webSocketDebuggerUrl + browserWSEndpoint = response.data.webSocketDebuggerUrl if (!browserWSEndpoint) { throw new Error("Could not find webSocketDebuggerUrl in the response") @@ -71,34 +131,63 @@ export class BrowserSession { console.log(`Found WebSocket endpoint: ${browserWSEndpoint}`) + // Cache the successful endpoint + this.cachedWebSocketEndpoint = browserWSEndpoint + this.lastConnectionAttempt = Date.now() + this.browser = await connect({ browserWSEndpoint, - defaultViewport: (() => { - const size = - (this.context.globalState.get("browserViewportSize") as string | undefined) || "900x600" - const [width, height] = size.split("x").map(Number) - return { width, height } - })(), + defaultViewport: getViewport(), }) this.page = await this.browser?.newPage() return } catch (error) { console.error(`Failed to connect to remote browser: ${error}`) - // Fall back to local browser if remote connection fails + // Fall back to auto-discovery if remote connection fails + } + } + + // Always try auto-discovery if no custom URL is specified or if connection failed + try { + console.log("Attempting auto-discovery...") + const discoveredHost = await discoverChromeInstances() + + if (discoveredHost) { + console.log(`Auto-discovered Chrome at ${discoveredHost}`) + + // Don't save the discovered host to global state to avoid overriding user preference + // We'll just use it for this session + + // Try to connect to the discovered host + const testResult = await testBrowserConnection(discoveredHost) + + if (testResult.success && testResult.endpoint) { + // Cache the successful endpoint + this.cachedWebSocketEndpoint = testResult.endpoint + this.lastConnectionAttempt = Date.now() + + this.browser = await connect({ + browserWSEndpoint: testResult.endpoint, + defaultViewport: getViewport(), + }) + this.page = await this.browser?.newPage() + return + } } + } catch (error) { + console.error(`Auto-discovery failed: ${error}`) + // Fall back to local browser if auto-discovery fails } + // If all remote connection attempts fail, fall back to local browser + console.log("Falling back to local browser") const stats = await this.ensureChromiumExists() this.browser = await stats.puppeteer.launch({ args: [ "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", ], executablePath: stats.executablePath, - defaultViewport: (() => { - const size = (this.context.globalState.get("browserViewportSize") as string | undefined) || "900x600" - const [width, height] = size.split("x").map(Number) - return { width, height } - })(), + defaultViewport: getViewport(), // headless: false, }) // (latest version of puppeteer does not add headless to user agent) @@ -109,8 +198,8 @@ export class BrowserSession { if (this.browser || this.page) { console.log("closing browser...") - const remoteBrowserHost = this.context.globalState.get("remoteBrowserHost") as string | undefined - if (remoteBrowserHost && this.browser) { + const remoteBrowserEnabled = this.context.globalState.get("remoteBrowserEnabled") as string | undefined + if (remoteBrowserEnabled && this.browser) { await this.browser.disconnect().catch(() => {}) } else { await this.browser?.close().catch(() => {}) diff --git a/src/services/browser/browserDiscovery.ts b/src/services/browser/browserDiscovery.ts new file mode 100644 index 00000000000..a29bab5b784 --- /dev/null +++ b/src/services/browser/browserDiscovery.ts @@ -0,0 +1,253 @@ +import * as vscode from "vscode" +import * as os from "os" +import * as net from "net" +import axios from "axios" + +/** + * Check if a port is open on a given host + */ +export async function isPortOpen(host: string, port: number, timeout = 1000): Promise { + return new Promise((resolve) => { + const socket = new net.Socket() + let status = false + + // Set timeout + socket.setTimeout(timeout) + + // Handle successful connection + socket.on("connect", () => { + status = true + socket.destroy() + }) + + // Handle any errors + socket.on("error", () => { + socket.destroy() + }) + + // Handle timeout + socket.on("timeout", () => { + socket.destroy() + }) + + // Handle close + socket.on("close", () => { + resolve(status) + }) + + // Attempt to connect + socket.connect(port, host) + }) +} + +/** + * Try to connect to Chrome at a specific IP address + */ +export async function tryConnect(ipAddress: string): Promise<{ endpoint: string; ip: string } | null> { + try { + console.log(`Trying to connect to Chrome at: http://${ipAddress}:9222/json/version`) + const response = await axios.get(`http://${ipAddress}:9222/json/version`, { timeout: 1000 }) + const data = response.data + return { endpoint: data.webSocketDebuggerUrl, ip: ipAddress } + } catch (error) { + return null + } +} + +/** + * Get Docker gateway IP + */ +export async function getDockerGatewayIP(): Promise { + try { + // Try to get the default gateway from the route table + if (process.platform === "linux") { + try { + const { stdout } = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Checking Docker gateway IP", + cancellable: false, + }, + async () => { + const result = await new Promise<{ stdout: string; stderr: string }>((resolve) => { + const cp = require("child_process") + cp.exec( + "ip route | grep default | awk '{print $3}'", + (err: any, stdout: string, stderr: string) => { + resolve({ stdout, stderr }) + }, + ) + }) + return result + }, + ) + return stdout.trim() + } catch (error) { + console.log("Could not determine Docker gateway IP:", error) + } + } + return null + } catch (error) { + console.log("Could not determine Docker gateway IP:", error) + return null + } +} + +/** + * Get Docker host IP + */ +export async function getDockerHostIP(): Promise { + try { + // Try to resolve host.docker.internal (works on Docker Desktop) + return new Promise((resolve) => { + const dns = require("dns") + dns.lookup("host.docker.internal", (err: any, address: string) => { + if (err) { + resolve(null) + } else { + resolve(address) + } + }) + }) + } catch (error) { + console.log("Could not determine Docker host IP:", error) + return null + } +} + +/** + * Scan a network range for Chrome debugging port + */ +export async function scanNetworkForChrome(baseIP: string): Promise { + if (!baseIP || !baseIP.match(/^\d+\.\d+\.\d+\./)) { + return null + } + + // Extract the network prefix (e.g., "192.168.65.") + const networkPrefix = baseIP.split(".").slice(0, 3).join(".") + "." + + // Common Docker host IPs to try first + const priorityIPs = [ + networkPrefix + "1", // Common gateway + networkPrefix + "2", // Common host + networkPrefix + "254", // Common host in some Docker setups + ] + + console.log(`Scanning priority IPs in network ${networkPrefix}*`) + + // Check priority IPs first + for (const ip of priorityIPs) { + const isOpen = await isPortOpen(ip, 9222) + if (isOpen) { + console.log(`Found Chrome debugging port open on ${ip}`) + return ip + } + } + + return null +} + +/** + * Discover Chrome instances on the network + */ +export async function discoverChromeInstances(): Promise { + // Get all network interfaces + const networkInterfaces = os.networkInterfaces() + const ipAddresses = [] + + // Always try localhost first + ipAddresses.push("localhost") + ipAddresses.push("127.0.0.1") + + // Try to get Docker gateway IP + const gatewayIP = await getDockerGatewayIP() + if (gatewayIP) { + console.log("Found Docker gateway IP:", gatewayIP) + ipAddresses.push(gatewayIP) + } + + // Try to get Docker host IP + const hostIP = await getDockerHostIP() + if (hostIP) { + console.log("Found Docker host IP:", hostIP) + ipAddresses.push(hostIP) + } + + // Add all local IP addresses from network interfaces + const localIPs: string[] = [] + Object.values(networkInterfaces).forEach((interfaces) => { + if (!interfaces) return + interfaces.forEach((iface) => { + // Only consider IPv4 addresses + if (iface.family === "IPv4" || iface.family === (4 as any)) { + localIPs.push(iface.address) + } + }) + }) + + // Add local IPs to the list + ipAddresses.push(...localIPs) + + // Scan network for Chrome debugging port + for (const ip of localIPs) { + const chromeIP = await scanNetworkForChrome(ip) + if (chromeIP && !ipAddresses.includes(chromeIP)) { + console.log("Found potential Chrome host via network scan:", chromeIP) + ipAddresses.push(chromeIP) + } + } + + // Remove duplicates + const uniqueIPs = [...new Set(ipAddresses)] + console.log("IP Addresses to try:", uniqueIPs) + + // Try connecting to each IP address + for (const ip of uniqueIPs) { + const connection = await tryConnect(ip) + if (connection) { + console.log(`Successfully connected to Chrome at: ${connection.ip}`) + // Store the successful IP for future use + console.log(`✅ Found Chrome at ${connection.ip} - You can hardcode this IP if needed`) + + // Return the host URL and endpoint + return `http://${connection.ip}:9222` + } + } + + return null +} + +/** + * Test connection to a remote browser + */ +export async function testBrowserConnection( + host: string, +): Promise<{ success: boolean; message: string; endpoint?: string }> { + try { + // Fetch the WebSocket endpoint from the Chrome DevTools Protocol + const versionUrl = `${host.replace(/\/$/, "")}/json/version` + console.log(`Testing connection to ${versionUrl}`) + + const response = await axios.get(versionUrl, { timeout: 3000 }) + const browserWSEndpoint = response.data.webSocketDebuggerUrl + + if (!browserWSEndpoint) { + return { + success: false, + message: "Could not find webSocketDebuggerUrl in the response", + } + } + + return { + success: true, + message: "Successfully connected to Chrome browser", + endpoint: browserWSEndpoint, + } + } catch (error) { + console.error(`Failed to connect to remote browser: ${error}`) + return { + success: false, + message: `Failed to connect: ${error instanceof Error ? error.message : String(error)}`, + } + } +} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 5b638e6534e..5e95c0f68ec 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -51,6 +51,8 @@ export interface ExtensionMessage { | "humanRelayResponse" | "humanRelayCancel" | "browserToolEnabled" + | "browserConnectionResult" + | "remoteBrowserEnabled" text?: string action?: | "chatButtonClicked" @@ -83,6 +85,8 @@ export interface ExtensionMessage { mode?: Mode customMode?: ModeConfig slug?: string + success?: boolean + values?: Record } export interface ApiConfigMeta { @@ -124,6 +128,7 @@ export interface ExtensionState { browserViewportSize?: string screenshotQuality?: number remoteBrowserHost?: string + remoteBrowserEnabled?: boolean fuzzyMatchThreshold?: number preferredLanguage: string writeDelayMs: number diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index df5c7d29f91..79bf5c74054 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -102,6 +102,10 @@ export interface WebviewMessage { | "browserToolEnabled" | "telemetrySetting" | "showRooIgnoredFiles" + | "testBrowserConnection" + | "discoverBrowser" + | "browserConnectionResult" + | "remoteBrowserEnabled" text?: string disabled?: boolean askResponse?: ClineAskResponse diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index 05f54bfb8b6..5f4b216f6d7 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -100,6 +100,7 @@ export const GLOBAL_STATE_KEYS = [ "lmStudioDraftModelId", "telemetrySetting", "showRooIgnoredFiles", + "remoteBrowserEnabled", ] as const // Derive the type from the array - creates a union of string literals diff --git a/webview-ui/src/components/settings/BrowserSettings.tsx b/webview-ui/src/components/settings/BrowserSettings.tsx index ab4a88113a0..5c20a632c6d 100644 --- a/webview-ui/src/components/settings/BrowserSettings.tsx +++ b/webview-ui/src/components/settings/BrowserSettings.tsx @@ -1,5 +1,5 @@ -import { HTMLAttributes } from "react" -import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" +import React, { HTMLAttributes, useState, useEffect } from "react" +import { VSCodeButton, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" import { Dropdown, type DropdownOption } from "vscrui" import { SquareMousePointer } from "lucide-react" @@ -7,14 +7,20 @@ import { SetCachedStateField } from "./types" import { sliderLabelStyle } from "./styles" import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" +import { vscode } from "../../utils/vscode" type BrowserSettingsProps = HTMLAttributes & { browserToolEnabled?: boolean browserViewportSize?: string screenshotQuality?: number remoteBrowserHost?: string + remoteBrowserEnabled?: boolean setCachedStateField: SetCachedStateField< - "browserToolEnabled" | "browserViewportSize" | "screenshotQuality" | "remoteBrowserHost" + | "browserToolEnabled" + | "browserViewportSize" + | "screenshotQuality" + | "remoteBrowserHost" + | "remoteBrowserEnabled" > } @@ -23,9 +29,74 @@ export const BrowserSettings = ({ browserViewportSize, screenshotQuality, remoteBrowserHost, + remoteBrowserEnabled, setCachedStateField, ...props }: BrowserSettingsProps) => { + const [testingConnection, setTestingConnection] = useState(false) + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) + const [discovering, setDiscovering] = useState(false) + // We don't need a local state for useRemoteBrowser since we're using the enableRemoteBrowser prop directly + // This ensures the checkbox always reflects the current global state + + // Set up message listener for browser connection results + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + + if (message.type === "browserConnectionResult") { + setTestResult({ + success: message.success, + message: message.text, + }) + setTestingConnection(false) + setDiscovering(false) + } + } + + window.addEventListener("message", handleMessage) + + return () => { + window.removeEventListener("message", handleMessage) + } + }, []) + + const testConnection = async () => { + setTestingConnection(true) + setTestResult(null) + + try { + // Send a message to the extension to test the connection + vscode.postMessage({ + type: "testBrowserConnection", + text: remoteBrowserHost, + }) + } catch (error) { + setTestResult({ + success: false, + message: `Error: ${error instanceof Error ? error.message : String(error)}`, + }) + setTestingConnection(false) + } + } + + const discoverBrowser = async () => { + setDiscovering(true) + setTestResult(null) + + try { + // Send a message to the extension to discover Chrome instances + vscode.postMessage({ + type: "discoverBrowser", + }) + } catch (error) { + setTestResult({ + success: false, + message: `Error: ${error instanceof Error ? error.message : String(error)}`, + }) + setDiscovering(false) + } + } return (
@@ -101,31 +172,70 @@ export const BrowserSettings = ({

- - - setCachedStateField("remoteBrowserHost", e.target.value || undefined) - } - /> -

- Connect to a remote Chrome browser by providing the DevTools Protocol host address. - Roo will automatically fetch the WebSocket endpoint from this address. If provided, - Roo will use this browser instead of launching a local one. Leave empty to use the - built-in browser. -

+
+ { + // Update the global state - remoteBrowserEnabled now means "enable remote browser connection" + setCachedStateField("remoteBrowserEnabled", e.target.checked) + if (!e.target.checked) { + // If disabling remote browser, clear the custom URL + setCachedStateField("remoteBrowserHost", undefined) + } + }}> + Use remote browser connection + +

+ Connect to a Chrome browser running with remote debugging enabled + (--remote-debugging-port=9222). +

+
+ {remoteBrowserEnabled && ( + <> +
+ + setCachedStateField( + "remoteBrowserHost", + e.target.value || undefined, + ) + } + /> + + {testingConnection || discovering ? "Testing..." : "Test Connection"} + +
+ {testResult && ( +
+ {testResult.message} +
+ )} +

+ Enter the DevTools Protocol host address or leave empty to auto-discover + Chrome instances on your network. The Test Connection button will try the + custom URL if provided, or auto-discover if the field is empty. +

+ + )}
)} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 0b8cce9b877..866f8bc2808 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -85,6 +85,7 @@ const SettingsView = forwardRef(({ onDone }, terminalOutputLimit, writeDelayMs, showRooIgnoredFiles, + remoteBrowserEnabled, } = cachedState // Make sure apiConfiguration is initialized and managed by SettingsView. @@ -174,6 +175,7 @@ const SettingsView = forwardRef(({ onDone }, vscode.postMessage({ type: "checkpointStorage", text: checkpointStorage }) vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize }) vscode.postMessage({ type: "remoteBrowserHost", text: remoteBrowserHost }) + vscode.postMessage({ type: "remoteBrowserEnabled", bool: remoteBrowserEnabled }) vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 }) vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs }) vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 }) @@ -381,6 +383,7 @@ const SettingsView = forwardRef(({ onDone }, browserViewportSize={browserViewportSize} screenshotQuality={screenshotQuality} remoteBrowserHost={remoteBrowserHost} + remoteBrowserEnabled={remoteBrowserEnabled} setCachedStateField={setCachedStateField} /> diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 8d16f2e0f05..64264747536 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -73,6 +73,8 @@ export interface ExtensionStateContextType extends ExtensionState { setCustomModes: (value: ModeConfig[]) => void setMaxOpenTabsContext: (value: number) => void setTelemetrySetting: (value: TelemetrySetting) => void + remoteBrowserEnabled?: boolean + setRemoteBrowserEnabled: (value: boolean) => void machineId?: string } @@ -281,6 +283,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setBrowserToolEnabled: (value) => setState((prevState) => ({ ...prevState, browserToolEnabled: value })), setTelemetrySetting: (value) => setState((prevState) => ({ ...prevState, telemetrySetting: value })), setShowRooIgnoredFiles: (value) => setState((prevState) => ({ ...prevState, showRooIgnoredFiles: value })), + setRemoteBrowserEnabled: (value) => setState((prevState) => ({ ...prevState, remoteBrowserEnabled: value })), } return {children} From 71795d5487302ca0b9894c58db946b273cee0c03 Mon Sep 17 00:00:00 2001 From: Afshawn Lotfi Date: Mon, 10 Mar 2025 06:51:35 +0000 Subject: [PATCH 3/4] Enhance BrowserSettings component with VSCodeTextField for remote browser URL input --- .../components/settings/BrowserSettings.tsx | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/webview-ui/src/components/settings/BrowserSettings.tsx b/webview-ui/src/components/settings/BrowserSettings.tsx index 5c20a632c6d..5c385a3d8d8 100644 --- a/webview-ui/src/components/settings/BrowserSettings.tsx +++ b/webview-ui/src/components/settings/BrowserSettings.tsx @@ -1,5 +1,5 @@ import React, { HTMLAttributes, useState, useEffect } from "react" -import { VSCodeButton, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" +import { VSCodeButton, VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { Dropdown, type DropdownOption } from "vscrui" import { SquareMousePointer } from "lucide-react" @@ -192,28 +192,19 @@ export const BrowserSettings = ({ {remoteBrowserEnabled && ( <> -
- + + onChange={(e: any) => setCachedStateField( "remoteBrowserHost", e.target.value || undefined, ) } + placeholder="Custom URL (e.g., http://localhost:9222)" + style={{ flexGrow: 1 }} /> {testingConnection || discovering ? "Testing..." : "Test Connection"} @@ -221,7 +212,7 @@ export const BrowserSettings = ({
{testResult && (
)} -

- Enter the DevTools Protocol host address or leave empty to auto-discover - Chrome instances on your network. The Test Connection button will try the - custom URL if provided, or auto-discover if the field is empty. +

+ Enter the DevTools Protocol host address or + leave empty to auto-discover Chrome local instances. + The Test Connection button will try the custom URL if provided, or + auto-discover if the field is empty.

)} From ce3e22ecfee6676ec78d4ae63c769c666e2fbf3f Mon Sep 17 00:00:00 2001 From: Afshawn Lotfi Date: Mon, 10 Mar 2025 21:40:08 +0000 Subject: [PATCH 4/4] Refactor Docker gateway IP retrieval to use a dedicated shell command execution function --- src/services/browser/browserDiscovery.ts | 37 ++++++++++-------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/src/services/browser/browserDiscovery.ts b/src/services/browser/browserDiscovery.ts index a29bab5b784..187f90e2994 100644 --- a/src/services/browser/browserDiscovery.ts +++ b/src/services/browser/browserDiscovery.ts @@ -55,32 +55,25 @@ export async function tryConnect(ipAddress: string): Promise<{ endpoint: string; } /** - * Get Docker gateway IP + * Execute a shell command and return stdout and stderr + */ +export async function executeShellCommand(command: string): Promise<{ stdout: string; stderr: string }> { + return new Promise<{ stdout: string; stderr: string }>((resolve) => { + const cp = require("child_process") + cp.exec(command, (err: any, stdout: string, stderr: string) => { + resolve({ stdout, stderr }) + }) + }) +} + +/** + * Get Docker gateway IP without UI feedback */ export async function getDockerGatewayIP(): Promise { try { - // Try to get the default gateway from the route table if (process.platform === "linux") { try { - const { stdout } = await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: "Checking Docker gateway IP", - cancellable: false, - }, - async () => { - const result = await new Promise<{ stdout: string; stderr: string }>((resolve) => { - const cp = require("child_process") - cp.exec( - "ip route | grep default | awk '{print $3}'", - (err: any, stdout: string, stderr: string) => { - resolve({ stdout, stderr }) - }, - ) - }) - return result - }, - ) + const { stdout } = await executeShellCommand("ip route | grep default | awk '{print $3}'") return stdout.trim() } catch (error) { console.log("Could not determine Docker gateway IP:", error) @@ -159,7 +152,7 @@ export async function discoverChromeInstances(): Promise { ipAddresses.push("localhost") ipAddresses.push("127.0.0.1") - // Try to get Docker gateway IP + // Try to get Docker gateway IP (headless mode) const gatewayIP = await getDockerGatewayIP() if (gatewayIP) { console.log("Found Docker gateway IP:", gatewayIP)