/** * Automation-browser launcher. * * Two operating modes: * * - "dedicated" (default): the bridge owns its own profile directory under * ~/.pi--profile. Fully isolated from your daily browsing. You log * into ChatGPT once inside it; conversation history syncs from your account, * so opening the same conversation URL gives you the same thread. * * - "existing": the bridge launches *your real* browser profile with the * DevTools port enabled, so the live ChatGPT login is reused with zero * setup. Cost: that browser must be fully quit before the bridge starts it, * and afterwards you should keep using the window the bridge opened. * * Why a debug port is needed at all: a browser that is already running without * `--remote-debugging-port` cannot be attached to retroactively, and Chrome 136+ * only honours the flag when `--user-data-dir` is passed explicitly. */ import { execFileSync, spawn } from "node:child_process"; import { existsSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { DEFAULT_CDP_PORT, RuntimeRequirementError, activateTarget, cdpVersion, listTargets, openTab, } from "./cdp.ts"; export type BrowserChoice = "auto" | "chrome" | "edge"; export type ProfileMode = "dedicated" | "existing"; export interface BrowserStatus { running: boolean; browser?: string; port: number; tabs: Array<{ url: string; title: string }>; } export const WINDOWS_CANDIDATES: Record, string[]> = { chrome: [ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", ], edge: [ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", ], }; export const POSIX_CANDIDATES: Record, string[]> = { chrome: [ "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", ], edge: ["/usr/bin/microsoft-edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"], }; export function defaultProfileDir(browser: BrowserChoice = "chrome"): string { const name = browser === "edge" ? ".pi-edge-profile" : ".pi-chrome-profile"; return join(homedir(), name); } /** The real (daily) profile directory of the chosen browser. */ export function realUserDataDir(browser: Exclude): string { const local = process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"); if (process.platform === "win32") { return browser === "edge" ? join(local, "Microsoft", "Edge", "User Data") : join(local, "Google", "Chrome", "User Data"); } if (process.platform === "darwin") { return browser === "edge" ? join(homedir(), "Library", "Application Support", "Microsoft Edge") : join(homedir(), "Library", "Application Support", "Google", "Chrome"); } return browser === "edge" ? join(homedir(), ".config", "microsoft-edge") : join(homedir(), ".config", "google-chrome"); } function candidates(choice: BrowserChoice): Array<{ name: Exclude; path: string }> { const table = process.platform === "win32" ? WINDOWS_CANDIDATES : POSIX_CANDIDATES; const order: Array> = choice === "auto" ? ["chrome", "edge"] : [choice]; const out: Array<{ name: Exclude; path: string }> = []; for (const name of order) { for (const path of table[name]) out.push({ name, path }); } return out; } export function findBrowserExecutable(choice: BrowserChoice = "auto"): { name: Exclude; path: string; } { const found = candidates(choice).find((candidate) => existsSync(candidate.path)); if (!found) { throw new Error( `No ${choice === "auto" ? "Chrome or Edge" : choice} executable found. ` + `Set "browserExecutable" in ${join(homedir(), ".pi", "agent", "chatgpt-web-bridge.json")}.`, ); } return found; } export async function browserStatus(port = DEFAULT_CDP_PORT, signal?: AbortSignal): Promise { try { const version = await cdpVersion(port, signal); const targets = await listTargets(port, signal); return { running: true, browser: version.Browser, port, tabs: targets .filter((target) => target.type === "page") .map((target) => ({ url: target.url, title: target.title })), }; } catch (error) { // A runtime requirement is not a missing browser. Swallowing it into `running: false` // is what made a Node-version mismatch read as "the browser did not start", and sent // this investigation looking at Chrome while the actual answer was the shell's node. if (error instanceof RuntimeRequirementError) throw error; return { running: false, port, tabs: [] }; } } /** True when this browser already has the given profile directory open. */ export function isProfileInUse(browser: Exclude, userDataDir: string): boolean { if (process.platform !== "win32") return false; const exe = browser === "edge" ? "msedge.exe" : "chrome.exe"; const needle = userDataDir.replace(/'/g, "''"); const script = ` $p = Get-CimInstance Win32_Process -Filter "Name='${exe}'" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -and $_.CommandLine -like '*${needle}*' } if ($p) { 'yes' } else { 'no' } `; try { return execFileSync("powershell", ["-NoProfile", "-Command", script], { encoding: "utf8" }).trim() === "yes"; } catch { return false; } } export interface LaunchOptions { port?: number; browser?: BrowserChoice; browserExecutable?: string; profileMode?: ProfileMode; /** dedicated mode: the profile directory the bridge owns. */ profileDir?: string; /** existing mode: which profile inside the real User Data dir, e.g. "Default". */ profileDirectory?: string; /** * Proxy for the automation browser, e.g. `http://127.0.0.1:7890`. * * Passed explicitly rather than relying on the system proxy: the automation * profile is a separate browser, and it has to keep working when someone is not * running their proxy in system-wide mode. Without this the dedicated browser * simply cannot reach chatgpt.com, which reads as "the bridge is broken" rather * than "this browser has no route out". */ proxy?: string; url?: string; headless?: boolean; } export interface LaunchResult { launched: boolean; status: BrowserStatus; mode: ProfileMode; userDataDir: string; executable: string; note?: string; } /** Launch the automation browser. No-op if the debug port is already live. */ export async function ensureBrowser(options: LaunchOptions = {}): Promise { const port = options.port ?? DEFAULT_CDP_PORT; const mode: ProfileMode = options.profileMode ?? "dedicated"; const existing = await browserStatus(port); if (existing.running) { if (options.url && !existing.tabs.some((tab) => tab.url.startsWith("https://chatgpt.com"))) { const tab = await openTab(options.url, port); await activateTarget(tab.id, port); } return { launched: false, status: await browserStatus(port), mode, userDataDir: "", executable: "", note: "调试端口已在运行,未重启浏览器。", }; } const resolved = options.browserExecutable ? { name: "chrome" as const, path: options.browserExecutable } : findBrowserExecutable(options.browser ?? "auto"); let userDataDir: string; const args = [ `--remote-debugging-port=${port}`, // Chrome >= 111 enforces an Origin allow-list on the CDP WebSocket. "--remote-allow-origins=*", "--no-first-run", "--no-default-browser-check", "--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding", "--disable-features=CalculateNativeWinOcclusion", ]; let note: string | undefined; if (mode === "existing") { userDataDir = realUserDataDir(resolved.name); if (!existsSync(userDataDir)) { throw new Error(`找不到 ${resolved.name} 的真实 User Data 目录:${userDataDir}`); } if (isProfileInUse(resolved.name, userDataDir)) { throw new Error( `${resolved.name} 正在使用 ${userDataDir} 运行,但它没有开启调试端口。\n` + `请在任务栏完全退出 ${resolved.name}(含后台进程),然后用 /brb launch 让桥接重新拉起它。`, ); } args.push(`--user-data-dir=${userDataDir}`); if (options.profileDirectory) args.push(`--profile-directory=${options.profileDirectory}`); note = "正在以你的真实浏览器 profile 启动(复用已有登录态)。"; } else { userDataDir = options.profileDir ?? defaultProfileDir(resolved.name); mkdirSync(userDataDir, { recursive: true }); args.push(`--user-data-dir=${userDataDir}`); note = "正在以独立 automation profile 启动。首次使用需要在这个窗口里登录一次 ChatGPT。"; } if (options.proxy) args.push(`--proxy-server=${options.proxy}`); if (options.headless) args.push("--headless=new"); if (options.url) args.push(options.url); const child = spawn(resolved.path, args, { detached: true, stdio: "ignore", windowsHide: false }); // `spawn()` reports a missing executable asynchronously. Keep it in the launch // promise so operation preflight can degrade through its one shared guard instead // of letting EventEmitter turn ENOENT into an uncaught process error. let spawnError: Error | null = null; child.once("error", (error) => { spawnError = error; }); child.unref(); const deadline = Date.now() + 30_000; for (;;) { if (spawnError) throw spawnError; const status = await browserStatus(port); if (status.running) { return { launched: true, status, mode, userDataDir, executable: resolved.path, note }; } if (Date.now() > deadline) { throw new Error( `已启动 ${resolved.name},但 ${port} 端口上的 DevTools 端点没起来。\n` + `检查端口占用,然后重试 /brb launch。`, ); } await new Promise((resolve) => setTimeout(resolve, 700)); } } /** Close the browser the bridge launched. */ export async function killBrowser(port = DEFAULT_CDP_PORT): Promise { try { const version = await cdpVersion(port); const ws = new WebSocket(version.webSocketDebuggerUrl); return await new Promise((resolve) => { ws.addEventListener("open", () => { ws.send(JSON.stringify({ id: 1, method: "Browser.close" })); setTimeout(() => resolve(true), 800); }); ws.addEventListener("error", () => resolve(false)); setTimeout(() => resolve(false), 4000); }); } catch { return false; } }