Files
Termind/Frameworks/TermindPersistence/SwiftDataVaultStore.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

216 lines
9.0 KiB
Swift

import Foundation
import SwiftData
import TermindApplication
import TermindDomain
@MainActor
public final class HostPersistence {
public static let schema = TermindPersistence.schema
public let context: ModelContext
public init(context: ModelContext) {
self.context = context
}
public func load() throws -> VaultSnapshot {
let snapshot = try fetchSnapshot()
let journal = VaultSyncJournal(context: context)
_ = try journal.bootstrapIfNeeded(snapshot)
if context.hasChanges { try context.save() }
return snapshot
}
private func fetchSnapshot() throws -> VaultSnapshot {
let hosts = try context.fetch(FetchDescriptor<PersistentHost>()).map(\.value)
let groups = try Self.groups(in: context)
let identities = try context.fetch(FetchDescriptor<PersistentIdentity>()).map(\.value)
let keys = try context.fetch(FetchDescriptor<PersistentSSHKey>()).map(\.value)
let knownHosts = try context.fetch(FetchDescriptor<PersistentKnownHost>()).map(\.value)
let snippets = try context.fetch(FetchDescriptor<PersistentSnippet>()).map(\.value)
let snippetPackages = try context.fetch(FetchDescriptor<PersistentSnippetPackage>()).map(\.value)
let portForwardings = try context.fetch(FetchDescriptor<PersistentPortForwardingRule>()).map(\.value)
let workspaceTemplates = try context.fetch(FetchDescriptor<PersistentWorkspaceTemplate>()).map {
try $0.value()
}
return VaultSnapshot(
hosts: hosts, groups: groups, identities: identities, keys: keys,
knownHosts: knownHosts, snippets: snippets,
snippetPackages: snippetPackages, portForwardings: portForwardings,
workspaceTemplates: workspaceTemplates
)
}
public func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet {
try save(snapshot, touching: [])
}
public func save(
_ snapshot: VaultSnapshot, touching keys: Set<VaultRecordKey>
) throws -> VaultMutationSet {
do {
let oldSnapshot = try fetchSnapshot()
let journal = VaultSyncJournal(context: context)
_ = try journal.bootstrapIfNeeded(oldSnapshot)
let mutations = try journal.recordLocalChanges(
from: oldSnapshot, to: snapshot, touching: keys)
try persist(snapshot)
try context.save()
return mutations
} catch {
context.rollback()
throw error
}
}
public func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult {
try applyRemote(envelopes, beforeSave: {})
}
public func applyRemote(
_ envelopes: [VaultEnvelope], deletions: Set<VaultRecordKey> = [], beforeSave: () throws -> Void
) throws -> VaultRemoteApplyResult {
do {
let journal = VaultSyncJournal(context: context)
let deleted = try journal.deletionEnvelopes(deletions)
let decision = try journal.mergeDecision(for: envelopes.filter { !deletions.contains($0.key) } + deleted)
var snapshot = try fetchSnapshot()
for envelope in decision.accepted { try snapshot.apply(envelope) }
try persist(snapshot)
try journal.acceptRemote(decision.accepted)
let needsUpload = decision.needsUpload
try journal.markNeedsUpload(needsUpload)
try beforeSave()
try context.save()
return VaultRemoteApplyResult(
snapshot: snapshot,
acceptedKeys: Set(decision.accepted.map(\.key)),
needsUpload: needsUpload
)
} catch {
context.rollback()
throw error
}
}
public func recordExternalMutation() throws -> VaultMutationSet {
let snapshot = try fetchSnapshot()
let journal = VaultSyncJournal(context: context)
_ = try journal.bootstrapIfNeeded(snapshot)
let vault = try journal.recordCurrentSnapshot(snapshot)
return vault
}
public func modificationDates() throws -> [VaultRecordKey: Date] {
try VaultSyncJournal(context: context).modificationDates()
}
/// Applies an account-owner CloudKit purge as one local persistence transaction.
/// Installation identity and account binding remain stable, while no old snapshot
/// or journal row remains available for bootstrap/re-upload.
public func purgeCloudCache(
scope: CloudSyncScope, assetCleanupKey: String? = nil
) throws -> (VaultSnapshot, Set<VaultRecordKey>) {
var removed = Set<VaultRecordKey>()
do {
try context.commit {
let journal = VaultSyncJournal(context: context)
removed = Set(
try journal.cloudRecords().compactMap { VaultRecordKey(recordName: $0.mutation.recordName) })
try persist(VaultSnapshot())
for row in try context.fetch(FetchDescriptor<PersistentVaultSyncEntry>()) { context.delete(row) }
let state = try journal.state()
state.bootstrapCompleted = true
state.engineStateData = nil
state.pendingUserRecordName = nil
state.lastSuccessfulSync = nil
try CloudSyncSafetyStore(context: context, scope: scope).purgeScope()
try CloudSyncRecordStateStore(context: context, scope: scope).purgeScope()
if let assetCleanupKey {
try CloudSyncAssetCleanupStore(context: context).markPending(
scope: scope, cacheKey: assetCleanupKey)
}
}
return (VaultSnapshot(), removed)
} catch {
context.rollback()
throw error
}
}
private func persist(_ snapshot: VaultSnapshot) throws {
guard snapshot.hosts.allSatisfy({ !$0.isTailscaleManaged }) else {
throw TailscaleConfigurationError.managedHost
}
try persistTailscaleGroups(snapshot.groups)
try reconcile(
try context.fetch(FetchDescriptor<PersistentHost>()), with: snapshot.hosts,
rowID: \.id, valueID: \.id,
make: PersistentHost.init, apply: { $0.apply($1) }
)
try reconcile(
try context.fetch(FetchDescriptor<PersistentHostGroup>()), with: snapshot.groups,
rowID: \.id, valueID: \.id,
make: PersistentHostGroup.init, apply: { $0.apply($1) }
)
try reconcile(
try context.fetch(FetchDescriptor<PersistentIdentity>()), with: snapshot.identities,
rowID: \.id, valueID: \.id,
make: PersistentIdentity.init, apply: { $0.apply($1) }
)
try reconcile(
try context.fetch(FetchDescriptor<PersistentSSHKey>()), with: snapshot.keys,
rowID: \.id, valueID: \.id,
make: PersistentSSHKey.init, apply: { $0.apply($1) }
)
try reconcile(
try context.fetch(FetchDescriptor<PersistentKnownHost>()), with: snapshot.knownHosts,
rowID: \.id, valueID: \.id,
make: PersistentKnownHost.init, apply: { $0.apply($1) }
)
try reconcile(
try context.fetch(FetchDescriptor<PersistentSnippet>()), with: snapshot.snippets,
rowID: \.id, valueID: \.id,
make: PersistentSnippet.init, apply: { $0.apply($1) }
)
try reconcile(
try context.fetch(FetchDescriptor<PersistentSnippetPackage>()),
with: snapshot.snippetPackages, rowID: \.id, valueID: \.id,
make: PersistentSnippetPackage.init, apply: { $0.apply($1) }
)
try reconcile(
try context.fetch(FetchDescriptor<PersistentPortForwardingRule>()),
with: snapshot.portForwardings, rowID: \.id, valueID: \.id,
make: PersistentPortForwardingRule.init, apply: { $0.apply($1) }
)
try reconcile(
try context.fetch(FetchDescriptor<PersistentWorkspaceTemplate>()),
with: snapshot.workspaceTemplates, rowID: \.id, valueID: \.id,
make: PersistentWorkspaceTemplate.init, apply: { try $0.apply($1) }
)
}
private func reconcile<Model: PersistentModel, Value>(
_ rows: [Model], with values: [Value],
rowID: (Model) -> UUID, valueID: (Value) -> UUID,
make: (Value) throws -> Model, apply: (Model, Value) throws -> Void
) throws {
var byID: [UUID: Model] = [:]
for row in rows {
let id = rowID(row)
if byID[id] == nil { byID[id] = row } else { context.delete(row) }
}
let wanted = Set(values.map(valueID))
for row in byID.values where !wanted.contains(rowID(row)) { context.delete(row) }
for value in values {
if let row = byID[valueID(value)] {
try apply(row, value)
} else {
context.insert(try make(value))
}
}
}
}
extension HostPersistence: VaultPersisting {}