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