PRA-only-demo/src/main/index.ts
yu f95a24458e 新增任务分享/导入功能并修复只读文件保存 EPERM
- flows: writeFlowFile 覆盖写加固(清只读属性->删除后 rename->覆盖写),
  外部复制导入的只读任务文件此前导致 flows:save EPERM 崩溃
- flows: exportFlowToPath/importFlowFromPath, 分享文件为单个 FlowDefinition
  JSON(.rpaflow.json); 导入校验 schemaVersion/nodes/edges, id 冲突时生成
  imported_ 前缀新 id 绝不覆盖既有任务
- IPC: flows:export 弹保存对话框 / flows:import 弹多选打开对话框,
  失败文件逐个汇报不阻塞其余
- preload: flows.export/flows.import; shared: FlowsImportResult 类型
- TasksPage: 头部「导入任务」按钮 + 每行「分享」按钮; App 接线与 toast
- 测试: 导出/导入往返、id 冲突、非法文件原因、只读覆盖共 4 例(7/7 绿)
2026-11-04 17:31:44 +08:00

321 lines
13 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, 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) });
// 宿主能力桥(决策点 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();
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();
});
}