业务逻辑(用户定稿): - 点主机总是新建会话(会话:主机=多:1);activate 选中断线会话自动重连 - 「直接输入」凭据保存时自动落 Keychain(去重复用),主机只存引用 - 主机编辑页可就地新建 Tailscale 连接;删无人引用的旧 HostsListView 首页/弹框减法: - 删侧边栏「连接新主机」、无 tmux 横幅与安装引导、页头「新建连接」 - 搜索移到「全部主机」行;添加主机虚线卡与主机卡等高(骨架复刻) - 会话卡加 ✕ + 复用终端页同款二次确认 - 会话选择器 sheet → 项目风格居中弹框(去手柄/Header/最近) 终端页修复: - 侧边栏会话列表改创建顺序,切换不再重排抖动 - 焦点高亮上移到 pane 容器边框(四边完整,去负 padding) - 软键盘双重让位修复:画布忽略键盘 + TXKeyboardObserver 显式让位一次 - 自动聚焦:focusTick 脉冲强制翻转 FocusState + surface 就绪补聚焦; vendor 补 iPadOS 触摸板 indirectPointer 的 becomeFirstResponder Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
210 lines
9.1 KiB
Swift
210 lines
9.1 KiB
Swift
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)
|
||
}
|
||
}
|