Files
terminalX/docs/HANDOFF.md
kid 28e9cfc207 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>
2026-07-25 22:59:35 +08:00

292 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# terminalX 交接文档HANDOFF
> 目的:让**没有上下文的新会话**读完即可掌握项目全貌并继续工作。配合 `../CLAUDE.md`(操作规范/命令)与项目记忆(`~/.claude/projects/-Users-kid-...-terminalX/memory/`,本项目会话会自动注入)一起看。
> 最后更新2026-07-24。
---
## 0. 一句话现状
一个**原生 iPad SSH 终端**已可用并真机验证libghostty 渲染 + 键盘输入 + SSH 直连 + **SSH-over-tsnet(Tailscale 用户态)** + **tmux -CC 原生 tab + 多 pane 分屏** + **mosh(LAN + over-tsnet 均已端到端验证)** + 断线自动重连 + **mosh 挂起→恢复<3s(M3)**。四大核心需求libghostty/tailscale/tmux/**mosh 均已验证**)。仅在 iOS 模拟器验证(未上真机/未签名)。
> **2026-07-24 本轮**:修复 3 个渲染/tmux 显示 bug + 完成 UI 产品化第一波(对标 Moshi。详见 **§10**。UI 已从 demo 态变为深色 Catppuccin 主题 + 主机管理页 + 终端 chrome + 统一主题切换。
> **mosh 状态2026-07-24 全部验证通过)**mosh(C++,blinksh/ios)+protobuf-lite 交叉编译成 `MoshCore.xcframework`CommonCrypto 后端,含 arm64 模拟器 slicetsnet 桥加 UDP relay(`StartMoshRelay`)`MoshSession`(pipe↔FILE*/pthread/SIGWINCH)+`MoshConnectScanner`(TXCore)+`SSHTerminalModel` 编排SSH 引导 mosh-server→解析 MOSH CONNECT→接管
> - **LAN 直连端到端验证**192.168.9.199mosh-server 存活 104s≫60s 无客户端超时+子 shell+心跳=真连。
> - **mosh over tsnet 端到端验证**vohive-vm 100.69.201.101app 日志 `parsed MOSH CONNECT port=60002`→`relay up localPort=63542`→`activateMosh started`;屏幕渲染远端 shell 且无 mosh 断连 overlay=relay UDP 双向流通。**R1(tsnet UDP 数据报语义) 证伪**。
> - 复现 tsnet模拟器已持久化 tsnet 节点状态(`~/Library/.../tsnet-main`)`-txTsnetKey` 传任意非空串即可凭存量身份重连(节点未被 tailnet 清理时);节点被清理则需新 ephemeral key。
## 1. 项目愿景与硬约束
- 目标iPad/iPhone 远程轻量运维 macOS/Linux + vibe coding。平台优先级 **iPadOS > iOS > macOS(Apple Silicon)**
- iOS/iPadOS **只远程**(沙盒禁 fork/exec不能跑本地 shellmacOS 才支持本地 PTY。
- 分发目标 App StoreM5已暂缓
- 四大核心:**libghostty**(终端引擎) · **mosh** · **Tailscale tsnet(用户态,非 NetworkExtension)** · **tmux control-mode(-CC) hook**
## 2. 关键架构决策(已定,勿推翻)
1. 终端引擎用 **libghostty**,经社区 **GhosttyKit**`Lakr233/libghostty-spm`,已 vendor 到 `vendor/libghostty-spm`)。`TerminalViewState``ObservableObject`;喂字节入口 `InMemoryTerminalSession.receive(Data)`SwiftUI 视图 `TerminalSurfaceView(context:)`
2. Tailscale 走 **tsnet 用户态**Gogomobile bind → `TsnetBridge.xcframework`**不用 NetworkExtension**(避开 VPN entitlement + 50MB 内存墙)。
3. SSH 用 **libssh2 + mbedTLS**`CSSHCore.xcframework`),支持传入外部 fd。
4. **SSH-over-tsnet = fd 桥**`tsnet.Dial()` 得 net.Conn无真实 fd`socketpair(AF_UNIX)` + 双向 io.Copy 泵 → 把另一端 fd 交给 libssh2。见 `vendor/tsnet-bridge/tsnetbridge/bridge.go: DialTCPFD`
5. 一进程只一个 Go runtime、只一次 gomobile bindC/C++ 库符号隐藏只导出 C API。
6. 多账号(未实现):多 tsnet 节点并存;每 host 绑 egress profile(Direct/tailnet)**禁止多 tailnet 并发盲探**(重叠 100.x/同名会误连)。
## 3. 环境(决定构建方式,极重要)
- **带认证的 HTTPS 代理**`HTTPS_PROXY=...@...:38128`)。`github.com` release blob 能 curl**go.dev / ghcr.io 被代理 abort****SwiftPM 不支持带认证代理** → 所有 SPM 依赖 vendor 本地化。Go 模块用 `goproxy.cn`
- **Go 1.26.5 在 `~/.local/go`**(不在默认 PATH经 aliyun 镜像 curl 装),**gomobile/gobind 在 `~/go/bin`**`go env -w GOPROXY=https://goproxy.cn GOSUMDB=off` 已固化。
- 工具链Xcode 26.6 / Swift 6.3 / xcodegen 2.46 / zig 0.15.2 / cmake 4.4 / ninja 1.13 / git。
## 4. 里程碑状态
| 里程碑 | 状态 | 真机实证 |
|---|---|---|
| **M0** SSH 直连终端 | ✅ 完成 | libghostty 渲染(SGR/CJK)、键盘输入、连接;**白屏 bug 修复**(OutputGate);会话状态机+自动重连TXCore 单测总数见 §5=50 |
| **M1** tsnet + SSH-over-tsnet | ✅ 完成 | app 内 tsnet 加入 tailnet(IP 100.74.60.108);对端枚举;**SSH-over-tsnet 登录 Ubuntu shell**(服务端 `Last login from 100.x` 证明走 tailnet |
| **M1.5** tmux -CC 原生 tab | ✅ MVP | 直连跑 `tmux -CC attach` → 渲染 fish/tmux 两原生 tab + 活动窗口内容 |
| dial 重试打磨 | ✅ | `TsnetManager.dialFD` 6 次退避重试,解决 tsnet 首连路径预热超时 |
| **R5** Go+gvisor+C+++Swift 同进程 | ✅ retire | 全程真机无崩溃 |
| **M2** mosh | ✅ 完成LAN + tsnet 均验证) | libmoshios(C++)+protobuf-lite→`MoshCore.xcframework`UDP relay over tsnet**LAN 直连 + mosh-over-tsnet 均端到端验证通过**R1 证伪) |
| **M3** 生命周期深化 | ✅ mosh 快恢复(真机深挂起待补) | scenePhase FSM + **mosh 挂起→恢复<3s 已验证**模拟器15/30s 冻结→前台wake pulse→1.3s 收到服务器包,"已恢复");冷启动重连(SSP 序号+key 持久化)划入 M4/M5 |
| **M4** 安全 | ✅ 完成(真机 SE 硬件待补) | **host key TOFU pin**(首次信任+不符告警,指纹对齐 ssh-keygenMITM 关闭)+ **publickey 认证**P-256 sign_callback→SecKeyCreateSignature→DER→SSH mpint免密登录 192.168.9.199 验证通过SE 优先/未签名回退软件+文件SE 生成在模拟器 hw=true 可达,真机 SE 硬件签名+keychain 持久化待补) |
| **M5** 合规提审 | ⏸️ 暂缓 | 用户指示暂缓 |
## 5. 代码地图与数据流
- `packages/TXCore/Sources/TXCore/`
- `Tmux/TmuxControlParser.swift` `TmuxEvent.swift` `TmuxIDs.swift` `TmuxOutputDecoder.swift` `TmuxLayout.swift` `TmuxControlSequence.swift`DCS 检测)—— 纯解析。TXCore 全部单测共 **50**40 swift-testing + 10 XCTesttmux 解析/`SessionMachine`含mosh三相/`MoshConnect`/`SSHWire``swift test --package-path packages/TXCore` 全绿。
- `Session/SessionMachine.swift` —— `reduce(event)->[Effect]` 会话状态机(指数退避重连/后台冻结/前台恢复)。**M3 加 mosh 三相** `moshActive/moshParked/moshResuming` + 事件 `moshEstablished/moshHealthy/moshExited/resumeWatchdogFired` + 效果 `nudgeResume/scheduleResumeWatchdog/teardownMosh`mosh 接管即拆 SSH、后台冻结不重建、前台打唤醒脉冲等 SSP 续、两次脉冲无效兜底全量重建。
- `packages/TXTransport/Sources/`
- `TXTransport/Transport.swift`(协议+SSHConfig+SSHError`SSHSession.swift`libssh2`preconnectedFD` 支持外部 fd事件循环 poll+非阻塞)。
- `TXTransport/MoshSession.swift`M2驱动 `mosh_main`pipe↔FILE* 桥in/out、Foundation.Thread 跑阻塞主循环+`pthread_self()``pthread_kill(SIGWINCH)` resize、忽略 SIGPIPE、setenv UTF-8 locale。
- `CSSH/`C targetlibssh2 头 + modulemap`import CSSH`)、`CSSHCore`binaryTargetlibrary-only
- `CMosh/`C target`moshiosbridge.h`(mosh_main)+modulemap+占位 shim.c`import CMosh`)、`MoshCore`binaryTargetlibrary-onlyTXTransport 链 `libc++`/`libz`mosh 是 C++ 且 compressor 用 zlibCommonCrypto 属 libSystem 自动解析)。
- `TXCore/Mosh/MoshConnect.swift``MoshConnectScanner` 流式扫描 `MOSH CONNECT <port> <key>`(注意 Swift 视 `\r\n` 为单 Character行尾判定须用 `isNewline`)。
- `TXCore/SSH/`M4`SSHWire`(string/mpint 编码)/`ECDSAConv`(DER→SSH 签名·P256 blob)/`HostKey`(opensshFingerprint·evaluate·HostTriple·KnownHostsStore 协议)SSHWireTests 10 单测(真实指纹向量+高位 DER
- **M4 安全**`TXTransport` `SSHConfig.hostKeyVerifier`(闭包)+`SSHError.hostKeyMismatch`+`SSHSigner` 协议+`Authentication.publicKeyCallback``SSHSession` 握手后 `libssh2_session_hostkey` 校验、`sshSignCallback`(@convention(c)+malloc 出参)接 `libssh2_userauth_publickey`。app`KeychainKnownHostsStore`(文件后端 TOFU pin)、`SigningKeyProvider`(SE 优先/软件回退+文件持久化,`SecKeyCreateSignature`)、mismatch alert(ContentView)。
- **M3 快恢复链**`bridge.go``Node.WakeUp()`(tsnet `InjectEvent`+`MagicSock.Rebind/ReSTUN`) 与 `MoshRelay.Rebind()`(同端口重开 loopback、泵按 socket 生成容错)`iosclient.cc``g_mosh_last_heard`(收到服务器包即更新,导出 `mosh_last_heard_ms()`) 并恢复 SIGCONT 全屏重绘;`MoshSession.nudge()`(SIGCONT) + `lastHeardMs()``SSHTerminalModel` 前台执行"唤醒脉冲三连"(WakeUp+Rebind+nudge)+healthy 轮询(探针增长→`moshHealthy`)+恢复看门狗+`beginBackgroundTask`
- `apps/TerminalX/iOS/`
- `SSHTerminalModel.swift` —— 核心编排:`OutputGate`(surface-ready 缓冲)、`TransportHolder``TsnetManager`(tsnet 节点 up/dial+重试)、`TsnetError``TmuxRouter`(raw/gateway 字节分流)、状态机驱动 startSession(直连/tsnet 分支)、`autoConnectIfConfigured`(启动参数)。
- `ContentView.swift` —— 分支:`tmuxController != nil``TmuxTabbedView`(tab 条+活动窗口);否则 `RawTerminalView``TsnetProbeView`(纯自检)`ConnectionForm`
- `TmuxController.swift` —— tmux 网关:消费解析事件;**pane-per-surface**`TmuxPaneSurface`=session+state+gate/pane`TmuxWindow` 持 panes 字典 + `visibleLayout`(渲染)/`fullLayout`(diff)`surfaceByPane` O(1) 路由 %output`applyLayout` reconcile(增删留)attach: `refresh-client -C WxH``list-windows`(带 layout)→每 pane `capture-pane``awaitingCapture` 丢弃快照前 %output输入绑 pane→`send-keys -t %self`tap→`select-pane`。字节经 `SSHTerminalModel``TmuxByteChannel`(AsyncStream 单消费者)保序入网关。
- `TsnetProbe.swift` —— M1 自检视图(加入 tailnet + 对端列表)。
- `TerminalXApp.swift` —— @main
- **数据流(直连)**transport.onBytes → `TmuxRouter.feed` → (raw) `OutputGate.deliver``session.receive`→libghostty(检测到 DCS) → `TmuxController.feed`→解析→各 window。输入libghostty→session write 闭包→transport.sendtmux 模式→send-keys
- **数据流(tsnet)**`TsnetManager.ensureUp`(auth key)→`dialFD`(socketpair fd)→`SSHSession(preconnectedFD:)`
## 6. 已知问题 / TODO继续工作的入口
- **M4 安全(已完成,剩真机 SE**host key TOFU pin(`SSHConfig.hostKeyVerifier`→app `KeychainKnownHostsStore` 文件后端) + publickey 认证(`Authentication.publicKeyCallback(SSHSigner)``SSHSession` C `sshSignCallback``SigningKeyProvider`)。**剩**:真机验证 SE 硬件密钥生成+签名+keychain 持久化(未签名/模拟器回退软件+文件);`libssh2_session_method_pref` 锁定 pinned hostkey 类型防算法漂移误报fable 建议未做M4DBG NSLog 埋点可清理。
- **tmux 多 pane 分屏 + 动态 resize**:✅ 已做pane-per-surface + layout rect 绝对定位渲染 + 按 pane %output 路由 + tap 切焦点/select-pane + attach `refresh-client -C` 设尺寸 + **iPadOS 拖拽窗口/旋转 → `onChange(geo.size)`→`containerResized`(点×displayScale÷cell像素)→debounce→refresh-client -C→%layout-change→更新 frame**,验证:容器 1032x1280@2x cell16x35→129x73tmux 精确采纳)。**剩余**:完整 scrollback仍靠 capture-pane 抓当前屏pause 流控(`%pause/%continue`,防单 pane 刷屏split/kill pane 手势tmux<3.1 降级路径未做
- **tsnet 首连**已加 dial 重试更优是等 peer online dial PeersJSON online 状态)。
- **签名/真机**`CODE_SIGNING_ALLOWED=NO`仅模拟器上真机/TestFlight 需配置签名 + entitlementstsnet 用户态**无需** NE entitlement利好审核)。
- **git**main 分支已有 6 次提交初始 + M2/M3/tmux多pane/tmux resize/M4a/M4b工作区干净** push 远端** remote)。commit/push 只在用户明确要求时`artifacts/*.xcframework``CSSHCore`/`TsnetBridge`/`MoshCore`+ `vendor/mosh`+`vendor/protobuf` 源码**均有意提交**克隆即可构建无需网络构建中间产物(`vendor/build/mosh-out``vendor/protobuf/build-*`) gitignore
- **多平台**iOS target 已跑macOS target本地 PTY, `TXPTY`未建
## 7. 下一步建议M0~M4 + tmux 多pane/resize 均已完成并验证;以下按价值)
1. **真机化(最大缺口,多项验证只在模拟器)**配置签名 + entitlementstsnet 用户态**无需** NE利好审核)→ 上真机/TestFlight真机上补验三件模拟器测不了的:①**SE 硬件密钥**生成+签名+keychain 持久化M4b 现回退软件+文件`SigningKeyProvider` 已备 SE 路径);②**mosh 深挂起恢复**锁屏数分钟过 WG 180s 过期线 / socket defunct `MoshRelay.Rebind`+`hop_port`);③签名后 **Keychain 可用**当前未签名 SecItem -34018host key pin 才回退到文件)。
2. **产品化**主机列表持久化签名后用 Keychain 存凭据/host 配置)、多账号多 tsnet 节点架构决策 §2.6 host egress禁多 tailnet 盲探)、软键盘运维工具栏GhosttyKit 内建接线即可)、多标签 tmux)。
3. **打磨(可选,不阻塞)**
- 清理诊断埋点`SSHTerminalModel`/`TmuxController`/`iosclient.cc` `NSLog("MOSHDBG/TXM3/TMUXDBG/M4DBG …")` + moshiosbridge.cc `fwrite("Hello from the Bridge!")`
- mosh`activateMosh` 写死 80x24 初值靠首次 resize 纠正mosh 接管后 idle SSH 未关`MoshSession.close()` mosh 线程可能滞留远端累积 detached mosh-server每次 `mosh-server new`需清理策略
- tmuxpause 流控(`%pause/%continue` 防单 pane 刷屏)split/kill pane 手势外接键盘 `Cmd+Opt+方向` 导航 pane完整 scrollback现靠 capture-pane 抓当前屏tmux<3.1 降级路径
- M4`libssh2_session_method_pref` 锁定 pinned hostkey 类型防算法漂移误报host key pin UI 管理查看/删除已信任)。
- tsnet 首连更优是等 peer online dial PeersJSON online 状态)。
4. **冷启动 mosh 重连(大活)**mosh 进程被杀后接原 detached 会话—— SSP 序号防重放限制需持久化 MOSH_KEY+port 且改 mosh 序列化客户端状态Blink )。价值中等成本高
5. **macOS target**本地 PTY`TXPTY`未建
6. **M5 合规提审**用户指示暂缓
## 8. 测试资源(用户提供,**凭据勿写入仓库/勿硬编码**,每次向用户索取)
- 一台局域网 macOS( tmux + **mosh-server** MacBook-Air-M1fish 为默认 shell`mosh-server` `/opt/homebrew/bin` 且交互 shell PATH 可见)、一台 tailnet online Ubuntu VM(vohive-vm)、一个 tailnet auth keyephemeral可能过期用户会给新的)。用于直连 SSH / SSH-over-tsnet / tmux / M2 mosh 端到端验证凭据每次向用户即时索取
- tailnet `tail3b5057.ts.net`已见节点vohive-vm(linux)、iphone-airipad-promacbook-air-m1macbook-air
## 9. 工作纪律(务必遵守)
- **绝不臆想工具结果**调用后等真实返回再推理绝不编造 BUILD/日志/截图曾因连续编造工具输出含假 BUILD SUCCEEDED错误归因 GhosttyKit而误诊——真相全靠老实 `Read` 完整 log此规则已写入全局 `~/.claude/CLAUDE.md`
- 每步用真实证据验证`swift test` 看真实计数`xcodebuild` 看真实 BUILD 结果模拟器 `screenshot` + `Read` 看真实画面
- 与用户用**简体中文**commit/push 只在明确要求时
## 10. UI 产品化 + 渲染修复2026-07-24 本轮)
### 10.1 修复的 3 个渲染/tmux 显示 buge2e 复现+修复+回归,均模拟器 idb 点击+截图取证)
1. **终端底部残影**旧帧钉屏底ghostty iOS `addSublayer:` IOSurfaceLayer `Metal.deinit` 从不 `removeFromSuperlayer`上游 iOS-only 缺陷SwiftUI attach 抖动导致同一 view 多次建 surface 时旧层泄漏冻结小尺寸首帧 CA `topLeft` gravity 钉在屏底vendor `UITerminalView.platformSetup` surface 前清现存子层 + `TerminalSurfaceCoordinator.onSurfaceFreed` 钩子`tearDownSurface` 后回调 `commonInit` 里摘子层。(诊断经 Fable 顾问在 ghostty 源码层锚定。)
2. **tmux 切 tab 切回旧 tab 内容消失**`TmuxTabbedView` 原只渲 activeWindow `.id(win.id)`切走销毁 pane 视图释放 surface内容丢失改成**所有窗口常驻挂载仅激活可见**`ZStack`+`opacity`+`allowsHitTesting`surface 不释放内容保留后台窗口持续收 %output
3. **tmux pane 内容钉底/顶部残行**pane ghostty grid 由容器像素几何决定tab 条吃高度73 attach raw 全屏 75 capture75 行快照喂 73 grid 溢出上滚(A) 容器几何测量上移到 `TmuxTabbedView` 内容槽破鸡生蛋死锁(B) `TmuxController` attach kickoff `attachAcked ∧ containerKnown` 双条件`kickoffIfReady`(C) `capture-pane` 铺快照前剔尾部空行
### 10.2 UI 产品化(对标 Moshi getmoshi.app深蓝黑底 + 终端绿 + 圆角卡片)
- **设计系统颜色封装为主题系统**`apps/TerminalX/iOS/Theme.swift``TXPalette`(所有色角色 struct)+`TXThemeManager:ObservableObject`(环境注入)结构量走 `enum TX`(圆角/间距/等宽字体)。
- **统一主题** GhosttyTheme 抽离 4 Catppuccin(Mocha/Macchiato/Frappé/Latte)每套同时定义 app 配色+终端配色(`ghosttyName``terminalTheme`)。设置里选主题 **app 与终端一起变色 + 持久化**(`UserDefaults(txThemeStorageKey)`)`ContentView` `.onChange(palette.id)→model.applyTerminalTheme()`新终端/pane `TerminalTheme.txDefault` 读持久化
- **主屏/主机管理页**(`ContentView.HomeView`)主机卡(`HostCard`)+FAB 加主机(`AddHostSheet`)+设置(`SettingsStubView` 含主题选择器)`HostStore`(Application Support/hosts.json 持久化密码暂存本地/TODO Keychain)点卡片 `SSHTerminalModel.connect(to:)`
- **终端外壳 chrome**(`ContentView.TerminalScreen`+`TerminalTopBar`)顶栏(圆形返回钮+会话标题`model.connectionTitle`+传输徽章`model.moshEngaged`?mosh青:SSH绿+连接态点+状态提示)`TmuxTabChip` 绿 pill 底部 GhosttyKit 工具栏深色自适应终端默认 Catppuccin Mocha
- **验证**iPad mini(A17 Pro) live 验证全流程加主机连接终端 tab分屏点焦点主题切换 app+终端同步)。
### 10.3 待续UI
- 会话缩略卡区Moshi 首屏实时终端预览横滑需会话管理设置页字体/工具栏配置导航壳打磨终端工具栏 accent tint`inputAccessoryStyle` SwiftUI 未暴露需桥接)。
- 无头 UI 测试`idb ui tap x y`Python3.14 需事件循环 shim `/tmp/idbrun.py`坐标=屏幕像素×0.5,注意设备朝向)。诊断埋点(GKDBG/txDbg*)已清理MOSHDBG/TMUXDBG/M4DBG 仍在( cleanup-todo)。
## 11. Hosts / Keychain / Tailscale 三分离管理2026-07-24 本轮,对标 Termius
把连接拆成 **Hosts / Keychain(凭据) / Tailscale 连接** 三者分别管理iPad 优先设计阶段咨询 Fable 顾问Q1Q5 裁决)。**BUILD SUCCEEDED + 模拟器 idb 截图全流程验证 + TXCore 40 tests 绿。**
### 11.1 数据模型app 层新文件)
- `Credential.swift``Credential`(凭据 meta无机密) + `CredentialSecret`(自描述机密)。Kind: password / privateKey(source: pasted/imported/generated, publicKeyBlob) / secureEnclave(占位)。
- `CredentialStore.swift``@MainActor protocol CredentialStore`(list/secret(for:)/upsert/deletemeta 与机密分离) + `FileCredentialStore`(沙盒 `credentials.json`版本化信封 `{"scheme":"plaintext-v1"}`0600+`isExcludedFromBackup`+`completeFileProtection`)。**不自造加密**无根信任下伪安全签名后换 Keychain 后端即可协议已抽象)。
- `TailscaleStore.swift``TailscaleConnection`(每个=一个常驻 tsnet 节点`stateDirName="tsnet-<uuid>"` 创建即固化终身不变——tsnet 无文件锁 Dir Up 损坏状态) + `TailscaleStore`(tailnets.json无机密authKey 注册后即弃)。
- `HostStore.swift``SavedHost` 改为 `auth: AuthRef`(.inlinePassword / .credential(id)) + `tailscaleID: UUID?`自定义 decoder 迁移旧 inline username/password `.inlinePassword`不造幽灵凭据)。
### 11.2 签名映射 `CredentialAuth.swift`(复用既有三认证路径,传输层零改动)
- password `.password`生成软件 P-256 `SigningKeyProvider.from(x963:)` `.publicKeyCallback`(路径A)粘贴/导入 PEM `.privateKey`(libssh2 `publickey_frommemory` 路径B)。
- **关键`SSHSession.authenticate()` 三分支(.password/.privateKey/.publicKeyCallback)早已全部实现**本次无需动 TXTransport
- `SigningKeyProvider` `from(x963:)`/`generateSoftwareP256()`/`secureEnclaveAvailable()`(非持久探测签名真机自动点亮 Face ID UI)。openssh-key-v1 mbedTLS 不认 UI 警告提示 `ssh-keygen -p -m PEM` 转换
### 11.3 多 tsnet 节点 `TsnetRegistry.swift`(替换旧单例 `TsnetManager`
- 单例 `@unchecked Sendable`+ connID **懒启动/Up 后常驻/LRU 限 3/in-flight 去重**Fable tsnet v1.102.0 源码确认一进程多 `tsnet.Server` 官方支持dial 走具体 node 句柄无全局路由重叠 100.x 不误连红线仅 stateDir 唯一)。
- **`TsnetbridgeNode`/`MoshRelay` Sendable**不能在 `Task.detached` 捕获或跨 actor mosh relay/wakeUp 收进 registry connID 操作model 只留 `activeConnID`不再持 node)。
### 11.4 连接编排(`SSHTerminalModel`
- `connect(to:)` 解析 AuthRef+tailscaleID `EgressPlan`(.direct/.tsnet(connID,stateDir,hostname,authKey))`startConnect` 统一构造 confighost key pin 命名空间=`egressPlan.egressKey`(`direct`/`tsnet-<uuid>`)。删实例级 `tsnetAuthKey`/`TsnetManager` 单例
- 无头验证`-txTsnetKey` 合成临时连接固定 connID + stateDir `tsnet-main` 复用模拟器身份)→ 启动参数矩阵不回归已验证直连 SSH 127.0.0.1:22 走通 host key 校验+密码认证)。
- `model.bind(credentials:tailscale:)` ContentView 注入两 Store
### 11.5 UI`ContentView` + 4 个 View 文件 + `UIComponents.swift`
- `RootSplitView`=`NavigationSplitView` 侧栏连接:主机 / §保险库:Keychain·Known Hosts / §网络:Tailscale / 设置)+详情iPhone 紧凑自动坍缩终端改 `.fullScreenCover($model.showsTerminal)` 全屏盖退出回列表状态保留)。
- `HostsView.swift`(列表+HostCard+HostEditorView认证 segmented[直接输入/ Keychain ]+出口 Picker[Direct/ Tailscale])、`KeychainView.swift`(列表+CredentialEditorView密码/SSH密钥[生成·粘贴·导入]/Face ID 占位)、`TailscaleView.swift`(列表+编辑器 name/hostname/authKey+注册按钮经 registry Up+SelfIP)、`SettingsView.swift`(主题+关于 + `KnownHostsView` pin 查看/删除)。
### 11.6 未验证(需用户实时资源)
tsnet 多节点 Up authkey)、密钥/凭据成功登录需服务器信任 key)、mosh over egress plan这些在有凭据时按 §8 流程补测
---
## 12. Demo → 商业化 UI/UE 改造2026-07-25 起,按 Claude Design 定稿)
**设计定稿**hifi含精确 token`docs/design/UI-DESIGN-HANDOFF.md` + `terminalX-iPadOS.dc.html`13 屏画板)。
渲染对拍`python3 docs/design/render-screens.py` 拆屏 headless Chrome 逐屏截 PNG规划级裁决咨询 Fable 顾问
**总判断**差距不在样式而在结构三处必须先拆的地基——① chrome Catppuccin 派生改为 Nocturne 中性阶 + 恒定 blurple;② `NavigationSplitView`+`fullScreenCover` 改路由 + 会话常驻画布;③ 单会话模型改多会话并存
### 12.1 路线图6 阶段UI-1/UI-2 已完成)
| 阶段 | 内容 | 状态 |
|---|---|---|
| UI-1 | 设计令牌地基Nocturne chrome + blurple + JetBrains Mono | 完成 |
| UI-2 | 导航与多会话状态模型AppRouter/SessionManager/SessionCanvas | 完成 |
| UI-3 | 终端主界面56pt 轨道 + 浮起双胶囊 + 侧边栏三态 | 完成pane 缝拖拽待做 |
| UI-4 | 首页会话卡 + 关闭确认 + 分屏菜单 + pane 拖拽 | 完成pane 拖拽待真机 tmux 验证 |
| UI-5 | 设置·外观 + 键盘体系GCKeyboard 检测 + 全套 快捷键 | 待做 |
| UI-6 | 竖屏参数化 + iPhone 首版 | 待做 |
### 12.2 UI-1 设计令牌(`Theme.swift` 重写)
- 三层分工`TXAccent`Nocturne blurple 8 **恒定不随主题变**只做线//选中底/文字)· `TXChrome`chrome 中性阶 14 **4 张预生成表**)· `TXFlavor`Catppuccin flavor驱动终端 + 状态语义色)。`TXPalette` 是门面旧取色名`p.accent`/`p.textPrimary`…)保留为 computed逐屏重写时再换直通名
- chrome 表推导**Mocha 用设计交付的精确锚点值**Macchiato/Frappé flavor base 相对 Mocha HSL 差平移文字阶抬升幅度收一半保对比度Latte 是唯一浅色整体反转手工写仍不用纯黑纯白)。 flavor chrome 变阶accent 不变
- JetBrains Mono 四权重 vendor `apps/TerminalX/Resources/Fonts/` OFL.txtOFL-1.1 要求随分发)。**必须平铺 bundle **`UIAppFonts` 值不带目录)→ project.yml group 而非 folder reference`TX.Font.mono` weight 映射 PostScript 缺失时 SwiftUI 自动回退
### 12.3 UI-2 导航与多会话(新 `AppRouter.swift`/`SessionManager.swift``SSHTerminalModel`→`TerminalSession`
- `AppRoute = .home | .terminal(UUID)``SidebarState = .collapsed|.overlay|.pinned`overlay 不占位 resizepinned 钉住各 resize 一次)。
- `TerminalSession` 加身份`id`/`host`/`isLive`( showsTerminal**不再驱动导航**)/`isMinimized`/`lastActiveAt`/`link`(LinkState 五态 phase+egress 推导)/`displayName`/`initials`/`isTmuxSession`。**TXCore传输层零改动**50 tests 保持绿)。
- `SessionManager`多实例持有`open(host:)` 复用同主机会话`focus`/`minimize`/`close`前后台广播、⌘19 索引首页排序最小化置顶 + 最近活跃)。
- `ContentView` `RootView(ZStack{SessionCanvas, HomeShell})`。**SessionCanvas 常驻挂载所有会话**详见 CLAUDE.md 摘除即丢内容 + matchedGeometry 要求同层级)。host key alert 下移到 `TerminalScreen`每会话一份后台会话也能弹安全告警)。
- 无头验证脚手架DEBUG`UIPreviewFixture` 假会话 + `terminalx://ui/*` 导航驱动 + `scripts/tx-shot.sh` 截图定向。****假会话必须 `isSyntheticFixture=true` 从生命周期广播里排除否则 scenePhaseactive 广播经 `syncUI()` 把注入状态覆盖回 idle
- **验收**四步截图取证全通过A 终端会话1 B `ui/minimize` 回首页3 会话并存 + mac-studio 最小化中置顶)→ C `ui/session/2` 切会话2独立内容 + tsnet 徽标)→ D `ui/session/1` 回会话1 **内容原样**
### 12.3b UI-3/UI-4 两屏还原(新文件 `TXParts` / `RailSidebar` / `TerminalChrome` / `HomeView`
- **`TXParts.swift`** 设计稿反复出现的小件`TXStatusDot`(8pt + 2pt 挖底描边) · `TXInitialsAvatar`(26/38pt 两字缩写 + 右上状态点) · `TXSectionLabel`(600 10px ls .09em uppercase) · `TXBadge` · `TXChip` · `TXSearchField`(32pt) · `TXButton` · `TXIconButton`(视觉 34命中 44) · **`TXTerminalMirror`**(终端文本镜像底对齐)。
- **`RailSidebar.swift`** `RailView`(56pt/竖屏 48pt 轨道logo 不可点 搜索 首页 主机头像+状态点 虚线+ TMUX window 数字 状态点 »展开 齿轮) + `SidebarPanel`(288pt遮罩/Pin 共用同一套内容) + `SidebarSessionRow`/`SidebarWindowRow`。**开合语义唯一**logo 不可点折叠态»展开在轨道底展开态«收起在标题右Pin 是独立按钮只在展开态出现
- **`TerminalChrome.swift`** 浮起胶囊`.ultraThinMaterial` + 半透底 + 描边 + 圆角 11左上标题胶囊红黄点/竖分隔/状态点/主机名/window title+ 右上三格状态胶囊`tsnet | mosh | tmux`**只染出问题的那层**+ 底部 34pt 提示条文案随侧边栏三态切换)。
- **`HomeView.swift`** 页头34px 标题 + 副行 + 220pt 搜索 + 新建连接 + 齿轮)→ 活动会话 3 `SessionCard`卡头状态/主机名/tmux/链路 · 文本镜像 · 卡脚 windows·panes·上次活跃 + n + 回到会话)→ 全部主机 4 `HostTile` + 筛选 chips + 添加卡重连/离线卡镜像 `blur(1.5)` + `rgba(24,26,40,.72)` 遮罩 + 转圈 + 屏幕内容已缓存恢复后原样接回」。
- 舞台pane 区顶部留 `TX.Layout.capsuleInset`(86pt) 预留带 + `clipped()` 防溢出pane 之间 2pt 各让 1pt`TmuxStage` 去掉 tab window 切换移到轨道/侧边栏保留 window 常驻挂载 + opacity与容器几何单一来源
- **缩略卡的真实回滚」**`TerminalSession.captureSnapshot()` `readViewportText()` 抓尾部 32 剔尾部空行首页可见时每 1.5s 轮询 + 最小化前抓一次。**没有第二个 surface**。
- fixture 扩展`UIPreviewFixture.hosts`(7 + 分组/os)、`tailnets`每条会话的 `TmuxSummary` 注入`HostStore.injectFixtureHosts`/`TailscaleStore.injectFixtureConnections` 只进内存**不写盘**。
- **验收**四态截图对拍设计稿 —— 折叠轨道(08) · 遮罩态(00) · Pin (01终端真的变窄) · 首页(09)。TXCore 50 tests 保持绿
### 12.3c 像素级走查修正(用户走查 + 逐屏对拍2026-07-25 二轮)
走查方法**后续每屏都照这个来**`scripts/tx-shot.sh` 截图 `python3` 把设计稿 PNG 与截图统一缩放到 1366×1024 裁同一区域上下/左右并排 再对元素列做**垂直投影扫描**逐行找与底色不同的段把元素 y 坐标量化对比目测靠不住缩略图里 `#1E1E2E` `#11121C` 分不出)。
修正项
1. **沉浸不彻底(最严重)**原来把 86pt 预留带做成"pane 容器整体下移"胶囊浮在舞台底色上改为 **pane 容器从舞台顶部就开始**终端底色 + 边框铺满只把 surface 内容 `padding(.top, 86)` —— 预留带铺的是 `flavor.terminalBg`故与终端内容视觉连续胶囊真正"悬浮在终端之上"。像素采样验证预留带与内容区同为 `#1E1E2E`
2. **终端页隐藏系统状态栏**设计稿 `1b`/`3a` 顶部就是胶囊/logo`1e`/`1g` 才有状态栏+ `ignoresSafeArea(edges: .top)`。****`statusBarHidden` 不能挂在 `TerminalScreen` ——它常驻挂载SessionCanvas会连首页一起隐藏要挂在 `RootView` 上按 `router.route` 判断
3. **侧边栏动画**三态切换统一走 `AppRouter.sidebarAnimation``.easeOut(0.24)`设计要求 200260ms面板 `.move(edge: .leading)` 滑入 + 遮罩 `.opacity` 淡入
4. **底部两区同高**侧边栏脚与舞台提示条共用 `TX.Layout.footHeight`(34pt)故两侧分割线落在同一 y侧边栏脚原来的深色块 + 齿轮**去掉** 1pt `divider`设计稿如此)。
5. 轨道结构**搜索/首页/设置是裸图标**无底无框只有 logo 与展开键有框`TXIconButton(bare:)`纵向节奏按扫描到的设计坐标写死logo 28 · 头像 174/220/266 · TMUX 标签 388 · 数字 408 TMUX 段标签用 accent-500 而非中性灰
6. 侧边栏面板logo 30pt会话行 48ptwindow 44pt实测值分组分隔线改**渐隐** LinearGradient占位分支补回漏掉的新建 window T对齐后与设计稿偏差 9ptwindow 区完全重合)。
7. 缩略卡卡头/卡脚分隔线用 1pt `Rectangle``Divider().overlay()` 压不住默认色行距 `fontSize×0.6`设计 11px/1.5**启发式着色** —— `readViewportText()` 只给纯文本故按行首符号/关键字上色`✓`绿 `error` `◇▸` `[..]` 提示符行亮逼近真实终端密度
### 12.3d UI-4 交互闭环(关闭确认 / 分屏菜单 / pane 拖拽)
- **`TmuxController` 新增命令**纯追加未动既有链路`detach()`(`detach-client`) · `killSession()` · `splitWindow(vertical:joinSession:)``split-window` / 跨会话用 `join-pane -s`**不嵌套 tmux**)· `splitIntoNewSession()``new-session -d` + `join-pane`)· `resizePane(cols:rows:)` · `listSessions()` `PendingKind.listSessions`结果进 `@Published sessions: [TmuxSessionInfo]`)。
- **`AppModal` 提到 `AppRouter`**不是视图 `@State`):⌘W/⌘D 快捷键与无头 URL 驱动都要能打开浮层视图内部状态触达不到
- **关闭确认 `CloseConfirmDialog`**3c436/404pttmux 推荐仅断开 ⏎」(`detach-client`服务端继续跑) + 次要红字结束会话」(`kill-session`**再点一次才执行**)原生窗口 琥珀警告 + 危险按钮设计精确色 `#8d5560` / `rgba(92,58,63,.35)` / `#f5a0ac`)。`closeSession(kill:)` 先发 tmux 命令延迟 250ms teardown 传输层 `%exit` 走既有 `onExit`)。
- **分屏菜单 `SplitMenuView`**3b360pt**不问方向**(⌘D/⌘⇧D 本就是两个入口只列分到哪里」——当前会话选中底 + 当前」)· detached 会话绿点因为进程仍在服务端跑 + 上次活跃)· 新建 tmux 会话前导列统一 20pt 使各行文字左缘一致。**原生窗口项灰显标二期」**需要一台主机多条 SSH channel §12.4不装作能用
- **pane 拖拽 `PaneSplitter`**从布局树内部节点算出相邻子节点边界 热区 12pt + 把手 44×3pt。**拖动中只动视觉**把手位移 + 转强调色不改 pane frame不发命令**松手才发一次 `resize-pane`** `%layout-change` 回来更新真实 frame"路线 b"一致客户端不自行重排)。
- **最小化过渡**离开终端时 `opacity + scaleEffect(0.97)`240ms easeOut)。 `scaleEffect` 而非 frame 动画 —— 前者只是渲染变换**不触发 PTY resize**后者会让 tmux 反复重排完整 matchedGeometry 缩回卡片未做fable 裁决不对 `TerminalSurfaceView` 本体做 matchedGeometry)。
- **验收**三个浮层逐个对拍设计稿3c 两套文案 + 3b 菜单最小化往返内容原样日志无 crashTXCore 50 tests 绿。**pane 拖拽只做了静态编译与逻辑走查没有真实 tmux pane 会话验证过**fixture 没有 controller)——有测试主机时必须补
### 12.3e 接真实数据 + 空状态UI-5 之前的必要一步)
之前几轮都靠 `-txUIFixture` 假会话对拍设计稿真实路径下有数据缺口和裸露的空白屏本轮补齐
**真实数据打通**
- **tmux 会话名**kickoff 时顺带 `listSessions()``currentSessionName` = list-sessions attached 的那个 状态胶囊/侧边栏标题显示真实 `tmux <会话名>`拿不到就只显示 `tmux`不编造)。
- **cwd**`list-windows` format 追加 `#{pane_current_path}` `TmuxWindow.cwd`标题胶囊显示window title · ~/src/x」,侧边栏 window 行副行显示cwd · n panes」。`abbreviate()` `/home/u/x``/Users/u/x` 收成 `~/x`
- **主机的分组 / 系统**`HostEditorView` 新增归类分组输入 + 已有分组一键选 chips + 系统描述)。此前 `SavedHost.group/os` 只有 fixture 在写真实主机永远没有分组 首页 chips 恒只有全部」。
- IP 主机名的缩写`10.255.255.1` 按字母规则会得到无意义的 `11` IP 取首段前两位`10`)。
**空状态(真实路径必经)**
- 首页首次运行0 主机 0 会话终端图标 + 还没有主机」+ 能力说明 + 新建连接 / 先配置 Tailscale」。
- 有主机无会话虚线提示条还没有在跑的会话 —— 点下面任意主机即可连接」。
- 搜索/筛选无命中说明是分组还是搜索造成的 + 清除筛选」。
- 缩略卡无输出:「等待输出…」占位真实会话刚连上时 `readViewportText()` 还是空的)。
- 侧边栏无会话:「没有在跑的会话」。
- **终端区 `TerminalStateOverlay`**连接中转圈 + 分阶段文案 + **已等待 Ns** + 5s 后出取消连接」)/ 连接失败错误详情 + 返回首页 + 重试/ 已关闭重新连接)。已连接与重连中**不渲染此层**不遮挡终端内容
- 路由兜底`route` 指向的会话已被关闭 自动回首页不停在空画布上黑屏
**传输层修一处真实可用性问题(`TXTransport`**
- `SSHSession.connect()` 原来用裸 `Darwin.connect`**阻塞到系统 TCP 超时75s+**连不上的主机会让用户对着连接中干等改为 `connectWithTimeout()`置非阻塞 connect `poll(POLLOUT)` 等超时 **查 `SO_ERROR`**可写成功拒绝连接也会变可写)→ 恢复阻塞libssh2 用阻塞模式)。超时值走新增的 `SSHConfig.connectTimeoutSec`默认 12s)。tsnet 路径`preconnectedFD`不经此处不受影响
- 验证不可达地址 12s 后报失败socket("连接失败12s 超时)…")」,失败态覆盖层给出重试/返回
### 12.3f tmux 自动进入(真机反馈修复:连上永远显示「无 tmux」
**根因**全代码库没有任何地方发起 `tmux -CC attach``TmuxRouter` 只被动检测传输字节流里的 DCS 前导之前所有 tmux 端到端验证都靠 `-txAutoCommand "tmux -CC …"` 手动触发 真实点击连接只有 raw shell状态胶囊第三格恒为 tmux · 客户端窗口」。不是检测坏了是从来没人发起
**修复**
- `SavedHost` `useTmux: Bool = true`旧数据迁移也默认 true+ `tmuxSession: String?`默认 `main`主机编辑器新增会话tmux 开关 + 会话名 + mosh 开关带说明)。
- `TerminalSession.scheduleTmuxBootstrap()`连接建立后mosh 模式则等 mosh 接管后延迟 **2.5s** shell 提示符 `tmux -CC new -A -s <名>``-A` = 同名会话存在就接回断线原样恢复的关键)。
- **探测降级**4s 内没进 control mode `tmuxUnavailable = true` 终端顶部行内横幅设计 2c说明关掉 App 后服务端不会继续跑」+「安装并启用 tmux」+ 可关闭)。装完自动重试一次
- `exitTmux()` 重置引导标记 重连后重新 attach
- 无头参数`-txTmux 0|1``-txTmuxSession <名>`
**真机端到端验证通过**192.168.99.83macOS + fish + tmux 3.7b)。过程中查出 3 个真实 bug
1. **tab 分隔符被 PTY 吃掉**`list-windows -F "…\t…"` tab PTY tmux 后变成**下划线**整行 `components(separatedBy:)` 分不开`count=1`)→ title/cwd/会话名全丢UI 显示兜底名窗口 N」。改用 `TmuxController.fieldSep = "|:|"`。**排查坑**`os_log` 把控制字符显示成 `_`光看日志无法区分真下划线被显示替换的 tab」,我因此先误判为 os_log 显示问题——最后靠打印 `parts.count` 才确诊
2. **UI 不刷新**正是 CLAUDE.md 嵌套 ObservableObject 不冒泡那条坑的另一面`tmuxSummary`/window 列表是读 controller computed property而轨道/胶囊/侧边栏只订阅 session 数据解析对了 UI 也不动修法`TerminalSession` Combine 订阅 `controller.objectWillChange` 并转发自己的 `objectWillChange`一处解决所有读 controller 的视图)。
3. **会话名猜错**`currentSessionName` 原来取 `list-sessions` `session_attached==1` 的那个结果把用户自己终端 attach `demo` 当成了当前会话改用 `display-message -p "#{session_name}"`权威本客户端所在会话)。分屏菜单的当前判定也随之改为按会话名比较
4. 顺带修正轨道/侧边栏的 window 编号用 `#{window_index}`用户可见的 0/1/2…)而非内部 `window_id`@2/@3…)。
**验证结果**服务器侧交叉验证
- tmux 自动进入 状态胶囊 `direct | ssh | tmux main`标题胶囊 `fish · ~ 163×50`轨道 window `0/1/2/3/4`与服务器 `idx=0..4 active=@3/idx=1` 完全吻合
- **⌘D 分屏 ✓**`split-window -h` 后服务器 `list-panes` `%7 164x50` 变为 `%7 82x50 + %8 81x50`app 端双 pane 左右并排渲染2pt 拖拽把手活动 pane blurple 边框
- **`resize-pane` ✓**`ui/resize/110` 后服务器 pane 宽度变为 `110`另一侧自动收缩到 53)——拖拽条的命令链路已验证手势本身是标准 `DragGesture`
- **会话服务端持久 ✓**app 退出后 `tmux ls` 里测试会话仍在验证完已 kill 清理
- 新增无头验证命令`ui/split-now/v|h`跳过菜单直接分屏)、`ui/resize/<cols>`
### 12.3g 连接后的会话选择器(用户定稿交互,替换掉「自动 attach 固定会话」)
**为什么改**之前实现是连上就 `tmux -CC new -A -s main` —— 替用户决定了 attach 哪个会话真实运维里服务器上常有多个会话各跑不同任务必须让用户挑
**新流程**SSH 连上 普通 shell 里跑探测 弹选择器 sheet 按选择 attach。**没装 tmux 时不弹框**直接原生终端 + 安装引导横幅不打扰)。
- **`TXCore/Tmux/TmuxSessionProbe.swift`**纯逻辑 + 5 个单测流式扫描 `BEGIN…END` 标记块产出 `.available(version:sessions:)` / `.unavailable`三个坑都在注释里
1. **PTY 回显**命令本身会被回显标记若字面出现在命令里回显行会被当成结果起点 写成相邻字符串拼接 `"__TX""_SESSIONS_BEGIN"`shell 输出完整标记回显的是拼接前的样子)。
2. **三态要分开**:「没装 tmux装了但没会话 stderr 都被 `2>/dev/null` 吞掉标记之间同样是空的只能靠 `tmux -V` 的版本行区分 —— 两者的正确 UI 完全不同安装引导 vs 让用户新建)。
3. **别脏屏**探测命令与输出会打在用户屏幕上 命令末尾接 `printf '\033[H\033[2J\033[3J'`归位+清屏+ scrollback不依赖 terminfo)。刚连上时清掉的只有 motd/shell 欢迎语首屏反而更干净
- **`OutputTap`**raw 字节的旁路同时喂终端渲染与探测扫描器)。 `RawOutputSink` 协议让 `TmuxRouter` 能接受 gate tap —— **不动 `OutputGate` 的缓冲/flush 语义**那是修白屏的关键)。
- **`SessionPickerSheet.swift`**按用户给的设计图分段控件 `>_ Tmux` | `Herdr`(置灰) | `最近`(置灰) + 右侧跳过 ▷|」;会话行 = 大字会话名 + 活跃徽标被别的客户端占用时另标已被接入」)+ 副行N 个窗口 · M 分钟前」;底部「+ 新建 tmux 会话…」(点开就地输入名字)。`.presentationDetents([.medium, .large])`
- `TerminalSession``pendingSessionChoice` / `attachTmuxSession` / `createTmuxSession` / `skipTmux``tmuxSkipped` 让状态胶囊显示原生窗口 · 不经 tmux而不是谎报 tmux」);探测 6s 超时兜底按无 tmux 处理
- 无头命令`ui/pick/<会话名>` · `ui/pick-new/<名>` · `ui/pick-skip`
**时机修正(用户第二轮反馈)**选择器属于**连接流程的一部分** —— 点主机后**留在首页**探测在首页弹选择器选完才进终端页不是先把用户丢进空终端再弹框
- `SessionManager``open(host:)` 不再立刻 `router.enter`改为 `awaitingEnter` 集合 + `@Published enterRequest`视图层消费后进终端)。`evaluateEnter` 的就绪条件不等选择 不在探测 ∧(已建立 / tmux / 选了原生 / 失败)。失败也进终端 —— 那里有重试 / 返回首页」,比留在首页只有一行错误更有出路
- ****`isProbingTmux` 必须在 `.connected` 那一刻就置位否则连接建立探测开始延迟 2.5s)」之间有空窗`evaluateEnter` 看到 `isLive == true` 就把人送进终端页 —— 第一次改完就是这个症状选择器弹在终端页上)。
- `SessionManager` 也要转发每条会话的 `objectWillChange` `TerminalSession` 转发 controller 那条坑否则首页/选择器不会因会话内部变化刷新
- 首页新增 `ConnectingRow`正在连接 / 等待选择 / 失败+重试+取消`liveSessions` 排除正在连接的会话避免同一会话既有进度行又有卡片且卡片里的 tmux 信息此刻还没定会先显示成原生窗口再跳变)。
- 无头验证真实路径`-txOpenAsHost 1`把启动参数造成一台内存主机后走 `open(host:)`即探测选择进终端与老的 `-txHost` 直连路径互斥)。
**措辞修正**右上不再是跳过」(读起来像"跳过某个步骤"、含义模糊改为**「原生终端」**+ terminal 图标 —— 它是与 tmux 并列的一种连接方式`skipTmux()` `useNativeTerminal()`状态胶囊第三格显示原生窗口 · 不经 tmux」。首版只有 Tmux 与原生两种所以不做别的出口
**真机验证192.168.99.83)三条出路全过**
- 探测 `probe: tmux 3.7b sessions=["demo", "main"]`选择器如设计图渲染终端首屏干净清屏生效
- **attach 已有** `attach -t demo` `currentSession=demo`
- **新建** `new -A -s uitest` `currentSession=uitest`服务器 `tmux ls` 出现 `uitest: 1 windows (attached)`验完已 kill
- **原生终端** 状态胶囊 `direct | ssh | 原生窗口 · 不经 tmux`
- **完整真实路径**`-txOpenAsHost`)→ 首页正在连接 192.168.99.83」→ 首页上弹选择器标题连接到 …」)→ `main` 进终端页 `tmux main` + 轨道 window `0/1/2/3/4`
### 12.4 首版明确推迟Fable 裁决,勿在 UI-3~6 里偷偷做)
`mosh <RTT>` 实时数字需改 vendor/mosh iosclient 导出 SRTT属传输层)· K 命令面板 · tmux 主机的多客户端窗口保留横幅引导)· iPad 竖屏 pane 强制上下堆叠会推翻路线 b」,交给 tmux relayout)· 缩略卡 ANSI 彩色 `readViewportText()` 文本镜像**绝不做离屏 Metal/ surface**——IOSurfaceLayer 泄漏史)· 设置页连字/CJK 开关ghostty 无运行时 mutation灰显占位)。
</content>