把桌面端从 Slint 迁到 Tauri v2,后端先落地,界面待接。拆成三层: - desktop/core(cam-core,67 测试):从旧 client/ 抽出的非界面部分。UDP 接收按 发送方重组、装甲板发现与血量、帧率窗口、槽位分配、配置与密码。 - desktop/src-tauri(27 测试):Tauri 后端。回环 WebSocket 推二进制 JPEG、 每 200ms 一份状态快照、密码闸门、截图、ffmpeg 录制 mp4、演示数据源。 - protocol:新增 ArmorHealth::decode_from,把帧头里的 sender_id 交出来—— 装甲板一直带着 MAC 派生 id 在广播,旧解码路径把它丢了,所以"装甲板下拉可选" 不需要改固件。 几个改对了的旧问题: - 槽位分配:旧实现只排除"槽位自己原来那个 id",于是队伍一的板子一离线,它的槽位 会去拿队伍二那块,两格显示同一块板子。现在候选要排除另一个槽位占着的 id。 - 帧率窗口的基线:掉线又回来的板子带着一个很大的累计帧数回来,从 0 起算会报出 几百 fps。改成 seeded + 用"已结算窗口数"判断有没有读数。 - 密码哈希:password-hash 能解析 `$argon2id$broken` 这种截断串,于是"配置坏了" 会被读成"密码错了"。现在要求盐与摘要都在。 - 录制文件名:同一秒停一次再开一次会用同一个名字,ffmpeg 的 -y 静默覆盖上一段。 现在先收尾上一段再开新的,并且重名往后排号。 - 两道闸门:帧的限制与发现列表的限制分开。设置界面需要列候选摄像头,但**画面** 不需要——旧版把两件事绑在一道闸门上,只能在"能列候选"和"挡住对手"之间二选一。 测试:protocol 15、cam-core 67、desktop 27,其中录制那条真的起 ffmpeg 并用 ffprobe 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
1065 lines
37 KiB
Rust
1065 lines
37 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;
|
||
|
||
/// 客户端身份。
|
||
#[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,
|
||
/// 画面抖一下。
|
||
pub shake: bool,
|
||
/// 在瞄点旁标出伤害数字。
|
||
pub marker: bool,
|
||
/// 闪红与抖动的强度(0.2–2.0)。
|
||
pub intensity: f32,
|
||
}
|
||
|
||
impl Default for HitFeedbackConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
flash: true,
|
||
shake: true,
|
||
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,
|
||
/// 绑定的图传板(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,
|
||
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
|
||
}
|
||
|
||
/// 这个身份现在就能用了吗?
|
||
///
|
||
/// 裁判端永远可以用(`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)
|
||
}
|
||
}
|
||
|
||
/// 配置文件开头那段自我说明。
|
||
const HEADER: &str = "\
|
||
# cam-desktop 配置。
|
||
#
|
||
# 这个文件由程序写出,也可以手改;改完重启生效。
|
||
# 未知的键名会被当作错误拒绝加载,而不是静默忽略——打错一个键名却看起来「生效了」
|
||
# 在现场是要浪费时间的。
|
||
#
|
||
# role : team | referee
|
||
# team = 用户端:一块画面 + 自己的血条与瞄准器
|
||
# referee = 裁判端:两块画面 + 双方血量 + 装甲板列表 + 录制
|
||
# team : 0 = 队伍一, 1 = 队伍二(仅 team 端用)
|
||
# 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>)> {
|
||
load_with_migration_at(path, &legacy_config_path())
|
||
}
|
||
|
||
/// 与 [`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.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.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 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());
|
||
}
|
||
}
|