Web 客户端平台

Tether 的浏览器 UI 不是自研前端:默认 web.yaml 组合把 dsh 锚点(web/UPSTREAM.json, 当前 c36a83ff)的客户端逐字节 vendoring 到 web/upstream/,品牌与行为偏离一律走 web/patches/ + ALLOWLIST 登记,由源码门禁逐字节判定。tether 自有浏览器能力 (如 @tether/client-ui-sharing、@tether/client-ui-nyxid)作为一等 web/tether/packages/ 包挂进同一 module graph,host 半边是 src/Tether.Web.Client/ 的一组 RemoteFace。

Boot graph:__DSH_BOOT__

  • ClientBootProjector(src/Tether.Web.Client/ClientBootProjector.cs)把 IClientBundleCatalog + 一份 roster 投影成不可变的 boot 快照:window.__DSH_BOOT__ 的图,加上该图能回答的全部 combo 路径。roster 变化必须重新投影——combo URL 里的内容 rev 是对浏览器缓存的承诺,投影不变则字节与 rev 都不变。
  • ClientIndexComposer 输出 index.html 形状(boot 注入点),ClientModuleRegistry 及其 Live 面在宿主侧登记模块;浏览器端的 ModuleLoader 复刻上游 packages/client/modules。
  • 组合裁剪发生在 boot 之前:YAML 里的 client module 列表决定哪些插件 bundle 进图,不挂载 的模块既不下发字节也不出现在 boot graph 中。

Combo 路由

ClientComboComposer(src/Tether.Web.Client/ClientComboComposer.cs)逐条复刻上游 packages/client/modules/src/index.ts:

  • 一个 combo URL(??ids&rev=…)按序拼接多个插件 bundle 为一份响应,附带 Indexed Source Map v3;逐条模块与逐项 rev 均做地址化处理,单条模块超过 MaxComboUrlBytes 即拒绝。
  • 浏览器把 combo 当不可变资源缓存(max-age=31536000, immutable),所以 rev 由 FramedHash("combo", sourceBytes, sourceMap) 派生;路由键首 / 在两个半边的边界上 去掉(上游 comboReference == comboUrl.slice(1))。

RPC / mux / $events

浏览器与宿主之间只有两条传输:REST 静态/上传面,和一条 WebSocket 逻辑流 mux (/api/remote.mux)。

  • 帧协议:RemoteStreamProtocol(src/Tether.Web.Client/RemoteStreamProtocol.cs) 定义上行 open/cancel/item/end 与下行 item/error/end; value 缺席与显式 null 在 wire 上是两回事(上游 value?: unknown),HasValue 开关保证语义。
  • 心跳:mux 按 WebServerOptions.WebSocketHeartbeatMs(缺省 2000ms)发协议级 Ping,连续 RemoteStreamProtocol.MaxMissedHeartbeats(2)次无 Pong 即 terminate ——心跳不在 WebServer 层做,由 RemoteStreamMux 在 codec 上计数、终止前复检。
  • 声明门:RemoteEndpointParameters + VendoredRemoteRoutes 在宿主侧钉死每个 endpoint 的 wire 参数名(RemoteWire.Exact),tests/Tether.Web.Client.Tests/ Integration/WireDeclarationParityTests.cs 从 vendored 上游源解析声明集(remote-events 23 项 ∪ 各包 TypertRemoteEventSelection 增广;SessionProjectionMap 19 键), 与 Tether 下发集做双向对账——Tether 扩展键必须在 TetherExtraProjectionKeys 登记,登记了又没真发也会让门禁失败。
  • $events:SessionRemoteEvents/RemoteInvalidationBridge 把 Cordis 事件转译成 浏览器订阅的投影失效流,客户端靠 follow 帧和投影键形状重建 UI 状态。

认证

BrowserSessionAuth 逐条复刻上游 packages/client/connection/src/browser-auth.ts (spec §2.3-D):

  • 进程级 launch token 只在 GET / 生效,且只接受恰好一个 token 参数(常量时间比较); 命中后铸 cookie 并 303 回干净的 /。
  • cookie 名绑定 authority:dsh-auth-<b64u(sha256(authority))>,同一浏览器连多个 实例互不顶掉;签名里带 authority 做受众绑定。
  • ApiRequestTrust 限定该载体只对回环与显式信任的 authority 开放。

Roster 与同步

  • RemoteFace 是「浏览器端的 seam 投影」:每个 face(SessionRemoteFace、LlmRemoteFace、 AgentTeamsRemoteFace、NyxIdRemoteFace 等)把 Cordis 服务契约翻译成 namespace/endpoint 的 wire 形状,参数名经 RemoteWire.Exact 逐字对齐上游描述符; tether 自有 face(nyxid/*、tetherSharing/*)不进 vendored RemoteInventory 的 Implemented 清单,由 RemoteInventoryTests 负向断言。
  • 投影同步:ISessionProjectionRegistry 的快照经 $events 下发,失效由 RemoteInvalidationBridge 触发重投影;同步历史读取走 SessionObservationReader 租约分页(#244 已废弃任意位置同步读)。

外观、交互与品牌演进

Web 端与原生桌面壳共享同一套客户端代码,最近经历了一系列面向专业性与交互直觉的迭代优化:

  • 品牌主题层收敛:撤除了侵入性的“营造母题”过重装饰与试验性光标水墨层,收敛为极简干净的 Tether 蓝白色盘与零散抽象几何线条,保持界面的克制与纯粹。
  • 状态印章与原生胶囊:印章规制规则严格收窄,彻底杜绝误伤 [data-composer-stats] 计数胶囊与上下文环;统计胶囊与 Dock 上下文触发器完全保留上游原生 24px 圆角与极简无边框设计。
  • 会话侧栏多选与批量管理:对齐现代专业开发工具交互习惯,侧栏会话列表支持 Shift 范围连选、右键上下文菜单一键批量归档或删除;会话条目删除与归档按钮采用两步防误触确认。
  • 设置页「模型提供商」升级:设置中的模型配置全面支持整行点击即时展开编辑,内置提供商允许自定义显示别名,底层通过 volatile baseUrl 热替换无感生效。

门禁与测试

门位置判定
源码门禁web/scripts/sync-upstream.mjs --checkweb/upstream/ 与锚点逐字节一致;偏离必须落在 patches/ + ALLOWLIST
退役门禁web/scripts/retirement-gate.mjs + retired-paths.json112 条退役路径/端点不得复活
Wire 声明门WireDeclarationParityTests转发事件集与 SessionProjectionMap 键集与上游声明双向对账
心跳/流RemoteStreamHeartbeatTests、RemoteStreamHeartbeatLiveTestsPing 节拍、漏 Pong 终止、socket 语义
浏览器集成tests/Tether.WebHost.Browser.Tests/*.mjs真实 WebHost + 真实 Chrome,page error 为零

门禁 4(与 dsh 实例同脚本对跑 a11y 树 + 像素比对)的历史验收记录与运行方式见 scripts/web-ui-parity.md 与 site/pages/parity/。

在 GitHub 上编辑此页