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
|
## 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)
|
||||||
|
|
||||||
|
|||||||
@ -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