← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


Listless/Sync/CloudKitSyncMonitor.swift (6144B)

1import CoreData
2import Foundation
3import os
4
5struct SyncDiagnosticEntry: Identifiable {
6    let id = UUID()
7    let timestamp: Date
8    let level: String
9    let message: String
10}
11
12@MainActor
13final 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}