Files
Termind/Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycle.swift
T

536 lines
20 KiB
Swift

import CloudKit
import Foundation
import TermindApplication
import TermindDomain
/// A value projection of journal state; persistent models remain owned by Persistence.
struct CloudSyncState {
var engineStateData: Data?
var boundUserRecordName: String?
var pendingUserRecordName: String?
var lastSuccessfulSync: Date?
}
@MainActor
struct CloudSyncLifecycleStore {
var state: () throws -> CloudSyncState
var updateState: (CloudSyncState) throws -> Void
var save: () throws -> Void
var rollback: () -> Void
var pendingRecordNames: (CloudSyncScope) throws -> [String]
var safety: CloudSyncLifecycleSafety
var resetForNewAccount: () throws -> Void
var resetForZone: () throws -> Void
var resetForEnvironment: () throws -> Void
}
@MainActor
protocol CloudSyncEngineSession: AnyObject {
func fetch(in zoneID: CKRecordZone.ID) async throws
func send() async throws
func cancel() async
func enqueue(_ recordNames: [String], in zoneID: CKRecordZone.ID)
func removePending(_ recordNames: Set<String>, in zoneID: CKRecordZone.ID)
func recreateZone(_ zoneID: CKRecordZone.ID)
}
extension CKSyncEngine: CloudSyncEngineSession {
func fetch(in zoneID: CKRecordZone.ID) async throws {
try await fetchChanges(.init(scope: .zoneIDs([zoneID])))
}
func send() async throws { try await sendChanges() }
func cancel() async { await cancelOperations() }
func enqueue(_ recordNames: [String], in zoneID: CKRecordZone.ID) {
state.add(pendingRecordZoneChanges: recordNames.map { .saveRecord(.init(recordName: $0, zoneID: zoneID)) })
}
func removePending(_ recordNames: Set<String>, in zoneID: CKRecordZone.ID) {
state.remove(
pendingRecordZoneChanges: state.pendingRecordZoneChanges.filter { change in
switch change {
case .saveRecord(let id):
id.zoneID == zoneID && recordNames.contains(id.recordName)
case .deleteRecord(let id):
// No physical deletes belong to this protocol, including restored legacy queue entries.
id.zoneID == zoneID
@unknown default: false
}
})
}
func recreateZone(_ zoneID: CKRecordZone.ID) {
state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: zoneID))])
}
}
@MainActor
struct CloudSyncTransport {
var accountStatus: () async throws -> CKAccountStatus
var userRecordName: () async throws -> String
var createZone: () async throws -> Void
var makeEngine: (Data?) throws -> any CloudSyncEngineSession
static func live(
container: CKContainer, zoneID: CKRecordZone.ID, subscriptionID: String, delegate: any CKSyncEngineDelegate
) -> Self {
Self(
accountStatus: { try await container.accountStatus() },
userRecordName: { try await container.userRecordID().recordName },
createZone: {
_ = try await container.privateCloudDatabase.modifyRecordZones(
saving: [CKRecordZone(zoneID: zoneID)], deleting: [])
},
makeEngine: { [weak delegate] data in
guard let delegate else { throw CancellationError() }
let serialization = data.flatMap {
try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: $0)
}
var configuration = CKSyncEngine.Configuration(
database: container.privateCloudDatabase, stateSerialization: serialization, delegate: delegate)
configuration.automaticallySync = true
configuration.subscriptionID = subscriptionID
return CKSyncEngine(configuration)
})
}
}
/// One instance per category: only implementation is shared, never account or engine state.
@MainActor @Observable
final class CloudSyncLifecycle {
/// CloudKit accepts at most 250 record changes in one network request.
/// Every CKSyncEngine delegate in the app builds its batches through this lifecycle.
nonisolated static let maximumRecordsPerRequest = 250
let store: CloudSyncLifecycleStore
let transport: CloudSyncTransport
let zoneID: CKRecordZone.ID
private let containerIdentifier: String
private let defaults: UserDefaults
private let enabledKey: String
private let defaultEnabled: Bool
private let environmentKey: String
private let environmentIdentifier: String
private let signInMessage: () -> String
private let authorizeUploads: (Bool) -> Void
private let beforeSend: () throws -> Void
private(set) var engine: (any CloudSyncEngineSession)?
private(set) var scope: CloudSyncScope?
private(set) var operation: Task<Void, Never>?
private var operationID = UUID()
private var isStarted = false
private var zonePrepared = false
// Explicit fetch/send calls and automatic engine events can overlap.
private var fetching = 0
private var sending = 0
private var fetchFailed = false
private(set) var isReadyToSend = false
var terminalError: String?
var status: VaultSyncStatus = .checkingAccount
init(
store: CloudSyncLifecycleStore, transport: CloudSyncTransport, zoneID: CKRecordZone.ID,
containerIdentifier: String, defaults: UserDefaults, enabledKey: String, defaultEnabled: Bool,
environmentKey: String, environmentIdentifier: String, signInMessage: @escaping () -> String,
authorizeUploads: @escaping (Bool) -> Void = { _ in }, beforeSend: @escaping () throws -> Void = {}
) {
self.store = store
self.transport = transport
self.zoneID = zoneID
self.containerIdentifier = containerIdentifier
self.defaults = defaults
self.enabledKey = enabledKey
self.defaultEnabled = defaultEnabled
self.environmentKey = environmentKey
self.environmentIdentifier = environmentIdentifier
self.signInMessage = signInMessage
self.authorizeUploads = authorizeUploads
self.beforeSend = beforeSend
}
var isEnabled: Bool {
(defaults.object(forKey: SettingsKey.iCloudSyncEnabled) as? Bool ?? SettingsKey.defaultICloudSyncEnabled)
&& (defaults.object(forKey: enabledKey) as? Bool ?? defaultEnabled)
}
func start() {
guard isEnabled else {
pause()
return
}
guard !isStarted else { return }
isStarted = true
run { [weak self] in try await self?.prepareAndSynchronize() }
}
func setEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: enabledKey)
if enabled { start() } else { pause() }
}
func pause() {
retireEngine()
status = .paused
}
func retry() {
guard isEnabled else { return }
terminalError = nil
if engine == nil {
isStarted = false
start()
} else {
run { [weak self] in try await self?.synchronizeNow() }
}
}
func noteLocalChanges(_ recordNames: [String]) {
guard !recordNames.isEmpty else { return }
do {
try enqueue(recordNames)
guard isEnabled, engine != nil else { return }
run { [weak self] in
try await self?.sendPendingChanges()
try self?.updateUpToDateStatus()
}
} catch { failClosed(error) }
}
private func makeScope(account: String, environment: String) -> CloudSyncScope {
CloudSyncScope(
container: containerIdentifier, environment: environment, account: account,
zone: zoneID.zoneName, owner: zoneID.ownerName)
}
private func legacyScope(for state: CloudSyncState) -> CloudSyncScope {
makeScope(
account: state.boundUserRecordName ?? "legacy-unbound",
environment: defaults.string(forKey: environmentKey) ?? "Unknown")
}
func confirmAccountSwitchMerge() {
guard isEnabled else { return }
do {
var state = try store.state()
guard let pendingUser = state.pendingUserRecordName else { return }
let oldScope = legacyScope(for: state)
_ = try store.safety.prepare(oldScope, oldScope)
try store.resetForNewAccount()
state = try store.state()
state.boundUserRecordName = pendingUser
state.pendingUserRecordName = nil
try store.updateState(state)
try store.save()
retireEngine()
start()
} catch { failClosed(error) }
}
private func run(_ body: @escaping @MainActor () async throws -> Void) {
guard operation == nil else { return }
let id = UUID()
operationID = id
operation = Task { [weak self] in
defer { if self?.operationID == id { self?.operation = nil } }
do { try await body() } catch is CancellationError {} catch {
guard let self, self.operationID == id else { return }
if let cloudError = error as? CKError, Self.isRetryable(cloudError) {
self.status = self.status(for: error)
} else {
self.failClosed(error)
}
}
}
}
private func prepareAndSynchronize() async throws {
let id = operationID
status = .checkingAccount
let accountStatus = try await transport.accountStatus()
guard operationID == id, isStarted, isEnabled, !Task.isCancelled else { return }
guard accountStatus == .available else {
status = .unavailable(accountStatusDescription(accountStatus))
isStarted = false
return
}
let currentUser = try await transport.userRecordName()
guard operationID == id, isStarted, isEnabled, !Task.isCancelled else { return }
var state = try store.state()
if let boundUser = state.boundUserRecordName, boundUser != currentUser {
state.pendingUserRecordName = currentUser
try store.updateState(state)
try store.save()
status = .accountChangeConfirmationRequired
isStarted = false
return
}
let scope = makeScope(account: currentUser, environment: environmentIdentifier)
let requiresRefetch = try store.safety.prepare(scope, legacyScope(for: state))
self.scope = scope
state.boundUserRecordName = currentUser
state.pendingUserRecordName = nil
if requiresRefetch { state.engineStateData = nil }
try store.updateState(state)
if defaults.string(forKey: environmentKey) != environmentIdentifier {
try store.resetForEnvironment()
try store.save()
defaults.set(environmentIdentifier, forKey: environmentKey)
terminalError = nil
}
// Commit the recovery fence before constructing an automatically syncing engine.
try setRecoveryRequired(true)
let engine = try transport.makeEngine(store.state().engineStateData)
self.engine = engine
try reconcilePendingChanges()
try store.save()
try await transport.createZone()
guard accepts(engine), !Task.isCancelled else { return }
zonePrepared = true
try await fetchAndSend(engine)
}
private func synchronizeNow() async throws {
guard let engine, let scope else { return }
status = .syncing(pendingCount: try store.pendingRecordNames(scope).count)
try await fetchAndSend(engine)
}
private func fetchAndSend(_ engine: any CloudSyncEngineSession) async throws {
try beginFetch()
do {
try await engine.fetch(in: zoneID)
} catch {
if accepts(engine) { try finishFetch(succeeded: false) }
throw error
}
guard accepts(engine), !Task.isCancelled else { return }
try finishFetch()
try await sendPendingChanges()
guard accepts(engine), !Task.isCancelled else { return }
try updateUpToDateStatus()
}
private func sendPendingChanges() async throws {
guard let engine, let scope, isEnabled, isReadyToSend else { return }
try beforeSend()
try reconcilePendingChanges()
let pending = try store.pendingRecordNames(scope)
guard !pending.isEmpty else { return }
status = .syncing(pendingCount: pending.count)
try beginSend()
do {
try await engine.send()
} catch {
if accepts(engine) { try finishSend(succeeded: false) }
throw error
}
guard accepts(engine), !Task.isCancelled else { return }
try finishSend()
}
private func setRecoveryRequired(_ required: Bool) throws {
guard let scope else { throw CancellationError() }
try store.safety.setRecoveryRequired(scope, required)
try store.save()
}
}
extension CloudSyncLifecycle {
func beginFetch() throws {
isReadyToSend = false
authorizeUploads(false)
if fetching == 0 { fetchFailed = false }
fetching += 1
try setRecoveryRequired(true)
}
func noteFetchFailure(_ error: CKError) {
fetchFailed = true
isReadyToSend = false
authorizeUploads(false)
status = self.status(for: error)
}
func finishFetch(succeeded: Bool = true) throws {
fetching = max(0, fetching - 1)
fetchFailed = fetchFailed || !succeeded
try setRecoveryRequired(fetching > 0 || sending > 0 || fetchFailed)
isReadyToSend = zonePrepared && fetching == 0 && !fetchFailed
authorizeUploads(isReadyToSend)
}
func beginSend() throws {
sending += 1
try setRecoveryRequired(true)
}
func finishSend(succeeded: Bool = true) throws {
sending = max(0, sending - 1)
try setRecoveryRequired(!succeeded || fetching > 0 || sending > 0 || fetchFailed)
}
func enqueue(_ recordNames: [String]) throws {
guard let engine, let scope else { return }
let blocked = try store.safety.blockedRecordNames(scope)
engine.removePending(blocked, in: zoneID)
engine.enqueue(recordNames.filter { !blocked.contains($0) }, in: zoneID)
}
func reconcilePendingChanges() throws {
guard let scope else { return }
try enqueue(store.pendingRecordNames(scope))
}
func accepts(_ engine: any CloudSyncEngineSession) -> Bool { self.engine === engine && isEnabled }
func canUpload(_ recordID: CKRecord.ID, engine: any CloudSyncEngineSession) throws -> Bool {
guard accepts(engine), isReadyToSend, let scope, recordID.zoneID == zoneID else { return false }
if try store.safety.isUploadBlocked(scope, recordID.recordName) {
engine.removePending([recordID.recordName], in: zoneID)
return false
}
return true
}
/// Synchronous materialization avoids yielding between the final fence check and batch handoff.
func makeChangeBatch(
_ pending: [CKSyncEngine.PendingRecordZoneChange], engine: any CloudSyncEngineSession,
recordProvider: (CKRecord.ID) -> CKRecord?
) -> CKSyncEngine.RecordZoneChangeBatch? {
guard accepts(engine), isReadyToSend else { return nil }
do {
try setRecoveryRequired(true)
try reconcilePendingChanges()
var records: [CKRecord] = []
for change in pending {
// The protocol uses tombstones. Never emit a physical deletion from a restored queue.
guard case .saveRecord(let id) = change, try canUpload(id, engine: engine) else { continue }
if let record = recordProvider(id) {
records.append(record)
if records.count == Self.maximumRecordsPerRequest { break }
}
}
guard accepts(engine), isReadyToSend, !records.isEmpty else { return nil }
return CKSyncEngine.RecordZoneChangeBatch(recordsToSave: records)
} catch {
failClosed(error)
return nil
}
}
func updateUpToDateStatus() throws {
if let terminalError {
status = .failed(terminalError)
return
}
guard isReadyToSend, let scope else { return }
let pendingCount = try store.pendingRecordNames(scope).count
let blockedCount = try store.safety.blockedRecordNames(scope).count
if blockedCount > 0 {
status = .partiallyBlocked(
pendingCount: pendingCount, blockedCount: blockedCount,
requiresUpdate: try store.safety.requiresNewerClient(scope))
} else if pendingCount == 0 {
var state = try store.state()
state.lastSuccessfulSync = .now
try store.updateState(state)
try store.save()
status = .upToDate(lastSuccessfulSync: state.lastSuccessfulSync)
} else {
status = .syncing(pendingCount: pendingCount)
}
}
func handleAccountChange(_ change: CKSyncEngine.Event.AccountChange.ChangeType) throws {
var state = try store.state()
switch change {
case .signIn(let user):
if let bound = state.boundUserRecordName, bound != user.recordName {
retireEngine()
state.pendingUserRecordName = user.recordName
status = .accountChangeConfirmationRequired
}
case .signOut:
retireEngine()
status = .unavailable(signInMessage())
case .switchAccounts(_, let user):
retireEngine()
state.pendingUserRecordName = user.recordName
status = .accountChangeConfirmationRequired
@unknown default:
retireEngine()
status = .unavailable(String(localized: "The iCloud account changed."))
}
try store.updateState(state)
try store.save()
}
/// A delegate persistence failure must invalidate this engine, not just change a label.
/// The already-committed recovery fence causes the next engine to fetch from the beginning.
func failClosed(_ error: any Error) {
store.rollback()
retireEngine()
status = self.status(for: error)
}
private func retireEngine() {
operationID = UUID()
operation?.cancel()
operation = nil
isReadyToSend = false
authorizeUploads(false)
isStarted = false
zonePrepared = false
fetching = 0
sending = 0
fetchFailed = false
scope = nil
let activeEngine = engine
engine = nil
if let activeEngine { Task { await activeEngine.cancel() } }
}
func handleZoneDeletion() throws {
try store.resetForZone()
engine?.recreateZone(zoneID)
try reconcilePendingChanges()
try store.save()
}
func saveEngineState(_ serialization: CKSyncEngine.State.Serialization) throws {
var state = try store.state()
state.engineStateData = try JSONEncoder().encode(serialization)
try store.updateState(state)
try store.save()
}
static func isRetryable(_ error: CKError) -> Bool {
switch error.code {
case .networkFailure, .networkUnavailable, .serviceUnavailable,
.requestRateLimited, .zoneBusy, .resultsTruncated:
true
default: false
}
}
func status(for error: any Error) -> VaultSyncStatus {
if let cloudError = error as? CKError {
if cloudError.code == .notAuthenticated { return .unavailable(signInMessage()) }
if Self.isRetryable(cloudError) { return .syncing(pendingCount: 0) }
}
return .failed(error.localizedDescription)
}
}
extension CloudSyncLifecycle {
private func accountStatusDescription(_ status: CKAccountStatus) -> String {
switch status {
case .noAccount: signInMessage()
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.")
}
}
}