渲染/tmux 修复(e2e 复现+回归,模拟器 idb 点击+截图取证): - 终端底部残影:ghostty iOS 释放 surface 不摘 IOSurfaceLayer(上游缺陷), platformSetup 清现存子层 + TerminalSurfaceCoordinator.onSurfaceFreed 钩子摘孤儿层 - tmux 切 tab 切回旧 tab 内容消失:改所有窗口常驻挂载(ZStack+opacity+hitTesting), surface 不释放、内容保留、后台窗口持续收 %output - tmux pane 内容钉底/顶部残行:pane grid 由容器几何定(tab 条吃高度),75 行快照喂 73 行 溢出上滚;几何测量上移 TmuxTabbedView + attach kickoff 双条件(attachAcked∧containerKnown) + capture-pane 铺快照前剔尾空行 UI 产品化(对标 Moshi getmoshi.app:深蓝黑 + 终端绿 + Catppuccin): - 设计系统 Theme.swift:TXPalette(色角色 struct)+TXThemeManager(环境注入),颜色封装供主题系统 - 统一主题:抽离 4 套 Catppuccin(Mocha/Macchiato/Frappé/Latte),一套同时驱动 app+终端配色, 设置里选主题 app 与终端一起变色 + UserDefaults 持久化 - 主屏/主机管理页 HomeView:HostCard 主机卡 + FAB 加主机(AddHostSheet) + 设置(主题选择器), HostStore 本地 JSON 持久化(密码暂存/TODO Keychain),点卡片 connect(to:) - 终端外壳 chrome TerminalTopBar:圆形返回钮 + 会话标题 + SSH/mosh 传输徽章 + 状态提示, tmux tab 绿 pill 化,底部工具栏深色自适应,终端默认 Catppuccin Mocha iPad mini(A17 Pro) live 端到端验证:加主机→连接→终端→切 tab→分屏点焦点→主题切换同步。 详见 docs/HANDOFF.md §10。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.6 KiB
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/exec);macOS 支持本地 PTY。
新会话必读:先读
docs/HANDOFF.md(项目全貌 + 里程碑状态 + 如何继续)。本文件只讲操作规范。
⚠️ 铁律:绝不臆想工具结果
调用任何工具后,在真实结果返回前绝不预测/编造/续写其输出(不写假想的 BUILD SUCCEEDED、日志、ls、截图内容)。一次调用→停→读真实返回→再推理。想说预期只在调用之前用将来时。发现自己编造要硬停重新核实。(曾因此误诊,教训见 git 历史 / HANDOFF。)
环境约束(关键,决定一切构建方式)
- 构建机走带认证的 HTTPS 代理(疑似国内网)。规律:
github.comrelease blob 可经curl下;go.dev/ghcr.io(brew bottle) 被代理 abort;SwiftPM 不支持带认证代理 → 所有 SPM 依赖必须 vendor 本地化(见vendor/)。Go 模块走goproxy.cn。 - Go 1.26.5 在
~/.local/go(不在默认 PATH!经 aliyun 镜像 curl 装);gomobile/gobind 在~/go/bin。go 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 库 xcframework(libssh2+mbedTLS,改 vendor/libssh2|mbedtls 后)
bash vendor/build/build-cssh.sh all # 产出 artifacts/CSSHCore.xcframework (library-only!)
# 重建 mosh 库 xcframework(mosh 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) 模拟器 UDID:2EA9055E-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 IP;relay=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) - M4 publickey 认证:
-txPubkeyAuth 1(不给 -txPass;SigningKeyProvider 造 P-256 key,日志打 authorized_keys 行,加到服务器后重连免密登录) - M4 host key mismatch 测试:
-txHostKeyPinOverrideBase64 <b64>(播种假 pin → 真 host key 不符 → 弹告警)
M3 挂起→恢复无头验证:退后台 simctl openurl $UDID https://apple.com;冻结 kill -STOP $(pgrep -x TerminalX);解冻 kill -CONT;前台 simctl openurl $UDID "terminalx://resume"(URL scheme 已注册)。日志 log stream | grep -E 'MOSHDBG|TXM3' 看 wake pulse→TXM3 recovered 时间差判 <3s。坑:模拟器看门狗会杀掉被 STOP 冻结过久(>~30s)的 app;且模拟器不复现 socket defunct/WG 过期,深挂起需真机锁屏补测。
仓库结构
packages/TXCore— 零依赖纯逻辑:tmux 解析器/layout/SessionMachine(会话状态机+退避重连)/TmuxControlSequence(DCS 检测)。packages/TXTransport—SSHSession(libssh2, 支持外部 fd)/MoshSession(mosh_main 桥)/Transport协议;C shim:CSSH+CSSHCore(libssh2)、CMosh+MoshCore(mosh)。apps/TerminalX/iOS—SSHTerminalModel(连接编排+tsnet+tmux 路由+mosh 编排)/ContentView(HomeView 主机管理页+TerminalScreen 终端 chrome+设置)/TmuxController(tmux 网关)/TsnetProbe(M1 自检)/Theme(TXPalette+TXThemeManager 设计令牌,颜色封装供主题系统)/HostStore(主机列表 JSON 持久化)。UI 对标 Moshi(深蓝黑+终端绿+Catppuccin),见docs/HANDOFF.md §10。vendor/— 所有非 Swift 依赖本地化:libghostty-spm(GhosttyKit)/MSDisplayLink/libssh2/mbedtls/tsnet-bridge(Go)/mosh(blinksh/ios 分支)/protobuf(3.21.12);build/mosh/(手写 config.h+CMakeLists)。artifacts/— 预编译 xcframework(有意提交,非 gitignore):CSSHCore/TsnetBridge/MoshCore。
约定 & 坑
- CSSHCore/MoshCore 必须 library-only(无 Headers/modulemap);Swift module 由包内
CSSH/CMoshC target 提供。否则与 GhosttyKit 撞include/module.modulemap。 - mosh 构建:绕过 autotools 用 CMake 直编
src/*.cc(手写build/mosh/config.h);crypto 用 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
SendNonSendablepass 可能崩溃(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 多 pane 尺寸走"路线 b":app 按全屏格子发
refresh-client -C WxH让 tmux 布局,pane surface 尺寸严格取 layout rect(%output 按 tmux pane 宽高排版,二者不一致必换行/清屏错乱);pane surface 的 resize 回调只断言不反向驱动 tmux(防反馈环)。diff 用 full layout、渲染用 visible layout(zoom 免费);SwiftUI identity 只用 paneID(布局变不重建 surface)。新 pane 在capture-pane%end 前丢弃其 %output(tmux 单线程串行保证快照不缺不重)。 - tmux 网关字节必须单消费者保序:多个
Task{@MainActor}到主线程顺序无保证,多 pane 高吞吐会打碎 % 协议 → 用TmuxByteChannel(AsyncStream) 单消费者。 - SourceKit "No such module" 是索引噪声,以
swift build/xcodebuild为准。 - 密钥/密码绝不写进仓库;测试主机凭据由用户即时提供。
- 提交/推送 git 只在用户明确要求时(main 分支已有 6 次提交,未 push、无 remote)。
- 与用户始终用简体中文交流。