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:
kid
2026-07-25 22:59:35 +08:00
parent 6dd23275ff
commit 28e9cfc207
46 changed files with 10237 additions and 1204 deletions

View File

@@ -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 over tsnet**:额外加 `-txTsnetKey <authkey>`-txHost 填 tailnet IPrelay=loopback↔tsnet UDP
- 自定义 mosh-server 命令:`-txMoshServerCmd "…"`(默认 `mosh-server new -s -c 256 -l LANG=en_US.UTF-8 -l LC_ALL=en_US.UTF-8`
- **tmux 自动进入**:默认开(`SavedHost.useTmux`)。`-txTmux 0` 关闭(回归 raw 终端路径)、`-txTmuxSession <名>` 指定会话名(默认 `main`)。
- **M4 publickey 认证**`-txPubkeyAuth 1`(不给 -txPassSigningKeyProvider 造 P-256 key日志打 authorized_keys 行,加到服务器后重连免密登录)
- **M4 host key mismatch 测试**`-txHostKeyPinOverrideBase64 <b64>`(播种假 pin → 真 host key 不符 → 弹告警)
**UI 改造期无头验证(推荐走脚本)**`scripts/tx-shot.sh <out.png> [-- <启动参数>]` 一步完成装/启/截图/修正方向。
- **`-txLandscape 1`**app 侧 `requestGeometryUpdate` 转横屏(设计基准 iPad Pro 13″ 1366×1024。无 GUI 时模拟器帧缓冲恒竖屏,故脚本截完 `sips -r 90` 转回来(内容已横向渲染)。
- **`-txUIFixture 1`**:造 3 条**假会话**mac-studio/ubuntu-cn2/nas-home含 direct/relay/重连三种状态)灌示意输出进 in-memory 终端,**不建任何传输连接**。设计稿的首页会话卡/轨道多头像/状态徽标都要多会话才有内容,用真机连接验证太慢。见 `UIPreviewFixture.swift`DEBUG only`-txHost` 互斥。
- **`terminalx://ui/<动作>`**`xcrun simctl openurl`):无头驱动导航与浮层——`home` / `minimize` / `close`(弹确认) / `close-now`(直接关) / `split/v|h`(分屏菜单) / `dismiss` / `session/<n>` / `sidebar/toggle|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 过期,深挂起需真机锁屏补测。**
## 仓库结构
- `packages/TXCore` — 零依赖纯逻辑tmux 解析器/layout/`SessionMachine`(会话状态机+退避重连)/`TmuxControlSequence`(DCS 检测)。
- `packages/TXTransport``SSHSession`(libssh2, 支持外部 fd)/`MoshSession`(mosh_main 桥)/`Transport` 协议C shim: `CSSH`+`CSSHCore`(libssh2)、`CMosh`+`MoshCore`(mosh)。
- `apps/TerminalX/iOS``SSHTerminalModel`(连接编排+tsnet+tmux 路由+mosh 编排)/`ContentView`(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)。
- `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`)。
- GhosttyKit 的 `InMemoryTerminalSession.receive` 在 surface 未 attach 时**丢弃**写入 → 用 `OutputGate` 缓冲到 `surfaceSize != nil` 再 flush。
- tmux `-CC attach` **不推 %window-add** → 必须主动 `list-windows` 枚举。
- **tmux format 的字段分隔符不能用 tab**:真机实测 tab 经 PTY 送到 tmux 后**变成下划线**`list-windows`/`list-sessions` 整行分不开(`count=1`)→ title/cwd/会话名全丢、UI 显示兜底名「窗口 N」。改用 `TmuxController.fieldSep = "|:|"`(可打印、不被 PTY 处理、`#` 会被 tmux format 当特殊字符所以不能用)。**排查提醒**`os_log` 会把控制字符显示成 `_`,光看日志分不出「真下划线」还是「被显示替换的 tab」——要打印 `parts.count` 才能确诊。
- **tmux 当前会话名要用 `display-message -p "#{session_name}"`**,不能拿 `list-sessions``session_attached` 反推:服务器上别的客户端 attach 着其它会话时会猜错(真机把用户自己的 `demo` 当成了当前会话)。
- **轨道/侧边栏显示 `#{window_index}` 而非 window_id**`@2` 是内部 id用户看到的编号是 index0 起,删过 window 后两者会差很远)。
- **`TerminalSession` 必须转发 `TmuxController.objectWillChange`**`tmuxSummary` 等是读 controller 的 computed property而轨道/胶囊/侧边栏只 `@ObservedObject` 订阅 session。没有这条转发数据解析全对但 UI 不刷新真机表现为轨道空、第三格只有「tmux」。同一个坑的另一面见下条。
- **连接流程 = 点主机 → 留在首页探测 → 首页弹选择器 → 选完才进终端**(用户定稿,别改回"自动 attach"或"先进终端再弹框"SSH 连上 → 在**普通 shell** 里跑 `TmuxSessionProbe.command` → 选择器给三种连接方式(接回已有会话 / 新建会话 / **原生终端**,不用"跳过"这种模糊措辞)。`SessionManager.enterRequest` 决定何时进终端;`isProbingTmux` **必须在 `.connected` 当刻置位**,否则连接建立与探测开始(延迟 2.5s)之间的空窗会让 `evaluateEnter` 提前把人送进终端页。**没装 tmux 时不弹框**,直接原生终端 + 安装引导横幅。探测的三个坑:① 命令会被 PTY **回显**,标记必须写成相邻字符串拼接(`"__TX""_SESSIONS_BEGIN"`)否则回显行被当成结果起点;② 「没装 tmux」与「装了但没会话」的 stderr 都被 `2>/dev/null` 吞掉、标记之间同样空,只能靠 `tmux -V` 的版本行分开(两者 UI 完全不同);③ 探测命令与输出会脏屏 → 命令末尾接 `printf '\033[H\033[2J\033[3J'` 清屏(刚连上时清掉的只有 motd
- **tmux 不会自己进**`TmuxRouter` 只被动检测字节流里的 DCS 前导,**必须由 app 主动发 `tmux -CC new -A -s <名>`**(登录后 2.5s 等提示符就绪,沿用 mosh 引导已验证的延迟)。`-A` = 同名会话存在就接回。4s 内没进 control mode 判定无 tmux → 降级客户端窗口 + 行内安装引导横幅(设计 2c**不弹窗不强推**。曾经漏了这一步 → 真机连上永远显示「无 tmux」。
- **tmux 多 pane 尺寸走"路线 b"**app 按全屏格子发 `refresh-client -C WxH` 让 tmux 布局pane surface 尺寸严格取 layout rect%output 按 tmux pane 宽高排版,二者不一致必换行/清屏错乱pane surface 的 resize 回调**只断言不反向驱动 tmux**防反馈环。diff 用 full layout、渲染用 visible layoutzoom 免费SwiftUI identity 只用 paneID布局变不重建 surface。新 pane 在 `capture-pane` %end 前丢弃其 %outputtmux 单线程串行保证快照不缺不重)。
- **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` 为准**。
- 密钥/密码**绝不写进仓库**;测试主机凭据由用户即时提供。
- 提交/推送 git 只在用户明确要求时main 分支已有 6 次提交,未 push、无 remote

Binary file not shown.

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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?
/// 稿 200260ms 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 }
}
/// /WDD 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

View File

@@ -0,0 +1,92 @@
import Foundation
/// Keychain ****
/// //passphrase `CredentialStore.secret(for:)`
/// UI meta Keychain
struct Credential: Identifiable, Codable, Equatable {
var id: UUID = UUID()
var name: String
var username: String
var kind: Kind
var createdAt: Date = Date()
enum Kind: Codable, Equatable {
/// +
case password
/// SSH `publicKeyBlob` SSH 线/ authorized_keys
case privateKey(keyType: KeyType, source: Source, hasPassphrase: Bool, publicKeyBlob: Data)
/// Secure EnclaveFace ID SE tag UI
case secureEnclave(keyTag: String)
}
///
enum Source: String, Codable { case pasted, imported, generated }
/// libssh2 Ed25519 ECDSA"" P-256
enum KeyType: String, Codable {
case ecdsaP256, ecdsaP384, ecdsaP521, rsa, unknown
var displayName: String {
switch self {
case .ecdsaP256: "ECDSA P-256"
case .ecdsaP384: "ECDSA P-384"
case .ecdsaP521: "ECDSA P-521"
case .rsa: "RSA"
case .unknown: "SSH 私钥"
}
}
}
// MARK: -
var kindLabel: String {
switch kind {
case .password: "密码"
case .privateKey(let t, _, _, _): t.displayName
case .secureEnclave: "Face ID 密钥"
}
}
var iconName: String {
switch kind {
case .password: "key.fill"
case .privateKey: "lock.doc.fill"
case .secureEnclave: "faceid"
}
}
/// SSH 线 blob
var publicKeyBlob: Data? {
if case .privateKey(_, _, _, let blob) = kind { return blob }
return nil
}
/// authorized_keys /
var authorizedKeysLine: String? {
guard let blob = publicKeyBlob, !blob.isEmpty else { return nil }
// ecdsa-sha2-nistp256 keyType
let algo: String
if case .privateKey(let t, _, _, _) = kind {
switch t {
case .ecdsaP256: algo = "ecdsa-sha2-nistp256"
case .ecdsaP384: algo = "ecdsa-sha2-nistp384"
case .ecdsaP521: algo = "ecdsa-sha2-nistp521"
case .rsa: algo = "ssh-rsa"
case .unknown: return nil
}
} else { return nil }
return "\(algo) \(blob.base64EncodedString()) \(username)@terminalx"
}
}
///
enum CredentialSecret: Codable, Equatable {
/// `.password`
case password(String)
/// PEM / PKCS#8 / libssh2 `publickey_frommemory` B
case pemPrivateKey(pem: Data, passphrase: String?)
/// SecKey P-256 SecKey SSHSigner A
case rawECPrivateKey(x963: Data)
/// Secure Enclave
case none
}

View File

@@ -0,0 +1,84 @@
import Foundation
import TXTransport
/// SSH
/// - `.password`
/// - P-256 SecKey `SSHSigner` `.publicKeyCallback` A
/// - / PEM/PKCS#8 `.privateKey`libssh2 `publickey_frommemory` B
///
/// libssh2/mbedTLS****//
enum CredentialAuth {
// MARK: -
/// P-256 CredentialStore (kind, secret)
static func generateECDSAP256() -> (kind: Credential.Kind, secret: CredentialSecret)? {
guard let (provider, x963) = SigningKeyProvider.generateSoftwareP256() else { return nil }
let kind = Credential.Kind.privateKey(
keyType: .ecdsaP256, source: .generated, hasPassphrase: false,
publicKeyBlob: Data(provider.publicKeyBlob))
return (kind, .rawECPrivateKey(x963: x963))
}
// MARK: - /
struct ParsedKey {
var kind: Credential.Kind
var secret: CredentialSecret
/// UI "PEM · EC "
var formatLabel: String
/// openssh-key-v1 mbedTLS nil =
var warning: String?
}
/// kind + B libssh2
static func parsePrivateKey(_ text: String, source: Credential.Source) -> ParsedKey {
let t = text.trimmingCharacters(in: .whitespacesAndNewlines)
let encrypted = t.contains("Proc-Type: 4,ENCRYPTED")
|| t.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----")
var keyType: Credential.KeyType = .unknown
var label = "SSH 私钥"
var warning: String?
if t.contains("-----BEGIN OPENSSH PRIVATE KEY-----") {
label = "OpenSSH 新格式"
warning = "mbedTLS 不识别 openssh-key-v1可能认证失败。建议先 `ssh-keygen -p -m PEM` 转为 PEM。"
} else if t.contains("-----BEGIN RSA PRIVATE KEY-----") {
keyType = .rsa; label = "PEM · RSA (PKCS#1)"
} else if t.contains("-----BEGIN EC PRIVATE KEY-----") {
keyType = .ecdsaP256; label = "PEM · EC (SEC1)"
} else if t.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----") {
label = "PKCS#8加密"
} else if t.contains("-----BEGIN PRIVATE KEY-----") {
label = "PKCS#8"
} else {
warning = "未识别到 PEM 私钥头,请确认粘贴的是私钥内容。"
}
let kind = Credential.Kind.privateKey(
keyType: keyType, source: source, hasPassphrase: encrypted, publicKeyBlob: Data())
let secret = CredentialSecret.pemPrivateKey(pem: Data(text.utf8), passphrase: nil)
return ParsedKey(kind: kind, secret: secret, formatLabel: label, warning: warning)
}
// MARK: - Authentication
/// meta + SSH nil = SE
/// `passphraseOverride`
static func authentication(for cred: Credential, secret: CredentialSecret,
passphraseOverride: String? = nil) -> SSHConfig.Authentication? {
switch secret {
case .password(let pw):
return .password(pw)
case .pemPrivateKey(let pem, let storedPass):
let pem = String(decoding: pem, as: UTF8.self)
return .privateKey(pem: pem, passphrase: passphraseOverride ?? storedPass)
case .rawECPrivateKey(let x963):
guard let signer = SigningKeyProvider.from(x963: x963) else { return nil }
return .publicKeyCallback(signer)
case .none:
return nil // Secure Enclave
}
}
}

View File

@@ -0,0 +1,78 @@
import Foundation
/// meta
/// Keychain `list()` meta
/// `secret(for:)` Keychain meta JSON SecItem
@MainActor
protocol CredentialStore: AnyObject {
func list() -> [Credential]
func secret(for id: UUID) -> CredentialSecret?
func upsert(_ cred: Credential, secret: CredentialSecret?) // secret == nil meta
func delete(_ id: UUID)
}
///
///
/// Keychain/SE
/// / /
/// host
/// 0600 `isExcludedFromBackup` iCloud/iTunes `FileProtectionType.complete`
/// `scheme: "plaintext-v1"`便 scheme
@MainActor
final class FileCredentialStore: ObservableObject, CredentialStore {
@Published private(set) var credentials: [Credential] = []
private let url: URL
private var secrets: [UUID: CredentialSecret] = [:]
private struct Entry: Codable { var meta: Credential; var secret: CredentialSecret }
private struct Envelope: Codable { var scheme: String; var entries: [Entry] }
private static let scheme = "plaintext-v1"
init() {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
url = base.appendingPathComponent("credentials.json")
load()
}
func list() -> [Credential] { credentials }
func secret(for id: UUID) -> CredentialSecret? { secrets[id] }
func upsert(_ cred: Credential, secret: CredentialSecret?) {
if let i = credentials.firstIndex(where: { $0.id == cred.id }) {
credentials[i] = cred
} else {
credentials.append(cred)
}
if let secret { secrets[cred.id] = secret }
persist()
}
func delete(_ id: UUID) {
credentials.removeAll { $0.id == id }
secrets[id] = nil
persist()
}
// MARK: -
private func load() {
guard let data = try? Data(contentsOf: url),
let env = try? JSONDecoder().decode(Envelope.self, from: data) else { return }
credentials = env.entries.map(\.meta)
secrets = Dictionary(uniqueKeysWithValues: env.entries.map { ($0.meta.id, $0.secret) })
}
private func persist() {
let entries = credentials.map { Entry(meta: $0, secret: secrets[$0.id] ?? .none) }
let env = Envelope(scheme: Self.scheme, entries: entries)
guard let data = try? JSONEncoder().encode(env) else { return }
try? data.write(to: url, options: [.atomic, .completeFileProtection])
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
var u = url
var rv = URLResourceValues(); rv.isExcludedFromBackup = true
try? u.setResourceValues(rv)
}
}

View File

@@ -0,0 +1,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)
}
}

View File

@@ -1,18 +1,103 @@
import Foundation
///
/// `password` JSON demo Keychain SecItem HANDOFF
/// `AuthRef` `tailscaleID`
struct SavedHost: Identifiable, Codable, Equatable {
var id: UUID = UUID()
var name: String
var host: String
var port: Int = 22
var username: String
var password: String = ""
/// mosh
var useMosh: Bool = false
/// tmux control mode`tmux -CC new -A`tmux
/// /线 tmux
var useTmux: Bool = true
/// tmux `-s`nil = `main`
var tmuxSession: String?
/// /宿 Keychain
var auth: AuthRef
/// nil = Direct Tailscale egress profile §2.6
var tailscaleID: UUID?
/// chips / / nil =
var group: String?
/// macOS · M4 Max
var os: String?
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 {
// schemainline 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
@@ -45,6 +130,13 @@ final class HostStore: ObservableObject {
save()
}
#if DEBUG
/// UI **** hosts.json
func injectFixtureHosts(_ hosts: [SavedHost]) {
self.hosts = hosts
}
#endif
private func load() {
guard let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode([SavedHost].self, from: data) else { return }

View File

@@ -0,0 +1,350 @@
import SwiftUI
/// split + /
struct HostsListView: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var hostStore: HostStore
@ObservedObject var credStore: FileCredentialStore
@ObservedObject var tailscaleStore: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@State private var editing: SavedHost?
@State private var showEditor = false
private var p: TXPalette { theme.palette }
var body: some View {
ZStack {
p.bg.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: TX.Space.m) {
if hostStore.hosts.isEmpty {
TXEmptyState(icon: "server.rack", title: "还没有主机",
subtitle: "点击右上角 + 添加一台服务器")
} else {
ForEach(hostStore.hosts) { host in
HostCard(host: host,
credName: credName(for: host),
tailscaleName: tailscaleName(for: host)) {
// 稿
let s = manager.open(host: host)
router.enter(s.id)
}
.contextMenu {
Button { editing = host; showEditor = true } label: {
Label("编辑", systemImage: "pencil")
}
Button(role: .destructive) { hostStore.remove(host) } label: {
Label("删除", systemImage: "trash")
}
}
}
}
// manager ObservableObject
if let s = manager.focused { SessionStatusRow(session: s) }
}
.padding(TX.Space.m)
}
}
.navigationTitle("主机")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(p.bg, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { editing = nil; showEditor = true } label: { Image(systemName: "plus") }
}
}
.sheet(isPresented: $showEditor) {
HostEditorView(existing: editing, credStore: credStore, tailscaleStore: tailscaleStore) { host in
if hostStore.hosts.contains(where: { $0.id == host.id }) { hostStore.update(host) }
else { hostStore.add(host) }
}
}
}
private func credName(for host: SavedHost) -> String? {
if case .credential(let id) = host.auth {
return credStore.list().first { $0.id == id }?.name ?? "(凭据已删除)"
}
return nil
}
private func tailscaleName(for host: SavedHost) -> String? {
guard let id = host.tailscaleID else { return nil }
return tailscaleStore.connection(id: id)?.name ?? "(连接已删除)"
}
}
/// /
/// `@ObservedObject` `SessionManager`
struct SessionStatusRow: View {
@ObservedObject var session: TerminalSession
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var isBusy: Bool {
session.phaseText.hasSuffix("") || session.phaseText.contains("重连")
}
var body: some View {
if isBusy || session.phaseText.hasPrefix("失败") {
HStack(spacing: TX.Space.s) {
if isBusy { ProgressView().controlSize(.small).tint(p.accent) }
Text(session.phaseText)
.font(.system(size: 13))
.foregroundStyle(session.phaseText.hasPrefix("失败") ? p.danger : p.textSecondary)
}
.padding(.horizontal, 4)
}
}
}
/// + mosh / / / ·Tailscale / chevron
struct HostCard: View {
let host: SavedHost
let credName: String?
let tailscaleName: String?
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Button(action: onTap) {
HStack(spacing: TX.Space.m) {
ZStack(alignment: .topTrailing) {
Image(systemName: "server.rack")
.font(.system(size: 20, weight: .regular))
.foregroundStyle(p.textSecondary)
.frame(width: 44, height: 44)
.background(p.surfaceElevated, in: RoundedRectangle(cornerRadius: TX.Radius.chip))
if host.useMosh {
Circle().fill(p.mosh).frame(width: 9, height: 9)
.overlay(Circle().stroke(p.surface, lineWidth: 2))
.offset(x: 3, y: -3)
}
}
VStack(alignment: .leading, spacing: 3) {
Text(host.name)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(p.textPrimary)
Text(host.addressLine)
.font(TX.Font.mono(13))
.foregroundStyle(p.textSecondary)
if credName != nil || tailscaleName != nil {
HStack(spacing: TX.Space.xs) {
if let c = credName { badge("key.fill", c, p.accent) }
if let t = tailscaleName { badge("network", t, p.mosh) }
}
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(p.textTertiary)
}
.padding(TX.Space.m)
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1))
}
.buttonStyle(.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()
}
}

View File

@@ -1,6 +1,15 @@
import Foundation
import TXCore
/// host key Known Hosts
struct KnownHostEntry: Identifiable {
let id: String // accountKey "egress|host|port"
let egress: String
let host: String
let port: Int
let fingerprint: String
}
/// known_hosts pin
///
/// Keychain app CODE_SIGNING_ALLOWED=NO keychain-access-group
@@ -47,6 +56,27 @@ final class KeychainKnownHostsStore: KnownHostsStore, @unchecked Sendable {
persist()
}
/// host key /
func entries() -> [KnownHostEntry] {
lock.lock(); defer { lock.unlock() }
return cache.compactMap { key, entry in
guard let b64 = entry["blob"] as? String, let blob = Data(base64Encoded: b64) else { return nil }
let parts = key.split(separator: "|", maxSplits: 2).map(String.init)
return KnownHostEntry(
id: key,
egress: parts.count > 0 ? parts[0] : "",
host: parts.count > 1 ? parts[1] : "",
port: parts.count > 2 ? (Int(parts[2]) ?? 0) : 0,
fingerprint: HostKey.opensshFingerprint(blob))
}.sorted { $0.host < $1.host }
}
func removeKey(_ id: String) {
lock.lock(); defer { lock.unlock() }
cache[id] = nil
persist()
}
private func persist() {
guard let data = try? JSONSerialization.data(withJSONObject: cache) else { return }
try? data.write(to: url, options: .atomic)

View File

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

View File

@@ -0,0 +1,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 gapwindow = 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 IPUp 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 panescwd #{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)
}
}

View File

@@ -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 relayloopback 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?
/// M4host 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(
// /resizemosh 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 falseSSHSession
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 connectingverifier 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
// M4bpublickey 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 SSHmoshActive 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) 次)"
}
}
}

View File

@@ -0,0 +1,193 @@
import Combine
import Foundation
import GhosttyTerminal
/// /
///
///
/// - **** / W teardown
/// - surface `SessionCanvas` `%output`
/// - 广`TerminalSession` scenePhase
@MainActor
final class SessionManager: ObservableObject {
@Published private(set) var sessions: [TerminalSession] = []
///
@Published var focusedID: UUID?
///
///
/// **** tmux
/// tmux ""
@Published var enterRequest: UUID?
/// id/
private var awaitingEnter: Set<UUID> = []
/// ObservableObject CLAUDE.md
private var sessionObservers: [UUID: AnyCancellable] = [:]
private weak var credentialStore: (any CredentialStore)?
private weak var tailscaleStore: TailscaleStore?
var focused: TerminalSession? {
guard let id = focusedID else { return nil }
return sessions.first { $0.id == id }
}
func session(_ id: UUID) -> TerminalSession? {
sessions.first { $0.id == id }
}
///
var connectingSessions: [TerminalSession] {
sessions.filter { awaitingEnter.contains($0.id) }
}
/// tmux
var sessionAwaitingChoice: TerminalSession? {
sessions.first { $0.pendingSessionChoice != nil }
}
/// 西
var activeSessions: [TerminalSession] {
sessions.sorted {
if $0.isMinimized != $1.isMinimized { return $0.isMinimized }
return $0.lastActiveAt > $1.lastActiveAt
}
}
/// 19
func session(atShortcutIndex i: Int) -> TerminalSession? {
let list = activeSessions
return i >= 0 && i < list.count ? list[i] : nil
}
/// / Store
func bind(credentials: any CredentialStore, tailscale: TailscaleStore) {
credentialStore = credentials
tailscaleStore = tailscale
for s in sessions { s.bind(credentials: credentials, tailscale: tailscale) }
}
// MARK: - /
/// ****
/// ****
/// **** tmux / `enterRequest`
@discardableResult
func open(host: SavedHost) -> TerminalSession {
if let existing = sessions.first(where: { $0.host?.id == host.id }) {
focus(existing)
enterRequest = existing.id //
return existing
}
let s = makeSession()
sessions.append(s)
focus(s)
awaitingEnter.insert(s.id)
s.connect(to: host)
return s
}
///
///
/// = / tmux / /
/// /
private func evaluateEnter(_ s: TerminalSession) {
guard awaitingEnter.contains(s.id) else { return }
if s.pendingSessionChoice != nil { return } //
if s.isProbingTmux { return } //
let failed = s.phaseText.hasPrefix("失败")
guard s.isLive || s.tmuxUnavailable || s.tmuxSkipped || failed else { return }
awaitingEnter.remove(s.id)
enterRequest = s.id
}
///
func focus(_ s: TerminalSession) {
focusedID = s.id
s.isMinimized = false
s.lastActiveAt = Date()
}
/// **** +
func minimize(_ id: UUID) {
guard let s = session(id) else { return }
s.isMinimized = true
s.lastActiveAt = Date()
}
/// tmux / UI-4
/// teardown + surface
func close(_ id: UUID) {
guard let s = session(id) else { return }
s.close()
sessions.removeAll { $0.id == id }
sessionObservers[id] = nil
awaitingEnter.remove(id)
if focusedID == id { focusedID = activeSessions.first?.id }
}
// MARK: - 广
// 广 syncUI idle
func enterBackground() { for s in sessions where !s.isSyntheticFixture { s.enterBackground() } }
func enterForeground() { for s in sessions where !s.isSyntheticFixture { s.enterForeground() } }
/// raw surface tmux pane
func applyTerminalTheme(_ t: TerminalTheme) {
for s in sessions { s.applyTerminalTheme(t) }
}
/// host key
var pendingHostKeyMismatch: TerminalSession? {
sessions.first { $0.hostKeyMismatch != nil }
}
// MARK: -
/// simctl launch --args -txHost
@discardableResult
func autoConnectIfConfigured() -> TerminalSession? {
let d = UserDefaults.standard
guard let host = d.string(forKey: "txHost"), !host.isEmpty else { return nil }
let s = makeSession()
sessions.append(s)
focus(s)
s.autoConnectIfConfigured()
return s
}
#if DEBUG
/// UI `-txUIFixture 1`
@discardableResult
func spawnFixtureSessions() -> TerminalSession? {
for (i, spec) in UIPreviewFixture.specs.enumerated() {
// UIPreviewFixture.hosts id
guard let host = UIPreviewFixture.hosts.first(where: { $0.name == spec.name }) else { continue }
let s = makeSession()
s.injectFixture(host: host, lines: spec.lines, link: spec.link,
mosh: host.useMosh, tmux: spec.tmux,
tmuxSessions: spec.tmux == nil ? [] : UIPreviewFixture.tmuxSessions(current: spec.tmux!.name))
//
s.lastActiveAt = Date(timeIntervalSinceNow: Double(-i) * 60)
sessions.append(s)
}
focusedID = sessions.first?.id
return sessions.first
}
#endif
private func makeSession() -> TerminalSession {
let s = TerminalSession()
if let c = credentialStore, let t = tailscaleStore {
s.bind(credentials: c, tailscale: t)
}
// manager / enter
sessionObservers[s.id] = s.objectWillChange.sink { [weak self, weak s] _ in
guard let self else { return }
self.objectWillChange.send()
if let s { Task { @MainActor in self.evaluateEnter(s) } }
}
return s
}
}

View File

@@ -0,0 +1,206 @@
import SwiftUI
import TXCore
///
///
/// ** tmux ** · ** tmux ** · **** tmux
/// ** tmux sheet**
///
/// ""****
/// `Herdr` / ``
struct SessionPickerSheet: View {
/// ""
let host: String
let choice: TmuxSessionChoice
let onAttach: (String) -> Void
let onCreate: (String) -> Void
let onNative: () -> Void
@EnvironmentObject var theme: TXThemeManager
@State private var newName = ""
@State private var creating = false
@FocusState private var nameFocused: Bool
private var p: TXPalette { theme.palette }
var body: some View {
VStack(alignment: .leading, spacing: TX.Space.m) {
grabber
title
header
if choice.sessions.isEmpty {
emptyState
} else {
sessionList
}
newSessionRow
Spacer(minLength: 0)
}
.padding(.horizontal, TX.Space.l)
.padding(.bottom, TX.Space.l)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(p.bg.ignoresSafeArea())
}
private var title: some View {
VStack(alignment: .leading, spacing: 3) {
Text("连接到 \(host)")
.font(TX.Font.mono(17, .medium))
.foregroundStyle(p.text)
Text("接回一个 tmux 会话,或直接用原生终端")
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
}
}
private var grabber: some View {
RoundedRectangle(cornerRadius: 2.5)
.fill(p.borderStrong)
.frame(width: 44, height: 5)
.frame(maxWidth: .infinity)
.padding(.top, TX.Space.s)
}
// MARK: - +
private var header: some View {
HStack(spacing: TX.Space.s) {
HStack(spacing: 2) {
segment(icon: "chevron.left.forwardslash.chevron.right", title: "Tmux", enabled: true)
segment(icon: "shippingbox", title: "Herdr", enabled: false)
segment(icon: "clock.arrow.circlepath", title: "最近", enabled: false)
}
.padding(4)
.background(p.surface, in: Capsule())
Spacer(minLength: TX.Space.s)
Button(action: onNative) {
HStack(spacing: TX.Space.xs) {
Image(systemName: "terminal")
.font(.system(size: 13, weight: .medium))
Text("原生终端")
.font(TX.Font.mono(15, .medium))
}
.foregroundStyle(p.text)
.padding(.horizontal, TX.Space.m)
.frame(height: 44)
.background(p.surface, in: Capsule())
.overlay(Capsule().strokeBorder(p.border, lineWidth: 1))
}
.buttonStyle(.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)
}
}
}

View File

@@ -0,0 +1,173 @@
import SwiftUI
/// 齿 sheet`NavigationStack` +
/// UI-5 稿 `1g -` 280pt +
struct SettingsHost: View {
@ObservedObject var credStore: FileCredentialStore
@ObservedObject var tailscaleStore: TailscaleStore
@Environment(\.dismiss) private var dismiss
@EnvironmentObject var theme: TXThemeManager
/// known hosts keychain/访
private let knownHosts = KeychainKnownHostsStore()
private var p: TXPalette { theme.palette }
var body: some View {
NavigationStack {
ZStack {
p.bg.ignoresSafeArea()
List {
Section("外观") {
NavigationLink { SettingsView() } label: { row("paintbrush", "外观与主题") }
}
.listRowBackground(p.surface)
Section("保险库") {
NavigationLink { KeychainListView(store: credStore) } label: {
row("key.fill", "Keychain 凭据")
}
NavigationLink { KnownHostsView(store: knownHosts) } label: {
row("checkmark.shield", "Known Hosts")
}
}
.listRowBackground(p.surface)
Section("网络") {
NavigationLink { TailscaleListView(store: tailscaleStore) } label: {
row("network", "Tailscale")
}
}
.listRowBackground(p.surface)
}
.scrollContentBackground(.hidden)
.foregroundStyle(p.text)
}
.navigationTitle("设置")
.toolbarBackground(p.bg, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("完成") { dismiss() }.tint(TXAccent.base)
}
}
}
.tint(TXAccent.base)
}
private func row(_ icon: String, _ title: String) -> some View {
Label(title, systemImage: icon)
.font(TX.Font.mono(13))
.foregroundStyle(p.text)
}
}
/// app + +
struct SettingsView: View {
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
ZStack {
p.bg.ignoresSafeArea()
List {
Section("主题app 与终端统一)") {
ForEach(TXPalette.all) { pal in
Button { theme.apply(pal) } label: { themeRow(pal) }
.buttonStyle(.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 keyTOFU pin/
struct KnownHostsView: View {
let store: KeychainKnownHostsStore
@EnvironmentObject var theme: TXThemeManager
@State private var entries: [KnownHostEntry] = []
private var p: TXPalette { theme.palette }
var body: some View {
ZStack {
p.bg.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: TX.Space.s) {
if entries.isEmpty {
TXEmptyState(icon: "checkmark.shield", title: "还没有已信任的主机密钥",
subtitle: "首次连接主机时会自动记住其 host key")
} else {
ForEach(entries) { e in
row(e)
.contextMenu {
Button(role: .destructive) {
store.removeKey(e.id); reload()
} label: { Label("移除信任", systemImage: "trash") }
}
}
}
}
.padding(TX.Space.m)
}
}
.navigationTitle("Known Hosts")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(p.bg, for: .navigationBar)
.onAppear(perform: reload)
}
private func row(_ e: KnownHostEntry) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text("\(e.host):\(e.port)").font(.system(size: 15, weight: .semibold)).foregroundStyle(p.textPrimary)
Text("出口:\(e.egress)").font(TX.Font.mono(11)).foregroundStyle(p.textSecondary)
Text(e.fingerprint).font(TX.Font.mono(11)).foregroundStyle(p.textTertiary)
.textSelection(.enabled)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(TX.Space.m)
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1))
}
private func reload() { entries = store.entries() }
}

View File

@@ -43,6 +43,23 @@ final class SigningKeyProvider: SSHSigner, @unchecked Sendable {
return URL(fileURLWithPath: base).appendingPathComponent("ssh-ecdsa-p256.key")
}
/// SecKey x9.63 A
static func from(x963: Data) -> SigningKeyProvider? {
guard let key = SecKeyCreateWithData(x963 as CFData, [
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrKeySizeInBits: 256,
] as CFDictionary, nil) else { return nil }
return SigningKeyProvider(privateKey: key, hardware: false)
}
/// P-256 +
static func generateSoftwareP256() -> (provider: SigningKeyProvider, x963: Data)? {
guard let sw = createSoftwareKey(),
let ext = SecKeyCopyExternalRepresentation(sw, nil) as Data? else { return nil }
return (SigningKeyProvider(privateKey: sw, hardware: false), ext)
}
static func loadOrCreate() -> SigningKeyProvider? {
// 1)
if let data = try? Data(contentsOf: keyFileURL),
@@ -65,6 +82,23 @@ final class SigningKeyProvider: SSHSigner, @unchecked Sendable {
return SigningKeyProvider(privateKey: sw, hardware: false)
}
/// Secure Enclave SE key
/// / false UI Face ID
static func secureEnclaveAvailable() -> Bool {
guard let access = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .privateKeyUsage, nil) else { return false }
let attrs: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: false,
kSecAttrAccessControl as String: access,
],
]
return SecKeyCreateRandomKey(attrs as CFDictionary, nil) != nil
}
private static func createSecureEnclaveKey() -> SecKey? {
guard let access = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .privateKeyUsage, nil) else { return nil }

View File

@@ -0,0 +1,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
}
}

View File

@@ -0,0 +1,80 @@
import Foundation
/// Tailscale = tsnet host
/// stateDir`tsnet-<uuid>`tsnet Dir Up
struct TailscaleConnection: Identifiable, Codable, Equatable {
let id: UUID
var name: String
/// tailnet
var hostname: String
/// tsnet Application Support
let stateDirName: String
/// true = tailnet authKey Up
var needsAuthKey: Bool
/// Up tailnet IP
var lastSelfIP: String?
init(id: UUID = UUID(), name: String, hostname: String = "terminalx-ipad",
needsAuthKey: Bool = true, lastSelfIP: String? = nil) {
self.id = id
self.name = name
self.hostname = hostname
self.stateDirName = "tsnet-\(id.uuidString)"
self.needsAuthKey = needsAuthKey
self.lastSelfIP = lastSelfIP
}
}
/// Tailscale tailnets.jsonauthKey
@MainActor
final class TailscaleStore: ObservableObject {
@Published private(set) var connections: [TailscaleConnection] = []
private let url: URL
init() {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
url = base.appendingPathComponent("tailnets.json")
load()
}
func add(_ conn: TailscaleConnection) { connections.append(conn); save() }
func update(_ conn: TailscaleConnection) {
guard let i = connections.firstIndex(where: { $0.id == conn.id }) else { return }
connections[i] = conn
save()
}
func remove(_ conn: TailscaleConnection) {
connections.removeAll { $0.id == conn.id }
save()
// tsnet
let base = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0]
try? FileManager.default.removeItem(atPath: base + "/" + conn.stateDirName)
}
func connection(id: UUID?) -> TailscaleConnection? {
guard let id else { return nil }
return connections.first { $0.id == id }
}
#if DEBUG
/// UI tailnet**** tailnets.json
func injectFixtureConnections(_ list: [TailscaleConnection]) {
connections = list
}
#endif
private func load() {
guard let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode([TailscaleConnection].self, from: data) else { return }
connections = decoded
}
private func save() {
guard let data = try? JSONEncoder().encode(connections) else { return }
try? data.write(to: url, options: .atomic)
}
}

View File

@@ -0,0 +1,206 @@
import SwiftUI
/// Tailscale tsnet
struct TailscaleListView: View {
@ObservedObject var store: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@State private var editing: TailscaleConnection?
@State private var showEditor = false
private var p: TXPalette { theme.palette }
var body: some View {
ZStack {
p.bg.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: TX.Space.s) {
if store.connections.isEmpty {
TXEmptyState(icon: "network", title: "还没有 Tailscale 连接",
subtitle: "添加一个 tailnet主机可选择经它连接")
} else {
ForEach(store.connections) { conn in
connRow(conn)
.contextMenu {
Button { editing = conn; showEditor = true } label: {
Label("编辑", systemImage: "pencil")
}
Button(role: .destructive) { store.remove(conn) } label: {
Label("删除", systemImage: "trash")
}
}
}
}
}
.padding(TX.Space.m)
}
}
.navigationTitle("Tailscale")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(p.bg, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { editing = nil; showEditor = true } label: { Image(systemName: "plus") }
}
}
.sheet(isPresented: $showEditor) {
TailscaleEditorView(existing: editing, store: store)
}
}
private func connRow(_ conn: TailscaleConnection) -> some View {
HStack(spacing: TX.Space.m) {
Image(systemName: "network")
.font(.system(size: 18))
.foregroundStyle(conn.needsAuthKey ? p.textTertiary : p.mosh)
.frame(width: 40, height: 40)
.background(p.surfaceElevated, in: RoundedRectangle(cornerRadius: TX.Radius.chip))
VStack(alignment: .leading, spacing: 2) {
Text(conn.name).font(.system(size: 15, weight: .semibold)).foregroundStyle(p.textPrimary)
Text(conn.needsAuthKey ? "未认证" : (conn.lastSelfIP.map { "已注册 · \($0)" } ?? "已注册"))
.font(TX.Font.mono(12))
.foregroundStyle(conn.needsAuthKey ? p.warning : p.textSecondary)
}
Spacer()
Circle().fill(conn.needsAuthKey ? p.offline : p.mosh).frame(width: 8, height: 8)
}
.padding(TX.Space.m)
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1))
}
}
/// Tailscale name/hostname/authKey Up tsnet SelfIP
struct TailscaleEditorView: View {
let existing: TailscaleConnection?
@ObservedObject var store: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var hostname = "terminalx-ipad"
@State private var authKey = ""
@State private var registering = false
@State private var statusText = ""
@State private var registered = false
@State private var draft: TailscaleConnection?
private var p: TXPalette { theme.palette }
private var canRegister: Bool { !name.isEmpty && (!authKey.isEmpty || registered) }
var body: some View {
NavigationStack {
ZStack {
p.bg.ignoresSafeArea()
Form {
Section("连接") {
TXTextField(title: "名称", text: $name, placeholder: "公司 tailnet")
TXTextField(title: "本节点主机名", text: $hostname)
}
Section {
TXTextField(title: "Auth Keytskey-…)", 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)"
}
}
}
}
/// metaneedsAuthKey=true
private func finish() {
let base = draft ?? existing
let conn: TailscaleConnection
if let base {
conn = TailscaleConnection(id: base.id, name: name.isEmpty ? "tailnet" : name,
hostname: hostname, needsAuthKey: !registered,
lastSelfIP: registered ? base.lastSelfIP : nil)
} else {
conn = TailscaleConnection(name: name.isEmpty ? "tailnet" : name,
hostname: hostname, needsAuthKey: !registered)
}
persist(conn)
dismiss()
}
private func persist(_ conn: TailscaleConnection) {
if store.connections.contains(where: { $0.id == conn.id }) { store.update(conn) }
else { store.add(conn) }
draft = conn
}
}

View File

@@ -0,0 +1,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 {
// 12s
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)
}
}
}
/// 34ptUI-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)
}
}

View 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 = Dfalse = 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)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,86 +2,210 @@ import SwiftUI
import GhosttyTerminal
import GhosttyTheme
/// **app UI** ****`ghosttyName` GhosttyTheme
/// app = `static let`
struct TXPalette: Equatable, Identifiable {
// MARK: - docs/design/UI-DESIGN-HANDOFF.md
//
//
// TXAccent Nocturne blurple****线
// TXChrome app chrome Catppuccin flavor 4
// TXFlavor Catppuccin flavor ****libghostty +
// TXPalette palette
/// Nocturne blurple
enum TXAccent {
/// `--color-accent`线
static let base = Color(hex: 0x9184D9)
/// accent-600
static let border = Color(hex: 0x796CBF)
/// accent-900
static let selectedBg = Color(hex: 0x2B2741)
/// accent-800 inset stroke 1pt
static let selectedStroke = Color(hex: 0x423A6A)
/// accent-400
static let text = Color(hex: 0xB5ABFC)
/// accent-300 hover/
static let textBright = Color(hex: 0xD2CEFD)
/// accent-500
static let mid = Color(hex: 0x968AE0)
/// accent-700 inset
static let dim = Color(hex: 0x5D5294)
}
/// macOS 沿/
enum TXWindowDot {
static let close = Color(hex: 0xFF5F57)
static let minimize = Color(hex: 0xFEBC2E)
}
/// app chrome **** TXAccent
struct TXChrome: Equatable {
/// / pane
let bgDeep: UInt32
/// /
let bg: UInt32
/// / /
let surfaceDark: UInt32
///
let surface: UInt32
/// 线
let divider: UInt32
///
let border: UInt32
/// /
let borderStrong: UInt32
/// /
let borderDialog: UInt32
///
let text: UInt32
/// neutral-300
let text2: UInt32
/// neutral-400
let text3: UInt32
/// neutral-500
let text4: UInt32
/// / neutral-600
let textWeak: UInt32
/// neutral-700
let textFaint: UInt32
}
extension TXChrome {
/// Mocha = **** flavor HSL docs/design/render-screens.py
static let mocha = TXChrome(
bgDeep: 0x11121C, bg: 0x161826, surfaceDark: 0x1B1D2B, surface: 0x232532,
divider: 0x2C2F3D, border: 0x3F424D, borderStrong: 0x595D6C, borderDialog: 0x4A4D5E,
text: 0xE9E9ED, text2: 0xCFD3E5, text3: 0xB2B6CA, text4: 0x9397AB,
textWeak: 0x75798C, textFaint: 0x4A4D5E)
/// Macchiato flavor base Mocha /
static let macchiato = TXChrome(
bgDeep: 0x171B28, bg: 0x1B2133, surfaceDark: 0x212737, surface: 0x292F3E,
divider: 0x323949, border: 0x464C58, borderStrong: 0x606877, borderDialog: 0x515869,
text: 0xEDEEF1, text2: 0xD3DAE9, text3: 0xB6BDCF, text4: 0x969EB0,
textWeak: 0x788091, textFaint: 0x515869)
static let frappe = TXChrome(
bgDeep: 0x222735, bg: 0x272E3F, surfaceDark: 0x2D3443, surface: 0x363C49,
divider: 0x3F4754, border: 0x535963, borderStrong: 0x6D7582, borderDialog: 0x5E6574,
text: 0xF4F4F5, text2: 0xDCE1EB, text3: 0xBEC5D1, text4: 0x9FA6B2,
textWeak: 0x818793, textFaint: 0x5E6574)
/// Latte flavor""
static let latte = TXChrome(
bgDeep: 0xE4E6ED, bg: 0xF3F4F8, surfaceDark: 0xFAFBFD, surface: 0xEBEDF3,
divider: 0xDCDEE7, border: 0xC7CAD6, borderStrong: 0xA9ADBD, borderDialog: 0xB8BCCA,
text: 0x2A2C3A, text2: 0x43465A, text3: 0x5A5E73, text4: 0x757994,
textWeak: 0x8E92A6, textFaint: 0xB0B4C4)
}
/// Catppuccin flavor +
struct TXFlavor: Equatable {
let id: String
let name: String
let isDark: Bool
/// GhosttyTheme app Catppuccin flavor
/// GhosttyTheme
let ghosttyName: String
let bg: Color
let surface: Color
let surfaceElevated: Color
let accent: Color
let accentSoft: Color
let textPrimary: Color
let textSecondary: Color
let textTertiary: Color
let border: Color
let borderStrong: Color
let running: Color
let mosh: Color
let warning: Color
let offline: Color
// Catppuccin
let green: UInt32 // 线
let red: UInt32 // error
let yellow: UInt32 // warninggit
let blue: UInt32 //
let teal: UInt32 // mosh
let peach: UInt32 //
let overlay: UInt32 // 线
/// Catppuccin base
/// libghostty pane
let terminalBg: UInt32
}
// MARK: - Palette
/// **app chrome** ****`ghosttyName` GhosttyTheme
/// Nocturne blurple = `static let`
struct TXPalette: Equatable, Identifiable {
let flavor: TXFlavor
let chrome: TXChrome
var id: String { flavor.id }
var name: String { flavor.name }
var isDark: Bool { flavor.isDark }
var ghosttyName: String { flavor.ghosttyName }
/// GhosttyTheme TerminalTheme退
var terminalTheme: TerminalTheme {
GhosttyThemeCatalog.theme(named: 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 {
/// Catppuccin flavor paletteapp
private static func catppuccin(
id: String, name: String, ghostty: String, isDark: Bool,
base: UInt32, surface0: UInt32, surface1: UInt32,
text: UInt32, subtext0: UInt32, overlay0: UInt32,
green: UInt32, teal: UInt32, yellow: UInt32
) -> 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 = TXPalette(
flavor: TXFlavor(
id: "catppuccin-mocha", name: "Mocha", isDark: true, ghosttyName: "Catppuccin Mocha",
green: 0xA6E3A1, red: 0xF38BA8, yellow: 0xF9E2AF, blue: 0x89B4FA,
teal: 0x94E2D5, peach: 0xFAB387, overlay: 0x6C7086, terminalBg: 0x1E1E2E),
chrome: .mocha)
static let catppuccinMocha = catppuccin(
id: "catppuccin-mocha", name: "Catppuccin Mocha", ghostty: "Catppuccin Mocha", isDark: true,
base: 0x1E1E2E, surface0: 0x313244, surface1: 0x45475A,
text: 0xCDD6F4, subtext0: 0xA6ADC8, overlay0: 0x6C7086,
green: 0xA6E3A1, teal: 0x94E2D5, yellow: 0xF9E2AF)
static let catppuccinMacchiato = TXPalette(
flavor: TXFlavor(
id: "catppuccin-macchiato", name: "Macchiato", isDark: true, ghosttyName: "Catppuccin Macchiato",
green: 0xA6DA95, red: 0xED8796, yellow: 0xEED49F, blue: 0x8AADF4,
teal: 0x8BD5CA, peach: 0xF5A97F, overlay: 0x6E738D, terminalBg: 0x24273A),
chrome: .macchiato)
static let catppuccinMacchiato = catppuccin(
id: "catppuccin-macchiato", name: "Catppuccin Macchiato", ghostty: "Catppuccin Macchiato", isDark: true,
base: 0x24273A, surface0: 0x363A4F, surface1: 0x494D64,
text: 0xCAD3F5, subtext0: 0xA5ADCB, overlay0: 0x6E738D,
green: 0xA6DA95, teal: 0x8BD5CA, yellow: 0xEED49F)
static let catppuccinFrappe = TXPalette(
flavor: TXFlavor(
id: "catppuccin-frappe", name: "Frappé", isDark: true, ghosttyName: "Catppuccin Frappe",
green: 0xA6D189, red: 0xE78284, yellow: 0xE5C890, blue: 0x8CAAEE,
teal: 0x81C8BE, peach: 0xEF9F76, overlay: 0x737994, terminalBg: 0x303446),
chrome: .frappe)
static let catppuccinFrappe = catppuccin(
id: "catppuccin-frappe", name: "Catppuccin Frappé", ghostty: "Catppuccin Frappe", isDark: true,
base: 0x303446, surface0: 0x414559, surface1: 0x51576D,
text: 0xC6D0F5, subtext0: 0xA5ADCE, overlay0: 0x737994,
green: 0xA6D189, teal: 0x81C8BE, yellow: 0xE5C890)
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 catppuccinLatte = TXPalette(
flavor: TXFlavor(
id: "catppuccin-latte", name: "Latte", isDark: false, ghosttyName: "Catppuccin Latte",
green: 0x40A02B, red: 0xD20F39, yellow: 0xDF8E1D, blue: 0x1E66F5,
teal: 0x179299, peach: 0xFE640B, overlay: 0x9CA0B0, terminalBg: 0xEFF1F5),
chrome: .latte)
///
static let all: [TXPalette] = [catppuccinMocha, catppuccinMacchiato, catppuccinFrappe, catppuccinLatte]
@@ -118,24 +242,91 @@ extension TerminalTheme {
}
}
/// //
// MARK: -
/// / / /
enum TX {
enum Radius {
static let card: CGFloat = 16
static let chip: CGFloat = 10
static let field: CGFloat = 12
/// /
static let badge: CGFloat = 6
///
static let control: CGFloat = 9
///
static let card: CGFloat = 12
///
static let dialog: CGFloat = 15
/// chrome/
static let capsule: CGFloat = 11
//
static let chip: CGFloat = 9
static let field: CGFloat = 9
}
/// 2 / 6 / 8 / 10 / 12 / 14 / 16 / 20 / 26 / 34 / 40
enum Space {
static let hairline: CGFloat = 2
static let xs: CGFloat = 6
static let s: CGFloat = 10
static let m: CGFloat = 16
static let l: CGFloat = 22
static let xl: CGFloat = 32
static let l: CGFloat = 20
static let xl: CGFloat = 26
static let xxl: CGFloat = 34
/// /
static let screenGutter: CGFloat = 40
}
/// pt `.compact`
enum Layout {
/// 48
static let railWidth: CGFloat = 56
static let railWidthCompact: CGFloat = 48
///
static let sidebarWidth: CGFloat = 288
/// pane
static let paneGap: CGFloat = 2
///
static let paneDragHit: CGFloat = 12
///
static let minTouch: CGFloat = 44
/// pane
static let capsuleInset: CGFloat = 86
/// ****线 y
static let footHeight: CGFloat = 34
}
enum Font {
/// JetBrains Monochrome SwiftUI 退 PingFang SC
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)
}
}

View File

@@ -37,7 +37,11 @@ final class TmuxPaneSurface: ObservableObject, Identifiable {
@MainActor
final class TmuxWindow: ObservableObject, Identifiable {
let id: TmuxWindowID
/// tmux `#{window_index}`/`id` `@N`
@Published var index: Int
@Published var title: String
/// pane `#{pane_current_path}`title shell UI 退
@Published var cwd: String = ""
@Published var panes: [TmuxPaneID: TmuxPaneSurface] = [:]
/// zoom zoom pane
@Published var visibleLayout: TmuxLayout?
@@ -45,7 +49,19 @@ final class TmuxWindow: ObservableObject, Identifiable {
var fullLayout: TmuxLayout?
@Published var activePane: TmuxPaneID?
init(id: TmuxWindowID, title: String) { self.id = id; self.title = title }
init(id: TmuxWindowID, index: Int, title: String) {
self.id = id; self.index = index; self.title = title
}
}
/// tmux `list-sessions`
struct TmuxSessionInfo: Identifiable, Equatable {
var id: String { name }
let name: String
let windows: Int
/// attach attached
let isAttached: Bool
let lastActivity: Date?
}
/// tmux control-mode `%`- window tabwindow pane
@@ -55,6 +71,8 @@ final class TmuxWindow: ObservableObject, Identifiable {
final class TmuxController: ObservableObject {
@Published private(set) var windows: [TmuxWindow] = []
@Published var activeWindowID: TmuxWindowID?
/// tmux server `listSessions()`
@Published private(set) var sessions: [TmuxSessionInfo] = []
private let parser = TmuxControlParser()
private var windowByID: [TmuxWindowID: TmuxWindow] = [:]
@@ -70,7 +88,18 @@ final class TmuxController: ObservableObject {
// control-mode FIFO
private var attachAcked = false
private enum PendingKind { case ignore, listWindows, capturePane(TmuxPaneID) }
private enum PendingKind: CustomStringConvertible {
case ignore, listWindows, listSessions, currentSession, capturePane(TmuxPaneID)
var description: String {
switch self {
case .ignore: "ignore"
case .listWindows: "listWindows"
case .listSessions: "listSessions"
case .currentSession: "currentSession"
case .capturePane(let p): "capturePane(%\(p.raw))"
}
}
}
private var pending: [PendingKind] = []
// attach kickoffrefresh-client + list-windows + pane capture""
@@ -80,6 +109,11 @@ final class TmuxController: ObservableObject {
private var containerKnown = false
private var kickoffDone = false
/// format ** tab** tab PTY tmux 线
/// list-windows/list-sessions title/cwd/UI
/// `#`tmux format `#{}`/`##`
static let fieldSep = "|:|"
init(sendRaw: @escaping @Sendable (Data) -> Void) {
self.sendRaw = sendRaw
}
@@ -91,8 +125,14 @@ final class TmuxController: ObservableObject {
NSLog("TMUXDBG kickoff refresh-client -C \(clientCols)x\(clientRows)")
sendCommand("refresh-client -C \(clientCols)x\(clientRows)") //
pending.append(.ignore)
sendCommand("list-windows -F \"#{window_id}\t#{window_name}\t#{window_active}\t#{window_layout}\t#{window_visible_layout}\"")
let sep = Self.fieldSep
sendCommand("list-windows -F \"#{window_id}\(sep)#{window_name}\(sep)#{window_active}\(sep)#{window_layout}\(sep)#{window_visible_layout}\(sep)#{pane_current_path}\(sep)#{window_index}\"")
pending.append(.listWindows)
// **** list-sessions `session_attached`
// attach `demo`
sendCommand("display-message -p \"#{session_name}\"")
pending.append(.currentSession)
listSessions() //
}
/// model attach tmux refresh-client -C
@@ -125,6 +165,10 @@ final class TmuxController: ObservableObject {
}
}
/// tmux `display-message -p "#{session_name}"`
/// UI tmux
@Published private(set) var currentSessionName = ""
var activeWindow: TmuxWindow? {
guard let id = activeWindowID else { return windows.first }
return windowByID[id]
@@ -231,18 +275,45 @@ final class TmuxController: ObservableObject {
return
}
guard !pending.isEmpty else { return }
switch pending.removeFirst() {
let kind = pending.removeFirst()
switch kind {
case .ignore:
break
case .listWindows:
for line in r.lines {
let parts = line.split(separator: "\t", omittingEmptySubsequences: false)
guard parts.count >= 5, let w = TmuxWindowID.parse(parts[0]) else { continue }
let parts = line.components(separatedBy: Self.fieldSep)
guard parts.count >= 5, let w = TmuxWindowID.parse(parts[0]) else {
NSLog("TMUXDBG winparse FAILED count=\(parts.count) line=[\(line.prefix(160))]")
continue
}
let win = ensureWindow(w)
win.title = String(parts[1])
if parts.count >= 6 { win.cwd = String(parts[5]) }
if parts.count >= 7, let idx = Int(parts[6]) { win.index = idx }
if parts[2] == "1" { activeWindowID = w }
applyLayout(window: w, full: String(parts[3]), visible: String(parts[4]))
}
case .listSessions:
sessions = r.lines.compactMap { line in
let f = line.components(separatedBy: Self.fieldSep)
guard f.count >= 3 else { return nil }
let activity = f.count >= 4 ? Double(f[3]).map { Date(timeIntervalSince1970: $0) } : nil
return TmuxSessionInfo(name: f[0],
windows: Int(f[1]) ?? 1,
isAttached: f[2] == "1",
lastActivity: activity)
}
NSLog("TMUXDBG sessions=\(sessions.map { "\($0.name)/w\($0.windows)/a\($0.isAttached)" })")
case .currentSession:
if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
currentSessionName = name
NSLog("TMUXDBG currentSession=\(name)")
}
case .currentSession:
if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
currentSessionName = name
NSLog("TMUXDBG currentSession=\(name)")
}
case .capturePane(let p):
// capture-pane pane > pane grid
// ESC[H + N-1 \r\n/
@@ -263,7 +334,8 @@ final class TmuxController: ObservableObject {
@discardableResult
private func ensureWindow(_ w: TmuxWindowID) -> TmuxWindow {
if let existing = windowByID[w] { return existing }
let win = TmuxWindow(id: w, title: "窗口 \(w.raw)")
// index id list-windows window_index
let win = TmuxWindow(id: w, index: w.raw, title: "窗口 \(w.raw)")
windowByID[w] = win
windows.append(win)
windows.sort { $0.id.raw < $1.id.raw }
@@ -307,6 +379,47 @@ final class TmuxController: ObservableObject {
func newWindow() { sendCommand("new-window") }
func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") }
// MARK: - / / resizeUI-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 panetmux tmux
sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(s):")
} else {
sendCommand("split-window \(vertical ? "-h" : "-v")")
}
}
/// tmux window window detached
func splitIntoNewSession(vertical: Bool, name: String) {
sendCommand("new-session -d -s \(name)")
sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(name):")
}
/// pane ****
/// `%layout-change` frame "线 b"
func resizePane(_ p: TmuxPaneID, cols: Int? = nil, rows: Int? = nil) {
if let cols, cols > 0 { sendCommand("resize-pane -t %\(p.raw) -x \(cols)") }
if let rows, rows > 0 { sendCommand("resize-pane -t %\(p.raw) -y \(rows)") }
}
/// tmux server detached `sessions`
func listSessions() {
let sep = Self.fieldSep
sendCommand("list-sessions -F \"#{session_name}\(sep)#{session_windows}\(sep)#{session_attached}\(sep)#{session_activity}\"")
pending.append(.listSessions)
}
/// pane surface
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }
@@ -320,13 +433,13 @@ final class TmuxController: ObservableObject {
/// 线线 rawGate hop 线
final class TmuxRouter: @unchecked Sendable {
private let lock = NSLock()
private let rawGate: OutputGate
private let rawGate: any RawOutputSink
private let enterGateway: @Sendable ([UInt8]) -> Void
private let gatewayBytes: @Sendable ([UInt8]) -> Void
private var carry: [UInt8] = []
private var inGateway = false
init(rawGate: OutputGate,
init(rawGate: any RawOutputSink,
enterGateway: @escaping @Sendable ([UInt8]) -> Void,
gatewayBytes: @escaping @Sendable ([UInt8]) -> Void) {
self.rawGate = rawGate

View File

@@ -0,0 +1,103 @@
import Foundation
import TsnetBridge
enum TsnetError: Error { case newNodeFailed, notUp, needsAuthKey }
/// tsnet Tailscale id
///
/// tsnet v1.102.0 `tsnet.Server` netstackdial node
/// 100.x host Node dial
/// Go runtime gomobile bind tsnetbridge
///
/// **** Up·**Up ** DownM3 mosh netstack ·
/// ** 3** LRU ·**in-flight ** connect
/// double-Up stateDirtsnet 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()
// Upin-flight
upLock.lock()
defer { upLock.unlock() }
lock.lock()
if let n = nodes[connID] { lastUsed[connID] = Date(); lock.unlock(); return n }
lock.unlock()
let dir = stateBase + "/" + stateDirName
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
guard let n = TsnetbridgeNewNode(dir, hostname) else { throw TsnetError.newNodeFailed }
try n.up(withAuthKey: authKey ?? "", timeoutMs: timeoutMs)
lock.lock()
nodes[connID] = n
lastUsed[connID] = Date()
evictIfNeededLocked(keeping: connID)
lock.unlock()
return n
}
/// mosh UDP relay线 Up
func startMoshRelay(connID: UUID, host: String, moshPort: Int, timeoutMs: Int) throws -> TsnetbridgeMoshRelay {
lock.lock(); let n = nodes[connID]; lock.unlock()
guard let n else { throw TsnetError.notUp }
return try n.startMoshRelay(host, moshPort: moshPort, timeoutMs: timeoutMs)
}
/// M3 tsnet
func wakeUp(connID: UUID) {
lock.lock(); let n = nodes[connID]; lock.unlock()
n?.wakeUp()
}
/// LRU
func markSessionActive(_ connID: UUID, _ active: Bool) {
lock.lock()
if active { activeSessions.insert(connID) } else { activeSessions.remove(connID) }
lock.unlock()
}
/// / LRU
func close(_ connID: UUID) {
lock.lock()
let n = nodes[connID]
nodes[connID] = nil; lastUsed[connID] = nil; activeSessions.remove(connID)
lock.unlock()
try? n?.close()
}
/// keeping
private func evictIfNeededLocked(keeping: UUID) {
while nodes.count > maxLive {
let candidates = nodes.keys
.filter { $0 != keeping && !activeSessions.contains($0) }
.sorted { (lastUsed[$0] ?? .distantPast) < (lastUsed[$1] ?? .distantPast) }
guard let victim = candidates.first, let n = nodes[victim] else { break }
nodes[victim] = nil; lastUsed[victim] = nil
try? n.close()
}
}
}

View File

@@ -0,0 +1,56 @@
import SwiftUI
/// Form +
extension View {
func txRow(_ p: TXPalette) -> some View {
self.listRowBackground(p.surface).foregroundStyle(p.textPrimary)
}
}
///
struct TXTextField: View {
let title: String
@Binding var text: String
var placeholder: String = ""
var keyboard: UIKeyboardType = .default
var secure: Bool = false
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Group {
if secure {
SecureField(placeholder.isEmpty ? title : placeholder, text: $text)
} else {
TextField(placeholder.isEmpty ? title : placeholder, text: $text)
.keyboardType(keyboard)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
}
.foregroundStyle(p.textPrimary)
.txRow(p)
}
}
///
struct TXEmptyState: View {
let icon: String
let title: String
let subtitle: String
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
VStack(spacing: TX.Space.s) {
Image(systemName: icon)
.font(.system(size: 34, weight: .light))
.foregroundStyle(p.textTertiary)
Text(title).font(.system(size: 15)).foregroundStyle(p.textSecondary)
Text(subtitle).font(.system(size: 13)).foregroundStyle(p.textTertiary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
}
}

View File

@@ -0,0 +1,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/<>"`
///
/// GUIidb 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

View File

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

View File

@@ -115,4 +115,177 @@
### 10.3 待续UI
- 会话缩略卡区Moshi 首屏实时终端预览横滑需会话管理设置页字体/工具栏配置导航壳打磨终端工具栏 accent tint`inputAccessoryStyle` SwiftUI 未暴露需桥接)。
- 无头 UI 测试`idb ui tap x y`Python3.14 需事件循环 shim `/tmp/idbrun.py`坐标=屏幕像素×0.5,注意设备朝向)。诊断埋点(GKDBG/txDbg*)已清理MOSHDBG/TMUXDBG/M4DBG 仍在( cleanup-todo)。
## 11. Hosts / Keychain / Tailscale 三分离管理2026-07-24 本轮,对标 Termius
把连接拆成 **Hosts / Keychain(凭据) / Tailscale 连接** 三者分别管理iPad 优先设计阶段咨询 Fable 顾问Q1Q5 裁决)。**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/deletemeta 与机密分离) + `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` 统一构造 confighost 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.txtOFL-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 不占位 resizepinned 钉住各 resize 一次)。
- `TerminalSession` 加身份`id`/`host`/`isLive`( showsTerminal**不再驱动导航**)/`isMinimized`/`lastActiveAt`/`link`(LinkState 五态 phase+egress 推导)/`displayName`/`initials`/`isTmuxSession`。**TXCore传输层零改动**50 tests 保持绿)。
- `SessionManager`多实例持有`open(host:)` 复用同主机会话`focus`/`minimize`/`close`前后台广播、⌘19 索引首页排序最小化置顶 + 最近活跃)。
- `ContentView` `RootView(ZStack{SessionCanvas, HomeShell})`。**SessionCanvas 常驻挂载所有会话**详见 CLAUDE.md 摘除即丢内容 + matchedGeometry 要求同层级)。host key alert 下移到 `TerminalScreen`每会话一份后台会话也能弹安全告警)。
- 无头验证脚手架DEBUG`UIPreviewFixture` 假会话 + `terminalx://ui/*` 导航驱动 + `scripts/tx-shot.sh` 截图定向。****假会话必须 `isSyntheticFixture=true` 从生命周期广播里排除否则 scenePhaseactive 广播经 `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)`设计要求 200260ms面板 `.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会话行 48ptwindow 44pt实测值分组分隔线改**渐隐** LinearGradient占位分支补回漏掉的新建 window T对齐后与设计稿偏差 9ptwindow 区完全重合)。
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`**3c436/404pttmux 推荐仅断开 ⏎」(`detach-client`服务端继续跑) + 次要红字结束会话」(`kill-session`**再点一次才执行**)原生窗口 琥珀警告 + 危险按钮设计精确色 `#8d5560` / `rgba(92,58,63,.35)` / `#f5a0ac`)。`closeSession(kill:)` 先发 tmux 命令延迟 250ms teardown 传输层 `%exit` 走既有 `onExit`)。
- **分屏菜单 `SplitMenuView`**3b360pt**不问方向**(⌘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 菜单最小化往返内容原样日志无 crashTXCore 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.83macOS + 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>

View File

@@ -0,0 +1,241 @@
# Handoff: terminalX — iPadOS 终端 App沉浸轨道方案
## Overview
terminalX 是面向 iPadOS / iOS 的原生终端运维工具,解决 iPad 远程运维 macOS / Linux 的痛点。技术选型:**libghostty**(终端引擎)· **mosh**(抗断线传输)· **Tailscale tsnet**(用户态入网)· **tmux control mode**(会话复用)。平台优先级 **iPadOS > iOS > macOS(Apple Silicon)**
本包描述的是**终端主界面(沉浸轨道)+ 首页(主机管理)+ 设置**这一套已定稿的 UI含侧边栏三态、分屏模型、关闭/最小化语义、竖屏与 iPhone 降级规则。
## About the Design Files
`terminalX iPadOS.dc.html` 是**设计稿**,不是生产代码。它用 HTML/CSS 表达最终视觉与交互意图1:1 像素尺寸),目的是让你在**目标工程里用原生技术重建**——本项目的目标环境是 **SwiftUIiPadOS 26 / iOS 26**,请使用工程已有的架构与组件模式实现,不要移植 HTML/CSS。
打开方式:浏览器直接打开该文件即可,画板可自由缩放平移。每块屏幕都带 `data-screen-label`,便于对照本文档。
## Fidelity
**High-fidelityhifi**。颜色、字号、圆角、间距、行高均为最终值,可按本文档的精确数值实现。文案(简体中文,技术术语保留英文)也是最终稿。
唯一例外:终端内容区的命令输出是**示意数据**,用于验证排版密度与配色,不必照搬。
---
## Design Tokens
### 颜色 — App chromeNocturne 设计系统)
| 用途 | 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 Mochalibghostty 渲染)
`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 / 700chrome 与终端同源。中文走系统回退PingFang SC
| 场景 | 规格 |
| --- | --- |
| 屏幕主标题(首页 terminalX | 500 34px, letter-spacing -.025em |
| 页面标题(设置、总览) | 500 2428px, ls -.02em |
| 分区标题 | 500 22px, ls -.02em |
| 分组小标题(活动会话 / TMUX | 600 10px, ls .09em, uppercase, `#75798c` |
| 列表主行 | 400500 13px |
| 列表副行 / 元信息 | 400 10.511px, `#75798c` |
| 徽标 / 胶囊 | 500 11.512px |
| 终端正文(主 pane | 400 13.5px / 1.55 |
| 终端正文(次 pane / 缩略) | 400 1112px / 1.5 |
| 终端 pane 头 | 500 11px, `#6c7086` |
| 底部提示条 | 400 11.5px, `#75798c` |
### 间距 / 圆角 / 阴影
- 间距2 / 6 / 8 / 9 / 10 / 12 / 14 / 16 / 18 / 20 / 26 / 34 / 40px
- 圆角:徽标 57 · 控件 89 · 卡片 1113 · 对话框 1415 · 屏幕卡 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**(键盘附加行按键高 3840pt + 6pt 间距 = 44pt 节距)
- 终端主 pane 顶部预留 **86pt** 给浮起胶囊2c 因多一条横幅为 122pt预留带用 padding-top 实现,内容容器 `min-height:0; overflow:hidden` 防止向上溢出
- Home indicator 条200×4ptiPhone 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 gapwindow 行 = 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」两枚底部常驻单行键盘附加行。
- **iPhone393×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 系统行为) |
**动效**:状态切换 200260ms `easeOut`;侧边栏浮层从左滑入 + 遮罩淡入;最小化 = 终端缩回首页卡片matched geometry重连转圈 1s 线性循环。
**状态徽标规则(分层,只染出问题的那层)**
1. `tsnet` — 网络可达;断则本格转灰/琥珀
2. `mosh <RTT>` — 数字实时跳动;`≤120ms` 绿 `#a6e3a1``120200ms` 中性,`>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 MonoGoogle Fonts终端配色 Catppuccin

View File

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

1911
docs/design/support.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,102 @@
import Foundation
/// ** shell** control mode tmux
///
/// attach / /
/// control mode **** `tmux -CC new`
///
/// **PTY ** PTY
/// `"__TX""_SESSIONS_BEGIN"`shell
///
///
/// ** `tmux -V`**`tmux list-sessions` tmux
/// stderr 西 `2>/dev/null`
/// UI vs
public struct TmuxSessionProbe: Sendable {
public static let beginMark = "__TX_SESSIONS_BEGIN"
public static let endMark = "__TX_SESSIONS_END"
///
public static let sessionPrefix = "S"
/// `TmuxController.fieldSep` tab PTY 线
public static let fieldSep = "|:|"
/// shell `printf` `echo` shellfish/bash/zsh
///
/// **** PTY
/// 西 motd/shell
/// ANSI`[H` + `[2J` + `[3J` scrollback `clear` terminfo
public static var command: String {
let begin = #"printf '%s\n' "__TX""_SESSIONS_BEGIN""#
let end = #"printf '%s\n' "__TX""_SESSIONS_END""#
let version = "tmux -V 2>/dev/null"
let list = "tmux list-sessions -F '\(sessionPrefix)\(fieldSep)#{session_name}\(fieldSep)#{session_windows}\(fieldSep)#{session_attached}\(fieldSep)#{session_activity}' 2>/dev/null"
let clear = #"printf '\033[H\033[2J\033[3J'"#
return "\(begin); \(version); \(list); \(end); \(clear)\n"
}
///
public struct Session: Equatable, Sendable {
public let name: String
public let windows: Int
public let isAttached: Bool
/// `#{session_activity}`Unix N
public let lastActivity: Date?
public init(name: String, windows: Int, isAttached: Bool, lastActivity: Date? = nil) {
self.name = name
self.windows = windows
self.isAttached = isAttached
self.lastActivity = lastActivity
}
}
public enum Result: Equatable, Sendable {
/// tmux `sessions` UI /
case available(version: String, sessions: [Session])
/// tmux`tmux -V` +
case unavailable
}
private var buffer = ""
private var inBlock = false
private var version: String?
private var sessions: [Session] = []
private var done = false
public init() {}
/// END nil
public mutating func feed(_ text: String) -> Result? {
guard !done else { return nil }
buffer += text
// PTY CRLF Swift "\r\n" Character isNewline
while let idx = buffer.firstIndex(where: { $0.isNewline }) {
let raw = String(buffer[buffer.startIndex ..< idx])
buffer.removeSubrange(buffer.startIndex ... idx)
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !inBlock {
if line == Self.beginMark { inBlock = true }
continue
}
if line == Self.endMark {
done = true
guard let version else { return .unavailable }
return .available(version: version, sessions: sessions)
}
if line.hasPrefix(Self.sessionPrefix + Self.fieldSep) {
if let s = Self.parseSession(line) { sessions.append(s) }
} else if line.hasPrefix("tmux ") {
version = line // tmux 3.7b
}
}
return nil
}
/// `S|:|name|:|windows|:|attached|:|activity` Session
static func parseSession(_ line: String) -> Session? {
let f = line.components(separatedBy: fieldSep)
guard f.count >= 4, f[0] == sessionPrefix, !f[1].isEmpty, let w = Int(f[2]) else { return nil }
let activity = f.count >= 5 ? Double(f[4]).map { Date(timeIntervalSince1970: $0) } : nil
return Session(name: f[1], windows: w, isAttached: f[3] == "1", lastActivity: activity)
}
}

View File

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

View File

@@ -102,6 +102,30 @@ public final class SSHSession: Transport, @unchecked Sendable {
// MARK: - Connect / Handshake
/// TCP connect connect `poll` SO_ERROR
/// `Darwin.connect` TCP 75s+UI
private static func connectWithTimeout(_ fd: Int32, _ addr: UnsafeMutablePointer<sockaddr>,
_ len: socklen_t, seconds: Int) -> Bool {
let flags = fcntl(fd, F_GETFL, 0)
guard flags >= 0, fcntl(fd, F_SETFL, flags | O_NONBLOCK) >= 0 else {
return Darwin.connect(fd, addr, len) == 0 // flags 退
}
defer { _ = fcntl(fd, F_SETFL, flags) } // libssh2
if Darwin.connect(fd, addr, len) == 0 { return true }
guard errno == EINPROGRESS else { return false }
var pfd = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0)
let ready = withUnsafeMutablePointer(to: &pfd) { poll($0, 1, Int32(seconds * 1000)) }
guard ready == 1, pfd.revents & Int16(POLLOUT) != 0 else { return false }
// SO_ERROR fd
var soErr: Int32 = 0
var soLen = socklen_t(MemoryLayout<Int32>.size)
guard getsockopt(fd, SOL_SOCKET, SO_ERROR, &soErr, &soLen) == 0, soErr == 0 else { return false }
return true
}
private func connect() throws {
let fd: Int32
if preconnectedFD >= 0 {
@@ -124,12 +148,16 @@ public final class SSHSession: Transport, @unchecked Sendable {
while let addr = node {
dialed = socket(addr.pointee.ai_family, addr.pointee.ai_socktype, addr.pointee.ai_protocol)
if dialed >= 0 {
if Darwin.connect(dialed, addr.pointee.ai_addr, addr.pointee.ai_addrlen) == 0 { break }
if Self.connectWithTimeout(dialed, addr.pointee.ai_addr,
addr.pointee.ai_addrlen,
seconds: config.connectTimeoutSec) { break }
close(dialed); dialed = -1
}
node = addr.pointee.ai_next
}
guard dialed >= 0 else { throw SSHError.socket("连接失败: \(config.host):\(config.port)") }
guard dialed >= 0 else {
throw SSHError.socket("连接失败(\(config.connectTimeoutSec)s 超时): \(config.host):\(config.port)")
}
fd = dialed
}

View File

@@ -35,6 +35,9 @@ public struct SSHConfig: Sendable {
/// trusted firstUse pinfalse=mismatch SSHSession hostKeyMismatch
/// nil /pin UI app
public var hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)?
/// TCP connect ****`Darwin.connect` TCP 75s+
/// tsnet preconnectedFD
public var connectTimeoutSec: Int
public enum Authentication: Sendable {
case password(String)
@@ -51,9 +54,11 @@ public struct SSHConfig: Sendable {
terminalType: String = "xterm-256color",
initialCols: UInt16 = 80,
initialRows: UInt16 = 24,
hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)? = nil
hostKeyVerifier: (@Sendable (Data, Int32) -> Bool)? = nil,
connectTimeoutSec: Int = 12
) {
self.host = host
self.connectTimeoutSec = connectTimeoutSec
self.port = port
self.username = username
self.authentication = authentication

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

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