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增广;SessionProjectionMap19 键), 与 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/*)不进 vendoredRemoteInventory的Implemented清单,由RemoteInventoryTests负向断言。 - 投影同步:
ISessionProjectionRegistry的快照经$events下发,失效由RemoteInvalidationBridge触发重投影;同步历史读取走SessionObservationReader租约分页(#244 已废弃任意位置同步读)。
外观、交互与品牌演进
Web 端与原生桌面壳共享同一套客户端代码,最近经历了一系列面向专业性与交互直觉的迭代优化:
- 品牌主题层收敛:撤除了侵入性的“营造母题”过重装饰与试验性光标水墨层,收敛为极简干净的 Tether 蓝白色盘与零散抽象几何线条,保持界面的克制与纯粹。
- 状态印章与原生胶囊:印章规制规则严格收窄,彻底杜绝误伤
[data-composer-stats]计数胶囊与上下文环;统计胶囊与 Dock 上下文触发器完全保留上游原生 24px 圆角与极简无边框设计。 - 会话侧栏多选与批量管理:对齐现代专业开发工具交互习惯,侧栏会话列表支持
Shift范围连选、右键上下文菜单一键批量归档或删除;会话条目删除与归档按钮采用两步防误触确认。 - 设置页「模型提供商」升级:设置中的模型配置全面支持整行点击即时展开编辑,内置提供商允许自定义显示别名,底层通过
volatile baseUrl热替换无感生效。
门禁与测试
| 门 | 位置 | 判定 |
|---|---|---|
| 源码门禁 | web/scripts/sync-upstream.mjs --check | web/upstream/ 与锚点逐字节一致;偏离必须落在 patches/ + ALLOWLIST |
| 退役门禁 | web/scripts/retirement-gate.mjs + retired-paths.json | 112 条退役路径/端点不得复活 |
| Wire 声明门 | WireDeclarationParityTests | 转发事件集与 SessionProjectionMap 键集与上游声明双向对账 |
| 心跳/流 | RemoteStreamHeartbeatTests、RemoteStreamHeartbeatLiveTests | Ping 节拍、漏 Pong 终止、socket 语义 |
| 浏览器集成 | tests/Tether.WebHost.Browser.Tests/*.mjs | 真实 WebHost + 真实 Chrome,page error 为零 |
门禁 4(与 dsh 实例同脚本对跑 a11y 树 + 像素比对)的历史验收记录与运行方式见
scripts/web-ui-parity.md 与 site/pages/parity/。