300 lines
8.3 KiB
TypeScript
300 lines
8.3 KiB
TypeScript
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 { ensurePortAvailable } from "../infra/ports.js";
|
|
import { createSubsystemLogger } from "../logging.js";
|
|
import { CONFIG_DIR } from "../utils.js";
|
|
import { getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js";
|
|
import {
|
|
type BrowserExecutable,
|
|
resolveBrowserExecutableForPlatform,
|
|
} from "./chrome.executables.js";
|
|
import { decorateClawdProfile, isProfileDecorated } from "./chrome.profile-decoration.js";
|
|
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
|
|
import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_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 { decorateClawdProfile, 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 resolveClawdUserDataDir(profileName = DEFAULT_CLAWD_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 base = cdpUrl.replace(/\/$/, "");
|
|
const res = await fetch(`${base}/json/version`, {
|
|
signal: ctrl.signal,
|
|
headers: getHeadersWithAuth(`${base}/json/version`),
|
|
});
|
|
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 ws = new WebSocket(wsUrl, { handshakeTimeout: timeoutMs });
|
|
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 launchClawdChrome(
|
|
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 = resolveClawdUserDataDir(profile.name);
|
|
fs.mkdirSync(userDataDir, { recursive: true });
|
|
|
|
const needsDecorate = !isProfileDecorated(
|
|
userDataDir,
|
|
profile.name,
|
|
(profile.color ?? DEFAULT_CLAWD_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",
|
|
"--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 {
|
|
decorateClawdProfile(userDataDir, {
|
|
name: profile.name,
|
|
color: profile.color,
|
|
});
|
|
log.info(`🦞 clawd browser profile decorated (${profile.color})`);
|
|
} catch (err) {
|
|
log.warn(`clawd browser profile decoration 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(
|
|
`🦞 clawd 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 stopClawdChrome(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
|
|
}
|
|
}
|