03-Niskle Link 互联方案(头显端)

admin · 9 小时前

Niskle Link 互联方案(头显端)

本文是三份文档中的头显端实现方案协议只在 01-互联协议规范-v1.md 里定义,本文不重复定义协议,只描述 Link 侧如何实现它。文中「协议 §N」均指该文档。

  • 目标版本:Link 1.9.0(当前 app/build.gradle.kts:19-20 = versionCode 162 / 1.8.1)

  • 对应协议阶段:P4–P7(协议 §13) 编写日期:2026-09-15 状态:设计定稿,尚未实现

  • --

1. 目标与非目标

1.1 目标

#目标验收方式
G1不摘头显就能看 PC 端 Niskle Hub 的运行状态头显出现「已连接」与状态卡片,数字与 PC 端一致
G2开关追踪、调面捕灵敏度、操作体感追踪头显点一下,PC 桌面 UI 立刻同步
G3PC 端出错时头显里能看见(用户看不到 PC 屏幕)杀掉引擎后头显弹 notice toast
G4免手动输 IP 的局域网发现冷启动 3 秒内自动找到 PC
G5不引入任何新第三方依赖settings.gradle.kts:1-18app/build.gradle.kts:90-111 不变
G6新代码可回归验证新增 app/src/test,覆盖协议解析/状态机/发现排序

1.2 非目标

非目标理由
云端中继 / 跨网已锁定局域网直连,协议 §10.4 只预留形状
OpenXR / 沉浸式 3D已锁定 2D 悬浮面板,不引入 XR SDK
按能力清单自动生成界面协议 §6.1:界面永远手写 Compose,清单只做门控
替代桌面 UI / 取代 FrameCast 视频LINK 是桌面 UI 的平行入口,复用同一套状态(协议 §14)
QR 扫码配对见 §5.4,列为后续改进
头显端 TLS局域网明文 + 令牌,与协议 §9 一致(network_security_config.xml:3 已允许明文)

2. 整体架构

2.1 包结构(新增 app/src/main/java/com/framecast/stream/remote/

文件职责
RemoteHub.ktapp 级单例,持有 CoroutineScope(SupervisorJob() + Dispatchers.IO),组装并暴露给 UI
RemoteRepository.kt连接状态机 + 能力清单缓存 + 事件分发(唯一有状态的类)
RemoteClient.ktHttpURLConnection 短超时封送:info/pair/rpc/capabilities/unpair
HttpJson.kt信封的 org.json 编解码 + 错误码映射
RemoteError.ktsealed class,与协议 §4.4 封闭错误码一一对应
EventStream.ktSSE 读取循环、退避重连、应用服务端 retry
RemoteDiscovery.kt发现优先级调度(协议 §10.2 四条)
BeaconListener.ktUDP 37022 监听 + peer 表(instance_id 去重、6 秒过期)
MjpegPeerHint.kt「从既有入站 MJPEG 连接学习 PC IP」的唯一写入口
NetworkSnapshot.kt接口列表 / 本机候选 IPv4 / 子网前缀(补 NetworkUtils 只有一个字符串的缺陷)
CapabilityGate.kt纯函数:op 不存在或 since > 1 → 置灰
RemoteTokenStore.ktKeystore 信封加密 + 落盘,唯一能解开令牌的地方
KeystoreCipher.ktandroid.security.keystore AES-GCM 加解密
RemotePrefs.kt新 DataStore 键的读写(仅 suspend
RemoteModels.kt手写 data classHubInfo/HubCapabilities/HubOperation/RemoteStatus/RemoteNotice
RemoteLogScrubber.kt统一脱敏,供日志与导出使用
ui/remote/RemoteViewModel.kt唯一 ViewModel,AndroidViewModel(同 MainViewModel.kt:34 风格)
ui/remote/LinkScreen.ktLINK 顶层页壳(子页切换)
ui/remote/{Connect,Overview,Face,Body,Update}Page.kt五个手写 Compose 页面
ui/remote/RemoteControls.kt先建的缺失组件(§8.3)

remote/ 不 import service.ui.,唯一例外见 §2.3。

2.2 数据流

┌──────────────────────── Compose 层(ui/remote/)────────────────────────┐
│ LinkScreen ─┬─ ConnectPage  配对码 / 手动 IP / 发现的 PC 列表            │
│             ├─ OverviewPage 状态 + 快捷开关                              │
│             ├─ FacePage     面捕参数                                     │
│             ├─ BodyPage     设备列表 / 部位分配 / 校准 / 自动测量         │
│             └─ UpdatePage   更新 + 已配对设备管理                        │
│                    │                                                     │
│        RemoteToastHost ◄── Flow<RemoteNotice>(面板内 toast,绝不静默)   │
└────────────────────┬─────────────────────────────────────────────────────┘
                     │ StateFlow<RemoteUiState> / 事件回调
┌────────────────────▼─────────────────────────────────────────────────────┐
│ RemoteViewModel (AndroidViewModel) —— 无业务逻辑                          │
└────────────────────┬─────────────────────────────────────────────────────┘
┌────────────────────▼─────────────────────────────────────────────────────┐
│ RemoteRepository —— 连接状态机(§3)                                      │
│   ├─ 能力清单缓存 Map<op, HubOperation> ──► CapabilityGate                │
│   └─ 令牌(内存态;磁盘态由 RemoteTokenStore 加密保存)                    │
└──┬──────────────────┬───────────────────────┬────────────────────────────┘
┌──▼───────────────┐ ┌▼────────────────────┐ ┌▼───────────────────────────┐
│ RemoteClient     │ │ EventStream (SSE)   │ │ RemoteDiscovery            │
│ HttpURLConnection│ │ BufferedReader      │ │ 1 记住的端点               │
│ 2s / 5s 超时     │ │ 退避 1→2→4…30s      │ │ 2 MjpegPeerHint(入站源地址)│
│ 请求 id 关联     │ │ 服务端 retry 覆盖   │ │ 3 BeaconListener(UDP 37022)│
│ busy→按 retry    │ │ status/notice 分发  │ │ 4 手动 IP                  │
└──────────────────┘ └─────────────────────┘ └──────┬─────────────────────┘
                                                    │ 只读
                                        ┌───────────▼─────────────────────┐
                                        │ MjpegHttpServer(既有,:18980) │
                                        └─────────────────────────────────┘

2.3 与既有 StreamService 的关系:互不依赖

StreamService 用静态 companion flow 发布状态、onBind 返回 null(StreamService.kt:111),没有 binder、没有第三个进程内服务位。

  • 不新增前台服务:控制 PC 的场景就是用户正看着 LINK 面板的时候,面板不可见时保持 SSE 也没有意义(没人看 toast)。新开 FGS 还要与 StreamServiceforegroundServiceType="camera|connectedDevice"AndroidManifest.xml:98)争抢类型与 startForeground 路径(StreamService.kt:498-525),风险大于收益。

  • RemoteHubFrameCastApp 持有的 app 单例(照抄 FrameCastApp.kt:14-39preferences/instance 的既有模式,零新概念)。

  • 唯一接触点:MjpegHttpServer.handleClient(socket):94)记录 socket.inetAddress.hostAddressMjpegPeerHint。单向、可空、可失败;remote/ 不 import service.,耦合面 1 行。

  • --

3. 连接状态机

3.1 状态与 UI

状态进入条件UI 表现
未配置从未配对成功且最近一次发现为空「搜索 PC」大按钮;30 秒无结果则直接显示「手动输入 IP」入口
搜索中四级发现依次尝试中进度指示 + 「正在寻找 Niskle Hub…」,10 秒后追加「也可以手动输入 IP」
待配对发现到 PC 且 /v1/info 返回 paired:false六位码输入框 + 「请在 PC 上点击『配对新设备』」
配对中POST /v1/pair 在途按钮 loading(复用 LiquidActionButtonloadingLiquidControls.kt:250
连接中有令牌,正拉 /v1/capabilities 并建 SSE骨架 + 「正在连接 DESKTOP-ABC…」
已连接清单已缓存且 SSE 已通绿点徽章 + PC 名 + 版本;状态卡片实时刷新
降级链路在但语义失败(unavailable/timeout/internal黄点徽章 + 「PC 端部分服务未运行」,具体控件置灰并附原因
连接中断SSE 断开且重连未成功,或 rpcIOException/超时灰点徽章 + 「连接中断,正在重连…」;保留最后一帧状态,不显示「未配对」(协议 §8.2)

unknown_op 不改变连接状态(协议 §4.4 硬规则):只让 CapabilityGate 把对应控件置灰,行内显示「PC 端版本过旧」。

3.2 关键迁移

当前事件下一个副作用
搜索中/v1/infopaired:true + 有令牌连接中remote_last_endpoint
搜索中paired:false,或本机无令牌待配对显示配对码输入框
搜索中四步全败未配置面板内 toast:「没找到 PC。请确认 Hub 已启动且在同一 Wi-Fi。」
配对中ok:true连接中加密存 token + client_id + hub.instance_id
配对中forbidden(码错)待配对清空输入,toast 原样展示服务端 message(含剩余次数)
配对中unsupported_version未配置toast:「PC 端版本不兼容,请更新 Hub 或 Link」
连接中unauthorized待配对清除令牌与 client_id + toast:「配对已失效,请重新配对」
连接中三次重试仍失败连接中断保留令牌,指数退避 1/2/4/8s(上限 30s)
已连接SSE 断开连接中断保留最后一帧状态;不弹 toast(噪声),只变徽章
已连接rpc 返回 unavailable/internal/timeout降级面板内 toast 显示服务端中文 message
降级任意一次 rpc 成功已连接清掉降级徽章
连接中断重连成功已连接重拉 capabilities(PC 可能刚更新)
连接中断用户点「重新配对」待配对清令牌 + 调 POST /v1/unpair(失败忽略)
任意网络接口变化(Wi-Fi 切换/插拔 USB 网卡)搜索中丢弃 peer 表重新发现

3.3 三种典型故障的确定行为

场景判定依据表现
PC 关机TCP connect 2 秒超时,四级发现全败未配置,toast 指引检查 Hub 与网络,不无限重试轰炸
Hub 未启动(PC 在线)发现失败但 MjpegPeerHint 有 IP,或 /v1/info 连接被拒toast:「找到了 PC 192.168.x.x,但 Hub 未启动」——比笼统的「没找到」有用得多
令牌失效任意请求 unauthorized(401)清令牌 → 待配对
PC 版本过旧清单缺该 op,或 rpc 返回 unknown_op连接状态不变,控件置灰 + 行内说明

4. 发现实现

按协议 §10.2 的四级优先级,前一步成功即停止。

4.1 第 1 级:记住的上次成功端点

持久化 remote_last_endpoint192.168.1.10:21120)。冷启动先 GET /v1/info 探测,2 秒超时;失败降级到第 2 级,该键先留着不删(PC 可能只是还没开机完)。

4.2 第 2 级:从既有入站 MJPEG 连接学习 PC IP(零新增机制)

PC 端 Engine 会主动连接 Link 的 MJPEG 服务器(mjpeg_http_client.cpp:171-174),Link 因此天然知道 PC 的源地址

  1. MjpegHttpServer.handleClient(socket)MjpegHttpServer.kt:94)把 socket.inetAddress.hostAddress 写入 MjpegPeerHint(带时间戳),只接受通过协议 §10.1 安全 IPv4 校验的地址。

  2. 发现流程读该 hint,配 21120/v1/info 探测。

  3. StreamService 未运行、hint 为空时直接跳过。

在已经推流的正常场景下这一条几乎总是第一个命中,完全不需要广播;代价是 1 行写入。

4.3 第 3 级:监听 Hub 信标(UDP 37022)

LanDiscovery只发不收registerServiceLanDiscovery.kt:137-160)、DatagramSocketsend:171-174,205-207),没有 discoverServices、没有 ResolveListener、没有接收线程。接收侧由新建的 BeaconListener 承担:

设计
socketDatagramSocket(null)reuseAddress = truebind(InetSocketAddress(37022)),1024 字节缓冲
线程专用 daemon 线程(同 LanDiscovery.kt:167-169 命名习惯),soTimeout = 1000ms 以便响应停止
校验app == "NiskleHub"proto <= 1、来源 IP 过安全 IPv4 校验(首字节非 0/127、<224、非 255.255.255.255,协议 §10.1)、ts 与本地时钟差 > 30 秒丢弃
peer 表ConcurrentHashMap<instance_id, Peer(ip, port, name, paired, lastSeenAt)>;同 id 只更新 lastSeenAt新 IP 覆盖旧 IP 并重置
过期每写入前清理 now - lastSeenAt > 6000ms(信标 2 秒一次,允许丢 2 帧)
广播地址255.255.255.255:37022;3 秒内零 peer 则用 NetworkSnapshot 枚举各接口的子网定向广播地址再试一轮

4.4 MulticastLock:需要,但只在有限窗口持有

CHANGE_WIFI_MULTICAST_STATE 已在 AndroidManifest.xml:21 声明却从未使用。现代 AP 广播抑制与 Android Wi-Fi 省电会按接口丢弃广播帧,Pico Neo3 在息屏/省电档尤其明显——不持锁时「时好时坏」是最难排查的现场问题。但长持锁显著增加功耗,因此窗口定得很窄:只在发现流程进行中且 LINK 面板可见时 createMulticastLock("niskle-link-discovery"),最长 30 秒或首个 peer 到达即释放;acquire()/release() 严格配对,在 finally 里释放。

4.5 第 4 级:手动 IP(VR 里必须好用)

  • 入口自动浮现:搜索 10 秒无结果即显示,不藏在设置里。

  • LiquidTextFieldLiquidControls.kt:967)+ KeyboardOptions(keyboardType = KeyboardType.Number)。头显数字键盘没有 .,因此输入目标不是完整 IPv4,二选一:① 默认只输最后一段(用 NetworkSnapshot 推本机前缀如 192.168.1.,只输 10,覆盖 95% 同网段场景);② 完整 IPv4,用 KeyboardType.Uri,作为「高级」开关。

  • 端口默认 21120,折叠在「高级」里,同样只允许数字。

  • 输满即自动探测 /v1/info,用 LiquidProgressIndicator:1185)显示进度;失败用字段的 isError + supportingText 报错,成功写入 remote_last_endpoint

  • --

5. 配对流程 UI

5.1 用户看到什么

  1. PC 端在 Hub 设置页点「配对新设备」→ Hub 窗口显示 6 位大字号数字码,TTL 120 秒(协议 §5.3)。

  2. 头显进入 待配对:标题「在 PC 上点击『配对新设备』」+ 六格大号输入框(RemotePinField,§8.3),每格触摸目标 ≥ 64dp。

  3. 输满 6 位按钮自动点亮(LiquidActionButtonloadingLiquidControls.kt:250)。

  4. 成功 → 连接中已连接 + toast「已连接到 DESKTOP-ABC」;失败 → 清空输入,toast 显示服务端 message(协议 §4.3 要求它是可直接展示的中文)。

5.2 错误处理

情况服务端返回头显表现
配对码错误forbidden + message(含剩余次数)清空输入,toast 原文展示;连续 3 次后输入框上方常驻「还剩 N 次机会」
配对码过期forbiddentoast:「配对码已过期,请在 PC 上重新生成」
5 次用尽forbidden输入框禁用 3 秒 + toast:「配对码已作废」;不自动重试(避免与 PC 端重新生成的节奏打架)
PC 端没点「配对新设备」forbidden同上,文案引导先去 PC 操作
协议版本不符unsupported_versiontoast:「PC 端版本不兼容」并回 未配置
网络抖动IOExceptiontoast:「网络中断,请重试」,保留已输入的 6 位码

5.3 令牌去向

token(32 字节 base64url)与 client_id不进普通持久化,只经 RemoteTokenStore 用 Keystore AES-GCM 包一层后落盘(§11.2)。内存态只存在 RemoteRepository 的私有字段;RemoteClient 只从参数接收令牌,异常信息里也不拼接令牌。

5.4 后续改进(本阶段不做)

App 已声明 horizonos.permission.HEADSET_CAMERA 与相机 feature(AndroidManifest.xml:11-13,37),Pico/Quest 均允许相机访问,因此用头显摄像头扫 PC 屏幕上的二维码是可行的下一步:Hub 把 {instance_id, ip, port, 一次性 pair_code} 编成二维码,扫码即完成发现 + 配对,把 §4 与 §5 压成一步。本次仅备案。


6. HTTP 客户端

6.1 为什么仍是 HttpURLConnection 且不需要新依赖

  • 全仓库唯一客户端是 update/UpdateRepository.kt(217 行,HttpURLConnection:8,28,106),焊死在 OTA 语义上:15s/60s 超时(UpdateConfig.kt:29-30)不可按调用覆盖、无取消。UpdateRepository 不复用——对休眠中的 PC 发一次「开始追踪」会卡 UI 15–60 秒。

  • 协议只有 6 个端点、请求/响应模型、JSON 体 ≤64 KiB、下行走 SSE。这种规模下 OkHttp/Retrofit 的连接池/拦截器/类型安全收益都不成立,代价却是新依赖 + 混淆规则 + 体积。

  • isMinifyEnabled=true/isShrinkResources=trueapp/build.gradle.kts:42-43)且 proguard-rules.pro 只保护 UVC/JNI(:1-16),任何反射式序列化库都要额外 keep 规则,漏一条就是 release-only 崩溃。org.json 是平台内置、无反射、无 keep 需求。

  • settings.gradle.kts:1-18 未声明任何序列化插件——这本身就是引入新依赖不划算的信号。

  • *结论:org.json + HttpURLConnection,零新依赖**,与 UpdateInfo.fromJsonUpdateInfo.kt:52-60)的手写风格一致。

6.2 客户端要求

连接超时 / 读取超时2000 ms / 5000 ms,且每次调用可覆盖(这正是 UpdateConfig 做不到的)
端点POST /v1/rpcGET /v1/infoGET /v1/capabilitiesPOST /v1/pairPOST /v1/unpair(协议 §3)
请求头Content-Type: application/json; charset=utf-8Accept: application/jsonCache-Control: no-storeAuthorization: Bearer <token>
请求体{"v":1,"id":<自增>,"op":"...","args":{...}}id 从 1 单调递增、进程内唯一
关联响应 id != 请求 id → 记日志并当 internal。Rust 侧无关联机制(协议 §1.3 约束 2),关联只能在 LAN 层做
取消withContext(Dispatchers.IO) + 外层 Job 取消,阻塞 IO 前后 ensureActive()HttpURLConnection 无原生取消,短超时兜底
并发在途 rpc ≤ 4(对齐协议 §7.1),超出本地拒绝 + toast

6.3 错误映射(封闭集合)

RemoteError 与协议 §4.4 一一对应:BadRequest/UnsupportedVersion/Unauthorized/Forbidden/UnknownOp/InvalidArgs(field)/Busy(retryAfterMs)/Unavailable/Timeout/Internal

  • 4xx/5xx 能解析出 error.code → 用 codeHTTP 状态码只做校验,不参与判定,避免两端状态码表漂移)。

  • 未知 codeInternal(协议 §11.1:未知错误码当 internal)。

  • 非 2xx 且响应体不是 JSON(中间设备插了 HTML 页)→ Internal,message 用「PC 端返回了无法解析的响应」。

  • 连接/读取超时 → Timeout;连接被拒 / host unreachable → Unavailable二者区分开,否则给不出「Hub 未启动」这种有用的提示。

6.4 busy 自动重试

busy(409,协议 §7.2 的资源组串行化)是预期内的正常返回:按钮进 loading → 等 retry_after_ms(缺省 500ms、上限 3000ms,协议 §4.3)→ 自动重试最多一次 → 仍失败则停止自动重试,把按钮恢复为可手动重试并 toast 服务端 message(如「体感追踪正在启动」),不进入循环;用户离开页面或点取消 → Job.cancel() 立即终止。

为什么只自动重试一次而不是三次:协议 §7.2 的串行化是「不排队、立刻返回 busy」。 如果客户端连续重试三次,在 PC 端看起来就是一次操作被重放三次,而 Engine 命令队列溢出时会丢弃最旧 ——重试反而可能挤掉桌面用户自己的操作。一次重试是「等对方忙完」,多次重试就是「抢」。

6.5 两个具体坑

  1. connectTimeout/readTimeout 是实例属性,不是全局设置。 每个新建的 HttpURLConnection 都要显式赋值(参照 UpdateRepository.kt:29-30,107-108);漏设就拿不到 2s/5s,退回平台默认(读超时 0 = 无限等待)。因此「建连 + 设超时 + 设头」收敛到一个私有函数,openConnection() 全项目只有这一处。

  2. 连接池是 per-JVM 的且行为受限。 复用依赖 keep-alive 与响应体被读完;协议要求服务端设 Connection: close 或正确 Content-Length(协议 §3),因此实际上每次请求都会新建 TCP 连接。局域网可接受(RTT < 2ms),但不能假设「热连接更快」,也不要基于连接复用做限流设计。响应体读完并 finally { conn.disconnect() }(照抄 UpdateRepository.kt:50,206)。


7. SSE 客户端

7.1 读取循环

GET /v1/events?topics=status,notice&hz=2Accept: text/event-stream。用 BufferedReader(InputStreamReader(conn.inputStream, UTF_8)) 逐行 readLine()

处理
空行事件边界:把累积的 event/data 组装并分发
: keepalive忽略(同时是链路存活的信号)
其它 : 开头忽略(SSE 注释语法)
event: / data:记录事件名 / 追加数据(多行 data:\n 拼接)
id:记录但 v1 不使用:协议规定连上即推全量 status 快照(§8.2),不需要 Last-Event-ID 续传
retry:记录服务端建议间隔,下次重连使用,钳制 1000–30000 ms

readTimeout40 秒(服务端心跳 15 秒,允许丢 2 次)。这个值是有作用的:若服务端进程被挂起而非断开,没有读超时读取线程会永久阻塞,重连永不触发。statusdataorg.json 解析成 RemoteStatus(未知字段忽略),notice 解析成 {level, text, sticky}

7.2 重连退避

  • 默认 1→2→4→8→16→30 秒(上限 30s),带 ±20% 抖动避免多头显同时重连。

  • 收到过服务端 retry: 时用它作基数再乘退避倍数,仍钳制 1000–30000 ms。

  • 连接稳定 10 秒后退避计数归零。

  • 断线语义遵循协议 §8.2:只显示「连接中断」,不清除已配对状态

  • 重连成功后重拉 GET /v1/capabilities(PC 可能刚更新过)。

7.3 屏幕不可见时是否保活

结论:不保活,但保留快速重连能力。

  • 面板不可见(Lifecycle < STARTED)→ 断开 SSE,转低频探测(每 30 秒一次 GET /v1/info,2 秒超时)。

  • 面板重新可见 → 立即重连并拉一次 capabilities(预期 < 500ms,用户无感)。

  • 理由:① 看不到 toast 时接收 notice 毫无价值;② 头显电池与 Wi-Fi 唤醒预算宝贵,且同进程 StreamService 已在做 UVC 采集与 YUV→JPEG;③ 协议 §8.2 明确「断线不改变配对状态」,断开是安全的。

  • 唯一例外:用户正在观察长操作(body.autoboneupdate.install)时,页面切走也保留 SSE,直到该操作结束或 5 分钟超时。

  • --

8. 屏幕与交互

8.1 页面清单(手写 Compose,映射协议 §12.1 分组)

页面op 分组内容复用既有组件需先建
连接/配对页hub.info发现的 PC 列表、手动 IP、6 位码输入、Hub 版本ContentGroup(LiquidGlass.kt:568)、LiquidActionButton(LiquidControls.kt:250)、LiquidTextField(:967)、LiquidProgressIndicator(:1185)RemotePinFieldRemoteList
总览页hub.status/hub.performance/tracking.*/framecast.status/app.show连接徽章、状态卡片、快捷开关(启动/停止追踪、下半脸、眼动)、app.show 大按钮ContentGroupStandardSwitch(LiquidGlass.kt:531)、StandardActionButton(:472)、ProgressiveBlurHeader(:334)ConnectionBadge
面部追踪页face.*vrcft.*灵敏度滑块(0–200%)、时间滤波开关 + 强度、VRCFT 启停StandardSlider(:546)、LiquidSlider(LiquidControls.kt:764,943)、LiquidChoiceField(:1058)RemoteDialog
体感追踪页body.*设备列表(在线/电量/部位)、部位分配下拉、比例调节、校准重置、自动测量LiquidSegmentButton(:1161)、LiquidNavRow(:1121)、LiquidSliderLiquidProgressIndicatorRemoteListRemoteDropdown
通知与更新页update.*client.*更新检查、下载进度、安装、已配对设备列表与吊销LiquidProgressIndicatorLiquidNavRowContentGroupRemoteListRemoteDialog

confirm: true 的操作(vrcft.stopbody.startbody.stopbody.autobonecapture.clearupdate.installclient.revoke一律用 Link 自己的确认弹层,文案逐字照抄协议 §9.4——用户看不到 PC 屏幕,文字必须说明后果。

8.2 VR 可用性规则(硬性)

  1. 触摸目标 ≥ 56dp,主操作 ≥ 64dp 高(头显手势/手柄射线落点精度远低于手指)。

  2. 不做密集文本:每卡片最多 3 行,正文 ≥ 15sp,数字用大字号独立块。

  3. 除数字外不用打字:枚举一律用选择器,只有配对码、IP、设备重命名会唤起键盘。

  4. 破坏性操作一律在 App 内二次确认,不用系统 Toast 承担确认职责。

  5. 每个错误都以面板内 toast 呈现,没有静默失败。 唯一例外是 SSE 断开(只改徽章:它会自动恢复且会连续发生)。任何 catch 里既无 toast 也无状态变更,都算 bug。

8.3 先建的缺失组件

现有设计系统没有列表/LazyColumn、下拉选择、Snackbar/Toast、连接状态组件,且 LiquidModal 是 private(HomeScreen.kt:2017)。建设顺序按下表:

组件要点
RemoteListLazyColumnHomeScreen.kt 全程用 verticalScroll,长列表要 Lazy)+ ContentGroup 卡片行;空/加载/错误三态齐全
RemoteToastHost顶对齐 Box + AnimatedVisibility 队列,形似 LiquidGlassSurfaceLiquidGlass.kt:112);时长按 level 区分(error 5s,其余 3s)
ConnectionBadge状态点 + PC 名 + 版本,绿/黄/灰;点击展开诊断(IP、端口、instance_id 前 8 位)
RemoteDialogHomeScreen.kt:2017private fun LiquidModal 提升为 internal 并移到独立文件,不做复制
RemoteDropdown复用 HomeScreen.kt:59-62 已引入的 Material3 ExposedDropdownMenuBox,外观用 ContentGroup
RemotePinField六格大号数字格 + 一个隐藏 BasicTextField,支持粘贴;6 个独立输入框的焦点管理会失控,不这么做

8.4 视觉预期

supportsOpticalGlassBuild.VERSION.SDK_INT >= TIRAMISUHomeScreen.kt:201LiquidGlass.kt:134)。Pico Neo3 是 Android 10(API 29),远低于 33,因此目标设备上「液态玻璃」会退化到 LiquidGlass.kt:287-304 的平面分支:纯色 tint + 阴影 + 边框。这不是 bug,是既有行为。本次不承诺任何视觉打磨:不靠模糊区分层级,不把关键信息藏在玻璃层后面;验收只看「API 29 上信息清晰、可点、状态可读」。


9. 通知(notice → 面板内 toast)

链路:SSE event: noticeRemoteRepositoryFlow<RemoteNotice>RemoteToastHost。协议 §8.3 的 notice 是头显场景价值最高的主题。

机制规则
去重键`level + "" + text`
去重窗口同一键 10 秒内只入队一次(与协议 §8.3 的服务端去重窗口一致),重复命中则把已显示条目计数 +1,显示为「文本 ×N」
速率上限队列最多 4 条(与 Hub 桌面端一致)、消费间隔 400ms;整体不超过 5 条/10 秒;超限丢弃最旧的保留最新(用户最需要当前状态)
合并level 且 1 秒内的多条合成一条(换行分隔,最多 3 行)
普通通知展示 3 秒后自动消失(与 Hub 桌面端一致)
sticky: true不自动消失,带「知道了」;同时最多 1 条 sticky,新 sticky 顶掉旧的(旧的降级为普通 toast);最长 30 秒后强制降级为可关闭的常驻条,不长期占据画面(协议 §8.3)
断线清空SSE 断开时清空队列(重连后的旧错误已无意义)。协议 §8.3 规定服务端不重发历史通知
第二道防线服务端「变化检测 + 节流」是协议要求(§8.2),客户端这层仍要做——不能假设服务端实现正确

status 变化驱动的本地提示(如「追踪已启动」)也走同一队列,保证用户操作总有反馈。


10. 导航改造

10.1 现状为什么不可扩展

NiskleTab 只有两个成员(LiquidGlass.kt:102)。加第三个 Tab 要改 7 处硬编码:枚举本身、selectedIndex = if (selected == STREAM) 0 else 1LiquidBottomTabs.kt:373)、if (index == 0) … else …:382)、tabsCount = 2 与固定 Modifier.width(220.dp):386-387)、两个字面量 Tab 块(:390-434)、when(tab)HomeScreen.kt:270-341)、按 ordinal 排序的 AnimatedContent:366)。而 HomeScreen.kt 已 2503 行,HomeScreen(...) 已吃 18 个状态值 + 19 个回调(:154-196)。

10.2 改造三步(每步独立可编译)

动作风险
1NiskleTabenum 改为 data class NiskleTabItem(id, title, iconRes, badge) + 顶层 val NISKLE_TABS: List<NiskleTabItem>(顺序即展示顺序)低:纯数据搬迁
2NiskleLiquidFloatingNavigationBar 改为遍历 tabsselectedIndex = tabs.indexOfFirst { it.id == selected }tabsCount = tabs.size、宽度 Modifier.width((110 * tabs.size).dp)、Tab 内容 tabs.forEach { LiquidBottomTab(...) }低:行为不变,仅去硬编码
3pageContentwhen (tab.id) + "link" -> LinkScreen(...);LINK 自带 RemoteViewModel,故 HomeScreen 参数只增 1 个remote: RemoteUiState中:唯一触及 2503 行文件的一步,单独提交一次

HomeScreen 的 37 个参数本身就是下一个技术债。本次不做全面重构(无测试保护,见 §12),但边界要守住:remote 相关回调不再往 HomeScreen 里塞。

10.3 顶层 Tab 还是设置子页

结论:顶层 Tab(第 3 个)。

  1. 需要常驻状态可见性:用户随时都想知道「PC 还连着吗」。放进设置二级页等于把这信息藏两层,而 ConnectionBadge 的价值就在常驻。

  2. 操作频次:戴着头显操作 PC 是高频核心动作,「设置 → 找入口 → 进入」在 VR 里是 3 倍操作成本。

  3. ordinal 动画天然支持AnimatedContenttargetState.ordinal 判方向(HomeScreen.kt:366),列表顺序天然给出 ordinal,无需改动画代码。

代价是底部栏由 220dp 变宽到约 330dp,在 Pico Neo3 的 2D 面板里仍居中可点。SettingsPage 那套 private enum 保持不动(HomeScreen.kt:151),LINK 内部子页照抄同样的 rememberSaveable + enum 模式:212-213),不引入 navigation-compose——为 5 个子页引入导航库不值得,且会与既有返回键处理(:262-267)打架。


11. 持久化与安全

11.1 新增 DataStore 键

文件仍是 framecast_settings_v2PreferencesRepository.kt:19),键加在 private object Keys:71-87,现 15 个键)。

类型说明默认
remote_token_sealedStringKeystore 信封加密后的令牌(§11.2)
remote_client_idString本机 client id(UUID,与令牌同生命周期)
remote_last_endpointString上次成功的 ip:port
remote_hub_instance_idString上次配对的 Hub instance_id
remote_manual_ipString手动输入的 IP(含前缀)
remote_manual_portInt手动端口21120
remote_poll_hzIntstatus 订阅频率 1–52
remote_notice_level_minString最低显示级别info

硬规则:新键只提供 Flowsuspend fun set…(),不再新增 …Sync() getter。既有 runBlocking { dataStore.data.first() }:169-171,173-175,197-203)被主线程 1Hz 与逐帧调用(StreamService.kt:50,225-232,415)是既有反模式;本次不改它(无测试保护),但新代码不复制这套写法,RemotePrefs 全部挂起。

11.2 令牌保护决策

事实:android:allowBackup="true" + dataExtractionRules/fullBackupContentAndroidManifest.xml:51-53)。现有规则只 include domain="sharedpref"backup_rules.xml:3data_extraction_rules.xml:4,7),不含 file,所以 DataStore 的 framecast_settings_v2.preferences_pb 目前不在备份范围内——但这是巧合而非设计,且 adb backup、root、云备份策略变更都可能打破它。真正的现实风险是 AppLog/LogExporter/CrashHandler(§11.3)。

选定方案:Keystore AES-GCM 信封加密后再落入 DataStore。

候选结论
android:allowBackup="false"为一个 token 牺牲全部设置的换机迁移,副作用过大,否决
只用 data-extraction 规则排除依赖「规则写对且系统遵守」,漏配即明文外泄;只作补充
androidx.security:security-crypto引入新依赖,与 G5 冲突,且该库已维护状态
只读 javax.crypto + android.security.keystore采纳:零新依赖,minSdk 24 已满足 Keystore 内 AES-GCM(API 23+),密钥不可导出,抄走的文件在别的机器上解不开

实现要点:

  1. 别名 niskle_link_hub_token_v1KeyGenParameterSpecAES/GCM/NoPaddingsetUserAuthenticationRequired(false)setRandomizedEncryptionRequired(true)

  2. 信封结构(避免每次请求走 Keystore 慢路径,也便于轮换):随机 32 字节 DEK → 用 DEK 加密 token(随机 12 字节 IV,128-bit tag)→ 用 Keystore 的 KEK 加密 DEK(同样随机 12 字节 IV)→ 存 base64([1B 版本][12B wrapIV][wrapCt][12B dataIV][dataCt])。

  3. IV 处理:IV 每次都从 SecureRandom 取新的 12 字节,重用一次都不行;GCM 下 IV 重用是灾难性错误。IV 需要保证的是唯一性而非保密性,明文拼接存储是正确的。

  4. 密钥失效KeyPermanentlyInvalidatedExceptionUserNotAuthenticatedExceptionAEADBadTagException、解密后校验失败,全部走同一条路径——清 remote_token_sealed + remote_client_id待配对 → toast「配对信息已失效,请重新配对」。不做「先试试无令牌请求」的模糊降级,那只会让用户看到一堆 unauthorized 却不知发生了什么。

  5. 解密入口唯一:只有 RemoteTokenStore 能产出明文 token;明文只以参数形式在进程内传递,不进任何 data classtoString

11.3 日志不泄露令牌与配对码

AppLogAppLog.kt:23-41)既 Log.x 到 logcat 又写入内存环形缓冲;LogExporter.exportToDownloadsAppLog.dumpAll()LogExporter.kt:97写到公共 Downloads 目录:26-31);CrashHandler 闪退时把同一份缓冲写到应用私有目录 + Downloads(:37-53);:148-165 的兜底路径还会写 getExternalFilesDir,部分设备上可被文件管理器读到。措施:

  1. RemoteLogScrubber.scrub(s):正则替换 (?i)(token|authorization|bearer|pair_code|secret)\s*[:=]\s*\S+$1=***,并对疑似 base64url 的长串整体打码。

  2. remote 日志全部过 scrubber;RemoteClient/RemoteTokenStore/SSE 解析层不直接调 AppLog

  3. 配对码只存在于配对页 Composable 局部状态与单次请求体,不进 Repository、不进持久化、不进日志

  4. 新增自检:debug 构建导出日志前断言 dumpAll() 不含任何已注册敏感串,把「有没有泄露」变成可回归的测试。

  5. 顺带建议(属 PC 端范畴):network_security_config.xml:3 是裸的 cleartextTrafficPermitted="true",将来若要收窄头显端明文范围,做法是改成 domain-config 白名单。


12. 风险与未决问题

#风险影响缓解
R1无 git 历史、无测试、无 CIgit log 为零提交(约 30 个未跟踪项),app/src 只有 main(无 test/androidTest),任何重构都没有回滚点和回归网P4 的第一个交付物就是 app/src/test + 纯函数测试(发现排序、peer 过期、信封编解码、错误映射、令牌往返、CapabilityGate);为此 HttpJson/CapabilityGate/PeerTable不依赖 Android 框架。动手前另打一份目录快照(不执行任何 git 写命令)
R2广播发现可靠性:AP 广播抑制、多 AP 漫游、头显 Wi-Fi 省电都会丢信标① 四级优先级保证推流场景下第 2 条即命中;② 手动 IP 始终可达;③ MulticastLock 只在搜索窗口持有(§4.4)
R3StreamService 同进程做 UVC 采集 + YUV→JPEG,新增 SSE 与发现线程会争 CPU/网络RemoteHub 只在 Dispatchers.IO;无线程轮询式 DataStore 写入;低频探测最小 30 秒;断线退避而非死重连
R4runBlocking 反模式就在隔壁PreferencesRepository.kt:169-203,被 StreamService.kt:50,225-232,415 以 1Hz 与逐帧调用)新代码全挂起;Review 检查项:remote/ui/remote/ 中不出现 runBlocking(可用 grep 当 CI 规则)
R5玻璃 UI 在目标机上是空操作:API 33 门槛(HomeScreen.kt:201LiquidGlass.kt:134)vs Pico Neo3 的 API 29低(影响预期)§8.4 已明确:不承诺视觉打磨,验收只看信息可读与可点
R6Hub 端 API 尚不存在(P0–P3 未实现,/v1/* 一个端点都没有)高(阻塞)① 先写 ~150 行 mock(PowerShell HttpListener 或 Python)实现 5 个端点 + 错误注入(busy/unauthorized/unknown_op);协议 §11.4 也要求 Hub 提供探针脚本,正好复用;② Hub 端 curl 验证通过前,Link 侧联调结论都不算数
R7协议无机器可校验的一致性点(协议 §11.4 已自陈漂移风险)单元测试固化 v1 的 op 白名单与错误码封闭集;未知 op → 置灰,不崩
R8HttpURLConnection 无取消、连接池行为受限短超时 + Job 取消兜底;不基于连接复用做任何假设
R9remote/ 与既有 update/ 都叫「Repository」但语义不同RemoteRepository/RemoteClient 前缀;KDoc 写清二者不可互相复用
R10头显输入法与 Compose adjustResize 的组合差异windowSoftInputMode="adjustResize"AndroidManifest.xml:72)已就位;键盘是否遮挡配对输入框只能在真机上看,编译期验证不了

12.1 协议修订项(已全部采纳并写入协议规范

编写本文时针对协议规范 v1 提出了 7 条修订意见,均已采纳,下表是它们的落地位置。 实现时以协议规范为准,本节仅作追溯。

#原问题协议中的落地
1信标既有固定发送端口又有 port 字段,语义重叠协议 §10.1:信标恒发 37022/UDP 且不可配置port 只表示控制 API 的 TCP 端口
2GET /v1/info 缺完整字段表协议 §3.1:已补完整字段表与「不得返回什么」的清单,并新增 addresses[]
3busy.retry_after_ms 缺省值与上限未定义协议 §4.3:缺省 500 ms、上限 3000 ms,客户端最多自动重试一次
4retry: 的发送时机与是否持久化未定义协议 §8.2:每条连接建立时发一次;客户端host:port 持久化(原稿「仅进程内生效」已按协议升级为持久化)
5notice.sticky:true 缺超时与数量上限协议 §8.3:30 秒后降级、同时最多 1 条、服务端 10 秒内同文本去重、整体 ≤5 条/10 秒(原稿写的 60 秒/3 条已按协议修正)
6client.id 生成方与存储位置协议 §5.2:Link 生成 UUID v4,首次配对面生成、终身不变;Hub 见到已知 id 视为重新配对,不新增条目
7多网卡 PC 时学到的 IP 可能不通协议 §10.3(新增):把三个来源的地址合并成候选列表并发探测,取最先成功者。/v1/infoaddresses[] 由 Hub 枚举并排序

另外协议还新增了两节与本文强相关:§0.1 / §15(控制面与未来串流媒体面的解耦要求)。 Link 将来要承载串流,因此 §15.2 的分层要求同样约束本文的 remote/ 包 ——remote/ 里的纯逻辑部分不依赖 Android 框架,以便将来头显端的媒体面复用它。


13. 分阶段实施(对齐协议 §13 的 P4–P7)

阶段交付物主要文件真机验证
P4-preapp/src/test 源集 + 纯函数单测;RemoteDialog 抽取(LiquidModal 提为 internal);底部栏改造步 1–2app/src/test/java/com/framecast/stream/remote/**LiquidGlass.kt(102)、LiquidBottomTabs.kt(363-434)、HomeScreen.kt(2017).\gradlew.bat testDebugUnitTest 通过;头显上两个 Tab 视觉无回归
P4发现(四级)+ 配对 + 状态机 + HTTP/SSE 客户端 + 连接/配对页 + 总览页;导航改造步 3remote/**(全部)、ui/remote/{RemoteViewModel,LinkScreen,ConnectPage,OverviewPage,RemoteControls}.ktMjpegHttpServer.kt(94 处加 1 行 hint)冷启动 3 秒内自动找到 PC;配对后重启 App 仍「已连接」;关掉 Hub → 面板内 toast「Hub 未启动」;PC 重启后自动重连
P5追踪/面捕控制页(tracking.*/face.*/vrcft.*)+ notice toast + RemoteToastHostui/remote/{FacePage,RemoteControls}.ktremote/{RemoteRepository,EventStream}.kt头显开关追踪,PC 桌面 UI 立刻同步;杀掉 Engine → 1 秒内红 toast;vrcft.stop 弹确认
P6体感追踪页(设备列表、部位分配、比例、校准、自动测量)ui/remote/BodyPage.ktremote/RemoteModels.kt追踪器列表与 PC 一致;分配部位后 PC 同步;body.start(会拉起 Java)有强确认;body.autobone 全流程可走完
P7更新页 + 已配对设备管理ui/remote/UpdatePage.ktupdate.check/update.install 确认文案正确;client.list 与 PC 一致;client.revoke 后该设备回到 待配对

每阶段收尾的固定动作:① .\gradlew.bat assembleDebug 通过;② .\gradlew.bat testDebugUnitTest 通过;③ 真机 Pico Neo3 按上表手工验证并留截图/日志;④ 全程不执行任何 git 写命令(仓库无历史,git checkout/reset/commit 都可能造成不可逆损失)。


14. AI 实现提示词(P4 + P5)

把下面整段交给一个在 D:\Work\Niskle-Link 里工作的 AI 编码代理。先只读代码,再改代码。

你在 D:\Work\Niskle-Link(Niskle Link,Android 头显端 App)里实现「远程控制 PC 端 Niskle Hub」的头显侧客户端。

一、必读(只读,不要改)
1. C:\Users\Administrator\Desktop\Niskle互联方案\01-互联协议规范-v1.md —— 唯一协议真相源,不要自己重新定义协议。
2. 先完整读这些文件再动手:
   app/src/main/java/com/framecast/stream/update/{UpdateRepository,UpdateConfig,UpdateInfo}.kt
   app/src/main/java/com/framecast/stream/data/PreferencesRepository.kt
   app/src/main/java/com/framecast/stream/ui/screens/{HomeScreen,LiquidGlass,LiquidControls,LiquidBottomTabs}.kt
   app/src/main/java/com/framecast/stream/FrameCastApp.kt
   app/src/main/AndroidManifest.xml、app/build.gradle.kts、settings.gradle.kts

二、技术栈与硬约束(违反即返工)
- Kotlin + Jetpack Compose + Gradle KTS,package com.framecast.stream,minSdk 24 / targetSdk 34。
- 【禁止新增任何第三方依赖】。JSON 一律用平台自带的 org.json 手写解析(参考 UpdateInfo.fromJson)。不要引入 Retrofit/OkHttp/Gson/Moshi/kotlinx-serialization/Ktor。
- 【禁止复用 UpdateRepository】。它的超时是 15s/60s 且不可覆盖(UpdateConfig.kt:29-30),会对休眠中的 PC 卡死 UI。新写一个客户端:连接超时 2s、读取超时 5s,每次调用可覆盖;每个 HttpURLConnection 实例都必须显式设置 connectTimeout/readTimeout,并 finally { disconnect() }。
- 【持久化只用挂起函数】。绝对不要写 runBlocking,也不要新增任何 …Sync() getter(PreferencesRepository.kt:169-203 的 runBlocking 是既有反模式,不要复制)。
- 令牌不能明文落盘:用 javax.crypto + android.security.keystore 做 AES-GCM 信封加密后再存进 DataStore(framecast_settings_v2)。令牌与配对码绝不进 AppLog/日志/异常 message(AppLog 会被 LogExporter 导出到公共 Downloads 目录)。
- 设计系统组件都是 internal(模块内可见,可直接用):ContentGroup、LiquidGlassSurface、StandardActionButton、StandardSwitch、StandardSlider、ProgressiveBlurHeader、LiquidButton、LiquidActionButton(有 loading 参数)、LiquidTextField、LiquidChoiceField、LiquidNavRow、LiquidSegmentButton、LiquidProgressIndicator。缺少列表/下拉/Toast/对话框组件,需要自己建;LiquidModal 在 HomeScreen.kt:2017 是 private,把它提升为 internal,不要复制一份。
- 新增包 com.framecast.stream.remote(纯逻辑,其中不依赖 Android 框架的部分要能跑单元测试)与 com.framecast.stream.ui.remote(Compose)。
- 新建 app/src/test 单元测试源集,至少覆盖:发现优先级排序、peer 表按 instance_id 去重与 6 秒过期、请求/响应信封编解码、错误码→RemoteError 映射、令牌加解密往返、能力门控(op 不存在或 since>1 → 置灰)。

三、本次范围(只做 P4 + P5)
P4:发现(按协议 §10.2 四级优先级:记住的端点 → 从 MjpegHttpServer 入站连接学习 PC 的 IP → 监听 UDP 37022 信标 → 手动 IP)、配对界面(6 位数字码)、连接状态机(未配置/搜索中/待配对/配对中/连接中/已连接/降级/连接中断)、总览状态页。
P5:追踪与面捕控制页、SSE 客户端(status + notice)、面板内 toast 通知队列(去重 + 限流)。
另外:把底部导航栏改造成数据驱动(NiskleTab → data class 列表,去掉 tabsCount=2、selectedIndex 的 if-else、Modifier.width(220.dp) 这些硬编码),并新增一个 LINK 顶层 Tab。
不做:体感追踪页(P6)、更新页(P7)、QR 扫码、云端中继、任何 XR SDK。

四、语义规则
- unknown_op 绝不断连:只把对应控件置灰并提示「PC 端版本过旧」。
- unauthorized:清除本地令牌与 client_id,回到配对流程。
- busy:按 retry_after_ms 自动重试,**最多一次**(缺省 500ms,上限 3000ms,见协议 §4.3);仍失败则把按钮置为可手动重试并提示,不允许无限自动重试。
- SSE 断开只显示「连接中断」,不改配对状态;退避 1→2→4→8→16→30 秒,服务端 retry: 优先。
- 所有错误必须在面板内 toast 显示服务端返回的中文 message,不允许静默失败(唯一例外:SSE 断开只改状态徽章)。
- 配对码/IP 输入只允许数字键盘;触摸目标 ≥ 56dp;破坏性操作必须用 App 内确认弹层。

五、验证与汇报(必须做)
1. 运行 .\gradlew.bat assembleDebug(JDK 17、Android SDK 34),把完整结果如实报告(成功或错误原文)。
2. 运行 .\gradlew.bat testDebugUnitTest,报告通过情况。
3. 明确列出「哪些结论在没有真机 Pico Neo3 的情况下无法验证」,至少包括:UDP 广播在实际 AP 下能否收到、MulticastLock 是否必要、系统键盘是否遮挡配对输入框、与实际 Hub 的端到端联调(Hub 端 API 目前尚不存在,只能对 mock 验证)。
4. 不要执行任何 git 写命令(该仓库没有任何提交历史)。
5. 不要改动 package name、versionCode/versionName、签名配置、minSdk/targetSdk、abiFilters。

15. 附:与既有模块的关系

既有模块关系
update/UpdateRepository.kt不复用(超时不可覆盖、语义焊死 OTA);remote/RemoteClient.kt 是独立实现
service/StreamService.kt互不依赖;唯一接触点是 MjpegHttpServer.handleClient 把对端 IP 写进 MjpegPeerHint
discovery/LanDiscovery.kt不动(继续保持 advertise-only);新增 BeaconListener 负责接收
util/NetworkUtils.kt只读复用 deviceLabel();子网/多接口需求由新增 NetworkSnapshot 补足,不改旧函数
data/PreferencesRepository.kt复用同一个 DataStore 文件,只新增键;不新增 …Sync()
util/{AppLog,LogExporter,CrashHandler}.kt不改行为,但所有 remote 日志都过 RemoteLogScrubber
ui/screens/{HomeScreen,LiquidGlass,LiquidBottomTabs}.kt按 §10.2 三步改造;设计系统组件直接复用,不重写
FrameCastApp.kt新增持有 RemoteHub 单例(与现有 preferences 同级)