remote/agent/src/input.rs
郑博文 78ac9d9972 feat(agent): 相对鼠标注入(读光标+增量+clamp 绝对注入)+ 显式 DPI 感知
注入实现刻意**不用** SendInput(MOUSEEVENTF_MOVE):实测该路径的 dx 不是
像素而是 mickey,会被「指针加速」与「DPI 缩放」双重放大——本机 dx=1 走 0px、
dx=20 走 41px、dx=100 走 309px,且倍率随用户系统设置与机器变化,无法标定。
改为「GetCursorPos + 增量 + clamp + 绝对注入」,实测 1:1 精确(±1/2/3/5/17/100
四次重复全部等于请求值),撞边后回滑立即响应、不积步数债。

同时显式设 PER_MONITOR_AWARE_V2:实测本机 agent.exe 虽是 per-monitor,
但那是注册表 AppCompatFlags\\Layers 里一条**路径绑定**的 HIGHDPIAWARE 覆盖
造成的(同一 exe 复制到 %TEMP% 启动即变 UNAWARE),换机器/换构建路径即失效。
UNAWARE 时 GetCursorPos 返回逻辑坐标(1536x864)而采集是物理像素(1920x1080),
clamp 上界与增量尺度都会错。设置失败时区分「已被外部设定」与「真正降级」并分别报日志。
2026-10-31 17:34:54 +08:00

243 lines
11 KiB
Rust
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.

//! 输入注入: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, 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::{
GetCursorPos, GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_CXSCREEN,
SM_CYSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
};
use windows::Win32::Foundation::POINT;
// windows 0.58 未导出 LEFT 常量,按 WinUser.h 原值定义
const MOUSEEVENTF_LEFTDOWN: u32 = 0x0002;
const MOUSEEVENTF_LEFTUP: u32 = 0x0004;
fn send_mouse(flags: u32, dx: i32, dy: i32, data: u32) {
let inp = INPUT {
r#type: INPUT_MOUSE,
Anonymous: INPUT_0 {
mi: MOUSEINPUT {
dx,
dy,
mouseData: data,
dwFlags: MOUSE_EVENT_FLAGS(flags),
time: 0,
dwExtraInfo: 0,
},
},
};
unsafe {
SendInput(&[inp], std::mem::size_of::<INPUT>() as i32);
}
}
/// 主显示器物理尺寸(像素)。相对移动的 clamp 上界必须与**采集空间**一致
/// (capture 用 `EnumOutputs(0)`;AuthOk 下发的 width/height 也来自它)。
fn output_size() -> (i32, i32) {
unsafe {
(
GetSystemMetrics(SM_CXSCREEN),
GetSystemMetrics(SM_CYSCREEN),
)
}
}
/// v4 相对鼠标:把远端指针从当前位置移动 `(dx, dy)` 像素。
///
/// **为什么不用 `SendInput(MOUSEEVENTF_MOVE)`**(协议 v4 的语义最容易在这里跑偏):
/// 实测(2026-10-31,见个人层 `docs/output/2026-10-31-3b-开工前实测.md`)该路径的
/// `dx` 不是像素,而是「mickey」,会被两件事放大:
/// 1. **指针加速**(「提高指针精确度」)。本机 `SPI_GETMOUSE=6/10/1` 下
/// dx=20 实际走 41px、dx=100 走 309px,且倍率是非线性的;
/// 2. **DPI 缩放**。关掉加速后倍率恒为 1.25 = 本机 125% 缩放。
/// 两者都随用户系统设置与机器变化,无法标定,因此**不能**把协议里的像素增量直接透传。
///
/// 改为「读光标 → 加增量 → clamp → 绝对注入」:
/// - 语义仍是**相对**(主控端只说移动多少,不说去哪里),远端指针始终权威;
/// - 实测 1:1 精确(dx=±1/2/3/5/17/100 四次重复全部等于请求值);
/// - 撞边后回滑立即响应,不积累「步数债」(clamp 丢掉的部分不回补)。
///
/// 已知限制(不隐藏):读光标与注入之间若用户同时移动真鼠标,会有一次增量被叠加。
/// 本机联调场景可接受;彻底消除需要 `SendInput` 的 mickey 路径,而那条路不可标定。
fn send_mouse_rel(dx: i16, dy: i16) {
if dx == 0 && dy == 0 {
return; // 零位移没有注入语义,也不该产生一次系统调用
}
let (w, h) = output_size();
if w <= 1 || h <= 1 {
eprintln!("[input] 相对移动失败:输出尺寸异常 {w}x{h}");
return;
}
let mut cur = POINT::default();
// GetCursorPos 失败时不能猜一个坐标继续注入——那会把指针扔到屏幕角落
if let Err(e) = unsafe { GetCursorPos(&mut cur) } {
eprintln!("[input] GetCursorPos 失败,相对移动被丢弃: {e}");
return;
}
let tx = (cur.x + dx as i32).clamp(0, w - 1);
let ty = (cur.y + dy as i32).clamp(0, h - 1);
// 归一化到 0..=65535(绝对注入空间 = 主显示器,不带 VIRTUALDESK,见 MouseMove 分支)
let nx = ((tx as i64 * 65535 + (w as i64 - 1) / 2) / (w as i64 - 1)) as i32;
let ny = ((ty as i64 * 65535 + (h as i64 - 1) / 2) / (h as i64 - 1)) as i32;
send_mouse(
MOUSEEVENTF_ABSOLUTE.0 | MOUSEEVENTF_MOVE.0,
nx,
ny,
0,
);
}
fn send_key(scancode: u16, extended: bool, up: bool) {
let mut flags = KEYEVENTF_SCANCODE;
if extended {
flags |= KEYEVENTF_EXTENDEDKEY;
}
if up {
flags |= KEYEVENTF_KEYUP;
}
let inp = INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: INPUT_0 {
ki: KEYBDINPUT {
wVk: VIRTUAL_KEY(0),
wScan: scancode,
dwFlags: flags,
time: 0,
dwExtraInfo: 0,
},
},
};
unsafe {
SendInput(&[inp], std::mem::size_of::<INPUT>() as i32);
}
}
/// 打印虚拟桌面信息(调试用)
#[allow(dead_code)]
pub fn virtual_desktop() -> (i32, i32, i32, i32) {
unsafe {
(
GetSystemMetrics(SM_XVIRTUALSCREEN),
GetSystemMetrics(SM_YVIRTUALSCREEN),
GetSystemMetrics(SM_CXVIRTUALSCREEN),
GetSystemMetrics(SM_CYVIRTUALSCREEN),
)
}
}
/// 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`。
/// - 文档原文是 "This flag can only be combined with the KEYEVENTF_KEYUP flag"。
/// 本项目**仍先发不带 KEYUP 的 down**(与现有 `send_key` 的 down/up 成对形态一致),
/// 把那句读作「不能与 SCANCODE/EXTENDEDKEY 混用」是**我的推断,文档没有明说**。
/// 若真机验收发现 down 事件被系统丢弃(表现为一个字符进两次或不进),
/// 退路是只发 `KEYEVENTF_UNICODE | KEYEVENTF_KEYUP` 单事件——改一处 `mk` 即可。
///
/// 为什么一次性批量提交(而非逐字符一对 down/up):
/// 代理对(U+1F600 = D83D DE00)的两个 code unit 必须在输入队列里**相邻**,
/// 远端才会把它们合成成一个字符。逐字符成对(down,up,down,up)会在两个
/// down 之间插入 up 事件;Windows 对 VK_PACKET 孤立代理项的缓冲规则文档
/// 没有描述,所以这里选择让两个 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)保证;这里对空输入直接返回,
/// 因为 `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,
},
},
};
// 一次提交:全部 down,紧接全部 up。
let inputs: Vec<INPUT> = units
.iter()
.map(|&u| mk(u, false))
.chain(units.iter().map(|&u| mk(u, true)))
.collect();
let n = unsafe { SendInput(&inputs, std::mem::size_of::<INPUT>() as i32) };
// 可见失败后果:SendInput 返回「实际插入的事件数」,不等于请求数即失败
// (常见原因:桌面被 Secure UI 锁定、被 BlockInput 拦截)。
if n as usize != inputs.len() {
eprintln!("[input] SendInput 文本注入不完整: {n}/{}", inputs.len());
}
}
pub fn handle(ev: InputEvent) {
match ev {
InputEvent::MouseMove { x, y } => {
// 归一化坐标系 = **被采集的那个输出**(capture 用 EnumOutputs(0),
// 即主屏;AuthOk 的 width/height 也是它)。
// MOUSEEVENTF_ABSOLUTE 不带 VIRTUALDESK 时,0..=65535 恰好映射到
// 主显示器——与采集空间一致。
// 曾经带 VIRTUALDESK:多显示器机器上注入空间变成整个虚拟桌面
// (比主屏宽),手机画面与指针位置系统性错位(2026-10-30 实测)。
// 单显示器时两者重合,所以桌面端从未暴露此问题。
send_mouse(
MOUSEEVENTF_ABSOLUTE.0 | MOUSEEVENTF_MOVE.0,
x as i32,
y as i32,
0,
);
}
InputEvent::MouseDown(b) => match b {
MouseButton::Left => send_mouse(MOUSEEVENTF_LEFTDOWN, 0, 0, 0),
MouseButton::Right => send_mouse(MOUSEEVENTF_RIGHTDOWN.0, 0, 0, 0),
MouseButton::Middle => send_mouse(MOUSEEVENTF_MIDDLEDOWN.0, 0, 0, 0),
},
InputEvent::MouseUp(b) => match b {
MouseButton::Left => send_mouse(MOUSEEVENTF_LEFTUP, 0, 0, 0),
MouseButton::Right => send_mouse(MOUSEEVENTF_RIGHTUP.0, 0, 0, 0),
MouseButton::Middle => send_mouse(MOUSEEVENTF_MIDDLEUP.0, 0, 0, 0),
},
InputEvent::Wheel { dy } => {
send_mouse(MOUSEEVENTF_WHEEL.0, 0, 0, dy as u32);
}
InputEvent::KeyDown { scancode, extended } => send_key(scancode, extended, false),
InputEvent::KeyUp { scancode, extended } => send_key(scancode, extended, true),
InputEvent::MouseMoveRel { dx, dy } => send_mouse_rel(dx, dy),
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);
}
}
}