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 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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>?
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user