feat: 外接浏览器支持阶段一+二(Edge/Chrome 打开与基础自动化)

- 新增 browser/transport.ts: CdpTransport 抽象(Embedded=webContents.debugger / External=ws 直连调试端口)
- 新增 browser/external.ts: Chrome/Edge 路径探测(常见目录+注册表App Paths)、带独立 user-data-dir 与调试端口拉起、端口占用自增、/json/version 就绪轮询、远程连接模式
- session.ts: BrowserSession 改为传输层注入, navigate/currentUrl/download/弹窗/网络监听双模式适配; 外部会话按 (mode,type,port) 缓存复用; pickElement 外部模式明确报不支持(阶段四)
- capabilities/browser.ts: WebBrowser_Create/V2/V3/CreateBackground 读取 type/isRemote/ipAddress/port/arguments; type=chrome/edge 或 isRemote=1 走外部浏览器, 缺省仍走内置兜底
- 依赖: +ws ^8.22.0
- 冒烟已验证: Edge 156 拉起→CDP 连接→Page.navigate+loadEventFired→取标题→截图→setDownloadBehavior→关闭 全通过
- 内置浏览器行为不变(browser.test.ts 21 项回归通过)
This commit is contained in:
yu 2026-10-30 11:09:06 +08:00
parent a25f8c76b6
commit ccfd82e894
6 changed files with 786 additions and 74 deletions

33
package-lock.json generated
View File

@ -22,6 +22,7 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tesseract.js": "^7.0.0", "tesseract.js": "^7.0.0",
"ws": "^8.22.0",
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
@ -31,6 +32,7 @@
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/ws": "^8.18.2",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"electron": "31.7.7", "electron": "31.7.7",
"electron-builder": "^26.15.7", "electron-builder": "^26.15.7",
@ -3013,6 +3015,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmmirror.com/@types/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yauzl": { "node_modules/@types/yauzl": {
"version": "2.10.3", "version": "2.10.3",
"resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz", "resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz",
@ -10535,6 +10547,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlbuilder": { "node_modules/xmlbuilder": {
"version": "15.1.1", "version": "15.1.1",
"resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz",

View File

@ -31,6 +31,7 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tesseract.js": "^7.0.0", "tesseract.js": "^7.0.0",
"ws": "^8.22.0",
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
@ -40,6 +41,7 @@
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/ws": "^8.18.2",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"electron": "31.7.7", "electron": "31.7.7",
"electron-builder": "^26.15.7", "electron-builder": "^26.15.7",

View File

@ -0,0 +1,267 @@
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;
}
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;
/** 由本模块拉起的浏览器进程;远程连接时为空 */
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(process.env.LOCALAPPDATA ?? "", "Google", "Chrome", "Application", "chrome.exe")
],
edge: [
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"
]
};
const BROWSER_APP_PATHS_REGISTRY: Record<ExternalBrowserType, string> = {
chrome: "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe",
edge: "HKLM\\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 兜底 */
export async function detectBrowserPath(browserType: ExternalBrowserType): Promise<string> {
for (const candidate of BROWSER_CANDIDATE_PATHS[browserType]) {
if (await fileExists(candidate)) return candidate;
}
try {
const stdout = await queryRegistry(BROWSER_APP_PATHS_REGISTRY[browserType]);
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"})`,
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 args = [
`--remote-debugging-port=${port}`,
`--user-data-dir=${userDataDir}`,
"--no-first-run",
"--no-default-browser-check",
"about:blank",
...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})`,
child,
killOnClose: true
};
}
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)}`;
}

View File

@ -2,6 +2,13 @@ import { BrowserWindow } from "electron";
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import type { ElementPickResult } from "../../shared/types"; import type { ElementPickResult } from "../../shared/types";
import { EmbeddedTransport, type CdpTransport, type CdpEventHandler } from "./transport";
import {
openExternalBrowser,
sessionKeyOf,
type ExternalBrowserOptions,
type ExternalConnection
} from "./external";
const DEFAULT_ELEMENT_WAIT_MS = 5000; const DEFAULT_ELEMENT_WAIT_MS = 5000;
let globalElementWaitMs = DEFAULT_ELEMENT_WAIT_MS; let globalElementWaitMs = DEFAULT_ELEMENT_WAIT_MS;
@ -84,15 +91,27 @@ function selectorSpec(selector: unknown): { kind: string; value: string; role?:
} }
export class BrowserSession implements BrowserLike { export class BrowserSession implements BrowserLike {
private win: BrowserWindow; private win: BrowserWindow | null;
private transport: CdpTransport;
private external: ExternalConnection | null;
private externalKey: string;
// 实例级监听器状态(自动关弹窗 / 网络监听)
private dialogAutoClose = false;
private dialogHandler: CdpEventHandler | null = null;
private netListening = false;
private netHandler: CdpEventHandler | null = null;
private netCaptures: Array<{ requestId: string; url: string; status: number; mimeType: string }> = [];
private constructor(win: BrowserWindow) { private constructor(win: BrowserWindow | null, transport: CdpTransport, external: ExternalConnection | null = null) {
this.win = win; this.win = win;
this.transport = transport;
this.external = external;
this.externalKey = external?.sessionKey ?? "";
} }
static async create(): Promise<BrowserSession> { static async create(): Promise<BrowserSession> {
if (globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed()) { if (globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed()) {
return new BrowserSession(globalThis.__rpaBrowserWindow); return new BrowserSession(globalThis.__rpaBrowserWindow, new EmbeddedTransport(globalThis.__rpaBrowserWindow));
} }
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1200, width: 1200,
@ -114,22 +133,37 @@ export class BrowserSession implements BrowserLike {
if (!win.webContents.debugger.isAttached()) { if (!win.webContents.debugger.isAttached()) {
win.webContents.debugger.attach("1.3"); win.webContents.debugger.attach("1.3");
} }
return new BrowserSession(win); return new BrowserSession(win, new EmbeddedTransport(win));
}
/**
* 创建外部浏览器会话(Edge/Chrome 或远程调试端口连接)。
* 会话按 (mode, browserType/host, port) 缓存复用,同一流程多次「打开网页」不会重复拉起。
*/
static async createExternal(options: ExternalBrowserOptions): Promise<BrowserSession> {
const key = sessionKeyOf(options);
const cache = externalSessionCache();
const existing = cache.get(key);
if (existing && !existing.transport.closed) return existing;
if (existing) cache.delete(key);
const connection = await openExternalBrowser(options);
const session = new BrowserSession(null, connection.transport, connection);
cache.set(key, session);
return session;
}
/** 当前会话是否为外部浏览器 */
get isExternal(): boolean {
return this.external !== null;
}
/** 当前会话标识(用于日志,如 "Edge (127.0.0.1:9222)") */
get label(): string {
return this.external?.label ?? "内置运行浏览器";
} }
private async cdp<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> { private async cdp<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
if (this.win.isDestroyed()) { return this.transport.send<T>(method, params);
throw new RuntimeError("tab_unavailable", "运行浏览器已关闭", { recommendation: "重新运行流程" });
}
try {
if (!this.win.webContents.debugger.isAttached()) this.win.webContents.debugger.attach("1.3");
return (await this.win.webContents.debugger.sendCommand(method, params)) as T;
} catch (error) {
if (/destroyed|Target closed|not attached/i.test((error as Error).message)) {
throw new RuntimeError("tab_unavailable", "浏览器连接已失效", { detail: (error as Error).message });
}
throw new RuntimeError("browser_error", (error as Error).message, { recommendation: "重试或检查页面状态" });
}
} }
private async evaluate<T = unknown>(expression: string): Promise<T> { private async evaluate<T = unknown>(expression: string): Promise<T> {
@ -263,8 +297,40 @@ export class BrowserSession implements BrowserLike {
async navigate(url: string): Promise<{ url: string; title: string }> { async navigate(url: string): Promise<{ url: string; title: string }> {
const target = assertUrl(url); const target = assertUrl(url);
await this.cdp("Page.enable"); await this.cdp("Page.enable");
await this.win.loadURL(target.toString()); if (this.win) {
return { url: this.win.webContents.getURL(), title: this.win.webContents.getTitle() }; await this.win.loadURL(target.toString());
return { url: this.win.webContents.getURL(), title: this.win.webContents.getTitle() };
}
// 外部浏览器:Page.navigate + 等待 loadEventFired(与内置 loadURL 等加载完成的语义一致)
const loaded = new Promise<void>((resolve) => {
let done = false;
const onLoad = () => {
if (done) return;
done = true;
clearTimeout(timer);
this.transport.off("Page.loadEventFired", onLoad);
// 留 150ms 余量让 load 事件后的首帧渲染稳定
setTimeout(resolve, 150);
};
const timer = setTimeout(() => {
if (done) return;
done = true;
this.transport.off("Page.loadEventFired", onLoad);
resolve();
}, 30000);
this.transport.on("Page.loadEventFired", onLoad);
});
const result = await this.cdp<{ errorText?: string }>("Page.navigate", { url: target.toString() });
if (result?.errorText) {
throw new RuntimeError("browser_error", `页面导航失败:${result.errorText}`, {
detail: target.toString(),
recommendation: "确认网址可达"
});
}
await loaded;
const currentUrl = await this.evaluate<string>("location.href");
const title = await this.evaluate<string>("document.title");
return { url: currentUrl, title };
} }
private async wait(milliseconds: number): Promise<void> { private async wait(milliseconds: number): Promise<void> {
@ -395,8 +461,10 @@ export class BrowserSession implements BrowserLike {
async type(selector: unknown, text: string, clearFirst = true, timeoutMs?: number): Promise<unknown> { async type(selector: unknown, text: string, clearFirst = true, timeoutMs?: number): Promise<unknown> {
const info = await this.locate(selector, timeoutMs); const info = await this.locate(selector, timeoutMs);
this.win.show(); if (this.win) {
this.win.focus(); this.win.show();
this.win.focus();
}
await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2); await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2);
const focused = await this.withElement<boolean>(selector, ` const focused = await this.withElement<boolean>(selector, `
el.focus({ focusVisible: true }); el.focus({ focusVisible: true });
@ -483,11 +551,13 @@ export class BrowserSession implements BrowserLike {
} }
async download(selector: unknown, folder: string, filename = "", timeoutMs = 10000): Promise<string> { async download(selector: unknown, folder: string, filename = "", timeoutMs = 10000): Promise<string> {
if (!this.win) return this.downloadExternal(selector, folder, filename, timeoutMs);
const win = this.win;
const targetFolder = path.resolve(folder); const targetFolder = path.resolve(folder);
await fs.mkdir(targetFolder, { recursive: true }); await fs.mkdir(targetFolder, { recursive: true });
return new Promise<string>((resolve, reject) => { return new Promise<string>((resolve, reject) => {
let settled = false; let settled = false;
const browserSession = this.win.webContents.session; const browserSession = win.webContents.session;
const onDownload = (_event: Electron.Event, item: Electron.DownloadItem) => { const onDownload = (_event: Electron.Event, item: Electron.DownloadItem) => {
const target = path.join(targetFolder, filename || item.getFilename()); const target = path.join(targetFolder, filename || item.getFilename());
item.setSavePath(target); item.setSavePath(target);
@ -509,6 +579,49 @@ export class BrowserSession implements BrowserLike {
}); });
} }
/** 外部浏览器下载:Browser.setDownloadBehavior + downloadProgress 事件(guid 命名,完成后重命名为期望文件名) */
private async downloadExternal(selector: unknown, folder: string, filename = "", timeoutMs = 10000): Promise<string> {
const targetFolder = path.resolve(folder);
await fs.mkdir(targetFolder, { recursive: true });
await this.cdp("Browser.setDownloadBehavior", {
behavior: "allowAndName",
downloadPath: targetFolder,
eventsEnabled: true
});
return new Promise<string>((resolve, reject) => {
let settled = false;
let guid = "";
let suggestedName = "";
const finish = (error?: Error, guidFile?: string) => {
if (settled) return;
settled = true;
clearTimeout(timer);
this.transport.off("Browser.downloadWillBegin", onBegin);
this.transport.off("Browser.downloadProgress", onProgress);
if (error || !guidFile) return reject(error ?? new RuntimeError("browser_error", "下载未完成"));
const desired = filename || suggestedName;
const desiredPath = desired ? path.join(targetFolder, desired) : guidFile;
fs
.rename(guidFile, desiredPath)
.then(() => resolve(desiredPath))
.catch(() => resolve(guidFile));
};
const onBegin = (params: Record<string, unknown>) => {
guid = String(params.guid ?? "");
suggestedName = String(params.suggestedFilename ?? "");
};
const onProgress = (params: Record<string, unknown>) => {
if (guid && params.guid !== guid) return;
if (params.state === "completed") finish(undefined, path.join(targetFolder, guid));
else if (params.state === "canceled") finish(new RuntimeError("browser_error", "下载被取消"));
};
const timer = setTimeout(() => finish(new RuntimeError("timeout", `下载超时(${timeoutMs}ms)`)), timeoutMs);
this.transport.on("Browser.downloadWillBegin", onBegin);
this.transport.on("Browser.downloadProgress", onProgress);
void this.click(selector).catch((error) => finish(error instanceof Error ? error : new Error(String(error))));
});
}
async screenshot(filePath: string): Promise<string> { async screenshot(filePath: string): Promise<string> {
const result = await this.cdp<{ data: string }>("Page.captureScreenshot", { format: "png" }); const result = await this.cdp<{ data: string }>("Page.captureScreenshot", { format: "png" });
const target = path.resolve(filePath); const target = path.resolve(filePath);
@ -773,15 +886,19 @@ export class BrowserSession implements BrowserLike {
} }
maximize(): void { maximize(): void {
if (!this.win.isDestroyed()) this.win.maximize(); if (this.win && !this.win.isDestroyed()) this.win.maximize();
} }
minimize(): void { minimize(): void {
if (!this.win.isDestroyed()) this.win.minimize(); if (this.win && !this.win.isDestroyed()) this.win.minimize();
} }
stopLoading(): void { stopLoading(): void {
if (!this.win.isDestroyed()) this.win.webContents.stop(); if (this.win) {
if (!this.win.isDestroyed()) this.win.webContents.stop();
return;
}
void this.cdp("Page.stopLoading").catch(() => {});
} }
async uploadFileToInput(selector: string, filePath: string): Promise<number> { async uploadFileToInput(selector: string, filePath: string): Promise<number> {
@ -798,55 +915,46 @@ export class BrowserSession implements BrowserLike {
} }
startAutoCloseDialogs(): boolean { startAutoCloseDialogs(): boolean {
if (!this.win.isDestroyed() && !this.win.webContents.debugger.isAttached()) this.win.webContents.debugger.attach("1.3"); if (this.dialogAutoClose) return false;
this.dialogAutoClose = true;
this.cdp("Page.enable").catch(() => {}); this.cdp("Page.enable").catch(() => {});
const flags = globalThis as { __rpaAutoCloseDialogs?: boolean; __rpaAutoCloseDialogListener?: unknown }; const handler: CdpEventHandler = () => {
if (flags.__rpaAutoCloseDialogs) return false; this.cdp("Page.handleJavaScriptDialog", { accept: true }).catch(() => {});
flags.__rpaAutoCloseDialogs = true;
const listener = (_event: unknown, method: string) => {
if (method === "Page.javascriptDialogOpening") {
this.cdp("Page.handleJavaScriptDialog", { accept: true }).catch(() => {});
}
}; };
flags.__rpaAutoCloseDialogListener = listener; this.dialogHandler = handler;
this.win.webContents.debugger.on("message", listener); this.transport.on("Page.javascriptDialogOpening", handler);
return true; return true;
} }
stopAutoCloseDialogs(): boolean { stopAutoCloseDialogs(): boolean {
const flags = globalThis as { __rpaAutoCloseDialogs?: boolean; __rpaAutoCloseDialogListener?: unknown }; if (!this.dialogAutoClose) return false;
if (!flags.__rpaAutoCloseDialogs) return false; if (this.dialogHandler) {
if (flags.__rpaAutoCloseDialogListener) { this.transport.off("Page.javascriptDialogOpening", this.dialogHandler);
this.win.webContents.debugger.removeListener("message", flags.__rpaAutoCloseDialogListener as never);
} }
flags.__rpaAutoCloseDialogs = false; this.dialogAutoClose = false;
flags.__rpaAutoCloseDialogListener = undefined; this.dialogHandler = null;
return true; return true;
} }
startNetworkListening(): boolean { startNetworkListening(): boolean {
const flags = globalThis as { __rpaNetListening?: boolean; __rpaNetListener?: unknown; __rpaNetCaptures?: Array<{ requestId: string; url: string; status: number; mimeType: string }> }; if (this.netListening) return false;
if (!this.win.isDestroyed() && !this.win.webContents.debugger.isAttached()) this.win.webContents.debugger.attach("1.3"); this.netListening = true;
this.netCaptures = [];
this.cdp("Network.enable").catch(() => {}); this.cdp("Network.enable").catch(() => {});
if (flags.__rpaNetListening) return false; const handler: CdpEventHandler = (params) => {
flags.__rpaNetListening = true;
flags.__rpaNetCaptures = [];
const listener = (_event: unknown, method: string, params: Record<string, unknown>) => {
if (method !== "Network.responseReceived") return;
const response = params.response as { url?: string; status?: number; mimeType?: string } | undefined; const response = params.response as { url?: string; status?: number; mimeType?: string } | undefined;
const request = params.requestId as string | undefined; const request = params.requestId as string | undefined;
if (!response || !request) return; if (!response || !request) return;
flags.__rpaNetCaptures?.push({ requestId: request, url: response.url ?? "", status: response.status ?? 0, mimeType: response.mimeType ?? "" }); this.netCaptures.push({ requestId: request, url: response.url ?? "", status: response.status ?? 0, mimeType: response.mimeType ?? "" });
if ((flags.__rpaNetCaptures?.length ?? 0) > 2000) flags.__rpaNetCaptures?.shift(); if (this.netCaptures.length > 2000) this.netCaptures.shift();
}; };
flags.__rpaNetListener = listener; this.netHandler = handler;
this.win.webContents.debugger.on("message", listener); this.transport.on("Network.responseReceived", handler);
return true; return true;
} }
async getNetworkCaptures(options: { url?: string; wildcard?: boolean; resourceType?: string; body?: boolean } = {}): Promise<Array<Record<string, unknown>>> { async getNetworkCaptures(options: { url?: string; wildcard?: boolean; resourceType?: string; body?: boolean } = {}): Promise<Array<Record<string, unknown>>> {
const flags = globalThis as { __rpaNetListening?: boolean; __rpaNetCaptures?: Array<{ requestId: string; url: string; status: number; mimeType: string }> }; let captures = this.netCaptures;
let captures = flags.__rpaNetCaptures ?? [];
if (options.resourceType) { if (options.resourceType) {
const want = options.resourceType.toLowerCase(); const want = options.resourceType.toLowerCase();
captures = captures.filter(c => c.mimeType.toLowerCase().includes(want)); captures = captures.filter(c => c.mimeType.toLowerCase().includes(want));
@ -872,22 +980,27 @@ export class BrowserSession implements BrowserLike {
} }
stopNetworkListening(): boolean { stopNetworkListening(): boolean {
const flags = globalThis as { __rpaNetListening?: boolean; __rpaNetListener?: unknown }; if (!this.netListening) return false;
if (!flags.__rpaNetListening) return false; if (this.netHandler) {
if (flags.__rpaNetListener) { this.transport.off("Network.responseReceived", this.netHandler);
this.win.webContents.debugger.removeListener("message", flags.__rpaNetListener as never);
} }
flags.__rpaNetListening = false; this.netListening = false;
flags.__rpaNetListener = undefined; this.netHandler = null;
this.cdp("Network.disable").catch(() => {}); this.cdp("Network.disable").catch(() => {});
return true; return true;
} }
async currentUrl(): Promise<string> { async currentUrl(): Promise<string> {
return this.win.webContents.getURL(); if (this.win) return this.win.webContents.getURL();
return this.evaluate<string>("location.href");
} }
async pickElement(timeoutMs = 90000): Promise<import("../../shared/types").ElementPickResult> { async pickElement(timeoutMs = 90000): Promise<import("../../shared/types").ElementPickResult> {
if (!this.win) {
throw new RuntimeError("not_supported", "元素拾取目前仅支持内置运行浏览器", {
recommendation: "外部浏览器场景请直接填写 css:/text:/xpath: 选择器,或先在内置浏览器中拾取后再切换浏览器类型"
});
}
const url = this.win.webContents.getURL(); const url = this.win.webContents.getURL();
if (!url || url === "about:blank") { if (!url || url === "about:blank") {
throw new RuntimeError("no_page", "浏览器还没有打开网页", { recommendation: "先运行或配置「打开网页」,再拾取元素" }); throw new RuntimeError("no_page", "浏览器还没有打开网页", { recommendation: "先运行或配置「打开网页」,再拾取元素" });
@ -1037,20 +1150,46 @@ export class BrowserSession implements BrowserLike {
} }
async close(): Promise<void> { async close(): Promise<void> {
if (!this.win.isDestroyed()) this.win.close(); if (this.win) {
globalThis.__rpaBrowserWindow = null; if (!this.win.isDestroyed()) this.win.close();
globalThis.__rpaBrowserWindow = null;
return;
}
// 外部浏览器:断开 CDP 连接;本模块拉起的进程按 killOnClose 决定是否关闭
if (this.externalKey) externalSessionCache().delete(this.externalKey);
await this.transport.close().catch(() => {});
const child = this.external?.child;
if (this.external?.killOnClose && child && child.exitCode === null) {
try {
child.kill();
} catch {
// 进程可能已自行退出
}
}
this.external = null;
} }
} }
declare global { declare global {
// eslint-disable-next-line no-var // eslint-disable-next-line no-var
var __rpaBrowserWindow: BrowserWindow | null | undefined; var __rpaBrowserWindow: BrowserWindow | null | undefined;
// eslint-disable-next-line no-var
var __rpaExternalSessions: Map<string, BrowserSession> | undefined;
}
function externalSessionCache(): Map<string, BrowserSession> {
if (!globalThis.__rpaExternalSessions) globalThis.__rpaExternalSessions = new Map();
return globalThis.__rpaExternalSessions;
} }
export async function browserSession(): Promise<BrowserSession> { export async function browserSession(): Promise<BrowserSession> {
return BrowserSession.create(); return BrowserSession.create();
} }
export async function externalBrowserSession(options: ExternalBrowserOptions): Promise<BrowserSession> {
return BrowserSession.createExternal(options);
}
export function hasBrowserSession(): boolean { export function hasBrowserSession(): boolean {
return Boolean(globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed()); return Boolean(globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed());
} }

View File

@ -0,0 +1,232 @@
import type { BrowserWindow } from "electron";
import WebSocket from "ws";
import { RuntimeError } from "../engine/types";
/**
* CDP 传输层抽象。
*
* 上层 BrowserSession 的全部 CDP 通信都经由本接口:
* - EmbeddedTransport:内置运行浏览器(Electron BrowserWindow.webContents.debugger)
* - ExternalTransport:外部 Edge/Chrome 浏览器(WebSocket 直连调试端口)
*
* 两者只在传输方式上不同,CDP 协议域(DOM/Input/Page/Network/Runtime)完全一致。
*/
export type CdpEventHandler = (params: Record<string, unknown>) => void;
export interface CdpTransport {
readonly kind: "embedded" | "external";
/** 连接是否已失效(窗口销毁 / ws 断开) */
readonly closed: boolean;
send<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
/** 订阅 CDP 事件(如 Page.loadEventFired、Network.responseReceived),handler 只收到对应 method 的 params */
on(method: string, handler: CdpEventHandler): void;
off(method: string, handler: CdpEventHandler): void;
close(): Promise<void>;
}
export function mapCdpError(error: unknown, kind: "embedded" | "external"): RuntimeError {
const message = (error as Error)?.message || String(error);
if (/destroyed|Target closed|not attached|connection.*(closed|lost)|websocket/i.test(message)) {
if (kind === "external") {
return new RuntimeError("browser_disconnected", "外部浏览器连接已断开", {
detail: message,
recommendation: "确认浏览器仍处于打开状态;若被手动关闭请重新运行「打开网页」"
});
}
return new RuntimeError("tab_unavailable", "浏览器连接已失效", { detail: message });
}
return new RuntimeError("browser_error", message, { recommendation: "重试或检查页面状态" });
}
/** 内置运行浏览器:包装 webContents.debugger */
export class EmbeddedTransport implements CdpTransport {
readonly kind = "embedded" as const;
private wrappers = new Map<CdpEventHandler, (event: unknown, method: string, params?: Record<string, unknown>) => void>();
constructor(private win: BrowserWindow) {}
get closed(): boolean {
return this.win.isDestroyed();
}
async send<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
if (this.win.isDestroyed()) {
throw new RuntimeError("tab_unavailable", "运行浏览器已关闭", { recommendation: "重新运行流程" });
}
try {
if (!this.win.webContents.debugger.isAttached()) this.win.webContents.debugger.attach("1.3");
return (await this.win.webContents.debugger.sendCommand(method, params)) as T;
} catch (error) {
throw mapCdpError(error, "embedded");
}
}
on(method: string, handler: CdpEventHandler): void {
if (this.win.isDestroyed()) return;
const wrapper = (_event: unknown, evtMethod: string, evtParams?: Record<string, unknown>) => {
if (evtMethod === method) handler(evtParams ?? {});
};
this.wrappers.set(handler, wrapper);
this.win.webContents.debugger.on("message", wrapper as never);
}
off(method: string, handler: CdpEventHandler): void {
const wrapper = this.wrappers.get(handler);
if (wrapper) {
this.win.webContents.debugger.removeListener("message", wrapper as never);
this.wrappers.delete(handler);
}
}
async close(): Promise<void> {
if (!this.win.isDestroyed() && this.win.webContents.debugger.isAttached()) {
try {
this.win.webContents.debugger.detach();
} catch {
// 窗口关闭过程中 detach 可能失败,忽略
}
}
}
}
interface CdpMessage {
id?: number;
method?: string;
params?: Record<string, unknown>;
result?: unknown;
error?: { message?: string; data?: unknown };
}
/** 外部 Edge/Chrome:WebSocket 直连页面级 CDP 端点(webSocketDebuggerUrl) */
export class ExternalTransport implements CdpTransport {
readonly kind = "external" as const;
private ws: WebSocket | null = null;
private nextId = 1;
private pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>();
private handlers = new Map<string, Set<CdpEventHandler>>();
private closedFlag = false;
private readonly openPromise: Promise<void>;
private constructor(
wsUrl: string,
readonly description: string
) {
this.openPromise = new Promise<void>((resolve, reject) => {
const ws = new WebSocket(wsUrl, { maxPayload: 256 * 1024 * 1024 });
this.ws = ws;
ws.on("open", () => resolve());
ws.on("message", (data: unknown) => this.handleMessage(String(data)));
ws.on("error", (error: Error) => {
this.closedFlag = true;
this.failAllPending(new Error(`外部浏览器连接异常:${error.message}`));
reject(error);
});
ws.on("close", () => {
this.closedFlag = true;
this.failAllPending(new Error("外部浏览器连接已关闭"));
});
});
}
static async connect(wsUrl: string, description: string, timeoutMs = 10000): Promise<ExternalTransport> {
const transport = new ExternalTransport(wsUrl, description);
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new RuntimeError("browser_disconnected", `连接外部浏览器超时:${description}`)), timeoutMs)
);
await Promise.race([transport.openPromise, timeout]);
return transport;
}
get closed(): boolean {
return this.closedFlag || !this.ws || this.ws.readyState !== this.ws.OPEN;
}
async send<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
if (this.closed) {
throw new RuntimeError("browser_disconnected", "外部浏览器连接已断开", {
recommendation: "确认浏览器未被手动关闭,然后重新运行「打开网页」"
});
}
const id = this.nextId++;
const ws = this.ws as WebSocket;
const reply = new Promise<unknown>((resolve, reject) => {
this.pending.set(id, { resolve, reject });
try {
ws.send(JSON.stringify({ id, method, params }));
} catch (error) {
this.pending.delete(id);
reject(error as Error);
}
});
try {
return (await reply) as T;
} catch (error) {
throw mapCdpError(error, "external");
}
}
on(method: string, handler: CdpEventHandler): void {
if (!this.handlers.has(method)) this.handlers.set(method, new Set());
this.handlers.get(method)?.add(handler);
}
off(method: string, handler: CdpEventHandler): void {
this.handlers.get(method)?.delete(handler);
}
async close(): Promise<void> {
const ws = this.ws;
this.closedFlag = true;
this.failAllPending(new Error("外部浏览器连接已主动关闭"));
if (!ws) return;
await new Promise<void>((resolve) => {
if (ws.readyState === ws.CLOSED) return resolve();
ws.once("close", () => resolve());
try {
ws.close();
} catch {
resolve();
}
setTimeout(() => resolve(), 1500);
});
}
private handleMessage(raw: string): void {
let message: CdpMessage;
try {
message = JSON.parse(raw) as CdpMessage;
} catch {
return;
}
if (typeof message.id === "number") {
const entry = this.pending.get(message.id);
if (!entry) return;
this.pending.delete(message.id);
if (message.error) {
entry.reject(new Error(message.error.message || "CDP 调用失败"));
} else {
entry.resolve(message.result);
}
return;
}
if (message.method) {
const handlers = this.handlers.get(message.method);
if (!handlers) return;
const params = message.params ?? {};
for (const handler of handlers) {
try {
handler(params);
} catch {
// 单个监听器异常不影响其他监听器
}
}
}
}
private failAllPending(error: Error): void {
for (const [id, entry] of this.pending) {
entry.reject(error);
this.pending.delete(id);
}
}
}

View File

@ -1,6 +1,6 @@
import { register } from "../engine/registry"; import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types"; import { RuntimeError } from "../engine/types";
import { browserSession } from "../browser/session"; import { browserSession, externalBrowserSession } from "../browser/session";
import type { BrowserLike, RunContext } from "../engine/types"; import type { BrowserLike, RunContext } from "../engine/types";
async function session(ctx: RunContext): Promise<BrowserLike> { async function session(ctx: RunContext): Promise<BrowserLike> {
@ -8,6 +8,56 @@ async function session(ctx: RunContext): Promise<BrowserLike> {
return ctx.browser; return ctx.browser;
} }
function isTruthyFlag(value: unknown): boolean {
const text = String(value ?? "").trim().toLowerCase();
return text === "1" || text === "true" || text === "yes";
}
/**
* 「打开网页」浏览器选择:
* - type 填 chrome / edge → 拉起本机对应浏览器(带独立 profile + 调试端口)并接管
* - isRemote 填 1/true → 直连 ipAddress:port 上已监听调试端口的实例(不拉起进程)
* - type 缺省或其他值 → 内置运行浏览器(现状行为,兜底)
*/
async function resolveSession(ctx: RunContext, args: Record<string, unknown>): Promise<{ browser: BrowserLike; label: string }> {
if (ctx.browser) return { browser: ctx.browser, label: "" };
const type = String(args.type ?? "").trim().toLowerCase();
const isRemote = isTruthyFlag(args.isRemote);
if (isRemote) {
const host = String(args.ipAddress ?? "127.0.0.1");
const port = Number(args.port ?? 9222);
const browser = await externalBrowserSession({ mode: "remote", host, port });
ctx.browser = browser;
return { browser, label: browser.label };
}
if (type === "chrome" || type === "edge") {
const extraArgs = String(args.arguments ?? "")
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean);
const browser = await externalBrowserSession({
mode: "launch",
browserType: type === "edge" ? "edge" : "chrome",
port: Number(args.port ?? 9222) || 9222,
extraArgs
});
ctx.browser = browser;
return { browser, label: browser.label };
}
return { browser: await session(ctx), label: "内置运行浏览器" };
}
register(["WebBrowser_CreateV3", "WebBrowser_CreateV2", "WebBrowser_Create", "WebBrowser_CreateBackground"], async (ctx, node, args) => {
const { browser, label } = await resolveSession(ctx, args);
const url = String(args.url ?? "");
if (url) {
const result = await browser.navigate(url);
ctx.log(`已使用${label}打开 ${result.url}`, "info", node.id);
return result;
}
return { url: await browser.currentUrl(), title: "" };
});
function selectorOf(args: Record<string, unknown>): unknown { function selectorOf(args: Record<string, unknown>): unknown {
const selector = args.uiSelector ?? args.selector ?? args.target ?? args.uiWindowSelector; const selector = args.uiSelector ?? args.selector ?? args.target ?? args.uiWindowSelector;
if (selector === undefined || selector === null || selector === "") { if (selector === undefined || selector === null || selector === "") {
@ -18,17 +68,6 @@ function selectorOf(args: Record<string, unknown>): unknown {
return selector; return selector;
} }
register(["WebBrowser_CreateV3", "WebBrowser_CreateV2", "WebBrowser_Create", "WebBrowser_CreateBackground"], async (ctx, node, args) => {
const browser = await session(ctx);
const url = String(args.url ?? "");
if (url) {
const result = await browser.navigate(url);
ctx.log(`已打开 ${result.url}`, "info", node.id);
return result;
}
return { url: await browser.currentUrl(), title: "" };
});
register(["WebBrowser_GoUrl", "WebBrowser_Navigate"], async (ctx, node, args) => { register(["WebBrowser_GoUrl", "WebBrowser_Navigate"], async (ctx, node, args) => {
const browser = await session(ctx); const browser = await session(ctx);
const url = String(args.url ?? ""); const url = String(args.url ?? "");