Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
e0392f2c22
commit
79ec9a57f3
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>?
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user