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
+635
View File
@@ -0,0 +1,635 @@
//! 进程内的共享状态。
//!
//! 这里是"网络层的推"与"界面的拉"交汇的地方:
//!
//! - **推**:接收线程拿到帧就交给 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::ArmorSource;
use cam_core::slots::SlotAllocator;
use cam_core::snapshot::{
ArmorBoardDto, CameraDto, CameraStatsDto, ConfigView, HitDto, Snapshot, SlotsDto, TeamHealthDto,
};
use cam_core::stats::{FpsWindow, DEFAULT_WINDOW};
use tokio::sync::broadcast;
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<Config>,
/// 会话内解锁(见模块文档)。
unlocked: AtomicBool,
/// 设置面板是否打开,决定发现列表放不放开。
discovery_open: AtomicBool,
pub receiver: Arc<Receiver>,
pub armor: Arc<ArmorHealthStore>,
discovery: Arc<Discovery>,
latest: Arc<LatestFrames>,
/// 每一路的帧率窗口。掉线的摄像头会被清掉,回来时重新建。
fps: Mutex<HashMap<u32, FpsWindow>>,
slots: Mutex<SlotAllocator>,
pub recordings: Arc<RecordingSet>,
tx: broadcast::Sender<Arc<ServerMsg>>,
/// 回环上已经绑好的 WebSocket listener,等 `spawn_background` 取走。
///
/// 先绑再交给后台任务,是为了让"端口绑不上"在启动阶段就报错,而不是变成一个永远
/// 连不上的界面。
listener: Mutex<Option<tokio::net::TcpListener>>,
pub port: u16,
token: String,
clients: Arc<AtomicUsize>,
pub demo: bool,
notes: Mutex<Vec<String>>,
started: Instant,
}
impl AppState {
/// 建好状态,并绑好两个 UDP 端口。
///
/// 视频端口(5600)绑不上是**致命**的:整个程序的存在意义就是收画面,绑不上应该
/// 立刻报错,而不是开一个永远不出画面的窗口。装甲板端口(5601)绑不上只是没有
/// 血量,退回占位符即可——血量显示不了不该让整个客户端起不来。
pub fn new(
config_path: PathBuf,
config: Config,
mut notes: Vec<String>,
demo: bool,
) -> Result<(Arc<Self>, Option<std::net::UdpSocket>, Option<std::net::UdpSocket>)> {
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 (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,
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<AppState>` 建好之后调用(sink 里要持有 state 的通道)。
pub fn install_sinks(self: &Arc<Self>) {
let tx = self.tx.clone();
let recordings = Arc::clone(&self.recordings);
// 帧的 sink:在接收线程里跑,只做"编码一次 + 广播"。这一份字节所有界面共用。
self.receiver
.set_frame_sink(Arc::new(move |frame: Arc<protocol::Frame>| {
recordings.feed(&frame);
let bytes = server::encode_frame(&frame);
let _ = tx.send(Arc::new(ServerMsg::Binary(bytes.into())));
}));
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<Self>) {
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);
tauri::async_runtime::spawn(async move {
server::serve(listener, token, tx, 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<Config> {
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<bool> {
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<String>) {
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<LatestFrames> {
Arc::clone(&self.latest)
}
// -----------------------------------------------------------------------
// 每 200ms 推进一次
// -----------------------------------------------------------------------
/// 推进帧率窗口与槽位分配。
pub fn tick(&self) {
let online: Vec<u32> = self.discovery.online_cameras().iter().map(|c| c.id).collect();
let online_set: HashSet<u32> = 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<f32> {
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<CameraDto> = self
.discovery
.cameras()
.iter()
.map(CameraDto::from)
.collect();
let stats: Vec<CameraStatsDto> = 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<ArmorBoardDto> =
self.armor.boards().iter().map(ArmorBoardDto::from).collect();
let slots = self.slots.lock().expect("slots mutex poisoned").slots();
let health = match config.role {
// 用户端只显示自己那一边。
Role::Team => vec![self.health_for(config.team)],
Role::Referee => vec![self.health_for(0), self.health_for(1)],
};
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` 自动找。
fn assigned_armor(&self, config: &Config, team: u8) -> Option<u32> {
let explicit = if team == 0 {
config.referee.armor_a
} else {
config.referee.armor_b
};
if 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)
})
}
/// 一边的血量。
///
/// 顺序是有意的:先看绑定的那块板子,再看"这一边有没有人报血量"。绑定了却在掉线
/// 状态时要显示**未接入**而不是拿别人的数据顶上——现场会以为对手还有血。
fn health_for(&self, team: u8) -> TeamHealthDto {
let config = self.config();
let low = config.hud.health.low_threshold;
let board = match config.role {
Role::Team => config
.armor_id
.or_else(|| self.assigned_armor(&config, team)),
Role::Referee => 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),
None => TeamHealthDto::absent(team, Some(board)),
};
}
match self.armor.health(team) {
Some(hps) => TeamHealthDto::present(team, None, hps, low),
None => TeamHealthDto::absent(team, None),
}
}
}
/// 演示模式下合成的板子 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;