Compare commits
18 Commits
backup/age
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fda49d9c9 | ||
|
|
ad022ffcf4 | ||
|
|
9c340813a8 | ||
|
|
8dd18a06a1 | ||
|
|
04c02d9ced | ||
|
|
68b2ddc59a | ||
|
|
168f600fe1 | ||
|
|
bd7ca29945 | ||
|
|
00b318af8b | ||
|
|
e00175385d | ||
|
|
9c72aecc3b | ||
|
|
82ec166f23 | ||
|
|
ddbb53d41a | ||
|
|
94c45aff3f | ||
|
|
e7472fa12f | ||
|
|
881341be3a | ||
|
|
7dbb04da17 | ||
|
|
95049b6642 |
15
.gitattributes
vendored
Normal file
15
.gitattributes
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# 统一换行符,避免跨平台踩坑
|
||||||
|
# shell 脚本必须 LF:Windows 上若被 checkout 成 CRLF,容器/Linux 里执行会报
|
||||||
|
# "$'\r': command not found",或被 sed/grep 当成带 \r 的内容
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# Windows 侧脚本保持 CRLF
|
||||||
|
*.ps1 text eol=crlf
|
||||||
|
*.bat text eol=crlf
|
||||||
|
|
||||||
|
# 二进制/生成物不参与换行转换
|
||||||
|
*.exe binary
|
||||||
|
*.dll binary
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.ico binary
|
||||||
117
B侧设计文档.md
Normal file
117
B侧设计文档.md
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# B 侧设计文档(主控端 + 协议 + 网络)
|
||||||
|
|
||||||
|
> 负责人:成员 B | 主责 crate:`viewer/`、`common/` | 配合:A(agent/)
|
||||||
|
> 按里程碑推进,每个里程碑独立 feature 分支,合入即打 tag。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B1 · 联调基线(1-2 天,和 A 合作完成)
|
||||||
|
|
||||||
|
目标:拿到 H.264 版双机实测数据,作为一切优化的对照基线。
|
||||||
|
|
||||||
|
1. **HUD 扩展**:新增 丢包率(估算)/抖动(jitter)/帧间隔 p95 三项读数
|
||||||
|
- 抖动 = 相邻帧 E2E 差值的绝对值 EMA;帧间隔 p95 用环形缓冲取 95 分位;
|
||||||
|
- 码流异常计数(解码失败次数)也上 HUD,验证关键帧自愈是否生效。
|
||||||
|
2. **测试脚本** `tools/bench.md`:固定三个场景各测 60s —— 静止桌面 / 播放视频 / 拖动窗口,记录 HUD 截图。
|
||||||
|
3. **产出**:基线数据表填入本文件末尾。
|
||||||
|
|
||||||
|
## B2 · 码率自适应(3-4 天)
|
||||||
|
|
||||||
|
目标:弱网自动降码率保流畅,好网缓慢升码率提画质。
|
||||||
|
|
||||||
|
### 协议扩展(common/)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// B2 新增控制消息(流 2)
|
||||||
|
RateReport {
|
||||||
|
loss_permille: u16, // 接收端估算丢包率 ‰
|
||||||
|
rtt_ms: u32, // 最近 RTT
|
||||||
|
e2e_ms: u32, // 平滑后端到端延迟
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
viewer 每 2s 汇总一次发送(流 2 复用,无新增握手)。
|
||||||
|
|
||||||
|
### 控制算法(先简后精,AIMD 风格)
|
||||||
|
|
||||||
|
| 网络状态(连续 3 个周期) | 动作 |
|
||||||
|
|---|---|
|
||||||
|
| 丢包 > 8% 或 E2E > 400ms | 码率 ×0.6(下限 1Mbps),同时通知编码器降帧率到 20fps |
|
||||||
|
| 丢包 3-8% | 码率 ×0.9 |
|
||||||
|
| 丢包 < 1% 且 E2E < 150ms 持续 10s | 码率 +500Kbps 步进上探(上限 15Mbps) |
|
||||||
|
|
||||||
|
### agent 侧配合点(给 A 的接口需求)
|
||||||
|
|
||||||
|
- 需要运行时改码率的入口:优先查 openh264 crate 是否暴露 `SetOption(RC_PARAM)`;
|
||||||
|
- 若 crate 不支持 → 方案 B:保留 encoder 实例但按新码率重建(重建成本 ~50ms,降码率不频繁,可接受);
|
||||||
|
- 降级到极限(1Mbps 仍丢包 >15%)→ 主动降分辨率 1080p→720p(A 实现,协议传 `ResChange` 通知,viewer 重配解码器与窗口尺寸)。
|
||||||
|
|
||||||
|
## B3 · P2P 打洞 + 信令 + 中继(1.5-2 周,核心里程碑)
|
||||||
|
|
||||||
|
目标:跨公网可用。三层结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────┐ ①注册/交换地址(WebSocket) ┌──────────┐
|
||||||
|
│ viewer │ ───────────────────────────→ │ 信令服务器 │
|
||||||
|
└────┬────┘ └──────────┘
|
||||||
|
│ ②拿到对方候选地址(IP:port×N) ↑
|
||||||
|
│ ③a 直连(打洞) ──────────────────── agent 注册
|
||||||
|
│ ③b 失败 → 走 TURN 中继
|
||||||
|
```
|
||||||
|
|
||||||
|
### 信令服务器(B 主建,A 参与评审)
|
||||||
|
|
||||||
|
- 最小实现:单二进制 Rust + WebSocket(tokio-tungstenite),无数据库;
|
||||||
|
- 消息:`Register{agent_id}` / `Connect{agent_id}` / `Answer{candidates, auth_code_hint}`;
|
||||||
|
- agent 开机注册自身 ID(设备码),viewer 输对方 ID 发起;
|
||||||
|
- 中立服务器,不碰媒体数据,带宽成本 ≈ 0。
|
||||||
|
|
||||||
|
### 候选地址与打洞
|
||||||
|
|
||||||
|
1. agent 侧收集候选:本机局域网地址、路由器公网映射(STUN 探测,自建 coturn 的 stun 或公共 stun);
|
||||||
|
2. 双方经信令交换候选后**同时**向对方发探测包(UDP 打洞,锥形 NAT 成功率高;对称 NAT 基本打不通);
|
||||||
|
3. 打通 → 直连(QUIC 直接到该地址);打不通 → TURN 中继兜底。
|
||||||
|
|
||||||
|
### 中继方案(务实路线)
|
||||||
|
|
||||||
|
QUIC 本身没有标准 TURN 集成,两条路:
|
||||||
|
- **路线一(推荐先做)**:自建中继服务 `relay` —— viewer 把 QUIC 包封装转发给 relay,relay 转给 agent(本质是应用层 TURN,复用信令服务器的部署);
|
||||||
|
- **路线二(二期)**:coturn + 手写 ICE-lite,仅在路线一性能不足时投入。
|
||||||
|
|
||||||
|
### 安全(随 B3 一起做,替代现在的跳过校验)
|
||||||
|
|
||||||
|
- 预共享验证码升级为 **PAKE(SPAKE2)**:验证码不落服务器、不落网络,双方推导会话密钥;
|
||||||
|
- 信令只传地址候选,媒体端到端加密保持 QUIC TLS 不变。
|
||||||
|
|
||||||
|
## B4 · 多屏 + 文件传输(各 2-3 天,穿插做)
|
||||||
|
|
||||||
|
**多屏**:
|
||||||
|
- 协议:`ScreenList{screens: Vec<{id, w, h}>}`(AuthOk 里带)、`SelectScreen{id}`;
|
||||||
|
- agent 枚举 DXGI outputs(A 实现),viewer 加屏幕切换下拉;
|
||||||
|
- 第二期再做"多屏同显"。
|
||||||
|
|
||||||
|
**文件传输**:
|
||||||
|
- 首选走已有 QUIC 连接开独立双向流(不引入 aria2 依赖,部署简单);
|
||||||
|
- 协议:`FileOffer{name, size}` → `FileAccept` → 原始分块流(1MB 块 + 校验);
|
||||||
|
- viewer 做拖拽发送 + 保存对话框。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 里程碑与分支规划
|
||||||
|
|
||||||
|
| 里程碑 | 分支 | 预估 | 验收标准 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| B1 联调基线 | feature/hud-baseline | 1-2 天 | 三场景基线数据入库 |
|
||||||
|
| B2 码率自适应 | feature/rate-adapt | 3-4 天 | 模拟弱网(clumsy/丢包工具)下自动降码率,画面不断流 |
|
||||||
|
| B3 打洞+中继 | feature/p2p-relay | 1.5-2 周 | 两台跨公网机器通过 ID 互联;打洞失败自动走中继 |
|
||||||
|
| B4 多屏/文件 | feature/screens-files | 3-5 天 | 多屏切换、拖拽传文件可用 |
|
||||||
|
|
||||||
|
顺序建议:B1 → B2 → B3 → B4(B3 是价值最大项,B4 可穿插)。
|
||||||
|
|
||||||
|
## 基线数据表(B1 完成后填写)
|
||||||
|
|
||||||
|
| 场景 | FPS | RTT | E2E | 码率 | 丢包 | 抖动 |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 静止桌面 | | | | | | |
|
||||||
|
| 视频播放 | | | | | | |
|
||||||
|
| 拖动窗口 | | | | | | |
|
||||||
162
Cargo.lock
generated
162
Cargo.lock
generated
@ -9,6 +9,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"common",
|
"common",
|
||||||
"lz4_flex",
|
"lz4_flex",
|
||||||
|
"nvenc",
|
||||||
"openh264",
|
"openh264",
|
||||||
"quinn",
|
"quinn",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
@ -17,6 +18,19 @@ dependencies = [
|
|||||||
"windows",
|
"windows",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "agent-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"common",
|
||||||
|
"openh264",
|
||||||
|
"quinn",
|
||||||
|
"rcgen",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.104"
|
version = "1.0.104"
|
||||||
@ -44,6 +58,27 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitfields"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef6e59298da389bc0649c7463856b34c6e17fe542f88939426ede4436c6b1195"
|
||||||
|
dependencies = [
|
||||||
|
"bitfields-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitfields-impl"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2c044f98f86f15414668d6c8187c7e4fadab1ad2b31680f648703e0fe07c555"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@ -303,6 +338,16 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gethostname"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||||
|
dependencies = [
|
||||||
|
"rustix",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@ -452,6 +497,19 @@ dependencies = [
|
|||||||
"redox_syscall 0.9.5",
|
"redox_syscall 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-agent"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"agent-core",
|
||||||
|
"anyhow",
|
||||||
|
"common",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"x11rb",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@ -567,6 +625,17 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nvenc"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3227c3b09b1e042dab7d8d82cea77c0404e7d0c2bbb92e7e87cb319749cd20dc"
|
||||||
|
dependencies = [
|
||||||
|
"bitfields",
|
||||||
|
"libloading",
|
||||||
|
"windows",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@ -1512,32 +1581,54 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.58.0"
|
version = "0.62.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||||
|
dependencies = [
|
||||||
|
"windows-collections",
|
||||||
|
"windows-core",
|
||||||
|
"windows-future",
|
||||||
|
"windows-numerics",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-collections"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core",
|
"windows-core",
|
||||||
"windows-targets",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.58.0"
|
version = "0.62.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
|
"windows-link",
|
||||||
"windows-result",
|
"windows-result",
|
||||||
"windows-strings",
|
"windows-strings",
|
||||||
"windows-targets",
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-future"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
"windows-threading",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-implement"
|
name = "windows-implement"
|
||||||
version = "0.58.0"
|
version = "0.60.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@ -1546,9 +1637,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-interface"
|
name = "windows-interface"
|
||||||
version = "0.58.0"
|
version = "0.59.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@ -1562,22 +1653,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-numerics"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-targets",
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-strings"
|
name = "windows-strings"
|
||||||
version = "0.1.0"
|
version = "0.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-result",
|
"windows-link",
|
||||||
"windows-targets",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1614,6 +1714,15 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc",
|
"windows_x86_64_msvc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-threading"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@ -1673,6 +1782,23 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "x11rb"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||||
|
dependencies = [
|
||||||
|
"gethostname",
|
||||||
|
"rustix",
|
||||||
|
"x11rb-protocol",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "x11rb-protocol"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xcursor"
|
name = "xcursor"
|
||||||
version = "0.3.11"
|
version = "0.3.11"
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["common", "agent", "viewer"]
|
# agent-core:平台无关会话层 + 软件编码视频管线(当前仅 linux-agent 使用)
|
||||||
|
# linux-agent:Linux 被控端(X11 + XTest)
|
||||||
|
members = ["common", "agent-core", "agent", "linux-agent", "viewer"]
|
||||||
|
# 默认成员保持 Windows 三件套,`cargo build --release` 行为与改动前完全一致;
|
||||||
|
# Linux 被控端显式构建:`cargo build --release -p linux-agent`
|
||||||
|
default-members = ["common", "agent", "viewer"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
@ -11,5 +16,5 @@ quinn = "0.11"
|
|||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "logging"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "logging"] }
|
||||||
rcgen = { version = "0.13", default-features = false, features = ["ring", "pem"] }
|
rcgen = { version = "0.13", default-features = false, features = ["ring", "pem"] }
|
||||||
lz4_flex = "0.11"
|
lz4_flex = "0.11"
|
||||||
windows = "0.58"
|
windows = "0.62"
|
||||||
minifb = "0.27"
|
minifb = "0.27"
|
||||||
|
|||||||
13
agent-core/Cargo.toml
Normal file
13
agent-core/Cargo.toml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "agent-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
anyhow.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
quinn.workspace = true
|
||||||
|
rustls.workspace = true
|
||||||
|
rcgen.workspace = true
|
||||||
|
openh264 = { version = "0.9.9", features = ["source"] }
|
||||||
407
agent-core/src/lib.rs
Normal file
407
agent-core/src/lib.rs
Normal file
@ -0,0 +1,407 @@
|
|||||||
|
//! 平台无关的被控端会话层:QUIC/TLS 握手、验证码认证、H.264 编码下行、输入分发。
|
||||||
|
//!
|
||||||
|
//! 从 `agent/`(Windows)抽出来,供 Windows 与 Linux 被控端共用,避免两端各写一份
|
||||||
|
//! QUIC 握手/认证/丢帧逻辑造成协议漂移。平台差异只通过两个 trait 注入:
|
||||||
|
//!
|
||||||
|
//! - [`ScreenCapturer`]:屏幕采集(Windows = DXGI Desktop Duplication,Linux = X11)
|
||||||
|
//! - [`InputInjector`]:输入注入(Windows = SendInput,Linux = XTest)
|
||||||
|
//!
|
||||||
|
//! 线格式完全复用 `common`,**不改协议**:键盘沿用 Set-1 扫描码 + extended 标志,
|
||||||
|
//! 鼠标沿用虚拟桌面归一化坐标 0..=65535。
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use common::{
|
||||||
|
encode_video_frame, read_control, write_control, ControlMsg, InputEvent, MouseButton,
|
||||||
|
VideoFrameHeader,
|
||||||
|
};
|
||||||
|
use openh264::encoder::{
|
||||||
|
BitRate, Encoder, EncoderConfig, FrameRate, IntraFramePeriod, RateControlMode, UsageType,
|
||||||
|
};
|
||||||
|
use openh264::formats::{BgraSliceU8, YUVBuffer};
|
||||||
|
use openh264::OpenH264API;
|
||||||
|
use rustls::pki_types::PrivatePkcs8KeyDer;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::{mpsc, watch};
|
||||||
|
|
||||||
|
/// 屏幕采集后端。
|
||||||
|
///
|
||||||
|
/// 语义刻意对齐 DXGI Desktop Duplication,使 Linux 侧只需实现同一套模型:
|
||||||
|
/// `acquire()` 阻塞至多 `timeout_ms`,返回是否拿到**新**帧;`copy_frame()` 再取出像素。
|
||||||
|
pub trait ScreenCapturer: Send {
|
||||||
|
/// 桌面尺寸(物理像素)
|
||||||
|
fn size(&self) -> (u32, u32);
|
||||||
|
|
||||||
|
/// 阻塞至多 `timeout_ms`。
|
||||||
|
/// `Ok(true)` = 有新帧(已拷进内部缓冲,可由 `copy_frame` 取出);
|
||||||
|
/// `Ok(false)` = 超时且画面未变(静止桌面)。
|
||||||
|
fn acquire(&mut self, timeout_ms: u32) -> Result<bool>;
|
||||||
|
|
||||||
|
/// 取出最近一帧,BGRA 紧凑排列(已去掉 row pitch),供 openh264 直接编码。
|
||||||
|
fn copy_frame(&mut self) -> Result<Vec<u8>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 输入注入后端。实现需可在异步运行时中被多任务共享,故要求 `Send + Sync`。
|
||||||
|
pub trait InputInjector: Send + Sync + 'static {
|
||||||
|
fn mouse_move(&self, x: u16, y: u16);
|
||||||
|
fn mouse_button(&self, b: MouseButton, down: bool);
|
||||||
|
fn wheel(&self, dy: i32);
|
||||||
|
fn key(&self, scancode: u16, extended: bool, down: bool);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把一条协议输入事件分发到注入后端。
|
||||||
|
pub fn dispatch_input(inj: &dyn InputInjector, ev: InputEvent) {
|
||||||
|
match ev {
|
||||||
|
InputEvent::MouseMove { x, y } => inj.mouse_move(x, y),
|
||||||
|
InputEvent::MouseDown(b) => inj.mouse_button(b, true),
|
||||||
|
InputEvent::MouseUp(b) => inj.mouse_button(b, false),
|
||||||
|
InputEvent::Wheel { dy } => inj.wheel(dy),
|
||||||
|
InputEvent::KeyDown { scancode, extended } => inj.key(scancode, extended, true),
|
||||||
|
InputEvent::KeyUp { scancode, extended } => inj.key(scancode, extended, false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话参数
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SessionConfig {
|
||||||
|
pub port: u16,
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编码档位。默认值即 Windows 现有档位,改动需两端同步评估。
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct EncoderSettings {
|
||||||
|
pub bitrate_bps: u32,
|
||||||
|
pub max_fps: f32,
|
||||||
|
pub intra_period: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EncoderSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
// 对标 UU:屏幕内容实时档;关键帧周期 300 帧 ≈ 10s
|
||||||
|
Self {
|
||||||
|
bitrate_bps: 5_000_000,
|
||||||
|
max_fps: 30.0,
|
||||||
|
intra_period: 300,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 `--port` / `--code`(两端共用,保持命令行一致)
|
||||||
|
pub fn parse_args() -> Result<(u16, String)> {
|
||||||
|
let mut port = 47800u16;
|
||||||
|
let mut code = "123456".to_string();
|
||||||
|
let a: Vec<String> = std::env::args().collect();
|
||||||
|
let mut i = 1;
|
||||||
|
while i + 1 < a.len() {
|
||||||
|
match a[i].as_str() {
|
||||||
|
"--port" => port = a[i + 1].parse()?,
|
||||||
|
"--code" => code = a[i + 1].clone(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
Ok((port, code))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_encoder(enc: &EncoderSettings) -> Result<Encoder> {
|
||||||
|
Ok(Encoder::with_api_config(
|
||||||
|
OpenH264API::from_source(),
|
||||||
|
EncoderConfig::new()
|
||||||
|
.bitrate(BitRate::from_bps(enc.bitrate_bps))
|
||||||
|
.max_frame_rate(FrameRate::from_hz(enc.max_fps))
|
||||||
|
.rate_control_mode(RateControlMode::Bitrate)
|
||||||
|
.usage_type(UsageType::ScreenContentRealTime)
|
||||||
|
.intra_frame_period(IntraFramePeriod::from_num_frames(enc.intra_period)),
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LoopEnd {
|
||||||
|
/// 下行通道关闭 —— 会话结束,采集线程退出
|
||||||
|
ChannelClosed,
|
||||||
|
/// 采集后端失效(Windows 的 ACCESS_LOST / X11 连接断开)—— 重建后继续
|
||||||
|
Rebuild,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编码一帧并推入下行通道。返回 false 表示通道已关闭(会话结束)。
|
||||||
|
fn encode_send(
|
||||||
|
encoder: &mut Encoder,
|
||||||
|
raw: &[u8],
|
||||||
|
(w, h): (u32, u32),
|
||||||
|
ts_ms: u64,
|
||||||
|
tx: &watch::Sender<Option<Vec<u8>>>,
|
||||||
|
last_frame: &mut Option<Vec<u8>>,
|
||||||
|
) -> bool {
|
||||||
|
let header = VideoFrameHeader {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
ts_ms,
|
||||||
|
};
|
||||||
|
let yuv = YUVBuffer::from_bgra8_source(BgraSliceU8::new(raw, (w as usize, h as usize)));
|
||||||
|
match encoder.encode(&yuv) {
|
||||||
|
Ok(ebs) => {
|
||||||
|
let frame = encode_video_frame(&header, &ebs.to_vec());
|
||||||
|
if tx.send(Some(frame.clone())).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*last_frame = Some(frame);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[capture] encode failed: {e}");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单次采集会话:抓帧 → 编码 → 推入 watch 单槽。
|
||||||
|
fn capture_session(
|
||||||
|
cap: &mut dyn ScreenCapturer,
|
||||||
|
tx: &watch::Sender<Option<Vec<u8>>>,
|
||||||
|
keyreq: &AtomicBool,
|
||||||
|
enc: &EncoderSettings,
|
||||||
|
) -> LoopEnd {
|
||||||
|
let mut encoder = match make_encoder(enc) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[capture] encoder init failed: {e}");
|
||||||
|
return LoopEnd::ChannelClosed;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (w, h) = cap.size();
|
||||||
|
eprintln!("[capture] started {w}x{h}");
|
||||||
|
let start = Instant::now();
|
||||||
|
let min_frame = Duration::from_secs_f32(1.0 / enc.max_fps);
|
||||||
|
let mut last_frame: Option<Vec<u8>> = None;
|
||||||
|
// 保留最近一帧裸像素:静止桌面收到关键帧请求时,仍能重编一个真关键帧
|
||||||
|
let mut last_raw: Option<Vec<u8>> = None;
|
||||||
|
let mut last_sent = Instant::now() - Duration::from_secs(10);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let iter_start = Instant::now();
|
||||||
|
match cap.acquire(80) {
|
||||||
|
Ok(true) => match cap.copy_frame() {
|
||||||
|
Ok(raw) => {
|
||||||
|
// 主控端请求关键帧(解码异常恢复)
|
||||||
|
if keyreq.swap(false, Ordering::Relaxed) {
|
||||||
|
encoder.force_intra_frame();
|
||||||
|
}
|
||||||
|
let ts = start.elapsed().as_millis() as u64;
|
||||||
|
if !encode_send(&mut encoder, &raw, (w, h), ts, tx, &mut last_frame) {
|
||||||
|
return LoopEnd::ChannelClosed; // 会话结束
|
||||||
|
}
|
||||||
|
last_raw = Some(raw);
|
||||||
|
last_sent = Instant::now();
|
||||||
|
// 帧间隔节流到 max_fps
|
||||||
|
let el = iter_start.elapsed();
|
||||||
|
if el < min_frame {
|
||||||
|
std::thread::sleep(min_frame - el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[capture] frame copy failed: {e}");
|
||||||
|
return LoopEnd::Rebuild;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Ok(false) => {
|
||||||
|
let force = keyreq.swap(false, Ordering::Relaxed);
|
||||||
|
if force && last_raw.is_some() {
|
||||||
|
// 静止桌面下的关键帧恢复:画面没变不代表可以不给关键帧——
|
||||||
|
// 解码端一旦出错,若只会收到旧 P 帧就永远恢复不了
|
||||||
|
encoder.force_intra_frame();
|
||||||
|
let raw = last_raw.as_ref().expect("上面已判断 is_some");
|
||||||
|
let ts = start.elapsed().as_millis() as u64;
|
||||||
|
if !encode_send(&mut encoder, raw, (w, h), ts, tx, &mut last_frame) {
|
||||||
|
return LoopEnd::ChannelClosed;
|
||||||
|
}
|
||||||
|
last_sent = Instant::now();
|
||||||
|
} else if last_sent.elapsed() > Duration::from_millis(500) && last_frame.is_some() {
|
||||||
|
// 桌面静止:低频重发最后一帧(重复 P 帧,解码端画面不变)
|
||||||
|
let f = last_frame.as_ref().expect("上面已判断 is_some");
|
||||||
|
if tx.send(Some(f.clone())).is_err() {
|
||||||
|
return LoopEnd::ChannelClosed;
|
||||||
|
}
|
||||||
|
last_sent = Instant::now();
|
||||||
|
} else {
|
||||||
|
std::thread::sleep(Duration::from_millis(8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[capture] acquire failed: {e}");
|
||||||
|
return LoopEnd::Rebuild;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 采集主循环:独立线程运行;后端失效时经 `factory` 重建,通道关闭时退出。
|
||||||
|
/// 通道为 watch 单槽(latest-wins):发送即覆盖,网络慢时不产生积压。
|
||||||
|
pub fn run_capture_loop<F>(
|
||||||
|
tx: watch::Sender<Option<Vec<u8>>>,
|
||||||
|
keyreq: Arc<AtomicBool>,
|
||||||
|
mut cap: Box<dyn ScreenCapturer>,
|
||||||
|
mut factory: F,
|
||||||
|
enc: EncoderSettings,
|
||||||
|
) where
|
||||||
|
F: FnMut() -> Result<Box<dyn ScreenCapturer>>,
|
||||||
|
{
|
||||||
|
loop {
|
||||||
|
match capture_session(cap.as_mut(), &tx, keyreq.as_ref(), &enc) {
|
||||||
|
LoopEnd::ChannelClosed => return,
|
||||||
|
LoopEnd::Rebuild => {}
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(300));
|
||||||
|
loop {
|
||||||
|
match factory() {
|
||||||
|
Ok(c) => {
|
||||||
|
cap = c;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[capture] init failed: {e}, retry in 2s");
|
||||||
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 跑一次完整的被控端会话:监听 → 认证 → 视频下行 + 输入上行。
|
||||||
|
///
|
||||||
|
/// `capture_factory` 可被多次调用(采集后端失效时重建),故需为 `FnMut`。
|
||||||
|
pub async fn serve<F>(
|
||||||
|
cfg: SessionConfig,
|
||||||
|
enc: EncoderSettings,
|
||||||
|
mut capture_factory: F,
|
||||||
|
injector: Arc<dyn InputInjector>,
|
||||||
|
) -> Result<()>
|
||||||
|
where
|
||||||
|
F: FnMut() -> Result<Box<dyn ScreenCapturer>> + Send + 'static,
|
||||||
|
{
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.install_default()
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
// 自签名证书(MVP:客户端跳过校验;正式版改为固定公钥/账号体系)
|
||||||
|
let ck = rcgen::generate_simple_self_signed(vec!["localhost".into()])?;
|
||||||
|
let cert = ck.cert.der().clone();
|
||||||
|
let key = PrivatePkcs8KeyDer::from(ck.key_pair.serialize_der());
|
||||||
|
|
||||||
|
// 传输参数调优:
|
||||||
|
// - 收发窗口放大,避免高 RTT 下吞吐被 窗口/RTT 死锁(默认 512KB 流窗口)
|
||||||
|
// - 发送缓冲上限 512KB:QUIC 内部排队 ≈ 3 帧 @5Mbps,E2E 延迟天花板 ~150ms;
|
||||||
|
// 配合上游 latest-wins 丢帧,超量部分在进 QUIC 前就被丢弃
|
||||||
|
// (与 Windows 端 agent/src/main.rs 保持一致,沿用同一次调优结论)
|
||||||
|
let mut transport = quinn::TransportConfig::default();
|
||||||
|
transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024));
|
||||||
|
transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024));
|
||||||
|
transport.send_window(512 * 1024);
|
||||||
|
let mut server_config = quinn::ServerConfig::with_single_cert(vec![cert], key.into())?;
|
||||||
|
server_config.transport_config(Arc::new(transport));
|
||||||
|
|
||||||
|
let endpoint = quinn::Endpoint::server(server_config, format!("0.0.0.0:{}", cfg.port).parse()?)?;
|
||||||
|
println!(
|
||||||
|
"[agent] listening 0.0.0.0:{}, verify code = {}",
|
||||||
|
cfg.port, cfg.code
|
||||||
|
);
|
||||||
|
|
||||||
|
let conn = endpoint
|
||||||
|
.accept()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("endpoint closed"))?
|
||||||
|
.await?;
|
||||||
|
println!("[agent] peer connected: {}", conn.remote_address());
|
||||||
|
|
||||||
|
// 流 1:认证 + 视频下行
|
||||||
|
let (mut send, mut recv) = conn.accept_bi().await?;
|
||||||
|
|
||||||
|
let first = read_control(&mut recv).await?;
|
||||||
|
let code_ok = matches!(&first, ControlMsg::Auth { code: c } if *c == cfg.code);
|
||||||
|
if !code_ok {
|
||||||
|
write_control(&mut send, &ControlMsg::AuthFail("bad code".into())).await?;
|
||||||
|
send.finish()?;
|
||||||
|
// 必须等对端读到 AuthFail 并挂断再退出:直接 return 会让进程带走 QUIC
|
||||||
|
// 缓冲区里尚未发出的数据,客户端只会看到 "connection lost" 而不是 AuthFail
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(5), conn.closed()).await;
|
||||||
|
anyhow::bail!("auth failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 采集后端先建一次:既拿桌面尺寸回给主控端,也避免白建一个 duplication 只为读尺寸
|
||||||
|
let first_cap = capture_factory()?;
|
||||||
|
let (w, h) = first_cap.size();
|
||||||
|
write_control(&mut send, &ControlMsg::AuthOk { width: w, height: h }).await?;
|
||||||
|
println!("[agent] auth ok, desktop {w}x{h}");
|
||||||
|
|
||||||
|
// 流 2:键鼠输入 + 控制上行(与视频分离,避免被视频积压阻塞)
|
||||||
|
let (mut isend, mut irecv) = conn.accept_bi().await?;
|
||||||
|
|
||||||
|
// 采集线程 → 视频帧(watch 单槽:新帧覆盖旧帧,latest-wins)
|
||||||
|
let (tx, mut frames_rx) = watch::channel::<Option<Vec<u8>>>(None);
|
||||||
|
let keyreq = Arc::new(AtomicBool::new(false));
|
||||||
|
let keyreq_capture = keyreq.clone();
|
||||||
|
let enc_settings = enc;
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
run_capture_loop(tx, keyreq_capture, first_cap, capture_factory, enc_settings)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 上行控制消息(Pong)经通道交给主循环写流 2,避免多任务争抢
|
||||||
|
let (ctl_tx, mut ctl_rx) = mpsc::unbounded_channel::<ControlMsg>();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
|
||||||
|
// 读循环(流 2):输入事件 + Ping;Pong 附带**视频下行方向**的 QUIC 丢包统计
|
||||||
|
// (conn.stats().path 统计的是本端发送方向,被控端的发送方向正是视频下行)
|
||||||
|
let keyreq_reader = keyreq.clone();
|
||||||
|
let conn_stats = conn.clone();
|
||||||
|
let reader = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match read_control(&mut irecv).await {
|
||||||
|
Ok(ControlMsg::Input(ev)) => dispatch_input(injector.as_ref(), ev),
|
||||||
|
Ok(ControlMsg::KeyFrameReq) => {
|
||||||
|
keyreq_reader.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Ok(ControlMsg::Ping(t)) => {
|
||||||
|
let agent_ts = t0.elapsed().as_millis() as u64;
|
||||||
|
let p = conn_stats.stats().path;
|
||||||
|
let _ = ctl_tx.send(ControlMsg::Pong {
|
||||||
|
t,
|
||||||
|
agent_ts,
|
||||||
|
lost: p.lost_packets,
|
||||||
|
sent: p.sent_packets,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
changed = frames_rx.changed() => {
|
||||||
|
if changed.is_err() {
|
||||||
|
break; // 采集线程退出
|
||||||
|
}
|
||||||
|
if let Some(data) = frames_rx.borrow().clone() {
|
||||||
|
if send.write_all(&data).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctl = ctl_rx.recv() => {
|
||||||
|
match ctl {
|
||||||
|
Some(m) => {
|
||||||
|
if write_control(&mut isend, &m).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.abort();
|
||||||
|
println!("[agent] session closed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ rustls.workspace = true
|
|||||||
rcgen.workspace = true
|
rcgen.workspace = true
|
||||||
lz4_flex.workspace = true
|
lz4_flex.workspace = true
|
||||||
openh264 = { version = "0.9.9", features = ["source"] }
|
openh264 = { version = "0.9.9", features = ["source"] }
|
||||||
|
nvenc = "0.1.0"
|
||||||
windows = { workspace = true, features = [
|
windows = { workspace = true, features = [
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
"Win32_Graphics_Direct3D",
|
"Win32_Graphics_Direct3D",
|
||||||
|
|||||||
59
agent/src/bin/encbench.rs
Normal file
59
agent/src/bin/encbench.rs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
//! 编码基准:1080p 噪声帧(最恶劣压缩工况)对比单线程与 4 线程 openh264。
|
||||||
|
//! 运行:cargo run --release -p agent --bin encbench
|
||||||
|
|
||||||
|
use openh264::encoder::{
|
||||||
|
BitRate, Encoder, EncoderConfig, FrameRate, IntraFramePeriod, RateControlMode, UsageType,
|
||||||
|
};
|
||||||
|
use openh264::formats::{BgraSliceU8, YUVBuffer};
|
||||||
|
use openh264::OpenH264API;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
fn bench(threads: u16, frames: usize) -> f64 {
|
||||||
|
let mut enc = 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))
|
||||||
|
.num_threads(threads),
|
||||||
|
)
|
||||||
|
.expect("encoder");
|
||||||
|
|
||||||
|
// 预生成 10 张不同噪声帧轮换(保证每帧 P 帧都真实编码,避免运动估计空转)
|
||||||
|
let mut frames_in = Vec::with_capacity(10);
|
||||||
|
for f in 0..10u32 {
|
||||||
|
let mut frame = vec![0u8; 1920 * 1080 * 4];
|
||||||
|
let mut seed = 12345u32.wrapping_add(f * 7919);
|
||||||
|
for px in frame.chunks_exact_mut(4) {
|
||||||
|
seed = seed.wrapping_mul(1103515245).wrapping_add(12345);
|
||||||
|
px.copy_from_slice(&seed.to_le_bytes());
|
||||||
|
}
|
||||||
|
frames_in.push(YUVBuffer::from_bgra8_source(BgraSliceU8::new(&frame, (1920, 1080))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预热 5 帧(首帧 IDR、线程池初始化)
|
||||||
|
for (i, y) in frames_in.iter().enumerate().take(5) {
|
||||||
|
let _ = enc.encode(y).unwrap_or_else(|_| panic!("frame {i}"));
|
||||||
|
}
|
||||||
|
let t = Instant::now();
|
||||||
|
for (i, y) in frames_in.iter().cycle().take(frames).enumerate() {
|
||||||
|
let _ = enc.encode(y).unwrap_or_else(|_| panic!("frame {i}"));
|
||||||
|
}
|
||||||
|
t.elapsed().as_millis() as f64 / frames as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("1080p 噪声帧编码基准(含 BGRA→YUV 转换)");
|
||||||
|
let t = Instant::now();
|
||||||
|
let _ = YUVBuffer::from_bgra8_source(BgraSliceU8::new(
|
||||||
|
&vec![7u8; 1920 * 1080 * 4],
|
||||||
|
(1920, 1080),
|
||||||
|
));
|
||||||
|
println!("BGRA→YUV 转换: {}ms/帧", t.elapsed().as_millis());
|
||||||
|
let s = bench(1, 20);
|
||||||
|
println!("单线程: {s:.1}ms/帧 → {}fps", (1000.0 / s) as u32);
|
||||||
|
let m = bench(4, 20);
|
||||||
|
println!("4 线程: {m:.1}ms/帧 → {}fps", (1000.0 / m) as u32);
|
||||||
|
}
|
||||||
@ -1,9 +1,17 @@
|
|||||||
//! DXGI Desktop Duplication 采集(Windows 8+)。
|
//! DXGI Desktop Duplication 采集(Windows 8+)。
|
||||||
//! 帧处理:BGRA → openh264 H.264 编码(屏幕内容实时档,5Mbps)→ 线格式。
|
//! 编码器自动选择:NVENC 硬编(N 卡:GPU 内 BGRA→H.264 零转换零拷贝直通)
|
||||||
|
//! → openh264 软编兜底(无 N 卡/驱动异常时自动启用)。
|
||||||
//! 支持主控端 KeyFrameReq 强制关键帧(解码异常恢复)。
|
//! 支持主控端 KeyFrameReq 强制关键帧(解码异常恢复)。
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use common::{encode_video_frame, VideoFrameHeader};
|
use common::{encode_video_frame, VideoFrameHeader};
|
||||||
|
use nvenc::bitstream::BitStream;
|
||||||
|
use nvenc::encoder::RegisteredResource;
|
||||||
|
use nvenc::session::{InitParams, Session};
|
||||||
|
use nvenc::sys::enums::{
|
||||||
|
NVencBufferFormat, NVencPicStruct, NVencPicType, NVencParamsRcMode, NVencTuningInfo,
|
||||||
|
};
|
||||||
|
use nvenc::sys::guids::{NV_ENC_CODEC_H264_GUID, NV_ENC_PRESET_P3_GUID};
|
||||||
use openh264::encoder::{
|
use openh264::encoder::{
|
||||||
BitRate, Encoder, EncoderConfig, FrameRate, IntraFramePeriod, RateControlMode, UsageType,
|
BitRate, Encoder, EncoderConfig, FrameRate, IntraFramePeriod, RateControlMode, UsageType,
|
||||||
};
|
};
|
||||||
@ -19,8 +27,9 @@ use windows::Win32::Graphics::Direct3D::{
|
|||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Direct3D11::{
|
use windows::Win32::Graphics::Direct3D11::{
|
||||||
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Resource, ID3D11Texture2D,
|
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Resource, ID3D11Texture2D,
|
||||||
D3D11_CREATE_DEVICE_FLAG, D3D11_CPU_ACCESS_READ, D3D11_MAP, D3D11_MAP_READ,
|
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_CREATE_DEVICE_FLAG,
|
||||||
D3D11_MAPPED_SUBRESOURCE, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_STAGING,
|
D3D11_CPU_ACCESS_READ, D3D11_MAP_READ, D3D11_MAPPED_SUBRESOURCE,
|
||||||
|
D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, D3D11_USAGE_STAGING,
|
||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC};
|
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC};
|
||||||
use windows::Win32::Graphics::Dxgi::{
|
use windows::Win32::Graphics::Dxgi::{
|
||||||
@ -31,9 +40,12 @@ use windows::Win32::Graphics::Dxgi::{
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
pub struct Duplication {
|
pub struct Duplication {
|
||||||
_device: ID3D11Device,
|
pub device: ID3D11Device,
|
||||||
context: ID3D11DeviceContext,
|
context: ID3D11DeviceContext,
|
||||||
staging_res: ID3D11Resource,
|
staging_res: ID3D11Resource,
|
||||||
|
/// GPU 内中间纹理(NVENC 注册目标;DEFAULT usage,BGRA 与采集一致)
|
||||||
|
gpu_res: ID3D11Resource,
|
||||||
|
gpu_tex: ID3D11Texture2D,
|
||||||
dup: IDXGIOutputDuplication,
|
dup: IDXGIOutputDuplication,
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
@ -47,13 +59,13 @@ impl Duplication {
|
|||||||
D3D11CreateDevice(
|
D3D11CreateDevice(
|
||||||
None,
|
None,
|
||||||
D3D_DRIVER_TYPE_HARDWARE,
|
D3D_DRIVER_TYPE_HARDWARE,
|
||||||
HMODULE::default(),
|
HMODULE(std::ptr::null_mut()),
|
||||||
D3D11_CREATE_DEVICE_FLAG(0),
|
D3D11_CREATE_DEVICE_FLAG(0),
|
||||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||||
D3D11_SDK_VERSION,
|
D3D11_SDK_VERSION,
|
||||||
Some(&mut device),
|
Some(&raw mut device),
|
||||||
None,
|
None,
|
||||||
Some(&mut context),
|
Some(&raw mut context),
|
||||||
)
|
)
|
||||||
.map_err(|e| anyhow!("D3D11CreateDevice failed: {e}"))?;
|
.map_err(|e| anyhow!("D3D11CreateDevice failed: {e}"))?;
|
||||||
let device = device.ok_or_else(|| anyhow!("no d3d device"))?;
|
let device = device.ok_or_else(|| anyhow!("no d3d device"))?;
|
||||||
@ -73,6 +85,7 @@ impl Duplication {
|
|||||||
let (width, height) = (desc.ModeDesc.Width, desc.ModeDesc.Height);
|
let (width, height) = (desc.ModeDesc.Width, desc.ModeDesc.Height);
|
||||||
anyhow::ensure!(width > 0 && height > 0, "bad desktop size");
|
anyhow::ensure!(width > 0 && height > 0, "bad desktop size");
|
||||||
|
|
||||||
|
// staging:软编路径 Map 读内存
|
||||||
let sdesc = D3D11_TEXTURE2D_DESC {
|
let sdesc = D3D11_TEXTURE2D_DESC {
|
||||||
Width: width,
|
Width: width,
|
||||||
Height: height,
|
Height: height,
|
||||||
@ -95,10 +108,26 @@ impl Duplication {
|
|||||||
let staging = staging_opt.ok_or_else(|| anyhow!("no staging texture"))?;
|
let staging = staging_opt.ok_or_else(|| anyhow!("no staging texture"))?;
|
||||||
let staging_res: ID3D11Resource = staging.cast()?;
|
let staging_res: ID3D11Resource = staging.cast()?;
|
||||||
|
|
||||||
|
// GPU 中间纹理:NVENC 路径的注册目标(DEFAULT usage,GPU 可读)
|
||||||
|
let gdesc = D3D11_TEXTURE2D_DESC {
|
||||||
|
Usage: D3D11_USAGE_DEFAULT,
|
||||||
|
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
||||||
|
CPUAccessFlags: 0,
|
||||||
|
..sdesc
|
||||||
|
};
|
||||||
|
let mut gpu_opt: Option<ID3D11Texture2D> = None;
|
||||||
|
device
|
||||||
|
.CreateTexture2D(&gdesc, None, Some(&mut gpu_opt))
|
||||||
|
.map_err(|e| anyhow!("CreateTexture2D(gpu) failed: {e}"))?;
|
||||||
|
let gpu_tex = gpu_opt.ok_or_else(|| anyhow!("no gpu texture"))?;
|
||||||
|
let gpu_res: ID3D11Resource = gpu_tex.cast()?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
_device: device,
|
device,
|
||||||
context,
|
context,
|
||||||
staging_res,
|
staging_res,
|
||||||
|
gpu_res,
|
||||||
|
gpu_tex,
|
||||||
dup,
|
dup,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@ -106,7 +135,8 @@ impl Duplication {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// true=拿到新帧并已复制到 staging;false=超时无更新
|
/// true=拿到新帧并已复制;false=超时无更新。
|
||||||
|
/// 同时复制到 staging(软编读内存)与 GPU 中间纹理(NVENC 注册输入)。
|
||||||
pub fn acquire(&mut self, timeout_ms: u32) -> Result<bool> {
|
pub fn acquire(&mut self, timeout_ms: u32) -> Result<bool> {
|
||||||
unsafe {
|
unsafe {
|
||||||
let mut info = DXGI_OUTDUPL_FRAME_INFO::default();
|
let mut info = DXGI_OUTDUPL_FRAME_INFO::default();
|
||||||
@ -116,6 +146,7 @@ impl Duplication {
|
|||||||
let res = resource.ok_or_else(|| anyhow!("no desktop resource"))?;
|
let res = resource.ok_or_else(|| anyhow!("no desktop resource"))?;
|
||||||
let src: ID3D11Resource = res.cast()?;
|
let src: ID3D11Resource = res.cast()?;
|
||||||
self.context.CopyResource(&self.staging_res, &src);
|
self.context.CopyResource(&self.staging_res, &src);
|
||||||
|
self.context.CopyResource(&self.gpu_res, &src);
|
||||||
let _ = self.dup.ReleaseFrame();
|
let _ = self.dup.ReleaseFrame();
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
@ -125,8 +156,8 @@ impl Duplication {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把 staging 纹理映射到内存并去掉行间距
|
/// 在 staging 映射窗口内执行回调(软编路径)。回调参数 = (像素数据, 行距)。
|
||||||
pub fn copy_frame(&mut self) -> Result<Vec<u8>> {
|
pub fn with_mapped<R>(&mut self, f: impl FnOnce(&[u8], usize) -> R) -> Result<R> {
|
||||||
unsafe {
|
unsafe {
|
||||||
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||||
self.context
|
self.context
|
||||||
@ -135,16 +166,108 @@ impl Duplication {
|
|||||||
let (w, h) = (self.width as usize, self.height as usize);
|
let (w, h) = (self.width as usize, self.height as usize);
|
||||||
let pitch = mapped.RowPitch as usize;
|
let pitch = mapped.RowPitch as usize;
|
||||||
let src = std::slice::from_raw_parts(mapped.pData as *const u8, pitch * h);
|
let src = std::slice::from_raw_parts(mapped.pData as *const u8, pitch * h);
|
||||||
let mut out = Vec::with_capacity(w * h * 4);
|
let r = f(src, pitch);
|
||||||
for row in 0..h {
|
|
||||||
out.extend_from_slice(&src[row * pitch..row * pitch + w * 4]);
|
|
||||||
}
|
|
||||||
self.context.Unmap(&self.staging_res, 0);
|
self.context.Unmap(&self.staging_res, 0);
|
||||||
Ok(out)
|
Ok(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// NVENC 硬编封装:H.264 High、CBR 码率控制、UltraLowLatency 调优、无 B 帧。
|
||||||
|
/// 输入 = 注册的 GPU 纹理(BGRA 与 DXGI 采集字节序一致,零转换)。
|
||||||
|
struct NvencEncoder {
|
||||||
|
encoder: nvenc::encoder::Encoder,
|
||||||
|
registered: RegisteredResource,
|
||||||
|
bitstream: BitStream,
|
||||||
|
frame_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NvencEncoder {
|
||||||
|
fn new(dup: &mut Duplication, bitrate_bps: u32) -> Result<Self> {
|
||||||
|
// 打开会话(无 N 卡 / 驱动不含编码库时在此失败)
|
||||||
|
let session: Session<_> = Session::open_dx(&dup.device)
|
||||||
|
.map_err(|e| anyhow!("open_dx: {e:?}"))?;
|
||||||
|
let codecs = session
|
||||||
|
.get_encode_codecs()
|
||||||
|
.map_err(|e| anyhow!("codecs: {e:?}"))?;
|
||||||
|
anyhow::ensure!(codecs.contains(&NV_ENC_CODEC_H264_GUID), "NVENC no H264 codec");
|
||||||
|
let (session, mut config) = session
|
||||||
|
.get_encode_preset_config_ex(
|
||||||
|
NV_ENC_CODEC_H264_GUID,
|
||||||
|
NV_ENC_PRESET_P3_GUID,
|
||||||
|
NVencTuningInfo::UltraLowLatency,
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow!("preset_config: {e:?}"))?;
|
||||||
|
// CBR 桌面实时档:码率恒定,延迟可预测(对标 UU 3.2Mbps@60fps)
|
||||||
|
config.preset_cfg.rc_params.rate_control_mode = NVencParamsRcMode::CBR;
|
||||||
|
config.preset_cfg.rc_params.average_bit_rate = bitrate_bps;
|
||||||
|
config.preset_cfg.gop_len = 240; // 周期 IDR(60fps 下 4s):网络异常后的自愈恢复点,
|
||||||
|
// 配合 KeyFrameReq 即时恢复,避免长时间冻结
|
||||||
|
config.preset_cfg.frame_interval_p = 1; // 无 B 帧
|
||||||
|
|
||||||
|
let init = InitParams {
|
||||||
|
encode_guid: NV_ENC_CODEC_H264_GUID,
|
||||||
|
preset_guid: NV_ENC_PRESET_P3_GUID,
|
||||||
|
resolution: [dup.width, dup.height],
|
||||||
|
aspect_ratio: [dup.width, dup.height],
|
||||||
|
frame_rate: [60, 1],
|
||||||
|
tuning_info: NVencTuningInfo::UltraLowLatency,
|
||||||
|
buffer_format: NVencBufferFormat::ARGB,
|
||||||
|
encode_config: &mut config.preset_cfg,
|
||||||
|
enable_ptd: true,
|
||||||
|
max_encoder_resolution: [dup.width, dup.height],
|
||||||
|
};
|
||||||
|
let encoder = session
|
||||||
|
.init_encoder(init)
|
||||||
|
.map_err(|e| anyhow!("init_encoder: {e:?}"))?;
|
||||||
|
let registered = encoder
|
||||||
|
.register_resource_dx11(&dup.gpu_tex, NVencBufferFormat::ARGB, 0)
|
||||||
|
.map_err(|e| anyhow!("register_resource: {e:?}"))?;
|
||||||
|
let bitstream = encoder
|
||||||
|
.create_bitstream_buffer()
|
||||||
|
.map_err(|e| anyhow!("bitstream: {e:?}"))?;
|
||||||
|
Ok(Self {
|
||||||
|
encoder,
|
||||||
|
registered,
|
||||||
|
bitstream,
|
||||||
|
frame_count: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编码当前 GPU 纹理内容,返回 Annex B H.264 码流。
|
||||||
|
fn encode(&mut self, force_idr: bool, ts_ms: u64) -> Result<Vec<u8>> {
|
||||||
|
// 首帧强制 IDR:解码端从任意时刻接入,没有 IDR 无法解出后续 P 帧
|
||||||
|
let ty = if force_idr || self.frame_count == 0 {
|
||||||
|
NVencPicType::IDR
|
||||||
|
} else {
|
||||||
|
NVencPicType::P
|
||||||
|
};
|
||||||
|
self.encoder.encode_picture(
|
||||||
|
&self.registered,
|
||||||
|
&self.bitstream,
|
||||||
|
self.frame_count,
|
||||||
|
ts_ms,
|
||||||
|
NVencBufferFormat::ARGB,
|
||||||
|
NVencPicStruct::Frame,
|
||||||
|
ty,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow!("encode_picture: {e:?}"))?;
|
||||||
|
self.frame_count += 1;
|
||||||
|
// 同步读出码流(NVENC 单帧编码 1-3ms,阻塞等待可接受)
|
||||||
|
let lock = self
|
||||||
|
.bitstream
|
||||||
|
.try_lock(true)
|
||||||
|
.map_err(|e| anyhow!("bitstream lock: {e:?}"))?;
|
||||||
|
let out = lock.as_slice().to_vec();
|
||||||
|
drop(lock);
|
||||||
|
if out.is_empty() {
|
||||||
|
anyhow::bail!("nvenc empty bitstream");
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn desktop_size() -> Result<(u32, u32)> {
|
pub fn desktop_size() -> Result<(u32, u32)> {
|
||||||
let d = Duplication::new()?;
|
let d = Duplication::new()?;
|
||||||
@ -153,7 +276,8 @@ pub fn desktop_size() -> Result<(u32, u32)> {
|
|||||||
|
|
||||||
/// 采集主循环:独立线程运行;连接断开(通道关闭)时退出。
|
/// 采集主循环:独立线程运行;连接断开(通道关闭)时退出。
|
||||||
/// 通道为 watch 单槽(latest-wins):发送即覆盖,网络慢时不产生积压。
|
/// 通道为 watch 单槽(latest-wins):发送即覆盖,网络慢时不产生积压。
|
||||||
pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>) {
|
/// congestion:发送侧 write 超时置位 → 跳过当前帧(不编码不发送),注入率自适应降速。
|
||||||
|
pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>, congestion: Arc<AtomicBool>) {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
loop {
|
loop {
|
||||||
let mut dup = match Duplication::new() {
|
let mut dup = match Duplication::new() {
|
||||||
@ -169,78 +293,178 @@ pub fn run(tx: Sender<Option<Vec<u8>>>, keyreq: Arc<AtomicBool>) {
|
|||||||
dup.width, dup.height
|
dup.width, dup.height
|
||||||
);
|
);
|
||||||
|
|
||||||
// H.264 编码器:屏幕内容实时档 + 码率控制(对标 UU:3.2Mbps@60fps)
|
// ---- 编码器自动选择:NVENC 硬编优先,失败落回 openh264 软编 ----
|
||||||
let mut encoder = match Encoder::with_api_config(
|
enum EncoderKind {
|
||||||
|
Nvenc(Box<NvencEncoder>),
|
||||||
|
Soft(Box<openh264::encoder::Encoder>),
|
||||||
|
}
|
||||||
|
let mut encoder: EncoderKind = match NvencEncoder::new(&mut dup, 5_000_000) {
|
||||||
|
Ok(nv) => {
|
||||||
|
eprintln!("[capture] encoder = NVENC hardware (CBR 5Mbps@60fps, ARGB direct)");
|
||||||
|
EncoderKind::Nvenc(Box::new(nv))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[capture] NVENC unavailable ({e}); fallback to openh264 software");
|
||||||
|
match Encoder::with_api_config(
|
||||||
OpenH264API::from_source(),
|
OpenH264API::from_source(),
|
||||||
EncoderConfig::new()
|
EncoderConfig::new()
|
||||||
.bitrate(BitRate::from_bps(5_000_000))
|
.bitrate(BitRate::from_bps(5_000_000))
|
||||||
.max_frame_rate(FrameRate::from_hz(30.0))
|
.max_frame_rate(FrameRate::from_hz(60.0))
|
||||||
.rate_control_mode(RateControlMode::Bitrate)
|
.rate_control_mode(RateControlMode::Bitrate)
|
||||||
.usage_type(UsageType::ScreenContentRealTime)
|
.usage_type(UsageType::ScreenContentRealTime)
|
||||||
.intra_frame_period(IntraFramePeriod::from_num_frames(300)),
|
.intra_frame_period(IntraFramePeriod::from_num_frames(300))
|
||||||
|
.num_threads(4),
|
||||||
) {
|
) {
|
||||||
Ok(e) => e,
|
Ok(e) => {
|
||||||
|
eprintln!("[capture] encoder = openh264 software (5Mbps@60fps, 4T)");
|
||||||
|
EncoderKind::Soft(Box::new(e))
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[capture] encoder init failed: {e}");
|
eprintln!("[capture] encoder init failed: {e}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut last_frame: Option<Vec<u8>> = None;
|
let mut last_frame: Option<Vec<u8>> = None; // 软编静止重发
|
||||||
let mut last_sent = Instant::now() - Duration::from_secs(10);
|
let mut last_sent = Instant::now() - Duration::from_secs(10);
|
||||||
let min_frame = Duration::from_millis(33); // 30fps 上限
|
let min_frame = Duration::from_millis(16); // 60fps 上限
|
||||||
loop {
|
let mut scratch: Vec<u8> = Vec::new(); // 软编:行距不连续时的复用缓冲
|
||||||
let iter_start = Instant::now();
|
// 编码耗时统计(每 120 帧输出一次均值,ms)
|
||||||
match dup.acquire(80) {
|
let mut stat_frames = 0u64;
|
||||||
Ok(true) => match dup.copy_frame() {
|
let mut stat_convert_ms = 0u64;
|
||||||
Ok(raw) => {
|
let mut stat_encode_ms = 0u64;
|
||||||
// 主控端请求关键帧(解码异常恢复)
|
let mut stat_skipped = 0u64; // 拥塞跳帧计数
|
||||||
if keyreq.swap(false, Ordering::Relaxed) {
|
|
||||||
encoder.force_intra_frame();
|
macro_rules! emit {
|
||||||
}
|
($payload:expr) => {{
|
||||||
let header = VideoFrameHeader {
|
let header = VideoFrameHeader {
|
||||||
width: dup.width,
|
width: dup.width,
|
||||||
height: dup.height,
|
height: dup.height,
|
||||||
ts_ms: start.elapsed().as_millis() as u64,
|
ts_ms: start.elapsed().as_millis() as u64,
|
||||||
};
|
};
|
||||||
let yuv = YUVBuffer::from_bgra8_source(BgraSliceU8::new(
|
let frame = encode_video_frame(&header, &$payload);
|
||||||
&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() {
|
if tx.send(Some(frame.clone())).is_err() {
|
||||||
return; // 会话结束
|
return; // 会话结束
|
||||||
}
|
}
|
||||||
last_frame = Some(frame);
|
last_frame = Some(frame);
|
||||||
last_sent = Instant::now();
|
last_sent = Instant::now();
|
||||||
|
stat_frames += 1;
|
||||||
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let iter_start = Instant::now();
|
||||||
|
match dup.acquire(80) {
|
||||||
|
Ok(true) => {
|
||||||
|
// 主控端请求关键帧(解码异常恢复)
|
||||||
|
let force_key = keyreq.swap(false, Ordering::Relaxed);
|
||||||
|
// 拥塞反馈:发送侧排队深 → 跳过本帧(NVENC 少输入一帧,参考链连续;
|
||||||
|
// 解码端收到的帧序 = 编码端实际输出序,无花屏风险)
|
||||||
|
if congestion.swap(false, Ordering::Relaxed) {
|
||||||
|
stat_skipped += 1;
|
||||||
|
let el = iter_start.elapsed();
|
||||||
|
if el < min_frame {
|
||||||
|
std::thread::sleep(min_frame - el);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match &mut encoder {
|
||||||
|
EncoderKind::Nvenc(enc) => {
|
||||||
|
let enc_start = Instant::now();
|
||||||
|
let r = enc.encode(force_key, start.elapsed().as_millis() as u64);
|
||||||
|
stat_encode_ms += enc_start.elapsed().as_millis() as u64;
|
||||||
|
match r {
|
||||||
|
Ok(payload) => emit!(payload),
|
||||||
|
Err(e) => eprintln!("[capture] nvenc encode failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EncoderKind::Soft(enc) => {
|
||||||
|
if force_key {
|
||||||
|
enc.force_intra_frame();
|
||||||
|
}
|
||||||
|
// 映射窗口内直接做 BGRA→YUV(行距连续时零拷贝,否则复用 scratch)
|
||||||
|
let conv_start = Instant::now();
|
||||||
|
let dims = (dup.width as usize, dup.height as usize);
|
||||||
|
let conv = dup.with_mapped(|src, pitch| {
|
||||||
|
if pitch == dims.0 * 4 {
|
||||||
|
YUVBuffer::from_bgra8_source(BgraSliceU8::new(src, dims))
|
||||||
|
} else {
|
||||||
|
scratch.resize(dims.0 * dims.1 * 4, 0);
|
||||||
|
for (row, dst) in
|
||||||
|
scratch.chunks_exact_mut(dims.0 * 4).enumerate()
|
||||||
|
{
|
||||||
|
dst.copy_from_slice(
|
||||||
|
&src[row * pitch..row * pitch + dims.0 * 4],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
YUVBuffer::from_bgra8_source(BgraSliceU8::new(&scratch, dims))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
match conv {
|
||||||
|
Ok(yuv) => {
|
||||||
|
stat_convert_ms +=
|
||||||
|
conv_start.elapsed().as_millis() as u64;
|
||||||
|
let enc_start = Instant::now();
|
||||||
|
let r = enc.encode(&yuv);
|
||||||
|
stat_encode_ms += enc_start.elapsed().as_millis() as u64;
|
||||||
|
match r {
|
||||||
|
Ok(ebs) => emit!(ebs.to_vec()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[capture] encode failed: {e}");
|
eprintln!("[capture] encode failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 帧间隔节流到 ~30fps
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[capture] frame convert failed: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stat_frames > 0 && stat_frames % 120 == 0 {
|
||||||
|
eprintln!(
|
||||||
|
"[capture] avg convert {}ms, encode {}ms/frame, skipped {} (last 120)",
|
||||||
|
stat_convert_ms / 120,
|
||||||
|
stat_encode_ms / 120,
|
||||||
|
stat_skipped
|
||||||
|
);
|
||||||
|
stat_convert_ms = 0;
|
||||||
|
stat_encode_ms = 0;
|
||||||
|
stat_skipped = 0;
|
||||||
|
}
|
||||||
|
// 帧间隔节流到 ~60fps
|
||||||
let el = iter_start.elapsed();
|
let el = iter_start.elapsed();
|
||||||
if el < min_frame {
|
if el < min_frame {
|
||||||
std::thread::sleep(min_frame - el);
|
std::thread::sleep(min_frame - el);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[capture] frame copy failed: {e}");
|
|
||||||
break; // 重建 duplication
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
// 桌面静止:低频重发最后一帧(重复 P 帧,解码端画面不变)
|
// 桌面静止:低频重发(丢包后恢复 + 会话保活)。
|
||||||
|
// 关键:KeyFrameReq 在桌面静止时也必须响应 —— 此前只在新帧路径
|
||||||
|
// 消费该标志,桌面完全静止时解码端请求 IDR 永远得不到响应 → 黑屏。
|
||||||
|
let force_key = keyreq.swap(false, Ordering::Relaxed);
|
||||||
if last_sent.elapsed() > Duration::from_millis(500) {
|
if last_sent.elapsed() > Duration::from_millis(500) {
|
||||||
|
match &mut encoder {
|
||||||
|
EncoderKind::Nvenc(enc) => {
|
||||||
|
// 重编当前 GPU 帧(内容未变,输出极小;force_key 时为 IDR)
|
||||||
|
let r = enc.encode(force_key, start.elapsed().as_millis() as u64);
|
||||||
|
match r {
|
||||||
|
Ok(payload) => emit!(payload),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[capture] nvenc idle encode failed: {e}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EncoderKind::Soft(_) => {
|
||||||
|
// 软编:直接重发最后一帧码流副本
|
||||||
if let Some(f) = last_frame.as_ref() {
|
if let Some(f) = last_frame.as_ref() {
|
||||||
if tx.send(Some(f.clone())).is_err() {
|
let _ = tx.send(Some(f.clone()));
|
||||||
return;
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
last_sent = Instant::now();
|
last_sent = Instant::now();
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
std::thread::sleep(Duration::from_millis(8));
|
std::thread::sleep(Duration::from_millis(8));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,12 +43,13 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
// 传输参数调优:
|
// 传输参数调优:
|
||||||
// - 收发窗口放大,避免高 RTT 下吞吐被 窗口/RTT 死锁(默认 512KB 流窗口)
|
// - 收发窗口放大,避免高 RTT 下吞吐被 窗口/RTT 死锁(默认 512KB 流窗口)
|
||||||
// - 发送缓冲上限 2MB:QUIC 内部排队有界(约 1 帧多),配合上游 latest-wins 丢帧
|
// - 发送缓冲上限 64KB:QUIC 内部排队硬上限(实测 WiFi ~3Mbps 吞吐 → E2E ≤ ~170ms;
|
||||||
// 把端到端排队延迟压在 ~0.4s 内
|
// 突发吞吐 = 64KB/7ms ≈ 72Mbps,不限制带宽)。注意此窗口为连接级,流 2 输入/Ping
|
||||||
|
// 数据量极小不受影响。配合下方拥塞反馈跳帧,把排队压在数帧以内
|
||||||
let mut transport = quinn::TransportConfig::default();
|
let mut transport = quinn::TransportConfig::default();
|
||||||
transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024));
|
transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024));
|
||||||
transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024));
|
transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024));
|
||||||
transport.send_window(2 * 1024 * 1024);
|
transport.send_window(64 * 1024);
|
||||||
let mut server_config = quinn::ServerConfig::with_single_cert(vec![cert], key.into())?;
|
let mut server_config = quinn::ServerConfig::with_single_cert(vec![cert], key.into())?;
|
||||||
server_config.transport_config(Arc::new(transport));
|
server_config.transport_config(Arc::new(transport));
|
||||||
|
|
||||||
@ -87,14 +88,19 @@ async fn main() -> Result<()> {
|
|||||||
let (tx, mut frames_rx) = watch::channel::<Option<Vec<u8>>>(None);
|
let (tx, mut frames_rx) = watch::channel::<Option<Vec<u8>>>(None);
|
||||||
let keyreq = Arc::new(AtomicBool::new(false));
|
let keyreq = Arc::new(AtomicBool::new(false));
|
||||||
let keyreq_capture = keyreq.clone();
|
let keyreq_capture = keyreq.clone();
|
||||||
std::thread::spawn(move || capture::run(tx, keyreq_capture));
|
// 拥塞反馈标志:发送侧 write 超过单帧预算(20ms) → 置位,采集线程跳帧降注入率
|
||||||
|
let congestion = Arc::new(AtomicBool::new(false));
|
||||||
|
let congestion_capture = congestion.clone();
|
||||||
|
std::thread::spawn(move || capture::run(tx, keyreq_capture, congestion_capture));
|
||||||
|
|
||||||
// 上行控制消息(Pong)经通道交给主循环写流 2,避免多任务争抢
|
// 上行控制消息(Pong)经通道交给主循环写流 2,避免多任务争抢
|
||||||
let (ctl_tx, mut ctl_rx) = mpsc::unbounded_channel::<ControlMsg>();
|
let (ctl_tx, mut ctl_rx) = mpsc::unbounded_channel::<ControlMsg>();
|
||||||
let t0 = std::time::Instant::now();
|
let t0 = std::time::Instant::now();
|
||||||
|
|
||||||
// 读循环(流 2):输入事件 + Ping
|
// 读循环(流 2):输入事件 + Ping;Pong 附带视频下行方向的 QUIC 丢包统计
|
||||||
|
// (conn.stats().path 统计的是本端发送方向,agent 的发送方向即视频下行)
|
||||||
let keyreq_reader = keyreq.clone();
|
let keyreq_reader = keyreq.clone();
|
||||||
|
let conn_stats = conn.clone();
|
||||||
let reader = tokio::spawn(async move {
|
let reader = tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match read_control(&mut irecv).await {
|
match read_control(&mut irecv).await {
|
||||||
@ -104,7 +110,13 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
Ok(ControlMsg::Ping(t)) => {
|
Ok(ControlMsg::Ping(t)) => {
|
||||||
let agent_ts = t0.elapsed().as_millis() as u64;
|
let agent_ts = t0.elapsed().as_millis() as u64;
|
||||||
let _ = ctl_tx.send(ControlMsg::Pong { t, agent_ts });
|
let p = conn_stats.stats().path;
|
||||||
|
let _ = ctl_tx.send(ControlMsg::Pong {
|
||||||
|
t,
|
||||||
|
agent_ts,
|
||||||
|
lost: p.lost_packets,
|
||||||
|
sent: p.sent_packets,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
@ -119,7 +131,14 @@ async fn main() -> Result<()> {
|
|||||||
break; // 采集线程退出
|
break; // 采集线程退出
|
||||||
}
|
}
|
||||||
if let Some(data) = frames_rx.borrow().clone() {
|
if let Some(data) = frames_rx.borrow().clone() {
|
||||||
if send.write_all(&data).await.is_err() {
|
let wt = std::time::Instant::now();
|
||||||
|
let ok = send.write_all(&data).await.is_ok();
|
||||||
|
// write 耗时 > 单帧预算(16.7ms 的 1.2 倍) → QUIC 窗口排队深,
|
||||||
|
// 通知采集线程跳帧,把注入率降到网络实际吞吐水平
|
||||||
|
if wt.elapsed() > std::time::Duration::from_millis(20) {
|
||||||
|
congestion.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,8 @@ pub enum ControlMsg {
|
|||||||
Ping(u64),
|
Ping(u64),
|
||||||
/// t=主控端发送时刻(主控时钟),agent_ts=被控端收到时刻(被控时钟)
|
/// t=主控端发送时刻(主控时钟),agent_ts=被控端收到时刻(被控时钟)
|
||||||
/// 两者配合可估计时钟偏移,进而用帧头 ts_ms 计算端到端延迟
|
/// 两者配合可估计时钟偏移,进而用帧头 ts_ms 计算端到端延迟
|
||||||
Pong { t: u64, agent_ts: u64 },
|
/// lost/sent = agent 发送方向(视频下行)的 QUIC 丢包/发包累计计数(conn.stats().path)
|
||||||
|
Pong { t: u64, agent_ts: u64, lost: u64, sent: u64 },
|
||||||
Input(InputEvent),
|
Input(InputEvent),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
linux-agent/Cargo.toml
Normal file
16
linux-agent/Cargo.toml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "linux-agent"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
agent-core = { path = "../agent-core" }
|
||||||
|
common = { path = "../common" }
|
||||||
|
anyhow.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
x11rb = { version = "0.13", features = ["xtest"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# 冒烟测试里要扮演主控端,自己发起 QUIC/TLS 连接
|
||||||
|
quinn.workspace = true
|
||||||
|
rustls.workspace = true
|
||||||
147
linux-agent/src/capture.rs
Normal file
147
linux-agent/src/capture.rs
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
//! X11 屏幕采集。
|
||||||
|
//!
|
||||||
|
//! MVP 走核心协议 `GetImage`:零扩展依赖、零 unsafe,先把「采集 → 编码 → QUIC」整条链路打通。
|
||||||
|
//! 变更检测用整帧字节比较,桌面静止时不上行视频(省掉编码开销)。
|
||||||
|
//!
|
||||||
|
//! 二期优化锚点(已在 `ScreenCapturer` 语义内预留,无需改 agent-core):
|
||||||
|
//! 1. **MIT-SHM**:共享内存替代 `GetImage` 的 socket 传输,省掉 X server 一次全画布拷贝;
|
||||||
|
//! 2. **XDamage**:只取变化矩形 + 只编码脏区,静止时零拷贝。
|
||||||
|
|
||||||
|
use agent_core::ScreenCapturer;
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use x11rb::connection::Connection;
|
||||||
|
use x11rb::protocol::xproto::{ConnectionExt as _, ImageFormat, VisualClass, Window};
|
||||||
|
use x11rb::rust_connection::RustConnection;
|
||||||
|
|
||||||
|
/// 帧节流上限,与 agent-core 默认编码档位(30fps)对齐
|
||||||
|
const FRAME_INTERVAL: Duration = Duration::from_millis(33);
|
||||||
|
|
||||||
|
pub struct X11Capturer {
|
||||||
|
conn: RustConnection,
|
||||||
|
root: Window,
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
/// 最近一帧 BGRA(既是变更检测基线,也是 copy_frame 的数据源)
|
||||||
|
last: Vec<u8>,
|
||||||
|
last_capture: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl X11Capturer {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let (conn, screen_num) = x11rb::connect(None).map_err(|e| {
|
||||||
|
anyhow!(
|
||||||
|
"连接 X11 失败(DISPLAY={:?}): {e}",
|
||||||
|
std::env::var("DISPLAY").ok()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 先把需要的屏幕参数拷出来:随后 conn 要被移进结构体,不能再持有对它的借用
|
||||||
|
let (root, width, height, depth, visual_class, bpp) = {
|
||||||
|
let setup = conn.setup();
|
||||||
|
let screen = &setup.roots[screen_num];
|
||||||
|
|
||||||
|
// bits_per_pixel 不在 visual 上,而在 Setup 的 pixmap_formats 里(按 depth 索引)
|
||||||
|
let fmt = setup
|
||||||
|
.pixmap_formats
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.depth == screen.root_depth)
|
||||||
|
.ok_or_else(|| anyhow!("找不到 depth={} 对应的像素格式", screen.root_depth))?;
|
||||||
|
|
||||||
|
let visual_class = screen
|
||||||
|
.allowed_depths
|
||||||
|
.iter()
|
||||||
|
.flat_map(|d| d.visuals.iter())
|
||||||
|
.find(|v| v.visual_id == screen.root_visual)
|
||||||
|
.map(|v| v.class)
|
||||||
|
.ok_or_else(|| anyhow!("找不到 root visual"))?;
|
||||||
|
|
||||||
|
(
|
||||||
|
screen.root,
|
||||||
|
screen.width_in_pixels,
|
||||||
|
screen.height_in_pixels,
|
||||||
|
screen.root_depth,
|
||||||
|
visual_class,
|
||||||
|
fmt.bits_per_pixel,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
anyhow::ensure!(width > 0 && height > 0, "屏幕尺寸异常 {width}x{height}");
|
||||||
|
anyhow::ensure!(
|
||||||
|
bpp == 32,
|
||||||
|
"当前仅支持 32bpp 真彩采集(depth={depth},实际 {bpp}bpp)"
|
||||||
|
);
|
||||||
|
// BGRA 字节序假设只在 TrueColor 下成立
|
||||||
|
anyhow::ensure!(
|
||||||
|
visual_class == VisualClass::TRUE_COLOR,
|
||||||
|
"当前仅支持 TrueColor 视觉(实际 {visual_class:?})"
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("[capture] X11 screen {width}x{height}, depth={depth}, {bpp}bpp");
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
conn,
|
||||||
|
root,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
last: Vec::new(),
|
||||||
|
last_capture: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 抓一帧整屏到 `last`,返回画面是否发生变化
|
||||||
|
fn grab(&mut self) -> Result<bool> {
|
||||||
|
let reply = self
|
||||||
|
.conn
|
||||||
|
.get_image(
|
||||||
|
ImageFormat::Z_PIXMAP,
|
||||||
|
self.root,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
self.width,
|
||||||
|
self.height,
|
||||||
|
!0,
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow!("get_image 请求失败: {e}"))?
|
||||||
|
.reply()
|
||||||
|
.map_err(|e| anyhow!("get_image 应答失败: {e}"))?;
|
||||||
|
|
||||||
|
let expect = self.width as usize * self.height as usize * 4;
|
||||||
|
anyhow::ensure!(
|
||||||
|
reply.data.len() == expect,
|
||||||
|
"帧数据长度异常:期望 {expect} 字节,实际 {}",
|
||||||
|
reply.data.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let changed = reply.data != self.last;
|
||||||
|
if changed {
|
||||||
|
self.last = reply.data;
|
||||||
|
}
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScreenCapturer for X11Capturer {
|
||||||
|
fn size(&self) -> (u32, u32) {
|
||||||
|
(self.width as u32, self.height as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// X11 核心协议没有「阻塞至新帧」语义,故这里按帧槽节流后主动抓帧:
|
||||||
|
/// 睡到下一个 1/30s 槽位 → 抓帧 → 与上一帧逐字节比较。
|
||||||
|
/// 因此 `timeout_ms` 在 Linux 实现中被忽略(agent-core 的 min_frame 节流也随之自然满足)。
|
||||||
|
fn acquire(&mut self, _timeout_ms: u32) -> Result<bool> {
|
||||||
|
if let Some(t) = self.last_capture {
|
||||||
|
let elapsed = t.elapsed();
|
||||||
|
if elapsed < FRAME_INTERVAL {
|
||||||
|
std::thread::sleep(FRAME_INTERVAL - elapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_capture = Some(Instant::now());
|
||||||
|
self.grab()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_frame(&mut self) -> Result<Vec<u8>> {
|
||||||
|
anyhow::ensure!(!self.last.is_empty(), "尚无帧数据可拷贝");
|
||||||
|
Ok(self.last.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
249
linux-agent/src/input.rs
Normal file
249
linux-agent/src/input.rs
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
//! X11 输入注入:XTest 扩展(Xorg / XWayland / Xvfb 均支持)。
|
||||||
|
//!
|
||||||
|
//! **键码映射是本模块的关键**:协议里键盘沿用 Windows 口味的 PS/2 Set-1 扫描码,
|
||||||
|
//! 而 X11 要的是 keycode,两套编号并非处处等价:
|
||||||
|
//! - 主键区:`keycode = scancode + 8`(evdev 偏移),已逐键核对;
|
||||||
|
//! - E0 扩展键:两条链路编号**完全不同**(如方向键 Set-1 是 0x48,evdev 侧 X11 keycode 是 111),必须查表。
|
||||||
|
//!
|
||||||
|
//! 鼠标沿用虚拟桌面归一化坐标 0..=65535:X11 根窗口天然横跨全部显示器,
|
||||||
|
//! 与 Windows 的 `MOUSEEVENTF_VIRTUALDESK` 语义一致,故映射到根窗口像素即可。
|
||||||
|
|
||||||
|
use agent_core::InputInjector;
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use common::MouseButton;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use x11rb::connection::Connection;
|
||||||
|
use x11rb::protocol::xproto::{ConnectionExt as _, Window};
|
||||||
|
use x11rb::protocol::xtest::ConnectionExt as _;
|
||||||
|
use x11rb::rust_connection::RustConnection;
|
||||||
|
|
||||||
|
// X11 核心事件码(协议固定值)
|
||||||
|
const KEY_PRESS: u8 = 2;
|
||||||
|
const KEY_RELEASE: u8 = 3;
|
||||||
|
const BUTTON_PRESS: u8 = 4;
|
||||||
|
const BUTTON_RELEASE: u8 = 5;
|
||||||
|
const MOTION_NOTIFY: u8 = 6;
|
||||||
|
|
||||||
|
// X11 按钮编号:1=左 2=中 3=右 4=滚轮上 5=滚轮下
|
||||||
|
const BTN_LEFT: u8 = 1;
|
||||||
|
const BTN_MIDDLE: u8 = 2;
|
||||||
|
const BTN_RIGHT: u8 = 3;
|
||||||
|
const BTN_WHEEL_UP: u8 = 4;
|
||||||
|
const BTN_WHEEL_DOWN: u8 = 5;
|
||||||
|
|
||||||
|
/// Windows 滚轮一格 = 120(协议沿用该语义)
|
||||||
|
const WHEEL_DELTA: i32 = 120;
|
||||||
|
|
||||||
|
/// 单次滚轮最多合成的格数,防止异常值刷爆事件队列
|
||||||
|
const WHEEL_MAX_CLICKS: i32 = 20;
|
||||||
|
|
||||||
|
pub struct X11Injector {
|
||||||
|
/// `Mutex` 只为满足 `InputInjector: Sync`(X11 连接本身非 Sync),无竞争
|
||||||
|
conn: Mutex<RustConnection>,
|
||||||
|
root: Window,
|
||||||
|
width: i32,
|
||||||
|
height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl X11Injector {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let (conn, screen_num) = x11rb::connect(None).map_err(|e| {
|
||||||
|
anyhow!(
|
||||||
|
"连接 X11 失败(DISPLAY={:?}): {e}",
|
||||||
|
std::env::var("DISPLAY").ok()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// XTest 缺失就没有注入能力,启动即失败好过连上后才报错
|
||||||
|
let ext = conn
|
||||||
|
.query_extension(b"XTEST")
|
||||||
|
.map_err(|e| anyhow!("查询 XTEST 扩展失败: {e}"))?
|
||||||
|
.reply()
|
||||||
|
.map_err(|e| anyhow!("查询 XTEST 扩展应答失败: {e}"))?;
|
||||||
|
anyhow::ensure!(ext.present, "X server 未提供 XTEST 扩展,无法注入输入");
|
||||||
|
|
||||||
|
// 先拷出参数,避免借用 conn 的同时又把它移进结构体
|
||||||
|
let (root, width, height) = {
|
||||||
|
let screen = &conn.setup().roots[screen_num];
|
||||||
|
(
|
||||||
|
screen.root,
|
||||||
|
screen.width_in_pixels as i32,
|
||||||
|
screen.height_in_pixels as i32,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
println!("[input] XTEST ready, root {width}x{height}");
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
root,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fake(&self, type_: u8, detail: u8, x: i32, y: i32) {
|
||||||
|
let conn = match self.conn.lock() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
if let Err(e) = conn.xtest_fake_input(
|
||||||
|
type_,
|
||||||
|
detail,
|
||||||
|
0,
|
||||||
|
self.root,
|
||||||
|
x as i16,
|
||||||
|
y as i16,
|
||||||
|
0, // deviceid:0 = 核心设备
|
||||||
|
) {
|
||||||
|
eprintln!("[input] xtest_fake_input 失败: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _ = conn.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn click(&self, button: u8) {
|
||||||
|
self.fake(BUTTON_PRESS, button, 0, 0);
|
||||||
|
self.fake(BUTTON_RELEASE, button, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputInjector for X11Injector {
|
||||||
|
fn mouse_move(&self, x: u16, y: u16) {
|
||||||
|
// detail=0 → 绝对坐标
|
||||||
|
let px = scale(x, self.width);
|
||||||
|
let py = scale(y, self.height);
|
||||||
|
self.fake(MOTION_NOTIFY, 0, px, py);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mouse_button(&self, b: MouseButton, down: bool) {
|
||||||
|
let button = match b {
|
||||||
|
MouseButton::Left => BTN_LEFT,
|
||||||
|
MouseButton::Middle => BTN_MIDDLE,
|
||||||
|
MouseButton::Right => BTN_RIGHT,
|
||||||
|
};
|
||||||
|
self.fake(
|
||||||
|
if down { BUTTON_PRESS } else { BUTTON_RELEASE },
|
||||||
|
button,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wheel(&self, dy: i32) {
|
||||||
|
// 协议沿用 Windows 语义:dy > 0 向上滚,一格 = 120
|
||||||
|
if dy == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let clicks = (dy.abs() / WHEEL_DELTA).clamp(1, WHEEL_MAX_CLICKS);
|
||||||
|
let button = if dy > 0 { BTN_WHEEL_UP } else { BTN_WHEEL_DOWN };
|
||||||
|
for _ in 0..clicks {
|
||||||
|
self.click(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(&self, scancode: u16, extended: bool, down: bool) {
|
||||||
|
let Some(keycode) = keycode_of(scancode, extended) else {
|
||||||
|
eprintln!("[input] 未映射的扫描码 scancode=0x{scancode:02X} extended={extended}");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.fake(
|
||||||
|
if down { KEY_PRESS } else { KEY_RELEASE },
|
||||||
|
keycode,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 虚拟桌面归一化坐标(0..=65535)→ 根窗口像素坐标
|
||||||
|
fn scale(v: u16, extent: i32) -> i32 {
|
||||||
|
if extent <= 1 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
((v as i64 * (extent as i64 - 1)) / 65535) as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PS/2 Set-1 扫描码 → X11 keycode。
|
||||||
|
///
|
||||||
|
/// 非扩展键恒为 `scancode + 8`(当前 viewer 的 keymap 只发该区间内的键);
|
||||||
|
/// E0 扩展键查表,编号与主键区规则无关。
|
||||||
|
fn keycode_of(scancode: u16, extended: bool) -> Option<u8> {
|
||||||
|
if extended {
|
||||||
|
// 已按 Xorg evdev 标准 keycode 分配核对
|
||||||
|
let kc = match scancode {
|
||||||
|
0x1C => 104, // 小键盘 Enter
|
||||||
|
0x1D => 105, // 右 Ctrl
|
||||||
|
0x35 => 106, // 小键盘 /
|
||||||
|
0x37 => 107, // PrintScreen
|
||||||
|
0x38 => 108, // 右 Alt
|
||||||
|
0x47 => 110, // Home
|
||||||
|
0x48 => 111, // ↑
|
||||||
|
0x49 => 112, // PageUp
|
||||||
|
0x4B => 113, // ←
|
||||||
|
0x4D => 114, // →
|
||||||
|
0x4F => 115, // End
|
||||||
|
0x50 => 116, // ↓
|
||||||
|
0x51 => 117, // PageDown
|
||||||
|
0x52 => 118, // Insert
|
||||||
|
0x53 => 119, // Delete
|
||||||
|
0x5B => 133, // 左 Super(Win)
|
||||||
|
0x5C => 134, // 右 Super(Win)
|
||||||
|
0x5D => 135, // Menu
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(kc)
|
||||||
|
} else {
|
||||||
|
// 0x01..=0x53 主键区,以及 0x57/0x58 的 F11/F12;其余为 Set-1 未定义位
|
||||||
|
match scancode {
|
||||||
|
0x01..=0x53 | 0x57 | 0x58 => Some((scancode + 8) as u8),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn main_block_uses_evdev_offset() {
|
||||||
|
assert_eq!(keycode_of(0x1E, false), Some(38)); // A
|
||||||
|
assert_eq!(keycode_of(0x02, false), Some(10)); // 1
|
||||||
|
assert_eq!(keycode_of(0x39, false), Some(65)); // Space
|
||||||
|
assert_eq!(keycode_of(0x1C, false), Some(36)); // Enter
|
||||||
|
assert_eq!(keycode_of(0x1D, false), Some(37)); // 左 Ctrl
|
||||||
|
assert_eq!(keycode_of(0x2A, false), Some(50)); // 左 Shift
|
||||||
|
assert_eq!(keycode_of(0x58, false), Some(96)); // F12
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extended_keys_are_not_offset() {
|
||||||
|
// 若误用 +8 规则,方向键会落到小键盘区(0x48+8=80=KP_8),这正是要防的坑
|
||||||
|
assert_eq!(keycode_of(0x48, true), Some(111)); // ↑
|
||||||
|
assert_ne!(keycode_of(0x48, true), Some(0x48 + 8));
|
||||||
|
assert_eq!(keycode_of(0x50, true), Some(116)); // ↓
|
||||||
|
assert_eq!(keycode_of(0x4B, true), Some(113)); // ←
|
||||||
|
assert_eq!(keycode_of(0x4D, true), Some(114)); // →
|
||||||
|
assert_eq!(keycode_of(0x47, true), Some(110)); // Home
|
||||||
|
assert_eq!(keycode_of(0x4F, true), Some(115)); // End
|
||||||
|
assert_eq!(keycode_of(0x49, true), Some(112)); // PageUp
|
||||||
|
assert_eq!(keycode_of(0x51, true), Some(117)); // PageDown
|
||||||
|
assert_eq!(keycode_of(0x53, true), Some(119)); // Delete
|
||||||
|
assert_eq!(keycode_of(0x5B, true), Some(133)); // 左 Super
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undefined_scancodes_are_rejected() {
|
||||||
|
assert_eq!(keycode_of(0x54, false), None);
|
||||||
|
assert_eq!(keycode_of(0x7F, false), None);
|
||||||
|
assert_eq!(keycode_of(0x99, true), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coordinate_scaling_covers_full_range() {
|
||||||
|
assert_eq!(scale(0, 1920), 0);
|
||||||
|
assert_eq!(scale(65535, 1920), 1919);
|
||||||
|
assert_eq!(scale(32768, 1920), 960);
|
||||||
|
assert_eq!(scale(12345, 0), 0); // 退化尺寸不 panic
|
||||||
|
}
|
||||||
|
}
|
||||||
31
linux-agent/src/main.rs
Normal file
31
linux-agent/src/main.rs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
//! Linux 被控端:X11 采集 + XTest 注入;会话层(QUIC/认证/编码/丢帧)复用 `agent-core`。
|
||||||
|
//!
|
||||||
|
//! 与 Windows 端的差异仅在这两个模块里,线格式与主控端完全一致,viewer 不需要任何改动。
|
||||||
|
//! 依赖 `DISPLAY` 环境变量(Xorg / XWayland 均可)。
|
||||||
|
|
||||||
|
mod capture;
|
||||||
|
mod input;
|
||||||
|
|
||||||
|
use agent_core::{parse_args, serve, EncoderSettings, InputInjector, ScreenCapturer, SessionConfig};
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let (port, code) = parse_args()?;
|
||||||
|
|
||||||
|
let display = std::env::var("DISPLAY").unwrap_or_else(|_| "(未设置)".to_string());
|
||||||
|
println!("[linux-agent] DISPLAY={display}");
|
||||||
|
|
||||||
|
// 注入端连接常驻(启动即校验 XTEST 扩展是否可用,缺了就直接报错退出)
|
||||||
|
let injector: Arc<dyn InputInjector> = Arc::new(input::X11Injector::new()?);
|
||||||
|
|
||||||
|
serve(
|
||||||
|
SessionConfig { port, code },
|
||||||
|
EncoderSettings::default(),
|
||||||
|
// 采集端可被重建(X11 连接断开等),故用工厂闭包
|
||||||
|
|| Ok(Box::new(capture::X11Capturer::new()?) as Box<dyn ScreenCapturer>),
|
||||||
|
injector,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
440
linux-agent/tests/smoke.rs
Normal file
440
linux-agent/tests/smoke.rs
Normal file
@ -0,0 +1,440 @@
|
|||||||
|
//! Linux 被控端端到端冒烟测试。
|
||||||
|
//!
|
||||||
|
//! 需要 X server(Xvfb 即可),通过 `DISPLAY` 指定;推荐用 `scripts/linux-smoke.sh` 一键跑。
|
||||||
|
//!
|
||||||
|
//! 覆盖链路:X11 采集 → openh264 编码 → QUIC/TLS 下行 → 主控端解帧;
|
||||||
|
//! 外加验证码认证、Ping/Pong、关键帧请求,以及**输入注入的实证断言**
|
||||||
|
//! (注入鼠标移动后,从另一条 X11 连接查询指针真实落点,而不是只看进程没崩)。
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use common::{read_control, read_video_frame, write_control, ControlMsg, InputEvent};
|
||||||
|
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||||
|
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||||
|
use rustls::{DigitallySignedStruct, SignatureScheme};
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::net::{TcpListener, ToSocketAddrs};
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::{mpsc, Arc};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use x11rb::connection::Connection;
|
||||||
|
use x11rb::protocol::xproto::{ConnectionExt as _, Window};
|
||||||
|
use x11rb::rust_connection::RustConnection;
|
||||||
|
|
||||||
|
/// 与 viewer 一致:MVP 阶段跳过自签名证书校验
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct SkipVerify;
|
||||||
|
|
||||||
|
impl ServerCertVerifier for SkipVerify {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
_end_entity: &CertificateDer<'_>,
|
||||||
|
_intermediates: &[CertificateDer<'_>],
|
||||||
|
_server_name: &ServerName<'_>,
|
||||||
|
_ocsp_response: &[u8],
|
||||||
|
_now: UnixTime,
|
||||||
|
) -> Result<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,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 被测进程的 RAII 句柄:测试无论成功失败都会收尸
|
||||||
|
struct AgentProc {
|
||||||
|
child: Child,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for AgentProc {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.child.kill();
|
||||||
|
let _ = self.child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expected_screen() -> (u32, u32) {
|
||||||
|
let s = std::env::var("SMOKE_SCREEN").unwrap_or_else(|_| "1280x720".to_string());
|
||||||
|
let (w, h) = s
|
||||||
|
.split_once('x')
|
||||||
|
.unwrap_or_else(|| panic!("SMOKE_SCREEN 需形如 1280x720,实际 {s:?}"));
|
||||||
|
(w.parse().expect("宽"), h.parse().expect("高"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn free_port() -> Result<u16> {
|
||||||
|
let l = TcpListener::bind("127.0.0.1:0")?;
|
||||||
|
Ok(l.local_addr()?.port())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_agent(port: u16, code: &str) -> Result<AgentProc> {
|
||||||
|
let mut child = Command::new(env!("CARGO_BIN_EXE_linux-agent"))
|
||||||
|
.args(["--port", &port.to_string(), "--code", code])
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.spawn()
|
||||||
|
.context("启动 linux-agent 失败")?;
|
||||||
|
|
||||||
|
let stdout = child.stdout.take().expect("stdout 已 piped");
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for line in BufReader::new(stdout).lines().map_while(|l| l.ok()) {
|
||||||
|
println!("[agent] {line}");
|
||||||
|
let _ = tx.send(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 等 QUIC 端口就绪
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
loop {
|
||||||
|
let left = deadline.saturating_duration_since(Instant::now());
|
||||||
|
if left.is_zero() {
|
||||||
|
bail!("等待 agent 监听超时(DISPLAY 是否已设置?)");
|
||||||
|
}
|
||||||
|
match rx.recv_timeout(left) {
|
||||||
|
Ok(line) if line.contains("listening") => return Ok(AgentProc { child }),
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(_) => bail!("等待 agent 监听超时,进程可能已退出"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect(port: u16) -> Result<(quinn::Endpoint, quinn::Connection)> {
|
||||||
|
let tls = rustls::ClientConfig::builder()
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(Arc::new(SkipVerify))
|
||||||
|
.with_no_client_auth();
|
||||||
|
let qc: quinn::crypto::rustls::QuicClientConfig = Arc::new(tls).try_into()?;
|
||||||
|
|
||||||
|
let mut transport = quinn::TransportConfig::default();
|
||||||
|
transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024));
|
||||||
|
transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024));
|
||||||
|
|
||||||
|
let mut client_config = quinn::ClientConfig::new(Arc::new(qc));
|
||||||
|
client_config.transport_config(Arc::new(transport));
|
||||||
|
|
||||||
|
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse()?)?;
|
||||||
|
endpoint.set_default_client_config(client_config);
|
||||||
|
|
||||||
|
let addr = ("127.0.0.1", port)
|
||||||
|
.to_socket_addrs()?
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("地址解析失败"))?;
|
||||||
|
let conn = endpoint.connect(addr, "localhost")?.await?;
|
||||||
|
// endpoint 必须与 conn 同生命周期,故一并返回
|
||||||
|
Ok((endpoint, conn))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_start_code(payload: &[u8]) -> bool {
|
||||||
|
let head = &payload[..payload.len().min(64)];
|
||||||
|
head.windows(4).any(|w| w == [0, 0, 0, 1]) || head.windows(3).any(|w| w == [0, 0, 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// H.264 NAL 类型:5 = IDR 片(关键帧)
|
||||||
|
const NAL_IDR: u8 = 5;
|
||||||
|
|
||||||
|
/// 按 Annex-B 起始码扫描,判断载荷里是否含指定类型的 NAL
|
||||||
|
fn contains_nal(payload: &[u8], nal_type: u8) -> bool {
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i + 3 <= payload.len() {
|
||||||
|
let sc_len = if payload[i..].starts_with(&[0, 0, 0, 1]) {
|
||||||
|
4
|
||||||
|
} else if payload[i..].starts_with(&[0, 0, 1]) {
|
||||||
|
3
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if i + sc_len >= payload.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if payload[i + sc_len] & 0x1F == nal_type {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
i += sc_len + 1;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在根窗口左上角画一个**小**矩形,制造一次细微画面变化。
|
||||||
|
///
|
||||||
|
/// 刻意不用「整屏刷白」那类大变化:大变化会触发编码器的场景切换自动 IDR,
|
||||||
|
/// 于是「重发旧帧」和「真重编关键帧」拿到同样的字节,用例就失去区分度了。
|
||||||
|
fn perturb_screen(conn: &RustConnection, root: Window) -> Result<()> {
|
||||||
|
use x11rb::protocol::xproto::{CreateGCAux, Rectangle};
|
||||||
|
let gc = conn.generate_id()?;
|
||||||
|
conn.create_gc(
|
||||||
|
gc,
|
||||||
|
root,
|
||||||
|
&CreateGCAux::new().foreground(0x00FF_FFFF).graphics_exposures(0),
|
||||||
|
)?;
|
||||||
|
conn.poly_fill_rectangle(
|
||||||
|
root,
|
||||||
|
gc,
|
||||||
|
&[Rectangle {
|
||||||
|
x: 32,
|
||||||
|
y: 32,
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
}],
|
||||||
|
)?;
|
||||||
|
conn.free_gc(gc)?;
|
||||||
|
conn.flush()?;
|
||||||
|
println!("[smoke] 已扰动屏幕(根窗口画 64x64 白块)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读帧直到拿到一个非关键帧(跳过静止桌面的低频重发),返回该帧载荷
|
||||||
|
async fn read_until_p_frame(recv: &mut quinn::RecvStream, timeout: Duration) -> Result<Vec<u8>> {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
let (_h, p) = tokio::time::timeout(Duration::from_secs(5), read_video_frame(recv))
|
||||||
|
.await
|
||||||
|
.context("等待扰动帧超时")??;
|
||||||
|
if !contains_nal(&p, NAL_IDR) {
|
||||||
|
return Ok(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bail!("扰动后未拿到非关键帧,用例前置条件不成立(扰动量是否太大?)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 请求关键帧后,容错地等待一个 IDR:期间允许出现静止桌面的 P 帧重发。
|
||||||
|
/// 返回是否在超时内拿到 IDR。
|
||||||
|
async fn wait_for_idr(recv: &mut quinn::RecvStream, timeout: Duration) -> Result<bool> {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
match tokio::time::timeout(Duration::from_secs(2), read_video_frame(recv)).await {
|
||||||
|
Ok(Ok((_h, p))) => {
|
||||||
|
if contains_nal(&p, NAL_IDR) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 归一化坐标 → 根窗口像素(与 linux-agent/src/input.rs 的 scale 完全一致)
|
||||||
|
fn scale(v: u16, extent: u32) -> i16 {
|
||||||
|
if extent <= 1 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
((v as i64 * (extent as i64 - 1)) / 65535) as i16
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_display() -> Result<(RustConnection, Window)> {
|
||||||
|
let (conn, screen_num) = x11rb::connect(None)
|
||||||
|
.map_err(|e| anyhow::anyhow!("连接 X11 失败(DISPLAY 是否已设置?): {e}"))?;
|
||||||
|
let root = conn.setup().roots[screen_num].root;
|
||||||
|
Ok((conn, root))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pointer_position(conn: &RustConnection, root: Window) -> Result<(i16, i16)> {
|
||||||
|
let r = conn.query_pointer(root)?.reply()?;
|
||||||
|
Ok((r.root_x, r.root_y))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_pointer(
|
||||||
|
conn: &RustConnection,
|
||||||
|
root: Window,
|
||||||
|
want: (i16, i16),
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<(i16, i16)> {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
let mut last = pointer_position(conn, root)?;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
last = pointer_position(conn, root)?;
|
||||||
|
if (last.0 - want.0).abs() <= 2 && (last.1 - want.1).abs() <= 2 {
|
||||||
|
return Ok(last);
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
Ok(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn end_to_end_capture_encode_transport_input() -> Result<()> {
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.install_default()
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let (sw, sh) = expected_screen();
|
||||||
|
let port = free_port()?;
|
||||||
|
let _agent = spawn_agent(port, "smoke-code")?;
|
||||||
|
let (_endpoint, conn) = connect(port).await?;
|
||||||
|
|
||||||
|
// 流 1:认证,并核对被控端上报的桌面尺寸就是 Xvfb 的尺寸
|
||||||
|
let (mut send, mut recv) = conn.open_bi().await?;
|
||||||
|
write_control(
|
||||||
|
&mut send,
|
||||||
|
&ControlMsg::Auth {
|
||||||
|
code: "smoke-code".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
match read_control(&mut recv).await? {
|
||||||
|
ControlMsg::AuthOk { width, height } => {
|
||||||
|
assert_eq!(
|
||||||
|
(width, height),
|
||||||
|
(sw, sh),
|
||||||
|
"agent 上报桌面尺寸与实际屏幕不符"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => bail!("认证应成功,却收到 {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流 2:输入 + 控制
|
||||||
|
let (mut isend, mut irecv) = conn.open_bi().await?;
|
||||||
|
|
||||||
|
// Ping/Pong(延迟测量通道 + 下行丢包统计)
|
||||||
|
write_control(&mut isend, &ControlMsg::Ping(4242)).await?;
|
||||||
|
match read_control(&mut irecv).await? {
|
||||||
|
ControlMsg::Pong {
|
||||||
|
t,
|
||||||
|
agent_ts,
|
||||||
|
lost,
|
||||||
|
sent,
|
||||||
|
} => {
|
||||||
|
assert_eq!(t, 4242, "Pong 回显的时间戳不一致");
|
||||||
|
assert!(sent > 0, "下行发包计数应大于 0(视频流已在发)");
|
||||||
|
println!("[smoke] Pong 收到,agent_ts={agent_ts},下行 sent={sent} lost={lost}");
|
||||||
|
}
|
||||||
|
other => bail!("期望 Pong,收到 {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首帧:证明采集 + 编码 + 线格式整条链路通了
|
||||||
|
let (h1, p1) = tokio::time::timeout(Duration::from_secs(15), read_video_frame(&mut recv))
|
||||||
|
.await
|
||||||
|
.context("等待首个视频帧超时")??;
|
||||||
|
assert_eq!((h1.width, h1.height), (sw, sh), "视频帧头尺寸不符");
|
||||||
|
assert!(!p1.is_empty(), "视频载荷为空");
|
||||||
|
println!(
|
||||||
|
"[smoke] 首帧 {}x{} ts={} 编码后 {} 字节,头部 {:02X?}",
|
||||||
|
h1.width,
|
||||||
|
h1.height,
|
||||||
|
h1.ts_ms,
|
||||||
|
p1.len(),
|
||||||
|
&p1[..p1.len().min(12)]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
has_start_code(&p1),
|
||||||
|
"载荷不像 H.264(前 16 字节 {:02X?})",
|
||||||
|
&p1[..p1.len().min(16)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 关键帧恢复:先在**静止**桌面上请求关键帧。
|
||||||
|
// 为了有区分度,先扰动一下屏幕,让缓存里落入一个非关键帧(P 帧),否则
|
||||||
|
// 「重发旧帧」和「真重编关键帧」两种行为拿到的字节是一样的,测不出来。
|
||||||
|
let (probe, root) = open_display()?;
|
||||||
|
perturb_screen(&probe, root)?;
|
||||||
|
let pp = read_until_p_frame(&mut recv, Duration::from_secs(10)).await?;
|
||||||
|
println!(
|
||||||
|
"[smoke] 扰动帧 {} 字节,头部 {:02X?}(应为 P 帧,无 IDR)",
|
||||||
|
pp.len(),
|
||||||
|
&pp[..pp.len().min(12)]
|
||||||
|
);
|
||||||
|
// 前置条件自检:没有这一步,用例可能因为「扰动本身产生了 IDR」而虚假通过
|
||||||
|
assert!(
|
||||||
|
!contains_nal(&pp, NAL_IDR),
|
||||||
|
"扰动帧仍是 IDR,本用例无法区分重发旧帧与真重编关键帧"
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(800)).await; // 等画面重新静止
|
||||||
|
|
||||||
|
write_control(&mut isend, &ControlMsg::KeyFrameReq).await?;
|
||||||
|
assert!(
|
||||||
|
wait_for_idr(&mut recv, Duration::from_secs(3)).await?,
|
||||||
|
"静止桌面请求关键帧后 3s 内未收到 IDR:解码端一旦出错将永远无法恢复"
|
||||||
|
);
|
||||||
|
println!("[smoke] 静止桌面请求关键帧 → 收到 IDR");
|
||||||
|
|
||||||
|
// 输入注入实证:注入到屏幕中心,再从独立 X11 连接查询指针真实落点
|
||||||
|
let before = pointer_position(&probe, root)?;
|
||||||
|
write_control(
|
||||||
|
&mut isend,
|
||||||
|
&ControlMsg::Input(InputEvent::MouseMove {
|
||||||
|
x: 32768,
|
||||||
|
y: 32768,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let want = (scale(32768, sw), scale(32768, sh));
|
||||||
|
let got = wait_pointer(&probe, root, want, Duration::from_secs(5))?;
|
||||||
|
println!("[smoke] 指针 {before:?} → {got:?}(期望 ≈{want:?})");
|
||||||
|
assert!(
|
||||||
|
(got.0 - want.0).abs() <= 2 && (got.1 - want.1).abs() <= 2,
|
||||||
|
"XTest 注入未生效:期望指针 ≈{want:?},实际 {got:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 键盘:链路不得因此崩溃(无窗口可断言落点)
|
||||||
|
write_control(
|
||||||
|
&mut isend,
|
||||||
|
&ControlMsg::Input(InputEvent::KeyDown {
|
||||||
|
scancode: 0x1E,
|
||||||
|
extended: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
write_control(
|
||||||
|
&mut isend,
|
||||||
|
&ControlMsg::Input(InputEvent::KeyUp {
|
||||||
|
scancode: 0x1E,
|
||||||
|
extended: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
assert!(
|
||||||
|
conn.close_reason().is_none(),
|
||||||
|
"会话被意外关闭:{:?}",
|
||||||
|
conn.close_reason()
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("[smoke] 全部断言通过");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_wrong_verification_code() -> Result<()> {
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.install_default()
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let port = free_port()?;
|
||||||
|
let _agent = spawn_agent(port, "right-code")?;
|
||||||
|
let (_endpoint, conn) = connect(port).await?;
|
||||||
|
|
||||||
|
let (mut send, mut recv) = conn.open_bi().await?;
|
||||||
|
write_control(
|
||||||
|
&mut send,
|
||||||
|
&ControlMsg::Auth {
|
||||||
|
code: "wrong-code".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match read_control(&mut recv).await? {
|
||||||
|
ControlMsg::AuthFail(_) => Ok(()),
|
||||||
|
other => bail!("错误验证码竟被接受:{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
48
scripts/linux-smoke.sh
Normal file
48
scripts/linux-smoke.sh
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Linux 被控端冒烟测试一键脚本:自建 Xvfb 虚拟显示 → 跑端到端测试 → 收摊。
|
||||||
|
#
|
||||||
|
# 用法(容器内或任意有 Xvfb 的 Linux):
|
||||||
|
# bash scripts/linux-smoke.sh
|
||||||
|
# 可用环境变量覆盖:
|
||||||
|
# SMOKE_SCREEN=1920x1080 虚拟屏分辨率(默认 1280x720)
|
||||||
|
# SMOKE_DISPLAY=:99 X display 编号(默认 :99)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCREEN="${SMOKE_SCREEN:-1280x720}"
|
||||||
|
DISPLAY_NUM="${SMOKE_DISPLAY:-:99}"
|
||||||
|
|
||||||
|
export DISPLAY="$DISPLAY_NUM"
|
||||||
|
export SMOKE_SCREEN="$SCREEN"
|
||||||
|
|
||||||
|
XVFB_PID=""
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "$XVFB_PID" ]; then
|
||||||
|
kill "$XVFB_PID" 2>/dev/null || true
|
||||||
|
wait "$XVFB_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "[smoke] 启动 Xvfb $DISPLAY_NUM (${SCREEN}x24)"
|
||||||
|
Xvfb "$DISPLAY_NUM" -screen 0 "${SCREEN}x24" -nolisten tcp >/tmp/xvfb.log 2>&1 &
|
||||||
|
XVFB_PID=$!
|
||||||
|
|
||||||
|
ready=0
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
if xdpyinfo -display "$DISPLAY_NUM" >/dev/null 2>&1; then
|
||||||
|
ready=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$ready" -ne 1 ]; then
|
||||||
|
echo "[smoke] Xvfb 启动失败,日志:"
|
||||||
|
cat /tmp/xvfb.log 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[smoke] X server 就绪:$(xdpyinfo -display "$DISPLAY_NUM" | grep -E 'dimensions' | head -1)"
|
||||||
|
|
||||||
|
# 两个测试共用同一块虚拟屏与指针,必须串行
|
||||||
|
cargo test -p linux-agent --test smoke -- --nocapture --test-threads=1
|
||||||
|
echo "[smoke] 通过"
|
||||||
37
tools/bench.md
Normal file
37
tools/bench.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# 双机测试基线脚本(B1)
|
||||||
|
|
||||||
|
每个场景测试 60 秒,记录 HUD 读数填入《B侧设计文档.md》末尾的基线数据表。
|
||||||
|
|
||||||
|
## 准备
|
||||||
|
|
||||||
|
- 两端都用最新 release:`agent.exe` + `viewer.exe`(同目录带 3 个 mingw DLL)
|
||||||
|
- 被控机:`agent.exe --port 47800 --code 123456`
|
||||||
|
- 主控机:`viewer.exe --addr <被控机IP> --port 47800 --code 123456`
|
||||||
|
- 测试前确认:被控机亮屏有桌面;同一网络(记下是有线还是 WiFi 频段)
|
||||||
|
|
||||||
|
## 场景
|
||||||
|
|
||||||
|
| # | 场景 | 被控机操作 | 观察点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 静止桌面 | 打开一个空白文档,不动 60s | FPS 应低(静止检测),E2E/JIT 应绿 |
|
||||||
|
| 2 | 视频播放 | 全屏播放任意 1080p 视频 | 码率峰值、FPS、丢包(LOSS 行) |
|
||||||
|
| 3 | 拖动窗口 | 持续快速拖动一个大窗口 60s | 最恶劣工况:E2E/P95/JIT 是否变红、DECFAIL 是否增长 |
|
||||||
|
|
||||||
|
## 记录方法
|
||||||
|
|
||||||
|
每个场景进行中截一张 HUD 图(Win+Shift+S 框选右上角),或肉眼逐行抄录:
|
||||||
|
|
||||||
|
```
|
||||||
|
FPS= RTT= E2E= JIT= P95= LOSS= DECFAIL= RATE=
|
||||||
|
```
|
||||||
|
|
||||||
|
## 附加记录
|
||||||
|
|
||||||
|
- 网络环境:有线 / WiFi 5GHz / WiFi 2.4GHz,信号强度
|
||||||
|
- 被控机 CPU 占用(任务管理器,agent.exe 一行)
|
||||||
|
- 编码器类型:当前 openh264 软编(A 侧换 NVENC 后需重测一轮对比)
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
|
||||||
|
- LOSS 显示 `--` 表示第一个 Pong 还没回来或 Δsent=0(静止场景发送少,属正常)
|
||||||
|
- DECFAIL 在连接建立初期可能有 1-2 次(等第一个关键帧),后续不应增长;持续增长 = 关键帧自愈有问题,报给 A
|
||||||
@ -81,8 +81,15 @@ fn now_ms() -> u64 {
|
|||||||
T0.get().unwrap().elapsed().as_millis() as u64
|
T0.get().unwrap().elapsed().as_millis() as u64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 帧到达间隔环形缓冲(取 p95 用)
|
||||||
|
struct GapRing {
|
||||||
|
last: Option<Instant>,
|
||||||
|
ring: [u32; 64],
|
||||||
|
idx: usize,
|
||||||
|
filled: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// 会话统计(HUD 数据源,全部原子量,跨任务共享)
|
/// 会话统计(HUD 数据源,全部原子量,跨任务共享)
|
||||||
#[derive(Default)]
|
|
||||||
struct Stats {
|
struct Stats {
|
||||||
/// 最近 RTT ms(0=未知)
|
/// 最近 RTT ms(0=未知)
|
||||||
rtt: AtomicU64,
|
rtt: AtomicU64,
|
||||||
@ -95,6 +102,80 @@ struct Stats {
|
|||||||
/// 累计收帧数 / 字节数
|
/// 累计收帧数 / 字节数
|
||||||
frames: AtomicU64,
|
frames: AtomicU64,
|
||||||
bytes: AtomicU64,
|
bytes: AtomicU64,
|
||||||
|
/// 抖动:相邻帧 |ΔE2E| 的 EMA(ms×16 定点存储避免浮点原子)
|
||||||
|
jitter_x16: AtomicU64,
|
||||||
|
prev_e2e: AtomicI64,
|
||||||
|
/// agent 发送侧(视频下行)QUIC 丢包/发包累计(最近一次 Pong)
|
||||||
|
agent_lost: AtomicU64,
|
||||||
|
agent_sent: AtomicU64,
|
||||||
|
/// H.264 解码失败计数(关键帧自愈验证指标)
|
||||||
|
dec_fail: AtomicU64,
|
||||||
|
/// 帧到达间隔环形缓冲
|
||||||
|
gaps: std::sync::Mutex<GapRing>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Stats {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
rtt: AtomicU64::new(0),
|
||||||
|
offset: AtomicI64::new(0),
|
||||||
|
offset_valid: AtomicBool::new(false),
|
||||||
|
e2e: AtomicU64::new(0),
|
||||||
|
e2e_valid: AtomicBool::new(false),
|
||||||
|
frames: AtomicU64::new(0),
|
||||||
|
bytes: AtomicU64::new(0),
|
||||||
|
jitter_x16: AtomicU64::new(0),
|
||||||
|
prev_e2e: AtomicI64::new(-1),
|
||||||
|
agent_lost: AtomicU64::new(0),
|
||||||
|
agent_sent: AtomicU64::new(0),
|
||||||
|
dec_fail: AtomicU64::new(0),
|
||||||
|
gaps: std::sync::Mutex::new(GapRing {
|
||||||
|
last: None,
|
||||||
|
ring: [0; 64],
|
||||||
|
idx: 0,
|
||||||
|
filled: 0,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stats {
|
||||||
|
/// 记录一次帧到达:更新间隔环形缓冲与抖动 EMA
|
||||||
|
fn on_frame_arrival(&self, est: Option<i64>) {
|
||||||
|
let now = Instant::now();
|
||||||
|
if let Ok(mut g) = self.gaps.lock() {
|
||||||
|
if let Some(t) = g.last {
|
||||||
|
let gap = (now - t).as_millis() as u32;
|
||||||
|
let i = g.idx;
|
||||||
|
g.ring[i] = gap;
|
||||||
|
g.idx = (i + 1) % g.ring.len();
|
||||||
|
g.filled = (g.filled + 1).min(g.ring.len());
|
||||||
|
}
|
||||||
|
g.last = Some(now);
|
||||||
|
}
|
||||||
|
// 抖动 = |本帧E2E - 上帧E2E| 的 EMA(RFC3550 风格,÷16 衰减)
|
||||||
|
if let Some(e) = est {
|
||||||
|
let prev = self.prev_e2e.load(Ordering::Relaxed);
|
||||||
|
self.prev_e2e.store(e, Ordering::Relaxed);
|
||||||
|
if prev >= 0 {
|
||||||
|
let d = (e - prev).unsigned_abs() as u64;
|
||||||
|
let old = self.jitter_x16.load(Ordering::Relaxed);
|
||||||
|
let sm = if old == 0 { d * 16 } else { old + d * 16 / 16 - old / 16 };
|
||||||
|
self.jitter_x16.store(sm, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帧间隔 p95(ms),样本不足返回 None
|
||||||
|
fn gap_p95(&self) -> Option<u32> {
|
||||||
|
let g = self.gaps.lock().ok()?;
|
||||||
|
if g.filled < 8 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut v: Vec<u32> = g.ring[..g.filled].to_vec();
|
||||||
|
v.sort_unstable();
|
||||||
|
v.get(v.len() * 95 / 100).copied()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@ -160,48 +241,105 @@ fn session(
|
|||||||
let (w, h) = (width as usize, height as usize);
|
let (w, h) = (width as usize, height as usize);
|
||||||
let stats = Arc::new(Stats::default());
|
let stats = Arc::new(Stats::default());
|
||||||
|
|
||||||
// 读任务(流 1):H.264 解码 → RGB → mpsc;同时统计端到端延迟与流量
|
// 读任务(流 1):H.264 解码 → RGB → 单槽 latest-wins;同时统计端到端延迟与流量
|
||||||
let (frame_tx, frame_rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
// 帧格式 (src_w, src_h, rgb);(0,0,_) = 解码失败占位,触发 KeyFrameReq
|
||||||
|
// 单槽而非无界队列:渲染跟不上 60fps 到达率时旧帧直接被覆盖,绝不积压
|
||||||
|
//(无界 mpsc 在 NVENC 60fps 下会无限积压 → 延迟线性增长 + 渲染 drain 时
|
||||||
|
// 长时间阻塞窗口消息泵 → 窗口假死、HUD 停刷新)
|
||||||
|
let latest: Arc<std::sync::Mutex<Option<(u32, u32, Vec<u8>)>>> =
|
||||||
|
Arc::new(std::sync::Mutex::new(None));
|
||||||
{
|
{
|
||||||
let stats = stats.clone();
|
let stats = stats.clone();
|
||||||
|
let latest = latest.clone();
|
||||||
let mut decoder = openh264::decoder::Decoder::new()
|
let mut decoder = openh264::decoder::Decoder::new()
|
||||||
.map_err(|e| anyhow::anyhow!("decoder init failed: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("decoder init failed: {e}"))?;
|
||||||
|
let mut rgb_buf: Vec<u8> = Vec::new();
|
||||||
|
let mut last_kfreq = Instant::now();
|
||||||
|
let mut skip_stale = 0u64;
|
||||||
|
let mut last_arr: Option<Instant> = None;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
let mut size_logged = false;
|
||||||
loop {
|
loop {
|
||||||
match common::read_video_frame(&mut recv).await {
|
match common::read_video_frame(&mut recv).await {
|
||||||
Ok((hdr, payload)) => {
|
Ok((hdr, payload)) => {
|
||||||
stats.bytes.fetch_add(payload.len() as u64, Ordering::Relaxed);
|
stats.bytes.fetch_add(payload.len() as u64, Ordering::Relaxed);
|
||||||
stats.frames.fetch_add(1, Ordering::Relaxed);
|
stats.frames.fetch_add(1, Ordering::Relaxed);
|
||||||
|
// 与上一帧的到达间隔:≤100ms 才算"突发到达"。
|
||||||
|
// 桌面静止时 agent 以 2Hz 空闲重发,帧龄天然 ≥500ms,
|
||||||
|
// 不能按陈旧帧丢弃——否则静止桌面所有帧被跳过 →
|
||||||
|
// 永久黑屏 + KeyFrameReq/IDR 死循环(v0.8 实测教训)
|
||||||
|
let burst = matches!(last_arr, Some(t) if t.elapsed() <= Duration::from_millis(100));
|
||||||
|
last_arr = Some(Instant::now());
|
||||||
// 端到端延迟 = 接收时刻 - 时钟偏移 - 采集时刻(EMA 平滑)
|
// 端到端延迟 = 接收时刻 - 时钟偏移 - 采集时刻(EMA 平滑)
|
||||||
|
let mut est = None;
|
||||||
|
let mut stale = false;
|
||||||
if stats.offset_valid.load(Ordering::Relaxed) {
|
if stats.offset_valid.load(Ordering::Relaxed) {
|
||||||
let est =
|
let e =
|
||||||
now_ms() as i64 - stats.offset.load(Ordering::Relaxed)
|
now_ms() as i64 - stats.offset.load(Ordering::Relaxed)
|
||||||
- hdr.ts_ms as i64;
|
- hdr.ts_ms as i64;
|
||||||
if (0..5000).contains(&est) {
|
if (0..5000).contains(&e) {
|
||||||
|
// 积压判定必须同时满足:突发到达(背靠背)+ 帧龄>250ms。
|
||||||
|
// QUIC 可靠有序,积压须逐帧读出但可不解码——跳过解码
|
||||||
|
// 让队列以网络速度排空
|
||||||
|
stale = burst && e > 250;
|
||||||
|
// 空闲重发帧的帧龄不代表显示延迟,不计入 EMA
|
||||||
|
if burst || e < 250 {
|
||||||
let old = stats.e2e.load(Ordering::Relaxed) as i64;
|
let old = stats.e2e.load(Ordering::Relaxed) as i64;
|
||||||
let sm = if stats.e2e_valid.load(Ordering::Relaxed) {
|
let sm = if stats.e2e_valid.load(Ordering::Relaxed) {
|
||||||
(old * 4 + est) / 5
|
(old * 4 + e) / 5
|
||||||
} else {
|
} else {
|
||||||
est
|
e
|
||||||
};
|
};
|
||||||
stats.e2e.store(sm as u64, Ordering::Relaxed);
|
stats.e2e.store(sm as u64, Ordering::Relaxed);
|
||||||
stats.e2e_valid.store(true, Ordering::Relaxed);
|
stats.e2e_valid.store(true, Ordering::Relaxed);
|
||||||
|
est = Some(sm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
stats.on_frame_arrival(est);
|
||||||
|
if stale {
|
||||||
|
skip_stale += 1;
|
||||||
|
if skip_stale % 60 == 1 {
|
||||||
|
eprintln!(
|
||||||
|
"[viewer] backlog: stale burst frame skipped (total {skip_stale})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 限频请求关键帧:积压排空后从 IDR 重新衔接画面
|
||||||
|
if last_kfreq.elapsed() >= Duration::from_millis(200) {
|
||||||
|
last_kfreq = Instant::now();
|
||||||
|
if let Ok(mut slot) = latest.lock() {
|
||||||
|
*slot = Some((0, 0, Vec::new()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match decoder.decode(&payload) {
|
match decoder.decode(&payload) {
|
||||||
Ok(Some(yuv)) => {
|
Ok(Some(yuv)) => {
|
||||||
let (dw, dh) = yuv.dimensions();
|
let (dw, dh) = yuv.dimensions();
|
||||||
let mut rgb = vec![0u8; dw * dh * 3];
|
let (dw, dh) = (dw as u32, dh as u32);
|
||||||
yuv.write_rgb8(&mut rgb);
|
if !size_logged {
|
||||||
if frame_tx.send(rgb).is_err() {
|
size_logged = true;
|
||||||
break;
|
eprintln!(
|
||||||
|
"[viewer] decoded frame {dw}x{dh} (window {}x{})",
|
||||||
|
hdr.width, hdr.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rgb_buf.clear();
|
||||||
|
rgb_buf.resize(dw as usize * dh as usize * 3, 0);
|
||||||
|
yuv.write_rgb8(&mut rgb_buf);
|
||||||
|
// latest-wins:直接覆盖旧帧,无积压
|
||||||
|
if let Ok(mut slot) = latest.lock() {
|
||||||
|
*slot = Some((dw, dh, rgb_buf.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None) => {}
|
Ok(None) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// 解码异常:请求被控端强制关键帧
|
// 解码异常:请求被控端强制关键帧
|
||||||
|
stats.dec_fail.fetch_add(1, Ordering::Relaxed);
|
||||||
eprintln!("[viewer] decode failed: {e}, request keyframe");
|
eprintln!("[viewer] decode failed: {e}, request keyframe");
|
||||||
let _ = frame_tx.send(Vec::new()); // 空帧占位唤醒渲染循环
|
if let Ok(mut slot) = latest.lock() {
|
||||||
|
*slot = Some((0, 0, Vec::new()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -220,9 +358,11 @@ fn session(
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match read_control(&mut irecv).await {
|
match read_control(&mut irecv).await {
|
||||||
Ok(ControlMsg::Pong { t, agent_ts }) => {
|
Ok(ControlMsg::Pong { t, agent_ts, lost, sent }) => {
|
||||||
let r = now_ms();
|
let r = now_ms();
|
||||||
stats.rtt.store(r.saturating_sub(t), Ordering::Relaxed);
|
stats.rtt.store(r.saturating_sub(t), Ordering::Relaxed);
|
||||||
|
stats.agent_lost.store(lost, Ordering::Relaxed);
|
||||||
|
stats.agent_sent.store(sent, Ordering::Relaxed);
|
||||||
// agent_ts ≈ (t+r)/2 时刻的 agent 时钟读数 → 偏移 EMA
|
// agent_ts ≈ (t+r)/2 时刻的 agent 时钟读数 → 偏移 EMA
|
||||||
let est = agent_ts as i64 - ((t + r) / 2) as i64;
|
let est = agent_ts as i64 - ((t + r) / 2) as i64;
|
||||||
let old = stats.offset.load(Ordering::Relaxed);
|
let old = stats.offset.load(Ordering::Relaxed);
|
||||||
@ -235,7 +375,10 @@ fn session(
|
|||||||
stats.offset_valid.store(true, Ordering::Relaxed);
|
stats.offset_valid.store(true, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(_) => break,
|
Err(e) => {
|
||||||
|
eprintln!("[viewer] control stream closed: {e} (RTT/LOSS 将停止更新)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -255,9 +398,11 @@ fn session(
|
|||||||
let push = |tx: &tokio::sync::mpsc::UnboundedSender<Vec<u8>>, m: ControlMsg| {
|
let push = |tx: &tokio::sync::mpsc::UnboundedSender<Vec<u8>>, m: ControlMsg| {
|
||||||
let _ = tx.send(common::encode_control(&m));
|
let _ = tx.send(common::encode_control(&m));
|
||||||
};
|
};
|
||||||
|
// 入会立即请求 IDR:避免从 GOP 中间接入收到裸 P 帧解码失败导致启动黑屏
|
||||||
|
push(&msg_tx, ControlMsg::KeyFrameReq);
|
||||||
|
|
||||||
let mut window = Window::new(
|
let mut window = Window::new(
|
||||||
"mvp-remote viewer (Esc 退出)",
|
"mvp-remote v0.8.1-CatchUp (Esc 退出)",
|
||||||
w,
|
w,
|
||||||
h,
|
h,
|
||||||
WindowOptions {
|
WindowOptions {
|
||||||
@ -278,6 +423,8 @@ fn session(
|
|||||||
let mut hud_bytes = 0u64;
|
let mut hud_bytes = 0u64;
|
||||||
let mut hud_fps = 0u64;
|
let mut hud_fps = 0u64;
|
||||||
let mut hud_mbps = 0f64;
|
let mut hud_mbps = 0f64;
|
||||||
|
let mut hud_lost = 0u64;
|
||||||
|
let mut hud_sent = 0u64;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if !window.is_open() || window.is_key_down(Key::Escape) {
|
if !window.is_open() || window.is_key_down(Key::Escape) {
|
||||||
@ -290,19 +437,16 @@ fn session(
|
|||||||
push(&msg_tx, ControlMsg::Ping(now_ms()));
|
push(&msg_tx, ControlMsg::Ping(now_ms()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) 渲染最新帧(只保留最新,丢弃积压);空帧=解码异常,请求关键帧
|
// 1) 渲染最新帧(单槽 latest-wins:每迭代只取最新一帧,天然防积压)
|
||||||
loop {
|
if let Ok(mut slot) = latest.lock() {
|
||||||
match frame_rx.try_recv() {
|
if let Some((sw, sh, raw)) = slot.take() {
|
||||||
Ok(raw) => {
|
|
||||||
if raw.is_empty() {
|
if raw.is_empty() {
|
||||||
push(&msg_tx, ControlMsg::KeyFrameReq);
|
push(&msg_tx, ControlMsg::KeyFrameReq);
|
||||||
} else if raw.len() == w * h * 3 {
|
} else if sw > 0 && sh > 0 {
|
||||||
blit(&mut buffer, &raw);
|
blit(&mut buffer, w, h, &raw, sw as usize, sh as usize);
|
||||||
dirty = true;
|
dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) 键盘
|
// 2) 键盘
|
||||||
@ -367,7 +511,9 @@ fn session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6) HUD:每秒刷新一次指标,画在右上角
|
// 6) HUD:指标每秒刷新一次;每秒至少强制呈现一次(视频冻结/黑屏时 HUD 依然可见)
|
||||||
|
let mut need_present = dirty;
|
||||||
|
let (hud_loss, hud_jit, hud_p95, hud_dec);
|
||||||
if hud_last.elapsed() >= Duration::from_secs(1) {
|
if hud_last.elapsed() >= Duration::from_secs(1) {
|
||||||
let f = stats.frames.load(Ordering::Relaxed);
|
let f = stats.frames.load(Ordering::Relaxed);
|
||||||
let b = stats.bytes.load(Ordering::Relaxed);
|
let b = stats.bytes.load(Ordering::Relaxed);
|
||||||
@ -375,12 +521,47 @@ fn session(
|
|||||||
hud_mbps = (b.saturating_sub(hud_bytes)) as f64 * 8.0 / 1e6;
|
hud_mbps = (b.saturating_sub(hud_bytes)) as f64 * 8.0 / 1e6;
|
||||||
hud_frames = f;
|
hud_frames = f;
|
||||||
hud_bytes = b;
|
hud_bytes = b;
|
||||||
|
// 丢包率 = Δlost / Δsent(agent 发送方向,随 Pong 同步)
|
||||||
|
let l = stats.agent_lost.load(Ordering::Relaxed);
|
||||||
|
let s = stats.agent_sent.load(Ordering::Relaxed);
|
||||||
|
hud_loss = if s > hud_sent {
|
||||||
|
((l - hud_lost) as f64 / (s - hud_sent) as f64 * 100.0).min(99.9)
|
||||||
|
} else {
|
||||||
|
-1.0
|
||||||
|
};
|
||||||
|
hud_lost = l;
|
||||||
|
hud_sent = s;
|
||||||
|
hud_jit = stats.jitter_x16.load(Ordering::Relaxed) / 16;
|
||||||
|
hud_p95 = stats.gap_p95();
|
||||||
|
hud_dec = stats.dec_fail.load(Ordering::Relaxed);
|
||||||
hud_last = Instant::now();
|
hud_last = Instant::now();
|
||||||
|
need_present = true; // HUD 面板每秒至少重绘一次
|
||||||
|
} else {
|
||||||
|
hud_loss = f64::NAN;
|
||||||
|
hud_jit = 0;
|
||||||
|
hud_p95 = None;
|
||||||
|
hud_dec = 0;
|
||||||
}
|
}
|
||||||
if dirty {
|
if need_present {
|
||||||
let rtt = stats.rtt.load(Ordering::Relaxed);
|
let rtt = stats.rtt.load(Ordering::Relaxed);
|
||||||
let e2e = stats.e2e.load(Ordering::Relaxed);
|
let e2e = stats.e2e.load(Ordering::Relaxed);
|
||||||
let e2e_ok = stats.e2e_valid.load(Ordering::Relaxed);
|
let e2e_ok = stats.e2e_valid.load(Ordering::Relaxed);
|
||||||
|
let loss_str = if hud_loss.is_nan() {
|
||||||
|
"--".to_string()
|
||||||
|
} else if hud_loss < 0.0 {
|
||||||
|
"--".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{hud_loss:.1}%")
|
||||||
|
};
|
||||||
|
let loss_c = if hud_loss.is_nan() || hud_loss < 0.0 {
|
||||||
|
C_GRAY
|
||||||
|
} else {
|
||||||
|
lat_color(hud_loss < 1.0, hud_loss < 5.0)
|
||||||
|
};
|
||||||
|
let jit_c = lat_color(hud_jit < 10, hud_jit < 30);
|
||||||
|
let p95_c = hud_p95
|
||||||
|
.map(|p| lat_color(p < 60, p < 150))
|
||||||
|
.unwrap_or(C_GRAY);
|
||||||
draw_hud(
|
draw_hud(
|
||||||
&mut buffer,
|
&mut buffer,
|
||||||
w,
|
w,
|
||||||
@ -392,6 +573,10 @@ fn session(
|
|||||||
&if e2e_ok { format!("{e2e}ms") } else { "--".into() },
|
&if e2e_ok { format!("{e2e}ms") } else { "--".into() },
|
||||||
lat_color(e2e < 100, e2e < 200),
|
lat_color(e2e < 100, e2e < 200),
|
||||||
),
|
),
|
||||||
|
("JIT", &format!("{hud_jit}ms"), jit_c),
|
||||||
|
("P95", &hud_p95.map(|p| format!("{p}ms")).unwrap_or("--".into()), p95_c),
|
||||||
|
("LOSS", &loss_str, loss_c),
|
||||||
|
("DECFAIL", &format!("{hud_dec}"), if hud_dec == 0 { C_GRAY } else { C_RED }),
|
||||||
("RATE", &format!("{:.0}MBP", hud_mbps), C_GRAY),
|
("RATE", &format!("{:.0}MBP", hud_mbps), C_GRAY),
|
||||||
("RES", &format!("{w}x{h}"), C_GRAY),
|
("RES", &format!("{w}x{h}"), C_GRAY),
|
||||||
],
|
],
|
||||||
@ -473,6 +658,7 @@ fn glyph(c: u8) -> [u8; 5] {
|
|||||||
b':' => [0x00, 0x36, 0x36, 0x00, 0x00],
|
b':' => [0x00, 0x36, 0x36, 0x00, 0x00],
|
||||||
b'.' => [0x00, 0x60, 0x60, 0x00, 0x00],
|
b'.' => [0x00, 0x60, 0x60, 0x00, 0x00],
|
||||||
b'-' => [0x08, 0x08, 0x08, 0x08, 0x08],
|
b'-' => [0x08, 0x08, 0x08, 0x08, 0x08],
|
||||||
|
b'%' => [0x13, 0x18, 0x04, 0x0A, 0x11],
|
||||||
b' ' => [0x00; 5],
|
b' ' => [0x00; 5],
|
||||||
_ => [0x00; 5],
|
_ => [0x00; 5],
|
||||||
}
|
}
|
||||||
@ -546,9 +732,17 @@ fn draw_hud(buffer: &mut [u32], w: usize, lines: &[(&str, &str, u32)]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// RGB888 字节(openh264 解码输出)→ minifb 0RGB u32 缓冲
|
/// RGB888 字节(openh264 解码输出)→ minifb 0RGB u32 缓冲。
|
||||||
fn blit(buffer: &mut [u32], raw: &[u8]) {
|
/// 支持源尺寸与窗口不同(解码器可能对齐到 16 倍数,如 1080→1088):
|
||||||
for (px, chunk) in buffer.iter_mut().zip(raw.chunks_exact(3)) {
|
/// 逐行拷贝重叠区域,源小于窗口时右侧/下方留黑。
|
||||||
|
fn blit(buffer: &mut [u32], w: usize, h: usize, raw: &[u8], dw: usize, dh: usize) {
|
||||||
|
let cw = w.min(dw);
|
||||||
|
let ch = h.min(dh);
|
||||||
|
for row in 0..ch {
|
||||||
|
let src = &raw[row * dw * 3..row * dw * 3 + cw * 3];
|
||||||
|
let dst = &mut buffer[row * w..row * w + cw];
|
||||||
|
for (px, chunk) in dst.iter_mut().zip(src.chunks_exact(3)) {
|
||||||
*px = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
|
*px = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
130
构建运行手册.md
130
构建运行手册.md
@ -1,23 +1,36 @@
|
|||||||
# MVP 远程控制 Demo · 构建运行手册
|
# MVP 远程控制 Demo · 构建运行手册
|
||||||
|
|
||||||
> 项目路径:`D:\wordkb\2026-10-22-15-52-47\mvp-remote\`
|
> 项目路径:`D:\code\remote\`
|
||||||
> 形态:Rust workspace,三个 crate:`common`(协议)、`agent`(被控端)、`viewer`(主控端)
|
> 形态:Rust workspace:`common`(协议)、`agent-core`(平台无关会话层,当前供 linux-agent 使用)、`agent`(Windows 被控端)、`linux-agent`(Linux 被控端)、`viewer`(主控端)
|
||||||
|
|
||||||
## 1. 功能范围(量级一 Demo)
|
## 1. 功能范围(量级一 Demo)
|
||||||
|
|
||||||
- ✅ DXGI Desktop Duplication 全屏采集(GPU 拷贝,脏区/硬编码留待二期)
|
- ✅ DXGI Desktop Duplication 全屏采集 + NVENC 硬编(无 N 卡时自动回落 openh264 软编)
|
||||||
- ✅ LZ4 压缩 + QUIC 加密传输(自签名证书,传输层已加密)
|
- ✅ QUIC 加密传输(自签名证书,传输层已加密)
|
||||||
- ✅ 验证码认证(默认 123456)
|
- ✅ 验证码认证(默认 123456)
|
||||||
- ✅ 键鼠远程控制(SendInput 注入,虚拟桌面绝对坐标 + 扫描码)
|
- ✅ 键鼠远程控制(SendInput 注入,虚拟桌面绝对坐标 + 扫描码)
|
||||||
- ✅ 桌面静止时低频重发(保持画面新鲜)
|
- ✅ 桌面静止时低频重发(保持画面新鲜)
|
||||||
- ❌ 暂无:硬件编码、P2P 打洞/中继(当前仅局域网/可直连地址)、音频、文件传输、Linux 被控
|
- ✅ Linux 被控端(X11 采集 + XTest 注入,见第 8 节)
|
||||||
|
- ❌ 暂无:P2P 打洞/中继(当前仅局域网/可直连地址)、音频、文件传输
|
||||||
|
|
||||||
## 2. 环境要求
|
## 2. 环境要求
|
||||||
|
|
||||||
- Windows 8+(被控端需要 DXGI Duplication;主控端 Win7 亦可)
|
- Windows 8+(被控端需要 DXGI Duplication;主控端 Win7 亦可)
|
||||||
- 工具链已全部部署在 D 盘(无需再装):
|
- Rust(GNU host:`x86_64-pc-windows-gnu`)+ MinGW-w64,已验证可用组合:
|
||||||
- Rust (GNU):`uurev/tools/rust/cargo/bin`
|
- `rustup default stable-x86_64-pc-windows-gnu`,装到 `%USERPROFILE%\.cargo\bin`
|
||||||
- MinGW-w64 14.2:`uurev/tools/mingw64/bin`
|
- MinGW-w64:`D:\mingw64\bin`(gcc 16.2.0)
|
||||||
|
- 构建时 PATH 需同时包含上面两个目录(缺 MinGW 会在链接 openh264 的 C++ 时失败)
|
||||||
|
- **cargo 源**:国内直连 crates.io 会超时(cargo 不读系统代理),需配置镜像。已在本机
|
||||||
|
`%USERPROFILE%\.cargo\config.toml` 写入 USTC 源:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[source.crates-io]
|
||||||
|
replace-with = "ustc"
|
||||||
|
[source.ustc]
|
||||||
|
registry = "sparse+https://mirrors.ustc.edu.cn/crates.io-index/"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注:旧版本手册写的 `D:\wordkb\...\uurev\tools\rust` 路径已不存在,按上面重新准备工具链即可。
|
||||||
|
|
||||||
## 3. 构建
|
## 3. 构建
|
||||||
|
|
||||||
@ -67,6 +80,30 @@ viewer.exe --addr <被控端IP> --port 47800 --code 888888
|
|||||||
4. 高 DPI/多屏:当前只采集主输出(EnumOutputs(0))。
|
4. 高 DPI/多屏:当前只采集主输出(EnumOutputs(0))。
|
||||||
5. 主控端窗口固定为远端分辨率,颜色序 BGRA→0RGB 若异常可在 `blit()` 一键翻转。
|
5. 主控端窗口固定为远端分辨率,颜色序 BGRA→0RGB 若异常可在 `blit()` 一键翻转。
|
||||||
|
|
||||||
|
### 6.1 待修问题(已定位,尚未修,均在 Windows `agent/`)
|
||||||
|
|
||||||
|
1. **错误验证码时 `AuthFail` 送不达**:`agent/src/main.rs` 写完 `AuthFail` 后立刻
|
||||||
|
`send.finish()` 并返回,进程退出会把 QUIC 缓冲区里尚未发出的数据一起带走,
|
||||||
|
客户端只会看到 `connection lost` 而不是 `AuthFail`。
|
||||||
|
修法:`finish()` 后等对端读到并挂断(`conn.closed()` + 超时)再退出。
|
||||||
|
|
||||||
|
2. **软编路径下静止桌面仍不响应关键帧请求**(`04c02d9` 只修了 NVENC 那一半):
|
||||||
|
`agent/src/capture.rs` 的 `Ok(false)`(画面未变)分支里,`force_key` 被
|
||||||
|
`keyreq.swap` 消费后,只有 `EncoderKind::Nvenc` 分支拿它去重编 IDR;
|
||||||
|
`EncoderKind::Soft` 分支仍然只是「重发最后一帧码流副本」,标志被直接丢弃。
|
||||||
|
后果:**无 N 卡 / 驱动异常(走软编兜底)的机器上,解码端一旦出错,静止画面下
|
||||||
|
永远拿不到 IDR,表现为黑屏。**
|
||||||
|
修法:软编路径同样保留最近一帧裸像素,`force_key` 时重编一个真关键帧。
|
||||||
|
|
||||||
|
3. **单次 `KeyFrameReq` 可能被静默吞掉**:同一个 `keyreq.swap(false, …)` 写在了
|
||||||
|
`if last_sent.elapsed() > 500ms` 的**外面**,因此请求若落在距上次发送 500ms 之内,
|
||||||
|
标志已被消费但分支不进入,该次请求就丢了,必须等下一次请求才可能生效
|
||||||
|
(主控端目前周期性重发,故表现为延迟而非永久黑屏)。
|
||||||
|
修法:把 `swap` 移进时间闸内,或先在闸外预判、进闸后再消费。
|
||||||
|
|
||||||
|
> 第 1、2 条在 `feature/agent-linux` 分支的 `agent-core` 里已经实现(**Linux 侧生效**),
|
||||||
|
> 但按"不与他线争抢 `agent/`"的约定,**本期没有改动 Windows 代码**。
|
||||||
|
|
||||||
## 7. 下一阶段任务队列(Phase 1 剩余)
|
## 7. 下一阶段任务队列(Phase 1 剩余)
|
||||||
|
|
||||||
1. H.264:接 openh264(软编兜底)→ NVENC/QSV(硬编优先)
|
1. H.264:接 openh264(软编兜底)→ NVENC/QSV(硬编优先)
|
||||||
@ -74,3 +111,80 @@ viewer.exe --addr <被控端IP> --port 47800 --code 888888
|
|||||||
3. QUIC 多流:视频/控制/键鼠分离流(键鼠零阻塞)
|
3. QUIC 多流:视频/控制/键鼠分离流(键鼠零阻塞)
|
||||||
4. P2P 打洞 + coturn 中继(自建信令服务器)
|
4. P2P 打洞 + coturn 中继(自建信令服务器)
|
||||||
5. 被控端服务化(Windows Service + 会话切换处理)
|
5. 被控端服务化(Windows Service + 会话切换处理)
|
||||||
|
|
||||||
|
## 8. Linux 被控端(linux-agent)
|
||||||
|
|
||||||
|
### 8.1 现状与边界(重要)
|
||||||
|
|
||||||
|
本期**只在新增文件里做,未改动 Windows `agent/`**,原因见下:
|
||||||
|
|
||||||
|
| crate / 文件 | 职责 | 当前使用者 |
|
||||||
|
|---|---|---|
|
||||||
|
| `agent-core/src/lib.rs` | QUIC/TLS 握手、传输调优、验证码认证、会话/select 循环、latest-wins 单槽、输入分发,以及软件编码视频管线(openh264) | **仅 linux-agent** |
|
||||||
|
| `linux-agent/src/capture.rs` | X11 `GetImage` 采集 + 整帧变更检测 | Linux |
|
||||||
|
| `linux-agent/src/input.rs` | XTest 注入 + 扫描码→X11 keycode 映射 | Linux |
|
||||||
|
| `agent/`(Windows) | 保留原有自持会话循环 + NVENC/软编双路编码,**本期未动** | Windows |
|
||||||
|
|
||||||
|
**为什么 Windows 端没有一并接过来**:`agent/` 的 NVENC 路径是「GPU 内 BGRA→H.264 零转换零拷贝」,
|
||||||
|
编码器必须直接吃 DXGI 的 GPU 纹理;而 `agent-core` 目前的接缝是 `ScreenCapturer`(返回 CPU 上的
|
||||||
|
BGRA 字节),硬接会把零拷贝优势废掉。正确的接缝应是「平台视频管线产出**已编码帧**」
|
||||||
|
(`VideoSource` 语义),待 `agent/` 由 NVENC 那条线稳定后再重切,届时应把 `openh264` 从
|
||||||
|
`agent-core` 移到各平台 crate。
|
||||||
|
|
||||||
|
在此之前 Windows 与 Linux 各持一份会话循环,属于**有意的过渡态**:`common/` 协议改动需要两处同步
|
||||||
|
(例如本期跟着补的 `Pong{lost,sent}`)。
|
||||||
|
|
||||||
|
### 8.2 构建
|
||||||
|
|
||||||
|
Windows 侧照旧(`default-members` 已排除 linux-agent,产物仍是 agent.exe / viewer.exe):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="$USERPROFILE/.cargo/bin:/d/mingw64/bin:$PATH" # Git Bash 写法
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux 侧显式指定包(跨平台交叉编译不推荐,用容器最省事):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release -p linux-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 被控 Linux 机器(Xorg 或 XWayland 均可,依赖 DISPLAY 环境变量)
|
||||||
|
./linux-agent --port 47800 --code 123456
|
||||||
|
# 主控端(Windows)命令行不变
|
||||||
|
viewer.exe --addr <被控机IP> --port 47800 --code 123456
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 冒烟测试(不需要图形桌面)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/linux-smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本自建 Xvfb 虚拟屏后跑端到端测试,覆盖:X11 采集 → openh264 编码 → QUIC 下行解帧、
|
||||||
|
验证码认证(含**错误验证码被拒**)、Ping/Pong(含下行丢包统计字段)、静止桌面下的关键帧
|
||||||
|
恢复,以及**输入注入实证**——注入鼠标移动后,从另一条 X11 连接读回指针真实坐标。
|
||||||
|
|
||||||
|
> 静止关键帧那条用例做过**变异测试**:把修复退回会失败、恢复后通过,确认断言不是摆设。
|
||||||
|
> 注意扰动量必须小(画 64x64 小方块)——整屏刷白会触发编码器场景切换自动 IDR,
|
||||||
|
> 于是"重发旧帧"和"真重编关键帧"拿到同样的字节,用例就失去区分度。
|
||||||
|
|
||||||
|
### 8.5 键码映射(两端最关键的差异)
|
||||||
|
|
||||||
|
协议里键盘沿用 Windows 口味的 PS/2 Set-1 扫描码,而 X11 要的是 keycode,两者**并非处处等价**:
|
||||||
|
|
||||||
|
- 主键区:满足 `keycode = scancode + 8`;
|
||||||
|
- **E0 扩展键编号体系完全不同**(`↑` 是 0x48,但 X11 keycode 是 111 而不是 0x50)——必须查表。
|
||||||
|
|
||||||
|
映射表在 `linux-agent/src/input.rs::keycode_of`,带单元测试防回归。
|
||||||
|
|
||||||
|
### 8.6 Linux 侧已知限制
|
||||||
|
|
||||||
|
1. 仅支持 32bpp TrueColor 视觉(绝大多数桌面满足),其他格式启动即明确报错而非静默出错;
|
||||||
|
2. 采集走核心协议 `GetImage`,每帧经 X socket 全量传输,静止桌面也会持续轮询抓帧;
|
||||||
|
**MIT-SHM 共享内存 + XDamage 脏区**是下一步优化(锚点已写在 `linux-agent/src/capture.rs` 顶部注释);
|
||||||
|
3. 仅覆盖 X11 / XWayland;纯 Wayland(无 XWayland)需另走 PipeWire 门户;
|
||||||
|
4. 键位表按 Xorg evdev 标准 keycode 编写,自定义键盘布局可能需要调整。
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user