commit bf1bf5b95390caa8dad248e27659676abca21641
parent 1fa75ef3ad4cfa2fb33e3cf48ee074b2c0ab76de
Author: Michael Camilleri <[email protected]>
Date: Sun, 10 May 2026 23:17:48 +0900
Clean up completed game ping records
Crossmate uses Ping records as a means of providing shared notification
triggers. They are used during an active cooperative puzzle but are no longer
required after a puzzle has been completed. Retaining them keeps (potentially)
a number of records around unnecessarily.
This commit causes Crossmate to delete non-win Ping records from an owned
game’s CloudKit zone after completion, while preserving win pings so the final
notification remains available. It also drops pending non-win pings for that
game before they are sent.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
3 files changed, 93 insertions(+), 1 deletion(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -327,7 +327,12 @@ private struct PuzzleDisplayView: View {
session: session,
shareController: shareController,
roster: roster,
- onComplete: { store.markCompleted(id: gameID) },
+ onComplete: {
+ store.markCompleted(id: gameID)
+ Task {
+ await services.cleanupPingsAfterCompletion(gameID: gameID)
+ }
+ },
onResign: { try store.resignGame(id: gameID) },
onDelete: { try store.deleteGame(id: gameID) }
)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -252,6 +252,13 @@ final class AppServices {
await movesUpdater.flush()
}
+ func cleanupPingsAfterCompletion(gameID: UUID) async {
+ guard await ensureICloudSyncStarted() else { return }
+ await syncMonitor.run("completed ping cleanup") {
+ try await syncEngine.deleteNonWinPings(forCompletedGame: gameID)
+ }
+ }
+
func syncOpenSharedPuzzle() async {
await movesUpdater.flush()
guard await ensureICloudSyncStarted() else { return }
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -337,6 +337,60 @@ actor SyncEngine {
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
}
+ /// Deletes transient Ping records for a completed owned game while keeping
+ /// every `.win` ping. Participants see the owner's zone through the share,
+ /// so the owner-side deletion removes the records from the cooperative
+ /// game without risking the final win notification.
+ func deleteNonWinPings(forCompletedGame gameID: UUID) async throws {
+ let ctx = persistence.container.newBackgroundContext()
+ guard let info = zoneInfo(forGameID: gameID, in: ctx),
+ info.scope == 0
+ else { return }
+
+ removePendingNonWinPings(for: gameID, zoneID: info.zoneID)
+
+ let records = try await queryLiveRecords(
+ type: "Ping",
+ database: container.privateCloudDatabase,
+ zoneID: info.zoneID,
+ since: nil,
+ desiredKeys: ["kind"]
+ )
+ let recordIDsToDelete = records.compactMap { record -> CKRecord.ID? in
+ guard (record["kind"] as? String) != PingKind.win.rawValue else { return nil }
+ return record.recordID
+ }
+ try await deleteRecords(withIDs: recordIDsToDelete, in: container.privateCloudDatabase)
+ if !recordIDsToDelete.isEmpty {
+ await trace("ping cleanup: deleted \(recordIDsToDelete.count) non-win ping(s) for \(gameID.uuidString)")
+ }
+ }
+
+ private func removePendingNonWinPings(for gameID: UUID, zoneID: CKRecordZone.ID) {
+ let namesToRemove = Set(pendingPings.compactMap { name, payload in
+ payload.gameID == gameID && payload.kind != .win ? name : nil
+ })
+ guard !namesToRemove.isEmpty else { return }
+
+ for name in namesToRemove {
+ pendingPings.removeValue(forKey: name)
+ }
+ guard let privateEngine else { return }
+ let changesToRemove = privateEngine.state.pendingRecordZoneChanges.filter { change in
+ switch change {
+ case .saveRecord(let id):
+ return id.zoneID == zoneID && namesToRemove.contains(id.recordName)
+ case .deleteRecord:
+ return false
+ @unknown default:
+ return false
+ }
+ }
+ if !changesToRemove.isEmpty {
+ privateEngine.state.remove(pendingRecordZoneChanges: changesToRemove)
+ }
+ }
+
private nonisolated static func notificationTitle(for entity: GameEntity?) -> String {
guard let entity else { return "" }
return PuzzleNotificationText.title(
@@ -512,6 +566,32 @@ actor SyncEngine {
return records
}
+ private func deleteRecords(
+ withIDs recordIDs: [CKRecord.ID],
+ in database: CKDatabase
+ ) async throws {
+ guard !recordIDs.isEmpty else { return }
+ let batchSize = 200
+ var index = recordIDs.startIndex
+ while index < recordIDs.endIndex {
+ let end = recordIDs.index(index, offsetBy: batchSize, limitedBy: recordIDs.endIndex)
+ ?? recordIDs.endIndex
+ let batch = Array(recordIDs[index..<end])
+ try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
+ let op = CKModifyRecordsOperation(
+ recordsToSave: nil,
+ recordIDsToDelete: batch
+ )
+ op.qualityOfService = .utility
+ op.modifyRecordsResultBlock = { result in
+ cont.resume(with: result)
+ }
+ database.add(op)
+ }
+ index = end
+ }
+ }
+
private func applyDirectRecordZoneChanges(
records: [CKRecord],
deletions: [(CKRecord.ID, CKRecord.RecordType)],