界面部分。后端在前一个提交里,这一提交把它接上,并删掉 client/。 用户端: - 底部胶囊血条(宽度/数字/阈值/脉动可配),未接入时显示占位说明而不是 0; - 瞄准器画在 canvas 上并与画面共用同一次变换计算——位置跟着缩放走、大小不跟, 像真瞄具;偏移校准存的是画面比例而不是像素,换分辨率不会失效; - 命中反馈:整屏闪红 + 抖动 + 准星旁的伤害数字。大号数字刻意不放画面正中: 第一版放了,正好压住瞄准点。掉血事件由相邻两次读数之差派生(主板只报"现在多少血"); - 摄像头/装甲板下拉绑定、队伍号、镜像;改动要密码,退出/关闭也要。 裁判端: - 两路等宽(flex: 1 1 0 + min-width: 0)各自独立缩放; - 装甲板发现列表:id、地址、自报队伍、血量、四条齐不齐、静默多久、分到哪个槽位; - 截图 PNG + ffmpeg 录 mp4,界面内回放(资源协议白名单外的目录给"用系统播放器打开"的退路)。 密码:Argon2id PHC,首启强制设置,会话内解锁一次。执行点在 Rust 侧命令里,界面上的 解锁对话框只是入口。故意不做 --camera-id 这类命令行覆盖:那是一条不用密码就能改绑的路。 其它: - 配置换成 serde + toml,字段名统一 camelCase(磁盘=IPC=TS),仍然拒绝未知键名, 原子写;旧客户端配置首次启动导入并备份成 .bak(只在默认路径下做); - 演示模式(CAM_DEMO=1)合成协议数据报喂进真实接收路径,没有板子也能把整条链路跑起来; - 仅 dev 的浏览器预览(?preview=referee),两重门保证不进正式产物(已验证); - CSP 收紧成白名单,验证方式是"前端能不能连上后端":clients 从 0 变成 1 同时证明 JS 加载、IPC、WebSocket、React effect 四件事。 删掉 client/:它的功能全部被 desktop/ 覆盖,且其中有几处真 bug 在新实现里改掉了 (槽位分配会让两格显示同一块板子、帧率窗口基线、密码哈希截断被当成密码错、录制文件名 同秒互相覆盖)。注意 client/ 里那几处**未提交**的改动随之丢弃,历史里的版本还在。 测试:protocol 15、cam-core 69、src-tauri 30;前端 tsc + vite build 通过。
357 lines
12 KiB
Rust
357 lines
12 KiB
Rust
//! cam-desktop —— ESP32 图传与装甲板系统的桌面端。
|
||
//!
|
||
//! 一个进程、两套页面:
|
||
//!
|
||
//! - **用户端**(`--role team`):一路画面 + 血条 + 瞄准器,绑定一块图传板与一块装甲板。
|
||
//! - **裁判端**(`--role referee`,默认):两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。
|
||
//!
|
||
//! 界面是 WebView(React 渲染到 canvas),后端是这里的 Rust:UDP 收流、发现、装甲板、
|
||
//! 配置与密码、录制。视频不经 IPC,而是走一个只监听回环的 WebSocket(见 [`server`])。
|
||
//!
|
||
//! # 命令行
|
||
//!
|
||
//! ```text
|
||
//! --role <team|referee> 身份。默认看配置文件,没有配置文件时是 referee
|
||
//! --config <路径> 配置文件位置
|
||
//! --demo 演示模式:本地合成画面与血量(标了"演示模式")
|
||
//! --list 打印发现的摄像头与装甲板后退出
|
||
//! -h, --help 显示这段帮助
|
||
//! ```
|
||
//!
|
||
//! **未知参数只记一条日志,不报错**:macOS 从访达启动 .app 时会塞进来一个
|
||
//! `-psn_0_123456`,把它当错误处理的表现是"双击图标什么都没发生"。
|
||
|
||
mod commands;
|
||
mod demo;
|
||
mod recording;
|
||
mod server;
|
||
mod state;
|
||
|
||
use std::error::Error;
|
||
use std::path::PathBuf;
|
||
use std::sync::Arc;
|
||
use std::time::Duration;
|
||
|
||
use cam_core::config::{self, Config, Role};
|
||
use tauri::{Manager, RunEvent, WindowEvent};
|
||
|
||
use state::AppState;
|
||
|
||
const USAGE: &str = "\
|
||
用法: cam-desktop [选项]
|
||
|
||
--role <team|referee> 身份。默认看配置文件;没有配置文件时是 referee
|
||
--config <路径> 配置文件位置
|
||
--demo 演示模式:画面与血量都是本地合成的
|
||
--list 打印发现的摄像头与装甲板后退出
|
||
-h, --help 显示这段帮助
|
||
|
||
身份由配置文件记住(~/.config/cam-desktop/config.toml),首次启动会在界面上问。
|
||
用户端的绑定改动需要密码;裁判端不需要。
|
||
|
||
环境变量:
|
||
CAM_DEMO=1 等价于 --demo
|
||
CAM_CONFIG=<路径> 等价于 --config <路径>
|
||
|
||
两个环境变量存在的理由很实际:`tauri dev` 要用两个 `--` 才能把参数递给应用
|
||
(`tauri dev -- -- --demo`),而写在一行环境变量里不会有人记错。
|
||
";
|
||
|
||
fn main() {
|
||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||
|
||
if let Err(err) = run() {
|
||
log::error!("{err}");
|
||
|
||
// 走到这里说明连窗口都没起来(配置坏了、回环端口绑不上)。GUI 程序从访达启动时
|
||
// 没有终端可看,所以把原因也写进一个旁边的文件——现场排查时这是唯一线索。
|
||
let path = config::default_config_path().with_extension("startup-error.txt");
|
||
let _ = std::fs::write(&path, format!("{err}\n"));
|
||
eprintln!("{USAGE}");
|
||
|
||
std::process::exit(1);
|
||
}
|
||
}
|
||
|
||
/// 命令行参数。全部可选——什么都不给时按配置文件走。
|
||
#[derive(Debug, Default)]
|
||
struct Args {
|
||
role: Option<Role>,
|
||
config: Option<PathBuf>,
|
||
demo: bool,
|
||
list: bool,
|
||
help: bool,
|
||
}
|
||
|
||
impl Args {
|
||
fn parse(argv: impl Iterator<Item = String>) -> Self {
|
||
let mut args = Args {
|
||
demo: std::env::var("CAM_DEMO").is_ok_and(|v| v == "1" || v == "true"),
|
||
config: std::env::var_os("CAM_CONFIG").map(PathBuf::from),
|
||
..Default::default()
|
||
};
|
||
|
||
let mut it = argv.peekable();
|
||
|
||
while let Some(arg) = it.next() {
|
||
match arg.as_str() {
|
||
"-h" | "--help" => args.help = true,
|
||
"--demo" => args.demo = true,
|
||
"--list" => args.list = true,
|
||
"--role" => {
|
||
args.role = it.next().and_then(|v| Role::parse(&v));
|
||
}
|
||
"--config" => {
|
||
args.config = it.next().map(PathBuf::from);
|
||
}
|
||
other => {
|
||
// 见模块文档:macOS 的 .app 启动参数与 shell 里的笔误都可能出现在这里,
|
||
// 但报错的代价(窗口起不来)远大于收益。
|
||
log::debug!("ignoring unknown argument {other:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
args
|
||
}
|
||
}
|
||
|
||
fn run() -> Result<(), Box<dyn Error>> {
|
||
let args = Args::parse(std::env::args().skip(1));
|
||
|
||
if args.help {
|
||
print!("{USAGE}");
|
||
return Ok(());
|
||
}
|
||
|
||
let config_path = args.config.clone().unwrap_or_else(config::default_config_path);
|
||
|
||
// 首次启动时导入旧客户端的配置,并把旧文件改名成 .bak。
|
||
let (mut config, notes) = config::load_with_migration(&config_path)?;
|
||
|
||
// 命令行覆盖配置文件,反过来不成立(只在内存里改)。这样现场可以把一台机器临时当
|
||
// 另一台用,而不动文件。
|
||
if let Some(role) = args.role {
|
||
config.role = role;
|
||
}
|
||
|
||
for note in ¬es {
|
||
log::info!("{note}");
|
||
}
|
||
|
||
log::info!(
|
||
"role={} team={} camera={:?} armor={:?} mirror={} demo={} config={}",
|
||
config.role.as_str(),
|
||
config.team,
|
||
config.camera_id,
|
||
config.armor_id,
|
||
config.mirror,
|
||
args.demo,
|
||
config_path.display()
|
||
);
|
||
|
||
let (state, video_socket, armor_socket) =
|
||
AppState::new(config_path, config, notes, args.demo)?;
|
||
|
||
// 视频端口绑不上时没有 socket,接收线程也就没有意义——界面会显示那条说明。
|
||
if let Some(socket) = video_socket {
|
||
let receiver = Arc::clone(&state.receiver);
|
||
std::thread::Builder::new()
|
||
.name("udp-reader".into())
|
||
.spawn(move || receiver.run(socket))?;
|
||
}
|
||
|
||
if let Some(socket) = armor_socket {
|
||
state.armor.spawn(socket);
|
||
}
|
||
|
||
// 顺序有讲究:sink 必须先装好,后台任务才开始推——否则头几帧会掉在空处。
|
||
state.install_sinks();
|
||
|
||
// 视频通道的地址。
|
||
//
|
||
// 带 token 的那一版只在 debug 级:它是给本机开发者用 `wscat` 连上去看这条通道用的
|
||
// ("画面不动"时能立刻分清是后端没推还是前端没接)。info 级只报端口。
|
||
log::info!("stream: listening on ws://127.0.0.1:{}", state.port);
|
||
log::debug!("stream: {}", state.stream_info().url);
|
||
|
||
if args.demo {
|
||
state.note("演示模式已打开:画面与血量由本机合成,不是场上的板子。");
|
||
demo::spawn(Arc::clone(&state));
|
||
}
|
||
|
||
// --list:等一个广播周期,把发现到的东西打出来。
|
||
//
|
||
// 旧的 Slint 客户端在 release 的用户端直接拒绝这个选项(它会列出场上所有板子的 id
|
||
// 与 IP)。现在这条限制落在别处:用户端的发现列表只在**解锁后的设置面板**里放开,
|
||
// 而这里的输出只进日志,不进界面。
|
||
if args.list {
|
||
std::thread::sleep(Duration::from_secs(5));
|
||
|
||
println!("摄像头:");
|
||
for camera in state.snapshot().cameras {
|
||
println!(
|
||
" {} {} {}",
|
||
camera.id_text,
|
||
camera.addr,
|
||
if camera.online { "在线" } else { "离线" }
|
||
);
|
||
}
|
||
|
||
println!("装甲板:");
|
||
for board in state.snapshot().armor_boards {
|
||
println!(
|
||
" {} {} 队伍{} {} 血",
|
||
board.id_text,
|
||
board.addr,
|
||
board.team + 1,
|
||
board.hp
|
||
);
|
||
}
|
||
|
||
state.recordings.stop_all();
|
||
_ = state;
|
||
return Ok(());
|
||
}
|
||
|
||
let listener_state = Arc::clone(&state);
|
||
|
||
let app = tauri::Builder::default()
|
||
.plugin(tauri_plugin_opener::init())
|
||
.manage(Arc::clone(&state))
|
||
.invoke_handler(tauri::generate_handler![
|
||
commands::stream_info,
|
||
commands::get_snapshot,
|
||
commands::get_config,
|
||
commands::list_media_files,
|
||
commands::config_path,
|
||
commands::unlock,
|
||
commands::lock,
|
||
commands::set_password,
|
||
commands::set_discovery_open,
|
||
commands::bind_camera,
|
||
commands::bind_armor,
|
||
commands::set_team,
|
||
commands::set_role,
|
||
commands::set_mirror,
|
||
commands::save_hud,
|
||
commands::set_referee_slots,
|
||
commands::set_recording_options,
|
||
commands::screenshot,
|
||
commands::start_recording,
|
||
commands::stop_recording,
|
||
commands::reveal,
|
||
commands::open_file,
|
||
commands::clear_config,
|
||
commands::quit_app,
|
||
commands::refresh,
|
||
])
|
||
.setup(move |_app| {
|
||
listener_state.spawn_background();
|
||
Ok(())
|
||
})
|
||
// 用户端未解锁时拦住"关窗口"与"退出"。
|
||
//
|
||
// 这是需求里明确要的(退出/关闭也要密码),所以这里把 close 拦下来并让前端弹
|
||
// 解锁框。拦不住的路径仍然存在——`kill -9`、强退、拔电源——所以它同样是"防
|
||
// 随手误操作",不是安全边界。
|
||
.on_window_event(|window, event| {
|
||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||
let state = window.state::<Arc<AppState>>();
|
||
|
||
if state.config().role.needs_password() && !state.is_unlocked() {
|
||
api.prevent_close();
|
||
state.push(server::WireMsg::ConfirmQuit);
|
||
}
|
||
}
|
||
})
|
||
.build(tauri::generate_context!())?;
|
||
|
||
app.run(|app, event| {
|
||
if let RunEvent::ExitRequested { api, .. } = &event {
|
||
let state = app.state::<Arc<AppState>>();
|
||
|
||
if state.config().role.needs_password() && !state.is_unlocked() {
|
||
api.prevent_exit();
|
||
state.push(server::WireMsg::ConfirmQuit);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if let RunEvent::Exit = event {
|
||
// 收尾录制:ffmpeg 需要把索引写完,否则留下一个放不了的 mp4。
|
||
let state = app.state::<Arc<AppState>>();
|
||
let stopped = state.recordings.stop_all();
|
||
|
||
for dto in stopped {
|
||
log::info!(
|
||
"退出:0x{:08X} 收了 {} 帧 -> {:?}",
|
||
dto.camera,
|
||
dto.frames_written,
|
||
dto.path
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 让 [`Config`] 在日志里可读。
|
||
///
|
||
/// 实现成自由函数而不是给 `Config` 加 `Display`:那是 cam-core 的公共类型,而"日志里长
|
||
/// 什么样"是这个二进制自己的事。
|
||
#[allow(dead_code)]
|
||
fn describe(config: &Config) -> String {
|
||
format!(
|
||
"role={} team={} camera={:?} armor={:?}",
|
||
config.role.as_str(),
|
||
config.team,
|
||
config.camera_id,
|
||
config.armor_id
|
||
)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::Args;
|
||
|
||
fn args(list: &[&str]) -> Args {
|
||
Args::parse(list.iter().map(|s| s.to_string()))
|
||
}
|
||
|
||
#[test]
|
||
fn defaults_are_all_optional() {
|
||
let parsed = args(&[]);
|
||
assert!(parsed.role.is_none(), "the config file decides");
|
||
assert!(!parsed.demo);
|
||
assert!(!parsed.help);
|
||
}
|
||
|
||
#[test]
|
||
fn role_and_config_are_read() {
|
||
let parsed = args(&["--role", "team", "--config", "/tmp/x.toml"]);
|
||
assert_eq!(parsed.role, Some(cam_core::Role::Team));
|
||
assert_eq!(parsed.config.unwrap().to_string_lossy(), "/tmp/x.toml");
|
||
}
|
||
|
||
#[test]
|
||
fn a_mac_app_launch_argument_does_not_break_startup() {
|
||
// 从访达双击 .app 时系统会塞进来这个。把它当错误处理的表现是"双击没反应"。
|
||
let parsed = args(&["-psn_0_123456"]);
|
||
assert!(parsed.role.is_none());
|
||
assert!(!parsed.help);
|
||
}
|
||
|
||
#[test]
|
||
fn a_bad_role_value_falls_back_to_the_config_rather_than_dying() {
|
||
let parsed = args(&["--role", "teem"]);
|
||
assert!(parsed.role.is_none(), "typo must not kill the window");
|
||
}
|
||
|
||
#[test]
|
||
fn demo_can_be_turned_on_from_the_command_line() {
|
||
assert!(args(&["--demo"]).demo);
|
||
}
|
||
}
|