//! 界面与后端之间的视频通道:一个只监听回环地址的 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>`:它本身就是引用计数的缓冲,克隆是加一次 /// 引用计数,而且正好是 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), /// 一次掉血,立即推。 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:/?token=...` pub url: String, pub port: u16, /// 当前连着的界面数。用于诊断"画面不动"到底是后端没推还是前端没连。 pub clients: usize, } /// 把一张帧编码成二进制消息。 pub fn encode_frame(frame: &protocol::Frame) -> Vec { 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>, clients: Arc, ) { 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>, clients: Arc, ) -> anyhow::Result<()> { stream.set_nodelay(true).ok(); let expected = token.to_string(); let callback = move |req: &Request, res: Response| -> Result { 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"}"#); } }