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:
@@ -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) });
|
||||
}
|
||||
Reference in New Issue
Block a user