183 lines
6.6 KiB
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
|
|
}
|
|
}
|