init: MVP remote desktop (agent/viewer/common) with H.264 pipeline

This commit is contained in:
dev-team 2026-10-23 09:17:55 +08:00
commit 61e8a75cdd
14 changed files with 3098 additions and 0 deletions

3
.cargo/config.toml Normal file
View File

@ -0,0 +1,3 @@
# openh264 为 C++ 代码,默认动态链接 libstdc++/libgcc;改为静态链接,交付单文件 exe
[target.x86_64-pc-windows-gnu]
rustflags = ["-C", "link-arg=-static-libstdc++", "-C", "link-arg=-static-libgcc"]

26
.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# ===== Rust / Cargo =====
/target
**/*.rs.bk
# ===== 构建产物 =====
*.exe
*.dll
*.pdb
*.lib
*.a
# ===== 运行时生成 =====
*.log
identity.pem
*.pem
*.der
# ===== IDE / 编辑器 =====
.idea/
.vscode/
*.swp
*~
# ===== 系统文件 =====
Thumbs.db
.DS_Store

1701
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

15
Cargo.toml Normal file
View File

@ -0,0 +1,15 @@
[workspace]
resolver = "2"
members = ["common", "agent", "viewer"]
[workspace.dependencies]
anyhow = "1"
serde = { version = "1", features = ["derive"] }
bincode = "1.3"
tokio = { version = "1", features = ["full"] }
quinn = "0.11"
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "logging"] }
rcgen = { version = "0.13", default-features = false, features = ["ring", "pem"] }
lz4_flex = "0.11"
windows = "0.58"
minifb = "0.27"

23
agent/Cargo.toml Normal file
View File

@ -0,0 +1,23 @@
[package]
name = "agent"
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
lz4_flex.workspace = true
openh264 = { version = "0.9.9", features = ["source"] }
windows = { workspace = true, features = [
"Win32_Foundation",
"Win32_Graphics_Direct3D",
"Win32_Graphics_Direct3D11",
"Win32_Graphics_Dxgi",
"Win32_Graphics_Dxgi_Common",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
] }

256
agent/src/capture.rs Normal file
View File

@ -0,0 +1,256 @@
//! DXGI Desktop Duplication 采集(Windows 8+)。
//! 帧处理:BGRA → openh264 H.264 编码(屏幕内容实时档,5Mbps)→ 线格式。
//! 支持主控端 KeyFrameReq 强制关键帧(解码异常恢复)。
use anyhow::{anyhow, Result};
use common::{encode_video_frame, VideoFrameHeader};
use openh264::encoder::{
BitRate, Encoder, EncoderConfig, FrameRate, IntraFramePeriod, RateControlMode, UsageType,
};
use openh264::formats::{BgraSliceU8, YUVBuffer};
use openh264::OpenH264API;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::watch::Sender;
use windows::core::Interface;
use windows::Win32::Foundation::HMODULE;
use windows::Win32::Graphics::Direct3D::{
D3D_DRIVER_TYPE_HARDWARE, D3D_FEATURE_LEVEL_11_0,
};
use windows::Win32::Graphics::Direct3D11::{
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Resource, ID3D11Texture2D,
D3D11_CREATE_DEVICE_FLAG, D3D11_CPU_ACCESS_READ, D3D11_MAP, D3D11_MAP_READ,
D3D11_MAPPED_SUBRESOURCE, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_STAGING,
};
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC};
use windows::Win32::Graphics::Dxgi::{
IDXGIDevice, IDXGIOutput1, IDXGIOutputDuplication, IDXGIResource, DXGI_ERROR_WAIT_TIMEOUT,
DXGI_OUTDUPL_DESC, DXGI_OUTDUPL_FRAME_INFO,
};
use std::time::{Duration, Instant};
pub struct Duplication {
_device: ID3D11Device,
context: ID3D11DeviceContext,
staging_res: ID3D11Resource,
dup: IDXGIOutputDuplication,
pub width: u32,
pub height: u32,
}
impl Duplication {
pub fn new() -> Result<Self> {
unsafe {
let mut device: Option<ID3D11Device> = None;
let mut context: Option<ID3D11DeviceContext> = None;
D3D11CreateDevice(
None,
D3D_DRIVER_TYPE_HARDWARE,
HMODULE::default(),
D3D11_CREATE_DEVICE_FLAG(0),
Some(&[D3D_FEATURE_LEVEL_11_0]),
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)
.map_err(|e| anyhow!("D3D11CreateDevice failed: {e}"))?;
let device = device.ok_or_else(|| anyhow!("no d3d device"))?;
let context = context.ok_or_else(|| anyhow!("no d3d context"))?;
let dxgi: IDXGIDevice = device.cast()?;
let adapter = dxgi.GetAdapter()?;
let output = adapter
.EnumOutputs(0)
.map_err(|e| anyhow!("no display output: {e}"))?;
let output1: IDXGIOutput1 = output.cast()?;
let dup: IDXGIOutputDuplication = output1
.DuplicateOutput(&device)
.map_err(|e| anyhow!("DuplicateOutput failed: {e}"))?;
let desc: DXGI_OUTDUPL_DESC = dup.GetDesc();
let (width, height) = (desc.ModeDesc.Width, desc.ModeDesc.Height);
anyhow::ensure!(width > 0 && height > 0, "bad desktop size");
let sdesc = D3D11_TEXTURE2D_DESC {
Width: width,
Height: height,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_STAGING,
BindFlags: 0,
CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
MiscFlags: 0,
};
let mut staging_opt: Option<ID3D11Texture2D> = None;
device
.CreateTexture2D(&sdesc, None, Some(&mut staging_opt))
.map_err(|e| anyhow!("CreateTexture2D failed: {e}"))?;
let staging = staging_opt.ok_or_else(|| anyhow!("no staging texture"))?;
let staging_res: ID3D11Resource = staging.cast()?;
Ok(Self {
_device: device,
context,
staging_res,
dup,
width,
height,
})
}
}
/// true=拿到新帧并已复制到 staging;false=超时无更新
pub fn acquire(&mut self, timeout_ms: u32) -> Result<bool> {
unsafe {
let mut info = DXGI_OUTDUPL_FRAME_INFO::default();
let mut resource: Option<IDXGIResource> = None;
match self.dup.AcquireNextFrame(timeout_ms, &mut info, &mut resource) {
Ok(()) => {
let res = resource.ok_or_else(|| anyhow!("no desktop resource"))?;
let src: ID3D11Resource = res.cast()?;
self.context.CopyResource(&self.staging_res, &src);
let _ = self.dup.ReleaseFrame();
Ok(true)
}
Err(e) if e.code() == DXGI_ERROR_WAIT_TIMEOUT => Ok(false),
Err(e) => Err(e.into()),
}
}
}
/// 把 staging 纹理映射到内存并去掉行间距
pub fn copy_frame(&mut self) -> Result<Vec<u8>> {
unsafe {
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
self.context
.Map(&self.staging_res, 0, D3D11_MAP_READ, 0, Some(&mut mapped))
.map_err(|e| anyhow!("Map staging failed: {e}"))?;
let (w, h) = (self.width as usize, self.height as usize);
let pitch = mapped.RowPitch as usize;
let src = std::slice::from_raw_parts(mapped.pData as *const u8, pitch * h);
let mut out = Vec::with_capacity(w * h * 4);
for row in 0..h {
out.extend_from_slice(&src[row * pitch..row * pitch + w * 4]);
}
self.context.Unmap(&self.staging_res, 0);
Ok(out)
}
}
}
#[allow(dead_code)]
pub fn desktop_size() -> Result<(u32, u32)> {
let d = Duplication::new()?;
Ok((d.width, d.height))
}
/// 采集主循环:独立线程运行;连接断开(通道关闭)时退出。
/// 通道为 watch 单槽(latest-wins):发送即覆盖,网络慢时不产生积压。
pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>) {
let start = Instant::now();
loop {
let mut dup = match Duplication::new() {
Ok(d) => d,
Err(e) => {
eprintln!("[capture] init failed: {e}, retry in 2s");
std::thread::sleep(Duration::from_secs(2));
continue;
}
};
eprintln!(
"[capture] duplication started {}x{}",
dup.width, dup.height
);
// H.264 编码器:屏幕内容实时档 + 码率控制(对标 UU:3.2Mbps@60fps)
let mut encoder = match Encoder::with_api_config(
OpenH264API::from_source(),
EncoderConfig::new()
.bitrate(BitRate::from_bps(5_000_000))
.max_frame_rate(FrameRate::from_hz(30.0))
.rate_control_mode(RateControlMode::Bitrate)
.usage_type(UsageType::ScreenContentRealTime)
.intra_frame_period(IntraFramePeriod::from_num_frames(300)),
) {
Ok(e) => e,
Err(e) => {
eprintln!("[capture] encoder init failed: {e}");
return;
}
};
let mut last_frame: Option<Vec<u8>> = None;
let mut last_sent = Instant::now() - Duration::from_secs(10);
let min_frame = Duration::from_millis(33); // 30fps 上限
loop {
let iter_start = Instant::now();
match dup.acquire(80) {
Ok(true) => match dup.copy_frame() {
Ok(raw) => {
// 主控端请求关键帧(解码异常恢复)
if keyreq.swap(false, Ordering::Relaxed) {
encoder.force_intra_frame();
}
let header = VideoFrameHeader {
width: dup.width,
height: dup.height,
ts_ms: start.elapsed().as_millis() as u64,
};
let yuv = YUVBuffer::from_bgra8_source(BgraSliceU8::new(
&raw,
(dup.width as usize, dup.height as usize),
));
match encoder.encode(&yuv) {
Ok(ebs) => {
let payload = ebs.to_vec();
let frame = encode_video_frame(&header, &payload);
if tx.send(Some(frame.clone())).is_err() {
return; // 会话结束
}
last_frame = Some(frame);
last_sent = Instant::now();
}
Err(e) => {
eprintln!("[capture] encode failed: {e}");
}
}
// 帧间隔节流到 ~30fps
let el = iter_start.elapsed();
if el < min_frame {
std::thread::sleep(min_frame - el);
}
}
Err(e) => {
eprintln!("[capture] frame copy failed: {e}");
break; // 重建 duplication
}
},
Ok(false) => {
// 桌面静止:低频重发最后一帧(重复 P 帧,解码端画面不变)
if last_sent.elapsed() > Duration::from_millis(500) {
if let Some(f) = last_frame.as_ref() {
if tx.send(Some(f.clone())).is_err() {
return;
}
last_sent = Instant::now();
}
} else {
std::thread::sleep(Duration::from_millis(8));
}
}
Err(e) => {
eprintln!("[capture] acquire failed: {e}");
break; // ACCESS_LOST 等,重建
}
}
}
std::thread::sleep(Duration::from_millis(300));
}
}

103
agent/src/input.rs Normal file
View File

@ -0,0 +1,103 @@
//! 输入注入:SendInput 实现(鼠标归一化绝对坐标 + 扫描码键盘)。
use common::{InputEvent, MouseButton};
use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_EXTENDEDKEY,
KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_MIDDLEDOWN,
MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP,
MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_WHEEL, MOUSEINPUT, VIRTUAL_KEY, MOUSE_EVENT_FLAGS,
};
use windows::Win32::UI::WindowsAndMessaging::{
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
SM_YVIRTUALSCREEN,
};
// windows 0.58 未导出 LEFT 常量,按 WinUser.h 原值定义
const MOUSEEVENTF_LEFTDOWN: u32 = 0x0002;
const MOUSEEVENTF_LEFTUP: u32 = 0x0004;
fn send_mouse(flags: u32, dx: i32, dy: i32, data: u32) {
let inp = INPUT {
r#type: INPUT_MOUSE,
Anonymous: INPUT_0 {
mi: MOUSEINPUT {
dx,
dy,
mouseData: data,
dwFlags: MOUSE_EVENT_FLAGS(flags),
time: 0,
dwExtraInfo: 0,
},
},
};
unsafe {
SendInput(&[inp], std::mem::size_of::<INPUT>() as i32);
}
}
fn send_key(scancode: u16, extended: bool, up: bool) {
let mut flags = KEYEVENTF_SCANCODE;
if extended {
flags |= KEYEVENTF_EXTENDEDKEY;
}
if up {
flags |= KEYEVENTF_KEYUP;
}
let inp = INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: INPUT_0 {
ki: KEYBDINPUT {
wVk: VIRTUAL_KEY(0),
wScan: scancode,
dwFlags: flags,
time: 0,
dwExtraInfo: 0,
},
},
};
unsafe {
SendInput(&[inp], std::mem::size_of::<INPUT>() as i32);
}
}
/// 打印虚拟桌面信息(调试用)
#[allow(dead_code)]
pub fn virtual_desktop() -> (i32, i32, i32, i32) {
unsafe {
(
GetSystemMetrics(SM_XVIRTUALSCREEN),
GetSystemMetrics(SM_YVIRTUALSCREEN),
GetSystemMetrics(SM_CXVIRTUALSCREEN),
GetSystemMetrics(SM_CYVIRTUALSCREEN),
)
}
}
pub fn handle(ev: InputEvent) {
match ev {
InputEvent::MouseMove { x, y } => {
// 主控端已按虚拟桌面归一化,这里直接透传 0..65535
send_mouse(
MOUSEEVENTF_ABSOLUTE.0 | MOUSEEVENTF_VIRTUALDESK.0 | MOUSEEVENTF_MOVE.0,
x as i32,
y as i32,
0,
);
}
InputEvent::MouseDown(b) => match b {
MouseButton::Left => send_mouse(MOUSEEVENTF_LEFTDOWN, 0, 0, 0),
MouseButton::Right => send_mouse(MOUSEEVENTF_RIGHTDOWN.0, 0, 0, 0),
MouseButton::Middle => send_mouse(MOUSEEVENTF_MIDDLEDOWN.0, 0, 0, 0),
},
InputEvent::MouseUp(b) => match b {
MouseButton::Left => send_mouse(MOUSEEVENTF_LEFTUP, 0, 0, 0),
MouseButton::Right => send_mouse(MOUSEEVENTF_RIGHTUP.0, 0, 0, 0),
MouseButton::Middle => send_mouse(MOUSEEVENTF_MIDDLEUP.0, 0, 0, 0),
},
InputEvent::Wheel { dy } => {
send_mouse(MOUSEEVENTF_WHEEL.0, 0, 0, dy as u32);
}
InputEvent::KeyDown { scancode, extended } => send_key(scancode, extended, false),
InputEvent::KeyUp { scancode, extended } => send_key(scancode, extended, true),
}
}

142
agent/src/main.rs Normal file
View File

@ -0,0 +1,142 @@
//! 被控端 Agent:DXGI 桌面复制采集 → LZ4 压缩 → QUIC 下行视频;上行接收输入事件注入。
//! P0 优化:视频帧经 watch 单槽通道(latest-wins,网络慢时只发最新帧,不积压);
//! 键鼠/控制走独立 QUIC 双向流,不与视频互相阻塞。
mod capture;
mod input;
use anyhow::Result;
use common::{read_control, write_control, ControlMsg};
use rustls::pki_types::PrivatePkcs8KeyDer;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::sync::{mpsc, watch};
fn parse_args() -> Result<(u16, String)> {
let mut port = 47800u16;
let mut code = "123456".to_string();
let a: Vec<String> = std::env::args().collect();
let mut i = 1;
while i < a.len() {
match a[i].as_str() {
"--port" => port = a[i + 1].parse()?,
"--code" => code = a[i + 1].clone(),
_ => {}
}
i += 2;
}
Ok((port, code))
}
#[tokio::main]
async fn main() -> Result<()> {
let (port, code) = parse_args()?;
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 流窗口)
// - 发送缓冲上限 2MB:QUIC 内部排队有界(约 1 帧多),配合上游 latest-wins 丢帧
// 把端到端排队延迟压在 ~0.4s 内
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(2 * 1024 * 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:{port}").parse()?,
)?;
println!("[agent] listening 0.0.0.0:{port}, verify code = {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 == code);
if !code_ok {
write_control(&mut send, &ControlMsg::AuthFail("bad code".into())).await?;
send.finish()?;
anyhow::bail!("auth failed");
}
let (w, h) = capture::desktop_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::<Option<Vec<u8>>>(None);
let keyreq = Arc::new(AtomicBool::new(false));
let keyreq_capture = keyreq.clone();
std::thread::spawn(move || capture::run(tx, keyreq_capture));
// 上行控制消息(Pong)经通道交给主循环写流 2,避免多任务争抢
let (ctl_tx, mut ctl_rx) = mpsc::unbounded_channel::<ControlMsg>();
let t0 = std::time::Instant::now();
// 读循环(流 2):输入事件 + Ping
let keyreq_reader = keyreq.clone();
let reader = tokio::spawn(async move {
loop {
match read_control(&mut irecv).await {
Ok(ControlMsg::Input(ev)) => input::handle(ev),
Ok(ControlMsg::KeyFrameReq) => {
keyreq_reader.store(true, Ordering::Relaxed);
}
Ok(ControlMsg::Ping(t)) => {
let agent_ts = t0.elapsed().as_millis() as u64;
let _ = ctl_tx.send(ControlMsg::Pong { t, agent_ts });
}
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(())
}

10
common/Cargo.toml Normal file
View File

@ -0,0 +1,10 @@
[package]
name = "common"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow.workspace = true
serde.workspace = true
bincode.workspace = true
tokio.workspace = true

102
common/src/lib.rs Normal file
View File

@ -0,0 +1,102 @@
//! 通用协议与帧封装:控制消息(bincode + 长度前缀)与视频帧格式。
use anyhow::Result;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// 控制通道消息(双向)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ControlMsg {
/// 主控端认证(临时验证码,MVP 明文走 QUIC-TLS 加密通道)
Auth { code: String },
AuthOk { width: u32, height: u32 },
AuthFail(String),
KeyFrameReq,
Ping(u64),
/// t=主控端发送时刻(主控时钟),agent_ts=被控端收到时刻(被控时钟)
/// 两者配合可估计时钟偏移,进而用帧头 ts_ms 计算端到端延迟
Pong { t: u64, agent_ts: u64 },
Input(InputEvent),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MouseButton {
Left,
Right,
Middle,
}
/// 输入事件。鼠标坐标为虚拟桌面归一化 0..=65535;
/// 按键为 Set-1 扫描码,extended 标记扩展键(方向键/Home 等,需要 EXTENDEDKEY 标志)。
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum InputEvent {
MouseMove { x: u16, y: u16 },
MouseDown(MouseButton),
MouseUp(MouseButton),
Wheel { dy: i32 },
KeyDown { scancode: u16, extended: bool },
KeyUp { scancode: u16, extended: bool },
}
/// 视频帧头(载荷为 LZ4 压缩的 BGRA 像素,按行紧凑排列)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoFrameHeader {
pub width: u32,
pub height: u32,
pub ts_ms: u64,
}
async fn read_u32le<R: AsyncReadExt + Unpin>(r: &mut R) -> Result<u32> {
let mut b = [0u8; 4];
r.read_exact(&mut b).await?;
Ok(u32::from_le_bytes(b))
}
/// 编码一条控制消息为「长度前缀 + bincode」
pub fn encode_control(msg: &ControlMsg) -> Vec<u8> {
let body = bincode::serialize(msg).expect("serialize control");
let mut out = Vec::with_capacity(body.len() + 4);
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&body);
out
}
pub async fn write_control<W: AsyncWriteExt + Unpin>(
w: &mut W,
msg: &ControlMsg,
) -> Result<()> {
w.write_all(&encode_control(msg)).await?;
Ok(())
}
pub async fn read_control<R: AsyncReadExt + Unpin>(r: &mut R) -> Result<ControlMsg> {
let len = read_u32le(r).await? as usize;
anyhow::ensure!(len < 1 << 20, "control frame too large: {len}");
let mut buf = vec![0u8; len];
r.read_exact(&mut buf).await?;
Ok(bincode::deserialize(&buf)?)
}
/// 编码视频帧:[总长 u32][头长 u32][bincode 头][LZ4 载荷]
pub fn encode_video_frame(header: &VideoFrameHeader, payload: &[u8]) -> Vec<u8> {
let hb = bincode::serialize(header).expect("serialize header");
let total = 4 + hb.len() + payload.len();
let mut out = Vec::with_capacity(total + 4);
out.extend_from_slice(&(total as u32).to_le_bytes());
out.extend_from_slice(&(hb.len() as u32).to_le_bytes());
out.extend_from_slice(&hb);
out.extend_from_slice(payload);
out
}
pub async fn read_video_frame<R: AsyncReadExt + Unpin>(
r: &mut R,
) -> Result<(VideoFrameHeader, Vec<u8>)> {
let total = read_u32le(r).await? as usize;
anyhow::ensure!(total < 256 << 20, "video frame too large: {total}");
let mut buf = vec![0u8; total];
r.read_exact(&mut buf).await?;
let hlen = u32::from_le_bytes(buf[0..4].try_into().unwrap()) as usize;
let header: VideoFrameHeader = bincode::deserialize(&buf[4..4 + hlen])?;
Ok((header, buf[4 + hlen..].to_vec()))
}

14
viewer/Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[package]
name = "viewer"
version = "0.1.0"
edition = "2021"
[dependencies]
common = { path = "../common" }
anyhow.workspace = true
tokio.workspace = true
quinn.workspace = true
rustls.workspace = true
lz4_flex.workspace = true
openh264 = { version = "0.9.9", features = ["source"] }
minifb.workspace = true

73
viewer/src/keymap.rs Normal file
View File

@ -0,0 +1,73 @@
//! minifb::Key → Set-1 扫描码映射。返回 (扫描码, 是否扩展键)。
use minifb::Key;
pub fn map(k: Key) -> Option<(u16, bool)> {
use Key::*;
Some(match k {
A => (0x1E, false),
B => (0x30, false),
C => (0x2E, false),
D => (0x20, false),
E => (0x12, false),
F => (0x21, false),
G => (0x22, false),
H => (0x23, false),
I => (0x17, false),
J => (0x24, false),
K => (0x25, false),
L => (0x26, false),
M => (0x32, false),
N => (0x31, false),
O => (0x18, false),
P => (0x19, false),
Q => (0x10, false),
R => (0x13, false),
S => (0x1F, false),
T => (0x14, false),
U => (0x16, false),
V => (0x2F, false),
W => (0x11, false),
X => (0x2D, false),
Y => (0x15, false),
Z => (0x2C, false),
Key1 => (0x02, false),
Key2 => (0x03, false),
Key3 => (0x04, false),
Key4 => (0x05, false),
Key5 => (0x06, false),
Key6 => (0x07, false),
Key7 => (0x08, false),
Key8 => (0x09, false),
Key9 => (0x0A, false),
Key0 => (0x0B, false),
Space => (0x39, false),
Enter => (0x1C, false),
Backspace => (0x0E, false),
Tab => (0x0F, false),
CapsLock => (0x3A, false),
Backquote => (0x29, false),
Minus => (0x0C, false),
Equal => (0x0D, false),
Backslash => (0x2B, false),
Comma => (0x33, false),
Period => (0x34, false),
Slash => (0x35, false),
Semicolon => (0x27, false),
Apostrophe => (0x28, false),
LeftShift => (0x2A, false),
LeftCtrl => (0x1D, false),
LeftAlt => (0x38, false),
LeftSuper => (0x5B, true),
Up => (0x48, true),
Down => (0x50, true),
Left => (0x4B, true),
Right => (0x4D, true),
Home => (0x47, true),
End => (0x4F, true),
Delete => (0x53, true),
PageUp => (0x49, true),
PageDown => (0x51, true),
_ => return None,
})
}

554
viewer/src/main.rs Normal file
View File

@ -0,0 +1,554 @@
//! 主控端 Viewer:QUIC 连接被控端 → 解压帧 → minifb 渲染;采集键鼠上行。
//! HUD:右上角半透明白底参数面板(FPS / RTT / 端到端延迟 / 码率),延迟绿-黄-红分级。
mod keymap;
use anyhow::{bail, Result};
use common::{read_control, write_control, ControlMsg, InputEvent, MouseButton};
use minifb::{Key, MouseMode, Window, WindowOptions};
use openh264::formats::YUVSource;
use rustls::client::danger::{
HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier,
};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{DigitallySignedStruct, SignatureScheme};
use std::collections::HashSet;
use std::net::ToSocketAddrs;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
/// 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<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_certificate: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_certificate: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::ED25519,
SignatureScheme::RSA_PSS_SHA256,
]
}
}
fn parse_args() -> Result<(String, u16, String)> {
let mut addr = "127.0.0.1".to_string();
let mut port = 47800u16;
let mut code = "123456".to_string();
let a: Vec<String> = std::env::args().collect();
let mut i = 1;
while i < a.len() {
match a[i].as_str() {
"--addr" => addr = a[i + 1].clone(),
"--port" => port = a[i + 1].parse()?,
"--code" => code = a[i + 1].clone(),
_ => {}
}
i += 2;
}
Ok((addr, port, code))
}
static T0: OnceLock<Instant> = OnceLock::new();
fn now_ms() -> u64 {
T0.get().unwrap().elapsed().as_millis() as u64
}
/// 会话统计(HUD 数据源,全部原子量,跨任务共享)
#[derive(Default)]
struct Stats {
/// 最近 RTT ms(0=未知)
rtt: AtomicU64,
/// 被控时钟 - 主控时钟 偏移估计 ms(EMA 平滑)
offset: AtomicI64,
offset_valid: AtomicBool,
/// 端到端延迟估计 ms(采集→网络→接收,EMA 平滑)
e2e: AtomicU64,
e2e_valid: AtomicBool,
/// 累计收帧数 / 字节数
frames: AtomicU64,
bytes: AtomicU64,
}
fn main() -> Result<()> {
let (addr, port, code) = parse_args()?;
T0.set(Instant::now()).ok();
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(run(addr, port, code))
}
async fn run(addr: String, port: u16, code: String) -> Result<()> {
rustls::crypto::ring::default_provider()
.install_default()
.ok();
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()?;
// 接收窗口放大:视频是下行大流量方向,默认 512KB 流窗口会死锁吞吐
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 sock_addr = (addr.as_str(), port)
.to_socket_addrs()?
.next()
.ok_or_else(|| anyhow::anyhow!("resolve failed: {addr}"))?;
let conn = endpoint
.connect(sock_addr, "localhost")?
.await
.map_err(|e| anyhow::anyhow!("connect {addr}:{port} failed: {e}"))?;
println!("[viewer] connected to {addr}:{port}");
let (mut send, mut recv) = conn.open_bi().await?;
write_control(&mut send, &ControlMsg::Auth { code }).await?;
match read_control(&mut recv).await? {
ControlMsg::AuthOk { width, height } => {
println!("[viewer] auth ok, remote desktop {width}x{height}");
// 流 2:键鼠输入 + Ping/Pong 独立通道,不与视频流竞争
let (isend, irecv) = conn.open_bi().await?;
session(isend, irecv, recv, width, height)
}
ControlMsg::AuthFail(e) => bail!("auth failed: {e}"),
m => bail!("unexpected message: {m:?}"),
}
}
fn session(
send: quinn::SendStream,
mut irecv: quinn::RecvStream,
mut recv: quinn::RecvStream,
width: u32,
height: u32,
) -> Result<()> {
let (w, h) = (width as usize, height as usize);
let stats = Arc::new(Stats::default());
// 读任务(流 1):H.264 解码 → RGB → mpsc;同时统计端到端延迟与流量
let (frame_tx, frame_rx) = std::sync::mpsc::channel::<Vec<u8>>();
{
let stats = stats.clone();
let mut decoder = openh264::decoder::Decoder::new()
.map_err(|e| anyhow::anyhow!("decoder init failed: {e}"))?;
tokio::spawn(async move {
loop {
match common::read_video_frame(&mut recv).await {
Ok((hdr, payload)) => {
stats.bytes.fetch_add(payload.len() as u64, Ordering::Relaxed);
stats.frames.fetch_add(1, Ordering::Relaxed);
// 端到端延迟 = 接收时刻 - 时钟偏移 - 采集时刻(EMA 平滑)
if stats.offset_valid.load(Ordering::Relaxed) {
let est =
now_ms() as i64 - stats.offset.load(Ordering::Relaxed)
- hdr.ts_ms as i64;
if (0..5000).contains(&est) {
let old = stats.e2e.load(Ordering::Relaxed) as i64;
let sm = if stats.e2e_valid.load(Ordering::Relaxed) {
(old * 4 + est) / 5
} else {
est
};
stats.e2e.store(sm as u64, Ordering::Relaxed);
stats.e2e_valid.store(true, Ordering::Relaxed);
}
}
match decoder.decode(&payload) {
Ok(Some(yuv)) => {
let (dw, dh) = yuv.dimensions();
let mut rgb = vec![0u8; dw * dh * 3];
yuv.write_rgb8(&mut rgb);
if frame_tx.send(rgb).is_err() {
break;
}
}
Ok(None) => {}
Err(e) => {
// 解码异常:请求被控端强制关键帧
eprintln!("[viewer] decode failed: {e}, request keyframe");
let _ = frame_tx.send(Vec::new()); // 空帧占位唤醒渲染循环
}
}
}
Err(e) => {
eprintln!("[viewer] stream closed: {e}");
break;
}
}
}
});
}
// 读任务(流 2):Pong → RTT 与时钟偏移估计
{
let stats = stats.clone();
tokio::spawn(async move {
loop {
match read_control(&mut irecv).await {
Ok(ControlMsg::Pong { t, agent_ts }) => {
let r = now_ms();
stats.rtt.store(r.saturating_sub(t), Ordering::Relaxed);
// agent_ts ≈ (t+r)/2 时刻的 agent 时钟读数 → 偏移 EMA
let est = agent_ts as i64 - ((t + r) / 2) as i64;
let old = stats.offset.load(Ordering::Relaxed);
let sm = if stats.offset_valid.load(Ordering::Relaxed) {
(old * 3 + est) / 4
} else {
est
};
stats.offset.store(sm, Ordering::Relaxed);
stats.offset_valid.store(true, Ordering::Relaxed);
}
Ok(_) => {}
Err(_) => break,
}
}
});
}
// 写任务(流 2):输入 / Ping
let (msg_tx, mut msg_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
tokio::spawn(async move {
let mut send = send;
while let Some(b) = msg_rx.recv().await {
if send.write_all(&b).await.is_err() {
break;
}
}
let _ = send.finish();
});
let push = |tx: &tokio::sync::mpsc::UnboundedSender<Vec<u8>>, m: ControlMsg| {
let _ = tx.send(common::encode_control(&m));
};
let mut window = Window::new(
"mvp-remote viewer (Esc 退出)",
w,
h,
WindowOptions {
resize: false,
..Default::default()
},
)?;
let mut buffer = vec![0u32; w * h];
let mut prev_keys: HashSet<Key> = HashSet::new();
let mut prev_btns = [false; 3];
let mut last_pos: Option<(u16, u16)> = None;
let mut dirty = false;
let mut last_ping = Instant::now() - Duration::from_secs(1);
// HUD 每秒刷新的统计基线
let mut hud_last = Instant::now();
let mut hud_frames = 0u64;
let mut hud_bytes = 0u64;
let mut hud_fps = 0u64;
let mut hud_mbps = 0f64;
loop {
if !window.is_open() || window.is_key_down(Key::Escape) {
break;
}
// 0) 每 500ms 发一次 Ping(RTT/时钟偏移测量)
if last_ping.elapsed() >= Duration::from_millis(500) {
last_ping = Instant::now();
push(&msg_tx, ControlMsg::Ping(now_ms()));
}
// 1) 渲染最新帧(只保留最新,丢弃积压);空帧=解码异常,请求关键帧
loop {
match frame_rx.try_recv() {
Ok(raw) => {
if raw.is_empty() {
push(&msg_tx, ControlMsg::KeyFrameReq);
} else if raw.len() == w * h * 3 {
blit(&mut buffer, &raw);
dirty = true;
}
}
Err(_) => break,
}
}
// 2) 键盘
let keys: HashSet<Key> = window.get_keys().into_iter().collect();
for k in keys.difference(&prev_keys) {
if let Some((sc, ext)) = keymap::map(*k) {
push(
&msg_tx,
ControlMsg::Input(InputEvent::KeyDown {
scancode: sc,
extended: ext,
}),
);
}
}
for k in prev_keys.difference(&keys) {
if let Some((sc, ext)) = keymap::map(*k) {
push(
&msg_tx,
ControlMsg::Input(InputEvent::KeyUp {
scancode: sc,
extended: ext,
}),
);
}
}
prev_keys = keys;
// 3) 鼠标移动(窗口坐标 → 虚拟桌面归一化 0..65535;位置变化才发送)
if let Some((x, y)) = window.get_mouse_pos(MouseMode::Discard) {
let nx = ((x / w as f32) * 65535.0).clamp(0.0, 65535.0) as u16;
let ny = ((y / h as f32) * 65535.0).clamp(0.0, 65535.0) as u16;
if last_pos != Some((nx, ny)) {
last_pos = Some((nx, ny));
push(&msg_tx, ControlMsg::Input(InputEvent::MouseMove { x: nx, y: ny }));
}
}
// 4) 鼠标按键(边沿触发)
for (i, (btn, mfb)) in BTN_MAP.iter().enumerate() {
let down = window.get_mouse_down(*mfb);
if down != prev_btns[i] {
prev_btns[i] = down;
push(
&msg_tx,
ControlMsg::Input(if down {
InputEvent::MouseDown(*btn)
} else {
InputEvent::MouseUp(*btn)
}),
);
}
}
// 5) 滚轮
if let Some((_, dy)) = window.get_scroll_wheel() {
if dy.abs() >= 1.0 {
push(
&msg_tx,
ControlMsg::Input(InputEvent::Wheel { dy: dy as i32 * 120 }),
);
}
}
// 6) HUD:每秒刷新一次指标,画在右上角
if hud_last.elapsed() >= Duration::from_secs(1) {
let f = stats.frames.load(Ordering::Relaxed);
let b = stats.bytes.load(Ordering::Relaxed);
hud_fps = f.saturating_sub(hud_frames);
hud_mbps = (b.saturating_sub(hud_bytes)) as f64 * 8.0 / 1e6;
hud_frames = f;
hud_bytes = b;
hud_last = Instant::now();
}
if dirty {
let rtt = stats.rtt.load(Ordering::Relaxed);
let e2e = stats.e2e.load(Ordering::Relaxed);
let e2e_ok = stats.e2e_valid.load(Ordering::Relaxed);
draw_hud(
&mut buffer,
w,
&[
("FPS", &format!("{hud_fps}"), lat_color(hud_fps >= 25, hud_fps >= 15)),
("RTT", &format!("{rtt}ms"), lat_color(rtt < 50 && rtt > 0, rtt < 100)),
(
"E2E",
&if e2e_ok { format!("{e2e}ms") } else { "--".into() },
lat_color(e2e < 100, e2e < 200),
),
("RATE", &format!("{:.0}MBP", hud_mbps), C_GRAY),
("RES", &format!("{w}x{h}"), C_GRAY),
],
);
window.update_with_buffer(&buffer, w, h)?;
dirty = false;
} else {
window.update();
std::thread::sleep(Duration::from_millis(4));
}
}
println!("[viewer] closed");
Ok(())
}
const BTN_MAP: [(MouseButton, minifb::MouseButton); 3] = [
(MouseButton::Left, minifb::MouseButton::Left),
(MouseButton::Right, minifb::MouseButton::Right),
(MouseButton::Middle, minifb::MouseButton::Middle),
];
// ---------- HUD ----------
const C_GREEN: u32 = 0x0A8F3C;
const C_AMBER: u32 = 0xC07000;
const C_RED: u32 = 0xC02222;
const C_GRAY: u32 = 0x3A3A3A;
fn lat_color(good: bool, mid: bool) -> u32 {
if good {
C_GREEN
} else if mid {
C_AMBER
} else {
C_RED
}
}
/// 5x7 点阵字体(列优先,bit0=顶行)。覆盖 HUD 用到的字符。
fn glyph(c: u8) -> [u8; 5] {
match c {
b'0' => [0x3E, 0x51, 0x49, 0x45, 0x3E],
b'1' => [0x00, 0x42, 0x7F, 0x40, 0x00],
b'2' => [0x42, 0x61, 0x51, 0x49, 0x46],
b'3' => [0x21, 0x41, 0x45, 0x4B, 0x31],
b'4' => [0x18, 0x14, 0x12, 0x7F, 0x10],
b'5' => [0x27, 0x45, 0x45, 0x45, 0x39],
b'6' => [0x3C, 0x4A, 0x49, 0x49, 0x30],
b'7' => [0x01, 0x71, 0x09, 0x05, 0x03],
b'8' => [0x36, 0x49, 0x49, 0x49, 0x36],
b'9' => [0x06, 0x49, 0x49, 0x29, 0x1E],
b'A' => [0x7E, 0x11, 0x11, 0x11, 0x7E],
b'B' => [0x7F, 0x49, 0x49, 0x49, 0x36],
b'C' => [0x3E, 0x41, 0x41, 0x41, 0x22],
b'D' => [0x7F, 0x41, 0x41, 0x22, 0x1C],
b'E' => [0x7F, 0x49, 0x49, 0x49, 0x41],
b'F' => [0x7F, 0x09, 0x09, 0x09, 0x01],
b'G' => [0x3E, 0x41, 0x49, 0x49, 0x7A],
b'H' => [0x7F, 0x08, 0x08, 0x08, 0x7F],
b'I' => [0x00, 0x41, 0x7F, 0x41, 0x00],
b'J' => [0x20, 0x40, 0x41, 0x3F, 0x01],
b'K' => [0x7F, 0x08, 0x14, 0x22, 0x41],
b'L' => [0x7F, 0x40, 0x40, 0x40, 0x40],
b'M' => [0x7F, 0x02, 0x0C, 0x02, 0x7F],
b'N' => [0x7F, 0x04, 0x08, 0x10, 0x7F],
b'O' => [0x3E, 0x41, 0x41, 0x41, 0x3E],
b'P' => [0x7F, 0x09, 0x09, 0x09, 0x06],
b'Q' => [0x3E, 0x41, 0x51, 0x21, 0x5E],
b'R' => [0x7F, 0x09, 0x19, 0x29, 0x46],
b'S' => [0x46, 0x49, 0x49, 0x49, 0x31],
b'T' => [0x01, 0x01, 0x7F, 0x01, 0x01],
b'U' => [0x3F, 0x40, 0x40, 0x40, 0x3F],
b'V' => [0x1F, 0x20, 0x40, 0x20, 0x1F],
b'W' => [0x7F, 0x20, 0x18, 0x20, 0x7F],
b'X' => [0x63, 0x14, 0x08, 0x14, 0x63],
b'Y' => [0x03, 0x04, 0x78, 0x04, 0x03],
b'Z' => [0x61, 0x51, 0x49, 0x45, 0x43],
b':' => [0x00, 0x36, 0x36, 0x00, 0x00],
b'.' => [0x00, 0x60, 0x60, 0x00, 0x00],
b'-' => [0x08, 0x08, 0x08, 0x08, 0x08],
b' ' => [0x00; 5],
_ => [0x00; 5],
}
}
/// 在 (x,y) 处以 scale 倍率画一行文字
fn draw_text(
buffer: &mut [u32],
w: usize,
mut x: usize,
y: usize,
s: &str,
color: u32,
scale: usize,
) {
let h = buffer.len() / w.max(1);
for ch in s.bytes() {
let g = glyph(ch.to_ascii_uppercase());
for (col, bits) in g.iter().enumerate() {
for row in 0..7 {
if bits & (1 << row) != 0 {
for sy in 0..scale {
for sx in 0..scale {
let px = x + col * scale + sx;
let py = y + row * scale + sy;
if px < w && py < h {
buffer[py * w + px] = color;
}
}
}
}
}
}
x += 6 * scale;
}
}
/// 右上角半透明白底 HUD:lines = [(标签, 值, 颜色)]
fn draw_hud(buffer: &mut [u32], w: usize, lines: &[(&str, &str, u32)]) {
const SCALE: usize = 2;
const PAD: usize = 10;
const MARGIN: usize = 12;
const LINE: usize = 8 * SCALE;
let h = buffer.len() / w.max(1);
let line_w = |l: &(&str, &str, u32)| (l.0.len() + 2 + l.1.len()) * 6 * SCALE;
let box_w = lines.iter().map(line_w).max().unwrap_or(0) + PAD * 2;
let box_h = lines.len() * LINE + PAD * 2;
let x0 = w.saturating_sub(box_w + MARGIN);
let y0 = MARGIN;
// 半透明白底:px' = px*0.35 + 255*0.65
for by in y0..(y0 + box_h).min(h) {
for bx in x0..(x0 + box_w).min(w) {
let p = &mut buffer[by * w + bx];
let v = *p;
let r = (v >> 16) & 0xFF;
let g = (v >> 8) & 0xFF;
let b = v & 0xFF;
let mix = |c: u32| c * 89 / 255 + 166;
*p = (mix(r) << 16) | (mix(g) << 8) | mix(b);
}
}
for (i, (label, value, color)) in lines.iter().enumerate() {
let mut x = x0 + PAD;
let y = y0 + PAD + i * LINE;
draw_text(buffer, w, x, y, label, C_GRAY, SCALE);
x += (label.len() + 2) * 6 * SCALE;
draw_text(buffer, w, x, y, value, *color, SCALE);
}
}
/// RGB888 字节(openh264 解码输出)→ minifb 0RGB u32 缓冲
fn blit(buffer: &mut [u32], raw: &[u8]) {
for (px, chunk) in buffer.iter_mut().zip(raw.chunks_exact(3)) {
*px = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
}
}

76
构建运行手册.md Normal file
View File

@ -0,0 +1,76 @@
# MVP 远程控制 Demo · 构建运行手册
> 项目路径:`D:\wordkb\2026-10-22-15-52-47\mvp-remote\`
> 形态:Rust workspace,三个 crate:`common`(协议)、`agent`(被控端)、`viewer`(主控端)
## 1. 功能范围(量级一 Demo)
- ✅ DXGI Desktop Duplication 全屏采集(GPU 拷贝,脏区/硬编码留待二期)
- ✅ LZ4 压缩 + QUIC 加密传输(自签名证书,传输层已加密)
- ✅ 验证码认证(默认 123456)
- ✅ 键鼠远程控制(SendInput 注入,虚拟桌面绝对坐标 + 扫描码)
- ✅ 桌面静止时低频重发(保持画面新鲜)
- ❌ 暂无:硬件编码、P2P 打洞/中继(当前仅局域网/可直连地址)、音频、文件传输、Linux 被控
## 2. 环境要求
- Windows 8+(被控端需要 DXGI Duplication;主控端 Win7 亦可)
- 工具链已全部部署在 D 盘(无需再装):
- Rust (GNU):`uurev/tools/rust/cargo/bin`
- MinGW-w64 14.2:`uurev/tools/mingw64/bin`
## 3. 构建
```bash
export RUSTUP_HOME="D:\\wordkb\\2026-10-22-15-52-47\\uurev\\tools\\rust\\rustup"
export CARGO_HOME="D:\\wordkb\\2026-10-22-15-52-47\\uurev\\tools\\rust\\cargo"
export PATH="/d/wordkb/2026-10-22-15-52-47/uurev/tools/rust/cargo/bin:/d/wordkb/2026-10-22-15-52-47/uurev/tools/mingw64/bin:$PATH"
cd /d/wordkb/2026-10-22-15-52-47/mvp-remote
cargo build --release
```
产物:
- 被控端 `target/release/agent.exe`
- 主控端 `target/release/viewer.exe`
## 4. 运行(两台机器,或同机双开验证)
被控端(要被控制的电脑):
```bash
agent.exe --port 47800 --code 888888
```
主控端(操作的电脑):
```bash
viewer.exe --addr <被控端IP> --port 47800 --code 888888
```
同机自测:被控端 `--port 47800`,主控端 `--addr 127.0.0.1`。
操作说明:窗口内直接键鼠操作;**Esc 退出**。窗口尺寸=被控端分辨率(暂不可缩放)。
## 5. 代码结构导读(演进锚点)
| 文件 | 职责 | 二期改造点 |
|---|---|---|
| `common/src/lib.rs` | 控制消息/视频帧线格式 | 换 protobuf;加 FEC/序列号 |
| `agent/src/capture.rs` | DXGI 采集 + LZ4 | 加脏矩形、NVENC/QSV 硬编(替换 LZ4) |
| `agent/src/input.rs` | SendInput 注入 | 加剪贴板/多显示器虚拟桌面细化 |
| `viewer/src/main.rs` | minifb 渲染 + 输入采集 | 换 GPU 解码渲染(D3D11/YUV);加自适应码率 |
| `viewer/src/keymap.rs` | 扫描码映射 | 补全 F 区/小键盘/IME 文本通道 |
## 6. 已知限制(MVP 预期内)
1. 带宽:1080p LZ4 裸帧约 20-60MB/s,**仅适合局域网**。公网可用必须上 H.264 硬编(二期第一个任务)。
2. 安顿证书:客户端跳过服务端证书校验(代码内 `SkipVerify` 已标注),存在中间人风险,仅限测试。
3. 被控端需在**交互会话**中运行(RDP 断开后桌面复制会失效——二期做服务化+虚拟桌面处理)。
4. 高 DPI/多屏:当前只采集主输出(EnumOutputs(0))。
5. 主控端窗口固定为远端分辨率,颜色序 BGRA→0RGB 若异常可在 `blit()` 一键翻转。
## 7. 下一阶段任务队列(Phase 1 剩余)
1. H.264:接 openh264(软编兜底)→ NVENC/QSV(硬编优先)
2. 码率自适应 + 关键帧请求(协议已留 `KeyFrameReq`)
3. QUIC 多流:视频/控制/键鼠分离流(键鼠零阻塞)
4. P2P 打洞 + coturn 中继(自建信令服务器)
5. 被控端服务化(Windows Service + 会话切换处理)