- 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 绿)
321 lines
13 KiB
TypeScript
321 lines
13 KiB
TypeScript
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();
|
||
});
|
||
}
|
||
|
||
|