commit 3b1ac748f6bb2759d9ecfa44c929ee6f61d1f40a
parent f79951c49f4dd5b835fc80baea32f573c6784aa2
Author: Michael Camilleri <[email protected]>
Date: Mon, 3 Aug 2026 21:46:09 +0900
Extend the storage audit to the abandoned v2 container
The audit's totals fell well short of the storage iCloud attributes to
Crossmate, and it could not say where the rest sat. Abandoning a
container generation does not reclaim its records — they consume the
account's quota until something deletes their zones — but v2 was never
entitled, so a whole generation was invisible. A container that failed
to list its zones then contributed nothing and was dropped without
comment, which is exactly how v1 fails, with 'Invalid bundle ID for
container', so the grand totals read as a complete account inventory
while omitting two generations of records.
This commit entitles v2, adds it to the containers the audit walks, and
reports coverage explicitly: a container that could not be read is now
named alongside the totals rather than passing in silence.
CloudContainer names its generations vN throughout, so the audit's
labels and the identifiers it reads agree.
The audit also could not say which CloudKit database it had read, and
the Development and Production private databases share nothing, so an
empty result meant little on its own. ICLOUD_ENVIRONMENT now feeds both
the icloud-container-environment entitlement and Info.plist, on the
pattern APS_ENVIRONMENT already follows, so the environment the audit
reports is the one the entitlement selected. It stays Development for
Debug and Production for Release — the choice Xcode was making
implicitly — and ICLOUD_ENVIRONMENT_OVERRIDE in Local.xcconfig points a
Debug build at Production without disturbing those defaults.
Co-Authored-By: Claude Opus 5 <[email protected]>
Diffstat:
8 files changed, 89 insertions(+), 20 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -1515,6 +1515,8 @@
CROSSMATE_PUSH_BASE_URL = "$(inherited)";
CROSSMATE_SHARE_LINK_BASE_URL = "$(inherited)";
CROSSMATE_SHARE_LINK_HOST = "$(inherited)";
+ ICLOUD_ENVIRONMENT = "$(ICLOUD_ENVIRONMENT_OVERRIDE:default=$(ICLOUD_ENVIRONMENT_DEFAULT))";
+ ICLOUD_ENVIRONMENT_DEFAULT = Production;
INFOPLIST_FILE = Crossmate/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -1540,6 +1542,8 @@
CROSSMATE_PUSH_BASE_URL = "$(inherited)";
CROSSMATE_SHARE_LINK_BASE_URL = "$(inherited)";
CROSSMATE_SHARE_LINK_HOST = "$(inherited)";
+ ICLOUD_ENVIRONMENT = "$(ICLOUD_ENVIRONMENT_OVERRIDE:default=$(ICLOUD_ENVIRONMENT_DEFAULT))";
+ ICLOUD_ENVIRONMENT_DEFAULT = Development;
INFOPLIST_FILE = Crossmate/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
diff --git a/Crossmate/Crossmate.entitlements b/Crossmate/Crossmate.entitlements
@@ -6,10 +6,13 @@
<string>$(APS_ENVIRONMENT)</string>
<key>com.apple.developer.devicecheck.appattest-environment</key>
<string>$(APP_ATTEST_ENVIRONMENT)</string>
+ <key>com.apple.developer.icloud-container-environment</key>
+ <string>$(ICLOUD_ENVIRONMENT)</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.net.inqk.crossmate.v4</string>
<string>iCloud.net.inqk.crossmate.v3</string>
+ <string>iCloud.net.inqk.crossmate.v2</string>
<string>iCloud.net.inqk.crossmate</string>
</array>
<key>com.apple.developer.icloud-services</key>
diff --git a/Crossmate/Info.plist b/Crossmate/Info.plist
@@ -42,6 +42,8 @@
<string>$(APS_ENVIRONMENT)</string>
<key>CrossmateEngagementSocketURL</key>
<string>$(CROSSMATE_ENGAGEMENT_SOCKET_URL)</string>
+ <key>CrossmateICloudEnvironment</key>
+ <string>$(ICLOUD_ENVIRONMENT)</string>
<key>CrossmatePushBaseURL</key>
<string>$(CROSSMATE_PUSH_BASE_URL)</string>
<key>CrossmateShareLinkBaseURL</key>
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -799,7 +799,7 @@ final class AppServices {
private func deviceHasLegacyData() async -> Bool {
if store.hasAnyGames() { return true }
do {
- let zones = try await CloudContainer.legacyContainer
+ let zones = try await CloudContainer.v3Container
.privateCloudDatabase.allRecordZones()
let defaultZoneName = CKRecordZone.default().zoneID.zoneName
return zones.contains { $0.zoneID.zoneName != defaultZoneName }
diff --git a/Crossmate/Services/CloudContainer.swift b/Crossmate/Services/CloudContainer.swift
@@ -5,9 +5,13 @@ import CloudKit
/// Synced game data lives in a *versioned* container. A breaking schema change
/// spins up a fresh container rather than mutating the append-only Production
/// schema in place; the app points at the current generation and abandons the
-/// previous one's data. `legacyIdentifier` is retained only so a build can
-/// still *read* the prior container to detect a user carrying pre-v4 data (the
-/// v3→v4 reset flow), and can be dropped a release after launch.
+/// previous one's data. `v3Identifier` is retained only so a build can still
+/// *read* the prior container to detect a user carrying pre-v4 data (the v3→v4
+/// reset flow), and can be dropped a release after launch.
+///
+/// The superseded generations stay entitled because abandoning a container does
+/// not reclaim its records: they keep consuming the signed-in account's iCloud
+/// quota indefinitely, so the storage audit needs to be able to inventory them.
///
/// Bump `identifier` and add the new id to `Crossmate.entitlements` together on
/// the next breaking schema change.
@@ -17,14 +21,19 @@ enum CloudContainer {
/// The immediately-previous generation, kept readable one release for the
/// pre-v4 data probe. Remove once all users have moved off it.
- static let legacyIdentifier = "iCloud.net.inqk.crossmate.v3"
+ static let v3Identifier = "iCloud.net.inqk.crossmate.v3"
+
+ /// The second generation, abandoned in favour of v3. Diagnostics only —
+ /// nothing reads or writes game data here.
+ static let v2Identifier = "iCloud.net.inqk.crossmate.v2"
/// 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 let v1Identifier = "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) }
+ static var v3Container: CKContainer { CKContainer(identifier: v3Identifier) }
+ static var v2Container: CKContainer { CKContainer(identifier: v2Identifier) }
+ static var v1Container: CKContainer { CKContainer(identifier: v1Identifier) }
}
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 = CloudContainer.originalIdentifier
+ private let containerID = CloudContainer.v1Identifier
private var documentsURL: URL?
private let query = NSMetadataQuery()
private var observers: [NSObjectProtocol] = []
diff --git a/Crossmate/Sync/CloudDiagnostics.swift b/Crossmate/Sync/CloudDiagnostics.swift
@@ -158,10 +158,18 @@ extension SyncEngine {
}
/// Downloads the current records in every custom zone owned by this iCloud
- /// 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.
+ /// account across every 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.
+ ///
+ /// Superseded containers are audited alongside the current one because
+ /// abandoning a generation does not reclaim its records — they keep
+ /// consuming the account's iCloud quota until something deletes their zones.
+ ///
+ /// The audit reads whichever CloudKit environment the build is signed for,
+ /// and the two environments have entirely separate private databases, so the
+ /// environment is logged first: an empty result means nothing without it.
///
/// The byte count is diagnostic rather than billing-exact: CKAsset file
/// lengths and inline String/Data payloads are measurable, but CloudKit does
@@ -172,15 +180,18 @@ extension SyncEngine {
) async {
let containers: [(label: String, container: CKContainer)] = [
("v4", container),
- ("v3", CloudContainer.legacyContainer),
- ("original", CloudContainer.originalContainer),
+ ("v3", CloudContainer.v3Container),
+ ("v2", CloudContainer.v2Container),
+ ("v1", CloudContainer.v1Container),
]
+ let labels = containers.map(\.label).joined(separator: ", ")
await progress(
- "storage audit: starting accessible production containers " +
- "[v4, v3, original]; v2 is not entitled and will not be queried"
+ "storage audit: starting entitled containers [\(labels)] in the " +
+ "\(cloudEnvironment()) CloudKit environment"
)
var grandTotals = StorageAuditTotals()
+ var unreadable: [String] = []
for source in containers {
if let totals = await auditPrivateCloudStorage(
label: source.label,
@@ -188,22 +199,49 @@ extension SyncEngine {
progress: progress
) {
grandTotals.merge(totals)
+ } else {
+ unreadable.append(source.label)
}
}
await logStorageTotals(
grandTotals,
- prefix: "storage audit accessible CloudKit grand totals",
+ prefix: "storage audit CloudKit grand totals",
includeLargest: true,
progress: progress
)
+ // A container that fails to list its zones contributes nothing to the
+ // totals, which otherwise read as a complete account inventory. Say so
+ // explicitly rather than letting the omission pass silently.
+ if unreadable.isEmpty {
+ await progress("storage audit coverage: every entitled container was read")
+ } else {
+ await progress(
+ "storage audit coverage INCOMPLETE: grand totals omit " +
+ "[\(unreadable.joined(separator: ", "))] — those containers failed to " +
+ "list zones, so any bytes they hold are unmeasured"
+ )
+ }
await auditICloudDocuments(progress: progress)
await progress(
"storage audit complete: measured bytes exclude CloudKit metadata, shares, " +
- "zone overhead, encryption overhead, server-retained state, and v2"
+ "zone overhead, encryption overhead, and server-retained state"
)
}
+ /// The CloudKit environment this build talks to. `ICLOUD_ENVIRONMENT` feeds
+ /// both the icloud-container-environment entitlement and this Info.plist
+ /// key, so what the audit reports is what the entitlement selected. The
+ /// entitlement itself is not readable at runtime on iOS — `SecTask` is
+ /// macOS-only — which is why it travels through Info.plist instead.
+ private nonisolated func cloudEnvironment() -> String {
+ let value = Bundle.main.object(forInfoDictionaryKey: "CrossmateICloudEnvironment")
+ guard let environment = value as? String, !environment.isEmpty else {
+ return "unreported"
+ }
+ return environment
+ }
+
private func auditPrivateCloudStorage(
label: String,
container: CKContainer,
@@ -329,7 +367,7 @@ extension SyncEngine {
progress: @MainActor @Sendable (String) -> Void
) async {
guard let root = FileManager.default.url(
- forUbiquityContainerIdentifier: CloudContainer.originalIdentifier
+ forUbiquityContainerIdentifier: CloudContainer.v1Identifier
) else {
await progress("storage audit iCloud Documents: container unavailable")
return
diff --git a/project.yml b/project.yml
@@ -75,6 +75,7 @@ targets:
CrossmatePushBaseURL: $(CROSSMATE_PUSH_BASE_URL)
CrossmateShareLinkBaseURL: $(CROSSMATE_SHARE_LINK_BASE_URL)
CrossmateAPSEnvironment: $(APS_ENVIRONMENT)
+ CrossmateICloudEnvironment: $(ICLOUD_ENVIRONMENT)
LSSupportsOpeningDocumentsInPlace: false
UILaunchScreen: {}
UISupportedInterfaceOrientations:
@@ -100,6 +101,16 @@ targets:
APP_ATTEST_ENVIRONMENT: production
TARGETED_DEVICE_FAMILY: "1,2"
CODE_SIGN_STYLE: Automatic
+ # ICLOUD_ENVIRONMENT is the single source of truth for the CloudKit
+ # environment, on the same pattern as APS_ENVIRONMENT: it feeds the
+ # icloud-container-environment entitlement (which picks the database)
+ # and Info.plist (which the storage audit reports), so a log can never
+ # misattribute which database it read. Xcode picks the environment
+ # implicitly from the build configuration when the entitlement is
+ # absent; naming it makes that choice visible and overridable.
+ # Set ICLOUD_ENVIRONMENT_OVERRIDE in Local.xcconfig to point a Debug
+ # build at Production without touching the committed defaults.
+ ICLOUD_ENVIRONMENT: $(ICLOUD_ENVIRONMENT_OVERRIDE:default=$(ICLOUD_ENVIRONMENT_DEFAULT))
configs:
# APS_ENVIRONMENT is the single source of truth for the APNs
# environment: it is substituted into the aps-environment entitlement
@@ -107,12 +118,14 @@ targets:
# PushClient reports to the push worker), so the two cannot diverge.
Debug:
APS_ENVIRONMENT: development
+ ICLOUD_ENVIRONMENT_DEFAULT: Development
# Disable linker identical-code-folding in Release so TestFlight crash
# reports name the real Swift async functions instead of collapsing
# them to <deduplicated_symbol> (which forces dwarfdump --lookup
# guesswork during symbolication).
Release:
APS_ENVIRONMENT: production
+ ICLOUD_ENVIRONMENT_DEFAULT: Production
OTHER_LDFLAGS: $(inherited) -Wl,-no_deduplicate
NotificationService: