PRA-only-demo/packages/rpa-core/src/browser/external.ts
yu ea3ff0b891 修复元素拾取 Chrome 启动失败(exit 0): 残留实例占用专用 profile
根因: 上次运行遗留的 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
2026-11-04 17:45:29 +08:00

359 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)}`;
}