feat: 业务逻辑对齐 + 首页/弹框七项减法 + 终端页四项体验修复

业务逻辑(用户定稿):
- 点主机总是新建会话(会话:主机=多: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>
This commit is contained in:
kid
2026-07-26 17:49:58 +08:00
parent 8da725114f
commit 4b5292107b
14 changed files with 378 additions and 470 deletions

View File

@@ -88,7 +88,7 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
## 仓库结构
- `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 图标列,收起=左侧裁切列活动会话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 验证脚手架)。
- `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`
@@ -106,8 +106,8 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
- **tmux 当前会话名要用 `display-message -p "#{session_name}"`**,不能拿 `list-sessions``session_attached` 反推:服务器上别的客户端 attach 着其它会话时会猜错(真机把用户自己的 `demo` 当成了当前会话)。
- **轨道/侧边栏显示 `#{window_index}` 而非 window_id**`@2` 是内部 id用户看到的编号是 index0 起,删过 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」。
- **连接流程 = 点主机 → 留在首页探测 → 首页弹选择器 → 选完才进终端**(用户定稿,别改回"自动 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 layoutzoom 免费SwiftUI identity 只用 paneID布局变不重建 surface。新 pane 在 `capture-pane` %end 前丢弃其 %outputtmux 单线程串行保证快照不缺不重)。
- **客户端格子数的权威 = 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) 单消费者。
@@ -116,7 +116,10 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
- **终端格子数不是"首次布局后稳定"**:侧边栏开合就会 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 `top:-1/trailing:-1` 推出屏外)。Stage Manager 小窗同理。
- **贴屏幕边缘的容器不许收小圆角**:设备屏幕本身是 ~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 下 becomeFirstResponderiPadOS 漏了 → 触摸板点终端永远拿不到焦点(已补,改 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 颜色。