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 /// 保存回调(主机编辑页就地新建时用于自动选中刚建的连接)。 var onSave: ((TailscaleConnection) -> Void)? @EnvironmentObject var theme: TXThemeManager @Environment(\.dismiss) private var dismiss @State private var name = "" @State private var hostname = "terminalx-ipad" @State private var authKey = "" @State private var registering = false @State private var statusText = "" @State private var registered = false @State private var draft: TailscaleConnection? private var p: TXPalette { theme.palette } private var canRegister: Bool { !name.isEmpty && (!authKey.isEmpty || registered) } var body: some View { NavigationStack { ZStack { p.bg.ignoresSafeArea() Form { Section("连接") { TXTextField(title: "名称", text: $name, placeholder: "公司 tailnet") TXTextField(title: "本节点主机名", text: $hostname) } Section { TXTextField(title: "Auth Key(tskey-…)", text: $authKey, secure: true) Button { register() } label: { HStack { if registering { ProgressView().controlSize(.small) } Text(registering ? "注册中…" : "注册 / 连接测试") } } .disabled(!canRegister || registering) .txRow(p) } header: { Text("认证") } footer: { Text("Auth key 仅用于首次注册,注册成功后不保存(节点身份持久化在设备本地)。") } if !statusText.isEmpty { Section { Text(statusText) .font(.system(size: 13)) .foregroundStyle(registered ? p.accent : p.warning) .txRow(p) } } } .scrollContentBackground(.hidden) } .navigationTitle(existing == nil ? "添加 Tailscale" : "编辑 Tailscale") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() }.tint(p.textSecondary) } ToolbarItem(placement: .confirmationAction) { Button("完成") { finish() }.tint(p.accent) } } .toolbarBackground(p.bg, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) .onAppear(perform: populate) } .tint(p.accent) } private func populate() { guard let c = existing else { return } name = c.name; hostname = c.hostname registered = !c.needsAuthKey draft = c if registered { statusText = c.lastSelfIP.map { "已注册 · \($0)" } ?? "已注册" } } /// 注册:经 TsnetRegistry Up 该连接的节点(后台阻塞),成功后 SelfIP 落库、needsAuthKey=false。 private func register() { // 确定连接身份(新建则造一个,固化 stateDir)。 let conn = draft ?? existing ?? { let c = TailscaleConnection(name: name, hostname: hostname) return c }() draft = conn registering = true; statusText = "" let connID = conn.id, dir = conn.stateDirName, host = hostname, key = authKey Task.detached { do { let node = try TsnetRegistry.shared.node( connID: connID, stateDirName: dir, hostname: host, authKey: key, timeoutMs: 45000) let ip = node.selfIP() await MainActor.run { registering = false; registered = true statusText = ip.isEmpty ? "已注册(IP 获取中)" : "已注册 · \(ip)" // 落库(保留同一 id/stateDir)。 let updated = TailscaleConnection(id: connID, name: name.isEmpty ? "tailnet" : name, hostname: host, needsAuthKey: false, lastSelfIP: ip.isEmpty ? nil : ip) persist(updated) } } catch { await MainActor.run { registering = false statusText = "注册失败:\(error.localizedDescription)" } } } } /// 完成:若已注册则确保落库;未注册也保存 meta(needsAuthKey=true,供之后再认证)。 private func finish() { let base = draft ?? existing let conn: TailscaleConnection if let base { conn = TailscaleConnection(id: base.id, name: name.isEmpty ? "tailnet" : name, hostname: hostname, needsAuthKey: !registered, lastSelfIP: registered ? base.lastSelfIP : nil) } else { conn = TailscaleConnection(name: name.isEmpty ? "tailnet" : name, hostname: hostname, needsAuthKey: !registered) } persist(conn) dismiss() } private func persist(_ conn: TailscaleConnection) { if store.connections.contains(where: { $0.id == conn.id }) { store.update(conn) } else { store.add(conn) } draft = conn onSave?(conn) } }