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

This commit is contained in:
Matt Bruce 2026-02-16 16:20:12 -06:00
parent 881ac5ad4c
commit e9de34355b
6 changed files with 296 additions and 56 deletions

View File

@ -1,18 +1,17 @@
import Foundation import Foundation
import Observation import Observation
import SwiftData import SwiftData
import CoreData
import Bedrock import Bedrock
import WidgetKit import WidgetKit
@MainActor @MainActor
@Observable @Observable
final class RitualStore: RitualStoreProviding { final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore {
private static let dataIntegrityMigrationVersion = 1 private static let dataIntegrityMigrationVersion = 1
private static let dataIntegrityMigrationVersionKey = "ritualDataIntegrityMigrationVersion" private static let dataIntegrityMigrationVersionKey = "ritualDataIntegrityMigrationVersion"
@ObservationIgnored private let modelContainer: ModelContainer @ObservationIgnored let modelContainer: ModelContainer
@ObservationIgnored private var modelContext: ModelContext @ObservationIgnored var modelContext: ModelContext
@ObservationIgnored private let seedService: RitualSeedProviding @ObservationIgnored private let seedService: RitualSeedProviding
@ObservationIgnored private let settingsStore: any RitualFeedbackSettingsProviding @ObservationIgnored private let settingsStore: any RitualFeedbackSettingsProviding
@ObservationIgnored private let calendar: Calendar @ObservationIgnored private let calendar: Calendar
@ -20,14 +19,15 @@ 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 var remoteChangeObserver: NSObjectProtocol? @ObservationIgnored let cloudKitSyncManager = SwiftDataCloudKitSyncManager(
@ObservationIgnored private var remoteChangeEventCount: Int = 0 isEnabled: true,
logIdentifier: "RitualStore.CloudKitSync"
)
private(set) var rituals: [Ritual] = [] private(set) var rituals: [Ritual] = []
private(set) var currentRituals: [Ritual] = [] private(set) var currentRituals: [Ritual] = []
private(set) var pastRituals: [Ritual] = [] private(set) var pastRituals: [Ritual] = []
private(set) var lastErrorMessage: String? private(set) var lastErrorMessage: String?
private(set) var lastRemoteChangeDate: Date?
private(set) var dataRefreshVersion: Int = 0 private(set) var dataRefreshVersion: Int = 0
private var analyticsNeedsRefresh = true private var analyticsNeedsRefresh = true
private var cachedDatesWithActivity: Set<Date> = [] private var cachedDatesWithActivity: Set<Date> = []
@ -52,7 +52,7 @@ final class RitualStore: RitualStoreProviding {
updateCurrentTimeOfDay() updateCurrentTimeOfDay()
analyticsNeedsRefresh = true analyticsNeedsRefresh = true
insightCardsNeedRefresh = true insightCardsNeedRefresh = true
reloadRituals() reloadData()
} }
} }
@ -93,34 +93,23 @@ final class RitualStore: RitualStoreProviding {
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() {
remoteChangeObserver = NotificationCenter.default.addObserver( cloudKitSyncManager.startObserving { [weak self] in
forName: .NSPersistentStoreRemoteChange,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.handleRemoteStoreChange() self?.handleRemoteStoreChange()
} }
} }
}
private func handleRemoteStoreChange() { private func handleRemoteStoreChange() {
remoteChangeEventCount += 1 let result = cloudKitSyncManager.processObservedRemoteChange(
let eventCount = remoteChangeEventCount modelContext: &modelContext,
lastRemoteChangeDate = now() modelContainer: modelContainer
// SwiftData may keep stale registered objects in long-lived contexts. )
// Recreate the context on remote store changes so fetches observe latest merged values. Design.debugLog(
modelContext = ModelContext(modelContainer) "RitualStore.CloudKitSync: received remote store change #\(result.eventCount); " +
Design.debugLog("Received remote store change #\(eventCount); reloading rituals") "rebuiltContext=\(result.didRebuildModelContext); reloading rituals"
reloadRituals() )
reloadData()
// Also refresh widgets when data arrives from other devices // Also refresh widgets when data arrives from other devices
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
} }
@ -144,7 +133,7 @@ final class RitualStore: RitualStoreProviding {
/// Refreshes rituals and derived state for current date/time. /// Refreshes rituals and derived state for current date/time.
func refresh() { func refresh() {
updateCurrentTimeOfDay() updateCurrentTimeOfDay()
reloadRituals() reloadData()
checkForCompletedArcs() checkForCompletedArcs()
} }
@ -895,7 +884,7 @@ final class RitualStore: RitualStoreProviding {
} }
private func loadRitualsIfNeeded() { private func loadRitualsIfNeeded() {
reloadRituals() reloadData()
// No longer auto-seed rituals on fresh install // No longer auto-seed rituals on fresh install
// Users start with empty state and create their own rituals // Users start with empty state and create their own rituals
} }
@ -916,7 +905,7 @@ final class RitualStore: RitualStoreProviding {
} }
UserDefaults.standard.set(Self.dataIntegrityMigrationVersion, forKey: Self.dataIntegrityMigrationVersionKey) UserDefaults.standard.set(Self.dataIntegrityMigrationVersion, forKey: Self.dataIntegrityMigrationVersionKey)
reloadRituals() reloadData()
} catch { } catch {
lastErrorMessage = error.localizedDescription lastErrorMessage = error.localizedDescription
} }
@ -1002,7 +991,7 @@ final class RitualStore: RitualStoreProviding {
return didChange return didChange
} }
private func reloadRituals() { func reloadData() {
do { do {
rituals = try modelContext.fetch(FetchDescriptor<Ritual>()) rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
updateDerivedData() updateDerivedData()
@ -1017,7 +1006,7 @@ final class RitualStore: RitualStoreProviding {
private func saveContext() { private func saveContext() {
do { do {
try modelContext.save() try modelContext.save()
reloadRituals() reloadData()
// Widget timeline reloads can destabilize test hosts; skip in tests. // Widget timeline reloads can destabilize test hosts; skip in tests.
if !isRunningTests { if !isRunningTests {
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
@ -1590,5 +1579,6 @@ final class RitualStore: RitualStoreProviding {
Design.debugLog("Arc '\(ritual.title)' marked as completed. Navigate to Today tab to see renewal prompt.") Design.debugLog("Arc '\(ritual.title)' marked as completed. Navigate to Today tab to see renewal prompt.")
} }
#endif #endif
} }

View File

@ -11,11 +11,13 @@ struct RootView: View {
@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 aggressiveCloudKitRefreshTasks: [Task<Void, Never>] = []
@State private var macCloudKitPulseTask: 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
private let debugForegroundRefreshMinimumSeconds: TimeInterval = 0.8 private let debugForegroundRefreshMinimumSeconds: TimeInterval = 0.8
private let debugForegroundRefreshKey = "debugForegroundRefreshNextForeground" private let debugForegroundRefreshKey = "debugForegroundRefreshNextForeground"
private let macCloudKitPulseIntervalSeconds: TimeInterval = 5
/// The available tabs in the app. /// The available tabs in the app.
enum RootTab: Hashable { enum RootTab: Hashable {
@ -109,6 +111,11 @@ struct RootView: View {
transaction.animation = nil transaction.animation = nil
} }
} }
.onAppear {
if scenePhase == .active {
startMacCloudKitPulseLoopIfNeeded()
}
}
.onChange(of: scenePhase) { _, newPhase in .onChange(of: scenePhase) { _, newPhase in
if newPhase == .active { if newPhase == .active {
store.reminderScheduler.clearBadge() store.reminderScheduler.clearBadge()
@ -136,12 +143,14 @@ struct RootView: View {
) )
scheduleAggressiveCloudKitRefreshes() scheduleAggressiveCloudKitRefreshes()
scheduleCloudKitFallbackRefresh() scheduleCloudKitFallbackRefresh()
startMacCloudKitPulseLoopIfNeeded()
} 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.forEach { $0.cancel() }
aggressiveCloudKitRefreshTasks.removeAll() aggressiveCloudKitRefreshTasks.removeAll()
stopMacCloudKitPulseLoop()
} }
} }
.onChange(of: selectedTab) { _, _ in .onChange(of: selectedTab) { _, _ in
@ -159,6 +168,7 @@ struct RootView: View {
.onDisappear { .onDisappear {
aggressiveCloudKitRefreshTasks.forEach { $0.cancel() } aggressiveCloudKitRefreshTasks.forEach { $0.cancel() }
aggressiveCloudKitRefreshTasks.removeAll() aggressiveCloudKitRefreshTasks.removeAll()
stopMacCloudKitPulseLoop()
} }
} }
@ -219,8 +229,7 @@ struct RootView: View {
cloudKitFallbackRefreshTask = Task { @MainActor in cloudKitFallbackRefreshTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(8)) try? await Task.sleep(for: .seconds(8))
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
let receivedRemoteChangeSinceActivation = store.lastRemoteChangeDate.map { $0 >= activationDate } ?? false guard !store.hasReceivedRemoteChange(since: activationDate) else { return }
guard !receivedRemoteChangeSinceActivation else { return }
store.refresh() store.refresh()
} }
} }
@ -242,6 +251,25 @@ struct RootView: View {
} }
} }
private func startMacCloudKitPulseLoopIfNeeded() {
guard store.supportsMacCloudKitImportPulseFallback else { return }
macCloudKitPulseTask?.cancel()
macCloudKitPulseTask = Task { @MainActor in
Design.debugLog("RootView.CloudKitSync: starting macOS pulse loop interval=\(macCloudKitPulseIntervalSeconds)s")
while !Task.isCancelled {
guard scenePhase == .active else { return }
store.forceCloudKitImportPulse(reason: "active_loop")
try? await Task.sleep(for: .seconds(macCloudKitPulseIntervalSeconds))
}
}
}
private func stopMacCloudKitPulseLoop() {
macCloudKitPulseTask?.cancel()
macCloudKitPulseTask = nil
}
} }
#Preview { #Preview {

View File

@ -0,0 +1,176 @@
# Bedrock Deferred Refactor Plan
This plan captures the **remaining** reusable extraction work that was intentionally deferred during the safe-core sync refactor.
## Current Baseline (Already Done)
- Reusable sync lifecycle utility in Bedrock:
- `Bedrock/Sources/Bedrock/Storage/SwiftDataCloudKitSyncManager.swift`
- Reusable refresh throttling utility in Bedrock:
- `Bedrock/Sources/Bedrock/Storage/SwiftDataRefreshThrottler.swift`
- Bedrock-first setup guide:
- `Bedrock/Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md`
## Deferred Items (This Plan)
### Phase 1 - Derived Data Coordinator (Medium Risk)
**Goal:** Extract generic derived-state recomputation orchestration from app stores.
**Candidate extraction:**
- Generic coordinator to run ordered recompute steps after reload:
- fetch/load -> derive grouped/sorted slices -> invalidate downstream caches -> optional side effects
**Bedrock target:**
- `Bedrock/Sources/Bedrock/Storage/SwiftDataDerivedStateCoordinator.swift`
**App integration target:**
- `Andromida/Andromida/App/State/RitualStore.swift`
**Guardrail:**
- Keep ritual-specific filtering/sorting closures in app, only orchestration in Bedrock.
---
### Phase 2 - Analytics Cache Framework (Medium Risk)
**Goal:** Reuse cache invalidation and lazy recomputation structure without moving domain metrics.
**Candidate extraction:**
- Generic keyed cache invalidation container
- "dirty flag + recompute-on-demand" helper patterns
**Bedrock target:**
- `Bedrock/Sources/Bedrock/Storage/SwiftDataAnalyticsCacheController.swift`
**App integration target:**
- `Andromida/Andromida/App/State/RitualStore.swift`
**Guardrail:**
- Keep metric formulas and ritual semantics in app.
---
### Phase 3 - Refresh Orchestration Utility (Medium Risk)
**Goal:** Centralize recurring refresh sequences used across tabs/lifecycle transitions.
**Candidate extraction:**
- Reusable refresh runner with:
- optional overlay timing
- delayed aggressive refresh scheduling
- cancellation management
**Bedrock target:**
- `Bedrock/Sources/Bedrock/Storage/SwiftDataRefreshCoordinator.swift`
**App integration target:**
- `Andromida/Andromida/App/Views/RootView.swift`
**Guardrail:**
- Keep app tab selection and app-specific scheduler calls local.
---
### Phase 4 - Migration Scaffolding (Higher Risk)
**Goal:** Reuse migration structure, not migration rules.
**Candidate extraction:**
- Versioned migration runner skeleton
- Shared logging and one-time version persistence wrapper
**Bedrock target:**
- `Bedrock/Sources/Bedrock/Storage/SwiftDataMigrationRunner.swift`
**App integration target:**
- `Andromida/Andromida/App/State/RitualStore.swift`
**Guardrail:**
- Keep ritual data integrity rule implementations in app.
---
### Phase 5 - Documentation and Templates (Low Risk)
**Goal:** Expand Bedrock docs to cover phases 1-4 with copy/paste adapter examples.
**Bedrock docs target:**
- `Bedrock/Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md`
- `Bedrock/README.md`
**Andromida docs target:**
- Keep short pointers only; Bedrock remains source of truth.
## Acceptance Criteria Per Phase
- No app-domain naming (`Ritual`, etc.) in new Bedrock symbols/docs.
- `xcodebuild -scheme Andromida` passes after each phase.
- Existing iPhone/iPad sync behavior unchanged.
- No additional runtime-only workaround logic introduced without explicit opt-in.
## Rollback Strategy
- Commit each phase separately in Bedrock and Andromida repos.
- If behavior drifts, revert only latest phase commit(s), keep previous phases.
- Avoid multi-phase combined commits.
## Recommended Execution Order
1. Phase 1 (Derived state coordinator)
2. Phase 3 (Refresh orchestration utility)
3. Phase 2 (Analytics cache framework)
4. Phase 4 (Migration scaffolding)
5. Phase 5 (Docs/templates refresh)
## Notes
- This plan is intentionally incremental and conservative.
- Safe-core extraction stays as baseline; deferred phases should not block current product work.
---
## Known Issue Backlog
### KIB-001 - Mac runtime inbound CloudKit updates stall while app stays active
**Problem statement**
- Mac runtime does not reliably reflect incoming CloudKit changes from iPhone/iPad while app remains active.
- Updates appear after app lifecycle transition (foreground/background or relaunch).
**Observed behavior**
- Mac -> iPhone/iPad sync propagates quickly (about 1-2 seconds).
- iPhone/iPad -> iPhone/iPad sync propagates quickly.
- iPhone/iPad -> Mac often does not appear until activation transition.
- Remote change debug logs are absent on Mac during active-state misses.
**Repro steps**
1. Keep Mac app active on Today or Rituals screen.
2. On iPhone or iPad, toggle one or more habit completions.
3. Observe Mac app does not update in real time.
4. Trigger app activation transition on Mac (switch away/back or relaunch).
5. Observe pending updates appear.
**Impact**
- Cross-device real-time expectation is broken for Mac runtime inbound updates.
- User trust risk for multi-device usage.
**Current status**
- Not a regression from Bedrock safe-core extraction; behavior also reproduced on original direct observer path.
- iPhone/iPad path remains healthy.
**Hypothesis**
- Runtime/platform-level import delivery behavior for SwiftData+CloudKit on Mac runtime while active (push/import callbacks not firing consistently).
**Deferred investigation tasks**
- Capture high-signal telemetry during active-state misses:
- last remote change callback time
- last lifecycle-triggered refresh time
- local store update timestamps
- Compare iOS-on-Mac runtime vs native iOS device callback cadence.
- Evaluate dedicated Mac sync architecture options:
- explicit sync UX fallback
- alternative persistence/sync stack for Mac runtime if real-time inbound is mandatory.
**Acceptance criteria for closure**
- While Mac app remains active, inbound updates from iPhone/iPad appear without requiring foreground/background transitions in repeated test runs.

11
PRD.md
View File

@ -628,3 +628,14 @@ Andromida/
| 1.1 | February 2026 | Fixed time-of-day refresh bug in Today view and Widget; added debug time simulation | | 1.1 | February 2026 | Fixed time-of-day refresh bug in Today view and Widget; added debug time simulation |
| 1.2 | February 2026 | Added deterministic UI-test launch harness and expanded critical UI flow coverage | | 1.2 | February 2026 | Added deterministic UI-test launch harness and expanded critical UI flow coverage |
| 1.3 | February 2026 | Added reusable SwiftData to CloudKit sync requirements, runtime expectations, and two-device verification standard | | 1.3 | February 2026 | Added reusable SwiftData to CloudKit sync requirements, runtime expectations, and two-device verification standard |
---
## Known Limitations
### KIB-001 - Mac Runtime Inbound CloudKit Updates May Require Activation Transition
- **Summary**: On Mac runtime, inbound SwiftData+CloudKit updates from iPhone/iPad may not appear while app remains active.
- **Observed**: iPhone/iPad to iPhone/iPad sync is near-real-time; Mac to iPhone/iPad sync is near-real-time; iPhone/iPad to Mac may require foreground/background transition or relaunch.
- **Status**: Known platform/runtime behavior under investigation; not introduced by recent Bedrock safe-core extraction.
- **Tracking**: Detailed investigation notes are maintained in `BEDROCK_DEFERRED_REFACTOR_PLAN.md` under `Known Issue Backlog (KIB-001)`.

View File

@ -40,10 +40,13 @@ 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. - Prefer Bedrock `SwiftDataCloudKitSyncManager` as the reusable remote observer/lifecycle component.
- Prefer Bedrock `SwiftDataStore` + `SwiftDataCloudKitStore` to avoid app-specific pulse boilerplate.
- 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, call Bedrock `processObservedRemoteChange(modelContext:modelContainer:)` before refetch.
- For long-lived stores, recreate `ModelContext` on remote change before refetch when stale objects are observed. - Rebuild long-lived contexts only when safe (`hasChanges == false`) to avoid dropping unsaved local edits.
- Keep a foreground fallback refresh as a safety net, but do not rely on force-quit/relaunch behavior. - Implement protocol reload hook (`reloadData`) to run your store-specific fetch step.
- Keep iOS-on-Mac pulsing loop in the root scene lifecycle (`active` only, cancel on `background`).
- Keep a foreground fallback refresh as a safety net; gate it with Bedrock `hasReceivedRemoteChange(since:)`.
- Emit structured logs for remote sync events (event count + timestamp) for debugging. - Emit structured logs for remote sync events (event count + timestamp) for debugging.
## 5) UI Freshness Requirements ## 5) UI Freshness Requirements
@ -68,6 +71,7 @@ Test all cases on two physical devices with the same Apple ID and same app flavo
- Device logs: filter by sync logger category (for example `CloudKitSync`). - Device logs: filter by sync logger category (for example `CloudKitSync`).
- CloudKit Console: validate record updates in the app container private database. - CloudKit Console: validate record updates in the app container private database.
- If pushes are delivered but UI is stale, investigate context freshness and view invalidation, not transport. - If pushes are delivered but UI is stale, investigate context freshness and view invalidation, not transport.
- If APNs is unreliable on Mac runtime, validate that pulse logs appear every interval while active.
## 8) Reuse Checklist for New Apps ## 8) Reuse Checklist for New Apps
@ -76,7 +80,9 @@ Before shipping any new SwiftData+CloudKit app:
- [ ] Capabilities: iCloud/CloudKit + Push + Remote Notifications are enabled - [ ] Capabilities: iCloud/CloudKit + Push + Remote Notifications are enabled
- [ ] Entitlements include `aps-environment` and correct container IDs - [ ] Entitlements include `aps-environment` and correct container IDs
- [ ] xcconfig defines `APS_ENVIRONMENT` per configuration - [ ] xcconfig defines `APS_ENVIRONMENT` per configuration
- [ ] Remote change observer reloads data and invalidates caches - [ ] Remote change observer uses Bedrock `processObservedRemoteChange(...)` + reloads data
- [ ] Store conforms to Bedrock `SwiftDataCloudKitStore`
- [ ] Foreground fallback is gated by Bedrock `hasReceivedRemoteChange(since:)`
- [ ] UI has deterministic invalidation on remote reload - [ ] UI has deterministic invalidation on remote reload
- [ ] Two-device batch-update test passes without manual refresh - [ ] Two-device batch-update test passes without manual refresh
- [ ] CloudKit Console verification documented in README/PRD - [ ] CloudKit Console verification documented in README/PRD

View File

@ -68,17 +68,16 @@ import Bedrock
@MainActor @MainActor
@Observable @Observable
final class AppDataStore { final class AppDataStore: SwiftDataCloudKitStore {
@ObservationIgnored private let modelContainer: ModelContainer @ObservationIgnored let modelContainer: ModelContainer
@ObservationIgnored private var modelContext: ModelContext @ObservationIgnored var modelContext: ModelContext
@ObservationIgnored private let cloudKitSyncManager = SwiftDataCloudKitSyncManager( @ObservationIgnored let cloudKitSyncManager = SwiftDataCloudKitSyncManager(
isEnabled: true, isEnabled: true,
logIdentifier: "AppCloudKitSync" logIdentifier: "AppCloudKitSync"
) )
private(set) var entities: [PrimaryEntity] = [] private(set) var entities: [PrimaryEntity] = []
private(set) var dataRefreshVersion: Int = 0 private(set) var dataRefreshVersion: Int = 0
private(set) var lastRemoteChangeDate: Date?
init(modelContext: ModelContext) { init(modelContext: ModelContext) {
self.modelContainer = modelContext.container self.modelContainer = modelContext.container
@ -94,17 +93,22 @@ final class AppDataStore {
} }
private func handleRemoteStoreChange() { private func handleRemoteStoreChange() {
lastRemoteChangeDate = cloudKitSyncManager.lastRemoteChangeDate let result = cloudKitSyncManager.processObservedRemoteChange(
let eventCount = cloudKitSyncManager.remoteChangeEventCount modelContext: &modelContext,
modelContainer: modelContainer
// Important when long-lived contexts become stale after remote merges. )
modelContext = ModelContext(modelContainer) Design.debugLog(
"Received remote store change #\(result.eventCount); " +
Design.debugLog("Received remote store change #\(eventCount); reloading") "rebuiltContext=\(result.didRebuildModelContext); reloading"
)
reloadEntities() reloadEntities()
} }
func refresh() { func refresh() {
reloadData()
}
func reloadData() {
reloadEntities() reloadEntities()
} }
@ -128,17 +132,26 @@ struct RootView: View {
@Bindable var store: AppDataStore @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>?
@State private var macPulseTask: Task<Void, Never>?
private let macPulseIntervalSeconds: TimeInterval = 5
var body: some View { var body: some View {
// Ensure body observes remote reload increments. // Ensure body observes remote reload increments.
let _ = store.dataRefreshVersion let _ = store.dataRefreshVersion
ContentView(store: store) ContentView(store: store)
.onAppear {
if scenePhase == .active {
startMacPulseLoopIfNeeded()
}
}
.onChange(of: scenePhase) { _, newPhase in .onChange(of: scenePhase) { _, newPhase in
if newPhase == .active { if newPhase == .active {
startMacPulseLoopIfNeeded()
scheduleCloudKitFallbackRefresh() scheduleCloudKitFallbackRefresh()
} else if newPhase == .background { } else if newPhase == .background {
fallbackRefreshTask?.cancel() fallbackRefreshTask?.cancel()
stopMacPulseLoop()
} }
} }
} }
@ -149,11 +162,27 @@ struct RootView: View {
fallbackRefreshTask = Task { @MainActor in fallbackRefreshTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(8)) try? await Task.sleep(for: .seconds(8))
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
let gotRemoteSinceActive = store.lastRemoteChangeDate.map { $0 >= activationDate } ?? false guard !store.hasReceivedRemoteChange(since: activationDate) else { return }
guard !gotRemoteSinceActive else { return }
store.refresh() store.refresh()
} }
} }
private func startMacPulseLoopIfNeeded() {
guard store.supportsMacCloudKitImportPulseFallback else { return }
macPulseTask?.cancel()
macPulseTask = Task { @MainActor in
while !Task.isCancelled {
guard scenePhase == .active else { return }
store.forceCloudKitImportPulse(reason: "active_loop")
try? await Task.sleep(for: .seconds(macPulseIntervalSeconds))
}
}
}
private func stopMacPulseLoop() {
macPulseTask?.cancel()
macPulseTask = nil
}
} }
``` ```