import { app } from "electron"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { FlowDefinition, FlowEdge, FlowNode, FlowSummary } from "../shared/types"; const ID_PATTERN = /^[A-Za-z0-9_-]{3,80}$/; function dir(): string { const value = path.join(app.getPath("userData"), "flows"); fs.mkdirSync(value, { recursive: true }); return value; } function fileOf(id: string): string { if (!ID_PATTERN.test(id)) throw new Error(`invalid flow id: ${id}`); return path.join(dir(), `${id}.json`); } interface PersistedRunSummary { flowId: string; status: "succeeded" | "failed" | "cancelled"; finishedAt?: string; startedAt?: string; durationMs?: number; error?: { reason?: string } | string; } function latestRuns(): Map { const result = new Map(); const runsDir = path.join(app.getPath("userData"), "runs"); if (!fs.existsSync(runsDir)) return result; for (const entry of fs.readdirSync(runsDir)) { const file = path.join(runsDir, entry, "run.json"); if (!fs.existsSync(file)) continue; try { const run = JSON.parse(fs.readFileSync(file, "utf8")) as PersistedRunSummary; if (!run.flowId || !run.status) continue; const current = result.get(run.flowId); const time = run.finishedAt || run.startedAt || ""; const currentTime = current?.finishedAt || current?.startedAt || ""; if (!current || time > currentTime) result.set(run.flowId, run); } catch { // 损坏的单次运行记录不应阻塞任务列表 } } return result; } export function createFlow(name = "未命名流程"): FlowDefinition { const now = new Date().toISOString(); const id = `flow_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; return { schemaVersion: 1, id, name, description: "", enabled: true, trigger: { type: "manual", label: "手动运行" }, variables: [], nodes: [ { id: "node_start", componentId: "Trigger.Manual", position: { x: 160, y: 120 }, values: { entryName: "立即执行" }, options: { timeoutMs: 30000 } } ], edges: [], meta: { createdAt: now, updatedAt: now, appVersion: app.getVersion() } }; } /** 演示流程浏览器类型:优先 Chrome,未安装时回退 Edge;两者都缺失则保持 chrome 由能力层给出友好错误 */ function demoBrowserType(): string { // RPA_DEMO_BROWSER 可强制指定(e2e/诊断用) const forced = String(process.env.RPA_DEMO_BROWSER || "").trim().toLowerCase(); if (forced === "chrome" || forced === "edge") return forced; const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"); const chromePaths = [ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe") ]; if (chromePaths.some((p) => fs.existsSync(p))) return "chrome"; const edgePaths = [ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe" ]; if (edgePaths.some((p) => fs.existsSync(p))) return "edge"; return "chrome"; } export function createDemoFlow(url: string): FlowDefinition { const flow = createFlow("本地网页订单演示"); flow.description = "打开本地演示站,填写客户和金额,提交订单并读取结果。"; flow.nodes = [ flow.nodes[0], { id: "node_open", componentId: "WebBrowser_CreateV3", position: { x: 180, y: 250 }, values: { type: demoBrowserType(), url, isRemote: "0", openNewTab: "1", waitingload: "1" }, options: { timeoutMs: 30000 } }, { id: "node_name", componentId: "Element_SetValue_V6", position: { x: 180, y: 390 }, values: { uiSelector: "css:#name", designated_info: "0", designated_obj: "", text: "RPA 客户", input_type: "0", isEnter: "0", interval: "0" }, options: { timeoutMs: 10000 } }, { id: "node_amount", componentId: "Element_SetValue_V6", position: { x: 180, y: 530 }, values: { uiSelector: "css:#amount", designated_info: "0", designated_obj: "", text: "128", input_type: "0", isEnter: "0", interval: "0" }, options: { timeoutMs: 10000 } }, { id: "node_submit", componentId: "Element_Click_V6", position: { x: 180, y: 670 }, values: { uiSelector: "css:#submit", designated_info: "0", designated_obj: "", clickway: "0", clickfunc: "0", operation: "1" }, options: { timeoutMs: 10000 } }, { id: "node_result", componentId: "Element_GetText_V5", position: { x: 180, y: 810 }, values: { choose: "0", uiSelector: "css:#result", cvSelector: "", designated_info: "0", designated_obj: "", attr_type: "default", attr_name: "" }, options: { timeoutMs: 10000, outputVar: "订单结果" } } ]; flow.edges = [ { id: "edge_start_open", source: "node_start", target: "node_open" }, { id: "edge_open_name", source: "node_open", target: "node_name" }, { id: "edge_name_amount", source: "node_name", target: "node_amount" }, { id: "edge_amount_submit", source: "node_amount", target: "node_submit" }, { id: "edge_submit_result", source: "node_submit", target: "node_result" } ]; return flow; } function exampleFlow(id: string, name: string, description: string): FlowDefinition { const flow = createFlow(name); flow.id = id; flow.description = description; flow.nodes = []; flow.edges = []; return flow; } function exampleNode(id: string, componentId: string, y: number, values: Record = {}, outputVar?: string): FlowNode { return { id, componentId, position: { x: 220, y }, values, options: { timeoutMs: 30000, retryCount: 0, retryIntervalMs: 1000, skipOnError: false, logException: true, outputVar }, enabled: true }; } function chain(nodes: FlowNode[]): FlowEdge[] { return nodes.slice(0, -1).map((node, index) => ({ id: `edge_${node.id}_${nodes[index + 1].id}`, source: node.id, target: nodes[index + 1].id })); } export function createExampleFlows(url: string): FlowDefinition[] { const browser = createDemoFlow(url); browser.id = "example_browser_order"; browser.name = "示例 1 · 本地网页订单"; const file = exampleFlow("example_file_text", "示例 2 · 文件与文本处理", "在临时目录写入文本、读取、转换为大写并写入结果文件。"); file.nodes = [ exampleNode("file_start", "Trigger.Manual", 80, { entryName: "立即执行" }), exampleNode("file_temp", "System_GetTempPath", 210, {}, "tempPath"), exampleNode("file_write", "File_Write", 340, { path: "${tempPath}\\RPA-Studio-Demo\\input.txt", content: "hello rpa studio", encode: "utf-8", append: "False" }), exampleNode("file_read", "File_Read", 470, { path: "${tempPath}\\RPA-Studio-Demo\\input.txt", encode: "utf-8" }, "sourceText"), exampleNode("file_upper", "StrUtil_UpperCase", 600, { string: "${sourceText}" }, "upperText"), exampleNode("file_result", "File_Write", 730, { path: "${tempPath}\\RPA-Studio-Demo\\result.txt", content: "${upperText}", encode: "utf-8", append: "False" }), exampleNode("file_log", "Basic_DebugOutput", 860, { msg: "处理完成:${upperText}" }) ]; file.edges = chain(file.nodes); const excel = exampleFlow("example_excel_orders", "示例 3 · Excel 订单读写", "在临时目录创建 Excel,写入订单数据并读取验证。"); excel.nodes = [ exampleNode("excel_start", "Trigger.Manual", 80, { entryName: "立即执行" }), exampleNode("excel_temp", "System_GetTempPath", 210, {}, "tempPath"), exampleNode("excel_new", "Excel_Cs_NewV5", 340, { path: "${tempPath}\\RPA-Studio-Demo", name: "orders.xlsx", override: "1", show: "0" }, "workbook"), exampleNode("excel_write", "Excel_Cs_WriteRange", 470, { workbook: "${workbook}", sheet: "Sheet1", row: "1", column: "A", contents: '[["客户","金额","状态"],["张三",128,"已创建"],["李四",256,"已创建"]]', save: "1" }), exampleNode("excel_read", "Excel_Cs_ReadRangeV2", 600, { workbook: "${workbook}", sheet: "Sheet1", range: "A1:C3", decimal: "0" }, "excelData"), exampleNode("excel_log", "Basic_DebugOutput", 730, { msg: "Excel内容:${excelData}" }) ]; excel.edges = chain(excel.nodes); const clipboard = exampleFlow("example_clipboard", "示例 4 · 剪贴板往返", "写入并读取 Windows 剪贴板文本。"); clipboard.nodes = [ exampleNode("clip_start", "Trigger.Manual", 80, { entryName: "立即执行" }), exampleNode("clip_set", "Clipboard_SetText", 230, { text: "RPA Studio 剪贴板测试" }), exampleNode("clip_get", "Clipboard_GetText", 380, {}, "clipboardText"), exampleNode("clip_log", "Basic_DebugOutput", 530, { msg: "剪贴板内容:${clipboardText}" }) ]; clipboard.edges = chain(clipboard.nodes); const word = exampleFlow("example_word_document", "示例 5 · Word 文档生成", "在临时目录创建 Word 文档、写入文本并读取验证,需要本机安装 Word。"); word.nodes = [ exampleNode("word_start", "Trigger.Manual", 80, { entryName: "立即执行" }), exampleNode("word_temp", "System_GetTempPath", 200, {}, "tempPath"), exampleNode("word_folder", "File_MakeDir", 320, { folder_path: "${tempPath}\\RPA-Studio-Demo" }), exampleNode("word_new", "Word_New_V1", 440, { filePath: "${tempPath}\\RPA-Studio-Demo", name: "demo.docx", visible: "False" }, "wordDoc"), exampleNode("word_write", "Word_Write", 560, { objWord: "${wordDoc}", text: "RPA Studio Word 自动化测试" }), exampleNode("word_read", "Word_ReadAll", 680, { objWord: "${wordDoc}" }, "wordText"), exampleNode("word_log", "Basic_DebugOutput", 800, { msg: "Word内容:${wordText}" }) ]; word.edges = chain(word.nodes); const error = exampleFlow("example_error_route", "示例 6 · 异常分支", "故意读取不存在文件,验证右侧异常出口和错误变量。"); error.nodes = [ exampleNode("error_start", "Trigger.Manual", 80, { entryName: "立即执行" }), { ...exampleNode("error_fail", "File_Read", 230, { path: "Z:\\RPA-DEMO-NOT-FOUND.txt", encode: "utf-8" }), options: { timeoutMs: 5000, retryCount: 0, retryIntervalMs: 1000, skipOnError: false, logException: true, errorRouteEnabled: true } }, exampleNode("error_normal", "Basic_DebugOutput", 390, { msg: "不应执行到正常出口" }), { ...exampleNode("error_recover", "Basic_DebugOutput", 230, { msg: "异常已捕获:${error.reason}" }), position: { x: 560, y: 230 } } ]; error.edges = [ { id: "edge_error_start", source: "error_start", target: "error_fail" }, { id: "edge_error_normal", source: "error_fail", target: "error_normal" }, { id: "edge_error_recover", source: "error_fail", sourceHandle: "error", target: "error_recover" } ]; return [browser, file, excel, clipboard, word, error]; } export function listFlows(): FlowSummary[] { const base = dir(); const rows: FlowSummary[] = []; const runs = latestRuns(); for (const entry of fs.readdirSync(base)) { if (!entry.endsWith(".json")) continue; if (/-pre-|\\.bak/i.test(entry)) continue; try { const flow = JSON.parse(fs.readFileSync(path.join(base, entry), "utf8")) as FlowDefinition; if (!flow?.id || !Array.isArray(flow.nodes)) continue; const lastRun = runs.get(flow.id); rows.push({ id: flow.id, name: flow.name || "未命名流程", updatedAt: flow.meta?.updatedAt || new Date(0).toISOString(), nodeCount: flow.nodes.length, enabled: flow.enabled !== false, triggerLabel: flow.trigger?.label || "手动运行", lastRunAt: lastRun?.finishedAt || lastRun?.startedAt, lastRunStatus: lastRun?.status || "never", lastRunDurationMs: lastRun?.durationMs, lastRunError: typeof lastRun?.error === "string" ? lastRun.error : lastRun?.error?.reason }); } catch { // 忽略损坏文件,避免一个坏流程阻塞列表 } } return rows.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } export function getFlow(id: string): FlowDefinition { const raw = fs.readFileSync(fileOf(id), "utf8"); const flow = JSON.parse(raw) as FlowDefinition; if (!flow || flow.schemaVersion !== 1 || !Array.isArray(flow.nodes) || !Array.isArray(flow.edges)) { throw new Error(`invalid flow file: ${id}`); } return flow; } export function saveFlow(input: FlowDefinition): FlowDefinition { if (!input || input.schemaVersion !== 1) throw new Error("invalid flow schemaVersion"); if (!Array.isArray(input.nodes) || !Array.isArray(input.edges)) throw new Error("invalid flow structure"); if (input.nodes.length > 2000) throw new Error("flow has too many nodes"); const now = new Date().toISOString(); const flow: FlowDefinition = { ...input, meta: { createdAt: input.meta?.createdAt || now, updatedAt: now, appVersion: app.getVersion() } }; writeFlowFile(fileOf(flow.id), flow); return flow; } /** 覆盖写任务文件。Windows 上目标文件带只读属性(外部导入/复制来的文件常见)或被占用时, * rename 会抛 EPERM——这里依次尝试:清除只读属性后 rename → 删除后 rename → 直接覆盖写。 */ function writeFlowFile(target: string, flow: FlowDefinition): void { const temp = `${target}.tmp`; fs.writeFileSync(temp, JSON.stringify(flow, null, 2), "utf8"); const retryable = (error: unknown): boolean => { const code = (error as NodeJS.ErrnoException)?.code; return code === "EPERM" || code === "EACCES" || code === "EBUSY"; }; const cleanupTemp = () => { try { fs.unlinkSync(temp); } catch { // 清理失败不影响主流程 } }; try { fs.renameSync(temp, target); return; } catch (error) { if (!retryable(error)) { cleanupTemp(); throw error; } } try { fs.chmodSync(target, 0o666); } catch { // 清只读属性失败不阻塞后续删除/覆盖尝试 } try { fs.unlinkSync(target); } catch { // 目标可能被其他进程占用,删除失败继续走覆盖写 } try { fs.renameSync(temp, target); return; } catch { // rename 仍失败(目标被锁定),尝试直接覆盖 } try { fs.copyFileSync(temp, target); cleanupTemp(); } catch (error) { cleanupTemp(); throw new Error( `任务文件被占用或无写入权限(${path.basename(target)})。请关闭可能打开该文件的编辑器/网盘同步后再试。`, { cause: error } ); } } export function deleteFlow(id: string): void { const target = fileOf(id); if (fs.existsSync(target)) fs.unlinkSync(target); } /** 任务分享文件扩展名:单文件就是一个 FlowDefinition JSON,便于直接审阅/版本管理 */ const SHARE_EXTENSION = "rpaflow"; function normalizeImportedName(raw: unknown): string { const name = String(raw ?? "").trim(); return name || "导入的任务"; } /** 校验并归一化外部任务文件内容,不合法时抛出带原因的 Error */ function parseSharedFlow(raw: string, source: string): FlowDefinition { let data: unknown; try { data = JSON.parse(raw); } catch { throw new Error(`${source}:不是有效的 JSON 文件`); } const flow = data as FlowDefinition; if (!flow || typeof flow !== "object") throw new Error(`${source}:文件内容不是任务结构`); if (flow.schemaVersion !== 1) throw new Error(`${source}:schemaVersion 不是 1(版本不兼容)`); if (!Array.isArray(flow.nodes) || !Array.isArray(flow.edges)) { throw new Error(`${source}:缺少 nodes/edges 结构`); } if (flow.nodes.length > 2000) throw new Error(`${source}:节点数超过上限 2000`); return flow; } /** 从分享文件导入任务:id 冲突时生成新 id,避免覆盖既有任务 */ export function importFlowFromPath(filePath: string): FlowDefinition { const raw = fs.readFileSync(filePath, "utf8"); const flow = parseSharedFlow(raw, path.basename(filePath)); const now = new Date().toISOString(); let id = typeof flow.id === "string" && ID_PATTERN.test(flow.id) ? flow.id : ""; if (!id || fs.existsSync(fileOf(id))) { // 冲突/缺 id:基于名称生成可读 id,追加随机尾巴保证唯一 const seed = normalizeImportedName(flow.name) .replace(/[^A-Za-z0-9]+/g, "_") .replace(/^_+|_+$/g, "") .slice(0, 40) .toLowerCase(); const base = `imported_${seed || "task"}`; id = `${base}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; while (!ID_PATTERN.test(id)) id = `imported_task_${Date.now().toString(36)}`; } const imported: FlowDefinition = { ...flow, id, name: normalizeImportedName(flow.name), meta: { ...flow.meta, createdAt: flow.meta?.createdAt || now } }; return saveFlow(imported); } /** 导出任务到指定路径(main 侧先用 dialog 拿到路径) */ export function exportFlowToPath(id: string, targetPath: string): string { const flow = getFlow(id); fs.writeFileSync(targetPath, JSON.stringify(flow, null, 2), "utf8"); return targetPath; } export { SHARE_EXTENSION };