Compare commits

...

3 Commits

Author SHA1 Message Date
kid
5707d70dff feat: 侧边栏两态重构(统一视图身份)+ 第四轮触控化走查
侧边栏(真机验证):
- 砍掉悬浮态,只留收起⇄固定展开;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>
2026-07-26 00:55:53 +08:00
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
kid
6dd23275ff feat: 修复 3 个渲染/tmux 显示 bug + UI 产品化第一波(对标 Moshi)
渲染/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>
2026-07-24 22:43:00 +08:00
49 changed files with 10889 additions and 787 deletions

View File

@@ -42,6 +42,18 @@ xcodebuild -project TerminalX.xcodeproj -scheme TerminalX \
-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)。**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
@@ -58,15 +70,26 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
- **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` — `SSHTerminalModel`(连接编排+tsnet+tmux 路由+mosh 编排)/`ContentView`/`TmuxController`(tmux 网关)/`TsnetProbe`(M1 自检)。
- `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`。
@@ -79,8 +102,24 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
- **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` → 选择器给三种连接方式(接回已有会话 / 新建会话 / **原生终端**,不用"跳过"这种模糊措辞)。`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 的角收圆角**,顶边/右边铺满物理边缘、不收角不描边(描边用负 padding `top:-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)。

View File

@@ -15,6 +15,7 @@
- **M3** mosh 挂起→恢复 <3s(唤醒脉冲 + 会话状态机三相)✅
- **M4** 安全:host key TOFU pin(MITM 防护)+ publickey 认证(SE 优先/软件回退)✅
- **M5** 合规提审 ⏸️ 暂缓
- **UI 产品化(进行中)** 对标 Moshi:深蓝黑 + 终端绿 + Catppuccin;主机管理页/终端 chrome/统一主题切换(app+终端同源,4 套 Catppuccin) ✅;会话缩略卡/设置细化待续。见 `docs/HANDOFF.md §10`。
四大核心需求(libghostty / tailscale / tmux / mosh)均已端到端验证。
@@ -26,8 +27,9 @@ packages/
MoshConnect(解析)、SSH(线格式/ECDSA/host key)。50 单测。
TXTransport SSHSession(libssh2)、MoshSession(mosh_main 桥)、Transport 协议;
C shim: CSSH+CSSHCore(libssh2)、CMosh+MoshCore(mosh)。
apps/TerminalX iOS app(XcodeGen 生成):SSHTerminalModel(编排)/ContentView/
TmuxController(pane-per-surface 网关)/SigningKeyProvider/KeychainKnownHostsStore。
apps/TerminalX iOS app(XcodeGen 生成):SSHTerminalModel(编排)/ContentView(主机管理页+
终端 chrome+设置)/TmuxController(pane-per-surface 网关)/Theme(TXPalette+
主题管理)/HostStore(主机持久化)/SigningKeyProvider/KeychainKnownHostsStore。
vendor/ 非 Swift 依赖本地化(认证代理不支持 SwiftPM,全 vendor):
libghostty-spm、libssh2、mbedtls、tsnet-bridge(Go)、mosh、protobuf。
artifacts/ 预编译 xcframework(CSSHCore/TsnetBridge/MoshCore,**有意提交**,克隆即可构建)。

Binary file not shown.

View File

@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,2 @@
JetBrains Mono 2.304 — SIL Open Font License 1.1(见 OFL.txt)。
仅打包 400/500/600/700 四权重(设计交付所需),来源 github.com/JetBrains/JetBrainsMono releases。

View File

@@ -2,8 +2,16 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 部分 Info.plist 基底:仅声明 URL scheme(供无头验证前台唤醒 terminalx://resume)。
<!-- 部分 Info.plist 基底:URL scheme(供无头验证前台唤醒 terminalx://resume)+ 打包字体。
其余键由 GENERATE_INFOPLIST_FILE=YES 自动生成并合并到本基底之上。 -->
<!-- JetBrains Mono:chrome 与终端同源字体(设计交付要求)。中文自动回退 PingFang SC。 -->
<key>UIAppFonts</key>
<array>
<string>JetBrainsMono-Regular.ttf</string>
<string>JetBrainsMono-Medium.ttf</string>
<string>JetBrainsMono-SemiBold.ttf</string>
<string>JetBrainsMono-Bold.ttf</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>

View File

@@ -0,0 +1,82 @@
import SwiftUI
/// 顶层去处。设计定稿只有两处落点:首页(主机管理)与终端。
///
/// 关键:路由与**会话生命周期解耦** —— 回首页/最小化只改 route,会话照跑
/// (会话 surface 由 `SessionCanvas` 常驻挂载,见 ContentView)。
enum AppRoute: Equatable {
case home
case terminal(UUID)
var sessionID: UUID? {
if case .terminal(let id) = self { return id }
return nil
}
}
/// 侧边栏两态(用户裁决砍掉悬浮态:展开即固定)。同一条栏,元素不增不减,只是展示密度不同。
enum SidebarState: Equatable {
/// 56pt 轨道:只显图标与两字缩写
case collapsed
/// 288pt 固定在左侧:终端变窄。开合各触发一次 resize。
case pinned
var isExpanded: Bool { self == .pinned }
/// 该状态下侧边栏占据的布局宽度。
var layoutWidth: CGFloat {
switch self {
case .collapsed: TX.Layout.railWidth
case .pinned: TX.Layout.sidebarWidth
}
}
}
/// 终端页上的模态浮层。放在 router 而不是视图 `@State`:⌘W/⌘D 快捷键与无头 URL 驱动
/// 都要能打开它,视图内部状态触达不到。
enum AppModal: Equatable {
/// 关闭确认(3c)
case closeConfirm
/// 分屏菜单(3b)。`vertical` = 竖分(⌘D)/ 横分(⌘⇧D)
case splitMenu(vertical: Bool)
}
/// 导航状态持有者。视图只读它、只经方法改它。
@MainActor
final class AppRouter: ObservableObject {
@Published var route: AppRoute = .home
@Published var sidebar: SidebarState = .collapsed
/// 当前模态浮层(关闭确认 / 分屏菜单)。
@Published var modal: AppModal?
/// 状态切换动效统一走 `TX.Motion.standard`(设计定稿 200–260ms easeOut,响应「减弱动态」)。
///
/// **路由 / 侧边栏 / 浮层的每一次变更都必须包在这条曲线里**,否则视图上的 `transition`
/// 拿不到动画事务就退化成硬切 —— 首页 ⇄ 终端、关闭确认、分屏菜单曾都是这么"啪"地跳出来的。
/// 收在 router 里的另一个好处:点击、⌘ 快捷键、无头 URL 三条入口天然同一手感。
private func animated(_ change: () -> Void) {
withAnimation(TX.Motion.standard, change)
}
/// 进入某会话(点会话卡 / 侧边栏会话行 / 新建连接后)。恢复焦点由会话自身持有。
func enter(_ sessionID: UUID) {
animated { route = .terminal(sessionID) }
}
/// 回首页:会话完全不动(⌘⇧H / 侧边栏首页行)。展开态跨导航保留。
func goHome() {
animated {
modal = nil
route = .home
}
}
/// 开合侧边栏(⌘\ / Logo 位展开键 / 头部收起键)。收起 ⇄ 固定展开。
func toggleSidebar() {
animated { sidebar = sidebar == .collapsed ? .pinned : .collapsed }
}
/// 打开/关闭模态浮层(红点、⌘W、⌘D、⌘⇧D 与无头 URL 共用同一入口)。
func present(_ modal: AppModal) { animated { self.modal = modal } }
func dismissModal() { animated { modal = nil } }
}

View File

@@ -1,119 +1,428 @@
import SwiftUI
import UIKit
import GhosttyTerminal
import TXCore
struct ContentView: View {
@StateObject private var model = SSHTerminalModel()
@StateObject private var manager = SessionManager()
@StateObject private var router = AppRouter()
@StateObject private var hostStore = HostStore()
@StateObject private var credStore = FileCredentialStore()
@StateObject private var tailscaleStore = TailscaleStore()
@StateObject private var theme = TXThemeManager()
@Environment(\.scenePhase) private var scenePhase
var body: some View {
// M1 纯自检分支:仅 -txTsnetKey(无 -txHost)时跑 tsnet 加入+对端枚举自检。
// 若同时有 -txHost,则走正常终端流程(model 经 tsnet fd 桥连接)。
if let tsnetKey = UserDefaults.standard.string(forKey: "txTsnetKey"), !tsnetKey.isEmpty,
(UserDefaults.standard.string(forKey: "txHost") ?? "").isEmpty {
TsnetProbeView(authKey: tsnetKey)
} else {
Group {
if model.showsTerminal {
TerminalScreen(model: model)
} else {
ConnectionForm(model: model)
}
}
.onAppear { model.autoConnectIfConfigured() }
.onChange(of: scenePhase) { _, phase in
switch phase {
case .background: model.enterBackground()
case .active: model.enterForeground()
default: break
}
}
.alert("⚠️ Host Key 已变化", isPresented: Binding(
get: { model.hostKeyMismatch != nil },
set: { if !$0 { model.dismissHostKeyMismatch() } }
), presenting: model.hostKeyMismatch) { _ in
Button("信任新密钥", role: .destructive) { model.trustNewHostKey() }
Button("取消", role: .cancel) { model.dismissHostKeyMismatch() }
} message: { info in
Text("\(info.egress)/\(info.host):\(info.port) 的 host key 与已记住的不同,可能是中间人攻击。\n\n已记住:\(info.storedFp)\n本次:\(info.presentedFp)")
Group {
// M1 纯自检分支:仅 -txTsnetKey(无 -txHost)时跑 tsnet 加入+对端枚举自检。
if let tsnetKey = UserDefaults.standard.string(forKey: "txTsnetKey"), !tsnetKey.isEmpty,
(UserDefaults.standard.string(forKey: "txHost") ?? "").isEmpty {
TsnetProbeView(authKey: tsnetKey)
} else {
RootView(manager: manager, router: router, hostStore: hostStore,
credStore: credStore, tailscaleStore: tailscaleStore)
}
}
.environmentObject(theme)
.preferredColorScheme(theme.palette.isDark ? .dark : .light)
.onChange(of: theme.palette.id) { _, _ in
// 主题切换:app 视图经 palette 响应式更新;这里把终端配色同步推给所有会话的 surface。
manager.applyTerminalTheme(theme.palette.terminalTheme)
}
.onAppear {
manager.bind(credentials: credStore, tailscale: tailscaleStore)
#if DEBUG
UIPreviewFixture.forceLandscapeIfRequested()
// 无头 UI 验证:造假会话+假主机供截图对拍(不建连接、不写盘),与 -txHost 真实连接互斥。
if UIPreviewFixture.isEnabled {
hostStore.injectFixtureHosts(UIPreviewFixture.hosts)
tailscaleStore.injectFixtureConnections(UIPreviewFixture.tailnets)
if let s = manager.spawnFixtureSessions() { router.enter(s.id) }
return
}
#endif
#if DEBUG
// 无头验证**真实的「首页点主机」路径**(`-txOpenAsHost 1`):把启动参数造成一台主机塞进
// 内存主机表,再走 manager.open(host:) —— 即探测 tmux → 弹选择器 → 选完才进终端。
// 与 `-txHost` 的老路径(autoConnectIfConfigured:直连 + 立刻进终端)互斥。
if UserDefaults.standard.bool(forKey: "txOpenAsHost"),
let h = UIPreviewFixture.hostFromLaunchArgs() {
hostStore.injectFixtureHosts([h])
manager.open(host: h)
return
}
#endif
// 无头验证:启动参数带 -txHost 时建会话并直接进终端(老路径,保留给回归脚本)。
if let s = manager.autoConnectIfConfigured() { router.enter(s.id) }
}
.onChange(of: scenePhase) { _, phase in
switch phase {
case .background: manager.enterBackground()
case .active: manager.enterForeground()
default: break
}
}
#if DEBUG
// 无头验证:terminalx://ui/<动作> 驱动导航(见 UIPreviewFixture.handle)。
// 非 ui/ 前缀的 URL(如 terminalx://resume)只用于把 app 拉回前台,无需处理。
.onOpenURL { url in
guard url.host == "ui" else { return }
let parts = url.pathComponents.filter { $0 != "/" }
_ = UIPreviewFixture.handle(uiCommand: parts, manager: manager, router: router)
}
#endif
}
}
/// M0 连接表单:SSH 直连(不过 tsnet)。真实运维即输入自己的服务器。
struct ConnectionForm: View {
@ObservedObject var model: SSHTerminalModel
@State private var host = ""
@State private var port = "22"
@State private var username = ""
@State private var password = ""
/// 顶层壳:会话画布常驻在底,首页盖在其上。
///
/// **为什么不用 fullScreenCover**:会话必须在离开终端后继续跑并保留内容,且「最小化缩回首页卡片」
/// 需要 matchedGeometry —— 那要求两端在同一视图层级,独立 presentation 层做不到。
struct RootView: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var hostStore: HostStore
@ObservedObject var credStore: FileCredentialStore
@ObservedObject var tailscaleStore: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
NavigationStack {
Form {
Section("SSH 直连 (M0)") {
TextField("主机 / IP", text: $host)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
TextField("端口", text: $port)
.keyboardType(.numberPad)
TextField("用户名", text: $username)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
SecureField("密码", text: $password)
}
Section {
Button {
model.connect(host: host, port: Int(port) ?? 22,
username: username, password: password)
} label: {
Text("连接").frame(maxWidth: .infinity)
}
.disabled(host.isEmpty || username.isEmpty)
}
Section("状态") {
Text(model.phaseText)
.foregroundStyle(model.phaseText.hasPrefix("失败") ? .red : .secondary)
if !model.banner.isEmpty {
Text(model.banner).font(.caption).foregroundStyle(.secondary)
}
}
ZStack {
SessionCanvas(manager: manager, router: router,
hostStore: hostStore, credStore: credStore, tailscaleStore: tailscaleStore)
if router.route == .home {
HomeView(manager: manager, router: router, hostStore: hostStore,
credStore: credStore, tailscaleStore: tailscaleStore)
// 首页略微推远后淡出,与终端从 97% 放大到位对偶 —— 读作"推进到会话里"。
.transition(TX.Motion.pushBack)
}
.navigationTitle("terminalX")
}
.background(p.bgDeep.ignoresSafeArea())
// 沉浸只在终端页:设计稿 1b/3a 顶部就是胶囊/logo(无状态栏),1e 首页与 1g 设置有状态栏。
.statusBarHidden(router.route != .home)
// 连接流程的一部分:探测出 tmux 会话后在**首页上**弹选择器,选完才进终端。
// 挂在 RootView 而不是终端页 —— 用户点主机时还没进终端。
.sheet(isPresented: Binding(
get: { manager.sessionAwaitingChoice != nil },
set: { if !$0 { manager.sessionAwaitingChoice?.useNativeTerminal() } }
)) {
if let s = manager.sessionAwaitingChoice, let choice = s.pendingSessionChoice {
SessionPickerSheet(
host: s.displayName,
choice: choice,
onAttach: { s.attachTmuxSession($0) },
onCreate: { s.createTmuxSession($0) },
onNative: { s.useNativeTerminal() })
.environmentObject(theme)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
}
}
// 会话就绪(选择完成 / 无 tmux / 失败)→ 进终端页。
.onChange(of: manager.enterRequest) { _, id in
guard let id else { return }
manager.enterRequest = nil
router.enter(id)
}
// 兜底:路由指向的会话已不存在(被关闭/清理)→ 回首页,而不是停在空画布上黑屏。
.onChange(of: manager.sessions.count) { _, _ in
if let id = router.route.sessionID, manager.session(id) == nil { router.goHome() }
}
}
}
/// 所有会话的终端视图**常驻挂载**,只有当前路由指向的那条可见。
///
/// 这是"回首页/切会话内容不丢"的根:GhosttyKit 的 surface 一旦从视图树摘除就释放 grid,
/// 故沿用 tmux 切 tab 已验证的手法——ZStack 全挂载 + opacity 切换 + 关掉非活动者的命中测试。
/// 画布尺寸恒为全屏,故切换路由**不会触发 PTY resize**。
struct SessionCanvas: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var hostStore: HostStore
@ObservedObject var credStore: FileCredentialStore
@ObservedObject var tailscaleStore: TailscaleStore
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ZStack {
ForEach(manager.sessions) { session in
let visible = router.route.sessionID == session.id
TerminalScreen(session: session, manager: manager, router: router,
tailscaleStore: tailscaleStore)
.opacity(visible ? 1 : 0)
// 离开终端时轻微缩小淡出,观感上"缩回首页卡片"。
// `scaleEffect` 只是渲染变换、不改 frame → **不触发 PTY resize**,
// 故不能用 frame 动画替代(那会让 tmux 反复重排)。
.scaleEffect(visible || reduceMotion ? 1 : 0.97)
.animation(TX.Motion.standard, value: visible)
.allowsHitTesting(visible)
}
}
}
}
/// 终端主界面 · 沉浸轨道(设计稿 `1b` + `3a`,用户裁决后侧边栏只剩收起/固定两态)。
///
/// 布局:左侧 `TerminalSidebar`(56 ⇄ 288,直接参与布局)+ 右舞台。舞台顶部留 86pt
/// 预留带给浮起胶囊,底部一条提示条。开合就是侧边栏的宽度动画:收起时明细淡出、
/// 终端从右侧盖上来;展开反向。开合各触发一次终端 resize。
///
/// **安全区**:整屏 chrome 铺到**物理**边缘(`ignoresSafeArea()`),安全区由两处底栏
/// (提示条 / 侧边栏脚)自己让出 —— 见 `TX.Layout.footBand`,两侧分割线落在同一条 y 上。
struct TerminalScreen: View {
@ObservedObject var model: SSHTerminalModel
@ObservedObject var session: TerminalSession
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var tailscaleStore: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@Environment(\.horizontalSizeClass) private var hSize
@State private var sidebarSearch = ""
/// 「安装并启用 tmux」引导弹层。
@State private var showTmuxInstall = false
private var p: TXPalette { theme.palette }
var body: some View {
if let tmux = model.tmuxController {
TmuxTabbedView(controller: tmux) // tmux -CC → 原生 tab
} else {
RawTerminalView(model: model) // 普通单终端
// 先量安全区,再铺满物理屏(顺序反了就量不到,见 TXScreenMetrics)。
TXScreenMetrics { geo in
screen(safeTop: geo.safeAreaInsets.top, safeBottom: geo.safeAreaInsets.bottom)
.ignoresSafeArea()
}
}
private func screen(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
HStack(spacing: 0) {
// 两态共用**同一个**视图实例(见 TerminalSidebar 的动画模型):开合就是它自己的
// 宽度动画(56 ⇄ 288),直接参与布局 —— 内容淡出的同时终端从右侧盖上来(收起),
// 反之展开。开合各触发一次终端 resize。
TerminalSidebar(manager: manager, router: router, session: session,
tailscaleStore: tailscaleStore,
railWidth: railWidth, safeBottom: safeBottom,
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost,
search: $sidebarSearch)
stage(safeTop: safeTop, safeBottom: safeBottom)
}
.background(p.bgDeep)
// 关闭确认(3c)与分屏菜单(3b):自绘浮层,不用系统 alert/sheet —— 设计给的是精确尺寸的卡片。
.overlay {
switch router.modal {
case .closeConfirm:
CloseConfirmDialog(
session: session,
onDetach: { router.dismissModal(); closeSession(kill: false) },
onKill: { router.dismissModal(); closeSession(kill: true) },
onCancel: { router.dismissModal() })
case .splitMenu(let vertical):
SplitMenuView(session: session, vertical: vertical,
onPick: { target in
router.dismissModal()
performSplit(target, vertical: vertical)
},
onCancel: { router.dismissModal() })
case nil:
EmptyView()
}
if showTmuxInstall {
TmuxInstallSheet(
onRun: { cmd in
withAnimation(TX.Motion.standard) { showTmuxInstall = false }
session.runInTerminal(cmd)
},
onCancel: { withAnimation(TX.Motion.standard) { showTmuxInstall = false } })
}
}
// host key 告警挂在会话上(多会话各自一份):安全告警即使会话不在前台也应弹出。
.alert("⚠️ Host Key 已变化", isPresented: Binding(
get: { session.hostKeyMismatch != nil },
set: { if !$0 { session.dismissHostKeyMismatch() } }
), presenting: session.hostKeyMismatch) { _ in
Button("信任新密钥", role: .destructive) { session.trustNewHostKey() }
Button("取消", role: .cancel) { session.dismissHostKeyMismatch() }
} message: { info in
Text("\(info.egress)/\(info.host):\(info.port) 的 host key 与已记住的不同,可能是中间人攻击。\n\n已记住:\(info.storedFp)\n本次:\(info.presentedFp)")
}
}
/// 折叠轨道宽(竖屏/窄窗口 48)。占位列宽度必须与侧边栏收起宽一致,否则多出一条底色缝。
private var railWidth: CGFloat {
hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth
}
/// 轨道 + 号 / 设置齿轮:首版回首页(那里有「新建连接」与设置入口)。
/// 设计的「就地弹主机选择菜单」属 UI-4 的分屏/新建菜单一族,一起做。
private func goHomeToAddHost() {
router.goHome()
}
// MARK: - 舞台
private func stage(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
ZStack(alignment: .top) {
VStack(spacing: 0) {
// 无 tmux 时的行内引导横幅(设计 2c):占一行,把 pane 往下推,不遮内容。
if session.tmuxUnavailable {
NoTmuxBanner(onInstall: { withAnimation(TX.Motion.standard) { showTmuxInstall = true } },
onDismiss: {
withAnimation(TX.Motion.standard) { session.dismissTmuxBanner() }
})
.padding(.top, TX.Layout.capsuleInset)
.transition(.opacity)
}
// 沉浸:pane 容器**从舞台顶部就开始**(底色 + 边框铺满),胶囊悬浮其上;
// 只把 surface 内容下移 86pt 让出预留带 —— 预留带铺的是终端底色,故视觉连续。
paneArea
// 横幅已占掉预留带时不再重复留白(设计 2c:多一条横幅时预留 122pt)。
.padding(.top, session.tmuxUnavailable ? TX.Space.hairline : TX.Layout.capsuleInset)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(p.terminalBackground)
.clipShape(paneShape)
.overlay(
paneShape.strokeBorder(p.border, lineWidth: 1)
// 顶边/右边与屏幕边缘齐平,那两条描边推出屏外:留着就是贴在屏幕
// 边上的一圈多余框线,还会在屏幕圆角处被切出斜口。
.padding(EdgeInsets(top: -1, leading: 0, bottom: 0, trailing: -1))
)
// 只在**朝轨道那一侧**留 2pt 缝;顶边/右边直接铺到屏幕物理边缘(沉浸)。
// 底边不留缝:pane 底边即与侧边栏脚同高的那条分割线。
.padding(.leading, TX.Layout.paneGap)
TerminalActionBar(context: barContext, actions: barActions, safeBottom: safeBottom)
}
capsules(safeTop: safeTop)
}
.background(p.bgDeep)
}
/// pane 容器的形状:**只在朝 app chrome 的那几个角收圆角**。
///
/// 终端右上角贴着屏幕物理边缘,而 iPad/iPhone 的屏幕本身是大圆角 —— 在那儿再画一个 6pt 小圆角,
/// 两种曲率必然打架:小圆角整段落在屏幕圆角之外,被"吃掉",只在切口处露出一块底色三角。
/// 故顶边/右边**不收角、不描边**,由屏幕自己的圆角收口;左边(挨轨道)、底边(挨提示条)
/// 才是真正需要圆角把 pane 和 chrome 分开的地方。Stage Manager 的小窗同理(窗圆角接管)。
private var paneShape: UnevenRoundedRectangle {
UnevenRoundedRectangle(
topLeadingRadius: TX.Radius.pane,
bottomLeadingRadius: TX.Radius.pane,
bottomTrailingRadius: TX.Radius.pane,
topTrailingRadius: 0)
}
@ViewBuilder private var paneArea: some View {
ZStack {
if let tmux = session.tmuxController {
TmuxStage(controller: tmux)
} else {
RawTerminalView(model: session)
}
// 连接中 / 失败 / 已关闭时给出解释与出路(已连接时此层不渲染,不遮挡终端)。
TerminalStateOverlay(session: session,
onRetry: { session.retry() },
onBack: { router.goHome() },
onCancel: {
let id = session.id
router.goHome()
manager.close(id)
})
// 连接中 → 已连接(或失败)时这层淡出,不硬切。
.animation(TX.Motion.standard, value: session.phaseText)
}
}
private func capsules(safeTop: CGFloat) -> some View {
HStack(alignment: .top) {
TerminalTitleCapsule(session: session, onClose: requestClose, onMinimize: minimizeSession)
Spacer(minLength: TX.Space.m)
TerminalStatusCapsule(session: session)
}
.padding(.horizontal, TX.Space.m)
// iPad 隐藏状态栏后顶边无遮挡 → 设计值 14;灵动岛/刘海机型按硬件安全区下移。
.padding(.top, TX.Layout.capsuleTop(safeTop))
}
/// 红点:**必须**先确认(设计要求)。确认后的动作分两套,见 `closeSession(kill:)`。
private func requestClose() {
router.present(.closeConfirm)
}
/// 执行关闭。tmux 会话默认只 `detach-client`(服务端继续跑);`kill=true` 才 `kill-session`。
/// 原生窗口没有服务端兜底,直接 teardown。
private func closeSession(kill: Bool) {
let id = session.id
if let tmux = session.tmuxController {
if kill { tmux.killSession() } else { tmux.detach() }
}
router.goHome() // 动画在 router 里(TX.Motion.standard)
// 给 detach/kill 命令一点时间落地再拆传输层(tmux 会回 %exit 走既有 onExit 收尾)。
Task { @MainActor in
try? await Task.sleep(nanoseconds: 250_000_000)
manager.close(id)
}
}
private func minimizeSession() {
session.captureSnapshot() // 缩回首页前抓一次镜像,卡片立刻有内容
manager.minimize(session.id)
router.goHome()
}
/// 分屏菜单选择后立即分屏(设计:选完不再问方向)。
private func performSplit(_ target: SplitMenuView.SplitTarget, vertical: Bool) {
guard let tmux = session.tmuxController else { return }
switch target {
case .currentSession:
tmux.splitWindow(vertical: vertical)
case .otherSession(let name):
tmux.splitWindow(vertical: vertical, joinSession: name)
case .newSession:
// 名字用会话数递增,避免与既有会话撞名(tmux 撞名会报错)。
tmux.splitIntoNewSession(vertical: vertical, name: "tx\(tmux.sessions.count + 1)")
case .nativeWindow:
break // 首版不可用(菜单里已灰显),见 HANDOFF §12.4
}
}
/// ⌘D / ⌘⇧D 的入口:先拉一次 list-sessions 再弹菜单(菜单要列 detached 会话)。
private func requestSplit(vertical: Bool) {
session.tmuxController?.listSessions()
router.present(.splitMenu(vertical: vertical))
}
/// 动作条左侧那句话:说**当前会话**的状态(此前写死「已连接妙控键盘 · 附加行已收起」——
/// 没接键盘时那是假话;侧边栏三态的说明属于实现细节,用户不需要)。
private var barContext: String {
if !session.isLive { return session.phaseText }
if let t = session.tmuxSummary {
let name = t.name.isEmpty ? "tmux" : "tmux \(t.name)"
return "\(session.displayName) · \(name) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
}
if session.tmuxSkipped { return "\(session.displayName) · 原生窗口" }
return session.displayName
}
/// 底部动作(**触控可点**,不是快捷键提示)。tmux 相关的两个在原生窗口下灰显。
private var barActions: [TerminalActionBar.Action] {
let tmux = session.tmuxController
return [
.init(id: "split", icon: "square.split.2x1", title: "竖分屏", enabled: tmux != nil) {
requestSplit(vertical: true)
},
.init(id: "window", icon: "plus.rectangle.on.rectangle", title: "新建 window",
enabled: tmux != nil) {
tmux?.newWindow()
},
.init(id: "home", icon: "house", title: "回首页") { router.goHome() },
]
}
}
/// 普通(非 tmux)单终端视图。
struct RawTerminalView: View {
@ObservedObject var model: SSHTerminalModel
@ObservedObject var model: TerminalSession
@FocusState private var focused: Bool
var body: some View {
TerminalSurfaceView(context: model.state)
.terminalFocusOnAppear($focused)
.overlay(alignment: .top) {
if model.phaseText != "已连接", !model.banner.isEmpty {
Text(model.banner)
.font(.caption)
.padding(.horizontal, 10).padding(.vertical, 6)
.background(.thinMaterial, in: Capsule())
.padding(.top, 6)
}
}
.task {
for _ in 0 ..< 300 where model.state.surfaceSize == nil {
try? await Task.sleep(nanoseconds: 30_000_000)
@@ -123,60 +432,55 @@ struct RawTerminalView: View {
}
}
/// tmux control-mode:window 映射为原生 tab 条 + 活动 window 终端。
struct TmuxTabbedView: View {
/// tmux control-mode 舞台:所有 window 常驻挂载、只显活动 window。
/// window 切换已上移到轨道数字 / 侧边栏 window 行(设计稿去掉了 tab 条)。
struct TmuxStage: View {
@ObservedObject var controller: TmuxController
@EnvironmentObject var theme: TXThemeManager
@Environment(\.displayScale) private var displayScale
private var p: TXPalette { theme.palette }
var body: some View {
VStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
Group {
if controller.windows.isEmpty {
VStack {
Spacer()
Text("正在枚举 tmux window…")
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
Spacer()
}
} else {
// 所有窗口常驻挂载、仅激活窗口可见:切 window 不销毁其它窗口的 pane 视图,
// 故 ghostty surface 不被释放、grid 内容保留(否则切回旧窗口会空白),
// 且后台窗口持续接收 %output 保持实时。非激活窗口关掉命中测试,避免吞触摸。
ZStack {
ForEach(controller.windows) { win in
TmuxTabChip(window: win,
isActive: win.id == controller.activeWindowID) {
controller.selectWindow(win.id)
}
}
Button { controller.newWindow() } label: {
Image(systemName: "plus").padding(.horizontal, 6)
TmuxWindowView(window: win, controller: controller)
.opacity(win.id == controller.activeWindowID ? 1 : 0)
.allowsHitTesting(win.id == controller.activeWindowID)
}
}
.padding(.horizontal, 8).padding(.vertical, 6)
}
.background(.thinMaterial)
Divider()
if let win = controller.activeWindow {
TmuxWindowView(window: win, controller: controller)
.id(win.id.raw)
} else {
Spacer(); Text("无 tmux 窗口").foregroundStyle(.secondary); Spacer()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// 容器几何测量放在这一层(windows 为空时同样占满剩余空间、尺寸相同):
// pane grid 由容器像素决定,须在 attach 前拿到真实容器格子;放 TmuxWindowView 会与
// "windows 靠 list-windows 才有" 形成鸡生蛋死锁(见 TmuxController.kickoffIfReady)。
.background(GeometryReader { geo in
Color.clear
.onAppear { controller.containerResized(widthPt: geo.size.width, heightPt: geo.size.height, scale: displayScale) }
.onChange(of: geo.size) { _, s in
controller.containerResized(widthPt: s.width, heightPt: s.height, scale: displayScale)
}
})
}
}
struct TmuxTabChip: View {
@ObservedObject var window: TmuxWindow
let isActive: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
Text(window.title)
.font(.caption).lineLimit(1)
.padding(.horizontal, 12).padding(.vertical, 6)
.background(isActive ? Color.accentColor.opacity(0.25) : Color.gray.opacity(0.12),
in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
/// 一个 window:按 tmux 可见布局树把多 pane 绝对定位为分屏(每 pane 一 surface)。
/// 一个 window:按 tmux 可见布局树把多 pane 绝对定位为分屏(每 pane 一 surface)+ pane 间拖拽条。
struct TmuxWindowView: View {
@ObservedObject var window: TmuxWindow
let controller: TmuxController
@Environment(\.displayScale) private var displayScale
var body: some View {
GeometryReader { geo in
@@ -200,16 +504,16 @@ struct TmuxWindowView: View {
y: (CGFloat(item.rect.y) + CGFloat(item.rect.height) / 2) * ch)
}
}
// pane 之间的拖拽条(在 pane 之上,热区 12pt)。
ForEach(splitters(layout.root), id: \.id) { s in
PaneSplitter(splitter: s, cellW: cw, cellH: ch, controller: controller)
}
}
} else {
Color.clear
}
}
// iPadOS 可自由拖拽窗口尺寸 → 容器变化即重算总格子并 refresh-client -C(controller 内 debounce)。
.onAppear { controller.containerResized(widthPt: geo.size.width, heightPt: geo.size.height, scale: displayScale) }
.onChange(of: geo.size) { _, s in
controller.containerResized(widthPt: s.width, heightPt: s.height, scale: displayScale)
}
// 容器尺寸测量已上移到 TmuxStage(单一来源),此处不再驱动 containerResized。
}
}
@@ -220,22 +524,147 @@ struct TmuxWindowView: View {
case .horizontal(let cs, _), .vertical(let cs, _): return cs.flatMap(leaves)
}
}
/// 收集所有相邻子节点之间的分隔位置。tmux 的 `resize-pane -x/-y` 作用于**包含该 pane 的那层分割**,
/// 故 resize 目标取"前一个子节点里的任一 pane"即可。
private func splitters(_ node: TmuxLayout.Node) -> [SplitterSpec] {
var out: [SplitterSpec] = []
func walk(_ n: TmuxLayout.Node) {
switch n {
case .leaf:
break
case .horizontal(let children, _):
// 左右并排 → 竖直分隔条,拖动改前一个子节点的**宽度**。
for (i, child) in children.enumerated() where i < children.count - 1 {
let r = child.rect
if let pane = firstPane(child) {
out.append(SplitterSpec(
id: "h-\(pane.raw)-\(i)", isVertical: true, pane: pane,
x: r.x + r.width, y: r.y, length: r.height, currentSize: r.width))
}
}
children.forEach(walk)
case .vertical(let children, _):
// 上下堆叠 → 水平分隔条,拖动改前一个子节点的**高度**。
for (i, child) in children.enumerated() where i < children.count - 1 {
let r = child.rect
if let pane = firstPane(child) {
out.append(SplitterSpec(
id: "v-\(pane.raw)-\(i)", isVertical: false, pane: pane,
x: r.x, y: r.y + r.height, length: r.width, currentSize: r.height))
}
}
children.forEach(walk)
}
}
walk(node)
return out
}
private func firstPane(_ node: TmuxLayout.Node) -> TmuxPaneID? {
switch node {
case .leaf(let p, _): return p
case .horizontal(let cs, _), .vertical(let cs, _): return cs.compactMap(firstPane).first
}
}
}
/// 单个 pane 的终端面 + 焦点边框 + 点击选中。
/// 一条分隔位置(格子坐标)。
struct SplitterSpec {
let id: String
/// true = 竖直条(左右分,拖动改宽);false = 水平条(上下分,拖动改高)
let isVertical: Bool
/// resize 目标 pane(前一个子节点里的任一 pane)
let pane: TmuxPaneID
let x: Int, y: Int
/// 条的长度(格子)
let length: Int
/// 前一个子节点当前的 cols / rows
let currentSize: Int
}
/// pane 拖拽条:热区 12pt、把手 44×3pt。
///
/// **拖动中只动视觉**(把手位移 + 高亮线),不改 pane frame、不发命令;**松手才发一次 `resize-pane`**,
/// 然后等 tmux 回 `%layout-change` 更新真实 frame —— 与"路线 b"一致(客户端不自行重排)。
struct PaneSplitter: View {
let splitter: SplitterSpec
let cellW: CGFloat
let cellH: CGFloat
let controller: TmuxController
@State private var drag: CGFloat = 0
@State private var dragging = false
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var hit: CGFloat { TX.Layout.paneDragHit }
var body: some View {
Group {
if splitter.isVertical {
bar(width: hit, height: CGFloat(splitter.length) * cellH)
.position(x: CGFloat(splitter.x) * cellW + drag,
y: (CGFloat(splitter.y) + CGFloat(splitter.length) / 2) * cellH)
} else {
bar(width: CGFloat(splitter.length) * cellW, height: hit)
.position(x: (CGFloat(splitter.x) + CGFloat(splitter.length) / 2) * cellW,
y: CGFloat(splitter.y) * cellH + drag)
}
}
.gesture(
DragGesture(minimumDistance: 2)
.onChanged { v in
dragging = true
drag = splitter.isVertical ? v.translation.width : v.translation.height
}
.onEnded { _ in
// 位移 → 格子增量;松手发一次命令,视觉位移立刻归零(等 tmux 回布局)。
let cell = splitter.isVertical ? cellW : cellH
let delta = cell > 0 ? Int((drag / cell).rounded()) : 0
let newSize = max(1, splitter.currentSize + delta)
if delta != 0 {
controller.resizePane(splitter.pane,
cols: splitter.isVertical ? newSize : nil,
rows: splitter.isVertical ? nil : newSize)
}
drag = 0
dragging = false
}
)
}
/// 热区透明 + 居中 44×3pt 把手(拖动中把手转强调色)。
private func bar(width: CGFloat, height: CGFloat) -> some View {
ZStack {
Color.clear.frame(width: width, height: height)
RoundedRectangle(cornerRadius: 2)
.fill(dragging ? TXAccent.base : p.borderStrong)
.frame(width: splitter.isVertical ? 3 : 44,
height: splitter.isVertical ? 44 : 3)
.opacity(dragging ? 1 : 0.55)
}
.contentShape(Rectangle())
}
}
/// 单个 pane 的终端面 + 焦点边框 + 点击选中。pane 之间留 2pt 缝(设计:舞台底色透出为缝)。
struct TmuxPaneView: View {
@ObservedObject var surface: TmuxPaneSurface
let isActive: Bool
let onTap: () -> Void
let onReady: () -> Void
@FocusState private var focused: Bool
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
TerminalSurfaceView(context: surface.state)
.terminalFocusOnAppear($focused)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
Rectangle().stroke(isActive ? Color.accentColor : Color.gray.opacity(0.35),
lineWidth: isActive ? 2 : 0.5)
RoundedRectangle(cornerRadius: 6)
.strokeBorder(isActive ? TXAccent.border : p.divider,
lineWidth: isActive ? 1 : 0.5)
)
// 非活动 pane 叠透明 tap-catcher(TerminalSurfaceView 会吞触摸);活动 pane 直通终端。
.overlay {
@@ -243,6 +672,7 @@ struct TmuxPaneView: View {
Color.black.opacity(0.04).contentShape(Rectangle()).onTapGesture { onTap() }
}
}
.padding(TX.Layout.paneGap / 2) // 相邻 pane 各让 1pt → 缝 2pt
.onChange(of: isActive) { _, active in focused = active }
.task {
for _ in 0 ..< 300 where surface.state.surfaceSize == nil {

View File

@@ -0,0 +1,92 @@
import Foundation
/// 一条 Keychain 凭据(元数据部分,**不含机密**)。
/// 机密(密码/私钥材料/passphrase)经 `CredentialStore.secret(for:)` 按需单条取,
/// 列表 UI 只碰这里的 meta,永不为渲染而加载机密(对齐签名后 Keychain 后端的天然分层)。
struct Credential: Identifiable, Codable, Equatable {
var id: UUID = UUID()
var name: String
var username: String
var kind: Kind
var createdAt: Date = Date()
enum Kind: Codable, Equatable {
/// 用户名 + 密码。
case password
/// SSH 私钥。`publicKeyBlob` 冗余存 SSH 线格式公钥,供列表展示指纹/复制 authorized_keys 而不碰机密。
case privateKey(keyType: KeyType, source: Source, hasPassphrase: Bool, publicKeyBlob: Data)
/// Secure Enclave(Face ID)密钥——机密不出 SE,只存 tag 引用。未签名构建下建不出,UI 占位。
case secureEnclave(keyTag: String)
}
/// 私钥来源。
enum Source: String, Codable { case pasted, imported, generated }
/// 私钥算法。libssh2 无 Ed25519、有 ECDSA;"生成新的"默认 P-256。
enum KeyType: String, Codable {
case ecdsaP256, ecdsaP384, ecdsaP521, rsa, unknown
var displayName: String {
switch self {
case .ecdsaP256: "ECDSA P-256"
case .ecdsaP384: "ECDSA P-384"
case .ecdsaP521: "ECDSA P-521"
case .rsa: "RSA"
case .unknown: "SSH 私钥"
}
}
}
// MARK: - 展示辅助
var kindLabel: String {
switch kind {
case .password: "密码"
case .privateKey(let t, _, _, _): t.displayName
case .secureEnclave: "Face ID 密钥"
}
}
var iconName: String {
switch kind {
case .password: "key.fill"
case .privateKey: "lock.doc.fill"
case .secureEnclave: "faceid"
}
}
/// SSH 线格式公钥 blob(仅私钥类凭据有)。
var publicKeyBlob: Data? {
if case .privateKey(_, _, _, let blob) = kind { return blob }
return nil
}
/// authorized_keys 行(生成/导入的私钥可复制到服务器)。
var authorizedKeysLine: String? {
guard let blob = publicKeyBlob, !blob.isEmpty else { return nil }
// 目前生成的都是 ecdsa-sha2-nistp256;导入的按 keyType 推断算法名。
let algo: String
if case .privateKey(let t, _, _, _) = kind {
switch t {
case .ecdsaP256: algo = "ecdsa-sha2-nistp256"
case .ecdsaP384: algo = "ecdsa-sha2-nistp384"
case .ecdsaP521: algo = "ecdsa-sha2-nistp521"
case .rsa: algo = "ssh-rsa"
case .unknown: return nil
}
} else { return nil }
return "\(algo) \(blob.base64EncodedString()) \(username)@terminalx"
}
}
/// 凭据机密(按需加载,自描述解释方式)。
enum CredentialSecret: Codable, Equatable {
/// 密码 → `.password` 认证。
case password(String)
/// PEM / PKCS#8 私钥文本(导入/粘贴)→ libssh2 `publickey_frommemory`(路径 B)。
case pemPrivateKey(pem: Data, passphrase: String?)
/// SecKey 外部表示(生成的软件 P-256 私钥)→ 重建 SecKey → SSHSigner(路径 A)。
case rawECPrivateKey(x963: Data)
/// 无机密(Secure Enclave:机密不出安全域)。
case none
}

View File

@@ -0,0 +1,84 @@
import Foundation
import TXTransport
/// 凭据 ↔ SSH 认证 的桥。三条各自很薄的路径:
/// - 密码 → `.password`
/// - 生成的软件 P-256 → 重建 SecKey → `SSHSigner` → `.publicKeyCallback`(路径 A)
/// - 粘贴/导入 的 PEM/PKCS#8 → `.privateKey`(libssh2 `publickey_frommemory`,路径 B)
///
/// 不自造私钥格式解析器:导入的私钥直接透传 libssh2/mbedTLS。只做**轻量头部嗅探**推断类型/加密/警告。
enum CredentialAuth {
// MARK: - 生成
/// 生成一把软件 P-256 私钥 → 返回可存入 CredentialStore 的 (kind, secret)。
static func generateECDSAP256() -> (kind: Credential.Kind, secret: CredentialSecret)? {
guard let (provider, x963) = SigningKeyProvider.generateSoftwareP256() else { return nil }
let kind = Credential.Kind.privateKey(
keyType: .ecdsaP256, source: .generated, hasPassphrase: false,
publicKeyBlob: Data(provider.publicKeyBlob))
return (kind, .rawECPrivateKey(x963: x963))
}
// MARK: - 导入 / 粘贴
struct ParsedKey {
var kind: Credential.Kind
var secret: CredentialSecret
/// 人类可读的格式标签(UI 实时提示,如 "PEM · EC 私钥")。
var formatLabel: String
/// 非致命警告(如 openssh-key-v1 mbedTLS 不认)。nil = 无。
var warning: String?
}
/// 嗅探私钥文本头部,产出凭据 kind + 机密(路径 B)。识别不了也照存(交由 libssh2 定夺)。
static func parsePrivateKey(_ text: String, source: Credential.Source) -> ParsedKey {
let t = text.trimmingCharacters(in: .whitespacesAndNewlines)
let encrypted = t.contains("Proc-Type: 4,ENCRYPTED")
|| t.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----")
var keyType: Credential.KeyType = .unknown
var label = "SSH 私钥"
var warning: String?
if t.contains("-----BEGIN OPENSSH PRIVATE KEY-----") {
label = "OpenSSH 新格式"
warning = "mbedTLS 不识别 openssh-key-v1,可能认证失败。建议先 `ssh-keygen -p -m PEM` 转为 PEM。"
} else if t.contains("-----BEGIN RSA PRIVATE KEY-----") {
keyType = .rsa; label = "PEM · RSA (PKCS#1)"
} else if t.contains("-----BEGIN EC PRIVATE KEY-----") {
keyType = .ecdsaP256; label = "PEM · EC (SEC1)"
} else if t.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----") {
label = "PKCS#8(加密)"
} else if t.contains("-----BEGIN PRIVATE KEY-----") {
label = "PKCS#8"
} else {
warning = "未识别到 PEM 私钥头,请确认粘贴的是私钥内容。"
}
let kind = Credential.Kind.privateKey(
keyType: keyType, source: source, hasPassphrase: encrypted, publicKeyBlob: Data())
let secret = CredentialSecret.pemPrivateKey(pem: Data(text.utf8), passphrase: nil)
return ParsedKey(kind: kind, secret: secret, formatLabel: label, warning: warning)
}
// MARK: - 构造 Authentication
/// 由凭据 meta + 机密构造 SSH 认证。返回 nil = 无法用于连接(如 SE 未签名建不出、机密缺失)。
/// `passphraseOverride` 供连接时按需补输加密私钥口令。
static func authentication(for cred: Credential, secret: CredentialSecret,
passphraseOverride: String? = nil) -> SSHConfig.Authentication? {
switch secret {
case .password(let pw):
return .password(pw)
case .pemPrivateKey(let pem, let storedPass):
let pem = String(decoding: pem, as: UTF8.self)
return .privateKey(pem: pem, passphrase: passphraseOverride ?? storedPass)
case .rawECPrivateKey(let x963):
guard let signer = SigningKeyProvider.from(x963: x963) else { return nil }
return .publicKeyCallback(signer)
case .none:
return nil // Secure Enclave 占位:未签名无法签名
}
}
}

View File

@@ -0,0 +1,78 @@
import Foundation
/// 凭据存储抽象:meta 与机密分离。
/// 让「未签名文件后端」与「签名后 Keychain 后端」可无缝替换——列表只用 `list()`(纯 meta),
/// 机密经 `secret(for:)` 按需单条取(Keychain 后端天然 meta 在 JSON、机密在 SecItem)。
@MainActor
protocol CredentialStore: AnyObject {
func list() -> [Credential]
func secret(for id: UUID) -> CredentialSecret?
func upsert(_ cred: Credential, secret: CredentialSecret?) // secret == nil 时仅改 meta
func delete(_ id: UUID)
}
/// 未签名构建的文件后端。
///
/// 为何不自造加密:无根信任(无 Keychain/SE 兜底存主密钥)则本地对称加密是伪安全——
/// 能读机密文件者同样能读密钥文件。威胁模型为开发自用工具 / 模拟器 / 非越狱设备,
/// 沙盒明文与现有 host 明文密码一致即可。做三件几乎免费的加固:
/// 0600 权限、`isExcludedFromBackup`(防机密进 iCloud/iTunes 备份)、`FileProtectionType.complete`(锁屏加密)。
/// 信封版本化(`scheme: "plaintext-v1"`)便于签名后按 scheme 分派迁移。
@MainActor
final class FileCredentialStore: ObservableObject, CredentialStore {
@Published private(set) var credentials: [Credential] = []
private let url: URL
private var secrets: [UUID: CredentialSecret] = [:]
private struct Entry: Codable { var meta: Credential; var secret: CredentialSecret }
private struct Envelope: Codable { var scheme: String; var entries: [Entry] }
private static let scheme = "plaintext-v1"
init() {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
url = base.appendingPathComponent("credentials.json")
load()
}
func list() -> [Credential] { credentials }
func secret(for id: UUID) -> CredentialSecret? { secrets[id] }
func upsert(_ cred: Credential, secret: CredentialSecret?) {
if let i = credentials.firstIndex(where: { $0.id == cred.id }) {
credentials[i] = cred
} else {
credentials.append(cred)
}
if let secret { secrets[cred.id] = secret }
persist()
}
func delete(_ id: UUID) {
credentials.removeAll { $0.id == id }
secrets[id] = nil
persist()
}
// MARK: - 持久化
private func load() {
guard let data = try? Data(contentsOf: url),
let env = try? JSONDecoder().decode(Envelope.self, from: data) else { return }
credentials = env.entries.map(\.meta)
secrets = Dictionary(uniqueKeysWithValues: env.entries.map { ($0.meta.id, $0.secret) })
}
private func persist() {
let entries = credentials.map { Entry(meta: $0, secret: secrets[$0.id] ?? .none) }
let env = Envelope(scheme: Self.scheme, entries: entries)
guard let data = try? JSONEncoder().encode(env) else { return }
try? data.write(to: url, options: [.atomic, .completeFileProtection])
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
var u = url
var rv = URLResourceValues(); rv.isExcludedFromBackup = true
try? u.setResourceValues(rv)
}
}

View File

@@ -0,0 +1,620 @@
import SwiftUI
/// 首页 · 主机管理(设计稿 `1e 主机-会话卡`)。
///
/// App 冷启动落点。首要动作是**「回到刚才那个会话」**,所以活动会话卡在最上面、占最大面积;
/// 「全部主机」在下面按内容高度铺 4 列。左右留白 40pt。
struct HomeView: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var hostStore: HostStore
@ObservedObject var credStore: FileCredentialStore
@ObservedObject var tailscaleStore: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@Environment(\.horizontalSizeClass) private var hSize
@State private var search = ""
@State private var groupFilter: String? = nil
@State private var showEditor = false
@State private var editing: SavedHost?
@State private var showSettings = false
private var p: TXPalette { theme.palette }
/// 分组 chips:「全部」+ 主机里出现过的分组(按首次出现顺序,不排序以免抖动)。
private var groups: [String] {
var seen: [String] = []
for h in hostStore.hosts {
if let g = h.group, !seen.contains(g) { seen.append(g) }
}
return seen
}
private var filteredHosts: [SavedHost] {
hostStore.hosts.filter { h in
let matchesGroup = groupFilter == nil || h.group == groupFilter
guard matchesGroup else { return false }
guard !search.isEmpty else { return true }
let q = search.lowercased()
return h.name.lowercased().contains(q) || h.host.lowercased().contains(q)
|| (h.os?.lowercased().contains(q) ?? false)
}
}
var body: some View {
// 量一次安全区:滚动内容要铺到物理底边(见下)。
TXScreenMetrics { geo in
content(safeBottom: geo.safeAreaInsets.bottom)
}
.background(p.bg.ignoresSafeArea())
// 首页可见时轮询会话内容做文本镜像(缩略卡「真实回滚」的落地方式)。
.task(id: manager.sessions.count) {
while !Task.isCancelled {
for s in manager.sessions { s.captureSnapshot() }
try? await Task.sleep(nanoseconds: 1_500_000_000)
}
}
.sheet(isPresented: $showEditor) {
HostEditorView(existing: editing, credStore: credStore, tailscaleStore: tailscaleStore,
existingGroups: groups) { host in
if hostStore.hosts.contains(where: { $0.id == host.id }) { hostStore.update(host) }
else { hostStore.add(host) }
}
}
.sheet(isPresented: $showSettings) {
SettingsHost(credStore: credStore, tailscaleStore: tailscaleStore)
}
}
private func content(safeBottom: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 0) {
header
if hostStore.hosts.isEmpty && manager.sessions.isEmpty {
firstRunEmptyState
} else {
ScrollView {
VStack(alignment: .leading, spacing: TX.Space.m) {
// 点主机后先留在首页探测 tmux → 这里给出进度,不让用户对着不动的界面等。
ForEach(manager.connectingSessions) { s in
ConnectingRow(session: s) { manager.close(s.id) }
}
if liveSessions.isEmpty {
if manager.connectingSessions.isEmpty { noSessionHint }
} else {
TXSectionLabel(text: "活动会话")
.padding(.top, TX.Space.xs)
sessionCards
}
HStack(alignment: .center) {
TXSectionLabel(text: "全部主机")
Spacer()
if !groups.isEmpty { chips }
}
.padding(.top, manager.sessions.isEmpty ? TX.Space.xs : TX.Space.s)
if filteredHosts.isEmpty {
noMatchState
} else {
hostGrid
}
}
.padding(.horizontal, gutter)
// 末行要能完整滚到安全区之上(否则最后一排主机卡永远被 home indicator 压着)。
.padding(.bottom, TX.Space.xxl + safeBottom)
}
// 列表内容从物理底边流出去(系统滚动视图的标准观感),别在安全区上方硬切一刀。
.ignoresSafeArea(edges: .bottom)
// 会话卡/进度行的增减跟着同一条曲线,避免连上/断开时整页跳一下。
.animation(TX.Motion.standard, value: manager.sessions.count)
}
}
}
// MARK: - 空状态
/// 首次运行:一台主机都没有。给一条明确的下一步,而不是空网格。
private var firstRunEmptyState: some View {
VStack(spacing: TX.Space.m) {
Spacer()
Image(systemName: "terminal")
.font(.system(size: 40, weight: .ultraLight))
.foregroundStyle(TXAccent.mid)
VStack(spacing: 6) {
Text("还没有主机")
.font(TX.Font.mono(15, .medium))
.foregroundStyle(p.text)
Text("添加一台服务器就能开始:SSH 直连,或经 Tailscale 出口访问内网。\n开启 mosh 后断线自动接回。")
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
.multilineTextAlignment(.center)
.lineSpacing(4)
}
HStack(spacing: TX.Space.s) {
TXButton(title: "新建连接", emphasized: true) {
editing = nil
showEditor = true
}
TXButton(title: "先配置 Tailscale") { showSettings = true }
}
.padding(.top, TX.Space.xs)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
/// 有主机但没有会话:不留白,提示从下面的主机开始。
private var noSessionHint: some View {
HStack(spacing: TX.Space.s) {
Image(systemName: "arrow.down")
.font(.system(size: 12))
.foregroundStyle(p.textWeak)
Text("还没有在跑的会话 —— 点下面任意主机即可连接")
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 40)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
.foregroundStyle(p.divider)
)
.padding(.top, TX.Space.xs)
}
/// 搜索/筛选无命中:说明命中为空的原因(是筛选还是搜索),并给一键清除。
private var noMatchState: some View {
VStack(spacing: TX.Space.s) {
Text(search.isEmpty ? "该分组下没有主机" : "没有匹配「\(search)」的主机")
.font(TX.Font.mono(12.5, .medium))
.foregroundStyle(p.text3)
TXButton(title: "清除筛选") {
search = ""
groupFilter = nil
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, TX.Space.xl)
}
// MARK: - 页头
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("terminalX")
.font(TX.Font.mono(34, .medium))
.tracking(-0.85)
.foregroundStyle(p.text)
Text(subtitle)
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
}
Spacer(minLength: TX.Space.s)
HStack(spacing: TX.Space.s) {
TXSearchField(text: $search, placeholder: "搜索主机")
// 可伸缩而非死死 220:窄窗口(分屏 / iPhone)下固定宽度会把「新建连接」
// 挤出屏幕外。
.frame(minWidth: 120, maxWidth: 220)
TXButton(title: "新建连接", emphasized: true) {
editing = nil
showEditor = true
}
.fixedSize()
TXIconButton(icon: "gearshape", label: "设置") { showSettings = true }
}
}
.padding(.horizontal, gutter)
.padding(.top, TX.Space.l)
.padding(.bottom, TX.Space.m)
}
/// 屏幕左右留白:设计基准(iPad 横屏)40pt;窄窗口收到 20pt,否则内容区被吃掉一大截。
private var gutter: CGFloat {
hSize == .compact ? TX.Space.l : TX.Space.screenGutter
}
private var subtitle: String {
var parts: [String] = []
if !manager.sessions.isEmpty { parts.append("\(manager.sessions.count) 个会话在跑") }
parts.append("\(hostStore.hosts.count) 台主机")
if let tailnet = tailscaleStore.connections.first {
parts.append("Tailscale \(tailnet.name)")
}
return parts.joined(separator: " · ")
}
private var chips: some View {
HStack(spacing: TX.Space.xs) {
TXChip(text: "全部", isSelected: groupFilter == nil) { groupFilter = nil }
ForEach(groups, id: \.self) { g in
TXChip(text: g, isSelected: groupFilter == g) { groupFilter = g }
}
}
}
// MARK: - 活动会话卡
/// 已建立的会话。**排除正在连接的**(它们走 ConnectingRow)—— 否则同一会话既有进度行又有卡片,
/// 而且卡片里的 tmux 信息此刻还没定(会先显示成「原生窗口」再跳变)。
private var liveSessions: [TerminalSession] {
let connecting = Set(manager.connectingSessions.map(\.id))
return manager.activeSessions.filter {
($0.isLive || $0.isMinimized) && !connecting.contains($0.id)
}
}
/// 会话卡:**一排固定 3 格**,不足 3 条从左往右排(不拉伸、不居中),每格 **4:3**。
///
/// 固定 3 列的好处:卡片尺寸与位置不随会话数跳动 —— 连上第二台主机时第一张卡不该改变大小。
/// 超过 3 条自动换行到下一排。
private var sessionCards: some View {
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: TX.Space.m), count: 3),
spacing: TX.Space.m
) {
ForEach(liveSessions) { s in
SessionCard(
session: s,
shortcutIndex: (liveSessions.firstIndex { $0.id == s.id } ?? 0) + 1,
isFocused: manager.focusedID == s.id
) {
manager.focus(s)
router.enter(s.id)
}
.aspectRatio(4.0 / 3.0, contentMode: .fit)
}
}
}
// MARK: - 全部主机(4 列,按内容高度)
private var hostGrid: some View {
// 设计基准 4 列(iPad 横屏);窄窗口 2 列,否则每张卡窄到主机名都放不下。
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: TX.Space.m),
count: hSize == .compact ? 2 : 4),
spacing: TX.Space.m) {
ForEach(filteredHosts) { host in
HostTile(host: host,
credName: credName(for: host),
hasTsnet: host.tailscaleID != nil,
liveSession: manager.sessions.first { $0.host?.id == host.id }) {
// 只发起连接:探测 tmux → 弹会话选择器 → 选完由 manager.enterRequest 进终端。
manager.open(host: host)
}
.contextMenu {
Button { editing = host; showEditor = true } label: {
Label("编辑", systemImage: "pencil")
}
Button(role: .destructive) { hostStore.remove(host) } label: {
Label("删除", systemImage: "trash")
}
}
}
addHostTile
}
}
private var addHostTile: some View {
Button {
editing = nil
showEditor = true
} label: {
VStack {
Text("添加主机")
.font(TX.Font.mono(12, .medium))
.foregroundStyle(p.textWeak)
}
.frame(maxWidth: .infinity, minHeight: 96)
.background(
RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
.foregroundStyle(p.border)
)
}
.buttonStyle(.txPress)
}
private func credName(for host: SavedHost) -> String? {
if case .credential(let id) = host.auth {
return credStore.list().first { $0.id == id }?.name
}
return nil
}
}
/// 首页的「正在连接」行:点主机后到进终端之间的反馈(探测 tmux、等选择、失败)。
/// 独立视图以直接订阅该会话(`SessionManager` 的转发只保证 manager 自身刷新)。
struct ConnectingRow: View {
@ObservedObject var session: TerminalSession
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var failed: Bool { session.phaseText.hasPrefix("失败") }
var body: some View {
HStack(spacing: TX.Space.s) {
if failed {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 13))
.foregroundStyle(p.danger)
} else {
ProgressView().controlSize(.small).tint(TXAccent.base)
}
VStack(alignment: .leading, spacing: 2) {
Text(failed ? "连接 \(session.displayName) 失败" : "正在连接 \(session.displayName)")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Text(detail)
.font(TX.Font.mono(11))
.foregroundStyle(failed ? p.danger : p.textWeak)
.lineLimit(1)
}
Spacer(minLength: TX.Space.s)
if failed {
TXButton(title: "重试") { session.retry() }
}
TXButton(title: failed ? "移除" : "取消", action: onCancel)
}
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(failed ? p.danger.opacity(0.4) : p.border, lineWidth: 1)
)
.padding(.top, TX.Space.xs)
}
private var detail: String {
if failed { return String(session.phaseText.dropFirst(3)) }
if session.pendingSessionChoice != nil { return "等待选择 tmux 会话" }
if !session.banner.isEmpty { return session.banner }
return session.phaseText
}
}
// MARK: - 活动会话卡
/// 会话缩略卡:卡头(状态 + 主机名 + tmux + 链路)· 终端文本镜像(底对齐)· 卡脚(元信息 + 主动作)。
/// 离线/重连中的卡片压一层遮罩 + 说明(设计要求遮罩是内容的最后一层,故用 overlay)。
struct SessionCard: View {
@ObservedObject var session: TerminalSession
let shortcutIndex: Int
let isFocused: Bool
let onOpen: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var statusColor: Color {
switch session.link {
case .direct, .relay: p.online
case .reconnecting: p.reconnecting
case .connecting: p.text4
case .offline: p.offline
}
}
private var isTroubled: Bool {
switch session.link {
case .reconnecting, .offline: true
default: false
}
}
/// 整张卡是**一个**按钮:卡脚的「回到会话 →」与点卡片是同一个动作,做成嵌套 Button 只会
/// 抢命中区、还各自一套按压反馈。故卡脚那行只作为文字提示(affordance),不再是控件。
var body: some View {
Button(action: onOpen) {
VStack(spacing: 0) {
cardHead
Rectangle().fill(p.divider).frame(height: 1)
// 离线/重连时缓存内容轻微模糊 + 压遮罩(设计:blur(1.5px) + rgba(24,26,40,.72)),
// 说明「内容还在,只是链路断了」。
TXTerminalMirror(lines: session.snapshotLines, background: p.terminalBackground)
.blur(radius: isTroubled ? 1.5 : 0)
.overlay { if isTroubled { troubleVeil } }
Rectangle().fill(p.divider).frame(height: 1)
cardFoot
}
// **必须**主动填满外面给的高度:`.frame(height:)` 只是给了个 560 的框,内容不声明
// 要撑满就按自身理想高度居中放进去 —— 内容少的卡会比内容多的矮一截、上下各缩 10pt,
// 一排卡片的上下边就对不齐了(改成 LazyVGrid 之前是 HStack 顶对齐,只在底边露馅)。
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(p.surfaceDark)
.clipShape(RoundedRectangle(cornerRadius: 13))
.overlay(
RoundedRectangle(cornerRadius: 13)
.strokeBorder(isFocused ? TXAccent.selectedStroke : p.border, lineWidth: 1)
)
.txShadow(isFocused ? TX.Elevation.cardFocused : TX.Elevation.card)
.animation(TX.Motion.quick, value: isFocused)
.contentShape(Rectangle())
}
.buttonStyle(.txPress)
}
private var cardHead: some View {
HStack(spacing: TX.Space.xs) {
TXStatusDot(color: statusColor)
Text(session.displayName)
.font(TX.Font.mono(13, .semibold))
.foregroundStyle(p.text)
.lineLimit(1)
if let t = session.tmuxSummary {
Text(t.name.isEmpty ? "tmux" : "tmux \(t.name)")
.font(TX.Font.mono(11))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
Spacer(minLength: TX.Space.xs)
linkTag
}
.padding(.horizontal, TX.Space.s)
.frame(height: 36)
}
/// 链路标:设计里这格是 RTT 数字,首版无 RTT 管道 → 显示链路类型/重连次数(见 HANDOFF §12.4)。
@ViewBuilder private var linkTag: some View {
switch session.link {
case .direct:
tag("direct", p.online)
case .relay:
tag("tsnet", p.online)
case .connecting:
tag("连接中", p.text4)
case .reconnecting(let n):
tag("重连中 · 第 \(n) 次", p.reconnecting)
case .offline:
tag("离线", p.offline)
}
}
private func tag(_ s: String, _ c: Color) -> some View {
Text(s).font(TX.Font.mono(11, .medium)).foregroundStyle(c).lineLimit(1)
}
/// 遮罩:转圈 + 两行说明。压在终端镜像之上,说明「屏幕内容已缓存,恢复后原样接回」。
private var troubleVeil: some View {
ZStack {
Color(hex: 0x181A28).opacity(0.72)
VStack(spacing: TX.Space.s) {
if case .reconnecting = session.link {
ProgressView().controlSize(.regular).tint(p.reconnecting)
}
Text(veilTitle)
.font(TX.Font.mono(12, .medium))
.foregroundStyle(p.reconnecting)
Text("屏幕内容已缓存,恢复后原样接回")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.text4)
}
.multilineTextAlignment(.center)
.padding(TX.Space.m)
}
}
private var veilTitle: String {
switch session.link {
case .reconnecting(let n): "\(session.moshEngaged ? "mosh " : "")会话保活中 · 第 \(n) 次重连"
default: "已断开"
}
}
private var cardFoot: some View {
HStack(spacing: TX.Space.xs) {
Text(footMeta)
.font(TX.Font.mono(11))
.foregroundStyle(p.textWeak)
.lineLimit(1)
Spacer(minLength: TX.Space.xs)
Text("⌘\(shortcutIndex)")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
Text(isTroubled ? "立即重试" : "回到会话 →")
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(TXAccent.textBright)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 38)
}
private var footMeta: String {
var parts: [String] = []
if let t = session.tmuxSummary {
parts.append("\(t.windows) window\(t.windows == 1 ? "" : "s")")
if t.panes > 0 { parts.append("\(t.panes) panes") }
} else {
parts.append("原生窗口")
}
if session.isMinimized {
parts.append("最小化中")
} else {
parts.append(Self.relative(session.lastActiveAt))
}
return parts.joined(separator: " · ")
}
/// 「上次活跃」相对时间(秒/分/小时/天)。
static func relative(_ date: Date) -> String {
let s = Int(Date().timeIntervalSince(date))
if s < 60 { return "\(max(1, s)) 秒前" }
if s < 3600 { return "\(s / 60) 分钟前" }
if s < 86400 { return "\(s / 3600) 小时前" }
return "\(s / 86400) 天前"
}
}
// MARK: - 主机卡
/// 主机卡(4 列网格):图标 + 名称 + os 副行 + 徽章行 + 右上状态点。
struct HostTile: View {
let host: SavedHost
let credName: String?
let hasTsnet: Bool
/// 该主机是否已有活动会话(有则右上点亮,点击复用而非新建)。
let liveSession: TerminalSession?
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var icon: String {
let os = (host.os ?? "").lowercased()
if os.contains("macos") { return "desktopcomputer" }
if os.contains("raspberry") { return "cpu" }
if os.contains("nas") || host.name.lowercased().contains("nas") { return "externaldrive" }
return "server.rack"
}
var body: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: TX.Space.s) {
HStack(alignment: .top, spacing: TX.Space.s) {
// 图标染发行版品牌色(Ubuntu 橙 / Debian 红 / macOS 中性银…),
// 一眼扫过去就能按 OS 分辨主机;认不出的发行版回中性。
Image(systemName: icon)
.font(.system(size: 15, weight: .light))
.foregroundStyle(TXTech.osTint(host.os, p))
.frame(width: 30, height: 30)
.background(p.surface, in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 2) {
Text(host.name)
.font(TX.Font.mono(13, .semibold))
.foregroundStyle(p.text)
.lineLimit(1)
Text(host.os ?? host.addressLine)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
Spacer(minLength: 0)
if let s = liveSession {
TXStatusDot(color: s.link == .offline ? p.offline
: (s.isMinimized ? TXAccent.mid : p.online))
} else {
TXStatusDot(color: p.offline.opacity(0.5))
}
}
HStack(spacing: 5) {
// 传输协议(互斥)着语义冷色;tsnet 是招牌能力,用 app 强调色;
// 凭据名是用户自己命的名、不是技术标签 → 中性(详见 TXTech)。
let transport = TXTech.transport(host.useMosh, p)
TXBadge(text: transport.text, tint: transport.tint)
if hasTsnet { TXBadge(text: "tsnet", emphasized: true) }
if let credName { TXBadge(text: credName) }
}
}
.padding(TX.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(p.border, lineWidth: 1)
)
}
.buttonStyle(.txPress)
}
}

View File

@@ -0,0 +1,150 @@
import Foundation
/// 已保存的主机(连接目标)。认证经 `AuthRef` 引用凭据或内联密码;出口经可选 `tailscaleID`。
struct SavedHost: Identifiable, Codable, Equatable {
var id: UUID = UUID()
var name: String
var host: String
var port: Int = 22
/// 登录后引导 mosh 承载会话。
var useMosh: Bool = false
/// 登录后自动进入 tmux control mode(`tmux -CC new -A`)。默认开——tmux 是本项目四大核心之一,
/// 会话复用/断线原样接回都靠它。服务器没装 tmux 时会自动降级为原生窗口并给安装引导。
var useTmux: Bool = true
/// tmux 会话名(`-s`)。nil = 用默认 `main`。
var tmuxSession: String?
/// 认证方式:内联用户名密码(快捷/旧数据归宿)或引用 Keychain 凭据。
var auth: AuthRef
/// 出口:nil = Direct 直连;否则经该 Tailscale 连接(egress profile,架构决策 §2.6)。
var tailscaleID: UUID?
/// 分组(首页「全部主机」的筛选 chips:生产 / 家里 / 云…)。nil = 未分组。
var group: String?
/// 系统描述(主机卡副行,如「macOS · M4 Max」)。纯展示,不参与连接。
var os: String?
/// 认证引用。
enum AuthRef: Codable, Equatable {
case inlinePassword(username: String, password: String)
case credential(id: UUID)
}
init(id: UUID = UUID(), name: String, host: String, port: Int = 22,
useMosh: Bool = false, useTmux: Bool = true, tmuxSession: String? = nil,
auth: AuthRef, tailscaleID: UUID? = nil,
group: String? = nil, os: String? = nil) {
self.id = id
self.name = name
self.host = host
self.port = port
self.useMosh = useMosh
self.useTmux = useTmux
self.tmuxSession = tmuxSession
self.auth = auth
self.tailscaleID = tailscaleID
self.group = group
self.os = os
}
/// 展示地址行;内联凭据带用户名,引用凭据时用户名在凭据里,故只显示 host:port。
var addressLine: String {
if case .inlinePassword(let user, _) = auth, !user.isEmpty {
return "\(user)@\(host):\(port)"
}
return "\(host):\(port)"
}
// MARK: - Codable(含旧 schema 迁移)
enum CodingKeys: String, CodingKey {
case id, name, host, port, useMosh, useTmux, tmuxSession, auth, tailscaleID, group, os
// 旧字段
case username, password
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
name = try c.decode(String.self, forKey: .name)
host = try c.decode(String.self, forKey: .host)
port = try c.decodeIfPresent(Int.self, forKey: .port) ?? 22
useMosh = try c.decodeIfPresent(Bool.self, forKey: .useMosh) ?? false
// 旧数据没有这两个键:tmux 默认开(此前是"从不自动进 tmux",升级后按设计默认启用)。
useTmux = try c.decodeIfPresent(Bool.self, forKey: .useTmux) ?? true
tmuxSession = try c.decodeIfPresent(String.self, forKey: .tmuxSession)
tailscaleID = try c.decodeIfPresent(UUID.self, forKey: .tailscaleID)
group = try c.decodeIfPresent(String.self, forKey: .group)
os = try c.decodeIfPresent(String.self, forKey: .os)
if let auth = try c.decodeIfPresent(AuthRef.self, forKey: .auth) {
self.auth = auth
} else {
// 旧 schema:inline username/password → inlinePassword(不自动造幽灵凭据)。
let user = try c.decodeIfPresent(String.self, forKey: .username) ?? ""
let pw = try c.decodeIfPresent(String.self, forKey: .password) ?? ""
self.auth = .inlinePassword(username: user, password: pw)
}
}
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(id, forKey: .id)
try c.encode(name, forKey: .name)
try c.encode(host, forKey: .host)
try c.encode(port, forKey: .port)
try c.encode(useMosh, forKey: .useMosh)
try c.encode(useTmux, forKey: .useTmux)
try c.encodeIfPresent(tmuxSession, forKey: .tmuxSession)
try c.encode(auth, forKey: .auth)
try c.encodeIfPresent(tailscaleID, forKey: .tailscaleID)
try c.encodeIfPresent(group, forKey: .group)
try c.encodeIfPresent(os, forKey: .os)
}
}
/// 主机列表持久化(Application Support 下 JSON)。凭据不入仓库;此文件只在设备本地。
@MainActor
final class HostStore: ObservableObject {
@Published private(set) var hosts: [SavedHost] = []
private let url: URL
init() {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
url = base.appendingPathComponent("hosts.json")
load()
}
func add(_ host: SavedHost) {
hosts.append(host)
save()
}
func update(_ host: SavedHost) {
guard let i = hosts.firstIndex(where: { $0.id == host.id }) else { return }
hosts[i] = host
save()
}
func remove(_ host: SavedHost) {
hosts.removeAll { $0.id == host.id }
save()
}
#if DEBUG
/// 无头 UI 验证:把示意主机塞进内存(**不写盘**,不污染真实 hosts.json)。
func injectFixtureHosts(_ hosts: [SavedHost]) {
self.hosts = hosts
}
#endif
private func load() {
guard let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode([SavedHost].self, from: data) else { return }
hosts = decoded
}
private func save() {
guard let data = try? JSONEncoder().encode(hosts) else { return }
try? data.write(to: url, options: .atomic)
}
}

View File

@@ -0,0 +1,350 @@
import SwiftUI
/// 主机列表页(split 详情)。点卡片连接;右上 + 新建;上下文菜单编辑/删除。
struct HostsListView: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var hostStore: HostStore
@ObservedObject var credStore: FileCredentialStore
@ObservedObject var tailscaleStore: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@State private var editing: SavedHost?
@State private var showEditor = false
private var p: TXPalette { theme.palette }
var body: some View {
ZStack {
p.bg.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: TX.Space.m) {
if hostStore.hosts.isEmpty {
TXEmptyState(icon: "server.rack", title: "还没有主机",
subtitle: "点击右上角 + 添加一台服务器")
} else {
ForEach(hostStore.hosts) { host in
HostCard(host: host,
credName: credName(for: host),
tailscaleName: tailscaleName(for: host)) {
// 设计定稿:建好即进终端(连接进度显示在终端页);已有该主机会话则复用。
let s = manager.open(host: host)
router.enter(s.id)
}
.contextMenu {
Button { editing = host; showEditor = true } label: {
Label("编辑", systemImage: "pencil")
}
Button(role: .destructive) { hostStore.remove(host) } label: {
Label("删除", systemImage: "trash")
}
}
}
}
// 独立订阅焦点会话:manager 是嵌套 ObservableObject,会话内部变化不冒泡到这里。
if let s = manager.focused { SessionStatusRow(session: s) }
}
.padding(TX.Space.m)
}
}
.navigationTitle("主机")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(p.bg, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { editing = nil; showEditor = true } label: { Image(systemName: "plus") }
}
}
.sheet(isPresented: $showEditor) {
HostEditorView(existing: editing, credStore: credStore, tailscaleStore: tailscaleStore) { host in
if hostStore.hosts.contains(where: { $0.id == host.id }) { hostStore.update(host) }
else { hostStore.add(host) }
}
}
}
private func credName(for host: SavedHost) -> String? {
if case .credential(let id) = host.auth {
return credStore.list().first { $0.id == id }?.name ?? "(凭据已删除)"
}
return nil
}
private func tailscaleName(for host: SavedHost) -> String? {
guard let id = host.tailscaleID else { return nil }
return tailscaleStore.connection(id: id)?.name ?? "(连接已删除)"
}
}
/// 焦点会话的连接进度/失败提示(连接中或失败时才出现)。
/// 单独成视图以 `@ObservedObject` 直接订阅该会话——`SessionManager` 不会转发会话内部变化。
struct SessionStatusRow: View {
@ObservedObject var session: TerminalSession
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var isBusy: Bool {
session.phaseText.hasSuffix("…") || session.phaseText.contains("重连")
}
var body: some View {
if isBusy || session.phaseText.hasPrefix("失败") {
HStack(spacing: TX.Space.s) {
if isBusy { ProgressView().controlSize(.small).tint(p.accent) }
Text(session.phaseText)
.font(.system(size: 13))
.foregroundStyle(session.phaseText.hasPrefix("失败") ? p.danger : p.textSecondary)
}
.padding(.horizontal, 4)
}
}
}
/// 主机卡片:图标 + mosh 点 / 名称 / 地址(等宽)/ 凭据·Tailscale 徽章 / chevron。
struct HostCard: View {
let host: SavedHost
let credName: String?
let tailscaleName: String?
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Button(action: onTap) {
HStack(spacing: TX.Space.m) {
ZStack(alignment: .topTrailing) {
Image(systemName: "server.rack")
.font(.system(size: 20, weight: .regular))
.foregroundStyle(p.textSecondary)
.frame(width: 44, height: 44)
.background(p.surfaceElevated, in: RoundedRectangle(cornerRadius: TX.Radius.chip))
if host.useMosh {
Circle().fill(p.mosh).frame(width: 9, height: 9)
.overlay(Circle().stroke(p.surface, lineWidth: 2))
.offset(x: 3, y: -3)
}
}
VStack(alignment: .leading, spacing: 3) {
Text(host.name)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(p.textPrimary)
Text(host.addressLine)
.font(TX.Font.mono(13))
.foregroundStyle(p.textSecondary)
if credName != nil || tailscaleName != nil {
HStack(spacing: TX.Space.xs) {
if let c = credName { badge("key.fill", c, p.accent) }
if let t = tailscaleName { badge("network", t, p.mosh) }
}
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(p.textTertiary)
}
.padding(TX.Space.m)
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1))
}
.buttonStyle(.txPress)
}
private func badge(_ icon: String, _ text: String, _ color: Color) -> some View {
HStack(spacing: 3) {
Image(systemName: icon).font(.system(size: 9))
Text(text).font(.system(size: 11, weight: .medium)).lineLimit(1)
}
.foregroundStyle(color)
.padding(.horizontal, 7).padding(.vertical, 3)
.background(color.opacity(0.13), in: Capsule())
}
}
/// 主机编辑页:基本信息 + 认证(直接输入 / 选 Keychain)+ 网络(Direct / Tailscale)+ 选项。
struct HostEditorView: View {
let existing: SavedHost?
@ObservedObject var credStore: FileCredentialStore
@ObservedObject var tailscaleStore: TailscaleStore
/// 已有分组(供一键选,避免录入同义不同名)。
var existingGroups: [String] = []
let onSave: (SavedHost) -> Void
@EnvironmentObject var theme: TXThemeManager
@Environment(\.dismiss) private var dismiss
private enum AuthMode: Hashable { case inline, credential }
@State private var name = ""
@State private var host = ""
@State private var port = "22"
@State private var authMode: AuthMode = .inline
@State private var inlineUser = ""
@State private var inlinePassword = ""
@State private var selectedCredentialID: UUID?
@State private var tailscaleID: UUID?
@State private var useMosh = false
@State private var showNewCredential = false
/// 分组(首页筛选 chips)与系统描述(主机卡副行)——真实数据只能从这里录入。
@State private var group = ""
@State private var os = ""
@State private var useTmux = true
@State private var tmuxSession = ""
private var p: TXPalette { theme.palette }
private var canSave: Bool {
guard !host.isEmpty else { return false }
switch authMode {
case .inline: return !inlineUser.isEmpty
case .credential: return selectedCredentialID != nil
}
}
var body: some View {
NavigationStack {
ZStack {
p.bg.ignoresSafeArea()
Form {
Section("基本") {
TXTextField(title: "名称", text: $name, placeholder: host.isEmpty ? "我的服务器" : host)
TXTextField(title: "主机 / IP", text: $host)
TXTextField(title: "端口", text: $port, keyboard: .numberPad)
}
Section {
TXTextField(title: "分组", text: $group, placeholder: "生产 / 家里 / 云(可空)")
if !existingGroups.isEmpty {
// 已有分组一键选,避免同义不同名把 chips 撑爆。
HStack(spacing: TX.Space.xs) {
ForEach(existingGroups, id: \.self) { g in
TXChip(text: g, isSelected: group == g) {
group = (group == g) ? "" : g
}
}
}
.txRow(p)
}
TXTextField(title: "系统", text: $os, placeholder: "macOS · M4 Max(可空)")
} header: {
Text("归类(首页展示用)")
} footer: {
Text("分组决定首页「全部主机」的筛选 chips;系统只作卡片副行展示,不参与连接。")
}
authSection
networkSection
Section {
Toggle("自动进入 tmux", isOn: $useTmux).tint(p.accent).txRow(p)
if useTmux {
TXTextField(title: "tmux 会话名", text: $tmuxSession, placeholder: "main")
}
Toggle("使用 mosh", isOn: $useMosh).tint(p.accent).txRow(p)
} header: {
Text("会话")
} footer: {
Text(useTmux
? "登录后执行 tmux -CC new -A -s <会话名>:同名会话存在就接回,否则新建。服务器没装 tmux 会自动降级为客户端窗口并给安装引导。"
: "不进 tmux:会话由 App 维持,关掉 App 后服务端不会继续跑(mosh 仍能兜断线)。")
}
}
.scrollContentBackground(.hidden)
}
.navigationTitle(existing == nil ? "添加主机" : "编辑主机")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }.tint(p.textSecondary)
}
ToolbarItem(placement: .confirmationAction) {
Button("保存") { save() }.disabled(!canSave).tint(p.accent)
}
}
.toolbarBackground(p.bg, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.sheet(isPresented: $showNewCredential) {
CredentialEditorView(existing: nil, store: credStore) { newCred in
selectedCredentialID = newCred.id
authMode = .credential
}
}
.onAppear(perform: populate)
}
.tint(p.accent)
}
@ViewBuilder private var authSection: some View {
Section("认证") {
Picker("方式", selection: $authMode) {
Text("直接输入").tag(AuthMode.inline)
Text("从 Keychain 选择").tag(AuthMode.credential)
}
.pickerStyle(.segmented)
.txRow(p)
if authMode == .inline {
TXTextField(title: "用户名", text: $inlineUser)
TXTextField(title: "密码", text: $inlinePassword, secure: true)
} else {
Picker("凭据", selection: $selectedCredentialID) {
Text("未选择").tag(UUID?.none)
ForEach(credStore.credentials) { c in
Text("\(c.name) · \(c.username)").tag(UUID?.some(c.id))
}
}
.txRow(p)
Button { showNewCredential = true } label: {
Label("新建凭据", systemImage: "plus.circle")
}
.txRow(p)
}
}
}
@ViewBuilder private var networkSection: some View {
Section("网络(出口)") {
Picker("经由", selection: $tailscaleID) {
Text("Direct(直连)").tag(UUID?.none)
ForEach(tailscaleStore.connections) { conn in
Text(conn.name).tag(UUID?.some(conn.id))
}
}
.txRow(p)
if let id = tailscaleID, let conn = tailscaleStore.connection(id: id), conn.needsAuthKey {
Text("该 Tailscale 连接尚未认证,请先在 Tailscale 页完成。")
.font(.system(size: 12)).foregroundStyle(p.warning).txRow(p)
}
}
}
private func populate() {
guard let h = existing else { return }
name = h.name; host = h.host; port = String(h.port)
useMosh = h.useMosh; tailscaleID = h.tailscaleID
group = h.group ?? ""; os = h.os ?? ""
useTmux = h.useTmux; tmuxSession = h.tmuxSession ?? ""
switch h.auth {
case .inlinePassword(let u, let pw):
authMode = .inline; inlineUser = u; inlinePassword = pw
case .credential(let id):
authMode = .credential; selectedCredentialID = id
}
}
private func save() {
let auth: SavedHost.AuthRef
switch authMode {
case .inline: auth = .inlinePassword(username: inlineUser, password: inlinePassword)
case .credential:
guard let id = selectedCredentialID else { return }
auth = .credential(id: id)
}
let saved = SavedHost(
id: existing?.id ?? UUID(),
name: name.isEmpty ? host : name,
host: host, port: Int(port) ?? 22,
useMosh: useMosh, useTmux: useTmux,
tmuxSession: tmuxSession.isEmpty ? nil : tmuxSession.trimmingCharacters(in: .whitespaces),
auth: auth, tailscaleID: tailscaleID,
group: group.isEmpty ? nil : group.trimmingCharacters(in: .whitespaces),
os: os.isEmpty ? nil : os.trimmingCharacters(in: .whitespaces))
onSave(saved)
dismiss()
}
}

View File

@@ -1,6 +1,15 @@
import Foundation
import TXCore
/// 已信任 host key 的展示条目(设置 → Known Hosts)。
struct KnownHostEntry: Identifiable {
let id: String // accountKey "egress|host|port"
let egress: String
let host: String
let port: Int
let fingerprint: String
}
/// known_hosts pin 存储(文件后端)。
///
/// 为何用文件而非 Keychain:本 app 当前未签名(CODE_SIGNING_ALLOWED=NO),无 keychain-access-group
@@ -47,6 +56,27 @@ final class KeychainKnownHostsStore: KnownHostsStore, @unchecked Sendable {
persist()
}
/// 已信任 host key 列表(供设置里查看/删除)。
func entries() -> [KnownHostEntry] {
lock.lock(); defer { lock.unlock() }
return cache.compactMap { key, entry in
guard let b64 = entry["blob"] as? String, let blob = Data(base64Encoded: b64) else { return nil }
let parts = key.split(separator: "|", maxSplits: 2).map(String.init)
return KnownHostEntry(
id: key,
egress: parts.count > 0 ? parts[0] : "",
host: parts.count > 1 ? parts[1] : "",
port: parts.count > 2 ? (Int(parts[2]) ?? 0) : 0,
fingerprint: HostKey.opensshFingerprint(blob))
}.sorted { $0.host < $1.host }
}
func removeKey(_ id: String) {
lock.lock(); defer { lock.unlock() }
cache[id] = nil
persist()
}
private func persist() {
guard let data = try? JSONSerialization.data(withJSONObject: cache) else { return }
try? data.write(to: url, options: .atomic)

View File

@@ -0,0 +1,358 @@
import SwiftUI
import UniformTypeIdentifiers
import TXCore
/// Keychain 凭据列表页。
struct KeychainListView: View {
@ObservedObject var store: FileCredentialStore
@EnvironmentObject var theme: TXThemeManager
@State private var editing: Credential?
@State private var showEditor = false
private var p: TXPalette { theme.palette }
var body: some View {
ZStack {
p.bg.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: TX.Space.s) {
if store.credentials.isEmpty {
TXEmptyState(icon: "key.fill", title: "还没有凭据",
subtitle: "添加密码或 SSH 私钥,供主机复用")
} else {
ForEach(store.credentials) { cred in
credRow(cred)
.contextMenu {
Button { editing = cred; showEditor = true } label: {
Label("编辑", systemImage: "pencil")
}
Button(role: .destructive) { store.delete(cred.id) } label: {
Label("删除", systemImage: "trash")
}
}
}
}
}
.padding(TX.Space.m)
}
}
.navigationTitle("Keychain 凭据")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(p.bg, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { editing = nil; showEditor = true } label: { Image(systemName: "plus") }
}
}
.sheet(isPresented: $showEditor) {
CredentialEditorView(existing: editing, store: store) { _ in }
}
}
private func credRow(_ cred: Credential) -> some View {
HStack(spacing: TX.Space.m) {
Image(systemName: cred.iconName)
.font(.system(size: 18))
.foregroundStyle(p.accent)
.frame(width: 40, height: 40)
.background(p.surfaceElevated, in: RoundedRectangle(cornerRadius: TX.Radius.chip))
VStack(alignment: .leading, spacing: 2) {
Text(cred.name).font(.system(size: 15, weight: .semibold)).foregroundStyle(p.textPrimary)
Text("\(cred.kindLabel) · \(cred.username)")
.font(TX.Font.mono(12)).foregroundStyle(p.textSecondary)
}
Spacer()
}
.padding(TX.Space.m)
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1))
}
}
/// 凭据编辑页:类型(密码 / SSH 密钥 / Face ID);SSH 密钥支持 粘贴 / 导入 / 生成。
struct CredentialEditorView: View {
let existing: Credential?
@ObservedObject var store: FileCredentialStore
let onSave: (Credential) -> Void
@EnvironmentObject var theme: TXThemeManager
@Environment(\.dismiss) private var dismiss
private enum Kind: Hashable { case password, sshKey, faceID }
private enum KeySource: Hashable { case generate, paste, importFile }
@State private var name = ""
@State private var username = ""
@State private var kind: Kind = .password
@State private var password = ""
@State private var keySource: KeySource = .generate
@State private var keyText = ""
@State private var passphrase = ""
@State private var parsed: CredentialAuth.ParsedKey?
// 生成结果(暂存直到保存)
@State private var generatedKind: Credential.Kind?
@State private var generatedSecret: CredentialSecret?
@State private var generatedFingerprint = ""
@State private var generatedAuthLine = ""
@State private var showFileImporter = false
@State private var importError: String?
private let seAvailable = SigningKeyProvider.secureEnclaveAvailable()
private let isEditing: Bool
private var p: TXPalette { theme.palette }
init(existing: Credential?, store: FileCredentialStore, onSave: @escaping (Credential) -> Void) {
self.existing = existing
self.store = store
self.onSave = onSave
self.isEditing = existing != nil
}
private var canSave: Bool {
guard !username.isEmpty else { return false }
switch kind {
case .password: return true
case .sshKey:
if isEditing { return true } // 编辑时允许只改名/用户名
switch keySource {
case .generate: return generatedSecret != nil
case .paste, .importFile: return !keyText.isEmpty
}
case .faceID: return false
}
}
var body: some View {
NavigationStack {
ZStack {
p.bg.ignoresSafeArea()
Form {
Section("基本") {
TXTextField(title: "名称", text: $name, placeholder: "我的密钥")
TXTextField(title: "用户名", text: $username, placeholder: "登录服务器的用户名")
}
Section("类型") {
Picker("类型", selection: $kind) {
Text("密码").tag(Kind.password)
Text("SSH 密钥").tag(Kind.sshKey)
Text("Face ID").tag(Kind.faceID)
}
.pickerStyle(.segmented)
.txRow(p)
}
switch kind {
case .password: passwordSection
case .sshKey: sshKeySection
case .faceID: faceIDSection
}
}
.scrollContentBackground(.hidden)
}
.navigationTitle(isEditing ? "编辑凭据" : "添加凭据")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }.tint(p.textSecondary)
}
ToolbarItem(placement: .confirmationAction) {
Button("保存") { save() }.disabled(!canSave).tint(p.accent)
}
}
.toolbarBackground(p.bg, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.data, .text]) { result in
handleImport(result)
}
.onAppear(perform: populate)
}
.tint(p.accent)
}
// MARK: - 各类型区块
@ViewBuilder private var passwordSection: some View {
Section("密码") {
TXTextField(title: "密码", text: $password, secure: true)
}
}
@ViewBuilder private var sshKeySection: some View {
Section("SSH 私钥") {
Picker("来源", selection: $keySource) {
Text("生成").tag(KeySource.generate)
Text("粘贴").tag(KeySource.paste)
Text("导入").tag(KeySource.importFile)
}
.pickerStyle(.segmented)
.txRow(p)
switch keySource {
case .generate: generateContent
case .paste: pasteContent
case .importFile: importContent
}
}
}
@ViewBuilder private var generateContent: some View {
if generatedSecret == nil {
Button {
generate()
} label: {
Label("生成 ECDSA P-256 密钥", systemImage: "sparkles")
}
.txRow(p)
} else {
VStack(alignment: .leading, spacing: 6) {
Text("已生成 ECDSA P-256").font(.system(size: 13, weight: .medium)).foregroundStyle(p.accent)
Text("公钥指纹:\(generatedFingerprint)").font(TX.Font.mono(11)).foregroundStyle(p.textSecondary)
Text("authorized_keys(复制到服务器):").font(.system(size: 12)).foregroundStyle(p.textSecondary)
Text(generatedAuthLine).font(TX.Font.mono(10)).foregroundStyle(p.textPrimary)
.textSelection(.enabled).lineLimit(3)
Button { UIPasteboard.general.string = generatedAuthLine } label: {
Label("复制 authorized_keys 行", systemImage: "doc.on.doc")
}
.font(.system(size: 13)).padding(.top, 4)
}
.txRow(p)
}
}
@ViewBuilder private var pasteContent: some View {
VStack(alignment: .leading, spacing: 6) {
Text("粘贴 PEM / PKCS#8 私钥内容").font(.system(size: 12)).foregroundStyle(p.textSecondary)
TextEditor(text: $keyText)
.font(TX.Font.mono(11)).frame(minHeight: 120)
.foregroundStyle(p.textPrimary).scrollContentBackground(.hidden)
.background(p.bg, in: RoundedRectangle(cornerRadius: 8))
.onChange(of: keyText) { _, t in parsed = CredentialAuth.parsePrivateKey(t, source: .pasted) }
detectionLabel
}
.txRow(p)
if parsedNeedsPassphrase {
TXTextField(title: "私钥口令(passphrase)", text: $passphrase, secure: true)
}
}
@ViewBuilder private var importContent: some View {
VStack(alignment: .leading, spacing: 6) {
Button { showFileImporter = true } label: {
Label("从文件导入私钥", systemImage: "folder")
}
if let e = importError {
Text(e).font(.system(size: 12)).foregroundStyle(.red)
}
if !keyText.isEmpty { detectionLabel }
}
.txRow(p)
if parsedNeedsPassphrase {
TXTextField(title: "私钥口令(passphrase)", text: $passphrase, secure: true)
}
}
@ViewBuilder private var faceIDSection: some View {
Section {
HStack {
Image(systemName: "faceid").foregroundStyle(seAvailable ? p.accent : p.textTertiary)
Text(seAvailable ? "Face ID 密钥(Secure Enclave)" : "Face ID 密钥(需签名构建)")
.foregroundStyle(seAvailable ? p.textPrimary : p.textTertiary)
}
.txRow(p)
} footer: {
Text(seAvailable
? "私钥生成于 Secure Enclave,不可导出,签名时经 Face ID 授权。"
: "当前构建未签名,Secure Enclave 不可用。签名真机构建后此选项自动可用。")
}
}
@ViewBuilder private var detectionLabel: some View {
if let parsed {
VStack(alignment: .leading, spacing: 3) {
Text("检测到:\(parsed.formatLabel)").font(.system(size: 12)).foregroundStyle(p.accent)
if let w = parsed.warning {
Text(w).font(.system(size: 11)).foregroundStyle(p.warning)
}
}
}
}
private var parsedNeedsPassphrase: Bool {
if case .privateKey(_, _, let hasPass, _) = parsed?.kind { return hasPass }
return false
}
// MARK: - 动作
private func generate() {
guard let (k, s) = CredentialAuth.generateECDSAP256() else { return }
generatedKind = k; generatedSecret = s
if case .privateKey(_, _, _, let blob) = k {
generatedAuthLine = "ecdsa-sha2-nistp256 \(blob.base64EncodedString()) \(username.isEmpty ? "terminalx" : username)@terminalx"
generatedFingerprint = HostKey.opensshFingerprint(blob)
}
}
private func handleImport(_ result: Result<URL, Error>) {
switch result {
case .failure(let e): importError = e.localizedDescription
case .success(let url):
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
guard let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) else {
importError = "无法读取文件(需 UTF-8 文本私钥)"; return
}
importError = nil
keyText = text
parsed = CredentialAuth.parsePrivateKey(text, source: .imported)
}
}
private func populate() {
guard let c = existing else { return }
name = c.name; username = c.username
switch c.kind {
case .password:
kind = .password
if case .password(let pw)? = store.secret(for: c.id) { password = pw }
case .privateKey:
kind = .sshKey; keySource = .generate // 编辑既有密钥仅改名/用户名
case .secureEnclave:
kind = .faceID
}
}
private func save() {
let credID = existing?.id ?? UUID()
var credKind: Credential.Kind
var secret: CredentialSecret?
switch kind {
case .password:
credKind = .password
secret = .password(password)
case .sshKey:
if isEditing, let c = existing, case .privateKey = c.kind {
credKind = c.kind; secret = nil // 保留原机密,仅改 meta
} else if keySource == .generate, let gk = generatedKind, let gs = generatedSecret {
credKind = gk; secret = gs
} else {
let parsedKey = parsed ?? CredentialAuth.parsePrivateKey(keyText, source: keySource == .paste ? .pasted : .imported)
// 注入用户填写的 passphrase
if case .privateKey(let t, let src, _, let blob) = parsedKey.kind {
credKind = .privateKey(keyType: t, source: src, hasPassphrase: !passphrase.isEmpty, publicKeyBlob: blob)
} else {
credKind = parsedKey.kind
}
secret = .pemPrivateKey(pem: Data(keyText.utf8), passphrase: passphrase.isEmpty ? nil : passphrase)
}
case .faceID:
return
}
let cred = Credential(id: credID, name: name.isEmpty ? username : name,
username: username, kind: credKind,
createdAt: existing?.createdAt ?? Date())
store.upsert(cred, secret: secret)
onSave(cred)
dismiss()
}
}

View File

@@ -0,0 +1,558 @@
import SwiftUI
// 侧边栏两态(收起 56pt 轨道 ⇄ 固定展开 288pt)的**唯一**视图。
//
// 动画模型(关键,别再拆回"轨道 + 面板"两套视图):
// 内容永远按 288pt 全宽排版;收起只是把容器宽度收到 railWidth 并 `clipped()`
// —— 「收起 = 展开在左侧裁切一部分」的字面实现。
// 最左 railWidth 是**图标列**:logo(展开键) / 搜索 / 首页 / 头像 / window 徽标 /
// 状态点 / 齿轮在两态里**同尺寸同位置**,宽度动画时纹丝不动;右侧明细
// (标题、副行、快捷键、选中底)随展开淡入、随收起淡出并被裁掉。
// 视图直接参与布局(无浮层/遮罩):收起时明细淡出、终端从右侧盖上来,展开反向
// —— 用户定稿就要这个效果。开合入口:Logo 位(收起态=»)与头部收起键(«)。
//
// **命中裁剪**:`clipped()` 只裁绘制不裁触摸 —— 整行按钮(首页/会话/window 行)在
// 收起态会把不可见的明细区伸进终端里抢点击。所以每个整行按钮都用 `LeadingHitShape`
// 把命中区限制在当前可见宽度内;纯明细控件(搜索框、Pin、新建 window)额外
// `allowsHitTesting(expanded)`。
/// 整行按钮的命中形状:只有左起 `width` 宽度可点(随开合动画走)。
private struct LeadingHitShape: Shape {
var width: CGFloat
var animatableData: CGFloat {
get { width }
set { width = newValue }
}
func path(in rect: CGRect) -> Path {
Path(CGRect(x: 0, y: 0, width: width, height: rect.height))
}
}
struct TerminalSidebar: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
/// 当前会话(tmux 段列它的 window)。
@ObservedObject var session: TerminalSession
@ObservedObject var tailscaleStore: TailscaleStore
/// 收起态宽度(竖屏 48)。图标列宽 = 这个值,两态图标中心线才重合。
let railWidth: CGFloat
/// home indicator 安全区(沉浸页铺满物理屏,由底栏自己让位)。
let safeBottom: CGFloat
let onNewHost: () -> Void
let onSettings: () -> Void
@Binding var search: String
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var expanded: Bool { router.sidebar.isExpanded }
private var currentWidth: CGFloat { expanded ? TX.Layout.sidebarWidth : railWidth }
var body: some View {
content
.frame(width: TX.Layout.sidebarWidth, alignment: .leading)
.background(p.surfaceDark)
.frame(width: currentWidth, alignment: .leading)
.clipped()
}
private var content: some View {
VStack(alignment: .leading, spacing: 0) {
header
searchRow
.padding(.bottom, TX.Space.s)
homeRow
sectionRow(label: "活动会话", trailing: "\(manager.sessions.count)")
sessionRows
newHostRow
tmuxDivider
tmuxLabelRow
tmuxRows
Spacer(minLength: TX.Space.s)
tailscaleRow
footRow
}
}
// MARK: - 头部
/// Logo 位是展开的**主入口**(用户定稿:将来 App Logo 设计与展开按钮一体):
/// 收起态显展开键(»),展开态还原成 Logo(`›`,不可点)。两者同框同位交叉淡化。
private var header: some View {
HStack(spacing: 0) {
ZStack {
logoBox { Image(systemName: "chevron.right.2").font(.system(size: 13, weight: .semibold)) }
.opacity(expanded ? 0 : 1)
logoBox { Text("›").font(TX.Font.mono(15, .bold)) }
.opacity(expanded ? 1 : 0)
}
.frame(width: railWidth)
.contentShape(Rectangle())
.onTapGesture { if !expanded { router.toggleSidebar() } }
.accessibilityLabel(expanded ? "terminalX" : "展开侧边栏")
.accessibilityAddTraits(expanded ? [] : .isButton)
HStack(spacing: TX.Space.s) {
Text("terminalX")
.font(TX.Font.mono(14, .medium))
.foregroundStyle(p.text)
Spacer(minLength: 0)
TXIconButton(icon: "chevron.left.2", size: 28, label: "收起侧边栏") {
router.toggleSidebar()
}
}
.padding(.trailing, TX.Space.s)
.opacity(expanded ? 1 : 0)
.allowsHitTesting(expanded)
}
.padding(.top, 14)
.padding(.bottom, 10)
}
/// Logo / 展开键共用的 30×30 方框(同框同位,只换内容物)。
private func logoBox(@ViewBuilder _ inner: () -> some View) -> some View {
inner()
.foregroundStyle(TXAccent.text)
.frame(width: 30, height: 30)
.background(p.surface, in: RoundedRectangle(cornerRadius: 9))
.overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.border, lineWidth: 1))
}
/// 搜索:收起 = 裸图标(点击展开去搜),展开 = 输入框。字段的放大镜与图标列中心
/// 几乎重合(12 + 10 + 6 ≈ railWidth/2),交叉淡化读作"图标长成了输入框"。
private var searchRow: some View {
ZStack(alignment: .leading) {
TXIconButton(icon: "magnifyingglass", bare: true, hitSize: 34, label: "搜索") {
router.toggleSidebar()
}
.frame(width: railWidth)
.opacity(expanded ? 0 : 1)
.allowsHitTesting(!expanded)
TXSearchField(text: $search, placeholder: "搜索主机 · 会话")
.padding(.leading, 12)
.padding(.trailing, TX.Space.m)
.opacity(expanded ? 1 : 0)
.allowsHitTesting(expanded)
}
}
private var homeRow: some View {
Button { router.goHome() } label: {
HStack(spacing: 0) {
Image(systemName: "house")
.font(.system(size: 17))
.foregroundStyle(p.text3)
.frame(width: railWidth)
HStack {
Text("首页")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Spacer(minLength: 0)
Text("⌘⇧H")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
}
.padding(.trailing, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.frame(height: 36)
.contentShape(LeadingHitShape(width: currentWidth))
}
.buttonStyle(.txPress)
.accessibilityLabel("首页")
}
// MARK: - 分组标题 ⇄ 轨道短分隔线(同一行的两种密度)
/// 展开 = 分组小标题;收起 = 图标列里 24pt 短分隔线。语义同为"分组",交叉淡化。
private func sectionRow(label: String, trailing: String? = nil) -> some View {
ZStack(alignment: .leading) {
shortDivider
.frame(width: railWidth)
.opacity(expanded ? 0 : 1)
TXSectionLabel(text: label, trailing: trailing)
.padding(.horizontal, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.frame(height: 26)
}
private var shortDivider: some View {
Rectangle()
.fill(p.divider)
.frame(width: 24, height: 1)
}
// MARK: - 会话
@ViewBuilder private var sessionRows: some View {
if manager.activeSessions.isEmpty {
Text("没有在跑的会话")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.padding(.leading, railWidth)
.frame(height: 32, alignment: .leading)
.opacity(expanded ? 1 : 0)
}
ForEach(manager.activeSessions) { s in
SidebarSessionRow(session: s,
isSelected: router.route.sessionID == s.id,
expanded: expanded, railWidth: railWidth,
hitWidth: currentWidth) {
manager.focus(s)
router.enter(s.id)
}
}
}
/// 虚线 + 号:在当前主机之外新开会话(两态都可点)。
private var newHostRow: some View {
Button(action: onNewHost) {
HStack(spacing: 0) {
Image(systemName: "plus")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(p.textWeak)
.frame(width: 30, height: 30)
.background(
RoundedRectangle(cornerRadius: 9)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
.foregroundStyle(p.border)
)
.frame(width: railWidth)
Text("连接新主机…")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
.opacity(expanded ? 1 : 0)
Spacer(minLength: 0)
}
.frame(height: 40)
.contentShape(LeadingHitShape(width: currentWidth))
}
.buttonStyle(.txPress)
.accessibilityLabel("连接新主机")
}
// MARK: - tmux 段
/// 展开 = 渐隐分隔(设计 3a);收起 = 图标列短线。
private var tmuxDivider: some View {
ZStack(alignment: .leading) {
shortDivider
.frame(width: railWidth)
.opacity(expanded ? 0 : 1)
LinearGradient(colors: [p.divider.opacity(0), p.divider, p.divider.opacity(0)],
startPoint: .leading, endPoint: .trailing)
.frame(height: 1)
.padding(.horizontal, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.padding(.vertical, TX.Space.s)
}
/// 收起 = 图标列里的 accent 迷你标签(拼写按 TXTech 规范:tmux 小写);
/// 展开 = 完整分组标题(tmux · 主机 · 会话名)。
private var tmuxLabelRow: some View {
ZStack(alignment: .leading) {
Text(session.tmuxSummary == nil ? "窗口" : "tmux")
.font(TX.Font.mono(9, .semibold))
.tracking(0.9)
.foregroundStyle(TXAccent.mid)
.frame(width: railWidth)
.opacity(expanded ? 0 : 1)
TXSectionLabel(text: tmuxHeader)
.padding(.horizontal, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.frame(height: 22)
.padding(.bottom, 4)
}
@ViewBuilder private var tmuxRows: some View {
if let tmux = session.tmuxController {
ForEach(tmux.windows) { win in
SidebarWindowRow(window: win,
isSelected: win.id == tmux.activeWindowID,
expanded: expanded, railWidth: railWidth,
hitWidth: currentWidth) {
tmux.selectWindow(win.id)
}
}
newWindowRow { tmux.newWindow() }
} else if let summary = session.tmuxSummary {
// 已知是 tmux 会话但 controller 还没 attach:按概览占位,保持两态节奏稳定。
ForEach(0 ..< max(1, summary.windows), id: \.self) { i in
WindowRowBody(
index: "\(i)",
title: i == 0 && !summary.currentTitle.isEmpty ? summary.currentTitle : "window \(i)",
subtitle: i == 0 && summary.panes > 0
? "\(summary.panes) pane\(summary.panes == 1 ? "" : "s")" : "1 pane",
isSelected: i == 0,
expanded: expanded, railWidth: railWidth)
}
newWindowRow {}
} else {
Text("无 tmux · 客户端窗口")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.padding(.leading, railWidth)
.frame(height: 32, alignment: .leading)
.opacity(expanded ? 1 : 0)
}
}
/// 「新建 window ⌘T」:纯明细行 —— 收起态整行淡出但保留高度,两态节奏一致。
private func newWindowRow(_ action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 0) {
Color.clear.frame(width: railWidth, height: 1)
Text("新建 window")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
Spacer(minLength: 0)
Text("⌘T")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
.padding(.trailing, TX.Space.m)
}
.frame(height: 32)
}
.buttonStyle(.txPress)
.opacity(expanded ? 1 : 0)
.allowsHitTesting(expanded)
}
private var tmuxHeader: String {
let host = session.displayName
if let t = session.tmuxSummary, !t.name.isEmpty { return "tmux · \(host) · \(t.name)" }
return session.tmuxSummary == nil ? "窗口 · \(host)" : "tmux · \(host)"
}
// MARK: - 底部(自上而下:tailnet 状态 · 分割线 + 齿轮)
/// tailnet 状态:点在图标列(两态可见),文字是明细。
private var tailscaleRow: some View {
HStack(spacing: 0) {
TXStatusDot(color: tailscaleStore.connections.isEmpty ? p.offline : p.online,
punchOut: p.surfaceDark)
.frame(width: railWidth)
VStack(alignment: .leading, spacing: 1) {
Text(tailscaleStore.connections.isEmpty ? "Tailscale 未配置" : "Tailscale 已连接")
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(p.text2)
if let c = tailscaleStore.connections.first {
Text([c.hostname, c.lastSelfIP].compactMap { $0 }.joined(separator: " · "))
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
}
.padding(.trailing, TX.Space.m)
.opacity(expanded ? 1 : 0)
Spacer(minLength: 0)
}
.frame(height: 40)
}
/// 底部带:分割线与舞台提示条的分割线同一条 y(都在 footBand 顶),齿轮居中在带内。
private var footRow: some View {
VStack(spacing: 0) {
Rectangle().fill(p.divider).frame(height: 1)
Button(action: onSettings) {
HStack(spacing: 0) {
Image(systemName: "gearshape")
.font(.system(size: 17))
.foregroundStyle(p.text3)
.frame(width: railWidth)
Text("设置")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
.opacity(expanded ? 1 : 0)
Spacer(minLength: 0)
}
.frame(height: TX.Layout.footBand(safeBottom))
.contentShape(LeadingHitShape(width: currentWidth))
}
.buttonStyle(.txPress)
.accessibilityLabel("设置")
}
}
}
/// 轨道与面板共用的状态色映射。
@MainActor
enum RailSidebarStyle {
static func statusColor(_ s: TerminalSession, _ p: TXPalette) -> Color {
switch s.link {
case .direct, .relay: s.isMinimized ? TXAccent.mid : p.online
case .reconnecting: p.reconnecting
case .connecting: p.text4
case .offline: p.offline
}
}
}
// MARK: - 行
/// 会话行:图标列 30pt 头像(两态同位)+ 明细(名称·window title + 链路标)。
/// 选中底随明细淡入淡出 —— 收起态由头像自身的选中样式表达,避免高亮块被裁出直边。
struct SidebarSessionRow: View {
@ObservedObject var session: TerminalSession
let isSelected: Bool
let expanded: Bool
let railWidth: CGFloat
let hitWidth: CGFloat
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Button(action: onTap) {
HStack(spacing: 0) {
TXInitialsAvatar(initials: session.initials, size: 30,
isSelected: isSelected,
status: RailSidebarStyle.statusColor(session, p),
punchOut: isSelected && expanded ? TXAccent.selectedBg : p.surfaceDark)
.frame(width: railWidth)
HStack(spacing: TX.Space.s) {
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
.lineLimit(1)
Text(subtitle)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
Spacer(minLength: TX.Space.xs)
trailingTag
}
.padding(.trailing, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.frame(height: 44)
.background(
RoundedRectangle(cornerRadius: TX.Radius.control)
.fill(TXAccent.selectedBg)
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(TXAccent.selectedStroke, lineWidth: 1)
)
.padding(.horizontal, TX.Space.xs)
.opacity(isSelected && expanded ? 1 : 0)
)
.contentShape(LeadingHitShape(width: hitWidth))
.animation(TX.Motion.quick, value: isSelected)
}
.buttonStyle(.txPress)
}
private var title: String {
if let t = session.tmuxSummary, !t.name.isEmpty {
return "\(session.displayName) · \(t.name)"
}
return session.displayName
}
private var subtitle: String {
if session.isMinimized { return "最小化中" }
guard let t = session.tmuxSummary else { return "原生窗口" }
let title = t.currentTitle.isEmpty ? session.phaseText : t.currentTitle
return "\(title) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
}
@ViewBuilder private var trailingTag: some View {
switch session.link {
case .reconnecting:
Text("重连中").font(TX.Font.mono(10.5, .medium)).foregroundStyle(p.reconnecting)
case .offline:
Text("离线").font(TX.Font.mono(10.5)).foregroundStyle(p.offline)
default:
EmptyView()
}
}
}
/// window 行:图标列 26×22 编号徽标(两态同位,选中态徽标自己高亮)+ 明细(title + cwd/panes)。
struct SidebarWindowRow: View {
@ObservedObject var window: TmuxWindow
let isSelected: Bool
let expanded: Bool
let railWidth: CGFloat
let hitWidth: CGFloat
let onTap: () -> Void
private var subtitle: String {
var parts: [String] = []
if !window.cwd.isEmpty { parts.append(TerminalTitleCapsule.abbreviate(window.cwd)) }
let n = window.panes.count
if n > 1 { parts.append("\(n) panes") }
return parts.isEmpty ? "1 pane" : parts.joined(separator: " · ")
}
var body: some View {
Button(action: onTap) {
WindowRowBody(index: "\(window.index)", title: window.title, subtitle: subtitle,
isSelected: isSelected, expanded: expanded, railWidth: railWidth)
.contentShape(LeadingHitShape(width: hitWidth))
}
.buttonStyle(.txPress)
}
}
/// window 行的视觉本体(SidebarWindowRow 与占位行共用)。
struct WindowRowBody: View {
let index: String
let title: String
let subtitle: String
let isSelected: Bool
let expanded: Bool
let railWidth: CGFloat
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: 0) {
Text(index)
.font(TX.Font.mono(11, .semibold))
.foregroundStyle(isSelected ? TXAccent.text : p.text4)
.frame(width: 26, height: 22)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: 5))
.overlay(
RoundedRectangle(cornerRadius: 5)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.frame(width: railWidth)
HStack(spacing: TX.Space.s) {
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(TX.Font.mono(13, isSelected ? .medium : .regular))
.foregroundStyle(isSelected ? p.text : p.text2)
.lineLimit(1)
Text(subtitle)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
Spacer(minLength: 0)
if isSelected {
TXStatusDot(color: p.online, size: 7)
}
}
.padding(.trailing, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.frame(height: 44)
.background(
RoundedRectangle(cornerRadius: TX.Radius.control)
.fill(TXAccent.selectedBg.opacity(0.75))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(TXAccent.selectedStroke, lineWidth: 1)
)
.padding(.horizontal, TX.Space.xs)
.opacity(isSelected && expanded ? 1 : 0)
)
.animation(TX.Motion.quick, value: isSelected)
}
}

View File

@@ -1,633 +0,0 @@
import Foundation
import GhosttyTerminal
import TXCore
import TXTransport
import TsnetBridge
#if canImport(UIKit)
import UIKit
#endif
enum TsnetError: Error { case newNodeFailed, notUp }
/// 主 tsnet 节点管理器(线程安全,供 SSHTerminalModel 在后台线程 up/dial)。
/// up 后节点常驻,重连时复用(只重新 dial fd,不重新加入 tailnet)。
final class TsnetManager: @unchecked Sendable {
private let lock = NSLock()
private var node: TsnetbridgeNode?
private let stateDir: String
init() {
let base = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0]
stateDir = base + "/tsnet-main"
try? FileManager.default.createDirectory(atPath: stateDir, withIntermediateDirectories: true)
}
/// 确保节点已 up(幂等)。阻塞,需后台线程调用。
func ensureUp(authKey: String, timeoutMs: Int) throws {
lock.lock(); let existing = node; lock.unlock()
if existing != nil { return }
guard let n = TsnetbridgeNewNode(stateDir, "terminalx-ipad") else { throw TsnetError.newNodeFailed }
try n.up(withAuthKey: authKey, timeoutMs: timeoutMs)
lock.lock(); node = n; lock.unlock()
}
/// 经 tsnet dial tailnet 内 host:port,返回已连接 fd。阻塞,需后台线程调用。
/// 带重试:tsnet 刚 Up 后到 peer 的路径(DERP/直连打洞)预热期间首次 dial 可能 i/o timeout,
/// 短退避重试若干次直到路径就绪。
func dialFD(host: String, port: Int, timeoutMs: Int) throws -> Int32 {
lock.lock(); let n = node; lock.unlock()
guard let n else { throw TsnetError.notUp }
var lastError: Error?
for attempt in 0 ..< 6 {
do {
var fd: Int64 = -1
try n.dialTCPFD(host, port: port, timeoutMs: 8000, ret0_: &fd)
return Int32(fd)
} catch {
lastError = error
if attempt < 5 { Thread.sleep(forTimeInterval: 2.0) } // 后台线程,路径预热
}
}
throw lastError ?? TsnetError.notUp
}
/// 为 mosh 建立 UDP relay:loopback ↔ tsnet(host:moshPort)。返回句柄(含 localPort)。
/// 阻塞(tsnet UDP Dial),需后台线程调用。
func startMoshRelay(host: String, moshPort: Int, timeoutMs: Int) throws -> TsnetbridgeMoshRelay {
lock.lock(); let n = node; lock.unlock()
guard let n else { throw TsnetError.notUp }
return try n.startMoshRelay(host, moshPort: moshPort, timeoutMs: timeoutMs)
}
/// M3:前台恢复时唤醒 tsnet 网络层(注入链路事件 + Rebind/重新 STUN)。可能阻塞,后台线程调用。
func wakeUp() {
lock.lock(); let n = node; lock.unlock()
n?.wakeUp()
}
}
/// 线程安全地持有 transport 引用,供 libghostty 的输入/resize 回调(非主线程)安全取用。
final class TransportHolder: @unchecked Sendable {
private let lock = NSLock()
private var value: Transport?
var transport: Transport? {
get { lock.lock(); defer { lock.unlock() }; return value }
set { lock.lock(); value = newValue; lock.unlock() }
}
}
/// 线程安全地持有 mosh 会话引用;非 nil 时终端输入/resize 路由到 mosh 而非 SSH transport。
final class MoshHolder: @unchecked Sendable {
private let lock = NSLock()
private var value: MoshSession?
var session: MoshSession? {
get { lock.lock(); defer { lock.unlock() }; return value }
set { lock.lock(); value = newValue; lock.unlock() }
}
}
/// 输出闸门:surface 未 attach 时 receive 会丢弃写入,故未就绪先缓冲、就绪后按序 flush(修白屏)。
final class OutputGate: @unchecked Sendable {
private let lock = NSLock()
private let session: InMemoryTerminalSession
private var ready = false
private var pending = Data()
init(session: InMemoryTerminalSession) { self.session = session }
func deliver(_ data: Data) {
lock.lock()
if ready { lock.unlock(); session.receive(data) }
else { pending.append(data); lock.unlock() }
}
func markReady() {
lock.lock()
if ready { lock.unlock(); return }
ready = true
let buffered = pending; pending = Data()
lock.unlock()
if !buffered.isEmpty { session.receive(buffered) }
}
}
/// tmux 网关字节的有序事件(enter 携初始字节 / bytes 后续)。经单一 AsyncStream 传到主线程,
/// 杜绝"每 chunk 起独立 Task"导致的乱序(多 pane 高吞吐会打碎 % 协议解析)。
enum TmuxByteEvent: Sendable { case enter([UInt8]); case bytes([UInt8]) }
/// 线程安全承载 tmux 字节通道的 continuation(传输后台线程 yield,主线程单消费者取用)。
final class TmuxByteChannel: @unchecked Sendable {
let stream: AsyncStream<TmuxByteEvent>
private let cont: AsyncStream<TmuxByteEvent>.Continuation
init() { (stream, cont) = AsyncStream.makeStream() }
func enter(_ b: [UInt8]) { cont.yield(.enter(b)) }
func bytes(_ b: [UInt8]) { cont.yield(.bytes(b)) }
}
/// 线程安全记录全屏终端格子数 + cell 像素尺寸(tmux attach/resize 时据此换算 `refresh-client -C`)。
final class GridBox: @unchecked Sendable {
private let lock = NSLock()
private var cols = 80, rows = 24, cellW = 0, cellH = 0
var value: (cols: Int, rows: Int, cellW: Int, cellH: Int) {
lock.lock(); defer { lock.unlock() }; return (cols, rows, cellW, cellH)
}
func set(cols: Int, rows: Int, cellW: Int, cellH: Int) {
lock.lock()
self.cols = max(1, cols); self.rows = max(1, rows)
if cellW > 0 { self.cellW = cellW } // cell 尺寸稳定,保留最后有效值
if cellH > 0 { self.cellH = cellH }
lock.unlock()
}
}
/// M4 host key 不符详情(供 SwiftUI alert 并排显示旧/新指纹 + 用户抉择)。
struct HostKeyMismatchInfo: Identifiable {
let id = UUID()
let egress: String, host: String, port: Int
let keyType: Int32
let presentedBlob: Data
let presentedFp: String
let storedFp: String
}
/// M0 SSH 终端会话模型:SSHSession(libssh2) ⇄ GhosttyKit in-memory 终端,
/// 由 TXCore.SessionMachine 编排生命周期(断线指数退避自动重连、后台冻结/前台恢复)。
@MainActor
final class SSHTerminalModel: ObservableObject {
let state: TerminalViewState
let session: InMemoryTerminalSession
private let holder: TransportHolder
private let gate: OutputGate
/// 是否展示终端页(连接后保持,重连/后台期间不退回表单)。
@Published var showsTerminal = false
@Published var banner: String = ""
@Published var phaseText: String = "空闲"
/// 非 nil 时进入 tmux control-mode(原生 tab)。
@Published var tmuxController: TmuxController?
/// M4:host key 与 pin 不符 → 非 nil 触发 SwiftUI alert(信任新密钥 / 取消)。
@Published var hostKeyMismatch: HostKeyMismatchInfo?
private let knownHosts = KeychainKnownHostsStore()
private var machine = SessionMachine()
private var config: SSHConfig?
private var reconnectTask: Task<Void, Never>?
private var autoCommand: String?
/// tsnet 模式:非 nil 时经 tsnet fd 桥连接(而非系统直连),重连复用同一节点。
private var tsnetAuthKey: String?
private let tsnetMgr = TsnetManager()
/// M2 mosh 模式:SSH 登录后跑 mosh-server,解析 MOSH CONNECT,改由 mosh(UDP over tsnet relay) 承载会话。
private var moshMode = false
private var moshServerCmd = "mosh-server new -s -c 256 -l LANG=en_US.UTF-8 -l LC_ALL=en_US.UTF-8"
private let moshHolder: MoshHolder
private var moshSession: MoshSession?
private var moshRelay: TsnetbridgeMoshRelay?
private var moshScanner = MoshConnectScanner()
private var moshBootstrapSent = false
// M3 前台恢复:基线时刻 + healthy 轮询 + 恢复看门狗 + 后台任务。
private var moshResumeBaseMs: UInt64 = 0
private var moshHealthyPoll: Task<Void, Never>?
private var resumeWatchdog: Task<Void, Never>?
#if canImport(UIKit)
private var bgTask: UIBackgroundTaskIdentifier = .invalid
#endif
private let tmuxChannel = TmuxByteChannel()
/// 全屏终端格子数(raw 模式 surface resize 时记录;tmux attach 用于 refresh-client -C)。
let screenGrid = GridBox()
init() {
let holder = TransportHolder()
let moshHolder = MoshHolder()
let grid = screenGrid
let session = InMemoryTerminalSession(
// 输入/resize:mosh 激活时路由到 mosh,否则走 SSH transport。
write: { data in
if let m = moshHolder.session { m.send([UInt8](data)) }
else { holder.transport?.send(data) }
},
resize: { vp in
// 记录全屏格子 + cell 像素(tmux resize 换算用)。
grid.set(cols: Int(vp.columns), rows: Int(vp.rows),
cellW: Int(vp.cellWidthPixels), cellH: Int(vp.cellHeightPixels))
if let m = moshHolder.session { m.resize(cols: Int(vp.columns), rows: Int(vp.rows)) }
else { holder.transport?.resize(cols: vp.columns, rows: vp.rows) }
}
)
let state = TerminalViewState()
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
self.holder = holder
self.moshHolder = moshHolder
self.session = session
self.state = state
self.gate = OutputGate(session: session)
// tmux 网关字节:单消费者按序处理(enter 建 controller+喂初始,bytes 续喂)。
let ch = tmuxChannel
Task { @MainActor [weak self] in
for await ev in ch.stream {
guard let self else { continue }
switch ev {
case .enter(let initial): self.enterTmux(initialBytes: initial)
case .bytes(let b): self.tmuxController?.feed(Data(b))
}
}
}
}
// MARK: - 公开入口
/// M4b:非 nil 时用 publickey(SE/软件签名器) 认证而非密码。
private var pubkeySigner: SigningKeyProvider?
func connect(host: String, port: Int, username: String, password: String) {
let egress = tsnetAuthKey != nil ? "tsnet" : "direct"
let auth: SSHConfig.Authentication = pubkeySigner.map { .publicKeyCallback($0) } ?? .password(password)
config = SSHConfig(host: host, port: port, username: username,
authentication: auth,
hostKeyVerifier: makeHostKeyVerifier(egress: egress, host: host, port: port))
run(machine.reduce(.connectRequested))
}
/// M4:构造 host key 校验器(TOFU)。在 ssh 串行队列同步调用;firstUse 自动 pin + 横幅,
/// mismatch 冒泡到主线程触发 alert 并返回 false(SSHSession 会断开重试)。
private func makeHostKeyVerifier(egress: String, host: String, port: Int) -> @Sendable (Data, Int32) -> Bool {
let store = knownHosts
let triple = HostTriple(egress: egress, host: host, port: port)
return { [weak self] blob, keyType in
switch HostKey.evaluate(stored: store.lookup(triple)?.blob, presented: blob) {
case .firstUse:
store.pin(triple, record: HostKeyRecord(blob: blob, keyType: keyType))
let fp = HostKey.opensshFingerprint(blob)
Task { @MainActor in self?.banner = "已记住 host key \(fp)" }
return true
case .trusted:
return true
case .mismatch(let stored):
let info = HostKeyMismatchInfo(
egress: egress, host: host, port: port, keyType: keyType,
presentedBlob: blob, presentedFp: HostKey.opensshFingerprint(blob),
storedFp: HostKey.opensshFingerprint(stored))
Task { @MainActor in self?.hostKeyMismatch = info }
return false
}
}
}
/// 用户确认信任新 host key → 覆盖 pin 并重连。
func trustNewHostKey() {
guard let m = hostKeyMismatch else { return }
knownHosts.pin(HostTriple(egress: m.egress, host: m.host, port: m.port),
record: HostKeyRecord(blob: m.presentedBlob, keyType: m.keyType))
hostKeyMismatch = nil
run(machine.reduce(.retryRequested)) // failed → connecting(verifier 此时将判定 trusted)
}
/// 用户取消 → 保持断开。
func dismissHostKeyMismatch() { hostKeyMismatch = nil }
func close() { run(machine.reduce(.closeRequested)) }
func enterBackground() {
if moshMode { beginBgTask() } // 争取 ~30s 后台执行,mosh 每 3s ack 保活 NAT/WG/DERP
run(machine.reduce(.enteredBackground))
}
func enterForeground() {
endBgTask()
run(machine.reduce(.enteredForeground))
}
/// TerminalScreen 在 surface 首次布局后调用:放行缓冲输出(+ 无头自动命令)。
func markSurfaceReady() {
gate.markReady()
if let cmd = autoCommand {
autoCommand = nil
let holder = self.holder
Task {
try? await Task.sleep(nanoseconds: 2_500_000_000)
holder.transport?.send(Data("\n".utf8))
try? await Task.sleep(nanoseconds: 900_000_000)
holder.transport?.send(Data((cmd + "\n").utf8))
}
}
}
/// 无头验证:从启动参数自动连接(simctl launch --args -txHost … -txAutoCommand …)。
func autoConnectIfConfigured() {
let d = UserDefaults.standard
guard let host = d.string(forKey: "txHost"), !host.isEmpty,
let user = d.string(forKey: "txUser") else { return }
let port = d.integer(forKey: "txPort")
autoCommand = d.string(forKey: "txAutoCommand")
if let key = d.string(forKey: "txTsnetKey"), !key.isEmpty { tsnetAuthKey = key }
if d.bool(forKey: "txMosh") {
moshMode = true
if let cmd = d.string(forKey: "txMoshServerCmd"), !cmd.isEmpty { moshServerCmd = cmd }
autoCommand = nil // mosh 模式用自己的引导流程,不用 txAutoCommand
}
let realPort = port == 0 ? 22 : port
// M4b:publickey 认证(SE/软件签名器)。日志打出 authorized_keys 行供加入服务器。
if d.bool(forKey: "txPubkeyAuth") {
pubkeySigner = SigningKeyProvider.loadOrCreate()
if let s = pubkeySigner {
NSLog("M4DBG authorized_keys hw=\(s.isHardwareBacked): \(s.authorizedKeysLine)")
}
}
// 无头验证:播种一个假 pin → 真实 host key 将 mismatch,触发 alert 路径。
if let b64 = d.string(forKey: "txHostKeyPinOverrideBase64"), let fake = Data(base64Encoded: b64) {
let egress = tsnetAuthKey != nil ? "tsnet" : "direct"
knownHosts.pin(HostTriple(egress: egress, host: host, port: realPort),
record: HostKeyRecord(blob: fake, keyType: 3))
}
connect(host: host, port: realPort,
username: user, password: d.string(forKey: "txPass") ?? "")
}
// MARK: - 状态机副作用执行
private func run(_ effects: [SessionMachine.Effect]) {
for effect in effects {
switch effect {
case .startConnect: startSession()
case .scheduleReconnect(_, let delayMS): scheduleReconnect(delayMS)
case .cancelReconnectTimer: reconnectTask?.cancel(); reconnectTask = nil
case .teardownTransport: holder.transport?.stop()
case .notify(let message): banner = message
case .nudgeResume: performWakePulse(); startHealthyPollIfNeeded()
case .scheduleResumeWatchdog(let ms): scheduleResumeWatchdog(ms)
case .cancelResumeWatchdog: cancelResumeWatchdog()
case .teardownMosh: teardownMosh()
}
}
syncUI()
}
private func startSession() {
guard let config else { return }
if let key = tsnetAuthKey {
// tsnet 模式:后台 up 节点 + dial fd,再用外部 fd 建 SSHSession。
let mgr = tsnetMgr
Task.detached {
do {
try mgr.ensureUp(authKey: key, timeoutMs: 45000)
let fd = try mgr.dialFD(host: config.host, port: config.port, timeoutMs: 15000)
await MainActor.run { self.wireAndStart(SSHSession(config: config, preconnectedFD: fd)) }
} catch {
await MainActor.run {
self.handleTransportState(.failed("tsnet: \(error.localizedDescription)"))
}
}
}
} else {
wireAndStart(SSHSession(config: config))
}
}
private func wireAndStart(_ ssh: SSHSession) {
exitTmux() // 新会话回原始模式(tmux 会话在服务端,重连后需重新 -CC attach)
let router = tmuxRouter
if moshMode {
// mosh 模式:SSH 字节喂给引导流程(既显示进度、又扫描 MOSH CONNECT)。
ssh.onBytes = { [weak self] data in
Task { @MainActor in self?.moshBootstrapFeed(data) }
}
} else {
ssh.onBytes = { data in router.feed(data) } // 经路由器分流 raw / tmux 网关
}
ssh.onState = { [weak self] newState in
Task { @MainActor in self?.handleTransportState(newState) }
}
holder.transport = ssh
ssh.start()
}
private lazy var tmuxRouter = TmuxRouter(
rawGate: gate,
// 经有序通道传主线程(enter 先于后续 bytes,单消费者保序)。
enterGateway: { [tmuxChannel] after in tmuxChannel.enter(after) },
gatewayBytes: { [tmuxChannel] bytes in tmuxChannel.bytes(bytes) }
)
private func enterTmux(initialBytes: [UInt8]) {
let holder = self.holder
let controller = TmuxController(sendRaw: { data in holder.transport?.send(data) })
controller.onExit = { [weak self] in Task { @MainActor in self?.exitTmux() } }
// 告知全屏格子数 + cell 像素 → attach 时 refresh-client -C、动态 resize 换算。
let g = screenGrid.value
controller.setClientSize(cols: g.cols, rows: g.rows)
controller.setCellPixels(w: g.cellW, h: g.cellH)
tmuxController = controller
if !initialBytes.isEmpty { controller.feed(Data(initialBytes)) }
}
private func exitTmux() {
if tmuxController != nil { tmuxController = nil }
tmuxRouter.reset()
}
// MARK: - M2 mosh 编排
/// SSH 已连接后引导 mosh-server(延迟等 shell 提示符,只发一次)。
private func scheduleMoshBootstrap() {
guard moshMode, !moshBootstrapSent, moshSession == nil else { return }
moshBootstrapSent = true
let holder = self.holder
let cmd = moshServerCmd
banner = "启动 mosh-server…"
Task {
try? await Task.sleep(nanoseconds: 2_500_000_000) // 等 shell 提示符就绪
holder.transport?.send(Data("\n".utf8))
try? await Task.sleep(nanoseconds: 800_000_000)
NSLog("MOSHDBG sent mosh-server cmd: \(cmd)")
holder.transport?.send(Data((cmd + "\n").utf8))
}
}
/// mosh 引导期消费 SSH 字节:显示进度 + 扫描 MOSH CONNECT,命中即切换到 mosh。
/// mosh 接管后忽略 SSH 残余字节,避免污染 mosh 的屏幕状态。
private func moshBootstrapFeed(_ data: Data) {
guard moshSession == nil else { return }
gate.deliver(data) // 让用户看到 mosh-server 引导输出
let text = String(decoding: data, as: UTF8.self)
if let info = moshScanner.feed(text) {
NSLog("MOSHDBG parsed MOSH CONNECT port=\(info.port) keyLen=\(info.key.count)")
launchMosh(info)
}
}
/// 拿到 MOSH CONNECT 后:tsnet 起 UDP relay(loopback:port) → 建 MoshSession → 接管会话。
private func launchMosh(_ info: MoshConnectInfo) {
guard moshSession == nil, let config else { return }
banner = "mosh 连接中(server port \(info.port))…"
if tsnetAuthKey != nil {
let mgr = tsnetMgr
let host = config.host
Task.detached {
do {
NSLog("MOSHDBG startMoshRelay host=\(host) moshPort=\(info.port)")
let relay = try mgr.startMoshRelay(host: host, moshPort: info.port, timeoutMs: 15000)
let localPort = relay.localPort()
NSLog("MOSHDBG relay up localPort=\(localPort)")
await MainActor.run { self.activateMosh(ip: "127.0.0.1", port: localPort, key: info.key, relay: relay) }
} catch {
NSLog("MOSHDBG relay FAILED: \(error.localizedDescription)")
await MainActor.run { self.banner = "mosh relay 失败:\(error.localizedDescription)" }
}
}
} else {
// 直连模式:iOS 允许出站 UDP,直接连 host:port。
activateMosh(ip: config.host, port: info.port, key: info.key, relay: nil)
}
}
private func activateMosh(ip: String, port: Int, key: String, relay: TsnetbridgeMoshRelay?) {
guard moshSession == nil else { return }
let mosh = MoshSession(ip: ip, port: port, key: key, cols: 80, rows: 24)
mosh.onBytes = { [weak self] bytes in
guard let self else { return }
self.gate.deliver(Data(bytes))
}
mosh.onClosed = { [weak self] rc in
Task { @MainActor in
guard let self else { return }
self.run(self.machine.reduce(.moshExited(rc: Int(rc))))
}
}
moshRelay = relay
moshSession = mosh
moshHolder.session = mosh // 输入/resize 从此路由到 mosh
mosh.start()
NSLog("MOSHDBG activateMosh ip=\(ip) port=\(port) started")
// mosh 接管成功 → 状态机拆掉底层 idle SSH(moshActive 之后忽略其 transportClosed)。
run(machine.reduce(.moshEstablished))
}
// MARK: - M3 前台快速恢复
/// 唤醒脉冲:tsnet WakeUp(后台线程,可能阻塞)+ relay 同端口 Rebind + mosh SIGCONT。
private func performWakePulse() {
guard let mosh = moshSession else { return }
if moshResumeBaseMs == 0 { moshResumeBaseMs = mosh.lastHeardMs() }
if tsnetAuthKey != nil {
let mgr = tsnetMgr
Task.detached { mgr.wakeUp() }
}
do { try moshRelay?.rebind() }
catch { NSLog("MOSHDBG relay rebind failed: \(error.localizedDescription)") }
mosh.nudge()
NSLog("MOSHDBG wake pulse base=\(moshResumeBaseMs)")
}
/// 轮询 mosh 探针:收到基线之后的新服务器状态即判定 SSP 已续(healthy)。
private func startHealthyPollIfNeeded() {
guard moshHealthyPoll == nil, let mosh = moshSession else { return }
let base = moshResumeBaseMs
moshHealthyPoll = Task { [weak self] in
for _ in 0 ..< 100 { // ~10s 安全上限(看门狗更早兜底)
try? await Task.sleep(nanoseconds: 100_000_000)
if Task.isCancelled { return }
if mosh.lastHeardMs() > base {
guard let self else { return }
NSLog("TXM3 recovered base=\(base) now=\(mosh.lastHeardMs())")
self.moshHealthyPoll = nil
self.run(self.machine.reduce(.moshHealthy))
return
}
}
}
}
private func scheduleResumeWatchdog(_ ms: Int) {
resumeWatchdog?.cancel()
resumeWatchdog = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(ms) * 1_000_000)
guard !Task.isCancelled, let self else { return }
self.run(self.machine.reduce(.resumeWatchdogFired))
}
}
private func cancelResumeWatchdog() {
resumeWatchdog?.cancel(); resumeWatchdog = nil
moshHealthyPoll?.cancel(); moshHealthyPoll = nil
moshResumeBaseMs = 0
}
/// 拆除 mosh 会话(兜底全量重建 / 关闭时)。重置引导状态,允许重新引导 mosh-server。
private func teardownMosh() {
cancelResumeWatchdog()
moshHolder.session = nil
moshSession?.close(); moshSession = nil
try? moshRelay?.close(); moshRelay = nil
moshBootstrapSent = false
moshScanner = MoshConnectScanner()
}
private func beginBgTask() {
#if canImport(UIKit)
guard bgTask == .invalid else { return }
bgTask = UIApplication.shared.beginBackgroundTask(withName: "mosh-keepalive") { [weak self] in
self?.endBgTask()
}
#endif
}
private func endBgTask() {
#if canImport(UIKit)
if bgTask != .invalid { UIApplication.shared.endBackgroundTask(bgTask); bgTask = .invalid }
#endif
}
private func handleTransportState(_ st: TransportState) {
switch st {
case .authenticating: run(machine.reduce(.authenticating))
case .connected:
run(machine.reduce(.established))
if moshMode { scheduleMoshBootstrap() }
case .disconnected(let reason): run(machine.reduce(.transportClosed(reason: reason)))
case .failed(let message): run(machine.reduce(.authFailed(reason: message)))
case .idle, .connecting: break
}
}
private func scheduleReconnect(_ delayMS: Int) {
reconnectTask?.cancel()
reconnectTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(delayMS) * 1_000_000)
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self else { return }
self.run(self.machine.reduce(.reconnectTimerFired))
}
}
}
private func syncUI() {
switch machine.phase {
case .connected, .reconnecting, .waitingToReconnect, .backgroundParked,
.moshActive, .moshParked, .moshResuming:
showsTerminal = true
case .idle, .connecting, .authenticating, .closed, .failed:
showsTerminal = false
}
phaseText = Self.describe(machine.phase)
}
private static func describe(_ phase: SessionMachine.Phase) -> String {
switch phase {
case .idle: "空闲"
case .connecting: "连接中…"
case .authenticating: "认证中…"
case .connected: "已连接"
case .backgroundParked: "已挂起(后台)"
case .waitingToReconnect(let n): "等待重连(第 \(n) 次)"
case .reconnecting(let n): "重连中(第 \(n) 次)"
case .failed(let r): "失败:\(r)"
case .closed: "已关闭"
case .moshActive: "mosh 已连接"
case .moshParked: "已挂起(后台)"
case .moshResuming(let n): "mosh 恢复中(第 \(n) 次)"
}
}
}

View File

@@ -0,0 +1,193 @@
import Combine
import Foundation
import GhosttyTerminal
/// 多会话持有者:一台主机可以有多条会话,会话在后台继续跑(最小化 / 回首页都不断)。
///
/// 设计要点:
/// - **会话不因导航而销毁**:只有显式关闭(红点 / ⌘W)才 teardown。
/// - 会话 surface 由 `SessionCanvas` 常驻挂载,故 `%output` 持续消费、内容不丢。
/// - 生命周期广播(前后台)由本类扇出给每条会话——`TerminalSession` 自己不监听 scenePhase。
@MainActor
final class SessionManager: ObservableObject {
@Published private(set) var sessions: [TerminalSession] = []
/// 当前焦点会话(进终端时显示哪条)。
@Published var focusedID: UUID?
/// 「已就绪、可以进终端」的会话(视图层消费后清空)。
///
/// 为什么要这个信号:点主机后**先留在首页**探测 tmux、弹会话选择器,选完(或探测到没有
/// tmux)才进终端页 —— 连接过程属于"准备连接",不该先把用户丢进空终端再弹框。
@Published var enterRequest: UUID?
/// 点了主机但还没进终端的会话 id(等探测/选择结果)。
private var awaitingEnter: Set<UUID> = []
/// 转发每条会话的变化(嵌套 ObservableObject 不冒泡,见 CLAUDE.md)。
private var sessionObservers: [UUID: AnyCancellable] = [:]
private weak var credentialStore: (any CredentialStore)?
private weak var tailscaleStore: TailscaleStore?
var focused: TerminalSession? {
guard let id = focusedID else { return nil }
return sessions.first { $0.id == id }
}
func session(_ id: UUID) -> TerminalSession? {
sessions.first { $0.id == id }
}
/// 正在建立中(还没进终端)的会话 —— 首页据此显示连接进度。
var connectingSessions: [TerminalSession] {
sessions.filter { awaitingEnter.contains($0.id) }
}
/// 等用户选 tmux 会话的那条(选择器数据源)。
var sessionAwaitingChoice: TerminalSession? {
sessions.first { $0.pendingSessionChoice != nil }
}
/// 首页「活动会话」区的排序:最小化的置顶(设计要求,否则用户不知道东西去哪了),其余按最近活跃。
var activeSessions: [TerminalSession] {
sessions.sorted {
if $0.isMinimized != $1.isMinimized { return $0.isMinimized }
return $0.lastActiveAt > $1.lastActiveAt
}
}
/// ⌘1…9 的会话顺序(与首页卡片顺序一致)。
func session(atShortcutIndex i: Int) -> TerminalSession? {
let list = activeSessions
return i >= 0 && i < list.count ? list[i] : nil
}
/// 注入解析凭据/出口所需的 Store(新建会话时转交)。
func bind(credentials: any CredentialStore, tailscale: TailscaleStore) {
credentialStore = credentials
tailscaleStore = tailscale
for s in sessions { s.bind(credentials: credentials, tailscale: tailscale) }
}
// MARK: - 开 / 关
/// 打开一台主机的会话:**已有该主机的活会话则复用**(对标设计「回到刚才那个会话」),否则新建并连接。
/// 打开一台主机:**已有该主机的活会话则直接复用并进终端**;否则新建会话开始连接,
/// 但**先不进终端** —— 等 tmux 探测/用户选择完成后由 `enterRequest` 通知视图层。
@discardableResult
func open(host: SavedHost) -> TerminalSession {
if let existing = sessions.first(where: { $0.host?.id == host.id }) {
focus(existing)
enterRequest = existing.id // 复用的会话没有连接过程,立刻进
return existing
}
let s = makeSession()
sessions.append(s)
focus(s)
awaitingEnter.insert(s.id)
s.connect(to: host)
return s
}
/// 会话状态变化后判断是否该进终端了。
///
/// 就绪 = 不需要用户选择,且(已建立 / 探测确认无 tmux / 用户选了原生 / 连接失败)。
/// 失败也进终端:那里有「重试 / 返回首页」,比留在首页只显示一行错误更有出路。
private func evaluateEnter(_ s: TerminalSession) {
guard awaitingEnter.contains(s.id) else { return }
if s.pendingSessionChoice != nil { return } // 等用户选会话
if s.isProbingTmux { return } // 还在探测 → 留在首页,别先把人送进终端
let failed = s.phaseText.hasPrefix("失败")
guard s.isLive || s.tmuxUnavailable || s.tmuxSkipped || failed else { return }
awaitingEnter.remove(s.id)
enterRequest = s.id
}
/// 焦点切换:记录活跃时刻并解除最小化标记。
func focus(_ s: TerminalSession) {
focusedID = s.id
s.isMinimized = false
s.lastActiveAt = Date()
}
/// 最小化:**不确认**、不动传输,只标记 + 记时刻(回首页由调用方改路由)。
func minimize(_ id: UUID) {
guard let s = session(id) else { return }
s.isMinimized = true
s.lastActiveAt = Date()
}
/// 关闭并移除会话。tmux 的「仅断开 / 结束会话」分支在 UI-4 的确认对话框里决定,
/// 这里只负责最终 teardown + 摘除(会话对象随之释放,surface 一并回收)。
func close(_ id: UUID) {
guard let s = session(id) else { return }
s.close()
sessions.removeAll { $0.id == id }
sessionObservers[id] = nil
awaitingEnter.remove(id)
if focusedID == id { focusedID = activeSessions.first?.id }
}
// MARK: - 广播
// 假会话不参与:它没有传输层,广播会经 syncUI 把注入状态覆盖回 idle。
func enterBackground() { for s in sessions where !s.isSyntheticFixture { s.enterBackground() } }
func enterForeground() { for s in sessions where !s.isSyntheticFixture { s.enterForeground() } }
/// 主题切换:推给所有会话的 raw surface 与 tmux pane。
func applyTerminalTheme(_ t: TerminalTheme) {
for s in sessions { s.applyTerminalTheme(t) }
}
/// 任一会话的 host key 告警(多会话时取第一条,逐条处理)。
var pendingHostKeyMismatch: TerminalSession? {
sessions.first { $0.hostKeyMismatch != nil }
}
// MARK: - 无头验证
/// 启动参数自动连接(simctl launch --args -txHost …)。返回新建的会话供调用方切路由。
@discardableResult
func autoConnectIfConfigured() -> TerminalSession? {
let d = UserDefaults.standard
guard let host = d.string(forKey: "txHost"), !host.isEmpty else { return nil }
let s = makeSession()
sessions.append(s)
focus(s)
s.autoConnectIfConfigured()
return s
}
#if DEBUG
/// 无头 UI 验证:批量造假会话(`-txUIFixture 1`)。返回第一条供调用方进终端。
@discardableResult
func spawnFixtureSessions() -> TerminalSession? {
for (i, spec) in UIPreviewFixture.specs.enumerated() {
// 复用 UIPreviewFixture.hosts 里的同名主机(同一 id),主机卡才能亮「已有会话」。
guard let host = UIPreviewFixture.hosts.first(where: { $0.name == spec.name }) else { continue }
let s = makeSession()
s.injectFixture(host: host, lines: spec.lines, link: spec.link,
mosh: host.useMosh, tmux: spec.tmux,
tmuxSessions: spec.tmux == nil ? [] : UIPreviewFixture.tmuxSessions(current: spec.tmux!.name))
// 活跃时刻错开,让首页卡片排序稳定可对拍。
s.lastActiveAt = Date(timeIntervalSinceNow: Double(-i) * 60)
sessions.append(s)
}
focusedID = sessions.first?.id
return sessions.first
}
#endif
private func makeSession() -> TerminalSession {
let s = TerminalSession()
if let c = credentialStore, let t = tailscaleStore {
s.bind(credentials: c, tailscale: t)
}
// 转发会话变化:① 让订阅 manager 的视图(首页/选择器)跟着刷新;② 驱动 enter 判定。
sessionObservers[s.id] = s.objectWillChange.sink { [weak self, weak s] _ in
guard let self else { return }
self.objectWillChange.send()
if let s { Task { @MainActor in self.evaluateEnter(s) } }
}
return s
}
}

View File

@@ -0,0 +1,206 @@
import SwiftUI
import TXCore
/// 连接前的会话选择器(连接流程的一部分):点主机 → 探测服务器 → 问「接回哪个会话」,选完才进终端。
///
/// 三种连接方式:**接回已有 tmux 会话** · **新建 tmux 会话** · **原生终端**(不经 tmux)。
/// 服务器**没装 tmux 时不显示本 sheet** —— 直接连原生终端,不拿一个只有一个选项的框去打扰用户。
///
/// 措辞注意:右上不是「跳过」(那读起来像"跳过某个步骤",含义模糊)——它是一种**并列的连接方式**,
/// 所以直接叫「原生终端」。分段控件里 `Herdr` / `最近` 暂时置灰(后续支持),位置先占住避免重排。
struct SessionPickerSheet: View {
/// 正在连接的主机名(标题里点明"连到哪台",避免多主机时看不出上下文)。
let host: String
let choice: TmuxSessionChoice
let onAttach: (String) -> Void
let onCreate: (String) -> Void
let onNative: () -> Void
@EnvironmentObject var theme: TXThemeManager
@State private var newName = ""
@State private var creating = false
@FocusState private var nameFocused: Bool
private var p: TXPalette { theme.palette }
var body: some View {
VStack(alignment: .leading, spacing: TX.Space.m) {
grabber
title
header
if choice.sessions.isEmpty {
emptyState
} else {
sessionList
}
newSessionRow
Spacer(minLength: 0)
}
.padding(.horizontal, TX.Space.l)
.padding(.bottom, TX.Space.l)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(p.bg.ignoresSafeArea())
}
private var title: some View {
VStack(alignment: .leading, spacing: 3) {
Text("连接到 \(host)")
.font(TX.Font.mono(17, .medium))
.foregroundStyle(p.text)
Text("接回一个 tmux 会话,或直接用原生终端")
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
}
}
private var grabber: some View {
RoundedRectangle(cornerRadius: 2.5)
.fill(p.borderStrong)
.frame(width: 44, height: 5)
.frame(maxWidth: .infinity)
.padding(.top, TX.Space.s)
}
// MARK: - 分段控件 + 跳过
private var header: some View {
HStack(spacing: TX.Space.s) {
HStack(spacing: 2) {
segment(icon: "chevron.left.forwardslash.chevron.right", title: "Tmux", enabled: true)
segment(icon: "shippingbox", title: "Herdr", enabled: false)
segment(icon: "clock.arrow.circlepath", title: "最近", enabled: false)
}
.padding(4)
.background(p.surface, in: Capsule())
Spacer(minLength: TX.Space.s)
Button(action: onNative) {
HStack(spacing: TX.Space.xs) {
Image(systemName: "terminal")
.font(.system(size: 13, weight: .medium))
Text("原生终端")
.font(TX.Font.mono(15, .medium))
}
.foregroundStyle(p.text)
.padding(.horizontal, TX.Space.m)
.frame(height: 44)
.background(p.surface, in: Capsule())
.overlay(Capsule().strokeBorder(p.border, lineWidth: 1))
}
.buttonStyle(.txPress)
.accessibilityLabel("直接连接原生终端,不经过 tmux")
}
}
private func segment(icon: String, title: String, enabled: Bool) -> some View {
HStack(spacing: TX.Space.xs) {
Image(systemName: icon)
.font(.system(size: 13, weight: .medium))
Text(title)
.font(TX.Font.mono(15, .medium))
}
.foregroundStyle(enabled ? p.text : p.textWeak.opacity(0.7))
.padding(.horizontal, TX.Space.m)
.frame(height: 36)
.background(enabled ? p.bgDeep : .clear, in: Capsule())
.overlay {
if enabled {
Capsule().strokeBorder(p.border, lineWidth: 1)
}
}
}
// MARK: - 会话列表
private var sessionList: some View {
VStack(spacing: 0) {
ForEach(Array(choice.sessions.enumerated()), id: \.element.name) { index, s in
Button { onAttach(s.name) } label: {
sessionRow(s)
}
.buttonStyle(.txPress)
if index < choice.sessions.count - 1 {
Rectangle().fill(p.divider).frame(height: 1)
.padding(.horizontal, TX.Space.m)
}
}
}
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
}
private func sessionRow(_ s: TmuxSessionProbe.Session) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: TX.Space.s) {
Text(s.name)
.font(TX.Font.mono(22, .medium))
.foregroundStyle(p.text)
// tmux 会话本身就是活着的服务端进程 → 一律「活跃」;被别的客户端占用时另标。
TXBadge(text: "活跃", emphasized: true)
if s.isAttached {
TXBadge(text: "已被接入")
}
Spacer(minLength: 0)
}
Text(subtitle(s))
.font(TX.Font.mono(13))
.foregroundStyle(p.textWeak)
}
.padding(.horizontal, TX.Space.m)
.padding(.vertical, 18)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
private func subtitle(_ s: TmuxSessionProbe.Session) -> String {
var parts = ["\(s.windows) 个窗口"]
if let t = s.lastActivity { parts.append(SessionCard.relative(t)) }
return parts.joined(separator: " · ")
}
private var emptyState: some View {
VStack(alignment: .leading, spacing: 6) {
Text("这台主机上还没有 tmux 会话")
.font(TX.Font.mono(15, .medium))
.foregroundStyle(p.text)
Text("新建一个就能让会话留在服务端:关掉 App、换网络都能原样接回。也可以直接用原生终端。")
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
.lineSpacing(3)
}
.padding(TX.Space.m)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
}
// MARK: - 新建
@ViewBuilder private var newSessionRow: some View {
if creating {
HStack(spacing: TX.Space.s) {
TXSearchField(text: $newName, placeholder: choice.defaultNewName)
.focused($nameFocused)
TXButton(title: "创建", emphasized: true) {
let name = newName.trimmingCharacters(in: .whitespaces)
onCreate(name.isEmpty ? choice.defaultNewName : name)
}
TXButton(title: "取消") { creating = false }
}
} else {
Button {
creating = true
nameFocused = true
} label: {
HStack(spacing: TX.Space.s) {
Image(systemName: "plus")
.font(.system(size: 13, weight: .medium))
Text("新建 tmux 会话…")
.font(TX.Font.mono(15, .medium))
Spacer(minLength: 0)
}
.foregroundStyle(TXAccent.textBright)
.padding(.horizontal, TX.Space.m)
.frame(height: 56)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.contentShape(Rectangle())
}
.buttonStyle(.txPress)
}
}
}

View File

@@ -0,0 +1,173 @@
import SwiftUI
/// 设置容器(首页齿轮 → sheet)。过渡形态:`NavigationStack` + 分类跳转。
/// UI-5 将按设计稿 `1g 设置-外观` 重写为「左 280pt 类别栏 + 右内容区」。
struct SettingsHost: View {
@ObservedObject var credStore: FileCredentialStore
@ObservedObject var tailscaleStore: TailscaleStore
@Environment(\.dismiss) private var dismiss
@EnvironmentObject var theme: TXThemeManager
/// known hosts 是无状态的 keychain/文件访问层,容器自持一个即可。
private let knownHosts = KeychainKnownHostsStore()
private var p: TXPalette { theme.palette }
var body: some View {
NavigationStack {
ZStack {
p.bg.ignoresSafeArea()
List {
Section("外观") {
NavigationLink { SettingsView() } label: { row("paintbrush", "外观与主题") }
}
.listRowBackground(p.surface)
Section("保险库") {
NavigationLink { KeychainListView(store: credStore) } label: {
row("key.fill", "Keychain 凭据")
}
NavigationLink { KnownHostsView(store: knownHosts) } label: {
row("checkmark.shield", "Known Hosts")
}
}
.listRowBackground(p.surface)
Section("网络") {
NavigationLink { TailscaleListView(store: tailscaleStore) } label: {
row("network", "Tailscale")
}
}
.listRowBackground(p.surface)
}
.scrollContentBackground(.hidden)
.foregroundStyle(p.text)
}
.navigationTitle("设置")
.toolbarBackground(p.bg, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("完成") { dismiss() }.tint(TXAccent.base)
}
}
}
.tint(TXAccent.base)
}
private func row(_ icon: String, _ title: String) -> some View {
Label(title, systemImage: icon)
.font(TX.Font.mono(13))
.foregroundStyle(p.text)
}
}
/// 设置页:主题(app 与终端统一)+ 终端 + 关于。
struct SettingsView: View {
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
ZStack {
p.bg.ignoresSafeArea()
List {
Section("主题(app 与终端统一)") {
ForEach(TXPalette.all) { pal in
Button { theme.apply(pal) } label: { themeRow(pal) }
.buttonStyle(.txPress)
}
}
.listRowBackground(p.surface)
Section("终端") {
row("textformat", "字体与大小", value: "系统等宽")
}
.listRowBackground(p.surface)
Section("关于") {
row("info.circle", "terminalX", value: "0.0.1")
}
.listRowBackground(p.surface)
}
.scrollContentBackground(.hidden)
.foregroundStyle(p.textPrimary)
}
.navigationTitle("设置")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(p.bg, for: .navigationBar)
}
/// 主题行:色板预览(底/面/强调)+ 名称 + 选中勾。
private func themeRow(_ pal: TXPalette) -> some View {
HStack(spacing: TX.Space.m) {
ZStack {
RoundedRectangle(cornerRadius: 7).fill(pal.bg)
HStack(spacing: 3) {
Circle().fill(pal.accent).frame(width: 7, height: 7)
Circle().fill(pal.mosh).frame(width: 7, height: 7)
}
}
.frame(width: 40, height: 28)
.overlay(RoundedRectangle(cornerRadius: 7).stroke(p.border, lineWidth: 1))
Text(pal.name).foregroundStyle(p.textPrimary)
Spacer()
if pal.id == theme.palette.id {
Image(systemName: "checkmark").foregroundStyle(p.accent).font(.system(size: 14, weight: .semibold))
}
}
}
private func row(_ icon: String, _ title: String, value: String) -> some View {
HStack {
Image(systemName: icon).foregroundStyle(p.textSecondary).frame(width: 26)
Text(title).foregroundStyle(p.textPrimary)
Spacer()
Text(value).foregroundStyle(p.textSecondary).font(.system(size: 14))
}
}
}
/// 已信任 host key(TOFU pin)查看/删除页。
struct KnownHostsView: View {
let store: KeychainKnownHostsStore
@EnvironmentObject var theme: TXThemeManager
@State private var entries: [KnownHostEntry] = []
private var p: TXPalette { theme.palette }
var body: some View {
ZStack {
p.bg.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: TX.Space.s) {
if entries.isEmpty {
TXEmptyState(icon: "checkmark.shield", title: "还没有已信任的主机密钥",
subtitle: "首次连接主机时会自动记住其 host key")
} else {
ForEach(entries) { e in
row(e)
.contextMenu {
Button(role: .destructive) {
store.removeKey(e.id); reload()
} label: { Label("移除信任", systemImage: "trash") }
}
}
}
}
.padding(TX.Space.m)
}
}
.navigationTitle("Known Hosts")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(p.bg, for: .navigationBar)
.onAppear(perform: reload)
}
private func row(_ e: KnownHostEntry) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text("\(e.host):\(e.port)").font(.system(size: 15, weight: .semibold)).foregroundStyle(p.textPrimary)
Text("出口:\(e.egress)").font(TX.Font.mono(11)).foregroundStyle(p.textSecondary)
Text(e.fingerprint).font(TX.Font.mono(11)).foregroundStyle(p.textTertiary)
.textSelection(.enabled)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(TX.Space.m)
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1))
}
private func reload() { entries = store.entries() }
}

View File

@@ -43,6 +43,23 @@ final class SigningKeyProvider: SSHSigner, @unchecked Sendable {
return URL(fileURLWithPath: base).appendingPathComponent("ssh-ecdsa-p256.key")
}
/// 从 SecKey 外部表示(x9.63 私钥,生成时存入凭据机密)重建签名器(路径 A)。
static func from(x963: Data) -> SigningKeyProvider? {
guard let key = SecKeyCreateWithData(x963 as CFData, [
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrKeySizeInBits: 256,
] as CFDictionary, nil) else { return nil }
return SigningKeyProvider(privateKey: key, hardware: false)
}
/// 生成一把软件 P-256 私钥,返回签名器 + 其外部表示(供凭据机密持久化)。
static func generateSoftwareP256() -> (provider: SigningKeyProvider, x963: Data)? {
guard let sw = createSoftwareKey(),
let ext = SecKeyCopyExternalRepresentation(sw, nil) as Data? else { return nil }
return (SigningKeyProvider(privateKey: sw, hardware: false), ext)
}
static func loadOrCreate() -> SigningKeyProvider? {
// 1) 软件密钥文件(模拟器持久化,跨启动稳定)。
if let data = try? Data(contentsOf: keyFileURL),
@@ -65,6 +82,23 @@ final class SigningKeyProvider: SSHSigner, @unchecked Sendable {
return SigningKeyProvider(privateKey: sw, hardware: false)
}
/// 运行时探测 Secure Enclave 是否可用(建一把临时非持久 SE key)。
/// 未签名/模拟器返回 false(驱动 UI 灰化 Face ID 选项);签名真机自动点亮,无需改码。
static func secureEnclaveAvailable() -> Bool {
guard let access = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .privateKeyUsage, nil) else { return false }
let attrs: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: false,
kSecAttrAccessControl as String: access,
],
]
return SecKeyCreateRandomKey(attrs as CFDictionary, nil) != nil
}
private static func createSecureEnclaveKey() -> SecKey? {
guard let access = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .privateKeyUsage, nil) else { return nil }

View File

@@ -0,0 +1,422 @@
import SwiftUI
// 设计稿反复出现的小件(首页 / 轨道 / 侧边栏共用)。数值取自 docs/design/UI-DESIGN-HANDOFF.md。
// MARK: - 通用修饰
extension View {
/// 统一走 `TX.Elevation` 的投影(别在视图里手写 `.shadow`,那样迟早各处不一致)。
func txShadow(_ s: TX.Shadow) -> some View {
shadow(color: .black.opacity(s.opacity), radius: s.radius, x: s.x, y: s.y)
}
}
/// 可按下容器的按压反馈:轻微缩放 + 压暗。
///
/// `.plain` 在深色界面里**完全没有**按下反馈 —— 触屏上"点了没反应"是最伤手感的一类瑕疵,
/// 尤其主机卡/会话卡这种点下去要等几百毫秒才有结果的入口。
struct TXPressStyle: ButtonStyle {
/// 大块卡片缩得更少(大面积缩放会显得晃);小图标可以缩多一点。
var scale: CGFloat = 0.985
var dim: Double = 0.78
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? scale : 1)
.opacity(configuration.isPressed ? dim : 1)
.animation(TX.Motion.quick, value: configuration.isPressed)
}
}
extension ButtonStyle where Self == TXPressStyle {
/// 卡片 / 列表行
static var txPress: TXPressStyle { TXPressStyle() }
/// 图标、圆点等小目标
static var txPressTight: TXPressStyle { TXPressStyle(scale: 0.9, dim: 0.7) }
}
/// 量一次屏幕度量(安全区 + 可用尺寸),供两件事用:
///
/// 1. **沉浸终端**要顶到物理屏幕边缘(`ignoresSafeArea`),于是必须自己让位 —— 底栏在
/// 「内容带 + 安全区」里居中、浮起胶囊按硬件遮挡下移。SwiftUI 没有读安全区的环境值,
/// 故必须在 `ignoresSafeArea` **之前**量。
/// 2. **首页**按屏高收敛会话卡高度(硬写 560pt 在竖屏/分屏/小 iPad 上会把主机网格挤出屏外)。
///
/// 旋转、分屏改尺寸都会自动重新量,不需要监听通知。
struct TXScreenMetrics<Content: View>: View {
@ViewBuilder let content: (GeometryProxy) -> Content
var body: some View {
GeometryReader { geo in
content(geo)
}
}
}
/// 状态点:8pt 实心 + 2pt 挖底描边(`box-shadow: 0 0 0 2px <底色>`,让点浮在头像/卡片边缘上)。
struct TXStatusDot: View {
let color: Color
var size: CGFloat = 8
/// 挖底色(点所压的那层的底色);nil = 不挖底。
var punchOut: Color?
var body: some View {
Circle()
.fill(color)
.frame(width: size, height: size)
.overlay {
if let punchOut {
Circle().stroke(punchOut, lineWidth: 2)
}
}
// 链路状态变色(绿 → 琥珀 → 灰)渐变过去,别硬跳。
.animation(TX.Motion.quick, value: color)
}
}
/// 两字缩写头像(轨道 38pt / 侧边栏 26pt / 首页卡 26pt)。选中态用 inset 描边,避免内容位移 1pt。
struct TXInitialsAvatar: View {
let initials: String
var size: CGFloat = 26
var isSelected: Bool = false
/// 右上状态点颜色(nil = 不显示)。
var status: Color?
/// 状态点的挖底色(所在容器底色)。
var punchOut: Color = .clear
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var radius: CGFloat { size >= 34 ? 11 : TX.Radius.badge }
var body: some View {
Text(initials)
.font(TX.Font.mono(size >= 34 ? 13 : 11, .semibold))
.foregroundStyle(isSelected ? TXAccent.text : p.text3)
.frame(width: size, height: size)
.background(isSelected ? TXAccent.selectedBg : p.surface,
in: RoundedRectangle(cornerRadius: radius))
.overlay(
RoundedRectangle(cornerRadius: radius)
.strokeBorder(isSelected ? TXAccent.dim : p.border, lineWidth: 1)
)
.overlay(alignment: .topTrailing) {
if let status {
TXStatusDot(color: status, punchOut: punchOut)
.offset(x: 3, y: -3)
}
}
}
}
/// 分组小标题(600 10px, ls .09em)。
///
/// **不做 `.uppercased()`**:这类标签会嵌主机名/会话名/技术名(`tmux · mac-studio · dev`),
/// 排版性大写会把它们全改错(曾显示成 `TMUX · MAC-STUDIO · DEV`)。中文本来无大小写,
/// 英文标签直接按 `TXTech` 的规范拼写传进来即可。
struct TXSectionLabel: View {
let text: String
/// 右侧计数(如「活动会话 3」的 3 单独右对齐)。
var trailing: String?
var color: Color?
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack {
Text(text)
.font(TX.Font.mono(10, .semibold))
.tracking(0.9)
.foregroundStyle(color ?? p.textWeak)
if let trailing {
Spacer()
Text(trailing)
.font(TX.Font.mono(10, .semibold))
.foregroundStyle(color ?? p.textWeak)
}
}
}
}
/// 技术标签的**拼写与配色**规范(`SSH` / `mosh` / `tsnet` / `tmux` / 发行版名)。
///
/// **拼写**:一律照各自的官方写法 —— 缩写全大写(`SSH`、`TLS`)、项目名按作者的写法
/// (`mosh` / `tmux` / `tsnet` 本来就是小写)、产品名照抄(`macOS`、`TrueNAS`)。
/// 别为了"看起来统一"把 `mosh` 写成 `MOSH` 或把 `SSH` 写成 `ssh` —— 目标用户是开发者,
/// 写错拼写比大小写不齐更刺眼。同理**分组小标题的排版性大写不施加到名字上**
/// (`TMUX · MAC-STUDIO · DEV` 把主机名和会话名都改错了)。
///
/// **配色**:取各技术自己的"主题色",映射到当前 flavor 的 Catppuccin 调色板。
/// 只染文字与描边、底色压到 12% —— 强调色不铺面(设计约束)。
@MainActor
enum TXTech {
/// 传输协议:冷色区分,两者互斥所以一行里只出现一个。
static func transport(_ useMosh: Bool, _ p: TXPalette) -> (text: String, tint: Color) {
useMosh ? ("mosh", p.mosh) : ("SSH", p.linkPath)
}
/// 发行版 / OS 的品牌色(Ubuntu 橙 → peach、Debian 红 → red、树莓 → red…)。
/// 认不出来就回中性色,不硬凑。
static func osTint(_ os: String?, _ p: TXPalette) -> Color {
let s = (os ?? "").lowercased()
if s.contains("ubuntu") { return p.reconnecting } // Ubuntu orange
if s.contains("debian") || s.contains("raspberry") { return p.danger }
if s.contains("centos") || s.contains("rhel") || s.contains("red hat") { return p.danger }
if s.contains("fedora") || s.contains("truenas") || s.contains("alpine") { return p.linkPath }
if s.contains("arch") { return p.mosh }
if s.contains("suse") { return p.online }
if s.contains("macos") || s.contains("darwin") { return p.text3 } // Apple 是中性银
return p.text4
}
}
/// 徽标胶囊(主机卡的 mosh / SSH / tsnet / 凭据名)。
///
/// `tint` 给技术标签上语义色(见 `TXTech`);`emphasized` 是 app 强调色,**只留给 tsnet**
/// —— 它是本产品的招牌能力。凭据名是用户自己命的名、不是技术标签,保持中性,
/// 免得一排徽标全是彩色反而没有重点。
struct TXBadge: View {
let text: String
var tint: Color?
var emphasized: Bool = false
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var fg: Color { emphasized ? TXAccent.text : (tint ?? p.text4) }
private var stroke: Color {
if emphasized { return TXAccent.selectedStroke }
return tint?.opacity(0.35) ?? .clear
}
var body: some View {
Text(text)
.font(TX.Font.mono(11, .medium))
.foregroundStyle(fg)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background {
let shape = RoundedRectangle(cornerRadius: 5)
if emphasized {
shape.fill(TXAccent.selectedBg)
} else if let tint {
shape.fill(tint.opacity(0.12))
} else {
shape.fill(p.surface)
}
}
.overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(stroke, lineWidth: 1))
}
}
/// 筛选 chip(首页「全部 / 生产 / 家里 / 云」)。
struct TXChip: View {
let text: String
let isSelected: Bool
let action: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Button(action: action) {
Text(text)
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(isSelected ? TXAccent.textBright : p.text4)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(isSelected ? TXAccent.selectedBg : p.surface,
in: RoundedRectangle(cornerRadius: TX.Radius.badge))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.badge)
.strokeBorder(isSelected ? TXAccent.selectedStroke : p.divider, lineWidth: 1)
)
.animation(TX.Motion.quick, value: isSelected)
}
.buttonStyle(.txPress)
}
}
/// 搜索框(32pt 高、圆角 8、控件底)。右侧可挂快捷键提示。
struct TXSearchField: View {
@Binding var text: String
var placeholder: String
var shortcut: String?
@EnvironmentObject var theme: TXThemeManager
/// 聚焦态:描边转强调色。接妙控键盘用 Tab 走焦点时必须看得出焦点在哪
/// (无障碍底线:键盘焦点可见)。
@FocusState private var focused: Bool
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: TX.Space.xs) {
Image(systemName: "magnifyingglass")
.font(.system(size: 12))
.foregroundStyle(focused ? TXAccent.text : p.textWeak)
TextField(placeholder, text: $text)
.font(TX.Font.mono(12))
.foregroundStyle(p.text)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($focused)
if let shortcut, text.isEmpty, !focused {
Text(shortcut)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
}
}
.padding(.horizontal, TX.Space.s)
.frame(height: 32)
.background(p.surface, in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(focused ? TXAccent.border : p.divider, lineWidth: 1)
)
.animation(TX.Motion.quick, value: focused)
}
}
/// 次要按钮(描边款,如「新建连接」「安装并启用 tmux」)。
struct TXButton: View {
let title: String
var icon: String?
/// 主动作用强调描边 + 强调文字(设计:强调色只做线和文字,不铺底)。
var emphasized: Bool = false
let action: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Button(action: action) {
HStack(spacing: TX.Space.xs) {
if let icon {
Image(systemName: icon).font(.system(size: 12, weight: .medium))
}
Text(title).font(TX.Font.mono(12, .medium))
}
.foregroundStyle(emphasized ? TXAccent.textBright : p.text2)
.padding(.horizontal, 14)
.frame(height: 32)
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.control))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(emphasized ? TXAccent.border : p.border, lineWidth: 1)
)
}
.buttonStyle(.txPress)
}
}
/// 图标按钮。两种形态(按设计稿区分,别混):
/// - **有框**(`bare: false`):控件底 + 常规描边,用于展开键、页头齿轮、Pin 等"控件"。
/// - **裸图标**(`bare: true`):无底无框,用于轨道里的搜索 / 首页 / 设置——设计稿这三个只有图标。
struct TXIconButton: View {
let icon: String
var size: CGFloat = 34
var isSelected: Bool = false
var bare: Bool = false
/// 命中区(默认补到 44pt 触控下限;轨道里按设计坐标排布时传入实际间距)。
var hitSize: CGFloat?
var label: String?
let action: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: bare ? 17 : 14, weight: .regular))
.foregroundStyle(isSelected ? TXAccent.text : p.text3)
.frame(width: size, height: size)
.background {
if !bare {
RoundedRectangle(cornerRadius: TX.Radius.control)
.fill(isSelected ? TXAccent.selectedBg : p.surface)
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(isSelected ? TXAccent.selectedStroke : p.border,
lineWidth: 1)
)
}
}
.frame(width: hitSize ?? max(size, TX.Layout.minTouch),
height: hitSize ?? max(size, TX.Layout.minTouch))
.contentShape(Rectangle())
.animation(TX.Motion.quick, value: isSelected)
}
.buttonStyle(.txPressTight)
.accessibilityLabel(label ?? icon)
}
}
/// 终端内容的**文本镜像**(首页会话缩略卡 / 侧边栏预览)。
///
/// 为什么不渲染真 surface:一个 ghostty surface 只有一个 IOSurfaceLayer,不能同处两地;
/// 离屏 Metal 渲染是已修过的泄漏雷区。故用 `readViewportText()` 抓文本,按终端底色排版,
/// **内容底对齐**(新输出贴着卡片下缘,和真终端的视觉重心一致)。
struct TXTerminalMirror: View {
let lines: [String]
var fontSize: CGFloat = 11
/// 终端底色(Catppuccin base,跟随所选 flavor 的终端配色而非 chrome)。
var background: Color
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
/// 无内容时的占位文案(真实会话刚建立、surface 还没 attach 或还没有输出)。
var placeholder: String = "等待输出…"
var body: some View {
// 尺寸**完全由外部决定**:行堆叠放进 `Color.clear` 的 overlay —— overlay 不参与父视图定尺,
// 故行数再多也顶不高卡片。(此前行多的卡比同排的高 20pt,一排卡片上下边对不齐;
// 外层 `.frame(height:)` 也压不住它,因为 VStack 的最小高度就是各行高之和。)
// 溢出的旧行被 `clipped()` 切掉,正是想要的——内容底对齐,留住最新输出。
Color.clear
// 有输出时贴底(新行在下缘,与真终端的视觉重心一致);空的时候占位文案居中。
.overlay(alignment: lines.isEmpty ? .center : .bottomLeading) { rows }
.background(background)
.clipped()
}
private var rows: some View {
VStack(alignment: .leading, spacing: 0) {
if lines.isEmpty {
Text(placeholder)
.font(TX.Font.mono(fontSize))
.foregroundStyle(p.text4)
.frame(maxWidth: .infinity, alignment: .center)
}
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(line.isEmpty ? " " : line)
.font(TX.Font.mono(fontSize))
// 设计:终端次要文本 11px / 行高 1.5 → 行距 ≈ 字号 × 0.6。
.lineSpacing(fontSize * 0.6)
.foregroundStyle(color(for: line))
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(.horizontal, 14)
.padding(.vertical, TX.Space.xs)
}
/// 启发式着色:`readViewportText()` 只给纯文本(ANSI 属性已丢),故按行首符号/常见片段上色,
/// 逼近真实终端的信息密度。不追求语法正确——错判只影响一行的颜色,不影响可读性。
private func color(for line: String) -> Color {
let t = line.trimmingCharacters(in: .whitespaces)
if t.isEmpty { return p.text4 }
if t.hasPrefix("✓") || t.hasPrefix("✔") || t.hasPrefix("✅") || t.hasPrefix("**") {
return p.online
}
if t.hasPrefix("✗") || t.hasPrefix("❌") { return p.danger }
let lower = t.lowercased()
if lower.hasPrefix("error") || lower.contains(" error:") { return p.danger }
if lower.hasPrefix("warning") || lower.contains(" warning:") { return p.warning }
if t.hasPrefix("◇") || t.hasPrefix("▸") || t.hasPrefix("↳") || t.hasPrefix("*") {
return p.mosh
}
if t.hasPrefix("●") { return p.online }
// 提示符行(→ / ➜ / $ 结尾的命令行)用主文字色,命令输出用次文字色 —— 与真实终端观感一致。
if t.hasPrefix("→") || t.hasPrefix("➜") || t.contains("$ ") { return p.text }
if t.hasPrefix("[") { return p.offline }
return p.text3
}
}

View File

@@ -0,0 +1,80 @@
import Foundation
/// 一个 Tailscale 连接(= 一个常驻 tsnet 节点)。可添加多个;host 可绑定其一作为出口。
/// 每连接独立 stateDir(`tsnet-<uuid>`,创建即固化、终身不变——tsnet 无文件锁,同 Dir 双 Up 会损坏状态)。
struct TailscaleConnection: Identifiable, Codable, Equatable {
let id: UUID
var name: String
/// tailnet 内展示的主机名。
var hostname: String
/// tsnet 状态目录名(Application Support 下),创建即固化。
let stateDirName: String
/// true = 从未注册 或 被 tailnet 清理 → 需重填 authKey 才能 Up。
var needsAuthKey: Bool
/// 展示用缓存:上次 Up 后的本节点 tailnet IP。
var lastSelfIP: String?
init(id: UUID = UUID(), name: String, hostname: String = "terminalx-ipad",
needsAuthKey: Bool = true, lastSelfIP: String? = nil) {
self.id = id
self.name = name
self.hostname = hostname
self.stateDirName = "tsnet-\(id.uuidString)"
self.needsAuthKey = needsAuthKey
self.lastSelfIP = lastSelfIP
}
}
/// Tailscale 连接列表持久化(tailnets.json)。本体无机密(authKey 默认注册后即丢弃)。
@MainActor
final class TailscaleStore: ObservableObject {
@Published private(set) var connections: [TailscaleConnection] = []
private let url: URL
init() {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
url = base.appendingPathComponent("tailnets.json")
load()
}
func add(_ conn: TailscaleConnection) { connections.append(conn); save() }
func update(_ conn: TailscaleConnection) {
guard let i = connections.firstIndex(where: { $0.id == conn.id }) else { return }
connections[i] = conn
save()
}
func remove(_ conn: TailscaleConnection) {
connections.removeAll { $0.id == conn.id }
save()
// 删连接 → 删其 tsnet 状态目录,避免残留身份。
let base = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0]
try? FileManager.default.removeItem(atPath: base + "/" + conn.stateDirName)
}
func connection(id: UUID?) -> TailscaleConnection? {
guard let id else { return nil }
return connections.first { $0.id == id }
}
#if DEBUG
/// 无头 UI 验证:注入示意 tailnet(**不写盘**,不污染真实 tailnets.json)。
func injectFixtureConnections(_ list: [TailscaleConnection]) {
connections = list
}
#endif
private func load() {
guard let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode([TailscaleConnection].self, from: data) else { return }
connections = decoded
}
private func save() {
guard let data = try? JSONEncoder().encode(connections) else { return }
try? data.write(to: url, options: .atomic)
}
}

View File

@@ -0,0 +1,206 @@
import SwiftUI
/// Tailscale 连接列表页。可添加多个;每个是一个常驻 tsnet 节点。
struct TailscaleListView: View {
@ObservedObject var store: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@State private var editing: TailscaleConnection?
@State private var showEditor = false
private var p: TXPalette { theme.palette }
var body: some View {
ZStack {
p.bg.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: TX.Space.s) {
if store.connections.isEmpty {
TXEmptyState(icon: "network", title: "还没有 Tailscale 连接",
subtitle: "添加一个 tailnet,主机可选择经它连接")
} else {
ForEach(store.connections) { conn in
connRow(conn)
.contextMenu {
Button { editing = conn; showEditor = true } label: {
Label("编辑", systemImage: "pencil")
}
Button(role: .destructive) { store.remove(conn) } label: {
Label("删除", systemImage: "trash")
}
}
}
}
}
.padding(TX.Space.m)
}
}
.navigationTitle("Tailscale")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(p.bg, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { editing = nil; showEditor = true } label: { Image(systemName: "plus") }
}
}
.sheet(isPresented: $showEditor) {
TailscaleEditorView(existing: editing, store: store)
}
}
private func connRow(_ conn: TailscaleConnection) -> some View {
HStack(spacing: TX.Space.m) {
Image(systemName: "network")
.font(.system(size: 18))
.foregroundStyle(conn.needsAuthKey ? p.textTertiary : p.mosh)
.frame(width: 40, height: 40)
.background(p.surfaceElevated, in: RoundedRectangle(cornerRadius: TX.Radius.chip))
VStack(alignment: .leading, spacing: 2) {
Text(conn.name).font(.system(size: 15, weight: .semibold)).foregroundStyle(p.textPrimary)
Text(conn.needsAuthKey ? "未认证" : (conn.lastSelfIP.map { "已注册 · \($0)" } ?? "已注册"))
.font(TX.Font.mono(12))
.foregroundStyle(conn.needsAuthKey ? p.warning : p.textSecondary)
}
Spacer()
Circle().fill(conn.needsAuthKey ? p.offline : p.mosh).frame(width: 8, height: 8)
}
.padding(TX.Space.m)
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1))
}
}
/// Tailscale 连接编辑页:填 name/hostname/authKey → 注册(Up tsnet 节点)→ 显示 SelfIP。
struct TailscaleEditorView: View {
let existing: TailscaleConnection?
@ObservedObject var store: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var hostname = "terminalx-ipad"
@State private var authKey = ""
@State private var registering = false
@State private var statusText = ""
@State private var registered = false
@State private var draft: TailscaleConnection?
private var p: TXPalette { theme.palette }
private var canRegister: Bool { !name.isEmpty && (!authKey.isEmpty || registered) }
var body: some View {
NavigationStack {
ZStack {
p.bg.ignoresSafeArea()
Form {
Section("连接") {
TXTextField(title: "名称", text: $name, placeholder: "公司 tailnet")
TXTextField(title: "本节点主机名", text: $hostname)
}
Section {
TXTextField(title: "Auth Key(tskey-…)", text: $authKey, secure: true)
Button {
register()
} label: {
HStack {
if registering { ProgressView().controlSize(.small) }
Text(registering ? "注册中…" : "注册 / 连接测试")
}
}
.disabled(!canRegister || registering)
.txRow(p)
} header: {
Text("认证")
} footer: {
Text("Auth key 仅用于首次注册,注册成功后不保存(节点身份持久化在设备本地)。")
}
if !statusText.isEmpty {
Section {
Text(statusText)
.font(.system(size: 13))
.foregroundStyle(registered ? p.accent : p.warning)
.txRow(p)
}
}
}
.scrollContentBackground(.hidden)
}
.navigationTitle(existing == nil ? "添加 Tailscale" : "编辑 Tailscale")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }.tint(p.textSecondary)
}
ToolbarItem(placement: .confirmationAction) {
Button("完成") { finish() }.tint(p.accent)
}
}
.toolbarBackground(p.bg, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.onAppear(perform: populate)
}
.tint(p.accent)
}
private func populate() {
guard let c = existing else { return }
name = c.name; hostname = c.hostname
registered = !c.needsAuthKey
draft = c
if registered { statusText = c.lastSelfIP.map { "已注册 · \($0)" } ?? "已注册" }
}
/// 注册:经 TsnetRegistry Up 该连接的节点(后台阻塞),成功后 SelfIP 落库、needsAuthKey=false。
private func register() {
// 确定连接身份(新建则造一个,固化 stateDir)。
let conn = draft ?? existing ?? {
let c = TailscaleConnection(name: name, hostname: hostname)
return c
}()
draft = conn
registering = true; statusText = ""
let connID = conn.id, dir = conn.stateDirName, host = hostname, key = authKey
Task.detached {
do {
let node = try TsnetRegistry.shared.node(
connID: connID, stateDirName: dir, hostname: host, authKey: key, timeoutMs: 45000)
let ip = node.selfIP()
await MainActor.run {
registering = false; registered = true
statusText = ip.isEmpty ? "已注册(IP 获取中)" : "已注册 · \(ip)"
// 落库(保留同一 id/stateDir)。
let updated = TailscaleConnection(id: connID, name: name.isEmpty ? "tailnet" : name,
hostname: host, needsAuthKey: false,
lastSelfIP: ip.isEmpty ? nil : ip)
persist(updated)
}
} catch {
await MainActor.run {
registering = false
statusText = "注册失败:\(error.localizedDescription)"
}
}
}
}
/// 完成:若已注册则确保落库;未注册也保存 meta(needsAuthKey=true,供之后再认证)。
private func finish() {
let base = draft ?? existing
let conn: TailscaleConnection
if let base {
conn = TailscaleConnection(id: base.id, name: name.isEmpty ? "tailnet" : name,
hostname: hostname, needsAuthKey: !registered,
lastSelfIP: registered ? base.lastSelfIP : nil)
} else {
conn = TailscaleConnection(name: name.isEmpty ? "tailnet" : name,
hostname: hostname, needsAuthKey: !registered)
}
persist(conn)
dismiss()
}
private func persist(_ conn: TailscaleConnection) {
if store.connections.contains(where: { $0.id == conn.id }) { store.update(conn) }
else { store.add(conn) }
draft = conn
}
}

View File

@@ -0,0 +1,470 @@
import SwiftUI
// 设计稿 `1b 终端-沉浸轨道` 的浮起 chrome:左上标题胶囊 + 右上状态胶囊 + 底部提示条。
//
// 浮起(absolute + blur)而非占一行布局:终端面积最大化是这一屏的唯一目标。
// 终端内容容器顶部留 86pt 预留带给胶囊,预留带用 padding 实现(容器 clipped 防向上溢出)。
/// 浮起胶囊的共用外观:blur 材质 + 半透底 + 常规描边 + 圆角 11。
///
/// **扁平**:分层交给描边和材质,投影只留一点点(`TX.Elevation.floating`)。原先那道
/// `0 10px 30px rgba(0,0,0,.55)` 在终端文字上糊出一大团脏斑 —— 胶囊底本身是半透的,
/// 投影一重就成了"漏墨"。底色相应提到 .92,少了投影仍然读得清。
private struct FloatingCapsule<Content: View>: View {
@EnvironmentObject var theme: TXThemeManager
@ViewBuilder let content: Content
private var p: TXPalette { theme.palette }
var body: some View {
content
.padding(.horizontal, TX.Space.s)
.frame(height: 34)
.background {
RoundedRectangle(cornerRadius: TX.Radius.capsule)
.fill(p.surfaceDark.opacity(0.92))
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: TX.Radius.capsule))
}
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.capsule)
.strokeBorder(p.border, lineWidth: 1)
)
.txShadow(TX.Elevation.floating)
}
}
/// 左上标题胶囊:红/黄圆点 → 竖分隔 → 状态点 → 主机名 → 「window title · cwd」。
struct TerminalTitleCapsule: View {
@ObservedObject var session: TerminalSession
let onClose: () -> Void
let onMinimize: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
FloatingCapsule {
HStack(spacing: TX.Space.xs) {
// 顺序沿用 macOS 的肌肉记忆(关闭在左、最小化在右),但形态改成带字形的按钮,
// 详见 windowButton。
windowButton(icon: "xmark", tint: TXWindowDot.close,
label: "关闭会话", action: onClose)
// 向下的 chevron 而不是双向内收箭头:后者在 9.5pt 下糊成一团认不出。
// 向下同时对上设计里的「下滑最小化」手势语义。
windowButton(icon: "chevron.down", tint: TXWindowDot.minimize,
label: "最小化到首页", action: onMinimize)
Rectangle().fill(p.border).frame(width: 1, height: 14)
.padding(.leading, 4)
.padding(.trailing, 2)
TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 6)
Text(session.displayName)
.font(TX.Font.mono(12.5, .medium))
.foregroundStyle(p.text)
.lineLimit(1)
if !contextLine.isEmpty {
Text(contextLine)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
// 终端格子数(设计稿 08 的「· 168×44」)。侧边栏开合会 resize ——
// 数字经 GridBox.onGridChange → session.objectWillChange 跟着刷新。
Text(gridLabel)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textFaint)
}
}
}
/// 「当前 window title · cwd」(设计稿 3a)。无 tmux 时退回连接状态文案。
private var contextLine: String {
guard let t = session.tmuxSummary else {
return session.isLive ? "" : session.phaseText
}
var parts: [String] = []
if !t.currentTitle.isEmpty { parts.append(t.currentTitle) }
if !t.cwd.isEmpty { parts.append(Self.abbreviate(t.cwd)) }
return parts.joined(separator: " · ")
}
/// `/Users/yz/src/x` → `~/src/x`(终端习惯写法,设计稿即此形态)。
static func abbreviate(_ path: String) -> String {
let home = NSHomeDirectory()
if !home.isEmpty, path.hasPrefix(home) { return "~" + path.dropFirst(home.count) }
// 远端 home 与本机不同,按常见前缀兜底。
for prefix in ["/home/", "/Users/"] where path.hasPrefix(prefix) {
let rest = path.dropFirst(prefix.count)
if let slash = rest.firstIndex(of: "/") { return "~" + rest[slash...] }
return "~"
}
return path
}
private var gridLabel: String {
let g = session.screenGrid.value
return "\(g.cols)×\(g.rows)"
}
/// 关闭 / 最小化按钮。
///
/// 原来是 macOS 交通灯式的 13pt 纯色圆点,三处不合适:① 纯色块**不说明自己是干什么的** ——
/// mac 上靠 hover 才浮出字形,触屏没有 hover,于是永远只是两个色点;② 13pt 命中目标在
/// 触屏上太小,而"关闭"误触代价很高;③ 圆点是 mac 窗口的语汇,与本 app 通篇的圆角矩形
/// 控件语汇不搭。
///
/// 现在:22pt 圆角方块 + **常显字形**(`✕` 关闭 / `⌄` 收下去 = 缩回首页卡片),颜色语义保留
/// (红=关闭、琥珀=最小化),底色只上 14% 不铺满 —— 与徽标/控件同一套形状语言,命中 26×34。
private func windowButton(icon: String, tint: Color, label: String,
action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 9.5, weight: .bold))
.foregroundStyle(tint)
.frame(width: 22, height: 22)
.background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 7))
.overlay(
RoundedRectangle(cornerRadius: 7).strokeBorder(tint.opacity(0.45), lineWidth: 1)
)
.frame(width: 26, height: 34)
.contentShape(Rectangle())
}
.buttonStyle(.txPressTight)
.accessibilityLabel(label)
}
}
/// 右上状态胶囊:三格分层徽标 `tsnet | mosh | tmux`,格间 1×16pt 竖线。
///
/// **只染出问题的那层**:网络层不可达 → 第一格转琥珀;重连中 → 第二格转琥珀;tmux 缺失 → 第三格
/// 写「无 tmux · 客户端窗口」而非报错。首版第二格不显示 RTT 数字(见 HANDOFF §12.4)。
struct TerminalStatusCapsule: View {
@ObservedObject var session: TerminalSession
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
FloatingCapsule {
HStack(spacing: 0) {
cell(egressText, color: egressColor, dot: true)
separator
cell(transportText, color: transportColor, dot: false)
separator
cell(tmuxText, color: p.text2, dot: false)
}
}
}
private var separator: some View {
Rectangle().fill(p.border).frame(width: 1, height: 16)
.padding(.horizontal, TX.Space.s)
}
private func cell(_ text: String, color: Color, dot: Bool) -> some View {
HStack(spacing: 5) {
if dot { TXStatusDot(color: color, size: 6) }
Text(text)
.font(TX.Font.mono(12, .medium))
.foregroundStyle(color)
.lineLimit(1)
}
}
// 第一格:网络可达性(tsnet 出口 / 直连)
private var egressText: String {
switch session.link {
case .relay: "tsnet"
case .direct: "direct"
case .connecting: "连接中"
case .reconnecting: "tsnet"
case .offline: "离线"
}
}
private var egressColor: Color {
switch session.link {
case .direct, .relay: p.online
case .connecting: p.text4
case .reconnecting: p.reconnecting
case .offline: p.offline
}
}
// 第二格:传输层(mosh / SSH)
private var transportText: String {
if case .reconnecting(let n) = session.link {
return "\(session.moshEngaged ? "mosh" : "ssh") 重连 \(n)"
}
return session.moshEngaged ? "mosh" : "ssh"
}
private var transportColor: Color {
if case .reconnecting = session.link { return p.reconnecting }
// 设计:这一格的文字是中性色,只有 RTT 数字才染绿/琥珀(首版无 RTT → 全中性)。
return p.text2
}
// 第三格:会话复用层
private var tmuxText: String {
if let t = session.tmuxSummary { return t.name.isEmpty ? "tmux" : "tmux \(t.name)" }
// 还在探测 / 等用户选会话时别谎报「无 tmux」——那是探测确定不可用后才成立的结论。
if session.pendingSessionChoice != nil { return "选择 tmux 会话…" }
if session.tmuxSkipped { return "原生窗口 · 不经 tmux" }
if session.tmuxUnavailable { return "无 tmux · 客户端窗口" }
return session.isLive ? "检测 tmux…" : "—"
}
}
/// 终端区的**非内容态**覆盖层:连接中 / 失败 / 已关闭。
///
/// 真实连接不是瞬间的:解析凭据 → tsnet Up → dial → 认证 → (mosh 引导)→ tmux attach,
/// 期间终端一片空白。这层给出「现在在干什么、失败了能怎么办」,避免用户对着黑屏猜。
/// 已连接(含重连中,屏幕内容还在)时**不显示**,不遮挡终端。
struct TerminalStateOverlay: View {
@ObservedObject var session: TerminalSession
let onRetry: () -> Void
let onBack: () -> Void
/// 取消正在进行的连接(用户不想再等)。
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
/// 连接已等待秒数 —— 真实连接可能要走 tsnet Up / mosh 引导,等待要可见、可取消。
@State private var waited = 0
private var p: TXPalette { theme.palette }
/// 失败态:phaseText 由 SessionMachine 给出(`失败:…`)。
private var failure: String? {
session.phaseText.hasPrefix("失败") ? String(session.phaseText.dropFirst(3)) : nil
}
private var isConnecting: Bool {
!session.isLive && failure == nil && session.phaseText != "已关闭"
}
var body: some View {
if let failure {
state(icon: "exclamationmark.triangle", tint: p.danger, title: "连接失败",
detail: failure) {
HStack(spacing: TX.Space.s) {
TXButton(title: "返回首页", action: onBack)
TXButton(title: "重试", emphasized: true, action: onRetry)
}
}
} else if session.phaseText == "已关闭" {
state(icon: "power", tint: p.textWeak, title: "会话已关闭",
detail: "服务端连接已断开。") {
HStack(spacing: TX.Space.s) {
TXButton(title: "返回首页", action: onBack)
TXButton(title: "重新连接", emphasized: true, action: onRetry)
}
}
} else if isConnecting {
state(icon: nil, tint: p.text4, title: session.phaseText,
detail: session.banner.isEmpty ? connectHint : session.banner) {
VStack(spacing: TX.Space.xs) {
if waited >= 3 {
Text("已等待 \(waited)s")
.font(TX.Font.mono(11))
.foregroundStyle(p.textFaint)
}
if waited >= 5 {
// 等久了才给取消:避免正常的 1–2s 连接闪现一个按钮。
TXButton(title: "取消连接", action: onCancel)
}
}
}
.task(id: session.phaseText) {
waited = 0
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
waited += 1
}
}
}
}
/// 分阶段提示:让等待有解释(尤其 tsnet 首次 Up 与 mosh 引导都要数秒)。
private var connectHint: String {
switch session.link {
case .relay: "正在经 Tailscale 出口建立连接…"
case .connecting, .direct: session.moshEngaged ? "正在引导 mosh…" : "正在建立 SSH 连接…"
case .reconnecting(let n): "第 \(n) 次重连中…"
case .offline: "等待连接…"
}
}
private func state<Actions: View>(icon: String?, tint: Color, title: String, detail: String,
@ViewBuilder actions: () -> Actions) -> some View {
VStack(spacing: TX.Space.s) {
if let icon {
Image(systemName: icon)
.font(.system(size: 26, weight: .light))
.foregroundStyle(tint)
} else {
ProgressView().controlSize(.regular).tint(TXAccent.base)
}
Text(title)
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Text(detail)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.multilineTextAlignment(.center)
.lineLimit(3)
.frame(maxWidth: 420)
actions()
.padding(.top, TX.Space.xs)
}
.padding(TX.Space.l)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// 连上后这一层要淡出去,别"啪"地掀掉(动画由 paneArea 按 phaseText 驱动)。
.transition(.opacity)
}
}
/// 无 tmux 主机的行内横幅(设计稿 `2c`):说明窗口由 App 维持、mosh 兜底,但**关掉 App 不会在
/// 服务端继续跑**;右侧给「安装并启用 tmux」次要按钮。**不弹窗、不强推**,可关闭。
struct NoTmuxBanner: View {
let onInstall: () -> Void
let onDismiss: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: TX.Space.s) {
Image(systemName: "info.circle")
.font(.system(size: 12))
.foregroundStyle(p.warning)
Text("未检测到 tmux · 当前是客户端窗口:mosh 能兜断线,但**关掉 App 后服务端不会继续跑**")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.text3)
.lineLimit(1)
Spacer(minLength: TX.Space.s)
TXButton(title: "安装并启用 tmux", action: onInstall)
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(p.textWeak)
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.txPressTight)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 40)
.background(p.surfaceDark.opacity(0.92))
.overlay(alignment: .bottom) { Rectangle().fill(p.divider).frame(height: 1) }
}
}
/// 「安装并启用 tmux」的引导(点横幅按钮后):给出各发行版命令,用户可一键塞进终端执行。
struct TmuxInstallSheet: View {
let onRun: (String) -> Void
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
/// 常见包管理器。跨发行版探测一条命令搞定:有哪个用哪个。
private let combined = "command -v apt-get >/dev/null && sudo apt-get install -y tmux || " +
"command -v dnf >/dev/null && sudo dnf install -y tmux || " +
"command -v pacman >/dev/null && sudo pacman -S --noconfirm tmux || " +
"command -v apk >/dev/null && sudo apk add tmux || " +
"command -v brew >/dev/null && brew install tmux"
var body: some View {
TXModalScrim(onDismiss: onCancel) {
VStack(alignment: .leading, spacing: TX.Space.s) {
Text("安装 tmux")
.font(TX.Font.mono(15, .medium))
.foregroundStyle(p.text)
Text("tmux 让会话留在服务端:关掉 App、换网络、睡一觉回来都能原样接回。\n下面这条会按服务器的包管理器自动选择(需要 sudo 权限)。")
.font(TX.Font.mono(12))
.foregroundStyle(p.text3)
.lineSpacing(4)
Text(combined)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.text4)
.padding(TX.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.bgDeep, in: RoundedRectangle(cornerRadius: 8))
HStack(spacing: TX.Space.s) {
Spacer()
TXButton(title: "取消", action: onCancel)
TXButton(title: "在终端执行", emphasized: true) { onRun(combined) }
}
.padding(.top, TX.Space.xs)
}
.padding(TX.Space.l)
.frame(width: 520)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.dialog)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.txShadow(TX.Elevation.dialog)
}
}
}
/// 舞台底部动作条。
///
/// **不是快捷键清单,而是真按钮**:iPad 大概率没接键盘,把「竖分屏 / 新 window / 回首页」
/// 只写成 `⌘D` 之类的文字提示等于这些功能对触控用户不存在。硬件键盘接上后这几个动作
/// 仍然可以走快捷键(UI-5),按钮照旧留着 —— 常驻可点是底线。
///
/// 高度 = 内容带 34pt + home indicator 安全区,内容在其中**居中** —— 与侧边栏脚、轨道齿轮
/// 同一条中心线。(此前只有 34pt 且被安全区顶起,屏幕底下留一条 24pt 的空带,看着像"没铺到底"。)
struct TerminalActionBar: View {
/// 左侧上下文说明(会话/链路状态一句话)。
let context: String
let actions: [Action]
let safeBottom: CGFloat
@EnvironmentObject var theme: TXThemeManager
@Environment(\.horizontalSizeClass) private var hSize
private var p: TXPalette { theme.palette }
struct Action: Identifiable {
let id: String
let icon: String
let title: String
/// 不可用时灰显但仍在(如非 tmux 会话不能分屏)——不隐藏,免得布局跳。
var enabled: Bool = true
let run: () -> Void
}
/// 窄屏只留前两个:四个在 iPhone 上放不下,会折行把 34pt 的带撑破。
private var shown: [Action] { hSize == .compact ? Array(actions.prefix(2)) : actions }
var body: some View {
HStack(spacing: TX.Space.s) {
Text(context)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
Spacer(minLength: TX.Space.s)
ForEach(shown) { action in
Button(action: action.run) {
HStack(spacing: 5) {
Image(systemName: action.icon)
.font(.system(size: 10.5, weight: .medium))
Text(action.title)
.font(TX.Font.mono(11.5, .medium))
}
.foregroundStyle(action.enabled ? p.text2 : p.textFaint)
.padding(.horizontal, TX.Space.xs)
.frame(height: 26)
.background(action.enabled ? p.surface : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.badge))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.badge)
.strokeBorder(action.enabled ? p.border : p.divider, lineWidth: 1)
)
// 动作条永远**单行**:宁可少放一个也不许折行(折行会溢出底部带)。
.lineLimit(1)
.fixedSize()
}
.buttonStyle(.txPress)
.disabled(!action.enabled)
}
}
.padding(.horizontal, TX.Space.m)
.frame(height: TX.Layout.footBand(safeBottom))
.animation(TX.Motion.standard, value: context)
}
}

View File

@@ -0,0 +1,284 @@
import SwiftUI
// 设计稿 `3c 关闭确认` 与 `3b 新建分屏菜单`。
//
// 语义分工(务必别混):
// - **黄点 / ⌘M / 下滑 = 最小化**:不确认,直接回首页,会话继续跑。
// - **红点 / ⌘W = 关闭**:弹一次确认,按主机能力两套文案;`esc` 取消、`⏎` 选推荐项。
/// 浮层容器:遮罩 + 居中卡片(对话框 / 菜单共用)。
///
/// 遮罩淡入、卡片从 96% 长出来(`TX.Motion.pop`)—— 两段分开写才有层次;整块套一个
/// `.transition(.opacity)` 的话卡片只是跟着变淡,弹出感全无。
/// 动画事务由 `AppRouter.present/dismissModal` 提供。
struct TXModalScrim<Content: View>: View {
let onDismiss: () -> Void
@ViewBuilder let content: Content
var body: some View {
ZStack {
TX.Scrim.color.opacity(TX.Scrim.dialog)
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture(perform: onDismiss)
.transition(.opacity)
content
.transition(TX.Motion.pop)
}
}
}
// MARK: - 关闭确认(3c)
/// 关闭确认。两套文案由会话能力决定:
/// - **tmux 会话**:推荐「仅断开」(`detach-client`,服务端继续跑);左侧次要文字按钮「结束会话」
/// (`kill-session`,**需再确认一次**)。
/// - **原生窗口**:琥珀警告「没有 tmux 兜底,前台进程会随之结束」,推荐「取消」,危险按钮「关闭窗口」。
struct CloseConfirmDialog: View {
@ObservedObject var session: TerminalSession
/// 仅断开(tmux)/ 直接关闭(原生窗口)
let onDetach: () -> Void
/// 结束 tmux 会话(kill-session)
let onKill: () -> Void
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
@State private var confirmingKill = false
private var p: TXPalette { theme.palette }
private var isTmux: Bool { session.tmuxSummary != nil }
var body: some View {
TXModalScrim(onDismiss: onCancel) {
VStack(alignment: .leading, spacing: 0) {
Text(title)
.font(TX.Font.mono(15, .medium))
.foregroundStyle(p.text)
.padding(.bottom, 8)
message
.padding(.bottom, TX.Space.l)
buttons
}
.padding(TX.Space.l)
.frame(width: isTmux ? 436 : 404)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.dialog)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.txShadow(TX.Elevation.dialog)
}
}
private var title: String {
if let t = session.tmuxSummary, !t.name.isEmpty {
return "关闭 \(session.displayName) · \(t.name)?"
}
// 原生窗口带窗口编号(首版每主机单窗口,恒为 1;多窗口是二期能力)。
return "关闭 \(session.displayName) · 窗口 1?"
}
@ViewBuilder private var message: some View {
if let t = session.tmuxSummary {
Text("tmux 会话 (\(t.windows) windows / \(t.panes) panes)。仅断开后服务端继续跑,下次原样接回。")
.font(TX.Font.mono(12))
.foregroundStyle(p.text3)
.lineSpacing(4)
} else {
Text("没有 tmux 兜底,")
.font(TX.Font.mono(12))
.foregroundStyle(p.text3)
+ Text("前台进程会随之结束")
.font(TX.Font.mono(12, .medium))
.foregroundStyle(p.warning)
+ Text("。想留着就点黄点最小化。")
.font(TX.Font.mono(12))
.foregroundStyle(p.text3)
}
}
@ViewBuilder private var buttons: some View {
HStack(spacing: TX.Space.s) {
if isTmux {
// 次要文字按钮(危险):kill-session 需再点一次确认。
Button {
if confirmingKill { onKill() } else { confirmingKill = true }
} label: {
Text(confirmingKill ? "确认结束会话?" : "结束会话")
.font(TX.Font.mono(12, confirmingKill ? .semibold : .regular))
.foregroundStyle(p.danger)
}
.buttonStyle(.txPress)
}
Spacer()
TXButton(title: "取消", action: onCancel)
if isTmux {
TXButton(title: "仅断开 ⏎", emphasized: true, action: onDetach)
} else {
dangerButton
}
}
}
/// 危险按钮(设计给的精确色:描边 #8d5560 / 底 rgba(92,58,63,.35) / 字 #f5a0ac)。
private var dangerButton: some View {
Button(action: onDetach) {
Text("关闭窗口")
.font(TX.Font.mono(12, .medium))
.foregroundStyle(Color(hex: 0xF5A0AC))
.padding(.horizontal, 14)
.frame(height: 32)
.background(Color(hex: 0x5C3A3F).opacity(0.35),
in: RoundedRectangle(cornerRadius: TX.Radius.control))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(Color(hex: 0x8D5560), lineWidth: 1)
)
}
.buttonStyle(.txPress)
}
}
// MARK: - 新建分屏菜单(3b)
/// 分屏菜单。**不问方向**(竖分 ⌘D / 横分 ⌘⇧D 本就是两个入口),只问「分到哪里」:
/// 当前会话 → 直接 `split-window`;其余 detached 会话 → `join-pane -s`;也可新建一个 tmux 会话。
///
/// 模型约束:分屏只发生在**同一台主机内**(跨主机分屏设计已取消)。
struct SplitMenuView: View {
@ObservedObject var session: TerminalSession
/// true = 竖分(左右并排,⌘D);false = 横分(上下堆叠,⌘⇧D)
let vertical: Bool
let onPick: (SplitTarget) -> Void
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
enum SplitTarget {
case currentSession
case otherSession(String)
case newSession
/// 原生窗口(不经 tmux)。首版不可用:需要一台主机多条 SSH channel(见 HANDOFF §12.4)。
case nativeWindow
}
/// 其余会话 = 除了本客户端所在的那个。(不能按 `isAttached` 过滤——服务器上别的客户端
/// attach 着其它会话时会把它们错误地排除掉。)
private var detached: [TmuxSessionInfo] {
let current = session.tmuxSummary?.name ?? ""
return session.tmuxSessionList.filter { $0.name != current }
}
var body: some View {
TXModalScrim(onDismiss: onCancel) {
VStack(alignment: .leading, spacing: 0) {
header
Rectangle().fill(p.divider).frame(height: 1)
if session.tmuxSummary != nil {
TXSectionLabel(text: "tmux 会话")
.padding(.horizontal, TX.Space.m)
.padding(.top, TX.Space.s)
.padding(.bottom, 2)
row(title: currentName, detail: currentDetail, dot: p.online,
tag: "当前", selected: true) { onPick(.currentSession) }
ForEach(detached) { s in
// detached 会话的进程仍在服务端跑 → 状态点同样是绿的(设计稿如此)。
row(title: s.name,
detail: "\(s.windows) window\(s.windows == 1 ? "" : "s") · detached",
dot: p.online,
tag: s.lastActivity.map { SessionCard.relative($0) }) {
onPick(.otherSession(s.name))
}
}
row(title: "新建 tmux 会话…", detail: nil, dot: nil, tag: nil) {
onPick(.newSession)
}
Rectangle().fill(p.divider).frame(height: 1)
.padding(.vertical, TX.Space.xs)
}
// 原生窗口:设计里是并列选项;首版能力未就绪 → 灰显并注明,不装作能用。
row(title: "原生窗口", detail: "不经 tmux · 关 App 即结束(二期)",
dot: nil, tag: nil, disabled: true) {}
}
.padding(.bottom, TX.Space.xs)
.frame(width: 360)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog - 1))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.dialog - 1)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.txShadow(TX.Elevation.dialog)
}
}
private var header: some View {
HStack {
Text(vertical ? "竖分屏到…" : "横分屏到…")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Spacer()
Text(session.displayName)
.font(TX.Font.mono(11))
.foregroundStyle(TXAccent.mid)
}
.padding(.horizontal, TX.Space.m)
.frame(height: 44)
}
private var currentName: String {
guard let t = session.tmuxSummary else { return session.displayName }
return t.name.isEmpty ? "当前会话" : t.name
}
private var currentDetail: String {
guard let t = session.tmuxSummary else { return "" }
var parts: [String] = []
if !t.currentTitle.isEmpty { parts.append(t.currentTitle) }
parts.append("\(t.windows) window\(t.windows == 1 ? "" : "s")")
if t.panes > 0 { parts.append("\(t.panes) panes") }
return parts.joined(separator: " · ")
}
/// 菜单行:**前导列统一 20pt**(状态点也包在 20pt 居中列里),保证所有行文字左缘一致。
private func row(title: String, detail: String?, dot: Color?, tag: String?,
selected: Bool = false, disabled: Bool = false,
action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: TX.Space.s) {
ZStack {
if let dot { TXStatusDot(color: dot, size: 7) }
}
.frame(width: 20)
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(TX.Font.mono(13, .medium))
.foregroundStyle(disabled ? p.textWeak : p.text)
if let detail, !detail.isEmpty {
Text(detail)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
}
Spacer(minLength: TX.Space.xs)
if let tag {
Text(tag)
.font(TX.Font.mono(10.5, .medium))
.foregroundStyle(selected ? TXAccent.text : TXAccent.mid)
}
}
.padding(.horizontal, TX.Space.s)
.frame(height: 40)
.frame(maxWidth: .infinity, alignment: .leading)
.background(selected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(selected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.padding(.horizontal, TX.Space.xs)
.contentShape(Rectangle())
}
.buttonStyle(.txPress)
.disabled(disabled)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,434 @@
import SwiftUI
import UIKit
import GhosttyTerminal
import GhosttyTheme
// MARK: - 设计令牌(对齐 docs/design/UI-DESIGN-HANDOFF.md)
//
// 三层分工,别混:
// TXAccent —— 唯一强调色(Nocturne blurple),**恒定不随主题变**。只做线、点、选中底和文字,绝不大面积铺色。
// TXChrome —— app chrome 的中性阶。切 Catppuccin flavor 时这一层随之推导(4 张预生成表,见下)。
// TXFlavor —— Catppuccin flavor 本体:驱动**终端内容区**(libghostty 渲染)+ 提供状态语义色。
// TXPalette 是三者的门面,视图只吃 palette。
/// 唯一强调色:Nocturne blurple 阶。恒定 —— 换主题只换中性阶与终端配色,强调色不动。
enum TXAccent {
/// `--color-accent`:主强调(线、点、选中描边的源色)
static let base = Color(hex: 0x9184D9)
/// accent-600:强调描边(如主题卡选中框)
static let border = Color(hex: 0x796CBF)
/// accent-900:选中底(列表行、轨道头像)
static let selectedBg = Color(hex: 0x2B2741)
/// accent-800:选中描边(配 inset stroke 用,避免选中时内容位移 1pt)
static let selectedStroke = Color(hex: 0x423A6A)
/// accent-400:强调文字
static let text = Color(hex: 0xB5ABFC)
/// accent-300:强调文字(更亮,用于 hover/主按钮标题)
static let textBright = Color(hex: 0xD2CEFD)
/// accent-500:弱化强调文字(分组小标题)
static let mid = Color(hex: 0x968AE0)
/// accent-700:极弱强调(轨道头像 inset 描边)
static let dim = Color(hex: 0x5D5294)
}
/// macOS 窗口按钮沿用系统色(关闭/最小化圆点),不进主题体系。
enum TXWindowDot {
static let close = Color(hex: 0xFF5F57)
static let minimize = Color(hex: 0xFEBC2E)
}
/// app chrome 的中性阶。**不含强调色**(那是 TXAccent 的事)。
struct TXChrome: Equatable {
/// 舞台底 / pane 之间的缝(最深)
let bgDeep: UInt32
/// 画布底 / 屏幕底
let bg: UInt32
/// 侧边栏 / 轨道 / 卡片底
let surfaceDark: UInt32
/// 控件底(输入框、次要按钮、徽标)
let surface: UInt32
/// 分隔线(弱)
let divider: UInt32
/// 描边(常规)
let border: UInt32
/// 描边(强调 / 对话框)
let borderStrong: UInt32
/// 描边(对话框内层 / 中等)
let borderDialog: UInt32
/// 主文字
let text: UInt32
/// 次文字(neutral-300)
let text2: UInt32
/// 次文字(neutral-400)
let text3: UInt32
/// 三级文字(neutral-500)
let text4: UInt32
/// 弱文字 / 占位(neutral-600)
let textWeak: UInt32
/// 极弱(提示、注脚,neutral-700)
let textFaint: UInt32
}
extension TXChrome {
/// Mocha 表 = 设计交付的**精确锚点值**,其余 flavor 由此按 HSL 平移推导(见 docs/design/render-screens.py 同目录笔记)。
static let mocha = TXChrome(
bgDeep: 0x11121C, bg: 0x161826, surfaceDark: 0x1B1D2B, surface: 0x232532,
divider: 0x2C2F3D, border: 0x3F424D, borderStrong: 0x595D6C, borderDialog: 0x4A4D5E,
text: 0xE9E9ED, text2: 0xCFD3E5, text3: 0xB2B6CA, text4: 0x9397AB,
textWeak: 0x75798C, textFaint: 0x4A4D5E)
/// Macchiato:按 flavor base 相对 Mocha 的色相/明度差平移(文字阶抬升幅度收一半,保住对比度)。
static let macchiato = TXChrome(
bgDeep: 0x171B28, bg: 0x1B2133, surfaceDark: 0x212737, surface: 0x292F3E,
divider: 0x323949, border: 0x464C58, borderStrong: 0x606877, borderDialog: 0x515869,
text: 0xEDEEF1, text2: 0xD3DAE9, text3: 0xB6BDCF, text4: 0x969EB0,
textWeak: 0x788091, textFaint: 0x515869)
static let frappe = TXChrome(
bgDeep: 0x222735, bg: 0x272E3F, surfaceDark: 0x2D3443, surface: 0x363C49,
divider: 0x3F4754, border: 0x535963, borderStrong: 0x6D7582, borderDialog: 0x5E6574,
text: 0xF4F4F5, text2: 0xDCE1EB, text3: 0xBEC5D1, text4: 0x9FA6B2,
textWeak: 0x818793, textFaint: 0x5E6574)
/// Latte 是唯一浅色 flavor:中性阶整体反转(底浅字深),不走平移。仍遵守"不用纯黑纯白"。
static let latte = TXChrome(
bgDeep: 0xE4E6ED, bg: 0xF3F4F8, surfaceDark: 0xFAFBFD, surface: 0xEBEDF3,
divider: 0xDCDEE7, border: 0xC7CAD6, borderStrong: 0xA9ADBD, borderDialog: 0xB8BCCA,
text: 0x2A2C3A, text2: 0x43465A, text3: 0x5A5E73, text4: 0x757994,
textWeak: 0x8E92A6, textFaint: 0xB0B4C4)
}
/// Catppuccin flavor:驱动终端内容区配色 + 提供状态语义色(徽标、状态点)。
struct TXFlavor: Equatable {
let id: String
let name: String
let isDark: Bool
/// 对应 GhosttyTheme 目录名,驱动终端配色。
let ghosttyName: String
// 语义色(Catppuccin 命名)
let green: UInt32 // 成功、提示符、在线
let red: UInt32 // error
let yellow: UInt32 // warning、git 标记
let blue: UInt32 // 路径、字段
let teal: UInt32 // 勾选、mosh
let peach: UInt32 // 重连中
let overlay: UInt32 // 次要输出、离线
/// 终端内容区底色(Catppuccin base)。沉浸态胶囊下的预留带、首页缩略卡都铺这个色,
/// 与 libghostty 实际渲染的 pane 底色一致,视觉才连续。
let terminalBg: UInt32
}
// MARK: - Palette(视图唯一入口)
/// 统一主题门面:一套选择同时驱动 **app chrome**(中性阶)与 **终端**(`ghosttyName` → GhosttyTheme),
/// 强调色恒为 Nocturne blurple。新增主题 = 再写一个 `static let`,视图零改动。
struct TXPalette: Equatable, Identifiable {
let flavor: TXFlavor
let chrome: TXChrome
var id: String { flavor.id }
var name: String { flavor.name }
var isDark: Bool { flavor.isDark }
var ghosttyName: String { flavor.ghosttyName }
/// 该主题的终端配色(GhosttyTheme → TerminalTheme)。找不到回退系统默认。
var terminalTheme: TerminalTheme {
GhosttyThemeCatalog.theme(named: flavor.ghosttyName)?.toTerminalTheme() ?? .default
}
// MARK: chrome 取色(设计 token 直通)
var bgDeep: Color { Color(hex: chrome.bgDeep) }
var bg: Color { Color(hex: chrome.bg) }
var surfaceDark: Color { Color(hex: chrome.surfaceDark) }
var surface: Color { Color(hex: chrome.surface) }
var divider: Color { Color(hex: chrome.divider) }
var border: Color { Color(hex: chrome.border) }
var borderStrong: Color { Color(hex: chrome.borderStrong) }
var borderDialog: Color { Color(hex: chrome.borderDialog) }
var text: Color { Color(hex: chrome.text) }
var text2: Color { Color(hex: chrome.text2) }
var text3: Color { Color(hex: chrome.text3) }
var text4: Color { Color(hex: chrome.text4) }
var textWeak: Color { Color(hex: chrome.textWeak) }
var textFaint: Color { Color(hex: chrome.textFaint) }
// MARK: 状态语义色(随 flavor)
/// 在线 / 成功 / 运行中
var online: Color { Color(hex: flavor.green) }
/// error
var danger: Color { Color(hex: flavor.red) }
/// 重连中
var reconnecting: Color { Color(hex: flavor.peach) }
var linkGood: Color { Color(hex: flavor.green) }
var linkPath: Color { Color(hex: flavor.blue) }
/// 终端内容区底色(沉浸预留带 / 缩略卡背景)。
var terminalBackground: Color { Color(hex: flavor.terminalBg) }
// MARK: 兼容旧取色点
// 各屏按设计稿重写时逐步改用上面的 chrome/TXAccent 直通名,重写完这一段就删。
var surfaceElevated: Color { divider }
var accent: Color { TXAccent.base }
var accentSoft: Color { TXAccent.selectedBg }
var textPrimary: Color { text }
var textSecondary: Color { text3 }
var textTertiary: Color { textWeak }
var running: Color { online }
var mosh: Color { Color(hex: flavor.teal) }
var warning: Color { Color(hex: flavor.yellow) }
var offline: Color { Color(hex: flavor.overlay) }
}
extension TXPalette {
static let catppuccinMocha = TXPalette(
flavor: TXFlavor(
id: "catppuccin-mocha", name: "Mocha", isDark: true, ghosttyName: "Catppuccin Mocha",
green: 0xA6E3A1, red: 0xF38BA8, yellow: 0xF9E2AF, blue: 0x89B4FA,
teal: 0x94E2D5, peach: 0xFAB387, overlay: 0x6C7086, terminalBg: 0x1E1E2E),
chrome: .mocha)
static let catppuccinMacchiato = TXPalette(
flavor: TXFlavor(
id: "catppuccin-macchiato", name: "Macchiato", isDark: true, ghosttyName: "Catppuccin Macchiato",
green: 0xA6DA95, red: 0xED8796, yellow: 0xEED49F, blue: 0x8AADF4,
teal: 0x8BD5CA, peach: 0xF5A97F, overlay: 0x6E738D, terminalBg: 0x24273A),
chrome: .macchiato)
static let catppuccinFrappe = TXPalette(
flavor: TXFlavor(
id: "catppuccin-frappe", name: "Frappé", isDark: true, ghosttyName: "Catppuccin Frappe",
green: 0xA6D189, red: 0xE78284, yellow: 0xE5C890, blue: 0x8CAAEE,
teal: 0x81C8BE, peach: 0xEF9F76, overlay: 0x737994, terminalBg: 0x303446),
chrome: .frappe)
static let catppuccinLatte = TXPalette(
flavor: TXFlavor(
id: "catppuccin-latte", name: "Latte", isDark: false, ghosttyName: "Catppuccin Latte",
green: 0x40A02B, red: 0xD20F39, yellow: 0xDF8E1D, blue: 0x1E66F5,
teal: 0x179299, peach: 0xFE640B, overlay: 0x9CA0B0, terminalBg: 0xEFF1F5),
chrome: .latte)
/// 可选主题(设置里列出)。默认第一个。
static let all: [TXPalette] = [catppuccinMocha, catppuccinMacchiato, catppuccinFrappe, catppuccinLatte]
static let `default` = catppuccinMocha
static func byID(_ id: String?) -> TXPalette {
all.first { $0.id == id } ?? .default
}
}
/// 当前主题的持有者(app + 终端统一)。选择持久化到 UserDefaults,终端侧经 `TerminalTheme.txDefault` 同源读取。
/// 主题选择的持久化键(文件级 nonisolated,供 app 与终端两侧读取)。
let txThemeStorageKey = "txThemeID"
@MainActor
final class TXThemeManager: ObservableObject {
@Published var palette: TXPalette
init() {
palette = TXPalette.byID(UserDefaults.standard.string(forKey: txThemeStorageKey))
}
/// 切换主题:持久化 + 更新(app 视图响应式;终端由 ContentView 观察 palette.id 变化后推送)。
func apply(_ palette: TXPalette) {
self.palette = palette
UserDefaults.standard.set(palette.id, forKey: txThemeStorageKey)
}
}
extension TerminalTheme {
/// 当前所选主题的终端配色(与 app 同源)。model / tmux pane 新建时读取,保证新面板也用当前主题。
static var txDefault: TerminalTheme {
TXPalette.byID(UserDefaults.standard.string(forKey: txThemeStorageKey)).terminalTheme
}
}
// MARK: - 结构性令牌(不随主题变)
/// 圆角 / 间距 / 字体 / 布局基准,数值取自设计交付文档。
enum TX {
enum Radius {
/// 徽标 / 胶囊小件
static let badge: CGFloat = 6
/// 控件(输入框、按钮)
static let control: CGFloat = 9
/// 卡片
static let card: CGFloat = 12
/// 对话框
static let dialog: CGFloat = 15
/// 浮起 chrome(标题/状态胶囊)
static let capsule: CGFloat = 11
/// 终端 pane 容器。**只用在朝 app chrome 的那几个角**——贴着屏幕物理边缘的角必须留直角,
/// 否则会与设备自身的大圆角打架(详见 `TerminalScreen.paneShape`)。
static let pane: CGFloat = 6
// 兼容旧名
static let chip: CGFloat = 9
static let field: CGFloat = 9
}
/// 设计间距阶:2 / 6 / 8 / 10 / 12 / 14 / 16 / 20 / 26 / 34 / 40
enum Space {
static let hairline: CGFloat = 2
static let xs: CGFloat = 6
static let s: CGFloat = 10
static let m: CGFloat = 16
static let l: CGFloat = 20
static let xl: CGFloat = 26
static let xxl: CGFloat = 34
/// 屏幕左右留白(首页 / 设置内容区)
static let screenGutter: CGFloat = 40
}
/// 布局基准(pt)。竖屏值见 `.compact` 变体。
enum Layout {
/// 折叠轨道宽(竖屏 48)
static let railWidth: CGFloat = 56
static let railWidthCompact: CGFloat = 48
/// 展开侧边栏宽
static let sidebarWidth: CGFloat = 288
/// pane 之间的缝
static let paneGap: CGFloat = 2
/// 拖拽热区
static let paneDragHit: CGFloat = 12
/// 触控命中区下限
static let minTouch: CGFloat = 44
/// 终端主 pane 顶部给浮起胶囊预留
static let capsuleInset: CGFloat = 86
/// 底部区**内容带**高度:舞台提示条 / 侧边栏脚 / 轨道齿轮共用,故三者中心线与上方那条
/// 分割线都落在同一条 y 上。真机上还要在下面叠 home indicator 的安全区
/// —— 沉浸页把 chrome 铺到物理边缘,安全区由底栏自己让出(见 `TX.Layout.footBand`)。
static let footHeight: CGFloat = 34
/// 底部区总高 = 内容带 + 安全区。内容在其中**居中**:既贴住物理底边不留悬空的死带,
/// 又不会压到 home indicator 上。
static func footBand(_ safeBottom: CGFloat) -> CGFloat { footHeight + safeBottom }
/// 沉浸态浮起胶囊距**物理**顶边的距离。
///
/// iPad 隐藏状态栏后顶边没有遮挡 → 用设计值 14。灵动岛/刘海机型的安全区读数很大
/// (≥30),此时按硬件让位,避免胶囊钻到岛下面。
static func capsuleTop(_ safeTop: CGFloat) -> CGFloat {
safeTop > 30 ? safeTop - 12 : 14
}
}
/// 动效。设计定稿:状态切换 200–260ms `easeOut`。
///
/// 所有过渡都从这里取曲线 —— 点击、快捷键、无头 URL 驱动的同一个状态变更拿到同一条动画,
/// 并统一响应系统「减弱动态」(位移退化为纯淡入淡出)。
enum Motion {
/// 常规状态切换:路由、侧边栏三态、模态浮层。
@MainActor static var standard: Animation {
.easeOut(duration: (reduceMotion ? 0.12 : 0.24) * slowFactor)
}
/// 轻量反馈:选中态、状态点变色、按压回弹。短一点,避免拖泥带水。
@MainActor static var quick: Animation {
.easeOut(duration: (reduceMotion ? 0.08 : 0.14) * slowFactor)
}
@MainActor static var reduceMotion: Bool { UIAccessibility.isReduceMotionEnabled }
/// 无头验证用的慢放(`-txSlowMotion 1` → ×12)。
///
/// `simctl io screenshot` 本身要几百毫秒,抓不到 240ms 的过渡中间帧 —— 想用截图证明
/// "动效真的在跑"(而不是又退化成硬切)就得先把它放慢。仅 DEBUG。
private static var slowFactor: Double {
#if DEBUG
UserDefaults.standard.bool(forKey: "txSlowMotion") ? 12 : 1
#else
1
#endif
}
/// 有位移的转场(侧边栏浮层从左滑入)。开了「减弱动态」只淡入淡出。
@MainActor static func slide(_ edge: Edge) -> AnyTransition {
reduceMotion ? .opacity : .move(edge: edge)
}
/// 浮层弹出:淡入 + 极轻的缩放(96% → 100%),让对话框"长出来"而不是硬切。
@MainActor static var pop: AnyTransition {
reduceMotion ? .opacity : .opacity.combined(with: .scale(scale: 0.96))
}
/// 首页 ⇄ 终端:首页略微推远、终端从 97% 放大到位,读作"推进到会话里"。
@MainActor static var pushBack: AnyTransition {
reduceMotion ? .opacity : .opacity.combined(with: .scale(scale: 1.02))
}
}
/// 阴影。**刻意比设计初稿扁平**:暗色 chrome 靠描边分层,阴影只做极轻的浮起暗示。
/// 初稿那套 CSS 值(30–80px / .55–.7 黑)在深色底上会糊成一团脏斑,浮起胶囊尤其明显
/// —— 终端文字从投影里穿过去,观感是"胶囊在漏墨"。故统一收到小半径、低透明度。
struct Shadow {
let opacity: Double
let radius: CGFloat
var x: CGFloat = 0
var y: CGFloat = 0
}
enum Elevation {
/// 浮起 chrome(标题 / 状态胶囊):贴在终端内容之上,投影必须几乎不可见。
static let floating = Shadow(opacity: 0.22, radius: 7, y: 2)
/// 侧边栏浮层:唯一带方向感的投影(往右推),让面板与被压暗的终端分层。
static let panel = Shadow(opacity: 0.28, radius: 16, x: 4)
/// 对话框 / 菜单。
static let dialog = Shadow(opacity: 0.34, radius: 16, y: 6)
/// 会话缩略卡(聚焦时略强)。
static let card = Shadow(opacity: 0.16, radius: 4, y: 2)
static let cardFocused = Shadow(opacity: 0.24, radius: 8, y: 3)
}
/// 浮层遮罩:对话框与侧边栏浮层共用同一底色,只有浓度不同。
enum Scrim {
static let color = Color(hex: 0x0A0B12)
/// 对话框:要求"必须先处理它",压得更实。
static let dialog: Double = 0.5
/// 侧边栏浮层:面板本身已占掉一大片,遮罩轻一点。
static let sidebar: Double = 0.42
}
enum Font {
/// JetBrains Mono(chrome 与终端同源)。字体缺失时 SwiftUI 自动回退系统字体,中文走 PingFang SC。
static func mono(_ size: CGFloat, _ weight: SwiftUI.Font.Weight = .regular) -> SwiftUI.Font {
.custom(psName(weight), fixedSize: size)
}
/// 随动态字体缩放的变体(正文型文案用这个,终端内容不用)。
static func monoScaled(_ size: CGFloat, _ weight: SwiftUI.Font.Weight = .regular) -> SwiftUI.Font {
.custom(psName(weight), size: size)
}
private static func psName(_ weight: SwiftUI.Font.Weight) -> String {
switch weight {
case .bold, .heavy, .black: return "JetBrainsMono-Bold"
case .semibold: return "JetBrainsMono-SemiBold"
case .medium: return "JetBrainsMono-Medium"
default: return "JetBrainsMono-Regular"
}
}
/// 分组小标题(600 10px, ls .09em, uppercase)
static let sectionLabel = mono(10, .semibold)
/// 屏幕主标题(500 34px)
static let screenTitle = mono(34, .medium)
/// 页面标题(500 24px)
static let pageTitle = mono(24, .medium)
/// 列表主行(500 13px)
static let listRow = mono(13, .medium)
/// 列表副行 / 元信息(400 11px)
static let listMeta = mono(11)
/// 徽标 / 胶囊(500 12px)
static let badge = mono(12, .medium)
}
}
extension Color {
/// 0xRRGGBB 十六进制构造。
init(hex: UInt32) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double(hex & 0xFF) / 255,
opacity: 1
)
}
}

View File

@@ -26,6 +26,7 @@ final class TmuxPaneSurface: ObservableObject, Identifiable {
)
let state = TerminalViewState()
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
_ = state.controller.setTheme(.txDefault) // Catppuccin Mocha 终端配色
self.session = session
self.state = state
self.gate = OutputGate(session: session)
@@ -36,7 +37,11 @@ final class TmuxPaneSurface: ObservableObject, Identifiable {
@MainActor
final class TmuxWindow: ObservableObject, Identifiable {
let id: TmuxWindowID
/// tmux `#{window_index}`(用户可见编号,轨道/侧边栏显示这个;`id` 是内部 `@N`)。
@Published var index: Int
@Published var title: String
/// 活动 pane 的工作目录(`#{pane_current_path}`)。title 是 shell 默认值时 UI 回退显示它。
@Published var cwd: String = ""
@Published var panes: [TmuxPaneID: TmuxPaneSurface] = [:]
/// 渲染用:zoom 时只含被 zoom 的 pane。
@Published var visibleLayout: TmuxLayout?
@@ -44,7 +49,19 @@ final class TmuxWindow: ObservableObject, Identifiable {
var fullLayout: TmuxLayout?
@Published var activePane: TmuxPaneID?
init(id: TmuxWindowID, title: String) { self.id = id; self.title = title }
init(id: TmuxWindowID, index: Int, title: String) {
self.id = id; self.index = index; self.title = title
}
}
/// 一个 tmux 会话的概要(`list-sessions` 结果)。分屏菜单列「分到哪里」用。
struct TmuxSessionInfo: Identifiable, Equatable {
var id: String { name }
let name: String
let windows: Int
/// 是否已被某客户端 attach(当前会话即 attached 的那个)。
let isAttached: Bool
let lastActivity: Date?
}
/// tmux control-mode 网关:消费 `%`-协议流,把 window 映射为原生 tab、window 内多 pane 映射为分屏。
@@ -54,6 +71,8 @@ final class TmuxWindow: ObservableObject, Identifiable {
final class TmuxController: ObservableObject {
@Published private(set) var windows: [TmuxWindow] = []
@Published var activeWindowID: TmuxWindowID?
/// 本 tmux server 上的会话(`listSessions()` 填充,分屏菜单用)。
@Published private(set) var sessions: [TmuxSessionInfo] = []
private let parser = TmuxControlParser()
private var windowByID: [TmuxWindowID: TmuxWindow] = [:]
@@ -69,13 +88,53 @@ final class TmuxController: ObservableObject {
// control-mode 命令应答 FIFO 匹配。
private var attachAcked = false
private enum PendingKind { case ignore, listWindows, capturePane(TmuxPaneID) }
private enum PendingKind: CustomStringConvertible {
case ignore, listWindows, listSessions, currentSession, capturePane(TmuxPaneID)
var description: String {
switch self {
case .ignore: "ignore"
case .listWindows: "listWindows"
case .listSessions: "listSessions"
case .currentSession: "currentSession"
case .capturePane(let p): "capturePane(%\(p.raw))"
}
}
}
private var pending: [PendingKind] = []
// attach kickoff(refresh-client + list-windows + 各 pane capture)必须等"容器真实几何已知"再发:
// pane 的 ghostty grid 尺寸由容器像素几何决定(tab 条吃掉顶部高度),一开始就 ≠ raw 全屏格子。
// 若先按 raw 尺寸 attach+capture,快照行数 > pane grid 行数 → 铺快照时溢出上滚 → 提示符被拆/内容钉底。
// 故 kickoff 双条件:attach 首应答已到 ∧ 容器几何已到(先到者等后到者触发)。
private var containerKnown = false
private var kickoffDone = false
/// format 字段分隔符。**不要用 tab**:真机实测 tab 经 PTY 到 tmux 后变成下划线,
/// 导致 list-windows/list-sessions 整行分不开(title/cwd/会话名全丢,UI 显示兜底名)。
/// 也不能用 `#`(tmux format 里 `#{}`/`##` 有特殊含义)。
static let fieldSep = "|:|"
init(sendRaw: @escaping @Sendable (Data) -> Void) {
self.sendRaw = sendRaw
}
/// attach 序列启动器:仅当 attach 应答与容器几何都就绪时发一次(refresh-client 用容器换算尺寸)。
private func kickoffIfReady() {
guard attachAcked, containerKnown, !kickoffDone else { return }
kickoffDone = true
NSLog("TMUXDBG kickoff refresh-client -C \(clientCols)x\(clientRows)")
sendCommand("refresh-client -C \(clientCols)x\(clientRows)") // 此刻已是容器真实格子
pending.append(.ignore)
let sep = Self.fieldSep
sendCommand("list-windows -F \"#{window_id}\(sep)#{window_name}\(sep)#{window_active}\(sep)#{window_layout}\(sep)#{window_visible_layout}\(sep)#{pane_current_path}\(sep)#{window_index}\"")
pending.append(.listWindows)
// 本客户端所在的会话名。**不能**用 list-sessions 的 `session_attached` 反推——
// 服务器上别的客户端 attach 着其它会话时会猜错(真机实测把 `demo` 当成了当前会话)。
sendCommand("display-message -p \"#{session_name}\"")
pending.append(.currentSession)
listSessions() // 分屏菜单要列全部会话
}
/// 由 model 在 attach 前告知全屏格子(tmux 据此 refresh-client -C 布局)。
func setClientSize(cols: Int, rows: Int) { clientCols = max(1, cols); clientRows = max(1, rows) }
@@ -94,15 +153,22 @@ final class TmuxController: ObservableObject {
lastSentCols = cols; lastSentRows = rows
clientCols = cols; clientRows = rows
NSLog("TMUXDBG containerResized pt=\(Int(widthPt))x\(Int(heightPt)) scale=\(scale) cell=\(cellWpx)x\(cellHpx) -> \(cols)x\(rows)")
// 首个真实几何:立即触发 attach kickoff(不 debounce),保证 attach 首帧起 client 格子==容器格子。
containerKnown = true
if !kickoffDone { kickoffIfReady(); return }
resizeDebounce?.cancel()
resizeDebounce = Task { [weak self] in
try? await Task.sleep(nanoseconds: 200_000_000) // 合并拖拽期间的连续变化
guard !Task.isCancelled, let self, self.attachAcked else { return }
guard !Task.isCancelled, let self, self.kickoffDone else { return }
NSLog("TMUXDBG refresh-client -C \(cols)x\(rows)")
self.sendCommand("refresh-client -C \(cols)x\(rows)") // tmux 重排 → 回 %layout-change → 更新 frame
}
}
/// 本客户端所在的 tmux 会话名(`display-message -p "#{session_name}"` 的权威答案)。
/// 拿不到时为空串 —— UI 只显示「tmux」而不编造名字。
@Published private(set) var currentSessionName = ""
var activeWindow: TmuxWindow? {
guard let id = activeWindowID else { return windows.first }
return windowByID[id]
@@ -203,28 +269,58 @@ final class TmuxController: ObservableObject {
private func handleCommandResponse(_ r: TmuxCommandResponse) {
if !attachAcked {
attachAcked = true
// 先设客户端尺寸(tmux 据此布局),再枚举——枚举到的 layout 已是我方尺寸,避免先渲一帧再重排的闪跳。
sendCommand("refresh-client -C \(clientCols)x\(clientRows)")
pending.append(.ignore)
sendCommand("list-windows -F \"#{window_id}\t#{window_name}\t#{window_active}\t#{window_layout}\t#{window_visible_layout}\"")
pending.append(.listWindows)
// kickoff 延后到容器几何已知(见 kickoffIfReady):避免用 raw 全屏格子 attach 导致
// 快照行数 > pane grid 行数、铺快照溢出上滚。几何先到则此处直接触发。
kickoffIfReady()
return
}
guard !pending.isEmpty else { return }
switch pending.removeFirst() {
let kind = pending.removeFirst()
switch kind {
case .ignore:
break
case .listWindows:
for line in r.lines {
let parts = line.split(separator: "\t", omittingEmptySubsequences: false)
guard parts.count >= 5, let w = TmuxWindowID.parse(parts[0]) else { continue }
let parts = line.components(separatedBy: Self.fieldSep)
guard parts.count >= 5, let w = TmuxWindowID.parse(parts[0]) else {
NSLog("TMUXDBG winparse FAILED count=\(parts.count) line=[\(line.prefix(160))]")
continue
}
let win = ensureWindow(w)
win.title = String(parts[1])
if parts.count >= 6 { win.cwd = String(parts[5]) }
if parts.count >= 7, let idx = Int(parts[6]) { win.index = idx }
if parts[2] == "1" { activeWindowID = w }
applyLayout(window: w, full: String(parts[3]), visible: String(parts[4]))
}
case .listSessions:
sessions = r.lines.compactMap { line in
let f = line.components(separatedBy: Self.fieldSep)
guard f.count >= 3 else { return nil }
let activity = f.count >= 4 ? Double(f[3]).map { Date(timeIntervalSince1970: $0) } : nil
return TmuxSessionInfo(name: f[0],
windows: Int(f[1]) ?? 1,
isAttached: f[2] == "1",
lastActivity: activity)
}
NSLog("TMUXDBG sessions=\(sessions.map { "\($0.name)/w\($0.windows)/a\($0.isAttached)" })")
case .currentSession:
if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
currentSessionName = name
NSLog("TMUXDBG currentSession=\(name)")
}
case .currentSession:
if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
currentSessionName = name
NSLog("TMUXDBG currentSession=\(name)")
}
case .capturePane(let p):
let snapshot = r.lines.joined(separator: "\r\n")
// 剔除尾部空行:capture-pane 常返回补满 pane 高度的尾随空行,若行数 > pane grid 行数,
// 铺快照(ESC[H + N-1 个 \r\n)会溢出上滚、把顶部内容顶出屏幕(提示符被拆/内容钉底)。
// ESC[H 铺法本身正确,前提是行数 ≤ grid 行数;trim 后冷启动近空屏恒成立。
var lines = r.lines
while lines.last?.isEmpty == true { lines.removeLast() }
let snapshot = lines.joined(separator: "\r\n")
if let s = surfaceByPane[p] {
if !snapshot.isEmpty {
// 归位后铺快照(不尾随换行,避免滚屏)。
@@ -238,7 +334,8 @@ final class TmuxController: ObservableObject {
@discardableResult
private func ensureWindow(_ w: TmuxWindowID) -> TmuxWindow {
if let existing = windowByID[w] { return existing }
let win = TmuxWindow(id: w, title: "窗口 \(w.raw)")
// index 先用 id 占位,list-windows 应答到达后校正为真实 window_index。
let win = TmuxWindow(id: w, index: w.raw, title: "窗口 \(w.raw)")
windowByID[w] = win
windows.append(win)
windows.sort { $0.id.raw < $1.id.raw }
@@ -282,21 +379,67 @@ final class TmuxController: ObservableObject {
func newWindow() { sendCommand("new-window") }
func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") }
// MARK: - 关闭 / 分屏 / resize(UI-4)
/// 断开 tmux 客户端:**服务端会话继续跑**,下次原样接回。红点确认里的推荐动作。
/// tmux 会回 `%exit` → 既有 `onExit` 链路收尾。
func detach() { sendCommand("detach-client") }
/// 结束当前 tmux 会话(服务端进程一并结束)。红点确认里的危险次要动作,需再确认一次。
func killSession() { sendCommand("kill-session") }
/// 分屏。tmux 语义:`-h` 左右并排(竖直分隔线)、`-v` 上下堆叠。
/// 只发命令即可 —— tmux 回 `%layout-change`,既有 reconcile 会为新 pane 建 surface
/// 并走 capture-pane 快照链路(见 `applyLayout` / `ensurePane`)。
func splitWindow(vertical: Bool, joinSession: String? = nil) {
if let s = joinSession {
// 把另一个会话的当前 window 拉进本 window 当 pane(tmux 原生支持,不嵌套 tmux)。
sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(s):")
} else {
sendCommand("split-window \(vertical ? "-h" : "-v")")
}
}
/// 新建 tmux 会话并把它的 window 拉进当前 window 分屏(detached 建,避免抢走客户端)。
func splitIntoNewSession(vertical: Bool, name: String) {
sendCommand("new-session -d -s \(name)")
sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(name):")
}
/// 拖 pane 分隔条**松手后**调整尺寸(拖动中只动视觉、不发命令,见设计约束)。
/// 发完等 `%layout-change` 回来再更新真实 frame —— 与"路线 b"一致,客户端不自行重排。
func resizePane(_ p: TmuxPaneID, cols: Int? = nil, rows: Int? = nil) {
if let cols, cols > 0 { sendCommand("resize-pane -t %\(p.raw) -x \(cols)") }
if let rows, rows > 0 { sendCommand("resize-pane -t %\(p.raw) -y \(rows)") }
}
/// 枚举本 tmux server 上的会话(分屏菜单要列 detached 会话)。结果进 `sessions`。
func listSessions() {
let sep = Self.fieldSep
sendCommand("list-sessions -F \"#{session_name}\(sep)#{session_windows}\(sep)#{session_attached}\(sep)#{session_activity}\"")
pending.append(.listSessions)
}
/// 某 pane 的 surface 就绪 → 放行其缓冲输出。
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }
/// 切换主题:把新终端配色推给所有已存在的 pane surface。
func applyTerminalTheme(_ t: TerminalTheme) {
for s in surfaceByPane.values { _ = s.state.controller.setTheme(t) }
}
}
/// 传输字节分流器:检测 `tmux -CC` 进入 DCS 前 → 原始终端;进入后 → tmux 网关。
/// 在传输后台线程调用;原始字节走线程安全的 rawGate,网关字节经闭包 hop 到主线程。
final class TmuxRouter: @unchecked Sendable {
private let lock = NSLock()
private let rawGate: OutputGate
private let rawGate: any RawOutputSink
private let enterGateway: @Sendable ([UInt8]) -> Void
private let gatewayBytes: @Sendable ([UInt8]) -> Void
private var carry: [UInt8] = []
private var inGateway = false
init(rawGate: OutputGate,
init(rawGate: any RawOutputSink,
enterGateway: @escaping @Sendable ([UInt8]) -> Void,
gatewayBytes: @escaping @Sendable ([UInt8]) -> Void) {
self.rawGate = rawGate

View File

@@ -0,0 +1,103 @@
import Foundation
import TsnetBridge
enum TsnetError: Error { case newNodeFailed, notUp, needsAuthKey }
/// 多 tsnet 节点注册表:按 Tailscale 连接 id 复用节点。
///
/// tsnet v1.102.0 官方支持一进程多 `tsnet.Server`(各自独立 netstack;dial 走具体 node 句柄、
/// 无全局路由,故重叠 100.x 不会误连——只要每个 host 始终经其绑定的那个 Node dial)。铁律不破:
/// 仍是一个 Go runtime、一次 gomobile bind(全在 tsnetbridge 包)。
///
/// 策略:**懒启动**(用到才 Up)·**Up 后常驻**(不空闲 Down,M3 mosh 恢复依赖 netstack 存活)·
/// **软上限 3**(超限 LRU 关闭无活跃会话的最久未用节点)·**in-flight 去重**(同连接并发 connect
/// 绝不 double-Up 同一 stateDir——tsnet 无文件锁,同 Dir 双 Up 会损坏状态)。
///
/// 线程安全(`@unchecked Sendable` + 锁):Up/dial 阻塞,由 `TerminalSession` 在后台线程调用。
final class TsnetRegistry: @unchecked Sendable {
static let shared = TsnetRegistry()
private let lock = NSLock()
private var nodes: [UUID: TsnetbridgeNode] = [:]
private var upLocks: [UUID: NSLock] = [:]
private var lastUsed: [UUID: Date] = [:]
private var activeSessions: Set<UUID> = []
private let maxLive = 3
private let stateBase: String
init() {
stateBase = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0]
}
/// 取(必要时懒启动)某连接的 tsnet 节点。阻塞,需后台线程调用。
/// `authKey` 仅在节点尚未注册(首次 Up)时需要;已持久化 state 的节点用 nil(走已存 node key)。
func node(connID: UUID, stateDirName: String, hostname: String,
authKey: String?, timeoutMs: Int) throws -> TsnetbridgeNode {
lock.lock()
if let n = nodes[connID] { lastUsed[connID] = Date(); lock.unlock(); return n }
let upLock = upLocks[connID] ?? { let l = NSLock(); upLocks[connID] = l; return l }()
lock.unlock()
// 同连接串行 Up(in-flight 去重);不同连接可并发。
upLock.lock()
defer { upLock.unlock() }
lock.lock()
if let n = nodes[connID] { lastUsed[connID] = Date(); lock.unlock(); return n }
lock.unlock()
let dir = stateBase + "/" + stateDirName
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
guard let n = TsnetbridgeNewNode(dir, hostname) else { throw TsnetError.newNodeFailed }
try n.up(withAuthKey: authKey ?? "", timeoutMs: timeoutMs)
lock.lock()
nodes[connID] = n
lastUsed[connID] = Date()
evictIfNeededLocked(keeping: connID)
lock.unlock()
return n
}
/// 为 mosh 建立 UDP relay(阻塞,后台线程调用)。节点必须已 Up。
func startMoshRelay(connID: UUID, host: String, moshPort: Int, timeoutMs: Int) throws -> TsnetbridgeMoshRelay {
lock.lock(); let n = nodes[connID]; lock.unlock()
guard let n else { throw TsnetError.notUp }
return try n.startMoshRelay(host, moshPort: moshPort, timeoutMs: timeoutMs)
}
/// M3:唤醒某连接的 tsnet 网络层(前台恢复,可能阻塞)。
func wakeUp(connID: UUID) {
lock.lock(); let n = nodes[connID]; lock.unlock()
n?.wakeUp()
}
/// 标记某连接是否有活跃会话(供 LRU 不驱逐在用节点)。
func markSessionActive(_ connID: UUID, _ active: Bool) {
lock.lock()
if active { activeSessions.insert(connID) } else { activeSessions.remove(connID) }
lock.unlock()
}
/// 关闭并销毁某连接的节点(删连接 / LRU 驱逐)。
func close(_ connID: UUID) {
lock.lock()
let n = nodes[connID]
nodes[connID] = nil; lastUsed[connID] = nil; activeSessions.remove(connID)
lock.unlock()
try? n?.close()
}
/// 超出软上限时,关闭无活跃会话、最久未用的节点(保护刚取到的 keeping)。
private func evictIfNeededLocked(keeping: UUID) {
while nodes.count > maxLive {
let candidates = nodes.keys
.filter { $0 != keeping && !activeSessions.contains($0) }
.sorted { (lastUsed[$0] ?? .distantPast) < (lastUsed[$1] ?? .distantPast) }
guard let victim = candidates.first, let n = nodes[victim] else { break }
nodes[victim] = nil; lastUsed[victim] = nil
try? n.close()
}
}
}

View File

@@ -0,0 +1,56 @@
import SwiftUI
/// 编辑页共享样式:深色 Form 行背景 + 主文字色。
extension View {
func txRow(_ p: TXPalette) -> some View {
self.listRowBackground(p.surface).foregroundStyle(p.textPrimary)
}
}
/// 深色主题的表单文本字段。
struct TXTextField: View {
let title: String
@Binding var text: String
var placeholder: String = ""
var keyboard: UIKeyboardType = .default
var secure: Bool = false
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Group {
if secure {
SecureField(placeholder.isEmpty ? title : placeholder, text: $text)
} else {
TextField(placeholder.isEmpty ? title : placeholder, text: $text)
.keyboardType(keyboard)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
}
.foregroundStyle(p.textPrimary)
.txRow(p)
}
}
/// 空状态占位(列表为空时)。
struct TXEmptyState: View {
let icon: String
let title: String
let subtitle: String
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
VStack(spacing: TX.Space.s) {
Image(systemName: icon)
.font(.system(size: 34, weight: .light))
.foregroundStyle(p.textTertiary)
Text(title).font(.system(size: 15)).foregroundStyle(p.textSecondary)
Text(subtitle).font(.system(size: 13)).foregroundStyle(p.textTertiary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
}
}

View File

@@ -0,0 +1,268 @@
#if DEBUG
import Foundation
import UIKit
/// 无头 UI 验证脚手架(仅 DEBUG)。
///
/// 本项目的构建机没有 GUI,每屏都靠 `simctl io screenshot` 取证对拍设计稿;而设计稿里的
/// 首页会话卡、轨道多主机头像、状态徽标都需要**多条已连接会话**才有内容。用真实主机验证既慢
/// 又依赖即时凭据,故提供这条假会话通道:只灌示意输出到 in-memory 终端,**不建任何传输连接**。
///
/// 用法:`xcrun simctl launch <UDID> ai.athom.terminalx --args -txUIFixture 1`
/// 真实端到端回归仍走 `-txHost/-txUser/-txPass`(见 CLAUDE.md 启动参数表),二者互斥。
enum UIPreviewFixture {
/// 三条会话对应设计稿 `1e 主机-会话卡` 的三张卡(直连 / 经 tsnet / 重连中)。
/// `name` 与 `hosts` 里的主机同名 → 会话复用同一 `SavedHost.id`,主机卡才能亮「已有会话」。
struct Spec {
let name: String
let link: LinkState
let tmux: TmuxSummary?
let lines: [String]
}
/// 假 Tailscale 出口 id(只为让主机卡亮 tsnet 徽章;fixture 不建连接)。
static let fixtureTailnetID = UUID(uuidString: "00000000-0000-0000-0000-0000000000FE")!
/// 示意 tailnet(首页副行 + 侧边栏脚的状态)。只进内存,不写盘。
static let tailnets: [TailscaleConnection] = [
TailscaleConnection(id: fixtureTailnetID, name: "athom.ts.net",
hostname: "ipad-pro", needsAuthKey: false, lastSelfIP: "100.92.14.7"),
]
/// 首页「全部主机」的示意主机(对拍设计稿 `1e` 的 7 台 + 添加卡)。只进内存,不写盘。
static let hosts: [SavedHost] = [
SavedHost(name: "mac-studio", host: "100.71.203.4", useMosh: true,
auth: .inlinePassword(username: "yz", password: ""),
tailscaleID: fixtureTailnetID, group: "生产", os: "macOS · M4 Max"),
SavedHost(name: "ubuntu-cn2", host: "100.88.9.61", useMosh: true,
auth: .inlinePassword(username: "yz", password: ""),
tailscaleID: fixtureTailnetID, group: "云", os: "Ubuntu 26.04"),
SavedHost(name: "edge-sg1", host: "10.1.2.11", useMosh: false,
auth: .inlinePassword(username: "root", password: ""),
tailscaleID: fixtureTailnetID, group: "云", os: "Debian 13"),
SavedHost(name: "edge-sg2", host: "10.1.2.12", useMosh: false,
auth: .inlinePassword(username: "root", password: ""),
group: "云", os: "Debian 13"),
SavedHost(name: "nas-home", host: "100.104.2.18", useMosh: true,
auth: .inlinePassword(username: "admin", password: ""),
group: "家里", os: "TrueNAS"),
SavedHost(name: "pi-lab", host: "192.168.1.31", useMosh: false,
auth: .inlinePassword(username: "pi", password: ""),
group: "家里", os: "Raspberry Pi OS"),
SavedHost(name: "mosh-lab", host: "192.168.1.44", useMosh: true,
auth: .inlinePassword(username: "yz", password: ""),
group: "家里", os: "Fedora 44"),
]
static let specs: [Spec] = [
Spec(name: "mac-studio", link: .direct,
tmux: TmuxSummary(name: "dev", windows: 3, panes: 4, currentTitle: "运行测试"), lines: [
"→ terminalx git:(main) git fetch --all --prune",
"Fetching origin … done",
"→ terminalx git:(main) swiftformat --lint Sources",
"✓ 0 issues in 84 files",
"→ terminalx git:(main) swift build -c release --arch arm64",
"[142/142] Compiling TXCore",
"✓ build complete (14.2s)",
"→ terminalx git:(main) swift test --package-path packages/TXCore",
"◇ Test run started.",
" ✔ parses %output escaped octal",
" ✔ layout diff keeps pane identity",
" ✔ host-key pin survives wipe",
"◇ 50 tests passed after 0.412s",
"→ terminalx git:(main) xcodegen generate --spec project.yml",
"✅ Created TerminalX.xcodeproj",
"→ terminalx git:(main) xcodebuild -scheme TerminalX build",
"** BUILD SUCCEEDED **",
"→ terminalx git:(main) git status --short",
" M apps/TerminalX/iOS/RailSidebar.swift",
" M apps/TerminalX/iOS/HomeView.swift",
"?? docs/design/UI-DESIGN-HANDOFF.md",
"→ terminalx git:(main) scripts/tx-shot.sh /tmp/v3.png",
" pixelWidth: 2752",
" pixelHeight: 2064",
"→ terminalx git:(main) swift test --package-path packages/TXCore",
"◇ 50 tests passed after 0.398s",
"→ terminalx git:(main) tmux ls",
"dev: 3 windows (created Fri 09:12:03)",
"scratch: 1 windows (created Tue)",
"→ terminalx git:(main) tailscale status | head -3",
"100.71.203.4 mac-studio direct",
"100.88.9.61 ubuntu-cn2 relay hkg",
"→ terminalx git:(main) ",
]),
Spec(name: "ubuntu-cn2", link: .relay,
tmux: TmuxSummary(name: "build", windows: 1, panes: 1, currentTitle: "集成测试"), lines: [
"→ cn2 ~ journalctl -u build-runner -n 3 --no-pager",
"12:05:02 Started build-runner@42",
"12:06:02 build-runner@42 succeeded",
"→ cn2 ~ systemctl status build-runner --no-pager",
"● build-runner.service — active (running)",
" since Fri 09:12:03 CST; 2h 54min ago",
"→ cn2 ~ ninja -C build -j 8",
"[148/148] Linking libmosh-core.a",
"✓ ninja: no work to do.",
"→ cn2 ~ tailscale netcheck | head -5",
" * UDP: true",
" * IPv4: yes",
" * UPnP: false",
"→ cn2 ~ ./run-integration.sh --tsnet",
"▸ joining tailnet as ci-runner…",
"▸ peer enumeration: 7 nodes",
"▸ mosh relay loopback:60122 ⇄ tsnet",
"▸ handshake ok, AES-OCB session up",
" roaming: peer addr switched",
" resume ok in 1.8s, 0 bytes lost",
" suspend 90s → wake",
" resume ok in 2.4s",
"▸ 14/14 passed",
"→ cn2 ~ df -h /tank | tail -1",
"tank 187T 217T 7.87 42%",
"→ cn2 ~ ",
]),
Spec(name: "nas-home", link: .reconnecting(attempt: 3), tmux: nil, lines: [
"nas ~ $ zfs get compressratio tank",
"tank compressratio 1.34x",
"nas ~ $ docker ps --format 'table {{.Names}}'",
"NAMES STATUS",
"plex Up 6 days",
"syncthing Up 6 days",
"nas ~ $ uname -sr",
"Linux 6.12.9-truenas",
"nas ~ $ zpool status -x",
" all pools are healthy",
"nas ~ $ df -h /tank",
"Filesystem Size Used Avail Use%",
"tank 187T 217T 7.87 42%",
"nas ~ $ zfs list -t snapshot | tail -3",
"tank/media@auto-2026-07-24 1.2G",
"tank/docs@auto-2026-07-24 318M",
"tank/vm@auto-2026-07-24 2.4G",
"nas ~ $ smartctl -H /dev/sda | tail -2",
"SMART overall-health: PASSED",
"nas ~ $ systemctl is-active smbd nfs-server",
"active",
"active",
"nas ~ $ tail -2 /var/log/messages",
"12:04:31 nas kernel: wlan0 rssi -74",
"12:06:20 nas tailscaled: peer offline",
"nas ~ $ ",
]),
]
/// 分屏菜单要列的会话(当前 attached + 两个 detached)。对拍设计稿 `3b`。
static func tmuxSessions(current: String) -> [TmuxSessionInfo] {
// 相对时刻用 timeIntervalSinceNow 造(脚本里不能用固定日期,会随时间漂移)。
[
TmuxSessionInfo(name: current, windows: 3, isAttached: true, lastActivity: nil),
TmuxSessionInfo(name: "scratch", windows: 1, isAttached: false,
lastActivity: Date(timeIntervalSinceNow: -3 * 86400)),
TmuxSessionInfo(name: "deploy", windows: 2, isAttached: false,
lastActivity: Date(timeIntervalSinceNow: -86400)),
]
}
/// 把启动参数拼成一台主机(供 `-txOpenAsHost 1` 走真实的「点主机连接」路径)。
static func hostFromLaunchArgs() -> SavedHost? {
let d = UserDefaults.standard
guard let host = d.string(forKey: "txHost"), !host.isEmpty,
let user = d.string(forKey: "txUser") else { return nil }
let port = d.integer(forKey: "txPort")
return SavedHost(
name: host, host: host, port: port == 0 ? 22 : port,
useMosh: d.bool(forKey: "txMosh"),
useTmux: d.object(forKey: "txTmux") == nil ? true : d.bool(forKey: "txTmux"),
tmuxSession: d.string(forKey: "txTmuxSession"),
auth: .inlinePassword(username: user, password: d.string(forKey: "txPass") ?? ""))
}
/// 是否启用(启动参数 `-txUIFixture 1`)。
static var isEnabled: Bool {
UserDefaults.standard.bool(forKey: "txUIFixture")
}
/// 无头导航驱动:`xcrun simctl openurl <UDID> "terminalx://ui/<动作>"`。
///
/// 为什么不用坐标点击:本环境没有 GUI,idb companion 不可用;而坐标点击还会随布局改动失效。
/// 走 URL 直接驱动 `AppRouter`/`SessionManager` 的公开语义,既稳定又正是要验的那条路径。
///
/// 支持:`home` · `minimize` · `close`(弹确认) · `close-now`(直接关) · `split/v|h`
/// · `split-now/v|h`(直接分屏) · `resize/<cols>`(resize-pane)
/// · `pick/<会话名>` · `pick-new/<名>` · `pick-native`(连接前的会话选择器)
/// · `dismiss` · `session/<序号>`(1 起,按首页卡片顺序)· `sidebar/toggle|pin`
@MainActor
static func handle(uiCommand path: [String], manager: SessionManager, router: AppRouter) -> Bool {
switch path.first {
case "home":
router.goHome()
case "minimize":
if let id = router.route.sessionID { manager.minimize(id); router.goHome() }
case "close":
// 与红点一致:弹确认对话框(真正关闭要在对话框里选)。
guard router.route.sessionID != nil else { return false }
router.present(.closeConfirm)
case "close-now":
// 跳过确认直接关(回归脚本用)。
if let id = router.route.sessionID { router.goHome(); manager.close(id) }
case "split":
guard router.route.sessionID != nil else { return false }
router.present(.splitMenu(vertical: path.dropFirst().first != "h"))
case "dismiss":
router.dismissModal()
case "split-now":
// 跳过菜单直接对当前会话分屏(验证 split-window 链路)。
guard let id = router.route.sessionID, let s = manager.session(id),
let tmux = s.tmuxController else { return false }
tmux.splitWindow(vertical: path.dropFirst().first != "h")
case "pick":
// 会话选择器:attach 指定会话(`ui/pick/<name>`)。此时通常还在首页,故优先取待选择的会话。
guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session),
let name = path.dropFirst().first else { return false }
s.attachTmuxSession(name)
case "pick-new":
guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session)
else { return false }
s.createTmuxSession(path.dropFirst().first ?? "main")
case "pick-native":
// 选择器里的「原生终端」。会话可能还没进终端页,故不依赖 route。
guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session)
else { return false }
s.useNativeTerminal()
case "resize":
// 验证 resize-pane 链路(拖拽手势本身是标准 DragGesture):`ui/resize/<cols>`。
guard let id = router.route.sessionID, let s = manager.session(id),
let tmux = s.tmuxController,
let win = tmux.windows.first(where: { $0.id == tmux.activeWindowID }),
let pane = win.activePane,
let cols = path.dropFirst().first.flatMap(Int.init) else { return false }
tmux.resizePane(pane, cols: cols)
case "session":
guard let n = path.dropFirst().first.flatMap(Int.init),
let s = manager.session(atShortcutIndex: n - 1) else { return false }
manager.focus(s)
router.enter(s.id)
case "sidebar":
// 悬浮态已砍(用户裁决):只剩收起⇄固定,pin 与 toggle 同义(兼容旧脚本)。
router.toggleSidebar()
default:
return false
}
NSLog("TXUI cmd=\(path.joined(separator: "/")) route=\(router.route) sidebar=\(router.sidebar)")
return true
}
/// 无头验证强制横屏(`-txLandscape 1`)。
///
/// 没有 GUI 时模拟器起来是竖屏,而设计基准是 iPad Pro 13″ 横屏 1366×1024;`simctl` 没有旋转
/// 能力,故用 iOS 16+ 的 `requestGeometryUpdate` 由 app 侧请求方向,让 `simctl io screenshot`
/// 取到横屏帧。仅 DEBUG,生产路径不碰方向(用户可自由旋转)。
@MainActor
static func forceLandscapeIfRequested() {
guard UserDefaults.standard.bool(forKey: "txLandscape") else { return }
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
scene.requestGeometryUpdate(.iOS(interfaceOrientations: .landscapeRight)) { error in
NSLog("TXUI landscape request failed: \(error.localizedDescription)")
}
}
}
#endif

View File

@@ -21,6 +21,13 @@ targets:
deploymentTarget: "17.0"
sources:
- path: iOS
# 打包字体(JetBrains Mono 四权重,UIAppFonts 在 TerminalX-Info.plist 声明)。
# 必须平铺进 bundle 根(UIAppFonts 的值不带目录),故用 group 而非 folder reference。
# OFL.txt 一并打包(OFL-1.1 要求许可随分发)。
- path: Resources/Fonts
buildPhase: resources
excludes:
- "README.md"
dependencies:
- package: GhosttyKit
product: GhosttyTerminal
@@ -45,5 +52,7 @@ targets:
TARGETED_DEVICE_FAMILY: "1,2"
INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
# M0 渲染自检无需签名(仅模拟器);真机/上架在 M4/M5 配置。
CODE_SIGNING_ALLOWED: NO
# 真机自动签名(公司团队 Nanjing ShenRuan)。tsnet 用户态无需 NE entitlement。
# 签名后 Keychain(SecItem)/Secure Enclave 可用(未签名会 -34018 回退文件)。
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: J75A6S5938

View File

@@ -7,6 +7,7 @@
## 0. 一句话现状
一个**原生 iPad SSH 终端**已可用并真机验证:libghostty 渲染 + 键盘输入 + SSH 直连 + **SSH-over-tsnet(Tailscale 用户态)** + **tmux -CC 原生 tab + 多 pane 分屏** + **mosh(LAN + over-tsnet 均已端到端验证)** + 断线自动重连 + **mosh 挂起→恢复<3s(M3)**。四大核心需求(libghostty/tailscale/tmux/**mosh 均已验证**)。仅在 iOS 模拟器验证(未上真机/未签名)。
> **2026-07-24 本轮**:修复 3 个渲染/tmux 显示 bug + 完成 UI 产品化第一波(对标 Moshi)。详见 **§10**。UI 已从 demo 态变为深色 Catppuccin 主题 + 主机管理页 + 终端 chrome + 统一主题切换。
> **mosh 状态(2026-07-24 全部验证通过)**:mosh(C++,blinksh/ios)+protobuf-lite 交叉编译成 `MoshCore.xcframework`(CommonCrypto 后端,含 arm64 模拟器 slice);tsnet 桥加 UDP relay(`StartMoshRelay`);`MoshSession`(pipe↔FILE*/pthread/SIGWINCH)+`MoshConnectScanner`(TXCore)+`SSHTerminalModel` 编排(SSH 引导 mosh-server→解析 MOSH CONNECT→接管)。
> - **LAN 直连端到端验证**(192.168.9.199):mosh-server 存活 104s≫60s 无客户端超时+子 shell+心跳=真连。
> - **mosh over tsnet 端到端验证**(vohive-vm 100.69.201.101):app 日志 `parsed MOSH CONNECT port=60002`→`relay up localPort=63542`→`activateMosh started`;屏幕渲染远端 shell 且无 mosh 断连 overlay=relay UDP 双向流通。**R1(tsnet UDP 数据报语义) 证伪**。
@@ -96,4 +97,268 @@
- **绝不臆想工具结果**:调用后等真实返回再推理,绝不编造 BUILD/日志/截图。曾因连续编造工具输出(含假 BUILD SUCCEEDED、错误归因 GhosttyKit)而误诊——真相全靠老实 `Read` 完整 log。此规则已写入全局 `~/.claude/CLAUDE.md`。
- 每步用真实证据验证:`swift test` 看真实计数、`xcodebuild` 看真实 BUILD 结果、模拟器 `screenshot` + `Read` 看真实画面。
- 与用户用**简体中文**;commit/push 只在明确要求时。
## 10. UI 产品化 + 渲染修复(2026-07-24 本轮)
### 10.1 修复的 3 个渲染/tmux 显示 bug(e2e 复现+修复+回归,均模拟器 idb 点击+截图取证)
1. **终端底部残影**(旧帧钉屏底):ghostty 在 iOS 用 `addSublayer:` 挂 IOSurfaceLayer,但 `Metal.deinit` 从不 `removeFromSuperlayer`(上游 iOS-only 缺陷),SwiftUI attach 抖动导致同一 view 多次建 surface 时旧层泄漏、冻结小尺寸首帧,被 CA `topLeft` gravity 钉在屏底。修:vendor `UITerminalView.platformSetup` 建 surface 前清现存子层 + `TerminalSurfaceCoordinator.onSurfaceFreed` 钩子(`tearDownSurface` 后回调)在 `commonInit` 里摘子层。(诊断经 Fable 顾问在 ghostty 源码层锚定。)
2. **tmux 切 tab 切回旧 tab 内容消失**:`TmuxTabbedView` 原只渲 activeWindow 且 `.id(win.id)`,切走销毁 pane 视图→释放 surface→内容丢失。修:改成**所有窗口常驻挂载、仅激活可见**(`ZStack`+`opacity`+`allowsHitTesting`),surface 不释放、内容保留、后台窗口持续收 %output。
3. **tmux pane 内容钉底/顶部残行**:pane 的 ghostty grid 由容器像素几何决定(tab 条吃高度→73 行),而 attach 用 raw 全屏 75 行 capture,75 行快照喂 73 行 grid 溢出上滚。修:(A) 容器几何测量上移到 `TmuxTabbedView` 内容槽(破鸡生蛋死锁);(B) `TmuxController` attach kickoff 改 `attachAcked ∧ containerKnown` 双条件(`kickoffIfReady`);(C) `capture-pane` 铺快照前剔尾部空行。
### 10.2 UI 产品化(对标 Moshi getmoshi.app;深蓝黑底 + 终端绿 + 圆角卡片)
- **设计系统(颜色封装,为主题系统)**:`apps/TerminalX/iOS/Theme.swift`=`TXPalette`(所有色角色 struct)+`TXThemeManager:ObservableObject`(环境注入);结构量走 `enum TX`(圆角/间距/等宽字体)。
- **统一主题**:从 GhosttyTheme 抽离 4 套 Catppuccin(Mocha/Macchiato/Frappé/Latte),每套同时定义 app 配色+终端配色(`ghosttyName`→`terminalTheme`)。设置里选主题 → **app 与终端一起变色 + 持久化**(`UserDefaults(txThemeStorageKey)`);`ContentView` `.onChange(palette.id)→model.applyTerminalTheme()`;新终端/pane 经 `TerminalTheme.txDefault` 读持久化。
- **主屏/主机管理页**(`ContentView.HomeView`):主机卡(`HostCard`)+FAB 加主机(`AddHostSheet`)+设置(`SettingsStubView` 含主题选择器);`HostStore`(Application Support/hosts.json 持久化,密码暂存本地/TODO Keychain);点卡片 `SSHTerminalModel.connect(to:)`。
- **终端外壳 chrome**(`ContentView.TerminalScreen`+`TerminalTopBar`):顶栏(圆形返回钮+会话标题`model.connectionTitle`+传输徽章`model.moshEngaged`?mosh青:SSH绿+连接态点+状态提示);`TmuxTabChip` 绿 pill 化;底部 GhosttyKit 工具栏深色自适应;终端默认 Catppuccin Mocha。
- **验证**:iPad mini(A17 Pro) live 验证全流程(加主机→连接→终端→切 tab→分屏点焦点→主题切换 app+终端同步)。
### 10.3 待续(UI)
- 会话缩略卡区(Moshi 首屏实时终端预览横滑,需会话管理);设置页字体/工具栏配置;导航壳打磨;终端工具栏 accent tint(`inputAccessoryStyle` SwiftUI 未暴露需桥接)。
- 无头 UI 测试:`idb ui tap x y`(Python3.14 需事件循环 shim `/tmp/idbrun.py`;坐标=屏幕像素×0.5,注意设备朝向)。诊断埋点(GKDBG/txDbg*)已清理;MOSHDBG/TMUXDBG/M4DBG 仍在(旧 cleanup-todo)。
## 11. Hosts / Keychain / Tailscale 三分离管理(2026-07-24 本轮,对标 Termius)
把连接拆成 **Hosts / Keychain(凭据) / Tailscale 连接** 三者分别管理,iPad 优先。设计阶段咨询 Fable 顾问(Q1–Q5 裁决)。**BUILD SUCCEEDED + 模拟器 idb 截图全流程验证 + TXCore 40 tests 绿。**
### 11.1 数据模型(app 层新文件)
- `Credential.swift`:`Credential`(凭据 meta,无机密) + `CredentialSecret`(自描述机密)。Kind: password / privateKey(source: pasted/imported/generated, publicKeyBlob) / secureEnclave(占位)。
- `CredentialStore.swift`:`@MainActor protocol CredentialStore`(list/secret(for:)/upsert/delete,meta 与机密分离) + `FileCredentialStore`(沙盒 `credentials.json`,版本化信封 `{"scheme":"plaintext-v1"}`,0600+`isExcludedFromBackup`+`completeFileProtection`)。**不自造加密**:无根信任下伪安全;签名后换 Keychain 后端即可(协议已抽象)。
- `TailscaleStore.swift`:`TailscaleConnection`(每个=一个常驻 tsnet 节点;`stateDirName="tsnet-<uuid>"` 创建即固化终身不变——tsnet 无文件锁,同 Dir 双 Up 损坏状态) + `TailscaleStore`(tailnets.json,无机密;authKey 注册后即弃)。
- `HostStore.swift`:`SavedHost` 改为 `auth: AuthRef`(.inlinePassword / .credential(id)) + `tailscaleID: UUID?`;自定义 decoder 迁移旧 inline username/password 为 `.inlinePassword`(不造幽灵凭据)。
### 11.2 签名映射 `CredentialAuth.swift`(复用既有三认证路径,传输层零改动)
- password → `.password`;生成软件 P-256 → `SigningKeyProvider.from(x963:)` → `.publicKeyCallback`(路径A);粘贴/导入 PEM → `.privateKey`(libssh2 `publickey_frommemory` 路径B)。
- **关键:`SSHSession.authenticate()` 三分支(.password/.privateKey/.publicKeyCallback)早已全部实现**,本次无需动 TXTransport。
- `SigningKeyProvider` 加 `from(x963:)`/`generateSoftwareP256()`/`secureEnclaveAvailable()`(非持久探测,签名真机自动点亮 Face ID UI)。openssh-key-v1 mbedTLS 不认 → UI 警告提示 `ssh-keygen -p -m PEM` 转换。
### 11.3 多 tsnet 节点 `TsnetRegistry.swift`(替换旧单例 `TsnetManager`)
- 单例 `@unchecked Sendable`+锁,按 connID **懒启动/Up 后常驻/LRU 限 3/in-flight 去重**。Fable 经 tsnet v1.102.0 源码确认一进程多 `tsnet.Server` 官方支持(dial 走具体 node 句柄无全局路由→重叠 100.x 不误连;红线仅 stateDir 唯一)。
- **坑:`TsnetbridgeNode`/`MoshRelay` 非 Sendable**,不能在 `Task.detached` 捕获或跨 actor 传 → mosh relay/wakeUp 收进 registry 按 connID 操作,model 只留 `activeConnID`(不再持 node)。
### 11.4 连接编排(`SSHTerminalModel`)
- `connect(to:)` 解析 AuthRef+tailscaleID → `EgressPlan`(.direct/.tsnet(connID,stateDir,hostname,authKey));`startConnect` 统一构造 config,host key pin 命名空间=`egressPlan.egressKey`(`direct`/`tsnet-<uuid>`)。删实例级 `tsnetAuthKey`/`TsnetManager` 单例。
- 无头验证:`-txTsnetKey` 合成临时连接(固定 connID + stateDir `tsnet-main` 复用模拟器身份)→ 启动参数矩阵不回归(已验证直连 SSH 到 127.0.0.1:22 走通 host key 校验+密码认证)。
- `model.bind(credentials:tailscale:)` 由 ContentView 注入两 Store。
### 11.5 UI(`ContentView` + 4 个 View 文件 + `UIComponents.swift`)
- `RootSplitView`=`NavigationSplitView` 侧栏(§连接:主机 / §保险库:Keychain·Known Hosts / §网络:Tailscale / 设置)+详情;iPhone 紧凑自动坍缩。终端改 `.fullScreenCover($model.showsTerminal)` 全屏盖(退出回列表,状态保留)。
- `HostsView.swift`(列表+HostCard+HostEditorView:认证 segmented[直接输入/从 Keychain 选]+出口 Picker[Direct/各 Tailscale])、`KeychainView.swift`(列表+CredentialEditorView:密码/SSH密钥[生成·粘贴·导入]/Face ID 占位)、`TailscaleView.swift`(列表+编辑器 name/hostname/authKey+注册按钮经 registry Up+SelfIP)、`SettingsView.swift`(主题+关于 + `KnownHostsView` pin 查看/删除)。
### 11.6 未验证(需用户实时资源)
真 tsnet 多节点 Up(需 authkey)、密钥/凭据成功登录(需服务器信任 key)、mosh over 新 egress plan。这些在有凭据时按 §8 流程补测。
---
## 12. Demo → 商业化 UI/UE 改造(2026-07-25 起,按 Claude Design 定稿)
**设计定稿**(hifi,含精确 token):`docs/design/UI-DESIGN-HANDOFF.md` + `terminalX-iPadOS.dc.html`(13 屏画板)。
渲染对拍:`python3 docs/design/render-screens.py` 拆屏 → headless Chrome 逐屏截 PNG。规划级裁决咨询 Fable 顾问。
**总判断**:差距不在样式而在结构。三处必须先拆的地基——① chrome 从 Catppuccin 派生改为 Nocturne 中性阶 + 恒定 blurple;② `NavigationSplitView`+`fullScreenCover` 改路由 + 会话常驻画布;③ 单会话模型改多会话并存。
### 12.1 路线图(6 阶段,UI-1/UI-2 已完成)
| 阶段 | 内容 | 状态 |
|---|---|---|
| UI-1 | 设计令牌地基(Nocturne chrome + blurple + JetBrains Mono) | ✅ 完成 |
| UI-2 | 导航与多会话状态模型(AppRouter/SessionManager/SessionCanvas) | ✅ 完成 |
| UI-3 | 终端主界面:56pt 轨道 + 浮起双胶囊 + 侧边栏三态 | ✅ 完成(pane 缝拖拽待做) |
| UI-4 | 首页会话卡 + 关闭确认 + 分屏菜单 + pane 拖拽 | ✅ 完成(pane 拖拽待真机 tmux 验证) |
| UI-5 | 设置·外观 + 键盘体系(GCKeyboard 检测 + 全套 ⌘ 快捷键) | 待做 |
| UI-6 | 竖屏参数化 + iPhone 首版 | 待做 |
### 12.2 UI-1 设计令牌(`Theme.swift` 重写)
- 三层分工:`TXAccent`(Nocturne blurple 8 档,**恒定不随主题变**,只做线/点/选中底/文字)· `TXChrome`(chrome 中性阶 14 档,**4 张预生成表**)· `TXFlavor`(Catppuccin flavor,驱动终端 + 状态语义色)。`TXPalette` 是门面,旧取色名(`p.accent`/`p.textPrimary`…)保留为 computed,逐屏重写时再换直通名。
- chrome 表推导:**Mocha 用设计交付的精确锚点值**;Macchiato/Frappé 按 flavor base 相对 Mocha 的 HSL 差平移(文字阶抬升幅度收一半,保对比度);Latte 是唯一浅色,整体反转手工写(仍不用纯黑纯白)。切 flavor → chrome 变阶、accent 不变。
- JetBrains Mono 四权重 vendor 进 `apps/TerminalX/Resources/Fonts/`(附 OFL.txt,OFL-1.1 要求随分发)。**必须平铺 bundle 根**(`UIAppFonts` 值不带目录)→ project.yml 用 group 而非 folder reference。`TX.Font.mono` 按 weight 映射 PostScript 名,缺失时 SwiftUI 自动回退。
### 12.3 UI-2 导航与多会话(新 `AppRouter.swift`/`SessionManager.swift`,`SSHTerminalModel`→`TerminalSession`)
- `AppRoute = .home | .terminal(UUID)`;`SidebarState = .collapsed|.overlay|.pinned`(overlay 不占位、不 resize;pinned 钉住各 resize 一次)。
- `TerminalSession` 加身份:`id`/`host`/`isLive`(原 showsTerminal,**不再驱动导航**)/`isMinimized`/`lastActiveAt`/`link`(LinkState 五态,由 phase+egress 推导)/`displayName`/`initials`/`isTmuxSession`。**TXCore、传输层零改动**(50 tests 保持绿)。
- `SessionManager`:多实例持有、`open(host:)` 复用同主机会话、`focus`/`minimize`/`close`、前后台广播、⌘1…9 索引、首页排序(最小化置顶 + 最近活跃)。
- `ContentView` → `RootView(ZStack{SessionCanvas, HomeShell})`。**SessionCanvas 常驻挂载所有会话**(详见 CLAUDE.md 坑:摘除即丢内容 + matchedGeometry 要求同层级)。host key alert 下移到 `TerminalScreen`(每会话一份,后台会话也能弹安全告警)。
- 无头验证脚手架(DEBUG):`UIPreviewFixture` 假会话 + `terminalx://ui/*` 导航驱动 + `scripts/tx-shot.sh` 截图定向。**坑**:假会话必须 `isSyntheticFixture=true` 从生命周期广播里排除,否则 scenePhase→active 广播经 `syncUI()` 把注入状态覆盖回 idle。
- **验收**(四步截图取证,全通过):A 终端会话1 → B `ui/minimize` 回首页(3 会话并存 + mac-studio 标「最小化中」置顶)→ C `ui/session/2` 切会话2(独立内容 + tsnet 徽标)→ D `ui/session/1` 回会话1 **内容原样**。
### 12.3b UI-3/UI-4 两屏还原(新文件 `TXParts` / `RailSidebar` / `TerminalChrome` / `HomeView`)
- **`TXParts.swift`** — 设计稿反复出现的小件:`TXStatusDot`(8pt + 2pt 挖底描边) · `TXInitialsAvatar`(26/38pt 两字缩写 + 右上状态点) · `TXSectionLabel`(600 10px ls .09em uppercase) · `TXBadge` · `TXChip` · `TXSearchField`(32pt) · `TXButton` · `TXIconButton`(视觉 34、命中 44) · **`TXTerminalMirror`**(终端文本镜像,底对齐)。
- **`RailSidebar.swift`** — `RailView`(56pt/竖屏 48pt 轨道:logo 不可点 → 搜索 → 首页 → 主机头像+状态点 → 虚线+ → TMUX 段 window 数字 → 状态点 → »展开 → 齿轮) + `SidebarPanel`(288pt,遮罩/Pin 共用同一套内容) + `SidebarSessionRow`/`SidebarWindowRow`。**开合语义唯一**:logo 不可点,折叠态»展开在轨道底、展开态«收起在标题右,Pin 是独立按钮只在展开态出现。
- **`TerminalChrome.swift`** — 浮起胶囊(`.ultraThinMaterial` + 半透底 + 描边 + 圆角 11):左上标题胶囊(红黄点/竖分隔/状态点/主机名/window title)+ 右上三格状态胶囊(`tsnet | mosh | tmux`,**只染出问题的那层**)+ 底部 34pt 提示条(文案随侧边栏三态切换)。
- **`HomeView.swift`** — 页头(34px 标题 + 副行 + 220pt 搜索 + 新建连接 + 齿轮)→ 活动会话 3 列 `SessionCard`(卡头状态/主机名/tmux/链路 · 文本镜像 · 卡脚 windows·panes·上次活跃 + ⌘n + 回到会话)→ 全部主机 4 列 `HostTile` + 筛选 chips + 添加卡。重连/离线卡:镜像 `blur(1.5)` + `rgba(24,26,40,.72)` 遮罩 + 转圈 + 「屏幕内容已缓存,恢复后原样接回」。
- 舞台:pane 区顶部留 `TX.Layout.capsuleInset`(86pt) 预留带 + `clipped()` 防溢出;pane 之间 2pt 缝(各让 1pt);`TmuxStage` 去掉 tab 条(window 切换移到轨道/侧边栏),保留「全 window 常驻挂载 + opacity」与容器几何单一来源。
- **缩略卡的「真实回滚」**:`TerminalSession.captureSnapshot()` 用 `readViewportText()` 抓尾部 32 行(剔尾部空行),首页可见时每 1.5s 轮询 + 最小化前抓一次。**没有第二个 surface**。
- fixture 扩展:`UIPreviewFixture.hosts`(7 台 + 分组/os)、`tailnets`、每条会话的 `TmuxSummary` 注入;`HostStore.injectFixtureHosts`/`TailscaleStore.injectFixtureConnections` 只进内存**不写盘**。
- **验收**:四态截图对拍设计稿 —— 折叠轨道(08) · 遮罩态(00) · Pin 态(01,终端真的变窄) · 首页(09)。TXCore 50 tests 保持绿。
### 12.3c 像素级走查修正(用户走查 + 逐屏对拍,2026-07-25 二轮)
走查方法(**后续每屏都照这个来**):`scripts/tx-shot.sh` 截图 → `python3` 把设计稿 PNG 与截图统一缩放到 1366×1024 → 裁同一区域上下/左右并排 → 再对元素列做**垂直投影扫描**(逐行找与底色不同的段),把元素 y 坐标量化对比。目测靠不住(缩略图里 `#1E1E2E` 与 `#11121C` 分不出)。
修正项:
1. **沉浸不彻底(最严重)**:原来把 86pt 预留带做成"pane 容器整体下移",胶囊浮在舞台底色上。改为 **pane 容器从舞台顶部就开始**(终端底色 + 边框铺满),只把 surface 内容 `padding(.top, 86)` —— 预留带铺的是 `flavor.terminalBg`,故与终端内容视觉连续,胶囊真正"悬浮在终端之上"。像素采样验证:预留带与内容区同为 `#1E1E2E`。
2. **终端页隐藏系统状态栏**(设计稿 `1b`/`3a` 顶部就是胶囊/logo,`1e`/`1g` 才有状态栏)+ `ignoresSafeArea(edges: .top)`。**坑**:`statusBarHidden` 不能挂在 `TerminalScreen` 上——它常驻挂载(SessionCanvas),会连首页一起隐藏;要挂在 `RootView` 上按 `router.route` 判断。
3. **侧边栏动画**:三态切换统一走 `AppRouter.sidebarAnimation`(`.easeOut(0.24)`,设计要求 200–260ms),面板 `.move(edge: .leading)` 滑入 + 遮罩 `.opacity` 淡入。
4. **底部两区同高**:侧边栏脚与舞台提示条共用 `TX.Layout.footHeight`(34pt),故两侧分割线落在同一 y;侧边栏脚原来的深色块 + 齿轮**去掉**,改 1pt `divider`(设计稿如此)。
5. 轨道结构:**搜索/首页/设置是裸图标**(无底无框),只有 logo 与展开键有框(`TXIconButton(bare:)`);纵向节奏按扫描到的设计坐标写死(logo 28 · 头像 174/220/266 · TMUX 标签 388 · 数字 408 起);TMUX 段标签用 accent-500 而非中性灰。
6. 侧边栏面板:logo 30pt、会话行 48pt、window 行 44pt(实测值);分组分隔线改**渐隐** LinearGradient;占位分支补回漏掉的「新建 window ⌘T」行。对齐后与设计稿偏差 ≤9pt(window 区完全重合)。
7. 缩略卡:卡头/卡脚分隔线用 1pt `Rectangle`(`Divider().overlay()` 压不住默认色);行距 `fontSize×0.6`(设计 11px/1.5);**启发式着色** —— `readViewportText()` 只给纯文本,故按行首符号/关键字上色(`✓`绿 `error`红 `◇▸`青 `[..]`灰 提示符行亮),逼近真实终端密度。
### 12.3d UI-4 交互闭环(关闭确认 / 分屏菜单 / pane 拖拽)
- **`TmuxController` 新增命令**(纯追加,未动既有链路):`detach()`(`detach-client`) · `killSession()` · `splitWindow(vertical:joinSession:)`(`split-window` / 跨会话用 `join-pane -s`,**不嵌套 tmux**)· `splitIntoNewSession()`(`new-session -d` + `join-pane`)· `resizePane(cols:rows:)` · `listSessions()`(新 `PendingKind.listSessions`,结果进 `@Published sessions: [TmuxSessionInfo]`)。
- **`AppModal` 提到 `AppRouter`**(不是视图 `@State`):⌘W/⌘D 快捷键与无头 URL 驱动都要能打开浮层,视图内部状态触达不到。
- **关闭确认 `CloseConfirmDialog`**(3c,436/404pt):tmux → 推荐「仅断开 ⏎」(`detach-client`,服务端继续跑) + 次要红字「结束会话」(`kill-session`,**再点一次才执行**);原生窗口 → 琥珀警告 + 危险按钮(设计精确色 `#8d5560` / `rgba(92,58,63,.35)` / `#f5a0ac`)。`closeSession(kill:)` 先发 tmux 命令、延迟 250ms 再 teardown 传输层(让 `%exit` 走既有 `onExit`)。
- **分屏菜单 `SplitMenuView`**(3b,360pt):**不问方向**(⌘D/⌘⇧D 本就是两个入口),只列「分到哪里」——当前会话(选中底 + 「当前」)· detached 会话(绿点,因为进程仍在服务端跑 + 上次活跃)· 新建 tmux 会话。前导列统一 20pt 使各行文字左缘一致。**原生窗口项灰显标「二期」**(需要一台主机多条 SSH channel,见 §12.4),不装作能用。
- **pane 拖拽 `PaneSplitter`**:从布局树内部节点算出相邻子节点边界 → 热区 12pt + 把手 44×3pt。**拖动中只动视觉**(把手位移 + 转强调色),不改 pane frame、不发命令;**松手才发一次 `resize-pane`**,等 `%layout-change` 回来更新真实 frame(与"路线 b"一致:客户端不自行重排)。
- **最小化过渡**:离开终端时 `opacity + scaleEffect(0.97)`(240ms easeOut)。用 `scaleEffect` 而非 frame 动画 —— 前者只是渲染变换**不触发 PTY resize**,后者会让 tmux 反复重排。完整 matchedGeometry 缩回卡片未做(fable 裁决:不对 `TerminalSurfaceView` 本体做 matchedGeometry)。
- **验收**:三个浮层逐个对拍设计稿(3c 两套文案 + 3b 菜单);最小化往返内容原样、日志无 crash;TXCore 50 tests 绿。**pane 拖拽只做了静态编译与逻辑走查,没有真实 tmux 多 pane 会话验证过**(fixture 没有 controller)——有测试主机时必须补。
### 12.3e 接真实数据 + 空状态(UI-5 之前的必要一步)
之前几轮都靠 `-txUIFixture` 假会话对拍设计稿,真实路径下有数据缺口和裸露的空白屏。本轮补齐:
**真实数据打通**
- **tmux 会话名**:kickoff 时顺带 `listSessions()`,`currentSessionName` = list-sessions 里 attached 的那个 → 状态胶囊/侧边栏标题显示真实 `tmux <会话名>`(拿不到就只显示 `tmux`,不编造)。
- **cwd**:`list-windows` format 追加 `#{pane_current_path}` → `TmuxWindow.cwd`;标题胶囊显示「window title · ~/src/x」,侧边栏 window 行副行显示「cwd · n panes」。`abbreviate()` 把 `/home/u/x`、`/Users/u/x` 收成 `~/x`。
- **主机的分组 / 系统**:`HostEditorView` 新增「归类」区(分组输入 + 已有分组一键选 chips + 系统描述)。此前 `SavedHost.group/os` 只有 fixture 在写,真实主机永远没有分组 → 首页 chips 恒只有「全部」。
- IP 主机名的缩写:`10.255.255.1` 按字母规则会得到无意义的 `11` → 纯 IP 取首段前两位(`10`)。
**空状态(真实路径必经)**
- 首页首次运行(0 主机 0 会话):终端图标 + 「还没有主机」+ 能力说明 + 「新建连接 / 先配置 Tailscale」。
- 有主机无会话:虚线提示条「还没有在跑的会话 —— 点下面任意主机即可连接」。
- 搜索/筛选无命中:说明是分组还是搜索造成的 + 「清除筛选」。
- 缩略卡无输出:「等待输出…」占位(真实会话刚连上时 `readViewportText()` 还是空的)。
- 侧边栏无会话:「没有在跑的会话」。
- **终端区 `TerminalStateOverlay`**:连接中(转圈 + 分阶段文案 + **已等待 Ns** + ≥5s 后出「取消连接」)/ 连接失败(错误详情 + 返回首页 + 重试)/ 已关闭(重新连接)。已连接与重连中**不渲染此层**,不遮挡终端内容。
- 路由兜底:`route` 指向的会话已被关闭 → 自动回首页,不停在空画布上黑屏。
**传输层修一处真实可用性问题(`TXTransport`)**
- `SSHSession.connect()` 原来用裸 `Darwin.connect`,**阻塞到系统 TCP 超时(75s+)**,连不上的主机会让用户对着「连接中」干等。改为 `connectWithTimeout()`:置非阻塞 → connect → `poll(POLLOUT)` 等超时 → **查 `SO_ERROR`**(可写≠成功,拒绝连接也会变可写)→ 恢复阻塞(libssh2 用阻塞模式)。超时值走新增的 `SSHConfig.connectTimeoutSec`(默认 12s)。tsnet 路径(`preconnectedFD`)不经此处,不受影响。
- 验证:不可达地址 12s 后报「失败:socket("连接失败(12s 超时)…")」,失败态覆盖层给出重试/返回。
### 12.3f tmux 自动进入(真机反馈修复:连上永远显示「无 tmux」)
**根因**:全代码库没有任何地方发起 `tmux -CC attach`。`TmuxRouter` 只被动检测传输字节流里的 DCS 前导,之前所有 tmux 端到端验证都靠 `-txAutoCommand "tmux -CC …"` 手动触发 → 真实点击连接只有 raw shell,状态胶囊第三格恒为「无 tmux · 客户端窗口」。不是检测坏了,是从来没人发起。
**修复**
- `SavedHost` 加 `useTmux: Bool = true`(旧数据迁移也默认 true)+ `tmuxSession: String?`(默认 `main`);主机编辑器新增「会话」区(tmux 开关 + 会话名 + mosh 开关,带说明)。
- `TerminalSession.scheduleTmuxBootstrap()`:连接建立后(mosh 模式则等 mosh 接管后)延迟 **2.5s** 等 shell 提示符 → 发 `tmux -CC new -A -s <名>`。`-A` = 同名会话存在就接回(断线原样恢复的关键)。
- **探测降级**:4s 内没进 control mode → `tmuxUnavailable = true` → 终端顶部行内横幅(设计 2c:说明「关掉 App 后服务端不会继续跑」+「安装并启用 tmux」+ 可关闭)。装完自动重试一次。
- `exitTmux()` 重置引导标记 → 重连后重新 attach。
- 无头参数:`-txTmux 0|1`、`-txTmuxSession <名>`。
**真机端到端验证通过**(192.168.99.83,macOS + fish + tmux 3.7b)。过程中查出 3 个真实 bug:
1. **tab 分隔符被 PTY 吃掉**:`list-windows -F "…\t…"` 的 tab 经 PTY 到 tmux 后变成**下划线**,整行 `components(separatedBy:)` 分不开(`count=1`)→ title/cwd/会话名全丢、UI 显示兜底名「窗口 N」。改用 `TmuxController.fieldSep = "|:|"`。**排查坑**:`os_log` 把控制字符显示成 `_`,光看日志无法区分「真下划线」与「被显示替换的 tab」,我因此先误判为 os_log 显示问题——最后靠打印 `parts.count` 才确诊。
2. **UI 不刷新**(正是 CLAUDE.md 里「嵌套 ObservableObject 不冒泡」那条坑的另一面):`tmuxSummary`/window 列表是读 controller 的 computed property,而轨道/胶囊/侧边栏只订阅 session → 数据解析对了 UI 也不动。修法:`TerminalSession` 用 Combine 订阅 `controller.objectWillChange` 并转发自己的 `objectWillChange`(一处解决所有读 controller 的视图)。
3. **会话名猜错**:`currentSessionName` 原来取 `list-sessions` 里 `session_attached==1` 的那个,结果把用户自己终端 attach 的 `demo` 当成了当前会话。改用 `display-message -p "#{session_name}"`(权威:本客户端所在会话)。分屏菜单的「当前」判定也随之改为按会话名比较。
4. 顺带修正:轨道/侧边栏的 window 编号用 `#{window_index}`(用户可见的 0/1/2…)而非内部 `window_id`(@2/@3…)。
**验证结果**(服务器侧交叉验证):
- tmux 自动进入 ✓ 状态胶囊 `direct | ssh | tmux main`、标题胶囊 `fish · ~ 163×50`、轨道 window `0/1/2/3/4`(与服务器 `idx=0..4 active=@3/idx=1` 完全吻合)
- **⌘D 分屏 ✓**:`split-window -h` 后服务器 `list-panes` 从 `%7 164x50` 变为 `%7 82x50 + %8 81x50`;app 端双 pane 左右并排渲染、2pt 缝、拖拽把手、活动 pane blurple 边框
- **`resize-pane` ✓**:`ui/resize/110` 后服务器 pane 宽度变为 `110`(另一侧自动收缩到 53)——拖拽条的命令链路已验证(手势本身是标准 `DragGesture`)
- **会话服务端持久 ✓**:app 退出后 `tmux ls` 里测试会话仍在(验证完已 kill 清理)
- 新增无头验证命令:`ui/split-now/v|h`(跳过菜单直接分屏)、`ui/resize/<cols>`
### 12.3g 连接后的会话选择器(用户定稿交互,替换掉「自动 attach 固定会话」)
**为什么改**:之前实现是连上就 `tmux -CC new -A -s main` —— 替用户决定了 attach 哪个会话。真实运维里服务器上常有多个会话各跑不同任务,必须让用户挑。
**新流程**:SSH 连上 → 普通 shell 里跑探测 → 弹选择器 sheet → 按选择 attach。**没装 tmux 时不弹框**(直接原生终端 + 安装引导横幅,不打扰)。
- **`TXCore/Tmux/TmuxSessionProbe.swift`**(纯逻辑 + 5 个单测):流式扫描 `BEGIN…END` 标记块,产出 `.available(version:sessions:)` / `.unavailable`。三个坑都在注释里:
1. **PTY 回显**:命令本身会被回显,标记若字面出现在命令里,回显行会被当成结果起点 → 写成相邻字符串拼接 `"__TX""_SESSIONS_BEGIN"`(shell 输出完整标记,回显的是拼接前的样子)。
2. **三态要分开**:「没装 tmux」和「装了但没会话」的 stderr 都被 `2>/dev/null` 吞掉、标记之间同样是空的,只能靠 `tmux -V` 的版本行区分 —— 两者的正确 UI 完全不同(安装引导 vs 让用户新建)。
3. **别脏屏**:探测命令与输出会打在用户屏幕上 → 命令末尾接 `printf '\033[H\033[2J\033[3J'`(归位+清屏+清 scrollback,不依赖 terminfo)。刚连上时清掉的只有 motd/shell 欢迎语,首屏反而更干净。
- **`OutputTap`**:raw 字节的旁路(同时喂终端渲染与探测扫描器)。抽 `RawOutputSink` 协议让 `TmuxRouter` 能接受 gate 或 tap —— **不动 `OutputGate` 的缓冲/flush 语义**(那是修白屏的关键)。
- **`SessionPickerSheet.swift`**(按用户给的设计图):分段控件 `>_ Tmux` | `Herdr`(置灰) | `最近`(置灰) + 右侧「跳过 ▷|」;会话行 = 大字会话名 + 「活跃」徽标(被别的客户端占用时另标「已被接入」)+ 副行「N 个窗口 · M 分钟前」;底部「+ 新建 tmux 会话…」(点开就地输入名字)。`.presentationDetents([.medium, .large])`。
- `TerminalSession`:`pendingSessionChoice` / `attachTmuxSession` / `createTmuxSession` / `skipTmux`(`tmuxSkipped` 让状态胶囊显示「原生窗口 · 不经 tmux」而不是谎报「无 tmux」);探测 6s 超时兜底按无 tmux 处理。
- 无头命令:`ui/pick/<会话名>` · `ui/pick-new/<名>` · `ui/pick-skip`。
**时机修正(用户第二轮反馈)**:选择器属于**连接流程的一部分** —— 点主机后**留在首页**探测、在首页弹选择器、选完才进终端页;不是先把用户丢进空终端再弹框。
- `SessionManager`:`open(host:)` 不再立刻 `router.enter`;改为 `awaitingEnter` 集合 + `@Published enterRequest`(视图层消费后进终端)。`evaluateEnter` 的就绪条件:不等选择 ∧ 不在探测 ∧(已建立 / 无 tmux / 选了原生 / 失败)。失败也进终端 —— 那里有「重试 / 返回首页」,比留在首页只有一行错误更有出路。
- **坑**:`isProbingTmux` 必须在 `.connected` 那一刻就置位。否则「连接建立」与「探测开始(延迟 2.5s)」之间有空窗,`evaluateEnter` 看到 `isLive == true` 就把人送进终端页 —— 第一次改完就是这个症状(选择器弹在终端页上)。
- `SessionManager` 也要转发每条会话的 `objectWillChange`(同 `TerminalSession` 转发 controller 那条坑),否则首页/选择器不会因会话内部变化刷新。
- 首页新增 `ConnectingRow`(正在连接 / 等待选择 / 失败+重试+取消);`liveSessions` 排除正在连接的会话,避免同一会话既有进度行又有卡片(且卡片里的 tmux 信息此刻还没定,会先显示成「原生窗口」再跳变)。
- 无头验证真实路径:`-txOpenAsHost 1`(把启动参数造成一台内存主机后走 `open(host:)`,即探测→选择→进终端;与老的 `-txHost` 直连路径互斥)。
**措辞修正**:右上不再是「跳过」(读起来像"跳过某个步骤"、含义模糊),改为**「原生终端」**+ terminal 图标 —— 它是与 tmux 并列的一种连接方式。`skipTmux()` → `useNativeTerminal()`,状态胶囊第三格显示「原生窗口 · 不经 tmux」。首版只有 Tmux 与原生两种,所以不做别的出口。
**真机验证(192.168.99.83)三条出路全过**:
- 探测 → `probe: tmux 3.7b sessions=["demo", "main"]`,选择器如设计图渲染,终端首屏干净(清屏生效)
- **attach 已有** → `attach -t demo` → `currentSession=demo`
- **新建** → `new -A -s uitest` → `currentSession=uitest`,服务器 `tmux ls` 出现 `uitest: 1 windows (attached)`(验完已 kill)
- **原生终端** → 状态胶囊 `direct | ssh | 原生窗口 · 不经 tmux`
- **完整真实路径**(`-txOpenAsHost`)→ 首页「正在连接 192.168.99.83」→ 首页上弹选择器(标题「连接到 …」)→ 选 `main` → 进终端页 `tmux main` + 轨道 window `0/1/2/3/4`
### 12.3h 质感走查:动效 / 阴影 / 圆角 / 安全区(用户第三轮走查,2026-07-25)
用户反馈四类瑕疵(**风格保持不变,只修质感**):浮层"特别难展开和消失"缺过渡、胶囊阴影太重不够扁平、终端小圆角与 iPad 屏幕大圆角打架(全屏时右上角被吃掉)、底部混乱没考虑安全区。逐条修完并补了同类问题:
**1. 动效令牌化 + 补齐缺失过渡(`TX.Motion`)**
- 新 `TX.Motion`:`standard`(0.24 easeOut) / `quick`(0.14) / `slide(edge)` / `pop`(0.96→1 缩放+淡入) / `pushBack`(首页推远)。**统一响应「减弱动态」**(`UIAccessibility.isReduceMotionEnabled` → 时长减半 + 位移退化为纯淡入淡出)。
- **根因**:`AppRouter` 里只有侧边栏包了 `withAnimation`,`route` / `modal` 的变更**裸着改** → 视图上写好的 `.transition` 拿不到动画事务,全退化成硬切。现在 `enter`/`goHome`/`present`/`dismissModal`/`toggleSidebar`/`togglePin` **全部经 `animated{}`**,点击 / ⌘ 快捷键 / 无头 URL 三条入口自动同一手感。
- `TXModalScrim` 拆成两段转场(遮罩 `.opacity` + 卡片 `TX.Motion.pop`):整块套一个 `.transition(.opacity)` 只是一起变淡,没有弹出感。
- 补:无 tmux 横幅显隐、`TmuxInstallSheet`、`TerminalStateOverlay`(连上后淡出)、提示条文案随三态切换、会话卡增减、选中态/状态点变色。
- **按压反馈**:新 `TXPressStyle`(缩放 + 压暗 140ms),替换全部 `.buttonStyle(.plain)`。深色界面里 `.plain` 完全没有按下反馈,主机卡/会话卡这种"点下去要等几百毫秒"的入口尤其伤手感。
- 会话卡改成**整张一个 Button**,卡脚「回到会话 →」退化为文字提示(同一动作做成嵌套 Button 只会抢命中区 + 两套按压反馈)。
- **`-txSlowMotion 1`**(DEBUG)把所有动效 ×12:`simctl io screenshot` 自身要几百毫秒,抓不到 240ms 的中间帧,没这个开关就无法用截图证明"动效真的在跑"。验证方法:连发三张截图,采样同一像素应得到**三个不同的中间值**(硬切时三张完全一样)。
**2. 阴影扁平化(`TX.Elevation`,刻意偏离设计初稿)**
设计稿给的是 CSS 值(胶囊 `0 10px 30px rgba(0,0,0,.55)`、对话框 `0 30px 80px .7`),在深色底上会糊成一团脏斑 —— 半透胶囊叠重投影像"漏墨",终端文字从投影里穿过去。改为:`floating`(.22/7/y2) · `panel`(.28/16/x4) · `dialog`(.34/16/y6) · `card`(.16/4/y2) · `cardFocused`(.24/8/y3),分层交给描边与材质,胶囊底不透明度提到 .92 补偿。统一经 `View.txShadow(_:)`,视图里不许再手写 `.shadow`。
顺带补上**遮罩态侧边栏浮层一直漏掉的投影**(设计要求 `24px 0`,故 `panel` 是唯一带 x 位移的)。
**3. 圆角不再与屏幕圆角打架(`paneShape`)**
终端右上角贴着屏幕物理边缘,那里设备本身是 ~22pt 大圆角 —— 再画 6pt 小圆角必然被"吃掉"(小圆角整段落在屏幕圆角之外,切口处露出底色三角)。改为 `UnevenRoundedRectangle`:**只在朝 chrome 的角收圆角**(左上/左下/右下 6pt),顶边与右边**直接铺到物理边缘、不收角不描边**,由屏幕自己的圆角收口(Stage Manager 小窗同理,窗圆角接管)。
- 描边用负 padding(`top:-1, trailing:-1`)把顶边/右边那两条**推出屏外**:留着就是贴在屏幕边上的一圈多余框线。
- 顺带取消顶边/右边的 2pt 缝(2pt 是**pane 之间**的缝,不该出现在屏幕边缘),终端格子从 163×48 涨到 164×50。
**4. 安全区:chrome 铺满物理屏,底栏自己让位(`TX.Layout.footBand` / `capsuleTop`)**
- 原来 `.ignoresSafeArea(edges: .top)` 只放开顶边 → 底部内容停在安全区上沿,屏幕最下面留一条 24pt 的空带("没铺到底"),而**颜色型 `.background` 默认会溢进安全区**,于是轨道底色铺满、内容却停在 24pt 之上,一眼就是没对齐。
- 现在整屏 `.ignoresSafeArea()`,底部区高度 = `footHeight(34) + safeBottom`,内容**在其中居中** → 既贴住物理底边又让开 home indicator。提示条 / 侧边栏脚 / 轨道齿轮三者中心线同一条 y(995),上方分割线仍同在 964。
- **顺带修掉一个状态跳变**:遮罩态面板铺满全高、Pin 态被安全区推高 24pt,一钉住面板脚就跳一下。现在两态完全一致。
- 安全区读数经新 `TXScreenMetrics`(`GeometryReader` 必须在 `ignoresSafeArea` **之前**,否则读到 0)。
- 胶囊顶距:`capsuleTop(safeTop)` —— iPad 隐藏状态栏后无遮挡用设计值 14;灵动岛/刘海机型(safeTop>30)按硬件下移。**iPhone 17 Pro 模拟器实测**胶囊落在岛下方、底栏在 indicator 之上。
**5. 同类问题(走查中发现,一并修)**
- **会话卡不等高**(一排卡上下边对不齐):`TXTerminalMirror` 的行堆叠有内容驱动的最小高度,外层 `.frame(height:)` 压不住它 —— `.frame(height:)` 只是给了个框,**不想撑满的子视图会按自身理想高度居中放进去**。改为把行堆叠放进 `Color.clear` 的 overlay(overlay 不参与父视图定尺),尺寸完全由外部决定,溢出的旧行被 `clipped()` 切掉(正是想要的:底对齐留最新输出)。
- 会话卡从不换行的 `HStack` 改 `LazyVGrid`(最多 3 列):第 4 条会话进来原先会把所有卡压扁。高度由死写 560 改为 `min(560, max(300, 可用高度×0.56))`,竖屏/分屏/小 iPad 上不再把「全部主机」挤出屏外。
- 首页滚动内容铺到物理底边(`ignoresSafeArea(.bottom)` + 内容 padding 补 safeBottom),末行能完整滚到 indicator 之上。
- 窄窗口适配:主机网格 4→2 列、左右留白 40→20、搜索框 220 固定改可伸缩、提示条只留 2 条(4 条在 iPhone 上折行把 34pt 带撑破,且强制单行 `lineLimit(1).fixedSize()`)。
- 胶囊红/黄圆点命中区 22×30 → 26×34(13pt 视觉不变):关闭这种误触代价高的动作不该给这么小的目标。
- 搜索框补聚焦态描边(键盘焦点可见,无障碍底线)。
- 删掉 `HomeView` 里**嵌套重复定义**的 `ConnectingRow`(与文件级那份一字不差,编译能过但是死代码)。
- 遮罩浓度令牌化 `TX.Scrim`(对话框 .5 / 侧边栏 .42),不再散落 `Color(hex: 0x0A0B12)`。
**验收**:iPad 13″ 横屏逐屏对拍(终端 / Pin / 遮罩 / 关闭确认 / 分屏菜单 / 首页)+ iPhone 17 Pro 竖屏(灵动岛 + indicator);像素采样确认 —— 右上角 `(1365,0)` 为终端底色(不再是底色三角)、两态面板脚同在 964/982–991、三张会话卡同为 166..710、遮罩把终端文字亮度 185→97;TXCore 45 tests 绿。
### 12.3i 触控化与标签规范(用户第四轮走查 + **真机部署**,2026-07-26)
1. **首页会话卡固定 3 格 4:3**:`LazyVGrid` 列数写死 3(不再 `min(3, count)`),不足 3 条自然从左往右排、卡片尺寸不随会话数跳动;高度改 `.aspectRatio(4/3, contentMode: .fit)`,删掉按屏高算高度那段。
2. **技术标签规范(新 `TXTech`)**:
- **拼写**:照各自官方写法 —— 缩写全大写(`SSH`)、项目名按作者写法(`mosh`/`tmux`/`tsnet` 本来小写)、产品名照抄(`macOS`/`TrueNAS`)。**`TXSectionLabel` 不再 `.uppercased()`** —— 它会嵌主机名/会话名,之前渲染成 `TMUX · MAC-STUDIO · DEV` 把名字全改错了;轨道标签同步改 `tmux`。
- **配色**:传输协议冷色区分(`mosh` teal / `SSH` blue,二者互斥所以一行只出现一个);`tsnet` 用 app 强调色(招牌能力);凭据名回中性(那是用户命的名,不是技术标签,避免一排全彩色)。主机卡图标染**发行版品牌色**(Ubuntu 橙→peach、Debian/树莓→red、Fedora/TrueNAS→blue、Arch→teal、macOS→中性银),认不出的回中性。徽标只染文字+描边、底色 12%。
3. **关闭/最小化按钮重做**:原 macOS 交通灯式 13pt 纯色圆点三处不合适 —— 纯色块不说明自己干什么(mac 靠 hover 浮字形,触屏没有 hover)、13pt 命中目标太小、圆点是 mac 窗口语汇与本 app 圆角矩形语汇不搭。改 **22pt 圆角方块 + 常显字形**(`✕` / `⌄`),保留红=关闭、琥珀=最小化的颜色语义,底色 14% 不铺满,命中 26×34,间距放宽。`⌄` 而非双向内收箭头:后者在 9.5pt 下糊成一团,且向下正好对上设计的「下滑最小化」手势。
4. **侧边栏自身的平移动画(新 `leadingColumn`)**:原来是 `if pinned { 面板 } else { 轨道 }` —— 两者**互换**只能硬切,用户看到面板"消失"、轨道"出现",没有平移。改为轨道**常驻**在底、面板叠在上,列宽随三态动画(56/56/288)+ `clipped()`:收起时面板向左滑出并被裁掉、轨道从背后露出。慢放实测 1.2s 处列宽正处于中间态(≈140pt),确认逐帧插值而非硬切。
5. **去掉命令面板**:底部 `⌘K 命令面板` 与首页搜索框的 `⌘K` 提示一并删除(快捷键体系属 UI-5,未实现的提示等于假承诺)。
6. **底部改动作条 `TerminalActionBar`**(原 `TerminalHintBar`):iPad 大概率没接键盘,「竖分屏 / 新建 window / 回首页」只写成 `⌘D` 文字提示等于这些功能对触控用户不存在 → 改成**常驻可点按钮**(图标 + 文案,26pt 高胶囊)。非 tmux 会话下前两个灰显但保留(不隐藏,免得布局跳)。左侧上下文文案改为**真实会话状态**(`mac-studio · tmux dev · 3 windows`)—— 此前写死「已连接妙控键盘 · 附加行已收起」,没接键盘时那是假话。措辞与侧边栏统一为「新建 window」。
**真机部署(用户体验用)**:`-configuration Release -destination 'id=<iPad UDID>' -allowProvisioningUpdates` 自动签名(Apple Development: JIANKUAN YANG / Team J75A6S5938)→ `xcrun devicectl device install app` + `process launch`。**Release 里所有 `#if DEBUG` 的 fixture / `terminalx://ui` / `-txSlowMotion` 全部编译不进去**,所以真机跑的必然是真实数据路径(首次运行落在「还没有主机」空状态);签名后 Keychain/Secure Enclave 可用,凭据走系统钥匙串而非文件回退。
### 12.3j 侧边栏三态动画重构:统一视图身份(用户第五轮反馈「动画混乱」,2026-07-26)
用户判词:三态切换动画混乱;收起态应理解为「展开态在左侧裁切一部分」,允许改 UI 让两态最左侧图标同尺寸同位置。
**根源**(12.3i 的 leadingColumn 只修了一半):① 轨道 `RailView` 与面板 `SidebarPanel` 是两套视图,元素尺寸/位置互不对应(logo 34/30、头像 38/26、徽标 34×30/26×22,纵向节奏也不同)→ 就算列宽在动画,内容仍是两张不相干的画面交叠;② 遮罩态面板挂全屏 overlay、Pin 态面板是 leadingColumn 里**另一个实例** → overlay⇄pinned 切换视图身份互换,一个滑出一个滑入互相打架;③ 遮罩/投影靠 `if` 插拔。
**重构**(`RailSidebar.swift` 整文件重写为 `TerminalSidebar`,`ContentView.screen` 重组):
1. **三态唯一视图**:内容恒按 288pt 全宽排版;收起 = 容器宽度收到 railWidth + `clipped()`(字面实现「左侧裁切」)。最左 railWidth 为**图标列**:logo 30 / 裸图标 17pt / 头像 30 / window 徽标 26×22 / 状态点 / 开合键 / 齿轮,两态同尺寸同位置;右侧明细(标题/副行/快捷键/选中底)`opacity(expanded)` 淡入淡出。
2. **行对应关系**:轨道的 24pt 短分隔线 ⇄ 面板的分组标题(同一行两种密度,交叉淡化);轨道 `tmux` 迷你标签 ⇄ `tmux · 主机 · 会话` 分组标题;搜索裸图标 ⇄ 搜索框(放大镜位置几乎重合);底部统一为「tailnet 状态行 / 开合键行 / 分割线+齿轮 footBand」,两态元素不增不减。
3. **组合**:舞台前的占位列只在 Pin 态推到 288(保持「钉住/解除各 resize 一次」);遮罩与侧边栏**常驻**在同一 overlay 里,遮罩动 opacity、投影(`Elevation.panel`)动 opacity —— 遮罩⇄Pin 时侧边栏本体**一动不动**,只有遮罩淡出+舞台平移。
4. **新坑:`clipped()` 只裁绘制不裁触摸** —— 收起态下整行按钮被裁掉的明细区会伸进终端抢点击。整行按钮用 `LeadingHitShape`(Animatable `contentShape`,命中区=当前可见宽度);纯明细控件(搜索框/Pin/新建 window)再加 `allowsHitTesting(expanded)`。
5. **验证**(fixture + ×12 慢放 + 逐帧像素采样):收起→遮罩(遮罩逐帧变暗 30→29→22)、遮罩→收起(面板前沿扫过采样点+遮罩渐浅)、遮罩→Pin(胶囊中间帧 709px vs 终值 826px,舞台平移中)、Pin→收起(427→362)均有中间帧,无硬切。
**用户复核后追加裁决(同日)——砍掉悬浮态,只留两态**:Pin→收起的"明细淡出 + 终端从右盖上来"效果被点名保留,展开即反向。
- `SidebarState` 只剩 `collapsed|pinned`;`togglePin`/`dismissOverlaySidebar`/遮罩/浮层投影全删;`TerminalSidebar` 直接进 HStack 参与布局(无占位列),开合就是它的宽度动画(56⇄288),各触发一次 resize;点终端**不再**收起侧边栏(固定是持久态)。
- **开合入口收敛**(此前顶部/底部两个收起键冲突、底部那枚缩在左下角不协调):Logo 位=开合主入口(收起态显 » 展开键、展开态还原 `›` Logo,同框同位交叉淡化——用户定稿:将来 App Logo 设计与展开按钮一体);头部 « 收起键保留;底部开合行删除;Pin 键删除(两态下无意义)。`terminalx://ui/sidebar/pin` 兼容保留=toggle。
- **顺带修掉暴露的旧缺陷**:标题胶囊的格子数读 `screenGrid.value` 却从不刷新(旧注释"首次布局后稳定,不必 @Published"在"开合即 resize"下不成立,表现为开合后数字停在旧值)→ `GridBox` 加 `onGridChange`(去重)、`TerminalSession` 转发 `objectWillChange`。验证:展开 135×50 / 收起 164×50 实时跟随。
- 验证:展开动画 pane 左描边逐帧 58→198.5→261→290pt(easeOut 先快后慢),无硬切;收起态 Logo 位为 »、底部只剩状态点/分割线/齿轮;展开态头部仅 Logo+名称+«。
### 12.4 首版明确推迟(Fable 裁决,勿在 UI-3~6 里偷偷做)
`mosh <RTT>` 实时数字(需改 vendor/mosh iosclient 导出 SRTT,属传输层)· ⌘K 命令面板 · 无 tmux 主机的多客户端窗口(保留横幅引导)· iPad 竖屏 pane 强制上下堆叠(会推翻「路线 b」,交给 tmux relayout)· 缩略卡 ANSI 彩色(用 `readViewportText()` 文本镜像,**绝不做离屏 Metal/双 surface**——IOSurfaceLayer 泄漏史)· 设置页连字/CJK 开关(ghostty 无运行时 mutation,灰显占位)。
</content>

View File

@@ -0,0 +1,241 @@
# Handoff: terminalX — iPadOS 终端 App(沉浸轨道方案)
## Overview
terminalX 是面向 iPadOS / iOS 的原生终端运维工具,解决 iPad 远程运维 macOS / Linux 的痛点。技术选型:**libghostty**(终端引擎)· **mosh**(抗断线传输)· **Tailscale tsnet**(用户态入网)· **tmux control mode**(会话复用)。平台优先级 **iPadOS > iOS > macOS(Apple Silicon)**。
本包描述的是**终端主界面(沉浸轨道)+ 首页(主机管理)+ 设置**这一套已定稿的 UI,含侧边栏三态、分屏模型、关闭/最小化语义、竖屏与 iPhone 降级规则。
## About the Design Files
`terminalX iPadOS.dc.html` 是**设计稿**,不是生产代码。它用 HTML/CSS 表达最终视觉与交互意图(1:1 像素尺寸),目的是让你在**目标工程里用原生技术重建**——本项目的目标环境是 **SwiftUI(iPadOS 26 / iOS 26)**,请使用工程已有的架构与组件模式实现,不要移植 HTML/CSS。
打开方式:浏览器直接打开该文件即可,画板可自由缩放平移。每块屏幕都带 `data-screen-label`,便于对照本文档。
## Fidelity
**High-fidelity(hifi)**。颜色、字号、圆角、间距、行高均为最终值,可按本文档的精确数值实现。文案(简体中文,技术术语保留英文)也是最终稿。
唯一例外:终端内容区的命令输出是**示意数据**,用于验证排版密度与配色,不必照搬。
---
## Design Tokens
### 颜色 — App chrome(Nocturne 设计系统)
| 用途 | Hex | Nocturne token |
| --- | --- | --- |
| 画布底 / 屏幕底 | `#161826` | `--color-bg` |
| 侧边栏 / 轨道 / 卡片底 | `#1b1d2b` | surface-dark |
| 控件底(输入框、次要按钮、徽标) | `#232532` | `--color-surface` |
| 舞台底(pane 之间的缝) | `#11121c` | bg-deep |
| 分隔线(弱) | `#2c2f3d` | neutral-800 |
| 描边(常规) | `#3f424d` | neutral-700 |
| 描边(强调 / 对话框) | `#595d6c`、`#4a4d5e` | neutral-600 |
| 强调色(唯一) | `#9184d9` | `--color-accent` |
| 强调 - 边框 | `#796cbf` | accent-600 |
| 强调 - 选中底 | `#2b2741` | accent-900 |
| 强调 - 选中描边 | `#423a6a` | accent-800 |
| 强调 - 文字 | `#b5abfc` / `#d2cefd` | accent-400 / -300 |
| 强调 - 弱化文字 | `#5d5294`、`#968ae0` | accent-700 / -500 |
| 主文字 | `#e9e9ed` | `--color-text` |
| 次文字 | `#cfd3e5` / `#b2b6ca` | neutral-300 / -400 |
| 三级文字 | `#9397ab` | neutral-500 |
| 弱文字 / 占位 | `#75798c` | neutral-600 |
| 极弱(提示、注脚) | `#4a4d5e` | neutral-700 |
**规则**:强调色只做线、点、选中底和文字,**绝不大面积铺色**;不使用纯黑纯白。
### 颜色 — 终端内容区(Catppuccin Mocha,libghostty 渲染)
`base #1e1e2e`(pane 底)· `mantle #181825` · `surface0 #313244`(vim 状态栏)· `text #cdd6f4` · `subtext #a6adc8` · `overlay #6c7086`(次要输出)· `surface2 #585b70`(禁用/缓存态)
语义色:`green #a6e3a1`(成功、提示符)· `red #f38ba8`(error)· `yellow #f9e2af`(warning、git 标记)· `blue #89b4fa`(路径、字段)· `mauve #cba6f7`(关键字)· `teal #94e2d5`(勾选)· `peach #fab387`(重连中)· `pink #f5c2e7`
设置页可切换 Mocha / Macchiato / Frappé / Latte,**chrome 的中性阶随之推导,强调色恒为 Nocturne blurple `#9184d9`**。
macOS 窗口按钮沿用系统色:关闭 `#ff5f57`,最小化 `#febc2e`(13pt 圆点,`inset 0 0 0 .5px rgba(0,0,0,.25)`,仅悬停/触摸时显出符号)。
### 字体
全局 **JetBrains Mono**(400 / 500 / 600 / 700),chrome 与终端同源。中文走系统回退(PingFang SC)。
| 场景 | 规格 |
| --- | --- |
| 屏幕主标题(首页 terminalX) | 500 34px, letter-spacing -.025em |
| 页面标题(设置、总览) | 500 24–28px, ls -.02em |
| 分区标题 | 500 22px, ls -.02em |
| 分组小标题(活动会话 / TMUX) | 600 10px, ls .09em, uppercase, `#75798c` |
| 列表主行 | 400–500 13px |
| 列表副行 / 元信息 | 400 10.5–11px, `#75798c` |
| 徽标 / 胶囊 | 500 11.5–12px |
| 终端正文(主 pane) | 400 13.5px / 1.55 |
| 终端正文(次 pane / 缩略) | 400 11–12px / 1.5 |
| 终端 pane 头 | 500 11px, `#6c7086` |
| 底部提示条 | 400 11.5px, `#75798c` |
### 间距 / 圆角 / 阴影
- 间距:2 / 6 / 8 / 9 / 10 / 12 / 14 / 16 / 18 / 20 / 26 / 34 / 40(px)
- 圆角:徽标 5–7 · 控件 8–9 · 卡片 11–13 · 对话框 14–15 · 屏幕卡 18 · 手机卡 38
- 阴影:浮起胶囊 `0 10px 30px rgba(0,0,0,.55)`;侧边栏浮层 `24px 0 60px rgba(0,0,0,.55)`;对话框 `0 30px 80px rgba(0,0,0,.7)`;卡片 `0 6px 18px rgba(0,0,0,.4)`
- 遮罩:`rgba(10,11,18,.42–.5)`,浮层用 `backdrop-filter: blur(20px)`
### 图标
**Phosphor Icons**(regular)。用到:`magnifying-glass` `house` `plus` `gear-six` `caret-double-left` `caret-double-right` `caret-down` `caret-left` `push-pin` `columns` `terminal-window` `hard-drives` `desktop-tower` `cpu` `key` `shield-check` `lightning` `paint-brush` `text-aa` `keyboard` `export` `info` `warning` `x` `minus` `arrow-right` `arrow-left` `lightbulb` `moon` `check-circle` `wifi-high` `battery-high` `browsers` `rows` `git-branch` `plugs` `trash` `arrows-in-simple` `textbox` `sidebar-simple` `globe-hemisphere-west`。
SwiftUI 实现请换成等义 **SF Symbols**(例如 `house` / `magnifying-glass` → `house`、`magnifyingglass`;`caret-double-left` → `chevron.compact.left` 或 `sidebar.left`;`push-pin` → `pin`)。
---
## Layout 基准
- 画板尺寸:iPad Pro 13″ 横屏 **1366×1024pt**;竖屏 **1024×1366pt**;iPhone **393×852pt**
- 折叠轨道宽 **56pt**(竖屏 48pt),元素中心线统一在 28pt(竖屏 24pt)
- 展开侧边栏宽 **288pt**
- pane 之间的缝 **2pt**(拖拽热区 **12pt**,把手 44×3pt 圆角 2pt)
- 触控命中区不低于 **44pt**(键盘附加行按键高 38–40pt + 6pt 间距 = 44pt 节距)
- 终端主 pane 顶部预留 **86pt** 给浮起胶囊(2c 因多一条横幅为 122pt);预留带用 padding-top 实现,内容容器 `min-height:0; overflow:hidden` 防止向上溢出
- Home indicator 条:200×4pt(iPhone 140×5pt)
---
## Screens / Views
### 1) 终端主界面 · 沉浸轨道 — `1b 终端-沉浸轨道`
**Purpose**:真正干活的界面,终端面积最大化。
**Layout**:`HStack`:左 56pt 轨道 + 右舞台。舞台是 `VStack`:pane 区(flex)+ 硬件键盘提示条 34pt + home 区 14pt。pane 区 padding 2pt,上下两个 pane 比例 1.7 : 1,中间 12pt 拖拽条。
**轨道自上而下**:Logo 方块(34×34,圆角 11,底 `#232532`,inset 描边 `#3f424d`,字形 `›` 700 16px `#b5abfc`,**不可点**)→ 搜索图标 → 首页图标 → 1pt 分隔 → 主机头像(38×38 圆角 11,两字缩写;选中底 `#2b2741` + inset 描边 `#5d5294`;右上角 8pt 状态点,`box-shadow: 0 0 0 2px #1b1d2b` 挖底)→ 虚线 + 号 → 分隔 → `TMUX` 标签(600 9px ls .1em)+ window 数字(34×30,选中底 `#232532` + inset 描边 `#4a4d5e`)→ `Spacer` → 连接状态点 8pt → 展开键(`caret-double-right`,34×34 底 `#232532` 描边 `#3f424d`)→ 设置齿轮。
**浮起 chrome**(`position: absolute`,`backdrop-filter: blur(20px)`,底 `rgba(27,29,43,.86–.9)`,描边 `#3f424d`,圆角 11):
- 左上标题胶囊 `top 14, left 16`:红/黄圆点 → 1pt 竖分隔 → 6pt 绿点 → 主机名(500 12.5px)→ 「当前 window title · cwd」(400 11.5px `#75798c`)
- 右上状态胶囊 `top 14, right 16`:三格分层徽标 `tsnet` | `mosh 41ms` | `tmux dev`,格间 1×16pt 竖线
**底部提示条**:接硬件键盘时只留 34pt 提示条(快捷键清单);触控时替换为键盘附加行(两行,见 iPhone / 竖屏)。
### 2) 侧边栏三态 — `3a 侧边栏-遮罩态`、`3a 侧边栏-Pin 态`
同一条栏的三个状态,**元素不增不减**,只是展示密度不同:
| 状态 | 宽度 | 行为 |
| --- | --- | --- |
| 折叠 | 56pt | 只显图标与两字缩写 |
| 遮罩(默认展开) | 288pt | 面板**盖住**轨道(`left: 0`,不并排,避免两套入口),右侧终端压 `rgba(10,11,18,.5)` 遮罩,**点终端任意处收起**;终端不 reflow,不触发 PTY resize |
| Pin | 288pt | 点面板右上 `push-pin` 后钉住:遮罩消失,终端变窄,**只在钉住/解除时各 resize 一次**;再点解除回遮罩态 |
**开合语义(务必唯一、不歧义)**:`›` 方块是 App logo,不可点。开合只有一枚按钮,两态方向相反——折叠态在轨道底部 `caret-double-right`(展开),展开态在标题右侧 `caret-double-left`(收起)。Pin 是独立按钮,只在展开态出现。快捷键 `⌘\` 开合。
**面板内容自上而下**:Logo + `terminalX`(500 14px)+ Pin + 收起 → 搜索框(32pt 高,圆角 8)→ **首页**行(`house` + 「首页」+ `⌘⇧H`)→ 分组「活动会话 n」→ 会话行 → 1pt 渐隐分隔 → 分组「TMUX · <主机> · <会话>」→ window 行 + 「新建 window ⌘T」→ `Spacer` → Tailscale 状态脚(绿点 + tailnet + 设备 IP + 齿轮)。
**行规格(左缘必须对齐)**:会话行 = 26pt 头像列 + 10pt gap;window 行 = 26×22pt 徽标列 + 10pt gap;两者标题左缘同为 **x=53**。选中态用 `box-shadow: inset 0 0 0 1px #423a6a` 而非 `border`,避免选中时内容位移 1pt。
### 3) 首页 · 主机管理(落地页)— `1e 主机-会话卡`
**Purpose**:App 冷启动落点;「回到刚才那个会话」是首要动作。
**Layout**:状态栏 24pt → 页头(terminalX 500 34px + 副行)+ 右侧搜索 220pt / 新建连接 / 设置 → 分组「活动会话」→ **3 列会话缩略卡(flex:1)** → 分组「全部主机」+ 筛选 chips → **4 列主机卡(按内容高度)** → home 区。左右留白 40pt。
**会话缩略卡**(圆角 13,选中卡描边 `#423a6a` + `0 6px 18px rgba(0,0,0,.4)`):卡头 36pt(状态点 + 主机名 + tmux 会话 + RTT)→ 终端缩略(`#1e1e2e`,11px/1.5,**内容底对齐**,真实回滚)→ 卡脚 38pt(「n windows · 上次活跃」+ `⌘1/2/3` + 主动作「回到会话 →」)。离线卡加 `rgba(24,26,40,.72)` + `blur(1.5px)` 遮罩,内含 26pt 转圈 + 两行说明;**遮罩必须是 pane 的最后一个子节点**(pane 为 `position: relative`)。
**最小化回来的卡**:置顶并标「最小化中」,转场是从终端缩回该卡的动画。
### 4) 新建分屏 — `3b 新建分屏菜单`
**不问方向**(竖分 `⌘D` / 横分 `⌘⇧D` 本来就是两个入口)。菜单只问「分到哪里」:
- 分组「tmux 会话」:当前会话(带「当前」标签)、其余 detached 会话(title · n windows · 上次活跃)、「新建 tmux 会话…」
- 分隔线
- 「原生窗口 / 不经 tmux · 关 App 即结束」
选完**立即分屏**。菜单 360pt 宽,圆角 14,`#1b1d2b` + 描边 `#595d6c`;行高 ~40pt,前导列统一 20pt(状态点也要包在 20pt 居中列里,保证五行文字左缘一致 x=50)。
**模型约束**:分屏只发生在**同一台主机内**。卡片内部的分割 = 真 tmux pane(`split-window`,服务端持有,断线原样恢复);跨主机分屏**已取消**(层级过深),要同时看两台机器就切会话或 Pin 侧边栏。
### 5) 关闭 / 最小化 — `3c 关闭确认`
标题胶囊左侧 macOS 红黄圆点:
- **黄点(最小化)**:**不确认**,直接回首页,会话继续跑;卡片置顶标「最小化中」。手势:终端下滑。快捷键 `⌘M`。
- **红点(关闭)**:**弹一次确认**,按主机能力分两套文案:
- tmux 会话 → 主按钮「仅断开 ⏎」(`detach`,服务端继续跑);左侧次要文字按钮「结束会话」(`kill-session`,需再确认)
- 原生窗口 → 琥珀警告「没有 tmux 兜底,前台进程会随之结束」;推荐「取消」,危险按钮「关闭窗口」(描边 `#8d5560`,底 `rgba(92,58,63,.35)`,字 `#f5a0ac`)
- 快捷键 `⌘W`;`esc` 取消;`⏎` 选推荐项
对话框 436pt / 404pt 宽,圆角 15。
### 6) 首页 ⇄ 终端 跳转关系 — `3d 页面跳转关系`
**进入终端(2 条)**:点活动会话卡 / 会话行 → 恢复到**上一个焦点 pane**;新建连接或主机 + → 建好即进终端,侧边栏同步出现该会话。
**离开终端(3 条)**:侧边栏首页行(`⌘⇧H`,会话完全不动)· 黄点最小化(同上但语义是"待会儿还回来")· 红点关闭(二次确认,tmux detach / 原生窗口结束)。
换会话不必回首页——侧边栏直接切;回首页表示"换任务"。会话总览层(原 `⌘O` 缩放)**已取消**。
### 7) 无 tmux 主机 — `2c 无 tmux 主机`
轨道下半段的 `TMUX` 段换成「窗口」段(客户端窗口,每个 = 一条独立 SSH channel / mosh 会话,可随意新建)。状态胶囊第三格写「无 tmux · 客户端窗口」而非报错。终端顶部一条可关闭的行内横幅:说明窗口由 App 维持、mosh 兜底,但**关掉 App 不会在服务端继续跑**,右侧给「安装并启用 tmux」次要按钮。不弹窗、不强推。
### 8) 竖屏与 iPhone — `2d iPad 竖屏`、`2d iPhone 竖屏`、`1h iPhone-终端`、`1h iPhone-主机`
- **iPad 竖屏(1024×1366)**:轨道降到 48pt(元素中心 24pt,同样含搜索/首页/展开键);pane 由左右平铺改为**上下堆叠,最多两层**,多余 pane 折成顶部段控;状态胶囊压缩为「主机名 + RTT」两枚;底部常驻单行键盘附加行。
- **iPhone(393×852)**:轨道下沉为**底部主机条**(ms / u2 / nh 胶囊);单 pane 全屏,顶部 3 段进度式 pane 指示 +「左右轻扫切 pane」;下拉标题打开会话列表;键盘附加行两行(esc/ctrl/tab/~///| 与 ↑↓←→/⌘K);主机页用底部 tab(主机 / 会话 / 密钥 / 设置)。
### 9) 设置 · 外观 — `1g 设置-外观`
左 280pt 类别栏(外观与主题 / 终端与字体 / Tailscale / 密钥与安全 / mosh 与重连 / tmux 集成 / 键盘与手势 / 诊断日志,脚注版本行),右内容区 padding 28/40。内容:标题 + 说明 → 4 张 Catppuccin 主题卡(88pt 预览 + 5 色点 + 名称,选中描边 `#796cbf` + `check-circle`)→ 两栏(左「实时预览」终端框,右「排版」面板:字体、字号 13.5pt、行高 1.50、连字、CJK 等宽补偿、跟随系统深浅色)。
---
## Interactions & Behavior
| 交互 | 行为 |
| --- | --- |
| 侧边栏开合 | `⌘\` 或开合键;遮罩态点终端收起;Pin 切换推挤/浮层 |
| 切会话 | 轨道主机头像 / 侧边栏会话行 / `⌘1…9`;恢复到该会话上一个焦点 pane |
| 切 window | 轨道数字 / 侧边栏 window 行 / `⌃⇥` |
| 分屏 | `⌘D` 竖分、`⌘⇧D` 横分 → 弹会话选择菜单 → 立即分屏 |
| 新建 | `⌘T` 新 tmux window;`⌘⌥T` 原生窗口 |
| 调整 pane 比例 | 拖 12pt 分隔条(把手 44×3pt);松手才向 tmux 发 `resize-pane` |
| 回首页 | `⌘⇧H` / 侧边栏首页行 |
| 最小化 | 黄点 / `⌘M` / 终端下滑;无确认 |
| 关闭 | 红点 / `⌘W`;二次确认,`⏎` = 推荐项,`esc` 取消 |
| 命令面板 | `⌘K`(模糊匹配主机、会话、动作) |
| 硬件键盘 | 检测到即收起附加行,只留 34pt 提示条;拔掉恢复附加行 |
| 长按 `⌘` | 显示全部快捷键面板(iPadOS 系统行为) |
**动效**:状态切换 200–260ms `easeOut`;侧边栏浮层从左滑入 + 遮罩淡入;最小化 = 终端缩回首页卡片(matched geometry);重连转圈 1s 线性循环。
**状态徽标规则(分层,只染出问题的那层)**:
1. `tsnet` — 网络可达;断则本格转灰/琥珀
2. `mosh <RTT>` — 数字实时跳动;`≤120ms` 绿 `#a6e3a1`,`120–200ms` 中性,`>200ms` 琥珀 `#fab387`
3. `tmux <会话名>` / 「无 tmux · 客户端窗口」
**重连**:指数退避,上限 30s;卡片/胶囊显示「重连中 · 第 n 次 · ms 后」;屏幕内容本地缓存,恢复后原样接回(目标 <3s)。
**安全**:host key TOFU 固定在钥匙串;不匹配时**拦截连接**并告警;私钥存 Secure Enclave(不可导出),密码回退默认关闭。
## State Management
```
AppRoute = .home | .terminal(sessionID)
SidebarState = .collapsed | .overlay | .pinned
Session { id, host, kind: .tmux(name) | .nativeWindow, windows[], focusedPaneID,
transport: .ssh | .sshOverTsnet | .mosh | .moshOverTsnet,
link: .direct(rtt) | .relay(derp, rtt) | .reconnecting(attempt, nextIn) | .offline,
lastActiveAt, isMinimized }
TmuxWindow { index, title, cwd, panes[] } // title 为 shell 默认值时 UI 回退显示 cwd
Host { id, name, group, user, addr, port, os, tsnetIP, hasTmux, hostKeyPinnedAt }
```
关键转换:`选择会话 → .terminal`;`首页/最小化 → .home`(会话保留);`关闭 → detach 或 kill → .home`;`Pin → .pinned`(触发一次 PTY resize)。
## Files
- `terminalX iPadOS.dc.html` — 全部 13 块屏幕的设计稿(画板,可缩放平移)
- `CLAUDE.md` / `README.md`(源项目文档,若已在仓库中则以仓库版本为准)
## Assets
无位图资产。图标全部来自 Phosphor(实现时换 SF Symbols),字体 JetBrains Mono(Google Fonts),终端配色 Catppuccin。

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""把设计稿画板拆成每屏一个独立 HTML,便于 headless chrome 逐屏截图。"""
import re, os, sys
from html.parser import HTMLParser
# python3 docs/design/render-screens.py && \
# for f in /tmp/tx-screens/*.html; do "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
# --headless=new --window-size=1366,1024 --screenshot="${f%.html}.png" "file://$f"; done
SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "terminalX-iPadOS.dc.html")
OUT = "/tmp/tx-screens"
os.makedirs(OUT, exist_ok=True)
html = open(SRC, encoding="utf-8").read()
# 提取 <style>…</style>(helmet 内的全局样式)
style = "\n".join(re.findall(r"<style>(.*?)</style>", html, re.S))
VOID = {"br", "img", "hr", "input", "meta", "link", "source", "area", "base", "col", "embed", "param", "track", "wbr"}
class Finder(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=False)
self.stack = [] # 当前打开的 tag 名
self.capture = None # (label, depth, start_offset)
self.results = [] # (label, start, end)
def cur_off(self):
line, col = self.getpos()
return self.line_starts[line - 1] + col
def handle_starttag(self, tag, attrs):
if tag in VOID:
return
d = dict(attrs)
if self.capture is None and "data-screen-label" in d:
self.capture = (d["data-screen-label"], len(self.stack), self.cur_off())
self.stack.append(tag)
def handle_startendtag(self, tag, attrs):
pass
def handle_endtag(self, tag):
if tag in VOID:
return
while self.stack and self.stack[-1] != tag:
self.stack.pop()
if self.stack:
self.stack.pop()
if self.capture and len(self.stack) == self.capture[1]:
label, _, start = self.capture
end = self.cur_off() + len(f"</{tag}>")
self.results.append((label, start, end))
self.capture = None
f = Finder()
f.line_starts = [0]
for line in html.splitlines(keepends=True):
f.line_starts.append(f.line_starts[-1] + len(line))
f.feed(html)
HEAD = """<!DOCTYPE html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/style.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap">
<style>%s
html,body{margin:0;padding:0;background:#11121c}</style></head><body>"""
manifest = []
for i, (label, s, e) in enumerate(f.results):
frag = html[s:e]
m = re.search(r"width:(\d+)px;height:(\d+)px", frag)
w, h = (m.group(1), m.group(2)) if m else ("1366", "1024")
slug = re.sub(r"[^\w一-鿿]+", "_", label).strip("_")
path = f"{OUT}/{i:02d}_{slug}.html"
open(path, "w", encoding="utf-8").write(HEAD % style + frag + "</body></html>")
manifest.append((path, w, h, label))
print(f"{i:02d}\t{label}\t{w}x{h}\t{path}")
with open(f"{OUT}/manifest.tsv", "w") as fh:
for p, w, h, l in manifest:
fh.write(f"{p}\t{w}\t{h}\t{l}\n")
print(f"total {len(manifest)}")

1911
docs/design/support.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,102 @@
import Foundation
/// 在**普通 shell**(还没进 control mode)里探测服务器的 tmux 能力与会话列表。
///
/// 为什么需要它:点主机连接时要先让用户挑「attach 哪个会话 / 新建 / 跳过用原生终端」,
/// 而这个选择必须在进 control mode **之前**做出 —— 一旦发了 `tmux -CC new` 就已经建了会话。
///
/// **PTY 回显坑**:命令本身会被 PTY 回显。若标记字面量出现在命令里,回显行会被误当成结果起点。
/// 故命令里把标记写成相邻字符串拼接(`"__TX""_SESSIONS_BEGIN"`):shell 输出完整标记,
/// 回显的却是拼接前的样子,扫描器只认完整标记,天然不会命中回显。
///
/// **为什么要 `tmux -V`**:`tmux list-sessions` 在「没装 tmux」和「装了但没有会话」两种情况下
/// 都只往 stderr 写东西(被 `2>/dev/null` 吞掉),标记之间同样是空的 —— 只靠会话行分不开这两态,
/// 而它们的正确 UI 完全不同(装安装引导 vs 让用户新建会话)。版本行用来定这一刀。
public struct TmuxSessionProbe: Sendable {
public static let beginMark = "__TX_SESSIONS_BEGIN"
public static let endMark = "__TX_SESSIONS_END"
/// 会话行前缀(与版本行区分)。
public static let sessionPrefix = "S"
/// 字段分隔符:与 `TmuxController.fieldSep` 同源理由(tab 经 PTY 会被替换成下划线)。
public static let fieldSep = "|:|"
/// 要发进 shell 的探测命令(自带换行)。用 `printf` 而非 `echo`:跨 shell(fish/bash/zsh)一致。
///
/// **末尾清屏**:命令本身会被 PTY 回显、结果也会打在屏幕上,用户不该看见这些内部细节。
/// 探测发生在刚连上、用户还没输入任何东西时,清掉的只有 motd/shell 欢迎语,换来干净的首屏。
/// 用裸 ANSI(`[H` 归位 + `[2J` 清屏 + `[3J` 清 scrollback)而非 `clear`:不依赖 terminfo。
public static var command: String {
let begin = #"printf '%s\n' "__TX""_SESSIONS_BEGIN""#
let end = #"printf '%s\n' "__TX""_SESSIONS_END""#
let version = "tmux -V 2>/dev/null"
let list = "tmux list-sessions -F '\(sessionPrefix)\(fieldSep)#{session_name}\(fieldSep)#{session_windows}\(fieldSep)#{session_attached}\(fieldSep)#{session_activity}' 2>/dev/null"
let clear = #"printf '\033[H\033[2J\033[3J'"#
return "\(begin); \(version); \(list); \(end); \(clear)\n"
}
/// 一条会话记录(探测阶段拿到的最小信息)。
public struct Session: Equatable, Sendable {
public let name: String
public let windows: Int
public let isAttached: Bool
/// `#{session_activity}`(Unix 秒)→ 选择器副行的「N 分钟前」。
public let lastActivity: Date?
public init(name: String, windows: Int, isAttached: Bool, lastActivity: Date? = nil) {
self.name = name
self.windows = windows
self.isAttached = isAttached
self.lastActivity = lastActivity
}
}
public enum Result: Equatable, Sendable {
/// tmux 可用。`sessions` 可能为空 —— 装了但还没有任何会话,UI 只给「新建 / 跳过」。
case available(version: String, sessions: [Session])
/// 没有 tmux(`tmux -V` 没输出)→ 走原生终端 + 安装引导横幅。
case unavailable
}
private var buffer = ""
private var inBlock = false
private var version: String?
private var sessions: [Session] = []
private var done = false
public init() {}
/// 流式喂入终端输出。命中 END 标记时返回结果;之后恒返回 nil(一次性)。
public mutating func feed(_ text: String) -> Result? {
guard !done else { return nil }
buffer += text
// 按行切:PTY 行尾是 CRLF,而 Swift 视 "\r\n" 为单个 Character,故用 isNewline 判定。
while let idx = buffer.firstIndex(where: { $0.isNewline }) {
let raw = String(buffer[buffer.startIndex ..< idx])
buffer.removeSubrange(buffer.startIndex ... idx)
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !inBlock {
if line == Self.beginMark { inBlock = true }
continue
}
if line == Self.endMark {
done = true
guard let version else { return .unavailable }
return .available(version: version, sessions: sessions)
}
if line.hasPrefix(Self.sessionPrefix + Self.fieldSep) {
if let s = Self.parseSession(line) { sessions.append(s) }
} else if line.hasPrefix("tmux ") {
version = line // 例:tmux 3.7b
}
}
return nil
}
/// `S|:|name|:|windows|:|attached|:|activity` → Session
static func parseSession(_ line: String) -> Session? {
let f = line.components(separatedBy: fieldSep)
guard f.count >= 4, f[0] == sessionPrefix, !f[1].isEmpty, let w = Int(f[2]) else { return nil }
let activity = f.count >= 5 ? Double(f[4]).map { Date(timeIntervalSince1970: $0) } : nil
return Session(name: f[1], windows: w, isAttached: f[3] == "1", lastActivity: activity)
}
}

View File

@@ -0,0 +1,69 @@
import Foundation
import Testing
@testable import TXCore
@Suite("tmux 会话探测(连接前选择用)")
struct TmuxSessionProbeTests {
/// 命令里的标记必须是**拼接写法** —— 否则 PTY 回显会被误当成结果起点。
@Test("命令不含完整标记字面量,避免 PTY 回显误命中")
func commandAvoidsEchoSelfMatch() {
let cmd = TmuxSessionProbe.command
#expect(!cmd.contains(TmuxSessionProbe.beginMark))
#expect(!cmd.contains(TmuxSessionProbe.endMark))
#expect(cmd.contains("tmux -V"))
#expect(cmd.contains("list-sessions"))
}
@Test("解析会话列表(CRLF 行尾 + 版本行)")
func parsesSessions() {
var p = TmuxSessionProbe()
// 先喂一段回显噪声(含拼接前的命令文本),不应触发。
#expect(p.feed("kid@Air ~> printf '%s\\n' \"__TX\"\"_SESSIONS_BEGIN\"\r\n") == nil)
#expect(p.feed("\(TmuxSessionProbe.beginMark)\r\n") == nil)
#expect(p.feed("tmux 3.7b\r\n") == nil)
#expect(p.feed("S|:|main|:|5|:|0|:|1784988802\r\n") == nil)
#expect(p.feed("S|:|demo|:|2|:|1|:|1784901778\r\n") == nil)
let result = p.feed("\(TmuxSessionProbe.endMark)\r\n")
guard case .available(let version, let sessions) = result else {
Issue.record("期望 available,实际 \(String(describing: result))")
return
}
#expect(version == "tmux 3.7b")
#expect(sessions.count == 2)
#expect(sessions[0] == TmuxSessionProbe.Session(
name: "main", windows: 5, isAttached: false,
lastActivity: Date(timeIntervalSince1970: 1784988802)))
#expect(sessions[1].name == "demo")
#expect(sessions[1].isAttached)
// 一次性:命中后不再返回结果。
#expect(p.feed("S|:|late|:|1|:|0|:|1\r\n") == nil)
}
@Test("装了 tmux 但没有会话 → available 且列表为空(UI 只给新建/跳过)")
func availableWithNoSessions() {
var p = TmuxSessionProbe()
_ = p.feed("\(TmuxSessionProbe.beginMark)\n")
_ = p.feed("tmux 3.5a\n")
let r = p.feed("\(TmuxSessionProbe.endMark)\n")
#expect(r == .available(version: "tmux 3.5a", sessions: []))
}
@Test("没装 tmux(无版本行)→ unavailable,直接走原生终端")
func unavailableWithoutVersion() {
var p = TmuxSessionProbe()
_ = p.feed("\(TmuxSessionProbe.beginMark)\n")
let r = p.feed("\(TmuxSessionProbe.endMark)\n")
#expect(r == .unavailable)
}
@Test("畸形会话行被跳过而非崩溃")
func skipsMalformedLines() {
var p = TmuxSessionProbe()
_ = p.feed("\(TmuxSessionProbe.beginMark)\ntmux 3.7\n")
_ = p.feed("S|:|\n") // 字段不足
_ = p.feed("S|:||:|3|:|0\n") // 空名字
_ = p.feed("S|:|ok|:|x|:|0\n") // windows 非数字
let r = p.feed("\(TmuxSessionProbe.endMark)\n")
#expect(r == .available(version: "tmux 3.7", sessions: []))
}
}

View File

@@ -102,6 +102,30 @@ public final class SSHSession: Transport, @unchecked Sendable {
// MARK: - Connect / Handshake
/// 带超时的 TCP connect:临时置非阻塞 → connect → `poll` 等可写 → 校验 SO_ERROR → 恢复阻塞。
/// 不用裸 `Darwin.connect`:那会阻塞到系统 TCP 超时(75s+),UI 上就是长时间「连接中」。
private static func connectWithTimeout(_ fd: Int32, _ addr: UnsafeMutablePointer<sockaddr>,
_ len: socklen_t, seconds: Int) -> Bool {
let flags = fcntl(fd, F_GETFL, 0)
guard flags >= 0, fcntl(fd, F_SETFL, flags | O_NONBLOCK) >= 0 else {
return Darwin.connect(fd, addr, len) == 0 // 拿不到 flags 时退回阻塞语义
}
defer { _ = fcntl(fd, F_SETFL, flags) } // 恢复阻塞(libssh2 用阻塞模式)
if Darwin.connect(fd, addr, len) == 0 { return true }
guard errno == EINPROGRESS else { return false }
var pfd = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0)
let ready = withUnsafeMutablePointer(to: &pfd) { poll($0, 1, Int32(seconds * 1000)) }
guard ready == 1, pfd.revents & Int16(POLLOUT) != 0 else { return false }
// 可写不代表成功:必须查 SO_ERROR(拒绝连接也会让 fd 变可写)。
var soErr: Int32 = 0
var soLen = socklen_t(MemoryLayout<Int32>.size)
guard getsockopt(fd, SOL_SOCKET, SO_ERROR, &soErr, &soLen) == 0, soErr == 0 else { return false }
return true
}
private func connect() throws {
let fd: Int32
if preconnectedFD >= 0 {
@@ -124,12 +148,16 @@ public final class SSHSession: Transport, @unchecked Sendable {
while let addr = node {
dialed = socket(addr.pointee.ai_family, addr.pointee.ai_socktype, addr.pointee.ai_protocol)
if dialed >= 0 {
if Darwin.connect(dialed, addr.pointee.ai_addr, addr.pointee.ai_addrlen) == 0 { break }
if Self.connectWithTimeout(dialed, addr.pointee.ai_addr,
addr.pointee.ai_addrlen,
seconds: config.connectTimeoutSec) { break }
close(dialed); dialed = -1
}
node = addr.pointee.ai_next
}
guard dialed >= 0 else { throw SSHError.socket("连接失败: \(config.host):\(config.port)") }
guard dialed >= 0 else {
throw SSHError.socket("连接失败(\(config.connectTimeoutSec)s 超时): \(config.host):\(config.port)")
}
fd = dialed
}

View File

@@ -35,6 +35,9 @@ public struct SSHConfig: Sendable {
/// (trusted 或 firstUse 已 pin),false=拒绝(mismatch)→ SSHSession 断开并抛 hostKeyMismatch。
/// nil 时不校验(历史行为)。校验/pin 的存储与 UI 由 app 层实现。
public var hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)?
/// TCP connect 超时(秒)。**必须有**:`Darwin.connect` 默认阻塞到系统 TCP 超时(75s+),
/// 连不上的主机会让用户对着「连接中」干等。tsnet 路径(preconnectedFD)不经此处。
public var connectTimeoutSec: Int
public enum Authentication: Sendable {
case password(String)
@@ -51,9 +54,11 @@ public struct SSHConfig: Sendable {
terminalType: String = "xterm-256color",
initialCols: UInt16 = 80,
initialRows: UInt16 = 24,
hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)? = nil
hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)? = nil,
connectTimeoutSec: Int = 12
) {
self.host = host
self.connectTimeoutSec = connectTimeoutSec
self.port = port
self.username = username
self.authentication = authentication

39
scripts/tx-shot.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# 无头截图取证:装 → 启(可带启动参数)→ 截图 → 修正方向。
#
# 本环境没有 GUI,模拟器帧缓冲恒为竖屏;带 -txLandscape 1 时 app 会经
# requestGeometryUpdate 把「内容」转成横屏(设计基准 iPad Pro 13″ 1366×1024),
# 但帧缓冲仍是竖的,故截完用 sips 转回来,Read 出来才是设计稿的方向。
#
# 用法:
# scripts/tx-shot.sh /tmp/shot.png # 只截当前画面
# scripts/tx-shot.sh /tmp/shot.png -- -txUIFixture 1 -txLandscape 1 # 重启并带参数
set -euo pipefail
UDID="${TX_UDID:-2EA9055E-4557-4B20-82AF-56A68AC1D454}"
BUNDLE=ai.athom.terminalx
OUT="${1:?usage: tx-shot.sh <out.png> [-- <launch args>]}"
shift || true
if [ "${1:-}" = "--" ]; then
shift
APP=$(find ~/Library/Developer/Xcode/DerivedData/TerminalX-*/Build/Products/Debug-iphonesimulator \
-maxdepth 1 -name TerminalX.app | head -1)
[ -n "$APP" ] || { echo "TerminalX.app not found — build first" >&2; exit 1; }
xcrun simctl terminate "$UDID" "$BUNDLE" 2>/dev/null || true
xcrun simctl install "$UDID" "$APP"
xcrun simctl launch "$UDID" "$BUNDLE" --args "$@" >/dev/null
# 等 surface attach + OutputGate flush(tmux/多 pane 场景需要更久)
sleep "${TX_SETTLE:-6}"
LANDSCAPE=0
for a in "$@"; do [ "$a" = "-txLandscape" ] && LANDSCAPE=1; done
else
LANDSCAPE="${TX_LANDSCAPE:-1}"
fi
xcrun simctl io "$UDID" screenshot "$OUT" >/dev/null 2>&1
if [ "$LANDSCAPE" = "1" ]; then
sips -r 90 "$OUT" >/dev/null # landscapeRight 内容 → 正向
fi
sips -g pixelWidth -g pixelHeight "$OUT" | tail -2
echo "wrote $OUT"

View File

@@ -112,6 +112,15 @@
}
core.platformSetup = { [weak self] config in
guard let self else { return }
// ghostty 在 `ghostty_surface_new`(本闭包返回后)里把新的 IOSurfaceLayer 追加为
// 本 view.layer 的子层,但旧 surface 被 free 时并不移除其子层。detach/reattach 抖动或
// 配置变更导致的多次 rebuild 会遗留孤儿子层(冻结在旧的小尺寸首帧,因纹理小被底部锚定,
// 表现为屏幕底部一段永不更新的残影)。此刻旧 surface 已 free、新层尚未创建 —— 清掉现存
// 全部子层即只保留 ghostty 即将新建的那一个。本 view 的子层仅由 ghostty 管理,清理安全。
if let stale = self.layer.sublayers, !stale.isEmpty {
TerminalDebugLog.log(.lifecycle, "removing stale sublayers count=\(stale.count)")
for sub in stale { sub.removeFromSuperlayer() }
}
config.platform_tag = GHOSTTY_PLATFORM_IOS
config.platform = ghostty_platform_u(
ios: ghostty_platform_ios_s(
@@ -128,6 +137,13 @@
core.onPostRender = { [weak self] in
self?.enforceSublayerScale()
}
core.onSurfaceFreed = { [weak self] in
// ghostty(iOS) 释放 surface 不摘子层,泄漏的死层冻结在屏底。teardown 后清空遗留子层,
// 后续 createSurface 会重挂新层;纯 teardown(断开/卸载)则清掉残影。本 view 子层仅 ghostty 管理。
guard let sublayers = self?.layer.sublayers, !sublayers.isEmpty else { return }
TerminalDebugLog.log(.lifecycle, "onSurfaceFreed removing sublayers count=\(sublayers.count)")
for sub in sublayers { sub.removeFromSuperlayer() }
}
setupApplicationLifecycleObservers()
syncApplicationActiveState()

View File

@@ -45,6 +45,10 @@ final class TerminalSurfaceCoordinator {
var platformSetup: ((inout ghostty_surface_config_s) -> Void)?
var onMetricsUpdate: (() -> Void)?
var onCellSizeDidChange: (() -> Void)?
/// iOS 专用:surface 释放后回调。ghostty 在 iOS 用 addSublayer 挂 IOSurfaceLayer,但释放时不
/// removeFromSuperlayer(上游缺陷),泄漏的死层冻结在最后一帧、被 CA topLeft gravity 钉在屏底。
/// 平台层借此在每次 teardown 后摘除遗留子层。
var onSurfaceFreed: (() -> Void)?
/// Called after every display-link render (`tick`).
///
@@ -328,6 +332,7 @@ final class TerminalSurfaceCoordinator {
if hadSurface {
(delegate as? any TerminalSurfaceLifecycleDelegate)?
.terminalDidDetachSurface()
onSurfaceFreed?() // iOS:摘除 ghostty 泄漏的死 IOSurfaceLayer 子层
}
}