Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
65fe6cc178
commit
bd09c14a02
10
README.md
10
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:
|
||||
|
||||
171
Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md
Normal file
171
Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md
Normal file
@ -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
|
||||
<key>aps-environment</key>
|
||||
<string>$(APS_ENVIRONMENT)</string>
|
||||
```
|
||||
|
||||
## 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.
|
||||
182
Sources/Bedrock/Storage/SwiftDataCloudKitSyncManager.swift
Normal file
182
Sources/Bedrock/Storage/SwiftDataCloudKitSyncManager.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
38
Sources/Bedrock/Storage/SwiftDataRefreshThrottler.swift
Normal file
38
Sources/Bedrock/Storage/SwiftDataRefreshThrottler.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
50
Sources/Bedrock/Storage/SwiftDataStore.swift
Normal file
50
Sources/Bedrock/Storage/SwiftDataStore.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user