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