444 lines
19 KiB
Swift
444 lines
19 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
import TermindDomain
|
|
|
|
public nonisolated struct SettingsRemoteMerge: Sendable {
|
|
public var accepted: [SettingsEnvelope] = []
|
|
public var needsUpload: Set<SettingsRecordKey> = []
|
|
}
|
|
|
|
public nonisolated struct SettingsOutgoingEntry: Sendable {
|
|
public var envelope: SettingsEnvelope
|
|
public var systemFieldsData: Data?
|
|
public init(envelope: SettingsEnvelope, systemFieldsData: Data? = nil) {
|
|
self.envelope = envelope
|
|
self.systemFieldsData = systemFieldsData
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
public final class SettingsSyncJournal {
|
|
private let context: ModelContext
|
|
|
|
public init(context: ModelContext) { self.context = context }
|
|
|
|
/// Moves the pre-Guardian role record onto its canonical key. The legacy
|
|
/// row cannot be uploaded by current code because its decoded role is
|
|
/// `.guardian` while its record name is still `securityReview`.
|
|
///
|
|
/// The replacement deliberately has no CloudKit system fields: those
|
|
/// fields belong to the old record ID and must never be reused for the new
|
|
/// `v1/role/guardian` record.
|
|
public func migrateLegacyGuardianRole() throws -> SettingsMutationSet {
|
|
let legacyKey = SettingsRecordKey(kind: .role, id: SettingsRecordKey.legacySecurityReviewID)
|
|
guard let legacyRow = try entry(recordName: legacyKey.recordName),
|
|
let legacyEnvelope = legacyRow.envelope,
|
|
legacyEnvelope.isValidLegacyGuardianRecord
|
|
else { return .empty }
|
|
|
|
let migrated = legacyEnvelope.canonicalized
|
|
let canonicalKey = migrated.key
|
|
let canonicalRow = try entry(recordName: canonicalKey.recordName)
|
|
let winner: SettingsEnvelope
|
|
if let current = canonicalRow?.envelope {
|
|
winner = try preferred(current, migrated)
|
|
} else {
|
|
winner = migrated
|
|
}
|
|
|
|
let canonicalChanged = canonicalRow?.envelope != winner
|
|
if canonicalChanged {
|
|
if let canonicalRow {
|
|
try canonicalRow.apply(winner, needsUpload: true)
|
|
canonicalRow.systemFieldsData = nil
|
|
} else {
|
|
context.insert(
|
|
try PersistentAIConfigurationSyncEntry(envelope: winner, needsUpload: true)
|
|
)
|
|
}
|
|
}
|
|
// Preserve tombstone acknowledgement state. The scoped physical-deletion sidecar
|
|
// schedules an unconfirmed historical tombstone even when needsUpload is false.
|
|
if !legacyEnvelope.isDeleted {
|
|
context.delete(legacyRow)
|
|
}
|
|
if canonicalChanged || !legacyEnvelope.isDeleted {
|
|
let syncState = try state()
|
|
syncState.engineStateData = nil
|
|
}
|
|
return SettingsMutationSet(keys: canonicalChanged ? [canonicalKey] : [])
|
|
}
|
|
|
|
/// Removes records that are retained only for wire compatibility. Resetting
|
|
/// the engine state also drops any pending save for those records without
|
|
/// creating a CloudKit deletion.
|
|
public func discardInactiveEntries() throws {
|
|
let rows = try context.fetch(FetchDescriptor<PersistentAIConfigurationSyncEntry>()).filter {
|
|
// Highlight payloads are retired, including malformed data and old tombstones.
|
|
if $0.kind == SettingsRecordKind.terminalHighlighting.rawValue
|
|
|| SettingsRecordKey(recordName: $0.recordName)?.kind == .terminalHighlighting
|
|
{
|
|
return true
|
|
}
|
|
return $0.envelope.map { !$0.key.kind.participatesInSync && !$0.isDeleted } == true
|
|
}
|
|
if !rows.isEmpty {
|
|
for row in rows { context.delete(row) }
|
|
let syncState = try state()
|
|
syncState.engineStateData = nil
|
|
}
|
|
}
|
|
|
|
/// Clears the synchronized replica while retaining non-uploadable tombstone sentinels
|
|
/// for singleton defaults that `prepare()` otherwise adds automatically.
|
|
public func purgeCloudCache() throws -> [CloudSyncMutation] {
|
|
let state = try state()
|
|
var suppressed: [CloudSyncMutation] = []
|
|
for row in try context.fetch(FetchDescriptor<PersistentAIConfigurationSyncEntry>()) {
|
|
context.delete(row)
|
|
}
|
|
for key in [
|
|
SettingsRecordKey(kind: .webSearch, id: "default"),
|
|
SettingsRecordKey(kind: .terminal, id: "default"),
|
|
SettingsRecordKey(kind: .ssh, id: "default"),
|
|
SettingsRecordKey(kind: .sftp, id: "default"),
|
|
SettingsRecordKey(kind: .workspace, id: "default"),
|
|
SettingsRecordKey(kind: .notifications, id: "default"),
|
|
] {
|
|
let envelope = SettingsEnvelope(
|
|
key: key, version: .baseline(writerID: state.installationID), isDeleted: true)
|
|
context.insert(try PersistentAIConfigurationSyncEntry(envelope: envelope, needsUpload: false))
|
|
suppressed.append(
|
|
CloudSyncMutation(recordName: key.recordName, operation: .delete, version: envelope.version))
|
|
}
|
|
state.bootstrapCompleted = true
|
|
state.engineStateData = nil
|
|
state.pendingUserRecordName = nil
|
|
state.lastSuccessfulSync = nil
|
|
return suppressed
|
|
}
|
|
|
|
public func state() throws -> PersistentAIConfigurationSyncState {
|
|
var descriptor = FetchDescriptor<PersistentAIConfigurationSyncState>()
|
|
descriptor.fetchLimit = 1
|
|
if let existing = try context.fetch(descriptor).first { return existing }
|
|
let state = PersistentAIConfigurationSyncState()
|
|
context.insert(state)
|
|
return state
|
|
}
|
|
|
|
@discardableResult
|
|
public func bootstrapIfNeeded(_ snapshot: SettingsSyncSnapshot) throws -> SettingsMutationSet {
|
|
let state = try state()
|
|
guard !state.bootstrapCompleted else { return .empty }
|
|
let existing = try entriesByName()
|
|
var changed = Set<SettingsRecordKey>()
|
|
for payload in snapshot.recordPayloads.values {
|
|
let key = payload.key
|
|
guard existing[key.recordName] == nil else { continue }
|
|
let envelope = SettingsEnvelope(
|
|
key: key, version: .baseline(writerID: state.installationID), payload: payload
|
|
)
|
|
context.insert(try PersistentAIConfigurationSyncEntry(envelope: envelope, needsUpload: true))
|
|
changed.insert(key)
|
|
}
|
|
state.bootstrapCompleted = true
|
|
return SettingsMutationSet(keys: changed)
|
|
}
|
|
|
|
public func recordLocalChanges(
|
|
from oldSnapshot: SettingsSyncSnapshot, to newSnapshot: SettingsSyncSnapshot,
|
|
now: Date = .now
|
|
) throws -> SettingsMutationSet {
|
|
let state = try state()
|
|
let old = oldSnapshot.recordPayloads
|
|
let new = newSnapshot.recordPayloads
|
|
var entries = try entriesByName()
|
|
var changed = Set<SettingsRecordKey>()
|
|
|
|
for (key, payload) in new where old[key] != payload {
|
|
let row = entries[key.recordName]
|
|
let envelope = SettingsEnvelope(
|
|
key: key,
|
|
version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now),
|
|
payload: payload
|
|
)
|
|
try store(envelope, needsUpload: true, in: &entries)
|
|
changed.insert(key)
|
|
}
|
|
|
|
for key in old.keys where new[key] == nil {
|
|
let row = entries[key.recordName]
|
|
let envelope = SettingsEnvelope(
|
|
key: key,
|
|
version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now),
|
|
isDeleted: true
|
|
)
|
|
try store(envelope, needsUpload: true, in: &entries)
|
|
changed.insert(key)
|
|
}
|
|
return SettingsMutationSet(keys: changed)
|
|
}
|
|
|
|
public func pendingEnvelopes() throws -> [SettingsEnvelope] {
|
|
try context.fetch(
|
|
FetchDescriptor<PersistentAIConfigurationSyncEntry>(
|
|
predicate: #Predicate { $0.needsUpload }
|
|
)
|
|
).compactMap(\.envelope).filter { $0.key.kind.participatesInSync }
|
|
}
|
|
|
|
public func outgoingEntry(for key: SettingsRecordKey, scope: CloudSyncScope? = nil) throws -> SettingsOutgoingEntry?
|
|
{
|
|
if let scope,
|
|
try CloudSyncSafetyStore(context: context, scope: scope).isUploadBlocked(recordName: key.recordName)
|
|
{
|
|
return nil
|
|
}
|
|
guard key.kind.participatesInSync else { return nil }
|
|
guard let row = try entry(recordName: key.recordName), row.needsUpload, let envelope = row.envelope,
|
|
!envelope.isDeleted
|
|
else {
|
|
return nil
|
|
}
|
|
return SettingsOutgoingEntry(envelope: envelope, systemFieldsData: row.systemFieldsData)
|
|
}
|
|
|
|
public func envelope(for key: SettingsRecordKey) throws -> SettingsEnvelope? {
|
|
try entry(recordName: key.recordName)?.envelope
|
|
}
|
|
|
|
public func mergeDecision(for remote: [SettingsEnvelope]) throws -> SettingsRemoteMerge {
|
|
var result = SettingsRemoteMerge()
|
|
let entries = try entriesByName()
|
|
// A fetch batch can contain several versions of one record. Compare the
|
|
// newest candidate once, independently of delivery order.
|
|
var candidates: [SettingsRecordKey: SettingsEnvelope] = [:]
|
|
for envelope in remote {
|
|
guard envelope.key.kind.participatesInSync else { continue }
|
|
guard envelope.isValid else {
|
|
throw SettingsSyncPersistenceError.invalidEnvelope(envelope.key.recordName)
|
|
}
|
|
if let previous = candidates[envelope.key] {
|
|
if envelope.version < previous.version { continue }
|
|
let previousData = try SettingsSyncCodec.encode(previous)
|
|
let incomingData = try SettingsSyncCodec.encode(envelope)
|
|
if envelope.version == previous.version, !previousData.lexicographicallyPrecedes(incomingData) {
|
|
continue
|
|
}
|
|
}
|
|
candidates[envelope.key] = envelope
|
|
}
|
|
for envelope in candidates.values {
|
|
guard envelope.formatVersion <= SettingsEnvelope.currentFormatVersion else {
|
|
throw SettingsSyncPersistenceError.unsupportedFormat(envelope.formatVersion)
|
|
}
|
|
guard envelope.isValid else {
|
|
throw SettingsSyncPersistenceError.invalidEnvelope(envelope.key.recordName)
|
|
}
|
|
guard let local = entries[envelope.key.recordName]?.envelope else {
|
|
result.accepted.append(envelope)
|
|
continue
|
|
}
|
|
if local.isDeleted && !envelope.isDeleted && entries[envelope.key.recordName]?.needsUpload == true {
|
|
result.needsUpload.insert(local.key)
|
|
continue
|
|
}
|
|
if local.version.modifiedAt == VaultVersion.baselineDate
|
|
&& !local.isDeleted && local != envelope
|
|
{
|
|
result.accepted.append(envelope)
|
|
} else 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 = try SettingsSyncCodec.encode(local)
|
|
let remoteData = try SettingsSyncCodec.encode(envelope)
|
|
if localData.lexicographicallyPrecedes(remoteData) {
|
|
result.accepted.append(envelope)
|
|
} else {
|
|
result.needsUpload.insert(local.key)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
public func acceptRemote(_ envelopes: [SettingsEnvelope]) throws {
|
|
var entries = try entriesByName()
|
|
for envelope in envelopes {
|
|
try store(envelope, needsUpload: false, in: &entries)
|
|
}
|
|
}
|
|
|
|
func storeMigrationSeed(_ payload: SettingsRecordPayload) throws {
|
|
let envelope = SettingsEnvelope(
|
|
key: payload.key, version: .baseline(writerID: try state().installationID), payload: payload)
|
|
var entries = try entriesByName()
|
|
try store(envelope, needsUpload: true, in: &entries)
|
|
}
|
|
|
|
public func markNeedsUpload(_ keys: Set<SettingsRecordKey>) throws {
|
|
let entries = try entriesByName()
|
|
for key in keys where key.kind.participatesInSync {
|
|
entries[key.recordName]?.needsUpload = true
|
|
}
|
|
}
|
|
|
|
public func markUploaded(_ key: SettingsRecordKey, version: VaultVersion) throws {
|
|
guard let row = try entry(recordName: key.recordName), row.envelope?.version == version, row.needsUpload else {
|
|
return
|
|
}
|
|
row.needsUpload = false
|
|
}
|
|
|
|
public func queueHistoricalDeletion(_ envelope: SettingsEnvelope) throws {
|
|
guard envelope.isDeleted,
|
|
!envelope.key.kind.participatesInSync || envelope.key.isLegacyGuardianRole
|
|
else { return }
|
|
var entries = try entriesByName()
|
|
try store(envelope, needsUpload: true, in: &entries)
|
|
try updateSystemFields(nil, for: envelope.key)
|
|
}
|
|
|
|
public func clearSystemFields(recordName: String) throws {
|
|
try entry(recordName: recordName)?.systemFieldsData = nil
|
|
}
|
|
|
|
public func updateSystemFields(_ data: Data?, for key: SettingsRecordKey) throws {
|
|
guard let row = try entry(recordName: key.recordName), row.systemFieldsData != data else { return }
|
|
row.systemFieldsData = data
|
|
}
|
|
|
|
public func markAllLiveRecordsNeedsUploadForNewAccount() throws {
|
|
for row in try context.fetch(FetchDescriptor<PersistentAIConfigurationSyncEntry>()) {
|
|
guard let envelope = row.envelope else { continue }
|
|
guard envelope.key.kind.participatesInSync else {
|
|
context.delete(row)
|
|
continue
|
|
}
|
|
if envelope.isDeleted {
|
|
context.delete(row)
|
|
} else {
|
|
row.needsUpload = true
|
|
row.systemFieldsData = nil
|
|
}
|
|
}
|
|
let state = try state()
|
|
state.engineStateData = nil
|
|
state.lastSuccessfulSync = nil
|
|
}
|
|
|
|
public func markAllEntriesNeedsUploadAfterZoneReset() throws {
|
|
for row in try context.fetch(FetchDescriptor<PersistentAIConfigurationSyncEntry>()) {
|
|
row.needsUpload = row.envelope?.key.kind.participatesInSync == true
|
|
row.systemFieldsData = nil
|
|
}
|
|
let state = try state()
|
|
state.engineStateData = nil
|
|
state.lastSuccessfulSync = nil
|
|
}
|
|
|
|
public func markAllEntriesNeedsUploadAfterEnvironmentChange() throws {
|
|
try markAllEntriesNeedsUploadAfterZoneReset()
|
|
}
|
|
|
|
private func entry(recordName: String) throws -> PersistentAIConfigurationSyncEntry? {
|
|
var descriptor = FetchDescriptor<PersistentAIConfigurationSyncEntry>(
|
|
predicate: #Predicate { $0.recordName == recordName }
|
|
)
|
|
descriptor.fetchLimit = 1
|
|
return try context.fetch(descriptor).first
|
|
}
|
|
|
|
private func store(
|
|
_ envelope: SettingsEnvelope, needsUpload: Bool,
|
|
in entries: inout [String: PersistentAIConfigurationSyncEntry]
|
|
) throws {
|
|
if let row = entries[envelope.key.recordName] {
|
|
if row.envelope?.isDeleted == true && !envelope.isDeleted { row.systemFieldsData = nil }
|
|
try row.apply(envelope, needsUpload: needsUpload)
|
|
} else {
|
|
let row = try PersistentAIConfigurationSyncEntry(
|
|
envelope: envelope, needsUpload: needsUpload
|
|
)
|
|
context.insert(row)
|
|
entries[envelope.key.recordName] = row
|
|
}
|
|
}
|
|
|
|
private func preferred(_ first: SettingsEnvelope, _ second: SettingsEnvelope) throws -> SettingsEnvelope {
|
|
if first.version != second.version {
|
|
return first.version < second.version ? second : first
|
|
}
|
|
let firstData = try SettingsSyncCodec.encode(first)
|
|
let secondData = try SettingsSyncCodec.encode(second)
|
|
return firstData.lexicographicallyPrecedes(secondData) ? second : first
|
|
}
|
|
|
|
private func entriesByName() throws -> [String: PersistentAIConfigurationSyncEntry] {
|
|
Dictionary(
|
|
try context.fetch(FetchDescriptor<PersistentAIConfigurationSyncEntry>()).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
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
extension SettingsSyncJournal {
|
|
public func cloudRecords() throws -> [CloudSyncJournalRecord] {
|
|
try context.fetch(FetchDescriptor<PersistentAIConfigurationSyncEntry>()).compactMap { row in
|
|
guard let envelope = row.envelope else { return nil }
|
|
let key = envelope.key
|
|
guard key.kind.participatesInSync || envelope.isDeleted else { return nil }
|
|
return CloudSyncJournalRecord(
|
|
recordName: key.recordName, version: envelope.version, isDeleted: envelope.isDeleted,
|
|
needsUpload: row.needsUpload, hasSystemFields: row.systemFieldsData != nil)
|
|
}
|
|
}
|
|
|
|
public func deletionEnvelopes(_ keys: Set<SettingsRecordKey>) throws -> [SettingsEnvelope] {
|
|
try keys.compactMap { key in
|
|
guard key.kind.participatesInSync else { return nil }
|
|
let row = try entry(recordName: key.recordName)
|
|
// Preserve undecodable local data until a compatible client can reconcile it.
|
|
if let row, row.envelope == nil { return nil }
|
|
let old = row?.envelope
|
|
if let old, old.isDeleted { return old }
|
|
return SettingsEnvelope(
|
|
key: key, version: .next(after: old?.version, writerID: try state().installationID), isDeleted: true)
|
|
}
|
|
}
|
|
|
|
public func acknowledgeDeletion(_ mutation: CloudSyncMutation, scope: CloudSyncScope) throws {
|
|
guard let key = SettingsRecordKey(recordName: mutation.recordName),
|
|
let row = try entry(recordName: mutation.recordName), let envelope = row.envelope
|
|
else { return }
|
|
guard key.kind.participatesInSync || envelope.isDeleted else { return }
|
|
if !envelope.isDeleted && row.needsUpload && mutation.version < envelope.version {
|
|
// A later explicit recreation is still pending; acknowledge only the old server incarnation.
|
|
try updateSystemFields(nil, for: key)
|
|
try CloudSyncRecordStateStore(context: context, scope: scope).deleted(
|
|
key.recordName, version: mutation.version)
|
|
return
|
|
}
|
|
guard envelope.isDeleted, envelope.version == mutation.version else { return }
|
|
try markUploaded(key, version: mutation.version)
|
|
try updateSystemFields(nil, for: key)
|
|
try CloudSyncRecordStateStore(context: context, scope: scope).deleted(key.recordName, version: mutation.version)
|
|
}
|
|
}
|