把桌面端从 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 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
343 lines
11 KiB
Rust
343 lines
11 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 等价。
|
||
";
|
||
|
||
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"),
|
||
..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();
|
||
|
||
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::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);
|
||
}
|
||
}
|