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 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<Void, Never>?
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()
}

View File

@ -10,6 +10,8 @@ struct RootView: View {
@State private var selectedTab: RootTab
@State private var analyticsPrewarmTask: 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 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 {

View File

@ -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.

View File

@ -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<Ritual>())
entities = try modelContext.fetch(FetchDescriptor<PrimaryEntity>())
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<Void, Never>?