init: MVP remote desktop (agent/viewer/common) with H.264 pipeline
This commit is contained in:
commit
61e8a75cdd
3
.cargo/config.toml
Normal file
3
.cargo/config.toml
Normal 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
26
.gitignore
vendored
Normal 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
1701
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal 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
23
agent/Cargo.toml
Normal 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
256
agent/src/capture.rs
Normal 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
103
agent/src/input.rs
Normal 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
142
agent/src/main.rs
Normal 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
10
common/Cargo.toml
Normal 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
102
common/src/lib.rs
Normal 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
14
viewer/Cargo.toml
Normal 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
73
viewer/src/keymap.rs
Normal 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
554
viewer/src/main.rs
Normal 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
76
构建运行手册.md
Normal 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 + 会话切换处理)
|
||||
Loading…
Reference in New Issue
Block a user