5 Commits
Author SHA1 Message Date
jkooe 7037c7ff94 chore(release): v1.5.0 2026-10-07 01:00:25 +08:00
jkooe 689bb8c98d feat(v1.5): 快捷键控制范围扩展 —— 字符串热键 / 预设别名 / 长按 / 后台定向投递
## 背景
原 `input.key.press` 只接受数组和弦(keys/sequence),按键表也不全:
AI/MCP 调用要拼 ["ctrl","shift","esc"],且 **win+d、ctrl++、win+. 这类
"修饰键 + OEM 符号键"的组合根本打不出来**(表里没有符号键)。

## 四类扩展
① **字符串热键**:`hotkey: "ctrl+shift+esc"`、`hotkeys: ["ctrl+c","ctrl+v"]`
   —— 分隔符支持 `+ - 空格`,大小写不敏感,别名归一(command/cmd→win、del→delete…)
② **预设别名**:约 50 个语义名(copy / show_desktop / task_manager / screenshot /
   volume_mute_toggle…),AI 调用免记键位;`preset` 与 `presets`(序列)均支持
③ **长按** `hold_ms`(≤5s):按下→保持→释放,插在"按完"与"释放"之间
④ **后台定向投递** `route=post` + `target_pid`:向目标进程**全部顶层窗口**
   PostMessage WM_KEYDOWN/UP —— 目标窗口被遮挡/最小化时可用
   (媒体键的 WM_APPCOMMAND 机制早已验证,此为其等价物)
   文档明示局限:游戏/部分输入型程序不响应后台键消息

## 按键表补齐(单测覆盖断言防漂移)
- 左右侧修饰键 lctrl/rctrl/lshift/rshift/lalt/ralt/lwin/rwin(游戏常区分左右)
- 小键盘 numpad0-9 + 四则运算,**与数字键分开**(Excel 靠 NumLock 区分)
- OEM 符号键 oem_1..oem_8 / oem_minus/plus/comma/period(解锁 win+d、ctrl++、win+.)
- 浏览器收藏夹/主页、系统键(sleep/help/select/execute/clear/separator/oem_clear)
- IME 键(kana/hangul/kanji/hanja/convert/nonconvert/accept/modechange)
- 补齐 pause/apps(重构时一度漏掉,被覆盖断言测试立刻抓到)

## 可测性重构
脚本生成抽成**纯函数** `apps/agent/src/capabilities/input-script.ts`
(VK_MAP / MEDIA_COMMANDS / toVk / build{Media,Foreground,Post}Body),
行内生成逻辑全部改为调用 —— 于是**按下顺序/逆序释放/hold 插入位置/post 路由**
这些最容易写错的部分,现在由 14 个单测逐行断言,真机只需验证最终效果。

## 参数校验
protocol `expandChords()` 统一展开六种输入形式,**混用即报错**(消除"以谁为准"歧义);
同一和弦重复修饰键、超 4 键、空输入均拒绝。渲染返回值新增 presets/via/channel/sent_windows。

## CLI / MCP
- CLI:`nodeagent key press "ctrl+shift+esc"` / `key press copy` / 老形式 `key press ctrl c`
  新增 `--hold`、`--route`;回显预设展开与后台投递命中窗口数
- MCP `na_key`:同步支持 hotkey/hotkeys/preset/presets/hold_ms/route/target_pid

## 验证
- 单测 **179 → 195**:hotkeys 解析与别名 17 例、**脚本生成逐行断言 14 例**、
  按键覆盖断言(HOTKEY_KEYS ⊆ VK_MAP)、新参数 schema 校验 2 例
- e2e **29** 全绿
- ⚠️ **真机未验证**:Windows 被控端当前离线(连接超时)。待上线后需验:
  `key press "win+d"`(切桌面再切回)、`key press copy` 配剪贴板、
  `route=post` 定向一个最小化窗口。验证通过后再发版。
2026-10-07 00:59:40 +08:00
jkooe 9d32592ed1 feat(v1.2): input.key.press 支持媒体键(WM_APPCOMMAND 通道 + 定向投递)
远程控 QQ 音乐时发现:媒体键全部查不到,window.focus 越不过游戏全屏,
而软件热键在精简模式下未注册 —— 只能绕道 WM_APPCOMMAND 手写脚本。
现把这条通道正式纳入能力,避免每次重造轮子。

1. VK_MAP 补全(input.ts)
   media_play_pause(0xB3) / media_stop(0xB2) / media_next(0xB0) / media_prev(0xB1)
   media_rewind(0xB7) / media_fast_forward(0xB4) / volume_mute(0xAD) /
   volume_down(0xAE) / volume_up(0xAF) / 浏览器键 / F13-F24

2. 新增 WM_APPCOMMAND 通道(关键,非仅补 VK)
   媒体键不再走 SendInput,改为识别「整条序列都是媒体键」后走
   PostMessage(WM_APPCOMMAND)。原因:
   - SendInput 打进媒体键后,只有当前媒体会话焦点的窗口才响应
   - APPCOMMAND 可定向投递到指定窗口/进程,绕开该不确定性
   - 窗口最小化或被全屏游戏遮挡时依然有效(实测 QQ 音乐被 DNF 全屏压住仍可控)

3. 新增 target_pid 参数 + CLI --pid
   播放器常创建 30+ 辅助顶层窗口(QQ 音乐实测 36 个),只投主窗口常常无效
   → 指定 PID 后向该进程全部顶层窗口投递。
   返回值加 via:'appcommand' 与 target_pid,便于确认走的是哪条通道。

4. 音量键刻意不进 APPCOMMAND 表
   APPCOMMAND 的 lParam 按位传递(低 16 位 command、位 12-15 设备类型),
   裸值会撞车(APPCOMMAND_VOLUME_DOWN 与 APPCOMMAND_PAUSE 都是 1),
   纳入表中会导致静音键被误当暂停。故音量键走真·VK 注入,由系统自行翻译。

5. screenshot --scale 报错信息改善(gui.ts)
   协议限定 scale ∈ (0,1],原先非法值直发只回「参数校验失败」,看不出是哪项
   → CLI 前置校验,明确提示「--scale 应在 0 到 1 之间…收到: 3」。

6. window.focus 边界写入 manifest
   明确无法越过全屏独占应用:此时返回 focused:true 但视觉仍遮挡、点不到,
   属预期而非失败;指引改用媒体键 / shell.exec / 请用户切出全屏。

真机验证(192.168.1.100):单测 162 全绿;媒体键广播与定向投递均生效;
volume_up 20%→22%、volume_down×2 →18%;非法键名仍明确报错。
双端已部署,指纹 5b68faa20a3e,v1.4.3。
2026-10-05 15:06:24 +08:00
jkooe 3ff8e86fe8 chore(release): v1.4.3 2026-10-05 11:35:53 +08:00
jkooe 7689a1f80c fix(v22): 构建改为**确定性** —— 去掉注入的构建时间,否则「指纹比对是否最新」永远失真
## 现象(真机执行 `deploy --check`)
明明被控端就跑着 v1.4.2、本地也是 v1.4.2,却报「版本不一致」:
    本地 : 47326da097c7  (976 KB)
    远端 : 6499b3f7ad3c  (node v22.20.0)

## 根因
打包时注入了 `__BUILD_TIME__`,于是**同一份代码每次打包的字节都不同** →
内容哈希无法充当「构建身份」,`--check` / 部署后校验只能靠"这次上传的文件"比对,
跨批次就必然误报。

## 修复
- `pack.mjs` 不再注入构建时间(版本与 commit 仍是确定性的)
- `system.info.build.built_at` 改用**入口文件的 mtime**(mtime 不在文件内容里,
  因此不破坏字节确定性,同时仍能反映构建时刻)

## 验证
- 连打两次包(间隔 2s),指纹完全相同:`b13ce7d3ba74d0e0b293649f` ✓
- 真机更新:指纹 b13ce7d3ba74、42 项能力、证书钉住一致 ✓
- **再次执行 `--check` → 「✓ 已是最新,无需更新」**(此前同版本却报不一致)✓

## 意义
产物变为**可复现**(reproducible):同一 commit 打出同一份字节,
sha256 可以放心用作分发与更新的身份标识 —— 这对一条「下载即执行代码」的更新通道很关键。
2026-10-05 11:27:28 +08:00
23 changed files with 1185 additions and 83 deletions

No files matched your search

+51 -1
View File
@@ -27,10 +27,60 @@ nodeagent 把一台 Windows 机器的能力**标准化成一组可授权、可
| **屏幕** | `screen.info` · `screen.capture` · `screen.record` · `screen.find` | 显示器 / 截屏 / **录屏为帧序列** / **元素定位三引擎**(UIA 控件树 → OCR 文字 → **图像模板**) |
| **窗口** | `window.list` · `window.focus` | 枚举可见窗口(精确矩形)/ 置前聚焦(**Windows + macOS**) |
| **文件** | `fs.list` · `fs.stat` · `fs.read` · `fs.write` | 列目录 / 元信息 / 分块读 / 原子写 |
| **输入** | `input.mouse.move` · `input.mouse.click` · `input.mouse.scroll` · `input.mouse.drag` · `input.key.type` · `input.key.press` | 键鼠控制(🔒 **默认禁用**),含**拖拽**与**按键序列/连按** |
| **输入** | `input.mouse.move` · `input.mouse.click` · `input.mouse.scroll` · `input.mouse.drag` · `input.key.type` · `input.key.press` | 键鼠控制(🔒 **默认禁用**),含**拖拽**、**字符串热键 / 预设 / 长按 / 后台定向投递** |
| **剪贴板** | `clip.get` · `clip.set` | 读写文本**或图片**(PNG Base64) |
| **事件订阅** | `event.watch` · `event.unwatch` · `event.list` · `event.poll` | 文件变动 / 进程启停 / 端口开闭,**主动推送**(无需轮询) |
## 快捷键(hotkey)控制 —— v1.5
`input.key.press` 支持 **4 种输入形式**(一次只用一种):
```bash
# ① 字符串热键(最常用;分隔符支持 + - 空格,大小写不敏感)
nodeagent --node win key press "ctrl+shift+esc"
nodeagent --node win key press "win+d" # 显示桌面
nodeagent --node win key press "alt+tab" # 切换应用
# ② 热键序列
nodeagent --node win key press "ctrl+c" # 老形式也兼容:key press ctrl c
nodeagent --node win invoke input.key.press --args '{"hotkeys":["ctrl+c","ctrl+v"]}'
# ③ 预设语义名(约 50 个,免记键位)
nodeagent --node win key press copy # = ctrl+c
nodeagent --node win key press task_manager # = ctrl+shift+esc
nodeagent --node win key press volume_mute_toggle # 媒体键(走 WM_APPCOMMAND)
# ④ 老数组形式
nodeagent --node win key press ctrl c
```
**按键覆盖**:字母/数字 · F1–F24 · 左右侧修饰键(`lalt`/`rctrl`/`lwin`…)·
小键盘(`numpad7`、`numpad_add`…,**与数字键分开**——Excel 靠 NumLock 区分)·
OEM 符号键(`oem_plus`/`oem_comma`/`oem_period`… → 支持 `win+d`、`ctrl++`、`win+.`)·
媒体键 · 浏览器键 · IME 键(`convert`/`kana`…)· 系统键(`sleep`/`help`…)
**进阶参数**:
| 参数 | 说明 |
|---|---|
| `hold_ms` | 长按:按下后保持 N 毫秒再释放(≤5000) |
| `route=post` + `target_pid` | **后台定向投递**:向目标进程全部顶层窗口 PostMessage(目标窗口被遮挡/最小化时用)。⚠️ 游戏与部分输入型程序不响应后台键消息 |
| `target_pid`(默认路由) | 媒体键定向(播放器常建 30+ 辅助窗口,指定 PID 投给该进程全部顶层窗口) |
**预设速查**(完整表见 `packages/protocol/src/hotkeys.ts` 的 `HOTKEY_PRESETS`):
| 类别 | 预设名 |
|---|---|
| 编辑 | `copy` `cut` `paste` `paste_plain` `undo` `redo` `select_all` |
| 文件 | `save` `save_as` `new` `open` `close_tab` `print` |
| 窗口 | `switch_app` `task_view` `task_manager` `show_desktop` `minimize_all` `maximize` `snap_left` `snap_right` `rename` `properties` |
| 系统 | `lock_screen` `run` `explorer` `search` `settings` `clipboard_history` `emoji_picker` `screenshot` |
| 虚拟桌面 | `new_desktop` `desktop_left` `desktop_right` |
| 标签/视图 | `new_tab` `next_tab` `reopen_tab` `zoom_in` `zoom_out` `fullscreen` |
| 媒体 | `music_play_pause` `music_next` `music_prev` `volume_add` `volume_mute_toggle` |
> MCP 侧同名工具为 `na_key`(`action: "press"`),支持同一套参数。
**36 项能力** · **32 个 MCP 工具** · **114 项单元测试** · **26 项端到端用例**(CI 在真实 Windows 上验证)
> 关键里程碑:**GUI 语义**(`window.list` + `screen.find`,UIA 找不到自动降级 OCR)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/agent",
"version": "1.4.2",
"version": "1.5.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+200
View File
@@ -0,0 +1,200 @@
/**
* 输入注入的 **PowerShell 脚本生成**(纯函数,无副作用 —— 便于单测)。
*
* 独立成模块的原因:脚本生成逻辑(顺序、逆序释放、hold 插入、PostMessage 路由)
* 以往只能靠真机验证;而它恰好是最容易写错(一行顺序错 → 组合键失效)的部分。
* 现在由单测逐行断言,真机只需验证"最终是否生效"。
*/
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
/** 键名 → 虚拟键码(字母/数字走 ASCII 映射,其余查表白名单)。 */
export const VK_MAP: Record<string, number> = {
ctrl: 0x11, control: 0x11, shift: 0x10, alt: 0x12, win: 0x5b, meta: 0x5b,
enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, space: 0x20,
backspace: 0x08, delete: 0x2e, del: 0x2e, insert: 0x2d, ins: 0x2d,
up: 0x26, down: 0x28, left: 0x25, right: 0x27,
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
capslock: 0x14, printscreen: 0x2c,
// 系统/编辑键补充
pause: 0x13, break: 0x13,
apps: 0x5d, menu: 0x5d,
f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7a, f12: 0x7b,
f13: 0x7c, f14: 0x7d, f15: 0x7e, f16: 0x7f, f17: 0x80, f18: 0x81,
f19: 0x82, f20: 0x83, f21: 0x84, f22: 0x85, f23: 0x86, f24: 0x87,
// 媒体键(0xA6-0xB7 区段)—— 2026-10-05 补
//
// 为何必要:远程控媒体播放器(QQ 音乐 / 网易云 / 浏览器视频)时,
// 若软件热键不可用(精简模式常不注册全局热键),系统媒体键是唯一
// 与「窗口是否可见/被遮挡」无关的通道 —— 窗口最小化、被游戏全屏盖住时
// 依然有效。实测:QQ 音乐精简窗被 DNF 全屏遮挡时,媒体键仍能控制。
media_play: 0xb3, media_pause: 0xb3, media_play_pause: 0xb3, play_pause: 0xb3,
media_stop: 0xb2, stop: 0xb2,
media_prev: 0xb1, media_previous: 0xb1, prev_track: 0xb1, prev: 0xb1,
media_next: 0xb0, next_track: 0xb0, next: 0xb0,
media_fast_forward: 0xb4, fast_forward: 0xb4,
media_rewind: 0xb7, rewind: 0xb7,
media_eject: 0xb2,
volume_mute: 0xad, mute: 0xad,
volume_down: 0xae,
volume_up: 0xaf,
browser_back: 0xa6, browser_forward: 0xa7, browser_refresh: 0xa8, browser_stop: 0xa9, browser_search: 0xaa,
launch_mail: 0xb4, launch_media: 0xb5, launch_app1: 0xb6, launch_app2: 0xb7,
browser_favorites: 0xab, browser_home: 0xac,
// ---- 左右侧修饰键(v1.5)----
// 游戏/部分应用区分左右(如游戏中把 LAlt 设为跑步、RAlt 设为瞄准),故不能只给一个 alt。
lctrl: 0xa2, rctrl: 0xa3, lshift: 0xa0, rshift: 0xa1,
lalt: 0xa4, ralt: 0xa5, lwin: 0x5b, rwin: 0x5c,
super: 0x5b, cmd: 0x5b, left_cmd: 0x5b, right_cmd: 0x5c,
// ---- 小键盘(v1.5)----
// numpadN 与数字 N 分开:Excel 等程序靠 NumLock 状态区分二者行为,合成一个会坏掉快捷键。
numpad0: 0x60, numpad1: 0x61, numpad2: 0x62, numpad3: 0x63, numpad4: 0x64,
numpad5: 0x65, numpad6: 0x66, numpad7: 0x67, numpad8: 0x68, numpad9: 0x69,
numpad_multiply: 0x6a, numpad_add: 0x6b, numpad_subtract: 0x6d, numpad_decimal: 0x6e, numpad_divide: 0x6f,
numlock: 0x90, scrolllock: 0x91,
numpad_enter: 0x0d,
// ---- OEM 符号键(v1.5,美式布局 VK 码)----
// 必备理由:大量系统/应用快捷键就是"修饰键 + 符号键"(win+d 显示桌面、win+, 速览、
// ctrl++ 放大、ctrl+- 缩小、win+. 表情面板),此前只能靠字母数字,覆盖不到。
oem_1: 0xba, // : ;
oem_2: 0xbf, // / ?
oem_3: 0xc0, // ` ~
oem_4: 0xdb, // [ {
oem_5: 0xdc, // \ |
oem_6: 0xdd, // ] }
oem_7: 0xde, // ' "
oem_8: 0xdf,
oem_minus: 0xbd, // - _
oem_plus: 0xbb, // + =
oem_comma: 0xbc, // , <
oem_period: 0xbe,// . >
// ---- 系统/IME 杂项(v1.5)----
sleep: 0x5f, help: 0x2f, select: 0x29, execute: 0x2b, clear: 0x0c,
separator: 0x6c, oem_clear: 0xfe,
kana: 0x15, hangul: 0x15, kanji: 0x19, hanja: 0x19,
convert: 0x1c, nonconvert: 0x1d, accept: 0x1e, modechange: 0x1f,
};
/** 键名 → 虚拟键码。 */
export function toVk(key: string): number {
const k = key.trim().toLowerCase();
if (VK_MAP[k] !== undefined) return VK_MAP[k]!;
if (/^[a-z]$/.test(k)) return k.toUpperCase().charCodeAt(0);
if (/^[0-9]$/.test(k)) return k.charCodeAt(0);
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的按键: ${key}`, {
supported: [...Object.keys(VK_MAP), 'a-z', '0-9'],
});
}
/**
* 媒体键名 → APPCOMMAND 取值。
*
* 取值见 Windows `APPCOMMAND_*` 常量。整条序列都是媒体键时改走 WM_APPCOMMAND
* 投递(而非 SendInput 注入 VK)—— 前者能定向到指定进程且不受窗口遮挡影响。
*
* ⚠️ 音量键(volume_mute / volume_up / volume_down)**不在此表**:
* WM_APPCOMMAND 的 lParam 低 16 位按 device 段解释,VOLUME_DOWN 与 PAUSE 的裸值相同(都是 1),
* 放进表里会与 PAUSE 撞车。音量键走 VK 注入(0xAD-0xAF)更可靠 —— 系统会自行映射为
* APPCOMMAND_VOLUME_*,故此处刻意不收录音量键,避免 device-bit 歧义。
*/
export const MEDIA_COMMANDS: Record<string, number> = {
media_play: 14, play: 14,
media_pause: 1, pause: 1,
media_play_pause: 12, play_pause: 12,
media_stop: 15, stop: 15,
media_prev: 6, media_previous: 6, prev_track: 6, prev: 6,
media_next: 7, next_track: 7, next: 7,
media_fast_forward: 3, fast_forward: 3,
media_rewind: 4, rewind: 4,
media_close: 10, close: 10,
};
export interface PressBodyOptions {
/** 序列中相邻和弦之间的间隔毫秒 */
gapMs: number;
/** 按下后保持毫秒数(长按) */
holdMs: number;
/** route=post 时的目标进程 PID */
targetPid: number;
/** 投放路由 */
route: 'foreground' | 'post';
}
/** 判断整条序列是否都是媒体键。 */
export function isAllMedia(chords: string[][]): boolean {
return (
chords.length > 0 &&
chords.every((ch) => ch.length === 1 && MEDIA_COMMANDS[ch[0]!.trim().toLowerCase()] !== undefined)
);
}
/**
* 生成媒体键(WM_APPCOMMAND)投放脚本。
* targetPid=0 → HWND_BROADCAST 广播;否则定向该进程全部顶层窗口。
*/
export function buildMediaBody(chords: string[][], opts: PressBodyOptions): string[] {
const body: string[] = [];
const pid = Math.max(0, Math.trunc(opts.targetPid));
for (let i = 0; i < chords.length; i += 1) {
const cmd = MEDIA_COMMANDS[chords[i]![0]!.trim().toLowerCase()]!;
body.push(`[NAInput]::MediaCommand(${cmd}, ${pid})`);
if (i < chords.length - 1 && opts.gapMs > 0) {
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
}
}
return body;
}
/**
* 生成前台注入(SendInput Vk)脚本。
*
* 顺序至关重要:按下按给定顺序、**释放逆序** —— 否则 Windows 组合键状态机会淆
* (例如 ctrl+c 若先释放 ctrl,绝大多数程序收不到"带 ctrl 的 c")。
*/
export function buildForegroundBody(chords: string[][], opts: PressBodyOptions): string[] {
const body: string[] = [];
for (let i = 0; i < chords.length; i += 1) {
const chord = chords[i]!;
if (chord.length === 0) continue;
const vks = chord.map(toVk);
for (const vk of vks) body.push(`[NAInput]::Vk(${vk}, $false)`);
if (opts.holdMs > 0) body.push(`Start-Sleep -Milliseconds ${opts.holdMs}`);
// 逆序释放,保证组合键正确
for (const vk of [...vks].reverse()) body.push(`[NAInput]::Vk(${vk}, $true)`);
if (i < chords.length - 1 && opts.gapMs > 0) {
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
}
}
return body;
}
/**
* 生成后台定向投递(PostMessage)脚本。
*
* 与前台注入同样遵守"按下顺序 / 释放逆序";每和弦投递给目标进程全部顶层窗口。
* C# 侧 PostChord(vks, targetPid, up):up=false → WM_KEYDOWN,up=true → WM_KEYUP。
*/
export function buildPostBody(chords: string[][], opts: PressBodyOptions): string[] {
const body: string[] = [];
const pid = Math.max(1, Math.trunc(opts.targetPid));
for (let i = 0; i < chords.length; i += 1) {
const vks = chords[i]!.map(toVk);
if (vks.length === 0) continue;
body.push(`[NAInput]::PostChord(@(${vks.join(', ')}), ${pid}, $false)`);
if (opts.holdMs > 0) body.push(`Start-Sleep -Milliseconds ${opts.holdMs}`);
// 逆序释放
const rel = [...vks].reverse().join(', ');
body.push(`[NAInput]::PostChord(@(${rel}), ${pid}, $true)`);
if (i < chords.length - 1 && opts.gapMs > 0) {
body.push(`Start-Sleep -Milliseconds ${opts.gapMs}`);
}
}
return body;
}
+176 -50
View File
@@ -1,4 +1,10 @@
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { CapabilityError, ErrorCodes, expandChords } from '@nodeagent/protocol';
import {
isAllMedia,
buildMediaBody,
buildForegroundBody,
buildPostBody,
} from './input-script.js';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
type Args = Record<string, unknown>;
@@ -128,6 +134,83 @@ public static class NAInput {
var u = new INPUT[1]; u[0].type = 0; u[0].U.mi.dwFlags = upFlag;
SendInput(1, u, SIZE);
}
/// <summary>
/// 媒体控制(2026-10-05 补):经 WM_APPCOMMAND 广播到目标窗口。
///
/// 为何不只靠 VK 注入:媒体键用 SendInput 打进去后,**并非所有播放器都接收**
/// —— 只有当前获得媒体会话焦点的窗口才会响应。而 WM_APPCOMMAND 可直接
/// 投递给指定 hwnd,绕开「谁是当前媒体会话焦点」这层不确定性。
///
/// 用途:远程控制 QQ 音乐 / 网易云 / 浏览器视频,且**窗口最小化或被全屏游戏
/// 遮挡时依然有效**(实测 QQ 音乐精简窗被 DNF 全屏压住时该通道可用)。
/// </summary>
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
private static extern bool EnumWindows(EnumProc cb, IntPtr p);
private delegate bool EnumProc(IntPtr h, IntPtr p);
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
/// <summary>
/// 向目标窗口(或其全部顶层窗口)发媒体控制命令。
/// appCommand 取值:14=PLAY 15=STOP 12=PLAY_PAUSE 6=PREV 7=NEXT
/// 3=FFWD 4=REWIND 10=CLOSE 0=PLAY 1=PAUSE
/// targetPid 非 0 时,自动找到该进程的所有顶层窗口并逐个投递
/// (QQ 音乐等会创建 30+ 个辅助窗口,只投主窗口常常无效)。
/// </summary>
public static int MediaCommand(int appCommand, int targetPid) {
int sent = 0;
if (targetPid == 0) {
// HWND_BROADCAST:广播给所有顶层窗口
if (PostMessage((IntPtr)0xFFFF, 0x0319, IntPtr.Zero, (IntPtr)appCommand)) sent++;
return sent;
}
var handles = new System.Collections.Generic.List<IntPtr>();
EnumWindows(delegate(IntPtr h, IntPtr p) {
uint wpid;
GetWindowThreadProcessId(h, out wpid);
if (wpid == (uint)targetPid) handles.Add(h);
return true;
}, IntPtr.Zero);
foreach (var h in handles) {
if (PostMessage(h, 0x0319, IntPtr.Zero, (IntPtr)appCommand)) sent++;
}
return sent;
}
/// <summary>
/// v1.5:向目标进程的**全部顶层窗口** PostMessage 键盘消息(后端定向快捷键投放)。
///
/// 场景:目标窗口不在前台(被遮挡 / 最小化 / 聊天窗口在后台),又要给它发快捷键。
/// WM_KEYDOWN=0x0100,WM_KEYUP=0x0101;修饰键按顺序投递,调用方负责释放顺序。
///
/// ⚠️ 已知局限(务必告知调用方):
/// - 部分程序不处理**后台**键盘消息(游戏、部分浏览器与输入型应用直接忽略);
/// - PostMessage 不带 scancode,依赖 lParam 解码按键的程序可能取不到键。
/// 该类目标请改用默认 route=foreground(前台注入,需目标可见)。
/// </summary>
public static int PostChord(ushort[] vks, int targetPid, bool up) {
int sent = 0;
if (vks == null || vks.Length == 0) return 0;
var handles = new System.Collections.Generic.List<IntPtr>();
EnumWindows(delegate(IntPtr h, IntPtr p) {
uint wpid;
GetWindowThreadProcessId(h, out wpid);
if (wpid == (uint)targetPid) handles.Add(h);
return true;
}, IntPtr.Zero);
uint msg = up ? 0x0101u : 0x0100u;
foreach (var h in handles) {
foreach (var vk in vks) {
if (PostMessage(h, msg, (IntPtr)vk, IntPtr.Zero)) sent++;
}
}
return sent;
}
}`;
/** 组装:加类型定义 + 具体操作。 */
@@ -137,13 +220,14 @@ function psScript(body: string[]): string {
);
}
async function runPs(body: string[], timeoutMs = 30_000): Promise<void> {
async function runPs(body: string[], timeoutMs = 30_000): Promise<string> {
const r = await execCommand({ command: psScript(body), timeoutMs });
if (r.exit_code !== 0) {
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '输入注入失败', {
detail: (r.stderr || r.stdout).slice(0, 400),
});
}
return r.stdout ?? '';
}
// ---------------- 鼠标 ----------------
@@ -256,29 +340,6 @@ export async function mouseDrag(args: Args): Promise<unknown> {
return { dragged: true, from: { x: x1, y: y1 }, to: { x: x2, y: y2 }, button, steps };
}
// ---------------- 键盘 ----------------
const VK_MAP: Record<string, number> = {
ctrl: 0x11, control: 0x11, shift: 0x10, alt: 0x12, win: 0x5b, meta: 0x5b,
enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, space: 0x20,
backspace: 0x08, delete: 0x2e, del: 0x2e, insert: 0x2d, ins: 0x2d,
up: 0x26, down: 0x28, left: 0x25, right: 0x27,
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
capslock: 0x14, printscreen: 0x2c,
f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7a, f12: 0x7b,
};
/** 键名 → 虚拟键码(字母/数字走 ASCII 映射,其余查表白名单)。 */
function toVk(key: string): number {
const k = key.trim().toLowerCase();
if (VK_MAP[k] !== undefined) return VK_MAP[k]!;
if (/^[a-z]$/.test(k)) return k.toUpperCase().charCodeAt(0);
if (/^[0-9]$/.test(k)) return k.charCodeAt(0);
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的按键: ${key}`, {
supported: [...Object.keys(VK_MAP), 'a-z', '0-9'],
});
}
export async function keyType(args: Args): Promise<unknown> {
assertAllowed('input.key.type');
requireWindows('input.key.type');
@@ -303,35 +364,96 @@ export async function keyPress(args: Args): Promise<unknown> {
assertAllowed('input.key.press');
requireWindows('input.key.press');
// v10.2:支持三种语义
// 1) keys —— 单个和弦(如 ["ctrl","c"])
// 2) repeat —— 和弦重复 N 次(解决「连按上键 3 次」这类需求)
// 3) sequence —— 任意和弦序列(如 [["ctrl","c"], ["ctrl","v"]])
const sequence = args['sequence'] as string[][] | undefined;
const repeat = Math.max(1, Math.min(50, (args['repeat'] as number | undefined) ?? 1));
const keys = args['keys'] as string[] | undefined;
// v1.5:入参统一经 protocol 的 expandChords 展开 —— 字符串热键 / 预设名 / 老数组形式
// 全部收敛成「归一化和弦序列」。混用多种形式会直接报错,避免"以谁为准"的歧义。
let expanded;
try {
expanded = expandChords({
hotkey: args['hotkey'] as string | undefined,
hotkeys: args['hotkeys'] as string[] | undefined,
preset: args['preset'] as string | undefined,
presets: args['presets'] as string[] | undefined,
keys: args['keys'] as string[] | undefined,
sequence: args['sequence'] as string[][] | undefined,
repeat: args['repeat'] as number | undefined,
});
} catch (err) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `快捷键参数错误: ${err instanceof Error ? err.message : String(err)}`);
}
const chords = expanded.chords;
const presets = expanded.presets;
const chords: string[][] = sequence?.length
? sequence
: keys?.length
? Array.from({ length: repeat }, () => keys)
: [];
if (chords.length === 0) {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, '需提供 keys(可配 repeat)或 sequence');
const gap = Math.max(0, Math.min(2000, (args['interval_ms'] as number) ?? 40));
// v1.5:长按(按下到释放的保持时长),用于"长按音量/持续按住方向键"等场景
const holdMs = Math.max(0, Math.min(5000, Math.round((args['hold_ms'] as number | undefined) ?? 0)));
// v1.5:投放路由。foreground = SendInput 注入当前焦点(默认,旧行为);
// post = 向目标进程全部顶层窗口 PostMessage(窗口在后台时可用,但兼容性差)
const route = (args['route'] as string | undefined) ?? 'foreground';
if (route !== 'foreground' && route !== 'post') {
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `不支持的 route: ${route}`, { allowed: ['foreground', 'post'] });
}
const pid = (args['target_pid'] as number) ?? 0;
if (route === 'post' && pid <= 0) {
throw new CapabilityError(
ErrorCodes.PARAM_INVALID,
'route=post 需要提供 target_pid(≥1)—— 后台投递必须知道投给哪个进程',
{ hint: '用 window.list 取目标进程 PID;无目标时请用默认 route=foreground' },
);
}
const body: string[] = [];
const gap = Math.max(0, Math.min(2000, (args['interval_ms'] as number | undefined) ?? 40));
for (let i = 0; i < chords.length; i += 1) {
const chord = chords[i]!;
if (chord.length === 0) continue;
const vks = chord.map(toVk);
for (const vk of vks) body.push(`[NAInput]::Vk(${vk}, $false)`);
// 逆序释放,保证组合键正确
for (const vk of [...vks].reverse()) body.push(`[NAInput]::Vk(${vk}, $true)`);
if (i < chords.length - 1 && gap > 0) body.push(`Start-Sleep -Milliseconds ${gap}`);
// 媒体键走 WM_APPCOMMAND 通道(2026-10-05 补)
//
// 仅当**整条序列都是媒体键**时启用。若混在普通按键里(如 ["ctrl","media_play_pause"])
// 则回退到 VK 注入 —— 那种组合场景本就罕见,SendInput 足够。
const allMedia = isAllMedia(chords);
if (allMedia) {
// target_pid 非 0 时定向投递到该进程的全部顶层窗口(QQ 音乐会建 30+ 辅助窗口,
// 只投主窗口常常无效 —— 实测踩过);否则广播。
const mediaBody = buildMediaBody(chords, { gapMs: gap, holdMs, targetPid: pid, route });
await runPs(mediaBody);
return {
pressed: true,
keys: chords.length === 1 ? chords[0] : undefined,
chords,
times: chords.length,
presets,
via: expanded.via,
channel: 'appcommand',
target_pid: pid || null,
};
}
await runPs(body);
// v1.5:后端定向投递(route=post)—— 目标窗口不在前台时使用
if (route === 'post') {
let sent = 0;
const out = await runPs(buildPostBody(chords, { gapMs: gap, holdMs, targetPid: pid, route }));
// 累计投递成功的窗口数(C# 侧每次 PostMessage 成功计数)
let total = 0;
for (const line of out.split('\n')) {
const m = /(\d+)/.exec(line.trim());
if (m) total += Number(m[1]);
}
sent = total;
return {
pressed: true,
keys: chords.length === 1 ? chords[0] : undefined,
chords,
times: chords.length,
presets,
via: expanded.via,
channel: 'postmessage',
route,
target_pid: pid,
sent_windows: sent,
note:
sent === 0
? '未投递到任何窗口(进程可能已退出或没有任何顶层窗口)'
: '后台投递兼容性有限:游戏/部分浏览器与输入型程序不响应后台键盘消息,' +
'无效时改用 route=foreground 并确保目标窗口可见',
};
}
await runPs(buildForegroundBody(chords, { gapMs: gap, holdMs, targetPid: pid, route }));
// 同时回传 keys(单和弦时的回显)与 chords(完整序列)。
// 修正 2026-10-04:此前只回 chords,控制端 CLI 读 keys.join() 直接崩溃。
return {
@@ -339,5 +461,9 @@ export async function keyPress(args: Args): Promise<unknown> {
keys: chords.length === 1 ? chords[0] : undefined,
chords,
times: chords.length,
presets,
via: expanded.via,
hold_ms: holdMs || undefined,
route,
};
}
+5 -5
View File
@@ -55,7 +55,6 @@ async function isAdmin(): Promise<boolean> {
// 故一律用 typeof 探测,绝不能直接引用(否则 dev 启动就 ReferenceError)。
declare const __AGENT_VERSION__: string | undefined;
declare const __BUILD_COMMIT__: string | undefined;
declare const __BUILD_TIME__: string | undefined;
/** v21:读取当前生效的来源白名单(用于 system.info 上报)。 */
function configAllowFrom(): string[] | null {
@@ -67,10 +66,9 @@ function configAllowFrom(): string[] | null {
}
}
function injected(name: 'version' | 'commit' | 'time'): string {
function injected(name: 'version' | 'commit'): string {
if (name === 'version') return typeof __AGENT_VERSION__ === 'undefined' ? 'dev' : __AGENT_VERSION__;
if (name === 'commit') return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__;
return typeof __BUILD_TIME__ === 'undefined' ? 'dev' : __BUILD_TIME__;
return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__;
}
const STARTED_AT = Date.now();
@@ -118,7 +116,9 @@ export function buildInfo(): {
// v20:语义化版本 + 提交 + 构建时间(来自构建期注入)
version: injected('version'),
commit: injected('commit'),
built_at: injected('time'),
// 构建时间取入口文件的 mtime(而非构建期注入)—— 注入会让每次打包字节不同,
// 破坏「指纹 = 构建身份」的确定性(v22 真机踩过)
built_at: buildCache.mtime_ms ? new Date(buildCache.mtime_ms).toISOString() : 'unknown',
// v21:TLS 证书指纹(控制端据此钉住被控端身份)
cert_sha256: currentCertFingerprint(),
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/cli",
"version": "1.4.2",
"version": "1.5.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+53 -6
View File
@@ -25,6 +25,7 @@ import {
} from '@nodeagent/client';
import {
CapabilityNames,
HOTKEY_PRESETS,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
@@ -61,7 +62,15 @@ export async function cmdScreen(opts: Options): Promise<void> {
export async function cmdScreenshot(opts: Options): Promise<void> {
const args: Record<string, unknown> = {};
if (opts.format) args['format'] = opts.format;
if (opts.scale) args['scale'] = Number(opts.scale);
if (opts.scale) {
const s = Number(opts.scale);
// 协议限定 scale ∈ (0, 1](缩小)。原先直接把非法值发出去,
// 被控端只回一句「参数校验失败」,看不出是哪一项 —— 2026-10-05 实测踩过。
if (!Number.isFinite(s) || s <= 0 || s > 1) {
fail(`--scale 应在 0 到 1 之间(1=原尺寸,0.5=半尺寸),收到: ${opts.scale}`);
}
args['scale'] = s;
}
if (opts.region) {
const nums = opts.region.split(',').map(Number);
if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n))) fail('--region 格式应为 x,y,width,height');
@@ -163,19 +172,57 @@ export async function cmdKey(action: string | undefined, positionals: string[],
return;
}
case 'press': {
if (positionals.length === 0) fail('用法: nodeagent key press <键1> [键2] ...(如 ctrl c)');
// v1.5:三种用法(一次一种)
// nodeagent key press "ctrl+shift+esc" 字符串热键
// nodeagent key press copy 预设名
// nodeagent key press ctrl c 老形式(空格分隔的各键)
if (positionals.length === 0) fail('用法: nodeagent key press "<热键>" | <预设名> | <键1> [键2] ...(如 "ctrl+shift+esc" / copy / ctrl c)');
const args: Record<string, unknown> = {};
let positionalsEcho = positionals;
if (positionals.length === 1) {
const one = positionals[0]!;
// 含分隔符 → 字符串热键;不含但命中预设表 → 预设;都不中 → 单键(也当热键交给被控端解析)
const spec = HOTKEY_PRESETS[one.trim().toLowerCase()];
if (spec) {
args['preset'] = one.trim().toLowerCase();
} else {
args['hotkey'] = one;
}
} else {
args['keys'] = positionals;
}
// 老形式说明:位置参数以空格分隔 → 兼容"ctrl c"(无 + 号)
if (opts.interval) args['interval_ms'] = Number(opts.interval);
// v1.5 新增可选参数
if (opts.hold) args['hold_ms'] = Number(opts.hold);
if (opts.route) args['route'] = opts.route;
// 媒体键可定向到指定进程(播放器常建 30+ 辅助窗口,只投主窗口往往无效)
if (opts.pid) args['target_pid'] = Number(opts.pid);
await withClient((c) =>
callAndPrint(c, CapabilityNames.KeyPress, { keys: positionals }, opts.json, (d) => {
callAndPrint(c, CapabilityNames.KeyPress, args, opts.json, (d) => {
// 被控端返回 { pressed, chords, times },**没有 keys 字段**
// (真机 2026-10-04 实测:原先直接读 r.keys.join() 会抛
// "Cannot read properties of undefined (reading 'join')")
// 故优先用请求参数回显,缺失时再退回 chords 展开。
const r = d as { keys?: string[]; chords?: string[][]; times?: number };
const r = d as {
keys?: string[]; chords?: string[][]; times?: number; via?: string;
presets?: string[]; channel?: string; route?: string; sent_windows?: number;
target_pid?: number | null; note?: string;
};
const shown = (r.chords ?? []).map((ch) => ch.join('+')).join(' → ');
const label = r.keys?.length
? r.keys.join('+')
: (r.chords ?? []).map((ch) => ch.join('+')).join(' → ') || positionals.join('+');
: shown || positionalsEcho.join('+');
const times = r.times && r.times > 1 ? `(重复 ${r.times} 次)` : '';
console.log(`✓ 已按下 ${label}${times}`);
const presetEcho = r.presets?.length ? `(预设 ${r.presets.join('→')} = ${shown})` : '';
const via =
r.channel === 'appcommand'
? `(媒体通道${r.target_pid ? ` → pid ${r.target_pid}` : ' · 广播'})`
: r.channel === 'postmessage'
? `(后台投递 → pid ${r.target_pid},命中 ${r.sent_windows ?? 0} 窗口)`
: '';
console.log(`✓ 已按下 ${label}${times}${presetEcho}${via}`);
if (r.note) console.log(` ℹ️ ${r.note}`);
}),
);
return;
+3
View File
@@ -72,6 +72,9 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
nodeagent mouse scroll <delta>
nodeagent key type "<文本>" [--interval 10]
nodeagent key press <键1> [键2] ... (组合键,如 ctrl c)
nodeagent key press media_play_pause --pid 1234 (媒体键,v1.2:走 WM_APPCOMMAND,
--pid 定向到该进程的全部顶层窗口;省略则广播。
窗口被遮挡/最小化/游戏全屏时依然有效)
通用选项:
--node <设备名> 本次命令临时指定目标设备(不改变 current)
+7
View File
@@ -54,6 +54,10 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
button: { type: 'string' },
duration: { type: 'string' },
interval: { type: 'string' },
// 媒体键定向投递的目标进程 PID(v1.2)
pid: { type: 'string' },
hold: { type: 'string' },
route: { type: 'string' },
'auth-mode': { type: 'string' },
since: { type: 'string' },
type: { type: 'string' },
@@ -123,6 +127,9 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
button: values['button'] as string | undefined,
duration: values['duration'] as string | undefined,
interval: values['interval'] as string | undefined,
pid: values['pid'] as string | undefined,
hold: values['hold'] as string | undefined,
route: values['route'] as string | undefined,
authMode: values['auth-mode'] as string | undefined,
since: values['since'] as string | undefined,
type: values['type'] as string | undefined,
+6
View File
@@ -16,6 +16,12 @@ export interface Options {
button?: string;
duration?: string;
interval?: string;
/** 媒体键定向投递的目标进程 PID(v1.2 keyPress) */
/** v1.5 快捷键扩展:长按毫秒 */
hold?: string;
/** v1.5 快捷键扩展:投递路由 foreground|post */
route?: string;
pid?: string;
/** v3:认证模式 psk | ed25519 */
authMode?: string;
/** v3+ 审计查询 */
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/hub",
"version": "1.4.2",
"version": "1.5.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/mcp",
"version": "1.4.2",
"version": "1.5.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+35 -5
View File
@@ -207,13 +207,35 @@ const TOOLS = [
{
name: 'na_key',
description:
'控制被控端键盘(需被控端已开启输入控制)。action: type 输入一段文本;press 按下组合键(如 ["ctrl","c"])。',
'控制被控端键盘(需被控端已开启输入控制)。\n' +
'action: type = 输入一段文本;press = 按**快捷键**。\n' +
'press 支持四种形式(一次只用一种):\n' +
'① hotkey 单条字符串,如 "ctrl+shift+esc"、"win+d"、"alt+tab";\n' +
'② hotkeys 字符串序列,如 ["ctrl+c","ctrl+v"];\n' +
'③ preset 预设语义名,如 copy/paste/save/show_desktop/lock_screen/screenshot/' +
'zoom_in/music_next/volume_mute_toggle(约 50 个);\n' +
'④ keys 老形式数组,如 ["ctrl","c"]。\n' +
'按键覆盖:字母数字、F1-F24、左右侧修饰键(lalt/rctrl/lwin…)、小键盘(numpad7、numpad_add…)、' +
'OEM 符号键(oem_plus/oem_comma/oem_period…,支持 win+d、ctrl++、win+. 这类组合)、' +
'媒体键(media_next/music_play_pause…)、浏览器键、IME 键、系统键。\n' +
'可配:hold_ms 长按(≤5000);route=foreground(默认,注入当前焦点)或 ' +
'route=post(向 target_pid 指定进程的全部顶层窗口 PostMessage,目标在后台时可用,' +
'但游戏/部分输入型程序不响应后台键消息)。\n' +
'媒体键整条序列会自动走 WM_APPCOMMAND(可定向 target_pid,窗口被遮挡也有效)。',
inputSchema: {
type: 'object',
properties: {
action: { type: 'string', enum: ['type', 'press'], description: '操作类型' },
text: { type: 'string', description: 'type 时输入的文本' },
keys: { type: 'array', items: { type: 'string' }, description: 'press 时的按键列表' },
hotkey: { type: 'string', maxLength: 64, description: '单条热键字符串,如 "ctrl+shift+esc";支持 + - 空格分隔' },
hotkeys: { type: 'array', items: { type: 'string' }, maxItems: 50, description: '热键字符串序列,如 ["ctrl+c","ctrl+v"]' },
preset: { type: 'string', description: '预设语义名(单个),如 copy / show_desktop / task_manager' },
presets: { type: 'array', items: { type: 'string' }, maxItems: 50, description: '多个预设名 = 序列' },
keys: { type: 'array', items: { type: 'string' }, description: '(老形式)和弦按键数组,如 ["ctrl","c"]' },
repeat: { type: 'integer', minimum: 1, maximum: 50, description: '重复次数(默认 1)' },
hold_ms: { type: 'integer', minimum: 0, maximum: 5000, description: '长按保持毫秒数(按下后延时再释放)' },
route: { type: 'string', enum: ['foreground', 'post'], description: '投递路由,默认 foreground' },
target_pid: { type: 'integer', minimum: 0, description: '目标进程 PID(媒体键定向或 route=post 必填)' },
},
required: ['action'],
additionalProperties: false,
@@ -740,10 +762,18 @@ function resolveToolCall(toolName: string, input: Record<string, unknown>): Reso
return { capability: CapabilityNames.KeyType, args: { text: input['text'] } };
}
if (action === 'press') {
if (!Array.isArray(input['keys']) || input['keys'].length === 0) {
return { error: 'press 需要非空 keys 数组' };
// v1.5:四种形式一次只用一种(hotkeys > hotkey > presets > preset > keys),
// 其余各端会做同样的校验与展开,这里只做薄校验后原样透传。
const has = (k: string): boolean =>
input[k] !== undefined && !(Array.isArray(input[k]) && (input[k] as unknown[]).length === 0);
const forms = ['hotkeys', 'hotkey', 'presets', 'preset', 'keys'].filter(has);
if (forms.length === 0) return { error: 'press 需要 hotkey / hotkeys / preset / presets / keys 之一' };
if (forms.length > 1) return { error: `参数混用:${forms.join(' + ')},一次请只用一种形式` };
const args: Record<string, unknown> = { [forms[0]!]: input[forms[0]!] };
for (const k of ['repeat', 'hold_ms', 'route', 'target_pid', 'interval_ms']) {
if (input[k] !== undefined) args[k] = input[k];
}
return { capability: CapabilityNames.KeyPress, args: { keys: input['keys'] } };
return { capability: CapabilityNames.KeyPress, args };
}
return { error: `不支持的 action: ${String(action)}` };
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "nodeagent",
"private": true,
"version": "1.4.2",
"version": "1.5.0",
"description": "跨机 AI 接管框架 —— 让 Mac 上的 AI 无缝接管 Windows,装软件、查状态",
"type": "module",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/client",
"version": "1.4.2",
"version": "1.5.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/protocol",
"version": "1.4.2",
"version": "1.5.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
@@ -186,33 +186,106 @@ export const GRAPHICS_CAPABILITIES: CapabilityDescriptor[] = [
},
{
name: CapabilityNames.KeyPress,
version: '1.1',
version: '1.5',
description:
'按下按键。keys=["ctrl","c"] 表示和弦;配 repeat=3 可连按 3 次;' +
'sequence=[["ctrl","c"],["ctrl","v"]] 表示按键序列',
'按下**快捷键**(v1.5 大幅扩展范围)。支持的输入形式(**一次只用一种**):' +
'① 字符串热键 hotkey: "ctrl+shift+esc"(配 repeat 可连按);② 热键序列 hotkeys: ["ctrl+c","ctrl+v"];' +
'③ 预设别名 preset: "copy" / presets: ["copy","paste"](约 50 个语义名,免记键位);' +
'④ 老数组形式 keys: ["ctrl","c"](配 repeat);⑤ 序列 sequence: [["up"],["enter"]]。' +
'**按键覆盖**:字母/数字、F1-F24、左右侧修饰键(lctrl/rctrl/…)、小键盘(numpad0-9 与四则运算,' +
'与数字键分开)、OEM 符号键(oem_plus/oem_comma/oem_period… → 支持 win+d、ctrl++、win+. 这类组合)、' +
'媒体键、浏览器键、IME 键(kana/convert/…)、系统键(sleep/help/…)。' +
'**长按**:hold_ms(按下到释放的保持时长,≤5s)。' +
'**投放路由**:route=foreground(默认,SendInput 注入当前焦点);' +
'route=post(向 target_pid 指定进程的**全部顶层窗口** PostMessage —— 目标在后台/被遮挡时可用,' +
'⚠️ 但游戏与部分输入型程序不响应后台键盘消息)。' +
'**媒体键**:整条序列都是媒体键时自动改走 WM_APPCOMMAND(可定向 target_pid、不受遮挡影响)。' +
'音量键(volume_mute/up/down)仍走虚拟键 0xAD-0xAF,由系统自行映射。',
risk: 'high',
params_schema: {
type: 'object',
properties: {
hotkey: {
type: 'string',
maxLength: 64,
description: '单条热键字符串,如 "ctrl+shift+esc"、"win+d"、"alt+tab";分隔符支持 + - 空格',
},
hotkeys: {
type: 'array',
items: { type: 'string', maxLength: 64 },
minItems: 1,
maxItems: 50,
description: '热键字符串序列,如 ["ctrl+c","ctrl+v"](按键序列)',
},
preset: { type: 'string', description: '预设语义名(单个),如 copy / show_desktop / volume_mute_toggle' },
presets: {
type: 'array',
items: { type: 'string' },
minItems: 1,
maxItems: 50,
description: '多个预设名 = 序列',
},
keys: {
type: 'array',
items: { type: 'string' },
minItems: 1,
maxItems: 4,
description: '和弦按键列表,如 ["ctrl","shift","esc"]',
description: '(老形式)和弦按键列表,如 ["ctrl","shift","esc"]',
},
repeat: { type: 'integer', minimum: 1, maximum: 50, default: 1, description: '和弦重复次数' },
repeat: { type: 'integer', minimum: 1, maximum: 50, default: 1, description: '和弦/热键重复次数' },
sequence: {
type: 'array',
items: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 4 },
description: '和弦序列,如 [["up"],["up"],["enter"]];提供时忽略 keys/repeat',
description: '(老形式)和弦序列,如 [["up"],["up"],["enter"]]',
},
hold_ms: {
type: 'integer',
minimum: 0,
maximum: 5000,
default: 0,
description: 'v1.5 长按:按下后保持该毫秒数再释放(如长按音量键)',
},
route: {
type: 'string',
enum: ['foreground', 'post'],
default: 'foreground',
description:
'v1.5 投放路由:foreground = SendInput 注入当前焦点(默认,需目标可见);' +
'post = 向 target_pid 进程全部顶层窗口 PostMessage(目标在后台时可用)',
},
target_pid: {
type: 'integer',
minimum: 0,
description:
'目标进程 PID。媒体键:0 或省略 = 广播给所有顶层窗口(播放器常建 30+ 辅助窗口,' +
'指定 PID 会投给该进程全部顶层窗口)。route=post:必填(≥1)。',
},
interval_ms: {
type: 'integer',
minimum: 0,
maximum: 2000,
default: 40,
description: '序列中相邻和弦之间的间隔',
},
},
additionalProperties: false,
},
returns_schema: {
type: 'object',
properties: { pressed: { type: 'boolean' }, keys: { type: 'array', items: { type: 'string' } } },
properties: {
pressed: { type: 'boolean' },
keys: { type: 'array', items: { type: 'string' } },
chords: { type: 'array', items: { type: 'array', items: { type: 'string' } } },
times: { type: 'integer' },
presets: { type: 'array', items: { type: 'string' } },
via: { type: 'string', description: '输入形式来源:hotkey/hotkeys/preset/presets/keys/sequence' },
channel: { type: 'string', description: '实际投递通道:sendinput / appcommand / postmessage' },
route: { type: 'string' },
hold_ms: { type: 'integer' },
target_pid: { type: 'integer' },
sent_windows: { type: 'integer', description: 'route=post 时投递成功的窗口数' },
note: { type: 'string' },
},
},
},
{
@@ -257,7 +330,12 @@ export const GRAPHICS_CAPABILITIES: CapabilityDescriptor[] = [
{
name: CapabilityNames.WindowFocus,
version: '1.0',
description: '将指定窗口(按标题正则或句柄)置前并聚焦,返回其精确矩形。',
description:
'将指定窗口(按标题正则或句柄)置前并聚焦,返回其精确矩形。' +
'⚠️ 边界:**无法越过全屏独占应用**(游戏、演示全屏)。此时会返回 focused:true,' +
'但目标窗口在**视觉上仍被遮挡**、鼠标点击也点不到它 —— 属预期行为而非失败。' +
'此时请改用与可见性无关的通道:input.key.press 的媒体键(走 WM_APPCOMMAND)、' +
'system.shell.exec,或让用户手动切出全屏。',
risk: 'medium',
params_schema: {
type: 'object',
+261
View File
@@ -0,0 +1,261 @@
/**
* 快捷键(hotkey)解析与预设(v1.5)。
*
* ## 为什么放在 protocol 包
* 解析逻辑必须**两端一致 + 可单测**:控制端 CLI 先把字符串展开成和弦列表用于本地校验,
* 被控端再展开一次用于执行。纯函数 + 无副作用,测试直接 import dist 即可。
*
* ## 支持的形式
* 1. `hotkey: "ctrl+shift+esc"` 单个和弦(字符串)
* 2. `hotkeys: ["ctrl+c","ctrl+v"]` 序列
* 3. `preset: "copy"` / `presets: [...]` 别名 → 展开成热键字符串后按上两条走
* 4. `keys: ["ctrl","c"]` 老形式(数组),保留兼容
*
* 分隔符支持 `+ - 空格`;大小写不敏感;`control/command/cmd` 等别名归一。
*/
/** 规范按键名(agent 侧 VK_MAP 必须全覆盖,单测有断言)。 */
export const HOTKEY_KEYS: ReadonlySet<string> = new Set([
// 修饰键(含左右区分)
'ctrl', 'lctrl', 'rctrl', 'shift', 'lshift', 'rshift',
'alt', 'lalt', 'ralt', 'win', 'lwin', 'rwin',
// 导航/编辑
'enter', 'tab', 'esc', 'space', 'backspace', 'delete', 'insert',
'up', 'down', 'left', 'right', 'home', 'end', 'pageup', 'pagedown',
'capslock', 'numlock', 'scrolllock', 'printscreen', 'pause', 'apps',
// 功能键
...Array.from({ length: 24 }, (_, i) => `f${i + 1}`),
// 媒体键(音量键只走 VK 注入,见 agent 侧 MEDIA_COMMANDS 注释)
'media_play', 'media_pause', 'media_play_pause', 'media_stop',
'media_prev', 'media_next', 'media_fast_forward', 'media_rewind',
'volume_mute', 'volume_down', 'volume_up',
// 浏览器/系统启动
'browser_back', 'browser_forward', 'browser_refresh', 'browser_stop',
'browser_search', 'browser_favorites', 'browser_home',
'launch_mail', 'launch_media', 'launch_app1', 'launch_app2',
// OEM 符号键(美式布局 VK 码,覆盖 +1-9 之外最常用的符号)
'oem_minus', 'oem_plus', 'oem_comma', 'oem_period',
'oem_1', 'oem_2', 'oem_3', 'oem_4', 'oem_5', 'oem_6', 'oem_7', 'oem_8',
// 系统/IME 杂项
'sleep', 'help', 'select', 'execute', 'clear', 'separator', 'oem_clear',
'kana', 'hangul', 'kanji', 'hanja', 'convert', 'nonconvert', 'accept', 'modechange',
]);
/** 键名别名 → 规范名。 */
export const HOTKEY_ALIASES: Readonly<Record<string, string>> = {
control: 'ctrl', 'left_ctrl': 'lctrl', 'right_ctrl': 'rctrl',
left_shift: 'lshift', right_shift: 'rshift',
left_alt: 'alt', right_alt: 'ralt', menu: 'apps', context_menu: 'apps',
cmd: 'win', 'command': 'win', super: 'win', meta: 'win',
left_win: 'lwin', right_win: 'rwin', left_cmd: 'lwin', right_cmd: 'rwin',
esc: 'esc', escape: 'esc', del: 'delete', ins: 'insert',
ret: 'enter', return: 'enter', spacebar: 'space',
pgup: 'pageup', pgdn: 'pagedown',
caps: 'capslock', num: 'numlock', scroll: 'scrolllock',
prtsc: 'printscreen', print: 'printscreen',
break: 'pause',
mute: 'volume_mute', volume: 'volume_mute',
vol_up: 'volume_up', vol_up_key: 'volume_up',
vol_down: 'volume_down',
prev: 'media_prev', 'prev_track': 'media_prev', previous: 'media_prev',
next: 'media_next', next_track: 'media_next',
play: 'media_play', pause: 'media_pause',
play_pause: 'media_play_pause', ff: 'media_fast_forward', rew: 'media_rewind',
semi: 'oem_1', colon: 'oem_1', slash: 'oem_2', qmark: 'oem_2',
backquote: 'oem_3', tilde: 'oem_3', grave: 'oem_3',
lbrace: 'oem_4', lbracket: 'oem_4', bslash: 'oem_5', pipe: 'oem_5',
rbrace: 'oem_6', rbracket: 'oem_6', quote: 'oem_7', apos: 'oem_7',
minus: 'oem_minus', underscore: 'oem_minus',
plus: 'oem_plus', equal: 'oem_plus', equals: 'oem_plus',
comma: 'oem_comma', lt: 'oem_comma',
period: 'oem_period', dot: 'oem_period', gt: 'oem_period',
// 小键盘
numpad_enter: 'enter', numpad_0: 'numpad0',
};
/**
* 小键盘键名 → canonical 名(单独成组,便于校验覆盖)。
* 注意:numpad enter 归一到 enter(多数程序不区分),其余 numpadN 与数字 N 分开
* —— 部分应用(如 Excel)靠 NumLock 状态区分二者行为。
*/
export const NUMPAD_KEYS: readonly string[] = [
'numpad0', 'numpad1', 'numpad2', 'numpad3', 'numpad4',
'numpad5', 'numpad6', 'numpad7', 'numpad8', 'numpad9',
'numpad_add', 'numpad_subtract', 'numpad_multiply', 'numpad_divide', 'numpad_decimal',
];
/** 常用快捷键预设:语义名 → 热键字符串。AI/MCP 直接用语义名,免记键位。 */
export const HOTKEY_PRESETS: Readonly<Record<string, string>> = {
// 编辑
copy: 'ctrl+c', paste: 'ctrl+v', paste_plain: 'ctrl+shift+v',
cut: 'ctrl+x', undo: 'ctrl+z', redo: 'ctrl+y', select_all: 'ctrl+a',
// 文件/窗口
save: 'ctrl+s', save_as: 'ctrl+shift+s', new: 'ctrl+n', open: 'ctrl+o',
close_tab: 'ctrl+w', close_window: 'alt+f4', print: 'ctrl+p',
find: 'ctrl+f', replace: 'ctrl+h', find_next: 'f3',
// 标签页
new_tab: 'ctrl+t', next_tab: 'ctrl+tab', prev_tab: 'ctrl+shift+tab',
reopen_tab: 'ctrl+shift+t',
// 刷新/缩放/视图
refresh: 'f5', hard_refresh: 'ctrl+f5',
zoom_in: 'ctrl+oem_plus', zoom_out: 'ctrl+oem_minus', zoom_reset: 'ctrl+0',
fullscreen: 'f11',
// 窗口管理
switch_app: 'alt+tab', switch_app_back: 'alt+shift+tab',
task_view: 'win+tab', task_manager: 'ctrl+shift+esc',
show_desktop: 'win+d', peek_desktop: 'win+oem_comma',
minimize_all: 'win+m', maximize: 'win+up', minimize: 'win+down',
snap_left: 'win+left', snap_right: 'win+right',
rename: 'f2', properties: 'alt+enter', delete_permanent: 'shift+delete',
// 系统
lock_screen: 'win+l', run: 'win+r', run_dialog: 'win+r',
explorer: 'win+e', search: 'win+s', settings: 'win+i',
clipboard_history: 'win+v', emoji_picker: 'win+oem_period',
notifications: 'win+a', widgets: 'win+w', dictation: 'win+h',
screenshot: 'win+shift+s',
// 虚拟桌面
new_desktop: 'ctrl+win+d', close_desktop: 'ctrl+win+f4',
desktop_left: 'ctrl+win+left', desktop_right: 'ctrl+win+right',
// 媒体(映射到媒体键)
music_play_pause: 'media_play_pause', music_next: 'media_next',
music_prev: 'media_prev', music_stop: 'media_stop',
volume_add: 'volume_up', volume_sub: 'volume_down', volume_mute_toggle: 'volume_mute',
};
const ALIAS_INVERSE: ReadonlyMap<string, string> = new Map(
Object.entries(HOTKEY_ALIASES).map(([alias, canon]) => [alias, canon]),
);
/** 归一化单个键名(别名 → 规范名),大小写不敏感;非法键抛错。 */
export function normalizeKey(key: string): string {
const k = key.trim().toLowerCase();
if (!k) throw new Error(`空按键名`);
const canon = ALIAS_INVERSE.get(k) ?? k;
if (/^[a-z]$/.test(canon) || /^[0-9]$/.test(canon)) return canon;
if (NUMPAD_KEYS.includes(canon)) return canon;
if (HOTKEY_KEYS.has(canon)) return canon;
throw new Error(`不支持的按键: ${key}`);
}
/**
* 解析一条热键字符串为和弦数组(按键已归一化)。
* 例:`"ctrl+shift+esc"` → `["ctrl","shift","esc"]`;`"win+d"` → `["win","d"]`。
*/
export function parseHotkey(input: string): string[] {
const parts = input
.trim()
.split(/[+\-\s]+/)
.filter((p) => p.length > 0);
if (parts.length === 0) throw new Error('空热键字符串');
if (parts.length > 4) {
throw new Error('单个和弦最多 4 个键(Windows 输入模型的实用上限)');
}
return parts.map(normalizeKey);
}
/** 展开一个预设名为和弦。 */
export function expandPreset(name: string): string[] {
const spec = HOTKEY_PRESETS[name.trim().toLowerCase()];
if (!spec) {
throw new Error(
`未知预设: ${name}(共 ${Object.keys(HOTKEY_PRESETS).length} 个可用,见 HOTKEY_PRESETS)`,
);
}
return parseHotkey(spec);
}
export interface KeyPressInput {
/** 单条热键字符串(如 "ctrl+shift+esc"),可与 repeat 配合 */
hotkey?: string;
/** 多条热键字符串 = 序列 */
hotkeys?: string[];
/** 预设名(单个) */
preset?: string;
/** 预设名(多个 = 序列) */
presets?: string[];
/** 老形式:和弦数组 */
keys?: string[];
/** 老形式:序列 */
sequence?: string[][];
repeat?: number;
}
export interface ExpandedChords {
/** 归一化后的和弦序列 */
chords: string[][];
/** 命中的预设名(用于回显),非预设来源为空数组 */
presets: string[];
/** 来源形式,便于回显与排查 */
via: 'hotkeys' | 'hotkey' | 'presets' | 'preset' | 'keys' | 'sequence';
}
/**
* 把各种输入形式统一展开为**归一化和弦序列**。
*
* 优先级(只取其一,混用即报错——避免"到底以谁为准"的歧义):
* hotkeys > hotkey > presets > preset > sequence > keys
*/
export function expandChords(input: KeyPressInput): ExpandedChords {
const provided: string[] = [];
if (input.hotkeys && input.hotkeys.length > 0) provided.push('hotkeys');
if (input.hotkey !== undefined) provided.push('hotkey');
if (input.presets && input.presets.length > 0) provided.push('presets');
if (input.preset !== undefined) provided.push('preset');
if (input.sequence && input.sequence.length > 0) provided.push('sequence');
if (input.keys && input.keys.length > 0) provided.push('keys');
if (provided.length === 0) {
throw new Error('需提供 hotkey / hotkeys / preset / presets / keys / sequence 之一');
}
if (provided.length > 1) {
throw new Error(`参数混用:${provided.join(' + ')} —— 一次请只用一种形式`);
}
const presets: string[] = [];
let chords: string[][];
if (provided[0] === 'hotkeys') {
chords = (input.hotkeys ?? []).map(parseHotkey);
} else if (provided[0] === 'hotkey') {
const chord = parseHotkey(input.hotkey as string);
const repeat = Math.max(1, Math.min(50, input.repeat ?? 1));
chords = Array.from({ length: repeat }, () => chord);
} else if (provided[0] === 'presets') {
chords = [];
for (const p of input.presets ?? []) {
presets.push(p.trim().toLowerCase());
chords.push(expandPreset(p));
}
} else if (provided[0] === 'preset') {
presets.push((input.preset as string).trim().toLowerCase());
chords = [expandPreset(input.preset as string)];
} else if (provided[0] === 'sequence') {
chords = (input.sequence ?? []).map((ch) => ch.map(normalizeKey));
} else {
const chord = (input.keys ?? []).map(normalizeKey);
const repeat = Math.max(1, Math.min(50, input.repeat ?? 1));
chords = Array.from({ length: repeat }, () => chord);
}
// 合法性:每条非空、≤4 键、修饰键不重复(同一和弦里两个 ctrl 无意义且可能互相抵消)
const seenModifier = new Set<string>();
for (const chord of chords) {
if (chord.length === 0) throw new Error('空和弦');
if (chord.length > 4) throw new Error(`单和弦最多 4 键:${chord.join('+')}`);
seenModifier.clear();
for (const k of chord) {
if (!isModifier(k)) continue;
if (seenModifier.has(k)) throw new Error(`同一和弦中重复的修饰键: ${k}`);
seenModifier.add(k);
}
}
return { chords, presets, via: provided[0] as ExpandedChords['via'] };
}
function isModifier(k: string): boolean {
return (
k === 'ctrl' || k === 'lctrl' || k === 'rctrl' ||
k === 'shift' || k === 'lshift' || k === 'rshift' ||
k === 'alt' || k === 'lalt' || k === 'ralt' ||
k === 'win' || k === 'lwin' || k === 'rwin'
);
}
+1
View File
@@ -8,3 +8,4 @@ export * from './acl.js';
export * from './validate.js';
export * from './capabilities.js';
export * from './net.js';
export * from './hotkeys.js';
+3 -1
View File
@@ -97,11 +97,13 @@ async function main() {
'--target=node22',
'--format=esm',
'--banner:js=import { createRequire } from "node:module"; const require = createRequire(import.meta.url);',
// 刻意**不注入构建时间**:注入后同一份代码每次打包字节都不同,
// 「指纹比对是否最新」就永远失真(真机踩过:明明同版本却报不一致)。
// 构建时间改由入口文件的 mtime 反映(见 system.ts 的 build.built_at)。
// 注意:define 的值按 **JS 表达式** 解析,故用双引号包裹;写成单引号会让引号成为值的一部分
// (真机踩过:版本显示成 v"1.4.0")。
`--define:__AGENT_VERSION__=${JSON.stringify(pkg.version)}`,
`--define:__BUILD_COMMIT__=${JSON.stringify(commit)}`,
`--define:__BUILD_TIME__=${JSON.stringify(builtAt)}`,
`--outfile=${bundleOut}`,
],
{ stdio: 'inherit' },
+19
View File
@@ -254,3 +254,22 @@ test('capabilityDiff:真实清单与自身比对应无差异(防手滑改坏
assert.equal(d.missingOnRemote.length, 0);
assert.equal(d.unknownLocally.length, 0);
});
// ---------- v1.5 快捷键扩展:新参数必须通过 schema 校验 ----------
test('input.key.press:v1.5 新参数(hotkey/hotkeys/preset/hold_ms/route)通过校验', () => {
assert.equal(check('input.key.press', { hotkey: 'ctrl+shift+esc' }).length, 0);
assert.equal(check('input.key.press', { hotkeys: ['ctrl+c', 'ctrl+v'] }).length, 0);
assert.equal(check('input.key.press', { preset: 'copy' }).length, 0);
assert.equal(check('input.key.press', { presets: ['copy', 'paste'] }).length, 0);
assert.equal(check('input.key.press', { hotkey: 'win+d', hold_ms: 300 }).length, 0);
assert.equal(check('input.key.press', { hotkey: 'alt+tab', route: 'post', target_pid: 4242 }).length, 0);
// 老形式仍应通过(向后兼容)
assert.equal(check('input.key.press', { keys: ['ctrl', 'c'] }).length, 0);
assert.equal(check('input.key.press', { sequence: [['up'], ['enter']] }).length, 0);
});
test('input.key.press:非法 route / 缺 target_pid 语义由 agent 层拒(schema 层只卡类型)', () => {
// route 枚举外值必须在 schema 层就被拒
assert.ok(check('input.key.press', { hotkey: 'a', route: 'sideways' }).length > 0, 'route 枚举外值应被拒');
});
+145
View File
@@ -0,0 +1,145 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
parseHotkey,
normalizeKey,
expandChords,
expandPreset,
HOTKEY_PRESETS,
HOTKEY_KEYS,
NUMPAD_KEYS,
} from '../../packages/protocol/dist/index.js';
const ROOT = join(import.meta.dirname, '..', '..');
// ---------- parseHotkey ----------
test('parseHotkey:基本分隔符与大小写', () => {
assert.deepEqual(parseHotkey('ctrl+shift+esc'), ['ctrl', 'shift', 'esc']);
assert.deepEqual(parseHotkey('Ctrl+Shift+Esc'), ['ctrl', 'shift', 'esc']);
assert.deepEqual(parseHotkey('win - d'), ['win', 'd'], '分隔符支持 - 与空格');
assert.deepEqual(parseHotkey('alt + tab'), ['alt', 'tab']);
assert.deepEqual(parseHotkey('ctrl c'), ['ctrl', 'c'], '空格也可作分隔符');
});
test('parseHotkey:别名归一', () => {
assert.deepEqual(parseHotkey('command+space'), ['win', 'space']);
assert.deepEqual(parseHotkey('control+a'), ['ctrl', 'a']);
assert.deepEqual(parseHotkey('del'), ['delete']);
assert.deepEqual(parseHotkey('pgup'), ['pageup']);
assert.deepEqual(parseHotkey('numpad_enter+c'), ['enter', 'c']);
});
test('parseHotkey:符号键(快捷键覆盖的关键增量)', () => {
assert.deepEqual(parseHotkey('win+d'), ['win', 'd']);
assert.deepEqual(parseHotkey('win+oem_comma'), ['win', 'oem_comma']);
assert.deepEqual(parseHotkey('ctrl+oem_plus'), ['ctrl', 'oem_plus']);
assert.deepEqual(parseHotkey('ctrl+oem_minus'), ['ctrl', 'oem_minus']);
assert.deepEqual(parseHotkey('ctrl+0'), ['ctrl', '0']);
});
test('parseHotkey:小键盘与左右修饰键', () => {
assert.deepEqual(parseHotkey('numpad5'), ['numpad5']);
assert.deepEqual(parseHotkey('lalt+tab'), ['lalt', 'tab']);
assert.deepEqual(parseHotkey('rwin+d'), ['rwin', 'd']);
});
test('parseHotkey:非法输入必须报错(不静默吞掉)', () => {
assert.throws(() => parseHotkey(''), /空热键|不支持/);
assert.throws(() => parseHotkey('ctrl+nope'), /不支持的按键: nope/);
assert.throws(() => parseHotkey('a+b+c+d+e'), /最多 4 个键/, '单和弦超限必须拒');
});
// ---------- expandChords ----------
test('expandChords:hotkey + repeat 连按', () => {
const r = expandChords({ hotkey: 'up', repeat: 3 });
assert.deepEqual(r.chords, [['up'], ['up'], ['up']]);
assert.equal(r.via, 'hotkey');
});
test('expandChords:hotkeys 序列与 presets 序列', () => {
assert.deepEqual(expandChords({ hotkeys: ['ctrl+c', 'ctrl+v'] }).chords, [['ctrl', 'c'], ['ctrl', 'v']]);
const p = expandChords({ presets: ['copy', 'paste'] });
assert.deepEqual(p.chords, [['ctrl', 'c'], ['ctrl', 'v']]);
assert.deepEqual(p.presets, ['copy', 'paste']);
assert.equal(p.via, 'presets');
});
test('expandChords:老形式 keys/sequence 仍然可用(向后兼容)', () => {
assert.deepEqual(expandChords({ keys: ['ctrl', 'shift', 'esc'] }).chords, [['ctrl', 'shift', 'esc']]);
assert.deepEqual(expandChords({ sequence: [['up'], ['enter']] }).chords, [['up'], ['enter']]);
assert.equal(expandChords({ keys: ['ctrl'], repeat: 2 }).chords.length, 2);
});
test('expandChords:参数混用必须报错(消除"以谁为准"歧义)', () => {
assert.throws(() => expandChords({ hotkey: 'ctrl+a', keys: ['ctrl', 'a'] }), /混用/);
assert.throws(() => expandChords({}), /需提供/);
});
test('expandChords:同一和弦重复修饰键必须报错', () => {
assert.throws(() => expandChords({ keys: ['ctrl', 'ctrl', 'a'] }), /重复的修饰键/);
});
// ---------- 预设表 ----------
test('HOTKEY_PRESETS:每个预设都可展开且合法', () => {
for (const [name, spec] of Object.entries(HOTKEY_PRESETS)) {
const chord = parseHotkey(spec);
assert.ok(chord.length >= 1 && chord.length <= 4, `${name} → ${spec} 键数越界`);
assert.deepEqual(expandPreset(name), chord, `${name} 展开不一致`);
}
});
test('HOTKEY_PRESETS:媒体类预设映射到媒体键(可走 APPCOMMAND 通道)', () => {
assert.deepEqual(expandPreset('music_play_pause'), ['media_play_pause']);
assert.deepEqual(expandPreset('volume_mute_toggle'), ['volume_mute']);
assert.deepEqual(expandPreset('volume_add'), ['volume_up']);
});
test('HOTKEY_PRESETS:符号键类预设可展开(证明 oem 链路可用)', () => {
assert.deepEqual(expandPreset('show_desktop'), ['win', 'd']);
assert.deepEqual(expandPreset('emoji_picker'), ['win', 'oem_period']);
assert.deepEqual(expandPreset('zoom_in'), ['ctrl', 'oem_plus']);
});
test('expandPreset:未知预设必须报错', () => {
assert.throws(() => expandPreset('nope'), /未知预设/);
});
// ---------- 覆盖断言:protocol 声明的键 agent 必须能映射 ----------
test('覆盖断言:HOTKEY_KEYS + NUMPAD_KEYS 全部存在于 agent 侧 VK_MAP', () => {
const src = readFileSync(join(ROOT, 'apps/agent/src/capabilities/input-script.ts'), 'utf8');
// 抓 VK_MAP 对象体的键名(到 "媒体键名 → APPCOMMAND" 注释前为止)
const m = /const VK_MAP: Record<string, number> = \{([\s\S]*?)\n\};/.exec(src);
assert.ok(m, '未找到 VK_MAP 定义');
const declared = new Set(
[...String(m[1]).matchAll(/(?:^|[\s,])([a-z_][a-z0-9_]*)\s*:/gm)].map((x) => String(x[1])),
);
const missing = [];
for (const k of HOTKEY_KEYS) if (!declared.has(k)) missing.push(k);
for (const k of NUMPAD_KEYS) if (!declared.has(k)) missing.push(`numpad:${k}`);
assert.deepEqual(missing, [], `protocol 声明的按键在 agent 侧未映射: ${missing.join(', ')}`);
});
test('覆盖断言:每个预设只用已知键(防止表与实现漂移)', () => {
const known = new Set([...HOTKEY_KEYS, ...NUMPAD_KEYS]);
const bad = [];
for (const [name, spec] of Object.entries(HOTKEY_PRESETS)) {
for (const k of parseHotkey(spec)) {
if (!known.has(k) && !/^[a-z0-9]$/.test(k)) bad.push(`${name}→${k}`);
}
}
assert.deepEqual(bad, []);
});
test('normalizeKey:单键归一化', () => {
assert.equal(normalizeKey('CTRL'), 'ctrl');
assert.equal(normalizeKey(' command '), 'win');
assert.equal(normalizeKey('F12'), 'f12');
assert.equal(normalizeKey('a'), 'a');
assert.throws(() => normalizeKey(''), /空按键名/);
});
+127
View File
@@ -0,0 +1,127 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
VK_MAP,
MEDIA_COMMANDS,
toVk,
isAllMedia,
buildMediaBody,
buildForegroundBody,
buildPostBody,
} from '../../apps/agent/dist/capabilities/input-script.js';
const OPTS = { gapMs: 40, holdMs: 0, targetPid: 0, route: 'foreground' };
// ---------- toVk / 按键映射 ----------
test('toVk:修饰键与扩展按键', () => {
assert.equal(toVk('ctrl'), 0x11);
assert.equal(toVk(' lalt '), 0xa4);
assert.equal(toVk('rctrl'), 0xa3);
assert.equal(toVk('rwin'), 0x5c);
assert.equal(toVk('d'), 0x44, '字母走 ASCII 大写映射');
assert.equal(toVk('7'), 0x37);
assert.equal(toVk('numpad7'), 0x67, 'numpad7 与 7 必须不同 VK');
assert.notEqual(toVk('numpad7'), toVk('7'));
assert.equal(toVk('numpad_add'), 0x6b);
assert.equal(toVk('oem_plus'), 0xbb);
assert.equal(toVk('oem_comma'), 0xbc);
assert.equal(toVk('oem_period'), 0xbe);
assert.equal(toVk('f13'), 0x7c);
assert.equal(toVk('convert'), 0x1c);
assert.equal(toVk('sleep'), 0x5f);
});
test('toVk:未知键必须抛错', () => {
assert.throws(() => toVk('nope'), /不支持的按键/);
});
// ---------- 前台注入脚本 ----------
test('buildForegroundBody:按下顺序 + 释放必须逆序(组合键生死线)', () => {
const body = buildForegroundBody([['ctrl', 'c']], OPTS);
assert.deepEqual(body, ['[NAInput]::Vk(17, $false)', '[NAInput]::Vk(67, $false)', '[NAInput]::Vk(67, $true)', '[NAInput]::Vk(17, $true)']);
// 断言语义:第一个 down 是修饰键,第一个 up 是主键(即释放逆序)
assert.ok(body[0].includes('Vk(17'), '按顺序按下:ctrl 先');
assert.ok(body[2].includes('Vk(67'),'逆序释放:主键先起');
});
test('buildForegroundBody:hold_ms 插在"按完"与"释放"之间', () => {
const body = buildForegroundBody([['ctrl', 'a']], { ...OPTS, holdMs: 500 });
assert.ok(body.includes('Start-Sleep -Milliseconds 500'));
const sleepAt = body.indexOf('Start-Sleep -Milliseconds 500');
assert.ok(body[sleepAt - 1].includes('$false'), '长按前必须是按下动作');
assert.ok(body[sleepAt + 1].includes('$true'), '长按后必须是释放动作');
});
test('buildForegroundBody:多和弦之间插 interval', () => {
const body = buildForegroundBody([['ctrl', 'c'], ['ctrl', 'v']], { ...OPTS, gapMs: 100 });
assert.ok(body.includes('Start-Sleep -Milliseconds 100'));
// 间隔只出现在两个和弦之间(不应出现在和弦内部,hold=0 时)
assert.equal(body.filter((l) => l.includes('-Milliseconds 100')).length, 1);
});
test('buildForegroundBody:hold=0 时不产生多余 sleep', () => {
const body = buildForegroundBody([['ctrl', 'c']], { ...OPTS, holdMs: 0 });
assert.equal(body.filter((l) => l.includes('Start-Sleep')).length, 0);
});
// ---------- 媒体键脚本 ----------
test('buildMediaBody:使用 APPCOMMAND 常量而非 VK', () => {
const body = buildMediaBody([['media_next'], ['media_play_pause']], { ...OPTS, gapMs: 0 });
assert.deepEqual(body, ['[NAInput]::MediaCommand(7, 0)', '[NAInput]::MediaCommand(12, 0)']);
});
test('buildMediaBody:target_pid 透传(定向该进程全窗口)', () => {
const body = buildMediaBody([['media_next']], { ...OPTS, targetPid: 4242 });
assert.deepEqual(body, ['[NAInput]::MediaCommand(7, 4242)']);
});
test('isAllMedia:混入普通键则整条不走 APPCOMMAND', () => {
assert.ok(isAllMedia([['media_next'], ['media_stop']]));
assert.ok(!isAllMedia([['media_next'], ['enter']]), '混入 enter 必须回退 VK 注入');
assert.ok(!isAllMedia([['ctrl', 'c']]));
assert.ok(!isAllMedia([]), '空序列不算全媒体(避免空走媒体通道)');
});
test('音量键不在 MEDIA_COMMANDS 表(避免 APPCOMMAND device 位歧义)', () => {
assert.ok(!('volume_mute' in MEDIA_COMMANDS));
assert.ok(!('volume_up' in MEDIA_COMMANDS));
assert.ok(!('volume_down' in MEDIA_COMMANDS));
});
// ---------- 后台 PostMessage 脚本 ----------
test('buildPostBody:按下顺序 + 释放逆序,且带 PID', () => {
const body = buildPostBody([['ctrl', 'c']], { gapMs: 0, holdMs: 0, targetPid: 777, route: 'post' });
assert.deepEqual(body, [
'[NAInput]::PostChord(@(17, 67), 777, $false)',
'[NAInput]::PostChord(@(67, 17), 777, $true)',
]);
});
test('buildPostBody:hold 与 interval 与前台语义一致', () => {
const body = buildPostBody([['alt', 'tab'], ['esc']], { gapMs: 50, holdMs: 300, targetPid: 9, route: 'post' });
assert.deepEqual(body, [
'[NAInput]::PostChord(@(18, 9), 9, $false)',
'Start-Sleep -Milliseconds 300',
'[NAInput]::PostChord(@(9, 18), 9, $true)',
'Start-Sleep -Milliseconds 50',
'[NAInput]::PostChord(@(27), 9, $false)',
'Start-Sleep -Milliseconds 300',
'[NAInput]::PostChord(@(27), 9, $true)',
]);
});
// ---------- 回归:real-world 快捷键展开后的脚本 ----------
test('端到端脚本样例:win+d 显示桌面(OEM 符号键链路)', () => {
const body = buildForegroundBody([['win', 'd']], OPTS);
assert.deepEqual(body, ['[NAInput]::Vk(91, $false)', '[NAInput]::Vk(68, $false)', '[NAInput]::Vk(68, $true)', '[NAInput]::Vk(91, $true)']);
});
test('VK_MAP 与 MEDIA_COMMANDS 存在且非空(防误清空)', () => {
assert.ok(Object.keys(VK_MAP).length > 60, '按键表应已覆盖扩展键');
assert.ok(Object.keys(MEDIA_COMMANDS).length >= 10);
});