CloudKitSyncMonitor.swift (6144B)
1 import CoreData 2 import Foundation 3 import os 4 5 struct SyncDiagnosticEntry: Identifiable { 6 let id = UUID() 7 let timestamp: Date 8 let level: String 9 let message: String 10 } 11 12 @MainActor 13 final class CloudKitSyncMonitor: ObservableObject { 14 @Published private(set) var transientErrorMessage: String? 15 @Published private(set) var lastSuccessfulSyncDate: Date? 16 @Published private(set) var lastCloudKitErrorDomain: String? 17 @Published private(set) var lastCloudKitErrorCode: Int? 18 @Published private(set) var lastCloudKitErrorDescription: String? 19 @Published private(set) var recentDiagnostics: [SyncDiagnosticEntry] = [] 20 21 private var monitoringTask: Task<Void, Never>? 22 private var deferredTask: Task<Void, Never>? 23 private let logger = Logger(subsystem: "net.inqk.listless", category: "CloudKitSync") 24 private let maxDiagnosticEntries = 80 25 26 var hasDiagnosticsIssue: Bool { 27 transientErrorMessage != nil || lastCloudKitErrorDescription != nil 28 } 29 30 deinit { 31 monitoringTask?.cancel() 32 deferredTask?.cancel() 33 } 34 35 func startMonitoring(container: NSPersistentCloudKitContainer) { 36 guard monitoringTask == nil else { return } 37 logger.info("Starting CloudKit event monitoring") 38 appendDiagnostic(level: "info", "Starting CloudKit event monitoring") 39 40 monitoringTask = Task { [weak self] in 41 guard let self else { return } 42 43 for await notification in NotificationCenter.default.notifications( 44 named: NSPersistentCloudKitContainer.eventChangedNotification, 45 object: container 46 ) { 47 guard 48 let event = 49 notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] 50 as? NSPersistentCloudKitContainer.Event 51 else { 52 self.logger.error("Received CloudKit eventChangedNotification without event payload") 53 self.appendDiagnostic( 54 level: "error", 55 "Received eventChangedNotification without event payload" 56 ) 57 continue 58 } 59 60 let eventDescription = 61 "CloudKit event: type=\(self.eventTypeName(event.type)) endDatePresent=\(event.endDate != nil)" 62 self.logger.debug("\(eventDescription, privacy: .public)") 63 self.appendDiagnostic(level: "debug", eventDescription) 64 65 if let error = event.error { 66 self.logCloudKitError(error) 67 self.handle(issue: CloudKitErrorClassifier.classify(error)) 68 } else if self.isSuccessfulSyncCompletion(event) { 69 self.deferredTask?.cancel() 70 self.deferredTask = nil 71 self.transientErrorMessage = nil 72 self.lastSuccessfulSyncDate = event.endDate ?? Date() 73 let successDescription = "CloudKit sync succeeded: type=\(self.eventTypeName(event.type))" 74 self.logger.info("\(successDescription, privacy: .public)") 75 self.appendDiagnostic(level: "info", successDescription) 76 } 77 } 78 } 79 } 80 81 func ingest(error: Error) { 82 handle(issue: CloudKitErrorClassifier.classify(error)) 83 } 84 85 private func handle(issue: SyncIssue) { 86 switch issue { 87 case .transient(let message): 88 logger.warning("Transient sync issue: \(message, privacy: .public)") 89 appendDiagnostic(level: "warning", "Transient sync issue: \(message)") 90 showTransient(message) 91 92 case .deferred(let message): 93 logger.warning("Deferred sync issue scheduled: \(message, privacy: .public)") 94 appendDiagnostic(level: "warning", "Deferred sync issue scheduled: \(message)") 95 guard deferredTask == nil else { return } 96 deferredTask = Task { 97 try? await Task.sleep(for: .seconds(60)) 98 guard !Task.isCancelled else { return } 99 showTransient(message) 100 self.logger.warning("Deferred sync issue surfaced: \(message, privacy: .public)") 101 self.appendDiagnostic(level: "warning", "Deferred sync issue surfaced: \(message)") 102 } 103 } 104 } 105 106 private func showTransient(_ message: String) { 107 transientErrorMessage = message 108 } 109 110 private func isSuccessfulSyncCompletion(_ event: NSPersistentCloudKitContainer.Event) -> Bool { 111 guard event.endDate != nil else { return false } 112 113 switch event.type { 114 case .import, .export: 115 return true 116 case .setup: 117 return false 118 @unknown default: 119 return false 120 } 121 } 122 123 private func eventTypeName(_ type: NSPersistentCloudKitContainer.EventType) -> String { 124 switch type { 125 case .setup: 126 return "setup" 127 case .import: 128 return "import" 129 case .export: 130 return "export" 131 @unknown default: 132 return "unknown" 133 } 134 } 135 136 private func logCloudKitError(_ error: Error) { 137 let nsError = error as NSError 138 lastCloudKitErrorDomain = nsError.domain 139 lastCloudKitErrorCode = nsError.code 140 lastCloudKitErrorDescription = nsError.localizedDescription 141 let message = 142 "CloudKit event error: domain=\(nsError.domain) code=\(nsError.code) description=\(nsError.localizedDescription) userInfo=\(String(describing: nsError.userInfo))" 143 logger.error( 144 """ 145 \(message, privacy: .public) 146 """ 147 ) 148 appendDiagnostic(level: "error", message) 149 } 150 151 private func appendDiagnostic(level: String, _ message: String) { 152 recentDiagnostics.append(SyncDiagnosticEntry(timestamp: Date(), level: level, message: message)) 153 if recentDiagnostics.count > maxDiagnosticEntries { 154 recentDiagnostics.removeFirst(recentDiagnostics.count - maxDiagnosticEntries) 155 } 156 } 157 }