remote/linux-agent/src/input.rs
yu 9c340813a8 feat(agent): 新增 agent-core 与 Linux 被控端(X11 + XTest,纯新增不动 agent/)
本期只在新增文件里做,**未改动 Windows agent/ 一个字节**(与 NVENC 线并行,避免争抢)。

- 新增 agent-core:平台无关会话层 —— QUIC/TLS 握手、传输调优(沿用 512KB 发送窗口
  结论)、验证码认证、会话/select 循环、latest-wins 单槽、输入分发,以及软件编码
  视频管线(openh264)。同步跟进协议新增的 Pong{lost,sent} 下行丢包统计字段
- 新增 linux-agent:X11 GetImage 采集 + 整帧变更检测;XTest 注入 + 扫描码→X11
  keycode 映射(主键区 = scancode+8,E0 扩展键必须查表,含单元测试)
- 协议零改动:复用 Set-1 扫描码与虚拟桌面归一化坐标,viewer 无需任何修改
- 新增端到端冒烟测试 tests/smoke.rs 与 scripts/linux-smoke.sh(自建 Xvfb,无需
  图形桌面):采集→编码→QUIC 解帧、认证(含错误码拒绝)、Ping/Pong(含丢包字段)、
  静止桌面关键帧恢复、输入注入实证(注入后从另一条 X11 连接读回指针真实坐标)
- 该关键帧用例做过变异测试:把修复退回会失败、恢复后通过,确认断言有效
- .gitattributes:shell 脚本强制 LF(Windows checkout 成 CRLF 会导致容器内无法执行)
- 手册:修正已失效的工具链路径;补充 cargo 镜像;新增第 8 节(Linux)与 6.1 节
  (三条待修问题,含两条 Windows 侧仍存在的关键帧缺陷)

验证:
- Linux(容器, USTC 源 + Xvfb): cargo test -p linux-agent 通过,2 passed
- Windows: 本期未改 agent/,default-members 未变,`cargo build --release` 行为不变
2026-10-23 13:31:08 +08:00

250 lines
8.3 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);
assert_eq!(scale(65535, 1920), 1919);
assert_eq!(scale(32768, 1920), 960);
assert_eq!(scale(12345, 0), 0); // 退化尺寸不 panic
}
}