desktop: 用户端与裁判端两套页面(Tauri v2 + React),删掉 Slint 客户端

界面部分。后端在前一个提交里,这一提交把它接上,并删掉 client/。

用户端:
- 底部胶囊血条(宽度/数字/阈值/脉动可配),未接入时显示占位说明而不是 0;
- 瞄准器画在 canvas 上并与画面共用同一次变换计算——位置跟着缩放走、大小不跟,
  像真瞄具;偏移校准存的是画面比例而不是像素,换分辨率不会失效;
- 命中反馈:整屏闪红 + 抖动 + 准星旁的伤害数字。大号数字刻意不放画面正中:
  第一版放了,正好压住瞄准点。掉血事件由相邻两次读数之差派生(主板只报"现在多少血");
- 摄像头/装甲板下拉绑定、队伍号、镜像;改动要密码,退出/关闭也要。

裁判端:
- 两路等宽(flex: 1 1 0 + min-width: 0)各自独立缩放;
- 装甲板发现列表:id、地址、自报队伍、血量、四条齐不齐、静默多久、分到哪个槽位;
- 截图 PNG + ffmpeg 录 mp4,界面内回放(资源协议白名单外的目录给"用系统播放器打开"的退路)。

密码:Argon2id PHC,首启强制设置,会话内解锁一次。执行点在 Rust 侧命令里,界面上的
解锁对话框只是入口。故意不做 --camera-id 这类命令行覆盖:那是一条不用密码就能改绑的路。

其它:
- 配置换成 serde + toml,字段名统一 camelCase(磁盘=IPC=TS),仍然拒绝未知键名,
  原子写;旧客户端配置首次启动导入并备份成 .bak(只在默认路径下做);
- 演示模式(CAM_DEMO=1)合成协议数据报喂进真实接收路径,没有板子也能把整条链路跑起来;
- 仅 dev 的浏览器预览(?preview=referee),两重门保证不进正式产物(已验证);
- CSP 收紧成白名单,验证方式是"前端能不能连上后端":clients 从 0 变成 1 同时证明
  JS 加载、IPC、WebSocket、React effect 四件事。

删掉 client/:它的功能全部被 desktop/ 覆盖,且其中有几处真 bug 在新实现里改掉了
(槽位分配会让两格显示同一块板子、帧率窗口基线、密码哈希截断被当成密码错、录制文件名
同秒互相覆盖)。注意 client/ 里那几处**未提交**的改动随之丢弃,历史里的版本还在。

测试:protocol 15、cam-core 69、src-tauri 30;前端 tsc + vite build 通过。
This commit is contained in:
2026-10-14 19:28:49 +08:00
parent a89d7ae5fb
commit 4f7a142a39
36 changed files with 4234 additions and 8739 deletions
+44 -1
View File
@@ -122,6 +122,8 @@ struct Inner {
boards: HashMap<u32, ArmorBoard>,
/// 已经打过"这块板子在线"的板子 id,避免刷屏。
announced: Vec<u32>,
/// 已经警告过"同一队有多块板子"的队伍,避免刷屏。
warned_multi_board: Vec<u8>,
}
/// 相邻两次读数之差还原出来的掉血事件。
@@ -304,8 +306,12 @@ impl ArmorHealthStore {
// 血量变化也要能看见:窗口是给别人看的,抓日志的人得知道掉血没有。整车一条
// 血、四条同值,所以只对 index 0 报一次。
//
// 上一次的读数取自**这块板子自己**的记录,而不是 `readings[team][0]`:那个
// 槽位是"这一队"的,同队有两块板子在广播时会被互相覆盖,于是日志会把另一块
// 板子的血量当成"这块板子之前的血量"——一条读起来像真事的假线索。
if index == 0 {
if let Some(previous) = inner.readings[team][0].map(|reading| reading.hp) {
if let Some(previous) = inner.boards.get(&board).map(|entry| entry.hp) {
if previous != health.hp {
log::info!(
"armor: board 0x{board:08X} team {team} hp {previous} -> {}",
@@ -315,6 +321,21 @@ impl ArmorHealthStore {
}
}
// 同一队出现第二块板子:说清楚后果,并提醒用绑定/指定板子 id 来消歧。
let same_team = inner
.boards
.values()
.any(|other| other.id != board && other.team == health.team && other.is_online());
if same_team && !inner.warned_multi_board.contains(&health.team) {
inner.warned_multi_board.push(health.team);
log::warn!(
"armor: 队伍 {team} 上有不止一块装甲板在广播(现在这是 0x{board:08X})。\
`health(team)` 是**最后写入者优先**,四条上报可能来自不同的板子;\
要确定是谁的血,请在界面上绑定/指定具体的板子 id。"
);
}
inner.readings[team][index] = Some(Reading {
hp: health.hp,
at: Instant::now(),
@@ -498,6 +519,28 @@ mod tests {
assert_eq!(seen.lock().unwrap().len(), ARMOR_BOARDS);
}
#[test]
fn two_boards_on_the_same_team_share_the_per_team_slot() {
// 这是 `health(team)` 的已知限制,不是意外:一个队里同时有两块板子广播时,那
// 四个格子是"最后写入者优先",可能混着两块板子的数。真实比赛里一队只有一块,
// 所以这条路径只在开发(演示 + 真板子)或烧错固件时才会走到。
//
// 消歧的办法是绑定具体的板子 id,走 `health_of_board`。
let store = ArmorHealthStore::new();
tick(&store, 0xAAAA_0001, 0, 100, [192, 168, 1, 50]);
tick(&store, 0xBBBB_0002, 0, 40, [192, 168, 1, 60]);
// 后写的那块板子赢了。
assert_eq!(store.health(0), Some([40, 40, 40, 40]));
// 按板子取就没这个歧义。
assert_eq!(store.health_of_board(0xAAAA_0001), Some(100));
assert_eq!(store.health_of_board(0xBBBB_0002), Some(40));
// 两块板子都还在发现列表里,界面才能让人选。
assert_eq!(store.boards().len(), 2);
}
#[test]
fn a_falling_hp_is_exactly_one_hit() {
let mut tracker = HealthTracker::new();
+12 -1
View File
@@ -691,7 +691,18 @@ pub fn parse_id(text: &str) -> Option<u32> {
/// 返回值里的 `Vec<String>` 是要显示给用户/写进日志的说明。旧文件**不删**:现场出问题
/// 时,"旧的那份到底写了什么"是第一个要看的东西。
pub fn load_with_migration(path: &Path) -> Result<(Config, Vec<String>)> {
load_with_migration_at(path, &legacy_config_path())
// 只有用**默认**路径时才去动旧配置。
//
// `--config` 指定了别的路径,说明使用者有意让这次运行与"这台机器的常规配置"分开
// (开发、临时当另一台机器用)。那种情况下把 ~/.config/cam-client 挪走是越界的:
// 它改的是常规运行的东西,而这一次不是常规运行。
let legacy = if path == default_config_path() {
legacy_config_path()
} else {
PathBuf::from("/nonexistent/never-import-legacy-config")
};
load_with_migration_at(path, &legacy)
}
/// 与 [`load_with_migration`] 相同,但旧配置的路径由调用方给。
+13
View File
@@ -125,6 +125,19 @@ mod tests {
assert!(phc.contains("$v=19$"), "got {phc}");
}
#[test]
fn a_known_phc_string_still_verifies() {
// 一条固定的 Argon2id PHC 串,密码是 1234。
//
// 作用是把"已经写进某个人的配置文件里的哈希,将来还能不能验证"钉下来:换库、
// 换默认参数、或者手滑把 salt 的编码改成别的形式时,这条会立刻失败——而它失败的
// 那一天,现场的表现会是"密码明明对,却一直说密码错"。
const PHC: &str = "$argon2id$v=19$m=19456,t=2,p=1$rBxlGFBYgghR/PPmKlx++A$B7J4JeKgDzv4JdNycjf7rb+5UPgO9JY+KhURaZtn5R0";
assert!(verify_password("1234", PHC).expect("verifies"));
assert!(!verify_password("1235", PHC).expect("the hash itself is fine"));
}
#[test]
fn a_corrupt_stored_hash_is_an_error_not_a_wrong_password() {
// 这两种情况必须能分开:一个是输错了,一个是配置文件坏了。