阶段2-断Electron依赖: human-dialog/window-extended/ai-service经HostBridge迁入包, external+transport迁包(session留宿主), 新增check:deps依赖护栏与包README留守清单
This commit is contained in:
parent
caa8e19332
commit
3decced268
39
package-lock.json
generated
39
package-lock.json
generated
@ -7,6 +7,9 @@
|
||||
"": {
|
||||
"name": "rpa-studio",
|
||||
"version": "0.1.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.11.1",
|
||||
"@gutenye/ocr-models": "^1.4.2",
|
||||
@ -31,7 +34,6 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"sm-crypto": "^0.5.8",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"ws": "^8.22.0",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -43,7 +45,6 @@
|
||||
"@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",
|
||||
@ -3140,6 +3141,10 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rpa/core": {
|
||||
"resolved": "packages/rpa-core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@selderee/plugin-htmlparser2": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmmirror.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
|
||||
@ -12645,6 +12650,36 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/rpa-core": {
|
||||
"name": "@rpa/core",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@gutenye/ocr-node": "^1.4.9",
|
||||
"adm-zip": "^0.5.16",
|
||||
"basic-ftp": "^6.2.3",
|
||||
"exceljs": "^4.4.0",
|
||||
"imapflow": "^2.10.0",
|
||||
"mailparser": "^3.14.1",
|
||||
"mssql": "^12.7.13",
|
||||
"mysql2": "^3.24.6",
|
||||
"nodemailer": "^6.9.16",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "^8.23.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"sm-crypto": "^0.5.8",
|
||||
"ws": "^8.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/mssql": "^12.3.0",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/ws": "^8.18.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"build": "electron-vite build",
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"check:deps": "node packages/rpa-core/scripts/check-deps.mjs",
|
||||
"catalog": "node scripts/build-catalog.mjs",
|
||||
"snapshot": "vitest run scripts/capability-snapshot.test.ts",
|
||||
"package": "node scripts/package.mjs",
|
||||
@ -44,7 +45,6 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"sm-crypto": "^0.5.8",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"ws": "^8.22.0",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -56,7 +56,6 @@
|
||||
"@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",
|
||||
|
||||
47
packages/rpa-core/README.md
Normal file
47
packages/rpa-core/README.md
Normal file
@ -0,0 +1,47 @@
|
||||
# @rpa/core
|
||||
|
||||
RPA 能力层包:零 Electron 依赖的流程引擎、能力注册表与能力实现。
|
||||
抽离路线见 `docs/rpa-core-refactor-tasks.md`,开发规范见 `docs/dev-conventions.md`。
|
||||
|
||||
## 包边界
|
||||
|
||||
| 模块 | 说明 |
|
||||
|---|---|
|
||||
| `src/engine/` | 注册表、运行时、表达式、`createEngine()` 工厂、`host-bridge.ts` 宿主桥 |
|
||||
| `src/capabilities/` | 能力实现(副作用注册,经 `@rpa/core/capabilities` 拉起) |
|
||||
| `src/browser/` | 外部浏览器 CDP:`external.ts`(Edge/Chrome 进程管理)+ `transport.ts`(CdpTransport 接口、ExternalTransport) |
|
||||
|
||||
## HostBridge 契约(决策点 4 最小面)
|
||||
|
||||
宿主(Electron 主进程 / 未来 Agent-Employee product-shell)启动时注入:
|
||||
|
||||
```ts
|
||||
import { setHostBridge, setSubflowSource } from "@rpa/core";
|
||||
|
||||
setHostBridge({
|
||||
createFormWindow({ title }) { /* 返回 HostDialogWindow 适配(BrowserWindow) */ },
|
||||
dipToScreenPoint(p) { /* screen.dipToScreenPoint */ },
|
||||
screenToDipPoint(p) { /* screen.screenToDipPoint */ },
|
||||
loadAiConfig() { /* settings.aiConfig */ }
|
||||
});
|
||||
setSubflowSource({ listFlows, getFlow }); // 子流程仓库
|
||||
```
|
||||
|
||||
未注入时的行为:window-extended 的 DPI 换算回退恒等(与旧 vitest 行为一致);
|
||||
ai-service 返回空配置(requireAiConfig 统一抛 `ai_not_configured`);
|
||||
human-dialog 抛 `host_unavailable`。
|
||||
|
||||
## Electron 相关能力留守清单(宿主适配层,不入包)
|
||||
|
||||
- `src/main/browser/session.ts`:BrowserSession 编排 + 内置运行浏览器(依赖 `BrowserWindow` 与应用生命周期)
|
||||
- `src/main/browser/transport.ts`:`EmbeddedTransport`(包装 `webContents.debugger`)
|
||||
- `src/main/capabilities/browser*.ts` / `web-batch2.ts` / `web-crawl.ts`:经 BrowserSession 操作浏览器窗口
|
||||
- `src/main/capabilities/ai-caps.ts`:AI 编排能力(依赖宿主注入的 chatCompletion 配置面,可随后续阶段评估迁入)
|
||||
- `src/main/overlay.ts` / `desktop-pick.ts` / `vision-lab.ts` / `fixture/`:Electron 窗口强耦合
|
||||
- `src/main/local-vision/`:onnxruntime-node 原生依赖,Bun 兼容性单独立项验证
|
||||
- `src/main/flows.ts` / `run/` / `settings.ts`:应用级编排与持久化
|
||||
|
||||
## 依赖护栏
|
||||
|
||||
`npm run check:deps`(根目录亦可)扫描 `src/**`:禁止 `import "electron"`、
|
||||
相对引用逃出包 `src/`、引用宿主 `src/main`。阶段 2 起为提交前必跑项。
|
||||
@ -11,7 +11,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"check:deps": "node scripts/check-deps.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gutenye/ocr-node": "^1.4.9",
|
||||
@ -26,7 +27,8 @@
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "^8.23.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"sm-crypto": "^0.5.8"
|
||||
"sm-crypto": "^0.5.8",
|
||||
"ws": "^8.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
@ -34,6 +36,7 @@
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/ws": "^8.18.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
|
||||
63
packages/rpa-core/scripts/check-deps.mjs
Normal file
63
packages/rpa-core/scripts/check-deps.mjs
Normal file
@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* rpa-core 依赖护栏(任务 2.4,规范 v1 第二节的机器强制版)。
|
||||
*
|
||||
* 禁止:
|
||||
* 1. import "electron"(含 "electron/..." 子路径);
|
||||
* 2. 相对 import 逃出包 src/(如指向宿主 src/main 的 ../../../src/main/...);
|
||||
* 3. 任何 import 路径中出现 src/main(宿主模块横向依赖)。
|
||||
*
|
||||
* 用法:node scripts/check-deps.mjs(退出码非 0 表示违规)
|
||||
*/
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const srcRoot = path.join(pkgRoot, "src");
|
||||
|
||||
const IMPORT_RE = /(?:import|export)\s+(?:[\s\S]*?from\s*)?["']([^"']+)["']/g;
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...(await walk(full)));
|
||||
else if (/\.tsx?$/.test(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const offenders = [];
|
||||
for (const file of await walk(srcRoot)) {
|
||||
const raw = await readFile(file, "utf8");
|
||||
// 剥掉注释,避免文档示例里的 "electron" 字样误报
|
||||
const source = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||
for (const match of source.matchAll(IMPORT_RE)) {
|
||||
const spec = match[1];
|
||||
const rel = path.relative(pkgRoot, file).replaceAll("\\", "/");
|
||||
if (spec === "electron" || spec.startsWith("electron/")) {
|
||||
offenders.push(`${rel}: 禁止 import "${spec}"(宿主能力走 HostBridge 注入)`);
|
||||
continue;
|
||||
}
|
||||
if (spec.includes("src/main")) {
|
||||
offenders.push(`${rel}: 禁止引用宿主模块 "${spec}"`);
|
||||
continue;
|
||||
}
|
||||
if (spec.startsWith(".")) {
|
||||
const resolved = path.resolve(path.dirname(file), spec);
|
||||
if (!resolved.startsWith(srcRoot + path.sep) && resolved !== srcRoot) {
|
||||
offenders.push(`${rel}: 相对引用逃出包 src/ → "${spec}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (offenders.length) {
|
||||
console.error("rpa-core 依赖护栏检查失败:");
|
||||
for (const line of offenders) console.error(" - " + line);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`rpa-core 依赖护栏检查通过(src/ 下 ${offenders.length === 0 ? "无" : ""}违规 import)`);
|
||||
@ -2,7 +2,7 @@ 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 "@rpa/core";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
import { ExternalTransport } from "./transport";
|
||||
|
||||
/**
|
||||
180
packages/rpa-core/src/browser/transport.ts
Normal file
180
packages/rpa-core/src/browser/transport.ts
Normal file
@ -0,0 +1,180 @@
|
||||
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: "重试或检查页面状态" });
|
||||
}
|
||||
|
||||
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,5 +1,5 @@
|
||||
import { loadSettings } from "../settings";
|
||||
import { RuntimeError } from "@rpa/core";
|
||||
import { getHostBridge } from "../engine/host-bridge";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
|
||||
export type ChatContentPart =
|
||||
| { type: "text"; text: string }
|
||||
@ -18,7 +18,9 @@ export interface ResolvedAiConfig {
|
||||
}
|
||||
|
||||
export function getAiConfig(): ResolvedAiConfig {
|
||||
const config = loadSettings().aiConfig;
|
||||
const bridge = getHostBridge();
|
||||
// 宿主桥未注入(vitest / CLI 未配置)时返回空配置,requireAiConfig 统一抛 ai_not_configured
|
||||
const config = bridge ? bridge.loadAiConfig() : { baseUrl: "", apiKey: "", model: "", visionModel: "" };
|
||||
return { ...config, visionModel: config.visionModel || config.model };
|
||||
}
|
||||
|
||||
@ -4,10 +4,10 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../../shared/types";
|
||||
import type { RunContext } from "@rpa/core";
|
||||
import { getHandler } from "@rpa/core";
|
||||
import "@rpa/core/capabilities"; import "./window-extended";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./index";
|
||||
|
||||
const dirs: string[] = []; const context = { vars: {} } as RunContext; const component = {} as CatalogComponent;
|
||||
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
@ -1,6 +1,6 @@
|
||||
import { BrowserWindow } from "electron";
|
||||
import { register } from "@rpa/core";
|
||||
import { RuntimeError } from "@rpa/core";
|
||||
import { getHostBridge, type HostDialogWindow } from "../engine/host-bridge";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError } from "../engine/types";
|
||||
|
||||
interface FormFieldSpec { key?: string; name?: string; label?: string; type?: string; value?: string; options?: unknown[] }
|
||||
|
||||
@ -28,6 +28,12 @@ function escapeHtml(s: string): string {
|
||||
}
|
||||
|
||||
async function showForm(options: { title: string; fields: FormFieldSpec[]; timeoutSec: number }): Promise<Record<string, string>> {
|
||||
const bridge = getHostBridge();
|
||||
if (!bridge) {
|
||||
throw new RuntimeError("host_unavailable", "宿主桥未注入,无法打开人机交互窗口", {
|
||||
recommendation: "宿主(Electron 主进程)启动时需调用 setHostBridge() 注入窗口工厂"
|
||||
});
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const rows = options.fields.map((f, i) => {
|
||||
const key = escapeHtml(fieldKey(f, i));
|
||||
@ -53,11 +59,7 @@ async function showForm(options: { title: string; fields: FormFieldSpec[]; timeo
|
||||
return "<label>" + label + '<br/><input type="' + inputType + '" data-key="' + key + '" value="' + value + '" /></label><br/>';
|
||||
}).join("\n");
|
||||
const html = '<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{font-family:Microsoft YaHei,sans-serif;margin:16px;font-size:14px;color:#1f2937}label{display:block;margin-bottom:10px}input,select,textarea{width:100%;box-sizing:border-box;padding:6px;margin-top:4px}#ok{margin-top:8px;padding:8px 24px;background:#2563eb;color:#fff;border:none;border-radius:6px;cursor:pointer}</style></head><body><h3>' + escapeHtml(options.title) + "</h3>" + rows + '<button id="ok">确认</button><script>document.getElementById("ok").onclick=function(){var out={};document.querySelectorAll("[data-key]").forEach(function(el){out[el.dataset.key]=el.type==="checkbox"?el.checked:el.value});window.__formValues=out;};</script></body></html>';
|
||||
const win = new BrowserWindow({
|
||||
width: 480, height: 0, useContentSize: true, autoHideMenuBar: true,
|
||||
title: options.title, alwaysOnTop: true,
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }
|
||||
});
|
||||
const win: HostDialogWindow = bridge.createFormWindow({ title: options.title });
|
||||
let settled = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
@ -71,14 +73,14 @@ async function showForm(options: { title: string; fields: FormFieldSpec[]; timeo
|
||||
};
|
||||
timer = setInterval(async () => {
|
||||
try {
|
||||
const values = await win.webContents.executeJavaScript("window.__formValues || null", true);
|
||||
const values = await win.executeJavaScript("window.__formValues || null");
|
||||
if (values) finish(values as Record<string, string>);
|
||||
} catch {
|
||||
finish({});
|
||||
}
|
||||
}, 250);
|
||||
timeout = setTimeout(() => {
|
||||
try { void win.webContents.executeJavaScript("document.getElementById(\"ok\").click()"); } catch {
|
||||
try { void win.executeJavaScript("document.getElementById(\"ok\").click()"); } catch {
|
||||
finish({});
|
||||
}
|
||||
}, Math.max(5, options.timeoutSec) * 1000);
|
||||
@ -1,11 +1,11 @@
|
||||
/**
|
||||
* rpa-core 纯 Node 能力注册入口。
|
||||
* rpa-core 能力注册入口。
|
||||
*
|
||||
* import 本模块即完成全部纯 Node 能力的副作用注册(与改造前
|
||||
* import 本模块即完成全部包内能力的副作用注册(与改造前
|
||||
* src/main/capabilities/index.ts 的加载顺序保持一致,别名/重复 id 的
|
||||
* last-wins 语义不变)。依赖 Electron 或宿主模块的能力(human-dialog /
|
||||
* window-extended / ai-service / ai-caps / browser* / web-batch2 / web-crawl)
|
||||
* 留在宿主 src/main/capabilities,阶段 2 通过 HostBridge 迁入。
|
||||
* last-wins 语义不变)。依赖 Electron 窗口的浏览器能力(ai-caps / browser* /
|
||||
* web-batch2 / web-crawl)留在宿主 src/main/capabilities;
|
||||
* human-dialog / ai-service / window-extended 已于阶段 2 经 HostBridge 迁入。
|
||||
*/
|
||||
import "./control";
|
||||
import "./files";
|
||||
@ -37,3 +37,6 @@ import "./basic-encryption";
|
||||
import "./mail-remaining";
|
||||
import "./remaining-core";
|
||||
import "./common-final";
|
||||
import "./human-dialog";
|
||||
import "./ai-service";
|
||||
import "./window-extended";
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../../shared/types";
|
||||
import { getHandler } from "@rpa/core";
|
||||
import type { RunContext } from "@rpa/core";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import "./window-extended";
|
||||
|
||||
const run = promisify(execFile);
|
||||
@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowNode } from "../../shared/types";
|
||||
import type { RunContext } from "@rpa/core";
|
||||
import { getHandler } from "@rpa/core";
|
||||
import type { CatalogComponent, FlowNode } from "../types";
|
||||
import type { RunContext } from "../engine/types";
|
||||
import { getHandler } from "../engine/registry";
|
||||
import "./window-extended";
|
||||
|
||||
const context = {} as RunContext;
|
||||
@ -1,7 +1,7 @@
|
||||
import { register } from "@rpa/core";
|
||||
import { RuntimeError, type CapabilityHandler } from "@rpa/core";
|
||||
import { powershell, psString } from "@rpa/core";
|
||||
import { screen } from "electron";
|
||||
import { register } from "../engine/registry";
|
||||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||||
import { powershell, psString } from "./desktop";
|
||||
import { getHostBridge } from "../engine/host-bridge";
|
||||
|
||||
const WINDOW_API = `Add-Type @'
|
||||
using System;
|
||||
@ -136,11 +136,12 @@ function mousePath(from: [number, number], to: [number, number], mode: string, s
|
||||
|
||||
/** 界面(框选/元素拾取/截图)产出的是 DIP 逻辑坐标;DPI 感知进程的 SetCursorPos 用物理像素,
|
||||
* 缩放屏(125%/150%)下直接混用会成比例偏移(用户实测 125% 下差约 25% 距离)。
|
||||
* 统一在执行前换算;非 Electron 环境(vitest)screen 不可用,回退原值保持旧行为 */
|
||||
* 统一在执行前换算;宿主桥未注入时(vitest)回退原值保持旧行为 */
|
||||
function dipToPhysical(x: number, y: number): [number, number] {
|
||||
try {
|
||||
if (screen && typeof screen.dipToScreenPoint === "function") {
|
||||
const physical = screen.dipToScreenPoint({ x, y });
|
||||
const bridge = getHostBridge();
|
||||
if (bridge) {
|
||||
const physical = bridge.dipToScreenPoint({ x, y });
|
||||
return [Math.round(physical.x), Math.round(physical.y)];
|
||||
}
|
||||
} catch {}
|
||||
@ -150,8 +151,9 @@ function dipToPhysical(x: number, y: number): [number, number] {
|
||||
/** 反向换算:物理像素(如 GetWindowRect 结果) → DIP,用于「相对激活窗口」偏移叠加 */
|
||||
function physicalToDip(x: number, y: number): [number, number] {
|
||||
try {
|
||||
if (screen && typeof screen.screenToDipPoint === "function") {
|
||||
const dip = screen.screenToDipPoint({ x, y });
|
||||
const bridge = getHostBridge();
|
||||
if (bridge) {
|
||||
const dip = bridge.screenToDipPoint({ x, y });
|
||||
return [Math.round(dip.x), Math.round(dip.y)];
|
||||
}
|
||||
} catch {}
|
||||
@ -1,11 +1,13 @@
|
||||
import type { FlowDefinition } from "../types";
|
||||
|
||||
/**
|
||||
* 宿主桥(HostBridge 候选,阶段 2 正式化接口)。
|
||||
* 宿主桥(任务 2.1 正式化)。
|
||||
*
|
||||
* 包内个别能力需要宿主数据(目前仅子流程仓库),通过显式 setter 注入,
|
||||
* 包本身不得 import 宿主模块。宿主启动时调用 setSubflowSource()。
|
||||
* 包内能力需要的宿主能力(子流程仓库 / Electron 窗口 / DPI 换算 / 应用配置)
|
||||
* 通过本接口注入,包本身禁止 import "electron" 或宿主模块(见包内依赖检查脚本)。
|
||||
* 宿主启动时调用 setSubflowSource() / setHostBridge()。
|
||||
*/
|
||||
|
||||
export interface SubflowSource {
|
||||
listFlows(): Promise<Array<{ id: string; name: string }>>;
|
||||
getFlow(id: string): Promise<FlowDefinition>;
|
||||
@ -20,3 +22,48 @@ export function setSubflowSource(source: SubflowSource): void {
|
||||
export function getSubflowSource(): SubflowSource | undefined {
|
||||
return subflowSource;
|
||||
}
|
||||
|
||||
/** 人机交互表单窗口的最小面(能力只用到这些成员,宿主用 BrowserWindow 适配) */
|
||||
export interface HostDialogWindow {
|
||||
isDestroyed(): boolean;
|
||||
destroy(): void;
|
||||
loadURL(url: string): Promise<void>;
|
||||
on(event: "closed", listener: () => void): void;
|
||||
/** 在窗口页面里执行 JS 并取回结果(宿主侧以 userGesture=true 执行) */
|
||||
executeJavaScript(script: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface HostPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** AI 服务配置(宿主 settings.aiConfig) */
|
||||
export interface HostAiConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
visionModel: string;
|
||||
}
|
||||
|
||||
/** 宿主能力桥最小面(决策点 4):dialog 窗口工厂 / DPI 坐标换算 / 配置读取 */
|
||||
export interface HostBridge {
|
||||
/** 创建人机交互表单窗口(human-dialog 用) */
|
||||
createFormWindow(options: { title: string }): HostDialogWindow;
|
||||
/** DIP 逻辑坐标 → 物理像素(window-extended 的鼠标/拖拽坐标换算) */
|
||||
dipToScreenPoint(point: HostPoint): HostPoint;
|
||||
/** 物理像素 → DIP 逻辑坐标 */
|
||||
screenToDipPoint(point: HostPoint): HostPoint;
|
||||
/** 读取 AI 服务配置(ai-service 用) */
|
||||
loadAiConfig(): HostAiConfig;
|
||||
}
|
||||
|
||||
let hostBridge: HostBridge | undefined;
|
||||
|
||||
export function setHostBridge(bridge: HostBridge): void {
|
||||
hostBridge = bridge;
|
||||
}
|
||||
|
||||
export function getHostBridge(): HostBridge | undefined {
|
||||
return hostBridge;
|
||||
}
|
||||
|
||||
@ -10,6 +10,40 @@ export { register, getHandler, hasCapability, capabilityIds, capabilityCount } f
|
||||
export { runFlow } from "./engine/runtime";
|
||||
export { RuntimeError, BreakSignal, ContinueSignal, StopSignal, asRuntimeError } from "./engine/types";
|
||||
export { setSubflowSource, getSubflowSource, type SubflowSource } from "./engine/host-bridge";
|
||||
export {
|
||||
setHostBridge,
|
||||
getHostBridge,
|
||||
type HostBridge,
|
||||
type HostDialogWindow,
|
||||
type HostPoint,
|
||||
type HostAiConfig
|
||||
} from "./engine/host-bridge";
|
||||
// AI 服务(阶段 2 经 HostBridge 迁入包内):宿主注入 loadAiConfig,消费方走此导出
|
||||
export {
|
||||
chatCompletion,
|
||||
getAiConfig,
|
||||
requireAiConfig,
|
||||
type ChatCompletionOptions,
|
||||
type ChatContentPart,
|
||||
type ChatMessage,
|
||||
type ResolvedAiConfig
|
||||
} from "./capabilities/ai-service";
|
||||
// 浏览器 CDP 外部路径(阶段 2 迁入):ExternalTransport + 外部浏览器进程管理
|
||||
export { ExternalTransport, mapCdpError, type CdpTransport, type CdpEventHandler } from "./browser/transport";
|
||||
export {
|
||||
listPageTargets,
|
||||
openExternalBrowser,
|
||||
openNewPageTarget,
|
||||
sessionKeyOf,
|
||||
detectBrowserPath,
|
||||
connectRemoteBrowser,
|
||||
launchExternalBrowser,
|
||||
type ExternalBrowserOptions,
|
||||
type ExternalBrowserType,
|
||||
type ExternalLaunchOptions,
|
||||
type ExternalRemoteOptions,
|
||||
type ExternalConnection
|
||||
} from "./browser/external";
|
||||
// PowerShell 助手(阶段 3 将移入 driver/windows/):宿主留守能力(window-extended 等)经此引用
|
||||
export { powershell, psString } from "./capabilities/desktop";
|
||||
export type {
|
||||
|
||||
@ -2,15 +2,18 @@ import { BrowserWindow } from "electron";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ElementPickResult } from "../../shared/types";
|
||||
import { EmbeddedTransport, ExternalTransport, type CdpTransport, type CdpEventHandler } from "./transport";
|
||||
import { EmbeddedTransport } from "./transport";
|
||||
import {
|
||||
ExternalTransport,
|
||||
listPageTargets,
|
||||
openExternalBrowser,
|
||||
openNewPageTarget,
|
||||
sessionKeyOf,
|
||||
type CdpEventHandler,
|
||||
type CdpTransport,
|
||||
type ExternalBrowserOptions,
|
||||
type ExternalConnection
|
||||
} from "./external";
|
||||
} from "@rpa/core";
|
||||
|
||||
const DEFAULT_ELEMENT_WAIT_MS = 5000;
|
||||
let globalElementWaitMs = DEFAULT_ELEMENT_WAIT_MS;
|
||||
|
||||
@ -1,44 +1,12 @@
|
||||
import type { BrowserWindow } from "electron";
|
||||
import WebSocket from "ws";
|
||||
import { RuntimeError } from "@rpa/core";
|
||||
import { RuntimeError, mapCdpError, type CdpEventHandler, type CdpTransport } from "@rpa/core";
|
||||
|
||||
/**
|
||||
* CDP 传输层抽象。
|
||||
* 内置运行浏览器传输层(宿主适配层,任务 2.3)。
|
||||
*
|
||||
* 上层 BrowserSession 的全部 CDP 通信都经由本接口:
|
||||
* - EmbeddedTransport:内置运行浏览器(Electron BrowserWindow.webContents.debugger)
|
||||
* - ExternalTransport:外部 Edge/Chrome 浏览器(WebSocket 直连调试端口)
|
||||
*
|
||||
* 两者只在传输方式上不同,CDP 协议域(DOM/Input/Page/Network/Runtime)完全一致。
|
||||
* 包装 Electron BrowserWindow.webContents.debugger,实现包内的 CdpTransport 接口;
|
||||
* CdpTransport / mapCdpError / ExternalTransport(外部浏览器)已迁入 @rpa/core。
|
||||
*/
|
||||
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>();
|
||||
@ -88,145 +56,3 @@ export class EmbeddedTransport implements CdpTransport {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 "@rpa/core";
|
||||
import { RuntimeError } from "@rpa/core";
|
||||
import { chatCompletion, requireAiConfig } from "./ai-service";
|
||||
import { chatCompletion, requireAiConfig } from "@rpa/core";
|
||||
|
||||
function inputText(args: Record<string, unknown>, nodeId: string): string {
|
||||
for (const key of ["input", "text", "content", "sentence", "prompt", "sourceFilePath", "filePath", "source", "document"]) {
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
/**
|
||||
* 宿主能力注册入口。
|
||||
*
|
||||
* 纯 Node 能力已迁入 @rpa/core(packages/rpa-core),第一行 import 拉起包内全部注册;
|
||||
* 此处只保留依赖 Electron / 宿主模块的能力,阶段 2 通过 HostBridge 逐步迁入。
|
||||
* import "@rpa/core/capabilities" 拉起包内全部能力注册(含阶段 2 经
|
||||
* HostBridge 迁入的 human-dialog / ai-service / window-extended);
|
||||
* 此处只保留依赖 Electron 窗口/会话的浏览器能力与 AI 编排能力。
|
||||
* 导出顺序整体保持改造前 index.ts 的注册顺序(别名/重复 id last-wins 语义不变)。
|
||||
*/
|
||||
import "@rpa/core/capabilities";
|
||||
@ -13,9 +14,6 @@ import "./browser-common";
|
||||
import "./browser-control";
|
||||
import "./web-batch2";
|
||||
import "./web-crawl";
|
||||
import "./human-dialog";
|
||||
import "./ai-service";
|
||||
import "./ai-caps";
|
||||
import "./window-extended";
|
||||
|
||||
export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "@rpa/core";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
|
||||
import { app, BrowserWindow, dialog, ipcMain, screen, shell } from "electron";
|
||||
import path from "node:path";
|
||||
import type { AppInfo, AppSettings, FlowDefinition, FlowSaveOptions, FlowSaveResult } from "../shared/types";
|
||||
import {
|
||||
@ -11,7 +11,7 @@ import {
|
||||
import "./capabilities";
|
||||
import { catalogPath, loadCatalog } from "./catalog";
|
||||
import { createDemoFlow, createExampleFlows, createFlow, deleteFlow, getFlow, listFlows, saveFlow } from "./flows";
|
||||
import { capabilityCount, capabilityIds, setSubflowSource } from "@rpa/core";
|
||||
import { capabilityCount, capabilityIds, setHostBridge, setSubflowSource, type HostDialogWindow } from "@rpa/core";
|
||||
import { closeBrowserSession, setMainWindowGetter, startRun, stopRun } from "./run/manager";
|
||||
import { registerOverlayIpc } from "./overlay";
|
||||
import { fixtureUrl, startFixtureServer, stopFixtureServer } from "./fixture/server";
|
||||
@ -24,6 +24,31 @@ import { logMain } from "./logger";
|
||||
// 子流程仓库在宿主(flows.ts),包内能力经 host-bridge 取用(阶段 2 正式化 HostBridge)
|
||||
setSubflowSource({ listFlows: async () => listFlows(), getFlow: async (id) => getFlow(id) });
|
||||
|
||||
// 宿主能力桥(决策点 4 最小面):人机交互窗口工厂 / DPI 坐标换算 / AI 配置读取
|
||||
setHostBridge({
|
||||
createFormWindow: ({ title }) => {
|
||||
const win = new BrowserWindow({
|
||||
width: 480, height: 0, useContentSize: true, autoHideMenuBar: true,
|
||||
title, alwaysOnTop: true,
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }
|
||||
});
|
||||
const dialogWindow: HostDialogWindow = {
|
||||
isDestroyed: () => win.isDestroyed(),
|
||||
destroy: () => { if (!win.isDestroyed()) win.destroy(); },
|
||||
loadURL: (url) => win.loadURL(url),
|
||||
on: (event, listener) => { win.on(event as "closed", listener); },
|
||||
executeJavaScript: (script) => win.webContents.executeJavaScript(script, true)
|
||||
};
|
||||
return dialogWindow;
|
||||
},
|
||||
dipToScreenPoint: (point) => screen.dipToScreenPoint(point),
|
||||
screenToDipPoint: (point) => screen.screenToDipPoint(point),
|
||||
loadAiConfig: () => {
|
||||
const ai = loadSettings().aiConfig;
|
||||
return { baseUrl: ai.baseUrl ?? "", apiKey: ai.apiKey ?? "", model: ai.model ?? "", visionModel: ai.visionModel ?? "" };
|
||||
}
|
||||
});
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
if (process.env.RPA_ENABLE_GPU !== "1") app.disableHardwareAcceleration();
|
||||
@ -117,7 +142,7 @@ function registerIpc(): void {
|
||||
});
|
||||
type BrowserPickOptions = {
|
||||
url?: string;
|
||||
browser?: import("./browser/external").ExternalBrowserOptions;
|
||||
browser?: import("@rpa/core").ExternalBrowserOptions;
|
||||
};
|
||||
|
||||
ipcMain.handle("browser:pick-element", async (_event, options: BrowserPickOptions = {}) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user