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

@@ -0,0 +1,97 @@
import SwiftUI
/// 顶层去处。设计定稿只有两处落点:首页(主机管理)与终端。
///
/// 关键:路由与**会话生命周期解耦** —— 回首页/最小化只改 route,会话照跑
/// (会话 surface 由 `SessionCanvas` 常驻挂载,见 ContentView)。
enum AppRoute: Equatable {
case home
case terminal(UUID)
var sessionID: UUID? {
if case .terminal(let id) = self { return id }
return nil
}
}
/// 侧边栏三态。同一条栏,元素不增不减,只是展示密度不同。
enum SidebarState: Equatable {
/// 56pt 轨道:只显图标与两字缩写
case collapsed
/// 288pt 面板**盖住**轨道,右侧终端压遮罩;点终端任意处收起。终端不 reflow、不触发 PTY resize。
case overlay
/// 288pt 钉在左侧:遮罩消失,终端变窄。**只在钉住/解除时各 resize 一次**。
case pinned
var isExpanded: Bool { self != .collapsed }
/// 该状态下侧边栏占据的布局宽度(overlay 是浮层,不占位)。
var layoutWidth: CGFloat {
switch self {
case .collapsed, .overlay: TX.Layout.railWidth
case .pinned: TX.Layout.sidebarWidth
}
}
}
/// 终端页上的模态浮层。放在 router 而不是视图 `@State`:⌘W/⌘D 快捷键与无头 URL 驱动
/// 都要能打开它,视图内部状态触达不到。
enum AppModal: Equatable {
/// 关闭确认(3c)
case closeConfirm
/// 分屏菜单(3b)。`vertical` = 竖分(⌘D)/ 横分(⌘⇧D)
case splitMenu(vertical: Bool)
}
/// 导航状态持有者。视图只读它、只经方法改它。
@MainActor
final class AppRouter: ObservableObject {
@Published var route: AppRoute = .home
@Published var sidebar: SidebarState = .collapsed
/// 当前模态浮层(关闭确认 / 分屏菜单)。
@Published var modal: AppModal?
/// 状态切换动效:设计定稿 200–260ms easeOut(侧边栏浮层从左滑入 + 遮罩淡入)。
/// 所有侧边栏状态变更都经这里,键盘快捷键与点击拿到同一条曲线。
static let sidebarAnimation = Animation.easeOut(duration: 0.24)
private func animateSidebar(_ change: () -> Void) {
withAnimation(Self.sidebarAnimation, change)
}
/// 进入某会话(点会话卡 / 侧边栏会话行 / 新建连接后)。恢复焦点由会话自身持有。
func enter(_ sessionID: UUID) {
route = .terminal(sessionID)
}
/// 回首页:会话完全不动(⌘⇧H / 侧边栏首页行)。
func goHome() {
modal = nil
route = .home
if sidebar == .overlay { animateSidebar { sidebar = .collapsed } }
}
/// 开合侧边栏(⌘\ / 开合键)。折叠 ⇄ 遮罩;Pin 态视作展开,收起回折叠。
func toggleSidebar() {
animateSidebar {
switch sidebar {
case .collapsed: sidebar = .overlay
case .overlay, .pinned: sidebar = .collapsed
}
}
}
/// Pin 切换(只在展开态出现)。钉住/解除各触发一次终端 resize。
func togglePin() {
animateSidebar { sidebar = sidebar == .pinned ? .overlay : .pinned }
}
/// 打开/关闭模态浮层(红点、⌘W、⌘D、⌘⇧D 与无头 URL 共用同一入口)。
func present(_ modal: AppModal) { self.modal = modal }
func dismissModal() { modal = nil }
/// 点终端任意处:遮罩态收起(Pin 态不受影响)。
func dismissOverlaySidebar() {
if sidebar == .overlay { animateSidebar { sidebar = .collapsed } }
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 {
// 旧 schema:inline username/password → inlinePassword(不自动造幽灵凭据)。
let user = try c.decodeIfPresent(String.self, forKey: .username) ?? ""
let pw = try c.decodeIfPresent(String.self, forKey: .password) ?? ""
self.auth = .inlinePassword(username: user, password: pw)
}
}
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(id, forKey: .id)
try c.encode(name, forKey: .name)
try c.encode(host, forKey: .host)
try c.encode(port, forKey: .port)
try c.encode(useMosh, forKey: .useMosh)
try c.encode(useTmux, forKey: .useTmux)
try c.encodeIfPresent(tmuxSession, forKey: .tmuxSession)
try c.encode(auth, forKey: .auth)
try c.encodeIfPresent(tailscaleID, forKey: .tailscaleID)
try c.encodeIfPresent(group, forKey: .group)
try c.encodeIfPresent(os, forKey: .os)
}
}
/// 主机列表持久化(Application Support 下 JSON)。凭据不入仓库;此文件只在设备本地。
@@ -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 ID);SSH 密钥支持 粘贴 / 导入 / 生成。
struct CredentialEditorView: View {
let existing: Credential?
@ObservedObject var store: FileCredentialStore
let onSave: (Credential) -> Void
@EnvironmentObject var theme: TXThemeManager
@Environment(\.dismiss) private var dismiss
private enum Kind: Hashable { case password, sshKey, faceID }
private enum KeySource: Hashable { case generate, paste, importFile }
@State private var name = ""
@State private var username = ""
@State private var kind: Kind = .password
@State private var password = ""
@State private var keySource: KeySource = .generate
@State private var keyText = ""
@State private var passphrase = ""
@State private var parsed: CredentialAuth.ParsedKey?
// 生成结果(暂存直到保存)
@State private var generatedKind: Credential.Kind?
@State private var generatedSecret: CredentialSecret?
@State private var generatedFingerprint = ""
@State private var generatedAuthLine = ""
@State private var showFileImporter = false
@State private var importError: String?
private let seAvailable = SigningKeyProvider.secureEnclaveAvailable()
private let isEditing: Bool
private var p: TXPalette { theme.palette }
init(existing: Credential?, store: FileCredentialStore, onSave: @escaping (Credential) -> Void) {
self.existing = existing
self.store = store
self.onSave = onSave
self.isEditing = existing != nil
}
private var canSave: Bool {
guard !username.isEmpty else { return false }
switch kind {
case .password: return true
case .sshKey:
if isEditing { return true } // 编辑时允许只改名/用户名
switch keySource {
case .generate: return generatedSecret != nil
case .paste, .importFile: return !keyText.isEmpty
}
case .faceID: return false
}
}
var body: some View {
NavigationStack {
ZStack {
p.bg.ignoresSafeArea()
Form {
Section("基本") {
TXTextField(title: "名称", text: $name, placeholder: "我的密钥")
TXTextField(title: "用户名", text: $username, placeholder: "登录服务器的用户名")
}
Section("类型") {
Picker("类型", selection: $kind) {
Text("密码").tag(Kind.password)
Text("SSH 密钥").tag(Kind.sshKey)
Text("Face ID").tag(Kind.faceID)
}
.pickerStyle(.segmented)
.txRow(p)
}
switch kind {
case .password: passwordSection
case .sshKey: sshKeySection
case .faceID: faceIDSection
}
}
.scrollContentBackground(.hidden)
}
.navigationTitle(isEditing ? "编辑凭据" : "添加凭据")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }.tint(p.textSecondary)
}
ToolbarItem(placement: .confirmationAction) {
Button("保存") { save() }.disabled(!canSave).tint(p.accent)
}
}
.toolbarBackground(p.bg, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.data, .text]) { result in
handleImport(result)
}
.onAppear(perform: populate)
}
.tint(p.accent)
}
// MARK: - 各类型区块
@ViewBuilder private var passwordSection: some View {
Section("密码") {
TXTextField(title: "密码", text: $password, secure: true)
}
}
@ViewBuilder private var sshKeySection: some View {
Section("SSH 私钥") {
Picker("来源", selection: $keySource) {
Text("生成").tag(KeySource.generate)
Text("粘贴").tag(KeySource.paste)
Text("导入").tag(KeySource.importFile)
}
.pickerStyle(.segmented)
.txRow(p)
switch keySource {
case .generate: generateContent
case .paste: pasteContent
case .importFile: importContent
}
}
}
@ViewBuilder private var generateContent: some View {
if generatedSecret == nil {
Button {
generate()
} label: {
Label("生成 ECDSA P-256 密钥", systemImage: "sparkles")
}
.txRow(p)
} else {
VStack(alignment: .leading, spacing: 6) {
Text("已生成 ECDSA P-256").font(.system(size: 13, weight: .medium)).foregroundStyle(p.accent)
Text("公钥指纹:\(generatedFingerprint)").font(TX.Font.mono(11)).foregroundStyle(p.textSecondary)
Text("authorized_keys(复制到服务器):").font(.system(size: 12)).foregroundStyle(p.textSecondary)
Text(generatedAuthLine).font(TX.Font.mono(10)).foregroundStyle(p.textPrimary)
.textSelection(.enabled).lineLimit(3)
Button { UIPasteboard.general.string = generatedAuthLine } label: {
Label("复制 authorized_keys 行", systemImage: "doc.on.doc")
}
.font(.system(size: 13)).padding(.top, 4)
}
.txRow(p)
}
}
@ViewBuilder private var pasteContent: some View {
VStack(alignment: .leading, spacing: 6) {
Text("粘贴 PEM / PKCS#8 私钥内容").font(.system(size: 12)).foregroundStyle(p.textSecondary)
TextEditor(text: $keyText)
.font(TX.Font.mono(11)).frame(minHeight: 120)
.foregroundStyle(p.textPrimary).scrollContentBackground(.hidden)
.background(p.bg, in: RoundedRectangle(cornerRadius: 8))
.onChange(of: keyText) { _, t in parsed = CredentialAuth.parsePrivateKey(t, source: .pasted) }
detectionLabel
}
.txRow(p)
if parsedNeedsPassphrase {
TXTextField(title: "私钥口令(passphrase)", text: $passphrase, secure: true)
}
}
@ViewBuilder private var importContent: some View {
VStack(alignment: .leading, spacing: 6) {
Button { showFileImporter = true } label: {
Label("从文件导入私钥", systemImage: "folder")
}
if let e = importError {
Text(e).font(.system(size: 12)).foregroundStyle(.red)
}
if !keyText.isEmpty { detectionLabel }
}
.txRow(p)
if parsedNeedsPassphrase {
TXTextField(title: "私钥口令(passphrase)", text: $passphrase, secure: true)
}
}
@ViewBuilder private var faceIDSection: some View {
Section {
HStack {
Image(systemName: "faceid").foregroundStyle(seAvailable ? p.accent : p.textTertiary)
Text(seAvailable ? "Face ID 密钥(Secure Enclave)" : "Face ID 密钥(需签名构建)")
.foregroundStyle(seAvailable ? p.textPrimary : p.textTertiary)
}
.txRow(p)
} footer: {
Text(seAvailable
? "私钥生成于 Secure Enclave,不可导出,签名时经 Face ID 授权。"
: "当前构建未签名,Secure Enclave 不可用。签名真机构建后此选项自动可用。")
}
}
@ViewBuilder private var detectionLabel: some View {
if let parsed {
VStack(alignment: .leading, spacing: 3) {
Text("检测到:\(parsed.formatLabel)").font(.system(size: 12)).foregroundStyle(p.accent)
if let w = parsed.warning {
Text(w).font(.system(size: 11)).foregroundStyle(p.warning)
}
}
}
}
private var parsedNeedsPassphrase: Bool {
if case .privateKey(_, _, let hasPass, _) = parsed?.kind { return hasPass }
return false
}
// MARK: - 动作
private func generate() {
guard let (k, s) = CredentialAuth.generateECDSAP256() else { return }
generatedKind = k; generatedSecret = s
if case .privateKey(_, _, _, let blob) = k {
generatedAuthLine = "ecdsa-sha2-nistp256 \(blob.base64EncodedString()) \(username.isEmpty ? "terminalx" : username)@terminalx"
generatedFingerprint = HostKey.opensshFingerprint(blob)
}
}
private func handleImport(_ result: Result<URL, Error>) {
switch result {
case .failure(let e): importError = e.localizedDescription
case .success(let url):
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
guard let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) else {
importError = "无法读取文件(需 UTF-8 文本私钥)"; return
}
importError = nil
keyText = text
parsed = CredentialAuth.parsePrivateKey(text, source: .imported)
}
}
private func populate() {
guard let c = existing else { return }
name = c.name; username = c.username
switch c.kind {
case .password:
kind = .password
if case .password(let pw)? = store.secret(for: c.id) { password = pw }
case .privateKey:
kind = .sshKey; keySource = .generate // 编辑既有密钥仅改名/用户名
case .secureEnclave:
kind = .faceID
}
}
private func save() {
let credID = existing?.id ?? UUID()
var credKind: Credential.Kind
var secret: CredentialSecret?
switch kind {
case .password:
credKind = .password
secret = .password(password)
case .sshKey:
if isEditing, let c = existing, case .privateKey = c.kind {
credKind = c.kind; secret = nil // 保留原机密,仅改 meta
} else if keySource == .generate, let gk = generatedKind, let gs = generatedSecret {
credKind = gk; secret = gs
} else {
let parsedKey = parsed ?? CredentialAuth.parsePrivateKey(keyText, source: keySource == .paste ? .pasted : .imported)
// 注入用户填写的 passphrase
if case .privateKey(let t, let src, _, let blob) = parsedKey.kind {
credKind = .privateKey(keyType: t, source: src, hasPassphrase: !passphrase.isEmpty, publicKeyBlob: blob)
} else {
credKind = parsedKey.kind
}
secret = .pemPrivateKey(pem: Data(keyText.utf8), passphrase: passphrase.isEmpty ? nil : passphrase)
}
case .faceID:
return
}
let cred = Credential(id: credID, name: name.isEmpty ? username : name,
username: username, kind: credKind,
createdAt: existing?.createdAt ?? Date())
store.upsert(cred, secret: secret)
onSave(cred)
dismiss()
}
}

View File

@@ -0,0 +1,529 @@
import SwiftUI
// 设计稿 `1b 终端-沉浸轨道` 的左侧 56pt 轨道 + `3a 侧边栏-遮罩态/Pin 态` 的 288pt 面板。
//
// **开合语义(唯一、不歧义)**:`›` 方块是 App logo,不可点。开合只有一枚按钮,两态方向相反——
// 折叠态在轨道底部(»展开),展开态在标题右侧(«收起)。Pin 是独立按钮,只在展开态出现。
// 三态元素不增不减,只是展示密度不同。
/// 折叠轨道(56pt,竖屏 48pt)。元素中心线统一在轨道正中。
struct RailView: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
/// 当前会话(TMUX 段列它的 window)。
@ObservedObject var session: TerminalSession
let onNewHost: () -> Void
let onSettings: () -> Void
@EnvironmentObject var theme: TXThemeManager
@Environment(\.horizontalSizeClass) private var hSize
private var p: TXPalette { theme.palette }
private var width: CGFloat {
hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth
}
private var avatarSize: CGFloat { hSize == .compact ? 34 : 38 }
// 纵向节奏取自设计稿 `1b` 的实测坐标(1366×1024 画板扫描):
// logo 28..62 · 搜索 75..109 · 首页 118..152 · 分隔 160 · 头像 174/220/266(间距 8)
// · 虚线+ 313..351 · 分隔 370 · TMUX 标签 382 · window 数字 408 起(间距 7)
// · 底部自下而上:齿轮 985..1019 · 展开键 932..967 · 状态点 914。
var body: some View {
VStack(spacing: 0) {
logo
.padding(.top, 28)
.padding(.bottom, 13)
// 搜索 / 首页 / 设置在设计稿里是**裸图标**(无底无框),只有 logo 与展开键有框。
TXIconButton(icon: "magnifyingglass", bare: true, hitSize: 34, label: "搜索") {
router.toggleSidebar()
}
.padding(.bottom, 9)
TXIconButton(icon: "house", bare: true, hitSize: 34, label: "首页") { router.goHome() }
.padding(.bottom, 8)
divider
.padding(.bottom, 13)
hostAvatars
newHostButton
divider
.padding(.top, 9)
.padding(.bottom, 12)
tmuxSection
Spacer(minLength: TX.Space.s)
TXStatusDot(color: linkColor, punchOut: p.surfaceDark)
.padding(.bottom, 11)
// 折叠态的开合键:方向朝右(展开)。设计稿里这一枚**有框**。
TXIconButton(icon: "chevron.right.2", hitSize: 34, label: "展开侧边栏") {
router.toggleSidebar()
}
.padding(.bottom, 18)
TXIconButton(icon: "gearshape", bare: true, hitSize: 34, label: "设置", action: onSettings)
.padding(.bottom, 5)
}
.frame(width: width)
.background(p.surfaceDark)
}
/// App logo(`›`)。**不可点** —— 开合是另一枚按钮的职责。
private var logo: some View {
Text("›")
.font(TX.Font.mono(16, .bold))
.foregroundStyle(TXAccent.text)
.frame(width: 34, height: 34)
.background(p.surface, in: RoundedRectangle(cornerRadius: 11))
.overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border, lineWidth: 1))
.accessibilityLabel("terminalX")
}
private var divider: some View {
Rectangle()
.fill(p.divider)
.frame(width: 24, height: 1)
}
private var hostAvatars: some View {
VStack(spacing: 8) {
ForEach(manager.activeSessions) { s in
Button {
manager.focus(s)
router.enter(s.id)
} label: {
TXInitialsAvatar(initials: s.initials,
size: avatarSize,
isSelected: router.route.sessionID == s.id,
status: RailSidebarStyle.statusColor(s, p),
punchOut: p.surfaceDark)
}
.buttonStyle(.plain)
}
}
}
/// 虚线 + 号:在当前主机之外新开会话。
private var newHostButton: some View {
Button(action: onNewHost) {
Image(systemName: "plus")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(p.textWeak)
.frame(width: avatarSize, height: avatarSize)
.background(
RoundedRectangle(cornerRadius: 11)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
.foregroundStyle(p.border)
)
}
.buttonStyle(.plain)
.padding(.top, 9)
}
/// TMUX 段:标签 + window 数字。无 tmux 的主机换成「窗口」段(客户端窗口)。
private var tmuxSection: some View {
VStack(spacing: 7) {
Text(session.tmuxSummary == nil ? "窗口" : "TMUX")
.font(TX.Font.mono(9, .semibold))
.tracking(0.9)
// 设计稿:轨道的 TMUX 段标签用 accent-500(弱化强调),不是中性灰。
.foregroundStyle(TXAccent.mid)
.padding(.bottom, 9)
if let tmux = session.tmuxController {
ForEach(tmux.windows) { win in
Button { tmux.selectWindow(win.id) } label: {
windowNumber("\(win.index)", isActive: win.id == tmux.activeWindowID)
}
.buttonStyle(.plain)
}
} else {
// 还没 attach(或假会话)时按概览里的 window 数占位,保持轨道高度稳定。
ForEach(0 ..< max(1, session.tmuxSummary?.windows ?? 1), id: \.self) { i in
windowNumber("\(i)", isActive: i == 0)
}
}
}
}
/// window 数字格(34×30,选中态填控件底 + 中等描边)。
private func windowNumber(_ text: String, isActive: Bool) -> some View {
Text(text)
.font(TX.Font.mono(12, isActive ? .semibold : .regular))
.foregroundStyle(isActive ? p.text : p.text4)
.frame(width: 34, height: 30)
.background(isActive ? p.surface : .clear, in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(isActive ? p.borderDialog : .clear, lineWidth: 1)
)
}
private var linkColor: Color { RailSidebarStyle.statusColor(session, p) }
}
/// 轨道与面板共用的状态色映射。
@MainActor
enum RailSidebarStyle {
static func statusColor(_ s: TerminalSession, _ p: TXPalette) -> Color {
switch s.link {
case .direct, .relay: s.isMinimized ? TXAccent.mid : p.online
case .reconnecting: p.reconnecting
case .connecting: p.text4
case .offline: p.offline
}
}
}
// MARK: - 288pt 侧边栏面板
/// 展开态面板(遮罩 / Pin 共用同一套内容)。
///
/// 行规格:会话行 = 26pt 头像列 + 10pt gap;window 行 = 26×22pt 徽标列 + 10pt gap
/// → 两者标题左缘对齐。选中态用 **inset 描边**(`strokeBorder`)而非 border,避免内容位移 1pt。
struct SidebarPanel: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var session: TerminalSession
@ObservedObject var tailscaleStore: TailscaleStore
let onNewHost: () -> Void
let onSettings: () -> Void
@Binding var search: String
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
TXSearchField(text: $search, placeholder: "搜索主机 · 会话")
.padding(.horizontal, TX.Space.m)
.padding(.bottom, TX.Space.s)
homeRow
sessionsSection
// 设计:1pt **渐隐**分隔(两端淡出,不是通栏实线)。
LinearGradient(colors: [p.divider.opacity(0), p.divider, p.divider.opacity(0)],
startPoint: .leading, endPoint: .trailing)
.frame(height: 1)
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.s)
tmuxSection
Spacer(minLength: 0)
tailscaleFoot
}
.frame(width: TX.Layout.sidebarWidth)
.background(p.surfaceDark)
}
private var header: some View {
HStack(spacing: TX.Space.s) {
Text("›")
.font(TX.Font.mono(15, .bold))
.foregroundStyle(TXAccent.text)
.frame(width: 30, height: 30)
.background(p.surface, in: RoundedRectangle(cornerRadius: 9))
.overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.border, lineWidth: 1))
Text("terminalX")
.font(TX.Font.mono(14, .medium))
.foregroundStyle(p.text)
Spacer()
// Pin 只在展开态出现;钉住时高亮。
TXIconButton(icon: "pin", size: 28, isSelected: router.sidebar == .pinned,
label: "钉在左侧") { router.togglePin() }
// 展开态的开合键:方向朝左(收起)。
TXIconButton(icon: "chevron.left.2", size: 28, label: "收起") { router.toggleSidebar() }
}
.padding(.horizontal, TX.Space.m)
.padding(.top, 11)
.padding(.bottom, 6)
}
private var homeRow: some View {
Button { router.goHome() } label: {
HStack(spacing: TX.Space.s) {
Image(systemName: "house")
.font(.system(size: 13))
.foregroundStyle(p.text2)
.frame(width: 26)
Text("首页")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Spacer()
Text("⌘⇧H")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
}
.padding(.horizontal, TX.Space.m)
.frame(height: 36)
}
.buttonStyle(.plain)
}
private var sessionsSection: some View {
VStack(alignment: .leading, spacing: 2) {
TXSectionLabel(text: "活动会话", trailing: "\(manager.sessions.count)")
.padding(.horizontal, TX.Space.m)
.padding(.top, TX.Space.s)
.padding(.bottom, 2)
if manager.activeSessions.isEmpty {
Text("没有在跑的会话")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.padding(.leading, 53)
.frame(height: 32, alignment: .leading)
}
ForEach(manager.activeSessions) { s in
SidebarSessionRow(session: s, isSelected: router.route.sessionID == s.id) {
manager.focus(s)
router.enter(s.id)
}
}
Button(action: onNewHost) {
Text("连接新主机…")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
.padding(.leading, 53) // 与会话行标题左缘对齐
.frame(height: 32, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
}
}
private var tmuxSection: some View {
VStack(alignment: .leading, spacing: 2) {
TXSectionLabel(text: tmuxHeader)
.padding(.horizontal, TX.Space.m)
.padding(.bottom, 2)
if let tmux = session.tmuxController {
ForEach(tmux.windows) { win in
SidebarWindowRow(window: win,
isSelected: win.id == tmux.activeWindowID) {
tmux.selectWindow(win.id)
}
}
newWindowRow { tmux.newWindow() }
} else if let summary = session.tmuxSummary {
// 已知是 tmux 会话但 controller 还没 attach:按概览占位,避免与分组标题矛盾。
ForEach(0 ..< max(1, summary.windows), id: \.self) { i in
placeholderWindowRow(
index: i,
title: i == 0 && !summary.currentTitle.isEmpty ? summary.currentTitle : "window \(i)",
detail: i == 0 && summary.panes > 0
? "\(summary.panes) pane\(summary.panes == 1 ? "" : "s")" : "",
isSelected: i == 0)
}
newWindowRow {}
} else {
Text("无 tmux · 客户端窗口")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.xs)
}
}
}
/// 「新建 window ⌘T」行(左缘与 window 行标题对齐 x=53)。
private func newWindowRow(_ action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
Text("新建 window")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
Spacer()
Text("⌘T")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
}
.padding(.leading, 53)
.padding(.trailing, TX.Space.m)
.frame(height: 32)
}
.buttonStyle(.plain)
}
/// controller 未就绪时的 window 行占位(视觉与 SidebarWindowRow 一致)。
private func placeholderWindowRow(index: Int, title: String, detail: String,
isSelected: Bool) -> some View {
HStack(spacing: TX.Space.s) {
Text("\(index)")
.font(TX.Font.mono(11, .semibold))
.foregroundStyle(isSelected ? TXAccent.text : p.text4)
.frame(width: 26, height: 22)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: 5))
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(TX.Font.mono(13, isSelected ? .medium : .regular))
.foregroundStyle(isSelected ? p.text : p.text2)
.lineLimit(1)
if !detail.isEmpty {
Text(detail)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
}
}
Spacer(minLength: 0)
if isSelected { TXStatusDot(color: p.online, size: 7) }
}
.padding(.horizontal, TX.Space.s)
.frame(height: 44)
.background(isSelected ? TXAccent.selectedBg.opacity(0.75) : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.padding(.horizontal, TX.Space.s)
}
private var tmuxHeader: String {
let host = session.displayName
if let t = session.tmuxSummary, !t.name.isEmpty { return "TMUX · \(host) · \(t.name)" }
return session.tmuxSummary == nil ? "窗口 · \(host)" : "TMUX · \(host)"
}
/// 面板脚:tailnet 状态(绿点 + tailnet + 设备 IP)。
///
/// **与舞台底部提示条同高**(都是 `TX.Layout.footHeight`),故两侧的分割线落在同一条 y 上;
/// 分割线用 1pt divider(设计稿如此),不加底色块、不加阴影。
private var tailscaleFoot: some View {
VStack(spacing: 0) {
Rectangle().fill(p.divider).frame(height: 1)
HStack(spacing: TX.Space.s) {
TXStatusDot(color: tailscaleStore.connections.isEmpty ? p.offline : p.online)
VStack(alignment: .leading, spacing: 1) {
Text(tailscaleStore.connections.isEmpty ? "Tailscale 未配置" : "Tailscale 已连接")
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(p.text2)
if let c = tailscaleStore.connections.first {
// 设计:tailnet 内主机名 + 本机 tailnet IP(Up 后缓存的 SelfIP)。
Text([c.hostname, c.lastSelfIP].compactMap { $0 }.joined(separator: " · "))
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
}
Spacer(minLength: 0)
}
.padding(.horizontal, TX.Space.m)
.frame(height: TX.Layout.footHeight)
}
}
}
/// 侧边栏会话行:26pt 头像 + 名称·window title + 右侧链路/重连标。
struct SidebarSessionRow: View {
@ObservedObject var session: TerminalSession
let isSelected: Bool
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Button(action: onTap) {
HStack(spacing: TX.Space.s) {
TXInitialsAvatar(initials: session.initials, size: 26,
isSelected: isSelected,
status: RailSidebarStyle.statusColor(session, p),
punchOut: isSelected ? TXAccent.selectedBg : p.surfaceDark)
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
.lineLimit(1)
Text(subtitle)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
Spacer(minLength: TX.Space.xs)
trailingTag
}
.padding(.horizontal, TX.Space.s)
.frame(height: 48)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
// inset 描边而非 border:选中时内容不位移 1pt。
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.padding(.horizontal, TX.Space.s)
}
.buttonStyle(.plain)
}
private var title: String {
if let t = session.tmuxSummary, !t.name.isEmpty {
return "\(session.displayName) · \(t.name)"
}
return session.displayName
}
private var subtitle: String {
if session.isMinimized { return "最小化中" }
guard let t = session.tmuxSummary else { return "原生窗口" }
let title = t.currentTitle.isEmpty ? session.phaseText : t.currentTitle
return "\(title) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
}
@ViewBuilder private var trailingTag: some View {
switch session.link {
case .reconnecting:
Text("重连中").font(TX.Font.mono(10.5, .medium)).foregroundStyle(p.reconnecting)
case .offline:
Text("离线").font(TX.Font.mono(10.5)).foregroundStyle(p.offline)
default:
EmptyView()
}
}
}
/// 侧边栏 window 行:26×22pt 编号徽标 + title + cwd/panes。
struct SidebarWindowRow: View {
@ObservedObject var window: TmuxWindow
let isSelected: Bool
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var subtitle: String {
var parts: [String] = []
if !window.cwd.isEmpty { parts.append(TerminalTitleCapsule.abbreviate(window.cwd)) }
let n = window.panes.count
if n > 1 { parts.append("\(n) panes") }
return parts.isEmpty ? "1 pane" : parts.joined(separator: " · ")
}
var body: some View {
Button(action: onTap) {
HStack(spacing: TX.Space.s) {
Text("\(window.index)")
.font(TX.Font.mono(11, .semibold))
.foregroundStyle(isSelected ? TXAccent.text : p.text4)
.frame(width: 26, height: 22)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: 5))
VStack(alignment: .leading, spacing: 1) {
Text(window.title)
.font(TX.Font.mono(13, isSelected ? .medium : .regular))
.foregroundStyle(isSelected ? p.text : p.text2)
.lineLimit(1)
// 设计:cwd · n panes(cwd 来自 #{pane_current_path})
Text(subtitle)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
Spacer(minLength: 0)
if isSelected {
TXStatusDot(color: p.online, size: 7)
}
}
.padding(.horizontal, TX.Space.s)
.frame(height: 44)
.background(isSelected ? TXAccent.selectedBg.opacity(0.75) : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.padding(.horizontal, TX.Space.s)
}
.buttonStyle(.plain)
}
}

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

View File

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

View File

@@ -0,0 +1,206 @@
import SwiftUI
import TXCore
/// 连接前的会话选择器(连接流程的一部分):点主机 → 探测服务器 → 问「接回哪个会话」,选完才进终端。
///
/// 三种连接方式:**接回已有 tmux 会话** · **新建 tmux 会话** · **原生终端**(不经 tmux)。
/// 服务器**没装 tmux 时不显示本 sheet** —— 直接连原生终端,不拿一个只有一个选项的框去打扰用户。
///
/// 措辞注意:右上不是「跳过」(那读起来像"跳过某个步骤",含义模糊)——它是一种**并列的连接方式**,
/// 所以直接叫「原生终端」。分段控件里 `Herdr` / `最近` 暂时置灰(后续支持),位置先占住避免重排。
struct SessionPickerSheet: View {
/// 正在连接的主机名(标题里点明"连到哪台",避免多主机时看不出上下文)。
let host: String
let choice: TmuxSessionChoice
let onAttach: (String) -> Void
let onCreate: (String) -> Void
let onNative: () -> Void
@EnvironmentObject var theme: TXThemeManager
@State private var newName = ""
@State private var creating = false
@FocusState private var nameFocused: Bool
private var p: TXPalette { theme.palette }
var body: some View {
VStack(alignment: .leading, spacing: TX.Space.m) {
grabber
title
header
if choice.sessions.isEmpty {
emptyState
} else {
sessionList
}
newSessionRow
Spacer(minLength: 0)
}
.padding(.horizontal, TX.Space.l)
.padding(.bottom, TX.Space.l)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(p.bg.ignoresSafeArea())
}
private var title: some View {
VStack(alignment: .leading, spacing: 3) {
Text("连接到 \(host)")
.font(TX.Font.mono(17, .medium))
.foregroundStyle(p.text)
Text("接回一个 tmux 会话,或直接用原生终端")
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
}
}
private var grabber: some View {
RoundedRectangle(cornerRadius: 2.5)
.fill(p.borderStrong)
.frame(width: 44, height: 5)
.frame(maxWidth: .infinity)
.padding(.top, TX.Space.s)
}
// MARK: - 分段控件 + 跳过
private var header: some View {
HStack(spacing: TX.Space.s) {
HStack(spacing: 2) {
segment(icon: "chevron.left.forwardslash.chevron.right", title: "Tmux", enabled: true)
segment(icon: "shippingbox", title: "Herdr", enabled: false)
segment(icon: "clock.arrow.circlepath", title: "最近", enabled: false)
}
.padding(4)
.background(p.surface, in: Capsule())
Spacer(minLength: TX.Space.s)
Button(action: onNative) {
HStack(spacing: TX.Space.xs) {
Image(systemName: "terminal")
.font(.system(size: 13, weight: .medium))
Text("原生终端")
.font(TX.Font.mono(15, .medium))
}
.foregroundStyle(p.text)
.padding(.horizontal, TX.Space.m)
.frame(height: 44)
.background(p.surface, in: Capsule())
.overlay(Capsule().strokeBorder(p.border, lineWidth: 1))
}
.buttonStyle(.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 key(TOFU pin)查看/删除页。
struct KnownHostsView: View {
let store: KeychainKnownHostsStore
@EnvironmentObject var theme: TXThemeManager
@State private var entries: [KnownHostEntry] = []
private var p: TXPalette { theme.palette }
var body: some View {
ZStack {
p.bg.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: TX.Space.s) {
if entries.isEmpty {
TXEmptyState(icon: "checkmark.shield", title: "还没有已信任的主机密钥",
subtitle: "首次连接主机时会自动记住其 host key")
} else {
ForEach(entries) { e in
row(e)
.contextMenu {
Button(role: .destructive) {
store.removeKey(e.id); reload()
} label: { Label("移除信任", systemImage: "trash") }
}
}
}
}
.padding(TX.Space.m)
}
}
.navigationTitle("Known Hosts")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(p.bg, for: .navigationBar)
.onAppear(perform: reload)
}
private func row(_ e: KnownHostEntry) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text("\(e.host):\(e.port)").font(.system(size: 15, weight: .semibold)).foregroundStyle(p.textPrimary)
Text("出口:\(e.egress)").font(TX.Font.mono(11)).foregroundStyle(p.textSecondary)
Text(e.fingerprint).font(TX.Font.mono(11)).foregroundStyle(p.textTertiary)
.textSelection(.enabled)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(TX.Space.m)
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1))
}
private func reload() { entries = store.entries() }
}

View File

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

View File

@@ -0,0 +1,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.json)。本体无机密(authKey 默认注册后即丢弃)。
@MainActor
final class TailscaleStore: ObservableObject {
@Published private(set) var connections: [TailscaleConnection] = []
private let url: URL
init() {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
url = base.appendingPathComponent("tailnets.json")
load()
}
func add(_ conn: TailscaleConnection) { connections.append(conn); save() }
func update(_ conn: TailscaleConnection) {
guard let i = connections.firstIndex(where: { $0.id == conn.id }) else { return }
connections[i] = conn
save()
}
func remove(_ conn: TailscaleConnection) {
connections.removeAll { $0.id == conn.id }
save()
// 删连接 → 删其 tsnet 状态目录,避免残留身份。
let base = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0]
try? FileManager.default.removeItem(atPath: base + "/" + conn.stateDirName)
}
func connection(id: UUID?) -> TailscaleConnection? {
guard let id else { return nil }
return connections.first { $0.id == id }
}
#if DEBUG
/// 无头 UI 验证:注入示意 tailnet(**不写盘**,不污染真实 tailnets.json)。
func injectFixtureConnections(_ list: [TailscaleConnection]) {
connections = list
}
#endif
private func load() {
guard let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode([TailscaleConnection].self, from: data) else { return }
connections = decoded
}
private func save() {
guard let data = try? JSONEncoder().encode(connections) else { return }
try? data.write(to: url, options: .atomic)
}
}

View File

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

View File

@@ -0,0 +1,406 @@
import SwiftUI
// 设计稿 `1b 终端-沉浸轨道` 的浮起 chrome:左上标题胶囊 + 右上状态胶囊 + 底部提示条。
//
// 浮起(absolute + blur)而非占一行布局:终端面积最大化是这一屏的唯一目标。
// 终端内容容器顶部留 86pt 预留带给胶囊,预留带用 padding 实现(容器 clipped 防向上溢出)。
/// 浮起胶囊的共用外观:blur 材质 + 半透底 + 常规描边 + 圆角 11。
private struct FloatingCapsule<Content: View>: View {
@EnvironmentObject var theme: TXThemeManager
@ViewBuilder let content: Content
private var p: TXPalette { theme.palette }
var body: some View {
content
.padding(.horizontal, TX.Space.s)
.frame(height: 34)
.background {
RoundedRectangle(cornerRadius: TX.Radius.capsule)
.fill(p.surfaceDark.opacity(0.88))
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: TX.Radius.capsule))
}
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.capsule)
.strokeBorder(p.border, lineWidth: 1)
)
.shadow(color: .black.opacity(0.55), radius: 15, y: 10)
}
}
/// 左上标题胶囊:红/黄圆点 → 竖分隔 → 状态点 → 主机名 → 「window title · cwd」。
struct TerminalTitleCapsule: View {
@ObservedObject var session: TerminalSession
let onClose: () -> Void
let onMinimize: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
FloatingCapsule {
HStack(spacing: TX.Space.xs) {
windowDot(TXWindowDot.close, "关闭会话", action: onClose)
windowDot(TXWindowDot.minimize, "最小化", action: onMinimize)
Rectangle().fill(p.border).frame(width: 1, height: 14)
.padding(.horizontal, 2)
TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 6)
Text(session.displayName)
.font(TX.Font.mono(12.5, .medium))
.foregroundStyle(p.text)
.lineLimit(1)
if !contextLine.isEmpty {
Text(contextLine)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
// 终端格子数(设计稿 08 的「· 168×44」)。首次布局后稳定,故不必 @Published。
Text(gridLabel)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textFaint)
}
}
}
/// 「当前 window title · cwd」(设计稿 3a)。无 tmux 时退回连接状态文案。
private var contextLine: String {
guard let t = session.tmuxSummary else {
return session.isLive ? "" : session.phaseText
}
var parts: [String] = []
if !t.currentTitle.isEmpty { parts.append(t.currentTitle) }
if !t.cwd.isEmpty { parts.append(Self.abbreviate(t.cwd)) }
return parts.joined(separator: " · ")
}
/// `/Users/yz/src/x` → `~/src/x`(终端习惯写法,设计稿即此形态)。
static func abbreviate(_ path: String) -> String {
let home = NSHomeDirectory()
if !home.isEmpty, path.hasPrefix(home) { return "~" + path.dropFirst(home.count) }
// 远端 home 与本机不同,按常见前缀兜底。
for prefix in ["/home/", "/Users/"] where path.hasPrefix(prefix) {
let rest = path.dropFirst(prefix.count)
if let slash = rest.firstIndex(of: "/") { return "~" + rest[slash...] }
return "~"
}
return path
}
private var gridLabel: String {
let g = session.screenGrid.value
return "\(g.cols)×\(g.rows)"
}
private func windowDot(_ color: Color, _ label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Circle()
.fill(color)
.frame(width: 13, height: 13)
.overlay(Circle().strokeBorder(Color.black.opacity(0.25), lineWidth: 0.5))
.frame(width: 22, height: 30)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(label)
}
}
/// 右上状态胶囊:三格分层徽标 `tsnet | mosh | tmux`,格间 1×16pt 竖线。
///
/// **只染出问题的那层**:网络层不可达 → 第一格转琥珀;重连中 → 第二格转琥珀;tmux 缺失 → 第三格
/// 写「无 tmux · 客户端窗口」而非报错。首版第二格不显示 RTT 数字(见 HANDOFF §12.4)。
struct TerminalStatusCapsule: View {
@ObservedObject var session: TerminalSession
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
FloatingCapsule {
HStack(spacing: 0) {
cell(egressText, color: egressColor, dot: true)
separator
cell(transportText, color: transportColor, dot: false)
separator
cell(tmuxText, color: p.text2, dot: false)
}
}
}
private var separator: some View {
Rectangle().fill(p.border).frame(width: 1, height: 16)
.padding(.horizontal, TX.Space.s)
}
private func cell(_ text: String, color: Color, dot: Bool) -> some View {
HStack(spacing: 5) {
if dot { TXStatusDot(color: color, size: 6) }
Text(text)
.font(TX.Font.mono(12, .medium))
.foregroundStyle(color)
.lineLimit(1)
}
}
// 第一格:网络可达性(tsnet 出口 / 直连)
private var egressText: String {
switch session.link {
case .relay: "tsnet"
case .direct: "direct"
case .connecting: "连接中"
case .reconnecting: "tsnet"
case .offline: "离线"
}
}
private var egressColor: Color {
switch session.link {
case .direct, .relay: p.online
case .connecting: p.text4
case .reconnecting: p.reconnecting
case .offline: p.offline
}
}
// 第二格:传输层(mosh / SSH)
private var transportText: String {
if case .reconnecting(let n) = session.link {
return "\(session.moshEngaged ? "mosh" : "ssh") 重连 \(n)"
}
return session.moshEngaged ? "mosh" : "ssh"
}
private var transportColor: Color {
if case .reconnecting = session.link { return p.reconnecting }
// 设计:这一格的文字是中性色,只有 RTT 数字才染绿/琥珀(首版无 RTT → 全中性)。
return p.text2
}
// 第三格:会话复用层
private var tmuxText: String {
if let t = session.tmuxSummary { return t.name.isEmpty ? "tmux" : "tmux \(t.name)" }
// 还在探测 / 等用户选会话时别谎报「无 tmux」——那是探测确定不可用后才成立的结论。
if session.pendingSessionChoice != nil { return "选择 tmux 会话…" }
if session.tmuxSkipped { return "原生窗口 · 不经 tmux" }
if session.tmuxUnavailable { return "无 tmux · 客户端窗口" }
return session.isLive ? "检测 tmux…" : "—"
}
}
/// 终端区的**非内容态**覆盖层:连接中 / 失败 / 已关闭。
///
/// 真实连接不是瞬间的:解析凭据 → tsnet Up → dial → 认证 → (mosh 引导)→ tmux attach,
/// 期间终端一片空白。这层给出「现在在干什么、失败了能怎么办」,避免用户对着黑屏猜。
/// 已连接(含重连中,屏幕内容还在)时**不显示**,不遮挡终端。
struct TerminalStateOverlay: View {
@ObservedObject var session: TerminalSession
let onRetry: () -> Void
let onBack: () -> Void
/// 取消正在进行的连接(用户不想再等)。
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
/// 连接已等待秒数 —— 真实连接可能要走 tsnet Up / mosh 引导,等待要可见、可取消。
@State private var waited = 0
private var p: TXPalette { theme.palette }
/// 失败态:phaseText 由 SessionMachine 给出(`失败:…`)。
private var failure: String? {
session.phaseText.hasPrefix("失败") ? String(session.phaseText.dropFirst(3)) : nil
}
private var isConnecting: Bool {
!session.isLive && failure == nil && session.phaseText != "已关闭"
}
var body: some View {
if let failure {
state(icon: "exclamationmark.triangle", tint: p.danger, title: "连接失败",
detail: failure) {
HStack(spacing: TX.Space.s) {
TXButton(title: "返回首页", action: onBack)
TXButton(title: "重试", emphasized: true, action: onRetry)
}
}
} else if session.phaseText == "已关闭" {
state(icon: "power", tint: p.textWeak, title: "会话已关闭",
detail: "服务端连接已断开。") {
HStack(spacing: TX.Space.s) {
TXButton(title: "返回首页", action: onBack)
TXButton(title: "重新连接", emphasized: true, action: onRetry)
}
}
} else if isConnecting {
state(icon: nil, tint: p.text4, title: session.phaseText,
detail: session.banner.isEmpty ? connectHint : session.banner) {
VStack(spacing: TX.Space.xs) {
if waited >= 3 {
Text("已等待 \(waited)s")
.font(TX.Font.mono(11))
.foregroundStyle(p.textFaint)
}
if waited >= 5 {
// 等久了才给取消:避免正常的 1–2s 连接闪现一个按钮。
TXButton(title: "取消连接", action: onCancel)
}
}
}
.task(id: session.phaseText) {
waited = 0
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
waited += 1
}
}
}
}
/// 分阶段提示:让等待有解释(尤其 tsnet 首次 Up 与 mosh 引导都要数秒)。
private var connectHint: String {
switch session.link {
case .relay: "正在经 Tailscale 出口建立连接…"
case .connecting, .direct: session.moshEngaged ? "正在引导 mosh…" : "正在建立 SSH 连接…"
case .reconnecting(let n): "第 \(n) 次重连中…"
case .offline: "等待连接…"
}
}
private func state<Actions: View>(icon: String?, tint: Color, title: String, detail: String,
@ViewBuilder actions: () -> Actions) -> some View {
VStack(spacing: TX.Space.s) {
if let icon {
Image(systemName: icon)
.font(.system(size: 26, weight: .light))
.foregroundStyle(tint)
} else {
ProgressView().controlSize(.regular).tint(TXAccent.base)
}
Text(title)
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Text(detail)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.multilineTextAlignment(.center)
.lineLimit(3)
.frame(maxWidth: 420)
actions()
.padding(.top, TX.Space.xs)
}
.padding(TX.Space.l)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// 无 tmux 主机的行内横幅(设计稿 `2c`):说明窗口由 App 维持、mosh 兜底,但**关掉 App 不会在
/// 服务端继续跑**;右侧给「安装并启用 tmux」次要按钮。**不弹窗、不强推**,可关闭。
struct NoTmuxBanner: View {
let onInstall: () -> Void
let onDismiss: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: TX.Space.s) {
Image(systemName: "info.circle")
.font(.system(size: 12))
.foregroundStyle(p.warning)
Text("未检测到 tmux · 当前是客户端窗口:mosh 能兜断线,但**关掉 App 后服务端不会继续跑**")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.text3)
.lineLimit(1)
Spacer(minLength: TX.Space.s)
TXButton(title: "安装并启用 tmux", action: onInstall)
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(p.textWeak)
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 40)
.background(p.surfaceDark.opacity(0.92))
.overlay(alignment: .bottom) { Rectangle().fill(p.divider).frame(height: 1) }
}
}
/// 「安装并启用 tmux」的引导(点横幅按钮后):给出各发行版命令,用户可一键塞进终端执行。
struct TmuxInstallSheet: View {
let onRun: (String) -> Void
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
/// 常见包管理器。跨发行版探测一条命令搞定:有哪个用哪个。
private let combined = "command -v apt-get >/dev/null && sudo apt-get install -y tmux || " +
"command -v dnf >/dev/null && sudo dnf install -y tmux || " +
"command -v pacman >/dev/null && sudo pacman -S --noconfirm tmux || " +
"command -v apk >/dev/null && sudo apk add tmux || " +
"command -v brew >/dev/null && brew install tmux"
var body: some View {
TXModalScrim(onDismiss: onCancel) {
VStack(alignment: .leading, spacing: TX.Space.s) {
Text("安装 tmux")
.font(TX.Font.mono(15, .medium))
.foregroundStyle(p.text)
Text("tmux 让会话留在服务端:关掉 App、换网络、睡一觉回来都能原样接回。\n下面这条会按服务器的包管理器自动选择(需要 sudo 权限)。")
.font(TX.Font.mono(12))
.foregroundStyle(p.text3)
.lineSpacing(4)
Text(combined)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.text4)
.padding(TX.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.bgDeep, in: RoundedRectangle(cornerRadius: 8))
HStack(spacing: TX.Space.s) {
Spacer()
TXButton(title: "取消", action: onCancel)
TXButton(title: "在终端执行", emphasized: true) { onRun(combined) }
}
.padding(.top, TX.Space.xs)
}
.padding(TX.Space.l)
.frame(width: 520)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.dialog)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.shadow(color: .black.opacity(0.7), radius: 40, y: 30)
}
}
}
/// 底部提示条(34pt):接硬件键盘时只留这条快捷键清单;触控时由键盘附加行替换(UI-5)。
struct TerminalHintBar: View {
/// 左侧上下文说明(如「Pin 态 · 终端变窄,仅在钉住/解除时 resize 一次」)。
let context: String
let hints: [(key: String, label: String)]
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: TX.Space.s) {
Text(context)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
Spacer(minLength: TX.Space.m)
ForEach(hints, id: \.key) { hint in
HStack(spacing: 5) {
Text(hint.key)
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(p.text4)
Text(hint.label)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
}
}
}
.padding(.horizontal, TX.Space.m)
.frame(height: TX.Layout.footHeight)
}
}

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 = 竖分(左右并排,⌘D);false = 横分(上下堆叠,⌘⇧D)
let vertical: Bool
let onPick: (SplitTarget) -> Void
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
enum SplitTarget {
case currentSession
case otherSession(String)
case newSession
/// 原生窗口(不经 tmux)。首版不可用:需要一台主机多条 SSH channel(见 HANDOFF §12.4)。
case nativeWindow
}
/// 其余会话 = 除了本客户端所在的那个。(不能按 `isAttached` 过滤——服务器上别的客户端
/// attach 着其它会话时会把它们错误地排除掉。)
private var detached: [TmuxSessionInfo] {
let current = session.tmuxSummary?.name ?? ""
return session.tmuxSessionList.filter { $0.name != current }
}
var body: some View {
TXModalScrim(onDismiss: onCancel) {
VStack(alignment: .leading, spacing: 0) {
header
Rectangle().fill(p.divider).frame(height: 1)
if session.tmuxSummary != nil {
TXSectionLabel(text: "tmux 会话")
.padding(.horizontal, TX.Space.m)
.padding(.top, TX.Space.s)
.padding(.bottom, 2)
row(title: currentName, detail: currentDetail, dot: p.online,
tag: "当前", selected: true) { onPick(.currentSession) }
ForEach(detached) { s in
// detached 会话的进程仍在服务端跑 → 状态点同样是绿的(设计稿如此)。
row(title: s.name,
detail: "\(s.windows) window\(s.windows == 1 ? "" : "s") · detached",
dot: p.online,
tag: s.lastActivity.map { SessionCard.relative($0) }) {
onPick(.otherSession(s.name))
}
}
row(title: "新建 tmux 会话…", detail: nil, dot: nil, tag: nil) {
onPick(.newSession)
}
Rectangle().fill(p.divider).frame(height: 1)
.padding(.vertical, TX.Space.xs)
}
// 原生窗口:设计里是并列选项;首版能力未就绪 → 灰显并注明,不装作能用。
row(title: "原生窗口", detail: "不经 tmux · 关 App 即结束(二期)",
dot: nil, tag: nil, disabled: true) {}
}
.padding(.bottom, TX.Space.xs)
.frame(width: 360)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog - 1))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.dialog - 1)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.shadow(color: .black.opacity(0.7), radius: 40, y: 30)
}
}
private var header: some View {
HStack {
Text(vertical ? "竖分屏到…" : "横分屏到…")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Spacer()
Text(session.displayName)
.font(TX.Font.mono(11))
.foregroundStyle(TXAccent.mid)
}
.padding(.horizontal, TX.Space.m)
.frame(height: 44)
}
private var currentName: String {
guard let t = session.tmuxSummary else { return session.displayName }
return t.name.isEmpty ? "当前会话" : t.name
}
private var currentDetail: String {
guard let t = session.tmuxSummary else { return "" }
var parts: [String] = []
if !t.currentTitle.isEmpty { parts.append(t.currentTitle) }
parts.append("\(t.windows) window\(t.windows == 1 ? "" : "s")")
if t.panes > 0 { parts.append("\(t.panes) panes") }
return parts.joined(separator: " · ")
}
/// 菜单行:**前导列统一 20pt**(状态点也包在 20pt 居中列里),保证所有行文字左缘一致。
private func row(title: String, detail: String?, dot: Color?, tag: String?,
selected: Bool = false, disabled: Bool = false,
action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: TX.Space.s) {
ZStack {
if let dot { TXStatusDot(color: dot, size: 7) }
}
.frame(width: 20)
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(TX.Font.mono(13, .medium))
.foregroundStyle(disabled ? p.textWeak : p.text)
if let detail, !detail.isEmpty {
Text(detail)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
}
Spacer(minLength: TX.Space.xs)
if let tag {
Text(tag)
.font(TX.Font.mono(10.5, .medium))
.foregroundStyle(selected ? TXAccent.text : TXAccent.mid)
}
}
.padding(.horizontal, TX.Space.s)
.frame(height: 40)
.frame(maxWidth: .infinity, alignment: .leading)
.background(selected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(selected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.padding(.horizontal, TX.Space.xs)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(disabled)
}
}

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 // warning、git 标记
let blue: UInt32 // 路径、字段
let teal: UInt32 // 勾选、mosh
let peach: UInt32 // 重连中
let overlay: UInt32 // 次要输出、离线
/// 终端内容区底色(Catppuccin base)。沉浸态胶囊下的预留带、首页缩略卡都铺这个色,
/// 与 libghostty 实际渲染的 pane 底色一致,视觉才连续。
let terminalBg: UInt32
}
// MARK: - Palette(视图唯一入口)
/// 统一主题门面:一套选择同时驱动 **app chrome**(中性阶)与 **终端**(`ghosttyName` → GhosttyTheme),
/// 强调色恒为 Nocturne blurple。新增主题 = 再写一个 `static let`,视图零改动。
struct TXPalette: Equatable, Identifiable {
let flavor: TXFlavor
let chrome: TXChrome
var id: String { flavor.id }
var name: String { flavor.name }
var isDark: Bool { flavor.isDark }
var ghosttyName: String { flavor.ghosttyName }
/// 该主题的终端配色(GhosttyTheme → TerminalTheme)。找不到回退系统默认。
var terminalTheme: TerminalTheme {
GhosttyThemeCatalog.theme(named: 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 的核心色构造统一 palette(app 与终端同源)。
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 Mono(chrome 与终端同源)。字体缺失时 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 映射为原生 tab、window 内多 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 kickoff(refresh-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: - 关闭 / 分屏 / resize(UI-4)
/// 断开 tmux 客户端:**服务端会话继续跑**,下次原样接回。红点确认里的推荐动作。
/// tmux 会回 `%exit` → 既有 `onExit` 链路收尾。
func detach() { sendCommand("detach-client") }
/// 结束当前 tmux 会话(服务端进程一并结束)。红点确认里的危险次要动作,需再确认一次。
func killSession() { sendCommand("kill-session") }
/// 分屏。tmux 语义:`-h` 左右并排(竖直分隔线)、`-v` 上下堆叠。
/// 只发命令即可 —— tmux 回 `%layout-change`,既有 reconcile 会为新 pane 建 surface
/// 并走 capture-pane 快照链路(见 `applyLayout` / `ensurePane`)。
func splitWindow(vertical: Bool, joinSession: String? = nil) {
if let s = joinSession {
// 把另一个会话的当前 window 拉进本 window 当 pane(tmux 原生支持,不嵌套 tmux)。
sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(s):")
} else {
sendCommand("split-window \(vertical ? "-h" : "-v")")
}
}
/// 新建 tmux 会话并把它的 window 拉进当前 window 分屏(detached 建,避免抢走客户端)。
func splitIntoNewSession(vertical: Bool, name: String) {
sendCommand("new-session -d -s \(name)")
sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(name):")
}
/// 拖 pane 分隔条**松手后**调整尺寸(拖动中只动视觉、不发命令,见设计约束)。
/// 发完等 `%layout-change` 回来再更新真实 frame —— 与"路线 b"一致,客户端不自行重排。
func resizePane(_ p: TmuxPaneID, cols: Int? = nil, rows: Int? = nil) {
if let cols, cols > 0 { sendCommand("resize-pane -t %\(p.raw) -x \(cols)") }
if let rows, rows > 0 { sendCommand("resize-pane -t %\(p.raw) -y \(rows)") }
}
/// 枚举本 tmux server 上的会话(分屏菜单要列 detached 会话)。结果进 `sessions`。
func listSessions() {
let sep = Self.fieldSep
sendCommand("list-sessions -F \"#{session_name}\(sep)#{session_windows}\(sep)#{session_attached}\(sep)#{session_activity}\"")
pending.append(.listSessions)
}
/// 某 pane 的 surface 就绪 → 放行其缓冲输出。
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }
@@ -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`(各自独立 netstack;dial 走具体 node 句柄、
/// 无全局路由,故重叠 100.x 不会误连——只要每个 host 始终经其绑定的那个 Node dial)。铁律不破:
/// 仍是一个 Go runtime、一次 gomobile bind(全在 tsnetbridge 包)。
///
/// 策略:**懒启动**(用到才 Up)·**Up 后常驻**(不空闲 Down,M3 mosh 恢复依赖 netstack 存活)·
/// **软上限 3**(超限 LRU 关闭无活跃会话的最久未用节点)·**in-flight 去重**(同连接并发 connect
/// 绝不 double-Up 同一 stateDir——tsnet 无文件锁,同 Dir 双 Up 会损坏状态)。
///
/// 线程安全(`@unchecked Sendable` + 锁):Up/dial 阻塞,由 `TerminalSession` 在后台线程调用。
final class TsnetRegistry: @unchecked Sendable {
static let shared = TsnetRegistry()
private let lock = NSLock()
private var nodes: [UUID: TsnetbridgeNode] = [:]
private var upLocks: [UUID: NSLock] = [:]
private var lastUsed: [UUID: Date] = [:]
private var activeSessions: Set<UUID> = []
private let maxLive = 3
private let stateBase: String
init() {
stateBase = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0]
}
/// 取(必要时懒启动)某连接的 tsnet 节点。阻塞,需后台线程调用。
/// `authKey` 仅在节点尚未注册(首次 Up)时需要;已持久化 state 的节点用 nil(走已存 node key)。
func node(connID: UUID, stateDirName: String, hostname: String,
authKey: String?, timeoutMs: Int) throws -> TsnetbridgeNode {
lock.lock()
if let n = nodes[connID] { lastUsed[connID] = Date(); lock.unlock(); return n }
let upLock = upLocks[connID] ?? { let l = NSLock(); upLocks[connID] = l; return l }()
lock.unlock()
// 同连接串行 Up(in-flight 去重);不同连接可并发。
upLock.lock()
defer { upLock.unlock() }
lock.lock()
if let n = nodes[connID] { lastUsed[connID] = Date(); lock.unlock(); return n }
lock.unlock()
let dir = stateBase + "/" + stateDirName
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
guard let n = TsnetbridgeNewNode(dir, hostname) else { throw TsnetError.newNodeFailed }
try n.up(withAuthKey: authKey ?? "", timeoutMs: timeoutMs)
lock.lock()
nodes[connID] = n
lastUsed[connID] = Date()
evictIfNeededLocked(keeping: connID)
lock.unlock()
return n
}
/// 为 mosh 建立 UDP relay(阻塞,后台线程调用)。节点必须已 Up。
func startMoshRelay(connID: UUID, host: String, moshPort: Int, timeoutMs: Int) throws -> TsnetbridgeMoshRelay {
lock.lock(); let n = nodes[connID]; lock.unlock()
guard let n else { throw TsnetError.notUp }
return try n.startMoshRelay(host, moshPort: moshPort, timeoutMs: timeoutMs)
}
/// M3:唤醒某连接的 tsnet 网络层(前台恢复,可能阻塞)。
func wakeUp(connID: UUID) {
lock.lock(); let n = nodes[connID]; lock.unlock()
n?.wakeUp()
}
/// 标记某连接是否有活跃会话(供 LRU 不驱逐在用节点)。
func markSessionActive(_ connID: UUID, _ active: Bool) {
lock.lock()
if active { activeSessions.insert(connID) } else { activeSessions.remove(connID) }
lock.unlock()
}
/// 关闭并销毁某连接的节点(删连接 / LRU 驱逐)。
func close(_ connID: UUID) {
lock.lock()
let n = nodes[connID]
nodes[connID] = nil; lastUsed[connID] = nil; activeSessions.remove(connID)
lock.unlock()
try? n?.close()
}
/// 超出软上限时,关闭无活跃会话、最久未用的节点(保护刚取到的 keeping)。
private func evictIfNeededLocked(keeping: UUID) {
while nodes.count > maxLive {
let candidates = nodes.keys
.filter { $0 != keeping && !activeSessions.contains($0) }
.sorted { (lastUsed[$0] ?? .distantPast) < (lastUsed[$1] ?? .distantPast) }
guard let victim = candidates.first, let n = nodes[victim] else { break }
nodes[victim] = nil; lastUsed[victim] = nil
try? n.close()
}
}
}

View File

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

View File

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