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:
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