diff --git a/.workbuddy/memory/2026-11-04.md b/.workbuddy/memory/2026-11-04.md new file mode 100644 index 0000000..0991d60 --- /dev/null +++ b/.workbuddy/memory/2026-11-04.md @@ -0,0 +1,21 @@ +# 2026-11-04 + +## 合并 origin/main(psText 非 ASCII 修复) +- 本地重构(rpa-core + driver 抽象)与远端 2 个旧路径修复提交冲突,3 文件已解:取本地 driver 骨架,把 psText 修复语义移植到新位置(powershell.ts 新增导出、windows-driver.ts 委托、desktop.ts 保持删除、compat-audit.test 迁入 rpa-core)。合并提交 `3a9a8b6`,测试全绿。 + +## 排查+修复外部浏览器 CDP 无响应(重要根因) +- 现象:e2e / 应用中 Element_SetValue_V6 在外部 Chrome 的 input 上 10s 超时;裸脚本、裸 Electron 复现均正常;换 Edge、退出 Clash Verge(含 clash-verge-service)均无效——Clash 不是元凶(已道歉)。 +- 取证路径:TCP 层应用↔Chrome 连接真实 ESTABLISHED(重试泄漏 11 条 socket);自写 TCP MITM 代理 + external.ts 加 RPA_EXT_PORT/RPA_EXT_LAUNCH_PORT 诊断开关(启停端口分离 + wsURL 改写)抓到决定性证据:**应用每条 ws 握手后 0 字节发出**,且握手请求带 permessage-deflate。 +- 根因:ws 打进主进程 bundle 时,可选依赖 bufferutil 的打包解析产物破坏 mask fallback;permessage-deflate 异步路径把 `bufferUtil.mask is not a function` 静默吞掉 → 帧 0 字节无报错。 +- 修复(提交 `22a7ee1`): + - electron.vite.config:externalizeDepsPlugin({ include: ["ws"] }),运行时 require + - package.json:ws 提升到根依赖(electron-builder 收集进 asar,21595 条 node_modules) + - transport.ts:显式 perMessageDeflate: false(双保险) + - package.mjs verifyLaunch:剥离 ELECTRON_RUN_AS_NODE / NODE_OPTIONS(WorkBuddy 沙箱 shell 带 ELECTRON_RUN_AS_NODE=1,曾把打包应用带进纯 Node 模式 → "bad option" 退出码 9 假故障) + - flows.ts:RPA_DEMO_BROWSER 强制指定浏览器 + Chrome 缺装回退 Edge +- 验证:349 测试 + e2e + portable/NSIS 双冒烟全绿;release/dist 产出免安装目录、zip、安装包、SHA256SUMS。 +- 教训:electron-builder 的 asar list 输出用反斜杠路径,grep 判缺失时别用 `/`;mouse/keyboard 真实冒烟测试运行期间桌面不能有人操作。 + +## 其他 +- 用户确认删除了 compat-audit 测试产物 .compat-report.txt(vitest 跑 compat-audit 会重新生成,未跟踪)。 +- 用户询问改造进度:阶段 0~5 已全部完成,只剩阶段 6(迁入 Agent-Employee + Bun 冒烟 + 可选 PowerShell worker)。 diff --git a/src/main/flows.test.ts b/src/main/flows.test.ts index 323e4bb..40e3793 100644 --- a/src/main/flows.test.ts +++ b/src/main/flows.test.ts @@ -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); + }); +}); diff --git a/src/main/flows.ts b/src/main/flows.ts index c673f3b..9cae214 100644 --- a/src/main/flows.ts +++ b/src/main/flows.ts @@ -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 }; + diff --git a/src/main/index.ts b/src/main/index.ts index 0d3cd92..ed0ff26 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 => { + 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 => { + 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; diff --git a/src/preload/index.ts b/src/preload/index.ts index 6878953..ac8aeaa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from "electron"; -import type { AppInfo, AppSettings, Catalog, FlowDefinition, FlowSaveOptions, FlowSaveResult, FlowSummary, RunEvent, VisionLabDetectRequest, VisionLabDetection, VisionLabSource } from "../shared/types"; +import type { AppInfo, AppSettings, Catalog, FlowDefinition, FlowSaveOptions, FlowSaveResult, FlowSummary, FlowsImportResult, RunEvent, VisionLabDetectRequest, VisionLabDetection, VisionLabSource } from "../shared/types"; const api = { app: { @@ -20,7 +20,11 @@ const api = { createExamples: (): Promise => ipcRenderer.invoke("flows:create-examples"), get: (id: string): Promise => ipcRenderer.invoke("flows:get", id), save: (flow: FlowDefinition, opts?: FlowSaveOptions): Promise => ipcRenderer.invoke("flows:save", flow, opts), - delete: (id: string): Promise => ipcRenderer.invoke("flows:delete", id) + delete: (id: string): Promise => ipcRenderer.invoke("flows:delete", id), + /** 分享任务:弹出保存对话框写入分享文件,取消返回 null,成功返回写入路径 */ + export: (id: string): Promise => ipcRenderer.invoke("flows:export", id), + /** 导入任务:弹出打开对话框(可多选),返回成功摘要与失败明细 */ + import: (): Promise => ipcRenderer.invoke("flows:import") }, dialog: { open: (options?: { title?: string; filters?: Array<{ name: string; extensions: string[] }>; directories?: boolean }): Promise => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0253066..164fe78 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -157,6 +157,32 @@ export function App() { } }; + const exportTask = async (id: string) => { + try { + const target = await window.rpa.flows.export(id); + if (target) showToast(`已分享到 ${target}`); + } catch (exportError) { + showToast(exportError instanceof Error ? exportError.message : String(exportError), "error"); + } + }; + + const importTasks = async () => { + try { + const result = await window.rpa.flows.import(); + if (!result.imported.length && !result.failed.length) return; // 用户取消 + await refreshFlows(); + if (result.imported.length) { + const names = result.imported.map((item) => item.name).join("、"); + showToast(`已导入 ${result.imported.length} 个任务:${names}`); + } + for (const failure of result.failed) { + showToast(`导入失败 ${failure.file}:${failure.reason}`, "error"); + } + } catch (importError) { + showToast(importError instanceof Error ? importError.message : String(importError), "error"); + } + }; + const deleteTask = async (id: string) => { if (flows.length <= 1) { showToast("至少保留一个任务", "error"); @@ -257,6 +283,8 @@ export function App() { flows={flows} onCreate={() => void createTask()} onCreateExamples={() => void createExamples()} + onImport={() => void importTasks()} + onExport={(id) => void exportTask(id)} onOpenEditor={(id) => void openEditor(id)} onRun={(id) => void runTask(id)} onDelete={(id) => void deleteTask(id)} diff --git a/src/renderer/src/pages/TasksPage.tsx b/src/renderer/src/pages/TasksPage.tsx index 548ea72..6afd004 100644 --- a/src/renderer/src/pages/TasksPage.tsx +++ b/src/renderer/src/pages/TasksPage.tsx @@ -12,7 +12,9 @@ import { PackagePlus, RefreshCw, Search, + Share2, Trash2, + Upload, Zap } from "lucide-react"; import type { FlowSummary } from "@shared/types"; @@ -21,6 +23,8 @@ interface TasksPageProps { flows: FlowSummary[]; onCreate: () => void; onCreateExamples: () => void; + onImport: () => void; + onExport: (id: string) => void; onOpenEditor: (id: string) => void; onRun: (id: string) => void; onDelete: (id: string) => void; @@ -45,7 +49,7 @@ function formatDuration(value?: number): string { return `${(value / 1000).toFixed(value < 10000 ? 1 : 0)} s`; } -export function TasksPage({ flows, onCreate, onCreateExamples, onOpenEditor, onRun, onDelete, onRefresh, onToast }: TasksPageProps) { +export function TasksPage({ flows, onCreate, onCreateExamples, onImport, onExport, onOpenEditor, onRun, onDelete, onRefresh, onToast }: TasksPageProps) { const [query, setQuery] = useState(""); const [selectedId, setSelectedId] = useState(flows[0]?.id || null); const [refreshing, setRefreshing] = useState(false); @@ -88,6 +92,9 @@ export function TasksPage({ flows, onCreate, onCreateExamples, onOpenEditor, onR

选择一个任务开始运行或编辑流程;组件库只在进入编辑时参与搭建。

+ @@ -166,6 +173,9 @@ export function TasksPage({ flows, onCreate, onCreateExamples, onOpenEditor, onR + diff --git a/src/shared/types.ts b/src/shared/types.ts index 0c0fdda..b493143 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -33,6 +33,12 @@ export interface FlowSaveOptions { export type FlowSaveResult = | { conflict: false; flow: FlowDefinition } | { conflict: true; diskFlow: FlowDefinition }; + +/** 批量导入任务的结果:成功与失败分开汇报,单文件失败不阻塞其余 */ +export interface FlowsImportResult { + imported: FlowSummary[]; + failed: Array<{ file: string; reason: string }>; +} export interface FlowSummary { id: string; name: string;