02-Niskle Hub 互联方案(PC 端)

admin · 9 小时前

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.ps1tools/check-release.ps1两个脚本 RESULT 全绿
G7不新增离线缓存里不存在的 crateCARGO_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_appwifi_provision_slimevrdetect_slimevr_serialminimize_window/close_window协议 §12.2 明确排除
把 LAN 服务做成独立进程 / 独立 sidecar约束 1:Engine 命名管道 nMaxInstances=1 + PIPE_REJECT_REMOTE_CLIENTSengine/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 CSPtauri.conf.json:30connect-src ipc: http://ipc.localhost 没有为 LAN 服务放宽的余地——设置面板走 invoke,WebView 从不直连 21120

1.3 端口预算(协议 §15.5)

用途端口本期动作
控制 APITCP 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.rssetup 里、EngineManager::start 之后启动(lib.rs:142-159)。理由见协议 §15.4:本期目标(在 VR 里操作 Hub)本身就要求 Hub 正在运行——用户在用面捕时 Hub 必然是开着的。现在直接做服务会带来多进程、开机自启、权限提升与多一个攻击面,收益为零。

2.2 启动顺序与关闭

时机动作
lib.rs:81-95管理器 Arc 化并 manage(含新增 RemoteHandle 占位不需要——见 §5.4 的单例锁设计)
setuplib.rs:142-159EngineManager::start 之后调 remote::host_app::start(app.handle());失败只记日志并置 remote_status.last_error不拖累 Hub 自身启动
启动失败端口被占 / 公用网络被拒 / 配置损坏 → 设置页显示原因,服务保持关闭
quit_app_flowlib.rs:34-41)与 RunEvent::Exitlib.rs:163-169各加一行 remote::shutdown(),位置在 app.exit(0) 之前,先释放 21120 再停信标线程

2.3 运行时模型

关注点决策理由
HTTP 服务独立的 tokio 多线程 runtime(worker_threads(2)),不用 Tauri 的 async_runtimeTauri runtime 被 WebView/插件共用,SSE 长连接不该占它的 worker;且 core/ 不该依赖 tauri
op 执行所有 handler 走 tokio::task::spawn_blocking底层管理器是阻塞 IO:TcpStream::connect_timeoutslimevr_manager.rs:246,288)、kill_orphan_servers 的 800 ms sleep、updates::checkreqwest::blockingupdates.rs:69)。直接在 async 任务里跑会卡死 runtime worker
锁与限流std::sync::Mutex(因为都在 spawn_blocking 里)避免锁跨 .await,也不需要 tokio::sync 的异步锁
窗口类 opRemoteHost::show_window(),host 侧用 show_main_windowlib.rs:43-49与既有 hide_to_traycommands.rs:117-120)同路径;若真机出现跨线程 panic,改 app.run_on_main_thread

2.4 依赖规则与强制手段

允许依赖禁止依赖
remote/core/**stdserdeserde_jsontokio(仅 rt/rt-multi-thread/net/time/sync/io-util)、httparsesha2subtlebase64randzeroizecrate::contracts(纯 serde 数据,contracts.rs:1 只 use serde)tauri(含 AppHandle/State/Manager/Emitter/WebviewWindow)、crate::engine_managercrate::slimevr_managercrate::vrcft_runtimecrate::updatescrate::upload(它 use tauriupload.rs:4)、crate::named_pipewindows-sys
remote/host_app/**core/ + tauri + 全部管理器 + windows-sys业务逻辑(只做适配与 DTO 转换;判断、校验、限流一律在 core/

强制手段(三层,成本递增)

  1. 门控脚本(本期就做)tools/verify-commands.ps1 新增第 7 项——扫 src/remote/core/**/*.rs,命中 use tauritauri::AppHandleState<ManagerEmitterWebviewWindowwindows_syscrate::engine_managercrate::slimevr_managercrate::vrcft_runtimecrate::updatescrate::uploadcrate::named_pipe 任一即 problems++。它挂在 check-release.ps1:25-40 里,所以这是发布门而不只是本地提示。

  2. 命名陷阱同样入脚本core 是内置 crate 名,core/ 里的代码若写 use core::… 会解析到内置 core 而不是本模块。脚本额外拦 src/remote/** 里的裸 use core::,写法定为 use crate::remote::core::…

  3. 评审清单(现在写进 AGENTS.md)core/ 的任何 PR 出现 tauri 字样即拒。将来真正新建 host_service/ 时,把 core/ 提升为独立 crateremote-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.* 占位 */ ];

五个设计取舍:

  1. 表是 static 而不是运行时构造。门控脚本按行读的就是它;运行时拼出来的表脚本看不见。

  2. handler 用函数指针而不是闭包/trait objectfn 指针可以放进 static;闭包需要 Box<dyn Fn>,只能 OnceLock 惰性初始化,且脚本无法静态提取。

  3. args单行** JSON 字符串,不存嵌套结构体。脚本要按 OpSpec { 到单独一行的 } 切分条目(§3.5),多行嵌套字面量的大括号会把切分弄碎。排版约束写进文件头注释**:OpSpec { 独占一行、每个字段一行、} 独占一行、args 的 JSON 写成单行——多行的话脚本就切不开条目。

  4. core 字段显式声明适配方法名。Rust 没有反射,这是「注册表 op ↔ 宿主方法」可校验的唯一方式。

  5. Resource::None 是显式值而不是 Option。读操作与 app.*client.* 明确不参与资源锁,后来者「顺手」给读操作加锁的路径也就不存在。

3.2 预留命名空间(协议 §6.3 / §12.3)

要求实现
v1 不发出 stream.*RESERVED_OPSOPS 分离;capabilities 只遍历 OPS
误发 stream.* 返回 unknown_op路由只在 OPS 里查找,天然 404 + unknown_op(协议 §4.4 规则:不会导致断连)
将来「填实现」而非「改协议」stream.*RESERVED_OPS 移到 OPS 并补 handler + since: 2GROUP_METAstream 组的 {id:"stream",title:"串流",order:30} 已按协议 §12.3 写好
v1 不返回清单里的 streaming 对象单测 manifest_has_no_streaming_objectcapabilities.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.versionRemoteHost::app_info()(host 侧用 app.package_info().version,单一版本源,不手抄 Cargo.toml
hub.instance_idapp_info().instance_id ← host 侧 upload::install_id()upload.rs:21-39)。复用现有 install_id,不新建第二个 id(协议 §3.1、§10.3 要求它同时是发现去重键与中继路由键)
hub.namehost 侧 COMPUTERNAME 环境变量,取不到则 "Niskle Hub PC"
operations[]OPS 逐条映射;argsserde_json::from_str 解析
groups[]OPS 的实际分组 ∩ GROUP_META
events[]EVENTS

硬规则:capabilities.rs 里出现的任何 op 名字面量都等于给规范开了第二个真相源。 由单测 capabilities_are_registry_derived(集合相等)与脚本(检查该文件不出现 "xxx.yyy" 形式字符串)双重保证。

3.4 Tauri 命令保持薄适配

commands.rs35 个命令名、返回类型、前端可见参数名一个都不改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>),CoreErrorDisplay 转字符串,前端 friendlyError 路径零改动。

  • 参数表也不变。这是选择「单例锁」而非「注入 State<RemoteHandle>」的直接原因(§5.4)。verify-commands.ps1:31-41 只提取函数,所以即便将来加参数也不会打破门控,但本期不引入这个变量。

  • SlimeVrManagerVrcftRuntime 改成 Arc<T>。它们现在是裸值 .manage(...)lib.rs:94-95),TauriHost 需要共享所有权。改动点收敛在 6 处:lib.rs:38,94,95,167commands.rs:157,175,292,294,301app.state::<SlimeVrManager>() / app.state::<VrcftRuntime>(),把类型参数换成 Arc<...>lib.rs:93Arc<EngineManager> 已是 Arc,不动。

  • 不新增 mod 到两个正则块里mod remote; 只加在 lib.rs:3-11 的模块列表末尾,写进 use commands::{...}lib.rs:15-24)或 generate_handler![...]lib.rs:96-132)都会被脚本按裸标识符提取,判成「注册了但不存在的命令」。

3.5 新增一个 op 的代价

步骤必做
1core/registry/mod.rs 加一条 OpSpec(含 sincecoreargs
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/maximumoneOf/$ref/pattern 不在协议子集里);⑤ 有没有误暴露协议 §12.2 排除的命令;⑥ core/ 里有没有出现 tauri;⑦ 有没有顺手动了 stream.*37100–37199

3.6 扩展 tools/verify-commands.ps1

现有 5 项检查(:53-168)全部保留,新增第 6–8 项,全部累加到同一个 $problems(最后 :171 赋给 $script:CommandSurfaceProblemscheck-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/capabilitiesoperations + 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.rsRemoteHost trait + DTOAppInfo/IfaceInfo/NetworkProfile/ClientView)——core 唯一的对外依赖面
transport/server.rsTcpListener accept 循环、来源网段过滤、连接上限、shutdown 信号
transport/http.rsHTTP/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.rsSSE 帧编码(event:/data:/retry: 只发一次/: keepalive)与 chunked 分块
auth/mod.rsBearer 解析与鉴权;/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.rsargs schema 是唯一参数边界来源,调用前校验
ops/hub.rs ops/tracking.rs ops/face.rsP0/P1 的处理器,只调 RemoteHost
events/mod.rs事件总线与订阅者管理(latest-only 有界队列)
events/status.rs变化检测(排除 timestamp_ms/sequence)+ hz 节流
events/notice.rsnotice 服务端半边限流:文本 10 s 去重、整体 ≤5 条/10 s、sticky 规则
policy/locks.rsResourceLocks 单例 + ResourceGuard
policy/ratelimit.rs每客户端令牌桶(20/s)+ 在途计数(≤4)+ retry_after_ms 计算
discovery/beacon.rs每块可用网卡各发一份 UDP 信标(固定 37022)
config.rs配置模型、%APPDATA%\FrameFace\remote.json 原子读写、端口区间校验(拒绝 37100–37199)
error.rsCoreError + 封闭错误码 → HTTP 状态码 + retry_after_ms 钳制(≤3000)

4.2 remote/host_app/(Tauri 宿主适配层)

文件职责
mod.rspub fn start(app: &AppHandle) / shutdown();把 RemoteHost 实现交给 core,在 setup 里调用
host.rsTauriHost: impl RemoteHost——转调 EngineManager / SlimeVrManager / VrcftRuntime / updates / 窗口;app_info() 提供 version/name/instance_id
interfaces_win.rsGetAdaptersAddresses FFI 封装 → Vec<IfaceInfo>(地址、前缀长度、IfType、IfIndex、OperStatus)
netprofile_win.rsGet-NetConnectionProfile 检测(PowerShell,沿用 slimevr_manager.rs:456-462CREATE_NO_WINDOW 做法),结果缓存 30 s
paths.rs%APPDATA%\FrameFace 路径解析(与 upload.rs:14-19 同一布局)
glue.rscommands.rs 用的 lock(Resource) -> Result<ResourceGuard, CoreError> 等薄适配
mod.rs 里的 RemoteHandleremote_status 命令读它(是否运行、实际端口、地址列表、已配对数量、last_error)

4.3 其它仓库文件

文件动作
src/commands.rs35 个签名不动,函数体加取锁 + 转发;新增 3 个命令(§5.6)
src/lib.rsmod remote;Arc 化两个管理器、setup 启动、退出路径 remote::shutdown():93-95:96-132 两个块不被污染
src/engine_manager.rs6 处 app.emit(STATUS_EVENT, …):312,321,408,487,497,510)收敛成 publish_status(app, snapshot),同时喂 WebView 与远程总线
src/contracts.rsTrackingStatus 及嵌套结构加 #[derive(PartialEq)](纯派生,不改字段)
Cargo.toml / Cargo.lock§5.1 的 7 个依赖(全部已在 lock 中,无需重新解析);windows-sys 增加 Win32_NetworkManagement_IpHelperWin32_Networking_WinSock features(该 crate 唯一依赖是 windows-link,缓存与 lock 中已有,不引入新 crate
build.rs加 3 个新命令名;其它引号小写串会被脚本 :47 的正则误判成命令名
permissions/frameface-control.toml加同样 3 个命令名
src/main.ts3 个新 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.jsoncontracts/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.ktP3:ServerSocket(STATUS_PORT):185)改为只绑回环(§7 第 8 条)

5. 关键实现要点

5.1 HTTP 服务装配与依赖(本方案最重要的选型修正

决策:不用 hyperserver 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 侧)。结论:hyperserver feature 无法离线构建axum 依附它,同样不可用。

手写在这份协议下不是妥协,因为协议把所有难点都排除掉了:

协议要求后果
每个响应都带 Content-LengthConnection: close(§3)不需要 keep-alive 复用与分块请求体
请求体上限 64 KiB(§3)读固定上限即可,只有 Content-Length 一种情况
未知路径返回 404 + 标准错误信封(§3)不需要路由框架
未鉴权端点只有 2 个(§3 总览)不需要中间件栈
SSE 是唯一流式响应只有 /v1/events 需要 chunked

依赖清单(P0 一次性加进 Cargo.toml,逐项核对过缓存与 lock):

crate版本用途注意
tokio1.53.1TCP、runtime、timesync::broadcastfeatures 仅 rt,rt-multi-thread,net,time,sync,io-util。启用 macros 会拉进缓存里没有的 tokio-macros,启用 signal 会拉进缓存里没有的 signal-hook-registry,两者都会直接构建失败——也就是没有 #[tokio::main] / tokio::select! 可用
httparse1.10.1请求头解析无依赖
sha20.10.9令牌哈希依赖 digest/cfg-if,均在缓存
subtle2.6.1常量时间比较无必需依赖
base640.22.1base64url无依赖
rand0.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 一处
zeroize1.9.0配对码擦除无依赖

关键机制:edition 2024 默认 resolver = "3"Cargo.lockfeature-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-8Cache-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 OKContent-Type: text/event-streamCache-Control: no-storeConnection: keep-aliveTransfer-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 状态变化检测与节流

TrackingStatuscontracts.rs:159-174)里 timestamp_mssequence 每个 tick 都变,逐字段比较整结构 = 永远「变了」

  1. contracts.rsTrackingStatusTrackingCapabilitiesPerformanceStatsFrameCastMetricsCaptureStatus#[derive(PartialEq)](纯派生,不改字段,不影响 commands.rs 返回类型)。

  2. events/status.rs 提供显式比较 semantically_equal(a, b):逐字段列出 engine/framecast/vrcft/face_tracking/eye_tracking/tracking_enabled/capabilities/performance/framecast_metrics/capture/last_error故意排除 timestamp_mssequence。逐字段写全而不是「整体比较再排除」,是为了让以后新增字段时被迫做决定:漏加 = 该字段变化不上报(联调能发现),比整体比较的沉默失效更安全。

  3. 节流才是真正的限速器performance.fps/inference_ms/cpu_usage_percent/tracking_quality 每帧都在变,变化检测只能消掉 timestamp_ms/sequence 造成的假变化,不会把推送降到接近零;真正的上限由 hz 保证。

  4. 推给桌面 WebView 的 frameface://status 不节流(保持现有行为),节流只作用于 SSE——远程不改变桌面 UI 的实时性。

5.4 按资源串行化(约束 4 的正面回应)

现状:互斥只写在前端(slimevrBusymain.ts:740,2041-2078pairingInProgressmain.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覆盖桌面命令
enginetracking.*face.*capture.*start_tracking/stop_tracking/enable_*/set_expression_*/set_capture_enabled/clear_capture_data
vrcftvrcft.openvrcft.stopopen_vrcft/stop_vrcft
bodybody.*start_slimevr/stop_slimevr/reset_slimevr/pair_slimevr_device/unpair_slimevr_device/assign_slimevr_tracker/set_slimevr_osc/set_slimevr_proportions/autobone_slimevr
updateupdate.*download_and_install_update_channel
无锁hub.*(read)、framecast.statusapp.*client.*get_*hide_to_trayminimize_windowclose_window

四条行为写得再清楚也不过:

  1. 不排队,立即 busy(协议 §7.2)。这是刻意的:Engine 队列溢出丢最旧engine_manager.rs:419-422),排队等于让远程请求悄悄挤掉桌面用户自己的操作。

  2. 桌面 UI 也走同一把锁。这才是「下沉到 Rust」的全部意义——不是给远程单开一把锁,而是让 commands.rs 也经过它。改造后前端的 slimevrBusy 降级为纯 UI 反馈(防连点、显示 loading),不再是正确性边界。

  3. 前端 busy 标志不删除,改为可被远程置位。新增 Tauri 事件 frameface://remote-activity,payload {"resources":["body"]}main.ts 加一个监听,远程持锁时把 slimevrBusy = true、释放时置回。这样「头显正在启动体感追踪」实时反映到桌面开关的 loading 态。删掉标志会带来双击重复提交,所以这里是保留并同步,不是退役。

  4. 单例锁优于注入 State。用 static LOCKScommands.rs 的参数表一字不改(§3.4);进程内只有一份锁集合,语义上就是单例。

  5. 锁的泄漏面要封住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 mserror.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-19install_id 同目录;不用 app_config_dir()(那是 %APPDATA%\tech.nisklehub.desktop,会凭空多出第二个配置位置)
文件remote.json(配置)、remote-clients.json(客户端表)分开,让配置写入不碰凭据文件
字段enabledportbind_ipbeacon_enabledallow_public_profileschema_version没有 beacon_port——协议 §10.1 规定 37022 永不可配置。不含任何密钥
校验port 落在 37100..=37199 即拒绝(协议 §15.5 媒体面预留),并给出明确错误防止随手占用未来媒体面端口
写入remote.json.tmpstd::fs::rename 覆盖AGENTS.md:88「文件写入优先原子替换」;Rust 的 fs::rename 在 Windows 上用 MOVEFILE_REPLACE_EXISTING,可覆盖已存在目标
读取解析失败 → 用默认值 + 备份坏文件为 remote.json.bad + 记日志配置损坏不能让 Hub 起不来
并发所有写入串行化在一把 Mutex<Config>install_id()upload.rs:37)用的是非原子 fs::writeconfig.json;远程侧只读该文件,避免互相踩

5.9 /v1/infoaddresses(协议 §3.1、§10.4)

/v1/info 的字段集合是封闭的,只返回协议 §3.1 那 10 个:

字段来源
v常量 1(最高支持的主版本)
app / product常量 "NiskleHub" / "Niskle Hub"
versionapp_info().version(host 侧 app.package_info().version
instance_idapp_info().instance_idupload::install_id()
nameCOMPUTERNAME
pairedHub 级!client_store.is_empty()(不是「本客户端是否配对」)
port实际绑定的 TCP 端口(从 listener 的 local_addr() 回读)。客户端以它为准,21120 只是缺省值
addresses见下
uptime_sOnceLock<Instant> 记进程启动时刻(语义是「PC 端刚重启过」)

addresses 的枚举与排序(host 侧 GetAdaptersAddresseswindows-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/infoaddresses 与 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);任一活动连接为 Publicallow_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安装模式是 currentUsertauri.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
去重与限流是全局的生产端做一次,再扇出给所有订阅者(不是每连接各做一次)
重连不补发总线只保留最新 statusnotice 不进任何历史缓冲

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:93unavailable
sender.send 失败"引擎通信线程不可用":97,114,132,158,165unavailable
参数越界"灵敏度参数无效"/"采集间隔无效"/"采集帧数上限无效"/"采集累计帧数上限无效"/"滤波强度无效":107,123,143,146,149invalid_args + field
SlimeVR 桥不可达"无法连接 SlimeVR 服务"commands.rs:205,214,223,232,248,260,273,282unavailable
服务未运行is_running() 为 falseunavailable
tracking.set_eye_enabledcapabilities.eye_tracking == false协议 §12.1 明确要求unavailable + 「当前硬件版本尚未支持眼动追踪」
资源锁BusyInfobusy + 钳制后的 retry_after_ms
限流令牌桶 / 在途计数busy + 钳制后的 retry_after_ms
未匹配其它internal(message 保留原始中文串)

这里有一处已知的技术债:底层返回 Stringcommands.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.rsmod remote;Arc 化、setup 启动、退出 shutdown():93-95:96-132 两个块不被污染
src/engine_manager.rs6 处 emit 收敛为 publish_statusMAX_QUEUED_COMMANDS:23)与丢弃策略不动(协议要求靠限流保护它,不是改它)
src/contracts.rsPartialEq 派生;不改字段名/类型(Engine 契约,contracts/README.md 明确不兼容变更需新版本号)
src/slimevr_manager.rs不改保持签名;taskkill 逻辑(:231-236)原样保留(见 §9 风险 1)
src/vrcft_runtime.rssrc/updates.rs不改只被 host_app/host.rs 调用
src/upload.rs不改install_id():21)已是 pub,由 host 侧调用(core 不能直接调,它 use tauri
Cargo.toml7 个新依赖 + 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.jsoncontracts/README.md新增/改生成式快照
docs/SECURITY_BOUNDARIES.md新增 LAN 入口信任级别说明
docs/(新文件)新增端口预算表(含 37100–37199 预留)
AGENTS.md四条新踩坑(§4.3)
third_party/.../NiskleHubStatusBridge.ktP3 改§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.jsonremote.json%LOCALAPPDATA%\FrameFace\*.log、SSE 帧中均无 token 原文(探针 --grep-secret 全量扫)
4配对码零泄漏不在日志、不在 UDP 信标、不在任何 HTTP 响应(除 PC 端本地命令)
5常量时间比较token 与配对码比较均走 subtle,无 == 早退
6吊销即时生效/v1/unpairclient.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.1slimevr_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: truetitle 写明后果
14不暴露 Irreversible / 预留项注册表无 app.quitupdate.install P0–P3 不暴露;无 stream.*;误发 stream.* 返回 unknown_op 且不断连
15体积与头部上限Content-Length > 65536 → 400 且不分配缓冲;头部 > 8 KiB → 400
16未知路径404 + 标准错误信封,不返回 HTML
17notice 不骚扰同文本 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 --offlinecore/#[cfg(test)],无需 Tauri 即可跑)

测试断言
envelope_ignores_unknown_fields请求带未知字段仍解析成功(协议 §4.1)
envelope_rejects_bad_requestsv/id/opbad_requestid=0bad_requestv=2unsupported_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_authoritativeport 等于实际绑定端口(用 0 端口启动时也正确)
addresses_ordering物理网卡 RFC1918 在 VPN 之前、APIPA 最后;排除回环
capabilities_are_registry_derivedop 集合相等;每个 op 有 sinceargs 只含允许关键字
manifest_has_no_streaming_object清单无 streaming 键,且无任何 stream.*
reserved_ops_do_not_leakOPS ∩ RESERVED_OPS == ∅RESERVED_OPS == 协议 §12.3 的 6 个
groups_derived_from_ops空分组不出现在 groups[]
ops_snapshot_matches_registrycontracts/remote-v1-ops.json 一致,不一致打印 diff
host_mapping_targets_exist每个 OpSpec.coreRemoteHost 实现里存在
mandatory_confirm_flags§9.4 五条 confirm: true
status_change_detectiontimestamp_ms/sequence 不同 → 不变化;仅 performance.fps 不同 → 变化
notice_dedup_and_rate同文本 10 s 内只 1 条;10 s 内第 6 条被吞
retry_after_clamped任何 CoreErrorretry_after_ms ≤ 3000;缺省 500
resource_lock_serialises两个并发同组 op → 一个成功、一个 busy
rate_limit_returns_busy21 次/秒 → 第 21 次 busy(注入假时钟,不真等)
pairing_code_lifecycleTTL 过期作废;成功即作废;5 次失败作废;比较走常量时间
repair_reuses_entry相同 client.id 二次配对:条目数不变、旧 token 失效、显示名保留、16 满时仍允许
unpair_invalidates/v1/unpair 后旧 token unauthorizedepoch 递增
token_never_stored_plaintext序列化后的客户端表不含 token 原文
beacon_payload_bounded≤1024 字节,且不含 token/配对码
config_rejects_media_portsport = 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.pyframecast_status_probe.py):仅标准库(argparse/json/socket/sys/time/urllib.request/threading)、无第三方依赖、main() -> intsys.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 /Tslimevr_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 项)文本
2P0 三条 curl 的完整命令 + 完整响应(含 id 原样回带、t 为毫秒时间戳)文本
3探针 pass/fail 表(--json 报告)JSON
4GET /v1/capabilitiescontracts/remote-v1-ops.json 的 diff空 diff
5SSE 原始前 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/eventsunauthorized;错配对码 5 次后第 6 次即使输对也失败;stream.startunknown_op 且连接不断文本
10二次配对证据:同 client.id 再配对后,client.list(或 remote_status)条目数不变、旧 token 失效文本
11分层证据:grep -rn "tauri" src/remote/core/ 无输出文本
12压测后桌面 UI 手动操作仍成功的录屏或截图图片

9. 风险与未决问题

#风险影响缓解 / 结论
1body.stop = 杀掉本机所有 Java 进程远程一条命令会终止 SlimeVR 以及其它 Java 程序(Minecraft、IDE、其它服务)协议 §9.4 已要求 confirm: truetitle 写明后果。在 P0–P3 暴露 body.stop,放到 P6 与 Link 的确认弹层一起做。长期修法(另立任务):把 kill_orphan_serversslimevr_manager.rs:226-238)从「按镜像名杀」改成「按我们记录的 pid 树 + 端口占用者 pid 杀」
2.cargo-home 离线约束本期 7 个依赖全在 lock 与缓存里,可离线构建。但 edition 2024 的 resolver = "3" 让 lock 是 feature-minimal 的——任何新启用的 feature 一旦拉到 lock 里没有的可选依赖就直接失败hyperserverhttpdate 就是活例子)新增依赖前先跑 $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 供人工核对
5update.install 无完成信号download_and_installupdates.rs:136-138)交给 NSIS 后基本不返回,调用方无法区分成功与卡死P0–P3 不暴露该 op。见文末 ⚠️ 第 6 条
6retry_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
9notice 主题缺少前端来源Hub 现有 toast 都在前端 notify(...)main.ts:351-364),Rust 看不到,无法自动转发P2 只从 Rust 可观测事件生成 notice。见文末 ⚠️ 第 7 条
10install_id() 的写入不是原子替换upload.rs:37fs::write;若远程也写 config.json 可能写坏远程侧只读不写该文件,自己的配置写独立的 remote.json(原子替换)
11GetAdaptersAddresses 的 FFI 复杂度双缓冲重试、IfOperStatus/IfType 过滤、前缀长度解析都是易错代码全部关在 host_app/interfaces_win.rs;单测用固定 DTO 覆盖排序规则,FFI 部分只在真机验证并留证据;万一不稳定,退化到 `Get-NetIPAddressConvertTo-Json 的 PowerShell 方案(house style 已有先例),core` 侧零改动
12资源锁因进程外异常泄漏组内操作永久 busyResourceGuard::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/rpchub.status/hub.performance/hub.info);客户端令牌存储;ResourceLocks 骨架;3 个新 Tauri 命令:remote_begin_pairingremote_cancel_pairingremote_status
涉及文件remote/core/{mod,host,error,config}.rsremote/core/transport/{server,http,router}.rsremote/core/auth/{mod,pairing,client_store,tokens}.rsremote/core/registry/{mod,capabilities,validate}.rsremote/core/ops/hub.rsremote/core/policy/locks.rsremote/host_app/{mod,host,interfaces_win,netprofile_win,paths,glue}.rslib.rscommands.rsmain.tsbuild.rspermissions/frameface-control.tomlCargo.tomltools/verify-commands.ps1contracts/remote-v1-ops.json
证明.\tools\verify-commands.ps1 打印 commands.rs declares 38 且 RESULT 一致;② grep -rn "tauri" src/remote/core/ 无输出;③ 三条 curl(PowerShell 里写 curl.execurlInvoke-WebRequest 的别名;JSON 用 --data-binary "@file.json" 绕开引号地狱):
curl.exe -s http://127.0.0.1:21120/v1/info
curl.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}.rscore/policy/{locks,ratelimit}.rscore/error.rshost_app/host.rsengine_manager.rspublish_status 收敛)、contracts.rsPartialEq)、tools/remote_api_probe.pytools/verify-commands.ps1contracts/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.startunknown_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}.rscore/events/{mod,status,notice}.rscore/ops/hub.rsmain.tsengine_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 秒出现 : keepaliveretry: 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.rscore/config.rshost_app/{interfaces_win,netprofile_win}.rsmain.tsdocs/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 -anofindstr 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-homeCargo.lock 中均为 0 次出现,故 server 侧无法离线构建(证据:解包 hyper-1.11.0.crateCargo.tomlserver = ["dep:httpdate", …]改为「hyper 的 client feature 可用;如需 server 侧必须先在线补齐 httpdate」,并补一句:edition 2024 的 resolver = "3" 使 Cargo.lock 为 feature-minimal,任何新 feature 拉到 lock 外的可选依赖都会直接失败
2§1.3 约束 2「信封结构里没有 request_id 字段」表述不精确:write_commandengine_manager.rs:434-438会发送 request_id,但 ProtocolEnvelopecontracts.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.jsoninstall_idupload.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_enabledargs 只写类型未写边界;实际边界是 interval_ms ≤ 60000max_frames ∈ 1..=8192total_max_frames ∈ 1..=8192engine_manager.rs:142-150),而契约 schema 恰在这里漏过字段(contracts/ipc-v1-command.schema.json:32把这三个边界写进协议 §12.1 的 args 列,与注册表 schema 保持一致(本方案已把边界放进注册表 args,作为唯一可校验来源)