Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7037c7ff94 | ||
|
|
689bb8c98d | ||
|
|
9d32592ed1 | ||
|
|
3ff8e86fe8 | ||
|
|
7689a1f80c | ||
|
|
6c650244bf | ||
|
|
a56d67c012 | ||
|
|
4106ada876 | ||
|
|
f88738d36d | ||
|
|
50ac4b9646 | ||
|
|
e841fbb187 |
No files matched your search
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/agent",
|
||||
"version": "1.3.0",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -167,15 +167,38 @@ export async function agentUpdate(args: Args): Promise<unknown> {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
// ⚠️ 必须**就地覆写**,不能用 rename 覆盖 —— Windows 不允许重命名/替换「正被运行进程
|
||||
// 打开的文件」(EPERM/EBUSY),而 node.exe 正持有 agent.mjs。真机踩过:rename 必失败。
|
||||
// 覆写打开中的文件是允许的(只有删除/重命名被禁),这也正是推送式 deploy 能工作的原因。
|
||||
try {
|
||||
renameSync(staged, scriptPath);
|
||||
writeFileSync(scriptPath, buf);
|
||||
} catch (err) {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '替换脚本失败', {
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入脚本失败(已保留备份,可回滚)', {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
backup_path: backup,
|
||||
staged_path: staged,
|
||||
});
|
||||
}
|
||||
// 写后校验:内容必须与刚校验过的下载一致,否则立即回滚(防止截断/半写)
|
||||
const written = createHash('sha256').update(readFileSync(scriptPath)).digest('hex');
|
||||
if (written !== got) {
|
||||
try {
|
||||
copyFileSync(backup, scriptPath); // 回滚
|
||||
} catch {
|
||||
/* 回滚失败时靠 backup_path 人工兜底 */
|
||||
}
|
||||
throw new CapabilityError(ErrorCodes.EXECUTION_FAILED, '写入后校验不一致,已自动回滚', {
|
||||
expected: got.slice(0, 12),
|
||||
actual: written.slice(0, 12),
|
||||
backup_path: backup,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
unlinkSync(staged); // 暂存文件已并入入口文件,及时清理
|
||||
} catch {
|
||||
/* 清理失败不影响正确性 */
|
||||
}
|
||||
|
||||
audit({
|
||||
type: 'agent.update',
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -8,8 +8,17 @@ export interface AgentConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
/** v1 简化:预共享密钥存于 600 权限文件;v2 升级 DPAPI */
|
||||
/** v1 简化:预共享密钥存于 600 权限文件;v2 升级 DPAPI。**所有调用方共用**,仅适合信任网络 */
|
||||
key: string;
|
||||
/**
|
||||
* v22:**每客户端一把**预共享密钥(`client_id` → key)。
|
||||
*
|
||||
* 为什么需要它:psk 模式下 `client_id` 由客户端自报,历史上「一把共享 key」等于
|
||||
* **持有者可冒充任意 client_id**,ACL 的身份维度形同虚设。
|
||||
* 改为每个 client_id 一把钥匙后,「知道 key A 就只能以 A 的身份进来」——
|
||||
* 身份才真正被绑定。未登记的 client_id 回落到上面的 `key`(旧部署行为不变)。
|
||||
*/
|
||||
keys?: Record<string, string>;
|
||||
log_level: 'debug' | 'info' | 'warn';
|
||||
/**
|
||||
* v2:是否允许输入控制(鼠标 / 键盘)。
|
||||
@@ -53,6 +62,17 @@ export interface AgentConfig {
|
||||
broadcast?: string;
|
||||
/** 广播间隔毫秒(默认 5000) */
|
||||
interval_ms?: number;
|
||||
/**
|
||||
* v22:广播详情级别。
|
||||
* - `full`(默认,向后兼容):携带地址/端口/认证方式/键鼠开关等全部信息
|
||||
* - `minimal`:只广播「存在 + node_id + 时间」,详情放进 `detail` 字段;
|
||||
* 配了 `secret` 时详情会被 **HMAC 认证**(控制端持钥才看得到、且能验证真伪)
|
||||
* ⚠️ 广播是**未认证**的:任何人收 UDP 都能看到内容。minimal 仍会暴露
|
||||
* 「这里有一台可被接管的机器」,但不再泄露地址、端口与认证方式。
|
||||
*/
|
||||
detail?: 'full' | 'minimal';
|
||||
/** v22:详情认证密钥(配合 detail=minimal 使用;配了就会自动启用 minimal) */
|
||||
secret?: string;
|
||||
};
|
||||
/**
|
||||
* v5:文件访问白名单(根目录列表)。
|
||||
|
||||
+69
-19
@@ -1,5 +1,5 @@
|
||||
import { createSocket, type Socket } from 'node:dgram';
|
||||
import { DEFAULT_DISCOVERY_PORT } from '@nodeagent/protocol';
|
||||
import { DEFAULT_DISCOVERY_PORT, computeHmac } from '@nodeagent/protocol';
|
||||
import { currentCertFingerprint } from './certs.js';
|
||||
|
||||
export { DEFAULT_DISCOVERY_PORT };
|
||||
@@ -10,20 +10,41 @@ export interface BeaconPayload {
|
||||
service: 'nodeagent';
|
||||
version: string;
|
||||
node_id: string;
|
||||
/**
|
||||
* 以下字段在 `full` 模式下直给在顶层;在 `minimal` 模式下收在 `detail` 里
|
||||
* (顶层只留「存在性 + node_id + 时间」)。故为可选。
|
||||
*/
|
||||
/** 被控端可达地址(由被控端自行判定) */
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
auth_mode: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
tls?: boolean;
|
||||
auth_mode?: string;
|
||||
/** 需要输入控制开关的状态,便于控制端提示 */
|
||||
input_enabled: boolean;
|
||||
input_enabled?: boolean;
|
||||
/**
|
||||
* v21:TLS 证书指纹(sha256 hex)。
|
||||
* ⚠️ 这是**未认证**广播,可被伪造 —— 只用于「看起来像不像」,不能作为身份凭据;
|
||||
* 真正的身份确认发生在握手后(auth_ok 里带指纹,且控制端会严格比对)。
|
||||
*/
|
||||
cert_sha256?: string | null;
|
||||
platform: string;
|
||||
platform?: string;
|
||||
/**
|
||||
* v22:`true` 表示这是**最小化**广播 —— 顶层只有存在性信息,
|
||||
* 真实地址/端口/认证方式等在 `detail` 里(有 secret 时带 `sig` 认证)。
|
||||
*/
|
||||
minimal?: boolean;
|
||||
/** v22:最小化广播里的详情(可能带 `sig`,由持有 secret 的控制端验证) */
|
||||
detail?: {
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
auth_mode: string;
|
||||
input_enabled: boolean;
|
||||
cert_sha256?: string | null;
|
||||
platform: string;
|
||||
};
|
||||
/** v22:详情签名(HMAC-SHA256 over `node_id|ts|JSON(detail)`),由持有 secret 的控制端验证 */
|
||||
sig?: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
@@ -34,8 +55,14 @@ export interface BeaconOptions {
|
||||
/** 广播地址;局域网内通常为 255.255.255.255 */
|
||||
broadcast: string;
|
||||
intervalMs: number;
|
||||
/** 报文中除 ts 外的固定字段 */
|
||||
base: Omit<BeaconPayload, 'service' | 'version' | 'ts'>;
|
||||
/** 报文中除 ts 外的固定字段(full 模式直给顶层的那组) */
|
||||
base: Required<
|
||||
Pick<BeaconPayload, 'node_id' | 'host' | 'port' | 'tls' | 'auth_mode' | 'input_enabled' | 'platform'>
|
||||
>;
|
||||
/** v22:详情级别;配了 secret 也会自动启用 minimal */
|
||||
detail?: 'full' | 'minimal';
|
||||
/** v22:详情认证密钥(HMAC-SHA256 over node_id|ts|detail) */
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export interface Beacon {
|
||||
@@ -63,16 +90,39 @@ export function startBeacon(opts: BeaconOptions, onError?: (msg: string) => void
|
||||
|
||||
const send = (): void => {
|
||||
if (!ready) return;
|
||||
const buf = Buffer.from(
|
||||
JSON.stringify({
|
||||
service: SERVICE_TAG,
|
||||
version: PROTOCOL_VERSION,
|
||||
cert_sha256: currentCertFingerprint(),
|
||||
...opts.base,
|
||||
ts: Date.now(),
|
||||
} satisfies BeaconPayload),
|
||||
'utf8',
|
||||
);
|
||||
const ts = Date.now();
|
||||
// v22 详情分级:minimal 模式只广播「存在性」,详情移入 detail{} 并用 secret 做 HMAC。
|
||||
// 未配 secret 时 detail 仍然明文(此时 sig 缺省,控制端会标为「未认证」)。
|
||||
const full = {
|
||||
host: opts.base.host,
|
||||
port: opts.base.port,
|
||||
tls: opts.base.tls,
|
||||
auth_mode: opts.base.auth_mode,
|
||||
input_enabled: opts.base.input_enabled,
|
||||
cert_sha256: currentCertFingerprint(),
|
||||
platform: opts.base.platform,
|
||||
};
|
||||
const minimal = opts.detail === 'minimal' || !!opts.secret;
|
||||
const payload: BeaconPayload = minimal
|
||||
? {
|
||||
service: SERVICE_TAG,
|
||||
version: PROTOCOL_VERSION,
|
||||
node_id: opts.base.node_id,
|
||||
minimal: true,
|
||||
detail: full,
|
||||
sig: opts.secret
|
||||
? computeHmac(opts.secret, `${opts.base.node_id}|${ts}|${JSON.stringify(full)}`)
|
||||
: undefined,
|
||||
ts,
|
||||
}
|
||||
: {
|
||||
service: SERVICE_TAG,
|
||||
version: PROTOCOL_VERSION,
|
||||
cert_sha256: currentCertFingerprint(),
|
||||
...opts.base,
|
||||
ts,
|
||||
};
|
||||
const buf = Buffer.from(JSON.stringify(payload), 'utf8');
|
||||
try {
|
||||
socket.send(buf, 0, buf.length, opts.port, opts.broadcast, (err) => {
|
||||
if (err) onError?.(`广播失败: ${err.message}`);
|
||||
|
||||
@@ -61,6 +61,9 @@ async function main(): Promise<void> {
|
||||
port: config.discovery?.port ?? DEFAULT_DISCOVERY_PORT,
|
||||
broadcast: config.discovery?.broadcast ?? '255.255.255.255',
|
||||
intervalMs: config.discovery?.interval_ms ?? 5000,
|
||||
// v22:详情分级 + 认证(配了 secret 自动启用 minimal)
|
||||
detail: config.discovery?.detail ?? 'full',
|
||||
secret: config.discovery?.secret,
|
||||
base: {
|
||||
node_id: config.node_id,
|
||||
host: ips[0] ?? '127.0.0.1',
|
||||
@@ -88,6 +91,19 @@ async function main(): Promise<void> {
|
||||
console.log(` 能力 : ${CAPABILITY_MANIFEST.length} 项`);
|
||||
console.log(` 认证模式 : ${auditMode}${auditMode === 'ed25519' ? `(已登记 ${config.acl?.clients.length ?? 0} 个调用方)` : ''}`);
|
||||
console.log(` 输入控制 : ${config.allow_input === true ? '已开启' : '已禁用(默认)'}`);
|
||||
// v22:明文广播会暴露「地址/端口/认证方式/键鼠是否开启」,未收敛时必须提醒
|
||||
if (config.discovery?.enabled !== false && !useHub) {
|
||||
const minimal = config.discovery?.detail === 'minimal' || !!config.discovery?.secret;
|
||||
if (!minimal) {
|
||||
console.log(
|
||||
' ⚠️ 发现广播为「明文全量」:同网段可见 IP/端口/认证方式/键鼠开关。' +
|
||||
'建议在 agent.json 设 discovery.detail="minimal"(可再配 secret 加密详情)',
|
||||
);
|
||||
} else if (!config.discovery?.secret) {
|
||||
console.log(' ℹ️ 发现广播已最小化,但 detail 未配 secret —— 详情仍是明文(可加 secret 让其带签名)');
|
||||
}
|
||||
}
|
||||
|
||||
const discoOn = config.discovery?.enabled !== false;
|
||||
console.log(
|
||||
` 局域网发现 : ${discoOn ? `已开启(UDP ${config.discovery?.port ?? DEFAULT_DISCOVERY_PORT} 心跳广播)` : '已关闭'}`,
|
||||
|
||||
@@ -132,6 +132,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
}
|
||||
const clientId = params.client_id ?? '?';
|
||||
let authorized: string[] | null = null;
|
||||
let keyKindUsed = 'ed25519';
|
||||
|
||||
if (authMode === 'ed25519') {
|
||||
const client = aclPolicy.clients.find((c) => c.client_id === clientId);
|
||||
@@ -156,13 +157,28 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
clientId,
|
||||
CAPABILITY_MANIFEST.map((c) => c.name),
|
||||
);
|
||||
} else if (!verifyHmac(cfg.key, state.nonce, params.hmac ?? '')) {
|
||||
log('warn', `鉴权失败 client_id=${clientId}`);
|
||||
audit({ type: 'auth.failure', client_id: clientId, remote: state.remote, reason: '预共享密钥校验失败' });
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '预共享密钥校验失败');
|
||||
// 稍作延迟再关闭,确保错误响应先送达控制端
|
||||
setTimeout(() => ws.close(), 100);
|
||||
return;
|
||||
} else {
|
||||
// v22:优先用「该 client_id 专属的钥匙」,未登记才回落到共享 key。
|
||||
// 这样「知道 key A」就只能以 A 的身份进来 —— ACL 的身份维度在 psk 模式下才有意义。
|
||||
const scopedKey = cfg.keys?.[clientId];
|
||||
const keyForClient = scopedKey ?? cfg.key;
|
||||
const keyKind = scopedKey ? 'scoped' : cfg.keys ? 'shared-fallback' : 'shared';
|
||||
if (!keyForClient || !verifyHmac(keyForClient, state.nonce, params.hmac ?? '')) {
|
||||
log('warn', `鉴权失败 client_id=${clientId} key=${keyKind}`);
|
||||
audit({
|
||||
type: 'auth.failure',
|
||||
client_id: clientId,
|
||||
remote: state.remote,
|
||||
reason: scopedKey
|
||||
? '该 client_id 的专属密钥校验失败'
|
||||
: '预共享密钥校验失败(未登记专属密钥,已回落到共享 key)',
|
||||
});
|
||||
sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '预共享密钥校验失败');
|
||||
// 稍作延迟再关闭,确保错误响应先送达控制端
|
||||
setTimeout(() => ws.close(), 100);
|
||||
return;
|
||||
}
|
||||
keyKindUsed = keyKind;
|
||||
}
|
||||
|
||||
state.nonce = null; // 一次性,用后即废
|
||||
@@ -172,7 +188,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore {
|
||||
state.authorized = authorized;
|
||||
log(
|
||||
'info',
|
||||
`鉴权通过 client_id=${clientId} mode=${authMode}` +
|
||||
`鉴权通过 client_id=${clientId} mode=${authMode} key=${keyKindUsed}` +
|
||||
(authorized ? `,授权 ${authorized.length}/${CAPABILITY_MANIFEST.length} 项能力` : ''),
|
||||
);
|
||||
audit({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/cli",
|
||||
"version": "1.3.0",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -25,8 +25,9 @@ import {
|
||||
} from '@nodeagent/client';
|
||||
import {
|
||||
CapabilityNames,
|
||||
matchPattern,
|
||||
DEFAULT_DISCOVERY_PORT,
|
||||
generateSharedKey,
|
||||
matchPattern,
|
||||
type CapabilityDescriptor,
|
||||
type InvokeResult,
|
||||
} from '@nodeagent/protocol';
|
||||
@@ -176,6 +177,31 @@ export async function cmdRemove(name: string | undefined, opts: Options): Promis
|
||||
|
||||
export async function cmdKeygen(opts: Options): Promise<void> {
|
||||
const clientId = opts.id ?? 'mac_01';
|
||||
|
||||
// v22:`--psk` 生成**每客户端专属**的预共享密钥(配合被控端 agent.json 的 keys{})。
|
||||
// 与 ed25519 的区别:这是「对称」方案 —— 实现简单、不用管公钥,但同样是**一把一个身份**
|
||||
// (知道 A 的 key 就冒充不了 B),适合不方便管理公钥的场景。
|
||||
if (opts.psk === true) {
|
||||
const psk = generateSharedKey();
|
||||
console.log('✓ 已生成该 client_id 的专属预共享密钥(psk 模式)');
|
||||
console.log(` client_id : ${clientId}`);
|
||||
console.log(` 密钥 : ${psk}`);
|
||||
console.log('\n请把下面这段加入被控端 agent.json(可与其它 client_id 并列):\n');
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ keys: { [clientId]: psk } },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log('\n然后控制端这样连(key 即上面的密钥):');
|
||||
console.log(` nodeagent connect <被控端IP> --port 8765 --key ${psk} --id ${clientId} --insecure`);
|
||||
console.log(
|
||||
'\n提示:登记了 keys{} 之后,未登记的 client_id 会回落到共享 key(若被控端仍配有 key)。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = createKeys(clientId);
|
||||
console.log('✓ 已生成 Ed25519 密钥对');
|
||||
console.log(` client_id : ${keys.client_id}`);
|
||||
@@ -206,7 +232,7 @@ export async function cmdDiscover(opts: Options): Promise<void> {
|
||||
|
||||
let nodes: Awaited<ReturnType<typeof discoverOnce>>;
|
||||
try {
|
||||
nodes = await discoverOnce(waitMs, { port });
|
||||
nodes = await discoverOnce(waitMs, { port, secret: opts.discoverSecret });
|
||||
} catch (err) {
|
||||
fail(`监听失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
@@ -222,11 +248,21 @@ export async function cmdDiscover(opts: Options): Promise<void> {
|
||||
if (opts.json) return printJson(nodes);
|
||||
|
||||
console.log(`发现 ${nodes.length} 台被控端:\n`);
|
||||
console.log(` ${'节点 ID'.padEnd(16)} ${'地址'.padEnd(20)} ${'协议'.padEnd(6)} ${'认证'.padEnd(8)} 平台`);
|
||||
console.log(
|
||||
` ${'节点 ID'.padEnd(16)} ${'地址'.padEnd(20)} ${'协议'.padEnd(6)} ${'认证'.padEnd(8)} ${'来源'.padEnd(10)} 平台`,
|
||||
);
|
||||
for (const n of nodes) {
|
||||
const ctl = n.input_enabled ? ' [输入控制已开]' : '';
|
||||
// v22:广播本身是未认证的,只有验签通过的才标 🔒
|
||||
const trust = n.authenticated ? '🔒 已认证' : '⚠️ 明文';
|
||||
console.log(
|
||||
` ${n.node_id.padEnd(16)} ${`${n.host}:${n.port}`.padEnd(20)} ${(n.tls ? 'wss' : 'ws').padEnd(6)} ${n.auth_mode.padEnd(8)} ${n.platform}${ctl}`,
|
||||
` ${n.node_id.padEnd(16)} ${`${n.host}:${n.port}`.padEnd(20)} ${(n.tls ? 'wss' : 'ws').padEnd(6)} ${n.auth_mode.padEnd(8)} ${trust.padEnd(10)} ${n.platform}${ctl}`,
|
||||
);
|
||||
}
|
||||
if (nodes.some((n) => !n.authenticated) && !opts.discoverSecret) {
|
||||
console.log(
|
||||
'\n ℹ️ 标「⚠️ 明文」的条目:广播可被同网段任意伪造,其地址/认证方式仅作参考。' +
|
||||
'\n 要可信发现,请给被控端 discovery.secret,并在此用 --discover-secret <同一密钥>。',
|
||||
);
|
||||
}
|
||||
const first = nodes[0]!;
|
||||
|
||||
+53
-6
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' },
|
||||
@@ -94,6 +98,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
url: { type: 'string' },
|
||||
sha256: { type: 'string' },
|
||||
'dry-run': { type: 'boolean', default: false },
|
||||
psk: { type: 'boolean', default: false },
|
||||
'discover-secret': { type: 'string' },
|
||||
'forget-cert': { type: 'boolean', default: false },
|
||||
check: { type: 'boolean', default: false },
|
||||
force: { type: 'boolean', default: false },
|
||||
@@ -121,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,
|
||||
@@ -152,6 +161,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri
|
||||
url: values['url'] as string | undefined,
|
||||
sha256: values['sha256'] as string | undefined,
|
||||
dryRun: Boolean(values['dry-run']),
|
||||
psk: Boolean(values['psk']),
|
||||
discoverSecret: values['discover-secret'] as string | undefined,
|
||||
forgetCert: Boolean(values['forget-cert']),
|
||||
check: Boolean(values['check']),
|
||||
force: Boolean(values['force']),
|
||||
|
||||
@@ -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+ 审计查询 */
|
||||
@@ -55,6 +61,10 @@ export interface Options {
|
||||
kind?: string;
|
||||
/** v12 宏变量 */
|
||||
vars?: string[];
|
||||
/** v22 发现广播认证密钥 */
|
||||
discoverSecret?: string;
|
||||
/** v22 每客户端 PSK */
|
||||
psk?: boolean;
|
||||
/** v21 证书指纹 */
|
||||
forgetCert?: boolean;
|
||||
/** v19 拉取式自更新 */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/hub",
|
||||
"version": "1.3.0",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/mcp",
|
||||
"version": "1.3.0",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
+35
-5
@@ -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)}` };
|
||||
}
|
||||
|
||||
+46
-2
@@ -72,12 +72,56 @@ nodeagent connect <地址> --port 8765 --key <密钥> --insecure --forget-cert
|
||||
- 判定按**前缀比特**而非字符串相等 —— Node 在 IPv6 监听上把 IPv4 客户端报成
|
||||
`::ffff:a.b.c.d`,已做归一化
|
||||
|
||||
## 4.5 发现广播最小化(v22)
|
||||
|
||||
UDP 广播是**未认证**的:默认配置下同网段任何人都能看到
|
||||
「哪台机器可接管 / IP / 端口 / 用什么认证 / 键鼠是否开着」。
|
||||
|
||||
```json
|
||||
"discovery": { "detail": "minimal", "secret": "<与控制端相同的密钥>" }
|
||||
```
|
||||
|
||||
- `detail: "minimal"`:顶层只留 `service / version / node_id / ts`,
|
||||
真实信息收进 `detail{}`(**同网段看不到地址、端口、认证方式、键鼠开关**)
|
||||
- 配了 `secret`:`detail` 带 **HMAC 签名**(`node_id|ts|JSON(detail)`),
|
||||
控制端用同一密钥验签 → 列表里显示 **🔒 已认证**;没配/验签失败显示 **⚠️ 明文**(仅作参考)
|
||||
- 控制端:`nodeagent discover --discover-secret <同一密钥>`
|
||||
- 未配置时 agent 启动会打印醒目告警,提示收敛
|
||||
|
||||
> 仍会暴露「这里有一台可被接管的机器」(存在性无法隐藏,除非关广播或改用 Hub)。
|
||||
> 真正的身份确认始终发生在**握手之后**(`auth_ok` 带证书指纹,控制端严格比对)。
|
||||
|
||||
## 4.6 每客户端一把 PSK(v22,psk 模式下的身份绑定)
|
||||
|
||||
psk 模式里 `client_id` 由客户端自报,所以**一把共享 key 意味着持有者可冒充任意 client_id**,
|
||||
ACL 的身份维度形同虚设。v22 起支持按 client_id 分配独立钥匙:
|
||||
|
||||
```bash
|
||||
# 控制端:为某个 client_id 生成专属 PSK
|
||||
nodeagent keygen --psk --id mac_02
|
||||
```
|
||||
|
||||
```json
|
||||
// 被控端 agent.json(可并列多个 client_id)
|
||||
{ "keys": { "mac_02": "<那把密钥>" } }
|
||||
```
|
||||
|
||||
效果(已实测):
|
||||
|
||||
| 场景 | 结果 |
|
||||
|---|---|
|
||||
| alice 用自己的 key | ✅ 通过 |
|
||||
| **alice 的 key 冒用 bob 身份** | ❌ `E_AUTH_FAILED` |
|
||||
| bob 用自己的 key | ✅ 通过 |
|
||||
| 未登记的 client_id 用共享 `key` | ✅ 通过(**旧部署行为不变**) |
|
||||
| 已登记的 client_id 用共享 `key` | ❌ 拒绝(**专属钥匙优先**,不被共享 key 绕过) |
|
||||
|
||||
## 5. 还没做的(后续批次)
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 发现报文最小化 | UDP 广播目前明文告知 IP/端口/认证方式/键鼠是否开启(可被伪造,仅作发现用) |
|
||||
| ed25519 默认化 | 新装仍默认 psk,ACL 的身份维度默认无效 |
|
||||
| ~~发现报文最小化~~ | ✅ **v22 已做**(见 §4.5) |
|
||||
| ed25519 默认化 | 新装仍默认 psk;v22 提供了更轻量的替代:`keys{}` 每客户端一把 PSK(见 §4.6) |
|
||||
| 握手失败封禁 | 目前只计数 + 审计,无按 IP 的临时拉黑 |
|
||||
| 审计链签名 | 哈希链可被「有 root 的攻击者」整链重写,缺外部锚定 |
|
||||
| 会话上限 | 无最大连接数与空闲断开 |
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nodeagent",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.5.0",
|
||||
"description": "跨机 AI 接管框架 —— 让 Mac 上的 AI 无缝接管 Windows,装软件、查状态",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/client",
|
||||
"version": "1.3.0",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSocket, type RemoteInfo, type Socket } from 'node:dgram';
|
||||
import { verifyHmac } from '@nodeagent/protocol';
|
||||
|
||||
/** 发现到的被控端。 */
|
||||
export interface DiscoveredNode {
|
||||
@@ -12,6 +13,11 @@ export interface DiscoveredNode {
|
||||
auth_mode: string;
|
||||
input_enabled: boolean;
|
||||
platform: string;
|
||||
/**
|
||||
* v22:详情是否**通过 HMAC 验签**(被控端配了 `discovery.secret` 且我方也持有)。
|
||||
* false/未定义 = 明文广播(任何人可伪造)→ 此时这些字段仅作展示,**不可当凭据**。
|
||||
*/
|
||||
authenticated?: boolean;
|
||||
/** 最近一次收到广播的时间 */
|
||||
last_seen: number;
|
||||
}
|
||||
@@ -23,6 +29,11 @@ export interface DiscoveryOptions {
|
||||
ttlMs?: number;
|
||||
/** 报文日志(调试用) */
|
||||
onLog?: (msg: string) => void;
|
||||
/**
|
||||
* v22:发现广播的**认证密钥**。与被控端 `discovery.secret` 一致时,
|
||||
* 最小化广播里的详情会带 HMAC 签名,我方验签通过才采信(`authenticated: true`)。
|
||||
*/
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
interface RawBeacon {
|
||||
@@ -35,6 +46,20 @@ interface RawBeacon {
|
||||
input_enabled?: boolean;
|
||||
platform?: string;
|
||||
ts?: number;
|
||||
cert_sha256?: string | null;
|
||||
/** v22:最小化广播标记(顶层只剩存在性,详情在 detail 里) */
|
||||
minimal?: boolean;
|
||||
detail?: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
tls?: boolean;
|
||||
auth_mode?: string;
|
||||
input_enabled?: boolean;
|
||||
cert_sha256?: string | null;
|
||||
platform?: string;
|
||||
};
|
||||
/** v22:详情签名(HMAC over `node_id|ts|JSON(detail)`) */
|
||||
sig?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,15 +104,31 @@ export class Discovery {
|
||||
}
|
||||
if (raw.service !== 'nodeagent' || !raw.node_id) return;
|
||||
|
||||
// v22:最小化广播 —— 顶层只有存在性,真实信息在 detail 里。
|
||||
// 持有 secret 时**必须验签**通过才采信 detail;否则标记为未认证(仅作展示)。
|
||||
const detail = raw.minimal && raw.detail ? raw.detail : raw;
|
||||
let authenticated = false;
|
||||
if (raw.minimal && raw.detail) {
|
||||
if (this.opts.secret && raw.sig) {
|
||||
authenticated = verifyHmac(
|
||||
this.opts.secret,
|
||||
`${raw.node_id}|${raw.ts}|${JSON.stringify(raw.detail)}`,
|
||||
raw.sig,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const node: DiscoveredNode = {
|
||||
node_id: raw.node_id,
|
||||
// 地址永远取「报文来源 IP」—— 一定可达(自报地址仅作参考)
|
||||
host: rinfo.address,
|
||||
advertised_host: raw.host ?? rinfo.address,
|
||||
port: Number(raw.port ?? 8765),
|
||||
tls: raw.tls !== false,
|
||||
auth_mode: raw.auth_mode ?? 'psk',
|
||||
input_enabled: raw.input_enabled === true,
|
||||
platform: raw.platform ?? 'unknown',
|
||||
advertised_host: detail.host ?? rinfo.address,
|
||||
port: Number(detail.port ?? 8765),
|
||||
tls: detail.tls !== false,
|
||||
auth_mode: detail.auth_mode ?? 'psk',
|
||||
input_enabled: detail.input_enabled === true,
|
||||
platform: detail.platform ?? 'unknown',
|
||||
authenticated,
|
||||
last_seen: Date.now(),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/protocol",
|
||||
"version": "1.3.0",
|
||||
"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',
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export * from './acl.js';
|
||||
export * from './validate.js';
|
||||
export * from './capabilities.js';
|
||||
export * from './net.js';
|
||||
export * from './hotkeys.js';
|
||||
+7
-3
@@ -97,9 +97,13 @@ async function main() {
|
||||
'--target=node22',
|
||||
'--format=esm',
|
||||
'--banner:js=import { createRequire } from "node:module"; const require = createRequire(import.meta.url);',
|
||||
`--define:__AGENT_VERSION__='"${pkg.version}"'`,
|
||||
`--define:__BUILD_COMMIT__='"${commit}"'`,
|
||||
`--define:__BUILD_TIME__='"${builtAt}"'`,
|
||||
// 刻意**不注入构建时间**:注入后同一份代码每次打包字节都不同,
|
||||
// 「指纹比对是否最新」就永远失真(真机踩过:明明同版本却报不一致)。
|
||||
// 构建时间改由入口文件的 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)}`,
|
||||
`--outfile=${bundleOut}`,
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
|
||||
@@ -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 枚举外值应被拒');
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { computeHmac, verifyHmac } from '../../packages/protocol/dist/index.js';
|
||||
|
||||
/**
|
||||
* v22:发现广播的详情签名。
|
||||
* 被控端与控制端必须**逐字节同构**,否则「🔒 已认证」永远不亮 —— 故这里做往返一致性测试。
|
||||
*/
|
||||
const detail = {
|
||||
host: '192.168.1.100',
|
||||
port: 8765,
|
||||
tls: true,
|
||||
auth_mode: 'psk',
|
||||
input_enabled: false,
|
||||
cert_sha256: null,
|
||||
platform: 'win32',
|
||||
};
|
||||
const nodeId = 'win_01';
|
||||
const ts = 1790000000000;
|
||||
const SECRET = 'S3CRET-DISCOVERY-KEY';
|
||||
|
||||
test('详情签名:同一输入两端算出同一值并验签通过', () => {
|
||||
const payload = `${nodeId}|${ts}|${JSON.stringify(detail)}`;
|
||||
const sig = computeHmac(SECRET, payload);
|
||||
assert.ok(verifyHmac(SECRET, payload, sig), '控制端应验签通过');
|
||||
});
|
||||
|
||||
test('详情签名:错误密钥 / 篡改详情 / 改时间戳 都必须验签失败', () => {
|
||||
const payload = `${nodeId}|${ts}|${JSON.stringify(detail)}`;
|
||||
const sig = computeHmac(SECRET, payload);
|
||||
assert.ok(!verifyHmac('wrong-key', payload, sig), '错误密钥应失败');
|
||||
const tampered = `${nodeId}|${ts}|${JSON.stringify({ ...detail, port: 9999 })}`;
|
||||
assert.ok(!verifyHmac(SECRET, tampered, sig), '篡改详情应失败');
|
||||
assert.ok(!verifyHmac(SECRET, `${nodeId}|${ts + 1}|${JSON.stringify(detail)}`, sig), '改时间戳应失败');
|
||||
});
|
||||
|
||||
test('详情签名:字段顺序变化会改变签名(提醒:两侧都按对象字面量顺序拼)', () => {
|
||||
const a = computeHmac(SECRET, JSON.stringify({ x: 1, y: 2 }));
|
||||
const b = computeHmac(SECRET, JSON.stringify({ y: 2, x: 1 }));
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
@@ -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(''), /空按键名/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user