crossmate

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

CloudDiagnostics.swift (21075B)


      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 production container in the app's current
    162     /// entitlements, then inventories the separate iCloud Documents container.
    163     /// This is an account-scoped alternative to CloudKit Console's Act As iCloud
    164     /// workflow for a TestFlight build.
    165     ///
    166     /// The byte count is diagnostic rather than billing-exact: CKAsset file
    167     /// lengths and inline String/Data payloads are measurable, but CloudKit does
    168     /// not expose its record, share, zone, encryption, or retained-server-state
    169     /// overhead. Nothing is saved, changed, or deleted.
    170     func auditPrivateCloudStorage(
    171         progress: @MainActor @Sendable (String) -> Void
    172     ) async {
    173         let containers: [(label: String, container: CKContainer)] = [
    174             ("v4", container),
    175             ("v3", CloudContainer.legacyContainer),
    176             ("original", CloudContainer.originalContainer),
    177         ]
    178         await progress(
    179             "storage audit: starting accessible production containers " +
    180             "[v4, v3, original]; v2 is not entitled and will not be queried"
    181         )
    182 
    183         var grandTotals = StorageAuditTotals()
    184         for source in containers {
    185             if let totals = await auditPrivateCloudStorage(
    186                 label: source.label,
    187                 container: source.container,
    188                 progress: progress
    189             ) {
    190                 grandTotals.merge(totals)
    191             }
    192         }
    193 
    194         await logStorageTotals(
    195             grandTotals,
    196             prefix: "storage audit accessible CloudKit grand totals",
    197             includeLargest: true,
    198             progress: progress
    199         )
    200         await auditICloudDocuments(progress: progress)
    201         await progress(
    202             "storage audit complete: measured bytes exclude CloudKit metadata, shares, " +
    203             "zone overhead, encryption overhead, server-retained state, and v2"
    204         )
    205     }
    206 
    207     private func auditPrivateCloudStorage(
    208         label: String,
    209         container: CKContainer,
    210         progress: @MainActor @Sendable (String) -> Void
    211     ) async -> StorageAuditTotals? {
    212         let identifier = container.containerIdentifier ?? "unknown"
    213         let database = container.privateCloudDatabase
    214         await progress("storage audit container \(label) [\(identifier)]: starting private database")
    215 
    216         let zones: [CKRecordZone]
    217         do {
    218             zones = try await database.allRecordZones()
    219         } catch {
    220             await progress(
    221                 "storage audit container \(label): couldn't list private zones — \(describe(error))"
    222             )
    223             return nil
    224         }
    225 
    226         let defaultZoneID = CKRecordZone.default().zoneID
    227         let customZones = zones
    228             .map(\.zoneID)
    229             .filter { $0 != defaultZoneID }
    230             .sorted { $0.zoneName < $1.zoneName }
    231         await progress(
    232             "storage audit container \(label): found \(customZones.count) custom zone(s); " +
    233             "asset contents will be downloaded"
    234         )
    235 
    236         var totals = StorageAuditTotals()
    237         for (index, zoneID) in customZones.enumerated() {
    238             do {
    239                 let zone = try await auditStorageZone(zoneID, containerLabel: label, in: database)
    240                 var completedZone = zone
    241                 completedZone.zoneCount = 1
    242                 totals.merge(completedZone)
    243 
    244                 let types = formattedCounts(zone.recordCounts)
    245                 let title = zone.title.map { " title=[\(singleLine($0))]" } ?? ""
    246                 await progress(
    247                     "storage audit container \(label) zone \(index + 1)/\(customZones.count) " +
    248                     "[\(zoneID.zoneName)]:\(title) records=\(zone.recordCount) " +
    249                     "assets=\(zone.assetCount) assetBytes=\(formatBytes(zone.assetBytes)) " +
    250                     "inline=\(formatBytes(zone.inlineBytes)) " +
    251                     "types=[\(types)]"
    252                 )
    253             } catch {
    254                 await progress(
    255                     "storage audit container \(label) zone \(index + 1)/\(customZones.count) " +
    256                     "[\(zoneID.zoneName)] FAILED — \(describe(error))"
    257                 )
    258             }
    259         }
    260 
    261         await logStorageTotals(
    262             totals,
    263             prefix: "storage audit container \(label) totals",
    264             includeLargest: false,
    265             progress: progress
    266         )
    267         return totals
    268     }
    269 
    270     private func logStorageTotals(
    271         _ totals: StorageAuditTotals,
    272         prefix: String,
    273         includeLargest: Bool,
    274         progress: @MainActor @Sendable (String) -> Void
    275     ) async {
    276         await progress(
    277             "\(prefix): zones=\(totals.zoneCount) " +
    278             "records=\(totals.recordCount) " +
    279             "assets=\(totals.assetCount) assetBytes=\(formatBytes(totals.assetBytes)) " +
    280             "inline=\(formatBytes(totals.inlineBytes)) " +
    281             "measured=\(formatBytes(totals.assetBytes + totals.inlineBytes))"
    282         )
    283         await progress("\(prefix) record types: [\(formattedCounts(totals.recordCounts))]")
    284 
    285         for field in totals.assetFields.keys.sorted() {
    286             guard let value = totals.assetFields[field] else { continue }
    287             await progress(
    288                 "\(prefix) asset field \(field): count=\(value.count) " +
    289                 "bytes=\(formatBytes(value.bytes)) unavailable=\(value.unavailableCount)"
    290             )
    291         }
    292 
    293         guard includeLargest else {
    294             if totals.unavailableAssetCount > 0 || totals.recordErrorCount > 0 {
    295                 await logStorageWarnings(totals, prefix: prefix, progress: progress)
    296             }
    297             return
    298         }
    299 
    300         let largest = totals.assets
    301             .sorted { $0.bytes > $1.bytes }
    302             .prefix(20)
    303         for (index, asset) in largest.enumerated() {
    304             await progress(
    305                 "\(prefix) largest #\(index + 1): \(asset.recordType).\(asset.field) " +
    306                 "bytes=\(formatBytes(asset.bytes)) container=\(asset.containerLabel) " +
    307                 "zone=\(asset.zoneName) " +
    308                 "record=\(asset.recordName)"
    309             )
    310         }
    311 
    312         if totals.unavailableAssetCount > 0 || totals.recordErrorCount > 0 {
    313             await logStorageWarnings(totals, prefix: prefix, progress: progress)
    314         }
    315     }
    316 
    317     private func logStorageWarnings(
    318         _ totals: StorageAuditTotals,
    319         prefix: String,
    320         progress: @MainActor @Sendable (String) -> Void
    321     ) async {
    322         await progress(
    323             "\(prefix) warnings: unavailableAssets=\(totals.unavailableAssetCount) " +
    324             "recordErrors=\(totals.recordErrorCount)"
    325         )
    326     }
    327 
    328     private func auditICloudDocuments(
    329         progress: @MainActor @Sendable (String) -> Void
    330     ) async {
    331         guard let root = FileManager.default.url(
    332             forUbiquityContainerIdentifier: CloudContainer.originalIdentifier
    333         ) else {
    334             await progress("storage audit iCloud Documents: container unavailable")
    335             return
    336         }
    337         let documents = root.appendingPathComponent("Documents", isDirectory: true)
    338         let totals = Self.scanICloudDocuments(at: documents)
    339 
    340         await progress(
    341             "storage audit iCloud Documents totals: files=\(totals.fileCount) " +
    342             "bytes=\(formatBytes(totals.bytes)) unavailable=\(totals.unavailableCount)"
    343         )
    344         for (index, file) in totals.files.sorted(by: { $0.bytes > $1.bytes }).prefix(20).enumerated() {
    345             await progress(
    346                 "storage audit iCloud Documents largest #\(index + 1): " +
    347                 "bytes=\(formatBytes(file.bytes)) path=[\(singleLine(file.path))]"
    348             )
    349         }
    350     }
    351 
    352     private nonisolated static func scanICloudDocuments(
    353         at documents: URL
    354     ) -> DocumentsAuditTotals {
    355         let keys: [URLResourceKey] = [.isRegularFileKey, .fileSizeKey]
    356         guard let enumerator = FileManager.default.enumerator(
    357             at: documents,
    358             includingPropertiesForKeys: keys,
    359             options: [.skipsHiddenFiles, .skipsPackageDescendants]
    360         ) else {
    361             return DocumentsAuditTotals(unavailableCount: 1)
    362         }
    363 
    364         var totals = DocumentsAuditTotals()
    365         for case let url as URL in enumerator {
    366             guard let values = try? url.resourceValues(forKeys: Set(keys)),
    367                   values.isRegularFile == true
    368             else { continue }
    369             totals.fileCount += 1
    370             if let size = values.fileSize {
    371                 let bytes = Int64(size)
    372                 totals.bytes += bytes
    373                 totals.files.append(
    374                     DocumentsAuditFile(
    375                         path: url.path.replacingOccurrences(of: documents.path + "/", with: ""),
    376                         bytes: bytes
    377                     )
    378                 )
    379             } else {
    380                 totals.unavailableCount += 1
    381             }
    382         }
    383 
    384         return totals
    385     }
    386 
    387     private func auditStorageZone(
    388         _ zoneID: CKRecordZone.ID,
    389         containerLabel: String,
    390         in database: CKDatabase
    391     ) async throws -> StorageAuditTotals {
    392         var totals = StorageAuditTotals()
    393         var token: CKServerChangeToken?
    394         var moreComing = true
    395 
    396         while moreComing {
    397             let page = try await database.recordZoneChanges(
    398                 inZoneWith: zoneID,
    399                 since: token
    400             )
    401             token = page.changeToken
    402             moreComing = page.moreComing
    403 
    404             for result in page.modificationResultsByID.values {
    405                 do {
    406                     let record = try result.get().record
    407                     measure(record, containerLabel: containerLabel, into: &totals)
    408                 } catch {
    409                     totals.recordErrorCount += 1
    410                 }
    411             }
    412         }
    413         return totals
    414     }
    415 
    416     private func measure(
    417         _ record: CKRecord,
    418         containerLabel: String,
    419         into totals: inout StorageAuditTotals
    420     ) {
    421         totals.recordCount += 1
    422         totals.recordCounts[record.recordType, default: 0] += 1
    423         if totals.title == nil,
    424            record.recordType == "Game"
    425             || record.recordType == Archive.recordType
    426             || record.recordType == Archive.legacyRecordType {
    427             totals.title = record["title"] as? String
    428         }
    429 
    430         for field in record.allKeys() {
    431             guard let value = record[field] else { continue }
    432             if let asset = value as? CKAsset {
    433                 let key = "\(record.recordType).\(field)"
    434                 var fieldTotal = totals.assetFields[key, default: StorageAuditFieldTotal()]
    435                 fieldTotal.count += 1
    436                 if let bytes = asset.fileURL.flatMap(assetFileSize) {
    437                     fieldTotal.bytes += bytes
    438                     totals.assets.append(
    439                         StorageAuditAsset(
    440                             containerLabel: containerLabel,
    441                             recordType: record.recordType,
    442                             field: field,
    443                             recordName: record.recordID.recordName,
    444                             zoneName: record.recordID.zoneID.zoneName,
    445                             bytes: bytes
    446                         )
    447                     )
    448                 } else {
    449                     fieldTotal.unavailableCount += 1
    450                     totals.unavailableAssetCount += 1
    451                 }
    452                 totals.assetFields[key] = fieldTotal
    453             } else {
    454                 totals.inlineBytes += approximateInlineSize(value)
    455             }
    456         }
    457     }
    458 
    459     private func assetFileSize(at url: URL) -> Int64? {
    460         guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
    461               let size = attributes[.size] as? NSNumber
    462         else { return nil }
    463         return size.int64Value
    464     }
    465 
    466     private func approximateInlineSize(_ value: Any) -> Int64 {
    467         switch value {
    468         case let data as Data:
    469             return Int64(data.count)
    470         case let string as String:
    471             return Int64(string.utf8.count)
    472         case let values as [Any]:
    473             return values.reduce(0) { $0 + approximateInlineSize($1) }
    474         case is NSNumber, is Date:
    475             return 8
    476         case let reference as CKRecord.Reference:
    477             return Int64(reference.recordID.recordName.utf8.count)
    478         default:
    479             return 0
    480         }
    481     }
    482 
    483     private func formattedCounts(_ counts: [String: Int]) -> String {
    484         counts.keys.sorted().compactMap { key in
    485             counts[key].map { "\(key)=\($0)" }
    486         }.joined(separator: ", ")
    487     }
    488 
    489     private func formatBytes(_ bytes: Int64) -> String {
    490         "\(ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)) (\(bytes) B)"
    491     }
    492 
    493     private func singleLine(_ value: String) -> String {
    494         String(value.replacing(/\s+/, with: " ").prefix(80))
    495     }
    496 
    497     private func probeSubscriptions(
    498         database: CKDatabase,
    499         label: String
    500     ) async -> (String, String) {
    501         do {
    502             let subs = try await database.allSubscriptions()
    503             if subs.isEmpty {
    504                 return (label, "0 subscriptions — pushes will not fire")
    505             }
    506             let descriptions = subs.map { sub -> String in
    507                 let kind: String
    508                 switch sub {
    509                 case is CKDatabaseSubscription: kind = "database"
    510                 case is CKQuerySubscription: kind = "query"
    511                 case is CKRecordZoneSubscription: kind = "zone"
    512                 default: kind = "other(\(type(of: sub)))"
    513                 }
    514                 let silent = sub.notificationInfo?.shouldSendContentAvailable == true ? "silent" : "alert-only"
    515                 return "\(kind):\(sub.subscriptionID)[\(silent)]"
    516             }
    517             return (label, "\(subs.count): [\(descriptions.joined(separator: ", "))]")
    518         } catch {
    519             return (label, describe(error))
    520         }
    521     }
    522 
    523     nonisolated func describe(_ error: Error) -> String {
    524         let nsError = error as NSError
    525         return "ERROR domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)"
    526     }
    527 
    528     private nonisolated func describeStatus(_ status: CKAccountStatus) -> String {
    529         switch status {
    530         case .available: return "available"
    531         case .noAccount: return "noAccount"
    532         case .restricted: return "restricted"
    533         case .couldNotDetermine: return "couldNotDetermine"
    534         case .temporarilyUnavailable: return "temporarilyUnavailable"
    535         @unknown default: return "unknown(\(status.rawValue))"
    536         }
    537     }
    538 }