From b242b9c4fe7b82db11fd46e436246d3f55ba61f0 Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Wed, 4 Nov 2026 09:47:19 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E9=94=AE=E7=9B=98=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=80=A7:=20DriveSend=20=E5=AE=B6=E6=97=8F?= =?UTF-8?q?=E7=BA=B3=E5=85=A5=E6=96=87=E6=9C=AC=E8=AF=AD=E4=B9=89=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=9B=9B=E7=B1=BB=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/capabilities/desktop.ts | 4 +++- .../capabilities/keyboard-send-smoke.test.ts | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/main/capabilities/desktop.ts b/src/main/capabilities/desktop.ts index ae3b614..e369ed2 100644 --- a/src/main/capabilities/desktop.ts +++ b/src/main/capabilities/desktop.ts @@ -152,13 +152,15 @@ function psCharLiteral(text: string): string { const keyboardSend: CapabilityHandler = async (_ctx, node, args) => { // 字段: words=输入内容(键盘输入), keys/key=按键组合(按键系列), interval=输入间隔(V1毫秒/基础版秒), fuctype=输入方式(0后台接口/1人工模拟/2剪贴板粘贴), window_title=输入目标窗口(留空=当前焦点窗口) + // 文本输入语义组件: Keyboard_Send/Keyboard_Send_V1/键盘驱动输入家族(DriveSend 委托至此, 字段同为 words+interval); + // 按键组合组件(Keyboard_Press/PressNew)字段是 keys/key, 内容保留 SendKeys 语法(^s {ENTER} %{F4})不做转义 + const isTextSend = node.componentId === "Keyboard_Send" || node.componentId === "Keyboard_Send_V1" || node.componentId.startsWith("Keyboard_DriveSend"); const keys = text(args, ["words", "keys", "key", "text", "value"]); if (!keys) throw new RuntimeError("validation_error", "键盘输入为空", { nodeId: node.id, componentId: node.componentId }); await manualDelay(); const rawInterval = number(args, ["interval"], 0); const intervalMs = node.componentId === "Keyboard_Send" ? Math.max(0, Math.round(rawInterval * 1000)) : Math.max(0, Math.round(rawInterval)); const mode = text(args, ["fuctype"], "0"); - const isTextSend = node.componentId === "Keyboard_Send" || node.componentId === "Keyboard_Send_V1"; // 指定目标窗口时先激活再输入,内容才会进到目标窗口而不是运行时的焦点窗口 const targetTitle = text(args, ["window_title", "target_window", "window_obj", "match_title", "select_title"]); const activate = targetTitle ? `${activateWindowScript(targetTitle)}; Start-Sleep -Milliseconds 300; ` : ""; diff --git a/src/main/capabilities/keyboard-send-smoke.test.ts b/src/main/capabilities/keyboard-send-smoke.test.ts index 78c3215..3977536 100644 --- a/src/main/capabilities/keyboard-send-smoke.test.ts +++ b/src/main/capabilities/keyboard-send-smoke.test.ts @@ -3,6 +3,7 @@ import type { CatalogComponent, FlowNode } from "../../shared/types"; import { getHandler } from "../engine/registry"; import type { RunContext } from "../engine/types"; import "./desktop"; +import "./common-final"; const component = {} as CatalogComponent; const node = (id: string): FlowNode => ({ id: "smoke-kb", componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} }); @@ -45,6 +46,27 @@ describe("键盘输入组件真实冒烟", () => { console.log("[键盘输入] 人工模拟+中文标点+智能引号 ✓"); }, 60000); + it("兼容性: Keyboard_Press 组合键语法(^s {ENTER} %{F4})不被文本转义破坏", async () => { + // 组合键必须保留 SendKeys 原语法:^=Ctrl %=Alt {ENTER}=回车 + const out = await invoke("Keyboard_Press", { keys: "^s", interval: "10" }); + expect(out).toBe("^s"); + const out2 = await invoke("Keyboard_PressNew", { keys: "{ENTER}%{F4}" }); + expect(out2).toBe("{ENTER}%{F4}"); + console.log("[键盘输入] 组合键 SendKeys 语法保留 ✓"); + }, 60000); + + it("兼容性: Keyboard_DriveSend_V2(键盘驱动输入,委托组件)按文本输入语义转义特殊字符", async () => { + const out = await invoke("Keyboard_DriveSend_V2", { words: "驱动(a*100%)", interval: "5", keyboard_type: "1" }); + expect(out).toBe("驱动(a*100%)"); + console.log("[键盘输入] DriveSend 委托文本转义 ✓:", out); + }, 60000); + + it("兼容性: 基础版 Keyboard_Send interval 秒级换算", async () => { + const out = await invoke("Keyboard_Send", { words: "秒", interval: "0.01" }); + expect(out).toBe("秒"); + console.log("[键盘输入] 基础版秒级间隔 ✓"); + }, 60000); + it("剪切板粘贴模式: 原文进入剪贴板并发送 Ctrl+V", async () => { const content = `粘贴${Date.now()}`; const out = await invoke("Keyboard_Send_V1", { words: content, fuctype: "2" }); From 1c5d981e3808a10b3774edee0f6cf152fcbed8eb Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Wed, 4 Nov 2026 10:11:31 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E7=BA=A7=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=85=BC=E5=AE=B9=E6=80=A7=E5=AE=A1=E6=9F=A5:=20psTex?= =?UTF-8?q?t=20=E7=BB=9F=E4=B8=80=E9=9D=9E=20ASCII=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=AD=97=E9=9D=A2=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/capabilities/compat-audit.test.ts | 75 ++++++++++++++++++++++ src/main/capabilities/desktop.ts | 31 +++++---- src/main/capabilities/system-extended.ts | 7 +- src/main/capabilities/window-extended.ts | 5 +- 4 files changed, 100 insertions(+), 18 deletions(-) create mode 100644 src/main/capabilities/compat-audit.test.ts diff --git a/src/main/capabilities/compat-audit.test.ts b/src/main/capabilities/compat-audit.test.ts new file mode 100644 index 0000000..ffd2a5d --- /dev/null +++ b/src/main/capabilities/compat-audit.test.ts @@ -0,0 +1,75 @@ +import { describe, it } from "vitest"; +import { capabilityIds } from "../engine/registry"; +import { writeFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; +import "./index"; // 汇总导入全部能力模块(与运行时同构) + +interface CatalogComp { + id: string; + name?: string; + fields?: { key: string; label?: string; widget?: string; options?: { label: string; value: string }[]; defaultValue?: unknown }[]; +} + +describe("系统组件兼容性全量审查", () => { + it("注册覆盖 + 面板可见性 + 字段契约核对", async () => { + const catalog: CatalogComp[] = JSON.parse(readFileSync("resources/catalog.json", "utf-8")).components ?? []; + const dup = JSON.parse(readFileSync("resources/capability-duplicates.json", "utf-8")); + const dupMap = new Map( + (Array.isArray(dup) ? dup : []).map((d: { id: string; status: string; canonical?: string }) => [d.id, { status: d.status, canonical: d.canonical }]) + ); + const registered = new Set(capabilityIds()); + + // 1) catalog 组件 → 运行时注册 + const unregistered: { id: string; name?: string }[] = []; + for (const c of catalog) { + if (!registered.has(c.id)) unregistered.push({ id: c.id, name: c.name }); + } + + // 2) 幽灵注册:handler 有但 catalog 没有(可能是内部别名,不一定是问题,但要列出来) + const catalogIds = new Set(catalog.map((c) => c.id)); + const ghosts: string[] = []; + for (const id of registered) { + if (!catalogIds.has(id)) ghosts.push(id); + } + + // 3) duplicates 一致性:hide 必须有 canonical 且 canonical 已注册;keep/隐藏组件的执行器必须在 + const dupIssues: string[] = []; + for (const [id, info] of dupMap) { + if (info.status === "hide" && info.canonical && !registered.has(info.canonical)) { + dupIssues.push(`hide 项 ${id} 的 canonical ${info.canonical} 未注册`); + } + if (info.status === "keep" && !registered.has(id)) { + dupIssues.push(`keep 项 ${id} 未注册(面板可见但运行会报未实现)`); + } + if (info.status === "hide" && !info.canonical && !registered.has(id)) { + dupIssues.push(`hide 项 ${id} 无 canonical 且未注册(旧流程节点将无法运行)`); + } + } + + // 4) 字段契约抽查:select 枚举 defaultValue 必须在 options 里;hidden 字段不能 required + const fieldIssues: string[] = []; + for (const c of catalog) { + for (const f of c.fields ?? []) { + if (f.widget === "select" && Array.isArray(f.options) && f.options.length > 0) { + const values = f.options.map((o) => String(o.value)); + if (f.defaultValue !== undefined && f.defaultValue !== "" && !values.includes(String(f.defaultValue))) { + fieldIssues.push(`${c.id}.${f.key} select 默认值 "${f.defaultValue}" 不在选项值 [${values.join(",")}] 中`); + } + } + } + } + + const report = { + catalogTotal: catalog.length, + registeredTotal: registered.size, + unregisteredCount: unregistered.length, + unregistered: unregistered.slice(0, 50), + ghostCount: ghosts.length, + ghosts: ghosts.slice(0, 40), + dupIssues, + fieldIssues: fieldIssues.slice(0, 40), + fieldIssueCount: fieldIssues.length + }; + writeFileSync(".compat-report.txt", JSON.stringify(report, null, 1), "utf8"); + }, 120000); +}); diff --git a/src/main/capabilities/desktop.ts b/src/main/capabilities/desktop.ts index e369ed2..c05b306 100644 --- a/src/main/capabilities/desktop.ts +++ b/src/main/capabilities/desktop.ts @@ -141,13 +141,19 @@ function sendKeysEscape(ch: string): string { } /** - * 把字符包装成 PowerShell 安全字面量。 - * ASCII 直接用单引号字符串(撇号内部 '' 转义);非 ASCII 用 [char]0xXXXX 数值构造—— + * 把文本包装成 PowerShell 安全表达式。 + * ASCII 直接用单引号字符串(撇号内部 '' 转义);含非 ASCII 用 [char]0xXXXX 数值构造—— * Windows PowerShell 5.1 会把 ‘ ’ “ ” 等智能引号当作引号变体解析,直接放进字符串字面量会报「字符串缺少终止符」。 + * 所有内嵌用户文本的脚本生成点(Set-Clipboard/SendWait/throw 消息等)都应使用本函数而非 psString。 */ -function psCharLiteral(text: string): string { +export function psText(text: string): string { if (/^[\x20-\x7E]*$/.test(text)) return psString(text); - return Array.from(text).map((c) => `[char]0x${c.codePointAt(0)!.toString(16).padStart(4, "0")}`).join("+") || psString(""); + return `(-join @(${Array.from(text).map((c) => `[char]0x${c.codePointAt(0)!.toString(16).padStart(4, "0")}`).join(",")}))`; +} + +/** 单字符/短字面量的别名(语义标注用),实现同 psText */ +function psCharLiteral(text: string): string { + return psText(text); } const keyboardSend: CapabilityHandler = async (_ctx, node, args) => { @@ -168,9 +174,8 @@ const keyboardSend: CapabilityHandler = async (_ctx, node, args) => { // 三种输入方式:脚本尽量简单,转义逻辑放 JS 侧,避免 PowerShell 字符串拼接的语法陷阱 let send: string; if (mode === "2") { - // 剪贴板粘贴:原文进剪贴板 + Ctrl+V,不做 SendKeys 转义;内容含智能引号等非 ASCII 时同样走 [char] 构造 - const contentExpr = /^[\x20-\x7E]*$/.test(keys) ? psString(keys) : `(-join @(${Array.from(keys).map((c) => psCharLiteral(c)).join(",")}))`; - send = `Set-Clipboard -Value ${contentExpr}; Start-Sleep -Milliseconds 120; [System.Windows.Forms.SendKeys]::SendWait('^v')`; + // 剪贴板粘贴:原文进剪贴板 + Ctrl+V,不做 SendKeys 转义 + send = `Set-Clipboard -Value ${psText(keys)}; Start-Sleep -Milliseconds 120; [System.Windows.Forms.SendKeys]::SendWait('^v')`; } else if (mode === "1" && isTextSend) { // 人工模拟:逐字符;JS 侧把每个字符转义成 SendKeys 字面量,PS 侧只做数组循环 // 数组元素必须用 psCharLiteral:ASCII 走引号字符串,非 ASCII 走 [char]0xXXXX(避开 PS5.1 智能引号解析陷阱) @@ -180,8 +185,7 @@ const keyboardSend: CapabilityHandler = async (_ctx, node, args) => { } else { // 后台接口 / 按键组合 / 人工模拟(按键组合组件):整段发送,文本特殊字符转义 const escaped = isTextSend ? Array.from(keys).map(sendKeysEscape).join("") : keys; - const contentExpr = /^[\x20-\x7E]*$/.test(escaped) ? psString(escaped) : `(-join @(${Array.from(escaped).map((c) => psCharLiteral(c)).join(",")}))`; - send = `[System.Windows.Forms.SendKeys]::SendWait(${contentExpr})`; + send = `[System.Windows.Forms.SendKeys]::SendWait(${psText(escaped)})`; } await powershell(`${USER32}; ${activate}Add-Type -AssemblyName System.Windows.Forms; ${send}`); await manualDelay(); @@ -237,10 +241,11 @@ const mouseRoll: CapabilityHandler = async (_ctx, node, args) => { * Windows 前台锁定策略会拒绝普通 SetForegroundWindow,最小化再恢复可绕过)。 * 失败用 RPA_ERROR 标记写 stdout 后 exit 1:不走 throw/CLIXML,避免控制台宽度截断和编码歧义 */ function activateWindowScript(selector: string): string { - const safeTitle = selector.replace(/'/g, "''"); + // 错误消息内嵌用户文本走 psText(窗口标题可能含智能引号/中文标点,直接放进单引号字面量会被 PS5.1 解析炸) + const failMsg = psText(`找不到窗口:${selector}。当前可见窗口标题: `); return `function RpaFail($m) { [Console]::Out.WriteLine('RPA_ERROR::' + $m); exit 1 } $p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1 -if (-not $p) { $vis = (Get-Process | Where-Object { $_.MainWindowTitle } | Select-Object -First 12 | ForEach-Object { '『' + $_.MainWindowTitle + '』' }) -join ' '; RpaFail ('找不到窗口:${safeTitle}。当前可见窗口标题: ' + $vis) } +if (-not $p) { $vis = (Get-Process | Where-Object { $_.MainWindowTitle } | Select-Object -First 12 | ForEach-Object { '『' + $_.MainWindowTitle + '』' }) -join ' '; RpaFail (${failMsg} + $vis) } 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("user32.dll")] public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab); [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); Start-Sleep -Milliseconds 150 } @@ -306,14 +311,14 @@ const runApp: CapabilityHandler = async (_ctx, node, args) => { const clipboardGet: CapabilityHandler = async () => powershell("Get-Clipboard -Raw"); const clipboardSet: CapabilityHandler = async (_ctx, _node, args) => { const content = text(args, ["text", "value"]); - await powershell(`Set-Clipboard -Value ${psString(content)}`); + await powershell(`Set-Clipboard -Value ${psText(content)}`); return content; }; const notice: CapabilityHandler = async (_ctx, _node, args) => { const title = text(args, ["title"], "提醒"); const content = text(args, ["text", "message"]); - await powershell(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show(${psString(content)},${psString(title)}) | Out-Null`); + await powershell(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show(${psText(content)},${psText(title)}) | Out-Null`); return true; }; diff --git a/src/main/capabilities/system-extended.ts b/src/main/capabilities/system-extended.ts index 3beffc8..a23c7fa 100644 --- a/src/main/capabilities/system-extended.ts +++ b/src/main/capabilities/system-extended.ts @@ -2,7 +2,7 @@ import os from "node:os"; import path from "node:path"; import { register } from "../engine/registry"; import { RuntimeError, type CapabilityHandler } from "../engine/types"; -import { powershell, psString } from "./desktop"; +import { powershell, psString, psText } from "./desktop"; function value(args: Record, keys: string[], fallback = ""): string { for (const key of keys) { @@ -43,7 +43,8 @@ public static class RpaWindowApi { '@`; function findWindowScript(title: string): string { - return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口:${title.replace(/'/g, "''")}' }`; + // throw 消息内嵌用户文本走 psText(智能引号等非 ASCII 会让 PS5.1 字符串字面量未闭合) + return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw (${psText(`找不到窗口:${title}`)}) }`; } register("System_GetEnv", (_ctx, node, args) => { @@ -105,7 +106,7 @@ register(["App_Kill", "App_KillV1"], async (_ctx, node, args) => { register("Clipboard_GetTextV1", () => powershell("Get-Clipboard -Raw")); register("Clipboard_SetTextV1", async (_ctx, _node, args) => { const content = value(args, ["text", "value"]); - await powershell(`Set-Clipboard -Value ${psString(content)}`); + await powershell(`Set-Clipboard -Value ${psText(content)}`); return content; }); register("Clipboard_ClearClipboard", async () => { diff --git a/src/main/capabilities/window-extended.ts b/src/main/capabilities/window-extended.ts index 2e3ff9b..166967f 100644 --- a/src/main/capabilities/window-extended.ts +++ b/src/main/capabilities/window-extended.ts @@ -1,6 +1,6 @@ import { register } from "../engine/registry"; import { RuntimeError, type CapabilityHandler } from "../engine/types"; -import { powershell, psString } from "./desktop"; +import { powershell, psString, psText } from "./desktop"; import { screen } from "electron"; const WINDOW_API = `Add-Type @' @@ -50,7 +50,8 @@ function requiredTitle(args: Record, nodeId: string, componentI } function findWindow(title: string): string { - return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口:${title.replace(/'/g, "''")}' }`; + // throw 消息内嵌用户文本走 psText(智能引号等非 ASCII 会让 PS5.1 字符串字面量未闭合) + return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw (${psText(`找不到窗口:${title}`)}) }`; } function parsePoint(candidate: unknown, label: string, nodeId: string, componentId: string): [number, number] {