Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-16 14:39:09 -06:00
parent e0392f2c22
commit 79ec9a57f3
4 changed files with 110 additions and 50 deletions

View File

@ -20,7 +20,8 @@ final class RitualStore: RitualStoreProviding {
@ObservationIgnored private let isRunningTests: Bool @ObservationIgnored private let isRunningTests: Bool
@ObservationIgnored private let dayFormatter: DateFormatter @ObservationIgnored private let dayFormatter: DateFormatter
@ObservationIgnored private let displayFormatter: 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 rituals: [Ritual] = []
private(set) var currentRituals: [Ritual] = [] private(set) var currentRituals: [Ritual] = []
@ -34,7 +35,7 @@ final class RitualStore: RitualStoreProviding {
private var pendingReminderTask: Task<Void, Never>? private var pendingReminderTask: Task<Void, Never>?
private var insightCardsNeedRefresh = true private var insightCardsNeedRefresh = true
private var cachedInsightCards: [InsightCard] = [] private var cachedInsightCards: [InsightCard] = []
private var lastRefreshDate: Date? @ObservationIgnored private let refreshThrottler = SwiftDataRefreshThrottler()
/// Reminder scheduler for time-slot based notifications /// Reminder scheduler for time-slot based notifications
let reminderScheduler = ReminderScheduler() let reminderScheduler = ReminderScheduler()
@ -75,10 +76,6 @@ final class RitualStore: RitualStoreProviding {
self.isRunningTests = isRunningTests self.isRunningTests = isRunningTests
self.dayFormatter = DateFormatter() self.dayFormatter = DateFormatter()
self.displayFormatter = DateFormatter() self.displayFormatter = DateFormatter()
self.cloudKitSyncManager = SwiftDataCloudKitSyncManager(
isEnabled: !isRunningTests,
logIdentifier: "AndromidaCloudKitSync"
)
dayFormatter.calendar = calendar dayFormatter.calendar = calendar
dayFormatter.dateFormat = "yyyy-MM-dd" dayFormatter.dateFormat = "yyyy-MM-dd"
displayFormatter.calendar = calendar displayFormatter.calendar = calendar
@ -95,17 +92,30 @@ final class RitualStore: RitualStoreProviding {
private func now() -> Date { private func now() -> Date {
nowProvider() nowProvider()
} }
deinit {
if let observer = remoteChangeObserver {
NotificationCenter.default.removeObserver(observer)
}
}
/// Observes CloudKit remote change notifications to auto-refresh UI when iCloud data syncs. /// Observes CloudKit remote change notifications to auto-refresh UI when iCloud data syncs.
private func observeRemoteChanges() { private func observeRemoteChanges() {
cloudKitSyncManager.startObserving { [weak self] in remoteChangeObserver = NotificationCenter.default.addObserver(
self?.handleRemoteStoreChange() forName: .NSPersistentStoreRemoteChange,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.handleRemoteStoreChange()
}
} }
} }
private func handleRemoteStoreChange() { private func handleRemoteStoreChange() {
let eventCount = cloudKitSyncManager.remoteChangeEventCount remoteChangeEventCount += 1
lastRemoteChangeDate = cloudKitSyncManager.lastRemoteChangeDate let eventCount = remoteChangeEventCount
lastRemoteChangeDate = now()
// SwiftData may keep stale registered objects in long-lived contexts. // SwiftData may keep stale registered objects in long-lived contexts.
// Recreate the context on remote store changes so fetches observe latest merged values. // Recreate the context on remote store changes so fetches observe latest merged values.
modelContext = ModelContext(modelContainer) modelContext = ModelContext(modelContainer)
@ -157,10 +167,16 @@ final class RitualStore: RitualStoreProviding {
/// Refreshes rituals if the last refresh was beyond the minimum interval. /// Refreshes rituals if the last refresh was beyond the minimum interval.
func refreshIfNeeded(minimumInterval: TimeInterval = 5) { func refreshIfNeeded(minimumInterval: TimeInterval = 5) {
let currentDate = now() let currentDate = now()
if let lastRefreshDate, currentDate.timeIntervalSince(lastRefreshDate) < minimumInterval { guard refreshThrottler.shouldRefresh(now: currentDate, minimumInterval: minimumInterval) else {
return 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() refresh()
} }

View File

@ -10,6 +10,8 @@ struct RootView: View {
@State private var selectedTab: RootTab @State private var selectedTab: RootTab
@State private var analyticsPrewarmTask: Task<Void, Never>? @State private var analyticsPrewarmTask: Task<Void, Never>?
@State private var cloudKitFallbackRefreshTask: Task<Void, Never>? @State private var cloudKitFallbackRefreshTask: Task<Void, Never>?
@State private var aggressiveCloudKitRefreshTasks: [Task<Void, Never>] = []
@State private var macSyncSafetyNetTask: Task<Void, Never>?
@State private var isForegroundRefreshing = false @State private var isForegroundRefreshing = false
@State private var isResumingFromBackground = false @State private var isResumingFromBackground = false
private let foregroundRefreshMinimumSeconds: TimeInterval = 0.15 private let foregroundRefreshMinimumSeconds: TimeInterval = 0.15
@ -133,11 +135,16 @@ struct RootView: View {
showOverlay: useDebugOverlay, showOverlay: useDebugOverlay,
minimumSeconds: useDebugOverlay ? debugForegroundRefreshMinimumSeconds : foregroundRefreshMinimumSeconds minimumSeconds: useDebugOverlay ? debugForegroundRefreshMinimumSeconds : foregroundRefreshMinimumSeconds
) )
scheduleAggressiveCloudKitRefreshes()
scheduleCloudKitFallbackRefresh() scheduleCloudKitFallbackRefresh()
startMacSyncSafetyNetIfNeeded()
} else if newPhase == .background { } else if newPhase == .background {
// Prepare for next resume // Prepare for next resume
isResumingFromBackground = true isResumingFromBackground = true
cloudKitFallbackRefreshTask?.cancel() cloudKitFallbackRefreshTask?.cancel()
aggressiveCloudKitRefreshTasks.forEach { $0.cancel() }
aggressiveCloudKitRefreshTasks.removeAll()
macSyncSafetyNetTask?.cancel()
} }
} }
.onChange(of: selectedTab) { _, _ in .onChange(of: selectedTab) { _, _ in
@ -152,6 +159,15 @@ struct RootView: View {
.onOpenURL { url in .onOpenURL { url in
handleURL(url) handleURL(url)
} }
.onAppear {
startMacSyncSafetyNetIfNeeded()
}
.onDisappear {
macSyncSafetyNetTask?.cancel()
macSyncSafetyNetTask = nil
aggressiveCloudKitRefreshTasks.forEach { $0.cancel() }
aggressiveCloudKitRefreshTasks.removeAll()
}
} }
private func handleURL(_ url: URL) { private func handleURL(_ url: URL) {
@ -216,6 +232,41 @@ struct RootView: View {
store.refresh() 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 { #Preview {

View File

@ -1,5 +1,10 @@
# SwiftData to CloudKit Sync Requirements (Reusable) # 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. Use this checklist for any iOS app that uses SwiftData with CloudKit and requires near-real-time multi-device sync.
## 1) Capabilities and Entitlements ## 1) Capabilities and Entitlements
@ -34,6 +39,7 @@ Entitlements should reference variables, not hard-coded values, where possible.
## 4) Runtime Sync Behavior ## 4) Runtime Sync Behavior
- Prefer Bedrock `SwiftDataCloudKitSyncManager` as the reusable remote observer/lifecycle component.
- Observe `.NSPersistentStoreRemoteChange` to detect remote merges. - Observe `.NSPersistentStoreRemoteChange` to detect remote merges.
- On remote change, refetch from SwiftData and invalidate derived caches. - 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. - For long-lived stores, recreate `ModelContext` on remote change before refetch when stale objects are observed.

View File

@ -1,5 +1,10 @@
# SwiftData to CloudKit Sync Template (Copy/Paste) # 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. Use this as a starter for new apps that need near-real-time SwiftData sync across Apple devices.
## 1) Build Config (xcconfig) ## 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 ```swift
import Foundation import Foundation
import Observation import Observation
import SwiftData import SwiftData
import CoreData import Bedrock
import os
@MainActor @MainActor
@Observable @Observable
final class RitualStore { final class AppDataStore {
@ObservationIgnored private let modelContainer: ModelContainer @ObservationIgnored private let modelContainer: ModelContainer
@ObservationIgnored private var modelContext: ModelContext @ObservationIgnored private var modelContext: ModelContext
@ObservationIgnored private var remoteChangeObserver: NSObjectProtocol? @ObservationIgnored private let cloudKitSyncManager = SwiftDataCloudKitSyncManager(
@ObservationIgnored private let syncLogger = Logger( isEnabled: true,
subsystem: Bundle.main.bundleIdentifier ?? "App", logIdentifier: "AppCloudKitSync"
category: "CloudKitSync"
) )
private(set) var rituals: [Ritual] = [] private(set) var entities: [PrimaryEntity] = []
private(set) var dataRefreshVersion: Int = 0 private(set) var dataRefreshVersion: Int = 0
private(set) var lastRemoteChangeDate: Date? private(set) var lastRemoteChangeDate: Date?
private(set) var remoteChangeEventCount: Int = 0
init(modelContext: ModelContext) { init(modelContext: ModelContext) {
self.modelContainer = modelContext.container self.modelContainer = modelContext.container
self.modelContext = modelContext self.modelContext = modelContext
reloadRituals() reloadEntities()
observeRemoteChanges() observeRemoteChanges()
} }
deinit {
if let observer = remoteChangeObserver {
NotificationCenter.default.removeObserver(observer)
}
}
private func observeRemoteChanges() { private func observeRemoteChanges() {
syncLogger.info("Starting CloudKit remote change observation") cloudKitSyncManager.startObserving { [weak self] in
remoteChangeObserver = NotificationCenter.default.addObserver( self?.handleRemoteStoreChange()
forName: .NSPersistentStoreRemoteChange,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.handleRemoteStoreChange()
}
} }
} }
private func handleRemoteStoreChange() { private func handleRemoteStoreChange() {
remoteChangeEventCount += 1 lastRemoteChangeDate = cloudKitSyncManager.lastRemoteChangeDate
lastRemoteChangeDate = Date() let eventCount = cloudKitSyncManager.remoteChangeEventCount
// Important when long-lived contexts become stale after remote merges. // Important when long-lived contexts become stale after remote merges.
modelContext = ModelContext(modelContainer) modelContext = ModelContext(modelContainer)
syncLogger.info( Design.debugLog("Received remote store change #\(eventCount); reloading")
"Received remote store change #\(self.remoteChangeEventCount, privacy: .public); reloading" reloadEntities()
)
reloadRituals()
} }
func refresh() { func refresh() {
reloadRituals() reloadEntities()
} }
private func reloadRituals() { private func reloadEntities() {
do { do {
rituals = try modelContext.fetch(FetchDescriptor<Ritual>()) entities = try modelContext.fetch(FetchDescriptor<PrimaryEntity>())
dataRefreshVersion &+= 1 dataRefreshVersion &+= 1
} catch { } 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 import SwiftUI
struct RootView: View { struct RootView: View {
@Bindable var store: RitualStore @Bindable var store: AppDataStore
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@State private var fallbackRefreshTask: Task<Void, Never>? @State private var fallbackRefreshTask: Task<Void, Never>?