crossmate

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

commit 268cd8fbad3c4bcf84c74ce317c63d8b70add6da
parent 6e580a52de182ba41ec6b7c36ac3dfb566fbc1e7
Author: Michael Camilleri <[email protected]>
Date:   Thu, 23 Jul 2026 06:51:25 +0900

Expand scope of storage audit

Diffstat:
MCrossmate/Services/CloudContainer.swift | 6++++++
MCrossmate/Services/DriveMonitor.swift | 2+-
MCrossmate/Sync/CloudDiagnostics.swift | 240++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
3 files changed, 207 insertions(+), 41 deletions(-)

diff --git a/Crossmate/Services/CloudContainer.swift b/Crossmate/Services/CloudContainer.swift @@ -19,6 +19,12 @@ enum CloudContainer { /// pre-v4 data probe. Remove once all users have moved off it. static let legacyIdentifier = "iCloud.net.inqk.crossmate.v3" + /// The original CloudKit generation, which is also Crossmate's public + /// iCloud Documents container. It remains entitled so imported puzzle files + /// stay available and lets diagnostics inventory the original CloudKit data. + static let originalIdentifier = "iCloud.net.inqk.crossmate" + static var container: CKContainer { CKContainer(identifier: identifier) } static var legacyContainer: CKContainer { CKContainer(identifier: legacyIdentifier) } + static var originalContainer: CKContainer { CKContainer(identifier: originalIdentifier) } } diff --git a/Crossmate/Services/DriveMonitor.swift b/Crossmate/Services/DriveMonitor.swift @@ -35,7 +35,7 @@ final class DriveMonitor { private(set) var root: DriveItem? private(set) var containerAvailable: Bool = false - private let containerID = "iCloud.net.inqk.crossmate" + private let containerID = CloudContainer.originalIdentifier private var documentsURL: URL? private let query = NSMetadataQuery() private var observers: [NSObjectProtocol] = [] diff --git a/Crossmate/Sync/CloudDiagnostics.swift b/Crossmate/Sync/CloudDiagnostics.swift @@ -3,6 +3,7 @@ import Foundation extension SyncEngine { private struct StorageAuditAsset { + let containerLabel: String let recordType: String let field: String let recordName: String @@ -30,6 +31,40 @@ extension SyncEngine { var assetBytes: Int64 { assetFields.values.reduce(0) { $0 + $1.bytes } } + + var assetCount: Int { + assetFields.values.reduce(0) { $0 + $1.count } + } + + mutating func merge(_ other: StorageAuditTotals) { + zoneCount += other.zoneCount + recordCount += other.recordCount + inlineBytes += other.inlineBytes + unavailableAssetCount += other.unavailableAssetCount + recordErrorCount += other.recordErrorCount + assets.append(contentsOf: other.assets) + for (type, count) in other.recordCounts { + recordCounts[type, default: 0] += count + } + for (field, value) in other.assetFields { + assetFields[field, default: StorageAuditFieldTotal()].count += value.count + assetFields[field, default: StorageAuditFieldTotal()].bytes += value.bytes + assetFields[field, default: StorageAuditFieldTotal()].unavailableCount += + value.unavailableCount + } + } + } + + private struct DocumentsAuditFile: Sendable { + let path: String + let bytes: Int64 + } + + private struct DocumentsAuditTotals: Sendable { + var fileCount = 0 + var bytes: Int64 = 0 + var unavailableCount = 0 + var files: [DocumentsAuditFile] = [] } struct DiagnosticSnapshot: Sendable { @@ -121,9 +156,10 @@ extension SyncEngine { } /// 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. + /// account across every production container in the app's current + /// entitlements, then inventories the separate iCloud Documents container. + /// This is an account-scoped alternative to CloudKit Console's Act As iCloud + /// workflow for a TestFlight build. /// /// The byte count is diagnostic rather than billing-exact: CKAsset file /// lengths and inline String/Data payloads are measurable, but CloudKit does @@ -132,15 +168,57 @@ extension SyncEngine { func auditPrivateCloudStorage( progress: @MainActor @Sendable (String) -> Void ) async { + let containers: [(label: String, container: CKContainer)] = [ + ("v4", container), + ("v3", CloudContainer.legacyContainer), + ("original", CloudContainer.originalContainer), + ] + await progress( + "storage audit: starting accessible production containers " + + "[v4, v3, original]; v2 is not entitled and will not be queried" + ) + + var grandTotals = StorageAuditTotals() + for source in containers { + if let totals = await auditPrivateCloudStorage( + label: source.label, + container: source.container, + progress: progress + ) { + grandTotals.merge(totals) + } + } + + await logStorageTotals( + grandTotals, + prefix: "storage audit accessible CloudKit grand totals", + includeLargest: true, + progress: progress + ) + await auditICloudDocuments(progress: progress) + await progress( + "storage audit complete: measured bytes exclude CloudKit metadata, shares, " + + "zone overhead, encryption overhead, server-retained state, and v2" + ) + } + + private func auditPrivateCloudStorage( + label: String, + container: CKContainer, + progress: @MainActor @Sendable (String) -> Void + ) async -> StorageAuditTotals? { + let identifier = container.containerIdentifier ?? "unknown" let database = container.privateCloudDatabase - await progress("storage audit: starting private database inventory") + await progress("storage audit container \(label) [\(identifier)]: starting private database") let zones: [CKRecordZone] do { zones = try await database.allRecordZones() } catch { - await progress("storage audit: couldn't list private zones — \(describe(error))") - return + await progress( + "storage audit container \(label): couldn't list private zones — \(describe(error))" + ) + return nil } let defaultZoneID = CKRecordZone.default().zoneID @@ -149,87 +227,164 @@ extension SyncEngine { .filter { $0 != defaultZoneID } .sorted { $0.zoneName < $1.zoneName } await progress( - "storage audit: found \(customZones.count) custom zone(s); " + + "storage audit container \(label): 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 zone = try await auditStorageZone(zoneID, containerLabel: label, in: database) + var completedZone = zone + completedZone.zoneCount = 1 + totals.merge(completedZone) let types = formattedCounts(zone.recordCounts) let title = zone.title.map { " title=[\(singleLine($0))]" } ?? "" await progress( - "storage audit zone \(index + 1)/\(customZones.count) " + + "storage audit container \(label) zone \(index + 1)/\(customZones.count) " + "[\(zoneID.zoneName)]:\(title) records=\(zone.recordCount) " + - "assets=\(formatBytes(zone.assetBytes)) inline=\(formatBytes(zone.inlineBytes)) " + + "assets=\(zone.assetCount) assetBytes=\(formatBytes(zone.assetBytes)) " + + "inline=\(formatBytes(zone.inlineBytes)) " + "types=[\(types)]" ) } catch { await progress( - "storage audit zone \(index + 1)/\(customZones.count) " + + "storage audit container \(label) zone \(index + 1)/\(customZones.count) " + "[\(zoneID.zoneName)] FAILED — \(describe(error))" ) } } + await logStorageTotals( + totals, + prefix: "storage audit container \(label) totals", + includeLargest: false, + progress: progress + ) + return totals + } + + private func logStorageTotals( + _ totals: StorageAuditTotals, + prefix: String, + includeLargest: Bool, + progress: @MainActor @Sendable (String) -> Void + ) async { await progress( - "storage audit totals: zones=\(totals.zoneCount)/\(customZones.count) " + - "records=\(totals.recordCount) assets=\(formatBytes(totals.assetBytes)) " + + "\(prefix): zones=\(totals.zoneCount) " + + "records=\(totals.recordCount) " + + "assets=\(totals.assetCount) assetBytes=\(formatBytes(totals.assetBytes)) " + "inline=\(formatBytes(totals.inlineBytes)) " + "measured=\(formatBytes(totals.assetBytes + totals.inlineBytes))" ) - await progress("storage audit record types: [\(formattedCounts(totals.recordCounts))]") + await progress("\(prefix) 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) " + + "\(prefix) asset field \(field): count=\(value.count) " + "bytes=\(formatBytes(value.bytes)) unavailable=\(value.unavailableCount)" ) } + guard includeLargest else { + if totals.unavailableAssetCount > 0 || totals.recordErrorCount > 0 { + await logStorageWarnings(totals, prefix: prefix, progress: progress) + } + return + } + 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) " + + "\(prefix) largest #\(index + 1): \(asset.recordType).\(asset.field) " + + "bytes=\(formatBytes(asset.bytes)) container=\(asset.containerLabel) " + + "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 logStorageWarnings(totals, prefix: prefix, progress: progress) } + } + + private func logStorageWarnings( + _ totals: StorageAuditTotals, + prefix: String, + progress: @MainActor @Sendable (String) -> Void + ) async { await progress( - "storage audit complete: measured bytes exclude CloudKit metadata, shares, " + - "zone overhead, encryption overhead, and server-retained state" + "\(prefix) warnings: unavailableAssets=\(totals.unavailableAssetCount) " + + "recordErrors=\(totals.recordErrorCount)" + ) + } + + private func auditICloudDocuments( + progress: @MainActor @Sendable (String) -> Void + ) async { + guard let root = FileManager.default.url( + forUbiquityContainerIdentifier: CloudContainer.originalIdentifier + ) else { + await progress("storage audit iCloud Documents: container unavailable") + return + } + let documents = root.appendingPathComponent("Documents", isDirectory: true) + let totals = Self.scanICloudDocuments(at: documents) + + await progress( + "storage audit iCloud Documents totals: files=\(totals.fileCount) " + + "bytes=\(formatBytes(totals.bytes)) unavailable=\(totals.unavailableCount)" ) + for (index, file) in totals.files.sorted(by: { $0.bytes > $1.bytes }).prefix(20).enumerated() { + await progress( + "storage audit iCloud Documents largest #\(index + 1): " + + "bytes=\(formatBytes(file.bytes)) path=[\(singleLine(file.path))]" + ) + } + } + + private nonisolated static func scanICloudDocuments( + at documents: URL + ) -> DocumentsAuditTotals { + let keys: [URLResourceKey] = [.isRegularFileKey, .fileSizeKey] + guard let enumerator = FileManager.default.enumerator( + at: documents, + includingPropertiesForKeys: keys, + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { + return DocumentsAuditTotals(unavailableCount: 1) + } + + var totals = DocumentsAuditTotals() + for case let url as URL in enumerator { + guard let values = try? url.resourceValues(forKeys: Set(keys)), + values.isRegularFile == true + else { continue } + totals.fileCount += 1 + if let size = values.fileSize { + let bytes = Int64(size) + totals.bytes += bytes + totals.files.append( + DocumentsAuditFile( + path: url.path.replacingOccurrences(of: documents.path + "/", with: ""), + bytes: bytes + ) + ) + } else { + totals.unavailableCount += 1 + } + } + + return totals } private func auditStorageZone( _ zoneID: CKRecordZone.ID, + containerLabel: String, in database: CKDatabase ) async throws -> StorageAuditTotals { var totals = StorageAuditTotals() @@ -247,7 +402,7 @@ extension SyncEngine { for result in page.modificationResultsByID.values { do { let record = try result.get().record - measure(record, into: &totals) + measure(record, containerLabel: containerLabel, into: &totals) } catch { totals.recordErrorCount += 1 } @@ -256,7 +411,11 @@ extension SyncEngine { return totals } - private func measure(_ record: CKRecord, into totals: inout StorageAuditTotals) { + private func measure( + _ record: CKRecord, + containerLabel: String, + into totals: inout StorageAuditTotals + ) { totals.recordCount += 1 totals.recordCounts[record.recordType, default: 0] += 1 if totals.title == nil, @@ -274,6 +433,7 @@ extension SyncEngine { fieldTotal.bytes += bytes totals.assets.append( StorageAuditAsset( + containerLabel: containerLabel, recordType: record.recordType, field: field, recordName: record.recordID.recordName,