Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
881ac5ad4c
commit
e9de34355b
@ -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,
|
self?.handleRemoteStoreChange()
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
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.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)`.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user