Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
881ac5ad4c
commit
e9de34355b
@ -1,18 +1,17 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftData
|
||||
import CoreData
|
||||
import Bedrock
|
||||
import WidgetKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class RitualStore: RitualStoreProviding {
|
||||
final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore {
|
||||
private static let dataIntegrityMigrationVersion = 1
|
||||
private static let dataIntegrityMigrationVersionKey = "ritualDataIntegrityMigrationVersion"
|
||||
|
||||
@ObservationIgnored private let modelContainer: ModelContainer
|
||||
@ObservationIgnored private var modelContext: ModelContext
|
||||
@ObservationIgnored let modelContainer: ModelContainer
|
||||
@ObservationIgnored var modelContext: ModelContext
|
||||
@ObservationIgnored private let seedService: RitualSeedProviding
|
||||
@ObservationIgnored private let settingsStore: any RitualFeedbackSettingsProviding
|
||||
@ObservationIgnored private let calendar: Calendar
|
||||
@ -20,14 +19,15 @@ final class RitualStore: RitualStoreProviding {
|
||||
@ObservationIgnored private let isRunningTests: Bool
|
||||
@ObservationIgnored private let dayFormatter: DateFormatter
|
||||
@ObservationIgnored private let displayFormatter: DateFormatter
|
||||
@ObservationIgnored private var remoteChangeObserver: NSObjectProtocol?
|
||||
@ObservationIgnored private var remoteChangeEventCount: Int = 0
|
||||
@ObservationIgnored let cloudKitSyncManager = SwiftDataCloudKitSyncManager(
|
||||
isEnabled: true,
|
||||
logIdentifier: "RitualStore.CloudKitSync"
|
||||
)
|
||||
|
||||
private(set) var rituals: [Ritual] = []
|
||||
private(set) var currentRituals: [Ritual] = []
|
||||
private(set) var pastRituals: [Ritual] = []
|
||||
private(set) var lastErrorMessage: String?
|
||||
private(set) var lastRemoteChangeDate: Date?
|
||||
private(set) var dataRefreshVersion: Int = 0
|
||||
private var analyticsNeedsRefresh = true
|
||||
private var cachedDatesWithActivity: Set<Date> = []
|
||||
@ -52,7 +52,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
updateCurrentTimeOfDay()
|
||||
analyticsNeedsRefresh = true
|
||||
insightCardsNeedRefresh = true
|
||||
reloadRituals()
|
||||
reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,34 +93,23 @@ final class RitualStore: RitualStoreProviding {
|
||||
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() {
|
||||
remoteChangeObserver = NotificationCenter.default.addObserver(
|
||||
forName: .NSPersistentStoreRemoteChange,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
cloudKitSyncManager.startObserving { [weak self] in
|
||||
self?.handleRemoteStoreChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRemoteStoreChange() {
|
||||
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)
|
||||
Design.debugLog("Received remote store change #\(eventCount); reloading rituals")
|
||||
reloadRituals()
|
||||
let result = cloudKitSyncManager.processObservedRemoteChange(
|
||||
modelContext: &modelContext,
|
||||
modelContainer: modelContainer
|
||||
)
|
||||
Design.debugLog(
|
||||
"RitualStore.CloudKitSync: received remote store change #\(result.eventCount); " +
|
||||
"rebuiltContext=\(result.didRebuildModelContext); reloading rituals"
|
||||
)
|
||||
reloadData()
|
||||
// Also refresh widgets when data arrives from other devices
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
@ -144,7 +133,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
/// Refreshes rituals and derived state for current date/time.
|
||||
func refresh() {
|
||||
updateCurrentTimeOfDay()
|
||||
reloadRituals()
|
||||
reloadData()
|
||||
checkForCompletedArcs()
|
||||
}
|
||||
|
||||
@ -895,7 +884,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
}
|
||||
|
||||
private func loadRitualsIfNeeded() {
|
||||
reloadRituals()
|
||||
reloadData()
|
||||
// No longer auto-seed rituals on fresh install
|
||||
// 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)
|
||||
reloadRituals()
|
||||
reloadData()
|
||||
} catch {
|
||||
lastErrorMessage = error.localizedDescription
|
||||
}
|
||||
@ -1002,7 +991,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
return didChange
|
||||
}
|
||||
|
||||
private func reloadRituals() {
|
||||
func reloadData() {
|
||||
do {
|
||||
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
|
||||
updateDerivedData()
|
||||
@ -1017,7 +1006,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
private func saveContext() {
|
||||
do {
|
||||
try modelContext.save()
|
||||
reloadRituals()
|
||||
reloadData()
|
||||
// Widget timeline reloads can destabilize test hosts; skip in tests.
|
||||
if !isRunningTests {
|
||||
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.")
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -11,11 +11,13 @@ struct RootView: View {
|
||||
@State private var analyticsPrewarmTask: Task<Void, Never>?
|
||||
@State private var cloudKitFallbackRefreshTask: 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 isResumingFromBackground = false
|
||||
private let foregroundRefreshMinimumSeconds: TimeInterval = 0.15
|
||||
private let debugForegroundRefreshMinimumSeconds: TimeInterval = 0.8
|
||||
private let debugForegroundRefreshKey = "debugForegroundRefreshNextForeground"
|
||||
private let macCloudKitPulseIntervalSeconds: TimeInterval = 5
|
||||
|
||||
/// The available tabs in the app.
|
||||
enum RootTab: Hashable {
|
||||
@ -109,6 +111,11 @@ struct RootView: View {
|
||||
transaction.animation = nil
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if scenePhase == .active {
|
||||
startMacCloudKitPulseLoopIfNeeded()
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
if newPhase == .active {
|
||||
store.reminderScheduler.clearBadge()
|
||||
@ -136,12 +143,14 @@ struct RootView: View {
|
||||
)
|
||||
scheduleAggressiveCloudKitRefreshes()
|
||||
scheduleCloudKitFallbackRefresh()
|
||||
startMacCloudKitPulseLoopIfNeeded()
|
||||
} else if newPhase == .background {
|
||||
// Prepare for next resume
|
||||
isResumingFromBackground = true
|
||||
cloudKitFallbackRefreshTask?.cancel()
|
||||
aggressiveCloudKitRefreshTasks.forEach { $0.cancel() }
|
||||
aggressiveCloudKitRefreshTasks.removeAll()
|
||||
stopMacCloudKitPulseLoop()
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedTab) { _, _ in
|
||||
@ -159,6 +168,7 @@ struct RootView: View {
|
||||
.onDisappear {
|
||||
aggressiveCloudKitRefreshTasks.forEach { $0.cancel() }
|
||||
aggressiveCloudKitRefreshTasks.removeAll()
|
||||
stopMacCloudKitPulseLoop()
|
||||
}
|
||||
}
|
||||
|
||||
@ -219,8 +229,7 @@ struct RootView: View {
|
||||
cloudKitFallbackRefreshTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: .seconds(8))
|
||||
guard !Task.isCancelled else { return }
|
||||
let receivedRemoteChangeSinceActivation = store.lastRemoteChangeDate.map { $0 >= activationDate } ?? false
|
||||
guard !receivedRemoteChangeSinceActivation else { return }
|
||||
guard !store.hasReceivedRemoteChange(since: activationDate) else { return }
|
||||
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 {
|
||||
|
||||
176
BEDROCK_DEFERRED_REFACTOR_PLAN.md
Normal file
176
BEDROCK_DEFERRED_REFACTOR_PLAN.md
Normal 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
11
PRD.md
@ -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.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 |
|
||||
|
||||
---
|
||||
|
||||
## 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)`.
|
||||
|
||||
@ -40,10 +40,13 @@ Entitlements should reference variables, not hard-coded values, where possible.
|
||||
## 4) Runtime Sync Behavior
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- Keep a foreground fallback refresh as a safety net, but do not rely on force-quit/relaunch behavior.
|
||||
- On remote change, call Bedrock `processObservedRemoteChange(modelContext:modelContainer:)` before refetch.
|
||||
- Rebuild long-lived contexts only when safe (`hasChanges == false`) to avoid dropping unsaved local edits.
|
||||
- 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.
|
||||
|
||||
## 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`).
|
||||
- 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 APNs is unreliable on Mac runtime, validate that pulse logs appear every interval while active.
|
||||
|
||||
## 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
|
||||
- [ ] Entitlements include `aps-environment` and correct container IDs
|
||||
- [ ] 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
|
||||
- [ ] Two-device batch-update test passes without manual refresh
|
||||
- [ ] CloudKit Console verification documented in README/PRD
|
||||
|
||||
@ -68,17 +68,16 @@ import Bedrock
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppDataStore {
|
||||
@ObservationIgnored private let modelContainer: ModelContainer
|
||||
@ObservationIgnored private var modelContext: ModelContext
|
||||
@ObservationIgnored private let cloudKitSyncManager = SwiftDataCloudKitSyncManager(
|
||||
final class AppDataStore: SwiftDataCloudKitStore {
|
||||
@ObservationIgnored let modelContainer: ModelContainer
|
||||
@ObservationIgnored var modelContext: ModelContext
|
||||
@ObservationIgnored let cloudKitSyncManager = SwiftDataCloudKitSyncManager(
|
||||
isEnabled: true,
|
||||
logIdentifier: "AppCloudKitSync"
|
||||
)
|
||||
|
||||
private(set) var entities: [PrimaryEntity] = []
|
||||
private(set) var dataRefreshVersion: Int = 0
|
||||
private(set) var lastRemoteChangeDate: Date?
|
||||
|
||||
init(modelContext: ModelContext) {
|
||||
self.modelContainer = modelContext.container
|
||||
@ -94,17 +93,22 @@ final class AppDataStore {
|
||||
}
|
||||
|
||||
private func handleRemoteStoreChange() {
|
||||
lastRemoteChangeDate = cloudKitSyncManager.lastRemoteChangeDate
|
||||
let eventCount = cloudKitSyncManager.remoteChangeEventCount
|
||||
|
||||
// Important when long-lived contexts become stale after remote merges.
|
||||
modelContext = ModelContext(modelContainer)
|
||||
|
||||
Design.debugLog("Received remote store change #\(eventCount); reloading")
|
||||
let result = cloudKitSyncManager.processObservedRemoteChange(
|
||||
modelContext: &modelContext,
|
||||
modelContainer: modelContainer
|
||||
)
|
||||
Design.debugLog(
|
||||
"Received remote store change #\(result.eventCount); " +
|
||||
"rebuiltContext=\(result.didRebuildModelContext); reloading"
|
||||
)
|
||||
reloadEntities()
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
reloadData()
|
||||
}
|
||||
|
||||
func reloadData() {
|
||||
reloadEntities()
|
||||
}
|
||||
|
||||
@ -128,17 +132,26 @@ struct RootView: View {
|
||||
@Bindable var store: AppDataStore
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var fallbackRefreshTask: Task<Void, Never>?
|
||||
@State private var macPulseTask: Task<Void, Never>?
|
||||
private let macPulseIntervalSeconds: TimeInterval = 5
|
||||
|
||||
var body: some View {
|
||||
// Ensure body observes remote reload increments.
|
||||
let _ = store.dataRefreshVersion
|
||||
|
||||
ContentView(store: store)
|
||||
.onAppear {
|
||||
if scenePhase == .active {
|
||||
startMacPulseLoopIfNeeded()
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
if newPhase == .active {
|
||||
startMacPulseLoopIfNeeded()
|
||||
scheduleCloudKitFallbackRefresh()
|
||||
} else if newPhase == .background {
|
||||
fallbackRefreshTask?.cancel()
|
||||
stopMacPulseLoop()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -149,11 +162,27 @@ struct RootView: View {
|
||||
fallbackRefreshTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: .seconds(8))
|
||||
guard !Task.isCancelled else { return }
|
||||
let gotRemoteSinceActive = store.lastRemoteChangeDate.map { $0 >= activationDate } ?? false
|
||||
guard !gotRemoteSinceActive else { return }
|
||||
guard !store.hasReceivedRemoteChange(since: activationDate) else { return }
|
||||
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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user