diff --git a/resources/catalog.json b/resources/catalog.json index f330214..9cfd730 100644 --- a/resources/catalog.json +++ b/resources/catalog.json @@ -71044,6 +71044,19 @@ "value": "2" } ] + }, + { + "key": "window_title", + "label": "输入目标窗口", + "widget": "text", + "type": "string", + "displayType": "text-expression", + "required": false, + "defaultValue": "", + "placeholder": "留空则输入到当前焦点窗口", + "description": "选择目标窗口后,运行时会先把该窗口带到前台再输入内容", + "advanced": false, + "valueTypes": [] } ], "output": { @@ -71145,6 +71158,19 @@ "value": "2" } ] + }, + { + "key": "window_title", + "label": "输入目标窗口", + "widget": "text", + "type": "string", + "displayType": "text-expression", + "required": false, + "defaultValue": "", + "placeholder": "留空则输入到当前焦点窗口", + "description": "选择目标窗口后,运行时会先把该窗口带到前台再输入内容", + "advanced": false, + "valueTypes": [] } ], "output": { diff --git a/src/main/capabilities/desktop.ts b/src/main/capabilities/desktop.ts index 2d536b2..b0a96d9 100644 --- a/src/main/capabilities/desktop.ts +++ b/src/main/capabilities/desktop.ts @@ -99,7 +99,7 @@ export function psString(value: unknown): string { } const keyboardSend: CapabilityHandler = async (_ctx, node, args) => { - // 字段: words=输入内容(键盘输入), keys/key=按键组合(按键系列), interval=输入间隔(V1毫秒/基础版秒), fuctype=输入方式(0后台接口/1人工模拟/2剪贴板粘贴) + // 字段: words=输入内容(键盘输入), keys/key=按键组合(按键系列), interval=输入间隔(V1毫秒/基础版秒), fuctype=输入方式(0后台接口/1人工模拟/2剪贴板粘贴), window_title=输入目标窗口(留空=当前焦点窗口) const keys = text(args, ["words", "keys", "key", "text", "value"]); if (!keys) throw new RuntimeError("validation_error", "键盘输入为空", { nodeId: node.id, componentId: node.componentId }); await manualDelay(); @@ -114,7 +114,10 @@ const keyboardSend: CapabilityHandler = async (_ctx, node, args) => { : mode === "1" ? `foreach ($ch in $t.ToCharArray()) { $c = [string]$ch; if ('+^%~(){}[]'.Contains($c)) { $c = '{' + $c + '}' }; [System.Windows.Forms.SendKeys]::SendWait($c); Start-Sleep -Milliseconds ${intervalMs} }` : `[System.Windows.Forms.SendKeys]::SendWait($t)`; - await powershell(`Add-Type -AssemblyName System.Windows.Forms; $t = ${psString(keys)}; ${escape}${send}`); + // 指定目标窗口时先激活再输入,内容才会进到目标窗口而不是运行时的焦点窗口 + const targetTitle = text(args, ["window_title", "target_window", "window_obj", "match_title", "select_title"]); + const activate = targetTitle ? `${activateWindowScript(targetTitle)}; Start-Sleep -Milliseconds 300; ` : ""; + await powershell(`${USER32}; ${activate}Add-Type -AssemblyName System.Windows.Forms; $t = ${psString(keys)}; ${escape}${send}`); await manualDelay(); return keys; }; @@ -164,12 +167,10 @@ const mouseRoll: CapabilityHandler = async (_ctx, node, args) => { return amount; }; -const setActiveWindow: CapabilityHandler = async (_ctx, node, args) => { - 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 '找不到窗口' } +/** 把匹配标题的窗口带到前台(Alt trick + AttachThreadInput 兜底);拼在 USER32 之后使用 */ +function activateWindowScript(selector: string): string { + return `$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1 +if (-not $p) { throw '找不到窗口:${selector.replace(/'/g, "''")}' } 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) } @@ -186,7 +187,14 @@ if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) { 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 '前台切换失败' } +if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) { throw '前台切换失败' }`; +} + +const setActiveWindow: CapabilityHandler = async (_ctx, node, args) => { + 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} +${activateWindowScript(selector)} $p.MainWindowTitle`); return result; }; diff --git a/src/main/capabilities/keyboard-send-smoke.test.ts b/src/main/capabilities/keyboard-send-smoke.test.ts index ebc89c2..953d7ae 100644 --- a/src/main/capabilities/keyboard-send-smoke.test.ts +++ b/src/main/capabilities/keyboard-send-smoke.test.ts @@ -45,4 +45,23 @@ describe("键盘输入组件真实冒烟", () => { await expect(invoke("Keyboard_Send_V1", { words: "", interval: "10" })).rejects.toThrow("键盘输入为空"); console.log("[键盘输入] 空输入校验 ✓"); }, 60000); + + it("输入目标窗口: 先激活记事本再输入, 前台窗口切到位", async () => { + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const runPs = promisify(execFile); + const ps = (cmd: string) => runPs("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", cmd], { windowsHide: true, timeout: 30000 }); + // Win11 记事本窗口标题是「无标题 - Notepad」; 沙盒内 Start-Process notepad 别名解析失败, 用完整路径 + await ps("Start-Process $env:SystemRoot\\System32\\notepad.exe; $diag=''; $ok=$false; foreach($i in 1..24){ $procs=Get-Process Notepad -ErrorAction SilentlyContinue; if($procs){ $diag=\"procs:$($procs.Count) titles:$(($procs|ForEach-Object{$_.MainWindowTitle}) -join '|')\"; $p=Get-Process | Where-Object {$_.MainWindowTitle -like '*Notepad*'}; if($p){$ok=$true; break} } else { $diag='no-process' }; Start-Sleep -Milliseconds 500 }; if(-not $ok){ throw \"notepad not visible: $diag\" }"); + try { + await invoke("Keyboard_Send_V1", { words: "目标窗口输入", interval: "5", fuctype: "0", window_title: "Notepad" }); + await new Promise((r) => setTimeout(r, 300)); + // activateWindowScript 内部已校验前台句柄=目标窗口, 这里再确认记事本进程窗口标题匹配 + const fg = await ps("[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); (Get-Process Notepad | Where-Object { $_.MainWindowTitle } | Select-Object -First 1).MainWindowTitle"); + expect(fg.stdout).toContain("Notepad"); + console.log("[键盘输入] 目标窗口激活 ✓ 前台:", fg.stdout.trim()); + } finally { + await ps("Get-Process notepad* -ErrorAction SilentlyContinue | Stop-Process -Force").catch(() => {}); + } + }, 90000); });