//! 进程内的共享状态。 //! //! 这里是"网络层的推"与"界面的拉"交汇的地方: //! //! - **推**:接收线程拿到帧就交给 sink,sink 直接发进广播通道(不阻塞、不排队); //! 装甲板上报推进一个 [`HealthTracker`],派生出掉血事件就立刻推一条。 //! - **拉**:一个 5 Hz 的循环把当前状态拼成一份 [`Snapshot`] 推出去。界面不需要任何 //! 轮询命令,它只是渲染收到的那份状态。 //! //! # 密码闸门的执行点在这里,不在界面里 //! //! 所有会改绑定的命令都过 [`AppState::require_unlocked`]。界面上有一个解锁对话框,但那 //! **只是入口**:即使有人从控制台直接调 `invoke("bind_camera")`,一样要过这一关。这和 //! 旧客户端把防作弊放在**接收端**而不是界面里是同一条原则。 use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use cam_core::armor::{ArmorHealthStore, HealthTracker}; use cam_core::config::{Config, Role}; use cam_core::password; use cam_core::receiver::{Discovery, LatestFrames, Receiver}; use cam_core::slots::SlotAllocator; use cam_core::snapshot::{ resolve_outcomes, ArmorBoardDto, CameraDto, CameraStatsDto, ConfigView, HitDto, SlotsDto, Snapshot, TeamHealthDto, }; use cam_core::stats::{FpsWindow, DEFAULT_WINDOW}; use cam_core::ArmorSource; use tokio::sync::{broadcast, watch}; use crate::recording::{now_ms, RecordingSet}; use crate::server::{self, ServerMsg, WireMsg}; /// 状态快照的推送频率。 /// /// 5 Hz:血量心跳是 200 ms,所以这个频率刚好不漏掉一次掉血;而再快也只是让界面做 /// 无意义的重绘。 pub const SNAPSHOT_INTERVAL: Duration = Duration::from_millis(200); /// 界面端是否产出技术读数。 /// /// 由**编译模式**决定而不是命令行参数:命令行开关意味着二进制里带着那套东西,忘开 /// 一次就泄露了。裁判端不受影响——它是中立方,双方遥测对判罚有用。 pub const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions); pub struct AppState { pub config_path: PathBuf, config: Mutex, /// 会话内解锁(见模块文档)。 unlocked: AtomicBool, /// 设置面板是否打开,决定发现列表放不放开。 discovery_open: AtomicBool, pub receiver: Arc, pub armor: Arc, discovery: Arc, latest: Arc, /// 每一路的帧率窗口。掉线的摄像头会被清掉,回来时重新建。 fps: Mutex>, slots: Mutex, pub recordings: Arc, tx: broadcast::Sender>, video: server::VideoSender, /// 回环上已经绑好的 WebSocket listener,等 `spawn_background` 取走。 /// /// 先绑再交给后台任务,是为了让"端口绑不上"在启动阶段就报错,而不是变成一个永远 /// 连不上的界面。 listener: Mutex>, pub port: u16, token: String, clients: Arc, pub demo: bool, notes: Mutex>, started: Instant, } impl AppState { /// 建好状态,并绑好两个 UDP 端口。 /// /// 视频端口(5600)绑不上是**致命**的:整个程序的存在意义就是收画面,绑不上应该 /// 立刻报错,而不是开一个永远不出画面的窗口。装甲板端口(5601)绑不上只是没有 /// 血量,退回占位符即可——血量显示不了不该让整个客户端起不来。 pub fn new( config_path: PathBuf, config: Config, mut notes: Vec, demo: bool, ) -> Result<( Arc, Option, Option, )> { let role = config.role; let bound = if role == Role::Team { config.camera_id } else { None }; // 视频端口绑不上**不**致命:界面照常起来,并把原因写在最显眼的地方。 // // 一开始这里是 `?`(直接退出)。但那样一来,现场看到的是一次闪退加上一句只出现 // 在 stderr 的话——从访达双击启动时连 stderr 都没有。让窗口起来并说明原因,比 // 静默退出有用得多;而且"另一个客户端已经在跑"恰好是最常见的原因。 let (receiver, video_socket) = match Receiver::start(protocol::BROADCAST_ADDR, role.to_protocol(), bound) { Ok((receiver, socket)) => (Arc::new(receiver), Some(socket)), Err(err) => { log::error!("could not bind UDP {}: {err}", protocol::DEFAULT_PORT); notes.push(format!( "绑不上 UDP {}:{err}。多半是同一个端口上已经跑着另一个客户端 \ (旧的 Slint 版或另一份本程序)。画面收不到,其它功能照常。", protocol::DEFAULT_PORT )); ( Arc::new(Receiver::new( protocol::BROADCAST_ADDR, role.to_protocol(), bound, )), None, ) } }; let discovery = receiver.discovery(); let latest = receiver.latest_frames(); // 装甲板:两端都要收。用户端要显示**双方**的血条(左上自己、右上对手),裁判端也是双方。 let (armor, armor_socket) = match ArmorHealthStore::start() { Ok((store, socket)) => (store, Some(socket)), Err(err) => { log::warn!( "armor: could not listen on UDP {}: {err}; health stays a placeholder", protocol::ARMOR_PORT ); (Arc::new(ArmorHealthStore::new()), None) } }; let (tx, _) = broadcast::channel(server::CHANNEL_CAPACITY); let (video, _) = watch::channel(server::VideoFrames::new()); let (listener, port) = tauri::async_runtime::block_on(server::bind()) .context("绑不上本地 WebSocket 端口(回环地址)")?; let state = Arc::new(Self { config_path, config: Mutex::new(config), unlocked: AtomicBool::new(false), discovery_open: AtomicBool::new(false), receiver, armor, discovery, latest, fps: Mutex::new(HashMap::new()), slots: Mutex::new(SlotAllocator::new()), recordings: RecordingSet::new(), tx, video, listener: Mutex::new(Some(listener)), port, token: server::random_token(), clients: Arc::new(AtomicUsize::new(0)), demo, notes: Mutex::new(notes), started: Instant::now(), }); state.apply_gates(); Ok((state, video_socket, armor_socket)) } /// 让视频通道开始工作:把帧与装甲板上报接到广播通道上。 /// /// 必须在 `Arc` 建好之后调用(sink 里要持有 state 的通道)。 pub fn install_sinks(self: &Arc) { let video = self.video.clone(); let recordings = Arc::clone(&self.recordings); // 帧的 sink:在接收线程里跑,只做"编码一次 + 广播"。这一份字节所有界面共用。 self.receiver .set_frame_sink(Arc::new(move |frame: Arc| { recordings.feed(&frame); server::publish_video(&video, &frame); })); let tx = self.tx.clone(); let tracker = Arc::new(Mutex::new(HealthTracker::new())); // 装甲板的 sink:把"现在多少血"派生为"刚刚掉了几点",立刻推给界面。 self.armor.set_sink(Arc::new(move |report| { let hit = tracker .lock() .expect("health tracker mutex poisoned") .observe(&report); let Some(hit) = hit else { return; }; log::info!( "armor: board 0x{:08X} took {} damage ({} -> {})", hit.board, hit.damage, hit.from, hit.to ); let dto = HitDto { board: hit.board, team: hit.team, index: hit.index, from: hit.from, to: hit.to, damage: hit.damage, at_ms: now_ms(), }; let _ = tx.send(Arc::new(ServerMsg::Text( serde_json::to_string(&WireMsg::Hit(dto)).unwrap_or_default(), ))); })); } /// 起 WebSocket 服务与快照循环。 pub fn spawn_background(self: &Arc) { let Some(listener) = self .listener .lock() .expect("listener mutex poisoned") .take() else { log::warn!("spawn_background called twice; the stream server is already running"); return; }; let state = Arc::clone(self); let token = self.token.clone(); let tx = self.tx.clone(); let clients = Arc::clone(&self.clients); let video = self.video.clone(); tauri::async_runtime::spawn(async move { server::serve(listener, token, tx, video, clients).await; }); let state_for_loop = Arc::clone(&state); tauri::async_runtime::spawn(async move { let mut ticker = tokio::time::interval(SNAPSHOT_INTERVAL); loop { ticker.tick().await; state_for_loop.tick(); state_for_loop.push_snapshot(); } }); } // ----------------------------------------------------------------------- // 配置 // ----------------------------------------------------------------------- pub fn config(&self) -> Config { self.config.lock().expect("config mutex poisoned").clone() } /// 改配置、落盘、重新套用闸门。 pub fn update_config(&self, change: impl FnOnce(&mut Config)) -> Result { let updated = { let mut config = self.config.lock().expect("config mutex poisoned"); change(&mut config); // Guard 上不能直接调 `normalized()`(它取走 self)。克隆一份再归一化:配置 // 只有几百字节,而这里每次都是人点一下才发生。 config.clone().normalized() }; updated .save(&self.config_path) .context("写入配置文件失败;改动只在本次运行内有效")?; *self.config.lock().expect("config mutex poisoned") = updated.clone(); self.apply_gates(); Ok(updated) } /// 按当前配置重新设置两道闸门。 /// /// 每一次可能改变身份、绑定或发现开合的改动之后都要调用它——把这件事收在一个函数 /// 里,是为了不让"某个命令忘了重新套用闸门"这种漏洞有时间长出来。 pub fn apply_gates(&self) { let config = self.config(); let role = config.role; self.receiver.set_role(role.to_protocol()); // 第一道闸门:帧。用户端只看绑定的那一块;裁判端看全部。 let limit = match role { Role::Team => config.camera_id, Role::Referee => None, }; self.receiver.restrict_to(limit); // 第二道闸门:发现列表。只有用户端在**解锁后**打开设置面板时才放开;裁判端 // 本来就要看到全场。 let open = match role { Role::Referee => true, Role::Team => { self.discovery_open.load(Ordering::Relaxed) && self.unlocked.load(Ordering::Relaxed) } }; self.receiver.set_discovery_all(open); if !open { self.discovery_open.store(false, Ordering::Relaxed); } } // ----------------------------------------------------------------------- // 密码闸门 // ----------------------------------------------------------------------- pub fn is_unlocked(&self) -> bool { // 裁判端没有密码(见 Role::needs_password),所以它永远是"已解锁"。 !self.config().role.needs_password() || self.unlocked.load(Ordering::Relaxed) } /// 校验一次密码,成功就把会话标记为已解锁。 pub fn unlock(&self, presented: &str) -> Result { let stored = self.config().password; let Some(stored) = stored else { // 还没设过密码:任何输入都不该被当成"通过"。 return Ok(false); }; let ok = password::verify_password(presented, &stored)?; if ok { self.unlocked.store(true, Ordering::Relaxed); log::info!("密码校验通过;本次运行内不再询问"); } else { log::warn!("密码校验失败"); } Ok(ok) } /// 手动重新上锁。 pub fn lock(&self) { self.unlocked.store(false, Ordering::Relaxed); self.discovery_open.store(false, Ordering::Relaxed); self.apply_gates(); } /// 改绑之前的守门人。 /// /// `presented` 允许调用方在同一次调用里带上密码(界面上就是这样:解锁对话框收起后 /// 用户立刻做改动),但会话已经解锁时不需要它。 pub fn require_unlocked(&self, presented: Option<&str>) -> Result<(), String> { if self.is_unlocked() { return Ok(()); } if let Some(presented) = presented { match self.unlock(presented) { Ok(true) => return Ok(()), Ok(false) => { return Err("密码不对。改摄像头/装甲板绑定、切换队伍都需要密码。".into()) } Err(err) => return Err(format!("存下来的密码哈希读不出来:{err}")), } } Err("这个改动需要密码。先解锁(密码只是防止现场随手改绑,见 README 的说明)。".into()) } /// 打开/关闭设置面板带来的发现列表。 pub fn set_discovery_open(&self, open: bool) -> Result<(), String> { if open { self.require_unlocked(None)?; } self.discovery_open.store(open, Ordering::Relaxed); self.apply_gates(); Ok(()) } /// 设置或修改密码。用户端第一次启动必须设。 pub fn set_password(&self, current: Option<&str>, fresh: &str) -> Result<()> { let existing = self.config().password; if let Some(existing) = existing { // 已有密码:必须能证明知道旧的(或者在本次运行里已经解锁过)。 let trusted = self.unlocked.load(Ordering::Relaxed) || current .map(|current| password::verify_password(current, &existing).unwrap_or(false)) .unwrap_or(false); if !trusted { anyhow::bail!("修改密码需要先输入当前密码"); } } let hash = password::hash_password(fresh)?; self.update_config(move |config| config.password = Some(hash))?; self.unlocked.store(true, Ordering::Relaxed); Ok(()) } // ----------------------------------------------------------------------- // 推送 // ----------------------------------------------------------------------- pub fn push(&self, msg: WireMsg) { let text = match serde_json::to_string(&msg) { Ok(text) => text, Err(err) => { log::error!("could not serialise a message for the interface: {err}"); return; } }; // 没有连接时发送会失败,这是正常情况(界面还没起来,或者刚刷新)。 let _ = self.tx.send(Arc::new(ServerMsg::Text(text))); } pub fn push_snapshot(&self) { self.push(WireMsg::Snapshot(Box::new(self.snapshot()))); } pub fn note(&self, message: impl Into) { let message = message.into(); log::info!("{message}"); self.notes .lock() .expect("notes mutex poisoned") .push(message.clone()); self.push(WireMsg::Note { message }); } pub fn stream_info(&self) -> server::StreamInfo { server::StreamInfo { url: format!("ws://127.0.0.1:{}/?token={}", self.port, self.token), port: self.port, clients: self.clients.load(Ordering::Relaxed), } } pub fn latest_frames(&self) -> Arc { Arc::clone(&self.latest) } // ----------------------------------------------------------------------- // 每 200ms 推进一次 // ----------------------------------------------------------------------- /// 推进帧率窗口与槽位分配。 pub fn tick(&self) { let online: Vec = self .discovery .online_cameras() .iter() .map(|c| c.id) .collect(); let online_set: HashSet = online.iter().copied().collect(); { let mut fps = self.fps.lock().expect("fps mutex poisoned"); let now = Instant::now(); // 掉线的摄像头把窗口丢掉。留着的话,它回来时累计帧数已经涨了一大截,下一 // 次结算会报出一个几百 fps 的假数字。 fps.retain(|id, _| online_set.contains(id)); for id in &online { let stats = self.discovery.stats(*id); fps.entry(*id) .or_insert_with(|| FpsWindow::seeded(DEFAULT_WINDOW, now, stats.frames_shown)) .observe(stats.frames_shown, now); } } let config = self.config(); let mut slots = self.slots.lock().expect("slots mutex poisoned"); match config.role { Role::Referee => { if config.referee.manual_slots { // 手动:以配置为准。现场有烧错固件的板子时,人需要能压过自动分配。 slots.force(0, config.referee.slot_a); slots.force(1, config.referee.slot_b); } else { slots.assign(&online); } } // 用户端只有一个"槽位":它绑定的那一块。 Role::Team => { slots.force(0, config.camera_id); slots.force(1, None); } } } fn diagnostics(&self) -> bool { self.config().role == Role::Referee || TEAM_DIAGNOSTICS } /// 某块摄像头的帧率(用于录制时告诉 ffmpeg 输入帧率)。 pub fn fps_of(&self, camera: u32) -> Option { let fps = self.fps.lock().expect("fps mutex poisoned"); let window = fps.get(&camera)?; if window.has_reading() { Some(window.fps()) } else { None } } // ----------------------------------------------------------------------- // 快照 // ----------------------------------------------------------------------- pub fn snapshot(&self) -> Snapshot { let config = self.config(); let diagnostics = self.diagnostics(); let cameras: Vec = self .discovery .cameras() .iter() .map(CameraDto::from) .collect(); let stats: Vec = self .discovery .cameras() .iter() .map(|camera| { let stats = self.discovery.stats(camera.id); let fps = self.fps_of(camera.id).unwrap_or(0.0); CameraStatsDto::build(diagnostics, camera.id, camera.is_online(), &stats, fps) }) .collect(); let armor_boards: Vec = self .armor .boards() .iter() .map(ArmorBoardDto::from) .collect(); let slots = self.slots.lock().expect("slots mutex poisoned").slots(); // 两边都给:用户端的画面右上角要显示对手的血量,裁判端本来就要双方。 // // 顺序固定为队伍号 0、1,界面按 `team` 取那一格——**不靠数组下标**判断"哪个是我", // 因为下标在"用户端绑的是队伍二"时就不再等于自己的队伍号了。 let mut health = vec![self.health_for(0), self.health_for(1)]; // 胜负是**两边合起来**才能算出来的东西,所以在这里补一步。 resolve_outcomes(&mut health); Snapshot { role: config.role, team: config.team, mirrored: config.mirror, demo: self.demo, diagnostics, unlocked: self.is_unlocked(), discovery_open: self.discovery_open.load(Ordering::Relaxed), uptime_ms: self.started.elapsed().as_millis() as u64, clients: self.clients.load(Ordering::Relaxed), config: ConfigView::from(&config), cameras, armor_boards, stats, slots: SlotsDto { camera_a: slots[0], camera_b: slots[1], armor_a: self.assigned_armor(&config, 0), armor_b: self.assigned_armor(&config, 1), manual: config.referee.manual_slots, }, health, recordings: self.recordings.active(), notes: self.notes.lock().expect("notes mutex poisoned").clone(), } } /// 一边的装甲板主板:手动指定优先,否则按板子自报的 `team` 自动找。 /// /// 槽位指定只有**裁判端**有:`referee.armor_a/b` 是裁判端界面上的"左格/右格",用户端 /// 拿它当自己的板子会让绑定被一份不相干的配置盖掉。 fn assigned_armor(&self, config: &Config, team: u8) -> Option { let explicit = if config.role == Role::Referee { if team == 0 { config.referee.armor_a } else { config.referee.armor_b } } else { None }; if config.role == Role::Referee && config.referee.manual_slots { return explicit; } explicit.or_else(|| { self.armor .boards() .iter() .find(|board| board.team == team && board.is_online()) .map(|board| board.id) }) } /// 一边的血量。 /// /// 顺序是有意的:先看绑定的那块板子,再看"这一边有没有人报血量"。绑定了却在掉线 /// 状态时要显示**未接入**而不是拿别人的数据顶上——现场会以为对手还有血。 /// /// # 对手那一块绝不能用 `armor_id` /// /// `armor_id` 是**我自己**绑定的主板。拿它去填对手那一格,右上角显示的就是自己的血—— /// 比不显示更糟:现场会照着一个假的对手血量做决策。 fn health_for(&self, team: u8) -> TeamHealthDto { let config = self.config(); let low = config.hud.health.low_threshold; let own = config.role == Role::Team && team == config.team; let bound = if own { config.armor_id } else { None }; let board = bound.or_else(|| self.assigned_armor(&config, team)); if let Some(board) = board { return match self.armor.health_of_board(board) { Some(hp) => { TeamHealthDto::present(team, Some(board), [hp; 4], low, &config.team_names) } None => TeamHealthDto::absent(team, Some(board), &config.team_names), }; } match self.armor.health(team) { Some(hps) => TeamHealthDto::present(team, None, hps, low, &config.team_names), None => TeamHealthDto::absent(team, None, &config.team_names), } } } /// 演示模式下合成的板子 id。 /// /// 取成合法十六进制、又不与真实 MAC 派生值相像的形状:截图里一眼能看出这是演示。 pub const DEMO_CAMERA_A: u32 = 0x0DE0_0001; pub const DEMO_CAMERA_B: u32 = 0x0DE0_0002; pub const DEMO_ARMOR_A: u32 = 0x0A20_0001; pub const DEMO_ARMOR_B: u32 = 0x0A20_0002;