crossmate

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

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:
MCrossmate/CrossmateApp.swift | 7++++++-
MCrossmate/Services/AppServices.swift | 7+++++++
MCrossmate/Sync/SyncEngine.swift | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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)],