remote/linux-agent/src/capture.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

148 lines
5.1 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 屏幕采集。
//!
//! MVP 走核心协议 `GetImage`:零扩展依赖、零 unsafe,先把「采集 → 编码 → QUIC」整条链路打通。
//! 变更检测用整帧字节比较,桌面静止时不上行视频(省掉编码开销)。
//!
//! 二期优化锚点(已在 `ScreenCapturer` 语义内预留,无需改 agent-core):
//! 1. **MIT-SHM**:共享内存替代 `GetImage` 的 socket 传输,省掉 X server 一次全画布拷贝;
//! 2. **XDamage**:只取变化矩形 + 只编码脏区,静止时零拷贝。
use agent_core::ScreenCapturer;
use anyhow::{anyhow, Result};
use std::time::{Duration, Instant};
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{ConnectionExt as _, ImageFormat, VisualClass, Window};
use x11rb::rust_connection::RustConnection;
/// 帧节流上限,与 agent-core 默认编码档位(30fps)对齐
const FRAME_INTERVAL: Duration = Duration::from_millis(33);
pub struct X11Capturer {
conn: RustConnection,
root: Window,
width: u16,
height: u16,
/// 最近一帧 BGRA(既是变更检测基线,也是 copy_frame 的数据源)
last: Vec<u8>,
last_capture: Option<Instant>,
}
impl X11Capturer {
pub fn new() -> Result<Self> {
let (conn, screen_num) = x11rb::connect(None).map_err(|e| {
anyhow!(
"连接 X11 失败(DISPLAY={:?}): {e}",
std::env::var("DISPLAY").ok()
)
})?;
// 先把需要的屏幕参数拷出来:随后 conn 要被移进结构体,不能再持有对它的借用
let (root, width, height, depth, visual_class, bpp) = {
let setup = conn.setup();
let screen = &setup.roots[screen_num];
// bits_per_pixel 不在 visual 上,而在 Setup 的 pixmap_formats 里(按 depth 索引)
let fmt = setup
.pixmap_formats
.iter()
.find(|f| f.depth == screen.root_depth)
.ok_or_else(|| anyhow!("找不到 depth={} 对应的像素格式", screen.root_depth))?;
let visual_class = screen
.allowed_depths
.iter()
.flat_map(|d| d.visuals.iter())
.find(|v| v.visual_id == screen.root_visual)
.map(|v| v.class)
.ok_or_else(|| anyhow!("找不到 root visual"))?;
(
screen.root,
screen.width_in_pixels,
screen.height_in_pixels,
screen.root_depth,
visual_class,
fmt.bits_per_pixel,
)
};
anyhow::ensure!(width > 0 && height > 0, "屏幕尺寸异常 {width}x{height}");
anyhow::ensure!(
bpp == 32,
"当前仅支持 32bpp 真彩采集(depth={depth},实际 {bpp}bpp)"
);
// BGRA 字节序假设只在 TrueColor 下成立
anyhow::ensure!(
visual_class == VisualClass::TRUE_COLOR,
"当前仅支持 TrueColor 视觉(实际 {visual_class:?})"
);
println!("[capture] X11 screen {width}x{height}, depth={depth}, {bpp}bpp");
Ok(Self {
conn,
root,
width,
height,
last: Vec::new(),
last_capture: None,
})
}
/// 抓一帧整屏到 `last`,返回画面是否发生变化
fn grab(&mut self) -> Result<bool> {
let reply = self
.conn
.get_image(
ImageFormat::Z_PIXMAP,
self.root,
0,
0,
self.width,
self.height,
!0,
)
.map_err(|e| anyhow!("get_image 请求失败: {e}"))?
.reply()
.map_err(|e| anyhow!("get_image 应答失败: {e}"))?;
let expect = self.width as usize * self.height as usize * 4;
anyhow::ensure!(
reply.data.len() == expect,
"帧数据长度异常:期望 {expect} 字节,实际 {}",
reply.data.len()
);
let changed = reply.data != self.last;
if changed {
self.last = reply.data;
}
Ok(changed)
}
}
impl ScreenCapturer for X11Capturer {
fn size(&self) -> (u32, u32) {
(self.width as u32, self.height as u32)
}
/// X11 核心协议没有「阻塞至新帧」语义,故这里按帧槽节流后主动抓帧:
/// 睡到下一个 1/30s 槽位 → 抓帧 → 与上一帧逐字节比较。
/// 因此 `timeout_ms` 在 Linux 实现中被忽略(agent-core 的 min_frame 节流也随之自然满足)。
fn acquire(&mut self, _timeout_ms: u32) -> Result<bool> {
if let Some(t) = self.last_capture {
let elapsed = t.elapsed();
if elapsed < FRAME_INTERVAL {
std::thread::sleep(FRAME_INTERVAL - elapsed);
}
}
self.last_capture = Some(Instant::now());
self.grab()
}
fn copy_frame(&mut self) -> Result<Vec<u8>> {
anyhow::ensure!(!self.last.is_empty(), "尚无帧数据可拷贝");
Ok(self.last.clone())
}
}