feat: sync vault with CloudKit

This commit is contained in:
Kaede Akino committed 2026-08-27 11:53:52 +08:00
1 parent 69bc5e0bae
commit cd67911cd8
30 files changed
+1735 -74

No files matched your search

+12
View File
@@ -227,6 +227,14 @@
TargetAttributes = {
TRMD00000000000000000050 = {
CreatedOnToolsVersion = 26.6;
SystemCapabilities = {
com.apple.Push = {
enabled = 1;
};
com.apple.iCloud = {
enabled = 1;
};
};
};
TRMD00000000000000020050 = {
CreatedOnToolsVersion = 26.6;
@@ -388,6 +396,7 @@
TRMD000000000000000000D0 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
APS_ENVIRONMENT = development;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Termind/Termind.entitlements;
@@ -401,6 +410,7 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
ICLOUD_CONTAINER_ENVIRONMENT = Development;
INFOPLIST_FILE = Termind/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -417,6 +427,7 @@
TRMD000000000000000000E0 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APS_ENVIRONMENT = production;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Termind/Termind.entitlements;
@@ -430,6 +441,7 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
ICLOUD_CONTAINER_ENVIRONMENT = Production;
INFOPLIST_FILE = Termind/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
+4 -1
View File
@@ -6,16 +6,19 @@ struct AppDependencies {
let outboxRunner: OutboxRunner?
let connectionCoordinator: RemoteConnectionCoordinator
let agentRunRepository: (any AgentRunPersisting)?
let vaultSync: VaultSyncCoordinator?
init(
hostRepository: any HostRepository, outboxRunner: OutboxRunner?,
connectionCoordinator: RemoteConnectionCoordinator = RemoteConnectionCoordinator(),
agentRunRepository: (any AgentRunPersisting)? = nil
agentRunRepository: (any AgentRunPersisting)? = nil,
vaultSync: VaultSyncCoordinator? = nil
) {
self.hostRepository = hostRepository
self.outboxRunner = outboxRunner
self.connectionCoordinator = connectionCoordinator
self.agentRunRepository = agentRunRepository
self.vaultSync = vaultSync
}
static func inMemory(hosts: [Host], groups: [HostGroup]) -> Self {
+44
View File
@@ -61,6 +61,7 @@ final class AppModel {
let commands = AppCommandRegistry()
let connections: RemoteConnectionCoordinator
let hostRepository: any HostRepository
let vaultSync: VaultSyncCoordinator?
let forwarding: PortForwardingController
let sftpTransfers: SFTPTransferCoordinator
/// Providers, models, role assignments, MCP servers.
@@ -125,6 +126,7 @@ final class AppModel {
openSFTP: { try await connectionCoordinator.connectSFTP(using: $0) }
)
hostRepository = dependencies.hostRepository
vaultSync = dependencies.vaultSync
forwarding = PortForwardingController(hosts: hosts)
sftpTransfers = SFTPTransferCoordinator(
store: hosts,
@@ -163,6 +165,14 @@ final class AppModel {
hosts.didPersistCore = { [weak repository = dependencies.hostRepository] in
try? repository?.refresh()
}
hosts.didRecordVaultChanges = { [weak vaultSync = dependencies.vaultSync] mutations in
vaultSync?.noteLocalChanges(mutations)
}
if let repository = dependencies.hostRepository as? SwiftDataHostRepository {
repository.didRecordVaultChanges = { [weak vaultSync = dependencies.vaultSync] mutations in
vaultSync?.noteLocalChanges(mutations)
}
}
if let outboxRunner {
outboxRunner.register(
RuntimeHostDeletionHandler(handlerID: HostDeletionOutbox.closeWorkspaces) {
@@ -196,6 +206,40 @@ final class AppModel {
)
outboxRunner.start()
}
vaultSync?.didApplyRemote = { [weak self] snapshot, keys in
self?.applyRemoteVaultSnapshot(snapshot, changedKeys: keys)
}
vaultSync?.start()
}
private func applyRemoteVaultSnapshot(
_ snapshot: VaultSnapshot, changedKeys: Set<VaultRecordKey>
) {
let changedHostIDs = Set(changedKeys.filter { $0.kind == .host }.map(\.id))
let changedRuleIDs = Set(changedKeys.filter { $0.kind == .portForwarding }.map(\.id))
let nextHostIDs = Set(snapshot.hosts.map(\.id))
for hostID in changedHostIDs {
let oldHost = hosts.host(hostID)
let newHost = snapshot.hosts.first { $0.id == hostID }
let routeChanged = oldHost.map { old in
newHost.map {
old.host != $0.host || old.port != $0.port
|| old.identityID != $0.identityID || old.jumpHostID != $0.jumpHostID
|| old.proxy != $0.proxy
} ?? true
} ?? false
if routeChanged {
hosts.willDeleteHost?(hostID, hosts.portForwardings(for: hostID).map(\.id))
}
if oldHost != nil, !nextHostIDs.contains(hostID) {
closeWorkspaces(for: hostID)
sftpTransfers.cancelAll(for: hostID)
commandHistory.clear(for: hostID)
}
}
if !changedRuleIDs.isEmpty { forwarding.stop(Array(changedRuleIDs)) }
hosts.replacePersistedSnapshot(snapshot)
}
func sendAgentDraft(
+2
View File
@@ -6,6 +6,8 @@ import Foundation
/// into two preferences that disagree. Everything user-facing that lives in
/// `UserDefaults` rather than SwiftData is named here.
nonisolated enum SettingsKey {
static let vaultSyncEnabled = "vault.sync.enabled"
static let defaultVaultSyncEnabled = true
static let mcpServerEnabled = "mcpServer.enabled"
static let mcpServerPort = "mcpServer.port"
static let mcpServerDisabledTools = "mcpServer.disabledTools"
+12 -2
View File
@@ -14,7 +14,13 @@ struct TermindApp: App {
init() {
do {
let configuration = ModelConfiguration("Termind", schema: HostPersistence.schema)
let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"]
!= nil
let configuration = ModelConfiguration(
"Termind", schema: HostPersistence.schema,
isStoredInMemoryOnly: isRunningTests,
cloudKitDatabase: .none
)
let container = try ModelContainer(
for: HostPersistence.schema,
migrationPlan: TermindMigrationPlan.self,
@@ -28,6 +34,9 @@ struct TermindApp: App {
let repository = SwiftDataHostRepository(context: container.mainContext)
let outboxStore = SwiftDataOutboxStore(context: container.mainContext)
let outboxRunner = OutboxRunner(store: outboxStore)
let vaultSync: VaultSyncCoordinator? = isRunningTests
? nil
: VaultSyncCoordinator(context: container.mainContext)
let externalToolCalls = ToolAuditModel(
modelContext: container.mainContext,
hostName: { [weak hosts] id in hosts?.host(id)?.name }
@@ -45,7 +54,8 @@ struct TermindApp: App {
outboxRunner: outboxRunner,
agentRunRepository: SwiftDataAgentRunRepository(
context: container.mainContext
)
),
vaultSync: vaultSync
)
)
)
@@ -26,6 +26,7 @@ final class HostLibraryModel {
var willDeleteHost: ((Host.ID, [PortForwardingRule.ID]) -> Void)?
var willDeletePortForwardings: (([PortForwardingRule.ID]) -> Void)?
var didPersistCore: (() -> Void)?
var didRecordVaultChanges: ((VaultMutationSet) -> Void)?
func reportStorageError(_ error: any Error) {
storageError = error.localizedDescription
@@ -1738,19 +1739,43 @@ final class HostLibraryModel {
return true
}
do {
try persistence.save(VaultSnapshot(
let mutations = try persistence.save(VaultSnapshot(
hosts: hosts, groups: groups, identities: identities,
keys: keys, knownHosts: knownHosts, snippets: snippets,
snippetPackages: snippetPackages, portForwardings: portForwardings
))
storageError = nil
didPersistCore?()
didRecordVaultChanges?(mutations)
return true
} catch {
storageError = error.localizedDescription
return false
}
}
/// Applies a CloudKit-imported projection without writing it back as a new
/// local edit. Runtime connection and transfer state intentionally remain.
func replacePersistedSnapshot(_ snapshot: VaultSnapshot) {
hosts = snapshot.hosts
groups = snapshot.groups
identities = normalizedManualOrder(
snapshot.identities, sortIndex: \Identity.sortIndex, name: \Identity.name
)
keys = normalizedManualOrder(
snapshot.keys, sortIndex: \SSHKeyRef.sortIndex, name: \SSHKeyRef.label
)
knownHosts = snapshot.knownHosts
snippets = normalizedSnippetOrder(snapshot.snippets)
snippetPackages = snapshot.snippetPackages
portForwardings = snapshot.portForwardings
statuses = statuses.filter { id, _ in hosts.contains { $0.id == id } }
connectionStatuses = connectionStatuses.filter { _, value in
hosts.contains { $0.id == value.hostID }
}
storageError = nil
didPersistCore?()
}
}
extension Host {
@@ -11,9 +11,15 @@ nonisolated struct VaultSnapshot: Sendable {
var portForwardings: [PortForwardingRule] = []
}
nonisolated struct VaultRemoteApplyResult: Sendable {
var snapshot: VaultSnapshot
var acceptedKeys: Set<VaultRecordKey>
var needsUpload: Set<VaultRecordKey>
}
@MainActor
protocol VaultPersisting: AnyObject {
func load() throws -> VaultSnapshot
func save(_ snapshot: VaultSnapshot) throws
func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet
func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult
}
@@ -1,6 +1,6 @@
import Foundation
nonisolated struct PortForwardingRule: Identifiable, Hashable, Sendable {
nonisolated struct PortForwardingRule: Identifiable, Codable, Hashable, Sendable {
nonisolated enum Kind: String, Codable, Sendable, CaseIterable, Identifiable {
case local, remote, dynamic
nonisolated var id: String { rawValue }
@@ -66,4 +66,3 @@ nonisolated struct PortForwardingRule: Identifiable, Hashable, Sendable {
}
typealias PortForwardingKind = PortForwardingRule.Kind
+1 -1
View File
@@ -65,7 +65,7 @@ nonisolated enum SecretAvailability: Equatable, Sendable {
/// "the password is filed under this name" — and `keyID` points at an `SSHKeyRef`,
/// which is itself only a reference. Neither the key nor the password has a path
/// into this struct, so neither has a path into SwiftData or CloudKit.
nonisolated struct Identity: Identifiable, Hashable, Sendable {
nonisolated struct Identity: Identifiable, Codable, Hashable, Sendable {
var id: UUID = UUID()
var name: String
var username: String
+1 -1
View File
@@ -26,7 +26,7 @@ nonisolated enum SSHKeyAlgorithm: String, Codable, Hashable, Sendable, CaseItera
nonisolated var id: Self { self }
}
nonisolated struct SSHKeyRef: Identifiable, Hashable, Sendable {
nonisolated struct SSHKeyRef: Identifiable, Codable, Hashable, Sendable {
var id: UUID = UUID()
var label: String
var algorithm: SSHKeyAlgorithm = .ed25519
+2 -2
View File
@@ -52,7 +52,7 @@ nonisolated struct HostProxy: Codable, Hashable, Sendable {
/// name and the secret both belong to `Identity`, which is shared across a fleet.
/// So there is no `username` and no `credentialRef` here, only `identityID`, and
/// therefore no path by which a secret could reach SwiftData or CloudKit.
nonisolated struct Host: Identifiable, Hashable, Sendable {
nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
var id: UUID = UUID()
var name: String
/// Where to connect: an IP literal (v4 or v6) or a DNS name. Named `host`
@@ -124,7 +124,7 @@ nonisolated struct Host: Identifiable, Hashable, Sendable {
/// A user-defined folder of hosts. `parentID` is optional and self-referential,
/// which is also what CloudKit requires of the eventual relationship.
nonisolated struct HostGroup: Identifiable, Hashable, Sendable {
nonisolated struct HostGroup: Identifiable, Codable, Hashable, Sendable {
var id: UUID = UUID()
var name: String
var parentID: HostGroup.ID?
+1 -1
View File
@@ -6,7 +6,7 @@ import Foundation
/// a fingerprint pinned on one machine should be pinned on all of them, so a
/// swapped key is caught the first time any device sees it rather than the third.
/// Nothing secret lives here; a public key fingerprint is public by construction.
nonisolated struct KnownHostEntry: Identifiable, Hashable, Sendable {
nonisolated struct KnownHostEntry: Identifiable, Codable, Hashable, Sendable {
var id: UUID = UUID()
var hostname: String
var port: Int = 22
+2 -2
View File
@@ -2,7 +2,7 @@ import Foundation
/// A folder used only to organise snippets. Execution settings deliberately
/// stay on the snippet, so moving a card never changes where it runs.
nonisolated struct SnippetPackage: Identifiable, Hashable, Sendable {
nonisolated struct SnippetPackage: Identifiable, Codable, Hashable, Sendable {
var id: UUID = UUID()
var name: String
var parentID: SnippetPackage.ID?
@@ -11,7 +11,7 @@ nonisolated struct SnippetPackage: Identifiable, Hashable, Sendable {
}
/// A reusable multi-line script with the hosts it normally opens and runs on.
nonisolated struct Snippet: Identifiable, Hashable, Sendable {
nonisolated struct Snippet: Identifiable, Codable, Hashable, Sendable {
var id: UUID = UUID()
var title: String
var body: String
+131
View File
@@ -0,0 +1,131 @@
import Foundation
nonisolated enum VaultRecordKind: String, Codable, CaseIterable, Sendable {
case host
case hostGroup
case identity
case sshKey
case knownHost
case snippet
case snippetPackage
case portForwarding
}
nonisolated struct VaultRecordKey: Codable, Hashable, Sendable {
var kind: VaultRecordKind
var id: UUID
var recordName: String { "v1/\(kind.rawValue)/\(id.uuidString.lowercased())" }
init(kind: VaultRecordKind, id: UUID) {
self.kind = kind
self.id = id
}
init?(recordName: String) {
let components = recordName.split(separator: "/", omittingEmptySubsequences: false)
guard components.count == 3, components[0] == "v1",
let kind = VaultRecordKind(rawValue: String(components[1])),
let id = UUID(uuidString: String(components[2]))
else { return nil }
self.init(kind: kind, id: id)
}
}
/// A hybrid timestamp. `counter` orders multiple writes when wall clocks do not
/// advance, and `writerID` makes ties deterministic across installations.
nonisolated struct VaultVersion: Codable, Hashable, Sendable, Comparable {
var modifiedAt: Date
var counter: UInt64
var writerID: UUID
static let baselineDate = Date(timeIntervalSince1970: 0)
static func baseline(writerID: UUID) -> Self {
Self(modifiedAt: baselineDate, counter: 0, writerID: writerID)
}
static func next(after previous: Self?, writerID: UUID, now: Date = .now) -> Self {
guard let previous else { return Self(modifiedAt: now, counter: 0, writerID: writerID) }
if now > previous.modifiedAt {
return Self(modifiedAt: now, counter: 0, writerID: writerID)
}
return Self(
modifiedAt: previous.modifiedAt,
counter: previous.counter &+ 1,
writerID: writerID
)
}
static func < (lhs: Self, rhs: Self) -> Bool {
if lhs.modifiedAt != rhs.modifiedAt { return lhs.modifiedAt < rhs.modifiedAt }
if lhs.counter != rhs.counter { return lhs.counter < rhs.counter }
return lhs.writerID.uuidString < rhs.writerID.uuidString
}
}
nonisolated enum VaultRecordPayload: Codable, Hashable, Sendable {
case host(Host)
case hostGroup(HostGroup)
case identity(Identity)
case sshKey(SSHKeyRef)
case knownHost(KnownHostEntry)
case snippet(Snippet)
case snippetPackage(SnippetPackage)
case portForwarding(PortForwardingRule)
var key: VaultRecordKey {
switch self {
case .host(let value): VaultRecordKey(kind: .host, id: value.id)
case .hostGroup(let value): VaultRecordKey(kind: .hostGroup, id: value.id)
case .identity(let value): VaultRecordKey(kind: .identity, id: value.id)
case .sshKey(let value): VaultRecordKey(kind: .sshKey, id: value.id)
case .knownHost(let value): VaultRecordKey(kind: .knownHost, id: value.id)
case .snippet(let value): VaultRecordKey(kind: .snippet, id: value.id)
case .snippetPackage(let value): VaultRecordKey(kind: .snippetPackage, id: value.id)
case .portForwarding(let value): VaultRecordKey(kind: .portForwarding, id: value.id)
}
}
}
nonisolated struct VaultEnvelope: Codable, Hashable, Sendable {
static let currentFormatVersion = 1
var formatVersion = Self.currentFormatVersion
var key: VaultRecordKey
var version: VaultVersion
var isDeleted: Bool
var payload: VaultRecordPayload?
init(
key: VaultRecordKey, version: VaultVersion,
isDeleted: Bool = false, payload: VaultRecordPayload? = nil
) {
self.key = key
self.version = version
self.isDeleted = isDeleted
self.payload = payload
}
var isValid: Bool {
formatVersion == Self.currentFormatVersion
&& (isDeleted ? payload == nil : payload?.key == key)
}
}
nonisolated struct VaultMutationSet: Equatable, Sendable {
var keys: Set<VaultRecordKey> = []
static let empty = Self()
var isEmpty: Bool { keys.isEmpty }
}
nonisolated enum VaultSyncStatus: Equatable, Sendable {
case checkingAccount
case paused
case unavailable(String)
case accountChangeConfirmationRequired
case syncing(pendingCount: Int)
case upToDate(lastSuccessfulSync: Date?)
case failed(String)
}
@@ -487,11 +487,63 @@ private struct TerminalSettings: View {
}
private struct SyncSettings: View {
@Environment(AppModel.self) private var app
@AppStorage(SettingsKey.vaultSyncEnabled) private var isEnabled =
SettingsKey.defaultVaultSyncEnabled
var body: some View {
Section("iCloud") {
LabeledContent("Status") { Text("Off") }
Text("iCloud sync is not available yet. Private keys and passwords remain in the Keychain.")
Toggle("Sync Vault with iCloud", isOn: $isEnabled)
LabeledContent("Status") { statusLabel }
if case .accountChangeConfirmationRequired = status {
Text("This Mac is signed in to a different iCloud account. Your local Vault will not be uploaded until you confirm the merge.")
.font(.caption).foregroundStyle(.secondary)
Button("Merge with This iCloud Account") {
app.vaultSync?.confirmAccountSwitchMerge()
}
} else if case .failed = status {
Button("Retry") { app.vaultSync?.retry() }
}
Text("Hosts, identities, public key metadata, Known Hosts, snippets, and port forwards sync through your private iCloud database. Passwords and private keys remain protected separately by Keychain. Credentials marked “This Mac” will be missing on other Macs.")
.font(.caption).foregroundStyle(.secondary)
}
.onChange(of: isEnabled) { _, enabled in app.vaultSync?.setEnabled(enabled) }
}
private var status: VaultSyncStatus {
app.vaultSync?.status ?? (isEnabled ? .checkingAccount : .paused)
}
@ViewBuilder
private var statusLabel: some View {
switch status {
case .checkingAccount:
Label("Checking iCloud…", systemImage: "icloud")
case .paused:
Label("Paused", systemImage: "pause.circle")
case .unavailable(let message):
Label(message, systemImage: "icloud.slash").foregroundStyle(.secondary)
case .accountChangeConfirmationRequired:
Label("Waiting for confirmation", systemImage: "person.crop.circle.badge.exclamationmark")
.foregroundStyle(.orange)
case .syncing(let pendingCount):
Label(
pendingCount == 1 ? "Syncing 1 change…" : "Syncing \(pendingCount) changes…",
systemImage: "arrow.triangle.2.circlepath.icloud"
)
case .upToDate(let lastSync):
if let lastSync {
Label(
"Up to date · \(lastSync.formatted(date: .abbreviated, time: .shortened))",
systemImage: "checkmark.icloud"
)
} else {
Label("Up to date", systemImage: "checkmark.icloud")
}
case .failed(let message):
Label(message, systemImage: "exclamationmark.icloud").foregroundStyle(.red)
}
}
}
@@ -0,0 +1,503 @@
import CloudKit
import CryptoKit
import Foundation
import SwiftData
@MainActor
@Observable
final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sendable {
nonisolated static let containerIdentifier = "iCloud.com.akinokaede.termind"
nonisolated static let zoneName = "TermindVaultV1"
nonisolated static let recordType = "VaultItem"
nonisolated static let subscriptionID = "termind-vault-v1-changes"
nonisolated static let inlinePayloadLimit = 700_000
private let context: ModelContext
private let persistence: HostPersistence
private let journal: VaultSyncJournal
private let container: CKContainer
private let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
private var engine: CKSyncEngine?
private var operation: Task<Void, Never>?
private var isStarted = false
private(set) var status: VaultSyncStatus = .checkingAccount
var didApplyRemote: ((VaultSnapshot, Set<VaultRecordKey>) -> Void)?
var isEnabled: Bool {
UserDefaults.standard.object(forKey: SettingsKey.vaultSyncEnabled) as? Bool
?? SettingsKey.defaultVaultSyncEnabled
}
init(
context: ModelContext,
container: CKContainer = CKContainer(identifier: VaultSyncCoordinator.containerIdentifier)
) {
self.context = context
persistence = HostPersistence(context: context)
journal = VaultSyncJournal(context: context)
self.container = container
super.init()
}
func start() {
guard isEnabled, !isStarted else {
if !isEnabled { status = .paused }
return
}
isStarted = true
run { [weak self] in try await self?.prepareAndSynchronize() }
}
func setEnabled(_ enabled: Bool) {
UserDefaults.standard.set(enabled, forKey: SettingsKey.vaultSyncEnabled)
if enabled { start() }
else { pause() }
}
func pause() {
operation?.cancel()
operation = nil
isStarted = false
let engine = self.engine
self.engine = nil
status = .paused
if let engine { Task { await engine.cancelOperations() } }
}
func retry() {
guard isEnabled else { return }
if engine == nil { isStarted = false; start() }
else { run { [weak self] in try await self?.synchronizeNow() } }
}
func noteLocalChanges(_ mutations: VaultMutationSet) {
guard !mutations.isEmpty else { return }
addPendingChanges(mutations.keys)
guard isEnabled, engine != nil else { return }
run { [weak self] in try await self?.sendPendingChanges() }
}
func confirmAccountSwitchMerge() {
guard isEnabled else { return }
do {
let state = try journal.state()
guard let pendingUser = state.pendingUserRecordName else { return }
try journal.markAllLiveRecordsNeedsUploadForNewAccount()
state.boundUserRecordName = pendingUser
state.pendingUserRecordName = nil
try context.save()
engine = nil
isStarted = false
start()
} catch {
context.rollback()
status = .failed(error.localizedDescription)
}
}
private func run(_ body: @escaping @MainActor () async throws -> Void) {
guard operation == nil else { return }
operation = Task { [weak self] in
defer { self?.operation = nil }
do { try await body() }
catch is CancellationError {}
catch { self?.status = Self.status(for: error) }
}
}
private func prepareAndSynchronize() async throws {
status = .checkingAccount
let accountStatus = try await container.accountStatus()
guard accountStatus == .available else {
status = .unavailable(Self.accountStatusDescription(accountStatus))
isStarted = false
return
}
let userRecordID = try await container.userRecordID()
let currentUser = userRecordID.recordName
let state = try journal.state()
if let boundUser = state.boundUserRecordName, boundUser != currentUser {
state.pendingUserRecordName = currentUser
try context.save()
status = .accountChangeConfirmationRequired
isStarted = false
return
}
state.boundUserRecordName = currentUser
state.pendingUserRecordName = nil
let serialization = state.engineStateData.flatMap {
try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: $0)
}
var configuration = CKSyncEngine.Configuration(
database: container.privateCloudDatabase,
stateSerialization: serialization,
delegate: self
)
configuration.automaticallySync = true
configuration.subscriptionID = Self.subscriptionID
let engine = CKSyncEngine(configuration)
self.engine = engine
let pending = try journal.pendingEnvelopes()
addPendingChanges(Set(pending.map(\.key)))
try context.save()
// Zone creation is idempotent and deliberately precedes the first fetch;
// no local record is sent until the server side has been read.
let zone = CKRecordZone(zoneID: zoneID)
_ = try await container.privateCloudDatabase.modifyRecordZones(
saving: [zone], deleting: []
)
try await engine.fetchChanges()
try await sendPendingChanges()
try updateUpToDateStatus()
}
private func synchronizeNow() async throws {
guard let engine else { return }
status = .syncing(pendingCount: try journal.pendingEnvelopes().count)
try await engine.fetchChanges()
try await sendPendingChanges()
try updateUpToDateStatus()
}
private func sendPendingChanges() async throws {
guard let engine, isEnabled else { return }
let pending = try journal.pendingEnvelopes()
guard !pending.isEmpty else { return }
addPendingChanges(Set(pending.map(\.key)))
status = .syncing(pendingCount: pending.count)
try await engine.sendChanges()
}
private func addPendingChanges(_ keys: Set<VaultRecordKey>) {
guard let engine else { return }
engine.state.add(pendingRecordZoneChanges: keys.map {
.saveRecord(CKRecord.ID(recordName: $0.recordName, zoneID: zoneID))
})
}
private func updateUpToDateStatus() throws {
if let quarantineReason = try journal.firstQuarantineReason() {
status = .failed(quarantineReason)
return
}
let pendingCount = try journal.pendingEnvelopes().count
let state = try journal.state()
if pendingCount == 0 {
state.lastSuccessfulSync = .now
try context.save()
status = .upToDate(lastSuccessfulSync: state.lastSuccessfulSync)
} else {
status = .syncing(pendingCount: pendingCount)
}
}
// MARK: CKSyncEngineDelegate
nonisolated func handleEvent(
_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine
) async {
await handle(event, engine: syncEngine)
}
nonisolated func nextRecordZoneChangeBatch(
_ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine
) async -> CKSyncEngine.RecordZoneChangeBatch? {
await makeChangeBatch(context, engine: syncEngine)
}
@MainActor
private func makeChangeBatch(
_ context: CKSyncEngine.SendChangesContext, engine: CKSyncEngine
) async -> CKSyncEngine.RecordZoneChangeBatch? {
let pending = engine.state.pendingRecordZoneChanges.filter {
context.options.scope.contains($0)
}
return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) {
[weak self] recordID in
guard let self else { return nil }
return await self.recordToSave(recordID)
}
}
@MainActor
private func recordToSave(_ recordID: CKRecord.ID) -> CKRecord? {
guard let key = VaultRecordKey(recordName: recordID.recordName),
let outgoing = try? journal.outgoingEntry(for: key)
else { return nil }
do { return try Self.makeRecord(outgoing, recordID: recordID) }
catch {
status = .failed(error.localizedDescription)
return nil
}
}
@MainActor
private func handle(_ event: CKSyncEngine.Event, engine: CKSyncEngine) async {
do {
switch event {
case .stateUpdate(let update):
let state = try journal.state()
state.engineStateData = try JSONEncoder().encode(update.stateSerialization)
try context.save()
case .accountChange(let change):
try handleAccountChange(change)
case .fetchedRecordZoneChanges(let changes):
try handleFetchedChanges(changes, engine: engine)
case .fetchedDatabaseChanges(let changes):
if changes.deletions.contains(where: { $0.zoneID == zoneID }) {
try handleZoneDeletion(engine: engine)
}
case .sentRecordZoneChanges(let changes):
try handleSentChanges(changes, engine: engine)
case .didFetchChanges, .didSendChanges:
try updateUpToDateStatus()
default:
break
}
} catch {
context.rollback()
status = Self.status(for: error)
}
}
private func handleAccountChange(_ change: CKSyncEngine.Event.AccountChange) throws {
let state = try journal.state()
switch change.changeType {
case .signIn(let currentUser):
if let bound = state.boundUserRecordName, bound != currentUser.recordName {
stopEngineForAccountChange()
state.pendingUserRecordName = currentUser.recordName
status = .accountChangeConfirmationRequired
}
case .signOut:
stopEngineForAccountChange()
status = .unavailable(String(localized: "Sign in to iCloud to sync the Vault."))
case .switchAccounts(_, let currentUser):
stopEngineForAccountChange()
state.pendingUserRecordName = currentUser.recordName
status = .accountChangeConfirmationRequired
@unknown default:
stopEngineForAccountChange()
status = .unavailable(String(localized: "The iCloud account changed."))
}
try context.save()
}
private func stopEngineForAccountChange() {
let activeEngine = engine
engine = nil
isStarted = false
if let activeEngine { Task { await activeEngine.cancelOperations() } }
}
private func handleFetchedChanges(
_ changes: CKSyncEngine.Event.FetchedRecordZoneChanges, engine: CKSyncEngine
) throws {
var recordsByKey: [VaultRecordKey: CKRecord] = [:]
var envelopes: [VaultEnvelope] = []
var quarantinedError: String?
for modification in changes.modifications {
let record = modification.record
guard record.recordType == Self.recordType, record.recordID.zoneID == zoneID else {
continue
}
do {
let envelope = try Self.envelope(from: record)
try journal.clearQuarantine(recordName: record.recordID.recordName)
recordsByKey[envelope.key] = record
envelopes.append(envelope)
} catch {
try journal.quarantine(
recordName: record.recordID.recordName,
payloadData: (try? Self.payloadData(from: record)) ?? Data(),
reason: error.localizedDescription
)
quarantinedError = error.localizedDescription
}
}
let result = try persistence.applyRemote(envelopes)
for (key, record) in recordsByKey {
try journal.updateSystemFields(Self.systemFields(of: record), for: key)
}
try journal.markNeedsUpload(result.needsUpload)
// Physical deletes are not part of the protocol; recreate the local
// envelope instead of converting a console accident into data loss.
let physicallyDeleted = Set(changes.deletions.compactMap {
VaultRecordKey(recordName: $0.recordID.recordName)
}).filter { (try? journal.envelope(for: $0)) != nil }
try journal.markNeedsUpload(physicallyDeleted)
addPendingChanges(result.needsUpload.union(physicallyDeleted))
try context.save()
if !result.acceptedKeys.isEmpty {
didApplyRemote?(result.snapshot, result.acceptedKeys)
}
if let quarantinedError { status = .failed(quarantinedError) }
}
private func handleSentChanges(
_ changes: CKSyncEngine.Event.SentRecordZoneChanges, engine: CKSyncEngine
) throws {
for record in changes.savedRecords {
guard let envelope = try? Self.envelope(from: record) else { continue }
try journal.markUploaded(envelope.key, version: envelope.version)
try journal.updateSystemFields(Self.systemFields(of: record), for: envelope.key)
try? FileManager.default.removeItem(at: Self.assetURL(for: envelope.key))
}
for failure in changes.failedRecordSaves {
let error = failure.error
if error.code == .serverRecordChanged, let serverRecord = error.serverRecord,
let serverEnvelope = try? Self.envelope(from: serverRecord)
{
let result = try persistence.applyRemote([serverEnvelope])
try journal.updateSystemFields(
Self.systemFields(of: serverRecord), for: serverEnvelope.key
)
try journal.markNeedsUpload(result.needsUpload)
addPendingChanges(result.needsUpload)
if !result.acceptedKeys.isEmpty {
didApplyRemote?(result.snapshot, result.acceptedKeys)
}
} else if error.code == .zoneNotFound {
try handleZoneDeletion(engine: engine)
} else if !Self.isRetryable(error) {
status = .failed(error.localizedDescription)
}
}
try context.save()
}
private func handleZoneDeletion(engine: CKSyncEngine) throws {
try journal.markAllEntriesNeedsUploadAfterZoneReset()
let zone = CKRecordZone(zoneID: zoneID)
engine.state.add(pendingDatabaseChanges: [.saveZone(zone)])
addPendingChanges(Set((try journal.pendingEnvelopes()).map(\.key)))
try context.save()
}
// MARK: Record coding
nonisolated static func makeRecord(
_ outgoing: VaultOutgoingEntry, recordID: CKRecord.ID
) throws -> CKRecord {
let envelope = outgoing.envelope
let record: CKRecord
if let data = outgoing.systemFieldsData,
let restored = try Self.record(fromSystemFields: data)
{
record = restored
} else {
record = CKRecord(recordType: recordType, recordID: recordID)
}
let payload = VaultSyncCodec.encode(envelope)
record["formatVersion"] = NSNumber(value: envelope.formatVersion)
record["kind"] = envelope.key.kind.rawValue as NSString
record["entityID"] = envelope.key.id.uuidString.lowercased() as NSString
record["modifiedAt"] = envelope.version.modifiedAt as NSDate
record["counter"] = NSNumber(value: Int64(clamping: envelope.version.counter))
record["writerID"] = envelope.version.writerID.uuidString.lowercased() as NSString
record["isDeleted"] = NSNumber(value: envelope.isDeleted)
record["payloadHash"] = SHA256.hash(data: payload).hexString as NSString
if payload.count > inlinePayloadLimit {
let url = try assetURL(for: envelope.key)
try payload.write(to: url, options: .atomic)
record["payload"] = nil
record["payloadAsset"] = CKAsset(fileURL: url)
} else {
record["payload"] = payload as NSData
record["payloadAsset"] = nil
}
return record
}
nonisolated static func envelope(from record: CKRecord) throws -> VaultEnvelope {
let data = try payloadData(from: record)
let expectedHash = record["payloadHash"] as? String
guard expectedHash == SHA256.hash(data: data).hexString else {
throw VaultSyncPersistenceError.invalidEnvelope(record.recordID.recordName)
}
let envelope = try VaultSyncCodec.decode(data)
guard envelope.key.recordName == record.recordID.recordName, envelope.isValid else {
throw VaultSyncPersistenceError.invalidEnvelope(record.recordID.recordName)
}
return envelope
}
nonisolated static func payloadData(from record: CKRecord) throws -> Data {
if let inline = record["payload"] as? Data { return inline }
if let asset = record["payloadAsset"] as? CKAsset, let url = asset.fileURL {
return try Data(contentsOf: url)
}
throw VaultSyncPersistenceError.invalidEnvelope(record.recordID.recordName)
}
nonisolated static func systemFields(of record: CKRecord) throws -> Data {
let archiver = NSKeyedArchiver(requiringSecureCoding: true)
record.encodeSystemFields(with: archiver)
archiver.finishEncoding()
return archiver.encodedData
}
private nonisolated static func record(fromSystemFields data: Data) throws -> CKRecord? {
let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
unarchiver.requiresSecureCoding = true
defer { unarchiver.finishDecoding() }
return CKRecord(coder: unarchiver)
}
private nonisolated static func assetURL(for key: VaultRecordKey) throws -> URL {
let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appending(path: "Termind/VaultSyncAssets", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory.appending(path: key.recordName.replacingOccurrences(of: "/", with: "-"))
}
private nonisolated static func isRetryable(_ error: CKError) -> Bool {
switch error.code {
case .networkFailure, .networkUnavailable, .serviceUnavailable,
.requestRateLimited, .zoneBusy, .resultsTruncated:
true
default: false
}
}
private nonisolated static func status(for error: any Error) -> VaultSyncStatus {
if let cloudError = error as? CKError {
if cloudError.code == .notAuthenticated {
return .unavailable(String(localized: "Sign in to iCloud to sync the Vault."))
}
if isRetryable(cloudError) { return .syncing(pendingCount: 0) }
}
return .failed(error.localizedDescription)
}
private nonisolated static func accountStatusDescription(
_ status: CKAccountStatus
) -> String {
switch status {
case .noAccount: String(localized: "Sign in to iCloud to sync the Vault.")
case .restricted: String(localized: "iCloud access is restricted on this Mac.")
case .couldNotDetermine: String(localized: "The iCloud account could not be checked.")
case .temporarilyUnavailable: String(localized: "iCloud is temporarily unavailable.")
case .available: ""
@unknown default: String(localized: "iCloud is unavailable.")
}
}
}
private extension SHA256.Digest {
nonisolated var hexString: String { map { String(format: "%02x", $0) }.joined() }
}
@@ -0,0 +1,443 @@
import Foundation
import SwiftData
@Model
final class PersistentVaultSyncEntry {
@Attribute(.unique) var recordName = ""
var kind = VaultRecordKind.host.rawValue
var entityID = UUID()
var envelopeData = Data()
var systemFieldsData: Data?
var needsUpload = false
init(envelope: VaultEnvelope, needsUpload: Bool) throws {
apply(envelope, needsUpload: needsUpload)
}
func apply(_ envelope: VaultEnvelope, needsUpload: Bool) {
recordName = envelope.key.recordName
kind = envelope.key.kind.rawValue
entityID = envelope.key.id
envelopeData = VaultSyncCodec.encode(envelope)
self.needsUpload = needsUpload
}
var envelope: VaultEnvelope? { try? VaultSyncCodec.decode(envelopeData) }
}
@Model
final class PersistentVaultSyncState {
@Attribute(.unique) var key = "vault-sync"
var installationID = UUID()
var engineStateData: Data?
var boundUserRecordName: String?
var pendingUserRecordName: String?
var bootstrapCompleted = false
var lastSuccessfulSync: Date?
init() {}
}
@Model
final class PersistentVaultSyncQuarantine {
@Attribute(.unique) var recordName = ""
var payloadData = Data()
var reason = ""
var receivedAt = Date()
init(recordName: String, payloadData: Data, reason: String) {
self.recordName = recordName
self.payloadData = payloadData
self.reason = reason
}
}
nonisolated enum VaultSyncPersistenceError: LocalizedError {
case invalidEnvelope(String)
case unsupportedFormat(Int)
var errorDescription: String? {
switch self {
case .invalidEnvelope(let recordName):
String(localized: "The iCloud Vault record \(recordName) is invalid.")
case .unsupportedFormat(let version):
String(localized: "This Vault record requires sync format \(version).")
}
}
}
nonisolated enum VaultSyncCodec {
static func encode(_ envelope: VaultEnvelope) -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
return (try? encoder.encode(envelope)) ?? Data()
}
static func decode(_ data: Data) throws -> VaultEnvelope {
try JSONDecoder().decode(VaultEnvelope.self, from: data)
}
}
nonisolated struct VaultRemoteMerge: Sendable {
var accepted: [VaultEnvelope] = []
var needsUpload: Set<VaultRecordKey> = []
}
nonisolated struct VaultOutgoingEntry: Sendable {
var envelope: VaultEnvelope
var systemFieldsData: Data?
}
extension VaultSnapshot {
nonisolated var recordPayloads: [VaultRecordKey: VaultRecordPayload] {
var result: [VaultRecordKey: VaultRecordPayload] = [:]
for value in hosts { result[VaultRecordKey(kind: .host, id: value.id)] = .host(value) }
for value in groups {
result[VaultRecordKey(kind: .hostGroup, id: value.id)] = .hostGroup(value)
}
for value in identities {
result[VaultRecordKey(kind: .identity, id: value.id)] = .identity(value)
}
for value in keys {
result[VaultRecordKey(kind: .sshKey, id: value.id)] = .sshKey(value)
}
for value in knownHosts {
result[VaultRecordKey(kind: .knownHost, id: value.id)] = .knownHost(value)
}
for value in snippets {
result[VaultRecordKey(kind: .snippet, id: value.id)] = .snippet(value)
}
for value in snippetPackages {
result[VaultRecordKey(kind: .snippetPackage, id: value.id)] = .snippetPackage(value)
}
for value in portForwardings {
result[VaultRecordKey(kind: .portForwarding, id: value.id)] = .portForwarding(value)
}
return result
}
nonisolated mutating func apply(_ envelope: VaultEnvelope) throws {
guard envelope.formatVersion <= VaultEnvelope.currentFormatVersion else {
throw VaultSyncPersistenceError.unsupportedFormat(envelope.formatVersion)
}
guard envelope.isValid else {
throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName)
}
if envelope.isDeleted {
remove(envelope.key)
return
}
guard let payload = envelope.payload else {
throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName)
}
switch payload {
case .host(let value): upsert(value, in: &hosts)
case .hostGroup(let value): upsert(value, in: &groups)
case .identity(let value): upsert(value, in: &identities)
case .sshKey(let value): upsert(value, in: &keys)
case .knownHost(let value): upsert(value, in: &knownHosts)
case .snippet(let value): upsert(value, in: &snippets)
case .snippetPackage(let value): upsert(value, in: &snippetPackages)
case .portForwarding(let value): upsert(value, in: &portForwardings)
}
}
private nonisolated mutating func remove(_ key: VaultRecordKey) {
switch key.kind {
case .host: hosts.removeAll { $0.id == key.id }
case .hostGroup: groups.removeAll { $0.id == key.id }
case .identity: identities.removeAll { $0.id == key.id }
case .sshKey: keys.removeAll { $0.id == key.id }
case .knownHost: knownHosts.removeAll { $0.id == key.id }
case .snippet: snippets.removeAll { $0.id == key.id }
case .snippetPackage: snippetPackages.removeAll { $0.id == key.id }
case .portForwarding: portForwardings.removeAll { $0.id == key.id }
}
}
private nonisolated func upsert<Value: Identifiable>(
_ value: Value, in values: inout [Value]
) where Value.ID == UUID {
if let index = values.firstIndex(where: { $0.id == value.id }) { values[index] = value }
else { values.append(value) }
}
}
@MainActor
final class VaultSyncJournal {
private let context: ModelContext
init(context: ModelContext) { self.context = context }
func state() throws -> PersistentVaultSyncState {
var descriptor = FetchDescriptor<PersistentVaultSyncState>()
descriptor.fetchLimit = 1
if let existing = try context.fetch(descriptor).first { return existing }
let state = PersistentVaultSyncState()
context.insert(state)
return state
}
@discardableResult
func bootstrapIfNeeded(_ snapshot: VaultSnapshot) throws -> VaultMutationSet {
let state = try state()
guard !state.bootstrapCompleted else { return .empty }
let existing = try entriesByName()
var changed = Set<VaultRecordKey>()
for payload in snapshot.recordPayloads.values {
let key = payload.key
guard existing[key.recordName] == nil else { continue }
let envelope = VaultEnvelope(
key: key, version: .baseline(writerID: state.installationID), payload: payload
)
context.insert(try PersistentVaultSyncEntry(envelope: envelope, needsUpload: true))
changed.insert(key)
}
state.bootstrapCompleted = true
return VaultMutationSet(keys: changed)
}
func recordLocalChanges(
from oldSnapshot: VaultSnapshot, to newSnapshot: VaultSnapshot,
now: Date = .now
) throws -> VaultMutationSet {
let state = try state()
let old = oldSnapshot.recordPayloads
let new = newSnapshot.recordPayloads
var entries = try entriesByName()
var changed = Set<VaultRecordKey>()
for (key, payload) in new where old[key] != payload {
let row = entries[key.recordName]
let envelope = VaultEnvelope(
key: key,
version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now),
payload: payload
)
if let row { row.apply(envelope, needsUpload: true) }
else {
let inserted = try PersistentVaultSyncEntry(envelope: envelope, needsUpload: true)
context.insert(inserted)
entries[key.recordName] = inserted
}
changed.insert(key)
}
for key in old.keys where new[key] == nil {
let row = entries[key.recordName]
let envelope = VaultEnvelope(
key: key,
version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now),
isDeleted: true
)
if let row { row.apply(envelope, needsUpload: true) }
else {
let inserted = try PersistentVaultSyncEntry(envelope: envelope, needsUpload: true)
context.insert(inserted)
entries[key.recordName] = inserted
}
changed.insert(key)
}
return VaultMutationSet(keys: changed)
}
func pendingEnvelopes() throws -> [VaultEnvelope] {
try context.fetch(
FetchDescriptor<PersistentVaultSyncEntry>(
predicate: #Predicate { $0.needsUpload }
)
).compactMap(\.envelope)
}
func outgoingEntry(for key: VaultRecordKey) throws -> VaultOutgoingEntry? {
guard let row = try entry(recordName: key.recordName), let envelope = row.envelope else {
return nil
}
return VaultOutgoingEntry(envelope: envelope, systemFieldsData: row.systemFieldsData)
}
func envelope(for key: VaultRecordKey) throws -> VaultEnvelope? {
try entry(recordName: key.recordName)?.envelope
}
func mergeDecision(for remote: [VaultEnvelope]) throws -> VaultRemoteMerge {
var result = VaultRemoteMerge()
let entries = try entriesByName()
for envelope in remote {
guard envelope.formatVersion <= VaultEnvelope.currentFormatVersion else {
throw VaultSyncPersistenceError.unsupportedFormat(envelope.formatVersion)
}
guard envelope.isValid else {
throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName)
}
guard let local = entries[envelope.key.recordName]?.envelope else {
result.accepted.append(envelope)
continue
}
if local.version < envelope.version { result.accepted.append(envelope) }
else if envelope.version < local.version { result.needsUpload.insert(local.key) }
else if local != envelope {
let localData = VaultSyncCodec.encode(local)
let remoteData = VaultSyncCodec.encode(envelope)
if localData.lexicographicallyPrecedes(remoteData) {
result.accepted.append(envelope)
} else {
result.needsUpload.insert(local.key)
}
}
}
return result
}
func acceptRemote(_ envelopes: [VaultEnvelope]) throws {
var entries = try entriesByName()
for envelope in envelopes {
if let row = entries[envelope.key.recordName] {
row.apply(envelope, needsUpload: false)
} else {
let row = try PersistentVaultSyncEntry(envelope: envelope, needsUpload: false)
context.insert(row)
entries[envelope.key.recordName] = row
}
}
}
func markNeedsUpload(_ keys: Set<VaultRecordKey>) throws {
let entries = try entriesByName()
for key in keys { entries[key.recordName]?.needsUpload = true }
}
func markUploaded(_ key: VaultRecordKey, version: VaultVersion) throws {
guard let row = try entry(recordName: key.recordName), row.envelope?.version == version else {
return
}
row.needsUpload = false
}
func updateSystemFields(_ data: Data?, for key: VaultRecordKey) throws {
try entry(recordName: key.recordName)?.systemFieldsData = data
}
func quarantine(recordName: String, payloadData: Data, reason: String) throws {
var descriptor = FetchDescriptor<PersistentVaultSyncQuarantine>(
predicate: #Predicate { $0.recordName == recordName }
)
descriptor.fetchLimit = 1
if let row = try context.fetch(descriptor).first {
row.payloadData = payloadData
row.reason = reason
row.receivedAt = .now
} else {
context.insert(PersistentVaultSyncQuarantine(
recordName: recordName, payloadData: payloadData, reason: reason
))
}
}
func clearQuarantine(recordName: String) throws {
try context.delete(
model: PersistentVaultSyncQuarantine.self,
where: #Predicate { $0.recordName == recordName }
)
}
func firstQuarantineReason() throws -> String? {
var descriptor = FetchDescriptor<PersistentVaultSyncQuarantine>(
sortBy: [SortDescriptor(\.receivedAt, order: .reverse)]
)
descriptor.fetchLimit = 1
return try context.fetch(descriptor).first?.reason
}
func recordCurrentSnapshot(_ snapshot: VaultSnapshot, now: Date = .now) throws
-> VaultMutationSet
{
let state = try state()
let current = snapshot.recordPayloads
var entries = try entriesByName()
var changed = Set<VaultRecordKey>()
for (key, payload) in current {
let row = entries[key.recordName]
guard row?.envelope?.payload != payload || row?.envelope?.isDeleted != false else {
continue
}
let envelope = VaultEnvelope(
key: key,
version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now),
payload: payload
)
if let row { row.apply(envelope, needsUpload: true) }
else {
let inserted = try PersistentVaultSyncEntry(envelope: envelope, needsUpload: true)
context.insert(inserted)
entries[key.recordName] = inserted
}
changed.insert(key)
}
for row in entries.values {
guard let envelope = row.envelope, !envelope.isDeleted,
current[envelope.key] == nil
else { continue }
let tombstone = VaultEnvelope(
key: envelope.key,
version: .next(after: envelope.version, writerID: state.installationID, now: now),
isDeleted: true
)
row.apply(tombstone, needsUpload: true)
changed.insert(envelope.key)
}
return VaultMutationSet(keys: changed)
}
func markAllLiveRecordsNeedsUploadForNewAccount() throws {
for row in try context.fetch(FetchDescriptor<PersistentVaultSyncEntry>()) {
guard let envelope = row.envelope else { continue }
if envelope.isDeleted { context.delete(row) }
else {
row.needsUpload = true
row.systemFieldsData = nil
}
}
let state = try state()
state.engineStateData = nil
state.lastSuccessfulSync = nil
}
func markAllEntriesNeedsUploadAfterZoneReset() throws {
for row in try context.fetch(FetchDescriptor<PersistentVaultSyncEntry>()) {
row.needsUpload = true
row.systemFieldsData = nil
}
let state = try state()
state.engineStateData = nil
state.lastSuccessfulSync = nil
}
private func entry(recordName: String) throws -> PersistentVaultSyncEntry? {
var descriptor = FetchDescriptor<PersistentVaultSyncEntry>(
predicate: #Predicate { $0.recordName == recordName }
)
descriptor.fetchLimit = 1
return try context.fetch(descriptor).first
}
private func entriesByName() throws -> [String: PersistentVaultSyncEntry] {
Dictionary(
try context.fetch(FetchDescriptor<PersistentVaultSyncEntry>()).map {
($0.recordName, $0)
},
uniquingKeysWith: { first, duplicate in
if let firstVersion = first.envelope?.version,
let duplicateVersion = duplicate.envelope?.version,
firstVersion < duplicateVersion
{
return duplicate
}
return first
}
)
}
}
@@ -356,6 +356,10 @@ final class PersistentPortForwardingRule {
init(_ value: PortForwardingRule) {
id = value.id
apply(value)
}
func apply(_ value: PortForwardingRule) {
hostID = value.hostID
kind = value.kind.rawValue
bindHost = value.bindHost
@@ -713,6 +717,60 @@ enum TermindSchemaV14: VersionedSchema {
}
}
/// Frozen sync models from the first development build that wrote a V15 store.
/// The live models later gained `systemFieldsData` and
/// `pendingUserRecordName`; keeping the original types here preserves V15's
/// on-disk checksum so staged migration can recognize those stores.
enum TermindSchemaV15Models {
@Model
final class PersistentVaultSyncEntry {
@Attribute(.unique) var recordName = ""
var kind = VaultRecordKind.host.rawValue
var entityID = UUID()
var envelopeData = Data()
var needsUpload = false
init() {}
}
@Model
final class PersistentVaultSyncState {
@Attribute(.unique) var key = "vault-sync"
var installationID = UUID()
var engineStateData: Data?
var boundUserRecordName: String?
var bootstrapCompleted = false
var lastSuccessfulSync: Date?
init() {}
}
}
enum TermindSchemaV15: VersionedSchema {
static let versionIdentifier = Schema.Version(15, 0, 0)
static var models: [any PersistentModel.Type] {
TermindSchemaV14.models + [
TermindSchemaV15Models.PersistentVaultSyncEntry.self,
TermindSchemaV15Models.PersistentVaultSyncState.self,
]
}
}
/// V15 shipped briefly in development with only the journal and engine state
/// models. Keep that shape frozen so stores created by those builds retain a
/// valid migration source checksum. Quarantined remote records were added in
/// V16 instead of mutating the already-used V15 schema in place.
enum TermindSchemaV16: VersionedSchema {
static let versionIdentifier = Schema.Version(16, 0, 0)
static var models: [any PersistentModel.Type] {
TermindSchemaV14.models + [
PersistentVaultSyncEntry.self,
PersistentVaultSyncState.self,
PersistentVaultSyncQuarantine.self,
]
}
}
enum TermindMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[
@@ -721,6 +779,8 @@ enum TermindMigrationPlan: SchemaMigrationPlan {
TermindSchemaV6.self, TermindSchemaV7.self, TermindSchemaV8.self,
TermindSchemaV9.self, TermindSchemaV10.self, TermindSchemaV11.self,
TermindSchemaV12.self, TermindSchemaV13.self, TermindSchemaV14.self,
TermindSchemaV15.self,
TermindSchemaV16.self,
]
}
@@ -791,13 +851,21 @@ enum TermindMigrationPlan: SchemaMigrationPlan {
fromVersion: TermindSchemaV13.self,
toVersion: TermindSchemaV14.self
),
.lightweight(
fromVersion: TermindSchemaV14.self,
toVersion: TermindSchemaV15.self
),
.lightweight(
fromVersion: TermindSchemaV15.self,
toVersion: TermindSchemaV16.self
),
]
}
}
@MainActor
final class HostPersistence {
static let schema = Schema(versionedSchema: TermindSchemaV14.self)
static let schema = Schema(versionedSchema: TermindSchemaV16.self)
let context: ModelContext
@@ -807,6 +875,13 @@ final class HostPersistence {
func load() throws -> VaultSnapshot {
try migrateLegacyCloudStorageNames()
let snapshot = try fetchSnapshot()
_ = try VaultSyncJournal(context: context).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 context.fetch(FetchDescriptor<PersistentHostGroup>()).map(\.value)
let identities = try context.fetch(FetchDescriptor<PersistentIdentity>()).map(\.value)
@@ -849,32 +924,108 @@ final class HostPersistence {
}
}
func save(_ snapshot: VaultSnapshot) throws {
func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet {
do {
try replace(PersistentHost.self, with: snapshot.hosts.map(PersistentHost.init))
try replace(PersistentHostGroup.self, with: snapshot.groups.map(PersistentHostGroup.init))
try replace(PersistentIdentity.self, with: snapshot.identities.map(PersistentIdentity.init))
try replace(PersistentSSHKey.self, with: snapshot.keys.map(PersistentSSHKey.init))
try replace(PersistentKnownHost.self, with: snapshot.knownHosts.map(PersistentKnownHost.init))
try replace(PersistentSnippet.self, with: snapshot.snippets.map(PersistentSnippet.init))
try replace(
PersistentSnippetPackage.self,
with: snapshot.snippetPackages.map(PersistentSnippetPackage.init)
)
try replace(
PersistentPortForwardingRule.self,
with: snapshot.portForwardings.map(PersistentPortForwardingRule.init)
)
let oldSnapshot = try fetchSnapshot()
let journal = VaultSyncJournal(context: context)
_ = try journal.bootstrapIfNeeded(oldSnapshot)
let mutations = try journal.recordLocalChanges(from: oldSnapshot, to: snapshot)
try persist(snapshot)
try context.save()
return mutations
} catch {
context.rollback()
throw error
}
}
private func replace<Model: PersistentModel>(_ type: Model.Type, with values: [Model]) throws {
try context.delete(model: type)
for value in values { context.insert(value) }
func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult {
do {
let journal = VaultSyncJournal(context: context)
let decision = try journal.mergeDecision(for: envelopes)
var snapshot = try fetchSnapshot()
for envelope in decision.accepted { try snapshot.apply(envelope) }
try persist(snapshot)
try journal.acceptRemote(decision.accepted)
try journal.markNeedsUpload(decision.needsUpload)
try context.save()
return VaultRemoteApplyResult(
snapshot: snapshot,
acceptedKeys: Set(decision.accepted.map(\.key)),
needsUpload: decision.needsUpload
)
} catch {
context.rollback()
throw error
}
}
func recordExternalMutation() throws -> VaultMutationSet {
let snapshot = try fetchSnapshot()
let journal = VaultSyncJournal(context: context)
_ = try journal.bootstrapIfNeeded(snapshot)
return try journal.recordCurrentSnapshot(snapshot)
}
private func persist(_ snapshot: VaultSnapshot) throws {
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) }
)
}
private func reconcile<Model: PersistentModel, Value>(
_ rows: [Model], with values: [Value],
rowID: (Model) -> UUID, valueID: (Value) -> UUID,
make: (Value) -> Model, apply: (Model, Value) -> 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)] { apply(row, value) }
else { context.insert(make(value)) }
}
}
}
@@ -6,6 +6,7 @@ final class SwiftDataHostRepository: HostRepository {
private let context: ModelContext
private var continuations: [UUID: AsyncStream<RepositoryChange<HostLibrarySnapshot>>.Continuation]
= [:]
var didRecordVaultChanges: ((VaultMutationSet) -> Void)?
init(context: ModelContext) {
self.context = context
@@ -140,7 +141,9 @@ final class SwiftDataHostRepository: HostRepository {
private func commitMutation() throws {
do {
let revision = try advanceRevisionWithoutSaving()
let vaultMutations = try HostPersistence(context: context).recordExternalMutation()
try context.save()
if !vaultMutations.isEmpty { didRecordVaultChanges?(vaultMutations) }
try publishCommittedMutation(revision: revision)
} catch {
context.rollback()
+12
View File
@@ -20,6 +20,18 @@
<!-- Local and dynamic port-forwarding listeners. -->
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.developer.aps-environment</key>
<string>$(APS_ENVIRONMENT)</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.akinokaede.termind</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.icloud-container-environment</key>
<string>$(ICLOUD_CONTAINER_ENVIRONMENT)</string>
<!-- Importing existing private keys and known_hosts from disk. -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
@@ -180,14 +180,14 @@ struct AIProviderPersistenceTests {
do {
let schema = Schema(versionedSchema: TermindSchemaV4.self)
let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL)
let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL)
let container = try ModelContainer(for: schema, configurations: [configuration])
container.mainContext.insert(PersistentHost(host))
try container.mainContext.save()
}
let schema = Schema(versionedSchema: TermindSchemaV5.self)
let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL)
let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL)
let container = try ModelContainer(
for: schema,
migrationPlan: TermindMigrationPlan.self,
@@ -217,7 +217,7 @@ struct AIProviderPersistenceTests {
do {
let schema = Schema(versionedSchema: TermindSchemaV9.self)
let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL)
let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL)
let container = try ModelContainer(for: schema, configurations: [configuration])
container.mainContext.insert(PersistentModelProvider(provider, sortIndex: 0))
let model = TermindSchemaV9AIModels.PersistentAIModel()
@@ -227,7 +227,7 @@ struct AIProviderPersistenceTests {
try container.mainContext.save()
}
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"Migration", schema: HostPersistence.schema, url: storeURL
)
let container = try ModelContainer(
@@ -244,7 +244,7 @@ struct AIProviderPersistenceTests {
}
private func makeContainer(_ name: String) throws -> ModelContainer {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
name, schema: HostPersistence.schema, isStoredInMemoryOnly: true
)
return try ModelContainer(
@@ -73,7 +73,7 @@ struct AgentInterruptedRunRecoveryTests {
@MainActor
@Test
func restoreRecoversResultsRecordedBeforeTheProcessDied() async throws {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"AgentInterruptedRunRecoveryTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
+23 -23
View File
@@ -18,7 +18,7 @@ struct AgentPersistenceTests {
do {
let schema = Schema(versionedSchema: TermindSchemaV8.self)
let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL)
let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL)
let container = try ModelContainer(for: schema, configurations: [configuration])
let row = TermindSchemaV8Models.PersistentAgentMessage()
row.id = messageID
@@ -27,7 +27,7 @@ struct AgentPersistenceTests {
try container.mainContext.save()
}
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"Migration", schema: HostPersistence.schema, url: storeURL
)
let container = try ModelContainer(
@@ -55,7 +55,7 @@ struct AgentPersistenceTests {
do {
let schema = Schema(versionedSchema: TermindSchemaV10.self)
let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL)
let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL)
let container = try ModelContainer(for: schema, configurations: [configuration])
let conversation = TermindSchemaV10AgentModels.PersistentAgentConversation()
conversation.id = conversationID
@@ -68,7 +68,7 @@ struct AgentPersistenceTests {
try container.mainContext.save()
}
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"Migration", schema: HostPersistence.schema, url: storeURL
)
let container = try ModelContainer(
@@ -101,7 +101,7 @@ struct AgentPersistenceTests {
do {
let schema = Schema(versionedSchema: TermindSchemaV11.self)
let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL)
let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL)
let container = try ModelContainer(for: schema, configurations: [configuration])
let message = TermindSchemaV11AgentModels.PersistentAgentMessage()
message.id = messageID
@@ -110,7 +110,7 @@ struct AgentPersistenceTests {
try container.mainContext.save()
}
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"Migration", schema: HostPersistence.schema, url: storeURL
)
let container = try ModelContainer(
@@ -136,7 +136,7 @@ struct AgentPersistenceTests {
do {
let schema = Schema(versionedSchema: TermindSchemaV12.self)
let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL)
let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL)
let container = try ModelContainer(for: schema, configurations: [configuration])
let invocation = TermindSchemaV12AgentModels.PersistentAgentToolInvocation()
invocation.id = invocationID
@@ -146,7 +146,7 @@ struct AgentPersistenceTests {
try container.mainContext.save()
}
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"Migration", schema: HostPersistence.schema, url: storeURL
)
let container = try ModelContainer(
@@ -173,7 +173,7 @@ struct AgentPersistenceTests {
do {
let schema = Schema(versionedSchema: TermindSchemaV13.self)
let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL)
let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL)
let container = try ModelContainer(for: schema, configurations: [configuration])
let message = TermindSchemaV13AgentModels.PersistentAgentMessage()
message.id = messageID
@@ -182,7 +182,7 @@ struct AgentPersistenceTests {
try container.mainContext.save()
}
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"Migration", schema: HostPersistence.schema, url: storeURL
)
let container = try ModelContainer(
@@ -200,7 +200,7 @@ struct AgentPersistenceTests {
@Test
func imagePartsRoundTripThroughConversationPersistence() async throws {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"AgentImagePersistenceTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
@@ -226,7 +226,7 @@ struct AgentPersistenceTests {
@Test
func displayReasoningRoundTripsRedactedWithoutProviderState() async throws {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"AgentReasoningPersistenceTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
@@ -268,7 +268,7 @@ struct AgentPersistenceTests {
@Test
func runOwnershipAndToolSourceMessageRoundTrip() async throws {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"AgentWorkPersistenceTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
@@ -311,7 +311,7 @@ struct AgentPersistenceTests {
@Test
func unfinishedRunsInterruptAndVisibleHistoryRestoresWithoutSecrets() async throws {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"AgentPersistenceTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
@@ -371,7 +371,7 @@ struct AgentPersistenceTests {
@Test
func fetchHeaderSecretsAreRedactedFromToolCallsAndInvocationRows() async throws {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"AgentFetchHeaderRedactionTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
@@ -425,7 +425,7 @@ struct AgentPersistenceTests {
/// already correct.
@Test
func replayedHistoryIsNotRewrittenByEveryLaterRun() async throws {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"AgentPersistenceReplayTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
@@ -480,7 +480,7 @@ struct AgentPersistenceTests {
@Test
func outOfOrderPersistenceEventsNeverDowngradeToolState() async throws {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"AgentPersistenceEventOrderTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
@@ -512,7 +512,7 @@ struct AgentPersistenceTests {
@Test
func deletingConversationRemovesItsCompletePersistentGraph() async throws {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"AgentPersistenceDeletionTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
@@ -568,7 +568,7 @@ struct AgentPersistenceTests {
/// back to notice.
@Test
func aTitleIsWrittenOnlyBySummaryAndSurvivesRestore() async throws {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"AgentPersistenceTitleTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
@@ -605,7 +605,7 @@ struct AgentPersistenceTests {
@Test
func metadataAndAuthoredTextSurviveRestore() async throws {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"AgentConversationMetadataTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
@@ -638,7 +638,7 @@ struct AgentPersistenceTests {
@Test
func truncationRemovesTheTargetRunAndEverythingAfterIt() async throws {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"AgentConversationTruncationTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
@@ -677,7 +677,7 @@ struct AgentPersistenceTests {
@Test
func truncationRejectsAMessageOutsideThePersistedConversation() async throws {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"AgentConversationMissingEditTargetTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
@@ -710,7 +710,7 @@ struct AgentPersistenceTests {
@Test
func creatingAForkPersistsAnIndependentConversationGraph() async throws {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"AgentConversationForkTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
+1 -1
View File
@@ -107,7 +107,7 @@ struct CommandHistoryTests {
}
private func makeContainer(_ name: String) throws -> ModelContainer {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
name, schema: HostPersistence.schema, isStoredInMemoryOnly: true
)
return try ModelContainer(
+1 -1
View File
@@ -62,7 +62,7 @@ struct KeychainOrderTests {
@Test
func keyAndIdentityOrdersSurvivePersistenceReload() throws {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"KeychainOrderTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
@@ -0,0 +1,32 @@
import Foundation
import SwiftData
/// Tests run inside the signed app host. Once that host has an iCloud
/// entitlement SwiftData's `.automatic` default would incorrectly attempt to
/// mirror every test store through CloudKit. Production also opts out because
/// Vault sync is owned explicitly by `VaultSyncCoordinator`.
func localModelConfiguration(
_ name: String? = nil,
schema: Schema? = nil,
isStoredInMemoryOnly: Bool = false,
allowsSave: Bool = true,
groupContainer: ModelConfiguration.GroupContainer = .automatic
) -> ModelConfiguration {
ModelConfiguration(
name, schema: schema, isStoredInMemoryOnly: isStoredInMemoryOnly,
allowsSave: allowsSave, groupContainer: groupContainer,
cloudKitDatabase: .none
)
}
func localModelConfiguration(
_ name: String? = nil,
schema: Schema? = nil,
url: URL,
allowsSave: Bool = true
) -> ModelConfiguration {
ModelConfiguration(
name, schema: schema, url: url, allowsSave: allowsSave,
cloudKitDatabase: .none
)
}
@@ -138,7 +138,7 @@ struct PortForwardingFeatureTests {
@Test
func termindV1RoundTripKeepsRoutesAndDropsDynamicTargets() throws {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"PortForwardingFeatureTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
+5 -5
View File
@@ -47,7 +47,7 @@ struct RepositoryOutboxTests {
do {
let schema = Schema(versionedSchema: TermindSchemaV3.self)
let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL)
let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL)
let container = try ModelContainer(for: schema, configurations: [configuration])
let context = container.mainContext
context.insert(TermindSchemaV3Models.PersistentIdentity(identity))
@@ -58,7 +58,7 @@ struct RepositoryOutboxTests {
}
let schema = Schema(versionedSchema: TermindSchemaV5.self)
let configuration = ModelConfiguration("Migration", schema: schema, url: storeURL)
let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL)
let container = try ModelContainer(
for: schema, migrationPlan: TermindMigrationPlan.self,
configurations: [configuration]
@@ -109,7 +109,7 @@ struct RepositoryOutboxTests {
do {
let schema = Schema(versionedSchema: TermindSchemaV1.self)
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"Migration", schema: schema, url: storeURL
)
let container = try ModelContainer(
@@ -130,7 +130,7 @@ struct RepositoryOutboxTests {
}
let schema = Schema(versionedSchema: TermindSchemaV5.self)
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"Migration", schema: schema, url: storeURL
)
let container = try ModelContainer(
@@ -258,7 +258,7 @@ struct RepositoryOutboxTests {
}
private func makeContainer(_ name: String) throws -> ModelContainer {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
name, schema: HostPersistence.schema, isStoredInMemoryOnly: true
)
return try ModelContainer(
+1 -1
View File
@@ -90,7 +90,7 @@ struct SnippetFeatureTests {
@Test
func snippetPersistenceRoundTripKeepsPackagesTargetsAndExpansion() throws {
let configuration = ModelConfiguration(
let configuration = localModelConfiguration(
"SnippetFeatureTests", schema: HostPersistence.schema,
isStoredInMemoryOnly: true
)
+233
View File
@@ -0,0 +1,233 @@
import CloudKit
import Foundation
import SwiftData
import Testing
@testable import Termind
@Suite
@MainActor
struct VaultSyncTests {
@Test
func allVaultPayloadsRoundTripWithStableRecordNames() throws {
let host = Host(name: "Production", host: "prod.example")
let group = HostGroup(name: "Servers")
let identity = Identity(name: "Deploy", username: "deploy")
let key = SSHKeyRef(label: "Deploy Key", publicKey: "ssh-ed25519 AAAA")
let knownHost = KnownHostEntry(hostname: "prod.example", fingerprint: "SHA256:test")
let package = SnippetPackage(name: "Operations")
let snippet = Snippet(title: "Uptime", body: "uptime", packageID: package.id)
let forwarding = PortForwardingRule(hostID: host.id)
let payloads: [VaultRecordPayload] = [
.host(host), .hostGroup(group), .identity(identity), .sshKey(key),
.knownHost(knownHost), .snippet(snippet), .snippetPackage(package),
.portForwarding(forwarding),
]
let writer = UUID()
for payload in payloads {
let envelope = VaultEnvelope(
key: payload.key, version: .baseline(writerID: writer), payload: payload
)
let decoded = try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope))
#expect(decoded == envelope)
#expect(VaultRecordKey(recordName: payload.key.recordName) == payload.key)
}
}
@Test
func journalRecordsOnlyRealChangesAndKeepsDeletionTombstones() throws {
let container = try makeContainer("JournalDiff")
let context = container.mainContext
let persistence = HostPersistence(context: context)
let host = Host(name: "Build", host: "build.example")
let initial = VaultSnapshot(hosts: [host])
let first = try persistence.save(initial)
#expect(first.keys == [VaultRecordKey(kind: .host, id: host.id)])
#expect(try persistence.save(initial).isEmpty)
let deletion = try persistence.save(VaultSnapshot())
let key = VaultRecordKey(kind: .host, id: host.id)
#expect(deletion.keys == [key])
let tombstone = try #require(
try VaultSyncJournal(context: context).envelope(for: key)
)
#expect(tombstone.isDeleted)
#expect(tombstone.payload == nil)
}
@Test
func newerRemoteRecordWinsAndOlderRemoteRecordIsRequeued() throws {
let container = try makeContainer("RemoteMerge")
let context = container.mainContext
let persistence = HostPersistence(context: context)
var host = Host(name: "Local", host: "local.example")
_ = try persistence.save(VaultSnapshot(hosts: [host]))
let key = VaultRecordKey(kind: .host, id: host.id)
let local = try #require(
try VaultSyncJournal(context: context).envelope(for: key)
)
host.name = "Remote"
let newer = VaultEnvelope(
key: key,
version: VaultVersion(
modifiedAt: local.version.modifiedAt.addingTimeInterval(10),
counter: 0, writerID: UUID()
),
payload: .host(host)
)
let accepted = try persistence.applyRemote([newer])
#expect(accepted.snapshot.hosts.first?.name == "Remote")
#expect(accepted.acceptedKeys == [key])
let older = VaultEnvelope(
key: key, version: .baseline(writerID: UUID()),
payload: .host(Host(id: host.id, name: "Stale", host: "stale.example"))
)
let rejected = try persistence.applyRemote([older])
#expect(rejected.snapshot.hosts.first?.name == "Remote")
#expect(rejected.needsUpload == [key])
}
@Test
func repositoryOwnedHostWritesAlsoEnterTheDurableJournal() throws {
let container = try makeContainer("RepositoryJournal")
let context = container.mainContext
_ = try HostPersistence(context: context).load()
let repository = SwiftDataHostRepository(context: context)
let host = Host(name: "Repository", host: "repository.example")
var published = VaultMutationSet.empty
repository.didRecordVaultChanges = { published = $0 }
try repository.upsertHost(host)
let key = VaultRecordKey(kind: .host, id: host.id)
#expect(published.keys == [key])
let envelope = try #require(
try VaultSyncJournal(context: context).envelope(for: key)
)
#expect(envelope.payload == .host(host))
#expect(
try context.fetch(FetchDescriptor<PersistentVaultSyncEntry>()).first?.needsUpload
== true
)
}
@Test
func encodedVaultMetadataContainsReferencesButNoCredentialMaterial() throws {
let secret = "private-material-must-never-sync"
let identity = Identity(
name: "Deploy", username: "deploy",
credentialRef: "termind.password.reference", passwordStorage: .iCloud
)
let envelope = VaultEnvelope(
key: VaultRecordKey(kind: .identity, id: identity.id),
version: .baseline(writerID: UUID()), payload: .identity(identity)
)
let encoded = String(decoding: VaultSyncCodec.encode(envelope), as: UTF8.self)
#expect(encoded.contains(identity.credentialRef))
#expect(!encoded.contains(secret))
}
@Test
func cloudRecordUsesAssetForLargePayloadAndRejectsCorruption() throws {
let host = Host(name: String(repeating: "A", count: 710_000), host: "large.example")
let envelope = VaultEnvelope(
key: VaultRecordKey(kind: .host, id: host.id),
version: VaultVersion(modifiedAt: .now, counter: 0, writerID: UUID()),
payload: .host(host)
)
let recordID = CKRecord.ID(
recordName: envelope.key.recordName,
zoneID: CKRecordZone.ID(
zoneName: VaultSyncCoordinator.zoneName,
ownerName: CKCurrentUserDefaultName
)
)
let record = try VaultSyncCoordinator.makeRecord(
VaultOutgoingEntry(envelope: envelope), recordID: recordID
)
#expect(record["payload"] == nil)
#expect(record["payloadAsset"] as? CKAsset != nil)
#expect(try VaultSyncCoordinator.envelope(from: record) == envelope)
record["payloadHash"] = "bad" as NSString
#expect(throws: (any Error).self) { try VaultSyncCoordinator.envelope(from: record) }
}
@Test
func versionFourteenMigratesToSyncJournalWithoutLosingVault() throws {
let directory = FileManager.default.temporaryDirectory
.appending(path: "VaultSyncMigration-\(UUID().uuidString)", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let storeURL = directory.appending(path: "Termind.store")
let host = Host(name: "Migration", host: "migration.example")
do {
let schema = Schema(versionedSchema: TermindSchemaV14.self)
let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL)
let container = try ModelContainer(for: schema, configurations: [configuration])
container.mainContext.insert(PersistentHost(host))
try container.mainContext.save()
}
let configuration = localModelConfiguration(
"Migration", schema: HostPersistence.schema, url: storeURL
)
let container = try ModelContainer(
for: HostPersistence.schema,
migrationPlan: TermindMigrationPlan.self,
configurations: [configuration]
)
let persistence = HostPersistence(context: container.mainContext)
#expect(try persistence.load().hosts == [host])
let entry = try #require(
container.mainContext.fetch(FetchDescriptor<PersistentVaultSyncEntry>()).first
)
#expect(entry.envelope?.payload == .host(host))
#expect(entry.needsUpload)
}
@Test
func originalVersionFifteenStoreMigratesToQuarantineSchema() throws {
let directory = FileManager.default.temporaryDirectory
.appending(path: "VaultSyncV15Migration-\(UUID().uuidString)", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let storeURL = directory.appending(path: "Termind.store")
let host = Host(name: "V15 Migration", host: "v15.example")
do {
let schema = Schema(versionedSchema: TermindSchemaV15.self)
let configuration = localModelConfiguration("Migration", schema: schema, url: storeURL)
let container = try ModelContainer(for: schema, configurations: [configuration])
container.mainContext.insert(PersistentHost(host))
container.mainContext.insert(TermindSchemaV15Models.PersistentVaultSyncState())
try container.mainContext.save()
}
let configuration = localModelConfiguration(
"Migration", schema: HostPersistence.schema, url: storeURL
)
let container = try ModelContainer(
for: HostPersistence.schema,
migrationPlan: TermindMigrationPlan.self,
configurations: [configuration]
)
#expect(try container.mainContext.fetch(FetchDescriptor<PersistentHost>()).first?.value == host)
#expect(try container.mainContext.fetch(FetchDescriptor<PersistentVaultSyncState>()).count == 1)
#expect(try container.mainContext.fetch(FetchDescriptor<PersistentVaultSyncQuarantine>()).isEmpty)
}
private func makeContainer(_ name: String) throws -> ModelContainer {
let configuration = localModelConfiguration(
name, schema: HostPersistence.schema, isStoredInMemoryOnly: true
)
return try ModelContainer(
for: HostPersistence.schema, configurations: [configuration]
)
}
}