feat(client): RMIC::2026 界面重做、可计算的版本机制与固件版本联动
构建客户端 / 检查(类型 + 单元测试) (push) Canceled after 0s
构建客户端 / Linux x64(deb + AppImage) (push) Canceled after 0s
构建客户端 / Windows x64(NSIS 安装包) (push) Canceled after 0s
构建客户端 / macOS 通用(.app + .dmg) (push) Canceled after 0s

- feat(client): 界面换成墨黑蓝底 + 单支红的矩形语言,切角全部去掉,仅装甲条保留平行四边形
- feat(client): 自托管 HarmonyOS Sans SC 与 JetBrains Mono,中文与拉丁数字逐字回退
- feat(client): 新增启动序列、首启门、用户端 HUD、裁判端控制台、设置抽屉与固件维护页
- feat(client): 裁判端中缝与下方控制台可拖,鼠标落在哪一路下方就看哪一路
- feat(client): 设置抽屉常驻实时预览,与画面共用同一份准星绘制代码
- feat(client): 动效改用 Material 3 的缓动曲线与时长,逐条替换掉自造的贝塞尔
- feat(version): 版本号与 CHANGELOG 由 scripts/version.mjs 从 git 历史算出,不再手写
- feat(firmware): Hello 报文携带语义化版本、构建号与自报协议号,负载向后兼容
- feat(client): 固件版本显示在绑定列表与裁判端每一格;协议不匹配时给出具体原因
- feat(firmware): 固件包版本与客户端同源,都从 version.json 读
- fix(client): getCurrentWindow() 在没有 Tauri 注入时同步抛异常,浏览器预览整棵树被卸载
- fix(client): 启动序列的淡出 effect 清掉了自己刚设的 setTimeout,界面永远停在启动页
- fix(client): 设置抽屉左侧导航的锚点没挂到 section 上,且用错了坐标系
- fix(protocol): Hello 改用宽松帧头解析,让协议不匹配的板子能被解释而不是静默消失
- fix(build): Windows 上 bindgen 加载不了 libclang 的同目录依赖,构建脚本补上搜索路径
- fix(build): esp-idf-sys 在长路径下拒绝构建,固件构建改用短的目标目录
- build(client): 队标图标,含光学尺寸分级与手工拼的多尺寸 ICO
- build(ci): 守门 job 增加版本一致性检查,产物名改为 rmic-2026-*
- docs: 补充界面语言、版本机制与协议通告布局的说明
This commit is contained in:
2026-10-19 02:40:08 +08:00
parent cc6b82d7ec
commit 896b21fe44
332 changed files with 16729 additions and 2765 deletions
+8 -1
View File
@@ -67,9 +67,16 @@ fn spawn_camera(state: Arc<AppState>, camera: u32, team: u8) {
41000 + team as u16,
));
// 演示模式合成的通告带上**本客户端自己的版本**(同一份 version.json),
// 所以界面上的"固件 v0.2.0 · 34"与这一份二进制是一致的。演示模式靠顶栏
// 那条琥珀标签区分,不靠版本号露馅——版本号是真实信息,不该为了演示造假。
let build = crate::version::build_info();
let hello = protocol::Hello {
sender_id: camera,
firmware: 0x0DE0,
firmware: 1,
version: build.packed(),
build: build.build,
protocol: build.protocol,
};
let frame_interval = Duration::from_micros(1_000_000 / FPS);
+103 -4
View File
@@ -1,4 +1,4 @@
//! cam-desktop —— ESP32 图传与装甲板系统的桌面端。
//! RMIC::2026 —— ESP32 图传与装甲板系统的桌面端。
//!
//! 一个进程、两套页面:
//!
@@ -23,7 +23,9 @@
mod commands;
mod demo;
mod version;
mod latency;
mod maintenance;
mod recording;
mod server;
mod state;
@@ -39,11 +41,15 @@ use tauri::{Manager, RunEvent, WindowEvent};
use state::AppState;
const USAGE: &str = "\
用法: cam-desktop [选项]
用法: RMIC::2026 [选项]
--role <team|referee> 身份。默认看配置文件;没有配置文件时是 referee
--config <路径> 配置文件位置
--latency-report <路径> 打开裁判端屏幕标记校准,写入新的本地 NDJSON 报告
--maintenance-probe <串口> 连接设备、打印芯片/分区表/App 信息后退出(会复位一次)
--maintenance-bundle <固件包> 只校验 .zip/目录包并打印清单,不连设备
--maintenance-plan <串口> <固件包> 打印烧录计划后退出(只读,不写 Flash)
--maintenance-write-test <串口> <固件包目录> <偏移> 写 App 并回读,不切分区表
--demo 演示模式:画面与血量都是本地合成的
--list 打印发现的摄像头与装甲板后退出
-h, --help 显示这段帮助
@@ -63,7 +69,7 @@ fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
if let Err(err) = run() {
log::error!("{err}");
log::error!("{err:#}");
// 走到这里说明连窗口都没起来(配置坏了、回环端口绑不上)。GUI 程序从访达启动时
// 没有终端可看,所以把原因也写进一个旁边的文件——现场排查时这是唯一线索。
@@ -81,6 +87,10 @@ struct Args {
role: Option<Role>,
config: Option<PathBuf>,
latency_report: Option<PathBuf>,
maintenance_probe: Option<String>,
maintenance_bundle: Option<PathBuf>,
maintenance_plan: Option<(String, PathBuf)>,
maintenance_write_test: Option<(String, PathBuf, u32)>,
demo: bool,
list: bool,
help: bool,
@@ -110,6 +120,27 @@ impl Args {
"--latency-report" => {
args.latency_report = it.next().map(PathBuf::from);
}
"--maintenance-probe" => {
args.maintenance_probe = it.next();
}
"--maintenance-bundle" => {
args.maintenance_bundle = it.next().map(PathBuf::from);
}
"--maintenance-plan" => {
let port = it.next();
let bundle = it.next();
if let (Some(port), Some(bundle)) = (port, bundle) {
args.maintenance_plan = Some((port, PathBuf::from(bundle)));
}
}
"--maintenance-write-test" => {
let port = it.next();
let bundle = it.next();
let offset = it.next().and_then(|value| parse_offset(&value));
if let (Some(port), Some(bundle), Some(offset)) = (port, bundle, offset) {
args.maintenance_write_test = Some((port, PathBuf::from(bundle), offset));
}
}
other => {
// 见模块文档:macOS 的 .app 启动参数与 shell 里的笔误都可能出现在这里,
// 但报错的代价(窗口起不来)远大于收益。
@@ -130,6 +161,39 @@ fn run() -> Result<(), Box<dyn Error>> {
return Ok(());
}
// 维护探测:不加载配置、不绑 UDP 端口,也不起窗口。现场可以用它确认串口和分区表。
if let Some(port) = &args.maintenance_probe {
let temp = std::env::current_dir()?.join("target").join("flash-tmp");
maintenance::set_temp_dir(temp);
let snapshot = maintenance::probe_snapshot(port)?;
println!("{}", serde_json::to_string_pretty(&snapshot)?);
return Ok(());
}
if let Some(bundle) = &args.maintenance_bundle {
let temp = std::env::current_dir()?.join("target").join("flash-tmp");
maintenance::set_temp_dir(temp);
let info = maintenance::inspect_bundle(bundle)?;
println!("{}", serde_json::to_string_pretty(&info)?);
return Ok(());
}
if let Some((port, bundle)) = &args.maintenance_plan {
let temp = std::env::current_dir()?.join("target").join("flash-tmp");
maintenance::set_temp_dir(temp);
let plan = maintenance::plan_snapshot(port, bundle)?;
println!("{}", serde_json::to_string_pretty(&plan)?);
return Ok(());
}
if let Some((port, bundle, offset)) = &args.maintenance_write_test {
let temp = std::env::current_dir()?.join("target").join("flash-tmp");
maintenance::set_temp_dir(temp);
let attempt = maintenance::write_test(port, bundle, *offset)?;
println!("{}", serde_json::to_string_pretty(&attempt)?);
return Ok(());
}
let config_path = args
.config
.clone()
@@ -228,11 +292,32 @@ fn run() -> Result<(), Box<dyn Error>> {
let listener_state = Arc::clone(&state);
// 日志第一行就报版本:现场拿到一台机器,最先要回答的两个问题就是"跑的是哪一版"
// 与"它和板子的协议对不对得上"。
{
let build = version::build_info();
log::info!(
"RMIC::2026 client {} (build {}, wire protocol {})",
build.version_text(),
build.build,
build.protocol
);
}
let app = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.manage(Arc::clone(&state))
.manage(latency_recorder)
.manage(Arc::new(maintenance::MaintenanceState::default()))
.invoke_handler(tauri::generate_handler![
maintenance::maintenance_list_ports,
maintenance::maintenance_load_bundle,
maintenance::maintenance_probe,
maintenance::maintenance_plan,
maintenance::maintenance_flash,
maintenance::maintenance_restore_table,
maintenance::maintenance_cancel,
latency::latency_enabled,
latency::latency_sample,
commands::stream_info,
@@ -262,7 +347,13 @@ fn run() -> Result<(), Box<dyn Error>> {
commands::quit_app,
commands::refresh,
])
.setup(move |_app| {
.setup(move |app| {
let temp = app
.path()
.app_cache_dir()
.unwrap_or_else(|_| std::env::temp_dir())
.join("flash-tmp");
maintenance::set_temp_dir(temp);
listener_state.spawn_background();
Ok(())
})
@@ -313,6 +404,14 @@ fn run() -> Result<(), Box<dyn Error>> {
Ok(())
}
fn parse_offset(text: &str) -> Option<u32> {
if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
u32::from_str_radix(hex, 16).ok()
} else {
text.parse::<u32>().ok()
}
}
/// 让 [`Config`] 在日志里可读。
///
/// 实现成自由函数而不是给 `Config` 加 `Display`:那是 cam-core 的公共类型,而"日志里长
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
//! 编译期嵌入的构建信息。
//!
//! # 为什么读仓库根的那个文件
//!
//! `version.json` 由 `scripts/version.mjs` 从 git 历史算出来,是版本号的唯一来源。
//! 这里用 `include_str!` 把仓库根的那一份**嵌进二进制**:
//!
//! - 不依赖运行时的文件(装到别人机器上也能读到);
//! - `include_str!` 会让 cargo 把它登记成依赖,所以它一变,这个 crate 就会重编——
//! 不需要再手写 `cargo:rerun-if-changed`。
//!
//! 二进制里的版本号因此一定与它编译时的那棵源码树一致,不会出现"装包写着 0.1.0、
//! 其实是两周后的代码"。
use serde_json::Value;
/// 仓库根 `version.json` 的原文。
const RAW: &str = include_str!("../../../version.json");
/// 一份构建的全部身份信息。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BuildInfo {
pub major: u16,
pub minor: u16,
pub patch: u16,
/// git 提交数,单调递增。
pub build: u16,
/// 线路协议版本。
pub protocol: u16,
}
impl BuildInfo {
/// 打包成 `major << 16 | minor << 8 | patch`,与协议里的 `Hello.version` 同一套编码。
pub fn packed(&self) -> u32 {
((self.major as u32) << 16) | (((self.minor as u32) & 0xff) << 8) | ((self.patch as u32) & 0xff)
}
pub fn version_text(&self) -> String {
format!("{}.{}.{}", self.major, self.minor, self.patch)
}
}
/// 解析失败时退回全 0——那会让界面显示"版本未知",比编不过或者编出一个假版本好。
pub fn build_info() -> BuildInfo {
let Ok(json) = serde_json::from_str::<Value>(RAW) else {
return BuildInfo {
major: 0,
minor: 0,
patch: 0,
build: 0,
protocol: 0,
};
};
let field = |name: &str| -> u16 {
json.get(name)
.and_then(Value::as_u64)
.unwrap_or(0)
.min(u16::MAX as u64) as u16
};
BuildInfo {
major: field("major"),
minor: field("minor"),
patch: field("patch"),
build: field("build"),
protocol: field("protocol"),
}
}