diff --git a/Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md b/Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md index c551516..2aa2e02 100644 --- a/Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md +++ b/Sources/Bedrock/Storage/SWIFTDATA_CLOUDKIT_SETUP_GUIDE.md @@ -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) diff --git a/Sources/Bedrock/Storage/SwiftDataCloudKitSceneSyncCoordinator.swift b/Sources/Bedrock/Storage/SwiftDataCloudKitSceneSyncCoordinator.swift new file mode 100644 index 0000000..e073392 --- /dev/null +++ b/Sources/Bedrock/Storage/SwiftDataCloudKitSceneSyncCoordinator.swift @@ -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? + private var aggressiveRefreshTasks: [Task] = [] + private var macPulseTask: Task? + + 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: 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: 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: 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 + } +}