From bd09c14a02534003fdcc01989ab7524729a0343a Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 16 Feb 2026 16:20:01 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- README.md | 10 + .../Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md | 171 ++++++++++++++++ .../SwiftDataCloudKitSyncManager.swift | 182 ++++++++++++++++++ .../Storage/SwiftDataRefreshThrottler.swift | 38 ++++ Sources/Bedrock/Storage/SwiftDataStore.swift | 50 +++++ 5 files changed, 451 insertions(+) create mode 100644 Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md create mode 100644 Sources/Bedrock/Storage/SwiftDataCloudKitSyncManager.swift create mode 100644 Sources/Bedrock/Storage/SwiftDataRefreshThrottler.swift create mode 100644 Sources/Bedrock/Storage/SwiftDataStore.swift diff --git a/README.md b/README.md index 94c8db3..39b6135 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Bedrock is designed to be the foundation upon which apps are built, providing: | **Theme Guide** | [`Sources/Bedrock/Theme/THEME_GUIDE.md`](Sources/Bedrock/Theme/THEME_GUIDE.md) | Create custom color themes using Bedrock's protocol-based theming system | | **Branding Guide** | [`Sources/Bedrock/Branding/BRANDING_GUIDE.md`](Sources/Bedrock/Branding/BRANDING_GUIDE.md) | Set up app icons, launch screens, and branded launch animations | | **Settings Guide** | [`Sources/Bedrock/Views/Settings/SETTINGS_GUIDE.md`](Sources/Bedrock/Views/Settings/SETTINGS_GUIDE.md) | Build branded settings screens with reusable UI components | +| **SwiftData CloudKit Guide** | [`Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md`](Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md) | Configure SwiftData+iCloud sync with reusable Bedrock sync lifecycle utilities | ### Implementation Checklist @@ -76,6 +77,15 @@ Use this checklist when setting up a new app with Bedrock: - [ ] Avoid manual horizontal child padding inside `SettingsCard` (card owns row insets) - [ ] Add `#if DEBUG` section for development tools +#### ☁️ SwiftData + CloudKit Sync (When enabling cross-device data sync) +- [ ] **Read**: `SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md` +- [ ] Enable iCloud/CloudKit + Push Notifications + Remote notifications background mode +- [ ] Configure `aps-environment` via xcconfig (`development`/`production`) +- [ ] Wire `SwiftDataCloudKitSyncManager` into app store lifecycle +- [ ] Use `SwiftDataRefreshThrottler` for minimum-interval refresh gates +- [ ] Add a root-level refresh version observation for reliable UI invalidation +- [ ] Validate with two physical devices and CloudKit Console checks + ### Quick Start Order When building a new app from scratch: diff --git a/Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md b/Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md new file mode 100644 index 0000000..3ec7ab4 --- /dev/null +++ b/Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md @@ -0,0 +1,171 @@ +# SwiftData + CloudKit Setup Guide + +Use this guide when enabling near-real-time SwiftData sync across multiple Apple devices. + +## Overview + +Bedrock provides four reusable building blocks for SwiftData + CloudKit apps: + +- `SwiftDataCloudKitSyncManager`: observes remote CloudKit-backed store changes. +- `SwiftDataRefreshThrottler`: enforces minimum refresh intervals. +- `SwiftDataStore`: generic store protocol (`modelContext`, `modelContainer`, `reloadData`). +- `SwiftDataCloudKitStore`: adds CloudKit sync defaults (remote-change metadata + Mac pulse fallback). + +These utilities are domain-neutral and intended to be integrated by your app store/state layer. + +## 1) Required Capabilities and Entitlements + +Enable in the app target: + +- iCloud with CloudKit +- Push Notifications +- Background Modes -> Remote notifications +- App Groups (if app/widget share SQLite storage) + +Required entitlements: + +- `com.apple.developer.icloud-container-identifiers` +- `com.apple.developer.icloud-services` includes `CloudKit` +- `aps-environment` (per build configuration) +- `com.apple.security.application-groups` (if app group is used) + +Recommended xcconfig setup: + +```xcconfig +// Debug.xcconfig +APS_ENVIRONMENT = development + +// Release.xcconfig +APS_ENVIRONMENT = production +``` + +Entitlements reference: + +```xml +aps-environment +$(APS_ENVIRONMENT) +``` + +## 2) Container Configuration + +Configure your SwiftData container with CloudKit mirroring: + +```swift +let config = ModelConfiguration( + schema: schema, + url: storeURL, + cloudKitDatabase: isRunningTests ? .none : .private(cloudKitContainerIdentifier) +) +``` + +For deterministic tests, disable CloudKit mirroring. + +## 3) Integrate `SwiftDataCloudKitSyncManager` + +Your app store owns domain behavior and plugs remote-change events into reload logic: + +```swift +import Bedrock +import SwiftData + +@MainActor +@Observable +final class AppDataStore: SwiftDataCloudKitStore { + @ObservationIgnored let modelContainer: ModelContainer + @ObservationIgnored var modelContext: ModelContext + @ObservationIgnored let cloudKitSyncManager = SwiftDataCloudKitSyncManager( + isEnabled: true, + logIdentifier: "AppCloudKitSync" + ) + + private(set) var refreshVersion: Int = 0 + + init(modelContext: ModelContext) { + self.modelContainer = modelContext.container + self.modelContext = modelContext + startSyncObservation() + reloadData() + } + + private func startSyncObservation() { + cloudKitSyncManager.startObserving { [weak self] in + guard let self else { return } + _ = self.cloudKitSyncManager.processObservedRemoteChange( + modelContext: &self.modelContext, + modelContainer: self.modelContainer + ) + self.reloadData() + } + } + + func reloadData() { + // Fetch your app entities and recompute derived state. + refreshVersion &+= 1 + } +} +``` + +## 4) Integrate `SwiftDataRefreshThrottler` + +Use throttling for on-appear and tab/scene refresh paths: + +```swift +@ObservationIgnored private let refreshThrottler = SwiftDataRefreshThrottler() + +func refreshIfNeeded(minimumInterval: TimeInterval = 5) { + guard refreshThrottler.shouldRefresh(minimumInterval: minimumInterval) else { return } + refresh() +} +``` + +## 5) UI Invalidation Pattern + +Remote merges can arrive in bursts. Keep a refresh counter in your store and observe it in root-level views: + +```swift +let _ = store.refreshVersion +``` + +This forces reevaluation even when model object identities do not change as expected. + +## 6) Fallback Refresh Pattern + +Keep a foreground fallback only as a safety net: + +1. Capture activation timestamp. +2. Delay once (for example 8 seconds). +3. Skip fallback if `store.hasReceivedRemoteChange(since:)` is true. +4. Otherwise run a refresh. + +### Mac Runtime Fallback (iOS-on-Mac) + +If APNs delivery is unreliable on Mac runtime, pulse import scheduling explicitly while active: + +```swift +if store.supportsMacCloudKitImportPulseFallback { + store.forceCloudKitImportPulse(reason: "active_loop") +} +``` + +Recommended usage: + +1. Start a foreground-only task when scene becomes `.active`. +2. Pulse on an interval (for example 5 seconds). +3. Cancel on `.background` / `.inactive`. +4. Keep this fallback Mac-only; leave iPhone/iPad on event-driven sync. + +## 7) Verification Checklist (Release Gate) + +Run on two physical devices, same Apple ID, same app flavor: + +1. Single edit appears cross-device while both apps are open. +2. Rapid batch edits all appear without manual pull-to-refresh. +3. Background/foreground recovery works without force-quit. +4. Logs show remote event sequence increasing. +5. CloudKit Console private DB records update as expected. + +## 8) Troubleshooting + +- Push events appear but UI is stale: verify context recreation + UI invalidation counter. +- Updates only after app relaunch: verify remote observer active and push entitlement provisioning. +- Works only in one build type: verify `aps-environment` and signing profile consistency. diff --git a/Sources/Bedrock/Storage/SwiftDataCloudKitSyncManager.swift b/Sources/Bedrock/Storage/SwiftDataCloudKitSyncManager.swift new file mode 100644 index 0000000..d7ca41f --- /dev/null +++ b/Sources/Bedrock/Storage/SwiftDataCloudKitSyncManager.swift @@ -0,0 +1,182 @@ +// +// SwiftDataCloudKitSyncManager.swift +// Bedrock +// +// Reusable remote-change observer for SwiftData + CloudKit mirroring. +// + +import Foundation +import CoreData +import SwiftData +import SwiftUI + +@MainActor +@Observable +public final class SwiftDataCloudKitSyncManager { + public struct RemoteChangeProcessingResult { + public let eventCount: Int + public let lastRemoteChangeDate: Date? + public let didRebuildModelContext: Bool + + public init( + eventCount: Int, + lastRemoteChangeDate: Date?, + didRebuildModelContext: Bool + ) { + self.eventCount = eventCount + self.lastRemoteChangeDate = lastRemoteChangeDate + self.didRebuildModelContext = didRebuildModelContext + } + } + + /// Last timestamp when a remote store change was observed. + public private(set) var lastRemoteChangeDate: Date? + + /// Monotonic count of observed remote store change events. + public private(set) var remoteChangeEventCount: Int = 0 + + /// Whether remote change observation is currently active. + public private(set) var isObservingRemoteChanges: Bool = false + + private let isEnabled: Bool + private let logIdentifier: String + private var remoteChangeObserver: NSObjectProtocol? + + public init( + isEnabled: Bool = true, + logIdentifier: String = "SwiftDataCloudKitSyncManager" + ) { + self.isEnabled = isEnabled + self.logIdentifier = logIdentifier + } + + /// Starts observing Core Data remote store changes and invokes `onRemoteChange` per event. + public func startObserving(onRemoteChange: @escaping @MainActor () -> Void) { + guard isEnabled else { + Design.debugLog("\(logIdentifier): remote observation disabled") + return + } + guard remoteChangeObserver == nil else { return } + + Design.debugLog("\(logIdentifier): starting remote store change observation") + isObservingRemoteChanges = true + + remoteChangeObserver = NotificationCenter.default.addObserver( + forName: .NSPersistentStoreRemoteChange, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + self.remoteChangeEventCount += 1 + self.lastRemoteChangeDate = Date() + onRemoteChange() + } + } + + /// Stops observing remote store changes. + public func stopObserving() { + if let observer = remoteChangeObserver { + NotificationCenter.default.removeObserver(observer) + remoteChangeObserver = nil + } + isObservingRemoteChanges = false + } + + /// Returns true when a remote store event has been observed on/after `date`. + public func hasReceivedRemoteChange(since date: Date) -> Bool { + guard let lastRemoteChangeDate else { return false } + return lastRemoteChangeDate >= date + } + + /// Applies standard post-observation handling for a remote CloudKit merge. + /// + /// Call this from the `startObserving` callback before fetching data if your store + /// keeps a long-lived `ModelContext`. + @discardableResult + public func processObservedRemoteChange( + modelContext: inout ModelContext, + modelContainer: ModelContainer, + rebuildContextIfSafe: Bool = true + ) -> RemoteChangeProcessingResult { + var didRebuildModelContext = false + + if rebuildContextIfSafe { + if modelContext.hasChanges { + Design.debugLog("\(logIdentifier): context has unsaved changes, skipping context rebuild after remote change") + } else { + modelContext = ModelContext(modelContainer) + didRebuildModelContext = true + Design.debugLog("\(logIdentifier): rebuilt model context after remote change") + } + } + + return RemoteChangeProcessingResult( + eventCount: remoteChangeEventCount, + lastRemoteChangeDate: lastRemoteChangeDate, + didRebuildModelContext: didRebuildModelContext + ) + } + + /// Returns true when this runtime can use AppKit activation pulse fallback. + public var supportsMacImportPulseFallback: Bool { + ProcessInfo.processInfo.isiOSAppOnMac + } + + /// Posts AppKit activation notifications that can nudge CloudKit import scheduling on iOS-on-Mac. + /// + /// Returns true only when a pulse was posted on a supported runtime. + @discardableResult + public func triggerMacImportPulse(reason: String? = nil) -> Bool { + guard supportsMacImportPulseFallback else { return false } + + let reasonText = reason?.isEmpty == false ? reason! : "unspecified" + Design.debugLog("\(logIdentifier): triggering mac import pulse; reason=\(reasonText)") + + let nsApp = NSClassFromString("NSApplication")?.value(forKeyPath: "sharedApplication") as? NSObject + Design.debugLog("\(logIdentifier): NSApplication.shared resolved=\(nsApp != nil)") + + NotificationCenter.default.post( + name: Notification.Name("NSApplicationDidBecomeActiveNotification"), + object: nsApp + ) + NotificationCenter.default.post( + name: Notification.Name("NSApplicationWillBecomeActiveNotification"), + object: nsApp + ) + + // Best-effort distributed notification for runtimes that rely on cross-process signaling. + if let distClass = NSClassFromString("NSDistributedNotificationCenter"), + let center = distClass.value(forKeyPath: "defaultCenter") as? NSObject { + _ = center.perform( + NSSelectorFromString("postNotificationName:object:"), + with: "NSApplicationDidBecomeActiveNotification", + with: nil + ) + } + + return true + } + + /// Triggers the mac import pulse and refreshes `modelContext` when safe. + /// + /// Use this to avoid duplicating "pulse + context rebuild" boilerplate in each app store. + @discardableResult + public func triggerMacImportPulse( + reason: String? = nil, + modelContext: inout ModelContext, + modelContainer: ModelContainer, + rebuildContextIfSafe: Bool = true + ) -> Bool { + guard triggerMacImportPulse(reason: reason) else { return false } + guard rebuildContextIfSafe else { return true } + + if modelContext.hasChanges { + Design.debugLog("\(logIdentifier): context has unsaved changes, skipping context rebuild after pulse") + return true + } + + modelContext = ModelContext(modelContainer) + Design.debugLog("\(logIdentifier): rebuilt model context after pulse") + return true + } +} diff --git a/Sources/Bedrock/Storage/SwiftDataRefreshThrottler.swift b/Sources/Bedrock/Storage/SwiftDataRefreshThrottler.swift new file mode 100644 index 0000000..016f19d --- /dev/null +++ b/Sources/Bedrock/Storage/SwiftDataRefreshThrottler.swift @@ -0,0 +1,38 @@ +// +// SwiftDataRefreshThrottler.swift +// Bedrock +// +// Generic minimum-interval gate for refresh operations. +// + +import Foundation + +public final class SwiftDataRefreshThrottler { + /// Timestamp of the last allowed refresh. + public private(set) var lastRefreshDate: Date? + + public init(lastRefreshDate: Date? = nil) { + self.lastRefreshDate = lastRefreshDate + } + + /// Returns true when a refresh should run now. + /// If true, records `now` as the latest refresh date. + @discardableResult + public func shouldRefresh( + now: Date = Date(), + minimumInterval: TimeInterval + ) -> Bool { + if let lastRefreshDate, + now.timeIntervalSince(lastRefreshDate) < minimumInterval { + return false + } + + lastRefreshDate = now + return true + } + + /// Clears refresh history so the next check can pass immediately. + public func reset() { + lastRefreshDate = nil + } +} diff --git a/Sources/Bedrock/Storage/SwiftDataStore.swift b/Sources/Bedrock/Storage/SwiftDataStore.swift new file mode 100644 index 0000000..cfeb0f0 --- /dev/null +++ b/Sources/Bedrock/Storage/SwiftDataStore.swift @@ -0,0 +1,50 @@ +// +// SwiftDataStore.swift +// Bedrock +// +// Generic macOS fallback pulse wiring for SwiftData + CloudKit stores. +// + +import Foundation +import SwiftData + +@MainActor +public protocol SwiftDataStore: AnyObject { + var modelContext: ModelContext { get set } + var modelContainer: ModelContainer { get } + + /// App-specific fetch/recompute entry point. + func reloadData() +} + +@MainActor +public protocol SwiftDataCloudKitStore: SwiftDataStore { + var cloudKitSyncManager: SwiftDataCloudKitSyncManager { get } +} + +@available(*, deprecated, renamed: "SwiftDataCloudKitStore") +public typealias SwiftDataCloudKitPulseReloadingStore = SwiftDataCloudKitStore + +public extension SwiftDataCloudKitStore { + var lastRemoteChangeDate: Date? { + cloudKitSyncManager.lastRemoteChangeDate + } + + var supportsMacCloudKitImportPulseFallback: Bool { + cloudKitSyncManager.supportsMacImportPulseFallback + } + + func hasReceivedRemoteChange(since date: Date) -> Bool { + cloudKitSyncManager.hasReceivedRemoteChange(since: date) + } + + /// Production fallback: pulses CloudKit import scheduling while app is active on Mac runtime. + func forceCloudKitImportPulse(reason: String) { + guard cloudKitSyncManager.triggerMacImportPulse( + reason: reason, + modelContext: &modelContext, + modelContainer: modelContainer + ) else { return } + reloadData() + } +}