定位(2026-10-29 方案裁决 A1-A4): 纯 Rust、平台无关,只做「怎么连 + 收发什么」——QUIC 会话、协议 v2 认证、 视频帧流、输入上行、Ping/Pong、KeyFrameReq。解码/渲染/触摸全在 Kotlin 侧, 本 crate 不碰像素、不依赖 openh264(dev-dependency 仅验证工具用)。 设计要点 - PeerTarget::Direct 是唯一的连接形态;不做中继、不定义 Relay 变体 (《多平台架构设计》§6.1 裁决)。将来设备码+打洞只换 connect() 内部。 - 帧经容量 2 的 mpsc 交付,**不丢帧**:跳帧决策属于看得到解码器状态的消费方 (Kotlin),Rust 层丢帧会重演 R-3 教训(跳帧断参考链)。 - insecure_skip_verify=false 时明确报错而非静默放行——证书钉扎登记在方案 阶段 4,安全红线不允许默默降级。 - 双流结构与桌面 viewer 一致(流1 认证+视频,流2 输入+控制),入会即请求 IDR。 验证(headless,桌面→桌面 127.0.0.1) - 单测 3 passed(帧契约、默认配置、strict-verify 显式报错) - examples/headless_check:300 帧,seq 违例 0、keyframe 标志不符 0、 解码成功 300/300、RTT 路径工作(RTT=1ms 出现) - 全 workspace 回归:common 20 passed,构建全绿
148 lines
5.0 KiB
Rust
148 lines
5.0 KiB
Rust
//! viewer-core 的桌面 headless 验证工具(阶段 1 验收用,不依赖 Android)。
|
||
//!
|
||
//! 证明三件事,逐条打印:
|
||
//! 1. viewer-core 能完成 QUIC 连接 + 协议 v2 认证握手;
|
||
//! 2. 帧流按契约交付(seq 单调递增、keyframe 标志与码流实际 NAL 一致);
|
||
//! 3. 收到的码流能被 openh264 解出画面(即:Android 侧 MediaCodec 将拿到同样
|
||
//! 合法的 Annex-B——解码器不同,但码流合法性在这里就证明了)。
|
||
//!
|
||
//! 用法:
|
||
//! 终端1: target\release\agent.exe --port 47900 --code t
|
||
//! 终端2: cargo run -p viewer-core --bin headless_check -- --port 47900 --code t --frames 60
|
||
|
||
use anyhow::Result;
|
||
use std::time::Duration;
|
||
use viewer_core::{ConnectConfig, Session};
|
||
|
||
fn arg(name: &str) -> Option<String> {
|
||
let a: Vec<String> = std::env::args().collect();
|
||
a.iter()
|
||
.position(|x| x == name)
|
||
.and_then(|i| a.get(i + 1))
|
||
.cloned()
|
||
}
|
||
|
||
/// 与 dump_stream 同款:扫 Annex-B 起始码拿 NAL 类型,交叉校验 keyframe 标志
|
||
fn has_idr(payload: &[u8]) -> bool {
|
||
let mut i = 0usize;
|
||
while i + 3 < payload.len() {
|
||
let sc = if payload[i..].starts_with(&[0, 0, 0, 1]) {
|
||
4
|
||
} else if payload[i..].starts_with(&[0, 0, 1]) {
|
||
3
|
||
} else {
|
||
i += 1;
|
||
continue;
|
||
};
|
||
match payload.get(i + sc) {
|
||
Some(&b) if b & 0x1F == 5 => return true,
|
||
Some(_) => i += sc + 1,
|
||
None => break,
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
#[tokio::main]
|
||
async fn main() -> Result<()> {
|
||
let port: u16 = arg("--port")
|
||
.map(|s| s.parse())
|
||
.transpose()?
|
||
.unwrap_or(47800);
|
||
let code = arg("--code").unwrap_or_else(|| "123456".into());
|
||
let frames_wanted: u64 = arg("--frames")
|
||
.map(|s| s.parse())
|
||
.transpose()?
|
||
.unwrap_or(60);
|
||
|
||
let mut session = Session::connect(ConnectConfig::direct("127.0.0.1", port, code)).await?;
|
||
println!("[check] 连接成功 peer={}", session.peer_addr());
|
||
|
||
let mut decoder = openh264::decoder::Decoder::new()?;
|
||
let mut last_seq: Option<u64> = None;
|
||
let mut got = 0u64;
|
||
let mut seq_violations = 0u64;
|
||
let mut kf_mismatches = 0u64;
|
||
let mut decoded = 0u64;
|
||
let mut decode_failed_before_idr = 0u64;
|
||
let mut awaiting_idr = true; // 与 Android 侧相同的状态机
|
||
let mut rtt_shown = false;
|
||
|
||
let deadline = std::time::Instant::now() + Duration::from_secs(30);
|
||
while got < frames_wanted && std::time::Instant::now() < deadline {
|
||
let Some(f) = tokio::time::timeout(Duration::from_secs(10), session.next_frame())
|
||
.await
|
||
.ok()
|
||
.flatten()
|
||
else {
|
||
break;
|
||
};
|
||
got += 1;
|
||
|
||
// 契约 2a:seq 单调递增
|
||
if let Some(prev) = last_seq {
|
||
if f.seq <= prev {
|
||
seq_violations += 1;
|
||
println!("[check] ⚠️ seq 回退: {prev} -> {}", f.seq);
|
||
}
|
||
}
|
||
last_seq = Some(f.seq);
|
||
|
||
// 契约 2b:keyframe 标志与码流一致
|
||
let real_kf = has_idr(&f.payload);
|
||
if f.keyframe != real_kf {
|
||
kf_mismatches += 1;
|
||
println!(
|
||
"[check] ⚠️ 帧头 keyframe={} 但码流实际 IDR={}(seq={})",
|
||
f.keyframe, real_kf, f.seq
|
||
);
|
||
}
|
||
|
||
// 契约 3:与 Android 相同的状态机下解码必须成功
|
||
if awaiting_idr && !f.keyframe {
|
||
continue; // 等 IDR,P 帧不喂(这是 R-3 教训的实现)
|
||
}
|
||
if f.keyframe {
|
||
awaiting_idr = false;
|
||
}
|
||
match decoder.decode(&f.payload) {
|
||
Ok(_) => decoded += 1,
|
||
Err(e) => {
|
||
decode_failed_before_idr += 1;
|
||
println!("[check] ❌ seq={} 解码失败: {e}", f.seq);
|
||
awaiting_idr = true;
|
||
session.request_keyframe();
|
||
}
|
||
}
|
||
|
||
// RTT 出现即记录一次
|
||
if !rtt_shown {
|
||
let s = session.stats();
|
||
if s.rtt_ms > 0 {
|
||
println!("[check] RTT={}ms frames_in={} bytes_in={}", s.rtt_ms, s.frames_in, s.bytes_in);
|
||
rtt_shown = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
let s = session.stats();
|
||
println!("\n========== 阶段 1 headless 验收 ==========");
|
||
println!("收到帧: {got}(目标 {frames_wanted})");
|
||
println!("seq 违例: {seq_violations}");
|
||
println!("keyframe 标志不符: {kf_mismatches}");
|
||
println!("解码成功: {decoded} / 解码失败: {decode_failed_before_idr}");
|
||
println!("统计: rtt={}ms frames_in={} bytes_in={}", s.rtt_ms, s.frames_in, s.bytes_in);
|
||
|
||
let ok = got >= frames_wanted.min(30)
|
||
&& seq_violations == 0
|
||
&& kf_mismatches == 0
|
||
&& decoded > 0
|
||
&& decode_failed_before_idr == 0;
|
||
println!(
|
||
"结论: {}",
|
||
if ok { "✅ viewer-core 收帧链路通过" } else { "❌ 存在未满足项" }
|
||
);
|
||
session.close().await;
|
||
Ok(())
|
||
}
|