diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b2a12ed --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# 统一换行符,避免跨平台踩坑 +# shell 脚本必须 LF:Windows 上若被 checkout 成 CRLF,容器/Linux 里执行会报 +# "$'\r': command not found",或被 sed/grep 当成带 \r 的内容 +*.sh text eol=lf + +# Windows 侧脚本保持 CRLF +*.ps1 text eol=crlf +*.bat text eol=crlf + +# 二进制/生成物不参与换行转换 +*.exe binary +*.dll binary +*.png binary +*.jpg binary +*.ico binary diff --git a/Cargo.lock b/Cargo.lock index a7e3349..b5f28aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,19 @@ dependencies = [ "windows", ] +[[package]] +name = "agent-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "common", + "openh264", + "quinn", + "rcgen", + "rustls", + "tokio", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -325,6 +338,16 @@ dependencies = [ "slab", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -474,6 +497,19 @@ dependencies = [ "redox_syscall 0.9.5", ] +[[package]] +name = "linux-agent" +version = "0.1.0" +dependencies = [ + "agent-core", + "anyhow", + "common", + "quinn", + "rustls", + "tokio", + "x11rb", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1746,6 +1782,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xcursor" version = "0.3.11" diff --git a/Cargo.toml b/Cargo.toml index ed98c0b..d5fcda1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,11 @@ [workspace] resolver = "2" -members = ["common", "agent", "viewer"] +# agent-core:平台无关会话层 + 软件编码视频管线(当前仅 linux-agent 使用) +# linux-agent:Linux 被控端(X11 + XTest) +members = ["common", "agent-core", "agent", "linux-agent", "viewer"] +# 默认成员保持 Windows 三件套,`cargo build --release` 行为与改动前完全一致; +# Linux 被控端显式构建:`cargo build --release -p linux-agent` +default-members = ["common", "agent", "viewer"] [workspace.dependencies] anyhow = "1" diff --git a/agent-core/Cargo.toml b/agent-core/Cargo.toml new file mode 100644 index 0000000..6b58b72 --- /dev/null +++ b/agent-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "agent-core" +version = "0.1.0" +edition = "2021" + +[dependencies] +common = { path = "../common" } +anyhow.workspace = true +tokio.workspace = true +quinn.workspace = true +rustls.workspace = true +rcgen.workspace = true +openh264 = { version = "0.9.9", features = ["source"] } diff --git a/agent-core/src/lib.rs b/agent-core/src/lib.rs new file mode 100644 index 0000000..713a3fc --- /dev/null +++ b/agent-core/src/lib.rs @@ -0,0 +1,407 @@ +//! 平台无关的被控端会话层:QUIC/TLS 握手、验证码认证、H.264 编码下行、输入分发。 +//! +//! 从 `agent/`(Windows)抽出来,供 Windows 与 Linux 被控端共用,避免两端各写一份 +//! QUIC 握手/认证/丢帧逻辑造成协议漂移。平台差异只通过两个 trait 注入: +//! +//! - [`ScreenCapturer`]:屏幕采集(Windows = DXGI Desktop Duplication,Linux = X11) +//! - [`InputInjector`]:输入注入(Windows = SendInput,Linux = XTest) +//! +//! 线格式完全复用 `common`,**不改协议**:键盘沿用 Set-1 扫描码 + extended 标志, +//! 鼠标沿用虚拟桌面归一化坐标 0..=65535。 + +use anyhow::Result; +use common::{ + encode_video_frame, read_control, write_control, ControlMsg, InputEvent, MouseButton, + VideoFrameHeader, +}; +use openh264::encoder::{ + BitRate, Encoder, EncoderConfig, FrameRate, IntraFramePeriod, RateControlMode, UsageType, +}; +use openh264::formats::{BgraSliceU8, YUVBuffer}; +use openh264::OpenH264API; +use rustls::pki_types::PrivatePkcs8KeyDer; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{mpsc, watch}; + +/// 屏幕采集后端。 +/// +/// 语义刻意对齐 DXGI Desktop Duplication,使 Linux 侧只需实现同一套模型: +/// `acquire()` 阻塞至多 `timeout_ms`,返回是否拿到**新**帧;`copy_frame()` 再取出像素。 +pub trait ScreenCapturer: Send { + /// 桌面尺寸(物理像素) + fn size(&self) -> (u32, u32); + + /// 阻塞至多 `timeout_ms`。 + /// `Ok(true)` = 有新帧(已拷进内部缓冲,可由 `copy_frame` 取出); + /// `Ok(false)` = 超时且画面未变(静止桌面)。 + fn acquire(&mut self, timeout_ms: u32) -> Result; + + /// 取出最近一帧,BGRA 紧凑排列(已去掉 row pitch),供 openh264 直接编码。 + fn copy_frame(&mut self) -> Result>; +} + +/// 输入注入后端。实现需可在异步运行时中被多任务共享,故要求 `Send + Sync`。 +pub trait InputInjector: Send + Sync + 'static { + fn mouse_move(&self, x: u16, y: u16); + fn mouse_button(&self, b: MouseButton, down: bool); + fn wheel(&self, dy: i32); + fn key(&self, scancode: u16, extended: bool, down: bool); +} + +/// 把一条协议输入事件分发到注入后端。 +pub fn dispatch_input(inj: &dyn InputInjector, ev: InputEvent) { + match ev { + InputEvent::MouseMove { x, y } => inj.mouse_move(x, y), + InputEvent::MouseDown(b) => inj.mouse_button(b, true), + InputEvent::MouseUp(b) => inj.mouse_button(b, false), + InputEvent::Wheel { dy } => inj.wheel(dy), + InputEvent::KeyDown { scancode, extended } => inj.key(scancode, extended, true), + InputEvent::KeyUp { scancode, extended } => inj.key(scancode, extended, false), + } +} + +/// 会话参数 +#[derive(Debug, Clone)] +pub struct SessionConfig { + pub port: u16, + pub code: String, +} + +/// 编码档位。默认值即 Windows 现有档位,改动需两端同步评估。 +#[derive(Debug, Clone, Copy)] +pub struct EncoderSettings { + pub bitrate_bps: u32, + pub max_fps: f32, + pub intra_period: u32, +} + +impl Default for EncoderSettings { + fn default() -> Self { + // 对标 UU:屏幕内容实时档;关键帧周期 300 帧 ≈ 10s + Self { + bitrate_bps: 5_000_000, + max_fps: 30.0, + intra_period: 300, + } + } +} + +/// 解析 `--port` / `--code`(两端共用,保持命令行一致) +pub fn parse_args() -> Result<(u16, String)> { + let mut port = 47800u16; + let mut code = "123456".to_string(); + let a: Vec = std::env::args().collect(); + let mut i = 1; + while i + 1 < a.len() { + match a[i].as_str() { + "--port" => port = a[i + 1].parse()?, + "--code" => code = a[i + 1].clone(), + _ => {} + } + i += 2; + } + Ok((port, code)) +} + +fn make_encoder(enc: &EncoderSettings) -> Result { + Ok(Encoder::with_api_config( + OpenH264API::from_source(), + EncoderConfig::new() + .bitrate(BitRate::from_bps(enc.bitrate_bps)) + .max_frame_rate(FrameRate::from_hz(enc.max_fps)) + .rate_control_mode(RateControlMode::Bitrate) + .usage_type(UsageType::ScreenContentRealTime) + .intra_frame_period(IntraFramePeriod::from_num_frames(enc.intra_period)), + )?) +} + +enum LoopEnd { + /// 下行通道关闭 —— 会话结束,采集线程退出 + ChannelClosed, + /// 采集后端失效(Windows 的 ACCESS_LOST / X11 连接断开)—— 重建后继续 + Rebuild, +} + +/// 编码一帧并推入下行通道。返回 false 表示通道已关闭(会话结束)。 +fn encode_send( + encoder: &mut Encoder, + raw: &[u8], + (w, h): (u32, u32), + ts_ms: u64, + tx: &watch::Sender>>, + last_frame: &mut Option>, +) -> bool { + let header = VideoFrameHeader { + width: w, + height: h, + ts_ms, + }; + let yuv = YUVBuffer::from_bgra8_source(BgraSliceU8::new(raw, (w as usize, h as usize))); + match encoder.encode(&yuv) { + Ok(ebs) => { + let frame = encode_video_frame(&header, &ebs.to_vec()); + if tx.send(Some(frame.clone())).is_err() { + return false; + } + *last_frame = Some(frame); + true + } + Err(e) => { + eprintln!("[capture] encode failed: {e}"); + true + } + } +} + +/// 单次采集会话:抓帧 → 编码 → 推入 watch 单槽。 +fn capture_session( + cap: &mut dyn ScreenCapturer, + tx: &watch::Sender>>, + keyreq: &AtomicBool, + enc: &EncoderSettings, +) -> LoopEnd { + let mut encoder = match make_encoder(enc) { + Ok(e) => e, + Err(e) => { + eprintln!("[capture] encoder init failed: {e}"); + return LoopEnd::ChannelClosed; + } + }; + + let (w, h) = cap.size(); + eprintln!("[capture] started {w}x{h}"); + let start = Instant::now(); + let min_frame = Duration::from_secs_f32(1.0 / enc.max_fps); + let mut last_frame: Option> = None; + // 保留最近一帧裸像素:静止桌面收到关键帧请求时,仍能重编一个真关键帧 + let mut last_raw: Option> = None; + let mut last_sent = Instant::now() - Duration::from_secs(10); + + loop { + let iter_start = Instant::now(); + match cap.acquire(80) { + Ok(true) => match cap.copy_frame() { + Ok(raw) => { + // 主控端请求关键帧(解码异常恢复) + if keyreq.swap(false, Ordering::Relaxed) { + encoder.force_intra_frame(); + } + let ts = start.elapsed().as_millis() as u64; + if !encode_send(&mut encoder, &raw, (w, h), ts, tx, &mut last_frame) { + return LoopEnd::ChannelClosed; // 会话结束 + } + last_raw = Some(raw); + last_sent = Instant::now(); + // 帧间隔节流到 max_fps + let el = iter_start.elapsed(); + if el < min_frame { + std::thread::sleep(min_frame - el); + } + } + Err(e) => { + eprintln!("[capture] frame copy failed: {e}"); + return LoopEnd::Rebuild; + } + }, + Ok(false) => { + let force = keyreq.swap(false, Ordering::Relaxed); + if force && last_raw.is_some() { + // 静止桌面下的关键帧恢复:画面没变不代表可以不给关键帧—— + // 解码端一旦出错,若只会收到旧 P 帧就永远恢复不了 + encoder.force_intra_frame(); + let raw = last_raw.as_ref().expect("上面已判断 is_some"); + let ts = start.elapsed().as_millis() as u64; + if !encode_send(&mut encoder, raw, (w, h), ts, tx, &mut last_frame) { + return LoopEnd::ChannelClosed; + } + last_sent = Instant::now(); + } else if last_sent.elapsed() > Duration::from_millis(500) && last_frame.is_some() { + // 桌面静止:低频重发最后一帧(重复 P 帧,解码端画面不变) + let f = last_frame.as_ref().expect("上面已判断 is_some"); + if tx.send(Some(f.clone())).is_err() { + return LoopEnd::ChannelClosed; + } + last_sent = Instant::now(); + } else { + std::thread::sleep(Duration::from_millis(8)); + } + } + Err(e) => { + eprintln!("[capture] acquire failed: {e}"); + return LoopEnd::Rebuild; + } + } + } +} + +/// 采集主循环:独立线程运行;后端失效时经 `factory` 重建,通道关闭时退出。 +/// 通道为 watch 单槽(latest-wins):发送即覆盖,网络慢时不产生积压。 +pub fn run_capture_loop( + tx: watch::Sender>>, + keyreq: Arc, + mut cap: Box, + mut factory: F, + enc: EncoderSettings, +) where + F: FnMut() -> Result>, +{ + loop { + match capture_session(cap.as_mut(), &tx, keyreq.as_ref(), &enc) { + LoopEnd::ChannelClosed => return, + LoopEnd::Rebuild => {} + } + std::thread::sleep(Duration::from_millis(300)); + loop { + match factory() { + Ok(c) => { + cap = c; + break; + } + Err(e) => { + eprintln!("[capture] init failed: {e}, retry in 2s"); + std::thread::sleep(Duration::from_secs(2)); + } + } + } + } +} + +/// 跑一次完整的被控端会话:监听 → 认证 → 视频下行 + 输入上行。 +/// +/// `capture_factory` 可被多次调用(采集后端失效时重建),故需为 `FnMut`。 +pub async fn serve( + cfg: SessionConfig, + enc: EncoderSettings, + mut capture_factory: F, + injector: Arc, +) -> Result<()> +where + F: FnMut() -> Result> + Send + 'static, +{ + rustls::crypto::ring::default_provider() + .install_default() + .ok(); + + // 自签名证书(MVP:客户端跳过校验;正式版改为固定公钥/账号体系) + let ck = rcgen::generate_simple_self_signed(vec!["localhost".into()])?; + let cert = ck.cert.der().clone(); + let key = PrivatePkcs8KeyDer::from(ck.key_pair.serialize_der()); + + // 传输参数调优: + // - 收发窗口放大,避免高 RTT 下吞吐被 窗口/RTT 死锁(默认 512KB 流窗口) + // - 发送缓冲上限 512KB:QUIC 内部排队 ≈ 3 帧 @5Mbps,E2E 延迟天花板 ~150ms; + // 配合上游 latest-wins 丢帧,超量部分在进 QUIC 前就被丢弃 + // (与 Windows 端 agent/src/main.rs 保持一致,沿用同一次调优结论) + let mut transport = quinn::TransportConfig::default(); + transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024)); + transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024)); + transport.send_window(512 * 1024); + let mut server_config = quinn::ServerConfig::with_single_cert(vec![cert], key.into())?; + server_config.transport_config(Arc::new(transport)); + + let endpoint = quinn::Endpoint::server(server_config, format!("0.0.0.0:{}", cfg.port).parse()?)?; + println!( + "[agent] listening 0.0.0.0:{}, verify code = {}", + cfg.port, cfg.code + ); + + let conn = endpoint + .accept() + .await + .ok_or_else(|| anyhow::anyhow!("endpoint closed"))? + .await?; + println!("[agent] peer connected: {}", conn.remote_address()); + + // 流 1:认证 + 视频下行 + let (mut send, mut recv) = conn.accept_bi().await?; + + let first = read_control(&mut recv).await?; + let code_ok = matches!(&first, ControlMsg::Auth { code: c } if *c == cfg.code); + if !code_ok { + write_control(&mut send, &ControlMsg::AuthFail("bad code".into())).await?; + send.finish()?; + // 必须等对端读到 AuthFail 并挂断再退出:直接 return 会让进程带走 QUIC + // 缓冲区里尚未发出的数据,客户端只会看到 "connection lost" 而不是 AuthFail + let _ = tokio::time::timeout(Duration::from_secs(5), conn.closed()).await; + anyhow::bail!("auth failed"); + } + + // 采集后端先建一次:既拿桌面尺寸回给主控端,也避免白建一个 duplication 只为读尺寸 + let first_cap = capture_factory()?; + let (w, h) = first_cap.size(); + write_control(&mut send, &ControlMsg::AuthOk { width: w, height: h }).await?; + println!("[agent] auth ok, desktop {w}x{h}"); + + // 流 2:键鼠输入 + 控制上行(与视频分离,避免被视频积压阻塞) + let (mut isend, mut irecv) = conn.accept_bi().await?; + + // 采集线程 → 视频帧(watch 单槽:新帧覆盖旧帧,latest-wins) + let (tx, mut frames_rx) = watch::channel::>>(None); + let keyreq = Arc::new(AtomicBool::new(false)); + let keyreq_capture = keyreq.clone(); + let enc_settings = enc; + std::thread::spawn(move || { + run_capture_loop(tx, keyreq_capture, first_cap, capture_factory, enc_settings) + }); + + // 上行控制消息(Pong)经通道交给主循环写流 2,避免多任务争抢 + let (ctl_tx, mut ctl_rx) = mpsc::unbounded_channel::(); + let t0 = Instant::now(); + + // 读循环(流 2):输入事件 + Ping;Pong 附带**视频下行方向**的 QUIC 丢包统计 + // (conn.stats().path 统计的是本端发送方向,被控端的发送方向正是视频下行) + let keyreq_reader = keyreq.clone(); + let conn_stats = conn.clone(); + let reader = tokio::spawn(async move { + loop { + match read_control(&mut irecv).await { + Ok(ControlMsg::Input(ev)) => dispatch_input(injector.as_ref(), ev), + Ok(ControlMsg::KeyFrameReq) => { + keyreq_reader.store(true, Ordering::Relaxed); + } + Ok(ControlMsg::Ping(t)) => { + let agent_ts = t0.elapsed().as_millis() as u64; + let p = conn_stats.stats().path; + let _ = ctl_tx.send(ControlMsg::Pong { + t, + agent_ts, + lost: p.lost_packets, + sent: p.sent_packets, + }); + } + Ok(_) => {} + Err(_) => break, + } + } + }); + + loop { + tokio::select! { + changed = frames_rx.changed() => { + if changed.is_err() { + break; // 采集线程退出 + } + if let Some(data) = frames_rx.borrow().clone() { + if send.write_all(&data).await.is_err() { + break; + } + } + } + ctl = ctl_rx.recv() => { + match ctl { + Some(m) => { + if write_control(&mut isend, &m).await.is_err() { + break; + } + } + None => break, + } + } + } + } + reader.abort(); + println!("[agent] session closed"); + Ok(()) +} diff --git a/linux-agent/Cargo.toml b/linux-agent/Cargo.toml new file mode 100644 index 0000000..9c082a2 --- /dev/null +++ b/linux-agent/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "linux-agent" +version = "0.1.0" +edition = "2021" + +[dependencies] +agent-core = { path = "../agent-core" } +common = { path = "../common" } +anyhow.workspace = true +tokio.workspace = true +x11rb = { version = "0.13", features = ["xtest"] } + +[dev-dependencies] +# 冒烟测试里要扮演主控端,自己发起 QUIC/TLS 连接 +quinn.workspace = true +rustls.workspace = true diff --git a/linux-agent/src/capture.rs b/linux-agent/src/capture.rs new file mode 100644 index 0000000..33bffc9 --- /dev/null +++ b/linux-agent/src/capture.rs @@ -0,0 +1,147 @@ +//! 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, + last_capture: Option, +} + +impl X11Capturer { + pub fn new() -> Result { + 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 { + 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 { + 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> { + anyhow::ensure!(!self.last.is_empty(), "尚无帧数据可拷贝"); + Ok(self.last.clone()) + } +} diff --git a/linux-agent/src/input.rs b/linux-agent/src/input.rs new file mode 100644 index 0000000..0a77bf5 --- /dev/null +++ b/linux-agent/src/input.rs @@ -0,0 +1,249 @@ +//! 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, + root: Window, + width: i32, + height: i32, +} + +impl X11Injector { + pub fn new() -> Result { + 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 { + 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 + } +} diff --git a/linux-agent/src/main.rs b/linux-agent/src/main.rs new file mode 100644 index 0000000..daa76de --- /dev/null +++ b/linux-agent/src/main.rs @@ -0,0 +1,31 @@ +//! Linux 被控端:X11 采集 + XTest 注入;会话层(QUIC/认证/编码/丢帧)复用 `agent-core`。 +//! +//! 与 Windows 端的差异仅在这两个模块里,线格式与主控端完全一致,viewer 不需要任何改动。 +//! 依赖 `DISPLAY` 环境变量(Xorg / XWayland 均可)。 + +mod capture; +mod input; + +use agent_core::{parse_args, serve, EncoderSettings, InputInjector, ScreenCapturer, SessionConfig}; +use anyhow::Result; +use std::sync::Arc; + +#[tokio::main] +async fn main() -> Result<()> { + let (port, code) = parse_args()?; + + let display = std::env::var("DISPLAY").unwrap_or_else(|_| "(未设置)".to_string()); + println!("[linux-agent] DISPLAY={display}"); + + // 注入端连接常驻(启动即校验 XTEST 扩展是否可用,缺了就直接报错退出) + let injector: Arc = Arc::new(input::X11Injector::new()?); + + serve( + SessionConfig { port, code }, + EncoderSettings::default(), + // 采集端可被重建(X11 连接断开等),故用工厂闭包 + || Ok(Box::new(capture::X11Capturer::new()?) as Box), + injector, + ) + .await +} diff --git a/linux-agent/tests/smoke.rs b/linux-agent/tests/smoke.rs new file mode 100644 index 0000000..f40d539 --- /dev/null +++ b/linux-agent/tests/smoke.rs @@ -0,0 +1,440 @@ +//! Linux 被控端端到端冒烟测试。 +//! +//! 需要 X server(Xvfb 即可),通过 `DISPLAY` 指定;推荐用 `scripts/linux-smoke.sh` 一键跑。 +//! +//! 覆盖链路:X11 采集 → openh264 编码 → QUIC/TLS 下行 → 主控端解帧; +//! 外加验证码认证、Ping/Pong、关键帧请求,以及**输入注入的实证断言** +//! (注入鼠标移动后,从另一条 X11 连接查询指针真实落点,而不是只看进程没崩)。 + +use anyhow::{bail, Context, Result}; +use common::{read_control, read_video_frame, write_control, ControlMsg, InputEvent}; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{DigitallySignedStruct, SignatureScheme}; +use std::io::{BufRead, BufReader}; +use std::net::{TcpListener, ToSocketAddrs}; +use std::process::{Child, Command, Stdio}; +use std::sync::{mpsc, Arc}; +use std::time::{Duration, Instant}; +use x11rb::connection::Connection; +use x11rb::protocol::xproto::{ConnectionExt as _, Window}; +use x11rb::rust_connection::RustConnection; + +/// 与 viewer 一致:MVP 阶段跳过自签名证书校验 +#[derive(Debug)] +struct SkipVerify; + +impl ServerCertVerifier for SkipVerify { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + fn verify_tls12_signature( + &self, + _message: &[u8], + _certificate: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + fn verify_tls13_signature( + &self, + _message: &[u8], + _certificate: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + fn supported_verify_schemes(&self) -> Vec { + vec![ + SignatureScheme::RSA_PKCS1_SHA256, + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::ED25519, + SignatureScheme::RSA_PSS_SHA256, + ] + } +} + +/// 被测进程的 RAII 句柄:测试无论成功失败都会收尸 +struct AgentProc { + child: Child, +} + +impl Drop for AgentProc { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn expected_screen() -> (u32, u32) { + let s = std::env::var("SMOKE_SCREEN").unwrap_or_else(|_| "1280x720".to_string()); + let (w, h) = s + .split_once('x') + .unwrap_or_else(|| panic!("SMOKE_SCREEN 需形如 1280x720,实际 {s:?}")); + (w.parse().expect("宽"), h.parse().expect("高")) +} + +fn free_port() -> Result { + let l = TcpListener::bind("127.0.0.1:0")?; + Ok(l.local_addr()?.port()) +} + +fn spawn_agent(port: u16, code: &str) -> Result { + let mut child = Command::new(env!("CARGO_BIN_EXE_linux-agent")) + .args(["--port", &port.to_string(), "--code", code]) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .context("启动 linux-agent 失败")?; + + let stdout = child.stdout.take().expect("stdout 已 piped"); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(|l| l.ok()) { + println!("[agent] {line}"); + let _ = tx.send(line); + } + }); + + // 等 QUIC 端口就绪 + let deadline = Instant::now() + Duration::from_secs(30); + loop { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + bail!("等待 agent 监听超时(DISPLAY 是否已设置?)"); + } + match rx.recv_timeout(left) { + Ok(line) if line.contains("listening") => return Ok(AgentProc { child }), + Ok(_) => continue, + Err(_) => bail!("等待 agent 监听超时,进程可能已退出"), + } + } +} + +async fn connect(port: u16) -> Result<(quinn::Endpoint, quinn::Connection)> { + let tls = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(SkipVerify)) + .with_no_client_auth(); + let qc: quinn::crypto::rustls::QuicClientConfig = Arc::new(tls).try_into()?; + + let mut transport = quinn::TransportConfig::default(); + transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024)); + transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024)); + + let mut client_config = quinn::ClientConfig::new(Arc::new(qc)); + client_config.transport_config(Arc::new(transport)); + + let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse()?)?; + endpoint.set_default_client_config(client_config); + + let addr = ("127.0.0.1", port) + .to_socket_addrs()? + .next() + .ok_or_else(|| anyhow::anyhow!("地址解析失败"))?; + let conn = endpoint.connect(addr, "localhost")?.await?; + // endpoint 必须与 conn 同生命周期,故一并返回 + Ok((endpoint, conn)) +} + +fn has_start_code(payload: &[u8]) -> bool { + let head = &payload[..payload.len().min(64)]; + head.windows(4).any(|w| w == [0, 0, 0, 1]) || head.windows(3).any(|w| w == [0, 0, 1]) +} + +/// H.264 NAL 类型:5 = IDR 片(关键帧) +const NAL_IDR: u8 = 5; + +/// 按 Annex-B 起始码扫描,判断载荷里是否含指定类型的 NAL +fn contains_nal(payload: &[u8], nal_type: u8) -> bool { + let mut i = 0usize; + while i + 3 <= payload.len() { + let sc_len = if payload[i..].starts_with(&[0, 0, 0, 1]) { + 4 + } else if payload[i..].starts_with(&[0, 0, 1]) { + 3 + } else { + i += 1; + continue; + }; + if i + sc_len >= payload.len() { + return false; + } + if payload[i + sc_len] & 0x1F == nal_type { + return true; + } + i += sc_len + 1; + } + false +} + +/// 在根窗口左上角画一个**小**矩形,制造一次细微画面变化。 +/// +/// 刻意不用「整屏刷白」那类大变化:大变化会触发编码器的场景切换自动 IDR, +/// 于是「重发旧帧」和「真重编关键帧」拿到同样的字节,用例就失去区分度了。 +fn perturb_screen(conn: &RustConnection, root: Window) -> Result<()> { + use x11rb::protocol::xproto::{CreateGCAux, Rectangle}; + let gc = conn.generate_id()?; + conn.create_gc( + gc, + root, + &CreateGCAux::new().foreground(0x00FF_FFFF).graphics_exposures(0), + )?; + conn.poly_fill_rectangle( + root, + gc, + &[Rectangle { + x: 32, + y: 32, + width: 64, + height: 64, + }], + )?; + conn.free_gc(gc)?; + conn.flush()?; + println!("[smoke] 已扰动屏幕(根窗口画 64x64 白块)"); + Ok(()) +} + +/// 读帧直到拿到一个非关键帧(跳过静止桌面的低频重发),返回该帧载荷 +async fn read_until_p_frame(recv: &mut quinn::RecvStream, timeout: Duration) -> Result> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + let (_h, p) = tokio::time::timeout(Duration::from_secs(5), read_video_frame(recv)) + .await + .context("等待扰动帧超时")??; + if !contains_nal(&p, NAL_IDR) { + return Ok(p); + } + } + bail!("扰动后未拿到非关键帧,用例前置条件不成立(扰动量是否太大?)") +} + +/// 请求关键帧后,容错地等待一个 IDR:期间允许出现静止桌面的 P 帧重发。 +/// 返回是否在超时内拿到 IDR。 +async fn wait_for_idr(recv: &mut quinn::RecvStream, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + match tokio::time::timeout(Duration::from_secs(2), read_video_frame(recv)).await { + Ok(Ok((_h, p))) => { + if contains_nal(&p, NAL_IDR) { + return Ok(true); + } + } + _ => break, + } + } + Ok(false) +} + +/// 归一化坐标 → 根窗口像素(与 linux-agent/src/input.rs 的 scale 完全一致) +fn scale(v: u16, extent: u32) -> i16 { + if extent <= 1 { + return 0; + } + ((v as i64 * (extent as i64 - 1)) / 65535) as i16 +} + +fn open_display() -> Result<(RustConnection, Window)> { + let (conn, screen_num) = x11rb::connect(None) + .map_err(|e| anyhow::anyhow!("连接 X11 失败(DISPLAY 是否已设置?): {e}"))?; + let root = conn.setup().roots[screen_num].root; + Ok((conn, root)) +} + +fn pointer_position(conn: &RustConnection, root: Window) -> Result<(i16, i16)> { + let r = conn.query_pointer(root)?.reply()?; + Ok((r.root_x, r.root_y)) +} + +fn wait_pointer( + conn: &RustConnection, + root: Window, + want: (i16, i16), + timeout: Duration, +) -> Result<(i16, i16)> { + let deadline = Instant::now() + timeout; + let mut last = pointer_position(conn, root)?; + while Instant::now() < deadline { + last = pointer_position(conn, root)?; + if (last.0 - want.0).abs() <= 2 && (last.1 - want.1).abs() <= 2 { + return Ok(last); + } + std::thread::sleep(Duration::from_millis(50)); + } + Ok(last) +} + +#[tokio::test] +async fn end_to_end_capture_encode_transport_input() -> Result<()> { + rustls::crypto::ring::default_provider() + .install_default() + .ok(); + + let (sw, sh) = expected_screen(); + let port = free_port()?; + let _agent = spawn_agent(port, "smoke-code")?; + let (_endpoint, conn) = connect(port).await?; + + // 流 1:认证,并核对被控端上报的桌面尺寸就是 Xvfb 的尺寸 + let (mut send, mut recv) = conn.open_bi().await?; + write_control( + &mut send, + &ControlMsg::Auth { + code: "smoke-code".into(), + }, + ) + .await?; + match read_control(&mut recv).await? { + ControlMsg::AuthOk { width, height } => { + assert_eq!( + (width, height), + (sw, sh), + "agent 上报桌面尺寸与实际屏幕不符" + ); + } + other => bail!("认证应成功,却收到 {other:?}"), + } + + // 流 2:输入 + 控制 + let (mut isend, mut irecv) = conn.open_bi().await?; + + // Ping/Pong(延迟测量通道 + 下行丢包统计) + write_control(&mut isend, &ControlMsg::Ping(4242)).await?; + match read_control(&mut irecv).await? { + ControlMsg::Pong { + t, + agent_ts, + lost, + sent, + } => { + assert_eq!(t, 4242, "Pong 回显的时间戳不一致"); + assert!(sent > 0, "下行发包计数应大于 0(视频流已在发)"); + println!("[smoke] Pong 收到,agent_ts={agent_ts},下行 sent={sent} lost={lost}"); + } + other => bail!("期望 Pong,收到 {other:?}"), + } + + // 首帧:证明采集 + 编码 + 线格式整条链路通了 + let (h1, p1) = tokio::time::timeout(Duration::from_secs(15), read_video_frame(&mut recv)) + .await + .context("等待首个视频帧超时")??; + assert_eq!((h1.width, h1.height), (sw, sh), "视频帧头尺寸不符"); + assert!(!p1.is_empty(), "视频载荷为空"); + println!( + "[smoke] 首帧 {}x{} ts={} 编码后 {} 字节,头部 {:02X?}", + h1.width, + h1.height, + h1.ts_ms, + p1.len(), + &p1[..p1.len().min(12)] + ); + assert!( + has_start_code(&p1), + "载荷不像 H.264(前 16 字节 {:02X?})", + &p1[..p1.len().min(16)] + ); + + // 关键帧恢复:先在**静止**桌面上请求关键帧。 + // 为了有区分度,先扰动一下屏幕,让缓存里落入一个非关键帧(P 帧),否则 + // 「重发旧帧」和「真重编关键帧」两种行为拿到的字节是一样的,测不出来。 + let (probe, root) = open_display()?; + perturb_screen(&probe, root)?; + let pp = read_until_p_frame(&mut recv, Duration::from_secs(10)).await?; + println!( + "[smoke] 扰动帧 {} 字节,头部 {:02X?}(应为 P 帧,无 IDR)", + pp.len(), + &pp[..pp.len().min(12)] + ); + // 前置条件自检:没有这一步,用例可能因为「扰动本身产生了 IDR」而虚假通过 + assert!( + !contains_nal(&pp, NAL_IDR), + "扰动帧仍是 IDR,本用例无法区分重发旧帧与真重编关键帧" + ); + tokio::time::sleep(Duration::from_millis(800)).await; // 等画面重新静止 + + write_control(&mut isend, &ControlMsg::KeyFrameReq).await?; + assert!( + wait_for_idr(&mut recv, Duration::from_secs(3)).await?, + "静止桌面请求关键帧后 3s 内未收到 IDR:解码端一旦出错将永远无法恢复" + ); + println!("[smoke] 静止桌面请求关键帧 → 收到 IDR"); + + // 输入注入实证:注入到屏幕中心,再从独立 X11 连接查询指针真实落点 + let before = pointer_position(&probe, root)?; + write_control( + &mut isend, + &ControlMsg::Input(InputEvent::MouseMove { + x: 32768, + y: 32768, + }), + ) + .await?; + let want = (scale(32768, sw), scale(32768, sh)); + let got = wait_pointer(&probe, root, want, Duration::from_secs(5))?; + println!("[smoke] 指针 {before:?} → {got:?}(期望 ≈{want:?})"); + assert!( + (got.0 - want.0).abs() <= 2 && (got.1 - want.1).abs() <= 2, + "XTest 注入未生效:期望指针 ≈{want:?},实际 {got:?}" + ); + + // 键盘:链路不得因此崩溃(无窗口可断言落点) + write_control( + &mut isend, + &ControlMsg::Input(InputEvent::KeyDown { + scancode: 0x1E, + extended: false, + }), + ) + .await?; + write_control( + &mut isend, + &ControlMsg::Input(InputEvent::KeyUp { + scancode: 0x1E, + extended: false, + }), + ) + .await?; + + tokio::time::sleep(Duration::from_millis(300)).await; + assert!( + conn.close_reason().is_none(), + "会话被意外关闭:{:?}", + conn.close_reason() + ); + + println!("[smoke] 全部断言通过"); + Ok(()) +} + +#[tokio::test] +async fn rejects_wrong_verification_code() -> Result<()> { + rustls::crypto::ring::default_provider() + .install_default() + .ok(); + + let port = free_port()?; + let _agent = spawn_agent(port, "right-code")?; + let (_endpoint, conn) = connect(port).await?; + + let (mut send, mut recv) = conn.open_bi().await?; + write_control( + &mut send, + &ControlMsg::Auth { + code: "wrong-code".into(), + }, + ) + .await?; + + match read_control(&mut recv).await? { + ControlMsg::AuthFail(_) => Ok(()), + other => bail!("错误验证码竟被接受:{other:?}"), + } +} diff --git a/scripts/linux-smoke.sh b/scripts/linux-smoke.sh new file mode 100644 index 0000000..327651b --- /dev/null +++ b/scripts/linux-smoke.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Linux 被控端冒烟测试一键脚本:自建 Xvfb 虚拟显示 → 跑端到端测试 → 收摊。 +# +# 用法(容器内或任意有 Xvfb 的 Linux): +# bash scripts/linux-smoke.sh +# 可用环境变量覆盖: +# SMOKE_SCREEN=1920x1080 虚拟屏分辨率(默认 1280x720) +# SMOKE_DISPLAY=:99 X display 编号(默认 :99) +set -euo pipefail + +SCREEN="${SMOKE_SCREEN:-1280x720}" +DISPLAY_NUM="${SMOKE_DISPLAY:-:99}" + +export DISPLAY="$DISPLAY_NUM" +export SMOKE_SCREEN="$SCREEN" + +XVFB_PID="" +cleanup() { + if [ -n "$XVFB_PID" ]; then + kill "$XVFB_PID" 2>/dev/null || true + wait "$XVFB_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +echo "[smoke] 启动 Xvfb $DISPLAY_NUM (${SCREEN}x24)" +Xvfb "$DISPLAY_NUM" -screen 0 "${SCREEN}x24" -nolisten tcp >/tmp/xvfb.log 2>&1 & +XVFB_PID=$! + +ready=0 +for _ in $(seq 1 50); do + if xdpyinfo -display "$DISPLAY_NUM" >/dev/null 2>&1; then + ready=1 + break + fi + sleep 0.2 +done + +if [ "$ready" -ne 1 ]; then + echo "[smoke] Xvfb 启动失败,日志:" + cat /tmp/xvfb.log 2>/dev/null || true + exit 1 +fi +echo "[smoke] X server 就绪:$(xdpyinfo -display "$DISPLAY_NUM" | grep -E 'dimensions' | head -1)" + +# 两个测试共用同一块虚拟屏与指针,必须串行 +cargo test -p linux-agent --test smoke -- --nocapture --test-threads=1 +echo "[smoke] 通过" diff --git a/构建运行手册.md b/构建运行手册.md index 112d7c2..9d76ba0 100644 --- a/构建运行手册.md +++ b/构建运行手册.md @@ -1,23 +1,36 @@ # MVP 远程控制 Demo · 构建运行手册 -> 项目路径:`D:\wordkb\2026-10-22-15-52-47\mvp-remote\` -> 形态:Rust workspace,三个 crate:`common`(协议)、`agent`(被控端)、`viewer`(主控端) +> 项目路径:`D:\code\remote\` +> 形态:Rust workspace:`common`(协议)、`agent-core`(平台无关会话层,当前供 linux-agent 使用)、`agent`(Windows 被控端)、`linux-agent`(Linux 被控端)、`viewer`(主控端) ## 1. 功能范围(量级一 Demo) -- ✅ DXGI Desktop Duplication 全屏采集(GPU 拷贝,脏区/硬编码留待二期) -- ✅ LZ4 压缩 + QUIC 加密传输(自签名证书,传输层已加密) +- ✅ DXGI Desktop Duplication 全屏采集 + NVENC 硬编(无 N 卡时自动回落 openh264 软编) +- ✅ QUIC 加密传输(自签名证书,传输层已加密) - ✅ 验证码认证(默认 123456) - ✅ 键鼠远程控制(SendInput 注入,虚拟桌面绝对坐标 + 扫描码) - ✅ 桌面静止时低频重发(保持画面新鲜) -- ❌ 暂无:硬件编码、P2P 打洞/中继(当前仅局域网/可直连地址)、音频、文件传输、Linux 被控 +- ✅ Linux 被控端(X11 采集 + XTest 注入,见第 8 节) +- ❌ 暂无:P2P 打洞/中继(当前仅局域网/可直连地址)、音频、文件传输 ## 2. 环境要求 - Windows 8+(被控端需要 DXGI Duplication;主控端 Win7 亦可) -- 工具链已全部部署在 D 盘(无需再装): - - Rust (GNU):`uurev/tools/rust/cargo/bin` - - MinGW-w64 14.2:`uurev/tools/mingw64/bin` +- Rust(GNU host:`x86_64-pc-windows-gnu`)+ MinGW-w64,已验证可用组合: + - `rustup default stable-x86_64-pc-windows-gnu`,装到 `%USERPROFILE%\.cargo\bin` + - MinGW-w64:`D:\mingw64\bin`(gcc 16.2.0) + - 构建时 PATH 需同时包含上面两个目录(缺 MinGW 会在链接 openh264 的 C++ 时失败) +- **cargo 源**:国内直连 crates.io 会超时(cargo 不读系统代理),需配置镜像。已在本机 + `%USERPROFILE%\.cargo\config.toml` 写入 USTC 源: + + ```toml + [source.crates-io] + replace-with = "ustc" + [source.ustc] + registry = "sparse+https://mirrors.ustc.edu.cn/crates.io-index/" + ``` + +> 注:旧版本手册写的 `D:\wordkb\...\uurev\tools\rust` 路径已不存在,按上面重新准备工具链即可。 ## 3. 构建 @@ -67,6 +80,30 @@ viewer.exe --addr <被控端IP> --port 47800 --code 888888 4. 高 DPI/多屏:当前只采集主输出(EnumOutputs(0))。 5. 主控端窗口固定为远端分辨率,颜色序 BGRA→0RGB 若异常可在 `blit()` 一键翻转。 +### 6.1 待修问题(已定位,尚未修,均在 Windows `agent/`) + +1. **错误验证码时 `AuthFail` 送不达**:`agent/src/main.rs` 写完 `AuthFail` 后立刻 + `send.finish()` 并返回,进程退出会把 QUIC 缓冲区里尚未发出的数据一起带走, + 客户端只会看到 `connection lost` 而不是 `AuthFail`。 + 修法:`finish()` 后等对端读到并挂断(`conn.closed()` + 超时)再退出。 + +2. **软编路径下静止桌面仍不响应关键帧请求**(`04c02d9` 只修了 NVENC 那一半): + `agent/src/capture.rs` 的 `Ok(false)`(画面未变)分支里,`force_key` 被 + `keyreq.swap` 消费后,只有 `EncoderKind::Nvenc` 分支拿它去重编 IDR; + `EncoderKind::Soft` 分支仍然只是「重发最后一帧码流副本」,标志被直接丢弃。 + 后果:**无 N 卡 / 驱动异常(走软编兜底)的机器上,解码端一旦出错,静止画面下 + 永远拿不到 IDR,表现为黑屏。** + 修法:软编路径同样保留最近一帧裸像素,`force_key` 时重编一个真关键帧。 + +3. **单次 `KeyFrameReq` 可能被静默吞掉**:同一个 `keyreq.swap(false, …)` 写在了 + `if last_sent.elapsed() > 500ms` 的**外面**,因此请求若落在距上次发送 500ms 之内, + 标志已被消费但分支不进入,该次请求就丢了,必须等下一次请求才可能生效 + (主控端目前周期性重发,故表现为延迟而非永久黑屏)。 + 修法:把 `swap` 移进时间闸内,或先在闸外预判、进闸后再消费。 + +> 第 1、2 条在 `feature/agent-linux` 分支的 `agent-core` 里已经实现(**Linux 侧生效**), +> 但按"不与他线争抢 `agent/`"的约定,**本期没有改动 Windows 代码**。 + ## 7. 下一阶段任务队列(Phase 1 剩余) 1. H.264:接 openh264(软编兜底)→ NVENC/QSV(硬编优先) @@ -74,3 +111,80 @@ viewer.exe --addr <被控端IP> --port 47800 --code 888888 3. QUIC 多流:视频/控制/键鼠分离流(键鼠零阻塞) 4. P2P 打洞 + coturn 中继(自建信令服务器) 5. 被控端服务化(Windows Service + 会话切换处理) + +## 8. Linux 被控端(linux-agent) + +### 8.1 现状与边界(重要) + +本期**只在新增文件里做,未改动 Windows `agent/`**,原因见下: + +| crate / 文件 | 职责 | 当前使用者 | +|---|---|---| +| `agent-core/src/lib.rs` | QUIC/TLS 握手、传输调优、验证码认证、会话/select 循环、latest-wins 单槽、输入分发,以及软件编码视频管线(openh264) | **仅 linux-agent** | +| `linux-agent/src/capture.rs` | X11 `GetImage` 采集 + 整帧变更检测 | Linux | +| `linux-agent/src/input.rs` | XTest 注入 + 扫描码→X11 keycode 映射 | Linux | +| `agent/`(Windows) | 保留原有自持会话循环 + NVENC/软编双路编码,**本期未动** | Windows | + +**为什么 Windows 端没有一并接过来**:`agent/` 的 NVENC 路径是「GPU 内 BGRA→H.264 零转换零拷贝」, +编码器必须直接吃 DXGI 的 GPU 纹理;而 `agent-core` 目前的接缝是 `ScreenCapturer`(返回 CPU 上的 +BGRA 字节),硬接会把零拷贝优势废掉。正确的接缝应是「平台视频管线产出**已编码帧**」 +(`VideoSource` 语义),待 `agent/` 由 NVENC 那条线稳定后再重切,届时应把 `openh264` 从 +`agent-core` 移到各平台 crate。 + +在此之前 Windows 与 Linux 各持一份会话循环,属于**有意的过渡态**:`common/` 协议改动需要两处同步 +(例如本期跟着补的 `Pong{lost,sent}`)。 + +### 8.2 构建 + +Windows 侧照旧(`default-members` 已排除 linux-agent,产物仍是 agent.exe / viewer.exe): + +```bash +export PATH="$USERPROFILE/.cargo/bin:/d/mingw64/bin:$PATH" # Git Bash 写法 +cargo build --release +``` + +Linux 侧显式指定包(跨平台交叉编译不推荐,用容器最省事): + +```bash +cargo build --release -p linux-agent +``` + +### 8.3 运行 + +```bash +# 被控 Linux 机器(Xorg 或 XWayland 均可,依赖 DISPLAY 环境变量) +./linux-agent --port 47800 --code 123456 +# 主控端(Windows)命令行不变 +viewer.exe --addr <被控机IP> --port 47800 --code 123456 +``` + +### 8.4 冒烟测试(不需要图形桌面) + +```bash +bash scripts/linux-smoke.sh +``` + +脚本自建 Xvfb 虚拟屏后跑端到端测试,覆盖:X11 采集 → openh264 编码 → QUIC 下行解帧、 +验证码认证(含**错误验证码被拒**)、Ping/Pong(含下行丢包统计字段)、静止桌面下的关键帧 +恢复,以及**输入注入实证**——注入鼠标移动后,从另一条 X11 连接读回指针真实坐标。 + +> 静止关键帧那条用例做过**变异测试**:把修复退回会失败、恢复后通过,确认断言不是摆设。 +> 注意扰动量必须小(画 64x64 小方块)——整屏刷白会触发编码器场景切换自动 IDR, +> 于是"重发旧帧"和"真重编关键帧"拿到同样的字节,用例就失去区分度。 + +### 8.5 键码映射(两端最关键的差异) + +协议里键盘沿用 Windows 口味的 PS/2 Set-1 扫描码,而 X11 要的是 keycode,两者**并非处处等价**: + +- 主键区:满足 `keycode = scancode + 8`; +- **E0 扩展键编号体系完全不同**(`↑` 是 0x48,但 X11 keycode 是 111 而不是 0x50)——必须查表。 + +映射表在 `linux-agent/src/input.rs::keycode_of`,带单元测试防回归。 + +### 8.6 Linux 侧已知限制 + +1. 仅支持 32bpp TrueColor 视觉(绝大多数桌面满足),其他格式启动即明确报错而非静默出错; +2. 采集走核心协议 `GetImage`,每帧经 X socket 全量传输,静止桌面也会持续轮询抓帧; + **MIT-SHM 共享内存 + XDamage 脏区**是下一步优化(锚点已写在 `linux-agent/src/capture.rs` 顶部注释); +3. 仅覆盖 X11 / XWayland;纯 Wayland(无 XWayland)需另走 PipeWire 门户; +4. 键位表按 Xorg evdev 标准 keycode 编写,自定义键盘布局可能需要调整。