02-Niskle Hub 互联方案(PC 端)
Niskle Hub 互联方案(PC 端)
协议前置:本方案实现
01-互联协议规范-v1.md(下称「协议」)。协议是唯一真相源,本文只写 PC 端(Rust Host) 的落地方式,不重新定义端点、信封、错误码、操作名与事件主题。凡本文与协议冲突,以协议为准,并按文末⚠️ 协议待修订清单回改协议。
目标代码库:
D:\Work\FrameFace(Tauri 2 + Rust edition 2024 + TypeScript)交付范围:协议 §13 的 P0–P3(全部在 Hub 侧,可只用 curl / 探针脚本独立验证)
不在范围:头显端 Niskle Link(P4–P7);媒体面(串流)全部不做,只按要求预留形状与端口预算
--
1. 目标与非目标
1.1 目标
| # | 目标 | 验收方式 |
|---|---|---|
| G1 | 在 Hub 里内置局域网控制面:HTTP 服务(TCP 21120)+ UDP 信标(37022) | curl.exe http://127.0.0.1:21120/v1/info 有响应 |
| G2 | 配对码换长期令牌;令牌只以 sha256 落盘;支持客户端自我注销 | 抓 %APPDATA%\FrameFace\remote-clients.json 看不到明文 |
| G3 | 单一操作注册表:能力清单、参数校验、错误映射、探针期望值全部由它生成 | GET /v1/capabilities 与注册表逐字段一致(单测 + 脚本) |
| G4 | 桌面 UI 与远程共用同一把资源锁,Engine 的 32 深队列不会被远程挤爆 | 并发压测返回 busy 而非静默丢命令 |
| G5 | 控制面核心不依赖 Tauri(协议 §15.2),未来可原样搬进 Windows 服务 | core/ 层 grep 检查(§2.4)+ 评审清单 |
| G6 | 全部改动通过 tools/verify-commands.ps1 与 tools/check-release.ps1 | 两个脚本 RESULT 全绿 |
| G7 | 不新增离线缓存里不存在的 crate | CARGO_HOME=<repo>\.cargo-home cargo build --offline 通过 |
1.2 非目标(明确不做,避免读者误判范围)
| 不做 | 原因 |
|---|---|
| 媒体面(音视频串流、编码器协商、虚拟显示器) | 协议 §0.1:本文只定义控制面。这是已规划但不属本期的产品路线 |
发出 stream.* 操作或清单里的 streaming 对象 | 协议 §6.3、§12.3:v1 只登记名字占位,不实现、不出现在清单里,误发返回 unknown_op |
| 云端中继 / TLS / 证书 | 协议 §1.2、§9 已定:本期仅局域网明文 HTTP;rustls 留到后续版本 |
| 本期做独立 Windows 服务 | 协议 §15.4 已定:本期把服务宿主在 Tauri 应用内。用户已确认「Hub 没运行时不需要头显把它拉起来」。将来加串流时新增 host_service/ 宿主层即可,属代码搬家,不是协议变更 |
| 自动渲染头显界面 | 协议 §6.1:清单只做门控,不生成 UI |
暴露 quit_app、wifi_provision_slimevr、detect_slimevr_serial、minimize_window/close_window | 协议 §12.2 明确排除 |
| 把 LAN 服务做成独立进程 / 独立 sidecar | 约束 1:Engine 命名管道 nMaxInstances=1 + PIPE_REJECT_REMOTE_CLIENTS(engine/ipc/src/named_pipe_server.cpp:33-40),Rust 是唯一客户端(engine_manager.rs:59,181-186)。头显直连 Engine 一定死锁,链路只能是 头显 → Rust Host → EngineManager → Engine |
| 改动 Engine / 契约 schema | 本协议不碰 \\.\pipe\FrameFace.Engine.v1(协议 §14) |
改 main.ts 的 2 秒 get_tracking_status 轮询与 150 ms 骨骼轮询 | 协议 §7.3 明确保持不动,只新增推送通道 |
| 放宽 WebView CSP | tauri.conf.json:30 的 connect-src ipc: http://ipc.localhost 没有为 LAN 服务放宽的余地——设置面板走 invoke,WebView 从不直连 21120 |
1.3 端口预算(协议 §15.5)
| 用途 | 端口 | 本期动作 |
|---|---|---|
| 控制 API | TCP 21120(可配置,持久化) | 新增 |
| 发现信标 | UDP 37022(固定,永不可配置,协议 §10.1) | 新增 |
| 未来媒体面 | UDP 37100–37199(建议区间) | 占位,不实现。config.rs 显式拒绝任何落在该区间的 UDP 端口配置,并在 docs/ 里写下这条预留 |
FrameCast(37020/37021)、SlimeVR(6969、21110、21112)、OSC(9000/9001/9003)、Link MJPEG(18980)一律不动。
1.4 编译期门控的取舍
不加 cargo feature,只用运行时配置 enabled 开关(默认 false)。理由:加 feature 会让 verify-commands.ps1、CI 与安装包需要两套构建路径,收益只是省几 MB 二进制。默认关闭 + 显式开启已经满足「不默认暴露」。
2. 架构落点
2.1 分层:core/ 与 host_app/(协议 §15.2 的硬要求)
产品路线已经明确:Link 将来是头显端串流接收端,Hub 是 PC 端串流服务端(对标 Virtual Desktop)。VD 的 PC 端是常驻服务,所以控制面核心逻辑不依赖 Tauri AppHandle,能被任意宿主进程承载。
头显 Niskle Link(Android / Compose)
│ HTTP/1.1 + SSE ,Authorization: Bearer <token>
▼
┌────────────────────── NiskleHub.exe(可信边界,SECURITY_BOUNDARIES.md:27) ──────────────────────┐
│ host_app/ ──适配──▶ core/ │
│ TauriHost transport/ registry/ auth/ ops/ events/ policy/ │
│ (AppHandle、 (tokio 监听、 (OPS 表、 (配对、 (处理器、(总线、 (锁、 │
│ State<Arc<…>>) HTTP/SSE) capabilities)令牌) 只调 host) 节流) 限流) │
│ │ │ │
│ │ │ core 只认识 RemoteHost 抽象(core/host.rs) │
│ ▼ ▼ │
│ ┌── 管理器(仅 host_app 能碰)────────────────────────────────────────────────────────────┐ │
│ │ EngineManager ──▶ \\.\pipe\FrameFace.Engine.v1(32 深队列,溢出丢最旧,:23,419-422) │ │
│ │ SlimeVrManager ──▶ java -jar slimevr.jar(stop 会 taskkill /F /IM java.exe /T,:231-236)│ │
│ │ VrcftRuntime ──▶ NiskleHubVrcft.exe │ │
│ │ updates::download_and_install ──▶ NSIS(基本不返回,updates.rs:136-138) │ │
│ └────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ └──▶ Tauri 事件 frameface://status / frameface://remote-activity ──▶ 桌面 WebView │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
(未来)host_service/ ← Windows 服务宿主,复用同一份 core/,协议与两端实现都不用改本期宿主决定:服务跑在 Tauri 应用进程内,在 lib.rs 的 setup 里、EngineManager::start 之后启动(lib.rs:142-159)。理由见协议 §15.4:本期目标(在 VR 里操作 Hub)本身就要求 Hub 正在运行——用户在用面捕时 Hub 必然是开着的。现在直接做服务会带来多进程、开机自启、权限提升与多一个攻击面,收益为零。
2.2 启动顺序与关闭
| 时机 | 动作 |
|---|---|
lib.rs:81-95 | 管理器 Arc 化并 manage(含新增 RemoteHandle 占位不需要——见 §5.4 的单例锁设计) |
setup(lib.rs:142-159) | EngineManager::start 之后调 remote::host_app::start(app.handle());失败只记日志并置 remote_status.last_error,不拖累 Hub 自身启动 |
| 启动失败 | 端口被占 / 公用网络被拒 / 配置损坏 → 设置页显示原因,服务保持关闭 |
quit_app_flow(lib.rs:34-41)与 RunEvent::Exit(lib.rs:163-169) | 各加一行 remote::shutdown(),位置在 app.exit(0) 之前,先释放 21120 再停信标线程 |
2.3 运行时模型
| 关注点 | 决策 | 理由 |
|---|---|---|
| HTTP 服务 | 独立的 tokio 多线程 runtime(worker_threads(2)),不用 Tauri 的 async_runtime | Tauri runtime 被 WebView/插件共用,SSE 长连接不该占它的 worker;且 core/ 不该依赖 tauri |
| op 执行 | 所有 handler 走 tokio::task::spawn_blocking | 底层管理器是阻塞 IO:TcpStream::connect_timeout(slimevr_manager.rs:246,288)、kill_orphan_servers 的 800 ms sleep、updates::check 的 reqwest::blocking(updates.rs:69)。直接在 async 任务里跑会卡死 runtime worker |
| 锁与限流 | std::sync::Mutex(因为都在 spawn_blocking 里) | 避免锁跨 .await,也不需要 tokio::sync 的异步锁 |
| 窗口类 op | 经 RemoteHost::show_window(),host 侧用 show_main_window(lib.rs:43-49) | 与既有 hide_to_tray(commands.rs:117-120)同路径;若真机出现跨线程 panic,改 app.run_on_main_thread |
2.4 依赖规则与强制手段
| 层 | 允许依赖 | 禁止依赖 |
|---|---|---|
remote/core/** | std、serde、serde_json、tokio(仅 rt/rt-multi-thread/net/time/sync/io-util)、httparse、sha2、subtle、base64、rand、zeroize、crate::contracts(纯 serde 数据,contracts.rs:1 只 use serde) | tauri(含 AppHandle/State/Manager/Emitter/WebviewWindow)、crate::engine_manager、crate::slimevr_manager、crate::vrcft_runtime、crate::updates、crate::upload(它 use tauri,upload.rs:4)、crate::named_pipe、windows-sys |
remote/host_app/** | core/ + tauri + 全部管理器 + windows-sys | 业务逻辑(只做适配与 DTO 转换;判断、校验、限流一律在 core/) |
强制手段(三层,成本递增):
门控脚本(本期就做):
tools/verify-commands.ps1新增第 7 项——扫src/remote/core/**/*.rs,命中use tauri、tauri::、AppHandle、State<、Manager、Emitter、WebviewWindow、windows_sys、crate::engine_manager、crate::slimevr_manager、crate::vrcft_runtime、crate::updates、crate::upload、crate::named_pipe任一即problems++。它挂在check-release.ps1:25-40里,所以这是发布门而不只是本地提示。命名陷阱同样入脚本:
core是内置 crate 名,core/里的代码若写use core::…会解析到内置core而不是本模块。脚本额外拦src/remote/**里的裸use core::,写法定为use crate::remote::core::…。评审清单(现在写进 AGENTS.md):
core/的任何 PR 出现tauri字样即拒。将来真正新建host_service/时,把core/提升为独立 crate(remote-core,path 依赖),用编译器而不是 grep 来保证——那时边界才有实际收益,现在加 crate 只会增加构建与目录复杂度。
为什么不用「core 只暴露 trait 给 Tauri 调」的反向做法:桌面 UI 的 35 个命令(commands.rs:14-306)已经在用 State<'_, Arc<EngineManager>>,让它们改走 trait object 会改掉命令参数表,收益只是形式上的对称。桌面命令直接调管理器,只额外取 core 的资源锁(§5.4),是改动最小的正确解。
3. 操作注册表(单一真相源)
协议 §11.4 的教训明确:命令名在 4 个文件 + Engine 枚举 + 前端字面量里手工同步,必然漂移(contracts/ipc-v1-command.schema.json:32 漏了 total_max_frames,而 protocol.cpp:468-474 接受它、engine_manager.rs:459-461 发送它)。所以远程操作的元数据只有一份。
3.1 数据结构(core/registry/mod.rs)
pub enum Effect { Read, Write, Action }
pub enum Resource { Engine, Vrcft, Body, Update, None } // 协议 §7.2 资源组
pub struct OpSpec {
pub op: &'static str, // 协议 §12.1 的点分 id,如 "tracking.start"
pub group: &'static str, // "hub" | "tracking" | "face" | ...
pub title: &'static str, // 中文标题,两端文案以它为准
pub effect: Effect,
pub confirm: bool, // 协议 §9.4
pub since: u32, // 协议 §11.2 铁律 3:必填
pub deprecated: bool,
pub args: &'static str, // 裁剪 JSON Schema 子集,单行字符串
pub resource: Resource,
pub core: &'static str, // 对应 RemoteHost 适配方法名(供脚本校验)
pub handler: fn(&OpsCtx, &serde_json::Value) -> Result<serde_json::Value, CoreError>,
}
pub static OPS: &[OpSpec] = &[ /* 每个 op 一条 */ ];
pub static GROUP_META: &[GroupSpec] = &[ /* id / title / order */ ];
pub static EVENTS: &[EventSpec] = &[ /* topic / title / since / default_hz / max_hz */ ];
pub static RESERVED_OPS: &[ReservedOp] = &[ /* 协议 §12.3 的 6 个 stream.* 占位 */ ];五个设计取舍:
表是
static而不是运行时构造。门控脚本按行读的就是它;运行时拼出来的表脚本看不见。handler用函数指针而不是闭包/trait object。fn指针可以放进static;闭包需要Box<dyn Fn>,只能OnceLock惰性初始化,且脚本无法静态提取。args存单行** JSON 字符串,不存嵌套结构体。脚本要按OpSpec {到单独一行的}切分条目(§3.5),多行嵌套字面量的大括号会把切分弄碎。排版约束写进文件头注释**:OpSpec {独占一行、每个字段一行、}独占一行、args的 JSON 写成单行——多行的话脚本就切不开条目。core字段显式声明适配方法名。Rust 没有反射,这是「注册表 op ↔ 宿主方法」可校验的唯一方式。Resource::None是显式值而不是Option。读操作与app.*、client.*明确不参与资源锁,后来者「顺手」给读操作加锁的路径也就不存在。
3.2 预留命名空间(协议 §6.3 / §12.3)
| 要求 | 实现 |
|---|---|
v1 不发出 stream.* | RESERVED_OPS 与 OPS 分离;capabilities 只遍历 OPS |
误发 stream.* 返回 unknown_op | 路由只在 OPS 里查找,天然 404 + unknown_op(协议 §4.4 规则:不会导致断连) |
| 将来「填实现」而非「改协议」 | 把 stream.* 从 RESERVED_OPS 移到 OPS 并补 handler + since: 2;GROUP_META 里 stream 组的 {id:"stream",title:"串流",order:30} 已按协议 §12.3 写好 |
v1 不返回清单里的 streaming 对象 | 单测 manifest_has_no_streaming_object;capabilities.rs 注释注明协议 §6.3 |
| 分组自动生成 | groups[] 由 OPS 实际用到的 group 集合派生(配 GROUP_META 取标题与顺序)——空分组不会出现在清单里,加了 stream op 分组自动出现 |
脚本守卫(§3.5 第 8 项):OPS 中不出现 op: "stream.;RESERVED_OPS 的 id 集合与协议 §12.3 的 6 个完全一致;两张表的 op 不相交。
3.3 GET /v1/capabilities 完全由注册表生成
core/registry/capabilities.rs 只做一件事:遍历 OPS/GROUP_META/EVENTS 拼出协议 §6.2 的 JSON。
| 响应字段 | 来源 |
|---|---|
hub.product / hub.version | RemoteHost::app_info()(host 侧用 app.package_info().version,单一版本源,不手抄 Cargo.toml) |
hub.instance_id | app_info().instance_id ← host 侧 upload::install_id()(upload.rs:21-39)。复用现有 install_id,不新建第二个 id(协议 §3.1、§10.3 要求它同时是发现去重键与中继路由键) |
hub.name | host 侧 COMPUTERNAME 环境变量,取不到则 "Niskle Hub PC" |
operations[] | OPS 逐条映射;args 用 serde_json::from_str 解析 |
groups[] | OPS 的实际分组 ∩ GROUP_META |
events[] | EVENTS |
硬规则:capabilities.rs 里出现的任何 op 名字面量都等于给规范开了第二个真相源。 由单测 capabilities_are_registry_derived(集合相等)与脚本(检查该文件不出现 "xxx.yyy" 形式字符串)双重保证。
3.4 Tauri 命令保持薄适配
commands.rs 的 35 个命令名、返回类型、前端可见参数名一个都不改(commands.rs:14-306)。函数体加一行取锁:
#[tauri::command]
pub fn start_tracking(manager: State<'_, Arc<EngineManager>>) -> Result<TrackingStatus, String> {
let _guard = host_app::lock(Resource::Engine).map_err(|e| e.to_string())?;
manager.send("start_tracking", None)
}要点:
返回值类型保持一致(
Result<TrackingStatus, String>),CoreError经Display转字符串,前端friendlyError路径零改动。参数表也不变。这是选择「单例锁」而非「注入
State<RemoteHandle>」的直接原因(§5.4)。verify-commands.ps1:31-41只提取函数名,所以即便将来加参数也不会打破门控,但本期不引入这个变量。SlimeVrManager与VrcftRuntime改成Arc<T>。它们现在是裸值.manage(...)(lib.rs:94-95),TauriHost需要共享所有权。改动点收敛在 6 处:lib.rs:38,94,95,167与commands.rs:157,175,292,294,301的app.state::<SlimeVrManager>()/app.state::<VrcftRuntime>(),把类型参数换成Arc<...>。lib.rs:93的Arc<EngineManager>已是 Arc,不动。不新增
mod到两个正则块里。mod remote;只加在lib.rs:3-11的模块列表末尾,写进use commands::{...}(lib.rs:15-24)或generate_handler都会被脚本按裸标识符提取,判成「注册了但不存在的命令」。
3.5 新增一个 op 的代价
| 步骤 | 必做 |
|---|---|
| 1 | core/registry/mod.rs 加一条 OpSpec(含 since、core、args) |
| 2 | 若语义是新的,RemoteHost trait 加一个方法,host_app/host.rs 填实现(转调既有管理器) |
| 3 | 若需要新资源组,加进 Resource 与 §5.5 的表 |
| 4 | 跑 .\tools\verify-commands.ps1(自动校验 op↔core↔since↔confirm↔group↔stream 前缀) |
| 5 | 重新生成 contracts/remote-v1-ops.json 快照 |
评审清单(这就是 code review 的全部内容):① effect 是不是最小权限(只读别写成 action);② 有没有副作用需要 confirm: true(协议 §9.4 五条之外,「不可逆 / 杀进程 / 删数据」一类都要加);③ since 填的是不是首次发布的协议版本(填错等于破坏兼容性,协议 §11.2);④ args 是否只用 type/properties/required/items/enum/minimum/maximum(oneOf/$ref/pattern 不在协议子集里);⑤ 有没有误暴露协议 §12.2 排除的命令;⑥ core/ 里有没有出现 tauri;⑦ 有没有顺手动了 stream.* 或 37100–37199。
3.6 扩展 tools/verify-commands.ps1
现有 5 项检查(:53-168)全部保留,新增第 6–8 项,全部累加到同一个 $problems(最后 :171 赋给 $script:CommandSurfaceProblems,check-release.ps1:35 读它)。
第 6 项:注册表自洽。 因为 Get-QuotedList(:47)会把任何 "小写标识符" 当命令名,注册表的排版只能是 §3.1 那一种,脚本按行切分而不是嵌套匹配大括号:
# 6. 远程操作注册表:op/since/effect/core/group/confirm 必须齐全且自洽。
# 注册表按 §3.1 的排版写(`OpSpec {` 独占一行、`}` 独占一行、args 单行),所以脚本按行切分:
$entries = @(); $current = $null
foreach ($line in [IO.File]::ReadAllLines($registryRs)) {
if ($line -match '^\s*OpSpec\s*\{\s*$') { $current = ''; continue }
if ($null -ne $current) {
if ($line -match '^\s*\},?\s*$') { $entries += $current; $current = $null; continue }
$current += $line + "`n" } }
# 逐条断言:op 唯一且形如 group.verb;since 存在;effect 存在;group 与 op 前缀一致;
# core 指向的 fn 在 host_app/host.rs 里存在;op 前缀不得为 stream.。
# 全局断言:协议 9.4 的 body.start / body.stop / capture.clear / update.install / client.revoke
# 必须 confirm:true;error.rs 的错误码必须落在协议 4.4 的 10 个之内;
# RESERVED_OPS 必须正好是协议 12.3 的 6 个 stream.*,且不得与 OPS 相交。第 7 项:core/ 依赖宿主即失败(协议 §15.2)。
# 7. core/ 不得依赖宿主(协议 15.2)。扫 src/remote/core/**/*.rs,命中任一即 problems++:
# use tauri / tauri:: / AppHandle / State< / Emitter / WebviewWindow / windows_sys /
# crate::engine_manager / crate::slimevr_manager / crate::vrcft_runtime / crate::updates /
# crate::upload / crate::named_pipe。
# 同时禁止 src/remote/** 出现裸 `use core::`(core 是内置 crate 名,必须写 crate::remote::core::…)。第 8 项:反向告警(不失败)——列出 host_app/host.rs 里从未被任何注册表条目引用的方法,防死代码堆积。只作 warning,否则内部辅助方法会造成假失败。
3.7 防漂移的第二道闸:contracts/remote-v1-ops.json
ipc-v1-command.schema.json 的教训是「规范与实现已经漂移,且没有可执行校验点」。所以:
该文件是生成物(不是手写规范),内容 =
GET /v1/capabilities的operations+events,头部写"generated_by": "core/registry/mod.rs — 不要手改"。Rust 单测
ops_snapshot_matches_registry比对注册表与快照,不一致即失败并打印 diff。tools/remote_api_probe.py运行时把真实响应与快照比对,不一致即探针失败。
这样「规范」与「实现」之间第一次有了可执行校验点,漂移会在提交前炸掉。
4. 模块与文件清单
全部位于 apps/frameface-ui/src-tauri/src/remote/(新建目录)。core/ 不 import tauri,host_app/ 不写业务判断(§2.4)。
4.1 remote/core/(纯逻辑,未来可整体搬进服务)
| 文件 | 职责(一句话) |
|---|---|
mod.rs | 层的公开 API:start(host, config) -> Handle / shutdown();编排 transport、events、discovery |
host.rs | RemoteHost trait + DTO(AppInfo/IfaceInfo/NetworkProfile/ClientView)——core 唯一的对外依赖面 |
transport/server.rs | TcpListener accept 循环、来源网段过滤、连接上限、shutdown 信号 |
transport/http.rs | HTTP/1.1 最小实现:httparse 解析请求头、64 KiB 体积上限、8 KiB 头部上限、响应写出 |
transport/router.rs | 路径 → 处理器:/v1/info、/v1/pair、/v1/rpc、/v1/capabilities、/v1/events、/v1/unpair;未知路径 404 + 标准错误信封 |
transport/sse.rs | SSE 帧编码(event:/data:/retry: 只发一次/: keepalive)与 chunked 分块 |
auth/mod.rs | Bearer 解析与鉴权;/v1/info、/v1/pair 的免鉴权白名单 |
auth/pairing.rs | 配对码生成 / TTL 120 s / 一次性 / 5 次尝试 / zeroize 擦除 |
auth/client_store.rs | ≤16 条客户端记录(sha256 + 元数据);重配对语义(§5.6);吊销 |
auth/tokens.rs | 令牌生成 / sha256 / subtle 常量时间比较 |
registry/mod.rs | 单一真相源:OPS / GROUP_META / EVENTS / RESERVED_OPS |
registry/capabilities.rs | 由注册表生成协议 §6.2 响应;不含任何 op 字面量 |
registry/validate.rs | args schema 是唯一参数边界来源,调用前校验 |
ops/hub.rs ops/tracking.rs ops/face.rs | P0/P1 的处理器,只调 RemoteHost |
events/mod.rs | 事件总线与订阅者管理(latest-only 有界队列) |
events/status.rs | 变化检测(排除 timestamp_ms/sequence)+ hz 节流 |
events/notice.rs | notice 服务端半边限流:文本 10 s 去重、整体 ≤5 条/10 s、sticky 规则 |
policy/locks.rs | ResourceLocks 单例 + ResourceGuard |
policy/ratelimit.rs | 每客户端令牌桶(20/s)+ 在途计数(≤4)+ retry_after_ms 计算 |
discovery/beacon.rs | 每块可用网卡各发一份 UDP 信标(固定 37022) |
config.rs | 配置模型、%APPDATA%\FrameFace\remote.json 原子读写、端口区间校验(拒绝 37100–37199) |
error.rs | CoreError + 封闭错误码 → HTTP 状态码 + retry_after_ms 钳制(≤3000) |
4.2 remote/host_app/(Tauri 宿主适配层)
| 文件 | 职责 |
|---|---|
mod.rs | pub fn start(app: &AppHandle) / shutdown();把 RemoteHost 实现交给 core,在 setup 里调用 |
host.rs | TauriHost: impl RemoteHost——转调 EngineManager / SlimeVrManager / VrcftRuntime / updates / 窗口;app_info() 提供 version/name/instance_id |
interfaces_win.rs | GetAdaptersAddresses FFI 封装 → Vec<IfaceInfo>(地址、前缀长度、IfType、IfIndex、OperStatus) |
netprofile_win.rs | Get-NetConnectionProfile 检测(PowerShell,沿用 slimevr_manager.rs:456-462 的 CREATE_NO_WINDOW 做法),结果缓存 30 s |
paths.rs | %APPDATA%\FrameFace 路径解析(与 upload.rs:14-19 同一布局) |
glue.rs | 给 commands.rs 用的 lock(Resource) -> Result<ResourceGuard, CoreError> 等薄适配 |
mod.rs 里的 RemoteHandle | remote_status 命令读它(是否运行、实际端口、地址列表、已配对数量、last_error) |
4.3 其它仓库文件
| 文件 | 动作 |
|---|---|
src/commands.rs | 35 个签名不动,函数体加取锁 + 转发;新增 3 个命令(§5.6) |
src/lib.rs | mod remote;、Arc 化两个管理器、setup 启动、退出路径 remote::shutdown();:93-95 与 :96-132 两个块不被污染 |
src/engine_manager.rs | 6 处 app.emit(STATUS_EVENT, …)(:312,321,408,487,497,510)收敛成 publish_status(app, snapshot),同时喂 WebView 与远程总线 |
src/contracts.rs | 给 TrackingStatus 及嵌套结构加 #[derive(PartialEq)](纯派生,不改字段) |
Cargo.toml / Cargo.lock | §5.1 的 7 个依赖(全部已在 lock 中,无需重新解析);windows-sys 增加 Win32_NetworkManagement_IpHelper、Win32_Networking_WinSock features(该 crate 唯一依赖是 windows-link,缓存与 lock 中已有,不引入新 crate) |
build.rs | 加 3 个新命令名;其它引号小写串会被脚本 :47 的正则误判成命令名 |
permissions/frameface-control.toml | 加同样 3 个命令名 |
src/main.ts | 3 个新 invoke("remote_*") + 设置面板;frameface://remote-activity 监听;2 s / 150 ms 轮询不动 |
tools/verify-commands.ps1 | 第 6–8 项检查(§3.6) |
tools/remote_api_probe.py | 新建集成探针(§8.2) |
contracts/remote-v1-ops.json、contracts/README.md | 新增生成式快照 + 说明它是生成物 |
docs/SECURITY_BOUNDARIES.md | 新增「LAN Remote API:需鉴权的半可信入口」与 21112 改动记录 |
docs/(新文件) | 端口预算表(含 37100–37199 预留),供未来媒体面参照;引用的路径必须真实存在(verify-docs.ps1 会查) |
AGENTS.md | 「易踩坑」补四条:① 新增 op 只改注册表;② build.rs 里只出现那 3 个引号小写串;③ 远程模块复用 install_id;④ core/ 不 import tauri |
third_party/slimevr-server/.../NiskleHubStatusBridge.kt | P3:ServerSocket(STATUS_PORT)(:185)改为只绑回环(§7 第 8 条) |
5. 关键实现要点
5.1 HTTP 服务装配与依赖(本方案最重要的选型修正)
决策:不用 hyper 的 server feature,用 tokio::net::TcpListener + httparse 手写 HTTP/1.1。
理由是硬的:解包 hyper-1.11.0.crate 核对它的 Cargo.toml:
server = ["dep:httpdate", "dep:pin-project-lite", "dep:smallvec"]
[dependencies.httpdate]
version = "1.0"; optional = true而 httpdate 在 .cargo-home 中出现 0 次、在 Cargo.lock 中出现 0 次(它只被 hyper 的 server feature 需要,当前工程只通过 reqwest 用 hyper 的 client 侧)。结论:hyper 的 server feature 无法离线构建;axum 依附它,同样不可用。
手写在这份协议下不是妥协,因为协议把所有难点都排除掉了:
| 协议要求 | 后果 |
|---|---|
每个响应都带 Content-Length 或 Connection: close(§3) | 不需要 keep-alive 复用与分块请求体 |
| 请求体上限 64 KiB(§3) | 读固定上限即可,只有 Content-Length 一种情况 |
| 未知路径返回 404 + 标准错误信封(§3) | 不需要路由框架 |
| 未鉴权端点只有 2 个(§3 总览) | 不需要中间件栈 |
| SSE 是唯一流式响应 | 只有 /v1/events 需要 chunked |
依赖清单(P0 一次性加进 Cargo.toml,逐项核对过缓存与 lock):
| crate | 版本 | 用途 | 注意 |
|---|---|---|---|
tokio | 1.53.1 | TCP、runtime、time、sync::broadcast | features 仅 rt,rt-multi-thread,net,time,sync,io-util。启用 macros 会拉进缓存里没有的 tokio-macros,启用 signal 会拉进缓存里没有的 signal-hook-registry,两者都会直接构建失败——也就是没有 #[tokio::main] / tokio::select! 可用 |
httparse | 1.10.1 | 请求头解析 | 无依赖 |
sha2 | 0.10.9 | 令牌哈希 | 依赖 digest/cfg-if,均在缓存 |
subtle | 2.6.1 | 常量时间比较 | 无必需依赖 |
base64 | 0.22.1 | base64url | 无依赖 |
rand | 0.10.2 | 配对码/令牌随机源 | 0.10 的 feature 名为 sys_rng/thread_rng,0.8 的 rand::thread_rng() 在这里不存在。类型名不符时的退化方案是 getrandom::fill(&mut [u8])(0.3.4 在缓存与 lock 中),只需改 core/auth/tokens.rs 一处 |
zeroize | 1.9.0 | 配对码擦除 | 无依赖 |
关键机制:edition 2024 默认 resolver = "3",Cargo.lock 是 feature-minimal 的(只为当前启用的 feature 记录节点)。所以「把已在 lock 里的 crate 提为直接依赖」不需要重新解析、可离线完成;但任何新启用的 feature 只要拉到 lock 里没有的可选依赖,构建就会直接失败。上表每个 feature 都按这条规则核过。
几个代价明确的坑:① Content-Length 要先校验存在且 ≤65536 再分配缓冲,否则 Content-Length: 999999999 就能 OOM;② 头部缓冲上限 8 KiB,超过直接 400 关连接;③ 一律按字节处理,UTF-8 只在 JSON 解析处出现;④ 每个响应显式写 Content-Type: application/json; charset=utf-8 与 Cache-Control: no-store,SSE 例外;⑤ Connection: close 后要 shutdown() 写端再 drop,否则客户端可能读到 RST 而不是 EOF。
升级路径(记录备用):一旦有网络把 httpdate 加入缓存与索引,transport/http.rs 可整体换成 hyper_util::server::conn::auto::Builder + service_fn,业务代码(router/registry/ops)零改动——这正是把 HTTP 细节全关进一个文件的收益。
5.2 SSE 响应体
/v1/events 响应头:HTTP/1.1 200 OK、Content-Type: text/event-stream、Cache-Control: no-store、Connection: keep-alive、Transfer-Encoding: chunked,随后立即写 retry: 3000\n\n。
| 要点 | 做法 | 坑 |
|---|---|---|
retry: | 每条连接只在建立时发一次(协议 §8.2) | 流中途重发会让客户端按 host:port 持久化一个错误的窗口值 |
| 首帧 | 连接建立后立即推一次 status 全量快照 | 等第一次节流 tick 的话,客户端要等 500 ms 才有内容 |
| 心跳 | 每 15 s 写一行 : keepalive | 注释行同样走 chunked 分块,否则客户端一直缓冲 |
| 节流 | status 只推「有变化且距上次推送 ≥ 1/hz」的快照,latest-wins 合并 | 在 SSE writer 侧合并;在生产侧丢帧会丢掉窗口末的值 |
| 背压 | 每个订阅者一个容量 1 的有界队列(latest-only) | 慢客户端不能阻塞状态生产;丢的是中间帧,不是最新值 |
| 断开 | 写失败即注销订阅者并释放连接计数 | 不实现的话,重连会累积幽灵订阅者 |
hz | 缺省取 EVENTS.default_hz,钳到 1..=max_hz | 非法值不报错,钳制即可(未知一律忽略,协议 §11.2) |
| 重连 | 不重发历史通知(协议 §8.3) | 通知是瞬时事件,不是状态 |
5.3 状态变化检测与节流
TrackingStatus(contracts.rs:159-174)里 timestamp_ms 与 sequence 每个 tick 都变,逐字段比较整结构 = 永远「变了」。
contracts.rs给TrackingStatus、TrackingCapabilities、PerformanceStats、FrameCastMetrics、CaptureStatus加#[derive(PartialEq)](纯派生,不改字段,不影响commands.rs返回类型)。events/status.rs提供显式比较semantically_equal(a, b):逐字段列出engine/framecast/vrcft/face_tracking/eye_tracking/tracking_enabled/capabilities/performance/framecast_metrics/capture/last_error,故意排除timestamp_ms与sequence。逐字段写全而不是「整体比较再排除」,是为了让以后新增字段时被迫做决定:漏加 = 该字段变化不上报(联调能发现),比整体比较的沉默失效更安全。节流才是真正的限速器。
performance.fps/inference_ms/cpu_usage_percent/tracking_quality每帧都在变,变化检测只能消掉timestamp_ms/sequence造成的假变化,不会把推送降到接近零;真正的上限由hz保证。推给桌面 WebView 的
frameface://status不节流(保持现有行为),节流只作用于 SSE——远程不改变桌面 UI 的实时性。
5.4 按资源串行化(约束 4 的正面回应)
现状:互斥只写在前端(slimevrBusy 在 main.ts:740,2041-2078,pairingInProgress 在 main.ts:1256-1291),Rust 侧完全没有。头显与桌面同时点「启动体感追踪」会双份 kill_orphan_servers() 互相杀对方刚拉起的进程。
// core/policy/locks.rs
pub struct ResourceLocks { held: Mutex<HashMap<Resource, LockState>> }
pub struct ResourceGuard { resource: Resource } // Drop 即释放
pub fn acquire(resource: Resource) -> Result<ResourceGuard, CoreError>; // 不排队,立即 busy
static LOCKS: OnceLock<ResourceLocks> = OnceLock::new(); // 单进程单例(刻意的)| 资源组 | 覆盖 op | 覆盖桌面命令 |
|---|---|---|
engine | tracking.*、face.*、capture.* | start_tracking/stop_tracking/enable_*/set_expression_*/set_capture_enabled/clear_capture_data |
vrcft | vrcft.open、vrcft.stop | open_vrcft/stop_vrcft |
body | body.* | start_slimevr/stop_slimevr/reset_slimevr/pair_slimevr_device/unpair_slimevr_device/assign_slimevr_tracker/set_slimevr_osc/set_slimevr_proportions/autobone_slimevr |
update | update.* | download_and_install_update_channel |
| 无锁 | hub.*(read)、framecast.status、app.*、client.* | get_*、hide_to_tray、minimize_window、close_window |
四条行为写得再清楚也不过:
不排队,立即
busy(协议 §7.2)。这是刻意的:Engine 队列溢出丢最旧(engine_manager.rs:419-422),排队等于让远程请求悄悄挤掉桌面用户自己的操作。桌面 UI 也走同一把锁。这才是「下沉到 Rust」的全部意义——不是给远程单开一把锁,而是让
commands.rs也经过它。改造后前端的slimevrBusy降级为纯 UI 反馈(防连点、显示 loading),不再是正确性边界。前端 busy 标志不删除,改为可被远程置位。新增 Tauri 事件
frameface://remote-activity,payload{"resources":["body"]};main.ts加一个监听,远程持锁时把slimevrBusy = true、释放时置回。这样「头显正在启动体感追踪」实时反映到桌面开关的 loading 态。删掉标志会带来双击重复提交,所以这里是保留并同步,不是退役。单例锁优于注入
State。用static LOCKS让commands.rs的参数表一字不改(§3.4);进程内只有一份锁集合,语义上就是单例。锁的泄漏面要封住。
ResourceGuard::drop释放;LockState另记started: Instant,超过 300 s 视为陈旧并强制释放 + 记 warning——防的是abort/进程外异常导致的永久死锁。
5.5 每客户端限流与 retry_after_ms 契约
| 限制 | 值 | 实现 |
|---|---|---|
| 在途请求 | 4 | 每客户端一个计数,响应写完即减 |
| 请求速率 | 20 次/秒 | 令牌桶,容量 20,按 Instant 补充 |
| 超限响应 | busy + retry_after_ms | 不静默丢弃(协议 §7.1) |
/v1/info | 单独 10 次/秒 | 它是未鉴权入口,防洪水探测 |
/v1/pair | 由配对码自身 5 次上限保护(协议 §5.3) | — |
retry_after_ms 遵守协议 §4.3 的契约:缺省 500 ms、上限 3000 ms。error.rs 在构造 CoreError 时统一钳制:min(3000, max(0, value.unwrap_or(500)))。算法:令牌桶等待 ceil((1 - tokens) / rate * 1000),向上取整到 50 ms 倍数,最小值 100;锁冲突按资源组给经验值(engine 200、vrcft 500、body 3000、update 3000)。
因为 Link 会「自动重试一次」(协议 §4.3),锁的持有时间要与 retry_after_ms 相称:body.* 里 SlimeVrManager::start 会先 server_ports_busy() 探测、必要时 kill_orphan_servers()(内含 800 ms sleep,slimevr_manager.rs:231-237)再 spawn,整体可能接近 3 s。见 §9 风险 6。
5.6 配对、重配对与自我注销
配对码(协议 §5.3):6 位数字(format!("{code:06}"),字符串截断会漏前导零);TTL 用 Instant 120 s(不用 SystemTime——用户改系统时钟不该延长有效期);成功即 take() 整段 session;失败计数到 5 立即作废整段 session(不是只锁一会儿);只在内存(Zeroizing<String>,drop 清零);日志与信标里都不出现配对码(core/auth/pairing.rs 里不出现日志宏,错误消息只写「配对码错误(剩余 N 次)」);session 上不加 #[derive(Debug)],防 {:?} 意外打日志。展示路径:PC 端专用命令返回 {code, expires_at_ms},不经由任何 HTTP 响应回传配对码。
client.id 重配对语义(协议 §5.2):client.id 由 Link 生成且永不变更。Hub 收到已存在的 client.id 时:
| 行为 | 规定 |
|---|---|
| 令牌 | 吊销旧令牌、签发新令牌(旧令牌立即失效,含在途请求与 SSE 连接) |
| 条目 | 不新增,复用原有条目 |
| 显示名 | 保留 Hub 端已存的显示名,忽略请求里的 name |
| 条目数 | 重配对不占用新的配额,即使已达 16 个也照样允许 |
保留显示名的原因:
client.rename(协议 §12.1)是 PC 端的用户操作,若重配对采用请求里的name,用户改的名字会被头显的默认名覆盖。⚠️ 见文末协议待修订第 3 条。
POST /v1/unpair(协议 §3.2):鉴权后删除自己那条记录并立即吊销令牌;实现方式是给每条记录一个 epoch,删除即 epoch += 1,在途请求与 SSE 连接在下一个检查点失效(只从表里删掉的话,已建立的 SSE 会继续收数据)。
5.7 令牌存储
| 项 | 做法 | 坑 | |
|---|---|---|---|
| 生成 | 32 字节随机 → base64::engine::general_purpose::URL_SAFE_NO_PAD | 用 URL-safe 变体(令牌会出现在 Header 里) | |
| 落盘 | 只写 sha256(token) 的十六进制(format!("{b:02x}") 手写,不引入 hex crate) | 出现 32 字节原文 = 严重缺陷 | |
| 比较 | 遍历全部 ≤16 条,用 Choice 折叠:`ok | = rec.hash.ct_eq(&digest),最后 bool::from(ok)` | HashMap::get(hash) 的提前返回不可用——哈希查找时序会泄漏信息;16 条全扫成本可忽略 |
| 上限 | 16 条;第 17 个 POST /v1/pair 返回错误而不是覆盖 | 错误码见文末 ⚠️ 第 4 条,本方案暂用 forbidden | |
| 吊销 | /v1/unpair 删自己;PC 端 client.revoke 删任意一条 | 都要走 epoch 失效机制(§5.6) |
5.8 配置持久化
| 项 | 值 | 理由 |
|---|---|---|
| 目录 | %APPDATA%\FrameFace\ | 与 upload.rs:14-19 的 install_id 同目录;不用 app_config_dir()(那是 %APPDATA%\tech.nisklehub.desktop,会凭空多出第二个配置位置) |
| 文件 | remote.json(配置)、remote-clients.json(客户端表) | 分开,让配置写入不碰凭据文件 |
| 字段 | enabled、port、bind_ip、beacon_enabled、allow_public_profile、schema_version | 没有 beacon_port——协议 §10.1 规定 37022 永不可配置。不含任何密钥 |
| 校验 | port 落在 37100..=37199 即拒绝(协议 §15.5 媒体面预留),并给出明确错误 | 防止随手占用未来媒体面端口 |
| 写入 | 写 remote.json.tmp → std::fs::rename 覆盖 | AGENTS.md:88「文件写入优先原子替换」;Rust 的 fs::rename 在 Windows 上用 MOVEFILE_REPLACE_EXISTING,可覆盖已存在目标 |
| 读取 | 解析失败 → 用默认值 + 备份坏文件为 remote.json.bad + 记日志 | 配置损坏不能让 Hub 起不来 |
| 并发 | 所有写入串行化在一把 Mutex<Config> 后 | install_id()(upload.rs:37)用的是非原子 fs::write 写 config.json;远程侧只读该文件,避免互相踩 |
5.9 /v1/info 与 addresses(协议 §3.1、§10.4)
/v1/info 的字段集合是封闭的,只返回协议 §3.1 那 10 个:
| 字段 | 来源 |
|---|---|
v | 常量 1(最高支持的主版本) |
app / product | 常量 "NiskleHub" / "Niskle Hub" |
version | app_info().version(host 侧 app.package_info().version) |
instance_id | app_info().instance_id ← upload::install_id() |
name | COMPUTERNAME |
paired | Hub 级:!client_store.is_empty()(不是「本客户端是否配对」) |
port | 实际绑定的 TCP 端口(从 listener 的 local_addr() 回读)。客户端以它为准,21120 只是缺省值 |
addresses | 见下 |
uptime_s | OnceLock<Instant> 记进程启动时刻(语义是「PC 端刚重启过」) |
addresses 的枚举与排序(host 侧 GetAdaptersAddresses,windows-sys features Win32_NetworkManagement_IpHelper/Win32_Networking_WinSock/Win32_Foundation;该 crate 唯一依赖 windows-link 已在缓存与 lock 中,不引入新 crate):
| 步骤 | 规则 |
|---|---|
| 过滤 | 跳过 IfOperStatus != Up;跳过 IF_TYPE_SOFTWARE_LOOPBACK(24);只取单播 IPv4(带 OnLinkPrefixLength);排除 127.0.0.0/8 |
| 排序 | ① 物理网卡(IfType ∈ {6 Ethernet, 71 IEEE80211})且 RFC1918;② 其它 RFC1918(VPN/虚拟交换机);③ APIPA 169.254/16 最后。同档按 IfIndex 升序 |
| 缓存 | 结果缓存 30 s,/v1/info 命中缓存;刷新不 spawn 进程,成本可忽略 |
| 一致性 | /v1/info 的 addresses 与 accept 过滤共用同一份 IfaceInfo 快照,保证「我宣告的地址」与「我接受的来源」同源、永不漂移 |
不返回:操作系统版本、用户名、已配对设备名与数量、网络接口详情、路径、密钥(协议 §3.1 底部)。单测 info_field_set_is_closed 用严格键集合断言守住这条边界。
5.10 监听范围与防火墙
| 决策 | 做法 | 理由 |
|---|---|---|
| 绑定 | 默认 bind_ip = "0.0.0.0",accept 时按来源网段放行:peer 落在「任一 Up 且非回环网卡的本地子网」内才通过(由 5.9 的 address + OnLinkPrefixLength 计算) | 直接绑某个网卡地址会在笔记本切换 Wi-Fi/有线时失效;按本地子网过滤比「任意 RFC1918」更精确,也自然接受 Hyper-V/WSL/VPN 这类本地虚拟网段。公网来源一律不放行 |
| 公网接口 | remote.json 可显式写 bind_ip = "192.168.1.10" 收紧 | 给特殊需求留出口,默认不动 |
| 网络配置文件 | 启动时调 Get-NetConnectionProfile(PowerShell,沿用 slimevr_manager.rs:456-462 的既有做法 + CREATE_NO_WINDOW);任一活动连接为 Public 且 allow_public_profile = false → 拒绝启动并在设置页显示原因与「仍然启用」按钮 | 协议 §9.1 |
| 检测失败 | 视为 unknown:记警告、设置页显式提示、允许启动 | 检测失败不该把功能锁死(VPN/域/无网络时该命令行为差异大);令牌鉴权才是真正的边界 |
| 防火墙 | 不自动改。设置页显示可复制的管理员命令:netsh advfirewall firewall add rule name="Niskle Hub 互联" dir=in action=allow protocol=TCP localport=21120 profile=private,domain | 安装模式是 currentUser(tauri.conf.json:60),没有管理员权限;静默改防火墙是越权 |
| 端口占用 | 配置端口被占即明确失败,不自动改端口 | 客户端会记住上次端点(协议 §10.2),静默漂移会造成难排查的连接失败 |
5.11 UDP 信标(协议 §10.1)
| 项 | 做法 | 坑 |
|---|---|---|
| 频率与端口 | 每 2 s,UDP 37022 固定(beacon_port 不存在于配置里) | 端口固定否则客户端不知道去哪听;可配置的只有 TCP 端口,由信标 port 字段告知 |
| 每网卡一份 | 对 5.9 枚举出的每块可用网卡各建一个 socket(bind((iface_addr, 0)) + set_broadcast(true)),ip 字段填该网卡地址 | 单 socket 发 255.255.255.255 在 Windows 多网卡上只走默认路由,头显可能收不到 |
| 目标地址 | 同时发 255.255.255.255:37022 与该网卡的子网定向广播(如 192.168.1.255,由地址 + 前缀算出) | 多网卡 Windows 上子网定向广播比受限广播可靠 |
| 跳过 | APIPA 网卡不发(无意义,且会在头显列表里出现不可达项) | — |
| 载荷 | 协议 §10.1 的 9 个字段;instance_id 与 /v1/info、能力清单同源 | — |
| 大小 | 序列化后断言 ≤1024 字节,超限则不发送并记 error | 单测同样断言 |
| 安全 | 载荷不出现 token、配对码、client_id 列表 | 单测:断言序列化结果不含任何密钥字段名 |
5.12 notice 服务端半边(协议 §8.3)
协议把 notice 的展示规则分给了两端。Hub 只负责服务端那两行,客户端规则(3 s、sticky 30 s、最多 1 条 sticky、队列 ≤4)属 Link:
| 服务端规则 | 实现(core/events/notice.rs) |
|---|---|
同一 text 10 秒内只推一次 | 一个 Mutex<(String, Instant)> 记录上次文本与时间;同文本且 <10 s 直接吞掉 |
| 整体 ≤5 条/10 秒 | 环形窗口计数,超出即丢弃并记 debug 日志(不报错、不回压 op) |
| 去重与限流是全局的 | 在生产端做一次,再扇出给所有订阅者(不是每连接各做一次) |
| 重连不补发 | 总线只保留最新 status;notice 不进任何历史缓冲 |
notice 的来源(本期):status.last_error 的变化、SlimeVr 启停结果、更新进度、能力清单变化。前端 toast(main.ts:345,351-364 已有 TOAST_DURATION_MS = 3000 与文本去重计数)无法被 Rust 看到——见文末 ⚠️ 第 7 条。
5.13 错误映射
core/error.rs 是唯一做映射的地方,向上给出 CoreError { code, message, field, retry_after_ms }:
| 底层来源 | 特征 | 映射 |
|---|---|---|
EngineManager 关闭后 send | "NiskleHub 正在关闭"(engine_manager.rs:93) | unavailable |
sender.send 失败 | "引擎通信线程不可用"(:97,114,132,158,165) | unavailable |
| 参数越界 | "灵敏度参数无效"/"采集间隔无效"/"采集帧数上限无效"/"采集累计帧数上限无效"/"滤波强度无效"(:107,123,143,146,149) | invalid_args + field |
| SlimeVR 桥不可达 | "无法连接 SlimeVR 服务"(commands.rs:205,214,223,232,248,260,273,282) | unavailable |
| 服务未运行 | is_running() 为 false | unavailable |
tracking.set_eye_enabled 且 capabilities.eye_tracking == false | 协议 §12.1 明确要求 | unavailable + 「当前硬件版本尚未支持眼动追踪」 |
| 资源锁 | BusyInfo | busy + 钳制后的 retry_after_ms |
| 限流 | 令牌桶 / 在途计数 | busy + 钳制后的 retry_after_ms |
| 未匹配 | 其它 | internal(message 保留原始中文串) |
这里有一处已知的技术债:底层返回 String(commands.rs 全部是 Result<_, String>),所以 P0–P1 的映射只能基于稳定前缀匹配。不为「优雅」把管理器返回类型改成 thiserror 枚举——那会改掉 commands.rs 签名与前端错误处理,超出授权。正确顺序是:P1 先把上表的字符串常量集中到 error.rs 一处(便于将来整体替换成类型化错误),P5+ 再单独评审类型化改造。
HTTP 状态码严格按协议 §4.4,不自创;错误码集合由脚本(§3.6 第 6 项)锁死为封闭集。
6. 与既有代码的接线点(完整清单)
| 文件 | 是否改动 | 原因 |
|---|---|---|
src/commands.rs | 改函数体 + 加 3 个新命令 | 既有 35 个命令名/返回类型/参数名一字不动;函数体加取锁与转发 |
src/lib.rs | 改 | mod remote;、Arc 化、setup 启动、退出 shutdown();:93-95 与 :96-132 两个块不被污染 |
src/engine_manager.rs | 改 | 6 处 emit 收敛为 publish_status;MAX_QUEUED_COMMANDS(:23)与丢弃策略不动(协议要求靠限流保护它,不是改它) |
src/contracts.rs | 改 | 加 PartialEq 派生;不改字段名/类型(Engine 契约,contracts/README.md 明确不兼容变更需新版本号) |
src/slimevr_manager.rs | 不改 | 保持签名;taskkill 逻辑(:231-236)原样保留(见 §9 风险 1) |
src/vrcft_runtime.rs、src/updates.rs | 不改 | 只被 host_app/host.rs 调用 |
src/upload.rs | 不改 | install_id()(:21)已是 pub,由 host 侧调用(core 不能直接调,它 use tauri) |
Cargo.toml | 改 | 7 个新依赖 + windows-sys 两个新 feature |
build.rs | 改 | 加 3 个新命令名;其它引号小写串会被脚本 :47 的正则误判 |
permissions/frameface-control.toml | 改 | 加同样 3 个命令名 |
apps/frameface-ui/src/main.ts | 改 | 设置面板 + 3 个新 invoke + remote-activity 监听;轮询不动 |
tools/verify-commands.ps1 | 改 | 第 6–8 项检查 |
tools/remote_api_probe.py | 新增 | §8.2 |
contracts/remote-v1-ops.json、contracts/README.md | 新增/改 | 生成式快照 |
docs/SECURITY_BOUNDARIES.md | 改 | 新增 LAN 入口信任级别说明 |
docs/(新文件) | 新增 | 端口预算表(含 37100–37199 预留) |
AGENTS.md | 改 | 四条新踩坑(§4.3) |
third_party/.../NiskleHubStatusBridge.kt | P3 改 | §7 第 8 条 |
verify-docs.ps1 会校验 docs/** 里反引号包裹的路径真实存在,所以新文档引用的路径要先落地。
7. 安全清单(默认启用前的逐项检查)
| # | 检查项 | 通过标准 |
|---|---|---|
| 1 | 无未鉴权可变操作 | /v1/info、/v1/pair 之外的端点无 token 一律 unauthorized(含 /v1/events、/v1/unpair) |
| 2 | /v1/info 字段封闭 | 响应键集合恰好等于协议 §3.1 的 10 个;无 OS 版本、用户名、设备名或数量、接口详情、路径 |
| 3 | 令牌零明文 | remote-clients.json、remote.json、%LOCALAPPDATA%\FrameFace\*.log、SSE 帧中均无 token 原文(探针 --grep-secret 全量扫) |
| 4 | 配对码零泄漏 | 不在日志、不在 UDP 信标、不在任何 HTTP 响应(除 PC 端本地命令) |
| 5 | 常量时间比较 | token 与配对码比较均走 subtle,无 == 早退 |
| 6 | 吊销即时生效 | /v1/unpair 与 client.revoke 后,旧 token 立即 unauthorized;已建立的 SSE 连接停止收数据 |
| 7 | 限流与 retry_after_ms 契约 | 21 次/秒的第 21 次返回 busy;所有 retry_after_ms ∈ [100, 3000];缺省 500 |
| 8 | 队列保护 | 压测 30 s 后桌面 UI 手动操作仍成功(证明远程没把 32 深队列刷爆) |
| 9 | 网络准入 | 来自公网 IP 的连接在 accept 阶段即被丢弃;addresses 与准入网段同源 |
| 10 | 公用网络拒绝 | 处于「公用网络」时服务不启动,设置页给出原因与覆盖按钮 |
| 11 | 端口预算 | remote.json 里的 port 落在 37100–37199 时被拒绝;无 beacon_port 字段;信标恒为 37022 |
| 12 | 顺带修既有风险:21112 只绑回环 | NiskleHubStatusBridge.kt:185 改为 ServerSocket(STATUS_PORT, 1, InetAddress.getLoopbackAddress())。Rust 侧本来就只连 127.0.0.1(slimevr_manager.rs:245-249,286),功能不受影响;消掉一个未鉴权的局域网命令通道(协议 §1.3 约束 7、§9.5)。需重编 slimevr.jar(Java/Gradle,见 docs/THIRD_PARTY_SLIMEVR.md),独立提交,不与 Rust 改动同一 PR |
| 13 | 危险 op 的 confirm | §9.4 五条在清单里均 confirm: true,title 写明后果 |
| 14 | 不暴露 Irreversible / 预留项 | 注册表无 app.quit;update.install P0–P3 不暴露;无 stream.*;误发 stream.* 返回 unknown_op 且不断连 |
| 15 | 体积与头部上限 | Content-Length > 65536 → 400 且不分配缓冲;头部 > 8 KiB → 400 |
| 16 | 未知路径 | 404 + 标准错误信封,不返回 HTML |
| 17 | notice 不骚扰 | 同文本 10 s 去重;整体 ≤5 条/10 s;重连不补发 |
| 18 | 可关闭 | enabled=false 后端口立刻释放、信标停止 |
| 19 | 分层未被破坏 | verify-commands.ps1 第 7 项(core/ 无 tauri)通过 |
| 20 | 文档同步 | docs/SECURITY_BOUNDARIES.md 已描述新边界;AGENTS.md 已记录新不变式 |
8. 测试与验证
8.1 单元测试(cargo test --offline,core/ 内 #[cfg(test)],无需 Tauri 即可跑)
| 测试 | 断言 |
|---|---|
envelope_ignores_unknown_fields | 请求带未知字段仍解析成功(协议 §4.1) |
envelope_rejects_bad_requests | 缺 v/id/op → bad_request;id=0 → bad_request;v=2 → unsupported_version |
unknown_op_is_not_fatal | 未知 op(含 stream.start)→ unknown_op(404),连接仍可用 |
info_field_set_is_closed | /v1/info 键集合 == 协议 §3.1 的 10 个 |
info_port_is_authoritative | port 等于实际绑定端口(用 0 端口启动时也正确) |
addresses_ordering | 物理网卡 RFC1918 在 VPN 之前、APIPA 最后;排除回环 |
capabilities_are_registry_derived | op 集合相等;每个 op 有 since;args 只含允许关键字 |
manifest_has_no_streaming_object | 清单无 streaming 键,且无任何 stream.* |
reserved_ops_do_not_leak | OPS ∩ RESERVED_OPS == ∅;RESERVED_OPS == 协议 §12.3 的 6 个 |
groups_derived_from_ops | 空分组不出现在 groups[] |
ops_snapshot_matches_registry | 与 contracts/remote-v1-ops.json 一致,不一致打印 diff |
host_mapping_targets_exist | 每个 OpSpec.core 在 RemoteHost 实现里存在 |
mandatory_confirm_flags | §9.4 五条 confirm: true |
status_change_detection | 仅 timestamp_ms/sequence 不同 → 不变化;仅 performance.fps 不同 → 变化 |
notice_dedup_and_rate | 同文本 10 s 内只 1 条;10 s 内第 6 条被吞 |
retry_after_clamped | 任何 CoreError 的 retry_after_ms ≤ 3000;缺省 500 |
resource_lock_serialises | 两个并发同组 op → 一个成功、一个 busy |
rate_limit_returns_busy | 21 次/秒 → 第 21 次 busy(注入假时钟,不真等) |
pairing_code_lifecycle | TTL 过期作废;成功即作废;5 次失败作废;比较走常量时间 |
repair_reuses_entry | 相同 client.id 二次配对:条目数不变、旧 token 失效、显示名保留、16 满时仍允许 |
unpair_invalidates | /v1/unpair 后旧 token unauthorized,epoch 递增 |
token_never_stored_plaintext | 序列化后的客户端表不含 token 原文 |
beacon_payload_bounded | ≤1024 字节,且不含 token/配对码 |
config_rejects_media_ports | port = 37150 被拒;beacon_port 不是合法字段 |
error_codes_closed_set | 码集合 == 协议 §4.4 的 10 个 |
8.2 集成探针 tools/remote_api_probe.py
沿用 tools/*_probe.py 的既有 house style(参照 slimevr_bridge_probe.py、framecast_status_probe.py):仅标准库(argparse/json/socket/sys/time/urllib.request/threading)、无第三方依赖、main() -> int、sys.exit(main())、顶部中文 docstring 写清「用途 / 用法」、参数用 argparse。
行为(按顺序):配对 → 拉能力清单 → 逐个调用 read 操作 → 短暂订阅 SSE → 输出 pass/fail 表。
python tools/remote_api_probe.py --pair --code 042317 --host 192.168.1.10
python tools/remote_api_probe.py --token-file %TEMP%\niskle-token.txt --reads --sse 5
python tools/remote_api_probe.py --beacon 10 # 监听 37022 十秒
python tools/remote_api_probe.py --json --out report.json安全约束(写进 docstring):
默认只调用
effect == "read"的操作;--allow-write才调 write;action类一律要求--allow-action且用--op逐个显式指定;body.stop/body.start不会自动调用(会taskkill /F /IM java.exe /T,slimevr_manager.rs:231-236),要调它们得同时给--allow-action --op body.stop --yes-kill-java;令牌缓存文件权限收紧(Windows 用
icacls),打印时只显示前 6 位。
附加检查(对齐协议新增内容):--reads 会顺带断言 /v1/info 的键集合封闭、port 与实际连接端口一致、addresses 非空且都是私有地址;--sse 会统计首帧延迟、平均间隔(验证 hz 节流)、retry: 只出现一次、keepalive 计数。
表格列:op | effect | HTTP | code | ms | verdict,外加汇总(ops 总数、read 通过数、能力快照 sha256)。失败项打印原始响应体(截断 2 KiB)。
8.3 要留存的证据
| # | 证据 | 形式 |
|---|---|---|
| 1 | .\tools\verify-commands.ps1 与 .\tools\check-release.ps1 输出(RESULT 全绿,含新增的第 6–8 项) | 文本 |
| 2 | P0 三条 curl 的完整命令 + 完整响应(含 id 原样回带、t 为毫秒时间戳) | 文本 |
| 3 | 探针 pass/fail 表(--json 报告) | JSON |
| 4 | GET /v1/capabilities 与 contracts/remote-v1-ops.json 的 diff | 空 diff |
| 5 | SSE 原始前 30 行:retry: 3000 只出现一次、连接后立刻的 status 全量帧、约 15 s 后的 : keepalive | 文本 |
| 6 | 串行化证据:两条并发 tracking.start → 一条 ok:true、一条 busy + retry_after_ms | 文本 |
| 7 | 限流证据:21 次/秒 → 第 21 次 busy | 文本 |
| 8 | 信标证据:--beacon 10 收到 ≥4 个信标;多网卡机器上看到多个不同 ip 的信标 | JSON |
| 9 | 反证:无 token 调 /v1/rpc+/v1/events → unauthorized;错配对码 5 次后第 6 次即使输对也失败;stream.start → unknown_op 且连接不断 | 文本 |
| 10 | 二次配对证据:同 client.id 再配对后,client.list(或 remote_status)条目数不变、旧 token 失效 | 文本 |
| 11 | 分层证据:grep -rn "tauri" src/remote/core/ 无输出 | 文本 |
| 12 | 压测后桌面 UI 手动操作仍成功的录屏或截图 | 图片 |
9. 风险与未决问题
| # | 风险 | 影响 | 缓解 / 结论 | |
|---|---|---|---|---|
| 1 | body.stop = 杀掉本机所有 Java 进程 | 远程一条命令会终止 SlimeVR 以及其它 Java 程序(Minecraft、IDE、其它服务) | 协议 §9.4 已要求 confirm: true 且 title 写明后果。不在 P0–P3 暴露 body.stop,放到 P6 与 Link 的确认弹层一起做。长期修法(另立任务):把 kill_orphan_servers(slimevr_manager.rs:226-238)从「按镜像名杀」改成「按我们记录的 pid 树 + 端口占用者 pid 杀」 | |
| 2 | .cargo-home 离线约束 | 本期 7 个依赖全在 lock 与缓存里,可离线构建。但 edition 2024 的 resolver = "3" 让 lock 是 feature-minimal 的——任何新启用的 feature 一旦拉到 lock 里没有的可选依赖就直接失败(hyper 的 server 缺 httpdate 就是活例子) | 新增依赖前先跑 $env:CARGO_HOME='D:\Work\FrameFace\.cargo-home'; cargo build --offline。将来若需要 axum/TLS/qrcode,要在有网环境 cargo fetch 补齐缓存与索引,这会打破「构建全离线」的约定,属需单独批准的变更 | |
| 3 | 防火墙与网络配置文件在真机上的差异 | 首次绑定会弹 Windows 防火墙对话框;点「取消」后表现为「服务在跑但头显连不上」。VPN/域/无网络时 Get-NetConnectionProfile 返回值不一 | 设置页显式显示「防火墙可能拦截」与可复制的 netsh 命令;探针失败时先在本机 curl.exe http://127.0.0.1:21120/v1/info,以区分「服务没起」与「防火墙拦了」 | |
| 4 | 多网卡与虚拟网卡 | addresses 里混入 VPN/Hyper-V/WSL 地址会让头显先试一个不可达的地址(协议 §10.4 就是为此而写) | §5.9 的排序规则把物理网卡排前、APIPA 排最后;探针输出多网卡机器上的实际 addresses 供人工核对 | |
| 5 | update.install 无完成信号 | download_and_install(updates.rs:136-138)交给 NSIS 后基本不返回,调用方无法区分成功与卡死 | P0–P3 不暴露该 op。见文末 ⚠️ 第 6 条 | |
| 6 | retry_after_ms 上限 3000 与长耗时 op 冲突 | 协议 §4.3 让客户端最多自动重试一次;body.start 在必要时会先 kill_orphan_servers(800 ms sleep,slimevr_manager.rs:231-237)再 spawn,接近或超过 3 s。用户会看到「忙碌」而非成功 | P1 先用探针实测 body.start 冷启动耗时并记录;若稳定 >3 s,向协议提报「action 类操作允许更长 retry_after_ms 或改为 accepted 语义」(已列入 ⚠️) | |
| 7 | 明文 HTTP 上的令牌可被同网嗅探 | 拿到 token 即可操作 PC | 协议 §9 已接受此风险(仅局域网);缓解:client.revoke、/v1/unpair、以及后续版本的 rustls(rustls/ring/tokio-rustls 都在缓存里可离线启用,但 ring 需 cc 编译,会明显增加构建时间) | |
| 8 | 远程改动与桌面 UI 的短暂不一致 | 协议 §7.3:桌面靠 2 秒轮询,可能有最多 2 秒错觉 | §5.4 用 frameface://remote-activity 补 busy 态实时同步;状态本身仍走既有 frameface://status 推送(engine_manager.rs:16,312) | |
| 9 | notice 主题缺少前端来源 | Hub 现有 toast 都在前端 notify(...)(main.ts:351-364),Rust 看不到,无法自动转发 | P2 只从 Rust 可观测事件生成 notice。见文末 ⚠️ 第 7 条 | |
| 10 | install_id() 的写入不是原子替换 | upload.rs:37 用 fs::write;若远程也写 config.json 可能写坏 | 远程侧只读不写该文件,自己的配置写独立的 remote.json(原子替换) | |
| 11 | GetAdaptersAddresses 的 FFI 复杂度 | 双缓冲重试、IfOperStatus/IfType 过滤、前缀长度解析都是易错代码 | 全部关在 host_app/interfaces_win.rs;单测用固定 DTO 覆盖排序规则,FFI 部分只在真机验证并留证据;万一不稳定,退化到 `Get-NetIPAddress | ConvertTo-Json 的 PowerShell 方案(house style 已有先例),core` 侧零改动 |
| 12 | 资源锁因进程外异常泄漏 | 组内操作永久 busy | ResourceGuard::drop + 300 s 陈旧锁强释(§5.4 第 5 条) | |
| 13 | 首次编译 tokio/mio 的构建时间 | 首次增量构建变慢(分钟级) | 一次性成本;Cargo.lock 不变(这些 crate 本就在 lock 里) | |
| 14 | 未来搬进服务时的隐含假设 | core/ 若偷偷依赖了「Tauri 一定存在」的时序(例如依赖 WebView 已就绪),搬家时会暴露 | §2.4 的脚本与评审清单现在就开始执行;core 与 Tauri 的唯一接触面是 RemoteHost trait,任何新增接触点都走它 |
10. 分阶段实施
对齐协议 §13。P0–P3 全部在 Hub 侧,不依赖 Link,每阶段结束都能用 curl / 探针留证据。分层(§2.1)在 P0 一次性建好,之后每阶段只往 core/ 加东西——这样 P4–P7 与未来的服务化都不会返工。
P0 — 配对 + 令牌 + RPC 骨架 + hub.status + 分层落地
| 项 | 内容 |
|---|---|
| 交付 | core/ 与 host_app/ 两层骨架(含 RemoteHost trait 与 §2.4 的脚本检查);/v1/info(§3.1 完整字段表 + addresses)、/v1/pair、/v1/unpair、/v1/rpc(hub.status/hub.performance/hub.info);客户端令牌存储;ResourceLocks 骨架;3 个新 Tauri 命令:remote_begin_pairing、remote_cancel_pairing、remote_status |
| 涉及文件 | remote/core/{mod,host,error,config}.rs、remote/core/transport/{server,http,router}.rs、remote/core/auth/{mod,pairing,client_store,tokens}.rs、remote/core/registry/{mod,capabilities,validate}.rs、remote/core/ops/hub.rs、remote/core/policy/locks.rs、remote/host_app/{mod,host,interfaces_win,netprofile_win,paths,glue}.rs、lib.rs、commands.rs、main.ts、build.rs、permissions/frameface-control.toml、Cargo.toml、tools/verify-commands.ps1、contracts/remote-v1-ops.json |
| 证明 | ① .\tools\verify-commands.ps1 打印 commands.rs declares 38 且 RESULT 一致;② grep -rn "tauri" src/remote/core/ 无输出;③ 三条 curl(PowerShell 里写 curl.exe,curl 是 Invoke-WebRequest 的别名;JSON 用 --data-binary "@file.json" 绕开引号地狱):curl.exe -s http://127.0.0.1:21120/v1/infocurl.exe -s -X POST http://127.0.0.1:21120/v1/pair -H "Content-Type: application/json" --data-binary "@pair.json"curl.exe -s -X POST http://127.0.0.1:21120/v1/rpc -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" --data-binary "@rpc.json" |
P1 — 能力清单 + hub/tracking/face 全量 + 串行化 + 限流
| 项 | 内容 |
|---|---|
| 交付 | GET /v1/capabilities 由注册表生成(含「不返回 streaming」守卫);hub.*/tracking.*/face.* 全部 op;RESERVED_OPS 占位与 unknown_op 行为;ResourceLocks 生效(桌面与远程共用);每客户端限流 + retry_after_ms 钳制;args schema 单一来源校验;contracts/remote-v1-ops.json 快照 + 单测;tools/remote_api_probe.py 首次落地;verify-commands.ps1 第 6–8 项 |
| 涉及文件 | core/registry/*、core/ops/{hub,tracking,face}.rs、core/policy/{locks,ratelimit}.rs、core/error.rs、host_app/host.rs、engine_manager.rs(publish_status 收敛)、contracts.rs(PartialEq)、tools/remote_api_probe.py、tools/verify-commands.ps1、contracts/remote-v1-ops.json |
| 证明 | ① python tools/remote_api_probe.py --pair --code <码> --reads 全绿表格;② 并发两条 tracking.start → 一条 ok:true、一条 {"code":"busy","retry_after_ms":200};③ 21 次/秒 → 第 21 次 busy;④ stream.start → unknown_op 且连接不断;⑤ cargo test --offline 全绿 |
P2 — SSE 事件流(status + notice)
| 项 | 内容 |
|---|---|
| 交付 | GET /v1/events?topics=&hz=;status 变化检测(排除 timestamp_ms/sequence)+ hz 节流;连接即推全量;15 s keepalive;retry: 每连接只发一次;notice 服务端限流(10 s 去重、≤5 条/10 s);frameface://remote-activity 同步桌面 busy 态 |
| 涉及文件 | core/transport/{sse,server}.rs、core/events/{mod,status,notice}.rs、core/ops/hub.rs、main.ts、engine_manager.rs |
| 证明 | ① curl.exe -N -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:21120/v1/events?topics=status,notice&hz=2" → 首帧立刻是全量 status;随后 5 秒约 10 帧(证明节流);≥15 秒出现 : keepalive;retry: 3000 只出现一次;② 探针 --sse 5 的统计(帧数、平均间隔、首帧延迟、keepalive 计数、retry 出现次数);③ PC 上点「停止追踪」→ 头显 SSE 立刻收到 status(协议 §7.3) |
P3 — UDP 信标 + 网络策略 + 顺带修 21112
| 项 | 内容 | |
|---|---|---|
| 交付 | 每块可用网卡各发一份 UDP 信标(37022 固定、≤1024 字节、无密钥);Get-NetConnectionProfile 检测「公用网络」并默认拒绝;来源本地子网过滤;设置页显示防火墙命令;host_app/interfaces_win.rs 的网卡枚举与 addresses 排序;独立提交:NiskleHubStatusBridge.kt 改绑回环并重编 slimevr.jar | |
| 涉及文件 | core/discovery/beacon.rs、core/config.rs、host_app/{interfaces_win,netprofile_win}.rs、main.ts、docs/SECURITY_BOUNDARIES.md、新文档(端口预算)、third_party/.../NiskleHubStatusBridge.kt | |
| 证明 | ① python tools/remote_api_probe.py --beacon 10 收到 ≥4 个信标且字段与协议 §10.1 一致(多网卡机器上看到多个不同 ip);② 切到「公用网络」→ 服务不启动且设置页显示原因;③ netsh advfirewall firewall show rule name="Niskle Hub 互联";④ python tools/slimevr_bridge_probe.py 仍能读到快照(证明 21112 改回环后功能不受影响),且 `netstat -ano | findstr 21112 显示 127.0.0.1:21112 而非 0.0.0.0:21112` |
P4–P7(Link 端)与媒体面(串流)不在本方案范围。
11. AI 实现提示词
你将在 Windows 上的真实仓库 D:\Work\FrameFace 里实现「Niskle Hub 局域网控制面」的 P0 + P1。
这是 Tauri 2 + Rust(edition 2024)桌面项目的 Rust Host 部分。
【第一步:先只读,不要改任何文件】
读完先复述一遍你理解的约束,再动手:
1. C:\Users\Administrator\Desktop\Niskle互联方案\01-互联协议规范-v1.md ← 唯一真相源
重点:§0.1 §3 §3.1 §3.2 §4 §5 §6 §6.3 §7 §8.3 §9 §10.1 §10.4 §12.1 §12.3 §15.2 §15.5
2. C:\Users\Administrator\Desktop\Niskle互联方案\02-Niskle Hub 互联方案(PC 端).md ← 设计依据
重点:§2.2 §2.4 §3 §5 §6
3. apps/frameface-ui/src-tauri/src/{commands.rs,lib.rs,engine_manager.rs,contracts.rs,upload.rs}
4. apps/frameface-ui/src-tauri/{Cargo.toml,build.rs,permissions/frameface-control.toml}
5. tools/verify-commands.ps1 与 tools/slimevr_bridge_probe.py(探针 house style)、AGENTS.md
【绝对不可破坏的不变式】
A. tools/verify-commands.ps1 必须继续通过(tools/check-release.ps1 会跑它)。命令面只允许新增
remote_begin_pairing / remote_cancel_pairing / remote_status 三个,并同步进 lib.rs 的
use commands::{...} 与 generate_handler![...]、build.rs 的 AppManifest::commands、
permissions/frameface-control.toml 的 commands.allow、main.ts 的 invoke 字面量。命令名必须小写 snake_case。
B. commands.rs 既有 35 个 #[tauri::command] 的命令名、返回类型(仍是 Result<_, String>)与前端可见参数名
一个字符都不能改。只允许在函数体开头加一行取资源锁,再照原样调用管理器。
C. build.rs 里不得新增任何其它带引号的小写字符串——verify-commands.ps1 用正则 "([a-z][a-z0-9_]*)" 提取,
多余引号串会被当成命令名。同理不要往 lib.rs 那两个块里加任何标识符。
D. 离线构建:$env:CARGO_HOME='D:\Work\FrameFace\.cargo-home'; cd apps\frameface-ui\src-tauri; cargo build --offline
只能加:tokio(features 仅 rt,rt-multi-thread,net,time,sync,io-util;绝对不要 macros 或 signal)、
httparse、sha2、subtle、base64、rand、zeroize。不要加 axum / hyper / tungstenite / tiny_http / rcgen /
hmac / aes-gcm / qrcode —— 缓存里没有。注意 hyper 的 server feature 需要 httpdate,而它在 .cargo-home
与 Cargo.lock 中都不存在,所以 HTTP 必须用 tokio::net::TcpListener + httparse 手写(方案 §5.1)。
E. 不要动 main.ts 里 2 秒的 get_tracking_status 轮询与 150ms 的骨骼轮询。
F. 不要用任何破坏性的 git / 文件命令;工作区有大量未提交的用户改动。不要 git add/commit/checkout/reset。
【架构硬要求(协议 §15.2)】
把模块分成两层,且 core/ 里**绝对不能出现 tauri**(不能 use tauri、不能出现 AppHandle/State</
Emitter/WebviewWindow/windows_sys,也不能引 crate::engine_manager / slimevr_manager / vrcft_runtime /
updates / upload / named_pipe)。core/ 只能通过 core/host.rs 里的 RemoteHost trait 与 DTO 访问外部世界;
host_app/ 实现该 trait 并负责所有 Tauri、windows-sys、注册表、PowerShell 相关代码。
注意 `core` 与内置 crate 同名:必须写 use crate::remote::core::…,不能写 use core::…。
本期宿主仍是 Tauri 应用(协议 §15.4),但分层现在就要建好,未来加 host_service/ 时只搬 core/。
【P0 交付】
- remote/core/ 与 remote/host_app/ 两层骨架;RemoteHost trait。
- GET /v1/info:字段集合严格等于协议 §3.1 的 10 个,不多不少。addresses 由 host 枚举本机可用 IPv4
(跳过回环/非 Up,物理网卡私有地址在前、APIPA 最后),port 用实际绑定端口回读。
- POST /v1/pair:6 位配对码,TTL 120 秒,一次性,最多 5 次尝试,只在内存,绝不进日志与信标。
若 client.id 已存在 → 视为同一设备重新配对:吊销旧令牌、签发新令牌、不新增条目、保留 Hub 端已存显示名。
- POST /v1/unpair:鉴权后立即吊销自己的令牌(在途请求与 SSE 也要失效)。
- POST /v1/rpc 支持 hub.status / hub.performance / hub.info。
- 令牌:32 字节随机 → base64url;只存 sha256 十六进制;subtle 常量时间比较;最多 16 个客户端。
- 配置写 %APPDATA%\FrameFace\remote.json,原子替换(.tmp + fs::rename);port 若落在 37100–37199 必须拒绝;
配置里不存在 beacon_port(信标端口固定 37022)。instance_id 复用 upload::install_id(),不要新建第二个 id。
【P1 交付】
- core/registry/mod.rs 是唯一真相源:每条 OpSpec 含 op/group/title/effect/confirm/since/args/resource/core/handler。
GET /v1/capabilities 完全由它生成,capabilities.rs 里不得出现任何 op 名字面量。排版硬约束:
`OpSpec {` 独占一行、每个字段一行、`}` 独占一行、args 的 JSON 必须单行(脚本按行切分)。
- 实现 hub / tracking / face 三个分组的全部 op(协议 §12.1),含 tracking.set_eye_enabled 在
capabilities.eye_tracking == false 时返回 unavailable。不实现其它分组。
- RESERVED_OPS 登记协议 §12.3 的 6 个 stream.*,但不出现在清单里、被调用时返回 unknown_op;
清单里不返回 streaming 对象。
- ResourceLocks:engine / vrcft / body / update 四组,同组已有操作在途则立即返回 busy + retry_after_ms
(缺省 500、上限 3000),不排队。桌面 UI 命令走同一把锁。前端 slimevrBusy 保留为纯 UI 状态。
- 每客户端限流:在途 ≤4、20 次/秒,超限返回 busy + retry_after_ms,绝不静默丢弃。
- 扩展 tools/verify-commands.ps1:注册表自洽检查(since/effect/core 方法存在/group 前缀/§9.4 五条 confirm/
错误码封闭集/stream.* 不得进 OPS/RESERVED_OPS 齐全)+ core/ 不得依赖宿主 + 禁止裸 use core::。
必须继续累加到同一个 $problems / $script:CommandSurfaceProblems。
- 新增 tools/remote_api_probe.py(仅标准库,main() -> int,中文 docstring 写用途与用法),默认只调
effect == read 的操作;body.start / body.stop 必须在 --allow-action --op --yes-kill-java 同时给出时才调用。
【必须报告】
1. 完整跑一次并贴出 .\tools\verify-commands.ps1 与 .\tools\check-release.ps1 的输出与退出码。
2. 贴出 cargo build --offline 与 cargo test --offline 的结果。
3. 给出 curl.exe 的 P0 三条命令与真实响应;以及 grep -rn "tauri" src/remote/core/ 的输出(应为空)。
4. 明确列出「你没能验证的东西」(例如:真实头显、真机防火墙行为、无管理员权限下的防火墙规则、
公用网络检测在你机器上的实际取值、多网卡 addresses 的真实排序、未在真机跑过的并发时序)。不要假装验证过。
5. 列出你为避免破坏 A–F 而做的每一处妥协。附:⚠️ 协议待修订 汇总
| # | 位置 | 问题 | 建议 |
|---|---|---|---|
| 1 | §1.3 约束 8 / §2.1 | 「hyper 1.11 已离线可用」不准确:hyper 的 server feature 依赖 httpdate,而它在 .cargo-home 与 Cargo.lock 中均为 0 次出现,故 server 侧无法离线构建(证据:解包 hyper-1.11.0.crate 的 Cargo.toml,server = ["dep:httpdate", …]) | 改为「hyper 的 client feature 可用;如需 server 侧必须先在线补齐 httpdate」,并补一句:edition 2024 的 resolver = "3" 使 Cargo.lock 为 feature-minimal,任何新 feature 拉到 lock 外的可选依赖都会直接失败 |
| 2 | §1.3 约束 2 | 「信封结构里没有 request_id 字段」表述不精确:write_command(engine_manager.rs:434-438)会发送 request_id,但 ProtocolEnvelope(contracts.rs:240-248)不解析它,所以响应从不关联 | 改为「出站信封带 request_id,但入站解析结构不含该字段,因此响应无法关联」。结论(LAN 层必须自带 id)不变 |
| 3 | §5.2 client.id 重配对 | 「保留该 client.id 与显示名」有歧义:请求里也带 name,若采用它,PC 端 client.rename 改的名字会在每次重配对时被覆盖 | 建议明确写「重配对时忽略请求里的 name,保留 Hub 端已存显示名」,并补一条:重配对不占用新配额,已满 16 个时也照样允许 |
| 4 | §5.4 / §12.1 | 「最多 16 个已配对客户端,超出需先吊销」没有对应错误码,POST /v1/pair 被拒时无法给出协议内语义 | 建议明确为 forbidden(本方案暂用此值),或新增 client_limit_reached |
| 5 | §2.3 vs §10.1 | 内部不一致:§2.3 的端口表写信标「可配置」,§10.1 写「固定为 37022/UDP,永不可配置」 | 以 §10.1 为准,回改 §2.3 的「说明」列 |
| 6 | §12.1 update.install | 「基本不返回」(updates.rs:136-138 交给 NSIS)意味着调用方无法区分「成功」「失败」「还在下载」 | 建议定义为「立即返回 {started:true},随后用 notice 推送进度,最终断连即为安装开始」。本方案 P0–P3 不暴露该 op |
| 7 | §8.3 notice 主题 | 「Hub 里现有的 notifyEngineError/notify 调用点应当全部转发过来」缺少落地路径:这些调用点在前端 main.ts:351-364,Rust 看不到,而前端→Rust 只有命令通道 | 需在 P2 评审后新增一条 Tauri 命令(例如 publish_notice)并同步 4 处命令面;或在协议里明确 v1 的 notice 只覆盖 Rust 可观测事件(本方案默认取后者) |
| 8 | §10.1 instance_id | 未指定来源。仓库里已有 %APPDATA%\FrameFace\config.json 的 install_id(upload.rs:21-39) | 明确「instance_id == 既有的 install_id」,避免 Hub 与中继出现两个不同 id |
| 9 | §3.1 addresses 排序 | 只写「按优先级排序」,未定义优先级。混合了以太网/Wi-Fi/VPN/Hyper-V/WSL 的机器上,顺序直接决定头显先试哪个地址(§10.4 的探测体验取决于它) | 建议把本方案 §5.9 的规则写进协议:物理网卡私有地址 → 其它私有地址(虚拟网卡/VPN)→ APIPA;排除回环与非 Up 接口 |
| 10 | §4.3 retry_after_ms 上限 3000 | 与长耗时 action 冲突:客户端最多自动重试一次,而 body.start 在必要时先 kill_orphan_servers(800 ms sleep,slimevr_manager.rs:231-237)再 spawn,可能接近或超过 3 s,正常操作会被呈现为「忙碌」 | 建议为 action 类操作放宽上限(或约定「长耗时 action 先返回 accepted 再走 notice 报结果」)。本方案 P1 会实测 body.start 冷启动耗时作为依据 |
| 11 | §12.1 capture.set_enabled | args 只写类型未写边界;实际边界是 interval_ms ≤ 60000、max_frames ∈ 1..=8192、total_max_frames ∈ 1..=8192(engine_manager.rs:142-150),而契约 schema 恰在这里漏过字段(contracts/ipc-v1-command.schema.json:32) | 把这三个边界写进协议 §12.1 的 args 列,与注册表 schema 保持一致(本方案已把边界放进注册表 args,作为唯一可校验来源) |