//! cam-desktop —— ESP32 图传与装甲板系统的桌面端。 //! //! 一个进程、两套页面: //! //! - **用户端**(`--role team`):一路画面 + 血条 + 瞄准器,绑定一块图传板与一块装甲板。 //! - **裁判端**(`--role referee`,默认):两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。 //! //! 界面是 WebView(React 渲染到 canvas),后端是这里的 Rust:UDP 收流、发现、装甲板、 //! 配置与密码、录制。视频不经 IPC,而是走一个只监听回环的 WebSocket(见 [`server`])。 //! //! # 命令行 //! //! ```text //! --role 身份。默认看配置文件,没有配置文件时是 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 身份。默认看配置文件;没有配置文件时是 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, config: Option, demo: bool, list: bool, help: bool, } impl Args { fn parse(argv: impl Iterator) -> 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> { 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::>(); 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::>(); 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::>(); 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); } }