desktop: Tauri v2 后端 + 共享网络层(用户端/裁判端双身份)

把桌面端从 Slint 迁到 Tauri v2,后端先落地,界面待接。拆成三层:

- desktop/core(cam-core,67 测试):从旧 client/ 抽出的非界面部分。UDP 接收按
  发送方重组、装甲板发现与血量、帧率窗口、槽位分配、配置与密码。
- desktop/src-tauri(27 测试):Tauri 后端。回环 WebSocket 推二进制 JPEG、
  每 200ms 一份状态快照、密码闸门、截图、ffmpeg 录制 mp4、演示数据源。
- protocol:新增 ArmorHealth::decode_from,把帧头里的 sender_id 交出来——
  装甲板一直带着 MAC 派生 id 在广播,旧解码路径把它丢了,所以"装甲板下拉可选"
  不需要改固件。

几个改对了的旧问题:

- 槽位分配:旧实现只排除"槽位自己原来那个 id",于是队伍一的板子一离线,它的槽位
  会去拿队伍二那块,两格显示同一块板子。现在候选要排除另一个槽位占着的 id。
- 帧率窗口的基线:掉线又回来的板子带着一个很大的累计帧数回来,从 0 起算会报出
  几百 fps。改成 seeded + 用"已结算窗口数"判断有没有读数。
- 密码哈希:password-hash 能解析 `$argon2id$broken` 这种截断串,于是"配置坏了"
  会被读成"密码错了"。现在要求盐与摘要都在。
- 录制文件名:同一秒停一次再开一次会用同一个名字,ffmpeg 的 -y 静默覆盖上一段。
  现在先收尾上一段再开新的,并且重名往后排号。
- 两道闸门:帧的限制与发现列表的限制分开。设置界面需要列候选摄像头,但**画面**
  不需要——旧版把两件事绑在一道闸门上,只能在"能列候选"和"挡住对手"之间二选一。

测试:protocol 15、cam-core 67、desktop 27,其中录制那条真的起 ffmpeg 并用
ffprobe 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
This commit is contained in:
2026-10-14 19:14:16 +08:00
parent eafa112959
commit a89d7ae5fb
50 changed files with 15615 additions and 11 deletions
+369
View File
@@ -0,0 +1,369 @@
//! 界面与后端之间的视频通道:一个只监听回环地址的 WebSocket。
//!
//! # 为什么不是 Tauri 的 IPC
//!
//! 两路 22fps、每帧约 22 kB,也就是每秒约 1 MB 的裸 JPEG。走 IPC 的话它要被序列化成
//! JSON(base64 再涨 1/3),再在 JS 侧解回来——CPU 花在没人需要的编解码上,延迟也白
//! 涨一截。WebSocket 的二进制帧把原始 JPEG 字节直接交给前端,浏览器用原生解码器
//! (`createImageBitmap`)解,**后端连 JPEG 都不用解**。
//!
//! # 为什么要有 token
//!
//! 端口是随机分配的,但同机器上任何进程都能连回环地址。这条通道里有实时画面,给它一
//! 个每次启动随机生成的 token(前端从 `stream_info` 命令拿到),至少让"另一个本地程序
//! 顺手连上来看画面"变成一件需要先读到那个 token 的事。
//!
//! 说清楚它的边界:这**不是**防对手的措施——对手要看画面得先在自己的机器上跑这套客户
//! 端,那道闸门在 [`cam_core::receiver::Receiver`] 里。这里的 token 只防止本机上的顺手
//! 连接。
//!
//! # 二进制帧的布局
//!
//! ```text
//! 0 4 8 10 12 16
//! +--------------+--------------+------+------+--------------+---------+
//! | sender_id u32| frame_id u32 | w u16| h u16| jpeg_len u32 | JPEG... |
//! +--------------+--------------+------+------+--------------+---------+
//! ```
//!
//! 小端。16 字节头,前端切掉它就能拿到一段完整的 JPEG。`sender_id` 让前端知道这一帧是
//! 哪块板子的——用户端只画绑定的那一路,裁判端按槽位分配画两路。
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde::Serialize;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::broadcast;
use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
use tokio_tungstenite::tungstenite::http::StatusCode;
use tokio_tungstenite::tungstenite::{Bytes, Message};
use cam_core::snapshot::{HitDto, Snapshot};
/// 二进制帧头长度。
pub const FRAME_HEADER_LEN: usize = 16;
/// 广播通道的容量。
///
/// 满了之后 `send` 会挤掉最老的(接收慢的连接会拿到 `Lagged`)——对实时画面这正是想要
/// 的:落后 200 帧的客户端需要的是最新的那一帧,不是补齐历史。按每帧 16+22 kB 算,
/// 256 个槽位约 5.6 MB 上限。
pub const CHANNEL_CAPACITY: usize = 256;
/// 向界面推送的消息。
pub enum ServerMsg {
/// 一帧原始 JPEG(已经拼好 16 字节头)。
///
/// 载荷用 `Bytes` 而不是 `Arc<Vec<u8>>`:它本身就是引用计数的缓冲,克隆是加一次
/// 引用计数,而且正好是 tungstenite 要的类型——每个连接一次多余的拷贝就此消失。
Binary(Bytes),
/// JSON 文本。
Text(String),
}
/// JSON 文本消息的信封。
///
/// 用内部标签 `t` 而不是两个字段:前端一个 `switch (msg.t)` 就能分发,且不需要为每种
/// 消息各写一次 `JSON.parse` 的错误处理。
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "t", rename_all = "camelCase")]
pub enum WireMsg {
/// 完整状态快照,5 Hz。
Snapshot(Box<Snapshot>),
/// 一次掉血,立即推。
Hit(HitDto),
/// 后端拦截了一次"关闭/退出"(用户端还没解锁)。前端据此弹出解锁对话框。
ConfirmQuit,
/// 一条说明(迁移、录制报错等),前端显示成一行提示。
///
/// 写成带字段的结构体而不是 `Note(String)`:serde 的**内部标签**(`#[serde(tag)]`)
/// 只支持载荷是 map 的变体,newtype 里放一个字符串会在运行时报错,而不是编译期。
Note { message: String },
}
/// 前端连接这条通道需要的信息。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StreamInfo {
/// `ws://127.0.0.1:<port>/?token=...`
pub url: String,
pub port: u16,
/// 当前连着的界面数。用于诊断"画面不动"到底是后端没推还是前端没连。
pub clients: usize,
}
/// 把一张帧编码成二进制消息。
pub fn encode_frame(frame: &protocol::Frame) -> Vec<u8> {
let mut out = Vec::with_capacity(FRAME_HEADER_LEN + frame.data.len());
out.extend_from_slice(&frame.sender_id.to_le_bytes());
out.extend_from_slice(&frame.frame_id.to_le_bytes());
out.extend_from_slice(&frame.width.to_le_bytes());
out.extend_from_slice(&frame.height.to_le_bytes());
out.extend_from_slice(&(frame.data.len() as u32).to_le_bytes());
out.extend_from_slice(&frame.data);
out
}
/// 从二进制消息里解出帧头。前后端对这段字节布局的约定由测试盯着。
#[cfg(test)]
pub fn decode_frame_header(message: &[u8]) -> Option<(u32, u32, u16, u16, usize)> {
if message.len() < FRAME_HEADER_LEN {
return None;
}
let sender_id = u32::from_le_bytes(message[0..4].try_into().ok()?);
let frame_id = u32::from_le_bytes(message[4..8].try_into().ok()?);
let width = u16::from_le_bytes(message[8..10].try_into().ok()?);
let height = u16::from_le_bytes(message[10..12].try_into().ok()?);
let len = u32::from_le_bytes(message[12..16].try_into().ok()?) as usize;
if message.len() < FRAME_HEADER_LEN + len {
return None;
}
Some((sender_id, frame_id, width, height, len))
}
/// 绑一个随机端口的回环 listener。
///
/// 随机端口而不是固定端口:同一台机器上同时开用户端和裁判端时不会撞车。
pub async fn bind() -> std::io::Result<(TcpListener, u16)> {
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
let port = listener.local_addr()?.port();
Ok((listener, port))
}
/// 接受循环。每个连接一个任务,活到连接断开。
pub async fn serve(
listener: TcpListener,
token: String,
tx: broadcast::Sender<Arc<ServerMsg>>,
clients: Arc<AtomicUsize>,
) {
loop {
let (stream, peer) = match listener.accept().await {
Ok(pair) => pair,
Err(err) => {
log::warn!("stream: accept failed: {err}");
tokio::time::sleep(Duration::from_millis(200)).await;
continue;
}
};
let tx = tx.clone();
let token = token.clone();
let clients = Arc::clone(&clients);
tauri::async_runtime::spawn(async move {
if let Err(err) = handle_client(stream, peer, &token, tx, clients).await {
log::debug!("stream: client {peer} ended: {err}");
}
});
}
}
async fn handle_client(
stream: TcpStream,
peer: std::net::SocketAddr,
token: &str,
tx: broadcast::Sender<Arc<ServerMsg>>,
clients: Arc<AtomicUsize>,
) -> anyhow::Result<()> {
stream.set_nodelay(true).ok();
let expected = token.to_string();
let callback = move |req: &Request, res: Response| -> Result<Response, ErrorResponse> {
let query = req.uri().query().unwrap_or("");
let presented = query
.split('&')
.filter_map(|pair| pair.split_once('='))
.find(|(key, _)| *key == "token")
.map(|(_, value)| value.to_string());
match presented {
Some(value) if constant_time_eq(&value, &expected) => Ok(res),
_ => {
log::warn!("stream: refusing {peer}: wrong or missing token");
let mut response = ErrorResponse::new(Some("bad token".into()));
*response.status_mut() = StatusCode::FORBIDDEN;
Err(response)
}
}
};
let ws = tokio_tungstenite::accept_hdr_async(stream, callback).await?;
let (mut sink, mut incoming) = ws.split();
let mut rx = tx.subscribe();
let mut keepalive = tokio::time::interval(Duration::from_secs(20));
let count = clients.fetch_add(1, Ordering::Relaxed) + 1;
log::info!("stream: interface connected from {peer} ({count} connected)");
// 连上就先推一条提示,前端能据此在界面上显示"通道已接通"。
let hello = serde_json::to_string(&WireMsg::Note {
message: "video channel ready".into(),
})
.unwrap_or_else(|_| "{}".into());
let _ = sink.send(Message::text(hello)).await;
let result = loop {
tokio::select! {
message = rx.recv() => match message {
Ok(msg) => {
let out = match &*msg {
ServerMsg::Binary(bytes) => Message::Binary(bytes.clone()),
ServerMsg::Text(text) => Message::text(text.clone()),
};
if let Err(err) = sink.send(out).await {
break Err(anyhow::anyhow!("send failed: {err}"));
}
}
// 接收慢,积压被挤掉了。对视频这是正常且期望的:跳过旧帧,继续送新的。
Err(broadcast::error::RecvError::Lagged(skipped)) => {
log::debug!("stream: {peer} lagged {skipped} messages; skipping ahead");
}
Err(broadcast::error::RecvError::Closed) => {
break Ok(());
}
},
_ = keepalive.tick() => {
// WKWebView 侧的连接可能在休眠后静默失效。ping 让这种情况变成一次明确的
// 发送失败,而不是一个永远不刷新的窗口。
if let Err(err) = sink.send(Message::Ping(Bytes::new())).await {
break Err(anyhow::anyhow!("keepalive failed: {err}"));
}
}
incoming_msg = incoming.next() => match incoming_msg {
None => break Ok(()),
Some(Err(err)) => break Err(anyhow::anyhow!("receive failed: {err}")),
// 这条通道是单向的:前端发来的东西(pong 除外)一律忽略。
Some(Ok(_)) => {}
},
}
};
let count = clients.fetch_sub(1, Ordering::Relaxed) - 1;
log::info!("stream: interface disconnected ({count} connected)");
result
}
/// 定长比较,避免 token 校验泄露时间信息。
fn constant_time_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b) {
diff |= x ^ y;
}
diff == 0
}
/// 生成一个随机 token。
pub fn random_token() -> String {
use rand::RngCore;
let mut bytes = [0u8; 24];
rand::rng().fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(sender: u32, id: u32, len: usize) -> protocol::Frame {
protocol::Frame {
sender_id: sender,
frame_id: id,
width: 640,
height: 480,
data: vec![0xAB; len],
}
}
#[test]
fn the_binary_header_roundtrips() {
let encoded = encode_frame(&frame(0x3D4D_8A9A, 4242, 22_000));
assert_eq!(encoded.len(), FRAME_HEADER_LEN + 22_000);
assert_eq!(
decode_frame_header(&encoded),
Some((0x3D4D_8A9A, 4242, 640, 480, 22_000))
);
}
#[test]
fn a_truncated_message_is_refused_rather_than_read_past_the_end() {
let mut encoded = encode_frame(&frame(1, 1, 100));
encoded.truncate(FRAME_HEADER_LEN + 40);
// 头里声明 100 字节,实际只有 40:宁可返回 None,也不要让前端拿到一段不完整的
// JPEG(那会解码失败,或者更糟——解出一张半截的图)。
assert_eq!(decode_frame_header(&encoded), None);
assert_eq!(decode_frame_header(&[0u8; 4]), None);
}
#[test]
fn the_token_comparison_is_exact() {
assert!(constant_time_eq("abc", "abc"));
assert!(!constant_time_eq("abc", "abd"));
assert!(!constant_time_eq("abc", "abcd"));
assert!(!constant_time_eq("", "a"));
assert!(constant_time_eq("", ""));
}
#[test]
fn tokens_differ_between_runs() {
assert_ne!(random_token(), random_token());
assert_eq!(random_token().len(), 48);
}
#[test]
fn the_wire_envelope_carries_the_message_kind() {
let json = serde_json::to_string(&WireMsg::Hit(HitDto {
board: 1,
team: 0,
index: 2,
from: 100,
to: 97,
damage: 3,
at_ms: 1,
}))
.unwrap();
assert!(json.contains(r#""t":"hit""#), "got {json}");
assert!(json.contains(r#""damage":3"#), "got {json}");
}
#[test]
fn a_unit_variant_is_just_a_tag() {
let json = serde_json::to_string(&WireMsg::ConfirmQuit).unwrap();
assert_eq!(json, r#"{"t":"confirmQuit"}"#);
}
#[test]
fn a_note_is_tagged_too() {
let json = serde_json::to_string(&WireMsg::Note {
message: "hi".into(),
})
.unwrap();
assert_eq!(json, r#"{"t":"note","message":"hi"}"#);
}
}