Files
nodeagent/apps/cli/src/cmd/gui.ts
T
jkooe aa72241080 fix: 真机全量测试修 5 个 bug(剪贴板假失败 / 音频后端误标 / CLI info 打印 / key press 崩溃 / Unicode 代理对)
在 192.168.1.100 真机逐项跑通 42 项能力,暴露并修复 5 个真 bug:

1. clip.set 假失败(间歇 ~20%)
   Set-Clipboard 抛「所请求的剪贴板操作失败」时 exit_code 仍为 0(只写 stderr),
   且内容其实已写入 —— 造成「成功却报错」。改为重试 3 次 + 退避 + 读回校验,
   不再凭 exit_code 下结论。验证:修前 2/5 失败 → 修后 0/8 失败。

2. Windows 音频 backend 误标 'CoreAudio'
   CoreAudio 是 macOS 的后端名;Windows 实际走 C# NAudio(CoreAudioAPI)。
   抽常量并修正 manifest 描述,避免排查时误判平台。

3. CLI info 把嵌套对象打成 [object Object]
   ps_helper / build 是嵌套对象,String(v) 丢失全部内容。新增 renderInfoValue():
   null→—、空串→—(未设置)、对象→key=value 紧凑摘要(180 字截断)。

4. nodeagent key press 必崩(100% 复现)
   字段契约不一致:被控端返回 {pressed,chords,times}(无 keys),
   CLI 读 r.keys.join() 抛 TypeError。双端对齐 —— agent 补回 keys 字段,
   CLI 改为 keys ?? chords 展开 ?? 参数回显。

5. Unicode 注入拆坏代理对(emoji / 生僻字)
   UnicodeChar(char) 逐字符注入,BMP 外字符被拆成两个孤立代理项,
   目标应用收到非法序列渲染 U+FFFD。新增 C# UnicodeString() 按 UTF-16 码元注入。
   注:KEYEVENTF_UNICODE 扫描码位仅 16 位,代理对必须各自单独发(API 固有限制)。

验证:单测 147 全绿;真机验收 15 通过 / 0 失败 / 1 跳过;双端已部署上线。
另确认中文偶发错字为 IME 上屏竞争,非注入逻辑问题(30 次对照实验全对)。
2026-10-04 23:40:25 +08:00

191 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
import fsSync from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path, { join } from 'node:path';
import {
NodeAgentClient,
loadMacroFile,
runMacro,
ClientError,
loadConfig,
saveConfig,
configPath,
toWsUrl,
loadKeys,
createKeys,
keysFilePath,
discoverOnce,
resolveTarget,
resolveNodeSelector,
emptyConfig,
type ClientConfig,
type ResolvedTarget,
type NodeProfile,
} from '@nodeagent/client';
import {
CapabilityNames,
matchPattern,
DEFAULT_DISCOVERY_PORT,
type CapabilityDescriptor,
type InvokeResult,
} from '@nodeagent/protocol';
import {
callAndPrint,
fail,
getClientConfig,
humanSize,
printJson,
riskIcon,
withClient,
withClientDirect,
} from '../core.js';
import { CHUNK_BYTES, type Options } from '../types.js';
// CLI 命令组:cmd/gui.ts
export async function cmdScreen(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.ScreenInfo, {}, opts.json, (data) => {
const rows = (
data as { displays: Array<{ id: number; name: string; width: number; height: number; is_primary: boolean }> }
).displays;
for (const d of rows) {
console.log(
` #${d.id} ${String(d.width).padStart(5)}x${String(d.height).padEnd(5)} ${d.is_primary ? '[主屏]' : ' '} ${d.name}`,
);
}
}),
);
}
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.region) {
const nums = opts.region.split(',').map(Number);
if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n))) fail('--region 格式应为 x,y,width,height');
args['region'] = { x: nums[0], y: nums[1], width: nums[2], height: nums[3] };
}
await withClient((c) =>
callAndPrint(c, CapabilityNames.ScreenCapture, args, opts.json, (data) => {
const d = data as { image: string; format: string; width: number; height: number; bytes: number };
const out = opts.out ?? `screenshot.${d.format === 'png' ? 'png' : 'jpg'}`;
writeFileSync(out, Buffer.from(d.image, 'base64'));
console.log(`✓ 已保存 ${out} ${d.width}x${d.height} ${humanSize(d.bytes)}`);
}),
);
}
export async function cmdMouse(action: string | undefined, positionals: string[], opts: Options): Promise<void> {
const [a, b] = positionals;
switch (action) {
case 'move': {
if (!a || !b) fail('用法: nodeagent mouse move <x> <y> [--duration 300]');
const args: Record<string, unknown> = { x: Number(a), y: Number(b) };
if (opts.duration) args['duration_ms'] = Number(opts.duration);
await withClient((c) =>
callAndPrint(c, CapabilityNames.MouseMove, args, opts.json, (d) => {
const r = d as { x: number; y: number };
console.log(`✓ 鼠标已移动到 (${r.x}, ${r.y})`);
}),
);
return;
}
case 'click': {
const args: Record<string, unknown> = {};
if (a && b) {
args['x'] = Number(a);
args['y'] = Number(b);
}
if (opts.button) args['button'] = opts.button;
await withClient((c) =>
callAndPrint(c, CapabilityNames.MouseClick, args, opts.json, (d) => {
const r = d as { x: number; y: number; button: string };
console.log(`✓ 已${r.button}键点击 (${r.x}, ${r.y})`);
}),
);
return;
}
case 'scroll': {
if (!a) fail('用法: nodeagent mouse scroll <delta> [y]');
const args: Record<string, unknown> = { delta: Number(a) };
if (b) args['y'] = Number(b);
await withClient((c) =>
callAndPrint(c, CapabilityNames.MouseScroll, args, opts.json, (d) => {
const r = d as { delta: number };
console.log(`✓ 已滚动 ${r.delta} 格`);
}),
);
return;
}
case 'drag': {
const [x1, y1, x2, y2] = positionals;
if (!x1 || !y1 || !x2 || !y2) {
fail('用法: nodeagent mouse drag <x1> <y1> <x2> <y2> [--button left|right|middle]');
}
const args: Record<string, unknown> = {
from_x: Number(x1),
from_y: Number(y1),
to_x: Number(x2),
to_y: Number(y2),
};
if (opts.button) args['button'] = opts.button;
if (opts.duration) args['step_delay_ms'] = Number(opts.duration);
await withClient((c) =>
callAndPrint(c, CapabilityNames.MouseDrag, args, opts.json, (d) => {
const r = d as { from: { x: number; y: number }; to: { x: number; y: number }; steps: number };
console.log(
`✓ 已拖拽 (${r.from.x}, ${r.from.y}) → (${r.to.x}, ${r.to.y}),${r.steps} 步`,
);
}),
);
return;
}
default:
fail('用法: nodeagent mouse <move|click|scroll> ...');
}
}
export async function cmdKey(action: string | undefined, positionals: string[], opts: Options): Promise<void> {
switch (action) {
case 'type': {
const text = positionals.join(' ');
if (!text) fail('用法: nodeagent key type "<文本>"');
const args: Record<string, unknown> = { text };
if (opts.interval) args['interval_ms'] = Number(opts.interval);
await withClient((c) =>
callAndPrint(c, CapabilityNames.KeyType, args, opts.json, (d) => {
const r = d as { length: number };
console.log(`✓ 已输入 ${r.length} 个字符`);
}),
);
return;
}
case 'press': {
if (positionals.length === 0) fail('用法: nodeagent key press <键1> [键2] ...(如 ctrl c)');
await withClient((c) =>
callAndPrint(c, CapabilityNames.KeyPress, { keys: positionals }, 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 label = r.keys?.length
? r.keys.join('+')
: (r.chords ?? []).map((ch) => ch.join('+')).join(' → ') || positionals.join('+');
const times = r.times && r.times > 1 ? `(重复 ${r.times} 次)` : '';
console.log(`✓ 已按下 ${label}${times}`);
}),
);
return;
}
default:
fail('用法: nodeagent key <type|press> ...');
}
}
// ---------- v3+ 审计 ----------
/** v11:审计链完整性校验。 */