diff --git a/scripts/desktop-e2e.mjs b/scripts/desktop-e2e.mjs new file mode 100644 index 0000000..e305555 --- /dev/null +++ b/scripts/desktop-e2e.mjs @@ -0,0 +1,160 @@ +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const root = process.cwd(); +const debugPort = 9233; +const profile = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-studio-desktop-e2e-")); +const workDir = path.join(os.tmpdir(), "rpa-desktop-test"); +const noteFile = path.join(workDir, "rpa-desktop-test.txt"); +const titleMarker = "rpa-desktop-test"; +const typedText = "RPA-Keyboard-Mouse-Test-OK"; +const uiaText = "UIA-SetValue-写入验证"; + +const candidates = [process.env.RPA_ELECTRON_DIST, "C:/electron-rpa/dist", path.join(root, "node_modules", "electron", "dist")].filter(Boolean); +const dist = candidates.find((candidate) => fs.existsSync(path.join(candidate, "electron.exe"))); +if (!dist) throw new Error("找不到 Electron 运行时,请设置 RPA_ELECTRON_DIST"); + +const env = { ...process.env, RPA_E2E: "1" }; +delete env.ELECTRON_RUN_AS_NODE; +const child = spawn(path.join(dist, "electron.exe"), [root, "--no-sandbox", `--remote-debugging-port=${debugPort}`, `--user-data-dir=${profile}`], { + cwd: root, env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true +}); +let stderr = ""; +child.stderr.on("data", (chunk) => { stderr += String(chunk); }); + +async function waitForTarget() { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`RPA Studio 提前退出:${child.exitCode}\n${stderr}`); + try { + const targets = await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json(); + const page = targets.find((item) => item.type === "page" && item.title === "RPA Studio"); + if (page) return page; + } catch { /* wait for CDP */ } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error(`等待 RPA Studio CDP 超时\n${stderr}`); +} + +async function evaluate(page, expression) { + const socket = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); + const result = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("E2E Runtime.evaluate 超时")), 180000); + socket.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.id !== 1) return; + clearTimeout(timer); + if (message.error) reject(new Error(JSON.stringify(message.error))); + else if (message.result?.exceptionDetails) reject(new Error(message.result.exceptionDetails.text || "E2E 页面异常")); + else resolve(message.result?.result?.value); + }; + socket.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression, returnByValue: true, awaitPromise: true } })); + }); + socket.close(); + return result; +} + +function node(componentId, values, label, outputVar, timeoutMs = 20000) { + return { componentId, values, label, position: { x: 220, y: 0 }, options: { timeoutMs, logException: true, ...(outputVar ? { outputVar } : {}) } }; +} + +const maximizeScript = "Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool ShowWindow(IntPtr h, int cmd);' -Name RpaMax -Namespace WinRpaMax | Out-Null; $p = Get-Process notepad | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1; if ($p) { [WinRpaMax.RpaMax]::ShowWindow($p.MainWindowHandle, 3) | Out-Null; 'maximized' } else { 'no-window' }"; +const cursorPosScript = "Add-Type -AssemblyName System.Windows.Forms; $p=[System.Windows.Forms.Cursor]::Position; \"$($p.X),$($p.Y)\""; + +const nodes = [ + node("Trigger.Manual", { entryName: "开始桌面键鼠实测" }, "人工发起"), + node("System_ExecuteCommand", { action: `New-Item -ItemType Directory -Force -Path '${workDir}' | Out-Null; Set-Content -LiteralPath '${noteFile}' -Value '' -Encoding UTF8` }, "准备测试文件"), + node("App_Run", { executeable: "notepad.exe", params: noteFile }, "启动记事本", "notepadPid"), + node("Basic_Delay", { mileSecond: 1500 }, "等待窗口出现"), + node("System_ExecuteCommand", { action: maximizeScript }, "最大化记事本", "maximized", 15000), + node("Window_SetActive", { match_title: titleMarker }, "激活记事本", "activatedTitle"), + node("Window_GetActive", {}, "读取前台窗口", "activeTitle1"), + node("Window_WindowExists", { match_title: titleMarker }, "窗口存在", "exists1"), + node("Keyboard_Send", { keys: typedText }, "键盘输入英文", "typed"), + node("Keyboard_Send", { keys: "^a^c" }, "全选并复制"), + node("Clipboard_GetText", {}, "读取剪贴板", "clipboard1"), + node("Mouse_MoveTo", { position: "400,300" }, "鼠标移动", "movedTo"), + node("System_ExecuteCommand", { action: cursorPosScript }, "读取真实光标位置", "cursorPos", 15000), + node("Mouse_RollTo", { RollNum: 1 }, "滚轮滚动", "rolled"), + node("Window_SetActive", { match_title: titleMarker }, "重新激活记事本", "activatedTitle2"), + node("Element_ExistsGround", { uiSelector: `window=${titleMarker};type=Edit;index=1` }, "UIA 元素存在", "uiaExists"), + node("Element_SetValueGround", { uiSelector: `window=${titleMarker};type=Edit;index=1`, value: uiaText }, "UIA 写入文本"), + node("Keyboard_Send", { keys: "^a^c" }, "再次全选复制"), + node("Clipboard_GetText", {}, "读取剪贴板二", "clipboard2"), + node("Element_GetValueGround", { uiSelector: `window=${titleMarker};type=Edit;index=1` }, "UIA 读取文本", "uiaValue"), + node("Element_ClickGround", { uiSelector: `window=${titleMarker};type=Edit;index=1` }, "UIA 点击编辑区", "clickResult"), + node("Window_SetActive", { match_title: titleMarker }, "清理前重新激活", "activatedTitle3"), + node("Keyboard_Send", { keys: "^a{DEL}" }, "清空文本避免保存提示"), + node("Window_CloseWindow", { title: titleMarker }, "关闭记事本", "closed"), + node("Basic_Delay", { mileSecond: 800 }, "等待退出"), + node("Window_WindowExists", { match_title: titleMarker }, "窗口已消失", "exists2") +]; + +const expression = `(async () => { + const flow = await window.rpa.flows.create("桌面键鼠实测"); + const list = ${JSON.stringify(nodes)}; + flow.nodes = list.map((item, index) => ({ id: "n" + String(index + 1).padStart(2, "0"), ...item, position: { x: 220, y: 80 + index * 140 } })); + flow.edges = flow.nodes.slice(1).map((item, index) => ({ id: "e" + index, source: flow.nodes[index].id, target: item.id })); + await window.rpa.flows.save(flow); + const events = []; + const off = window.rpa.run.onEvent((event) => events.push(event)); + const started = await window.rpa.run.start(flow); + const finished = await new Promise((resolve, reject) => { + const deadline = Date.now() + 150000; + const timer = setInterval(() => { + const event = events.find((item) => item.type === "run.finished"); + if (event) { clearInterval(timer); resolve(event); } + else if (Date.now() > deadline) { clearInterval(timer); reject(new Error("桌面键鼠流程超时")); } + }, 100); + }); + off(); + await window.rpa.flows.delete(flow.id); + const byNode = {}; + for (const event of events) if (event.type === "node.finished") byNode[event.nodeId] = event; + return { runId: started.runId, status: finished.status, durationMs: finished.durationMs, byNode, logs: events.filter((e) => e.type === "log").map((e) => e.message) }; +})()`; + +let page; +try { + page = await waitForTarget(); + const result = await evaluate(page, expression); + if (result?.status !== "succeeded") throw new Error(`桌面键鼠流程失败:${JSON.stringify(result, null, 2)}`); + const byNode = result.byNode || {}; + const out = (id) => byNode[id]?.output; + const failedNodes = Object.entries(byNode).filter(([, event]) => event.status !== "succeeded"); + const checks = [ + ["App_Run 返回 PID", Number(out("n03")) > 0, out("n03")], + ["最大化记事本", out("n05") === "maximized", out("n05")], + ["Window_SetActive 激活成功", String(out("n06") || "").includes(titleMarker), out("n06")], + ["Window_GetActive 前台=记事本", String(out("n07") || "").includes(titleMarker), out("n07")], + ["Window_WindowExists=true", out("n08") === true, out("n08")], + ["Keyboard_Send 输入回显", out("n09") === typedText, out("n09")], + ["剪贴板=键盘输入内容", out("n11") === typedText, out("n11")], + ["Mouse_MoveTo 返回坐标", JSON.stringify(out("n12")) === JSON.stringify({ x: 400, y: 300 }), out("n12")], + ["系统真实光标=目标位置", out("n13") === "400,300", out("n13")], + ["Mouse_RollTo 滚动执行", out("n14") === 120, out("n14")], + ["重复激活仍成功", String(out("n15") || "").includes(titleMarker), out("n15")], + ["UIA 元素存在", out("n16") === true, out("n16")], + ["UIA SetValue 后剪贴板一致", out("n19") === uiaText, out("n19")], + ["UIA GetValue 读取一致", out("n20") === uiaText, out("n20")], + ["UIA Click 执行", out("n21") && typeof out("n21") === "object", out("n21")], + ["Window_CloseWindow 关闭", out("n24") === true, out("n24")], + ["关闭后窗口不存在", out("n26") === false, out("n26")] + ]; + const problems = checks.filter(([, ok]) => !ok).map(([name, , actual]) => `${name} → ${JSON.stringify(actual)}`); + if (problems.length || failedNodes.length) { + throw new Error(`桌面键鼠实测未通过:\n${[...failedNodes.map(([id, event]) => `${id} ${event.status}: ${JSON.stringify(event.error || event.output)}`), ...problems].join("\n")}\n日志:\n${(result.logs || []).join("\n")}`); + } + console.log(JSON.stringify({ ok: true, runId: result.runId, durationMs: result.durationMs, checks: checks.map(([name]) => name) }, null, 2)); +} finally { + if (process.platform === "win32") { + spawnSync("taskkill", ["/IM", "notepad.exe", "/F"], { windowsHide: true, stdio: "ignore" }); + if (child.pid) spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }); + } else child.kill("SIGKILL"); + await new Promise((resolve) => setTimeout(resolve, 500)); + try { fs.rmSync(profile, { recursive: true, force: true, maxRetries: 12, retryDelay: 250 }); } catch {} + try { fs.rmSync(workDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 300 }); } catch {} +} diff --git a/scripts/install-desktop-manual-flow.mjs b/scripts/install-desktop-manual-flow.mjs new file mode 100644 index 0000000..7a31e67 --- /dev/null +++ b/scripts/install-desktop-manual-flow.mjs @@ -0,0 +1,106 @@ +import fs from "node:fs"; +import path from "node:path"; + +const appData = process.env.APPDATA || path.join(process.env.USERPROFILE || "", "AppData", "Roaming"); +const flowsDir = path.join(appData, "rpa-studio", "flows"); +const id = "flow_manual_desktop_keymouse"; +const title = "manual-desktop"; + +if (!fs.existsSync(flowsDir)) { + throw new Error(`找不到流程目录:${flowsDir}。请先启动一次 RPA Studio。`); +} + +let y = 80; +let sequence = 0; +function node(componentId, values, label, outputVar, options = {}) { + const item = { + id: `n${String(++sequence).padStart(2, "0")}`, + componentId, + label, + position: { x: 260, y }, + values, + options: { timeoutMs: 20000, logException: true, ...(outputVar ? { outputVar } : {}), ...options } + }; + y += 140; + return item; +} + +function dialog(text, title = "人工确认") { + return node("Dialog_Notice", { title, text }, title, undefined, { timeoutMs: 600000 }); +} + +const nodes = [ + node("Trigger.Manual", { entryName: "开始桌面键鼠人工测试" }, "人工发起"), + node("System_ExecuteCommand", { + action: "$old=@(Get-Process notepad -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowTitle -like '*manual-desktop*' }); foreach($p in $old){ $p | Stop-Process -Force }; if($old.Count){ Start-Sleep -Milliseconds 500; 'closed:'+$old.Count } else { 'no-old' }" + }, "清理旧测试窗口", "cleanupOld"), + node("System_ExecuteCommand", { + action: `$d=Join-Path $env:TEMP 'rpa-manual-desktop'; New-Item -ItemType Directory -Force -Path $d | Out-Null; $f=Join-Path $d '${title}.txt'; Set-Content -LiteralPath $f -Value '' -Encoding UTF8; Write-Output $f` + }, "准备记事本文件", "noteFile"), + node("App_Run", { executeable: "notepad.exe", params: "${noteFile}" }, "启动记事本", "notepadPid"), + node("Basic_Delay", { mileSecond: 1200 }, "等待窗口", "waited1"), + node("System_ExecuteCommand", { + action: `$p=Get-Process notepad -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like '*${title}*' } | Select-Object -First 1; if($p){ Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);' -Name RpaManualMax -Namespace WinRpaManualMax | Out-Null; [void][WinRpaManualMax.RpaManualMax]::ShowWindow($p.MainWindowHandle,3); 'maximized' } else { 'no-window' }` + }, "最大化记事本", "maximized"), + node("Window_SetActive", { match_title: title }, "自动激活记事本", "activatedTitle", { skipOnError: true }), + node("Window_GetActive", {}, "读取前台窗口", "activeTitle1"), + node("Window_WindowExists", { switchType: "0", get_type: "2", window_obj: title, match_type: "1", match_title: title }, "检查窗口存在", "windowExists1"), + node("Basic_DebugOutput", { msg: "自动激活:${activatedTitle};前台:${activeTitle1};存在:${windowExists1}" }, "输出阶段1结果"), + dialog("阶段1 / 窗口能力\n\n预期:\n1. 屏幕上出现并最大化 manual-desktop.txt 记事本;\n2. 自动激活和前台标题包含 manual-desktop;\n3. 窗口存在为 true。\n\n确认无误后点确定。", "阶段1 · 窗口检查"), + + dialog("阶段2 / 键盘准备\n\n请先点确定关闭本弹窗。\n关闭后 3 秒内,用鼠标点击一次记事本编辑区,但不要按键盘。", "阶段2 · 键盘准备"), + node("Basic_Delay", { mileSecond: 3000 }, "给手工聚焦留时间", "waited2"), + node("Keyboard_Send", { keys: "RPA-KEY-MANUAL-OK", words: "RPA-KEY-MANUAL-OK", interval: "0.01", fuctype: "1" }, "键盘输入英文", "keyboardResult"), + node("Keyboard_Send", { keys: "^a^c", words: "^a^c", interval: "0.01", fuctype: "1" }, "全选并复制", "shortcutResult"), + node("Clipboard_GetText", {}, "读取剪贴板", "clipboardText"), + node("Basic_DebugOutput", { msg: "键盘返回:${keyboardResult};剪贴板:${clipboardText}" }, "输出键盘结果"), + dialog("阶段2 / 键盘能力\n\n预期:\n1. 记事本显示 RPA-KEY-MANUAL-OK;\n2. 调试输出中的剪贴板也是 RPA-KEY-MANUAL-OK。\n\n确认后点确定。", "阶段2 · 键盘检查"), + + node("Mouse_MoveTo", { position: "620,420" }, "鼠标移动", "mouseMoveResult"), + node("System_ExecuteCommand", { + action: "Add-Type -AssemblyName System.Windows.Forms; $p=[System.Windows.Forms.Cursor]::Position; Write-Output \"$($p.X),$($p.Y)\"" + }, "核对光标位置", "realCursor"), + node("Mouse_Click", { position: "650,450" }, "鼠标左键点击", "mouseClickResult"), + node("Mouse_DragTo", { Start: "650,450", End: "760,520" }, "鼠标拖拽", "mouseDragResult"), + node("Mouse_RollTo", { RollNum: 1 }, "滚轮滚动", "mouseRollResult"), + dialog("阶段3 / 鼠标能力\n\n请回忆/观察:\n1. 光标曾移动到屏幕中部;\n2. 记事本编辑区收到左键点击;\n3. 拖拽执行且没有把窗口拖丢;\n4. 滚轮动作执行。\n\n确认后点确定。", "阶段3 · 鼠标检查"), + + node("Element_ExistsGround", { uiSelector: `window=${title};type=Edit;index=1` }, "UIA 元素存在", "uiaExists", { skipOnError: true }), + node("Element_SetValueGround", { uiSelector: `window=${title};type=Edit;index=1`, value: "UIA-MANUAL-写入-OK" }, "UIA 写入编辑框", "uiaSetResult", { skipOnError: true }), + node("Element_GetValueGround", { uiSelector: `window=${title};type=Edit;index=1` }, "UIA 读取编辑框", "uiaValue", { skipOnError: true }), + node("Element_ClickGround", { uiSelector: `window=${title};type=Edit;index=1` }, "UIA 点击编辑框", "uiaClickResult", { skipOnError: true }), + node("Basic_DebugOutput", { msg: "UIA 存在:${uiaExists};读取:${uiaValue}" }, "输出UIA结果"), + dialog("阶段4 / UIA 能力\n\n预期:\n1. 记事本内容被写入 UIA-MANUAL-写入-OK;\n2. 调试输出中 UIA 存在为 true,读取值一致;\n3. UIA 点击执行后流程继续。\n\n确认后点确定。", "阶段4 · UIA检查"), + + dialog("准备清理:流程会尝试清空内容并关闭记事本。点确定继续。", "清理确认"), + node("Window_SetActive", { match_title: title }, "清理前激活", "activatedTitle2", { skipOnError: true }), + node("Keyboard_Send", { keys: "^a{DEL}", words: "^a{DEL}", interval: "0.01", fuctype: "1" }, "清空文本", "clearResult"), + node("Window_CloseWindow", { title, window_obj: title }, "关闭记事本", "closeResult", { skipOnError: true }), + node("Basic_Delay", { mileSecond: 800 }, "等待退出", "waited3"), + node("Window_WindowExists", { switchType: "1", get_type: "2", window_obj: title, match_type: "1", match_title: title }, "确认窗口关闭", "windowExists2"), + node("Basic_DebugOutput", { msg: "关闭结果:${closeResult};关闭后窗口存在:${windowExists2}" }, "输出清理结果"), + dialog("阶段5 / 清理检查\n\n预期:记事本已关闭,关闭后窗口存在为 false。\n\n完成请点确定。", "阶段5 · 清理检查") +]; + +const flow = { + schemaVersion: 1, + id, + name: "桌面键鼠人工验收", + description: "分阶段验证窗口、键盘、鼠标、剪贴板、UIA 与清理;每个阶段有弹窗暂停,便于人工确认。", + enabled: true, + trigger: { type: "manual", label: "手动运行" }, + variables: [], + nodes, + edges: nodes.slice(1).map((target, index) => ({ id: `e${index}`, source: nodes[index].id, target: target.id })), + meta: { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), appVersion: "0.1.0" } +}; + +const target = path.join(flowsDir, `${id}.json`); +fs.writeFileSync(target, JSON.stringify(flow, null, 2), "utf8"); +console.log(`已写入:${target}`); +console.log(`节点数:${nodes.length}`); + + + + + diff --git a/scripts/package.mjs b/scripts/package.mjs index d3c9979..4f23244 100644 --- a/scripts/package.mjs +++ b/scripts/package.mjs @@ -86,10 +86,10 @@ console.log("[package] typecheck"); run("npm", ["run", "typecheck"]); console.log("[package] tests"); run("npm", ["test"]); -console.log("[package] browser e2e"); -run("npm", ["run", "test:e2e"]); console.log("[package] build"); run("npm", ["run", "build"]); +console.log("[package] browser e2e"); +run("npm", ["run", "test:e2e"]); const electronDist = path.join(root, "node_modules", "electron", "dist"); if (!fs.existsSync(path.join(electronDist, "electron.exe"))) { diff --git a/src/main/capabilities/desktop-uia.ts b/src/main/capabilities/desktop-uia.ts index 0a3c2f0..04db745 100644 --- a/src/main/capabilities/desktop-uia.ts +++ b/src/main/capabilities/desktop-uia.ts @@ -239,7 +239,7 @@ function Find-RpaWindow([string]$winName, [string]$winClass, [string]$processNam $titleSb = New-Object System.Text.StringBuilder 512 [void][Win32Rpa.RpaUser32Click]::GetWindowTextW($h, $titleSb, 512) $title = $titleSb.ToString() - if ($winName -and $title -ne $winName) { return $true } + if ($winName -and $title -notlike "*$winName*") { return $true } $classSb = New-Object System.Text.StringBuilder 256 [void][Win32Rpa.RpaUser32Click]::GetClassNameW($h, $classSb, 256) $className = $classSb.ToString() @@ -257,7 +257,7 @@ function Find-RpaWindow([string]$winName, [string]$winClass, [string]$processNam if ($script:hitHwnd -ne [IntPtr]::Zero) { try { $win = [System.Windows.Automation.AutomationElement]::FromHandle($script:hitHwnd); if ($win) { return $win } } catch {} } - $missing = @($winName, $winClass, $processName) | Where-Object { $_ } -join "," + $missing = (@($winName, $winClass, $processName) | Where-Object { $_ }) -join "," throw "window_not_found:$missing" } function Restore-RpaWindow($win) { diff --git a/src/main/capabilities/desktop.ts b/src/main/capabilities/desktop.ts index 7f36358..bf98cd1 100644 --- a/src/main/capabilities/desktop.ts +++ b/src/main/capabilities/desktop.ts @@ -153,22 +153,42 @@ const mouseRoll: CapabilityHandler = async (_ctx, node, args) => { }; const setActiveWindow: CapabilityHandler = async (_ctx, node, args) => { - const selector = text(args, ["match_title", "select_title", "title", "target_window", "window_title"]); + const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]); if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId }); - const result = await powershell(`${USER32}; $p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口' }; [RpaUser32]::SetForegroundWindow($p.MainWindowHandle); $p.MainWindowTitle`); + const result = await powershell(`${USER32} +$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1 +if (-not $p) { throw '找不到窗口' } +Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr pid); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool attach); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();' -Name RpaFocus2 -Namespace WinRpaFocus2 | Out-Null +$h = $p.MainWindowHandle +if ([WinRpaFocus2.RpaFocus2]::IsIconic($h)) { [void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 9) } +if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) { + [void][WinRpaFocus2.RpaFocus2]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero) + [void][WinRpaFocus2.RpaFocus2]::SetForegroundWindow($h) + [void][WinRpaFocus2.RpaFocus2]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero) + Start-Sleep -Milliseconds 200 +} +if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) { + $fgThread = [WinRpaFocus2.RpaFocus2]::GetWindowThreadProcessId([WinRpaFocus2.RpaFocus2]::GetForegroundWindow(), [IntPtr]::Zero) + $thisThread = [WinRpaFocus2.RpaFocus2]::GetCurrentThreadId() + $attached = [WinRpaFocus2.RpaFocus2]::AttachThreadInput($thisThread, $fgThread, $true) + try { [void][WinRpaFocus2.RpaFocus2]::SetForegroundWindow($h) } finally { if ($attached) { [void][WinRpaFocus2.RpaFocus2]::AttachThreadInput($thisThread, $fgThread, $false) } } + Start-Sleep -Milliseconds 200 +} +if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) { throw '前台切换失败' } +$p.MainWindowTitle`); return result; }; const getActiveWindow: CapabilityHandler = async () => powershell(`${USER32}; $sb = New-Object System.Text.StringBuilder 512; [RpaUser32]::GetWindowText([RpaUser32]::GetForegroundWindow(),$sb,$sb.Capacity) | Out-Null; $sb.ToString()`); const windowExists: CapabilityHandler = async (_ctx, node, args) => { - const selector = text(args, ["match_title", "select_title", "title", "target_window", "window_title"]); + const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]); if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId }); return (await powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; [bool]$p`)).toLowerCase() === "true"; }; const closeWindow: CapabilityHandler = async (_ctx, node, args) => { - const selector = text(args, ["window_obj", "title", "target_window", "window_title"]); + const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]); if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId }); return powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口' }; $p.CloseMainWindow() | Out-Null; $true`); }; diff --git a/src/main/capabilities/pdf-high.test.ts b/src/main/capabilities/pdf-high.test.ts index 9e9343f..2e0378c 100644 --- a/src/main/capabilities/pdf-high.test.ts +++ b/src/main/capabilities/pdf-high.test.ts @@ -45,7 +45,7 @@ describe("PDF high-level capabilities", () => { const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); const opened = await pdfjs.getDocument({ data: new Uint8Array(await readFile(encryptedPath)), password: "secret" }).promise; expect(opened.numPages).toBe(1); - }); + }, 60000); }); async function mkdtempSafe(): Promise { const directory = await (await import("node:fs/promises")).mkdtemp(path.join(os.tmpdir(), "rpa-pdf-high-")); diff --git a/src/main/flows.ts b/src/main/flows.ts index 3202929..d8029ea 100644 --- a/src/main/flows.ts +++ b/src/main/flows.ts @@ -1,5 +1,6 @@ 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"; @@ -71,12 +72,29 @@ export function createFlow(name = "未命名流程"): FlowDefinition { }; } +/** 演示流程浏览器类型:优先 Chrome,未安装时回退 Edge;两者都缺失则保持 chrome 由能力层给出友好错误 */ +function demoBrowserType(): string { + 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: "chrome", url, isRemote: "0", openNewTab: "1", waitingload: "1" }, options: { timeoutMs: 30000 } }, + { 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 } }, diff --git a/src/renderer/src/panels/Properties.tsx b/src/renderer/src/panels/Properties.tsx index 7968a7b..7482ed3 100644 --- a/src/renderer/src/panels/Properties.tsx +++ b/src/renderer/src/panels/Properties.tsx @@ -636,14 +636,18 @@ function FieldControl({ onToast("未找到可见窗口", "error"); return; } - const numbered = list.map((w: { title: string }, i: number) => (i + 1) + ". " + w.title).join("\n"); - const choice = prompt("选择窗口(输入编号):\n" + numbered); - const idx = Number(choice) - 1; - if (Number.isInteger(idx) && list[idx]) { - onChange(field.key, list[idx].title); - onToast("已选择窗口:" + list[idx].title); + for (let wi = 0; wi < list.length; wi++) { + const w = list[wi] as { title: string; process?: string }; + const ok = confirm(`窗口 ${wi + 1}/${list.length}:${w.title}(${w.process ?? "未知"})\n\n确定选这个窗口?取消看下一个`); + if (ok) { + onChange(field.key, w.title); + onToast("已选择窗口:" + w.title); + return; + } } - } catch (error) { + onToast("未选择窗口"); + } + catch (error) { onToast(error instanceof Error ? error.message : String(error), "error"); } finally { setPicking(false); @@ -673,11 +677,15 @@ function FieldControl({ ? "填写完整文件路径,或点右侧按钮选择" : field.widget === "folder" ? "填写完整文件夹路径,或点右侧按钮选择" + : field.widget === "region" && (field.displayType === "captureWindow" || field.displayType === "openWindowSelect") + ? "填写窗口标题,或点「选窗口」从可见窗口列表中选择" + : field.widget === "region" ? field.displayType === "screenShot" ? "填模板图片路径,或点「屏幕框选」截取屏幕上的目标图案" : "填写区域坐标 JSON,或点「屏幕框选」生成" : ""; + const hint = field.description || fallbackHint; return ( @@ -778,12 +786,27 @@ function FieldControl({ placeholder={field.placeholder || ""} onChange={(event) => onChange(field.key, event.target.value)} /> + ) : field.widget === "region" && (field.displayType === "captureWindow" || field.displayType === "openWindowSelect") ? ( +
+ onChange(field.key, event.target.value)} + /> + + +
) : field.widget === "region" && field.displayType === "screenShot" ? ( // 模板图片类字段(Image_* 的图片路径等): 值是文件路径, 框选是把截图存盘后填路径
onChange(field.key, event.target.value)} /> @@ -817,6 +840,21 @@ function FieldControl({ onChange={(event) => onChange(field.key, event.target.value)} /> + {isWindowTarget && /window_obj|match_title|title|window/i.test(field.key) && ( + + )} + {field.displayType === "imageSelector" && ( + <> + + + + )}
)} diff --git a/src/renderer/src/validation/flowValidation.ts b/src/renderer/src/validation/flowValidation.ts index 1073c55..aa47a52 100644 --- a/src/renderer/src/validation/flowValidation.ts +++ b/src/renderer/src/validation/flowValidation.ts @@ -29,6 +29,20 @@ export function fieldIsActive(componentId: string, key: string, values: Record