Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-16 16:46:41 -06:00
parent db06b43364
commit fc4e283124
2 changed files with 145 additions and 10 deletions

View File

@ -4,12 +4,13 @@ Use this guide when enabling near-real-time SwiftData sync across multiple Apple
## Overview ## Overview
Bedrock provides four reusable building blocks for SwiftData + CloudKit apps: Bedrock provides five reusable building blocks for SwiftData + CloudKit apps:
- `SwiftDataCloudKitSyncManager`: observes remote CloudKit-backed store changes. - `SwiftDataCloudKitSyncManager`: observes remote CloudKit-backed store changes.
- `SwiftDataRefreshThrottler`: enforces minimum refresh intervals. - `SwiftDataRefreshThrottler`: enforces minimum refresh intervals.
- `SwiftDataStore`: generic store protocol (`modelContext`, `modelContainer`, `reloadData`). - `SwiftDataStore`: generic store protocol (`modelContext`, `modelContainer`, `reloadData`).
- `SwiftDataCloudKitStore`: adds CloudKit sync defaults (remote-change metadata + Mac pulse fallback). - `SwiftDataCloudKitStore`: adds CloudKit sync defaults (remote-change metadata + Mac pulse fallback).
- `SwiftDataCloudKitSceneSyncCoordinator`: scene lifecycle fallback scheduling (foreground fallback + aggressive refreshes + Mac pulse loop).
These utilities are domain-neutral and intended to be integrated by your app store/state layer. These utilities are domain-neutral and intended to be integrated by your app store/state layer.
@ -151,20 +152,26 @@ Keep a foreground fallback only as a safety net:
### Mac Runtime Fallback (iOS-on-Mac) ### Mac Runtime Fallback (iOS-on-Mac)
If APNs delivery is unreliable on Mac runtime, pulse import scheduling explicitly while active: For shared scene-lifecycle fallback behavior, use Bedrock coordinator:
```swift ```swift
if store.supportsMacCloudKitImportPulseFallback { @State private var cloudKitSceneSyncCoordinator = SwiftDataCloudKitSceneSyncCoordinator(
store.forceCloudKitImportPulse(reason: "active_loop") configuration: .init(logIdentifier: "RootView.CloudKitSync")
} )
cloudKitSceneSyncCoordinator.handleDidBecomeActive(
store: store,
isAppActive: { scenePhase == .active },
refresh: { store.refresh() }
)
``` ```
Recommended usage: `SwiftDataCloudKitSceneSyncCoordinator` handles:
1. Start a foreground-only task when scene becomes `.active`. 1. One-shot fallback refresh if no remote change arrived after activation.
2. Pulse on an interval (for example 5 seconds). 2. Aggressive delayed refreshes (default: 5s and 10s).
3. Cancel on `.background` / `.inactive`. 3. Mac-only import pulse loop (default: every 5s while active).
4. Keep this fallback Mac-only; leave iPhone/iPad on event-driven sync. 4. Automatic task cancellation on background/disappear.
## 7) Verification Checklist (Release Gate) ## 7) Verification Checklist (Release Gate)

View File

@ -0,0 +1,128 @@
//
// SwiftDataCloudKitSceneSyncCoordinator.swift
// Bedrock
//
// Shared scene-lifecycle orchestration for SwiftData + CloudKit fallback refresh behavior.
//
import Foundation
@MainActor
public final class SwiftDataCloudKitSceneSyncCoordinator {
public struct Configuration {
public var fallbackRefreshDelay: TimeInterval
public var aggressiveRefreshDelays: [TimeInterval]
public var macPulseInterval: TimeInterval
public var macPulseReason: String
public var logIdentifier: String
public init(
fallbackRefreshDelay: TimeInterval = 8,
aggressiveRefreshDelays: [TimeInterval] = [5, 10],
macPulseInterval: TimeInterval = 5,
macPulseReason: String = "active_loop",
logIdentifier: String = "SwiftDataCloudKitSceneSync"
) {
self.fallbackRefreshDelay = fallbackRefreshDelay
self.aggressiveRefreshDelays = aggressiveRefreshDelays
self.macPulseInterval = macPulseInterval
self.macPulseReason = macPulseReason
self.logIdentifier = logIdentifier
}
}
private let configuration: Configuration
private var fallbackRefreshTask: Task<Void, Never>?
private var aggressiveRefreshTasks: [Task<Void, Never>] = []
private var macPulseTask: Task<Void, Never>?
public init(configuration: Configuration = .init()) {
self.configuration = configuration
}
/// Starts/restarts active-phase CloudKit fallback scheduling.
///
/// - Parameters:
/// - store: The CloudKit-backed SwiftData store.
/// - isAppActive: Returns whether the scene is currently active.
/// - refresh: App-specific refresh callback (for example store-level refresh routine).
public func handleDidBecomeActive<Store: SwiftDataCloudKitStore>(
store: Store,
isAppActive: @escaping @MainActor () -> Bool,
refresh: @escaping @MainActor () -> Void
) {
cancelAllTasks()
scheduleFallbackRefresh(store: store, isAppActive: isAppActive, refresh: refresh)
scheduleAggressiveRefreshes(isAppActive: isAppActive, refresh: refresh)
startMacPulseLoopIfNeeded(store: store, isAppActive: isAppActive)
}
/// Stops active-phase scheduling when entering background.
public func handleDidEnterBackground() {
cancelAllTasks()
}
/// Stops all tasks when view disappears.
public func handleViewDisappear() {
cancelAllTasks()
}
private func scheduleFallbackRefresh<Store: SwiftDataCloudKitStore>(
store: Store,
isAppActive: @escaping @MainActor () -> Bool,
refresh: @escaping @MainActor () -> Void
) {
let activationDate = Date()
fallbackRefreshTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(configuration.fallbackRefreshDelay))
guard !Task.isCancelled else { return }
guard isAppActive() else { return }
guard !store.hasReceivedRemoteChange(since: activationDate) else { return }
refresh()
}
}
private func scheduleAggressiveRefreshes(
isAppActive: @escaping @MainActor () -> Bool,
refresh: @escaping @MainActor () -> Void
) {
for delay in configuration.aggressiveRefreshDelays {
let task = Task { @MainActor in
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return }
guard isAppActive() else { return }
refresh()
}
aggressiveRefreshTasks.append(task)
}
}
private func startMacPulseLoopIfNeeded<Store: SwiftDataCloudKitStore>(
store: Store,
isAppActive: @escaping @MainActor () -> Bool
) {
guard store.supportsMacCloudKitImportPulseFallback else { return }
macPulseTask = Task { @MainActor in
Design.debugLog(
"\(configuration.logIdentifier): starting macOS pulse loop interval=\(configuration.macPulseInterval)s"
)
while !Task.isCancelled {
guard isAppActive() else { return }
store.forceCloudKitImportPulse(reason: configuration.macPulseReason)
try? await Task.sleep(for: .seconds(configuration.macPulseInterval))
}
}
}
private func cancelAllTasks() {
fallbackRefreshTask?.cancel()
fallbackRefreshTask = nil
aggressiveRefreshTasks.forEach { $0.cancel() }
aggressiveRefreshTasks.removeAll()
macPulseTask?.cancel()
macPulseTask = nil
}
}