CloudDiagnostics.swift (9545B)
1 import CloudKit 2 import Foundation 3 4 extension SyncEngine { 5 struct DiagnosticSnapshot: Sendable { 6 let accountStatus: CKAccountStatus 7 let engineRunning: Bool 8 let pendingChangesCount: Int 9 let privatePendingCount: Int 10 let sharedPendingCount: Int 11 } 12 13 /// Record names of pending `.saveRecord` changes queued on the given 14 /// scope's engine. Used by tests to verify that outbound enqueues route 15 /// to the correct database. 16 func pendingSaveRecordNames(scope: CKDatabase.Scope) -> [String] { 17 let engine = scope == .shared ? sharedEngine : privateEngine 18 guard let engine else { return [] } 19 return engine.state.pendingRecordZoneChanges.compactMap { 20 if case .saveRecord(let id) = $0 { return id.recordName } 21 return nil 22 } 23 } 24 25 /// Zone names queued for deletion on the given scope's engine. Used by 26 /// tests to verify delete routing after the local GameEntity is gone. 27 func pendingDeletedZoneNames(scope: CKDatabase.Scope) -> [String] { 28 let engine = scope == .shared ? sharedEngine : privateEngine 29 guard let engine else { return [] } 30 return engine.state.pendingDatabaseChanges.compactMap { 31 if case .deleteZone(let id) = $0 { return id.zoneName } 32 return nil 33 } 34 } 35 36 func diagnosticSnapshot() async -> DiagnosticSnapshot { 37 let status: CKAccountStatus 38 do { status = try await container.accountStatus() } 39 catch { status = .couldNotDetermine } 40 let running = privateEngine != nil 41 let privateCount = privateEngine.map { $0.state.pendingRecordZoneChanges.count } ?? 0 42 let sharedCount = sharedEngine.map { $0.state.pendingRecordZoneChanges.count } ?? 0 43 return DiagnosticSnapshot( 44 accountStatus: status, 45 engineRunning: running, 46 pendingChangesCount: privateCount + sharedCount, 47 privatePendingCount: privateCount, 48 sharedPendingCount: sharedCount 49 ) 50 } 51 52 /// Runs a series of lightweight CloudKit probes and returns human-readable 53 /// (name, result) pairs for display in the diagnostics view. 54 func probeContainer() async -> [(name: String, result: String)] { 55 var results: [(String, String)] = [] 56 results.append(("containerIdentifier", container.containerIdentifier ?? "nil")) 57 do { 58 let s = try await container.accountStatus() 59 results.append(("accountStatus", describeStatus(s))) 60 } catch { 61 results.append(("accountStatus", describe(error))) 62 } 63 do { 64 let id = try await container.userRecordID() 65 results.append(("userRecordID", id.recordName)) 66 } catch { 67 results.append(("userRecordID", describe(error))) 68 } 69 do { 70 let zones = try await container.privateCloudDatabase.allRecordZones() 71 let names = zones.map(\.zoneID.zoneName).joined(separator: ", ") 72 results.append(("privateZones", "\(zones.count) zone(s): [\(names)]")) 73 } catch { 74 results.append(("privateZones", describe(error))) 75 } 76 do { 77 let zones = try await container.sharedCloudDatabase.allRecordZones() 78 let names = zones.map(\.zoneID.zoneName).joined(separator: ", ") 79 results.append(("sharedZones", "\(zones.count) zone(s): [\(names)]")) 80 } catch { 81 results.append(("sharedZones", describe(error))) 82 } 83 // CKSyncEngine creates a CKDatabaseSubscription per scope on first 84 // start. If subscription creation silently failed, no push will ever 85 // fire for that scope — surface what's actually present so a missing 86 // entry is visible from the diagnostics view rather than diagnosed 87 // by elimination. 88 results.append(await probeSubscriptions(database: container.privateCloudDatabase, label: "privateSubs")) 89 results.append(await probeSubscriptions(database: container.sharedCloudDatabase, label: "sharedSubs")) 90 return results 91 } 92 93 private func probeSubscriptions( 94 database: CKDatabase, 95 label: String 96 ) async -> (String, String) { 97 do { 98 let subs = try await database.allSubscriptions() 99 if subs.isEmpty { 100 return (label, "0 subscriptions — pushes will not fire") 101 } 102 let descriptions = subs.map { sub -> String in 103 let kind: String 104 switch sub { 105 case is CKDatabaseSubscription: kind = "database" 106 case is CKQuerySubscription: kind = "query" 107 case is CKRecordZoneSubscription: kind = "zone" 108 default: kind = "other(\(type(of: sub)))" 109 } 110 let silent = sub.notificationInfo?.shouldSendContentAvailable == true ? "silent" : "alert-only" 111 return "\(kind):\(sub.subscriptionID)[\(silent)]" 112 } 113 return (label, "\(subs.count): [\(descriptions.joined(separator: ", "))]") 114 } catch { 115 return (label, describe(error)) 116 } 117 } 118 119 /// Fetches a single record by ID for the in-app record editor. Bypasses 120 /// CKSyncEngine's tracked changes — caller is responsible for triggering a 121 /// reconciling fetch if the record corresponds to a tracked local entity. 122 func fetchRecordForEdit( 123 scope: CKDatabase.Scope, 124 recordID: CKRecord.ID 125 ) async throws -> CKRecord { 126 let database = scope == .shared 127 ? container.sharedCloudDatabase 128 : container.privateCloudDatabase 129 return try await database.record(for: recordID) 130 } 131 132 /// Saves a record edited in the in-app record editor and runs a follow-up 133 /// `fetchChanges` so any locally-tracked entity picks up the new server 134 /// change tag via CKSyncEngine rather than going stale. 135 func saveRecordForEdit( 136 scope: CKDatabase.Scope, 137 record: CKRecord 138 ) async throws -> CKRecord { 139 let database = scope == .shared 140 ? container.sharedCloudDatabase 141 : container.privateCloudDatabase 142 let saved = try await database.save(record) 143 try? await fetchChanges(source: "record-editor") 144 return saved 145 } 146 147 /// Lists every zone in the given scope's database for the record editor. 148 /// Used to populate the zone picker without forcing the user to remember 149 /// raw zone names — important on Production builds where there is no 150 /// Console access. 151 func listZonesForEdit(scope: CKDatabase.Scope) async throws -> [CKRecordZone.ID] { 152 let database = scope == .shared 153 ? container.sharedCloudDatabase 154 : container.privateCloudDatabase 155 let zones = try await database.allRecordZones() 156 return zones 157 .map(\.zoneID) 158 .sorted { lhs, rhs in 159 if lhs.zoneName == rhs.zoneName { return lhs.ownerName < rhs.ownerName } 160 return lhs.zoneName < rhs.zoneName 161 } 162 } 163 164 /// Enumerates records of a given type in a single zone, capped at 165 /// `limit`. Client-side sort by `modificationDate` descending — newest 166 /// first matches how the dedupe/overlap machinery thinks about 167 /// time-ordered records. A single page is enough for ad-hoc debugging; 168 /// raise `limit` if a future caller needs deeper history. 169 func queryRecordsForEdit( 170 scope: CKDatabase.Scope, 171 zoneID: CKRecordZone.ID, 172 recordType: CKRecord.RecordType, 173 limit: Int = 100 174 ) async throws -> [CKRecord] { 175 let database = scope == .shared 176 ? container.sharedCloudDatabase 177 : container.privateCloudDatabase 178 let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true)) 179 let result = try await database.records( 180 matching: query, 181 inZoneWith: zoneID, 182 resultsLimit: limit 183 ) 184 let records = result.matchResults.compactMap { _, recordResult in 185 try? recordResult.get() 186 } 187 return records.sorted { 188 ($0.modificationDate ?? .distantPast) > ($1.modificationDate ?? .distantPast) 189 } 190 } 191 192 /// Deletes a record by ID via the in-app record editor. Matches the 193 /// editor's bypass-CKSyncEngine pattern; callers that maintain local 194 /// state for the record are responsible for reconciling separately. 195 func deleteRecordForEdit( 196 scope: CKDatabase.Scope, 197 recordID: CKRecord.ID 198 ) async throws { 199 let database = scope == .shared 200 ? container.sharedCloudDatabase 201 : container.privateCloudDatabase 202 _ = try await database.deleteRecord(withID: recordID) 203 try? await fetchChanges(source: "record-editor-delete") 204 } 205 206 nonisolated func describe(_ error: Error) -> String { 207 let nsError = error as NSError 208 return "ERROR domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)" 209 } 210 211 private nonisolated func describeStatus(_ status: CKAccountStatus) -> String { 212 switch status { 213 case .available: return "available" 214 case .noAccount: return "noAccount" 215 case .restricted: return "restricted" 216 case .couldNotDetermine: return "couldNotDetermine" 217 case .temporarilyUnavailable: return "temporarilyUnavailable" 218 @unknown default: return "unknown(\(status.rawValue))" 219 } 220 } 221 }