diff --git a/package-lock.json b/package-lock.json index abd6e8d..c39f455 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "tesseract.js": "^7.0.0", + "ws": "^8.22.0", "zustand": "^5.0.2" }, "devDependencies": { @@ -31,6 +32,7 @@ "@types/pngjs": "^6.0.5", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/ws": "^8.18.2", "@vitejs/plugin-react": "^4.7.0", "electron": "31.7.7", "electron-builder": "^26.15.7", @@ -3013,6 +3015,16 @@ "@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": { "version": "2.10.3", "resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -10535,6 +10547,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "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": { "version": "15.1.1", "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index af5ecd0..67323bf 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "tesseract.js": "^7.0.0", + "ws": "^8.22.0", "zustand": "^5.0.2" }, "devDependencies": { @@ -40,6 +41,7 @@ "@types/pngjs": "^6.0.5", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/ws": "^8.18.2", "@vitejs/plugin-react": "^4.7.0", "electron": "31.7.7", "electron-builder": "^26.15.7", diff --git a/src/main/browser/external.ts b/src/main/browser/external.ts new file mode 100644 index 0000000..76b1c97 --- /dev/null +++ b/src/main/browser/external.ts @@ -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 = { chrome: "Chrome", edge: "Edge" }; + +const BROWSER_CANDIDATE_PATHS: Record = { + 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 = { + 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 { + if (!p || p === "undefined") return false; + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +function queryRegistry(command: string): Promise { + 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 { + 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(url: string, timeoutMs = 5000, method = "GET"): Promise { + 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 { + try { + await fetchJson(`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 { + 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(`${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(`${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 { + 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(`${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 { + 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)}`; +} diff --git a/src/main/browser/session.ts b/src/main/browser/session.ts index 25d32bd..df6d6ae 100644 --- a/src/main/browser/session.ts +++ b/src/main/browser/session.ts @@ -2,6 +2,13 @@ import { BrowserWindow } from "electron"; import { promises as fs } from "node:fs"; import path from "node:path"; 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; let globalElementWaitMs = DEFAULT_ELEMENT_WAIT_MS; @@ -84,15 +91,27 @@ function selectorSpec(selector: unknown): { kind: string; value: string; role?: } 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.transport = transport; + this.external = external; + this.externalKey = external?.sessionKey ?? ""; } static async create(): Promise { if (globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed()) { - return new BrowserSession(globalThis.__rpaBrowserWindow); + return new BrowserSession(globalThis.__rpaBrowserWindow, new EmbeddedTransport(globalThis.__rpaBrowserWindow)); } const win = new BrowserWindow({ width: 1200, @@ -114,22 +133,37 @@ export class BrowserSession implements BrowserLike { if (!win.webContents.debugger.isAttached()) { 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 { + 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(method: string, params: Record = {}): Promise { - 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) { - 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: "重试或检查页面状态" }); - } + return this.transport.send(method, params); } private async evaluate(expression: string): Promise { @@ -263,8 +297,40 @@ export class BrowserSession implements BrowserLike { async navigate(url: string): Promise<{ url: string; title: string }> { const target = assertUrl(url); await this.cdp("Page.enable"); - await this.win.loadURL(target.toString()); - return { url: this.win.webContents.getURL(), title: this.win.webContents.getTitle() }; + if (this.win) { + 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((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("location.href"); + const title = await this.evaluate("document.title"); + return { url: currentUrl, title }; } private async wait(milliseconds: number): Promise { @@ -395,8 +461,10 @@ export class BrowserSession implements BrowserLike { async type(selector: unknown, text: string, clearFirst = true, timeoutMs?: number): Promise { const info = await this.locate(selector, timeoutMs); - this.win.show(); - this.win.focus(); + if (this.win) { + this.win.show(); + this.win.focus(); + } await this.mouseClick(info.rect.x + info.rect.width / 2, info.rect.y + info.rect.height / 2); const focused = await this.withElement(selector, ` el.focus({ focusVisible: true }); @@ -483,11 +551,13 @@ export class BrowserSession implements BrowserLike { } async download(selector: unknown, folder: string, filename = "", timeoutMs = 10000): Promise { + if (!this.win) return this.downloadExternal(selector, folder, filename, timeoutMs); + const win = this.win; const targetFolder = path.resolve(folder); await fs.mkdir(targetFolder, { recursive: true }); return new Promise((resolve, reject) => { let settled = false; - const browserSession = this.win.webContents.session; + const browserSession = win.webContents.session; const onDownload = (_event: Electron.Event, item: Electron.DownloadItem) => { const target = path.join(targetFolder, filename || item.getFilename()); 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 { + 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((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) => { + guid = String(params.guid ?? ""); + suggestedName = String(params.suggestedFilename ?? ""); + }; + const onProgress = (params: Record) => { + 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 { const result = await this.cdp<{ data: string }>("Page.captureScreenshot", { format: "png" }); const target = path.resolve(filePath); @@ -773,15 +886,19 @@ export class BrowserSession implements BrowserLike { } maximize(): void { - if (!this.win.isDestroyed()) this.win.maximize(); + if (this.win && !this.win.isDestroyed()) this.win.maximize(); } minimize(): void { - if (!this.win.isDestroyed()) this.win.minimize(); + if (this.win && !this.win.isDestroyed()) this.win.minimize(); } 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 { @@ -798,55 +915,46 @@ export class BrowserSession implements BrowserLike { } 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(() => {}); - const flags = globalThis as { __rpaAutoCloseDialogs?: boolean; __rpaAutoCloseDialogListener?: unknown }; - if (flags.__rpaAutoCloseDialogs) return false; - flags.__rpaAutoCloseDialogs = true; - const listener = (_event: unknown, method: string) => { - if (method === "Page.javascriptDialogOpening") { - this.cdp("Page.handleJavaScriptDialog", { accept: true }).catch(() => {}); - } + const handler: CdpEventHandler = () => { + this.cdp("Page.handleJavaScriptDialog", { accept: true }).catch(() => {}); }; - flags.__rpaAutoCloseDialogListener = listener; - this.win.webContents.debugger.on("message", listener); + this.dialogHandler = handler; + this.transport.on("Page.javascriptDialogOpening", handler); return true; } stopAutoCloseDialogs(): boolean { - const flags = globalThis as { __rpaAutoCloseDialogs?: boolean; __rpaAutoCloseDialogListener?: unknown }; - if (!flags.__rpaAutoCloseDialogs) return false; - if (flags.__rpaAutoCloseDialogListener) { - this.win.webContents.debugger.removeListener("message", flags.__rpaAutoCloseDialogListener as never); + if (!this.dialogAutoClose) return false; + if (this.dialogHandler) { + this.transport.off("Page.javascriptDialogOpening", this.dialogHandler); } - flags.__rpaAutoCloseDialogs = false; - flags.__rpaAutoCloseDialogListener = undefined; + this.dialogAutoClose = false; + this.dialogHandler = null; return true; } startNetworkListening(): boolean { - const flags = globalThis as { __rpaNetListening?: boolean; __rpaNetListener?: unknown; __rpaNetCaptures?: Array<{ requestId: string; url: string; status: number; mimeType: string }> }; - if (!this.win.isDestroyed() && !this.win.webContents.debugger.isAttached()) this.win.webContents.debugger.attach("1.3"); + if (this.netListening) return false; + this.netListening = true; + this.netCaptures = []; this.cdp("Network.enable").catch(() => {}); - if (flags.__rpaNetListening) return false; - flags.__rpaNetListening = true; - flags.__rpaNetCaptures = []; - const listener = (_event: unknown, method: string, params: Record) => { - if (method !== "Network.responseReceived") return; + const handler: CdpEventHandler = (params) => { const response = params.response as { url?: string; status?: number; mimeType?: string } | undefined; const request = params.requestId as string | undefined; if (!response || !request) return; - flags.__rpaNetCaptures?.push({ requestId: request, url: response.url ?? "", status: response.status ?? 0, mimeType: response.mimeType ?? "" }); - if ((flags.__rpaNetCaptures?.length ?? 0) > 2000) flags.__rpaNetCaptures?.shift(); + this.netCaptures.push({ requestId: request, url: response.url ?? "", status: response.status ?? 0, mimeType: response.mimeType ?? "" }); + if (this.netCaptures.length > 2000) this.netCaptures.shift(); }; - flags.__rpaNetListener = listener; - this.win.webContents.debugger.on("message", listener); + this.netHandler = handler; + this.transport.on("Network.responseReceived", handler); return true; } async getNetworkCaptures(options: { url?: string; wildcard?: boolean; resourceType?: string; body?: boolean } = {}): Promise>> { - const flags = globalThis as { __rpaNetListening?: boolean; __rpaNetCaptures?: Array<{ requestId: string; url: string; status: number; mimeType: string }> }; - let captures = flags.__rpaNetCaptures ?? []; + let captures = this.netCaptures; if (options.resourceType) { const want = options.resourceType.toLowerCase(); captures = captures.filter(c => c.mimeType.toLowerCase().includes(want)); @@ -872,22 +980,27 @@ export class BrowserSession implements BrowserLike { } stopNetworkListening(): boolean { - const flags = globalThis as { __rpaNetListening?: boolean; __rpaNetListener?: unknown }; - if (!flags.__rpaNetListening) return false; - if (flags.__rpaNetListener) { - this.win.webContents.debugger.removeListener("message", flags.__rpaNetListener as never); + if (!this.netListening) return false; + if (this.netHandler) { + this.transport.off("Network.responseReceived", this.netHandler); } - flags.__rpaNetListening = false; - flags.__rpaNetListener = undefined; + this.netListening = false; + this.netHandler = null; this.cdp("Network.disable").catch(() => {}); return true; } async currentUrl(): Promise { - return this.win.webContents.getURL(); + if (this.win) return this.win.webContents.getURL(); + return this.evaluate("location.href"); } async pickElement(timeoutMs = 90000): Promise { + if (!this.win) { + throw new RuntimeError("not_supported", "元素拾取目前仅支持内置运行浏览器", { + recommendation: "外部浏览器场景请直接填写 css:/text:/xpath: 选择器,或先在内置浏览器中拾取后再切换浏览器类型" + }); + } const url = this.win.webContents.getURL(); if (!url || url === "about:blank") { throw new RuntimeError("no_page", "浏览器还没有打开网页", { recommendation: "先运行或配置「打开网页」,再拾取元素" }); @@ -1037,20 +1150,46 @@ export class BrowserSession implements BrowserLike { } async close(): Promise { - if (!this.win.isDestroyed()) this.win.close(); - globalThis.__rpaBrowserWindow = null; + if (this.win) { + 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 { // eslint-disable-next-line no-var var __rpaBrowserWindow: BrowserWindow | null | undefined; + // eslint-disable-next-line no-var + var __rpaExternalSessions: Map | undefined; +} + +function externalSessionCache(): Map { + if (!globalThis.__rpaExternalSessions) globalThis.__rpaExternalSessions = new Map(); + return globalThis.__rpaExternalSessions; } export async function browserSession(): Promise { return BrowserSession.create(); } +export async function externalBrowserSession(options: ExternalBrowserOptions): Promise { + return BrowserSession.createExternal(options); +} + export function hasBrowserSession(): boolean { return Boolean(globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed()); } diff --git a/src/main/browser/transport.ts b/src/main/browser/transport.ts new file mode 100644 index 0000000..8c69f18 --- /dev/null +++ b/src/main/browser/transport.ts @@ -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) => void; + +export interface CdpTransport { + readonly kind: "embedded" | "external"; + /** 连接是否已失效(窗口销毁 / ws 断开) */ + readonly closed: boolean; + send(method: string, params?: Record): Promise; + /** 订阅 CDP 事件(如 Page.loadEventFired、Network.responseReceived),handler 只收到对应 method 的 params */ + on(method: string, handler: CdpEventHandler): void; + off(method: string, handler: CdpEventHandler): void; + close(): Promise; +} + +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) => void>(); + + constructor(private win: BrowserWindow) {} + + get closed(): boolean { + return this.win.isDestroyed(); + } + + async send(method: string, params: Record = {}): Promise { + 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) => { + 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 { + 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; + 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 void; reject: (error: Error) => void }>(); + private handlers = new Map>(); + private closedFlag = false; + private readonly openPromise: Promise; + + private constructor( + wsUrl: string, + readonly description: string + ) { + this.openPromise = new Promise((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 { + const transport = new ExternalTransport(wsUrl, description); + const timeout = new Promise((_, 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(method: string, params: Record = {}): Promise { + if (this.closed) { + throw new RuntimeError("browser_disconnected", "外部浏览器连接已断开", { + recommendation: "确认浏览器未被手动关闭,然后重新运行「打开网页」" + }); + } + const id = this.nextId++; + const ws = this.ws as WebSocket; + const reply = new Promise((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 { + const ws = this.ws; + this.closedFlag = true; + this.failAllPending(new Error("外部浏览器连接已主动关闭")); + if (!ws) return; + await new Promise((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); + } + } +} diff --git a/src/main/capabilities/browser.ts b/src/main/capabilities/browser.ts index 2060917..3bf169c 100644 --- a/src/main/capabilities/browser.ts +++ b/src/main/capabilities/browser.ts @@ -1,6 +1,6 @@ import { register } from "../engine/registry"; import { RuntimeError } from "../engine/types"; -import { browserSession } from "../browser/session"; +import { browserSession, externalBrowserSession } from "../browser/session"; import type { BrowserLike, RunContext } from "../engine/types"; async function session(ctx: RunContext): Promise { @@ -8,6 +8,56 @@ async function session(ctx: RunContext): Promise { 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): 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): unknown { const selector = args.uiSelector ?? args.selector ?? args.target ?? args.uiWindowSelector; if (selector === undefined || selector === null || selector === "") { @@ -18,17 +68,6 @@ function selectorOf(args: Record): unknown { 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) => { const browser = await session(ctx); const url = String(args.url ?? "");