Files
terminalX/CLAUDE.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

15 KiB
Raw Blame History

CLAUDE.md — terminalX

原生苹果生态终端工具,解决 iPad/iPhone 远程运维 macOS/Linux + vibe coding 痛点。 平台优先级 iPadOS > iOS > macOS(Apple Silicon)。四大核心:libghostty 引擎 · mosh · Tailscale(tsnet 用户态) · tmux control-mode hook。 iOS/iPadOS 只做远程(沙盒禁 fork/execmacOS 支持本地 PTY。

新会话必读:先读 docs/HANDOFF.md(项目全貌 + 里程碑状态 + 如何继续)。本文件只讲操作规范。

⚠️ 铁律:绝不臆想工具结果

调用任何工具后,在真实结果返回前绝不预测/编造/续写其输出(不写假想的 BUILD SUCCEEDED、日志、ls、截图内容)。一次调用→停→读真实返回→再推理。想说预期只在调用之前用将来时。发现自己编造要硬停重新核实。(曾因此误诊,教训见 git 历史 / HANDOFF。

环境约束(关键,决定一切构建方式)

  • 构建机走带认证的 HTTPS 代理(疑似国内网)。规律:github.com release blob 可经 curl 下;go.dev/ghcr.io(brew bottle) 被代理 abortSwiftPM 不支持带认证代理 → 所有 SPM 依赖必须 vendor 本地化(见 vendor/。Go 模块走 goproxy.cn
  • Go 1.26.5 在 ~/.local/go(不在默认 PATH经 aliyun 镜像 curl 装);gomobile/gobind 在 ~/go/bingo env 已固化 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。

构建 & 验证命令

# 纯逻辑单测(无需 Xcode/模拟器)
swift test --package-path packages/TXCore                 # 50 tests (40 swift-testing + 10 XCTest)

# 重建 SSH 库 xcframeworklibssh2+mbedTLS改 vendor/libssh2|mbedtls 后)
bash vendor/build/build-cssh.sh all                        # 产出 artifacts/CSSHCore.xcframework (library-only!)

# 重建 mosh 库 xcframeworkmosh C++ + protobuf-lite改 vendor/mosh|protobuf 或 build/mosh/* 后)
bash vendor/build/build-mosh.sh all                       # 产出 artifacts/MoshCore.xcframework (library-only!)
# 注:首次需 host protoc 生成 .pb.cc已 checkin无需重跑
#   cmake -S vendor/protobuf -B vendor/protobuf/build-host -G Ninja -Dprotobuf_BUILD_TESTS=OFF && \
#   cmake --build vendor/protobuf/build-host --target protoc && \
#   vendor/protobuf/build-host/protoc --proto_path=vendor/mosh/src/protobufs --cpp_out=vendor/mosh/src/protobufs *.proto

# 重建 tsnet 桥(改 vendor/tsnet-bridge/tsnetbridge/bridge.go 后)
export GOROOT=$HOME/.local/go; export PATH=$GOROOT/bin:$HOME/go/bin:$PATH
cd vendor/tsnet-bridge && gomobile bind -target=ios,iossimulator \
  -o ../../artifacts/TsnetBridge.xcframework ./tsnetbridge

# 构建 app改 project.yml 后先 xcodegen generate
cd apps/TerminalX && xcodegen generate --spec project.yml
xcodebuild -project TerminalX.xcodeproj -scheme TerminalX \
  -destination 'platform=iOS Simulator,id=<UDID>' \
  -clonedSourcePackagesDirPath /tmp/tx-spm build

无头验证(本环境无 GUI用 simctl 截图取证)

iPad Pro 13(M5) 模拟器 UDID2EA9055E-4557-4B20-82AF-56A68AC1D454(变了用 xcrun simctl list devices)。

UDID=2EA9055E-4557-4B20-82AF-56A68AC1D454
APP=$(find ~/Library/Developer/Xcode/DerivedData/TerminalX-*/Build/Products/Debug-iphonesimulator -maxdepth 1 -name TerminalX.app | head -1)
xcrun simctl boot $UDID; xcrun simctl install $UDID "$APP"
xcrun simctl launch $UDID ai.athom.terminalx --args <见下>
sleep N; xcrun simctl io $UDID screenshot /tmp/x.png     # 然后用 Read 工具看 /tmp/x.png

启动参数NSUserDefaults 参数域,自动连接,供无头验证)

  • 直连 SSH-txHost H -txPort 22 -txUser U -txPass PW -txAutoCommand "echo hi"
  • tsnet SSH额外加 -txTsnetKey <authkey>-txHost 填 tailnet IP
  • 纯 tsnet 自检(加入+对端枚举):只给 -txTsnetKey <authkey>(不给 -txHost
  • mosh 直连(LAN)-txHost H -txPort 22 -txUser U -txPass PW -txMosh 1(登录后跑 mosh-server→解析 MOSH CONNECT→mosh_main 直连 UDP
  • mosh over tsnet:额外加 -txTsnetKey <authkey>-txHost 填 tailnet IPrelay=loopback↔tsnet UDP
  • 自定义 mosh-server 命令:-txMoshServerCmd "…"(默认 mosh-server new -s -c 256 -l LANG=en_US.UTF-8 -l LC_ALL=en_US.UTF-8
  • tmux 自动进入:默认开(SavedHost.useTmux)。-txTmux 0 关闭(回归 raw 终端路径)、-txTmuxSession <名> 指定会话名(默认 main)。
  • M4 publickey 认证-txPubkeyAuth 1(不给 -txPassSigningKeyProvider 造 P-256 key日志打 authorized_keys 行,加到服务器后重连免密登录)
  • M4 host key mismatch 测试-txHostKeyPinOverrideBase64 <b64>(播种假 pin → 真 host key 不符 → 弹告警)

UI 改造期无头验证(推荐走脚本)scripts/tx-shot.sh <out.png> [-- <启动参数>] 一步完成装/启/截图/修正方向。

  • -txLandscape 1app 侧 requestGeometryUpdate 转横屏(设计基准 iPad Pro 13″ 1366×1024。无 GUI 时模拟器帧缓冲恒竖屏,故脚本截完 sips -r 90 转回来(内容已横向渲染)。
  • -txUIFixture 1:造 3 条假会话mac-studio/ubuntu-cn2/nas-home含 direct/relay/重连三种状态)灌示意输出进 in-memory 终端,不建任何传输连接。设计稿的首页会话卡/轨道多头像/状态徽标都要多会话才有内容,用真机连接验证太慢。见 UIPreviewFixture.swiftDEBUG only-txHost 互斥。
  • terminalx://ui/<动作>xcrun simctl openurl):无头驱动导航与浮层——home / minimize / close(弹确认) / close-now(直接关) / split/v|h(分屏菜单) / dismiss / session/<n> / sidebar/toggle|pin。比坐标点击稳(不依赖 idb companion不随布局失效。模态状态放在 AppRouter.modal 而非视图 @State,快捷键与 URL 才能共用同一入口。
  • 对拍走查法(还原度靠这个,别靠目测):截图 → 用 python+PIL 把设计稿 PNG 与截图统一缩放到 1366×1024 → 裁同区域并排 → 再对元素列做垂直投影扫描量化 y 坐标。缩略图里 #1E1E2E#11121C 目测分不出,必须采样像素。
  • 坑:statusBarHidden 不能挂在 TerminalScreen(它常驻挂载,会连首页一起隐藏)→ 挂 RootViewrouter.route 判断。
  • 设计稿本体在 docs/design/UI-DESIGN-HANDOFF.md(token/规格/交互全表) + terminalX-iPadOS.dc.html(13 屏画板) + render-screens.py(拆屏,配 headless Chrome 逐屏截 PNG 对拍)。

M3 挂起→恢复无头验证:退后台 simctl openurl $UDID https://apple.com;冻结 kill -STOP $(pgrep -x TerminalX);解冻 kill -CONT;前台 simctl openurl $UDID "terminalx://resume"URL scheme 已注册)。日志 log stream | grep -E 'MOSHDBG|TXM3'wake pulseTXM3 recovered 时间差判 <3s。坑:模拟器看门狗会杀掉被 STOP 冻结过久(>~30s)的 app且模拟器不复现 socket defunct/WG 过期,深挂起需真机锁屏补测。

仓库结构

  • packages/TXCore — 零依赖纯逻辑tmux 解析器/layout/SessionMachine(会话状态机+退避重连)/TmuxControlSequence(DCS 检测)。
  • packages/TXTransportSSHSession(libssh2, 支持外部 fd)/MoshSession(mosh_main 桥)/Transport 协议C shim: CSSH+CSSHCore(libssh2)、CMosh+MoshCore(mosh)。
  • apps/TerminalX/iOSTerminalSession(一实例=一条会话:连接编排+tsnet+tmux 路由+mosh 编排+viewport 快照)/SessionManager(多会话持有+最小化/关闭语义+生命周期广播)/AppRouter(AppRoute .home|.terminal(id) + SidebarState 三态)/ContentView(RootView+SessionCanvas 会话常驻挂载+TerminalScreen 沉浸轨道页+TmuxStage)/HomeView(首页会话卡+主机网格)/RailSidebar(56pt 轨道+288pt 面板三态)/TerminalChrome(浮起标题/状态胶囊+提示条)/TXParts(设计令牌小件+终端文本镜像)/TmuxController(tmux 网关)/TsnetProbe(M1 自检)/Theme(TXAccent 恒定 blurple + TXChrome 中性阶×4 + TXFlavor 终端配色)/HostStore(主机列表 JSON 持久化)/UIPreviewFixture(DEBUG 无头 UI 验证脚手架)。
  • UI 已从 Demo 转入商业化改造:设计定稿见 docs/design/UI-DESIGN-HANDOFF.mdNocturne chrome + Catppuccin 终端 + 沉浸轨道),路线图与进度见 docs/HANDOFF.md §12
  • vendor/ — 所有非 Swift 依赖本地化:libghostty-spm(GhosttyKit)/MSDisplayLink/libssh2/mbedtls/tsnet-bridge(Go)/mosh(blinksh/ios 分支)/protobuf(3.21.12)build/mosh/(手写 config.h+CMakeLists)。
  • artifacts/ — 预编译 xcframework有意提交,非 gitignoreCSSHCore/TsnetBridge/MoshCore

约定 & 坑

  • CSSHCore/MoshCore 必须 library-only(无 Headers/modulemapSwift module 由包内 CSSH/CMosh C target 提供。否则与 GhosttyKit 撞 include/module.modulemap
  • mosh 构建:绕过 autotools 用 CMake 直编 src/*.cc(手写 build/mosh/config.hcrypto 用 CommonCrypto(USE_APPLE_COMMON_CRYPTO_AES=1);用 terminaldisplayinit_ios.cc(无 terminfo);已 patch 3 处 vendored 源(::bind 消歧义 / curses include 包 #if !IOS_CONTROLLER / 去 iOS 禁用 system())protobuf 只需 lite runtime.pb.cc 已 checkin。
  • protoc 装不了不是问题:用源码 CMake 编一个 host protoc 生成 .pb.cc 即可(一次性,产物 checkin
  • 一进程一个 Go runtime、只一次 gomobile bind(全在 tsnetbridge 包)。
  • Swift \r\n 是单个 Character扩展字形簇:判行尾用 Character.isNewline,不能 c != "\r"MoshConnectScanner 曾踩)。
  • Swift6 SendNonSendable pass 可能崩溃pthread_create+Unmanaged 上下文+C 入口的组合):改用 Foundation.Thread+线程内 pthread_self() 规避(见 MoshSession)。
  • GhosttyKit 的 InMemoryTerminalSession.receive 在 surface 未 attach 时丢弃写入 → 用 OutputGate 缓冲到 surfaceSize != nil 再 flush。
  • tmux -CC attach 不推 %window-add → 必须主动 list-windows 枚举。
  • tmux format 的字段分隔符不能用 tab:真机实测 tab 经 PTY 送到 tmux 后变成下划线list-windows/list-sessions 整行分不开(count=1)→ title/cwd/会话名全丢、UI 显示兜底名「窗口 N」。改用 TmuxController.fieldSep = "|:|"(可打印、不被 PTY 处理、# 会被 tmux format 当特殊字符所以不能用)。排查提醒os_log 会把控制字符显示成 _,光看日志分不出「真下划线」还是「被显示替换的 tab」——要打印 parts.count 才能确诊。
  • tmux 当前会话名要用 display-message -p "#{session_name}",不能拿 list-sessionssession_attached 反推:服务器上别的客户端 attach 着其它会话时会猜错(真机把用户自己的 demo 当成了当前会话)。
  • 轨道/侧边栏显示 #{window_index} 而非 window_id@2 是内部 id用户看到的编号是 index0 起,删过 window 后两者会差很远)。
  • TerminalSession 必须转发 TmuxController.objectWillChangetmuxSummary 等是读 controller 的 computed property而轨道/胶囊/侧边栏只 @ObservedObject 订阅 session。没有这条转发数据解析全对但 UI 不刷新真机表现为轨道空、第三格只有「tmux」。同一个坑的另一面见下条。
  • 连接流程 = 点主机 → 留在首页探测 → 首页弹选择器 → 选完才进终端(用户定稿,别改回"自动 attach"或"先进终端再弹框"SSH 连上 → 在普通 shell 里跑 TmuxSessionProbe.command → 选择器给三种连接方式(接回已有会话 / 新建会话 / 原生终端,不用"跳过"这种模糊措辞)。SessionManager.enterRequest 决定何时进终端;isProbingTmux 必须在 .connected 当刻置位,否则连接建立与探测开始(延迟 2.5s)之间的空窗会让 evaluateEnter 提前把人送进终端页。没装 tmux 时不弹框,直接原生终端 + 安装引导横幅。探测的三个坑:① 命令会被 PTY 回显,标记必须写成相邻字符串拼接("__TX""_SESSIONS_BEGIN")否则回显行被当成结果起点;② 「没装 tmux」与「装了但没会话」的 stderr 都被 2>/dev/null 吞掉、标记之间同样空,只能靠 tmux -V 的版本行分开(两者 UI 完全不同);③ 探测命令与输出会脏屏 → 命令末尾接 printf '\033[H\033[2J\033[3J' 清屏(刚连上时清掉的只有 motd
  • tmux 不会自己进TmuxRouter 只被动检测字节流里的 DCS 前导,必须由 app 主动发 tmux -CC new -A -s <名>(登录后 2.5s 等提示符就绪,沿用 mosh 引导已验证的延迟)。-A = 同名会话存在就接回。4s 内没进 control mode 判定无 tmux → 降级客户端窗口 + 行内安装引导横幅(设计 2c不弹窗不强推。曾经漏了这一步 → 真机连上永远显示「无 tmux」。
  • tmux 多 pane 尺寸走"路线 b"app 按全屏格子发 refresh-client -C WxH 让 tmux 布局pane surface 尺寸严格取 layout rect%output 按 tmux pane 宽高排版,二者不一致必换行/清屏错乱pane surface 的 resize 回调只断言不反向驱动 tmux防反馈环。diff 用 full layout、渲染用 visible layoutzoom 免费SwiftUI identity 只用 paneID布局变不重建 surface。新 pane 在 capture-pane %end 前丢弃其 %outputtmux 单线程串行保证快照不缺不重)。
  • tmux 网关字节必须单消费者保序:多个 Task{@MainActor} 到主线程顺序无保证,多 pane 高吞吐会打碎 % 协议 → 用 TmuxByteChannel(AsyncStream) 单消费者。
  • 导航不得销毁会话:终端不能fullScreenCover——① surface 一旦从视图树摘除ghostty 释放 grid内容全丢② matchedGeometry最小化缩回首页卡要求两端同层级独立 presentation 层做不到。故 SessionCanvas 把所有会话 ZStack 常驻挂载、只切 opacity + allowsHitTesting(沿用 tmux 切 tab 已验证的手法),画布恒全屏 → 切路由不触发 PTY resize。
  • 首页缩略卡不许再开 surface:一个 ghostty surface 只有一个 IOSurfaceLayer同处两地/离屏 Metal 渲染是已修过的泄漏雷区。缩略卡走 TerminalSession.captureSnapshot()readViewportText() 抓尾部若干行)+ TXTerminalMirror 文本渲染(底对齐),代价只是丢 ANSI 颜色。
  • 嵌套 ObservableObject 不冒泡SessionManager@Published sessions 不会因某条会话内部 @Published 变化而发布。凡显示会话状态的行/卡片都要抽成独立视图用 @ObservedObject var session: 直接订阅(见 SessionSidebarRow/SessionStatusRow)。
  • SourceKit "No such module" 是索引噪声,swift build/xcodebuild 为准
  • 密钥/密码绝不写进仓库;测试主机凭据由用户即时提供。
  • 提交/推送 git 只在用户明确要求时main 分支已有 6 次提交,未 push、无 remote
  • 与用户始终用简体中文交流。