Compare commits
No commits in common. "db06b4336445b94876a6f2fc7421993213a234c5" and "65fe6cc17868afa6b13471a8f3e42c5e983d7767" have entirely different histories.
db06b43364
...
65fe6cc178
10
README.md
10
README.md
@ -29,7 +29,6 @@ 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
|
||||||
|
|
||||||
@ -77,15 +76,6 @@ 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:
|
||||||
|
|||||||
@ -1,183 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
//
|
|
||||||
// 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