正在查看历史版本(admin · 2026-09-16 01:42:06) 返回当前版本

01-互联协议规范-v1

admin · 10 小时前

Niskle Hub × Niskle Link 互联协议规范 v1

本文是三份文档中的「唯一真相源」,另外两份是各端实现方案:

  • Niskle Hub 互联方案(PC 端).md

  • Niskle Link 互联方案(头显端).md

协议层面的改动都落在本文,改完再同步到两端。两端方案里不再重复定义协议。

  • 文档版本:v1(对应协议 v: 1

  • 编写日期:2026-09-15

  • 状态:设计定稿,尚未实现

  • --

0. 一句话概括

在 PC 端 Niskle Hub 里内置一个局域网 HTTP 服务,头显端 Niskle Link 通过它发现 → 配对 → 读取能力清单 → 调用操作 / 订阅事件,从而在 VR 里操作 PC 端。

0.1 本文只定义「控制面」,不定义「媒体面」

这一点放在最前面说,因为它决定了整个架构能不能活到下一步:

平面内容状态
控制面(本文)发现、配对、鉴权、能力协商、状态订阅、操作调用本次实现
媒体面(未来)头显接收 PC 的画面与声音(类似 Virtual Desktop / ALVR 的串流)已规划,不在本次范围

产品路线是:Link 最终会成为头显端串流接收端,Hub 成为 PC 端串流服务端(对标 Virtual Desktop 的两端)。 控制面就是那套「配对、选择分辨率码率、启动/停止串流会话」的信令通道。

因此本文的每一条设计都遵守一个原则:控制面与媒体面完全解耦。 控制面不承载任何音视频数据,媒体面也不依赖控制面的连接保持。 将来加串流时,控制面不需要改协议,只需要新增操作和一段新的媒体面规范(见 §15)。


1. 设计目标与约束

1.1 要达成的目标

目标说明
头显内操作 PC戴着头显时不需要摘下来走到电脑前
长期可维护加新功能不需要重新设计协议
不写死操作名、能力、事件都是数据而不是散落在两端的字面量
低摩擦头显里不需要手输 IP 地址
可演进未来可以换传输层(如加 TLS、加云端中继)而不动业务语义

1.2 已确定的产品边界(不再讨论)

决策结论影响
头显显示形态2D 悬浮面板(普通 Android 应用,非 OpenXR 沉浸式)Link 沿用现有 Compose UI,不需要引入 XR SDK
界面生成方式Hub 声明能力,Link 手写界面,界面不来自清单的自动渲染清单只用于能力门控 + 文案复用 + 版本容忍,不用于生成 UI
网络范围仅局域网直连,本阶段不做云端中继但协议设计预留中继(见 §10)
鉴权配对码换长期令牌无配对码不能在头显上单方面执行操作
协议文件不建 JSON Schema 文件,本文即规范靠评审和联调保证一致,见 §11 的漂移风险

1.3 绕不开的既有约束(来自代码审计,不是猜测)

这些是硬约束,方案只能绕开,假装它们不存在会在实现时全部回来:

  1. LAN 服务只能做在 Rust Host 里。 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。

  2. Rust 侧的响应无法关联。 出站方向其实 request_idengine_manager.rs:434-438 会写进命令信封), 但入站的解析结构 ProtocolEnvelopecontracts.rs:240-248)里没有这个字段, 所以回包从来没有被关联过。结论不变:LAN 层需要自带 id 关联,不能指望复用现有结构。

  3. Engine 命令队列只有 32 深,且溢出时丢弃最旧的一条engine_manager.rs:23,419-422)。 这意味着一个不节制的 LAN 客户端可以静默丢掉桌面用户自己的操作。 → 协议据此规定服务端限流与串行化(§7)。

  4. 互斥目前只存在于前端。 slimevrBusypairingInProgress 都写在 main.ts:740-805,1256-1291,Rust 侧没有任何等价物。 → 头显和桌面 UI 并发调用会直接打架。串行化只有下沉到 Rust 才管得住两端(§7.2)。

  5. 部分操作具有机器级破坏性。 start_slimevr/stop_slimevr 会间接触发 taskkill /F /IM java.exe /Tjavaw.exeslimevr_manager.rs:231-236)。 远程暴露 body.stop 等于「一条命令杀掉这台机器上所有 Java 进程」。

  6. 不可逆操作存在。 quit_app 走一次性 latch(engine_manager.rs:170-172)后 app.exit(0),无恢复路径(lib.rs:34-41)。 download_and_install_update_channel 交给 NSIS 后基本不会返回updates.rs:136-138), 远程调用方无法区分成功与失败。

  7. 已经存在一个未鉴权的局域网命令通道。 SlimeVR 状态桥接监听 0.0.0.0:21112NiskleHubStatusBridge.kt:185,576), 接受明文 wifi_provision:<ssid>:<password>reset:assign: 等命令,没有任何鉴权。 → 这不是本协议引入的问题,但可以随本协议一并处理(见 §9.5)。

  8. 离线构建约束(这一条踩过坑,值得逐行看)。 仓库用 CARGO_HOME=<repo>\.cargo-home 离线构建。经解包核实,缓存中已存在hyper 1.11.0hyper-util 0.1.20http-body-util 0.1.4http 1.5bytestower 0.5tokio 1.53tokio-utilrustls 0.23tokio-rustlsringsha2subtlerandbase64zeroizeserdeserde_json

    缓存中不存在axumtungstenitetiny_httprcgenhmacqrcode

    ⚠️ 关键陷阱:hyperserver feature 依赖 httpdate,而离线缓存与 Cargo.lock 里都没有它。 证据(解包 hyper-1.11.0.crateCargo.toml):

    http1 = ["dep:atomic-waker", "dep:futures-channel", "dep:futures-core", "dep:httparse", "dep:itoa"]
    server = ["dep:httpdate", "dep:pin-project-lite", "dep:smallvec"]

    hyper 的 client 侧可以离线构建,server 侧不行httpdate 只被 hyper 用来生成响应的 Date 头——一个零依赖、约 10 KB 的小 crate。

    → 本协议据此选型,见 §2.4。这一件事要在开工前解决,否则 PC 端写到一半会发现编不过。


2. 传输层选型

2.1 结论

HTTP/1.1 + JSON,Server-Sent Events 做下行推送。

选型理由
传输HTTP/1.1 over TCPcurl / 浏览器 / Postman 可以直接调试;这一条对长期维护的价值最大
编码JSON(UTF-8)两端都已有现成的 JSON 能力(serde_json / org.json
请求POST /v1/rpc单一入口,新增操作不需要新增路由
下行推送SSEtext/event-streamSSE 是 HTTP 的子集,不需要 WebSocket crate
服务发现UDP 广播信标复用仓库既有的 FrameCast 信标习惯

依赖成本(两端不对称,这里说清楚):

新增依赖
Link(Android)HttpURLConnectionorg.json 都已在用(UpdateRepository.kt:8,28,106
Hub(Rust)见 §2.4。HTTP client 侧零新增;HTTP server 侧有一处需要做选择

2.2 为什么不用其它方案

方案否决理由
WebSocket离线缓存里没有 tungstenite。为了双向推送引入一个新依赖不值得——SSE 已覆盖「服务端推、客户端收」,而客户端→服务端本来就是请求/响应模型
gRPC需要 protobuf 工具链与多个 crate,头显端要加重量级依赖,且调试成本高。收益(IDL 生成)在本项目规模下不值
裸 TCP + JSON Lines仓库里已经有这个先例(21112 桥接),代价是没有状态码、没有标准框架、没法用 curl 调试。协议一旦变复杂,排查成本会指数上升
mDNS 做发现离线缓存没有 mdns-sd;且 Link 现有的 mDNS 只做注册(advertise-only,LanDiscovery.kt:137-160),没有解析回调。UDP 广播更省事,也与 FrameCast 现有做法一致
复用 21112 桥接那个通道归 SlimeVR 服务端所有,生命周期跟随 Java 进程;且它未鉴权、无版本、无信封。不适合作为产品级控制面

2.3 端口分配

用途端口协议可否配置
控制 API21120TCP可配置(持久化;客户端以 /v1/infoport 字段为准)
发现信标37022UDP固定,不可配置(客户端据此知道去哪听,见 §10.1)

避让说明:37020/37021 是 FrameCast 视频;6969/21110/21112 是 SlimeVR;18980 是 Link 的 MJPEG 默认端口;9000/9001/9003 是 OSC。21120 与 37022 均未被占用。 媒体面将来使用的区间见 §15.5。

2.4 Hub 侧 HTTP server 的实现选择(开工前先定下来)

这是本次唯一一处「离线缓存不够用」的地方,先摆清楚,免得写到一半发现编不过。

事实(解包 .crate 核实):

crate状态影响
hyper 1.11.0缓存里有,但 server feature 依赖 httpdatehttpdate 在缓存与 Cargo.lock 中都是 0 次 → hyper 的 server 侧离线编不过
axum不在缓存且它建立在 hyper server 之上,同样不可用
httparse在缓存里HTTP/1.1 报文解析可用
tokio 1.53在缓存里,但 macros feature 依赖 tokio-macrostokio-macros 不在缓存 → #[tokio::main] / tokio::select! / #[tokio::test] 都不可用
socket2miolibcbytesfutures-*windows-link在缓存里线程/网络/异步基础设施齐备

通用机制:本工程是 edition 2024,resolver = "3"Cargo.lockfeature-minimal 的。 任何新启用的 feature 只要拉到 lock 里没有的可选依赖,就会直接构建失败。 这就是「.crate 在缓存里」不等于「这个 feature 能用」的原因——判断依据要看 feature 的依赖闭包,不是看 crate 是否存在。

两条可行路线,选一条即可(内部实现细节,不影响协议):

路线做法代价
A. 手写 HTTP/1.1(建议)std::net::TcpListenertokio::net::TcpListener + httparse 解析请求行与头部keep-alive、分块、边界都要自己处理。本协议恰好把这些难点都收敛掉了Content-LengthConnection: close 二选一(§3)、64 KiB 体上限、只有 SSE 一条流式路径
B. 补一个 crate 后用 hyper联网拉取 httpdate(零依赖、约 10 KB,只用于生成 Date 响应头)放进 .cargo-home代价是一次联网;之后 hyper 的 server 侧整体可用,transport/ 层可替换而业务代码不动

两条路线都不改变协议。A 不需要联网,B 的代码量更小。具体取舍由 PC 端方案决定(该方案选的是 A)。

无论选哪条,tokiomacrossignal feature 都用不了: 异步运行时需要手动构造(tokio::runtime::Builder),并发组合需要手动实现而不是 select!。 如果觉得这些约束不划算,用 std::thread + std::net::TcpListener 的同步写法也完全够用 ——控制面只有个位数客户端、低频请求,而仓库里已经有「常驻线程 + 环形缓存」的同类实现 (slimevr_manager.rs:268-314)。


3. 接口总览

方法路径鉴权用途
GET/v1/info❌ 否身份 + 配对状态。唯一的探测入口,所以体量压到最小
POST/v1/pair❌ 否(凭配对码)用配对码换长期令牌
POST/v1/rpc✅ 是调用一个操作
GET/v1/capabilities✅ 是拉取能力清单
GET/v1/events✅ 是SSE 事件流
POST/v1/unpair✅ 是客户端自我注销(主动解除配对)

约定

  • 除 SSE 外,所有响应的 Content-Type: application/json; charset=utf-8

  • 所有请求体上限 64 KiB(与现有 IPC 上限一致)

  • 所有响应都带 Cache-Control: no-store

  • 服务端给出 Connection: close 或正确的 Content-Length,连接的结束方式由响应本身说明,不靠客户端猜

  • 未知路径返回 404 + 标准错误信封,返回 HTML 错误页会让两端的错误处理分叉

3.1 GET /v1/info 响应(完整字段表)

这是唯一未鉴权且会被频繁探测的接口(发现流程可能每几秒打一次),所以字段就到这里为止——多一个字段,每次探测的成本都跟着涨

{
  "v": 1,
  "app": "NiskleHub",
  "product": "Niskle Hub",
  "version": "1.9.0",
  "instance_id": "b1f0c3d4-…",
  "name": "DESKTOP-ABC",
  "paired": false,
  "port": 21120,
  "addresses": ["192.168.1.10", "10.0.0.5"],
  "uptime_s": 3821
}
字段类型说明
vint最高支持的协议主版本
appstring固定 "NiskleHub",用于识别
productstring展示名,用于头显列表
versionstringHub 版本号,用于提示更新
instance_idstring安装唯一标识(UUID),发现去重与未来中继路由都用它。取值沿用仓库既有的 install_idupload.rs:21-39,存在 %APPDATA%\FrameFace\config.json),避免同一个安装出现两个不同 id
namestringPC 主机名,用于头显列表
pairedbool该 Hub 是否已存在至少一个已配对客户端。这是 Hub 级状态,不是「本客户端是否已配对」
portint控制 API 的 TCP 端口。客户端取这个值,不假设 21120
addressesstring[]Hub 认为自己可达的 IPv4 列表,按优先级排序,排序规则见下。见 §10.3
uptime_sint运行时长,用于「PC 端刚重启过」这类提示

addresses 的排序规则(不定义的话,顺序会直接决定头显先试哪个地址,体验差别很大):

  1. 物理网卡上的私有地址(10/8172.16/12192.168/16)——最常见可达

  2. 其它私有地址(虚拟网卡、VPN、Hyper-V、WSL)

  3. 公网地址

  4. APIPA(169.254/16)——几乎一定不可达,放最后

排除项:回环、未 Up 的接口、0.0.0.0

实现提示:Windows 上用 GetAdaptersAddresses 取接口列表比较可靠。 该 API 需要 windows-sys 增加对应 feature——两个相关 crate(windows-syswindows-link) 都已在离线缓存与 Cargo.lock 中,不引入新依赖。

不返回的字段:操作系统版本、用户名、已配对设备名与数量、网络接口详情、任何路径或密钥。 paired 之所以在返回之列,是因为头显据此判断「要不要引导用户去 PC 上点配对」, 它不泄露任何可用于攻击的信息(配对仍然依赖 PC 上显示的那 6 位码)。

3.2 POST /v1/unpair

已鉴权客户端主动解除自己与 Hub 的配对。请求 { "v": 1 },成功返回 { "v": 1, "ok": true }。 Hub 收到后立即吊销该令牌并从配对列表移除,客户端拿到成功响应后清除本地令牌。

用途:用户在头显上点「忘记这台电脑」。没有这个接口,解除配对就只能回到 PC 上操作。


4. 信封格式

4.1 请求

{ "v": 1, "id": 17, "op": "tracking.start", "args": {} }
字段类型必填说明
vint协议主版本。服务端不支持的版本返回 unsupported_version
idint客户端自增,>0。响应原样回带,用于关联
opstring操作名,形如 domain.verb
argsobject缺省视为 {}未知字段一律忽略

4.2 成功响应

{ "v": 1, "id": 17, "ok": true, "t": 1737000000123, "result": { } }

4.3 错误响应

{ "v": 1, "id": 17, "ok": false, "t": 1737000000123,
  "error": { "code": "busy", "message": "体感追踪正在启动", "retry_after_ms": 800 } }

error 字段:

字段必填说明
code封闭枚举,见 §4.4
message面向人的中文说明,可直接展示在头显上
fieldinvalid_args 时指出出错字段
retry_after_msbusy / unavailable 时建议的重试间隔

retry_after_ms 的取值契约(Link 端直接照此做退避,所以这三个数字是固定的):

规则
缺省值(服务端没给时客户端采用)500 ms
上限(服务端不给出更大的值,客户端超过则按上限截断)3000 ms
客户端行为等待该时长后自动重试一次;仍为 busy 则把控件置为「忙碌」并提示用户稍后手动重试,不做无限自动重试

长耗时 operation 与这个上限的冲突(已知问题,实现时实测):

body.start 在必要时先执行 kill_orphan_servers(内含 800 ms 等待,slimevr_manager.rs:231-237) 再拉起 Java 进程,冷启动可能接近或超过 3 秒。按上面的规则,一次正常操作会被呈现成「忙碌」。

两种处理方式,二选一:

方式说明
放宽该 operation 的上限在注册表里允许 per-op 覆盖 retry_after_ms 上限,body.start 之类可用更大的值
改为「先受理、后通知」operation 立即返回 { "accepted": true },实际结果通过 notice 主题推送。对用户体验更好,但客户端需要多一条状态路径

建议先用 P1 阶段实测 body.start 冷启动耗时,再据此决定;实测数据出来之前,两种都先不做。

4.4 错误码(v1 封闭集)

codeHTTP含义Link 应如何反应
bad_request400JSON 或信封格式错误视为客户端 bug,记日志
unsupported_version400v 不被支持提示「PC 端版本不兼容」
unauthorized401令牌缺失/失效清除本地令牌,回到配对流程
forbidden403操作存在但该客户端无权提示无权限
unknown_op404不认识的操作名提示「PC 端版本过旧,需要更新」
invalid_args400参数校验失败高亮 field
busy409同一资源上有操作在进行retry_after_ms 重试,按钮置 loading
unavailable503子系统未运行(如 SlimeVR 未启动)提示先启动对应服务
timeout504操作超时提示重试
internal500未预期错误展示 message 并建议看 PC 端日志

规则unknown_op 不触发断连。断连会让老客户端彻底失去连接,而这里只需要把对应功能置灰。


5. 鉴权与配对

5.1 配对流程

PC 端 Hub                          头显端 Link
   │                                   │
   │  用户在 PC 设置里点「配对新设备」    │
   │  → 生成 6 位数字码,TTL 120 秒      │
   │  → 在窗口上显示(大字号)           │
   │                                   │
   │ ◄────── GET /v1/info ─────────────┤ 探测(未鉴权)
   │ ─────── { paired:false, ... } ────►│
   │                                   │ 用户在头显输入 6 位码
   │ ◄────── POST /v1/pair ────────────┤
   │         { code, client:{…} }      │
   │ ─────── { token, client_id } ────►│ 存入安全存储

5.2 POST /v1/pair

请求:

{
  "v": 1,
  "code": "042317",
  "client": {
    "id": "6f1c2f0e-9a3d-4b7e-8c11-2f4a9b0d1e55",
    "name": "Pico 4 · 客厅",
    "platform": "android",
    "app_version": "1.9.0"
  }
}

成功响应:

{
  "v": 1, "ok": true,
  "token": "9tR2…(base64url,32 字节随机)",
  "client_id": "6f1c2f0e-…",
  "hub": { "product": "Niskle Hub", "version": "1.9.0", "name": "DESKTOP-ABC", "instance_id": "…" }
}

失败:标准错误信封。配对码错误返回 forbiddenmessage 说明剩余尝试次数。

client.id 的责任划分(不统一的话,Hub 端会出现同一台设备的重复条目):

问题规定
谁生成Link(客户端)生成,UUID v4
何时生成首次发起配对之前,且此后永不变更(即使重新配对同一台设备也复用)
存哪里与令牌一起存在 Link 的安全存储里
Hub 行为若收到的 client.id 已存在,视为同一设备重新配对 → 吊销旧令牌、签发新令牌、保留该 client.id不新增条目
显示名重配对时忽略请求里的 name,沿用 Hub 端已保存的显示名。原因是 PC 端可以把设备重命名(client.rename),若每次都采用请求里的名字,用户改的名字会在重配对后被打回去
配额重配对不占新配额。已满 16 个时,重配对仍然放行
为什么不由 Hub 生成否则设备重装 App 后会变成两个条目,用户无法分辨该吊销哪个

5.3 配对码规则(实现侧的约束)

规则理由
位数6 位数字头显里手输的极限;纯数字可用数字键盘
有效期120 秒够输完,不够被隔壁房间试探
使用次数一次性,成功即作废防重放
错误尝试最多 5 次,超出即作废并重新生成防暴力枚举(10^6 空间下 5 次尝试成功率 5e-6)
展示只显示在 Hub 窗口,不进信标、也不进日志信标是广播,同网任何设备都能看见
生成密码学随机(rand crate,已离线可用)

5.4 令牌规则

规定
生成32 字节密码学随机 → base64url
传输Authorization: Bearer <token>(仅局域网明文 HTTP,见 §9)
存储(Hub)只存 sha256(token),明文不落盘
存储(Link)见 Link 方案文档(用 Keystore 包装,不明文落盘)
比较常量时间subtle crate 已离线可用)
生命周期长期有效,直到 Hub 端吊销或客户端主动 POST /v1/unpair
吊销Hub 设置页可查看已配对设备列表并逐个吊销
上限最多 16 个已配对客户端;已达上限且是新设备时,POST /v1/pair 返回 forbiddenmessage 说明需要先在 PC 端吊销一个(重配对不受此限,见 §5.2)

6. 能力清单(Capability Manifest)

6.1 定位:只做门控,不做渲染

这是本协议最重要的设计决策之一。

Hub 通过清单声明自己支持哪些操作、每个操作的参数形状、属于哪个分组、是否危险。 Link 的界面永远来自手写的 Compose 代码,不从清单生成

清单的用途仅有三条:

  1. 能力门控:Link 判断某个手写界面的控件是否能启用(op 不存在 → 置灰并提示更新 PC 端)

  2. 文案复用:分组标题、操作标题以 Hub 为准,保证两端叫法一致

  3. 版本容忍:老 Link 遇到新 Hub 时优雅降级,而不是报错

为什么不用自动渲染:自动生成的表单在 VR 里的可用性、层级、密度都不可控, 且一旦清单设计有缺陷,两端会同时出问题且难以定位。手写界面 + 清单门控是更稳的组合。

6.2 GET /v1/capabilities 响应

{
  "v": 1,
  "hub": {
    "product": "Niskle Hub",
    "version": "1.9.0",
    "instance_id": "b1f0…",
    "name": "DESKTOP-ABC"
  },
  "operations": [
    {
      "op": "tracking.start",
      "group": "tracking",
      "title": "启动追踪",
      "effect": "action",
      "confirm": false,
      "since": 1,
      "args": {}
    },
    {
      "op": "face.set_sensitivity",
      "group": "face",
      "title": "表情灵敏度",
      "effect": "write",
      "confirm": false,
      "since": 1,
      "args": {
        "type": "object",
        "required": ["parameter", "percent"],
        "properties": {
          "parameter": { "type": "string" },
          "percent":   { "type": "integer", "minimum": 0, "maximum": 200 }
        }
      }
    }
  ],
  "groups": [
    { "id": "tracking", "title": "面部追踪", "order": 10 },
    { "id": "body",     "title": "体感追踪", "order": 20 }
  ],
  "events": [
    { "topic": "status", "title": "运行状态", "since": 1, "default_hz": 2, "max_hz": 5 },
    { "topic": "notice", "title": "通知",     "since": 1 }
  ]
}

字段说明:

字段说明
effectread / write / actionaction 表示有副作用,Link 据此给即时反馈
confirmtrue 时 Link 弹二次确认(Link 自己的确认弹层,不是 PC 弹窗)
since该操作从哪个协议版本开始存在。Link 若只支持到 v1,忽略 since > 1 的项
deprecated可选。true 表示即将移除,Link 据此隐藏入口
args裁剪过的 JSON Schema 子集:只允许 type / properties / required / items / enum / minimum / maximum。这是给 Link 做参数校验的,不是渲染指令
order分组排序权重,小的在前

args 故意限制为 JSON Schema 的一个小子集:oneOf / $ref / pattern 这类关键字不在这里出现, 否则两端校验逻辑会分叉。

6.3 为将来的串流能力预留(现在不实现,但形状现在就定好

产品路线是 Link 成为头显端串流接收端(对标 Virtual Desktop)。届时能力清单需要多一个 streaming 对象。字段名和语义现在定下来,将来加它就是填字段而不是改协议版本。

预留形状(v1 不返回此字段;客户端见到未知字段按 §11.2 忽略):

"streaming": {
  "available": true,
  "encoders": ["nvenc", "amf", "qsv", "software"],
  "codecs": ["h264", "hevc", "av1"],
  "max_resolution": [3840, 2160],
  "max_fps": 120,
  "hdr": false,
  "audio": ["aac", "opus"],
  "transport": ["udp"],
  "virtual_display": true,
  "session_state": "idle"
}

对应的操作也现在就把名字登记进 §12 的命名空间,但 v1 不实现(since 留到 v2 再填):

预留 op用途
stream.capabilities单独查询串流能力(比塞在总清单里更适合带参数的探测)
stream.start建立串流会话(参数:分辨率、帧率、码率、编码器偏好)
stream.stop结束会话
stream.stats实时码率/延迟/丢包
stream.display.set切换虚拟显示器分辨率/刷新率
stream.audio.route音频路由选择

为什么现在只登记名字不实现: 命名空间一旦被占用就不能再改(§12 命名规则)。先把 stream.* 这一片占住, 将来加串流时就是「填实现」而不是「改协议」,也就不会破坏已发布的 Link 版本。

媒体面本身不走本协议:音视频是 UDP 上的独立数据流,有自己的端口、拥塞控制与时间戳规范。 控制面只负责「协商参数 + 会话生命周期 + 统计回报」。 详见 §15。


7. 并发、限流与串行化

这一节直接对应 §1.3 的约束 3 和 4。没有这一节,协议在真实使用中会以两种可预期的方式出错。

7.1 服务端的限流

限制建议值理由
单客户端在途请求4防止一个客户端占满
单客户端请求速率20 次/秒远高于人工操作,低于能压垮 Engine 的速率
单客户端 SSE 订阅主题全部(≤8)
配对尝试5 次/码(见 §5.3)

超出限流返回 busy + retry_after_ms,静默丢弃会让客户端以为请求已经受理。

7.2 按资源串行化(下沉到 Rust)

现状:互斥只写在前端(slimevrBusy 等),Rust 没有。头显和桌面 UI 同时点「启动体感追踪」会打架。

要求:串行化按资源分组做在 Rust 侧,桌面 UI 与远程共用同一把锁。

资源组覆盖操作
enginetracking.*face.*capture.*
vrcftvrcft.*
bodybody.*
updateupdate.*

同一资源组上已有操作在途 → 新请求立即返回 busy(不排队)。不排队是刻意的: 因为 Engine 命令队列溢出时丢弃最旧,排队会让远程请求悄悄挤掉桌面用户的操作。

7.3 与桌面 UI 的状态一致性

远程操作改变了状态后,桌面 UI 立刻反映,方式是服务端在每次状态变化时同时推给桌面 WebView(已有的 frameface://status 事件)和 LAN SSE 订阅者。 依赖桌面 UI 的 2 秒轮询会留下一个短暂的不一致窗口。

已知现状:桌面 UI 目前 2 秒轮询 get_tracking_statusmain.ts:3220-3227), 150 毫秒轮询骨骼(:3234)。这两处轮询照旧,新增的只是推送通道。


8. 事件流(SSE)

8.1 订阅

GET /v1/events?topics=status,notice&hz=2
Authorization: Bearer <token>
Accept: text/event-stream
参数说明
topics逗号分隔。缺省 = 全部
hzstatus 主题的推送频率上限(1–5)。缺省取清单里的 default_hz

8.2 报文格式

retry: 3000

event: status
data: {"v":1,"t":1737000000123,"status":{…}}

event: notice
data: {"v":1,"t":1737000000200,"level":"error","text":"引擎已断开,正在恢复","sticky":false}

: keepalive
规则说明
心跳15 秒发一行 : keepalive 注释,防中间设备断连
连接时立即推送一次 status 全量快照,客户端不需要额外拉取
retry: 时机只在每条 SSE 连接建立时发一次(重连时不重发)。客户端把该值按 host:port 持久化,下次冷启动首次重连时先用它,再根据实际失败情况退避
重连客户端退避重连:1s → 2s → 4s → 8s → 16s → 上限 30s。若服务端给了 retry:,以服务端值为起始间隔
读超时客户端的读超时取大于心跳间隔的值(建议 40 秒)。小于心跳间隔的话,服务端半死(TCP 通但不发数据)时客户端会永久挂住、永不重连
断线语义事件流断开不改变已配对状态,客户端显示的是「连接中断」而不是「未配对」
排序事件按产生顺序发送;不保证跨主题的全局顺序

8.3 主题定义(v1)

主题载荷说明
status{ status: <TrackingStatus> }直接复用已有的 TrackingStatus 结构contracts.rs:159-174)。它已经是后端中立、已脱敏、且对新增字段容忍的
notice{ level, text, sticky }用户可见通知。对应 Hub 现有的全局 toast 系统(3 秒自动消失),头显里同样弹 toast。sticky:true 表示需要用户手动确认才消失

notice 的限流与 sticky 规则(头显里弹窗很容易变成骚扰,所以这些数值在这里定死):

规则
普通通知(sticky:false)展示时长3 秒,与 Hub 桌面端一致
sticky:true 的展示时长最长 30 秒后自动降级为可关闭的常驻条,永久占据屏幕是更糟的体验
sticky:true 同时在屏数量最多 1 条,新的顶掉旧的(旧的降级为普通 toast)
服务端限流同一 text 在 10 秒内只推一次(去重);整体不超过 5 条/10 秒
客户端限流队列最多 4 条同时在屏,其余丢弃;重复文本合并为「×N」计数
断线重连后不重发历史通知。通知是瞬时事件,不是状态

其余主题:

主题载荷说明
clients{ clients: [ { client_id, name, paired_at, last_seen_at, online } ] }已配对设备列表变化
update{ phase, received, total, version }OTA 进度

参考实现:Hub 桌面端已有这套逻辑(main.ts:345TOAST_DURATION_MS = 3000main.ts:382 的「最多 4 条」、以及文本去重计数)。 头显端按同样的规则实现,两端行为一致,用户在两处看到的东西才一样。

notice 主题是头显场景里价值最高的一个:用户在 VR 里看不到 PC 屏幕, 出错时只能靠通知。

v1 的可达范围要说清楚:Hub 现有的大量提示调用点在前端(main.ts:351-364notifymain.ts:401notifyEngineError),而前端与 Rust 之间只有命令通道, Rust 侧看不到这些调用。因此 v1 的 notice 只覆盖 Rust 能观测到的事件 (引擎断开与恢复、服务启停、更新进度、配对变化等)。

想要把前端提示也转发到头显,需要在后续阶段新增一条从 WebView 到 Rust 的通知上报命令, 并同步命令面(commands.rs / lib.rs / 权限清单 / build.rs 四处,verify-commands.ps1 会校验)。 这件事可以独立于本协议推进,不影响 notice 的报文格式。

status 的推送要做变化检测 + 节流TrackingStatus 里含 timestamp_mssequence, 逐字段比较会永远认为「变了」。判断依据是「除 timestamp_ms/sequence 外的字段是否有变化」, 同时不超过 hz 上限。


9. 安全要求

9.1 监听范围

  • 默认只监听局域网接口,不监听公网

  • 启动时检测 Windows 网络配置文件:处于「公用网络」时默认拒绝启动并在设置页说明原因(可显式覆盖)

  • 端口可配置;配置后同步写入 Windows 防火墙入站规则(或明确提示用户手动放行)

9.2 鉴权边界

端点鉴权
GET /v1/info无。只返回身份与 paired 布尔,设备名、版本之外的细节不出现
POST /v1/pair配对码(§5.3 限流)
其它全部Bearer 令牌

不存在任何未鉴权的可变操作。

9.3 日志与隐私

  • 令牌、配对码不写进日志,也不明文落盘

  • 配对码只在内存中保存,过期即清

  • /v1/info 只暴露到 PC 主机名为止,系统信息不再往外走

9.4 危险操作的标注

以下操作带 confirm: true,且 title 写清后果:

操作头显上显示的确认文案
body.stop「停止体感追踪会结束 SlimeVR 服务。注意:该操作会强制结束本机所有 Java 进程。」
body.start「将启动 SlimeVR 服务(Java)。」
capture.clear「将永久删除已采集的数据,无法恢复。」
update.install「PC 端将下载并安装更新,安装期间 Hub 会关闭且不会返回结果。」
client.revoke「将断开该设备的连接,该设备需要重新配对。」

9.5 顺带处理既有风险

§1.3 约束 7 提到的既有问题可以随本协议一并处理:

  • 21112 桥接目前监听 0.0.0.0 且无鉴权。改为只监听 127.0.0.1 即可 (Rust 侧本来就只连回环,见 slimevr_manager.rs:245-249), 功能不受影响,同时消掉一个未鉴权的局域网命令通道。

  • --

10. 发现机制(Discovery)

10.1 Hub 广播信标

Hub 每 2 秒255.255.255.255:37022 广播一次 UDP:

{
  "app": "NiskleHub",
  "proto": 1,
  "port": 21120,
  "name": "DESKTOP-ABC",
  "ip": "192.168.1.10",
  "paired": true,
  "instance_id": "b1f0…",
  "ts": 1737000000123
}
规则说明
信标端口固定为 37022/UDP,永不可配置否则客户端不知道该去哪听。可配置的只有控制 API 的 TCP 端口,它由信标里的 port 字段告知
信标里不放密钥它是广播,同网任何设备可见
paired让客户端知道是否还需要走配对
大小不超过 1024 字节
校验接收方校验来源 IP 是「安全 IPv4」(首字节非 0/127,<224,非 255.255.255.255),沿用 FrameCast 的既有做法(framecast_beacon.cpp:109-137
多网卡Hub 在每块可用网卡上各广播一份,且每份的 ip 字段填该网卡的地址(不是 0.0.0.0

按顺序尝试,前一步成功就不做下一步——这是「不写死」在发现层的体现:

  1. 记住的上次成功端点(持久化)→ 直接 GET /v1/info 探测

  2. 从既有入站连接学习:PC 端 Engine 会主动连接 Link 的 MJPEG 服务器 (mjpeg_http_client.cpp:171-174),Link 因此天然知道 PC 的 IP, 可直接拿这个 IP 试 21120 端口。零新增机制,这是最省事的一条路

  3. 监听 Hub 广播信标(37022)

  4. 手动输入 IP(兜底手段,VR 里手输体验很差,但缺了它就没有退路)

第 2 条值得强调:现有产品里 PC 是去连手机的,所以手机已经知道 PC 的地址。 控制通道只是把这个已知信息用起来。

10.3 多网卡 PC 的处理(候选地址不止一个)

一台 PC 常见同时有以太网 + Wi-Fi + 虚拟网卡(VPN、Hyper-V、WSL、VirtualBox)。 从入站连接学到的那个地址不一定是 Hub 监听控制端口的那个网卡。 因此客户端的候选地址是集合,逐个尝试而不是只试一个:

来源可信度说明
/v1/infoaddresses 数组由 Hub 自己枚举可用网卡并按优先级排序,首选
信标里的 ip该信标就是从那块网卡发出的,一定可达
入站连接学到的源地址可能来自 Hub 未监听的网卡(如 VPN 或 Hyper-V 虚拟网段),作为补充候选
用户手输兜底

客户端行为:

  1. 把上述所有来源的地址合并去重成一个候选列表(/v1/info 与信标的排前面)

  2. 候选列表上并发探测 GET /v1/info(每地址超时 1 秒),取最先成功的那个

  3. 记住成功的地址,下次优先直连

  4. 地址变化时(探测失败)重新走一遍上述流程,而不是死守旧地址

这一条同时解释了 /v1/infoaddresses 的用途(§3.1): 这是解决多网卡问题最干净的办法,且不需要客户端做网段扫描。

10.4 为未来中继预留的形状

本阶段不实现,但协议形状已兼容:

  • 若未来接入 hcen.tech 中继,业务语义完全不变,只是把 POST /v1/rpc 的目标从 http://<PC-IP>:21120 换成 https://<relay>/hub/<instance_id>/v1/rpc

  • 因此:所有端点路径都不含 Hub 的 IP 或主机名(已满足)

  • instance_id 已存在于 /v1/info 与能力清单中,可直接作为中继路由键

  • 唯一需要新增的是「谁有权通过中继访问」——那是中继侧的事,不影响两端实现

  • --

11. 演进规则(这一节决定协议能不能长期活下去)

11.1 兼容性矩阵

变更是否升版本说明
新增一个 op❌ 不升老客户端看不见它,天然安全
给已有 op 新增可选参数❌ 不升服务端给出默认值即可
result 里新增字段❌ 不升客户端忽略未知字段
新增 event 主题❌ 不升客户端只订阅自己认识的
给错误码集合新增值❌ 不升客户端把未知 code 当 internal 处理
新增必填参数✅ 升主版本老客户端会 invalid_args
删除/重命名 op✅ 升主版本
改变已有参数语义✅ 升主版本
换传输层(HTTP→其它)✅ 升主版本但路径前缀已预留

11.2 三条铁律

  1. 路径里带主版本/v1/…)。新版本并行提供,/v1/v2 发布后至少再保留两个版本

  2. 未知一律忽略:未知字段忽略、未知参数忽略、未知错误码当 internal、未知主题忽略。 唯一的例外是未知 op → 返回 unknown_op(而不是断连)。

  3. since 是契约的一部分。每个 op / event 都标出它从哪个版本开始存在, 客户端据此做能力判断。漏标 since 的后果就是兼容性判断失效。

11.3 弃用流程

v1.9  op 标记 { "deprecated": true, "removed_in": 2 }  → Link 隐藏入口,Hub 仍执行
v2.0  op 从 v2 移除,但 /v1 继续支持                          → 老 Link 仍可用
v2.2  /v1 下线                                                → 强制老 Link 升级

11.4 已识别的漂移风险(实现时要防住的地方)

审计发现当前代码库已经存在这类问题,新协议不能重蹈覆辙:

既有问题教训
contracts/ipc-v1-command.schema.json:32 漏了 total_max_frames,而 protocol.cpp:468-474 接受它、engine_manager.rs:459-461 发送它规范与实现已经漂移了。新协议需要一个可执行的校验点
21112 桥接的推送间隔:注释与文档写 500ms,实现是 50ms(NiskleHubStatusBridge.kt:36,199文档里写死的数值需要单一来源
命令名在 4 个文件 + Engine 枚举 + 前端字面量里手工同步,靠正则脚本 verify-commands.ps1 兜底手工同步必然漂移

因此要求

  • Hub 端的操作清单是代码里的单一注册表,能力清单由它生成,不再手写第二份(详见 Hub 端方案)

  • 联调时有一个探针脚本(沿用仓库现有的 tools/*_probe.py 习惯)能列出实际暴露的操作并与文档对照

  • --

12. 操作清单 v1

命名规则:domain.verb,全小写,单词间下划线。操作名一旦发布就不再改动(改名等于删除+新增)。

12.1 分组与操作

opeffectconfirmargs说明
hub
hub.statusread完整 TrackingStatus 快照
hub.performancereadPerformanceStats(帧率/延迟/质量)
hub.inforead身份、版本、运行时长
tracking
tracking.startaction启动已启用的追踪管线
tracking.stopaction停止全部追踪
tracking.set_face_enabledwriteenabled: bool开关下半脸追踪
tracking.set_eye_enabledwriteenabled: bool开关眼动。当 TrackingCapabilities.eye_tracking == false 时返回 unavailable + 「当前硬件版本尚未支持眼动追踪」,因为静默接受会让头显显示一个实际没有生效的开关
face
face.set_sensitivitywriteparameter: string, percent: int(0-200)单参数灵敏度
face.set_filterwriteenabled: bool, percent: int(0-100)时间滤波
vrcft
vrcft.statusreadVRCFT 服务状态
vrcft.openaction启动内置 VRCFT
vrcft.stopaction结束 VRCFT 进程
framecast
framecast.statusread视频链路状态与指标
body
body.statusreadSlimeVR 服务状态与端口占用
body.devicesread追踪器列表、骨骼、比例
body.startaction启动 SlimeVR 服务(会拉起 Java
body.stopaction停止 SlimeVR(taskkill /F /IM java.exe /T
body.resetactionmode: "yaw"|"full"|"mounting"|"mounting_clear"|"pause"重置追踪
body.assign_trackerwritename: string, designation: string分配部位
body.set_proportionswritebone: string, value: number设置骨骼比例
body.autoboneactionaction: "record"|"stop"|"cancel"|"process"|"apply"|"reset"自动测量流程
body.pairwritemac: string记住追踪器
body.unpairwritemac: string遗忘追踪器
body.set_oscwritekey: string, value: stringOSC 配置
capture
capture.statusread采集状态
capture.set_enabledwriteenabled: boolinterval_ms?: int(0–60000)max_frames?: int(1–8192)total_max_frames?: int(1–8192)数据采集开关。这三个边界取自 engine_manager.rs:142-150;它们恰好也是 contracts/ipc-v1-command.schema.json:32 漏掉字段的同一处,取值以注册表为准
capture.clearaction删除已采集数据(不可恢复)
update
update.checkreadchannel: "stable"|"beta"查询更新
update.installactionchannel: string下载并安装。没有完成信号updates.rs:136-138 把控制权交给 NSIS,进程随后退出,调用方无法区分「成功 / 失败 / 还在下载」。这里定为:立即返回 { "started": true },进度走 update 事件主题,连接断开即表示安装已开始
client
client.listread已配对设备
client.revokeactionclient_id: string吊销某设备
client.renamewriteclient_id: string, name: string重命名设备
app
app.showaction把 Hub 窗口带到前台(VR 里最实用的一条
app.hide_to_trayaction最小化到托盘

12.2 刻意暴露的操作

原命令理由
quit_app不可逆(一次性 latch + app.exit(0))。若将来要做,须设计成 confirm:"pc"——即确认弹窗出现在 PC 端,头显无权单独执行
wifi_provision_slimevr携带 Wi-Fi 凭据,且是 USB 本地操作,远程无意义
detect_slimevr_serial枚举 PC 串口,泄露主机设备清单,远程无意义
minimize_window / close_window对看不见 PC 屏幕的人无意义(app.hide_to_tray 已覆盖需求)

12.3 已登记但 v1 不实现的操作(命名空间占位)

这些名字现在就占住,将来实现串流时直接填实现,不需要改协议、也不需要破坏已发布的 Link。

预留 op所属分组计划版本
stream.capabilitiesstreamv2
stream.startstreamv2
stream.stopstreamv2
stream.statsstreamv2
stream.display.setstreamv2
stream.audio.routestreamv2

v1 的行为:这些 op 不出现在能力清单里(因为还没实现)。 老 Link 若误发,Hub 返回 unknown_op——这是正确且安全的反应。 新增分组建议:{ "id": "stream", "title": "串流", "order": 30 }


13. 实现顺序建议

阶段内容可验证的结果
P0Hub:/v1/info/v1/pair、令牌存储、/v1/rpc 骨架 + hub.status 一个操作用 curl 完成配对并读到状态
P1Hub:能力清单生成 + hub/tracking/face 分组全部操作 + 串行化与限流探针脚本能跑通全部操作
P2Hub:SSE 事件流(status + noticecurl 能看到实时事件
P3Hub:UDP 信标广播抓包能看到信标
P4Link:发现 + 配对界面 + 状态页头显上能看到 PC 状态
P5Link:追踪/面捕控制页 + 通知 toastVR 里能开关追踪并看到报错
P6Link:体感追踪页(设备列表、分配、校准)完整覆盖当前功能
P7Link:更新页 + 已配对设备管理

P0–P3 不需要 Link 就能开发和验证(全部用 curl / 探针脚本)。 先把 Hub 端做完并用探针脚本留下证据,再动 Link,联调时就能立刻区分是哪一端的问题。


14. 附:与既有协议的关系

既有通道归谁本协议的关系
\\.\pipe\FrameFace.Engine.v1Rust Host ↔ Engine不动。本协议不直接碰它
\\.\pipe\FrameFace.Expressions.v1Engine → VRCFT不动
127.0.0.1:21112 SlimeVR 状态桥Rust ↔ SlimeVR Java不动(但建议改为只监听回环,§9.5)
UDP 37020/37021 FrameCast 视频Link → Hub Engine不动。本协议只在 37022 新增自己的信标
本协议 21120 / 37022Link ↔ Hub Rust Host新增

重要:本协议是桌面 UI 的平行入口,不是替代品。 头显与桌面 UI 调用的是 Rust Host 里同一套管理器和同一份状态, 所以两者共用串行化(§7.2)是必要的,否则两端会互相打架。


15. 与未来串流功能的关系(架构影响,这一节影响的不只是本次实现)

15.1 为什么现在就要考虑

产品路线已经明确:Link 将来是头显端串流接收端,Hub 是 PC 端串流服务端, 对标 Virtual Desktop 的两端。这不是一个小功能,它会改变 Hub 的进程模型。

如果不现在处理,最可能的返工是:控制面的监听器被写死在 Tauri 应用进程里, 而串流要求 PC 侧有一个常驻的、能独立于 GUI 运行的服务(VD 的 PC 端就是服务)。 到时候要么把控制面代码整体搬家,要么被迫接受「GUI 不开就没法串流」。

15.2 对本次实现的约束(只有一条,但它是这一节的全部意义)

控制面的核心逻辑不依赖 Tauri AppHandle,能被任意宿主进程承载。

具体做法:把远程控制模块分成两层——

remote/
  core/         ← 纯逻辑:协议解析、鉴权、配对、能力清单、限流、串行化
                  只依赖「操作注册表」提供的抽象接口,不 import tauri
  host_app/     ← 宿主适配层:把 Tauri 的 AppHandle / State<Arc<EngineManager>>
                  适配成 core 需要的接口;在 Tauri setup 时启动
  (未来)
  host_service/ ← 将来 Windows 服务里的宿主适配层,复用同一份 core/

这样将来做串流时,只需新增 host_service/,把 core/ 原样搬过去, 协议与两端实现都不用改

15.3 媒体面(未来)需要控制面提供什么

现在不实现,但设计时把这些后续需求考虑进去:

媒体面需求控制面提供的东西本协议的现状
协商编码器/分辨率/码率能力清单里的 streaming 对象(§6.3)形状已预留
会话生命周期stream.start / stream.stop名字已登记
交换媒体面的传输地址与端口stream.start 的返回值里带上候选地址与端口端点路径不含 IP(§10.4),不冲突
实时统计stream.stats 操作 + 可选的 stream 事件主题事件主题机制已通用
串流中途断线重连控制面连接与媒体面连接独立,互不拖累本文已按此设计:SSE 断开不影响配对
权限区分(能控制 ≠ 能串流)forbidden 错误码 + 每客户端权限位错误码已就位,权限位待加

15.4 需要产品侧确认的一件事(尚未确认)

PC 端将来是否需要常驻服务? 这会影响本次实现的落点:

方案本次实现将来加串流时
现在只做在 Tauri 应用里(本文默认)简单,Hub 开着就能控制需要把 core/ 搬到服务里(因为 §15.2 已经隔离,成本可控)
现在就直接做独立服务复杂得多:多一个进程、多一套安装/开机自启/权限提升、多一个攻击面平滑

建议:本次按「做在 Tauri 应用里」实现,但分层照 §15.2 执行。 理由是本次的目标(在 VR 里操作 Hub)本身要求 Hub 正在运行—— 面捕在使用中,Hub 必然是开着的。等到真正做串流时再引入服务, 那时 core/ 已经是可以直接搬的独立模块。

注:用户已确认「Hub 没运行时不需要头显把它拉起来」,与上面的建议一致。

15.5 端口预算(避免将来撞车)

将来媒体面会需要自己的端口。现在就把「谁用什么」记下来,避免随手占用:

用途端口归属
FrameCast 视频发现 / 视频UDP 37020 / 37021Link → Hub(现有)
本文控制面TCP 21120新增
本文发现信标UDP 37022新增
SlimeVRUDP 6969、TCP 21110 / 21112现有
OSCUDP 9000 / 9001 / 9003现有
Link MJPEGTCP 18980(默认)现有
未来媒体面待定,建议 UDP 37100–37199 区间未实现

划定 37100–37199 是刻意的:远离上面所有已用端口,且与 FrameCast 的 3702x 相邻便于记忆。 现在只需在文档里占位,不需要实现。