界面改动都在浏览器预览里逐条核对过(DOM 尺寸 + 视觉),截图留在 desktop/screenshots/ (该目录不进仓库)。 1. 客户端显示对手的血量 快照里两端都给两支队伍(顺序固定为队伍号 0/1),界面按 `team` 取那一格,不再靠 数组下标。顺带修掉一个真 bug:用户端"对手那一格"以前会走 `armor_id`——那是我自己 绑定的主板,于是右上角显示的是自己的血,比不显示更糟。现在只有"自己那一块"才用 `armor_id`;同理 `assigned_armor` 里的裁判端槽位指定对用户端不再生效。 2. 血条改到画面两个上角,平行四边形,一条分 5 格 左上角自己、右上角对手(裁判端两格各贴自己的角,读法一致)。整条 skewX(-15deg), 右条再 scaleX(-1) 镜像,两条斜边朝外对称、**都从靠中心的一侧开始灭**。一格 20 点血。 条挂在绝对定位的一层上并 pointer-events:none(那一层盖着画面,而画面要能滚轮缩放、 拖动),倍率与复位徽标因此从上沿挪到下沿。未接入显示"装甲板未接入"而不是 0。 注意:不要在 JS 里再反转一次数组——那会和 CSS 的 scaleX(-1) 互相抵消,表现是右条 从外侧开始灭(代码里写了注释钉住这件事)。 3. 受击画面不抖动 删掉整幅图(含瞄准器)的位移与 shakeOffset。`HitFeedbackConfig::shake` 字段保留但 **不再生效**:配置文件是 deny_unknown_fields 的,删字段会让所有写过 shake = true 的 配置解析失败,而配置解析失败等于程序起不来。默认值改 false,设置面板去掉开关, 并有单测钉住"旧配置读得进来、默认不抖"。 4. 血量归零算击败,不显示"损毁" 血条上不再出现"已击毁/损毁",归零的结论放到图传中央:胜利 / 击败 / 平局。结论在 Rust 侧算(snapshot::resolve_outcomes,4 条单测),规则有意不对称——**输只看自己** (自己归零就是输,哪怕对手主板掉线),**赢要看两边**(对手归零且自己还有血), 两边同时归零是平局。它一直显示到血量被重置,不做几秒淡出,否则截图与回放里就再也 没有"这局结束了"的痕迹。 5. 裁判端可以改队伍名 两格画面各自下面一个输入框:回车/失焦生效、Esc 取消、超 12 字截断、留空回落到 「队伍一 / 队伍二」。命令是 `set_team_name(team, name)`,**一次只改一边**——传整个 两条的数组就成了"读两条、改一条、写两条",而读来的那两条来自最多 200ms 前的快照, 紧接着改另一边会把上一次的改动覆盖回去。规范化统一在 `config::sanitize_team_name` (合并空白、按字符截断、留空回落),并同样过密码闸门(裁判端本来就没有密码)。 名字只有一个来源(快照的 `teamNames` / DTO 的 `name`):装甲板表、槽位标签、设置 面板与"等待图传"的占位文字都跟着改,不再有第二处自己拼"队伍一/队伍二"。 测试:core 80(69→80)、src-tauri 30、protocol 15;`pnpm build`(tsc + vite)通过。
1192 lines
43 KiB
Rust
1192 lines
43 KiB
Rust
//! 磁盘上的配置:身份、绑定、HUD、密码哈希、录制。
|
||
//!
|
||
//! # 为什么换掉了手写解析
|
||
//!
|
||
//! 旧客户端(`client/src/config.rs`)手写 TOML 是有理由的:配置只有四个标量字段,
|
||
//! 引一个解析器不值得,而手写带来的零依赖与"`save`/`parse` 严格对称"确实值钱。
|
||
//!
|
||
//! 现在配置里有 HUD 的嵌套结构、密码哈希、录制参数,还要能被界面反复改。手写解析器
|
||
//! 的对称性成本(每加一个字段要在两个地方各写一遍,且漏了不会报错)已经超过了它省下
|
||
//! 的那点依赖。所以改用 `serde` + `toml`,并把"未知键"仍然当作错误
|
||
//! (`deny_unknown_fields`)——打错一个键名**必须**报错,而不是静默地"看起来生效了"。
|
||
//!
|
||
//! 代价是:新版本写出的配置拿到旧版本上会因未知键而拒绝加载。可以接受:两台机器的
|
||
//! 配置本来各自独立,而"打错键名被静默忽略"在现场是真会浪费时间的。
|
||
//!
|
||
//! # 字段命名:一个名字,到处一样
|
||
//!
|
||
//! 这些结构体同时是**磁盘格式**和**推给界面的 JSON**,所以字段名统一用 camelCase
|
||
//! (`offsetX`、`cameraId`)。TOML 里写 camelCase 不算最漂亮,但换来的是"文件里的键名
|
||
//! = IPC 里的键名 = 前端 TS 里的键名",不需要任何一层做名字转换——而那种转换漏掉一个
|
||
//! 字段时**不会报错**,只会让某一项设置看起来"改了没生效"。
|
||
//!
|
||
//! 枚举的取值用 kebab-case(`cross-dot`、`squares`):它们出现在同一个文件里,读起来
|
||
//! 更像配置。
|
||
//!
|
||
//! # 路径
|
||
//!
|
||
//! - 新配置:`~/.config/cam-desktop/config.toml`
|
||
//! - 旧配置:`~/.config/cam-client/config.toml`(首次启动时能导入的字段会导入,然后
|
||
//! 被改名成 `.bak`,不删)
|
||
//!
|
||
//! # 原子写
|
||
//!
|
||
//! [`Config::save`] 先写同目录下的临时文件再 `rename`。配置文件里有密码哈希,写一半
|
||
//! 崩掉留下的截断文件会让"解锁"永久失败——而 `rename` 在同一文件系统上是原子的,
|
||
//! 要么是旧的完整内容,要么是新的完整内容。
|
||
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use anyhow::{anyhow, Context, Result};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
/// 配置文件格式版本。
|
||
///
|
||
/// 目前只是记下来:字段级的兼容靠 `#[serde(default)]`,所以加字段不需要升版本。它的
|
||
/// 用处是将来真需要迁移时有个明确的判据,以及现场排查时能一眼看出文件是哪一版写的。
|
||
pub const CONFIG_VERSION: u32 = 1;
|
||
|
||
/// 队伍名的兜底值,下标就是队伍号。
|
||
///
|
||
/// 界面上到处都在用这两个名字(血条、装甲板表、占位文字),所以它们是**默认值**而不是
|
||
/// "没配置":配置里留空时回落到这里,而不是显示一个空白。
|
||
pub const DEFAULT_TEAM_NAMES: [&str; 2] = ["队伍一", "队伍二"];
|
||
|
||
/// 队伍名的最大长度(字符,不是字节)。
|
||
///
|
||
/// 名字会画在血条左上角/右上角那一小块地方,超长的名字会把血条挤走。按**字符**截断而不是
|
||
/// 字节:中文按字节截断会切出半个字,界面上显示成一个方块。
|
||
pub const TEAM_NAME_MAX_CHARS: usize = 12;
|
||
|
||
/// 客户端身份。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum Role {
|
||
/// 用户端(队伍):一块画面 + 自己的血条与瞄准器。
|
||
Team,
|
||
/// 裁判端:两块画面 + 双方血量 + 装甲板发现列表 + 录制。
|
||
Referee,
|
||
}
|
||
|
||
impl Role {
|
||
pub fn as_str(self) -> &'static str {
|
||
match self {
|
||
Role::Team => "team",
|
||
Role::Referee => "referee",
|
||
}
|
||
}
|
||
|
||
pub fn parse(s: &str) -> Option<Self> {
|
||
match s {
|
||
"team" => Some(Role::Team),
|
||
"referee" => Some(Role::Referee),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
pub fn to_protocol(self) -> protocol::SubscribeRole {
|
||
match self {
|
||
Role::Team => protocol::SubscribeRole::Team,
|
||
Role::Referee => protocol::SubscribeRole::Referee,
|
||
}
|
||
}
|
||
|
||
/// 这个身份需不需要密码闸门。
|
||
///
|
||
/// 用户端要,裁判端不要:现场裁判要能最快地改槽位与开录,而裁判端本来就没有
|
||
/// "绑定到自己那一块板子"的动作可被滥用。
|
||
pub fn needs_password(self) -> bool {
|
||
matches!(self, Role::Team)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// HUD
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 瞄准器样式。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "kebab-case")]
|
||
pub enum CrosshairStyle {
|
||
/// 四臂十字,中间留空。
|
||
Cross,
|
||
/// 十字 + 中心点。
|
||
CrossDot,
|
||
/// 圆环 + 中心点。
|
||
CircleDot,
|
||
/// 只有一个点。
|
||
Dot,
|
||
}
|
||
|
||
/// 瞄准器。
|
||
///
|
||
/// # 偏移量为什么是比例而不是像素
|
||
///
|
||
/// 摄像头与枪管不同轴,瞄准点要往某个方向挪一点才能对上弹着点。这个偏移如果存成像素,
|
||
/// 分辨率一变(换相机配置、换窗口大小)就全错了。存成**画面尺寸的比例**(`0.1` = 画面
|
||
/// 宽度的 10%)之后,缩放、换分辨率、换机器都不影响它。
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
|
||
pub struct CrosshairConfig {
|
||
pub visible: bool,
|
||
pub style: CrosshairStyle,
|
||
/// 十字臂长 / 圆环半径,单位是画面短边的比例。
|
||
pub size: f32,
|
||
/// 线宽,画面短边的比例。
|
||
pub thickness: f32,
|
||
/// 中心空隙,画面短边的比例。
|
||
pub gap: f32,
|
||
/// `#RRGGBB`。
|
||
pub color: String,
|
||
/// 校准偏移,画面宽度的比例,正数向右。
|
||
pub offset_x: f32,
|
||
/// 校准偏移,画面高度的比例,正数向下。
|
||
pub offset_y: f32,
|
||
}
|
||
|
||
impl Default for CrosshairConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
visible: true,
|
||
style: CrosshairStyle::CrossDot,
|
||
size: 0.045,
|
||
thickness: 0.004,
|
||
gap: 0.012,
|
||
color: "#3dff88".to_string(),
|
||
offset_x: 0.0,
|
||
offset_y: 0.0,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl CrosshairConfig {
|
||
/// 偏移量换算成比例范围是否可信。
|
||
///
|
||
/// 允许 ±0.5:再多就是把瞄点挪出画面了。这个上限是防手滑写进一个 `5.0`,那会让
|
||
/// 瞄准器直接消失,而现场第一反应不会是"配置写错了"。
|
||
pub fn clamped(mut self) -> Self {
|
||
self.size = self.size.clamp(0.005, 0.4);
|
||
self.thickness = self.thickness.clamp(0.0008, 0.05);
|
||
self.gap = self.gap.clamp(0.0, 0.3);
|
||
self.offset_x = self.offset_x.clamp(-0.5, 0.5);
|
||
self.offset_y = self.offset_y.clamp(-0.5, 0.5);
|
||
self
|
||
}
|
||
}
|
||
|
||
/// 网格线样式。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "kebab-case")]
|
||
pub enum GridStyle {
|
||
/// 三分线(构图与测距都用得上)。
|
||
Thirds,
|
||
/// 等分方格。
|
||
Squares,
|
||
/// 只有中心十字。
|
||
Cross,
|
||
}
|
||
|
||
/// 叠加网格。用户端用来估距离与判断画面中心,裁判端用来比对两路。
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
|
||
pub struct GridConfig {
|
||
pub visible: bool,
|
||
pub style: GridStyle,
|
||
/// 方格用几条线(`Squares` 之外忽略)。
|
||
pub divisions: u8,
|
||
pub opacity: f32,
|
||
}
|
||
|
||
impl Default for GridConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
visible: false,
|
||
style: GridStyle::Thirds,
|
||
divisions: 6,
|
||
opacity: 0.22,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl GridConfig {
|
||
pub fn clamped(mut self) -> Self {
|
||
self.divisions = self.divisions.clamp(2, 16);
|
||
self.opacity = self.opacity.clamp(0.02, 1.0);
|
||
self
|
||
}
|
||
}
|
||
|
||
/// 血条。
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
|
||
pub struct HealthConfig {
|
||
pub visible: bool,
|
||
pub show_numbers: bool,
|
||
pub show_team_name: bool,
|
||
/// 低于它就把血条转成告警色并开始脉动。
|
||
pub low_threshold: u16,
|
||
pub pulse: bool,
|
||
/// 血条的宽度占画面宽度的比例。
|
||
pub width: f32,
|
||
}
|
||
|
||
impl Default for HealthConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
visible: true,
|
||
show_numbers: true,
|
||
show_team_name: true,
|
||
low_threshold: 30,
|
||
pulse: true,
|
||
width: 0.42,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl HealthConfig {
|
||
pub fn clamped(mut self) -> Self {
|
||
self.low_threshold = self.low_threshold.min(1000);
|
||
self.width = self.width.clamp(0.1, 0.9);
|
||
self
|
||
}
|
||
}
|
||
|
||
/// 命中反馈:被打中时画面上出现什么。
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
|
||
pub struct HitFeedbackConfig {
|
||
/// 整屏闪一下红。
|
||
pub flash: bool,
|
||
/// **已废弃,保留只是为了兼容旧配置文件。**
|
||
///
|
||
/// 命中时抖动画面的做法去掉了:抖动恰恰发生在最需要看清画面的那一下,而且它让**整幅
|
||
/// 图**(包括瞄准器)跟着动,操作手会误以为车在晃。现在这个字段读进来就丢掉,界面不再
|
||
/// 画抖动、设置面板里也没有这个开关。
|
||
///
|
||
/// 不删掉字段的理由很实际:这个文件是 `deny_unknown_fields` 的,删字段会让所有已经写
|
||
/// 过 `shake = true` 的配置**直接解析失败**,而配置解析失败等于程序起不来。
|
||
#[serde(default)]
|
||
pub shake: bool,
|
||
/// 在瞄点旁标出伤害数字。
|
||
pub marker: bool,
|
||
/// 闪红与伤害数字的强度(0.2–2.0)。
|
||
pub intensity: f32,
|
||
}
|
||
|
||
impl Default for HitFeedbackConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
flash: true,
|
||
// 见字段文档:不再抖动。默认值也就是"读旧配置时会被解析成什么"。
|
||
shake: false,
|
||
marker: true,
|
||
intensity: 1.0,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl HitFeedbackConfig {
|
||
pub fn clamped(mut self) -> Self {
|
||
self.intensity = self.intensity.clamp(0.2, 2.0);
|
||
self
|
||
}
|
||
}
|
||
|
||
/// 用户端 HUD 的全部可调项。
|
||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||
#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
|
||
pub struct HudConfig {
|
||
pub health: HealthConfig,
|
||
pub crosshair: CrosshairConfig,
|
||
pub grid: GridConfig,
|
||
pub hit_feedback: HitFeedbackConfig,
|
||
/// 放大倍率指示。
|
||
pub show_zoom: bool,
|
||
/// 技术读数(帧率/丢帧/分辨率)。用户端 release 下不显示,见 `Role` 与遥测门控。
|
||
pub show_stats: bool,
|
||
}
|
||
|
||
impl HudConfig {
|
||
/// 把每个子项的范围都夹一遍。
|
||
///
|
||
/// 界面上滑杆本来就不会给出越界值,但这个文件是**可以手改**的,而手改出一个
|
||
/// `size = 5.0` 会让瞄准器直接消失。夹一下比让人查半天便宜。
|
||
pub fn clamped(self) -> Self {
|
||
Self {
|
||
health: self.health.clamped(),
|
||
crosshair: self.crosshair.clamped(),
|
||
grid: self.grid.clamped(),
|
||
hit_feedback: self.hit_feedback.clamped(),
|
||
show_zoom: self.show_zoom,
|
||
show_stats: self.show_stats,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 裁判端
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 裁判端的槽位与显示项。
|
||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||
#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
|
||
pub struct RefereeConfig {
|
||
/// 左槽位绑定的摄像头 id。`None` = 自动挑一块。
|
||
#[serde(skip_serializing_if = "Option::is_none", with = "opt_hex_id")]
|
||
pub slot_a: Option<u32>,
|
||
/// 右槽位绑定的摄像头 id。
|
||
#[serde(skip_serializing_if = "Option::is_none", with = "opt_hex_id")]
|
||
pub slot_b: Option<u32>,
|
||
/// 左槽位绑定的装甲板主板 id。
|
||
#[serde(skip_serializing_if = "Option::is_none", with = "opt_hex_id")]
|
||
pub armor_a: Option<u32>,
|
||
/// 右槽位绑定的装甲板主板 id。
|
||
#[serde(skip_serializing_if = "Option::is_none", with = "opt_hex_id")]
|
||
pub armor_b: Option<u32>,
|
||
/// 槽位是手动指定的吗。
|
||
///
|
||
/// `false` 时按装甲板自报的 `team` 自动分到左右两边,`true` 时以 `slot_*`/`armor_*`
|
||
/// 为准。有这一位是因为现场会有烧错固件的板子(team 字段是反的),那时需要人压过
|
||
/// 自动分配。
|
||
pub manual_slots: bool,
|
||
/// 显示装甲板发现列表。
|
||
pub show_armor_table: bool,
|
||
/// 显示帧率/丢帧等技术读数。
|
||
pub show_diagnostics: bool,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 录制
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 录制与截图。
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
|
||
pub struct RecordingConfig {
|
||
/// 输出目录。`None` = [`default_recordings_dir`]。
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub dir: Option<String>,
|
||
/// ffmpeg 可执行文件。默认从 `PATH` 找。
|
||
pub ffmpeg: String,
|
||
/// 喂给 ffmpeg 的输入帧率。`0` = 用录制开始时实测的帧率。
|
||
///
|
||
/// # 为什么要有个数
|
||
///
|
||
/// `image2pipe` 里的 JPEG 序列**没有时间戳**:一串帧在 ffmpeg 看来就是"以固定
|
||
/// 帧率播放的图片"。所以帧率必须由我们告诉它,而这个数只能来自实测。用 0 让它
|
||
/// 自动取,是为了不让现场去猜一个"看着差不多"的值——猜错的表现是回放快放或慢放,
|
||
/// 而且看不出来是哪里错了。
|
||
pub fps: f32,
|
||
/// x264 的质量参数,越小越清晰、文件越大。
|
||
pub crf: u8,
|
||
/// x264 preset。默认 `veryfast`:现场录制时 CPU 要和两路解码抢时间。
|
||
pub preset: String,
|
||
}
|
||
|
||
impl Default for RecordingConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
dir: None,
|
||
ffmpeg: "ffmpeg".to_string(),
|
||
fps: 0.0,
|
||
crf: 20,
|
||
preset: "veryfast".to_string(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl RecordingConfig {
|
||
pub fn clamped(mut self) -> Self {
|
||
self.fps = if self.fps <= 0.0 { 0.0 } else { self.fps.clamp(1.0, 120.0) };
|
||
self.crf = self.crf.min(51);
|
||
self
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 配置
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 磁盘上的配置。
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(default, deny_unknown_fields, rename_all = "camelCase")]
|
||
pub struct Config {
|
||
pub version: u32,
|
||
pub role: Role,
|
||
/// 身份是不是已经**由人**选过了。
|
||
///
|
||
/// `role` 的默认值是裁判端(它是唯一不需要配置就能用的模式),所以"role == referee"
|
||
/// 既可能是人选了裁判端,也可能只是还没人碰过这个文件。界面需要一个明确的判据来决定
|
||
/// "首次启动时要不要先问一句",否则第一次打开会直接进裁判端——用户端的使用者会以为
|
||
/// 这个程序只有这一种界面。
|
||
pub role_chosen: bool,
|
||
/// 用户端属于哪一边:0 = 队伍一,1 = 队伍二。
|
||
pub team: u8,
|
||
/// 两边在界面上显示的名字,下标就是队伍号(裁判端可以直接改)。
|
||
///
|
||
/// 放在根上而不是 `[referee]` 里:用户端的血条同样要显示名字,而"这场比赛的队伍叫什么"
|
||
/// 是**比赛**的属性,不是裁判端这个身份的属性。
|
||
///
|
||
/// 空字符串会回落到 [`DEFAULT_TEAM_NAMES`](见 [`Config::normalized`])——名字是画在
|
||
/// 血条角上的,留空会在现场显示成一个没有名字的血条,而不是"没配置"。
|
||
pub team_names: [String; 2],
|
||
/// 绑定的图传板(MAC 派生,重启不变)。
|
||
#[serde(skip_serializing_if = "Option::is_none", with = "opt_hex_id")]
|
||
pub camera_id: Option<u32>,
|
||
/// 绑定的装甲板主板(MAC 派生)。
|
||
#[serde(skip_serializing_if = "Option::is_none", with = "opt_hex_id")]
|
||
pub armor_id: Option<u32>,
|
||
/// 左右镜像。摄像头装反了,出图左右颠倒,所以默认翻回来。
|
||
pub mirror: bool,
|
||
/// 密码的 Argon2id PHC 字符串。`None` = 还没设过。
|
||
///
|
||
/// 只对用户端有意义([`Role::needs_password`])。
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub password: Option<String>,
|
||
pub hud: HudConfig,
|
||
pub referee: RefereeConfig,
|
||
pub recording: RecordingConfig,
|
||
}
|
||
|
||
impl Default for Config {
|
||
/// 默认是裁判端:它不需要任何配置就能用,是现场最省事的起点。
|
||
fn default() -> Self {
|
||
Self {
|
||
version: CONFIG_VERSION,
|
||
role: Role::Referee,
|
||
role_chosen: false,
|
||
team: 0,
|
||
team_names: [
|
||
DEFAULT_TEAM_NAMES[0].to_string(),
|
||
DEFAULT_TEAM_NAMES[1].to_string(),
|
||
],
|
||
camera_id: None,
|
||
armor_id: None,
|
||
mirror: true,
|
||
password: None,
|
||
hud: HudConfig::default(),
|
||
referee: RefereeConfig::default(),
|
||
recording: RecordingConfig::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Config {
|
||
/// 从磁盘读取。文件不存在时返回默认配置——首次运行本来就没有配置。
|
||
pub fn load(path: &Path) -> Result<Self> {
|
||
if !path.exists() {
|
||
log::info!("no config at {}; starting with defaults", path.display());
|
||
return Ok(Self::default());
|
||
}
|
||
|
||
let text = std::fs::read_to_string(path)
|
||
.with_context(|| format!("reading {}", path.display()))?;
|
||
|
||
Self::parse(&text).with_context(|| format!("parsing {}", path.display()))
|
||
}
|
||
|
||
/// 解析 TOML 文本。
|
||
pub fn parse(text: &str) -> Result<Self> {
|
||
let config: Config = toml::from_str(text)?;
|
||
config.validate()?;
|
||
Ok(config)
|
||
}
|
||
|
||
/// 渲染成 TOML 文本。
|
||
///
|
||
/// 带上注释头,让这个文件在编辑器里自己解释自己。
|
||
pub fn render(&self) -> Result<String> {
|
||
let body = toml::to_string_pretty(self)?;
|
||
Ok(format!("{HEADER}{body}"))
|
||
}
|
||
|
||
/// 写入磁盘,原子替换。
|
||
pub fn save(&self, path: &Path) -> Result<()> {
|
||
if let Some(dir) = path.parent() {
|
||
std::fs::create_dir_all(dir)
|
||
.with_context(|| format!("creating {}", dir.display()))?;
|
||
}
|
||
|
||
let text = self.render()?;
|
||
|
||
// 同目录下的临时文件 + rename:rename 在同一文件系统上是原子的,所以配置
|
||
// 要么是旧的完整内容,要么是新的完整内容,不会出现半截。
|
||
let tmp = path.with_extension("toml.tmp");
|
||
std::fs::write(&tmp, text)
|
||
.with_context(|| format!("writing {}", tmp.display()))?;
|
||
std::fs::rename(&tmp, path)
|
||
.with_context(|| format!("replacing {}", path.display()))?;
|
||
|
||
log::info!("saved config to {}", path.display());
|
||
Ok(())
|
||
}
|
||
|
||
/// 越界字段夹回范围,非法组合报错。
|
||
pub fn validate(&self) -> Result<()> {
|
||
if self.team > 1 {
|
||
return Err(anyhow!(
|
||
"team must be 0 or 1 (0 = 队伍一, 1 = 队伍二), got {}",
|
||
self.team
|
||
));
|
||
}
|
||
if self.version > CONFIG_VERSION {
|
||
return Err(anyhow!(
|
||
"this config was written by a newer version (config version {}, this build knows {CONFIG_VERSION})",
|
||
self.version
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 夹过一遍范围的可保存副本。
|
||
pub fn normalized(mut self) -> Self {
|
||
self.hud = self.hud.clamped();
|
||
self.recording = self.recording.clamped();
|
||
self.team_names = [
|
||
sanitize_team_name(&self.team_names[0], 0),
|
||
sanitize_team_name(&self.team_names[1], 1),
|
||
];
|
||
self
|
||
}
|
||
|
||
/// 这个身份现在就能用了吗?
|
||
///
|
||
/// 裁判端永远可以用(`Referee => true`),用户端要先绑好一块图传板——否则界面上
|
||
/// 没有画面可看,只能去设置里选。
|
||
pub fn is_bound(&self) -> bool {
|
||
match self.role {
|
||
Role::Referee => true,
|
||
Role::Team => self.camera_id.is_some(),
|
||
}
|
||
}
|
||
|
||
/// 用户端还没设过密码吗。
|
||
pub fn needs_password_setup(&self) -> bool {
|
||
self.role.needs_password() && self.password.is_none()
|
||
}
|
||
|
||
/// 录制输出目录。
|
||
pub fn recordings_dir(&self) -> PathBuf {
|
||
self.recording
|
||
.dir
|
||
.as_deref()
|
||
.map(PathBuf::from)
|
||
.filter(|p| !p.as_os_str().is_empty())
|
||
.unwrap_or_else(default_recordings_dir)
|
||
}
|
||
}
|
||
|
||
/// 队伍名规范化:合并空白、按字符截断、空则回落到默认名。
|
||
///
|
||
/// 名字画在血条左上角/右上角那一小块地方,所以这三件事都是为了"不把布局弄坏":换行会在角上
|
||
/// 多顶出一行、超长会把血条挤走、空串会显示成一个没有名字的血条(而不是"没配置")。
|
||
pub fn sanitize_team_name(name: &str, team: usize) -> String {
|
||
let fallback = DEFAULT_TEAM_NAMES[team.min(1)];
|
||
|
||
// 合并内部空白(含换行与制表符):`"队伍 一"` 保留单个空格,`"a\nb"` 变成 `"a b"`。
|
||
let collapsed = name.split_whitespace().collect::<Vec<_>>().join(" ");
|
||
let text = if collapsed.is_empty() { fallback } else { collapsed.as_str() };
|
||
|
||
text.chars().take(TEAM_NAME_MAX_CHARS).collect()
|
||
}
|
||
|
||
/// 配置文件开头那段自我说明。
|
||
const HEADER: &str = "\
|
||
# cam-desktop 配置。
|
||
#
|
||
# 这个文件由程序写出,也可以手改;改完重启生效。
|
||
# 未知的键名会被当作错误拒绝加载,而不是静默忽略——打错一个键名却看起来「生效了」
|
||
# 在现场是要浪费时间的。
|
||
#
|
||
# role : team | referee
|
||
# team = 用户端:一块画面 + 自己的血条与瞄准器
|
||
# referee = 裁判端:两块画面 + 双方血量 + 装甲板列表 + 录制
|
||
# team : 0 = 队伍一, 1 = 队伍二(仅 team 端用)
|
||
# teamNames: 两边在界面上显示的名字,下标就是队伍号;裁判端可以直接改。
|
||
# 超过 12 个字会被截断,留空则回落到「队伍一 / 队伍二」。
|
||
# cameraId : 图传板 id,由 MAC 派生,重启不变,所以绑定是稳的
|
||
# armorId : 装甲板主板 id,同上
|
||
# mirror : true = 左右镜像(摄像头装反了,默认)
|
||
# password : Argon2id 哈希(仅 team 端)。这里没有明文,也恢复不出明文;
|
||
# 忘了密码就删掉这一行,然后第一次启动会要求重新设置。
|
||
#
|
||
# [hud.*] 用户端的界面可调项,改动会在设置面板里即时生效。
|
||
|
||
";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 路径
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn home_dir() -> PathBuf {
|
||
std::env::var_os("HOME")
|
||
.map(PathBuf::from)
|
||
.filter(|p| !p.as_os_str().is_empty())
|
||
.unwrap_or_else(|| PathBuf::from("."))
|
||
}
|
||
|
||
fn config_base() -> PathBuf {
|
||
std::env::var_os("XDG_CONFIG_HOME")
|
||
.map(PathBuf::from)
|
||
.filter(|p| !p.as_os_str().is_empty())
|
||
.unwrap_or_else(|| home_dir().join(".config"))
|
||
}
|
||
|
||
/// 默认配置文件位置:`~/.config/cam-desktop/config.toml`。
|
||
///
|
||
/// 沿用 `~/.config` 而不是 macOS 的 `~/Library/Application Support`,是为了让裁判机与
|
||
/// 队伍机(可能是不同系统)的配置结构一致,现场照着文档说"第几行"时不会对不上。
|
||
pub fn default_config_path() -> PathBuf {
|
||
config_base().join("cam-desktop").join("config.toml")
|
||
}
|
||
|
||
/// 旧客户端(Slint 版)的配置位置。
|
||
pub fn legacy_config_path() -> PathBuf {
|
||
config_base().join("cam-client").join("config.toml")
|
||
}
|
||
|
||
/// 默认录制目录。
|
||
pub fn default_recordings_dir() -> PathBuf {
|
||
let base = if cfg!(target_os = "macos") {
|
||
home_dir().join("Movies")
|
||
} else {
|
||
home_dir().join("Videos")
|
||
};
|
||
|
||
base.join("cam-desktop")
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 旧配置导入
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 从旧格式里认出来的字段。
|
||
///
|
||
/// 旧格式只有四个标量:`role` / `team` / `camera_id` / `mirror`。HUD、装甲板绑定、
|
||
/// 密码都是新的,没有对应项,用默认值。
|
||
#[derive(Debug, Clone, Default, PartialEq)]
|
||
pub struct LegacyImport {
|
||
pub role: Option<Role>,
|
||
pub team: Option<u8>,
|
||
pub camera_id: Option<u32>,
|
||
pub mirror: Option<bool>,
|
||
}
|
||
|
||
impl LegacyImport {
|
||
/// 把认出来的字段铺到一份默认配置上。
|
||
///
|
||
/// 裁判端不导入 `camera_id`:旧配置里那一项只对队伍端有意义,而裁判端是自动分配
|
||
/// 槽位的,硬塞一个"绑定"进去会让它少看一路。
|
||
pub fn apply_to(&self, mut config: Config) -> Config {
|
||
if let Some(role) = self.role {
|
||
config.role = role;
|
||
// 旧配置里写着角色,说明当年是人选过的。
|
||
config.role_chosen = true;
|
||
}
|
||
if let Some(team) = self.team {
|
||
config.team = team.min(1);
|
||
}
|
||
if let Some(mirror) = self.mirror {
|
||
config.mirror = mirror;
|
||
}
|
||
if let Some(id) = self.camera_id {
|
||
config.camera_id = Some(id);
|
||
}
|
||
config
|
||
}
|
||
}
|
||
|
||
/// 解析旧格式的配置文本。
|
||
///
|
||
/// 旧格式的语法极窄:`key = value`、`#` 注释、字符串可带引号、id 可以是 `0x` 十六进制。
|
||
/// 这里只认那四个键,认不出的行直接跳过——这一份代码的使命就是"读一次然后退休",
|
||
/// 没必要把旧版的报错逻辑一并搬过来。
|
||
pub fn parse_legacy(text: &str) -> LegacyImport {
|
||
let mut out = LegacyImport::default();
|
||
|
||
for raw in text.lines() {
|
||
let line = raw.split('#').next().unwrap_or("").trim();
|
||
if line.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
let Some((key, value)) = line.split_once('=') else {
|
||
continue;
|
||
};
|
||
|
||
let value = value.trim().trim_matches('"');
|
||
|
||
match key.trim() {
|
||
"role" => out.role = Role::parse(value),
|
||
"team" => out.team = value.parse().ok(),
|
||
"camera_id" => out.camera_id = parse_id(value),
|
||
"mirror" => out.mirror = value.parse().ok(),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
out
|
||
}
|
||
|
||
/// 解析 `0x1A2B3C4D` 或十进制。
|
||
pub fn parse_id(text: &str) -> Option<u32> {
|
||
let text = text.trim();
|
||
|
||
if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
|
||
u32::from_str_radix(hex, 16).ok()
|
||
} else {
|
||
text.parse().ok()
|
||
}
|
||
}
|
||
|
||
/// 首次启动时把旧配置导进来,并把旧文件改名成 `.bak`。
|
||
///
|
||
/// 返回值里的 `Vec<String>` 是要显示给用户/写进日志的说明。旧文件**不删**:现场出问题
|
||
/// 时,"旧的那份到底写了什么"是第一个要看的东西。
|
||
pub fn load_with_migration(path: &Path) -> Result<(Config, Vec<String>)> {
|
||
// 只有用**默认**路径时才去动旧配置。
|
||
//
|
||
// `--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`] 相同,但旧配置的路径由调用方给。
|
||
///
|
||
/// 拆出这个入口是为了能测:默认路径是写死的,测试没法在不碰真实
|
||
/// `~/.config/cam-client/config.toml` 的前提下验证导入与备份。
|
||
pub fn load_with_migration_at(
|
||
path: &Path,
|
||
legacy_path: &Path,
|
||
) -> Result<(Config, Vec<String>)> {
|
||
let mut notes = Vec::new();
|
||
|
||
if path.exists() {
|
||
return Ok((Config::load(path)?.normalized(), notes));
|
||
}
|
||
|
||
let mut config = Config::default();
|
||
|
||
if legacy_path.exists() {
|
||
match std::fs::read_to_string(legacy_path) {
|
||
Ok(text) => {
|
||
let imported = parse_legacy(&text);
|
||
config = imported.apply_to(config).normalized();
|
||
notes.push(format!(
|
||
"已从旧配置 {} 导入 role/team/camera_id/mirror",
|
||
legacy_path.display()
|
||
));
|
||
}
|
||
Err(err) => notes.push(format!("读不了旧配置 {}:{err}", legacy_path.display())),
|
||
}
|
||
|
||
// 改名而不是删除。已经有 .bak 就往后排一个序号,不覆盖上一次的现场证据。
|
||
let backup = next_free_backup_path(legacy_path);
|
||
match std::fs::rename(legacy_path, &backup) {
|
||
Ok(()) => notes.push(format!("旧配置已备份为 {}", backup.display())),
|
||
Err(err) => notes.push(format!("旧配置备份失败({err}),原文件保持不动")),
|
||
}
|
||
}
|
||
|
||
// 立刻落盘:否则"第一次启动"的每一步都会重新走一遍导入逻辑。
|
||
config.save(path)?;
|
||
|
||
Ok((config, notes))
|
||
}
|
||
|
||
/// 找一个还没被占用的备份名:`config.toml` → `config.toml.bak` → `.bak.2` → …
|
||
fn next_free_backup_path(path: &Path) -> PathBuf {
|
||
let base = PathBuf::from(format!("{}.bak", path.display()));
|
||
|
||
if !base.exists() {
|
||
return base;
|
||
}
|
||
|
||
for n in 2..1000 {
|
||
let candidate = PathBuf::from(format!("{}.{n}", base.display()));
|
||
if !candidate.exists() {
|
||
return candidate;
|
||
}
|
||
}
|
||
|
||
base
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// id 的十六进制表示
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 让 id 在文件里写成 `"0x3D4D8A9A"` 而不是 `1024363162`。
|
||
///
|
||
/// 界面上、日志里、Hello 广播里显示的 id 全是这个十六进制形式。配置文件跟着写成一样
|
||
/// 的形式,现场才能"看到界面上那个 id,去文件里找到它"。
|
||
mod opt_hex_id {
|
||
use serde::{Deserialize, Deserializer, Serializer};
|
||
|
||
pub fn serialize<S: Serializer>(id: &Option<u32>, s: S) -> Result<S::Ok, S::Error> {
|
||
match id {
|
||
// 这个分支到不了(字段上还有 skip_serializing_if),但把它写对是必须的:
|
||
// toml 序列化 `None` 会直接报错。
|
||
None => s.serialize_none(),
|
||
Some(id) => s.serialize_str(&format!("0x{id:08X}")),
|
||
}
|
||
}
|
||
|
||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<u32>, D::Error> {
|
||
let text = Option::<String>::deserialize(d)?;
|
||
|
||
match text {
|
||
None => Ok(None),
|
||
Some(text) => super::parse_id(&text).map(Some).ok_or_else(|| {
|
||
serde::de::Error::custom(format!("{text:?} is not an id like 0x1A2B3C4D"))
|
||
}),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn a_default_config_is_a_referee() {
|
||
let config = Config::default();
|
||
assert_eq!(config.role, Role::Referee);
|
||
assert!(config.is_bound(), "the referee watches everything");
|
||
assert!(!config.needs_password_setup(), "裁判端不设密码");
|
||
}
|
||
|
||
#[test]
|
||
fn the_roundtrip_survives_every_field() {
|
||
let mut config = Config::default();
|
||
config.role = Role::Team;
|
||
config.role_chosen = true;
|
||
config.team = 1;
|
||
config.team_names = ["红队".to_string(), "蓝队".to_string()];
|
||
config.camera_id = Some(0x3D4D_8A9A);
|
||
config.armor_id = Some(0x00AB_CDEF);
|
||
config.mirror = false;
|
||
config.password = Some("$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA".to_string());
|
||
config.hud.crosshair.offset_x = -0.125;
|
||
config.hud.crosshair.style = CrosshairStyle::CircleDot;
|
||
config.referee.slot_b = Some(0x1234_5678);
|
||
config.referee.manual_slots = true;
|
||
config.recording.crf = 18;
|
||
|
||
let text = config.render().expect("renders");
|
||
let back = Config::parse(&text).expect("parses");
|
||
|
||
assert_eq!(back.role, Role::Team);
|
||
assert!(back.role_chosen);
|
||
assert_eq!(back.team, 1);
|
||
assert_eq!(back.team_names, config.team_names);
|
||
assert_eq!(back.camera_id, Some(0x3D4D_8A9A));
|
||
assert_eq!(back.armor_id, Some(0x00AB_CDEF));
|
||
assert!(!back.mirror);
|
||
assert_eq!(back.password, config.password);
|
||
assert_eq!(back.hud.crosshair.offset_x, -0.125);
|
||
assert_eq!(back.hud.crosshair.style, CrosshairStyle::CircleDot);
|
||
assert_eq!(back.referee.slot_b, Some(0x1234_5678));
|
||
assert!(back.referee.manual_slots);
|
||
assert_eq!(back.recording.crf, 18);
|
||
}
|
||
|
||
#[test]
|
||
fn ids_are_written_in_hex_so_they_match_the_ui() {
|
||
let mut config = Config::default();
|
||
config.camera_id = Some(0x3D4D_8A9A);
|
||
|
||
let text = config.render().unwrap();
|
||
assert!(
|
||
text.contains(r#"cameraId = "0x3D4D8A9A""#),
|
||
"expected a hex id the on-site reader can match against the UI:\n{text}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_missing_file_is_the_default_not_an_error() {
|
||
let config = Config::load(Path::new("/nonexistent/cam-desktop/config.toml"))
|
||
.expect("a missing config is a normal first run");
|
||
assert_eq!(config.role, Role::Referee);
|
||
}
|
||
|
||
#[test]
|
||
fn a_typo_is_refused_instead_of_silently_ignored() {
|
||
// 这是从旧版继承下来的原则:静默忽略一个打错的键,会让配置看起来生效了。
|
||
assert!(Config::parse("rol = \"team\"").is_err(), "unknown top-level key");
|
||
assert!(Config::parse("role = \"camera\"").is_err(), "bad role value");
|
||
assert!(Config::parse("team = 9").is_err(), "team out of range");
|
||
assert!(
|
||
Config::parse("[hud]\nwat = 1").is_err(),
|
||
"unknown nested key"
|
||
);
|
||
assert!(Config::parse("cameraId = \"nope\"").is_err(), "bad id");
|
||
}
|
||
|
||
#[test]
|
||
fn an_older_config_without_the_new_sections_still_loads() {
|
||
// 只写了 role 的极简文件必须能用:加字段靠 serde(default),不该要求迁移。
|
||
let config = Config::parse("role = \"team\"\nteam = 1\ncameraId = \"0x0A0B0C0D\"")
|
||
.expect("parses");
|
||
|
||
assert_eq!(config.role, Role::Team);
|
||
assert_eq!(config.camera_id, Some(0x0A0B_0C0D));
|
||
assert!(config.hud.health.visible, "defaults fill the rest");
|
||
assert_eq!(config.version, CONFIG_VERSION);
|
||
}
|
||
|
||
#[test]
|
||
fn a_newer_config_is_refused_rather_than_half_understood() {
|
||
let err = Config::parse("version = 99").expect_err("refuses");
|
||
assert!(
|
||
err.to_string().contains("newer version"),
|
||
"the message must say why: {err}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_absent_role_falls_back_to_the_referee_but_is_not_marked_as_chosen() {
|
||
let config = Config::parse("team = 1").expect("parses");
|
||
assert_eq!(config.role, Role::Referee, "the only config-free mode");
|
||
assert!(!config.role_chosen, "默认值不等于人选过——首启要问一句");
|
||
}
|
||
|
||
#[test]
|
||
fn an_explicit_or_imported_role_counts_as_chosen() {
|
||
let config = Config::parse(r#"role = "referee"
|
||
roleChosen = true"#)
|
||
.unwrap();
|
||
assert!(config.role_chosen);
|
||
|
||
// 旧格式里写着角色也算人选过。
|
||
let imported = parse_legacy(r#"role = "team""#).apply_to(Config::default());
|
||
assert!(imported.role_chosen);
|
||
assert_eq!(imported.role, Role::Team);
|
||
}
|
||
|
||
#[test]
|
||
fn a_team_without_a_camera_is_unbound() {
|
||
let config = Config::parse("role = \"team\"\nteam = 0").expect("parses");
|
||
assert!(!config.is_bound(), "must ask which camera to watch");
|
||
assert!(config.needs_password_setup(), "first run sets a password");
|
||
}
|
||
|
||
#[test]
|
||
fn out_of_range_hud_values_are_clamped_not_rejected() {
|
||
// 手改配置写出一个 size = 5.0 会让瞄准器直接消失。夹一下比让人查半天便宜。
|
||
let config = Config::parse("[hud.crosshair]\nsize = 5.0\noffsetX = 9.0").unwrap();
|
||
let hud = config.hud.clamped();
|
||
|
||
assert!(hud.crosshair.size <= 0.4);
|
||
assert!(hud.crosshair.offset_x <= 0.5);
|
||
}
|
||
|
||
#[test]
|
||
fn team_names_are_sanitized_before_they_reach_the_disk() {
|
||
let mut config = Config::default();
|
||
// 前后空白 + 14 个字(超过上限)+ 换行 + 一个纯空白的名字。
|
||
config.team_names = [
|
||
" 很长的队伍名字超过了十二个字 ".to_string(),
|
||
" \n\t ".to_string(),
|
||
];
|
||
|
||
let config = config.normalized();
|
||
|
||
assert_eq!(
|
||
config.team_names[0].chars().count(),
|
||
TEAM_NAME_MAX_CHARS,
|
||
"按字符截断:按字节截断会切出半个字"
|
||
);
|
||
assert_eq!(config.team_names[0], "很长的队伍名字超过了十二");
|
||
assert_eq!(
|
||
config.team_names[1], DEFAULT_TEAM_NAMES[1],
|
||
"留空回落到默认名,而不是显示一个没有名字的血条"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn team_names_survive_the_round_trip() {
|
||
let mut config = Config::default();
|
||
config.team_names = ["红队".to_string(), "Blue Team".to_string()];
|
||
|
||
let text = config.render().expect("renders");
|
||
assert!(text.contains("teamNames"), "key name matters:\n{text}");
|
||
|
||
let back = Config::parse(&text).expect("parses");
|
||
assert_eq!(back.team_names, config.team_names);
|
||
}
|
||
|
||
#[test]
|
||
fn an_old_config_without_team_names_gets_the_defaults() {
|
||
let config = Config::parse("role = \"team\"\nteam = 1").expect("parses");
|
||
assert_eq!(
|
||
config.team_names,
|
||
[
|
||
DEFAULT_TEAM_NAMES[0].to_string(),
|
||
DEFAULT_TEAM_NAMES[1].to_string()
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_old_config_with_shake_true_still_loads() {
|
||
// 抖动去掉了,但字段必须还能读:这个文件是 deny_unknown_fields 的,删字段会让
|
||
// 所有写过 shake = true 的配置解析失败,而配置解析失败等于程序起不来。
|
||
let config = Config::parse("[hud.hitFeedback]\nshake = true")
|
||
.expect("旧配置必须还能读进来");
|
||
|
||
assert!(config.hud.hit_feedback.shake, "字段留着,只是不再生效");
|
||
assert!(
|
||
!HitFeedbackConfig::default().shake,
|
||
"默认值是不抖动"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn save_and_load_do_not_lose_the_password_hash() {
|
||
let dir = std::env::temp_dir().join(format!("cam-desktop-cfg-{}", std::process::id()));
|
||
let path = dir.join("nested").join("config.toml");
|
||
|
||
let mut config = Config::default();
|
||
config.role = Role::Team;
|
||
config.camera_id = Some(0xAAAA_BBBB);
|
||
config.password = Some("$argon2id$v=19$m=8,t=1,p=1$c2FsdA$aGFzaA".into());
|
||
|
||
config.save(&path).expect("saves");
|
||
let back = Config::load(&path).expect("loads");
|
||
|
||
assert_eq!(back.password, config.password);
|
||
assert_eq!(back.camera_id, config.camera_id);
|
||
assert!(
|
||
!path.with_extension("toml.tmp").exists(),
|
||
"the atomic temp file must be gone"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_configs_are_understood() {
|
||
let imported = parse_legacy(
|
||
r#"
|
||
# 队伍一的机器
|
||
role = "team"
|
||
team = 1
|
||
camera_id = "0x3D4D8A9A"
|
||
mirror = false
|
||
some_future_key = 1
|
||
"#,
|
||
);
|
||
|
||
assert_eq!(imported.role, Some(Role::Team));
|
||
assert_eq!(imported.team, Some(1));
|
||
assert_eq!(imported.camera_id, Some(0x3D4D_8A9A));
|
||
assert_eq!(imported.mirror, Some(false));
|
||
}
|
||
|
||
#[test]
|
||
fn importing_a_legacy_config_backs_the_old_file_up() {
|
||
let dir = std::env::temp_dir().join(format!("cam-desktop-mig-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
|
||
let path = dir.join("config.toml");
|
||
let legacy = dir.join("legacy-config.toml");
|
||
std::fs::write(
|
||
&legacy,
|
||
"role = \"team\"\nteam = 1\ncamera_id = \"0x3D4D8A9A\"\nmirror = false\n",
|
||
)
|
||
.unwrap();
|
||
|
||
let (config, notes) = load_with_migration_at(&path, &legacy).expect("migrates");
|
||
|
||
assert_eq!(config.role, Role::Team);
|
||
assert_eq!(config.team, 1);
|
||
assert_eq!(config.camera_id, Some(0x3D4D_8A9A));
|
||
assert!(!config.mirror);
|
||
assert!(path.exists(), "the new config is written immediately");
|
||
assert!(!legacy.exists(), "the old file is moved aside, not deleted");
|
||
assert!(dir.join("legacy-config.toml.bak").exists());
|
||
assert!(notes.len() >= 2, "the notes explain what happened: {notes:?}");
|
||
|
||
// 第二次启动不该再走导入:新文件已经在了。
|
||
let (again, notes) = load_with_migration_at(&path, &legacy).expect("loads");
|
||
assert_eq!(again.camera_id, Some(0x3D4D_8A9A));
|
||
assert!(notes.is_empty());
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn a_first_run_with_no_legacy_file_just_writes_defaults() {
|
||
let dir = std::env::temp_dir().join(format!("cam-desktop-fresh-{}", std::process::id()));
|
||
let path = dir.join("config.toml");
|
||
|
||
let (config, notes) = load_with_migration_at(&path, &dir.join("nope.toml")).unwrap();
|
||
|
||
assert_eq!(config.role, Role::Referee);
|
||
assert!(notes.is_empty());
|
||
assert!(path.exists());
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn backup_names_do_not_clobber_evidence() {
|
||
let dir = std::env::temp_dir().join(format!("cam-desktop-bak-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let path = dir.join("config.toml");
|
||
|
||
std::fs::write(&path, "x").unwrap();
|
||
let first = next_free_backup_path(&path);
|
||
assert!(first.to_string_lossy().ends_with("config.toml.bak"));
|
||
|
||
std::fs::write(&first, "y").unwrap();
|
||
let second = next_free_backup_path(&path);
|
||
assert!(
|
||
second.to_string_lossy().ends_with("config.toml.bak.2"),
|
||
"got {}",
|
||
second.display()
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn a_referee_legacy_import_keeps_the_referee_role() {
|
||
let imported = parse_legacy("role = \"referee\"\ncamera_id = \"0x1\"");
|
||
let config = imported.apply_to(Config::default());
|
||
|
||
assert_eq!(config.role, Role::Referee);
|
||
// 旧配置里的 camera_id 只对队伍端有意义;裁判端保留它但不会用它去限制接收。
|
||
assert_eq!(config.camera_id, Some(1));
|
||
assert!(config.is_bound());
|
||
}
|
||
|
||
#[test]
|
||
fn the_recordings_dir_honours_an_explicit_setting() {
|
||
let mut config = Config::default();
|
||
config.recording.dir = Some("/tmp/cam-recordings".into());
|
||
assert_eq!(config.recordings_dir(), PathBuf::from("/tmp/cam-recordings"));
|
||
|
||
config.recording.dir = Some(String::new());
|
||
assert_eq!(
|
||
config.recordings_dir(),
|
||
default_recordings_dir(),
|
||
"an empty string means `not set`, not `write to the current directory`"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn only_the_team_end_needs_a_password() {
|
||
assert!(Role::Team.needs_password());
|
||
assert!(!Role::Referee.needs_password());
|
||
}
|
||
}
|