按 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>
47 KiB
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. 关键架构决策(已定,勿推翻)
- 终端引擎用 libghostty,经社区 GhosttyKit(
Lakr233/libghostty-spm,已 vendor 到vendor/libghostty-spm)。TerminalViewState是ObservableObject;喂字节入口InMemoryTerminalSession.receive(Data);SwiftUI 视图TerminalSurfaceView(context:)。 - Tailscale 走 tsnet 用户态(Go,gomobile bind →
TsnetBridge.xcframework),不用 NetworkExtension(避开 VPN entitlement + 50MB 内存墙)。 - SSH 用 libssh2 + mbedTLS(
CSSHCore.xcframework),支持传入外部 fd。 - SSH-over-tsnet = fd 桥:
tsnet.Dial()得 net.Conn(无真实 fd)→socketpair(AF_UNIX)+ 双向 io.Copy 泵 → 把另一端 fd 交给 libssh2。见vendor/tsnet-bridge/tsnetbridge/bridge.go: DialTCPFD。 - 一进程只一个 Go runtime、只一次 gomobile bind;C/C++ 库符号隐藏只导出 C API。
- 多账号(未实现):多 tsnet 节点并存;每 host 绑 egress profile(Direct/tailnet);禁止多 tailnet 并发盲探(重叠 100.x/同名会误连)。
3. 环境(决定构建方式,极重要)
- 带认证的 HTTPS 代理(
HTTPS_PROXY=...@...:38128)。github.comrelease 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.swiftTmuxEvent.swiftTmuxIDs.swiftTmuxOutputDecoder.swiftTmuxLayout.swiftTmuxControlSequence.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 安全:
TXTransportSSHConfig.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()(tsnetInjectEvent+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);surfaceByPaneO(1) 路由 %output;applyLayoutreconcile(增删留);attach:refresh-client -C WxH→list-windows(带 layout)→每 panecapture-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→appKeychainKnownHostsStore文件后端) + publickey 认证(Authentication.publicKeyCallback(SSHSigner)→SSHSessionCsshSignCallback→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 均已完成并验证;以下按价值)
- 真机化(最大缺口,多项验证只在模拟器):配置签名 + entitlements(tsnet 用户态无需 NE,利好审核)→ 上真机/TestFlight。真机上补验三件模拟器测不了的:①SE 硬件密钥生成+签名+keychain 持久化(M4b 现回退软件+文件;
SigningKeyProvider已备 SE 路径);②mosh 深挂起恢复(锁屏数分钟过 WG 180s 过期线 / socket defunct,验MoshRelay.Rebind+hop_port);③签名后 Keychain 可用(当前未签名 SecItem 全 -34018,host key pin 才回退到文件)。 - 产品化:主机列表持久化(签名后用 Keychain 存凭据/host 配置)、多账号多 tsnet 节点(架构决策 §2.6:每 host 绑 egress,禁多 tailnet 盲探)、软键盘运维工具栏(GhosttyKit 内建,接线即可)、多标签(非 tmux)。
- 打磨(可选,不阻塞):
- 清理诊断埋点:
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 状态)。
- 清理诊断埋点:
- 冷启动 mosh 重连(大活):mosh 进程被杀后接原 detached 会话——受 SSP 序号防重放限制,需持久化 MOSH_KEY+port 且改 mosh 序列化客户端状态(Blink 式)。价值中等,成本高。
- macOS target:本地 PTY(
TXPTY)未建。 - 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 点击+截图取证)
- 终端底部残影(旧帧钉屏底):ghostty 在 iOS 用
addSublayer:挂 IOSurfaceLayer,但Metal.deinit从不removeFromSuperlayer(上游 iOS-only 缺陷),SwiftUI attach 抖动导致同一 view 多次建 surface 时旧层泄漏、冻结小尺寸首帧,被 CAtopLeftgravity 钉在屏底。修:vendorUITerminalView.platformSetup建 surface 前清现存子层 +TerminalSurfaceCoordinator.onSurfaceFreed钩子(tearDownSurface后回调)在commonInit里摘子层。(诊断经 Fable 顾问在 ghostty 源码层锚定。) - tmux 切 tab 切回旧 tab 内容消失:
TmuxTabbedView原只渲 activeWindow 且.id(win.id),切走销毁 pane 视图→释放 surface→内容丢失。修:改成所有窗口常驻挂载、仅激活可见(ZStack+opacity+allowsHitTesting),surface 不释放、内容保留、后台窗口持续收 %output。 - tmux pane 内容钉底/顶部残行:pane 的 ghostty grid 由容器像素几何决定(tab 条吃高度→73 行),而 attach 用 raw 全屏 75 行 capture,75 行快照喂 73 行 grid 溢出上滚。修:(A) 容器几何测量上移到
TmuxTabbedView内容槽(破鸡生蛋死锁);(B)TmuxControllerattach 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(
inputAccessoryStyleSwiftUI 未暴露需桥接)。 - 无头 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(libssh2publickey_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 + stateDirtsnet-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(主题+关于 +KnownHostsViewpin 查看/删除)。
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.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:)复用同主机会话、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 标「最小化中」置顶)→ Cui/session/2切会话2(独立内容 + tsnet 徽标)→ Dui/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 分不出)。
修正项:
- 沉浸不彻底(最严重):原来把 86pt 预留带做成"pane 容器整体下移",胶囊浮在舞台底色上。改为 pane 容器从舞台顶部就开始(终端底色 + 边框铺满),只把 surface 内容
padding(.top, 86)—— 预留带铺的是flavor.terminalBg,故与终端内容视觉连续,胶囊真正"悬浮在终端之上"。像素采样验证:预留带与内容区同为#1E1E2E。 - 终端页隐藏系统状态栏(设计稿
1b/3a顶部就是胶囊/logo,1e/1g才有状态栏)+ignoresSafeArea(edges: .top)。坑:statusBarHidden不能挂在TerminalScreen上——它常驻挂载(SessionCanvas),会连首页一起隐藏;要挂在RootView上按router.route判断。 - 侧边栏动画:三态切换统一走
AppRouter.sidebarAnimation(.easeOut(0.24),设计要求 200–260ms),面板.move(edge: .leading)滑入 + 遮罩.opacity淡入。 - 底部两区同高:侧边栏脚与舞台提示条共用
TX.Layout.footHeight(34pt),故两侧分割线落在同一 y;侧边栏脚原来的深色块 + 齿轮去掉,改 1ptdivider(设计稿如此)。 - 轨道结构:搜索/首页/设置是裸图标(无底无框),只有 logo 与展开键有框(
TXIconButton(bare:));纵向节奏按扫描到的设计坐标写死(logo 28 · 头像 174/220/266 · TMUX 标签 388 · 数字 408 起);TMUX 段标签用 accent-500 而非中性灰。 - 侧边栏面板:logo 30pt、会话行 48pt、window 行 44pt(实测值);分组分隔线改渐隐 LinearGradient;占位分支补回漏掉的「新建 window ⌘T」行。对齐后与设计稿偏差 ≤9pt(window 区完全重合)。
- 缩略卡:卡头/卡脚分隔线用 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-windowsformat 追加#{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:
- 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才确诊。 - UI 不刷新(正是 CLAUDE.md 里「嵌套 ObservableObject 不冒泡」那条坑的另一面):
tmuxSummary/window 列表是读 controller 的 computed property,而轨道/胶囊/侧边栏只订阅 session → 数据解析对了 UI 也不动。修法:TerminalSession用 Combine 订阅controller.objectWillChange并转发自己的objectWillChange(一处解决所有读 controller 的视图)。 - 会话名猜错:
currentSessionName原来取list-sessions里session_attached==1的那个,结果把用户自己终端 attach 的demo当成了当前会话。改用display-message -p "#{session_name}"(权威:本客户端所在会话)。分屏菜单的「当前」判定也随之改为按会话名比较。 - 顺带修正:轨道/侧边栏的 window 编号用
#{window_index}(用户可见的 0/1/2…)而非内部window_id(@2/@3…)。
验证结果(服务器侧交叉验证):
- tmux 自动进入 ✓ 状态胶囊
direct | ssh | tmux main、标题胶囊fish · ~ 163×50、轨道 window0/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。三个坑都在注释里:- PTY 回显:命令本身会被回显,标记若字面出现在命令里,回显行会被当成结果起点 → 写成相邻字符串拼接
"__TX""_SESSIONS_BEGIN"(shell 输出完整标记,回显的是拼接前的样子)。 - 三态要分开:「没装 tmux」和「装了但没会话」的 stderr 都被
2>/dev/null吞掉、标记之间同样是空的,只能靠tmux -V的版本行区分 —— 两者的正确 UI 完全不同(安装引导 vs 让用户新建)。 - 别脏屏:探测命令与输出会打在用户屏幕上 → 命令末尾接
printf '\033[H\033[2J\033[3J'(归位+清屏+清 scrollback,不依赖 terminfo)。刚连上时清掉的只有 motd/shell 欢迎语,首屏反而更干净。
- PTY 回显:命令本身会被回显,标记若字面出现在命令里,回显行会被当成结果起点 → 写成相邻字符串拼接
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+ 轨道 window0/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,灰显占位)。