crossmate

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

CloudDiagnostics.swift (26626B)


      1 import CloudKit
      2 import Foundation
      3 
      4 extension SyncEngine {
      5     private struct StorageAuditAsset {
      6         let containerLabel: String
      7         let recordType: String
      8         let field: String
      9         let recordName: String
     10         let zoneName: String
     11         let bytes: Int64
     12     }
     13 
     14     private struct StorageAuditFieldTotal {
     15         var count = 0
     16         var bytes: Int64 = 0
     17         var unavailableCount = 0
     18     }
     19 
     20     private struct StorageAuditTotals {
     21         var zoneCount = 0
     22         var recordCount = 0
     23         var title: String?
     24         var recordCounts: [String: Int] = [:]
     25         var assetFields: [String: StorageAuditFieldTotal] = [:]
     26         var inlineBytes: Int64 = 0
     27         var unavailableAssetCount = 0
     28         var recordErrorCount = 0
     29         var assets: [StorageAuditAsset] = []
     30 
     31         var assetBytes: Int64 {
     32             assetFields.values.reduce(0) { $0 + $1.bytes }
     33         }
     34 
     35         var assetCount: Int {
     36             assetFields.values.reduce(0) { $0 + $1.count }
     37         }
     38 
     39         mutating func merge(_ other: StorageAuditTotals) {
     40             zoneCount += other.zoneCount
     41             recordCount += other.recordCount
     42             inlineBytes += other.inlineBytes
     43             unavailableAssetCount += other.unavailableAssetCount
     44             recordErrorCount += other.recordErrorCount
     45             assets.append(contentsOf: other.assets)
     46             for (type, count) in other.recordCounts {
     47                 recordCounts[type, default: 0] += count
     48             }
     49             for (field, value) in other.assetFields {
     50                 assetFields[field, default: StorageAuditFieldTotal()].count += value.count
     51                 assetFields[field, default: StorageAuditFieldTotal()].bytes += value.bytes
     52                 assetFields[field, default: StorageAuditFieldTotal()].unavailableCount +=
     53                     value.unavailableCount
     54             }
     55         }
     56     }
     57 
     58     private struct DocumentsAuditFile: Sendable {
     59         let path: String
     60         let bytes: Int64
     61     }
     62 
     63     private struct DocumentsAuditTotals: Sendable {
     64         var fileCount = 0
     65         var bytes: Int64 = 0
     66         var unavailableCount = 0
     67         var files: [DocumentsAuditFile] = []
     68     }
     69 
     70     struct DiagnosticSnapshot: Sendable {
     71         let accountStatus: CKAccountStatus
     72         let engineRunning: Bool
     73         let pendingChangesCount: Int
     74         let privatePendingCount: Int
     75         let sharedPendingCount: Int
     76         let pendingInvitationCount: Int
     77     }
     78 
     79     /// Record names of pending `.saveRecord` changes queued on the given
     80     /// scope's engine. Used by tests to verify that outbound enqueues route
     81     /// to the correct database.
     82     func pendingSaveRecordNames(scope: CKDatabase.Scope) -> [String] {
     83         let engine = scope == .shared ? sharedEngine : privateEngine
     84         guard let engine else { return [] }
     85         return engine.state.pendingRecordZoneChanges.compactMap {
     86             if case .saveRecord(let id) = $0 { return id.recordName }
     87             return nil
     88         }
     89     }
     90 
     91     /// Zone names queued for deletion on the given scope's engine. Used by
     92     /// tests to verify delete routing after the local GameEntity is gone.
     93     func pendingDeletedZoneNames(scope: CKDatabase.Scope) -> [String] {
     94         let engine = scope == .shared ? sharedEngine : privateEngine
     95         guard let engine else { return [] }
     96         return engine.state.pendingDatabaseChanges.compactMap {
     97             if case .deleteZone(let id) = $0 { return id.zoneName }
     98             return nil
     99         }
    100     }
    101 
    102     func diagnosticSnapshot() async -> DiagnosticSnapshot {
    103         let status: CKAccountStatus
    104         do { status = try await container.accountStatus() }
    105         catch { status = .couldNotDetermine }
    106         let running = privateEngine != nil
    107         let privateCount = privateEngine.map { $0.state.pendingRecordZoneChanges.count } ?? 0
    108         let sharedCount = sharedEngine.map { $0.state.pendingRecordZoneChanges.count } ?? 0
    109         return DiagnosticSnapshot(
    110             accountStatus: status,
    111             engineRunning: running,
    112             pendingChangesCount: privateCount + sharedCount,
    113             privatePendingCount: privateCount,
    114             sharedPendingCount: sharedCount,
    115             pendingInvitationCount: pendingInvitationPingCount()
    116         )
    117     }
    118 
    119     /// Runs a series of lightweight CloudKit probes and returns human-readable
    120     /// (name, result) pairs for display in the diagnostics view.
    121     func probeContainer() async -> [(name: String, result: String)] {
    122         var results: [(String, String)] = []
    123         results.append(("containerIdentifier", container.containerIdentifier ?? "nil"))
    124         do {
    125             let s = try await container.accountStatus()
    126             results.append(("accountStatus", describeStatus(s)))
    127         } catch {
    128             results.append(("accountStatus", describe(error)))
    129         }
    130         do {
    131             let id = try await container.userRecordID()
    132             results.append(("userRecordID", id.recordName))
    133         } catch {
    134             results.append(("userRecordID", describe(error)))
    135         }
    136         do {
    137             let zones = try await container.privateCloudDatabase.allRecordZones()
    138             let names = zones.map(\.zoneID.zoneName).joined(separator: ", ")
    139             results.append(("privateZones", "\(zones.count) zone(s): [\(names)]"))
    140         } catch {
    141             results.append(("privateZones", describe(error)))
    142         }
    143         do {
    144             let zones = try await container.sharedCloudDatabase.allRecordZones()
    145             let names = zones.map(\.zoneID.zoneName).joined(separator: ", ")
    146             results.append(("sharedZones", "\(zones.count) zone(s): [\(names)]"))
    147         } catch {
    148             results.append(("sharedZones", describe(error)))
    149         }
    150         // CKSyncEngine creates a CKDatabaseSubscription per scope on first
    151         // start. If subscription creation silently failed, no push will ever
    152         // fire for that scope — surface what's actually present so a missing
    153         // entry is visible from the diagnostics view rather than diagnosed
    154         // by elimination.
    155         results.append(await probeSubscriptions(database: container.privateCloudDatabase, label: "privateSubs"))
    156         results.append(await probeSubscriptions(database: container.sharedCloudDatabase, label: "sharedSubs"))
    157         return results
    158     }
    159 
    160     /// Downloads the current records in every custom zone owned by this iCloud
    161     /// account across every container in the app's current entitlements, then
    162     /// inventories the separate iCloud Documents container. This is an
    163     /// account-scoped alternative to CloudKit Console's Act As iCloud workflow
    164     /// for a TestFlight build.
    165     ///
    166     /// Superseded containers are audited alongside the current one because
    167     /// abandoning a generation does not reclaim its records — they keep
    168     /// consuming the account's iCloud quota until something deletes their zones.
    169     ///
    170     /// The audit reads whichever CloudKit environment the build is signed for,
    171     /// and the two environments have entirely separate private databases, so the
    172     /// environment is logged first: an empty result means nothing without it.
    173     ///
    174     /// The byte count is diagnostic rather than billing-exact: CKAsset file
    175     /// lengths and inline String/Data payloads are measurable, but CloudKit does
    176     /// not expose its record, share, zone, encryption, or retained-server-state
    177     /// overhead. Nothing is saved, changed, or deleted.
    178     func auditPrivateCloudStorage(
    179         progress: @MainActor @Sendable (String) -> Void
    180     ) async {
    181         let containers: [(label: String, container: CKContainer)] = [
    182             ("v4", container),
    183             ("v3", CloudContainer.v3Container),
    184             ("v2", CloudContainer.v2Container),
    185             ("v1", CloudContainer.v1Container),
    186         ]
    187         let labels = containers.map(\.label).joined(separator: ", ")
    188         await progress(
    189             "storage audit: starting entitled containers [\(labels)] in the " +
    190             "\(cloudEnvironment()) CloudKit environment"
    191         )
    192 
    193         var grandTotals = StorageAuditTotals()
    194         var unreadable: [String] = []
    195         for source in containers {
    196             if let totals = await auditPrivateCloudStorage(
    197                 label: source.label,
    198                 container: source.container,
    199                 progress: progress
    200             ) {
    201                 grandTotals.merge(totals)
    202             } else {
    203                 unreadable.append(source.label)
    204             }
    205         }
    206 
    207         await logStorageTotals(
    208             grandTotals,
    209             prefix: "storage audit CloudKit grand totals",
    210             includeLargest: true,
    211             progress: progress
    212         )
    213         // A container that fails to list its zones contributes nothing to the
    214         // totals, which otherwise read as a complete account inventory. Say so
    215         // explicitly rather than letting the omission pass silently.
    216         if unreadable.isEmpty {
    217             await progress("storage audit coverage: every entitled container was read")
    218         } else {
    219             await progress(
    220                 "storage audit coverage INCOMPLETE: grand totals omit " +
    221                 "[\(unreadable.joined(separator: ", "))] — those containers failed to " +
    222                 "list zones, so any bytes they hold are unmeasured"
    223             )
    224         }
    225         await auditICloudDocuments(progress: progress)
    226         await progress(
    227             "storage audit complete: measured bytes exclude CloudKit metadata, shares, " +
    228             "zone overhead, encryption overhead, and server-retained state"
    229         )
    230     }
    231 
    232     /// The CloudKit environment this build talks to. `ICLOUD_ENVIRONMENT` feeds
    233     /// both the icloud-container-environment entitlement and this Info.plist
    234     /// key, so what the audit reports is what the entitlement selected. The
    235     /// entitlement itself is not readable at runtime on iOS — `SecTask` is
    236     /// macOS-only — which is why it travels through Info.plist instead.
    237     private nonisolated func cloudEnvironment() -> String {
    238         let value = Bundle.main.object(forInfoDictionaryKey: "CrossmateICloudEnvironment")
    239         guard let environment = value as? String, !environment.isEmpty else {
    240             return "unreported"
    241         }
    242         return environment
    243     }
    244 
    245     /// Deletes every custom zone this account holds in the abandoned v3
    246     /// container. The private default zone is left alone: the sync engine only
    247     /// ever wrote to custom zones, and CloudKit rejects change enumeration
    248     /// there outright ('AppDefaultZone does not support getChanges call'), so
    249     /// there is nothing to sweep and no cheap way to look.
    250     ///
    251     /// Abandoning a container generation does not reclaim its records — they
    252     /// consume the account's iCloud quota indefinitely — and v3 was abandoned
    253     /// rather than migrated, so nothing in the app reads it beyond the pre-v4
    254     /// data probe. This is irreversible, and the app holds no copy of what it
    255     /// removes.
    256     ///
    257     /// Returns `true` only when the container was swept clean, so a caller
    258     /// that records the work as done cannot mark a partial or offline run
    259     /// complete. It touches no container the sync engine uses, so it is safe
    260     /// to run alongside ordinary syncing.
    261     @discardableResult
    262     func deleteLegacyCloudData(
    263         progress: @MainActor @Sendable (String) -> Void
    264     ) async -> Bool {
    265         let container = CloudContainer.v3Container
    266         let identifier = container.containerIdentifier ?? "unknown"
    267         let database = container.privateCloudDatabase
    268         await progress(
    269             "legacy delete: starting v3 [\(identifier)] in the " +
    270             "\(cloudEnvironment()) CloudKit environment"
    271         )
    272 
    273         let zones: [CKRecordZone]
    274         do {
    275             zones = try await database.allRecordZones()
    276         } catch {
    277             await progress("legacy delete: couldn't list zones — \(describe(error)) — nothing deleted")
    278             return false
    279         }
    280 
    281         let defaultZoneID = CKRecordZone.default().zoneID
    282         let customZoneIDs = zones
    283             .map(\.zoneID)
    284             .filter { $0 != defaultZoneID }
    285             .sorted { $0.zoneName < $1.zoneName }
    286         await progress("legacy delete: \(customZoneIDs.count) custom zone(s) to remove")
    287 
    288         var deleted = 0
    289         var failed = 0
    290         // Batched so a single oversized request cannot fail the whole run, and
    291         // so a long delete reports progress rather than going quiet.
    292         for start in stride(from: 0, to: customZoneIDs.count, by: 20) {
    293             let batch = Array(customZoneIDs[start..<min(start + 20, customZoneIDs.count)])
    294             do {
    295                 let results = try await database.modifyRecordZones(
    296                     saving: [],
    297                     deleting: batch
    298                 )
    299                 for (zoneID, result) in results.deleteResults {
    300                     switch result {
    301                     case .success:
    302                         deleted += 1
    303                     case .failure(let error):
    304                         failed += 1
    305                         await progress(
    306                             "legacy delete: zone [\(zoneID.zoneName)] FAILED — \(describe(error))"
    307                         )
    308                     }
    309                 }
    310             } catch {
    311                 failed += batch.count
    312                 await progress("legacy delete: batch of \(batch.count) FAILED — \(describe(error))")
    313             }
    314             await progress(
    315                 "legacy delete: \(deleted) deleted, \(failed) failed of \(customZoneIDs.count)"
    316             )
    317         }
    318 
    319         let swept = failed == 0
    320         await progress(
    321             "legacy delete complete: zonesDeleted=\(deleted) zonesFailed=\(failed) " +
    322             "swept=\(swept)"
    323         )
    324         return swept
    325     }
    326 
    327     private func auditPrivateCloudStorage(
    328         label: String,
    329         container: CKContainer,
    330         progress: @MainActor @Sendable (String) -> Void
    331     ) async -> StorageAuditTotals? {
    332         let identifier = container.containerIdentifier ?? "unknown"
    333         let database = container.privateCloudDatabase
    334         await progress("storage audit container \(label) [\(identifier)]: starting private database")
    335 
    336         let zones: [CKRecordZone]
    337         do {
    338             zones = try await database.allRecordZones()
    339         } catch {
    340             await progress(
    341                 "storage audit container \(label): couldn't list private zones — \(describe(error))"
    342             )
    343             return nil
    344         }
    345 
    346         let defaultZoneID = CKRecordZone.default().zoneID
    347         let customZones = zones
    348             .map(\.zoneID)
    349             .filter { $0 != defaultZoneID }
    350             .sorted { $0.zoneName < $1.zoneName }
    351         await progress(
    352             "storage audit container \(label): found \(customZones.count) custom zone(s); " +
    353             "asset contents will be downloaded"
    354         )
    355 
    356         var totals = StorageAuditTotals()
    357         for (index, zoneID) in customZones.enumerated() {
    358             do {
    359                 let zone = try await auditStorageZone(zoneID, containerLabel: label, in: database)
    360                 var completedZone = zone
    361                 completedZone.zoneCount = 1
    362                 totals.merge(completedZone)
    363 
    364                 let types = formattedCounts(zone.recordCounts)
    365                 let title = zone.title.map { " title=[\(singleLine($0))]" } ?? ""
    366                 await progress(
    367                     "storage audit container \(label) zone \(index + 1)/\(customZones.count) " +
    368                     "[\(zoneID.zoneName)]:\(title) records=\(zone.recordCount) " +
    369                     "assets=\(zone.assetCount) assetBytes=\(formatBytes(zone.assetBytes)) " +
    370                     "inline=\(formatBytes(zone.inlineBytes)) " +
    371                     "types=[\(types)]"
    372                 )
    373             } catch {
    374                 await progress(
    375                     "storage audit container \(label) zone \(index + 1)/\(customZones.count) " +
    376                     "[\(zoneID.zoneName)] FAILED — \(describe(error))"
    377                 )
    378             }
    379         }
    380 
    381         await logStorageTotals(
    382             totals,
    383             prefix: "storage audit container \(label) totals",
    384             includeLargest: false,
    385             progress: progress
    386         )
    387         return totals
    388     }
    389 
    390     private func logStorageTotals(
    391         _ totals: StorageAuditTotals,
    392         prefix: String,
    393         includeLargest: Bool,
    394         progress: @MainActor @Sendable (String) -> Void
    395     ) async {
    396         await progress(
    397             "\(prefix): zones=\(totals.zoneCount) " +
    398             "records=\(totals.recordCount) " +
    399             "assets=\(totals.assetCount) assetBytes=\(formatBytes(totals.assetBytes)) " +
    400             "inline=\(formatBytes(totals.inlineBytes)) " +
    401             "measured=\(formatBytes(totals.assetBytes + totals.inlineBytes))"
    402         )
    403         await progress("\(prefix) record types: [\(formattedCounts(totals.recordCounts))]")
    404 
    405         for field in totals.assetFields.keys.sorted() {
    406             guard let value = totals.assetFields[field] else { continue }
    407             await progress(
    408                 "\(prefix) asset field \(field): count=\(value.count) " +
    409                 "bytes=\(formatBytes(value.bytes)) unavailable=\(value.unavailableCount)"
    410             )
    411         }
    412 
    413         guard includeLargest else {
    414             if totals.unavailableAssetCount > 0 || totals.recordErrorCount > 0 {
    415                 await logStorageWarnings(totals, prefix: prefix, progress: progress)
    416             }
    417             return
    418         }
    419 
    420         let largest = totals.assets
    421             .sorted { $0.bytes > $1.bytes }
    422             .prefix(20)
    423         for (index, asset) in largest.enumerated() {
    424             await progress(
    425                 "\(prefix) largest #\(index + 1): \(asset.recordType).\(asset.field) " +
    426                 "bytes=\(formatBytes(asset.bytes)) container=\(asset.containerLabel) " +
    427                 "zone=\(asset.zoneName) " +
    428                 "record=\(asset.recordName)"
    429             )
    430         }
    431 
    432         if totals.unavailableAssetCount > 0 || totals.recordErrorCount > 0 {
    433             await logStorageWarnings(totals, prefix: prefix, progress: progress)
    434         }
    435     }
    436 
    437     private func logStorageWarnings(
    438         _ totals: StorageAuditTotals,
    439         prefix: String,
    440         progress: @MainActor @Sendable (String) -> Void
    441     ) async {
    442         await progress(
    443             "\(prefix) warnings: unavailableAssets=\(totals.unavailableAssetCount) " +
    444             "recordErrors=\(totals.recordErrorCount)"
    445         )
    446     }
    447 
    448     private func auditICloudDocuments(
    449         progress: @MainActor @Sendable (String) -> Void
    450     ) async {
    451         guard let root = FileManager.default.url(
    452             forUbiquityContainerIdentifier: CloudContainer.v1Identifier
    453         ) else {
    454             await progress("storage audit iCloud Documents: container unavailable")
    455             return
    456         }
    457         let documents = root.appendingPathComponent("Documents", isDirectory: true)
    458         let totals = Self.scanICloudDocuments(at: documents)
    459 
    460         await progress(
    461             "storage audit iCloud Documents totals: files=\(totals.fileCount) " +
    462             "bytes=\(formatBytes(totals.bytes)) unavailable=\(totals.unavailableCount)"
    463         )
    464         for (index, file) in totals.files.sorted(by: { $0.bytes > $1.bytes }).prefix(20).enumerated() {
    465             await progress(
    466                 "storage audit iCloud Documents largest #\(index + 1): " +
    467                 "bytes=\(formatBytes(file.bytes)) path=[\(singleLine(file.path))]"
    468             )
    469         }
    470     }
    471 
    472     private nonisolated static func scanICloudDocuments(
    473         at documents: URL
    474     ) -> DocumentsAuditTotals {
    475         let keys: [URLResourceKey] = [.isRegularFileKey, .fileSizeKey]
    476         guard let enumerator = FileManager.default.enumerator(
    477             at: documents,
    478             includingPropertiesForKeys: keys,
    479             options: [.skipsHiddenFiles, .skipsPackageDescendants]
    480         ) else {
    481             return DocumentsAuditTotals(unavailableCount: 1)
    482         }
    483 
    484         var totals = DocumentsAuditTotals()
    485         for case let url as URL in enumerator {
    486             guard let values = try? url.resourceValues(forKeys: Set(keys)),
    487                   values.isRegularFile == true
    488             else { continue }
    489             totals.fileCount += 1
    490             if let size = values.fileSize {
    491                 let bytes = Int64(size)
    492                 totals.bytes += bytes
    493                 totals.files.append(
    494                     DocumentsAuditFile(
    495                         path: url.path.replacingOccurrences(of: documents.path + "/", with: ""),
    496                         bytes: bytes
    497                     )
    498                 )
    499             } else {
    500                 totals.unavailableCount += 1
    501             }
    502         }
    503 
    504         return totals
    505     }
    506 
    507     private func auditStorageZone(
    508         _ zoneID: CKRecordZone.ID,
    509         containerLabel: String,
    510         in database: CKDatabase
    511     ) async throws -> StorageAuditTotals {
    512         var totals = StorageAuditTotals()
    513         var token: CKServerChangeToken?
    514         var moreComing = true
    515 
    516         while moreComing {
    517             let page = try await database.recordZoneChanges(
    518                 inZoneWith: zoneID,
    519                 since: token
    520             )
    521             token = page.changeToken
    522             moreComing = page.moreComing
    523 
    524             for result in page.modificationResultsByID.values {
    525                 do {
    526                     let record = try result.get().record
    527                     measure(record, containerLabel: containerLabel, into: &totals)
    528                 } catch {
    529                     totals.recordErrorCount += 1
    530                 }
    531             }
    532         }
    533         return totals
    534     }
    535 
    536     private func measure(
    537         _ record: CKRecord,
    538         containerLabel: String,
    539         into totals: inout StorageAuditTotals
    540     ) {
    541         totals.recordCount += 1
    542         totals.recordCounts[record.recordType, default: 0] += 1
    543         if totals.title == nil,
    544            record.recordType == "Game"
    545             || record.recordType == Archive.recordType
    546             || record.recordType == Archive.legacyRecordType {
    547             totals.title = record["title"] as? String
    548         }
    549 
    550         for field in record.allKeys() {
    551             guard let value = record[field] else { continue }
    552             if let asset = value as? CKAsset {
    553                 let key = "\(record.recordType).\(field)"
    554                 var fieldTotal = totals.assetFields[key, default: StorageAuditFieldTotal()]
    555                 fieldTotal.count += 1
    556                 if let bytes = asset.fileURL.flatMap(assetFileSize) {
    557                     fieldTotal.bytes += bytes
    558                     totals.assets.append(
    559                         StorageAuditAsset(
    560                             containerLabel: containerLabel,
    561                             recordType: record.recordType,
    562                             field: field,
    563                             recordName: record.recordID.recordName,
    564                             zoneName: record.recordID.zoneID.zoneName,
    565                             bytes: bytes
    566                         )
    567                     )
    568                 } else {
    569                     fieldTotal.unavailableCount += 1
    570                     totals.unavailableAssetCount += 1
    571                 }
    572                 totals.assetFields[key] = fieldTotal
    573             } else {
    574                 totals.inlineBytes += approximateInlineSize(value)
    575             }
    576         }
    577     }
    578 
    579     private func assetFileSize(at url: URL) -> Int64? {
    580         guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
    581               let size = attributes[.size] as? NSNumber
    582         else { return nil }
    583         return size.int64Value
    584     }
    585 
    586     private func approximateInlineSize(_ value: Any) -> Int64 {
    587         switch value {
    588         case let data as Data:
    589             return Int64(data.count)
    590         case let string as String:
    591             return Int64(string.utf8.count)
    592         case let values as [Any]:
    593             return values.reduce(0) { $0 + approximateInlineSize($1) }
    594         case is NSNumber, is Date:
    595             return 8
    596         case let reference as CKRecord.Reference:
    597             return Int64(reference.recordID.recordName.utf8.count)
    598         default:
    599             return 0
    600         }
    601     }
    602 
    603     private func formattedCounts(_ counts: [String: Int]) -> String {
    604         counts.keys.sorted().compactMap { key in
    605             counts[key].map { "\(key)=\($0)" }
    606         }.joined(separator: ", ")
    607     }
    608 
    609     private func formatBytes(_ bytes: Int64) -> String {
    610         "\(ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)) (\(bytes) B)"
    611     }
    612 
    613     private func singleLine(_ value: String) -> String {
    614         String(value.replacing(/\s+/, with: " ").prefix(80))
    615     }
    616 
    617     private func probeSubscriptions(
    618         database: CKDatabase,
    619         label: String
    620     ) async -> (String, String) {
    621         do {
    622             let subs = try await database.allSubscriptions()
    623             if subs.isEmpty {
    624                 return (label, "0 subscriptions — pushes will not fire")
    625             }
    626             let descriptions = subs.map { sub -> String in
    627                 let kind: String
    628                 switch sub {
    629                 case is CKDatabaseSubscription: kind = "database"
    630                 case is CKQuerySubscription: kind = "query"
    631                 case is CKRecordZoneSubscription: kind = "zone"
    632                 default: kind = "other(\(type(of: sub)))"
    633                 }
    634                 let silent = sub.notificationInfo?.shouldSendContentAvailable == true ? "silent" : "alert-only"
    635                 return "\(kind):\(sub.subscriptionID)[\(silent)]"
    636             }
    637             return (label, "\(subs.count): [\(descriptions.joined(separator: ", "))]")
    638         } catch {
    639             return (label, describe(error))
    640         }
    641     }
    642 
    643     nonisolated func describe(_ error: Error) -> String {
    644         let nsError = error as NSError
    645         return "ERROR domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)"
    646     }
    647 
    648     private nonisolated func describeStatus(_ status: CKAccountStatus) -> String {
    649         switch status {
    650         case .available: return "available"
    651         case .noAccount: return "noAccount"
    652         case .restricted: return "restricted"
    653         case .couldNotDetermine: return "couldNotDetermine"
    654         case .temporarilyUnavailable: return "temporarilyUnavailable"
    655         @unknown default: return "unknown(\(status.rawValue))"
    656         }
    657     }
    658 }