import { execFile, spawn, type ChildProcess } from "node:child_process"; import { promisify } from "node:util"; import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; import { RuntimeError } from "../engine/types"; import { ExternalTransport } from "./transport"; const execAsync = promisify(execFile); /** * 外部浏览器(Edge / Chrome)进程管理与 CDP 连接。 * * 关键约束(Chromium 安全限制): * - 未带 --remote-debugging-port 启动的浏览器实例无法连接,必须由本模块带参拉起; * - 拉起时必须带独立 --user-data-dir,否则端口参数会被用户已开的实例吞掉(连不上且无报错的头号坑)。 */ export type ExternalBrowserType = "chrome" | "edge"; export interface ExternalLaunchOptions { mode: "launch"; browserType: ExternalBrowserType; /** 首选调试端口,被占用时自动 +1 重试;默认 9222 */ port?: number; /** 透传给浏览器命令行的额外参数(如 --headless) */ extraArgs?: string[]; /** 独立用户数据目录;缺省使用系统临时目录下的独立 profile */ userDataDir?: string; /** 首次启动时直接打开的 URL,避免先出现 about:blank */ initialUrl?: string; } export interface ExternalRemoteOptions { mode: "remote"; host: string; port: number; } export type ExternalBrowserOptions = ExternalLaunchOptions | ExternalRemoteOptions; export interface ExternalConnection { transport: ExternalTransport; /** 会话缓存键,close 时用于移出缓存 */ sessionKey: string; /** 人类可读标识,用于日志(如 "Edge (127.0.0.1:9222)") */ label: string; /** 调试 HTTP 端点根地址(如 http://127.0.0.1:9222),用于 /json/list、/json/new 多标签管理 */ httpBase: string; /** 初始接管的页面 targetId */ targetId: string; /** 由本模块拉起的浏览器进程;远程连接时为空 */ child?: ChildProcess; /** close 时是否结束浏览器进程(本模块拉起的默认关闭,远程连接绝不关闭) */ killOnClose: boolean; } const BROWSER_LABEL: Record = { chrome: "Chrome", edge: "Edge" }; const BROWSER_CANDIDATE_PATHS: Record = { chrome: [ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", path.join(localAppDataDir(), "Google", "Chrome", "Application", "chrome.exe") ], edge: [ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe" ] }; /** 用户级 AppData\Local 目录;LOCALAPPDATA 缺失(如受限启动环境)时退回 USERPROFILE 拼接 */ function localAppDataDir(): string { if (process.env.LOCALAPPDATA) return process.env.LOCALAPPDATA; return path.join(os.homedir(), "AppData", "Local"); } /** 注册表 App Paths 兜底:HKLM(64 位 + WOW6432Node)与 HKCU(用户级安装)都查 */ const BROWSER_APP_PATHS_REGISTRIES: Record = { chrome: [ "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe", "HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe", "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe" ], edge: [ "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe", "HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe", "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe" ] }; async function fileExists(p: string): Promise { if (!p || p === "undefined") return false; try { await fs.access(p); return true; } catch { return false; } } function queryRegistry(command: string): Promise { return new Promise((resolve, reject) => { execFile("reg", ["query", command, "/ve"], { windowsHide: true }, (error, stdout) => { if (error) return reject(error); resolve(String(stdout)); }); }); } /** 探测本机浏览器安装路径:常见目录优先,注册表 App Paths(HKLM/HKCU)兜底 */ export async function detectBrowserPath(browserType: ExternalBrowserType): Promise { for (const candidate of BROWSER_CANDIDATE_PATHS[browserType]) { if (await fileExists(candidate)) return candidate; } for (const registryKey of BROWSER_APP_PATHS_REGISTRIES[browserType]) { try { const stdout = await queryRegistry(registryKey); const match = stdout.match(/REG_SZ\s+(\S[^\r\n]*)/); if (match) { const registryPath = match[1].trim(); if (await fileExists(registryPath)) return registryPath; } } catch { // 该键不存在继续查下一个 } } throw new RuntimeError( "browser_not_found", `未找到本机 ${BROWSER_LABEL[browserType]},请确认已安装或在节点参数中填写正确端口进行远程连接`, { recommendation: "安装 Chrome/Edge,或改用「内置运行浏览器」" } ); } interface CdpVersionInfo { Browser?: string; webSocketDebuggerUrl?: string; } interface CdpTargetInfo { id?: string; type?: string; url?: string; title?: string; webSocketDebuggerUrl?: string; } async function fetchJson(url: string, timeoutMs = 5000, method = "GET"): Promise { const response = await fetch(url, { method, signal: AbortSignal.timeout(timeoutMs) }); if (!response.ok) { throw new RuntimeError("browser_disconnected", `调试端点响应异常 HTTP ${response.status}:${url}`); } return (await response.json()) as T; } async function isDebugPortReady(port: number, host = "127.0.0.1"): Promise { try { await fetchJson(`http://${host}:${port}/json/version`, 1200); return true; } catch { return false; } } /** 从 /json/list 里挑一个可用的页面级 target */ function pickPageTarget(targets: CdpTargetInfo[], preferUrl?: string): CdpTargetInfo { const pages = targets.filter((t) => t.type === "page" && t.webSocketDebuggerUrl); if (pages.length === 0) { throw new RuntimeError("browser_disconnected", "外部浏览器上没有可接管的页面", { recommendation: "关闭无头/后台模式限制,或重新运行「打开网页」" }); } if (preferUrl) { const preferred = pages.find((t) => (t.url ?? "") === preferUrl); if (preferred) return preferred; } const blank = pages.find((t) => /^about:blank|^chrome:\/\/newtab|^edge:\/\/newtab/.test(t.url ?? "")); return blank ?? pages[0]; } /** 连接一个已在监听调试端口的实例(远程场景,绝不关进程) */ export async function connectRemoteBrowser(options: ExternalRemoteOptions): Promise { const host = options.host.trim() || "127.0.0.1"; const port = Math.trunc(options.port); if (!port || port < 1 || port > 65535) { throw new RuntimeError("validation_error", `远程调试端口不合法:${options.port}`, { recommendation: "填写 1~65535 之间的端口号" }); } if (!/^[a-zA-Z0-9.\-]+$/.test(host)) { throw new RuntimeError("validation_error", `远程地址不合法:${host}`, { recommendation: "填写 IP 或主机名,如 127.0.0.1" }); } const base = `http://${host}:${port}`; let version: CdpVersionInfo; try { version = await fetchJson(`${base}/json/version`, 5000); } catch (error) { if (error instanceof RuntimeError) throw error; throw new RuntimeError("browser_disconnected", `无法连接远程浏览器 ${host}:${port}`, { detail: (error as Error).message, recommendation: "确认目标浏览器已带 --remote-debugging-port 启动,且防火墙已放行该端口" }); } const targets = await fetchJson(`${base}/json/list`, 5000); const target = pickPageTarget(targets); const transport = await ExternalTransport.connect(target.webSocketDebuggerUrl as string, `${host}:${port}`); return { transport, sessionKey: `remote|${host}|${port}`, label: `远程浏览器 ${host}:${port} (${version.Browser ?? "Chromium"})`, httpBase: base, targetId: String(target.id ?? ""), killOnClose: false }; } /** 清理占用专用 profile 的残留浏览器进程。 * 同一 user-data-dir 已有实例时,再次启动的 Chrome 会把请求转交旧实例并立即退出(代码 0), * 导致「启动失败(进程已退出,代码 0)」——这些实例都是本模块之前拉起的,清理是安全的。 */ async function killStaleBrowserProcesses(browserType: ExternalBrowserType, userDataDir: string): Promise { try { if (process.platform === "win32") { const names = browserType === "edge" ? "msedge.exe" : "chrome.exe"; const marker = userDataDir.replace(/'/g, "''"); const script = `Get-CimInstance Win32_Process -Filter "Name='${names}'" | ` + `Where-Object { $_.CommandLine -like '*${marker}*' } | ` + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"; await execAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: 8000, windowsHide: true }); } else { await execAsync("pkill", ["-f", userDataDir], { timeout: 5000 }); } } catch { // 清理失败不阻塞启动:可能本来就没有残留,或权限受限 } } /** 拉起本机 Edge/Chrome(带独立 profile + 调试端口),并接管其页面 */ export async function launchExternalBrowser(options: ExternalLaunchOptions): Promise { const browserType = options.browserType === "edge" ? "edge" : "chrome"; const label = BROWSER_LABEL[browserType]; const browserPath = await detectBrowserPath(browserType); // 端口管理:首选端口被占用(存在调试端点)则 +1 重试 let port = Math.trunc(options.port ?? 0) > 0 ? Math.trunc(options.port as number) : 9222; for (let attempt = 0; attempt < 20; attempt++) { if (await isDebugPortReady(port)) port += 1; else break; } const userDataDir = options.userDataDir?.trim() || path.join(os.tmpdir(), `rpa-studio-external-${browserType}`); await fs.mkdir(userDataDir, { recursive: true }); const extraArgs = (options.extraArgs ?? []).filter(Boolean); const startUrl = options.initialUrl?.trim() || "about:blank"; const args = [ `--remote-debugging-port=${port}`, `--user-data-dir=${userDataDir}`, "--no-first-run", "--no-default-browser-check", // 固定 profile 曾被强杀过时,Chrome 会弹「恢复页面?」气泡挡住页面拾取 "--hide-crash-restore-bubble", startUrl, ...extraArgs ]; const spawnBrowser = (): ChildProcess => spawn(browserPath, args, { detached: false, stdio: "ignore", windowsHide: true }); let child = spawnBrowser(); child.on("error", () => { // spawn 失败由下方就绪轮询超时统一报错 }); // 就绪轮询:等 /json/version 可访问(最长 15s) const deadline = Date.now() + 15000; let ready = false; let retriedStale = false; while (Date.now() < deadline) { if (child.exitCode !== null) { if (!retriedStale) { // 立即退出(常见代码 0):专用 profile 被残留实例占用,Chrome 转交后自杀; // 清理残留后重试一次 retriedStale = true; await killStaleBrowserProcesses(browserType, userDataDir); await new Promise((resolve) => setTimeout(resolve, 300)); child = spawnBrowser(); child.on("error", () => { // spawn 失败由下方就绪轮询超时统一报错 }); continue; } throw new RuntimeError("browser_error", `${label} 启动失败(进程已退出,代码 ${child.exitCode})`, { detail: browserPath, recommendation: "已尝试清理残留实例仍失败:检查浏览器安装是否完整,或重启电脑后重试" }); } if (await isDebugPortReady(port)) { ready = true; break; } await new Promise((resolve) => setTimeout(resolve, 300)); } if (!ready) { try { child.kill(); } catch { // 进程可能已退出 } throw new RuntimeError("timeout", `${label} 已启动但调试端口 ${port} 未就绪(15s 超时)`, { recommendation: "重启电脑后重试,或换一个端口;也可能是安全软件拦截了调试端口" }); } const base = `http://127.0.0.1:${port}`; const targets = await fetchJson(`${base}/json/list`, 5000); const target = pickPageTarget(targets, "about:blank"); const transport = await ExternalTransport.connect(target.webSocketDebuggerUrl as string, `127.0.0.1:${port}`); return { transport, sessionKey: `launch|${browserType}|${port}`, label: `${label} (127.0.0.1:${port})`, httpBase: base, targetId: String(target.id ?? ""), child, killOnClose: true }; } /** 列出调试端点上的全部页面级 target(多标签枚举用);排除 chrome://、edge:// 内部页 */ export async function listPageTargets(httpBase: string): Promise> { const targets = await fetchJson(`${httpBase}/json/list`, 5000); return targets .filter((t) => t.type === "page" && t.webSocketDebuggerUrl) .map((t) => ({ id: String(t.id ?? ""), title: String(t.title ?? ""), url: String(t.url ?? ""), webSocketDebuggerUrl: String(t.webSocketDebuggerUrl) })) .filter((t) => !/^chrome:\/\/|^edge:\/\/|^devtools:\/\//.test(t.url) || /^chrome:\/\/newtab|^edge:\/\/newtab/.test(t.url)); } /** 在外部浏览器上新建标签页并返回其调试端点(PUT /json/new) */ export async function openNewPageTarget(httpBase: string, url = "about:blank"): Promise<{ id: string; webSocketDebuggerUrl: string; url: string }> { const endpoint = `${httpBase}/json/new?${encodeURIComponent(url)}`; const target = await fetchJson<{ id?: string; webSocketDebuggerUrl?: string; url?: string }>(endpoint, 5000, "PUT"); if (!target.webSocketDebuggerUrl) { throw new RuntimeError("browser_error", "新建标签页未返回调试端点", { detail: JSON.stringify(target) }); } return { id: String(target.id ?? ""), webSocketDebuggerUrl: String(target.webSocketDebuggerUrl), url: String(target.url ?? url) }; } export async function openExternalBrowser(options: ExternalBrowserOptions): Promise { return options.mode === "remote" ? connectRemoteBrowser(options) : launchExternalBrowser(options); } export function sessionKeyOf(options: ExternalBrowserOptions): string { return options.mode === "remote" ? `remote|${options.host.trim()}|${Math.trunc(options.port)}` : `launch|${options.browserType === "edge" ? "edge" : "chrome"}|${Math.trunc(options.port ?? 9222)}`; }