本期只在新增文件里做,**未改动 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` 行为不变
148 lines
5.1 KiB
Rust
148 lines
5.1 KiB
Rust
//! 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())
|
||
}
|
||
}
|