remote/linux-agent/src/input.rs
yu bae871c78c fix(linux-agent): 修正坐标换算单测期望值 + 冒烟脚本纳入单测
- scale(32768,1920) 期望值应为 959(32768×1919÷65535=959.49 向下取整),
  原写成 960 —— 是**测试期望值错**,实现本身正确(0→0、65535→extents-1 均通过);
  集成测试因带 ±2 像素容差未能暴露,是跑单测才抓到的
- scripts/linux-smoke.sh 原先只跑 --test smoke,漏掉 --bins 单测;现两层都跑
- 手册 §8.4 说明补充单测这一层及其必要性

验证:bash scripts/linux-smoke.sh → 单测 4 passed + 端到端 2 passed
2026-10-23 15:04:56 +08:00

252 lines
8.5 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.

//! X11 输入注入:XTest 扩展(Xorg / XWayland / Xvfb 均支持)。
//!
//! **键码映射是本模块的关键**:协议里键盘沿用 Windows 口味的 PS/2 Set-1 扫描码,
//! 而 X11 要的是 keycode,两套编号并非处处等价:
//! - 主键区:`keycode = scancode + 8`(evdev 偏移),已逐键核对;
//! - E0 扩展键:两条链路编号**完全不同**(如方向键 Set-1 是 0x48,evdev 侧 X11 keycode 是 111),必须查表。
//!
//! 鼠标沿用虚拟桌面归一化坐标 0..=65535:X11 根窗口天然横跨全部显示器,
//! 与 Windows 的 `MOUSEEVENTF_VIRTUALDESK` 语义一致,故映射到根窗口像素即可。
use agent_core::InputInjector;
use anyhow::{anyhow, Result};
use common::MouseButton;
use std::sync::Mutex;
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{ConnectionExt as _, Window};
use x11rb::protocol::xtest::ConnectionExt as _;
use x11rb::rust_connection::RustConnection;
// X11 核心事件码(协议固定值)
const KEY_PRESS: u8 = 2;
const KEY_RELEASE: u8 = 3;
const BUTTON_PRESS: u8 = 4;
const BUTTON_RELEASE: u8 = 5;
const MOTION_NOTIFY: u8 = 6;
// X11 按钮编号:1=左 2=中 3=右 4=滚轮上 5=滚轮下
const BTN_LEFT: u8 = 1;
const BTN_MIDDLE: u8 = 2;
const BTN_RIGHT: u8 = 3;
const BTN_WHEEL_UP: u8 = 4;
const BTN_WHEEL_DOWN: u8 = 5;
/// Windows 滚轮一格 = 120(协议沿用该语义)
const WHEEL_DELTA: i32 = 120;
/// 单次滚轮最多合成的格数,防止异常值刷爆事件队列
const WHEEL_MAX_CLICKS: i32 = 20;
pub struct X11Injector {
/// `Mutex` 只为满足 `InputInjector: Sync`(X11 连接本身非 Sync),无竞争
conn: Mutex<RustConnection>,
root: Window,
width: i32,
height: i32,
}
impl X11Injector {
pub fn new() -> Result<Self> {
let (conn, screen_num) = x11rb::connect(None).map_err(|e| {
anyhow!(
"连接 X11 失败(DISPLAY={:?}): {e}",
std::env::var("DISPLAY").ok()
)
})?;
// XTest 缺失就没有注入能力,启动即失败好过连上后才报错
let ext = conn
.query_extension(b"XTEST")
.map_err(|e| anyhow!("查询 XTEST 扩展失败: {e}"))?
.reply()
.map_err(|e| anyhow!("查询 XTEST 扩展应答失败: {e}"))?;
anyhow::ensure!(ext.present, "X server 未提供 XTEST 扩展,无法注入输入");
// 先拷出参数,避免借用 conn 的同时又把它移进结构体
let (root, width, height) = {
let screen = &conn.setup().roots[screen_num];
(
screen.root,
screen.width_in_pixels as i32,
screen.height_in_pixels as i32,
)
};
println!("[input] XTEST ready, root {width}x{height}");
Ok(Self {
conn: Mutex::new(conn),
root,
width,
height,
})
}
fn fake(&self, type_: u8, detail: u8, x: i32, y: i32) {
let conn = match self.conn.lock() {
Ok(c) => c,
Err(poisoned) => poisoned.into_inner(),
};
if let Err(e) = conn.xtest_fake_input(
type_,
detail,
0,
self.root,
x as i16,
y as i16,
0, // deviceid:0 = 核心设备
) {
eprintln!("[input] xtest_fake_input 失败: {e}");
return;
}
let _ = conn.flush();
}
fn click(&self, button: u8) {
self.fake(BUTTON_PRESS, button, 0, 0);
self.fake(BUTTON_RELEASE, button, 0, 0);
}
}
impl InputInjector for X11Injector {
fn mouse_move(&self, x: u16, y: u16) {
// detail=0 → 绝对坐标
let px = scale(x, self.width);
let py = scale(y, self.height);
self.fake(MOTION_NOTIFY, 0, px, py);
}
fn mouse_button(&self, b: MouseButton, down: bool) {
let button = match b {
MouseButton::Left => BTN_LEFT,
MouseButton::Middle => BTN_MIDDLE,
MouseButton::Right => BTN_RIGHT,
};
self.fake(
if down { BUTTON_PRESS } else { BUTTON_RELEASE },
button,
0,
0,
);
}
fn wheel(&self, dy: i32) {
// 协议沿用 Windows 语义:dy > 0 向上滚,一格 = 120
if dy == 0 {
return;
}
let clicks = (dy.abs() / WHEEL_DELTA).clamp(1, WHEEL_MAX_CLICKS);
let button = if dy > 0 { BTN_WHEEL_UP } else { BTN_WHEEL_DOWN };
for _ in 0..clicks {
self.click(button);
}
}
fn key(&self, scancode: u16, extended: bool, down: bool) {
let Some(keycode) = keycode_of(scancode, extended) else {
eprintln!("[input] 未映射的扫描码 scancode=0x{scancode:02X} extended={extended}");
return;
};
self.fake(
if down { KEY_PRESS } else { KEY_RELEASE },
keycode,
0,
0,
);
}
}
/// 虚拟桌面归一化坐标(0..=65535)→ 根窗口像素坐标
fn scale(v: u16, extent: i32) -> i32 {
if extent <= 1 {
return 0;
}
((v as i64 * (extent as i64 - 1)) / 65535) as i32
}
/// PS/2 Set-1 扫描码 → X11 keycode。
///
/// 非扩展键恒为 `scancode + 8`(当前 viewer 的 keymap 只发该区间内的键);
/// E0 扩展键查表,编号与主键区规则无关。
fn keycode_of(scancode: u16, extended: bool) -> Option<u8> {
if extended {
// 已按 Xorg evdev 标准 keycode 分配核对
let kc = match scancode {
0x1C => 104, // 小键盘 Enter
0x1D => 105, // 右 Ctrl
0x35 => 106, // 小键盘 /
0x37 => 107, // PrintScreen
0x38 => 108, // 右 Alt
0x47 => 110, // Home
0x48 => 111, // ↑
0x49 => 112, // PageUp
0x4B => 113, // ←
0x4D => 114, // →
0x4F => 115, // End
0x50 => 116, // ↓
0x51 => 117, // PageDown
0x52 => 118, // Insert
0x53 => 119, // Delete
0x5B => 133, // 左 Super(Win)
0x5C => 134, // 右 Super(Win)
0x5D => 135, // Menu
_ => return None,
};
Some(kc)
} else {
// 0x01..=0x53 主键区,以及 0x57/0x58 的 F11/F12;其余为 Set-1 未定义位
match scancode {
0x01..=0x53 | 0x57 | 0x58 => Some((scancode + 8) as u8),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn main_block_uses_evdev_offset() {
assert_eq!(keycode_of(0x1E, false), Some(38)); // A
assert_eq!(keycode_of(0x02, false), Some(10)); // 1
assert_eq!(keycode_of(0x39, false), Some(65)); // Space
assert_eq!(keycode_of(0x1C, false), Some(36)); // Enter
assert_eq!(keycode_of(0x1D, false), Some(37)); // 左 Ctrl
assert_eq!(keycode_of(0x2A, false), Some(50)); // 左 Shift
assert_eq!(keycode_of(0x58, false), Some(96)); // F12
}
#[test]
fn extended_keys_are_not_offset() {
// 若误用 +8 规则,方向键会落到小键盘区(0x48+8=80=KP_8),这正是要防的坑
assert_eq!(keycode_of(0x48, true), Some(111)); // ↑
assert_ne!(keycode_of(0x48, true), Some(0x48 + 8));
assert_eq!(keycode_of(0x50, true), Some(116)); // ↓
assert_eq!(keycode_of(0x4B, true), Some(113)); // ←
assert_eq!(keycode_of(0x4D, true), Some(114)); // →
assert_eq!(keycode_of(0x47, true), Some(110)); // Home
assert_eq!(keycode_of(0x4F, true), Some(115)); // End
assert_eq!(keycode_of(0x49, true), Some(112)); // PageUp
assert_eq!(keycode_of(0x51, true), Some(117)); // PageDown
assert_eq!(keycode_of(0x53, true), Some(119)); // Delete
assert_eq!(keycode_of(0x5B, true), Some(133)); // 左 Super
}
#[test]
fn undefined_scancodes_are_rejected() {
assert_eq!(keycode_of(0x54, false), None);
assert_eq!(keycode_of(0x7F, false), None);
assert_eq!(keycode_of(0x99, true), None);
}
#[test]
fn coordinate_scaling_covers_full_range() {
assert_eq!(scale(0, 1920), 0);
// 归一化上界必须映射到 extent-1,否则指针永远到不了屏幕最右/最下
assert_eq!(scale(65535, 1920), 1919);
// 中点:32768 × 1919 ÷ 65535 = 959.49…,整数除法向下取整 → 959
assert_eq!(scale(32768, 1920), 959);
assert_eq!(scale(12345, 0), 0); // 退化尺寸不 panic
}
}