新增任务分享/导入功能并修复只读文件保存 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 绿)
This commit is contained in:
yu committed 2026-11-04 17:31:44 +08:00
1 parent 22a7ee1595
commit f95a24458e
8 files changed
+303 -10

No files matched your search

+72 -2
View File
@@ -1,4 +1,5 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm, chmod, writeFile } from "node:fs/promises";
import { writeFileSync, readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -11,7 +12,7 @@ vi.mock("electron", () => ({
}
}));
import { createDemoFlow, createExampleFlows, createFlow, listFlows, saveFlow } from "./flows";
import { createDemoFlow, createExampleFlows, createFlow, exportFlowToPath, importFlowFromPath, listFlows, saveFlow } from "./flows";
describe("flow summaries", () => {
beforeEach(async () => {
@@ -64,3 +65,72 @@ describe("flow summaries", () => {
expect(examples.find((flow) => flow.id === "example_error_route")?.edges.some((edge) => edge.sourceHandle === "error")).toBe(true);
});
});
describe("flow share export/import", () => {
beforeEach(async () => {
electronState.userData = await mkdtemp(path.join(os.tmpdir(), "rpa-flow-share-"));
});
afterEach(async () => {
await rm(electronState.userData, { recursive: true, force: true });
});
it("exports a flow to a share file and re-imports it with a fresh id on conflict", async () => {
const flow = saveFlow(createFlow("密钥读取任务"));
const shareFile = path.join(electronState.userData, "share", "密钥读取任务.rpaflow.json");
await mkdir(path.dirname(shareFile), { recursive: true });
exportFlowToPath(flow.id, shareFile);
expect(JSON.parse(readFileSync(shareFile, "utf8")).id).toBe(flow.id);
// id 冲突:再次导入同一文件应生成新 id,不覆盖原任务
const imported = importFlowFromPath(shareFile);
expect(imported.id).not.toBe(flow.id);
expect(imported.name).toBe("密钥读取任务");
expect(listFlows().filter((item) => item.name === "密钥读取任务")).toHaveLength(2);
});
it("keeps the original id when importing a share file whose id is free", () => {
const shareFile = path.join(electronState.userData, "手工分享.rpaflow.json");
writeFileSync(shareFile, JSON.stringify({
schemaVersion: 1,
id: "manual_task_01",
name: "手工分享的任务",
description: "",
enabled: true,
trigger: { type: "manual", label: "手动运行" },
variables: [],
nodes: [{ id: "node_start", componentId: "Trigger.Manual", position: { x: 160, y: 120 }, values: {}, options: {} }],
edges: [],
meta: { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", appVersion: "0.1.0" }
}), "utf8");
const imported = importFlowFromPath(shareFile);
expect(imported.id).toBe("manual_task_01");
expect(imported.name).toBe("手工分享的任务");
});
it("rejects invalid share files with a readable reason", () => {
const badJson = path.join(electronState.userData, "bad.rpaflow.json");
writeFileSync(badJson, "not json", "utf8");
expect(() => importFlowFromPath(badJson)).toThrow(/不是有效的 JSON/);
const badSchema = path.join(electronState.userData, "schema.rpaflow.json");
writeFileSync(badSchema, JSON.stringify({ schemaVersion: 99, nodes: [], edges: [] }), "utf8");
expect(() => importFlowFromPath(badSchema)).toThrow(/schemaVersion/);
const badStructure = path.join(electronState.userData, "structure.rpaflow.json");
writeFileSync(badStructure, JSON.stringify({ schemaVersion: 1, nodes: "no" }), "utf8");
expect(() => importFlowFromPath(badStructure)).toThrow(/nodes\/edges/);
});
it("overwrites a read-only flow file on save (imported file scenario)", async () => {
const flow = saveFlow(createFlow("只读任务"));
const target = path.join(electronState.userData, "flows", `${flow.id}.json`);
// 模拟外部复制进来带只读属性的导入文件
await chmod(target, 0o444);
expect(() => saveFlow({ ...flow, name: "只读任务-改" })).not.toThrow();
expect(JSON.parse(readFileSync(target, "utf8")).name).toBe("只读任务-改");
await chmod(target, 0o666);
});
});
+114 -3
View File
@@ -242,11 +242,61 @@ export function saveFlow(input: FlowDefinition): FlowDefinition {
appVersion: app.getVersion()
}
};
const target = fileOf(flow.id);
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");
fs.renameSync(temp, target);
return flow;
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 {
@@ -254,3 +304,64 @@ export function deleteFlow(id: string): void {
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 };
+45 -2
View File
@@ -1,6 +1,6 @@
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 type { AppInfo, AppSettings, FlowDefinition, FlowSaveOptions, FlowSaveResult, FlowSummary, FlowsImportResult } from "../shared/types";
import {
BrowserSession,
externalBrowserSession,
@@ -10,7 +10,7 @@ import {
} from "./browser/session";
import "./capabilities";
import { catalogPath, loadCatalog } from "./catalog";
import { createDemoFlow, createExampleFlows, createFlow, deleteFlow, getFlow, listFlows, saveFlow } from "./flows";
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";
@@ -214,6 +214,49 @@ function registerIpc(): void {
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;