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 committed 2026-10-30 11:09:19 +08:00
1 parent a25f8c76b6
commit ccfd82e894
6 files changed
+786 -74

No files matched your search

+201 -62
View File
@@ -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<BrowserSession> {
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<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> {
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<T>(method, params);
}
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 }> {
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<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> {
@@ -395,8 +461,10 @@ export class BrowserSession implements BrowserLike {
async type(selector: unknown, text: string, clearFirst = true, timeoutMs?: number): Promise<unknown> {
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<boolean>(selector, `
el.focus({ focusVisible: true });
@@ -483,11 +551,13 @@ export class BrowserSession implements BrowserLike {
}
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);
await fs.mkdir(targetFolder, { recursive: true });
return new Promise<string>((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<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> {
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<number> {
@@ -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<string, unknown>) => {
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<Array<Record<string, unknown>>> {
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<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> {
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<void> {
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<string, BrowserSession> | undefined;
}
function externalSessionCache(): Map<string, BrowserSession> {
if (!globalThis.__rpaExternalSessions) globalThis.__rpaExternalSessions = new Map();
return globalThis.__rpaExternalSessions;
}
export async function browserSession(): Promise<BrowserSession> {
return BrowserSession.create();
}
export async function externalBrowserSession(options: ExternalBrowserOptions): Promise<BrowserSession> {
return BrowserSession.createExternal(options);
}
export function hasBrowserSession(): boolean {
return Boolean(globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed());
}