import Foundation import IndieSync 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 /// Last non-fatal sync failure, surfaced for the UI to render. Cleared on the /// next successful write or full reconcile. private(set) var lastSyncError: String? /// Maps a seed's id to its clone's id after a clone-on-edit fork, so a view still /// holding the seed's id resolves to the live clone. In-memory only (@Observable /// notifies on change); not persisted — a seed is edited at most once before it's /// gone, so nothing needs to survive a relaunch. private(set) var cloneRedirects: [String: String] = [:] /// Follow the redirect chain from `id` to the current live split id. A seed /// redirects at most once, but the loop tolerates (and breaks) any chain or cycle. func currentSplitID(for id: String) -> String { var current = id var seen: Set = [current] while let next = cloneRedirects[current], seen.insert(next).inserted { current = next } return current } /// Called after the cache changes (local or remote). The watch bridge uses /// this to push fresh state to the watch. var onCacheChanged: (() -> Void)? /// Called when a completed workout is persisted. The HealthKit writer uses this to /// schedule an estimated Health workout if nothing recorded it. var onWorkoutCompleted: ((WorkoutDocument) -> Void)? /// Called when a workout carrying watch-recorded metrics is persisted, so the /// writer can cancel any pending phone estimate for it. var onWatchMetricsArrived: ((String) -> Void)? private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "sync") private let modelContainer: ModelContainer private var store: DocumentFileStore? private var tombstones: TombstoneStore? private var monitor: MetadataObserver? 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 store = DocumentFileStore(root: containerURL.appendingPathComponent("Documents", isDirectory: true)) // 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 store.prepareDirectories(["Splits", "Workouts", "Stubs"]) safety.cancel() guard attempt == connectAttempt else { return } self.store = store self.tombstones = TombstoneStore(store: store) iCloudStatus = .available log.info("connect[\(attempt)]: directories ready → available") WorkoutsModelContainer.persistCurrentIdentityToken() await reconcile() startMonitoring(documentsURL: store.rootURL) cleanupOldStubs() // Off the connect path so opening the gate isn't delayed by the settle wait. Task { await self.autoSeedIfEmpty() } } /// 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 = MetadataObserver(documentsURL: documentsURL) self.monitor = monitor monitor.start() monitorTask = Task { [weak self] in for await batch in monitor.events() { await self?.handle(batch) } } } private func handle(_ batch: [FileChangeEvent]) async { for event in batch { switch event { case .added(let path), .modified(let path): if path.hasPrefix("Stubs/") { let id = idFromStubPath(path) deleteCachedEntity(id: id) // A stub can arrive before (or instead of) the deleting device's // live-file removal; drop any live file for this id so the delete // takes effect and reconcile can't re-import it. await removeLiveFile(forID: id) } else { await importFile(relativePath: path) } case .removed(let path): if !path.hasPrefix("Stubs/") { deleteCachedEntity(jsonRelativePath: path) } } } do { try context.save() } catch { report("Cache save failed", error) } 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 { // A workout deleted on the phone leaves a tombstone; a watch that missed the // delete may still push its stale copy. Honor the veto — never resurrect it — // and re-push authoritative state (via onCacheChanged → pushAll) so the watch // replaces its stale set and drops the deleted workout. if let tombstones, await tombstones.stubExists(id: doc.id) { onCacheChanged?() return } await save(workout: doc) CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context) do { try context.save() } catch { report("Cache save failed", error) } onCacheChanged?() } // MARK: - Public CRUD (write path: files only) /// Returns the *effective* id of the split that was written — normally `doc.id`, /// but the clone's fresh id when an edited seed forks. Open views follow the swap /// via `currentSplitID(for:)`. @discardableResult func save(split doc: SplitDocument) async -> String { guard let store else { return doc.id } // Seeds are immutable: a real edit forks the seed into a user-owned split and // soft-deletes the original, keeping the curated seed intact and restorable. A // pristine (no-op) save must NOT fork — the edit sheets stamp `updatedAt` // unconditionally, so merely opening and saving a seed's editor without a change // would otherwise clone it. if SeedLibrary.isSeed(id: doc.id), !SeedLibrary.isPristine(doc) { return await cloneSeedOnEdit(doc) } do { try await store.write(doc, to: doc.relativePath) lastSyncError = nil } catch { report("Failed to save split", error) } // Cache updates reactively via the monitor. return doc.id } /// Fork an edited seed into a user-owned split. Writes the clone under a fresh /// ULID and upserts it into the cache immediately (same rationale as /// `ingestFromWatch`: a same-process write doesn't reliably wake the metadata /// observer, and open screens must see the clone the moment `save` returns), then /// soft-deletes the seed and drops its cache entity. Records the id redirect so a /// view still holding the seed's id follows the identity swap. private func cloneSeedOnEdit(_ doc: SplitDocument) async -> String { guard let store else { return doc.id } var clone = doc clone.id = ULID.make() clone.createdAt = Date() // Exercise ids are kept as-is — they only need uniqueness within the document. do { try await store.write(clone, to: clone.relativePath) CacheMapper.upsertSplit(clone, relativePath: clone.relativePath, into: context) // Soft-delete the seed (writes its veto stub, then removes the live file), // then evict its now-orphaned cache entity. await softDelete(id: doc.id, kind: "split", livePath: doc.relativePath) deleteCachedEntity(id: doc.id) try context.save() cloneRedirects[doc.id] = clone.id lastSyncError = nil onCacheChanged?() return clone.id } catch { report("Failed to fork edited starter split", error) return doc.id } } func save(workout doc: WorkoutDocument) async { guard let store else { return } do { try await store.write(doc, to: doc.relativePath) lastSyncError = nil } catch { report("Failed to save workout", error) } // Drive the HealthKit writer: a watch-recorded doc cancels any pending estimate; // a completed doc (re)considers an estimate (the writer dedupes on metrics). if doc.metrics?.source == .watch { onWatchMetricsArrived?(doc.id) } if doc.status == WorkoutStatus.completed.rawValue { onWorkoutCompleted?(doc) } } 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) } /// Writes a tombstone stub then removes the live file. Other devices learn of /// the delete via the stub even if they were offline for the file removal. private func softDelete(id: String, kind: String, livePath: String) async { guard let store, let tombstones else { return } do { try await tombstones.writeTombstone(Tombstone(id: id, deletedAt: Date(), kind: kind)) try await store.remove(at: livePath) lastSyncError = nil } catch { report("Failed to delete \(id)", error) } } // MARK: - Import / reconcile private func importFile(relativePath: String) async { guard let store, let tombstones else { return } let data: Data do { data = try await store.readData(from: relativePath) } catch { // Includes eviction-download timeouts — log loudly, never silently // treat an unreadable file as absent. The next monitor event or // reconcile retries it. log.error("import: read failed for \(relativePath, privacy: .public): \(error)") report("Failed to read \(relativePath)", error) return } if relativePath.hasPrefix("Splits/") { guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { return } if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return } CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context) } else if relativePath.hasPrefix("Workouts/") { guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return } if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: 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 store, let tombstones else { return } isSyncing = true defer { isSyncing = false } // IDs from stub filenames, not stub contents — an evicted stub must // still count as a tombstone or the deleted record resurrects. let tombstoned = await tombstones.listStubIDs() let dataFiles = await store.list().filter { !$0.hasPrefix("Stubs/") } var liveSplitIDs = Set() var liveWorkoutIDs = Set() var unreadablePaths = Set() for path in dataFiles { let data: Data do { data = try await store.readData(from: path) } catch { // A failed read (eviction-download timeout, coordination error) // is not proof the record is gone — remember the path so the // prune below keeps its cache entity. Skipping silently here is // how evicted records used to vanish on storage-constrained // devices. log.error("reconcile: read failed for \(path, privacy: .public): \(error)") report("Failed to read \(path)", error) unreadablePaths.insert(path) continue } if path.hasPrefix("Splits/") { guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue } if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue } CacheMapper.upsertSplit(doc, relativePath: path, into: context) liveSplitIDs.insert(doc.id) } else if path.hasPrefix("Workouts/") { guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { continue } if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue } CacheMapper.upsertWorkout(doc, relativePath: path, into: context) liveWorkoutIDs.insert(doc.id) } } // Prune cache entities no longer backed by a live file — but never for // a path that failed to read this pass (it may just be un-downloadable // right now; pruning would make an eviction look like a deletion). if let splits = try? context.fetch(FetchDescriptor()) { for s in splits where !liveSplitIDs.contains(s.id) && !unreadablePaths.contains(s.jsonRelativePath) { context.delete(s) } } if let workouts = try? context.fetch(FetchDescriptor()) { for w in workouts where !liveWorkoutIDs.contains(w.id) && !unreadablePaths.contains(w.jsonRelativePath) { context.delete(w) } } do { try context.save() // A fully clean pass supersedes any earlier failure; a pass with // unreadable files keeps its own report visible. if unreadablePaths.isEmpty { lastSyncError = nil } } catch { report("Cache save failed", error) } 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: "") } /// Remove any live data file for `id` when its tombstone arrives. Matches by /// filename identity across every non-`Stubs/` path — deliberately NOT /// `resolveExistingPath(forID:)`, which would also match the stub itself. private func removeLiveFile(forID id: String) async { guard let store else { return } let target = "\(id).json" let livePaths = await store.list().filter { !$0.hasPrefix("Stubs/") && ($0 as NSString).lastPathComponent == target } for path in livePaths { try? await store.remove(at: path) } } // MARK: - Maintenance private func cleanupOldStubs() { guard let tombstones else { return } let exempt = SeedLibrary.seedIDs Task.detached(priority: .utility) { // Downloads evicted stubs to read their deletedAt, prunes past the // 30-day grace period. Seed tombstones veto resurrection permanently, so // they're exempt and never pruned. try? await tombstones.prune(exempting: exempt) } } // MARK: - Seeding /// True when the container holds no documents at all — not one data file and not /// one tombstone. A user with only workouts, or any tombstone (i.e. a prior /// delete), is not a new user, so we never auto-seed over them. Placeholder-aware /// by construction (`store.list()` sees evicted files too). private func containerEmpty() async -> Bool { guard let store else { return false } return await store.list().isEmpty } /// Write the starter library exactly once, only into a verifiably empty container /// (the true first run). Deferred and re-checked after a settle delay: an empty /// listing right after connect can just mean the metadata index hasn't populated /// yet, and seeding into that illusion would duplicate a library that's about to /// arrive. private func autoSeedIfEmpty() async { guard let store, await containerEmpty() else { return } // Give the metadata index a chance to surface existing files before we conclude // the account is genuinely new. try? await Task.sleep(for: .seconds(5)) guard iCloudStatus == .available, await containerEmpty() else { return } for seed in SeedLibrary.seeds { do { // Verbatim bundle bytes: byte-identical files across devices make a // same-path conflict (two devices seeding at once) semantically empty. try await store.writeData(seed.data, to: seed.doc.relativePath) CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context) } catch { report("Failed to seed \(seed.doc.name)", error) } } do { try context.save() } catch { report("Cache save failed", error) } onCacheChanged?() } /// Add one seed fresh: write its verbatim bundle bytes and upsert the cache. The /// on-demand "Add Starter Splits" path uses this for a seed that's neither present /// nor tombstoned. func writeSeed(_ seed: SeedLibrary.Seed) async { guard let store else { return } do { try await store.writeData(seed.data, to: seed.doc.relativePath) CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context) try context.save() lastSyncError = nil } catch { report("Failed to add starter split \(seed.doc.name)", error) } onCacheChanged?() } /// Bring a previously-deleted seed back: lift its veto stub, then write its /// verbatim bundle bytes. The bundle is the canonical restore source (seed content /// is immutable), so this restores from the bundle rather than reconstructing from /// the stub — the seed's stub is only ever a minimal veto marker. The stub is /// removed first so neither a racing observer event nor a reconcile re-vetoes the /// file we're about to write while the stub still exists. func restoreSeed(_ seed: SeedLibrary.Seed) async { guard let store, let tombstones else { return } do { try await tombstones.removeStub(at: "\(seed.id).json") try await store.writeData(seed.data, to: seed.doc.relativePath) CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context) try context.save() lastSyncError = nil } catch { report("Failed to restore starter split \(seed.doc.name)", error) } onCacheChanged?() } /// Whether a tombstone exists for `id` (placeholder-aware). The on-demand seed /// path checks this to restore rather than re-create a deleted seed. func isTombstoned(id: String) async -> Bool { guard let tombstones else { return false } return await tombstones.stubExists(id: id) } // MARK: - Error Reporting /// Record a non-fatal sync failure: log it and publish it as `lastSyncError` /// for the UI to render. Replaces silent `try?` / print-only handling. private func report(_ message: String, _ error: Error? = nil) { let full = error.map { "\(message): \($0.localizedDescription)" } ?? message print("[Sync] \(full)") lastSyncError = full } }