commit 268cd8fbad3c4bcf84c74ce317c63d8b70add6da
parent 6e580a52de182ba41ec6b7c36ac3dfb566fbc1e7
Author: Michael Camilleri <[email protected]>
Date: Thu, 23 Jul 2026 06:51:25 +0900
Expand scope of storage audit
Diffstat:
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,