业务逻辑(用户定稿): - 点主机总是新建会话(会话:主机=多: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>
132 lines
25 KiB
Markdown
132 lines
25 KiB
Markdown
# 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.com` release 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。
|
||
|
||
## 构建 & 验证命令
|
||
|
||
```sh
|
||
# 纯逻辑单测(无需 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)
|
||
```sh
|
||
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)。**LAN 直连需要「本地网络」权限**:Info.plist 已带 `NSLocalNetworkUsageDescription`(没有它系统可能不弹框直接静默拒绝);重装后首次连 LAN 主机会弹授权框,被拒的表现是 **socket 纯超时(约 12s),别的 app 正常**——去 设置→隐私与安全性→本地网络 打开开关(2026-07-26 真机踩坑)。**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`)。
|
||
```sh
|
||
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` 转回来(内容已横向渲染)。**⚠️ 大坑(2026-07-26):帧缓冲不随窗口变横**——窗口按 1376×1032 排版但帧缓冲只显示**左边 1032pt**,右侧 ~344pt 直接落在屏外:顶带 trailing 的徽标/按钮在截图里"消失",看起来像没渲染(曾为此追了 6 轮假 bug:不是布局/条件/z 序问题,是根本没拍到)。**验证贴右边缘的 UI 必须去掉 `-txLandscape` 用竖屏**(窗口 1032×1376 完整落在帧缓冲内);横屏全宽对拍只能真机/带 GUI 环境做。
|
||
- **`-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`(直接关) / `dismiss` / `session/<n>` / `sidebar/toggle`(`sidebar/pin` 兼容保留=toggle,悬浮态已砍;`split*` 已随「放弃移动端分屏」删除)。比坐标点击稳(不依赖 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`(多会话持有:`open` 总是新建、`activate` 选中+断线自动重连+最小化/关闭语义+生命周期广播)/`AppRouter`(AppRoute .home|.terminal(id) + SidebarState 两态 collapsed|pinned)/`ContentView`(RootView+**SessionCanvas 会话常驻挂载**+TerminalScreen 沉浸轨道页+TmuxStage)/`HomeView`(首页会话卡+主机网格)/`RailSidebar`(`TerminalSidebar` 两态唯一视图:288pt 全宽排版+railWidth 图标列,收起=左侧裁切;列活动会话,tmux window 列表已上移为顶部 Tab)/`TerminalChrome`(`TerminalTopBar` 落地顶带:实心圆✕⌄按钮+弱化标题+`TmuxWindowTabGroup` 无边框 tab 胶囊+右上 mosh/SSH 协议徽标(协议唯一出口,底部状态行不重复);底部只剩状态行=链路+tmux 概览,动作按钮已删;**window/pane 管理交还 tmux 自己**:无新建 window 入口、无 pane 拖拽调宽(连同 `ui/resize` 无头命令、`newWindow`/`resizePane` 一并移除),app 只做切换与被动渲染;分屏更早已整体移除)/`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`/`CMosh` C 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 `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-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` → 选择器给三种连接方式(接回已有会话 / 新建会话 / **原生终端**,不用"跳过"这种模糊措辞)。**点主机总是新建会话**(会话:主机 = 多:1,用户定稿;回到旧会话走侧边栏行/首页会话卡 = `SessionManager.activate`,它对已断开的会话**自动重连**并重新走探测判定 tmux/原生)。会话列表持久显示(无论状态),只有用户显式关闭才移除。`SessionManager.enterRequest` 决定何时进终端;`isProbingTmux` **必须在 `.connected` 当刻置位**,否则连接建立与探测开始(延迟 2.5s)之间的空窗会让 `evaluateEnter` 提前把人送进终端页。**没装 tmux 时不弹框**,直接原生终端(曾有的「安装引导横幅」已按减法裁掉,状态只在底部状态行一句话)。选择器是**项目风格居中弹框**(TXModalScrim,非系统 sheet;点遮罩=取消本次连接)。探测的三个坑:① 命令会被 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 → 静默降级客户端窗口(横幅/安装引导已删,底部状态行写「无 tmux · 客户端窗口」)。曾经漏了这一步 → 真机连上永远显示「无 tmux」。
|
||
- **tmux 多 pane 尺寸走"路线 b"**:app 按全屏格子发 `refresh-client -C WxH` 让 tmux 布局,pane surface 尺寸严格取 layout rect(%output 按 tmux pane 宽高排版,二者不一致必换行/清屏错乱)。diff 用 full layout、渲染用 visible layout(zoom 免费);SwiftUI identity 只用 paneID(布局变不重建 surface)。新 pane 在 `capture-pane` %end 前丢弃其 %output(tmux 单线程串行保证快照不缺不重)。
|
||
- **客户端格子数的权威 = ghostty 实测,绝不是像素除法**(2026-07-26 真机 Claude Code 花屏根因):`容器px ÷ 整数cell px` 复刻不了 ghostty 内部的取整/内边距,实测**偏大 1 行/列**(算得 164×51,真实 163×50)→ 远端按更宽排版、surface 按真实宽度自动换行 → 每条满宽行掉字符,全屏 TUI 重绘几轮积累成行首碎片。修法:① kickoff 用 raw surface 实测格子(`setClientSize(screenGrid)`,不再被像素换算覆盖);② **单 pane window 的 pane 帧==容器帧,其 resize 上报的实测格子就是客户端格子** → `paneViewportChanged` 发 `refresh-client -C`(帧只由容器决定、与 tmux 布局无关,故无反馈环);③ 单 pane 时去掉 `TmuxPaneView` 的 1pt padding 圈(surface 像素帧必须与 raw/容器完全一致);④ 像素除法只留作「全是多 pane window」时的兜底。验证法:`tput cols` 宽的字符标尺应恰好一行不换行(模拟器连本机 sshd 即可复现/回归)。
|
||
- **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` 转发刷新,否则数字停在旧值(嵌套可观察不冒泡这坑的又一面)。**tmux 模式下 `screenGrid` 冻结**(RawTerminalView 已卸载)→ 胶囊改读 `TmuxController.clientGrid`(@Published,经 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 的角收圆角**,顶边/右边铺满物理边缘、不收角。描边**四边完整**(右缘落在物理圆角的平直段上无切角问题)——它现在是焦点高亮环,缺一边读作"没画完";旧的「负 padding 推出屏外」只适用当年的灰色装饰边。Stage Manager 小窗同理。
|
||
- **软键盘让位必须显式且只让一次**(2026-07-26 真机:软键盘一弹终端只剩 6 行):系统隐式 keyboard avoidance 压缩画布一次、`footBand(safeBottom)` 又把键盘高度算进底栏再压一次 = 双重让位。修法:`SessionCanvas .ignoresSafeArea(.keyboard)` 关掉隐式让位,`TerminalScreen` 读 `TXKeyboardObserver.height` 对舞台 `.padding(.bottom)` **一次**(键盘可见时 footBand 的 safeBottom 归 0;悬浮小键盘报 0 不让位)。
|
||
- **终端键盘焦点**:`TerminalSession.focusTick` 脉冲(route 切到本会话时 +1)→ raw / tmux 活动 pane 的 `@FocusState` **先翻 false 再置 true**(下一拍)→ becomeFirstResponder,**选中会话即可直接输入**。坑:`terminalFocusOnAppear` 挂载时就把 FocusState 挂在 true 上(当时视图未进 window、聚焦失败),直接 `focused = true` 是 true→true **不触发 updateUIView**,wrapper 的 `synchronizeFocus` 永远不重试 —— 必须强制翻转;surface 就绪(`markSurfaceReady`/`onReady`)后按 `focusEligible`(route 正指向本会话)再补一次,覆盖「进终端早于 pane 挂载」的扑空。**妙控触摸板点击是 indirectPointer touch**:vendor 的 `UITerminalView.handleIndirectPointerTouches` 原来只在 Catalyst 下 becomeFirstResponder,iPadOS 漏了 → 触摸板点终端永远拿不到焦点(已补,改 vendor/libghostty-spm 源)。
|
||
- **终端焦点高亮画在 pane 容器边框上**(在线转 accent):单 pane 曾自画 accent 描边,但与容器灰边**同位互盖**(左/底被吞、顶边因内容内衬悬空出 10pt 间隔)。单 pane 不再自画描边/圆角,容器是唯一边框;多 pane 保留 pane 间描边。
|
||
- **沉浸页的安全区由底栏自己让位**:整屏 `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 只在用户明确要求时;远端 `origin` 已配置(git@git.njcqit.com:2222/eric/terminalX.git),推送前用 `git status`/`git log origin/main..HEAD` 现查领先状态,不要凭记忆判断。
|
||
- 与用户始终用**简体中文**交流。
|
||
</content>
|