## ① gui.await 补齐(路线图 §1 收尾清单第 2、3 项) 此前该能力只有实现、**单测 0 覆盖、CLI/MCP 0 入口**: - 新增 tests/unit/gui-await.test.mjs(6 例):能力登记与风险等级、四类条件 × 两态 schema、timeout 上限兜底、isHit 对五种返回形态的判定契约、 CLI/MCP 入口存在性 - CLI:`nodeagent await --condition <window|control|process|file> [--text/--title/ --process/--path] [--state absent] [--timeout ms]`,回显 satisfied/耗时/轮询次数/末次异常 - MCP:新增 na_await 工具(定义 + 分发),描述含典型编排示例 ## ② 源码注释脱敏(路线图序 0.5 / §4.0) input.ts 与 input-script.ts 里 4 处真实播放器名 / 游戏名泛化为 「主流媒体播放器」「全屏独占应用」「部分播放器会创建数十个辅助窗口」—— 不再画像「哪台机器装了什么、常在什么场景下远程控它」。grep 复核零残留。 ## ③ .gitignore 补密钥规则(路线图 §6.2) 此前只挡 PSK.txt,挡不住 keys/(ed25519 私钥)与 *.key / *.pem(TLS 私钥) —— 一旦误 add 就是私钥入公开库。*.pub 显式放行。 ## 验证 单测 215 → **221**(+6 全绿);e2e **30** 全绿;7 包构建通过; CLI await 实测到连接层(被控端当前离线,超时属预期)。
201 lines
8.5 KiB
TypeScript
201 lines
8.5 KiB
TypeScript
/**
|
||
* 输入注入的 **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 补
|
||
//
|
||
// 为何必要:远程控媒体播放器(音乐 / 视频 / 浏览器内视频)时,
|
||
// 若软件热键不可用(精简模式常不注册全局热键),系统媒体键是唯一
|
||
// 与「窗口是否可见/被遮挡」无关的通道 —— 窗口最小化、被游戏全屏盖住时
|
||
// 依然有效。实测:精简模式的播放器被全屏游戏遮挡时,媒体键仍能控制。
|
||
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;
|
||
}
|