From 79ec9a57f3ddc95cfe1f3c06bd70432ee514bb3f Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 16 Feb 2026 14:39:09 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Andromida/App/State/RitualStore.swift | 42 +++++++++++----- Andromida/App/Views/RootView.swift | 51 +++++++++++++++++++ SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md | 6 +++ SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md | 61 +++++++++-------------- 4 files changed, 110 insertions(+), 50 deletions(-) diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index 553ebe8..b9cfa2b 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -20,7 +20,8 @@ final class RitualStore: RitualStoreProviding { @ObservationIgnored private let isRunningTests: Bool @ObservationIgnored private let dayFormatter: DateFormatter @ObservationIgnored private let displayFormatter: DateFormatter - @ObservationIgnored private let cloudKitSyncManager: SwiftDataCloudKitSyncManager + @ObservationIgnored private var remoteChangeObserver: NSObjectProtocol? + @ObservationIgnored private var remoteChangeEventCount: Int = 0 private(set) var rituals: [Ritual] = [] private(set) var currentRituals: [Ritual] = [] @@ -34,7 +35,7 @@ final class RitualStore: RitualStoreProviding { private var pendingReminderTask: Task? private var insightCardsNeedRefresh = true private var cachedInsightCards: [InsightCard] = [] - private var lastRefreshDate: Date? + @ObservationIgnored private let refreshThrottler = SwiftDataRefreshThrottler() /// Reminder scheduler for time-slot based notifications let reminderScheduler = ReminderScheduler() @@ -75,10 +76,6 @@ final class RitualStore: RitualStoreProviding { self.isRunningTests = isRunningTests self.dayFormatter = DateFormatter() self.displayFormatter = DateFormatter() - self.cloudKitSyncManager = SwiftDataCloudKitSyncManager( - isEnabled: !isRunningTests, - logIdentifier: "AndromidaCloudKitSync" - ) dayFormatter.calendar = calendar dayFormatter.dateFormat = "yyyy-MM-dd" displayFormatter.calendar = calendar @@ -95,17 +92,30 @@ final class RitualStore: RitualStoreProviding { private func now() -> Date { nowProvider() } - + + deinit { + if let observer = remoteChangeObserver { + NotificationCenter.default.removeObserver(observer) + } + } + /// Observes CloudKit remote change notifications to auto-refresh UI when iCloud data syncs. private func observeRemoteChanges() { - cloudKitSyncManager.startObserving { [weak self] in - self?.handleRemoteStoreChange() + remoteChangeObserver = NotificationCenter.default.addObserver( + forName: .NSPersistentStoreRemoteChange, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.handleRemoteStoreChange() + } } } private func handleRemoteStoreChange() { - let eventCount = cloudKitSyncManager.remoteChangeEventCount - lastRemoteChangeDate = cloudKitSyncManager.lastRemoteChangeDate + remoteChangeEventCount += 1 + let eventCount = remoteChangeEventCount + lastRemoteChangeDate = now() // SwiftData may keep stale registered objects in long-lived contexts. // Recreate the context on remote store changes so fetches observe latest merged values. modelContext = ModelContext(modelContainer) @@ -157,10 +167,16 @@ final class RitualStore: RitualStoreProviding { /// Refreshes rituals if the last refresh was beyond the minimum interval. func refreshIfNeeded(minimumInterval: TimeInterval = 5) { let currentDate = now() - if let lastRefreshDate, currentDate.timeIntervalSince(lastRefreshDate) < minimumInterval { + guard refreshThrottler.shouldRefresh(now: currentDate, minimumInterval: minimumInterval) else { return } - lastRefreshDate = currentDate + refresh() + } + + /// Forces a refresh path intended for external sync probes (for example, + /// runtimes where remote change callbacks may be delayed while foregrounded). + func refreshFromExternalSyncProbe() { + modelContext = ModelContext(modelContainer) refresh() } diff --git a/Andromida/App/Views/RootView.swift b/Andromida/App/Views/RootView.swift index 82a5415..e39aae8 100644 --- a/Andromida/App/Views/RootView.swift +++ b/Andromida/App/Views/RootView.swift @@ -10,6 +10,8 @@ struct RootView: View { @State private var selectedTab: RootTab @State private var analyticsPrewarmTask: Task? @State private var cloudKitFallbackRefreshTask: Task? + @State private var aggressiveCloudKitRefreshTasks: [Task] = [] + @State private var macSyncSafetyNetTask: Task? @State private var isForegroundRefreshing = false @State private var isResumingFromBackground = false private let foregroundRefreshMinimumSeconds: TimeInterval = 0.15 @@ -133,11 +135,16 @@ struct RootView: View { showOverlay: useDebugOverlay, minimumSeconds: useDebugOverlay ? debugForegroundRefreshMinimumSeconds : foregroundRefreshMinimumSeconds ) + scheduleAggressiveCloudKitRefreshes() scheduleCloudKitFallbackRefresh() + startMacSyncSafetyNetIfNeeded() } else if newPhase == .background { // Prepare for next resume isResumingFromBackground = true cloudKitFallbackRefreshTask?.cancel() + aggressiveCloudKitRefreshTasks.forEach { $0.cancel() } + aggressiveCloudKitRefreshTasks.removeAll() + macSyncSafetyNetTask?.cancel() } } .onChange(of: selectedTab) { _, _ in @@ -152,6 +159,15 @@ struct RootView: View { .onOpenURL { url in handleURL(url) } + .onAppear { + startMacSyncSafetyNetIfNeeded() + } + .onDisappear { + macSyncSafetyNetTask?.cancel() + macSyncSafetyNetTask = nil + aggressiveCloudKitRefreshTasks.forEach { $0.cancel() } + aggressiveCloudKitRefreshTasks.removeAll() + } } private func handleURL(_ url: URL) { @@ -216,6 +232,41 @@ struct RootView: View { store.refresh() } } + + /// Aggressive delayed refreshes to catch late CloudKit merge delivery. + /// This mirrors the previous high-reliability behavior used before Bedrock extraction. + private func scheduleAggressiveCloudKitRefreshes() { + aggressiveCloudKitRefreshTasks.forEach { $0.cancel() } + aggressiveCloudKitRefreshTasks.removeAll() + + for delay in [5.0, 10.0] { + let task = Task { @MainActor in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled else { return } + guard scenePhase == .active else { return } + store.refresh() + } + aggressiveCloudKitRefreshTasks.append(task) + } + } + + /// iOS-on-Mac can miss immediate remote push delivery while foregrounded. + /// Keep a lightweight active-phase polling safety net for CloudKit merges. + private func startMacSyncSafetyNetIfNeeded() { + let isMacRuntime = ProcessInfo.processInfo.isiOSAppOnMac || UIDevice.current.userInterfaceIdiom == .mac + guard isMacRuntime else { return } + guard scenePhase == .active else { return } + guard macSyncSafetyNetTask == nil else { return } + + macSyncSafetyNetTask = Task { @MainActor in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled else { return } + guard scenePhase == .active else { continue } + store.refreshFromExternalSyncProbe() + } + } + } } #Preview { diff --git a/SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md b/SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md index 322fbd6..53a0c5b 100644 --- a/SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md +++ b/SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md @@ -1,5 +1,10 @@ # SwiftData to CloudKit Sync Requirements (Reusable) +Primary source of truth lives in Bedrock: +`/Users/mattbruce/Documents/Projects/iPhone/Andromida/Bedrock/Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md` + +This Andromida copy is app-facing reference material and should stay aligned with the Bedrock guide. + Use this checklist for any iOS app that uses SwiftData with CloudKit and requires near-real-time multi-device sync. ## 1) Capabilities and Entitlements @@ -34,6 +39,7 @@ Entitlements should reference variables, not hard-coded values, where possible. ## 4) Runtime Sync Behavior +- Prefer Bedrock `SwiftDataCloudKitSyncManager` as the reusable remote observer/lifecycle component. - Observe `.NSPersistentStoreRemoteChange` to detect remote merges. - On remote change, refetch from SwiftData and invalidate derived caches. - For long-lived stores, recreate `ModelContext` on remote change before refetch when stale objects are observed. diff --git a/SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md b/SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md index 99bea3f..b33e6b7 100644 --- a/SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md +++ b/SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md @@ -1,5 +1,10 @@ # SwiftData to CloudKit Sync Template (Copy/Paste) +Primary source of truth lives in Bedrock: +`/Users/mattbruce/Documents/Projects/iPhone/Andromida/Bedrock/Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md` + +This file is an app-local quick reference and should mirror the Bedrock setup guide. + Use this as a starter for new apps that need near-real-time SwiftData sync across Apple devices. ## 1) Build Config (xcconfig) @@ -53,80 +58,62 @@ func makeModelContainer(isRunningTests: Bool) throws -> ModelContainer { } ``` -## 3) Store Pattern (Remote Change + Context Refresh) +## 3) Store Pattern (Bedrock Manager + Context Refresh) ```swift import Foundation import Observation import SwiftData -import CoreData -import os +import Bedrock @MainActor @Observable -final class RitualStore { +final class AppDataStore { @ObservationIgnored private let modelContainer: ModelContainer @ObservationIgnored private var modelContext: ModelContext - @ObservationIgnored private var remoteChangeObserver: NSObjectProtocol? - @ObservationIgnored private let syncLogger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "App", - category: "CloudKitSync" + @ObservationIgnored private let cloudKitSyncManager = SwiftDataCloudKitSyncManager( + isEnabled: true, + logIdentifier: "AppCloudKitSync" ) - private(set) var rituals: [Ritual] = [] + private(set) var entities: [PrimaryEntity] = [] private(set) var dataRefreshVersion: Int = 0 private(set) var lastRemoteChangeDate: Date? - private(set) var remoteChangeEventCount: Int = 0 init(modelContext: ModelContext) { self.modelContainer = modelContext.container self.modelContext = modelContext - reloadRituals() + reloadEntities() observeRemoteChanges() } - deinit { - if let observer = remoteChangeObserver { - NotificationCenter.default.removeObserver(observer) - } - } - private func observeRemoteChanges() { - syncLogger.info("Starting CloudKit remote change observation") - remoteChangeObserver = NotificationCenter.default.addObserver( - forName: .NSPersistentStoreRemoteChange, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.handleRemoteStoreChange() - } + cloudKitSyncManager.startObserving { [weak self] in + self?.handleRemoteStoreChange() } } private func handleRemoteStoreChange() { - remoteChangeEventCount += 1 - lastRemoteChangeDate = Date() + lastRemoteChangeDate = cloudKitSyncManager.lastRemoteChangeDate + let eventCount = cloudKitSyncManager.remoteChangeEventCount // Important when long-lived contexts become stale after remote merges. modelContext = ModelContext(modelContainer) - syncLogger.info( - "Received remote store change #\(self.remoteChangeEventCount, privacy: .public); reloading" - ) - reloadRituals() + Design.debugLog("Received remote store change #\(eventCount); reloading") + reloadEntities() } func refresh() { - reloadRituals() + reloadEntities() } - private func reloadRituals() { + private func reloadEntities() { do { - rituals = try modelContext.fetch(FetchDescriptor()) + entities = try modelContext.fetch(FetchDescriptor()) dataRefreshVersion &+= 1 } catch { - syncLogger.error("Reload failed: \(error.localizedDescription, privacy: .public)") + Design.debugLog("AppDataStore: reload failed: \(error.localizedDescription)") } } } @@ -138,7 +125,7 @@ final class RitualStore { import SwiftUI struct RootView: View { - @Bindable var store: RitualStore + @Bindable var store: AppDataStore @Environment(\.scenePhase) private var scenePhase @State private var fallbackRefreshTask: Task?