Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
db06b43364
commit
fc4e283124
@ -4,12 +4,13 @@ Use this guide when enabling near-real-time SwiftData sync across multiple Apple
|
||||
|
||||
## 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.
|
||||
- `SwiftDataRefreshThrottler`: enforces minimum refresh intervals.
|
||||
- `SwiftDataStore`: generic store protocol (`modelContext`, `modelContainer`, `reloadData`).
|
||||
- `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.
|
||||
|
||||
@ -151,20 +152,26 @@ Keep a foreground fallback only as a safety net:
|
||||
|
||||
### 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
|
||||
if store.supportsMacCloudKitImportPulseFallback {
|
||||
store.forceCloudKitImportPulse(reason: "active_loop")
|
||||
}
|
||||
@State private var cloudKitSceneSyncCoordinator = SwiftDataCloudKitSceneSyncCoordinator(
|
||||
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`.
|
||||
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.
|
||||
1. One-shot fallback refresh if no remote change arrived after activation.
|
||||
2. Aggressive delayed refreshes (default: 5s and 10s).
|
||||
3. Mac-only import pulse loop (default: every 5s while active).
|
||||
4. Automatic task cancellation on background/disappear.
|
||||
|
||||
## 7) Verification Checklist (Release Gate)
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user