业务逻辑(用户定稿): - 点主机总是新建会话(会话:主机=多:1);activate 选中断线会话自动重连 - 「直接输入」凭据保存时自动落 Keychain(去重复用),主机只存引用 - 主机编辑页可就地新建 Tailscale 连接;删无人引用的旧 HostsListView 首页/弹框减法: - 删侧边栏「连接新主机」、无 tmux 横幅与安装引导、页头「新建连接」 - 搜索移到「全部主机」行;添加主机虚线卡与主机卡等高(骨架复刻) - 会话卡加 ✕ + 复用终端页同款二次确认 - 会话选择器 sheet → 项目风格居中弹框(去手柄/Header/最近) 终端页修复: - 侧边栏会话列表改创建顺序,切换不再重排抖动 - 焦点高亮上移到 pane 容器边框(四边完整,去负 padding) - 软键盘双重让位修复:画布忽略键盘 + TXKeyboardObserver 显式让位一次 - 自动聚焦:focusTick 脉冲强制翻转 FocusState + surface 就绪补聚焦; vendor 补 iPadOS 触摸板 indirectPointer 的 becomeFirstResponder Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
398 lines
69 KiB
Markdown
398 lines
69 KiB
Markdown
# 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 模拟器 slice);tsnet 桥加 UDP relay(`StartMoshRelay`);`MoshSession`(pipe↔FILE*/pthread/SIGWINCH)+`MoshConnectScanner`(TXCore)+`SSHTerminalModel` 编排(SSH 引导 mosh-server→解析 MOSH CONNECT→接管)。
|
||
> - **LAN 直连端到端验证**(192.168.9.199):mosh-server 存活 104s≫60s 无客户端超时+子 shell+心跳=真连。
|
||
> - **mosh over tsnet 端到端验证**(vohive-vm 100.69.201.101):app 日志 `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,不能跑本地 shell);macOS 才支持本地 PTY。
|
||
- 分发目标 App Store(M5,已暂缓)。
|
||
- 四大核心:**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 用户态**(Go,gomobile 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 bind;C/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-keygen,MITM 关闭)+ **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 XCTest:tmux 解析/`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 target:libssh2 头 + modulemap,供 `import CSSH`)、`CSSHCore`(binaryTarget,library-only)。
|
||
- `CMosh/`(C target:`moshiosbridge.h`(mosh_main)+modulemap+占位 shim.c,供 `import CMosh`)、`MoshCore`(binaryTarget,library-only);TXTransport 链 `libc++`/`libz`(mosh 是 C++ 且 compressor 用 zlib;CommonCrypto 属 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.send(tmux 模式→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→129x73,tmux 精确采纳)。**剩余**:完整 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 需配置签名 + entitlements(tsnet 用户态**无需** 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. **真机化(最大缺口,多项验证只在模拟器)**:配置签名 + entitlements(tsnet 用户态**无需** NE,利好审核)→ 上真机/TestFlight。真机上补验三件模拟器测不了的:①**SE 硬件密钥**生成+签名+keychain 持久化(M4b 现回退软件+文件;`SigningKeyProvider` 已备 SE 路径);②**mosh 深挂起恢复**(锁屏数分钟过 WG 180s 过期线 / socket defunct,验 `MoshRelay.Rebind`+`hop_port`);③签名后 **Keychain 可用**(当前未签名 SecItem 全 -34018,host 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`)需清理策略。
|
||
- tmux:pause 流控(`%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-M1;fish 为默认 shell,`mosh-server` 在 `/opt/homebrew/bin` 且交互 shell PATH 可见)、一台 tailnet 内 online 的 Ubuntu VM(vohive-vm)、一个 tailnet auth key(ephemeral,可能过期,用户会给新的)。用于直连 SSH / SSH-over-tsnet / tmux / M2 mosh 端到端验证。凭据每次向用户即时索取。
|
||
- tailnet 是 `tail3b5057.ts.net`,已见节点:vohive-vm(linux)、iphone-air、ipad-pro、macbook-air-m1、macbook-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 显示 bug(e2e 复现+修复+回归,均模拟器 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 行 capture,75 行快照喂 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 顾问(Q1–Q5 裁决)。**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/delete,meta 与机密分离) + `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` 统一构造 config,host 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`(现只剩 HostEditorView:认证 segmented[直接输入/从 Keychain 选]+出口 Picker[直连/各 Tailscale,可就地新建];旧 `HostsListView`/`HostCard`/`SessionStatusRow` 已删——无人引用且"点卡片立即进终端+复用会话"与定稿流程相悖)、`KeychainView.swift`(列表+CredentialEditorView:密码/SSH密钥[生成·粘贴·导入]/Face ID 占位)、`TailscaleView.swift`(列表+编辑器 name/hostname/authKey+注册按钮经 registry Up+SelfIP,编辑器带 `onSave` 供主机页就地新建后自动选中)、`SettingsView.swift`(主题+关于 + `KnownHostsView` pin 查看/删除)。
|
||
- **直接输入自动对象化(2026-07-26 用户定稿)**:主机编辑里「直接输入」的用户名/密码在保存时**自动落成 Keychain 凭据**(同用户名同密码去重复用),主机只存 `.credential(id)` 引用;`.inlinePassword` 仅保留给启动参数/fixture 合成主机与旧数据迁移。Tailscale 同理:主机页可「新建 Tailscale 连接」(sheet 打开编辑器,保存即自动选中)。三对象(主机/凭据/Tailscale 连接)各自增删改查、互相引用。
|
||
|
||
### 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 拖拽 | ✅ 完成(分屏功能后被用户裁决整体移除,见 12.3k) |
|
||
| 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.txt,OFL-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 不占位、不 resize;pinned 钉住各 resize 一次)。
|
||
- `TerminalSession` 加身份:`id`/`host`/`isLive`(原 showsTerminal,**不再驱动导航**)/`isMinimized`/`lastActiveAt`/`link`(LinkState 五态,由 phase+egress 推导)/`displayName`/`initials`/`isTmuxSession`。**TXCore、传输层零改动**(50 tests 保持绿)。
|
||
- `SessionManager`:多实例持有、`open(host:)` **总是新建会话**(2026-07-26 用户定稿:会话与主机**不是一对一**,再点同一主机 = 再开一条并行会话;早期"复用同主机会话"的语义已废除)、`activate(_:)`(选中既有会话:聚焦 + **已断开的自动重连**并重新判定 tmux/原生)、`focus`/`minimize`/`close`、前后台广播、⌘1…9 索引、首页排序(最小化置顶 + 最近活跃)。会话列表**持久显示**(无论连接状态),只有用户显式关闭才移除。
|
||
- `ContentView` → `RootView(ZStack{SessionCanvas, HomeShell})`。**SessionCanvas 常驻挂载所有会话**(详见 CLAUDE.md 坑:摘除即丢内容 + matchedGeometry 要求同层级)。host key alert 下移到 `TerminalScreen`(每会话一份,后台会话也能弹安全告警)。
|
||
- 无头验证脚手架(DEBUG):`UIPreviewFixture` 假会话 + `terminalx://ui/*` 导航驱动 + `scripts/tx-shot.sh` 截图定向。**坑**:假会话必须 `isSyntheticFixture=true` 从生命周期广播里排除,否则 scenePhase→active 广播经 `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)`,设计要求 200–260ms),面板 `.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、会话行 48pt、window 行 44pt(实测值);分组分隔线改**渐隐** LinearGradient;占位分支补回漏掉的「新建 window ⌘T」行。对齐后与设计稿偏差 ≤9pt(window 区完全重合)。
|
||
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`**(3c,436/404pt):tmux → 推荐「仅断开 ⏎」(`detach-client`,服务端继续跑) + 次要红字「结束会话」(`kill-session`,**再点一次才执行**);原生窗口 → 琥珀警告 + 危险按钮(设计精确色 `#8d5560` / `rgba(92,58,63,.35)` / `#f5a0ac`)。`closeSession(kill:)` 先发 tmux 命令、延迟 250ms 再 teardown 传输层(让 `%exit` 走既有 `onExit`)。
|
||
- **分屏菜单 `SplitMenuView`**(3b,360pt):**不问方向**(⌘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 菜单);最小化往返内容原样、日志无 crash;TXCore 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.83,macOS + 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.3h 质感走查:动效 / 阴影 / 圆角 / 安全区(用户第三轮走查,2026-07-25)
|
||
|
||
用户反馈四类瑕疵(**风格保持不变,只修质感**):浮层"特别难展开和消失"缺过渡、胶囊阴影太重不够扁平、终端小圆角与 iPad 屏幕大圆角打架(全屏时右上角被吃掉)、底部混乱没考虑安全区。逐条修完并补了同类问题:
|
||
|
||
**1. 动效令牌化 + 补齐缺失过渡(`TX.Motion`)**
|
||
- 新 `TX.Motion`:`standard`(0.24 easeOut) / `quick`(0.14) / `slide(edge)` / `pop`(0.96→1 缩放+淡入) / `pushBack`(首页推远)。**统一响应「减弱动态」**(`UIAccessibility.isReduceMotionEnabled` → 时长减半 + 位移退化为纯淡入淡出)。
|
||
- **根因**:`AppRouter` 里只有侧边栏包了 `withAnimation`,`route` / `modal` 的变更**裸着改** → 视图上写好的 `.transition` 拿不到动画事务,全退化成硬切。现在 `enter`/`goHome`/`present`/`dismissModal`/`toggleSidebar`/`togglePin` **全部经 `animated{}`**,点击 / ⌘ 快捷键 / 无头 URL 三条入口自动同一手感。
|
||
- `TXModalScrim` 拆成两段转场(遮罩 `.opacity` + 卡片 `TX.Motion.pop`):整块套一个 `.transition(.opacity)` 只是一起变淡,没有弹出感。
|
||
- 补:无 tmux 横幅显隐、`TmuxInstallSheet`、`TerminalStateOverlay`(连上后淡出)、提示条文案随三态切换、会话卡增减、选中态/状态点变色。
|
||
- **按压反馈**:新 `TXPressStyle`(缩放 + 压暗 140ms),替换全部 `.buttonStyle(.plain)`。深色界面里 `.plain` 完全没有按下反馈,主机卡/会话卡这种"点下去要等几百毫秒"的入口尤其伤手感。
|
||
- 会话卡改成**整张一个 Button**,卡脚「回到会话 →」退化为文字提示(同一动作做成嵌套 Button 只会抢命中区 + 两套按压反馈)。
|
||
- **`-txSlowMotion 1`**(DEBUG)把所有动效 ×12:`simctl io screenshot` 自身要几百毫秒,抓不到 240ms 的中间帧,没这个开关就无法用截图证明"动效真的在跑"。验证方法:连发三张截图,采样同一像素应得到**三个不同的中间值**(硬切时三张完全一样)。
|
||
|
||
**2. 阴影扁平化(`TX.Elevation`,刻意偏离设计初稿)**
|
||
设计稿给的是 CSS 值(胶囊 `0 10px 30px rgba(0,0,0,.55)`、对话框 `0 30px 80px .7`),在深色底上会糊成一团脏斑 —— 半透胶囊叠重投影像"漏墨",终端文字从投影里穿过去。改为:`floating`(.22/7/y2) · `panel`(.28/16/x4) · `dialog`(.34/16/y6) · `card`(.16/4/y2) · `cardFocused`(.24/8/y3),分层交给描边与材质,胶囊底不透明度提到 .92 补偿。统一经 `View.txShadow(_:)`,视图里不许再手写 `.shadow`。
|
||
顺带补上**遮罩态侧边栏浮层一直漏掉的投影**(设计要求 `24px 0`,故 `panel` 是唯一带 x 位移的)。
|
||
|
||
**3. 圆角不再与屏幕圆角打架(`paneShape`)**
|
||
终端右上角贴着屏幕物理边缘,那里设备本身是 ~22pt 大圆角 —— 再画 6pt 小圆角必然被"吃掉"(小圆角整段落在屏幕圆角之外,切口处露出底色三角)。改为 `UnevenRoundedRectangle`:**只在朝 chrome 的角收圆角**(左上/左下/右下 6pt),顶边与右边**直接铺到物理边缘、不收角不描边**,由屏幕自己的圆角收口(Stage Manager 小窗同理,窗圆角接管)。
|
||
- 描边用负 padding(`top:-1, trailing:-1`)把顶边/右边那两条**推出屏外**:留着就是贴在屏幕边上的一圈多余框线。
|
||
- 顺带取消顶边/右边的 2pt 缝(2pt 是**pane 之间**的缝,不该出现在屏幕边缘),终端格子从 163×48 涨到 164×50。
|
||
|
||
**4. 安全区:chrome 铺满物理屏,底栏自己让位(`TX.Layout.footBand` / `capsuleTop`)**
|
||
- 原来 `.ignoresSafeArea(edges: .top)` 只放开顶边 → 底部内容停在安全区上沿,屏幕最下面留一条 24pt 的空带("没铺到底"),而**颜色型 `.background` 默认会溢进安全区**,于是轨道底色铺满、内容却停在 24pt 之上,一眼就是没对齐。
|
||
- 现在整屏 `.ignoresSafeArea()`,底部区高度 = `footHeight(34) + safeBottom`,内容**在其中居中** → 既贴住物理底边又让开 home indicator。提示条 / 侧边栏脚 / 轨道齿轮三者中心线同一条 y(995),上方分割线仍同在 964。
|
||
- **顺带修掉一个状态跳变**:遮罩态面板铺满全高、Pin 态被安全区推高 24pt,一钉住面板脚就跳一下。现在两态完全一致。
|
||
- 安全区读数经新 `TXScreenMetrics`(`GeometryReader` 必须在 `ignoresSafeArea` **之前**,否则读到 0)。
|
||
- 胶囊顶距:`capsuleTop(safeTop)` —— iPad 隐藏状态栏后无遮挡用设计值 14;灵动岛/刘海机型(safeTop>30)按硬件下移。**iPhone 17 Pro 模拟器实测**胶囊落在岛下方、底栏在 indicator 之上。
|
||
|
||
**5. 同类问题(走查中发现,一并修)**
|
||
- **会话卡不等高**(一排卡上下边对不齐):`TXTerminalMirror` 的行堆叠有内容驱动的最小高度,外层 `.frame(height:)` 压不住它 —— `.frame(height:)` 只是给了个框,**不想撑满的子视图会按自身理想高度居中放进去**。改为把行堆叠放进 `Color.clear` 的 overlay(overlay 不参与父视图定尺),尺寸完全由外部决定,溢出的旧行被 `clipped()` 切掉(正是想要的:底对齐留最新输出)。
|
||
- 会话卡从不换行的 `HStack` 改 `LazyVGrid`(最多 3 列):第 4 条会话进来原先会把所有卡压扁。高度由死写 560 改为 `min(560, max(300, 可用高度×0.56))`,竖屏/分屏/小 iPad 上不再把「全部主机」挤出屏外。
|
||
- 首页滚动内容铺到物理底边(`ignoresSafeArea(.bottom)` + 内容 padding 补 safeBottom),末行能完整滚到 indicator 之上。
|
||
- 窄窗口适配:主机网格 4→2 列、左右留白 40→20、搜索框 220 固定改可伸缩、提示条只留 2 条(4 条在 iPhone 上折行把 34pt 带撑破,且强制单行 `lineLimit(1).fixedSize()`)。
|
||
- 胶囊红/黄圆点命中区 22×30 → 26×34(13pt 视觉不变):关闭这种误触代价高的动作不该给这么小的目标。
|
||
- 搜索框补聚焦态描边(键盘焦点可见,无障碍底线)。
|
||
- 删掉 `HomeView` 里**嵌套重复定义**的 `ConnectingRow`(与文件级那份一字不差,编译能过但是死代码)。
|
||
- 遮罩浓度令牌化 `TX.Scrim`(对话框 .5 / 侧边栏 .42),不再散落 `Color(hex: 0x0A0B12)`。
|
||
|
||
**验收**:iPad 13″ 横屏逐屏对拍(终端 / Pin / 遮罩 / 关闭确认 / 分屏菜单 / 首页)+ iPhone 17 Pro 竖屏(灵动岛 + indicator);像素采样确认 —— 右上角 `(1365,0)` 为终端底色(不再是底色三角)、两态面板脚同在 964/982–991、三张会话卡同为 166..710、遮罩把终端文字亮度 185→97;TXCore 45 tests 绿。
|
||
|
||
### 12.3i 触控化与标签规范(用户第四轮走查 + **真机部署**,2026-07-26)
|
||
|
||
1. **首页会话卡固定 3 格 4:3**:`LazyVGrid` 列数写死 3(不再 `min(3, count)`),不足 3 条自然从左往右排、卡片尺寸不随会话数跳动;高度改 `.aspectRatio(4/3, contentMode: .fit)`,删掉按屏高算高度那段。
|
||
2. **技术标签规范(新 `TXTech`)**:
|
||
- **拼写**:照各自官方写法 —— 缩写全大写(`SSH`)、项目名按作者写法(`mosh`/`tmux`/`tsnet` 本来小写)、产品名照抄(`macOS`/`TrueNAS`)。**`TXSectionLabel` 不再 `.uppercased()`** —— 它会嵌主机名/会话名,之前渲染成 `TMUX · MAC-STUDIO · DEV` 把名字全改错了;轨道标签同步改 `tmux`。
|
||
- **配色**:传输协议冷色区分(`mosh` teal / `SSH` blue,二者互斥所以一行只出现一个);`tsnet` 用 app 强调色(招牌能力);凭据名回中性(那是用户命的名,不是技术标签,避免一排全彩色)。主机卡图标染**发行版品牌色**(Ubuntu 橙→peach、Debian/树莓→red、Fedora/TrueNAS→blue、Arch→teal、macOS→中性银),认不出的回中性。徽标只染文字+描边、底色 12%。
|
||
3. **关闭/最小化按钮重做**:原 macOS 交通灯式 13pt 纯色圆点三处不合适 —— 纯色块不说明自己干什么(mac 靠 hover 浮字形,触屏没有 hover)、13pt 命中目标太小、圆点是 mac 窗口语汇与本 app 圆角矩形语汇不搭。改 **22pt 圆角方块 + 常显字形**(`✕` / `⌄`),保留红=关闭、琥珀=最小化的颜色语义,底色 14% 不铺满,命中 26×34,间距放宽。`⌄` 而非双向内收箭头:后者在 9.5pt 下糊成一团,且向下正好对上设计的「下滑最小化」手势。
|
||
4. **侧边栏自身的平移动画(新 `leadingColumn`)**:原来是 `if pinned { 面板 } else { 轨道 }` —— 两者**互换**只能硬切,用户看到面板"消失"、轨道"出现",没有平移。改为轨道**常驻**在底、面板叠在上,列宽随三态动画(56/56/288)+ `clipped()`:收起时面板向左滑出并被裁掉、轨道从背后露出。慢放实测 1.2s 处列宽正处于中间态(≈140pt),确认逐帧插值而非硬切。
|
||
5. **去掉命令面板**:底部 `⌘K 命令面板` 与首页搜索框的 `⌘K` 提示一并删除(快捷键体系属 UI-5,未实现的提示等于假承诺)。
|
||
6. **底部改动作条 `TerminalActionBar`**(原 `TerminalHintBar`):iPad 大概率没接键盘,「竖分屏 / 新建 window / 回首页」只写成 `⌘D` 文字提示等于这些功能对触控用户不存在 → 改成**常驻可点按钮**(图标 + 文案,26pt 高胶囊)。非 tmux 会话下前两个灰显但保留(不隐藏,免得布局跳)。左侧上下文文案改为**真实会话状态**(`mac-studio · tmux dev · 3 windows`)—— 此前写死「已连接妙控键盘 · 附加行已收起」,没接键盘时那是假话。措辞与侧边栏统一为「新建 window」。
|
||
|
||
**真机部署(用户体验用)**:`-configuration Release -destination 'id=<iPad UDID>' -allowProvisioningUpdates` 自动签名(Apple Development: JIANKUAN YANG / Team J75A6S5938)→ `xcrun devicectl device install app` + `process launch`。**Release 里所有 `#if DEBUG` 的 fixture / `terminalx://ui` / `-txSlowMotion` 全部编译不进去**,所以真机跑的必然是真实数据路径(首次运行落在「还没有主机」空状态);签名后 Keychain/Secure Enclave 可用,凭据走系统钥匙串而非文件回退。
|
||
|
||
### 12.3j 侧边栏三态动画重构:统一视图身份(用户第五轮反馈「动画混乱」,2026-07-26)
|
||
|
||
用户判词:三态切换动画混乱;收起态应理解为「展开态在左侧裁切一部分」,允许改 UI 让两态最左侧图标同尺寸同位置。
|
||
|
||
**根源**(12.3i 的 leadingColumn 只修了一半):① 轨道 `RailView` 与面板 `SidebarPanel` 是两套视图,元素尺寸/位置互不对应(logo 34/30、头像 38/26、徽标 34×30/26×22,纵向节奏也不同)→ 就算列宽在动画,内容仍是两张不相干的画面交叠;② 遮罩态面板挂全屏 overlay、Pin 态面板是 leadingColumn 里**另一个实例** → overlay⇄pinned 切换视图身份互换,一个滑出一个滑入互相打架;③ 遮罩/投影靠 `if` 插拔。
|
||
|
||
**重构**(`RailSidebar.swift` 整文件重写为 `TerminalSidebar`,`ContentView.screen` 重组):
|
||
1. **三态唯一视图**:内容恒按 288pt 全宽排版;收起 = 容器宽度收到 railWidth + `clipped()`(字面实现「左侧裁切」)。最左 railWidth 为**图标列**:logo 30 / 裸图标 17pt / 头像 30 / window 徽标 26×22 / 状态点 / 开合键 / 齿轮,两态同尺寸同位置;右侧明细(标题/副行/快捷键/选中底)`opacity(expanded)` 淡入淡出。
|
||
2. **行对应关系**:轨道的 24pt 短分隔线 ⇄ 面板的分组标题(同一行两种密度,交叉淡化);轨道 `tmux` 迷你标签 ⇄ `tmux · 主机 · 会话` 分组标题;搜索裸图标 ⇄ 搜索框(放大镜位置几乎重合);底部统一为「tailnet 状态行 / 开合键行 / 分割线+齿轮 footBand」,两态元素不增不减。
|
||
3. **组合**:舞台前的占位列只在 Pin 态推到 288(保持「钉住/解除各 resize 一次」);遮罩与侧边栏**常驻**在同一 overlay 里,遮罩动 opacity、投影(`Elevation.panel`)动 opacity —— 遮罩⇄Pin 时侧边栏本体**一动不动**,只有遮罩淡出+舞台平移。
|
||
4. **新坑:`clipped()` 只裁绘制不裁触摸** —— 收起态下整行按钮被裁掉的明细区会伸进终端抢点击。整行按钮用 `LeadingHitShape`(Animatable `contentShape`,命中区=当前可见宽度);纯明细控件(搜索框/Pin/新建 window)再加 `allowsHitTesting(expanded)`。
|
||
5. **验证**(fixture + ×12 慢放 + 逐帧像素采样):收起→遮罩(遮罩逐帧变暗 30→29→22)、遮罩→收起(面板前沿扫过采样点+遮罩渐浅)、遮罩→Pin(胶囊中间帧 709px vs 终值 826px,舞台平移中)、Pin→收起(427→362)均有中间帧,无硬切。
|
||
|
||
**用户复核后追加裁决(同日)——砍掉悬浮态,只留两态**:Pin→收起的"明细淡出 + 终端从右盖上来"效果被点名保留,展开即反向。
|
||
- `SidebarState` 只剩 `collapsed|pinned`;`togglePin`/`dismissOverlaySidebar`/遮罩/浮层投影全删;`TerminalSidebar` 直接进 HStack 参与布局(无占位列),开合就是它的宽度动画(56⇄288),各触发一次 resize;点终端**不再**收起侧边栏(固定是持久态)。
|
||
- **开合入口收敛**(此前顶部/底部两个收起键冲突、底部那枚缩在左下角不协调):Logo 位=开合主入口(收起态显 » 展开键、展开态还原 `›` Logo,同框同位交叉淡化——用户定稿:将来 App Logo 设计与展开按钮一体);头部 « 收起键保留;底部开合行删除;Pin 键删除(两态下无意义)。`terminalx://ui/sidebar/pin` 兼容保留=toggle。
|
||
- **顺带修掉暴露的旧缺陷**:标题胶囊的格子数读 `screenGrid.value` 却从不刷新(旧注释"首次布局后稳定,不必 @Published"在"开合即 resize"下不成立,表现为开合后数字停在旧值)→ `GridBox` 加 `onGridChange`(去重)、`TerminalSession` 转发 `objectWillChange`。验证:展开 135×50 / 收起 164×50 实时跟随。
|
||
- 验证:展开动画 pane 左描边逐帧 58→198.5→261→290pt(easeOut 先快后慢),无硬切;收起态 Logo 位为 »、底部只剩状态点/分割线/齿轮;展开态头部仅 Logo+名称+«。
|
||
|
||
### 12.3k 放弃移动端分屏 + tmux window Tab 上移 + 状态出口合并(用户裁决,2026-07-26)
|
||
|
||
用户判词:全面放弃移动端的分屏**操作**(非核心功能);tmux 的切换从侧边栏移到顶部作 Tab,**活动会话(主机连接)的切换仍在侧边栏**;右上状态文字移到左下并与既有文字**合并**(原两处信息重复)。
|
||
(第一版误把「会话列表」理解成主机连接列表上移为 Tab,用户纠正 + AskUser 确认:Tab = **tmux window**(与 tmux -CC「window 映射为原生 tab」惯例一致),非 tmux session 切换——后者需 switch-client + 窗口全量重建,未做。)
|
||
|
||
**1. 删分屏(入口 + 逻辑)**:
|
||
- UI 入口:底部动作条「竖分屏」按钮、分屏菜单 `SplitMenuView`(12.3d 的 3b 卡片整个删除)、`AppModal.splitMenu`。
|
||
- 逻辑:`TmuxController.splitWindow/splitIntoNewSession`、`list-sessions` 管线(`TmuxSessionInfo`/`sessions`/`listSessions()`/`PendingKind.listSessions`——只服务分屏菜单)、`TerminalSession.tmuxSessionList`/`injectedTmuxSessions`、fixture 的 `tmuxSessions()`、无头 URL `ui/split`/`ui/split-now`。
|
||
- **保留**:多 pane 的**被动渲染**与 `PaneSplitter` 拖拽/`resizePane`(别的客户端建好的分屏 attach 进来仍须按 layout 正确显示)、`ui/resize/<cols>`。
|
||
- 顺带修掉 `TmuxController` 响应 switch 里重复的 `case .currentSession`(死代码)。
|
||
|
||
**2. tmux window Tab(`TmuxWindowTabsBar`,TerminalChrome.swift)**:顶带 = 左标题胶囊 + window Tab 条(右上状态胶囊撤销)。一个 window 一个浮起胶囊 tab(编号徽标 + 标题),点击 `selectWindow`(链路复用旧侧边栏 window 行);当前 window accent 高亮;controller 未 attach 但有 `tmuxSummary` 时按占位渲染(fixture/接回前),原生窗口不显示。三层嵌套订阅各自拆视图(`WindowTabsRow` 订阅 controller、`WindowTab` 订阅 window——title/index 是 window 自己的 @Published)。侧边栏 tmux 段(`tmuxDivider`/`tmuxLabelRow`/`tmuxRows`/`SidebarWindowRow`/`WindowRowBody`/「新建 window」行)删除,「新建 window」入口保留在底部动作条;侧边栏 = 首页 / **活动会话(保留,主机连接切换)** / 连接新主机 / tailnet / 设置,不再要 `session` 参数。
|
||
**3. 状态出口合并(`TerminalActionBar.statusLine`)**:原右上三格分层徽标(`tsnet|mosh|tmux`)与原左下上下文文字合并为左下**唯一**状态行:`[dot 链路] | 传输(重连计数) | 会话概览(tmux 名 · N windows / 原生窗口 / 无 tmux / phaseText)`。主机名不再出现(标题胶囊与当前 tab 已有)。「只染出问题的那层”规则原样保留。
|
||
- 验证:fixture 三会话截图对拍(window tab 高亮/占位 tab/合并状态行/侧边栏两态排版含活动会话段)+ `ui/session/2` 切换 + 开合 resize 格子数跟随(164×50⇄135×50)。真机 tmux 下点 tab 切 window 待验证(链路与旧侧边栏 window 行相同)。
|
||
|
||
### 12.3l 顶带落地重设计:按钮分离 / 标题弱化 / tab 组(用户定稿,2026-07-26)
|
||
|
||
用户判词:按钮、title、tab 可以在终端**外**区域;按钮分离出来单独悬浮;标题弱化;tab 要有「组」的概念。
|
||
|
||
- **浮起胶囊废除,顶带落地为真实 chrome 行**(`TerminalTopBar` 替换 `TerminalTitleCapsule`+`FloatingCapsule`):`stage` 从 ZStack overlay 改回 VStack 首行,终端 pane 从带下方开始 —— 86pt 预留带(`TX.Layout.capsuleInset`,已删令牌)与「胶囊遮内容」问题一并消失。pane 顶边现在朝 chrome:topLeading 收圆角、顶描边恢复(不再 `top:-1` 推出屏外);右边仍贴屏幕物理边缘不收角。pane 内容留 `TX.Space.s` 呼吸位。
|
||
- **顶带层级(一眼一个主角)**:`[✕ ⌄ 独立按钮组] · 弱化标题 · [tab 组]`。按钮升到 28pt 圆角方块自成一簇(危险操作与身份信息不共容器);标题降为纯文本行(状态点 5pt + 主机名 mono12 text2 + cwd textWeak + 格子数 textFaint),**当前 window title 从标题里删掉** —— 它已由 tab 组选中 chip 承担,写两遍是重复;tab 组是顶带唯一带容器的元素。
|
||
- **tab 组(`TmuxWindowTabGroup`)**:surfaceDark 圆角槽(radius 11)内衬 3pt,window 是槽内段式 chip(选中 = selectedBg+描边浮起,未选 = 透明贴槽底),尾部 `+` 新建 window。**坑:槽体背景必须挂在 ScrollView 的内容层** —— 挂 ScrollView 自身就是一条拖到屏幕右边的空槽(ScrollView 恒占满剩余宽度)。占位态(controller 未 attach 但有 tmuxSummary)共用 chip 本体;原生窗口不渲染 tab 组。
|
||
- 验证:fixture 截图 ×3(tmux 态顶带 / 原生窗口态无 tab 组 / 展开侧边栏组合态),标题去重与槽体紧贴成组均确认。
|
||
|
||
### 12.3m 换行错乱根因修复:客户端格子的权威改为 ghostty 实测(真机报障,2026-07-26)
|
||
|
||
用户报障:无论侧边栏收起/展开,终端(Claude Code 等全屏 TUI)换行错乱、行首冒碎片,竞品完美。
|
||
|
||
- **根因**(模拟器连本机 sshd 复现实锤):`containerResized` 用「容器px ÷ 整数cell px」算 tmux 客户端格子,复刻不了 ghostty 内部取整/内边距 —— 实测算得 **164×51**、ghostty 真实 **163×50**(列差来自 `TmuxPaneView` 的 1pt padding 圈,行差来自像素除法本身)。远端按 164 列排版、surface 按 163 列自动换行 → 每条满宽行掉字符,重绘积累成花屏。
|
||
- **修复**(`TmuxController` + `ContentView` + `TerminalChrome`):① pane resize 回调从「只报 cell 像素」升级为上报**完整实测格子**(`paneViewportChanged`);② **单 pane window 的 pane 帧==容器帧 → 它的实测格子就是客户端格子**,据此发 `refresh-client -C`(帧只由容器决定,无反馈环);③ kickoff 初值改用 raw surface 实测格子(`setClientSize` 不再被像素换算覆盖);④ 单 pane 时去掉 pane 的 1pt padding 圈(`solo`);⑤ 像素除法只兜底「全是多 pane window」的场景;⑥ 顶栏格子数 tmux 模式改读 `TmuxController.clientGrid`(`screenGrid` 随 RawTerminalView 卸载已冻结)。
|
||
- **验证**(模拟器 → 本机 sshd + tmux 3.7b 端到端):`tput cols` 宽字符标尺恰好一行不换行;侧边栏收起 164×50 ⇄ 展开 135×50 双态各验证;`top` 全屏重绘干净;顶栏胶囊数字随开合正确跳变。回归法已写入 CLAUDE.md(模拟器 `-txPubkeyAuth 1` 连 127.0.0.1,公钥临时入 authorized_keys)。
|
||
|
||
### 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>
|