crossmate

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

commit 6e580a52de182ba41ec6b7c36ac3dfb566fbc1e7
parent 73edb19b291887f85dbd4d1127eab610dddc905b
Author: Michael Camilleri <[email protected]>
Date:   Wed, 22 Jul 2026 23:02:09 +0900

Add storage audit button to Debug section

Diffstat:
MCrossmate/Sync/CloudDiagnostics.swift | 240+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/Settings/DiagnosticsView.swift | 30+++++++++++++++++++++++++++---
2 files changed, 267 insertions(+), 3 deletions(-)

diff --git a/Crossmate/Sync/CloudDiagnostics.swift b/Crossmate/Sync/CloudDiagnostics.swift @@ -2,6 +2,36 @@ import CloudKit import Foundation extension SyncEngine { + private struct StorageAuditAsset { + let recordType: String + let field: String + let recordName: String + let zoneName: String + let bytes: Int64 + } + + private struct StorageAuditFieldTotal { + var count = 0 + var bytes: Int64 = 0 + var unavailableCount = 0 + } + + private struct StorageAuditTotals { + var zoneCount = 0 + var recordCount = 0 + var title: String? + var recordCounts: [String: Int] = [:] + var assetFields: [String: StorageAuditFieldTotal] = [:] + var inlineBytes: Int64 = 0 + var unavailableAssetCount = 0 + var recordErrorCount = 0 + var assets: [StorageAuditAsset] = [] + + var assetBytes: Int64 { + assetFields.values.reduce(0) { $0 + $1.bytes } + } + } + struct DiagnosticSnapshot: Sendable { let accountStatus: CKAccountStatus let engineRunning: Bool @@ -90,6 +120,216 @@ extension SyncEngine { return results } + /// Downloads the current records in every custom zone owned by this iCloud + /// account and writes a size inventory through `progress`. TestFlight builds + /// carry the Production CloudKit entitlement, making this an account-scoped + /// alternative to CloudKit Console's Act As iCloud workflow. + /// + /// The byte count is diagnostic rather than billing-exact: CKAsset file + /// lengths and inline String/Data payloads are measurable, but CloudKit does + /// not expose its record, share, zone, encryption, or retained-server-state + /// overhead. Nothing is saved, changed, or deleted. + func auditPrivateCloudStorage( + progress: @MainActor @Sendable (String) -> Void + ) async { + let database = container.privateCloudDatabase + await progress("storage audit: starting private database inventory") + + let zones: [CKRecordZone] + do { + zones = try await database.allRecordZones() + } catch { + await progress("storage audit: couldn't list private zones — \(describe(error))") + return + } + + let defaultZoneID = CKRecordZone.default().zoneID + let customZones = zones + .map(\.zoneID) + .filter { $0 != defaultZoneID } + .sorted { $0.zoneName < $1.zoneName } + await progress( + "storage audit: found \(customZones.count) custom zone(s); " + + "asset contents will be downloaded" + ) + + var totals = StorageAuditTotals() + for (index, zoneID) in customZones.enumerated() { + do { + let zone = try await auditStorageZone(zoneID, in: database) + totals.zoneCount += 1 + totals.recordCount += zone.recordCount + totals.inlineBytes += zone.inlineBytes + totals.unavailableAssetCount += zone.unavailableAssetCount + totals.recordErrorCount += zone.recordErrorCount + totals.assets.append(contentsOf: zone.assets) + for (type, count) in zone.recordCounts { + totals.recordCounts[type, default: 0] += count + } + for (field, value) in zone.assetFields { + totals.assetFields[field, default: StorageAuditFieldTotal()].count += value.count + totals.assetFields[field, default: StorageAuditFieldTotal()].bytes += value.bytes + totals.assetFields[field, default: StorageAuditFieldTotal()].unavailableCount += + value.unavailableCount + } + + let types = formattedCounts(zone.recordCounts) + let title = zone.title.map { " title=[\(singleLine($0))]" } ?? "" + await progress( + "storage audit zone \(index + 1)/\(customZones.count) " + + "[\(zoneID.zoneName)]:\(title) records=\(zone.recordCount) " + + "assets=\(formatBytes(zone.assetBytes)) inline=\(formatBytes(zone.inlineBytes)) " + + "types=[\(types)]" + ) + } catch { + await progress( + "storage audit zone \(index + 1)/\(customZones.count) " + + "[\(zoneID.zoneName)] FAILED — \(describe(error))" + ) + } + } + + await progress( + "storage audit totals: zones=\(totals.zoneCount)/\(customZones.count) " + + "records=\(totals.recordCount) assets=\(formatBytes(totals.assetBytes)) " + + "inline=\(formatBytes(totals.inlineBytes)) " + + "measured=\(formatBytes(totals.assetBytes + totals.inlineBytes))" + ) + await progress("storage audit record types: [\(formattedCounts(totals.recordCounts))]") + + for field in totals.assetFields.keys.sorted() { + guard let value = totals.assetFields[field] else { continue } + await progress( + "storage audit asset field \(field): count=\(value.count) " + + "bytes=\(formatBytes(value.bytes)) unavailable=\(value.unavailableCount)" + ) + } + + let largest = totals.assets + .sorted { $0.bytes > $1.bytes } + .prefix(20) + for (index, asset) in largest.enumerated() { + await progress( + "storage audit largest #\(index + 1): \(asset.recordType).\(asset.field) " + + "bytes=\(formatBytes(asset.bytes)) zone=\(asset.zoneName) " + + "record=\(asset.recordName)" + ) + } + + if totals.unavailableAssetCount > 0 || totals.recordErrorCount > 0 { + await progress( + "storage audit warnings: unavailableAssets=\(totals.unavailableAssetCount) " + + "recordErrors=\(totals.recordErrorCount)" + ) + } + await progress( + "storage audit complete: measured bytes exclude CloudKit metadata, shares, " + + "zone overhead, encryption overhead, and server-retained state" + ) + } + + private func auditStorageZone( + _ zoneID: CKRecordZone.ID, + in database: CKDatabase + ) async throws -> StorageAuditTotals { + var totals = StorageAuditTotals() + var token: CKServerChangeToken? + var moreComing = true + + while moreComing { + let page = try await database.recordZoneChanges( + inZoneWith: zoneID, + since: token + ) + token = page.changeToken + moreComing = page.moreComing + + for result in page.modificationResultsByID.values { + do { + let record = try result.get().record + measure(record, into: &totals) + } catch { + totals.recordErrorCount += 1 + } + } + } + return totals + } + + private func measure(_ record: CKRecord, into totals: inout StorageAuditTotals) { + totals.recordCount += 1 + totals.recordCounts[record.recordType, default: 0] += 1 + if totals.title == nil, + record.recordType == "Game" || record.recordType == Archive.recordType { + totals.title = record["title"] as? String + } + + for field in record.allKeys() { + guard let value = record[field] else { continue } + if let asset = value as? CKAsset { + let key = "\(record.recordType).\(field)" + var fieldTotal = totals.assetFields[key, default: StorageAuditFieldTotal()] + fieldTotal.count += 1 + if let bytes = asset.fileURL.flatMap(assetFileSize) { + fieldTotal.bytes += bytes + totals.assets.append( + StorageAuditAsset( + recordType: record.recordType, + field: field, + recordName: record.recordID.recordName, + zoneName: record.recordID.zoneID.zoneName, + bytes: bytes + ) + ) + } else { + fieldTotal.unavailableCount += 1 + totals.unavailableAssetCount += 1 + } + totals.assetFields[key] = fieldTotal + } else { + totals.inlineBytes += approximateInlineSize(value) + } + } + } + + private func assetFileSize(at url: URL) -> Int64? { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attributes[.size] as? NSNumber + else { return nil } + return size.int64Value + } + + private func approximateInlineSize(_ value: Any) -> Int64 { + switch value { + case let data as Data: + return Int64(data.count) + case let string as String: + return Int64(string.utf8.count) + case let values as [Any]: + return values.reduce(0) { $0 + approximateInlineSize($1) } + case is NSNumber, is Date: + return 8 + case let reference as CKRecord.Reference: + return Int64(reference.recordID.recordName.utf8.count) + default: + return 0 + } + } + + private func formattedCounts(_ counts: [String: Int]) -> String { + counts.keys.sorted().compactMap { key in + counts[key].map { "\(key)=\($0)" } + }.joined(separator: ", ") + } + + private func formatBytes(_ bytes: Int64) -> String { + "\(ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)) (\(bytes) B)" + } + + private func singleLine(_ value: String) -> String { + String(value.replacing(/\s+/, with: " ").prefix(80)) + } + private func probeSubscriptions( database: CKDatabase, label: String diff --git a/Crossmate/Views/Settings/DiagnosticsView.swift b/Crossmate/Views/Settings/DiagnosticsView.swift @@ -42,6 +42,7 @@ struct DiagnosticsView: View { @Environment(EventLog.self) private var eventLog @State private var isSyncing = false + @State private var isAuditingStorage = false /// The on-screen list is a quick glance, not the archive — the buffer now /// spans a full day (tens of thousands of entries during a co-solve), and @@ -80,17 +81,30 @@ struct DiagnosticsView: View { } } } - .disabled(isSyncing) + .disabled(isSyncing || isAuditingStorage) Button("Probe Container") { Task { await probeContainer() } } - .disabled(isSyncing) + .disabled(isSyncing || isAuditingStorage) + + Button { + Task { await auditCloudStorage() } + } label: { + HStack { + Text("Audit Cloud Storage") + if isAuditingStorage { + Spacer() + ProgressView() + } + } + } + .disabled(isSyncing || isAuditingStorage) Button("Reset Sync State", role: .destructive) { Task { await resetSyncState() } } - .disabled(isSyncing) + .disabled(isSyncing || isAuditingStorage) } Section { @@ -155,6 +169,16 @@ struct DiagnosticsView: View { syncMonitor.note("Container probe complete") } + private func auditCloudStorage() async { + guard !isAuditingStorage, let syncEngine else { return } + isAuditingStorage = true + defer { isAuditingStorage = false } + + await syncEngine.auditPrivateCloudStorage { message in + syncMonitor.note(message) + } + } + private func runFullSync() async { guard !isSyncing, let syncEngine else { return } isSyncing = true