PRA-only-demo/src/main/index.ts
yu 1a20dd72d1 feat: 视觉能力去 Electron 化,经 HostBridge.screen 注入(迁移阶段1-A9)
- HostBridge 新增可选 screen 服务(listDisplays/primaryDisplay/dipToScreenPoint/
  displayMatching/captureDisplay),vision 3 处、cv-vision 2 处动态 import(electron)
  全部改为 requireScreenService();未注入时抛稳定的 host_unavailable
- demo 宿主 src/main/index.ts 用 desktopCapturer/screen 实现 screen 服务
- check-deps.mjs 升级:新增动态 import()/require() 扫描(旧正则实测 0 命中的盲区),
  合成用例验证命中,升级后真实源码 0 违规
- 包依赖闭包:补声明 @cantoo/pdf-lib、@napi-rs/canvas、pdfjs-dist
- cv-vision 测试从 vi.mock(electron) 改为宿主桥注入,11/11 通过
2026-11-06 10:53:42 +08:00

353 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, screen, shell } from "electron";
import path from "node:path";
import type { AppInfo, AppSettings, FlowDefinition, FlowSaveOptions, FlowSaveResult, FlowSummary, FlowsImportResult } from "../shared/types";
import {
BrowserSession,
externalBrowserSession,
hasBrowserSession,
latestExternalSession,
setBrowserElementWaitMs
} from "./browser/session";
import "./capabilities";
import { catalogPath, loadCatalog } from "./catalog";
import { createDemoFlow, createExampleFlows, createFlow, deleteFlow, exportFlowToPath, getFlow, importFlowFromPath, listFlows, saveFlow, SHARE_EXTENSION } from "./flows";
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";
import { loadSettings, updateSettings } from "./settings";
import { closeLocalVisionWorkers, defaultUiModelPath } from "./local-vision/ui-detect-service";
import { pickDesktopElement, listVisibleWindows, pickScreenRegion } from "./desktop-pick";
import { detectVisionLabTarget, listVisionLabSources } from "./vision-lab";
import { logMain } from "./logger";
// 子流程仓库在宿主(flows.ts),包内能力经 host-bridge 取用(阶段 2 正式化 HostBridge)
setSubflowSource({ listFlows: async () => listFlows(), getFlow: async (id) => getFlow(id) });
// 宿主屏幕服务(迁移阶段1-A9):视觉能力经宿主桥拿 desktopCapturer/screen,Core 零 Electron 依赖
const hostScreenService = {
listDisplays: () =>
screen.getAllDisplays().map((display) => ({
id: String(display.id),
bounds: display.bounds,
scaleFactor: display.scaleFactor || 1
})),
primaryDisplay: () => {
const display = screen.getPrimaryDisplay();
return { id: String(display.id), bounds: display.bounds, scaleFactor: display.scaleFactor || 1 };
},
dipToScreenPoint: (point: { x: number; y: number }) => screen.dipToScreenPoint(point),
displayMatching: (rect: { x: number; y: number; width: number; height: number }) => {
const display = screen.getDisplayMatching(rect);
return { id: String(display.id), bounds: display.bounds, scaleFactor: display.scaleFactor || 1 };
},
captureDisplay: async (displayId: string, wantWidth: number, wantHeight: number): Promise<Uint8Array> => {
const sources = await desktopCapturer.getSources({
types: ["screen"],
thumbnailSize: { width: wantWidth, height: wantHeight }
});
const source =
sources.find((item) => item.display_id === displayId) ||
sources.find((item) => item.thumbnail.getSize().width === wantWidth) ||
sources[0];
if (!source || source.thumbnail.isEmpty()) throw new Error(`无法获取显示器 ${displayId} 的屏幕截图`);
return source.thumbnail.toPNG();
}
};
// 宿主能力桥(决策点 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 ?? "" };
},
screen: hostScreenService
});
let mainWindow: BrowserWindow | null = null;
if (process.env.RPA_ENABLE_GPU !== "1") app.disableHardwareAcceleration();
process.on("uncaughtException", (error) => logMain("uncaughtException", error.stack || error.message));
process.on("unhandledRejection", (reason) => logMain("unhandledRejection", reason instanceof Error ? reason.stack || reason.message : String(reason)));
function createWindow(): void {
const e2e = process.env.RPA_E2E === "1";
logMain("createWindow", { packaged: app.isPackaged, appPath: app.getAppPath(), userData: app.getPath("userData") });
mainWindow = new BrowserWindow({
width: 1440,
height: 900,
minWidth: 1200,
minHeight: 760,
show: false,
backgroundColor: "#E7EDF3",
title: "RPA Studio",
titleBarStyle: "hidden",
titleBarOverlay: {
color: "#FFFFFF",
symbolColor: "#4E5A6B",
height: 58
},
webPreferences: {
preload: path.join(__dirname, "../preload/index.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
});
let shown = false;
const showWindow = (source: string) => {
if (e2e || shown || !mainWindow || mainWindow.isDestroyed()) return;
shown = true;
logMain("showWindow", source);
mainWindow.show();
mainWindow.focus();
};
const showFallback = setTimeout(() => showWindow("fallback-timeout"), 5000);
mainWindow.on("ready-to-show", () => { clearTimeout(showFallback); showWindow("ready-to-show"); });
mainWindow.on("unresponsive", () => logMain("window-unresponsive"));
mainWindow.on("responsive", () => logMain("window-responsive"));
mainWindow.on("closed", () => {
mainWindow = null;
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:/i.test(url)) void shell.openExternal(url);
return { action: "deny" };
});
mainWindow.webContents.on("did-fail-load", (_event, code, description, url) => {
logMain("did-fail-load", { code, description, url });
showWindow("did-fail-load");
});
mainWindow.webContents.on("render-process-gone", (_event, details) => logMain("render-process-gone", details));
mainWindow.webContents.on("console-message", (_event, level, message, line, sourceId) => {
if (level >= 2) logMain("renderer-console", { level, message, line, sourceId });
});
const devUrl = process.env.ELECTRON_RENDERER_URL;
if (devUrl) {
void mainWindow.loadURL(devUrl);
} else {
void mainWindow.loadFile(path.join(__dirname, "../renderer/index.html"));
}
}
function registerIpc(): void {
ipcMain.handle("app:info", (): AppInfo => ({
version: app.getVersion(),
electron: process.versions.electron,
chrome: process.versions.chrome,
node: process.versions.node,
userData: app.getPath("userData"),
catalogPath: catalogPath(),
packaged: app.isPackaged,
fixtureUrl: fixtureUrl()
}));
ipcMain.handle("catalog:get", () => loadCatalog());
ipcMain.handle("run:start", (event, flow: FlowDefinition) => startRun(flow, event.sender));
setMainWindowGetter(() => mainWindow);
registerOverlayIpc(() => mainWindow, () => { stopRun(); });
ipcMain.handle("run:stop", (_event, runId?: string) => stopRun(runId));
ipcMain.handle("run:capabilities", () => ({ count: capabilityCount(), ids: capabilityIds() }));
ipcMain.handle("settings:get", () => loadSettings());
ipcMain.handle("settings:update", (_event, patch: Partial<AppSettings> = {}) => {
const settings = updateSettings(patch);
setBrowserElementWaitMs(settings.browserElementWaitMs);
return settings;
});
type BrowserPickOptions = {
url?: string;
browser?: import("@rpa/core").ExternalBrowserOptions;
};
ipcMain.handle("browser:pick-element", async (_event, options: BrowserPickOptions = {}) => {
const url = options.url?.trim();
const browserOptions = options.browser;
let session;
if (browserOptions) {
session = await externalBrowserSession(
browserOptions.mode === "launch" && url ? { ...browserOptions, initialUrl: url } : browserOptions
);
} else {
session = latestExternalSession();
}
if (!session) {
if (!hasBrowserSession() && !url) {
throw new Error("请先配置或运行「打开网页」,再拾取元素");
}
session = await BrowserSession.create();
}
const currentUrl = await session.currentUrl();
if (url && currentUrl !== url) {
await session.navigate(url);
}
return session.pickElement();
});
ipcMain.handle("desktop:pick-element", () => pickDesktopElement());
ipcMain.handle("desktop:pick-window-list", () => listVisibleWindows());
ipcMain.handle("desktop:pick-region", () => pickScreenRegion());
ipcMain.handle("vision-lab:sources", () => listVisionLabSources());
ipcMain.handle("vision-lab:detect", (_event, request: Parameters<typeof detectVisionLabTarget>[0]) => detectVisionLabTarget(request));
ipcMain.handle("vision-lab:default-model", () => defaultUiModelPath());
ipcMain.handle("flows:list", () => listFlows());
ipcMain.handle("flows:create", (_event, name?: string) => {
const flow = createFlow(name);
return saveFlow(flow);
});
ipcMain.handle("flows:create-demo", () => {
const url = fixtureUrl();
return saveFlow(url ? createDemoFlow(url) : createFlow("未命名任务"));
});
ipcMain.handle("flows:create-examples", () => {
const url = fixtureUrl();
if (!url) throw new Error("本地演示站未启动");
for (const flow of createExampleFlows(url)) saveFlow(flow);
return listFlows();
});
ipcMain.handle("flows:get", (_event, id: string) => {
return getFlow(id);
});
ipcMain.handle("flows:save", (_event, flow: FlowDefinition, opts?: FlowSaveOptions): FlowSaveResult => {
if (!opts?.force && opts?.expectedUpdatedAt) {
let disk: FlowDefinition | null = null;
try {
disk = getFlow(flow.id);
} catch {
disk = null; // 磁盘文件不存在或损坏时视为全新保存
}
if (disk && disk.meta?.updatedAt && disk.meta.updatedAt !== opts.expectedUpdatedAt) {
// 内容一致(仅 updatedAt 差异,如并发/重复保存)不视为冲突
const normalize = (item: FlowDefinition) => JSON.stringify({ ...item, meta: undefined });
if (normalize(disk) !== normalize(flow)) {
return { conflict: true, diskFlow: disk };
}
}
}
return { conflict: false, flow: saveFlow(flow) };
});
ipcMain.handle("flows:delete", (_event, id: string) => {
deleteFlow(id);
return true;
});
ipcMain.handle("flows:export", async (_event, id: string): Promise<string | null> => {
if (!mainWindow) return null;
const flow = getFlow(id);
const safeName = (flow.name || "任务").replace(/[\\/:*?"<>|]/g, "_").slice(0, 60);
const result = await dialog.showSaveDialog(mainWindow, {
title: "分享任务",
defaultPath: `${safeName}.${SHARE_EXTENSION}.json`,
filters: [
{ name: "RPA 任务分享文件", extensions: [SHARE_EXTENSION, "json"] },
{ name: "所有文件", extensions: ["*"] }
]
});
if (result.canceled || !result.filePath) return null;
return exportFlowToPath(id, result.filePath);
});
ipcMain.handle("flows:import", async (): Promise<FlowsImportResult> => {
if (!mainWindow) return { imported: [], failed: [] };
const result = await dialog.showOpenDialog(mainWindow, {
title: "导入任务",
properties: ["openFile", "multiSelections"],
filters: [
{ name: "RPA 任务分享文件", extensions: [SHARE_EXTENSION, "json"] },
{ name: "所有文件", extensions: ["*"] }
]
});
if (result.canceled || !result.filePaths.length) return { imported: [], failed: [] };
const imported: FlowSummary[] = [];
const failed: Array<{ file: string; reason: string }> = [];
for (const filePath of result.filePaths) {
try {
const flow = importFlowFromPath(filePath);
imported.push(listFlows().find((item) => item.id === flow.id) || {
id: flow.id,
name: flow.name,
updatedAt: flow.meta?.updatedAt || new Date().toISOString(),
nodeCount: flow.nodes.length
});
} catch (error) {
failed.push({ file: path.basename(filePath), reason: error instanceof Error ? error.message : String(error) });
}
}
return { imported, failed };
});
ipcMain.handle("dialog:open", async (_event, options: { title?: string; filters?: Electron.FileFilter[]; directories?: boolean }) => {
if (!mainWindow) return null;
const result = await dialog.showOpenDialog(mainWindow, {
title: options?.title || "选择文件",
properties: options?.directories ? ["openDirectory"] : ["openFile"],
filters: options?.filters
});
return result.canceled ? null : result.filePaths[0] || null;
});
ipcMain.handle("dialog:save", async (_event, options: { title?: string; defaultPath?: string; filters?: Electron.FileFilter[] }) => {
if (!mainWindow) return null;
const result = await dialog.showSaveDialog(mainWindow, {
title: options?.title || "保存文件",
defaultPath: options?.defaultPath,
filters: options?.filters
});
return result.canceled ? null : result.filePath || null;
});
}
// 打包冒烟/自动化测试需要与用户日常实例隔离;正常启动仍然保持单实例。
const gotLock = process.env.RPA_SKIP_SINGLE_INSTANCE === "1" ? true : app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on("second-instance", () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
});
app.whenReady().then(async () => {
logMain("app-ready", { electron: process.versions.electron, chrome: process.versions.chrome, node: process.versions.node });
setBrowserElementWaitMs(loadSettings().browserElementWaitMs);
try {
await startFixtureServer(Number(process.env.RPA_FIXTURE_PORT || 17863));
} catch (error) {
console.warn("local fixture server unavailable", error);
logMain("fixture-server-failed", error instanceof Error ? error.stack || error.message : String(error));
}
registerIpc();
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
app.on("before-quit", () => {
void closeBrowserSession();
void stopFixtureServer();
closeLocalVisionWorkers();
});
}