根因: 上次运行遗留的 Chrome 仍占用 rpa-studio-external-* profile 时, 再次启动的 Chrome 会把请求转交旧实例并立即退出(代码 0), 报 「Chrome 启动失败(进程已退出,代码 0)」。 - external: 子进程立即退出时清理占用该 profile 的残留进程并重试一次 (win32 按命令行中的 user-data-dir 过滤杀进程, 其他平台 pkill -f) - 启动参数加 --hide-crash-restore-bubble, 避免固定 profile 被强杀后 「恢复页面?」气泡遮挡页面拾取 - 验证: 故意拉起无调试端口的同 profile Chrome 后跑浏览器 e2e, 自动 清理重试生效, 演示流程 succeeded
359 lines
15 KiB
TypeScript
359 lines
15 KiB
TypeScript
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<ExternalBrowserType, string> = { chrome: "Chrome", edge: "Edge" };
|
||
|
||
const BROWSER_CANDIDATE_PATHS: Record<ExternalBrowserType, string[]> = {
|
||
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<ExternalBrowserType, string[]> = {
|
||
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<boolean> {
|
||
if (!p || p === "undefined") return false;
|
||
try {
|
||
await fs.access(p);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function queryRegistry(command: string): Promise<string> {
|
||
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<string> {
|
||
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<T>(url: string, timeoutMs = 5000, method = "GET"): Promise<T> {
|
||
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<boolean> {
|
||
try {
|
||
await fetchJson<CdpVersionInfo>(`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<ExternalConnection> {
|
||
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<CdpVersionInfo>(`${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<CdpTargetInfo[]>(`${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<void> {
|
||
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<ExternalConnection> {
|
||
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<CdpTargetInfo[]>(`${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<Array<{ id: string; title: string; url: string; webSocketDebuggerUrl: string }>> {
|
||
const targets = await fetchJson<CdpTargetInfo[]>(`${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<ExternalConnection> {
|
||
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)}`;
|
||
}
|