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,89 @@
|
||||
/**
|
||||
* 解锁对话框。
|
||||
*
|
||||
* 它只是**入口**,不是闸门:真正拦住改绑的是后端的 `require_unlocked`。所以这里即使
|
||||
* 被人绕过、甚至被改成永远返回成功,配置也改不动。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "../lib/ipc";
|
||||
|
||||
interface Props {
|
||||
/** 为什么需要密码:显示在标题下。 */
|
||||
reason: string;
|
||||
onUnlocked: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function UnlockDialog({ reason, onUnlocked, onCancel }: Props) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const input = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
input.current?.focus();
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
if (busy) return;
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const ok = await api.unlock(password);
|
||||
if (ok) {
|
||||
onUnlocked();
|
||||
} else {
|
||||
setError("密码不对");
|
||||
setPassword("");
|
||||
input.current?.focus();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2 className="dialog__title">需要密码</h2>
|
||||
<p className="dialog__hint">{reason}</p>
|
||||
|
||||
<input
|
||||
ref={input}
|
||||
className="input"
|
||||
type="password"
|
||||
value={password}
|
||||
placeholder="密码"
|
||||
autoComplete="off"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void submit();
|
||||
if (event.key === "Escape") onCancel();
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <p className="dialog__error">{error}</p>}
|
||||
|
||||
<p className="dialog__note">
|
||||
密码只挡现场随手改绑,不是安全边界:配置文件与程序都在本机,愿意改的人绕得过去。
|
||||
忘了密码就删掉配置里的 password 一行。
|
||||
</p>
|
||||
|
||||
<div className="dialog__actions">
|
||||
<button className="button button--ghost" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
<button className="button button--primary" onClick={() => void submit()} disabled={busy}>
|
||||
{busy ? "校验中…" : "解锁"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user