把桌面端从 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 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
370 lines
13 KiB
Rust
370 lines
13 KiB
Rust
//! 界面与后端之间的视频通道:一个只监听回环地址的 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"}"#);
|
||
}
|
||
}
|