Files
ESP32S3_vt/desktop/core/src/config.rs
T
Horldsence a93ae065cf desktop: 双方血条(角上平行四边形)、归零即击败、去掉受击抖动、裁判端可改队名
界面改动都在浏览器预览里逐条核对过(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)通过。
2026-10-14 20:48:12 +08:00

1192 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 磁盘上的配置:身份、绑定、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());
}
}