- 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
252 lines
8.5 KiB
Rust
252 lines
8.5 KiB
Rust
//! 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
|
||
}
|
||
}
|