在 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 次对照实验全对)。
191 lines
6.7 KiB
TypeScript
191 lines
6.7 KiB
TypeScript
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:审计链完整性校验。 */
|