Files
simba-rmic-2026/desktop/src-tauri/src/server.rs
T
Horldsence a89d7ae5fb 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 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
2026-10-14 19:14:16 +08:00

370 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 界面与后端之间的视频通道:一个只监听回环地址的 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"}"#);
}
}