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) } }