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:
parent
a25f8c76b6
commit
ccfd82e894
33
package-lock.json
generated
33
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
267
src/main/browser/external.ts
Normal file
267
src/main/browser/external.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
232
src/main/browser/transport.ts
Normal file
232
src/main/browser/transport.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 ?? "");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user