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>
This commit is contained in:
21
CLAUDE.md
21
CLAUDE.md
@@ -58,15 +58,25 @@ 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 直连(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 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`)
|
- 自定义 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 publickey 认证**:`-txPubkeyAuth 1`(不给 -txPass;SigningKeyProvider 造 P-256 key,日志打 authorized_keys 行,加到服务器后重连免密登录)
|
||||||
- **M4 host key mismatch 测试**:`-txHostKeyPinOverrideBase64 <b64>`(播种假 pin → 真 host key 不符 → 弹告警)
|
- **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|pin`。比坐标点击稳(不依赖 idb companion,不随布局失效)。模态状态放在 `AppRouter.modal` 而非视图 `@State`,快捷键与 URL 才能共用同一入口。
|
||||||
|
- **对拍走查法(还原度靠这个,别靠目测)**:截图 → 用 python+PIL 把设计稿 PNG 与截图统一缩放到 1366×1024 → 裁同区域并排 → 再对元素列做垂直投影扫描量化 y 坐标。缩略图里 `#1E1E2E` 与 `#11121C` 目测分不出,必须采样像素。
|
||||||
|
- **坑:`statusBarHidden` 不能挂在 `TerminalScreen`**(它常驻挂载,会连首页一起隐藏)→ 挂 `RootView` 按 `router.route` 判断。
|
||||||
|
- 设计稿本体在 `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 过期,深挂起需真机锁屏补测。**
|
**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/TXCore` — 零依赖纯逻辑:tmux 解析器/layout/`SessionMachine`(会话状态机+退避重连)/`TmuxControlSequence`(DCS 检测)。
|
||||||
- `packages/TXTransport` — `SSHSession`(libssh2, 支持外部 fd)/`MoshSession`(mosh_main 桥)/`Transport` 协议;C shim: `CSSH`+`CSSHCore`(libssh2)、`CMosh`+`MoshCore`(mosh)。
|
- `packages/TXTransport` — `SSHSession`(libssh2, 支持外部 fd)/`MoshSession`(mosh_main 桥)/`Transport` 协议;C shim: `CSSH`+`CSSHCore`(libssh2)、`CMosh`+`MoshCore`(mosh)。
|
||||||
- `apps/TerminalX/iOS` — `SSHTerminalModel`(连接编排+tsnet+tmux 路由+mosh 编排)/`ContentView`(HomeView 主机管理页+TerminalScreen 终端 chrome+设置)/`TmuxController`(tmux 网关)/`TsnetProbe`(M1 自检)/`Theme`(TXPalette+TXThemeManager 设计令牌,颜色封装供主题系统)/`HostStore`(主机列表 JSON 持久化)。UI 对标 Moshi(深蓝黑+终端绿+Catppuccin),见 `docs/HANDOFF.md §10`。
|
- `apps/TerminalX/iOS` — `TerminalSession`(**一实例=一条会话**:连接编排+tsnet+tmux 路由+mosh 编排+viewport 快照)/`SessionManager`(多会话持有+最小化/关闭语义+生命周期广播)/`AppRouter`(AppRoute .home|.terminal(id) + SidebarState 三态)/`ContentView`(RootView+**SessionCanvas 会话常驻挂载**+TerminalScreen 沉浸轨道页+TmuxStage)/`HomeView`(首页会话卡+主机网格)/`RailSidebar`(56pt 轨道+288pt 面板三态)/`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)。
|
- `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`。
|
- `artifacts/` — 预编译 xcframework(**有意提交**,非 gitignore):`CSSHCore`/`TsnetBridge`/`MoshCore`。
|
||||||
|
|
||||||
@@ -79,8 +89,17 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
|
|||||||
- **Swift6 `SendNonSendable` pass 可能崩溃**(`pthread_create`+`Unmanaged` 上下文+C 入口的组合):改用 `Foundation.Thread`+线程内 `pthread_self()` 规避(见 `MoshSession`)。
|
- **Swift6 `SendNonSendable` pass 可能崩溃**(`pthread_create`+`Unmanaged` 上下文+C 入口的组合):改用 `Foundation.Thread`+线程内 `pthread_self()` 规避(见 `MoshSession`)。
|
||||||
- GhosttyKit 的 `InMemoryTerminalSession.receive` 在 surface 未 attach 时**丢弃**写入 → 用 `OutputGate` 缓冲到 `surfaceSize != nil` 再 flush。
|
- GhosttyKit 的 `InMemoryTerminalSession.receive` 在 surface 未 attach 时**丢弃**写入 → 用 `OutputGate` 缓冲到 `surfaceSize != nil` 再 flush。
|
||||||
- tmux `-CC attach` **不推 %window-add** → 必须主动 `list-windows` 枚举。
|
- 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 多 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) 单消费者。
|
- **tmux 网关字节必须单消费者保序**:多个 `Task{@MainActor}` 到主线程顺序无保证,多 pane 高吞吐会打碎 % 协议 → 用 `TmuxByteChannel`(AsyncStream) 单消费者。
|
||||||
|
- **导航不得销毁会话**:终端**不能**走 `fullScreenCover`——① surface 一旦从视图树摘除,ghostty 释放 grid,内容全丢;② matchedGeometry(最小化缩回首页卡)要求两端同层级,独立 presentation 层做不到。故 `SessionCanvas` 把所有会话 ZStack 常驻挂载、只切 opacity + `allowsHitTesting`(沿用 tmux 切 tab 已验证的手法),画布恒全屏 → 切路由不触发 PTY resize。
|
||||||
|
- **首页缩略卡不许再开 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` 为准**。
|
- SourceKit "No such module" 是索引噪声,**以 `swift build`/`xcodebuild` 为准**。
|
||||||
- 密钥/密码**绝不写进仓库**;测试主机凭据由用户即时提供。
|
- 密钥/密码**绝不写进仓库**;测试主机凭据由用户即时提供。
|
||||||
- 提交/推送 git 只在用户明确要求时(main 分支已有 6 次提交,未 push、无 remote)。
|
- 提交/推送 git 只在用户明确要求时(main 分支已有 6 次提交,未 push、无 remote)。
|
||||||
|
|||||||
BIN
apps/TerminalX/Resources/Fonts/JetBrainsMono-Bold.ttf
Normal file
BIN
apps/TerminalX/Resources/Fonts/JetBrainsMono-Bold.ttf
Normal file
Binary file not shown.
BIN
apps/TerminalX/Resources/Fonts/JetBrainsMono-Medium.ttf
Normal file
BIN
apps/TerminalX/Resources/Fonts/JetBrainsMono-Medium.ttf
Normal file
Binary file not shown.
BIN
apps/TerminalX/Resources/Fonts/JetBrainsMono-Regular.ttf
Normal file
BIN
apps/TerminalX/Resources/Fonts/JetBrainsMono-Regular.ttf
Normal file
Binary file not shown.
BIN
apps/TerminalX/Resources/Fonts/JetBrainsMono-SemiBold.ttf
Normal file
BIN
apps/TerminalX/Resources/Fonts/JetBrainsMono-SemiBold.ttf
Normal file
Binary file not shown.
93
apps/TerminalX/Resources/Fonts/OFL.txt
Normal file
93
apps/TerminalX/Resources/Fonts/OFL.txt
Normal 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.
|
||||||
2
apps/TerminalX/Resources/Fonts/README.md
Normal file
2
apps/TerminalX/Resources/Fonts/README.md
Normal 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。
|
||||||
@@ -2,8 +2,16 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<!-- 部分 Info.plist 基底:仅声明 URL scheme(供无头验证前台唤醒 terminalx://resume)。
|
<!-- 部分 Info.plist 基底:URL scheme(供无头验证前台唤醒 terminalx://resume)+ 打包字体。
|
||||||
其余键由 GENERATE_INFOPLIST_FILE=YES 自动生成并合并到本基底之上。 -->
|
其余键由 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>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
97
apps/TerminalX/iOS/AppRouter.swift
Normal file
97
apps/TerminalX/iOS/AppRouter.swift
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
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 面板**盖住**轨道,右侧终端压遮罩;点终端任意处收起。终端不 reflow、不触发 PTY resize。
|
||||||
|
case overlay
|
||||||
|
/// 288pt 钉在左侧:遮罩消失,终端变窄。**只在钉住/解除时各 resize 一次**。
|
||||||
|
case pinned
|
||||||
|
|
||||||
|
var isExpanded: Bool { self != .collapsed }
|
||||||
|
|
||||||
|
/// 该状态下侧边栏占据的布局宽度(overlay 是浮层,不占位)。
|
||||||
|
var layoutWidth: CGFloat {
|
||||||
|
switch self {
|
||||||
|
case .collapsed, .overlay: 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?
|
||||||
|
|
||||||
|
/// 状态切换动效:设计定稿 200–260ms easeOut(侧边栏浮层从左滑入 + 遮罩淡入)。
|
||||||
|
/// 所有侧边栏状态变更都经这里,键盘快捷键与点击拿到同一条曲线。
|
||||||
|
static let sidebarAnimation = Animation.easeOut(duration: 0.24)
|
||||||
|
|
||||||
|
private func animateSidebar(_ change: () -> Void) {
|
||||||
|
withAnimation(Self.sidebarAnimation, change)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进入某会话(点会话卡 / 侧边栏会话行 / 新建连接后)。恢复焦点由会话自身持有。
|
||||||
|
func enter(_ sessionID: UUID) {
|
||||||
|
route = .terminal(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回首页:会话完全不动(⌘⇧H / 侧边栏首页行)。
|
||||||
|
func goHome() {
|
||||||
|
modal = nil
|
||||||
|
route = .home
|
||||||
|
if sidebar == .overlay { animateSidebar { sidebar = .collapsed } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开合侧边栏(⌘\ / 开合键)。折叠 ⇄ 遮罩;Pin 态视作展开,收起回折叠。
|
||||||
|
func toggleSidebar() {
|
||||||
|
animateSidebar {
|
||||||
|
switch sidebar {
|
||||||
|
case .collapsed: sidebar = .overlay
|
||||||
|
case .overlay, .pinned: sidebar = .collapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pin 切换(只在展开态出现)。钉住/解除各触发一次终端 resize。
|
||||||
|
func togglePin() {
|
||||||
|
animateSidebar { sidebar = sidebar == .pinned ? .overlay : .pinned }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开/关闭模态浮层(红点、⌘W、⌘D、⌘⇧D 与无头 URL 共用同一入口)。
|
||||||
|
func present(_ modal: AppModal) { self.modal = modal }
|
||||||
|
func dismissModal() { modal = nil }
|
||||||
|
|
||||||
|
/// 点终端任意处:遮罩态收起(Pin 态不受影响)。
|
||||||
|
func dismissOverlaySidebar() {
|
||||||
|
if sidebar == .overlay { animateSidebar { sidebar = .collapsed } }
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
92
apps/TerminalX/iOS/Credential.swift
Normal file
92
apps/TerminalX/iOS/Credential.swift
Normal 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
|
||||||
|
}
|
||||||
84
apps/TerminalX/iOS/CredentialAuth.swift
Normal file
84
apps/TerminalX/iOS/CredentialAuth.swift
Normal 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 占位:未签名无法签名
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
apps/TerminalX/iOS/CredentialStore.swift
Normal file
78
apps/TerminalX/iOS/CredentialStore.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
632
apps/TerminalX/iOS/HomeView.swift
Normal file
632
apps/TerminalX/iOS/HomeView.swift
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
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, TX.Space.screenGutter)
|
||||||
|
.padding(.bottom, TX.Space.xxl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
HStack(spacing: TX.Space.s) {
|
||||||
|
TXSearchField(text: $search, placeholder: "搜索", shortcut: "⌘K")
|
||||||
|
.frame(width: 220)
|
||||||
|
TXButton(title: "新建连接", emphasized: true) {
|
||||||
|
editing = nil
|
||||||
|
showEditor = true
|
||||||
|
}
|
||||||
|
TXIconButton(icon: "gearshape", label: "设置") { showSettings = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, TX.Space.screenGutter)
|
||||||
|
.padding(.top, TX.Space.l)
|
||||||
|
.padding(.bottom, TX.Space.m)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首页的「正在连接」行:点主机后到进终端之间的反馈(探测 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: - 活动会话卡(3 列等宽)
|
||||||
|
|
||||||
|
/// 已建立的会话。**排除正在连接的**(它们走 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sessionCards: some View {
|
||||||
|
HStack(alignment: .top, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 560)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 全部主机(4 列,按内容高度)
|
||||||
|
|
||||||
|
private var hostGrid: some View {
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: TX.Space.m), count: 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(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
.background(p.surfaceDark)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 13))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 13)
|
||||||
|
.strokeBorder(isFocused ? TXAccent.selectedStroke : p.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.shadow(color: .black.opacity(isFocused ? 0.4 : 0.2), radius: isFocused ? 9 : 5, y: 6)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture(perform: onOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
Button(action: onOpen) {
|
||||||
|
Text(isTroubled ? "立即重试" : "回到会话 →")
|
||||||
|
.font(TX.Font.mono(11.5, .medium))
|
||||||
|
.foregroundStyle(TXAccent.textBright)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.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) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 15, weight: .light))
|
||||||
|
.foregroundStyle(p.text4)
|
||||||
|
.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) {
|
||||||
|
if host.useMosh { TXBadge(text: "mosh") } else { TXBadge(text: "SSH") }
|
||||||
|
if hasTsnet { TXBadge(text: "tsnet") }
|
||||||
|
if let credName { TXBadge(text: credName, emphasized: true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,103 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// 已保存的主机(连接目标)。
|
/// 已保存的主机(连接目标)。认证经 `AuthRef` 引用凭据或内联密码;出口经可选 `tailscaleID`。
|
||||||
/// 注:`password` 暂存本地 JSON 仅供 demo;产品化后应移入 Keychain(未签名时 SecItem 不可用,见 HANDOFF)。
|
|
||||||
struct SavedHost: Identifiable, Codable, Equatable {
|
struct SavedHost: Identifiable, Codable, Equatable {
|
||||||
var id: UUID = UUID()
|
var id: UUID = UUID()
|
||||||
var name: String
|
var name: String
|
||||||
var host: String
|
var host: String
|
||||||
var port: Int = 22
|
var port: Int = 22
|
||||||
var username: String
|
|
||||||
var password: String = ""
|
|
||||||
/// 登录后引导 mosh 承载会话。
|
/// 登录后引导 mosh 承载会话。
|
||||||
var useMosh: Bool = false
|
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?
|
||||||
|
|
||||||
var addressLine: String { "\(username)@\(host):\(port)" }
|
/// 认证引用。
|
||||||
|
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)。凭据不入仓库;此文件只在设备本地。
|
/// 主机列表持久化(Application Support 下 JSON)。凭据不入仓库;此文件只在设备本地。
|
||||||
@@ -45,6 +130,13 @@ final class HostStore: ObservableObject {
|
|||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// 无头 UI 验证:把示意主机塞进内存(**不写盘**,不污染真实 hosts.json)。
|
||||||
|
func injectFixtureHosts(_ hosts: [SavedHost]) {
|
||||||
|
self.hosts = hosts
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private func load() {
|
private func load() {
|
||||||
guard let data = try? Data(contentsOf: url),
|
guard let data = try? Data(contentsOf: url),
|
||||||
let decoded = try? JSONDecoder().decode([SavedHost].self, from: data) else { return }
|
let decoded = try? JSONDecoder().decode([SavedHost].self, from: data) else { return }
|
||||||
|
|||||||
350
apps/TerminalX/iOS/HostsView.swift
Normal file
350
apps/TerminalX/iOS/HostsView.swift
Normal 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(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import TXCore
|
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 存储(文件后端)。
|
/// known_hosts pin 存储(文件后端)。
|
||||||
///
|
///
|
||||||
/// 为何用文件而非 Keychain:本 app 当前未签名(CODE_SIGNING_ALLOWED=NO),无 keychain-access-group
|
/// 为何用文件而非 Keychain:本 app 当前未签名(CODE_SIGNING_ALLOWED=NO),无 keychain-access-group
|
||||||
@@ -47,6 +56,27 @@ final class KeychainKnownHostsStore: KnownHostsStore, @unchecked Sendable {
|
|||||||
persist()
|
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() {
|
private func persist() {
|
||||||
guard let data = try? JSONSerialization.data(withJSONObject: cache) else { return }
|
guard let data = try? JSONSerialization.data(withJSONObject: cache) else { return }
|
||||||
try? data.write(to: url, options: .atomic)
|
try? data.write(to: url, options: .atomic)
|
||||||
|
|||||||
358
apps/TerminalX/iOS/KeychainView.swift
Normal file
358
apps/TerminalX/iOS/KeychainView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
529
apps/TerminalX/iOS/RailSidebar.swift
Normal file
529
apps/TerminalX/iOS/RailSidebar.swift
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// 设计稿 `1b 终端-沉浸轨道` 的左侧 56pt 轨道 + `3a 侧边栏-遮罩态/Pin 态` 的 288pt 面板。
|
||||||
|
//
|
||||||
|
// **开合语义(唯一、不歧义)**:`›` 方块是 App logo,不可点。开合只有一枚按钮,两态方向相反——
|
||||||
|
// 折叠态在轨道底部(»展开),展开态在标题右侧(«收起)。Pin 是独立按钮,只在展开态出现。
|
||||||
|
// 三态元素不增不减,只是展示密度不同。
|
||||||
|
|
||||||
|
/// 折叠轨道(56pt,竖屏 48pt)。元素中心线统一在轨道正中。
|
||||||
|
struct RailView: View {
|
||||||
|
@ObservedObject var manager: SessionManager
|
||||||
|
@ObservedObject var router: AppRouter
|
||||||
|
/// 当前会话(TMUX 段列它的 window)。
|
||||||
|
@ObservedObject var session: TerminalSession
|
||||||
|
let onNewHost: () -> Void
|
||||||
|
let onSettings: () -> Void
|
||||||
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
|
@Environment(\.horizontalSizeClass) private var hSize
|
||||||
|
private var p: TXPalette { theme.palette }
|
||||||
|
|
||||||
|
private var width: CGFloat {
|
||||||
|
hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth
|
||||||
|
}
|
||||||
|
private var avatarSize: CGFloat { hSize == .compact ? 34 : 38 }
|
||||||
|
|
||||||
|
// 纵向节奏取自设计稿 `1b` 的实测坐标(1366×1024 画板扫描):
|
||||||
|
// logo 28..62 · 搜索 75..109 · 首页 118..152 · 分隔 160 · 头像 174/220/266(间距 8)
|
||||||
|
// · 虚线+ 313..351 · 分隔 370 · TMUX 标签 382 · window 数字 408 起(间距 7)
|
||||||
|
// · 底部自下而上:齿轮 985..1019 · 展开键 932..967 · 状态点 914。
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
logo
|
||||||
|
.padding(.top, 28)
|
||||||
|
.padding(.bottom, 13)
|
||||||
|
// 搜索 / 首页 / 设置在设计稿里是**裸图标**(无底无框),只有 logo 与展开键有框。
|
||||||
|
TXIconButton(icon: "magnifyingglass", bare: true, hitSize: 34, label: "搜索") {
|
||||||
|
router.toggleSidebar()
|
||||||
|
}
|
||||||
|
.padding(.bottom, 9)
|
||||||
|
TXIconButton(icon: "house", bare: true, hitSize: 34, label: "首页") { router.goHome() }
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
divider
|
||||||
|
.padding(.bottom, 13)
|
||||||
|
hostAvatars
|
||||||
|
newHostButton
|
||||||
|
divider
|
||||||
|
.padding(.top, 9)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
tmuxSection
|
||||||
|
Spacer(minLength: TX.Space.s)
|
||||||
|
TXStatusDot(color: linkColor, punchOut: p.surfaceDark)
|
||||||
|
.padding(.bottom, 11)
|
||||||
|
// 折叠态的开合键:方向朝右(展开)。设计稿里这一枚**有框**。
|
||||||
|
TXIconButton(icon: "chevron.right.2", hitSize: 34, label: "展开侧边栏") {
|
||||||
|
router.toggleSidebar()
|
||||||
|
}
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
TXIconButton(icon: "gearshape", bare: true, hitSize: 34, label: "设置", action: onSettings)
|
||||||
|
.padding(.bottom, 5)
|
||||||
|
}
|
||||||
|
.frame(width: width)
|
||||||
|
.background(p.surfaceDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App logo(`›`)。**不可点** —— 开合是另一枚按钮的职责。
|
||||||
|
private var logo: some View {
|
||||||
|
Text("›")
|
||||||
|
.font(TX.Font.mono(16, .bold))
|
||||||
|
.foregroundStyle(TXAccent.text)
|
||||||
|
.frame(width: 34, height: 34)
|
||||||
|
.background(p.surface, in: RoundedRectangle(cornerRadius: 11))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border, lineWidth: 1))
|
||||||
|
.accessibilityLabel("terminalX")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var divider: some View {
|
||||||
|
Rectangle()
|
||||||
|
.fill(p.divider)
|
||||||
|
.frame(width: 24, height: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hostAvatars: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(manager.activeSessions) { s in
|
||||||
|
Button {
|
||||||
|
manager.focus(s)
|
||||||
|
router.enter(s.id)
|
||||||
|
} label: {
|
||||||
|
TXInitialsAvatar(initials: s.initials,
|
||||||
|
size: avatarSize,
|
||||||
|
isSelected: router.route.sessionID == s.id,
|
||||||
|
status: RailSidebarStyle.statusColor(s, p),
|
||||||
|
punchOut: p.surfaceDark)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 虚线 + 号:在当前主机之外新开会话。
|
||||||
|
private var newHostButton: some View {
|
||||||
|
Button(action: onNewHost) {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(p.textWeak)
|
||||||
|
.frame(width: avatarSize, height: avatarSize)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 11)
|
||||||
|
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
|
||||||
|
.foregroundStyle(p.border)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.top, 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TMUX 段:标签 + window 数字。无 tmux 的主机换成「窗口」段(客户端窗口)。
|
||||||
|
private var tmuxSection: some View {
|
||||||
|
VStack(spacing: 7) {
|
||||||
|
Text(session.tmuxSummary == nil ? "窗口" : "TMUX")
|
||||||
|
.font(TX.Font.mono(9, .semibold))
|
||||||
|
.tracking(0.9)
|
||||||
|
// 设计稿:轨道的 TMUX 段标签用 accent-500(弱化强调),不是中性灰。
|
||||||
|
.foregroundStyle(TXAccent.mid)
|
||||||
|
.padding(.bottom, 9)
|
||||||
|
if let tmux = session.tmuxController {
|
||||||
|
ForEach(tmux.windows) { win in
|
||||||
|
Button { tmux.selectWindow(win.id) } label: {
|
||||||
|
windowNumber("\(win.index)", isActive: win.id == tmux.activeWindowID)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 还没 attach(或假会话)时按概览里的 window 数占位,保持轨道高度稳定。
|
||||||
|
ForEach(0 ..< max(1, session.tmuxSummary?.windows ?? 1), id: \.self) { i in
|
||||||
|
windowNumber("\(i)", isActive: i == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// window 数字格(34×30,选中态填控件底 + 中等描边)。
|
||||||
|
private func windowNumber(_ text: String, isActive: Bool) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(TX.Font.mono(12, isActive ? .semibold : .regular))
|
||||||
|
.foregroundStyle(isActive ? p.text : p.text4)
|
||||||
|
.frame(width: 34, height: 30)
|
||||||
|
.background(isActive ? p.surface : .clear, in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.strokeBorder(isActive ? p.borderDialog : .clear, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var linkColor: Color { RailSidebarStyle.statusColor(session, p) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 轨道与面板共用的状态色映射。
|
||||||
|
@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: - 288pt 侧边栏面板
|
||||||
|
|
||||||
|
/// 展开态面板(遮罩 / Pin 共用同一套内容)。
|
||||||
|
///
|
||||||
|
/// 行规格:会话行 = 26pt 头像列 + 10pt gap;window 行 = 26×22pt 徽标列 + 10pt gap
|
||||||
|
/// → 两者标题左缘对齐。选中态用 **inset 描边**(`strokeBorder`)而非 border,避免内容位移 1pt。
|
||||||
|
struct SidebarPanel: View {
|
||||||
|
@ObservedObject var manager: SessionManager
|
||||||
|
@ObservedObject var router: AppRouter
|
||||||
|
@ObservedObject var session: TerminalSession
|
||||||
|
@ObservedObject var tailscaleStore: TailscaleStore
|
||||||
|
let onNewHost: () -> Void
|
||||||
|
let onSettings: () -> Void
|
||||||
|
@Binding var search: String
|
||||||
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
|
private var p: TXPalette { theme.palette }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
header
|
||||||
|
TXSearchField(text: $search, placeholder: "搜索主机 · 会话")
|
||||||
|
.padding(.horizontal, TX.Space.m)
|
||||||
|
.padding(.bottom, TX.Space.s)
|
||||||
|
homeRow
|
||||||
|
sessionsSection
|
||||||
|
// 设计:1pt **渐隐**分隔(两端淡出,不是通栏实线)。
|
||||||
|
LinearGradient(colors: [p.divider.opacity(0), p.divider, p.divider.opacity(0)],
|
||||||
|
startPoint: .leading, endPoint: .trailing)
|
||||||
|
.frame(height: 1)
|
||||||
|
.padding(.horizontal, TX.Space.m)
|
||||||
|
.padding(.vertical, TX.Space.s)
|
||||||
|
tmuxSection
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
tailscaleFoot
|
||||||
|
}
|
||||||
|
.frame(width: TX.Layout.sidebarWidth)
|
||||||
|
.background(p.surfaceDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(spacing: TX.Space.s) {
|
||||||
|
Text("›")
|
||||||
|
.font(TX.Font.mono(15, .bold))
|
||||||
|
.foregroundStyle(TXAccent.text)
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
.background(p.surface, in: RoundedRectangle(cornerRadius: 9))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.border, lineWidth: 1))
|
||||||
|
Text("terminalX")
|
||||||
|
.font(TX.Font.mono(14, .medium))
|
||||||
|
.foregroundStyle(p.text)
|
||||||
|
Spacer()
|
||||||
|
// Pin 只在展开态出现;钉住时高亮。
|
||||||
|
TXIconButton(icon: "pin", size: 28, isSelected: router.sidebar == .pinned,
|
||||||
|
label: "钉在左侧") { router.togglePin() }
|
||||||
|
// 展开态的开合键:方向朝左(收起)。
|
||||||
|
TXIconButton(icon: "chevron.left.2", size: 28, label: "收起") { router.toggleSidebar() }
|
||||||
|
}
|
||||||
|
.padding(.horizontal, TX.Space.m)
|
||||||
|
.padding(.top, 11)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var homeRow: some View {
|
||||||
|
Button { router.goHome() } label: {
|
||||||
|
HStack(spacing: TX.Space.s) {
|
||||||
|
Image(systemName: "house")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(p.text2)
|
||||||
|
.frame(width: 26)
|
||||||
|
Text("首页")
|
||||||
|
.font(TX.Font.mono(13, .medium))
|
||||||
|
.foregroundStyle(p.text)
|
||||||
|
Spacer()
|
||||||
|
Text("⌘⇧H")
|
||||||
|
.font(TX.Font.mono(10.5))
|
||||||
|
.foregroundStyle(p.textFaint)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, TX.Space.m)
|
||||||
|
.frame(height: 36)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sessionsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
TXSectionLabel(text: "活动会话", trailing: "\(manager.sessions.count)")
|
||||||
|
.padding(.horizontal, TX.Space.m)
|
||||||
|
.padding(.top, TX.Space.s)
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
if manager.activeSessions.isEmpty {
|
||||||
|
Text("没有在跑的会话")
|
||||||
|
.font(TX.Font.mono(11.5))
|
||||||
|
.foregroundStyle(p.textWeak)
|
||||||
|
.padding(.leading, 53)
|
||||||
|
.frame(height: 32, alignment: .leading)
|
||||||
|
}
|
||||||
|
ForEach(manager.activeSessions) { s in
|
||||||
|
SidebarSessionRow(session: s, isSelected: router.route.sessionID == s.id) {
|
||||||
|
manager.focus(s)
|
||||||
|
router.enter(s.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(action: onNewHost) {
|
||||||
|
Text("连接新主机…")
|
||||||
|
.font(TX.Font.mono(12))
|
||||||
|
.foregroundStyle(p.text4)
|
||||||
|
.padding(.leading, 53) // 与会话行标题左缘对齐
|
||||||
|
.frame(height: 32, alignment: .leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tmuxSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
TXSectionLabel(text: tmuxHeader)
|
||||||
|
.padding(.horizontal, TX.Space.m)
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
if let tmux = session.tmuxController {
|
||||||
|
ForEach(tmux.windows) { win in
|
||||||
|
SidebarWindowRow(window: win,
|
||||||
|
isSelected: win.id == tmux.activeWindowID) {
|
||||||
|
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
|
||||||
|
placeholderWindowRow(
|
||||||
|
index: i,
|
||||||
|
title: i == 0 && !summary.currentTitle.isEmpty ? summary.currentTitle : "window \(i)",
|
||||||
|
detail: i == 0 && summary.panes > 0
|
||||||
|
? "\(summary.panes) pane\(summary.panes == 1 ? "" : "s")" : "",
|
||||||
|
isSelected: i == 0)
|
||||||
|
}
|
||||||
|
newWindowRow {}
|
||||||
|
} else {
|
||||||
|
Text("无 tmux · 客户端窗口")
|
||||||
|
.font(TX.Font.mono(11.5))
|
||||||
|
.foregroundStyle(p.textWeak)
|
||||||
|
.padding(.horizontal, TX.Space.m)
|
||||||
|
.padding(.vertical, TX.Space.xs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 「新建 window ⌘T」行(左缘与 window 行标题对齐 x=53)。
|
||||||
|
private func newWindowRow(_ action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack {
|
||||||
|
Text("新建 window")
|
||||||
|
.font(TX.Font.mono(12))
|
||||||
|
.foregroundStyle(p.text4)
|
||||||
|
Spacer()
|
||||||
|
Text("⌘T")
|
||||||
|
.font(TX.Font.mono(10.5))
|
||||||
|
.foregroundStyle(p.textFaint)
|
||||||
|
}
|
||||||
|
.padding(.leading, 53)
|
||||||
|
.padding(.trailing, TX.Space.m)
|
||||||
|
.frame(height: 32)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// controller 未就绪时的 window 行占位(视觉与 SidebarWindowRow 一致)。
|
||||||
|
private func placeholderWindowRow(index: Int, title: String, detail: String,
|
||||||
|
isSelected: Bool) -> some View {
|
||||||
|
HStack(spacing: TX.Space.s) {
|
||||||
|
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))
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(title)
|
||||||
|
.font(TX.Font.mono(13, isSelected ? .medium : .regular))
|
||||||
|
.foregroundStyle(isSelected ? p.text : p.text2)
|
||||||
|
.lineLimit(1)
|
||||||
|
if !detail.isEmpty {
|
||||||
|
Text(detail)
|
||||||
|
.font(TX.Font.mono(10.5))
|
||||||
|
.foregroundStyle(p.textWeak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if isSelected { TXStatusDot(color: p.online, size: 7) }
|
||||||
|
}
|
||||||
|
.padding(.horizontal, TX.Space.s)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(isSelected ? TXAccent.selectedBg.opacity(0.75) : .clear,
|
||||||
|
in: RoundedRectangle(cornerRadius: TX.Radius.control))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||||||
|
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.padding(.horizontal, TX.Space.s)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 面板脚:tailnet 状态(绿点 + tailnet + 设备 IP)。
|
||||||
|
///
|
||||||
|
/// **与舞台底部提示条同高**(都是 `TX.Layout.footHeight`),故两侧的分割线落在同一条 y 上;
|
||||||
|
/// 分割线用 1pt divider(设计稿如此),不加底色块、不加阴影。
|
||||||
|
private var tailscaleFoot: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Rectangle().fill(p.divider).frame(height: 1)
|
||||||
|
HStack(spacing: TX.Space.s) {
|
||||||
|
TXStatusDot(color: tailscaleStore.connections.isEmpty ? p.offline : p.online)
|
||||||
|
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 {
|
||||||
|
// 设计:tailnet 内主机名 + 本机 tailnet IP(Up 后缓存的 SelfIP)。
|
||||||
|
Text([c.hostname, c.lastSelfIP].compactMap { $0 }.joined(separator: " · "))
|
||||||
|
.font(TX.Font.mono(10.5))
|
||||||
|
.foregroundStyle(p.textWeak)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, TX.Space.m)
|
||||||
|
.frame(height: TX.Layout.footHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 侧边栏会话行:26pt 头像 + 名称·window title + 右侧链路/重连标。
|
||||||
|
struct SidebarSessionRow: View {
|
||||||
|
@ObservedObject var session: TerminalSession
|
||||||
|
let isSelected: Bool
|
||||||
|
let onTap: () -> Void
|
||||||
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
|
private var p: TXPalette { theme.palette }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack(spacing: TX.Space.s) {
|
||||||
|
TXInitialsAvatar(initials: session.initials, size: 26,
|
||||||
|
isSelected: isSelected,
|
||||||
|
status: RailSidebarStyle.statusColor(session, p),
|
||||||
|
punchOut: isSelected ? TXAccent.selectedBg : p.surfaceDark)
|
||||||
|
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(.horizontal, TX.Space.s)
|
||||||
|
.frame(height: 48)
|
||||||
|
.background(isSelected ? TXAccent.selectedBg : .clear,
|
||||||
|
in: RoundedRectangle(cornerRadius: TX.Radius.control))
|
||||||
|
// inset 描边而非 border:选中时内容不位移 1pt。
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||||||
|
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.padding(.horizontal, TX.Space.s)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
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×22pt 编号徽标 + title + cwd/panes。
|
||||||
|
struct SidebarWindowRow: View {
|
||||||
|
@ObservedObject var window: TmuxWindow
|
||||||
|
let isSelected: Bool
|
||||||
|
let onTap: () -> Void
|
||||||
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
|
private var p: TXPalette { theme.palette }
|
||||||
|
|
||||||
|
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) {
|
||||||
|
HStack(spacing: TX.Space.s) {
|
||||||
|
Text("\(window.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))
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(window.title)
|
||||||
|
.font(TX.Font.mono(13, isSelected ? .medium : .regular))
|
||||||
|
.foregroundStyle(isSelected ? p.text : p.text2)
|
||||||
|
.lineLimit(1)
|
||||||
|
// 设计:cwd · n panes(cwd 来自 #{pane_current_path})
|
||||||
|
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(.horizontal, TX.Space.s)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(isSelected ? TXAccent.selectedBg.opacity(0.75) : .clear,
|
||||||
|
in: RoundedRectangle(cornerRadius: TX.Radius.control))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||||||
|
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.padding(.horizontal, TX.Space.s)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,655 +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?
|
|
||||||
/// 终端顶部状态行:当前连接标题(主机名/地址)。
|
|
||||||
@Published var connectionTitle: String = ""
|
|
||||||
/// mosh 是否已接管(传输徽章 SSH/mosh)。
|
|
||||||
@Published var moshEngaged: Bool = false
|
|
||||||
|
|
||||||
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))
|
|
||||||
_ = state.controller.setTheme(.txDefault) // Catppuccin Mocha 终端配色
|
|
||||||
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?
|
|
||||||
|
|
||||||
/// 从主机列表连接(设置 mosh 模式后走标准连接)。
|
|
||||||
func connect(to h: SavedHost) {
|
|
||||||
moshMode = h.useMosh
|
|
||||||
connect(host: h.host, port: h.port, username: h.username, password: h.password)
|
|
||||||
connectionTitle = h.name
|
|
||||||
}
|
|
||||||
|
|
||||||
func connect(host: String, port: Int, username: String, password: String) {
|
|
||||||
connectionTitle = host
|
|
||||||
moshEngaged = false
|
|
||||||
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 }
|
|
||||||
|
|
||||||
/// 切换主题时推送新终端配色到当前 raw surface 与所有 tmux pane(新建面板另经 TerminalTheme.txDefault 读取)。
|
|
||||||
func applyTerminalTheme(_ t: TerminalTheme) {
|
|
||||||
_ = state.controller.setTheme(t)
|
|
||||||
tmuxController?.applyTerminalTheme(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
moshEngaged = true
|
|
||||||
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()
|
|
||||||
moshEngaged = false
|
|
||||||
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) 次)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
193
apps/TerminalX/iOS/SessionManager.swift
Normal file
193
apps/TerminalX/iOS/SessionManager.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
206
apps/TerminalX/iOS/SessionPickerSheet.swift
Normal file
206
apps/TerminalX/iOS/SessionPickerSheet.swift
Normal 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(.plain)
|
||||||
|
.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(.plain)
|
||||||
|
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(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
apps/TerminalX/iOS/SettingsView.swift
Normal file
173
apps/TerminalX/iOS/SettingsView.swift
Normal 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(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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() }
|
||||||
|
}
|
||||||
@@ -43,6 +43,23 @@ final class SigningKeyProvider: SSHSigner, @unchecked Sendable {
|
|||||||
return URL(fileURLWithPath: base).appendingPathComponent("ssh-ecdsa-p256.key")
|
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? {
|
static func loadOrCreate() -> SigningKeyProvider? {
|
||||||
// 1) 软件密钥文件(模拟器持久化,跨启动稳定)。
|
// 1) 软件密钥文件(模拟器持久化,跨启动稳定)。
|
||||||
if let data = try? Data(contentsOf: keyFileURL),
|
if let data = try? Data(contentsOf: keyFileURL),
|
||||||
@@ -65,6 +82,23 @@ final class SigningKeyProvider: SSHSigner, @unchecked Sendable {
|
|||||||
return SigningKeyProvider(privateKey: sw, hardware: false)
|
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? {
|
private static func createSecureEnclaveKey() -> SecKey? {
|
||||||
guard let access = SecAccessControlCreateWithFlags(
|
guard let access = SecAccessControlCreateWithFlags(
|
||||||
nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .privateKeyUsage, nil) else { return nil }
|
nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .privateKeyUsage, nil) else { return nil }
|
||||||
|
|||||||
301
apps/TerminalX/iOS/TXParts.swift
Normal file
301
apps/TerminalX/iOS/TXParts.swift
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// 设计稿反复出现的小件(首页 / 轨道 / 侧边栏共用)。数值取自 docs/design/UI-DESIGN-HANDOFF.md。
|
||||||
|
|
||||||
|
/// 状态点: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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 两字缩写头像(轨道 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, uppercase)。
|
||||||
|
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.uppercased())
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 徽标胶囊(主机卡的 mosh / tsnet / SSH / SE key)。
|
||||||
|
struct TXBadge: View {
|
||||||
|
let text: String
|
||||||
|
var emphasized: Bool = false
|
||||||
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
|
private var p: TXPalette { theme.palette }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(text)
|
||||||
|
.font(TX.Font.mono(11, .medium))
|
||||||
|
.foregroundStyle(emphasized ? TXAccent.text : p.text4)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(emphasized ? TXAccent.selectedBg : p.surface,
|
||||||
|
in: RoundedRectangle(cornerRadius: 5))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 5)
|
||||||
|
.strokeBorder(emphasized ? TXAccent.selectedStroke : .clear, 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 搜索框(32pt 高、圆角 8、控件底)。右侧可挂快捷键提示。
|
||||||
|
struct TXSearchField: View {
|
||||||
|
@Binding var text: String
|
||||||
|
var placeholder: String
|
||||||
|
var shortcut: String?
|
||||||
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
|
private var p: TXPalette { theme.palette }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: TX.Space.xs) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(p.textWeak)
|
||||||
|
TextField(placeholder, text: $text)
|
||||||
|
.font(TX.Font.mono(12))
|
||||||
|
.foregroundStyle(p.text)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
if let shortcut, text.isEmpty {
|
||||||
|
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(p.divider, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 次要按钮(描边款,如「新建连接」「安装并启用 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(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图标按钮。两种形态(按设计稿区分,别混):
|
||||||
|
/// - **有框**(`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())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.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 {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if lines.isEmpty {
|
||||||
|
Text(placeholder)
|
||||||
|
.font(TX.Font.mono(fontSize))
|
||||||
|
.foregroundStyle(p.text4)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .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)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
|
||||||
|
.background(background)
|
||||||
|
.clipped()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启发式着色:`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
|
||||||
|
}
|
||||||
|
}
|
||||||
80
apps/TerminalX/iOS/TailscaleStore.swift
Normal file
80
apps/TerminalX/iOS/TailscaleStore.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
206
apps/TerminalX/iOS/TailscaleView.swift
Normal file
206
apps/TerminalX/iOS/TailscaleView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
406
apps/TerminalX/iOS/TerminalChrome.swift
Normal file
406
apps/TerminalX/iOS/TerminalChrome.swift
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// 设计稿 `1b 终端-沉浸轨道` 的浮起 chrome:左上标题胶囊 + 右上状态胶囊 + 底部提示条。
|
||||||
|
//
|
||||||
|
// 浮起(absolute + blur)而非占一行布局:终端面积最大化是这一屏的唯一目标。
|
||||||
|
// 终端内容容器顶部留 86pt 预留带给胶囊,预留带用 padding 实现(容器 clipped 防向上溢出)。
|
||||||
|
|
||||||
|
/// 浮起胶囊的共用外观:blur 材质 + 半透底 + 常规描边 + 圆角 11。
|
||||||
|
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.88))
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: TX.Radius.capsule))
|
||||||
|
}
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: TX.Radius.capsule)
|
||||||
|
.strokeBorder(p.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.shadow(color: .black.opacity(0.55), radius: 15, y: 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 左上标题胶囊:红/黄圆点 → 竖分隔 → 状态点 → 主机名 → 「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) {
|
||||||
|
windowDot(TXWindowDot.close, "关闭会话", action: onClose)
|
||||||
|
windowDot(TXWindowDot.minimize, "最小化", action: onMinimize)
|
||||||
|
Rectangle().fill(p.border).frame(width: 1, height: 14)
|
||||||
|
.padding(.horizontal, 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」)。首次布局后稳定,故不必 @Published。
|
||||||
|
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)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func windowDot(_ color: Color, _ label: String, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 13, height: 13)
|
||||||
|
.overlay(Circle().strokeBorder(Color.black.opacity(0.25), lineWidth: 0.5))
|
||||||
|
.frame(width: 22, height: 30)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 无 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(.plain)
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
.shadow(color: .black.opacity(0.7), radius: 40, y: 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 底部提示条(34pt):接硬件键盘时只留这条快捷键清单;触控时由键盘附加行替换(UI-5)。
|
||||||
|
struct TerminalHintBar: View {
|
||||||
|
/// 左侧上下文说明(如「Pin 态 · 终端变窄,仅在钉住/解除时 resize 一次」)。
|
||||||
|
let context: String
|
||||||
|
let hints: [(key: String, label: String)]
|
||||||
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
|
private var p: TXPalette { theme.palette }
|
||||||
|
|
||||||
|
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.m)
|
||||||
|
ForEach(hints, id: \.key) { hint in
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Text(hint.key)
|
||||||
|
.font(TX.Font.mono(11.5, .medium))
|
||||||
|
.foregroundStyle(p.text4)
|
||||||
|
Text(hint.label)
|
||||||
|
.font(TX.Font.mono(11.5))
|
||||||
|
.foregroundStyle(p.textWeak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, TX.Space.m)
|
||||||
|
.frame(height: TX.Layout.footHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
279
apps/TerminalX/iOS/TerminalDialogs.swift
Normal file
279
apps/TerminalX/iOS/TerminalDialogs.swift
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// 设计稿 `3c 关闭确认` 与 `3b 新建分屏菜单`。
|
||||||
|
//
|
||||||
|
// 语义分工(务必别混):
|
||||||
|
// - **黄点 / ⌘M / 下滑 = 最小化**:不确认,直接回首页,会话继续跑。
|
||||||
|
// - **红点 / ⌘W = 关闭**:弹一次确认,按主机能力两套文案;`esc` 取消、`⏎` 选推荐项。
|
||||||
|
|
||||||
|
/// 浮层容器:遮罩 + 居中卡片(对话框 / 菜单共用)。
|
||||||
|
struct TXModalScrim<Content: View>: View {
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
@ViewBuilder let content: Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color(hex: 0x0A0B12).opacity(0.42)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture(perform: onDismiss)
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
)
|
||||||
|
.shadow(color: .black.opacity(0.7), radius: 40, y: 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(.plain)
|
||||||
|
}
|
||||||
|
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(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
)
|
||||||
|
.shadow(color: .black.opacity(0.7), radius: 40, y: 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(.plain)
|
||||||
|
.disabled(disabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
1097
apps/TerminalX/iOS/TerminalSession.swift
Normal file
1097
apps/TerminalX/iOS/TerminalSession.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,86 +2,210 @@ import SwiftUI
|
|||||||
import GhosttyTerminal
|
import GhosttyTerminal
|
||||||
import GhosttyTheme
|
import GhosttyTheme
|
||||||
|
|
||||||
/// 统一主题:一套配色同时驱动 **app UI**(下面的颜色角色)和 **终端**(`ghosttyName` → GhosttyTheme)。
|
// MARK: - 设计令牌(对齐 docs/design/UI-DESIGN-HANDOFF.md)
|
||||||
/// 在设置里选主题后 app 与终端一起变色。新增主题 = 再写一个 `static let`,视图零改动。
|
//
|
||||||
struct TXPalette: Equatable, Identifiable {
|
// 三层分工,别混:
|
||||||
|
// 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 id: String
|
||||||
let name: String
|
let name: String
|
||||||
let isDark: Bool
|
let isDark: Bool
|
||||||
/// 对应 GhosttyTheme 目录名,驱动终端配色(与 app 配色取自同一 Catppuccin flavor,视觉统一)。
|
/// 对应 GhosttyTheme 目录名,驱动终端配色。
|
||||||
let ghosttyName: String
|
let ghosttyName: String
|
||||||
|
|
||||||
let bg: Color
|
// 语义色(Catppuccin 命名)
|
||||||
let surface: Color
|
let green: UInt32 // 成功、提示符、在线
|
||||||
let surfaceElevated: Color
|
let red: UInt32 // error
|
||||||
let accent: Color
|
let yellow: UInt32 // warning、git 标记
|
||||||
let accentSoft: Color
|
let blue: UInt32 // 路径、字段
|
||||||
let textPrimary: Color
|
let teal: UInt32 // 勾选、mosh
|
||||||
let textSecondary: Color
|
let peach: UInt32 // 重连中
|
||||||
let textTertiary: Color
|
let overlay: UInt32 // 次要输出、离线
|
||||||
let border: Color
|
/// 终端内容区底色(Catppuccin base)。沉浸态胶囊下的预留带、首页缩略卡都铺这个色,
|
||||||
let borderStrong: Color
|
/// 与 libghostty 实际渲染的 pane 底色一致,视觉才连续。
|
||||||
let running: Color
|
let terminalBg: UInt32
|
||||||
let mosh: Color
|
}
|
||||||
let warning: Color
|
|
||||||
let offline: Color
|
// 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)。找不到回退系统默认。
|
/// 该主题的终端配色(GhosttyTheme → TerminalTheme)。找不到回退系统默认。
|
||||||
var terminalTheme: TerminalTheme {
|
var terminalTheme: TerminalTheme {
|
||||||
GhosttyThemeCatalog.theme(named: ghosttyName)?.toTerminalTheme() ?? .default
|
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 {
|
extension TXPalette {
|
||||||
/// 用一套 Catppuccin flavor 的核心色构造统一 palette(app 与终端同源)。
|
static let catppuccinMocha = TXPalette(
|
||||||
private static func catppuccin(
|
flavor: TXFlavor(
|
||||||
id: String, name: String, ghostty: String, isDark: Bool,
|
id: "catppuccin-mocha", name: "Mocha", isDark: true, ghosttyName: "Catppuccin Mocha",
|
||||||
base: UInt32, surface0: UInt32, surface1: UInt32,
|
green: 0xA6E3A1, red: 0xF38BA8, yellow: 0xF9E2AF, blue: 0x89B4FA,
|
||||||
text: UInt32, subtext0: UInt32, overlay0: UInt32,
|
teal: 0x94E2D5, peach: 0xFAB387, overlay: 0x6C7086, terminalBg: 0x1E1E2E),
|
||||||
green: UInt32, teal: UInt32, yellow: UInt32
|
chrome: .mocha)
|
||||||
) -> TXPalette {
|
|
||||||
TXPalette(
|
|
||||||
id: id, name: name, isDark: isDark, ghosttyName: ghostty,
|
|
||||||
bg: Color(hex: base),
|
|
||||||
surface: Color(hex: surface0),
|
|
||||||
surfaceElevated: Color(hex: surface1),
|
|
||||||
accent: Color(hex: green),
|
|
||||||
accentSoft: Color(hex: green).opacity(0.16),
|
|
||||||
textPrimary: Color(hex: text),
|
|
||||||
textSecondary: Color(hex: subtext0),
|
|
||||||
textTertiary: Color(hex: overlay0),
|
|
||||||
border: (isDark ? Color.white : Color.black).opacity(0.08),
|
|
||||||
borderStrong: (isDark ? Color.white : Color.black).opacity(0.14),
|
|
||||||
running: Color(hex: green),
|
|
||||||
mosh: Color(hex: teal),
|
|
||||||
warning: Color(hex: yellow),
|
|
||||||
offline: Color(hex: overlay0)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static let catppuccinMocha = catppuccin(
|
static let catppuccinMacchiato = TXPalette(
|
||||||
id: "catppuccin-mocha", name: "Catppuccin Mocha", ghostty: "Catppuccin Mocha", isDark: true,
|
flavor: TXFlavor(
|
||||||
base: 0x1E1E2E, surface0: 0x313244, surface1: 0x45475A,
|
id: "catppuccin-macchiato", name: "Macchiato", isDark: true, ghosttyName: "Catppuccin Macchiato",
|
||||||
text: 0xCDD6F4, subtext0: 0xA6ADC8, overlay0: 0x6C7086,
|
green: 0xA6DA95, red: 0xED8796, yellow: 0xEED49F, blue: 0x8AADF4,
|
||||||
green: 0xA6E3A1, teal: 0x94E2D5, yellow: 0xF9E2AF)
|
teal: 0x8BD5CA, peach: 0xF5A97F, overlay: 0x6E738D, terminalBg: 0x24273A),
|
||||||
|
chrome: .macchiato)
|
||||||
|
|
||||||
static let catppuccinMacchiato = catppuccin(
|
static let catppuccinFrappe = TXPalette(
|
||||||
id: "catppuccin-macchiato", name: "Catppuccin Macchiato", ghostty: "Catppuccin Macchiato", isDark: true,
|
flavor: TXFlavor(
|
||||||
base: 0x24273A, surface0: 0x363A4F, surface1: 0x494D64,
|
id: "catppuccin-frappe", name: "Frappé", isDark: true, ghosttyName: "Catppuccin Frappe",
|
||||||
text: 0xCAD3F5, subtext0: 0xA5ADCB, overlay0: 0x6E738D,
|
green: 0xA6D189, red: 0xE78284, yellow: 0xE5C890, blue: 0x8CAAEE,
|
||||||
green: 0xA6DA95, teal: 0x8BD5CA, yellow: 0xEED49F)
|
teal: 0x81C8BE, peach: 0xEF9F76, overlay: 0x737994, terminalBg: 0x303446),
|
||||||
|
chrome: .frappe)
|
||||||
|
|
||||||
static let catppuccinFrappe = catppuccin(
|
static let catppuccinLatte = TXPalette(
|
||||||
id: "catppuccin-frappe", name: "Catppuccin Frappé", ghostty: "Catppuccin Frappe", isDark: true,
|
flavor: TXFlavor(
|
||||||
base: 0x303446, surface0: 0x414559, surface1: 0x51576D,
|
id: "catppuccin-latte", name: "Latte", isDark: false, ghosttyName: "Catppuccin Latte",
|
||||||
text: 0xC6D0F5, subtext0: 0xA5ADCE, overlay0: 0x737994,
|
green: 0x40A02B, red: 0xD20F39, yellow: 0xDF8E1D, blue: 0x1E66F5,
|
||||||
green: 0xA6D189, teal: 0x81C8BE, yellow: 0xE5C890)
|
teal: 0x179299, peach: 0xFE640B, overlay: 0x9CA0B0, terminalBg: 0xEFF1F5),
|
||||||
|
chrome: .latte)
|
||||||
static let catppuccinLatte = catppuccin(
|
|
||||||
id: "catppuccin-latte", name: "Catppuccin Latte", ghostty: "Catppuccin Latte", isDark: false,
|
|
||||||
base: 0xEFF1F5, surface0: 0xCCD0DA, surface1: 0xBCC0CC,
|
|
||||||
text: 0x4C4F69, subtext0: 0x6C6F85, overlay0: 0x9CA0B0,
|
|
||||||
green: 0x40A02B, teal: 0x179299, yellow: 0xDF8E1D)
|
|
||||||
|
|
||||||
/// 可选主题(设置里列出)。默认第一个。
|
/// 可选主题(设置里列出)。默认第一个。
|
||||||
static let all: [TXPalette] = [catppuccinMocha, catppuccinMacchiato, catppuccinFrappe, catppuccinLatte]
|
static let all: [TXPalette] = [catppuccinMocha, catppuccinMacchiato, catppuccinFrappe, catppuccinLatte]
|
||||||
@@ -118,24 +242,91 @@ extension TerminalTheme {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 结构性设计令牌(圆角/间距/字体)——不随主题变。
|
// MARK: - 结构性令牌(不随主题变)
|
||||||
|
|
||||||
|
/// 圆角 / 间距 / 字体 / 布局基准,数值取自设计交付文档。
|
||||||
enum TX {
|
enum TX {
|
||||||
enum Radius {
|
enum Radius {
|
||||||
static let card: CGFloat = 16
|
/// 徽标 / 胶囊小件
|
||||||
static let chip: CGFloat = 10
|
static let badge: CGFloat = 6
|
||||||
static let field: CGFloat = 12
|
/// 控件(输入框、按钮)
|
||||||
|
static let control: CGFloat = 9
|
||||||
|
/// 卡片
|
||||||
|
static let card: CGFloat = 12
|
||||||
|
/// 对话框
|
||||||
|
static let dialog: CGFloat = 15
|
||||||
|
/// 浮起 chrome(标题/状态胶囊)
|
||||||
|
static let capsule: CGFloat = 11
|
||||||
|
|
||||||
|
// 兼容旧名
|
||||||
|
static let chip: CGFloat = 9
|
||||||
|
static let field: CGFloat = 9
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 设计间距阶:2 / 6 / 8 / 10 / 12 / 14 / 16 / 20 / 26 / 34 / 40
|
||||||
enum Space {
|
enum Space {
|
||||||
|
static let hairline: CGFloat = 2
|
||||||
static let xs: CGFloat = 6
|
static let xs: CGFloat = 6
|
||||||
static let s: CGFloat = 10
|
static let s: CGFloat = 10
|
||||||
static let m: CGFloat = 16
|
static let m: CGFloat = 16
|
||||||
static let l: CGFloat = 22
|
static let l: CGFloat = 20
|
||||||
static let xl: CGFloat = 32
|
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 上。
|
||||||
|
static let footHeight: CGFloat = 34
|
||||||
|
}
|
||||||
|
|
||||||
enum Font {
|
enum Font {
|
||||||
|
/// JetBrains Mono(chrome 与终端同源)。字体缺失时 SwiftUI 自动回退系统字体,中文走 PingFang SC。
|
||||||
static func mono(_ size: CGFloat, _ weight: SwiftUI.Font.Weight = .regular) -> SwiftUI.Font {
|
static func mono(_ size: CGFloat, _ weight: SwiftUI.Font.Weight = .regular) -> SwiftUI.Font {
|
||||||
.system(size: size, weight: weight, design: .monospaced)
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ final class TmuxPaneSurface: ObservableObject, Identifiable {
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class TmuxWindow: ObservableObject, Identifiable {
|
final class TmuxWindow: ObservableObject, Identifiable {
|
||||||
let id: TmuxWindowID
|
let id: TmuxWindowID
|
||||||
|
/// tmux `#{window_index}`(用户可见编号,轨道/侧边栏显示这个;`id` 是内部 `@N`)。
|
||||||
|
@Published var index: Int
|
||||||
@Published var title: String
|
@Published var title: String
|
||||||
|
/// 活动 pane 的工作目录(`#{pane_current_path}`)。title 是 shell 默认值时 UI 回退显示它。
|
||||||
|
@Published var cwd: String = ""
|
||||||
@Published var panes: [TmuxPaneID: TmuxPaneSurface] = [:]
|
@Published var panes: [TmuxPaneID: TmuxPaneSurface] = [:]
|
||||||
/// 渲染用:zoom 时只含被 zoom 的 pane。
|
/// 渲染用:zoom 时只含被 zoom 的 pane。
|
||||||
@Published var visibleLayout: TmuxLayout?
|
@Published var visibleLayout: TmuxLayout?
|
||||||
@@ -45,7 +49,19 @@ final class TmuxWindow: ObservableObject, Identifiable {
|
|||||||
var fullLayout: TmuxLayout?
|
var fullLayout: TmuxLayout?
|
||||||
@Published var activePane: TmuxPaneID?
|
@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 映射为分屏。
|
/// tmux control-mode 网关:消费 `%`-协议流,把 window 映射为原生 tab、window 内多 pane 映射为分屏。
|
||||||
@@ -55,6 +71,8 @@ final class TmuxWindow: ObservableObject, Identifiable {
|
|||||||
final class TmuxController: ObservableObject {
|
final class TmuxController: ObservableObject {
|
||||||
@Published private(set) var windows: [TmuxWindow] = []
|
@Published private(set) var windows: [TmuxWindow] = []
|
||||||
@Published var activeWindowID: TmuxWindowID?
|
@Published var activeWindowID: TmuxWindowID?
|
||||||
|
/// 本 tmux server 上的会话(`listSessions()` 填充,分屏菜单用)。
|
||||||
|
@Published private(set) var sessions: [TmuxSessionInfo] = []
|
||||||
|
|
||||||
private let parser = TmuxControlParser()
|
private let parser = TmuxControlParser()
|
||||||
private var windowByID: [TmuxWindowID: TmuxWindow] = [:]
|
private var windowByID: [TmuxWindowID: TmuxWindow] = [:]
|
||||||
@@ -70,7 +88,18 @@ final class TmuxController: ObservableObject {
|
|||||||
|
|
||||||
// control-mode 命令应答 FIFO 匹配。
|
// control-mode 命令应答 FIFO 匹配。
|
||||||
private var attachAcked = false
|
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] = []
|
private var pending: [PendingKind] = []
|
||||||
|
|
||||||
// attach kickoff(refresh-client + list-windows + 各 pane capture)必须等"容器真实几何已知"再发:
|
// attach kickoff(refresh-client + list-windows + 各 pane capture)必须等"容器真实几何已知"再发:
|
||||||
@@ -80,6 +109,11 @@ final class TmuxController: ObservableObject {
|
|||||||
private var containerKnown = false
|
private var containerKnown = false
|
||||||
private var kickoffDone = 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) {
|
init(sendRaw: @escaping @Sendable (Data) -> Void) {
|
||||||
self.sendRaw = sendRaw
|
self.sendRaw = sendRaw
|
||||||
}
|
}
|
||||||
@@ -91,8 +125,14 @@ final class TmuxController: ObservableObject {
|
|||||||
NSLog("TMUXDBG kickoff refresh-client -C \(clientCols)x\(clientRows)")
|
NSLog("TMUXDBG kickoff refresh-client -C \(clientCols)x\(clientRows)")
|
||||||
sendCommand("refresh-client -C \(clientCols)x\(clientRows)") // 此刻已是容器真实格子
|
sendCommand("refresh-client -C \(clientCols)x\(clientRows)") // 此刻已是容器真实格子
|
||||||
pending.append(.ignore)
|
pending.append(.ignore)
|
||||||
sendCommand("list-windows -F \"#{window_id}\t#{window_name}\t#{window_active}\t#{window_layout}\t#{window_visible_layout}\"")
|
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)
|
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 布局)。
|
/// 由 model 在 attach 前告知全屏格子(tmux 据此 refresh-client -C 布局)。
|
||||||
@@ -125,6 +165,10 @@ final class TmuxController: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 本客户端所在的 tmux 会话名(`display-message -p "#{session_name}"` 的权威答案)。
|
||||||
|
/// 拿不到时为空串 —— UI 只显示「tmux」而不编造名字。
|
||||||
|
@Published private(set) var currentSessionName = ""
|
||||||
|
|
||||||
var activeWindow: TmuxWindow? {
|
var activeWindow: TmuxWindow? {
|
||||||
guard let id = activeWindowID else { return windows.first }
|
guard let id = activeWindowID else { return windows.first }
|
||||||
return windowByID[id]
|
return windowByID[id]
|
||||||
@@ -231,18 +275,45 @@ final class TmuxController: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard !pending.isEmpty else { return }
|
guard !pending.isEmpty else { return }
|
||||||
switch pending.removeFirst() {
|
let kind = pending.removeFirst()
|
||||||
|
switch kind {
|
||||||
case .ignore:
|
case .ignore:
|
||||||
break
|
break
|
||||||
case .listWindows:
|
case .listWindows:
|
||||||
for line in r.lines {
|
for line in r.lines {
|
||||||
let parts = line.split(separator: "\t", omittingEmptySubsequences: false)
|
let parts = line.components(separatedBy: Self.fieldSep)
|
||||||
guard parts.count >= 5, let w = TmuxWindowID.parse(parts[0]) else { continue }
|
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)
|
let win = ensureWindow(w)
|
||||||
win.title = String(parts[1])
|
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 }
|
if parts[2] == "1" { activeWindowID = w }
|
||||||
applyLayout(window: w, full: String(parts[3]), visible: String(parts[4]))
|
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):
|
case .capturePane(let p):
|
||||||
// 剔除尾部空行:capture-pane 常返回补满 pane 高度的尾随空行,若行数 > pane grid 行数,
|
// 剔除尾部空行:capture-pane 常返回补满 pane 高度的尾随空行,若行数 > pane grid 行数,
|
||||||
// 铺快照(ESC[H + N-1 个 \r\n)会溢出上滚、把顶部内容顶出屏幕(提示符被拆/内容钉底)。
|
// 铺快照(ESC[H + N-1 个 \r\n)会溢出上滚、把顶部内容顶出屏幕(提示符被拆/内容钉底)。
|
||||||
@@ -263,7 +334,8 @@ final class TmuxController: ObservableObject {
|
|||||||
@discardableResult
|
@discardableResult
|
||||||
private func ensureWindow(_ w: TmuxWindowID) -> TmuxWindow {
|
private func ensureWindow(_ w: TmuxWindowID) -> TmuxWindow {
|
||||||
if let existing = windowByID[w] { return existing }
|
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
|
windowByID[w] = win
|
||||||
windows.append(win)
|
windows.append(win)
|
||||||
windows.sort { $0.id.raw < $1.id.raw }
|
windows.sort { $0.id.raw < $1.id.raw }
|
||||||
@@ -307,6 +379,47 @@ final class TmuxController: ObservableObject {
|
|||||||
func newWindow() { sendCommand("new-window") }
|
func newWindow() { sendCommand("new-window") }
|
||||||
func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") }
|
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 就绪 → 放行其缓冲输出。
|
/// 某 pane 的 surface 就绪 → 放行其缓冲输出。
|
||||||
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }
|
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }
|
||||||
|
|
||||||
@@ -320,13 +433,13 @@ final class TmuxController: ObservableObject {
|
|||||||
/// 在传输后台线程调用;原始字节走线程安全的 rawGate,网关字节经闭包 hop 到主线程。
|
/// 在传输后台线程调用;原始字节走线程安全的 rawGate,网关字节经闭包 hop 到主线程。
|
||||||
final class TmuxRouter: @unchecked Sendable {
|
final class TmuxRouter: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private let rawGate: OutputGate
|
private let rawGate: any RawOutputSink
|
||||||
private let enterGateway: @Sendable ([UInt8]) -> Void
|
private let enterGateway: @Sendable ([UInt8]) -> Void
|
||||||
private let gatewayBytes: @Sendable ([UInt8]) -> Void
|
private let gatewayBytes: @Sendable ([UInt8]) -> Void
|
||||||
private var carry: [UInt8] = []
|
private var carry: [UInt8] = []
|
||||||
private var inGateway = false
|
private var inGateway = false
|
||||||
|
|
||||||
init(rawGate: OutputGate,
|
init(rawGate: any RawOutputSink,
|
||||||
enterGateway: @escaping @Sendable ([UInt8]) -> Void,
|
enterGateway: @escaping @Sendable ([UInt8]) -> Void,
|
||||||
gatewayBytes: @escaping @Sendable ([UInt8]) -> Void) {
|
gatewayBytes: @escaping @Sendable ([UInt8]) -> Void) {
|
||||||
self.rawGate = rawGate
|
self.rawGate = rawGate
|
||||||
|
|||||||
103
apps/TerminalX/iOS/TsnetRegistry.swift
Normal file
103
apps/TerminalX/iOS/TsnetRegistry.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
apps/TerminalX/iOS/UIComponents.swift
Normal file
56
apps/TerminalX/iOS/UIComponents.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
267
apps/TerminalX/iOS/UIPreviewFixture.swift
Normal file
267
apps/TerminalX/iOS/UIPreviewFixture.swift
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
#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":
|
||||||
|
if path.dropFirst().first == "pin" { router.togglePin() } else { 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
|
||||||
@@ -21,6 +21,13 @@ targets:
|
|||||||
deploymentTarget: "17.0"
|
deploymentTarget: "17.0"
|
||||||
sources:
|
sources:
|
||||||
- path: iOS
|
- 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:
|
dependencies:
|
||||||
- package: GhosttyKit
|
- package: GhosttyKit
|
||||||
product: GhosttyTerminal
|
product: GhosttyTerminal
|
||||||
@@ -45,5 +52,7 @@ targets:
|
|||||||
TARGETED_DEVICE_FAMILY: "1,2"
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
# M0 渲染自检无需签名(仅模拟器);真机/上架在 M4/M5 配置。
|
# 真机自动签名(公司团队 Nanjing ShenRuan)。tsnet 用户态无需 NE entitlement。
|
||||||
CODE_SIGNING_ALLOWED: NO
|
# 签名后 Keychain(SecItem)/Secure Enclave 可用(未签名会 -34018 回退文件)。
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
DEVELOPMENT_TEAM: J75A6S5938
|
||||||
|
|||||||
173
docs/HANDOFF.md
173
docs/HANDOFF.md
@@ -115,4 +115,177 @@
|
|||||||
### 10.3 待续(UI)
|
### 10.3 待续(UI)
|
||||||
- 会话缩略卡区(Moshi 首屏实时终端预览横滑,需会话管理);设置页字体/工具栏配置;导航壳打磨;终端工具栏 accent tint(`inputAccessoryStyle` SwiftUI 未暴露需桥接)。
|
- 会话缩略卡区(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)。
|
- 无头 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.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>
|
</content>
|
||||||
|
|||||||
241
docs/design/UI-DESIGN-HANDOFF.md
Normal file
241
docs/design/UI-DESIGN-HANDOFF.md
Normal 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。
|
||||||
80
docs/design/render-screens.py
Normal file
80
docs/design/render-screens.py
Normal 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
1911
docs/design/support.js
Normal file
File diff suppressed because it is too large
Load Diff
810
docs/design/terminalX-iPadOS.dc.html
Normal file
810
docs/design/terminalX-iPadOS.dc.html
Normal file
File diff suppressed because one or more lines are too long
102
packages/TXCore/Sources/TXCore/Tmux/TmuxSessionProbe.swift
Normal file
102
packages/TXCore/Sources/TXCore/Tmux/TmuxSessionProbe.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: []))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,6 +102,30 @@ public final class SSHSession: Transport, @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: - Connect / Handshake
|
// 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 {
|
private func connect() throws {
|
||||||
let fd: Int32
|
let fd: Int32
|
||||||
if preconnectedFD >= 0 {
|
if preconnectedFD >= 0 {
|
||||||
@@ -124,12 +148,16 @@ public final class SSHSession: Transport, @unchecked Sendable {
|
|||||||
while let addr = node {
|
while let addr = node {
|
||||||
dialed = socket(addr.pointee.ai_family, addr.pointee.ai_socktype, addr.pointee.ai_protocol)
|
dialed = socket(addr.pointee.ai_family, addr.pointee.ai_socktype, addr.pointee.ai_protocol)
|
||||||
if dialed >= 0 {
|
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
|
close(dialed); dialed = -1
|
||||||
}
|
}
|
||||||
node = addr.pointee.ai_next
|
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
|
fd = dialed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ public struct SSHConfig: Sendable {
|
|||||||
/// (trusted 或 firstUse 已 pin),false=拒绝(mismatch)→ SSHSession 断开并抛 hostKeyMismatch。
|
/// (trusted 或 firstUse 已 pin),false=拒绝(mismatch)→ SSHSession 断开并抛 hostKeyMismatch。
|
||||||
/// nil 时不校验(历史行为)。校验/pin 的存储与 UI 由 app 层实现。
|
/// nil 时不校验(历史行为)。校验/pin 的存储与 UI 由 app 层实现。
|
||||||
public var hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)?
|
public var hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)?
|
||||||
|
/// TCP connect 超时(秒)。**必须有**:`Darwin.connect` 默认阻塞到系统 TCP 超时(75s+),
|
||||||
|
/// 连不上的主机会让用户对着「连接中」干等。tsnet 路径(preconnectedFD)不经此处。
|
||||||
|
public var connectTimeoutSec: Int
|
||||||
|
|
||||||
public enum Authentication: Sendable {
|
public enum Authentication: Sendable {
|
||||||
case password(String)
|
case password(String)
|
||||||
@@ -51,9 +54,11 @@ public struct SSHConfig: Sendable {
|
|||||||
terminalType: String = "xterm-256color",
|
terminalType: String = "xterm-256color",
|
||||||
initialCols: UInt16 = 80,
|
initialCols: UInt16 = 80,
|
||||||
initialRows: UInt16 = 24,
|
initialRows: UInt16 = 24,
|
||||||
hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)? = nil
|
hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)? = nil,
|
||||||
|
connectTimeoutSec: Int = 12
|
||||||
) {
|
) {
|
||||||
self.host = host
|
self.host = host
|
||||||
|
self.connectTimeoutSec = connectTimeoutSec
|
||||||
self.port = port
|
self.port = port
|
||||||
self.username = username
|
self.username = username
|
||||||
self.authentication = authentication
|
self.authentication = authentication
|
||||||
|
|||||||
39
scripts/tx-shot.sh
Executable file
39
scripts/tx-shot.sh
Executable 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"
|
||||||
Reference in New Issue
Block a user