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?
/// 稿 200260ms easeOut +
/// 线
static let sidebarAnimation = Animation.easeOut(duration: 0.24)
private func animateSidebar(_ change: () -> Void) {
withAnimation(Self.sidebarAnimation, change)
}
/// / /
func enter(_ sessionID: UUID) {
route = .terminal(sessionID)
}
/// H /
func goHome() {
modal = nil
route = .home
if sidebar == .overlay { animateSidebar { sidebar = .collapsed } }
}
/// \ / Pin
func toggleSidebar() {
animateSidebar {
switch sidebar {
case .collapsed: sidebar = .overlay
case .overlay, .pinned: sidebar = .collapsed
}
}
}
/// Pin / resize
func togglePin() {
animateSidebar { sidebar = sidebar == .pinned ? .overlay : .pinned }
}
/// /WDD URL
func present(_ modal: AppModal) { self.modal = modal }
func dismissModal() { modal = nil }
/// Pin
func dismissOverlaySidebar() {
if sidebar == .overlay { animateSidebar { sidebar = .collapsed } }
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,632 @@
import SwiftUI
/// · 稿 `1e -`
///
/// App ****
/// 4 40pt
struct HomeView: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var hostStore: HostStore
@ObservedObject var credStore: FileCredentialStore
@ObservedObject var tailscaleStore: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@State private var search = ""
@State private var groupFilter: String? = nil
@State private var showEditor = false
@State private var editing: SavedHost?
@State private var showSettings = false
private var p: TXPalette { theme.palette }
/// chips+
private var groups: [String] {
var seen: [String] = []
for h in hostStore.hosts {
if let g = h.group, !seen.contains(g) { seen.append(g) }
}
return seen
}
private var filteredHosts: [SavedHost] {
hostStore.hosts.filter { h in
let matchesGroup = groupFilter == nil || h.group == groupFilter
guard matchesGroup else { return false }
guard !search.isEmpty else { return true }
let q = search.lowercased()
return h.name.lowercased().contains(q) || h.host.lowercased().contains(q)
|| (h.os?.lowercased().contains(q) ?? false)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
if hostStore.hosts.isEmpty && manager.sessions.isEmpty {
firstRunEmptyState
} else {
ScrollView {
VStack(alignment: .leading, spacing: TX.Space.m) {
// tmux
ForEach(manager.connectingSessions) { s in
ConnectingRow(session: s) { manager.close(s.id) }
}
if liveSessions.isEmpty {
if manager.connectingSessions.isEmpty { noSessionHint }
} else {
TXSectionLabel(text: "活动会话")
.padding(.top, TX.Space.xs)
sessionCards
}
HStack(alignment: .center) {
TXSectionLabel(text: "全部主机")
Spacer()
if !groups.isEmpty { chips }
}
.padding(.top, manager.sessions.isEmpty ? TX.Space.xs : TX.Space.s)
if filteredHosts.isEmpty {
noMatchState
} else {
hostGrid
}
}
.padding(.horizontal, TX.Space.screenGutter)
.padding(.bottom, TX.Space.xxl)
}
}
}
.background(p.bg.ignoresSafeArea())
//
.task(id: manager.sessions.count) {
while !Task.isCancelled {
for s in manager.sessions { s.captureSnapshot() }
try? await Task.sleep(nanoseconds: 1_500_000_000)
}
}
.sheet(isPresented: $showEditor) {
HostEditorView(existing: editing, credStore: credStore, tailscaleStore: tailscaleStore,
existingGroups: groups) { host in
if hostStore.hosts.contains(where: { $0.id == host.id }) { hostStore.update(host) }
else { hostStore.add(host) }
}
}
.sheet(isPresented: $showSettings) {
SettingsHost(credStore: credStore, tailscaleStore: tailscaleStore)
}
}
// MARK: -
///
private var firstRunEmptyState: some View {
VStack(spacing: TX.Space.m) {
Spacer()
Image(systemName: "terminal")
.font(.system(size: 40, weight: .ultraLight))
.foregroundStyle(TXAccent.mid)
VStack(spacing: 6) {
Text("还没有主机")
.font(TX.Font.mono(15, .medium))
.foregroundStyle(p.text)
Text("添加一台服务器就能开始SSH 直连,或经 Tailscale 出口访问内网。\n开启 mosh 后断线自动接回。")
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
.multilineTextAlignment(.center)
.lineSpacing(4)
}
HStack(spacing: TX.Space.s) {
TXButton(title: "新建连接", emphasized: true) {
editing = nil
showEditor = true
}
TXButton(title: "先配置 Tailscale") { showSettings = true }
}
.padding(.top, TX.Space.xs)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
///
private var noSessionHint: some View {
HStack(spacing: TX.Space.s) {
Image(systemName: "arrow.down")
.font(.system(size: 12))
.foregroundStyle(p.textWeak)
Text("还没有在跑的会话 —— 点下面任意主机即可连接")
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 40)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
.foregroundStyle(p.divider)
)
.padding(.top, TX.Space.xs)
}
/// /
private var noMatchState: some View {
VStack(spacing: TX.Space.s) {
Text(search.isEmpty ? "该分组下没有主机" : "没有匹配「\(search)」的主机")
.font(TX.Font.mono(12.5, .medium))
.foregroundStyle(p.text3)
TXButton(title: "清除筛选") {
search = ""
groupFilter = nil
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, TX.Space.xl)
}
// MARK: -
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("terminalX")
.font(TX.Font.mono(34, .medium))
.tracking(-0.85)
.foregroundStyle(p.text)
Text(subtitle)
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
}
Spacer()
HStack(spacing: TX.Space.s) {
TXSearchField(text: $search, placeholder: "搜索", shortcut: "⌘K")
.frame(width: 220)
TXButton(title: "新建连接", emphasized: true) {
editing = nil
showEditor = true
}
TXIconButton(icon: "gearshape", label: "设置") { showSettings = true }
}
}
.padding(.horizontal, TX.Space.screenGutter)
.padding(.top, TX.Space.l)
.padding(.bottom, TX.Space.m)
}
private var subtitle: String {
var parts: [String] = []
if !manager.sessions.isEmpty { parts.append("\(manager.sessions.count) 个会话在跑") }
parts.append("\(hostStore.hosts.count) 台主机")
if let tailnet = tailscaleStore.connections.first {
parts.append("Tailscale \(tailnet.name)")
}
return parts.joined(separator: " · ")
}
private var chips: some View {
HStack(spacing: TX.Space.xs) {
TXChip(text: "全部", isSelected: groupFilter == nil) { groupFilter = nil }
ForEach(groups, id: \.self) { g in
TXChip(text: g, isSelected: groupFilter == g) { groupFilter = g }
}
}
}
/// tmux
/// `SessionManager` manager
struct ConnectingRow: View {
@ObservedObject var session: TerminalSession
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var failed: Bool { session.phaseText.hasPrefix("失败") }
var body: some View {
HStack(spacing: TX.Space.s) {
if failed {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 13))
.foregroundStyle(p.danger)
} else {
ProgressView().controlSize(.small).tint(TXAccent.base)
}
VStack(alignment: .leading, spacing: 2) {
Text(failed ? "连接 \(session.displayName) 失败" : "正在连接 \(session.displayName)")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Text(detail)
.font(TX.Font.mono(11))
.foregroundStyle(failed ? p.danger : p.textWeak)
.lineLimit(1)
}
Spacer(minLength: TX.Space.s)
if failed {
TXButton(title: "重试") { session.retry() }
}
TXButton(title: failed ? "移除" : "取消", action: onCancel)
}
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(failed ? p.danger.opacity(0.4) : p.border, lineWidth: 1)
)
.padding(.top, TX.Space.xs)
}
private var detail: String {
if failed { return String(session.phaseText.dropFirst(3)) }
if session.pendingSessionChoice != nil { return "等待选择 tmux 会话" }
if !session.banner.isEmpty { return session.banner }
return session.phaseText
}
}
// MARK: - 3
/// **** ConnectingRow
/// tmux
private var liveSessions: [TerminalSession] {
let connecting = Set(manager.connectingSessions.map(\.id))
return manager.activeSessions.filter {
($0.isLive || $0.isMinimized) && !connecting.contains($0.id)
}
}
private var sessionCards: some View {
HStack(alignment: .top, spacing: TX.Space.m) {
ForEach(liveSessions) { s in
SessionCard(
session: s,
shortcutIndex: (liveSessions.firstIndex { $0.id == s.id } ?? 0) + 1,
isFocused: manager.focusedID == s.id
) {
manager.focus(s)
router.enter(s.id)
}
}
}
.frame(height: 560)
}
// MARK: - 4
private var hostGrid: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: TX.Space.m), count: 4),
spacing: TX.Space.m) {
ForEach(filteredHosts) { host in
HostTile(host: host,
credName: credName(for: host),
hasTsnet: host.tailscaleID != nil,
liveSession: manager.sessions.first { $0.host?.id == host.id }) {
// tmux manager.enterRequest
manager.open(host: host)
}
.contextMenu {
Button { editing = host; showEditor = true } label: {
Label("编辑", systemImage: "pencil")
}
Button(role: .destructive) { hostStore.remove(host) } label: {
Label("删除", systemImage: "trash")
}
}
}
addHostTile
}
}
private var addHostTile: some View {
Button {
editing = nil
showEditor = true
} label: {
VStack {
Text("添加主机")
.font(TX.Font.mono(12, .medium))
.foregroundStyle(p.textWeak)
}
.frame(maxWidth: .infinity, minHeight: 96)
.background(
RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
.foregroundStyle(p.border)
)
}
.buttonStyle(.plain)
}
private func credName(for host: SavedHost) -> String? {
if case .credential(let id) = host.auth {
return credStore.list().first { $0.id == id }?.name
}
return nil
}
}
/// tmux
/// `SessionManager` manager
struct ConnectingRow: View {
@ObservedObject var session: TerminalSession
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var failed: Bool { session.phaseText.hasPrefix("失败") }
var body: some View {
HStack(spacing: TX.Space.s) {
if failed {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 13))
.foregroundStyle(p.danger)
} else {
ProgressView().controlSize(.small).tint(TXAccent.base)
}
VStack(alignment: .leading, spacing: 2) {
Text(failed ? "连接 \(session.displayName) 失败" : "正在连接 \(session.displayName)")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Text(detail)
.font(TX.Font.mono(11))
.foregroundStyle(failed ? p.danger : p.textWeak)
.lineLimit(1)
}
Spacer(minLength: TX.Space.s)
if failed {
TXButton(title: "重试") { session.retry() }
}
TXButton(title: failed ? "移除" : "取消", action: onCancel)
}
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(failed ? p.danger.opacity(0.4) : p.border, lineWidth: 1)
)
.padding(.top, TX.Space.xs)
}
private var detail: String {
if failed { return String(session.phaseText.dropFirst(3)) }
if session.pendingSessionChoice != nil { return "等待选择 tmux 会话" }
if !session.banner.isEmpty { return session.banner }
return session.phaseText
}
}
// MARK: -
/// + + tmux + · · +
/// 线/ + overlay
struct SessionCard: View {
@ObservedObject var session: TerminalSession
let shortcutIndex: Int
let isFocused: Bool
let onOpen: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var statusColor: Color {
switch session.link {
case .direct, .relay: p.online
case .reconnecting: p.reconnecting
case .connecting: p.text4
case .offline: p.offline
}
}
private var isTroubled: Bool {
switch session.link {
case .reconnecting, .offline: true
default: false
}
}
var body: some View {
VStack(spacing: 0) {
cardHead
Rectangle().fill(p.divider).frame(height: 1)
// 线/ + blur(1.5px) + rgba(24,26,40,.72)
//
TXTerminalMirror(lines: session.snapshotLines, background: p.terminalBackground)
.blur(radius: isTroubled ? 1.5 : 0)
.overlay { if isTroubled { troubleVeil } }
Rectangle().fill(p.divider).frame(height: 1)
cardFoot
}
.background(p.surfaceDark)
.clipShape(RoundedRectangle(cornerRadius: 13))
.overlay(
RoundedRectangle(cornerRadius: 13)
.strokeBorder(isFocused ? TXAccent.selectedStroke : p.border, lineWidth: 1)
)
.shadow(color: .black.opacity(isFocused ? 0.4 : 0.2), radius: isFocused ? 9 : 5, y: 6)
.contentShape(Rectangle())
.onTapGesture(perform: onOpen)
}
private var cardHead: some View {
HStack(spacing: TX.Space.xs) {
TXStatusDot(color: statusColor)
Text(session.displayName)
.font(TX.Font.mono(13, .semibold))
.foregroundStyle(p.text)
.lineLimit(1)
if let t = session.tmuxSummary {
Text(t.name.isEmpty ? "tmux" : "tmux \(t.name)")
.font(TX.Font.mono(11))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
Spacer(minLength: TX.Space.xs)
linkTag
}
.padding(.horizontal, TX.Space.s)
.frame(height: 36)
}
/// RTT RTT / HANDOFF §12.4
@ViewBuilder private var linkTag: some View {
switch session.link {
case .direct:
tag("direct", p.online)
case .relay:
tag("tsnet", p.online)
case .connecting:
tag("连接中", p.text4)
case .reconnecting(let n):
tag("重连中 · 第 \(n)", p.reconnecting)
case .offline:
tag("离线", p.offline)
}
}
private func tag(_ s: String, _ c: Color) -> some View {
Text(s).font(TX.Font.mono(11, .medium)).foregroundStyle(c).lineLimit(1)
}
/// +
private var troubleVeil: some View {
ZStack {
Color(hex: 0x181A28).opacity(0.72)
VStack(spacing: TX.Space.s) {
if case .reconnecting = session.link {
ProgressView().controlSize(.regular).tint(p.reconnecting)
}
Text(veilTitle)
.font(TX.Font.mono(12, .medium))
.foregroundStyle(p.reconnecting)
Text("屏幕内容已缓存,恢复后原样接回")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.text4)
}
.multilineTextAlignment(.center)
.padding(TX.Space.m)
}
}
private var veilTitle: String {
switch session.link {
case .reconnecting(let n): "\(session.moshEngaged ? "mosh " : "")会话保活中 · 第 \(n) 次重连"
default: "已断开"
}
}
private var cardFoot: some View {
HStack(spacing: TX.Space.xs) {
Text(footMeta)
.font(TX.Font.mono(11))
.foregroundStyle(p.textWeak)
.lineLimit(1)
Spacer(minLength: TX.Space.xs)
Text("\(shortcutIndex)")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
Button(action: onOpen) {
Text(isTroubled ? "立即重试" : "回到会话 →")
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(TXAccent.textBright)
}
.buttonStyle(.plain)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 38)
}
private var footMeta: String {
var parts: [String] = []
if let t = session.tmuxSummary {
parts.append("\(t.windows) window\(t.windows == 1 ? "" : "s")")
if t.panes > 0 { parts.append("\(t.panes) panes") }
} else {
parts.append("原生窗口")
}
if session.isMinimized {
parts.append("最小化中")
} else {
parts.append(Self.relative(session.lastActiveAt))
}
return parts.joined(separator: " · ")
}
/// ///
static func relative(_ date: Date) -> String {
let s = Int(Date().timeIntervalSince(date))
if s < 60 { return "\(max(1, s)) 秒前" }
if s < 3600 { return "\(s / 60) 分钟前" }
if s < 86400 { return "\(s / 3600) 小时前" }
return "\(s / 86400) 天前"
}
}
// MARK: -
/// 4 + + os + +
struct HostTile: View {
let host: SavedHost
let credName: String?
let hasTsnet: Bool
///
let liveSession: TerminalSession?
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var icon: String {
let os = (host.os ?? "").lowercased()
if os.contains("macos") { return "desktopcomputer" }
if os.contains("raspberry") { return "cpu" }
if os.contains("nas") || host.name.lowercased().contains("nas") { return "externaldrive" }
return "server.rack"
}
var body: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: TX.Space.s) {
HStack(alignment: .top, spacing: TX.Space.s) {
Image(systemName: icon)
.font(.system(size: 15, weight: .light))
.foregroundStyle(p.text4)
.frame(width: 30, height: 30)
.background(p.surface, in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 2) {
Text(host.name)
.font(TX.Font.mono(13, .semibold))
.foregroundStyle(p.text)
.lineLimit(1)
Text(host.os ?? host.addressLine)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
Spacer(minLength: 0)
if let s = liveSession {
TXStatusDot(color: s.link == .offline ? p.offline
: (s.isMinimized ? TXAccent.mid : p.online))
} else {
TXStatusDot(color: p.offline.opacity(0.5))
}
}
HStack(spacing: 5) {
if host.useMosh { TXBadge(text: "mosh") } else { TXBadge(text: "SSH") }
if hasTsnet { TXBadge(text: "tsnet") }
if let credName { TXBadge(text: credName, emphasized: true) }
}
}
.padding(TX.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(p.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}

View File

@@ -1,18 +1,103 @@
import Foundation
///
/// `password` JSON demo Keychain SecItem HANDOFF
/// `AuthRef` `tailscaleID`
struct SavedHost: Identifiable, Codable, Equatable {
var id: UUID = UUID()
var name: String
var host: String
var port: Int = 22
var username: String
var password: String = ""
/// mosh
var useMosh: Bool = false
/// tmux control mode`tmux -CC new -A`tmux
/// /线 tmux
var useTmux: Bool = true
/// tmux `-s`nil = `main`
var tmuxSession: String?
/// /宿 Keychain
var auth: AuthRef
/// nil = Direct Tailscale egress profile §2.6
var tailscaleID: UUID?
/// chips / / nil =
var group: String?
/// macOS · M4 Max
var os: String?
var addressLine: String { "\(username)@\(host):\(port)" }
///
enum AuthRef: Codable, Equatable {
case inlinePassword(username: String, password: String)
case credential(id: UUID)
}
init(id: UUID = UUID(), name: String, host: String, port: Int = 22,
useMosh: Bool = false, useTmux: Bool = true, tmuxSession: String? = nil,
auth: AuthRef, tailscaleID: UUID? = nil,
group: String? = nil, os: String? = nil) {
self.id = id
self.name = name
self.host = host
self.port = port
self.useMosh = useMosh
self.useTmux = useTmux
self.tmuxSession = tmuxSession
self.auth = auth
self.tailscaleID = tailscaleID
self.group = group
self.os = os
}
/// host:port
var addressLine: String {
if case .inlinePassword(let user, _) = auth, !user.isEmpty {
return "\(user)@\(host):\(port)"
}
return "\(host):\(port)"
}
// MARK: - Codable schema
enum CodingKeys: String, CodingKey {
case id, name, host, port, useMosh, useTmux, tmuxSession, auth, tailscaleID, group, os
//
case username, password
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
name = try c.decode(String.self, forKey: .name)
host = try c.decode(String.self, forKey: .host)
port = try c.decodeIfPresent(Int.self, forKey: .port) ?? 22
useMosh = try c.decodeIfPresent(Bool.self, forKey: .useMosh) ?? false
// tmux " tmux"
useTmux = try c.decodeIfPresent(Bool.self, forKey: .useTmux) ?? true
tmuxSession = try c.decodeIfPresent(String.self, forKey: .tmuxSession)
tailscaleID = try c.decodeIfPresent(UUID.self, forKey: .tailscaleID)
group = try c.decodeIfPresent(String.self, forKey: .group)
os = try c.decodeIfPresent(String.self, forKey: .os)
if let auth = try c.decodeIfPresent(AuthRef.self, forKey: .auth) {
self.auth = auth
} else {
// schemainline username/password inlinePassword
let user = try c.decodeIfPresent(String.self, forKey: .username) ?? ""
let pw = try c.decodeIfPresent(String.self, forKey: .password) ?? ""
self.auth = .inlinePassword(username: user, password: pw)
}
}
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(id, forKey: .id)
try c.encode(name, forKey: .name)
try c.encode(host, forKey: .host)
try c.encode(port, forKey: .port)
try c.encode(useMosh, forKey: .useMosh)
try c.encode(useTmux, forKey: .useTmux)
try c.encodeIfPresent(tmuxSession, forKey: .tmuxSession)
try c.encode(auth, forKey: .auth)
try c.encodeIfPresent(tailscaleID, forKey: .tailscaleID)
try c.encodeIfPresent(group, forKey: .group)
try c.encodeIfPresent(os, forKey: .os)
}
}
/// Application Support JSON
@@ -45,6 +130,13 @@ final class HostStore: ObservableObject {
save()
}
#if DEBUG
/// UI **** hosts.json
func injectFixtureHosts(_ hosts: [SavedHost]) {
self.hosts = hosts
}
#endif
private func load() {
guard let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode([SavedHost].self, from: data) else { return }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,529 @@
import SwiftUI
// 稿 `1b -` 56pt + `3a -/Pin ` 288pt
//
// ****`` App logo
// »«Pin
//
/// 56pt 48pt线
struct RailView: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
/// TMUX window
@ObservedObject var session: TerminalSession
let onNewHost: () -> Void
let onSettings: () -> Void
@EnvironmentObject var theme: TXThemeManager
@Environment(\.horizontalSizeClass) private var hSize
private var p: TXPalette { theme.palette }
private var width: CGFloat {
hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth
}
private var avatarSize: CGFloat { hSize == .compact ? 34 : 38 }
// 稿 `1b` 1366×1024
// logo 28..62 · 75..109 · 118..152 · 160 · 174/220/266 8
// · 线+ 313..351 · 370 · TMUX 382 · window 408 7
// · 齿 985..1019 · 932..967 · 914
var body: some View {
VStack(spacing: 0) {
logo
.padding(.top, 28)
.padding(.bottom, 13)
// / / 稿**** logo
TXIconButton(icon: "magnifyingglass", bare: true, hitSize: 34, label: "搜索") {
router.toggleSidebar()
}
.padding(.bottom, 9)
TXIconButton(icon: "house", bare: true, hitSize: 34, label: "首页") { router.goHome() }
.padding(.bottom, 8)
divider
.padding(.bottom, 13)
hostAvatars
newHostButton
divider
.padding(.top, 9)
.padding(.bottom, 12)
tmuxSection
Spacer(minLength: TX.Space.s)
TXStatusDot(color: linkColor, punchOut: p.surfaceDark)
.padding(.bottom, 11)
// 稿****
TXIconButton(icon: "chevron.right.2", hitSize: 34, label: "展开侧边栏") {
router.toggleSidebar()
}
.padding(.bottom, 18)
TXIconButton(icon: "gearshape", bare: true, hitSize: 34, label: "设置", action: onSettings)
.padding(.bottom, 5)
}
.frame(width: width)
.background(p.surfaceDark)
}
/// App logo``****
private var logo: some View {
Text("")
.font(TX.Font.mono(16, .bold))
.foregroundStyle(TXAccent.text)
.frame(width: 34, height: 34)
.background(p.surface, in: RoundedRectangle(cornerRadius: 11))
.overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border, lineWidth: 1))
.accessibilityLabel("terminalX")
}
private var divider: some View {
Rectangle()
.fill(p.divider)
.frame(width: 24, height: 1)
}
private var hostAvatars: some View {
VStack(spacing: 8) {
ForEach(manager.activeSessions) { s in
Button {
manager.focus(s)
router.enter(s.id)
} label: {
TXInitialsAvatar(initials: s.initials,
size: avatarSize,
isSelected: router.route.sessionID == s.id,
status: RailSidebarStyle.statusColor(s, p),
punchOut: p.surfaceDark)
}
.buttonStyle(.plain)
}
}
}
/// 线 +
private var newHostButton: some View {
Button(action: onNewHost) {
Image(systemName: "plus")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(p.textWeak)
.frame(width: avatarSize, height: avatarSize)
.background(
RoundedRectangle(cornerRadius: 11)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
.foregroundStyle(p.border)
)
}
.buttonStyle(.plain)
.padding(.top, 9)
}
/// TMUX + window tmux
private var tmuxSection: some View {
VStack(spacing: 7) {
Text(session.tmuxSummary == nil ? "窗口" : "TMUX")
.font(TX.Font.mono(9, .semibold))
.tracking(0.9)
// 稿 TMUX accent-500
.foregroundStyle(TXAccent.mid)
.padding(.bottom, 9)
if let tmux = session.tmuxController {
ForEach(tmux.windows) { win in
Button { tmux.selectWindow(win.id) } label: {
windowNumber("\(win.index)", isActive: win.id == tmux.activeWindowID)
}
.buttonStyle(.plain)
}
} else {
// attach window
ForEach(0 ..< max(1, session.tmuxSummary?.windows ?? 1), id: \.self) { i in
windowNumber("\(i)", isActive: i == 0)
}
}
}
}
/// window 34×30 +
private func windowNumber(_ text: String, isActive: Bool) -> some View {
Text(text)
.font(TX.Font.mono(12, isActive ? .semibold : .regular))
.foregroundStyle(isActive ? p.text : p.text4)
.frame(width: 34, height: 30)
.background(isActive ? p.surface : .clear, in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(isActive ? p.borderDialog : .clear, lineWidth: 1)
)
}
private var linkColor: Color { RailSidebarStyle.statusColor(session, p) }
}
///
@MainActor
enum RailSidebarStyle {
static func statusColor(_ s: TerminalSession, _ p: TXPalette) -> Color {
switch s.link {
case .direct, .relay: s.isMinimized ? TXAccent.mid : p.online
case .reconnecting: p.reconnecting
case .connecting: p.text4
case .offline: p.offline
}
}
}
// MARK: - 288pt
/// / Pin
///
/// = 26pt + 10pt gapwindow = 26×22pt + 10pt gap
/// **inset **`strokeBorder` border 1pt
struct SidebarPanel: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var session: TerminalSession
@ObservedObject var tailscaleStore: TailscaleStore
let onNewHost: () -> Void
let onSettings: () -> Void
@Binding var search: String
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
TXSearchField(text: $search, placeholder: "搜索主机 · 会话")
.padding(.horizontal, TX.Space.m)
.padding(.bottom, TX.Space.s)
homeRow
sessionsSection
// 1pt ****线
LinearGradient(colors: [p.divider.opacity(0), p.divider, p.divider.opacity(0)],
startPoint: .leading, endPoint: .trailing)
.frame(height: 1)
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.s)
tmuxSection
Spacer(minLength: 0)
tailscaleFoot
}
.frame(width: TX.Layout.sidebarWidth)
.background(p.surfaceDark)
}
private var header: some View {
HStack(spacing: TX.Space.s) {
Text("")
.font(TX.Font.mono(15, .bold))
.foregroundStyle(TXAccent.text)
.frame(width: 30, height: 30)
.background(p.surface, in: RoundedRectangle(cornerRadius: 9))
.overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.border, lineWidth: 1))
Text("terminalX")
.font(TX.Font.mono(14, .medium))
.foregroundStyle(p.text)
Spacer()
// Pin
TXIconButton(icon: "pin", size: 28, isSelected: router.sidebar == .pinned,
label: "钉在左侧") { router.togglePin() }
//
TXIconButton(icon: "chevron.left.2", size: 28, label: "收起") { router.toggleSidebar() }
}
.padding(.horizontal, TX.Space.m)
.padding(.top, 11)
.padding(.bottom, 6)
}
private var homeRow: some View {
Button { router.goHome() } label: {
HStack(spacing: TX.Space.s) {
Image(systemName: "house")
.font(.system(size: 13))
.foregroundStyle(p.text2)
.frame(width: 26)
Text("首页")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Spacer()
Text("⌘⇧H")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
}
.padding(.horizontal, TX.Space.m)
.frame(height: 36)
}
.buttonStyle(.plain)
}
private var sessionsSection: some View {
VStack(alignment: .leading, spacing: 2) {
TXSectionLabel(text: "活动会话", trailing: "\(manager.sessions.count)")
.padding(.horizontal, TX.Space.m)
.padding(.top, TX.Space.s)
.padding(.bottom, 2)
if manager.activeSessions.isEmpty {
Text("没有在跑的会话")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.padding(.leading, 53)
.frame(height: 32, alignment: .leading)
}
ForEach(manager.activeSessions) { s in
SidebarSessionRow(session: s, isSelected: router.route.sessionID == s.id) {
manager.focus(s)
router.enter(s.id)
}
}
Button(action: onNewHost) {
Text("连接新主机…")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
.padding(.leading, 53) //
.frame(height: 32, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
}
}
private var tmuxSection: some View {
VStack(alignment: .leading, spacing: 2) {
TXSectionLabel(text: tmuxHeader)
.padding(.horizontal, TX.Space.m)
.padding(.bottom, 2)
if let tmux = session.tmuxController {
ForEach(tmux.windows) { win in
SidebarWindowRow(window: win,
isSelected: win.id == tmux.activeWindowID) {
tmux.selectWindow(win.id)
}
}
newWindowRow { tmux.newWindow() }
} else if let summary = session.tmuxSummary {
// tmux controller attach
ForEach(0 ..< max(1, summary.windows), id: \.self) { i in
placeholderWindowRow(
index: i,
title: i == 0 && !summary.currentTitle.isEmpty ? summary.currentTitle : "window \(i)",
detail: i == 0 && summary.panes > 0
? "\(summary.panes) pane\(summary.panes == 1 ? "" : "s")" : "",
isSelected: i == 0)
}
newWindowRow {}
} else {
Text("无 tmux · 客户端窗口")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.xs)
}
}
}
/// window T window x=53
private func newWindowRow(_ action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
Text("新建 window")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
Spacer()
Text("⌘T")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
}
.padding(.leading, 53)
.padding(.trailing, TX.Space.m)
.frame(height: 32)
}
.buttonStyle(.plain)
}
/// controller window SidebarWindowRow
private func placeholderWindowRow(index: Int, title: String, detail: String,
isSelected: Bool) -> some View {
HStack(spacing: TX.Space.s) {
Text("\(index)")
.font(TX.Font.mono(11, .semibold))
.foregroundStyle(isSelected ? TXAccent.text : p.text4)
.frame(width: 26, height: 22)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: 5))
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(TX.Font.mono(13, isSelected ? .medium : .regular))
.foregroundStyle(isSelected ? p.text : p.text2)
.lineLimit(1)
if !detail.isEmpty {
Text(detail)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
}
}
Spacer(minLength: 0)
if isSelected { TXStatusDot(color: p.online, size: 7) }
}
.padding(.horizontal, TX.Space.s)
.frame(height: 44)
.background(isSelected ? TXAccent.selectedBg.opacity(0.75) : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.padding(.horizontal, TX.Space.s)
}
private var tmuxHeader: String {
let host = session.displayName
if let t = session.tmuxSummary, !t.name.isEmpty { return "TMUX · \(host) · \(t.name)" }
return session.tmuxSummary == nil ? "窗口 · \(host)" : "TMUX · \(host)"
}
/// tailnet 绿 + tailnet + IP
///
/// **** `TX.Layout.footHeight`线 y
/// 线 1pt divider稿
private var tailscaleFoot: some View {
VStack(spacing: 0) {
Rectangle().fill(p.divider).frame(height: 1)
HStack(spacing: TX.Space.s) {
TXStatusDot(color: tailscaleStore.connections.isEmpty ? p.offline : p.online)
VStack(alignment: .leading, spacing: 1) {
Text(tailscaleStore.connections.isEmpty ? "Tailscale 未配置" : "Tailscale 已连接")
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(p.text2)
if let c = tailscaleStore.connections.first {
// tailnet + tailnet IPUp SelfIP
Text([c.hostname, c.lastSelfIP].compactMap { $0 }.joined(separator: " · "))
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
}
Spacer(minLength: 0)
}
.padding(.horizontal, TX.Space.m)
.frame(height: TX.Layout.footHeight)
}
}
}
/// 26pt + ·window title + /
struct SidebarSessionRow: View {
@ObservedObject var session: TerminalSession
let isSelected: Bool
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Button(action: onTap) {
HStack(spacing: TX.Space.s) {
TXInitialsAvatar(initials: session.initials, size: 26,
isSelected: isSelected,
status: RailSidebarStyle.statusColor(session, p),
punchOut: isSelected ? TXAccent.selectedBg : p.surfaceDark)
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
.lineLimit(1)
Text(subtitle)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
Spacer(minLength: TX.Space.xs)
trailingTag
}
.padding(.horizontal, TX.Space.s)
.frame(height: 48)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
// inset border 1pt
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.padding(.horizontal, TX.Space.s)
}
.buttonStyle(.plain)
}
private var title: String {
if let t = session.tmuxSummary, !t.name.isEmpty {
return "\(session.displayName) · \(t.name)"
}
return session.displayName
}
private var subtitle: String {
if session.isMinimized { return "最小化中" }
guard let t = session.tmuxSummary else { return "原生窗口" }
let title = t.currentTitle.isEmpty ? session.phaseText : t.currentTitle
return "\(title) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
}
@ViewBuilder private var trailingTag: some View {
switch session.link {
case .reconnecting:
Text("重连中").font(TX.Font.mono(10.5, .medium)).foregroundStyle(p.reconnecting)
case .offline:
Text("离线").font(TX.Font.mono(10.5)).foregroundStyle(p.offline)
default:
EmptyView()
}
}
}
/// window 26×22pt + title + cwd/panes
struct SidebarWindowRow: View {
@ObservedObject var window: TmuxWindow
let isSelected: Bool
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var subtitle: String {
var parts: [String] = []
if !window.cwd.isEmpty { parts.append(TerminalTitleCapsule.abbreviate(window.cwd)) }
let n = window.panes.count
if n > 1 { parts.append("\(n) panes") }
return parts.isEmpty ? "1 pane" : parts.joined(separator: " · ")
}
var body: some View {
Button(action: onTap) {
HStack(spacing: TX.Space.s) {
Text("\(window.index)")
.font(TX.Font.mono(11, .semibold))
.foregroundStyle(isSelected ? TXAccent.text : p.text4)
.frame(width: 26, height: 22)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: 5))
VStack(alignment: .leading, spacing: 1) {
Text(window.title)
.font(TX.Font.mono(13, isSelected ? .medium : .regular))
.foregroundStyle(isSelected ? p.text : p.text2)
.lineLimit(1)
// cwd · n panescwd #{pane_current_path}
Text(subtitle)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
Spacer(minLength: 0)
if isSelected {
TXStatusDot(color: p.online, size: 7)
}
}
.padding(.horizontal, TX.Space.s)
.frame(height: 44)
.background(isSelected ? TXAccent.selectedBg.opacity(0.75) : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.padding(.horizontal, TX.Space.s)
}
.buttonStyle(.plain)
}
}

View File

@@ -1,655 +0,0 @@
import Foundation
import GhosttyTerminal
import TXCore
import TXTransport
import TsnetBridge
#if canImport(UIKit)
import UIKit
#endif
enum TsnetError: Error { case newNodeFailed, notUp }
/// tsnet 线 SSHTerminalModel 线 up/dial
/// up dial fd tailnet
final class TsnetManager: @unchecked Sendable {
private let lock = NSLock()
private var node: TsnetbridgeNode?
private let stateDir: String
init() {
let base = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0]
stateDir = base + "/tsnet-main"
try? FileManager.default.createDirectory(atPath: stateDir, withIntermediateDirectories: true)
}
/// up线
func ensureUp(authKey: String, timeoutMs: Int) throws {
lock.lock(); let existing = node; lock.unlock()
if existing != nil { return }
guard let n = TsnetbridgeNewNode(stateDir, "terminalx-ipad") else { throw TsnetError.newNodeFailed }
try n.up(withAuthKey: authKey, timeoutMs: timeoutMs)
lock.lock(); node = n; lock.unlock()
}
/// tsnet dial tailnet host:port fd线
/// tsnet Up peer DERP/ dial i/o timeout
/// 退
func dialFD(host: String, port: Int, timeoutMs: Int) throws -> Int32 {
lock.lock(); let n = node; lock.unlock()
guard let n else { throw TsnetError.notUp }
var lastError: Error?
for attempt in 0 ..< 6 {
do {
var fd: Int64 = -1
try n.dialTCPFD(host, port: port, timeoutMs: 8000, ret0_: &fd)
return Int32(fd)
} catch {
lastError = error
if attempt < 5 { Thread.sleep(forTimeInterval: 2.0) } // 线
}
}
throw lastError ?? TsnetError.notUp
}
/// mosh UDP relayloopback tsnet(host:moshPort) localPort
/// tsnet UDP Dial线
func startMoshRelay(host: String, moshPort: Int, timeoutMs: Int) throws -> TsnetbridgeMoshRelay {
lock.lock(); let n = node; lock.unlock()
guard let n else { throw TsnetError.notUp }
return try n.startMoshRelay(host, moshPort: moshPort, timeoutMs: timeoutMs)
}
/// M3 tsnet + Rebind/ STUN线
func wakeUp() {
lock.lock(); let n = node; lock.unlock()
n?.wakeUp()
}
}
/// 线 transport libghostty /resize 线
final class TransportHolder: @unchecked Sendable {
private let lock = NSLock()
private var value: Transport?
var transport: Transport? {
get { lock.lock(); defer { lock.unlock() }; return value }
set { lock.lock(); value = newValue; lock.unlock() }
}
}
/// 线 mosh nil /resize mosh SSH transport
final class MoshHolder: @unchecked Sendable {
private let lock = NSLock()
private var value: MoshSession?
var session: MoshSession? {
get { lock.lock(); defer { lock.unlock() }; return value }
set { lock.lock(); value = newValue; lock.unlock() }
}
}
/// surface attach receive flush
final class OutputGate: @unchecked Sendable {
private let lock = NSLock()
private let session: InMemoryTerminalSession
private var ready = false
private var pending = Data()
init(session: InMemoryTerminalSession) { self.session = session }
func deliver(_ data: Data) {
lock.lock()
if ready { lock.unlock(); session.receive(data) }
else { pending.append(data); lock.unlock() }
}
func markReady() {
lock.lock()
if ready { lock.unlock(); return }
ready = true
let buffered = pending; pending = Data()
lock.unlock()
if !buffered.isEmpty { session.receive(buffered) }
}
}
/// tmux enter / bytes AsyncStream 线
/// " chunk Task" pane %
enum TmuxByteEvent: Sendable { case enter([UInt8]); case bytes([UInt8]) }
/// 线 tmux continuation线 yield线
final class TmuxByteChannel: @unchecked Sendable {
let stream: AsyncStream<TmuxByteEvent>
private let cont: AsyncStream<TmuxByteEvent>.Continuation
init() { (stream, cont) = AsyncStream.makeStream() }
func enter(_ b: [UInt8]) { cont.yield(.enter(b)) }
func bytes(_ b: [UInt8]) { cont.yield(.bytes(b)) }
}
/// 线 + cell tmux attach/resize `refresh-client -C`
final class GridBox: @unchecked Sendable {
private let lock = NSLock()
private var cols = 80, rows = 24, cellW = 0, cellH = 0
var value: (cols: Int, rows: Int, cellW: Int, cellH: Int) {
lock.lock(); defer { lock.unlock() }; return (cols, rows, cellW, cellH)
}
func set(cols: Int, rows: Int, cellW: Int, cellH: Int) {
lock.lock()
self.cols = max(1, cols); self.rows = max(1, rows)
if cellW > 0 { self.cellW = cellW } // cell
if cellH > 0 { self.cellH = cellH }
lock.unlock()
}
}
/// M4 host key SwiftUI alert / +
struct HostKeyMismatchInfo: Identifiable {
let id = UUID()
let egress: String, host: String, port: Int
let keyType: Int32
let presentedBlob: Data
let presentedFp: String
let storedFp: String
}
/// M0 SSH SSHSession(libssh2) GhosttyKit in-memory
/// TXCore.SessionMachine 线退/
@MainActor
final class SSHTerminalModel: ObservableObject {
let state: TerminalViewState
let session: InMemoryTerminalSession
private let holder: TransportHolder
private let gate: OutputGate
/// /退
@Published var showsTerminal = false
@Published var banner: String = ""
@Published var phaseText: String = "空闲"
/// nil tmux control-mode tab
@Published var tmuxController: TmuxController?
/// M4host key pin nil SwiftUI alert /
@Published var hostKeyMismatch: HostKeyMismatchInfo?
/// /
@Published var connectionTitle: String = ""
/// mosh SSH/mosh
@Published var moshEngaged: Bool = false
private let knownHosts = KeychainKnownHostsStore()
private var machine = SessionMachine()
private var config: SSHConfig?
private var reconnectTask: Task<Void, Never>?
private var autoCommand: String?
/// tsnet nil tsnet fd
private var tsnetAuthKey: String?
private let tsnetMgr = TsnetManager()
/// M2 mosh SSH mosh-server MOSH CONNECT mosh(UDP over tsnet relay)
private var moshMode = false
private var moshServerCmd = "mosh-server new -s -c 256 -l LANG=en_US.UTF-8 -l LC_ALL=en_US.UTF-8"
private let moshHolder: MoshHolder
private var moshSession: MoshSession?
private var moshRelay: TsnetbridgeMoshRelay?
private var moshScanner = MoshConnectScanner()
private var moshBootstrapSent = false
// M3 线 + healthy + +
private var moshResumeBaseMs: UInt64 = 0
private var moshHealthyPoll: Task<Void, Never>?
private var resumeWatchdog: Task<Void, Never>?
#if canImport(UIKit)
private var bgTask: UIBackgroundTaskIdentifier = .invalid
#endif
private let tmuxChannel = TmuxByteChannel()
/// raw surface resize tmux attach refresh-client -C
let screenGrid = GridBox()
init() {
let holder = TransportHolder()
let moshHolder = MoshHolder()
let grid = screenGrid
let session = InMemoryTerminalSession(
// /resizemosh mosh SSH transport
write: { data in
if let m = moshHolder.session { m.send([UInt8](data)) }
else { holder.transport?.send(data) }
},
resize: { vp in
// + cell tmux resize
grid.set(cols: Int(vp.columns), rows: Int(vp.rows),
cellW: Int(vp.cellWidthPixels), cellH: Int(vp.cellHeightPixels))
if let m = moshHolder.session { m.resize(cols: Int(vp.columns), rows: Int(vp.rows)) }
else { holder.transport?.resize(cols: vp.columns, rows: vp.rows) }
}
)
let state = TerminalViewState()
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
_ = state.controller.setTheme(.txDefault) // Catppuccin Mocha
self.holder = holder
self.moshHolder = moshHolder
self.session = session
self.state = state
self.gate = OutputGate(session: session)
// tmux enter controller+bytes
let ch = tmuxChannel
Task { @MainActor [weak self] in
for await ev in ch.stream {
guard let self else { continue }
switch ev {
case .enter(let initial): self.enterTmux(initialBytes: initial)
case .bytes(let b): self.tmuxController?.feed(Data(b))
}
}
}
}
// MARK: -
/// M4b nil publickey(SE/)
private var pubkeySigner: SigningKeyProvider?
/// mosh
func connect(to h: SavedHost) {
moshMode = h.useMosh
connect(host: h.host, port: h.port, username: h.username, password: h.password)
connectionTitle = h.name
}
func connect(host: String, port: Int, username: String, password: String) {
connectionTitle = host
moshEngaged = false
let egress = tsnetAuthKey != nil ? "tsnet" : "direct"
let auth: SSHConfig.Authentication = pubkeySigner.map { .publicKeyCallback($0) } ?? .password(password)
config = SSHConfig(host: host, port: port, username: username,
authentication: auth,
hostKeyVerifier: makeHostKeyVerifier(egress: egress, host: host, port: port))
run(machine.reduce(.connectRequested))
}
/// M4 host key TOFU ssh firstUse pin +
/// mismatch 线 alert falseSSHSession
private func makeHostKeyVerifier(egress: String, host: String, port: Int) -> @Sendable (Data, Int32) -> Bool {
let store = knownHosts
let triple = HostTriple(egress: egress, host: host, port: port)
return { [weak self] blob, keyType in
switch HostKey.evaluate(stored: store.lookup(triple)?.blob, presented: blob) {
case .firstUse:
store.pin(triple, record: HostKeyRecord(blob: blob, keyType: keyType))
let fp = HostKey.opensshFingerprint(blob)
Task { @MainActor in self?.banner = "已记住 host key \(fp)" }
return true
case .trusted:
return true
case .mismatch(let stored):
let info = HostKeyMismatchInfo(
egress: egress, host: host, port: port, keyType: keyType,
presentedBlob: blob, presentedFp: HostKey.opensshFingerprint(blob),
storedFp: HostKey.opensshFingerprint(stored))
Task { @MainActor in self?.hostKeyMismatch = info }
return false
}
}
}
/// host key pin
func trustNewHostKey() {
guard let m = hostKeyMismatch else { return }
knownHosts.pin(HostTriple(egress: m.egress, host: m.host, port: m.port),
record: HostKeyRecord(blob: m.presentedBlob, keyType: m.keyType))
hostKeyMismatch = nil
run(machine.reduce(.retryRequested)) // failed connectingverifier trusted
}
///
func dismissHostKeyMismatch() { hostKeyMismatch = nil }
/// raw surface tmux pane TerminalTheme.txDefault
func applyTerminalTheme(_ t: TerminalTheme) {
_ = state.controller.setTheme(t)
tmuxController?.applyTerminalTheme(t)
}
func close() { run(machine.reduce(.closeRequested)) }
func enterBackground() {
if moshMode { beginBgTask() } // ~30s mosh 3s ack NAT/WG/DERP
run(machine.reduce(.enteredBackground))
}
func enterForeground() {
endBgTask()
run(machine.reduce(.enteredForeground))
}
/// TerminalScreen surface +
func markSurfaceReady() {
gate.markReady()
if let cmd = autoCommand {
autoCommand = nil
let holder = self.holder
Task {
try? await Task.sleep(nanoseconds: 2_500_000_000)
holder.transport?.send(Data("\n".utf8))
try? await Task.sleep(nanoseconds: 900_000_000)
holder.transport?.send(Data((cmd + "\n").utf8))
}
}
}
/// simctl launch --args -txHost -txAutoCommand
func autoConnectIfConfigured() {
let d = UserDefaults.standard
guard let host = d.string(forKey: "txHost"), !host.isEmpty,
let user = d.string(forKey: "txUser") else { return }
let port = d.integer(forKey: "txPort")
autoCommand = d.string(forKey: "txAutoCommand")
if let key = d.string(forKey: "txTsnetKey"), !key.isEmpty { tsnetAuthKey = key }
if d.bool(forKey: "txMosh") {
moshMode = true
if let cmd = d.string(forKey: "txMoshServerCmd"), !cmd.isEmpty { moshServerCmd = cmd }
autoCommand = nil // mosh txAutoCommand
}
let realPort = port == 0 ? 22 : port
// M4bpublickey SE/ authorized_keys
if d.bool(forKey: "txPubkeyAuth") {
pubkeySigner = SigningKeyProvider.loadOrCreate()
if let s = pubkeySigner {
NSLog("M4DBG authorized_keys hw=\(s.isHardwareBacked): \(s.authorizedKeysLine)")
}
}
// pin host key mismatch alert
if let b64 = d.string(forKey: "txHostKeyPinOverrideBase64"), let fake = Data(base64Encoded: b64) {
let egress = tsnetAuthKey != nil ? "tsnet" : "direct"
knownHosts.pin(HostTriple(egress: egress, host: host, port: realPort),
record: HostKeyRecord(blob: fake, keyType: 3))
}
connect(host: host, port: realPort,
username: user, password: d.string(forKey: "txPass") ?? "")
}
// MARK: -
private func run(_ effects: [SessionMachine.Effect]) {
for effect in effects {
switch effect {
case .startConnect: startSession()
case .scheduleReconnect(_, let delayMS): scheduleReconnect(delayMS)
case .cancelReconnectTimer: reconnectTask?.cancel(); reconnectTask = nil
case .teardownTransport: holder.transport?.stop()
case .notify(let message): banner = message
case .nudgeResume: performWakePulse(); startHealthyPollIfNeeded()
case .scheduleResumeWatchdog(let ms): scheduleResumeWatchdog(ms)
case .cancelResumeWatchdog: cancelResumeWatchdog()
case .teardownMosh: teardownMosh()
}
}
syncUI()
}
private func startSession() {
guard let config else { return }
if let key = tsnetAuthKey {
// tsnet up + dial fd fd SSHSession
let mgr = tsnetMgr
Task.detached {
do {
try mgr.ensureUp(authKey: key, timeoutMs: 45000)
let fd = try mgr.dialFD(host: config.host, port: config.port, timeoutMs: 15000)
await MainActor.run { self.wireAndStart(SSHSession(config: config, preconnectedFD: fd)) }
} catch {
await MainActor.run {
self.handleTransportState(.failed("tsnet: \(error.localizedDescription)"))
}
}
}
} else {
wireAndStart(SSHSession(config: config))
}
}
private func wireAndStart(_ ssh: SSHSession) {
exitTmux() // tmux -CC attach
let router = tmuxRouter
if moshMode {
// mosh SSH MOSH CONNECT
ssh.onBytes = { [weak self] data in
Task { @MainActor in self?.moshBootstrapFeed(data) }
}
} else {
ssh.onBytes = { data in router.feed(data) } // raw / tmux
}
ssh.onState = { [weak self] newState in
Task { @MainActor in self?.handleTransportState(newState) }
}
holder.transport = ssh
ssh.start()
}
private lazy var tmuxRouter = TmuxRouter(
rawGate: gate,
// 线enter bytes
enterGateway: { [tmuxChannel] after in tmuxChannel.enter(after) },
gatewayBytes: { [tmuxChannel] bytes in tmuxChannel.bytes(bytes) }
)
private func enterTmux(initialBytes: [UInt8]) {
let holder = self.holder
let controller = TmuxController(sendRaw: { data in holder.transport?.send(data) })
controller.onExit = { [weak self] in Task { @MainActor in self?.exitTmux() } }
// + cell attach refresh-client -C resize
let g = screenGrid.value
controller.setClientSize(cols: g.cols, rows: g.rows)
controller.setCellPixels(w: g.cellW, h: g.cellH)
tmuxController = controller
if !initialBytes.isEmpty { controller.feed(Data(initialBytes)) }
}
private func exitTmux() {
if tmuxController != nil { tmuxController = nil }
tmuxRouter.reset()
}
// MARK: - M2 mosh
/// SSH mosh-server shell
private func scheduleMoshBootstrap() {
guard moshMode, !moshBootstrapSent, moshSession == nil else { return }
moshBootstrapSent = true
let holder = self.holder
let cmd = moshServerCmd
banner = "启动 mosh-server…"
Task {
try? await Task.sleep(nanoseconds: 2_500_000_000) // shell
holder.transport?.send(Data("\n".utf8))
try? await Task.sleep(nanoseconds: 800_000_000)
NSLog("MOSHDBG sent mosh-server cmd: \(cmd)")
holder.transport?.send(Data((cmd + "\n").utf8))
}
}
/// mosh SSH + MOSH CONNECT mosh
/// mosh SSH mosh
private func moshBootstrapFeed(_ data: Data) {
guard moshSession == nil else { return }
gate.deliver(data) // mosh-server
let text = String(decoding: data, as: UTF8.self)
if let info = moshScanner.feed(text) {
NSLog("MOSHDBG parsed MOSH CONNECT port=\(info.port) keyLen=\(info.key.count)")
launchMosh(info)
}
}
/// MOSH CONNECT tsnet UDP relay(loopback:port) MoshSession
private func launchMosh(_ info: MoshConnectInfo) {
guard moshSession == nil, let config else { return }
banner = "mosh 连接中server port \(info.port))…"
if tsnetAuthKey != nil {
let mgr = tsnetMgr
let host = config.host
Task.detached {
do {
NSLog("MOSHDBG startMoshRelay host=\(host) moshPort=\(info.port)")
let relay = try mgr.startMoshRelay(host: host, moshPort: info.port, timeoutMs: 15000)
let localPort = relay.localPort()
NSLog("MOSHDBG relay up localPort=\(localPort)")
await MainActor.run { self.activateMosh(ip: "127.0.0.1", port: localPort, key: info.key, relay: relay) }
} catch {
NSLog("MOSHDBG relay FAILED: \(error.localizedDescription)")
await MainActor.run { self.banner = "mosh relay 失败:\(error.localizedDescription)" }
}
}
} else {
// iOS UDP host:port
activateMosh(ip: config.host, port: info.port, key: info.key, relay: nil)
}
}
private func activateMosh(ip: String, port: Int, key: String, relay: TsnetbridgeMoshRelay?) {
guard moshSession == nil else { return }
let mosh = MoshSession(ip: ip, port: port, key: key, cols: 80, rows: 24)
mosh.onBytes = { [weak self] bytes in
guard let self else { return }
self.gate.deliver(Data(bytes))
}
mosh.onClosed = { [weak self] rc in
Task { @MainActor in
guard let self else { return }
self.run(self.machine.reduce(.moshExited(rc: Int(rc))))
}
}
moshRelay = relay
moshSession = mosh
moshHolder.session = mosh // /resize mosh
mosh.start()
moshEngaged = true
NSLog("MOSHDBG activateMosh ip=\(ip) port=\(port) started")
// mosh idle SSHmoshActive transportClosed
run(machine.reduce(.moshEstablished))
}
// MARK: - M3
/// tsnet WakeUp线+ relay Rebind + mosh SIGCONT
private func performWakePulse() {
guard let mosh = moshSession else { return }
if moshResumeBaseMs == 0 { moshResumeBaseMs = mosh.lastHeardMs() }
if tsnetAuthKey != nil {
let mgr = tsnetMgr
Task.detached { mgr.wakeUp() }
}
do { try moshRelay?.rebind() }
catch { NSLog("MOSHDBG relay rebind failed: \(error.localizedDescription)") }
mosh.nudge()
NSLog("MOSHDBG wake pulse base=\(moshResumeBaseMs)")
}
/// mosh 线 SSP healthy
private func startHealthyPollIfNeeded() {
guard moshHealthyPoll == nil, let mosh = moshSession else { return }
let base = moshResumeBaseMs
moshHealthyPoll = Task { [weak self] in
for _ in 0 ..< 100 { // ~10s
try? await Task.sleep(nanoseconds: 100_000_000)
if Task.isCancelled { return }
if mosh.lastHeardMs() > base {
guard let self else { return }
NSLog("TXM3 recovered base=\(base) now=\(mosh.lastHeardMs())")
self.moshHealthyPoll = nil
self.run(self.machine.reduce(.moshHealthy))
return
}
}
}
}
private func scheduleResumeWatchdog(_ ms: Int) {
resumeWatchdog?.cancel()
resumeWatchdog = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(ms) * 1_000_000)
guard !Task.isCancelled, let self else { return }
self.run(self.machine.reduce(.resumeWatchdogFired))
}
}
private func cancelResumeWatchdog() {
resumeWatchdog?.cancel(); resumeWatchdog = nil
moshHealthyPoll?.cancel(); moshHealthyPoll = nil
moshResumeBaseMs = 0
}
/// mosh / mosh-server
private func teardownMosh() {
cancelResumeWatchdog()
moshEngaged = false
moshHolder.session = nil
moshSession?.close(); moshSession = nil
try? moshRelay?.close(); moshRelay = nil
moshBootstrapSent = false
moshScanner = MoshConnectScanner()
}
private func beginBgTask() {
#if canImport(UIKit)
guard bgTask == .invalid else { return }
bgTask = UIApplication.shared.beginBackgroundTask(withName: "mosh-keepalive") { [weak self] in
self?.endBgTask()
}
#endif
}
private func endBgTask() {
#if canImport(UIKit)
if bgTask != .invalid { UIApplication.shared.endBackgroundTask(bgTask); bgTask = .invalid }
#endif
}
private func handleTransportState(_ st: TransportState) {
switch st {
case .authenticating: run(machine.reduce(.authenticating))
case .connected:
run(machine.reduce(.established))
if moshMode { scheduleMoshBootstrap() }
case .disconnected(let reason): run(machine.reduce(.transportClosed(reason: reason)))
case .failed(let message): run(machine.reduce(.authFailed(reason: message)))
case .idle, .connecting: break
}
}
private func scheduleReconnect(_ delayMS: Int) {
reconnectTask?.cancel()
reconnectTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(delayMS) * 1_000_000)
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self else { return }
self.run(self.machine.reduce(.reconnectTimerFired))
}
}
}
private func syncUI() {
switch machine.phase {
case .connected, .reconnecting, .waitingToReconnect, .backgroundParked,
.moshActive, .moshParked, .moshResuming:
showsTerminal = true
case .idle, .connecting, .authenticating, .closed, .failed:
showsTerminal = false
}
phaseText = Self.describe(machine.phase)
}
private static func describe(_ phase: SessionMachine.Phase) -> String {
switch phase {
case .idle: "空闲"
case .connecting: "连接中…"
case .authenticating: "认证中…"
case .connected: "已连接"
case .backgroundParked: "已挂起(后台)"
case .waitingToReconnect(let n): "等待重连(第 \(n) 次)"
case .reconnecting(let n): "重连中(第 \(n) 次)"
case .failed(let r): "失败:\(r)"
case .closed: "已关闭"
case .moshActive: "mosh 已连接"
case .moshParked: "已挂起(后台)"
case .moshResuming(let n): "mosh 恢复中(第 \(n) 次)"
}
}
}

View File

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

View File

@@ -0,0 +1,206 @@
import SwiftUI
import TXCore
///
///
/// ** tmux ** · ** tmux ** · **** tmux
/// ** tmux sheet**
///
/// ""****
/// `Herdr` / ``
struct SessionPickerSheet: View {
/// ""
let host: String
let choice: TmuxSessionChoice
let onAttach: (String) -> Void
let onCreate: (String) -> Void
let onNative: () -> Void
@EnvironmentObject var theme: TXThemeManager
@State private var newName = ""
@State private var creating = false
@FocusState private var nameFocused: Bool
private var p: TXPalette { theme.palette }
var body: some View {
VStack(alignment: .leading, spacing: TX.Space.m) {
grabber
title
header
if choice.sessions.isEmpty {
emptyState
} else {
sessionList
}
newSessionRow
Spacer(minLength: 0)
}
.padding(.horizontal, TX.Space.l)
.padding(.bottom, TX.Space.l)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(p.bg.ignoresSafeArea())
}
private var title: some View {
VStack(alignment: .leading, spacing: 3) {
Text("连接到 \(host)")
.font(TX.Font.mono(17, .medium))
.foregroundStyle(p.text)
Text("接回一个 tmux 会话,或直接用原生终端")
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
}
}
private var grabber: some View {
RoundedRectangle(cornerRadius: 2.5)
.fill(p.borderStrong)
.frame(width: 44, height: 5)
.frame(maxWidth: .infinity)
.padding(.top, TX.Space.s)
}
// MARK: - +
private var header: some View {
HStack(spacing: TX.Space.s) {
HStack(spacing: 2) {
segment(icon: "chevron.left.forwardslash.chevron.right", title: "Tmux", enabled: true)
segment(icon: "shippingbox", title: "Herdr", enabled: false)
segment(icon: "clock.arrow.circlepath", title: "最近", enabled: false)
}
.padding(4)
.background(p.surface, in: Capsule())
Spacer(minLength: TX.Space.s)
Button(action: onNative) {
HStack(spacing: TX.Space.xs) {
Image(systemName: "terminal")
.font(.system(size: 13, weight: .medium))
Text("原生终端")
.font(TX.Font.mono(15, .medium))
}
.foregroundStyle(p.text)
.padding(.horizontal, TX.Space.m)
.frame(height: 44)
.background(p.surface, in: Capsule())
.overlay(Capsule().strokeBorder(p.border, lineWidth: 1))
}
.buttonStyle(.plain)
.accessibilityLabel("直接连接原生终端,不经过 tmux")
}
}
private func segment(icon: String, title: String, enabled: Bool) -> some View {
HStack(spacing: TX.Space.xs) {
Image(systemName: icon)
.font(.system(size: 13, weight: .medium))
Text(title)
.font(TX.Font.mono(15, .medium))
}
.foregroundStyle(enabled ? p.text : p.textWeak.opacity(0.7))
.padding(.horizontal, TX.Space.m)
.frame(height: 36)
.background(enabled ? p.bgDeep : .clear, in: Capsule())
.overlay {
if enabled {
Capsule().strokeBorder(p.border, lineWidth: 1)
}
}
}
// MARK: -
private var sessionList: some View {
VStack(spacing: 0) {
ForEach(Array(choice.sessions.enumerated()), id: \.element.name) { index, s in
Button { onAttach(s.name) } label: {
sessionRow(s)
}
.buttonStyle(.plain)
if index < choice.sessions.count - 1 {
Rectangle().fill(p.divider).frame(height: 1)
.padding(.horizontal, TX.Space.m)
}
}
}
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
}
private func sessionRow(_ s: TmuxSessionProbe.Session) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: TX.Space.s) {
Text(s.name)
.font(TX.Font.mono(22, .medium))
.foregroundStyle(p.text)
// tmux
TXBadge(text: "活跃", emphasized: true)
if s.isAttached {
TXBadge(text: "已被接入")
}
Spacer(minLength: 0)
}
Text(subtitle(s))
.font(TX.Font.mono(13))
.foregroundStyle(p.textWeak)
}
.padding(.horizontal, TX.Space.m)
.padding(.vertical, 18)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
private func subtitle(_ s: TmuxSessionProbe.Session) -> String {
var parts = ["\(s.windows) 个窗口"]
if let t = s.lastActivity { parts.append(SessionCard.relative(t)) }
return parts.joined(separator: " · ")
}
private var emptyState: some View {
VStack(alignment: .leading, spacing: 6) {
Text("这台主机上还没有 tmux 会话")
.font(TX.Font.mono(15, .medium))
.foregroundStyle(p.text)
Text("新建一个就能让会话留在服务端:关掉 App、换网络都能原样接回。也可以直接用原生终端。")
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
.lineSpacing(3)
}
.padding(TX.Space.m)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
}
// MARK: -
@ViewBuilder private var newSessionRow: some View {
if creating {
HStack(spacing: TX.Space.s) {
TXSearchField(text: $newName, placeholder: choice.defaultNewName)
.focused($nameFocused)
TXButton(title: "创建", emphasized: true) {
let name = newName.trimmingCharacters(in: .whitespaces)
onCreate(name.isEmpty ? choice.defaultNewName : name)
}
TXButton(title: "取消") { creating = false }
}
} else {
Button {
creating = true
nameFocused = true
} label: {
HStack(spacing: TX.Space.s) {
Image(systemName: "plus")
.font(.system(size: 13, weight: .medium))
Text("新建 tmux 会话…")
.font(TX.Font.mono(15, .medium))
Spacer(minLength: 0)
}
.foregroundStyle(TXAccent.textBright)
.padding(.horizontal, TX.Space.m)
.frame(height: 56)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,301 @@
import SwiftUI
// 稿 / / docs/design/UI-DESIGN-HANDOFF.md
/// 8pt + 2pt `box-shadow: 0 0 0 2px <>`/
struct TXStatusDot: View {
let color: Color
var size: CGFloat = 8
/// nil =
var punchOut: Color?
var body: some View {
Circle()
.fill(color)
.frame(width: size, height: size)
.overlay {
if let punchOut {
Circle().stroke(punchOut, lineWidth: 2)
}
}
}
}
/// 38pt / 26pt / 26pt inset 1pt
struct TXInitialsAvatar: View {
let initials: String
var size: CGFloat = 26
var isSelected: Bool = false
/// nil =
var status: Color?
///
var punchOut: Color = .clear
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var radius: CGFloat { size >= 34 ? 11 : TX.Radius.badge }
var body: some View {
Text(initials)
.font(TX.Font.mono(size >= 34 ? 13 : 11, .semibold))
.foregroundStyle(isSelected ? TXAccent.text : p.text3)
.frame(width: size, height: size)
.background(isSelected ? TXAccent.selectedBg : p.surface,
in: RoundedRectangle(cornerRadius: radius))
.overlay(
RoundedRectangle(cornerRadius: radius)
.strokeBorder(isSelected ? TXAccent.dim : p.border, lineWidth: 1)
)
.overlay(alignment: .topTrailing) {
if let status {
TXStatusDot(color: status, punchOut: punchOut)
.offset(x: 3, y: -3)
}
}
}
}
/// 600 10px, ls .09em, uppercase
struct TXSectionLabel: View {
let text: String
/// 3 3
var trailing: String?
var color: Color?
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack {
Text(text.uppercased())
.font(TX.Font.mono(10, .semibold))
.tracking(0.9)
.foregroundStyle(color ?? p.textWeak)
if let trailing {
Spacer()
Text(trailing)
.font(TX.Font.mono(10, .semibold))
.foregroundStyle(color ?? p.textWeak)
}
}
}
}
/// mosh / tsnet / SSH / SE key
struct TXBadge: View {
let text: String
var emphasized: Bool = false
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Text(text)
.font(TX.Font.mono(11, .medium))
.foregroundStyle(emphasized ? TXAccent.text : p.text4)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(emphasized ? TXAccent.selectedBg : p.surface,
in: RoundedRectangle(cornerRadius: 5))
.overlay(
RoundedRectangle(cornerRadius: 5)
.strokeBorder(emphasized ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
}
}
/// chip / / /
struct TXChip: View {
let text: String
let isSelected: Bool
let action: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Button(action: action) {
Text(text)
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(isSelected ? TXAccent.textBright : p.text4)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(isSelected ? TXAccent.selectedBg : p.surface,
in: RoundedRectangle(cornerRadius: TX.Radius.badge))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.badge)
.strokeBorder(isSelected ? TXAccent.selectedStroke : p.divider, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
/// 32pt 8
struct TXSearchField: View {
@Binding var text: String
var placeholder: String
var shortcut: String?
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: TX.Space.xs) {
Image(systemName: "magnifyingglass")
.font(.system(size: 12))
.foregroundStyle(p.textWeak)
TextField(placeholder, text: $text)
.font(TX.Font.mono(12))
.foregroundStyle(p.text)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
if let shortcut, text.isEmpty {
Text(shortcut)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
}
}
.padding(.horizontal, TX.Space.s)
.frame(height: 32)
.background(p.surface, in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8).strokeBorder(p.divider, lineWidth: 1)
)
}
}
/// tmux
struct TXButton: View {
let title: String
var icon: String?
/// + 线
var emphasized: Bool = false
let action: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Button(action: action) {
HStack(spacing: TX.Space.xs) {
if let icon {
Image(systemName: icon).font(.system(size: 12, weight: .medium))
}
Text(title).font(TX.Font.mono(12, .medium))
}
.foregroundStyle(emphasized ? TXAccent.textBright : p.text2)
.padding(.horizontal, 14)
.frame(height: 32)
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.control))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(emphasized ? TXAccent.border : p.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
/// 稿
/// - ****`bare: false` + 齿Pin ""
/// - ****`bare: true` / / 稿
struct TXIconButton: View {
let icon: String
var size: CGFloat = 34
var isSelected: Bool = false
var bare: Bool = false
/// 44pt
var hitSize: CGFloat?
var label: String?
let action: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: bare ? 17 : 14, weight: .regular))
.foregroundStyle(isSelected ? TXAccent.text : p.text3)
.frame(width: size, height: size)
.background {
if !bare {
RoundedRectangle(cornerRadius: TX.Radius.control)
.fill(isSelected ? TXAccent.selectedBg : p.surface)
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(isSelected ? TXAccent.selectedStroke : p.border,
lineWidth: 1)
)
}
}
.frame(width: hitSize ?? max(size, TX.Layout.minTouch),
height: hitSize ?? max(size, TX.Layout.minTouch))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(label ?? icon)
}
}
/// **** /
///
/// surface ghostty surface IOSurfaceLayer
/// Metal `readViewportText()`
/// ****
struct TXTerminalMirror: View {
let lines: [String]
var fontSize: CGFloat = 11
/// Catppuccin base flavor chrome
var background: Color
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
/// surface attach
var placeholder: String = "等待输出…"
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Spacer(minLength: 0)
if lines.isEmpty {
Text(placeholder)
.font(TX.Font.mono(fontSize))
.foregroundStyle(p.text4)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(line.isEmpty ? " " : line)
.font(TX.Font.mono(fontSize))
// 11px / 1.5 × 0.6
.lineSpacing(fontSize * 0.6)
.foregroundStyle(color(for: line))
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(.horizontal, 14)
.padding(.vertical, TX.Space.xs)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
.background(background)
.clipped()
}
/// `readViewportText()` ANSI /
///
private func color(for line: String) -> Color {
let t = line.trimmingCharacters(in: .whitespaces)
if t.isEmpty { return p.text4 }
if t.hasPrefix("") || t.hasPrefix("") || t.hasPrefix("") || t.hasPrefix("**") {
return p.online
}
if t.hasPrefix("") || t.hasPrefix("") { return p.danger }
let lower = t.lowercased()
if lower.hasPrefix("error") || lower.contains(" error:") { return p.danger }
if lower.hasPrefix("warning") || lower.contains(" warning:") { return p.warning }
if t.hasPrefix("") || t.hasPrefix("") || t.hasPrefix("") || t.hasPrefix("*") {
return p.mosh
}
if t.hasPrefix("") { return p.online }
// / / $
if t.hasPrefix("") || t.hasPrefix("") || t.contains("$ ") { return p.text }
if t.hasPrefix("[") { return p.offline }
return p.text3
}
}

View File

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

View File

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

View File

@@ -0,0 +1,406 @@
import SwiftUI
// 稿 `1b -` chrome + +
//
// absolute + blur
// 86pt padding clipped
/// blur + + + 11
private struct FloatingCapsule<Content: View>: View {
@EnvironmentObject var theme: TXThemeManager
@ViewBuilder let content: Content
private var p: TXPalette { theme.palette }
var body: some View {
content
.padding(.horizontal, TX.Space.s)
.frame(height: 34)
.background {
RoundedRectangle(cornerRadius: TX.Radius.capsule)
.fill(p.surfaceDark.opacity(0.88))
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: TX.Radius.capsule))
}
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.capsule)
.strokeBorder(p.border, lineWidth: 1)
)
.shadow(color: .black.opacity(0.55), radius: 15, y: 10)
}
}
/// / window title · cwd
struct TerminalTitleCapsule: View {
@ObservedObject var session: TerminalSession
let onClose: () -> Void
let onMinimize: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
FloatingCapsule {
HStack(spacing: TX.Space.xs) {
windowDot(TXWindowDot.close, "关闭会话", action: onClose)
windowDot(TXWindowDot.minimize, "最小化", action: onMinimize)
Rectangle().fill(p.border).frame(width: 1, height: 14)
.padding(.horizontal, 2)
TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 6)
Text(session.displayName)
.font(TX.Font.mono(12.5, .medium))
.foregroundStyle(p.text)
.lineLimit(1)
if !contextLine.isEmpty {
Text(contextLine)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
// 稿 08 · 168×44 @Published
Text(gridLabel)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textFaint)
}
}
}
/// window title · cwd稿 3a tmux 退
private var contextLine: String {
guard let t = session.tmuxSummary else {
return session.isLive ? "" : session.phaseText
}
var parts: [String] = []
if !t.currentTitle.isEmpty { parts.append(t.currentTitle) }
if !t.cwd.isEmpty { parts.append(Self.abbreviate(t.cwd)) }
return parts.joined(separator: " · ")
}
/// `/Users/yz/src/x` `~/src/x`稿
static func abbreviate(_ path: String) -> String {
let home = NSHomeDirectory()
if !home.isEmpty, path.hasPrefix(home) { return "~" + path.dropFirst(home.count) }
// home
for prefix in ["/home/", "/Users/"] where path.hasPrefix(prefix) {
let rest = path.dropFirst(prefix.count)
if let slash = rest.firstIndex(of: "/") { return "~" + rest[slash...] }
return "~"
}
return path
}
private var gridLabel: String {
let g = session.screenGrid.value
return "\(g.cols)×\(g.rows)"
}
private func windowDot(_ color: Color, _ label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Circle()
.fill(color)
.frame(width: 13, height: 13)
.overlay(Circle().strokeBorder(Color.black.opacity(0.25), lineWidth: 0.5))
.frame(width: 22, height: 30)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(label)
}
}
/// `tsnet | mosh | tmux` 1×16pt 线
///
/// **** tmux
/// tmux · RTT HANDOFF §12.4
struct TerminalStatusCapsule: View {
@ObservedObject var session: TerminalSession
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
FloatingCapsule {
HStack(spacing: 0) {
cell(egressText, color: egressColor, dot: true)
separator
cell(transportText, color: transportColor, dot: false)
separator
cell(tmuxText, color: p.text2, dot: false)
}
}
}
private var separator: some View {
Rectangle().fill(p.border).frame(width: 1, height: 16)
.padding(.horizontal, TX.Space.s)
}
private func cell(_ text: String, color: Color, dot: Bool) -> some View {
HStack(spacing: 5) {
if dot { TXStatusDot(color: color, size: 6) }
Text(text)
.font(TX.Font.mono(12, .medium))
.foregroundStyle(color)
.lineLimit(1)
}
}
// tsnet /
private var egressText: String {
switch session.link {
case .relay: "tsnet"
case .direct: "direct"
case .connecting: "连接中"
case .reconnecting: "tsnet"
case .offline: "离线"
}
}
private var egressColor: Color {
switch session.link {
case .direct, .relay: p.online
case .connecting: p.text4
case .reconnecting: p.reconnecting
case .offline: p.offline
}
}
// mosh / SSH
private var transportText: String {
if case .reconnecting(let n) = session.link {
return "\(session.moshEngaged ? "mosh" : "ssh") 重连 \(n)"
}
return session.moshEngaged ? "mosh" : "ssh"
}
private var transportColor: Color {
if case .reconnecting = session.link { return p.reconnecting }
// RTT 绿/ RTT
return p.text2
}
//
private var tmuxText: String {
if let t = session.tmuxSummary { return t.name.isEmpty ? "tmux" : "tmux \(t.name)" }
// / tmux
if session.pendingSessionChoice != nil { return "选择 tmux 会话…" }
if session.tmuxSkipped { return "原生窗口 · 不经 tmux" }
if session.tmuxUnavailable { return "无 tmux · 客户端窗口" }
return session.isLive ? "检测 tmux…" : ""
}
}
/// **** / /
///
/// tsnet Up dial mosh tmux attach
///
/// ****
struct TerminalStateOverlay: View {
@ObservedObject var session: TerminalSession
let onRetry: () -> Void
let onBack: () -> Void
///
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
/// tsnet Up / mosh
@State private var waited = 0
private var p: TXPalette { theme.palette }
/// phaseText SessionMachine ``
private var failure: String? {
session.phaseText.hasPrefix("失败") ? String(session.phaseText.dropFirst(3)) : nil
}
private var isConnecting: Bool {
!session.isLive && failure == nil && session.phaseText != "已关闭"
}
var body: some View {
if let failure {
state(icon: "exclamationmark.triangle", tint: p.danger, title: "连接失败",
detail: failure) {
HStack(spacing: TX.Space.s) {
TXButton(title: "返回首页", action: onBack)
TXButton(title: "重试", emphasized: true, action: onRetry)
}
}
} else if session.phaseText == "已关闭" {
state(icon: "power", tint: p.textWeak, title: "会话已关闭",
detail: "服务端连接已断开。") {
HStack(spacing: TX.Space.s) {
TXButton(title: "返回首页", action: onBack)
TXButton(title: "重新连接", emphasized: true, action: onRetry)
}
}
} else if isConnecting {
state(icon: nil, tint: p.text4, title: session.phaseText,
detail: session.banner.isEmpty ? connectHint : session.banner) {
VStack(spacing: TX.Space.xs) {
if waited >= 3 {
Text("已等待 \(waited)s")
.font(TX.Font.mono(11))
.foregroundStyle(p.textFaint)
}
if waited >= 5 {
// 12s
TXButton(title: "取消连接", action: onCancel)
}
}
}
.task(id: session.phaseText) {
waited = 0
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
waited += 1
}
}
}
}
/// tsnet Up mosh
private var connectHint: String {
switch session.link {
case .relay: "正在经 Tailscale 出口建立连接…"
case .connecting, .direct: session.moshEngaged ? "正在引导 mosh…" : "正在建立 SSH 连接…"
case .reconnecting(let n): "\(n) 次重连中…"
case .offline: "等待连接…"
}
}
private func state<Actions: View>(icon: String?, tint: Color, title: String, detail: String,
@ViewBuilder actions: () -> Actions) -> some View {
VStack(spacing: TX.Space.s) {
if let icon {
Image(systemName: icon)
.font(.system(size: 26, weight: .light))
.foregroundStyle(tint)
} else {
ProgressView().controlSize(.regular).tint(TXAccent.base)
}
Text(title)
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Text(detail)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.multilineTextAlignment(.center)
.lineLimit(3)
.frame(maxWidth: 420)
actions()
.padding(.top, TX.Space.xs)
}
.padding(TX.Space.l)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// tmux 稿 `2c` App mosh ** App
/// ** tmux****
struct NoTmuxBanner: View {
let onInstall: () -> Void
let onDismiss: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: TX.Space.s) {
Image(systemName: "info.circle")
.font(.system(size: 12))
.foregroundStyle(p.warning)
Text("未检测到 tmux · 当前是客户端窗口mosh 能兜断线,但**关掉 App 后服务端不会继续跑**")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.text3)
.lineLimit(1)
Spacer(minLength: TX.Space.s)
TXButton(title: "安装并启用 tmux", action: onInstall)
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(p.textWeak)
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 40)
.background(p.surfaceDark.opacity(0.92))
.overlay(alignment: .bottom) { Rectangle().fill(p.divider).frame(height: 1) }
}
}
/// tmux
struct TmuxInstallSheet: View {
let onRun: (String) -> Void
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
///
private let combined = "command -v apt-get >/dev/null && sudo apt-get install -y tmux || " +
"command -v dnf >/dev/null && sudo dnf install -y tmux || " +
"command -v pacman >/dev/null && sudo pacman -S --noconfirm tmux || " +
"command -v apk >/dev/null && sudo apk add tmux || " +
"command -v brew >/dev/null && brew install tmux"
var body: some View {
TXModalScrim(onDismiss: onCancel) {
VStack(alignment: .leading, spacing: TX.Space.s) {
Text("安装 tmux")
.font(TX.Font.mono(15, .medium))
.foregroundStyle(p.text)
Text("tmux 让会话留在服务端:关掉 App、换网络、睡一觉回来都能原样接回。\n下面这条会按服务器的包管理器自动选择(需要 sudo 权限)。")
.font(TX.Font.mono(12))
.foregroundStyle(p.text3)
.lineSpacing(4)
Text(combined)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.text4)
.padding(TX.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.bgDeep, in: RoundedRectangle(cornerRadius: 8))
HStack(spacing: TX.Space.s) {
Spacer()
TXButton(title: "取消", action: onCancel)
TXButton(title: "在终端执行", emphasized: true) { onRun(combined) }
}
.padding(.top, TX.Space.xs)
}
.padding(TX.Space.l)
.frame(width: 520)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.dialog)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.shadow(color: .black.opacity(0.7), radius: 40, y: 30)
}
}
}
/// 34ptUI-5
struct TerminalHintBar: View {
/// Pin · / resize
let context: String
let hints: [(key: String, label: String)]
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: TX.Space.s) {
Text(context)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
Spacer(minLength: TX.Space.m)
ForEach(hints, id: \.key) { hint in
HStack(spacing: 5) {
Text(hint.key)
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(p.text4)
Text(hint.label)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
}
}
}
.padding(.horizontal, TX.Space.m)
.frame(height: TX.Layout.footHeight)
}
}

View File

@@ -0,0 +1,279 @@
import SwiftUI
// 稿 `3c ` `3b `
//
//
// - ** / M / = **
// - ** / W = **`esc` ``
/// + /
struct TXModalScrim<Content: View>: View {
let onDismiss: () -> Void
@ViewBuilder let content: Content
var body: some View {
ZStack {
Color(hex: 0x0A0B12).opacity(0.42)
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture(perform: onDismiss)
content
}
.transition(.opacity)
}
}
// MARK: - 3c
///
/// - **tmux **`detach-client`
/// `kill-session`****
/// - **** tmux
struct CloseConfirmDialog: View {
@ObservedObject var session: TerminalSession
/// tmux/
let onDetach: () -> Void
/// tmux kill-session
let onKill: () -> Void
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
@State private var confirmingKill = false
private var p: TXPalette { theme.palette }
private var isTmux: Bool { session.tmuxSummary != nil }
var body: some View {
TXModalScrim(onDismiss: onCancel) {
VStack(alignment: .leading, spacing: 0) {
Text(title)
.font(TX.Font.mono(15, .medium))
.foregroundStyle(p.text)
.padding(.bottom, 8)
message
.padding(.bottom, TX.Space.l)
buttons
}
.padding(TX.Space.l)
.frame(width: isTmux ? 436 : 404)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.dialog)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.shadow(color: .black.opacity(0.7), radius: 40, y: 30)
}
}
private var title: String {
if let t = session.tmuxSummary, !t.name.isEmpty {
return "关闭 \(session.displayName) · \(t.name)?"
}
// 1
return "关闭 \(session.displayName) · 窗口 1?"
}
@ViewBuilder private var message: some View {
if let t = session.tmuxSummary {
Text("tmux 会话 (\(t.windows) windows / \(t.panes) panes)。仅断开后服务端继续跑,下次原样接回。")
.font(TX.Font.mono(12))
.foregroundStyle(p.text3)
.lineSpacing(4)
} else {
Text("没有 tmux 兜底,")
.font(TX.Font.mono(12))
.foregroundStyle(p.text3)
+ Text("前台进程会随之结束")
.font(TX.Font.mono(12, .medium))
.foregroundStyle(p.warning)
+ Text("。想留着就点黄点最小化。")
.font(TX.Font.mono(12))
.foregroundStyle(p.text3)
}
}
@ViewBuilder private var buttons: some View {
HStack(spacing: TX.Space.s) {
if isTmux {
// kill-session
Button {
if confirmingKill { onKill() } else { confirmingKill = true }
} label: {
Text(confirmingKill ? "确认结束会话?" : "结束会话")
.font(TX.Font.mono(12, confirmingKill ? .semibold : .regular))
.foregroundStyle(p.danger)
}
.buttonStyle(.plain)
}
Spacer()
TXButton(title: "取消", action: onCancel)
if isTmux {
TXButton(title: "仅断开 ⏎", emphasized: true, action: onDetach)
} else {
dangerButton
}
}
}
/// #8d5560 / rgba(92,58,63,.35) / #f5a0ac
private var dangerButton: some View {
Button(action: onDetach) {
Text("关闭窗口")
.font(TX.Font.mono(12, .medium))
.foregroundStyle(Color(hex: 0xF5A0AC))
.padding(.horizontal, 14)
.frame(height: 32)
.background(Color(hex: 0x5C3A3F).opacity(0.35),
in: RoundedRectangle(cornerRadius: TX.Radius.control))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(Color(hex: 0x8D5560), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
// MARK: - 3b
/// **** D / D
/// `split-window` detached `join-pane -s` tmux
///
/// ****
struct SplitMenuView: View {
@ObservedObject var session: TerminalSession
/// true = Dfalse = D
let vertical: Bool
let onPick: (SplitTarget) -> Void
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
enum SplitTarget {
case currentSession
case otherSession(String)
case newSession
/// tmux SSH channel HANDOFF §12.4
case nativeWindow
}
/// = `isAttached`
/// attach
private var detached: [TmuxSessionInfo] {
let current = session.tmuxSummary?.name ?? ""
return session.tmuxSessionList.filter { $0.name != current }
}
var body: some View {
TXModalScrim(onDismiss: onCancel) {
VStack(alignment: .leading, spacing: 0) {
header
Rectangle().fill(p.divider).frame(height: 1)
if session.tmuxSummary != nil {
TXSectionLabel(text: "tmux 会话")
.padding(.horizontal, TX.Space.m)
.padding(.top, TX.Space.s)
.padding(.bottom, 2)
row(title: currentName, detail: currentDetail, dot: p.online,
tag: "当前", selected: true) { onPick(.currentSession) }
ForEach(detached) { s in
// detached 绿稿
row(title: s.name,
detail: "\(s.windows) window\(s.windows == 1 ? "" : "s") · detached",
dot: p.online,
tag: s.lastActivity.map { SessionCard.relative($0) }) {
onPick(.otherSession(s.name))
}
}
row(title: "新建 tmux 会话…", detail: nil, dot: nil, tag: nil) {
onPick(.newSession)
}
Rectangle().fill(p.divider).frame(height: 1)
.padding(.vertical, TX.Space.xs)
}
//
row(title: "原生窗口", detail: "不经 tmux · 关 App 即结束(二期)",
dot: nil, tag: nil, disabled: true) {}
}
.padding(.bottom, TX.Space.xs)
.frame(width: 360)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.dialog - 1))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.dialog - 1)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.shadow(color: .black.opacity(0.7), radius: 40, y: 30)
}
}
private var header: some View {
HStack {
Text(vertical ? "竖分屏到…" : "横分屏到…")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Spacer()
Text(session.displayName)
.font(TX.Font.mono(11))
.foregroundStyle(TXAccent.mid)
}
.padding(.horizontal, TX.Space.m)
.frame(height: 44)
}
private var currentName: String {
guard let t = session.tmuxSummary else { return session.displayName }
return t.name.isEmpty ? "当前会话" : t.name
}
private var currentDetail: String {
guard let t = session.tmuxSummary else { return "" }
var parts: [String] = []
if !t.currentTitle.isEmpty { parts.append(t.currentTitle) }
parts.append("\(t.windows) window\(t.windows == 1 ? "" : "s")")
if t.panes > 0 { parts.append("\(t.panes) panes") }
return parts.joined(separator: " · ")
}
/// ** 20pt** 20pt
private func row(title: String, detail: String?, dot: Color?, tag: String?,
selected: Bool = false, disabled: Bool = false,
action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: TX.Space.s) {
ZStack {
if let dot { TXStatusDot(color: dot, size: 7) }
}
.frame(width: 20)
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(TX.Font.mono(13, .medium))
.foregroundStyle(disabled ? p.textWeak : p.text)
if let detail, !detail.isEmpty {
Text(detail)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
}
Spacer(minLength: TX.Space.xs)
if let tag {
Text(tag)
.font(TX.Font.mono(10.5, .medium))
.foregroundStyle(selected ? TXAccent.text : TXAccent.mid)
}
}
.padding(.horizontal, TX.Space.s)
.frame(height: 40)
.frame(maxWidth: .infinity, alignment: .leading)
.background(selected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(selected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.padding(.horizontal, TX.Space.xs)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(disabled)
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,267 @@
#if DEBUG
import Foundation
import UIKit
/// UI DEBUG
///
/// GUI `simctl io screenshot` 稿稿
/// ****
/// in-memory ****
///
/// `xcrun simctl launch <UDID> ai.athom.terminalx --args -txUIFixture 1`
/// `-txHost/-txUser/-txPass` CLAUDE.md
enum UIPreviewFixture {
/// 稿 `1e -` / tsnet /
/// `name` `hosts` `SavedHost.id`
struct Spec {
let name: String
let link: LinkState
let tmux: TmuxSummary?
let lines: [String]
}
/// Tailscale id tsnet fixture
static let fixtureTailnetID = UUID(uuidString: "00000000-0000-0000-0000-0000000000FE")!
/// tailnet +
static let tailnets: [TailscaleConnection] = [
TailscaleConnection(id: fixtureTailnetID, name: "athom.ts.net",
hostname: "ipad-pro", needsAuthKey: false, lastSelfIP: "100.92.14.7"),
]
/// 稿 `1e` 7 +
static let hosts: [SavedHost] = [
SavedHost(name: "mac-studio", host: "100.71.203.4", useMosh: true,
auth: .inlinePassword(username: "yz", password: ""),
tailscaleID: fixtureTailnetID, group: "生产", os: "macOS · M4 Max"),
SavedHost(name: "ubuntu-cn2", host: "100.88.9.61", useMosh: true,
auth: .inlinePassword(username: "yz", password: ""),
tailscaleID: fixtureTailnetID, group: "", os: "Ubuntu 26.04"),
SavedHost(name: "edge-sg1", host: "10.1.2.11", useMosh: false,
auth: .inlinePassword(username: "root", password: ""),
tailscaleID: fixtureTailnetID, group: "", os: "Debian 13"),
SavedHost(name: "edge-sg2", host: "10.1.2.12", useMosh: false,
auth: .inlinePassword(username: "root", password: ""),
group: "", os: "Debian 13"),
SavedHost(name: "nas-home", host: "100.104.2.18", useMosh: true,
auth: .inlinePassword(username: "admin", password: ""),
group: "家里", os: "TrueNAS"),
SavedHost(name: "pi-lab", host: "192.168.1.31", useMosh: false,
auth: .inlinePassword(username: "pi", password: ""),
group: "家里", os: "Raspberry Pi OS"),
SavedHost(name: "mosh-lab", host: "192.168.1.44", useMosh: true,
auth: .inlinePassword(username: "yz", password: ""),
group: "家里", os: "Fedora 44"),
]
static let specs: [Spec] = [
Spec(name: "mac-studio", link: .direct,
tmux: TmuxSummary(name: "dev", windows: 3, panes: 4, currentTitle: "运行测试"), lines: [
"→ terminalx git:(main) git fetch --all --prune",
"Fetching origin … done",
"→ terminalx git:(main) swiftformat --lint Sources",
"✓ 0 issues in 84 files",
"→ terminalx git:(main) swift build -c release --arch arm64",
"[142/142] Compiling TXCore",
"✓ build complete (14.2s)",
"→ terminalx git:(main) swift test --package-path packages/TXCore",
"◇ Test run started.",
" ✔ parses %output escaped octal",
" ✔ layout diff keeps pane identity",
" ✔ host-key pin survives wipe",
"◇ 50 tests passed after 0.412s",
"→ terminalx git:(main) xcodegen generate --spec project.yml",
"✅ Created TerminalX.xcodeproj",
"→ terminalx git:(main) xcodebuild -scheme TerminalX build",
"** BUILD SUCCEEDED **",
"→ terminalx git:(main) git status --short",
" M apps/TerminalX/iOS/RailSidebar.swift",
" M apps/TerminalX/iOS/HomeView.swift",
"?? docs/design/UI-DESIGN-HANDOFF.md",
"→ terminalx git:(main) scripts/tx-shot.sh /tmp/v3.png",
" pixelWidth: 2752",
" pixelHeight: 2064",
"→ terminalx git:(main) swift test --package-path packages/TXCore",
"◇ 50 tests passed after 0.398s",
"→ terminalx git:(main) tmux ls",
"dev: 3 windows (created Fri 09:12:03)",
"scratch: 1 windows (created Tue)",
"→ terminalx git:(main) tailscale status | head -3",
"100.71.203.4 mac-studio direct",
"100.88.9.61 ubuntu-cn2 relay hkg",
"→ terminalx git:(main) ",
]),
Spec(name: "ubuntu-cn2", link: .relay,
tmux: TmuxSummary(name: "build", windows: 1, panes: 1, currentTitle: "集成测试"), lines: [
"→ cn2 ~ journalctl -u build-runner -n 3 --no-pager",
"12:05:02 Started build-runner@42",
"12:06:02 build-runner@42 succeeded",
"→ cn2 ~ systemctl status build-runner --no-pager",
"● build-runner.service — active (running)",
" since Fri 09:12:03 CST; 2h 54min ago",
"→ cn2 ~ ninja -C build -j 8",
"[148/148] Linking libmosh-core.a",
"✓ ninja: no work to do.",
"→ cn2 ~ tailscale netcheck | head -5",
" * UDP: true",
" * IPv4: yes",
" * UPnP: false",
"→ cn2 ~ ./run-integration.sh --tsnet",
"▸ joining tailnet as ci-runner…",
"▸ peer enumeration: 7 nodes",
"▸ mosh relay loopback:60122 ⇄ tsnet",
"▸ handshake ok, AES-OCB session up",
" roaming: peer addr switched",
" resume ok in 1.8s, 0 bytes lost",
" suspend 90s → wake",
" resume ok in 2.4s",
"▸ 14/14 passed",
"→ cn2 ~ df -h /tank | tail -1",
"tank 187T 217T 7.87 42%",
"→ cn2 ~ ",
]),
Spec(name: "nas-home", link: .reconnecting(attempt: 3), tmux: nil, lines: [
"nas ~ $ zfs get compressratio tank",
"tank compressratio 1.34x",
"nas ~ $ docker ps --format 'table {{.Names}}'",
"NAMES STATUS",
"plex Up 6 days",
"syncthing Up 6 days",
"nas ~ $ uname -sr",
"Linux 6.12.9-truenas",
"nas ~ $ zpool status -x",
" all pools are healthy",
"nas ~ $ df -h /tank",
"Filesystem Size Used Avail Use%",
"tank 187T 217T 7.87 42%",
"nas ~ $ zfs list -t snapshot | tail -3",
"tank/media@auto-2026-07-24 1.2G",
"tank/docs@auto-2026-07-24 318M",
"tank/vm@auto-2026-07-24 2.4G",
"nas ~ $ smartctl -H /dev/sda | tail -2",
"SMART overall-health: PASSED",
"nas ~ $ systemctl is-active smbd nfs-server",
"active",
"active",
"nas ~ $ tail -2 /var/log/messages",
"12:04:31 nas kernel: wlan0 rssi -74",
"12:06:20 nas tailscaled: peer offline",
"nas ~ $ ",
]),
]
/// attached + detached稿 `3b`
static func tmuxSessions(current: String) -> [TmuxSessionInfo] {
// timeIntervalSinceNow
[
TmuxSessionInfo(name: current, windows: 3, isAttached: true, lastActivity: nil),
TmuxSessionInfo(name: "scratch", windows: 1, isAttached: false,
lastActivity: Date(timeIntervalSinceNow: -3 * 86400)),
TmuxSessionInfo(name: "deploy", windows: 2, isAttached: false,
lastActivity: Date(timeIntervalSinceNow: -86400)),
]
}
/// `-txOpenAsHost 1`
static func hostFromLaunchArgs() -> SavedHost? {
let d = UserDefaults.standard
guard let host = d.string(forKey: "txHost"), !host.isEmpty,
let user = d.string(forKey: "txUser") else { return nil }
let port = d.integer(forKey: "txPort")
return SavedHost(
name: host, host: host, port: port == 0 ? 22 : port,
useMosh: d.bool(forKey: "txMosh"),
useTmux: d.object(forKey: "txTmux") == nil ? true : d.bool(forKey: "txTmux"),
tmuxSession: d.string(forKey: "txTmuxSession"),
auth: .inlinePassword(username: user, password: d.string(forKey: "txPass") ?? ""))
}
/// `-txUIFixture 1`
static var isEnabled: Bool {
UserDefaults.standard.bool(forKey: "txUIFixture")
}
/// `xcrun simctl openurl <UDID> "terminalx://ui/<>"`
///
/// GUIidb companion
/// URL `AppRouter`/`SessionManager`
///
/// `home` · `minimize` · `close`() · `close-now`() · `split/v|h`
/// · `split-now/v|h`() · `resize/<cols>`(resize-pane)
/// · `pick/<>` · `pick-new/<>` · `pick-native`
/// · `dismiss` · `session/<>`1 · `sidebar/toggle|pin`
@MainActor
static func handle(uiCommand path: [String], manager: SessionManager, router: AppRouter) -> Bool {
switch path.first {
case "home":
router.goHome()
case "minimize":
if let id = router.route.sessionID { manager.minimize(id); router.goHome() }
case "close":
//
guard router.route.sessionID != nil else { return false }
router.present(.closeConfirm)
case "close-now":
//
if let id = router.route.sessionID { router.goHome(); manager.close(id) }
case "split":
guard router.route.sessionID != nil else { return false }
router.present(.splitMenu(vertical: path.dropFirst().first != "h"))
case "dismiss":
router.dismissModal()
case "split-now":
// split-window
guard let id = router.route.sessionID, let s = manager.session(id),
let tmux = s.tmuxController else { return false }
tmux.splitWindow(vertical: path.dropFirst().first != "h")
case "pick":
// attach `ui/pick/<name>`
guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session),
let name = path.dropFirst().first else { return false }
s.attachTmuxSession(name)
case "pick-new":
guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session)
else { return false }
s.createTmuxSession(path.dropFirst().first ?? "main")
case "pick-native":
// route
guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session)
else { return false }
s.useNativeTerminal()
case "resize":
// resize-pane DragGesture`ui/resize/<cols>`
guard let id = router.route.sessionID, let s = manager.session(id),
let tmux = s.tmuxController,
let win = tmux.windows.first(where: { $0.id == tmux.activeWindowID }),
let pane = win.activePane,
let cols = path.dropFirst().first.flatMap(Int.init) else { return false }
tmux.resizePane(pane, cols: cols)
case "session":
guard let n = path.dropFirst().first.flatMap(Int.init),
let s = manager.session(atShortcutIndex: n - 1) else { return false }
manager.focus(s)
router.enter(s.id)
case "sidebar":
if path.dropFirst().first == "pin" { router.togglePin() } else { router.toggleSidebar() }
default:
return false
}
NSLog("TXUI cmd=\(path.joined(separator: "/")) route=\(router.route) sidebar=\(router.sidebar)")
return true
}
/// `-txLandscape 1`
///
/// GUI iPad Pro 13 1366×1024`simctl`
/// iOS 16+ `requestGeometryUpdate` app `simctl io screenshot`
/// DEBUG
@MainActor
static func forceLandscapeIfRequested() {
guard UserDefaults.standard.bool(forKey: "txLandscape") else { return }
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
scene.requestGeometryUpdate(.iOS(interfaceOrientations: .landscapeRight)) { error in
NSLog("TXUI landscape request failed: \(error.localizedDescription)")
}
}
}
#endif