Bedrock/Sources/Bedrock/Storage/SwiftDataCloudKitSyncManager.swift

183 lines
6.6 KiB
Swift

//
// 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
}
}