feat: UI 商业化改造 + 连接前 tmux 会话选择(真机验证)

按 Claude Design 定稿(归档在 docs/design/)重做 UI,并把 tmux 从
「从不自动进」修成「连接前探测 → 让用户选会话」。

设计令牌与导航地基
- Theme 拆三层:TXAccent(恒定 blurple) / TXChrome(中性阶×4 表) / TXFlavor(Catppuccin 终端)
- vendor JetBrains Mono 四权重(附 OFL 许可)
- AppRouter + SessionManager:多会话并存,路由与会话生命周期解耦
- SessionCanvas 常驻挂载会话 surface(摘除即丢内容,也是缩回动画的前提)

按设计稿落地的屏
- 沉浸轨道页:56pt 轨道 + 浮起标题/状态胶囊 + 侧边栏三态(遮罩不 resize、Pin 各一次)
- 首页:活动会话卡(readViewportText 文本镜像)+ 主机网格 + 筛选 chips
- 关闭二次确认(tmux 仅断开 / 原生窗口两套文案)、分屏菜单、pane 拖拽条
- 空状态:首次运行 / 无会话 / 搜索无命中 / 连接中·失败·已关闭

tmux 真实链路(真机查出并修掉 4 个 bug)
- 全代码库从来没人发起 attach → 连接前探测 + 会话选择器(接回 / 新建 / 原生终端)
- format 分隔符 tab 经 PTY 变成下划线 → 改用 |:|
- controller 变化不冒泡到 session → Combine 转发(否则数据解析对了 UI 不刷新)
- 当前会话名不能靠 session_attached 反推 → 改用 display-message

传输层:SSH connect 加超时(原来阻塞到系统 TCP 超时 75s+)
无头验证设施:假会话 fixture · terminalx://ui/* 驱动 · 横屏截图脚本 · 对拍走查法
TXCore 45 tests 绿(新增 5 个会话探测单测)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kid
2026-07-25 22:59:35 +08:00
parent 6dd23275ff
commit 28e9cfc207
46 changed files with 10237 additions and 1204 deletions

View File

@@ -58,15 +58,25 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
- **mosh 直连(LAN)**`-txHost H -txPort 22 -txUser U -txPass PW -txMosh 1`(登录后跑 mosh-server→解析 MOSH CONNECT→mosh_main 直连 UDP
- **mosh over tsnet**:额外加 `-txTsnetKey <authkey>`-txHost 填 tailnet IPrelay=loopback↔tsnet UDP
- 自定义 mosh-server 命令:`-txMoshServerCmd "…"`(默认 `mosh-server new -s -c 256 -l LANG=en_US.UTF-8 -l LC_ALL=en_US.UTF-8`
- **tmux 自动进入**:默认开(`SavedHost.useTmux`)。`-txTmux 0` 关闭(回归 raw 终端路径)、`-txTmuxSession <名>` 指定会话名(默认 `main`)。
- **M4 publickey 认证**`-txPubkeyAuth 1`(不给 -txPassSigningKeyProvider 造 P-256 key日志打 authorized_keys 行,加到服务器后重连免密登录)
- **M4 host key mismatch 测试**`-txHostKeyPinOverrideBase64 <b64>`(播种假 pin → 真 host key 不符 → 弹告警)
**UI 改造期无头验证(推荐走脚本)**`scripts/tx-shot.sh <out.png> [-- <启动参数>]` 一步完成装/启/截图/修正方向。
- **`-txLandscape 1`**app 侧 `requestGeometryUpdate` 转横屏(设计基准 iPad Pro 13″ 1366×1024。无 GUI 时模拟器帧缓冲恒竖屏,故脚本截完 `sips -r 90` 转回来(内容已横向渲染)。
- **`-txUIFixture 1`**:造 3 条**假会话**mac-studio/ubuntu-cn2/nas-home含 direct/relay/重连三种状态)灌示意输出进 in-memory 终端,**不建任何传输连接**。设计稿的首页会话卡/轨道多头像/状态徽标都要多会话才有内容,用真机连接验证太慢。见 `UIPreviewFixture.swift`DEBUG only`-txHost` 互斥。
- **`terminalx://ui/<动作>`**`xcrun simctl openurl`):无头驱动导航与浮层——`home` / `minimize` / `close`(弹确认) / `close-now`(直接关) / `split/v|h`(分屏菜单) / `dismiss` / `session/<n>` / `sidebar/toggle|pin`。比坐标点击稳(不依赖 idb companion不随布局失效。模态状态放在 `AppRouter.modal` 而非视图 `@State`,快捷键与 URL 才能共用同一入口。
- **对拍走查法(还原度靠这个,别靠目测)**:截图 → 用 python+PIL 把设计稿 PNG 与截图统一缩放到 1366×1024 → 裁同区域并排 → 再对元素列做垂直投影扫描量化 y 坐标。缩略图里 `#1E1E2E``#11121C` 目测分不出,必须采样像素。
- **坑:`statusBarHidden` 不能挂在 `TerminalScreen`**(它常驻挂载,会连首页一起隐藏)→ 挂 `RootView``router.route` 判断。
- 设计稿本体在 `docs/design/``UI-DESIGN-HANDOFF.md`(token/规格/交互全表) + `terminalX-iPadOS.dc.html`(13 屏画板) + `render-screens.py`(拆屏,配 headless Chrome 逐屏截 PNG 对拍)。
**M3 挂起→恢复无头验证**:退后台 `simctl openurl $UDID https://apple.com`;冻结 `kill -STOP $(pgrep -x TerminalX)`;解冻 `kill -CONT`;前台 `simctl openurl $UDID "terminalx://resume"`URL scheme 已注册)。日志 `log stream | grep -E 'MOSHDBG|TXM3'``wake pulse``TXM3 recovered` 时间差判 <3s。**模拟器看门狗会杀掉被 STOP 冻结过久(>~30s)的 app且模拟器不复现 socket defunct/WG 过期,深挂起需真机锁屏补测。**
## 仓库结构
- `packages/TXCore` — 零依赖纯逻辑tmux 解析器/layout/`SessionMachine`(会话状态机+退避重连)/`TmuxControlSequence`(DCS 检测)。
- `packages/TXTransport``SSHSession`(libssh2, 支持外部 fd)/`MoshSession`(mosh_main 桥)/`Transport` 协议C shim: `CSSH`+`CSSHCore`(libssh2)、`CMosh`+`MoshCore`(mosh)。
- `apps/TerminalX/iOS``SSHTerminalModel`(连接编排+tsnet+tmux 路由+mosh 编排)/`ContentView`(HomeView 主机管理页+TerminalScreen 终端 chrome+设置)/`TmuxController`(tmux 网关)/`TsnetProbe`(M1 自检)/`Theme`(TXPalette+TXThemeManager 设计令牌,颜色封装供主题系统)/`HostStore`(主机列表 JSON 持久化)。UI 对标 Moshi(深蓝黑+终端绿+Catppuccin),见 `docs/HANDOFF.md §10`
- `apps/TerminalX/iOS``TerminalSession`(**一实例=一条会话**连接编排+tsnet+tmux 路由+mosh 编排+viewport 快照)/`SessionManager`(多会话持有+最小化/关闭语义+生命周期广播)/`AppRouter`(AppRoute .home|.terminal(id) + SidebarState 三态)/`ContentView`(RootView+**SessionCanvas 会话常驻挂载**+TerminalScreen 沉浸轨道页+TmuxStage)/`HomeView`(首页会话卡+主机网格)/`RailSidebar`(56pt 轨道+288pt 面板三态)/`TerminalChrome`(浮起标题/状态胶囊+提示条)/`TXParts`(设计令牌小件+终端文本镜像)/`TmuxController`(tmux 网关)/`TsnetProbe`(M1 自检)/`Theme`(TXAccent 恒定 blurple + TXChrome 中性阶×4 + TXFlavor 终端配色)/`HostStore`(主机列表 JSON 持久化)/`UIPreviewFixture`(DEBUG 无头 UI 验证脚手架)
- UI 已从 Demo 转入**商业化改造**:设计定稿见 `docs/design/UI-DESIGN-HANDOFF.md`Nocturne chrome + Catppuccin 终端 + 沉浸轨道),路线图与进度见 `docs/HANDOFF.md §12`
- `vendor/` — 所有非 Swift 依赖本地化:`libghostty-spm`(GhosttyKit)/`MSDisplayLink`/`libssh2`/`mbedtls`/`tsnet-bridge`(Go)/`mosh`(blinksh/ios 分支)/`protobuf`(3.21.12)`build/mosh/`(手写 config.h+CMakeLists)。
- `artifacts/` — 预编译 xcframework**有意提交**,非 gitignore`CSSHCore`/`TsnetBridge`/`MoshCore`
@@ -79,8 +89,17 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
- **Swift6 `SendNonSendable` pass 可能崩溃**`pthread_create`+`Unmanaged` 上下文+C 入口的组合):改用 `Foundation.Thread`+线程内 `pthread_self()` 规避(见 `MoshSession`)。
- GhosttyKit 的 `InMemoryTerminalSession.receive` 在 surface 未 attach 时**丢弃**写入 → 用 `OutputGate` 缓冲到 `surfaceSize != nil` 再 flush。
- tmux `-CC attach` **不推 %window-add** → 必须主动 `list-windows` 枚举。
- **tmux format 的字段分隔符不能用 tab**:真机实测 tab 经 PTY 送到 tmux 后**变成下划线**`list-windows`/`list-sessions` 整行分不开(`count=1`)→ title/cwd/会话名全丢、UI 显示兜底名「窗口 N」。改用 `TmuxController.fieldSep = "|:|"`(可打印、不被 PTY 处理、`#` 会被 tmux format 当特殊字符所以不能用)。**排查提醒**`os_log` 会把控制字符显示成 `_`,光看日志分不出「真下划线」还是「被显示替换的 tab」——要打印 `parts.count` 才能确诊。
- **tmux 当前会话名要用 `display-message -p "#{session_name}"`**,不能拿 `list-sessions``session_attached` 反推:服务器上别的客户端 attach 着其它会话时会猜错(真机把用户自己的 `demo` 当成了当前会话)。
- **轨道/侧边栏显示 `#{window_index}` 而非 window_id**`@2` 是内部 id用户看到的编号是 index0 起,删过 window 后两者会差很远)。
- **`TerminalSession` 必须转发 `TmuxController.objectWillChange`**`tmuxSummary` 等是读 controller 的 computed property而轨道/胶囊/侧边栏只 `@ObservedObject` 订阅 session。没有这条转发数据解析全对但 UI 不刷新真机表现为轨道空、第三格只有「tmux」。同一个坑的另一面见下条。
- **连接流程 = 点主机 → 留在首页探测 → 首页弹选择器 → 选完才进终端**(用户定稿,别改回"自动 attach"或"先进终端再弹框"SSH 连上 → 在**普通 shell** 里跑 `TmuxSessionProbe.command` → 选择器给三种连接方式(接回已有会话 / 新建会话 / **原生终端**,不用"跳过"这种模糊措辞)。`SessionManager.enterRequest` 决定何时进终端;`isProbingTmux` **必须在 `.connected` 当刻置位**,否则连接建立与探测开始(延迟 2.5s)之间的空窗会让 `evaluateEnter` 提前把人送进终端页。**没装 tmux 时不弹框**,直接原生终端 + 安装引导横幅。探测的三个坑:① 命令会被 PTY **回显**,标记必须写成相邻字符串拼接(`"__TX""_SESSIONS_BEGIN"`)否则回显行被当成结果起点;② 「没装 tmux」与「装了但没会话」的 stderr 都被 `2>/dev/null` 吞掉、标记之间同样空,只能靠 `tmux -V` 的版本行分开(两者 UI 完全不同);③ 探测命令与输出会脏屏 → 命令末尾接 `printf '\033[H\033[2J\033[3J'` 清屏(刚连上时清掉的只有 motd
- **tmux 不会自己进**`TmuxRouter` 只被动检测字节流里的 DCS 前导,**必须由 app 主动发 `tmux -CC new -A -s <名>`**(登录后 2.5s 等提示符就绪,沿用 mosh 引导已验证的延迟)。`-A` = 同名会话存在就接回。4s 内没进 control mode 判定无 tmux → 降级客户端窗口 + 行内安装引导横幅(设计 2c**不弹窗不强推**。曾经漏了这一步 → 真机连上永远显示「无 tmux」。
- **tmux 多 pane 尺寸走"路线 b"**app 按全屏格子发 `refresh-client -C WxH` 让 tmux 布局pane surface 尺寸严格取 layout rect%output 按 tmux pane 宽高排版,二者不一致必换行/清屏错乱pane surface 的 resize 回调**只断言不反向驱动 tmux**防反馈环。diff 用 full layout、渲染用 visible layoutzoom 免费SwiftUI identity 只用 paneID布局变不重建 surface。新 pane 在 `capture-pane` %end 前丢弃其 %outputtmux 单线程串行保证快照不缺不重)。
- **tmux 网关字节必须单消费者保序**:多个 `Task{@MainActor}` 到主线程顺序无保证,多 pane 高吞吐会打碎 % 协议 → 用 `TmuxByteChannel`(AsyncStream) 单消费者。
- **导航不得销毁会话**:终端**不能**走 `fullScreenCover`——① surface 一旦从视图树摘除ghostty 释放 grid内容全丢② matchedGeometry最小化缩回首页卡要求两端同层级独立 presentation 层做不到。故 `SessionCanvas` 把所有会话 ZStack 常驻挂载、只切 opacity + `allowsHitTesting`(沿用 tmux 切 tab 已验证的手法),画布恒全屏 → 切路由不触发 PTY resize。
- **首页缩略卡不许再开 surface**:一个 ghostty surface 只有一个 IOSurfaceLayer同处两地/离屏 Metal 渲染是已修过的泄漏雷区。缩略卡走 `TerminalSession.captureSnapshot()``readViewportText()` 抓尾部若干行)+ `TXTerminalMirror` 文本渲染(底对齐),代价只是丢 ANSI 颜色。
- **嵌套 ObservableObject 不冒泡**`SessionManager``@Published sessions` 不会因某条会话内部 `@Published` 变化而发布。凡显示会话状态的行/卡片都要抽成独立视图用 `@ObservedObject var session:` 直接订阅(见 `SessionSidebarRow`/`SessionStatusRow`)。
- SourceKit "No such module" 是索引噪声,**以 `swift build`/`xcodebuild` 为准**。
- 密钥/密码**绝不写进仓库**;测试主机凭据由用户即时提供。
- 提交/推送 git 只在用户明确要求时main 分支已有 6 次提交,未 push、无 remote