//! 磁盘上的配置:身份、绑定、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 { 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, /// 右槽位绑定的摄像头 id。 #[serde(skip_serializing_if = "Option::is_none", with = "opt_hex_id")] pub slot_b: Option, /// 左槽位绑定的装甲板主板 id。 #[serde(skip_serializing_if = "Option::is_none", with = "opt_hex_id")] pub armor_a: Option, /// 右槽位绑定的装甲板主板 id。 #[serde(skip_serializing_if = "Option::is_none", with = "opt_hex_id")] pub armor_b: Option, /// 槽位是手动指定的吗。 /// /// `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, /// 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, /// 绑定的装甲板主板(MAC 派生)。 #[serde(skip_serializing_if = "Option::is_none", with = "opt_hex_id")] pub armor_id: Option, /// 左右镜像。摄像头装反了,出图左右颠倒,所以默认翻回来。 pub mirror: bool, /// 密码的 Argon2id PHC 字符串。`None` = 还没设过。 /// /// 只对用户端有意义([`Role::needs_password`])。 #[serde(skip_serializing_if = "Option::is_none")] pub password: Option, 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 { 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 { let config: Config = toml::from_str(text)?; config.validate()?; Ok(config) } /// 渲染成 TOML 文本。 /// /// 带上注释头,让这个文件在编辑器里自己解释自己。 pub fn render(&self) -> Result { 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::>().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, pub team: Option, pub camera_id: Option, pub mirror: Option, } 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 { 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` 是要显示给用户/写进日志的说明。旧文件**不删**:现场出问题 /// 时,"旧的那份到底写了什么"是第一个要看的东西。 pub fn load_with_migration(path: &Path) -> Result<(Config, Vec)> { // 只有用**默认**路径时才去动旧配置。 // // `--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)> { 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(id: &Option, s: S) -> Result { 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, D::Error> { let text = Option::::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()); } }