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:
1 parent
a25f8c76b6
commit
ccfd82e894
6 files changed
+786
-74
No files matched your search
+201
-62
@@ -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());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user