01-互联协议规范-v1
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 绕不开的既有约束(来自代码审计,不是猜测)
这些是硬约束,方案只能绕开,假装它们不存在会在实现时全部回来:
LAN 服务只能做在 Rust Host 里。 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。Rust 侧的响应无法关联。 出站方向其实带
request_id(engine_manager.rs:434-438会写进命令信封), 但入站的解析结构ProtocolEnvelope(contracts.rs:240-248)里没有这个字段, 所以回包从来没有被关联过。结论不变:LAN 层需要自带id关联,不能指望复用现有结构。Engine 命令队列只有 32 深,且溢出时丢弃最旧的一条 (
engine_manager.rs:23,419-422)。 这意味着一个不节制的 LAN 客户端可以静默丢掉桌面用户自己的操作。 → 协议据此规定服务端限流与串行化(§7)。互斥目前只存在于前端。
slimevrBusy、pairingInProgress都写在main.ts:740-805,1256-1291,Rust 侧没有任何等价物。 → 头显和桌面 UI 并发调用会直接打架。串行化只有下沉到 Rust 才管得住两端(§7.2)。部分操作具有机器级破坏性。
start_slimevr/stop_slimevr会间接触发taskkill /F /IM java.exe /T与javaw.exe(slimevr_manager.rs:231-236)。 远程暴露body.stop等于「一条命令杀掉这台机器上所有 Java 进程」。不可逆操作存在。
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), 远程调用方无法区分成功与失败。已经存在一个未鉴权的局域网命令通道。 SlimeVR 状态桥接监听
0.0.0.0:21112(NiskleHubStatusBridge.kt:185,576), 接受明文wifi_provision:<ssid>:<password>、reset:、assign:等命令,没有任何鉴权。 → 这不是本协议引入的问题,但可以随本协议一并处理(见 §9.5)。离线构建约束(这一条踩过坑,值得逐行看)。 仓库用
CARGO_HOME=<repo>\.cargo-home离线构建。经解包核实,缓存中已存在:hyper 1.11.0、hyper-util 0.1.20、http-body-util 0.1.4、http 1.5、bytes、tower 0.5、tokio 1.53、tokio-util、rustls 0.23、tokio-rustls、ring、sha2、subtle、rand、base64、zeroize、serde、serde_json。缓存中不存在:
axum、tungstenite、tiny_http、rcgen、hmac、qrcode。⚠️ 关键陷阱:
hyper的serverfeature 依赖httpdate,而离线缓存与Cargo.lock里都没有它。 证据(解包hyper-1.11.0.crate的Cargo.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 TCP | curl / 浏览器 / Postman 可以直接调试;这一条对长期维护的价值最大 |
| 编码 | JSON(UTF-8) | 两端都已有现成的 JSON 能力(serde_json / org.json) |
| 请求 | POST /v1/rpc | 单一入口,新增操作不需要新增路由 |
| 下行推送 | SSE(text/event-stream) | SSE 是 HTTP 的子集,不需要 WebSocket crate |
| 服务发现 | UDP 广播信标 | 复用仓库既有的 FrameCast 信标习惯 |
依赖成本(两端不对称,这里说清楚):
| 端 | 新增依赖 |
|---|---|
| Link(Android) | 零。HttpURLConnection 与 org.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 端口分配
| 用途 | 端口 | 协议 | 可否配置 |
|---|---|---|---|
| 控制 API | 21120 | TCP | 可配置(持久化;客户端以 /v1/info 的 port 字段为准) |
| 发现信标 | 37022 | UDP | 固定,不可配置(客户端据此知道去哪听,见 §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 依赖 httpdate | httpdate 在缓存与 Cargo.lock 中都是 0 次 → hyper 的 server 侧离线编不过 |
axum | 不在缓存 | 且它建立在 hyper server 之上,同样不可用 |
httparse | 在缓存里 | HTTP/1.1 报文解析可用 |
tokio 1.53 | 在缓存里,但 macros feature 依赖 tokio-macros | tokio-macros 不在缓存 → #[tokio::main] / tokio::select! / #[tokio::test] 都不可用 |
socket2、mio、libc、bytes、futures-*、windows-link | 在缓存里 | 线程/网络/异步基础设施齐备 |
通用机制:本工程是 edition 2024,
resolver = "3"让Cargo.lock是 feature-minimal 的。 任何新启用的 feature 只要拉到 lock 里没有的可选依赖,就会直接构建失败。 这就是「.crate在缓存里」不等于「这个 feature 能用」的原因——判断依据要看 feature 的依赖闭包,不是看 crate 是否存在。
两条可行路线,选一条即可(内部实现细节,不影响协议):
| 路线 | 做法 | 代价 |
|---|---|---|
| A. 手写 HTTP/1.1(建议) | std::net::TcpListener 或 tokio::net::TcpListener + httparse 解析请求行与头部 | keep-alive、分块、边界都要自己处理。本协议恰好把这些难点都收敛掉了:Content-Length 或 Connection: close 二选一(§3)、64 KiB 体上限、只有 SSE 一条流式路径 |
| B. 补一个 crate 后用 hyper | 联网拉取 httpdate(零依赖、约 10 KB,只用于生成 Date 响应头)放进 .cargo-home | 代价是一次联网;之后 hyper 的 server 侧整体可用,transport/ 层可替换而业务代码不动 |
两条路线都不改变协议。A 不需要联网,B 的代码量更小。具体取舍由 PC 端方案决定(该方案选的是 A)。
无论选哪条,
tokio的macros与signalfeature 都用不了: 异步运行时需要手动构造(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
}| 字段 | 类型 | 说明 |
|---|---|---|
v | int | 最高支持的协议主版本 |
app | string | 固定 "NiskleHub",用于识别 |
product | string | 展示名,用于头显列表 |
version | string | Hub 版本号,用于提示更新 |
instance_id | string | 安装唯一标识(UUID),发现去重与未来中继路由都用它。取值沿用仓库既有的 install_id(upload.rs:21-39,存在 %APPDATA%\FrameFace\config.json),避免同一个安装出现两个不同 id |
name | string | PC 主机名,用于头显列表 |
paired | bool | 该 Hub 是否已存在至少一个已配对客户端。这是 Hub 级状态,不是「本客户端是否已配对」 |
port | int | 控制 API 的 TCP 端口。客户端取这个值,不假设 21120 |
addresses | string[] | Hub 认为自己可达的 IPv4 列表,按优先级排序,排序规则见下。见 §10.3 |
uptime_s | int | 运行时长,用于「PC 端刚重启过」这类提示 |
addresses 的排序规则(不定义的话,顺序会直接决定头显先试哪个地址,体验差别很大):
物理网卡上的私有地址(
10/8、172.16/12、192.168/16)——最常见可达其它私有地址(虚拟网卡、VPN、Hyper-V、WSL)
公网地址
APIPA(
169.254/16)——几乎一定不可达,放最后
排除项:回环、未 Up 的接口、0.0.0.0。
实现提示:Windows 上用
GetAdaptersAddresses取接口列表比较可靠。 该 API 需要windows-sys增加对应 feature——两个相关 crate(windows-sys、windows-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": {} }| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
v | int | ✅ | 协议主版本。服务端不支持的版本返回 unsupported_version |
id | int | ✅ | 客户端自增,>0。响应原样回带,用于关联 |
op | string | ✅ | 操作名,形如 domain.verb |
args | object | ❌ | 缺省视为 {}。未知字段一律忽略 |
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 | ✅ | 面向人的中文说明,可直接展示在头显上 |
field | ❌ | invalid_args 时指出出错字段 |
retry_after_ms | ❌ | busy / 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 封闭集)
| code | HTTP | 含义 | Link 应如何反应 |
|---|---|---|---|
bad_request | 400 | JSON 或信封格式错误 | 视为客户端 bug,记日志 |
unsupported_version | 400 | v 不被支持 | 提示「PC 端版本不兼容」 |
unauthorized | 401 | 令牌缺失/失效 | 清除本地令牌,回到配对流程 |
forbidden | 403 | 操作存在但该客户端无权 | 提示无权限 |
unknown_op | 404 | 不认识的操作名 | 提示「PC 端版本过旧,需要更新」 |
invalid_args | 400 | 参数校验失败 | 高亮 field |
busy | 409 | 同一资源上有操作在进行 | 按 retry_after_ms 重试,按钮置 loading |
unavailable | 503 | 子系统未运行(如 SlimeVR 未启动) | 提示先启动对应服务 |
timeout | 504 | 操作超时 | 提示重试 |
internal | 500 | 未预期错误 | 展示 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": "…" }
}失败:标准错误信封。配对码错误返回 forbidden,message 说明剩余尝试次数。
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 返回 forbidden,message 说明需要先在 PC 端吊销一个(重配对不受此限,见 §5.2) |
6. 能力清单(Capability Manifest)
6.1 定位:只做门控,不做渲染
这是本协议最重要的设计决策之一。
Hub 通过清单声明自己支持哪些操作、每个操作的参数形状、属于哪个分组、是否危险。 Link 的界面永远来自手写的 Compose 代码,不从清单生成。
清单的用途仅有三条:
能力门控:Link 判断某个手写界面的控件是否能启用(
op不存在 → 置灰并提示更新 PC 端)文案复用:分组标题、操作标题以 Hub 为准,保证两端叫法一致
版本容忍:老 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 }
]
}字段说明:
| 字段 | 说明 |
|---|---|
effect | read / write / action。action 表示有副作用,Link 据此给即时反馈 |
confirm | true 时 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 与远程共用同一把锁。
| 资源组 | 覆盖操作 |
|---|---|
engine | tracking.*、face.*、capture.* |
vrcft | vrcft.* |
body | body.* |
update | update.* |
同一资源组上已有操作在途 → 新请求立即返回 busy(不排队)。不排队是刻意的:
因为 Engine 命令队列溢出时丢弃最旧,排队会让远程请求悄悄挤掉桌面用户的操作。
7.3 与桌面 UI 的状态一致性
远程操作改变了状态后,桌面 UI 立刻反映,方式是服务端在每次状态变化时同时推给桌面 WebView(已有的 frameface://status 事件)和 LAN SSE 订阅者。
依赖桌面 UI 的 2 秒轮询会留下一个短暂的不一致窗口。
已知现状:桌面 UI 目前 2 秒轮询
get_tracking_status(main.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 | 逗号分隔。缺省 = 全部 |
hz | status 主题的推送频率上限(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:345的TOAST_DURATION_MS = 3000、main.ts:382的「最多 4 条」、以及文本去重计数)。 头显端按同样的规则实现,两端行为一致,用户在两处看到的东西才一样。
notice主题是头显场景里价值最高的一个:用户在 VR 里看不到 PC 屏幕, 出错时只能靠通知。v1 的可达范围要说清楚:Hub 现有的大量提示调用点在前端(
main.ts:351-364的notify、main.ts:401的notifyEngineError),而前端与 Rust 之间只有命令通道, Rust 侧看不到这些调用。因此 v1 的notice只覆盖 Rust 能观测到的事件 (引擎断开与恢复、服务启停、更新进度、配对变化等)。想要把前端提示也转发到头显,需要在后续阶段新增一条从 WebView 到 Rust 的通知上报命令, 并同步命令面(
commands.rs/lib.rs/ 权限清单 /build.rs四处,verify-commands.ps1会校验)。 这件事可以独立于本协议推进,不影响notice的报文格式。
status 的推送要做变化检测 + 节流:TrackingStatus 里含 timestamp_ms 和 sequence,
逐字段比较会永远认为「变了」。判断依据是「除 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) |
10.2 Link 的发现优先级
按顺序尝试,前一步成功就不做下一步——这是「不写死」在发现层的体现:
记住的上次成功端点(持久化)→ 直接
GET /v1/info探测从既有入站连接学习:PC 端 Engine 会主动连接 Link 的 MJPEG 服务器 (
mjpeg_http_client.cpp:171-174),Link 因此天然知道 PC 的 IP, 可直接拿这个 IP 试21120端口。零新增机制,这是最省事的一条路监听 Hub 广播信标(37022)
手动输入 IP(兜底手段,VR 里手输体验很差,但缺了它就没有退路)
第 2 条值得强调:现有产品里 PC 是去连手机的,所以手机已经知道 PC 的地址。 控制通道只是把这个已知信息用起来。
10.3 多网卡 PC 的处理(候选地址不止一个)
一台 PC 常见同时有以太网 + Wi-Fi + 虚拟网卡(VPN、Hyper-V、WSL、VirtualBox)。 从入站连接学到的那个地址不一定是 Hub 监听控制端口的那个网卡。 因此客户端的候选地址是集合,逐个尝试而不是只试一个:
| 来源 | 可信度 | 说明 |
|---|---|---|
/v1/info 的 addresses 数组 | 高 | 由 Hub 自己枚举可用网卡并按优先级排序,首选 |
信标里的 ip | 高 | 该信标就是从那块网卡发出的,一定可达 |
| 入站连接学到的源地址 | 中 | 可能来自 Hub 未监听的网卡(如 VPN 或 Hyper-V 虚拟网段),作为补充候选 |
| 用户手输 | 高 | 兜底 |
客户端行为:
把上述所有来源的地址合并去重成一个候选列表(
/v1/info与信标的排前面)候选列表上并发探测
GET /v1/info(每地址超时 1 秒),取最先成功的那个记住成功的地址,下次优先直连
地址变化时(探测失败)重新走一遍上述流程,而不是死守旧地址
这一条同时解释了
/v1/info里addresses的用途(§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 三条铁律
路径里带主版本(
/v1/…)。新版本并行提供,/v1在/v2发布后至少再保留两个版本。未知一律忽略:未知字段忽略、未知参数忽略、未知错误码当
internal、未知主题忽略。 唯一的例外是未知op→ 返回unknown_op(而不是断连)。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 分组与操作
| op | effect | confirm | args | 说明 |
|---|---|---|---|---|
hub | ||||
hub.status | read | — | — | 完整 TrackingStatus 快照 |
hub.performance | read | — | — | PerformanceStats(帧率/延迟/质量) |
hub.info | read | — | — | 身份、版本、运行时长 |
tracking | ||||
tracking.start | action | — | — | 启动已启用的追踪管线 |
tracking.stop | action | — | — | 停止全部追踪 |
tracking.set_face_enabled | write | — | enabled: bool | 开关下半脸追踪 |
tracking.set_eye_enabled | write | — | enabled: bool | 开关眼动。当 TrackingCapabilities.eye_tracking == false 时返回 unavailable + 「当前硬件版本尚未支持眼动追踪」,因为静默接受会让头显显示一个实际没有生效的开关 |
face | ||||
face.set_sensitivity | write | — | parameter: string, percent: int(0-200) | 单参数灵敏度 |
face.set_filter | write | — | enabled: bool, percent: int(0-100) | 时间滤波 |
vrcft | ||||
vrcft.status | read | — | — | VRCFT 服务状态 |
vrcft.open | action | — | — | 启动内置 VRCFT |
vrcft.stop | action | ✅ | — | 结束 VRCFT 进程 |
framecast | ||||
framecast.status | read | — | — | 视频链路状态与指标 |
body | ||||
body.status | read | — | — | SlimeVR 服务状态与端口占用 |
body.devices | read | — | — | 追踪器列表、骨骼、比例 |
body.start | action | ✅ | — | 启动 SlimeVR 服务(会拉起 Java) |
body.stop | action | ✅ | — | 停止 SlimeVR(会 taskkill /F /IM java.exe /T) |
body.reset | action | — | mode: "yaw"|"full"|"mounting"|"mounting_clear"|"pause" | 重置追踪 |
body.assign_tracker | write | — | name: string, designation: string | 分配部位 |
body.set_proportions | write | — | bone: string, value: number | 设置骨骼比例 |
body.autobone | action | ✅ | action: "record"|"stop"|"cancel"|"process"|"apply"|"reset" | 自动测量流程 |
body.pair | write | — | mac: string | 记住追踪器 |
body.unpair | write | — | mac: string | 遗忘追踪器 |
body.set_osc | write | — | key: string, value: string | OSC 配置 |
capture | ||||
capture.status | read | — | — | 采集状态 |
capture.set_enabled | write | — | enabled: bool,interval_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.clear | action | ✅ | — | 删除已采集数据(不可恢复) |
update | ||||
update.check | read | — | channel: "stable"|"beta" | 查询更新 |
update.install | action | ✅ | channel: string | 下载并安装。没有完成信号:updates.rs:136-138 把控制权交给 NSIS,进程随后退出,调用方无法区分「成功 / 失败 / 还在下载」。这里定为:立即返回 { "started": true },进度走 update 事件主题,连接断开即表示安装已开始 |
client | ||||
client.list | read | — | — | 已配对设备 |
client.revoke | action | ✅ | client_id: string | 吊销某设备 |
client.rename | write | — | client_id: string, name: string | 重命名设备 |
app | ||||
app.show | action | — | — | 把 Hub 窗口带到前台(VR 里最实用的一条) |
app.hide_to_tray | action | — | — | 最小化到托盘 |
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.capabilities | stream | v2 |
stream.start | stream | v2 |
stream.stop | stream | v2 |
stream.stats | stream | v2 |
stream.display.set | stream | v2 |
stream.audio.route | stream | v2 |
v1 的行为:这些 op 不出现在能力清单里(因为还没实现)。
老 Link 若误发,Hub 返回 unknown_op——这是正确且安全的反应。
新增分组建议:{ "id": "stream", "title": "串流", "order": 30 }。
13. 实现顺序建议
| 阶段 | 内容 | 可验证的结果 |
|---|---|---|
| P0 | Hub:/v1/info、/v1/pair、令牌存储、/v1/rpc 骨架 + hub.status 一个操作 | 用 curl 完成配对并读到状态 |
| P1 | Hub:能力清单生成 + hub/tracking/face 分组全部操作 + 串行化与限流 | 探针脚本能跑通全部操作 |
| P2 | Hub:SSE 事件流(status + notice) | curl 能看到实时事件 |
| P3 | Hub:UDP 信标广播 | 抓包能看到信标 |
| P4 | Link:发现 + 配对界面 + 状态页 | 头显上能看到 PC 状态 |
| P5 | Link:追踪/面捕控制页 + 通知 toast | VR 里能开关追踪并看到报错 |
| P6 | Link:体感追踪页(设备列表、分配、校准) | 完整覆盖当前功能 |
| P7 | Link:更新页 + 已配对设备管理 | — |
P0–P3 不需要 Link 就能开发和验证(全部用 curl / 探针脚本)。 先把 Hub 端做完并用探针脚本留下证据,再动 Link,联调时就能立刻区分是哪一端的问题。
14. 附:与既有协议的关系
| 既有通道 | 归谁 | 本协议的关系 |
|---|---|---|
\\.\pipe\FrameFace.Engine.v1 | Rust Host ↔ Engine | 不动。本协议不直接碰它 |
\\.\pipe\FrameFace.Expressions.v1 | Engine → VRCFT | 不动 |
127.0.0.1:21112 SlimeVR 状态桥 | Rust ↔ SlimeVR Java | 不动(但建议改为只监听回环,§9.5) |
UDP 37020/37021 FrameCast 视频 | Link → Hub Engine | 不动。本协议只在 37022 新增自己的信标 |
本协议 21120 / 37022 | Link ↔ 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 / 37021 | Link → Hub(现有) |
| 本文控制面 | TCP 21120 | 新增 |
| 本文发现信标 | UDP 37022 | 新增 |
| SlimeVR | UDP 6969、TCP 21110 / 21112 | 现有 |
| OSC | UDP 9000 / 9001 / 9003 | 现有 |
| Link MJPEG | TCP 18980(默认) | 现有 |
| 未来媒体面 | 待定,建议 UDP 37100–37199 区间 | 未实现 |
划定 37100–37199 是刻意的:远离上面所有已用端口,且与 FrameCast 的 3702x 相邻便于记忆。 现在只需在文档里占位,不需要实现。