fix(agent): 文本注入改为单次 SendInput 批量——按官方文档消除事件被打散的风险

实读 Keyboard Input Overview 后修正两处凭记忆的实现:
1. 原文承诺「单次调用的事件不会被其他输入插入」,此前拆成 down 批 + up 批
   两次调用,两次之间可能夹进真实键鼠事件,代理对存在被拆开的风险。合并为
   一次提交(全部 down 紧接全部 up)。
2. KEYBDINPUT 文档「只能与 KEYEVENTF_KEYUP 组合」那句按推断写成结论;
   改为如实标注为推断,并写明真机失败时的退路(只发 UNICODE|KEYUP 单事件)。
This commit is contained in:
郑博文 2026-10-30 11:41:54 +08:00
parent 174a3f8ac0
commit 55cc8edbc1

View File

@ -80,15 +80,24 @@ pub fn virtual_desktop() -> (i32, i32, i32, i32) {
/// [KEYBDINPUT (winuser.h)](https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-keybdinput) /// [KEYBDINPUT (winuser.h)](https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-keybdinput)
/// - `KEYEVENTF_UNICODE`(0x0004):`wVk` **必须为 0**,`wScan` 即要送出的 Unicode 字符; /// - `KEYEVENTF_UNICODE`(0x0004):`wVk` **必须为 0**,`wScan` 即要送出的 Unicode 字符;
/// 系统合成 `VK_PACKET` 按键事件,经 `TranslateMessage` 转为带该字符的 `WM_CHAR`。 /// 系统合成 `VK_PACKET` 按键事件,经 `TranslateMessage` 转为带该字符的 `WM_CHAR`。
/// - 该 flag「只能与 `KEYEVENTF_KEYUP` 组合」的含义是**不能与 SCANCODE/EXTENDEDKEY 混用**, /// - 文档原文是 "This flag can only be combined with the KEYEVENTF_KEYUP flag"。
/// 不是只能发 up 事件——down/up 成对与现有 `send_key` 形态一致。 /// 本项目**仍先发不带 KEYUP 的 down**(与现有 `send_key` 的 down/up 成对形态一致),
/// 把那句读作「不能与 SCANCODE/EXTENDEDKEY 混用」是**我的推断,文档没有明说**。
/// 若真机验收发现 down 事件被系统丢弃(表现为一个字符进两次或不进),
/// 退路是只发 `KEYEVENTF_UNICODE | KEYEVENTF_KEYUP` 单事件——改一处 `mk` 即可。
/// ///
/// 为什么一次性批量提交(而非逐字符一对 down/up): /// 为什么一次性批量提交(而非逐字符一对 down/up):
/// 代理对(U+1F600 = D83D DE00)的两个 code unit 必须在输入队列里**相邻**, /// 代理对(U+1F600 = D83D DE00)的两个 code unit 必须在输入队列里**相邻**,
/// 远端才会把它们合成成一个字符。逐字符成对(down,up,down,up)会在两个 /// 远端才会把它们合成成一个字符。逐字符成对(down,up,down,up)会在两个
/// down 之间插入一个 up 事件;Windows 对 VK_PACKET 的孤立代理项缓冲的确切 /// down 之间插入 up 事件;Windows 对 VK_PACKET 孤立代理项的缓冲规则文档
/// 规则我无法从文档确定(文档只说明"合成 WM_CHAR",未描述配对窗口), /// 没有描述,所以这里选择让两个 down 严格相邻的形态,由真机 emoji 验收(V7)定论。
/// 所以这里选择让两个 down 严格相邻的形态,最终由真机 emoji 验收(V7)定论。 ///
/// 为什么是**一次** `SendInput` 调用(而不是 down 批 + up 批两次):
/// [Keyboard Input Overview](https://learn.microsoft.com/en-us/windows/win32/inputdev/about-keyboard-input)
/// 的 Simulating Input 一节明确——单调用注入的一批事件"系统确保不会被其他输入事件
/// 打散"(the system ensures that no other input events intermingle with the
/// simulated events),两次调用之间则可能插入真实键盘/鼠标事件,代理对就有被打散风险。
/// 于是把 N 个 down + N 个 up 拼成一个数组一次提交,顺序仍是全部 down 先于全部 up。
/// ///
/// 上限由调用方(`common::clamp_text_units`,4096)保证;这里对空输入直接返回, /// 上限由调用方(`common::clamp_text_units`,4096)保证;这里对空输入直接返回,
/// 因为 `SendInput(0)` 会返回 0(失败)且没有任何注入语义。 /// 因为 `SendInput(0)` 会返回 0(失败)且没有任何注入语义。
@ -112,22 +121,17 @@ fn send_text(units: &[u16]) {
}, },
}, },
}; };
let downs: Vec<INPUT> = units.iter().map(|&u| mk(u, false)).collect(); // 一次提交:全部 down,紧接全部 up。
let ups: Vec<INPUT> = units.iter().map(|&u| mk(u, true)).collect(); let inputs: Vec<INPUT> = units
let size = std::mem::size_of::<INPUT>() as i32; .iter()
// 两批分开提交,且 down 批必须先于 up 批:`SendInput` 按提交顺序投递事件, .map(|&u| mk(u, false))
// 顺序颠倒会让 WM_CHAR 与按键状态错乱。两批之间不再插入其他输入, .chain(units.iter().map(|&u| mk(u, true)))
// 以保证代理对在远端仍相邻。 .collect();
let n_down = unsafe { SendInput(&downs, size) }; let n = unsafe { SendInput(&inputs, std::mem::size_of::<INPUT>() as i32) };
let n_up = unsafe { SendInput(&ups, size) };
// 可见失败后果:SendInput 返回「实际插入的事件数」,不等于请求数即失败 // 可见失败后果:SendInput 返回「实际插入的事件数」,不等于请求数即失败
// (常见原因:桌面被 Secure UI 锁定、被 BlockInput 拦截)。 // (常见原因:桌面被 Secure UI 锁定、被 BlockInput 拦截)。
if n_down as usize != downs.len() || n_up as usize != ups.len() { if n as usize != inputs.len() {
eprintln!( eprintln!("[input] SendInput 文本注入不完整: {n}/{}", inputs.len());
"[input] SendInput 文本注入不完整: down {n_down}/{} up {n_up}/{}",
downs.len(),
ups.len()
);
} }
} }