Files
Termind/Frameworks/TermindApplication/FeatureModels/TailscaleGroupEditor.swift
T
Kaede Akino 996ea85196 fix(tailscale)!: scope host identities and defer saved logout
Derive groupScopedV2 host UUIDs from the group, MagicDNS suffix and stable
node ID. Remove experimental identity compatibility and Tailnet conflict
handling, and hide unavailable Metrics hosts while retaining configuration.

Keep saved enrollments active until Save. Retire unsaved login candidates
on Logout or Discard, including repeated replacement logins, while preserving
the original enrollment. Use the same behavior in New and Edit forms.

Validation: 94 focused tests, iOS Simulator build, Swift formatting and
AppStrings/Chinese localization checks passed. The default suite still has
9 failures in untouched notification, agent and key export tests.

BREAKING CHANGE: Tailscale only accepts groupScopedV2 identities. Remove
old Tailscale test data before using this build; no ID migration is provided.
2026-10-03 16:58:40 +08:00

165 lines
7.1 KiB
Swift

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) || operationID != nil || isLogoutPending
|| hostname != (model.savedHostname(groupID: draft.id) ?? "")
|| 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
}
}