阶段3-driver抽象: powershell助手拆至driver/windows, 五域AutomationDriver接口+WindowsDriver(PowerShell实现行为不变), desktop/window-extended/system-extended/common-final改走driver, catalog构建填充platformTag=win32

This commit is contained in:
yu 2026-11-04 13:28:32 +08:00 committed by trae-solo-agent [origin=hook_stop]
parent 3decced268
commit e691b9fa78
22 changed files with 673 additions and 338 deletions

View File

@ -8,6 +8,7 @@ RPA 能力层包:零 Electron 依赖的流程引擎、能力注册表与能力
| 模块 | 说明 |
|---|---|
| `src/engine/` | 注册表、运行时、表达式、`createEngine()` 工厂、`host-bridge.ts` 宿主桥 |
| `src/driver/` | `types.ts` 五域 AutomationDriver 接口(mouse/keyboard/window/screen/element)+ `windows/powershell.ts` 进程助手 + `windows/windows-driver.ts` PowerShell 实现 |
| `src/capabilities/` | 能力实现(副作用注册,经 `@rpa/core/capabilities` 拉起) |
| `src/browser/` | 外部浏览器 CDP:`external.ts`(Edge/Chrome 进程管理)+ `transport.ts`(CdpTransport 接口、ExternalTransport) |
@ -45,3 +46,16 @@ human-dialog 抛 `host_unavailable`。
`npm run check:deps`(根目录亦可)扫描 `src/**`:禁止 `import "electron"`、
相对引用逃出包 `src/`、引用宿主 `src/main`。阶段 2 起为提交前必跑项。
## Driver(阶段 3)
- 五域接口 `AutomationDriver`(决策点 6):`RpaEngine.driver` 默认 `new WindowsDriver()`,
`createEngine()` 可注入自定义实现(CLI/Linux driver 等未来形态)。
- 驱动只承载**原始注入原语**(单次 SetCursorPos / SendKeys / 窗口操作);拟真抖动、
DIP 坐标换算、多步组合等策略留在能力文件,保证机械替换不改时序行为。
- element 域实现承载在 `capabilities/desktop-uia.ts`(经 `setElementDriverImplementation`
注册到 WindowsDriver),handler 层面向 driver 的收口在阶段 5/6 按需进行。
- `powershell()` 助手统一位于 `driver/windows/powershell.ts`,包内能力禁止自行
`execFile("powershell.exe")`(check:deps 与 review 双重把关)。
- 平台标记:`npm run catalog` 构建时按组填充 `platformTag: "win32"`(desktop/app/
office/vision 组),渲染层置灰延后(决策点 7)。

View File

@ -4,7 +4,7 @@ import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
const mocks = vi.hoisted(() => ({ powershell: vi.fn(), execFile: vi.fn() }));
vi.mock("./desktop", () => ({ powershell: mocks.powershell, psString: (value: unknown) => `'${String(value ?? "").replace(/'/g, "''")}'` }));
vi.mock("../driver/windows/powershell", () => ({ powershell: mocks.powershell, psString: (value: unknown) => `'${String(value ?? "").replace(/'/g, "''")}'` }));
vi.mock("node:child_process", () => ({ execFile: mocks.execFile }));
import "./data";
import "./control";

View File

@ -4,7 +4,11 @@ import path from "node:path";
import { PDFDocument, StandardFonts, degrees, rgb } from "pdf-lib";
import { getHandler, register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell, psString } from "./desktop";
import { powershell, psString } from "../driver/windows/powershell";
import { defaultEngine } from "../engine/engine";
// 窗口/屏幕查询统一走驱动(任务 3.3)
const driver = defaultEngine.driver;
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) { const value = args[key]; if (value !== undefined && value !== null && value !== "") return String(value); }
@ -20,25 +24,21 @@ register("Window_GetRect", delegate("Window_GetWindowRect"));
register("Window_MaximumSize", (ctx, node, args, component) => delegate("Window_SetWindowState")(ctx, node, { ...args, WindowType: "maximize" }, component));
register(["Keyboard_DriveSend", "Keyboard_DriveSend_V1", "Keyboard_DriveSend_V2"], delegate("Keyboard_Send_V1"));
register("Window_GetScreenSize", async () => {
const output = await powershell("Add-Type -AssemblyName System.Windows.Forms; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; @{width=$b.Width;height=$b.Height;x=$b.X;y=$b.Y} | ConvertTo-Json -Compress");
return JSON.parse(output);
});
register("Window_GetScreenSize", async () => driver.screen.size());
register("Window_WindowList", async (_ctx, _node, args) => {
const title = text(args, ["match_title", "select_title"]); const regex = String(args.is_regex ?? "0") === "1";
const output = await powershell(`Get-Process | Where-Object { $_.MainWindowHandle -ne 0 ${title ? (regex ? `-and $_.MainWindowTitle -match ${psString(title)}` : `-and $_.MainWindowTitle -like ${psString(`*${title}*`)}`) : ""} } | ForEach-Object { @{pid=$_.Id;title=$_.MainWindowTitle;hwnd=$_.MainWindowHandle.ToInt64()} } | ConvertTo-Json -Compress`);
if (!output) return []; const parsed = JSON.parse(output); return Array.isArray(parsed) ? parsed : [parsed];
return driver.window.list({ title, regex });
});
register("Window_SetWindowShow", async (_ctx, node, args) => {
const title = text(args, ["窗口对象", "window_obj", "title"]); if (!title || title === "窗口对象") throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
const show = !text(args, ["WindowType"], "show").toLowerCase().includes("hide");
await powershell(`$p=Get-Process|Where-Object{$_.MainWindowTitle -like ${psString(`*${title}*`)}}|Select-Object -First 1;if(!$p){throw '找不到窗口'};Add-Type @'\nusing System;using System.Runtime.InteropServices;public static class W{[DllImport("user32.dll")]public static extern bool ShowWindow(IntPtr h,int n);}\n'@;[W]::ShowWindow($p.MainWindowHandle,${show ? 5 : 0})|Out-Null`);
await driver.window.setState(title, show ? 5 : 0);
return show;
});
register("Window_WaitWindow", async (_ctx, node, args) => {
const title = text(args, ["match_title", "select_title", "window_obj", "target_window"]); if (!title) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
const expected = text(args, ["switchType"], "0") !== "1"; const deadline = Date.now() + Math.max(0, Number(args.second ?? 5)) * 1000;
do { const exists = (await powershell(`$p=Get-Process|Where-Object{$_.MainWindowTitle -like ${psString(`*${title}*`)}}|Select-Object -First 1;[bool]$p`)).toLowerCase() === "true"; if (exists === expected) return true; await new Promise((resolve) => setTimeout(resolve, 200)); } while (Date.now() <= deadline);
do { const exists = await driver.window.exists(title); if (exists === expected) return true; await new Promise((resolve) => setTimeout(resolve, 200)); } while (Date.now() <= deadline);
return false;
});

View File

@ -13,7 +13,7 @@ const detectMock = vi.hoisted(() => vi.fn(async () => ({
// desktopCapturer 模拟: 默认无画面(走 GDI 回退), 多屏用例按请求宽度提供对应显示器画面
const electronState = vi.hoisted(() => ({ sources: [] as Array<{ display_id: string; thumbnail: any }> }));
vi.mock("./desktop", () => ({ powershell: powershellMock }));
vi.mock("../driver/windows/powershell", () => ({ powershell: powershellMock }));
vi.mock("@gutenye/ocr-node", () => ({
default: { create: vi.fn(async () => ({ detect: detectMock })) }
}));

View File

@ -1,6 +1,6 @@
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell } from "./desktop";
import { powershell } from "../driver/windows/powershell";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

View File

@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const powershellMock = vi.hoisted(() => vi.fn(async () => '{"clicked":true,"value":"cached"}'));
vi.mock("./desktop", () => ({
vi.mock("../driver/windows/powershell", () => ({
powershell: powershellMock,
psString: (value: unknown) => JSON.stringify(String(value ?? ""))
}));

View File

@ -2,30 +2,11 @@ import { register } from "../engine/registry";
import fs from "node:fs";
import path from "node:path";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell, psString } from "./desktop";
import { powershell, psString } from "../driver/windows/powershell";
import type { UiaSelector } from "../driver/types";
import { setElementDriverImplementation } from "../driver/windows/windows-driver";
interface UiaSelector {
window?: string;
name?: string;
id?: string;
/** .NET ControlType 字段名(已映射,如 ComboBox/ListItem/DataGrid) */
type?: string;
/** 用户输入的原始类型字符串 */
typeRaw?: string;
className?: string;
screen?: { x: number; y: number; w: number; h: number };
/** Relative physical rect inside the top-level window. */
rel?: { x: number; y: number; w: number; h: number };
process?: string;
winClass?: string;
enriched?: boolean;
aiType?: string;
aiText?: string;
ai?: boolean;
/** 从窗口(或桌面根)到目标元素的祖先路径,逐段用稳定属性定位 */
path?: Array<{ type?: string; name?: string; id?: string; className?: string; index?: number }>;
index: number;
}
// UiaSelector 类型已上移 driver/types.ts(任务 3.2 element 域)
/** ControlType 显式映射:type= 值 → .NET ControlType 字段名;复合名称不得用首字母大写推导 */
const UIA_CONTROL_TYPE_MAP: Record<string, string> = {
@ -1045,3 +1026,27 @@ register("Element_DesktopChildren", childrenGroundHandler(true));
register("Element_RestoreWindowGround", restoreWindowGround);
register("Element_ActivateWindowGround", activateWindowGround);
register("Element_MoveWindowGround", moveWindowGround);
/** ElementDriver 的 PowerShell 实现(任务 3.3,决策点 6):ground 原语委托本模块内部函数。
* handler 层面向 driver 接口的收口放到阶段 5/6 按需进行,避免大范围重写引入行为回归。 */
setElementDriverImplementation({
parseSelector: (raw) => parseUiaSelector(raw),
async find(selector, options) {
return findElement({ selector, args: {}, node: { id: "driver", componentId: "driver" } }, options?.timeoutMs ?? 5000, options?.gapMs ?? 200);
},
async exists(selector) {
return (await findElement({ selector, args: {}, node: { id: "driver", componentId: "driver" } }, 2000, 200)) !== null;
},
async click(selector, options) {
const t = { selector, args: {}, node: { id: "driver", componentId: "driver" } };
await runScript(clickScript(t), t, options?.timeoutMs ?? 60000);
},
async setValue(selector, value) {
const t = { selector, args: {}, node: { id: "driver", componentId: "driver" } };
await runScript(setValueScript(t, value), t);
},
async getValue(selector) {
const t = { selector, args: {}, node: { id: "driver", componentId: "driver" } };
return runScript(getValueScript(t), t);
}
});

View File

@ -1,13 +1,12 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { type ManualOperationSettings, defaultEngine } from "../engine/engine";
const execFileAsync = promisify(execFile);
import { powershell, psString } from "../driver/windows/powershell";
// 人为拟真设置本体挂在引擎实例上(任务 1.3),此处委托 defaultEngine。
const manualOperation: ManualOperationSettings = defaultEngine.manualOperation;
// 桌面自动化驱动(任务 3.3):user32 注入原语统一走 WindowsDriver
const driver = defaultEngine.driver;
function jitter(value: number, amount = 3): number {
return Math.max(0, Math.round(value + (Math.random() * amount * 2 - amount)));
@ -26,18 +25,6 @@ async function manualDelay(): Promise<void> {
const timeout = low + Math.random() * (high - low);
if (timeout > 0) await new Promise((resolve) => setTimeout(resolve, timeout));
}
const USER32 = `Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class RpaUser32 {
[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int count);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
}
'@`;
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) {
if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
@ -65,82 +52,6 @@ function point(value: unknown): [number, number] {
}
}
function encoded(command: string): string {
return Buffer.from(command, "utf16le").toString("base64");
}
/** 从 PowerShell 错误输出里提取 CLIXML 段的真实错误文本(否则显示为乱码转义序列) */
function decodeCliXml(detail: string): string {
const lines = [...detail.matchAll(/<S S="Error">(.*?)<\/S>/gs)].map((m) => m[1]);
if (lines.length === 0) return detail;
// _xHHHH_ 是 CLIXML 转义
const decoded = lines.join("\n").replace(/_x([0-9A-Fa-f]{4})_/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
const parts = decoded.split("\n").map((l) => l.trim());
const position = parts.filter((l) => l.startsWith("所在位置")).slice(0, 1);
const message = parts.filter(
(l) => l && !l.startsWith("所在位置") && !l.startsWith("+") && !l.startsWith("~~") && !l.includes("CategoryInfo") && !l.includes("FullyQualifiedErrorId") && !l.startsWith("<Objs") && !l.startsWith("#<")
);
const main = message[0] || parts[0] || detail;
return position.length ? `${main}(${position[0]})` : main;
}
/** 管道字节流解码:优先 UTF-8;出现替换符说明是系统代码页(GBK)写入的(如解析类错误不走 UTF-8 前缀),回退 GB18030 */
function decodePipe(buf: Buffer): string {
const utf8 = buf.toString("utf8");
if (!utf8.includes("\uFFFD")) return utf8;
try {
return new TextDecoder("gb18030").decode(buf);
} catch {
return utf8;
}
}
export async function powershell(command: string, timeout = 30000): Promise<string> {
try {
// Windows PowerShell 的默认输出编码依机器代码页变化;Node 按 UTF-8 解码管道。
const utf8 = "$ProgressPreference='SilentlyContinue'; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $OutputEncoding=[Console]::OutputEncoding; ";
const result = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded(utf8 + command)], {
windowsHide: true,
timeout,
maxBuffer: 4 * 1024 * 1024,
encoding: "buffer"
} as never);
return decodePipe(result.stdout as unknown as Buffer).trim();
} catch (error) {
const err = error as Error & { stdout?: unknown; stderr?: unknown };
// 脚本主动失败(RpaFail)把完整错误写在 stdout 的 RPA_ERROR:: 标记后,不经 CLIXML 渲染,无截断
const stdoutText = Buffer.isBuffer(err.stdout) ? decodePipe(err.stdout) : String(err.stdout ?? "");
const marked = stdoutText.match(/RPA_ERROR::([\s\S]*)/);
if (marked) {
const detail = marked[1].trim();
throw new RuntimeError("desktop_command_failed", `桌面操作失败:${detail}`, { detail });
}
const stderrBuf = Buffer.isBuffer(err.stderr) ? err.stderr : Buffer.from(String(err.stderr ?? ""), "utf8");
const stderrText = stderrBuf.length > 0 ? decodePipe(stderrBuf) : err.message;
const detail = decodeCliXml(stderrText);
throw new RuntimeError("desktop_command_failed", `桌面操作失败:${detail}`, { detail });
}
}
export function psString(value: unknown): string {
return `'${String(value ?? "").replace(/'/g, "''")}'`;
}
/** 把 SendKeys 特殊字符按字面转义(+^%~(){}[] → {+} 等);SendKeys 用花括号表示字面量 */
function sendKeysEscape(ch: string): string {
return "+^%~(){}[]".includes(ch) ? `{${ch}}` : ch;
}
/**
* 把字符包装成 PowerShell 安全字面量。
* ASCII 直接用单引号字符串(撇号内部 '' 转义);非 ASCII 用 [char]0xXXXX 数值构造——
* Windows PowerShell 5.1 会把 ‘ ’ “ ” 等智能引号当作引号变体解析,直接放进字符串字面量会报「字符串缺少终止符」。
*/
function psCharLiteral(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("");
}
const keyboardSend: CapabilityHandler = async (_ctx, node, args) => {
// 字段: words=输入内容(键盘输入), keys/key=按键组合(按键系列), interval=输入间隔(V1毫秒/基础版秒), fuctype=输入方式(0后台接口/1人工模拟/2剪贴板粘贴), window_title=输入目标窗口(留空=当前焦点窗口)
const keys = text(args, ["words", "keys", "key", "text", "value"]);
@ -152,27 +63,7 @@ const keyboardSend: CapabilityHandler = async (_ctx, node, args) => {
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; ` : "";
// 三种输入方式:脚本尽量简单,转义逻辑放 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')`;
} else if (mode === "1" && isTextSend) {
// 人工模拟:逐字符;JS 侧把每个字符转义成 SendKeys 字面量,PS 侧只做数组循环
// 数组元素必须用 psCharLiteral:ASCII 走引号字符串,非 ASCII 走 [char]0xXXXX(避开 PS5.1 智能引号解析陷阱)
const escapedChars = Array.from(keys).map((ch) => sendKeysEscape(ch));
const arr = escapedChars.map((c) => psCharLiteral(c)).join(",");
send = `$chars = @(${arr}); foreach ($c in $chars) { [System.Windows.Forms.SendKeys]::SendWait([string]$c); Start-Sleep -Milliseconds ${intervalMs} }`;
} 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})`;
}
await powershell(`${USER32}; ${activate}Add-Type -AssemblyName System.Windows.Forms; ${send}`);
await driver.keyboard.send({ keys, intervalMs, mode: mode === "2" ? "clipboard" : mode === "1" ? "manual" : "api", textMode: isTextSend, activateTitle: targetTitle || undefined });
await manualDelay();
return keys;
};
@ -197,89 +88,47 @@ const disableManualOperation: CapabilityHandler = async () => {
const mouseClick: CapabilityHandler = async (_ctx, node, args) => {
const [x, y] = manualJitter(point(args.position ?? args.point ?? args.location ?? "[0,0]"), false, true);
const button = text(args, ["side", "button"], "0").toLowerCase();
const down = button.includes("right") || button === "1" ? 0x0008 : 0x0002;
const up = button.includes("right") || button === "1" ? 0x0010 : 0x0004;
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${x},${y}); [RpaUser32]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); [RpaUser32]::mouse_event(${up},0,0,0,[UIntPtr]::Zero)`);
await driver.mouse.click({ x, y }, { button: button.includes("right") || button === "1" ? "right" : "left" });
return { x, y };
};
const mouseMove: CapabilityHandler = async (_ctx, node, args) => {
const [x, y] = manualJitter(point(args.position ?? args.point ?? args.location ?? "[0,0]"), true, false);
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${x},${y})`);
await driver.mouse.moveTo({ x, y });
return { x, y };
};
const mouseDrag: CapabilityHandler = async (_ctx, node, args) => {
const [sx, sy] = point(args.Start ?? args.start ?? "[0,0]");
const [ex, ey] = point(args.End ?? args.end ?? "[0,0]");
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${sx},${sy}); [RpaUser32]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 80; [RpaUser32]::SetCursorPos(${ex},${ey}); Start-Sleep -Milliseconds 80; [RpaUser32]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)`);
await driver.mouse.drag({ x: sx, y: sy }, { x: ex, y: ey }, { holdMs: 80, releaseDelayMs: 80 });
return { start: [sx, sy], end: [ex, ey] };
};
const mouseRoll: CapabilityHandler = async (_ctx, node, args) => {
const amount = number(args, ["RollNum", "rollNum", "amount"], 1) * 120;
await powershell(`${USER32}; [RpaUser32]::mouse_event(0x0800,0,0,${amount},[UIntPtr]::Zero)`);
await driver.mouse.scroll(amount);
return amount;
};
/** 把匹配标题的窗口带到前台;多招式依次兜底(应用点击「运行」后自身是前台,
* Windows 前台锁定策略会拒绝普通 SetForegroundWindow,最小化再恢复可绕过)。
* 失败用 RPA_ERROR 标记写 stdout 后 exit 1:不走 throw/CLIXML,避免控制台宽度截断和编码歧义 */
function activateWindowScript(selector: string): string {
const safeTitle = selector.replace(/'/g, "''");
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) }
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 }
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) {
[void][WinRpaFocus2.RpaFocus2]::SwitchToThisWindow($h, $true)
Start-Sleep -Milliseconds 150
}
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) {
[void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 6)
Start-Sleep -Milliseconds 150
[void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 9)
Start-Sleep -Milliseconds 300
}
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) { RpaFail '无法把窗口带到前台,请确认窗口未被关闭或全屏占用' }`;
}
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;
return driver.window.focus(selector);
};
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 getActiveWindow: CapabilityHandler = async () => driver.window.foregroundTitle();
const windowExists: 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 });
return (await powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; [bool]$p`)).toLowerCase() === "true";
return driver.window.exists(selector);
};
const closeWindow: 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 });
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`);
return driver.window.close(selector);
};
const runApp: CapabilityHandler = async (_ctx, node, args) => {

View File

@ -3,7 +3,7 @@ import path from "node:path";
import ExcelJS from "exceljs";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell, psString } from "./desktop";
import { powershell, psString } from "../driver/windows/powershell";
import { releaseWorkbook } from "./excel-store";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {

View File

@ -1,7 +1,7 @@
import nodemailer from "nodemailer";
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { powershell } from "./desktop";
import { powershell } from "../driver/windows/powershell";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);

View File

@ -5,7 +5,7 @@ import { runFlow } from "../engine/runtime";
import { RuntimeError, type CapabilityHandler, type RunContext } from "../engine/types";
import { defaultEngine } from "../engine/engine";
import { getSubflowSource } from "../engine/host-bridge";
import { powershell, psString } from "./desktop";
import { powershell, psString } from "../driver/windows/powershell";
import { findImage } from "./vision";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {

View File

@ -2,7 +2,8 @@ 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 } from "../driver/windows/powershell";
import { defaultEngine } from "../engine/engine";
function value(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) {
@ -31,20 +32,8 @@ function selector(args: Record<string, unknown>): string {
return String(raw ?? "");
}
const WINDOW_API = `Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class RpaWindowApi {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int command);
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int width, int height, uint flags);
}
'@`;
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, "''")}' }`;
}
// 窗口矩形/状态操作统一走驱动(任务 3.3)
const driver = defaultEngine.driver;
register("System_GetEnv", (_ctx, node, args) => {
const name = required(args, ["name"], "环境变量名称", node.id, node.componentId);
@ -115,15 +104,14 @@ register("Clipboard_ClearClipboard", async () => {
register("Window_GetWindowRect", async (_ctx, node, args) => {
const title = required({ title: selector(args) }, ["title"], "窗口标题", node.id, node.componentId);
const output = await powershell(`${WINDOW_API}; ${findWindowScript(title)}; $r = New-Object RpaWindowApi+RECT; [RpaWindowApi]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; @{x=$r.Left;y=$r.Top;width=$r.Right-$r.Left;height=$r.Bottom-$r.Top;title=$p.MainWindowTitle;pid=$p.Id} | ConvertTo-Json -Compress`);
return JSON.parse(output);
return driver.window.findByTitle(title);
});
register(["Window_SetWindowState", "Window_SetWindowState_v1"], async (_ctx, node, args) => {
const title = required({ title: selector(args) }, ["title"], "窗口标题", node.id, node.componentId);
const state = value(args, ["WindowType", "switchType"], "maximize").toLowerCase();
const command = state.includes("min") ? 6 : state.includes("max") ? 3 : 9;
await powershell(`${WINDOW_API}; ${findWindowScript(title)}; [RpaWindowApi]::ShowWindow($p.MainWindowHandle,${command}) | Out-Null`);
await driver.window.setState(title, command);
return state;
});
@ -136,6 +124,6 @@ register("Window_SizeWindow_v1", async (_ctx, node, args) => {
}
const [width, height] = Array.isArray(size) ? size.map(Number) : [0, 0];
if (!(width > 0 && height > 0)) throw new RuntimeError("validation_error", "窗口尺寸无效", { nodeId: node.id, componentId: node.componentId });
await powershell(`${WINDOW_API}; ${findWindowScript(title)}; $r = New-Object RpaWindowApi+RECT; [RpaWindowApi]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; [RpaWindowApi]::SetWindowPos($p.MainWindowHandle,[IntPtr]::Zero,$r.Left,$r.Top,${width},${height},4) | Out-Null`);
await driver.window.setSize(title, width, height);
return { width, height };
});

View File

@ -3,7 +3,7 @@ import { promises as fs } from "node:fs";
import { PNG } from "pngjs";
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { powershell, psString } from "./desktop";
import { powershell, psString } from "../driver/windows/powershell";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);

View File

@ -1,25 +1,10 @@
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell, psString } from "./desktop";
import { getHostBridge } from "../engine/host-bridge";
import { defaultEngine } from "../engine/engine";
const WINDOW_API = `Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class RpaWindowExtended {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int command);
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int width, int height, uint flags);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT point);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
}
'@; try { [void][RpaWindowExtended]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {}`;
// 桌面自动化驱动(任务 3.3):窗口矩形/鼠标注入原语统一走 WindowsDriver
const driver = defaultEngine.driver;
function value(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) {
@ -49,10 +34,6 @@ function requiredTitle(args: Record<string, unknown>, nodeId: string, componentI
return title;
}
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, "''")}' }`;
}
function parsePoint(candidate: unknown, label: string, nodeId: string, componentId: string): [number, number] {
let parsed: unknown = candidate;
if (typeof parsed === "string") {
@ -70,70 +51,6 @@ function parsePoint(candidate: unknown, label: string, nodeId: string, component
return [Number(parsed[0]), Number(parsed[1])];
}
async function windowObject(title: string): Promise<Record<string, unknown>> {
const output = await powershell(`${WINDOW_API}; ${findWindow(title)}; $r = New-Object RpaWindowExtended+RECT; [RpaWindowExtended]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; @{pid=$p.Id;title=$p.MainWindowTitle;hwnd=$p.MainWindowHandle.ToInt64();x=$r.Left;y=$r.Top;width=$r.Right-$r.Left;height=$r.Bottom-$r.Top} | ConvertTo-Json -Compress`);
return JSON.parse(output);
}
const getWindow: CapabilityHandler = async (_ctx, node, args) => windowObject(requiredTitle(args, node.id, node.componentId));
register(["Window_Get", "Window_GetWindow", "Window_GetWindow_v1"], getWindow);
register("Window_ActiveWindow_v1", async (_ctx, node, args) => {
const title = requiredTitle(args, node.id, node.componentId);
const maximize = [true, 1, "1", "true", "True"].includes(args.isMax as never);
await powershell(`${WINDOW_API}; ${findWindow(title)}; [RpaWindowExtended]::SetForegroundWindow($p.MainWindowHandle) | Out-Null; ${maximize ? "[RpaWindowExtended]::ShowWindow($p.MainWindowHandle,3) | Out-Null" : ""}`);
return windowObject(title);
});
register(["Window_GetWindowMessage", "Window_GetWindowMessage_v1"], async (_ctx, node, args) => {
const object = await windowObject(requiredTitle(args, node.id, node.componentId));
const type = value(args, ["switchType"], "all").toLowerCase();
if (type === "title") return object.title;
if (type === "pid") return object.pid;
if (type === "position" || type === "location") return [object.x, object.y];
if (type === "size") return [object.width, object.height];
return object;
});
register(["Window_SetPos", "Window_MoveWindow", "Window_MoveWindow_v1", "Window_SizeWindow"], async (_ctx, node, args) => {
const title = requiredTitle(args, node.id, node.componentId);
const current = await windowObject(title);
const moveOnly = node.componentId.includes("MoveWindow");
const sizeOnly = node.componentId === "Window_SizeWindow";
const position = args.location !== undefined ? parsePoint(args.location, "窗口位置", node.id, node.componentId) : [Number(args.x ?? current.x), Number(args.y ?? current.y)] as [number, number];
const sizeCandidate = args.size ?? args.window_size;
const size = sizeCandidate !== undefined ? parsePoint(sizeCandidate, "窗口尺寸", node.id, node.componentId) : [Number(args.width ?? current.width), Number(args.height ?? current.height)] as [number, number];
const x = sizeOnly ? Number(current.x) : position[0];
const y = sizeOnly ? Number(current.y) : position[1];
const width = moveOnly ? Number(current.width) : size[0];
const height = moveOnly ? Number(current.height) : size[1];
if (!(width > 0 && height > 0)) throw new RuntimeError("validation_error", "窗口尺寸必须大于 0", { nodeId: node.id, componentId: node.componentId });
await powershell(`${WINDOW_API}; ${findWindow(title)}; [RpaWindowExtended]::SetWindowPos($p.MainWindowHandle,[IntPtr]::Zero,${x},${y},${width},${height},4) | Out-Null`);
return { ...(await windowObject(title)), x, y, width, height };
});
/** 生成从当前位置到目标的插值路径点(JS 侧算好,PS 侧只执行循环) */
function mousePath(from: [number, number], to: [number, number], mode: string, speed: number): Array<[number, number]> {
const dx = to[0] - from[0];
const dy = to[1] - from[1];
const distance = Math.hypot(dx, dy);
if (distance < 2) return [to];
const stepCount = Math.max(6, Math.min(50, Math.round(distance / 12)));
const points: Array<[number, number]> = [];
for (let i = 1; i <= stepCount; i++) {
const t = i / stepCount;
// 模拟人工:缓入缓出 + 轻微垂直抖动;匀速直线:线性
const ease = mode === "0" ? (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2) : t;
const jitter = mode === "0" ? Math.sin(t * Math.PI) * (Math.sin(i * 12.9898) * 43758.5453 % 1) * 3 : 0;
points.push([
Math.round(from[0] + dx * ease - (dy / distance) * jitter),
Math.round(from[1] + dy * ease + (dx / distance) * jitter)
]);
}
points.push(to);
return points;
}
/** 界面(框选/元素拾取/截图)产出的是 DIP 逻辑坐标;DPI 感知进程的 SetCursorPos 用物理像素,
* 缩放屏(125%/150%)下直接混用会成比例偏移(用户实测 125% 下差约 25% 距离)。
* 统一在执行前换算;宿主桥未注入时(vitest)回退原值保持旧行为 */
@ -160,6 +77,41 @@ function physicalToDip(x: number, y: number): [number, number] {
return [Math.round(x), Math.round(y)];
}
const getWindow: CapabilityHandler = async (_ctx, node, args) => driver.window.findByTitle(requiredTitle(args, node.id, node.componentId));
register(["Window_Get", "Window_GetWindow", "Window_GetWindow_v1"], getWindow);
register("Window_ActiveWindow_v1", async (_ctx, node, args) => {
const title = requiredTitle(args, node.id, node.componentId);
const maximize = [true, 1, "1", "true", "True"].includes(args.isMax as never);
return driver.window.activate(title, { maximize });
});
register(["Window_GetWindowMessage", "Window_GetWindowMessage_v1"], async (_ctx, node, args) => {
const object = await driver.window.findByTitle(requiredTitle(args, node.id, node.componentId));
const type = value(args, ["switchType"], "all").toLowerCase();
if (type === "title") return object.title;
if (type === "pid") return object.pid;
if (type === "position" || type === "location") return [object.x, object.y];
if (type === "size") return [object.width, object.height];
return object;
});
register(["Window_SetPos", "Window_MoveWindow", "Window_MoveWindow_v1", "Window_SizeWindow"], async (_ctx, node, args) => {
const title = requiredTitle(args, node.id, node.componentId);
const current = await driver.window.findByTitle(title);
const moveOnly = node.componentId.includes("MoveWindow");
const sizeOnly = node.componentId === "Window_SizeWindow";
const position = args.location !== undefined ? parsePoint(args.location, "窗口位置", node.id, node.componentId) : [Number(args.x ?? current.x), Number(args.y ?? current.y)] as [number, number];
const sizeCandidate = args.size ?? args.window_size;
const size = sizeCandidate !== undefined ? parsePoint(sizeCandidate, "窗口尺寸", node.id, node.componentId) : [Number(args.width ?? current.width), Number(args.height ?? current.height)] as [number, number];
const x = sizeOnly ? Number(current.x) : position[0];
const y = sizeOnly ? Number(current.y) : position[1];
const width = moveOnly ? Number(current.width) : size[0];
const height = moveOnly ? Number(current.height) : size[1];
if (!(width > 0 && height > 0)) throw new RuntimeError("validation_error", "窗口尺寸必须大于 0", { nodeId: node.id, componentId: node.componentId });
return driver.window.setPos(title, { x, y, width, height });
});
const moveMouse: CapabilityHandler = async (_ctx, node, args) => {
// 注意:position 字段是「坐标相对位置」(0=屏幕左上角/1=激活窗口左上角),坐标本体在 location
const [rawX, rawY] = parsePoint(args.location ?? args.point, "鼠标坐标", node.id, node.componentId);
@ -170,33 +122,22 @@ const moveMouse: CapabilityHandler = async (_ctx, node, args) => {
let dipTarget: [number, number];
if (relative) {
// 相对激活窗口左上角:窗口矩形是物理像素,换回 DIP 再叠加偏移,最后统一转物理
const output = await powershell(`${WINDOW_API}; $fw = [RpaWindowExtended]::GetForegroundWindow(); $r = New-Object RpaWindowExtended+RECT; [void][RpaWindowExtended]::GetWindowRect($fw, [ref]$r); "$($r.Left),$($r.Top)"`);
const [wx, wy] = output.split(",").map(Number);
const { x: wx, y: wy } = await driver.window.foregroundPosition();
if (!Number.isFinite(wx) || !Number.isFinite(wy)) throw new RuntimeError("browser_error", "无法获取激活窗口位置", { nodeId: node.id, componentId: node.componentId });
const [dipWx, dipWy] = physicalToDip(wx, wy);
dipTarget = [dipWx + rawX, dipWy + rawY];
} else {
dipTarget = [rawX, rawY];
}
return moveToSmooth(dipTarget, moveType, speed);
};
async function moveToSmooth(dipTarget: [number, number], moveType: string, speed: number): Promise<{ x: number; y: number }> {
const [tx, ty] = dipToPhysical(dipTarget[0], dipTarget[1]);
if (moveType === "2") {
await powershell(`${WINDOW_API}; [void][RpaWindowExtended]::SetCursorPos(${tx},${ty})`);
return { x: Math.round(dipTarget[0]), y: Math.round(dipTarget[1]) };
await driver.mouse.moveTo({ x: tx, y: ty });
} else {
// 平滑移动:从当前光标位置出发,按速度插值(路径全程物理像素,与目标同一坐标系)
await driver.mouse.moveSmooth({ x: tx, y: ty }, { ease: moveType === "0", speed });
}
// 平滑移动:从当前光标位置出发,按速度插值(路径全程物理像素,与目标同一坐标系)
const delay = Math.min(80, Math.max(4, Math.round(1500 / speed)));
const output = await powershell(`${WINDOW_API}; $cur = [RpaWindowExtended+POINT]::new(); [void][RpaWindowExtended]::GetCursorPos([ref]$cur); "$($cur.X),$($cur.Y)"`);
const [cx, cy] = output.split(",").map(Number);
const from: [number, number] = Number.isFinite(cx) && Number.isFinite(cy) ? [cx, cy] : [0, 0];
const path = mousePath(from, [tx, ty], moveType, speed);
const ptsLiteral = path.map(([px, py]) => `@(${px},${py})`).join(",");
await powershell(`${WINDOW_API}; $pts = @(${ptsLiteral}); foreach ($pt in $pts) { [void][RpaWindowExtended]::SetCursorPos($pt[0], $pt[1]); Start-Sleep -Milliseconds ${delay} }`);
return { x: Math.round(dipTarget[0]), y: Math.round(dipTarget[1]) };
}
};
register(["Mouse_MoveToV1", "Mouse_MoveToV2"], moveMouse);
const dragMouse: CapabilityHandler = async (_ctx, node, args) => {
@ -205,9 +146,8 @@ const dragMouse: CapabilityHandler = async (_ctx, node, args) => {
const [spx, spy] = dipToPhysical(sx, sy);
const [epx, epy] = dipToPhysical(ex, ey);
const right = ["1", "right"].includes(value(args, ["side"], "0").toLowerCase());
const down = right ? 8 : 2;
const up = right ? 16 : 4;
await powershell(`${WINDOW_API}; [RpaWindowExtended]::SetCursorPos(${spx},${spy}) | Out-Null; [RpaWindowExtended]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 80; [RpaWindowExtended]::SetCursorPos(${epx},${epy}) | Out-Null; [RpaWindowExtended]::mouse_event(${up},0,0,0,[UIntPtr]::Zero)`);
// 旧实现按下后停 80ms、拖到终点立即松开(与桌面版 Window_Mouse 不同,松开前不停顿)
await driver.mouse.drag({ x: spx, y: spy }, { x: epx, y: epy }, { button: right ? "right" : "left", holdMs: 80, releaseDelayMs: 0 });
return { start: [Math.round(sx), Math.round(sy)], end: [Math.round(ex), Math.round(ey)] };
};
register(["Mouse_DragToV1", "Mouse_DragToV2", "Mouse_DragToNew"], dragMouse);
@ -218,9 +158,7 @@ register(["Mouse_ClickCombine", "Mouse_ClickCombine_v1", "Mouse_ClickCombine_v2"
const [px, py] = dipToPhysical(x, y);
const right = value(args, ["click_key"], "left").toLowerCase() === "right";
const doubleClick = value(args, ["click_time"], "click").toLowerCase().includes("double");
const down = right ? 8 : 2;
const up = right ? 16 : 4;
const repeats = doubleClick ? 2 : 1;
await powershell(`${WINDOW_API}; [RpaWindowExtended]::SetCursorPos(${px},${py}) | Out-Null; 1..${repeats} | ForEach-Object { [RpaWindowExtended]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); [RpaWindowExtended]::mouse_event(${up},0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 80 }`);
return { x: Math.round(x), y: Math.round(y), button: right ? "right" : "left", clicks: repeats };
// 旧实现每次 down-up 之后停 80ms
await driver.mouse.click({ x: px, y: py }, { button: right ? "right" : "left", double: doubleClick, settleMs: 80 });
return { x: Math.round(x), y: Math.round(y), button: right ? "right" : "left", clicks: doubleClick ? 2 : 1 };
});

View File

@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
import path from "node:path";
import { getHandler, register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { powershell, psString } from "./desktop";
import { powershell, psString } from "../driver/windows/powershell";
interface WordHandle { path: string; }

View File

@ -1,6 +1,6 @@
import path from "node:path";
import { register } from "../engine/registry";
import { powershell, psString } from "./desktop";
import { powershell, psString } from "../driver/windows/powershell";
interface WordHandle { path: string; }

View File

@ -0,0 +1,165 @@
/**
* AutomationDriver 驱动抽象(任务 3.2,决策点 6:五域接口 + 机械包一层)。
*
* 接口签名以现有 PowerShell 能力的输入输出为准,不重新发明:
* WindowsDriver(driver/windows/windows-driver.ts)是 PowerShell 实现,行为与
* 改造前完全一致;引擎创建时注入 driver(createEngine 可注入自定义实现)。
*
* 约定:driver 只承载「原始注入原语」(单个 SendKeys/一次 SetCursorPos/一次
* 窗口操作);带拟真抖动、坐标换算、多步组合的「策略」仍留在能力文件,
* 保证机械替换不改任何时序行为。
*/
/** UIA 选择器(element 域类型,能力与 driver 共用) */
export interface UiaSelector {
window?: string;
name?: string;
id?: string;
/** .NET ControlType 字段名(已映射,如 ComboBox/ListItem/DataGrid) */
type?: string;
/** 用户输入的原始类型字符串 */
typeRaw?: string;
className?: string;
screen?: { x: number; y: number; w: number; h: number };
/** Relative physical rect inside the top-level window. */
rel?: { x: number; y: number; w: number; h: number };
process?: string;
winClass?: string;
enriched?: boolean;
aiType?: string;
aiText?: string;
ai?: boolean;
/** 从窗口(或桌面根)到目标元素的祖先路径,逐段用稳定属性定位 */
path?: Array<{ type?: string; name?: string; id?: string; className?: string; index?: number }>;
index: number;
}
export interface DriverPoint {
x: number;
y: number;
}
export interface DriverRect {
x: number;
y: number;
width: number;
height: number;
}
export interface WindowInfo extends DriverRect {
pid: number;
title: string;
hwnd: number;
}
export interface WindowSummary {
pid: number;
title: string;
hwnd: number;
}
export type MouseButton = "left" | "right";
export interface MouseClickOptions {
button?: MouseButton;
double?: boolean;
/** 每次 down-up 后的停顿毫秒(0 = 不停顿,保持旧桌面能力的紧凑时序) */
settleMs?: number;
}
export interface MouseDragOptions {
button?: MouseButton;
/** 按下后的按住毫秒 */
holdMs?: number;
/** 拖到终点后、松开前的停顿毫秒 */
releaseDelayMs?: number;
}
export interface MouseSmoothOptions {
/** true=缓入缓出+轻微抖动(拟真),false=匀速直线 */
ease?: boolean;
/** 1~100,越大越快 */
speed?: number;
}
/** mouse 域:坐标一律为物理像素(DIP 换算属于能力层策略) */
export interface MouseDriver {
moveTo(point: DriverPoint): Promise<void>;
click(point: DriverPoint, options?: MouseClickOptions): Promise<void>;
drag(from: DriverPoint, to: DriverPoint, options?: MouseDragOptions): Promise<void>;
scroll(amount: number): Promise<void>;
/** 从当前光标位置出发按速度插值移动到目标 */
moveSmooth(target: DriverPoint, options?: MouseSmoothOptions): Promise<void>;
}
export interface KeyboardSendOptions {
keys: string;
intervalMs: number;
/** api=后台接口整段发送 / manual=逐字符拟真 / clipboard=剪贴板粘贴 */
mode: "api" | "manual" | "clipboard";
/** 文本类组件做 SendKeys 字面转义;按键组合组件原样发送 */
textMode: boolean;
/** 目标窗口标题(非空时先多招式激活再输入,与旧行为一致) */
activateTitle?: string;
}
/** keyboard 域 */
export interface KeyboardDriver {
send(options: KeyboardSendOptions): Promise<void>;
}
export interface WindowDriver {
/** 按标题模糊匹配窗口并返回矩形信息(GetWindowRect,物理像素) */
findByTitle(title: string): Promise<WindowInfo>;
/** 简单激活(SetForegroundWindow + 可选最大化),返回窗口信息 */
activate(title: string, options?: { maximize?: boolean }): Promise<WindowInfo>;
/** 多招式前台激活(失败抛错),返回窗口主标题 */
focus(title: string): Promise<string>;
/** 前台窗口标题 */
foregroundTitle(): Promise<string>;
/** 前台窗口矩形左上角(物理像素) */
foregroundPosition(): Promise<DriverPoint>;
exists(title: string): Promise<boolean>;
/** CloseMainWindow 结果("True"/"False") */
close(title: string): Promise<string>;
/** ShowWindow 命令(6=最小化 3=最大化 9=恢复 5=显示 0=隐藏) */
setState(title: string, showCommand: number): Promise<void>;
/** 只改尺寸,保持左上角不动 */
setSize(title: string, width: number, height: number): Promise<void>;
setPos(title: string, rect: DriverRect): Promise<WindowInfo>;
list(filter?: { title?: string; regex?: boolean }): Promise<WindowSummary[]>;
}
/** screen 域(截图等 System.Drawing 组合操作不在接口面内,能力层可继续用 powershell()) */
export interface ScreenDriver {
/** 主屏边界(System.Windows.Forms.Screen.PrimaryScreen.Bounds) */
size(): Promise<{ x: number; y: number; width: number; height: number }>;
}
export interface ElementFindOptions {
timeoutMs?: number;
gapMs?: number;
}
/**
* element 域(UIA)。PowerShell 实现承载在 capabilities/desktop-uia.ts
* (ground 原语集),WindowsDriver.element 委托该实现;能力 handler 层
* 到 driver 形式的收口在阶段 5/6 按需进行,避免行为回归。
*/
export interface ElementDriver {
parseSelector(raw: unknown): UiaSelector;
find(selector: UiaSelector, options?: ElementFindOptions): Promise<Record<string, unknown> | null>;
exists(selector: UiaSelector): Promise<boolean>;
click(selector: UiaSelector, options?: ElementFindOptions): Promise<void>;
setValue(selector: UiaSelector, value: string): Promise<void>;
getValue(selector: UiaSelector): Promise<string | Record<string, unknown> | null>;
}
/** 五域聚合,注入点(RpaEngine.driver) */
export interface AutomationDriver {
readonly mouse: MouseDriver;
readonly keyboard: KeyboardDriver;
readonly window: WindowDriver;
readonly screen: ScreenDriver;
readonly element: ElementDriver;
}

View File

@ -0,0 +1,74 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { RuntimeError } from "../../engine/types";
/**
* PowerShell 进程助手(任务 3.1 从 capabilities/desktop.ts 拆出)。
*
* 包内所有 PowerShell 调用必须经此助手(开发规范 v1 第二节第 3 条),
* 禁止各能力文件自行 execFile("powershell.exe", ...);
* 阶段 3.5(可选)将统一切换为常驻 worker,接口签名不变。
*/
const execFileAsync = promisify(execFile);
function encoded(command: string): string {
return Buffer.from(command, "utf16le").toString("base64");
}
/** 从 PowerShell 错误输出里提取 CLIXML 段的真实错误文本(否则显示为乱码转义序列) */
function decodeCliXml(detail: string): string {
const lines = [...detail.matchAll(/<S S="Error">(.*?)<\/S>/gs)].map((m) => m[1]);
if (lines.length === 0) return detail;
// _xHHHH_ 是 CLIXML 转义
const decoded = lines.join("\n").replace(/_x([0-9A-Fa-f]{4})_/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
const parts = decoded.split("\n").map((l) => l.trim());
const position = parts.filter((l) => l.startsWith("所在位置")).slice(0, 1);
const message = parts.filter(
(l) => l && !l.startsWith("所在位置") && !l.startsWith("+") && !l.startsWith("~~") && !l.includes("CategoryInfo") && !l.includes("FullyQualifiedErrorId") && !l.startsWith("<Objs") && !l.startsWith("#<")
);
const main = message[0] || parts[0] || detail;
return position.length ? `${main}(${position[0]})` : main;
}
/** 管道字节流解码:优先 UTF-8;出现替换符说明是系统代码页(GBK)写入的(如解析类错误不走 UTF-8 前缀),回退 GB18030 */
function decodePipe(buf: Buffer): string {
const utf8 = buf.toString("utf8");
if (!utf8.includes("\uFFFD")) return utf8;
try {
return new TextDecoder("gb18030").decode(buf);
} catch {
return utf8;
}
}
export async function powershell(command: string, timeout = 30000): Promise<string> {
try {
// Windows PowerShell 的默认输出编码依机器代码页变化;Node 按 UTF-8 解码管道。
const utf8 = "$ProgressPreference='SilentlyContinue'; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $OutputEncoding=[Console]::OutputEncoding; ";
const result = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded(utf8 + command)], {
windowsHide: true,
timeout,
maxBuffer: 4 * 1024 * 1024,
encoding: "buffer"
} as never);
return decodePipe(result.stdout as unknown as Buffer).trim();
} catch (error) {
const err = error as Error & { stdout?: unknown; stderr?: unknown };
// 脚本主动失败(RpaFail)把完整错误写在 stdout 的 RPA_ERROR:: 标记后,不经 CLIXML 渲染,无截断
const stdoutText = Buffer.isBuffer(err.stdout) ? decodePipe(err.stdout) : String(err.stdout ?? "");
const marked = stdoutText.match(/RPA_ERROR::([\s\S]*)/);
if (marked) {
const detail = marked[1].trim();
throw new RuntimeError("desktop_command_failed", `桌面操作失败:${detail}`, { detail });
}
const stderrBuf = Buffer.isBuffer(err.stderr) ? err.stderr : Buffer.from(String(err.stderr ?? ""), "utf8");
const stderrText = stderrBuf.length > 0 ? decodePipe(stderrBuf) : err.message;
const detail = decodeCliXml(stderrText);
throw new RuntimeError("desktop_command_failed", `桌面操作失败:${detail}`, { detail });
}
}
export function psString(value: unknown): string {
return `'${String(value ?? "").replace(/'/g, "''")}'`;
}

View File

@ -0,0 +1,272 @@
import type {
AutomationDriver,
DriverPoint,
DriverRect,
ElementDriver,
KeyboardSendOptions,
MouseClickOptions,
MouseDragOptions,
MouseSmoothOptions,
MouseDriver,
KeyboardDriver,
ScreenDriver,
WindowDriver,
WindowInfo,
WindowSummary
} from "../types";
import { powershell, psString } from "./powershell";
/**
* WindowsDriver(任务 3.3):AutomationDriver 的 PowerShell 实现。
* 脚本与改造前各能力文件内的片段逐字一致(仅 Add-Type 块收拢到本文件),
* 行为不变;坐标一律物理像素,DIP 换算等策略留在能力层。
*/
/** 基础注入原语(原 capabilities/desktop.ts 的 USER32 块,补 SetProcessDpiAwarenessContext:
* 原 window-extended.ts 的 WINDOW_API 在同一进程内已设置 per-monitor DPI 感知,
* 驱动统一携带该声明,保证 SetCursorPos 全程物理像素语义一致) */
const USER32 = `Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class RpaUser32 {
[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int count);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
}
'@; try { [void][RpaUser32]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {}`;
/** 窗口矩形/鼠标原语(原 capabilities/window-extended.ts 的 WINDOW_API 块) */
const WINDOW_API = `Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class RpaWindowExtended {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int command);
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int width, int height, uint flags);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT point);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
}
'@; try { [void][RpaWindowExtended]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {}`;
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, "''")}' }`;
}
async function windowObject(title: string): Promise<WindowInfo> {
const output = await powershell(`${WINDOW_API}; ${findWindowScript(title)}; $r = New-Object RpaWindowExtended+RECT; [RpaWindowExtended]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; @{pid=$p.Id;title=$p.MainWindowTitle;hwnd=$p.MainWindowHandle.ToInt64();x=$r.Left;y=$r.Top;width=$r.Right-$r.Left;height=$r.Bottom-$r.Top} | ConvertTo-Json -Compress`);
return JSON.parse(output);
}
/** 把匹配标题的窗口带到前台;多招式依次兜底(应用点击「运行」后自身是前台,
* Windows 前台锁定策略会拒绝普通 SetForegroundWindow,最小化再恢复可绕过)。
* 失败用 RPA_ERROR 标记写 stdout 后 exit 1:不走 throw/CLIXML,避免控制台宽度截断和编码歧义 */
function activateWindowScript(selector: string): string {
const safeTitle = selector.replace(/'/g, "''");
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) }
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 }
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) {
[void][WinRpaFocus2.RpaFocus2]::SwitchToThisWindow($h, $true)
Start-Sleep -Milliseconds 150
}
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) {
[void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 6)
Start-Sleep -Milliseconds 150
[void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 9)
Start-Sleep -Milliseconds 300
}
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) { RpaFail '无法把窗口带到前台,请确认窗口未被关闭或全屏占用' }`;
}
/** 把 SendKeys 特殊字符按字面转义(+^%~(){}[] → {+} 等);SendKeys 用花括号表示字面量 */
function sendKeysEscape(ch: string): string {
return "+^%~(){}[]".includes(ch) ? `{${ch}}` : ch;
}
/** 把字符包装成 PowerShell 安全字面量。
* ASCII 直接用单引号字符串(撇号内部 '' 转义);非 ASCII 用 [char]0xXXXX 数值构造——
* Windows PowerShell 5.1 会把 ‘ ’ “ ” 等智能引号当作引号变体解析,直接放进字符串字面量会报「字符串缺少终止符」。 */
function psCharLiteral(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("");
}
/** 生成从当前位置到目标的插值路径点(JS 侧算好,PS 侧只执行循环)(原 window-extended.ts 的 mousePath) */
function mousePath(from: DriverPoint, to: DriverPoint, mode: string, speed: number): Array<[number, number]> {
const dx = to.x - from.x;
const dy = to.y - from.y;
const distance = Math.hypot(dx, dy);
if (distance < 2) return [[to.x, to.y]];
const stepCount = Math.max(6, Math.min(50, Math.round(distance / 12)));
const points: Array<[number, number]> = [];
for (let i = 1; i <= stepCount; i++) {
const t = i / stepCount;
// 模拟人工:缓入缓出 + 轻微垂直抖动;匀速直线:线性
const ease = mode === "0" ? (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2) : t;
const jitter = mode === "0" ? Math.sin(t * Math.PI) * (Math.sin(i * 12.9898) * 43758.5453 % 1) * 3 : 0;
points.push([
Math.round(from.x + dx * ease - (dy / distance) * jitter),
Math.round(from.y + dy * ease + (dx / distance) * jitter)
]);
}
points.push([to.x, to.y]);
return points;
}
const windowsMouse: MouseDriver = {
async moveTo(point) {
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${point.x},${point.y})`);
},
async click(point, options: MouseClickOptions = {}) {
const right = options.button === "right";
const down = right ? 0x0008 : 0x0002;
const up = right ? 0x0010 : 0x0004;
const repeats = options.double ? 2 : 1;
const settle = options.settleMs && options.settleMs > 0 ? `; Start-Sleep -Milliseconds ${Math.round(options.settleMs)}` : "";
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${point.x},${point.y});${Array.from({ length: repeats }, () => ` [RpaUser32]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); [RpaUser32]::mouse_event(${up},0,0,0,[UIntPtr]::Zero)${settle}`).join(";")}`);
},
async drag(from, to, options: MouseDragOptions = {}) {
const right = options.button === "right";
const down = right ? 0x0008 : 0x0002;
const up = right ? 0x0010 : 0x0004;
const hold = Math.max(0, Math.round(options.holdMs ?? 80));
const release = Math.max(0, Math.round(options.releaseDelayMs ?? 80));
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${from.x},${from.y}); [RpaUser32]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds ${hold}; [RpaUser32]::SetCursorPos(${to.x},${to.y});${release > 0 ? ` Start-Sleep -Milliseconds ${release};` : ""} [RpaUser32]::mouse_event(${up},0,0,0,[UIntPtr]::Zero)`);
},
async scroll(amount) {
await powershell(`${USER32}; [RpaUser32]::mouse_event(0x0800,0,0,${amount},[UIntPtr]::Zero)`);
},
async moveSmooth(target, options: MouseSmoothOptions = {}) {
const ease = options.ease !== false;
const speed = Math.min(100, Math.max(1, options.speed ?? 35));
const delay = Math.min(80, Math.max(4, Math.round(1500 / speed)));
// 从当前光标位置出发,按速度插值(路径全程物理像素,与目标同一坐标系)
const output = await powershell(`${WINDOW_API}; $cur = [RpaWindowExtended+POINT]::new(); [void][RpaWindowExtended]::GetCursorPos([ref]$cur); "$($cur.X),$($cur.Y)"`);
const [cx, cy] = output.split(",").map(Number);
const from: DriverPoint = Number.isFinite(cx) && Number.isFinite(cy) ? { x: cx, y: cy } : { x: 0, y: 0 };
const path = mousePath(from, target, ease ? "0" : "1", speed);
const ptsLiteral = path.map(([px, py]) => `@(${px},${py})`).join(",");
await powershell(`${WINDOW_API}; $pts = @(${ptsLiteral}); foreach ($pt in $pts) { [void][RpaWindowExtended]::SetCursorPos($pt[0], $pt[1]); Start-Sleep -Milliseconds ${delay} }`);
}
};
const windowsKeyboard: KeyboardDriver = {
async send(options: KeyboardSendOptions) {
// 三种输入方式:脚本尽量简单,转义逻辑放 JS 侧,避免 PowerShell 字符串拼接的语法陷阱
let send: string;
if (options.mode === "clipboard") {
// 剪贴板粘贴:原文进剪贴板 + Ctrl+V,不做 SendKeys 转义;内容含智能引号等非 ASCII 时同样走 [char] 构造
const contentExpr = /^[\x20-\x7E]*$/.test(options.keys) ? psString(options.keys) : `(-join @(${Array.from(options.keys).map((c) => psCharLiteral(c)).join(",")}))`;
send = `Set-Clipboard -Value ${contentExpr}; Start-Sleep -Milliseconds 120; [System.Windows.Forms.SendKeys]::SendWait('^v')`;
} else if (options.mode === "manual" && options.textMode) {
// 人工模拟:逐字符;JS 侧把每个字符转义成 SendKeys 字面量,PS 侧只做数组循环
const escapedChars = Array.from(options.keys).map((ch) => sendKeysEscape(ch));
const arr = escapedChars.map((c) => psCharLiteral(c)).join(",");
send = `$chars = @(${arr}); foreach ($c in $chars) { [System.Windows.Forms.SendKeys]::SendWait([string]$c); Start-Sleep -Milliseconds ${options.intervalMs} }`;
} else {
// 后台接口 / 按键组合 / 人工模拟(按键组合组件):整段发送,文本特殊字符转义
const escaped = options.textMode ? Array.from(options.keys).map(sendKeysEscape).join("") : options.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})`;
}
const activate = options.activateTitle ? `${activateWindowScript(options.activateTitle)}; Start-Sleep -Milliseconds 300; ` : "";
await powershell(`${USER32}; ${activate}Add-Type -AssemblyName System.Windows.Forms; ${send}`);
}
};
const windowsWindow: WindowDriver = {
findByTitle: (title) => windowObject(title),
async activate(title, options: { maximize?: boolean } = {}) {
const maximize = options.maximize === true;
await powershell(`${WINDOW_API}; ${findWindowScript(title)}; [RpaWindowExtended]::SetForegroundWindow($p.MainWindowHandle) | Out-Null; ${maximize ? "[RpaWindowExtended]::ShowWindow($p.MainWindowHandle,3) | Out-Null" : ""}`);
return windowObject(title);
},
async focus(title) {
return powershell(`${USER32}\n${activateWindowScript(title)}\n$p.MainWindowTitle`);
},
async foregroundTitle() {
return powershell(`${USER32}; $sb = New-Object System.Text.StringBuilder 512; [RpaUser32]::GetWindowText([RpaUser32]::GetForegroundWindow(),$sb,$sb.Capacity) | Out-Null; $sb.ToString()`);
},
async foregroundPosition(): Promise<DriverPoint> {
const output = await powershell(`${WINDOW_API}; $fw = [RpaWindowExtended]::GetForegroundWindow(); $r = New-Object RpaWindowExtended+RECT; [void][RpaWindowExtended]::GetWindowRect($fw, [ref]$r); "$($r.Left),$($r.Top)"`);
const [x, y] = output.split(",").map(Number);
return { x, y };
},
async exists(title) {
return (await powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; [bool]$p`)).toLowerCase() === "true";
},
async close(title): Promise<string> {
return powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口' }; $p.CloseMainWindow() | Out-Null; $true`);
},
async setState(title, showCommand): Promise<void> {
await powershell(`${WINDOW_API}; ${findWindowScript(title)}; [RpaWindowExtended]::ShowWindow($p.MainWindowHandle,${showCommand}) | Out-Null`);
},
async setSize(title, width, height) {
await powershell(`${WINDOW_API}; ${findWindowScript(title)}; $r = New-Object RpaWindowExtended+RECT; [RpaWindowExtended]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; [RpaWindowExtended]::SetWindowPos($p.MainWindowHandle,[IntPtr]::Zero,$r.Left,$r.Top,${width},${height},4) | Out-Null`);
},
async setPos(title, rect: DriverRect): Promise<WindowInfo> {
if (!(rect.width > 0 && rect.height > 0)) throw new Error("窗口尺寸必须大于 0");
await powershell(`${WINDOW_API}; ${findWindowScript(title)}; [RpaWindowExtended]::SetWindowPos($p.MainWindowHandle,[IntPtr]::Zero,${rect.x},${rect.y},${rect.width},${rect.height},4) | Out-Null`);
return { ...(await windowObject(title)), x: rect.x, y: rect.y, width: rect.width, height: rect.height };
},
async list(filter: { title?: string; regex?: boolean } = {}) {
const title = filter.title ?? "";
const regex = filter.regex === true;
const output = await powershell(`Get-Process | Where-Object { $_.MainWindowHandle -ne 0 ${title ? (regex ? `-and $_.MainWindowTitle -match ${psString(title)}` : `-and $_.MainWindowTitle -like ${psString(`*${title}*`)}`) : ""} } | ForEach-Object { @{pid=$_.Id;title=$_.MainWindowTitle;hwnd=$_.MainWindowHandle.ToInt64()} } | ConvertTo-Json -Compress`);
if (!output) return [] as WindowSummary[];
const parsed = JSON.parse(output);
return (Array.isArray(parsed) ? parsed : [parsed]) as WindowSummary[];
}
};
const windowsScreen: ScreenDriver = {
async size() {
const output = await powershell("Add-Type -AssemblyName System.Windows.Forms; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; @{width=$b.Width;height=$b.Height;x=$b.X;y=$b.Y} | ConvertTo-Json -Compress");
return JSON.parse(output);
}
};
/** element 域实现承载在 capabilities/desktop-uia.ts(阶段 3.3 说明),此处运行时注入,避免 driver → capabilities 的模块依赖 */
let elementImplementation: ElementDriver | undefined;
export function setElementDriverImplementation(impl: ElementDriver): void {
elementImplementation = impl;
}
export class WindowsDriver implements AutomationDriver {
readonly mouse = windowsMouse;
readonly keyboard = windowsKeyboard;
readonly window = windowsWindow;
readonly screen = windowsScreen;
get element(): ElementDriver {
if (!elementImplementation) {
throw new Error("ElementDriver 实现未注册:请 import @rpa/core/capabilities(desktop-uia 模块自动注册)");
}
return elementImplementation;
}
}

View File

@ -1,5 +1,7 @@
import type { Client } from "basic-ftp";
import type { Workbook as ExcelJSWorkbook } from "exceljs";
import { WindowsDriver } from "../driver/windows/windows-driver";
import type { AutomationDriver } from "../driver/types";
/** 人为拟真操作的开关与随机参数(desktop 能力的进程级设置)。 */
export interface ManualOperationSettings {
@ -39,6 +41,8 @@ export class RpaEngine {
minMs: 1000,
maxMs: 3000
};
/** 桌面自动化驱动(任务 3.4 注入点,默认 PowerShell 版 WindowsDriver) */
readonly driver: AutomationDriver = new WindowsDriver();
}
export function createEngine(): RpaEngine {

View File

@ -44,8 +44,30 @@ export {
type ExternalRemoteOptions,
type ExternalConnection
} from "./browser/external";
// PowerShell 助手(阶段 3 将移入 driver/windows/):宿主留守能力(window-extended 等)经此引用
export { powershell, psString } from "./capabilities/desktop";
// 桌面自动化驱动(阶段 3):五域接口 + PowerShell 实现,引擎创建时注入
export type {
AutomationDriver,
DriverPoint,
DriverRect,
ElementDriver,
ElementFindOptions,
KeyboardDriver,
KeyboardSendOptions,
MouseClickOptions,
MouseDragOptions,
MouseDriver,
MouseSmoothOptions,
MouseButton,
ScreenDriver,
UiaSelector,
WindowDriver,
WindowInfo,
WindowSummary
} from "./driver/types";
export { WindowsDriver, setElementDriverImplementation } from "./driver/windows/windows-driver";
// PowerShell 助手(任务 3.1 已移入 driver/windows/)
export { powershell, psString } from "./driver/windows/powershell";
export type {
BrowserLike,
BrowserTabInfo,

View File

@ -228,6 +228,10 @@ function normalizeVendorComponent(component, vendorMap) {
const fields = (component.inputFields || []).map((f) => enrichFromVendor(normalizeVendorField(f), vendor));
const output = outputOf(component, vendor);
const risk = ["web", "app", "desktop", "message", "mobile"].includes(group) ? "high" : "low";
// 任务 3.4(决策点 7):平台标记只填 catalog 数据,渲染层置灰延后。
// Windows 专用组:desktop(user32/键鼠)、app(COM/进程)、office(Excel/Word COM)、vision(PowerShell 截屏);
// data/file/message 纯 Node,web 走 CDP,均视为跨平台(null)。
const WIN32_GROUPS = new Set(["desktop", "app", "office", "vision"]);
return {
id: component.id,
name: component.name,
@ -242,7 +246,7 @@ function normalizeVendorComponent(component, vendorMap) {
functionCalls: (component.functions || []).map((f) => ({ function: f.function, args: f.arguments || [], assignTo: f.assignTo || "" })),
version: component.version || "",
tags: component.tags || [],
platformTag: component.platformTag || null,
platformTag: component.platformTag || (WIN32_GROUPS.has(group) ? "win32" : null),
icon: group,
risk,
fields,