commit ccb56ce371b93f166195f3395b64f9937cca4d66
parent 3b1ac748f6bb2759d9ecfa44c929ee6f61d1f40a
Author: Michael Camilleri <[email protected]>
Date: Tue, 4 Aug 2026 00:11:24 +0900
Reclaim the abandoned v3 container's storage on launch
Abandoning a CloudKit container generation does not reclaim its records.
Every zone the account ever wrote to v3 survived the v4 cutover and goes
on consuming iCloud quota indefinitely, for data nothing can reach — the
local cache was purged at the transition, and only the pre-v4 data probe
still reads the container at all. Users who ran the pre-release builds
carry the most of it, and had no way to get rid of any of it.
This commit sweeps the container once, from performStartup.
reclaimLegacyCloudStorageIfNeeded follows the shape of
migrateOffLegacyContainerIfNeeded: a UserDefaults marker keeps it
idempotent, but the marker is set only after a clean sweep, so a run
that was offline, signed out or partially rejected is retried on the
next launch rather than written off. The sweep is not awaited, so a slow
pass never delays launch, and it touches no container the sync engine
uses, so it cannot interfere with v4 syncing. It runs silently: the v4
notice already accounted for the loss of pre-v4 games, and this only
returns storage that is already unreachable.
deleteLegacyCloudData walks the private database alone, leaving zones
owned by other people untouched. Custom zones go in batches of twenty,
and a batch that fails is named and skipped rather than ending the run;
the default zone cannot itself be deleted, so anything left in it is
removed record by record with atomically false, which that zone
requires. The sweep reports whether the container came back clean, so
the caller cannot record partial work as done.
Co-Authored-By: Claude Opus 5 <[email protected]>
Diffstat:
2 files changed, 172 insertions(+), 0 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -791,6 +791,32 @@ final class AppServices {
defaults.set(Self.currentCloudGeneration, forKey: Self.cloudGenerationKey)
}
+ /// UserDefaults marker for the one-time v3 reclamation. Set only after a
+ /// clean sweep, so a run that was offline, signed out, or partially
+ /// rejected is retried on the next launch instead of being written off.
+ private static let legacyCloudReclaimedKey = "legacyCloudDataReclaimed"
+
+ /// Deletes this account's remaining v3 records, once, in the background.
+ /// Silent by design: the v4 notice already explained that pre-v4 games are
+ /// gone, so there is nothing further to tell the user — the work only
+ /// returns storage that is already unreachable.
+ private func reclaimLegacyCloudStorageIfNeeded() {
+ // The demo seed runs against a throwaway store and must never touch a
+ // real account's cloud data.
+ guard !ProcessInfo.processInfo.arguments.contains("--crossmate-seed-demo")
+ else { return }
+ let defaults = UserDefaults.standard
+ guard !defaults.bool(forKey: Self.legacyCloudReclaimedKey) else { return }
+
+ Task { [syncEngine, syncMonitor] in
+ let swept = await syncEngine.deleteLegacyCloudData { message in
+ syncMonitor.note(message)
+ }
+ guard swept else { return }
+ UserDefaults.standard.set(true, forKey: Self.legacyCloudReclaimedKey)
+ }
+ }
+
/// Pre-v4 data signal: the local store first (offline, instant), then — for
/// a reinstalled device with an empty store — a probe of the legacy v3
/// container for any custom zone (an `account` or per-game zone). A probe
@@ -829,6 +855,14 @@ final class AppServices {
// never shows the stale rows.
await migrateOffLegacyContainerIfNeeded()
+ // Reclaim the iCloud quota the abandoned v3 container still holds. The
+ // v4 cutover pointed the app at a fresh container and left every zone
+ // the account had written to v3 in place, where it consumes quota
+ // indefinitely for data nothing can reach. Detached so a slow sweep
+ // never delays launch, and it touches no container the sync engine
+ // uses, so it cannot interfere with v4 syncing.
+ reclaimLegacyCloudStorageIfNeeded()
+
// Surface one onboarding tip per cold launch. The in-memory
// AnnouncementCenter is empty on a fresh process, so this re-posts the
// next undismissed tip on each cold start; a warm resume doesn't re-run
diff --git a/Crossmate/Sync/CloudDiagnostics.swift b/Crossmate/Sync/CloudDiagnostics.swift
@@ -242,6 +242,144 @@ extension SyncEngine {
return environment
}
+ /// Deletes every record this account holds in the abandoned v3 container:
+ /// all of its custom zones, then anything left in the default zone, which
+ /// cannot itself be removed.
+ ///
+ /// Abandoning a container generation does not reclaim its records — they
+ /// consume the account's iCloud quota indefinitely — and v3 was abandoned
+ /// rather than migrated, so nothing in the app reads it beyond the pre-v4
+ /// data probe. This is irreversible, and the app holds no copy of what it
+ /// removes.
+ ///
+ /// Returns `true` only when the container was swept clean, so a caller
+ /// that records the work as done cannot mark a partial or offline run
+ /// complete. It touches no container the sync engine uses, so it is safe
+ /// to run alongside ordinary syncing.
+ @discardableResult
+ func deleteLegacyCloudData(
+ progress: @MainActor @Sendable (String) -> Void
+ ) async -> Bool {
+ let container = CloudContainer.v3Container
+ let identifier = container.containerIdentifier ?? "unknown"
+ let database = container.privateCloudDatabase
+ await progress(
+ "legacy delete: starting v3 [\(identifier)] in the " +
+ "\(cloudEnvironment()) CloudKit environment"
+ )
+
+ let zones: [CKRecordZone]
+ do {
+ zones = try await database.allRecordZones()
+ } catch {
+ await progress("legacy delete: couldn't list zones — \(describe(error)) — nothing deleted")
+ return false
+ }
+
+ let defaultZoneID = CKRecordZone.default().zoneID
+ let customZoneIDs = zones
+ .map(\.zoneID)
+ .filter { $0 != defaultZoneID }
+ .sorted { $0.zoneName < $1.zoneName }
+ await progress("legacy delete: \(customZoneIDs.count) custom zone(s) to remove")
+
+ var deleted = 0
+ var failed = 0
+ // Batched so a single oversized request cannot fail the whole run, and
+ // so a long delete reports progress rather than going quiet.
+ for start in stride(from: 0, to: customZoneIDs.count, by: 20) {
+ let batch = Array(customZoneIDs[start..<min(start + 20, customZoneIDs.count)])
+ do {
+ let results = try await database.modifyRecordZones(
+ saving: [],
+ deleting: batch
+ )
+ for (zoneID, result) in results.deleteResults {
+ switch result {
+ case .success:
+ deleted += 1
+ case .failure(let error):
+ failed += 1
+ await progress(
+ "legacy delete: zone [\(zoneID.zoneName)] FAILED — \(describe(error))"
+ )
+ }
+ }
+ } catch {
+ failed += batch.count
+ await progress("legacy delete: batch of \(batch.count) FAILED — \(describe(error))")
+ }
+ await progress(
+ "legacy delete: \(deleted) deleted, \(failed) failed of \(customZoneIDs.count)"
+ )
+ }
+
+ let defaultZoneCleared = await deleteDefaultZoneRecords(in: database, progress: progress)
+ let swept = failed == 0 && defaultZoneCleared
+ await progress(
+ "legacy delete complete: zonesDeleted=\(deleted) zonesFailed=\(failed) " +
+ "swept=\(swept)"
+ )
+ return swept
+ }
+
+ /// The default zone cannot be deleted, so anything sitting in it has to be
+ /// removed record by record. The audit skips this zone, so its contents are
+ /// unmeasured rather than known-empty.
+ private func deleteDefaultZoneRecords(
+ in database: CKDatabase,
+ progress: @MainActor @Sendable (String) -> Void
+ ) async -> Bool {
+ var recordIDs: [CKRecord.ID] = []
+ var token: CKServerChangeToken?
+ var moreComing = true
+ do {
+ while moreComing {
+ let page = try await database.recordZoneChanges(
+ inZoneWith: CKRecordZone.default().zoneID,
+ since: token
+ )
+ token = page.changeToken
+ moreComing = page.moreComing
+ for result in page.modificationResultsByID.values {
+ if let record = try? result.get().record {
+ recordIDs.append(record.recordID)
+ }
+ }
+ }
+ } catch {
+ await progress("legacy delete: couldn't read the default zone — \(describe(error))")
+ return false
+ }
+
+ guard !recordIDs.isEmpty else {
+ await progress("legacy delete: default zone holds no records")
+ return true
+ }
+
+ await progress("legacy delete: \(recordIDs.count) default-zone record(s) to remove")
+ var deleted = 0
+ for start in stride(from: 0, to: recordIDs.count, by: 100) {
+ let batch = Array(recordIDs[start..<min(start + 100, recordIDs.count)])
+ do {
+ // The default zone does not support atomic changes.
+ let results = try await database.modifyRecords(
+ saving: [],
+ deleting: batch,
+ atomically: false
+ )
+ deleted += results.deleteResults.values.filter {
+ if case .success = $0 { return true }
+ return false
+ }.count
+ } catch {
+ await progress("legacy delete: default-zone batch FAILED — \(describe(error))")
+ }
+ }
+ await progress("legacy delete: \(deleted) of \(recordIDs.count) default-zone record(s) deleted")
+ return deleted == recordIDs.count
+ }
+
private func auditPrivateCloudStorage(
label: String,
container: CKContainer,