desktop: Tauri v2 后端 + 共享网络层(用户端/裁判端双身份)

把桌面端从 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 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
This commit is contained in:
2026-10-14 19:14:16 +08:00
parent eafa112959
commit a89d7ae5fb
50 changed files with 15615 additions and 11 deletions
+131
View File
@@ -0,0 +1,131 @@
/**
* 后端状态的订阅式存储。
*
* 后端每 200 ms 推一份完整快照,前端只做渲染——没有轮询、没有"各处分别拉数据"带来的
* 两种时刻的状态混在一起。实现用一个模块级单例 + `useSyncExternalStore`。
*
* 快照对象**每次整体替换**:`useSyncExternalStore` 靠引用比较判断要不要重渲染,就地
* 修改字段不会触发更新。
*/
import { useSyncExternalStore } from "react";
import { StreamClient } from "./stream";
import type { HitEvent, Snapshot, WireMsg } from "./types";
/**
* 一次掉血在界面上的记录。
*
* `receivedAt` 用 `performance.now()` 而不是后端给的 `atMs`(Unix 毫秒):动画的时钟必须
* 与 `requestAnimationFrame` 的时间戳同源,把两个时钟相减会得到一个荒唐的年龄,动画也
* 就不会出现。
*/
export interface HitPulseRecord extends HitEvent {
seq: number;
receivedAt: number;
}
export interface AppStore {
/** 还没收到第一份快照时为 null。 */
snapshot: Snapshot | null;
/** 与后端的视频通道是否连着。 */
connected: boolean;
/** 最近一次掉血,界面用它做闪红/抖动。 */
lastHit: HitPulseRecord | null;
/** 后端要求弹出解锁框(用户端点关闭窗口时)。 */
confirmQuit: number;
/** 一条条要显示的说明。 */
notes: string[];
/** 通道本身的问题(连不上、丢帧)。 */
channelError: string | null;
}
const initial: AppStore = {
snapshot: null,
connected: false,
lastHit: null,
confirmQuit: 0,
notes: [],
channelError: null,
};
let store: AppStore = initial;
let hitSeq = 0;
const listeners = new Set<() => void>();
function emit(next: Partial<AppStore>) {
store = { ...store, ...next };
for (const listener of listeners) listener();
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** 组件里读状态。 */
export function useAppStore(): AppStore {
return useSyncExternalStore(subscribe, () => store);
}
/** 组件外读状态(事件处理、命令回调里用)。 */
export function currentStore(): AppStore {
return store;
}
function handleMessage(msg: WireMsg) {
switch (msg.t) {
case "snapshot": {
const { t: _tag, ...snapshot } = msg;
emit({ snapshot: snapshot as Snapshot });
return;
}
case "hit":
hitSeq += 1;
emit({ lastHit: { ...msg, seq: hitSeq, receivedAt: performance.now() } });
return;
case "note":
emit({ notes: [...store.notes, msg.message].slice(-6) });
return;
case "confirmQuit":
emit({ confirmQuit: store.confirmQuit + 1 });
return;
}
}
let client: StreamClient | null = null;
/**
* 建立与后端的通道。整个界面只调一次(在 `App` 里)。
*/
export function startStream(url: string): StreamClient {
client?.close();
client = new StreamClient({
onMessage: handleMessage,
onConnection: (connected) =>
emit({
connected,
channelError: connected ? null : "与后端的视频通道断开,正在重连…",
}),
});
client.connect(url);
return client;
}
export function streamClient(): StreamClient {
if (!client) throw new Error("startStream 还没有被调用");
return client;
}
/** 手动塞一条说明(本地校验失败之类)。 */
export function pushNote(message: string) {
emit({ notes: [...store.notes, message].slice(-6) });
}
/** 关掉一条说明。 */
export function dismissNote(index: number) {
emit({ notes: store.notes.filter((_, i) => i !== index) });
}