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
+25 -13
View File
@@ -263,7 +263,7 @@ pub fn set_recording_options(
// 截图与录制
// ---------------------------------------------------------------------------
/// 把某一路最新的一帧存成 PNG。
/// 把某一路最新的一帧存成 PNG(存在录制目录的 `shots/` 下)。
#[tauri::command]
pub fn screenshot(camera: u32, state: Shared<'_>) -> Result<String, String> {
let frame = state
@@ -273,19 +273,9 @@ pub fn screenshot(camera: u32, state: Shared<'_>) -> Result<String, String> {
let config = state.config();
let dir = config.recordings_dir().join("shots");
std::fs::create_dir_all(&dir).map_err(|err| format!("建不了截图目录 {dir:?}:{err}"))?;
let path = dir.join(format!(
"shot-{camera:08X}-{}.png",
chrono::Local::now().format("%Y-%m-%d_%H-%M-%S")
));
let image = image::load_from_memory_with_format(&frame.data, image::ImageFormat::Jpeg)
.map_err(|err| format!("这一帧不是能解码的 JPEG:{err}"))?;
image
.save_with_format(&path, image::ImageFormat::Png)
.map_err(|err| format!("写不了 {}:{err}", path.display()))?;
let path = crate::recording::save_screenshot(&frame.data, &dir, camera)
.map_err(|err| format!("{err:#}"))?;
log::info!(
"screenshot of 0x{camera:08X} (frame {}) -> {}",
@@ -386,6 +376,28 @@ pub fn reveal(path: String, state: Shared<'_>) -> Result<(), String> {
.map_err(|err| format!("打开 {path} 失败:{err}"))
}
/// 用系统默认程序打开一个文件。
///
/// 界面内回放走的是 Tauri 的资源协议,而那条协议只对白名单目录生效(见
/// `tauri.conf.json` 里的 `assetProtocol.scope`)。录制目录被改到白名单之外时,`<video>`
/// 会加载失败——那时这个命令是退路,而不是让现场以为"录下来的文件坏了"。
#[tauri::command]
pub fn open_file(path: String, state: Shared<'_>) -> Result<(), String> {
let dir = state.config().recordings_dir();
let target = PathBuf::from(&path);
if !is_within(&dir, &target) {
return Err(format!("只能打开录制目录({})里的文件", dir.display()));
}
if !target.exists() {
return Err(format!("{} 不存在了", target.display()));
}
tauri_plugin_opener::open_path(&target, None::<&str>)
.map_err(|err| format!("打开 {path} 失败:{err}"))
}
/// `child` 在 `parent` 里面吗。
///
/// 逐段比较而不是字符串前缀:`/a/bc` 以 `/a/b` 为前缀,但它不在 `/a/b` 里面。
+15 -1
View File
@@ -49,7 +49,12 @@ const USAGE: &str = "\
身份由配置文件记住(~/.config/cam-desktop/config.toml),首次启动会在界面上问。
用户端的绑定改动需要密码;裁判端不需要。
环境变量 CAM_DEMO=1 与 --demo 等价。
环境变量:
CAM_DEMO=1 等价于 --demo
CAM_CONFIG=<路径> 等价于 --config <路径>
两个环境变量存在的理由很实际:`tauri dev` 要用两个 `--` 才能把参数递给应用
(`tauri dev -- -- --demo`),而写在一行环境变量里不会有人记错。
";
fn main() {
@@ -82,6 +87,7 @@ impl Args {
fn parse(argv: impl Iterator<Item = String>) -> Self {
let mut args = Args {
demo: std::env::var("CAM_DEMO").is_ok_and(|v| v == "1" || v == "true"),
config: std::env::var_os("CAM_CONFIG").map(PathBuf::from),
..Default::default()
};
@@ -162,6 +168,13 @@ fn run() -> Result<(), Box<dyn Error>> {
// 顺序有讲究:sink 必须先装好,后台任务才开始推——否则头几帧会掉在空处。
state.install_sinks();
// 视频通道的地址。
//
// 带 token 的那一版只在 debug 级:它是给本机开发者用 `wscat` 连上去看这条通道用的
// ("画面不动"时能立刻分清是后端没推还是前端没接)。info 级只报端口。
log::info!("stream: listening on ws://127.0.0.1:{}", state.port);
log::debug!("stream: {}", state.stream_info().url);
if args.demo {
state.note("演示模式已打开:画面与血量由本机合成,不是场上的板子。");
demo::spawn(Arc::clone(&state));
@@ -228,6 +241,7 @@ fn run() -> Result<(), Box<dyn Error>> {
commands::start_recording,
commands::stop_recording,
commands::reveal,
commands::open_file,
commands::clear_config,
commands::quit_app,
commands::refresh,
+84 -2
View File
@@ -229,18 +229,54 @@ fn unique_take_path(dir: &Path, camera: u32) -> PathBuf {
chrono::Local::now().format("%Y-%m-%d_%H-%M-%S")
);
let mut candidate = dir.join(format!("{base}.mp4"));
unique_path(dir, &base, "mp4")
}
/// 挑一个还没被占用的路径:`<base>.<ext>` → `<base>-2.<ext>` → `<base>-3.<ext>`。
///
/// 录制与截图共用它。时间戳只到秒(截图到毫秒),所以"同一秒停一次再开一次"这种事
/// 会挑到同一个名字——而 ffmpeg 的 `-y` 与图片写入都会**静默覆盖**。现场丢掉的那一段
/// 往往正好是要复盘的那一段。
fn unique_path(dir: &Path, base: &str, ext: &str) -> PathBuf {
let mut candidate = dir.join(format!("{base}.{ext}"));
for n in 2..1000 {
if !candidate.exists() {
break;
}
candidate = dir.join(format!("{base}-{n}.mp4"));
candidate = dir.join(format!("{base}-{n}.{ext}"));
}
candidate
}
/// 把一张 JPEG 存成 PNG。
///
/// 从命令里抽出来是为了能测:这条路径里唯一可能出错的是"JPEG 解不开"与"PNG 写不出去",
/// 两者都值得有一个直接对着它们写的测试——现场截图失败却不报错,等于证据没留下来。
pub fn save_screenshot(jpeg: &[u8], dir: &Path, camera: u32) -> Result<PathBuf> {
std::fs::create_dir_all(dir)
.with_context(|| format!("creating the screenshot directory {}", dir.display()))?;
let path = unique_path(
dir,
&format!(
"shot-{camera:08X}-{}",
chrono::Local::now().format("%Y-%m-%d_%H-%M-%S-%3f")
),
"png",
);
let image = image::load_from_memory_with_format(jpeg, image::ImageFormat::Jpeg)
.map_err(|err| anyhow!("这一帧不是能解码的 JPEG:{err}"))?;
image
.save_with_format(&path, image::ImageFormat::Png)
.map_err(|err| anyhow!("写不了 {}:{err}", path.display()))?;
Ok(path)
}
/// 写入线程的主体:把队列里的帧写进 ffmpeg 的 stdin,队列关闭后收尾。
fn pump(
rx: &std::sync::mpsc::Receiver<Arc<Frame>>,
@@ -660,6 +696,52 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_screenshot_is_a_readable_png_of_the_same_size() {
let dir = temp_dir("shots");
let path = save_screenshot(&jpeg(64, 48, 7), &dir, 0x3D4D_8A9A).expect("saves");
let decoded = image::open(&path).expect("the PNG is readable");
assert_eq!((decoded.width(), decoded.height()), (64, 48));
assert!(
path.file_name().unwrap().to_string_lossy().starts_with("shot-3D4D8A9A-"),
"文件名要能看出是哪一路截的:{path:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn two_screenshots_never_land_on_the_same_file() {
let dir = temp_dir("shots-twice");
let first = save_screenshot(&jpeg(32, 32, 1), &dir, 1).unwrap();
let second = save_screenshot(&jpeg(32, 32, 2), &dir, 1).unwrap();
assert_ne!(first, second, "两次截图不能落在同一个文件上");
assert!(first.to_string_lossy().contains("shot-00000001-"));
assert!(second.to_string_lossy().contains("shot-00000001-"));
// 时间戳到毫秒,所以这两次通常落在不同的毫秒上,各自拿到自己的名字。真正要挡的
// 是**同一毫秒**内连按两次,那由 `unique_path` 的后缀兜住——下面直接验证它。
let forced = unique_path(&dir, "shot-00000001-fixed", "png");
std::fs::write(&forced, b"").unwrap();
let forced2 = unique_path(&dir, "shot-00000001-fixed", "png");
assert!(forced2.to_string_lossy().ends_with("-2.png"), "got {forced2:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_broken_frame_is_an_error_not_an_empty_png() {
let dir = temp_dir("shots-bad");
let err = save_screenshot(b"not a jpeg at all", &dir, 1).unwrap_err();
assert!(err.to_string().contains("JPEG"), "got {err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn media_listing_ignores_anything_that_is_not_a_take() {
let dir = temp_dir("listing");
+1 -1
View File
@@ -24,7 +24,7 @@
}
],
"security": {
"csp": null,
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' asset: http://asset.localhost blob:; connect-src 'self' ipc: http://ipc.localhost ws://127.0.0.1:*; object-src 'none'; base-uri 'self'; form-action 'none'",
"assetProtocol": {
"enable": true,
"scope": [