crossmate

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

commit 43ad4137f505113dac43732872cca3ec2c100de4
parent ed7c2f607c5711a56371788b90e61c6d262fbd1c
Author: Michael Camilleri <[email protected]>
Date:   Sun,  3 May 2026 00:06:45 +0900

Simplify leave/deletion options for shared games

If a shared game has been shared by the user, the user can delete it. If
a shared game has been shared by another user, the user can leave it.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Persistence/GameStore.swift | 32++++++++++++++++++++------------
MCrossmate/Services/AppServices.swift | 15++++++---------
MCrossmate/Sync/SyncEngine.swift | 24++++++++++++++++++++++++
MCrossmate/Views/GameListView.swift | 17++++++++++++++---
MTests/Unit/Sync/ShareRoutingTests.swift | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 139 insertions(+), 24 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -1,3 +1,4 @@ +import CloudKit import CoreData import Foundation import Observation @@ -112,6 +113,15 @@ struct GameSummary: Identifiable, Equatable { } } +/// CloudKit routing metadata captured before a game row is deleted locally. +/// The sync layer cannot look this up after the Core Data cascade completes. +struct GameCloudDeletion: Sendable, Equatable { + let gameID: UUID + let databaseScope: Int16 + let ckZoneName: String + let ckZoneOwnerName: String +} + /// Per-entity memoisation of `GameSummary`. The library list re-runs on /// every Core Data save (i.e., every keystroke), but only the active /// entity's fields actually change. The cache key intentionally uses fast @@ -203,11 +213,10 @@ final class GameStore { @ObservationIgnored var onGameCreated: ((String) -> Void)? - /// Called with the `ckRecordName`s of all records that should be deleted - /// from CloudKit after a game is removed locally. Wired to - /// `SyncEngine.enqueueDeleteRecords` by `AppServices`. + /// Called with CloudKit zone metadata after a game is removed locally. + /// Wired to `SyncEngine.enqueueDeleteGame` by `AppServices`. @ObservationIgnored - var onGameDeleted: (([String]) -> Void)? + var onGameDeleted: ((GameCloudDeletion) -> Void)? /// Called when a mutable field on the `Game` record (e.g. `completedAt`) /// changes and needs to be re-pushed. Wired to `SyncEngine.enqueueGame` @@ -531,13 +540,12 @@ final class GameStore { guard let entity = try context.fetch(request).first else { return } - // Collect all CloudKit record names before the cascade delete wipes them. - var ckRecordNames: [String] = [] - if let name = entity.ckRecordName { ckRecordNames.append(name) } - let moveNames = ((entity.moves as? Set<MoveEntity>) ?? []).compactMap(\.ckRecordName) - let snapshotNames = ((entity.snapshots as? Set<SnapshotEntity>) ?? []).compactMap(\.ckRecordName) - ckRecordNames.append(contentsOf: moveNames) - ckRecordNames.append(contentsOf: snapshotNames) + let deletion = GameCloudDeletion( + gameID: id, + databaseScope: entity.databaseScope, + ckZoneName: entity.ckZoneName ?? "game-\(id.uuidString)", + ckZoneOwnerName: entity.ckZoneOwnerName ?? CKCurrentUserDefaultName + ) // Clear current references if this is the active game if currentEntity?.id == id { @@ -548,7 +556,7 @@ final class GameStore { context.delete(entity) try context.save() - onGameDeleted?(ckRecordNames) + onGameDeleted?(deletion) } // MARK: - Resign a game diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -107,9 +107,9 @@ final class AppServices { syncEngine: syncEngine, colorStore: colorStore ) - store.onGameDeleted = { [preferences] ckRecordNames in + store.onGameDeleted = { [preferences] deletion in guard preferences.isICloudSyncEnabled else { return } - onGameDeleted(ckRecordNames) + onGameDeleted(deletion) } self.colorStore = colorStore self.cloudService = CloudService( @@ -388,13 +388,10 @@ final class AppServices { static func makeOnGameDeleted( syncEngine: SyncEngine, colorStore: GamePlayerColorStore - ) -> ([String]) -> Void { - { ckRecordNames in - if let gameName = ckRecordNames.first(where: { $0.hasPrefix("game-") }), - let gameID = UUID(uuidString: String(gameName.dropFirst("game-".count))) { - colorStore.clearColors(forGame: gameID) - } - Task { await syncEngine.enqueueDeleteRecords(ckRecordNames) } + ) -> (GameCloudDeletion) -> Void { + { deletion in + colorStore.clearColors(forGame: deletion.gameID) + Task { await syncEngine.enqueueDeleteGame(deletion) } } } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -210,6 +210,19 @@ actor SyncEngine { } } + /// Registers the game's CloudKit zone for deletion. Each game owns its + /// own zone, so this removes all remote records for the puzzle, including + /// moves, snapshots, player records, session pings, and share metadata. + func enqueueDeleteGame(_ deletion: GameCloudDeletion) { + let zoneID = CKRecordZone.ID( + zoneName: deletion.ckZoneName, + ownerName: deletion.ckZoneOwnerName + ) + let engine = deletion.databaseScope == 1 ? sharedEngine : privateEngine + guard let engine else { return } + engine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)]) + } + /// Registers a Snapshot record as a pending send. Parses the game UUID /// from the record name and routes to the correct engine. func enqueueSnapshot(ckRecordName: String) { @@ -321,6 +334,17 @@ actor SyncEngine { } } + /// Zone names queued for deletion on the given scope's engine. Used by + /// tests to verify delete routing after the local GameEntity is gone. + func pendingDeletedZoneNames(scope: CKDatabase.Scope) -> [String] { + let engine = scope == .shared ? sharedEngine : privateEngine + guard let engine else { return [] } + return engine.state.pendingDatabaseChanges.compactMap { + if case .deleteZone(let id) = $0 { return id.zoneName } + return nil + } + } + func diagnosticSnapshot() async -> DiagnosticSnapshot { let status: CKAccountStatus do { status = try await container.accountStatus() } diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -92,7 +92,11 @@ struct GameListView: View { Button("Cancel", role: .cancel) {} } message: { if let target = deleteTarget { - Text("This will permanently delete \"\(target.title)\" and all progress.") + if target.isOwned && target.isShared { + Text("This will permanently delete \"\(target.title)\" from iCloud for everyone.") + } else { + Text("This will permanently delete \"\(target.title)\" and all progress.") + } } } } @@ -156,8 +160,14 @@ struct GameListView: View { .opacity(0) ) .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button("Delete", role: .destructive) { - deleteTarget = game + if !game.isOwned && game.isShared { + Button("Leave", role: .destructive) { + leaveTarget = game + } + } else { + Button("Delete", role: .destructive) { + deleteTarget = game + } } } } @@ -247,6 +257,7 @@ private struct GameRowView: View { Section { Button(role: .destructive) { onResign() } label: { Label("Resign", systemImage: "flag") } Button(role: .destructive) { onDelete() } label: { Label("Delete", systemImage: "trash") } + .disabled(!game.isOwned && game.isShared) } } label: { Image(systemName: "ellipsis") diff --git a/Tests/Unit/Sync/ShareRoutingTests.swift b/Tests/Unit/Sync/ShareRoutingTests.swift @@ -139,6 +139,81 @@ struct ShareRoutingTests { #expect(!sharedNames.contains { $0.hasPrefix("move-\(privateID.uuidString)") }) } + @Test("Deleting a private game queues its zone for private CloudKit deletion") + func privateGameDeleteEnqueue() async throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + let zoneName = "game-\(gameID.uuidString)" + + let game = GameEntity(context: ctx) + game.id = gameID + game.title = "Private" + game.puzzleSource = "" + game.createdAt = Date() + game.updatedAt = Date() + game.ckRecordName = zoneName + game.ckZoneName = zoneName + game.databaseScope = 0 + try ctx.save() + + let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2") + let engine = SyncEngine(container: container, persistence: persistence) + await engine.start() + + let store = GameStore(persistence: persistence) + var capturedDeletion: GameCloudDeletion? + store.onGameDeleted = { deletion in + capturedDeletion = deletion + } + try store.deleteGame(id: gameID) + let deletion = try #require(capturedDeletion) + await engine.enqueueDeleteGame(deletion) + + let privateDeletes = await engine.pendingDeletedZoneNames(scope: .private) + let sharedDeletes = await engine.pendingDeletedZoneNames(scope: .shared) + #expect(privateDeletes.contains(zoneName)) + #expect(sharedDeletes.isEmpty) + } + + @Test("Deleting a shared game queues its zone for shared CloudKit deletion") + func sharedGameDeleteEnqueue() async throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + let zoneName = "game-\(gameID.uuidString)" + + let game = GameEntity(context: ctx) + game.id = gameID + game.title = "Shared" + game.puzzleSource = "" + game.createdAt = Date() + game.updatedAt = Date() + game.ckRecordName = zoneName + game.ckZoneName = zoneName + game.ckZoneOwnerName = "_someOtherUser" + game.databaseScope = 1 + try ctx.save() + + let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2") + let engine = SyncEngine(container: container, persistence: persistence) + await engine.start() + + let store = GameStore(persistence: persistence) + var capturedDeletion: GameCloudDeletion? + store.onGameDeleted = { deletion in + capturedDeletion = deletion + } + try store.deleteGame(id: gameID) + let deletion = try #require(capturedDeletion) + await engine.enqueueDeleteGame(deletion) + + let privateDeletes = await engine.pendingDeletedZoneNames(scope: .private) + let sharedDeletes = await engine.pendingDeletedZoneNames(scope: .shared) + #expect(sharedDeletes.contains(zoneName)) + #expect(privateDeletes.isEmpty) + } + @Test("Unknown game IDs enqueue nothing") func unknownGameIsDropped() async throws { let (engine, _, _) = try await makeEngineWithGames()