根因: ws 被打进主进程 bundle 时, 其可选依赖 bufferutil 的打包解析产物 破坏了客户端发帧的 mask fallback; permessage-deflate 协商开启时错误被 压缩路径静默吞掉 —— 表现为 ws 握手成功但所有 CDP 命令零字节发出、 无响应(输入文本节点 10s 超时), dev 与打包版皆受影响。 - electron.vite.config: externalizeDepsPlugin include ws, 运行时 require - package.json: ws 提升到根依赖, electron-builder 随包收集 - electron-builder.config: 确认 node_modules/ws 进入 asar - transport: 显式 perMessageDeflate:false(双保险, 附注释) - package.mjs: verifyLaunch 剥离 ELECTRON_RUN_AS_NODE/NODE_OPTIONS, 防止外部工具链环境把打包应用带进纯 Node 模式(bad option 退出码 9) - flows: 演示流程支持 RPA_DEMO_BROWSER 强制指定浏览器(Chrome 缺装回退 Edge) 验证: 全量测试 349 绿 + 浏览器 e2e 绿 + portable/NSIS 双冒烟通过, 安装包/zip/SHA256 已产出。
319 lines
13 KiB
TypeScript
319 lines
13 KiB
TypeScript
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
||
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";
|
||
|
||
/**
|
||
* 外部浏览器(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
|
||
};
|
||
}
|
||
|
||
/** 拉起本机 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",
|
||
startUrl,
|
||
...extraArgs
|
||
];
|
||
|
||
const child = spawn(browserPath, args, { detached: false, stdio: "ignore", windowsHide: true });
|
||
child.on("error", () => {
|
||
// spawn 失败由下方就绪轮询超时统一报错
|
||
});
|
||
|
||
// 就绪轮询:等 /json/version 可访问(最长 15s)
|
||
const deadline = Date.now() + 15000;
|
||
let ready = false;
|
||
while (Date.now() < deadline) {
|
||
if (child.exitCode !== null) {
|
||
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)}`;
|
||
}
|