From 507588886101c46ff507927c2e121fb78e119ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E5=8D=9A=E6=96=87?= <1323515022@qq.com> Date: Fri, 30 Oct 2026 11:29:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20v3=20=E6=96=87=E6=9C=AC=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E7=94=A8=20KEYEVENTF=5FUNICODE=EF=BC=8C=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E6=89=B9=E9=87=8F=E6=8F=90=E4=BA=A4=E6=88=90=E5=AF=B9?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows 侧按官方 KEYBDINPUT 语义实现:置 KEYEVENTF_UNICODE 时 wVk 必须为 0、 wScan 即要送出的字符,系统合成 VK_PACKET 经 TranslateMessage 产出 WM_CHAR。 down 批与 up 批分两次 SendInput,且让同一段文本的 code unit 在输入队列里相邻, 使代理对能被远端合成一个字符(最终由真机 emoji 验收定论)。 agent-core 的 InputInjector::text 用默认实现记日志而非静默忽略:Linux 后端 本轮不做文本注入(X11 需 keysym/XKB 映射,另立循环),但用户对着 Linux 被控端打字不能「什么都没发生还没痕迹」。Windows 端走自己的 handle(), 不经 agent-core,故超限截断日志在两侧同源补齐。 日志只打长度,不打用户输入内容。 --- agent-core/src/lib.rs | 26 ++++++++++++++ agent/src/input.rs | 79 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/agent-core/src/lib.rs b/agent-core/src/lib.rs index d986129..7279aaf 100644 --- a/agent-core/src/lib.rs +++ b/agent-core/src/lib.rs @@ -48,6 +48,21 @@ pub trait InputInjector: Send + Sync + 'static { fn mouse_button(&self, b: MouseButton, down: bool); fn wheel(&self, dy: i32); fn key(&self, scancode: u16, extended: bool, down: bool); + + /// 协议 v3 的文本注入(UTF-16 code unit)。 + /// + /// **默认实现刻意不注入任何东西,但必须留下可见痕迹**:Linux 后端本轮未实现 + /// 文本注入(X11 需要 keysym/XKB 映射,与 Windows 的 `KEYEVENTF_UNICODE` + /// 不同构,另立循环)。全局规则要求异常路径有可见后果——用户对着 Linux 被控端 + /// 打字时「什么都没发生」不能是静默的,日志必须说明是没实现。 + /// + /// 日志纪律:只打印长度,**不打印用户输入内容**。 + fn text(&self, units: &[u16]) { + eprintln!( + "[agent-core] 文本注入未实现(忽略 {} 个 UTF-16 code unit)", + units.len() + ); + } } /// 把一条协议输入事件分发到注入后端。 @@ -59,6 +74,17 @@ pub fn dispatch_input(inj: &dyn InputInjector, ev: InputEvent) { InputEvent::Wheel { dy } => inj.wheel(dy), InputEvent::KeyDown { scancode, extended } => inj.key(scancode, extended, true), InputEvent::KeyUp { scancode, extended } => inj.key(scancode, extended, false), + InputEvent::Text { units } => { + // 上限在前端截断,截断必须可见(否则用户以为长文本已送达)。 + let (units, truncated) = common::clamp_text_units(units); + if truncated { + eprintln!( + "[agent-core] 文本超过上限,已截断到 {} 个 code unit", + common::MAX_TEXT_UNITS + ); + } + inj.text(&units); + } } } diff --git a/agent/src/input.rs b/agent/src/input.rs index e2e9f0d..f422b30 100644 --- a/agent/src/input.rs +++ b/agent/src/input.rs @@ -1,11 +1,12 @@ -//! 输入注入:SendInput 实现(鼠标归一化绝对坐标 + 扫描码键盘)。 +//! 输入注入:SendInput 实现(鼠标归一化绝对坐标 + 扫描码键盘 + v3 文本注入)。 use common::{InputEvent, MouseButton}; use windows::Win32::UI::Input::KeyboardAndMouse::{ SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_EXTENDEDKEY, - KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_MIDDLEDOWN, - MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, - MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_WHEEL, MOUSEINPUT, VIRTUAL_KEY, MOUSE_EVENT_FLAGS, + KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, KEYEVENTF_UNICODE, MOUSEEVENTF_ABSOLUTE, + MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, + MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_WHEEL, MOUSEINPUT, VIRTUAL_KEY, + MOUSE_EVENT_FLAGS, }; use windows::Win32::UI::WindowsAndMessaging::{ GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, @@ -73,6 +74,63 @@ pub fn virtual_desktop() -> (i32, i32, i32, i32) { } } +/// v3 文本注入:把一段 UTF-16 code unit 作为键盘事件送入前台应用。 +/// +/// 语义来源(实读,不凭记忆): +/// [KEYBDINPUT (winuser.h)](https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-keybdinput) +/// - `KEYEVENTF_UNICODE`(0x0004):`wVk` **必须为 0**,`wScan` 即要送出的 Unicode 字符; +/// 系统合成 `VK_PACKET` 按键事件,经 `TranslateMessage` 转为带该字符的 `WM_CHAR`。 +/// - 该 flag「只能与 `KEYEVENTF_KEYUP` 组合」的含义是**不能与 SCANCODE/EXTENDEDKEY 混用**, +/// 不是只能发 up 事件——down/up 成对与现有 `send_key` 形态一致。 +/// +/// 为什么一次性批量提交(而非逐字符一对 down/up): +/// 代理对(U+1F600 = D83D DE00)的两个 code unit 必须在输入队列里**相邻**, +/// 远端才会把它们合成成一个字符。逐字符成对(down,up,down,up)会在两个 +/// down 之间插入一个 up 事件;Windows 对 VK_PACKET 的孤立代理项缓冲的确切 +/// 规则我无法从文档确定(文档只说明"合成 WM_CHAR",未描述配对窗口), +/// 所以这里选择让两个 down 严格相邻的形态,最终由真机 emoji 验收(V7)定论。 +/// +/// 上限由调用方(`common::clamp_text_units`,4096)保证;这里对空输入直接返回, +/// 因为 `SendInput(0)` 会返回 0(失败)且没有任何注入语义。 +fn send_text(units: &[u16]) { + if units.is_empty() { + return; + } + let mk = |scan: u16, up: bool| INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: VIRTUAL_KEY(0), // KEYEVENTF_UNICODE 要求 wVk=0(官方文档) + wScan: scan, + dwFlags: if up { + KEYEVENTF_UNICODE | KEYEVENTF_KEYUP + } else { + KEYEVENTF_UNICODE + }, + time: 0, + dwExtraInfo: 0, + }, + }, + }; + let downs: Vec = units.iter().map(|&u| mk(u, false)).collect(); + let ups: Vec = units.iter().map(|&u| mk(u, true)).collect(); + let size = std::mem::size_of::() as i32; + // 两批分开提交,且 down 批必须先于 up 批:`SendInput` 按提交顺序投递事件, + // 顺序颠倒会让 WM_CHAR 与按键状态错乱。两批之间不再插入其他输入, + // 以保证代理对在远端仍相邻。 + let n_down = unsafe { SendInput(&downs, size) }; + let n_up = unsafe { SendInput(&ups, size) }; + // 可见失败后果:SendInput 返回「实际插入的事件数」,不等于请求数即失败 + // (常见原因:桌面被 Secure UI 锁定、被 BlockInput 拦截)。 + if n_down as usize != downs.len() || n_up as usize != ups.len() { + eprintln!( + "[input] SendInput 文本注入不完整: down {n_down}/{} up {n_up}/{}", + downs.len(), + ups.len() + ); + } +} + pub fn handle(ev: InputEvent) { match ev { InputEvent::MouseMove { x, y } => { @@ -105,5 +163,18 @@ pub fn handle(ev: InputEvent) { } InputEvent::KeyDown { scancode, extended } => send_key(scancode, extended, false), InputEvent::KeyUp { scancode, extended } => send_key(scancode, extended, true), + InputEvent::Text { units } => { + // 上限截断必须可见(与 agent-core::dispatch_input 同口径;Windows 端 + // 走自己的 handle(),不经过 agent-core,所以这里同源补齐——R-3 教训)。 + let (units, truncated) = common::clamp_text_units(units); + if truncated { + eprintln!( + "[input] 文本超过上限,已截断到 {} 个 code unit", + common::MAX_TEXT_UNITS + ); + } + // 只打印长度:注入日志不得含用户输入内容(安全红线) + send_text(&units); + } } }