import Foundation import SwiftData import Observation import os enum ICloudStatus: Equatable { case checking case available case unavailable } /// Orchestrates the iCloud Drive file layer and the SwiftData cache. iCloud is /// the sole source of truth: every save/delete writes files only; the metadata /// observer (and the connect-time reconcile) is the sole mutator of the cache. @Observable @MainActor final class SyncEngine { nonisolated static let containerIdentifier = "iCloud.dev.rzen.indie.Workouts" private(set) var iCloudStatus: ICloudStatus = .checking private(set) var isSyncing = false /// Called after the cache changes (local or remote). The watch bridge uses /// this to push fresh state to the watch. var onCacheChanged: (() -> Void)? private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "sync") private let modelContainer: ModelContainer private var fileManager: ICloudFileManager? private var monitor: ICloudFileMonitor? private var monitorTask: Task? private var connectAttempt = 0 /// How long `connect()` keeps polling for a still-provisioning iCloud /// container before falling to the end-of-the-line gate. Deliberately long /// (~10 min): as long as the user is signed into iCloud, a container that's /// slow to come online should never be misreported as unavailable. Impatient /// users bail sooner via the connecting screen's escape hatch (28s). private static let connectTimeoutSeconds: TimeInterval = 600 private var context: ModelContext { modelContainer.mainContext } init(container: ModelContainer) { self.modelContainer = container } // MARK: - Connection (deferred, patient) func connect() async { guard iCloudStatus != .available else { return } connectAttempt += 1 let attempt = connectAttempt iCloudStatus = .checking log.info("connect[\(attempt)]: resolving container \(Self.containerIdentifier, privacy: .public)") // Definitive failure first: if the user isn't signed into iCloud at all, // no container is ever coming — go straight to the end-of-the-line gate // rather than spinning. (Signed-in-but-Drive-off still reports a token; // that case falls through to the patient poll below and the escape hatch.) let signedIn = await Task.detached { FileManager.default.ubiquityIdentityToken != nil }.value guard attempt == connectAttempt else { return } guard signedIn else { log.error("connect[\(attempt)]: not signed into iCloud → unavailable") iCloudStatus = .unavailable return } // Signed in, but the container may still be provisioning — common right // after enabling iCloud Drive. Keep polling patiently: we'd rather hold the // spinner than misreport a working account as unavailable. We only give up // after a considerable timeout; the user can bail sooner via the connecting // screen's escape hatch (which bumps connectAttempt and stops this loop). var resolved: URL? let deadline = Date().addingTimeInterval(Self.connectTimeoutSeconds) while resolved == nil { let url = await Task.detached { FileManager.default.url(forUbiquityContainerIdentifier: Self.containerIdentifier) }.value guard attempt == connectAttempt else { return } if let url { resolved = url break } if Date() >= deadline { log.error("connect[\(attempt)]: container still nil after \(Int(Self.connectTimeoutSeconds))s → unavailable") iCloudStatus = .unavailable return } log.info("connect[\(attempt)]: container nil — still provisioning, retrying") try? await Task.sleep(for: .seconds(2)) guard attempt == connectAttempt else { return } } guard let containerURL = resolved else { return } log.info("connect[\(attempt)]: container URL = \(containerURL.path, privacy: .public)") let fm = ICloudFileManager(containerURL: containerURL) // Safety net only: prepareDirectories is a local op that effectively never // blocks, but if the first container file op ever wedges we don't want an // eternal spinner. This is generous — it isn't the connect path's clock. let safety = Task { [weak self] in try? await Task.sleep(for: .seconds(30)) guard let self, !Task.isCancelled else { return } if self.iCloudStatus == .checking, attempt == self.connectAttempt { self.log.error("connect[\(attempt)]: prepareDirectories wedged 30s → unavailable") self.iCloudStatus = .unavailable } } log.info("connect[\(attempt)]: preparing directories…") await fm.prepareDirectories() safety.cancel() guard attempt == connectAttempt else { return } self.fileManager = fm iCloudStatus = .available log.info("connect[\(attempt)]: directories ready → available") WorkoutsModelContainer.persistCurrentIdentityToken() await reconcile() startMonitoring(documentsURL: fm.documentsURL) cleanupOldStubs() } /// Invoked from the connecting screen when the user chooses not to keep /// waiting. Bumps `connectAttempt` to stop the in-flight poll loop, then drops /// to the end-of-the-line gate (with its Try Again). func abandonWaiting() { guard iCloudStatus == .checking else { return } connectAttempt += 1 iCloudStatus = .unavailable log.info("connect: abandoned by user → unavailable") } // MARK: - Monitoring private func startMonitoring(documentsURL: URL) { monitorTask?.cancel() let monitor = ICloudFileMonitor(documentsURL: documentsURL) self.monitor = monitor monitor.start() monitorTask = Task { [weak self] in for await event in monitor.events() { await self?.handle(event) } } } private func handle(_ event: ICloudFileMonitor.FileChangeEvent) async { switch event { case .added(let path), .modified(let path): if path.hasPrefix("Stubs/") { deleteCachedEntity(id: idFromStubPath(path)) } else { await importFile(relativePath: path) } case .removed(let path): if !path.hasPrefix("Stubs/") { deleteCachedEntity(jsonRelativePath: path) } } try? context.save() onCacheChanged?() } /// Apply a workout received from the watch. iCloud Drive stays the source of /// truth (we write the file), but we also upsert the cache directly here. /// /// The phone's own edits drive a local view copy, so they don't need this — but a /// watch-originated change has nothing else refreshing the phone UI, and a /// same-process file overwrite doesn't reliably wake the `NSMetadataQuery` /// observer. Upserting the doc we just wrote keeps cache and file consistent (the /// observer re-applies idempotently if it does fire) and lets open phone screens /// reflect watch progress live. func ingestFromWatch(_ doc: WorkoutDocument) async { await save(workout: doc) CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context) try? context.save() onCacheChanged?() } // MARK: - Public CRUD (write path: files only) func save(split doc: SplitDocument) async { guard let fm = fileManager else { return } do { try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) } catch { print("[Sync] write failed for \(doc.relativePath): \(error)") } // Cache updates reactively via the monitor. } func save(workout doc: WorkoutDocument) async { guard let fm = fileManager else { return } do { try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath) } catch { print("[Sync] write failed for \(doc.relativePath): \(error)") } } func delete(split: Split) async { await softDelete(id: split.id, kind: .split, livePath: split.jsonRelativePath) } func delete(workout: Workout) async { await softDelete(id: workout.id, kind: .workout, livePath: workout.jsonRelativePath) } private func softDelete(id: String, kind: Tombstone.Kind, livePath: String) async { guard let fm = fileManager else { return } let tombstone = Tombstone(id: id, kind: kind, deletedAt: Date()) do { try await fm.writeTombstoneAndRemove(tombstone, livePath: livePath) } catch { print("[Sync] delete failed for \(id): \(error)") } } // MARK: - Import / reconcile private func importFile(relativePath: String) async { guard let fm = fileManager else { return } guard let data = try? await fm.read(relativePath: relativePath) else { return } if relativePath.hasPrefix("Splits/") { guard let doc = try? DocumentCoder.decoder.decode(SplitDocument.self, from: data), doc.isReadable else { return } if await fm.fileExists("Stubs/\(doc.id).json") { try? await fm.remove(relativePath: relativePath); return } CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context) } else if relativePath.hasPrefix("Workouts/") { guard let doc = try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return } if await fm.fileExists("Stubs/\(doc.id).json") { try? await fm.remove(relativePath: relativePath); return } CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context) } } /// Full sync against the current file set — imports new/changed files and /// prunes entities whose file is gone or tombstoned. Runs on connect so /// changes accumulated while the app was closed are picked up. private func reconcile() async { guard let fm = fileManager else { return } isSyncing = true defer { isSyncing = false } let tombstoned = Set(await fm.listTombstones().map(\.id)) let dataFiles = await fm.listDataFiles() var liveSplitIDs = Set() var liveWorkoutIDs = Set() for path in dataFiles { guard let data = try? await fm.read(relativePath: path) else { continue } if path.hasPrefix("Splits/") { guard let doc = try? DocumentCoder.decoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue } if tombstoned.contains(doc.id) { try? await fm.remove(relativePath: path); continue } CacheMapper.upsertSplit(doc, relativePath: path, into: context) liveSplitIDs.insert(doc.id) } else if path.hasPrefix("Workouts/") { guard let doc = try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { continue } if tombstoned.contains(doc.id) { try? await fm.remove(relativePath: path); continue } CacheMapper.upsertWorkout(doc, relativePath: path, into: context) liveWorkoutIDs.insert(doc.id) } } // Prune cache entities no longer backed by a live file. if let splits = try? context.fetch(FetchDescriptor()) { for s in splits where !liveSplitIDs.contains(s.id) { context.delete(s) } } if let workouts = try? context.fetch(FetchDescriptor()) { for w in workouts where !liveWorkoutIDs.contains(w.id) { context.delete(w) } } try? context.save() onCacheChanged?() } // MARK: - Cache deletes private func deleteCachedEntity(id: String) { if let s = CacheMapper.fetchSplit(id: id, in: context) { context.delete(s) } if let w = CacheMapper.fetchWorkout(id: id, in: context) { context.delete(w) } } private func deleteCachedEntity(jsonRelativePath path: String) { if let splits = try? context.fetch(FetchDescriptor(predicate: #Predicate { $0.jsonRelativePath == path })) { splits.forEach(context.delete) } if let workouts = try? context.fetch(FetchDescriptor(predicate: #Predicate { $0.jsonRelativePath == path })) { workouts.forEach(context.delete) } } private func idFromStubPath(_ path: String) -> String { (path as NSString).lastPathComponent.replacingOccurrences(of: ".json", with: "") } // MARK: - Maintenance private func cleanupOldStubs() { guard let fm = fileManager else { return } Task.detached(priority: .utility) { let cutoff = Date().addingTimeInterval(-Tombstone.gracePeriod) for tombstone in await fm.listTombstones() where tombstone.deletedAt < cutoff { try? await fm.remove(relativePath: tombstone.relativePath) } } } }