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
+76
View File
@@ -0,0 +1,76 @@
/** 纯格式化函数。界面里所有"给人看的数字"都从这里过一遍。 */
/** `0x3D4D8A9A`。与 Rust 侧的 `snapshot::id_text` 输出一致。 */
export function idText(id: number | null | undefined): string {
if (id === null || id === undefined) return "—";
return `0x${id.toString(16).toUpperCase().padStart(8, "0")}`;
}
/** 22000 → `21.5 kB`。 */
export function bytes(value: number): string {
if (value <= 0) return "—";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} kB`;
return `${(value / 1024 / 1024).toFixed(2)} MB`;
}
/** 毫秒 → 人话。 */
export function elapsed(ms: number | null | undefined): string {
if (ms === null || ms === undefined) return "—";
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
const totalSeconds = Math.round(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) return `${minutes} 分 ${seconds} 秒`;
const hours = Math.floor(minutes / 60);
return `${hours} 小时 ${minutes % 60} 分`;
}
/** 录制时长:从开始到现在的墙钟。 */
export function since(ms: number | null | undefined, now = Date.now()): string {
if (!ms) return "—";
return elapsed(now - ms);
}
/** Unix 毫秒 → `14:03:22`。 */
export function clockTime(ms: number | null | undefined): string {
if (!ms) return "—";
const date = new Date(ms);
const pad = (value: number) => value.toString().padStart(2, "0");
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
/** 小数位固定的帧率。0 表示"还没读数",显示成 `—` 而不是 `0.0`。 */
export function fps(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "—";
return value.toFixed(1);
}
/** 队伍名。与 Rust 侧的 `team_name` 一致。 */
export function teamName(team: number): string {
return team === 0 ? "队伍一" : "队伍二";
}
/** 队伍配色:与装甲板灯色一致(队伍一红、队伍二蓝)。 */
export function teamColor(team: number): string {
return team === 0 ? "#ff5f5f" : "#5aa9ff";
}
/** 把 `#RRGGBB` 变成 `rgba(...)`。瞄准器颜色要带透明度时用。 */
export function withAlpha(hex: string, alpha: number): string {
const match = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!match) return hex;
const value = parseInt(match[1], 16);
const r = (value >> 16) & 0xff;
const g = (value >> 8) & 0xff;
const b = value & 0xff;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
+87
View File
@@ -0,0 +1,87 @@
/**
* 后端命令的封装。
*
* 每个函数对应 `desktop/src-tauri/src/commands.rs` 里的一个 `#[tauri::command]`。
* Tauri 会把 JS 侧的 camelCase 参数名转成 Rust 侧的 snake_case,所以这里的参数名写成
* 前端习惯的样子即可。
*
* # 错误
*
* 后端的 `Err(String)` 会以 rejected promise 的形式回来,字符串就是给现场的人看的那句
* 话。`call()` 把 `unknown` 归一成 `Error`,调用方只管 `catch (err) { err.message }`。
*/
import { invoke } from "@tauri-apps/api/core";
import type {
ConfigView,
HudView,
MediaFileDto,
RecordingDto,
Role,
Snapshot,
StreamInfo,
} from "./types";
/** 把后端的字符串错误变成真正的 `Error`。 */
async function call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
try {
return await invoke<T>(command, args);
} catch (raw) {
throw new Error(typeof raw === "string" ? raw : String(raw));
}
}
export const api = {
// ---- 只读 ----
streamInfo: () => call<StreamInfo>("stream_info"),
snapshot: () => call<Snapshot>("get_snapshot"),
config: () => call<ConfigView>("get_config"),
mediaFiles: () => call<MediaFileDto[]>("list_media_files"),
configPath: () => call<string>("config_path"),
// ---- 密码 ----
unlock: (password: string) => call<boolean>("unlock", { password }),
lock: () => call<void>("lock"),
setPassword: (fresh: string, current?: string) =>
call<ConfigView>("set_password", { current: current ?? null, fresh }),
setDiscoveryOpen: (open: boolean) => call<void>("set_discovery_open", { open }),
// ---- 绑定与身份 ----
bindCamera: (cameraId: number | null, password?: string) =>
call<ConfigView>("bind_camera", { cameraId, password: password ?? null }),
bindArmor: (armorId: number | null, password?: string) =>
call<ConfigView>("bind_armor", { armorId, password: password ?? null }),
setTeam: (team: number, password?: string) =>
call<ConfigView>("set_team", { team, password: password ?? null }),
setRole: (role: Role, password?: string) =>
call<ConfigView>("set_role", { role, password: password ?? null }),
setMirror: (mirror: boolean) => call<ConfigView>("set_mirror", { mirror }),
saveHud: (hud: HudView) => call<ConfigView>("save_hud", { hud }),
setRefereeSlots: (slots: {
cameraA: number | null;
cameraB: number | null;
armorA: number | null;
armorB: number | null;
manual: boolean;
}) => call<ConfigView>("set_referee_slots", slots),
setRecordingOptions: (options: {
dir: string | null;
ffmpeg: string | null;
crf: number | null;
preset: string | null;
}) => call<ConfigView>("set_recording_options", options),
// ---- 截图与录制 ----
screenshot: (camera: number) => call<string>("screenshot", { camera }),
startRecording: (cameras: number[]) => call<RecordingDto[]>("start_recording", { cameras }),
stopRecording: (cameras: number[]) => call<RecordingDto[]>("stop_recording", { cameras }),
reveal: (path: string) => call<void>("reveal", { path }),
// ---- 退出与重置 ----
clearConfig: (password?: string) =>
call<string>("clear_config", { password: password ?? null }),
quit: (password?: string) => call<void>("quit_app", { password: password ?? null }),
refresh: () => call<void>("refresh"),
};
+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) });
}
+185
View File
@@ -0,0 +1,185 @@
/**
* 视频与状态通道的客户端。
*
* # 为什么是 WebSocket 而不是 invoke
*
* 两路 22fps、每帧约 22 kB,也就是每秒约 1 MB 的裸 JPEG。走 IPC 的话它要变成 base64
* 再涨 1/3,然后在 JS 里解回来——CPU 花在没人需要的编解码上。二进制 WebSocket 帧把
* 原始 JPEG 字节直接交给我们,交给 `createImageBitmap` 解码。
*
* # 解码是异步的,所以帧号要看着用
*
* `createImageBitmap` 返回 Promise,两个相邻帧的解码**可能**乱序完成。所以每路记住最后
* 画过的帧号,只有更新的那帧才会被采用。没有这一步,画面会偶发地往回跳一帧——看起来
* 像"卡了一下",极难归因。
*
* # `ImageBitmap` 必须显式 close
*
* 它持有解码后的像素(不吃 GC 的账)。每收到新帧就替换旧的那张并不够,必须把旧的那张
* `close()` 掉,否则两路 22fps 跑上几分钟就是几百 MB 的显存/内存。
*/
import { FRAME_HEADER_LEN, type WireMsg } from "./types";
/** 每路最新一张画面。 */
export interface FrameInfo {
camera: number;
frameId: number;
width: number;
height: number;
bitmap: ImageBitmap;
/** 收到的时刻(`performance.now()`),界面用来显示端到端延迟。 */
receivedAt: number;
}
interface Handlers {
onMessage: (msg: WireMsg) => void;
onConnection: (connected: boolean) => void;
}
/** 断线重连的退避上限。 */
const MAX_BACKOFF_MS = 3000;
export class StreamClient {
private ws: WebSocket | null = null;
private url: string | null = null;
private readonly frames = new Map<number, FrameInfo>();
private readonly handlers: Handlers;
private retryTimer: number | null = null;
private backoff = 250;
private closed = false;
/** 统计:收到多少帧、解码失败多少张。界面用它区分"链路没数据"和"数据坏了"。 */
readonly counters = { frames: 0, decodeFailures: 0, reconnects: 0 };
constructor(handlers: Handlers) {
this.handlers = handlers;
}
connect(url: string) {
this.url = url;
this.closed = false;
this.open();
}
private open() {
if (!this.url || this.closed) return;
let socket: WebSocket;
try {
socket = new WebSocket(this.url);
} catch {
this.scheduleReconnect();
return;
}
socket.binaryType = "arraybuffer";
this.ws = socket;
socket.onopen = () => {
this.backoff = 250;
this.handlers.onConnection(true);
};
socket.onclose = () => {
this.handlers.onConnection(false);
this.scheduleReconnect();
};
socket.onerror = () => {
// onerror 之后一定会有 onclose,重连交给那一条路径。
};
socket.onmessage = (event) => {
if (typeof event.data === "string") {
try {
this.handlers.onMessage(JSON.parse(event.data) as WireMsg);
} catch {
// 前端解析不了的消息只可能是版本不匹配,忽略比崩掉好。
}
return;
}
void this.onBinary(event.data as ArrayBuffer);
};
}
private async onBinary(buffer: ArrayBuffer) {
if (buffer.byteLength < FRAME_HEADER_LEN) return;
const view = new DataView(buffer);
const camera = view.getUint32(0, true);
const frameId = view.getUint32(4, true);
const width = view.getUint16(8, true);
const height = view.getUint16(10, true);
const length = view.getUint32(12, true);
if (buffer.byteLength < FRAME_HEADER_LEN + length) return;
this.counters.frames += 1;
const previous = this.frames.get(camera);
// 乱序完成时丢弃更旧的那一帧。用带符号差处理 u32 回绕。
if (previous && ((frameId - previous.frameId) | 0) <= 0) return;
const jpeg = new Uint8Array(buffer, FRAME_HEADER_LEN, length);
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(new Blob([jpeg], { type: "image/jpeg" }));
} catch {
this.counters.decodeFailures += 1;
return;
}
// 解码期间可能已经来了更新的帧:那就把刚解出来的这张丢掉。
const current = this.frames.get(camera);
if (current && ((frameId - current.frameId) | 0) <= 0) {
bitmap.close();
return;
}
current?.bitmap.close();
this.frames.set(camera, {
camera,
frameId,
width,
height,
bitmap,
receivedAt: performance.now(),
});
}
private scheduleReconnect() {
if (this.closed || this.retryTimer !== null) return;
this.retryTimer = window.setTimeout(() => {
this.retryTimer = null;
this.counters.reconnects += 1;
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
this.open();
}, this.backoff);
}
/** 某一路最新的一帧。 */
latest(camera: number | null | undefined): FrameInfo | undefined {
if (camera === null || camera === undefined) return undefined;
return this.frames.get(camera);
}
close() {
this.closed = true;
if (this.retryTimer !== null) {
window.clearTimeout(this.retryTimer);
this.retryTimer = null;
}
this.ws?.close();
this.ws = null;
for (const frame of this.frames.values()) frame.bitmap.close();
this.frames.clear();
}
}
+222
View File
@@ -0,0 +1,222 @@
/**
* 与 Rust 侧 DTO 一一对应的类型。
*
* 这些名字不是随手起的:Rust 那边的结构体带 `#[serde(rename_all = "camelCase")]`,
* 字段名就是这里写的这样。改任何一边都要同时改另一边——**编译器不会帮你发现这件事**,
* 所以两边都留了指向对方的注释。
*
* 对应的 Rust 定义在 `desktop/core/src/snapshot.rs` 与 `desktop/src-tauri/src/recording.rs`。
*/
export type Role = "team" | "referee";
/** `cam_core::config::CrosshairStyle` */
export type CrosshairStyle = "cross" | "cross-dot" | "circle-dot" | "dot";
/** `cam_core::config::GridStyle` */
export type GridStyle = "thirds" | "squares" | "cross";
/** `cam_core::config::HealthConfig` */
export interface HealthConfig {
visible: boolean;
showNumbers: boolean;
showTeamName: boolean;
lowThreshold: number;
pulse: boolean;
/** 血条宽度占画面宽度的比例。 */
width: number;
}
/** `cam_core::config::CrosshairConfig` */
export interface CrosshairConfig {
visible: boolean;
style: CrosshairStyle;
/** 臂长 / 半径,画面短边的比例。 */
size: number;
/** 线宽,画面短边的比例。 */
thickness: number;
/** 中心空隙,画面短边的比例。 */
gap: number;
/** `#RRGGBB` */
color: string;
/** 校准偏移,画面宽度的比例,正数向右。 */
offsetX: number;
/** 校准偏移,画面高度的比例,正数向下。 */
offsetY: number;
}
/** `cam_core::config::GridConfig` */
export interface GridConfig {
visible: boolean;
style: GridStyle;
divisions: number;
opacity: number;
}
/** `cam_core::config::HitFeedbackConfig` */
export interface HitFeedbackConfig {
flash: boolean;
shake: boolean;
marker: boolean;
intensity: number;
}
/** `cam_core::snapshot::HudView` */
export interface HudView {
health: HealthConfig;
crosshair: CrosshairConfig;
grid: GridConfig;
hitFeedback: HitFeedbackConfig;
showZoom: boolean;
showStats: boolean;
}
/** `cam_core::snapshot::ConfigView`(不含密码哈希) */
export interface ConfigView {
role: Role;
roleChosen: boolean;
team: number;
cameraId: number | null;
cameraIdText: string | null;
armorId: number | null;
armorIdText: string | null;
mirror: boolean;
passwordSet: boolean;
hud: HudView;
recordingDir: string;
ffmpeg: string;
}
/** `cam_core::snapshot::CameraDto` */
export interface CameraDto {
id: number;
idText: string;
addr: string;
online: boolean;
silenceMs: number;
firmware: number;
}
/** `cam_core::snapshot::ArmorBoardDto` */
export interface ArmorBoardDto {
id: number;
idText: string;
addr: string;
online: boolean;
team: number;
hp: number;
platesSeen: number;
reportsAllPlates: boolean;
silenceMs: number;
}
/** `cam_core::snapshot::CameraStatsDto` */
export interface CameraStatsDto {
camera: number;
cameraText: string;
online: boolean;
status: string;
hasFrames: boolean;
fps: number;
width: number;
height: number;
framesShown: number;
framesDropped: number;
lossPercent: number;
datagrams: number;
lastFrameBytes: number;
gapMs: number | null;
}
/** `cam_core::snapshot::SlotsDto` */
export interface SlotsDto {
cameraA: number | null;
cameraB: number | null;
armorA: number | null;
armorB: number | null;
manual: boolean;
}
/** `cam_core::snapshot::TeamHealthDto` */
export interface TeamHealthDto {
team: number;
name: string;
available: boolean;
placeholder: string;
hp: number | null;
hps: number[];
board: number | null;
boardText: string | null;
critical: boolean;
}
/** `cam_core::snapshot::RecordingDto` */
export interface RecordingDto {
camera: number;
cameraText: string;
active: boolean;
path: string | null;
inputFps: number;
framesWritten: number;
framesDropped: number;
startedMs: number | null;
error: string | null;
}
/** `desktop/src-tauri/src/recording.rs` 的 `MediaFileDto` */
export interface MediaFileDto {
name: string;
path: string;
kind: "video" | "image";
bytes: number;
modifiedMs: number;
}
/** `cam_core::snapshot::Snapshot` */
export interface Snapshot {
role: Role;
team: number;
mirrored: boolean;
demo: boolean;
diagnostics: boolean;
unlocked: boolean;
discoveryOpen: boolean;
uptimeMs: number;
clients: number;
config: ConfigView;
cameras: CameraDto[];
armorBoards: ArmorBoardDto[];
stats: CameraStatsDto[];
slots: SlotsDto;
health: TeamHealthDto[];
recordings: RecordingDto[];
notes: string[];
}
/** `desktop/src-tauri/src/server.rs` 的 `StreamInfo` */
export interface StreamInfo {
url: string;
port: number;
clients: number;
}
/** `server::WireMsg` 的 `Hit`。 */
export interface HitEvent {
board: number;
team: number;
index: number;
from: number;
to: number;
damage: number;
atMs: number;
}
/** `server::WireMsg` 的文本信封。`t` 是 serde 的内部标签。 */
export type WireMsg =
| ({ t: "snapshot" } & Snapshot)
| ({ t: "hit" } & HitEvent)
| { t: "note"; message: string }
| { t: "confirmQuit" };
/** 二进制视频帧的头部长度,与 `server::FRAME_HEADER_LEN` 一致。 */
export const FRAME_HEADER_LEN = 16;