Files
ESP32S3_vt/desktop
Horldsence 4f7a142a39 desktop: 用户端与裁判端两套页面(Tauri v2 + React),删掉 Slint 客户端
界面部分。后端在前一个提交里,这一提交把它接上,并删掉 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 通过。
2026-10-14 19:28:49 +08:00
..

desktop

桌面端。两种身份,两套页面,一个进程。

身份 画面 血量 装甲板 绑定
用户端(team) 一路 自己那边 + 血条 自己那块 要,改动需密码
裁判端(referee) 两路并排 双方 发现列表 + 槽位 不需要

界面是 WebView 里的 React(画面画在 canvas 上),后端是 Rust。它不是把旧客户端套进 一个窗口:网络、重组、发现、配置、密码、录制全部重写成了可单测的模块。

desktop/
  core/           cam-core:不含任何界面与 Tauri 的东西(69 个测试)
  src-tauri/      Tauri 后端:推流、命令、录制(30 个测试)
  src/            React 前端:两套页面 + HUD
  src/lib/        IPC 封装、WebSocket 客户端、状态存储、格式化
  src/components/ 视频窗格、血条、瞄准器、装甲板表、录制面板、设置抽屉
  src/pages/      用户端 / 裁判端

快速开始

cd desktop
pnpm install
pnpm app          # = tauri dev:起 vite + 编 Rust + 开窗口
pnpm build        # 只构建前端(tsc --noEmit && vite build)
pnpm tauri build  # 出 .app 与 .dmg

下面两条是 Rust 侧,用它验证不依赖窗口的部分:

cd desktop/core      && cargo test    # 69
cd desktop/src-tauri && cargo test    # 30,其中几条真的会起 ffmpeg

演示模式:没有板子也能把整条链路跑起来

cd desktop
CAM_DEMO=1 pnpm app

它不伪造数据给界面,而是合成协议数据报,从 Receiver::datagram 与 ArmorHealthStore::datagram 进去。也就是说演示模式跑的是同一条真实路径:

合成 JPEG ──分片──▶ Receiver::datagram ──重组──▶ 帧 sink ──▶ WebSocket ──▶ canvas
合成血量 ─────────▶ ArmorHealthStore ──▶ HealthTracker ──▶ 命中反馈

分片、重组、发现、统计、掉血派生、两道闸门全在跑真代码,截图与录制也不需要特殊分支。 界面上会有一条 演示模式 标记,板子 id 也刻意取成 0x0DE00001 这种一眼能看出是人造 的形状——别把它当成现场数据。

命令行与环境变量

  --role <team|referee>   身份。默认看配置文件
  --config <路径>          配置文件位置
  --demo                  演示模式
  --list                  打印发现的摄像头与装甲板后退出
  -h, --help              帮助

CAM_DEMO=1              等价于 --demo
CAM_CONFIG=<路径>        等价于 --config <路径>

两个环境变量存在的理由很实际:tauri dev 要用两个 -- 才能把参数递给应用 (tauri dev -- -- --demo),写在一行环境变量里不会有人记错。

未知参数只记一条 debug 日志,不报错。 macOS 从访达双击 .app 时系统会塞进来一个 -psn_0_123456;把它当错误处理的表现是"双击图标什么都没发生"。

为什么换成 Tauri,代价是什么

旧客户端是 Rust + Slint 单二进制,能用。换掉它的理由是界面这一侧的生产力:血条、 瞄准器、可拖拽的偏移校准、下拉绑定、设置抽屉、录制列表与回放——这些用 CSS/HTML 写是 几十分钟的事,用即时模式 UI 工具写要自己排版、自己画控件、自己处理焦点。这个项目的重心 在链路(UDP、重组、发现、防作弊),界面应当越快改完越好。

代价说清楚:

  • 多一层。WebView + Node 工具链 + Tauri 的权限系统,比一个 Slint 二进制重得多。
  • 要联网装依赖(前端 npm、Rust crates)。
  • 画面要过一条本地通道才能到界面(见下一节)。Slint 那版是把 RGBA 直接交给渲染器, 路径更短。

换来的另一样东西是可测性:core/ 不认识界面,所以重组、发现、帧率窗口、槽位分配、 配置与密码全都能用单元测试盯住——而它们恰好是最容易错、错了又最难在现场看出来的部分。

视频怎么进 WebView

一个只监听回环地址的 WebSocket,推二进制 JPEG。 界面从 stream_info 命令拿到 ws://127.0.0.1:<随机端口>/?token=...。

为什么不是 Tauri 的 IPC

两路 22fps、每帧约 22 kB,每秒约 1 MB 的裸 JPEG。走 IPC 要序列化成 JSON(base64 再涨 1/3),前端再解回来——CPU 花在没人需要的编解码上,延迟白涨一截。二进制 WebSocket 帧把 原始字节直接交给前端,浏览器用原生解码器解,后端连 JPEG 都不用解。

二进制帧的布局

  0              4              8      10     12             16
  +--------------+--------------+------+------+--------------+---------+
  | sender_id u32| frame_id u32 | w u16| h u16| jpeg_len u32 | JPEG... |
  +--------------+--------------+------+------+--------------+---------+

小端,16 字节头。前后端对这段布局的约定由一个测试盯着(server.rs 与 stream.ts 两侧都有),长度不一致的帧宁可丢掉,也不要让前端拿到一段不完整的 JPEG。

前端侧的两个陷阱

解码是异步的,所以帧号要看着用。 createImageBitmap 返回 Promise,两个相邻帧的解码 可能乱序完成。所以每路记住最后画过的帧号,只有更新的那帧才采用——没有这一步,画面会偶尔 往回跳一帧,看起来像"卡了一下",极难归因。

ImageBitmap 必须显式 close()。 它持有解码后的像素,不吃 GC 的账。每收到新帧就 替换旧的那张并不够,必须把旧的那张关掉,否则两路 22fps 跑几分钟就是几百 MB。

防作弊:两道闸门,不是一个

对抗赛里两队都用这套系统看自己小车的图传,要防止对手开第二个窗口连到另一队的板子。

关键是这件事做在接收端,不是界面里。 广播的 Hello 里就有 MAC 派生的 sender_id 与 IP,藏起来毫无意义;数据也可能已经收下并解码了,.slint/CSS 只控制显示。

闸门 管什么 什么时候开
only_sender 帧的重组与投递 用户端始终是绑定的那一块
discovery_all 是否记录其它板子的 Hello 只在密码解锁后的设置界面里打开

旧客户端把两件事绑在同一道闸门上,于是只能在"能列出候选"和"挡住对手"之间二选一,改绑 只能靠手改配置文件。现在分开了:设置界面能列出候选摄像头(否则无从选起),但画面不进 来——对手的板子即使把流推到本机端口、即使被记进发现列表,它的帧也进不了重组器。

四种情况不限制:裁判端(中立方)、debug 构建、用户端还没绑定、--camera-id 显式覆盖。

遥测按编译模式门控

TEAM_DIAGNOSTICS = cfg!(debug_assertions)。用户端 release 下不产出帧率/丢帧这些 数字——不是"界面藏起来",是 DTO 里就不存在(CameraStatsDto::build 直接把它们置零)。 由编译模式决定而不是命令行开关:开关意味着二进制里带着那套东西,忘开一次就泄露了。

这挡不住什么

诚实地说:IP 仍然通过 Hello 广播泄露。 对手知道你的 IP 之后可以自己发一个 Subscribe 过来——固件会向任何订阅者推流,那道闸门在他的客户端上,不在你的。真要挡住 需要固件侧做配对 token(首次绑定发一个令牌,固件只接受带令牌的订阅),代价是固件重启就 失配。

回环通道上那个 token 也不是防对手的:它只让"本机另一个程序顺手连上来看画面"变成一件需要 先读到那个 token 的事。

密码:它挡的是谁

必须说清楚,否则会被当成一把它并不是的锁。

  • 挡得住:现场随手改绑定。选手想把自己的画面换到对手那块板子上,得先知道密码;围观 的人翻不到明文(只有 Argon2id 哈希),也不能靠改配置文件把绑定改掉——改了之后界面会 要求先解锁。退出/关闭程序同样要密码,因为"随手关掉"也是一种要挡的误操作。
  • 挡不住:一个愿意在自己机器上重编这个客户端的人。配置与二进制都在他手上,删掉哈希 或者直接改源码都行。真要封住得回固件加配对 token。

所以它的定位是防误触与防随手改,不是密码学意义上的对抗。

实现上:

  • Argon2id(argon2 crate,默认参数 19 MiB / t=2),存标准的 PHC 字符串,参数跟着哈希 一起躺在配置里,以后调参不需要迁移。用快哈希(SHA-256 之类)对 4 位数字密码等于没有 保护。
  • 首次启动强制设置密码(Config::needs_password_setup),最短 4 位。长度下限只要 4 位是刻意的:一个 8 位强密码在这里换不来任何实际安全增益,却会让现场每次改绑都要掏 手机查一遍。
  • 解锁一次管到进程结束(会话内)。用户端可以手动"上锁"。
  • 裁判端不要密码:它没有"绑定到自己那块板子"的动作可被滥用,而现场裁判要能最快地改 槽位、开录。
  • 离开用户端要密码:否则切到裁判端就能看两路画面、还能随便改配置,闸门形同虚设。

执行点在 Rust 侧:每个改变配置的命令都过 AppState::require_unlocked。界面上的解锁 对话框只是入口——即使有人从控制台直接调 invoke("bind_camera"),一样要过这一关。

故意没有 --camera-id 这类命令行覆盖。 旧客户端有一个,用来"临时把一台机器当另一台 用";在这里它等于一条不需要密码就能改绑的路,所以去掉。要临时换一块板子,就在设置里改 (要密码),或者用 --config 指一份另外的配置文件。

忘了密码:删掉配置文件里的 password 一行(或整个文件)。旧客户端的配置会在首次启动时 导入并备份成 .bak。

用户端 HUD

血条

画面下面一条窄带里的胶囊条,不挡画面。宽度、是否显示数字/队名、低血阈值与脉动都可配。

未接入时显示占位说明而不是 0:0 是一个看起来像真值的假值,现场看到 0 会以为对方已经 没血了,然后开始找裁判理论。这一条在旧客户端上是踩过坑的。

瞄准器

画在 canvas 上(不是叠一层 DOM),因为它必须跟着画面走:它标的是相机光轴上的一个点。 用 DOM 摆一个 <img> 再叠一层 transform 的话,"图像落在哪、瞄准点落在哪"要算两遍,而 两份实现迟早会不一致——表现是放大之后瞄准器偏了,恰恰发生在这件事最要紧的时候。所以变换 只算一次,图像、网格、瞄准器、伤害数字全都用同一组数画出来。

尺寸与缩放的关系也是刻意分开的:

跟着缩放走吗 为什么
位置 是 它指的是画面里的一个点
大小 否 像真瞄具一样,放大之后准星覆盖画面里更小的一块
网格 是 它标的是画面里的比例,用来估距离

偏移校准存的是画面比例,不是像素。 摄像头与枪管不同轴时要把瞄点挪到弹着点上,而像素 偏移在分辨率或窗口大小一变就全错了。校准可以拖着画面调,也可以直接填数值。

命中反馈

主板不报"被打了一下",只报"现在是多少血"。所以命中是靠相邻两次读数之差还原的 (HealthTracker):

  • 血量减少 ⇒ 一次命中,伤害是两个读数之差;
  • 血量增加 ⇒ 不是命中,是长按 BOOT 的重置;
  • 第一次见到一块板子不产生命中——没有基线,此时 hp = 0 只是"已经没血了"。

按板子记基线而不是按 (板子, index):主板一次广播四条、hp 相同,按四条各记一份 基线会让一次掉血被报成四次命中。

已知精度上限:两次读数之间发生两次伤害时只能看到净差。心跳 200 ms,所以这段窗口里的两次 打击会被合并成一次——对"闪一下红"足够,对判定不够,判定看的是界面上的绝对血量。

反馈有三样:整屏闪红、画面抖动、准星旁的伤害数字。大号数字不放在画面正中——第一版 放了,结果它正好压在瞄准点上,而被打中的那一下恰恰是最需要看着准星的时候。

裁判端

两格等宽

flex: 1 1 0 + min-width: 0,两格的宽度只由容器决定。

旧客户端在这里翻过车:HorizontalLayout 先按内容的最大需求分配,剩下的才按 stretch 分。一格写着"已连接,等待画面…"(8 字),另一格只有"未收到画面"(5 字),于是左边多要 十几个像素——实测 55% / 45%。现在占位文字绝对定位、两格需求相同。

槽位分配:修掉了一个真 bug

规则:

  1. 还在线的槽位不换人;
  2. 候选里排除已经被另一个槽位占着的 id;
  3. 掉线的槽位保留它的 id(显示"离线"),这样它回来时还在原位;
  4. 没有候选就不动——只有一块板在场时另一个槽位空着,空着比显示一份复制品诚实。

规则 2 是修 bug。旧实现只排除"这个槽位自己原来那个 id",于是队伍一的板子一离线,它的 槽位就会去拿队伍二那块,两格显示同一块板子——看起来像图传串了,现场的排查方向会是网线、 AP、板子,而不是二十行分配代码。现在它有一个直接对着这个场景写的测试。

有烧错固件的板子(team 字段是反的)时可以在设置里改成手动指定槽位。

装甲板发现列表

场上连上了几块板子、各自自报哪一队、现在多少血、多久没广播、四条上报齐不齐、分到了哪个 槽位——"裁判端没显示血量"时第一眼看的就是这张表。它能区分"板子根本没上线"、"上线了但队 伍号烧反了"和"只有一条上报在发"。

这不需要改固件:主板一直用 encode_from(sender_id) 把自己的 MAC 派生 id 打进帧头, 只是旧的解码路径把它丢了。现在 ArmorHealth::decode_from 把它交出来。

截图与录制

  • 截图存 PNG(shots/ 下),文件名带板子 id 与毫秒时间戳。
  • 录制存 mp4,走 ffmpeg 子进程:-f image2pipe -c:v mjpeg -framerate <fps> -i pipe:0 喂帧,x264 编码,+faststart。
  • 帧率要显式告诉 ffmpeg。image2pipe 里的 JPEG 序列没有时间戳,一串帧在 ffmpeg 看来 就是"以固定帧率播放的图片"。所以这个数只能来自实测,它会写进日志与 DTO——回放快放/慢放 时第一个要看的就是它。
  • 喂帧经由一条长度 64 的队列与一个写盘线程。接收线程绝不能被写盘拖慢:那一天的表现是 链路上本不存在的丢帧。写不进去就丢帧并计数,丢帧数会出现在界面上。
  • 文件名会查重:同一秒停一次再开一次是很自然的操作("这局重录"),而重名意味着 ffmpeg 的 -y 把上一段静默覆盖掉。所以先收尾上一段再开新的,重名往后排 -2、-3。

界面内回放走 Tauri 的资源协议,只对 tauri.conf.json 里 assetProtocol.scope 白名单的 目录生效。默认录制目录在名单里;改到别处时 <video> 会加载失败,界面上给出"用系统播放器 打开"的退路——而不是让人以为录下来的文件坏了。

配置

~/.config/cam-desktop/config.toml,--config 可改。

role = "team"
roleChosen = true
team = 0
cameraId = "0x0DE00001"
armorId = "0x0A200001"
mirror = false
password = "$argon2id$v=19$m=19456,t=2,p=1$..."

[recording]
dir = "/Users/you/Movies/cam-desktop"
ffmpeg = "ffmpeg"
crf = 20
preset = "veryfast"

[hud.crosshair]
style = "cross-dot"
size = 0.05
offsetX = 0.0

几个决定:

  • 手写解析换成 serde + toml。 旧客户端的配置只有四个标量字段,手写换来零依赖是划算 的;现在有 HUD 的嵌套结构、密码哈希、录制参数,对称性成本超过了收益。
  • 未知键名仍然报错(deny_unknown_fields)。打错一个键名却"看起来生效了",在现场是 要浪费时间的。
  • 字段名统一 camelCase,磁盘、IPC、前端 TS 一个名字。TOML 里写 camelCase 不算最漂亮, 但换来的是不需要任何一层做名字转换——而那种转换漏掉一个字段时不会报错,只会让某一项设置 看起来"改了没生效"。
  • 原子写:先写同目录临时文件再 rename。配置文件里有密码哈希,写一半崩掉留下的截断 文件会让"解锁"永久失败。
  • id 写成十六进制字符串("0x3D4D8A9A"),与界面、日志、抓包里看到的完全一致。
  • roleChosen 单独一位:role 的默认值是裁判端,所以"role == referee"既可能是人选了裁判 端,也可能只是还没人碰过这个文件。界面需要这个判据来决定首启要不要先问一句。

旧客户端(Slint 版)的配置在首次启动时导入能对应的字段,然后改名成 .bak(不删:现场 出问题时"旧的那份到底写了什么"是第一个要看的东西)。只有用默认路径时才做这件事—— --config 指定了别的路径说明这次运行是有意分开的,那时去动常规配置是越界的。

浏览器预览(仅开发)

Tauri 的界面跑在 WKWebView 里,改一行 CSS 要走一次 cargo 构建。所以 dev 模式下、且页面 里没有 Tauri 注入时,同一套 React 组件可以在浏览器里跑起来:状态与画面由 src/lib/dev-preview.ts 合成。

pnpm dev                                          # 用户端
open 'http://127.0.0.1:1420/?preview=referee'     # 裁判端(密码 1234)

两重门保证它进不了正式构建:import.meta.env.DEV 为真且没有 __TAURI_INTERNALS__, 并且调用点是 await import(...)——条件为假时整个模块不会被打进产物。

测试

cd desktop/core      && cargo test    # 69:重组、发现、两道闸门、帧率窗口、槽位、配置、密码
cd desktop/src-tauri && cargo test    # 30:帧头布局、录制、截图、演示画面、命令行
cd protocol          && cargo test    # 15

三条值得单独说的:

  • 录制那条真的起 ffmpeg、喂 20 帧、停,然后用 ffprobe 读回帧数与分辨率。只断言"文件 存在且非空"是不够的:ffmpeg 参数写错时也会留下一个空的容器,而那正是最难在现场发现的 一种失败。
  • 截图那条真写一个 PNG 再读回来对尺寸,另外验证坏输入会报错而不是留下一个空文件。
  • 帧率窗口那条把"对帧间隔取平均会给出什么"也钉住了(约 30fps),防止有人"顺手修一下" 改回错误算法。

CSP 的验证不靠"看一眼没报错",而是靠前端能不能连上后端:前端的第一步是先 invoke 拿通道地址,再连 WebSocket。所以快照里的 clients 从 0 变成 1 同时证明了四件事——JS 加载 成功、IPC 通、WebSocket 通、React 的 effect 跑到了。CSP 一旦挡住其中任何一环,它就会停在 0。手动核对的办法:

RUST_LOG=debug ./target/aarch64-apple-darwin/debug/cam-desktop | grep 'stream:'
# 拿那条 ws://... 地址,用任何 WebSocket 客户端连上去,看 clients 是不是 1

线程与数据流

线程 做什么 为什么单独
udp-reader 收包、解析、按发送方重组 不能让解码/写盘挤进数据报之间
armor-reader 收 5601 上的血量广播 与视频是两个端口、两种节奏
rec-writer-<id> 把队列里的帧写进 ffmpeg 的 stdin 接收线程绝不能被写盘拖慢
demo-*(仅演示) 合成协议数据报 走同一条真实路径
tokio 任务 WebSocket 服务、5 Hz 快照 异步 IO

跨线程只有两样东西:Arc 与广播通道。帧在接收线程里编码一次(16 字节头 + JPEG), 所有界面共用同一份 Bytes。

已知限制与没做的

  • 同一队有两块装甲板在广播时,health(team) 是"最后写入者优先",四个格子可能混着两块 板子的数。真实比赛一队只有一块,所以这只在开发(演示 + 真板子)或烧错固件时才会遇到; 日志里会警告一次,消歧办法是绑定具体的板子 id(health_of_board)。
  • 录制帧率是固定值,不是可变帧率。源帧率变化大时回放速度会有偏差;这个数会写进日志与 DTO,所以至少能解释清楚。
  • csp 是一条收紧过的白名单,但它是"够用"而不是"最小":style-src 带 'unsafe-inline'(React 的内联 style 属性需要它),connect-src 放开了整个 ws://127.0.0.1:*(端口是启动时随机分配的,写不死)。验证方式是"前端能不能连上后端" ——见下面"测试"一节最后一条。
  • 证书与公证没做,.dmg 只在本机可用。
  • 前端没有自动化测试。界面这一侧的验证手段是浏览器预览 + 真机运行;逻辑都在 core/ 里 有测试。

排查

现象 原因
起不来,提示绑不上 UDP 5600 同一端口上已经跑着另一个客户端(旧的 Slint 版或另一份本程序)。看 ~/.config/cam-desktop/startup-error.txt
窗口起来了但没画面 设置里看"摄像头"下拉:绑定的是哪一块、在不在线
状态栏一直"已连接,等待画面…" 板子在线但没推流。检查它有没有在向这个源端口订阅(客户端每 800ms 续订一次)
队伍端下拉里只有一块板子 正常:帧的闸门是绑定的那一块。设置界面解锁后会列出全部候选
画面不动 先看顶栏的"界面 N":0 表示前端没接上,≥1 表示后端在推而前端没画。RUST_LOG=debug 会打印完整通道地址,可以用 wscat 连上去看
回放失败 录制目录不在资源协议白名单里。用"用系统播放器打开"
录不了 ffmpeg 不在 PATH 里。设置里可以指定路径;不起 ffmpeg 时会报错而不是假装在录
两块画面串了 检查是不是手动指定了槽位,或者两块板子烧了同一个 ARMOR_TEAM
macOS 上 WebKit 报 could not create directory .../WebKit/... 运行环境禁止写 ~/Library/WebKit(沙箱)。WebView 会退回无缓存的临时目录,功能不受影响
libc 找不到 MAP_FAILED desktop/.cargo/config.toml 的 target 没改成宿主三元组