Compare commits
2 Commits
65fe6cc178
...
db06b43364
| Author | SHA1 | Date | |
|---|---|---|---|
| db06b43364 | |||
| 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 |
|
| **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:
|
||||||
|
|||||||
183
Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md
Normal file
183
Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md
Normal 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.
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
118
Sources/Bedrock/Storage/SwiftDataStore.swift
Normal file
118
Sources/Bedrock/Storage/SwiftDataStore.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user