Files

168 lines
7.2 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindDomain
/// A group form owns its login attempt; authentication never dismisses or saves the form.
@MainActor
@Observable
public final class TailscaleGroupEditor {
public let model: TailscaleModel
private let initialDraft: HostGroup
private var initialHostname: String
private var logoutProfileID: UUID?
public var draft: HostGroup
public private(set) var original: HostGroup?
public private(set) var enabled: Bool
public var hostname: String
public var exitNodeID: String
public var controlURL: String
public private(set) var operationID: UUID?
public private(set) var isSaving = false
public init(model: TailscaleModel, group: HostGroup?, parentID: UUID? = nil) {
self.model = model
original = group
let initial = group ?? HostGroup(name: "", parentID: parentID)
draft = initial
initialDraft = initial
enabled = group?.tailscale != nil
let savedHostname = group.flatMap { model.savedHostname(groupID: $0.id) } ?? ""
hostname = savedHostname
initialHostname = savedHostname
exitNodeID = group?.tailscale?.exitNodeID ?? ""
controlURL = group?.tailscale?.network.controlURL ?? TailscaleNetwork.defaultControlURL
}
public var pending: TailscalePendingLogin? {
guard let operationID, model.pendingLogin?.id == operationID else { return nil }
return model.pendingLogin
}
public var isLoggingIn: Bool { operationID != nil && model.loginOperationID == operationID && model.isLoggingIn }
public var isLogoutPending: Bool { logoutProfileID != nil }
public var canChangeEnabled: Bool { isLogoutPending || !model.runningGroups.contains(draft.id) }
public var isConnected: Bool { pending != nil || (!isLogoutPending && model.runningGroups.contains(draft.id)) }
public var device: TailscaleDeviceInfo? { pending?.device ?? model.devices[draft.id] }
public var network: TailscaleNetwork? { pending?.network ?? model.group(draft.id)?.tailscale?.network }
public var candidates: [TailscalePeer] {
(pending?.peers ?? model.peers[draft.id] ?? []).filter { $0.exitNodeOption == true }
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
public var loginURL: URL? { model.loginOperationID == operationID ? model.loginURL : nil }
private var requiresLogin: Bool {
guard enabled, !isConnected else { return false }
let entered = controlURL.trimmingCharacters(in: .whitespacesAndNewlines)
let canonical = try? TailscaleNetwork(
controlURL: entered.isEmpty ? TailscaleNetwork.defaultControlURL : entered,
dnsSuffix: "pending.invalid"
).controlURL
return original?.tailscale == nil || canonical != original?.tailscale?.network.controlURL
|| hostname != initialHostname
}
public var canSave: Bool {
!draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isLoggingIn && !isSaving
&& !requiresLogin
}
public var isDirty: Bool {
draft != (original ?? initialDraft)
|| enabled != (original?.tailscale != nil) || isLoggingIn || pending != nil || isLogoutPending
|| hostname != initialHostname
|| exitNodeID != (original?.tailscale?.exitNodeID ?? "")
|| controlURL != (original?.tailscale?.network.controlURL ?? TailscaleNetwork.defaultControlURL)
}
public func setEnabled(_ value: Bool) {
guard !isSaving, canChangeEnabled else { return }
if !value { cancelLogin() }
enabled = value
}
public func login(authKey: String?) {
guard enabled, !isConnected, !isSaving else { return }
let url = controlURL.trimmingCharacters(in: .whitespacesAndNewlines)
operationID = model.beginLogin(
controlURL: url.isEmpty ? TailscaleNetwork.defaultControlURL : url,
authKey: authKey, groupID: draft.id, hostname: hostname)
}
public func receiveLogin() {
guard let pending else { return }
if draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { draft.name = pending.suggestedName }
hostname = pending.hostname
exitNodeID = pending.exitNodeID
}
public func logout() {
guard !isSaving else { return }
if pending != nil {
cancelLogin()
} else {
logoutProfileID = model.profileID(groupID: draft.id)
}
}
public func cancel() {
cancelLogin()
logoutProfileID = nil
}
private func cancelLogin() {
if let operationID { model.cancelLogin(operationID: operationID) }
operationID = nil
}
public func save() async throws -> HostGroup {
guard canSave else { throw TailscaleConfigurationError.unavailable }
isSaving = true
defer { isSaving = false }
if let logoutProfileID, let currentProfileID = model.profileID(groupID: draft.id),
currentProfileID != logoutProfileID
{
throw TailscaleConfigurationError.unavailable
}
let saved: HostGroup
if let pending {
do {
saved = try await model.commitLogin(
pending.id, group: draft, hostname: hostname, exitNodeID: exitNodeID)
} catch {
// A local activation failure can follow a successful public save.
// Keep the baseline current so disabling or retrying remains possible.
if let latest = model.group(draft.id), latest.tailscale?.network == pending.network {
original = latest
}
throw error
}
operationID = nil
} else {
// Validate the binding before applying any local edits, and again after suspension.
guard model.group(draft.id)?.tailscale?.network == original?.tailscale?.network else {
throw TailscaleConfigurationError.unavailable
}
if enabled, !isLogoutPending, model.runningGroups.contains(draft.id) {
try await model.saveHostname(hostname, groupID: draft.id)
}
saved = try model.saveGroup(
draft, original: original, enabled: enabled,
exitNodeID: enabled && isConnected && exitNodeID != (original?.tailscale?.exitNodeID ?? "")
? exitNodeID : nil)
if let logoutProfileID {
// A committed group edit must remain the baseline if local logout needs a retry.
original = saved
draft = saved
try await model.logout(groupID: saved.id, profileID: logoutProfileID)
}
}
logoutProfileID = nil
let latest = model.group(saved.id) ?? saved
original = latest
draft = latest
hostname = model.savedHostname(groupID: saved.id) ?? ""
initialHostname = hostname
controlURL = latest.tailscale?.network.controlURL ?? TailscaleNetwork.defaultControlURL
exitNodeID = latest.tailscale?.exitNodeID ?? ""
return latest
}
}