crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }