侧边栏(真机验证): - 砍掉悬浮态,只留收起⇄固定展开;TerminalSidebar 单一视图身份, 内容恒按 288pt 排版、收起=左侧裁切(clipped 宽度动画), 图标列两态同尺寸同位置,明细淡入淡出,终端随宽度盖上/让开 - 开合入口收敛:Logo 位(收起态=»展开键/展开态=Logo)+ 头部«; 删底部开合键与 Pin;LeadingHitShape 修 clipped 不裁触摸 - 修 GridBox 从不通知 UI:onGridChange→objectWillChange, 标题胶囊格子数随开合实时刷新 触控化走查(上一轮,真机部署验证): - 窗口按钮 13pt 色点→22pt 圆角方块常显字形;动作条改可点按钮 - TXTech 拼写/配色规范(SSH/mosh/tmux 官方写法+品牌色映射) - 首页会话卡固定 3 列 4:3;去掉未实现的 ⌘K 提示 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
20 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
真机部署(用户体验用,iPad Pro 13″ M5)
IPAD=454D7605-FA87-51F4-BA62-5E58B640188E # 变了用 xcrun devicectl list devices
cd apps/TerminalX && xcodebuild -project TerminalX.xcodeproj -scheme TerminalX \
-configuration Release -destination "id=$IPAD" \
-clonedSourcePackagesDirPath /tmp/tx-spm -allowProvisioningUpdates build
APP=~/Library/Developer/Xcode/DerivedData/TerminalX-*/Build/Products/Release-iphoneos/TerminalX.app
xcrun devicectl device install app --device $IPAD "$APP"
xcrun devicectl device process launch --device $IPAD --terminate-existing ai.athom.terminalx
自动签名已配好(Team J75A6S5938)。Release 把所有 #if DEBUG 编译掉 → fixture/terminalx://ui/-txSlowMotion 都不存在,真机跑的必然是真实数据路径;签名后 Keychain/SE 可用(不再回退文件后端)。给用户体验用 Release(Debug 的 SwiftUI 开销会让动效发闷)。
无头验证(本环境无 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) - tmux 自动进入:默认开(
SavedHost.useTmux)。-txTmux 0关闭(回归 raw 终端路径)、-txTmuxSession <名>指定会话名(默认main)。 - M4 publickey 认证:
-txPubkeyAuth 1(不给 -txPass;SigningKeyProvider 造 P-256 key,日志打 authorized_keys 行,加到服务器后重连免密登录) - M4 host key mismatch 测试:
-txHostKeyPinOverrideBase64 <b64>(播种假 pin → 真 host key 不符 → 弹告警)
UI 改造期无头验证(推荐走脚本):scripts/tx-shot.sh <out.png> [-- <启动参数>] 一步完成装/启/截图/修正方向。
-txLandscape 1:app 侧requestGeometryUpdate转横屏(设计基准 iPad Pro 13″ 1366×1024)。无 GUI 时模拟器帧缓冲恒竖屏,故脚本截完sips -r 90转回来(内容已横向渲染)。-txUIFixture 1:造 3 条假会话(mac-studio/ubuntu-cn2/nas-home,含 direct/relay/重连三种状态)灌示意输出进 in-memory 终端,不建任何传输连接。设计稿的首页会话卡/轨道多头像/状态徽标都要多会话才有内容,用真机连接验证太慢。见UIPreviewFixture.swift(DEBUG only),与-txHost互斥。terminalx://ui/<动作>(xcrun simctl openurl):无头驱动导航与浮层——home/minimize/close(弹确认) /close-now(直接关) /split/v|h(分屏菜单) /dismiss/session/<n>/sidebar/toggle(sidebar/pin兼容保留=toggle,悬浮态已砍)。比坐标点击稳(不依赖 idb companion,不随布局失效)。模态状态放在AppRouter.modal而非视图@State,快捷键与 URL 才能共用同一入口。- 对拍走查法(还原度靠这个,别靠目测):截图 → 用 python+PIL 把设计稿 PNG 与截图统一缩放到 1366×1024 → 裁同区域并排 → 再对元素列做垂直投影扫描量化 y 坐标。缩略图里
#1E1E2E与#11121C目测分不出,必须采样像素。 - 坑:
statusBarHidden不能挂在TerminalScreen(它常驻挂载,会连首页一起隐藏)→ 挂RootView按router.route判断。 -txSlowMotion 1:所有动效 ×12(DEBUG)。simctl io screenshot自身要几百毫秒,抓不到 240ms 的过渡中间帧 —— 想用截图证明「动效真的在跑」(而不是又退化成硬切)必须先开它。判定法:连发 3 张截图采样同一像素,应得到三个不同的中间值;硬切时三张一模一样。- 设计稿本体在
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 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—TerminalSession(一实例=一条会话:连接编排+tsnet+tmux 路由+mosh 编排+viewport 快照)/SessionManager(多会话持有+最小化/关闭语义+生命周期广播)/AppRouter(AppRoute .home|.terminal(id) + SidebarState 两态 collapsed|pinned)/ContentView(RootView+SessionCanvas 会话常驻挂载+TerminalScreen 沉浸轨道页+TmuxStage)/HomeView(首页会话卡+主机网格)/RailSidebar(TerminalSidebar两态唯一视图:288pt 全宽排版+railWidth 图标列,收起=左侧裁切)/TerminalChrome(浮起标题/状态胶囊+提示条)/TXParts(设计令牌小件+终端文本镜像)/TmuxController(tmux 网关)/TsnetProbe(M1 自检)/Theme(TXAccent 恒定 blurple + TXChrome 中性阶×4 + TXFlavor 终端配色)/HostStore(主机列表 JSON 持久化)/UIPreviewFixture(DEBUG 无头 UI 验证脚手架)。- UI 已从 Demo 转入商业化改造:设计定稿见
docs/design/UI-DESIGN-HANDOFF.md(Nocturne 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(有意提交,非 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 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-sessions的session_attached反推:服务器上别的客户端 attach 着其它会话时会猜错(真机把用户自己的demo当成了当前会话)。 - 轨道/侧边栏显示
#{window_index}而非 window_id:@2是内部 id,用户看到的编号是 index(0 起,删过 window 后两者会差很远)。 TerminalSession必须转发TmuxController.objectWillChange:tmuxSummary等是读 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 layout(zoom 免费);SwiftUI identity 只用 paneID(布局变不重建 surface)。新 pane 在capture-pane%end 前丢弃其 %output(tmux 单线程串行保证快照不缺不重)。 - tmux 网关字节必须单消费者保序:多个
Task{@MainActor}到主线程顺序无保证,多 pane 高吞吐会打碎 % 协议 → 用TmuxByteChannel(AsyncStream) 单消费者。 - 导航不得销毁会话:终端不能走
fullScreenCover——① surface 一旦从视图树摘除,ghostty 释放 grid,内容全丢;② matchedGeometry(最小化缩回首页卡)要求两端同层级,独立 presentation 层做不到。故SessionCanvas把所有会话 ZStack 常驻挂载、只切 opacity +allowsHitTesting(沿用 tmux 切 tab 已验证的手法),画布恒全屏 → 切路由不触发 PTY resize。 - 侧边栏两态(收起⇄固定展开,悬浮态已被用户裁决砍掉)必须是同一个视图身份(
TerminalSidebar):内容恒按 288pt 全宽排版,收起只是容器宽度收到 railWidth +clipped()("收起=展开在左侧裁切");最左 railWidth 是图标列,两态同尺寸同位置,明细随展开淡入;视图直接参与布局,收起=明细淡出+终端从右盖上来(用户定稿效果)。开合入口只有两个:Logo 位(收起态显 » 展开键,展开态还原 Logo——将来 App Logo 设计与展开按钮一体)与头部 « 收起键。曾经"轨道+面板两套视图 + overlay/pinned 两个面板实例",三态切换视图身份互换 → 动画混乱硬切。 - 终端格子数不是"首次布局后稳定":侧边栏开合就会 resize。读
screenGrid的视图(标题胶囊164×50)靠GridBox.onGridChange → session.objectWillChange转发刷新,否则数字停在旧值(嵌套可观察不冒泡这坑的又一面)。 clipped()只裁绘制不裁触摸:收起态下侧边栏被裁掉的明细区(整行按钮)会伸进终端里抢点击 → 整行按钮用LeadingHitShape(contentShape限制命中区到当前可见宽度,Animatable),纯明细控件再加allowsHitTesting(expanded)。- 状态变更必须包在
TX.Motion的动画事务里:AppRouter的 route/modal/sidebar 全走animated{}。裸改@Published→ 视图上写好的.transition拿不到事务,静默退化成硬切(首页⇄终端、关闭确认、分屏菜单都曾是这么"啪"地跳出来)。收在 router 里点击/⌘快捷键/无头 URL 才是同一手感。阴影同理只走View.txShadow(TX.Elevation.*),别手写.shadow(深色底上大半径高透明度会糊成脏斑)。 - 贴屏幕边缘的容器不许收小圆角:设备屏幕本身是 ~22pt 大圆角,在那儿再画 6pt 小角必被"吃掉"(小角整段落在屏幕圆角之外,切口露出底色三角)。终端 pane 用
UnevenRoundedRectangle只在朝 chrome 的角收圆角,顶边/右边铺满物理边缘、不收角不描边(描边用负 paddingtop:-1/trailing:-1推出屏外)。Stage Manager 小窗同理。 - 沉浸页的安全区由底栏自己让位:整屏
ignoresSafeArea(),底部区高 =TX.Layout.footBand(safeBottom)= 34 + 安全区,内容居中(贴住物理底边又让开 home indicator)。坑:只放开顶边时,颜色型.background会溢进底部安全区、内容却停在上沿 → 屏幕最下面一条 24pt 空带 + 轨道底色铺满,一眼没对齐;遮罩态/Pin 态面板脚还会差 24pt(一钉住就跳)。安全区读数用TXScreenMetrics(GeometryReader必须在ignoresSafeArea之前,否则读到 0)。 .frame(height:)压不住内容驱动的最小高度:它只是给了个框,不声明撑满的子视图按自身理想高度居中放进去 → 一排卡片高矮不齐。要么子视图自己.frame(maxHeight: .infinity),要么(内容天生比框高时,如TXTerminalMirror的行堆叠)把内容放进Color.clear的overlay—— overlay 不参与父视图定尺,尺寸完全由外部决定,溢出交给clipped()。- 首页缩略卡不许再开 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)。
- 与用户始终用简体中文交流。