Author SHA1 Message Date
yu 18d12baba1 fix(tools): 探针区分信令回复与打洞包,修掉打洞假阳性
问题:客户端反复注册引来的 PEER/WAIT 回复在 discover_peer 返回后才到达,
被接收线程当成「对方打进来了」,输出假的「✅ 打洞成功」。
实测形态:报告的对方地址实为信令服务器 47.98.245.66:47999,包长 22 字节
(真打洞包固定 16 字节 = PUNCHv1| + 8 字节时间戳)。

修复:
- 接收线程只认以 MAGIC 开头、且长度正确的包
- 显式排除信令服务器的 IP:端口
- 失败结论里报告被忽略的非打洞包数量,避免再次误读
- 验证记录补 4.5 节,写明 Rust 实现的对应判据
2026-10-23 18:02:14 +08:00
yu 2487e616c2 docs: 打洞设计/多平台架构/借鉴映射/验证记录
- 打洞与信令设计.md: 无中继方案的流程、成功率边界、Rust 实现约束
- 多平台架构设计.md: 6 层分层 + 平台矩阵 + 六项关键决策 + 分期路线
- 借鉴映射与落地规范.md: 每层看谁/抄什么/不抄什么 + 许可证红线(AGPL 只读)
- 打洞验证记录.md: POC 实测结论(锥形 NAT + 端口保留)与 4 个坑
- README: 文档索引补充
2026-10-23 17:32:17 +08:00
yu d659a4e7f1 feat(tools): 打洞探针与极简 rendezvous 服务器(Python POC)
- punch_probe.py: UDP 打洞探针,支持 STUN 查映射、--peer 手工配对、--server/--room 信令配对
- rendezvous.py: 极简 UDP 信令服务器,只做房间配对与地址回推,不承载媒体
- 已在 47.98.245.66 实测跨公网注册/配对可用
2026-10-23 17:32:17 +08:00
yu bb392507bd feat(common): 新增 P2P 打洞信令协议(无中继)
- signaling.rs: 信令消息契约 + NAT 画像/打洞前景诊断 + 证书指纹
- 明确不含 relay/TURN:打洞失败必须给出可执行原因(PunchFailReason::user_hint)
- 时间对齐不做时钟同步:服务器按两侧 RTT 下发不同 start_in_ms
- 保活按 binding_timeout_s 折半(keepalive_secs)
2026-10-23 17:32:17 +08:00
yu 9fda49d9c9 Merge branch 'main' of http://43.140.215.244:7040/wangyukun/remote into feature/agent-linux 2026-10-23 14:02:44 +08:00
dev-team ad022ffcf4 fix: v0.8.1 静止桌面黑屏 - 积压跳帧限定突发到达, 空闲重发帧不判陈旧不计E2E 2026-10-23 13:36:21 +08:00
yu 9c340813a8 feat(agent): 新增 agent-core 与 Linux 被控端(X11 + XTest,纯新增不动 agent/)
本期只在新增文件里做,**未改动 Windows agent/ 一个字节**(与 NVENC 线并行,避免争抢)。

- 新增 agent-core:平台无关会话层 —— QUIC/TLS 握手、传输调优(沿用 512KB 发送窗口
  结论)、验证码认证、会话/select 循环、latest-wins 单槽、输入分发,以及软件编码
  视频管线(openh264)。同步跟进协议新增的 Pong{lost,sent} 下行丢包统计字段
- 新增 linux-agent:X11 GetImage 采集 + 整帧变更检测;XTest 注入 + 扫描码→X11
  keycode 映射(主键区 = scancode+8,E0 扩展键必须查表,含单元测试)
- 协议零改动:复用 Set-1 扫描码与虚拟桌面归一化坐标,viewer 无需任何修改
- 新增端到端冒烟测试 tests/smoke.rs 与 scripts/linux-smoke.sh(自建 Xvfb,无需
  图形桌面):采集→编码→QUIC 解帧、认证(含错误码拒绝)、Ping/Pong(含丢包字段)、
  静止桌面关键帧恢复、输入注入实证(注入后从另一条 X11 连接读回指针真实坐标)
- 该关键帧用例做过变异测试:把修复退回会失败、恢复后通过,确认断言有效
- .gitattributes:shell 脚本强制 LF(Windows checkout 成 CRLF 会导致容器内无法执行)
- 手册:修正已失效的工具链路径;补充 cargo 镜像;新增第 8 节(Linux)与 6.1 节
  (三条待修问题,含两条 Windows 侧仍存在的关键帧缺陷)

验证:
- Linux(容器, USTC 源 + Xvfb): cargo test -p linux-agent 通过,2 passed
- Windows: 本期未改 agent/,default-members 未变,`cargo build --release` 行为不变
2026-10-23 13:31:08 +08:00
dev-team 8dd18a06a1 fix: E2E 积压 - 陈旧帧跳过解码排空 QUIC 队列 + rgb 缓冲复用 + 版本号升 v0.8 2026-10-23 13:27:18 +08:00
dev-team 04c02d9ced fix: 启动黑屏 - 静止桌面响应KeyFrameReq + 首帧强制IDR + 入会主动请求关键帧 2026-10-23 13:12:27 +08:00
dev-team 68b2ddc59a merge: E2E 排队修复(64KB 窗口 + 拥塞跳帧) 2026-10-23 12:48:17 +08:00
dev-team 168f600fe1 perf(agent): E2E 排队修复——发送窗口 512KB→64KB + 拥塞反馈自动跳帧
- 实测数据:FPS 61 / RTT 7ms / E2E 618ms → 延迟全在发送侧排队:
  512KB 窗口 ÷ WiFi 实际 ~3Mbps 吞吐 ≈ 1.3s 排队上限,latest-wins 管不到已进 QUIC 的字节
- send_window 64KB:排队硬上限 ~170ms@3Mbps;突发吞吐 64KB/7ms≈72Mbps 不受限;
  连接级窗口,流 2 输入数据量极小不受影响
- 拥塞反馈跳帧:send write 耗时 >20ms(单帧预算 1.2 倍) → 置位标志 → 采集线程
  跳过当前帧(NVENC 少输入一帧,参考链连续无花屏),注入率自适应匹配网络吞吐;
  capture 日志新增 skipped 计数
- viewer 标题版本 v0.7-LowLatency
- 回环实测:NVENC 4ms/帧、skipped 0(回环无拥塞符合预期)、零错误
2026-10-23 12:48:16 +08:00
dev-team bd7ca29945 merge: HUD 常显修复 2026-10-23 10:45:47 +08:00
dev-team 00b318af8b fix(viewer): HUD 每秒强制呈现(不依赖视频帧)+ 标题栏版本号
- HUD 此前仅在 dirty(收到新帧)时绘制,视频流异常/帧被丢弃时面板
  永不出现,且掩盖了排障信息;改为每秒统计刷新时强制 update_with_buffer
- 窗口标题加 v0.6-HUD 版本标记,用于识别误跑旧版 exe(排障关键)
- 回环实测:NVENC 4ms/帧、解码 1920x1080 匹配、14s 零错误
2026-10-23 10:45:46 +08:00
dev-team e00175385d merge: 修 HUD 停刷与帧率不稳(单槽 latest-wins + GOP 自愈) 2026-10-23 10:31:31 +08:00
dev-team 9c72aecc3b fix(viewer): 帧管线 latest-wins 单槽(修 NVENC 60fps 下积压假死/HUD 停刷)+ GOP 周期自愈
- viewer: 无界 mpsc → Mutex 单槽 latest-wins。根因:NVENC 60fps 到达率 > 渲染
  吞吐(~55fps),无界队列无限积压 → 延迟线性增长 + 渲染 drain 阻塞消息泵 →
  窗口假死、HUD 停刷(软编 20fps 时代掩盖,NVENC 后暴露)
- viewer: 解码帧尺寸与窗口解耦(blit 逐行拷贝,防 NVENC 对齐尺寸不匹配);
  首帧打印解码尺寸诊断
- agent: NVENC gop_len 0xffffffff→240(60fps 下 4s 周期 IDR 自愈恢复点,
  配合 KeyFrameReq 即时恢复,消除长时间冻结锯齿)
- 回环实测:NVENC 4ms/帧满速、解码 1920x1080 匹配、14s 零错误
2026-10-23 10:31:30 +08:00
dev-team 82ec166f23 merge: NVENC 硬编(4ms/帧@1080p,编码瓶颈解除) 2026-10-23 10:18:29 +08:00
dev-team ddbb53d41a feat(agent): NVENC 硬编接入(GPU 零拷贝 ARGB 直通,软编自动兜底)
- 编码器自动选择:NVENC(H264 High/CBR 5Mbps/UltraLowLatency/无B帧/按需IDR)→ openh264 兜底
- Duplication 增加 GPU 中间纹理(B8G8R8A8 DEFAULT),NVENC register_resource_dx11 直读,颜色转换完全省略
- KeyFrameReq → encode_picture(picture_type=IDR),静止 500ms 重编码 P 帧
- windows 0.58→0.62(nvenc crate 依赖 0.62,运行时 dlopen nvEncodeAPI64.dll 零 SDK 依赖)
- 实测(GTX 1650 Ti 回环):encode 41ms→4ms/帧(10x),convert 0ms,viewer 解码正常
2026-10-23 10:18:29 +08:00
dev-team 94c45aff3f merge: FPS 调优(60fps上限/零拷贝/E2E 修复) 2026-10-23 10:02:42 +08:00
dev-team e7472fa12f perf(agent): 60fps上限/零拷贝转换/多线程编码/发送缓冲512KB + encbench基准
- capture: min_frame 33→16ms,RC 60fps;with_mapped 映射窗口内直接 BGRA→YUV(行距连续零拷贝)
- capture: num_threads(4) + 每120帧输出 convert/encode 耗时(实测:convert 2ms,encode 41ms/帧真实工况)
- agent: send_window 2MB→512KB,E2E 排队天花板 ~0.15s(修复 E2E 633ms 红)
- encbench: 1080p 编码基准(合成 5ms/帧,真实工况 41ms/帧 → 软编 1080p 上限 ~24fps,60fps 需 NVENC)
- tools/bench.md
2026-10-23 10:02:41 +08:00
dev-team 881341be3a merge: B1 联调基线(HUD 扩展 + bench 脚本) 2026-10-23 09:47:10 +08:00
dev-team 7dbb04da17 feat(hud): B1 基线指标——抖动/帧间隔p95/丢包率/解码失败计数 + 测试脚本
- common: Pong 扩展 lost/sent(agent 发送方向 QUIC 计数,经交叉评审项)
- agent: Pong 附带 conn.stats().path 丢包统计
- viewer: Stats 加 GapRing(64)/jitter EMA/dec_fail;HUD 新增 JIT/P95/LOSS/DECFAIL 四行,% 字形
- tools/bench.md: 三场景基线测试脚本
2026-10-23 09:47:09 +08:00
dev-team 95049b6642 docs: B侧设计文档(联调基线/码率自适应/打洞中继/多屏文件) 2026-10-23 09:34:21 +08:00
28 changed files with 4422 additions and 116 deletions

No files matched your search

+15
View 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
View 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 | 码率 | 丢包 | 抖动 |
|---|---|---|---|---|---|---|
| 静止桌面 | | | | | | |
| 视频播放 | | | | | | |
| 拖动窗口 | | | | | | |
Generated
+144 -18
View File
@@ -9,6 +9,7 @@ dependencies = [
"anyhow",
"common",
"lz4_flex",
"nvenc",
"openh264",
"quinn",
"rcgen",
@@ -17,6 +18,19 @@ dependencies = [
"windows",
]
[[package]]
name = "agent-core"
version = "0.1.0"
dependencies = [
"anyhow",
"common",
"openh264",
"quinn",
"rcgen",
"rustls",
"tokio",
]
[[package]]
name = "anyhow"
version = "1.0.104"
@@ -44,6 +58,27 @@ dependencies = [
"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]]
name = "bitflags"
version = "1.3.2"
@@ -303,6 +338,16 @@ dependencies = [
"slab",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -452,6 +497,19 @@ dependencies = [
"redox_syscall 0.9.5",
]
[[package]]
name = "linux-agent"
version = "0.1.0"
dependencies = [
"agent-core",
"anyhow",
"common",
"quinn",
"rustls",
"tokio",
"x11rb",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -567,6 +625,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "once_cell"
version = "1.21.4"
@@ -1512,32 +1581,54 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.58.0"
version = "0.62.2"
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 = [
"windows-core",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.58.0"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"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]]
name = "windows-implement"
version = "0.58.0"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
@@ -1546,9 +1637,9 @@ dependencies = [
[[package]]
name = "windows-interface"
version = "0.58.0"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
@@ -1562,22 +1653,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.2.0"
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
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]]
name = "windows-strings"
version = "0.1.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-result",
"windows-targets",
"windows-link",
]
[[package]]
@@ -1614,6 +1714,15 @@ dependencies = [
"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]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -1673,6 +1782,23 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xcursor"
version = "0.3.11"
+7 -2
View File
@@ -1,6 +1,11 @@
[workspace]
resolver = "2"
members = ["common", "agent", "viewer"]
# agent-core:平台无关会话层 + 软件编码视频管线(当前仅 linux-agent 使用)
# linux-agent:Linux 被控端(X11 + XTest)
members = ["common", "agent-core", "agent", "linux-agent", "viewer"]
# 默认成员保持 Windows 三件套,`cargo build --release` 行为与改动前完全一致;
# Linux 被控端显式构建:`cargo build --release -p linux-agent`
default-members = ["common", "agent", "viewer"]
[workspace.dependencies]
anyhow = "1"
@@ -11,5 +16,5 @@ quinn = "0.11"
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "logging"] }
rcgen = { version = "0.13", default-features = false, features = ["ring", "pem"] }
lz4_flex = "0.11"
windows = "0.58"
windows = "0.62"
minifb = "0.27"
+1 -1
View File
@@ -27,4 +27,4 @@ agent.exe --port 47800 --code 123456
viewer.exe --addr <被控机IP> --port 47800 --code 123456
```
详见《构建运行手册.md》与《Git协作规范.md》。
详见《构建运行手册.md》、《Git协作规范.md》与《打洞与信令设计.md》。
+13
View 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
View 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(())
}
+1
View File
@@ -12,6 +12,7 @@ rustls.workspace = true
rcgen.workspace = true
lz4_flex.workspace = true
openh264 = { version = "0.9.9", features = ["source"] }
nvenc = "0.1.0"
windows = { workspace = true, features = [
"Win32_Foundation",
"Win32_Graphics_Direct3D",
+59
View 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);
}
+275 -51
View File
@@ -1,9 +1,17 @@
//! DXGI Desktop Duplication 采集(Windows 8+)。
//! 帧处理:BGRA → openh264 H.264 编码(屏幕内容实时档,5Mbps)→ 线格式。
//! 编码器自动选择:NVENC 硬编(N 卡:GPU 内 BGRA→H.264 零转换零拷贝直通)
//! → openh264 软编兜底(无 N 卡/驱动异常时自动启用)。
//! 支持主控端 KeyFrameReq 强制关键帧(解码异常恢复)。
use anyhow::{anyhow, Result};
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::{
BitRate, Encoder, EncoderConfig, FrameRate, IntraFramePeriod, RateControlMode, UsageType,
};
@@ -19,8 +27,9 @@ use windows::Win32::Graphics::Direct3D::{
};
use windows::Win32::Graphics::Direct3D11::{
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Resource, ID3D11Texture2D,
D3D11_CREATE_DEVICE_FLAG, D3D11_CPU_ACCESS_READ, D3D11_MAP, D3D11_MAP_READ,
D3D11_MAPPED_SUBRESOURCE, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC, D3D11_USAGE_STAGING,
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_CREATE_DEVICE_FLAG,
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::{
@@ -31,9 +40,12 @@ use windows::Win32::Graphics::Dxgi::{
use std::time::{Duration, Instant};
pub struct Duplication {
_device: ID3D11Device,
pub device: ID3D11Device,
context: ID3D11DeviceContext,
staging_res: ID3D11Resource,
/// GPU 内中间纹理(NVENC 注册目标;DEFAULT usage,BGRA 与采集一致)
gpu_res: ID3D11Resource,
gpu_tex: ID3D11Texture2D,
dup: IDXGIOutputDuplication,
pub width: u32,
pub height: u32,
@@ -47,13 +59,13 @@ impl Duplication {
D3D11CreateDevice(
None,
D3D_DRIVER_TYPE_HARDWARE,
HMODULE::default(),
HMODULE(std::ptr::null_mut()),
D3D11_CREATE_DEVICE_FLAG(0),
Some(&[D3D_FEATURE_LEVEL_11_0]),
D3D11_SDK_VERSION,
Some(&mut device),
Some(&raw mut device),
None,
Some(&mut context),
Some(&raw mut context),
)
.map_err(|e| anyhow!("D3D11CreateDevice failed: {e}"))?;
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);
anyhow::ensure!(width > 0 && height > 0, "bad desktop size");
// staging:软编路径 Map 读内存
let sdesc = D3D11_TEXTURE2D_DESC {
Width: width,
Height: height,
@@ -95,10 +108,26 @@ impl Duplication {
let staging = staging_opt.ok_or_else(|| anyhow!("no staging texture"))?;
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 {
_device: device,
device,
context,
staging_res,
gpu_res,
gpu_tex,
dup,
width,
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> {
unsafe {
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 src: ID3D11Resource = res.cast()?;
self.context.CopyResource(&self.staging_res, &src);
self.context.CopyResource(&self.gpu_res, &src);
let _ = self.dup.ReleaseFrame();
Ok(true)
}
@@ -125,8 +156,8 @@ impl Duplication {
}
}
/// 把 staging 纹理映射到内存并去掉行间距
pub fn copy_frame(&mut self) -> Result<Vec<u8>> {
/// 在 staging 映射窗口内执行回调(软编路径)。回调参数 = (像素数据, 行距)。
pub fn with_mapped<R>(&mut self, f: impl FnOnce(&[u8], usize) -> R) -> Result<R> {
unsafe {
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
self.context
@@ -135,16 +166,108 @@ impl Duplication {
let (w, h) = (self.width as usize, self.height as usize);
let pitch = mapped.RowPitch as usize;
let src = std::slice::from_raw_parts(mapped.pData as *const u8, pitch * h);
let mut out = Vec::with_capacity(w * h * 4);
for row in 0..h {
out.extend_from_slice(&src[row * pitch..row * pitch + w * 4]);
}
let r = f(src, pitch);
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)]
pub fn desktop_size() -> Result<(u32, u32)> {
let d = Duplication::new()?;
@@ -153,7 +276,8 @@ pub fn desktop_size() -> Result<(u32, u32)> {
/// 采集主循环:独立线程运行;连接断开(通道关闭)时退出。
/// 通道为 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();
loop {
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
);
// H.264 编码器:屏幕内容实时档 + 码率控制(对标 UU:3.2Mbps@60fps)
let mut encoder = match Encoder::with_api_config(
// ---- 编码器自动选择:NVENC 硬编优先,失败落回 openh264 软编 ----
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(),
EncoderConfig::new()
.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)
.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) => {
eprintln!("[capture] encoder init failed: {e}");
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 min_frame = Duration::from_millis(33); // 30fps 上限
loop {
let iter_start = Instant::now();
match dup.acquire(80) {
Ok(true) => match dup.copy_frame() {
Ok(raw) => {
// 主控端请求关键帧(解码异常恢复)
if keyreq.swap(false, Ordering::Relaxed) {
encoder.force_intra_frame();
}
let min_frame = Duration::from_millis(16); // 60fps 上限
let mut scratch: Vec<u8> = Vec::new(); // 软编:行距不连续时的复用缓冲
// 编码耗时统计(每 120 帧输出一次均值,ms)
let mut stat_frames = 0u64;
let mut stat_convert_ms = 0u64;
let mut stat_encode_ms = 0u64;
let mut stat_skipped = 0u64; // 拥塞跳帧计数
macro_rules! emit {
($payload:expr) => {{
let header = VideoFrameHeader {
width: dup.width,
height: dup.height,
ts_ms: start.elapsed().as_millis() as u64,
};
let yuv = YUVBuffer::from_bgra8_source(BgraSliceU8::new(
&raw,
(dup.width as usize, dup.height as usize),
));
match encoder.encode(&yuv) {
Ok(ebs) => {
let payload = ebs.to_vec();
let frame = encode_video_frame(&header, &payload);
let frame = encode_video_frame(&header, &$payload);
if tx.send(Some(frame.clone())).is_err() {
return; // 会话结束
}
last_frame = Some(frame);
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) => {
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();
if el < min_frame {
std::thread::sleep(min_frame - el);
}
}
Err(e) => {
eprintln!("[capture] frame copy failed: {e}");
break; // 重建 duplication
}
},
Ok(false) => {
// 桌面静止:低频重发最后一帧(重复 P 帧,解码端画面不变)
// 桌面静止:低频重发(丢包后恢复 + 会话保活)。
// 关键:KeyFrameReq 在桌面静止时也必须响应 —— 此前只在新帧路径
// 消费该标志,桌面完全静止时解码端请求 IDR 永远得不到响应 → 黑屏。
let force_key = keyreq.swap(false, Ordering::Relaxed);
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 tx.send(Some(f.clone())).is_err() {
return;
let _ = tx.send(Some(f.clone()));
}
}
}
last_sent = Instant::now();
}
} else {
std::thread::sleep(Duration::from_millis(8));
}
+26 -7
View File
@@ -43,12 +43,13 @@ async fn main() -> Result<()> {
// 传输参数调优:
// - 收发窗口放大,避免高 RTT 下吞吐被 窗口/RTT 死锁(默认 512KB 流窗口)
// - 发送缓冲上限 2MB:QUIC 内部排队有界(约 1 帧多),配合上游 latest-wins 丢帧
// 把端到端排队延迟压在 ~0.4s 内
// - 发送缓冲上限 64KB:QUIC 内部排队硬上限(实测 WiFi ~3Mbps 吞吐 → E2E ≤ ~170ms;
// 突发吞吐 = 64KB/7ms ≈ 72Mbps,不限制带宽)。注意此窗口为连接级,流 2 输入/Ping
// 数据量极小不受影响。配合下方拥塞反馈跳帧,把排队压在数帧以内
let mut transport = quinn::TransportConfig::default();
transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024));
transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024));
transport.send_window(2 * 1024 * 1024);
transport.send_window(64 * 1024);
let mut server_config = quinn::ServerConfig::with_single_cert(vec![cert], key.into())?;
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 keyreq = Arc::new(AtomicBool::new(false));
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,避免多任务争抢
let (ctl_tx, mut ctl_rx) = mpsc::unbounded_channel::<ControlMsg>();
let t0 = std::time::Instant::now();
// 读循环(流 2):输入事件 + Ping
// 读循环(流 2):输入事件 + Ping;Pong 附带视频下行方向的 QUIC 丢包统计
// (conn.stats().path 统计的是本端发送方向,agent 的发送方向即视频下行)
let keyreq_reader = keyreq.clone();
let conn_stats = conn.clone();
let reader = tokio::spawn(async move {
loop {
match read_control(&mut irecv).await {
@@ -104,7 +110,13 @@ async fn main() -> Result<()> {
}
Ok(ControlMsg::Ping(t)) => {
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(_) => {}
Err(_) => break,
@@ -119,7 +131,14 @@ async fn main() -> Result<()> {
break; // 采集线程退出
}
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;
}
}
+5 -1
View File
@@ -1,5 +1,8 @@
//! 通用协议与帧封装:控制消息(bincode + 长度前缀)与视频帧格式。
/// P2P 打洞信令协议(信令通道专用,不含中继;三端共享契约)
pub mod signaling;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -15,7 +18,8 @@ pub enum ControlMsg {
Ping(u64),
/// t=主控端发送时刻(主控时钟),agent_ts=被控端收到时刻(被控时钟)
/// 两者配合可估计时钟偏移,进而用帧头 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),
}
+757
View File
@@ -0,0 +1,757 @@
//! P2P 打洞信令协议(`common/` 契约,agent / viewer / 信令服务三端共享)。
//!
//! ## 职责边界(重要,改动前必读)
//! - **信令服务器只做两件事**:① 把双方的外部视角(公网映射地址 + NAT 行为)拼起来回推给对端;
//! ② 下发同步打洞计划,让双方在同一绝对时刻开始发包。建连完成后信令与本次会话再无关系,
//! 不承载任何媒体流量。
//! - **本协议不含中继**(无 relay / TURN 消息)。打洞失败就是失败,因此必须把原因讲清楚
//! (见 [`PunchFailReason::user_hint`]),不允许让用户停在"连接中"。
//! - 会话建立后走 QUIC:**不要**再用 viewer 现有的 `SkipVerify`,改用
//! [`EndpointInfo::cert_fingerprint`] 做公钥钉扎。
//!
//! ## 线格式
//! 类型只依赖 `serde`,不绑定具体编码。建议信令走 WSS/443(最容易穿过企业防火墙),
//! 编码用 JSON 便于抓包排查;本模块单元测试用 bincode 做往返校验,两者不冲突。
//!
//! ## 一次会话的时序
//! ```text
//! agent 信令服务器 viewer
//! |-- Register(info) ------->|<------- Register(info) -----|
//! |<-- Registered(observed) -|------ Registered(observed) ->|
//! |<-- PeerReady(peer info) -|------ PeerReady(peer info) -->|
//! |<-- PunchPlan(start_in) --|------ PunchPlan(start_in) --->|
//! | (双方同时向对端候选列表发包,多轮) |
//! |<========= 打通:UDP 直连 → QUIC 握手 + 证书钉扎 =========>|
//! |--- PunchReport(Connected) ->|<---- PunchReport ----------|
//! ```
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, SocketAddr};
// ---------------------------------------------------------------------------
// 常量
// ---------------------------------------------------------------------------
/// 信令协议版本。双方不一致时服务器应拒绝并提示升级,不要尝试兼容旧版。
pub const SIGNALING_VERSION: u16 = 1;
/// 打洞用的 UDP 端口,与 agent 当前监听端口一致。
/// 固定端口对两件事有直接好处:NAT 端口预测、以及用户手动做端口转发。
pub const PUNCH_UDP_PORT: u16 = 47800;
/// 信令默认端口。走 WSS/443 是因为企业防火墙通常只放行 80/443。
pub const SIGNALING_DEFAULT_PORT: u16 = 443;
/// 单次打洞的默认轮数。
pub const PUNCH_DEFAULT_ROUNDS: u8 = 3;
/// 打洞轮间隔(毫秒)。太短来不及让 NAT 建立映射,太长用户等不及。
pub const PUNCH_ROUND_INTERVAL_MS: u16 = 200;
/// 候选地址数量上限(含预测端口)。超出即截断,防止滥用与报文膨胀。
pub const MAX_CANDIDATES: usize = 32;
/// NAT 绑定保活周期的下限/上限(秒)。
pub const KEEPALIVE_MIN_S: u16 = 5;
pub const KEEPALIVE_MAX_S: u16 = 60;
/// 未测出 NAT 绑定超时时的保活默认值(秒)。家用 NAT 的 UDP 映射普遍 30s 左右超时。
pub const KEEPALIVE_DEFAULT_S: u16 = 15;
// ---------------------------------------------------------------------------
// 基础类型
// ---------------------------------------------------------------------------
/// 端点角色。打洞成功后 agent 作为 QUIC server,viewer 主动 connect。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Role {
/// 被控端
Agent,
/// 主控端
Viewer,
}
impl Role {
pub fn peer(self) -> Role {
match self {
Role::Agent => Role::Viewer,
Role::Viewer => Role::Agent,
}
}
}
/// 候选地址的来源,决定打洞优先级。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CandidateKind {
/// 本机网卡地址(同局域网直连用,最省事)
Host,
/// 经 UPnP / NAT-PMP / PCP 主动申请的端口映射(把对称 NAT 变成可直连)
Upnp,
/// 信令服务器观察到的公网映射(等价于一次 STUN binding,server-reflexive)
Reflexive,
/// 按 NAT 端口分配规律预测出的端口
Predicted,
}
/// 一个候选地址。IPv6 有公网地址时基本等同直连,因此固定排在 IPv4 之前。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Candidate {
pub addr: SocketAddr,
pub kind: CandidateKind,
}
impl Candidate {
pub fn new(addr: SocketAddr, kind: CandidateKind) -> Self {
Self { addr, kind }
}
pub fn is_ipv6(&self) -> bool {
self.addr.is_ipv6()
}
/// 打分越高越先打。IPv6(+100)> UPnP 显式映射 > 本机地址 > 公网反射 > 端口预测。
pub fn score(&self) -> u16 {
let base = match self.kind {
CandidateKind::Upnp => 90,
CandidateKind::Host => 80,
CandidateKind::Reflexive => 60,
CandidateKind::Predicted => 40,
};
if self.is_ipv6() {
base + 100
} else {
base
}
}
}
/// 按优先级从高到低排序(`sort_by` 是稳定排序,同分保持调用方给出的顺序)。
pub fn sort_candidates(v: &mut [Candidate]) {
v.sort_by(|a, b| b.score().cmp(&a.score()));
}
/// 清洗对端送来的候选列表:丢弃端口 0、按地址去重、截断到 [`MAX_CANDIDATES`]。
///
/// 对端数据一律视为不可信输入;打洞阶段每个候选都会产生实际发包,必须设上限。
pub fn sanitize_candidates(v: Vec<Candidate>) -> Vec<Candidate> {
let mut out: Vec<Candidate> = Vec::with_capacity(v.len().min(MAX_CANDIDATES));
for c in v {
if c.addr.port() == 0 {
continue;
}
if out.iter().any(|e| e.addr == c.addr) {
continue;
}
if out.len() == MAX_CANDIDATES {
break;
}
out.push(c);
}
out
}
// ---------------------------------------------------------------------------
// NAT 画像与诊断
// ---------------------------------------------------------------------------
/// RFC 4787 §4.1 映射行为:同一个内网 socket 的公网端口是否随目标变化。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MappingBehavior {
/// 端点无关(full cone / restricted cone):打洞最理想
EndpointIndependent,
/// 地址相关
AddressDependent,
/// 地址+端口相关(即"对称 NAT")
AddressAndPortDependent,
Unknown,
}
/// RFC 4787 §5.1 过滤行为:外部包能否进得来。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FilteringBehavior {
EndpointIndependent,
AddressDependent,
AddressAndPortDependent,
Unknown,
}
/// 公网端口分配规律,决定端口预测是否可用。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PortAllocation {
/// 保持内网端口
Preserved,
/// 递增分配(可预测,端口预测值得一试)
Sequential,
/// 随机(端口预测无效)
Random,
Unknown,
}
/// NAT 行为画像。信令握手阶段顺带测出,用于:① 决定打洞策略;② 失败时给用户解释。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct NatProfile {
pub mapping: MappingBehavior,
pub filtering: FilteringBehavior,
pub allocation: PortAllocation,
/// NAT 绑定超时(秒)。未测出为 `None`,此时用 [`KEEPALIVE_DEFAULT_S`]。
pub binding_timeout_s: Option<u16>,
/// 是否支持 hairpinning(同一 NAT 内的两端可互连)
pub hairpinning: bool,
}
impl Default for NatProfile {
fn default() -> Self {
Self {
mapping: MappingBehavior::Unknown,
filtering: FilteringBehavior::Unknown,
allocation: PortAllocation::Unknown,
binding_timeout_s: None,
hairpinning: false,
}
}
}
/// 打洞前景评估,直接用于 UI 提示与"是否值得继续重试"的决策。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PunchOutlook {
/// 锥形 NAT 或公网 IPv6,基本必成
Likely,
/// 需要多轮重试 / 端口预测,可能成
Possible,
/// 对称 NAT 且端口不可预测,通常打不通
Unlikely,
/// 运营商级 NAT(CGNAT),无中继方案下无解 —— 不必浪费多轮打洞
Hopeless,
}
/// 运营商级 NAT 判定:RFC 6598 共享地址空间 `100.64.0.0/10`。
///
/// 命中即无中继不可达,应立刻给出"换网络"提示,而不是让用户等完所有打洞轮次。
pub fn is_cgnat(observed: IpAddr) -> bool {
match observed {
IpAddr::V4(v4) => {
let o = v4.octets();
o[0] == 100 && (64..=127).contains(&o[1])
}
IpAddr::V6(_) => false,
}
}
/// 综合 NAT 画像与观察到的公网地址,评估打洞前景。
///
/// `observed_public` 是信令服务器看到的本端公网映射(IPv6 时即本机全局地址)。
pub fn punch_outlook(nat: &NatProfile, observed_public: IpAddr) -> PunchOutlook {
// IPv6 没有 NAT 可言,只要双方都有公网 v6 就是真直连。
if observed_public.is_ipv6() {
return PunchOutlook::Likely;
}
if is_cgnat(observed_public) {
return PunchOutlook::Hopeless;
}
match nat.mapping {
MappingBehavior::EndpointIndependent => PunchOutlook::Likely,
MappingBehavior::AddressDependent => PunchOutlook::Possible,
MappingBehavior::AddressAndPortDependent => match nat.allocation {
// 对称 NAT 但端口可预测 → 端口预测 + 多轮重试仍有机会
PortAllocation::Sequential | PortAllocation::Preserved => PunchOutlook::Possible,
PortAllocation::Random | PortAllocation::Unknown => PunchOutlook::Unlikely,
},
// 测不出来就按"可能"处理,先试再说
MappingBehavior::Unknown => PunchOutlook::Possible,
}
}
/// 双方协商保活周期(秒):取双方绑定超时的较小值再折半,并夹在
/// [`KEEPALIVE_MIN_S`]..=[`KEEPALIVE_MAX_S`] 之间。
///
/// 洞是靠出站包维持的,超时后会静默关闭 —— 表现为"连上了一会儿就断"。
pub fn keepalive_secs(mine: &NatProfile, theirs: &NatProfile) -> u16 {
let t = match (mine.binding_timeout_s, theirs.binding_timeout_s) {
(Some(a), Some(b)) => a.min(b),
(Some(a), None) => a,
(None, Some(b)) => b,
(None, None) => KEEPALIVE_DEFAULT_S,
};
(t / 2).clamp(KEEPALIVE_MIN_S, KEEPALIVE_MAX_S)
}
/// 服务器侧计算某个客户端的打洞启动延迟(相对 PunchPlan 送达时刻)。
///
/// 两侧 RTT 不同:若下发同一个延迟,RTT 大的一方会晚发包,洞就打不上。
/// 以双方最大 RTT 为基准、减去自身 RTT 的一半,即可在**不做时钟同步**的前提下
/// 让两侧的绝对开打时刻对齐(误差约等于单程抖动)。
///
/// ```text
/// start_in_ms = max_rtt_ms / 2 + margin_ms - own_rtt_ms / 2
/// ```
pub fn punch_start_delay_ms(own_rtt_ms: u16, max_rtt_ms: u16, margin_ms: u16) -> u16 {
let base = max_rtt_ms as u32 / 2 + margin_ms as u32;
let own_half = own_rtt_ms as u32 / 2;
base.saturating_sub(own_half).min(u16::MAX as u32) as u16
}
// ---------------------------------------------------------------------------
// 证书指纹(替代 viewer 现有的 SkipVerify)
// ---------------------------------------------------------------------------
/// 自签证书公钥指纹(SHA-256 的 32 字节裸值)。
///
/// agent 生成自签证书后把指纹经信令发布,viewer 用它对 QUIC 连接做**公钥钉扎**,
/// 从而去掉 `viewer/src/main.rs` 里的 `SkipVerify`(公网环境不可接受)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CertFingerprint(pub [u8; 32]);
impl CertFingerprint {
/// 小写十六进制(64 字符)
pub fn to_hex(&self) -> String {
self.0.iter().map(|b| format!("{b:02x}")).collect()
}
/// 解析 64 字符十六进制;长度不符或含非十六进制字符返回 `None`。
pub fn from_hex(s: &str) -> Option<Self> {
let s = s.trim();
if s.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
let hi = (chunk[0] as char).to_digit(16)?;
let lo = (chunk[1] as char).to_digit(16)?;
out[i] = (hi * 16 + lo) as u8;
}
Some(Self(out))
}
}
impl std::fmt::Display for CertFingerprint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.to_hex())
}
}
// ---------------------------------------------------------------------------
// 端点信息与消息
// ---------------------------------------------------------------------------
/// 连通性相关的能力标志。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Capabilities {
/// 已成功获得 UPnP / NAT-PMP / PCP 端口映射
pub upnp_mapped: bool,
/// 本机具备可用 IPv6 出口
pub ipv6_available: bool,
}
/// 一个端点的完整连通性描述,随 Register / PeerReady 交换。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EndpointInfo {
pub role: Role,
/// 本端打洞用的 UDP 监听端口(通常等于 [`PUNCH_UDP_PORT`])
pub port: u16,
/// 候选地址,发送前请用 [`sort_candidates`] 排好序
pub candidates: Vec<Candidate>,
pub nat: NatProfile,
/// QUIC 证书公钥指纹,供对端钉扎
pub cert_fingerprint: CertFingerprint,
pub caps: Capabilities,
}
/// 打洞失败原因。**无中继方案的用户体验下限就在这里** —— 每种原因都要能翻译成
/// 用户能照做的一句话,见 [`PunchFailReason::user_hint`]。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PunchFailReason {
/// 所有候选都无回包(最常见:双方都是地址+端口相关型 NAT)
NoCandidateReachable,
/// 双方均为对称 NAT 且端口不可预测
SymmetricNat,
/// 运营商级 NAT(100.64/10)
Cgnat,
/// 本机 UDP 出站被策略封锁(企业防火墙常见)
UdpBlocked,
/// 对端提前退出
PeerAborted,
/// 超时未收到任何回包
Timeout,
}
impl PunchFailReason {
/// 面向用户的中文处置建议。不要吞掉这个信息 —— 用户看不到原因就只会认为"这软件连不上"。
pub fn user_hint(self) -> &'static str {
match self {
PunchFailReason::NoCandidateReachable => {
"双方网络都无法直接互通。请在被控端路由器开启 UPnP 后重试,或双方改用 IPv6 网络。"
}
PunchFailReason::SymmetricNat => {
"当前网络是对称型 NAT,无法直连。请在被控端路由器开启 UPnP,或更换网络后重试。"
}
PunchFailReason::Cgnat => {
"当前处于运营商级 NAT(大内网),无法直连。请改用 IPv6 网络,或换一条能拿到公网地址的线路。"
}
PunchFailReason::UdpBlocked => {
"当前网络的防火墙拦截了 UDP 出站。请更换网络(如切换手机热点)后重试。"
}
PunchFailReason::PeerAborted => "对端已取消本次连接。",
PunchFailReason::Timeout => "连接超时,可能是网络波动。请重试一次。",
}
}
}
/// 打洞结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PunchOutcome {
/// 打通:`peer` 为真正生效的对端地址,`rtt_ms` 为实测往返
Connected { peer: SocketAddr, rtt_ms: u16 },
Failed { reason: PunchFailReason },
}
impl PunchOutcome {
pub fn is_connected(&self) -> bool {
matches!(self, PunchOutcome::Connected { .. })
}
/// 已连通返回 `None`,失败返回给用户看的建议。
pub fn user_hint(&self) -> Option<&'static str> {
match self {
PunchOutcome::Connected { .. } => None,
PunchOutcome::Failed { reason } => Some(reason.user_hint()),
}
}
}
/// 信令层错误码。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SignalError {
/// [`SIGNALING_VERSION`] 不匹配(客户端需升级)
VersionMismatch,
/// 验证码错误或已过期
BadAuth,
SessionNotFound,
PeerOffline,
/// 候选地址数量超限
TooManyCandidates,
RateLimited,
Internal,
}
/// 客户端 → 信令服务器。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ClientMsg {
/// 注册并等待对端。`auth` 必须是一次性验证码(不是 MVP 里的固定 123456)。
Register {
version: u16,
/// 会话/设备标识;服务器据此配对两端
session: String,
auth: String,
info: EndpointInfo,
},
/// 候选变化补报:UPnP 映射稍后才成功、或网卡地址变化时用。
UpdateCandidates {
session: String,
candidates: Vec<Candidate>,
nat: NatProfile,
},
/// 打洞结果回报。服务器可据此记录有效路径、统计成功率。
PunchReport {
session: String,
plan_id: u32,
outcome: PunchOutcome,
},
/// 保活:维持信令长连接,同时刷新本端 NAT 绑定。
Keepalive { session: String },
Bye {
session: String,
reason: String,
},
}
/// 信令服务器 → 客户端。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ServerMsg {
/// 注册成功。`observed` 是服务器看到的本端公网映射,等价于一次 STUN binding;
/// `rtt_ms` 供打洞计划做时间对齐(见 [`punch_start_delay_ms`])。
Registered {
session: String,
observed: SocketAddr,
/// 对端是否已在场。`false` 时应等待 [`ServerMsg::PeerReady`]。
peer_present: bool,
rtt_ms: u16,
},
/// 对端已就绪,附带其候选地址、NAT 画像与证书指纹。
PeerReady {
peer: EndpointInfo,
peer_observed: SocketAddr,
/// 打洞前景,可直接显示给用户("很可能直连成功" / "当前网络可能无法直连")
outlook: PunchOutlook,
},
/// 同步打洞计划。
///
/// `start_in_ms` 是**相对本消息**的延迟,服务器对两侧下发不同的值以使双方在同一
/// 绝对时刻开打 —— 因此本协议不要求两端时钟同步。
PunchPlan {
plan_id: u32,
start_in_ms: u16,
rounds: u8,
round_interval_ms: u16,
/// 对端候选(已排序、已清洗);本端需对其中每个地址发包
peer_candidates: Vec<Candidate>,
},
/// 打洞结果确认(用于 UI 显示"直连成功,RTT xx ms")。
PunchAck { plan_id: u32, outcome: PunchOutcome },
Error { code: SignalError, message: String },
}
// ---------------------------------------------------------------------------
// 测试
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn fp(byte: u8) -> CertFingerprint {
CertFingerprint([byte; 32])
}
fn info(role: Role) -> EndpointInfo {
let mut candidates = vec![
Candidate::new("1.2.3.4:47800".parse().unwrap(), CandidateKind::Reflexive),
Candidate::new("[2001:db8::1]:47800".parse().unwrap(), CandidateKind::Host),
Candidate::new("192.168.1.5:47800".parse().unwrap(), CandidateKind::Upnp),
];
sort_candidates(&mut candidates);
EndpointInfo {
role,
port: PUNCH_UDP_PORT,
candidates,
nat: NatProfile {
mapping: MappingBehavior::AddressDependent,
filtering: FilteringBehavior::AddressAndPortDependent,
allocation: PortAllocation::Sequential,
binding_timeout_s: Some(30),
hairpinning: false,
},
cert_fingerprint: fp(0xab),
caps: Capabilities {
upnp_mapped: true,
ipv6_available: true,
},
}
}
fn client_msgs() -> Vec<ClientMsg> {
vec![
ClientMsg::Register {
version: SIGNALING_VERSION,
session: "dev-1".into(),
auth: "0f3a91".into(),
info: info(Role::Agent),
},
ClientMsg::UpdateCandidates {
session: "dev-1".into(),
candidates: vec![Candidate::new(
"5.6.7.8:47800".parse().unwrap(),
CandidateKind::Predicted,
)],
nat: NatProfile::default(),
},
ClientMsg::PunchReport {
session: "dev-1".into(),
plan_id: 7,
outcome: PunchOutcome::Connected {
peer: "9.9.9.9:47800".parse().unwrap(),
rtt_ms: 42,
},
},
ClientMsg::PunchReport {
session: "dev-1".into(),
plan_id: 8,
outcome: PunchOutcome::Failed {
reason: PunchFailReason::Cgnat,
},
},
ClientMsg::Keepalive {
session: "dev-1".into(),
},
ClientMsg::Bye {
session: "dev-1".into(),
reason: "user quit".into(),
},
]
}
fn server_msgs() -> Vec<ServerMsg> {
vec![
ServerMsg::Registered {
session: "dev-1".into(),
observed: "203.0.113.9:51234".parse().unwrap(),
peer_present: true,
rtt_ms: 38,
},
ServerMsg::PeerReady {
peer: info(Role::Viewer),
peer_observed: "203.0.113.10:51235".parse().unwrap(),
outlook: PunchOutlook::Likely,
},
ServerMsg::PunchPlan {
plan_id: 9,
start_in_ms: 130,
rounds: PUNCH_DEFAULT_ROUNDS,
round_interval_ms: PUNCH_ROUND_INTERVAL_MS,
peer_candidates: info(Role::Viewer).candidates,
},
ServerMsg::PunchAck {
plan_id: 9,
outcome: PunchOutcome::Failed {
reason: PunchFailReason::SymmetricNat,
},
},
ServerMsg::Error {
code: SignalError::VersionMismatch,
message: "server expects v1".into(),
},
]
}
#[test]
fn bincode_roundtrip_client_msgs() {
for m in client_msgs() {
let bytes = bincode::serialize(&m).expect("serialize");
let back: ClientMsg = bincode::deserialize(&bytes).expect("deserialize");
assert_eq!(m, back);
}
}
#[test]
fn bincode_roundtrip_server_msgs() {
for m in server_msgs() {
let bytes = bincode::serialize(&m).expect("serialize");
let back: ServerMsg = bincode::deserialize(&bytes).expect("deserialize");
assert_eq!(m, back);
}
}
#[test]
fn candidates_sorted_ipv6_first_then_upnp() {
let mut v = vec![
Candidate::new("1.2.3.4:47800".parse().unwrap(), CandidateKind::Reflexive),
Candidate::new("[2001:db8::1]:47800".parse().unwrap(), CandidateKind::Host),
Candidate::new("192.168.1.5:47800".parse().unwrap(), CandidateKind::Upnp),
];
sort_candidates(&mut v);
assert!(v[0].is_ipv6(), "IPv6 候选必须排第一");
assert_eq!(v[1].kind, CandidateKind::Upnp);
assert_eq!(v[2].kind, CandidateKind::Reflexive);
}
#[test]
fn sanitize_dedups_and_bounds() {
let dup = Candidate::new("1.2.3.4:47800".parse().unwrap(), CandidateKind::Host);
let zero_port = Candidate::new("1.2.3.4:0".parse().unwrap(), CandidateKind::Host);
let mut v = vec![dup, dup, zero_port];
v.extend((0..MAX_CANDIDATES + 5).map(|i| {
Candidate::new(
format!("10.0.{}.{}:47800", i / 250, i % 250).parse().unwrap(),
CandidateKind::Predicted,
)
}));
let out = sanitize_candidates(v);
assert_eq!(out.len(), MAX_CANDIDATES);
assert!(out.iter().all(|c| c.addr.port() != 0));
let mut addrs: Vec<_> = out.iter().map(|c| c.addr).collect();
addrs.sort();
addrs.dedup();
assert_eq!(addrs.len(), out.len());
}
#[test]
fn outlook_covers_ipv6_cgnat_and_symmetric() {
let v6: IpAddr = "2001:db8::1".parse().unwrap();
let public: IpAddr = "203.0.113.9".parse().unwrap();
let cgnat: IpAddr = "100.64.1.1".parse().unwrap();
let mut nat = NatProfile::default();
assert_eq!(punch_outlook(&nat, v6), PunchOutlook::Likely);
assert_eq!(punch_outlook(&nat, cgnat), PunchOutlook::Hopeless);
assert!(is_cgnat("100.127.255.254".parse().unwrap()));
assert!(!is_cgnat("100.128.0.1".parse().unwrap()));
nat.mapping = MappingBehavior::EndpointIndependent;
assert_eq!(punch_outlook(&nat, public), PunchOutlook::Likely);
nat.mapping = MappingBehavior::AddressAndPortDependent;
nat.allocation = PortAllocation::Sequential;
assert_eq!(punch_outlook(&nat, public), PunchOutlook::Possible);
nat.allocation = PortAllocation::Random;
assert_eq!(punch_outlook(&nat, public), PunchOutlook::Unlikely);
}
#[test]
fn keepalive_halves_binding_timeout_and_clamps() {
let a = NatProfile {
binding_timeout_s: Some(30),
..Default::default()
};
let b = NatProfile {
binding_timeout_s: Some(20),
..Default::default()
};
assert_eq!(keepalive_secs(&a, &b), 10);
assert_eq!(keepalive_secs(&NatProfile::default(), &NatProfile::default()), 7);
let short = NatProfile {
binding_timeout_s: Some(4),
..Default::default()
};
assert_eq!(keepalive_secs(&short, &short), KEEPALIVE_MIN_S);
let long = NatProfile {
binding_timeout_s: Some(600),
..Default::default()
};
assert_eq!(keepalive_secs(&long, &long), KEEPALIVE_MAX_S);
}
#[test]
fn punch_start_delay_aligns_absolute_time() {
// RTT 大的一方应该更早出发,使两侧绝对开打时刻一致。
let slow = punch_start_delay_ms(200, 200, 100);
let fast = punch_start_delay_ms(20, 200, 100);
assert_eq!(slow, 100);
assert_eq!(fast, 190);
assert!(fast > slow);
}
#[test]
fn cert_fingerprint_hex_roundtrip() {
let f = fp(0x0f);
let hex = f.to_hex();
assert_eq!(hex.len(), 64);
assert_eq!(CertFingerprint::from_hex(&hex), Some(f));
assert_eq!(CertFingerprint::from_hex(&hex.to_uppercase()), Some(f));
assert_eq!(CertFingerprint::from_hex("00"), None);
assert_eq!(CertFingerprint::from_hex(&"z".repeat(64)), None);
}
#[test]
fn fail_reasons_all_have_user_hints() {
let all = [
PunchFailReason::NoCandidateReachable,
PunchFailReason::SymmetricNat,
PunchFailReason::Cgnat,
PunchFailReason::UdpBlocked,
PunchFailReason::PeerAborted,
PunchFailReason::Timeout,
];
for r in all {
assert!(!r.user_hint().is_empty());
let o = PunchOutcome::Failed { reason: r };
assert!(!o.is_connected());
assert!(o.user_hint().is_some());
}
let ok = PunchOutcome::Connected {
peer: "1.2.3.4:47800".parse().unwrap(),
rtt_ms: 30,
};
assert!(ok.is_connected());
assert!(ok.user_hint().is_none());
}
}
+16
View 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
View 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
View 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
View 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
View 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
View 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
View 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
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""UDP 打洞探针 —— 两台不同网络的机器各跑一个,用来摸清你们线路的底牌。
回答四个问题:
1. 这条线路的 NAT 能不能打洞?
2. 要几轮才能打通?(轮数越少说明 NAT 越宽松)
3. 对方的源端口有没有被 NAT 改写?(判断是不是对称 NAT)
4. 有没有公网 IPv6?IPv6 直连通不通?
用法
----
A 机(假设公网 1.2.3.4):
py punch_probe.py --peer 2.3.4.5:47800
B 机(假设公网 2.3.4.5):
py punch_probe.py --peer 1.2.3.4:47800
IPv6(双方都有公网 v6 时):
py punch_probe.py --peer [2408:8207:1234::1]:47800
先查自己的公网映射(走 STUN,不需要对方):
py punch_probe.py --stun
被动监听(不发包,只看能不能收到对方打进来的包):
py punch_probe.py
说明
----
- 两台机器必须**同时**启动,脚本靠「双方同时向对方发包」来穿透 NAT。
- 先各自查公网 IP(浏览器搜 "my ip" 或 --stun),再互相填。
- 只依赖 Python 标准库。
"""
import argparse
import socket
import struct
import sys
import threading
import time
MAGIC = b"PUNCHv1|"
# ---------------------------------------------------------------- 地址解析
def parse_addr(s):
"""解析 'ip:port' 或 '[v6]:port' -> (host, port, family)"""
s = s.strip()
if s.startswith("["):
end = s.index("]")
host = s[1:end]
port = int(s[end + 2:])
return host, port, socket.AF_INET6
host, _, p = s.rpartition(":")
return host, int(p), socket.AF_INET
def fmt_addr(sockaddr):
host, port = sockaddr[0], sockaddr[1]
if ":" in host:
return "[%s]:%d" % (host, port)
return "%s:%d" % (host, port)
# ---------------------------------------------------------------- STUN
def stun_mapped(server="stun.l.google.com", port=19302, timeout=3.0, sock=None):
"""最小 STUN Binding 请求,返回 (ip, port);失败返回 None。
sock 不为 None 时复用它(用来测「同一个本地端口对另一个目标」的映射端口,
这是判断 NAT 是不是对称型的关键)。
"""
txid = b"\x5a" * 12
req = struct.pack(">HHI12s", 0x0001, 0, 0x2112A442, txid)
try:
infos = socket.getaddrinfo(server, port, 0, socket.SOCK_DGRAM)
except OSError:
return None
fam, sockaddr = infos[0][0], infos[0][4]
own = sock is None
s = socket.socket(fam, socket.SOCK_DGRAM) if own else sock
s.settimeout(timeout)
try:
s.sendto(req, sockaddr)
data, _ = s.recvfrom(2048)
except OSError:
return None
finally:
if own:
s.close()
if len(data) < 20:
return None
off = 20
while off + 4 <= len(data):
atype, alen = struct.unpack(">HH", data[off:off + 4])
val = data[off + 4:off + 4 + alen]
if atype == 0x0020 and len(val) >= 8: # XOR-MAPPED-ADDRESS
famv = val[1]
xport = struct.unpack(">H", val[2:4])[0] ^ 0x2112
if famv == 0x01 and len(val) >= 8:
ip = bytes(a ^ b for a, b in zip(val[4:8], b"\x21\x12\xA4\x42"))
return socket.inet_ntoa(ip), xport
if famv == 0x02 and len(val) >= 20:
mask = b"\x21\x12\xA4\x42" + txid
ip = bytes(a ^ b for a, b in zip(val[4:20], mask))
return socket.inet_ntop(socket.AF_INET6, ip), xport
off += 4 + alen + ((4 - alen % 4) % 4)
return None
# ---------------------------------------------------------------- 信令
def discover_peer(sock, server, room, timeout=60.0):
"""注册到房间,等服务器回推对方的公网地址。
注意:这里**复用同一个 socket**——服务器看到的源地址,就是 NAT 为
「本机 → 服务器」这条流分配的映射。对锥形 NAT 而言,它与「本机 → 对端」
的映射相同,所以拿它去打洞是有效的;对对称 NAT 则不同,打不通属预期。
"""
host, port = server[0], server[1]
deadline = time.time() + timeout
last_send = 0.0
while time.time() < deadline:
now = time.time()
if now - last_send > 1.0:
try:
sock.sendto(b"REGISTER|" + room.encode("utf-8"), (host, port))
except OSError as e:
print(" 注册发送失败:%s" % e)
return None
last_send = now
try:
data, _src = sock.recvfrom(2048)
except socket.timeout:
continue
except ConnectionResetError:
continue
except OSError as e:
if getattr(e, "winerror", None) == 10054:
continue
return None
txt = data.decode("utf-8", "replace").strip()
if txt.startswith("PEER|"):
h, p, _f = parse_addr(txt[5:])
return (h, p)
return None
# ---------------------------------------------------------------- 主逻辑
class Probe:
def __init__(self, bind_host, port, family):
self.sock = socket.socket(family, socket.SOCK_DGRAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if family == socket.AF_INET6:
try:
self.sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
except OSError:
pass
self.sock.bind((bind_host, port))
self.sock.settimeout(0.2)
# Windows 特有坑:曾经发过包给对方,但对方端口没开 → 会收到 ICMP 端口不可达,
# 之后下一次 recv 直接抛 WSAECONNRESET(10054)。不打这个 ioctl,
# 接收循环会被"自己之前的失败发送"打断,之后再也收不到包。
# (Rust 实现同样要处理:tokio recv 返回 ConnectionReset 时不能退出循环)
if sys.platform == "win32":
try:
self.sock.ioctl(socket.SIO_UDP_CONNRESET, False)
except (AttributeError, OSError):
pass
self.port = self.sock.getsockname()[1]
self.stop = threading.Event()
self.first_src = None
self.packets = 0
self.junk = 0
def receiver(self, ignore=()):
"""接收线程。
只认「以 MAGIC 开头」且「来自对端而非信令服务器」的包。
信令服务器的 PEER/WAIT 回复绝不能算作打通——否则客户端反复注册引来的
回复会造成假阳性(已踩过:把服务器的 PEER 回复误判成打洞成功)。
"""
while not self.stop.is_set():
try:
data, src = self.sock.recvfrom(2048)
except socket.timeout:
continue
except ConnectionResetError:
# ICMP 端口不可达留下的错误,忽略并继续收(绝不能 break)
continue
except OSError as e:
if getattr(e, "winerror", None) == 10054:
continue
break
# ---- 过滤:非打洞包一律丢弃
if not data.startswith(MAGIC) or (ignore and src[:2] in ignore):
self.junk += 1
continue
if self.first_src is None:
self.first_src = src
print("\n ✅ 收到对方的包!")
print(" 对方源地址(NAT 之后)= %s" % fmt_addr(src))
if src[1] != self.port:
print(" 对方源端口 = %d(本机端口 %d);若双方约定端口相同,"
"说明 NAT 改写了端口" % (src[1], self.port))
else:
print(" 对方源端口与约定端口一致 → NAT 保留了端口")
self.packets += 1
if self.packets <= 3:
print(" [%s] 收到 %d 字节" % (fmt_addr(src), len(data)))
def send_rounds(self, peer_sockaddr, rounds, interval):
for i in range(1, rounds + 1):
if self.stop.is_set():
return
payload = MAGIC + struct.pack(">d", time.time())
try:
self.sock.sendto(payload, peer_sockaddr)
except OSError as e:
print(" 发送失败:%s" % e)
return
if i == 1 or i % 5 == 0 or self.first_src is not None:
mark = "✅" if self.first_src is not None else " "
print(" %s 第 %2d/%d 轮已发出" % (mark, i, rounds))
if self.first_src is not None:
return
time.sleep(interval)
def main():
ap = argparse.ArgumentParser(add_help=True, description="UDP 打洞探针")
ap.add_argument("--peer", help="对方地址 ip:port 或 [v6]:port;不给则只被动监听")
ap.add_argument("--port", type=int, default=47800, help="本地端口(默认 47800)")
ap.add_argument("--rounds", type=int, default=25, help="发包轮数(默认 25)")
ap.add_argument("--interval", type=float, default=0.2, help="轮间隔秒(默认 0.2)")
ap.add_argument("--grace", type=float, default=2.5,
help="发完后继续监听多少秒等回包(默认 2.5)")
ap.add_argument("--server", help="信令服务器 ip:port(与 --room 配合,自动配对)")
ap.add_argument("--room", help="房间名;同一房间的两个客户端会被配对")
ap.add_argument("--wait", type=float, default=60.0,
help="等对方加入房间的秒数(默认 60)")
ap.add_argument("--stun", action="store_true", help="只查询自己的公网映射后退出")
ap.add_argument("--stun-port", type=int, default=0,
help="配合 --stun:用指定本地端口发 STUN(与打洞端口一致时才能对比映射)")
args = ap.parse_args()
# ---- 只查 STUN
if args.stun:
s = None
if args.stun_port:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", args.stun_port))
print("[STUN] 用本机端口 %d 查询(与打洞同端口)..." % args.stun_port)
else:
print("[STUN] 查询公网映射(临时端口)...")
r = stun_mapped(sock=s)
if s:
s.close()
if r:
print(" 对 Google STUN 的映射 = %s:%d" % r)
if args.stun_port:
print(" 本机端口 = %d" % args.stun_port)
print(" 下一步:对比信令服务器日志里记录的源端口")
print(" 两者相同 → 端点无关映射(锥形 NAT)→ 打洞大概率成功")
print(" 两者不同 → 对称 NAT → 打洞困难,需要端口预测或中继")
else:
print(" 查询失败(STUN 19302 可能被封;可稍后重试)")
return 0
# ---- 决定地址族与模式
peer = None
if args.peer:
host, pport, family = parse_addr(args.peer)
peer = (host, pport)
bind_host = "::" if family == socket.AF_INET6 else "0.0.0.0"
mode = "双向打洞(手工填地址)"
elif args.server and args.room:
_h, _p, family = parse_addr(args.server)
bind_host = "::" if family == socket.AF_INET6 else "0.0.0.0"
mode = "信令自动配对 + 打洞"
else:
family = socket.AF_INET
bind_host = "0.0.0.0"
mode = "被动监听"
probe = Probe(bind_host, args.port, family)
print("=" * 60)
print(" UDP 打洞探针 模式=%s" % mode)
print(" 本机监听 %s:%d" % (bind_host, probe.port))
if peer:
print(" 发往 %s (%s)" % (fmt_addr(peer + (0, 0)),
"IPv6" if family == socket.AF_INET6 else "IPv4"))
print("=" * 60)
# ---- 信令配对:不用自己查公网 IP,服务器看到的就是你的公网映射
if args.server and args.room and peer is None:
srv = parse_addr(args.server)[:2]
print("\n向信令服务器 %s 注册 room=%s ..." % (fmt_addr(srv), args.room))
found = discover_peer(probe.sock, srv, args.room, timeout=args.wait)
if found is None:
print(" ❌ 超时:没等到对方加入同一个房间")
probe.sock.close()
return 1
peer = found
print(" ✅ 配对成功,对方公网地址 = %s" % fmt_addr(found))
# 信令服务器的回复(PEER/WAIT)绝不能算作打通,接收线程必须把它排除掉
ignore = set()
if args.server:
_sh, _sp, _sf = parse_addr(args.server)
ignore.add((_sh, _sp))
t = threading.Thread(target=probe.receiver, args=(ignore,), daemon=True)
t.start()
try:
if peer:
print("\n开始同时发包(若对方也在发包,第 1~3 轮通常就通)...")
probe.send_rounds(peer, args.rounds, args.interval)
# 对方可能比我晚启动:发完后继续监听,否则会把"晚到的成功"误判成失败
if probe.first_src is None:
print("\n发包结束,继续监听 %.1f 秒等对方回包 ..." % args.grace)
waited = 0.0
while waited < args.grace and probe.first_src is None:
time.sleep(0.1)
waited += 0.1
else:
print("\n被动监听中,Ctrl+C 退出 ...")
while not probe.stop.is_set():
time.sleep(0.3)
except KeyboardInterrupt:
pass
finally:
probe.stop.set()
time.sleep(0.3)
probe.sock.close()
print("\n" + "-" * 60)
if probe.first_src is not None:
print("结论:✅ 打洞成功")
print(" 对方实际源地址 = %s" % fmt_addr(probe.first_src))
print(" 共收到 %d 个包" % probe.packets)
else:
print("结论:❌ 本轮没有收到对方的打洞包")
if probe.junk:
print(" (另外收到 %d 个非打洞包并已忽略:那是信令服务器的回复,不算打通)"
% probe.junk)
print(" 可能原因(按概率排序):")
print(" 1. 一端或两端是对称型 NAT / 运营商级 NAT(CGNAT) → 需要端口预测或中继")
print(" 2. 一方根本没发包(对方没启动、或地址填错/端口填错)")
print(" 3. UDP 被防火墙拦截(企业网络常见)")
print(" 4. 双方没有真正同时发包(时间差超过 NAT 表项寿命)")
print(" 下一步:两端各跑一次 --stun,看看是不是落在 100.64~100.127 网段(CGNAT)")
print("-" * 60)
return 0 if probe.first_src is not None else 1
if __name__ == "__main__":
sys.exit(main())
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""极简 rendezvous(信令)服务器 —— POC 用
作用只有一个:**告诉双方「对方看到的你」是什么地址**。
双方注册到同一个房间后,服务器把各自观察到的公网地址回推给对方,
之后双方直接打洞,服务器不再参与(也不承载任何媒体流量)。
用法(跑在有公网 IP 的机器上,比如 43.140.215.244):
py rendezvous.py --port 47999
客户端:
py punch_probe.py --server <服务器IP>:47999 --room test01 --port 47800
"""
import argparse
import socket
import sys
import time
# 房间成员多久没动静就清掉
MEMBER_TTL = 30.0
def fmt(sockaddr):
host, port = sockaddr[0], sockaddr[1]
return "[%s]:%d" % (host, port) if ":" in host else "%s:%d" % (host, port)
def main():
ap = argparse.ArgumentParser(description="极简 rendezvous 服务器")
ap.add_argument("--bind", default="0.0.0.0")
ap.add_argument("--port", type=int, default=47999)
args = ap.parse_args()
# 重定向到文件时 stdout 会全缓冲,日志要等进程退出才出现;强制行缓冲
try:
sys.stdout.reconfigure(line_buffering=True)
except (AttributeError, OSError):
pass
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((args.bind, args.port))
print("[rendezvous] 监听 udp://%s:%d" % (args.bind, args.port))
print("[rendezvous] 房间成员 TTL = %.0fs,Ctrl+C 退出\n" % MEMBER_TTL)
rooms = {} # room -> [(addr, last_seen)]
try:
while True:
data, src = sock.recvfrom(2048)
txt = data.decode("utf-8", "replace").strip()
now = time.time()
if not txt.startswith("REGISTER|"):
continue
room = txt.split("|", 1)[1].strip()
if not room:
continue
# 清掉过期成员 + 去掉本机的旧记录(支持重复注册)
members = [(a, t) for (a, t) in rooms.get(room, [])
if now - t < MEMBER_TTL and a[1] != src[1]]
members.append((src, now))
rooms[room] = members
print("[rendezvous] %s 注册 room=%s (现有 %d 人) 源地址=%s"
% (fmt(src), room, len(members), fmt(src)))
if len(members) >= 2:
a1, _ = members[0]
a2, _ = members[1]
sock.sendto(b"PEER|" + fmt(a2).encode(), a1)
sock.sendto(b"PEER|" + fmt(a1).encode(), a2)
print("[rendezvous] -> 配对成功:%s <==> %s"
% (fmt(a1), fmt(a2)))
else:
sock.sendto(b"WAIT", src)
print("[rendezvous] -> 等待第二个人加入 ...")
except KeyboardInterrupt:
print("\n[rendezvous] 退出")
finally:
sock.close()
return 0
if __name__ == "__main__":
sys.exit(main())
+222 -28
View File
@@ -81,8 +81,15 @@ fn now_ms() -> u64 {
T0.get().unwrap().elapsed().as_millis() as u64
}
/// 帧到达间隔环形缓冲(取 p95 用)
struct GapRing {
last: Option<Instant>,
ring: [u32; 64],
idx: usize,
filled: usize,
}
/// 会话统计(HUD 数据源,全部原子量,跨任务共享)
#[derive(Default)]
struct Stats {
/// 最近 RTT ms(0=未知)
rtt: AtomicU64,
@@ -95,6 +102,80 @@ struct Stats {
/// 累计收帧数 / 字节数
frames: 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<()> {
@@ -160,48 +241,105 @@ fn session(
let (w, h) = (width as usize, height as usize);
let stats = Arc::new(Stats::default());
// 读任务(流 1):H.264 解码 → RGB → mpsc;同时统计端到端延迟与流量
let (frame_tx, frame_rx) = std::sync::mpsc::channel::<Vec<u8>>();
// 读任务(流 1):H.264 解码 → RGB → 单槽 latest-wins;同时统计端到端延迟与流量
// 帧格式 (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 latest = latest.clone();
let mut decoder = openh264::decoder::Decoder::new()
.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 {
let mut size_logged = false;
loop {
match common::read_video_frame(&mut recv).await {
Ok((hdr, payload)) => {
stats.bytes.fetch_add(payload.len() as u64, Ordering::Relaxed);
stats.frames.fetch_add(1, Ordering::Relaxed);
// 与上一帧的到达间隔:≤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 平滑)
let mut est = None;
let mut stale = false;
if stats.offset_valid.load(Ordering::Relaxed) {
let est =
let e =
now_ms() as i64 - stats.offset.load(Ordering::Relaxed)
- 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 sm = if stats.e2e_valid.load(Ordering::Relaxed) {
(old * 4 + est) / 5
(old * 4 + e) / 5
} else {
est
e
};
stats.e2e.store(sm as u64, 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) {
Ok(Some(yuv)) => {
let (dw, dh) = yuv.dimensions();
let mut rgb = vec![0u8; dw * dh * 3];
yuv.write_rgb8(&mut rgb);
if frame_tx.send(rgb).is_err() {
break;
let (dw, dh) = (dw as u32, dh as u32);
if !size_logged {
size_logged = true;
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) => {}
Err(e) => {
// 解码异常:请求被控端强制关键帧
stats.dec_fail.fetch_add(1, Ordering::Relaxed);
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 {
loop {
match read_control(&mut irecv).await {
Ok(ControlMsg::Pong { t, agent_ts }) => {
Ok(ControlMsg::Pong { t, agent_ts, lost, sent }) => {
let r = now_ms();
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
let est = agent_ts as i64 - ((t + r) / 2) as i64;
let old = stats.offset.load(Ordering::Relaxed);
@@ -235,7 +375,10 @@ fn session(
stats.offset_valid.store(true, Ordering::Relaxed);
}
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 _ = tx.send(common::encode_control(&m));
};
// 入会立即请求 IDR:避免从 GOP 中间接入收到裸 P 帧解码失败导致启动黑屏
push(&msg_tx, ControlMsg::KeyFrameReq);
let mut window = Window::new(
"mvp-remote viewer (Esc 退出)",
"mvp-remote v0.8.1-CatchUp (Esc 退出)",
w,
h,
WindowOptions {
@@ -278,6 +423,8 @@ fn session(
let mut hud_bytes = 0u64;
let mut hud_fps = 0u64;
let mut hud_mbps = 0f64;
let mut hud_lost = 0u64;
let mut hud_sent = 0u64;
loop {
if !window.is_open() || window.is_key_down(Key::Escape) {
@@ -290,19 +437,16 @@ fn session(
push(&msg_tx, ControlMsg::Ping(now_ms()));
}
// 1) 渲染最新帧(只保留最新,丢弃积压);空帧=解码异常,请求关键帧
loop {
match frame_rx.try_recv() {
Ok(raw) => {
// 1) 渲染最新帧(单槽 latest-wins:每迭代只取最新一帧,天然防积压)
if let Ok(mut slot) = latest.lock() {
if let Some((sw, sh, raw)) = slot.take() {
if raw.is_empty() {
push(&msg_tx, ControlMsg::KeyFrameReq);
} else if raw.len() == w * h * 3 {
blit(&mut buffer, &raw);
} else if sw > 0 && sh > 0 {
blit(&mut buffer, w, h, &raw, sw as usize, sh as usize);
dirty = true;
}
}
Err(_) => break,
}
}
// 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) {
let f = stats.frames.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_frames = f;
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();
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 e2e = stats.e2e.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(
&mut buffer,
w,
@@ -392,6 +573,10 @@ fn session(
&if e2e_ok { format!("{e2e}ms") } else { "--".into() },
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),
("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, 0x60, 0x60, 0x00, 0x00],
b'-' => [0x08, 0x08, 0x08, 0x08, 0x08],
b'%' => [0x13, 0x18, 0x04, 0x0A, 0x11],
b' ' => [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 缓冲
fn blit(buffer: &mut [u32], raw: &[u8]) {
for (px, chunk) in buffer.iter_mut().zip(raw.chunks_exact(3)) {
/// RGB888 字节(openh264 解码输出)→ minifb 0RGB u32 缓冲。
/// 支持源尺寸与窗口不同(解码器可能对齐到 16 倍数,如 1080→1088):
/// 逐行拷贝重叠区域,源小于窗口时右侧/下方留黑。
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;
}
}
}
+301
View File
@@ -0,0 +1,301 @@
# 借鉴映射与落地规范
> 配套《多平台架构设计.md》。本文回答三个问题:**每个模块怎么设计、看谁、抄什么、不抄什么。**
> 状态:架构部分已定稿;第 2 章的「逆向需求清单」等同事的逆向资料回填。
---
## 0. 使用方式
| 角色 | 看哪几章 |
|---|---|
| A(被控侧) | §3.5 platform、§3.6 agent-core、§4 表中 agent 相关行 |
| B(主控+协议) | §3.1–3.4、§3.7 viewer-core、§5 许可 |
| 移动端(待补人) | §3.4 media(解码后端)、§3.8 apps |
| 全员 | §2 逆向红线(踩了会出大事) |
---
## 1. 两条铁律
1. **架构由需求决定,不由参考项目决定。** 参考项目用来解决"这一块业界怎么做的",不是用来决定"我们做成什么样"。
2. **能抄的是算法和协议思路,抄不到的是平台集成和 UI。** 后者是体力活,任何参考项目都救不了(它们自己也是硬写的)。
---
## 2. 逆向资料的用法与红线
### 2.1 红线(务必转告同事)
| 可以做 | 不可以做 |
|---|---|
| 记录**功能清单、交互流程、界面布局** | 把反编译产物、反汇编清单提交进仓库 |
| 记录**用户可见的错误提示、状态显示** | 逐字复制对方的 UI 文案、图标、素材 |
| 推断**产品模型**(账号/设备/授权怎么设计) | 抄对方私有协议的具体编码(抄了也不能互通) |
| 记录**网络行为的宏观特征**(是否中转、连接耗时) | 提取并移植对方的算法实现 |
**仓库里只提交"行为结论",不提交"逆向产物"。** 建议逆向资料单独放一个**不纳入 git** 的目录(或内部网盘),文档里只写结论。这条不是洁癖——商用产品里出现竞品反编译代码是实打实的法律风险。
### 2.2 逆向需求清单(直接发给同事)
**A. 产品功能面(最重要,优先做)**
1. 主控端完整交互流程截图流:登录 → 设备列表 → 连接(输密码/免密)→ 会话中 → 断开
2. 每个平台的功能矩阵:Win/Linux/Android/iOS 各有哪些功能、哪些缺失
3. 会话中工具栏全清单:分辨率切换、多屏、文件传输、剪贴板、音频开关、画质/帧率档位、游戏模式
4. 设置项全清单(分"被控设置"和"主控设置"两类)
5. 错误提示文案全集 + 断线重连时的用户可见行为
**B. 身份与授权模型**
6. 设备 ID 的形式(长度/字符集)、在哪里查看、能否自定义
7. 授权方式有几种:临时密码 / 固定密码 / 免密白名单 / 账号直接授权?默认是哪种
8. 账号体系:是否必须登录、登录后设备列表怎么呈现、一台设备能否被多账号访问
9. 被控端的"允许谁连"控制(黑名单/白名单/单次确认弹窗)
**C. 被控端形态**
10. 是不是系统服务?开机自启吗?锁屏/注销/UAC 提权界面能不能连?
11. 安装包形态、是否需要管理员权限、卸载残留
**D. 网络行为(只看宏观,不要深入协议)**
12. 连接时用户是否能看到"直连 / 中转"标识
13. 典型连接耗时、转中继时的画质变化
14. 端口/协议宏观特征:UDP 还是 TCP 为主、监听端口是否固定
**E. 商业化(可选但有用)**
15. 免费额度/收费点/增值功能边界
### 2.3 逆向资料回填位置
| 资料 | 回填到 |
|---|---|
| A 组 | 本文 §6「产品范围基线」+ 里程碑取舍 |
| B/C 组 | §3.3 session(身份模型影响状态机) |
| D 组 | §3.2 conn(验证我们的打洞/中继策略) |
| E 组 | 不在本文,属于商业决策 |
---
## 3. 模块级设计规范
### 3.1 `proto` — 协议
**职责**:信令消息、控制消息、媒体容器格式。三端(agent/viewer/信令服务)共享契约。
**设计点**
- **双协议分离**:信令协议(`signaling.rs`,文本友好、低频、可 JSON)与媒体控制协议(`lib.rs`,二进制、高频)**不要混**。前者要便于抓包排查,后者要极致紧凑。
- **版本协商**:`SIGNALING_VERSION` 不一致直接拒绝,不做兼容。媒体协议用"能力协商"而非版本号(见 §3.3)。
- **消息演进**:现用 bincode。二期若换 protobuf,**必须一次性切换**,不要两套并存。
- **单帧长度上限**:现有 `ensure!(len < 1<<20)` 这类边界检查要保留,且信令侧对端候选列表必须经 `sanitize_candidates`。
**看谁 / 抄什么**
| 项目 | 抄什么 | 不抄 |
|---|---|---|
| RustDesk `libs/hbb_common` | 消息分类方式(信令/中继/控制分层) | 具体协议编码(AGPL + 抄了不互通) |
| Moonlight `moonlight-common-c` | **控制消息与媒体分离**的成熟做法 | 它的私有控制协议 |
**产出物**:`crates/proto/src/{lib.rs, signaling.rs}`
---
### 3.2 `conn` — 连接层(最值得借鉴的一块)
**职责**:信令客户端、打洞编排、路径选择、QUIC 会话、保活。
**设计点**
- **`PathProvider` 抽象**(已在《打洞与信令设计.md》定义):现在只实现 Direct,类型上预留 Relay。
- **打洞编排**:候选收集(v6/Host/UPnP/Reflexive/Predicted)→ 排序 → 信令交换 → 同步打洞 → 落选清理。
- **同一 socket 双角色**:打洞用的 UDP socket 必须与后续 QUIC 是同一个,否则洞不在同一映射上。
- **保活**:按双方 `binding_timeout_s` 折半,5–60s。**这是"连一会儿就断"的根治点。**
- **不用 QUIC 握手当探测**:先用轻量 UDP 探测包确认通路(秒级失败),再 `connect`。
**看谁 / 抄什么**
| 项目 | 抄什么 | 许可 |
|---|---|---|
| **iroh** | **整套**:打洞编排、中继回退、地址发现。技术栈与你们重合(Rust + quinn) | Apache-2.0 ✅ **可直接依赖** |
| webrtc-rs / pion/ice | ICE 状态机、候选配对(candidate pairing)策略 | MIT/Apache ✅ |
| Tailscale / headscale | DERP 中继的**回退判定条件**与降级策略 | BSD-3 ✅ |
| RustDesk `rendezvous_mediator` 相关 | 信令 ↔ 打洞的**编排顺序**(读思路) | AGPL ⚠️ 只读 |
| libjuice / libnice | ICE 精简实现(作为对照,不必依赖) | MPL / LGPL |
> **建议**:`conn` 优先评估**直接依赖 iroh**。它解决的正是你们纠结的"打洞要不要中继",而且给了"直连优先、失败回退"的完整实现。自研仅当 iroh 有硬伤(体积、可控性、特定 NAT 场景)时才考虑。
**产出物**:`crates/conn/`
---
### 3.3 `session` — 会话编排("一体程序"的核心)
**职责**:会话状态机、角色仲裁、能力协商、生命周期管理、被控端准入控制。
**设计点**
- **状态机双线**:主控线 / 被控线各自独立,同一进程可并发(被控中也能主控别人)。
- **角色仲裁**:打洞阶段双方对称,**谁先打通谁定角色**(被控方做 QUIC server),不预设。
- **能力协商**:被控端上报硬件能力(可用编码器、最大分辨率、帧率上限、是否支持音频),主控端选择。**用能力协商代替版本号**——旧客户端缺字段就是"不支持",天然向后兼容。
- **准入控制**:单会话/多会话、白名单、单次确认弹窗。**这一条直接来自 §2 逆向需求 B 组第 7/9 项**,等资料回填后定稿。
- **失败出口**:`PunchFailReason::user_hint()` 必须一路透传到 UI,不允许出现无解释的"连接中"。
**看谁 / 抄什么**
| 项目 | 抄什么 |
|---|---|
| RustDesk `src/server` / `src/client` | 会话生命周期与连接管理的分层方式(读思路) |
| Sunshine + Moonlight | host/client 两侧的**能力协商**与配置下发流程 |
| — | 状态机本身建议自己画,不要套用别人的(各家产品逻辑差异大) |
**产出物**:`crates/session/`
---
### 3.4 `media` — 编解码抽象
**职责**:编码/解码 trait、各平台后端、零拷贝通路、码率自适应闭环。
**设计点**
- **不对称策略**:**编码用 FFmpeg**(只在 Win/Linux,桌面端不心疼包体),**解码用平台原生**(四个主控平台都要,移动端必须硬解且要零拷贝)。
- **trait 收口**(见《多平台架构设计.md》§4 决策 4),上层不允许出现平台判断。
- **零拷贝**:解码器直接输出到渲染表面(D3D11 纹理 / `SurfaceView` / `CAMetalLayer`),中间不做 CPU 拷贝。
- **软硬兜底**:硬编失败回落 openh264;硬解失败回落软解(桌面可,移动端应直接提示而非硬撑)。
- **码率自适应闭环**:接收端统计(丢包/抖动/帧间隔 p95)→ 反馈编码端 → AIMD 调码率。协议已留 `RateReport`(见 B侧设计文档 B2)。
**看谁 / 抄什么**
| 项目 | 抄什么 | 许可 |
|---|---|---|
| **libwebrtc** `modules/congestion_controller/goog_cc` | **GCC 拥塞控制**的完整思路——这是码率自适应的教科书 | BSD-3 ✅ 可参考可移植 |
| **libwebrtc** `modules/video_coding/jitter_buffer` | 抖动缓冲与丢包重排 | BSD-3 ✅ |
| **Sunshine** `src/video` + `src/platform` | **抓屏→编码→打包**的完整管线组织,尤其是多编码器抽象与回落 | GPL-3.0 ⚠️ 只读 |
| **Moonlight** `moonlight-common-c` | 接收端解码→渲染→HUD 的管线,以及**丢包恢复策略** | GPL-3.0 ⚠️ 只读 |
| FFmpeg | NVENC/QSV/AMF/VAAPI 的调用姿势与参数 | LGPL/GPL ⚠️ 注意构建选项 |
| openh264 | 软编兜底(已在用) | BSD ✅ |
**产出物**:`crates/media/`
---
### 3.5 `platform` — L0 平台层(抄不到,只能自己写)
**职责**:抓屏、注入、渲染表面、音频 IO、权限、常驻。
**为什么抄不到**:这层是各平台 API 的体力活。参考项目能给的是"用哪个 API、踩过什么坑",不是可复用代码。
**设计点**
| 平台 | 关键坑 |
|---|---|
| Win 抓屏 | DXGI DD 在**会话切换/RDP 断开**时失效,必须处理 `DXGI_ERROR_ACCESS_LOST` 并重建 |
| Win 注入 | 服务进程注入到用户会话需要 `CreateProcessAsUser` + 会话隔离处理;UAC 提权界面默认收不到输入(需 `uiAccess` 签名) |
| Win 常驻 | 服务 + 用户会话代理双进程,锁屏可连靠的是**服务侧保持会话** |
| Linux 抓屏 | X11 用 XShm/XComposite;**Wayland 必须走 PipeWire + xdg-desktop-portal**(有授权弹窗,无法静默) |
| Linux 注入 | X11 用 XTest;**Wayland 用 libei/uinput**(uinput 需 root 或 udev 规则) |
| Linux 常驻 | systemd 服务 + 会话切换处理(Wayland 的 portal 授权是最大障碍) |
| 渲染表面 | 四平台各一套;**必须支持"表面尺寸≠视频尺寸"的缩放与拉伸模式** |
**看谁**
| 项目 | 看什么 |
|---|---|
| Sunshine `src/platform/{windows,linux}` | 抓屏与注入的分平台组织方式、Wayland 处理 |
| RustDesk `libs/scrap` | 同样的抓屏抽象(多平台统一 trait)——**这是它最有价值的部分** |
| libei / xdg-desktop-portal 官方文档 | Wayland 的正规姿势(别用野路子) |
**产出物**:`crates/platform/`
---
### 3.6 `agent-core` / `viewer-core` — 两条链路
**职责**:把现有 `agent`/`viewer` 的 main 逻辑抽成库,供一体程序按角色调用。
**设计点**
- **`agent-core`**:capture → encode → 打包 → 发送;输入接收 → 注入。A 已在进行中(`agent-core/src/lib.rs` 398 行)。
- **`viewer-core`**:接收 → 解码 → 渲染表面;输入采集 → 发送。
- **生命周期**:两者都不允许自己决定退出,由 `session` 层驱动。
- **单槽 latest-wins**:现有做法保留(网络慢时只发最新帧),这是正确设计。
**看谁**:RustDesk(抽库方式)、Sunshine/Moonlight(管线组织)。
**产出物**:`crates/agent-core/`、`crates/viewer-core/`
---
### 3.7 `apps/*` — UI 与常驻(第二个抄不到的地方)
**职责**:UI 壳、常驻服务、安装包。
**设计点**
- **硬约束**:所有业务逻辑在 Rust,UI 只调一层稳定 API(决定了将来能否换 UI 框架)。
- **视频走原生子表面**,绝不在 WebView 里逐帧渲染。
- **一体 = 一个安装包 + 两个进程**:前台 UI + 后台被控服务,本地 IPC。
- **移动端只有前台进程**(纯主控)。
**看谁**
| 项目 | 抄什么 |
|---|---|
| Tauri v2 官方 mobile 示例 | WKWebView/Android WebView 与原生 surface 的叠加方式 |
| RustDesk `flutter/` | 同一套 UI 适配多端的组织方式(它用 Flutter,可对照取舍) |
| Moonlight `moonlight-android` / `moonlight-ios` | **移动端主控的完整交互**:虚拟鼠标、手势映射、软键盘、性能档位 |
**产出物**:`apps/desktop/`、`apps/android/`、`apps/ios/`、`apps/daemon-win/`、`apps/daemon-linux/`
---
## 4. 借鉴映射总表
| 模块 | 主参考 | 许可 | 用法 |
|---|---|---|---|
| 打洞 / 中继 / 地址发现 | **iroh** | Apache-2.0 | **直接依赖** |
| ICE / STUN / TURN | webrtc-rs、pion、coturn | MIT/BSD | 依赖或参考 |
| 中继回退策略 | Tailscale / headscale | BSD-3 | 读思路 |
| 拥塞控制 / 抖动缓冲 | **libwebrtc** | BSD-3 | 读思路,重写 |
| 抓屏→编码管线 | **Sunshine** | GPL-3.0 | 只读 |
| 接收端解码→渲染 | **Moonlight** | GPL-3.0 | 只读 |
| 整机架构 / 抽库方式 | **RustDesk** | **AGPL-3.0** | **只读,绝不抄代码** |
| 移动端主控交互 | Moonlight mobile | GPL-3.0 | 只读 |
| Rust↔移动端绑定 | UniFFI | MPL-2.0 | 依赖 |
---
## 5. 许可结论与依赖策略
| 许可 | 能否进产品 | 项目 |
|---|---|---|
| MIT / Apache-2.0 / BSD | ✅ 可依赖、可移植 | iroh、webrtc-rs、pion、coturn、libwebrtc、UniFFI(MPL 弱) |
| LGPL | ⚠️ 可动态链接,改库要开源 | FFmpeg、GStreamer、libnice |
| **GPL / AGPL** | ❌ **不可进代码** | **RustDesk(AGPL)、Sunshine/Moonlight(GPL)** |
**AGPL 特别提示**:网络服务也算分发。引入 RustDesk 代码 = 你们整个产品连服务端都要开源。
**依赖准入流程**(补充进《Git协作规范.md》的合并规则):新增依赖必须在 PR 描述里写明**许可证 + 是否修改其源码**,由另一人确认。
---
## 6. 产品范围基线(待逆向资料回填)
> 拿到 §2.2 的 A 组资料后填这里,用于定"做哪些功能、砍哪些平台"。
| 功能 | UU 有 | 我们一期 | 备注 |
|---|---|---|---|
| 桌面画面 | | ✅ | 已有 |
| 键鼠控制 | | ✅ | 已有 |
| 音频 | | ? | |
| 文件传输 | | ? | |
| 剪贴板同步 | | ? | |
| 多屏 | | ? | |
| 移动端主控 | | ? | 人力问题见《多平台架构设计.md》§8 |
| 无人值守 | | ? | 安全含义,需固定密码+白名单 |
---
## 7. 待拍板
1. `conn` 是否**直接依赖 iroh**(而不是自研打洞)——建议先花 2 天做技术验证
2. 逆向资料回来后,"产品范围基线"的取舍(尤其移动端)
3. `media` 编码侧是否引入 FFmpeg(包体/构建成本 vs 省下的适配工作)
4. 依赖准入流程是否写进《Git协作规范.md》
+270
View File
@@ -0,0 +1,270 @@
# 多平台架构设计
> 需求:**主控端** Windows / Linux / Android / iOS;**被控端** Windows / Linux。
> 桌面端(Win/Linux)主控+被控合为一体;移动端仅主控。
> 前置文档:《打洞与信令设计.md》《B侧设计文档.md》《构建运行手册.md》
---
## 0. 一句话架构
**一个 Rust 核心 + 一层薄平台适配 + 可替换的 UI 壳。**
业务逻辑(连接/打洞/会话/协议)100% 在 Rust 里跨平台共享;只有**抓屏、编解码、注入、渲染表面**四件事分平台写;UI 层只管"壳",**永远不碰视频流**。
---
## 1. 纵向分层
```
┌──────────────────────────────────────────────────────────┐
│ L4 UI 壳 Win/Linux/Android/iOS 各自实现 │
│ 设备列表 / 设置 / 连接管理 / 悬浮控件 │
│ ★ 绝不放视频渲染 │
├──────────────────────────────────────────────────────────┤
│ L3 会话层 session 纯 Rust,全平台共享 │
│ 会话状态机、角色仲裁、能力协商、keepalive 调度 │
├──────────────────────────────────────────────────────────┤
│ L2 连接层 conn 纯 Rust,全平台共享 │
│ 信令客户端、打洞编排、中继接口(预留)、QUIC(quinn)多流 │
├──────────────────────────────────────────────────────────┤
│ L1 媒体层 media Rust + 平台后端 │
│ 编码/解码/色彩转换/缩放/音频(Opus) trait 化 │
├──────────────────────────────────────────────────────────┤
│ L0 平台层 platform 唯一必须分平台的地方 │
│ 抓屏 / 注入 / 音频IO / 渲染表面 / 权限 / 常驻 │
└──────────────────────────────────────────────────────────┘
```
**分界线原则**:L1 以上任何代码不允许出现 `#[cfg(windows)]`。平台差异只能通过 L0 的 trait 暴露。
---
## 2. 仓库结构
```
remote/
├── crates/
│ ├── proto/ # 协议:信令 + 控制 + 媒体容器(现 common/)
│ ├── conn/ # 信令客户端 / 打洞编排 / QUIC 会话 / 中继接口
│ ├── session/ # 会话状态机 + 角色仲裁("一体"的核心)
│ ├── agent-core/ # 被控链路:capture → encode → send [A 已在做]
│ ├── viewer-core/ # 主控链路:recv → decode → surface
│ ├── media/ # 编解码 trait + 各平台后端 + openh264 软兜底
│ └── platform/ # L0 实现,按 cfg 编译各平台
├── apps/
│ ├── desktop/ # Tauri v2 壳:Win/Linux 一体程序
│ ├── android/ # 主控(Tauri mobile 或原生 Kotlin)
│ ├── ios/ # 主控(Tauri mobile 或原生 Swift)
│ ├── daemon-win/ # Windows 服务:被控常驻
│ └── daemon-linux/ # systemd:被控常驻
├── bindings/ # UniFFI 生成的 Kotlin/Swift 绑定
└── tools/ # bench / 信令服务器
```
`agent-core` 与 `viewer-core` 的拆分是**一体化的前提**:现在的 `agent`/`viewer` 是两个 main,必须先变成库,才能被同一个进程按角色调用。A 正在做的 `agent-core` 抽取就是在铺这条路。
---
## 3. 平台能力矩阵
| 能力 | Windows | Linux | Android | iOS |
|---|---|---|---|---|
| 抓屏 | DXGI Desktop Duplication | X11 (XShm) / Wayland (PipeWire portal) | — | — |
| 硬编 | NVENC / QSV / AMF | VAAPI / NVENC | — | — |
| 硬解 | D3D11VA / NVDEC | VAAPI | MediaCodec | VideoToolbox |
| 渲染表面 | D3D11 子窗口 | X11/GL 子窗口 | SurfaceView | UIView + CAMetalLayer |
| 键鼠注入 | SendInput | XTest / uinput / libei | — | — |
| 键鼠采集 | Raw Input | evdev | 触摸→鼠标映射 | 触摸→鼠标映射 |
| 音频出 | WASAPI loopback | PulseAudio monitor | — | — |
| 音频入/放 | WASAPI | PulseAudio | AudioTrack | AVAudioEngine |
| 常驻服务 | Windows Service | systemd | — | — |
| **角色** | **主 + 被** | **主 + 被** | **仅主** | **仅主** |
移动端没有"被控",所以 Android/iOS **不需要**抓屏、编码、注入、服务化——少了一半工作量,这是需求里最省事的一条。
---
## 4. 六个关键决策
### 决策 1:视频渲染绝不走 UI 框架
从 WebView / Compose / Flutter 里逐帧渲染 60fps 视频必然掉帧、增延迟。
**做法**:UI 框架只画"壳",视频走**原生子表面**叠加在 UI 之上——
Win: 子 HWND + D3D11;Linux: 子窗口 + GL;Android: `SurfaceView`;iOS: `UIView` + `CAMetalLayer`。
MediaCodec / VideoToolbox 都能**直接解码到该 Surface,零拷贝**。这是 Parsec/ToDesk 这类产品的通行做法。
### 决策 2:UI 框架 —— 推荐 Tauri v2,但必须可替换
| 方案 | 覆盖 | 评价 |
|---|---|---|
| **Tauri v2** | Win/Linux/Android/iOS | ✅ 推荐:Rust 原生、一套 UI 四端、IPC 天然 |
| Flutter | 四端 | UI 强,但 Rust 桥(flutter_rust_bridge)+ 平台视图叠加更绕 |
| 各平台原生 ×4 | 四端 | 性能最好,**2-3 人团队不可能维护** |
选 Tauri v2 的理由:你们本来就写 Rust,视频走原生表面后 WebView 只做表单和列表,性能足够。
**代价**:Tauri 移动端还不够成熟(WKWebView 与原生 surface 叠加、生命周期)。所以——
> **硬性约束:所有业务逻辑必须在 Rust 里,UI 只通过一层稳定 API 调用。**
> 这样移动端若体验不达标,可以单点退化成原生 Kotlin/Swift + UniFFI,桌面仍用 Tauri,核心零改动。
### 决策 3:Rust ↔ 移动端绑定用 UniFFI
6 个平台角色组合下手写 JNI/FFI 会失控。UniFFI 从 Rust 生成 Kotlin/Swift 绑定,含 async、错误类型、对象生命周期,是当前标准解法。
### 决策 4:编码用 FFmpeg,解码用平台原生
不对称,但都有理由:
- **编码(只在 Win/Linux 被控端)**:用 FFmpeg 统一接管 NVENC/QSV/AMF/VAAPI,openh264 软编兜底。桌面端不心疼包体,省下大量适配。
- **解码(四个主控平台都要)**:直接用平台原生 API。
- 移动端**必须**硬解(软解烧电池、发热、跑不动 1080p60)
- 平台原生能零拷贝直出渲染表面,FFmpeg 反而多一次拷贝
- 绕开 FFmpeg 在 Android/iOS 上的交叉编译地狱和许可问题
`media` crate 用 trait 收口:
```rust
pub trait VideoDecoder {
fn configure(&mut self, cfg: &VideoConfig, target: SurfaceHandle) -> Result<()>;
fn decode(&mut self, packet: &[u8]) -> Result<Option<FrameHandle>>;
}
pub trait VideoEncoder {
fn configure(&mut self, cfg: &EncodeConfig) -> Result<()>;
fn encode(&mut self, frame: &FrameHandle, out: &mut Vec<u8>) -> Result<()>;
fn request_keyframe(&mut self);
}
```
### 决策 5:"一体程序" = 一个安装包 + 两个进程
用户视角是一个 app,实现上职责分离:
```
┌────────────────────────┐ ┌──────────────────────────┐
│ 前台进程(UI) │ IPC │ 后台常驻(被控服务) │
│ 主控画面 / 设备列表 │<──────>│ 抓屏·编码·注入·保活 │
│ 退出即结束 │ 本地管道│ 开机自启 / 锁屏可连 │
└────────────────────────┘ └──────────────────────────┘
▲ ▲
│ 移动端只有这一个 │ 仅 Win/Linux
```
理由:**两种角色的生命周期天然冲突**——被控要"开机就在、没人操作也在",主控要"跟窗口走、关了就走"。硬塞进一个进程会导致主控一退出就掉线。
IPC 用命名管道(Win)/ Unix socket(Linux),或复用 QUIC 走本地回环。
### 决策 6:中继接口必须预留 —— 哪怕现在不实现
`conn` 层定义:
```rust
pub trait PathProvider {
/// 返回一条可用数据通道:直连优先,失败时可回退
async fn establish(&self, peer: &PeerInfo) -> Result<Path>;
}
pub enum Path { Direct { addr: SocketAddr }, Relay { endpoint: EndpointId } }
```
现在只实现 `Direct`。**但类型上留出 `Relay`**,将来补中继不必动会话层。
> ⚠️ **这条与"不做中继"的决定直接相关,见 §6。**
---
## 5. 会话状态机(L3 核心)
一体程序两条线:
```
主控线: Idle → Signalling → Punching → Handshaking → Streaming → Closing
被控线: Idle → Registered → Incoming → Punching → Handshaking → Serving → Closing
↑ 同一进程两线可并发(被控中也能主控别人)
```
要点:
- 打洞阶段**双方对称**:谁都不预设客户端/服务端,各自向对端候选发包,谁先打通谁定角色(被控方做 QUIC server)
- 会话能力协商:分辨率、编码器、码率上限、是否带音频——由**被控端**根据硬件能力给候选,主控端选
- 单进程内用 tokio 一runtime 跑多会话;被控同时只服务一个主控(可配置)
---
## 6. ⚠️ 必须现在拍板:手机主控 vs 不做中继
**手机在蜂窝网络下几乎 100% 处于运营商级 NAT(CGNAT),打洞打不通。**
这不是实现问题,是网络事实——`100.64.0.0/10` 那段地址根本没给你可映射的公网端口。
后果:**"主控端支持安卓/iOS" + "不做中继" = 手机只能在 WiFi 下用。**
地铁上、外面、用流量,一律连不上。
三条出路,必须选一条:
| 方案 | 代价 | 说明 |
|---|---|---|
| **A. 只靠 IPv6** | 部分用户不可用 | 国内三大运营商 v6 覆盖率高,手机 v6 可用性远好于 v4 公网。**推荐先做,能覆盖大多数** |
| **B. 补一个只为移动端服务的中继** | 视频带宽成本 | 可用率 100%,但账单跟着视频流量走 |
| **C. 接受"手机只能 WiFi"** | 产品力受限 | 零成本,但要写进产品说明,别让用户以为坏了 |
**我的建议:A 先做(架构里 v6 是一等公民),B 的接口留在 `PathProvider` 里不给实现,等真要推移动端再决定。**
不要现在就把路堵死——留接口的成本几乎为零,推倒重来成本极高。
---
## 7. 分期路线(不要一次做 6 个组合)
| 里程碑 | 内容 | 预估 | 依赖 |
|---|---|---|---|
| **M1** | Win↔Win 打通:信令 + 打洞 + 证书钉扎 | 2-3 周 | 协议已就绪 |
| **M2** | Win 一体化:agent-core 抽库、会话状态机、前台/后台双进程 | 2-3 周 | M1 |
| **M3** | Win 被控服务化(开机即在线、锁屏可连、UAC 处理) | 2 周 | M2 |
| **M4** | Linux 被控(X11 先行,Wayland/PipeWire 预留) | 2-3 周 | M2,A 在做 |
| **M5** | 桌面 UI(Tauri v2 壳 + 原生视频表面) | 3 周 | M2 |
| **M6** | **Android 主控**(MediaCodec + UniFFI + 触摸映射) | 3-4 周 | M5 |
| **M7** | **iOS 主控**(VideoToolbox + SwiftUI/UniFFI) | 3-4 周 | M6 |
| **M8** | Linux 主控(复用 M5 的桌面 UI) | 1 周 | M5 |
| 穿插 | 音频(Opus) / 文件传输 / 多屏 | 各 1-2 周 | — |
**先做 Android 再做 iOS**:不用苹果开发者账号、真机调试方便、热重载快、审核风险低。iOS 放最后。
---
## 8. ⚠️ 必须说清楚的人力问题
现有团队 **2-3 人**,目前连"Win↔Win 打通"都还没做完。而这份需求是:
- 2 个被控平台(抓屏 + 编码 + 注入 + 服务化)× 各自两套显示服务器(Linux X11/Wayland)
- 4 个主控平台(解码 + 渲染 + 输入 + UI)× 各自一套原生集成
- 外加信令、打洞、音频、文件、多屏、账号体系
**这是 6-10 人年的工作量,2-3 人做不完。** 不是唱衰,是排期事实——Android + iOS 两个平台的 UI 和平台集成,单独拿出来就够一个人全职做半年。
可行的收缩方式(选一个):
1. **砍掉 iOS**(苹果审核 + 真机调试 + 开发者账号成本最高,收益最小)
2. **移动端只做 Android**,iOS 用 Web 端替代(但 Web 端无法硬解,体验差)
3. **桌面优先,做深做透**,移动端放到"产品验证之后",先招人
4. 移动端主控**外包**,核心协议和桌面端自己掌握
我的建议是 **3 或 1**:先把 Win/Linux 两端做成一款真的好用的产品(含打洞、服务化、音频、文件),移动端等有用户了再投入。现在同时铺 6 个平台,最大概率是每个都半成品。
---
## 9. 给 A / B 的分工调整建议
| 人 | 主责 | 说明 |
|---|---|---|
| A | `platform/` 被控侧:Win 抓屏/编码/注入/服务化 + Linux 被控 | 与现状一致 |
| B | `proto/` + `conn/` + `session/` + `viewer-core/` + 桌面 UI | 与现状一致 |
| — | **移动端(Android/iOS)** | **无人** ← 这是缺口 |
现有分工在"桌面 + 协议"上是自洽的,**移动端是纯增量,必须补人或砍需求**。
---
## 10. 开放问题(需要拍板)
1. 中继:确认走 §6 的 A 方案(v6 优先、留接口不实现)?
2. 移动端:先 Android 还是直接砍 iOS?
3. UI 框架:确认 Tauri v2,还是先原生桌面 + 后面再说移动?
4. 账号体系:现在是"输验证码",要支持设备列表就得有账号/设备 ID 体系——什么时候做?
5. 被控服务化后,主控端要不要支持"无人值守开机连接"(有安全含义,需要固定密码 + 白名单)?
+122
View File
@@ -0,0 +1,122 @@
# 打洞与信令设计(无中继方案)
> 状态:协议契约已落在 `common/src/signaling.rs`,待 A 过审后合入 main。
> 决策:**不做中继(无 TURN / coturn)**,服务器只保留信令 + STUN 职能。
> 关联:`构建运行手册.md` §7 第 4 项、`Git协作规范.md` 二·B 第 ③ 项。
## 1. 结论先行:信令不是中继
| | 信令服务器 | 中继服务器(已砍掉) |
|---|---|---|
| 承载媒体流量 | ❌ 不承载 | ✅ 全部承载 |
| 生命周期 | 只在建连那几秒 | 整个会话期间 |
| 成本 | 一台最低配 VPS 足够 | 视频流带宽,主要成本项 |
| 必要性 | **必须**(无它则无法交换地址) | 可选(可用率兜底) |
砍掉中继 ≠ 不要服务器。信令必须保留,因为它解决的是死循环:双方都在 NAT 后,
各自只知道自己内网地址,**"自己的公网映射"这个信息只有第三方发包过来时才能看见**。
## 2. 无中继的代价:可用率,不是功能
打洞成功率由 NAT 类型决定,与代码质量基本无关:
| NAT 类型 | 典型场景 | 无中继 |
|---|---|---|
| Full cone / Restricted cone | 家用路由器默认 | ✅ 很高 |
| Port-restricted cone | 家用宽带常见 | ✅ 需同时发包 |
| Symmetric NAT | 部分企业网关 | ⚠️ 仅端口可预测时可行 |
| CGNAT(`100.64.0.0/10`) | 移动/社区宽带 | ❌ 无解(IPv6 除外) |
预期 P2P 成功率 **80–90%**,其余连不上。
**产品边界(需写进对外说明)**:目标用户框定为「家用宽带 + IPv6 可用 + 可开 UPnP」;
企业网络 / CGNAT 标注为不支持。这样剩余失败就不是 bug,是已知边界。
## 3. 打洞流程(协议见 `common/src/signaling.rs`)
```
agent 信令服务器 viewer
|-- Register(info) ------->|<------- Register(info) -----|
|<-- Registered(observed) -|------ Registered(observed) ->| observed = 公网映射(等价一次 STUN binding)
|<-- PeerReady(peer info) -|------ PeerReady(peer info) -->| + 打洞前景 outlook
|<-- PunchPlan(start_in) --|------ PunchPlan(start_in) --->| 两侧 start_in 不同,绝对时刻对齐
| (双方同时向对端候选列表发包,多轮) |
|<========= 打通:UDP 直连 → QUIC 握手 + 证书钉扎 =========>|
|--- PunchReport(Connected) ->|<---- PunchReport ----------|
```
### 3.1 候选地址与优先级
`Candidate::score()`:IPv6(+100)> UPnP 显式映射(90) > 本机地址(80) > 公网反射(60) > 端口预测(40)。
**IPv6 是无中继策略的最强后盾** —— 双方都有公网 v6 就是真直连,成功率≈100%,
因此候选列表按 v6 → v4 排序,`EndpointInfo` 两类都带。
### 3.2 同时发包的时间对齐(不需要时钟同步)
两侧 RTT 不同,若下发同一个延迟,RTT 大的一方晚发包就打不上。服务器按
`punch_start_delay_ms(own_rtt, max_rtt, margin)` 给两侧算不同的 `start_in_ms`:
```
start_in_ms = max_rtt_ms / 2 + margin_ms - own_rtt_ms / 2
```
`start_in_ms` 是**相对消息送达**的延迟,所以两端时钟不需要同步,误差≈单程抖动。
### 3.3 保活
NAT 的 UDP 映射默认 30s 左右超时,洞是出站包维持的,超时后静默关闭
(表现为"连上了一会儿就断")。`keepalive_secs()` 取双方 `binding_timeout_s`
较小值折半,夹在 5–60s。
## 4. 提高成功率的实现清单(按性价比)
1. **IPv6 优先**:候选排序已实现;agent 侧需枚举全局 v6 地址填 `CandidateKind::Host`。
2. **UPnP / NAT-PMP / PCP**:agent 启动时就尝试为 `PUNCH_UDP_PORT(47800)` 申请映射,
成功后以 `CandidateKind::Upnp` 补报(走 `ClientMsg::UpdateCandidates`)。
一次成功就把对称 NAT 变成可直连,收益最大。
3. **双方同时发包**:见 §3.2,这是打洞能成的关键。
4. **端口预测**:`PortAllocation::Sequential/Preserved` 时才值得做,`Predicted` 候选由
`sanitize_candidates` 统一清洗(去重、丢端口 0、上限 32 个)。
5. **多轮重试**:`rounds=3 / 200ms`,一次不通不代表不通。
**不要走 TCP 打洞**:QUIC 只跑 UDP,TCP simultaneous open 成功率低,性价比不值得。
## 5. 实现约束(两端各自注意)
- **别拿 QUIC 握手当探测**:`quinn::Endpoint::connect` 打不通会持续重试,失败判定被
idle timeout 拖很久。应先用同一 UDP socket 发轻量探测包确认通路(秒级),
确认后再 `connect`;端口扫描也放这一层,便宜且可并行。
- **UDP socket 复用**:打洞用的 socket 要和后续 QUIC 用同一个,否则洞不在同一个映射上。
`quinn::Endpoint` 支持同时 `accept()` 与 `connect()`,可以在一个 Endpoint 上做完整流程。
- **固定端口**:`47800` 对端口预测和用户手动端口转发都友好,不要随机端口。
## 6. 安全(本次必须同步改,否则公网不可用)
1. **证书钉扎**:`viewer/src/main.rs` 的 `SkipVerify` 必须替换为对
`EndpointInfo.cert_fingerprint` 的校验(`CertFingerprint` 已定义 hex 编解码)。
2. **认证**:`ClientMsg::Register.auth` 必须是一次性验证码(替代固定 `123456`/`888888`),
带过期与失败限速,否则公网等于没有认证。
3. **候选列表视为不可信输入**:一律经 `sanitize_candidates` 后再发包。
## 7. 失败必须有明确出口(无中继的用户体验下限)
没有中继兜底,就必须把"为什么连不上"讲清楚,不允许用户停在"连接中"。
`PunchFailReason::user_hint()` 已给出每种原因对应的可执行建议,例如:
- `Cgnat` → "当前处于运营商级 NAT(大内网),无法直连。请改用 IPv6 网络……"
- `SymmetricNat` → "请在被控端路由器开启 UPnP 后重试。"
- `UdpBlocked` → "当前网络的防火墙拦截了 UDP 出站,请切换手机热点后重试。"
`punch_outlook()` 还能在**开打之前**就给出前景(`Likely/Possible/Unlikely/Hopeless`),
`Hopeless`(CGNAT)时可直接跳过打洞轮次并立刻提示,不让用户白等。
## 8. 后续动作
| 顺序 | 事项 | 负责 |
|---|---|---|
| 1 | `common/src/signaling.rs` 过审合入 main | B 提 PR / A review |
| 2 | 信令服务最小实现(WSS/443,配对 + 回推 observed + 下发 PunchPlan) | B |
| 3 | agent:候选收集(v6/Host/UPnP/Reflexive)、同步打洞、保活 | A |
| 4 | viewer:候选收集、同步打洞、改为证书钉扎 | B |
| 5 | 双机联调:家庭宽带 / 手机热点 / CGNAT 三种网络各跑一轮 | A + B |
+135
View File
@@ -0,0 +1,135 @@
# 打洞连通性验证记录
> 目的:用最小成本确认「自研 P2P 打洞」在本项目目标网络下是否可行,再决定要不要投入实现。
> 工具:`tools/punch_probe.py`(探针)、`tools/rendezvous.py`(极简信令服务器)
> 结论:**本机线路为锥形 NAT + 端口保留,打洞可行性高。**
---
## 1. 测试环境
| 角色 | 地址 | 说明 |
|---|---|---|
| 信令服务器 | `47.98.245.66:47999/udp` | 阿里云 ECS,Ubuntu 24.04.2,python 3.12.3 |
| 客户端 | 出口 `116.3.201.115` | 联通线路,位于 NAT 之后 |
| 工具 | `tools/rendezvous.py` + `tools/punch_probe.py` | 纯 Python 标准库 |
部署位置:`/root/uu-punch-poc/`(与服务器上其他服务完全隔离)
---
## 2. 验证过程与结果
| # | 验证项 | 结果 |
|---|---|---|
| 1 | 信令服务器回环注册 | ✅ 能注册、能记录 |
| 2 | 服务器进程/监听 | ✅ `0.0.0.0:47999`,长期运行稳定 |
| 3 | **跨公网注册** | ✅ 服务器日志出现 `116.3.201.115:47800` |
| 4 | **双方配对与地址回推** | ✅ `配对成功:116.3.201.115:47800 <==> :47801` |
| 5 | 同一 NAT 内两实例互打 | ❌ 失败(见 §4.1,属 hairpinning 限制) |
| 6 | **NAT 映射行为判定** | ✅ **端点无关映射 + 端口保留**(见 §3) |
---
## 3. 关键结论:NAT 类型 = 锥形 + 端口保留
用**同一个本地端口 47800** 分别打两个完全不同的目标,观察公网映射:
| 目标 | 观察到的公网映射 |
|---|---|
| Google STUN(境外) | `116.3.201.115:47800` |
| 自建信令服务器 `47.98.245.66:47999` | `116.3.201.115:47800` |
**不同目标 → 同一个公网端口 → 端点无关映射(Endpoint-Independent Mapping,即锥形 NAT),且端口原样保留。**
按打洞成功率排序:
```
锥形 + 端口保留 ← 本机(最优)
锥形 + 端口改写 成功率高
对称 NAT 困难,需端口预测
CGNAT 基本无解
```
**推论**:信令服务器观察到的地址,对该客户端是**可复用的真实映射**,对端直接向它发包即可打洞,无需端口预测。这是最理想的情形。
复现命令:
```powershell
# 一定要带 --stun-port,用与打洞相同的端口,否则映射不可比
py tools\punch_probe.py --stun --stun-port 47800
```
---
## 4. 踩到的坑(Rust 实现必须处理)
### 4.1 hairpinning:同一公网 IP 下两实例互打会失败
两个客户端都在 `116.3.201.115` 后面,互相打对方的公网地址,需要路由器支持 **NAT 环回(hairpinning)**。本线路不支持。
**这不代表真实打洞会失败**——真实场景是两台机器位于**不同**公网 IP 之后。此测试只能验证"信令配对"链路,不能验证穿透能力。
### 4.2 Windows:ICMP 端口不可达会打断接收循环
对方尚未启动时发包 → 收到 ICMP port unreachable → 下一次 `recv` 抛 `WSAECONNRESET(10054)`。若把它当致命错误 `break`,接收循环就此死掉,之后对方真打进来也收不到。
处理方式(两层):
```python
sock.ioctl(socket.SIO_UDP_CONNRESET, False) # 1) 关闭该行为
# 2) 收到该错误时 continue,绝不能 break
```
**Rust 对应**:`tokio` 的 `recv_from` 会返回 `ConnectionReset`,同样不能 `?` 或 `break`。
### 4.3 发完就退出会漏掉晚到的回包
对方可能比自己晚启动。发完最后一包立即关闭 socket,会把"晚到的成功"误判为失败。
→ 发完后保留 grace 期(默认 2.5s)继续监听。
### 4.4 部署层的两道防火墙
| 层 | 现象 | 处理 |
|---|---|---|
| 主机 ufw | `INPUT DROP`,无规则则静默丢弃 | `ufw allow 47999/udp` |
| 云安全组 | 包到不了网卡(tcpdump 零捕获) | 控制台加 UDP 入方向规则 |
**排查手法**:`tcpdump -n -i any udp port <port>` 零捕获 = 包在到达主机前就被丢,问题在云安全组而不在主机防火墙。
> 注意:首次配置时误把安全组规则设成了 **TCP**,UDP 包仍被丢弃。协议类型必须选 UDP。
### 4.5 假阳性:信令回复被误判为"打通"
客户端在拿到对端地址前会反复注册,服务器的 `PEER` / `WAIT` 回复可能在 `discover_peer` 返回之后才到达。若接收线程不做区分,会把**服务器发来的回复**当成"对方打进来了",输出假的"✅ 打洞成功"。
实测踩到的形态:报告的"对方源地址"是**信令服务器的 IP:端口**(`47.98.245.66:47999`),包长 22 字节——而真正的打洞包固定是 **16 字节**(`PUNCHv1|` + 8 字节时间戳)。
**判据(Rust 实现必须照做)**:
1. 只认以协议魔数开头的包,长度必须是约定值
2. 显式排除信令服务器的 `IP:端口`
3. 结论里如果出现"非打洞包 N 个",说明收到了信令回复,**不能**据此判定成功
---
## 5. 尚未验证
| 项 | 为什么重要 | 怎么验 |
|---|---|---|
| **两台不同网络的机器互打** | 唯一能证明真实穿透能力的测试 | 一台机器 + 一台连手机热点的笔记本 |
| 手机热点(CGNAT)场景 | 决定"无中继"方案对移动端是否成立 | 同上,热点那端即为 CGNAT |
| IPv6 直连 | 若两端都有公网 v6,可绕开 NAT | `--peer [v6地址]:端口` |
| 对端 NAT 是否同样友好 | 打洞需要两端配合 | 在对方网络重复 §3 的映射判定 |
**下一步优先级:手机热点组合 > IPv6 > 其他。**
---
## 6. 对实现的影响
1. **信令服务器的职责就此确认**:注册、配对、回推观察到的源地址。不承载媒体,建连后即退出。
2. **信令与打洞必须复用同一个 socket**:服务器看到的映射端口,必须就是打洞时使用的端口,否则映射不可复用。
- Rust 实现中:该 socket 需继续交给 `quinn::Endpoint` 使用(一次绑定,三处复用:信令 / 打洞 / QUIC)。
3. **失败判定要快**:先做 NAT 行为探测,若判定为对称 NAT 直接给出结论,不必耗完多轮。
4. **保活**:NAT 表项会超时,建连后需按 `binding_timeout_s` 折半做 keepalive(协议中已定义 `keepalive_secs()`)。
+122 -8
View File
@@ -1,23 +1,36 @@
# MVP 远程控制 Demo · 构建运行手册
> 项目路径:`D:\wordkb\2026-10-22-15-52-47\mvp-remote\`
> 形态:Rust workspace,三个 crate:`common`(协议)、`agent`(被控端)、`viewer`(主控端)
> 项目路径:`D:\code\remote\`
> 形态:Rust workspace:`common`(协议)、`agent-core`(平台无关会话层,当前供 linux-agent 使用)、`agent`(Windows 被控端)、`linux-agent`(Linux 被控端)、`viewer`(主控端)
## 1. 功能范围(量级一 Demo)
- ✅ DXGI Desktop Duplication 全屏采集(GPU 拷贝,脏区/硬编码留待二期)
- ✅ LZ4 压缩 + QUIC 加密传输(自签名证书,传输层已加密)
- ✅ DXGI Desktop Duplication 全屏采集 + NVENC 硬编(无 N 卡时自动回落 openh264 软编)
- ✅ QUIC 加密传输(自签名证书,传输层已加密)
- ✅ 验证码认证(默认 123456)
- ✅ 键鼠远程控制(SendInput 注入,虚拟桌面绝对坐标 + 扫描码)
- ✅ 桌面静止时低频重发(保持画面新鲜)
- ❌ 暂无:硬件编码、P2P 打洞/中继(当前仅局域网/可直连地址)、音频、文件传输、Linux 被控
- ✅ Linux 被控端(X11 采集 + XTest 注入,见第 8 节)
- ❌ 暂无:P2P 打洞/中继(当前仅局域网/可直连地址)、音频、文件传输
## 2. 环境要求
- Windows 8+(被控端需要 DXGI Duplication;主控端 Win7 亦可)
- 工具链已全部部署在 D 盘(无需再装):
- Rust (GNU):`uurev/tools/rust/cargo/bin`
- MinGW-w64 14.2:`uurev/tools/mingw64/bin`
- Rust(GNU host:`x86_64-pc-windows-gnu`)+ MinGW-w64,已验证可用组合:
- `rustup default stable-x86_64-pc-windows-gnu`,装到 `%USERPROFILE%\.cargo\bin`
- MinGW-w64:`D:\mingw64\bin`(gcc 16.2.0)
- 构建时 PATH 需同时包含上面两个目录(缺 MinGW 会在链接 openh264 的 C++ 时失败)
- **cargo 源**:国内直连 crates.io 会超时(cargo 不读系统代理),需配置镜像。已在本机
`%USERPROFILE%\.cargo\config.toml` 写入 USTC 源:
```toml
[source.crates-io]
replace-with = "ustc"
[source.ustc]
registry = "sparse+https://mirrors.ustc.edu.cn/crates.io-index/"
```
> 注:旧版本手册写的 `D:\wordkb\...\uurev\tools\rust` 路径已不存在,按上面重新准备工具链即可。
## 3. 构建
@@ -67,6 +80,30 @@ viewer.exe --addr <被控端IP> --port 47800 --code 888888
4. 高 DPI/多屏:当前只采集主输出(EnumOutputs(0))。
5. 主控端窗口固定为远端分辨率,颜色序 BGRA→0RGB 若异常可在 `blit()` 一键翻转。
### 6.1 待修问题(已定位,尚未修,均在 Windows `agent/`)
1. **错误验证码时 `AuthFail` 送不达**:`agent/src/main.rs` 写完 `AuthFail` 后立刻
`send.finish()` 并返回,进程退出会把 QUIC 缓冲区里尚未发出的数据一起带走,
客户端只会看到 `connection lost` 而不是 `AuthFail`。
修法:`finish()` 后等对端读到并挂断(`conn.closed()` + 超时)再退出。
2. **软编路径下静止桌面仍不响应关键帧请求**(`04c02d9` 只修了 NVENC 那一半):
`agent/src/capture.rs` 的 `Ok(false)`(画面未变)分支里,`force_key` 被
`keyreq.swap` 消费后,只有 `EncoderKind::Nvenc` 分支拿它去重编 IDR;
`EncoderKind::Soft` 分支仍然只是「重发最后一帧码流副本」,标志被直接丢弃。
后果:**无 N 卡 / 驱动异常(走软编兜底)的机器上,解码端一旦出错,静止画面下
永远拿不到 IDR,表现为黑屏。**
修法:软编路径同样保留最近一帧裸像素,`force_key` 时重编一个真关键帧。
3. **单次 `KeyFrameReq` 可能被静默吞掉**:同一个 `keyreq.swap(false, …)` 写在了
`if last_sent.elapsed() > 500ms` 的**外面**,因此请求若落在距上次发送 500ms 之内,
标志已被消费但分支不进入,该次请求就丢了,必须等下一次请求才可能生效
(主控端目前周期性重发,故表现为延迟而非永久黑屏)。
修法:把 `swap` 移进时间闸内,或先在闸外预判、进闸后再消费。
> 第 1、2 条在 `feature/agent-linux` 分支的 `agent-core` 里已经实现(**Linux 侧生效**),
> 但按"不与他线争抢 `agent/`"的约定,**本期没有改动 Windows 代码**。
## 7. 下一阶段任务队列(Phase 1 剩余)
1. H.264:接 openh264(软编兜底)→ NVENC/QSV(硬编优先)
@@ -74,3 +111,80 @@ viewer.exe --addr <被控端IP> --port 47800 --code 888888
3. QUIC 多流:视频/控制/键鼠分离流(键鼠零阻塞)
4. P2P 打洞 + coturn 中继(自建信令服务器)
5. 被控端服务化(Windows Service + 会话切换处理)
## 8. Linux 被控端(linux-agent)
### 8.1 现状与边界(重要)
本期**只在新增文件里做,未改动 Windows `agent/`**,原因见下:
| crate / 文件 | 职责 | 当前使用者 |
|---|---|---|
| `agent-core/src/lib.rs` | QUIC/TLS 握手、传输调优、验证码认证、会话/select 循环、latest-wins 单槽、输入分发,以及软件编码视频管线(openh264) | **仅 linux-agent** |
| `linux-agent/src/capture.rs` | X11 `GetImage` 采集 + 整帧变更检测 | Linux |
| `linux-agent/src/input.rs` | XTest 注入 + 扫描码→X11 keycode 映射 | Linux |
| `agent/`(Windows) | 保留原有自持会话循环 + NVENC/软编双路编码,**本期未动** | Windows |
**为什么 Windows 端没有一并接过来**:`agent/` 的 NVENC 路径是「GPU 内 BGRA→H.264 零转换零拷贝」,
编码器必须直接吃 DXGI 的 GPU 纹理;而 `agent-core` 目前的接缝是 `ScreenCapturer`(返回 CPU 上的
BGRA 字节),硬接会把零拷贝优势废掉。正确的接缝应是「平台视频管线产出**已编码帧**」
(`VideoSource` 语义),待 `agent/` 由 NVENC 那条线稳定后再重切,届时应把 `openh264` 从
`agent-core` 移到各平台 crate。
在此之前 Windows 与 Linux 各持一份会话循环,属于**有意的过渡态**:`common/` 协议改动需要两处同步
(例如本期跟着补的 `Pong{lost,sent}`)。
### 8.2 构建
Windows 侧照旧(`default-members` 已排除 linux-agent,产物仍是 agent.exe / viewer.exe):
```bash
export PATH="$USERPROFILE/.cargo/bin:/d/mingw64/bin:$PATH" # Git Bash 写法
cargo build --release
```
Linux 侧显式指定包(跨平台交叉编译不推荐,用容器最省事):
```bash
cargo build --release -p linux-agent
```
### 8.3 运行
```bash
# 被控 Linux 机器(Xorg 或 XWayland 均可,依赖 DISPLAY 环境变量)
./linux-agent --port 47800 --code 123456
# 主控端(Windows)命令行不变
viewer.exe --addr <被控机IP> --port 47800 --code 123456
```
### 8.4 冒烟测试(不需要图形桌面)
```bash
bash scripts/linux-smoke.sh
```
脚本自建 Xvfb 虚拟屏后跑端到端测试,覆盖:X11 采集 → openh264 编码 → QUIC 下行解帧、
验证码认证(含**错误验证码被拒**)、Ping/Pong(含下行丢包统计字段)、静止桌面下的关键帧
恢复,以及**输入注入实证**——注入鼠标移动后,从另一条 X11 连接读回指针真实坐标。
> 静止关键帧那条用例做过**变异测试**:把修复退回会失败、恢复后通过,确认断言不是摆设。
> 注意扰动量必须小(画 64x64 小方块)——整屏刷白会触发编码器场景切换自动 IDR,
> 于是"重发旧帧"和"真重编关键帧"拿到同样的字节,用例就失去区分度。
### 8.5 键码映射(两端最关键的差异)
协议里键盘沿用 Windows 口味的 PS/2 Set-1 扫描码,而 X11 要的是 keycode,两者**并非处处等价**:
- 主键区:满足 `keycode = scancode + 8`;
- **E0 扩展键编号体系完全不同**(`↑` 是 0x48,但 X11 keycode 是 111 而不是 0x50)——必须查表。
映射表在 `linux-agent/src/input.rs::keycode_of`,带单元测试防回归。
### 8.6 Linux 侧已知限制
1. 仅支持 32bpp TrueColor 视觉(绝大多数桌面满足),其他格式启动即明确报错而非静默出错;
2. 采集走核心协议 `GetImage`,每帧经 X socket 全量传输,静止桌面也会持续轮询抓帧;
**MIT-SHM 共享内存 + XDamage 脏区**是下一步优化(锚点已写在 `linux-agent/src/capture.rs` 顶部注释);
3. 仅覆盖 X11 / XWayland;纯 Wayland(无 XWayland)需另走 PipeWire 门户;
4. 键位表按 Xorg evdev 标准 keycode 编写,自定义键盘布局可能需要调整。