Compare commits

...

2 Commits

5 changed files with 531 additions and 0 deletions

View File

@ -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 | | **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 | | **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 | | **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 ### 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) - [ ] Avoid manual horizontal child padding inside `SettingsCard` (card owns row insets)
- [ ] Add `#if DEBUG` section for development tools - [ ] 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 ### Quick Start Order
When building a new app from scratch: When building a new app from scratch:

View File

@ -0,0 +1,183 @@
# 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
startObservingCloudKitRemoteChanges()
reloadData()
}
func reloadData() {
// Fetch your app entities and recompute derived state.
refreshVersion &+= 1
}
}
```
If your store needs post-remote side effects (for example widget timeline reload), use:
```swift
startObservingCloudKitRemoteChanges {
WidgetCenter.shared.reloadAllTimelines()
}
```
If your store needs save side effects/error handling, prefer Bedrock protocol hooks:
```swift
func didSaveAndReloadData() {
// optional side effects (widgets, cache invalidation, etc.)
}
func handleSaveAndReloadError(_ error: Error) {
// set store error state
}
// Then call the shared default:
saveAndReload()
```
## 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.

View 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
}
}

View 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
}
}

View File

@ -0,0 +1,118 @@
//
// SwiftDataStore.swift
// Bedrock
//
// Shared store protocols/defaults for SwiftData, including CloudKit helpers.
//
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()
/// App-specific side effects after a successful save+reload cycle.
func didSaveAndReloadData()
/// App-specific error handling when save+reload fails.
func handleSaveAndReloadError(_ error: Error)
}
public extension SwiftDataStore {
/// Default no-op success hook.
func didSaveAndReloadData() {}
/// Default error handling hook.
func handleSaveAndReloadError(_ error: Error) {
Design.debugLog(
"\(String(describing: type(of: self))): failed saveAndReload error=\(error.localizedDescription)"
)
}
/// Saves the context, reloads data, then runs optional hooks.
///
/// If hooks are omitted, protocol defaults are used:
/// - success: `didSaveAndReloadData()`
/// - failure: `handleSaveAndReloadError(_:)`
func saveAndReload(
onSuccess: (() -> Void)? = nil,
onFailure: ((Error) -> Void)? = nil
) {
do {
try modelContext.save()
reloadData()
(onSuccess ?? didSaveAndReloadData)()
} catch {
(onFailure ?? handleSaveAndReloadError)(error)
}
}
/// Convenience overload that always uses protocol hooks.
func saveAndReload() {
saveAndReload(onSuccess: didSaveAndReloadData, onFailure: handleSaveAndReloadError)
}
}
@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)
}
/// Starts observing remote CloudKit-backed store changes.
///
/// - Parameter afterReload: Optional callback that runs after the default `reloadData()` handling.
func startObservingCloudKitRemoteChanges(afterReload: (@MainActor () -> Void)? = nil) {
cloudKitSyncManager.startObserving { [weak self] in
self?.handleObservedCloudKitRemoteChange(afterReload: afterReload)
}
}
/// Applies the default handling for an observed remote CloudKit merge.
///
/// - Parameter afterReload: Optional callback that runs after `reloadData()`.
func handleObservedCloudKitRemoteChange(afterReload: (@MainActor () -> Void)? = nil) {
let result = cloudKitSyncManager.processObservedRemoteChange(
modelContext: &modelContext,
modelContainer: modelContainer
)
Design.debugLog(
"\(String(describing: type(of: self))): received remote store change #\(result.eventCount); " +
"rebuiltContext=\(result.didRebuildModelContext)"
)
reloadData()
afterReload?()
}
/// 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()
}
}