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:
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()