mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Merge pull request #1528 from afshawnlotfi/chrome-remote-webtools
Chrome remote webtools
This commit is contained in:
commit
6cfc1cd4c9
10 changed files with 847 additions and 36 deletions
|
|
@ -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"
|
||||
|
|
@ -1262,6 +1264,105 @@ 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 "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()
|
||||
|
|
@ -2197,6 +2298,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
soundVolume,
|
||||
browserViewportSize,
|
||||
screenshotQuality,
|
||||
remoteBrowserHost,
|
||||
remoteBrowserEnabled,
|
||||
preferredLanguage,
|
||||
writeDelayMs,
|
||||
terminalOutputLimit,
|
||||
|
|
@ -2255,6 +2358,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
soundVolume: soundVolume ?? 0.5,
|
||||
browserViewportSize: browserViewportSize ?? "900x600",
|
||||
screenshotQuality: screenshotQuality ?? 75,
|
||||
remoteBrowserHost,
|
||||
remoteBrowserEnabled: remoteBrowserEnabled ?? false,
|
||||
preferredLanguage: preferredLanguage ?? "English",
|
||||
writeDelayMs: writeDelayMs ?? 1000,
|
||||
terminalOutputLimit: terminalOutputLimit ?? TERMINAL_OUTPUT_LIMIT,
|
||||
|
|
@ -2408,6 +2513,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
soundVolume: stateValues.soundVolume,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
@ -1781,6 +1785,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", () => {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
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"
|
||||
import { discoverChromeInstances, testBrowserConnection } from "./browserDiscovery"
|
||||
|
||||
interface PCRStats {
|
||||
puppeteer: { launch: typeof launch }
|
||||
|
|
@ -19,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<PCRStats> {
|
||||
const globalStoragePath = this.context?.globalStorageUri?.fsPath
|
||||
if (!globalStoragePath) {
|
||||
|
|
@ -52,17 +63,131 @@ export class BrowserSession {
|
|||
await this.closeBrowser() // this may happen when the model launches a browser again after having used it already before
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// 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
|
||||
const versionUrl = `${remoteBrowserHost.replace(/\/$/, "")}/json/version`
|
||||
console.log(`Fetching WebSocket endpoint from ${versionUrl}`)
|
||||
|
||||
const response = await axios.get(versionUrl)
|
||||
browserWSEndpoint = response.data.webSocketDebuggerUrl
|
||||
|
||||
if (!browserWSEndpoint) {
|
||||
throw new Error("Could not find webSocketDebuggerUrl in the response")
|
||||
}
|
||||
|
||||
console.log(`Found WebSocket endpoint: ${browserWSEndpoint}`)
|
||||
|
||||
// Cache the successful endpoint
|
||||
this.cachedWebSocketEndpoint = browserWSEndpoint
|
||||
this.lastConnectionAttempt = Date.now()
|
||||
|
||||
this.browser = await connect({
|
||||
browserWSEndpoint,
|
||||
defaultViewport: getViewport(),
|
||||
})
|
||||
this.page = await this.browser?.newPage()
|
||||
return
|
||||
} catch (error) {
|
||||
console.error(`Failed to connect to remote browser: ${error}`)
|
||||
// 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)
|
||||
|
|
@ -72,7 +197,14 @@ export class BrowserSession {
|
|||
async closeBrowser(): Promise<BrowserActionResult> {
|
||||
if (this.browser || this.page) {
|
||||
console.log("closing browser...")
|
||||
await this.browser?.close().catch(() => {})
|
||||
|
||||
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(() => {})
|
||||
}
|
||||
|
||||
this.browser = undefined
|
||||
this.page = undefined
|
||||
this.currentMousePosition = undefined
|
||||
|
|
|
|||
246
src/services/browser/browserDiscovery.ts
Normal file
246
src/services/browser/browserDiscovery.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
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<boolean> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string | null> {
|
||||
try {
|
||||
if (process.platform === "linux") {
|
||||
try {
|
||||
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)
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.log("Could not determine Docker gateway IP:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Docker host IP
|
||||
*/
|
||||
export async function getDockerHostIP(): Promise<string | null> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
// 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 (headless mode)
|
||||
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)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, any>
|
||||
}
|
||||
|
||||
export interface ApiConfigMeta {
|
||||
|
|
@ -123,6 +127,8 @@ export interface ExtensionState {
|
|||
checkpointStorage: CheckpointStorage
|
||||
browserViewportSize?: string
|
||||
screenshotQuality?: number
|
||||
remoteBrowserHost?: string
|
||||
remoteBrowserEnabled?: boolean
|
||||
fuzzyMatchThreshold?: number
|
||||
preferredLanguage: string
|
||||
writeDelayMs: number
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export interface WebviewMessage {
|
|||
| "checkpointStorage"
|
||||
| "browserViewportSize"
|
||||
| "screenshotQuality"
|
||||
| "remoteBrowserHost"
|
||||
| "openMcpSettings"
|
||||
| "restartMcpServer"
|
||||
| "toggleToolAlwaysAllow"
|
||||
|
|
@ -102,6 +103,10 @@ export interface WebviewMessage {
|
|||
| "browserToolEnabled"
|
||||
| "telemetrySetting"
|
||||
| "showRooIgnoredFiles"
|
||||
| "testBrowserConnection"
|
||||
| "discoverBrowser"
|
||||
| "browserConnectionResult"
|
||||
| "remoteBrowserEnabled"
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
askResponse?: ClineAskResponse
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export const GLOBAL_STATE_KEYS = [
|
|||
"checkpointStorage",
|
||||
"browserViewportSize",
|
||||
"screenshotQuality",
|
||||
"remoteBrowserHost",
|
||||
"fuzzyMatchThreshold",
|
||||
"preferredLanguage", // Language setting for Cline's communication
|
||||
"writeDelayMs",
|
||||
|
|
@ -100,6 +101,7 @@ export const GLOBAL_STATE_KEYS = [
|
|||
"lmStudioDraftModelId",
|
||||
"telemetrySetting",
|
||||
"showRooIgnoredFiles",
|
||||
"remoteBrowserEnabled",
|
||||
] as const
|
||||
|
||||
// Derive the type from the array - creates a union of string literals
|
||||
|
|
|
|||
|
|
@ -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, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import { Dropdown, type DropdownOption } from "vscrui"
|
||||
import { SquareMousePointer } from "lucide-react"
|
||||
|
||||
|
|
@ -7,21 +7,96 @@ import { SetCachedStateField } from "./types"
|
|||
import { sliderLabelStyle } from "./styles"
|
||||
import { SectionHeader } from "./SectionHeader"
|
||||
import { Section } from "./Section"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
|
||||
type BrowserSettingsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
browserToolEnabled?: boolean
|
||||
browserViewportSize?: string
|
||||
screenshotQuality?: number
|
||||
setCachedStateField: SetCachedStateField<"browserToolEnabled" | "browserViewportSize" | "screenshotQuality">
|
||||
remoteBrowserHost?: string
|
||||
remoteBrowserEnabled?: boolean
|
||||
setCachedStateField: SetCachedStateField<
|
||||
| "browserToolEnabled"
|
||||
| "browserViewportSize"
|
||||
| "screenshotQuality"
|
||||
| "remoteBrowserHost"
|
||||
| "remoteBrowserEnabled"
|
||||
>
|
||||
}
|
||||
|
||||
export const BrowserSettings = ({
|
||||
browserToolEnabled,
|
||||
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 (
|
||||
<div {...props}>
|
||||
<SectionHeader>
|
||||
|
|
@ -96,6 +171,64 @@ export const BrowserSettings = ({
|
|||
screenshots but increase token usage.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="mb-2">
|
||||
<VSCodeCheckbox
|
||||
checked={remoteBrowserEnabled}
|
||||
onChange={(e: any) => {
|
||||
// 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)
|
||||
}
|
||||
}}>
|
||||
<span className="font-medium">Use remote browser connection</span>
|
||||
</VSCodeCheckbox>
|
||||
<p className="text-vscode-descriptionForeground text-sm mt-0 ml-6">
|
||||
Connect to a Chrome browser running with remote debugging enabled
|
||||
(--remote-debugging-port=9222).
|
||||
</p>
|
||||
</div>
|
||||
{remoteBrowserEnabled && (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: "5px", marginTop: "10px" }}>
|
||||
<VSCodeTextField
|
||||
value={remoteBrowserHost ?? ""}
|
||||
onChange={(e: any) =>
|
||||
setCachedStateField(
|
||||
"remoteBrowserHost",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
placeholder="Custom URL (e.g., http://localhost:9222)"
|
||||
style={{ flexGrow: 1 }}
|
||||
/>
|
||||
<VSCodeButton
|
||||
disabled={testingConnection}
|
||||
onClick={remoteBrowserHost ? testConnection : discoverBrowser}>
|
||||
{testingConnection || discovering ? "Testing..." : "Test Connection"}
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
{testResult && (
|
||||
<div
|
||||
className={`p-2 mt-2 mb-2 rounded text-sm ${
|
||||
testResult.success
|
||||
? "bg-green-800/20 text-green-400"
|
||||
: "bg-red-800/20 text-red-400"
|
||||
}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-vscode-descriptionForeground text-sm mt-2">
|
||||
Enter the DevTools Protocol host address or
|
||||
<strong> leave empty to auto-discover Chrome local instances.</strong>
|
||||
The Test Connection button will try the custom URL if provided, or
|
||||
auto-discover if the field is empty.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
mcpEnabled,
|
||||
rateLimitSeconds,
|
||||
requestDelaySeconds,
|
||||
remoteBrowserHost,
|
||||
screenshotQuality,
|
||||
soundEnabled,
|
||||
soundVolume,
|
||||
|
|
@ -85,6 +86,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
terminalOutputLimit,
|
||||
writeDelayMs,
|
||||
showRooIgnoredFiles,
|
||||
remoteBrowserEnabled,
|
||||
} = cachedState
|
||||
|
||||
// Make sure apiConfiguration is initialized and managed by SettingsView.
|
||||
|
|
@ -173,6 +175,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ 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: "remoteBrowserEnabled", bool: remoteBrowserEnabled })
|
||||
vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
|
||||
vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs })
|
||||
vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 })
|
||||
|
|
@ -367,6 +371,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
browserToolEnabled={browserToolEnabled}
|
||||
browserViewportSize={browserViewportSize}
|
||||
screenshotQuality={screenshotQuality}
|
||||
remoteBrowserHost={remoteBrowserHost}
|
||||
remoteBrowserEnabled={remoteBrowserEnabled}
|
||||
setCachedStateField={setCachedStateField}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -75,6 +75,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
|
||||
}
|
||||
|
||||
|
|
@ -286,6 +288,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 <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue