Spaces:
Sleeping
Sleeping
| import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; | |
| import fs from "node:fs"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| import WebSocket from "ws"; | |
| import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js"; | |
| import { ensurePortAvailable } from "../infra/ports.js"; | |
| import { createSubsystemLogger } from "../logging/subsystem.js"; | |
| import { CONFIG_DIR } from "../utils.js"; | |
| import { appendCdpPath } from "./cdp.helpers.js"; | |
| import { getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js"; | |
| import { | |
| type BrowserExecutable, | |
| resolveBrowserExecutableForPlatform, | |
| } from "./chrome.executables.js"; | |
| import { | |
| decorateOpenClawProfile, | |
| ensureProfileCleanExit, | |
| isProfileDecorated, | |
| } from "./chrome.profile-decoration.js"; | |
| import { | |
| DEFAULT_OPENCLAW_BROWSER_COLOR, | |
| DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, | |
| } from "./constants.js"; | |
| const log = createSubsystemLogger("browser").child("chrome"); | |
| export type { BrowserExecutable } from "./chrome.executables.js"; | |
| export { | |
| findChromeExecutableLinux, | |
| findChromeExecutableMac, | |
| findChromeExecutableWindows, | |
| resolveBrowserExecutableForPlatform, | |
| } from "./chrome.executables.js"; | |
| export { | |
| decorateOpenClawProfile, | |
| ensureProfileCleanExit, | |
| isProfileDecorated, | |
| } from "./chrome.profile-decoration.js"; | |
| function exists(filePath: string) { | |
| try { | |
| return fs.existsSync(filePath); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| export type RunningChrome = { | |
| pid: number; | |
| exe: BrowserExecutable; | |
| userDataDir: string; | |
| cdpPort: number; | |
| startedAt: number; | |
| proc: ChildProcessWithoutNullStreams; | |
| }; | |
| function resolveBrowserExecutable(resolved: ResolvedBrowserConfig): BrowserExecutable | null { | |
| return resolveBrowserExecutableForPlatform(resolved, process.platform); | |
| } | |
| export function resolveOpenClawUserDataDir(profileName = DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME) { | |
| return path.join(CONFIG_DIR, "browser", profileName, "user-data"); | |
| } | |
| function cdpUrlForPort(cdpPort: number) { | |
| return `http://127.0.0.1:${cdpPort}`; | |
| } | |
| export async function isChromeReachable(cdpUrl: string, timeoutMs = 500): Promise<boolean> { | |
| const version = await fetchChromeVersion(cdpUrl, timeoutMs); | |
| return Boolean(version); | |
| } | |
| type ChromeVersion = { | |
| webSocketDebuggerUrl?: string; | |
| Browser?: string; | |
| "User-Agent"?: string; | |
| }; | |
| async function fetchChromeVersion(cdpUrl: string, timeoutMs = 500): Promise<ChromeVersion | null> { | |
| const ctrl = new AbortController(); | |
| const t = setTimeout(() => ctrl.abort(), timeoutMs); | |
| try { | |
| const versionUrl = appendCdpPath(cdpUrl, "/json/version"); | |
| const res = await fetch(versionUrl, { | |
| signal: ctrl.signal, | |
| headers: getHeadersWithAuth(versionUrl), | |
| }); | |
| if (!res.ok) { | |
| return null; | |
| } | |
| const data = (await res.json()) as ChromeVersion; | |
| if (!data || typeof data !== "object") { | |
| return null; | |
| } | |
| return data; | |
| } catch { | |
| return null; | |
| } finally { | |
| clearTimeout(t); | |
| } | |
| } | |
| export async function getChromeWebSocketUrl( | |
| cdpUrl: string, | |
| timeoutMs = 500, | |
| ): Promise<string | null> { | |
| const version = await fetchChromeVersion(cdpUrl, timeoutMs); | |
| const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim(); | |
| if (!wsUrl) { | |
| return null; | |
| } | |
| return normalizeCdpWsUrl(wsUrl, cdpUrl); | |
| } | |
| async function canOpenWebSocket(wsUrl: string, timeoutMs = 800): Promise<boolean> { | |
| return await new Promise<boolean>((resolve) => { | |
| const headers = getHeadersWithAuth(wsUrl); | |
| const ws = new WebSocket(wsUrl, { | |
| handshakeTimeout: timeoutMs, | |
| ...(Object.keys(headers).length ? { headers } : {}), | |
| }); | |
| const timer = setTimeout( | |
| () => { | |
| try { | |
| ws.terminate(); | |
| } catch { | |
| // ignore | |
| } | |
| resolve(false); | |
| }, | |
| Math.max(50, timeoutMs + 25), | |
| ); | |
| ws.once("open", () => { | |
| clearTimeout(timer); | |
| try { | |
| ws.close(); | |
| } catch { | |
| // ignore | |
| } | |
| resolve(true); | |
| }); | |
| ws.once("error", () => { | |
| clearTimeout(timer); | |
| resolve(false); | |
| }); | |
| }); | |
| } | |
| export async function isChromeCdpReady( | |
| cdpUrl: string, | |
| timeoutMs = 500, | |
| handshakeTimeoutMs = 800, | |
| ): Promise<boolean> { | |
| const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs); | |
| if (!wsUrl) { | |
| return false; | |
| } | |
| return await canOpenWebSocket(wsUrl, handshakeTimeoutMs); | |
| } | |
| export async function launchOpenClawChrome( | |
| resolved: ResolvedBrowserConfig, | |
| profile: ResolvedBrowserProfile, | |
| ): Promise<RunningChrome> { | |
| if (!profile.cdpIsLoopback) { | |
| throw new Error(`Profile "${profile.name}" is remote; cannot launch local Chrome.`); | |
| } | |
| await ensurePortAvailable(profile.cdpPort); | |
| const exe = resolveBrowserExecutable(resolved); | |
| if (!exe) { | |
| throw new Error( | |
| "No supported browser found (Chrome/Brave/Edge/Chromium on macOS, Linux, or Windows).", | |
| ); | |
| } | |
| const userDataDir = resolveOpenClawUserDataDir(profile.name); | |
| fs.mkdirSync(userDataDir, { recursive: true }); | |
| const needsDecorate = !isProfileDecorated( | |
| userDataDir, | |
| profile.name, | |
| (profile.color ?? DEFAULT_OPENCLAW_BROWSER_COLOR).toUpperCase(), | |
| ); | |
| // First launch to create preference files if missing, then decorate and relaunch. | |
| const spawnOnce = () => { | |
| const args: string[] = [ | |
| `--remote-debugging-port=${profile.cdpPort}`, | |
| `--user-data-dir=${userDataDir}`, | |
| "--no-first-run", | |
| "--no-default-browser-check", | |
| "--disable-sync", | |
| "--disable-background-networking", | |
| "--disable-component-update", | |
| "--disable-features=Translate,MediaRouter", | |
| "--disable-session-crashed-bubble", | |
| "--hide-crash-restore-bubble", | |
| "--password-store=basic", | |
| ]; | |
| if (resolved.headless) { | |
| // Best-effort; older Chromes may ignore. | |
| args.push("--headless=new"); | |
| args.push("--disable-gpu"); | |
| } | |
| if (resolved.noSandbox) { | |
| args.push("--no-sandbox"); | |
| args.push("--disable-setuid-sandbox"); | |
| } | |
| if (process.platform === "linux") { | |
| args.push("--disable-dev-shm-usage"); | |
| } | |
| // Always open a blank tab to ensure a target exists. | |
| args.push("about:blank"); | |
| return spawn(exe.path, args, { | |
| stdio: "pipe", | |
| env: { | |
| ...process.env, | |
| // Reduce accidental sharing with the user's env. | |
| HOME: os.homedir(), | |
| }, | |
| }); | |
| }; | |
| const startedAt = Date.now(); | |
| const localStatePath = path.join(userDataDir, "Local State"); | |
| const preferencesPath = path.join(userDataDir, "Default", "Preferences"); | |
| const needsBootstrap = !exists(localStatePath) || !exists(preferencesPath); | |
| // If the profile doesn't exist yet, bootstrap it once so Chrome creates defaults. | |
| // Then decorate (if needed) before the "real" run. | |
| if (needsBootstrap) { | |
| const bootstrap = spawnOnce(); | |
| const deadline = Date.now() + 10_000; | |
| while (Date.now() < deadline) { | |
| if (exists(localStatePath) && exists(preferencesPath)) { | |
| break; | |
| } | |
| await new Promise((r) => setTimeout(r, 100)); | |
| } | |
| try { | |
| bootstrap.kill("SIGTERM"); | |
| } catch { | |
| // ignore | |
| } | |
| const exitDeadline = Date.now() + 5000; | |
| while (Date.now() < exitDeadline) { | |
| if (bootstrap.exitCode != null) { | |
| break; | |
| } | |
| await new Promise((r) => setTimeout(r, 50)); | |
| } | |
| } | |
| if (needsDecorate) { | |
| try { | |
| decorateOpenClawProfile(userDataDir, { | |
| name: profile.name, | |
| color: profile.color, | |
| }); | |
| log.info(`🦞 openclaw browser profile decorated (${profile.color})`); | |
| } catch (err) { | |
| log.warn(`openclaw browser profile decoration failed: ${String(err)}`); | |
| } | |
| } | |
| try { | |
| ensureProfileCleanExit(userDataDir); | |
| } catch (err) { | |
| log.warn(`openclaw browser clean-exit prefs failed: ${String(err)}`); | |
| } | |
| const proc = spawnOnce(); | |
| // Wait for CDP to come up. | |
| const readyDeadline = Date.now() + 15_000; | |
| while (Date.now() < readyDeadline) { | |
| if (await isChromeReachable(profile.cdpUrl, 500)) { | |
| break; | |
| } | |
| await new Promise((r) => setTimeout(r, 200)); | |
| } | |
| if (!(await isChromeReachable(profile.cdpUrl, 500))) { | |
| try { | |
| proc.kill("SIGKILL"); | |
| } catch { | |
| // ignore | |
| } | |
| throw new Error( | |
| `Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}".`, | |
| ); | |
| } | |
| const pid = proc.pid ?? -1; | |
| log.info( | |
| `🦞 openclaw browser started (${exe.kind}) profile "${profile.name}" on 127.0.0.1:${profile.cdpPort} (pid ${pid})`, | |
| ); | |
| return { | |
| pid, | |
| exe, | |
| userDataDir, | |
| cdpPort: profile.cdpPort, | |
| startedAt, | |
| proc, | |
| }; | |
| } | |
| export async function stopOpenClawChrome(running: RunningChrome, timeoutMs = 2500) { | |
| const proc = running.proc; | |
| if (proc.killed) { | |
| return; | |
| } | |
| try { | |
| proc.kill("SIGTERM"); | |
| } catch { | |
| // ignore | |
| } | |
| const start = Date.now(); | |
| while (Date.now() - start < timeoutMs) { | |
| if (!proc.exitCode && proc.killed) { | |
| break; | |
| } | |
| if (!(await isChromeReachable(cdpUrlForPort(running.cdpPort), 200))) { | |
| return; | |
| } | |
| await new Promise((r) => setTimeout(r, 100)); | |
| } | |
| try { | |
| proc.kill("SIGKILL"); | |
| } catch { | |
| // ignore | |
| } | |
| } | |