crossmate

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

commit 1d007f7432898632dc45c6a7d12513c84ec29076
parent 20896c851e80898c5eb044db25016b00d7d59380
Author: Michael Camilleri <[email protected]>
Date:   Mon, 18 May 2026 09:16:21 +0900

Propagate shared game departure to the user's own devices

ShareController.leaveShare deleted the zone-wide CKShare and the local row on
that device only. A sibling device sees nothing but the shared-zone deletion,
which is byte-identical to the owner revoking access, so
handleFetchedDatabaseChanges unconditionally marked the row access-revoked
rather than removing it: the game stayed put — wrongly labelled — on every
other device.

The leave is now recorded as a durable Decision (kind "left", key = gameID) in
the account zone — the self-healing counterpart to a transient ping,
re-consulted on every sync rather than consumed once, reusing the existing
block-decision machinery. leaveShare enqueues it; applyDecisionRecord's new
'left' case hard-deletes the scope==1 row on siblings, idempotent and guarded
to participant rows so an owned same-id copy is never collateral.
handleFetchedRecordZoneChanges fans the removed id to onGameRemoved so an open
PuzzleView and the game list react, matching the private zone-deletion path. A
'left' fact would otherwise re-delete a re-invited game, so regaining access to
a shared zone (the placeholder path in handleFetchedDatabaseChanges) now clears
it via a new enqueueDecisionDeletion; the deletion is a benign no-op on
siblings since applyDeletion ignores the Decision type.

Scope stays on the user's own devices — Decisions are account-zone,
same-user-only. No CloudKit schema change: the Decision type and its
kind/payload fields already ship for block, and a new kind value is name-only.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/Sync/RecordSerializer.swift | 16++++++++++++++++
MCrossmate/Sync/ShareController.swift | 8++++++++
MCrossmate/Sync/SyncEngine.swift | 53++++++++++++++++++++++++++++++++++++++++++++++-------
MTests/Unit/RecordSerializerTests.swift | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 160 insertions(+), 7 deletions(-)

diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -572,6 +572,22 @@ enum RecordSerializer { } if friend.createdAt == nil { friend.createdAt = Date() } return true + case "left": + // The user left this shared game on another of their devices. + // Hard-delete the local row so it stops hanging around (the + // shared-zone deletion alone would only flag it access-revoked, + // see SyncEngine.handleFetchedDatabaseChanges). Idempotent: a + // re-applied decision after the row is gone is a no-op. Guarded + // to participant rows (databaseScope == 1) so a same-id owned + // copy is never collateral. + guard let gameID = UUID(uuidString: key) else { return false } + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + guard let entity = try? ctx.fetch(req).first, + entity.databaseScope == 1 else { return false } + ctx.delete(entity) + return true default: // Unknown kind from a newer build — ignore rather than guess. return false diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift @@ -328,6 +328,14 @@ final class ShareController { // Already gone — proceed to clean up local state. } + // Record the leave as a durable per-user fact so the user's other + // devices hard-delete this game too. Without it, a sibling sees only + // the shared-zone deletion — indistinguishable from the owner + // revoking access — and would mislabel the row "no longer have + // access" instead of removing it. Best-effort but self-healing: the + // record is re-consulted on every sync, not consumed once. + await syncEngine.enqueueDecision(kind: "left", key: gameID.uuidString) + ctx.delete(entity) try ctx.save() } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -663,6 +663,20 @@ actor SyncEngine { Task { try? await engine.sendChanges() } } + /// Deletes a durable `Decision` record (account zone) so a fact that no + /// longer holds stops propagating — e.g. a `left` decision is voided when + /// the user re-joins that game. Deleting an absent record is benign + /// (CloudKit reports it gone; the send path does not retry-loop it). The + /// `sendChanges` is deferred via `Task` so this is safe to call from a + /// CKSyncEngine delegate callback (see the friend-invite re-entrancy fix). + func enqueueDecisionDeletion(kind: String, key: String) { + guard let engine = privateEngine else { return } + let name = RecordSerializer.decisionRecordName(kind: kind, key: key) + let recordID = CKRecord.ID(recordName: name, zoneID: RecordSerializer.accountZoneID) + engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) + Task { try? await engine.sendChanges() } + } + /// 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 @@ -2336,9 +2350,10 @@ actor SyncEngine { // create placeholder GameEntities for newly-joined shares. let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - let (removedIDs, revokedIDs): ([UUID], [UUID]) = ctx.performAndWait { + let (removedIDs, revokedIDs, rejoinedIDs): ([UUID], [UUID], [UUID]) = ctx.performAndWait { var removed: [UUID] = [] var revoked: [UUID] = [] + var rejoined: [UUID] = [] if !isPrivate { for mod in event.modifications { let zoneID = mod.zoneID @@ -2351,7 +2366,8 @@ actor SyncEngine { // Placeholder until the Game record arrives. let entity = GameEntity(context: ctx) let uuidString = String(zoneName.dropFirst("game-".count)) - entity.id = UUID(uuidString: uuidString) + let gid = UUID(uuidString: uuidString) + entity.id = gid entity.ckRecordName = zoneName entity.ckZoneName = zoneName entity.ckZoneOwnerName = zoneID.ownerName @@ -2360,6 +2376,12 @@ actor SyncEngine { entity.puzzleSource = "" entity.createdAt = Date() entity.updatedAt = Date() + // Gaining access to this shared zone means the user + // (re)joined. Any prior `left` decision for this game + // is now void — clear it so a re-invited game isn't + // re-deleted on this or a sibling device by the stale + // durable fact. + if let gid { rejoined.append(gid) } } } } @@ -2389,7 +2411,7 @@ actor SyncEngine { ) } } - return (removed, revoked) + return (removed, revoked, rejoined) } for id in removedIDs { @@ -2398,6 +2420,11 @@ actor SyncEngine { for id in revokedIDs { if let cb = onGameAccessRevoked { await cb(id) } } + // Deferred inside enqueueDecisionDeletion via Task — never awaits a + // CKSyncEngine call from this delegate callback's context. + for id in rejoinedIDs { + enqueueDecisionDeletion(kind: "left", key: id.uuidString) + } } private func handleFetchedRecordZoneChanges( @@ -2419,12 +2446,13 @@ actor SyncEngine { let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump let localAuthorID = await currentLocalAuthorID() - let (movesUpdatedGameIDs, affectedGameIDs, pings, playersUpdatedGameIDs): - (Set<UUID>, Set<UUID>, [Ping], Set<UUID>) = ctx.performAndWait { + let (movesUpdatedGameIDs, affectedGameIDs, pings, playersUpdatedGameIDs, removedGameIDs): + (Set<UUID>, Set<UUID>, [Ping], Set<UUID>, Set<UUID>) = ctx.performAndWait { var movesUpdated = Set<UUID>() var affected = Set<UUID>() var pings: [Ping] = [] var playersUpdated = Set<UUID>() + var removed = Set<UUID>() for mod in event.modifications { let record = mod.record switch record.recordType { @@ -2452,11 +2480,22 @@ actor SyncEngine { pings.append(ping) } case "Decision": - RecordSerializer.applyDecisionRecord( + let wrote = RecordSerializer.applyDecisionRecord( record, to: ctx, localAuthorID: localAuthorID ) + // A `left` decision hard-deletes a game row; surface it so + // an open PuzzleView / the game list reacts, the same as + // the private zone-deletion path does via onGameRemoved. + if wrote, + let (dKind, dKey) = RecordSerializer.parseDecisionRecordName( + record.recordID.recordName + ), + dKind == "left", + let gid = UUID(uuidString: dKey) { + removed.insert(gid) + } default: break } @@ -2491,7 +2530,7 @@ actor SyncEngine { ) } } - return (movesUpdated, affected, pings, playersUpdated) + return (movesUpdated, affected, pings, playersUpdated, removed) } if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty { diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -447,4 +447,94 @@ struct RecordSerializerTests { let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") #expect(try ctx.count(for: req) == 0) } + + @Test("applyDecisionRecord(.left) hard-deletes the participant game row") + @MainActor func applyDecisionLeftDeletesParticipantGame() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + let entity = GameEntity(context: ctx) + entity.id = gameID + entity.title = "Shared" + entity.puzzleSource = "" + entity.databaseScope = 1 + entity.createdAt = Date() + entity.updatedAt = Date() + try ctx.save() + + let record = RecordSerializer.decisionRecord( + kind: "left", + key: gameID.uuidString, + zone: RecordSerializer.accountZoneID + ) + let wrote = RecordSerializer.applyDecisionRecord( + record, to: ctx, localAuthorID: "_alice" + ) + #expect(wrote) + + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + #expect(try ctx.count(for: req) == 0) + } + + @Test("applyDecisionRecord(.left) leaves an owned (scope 0) row intact") + @MainActor func applyDecisionLeftSkipsOwnedGame() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + let entity = GameEntity(context: ctx) + entity.id = gameID + entity.title = "Owned" + entity.puzzleSource = "" + entity.databaseScope = 0 + entity.createdAt = Date() + entity.updatedAt = Date() + try ctx.save() + + let record = RecordSerializer.decisionRecord( + kind: "left", + key: gameID.uuidString, + zone: RecordSerializer.accountZoneID + ) + let wrote = RecordSerializer.applyDecisionRecord( + record, to: ctx, localAuthorID: "_alice" + ) + // A `left` fact never applies to an owned copy — the participant who + // left can't be the owner of the same game id. + #expect(!wrote) + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + #expect(try ctx.count(for: req) == 1) + } + + @Test("applyDecisionRecord(.left) is a no-op when the row is already gone") + @MainActor func applyDecisionLeftIdempotent() { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let record = RecordSerializer.decisionRecord( + kind: "left", + key: UUID().uuidString, + zone: RecordSerializer.accountZoneID + ) + let wrote = RecordSerializer.applyDecisionRecord( + record, to: ctx, localAuthorID: "_alice" + ) + #expect(!wrote) + } + + @Test("applyDecisionRecord(.left) rejects a non-UUID key") + @MainActor func applyDecisionLeftRejectsBadKey() { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let record = RecordSerializer.decisionRecord( + kind: "left", + key: "not-a-uuid", + zone: RecordSerializer.accountZoneID + ) + #expect( + !RecordSerializer.applyDecisionRecord( + record, to: ctx, localAuthorID: "_alice" + ) + ) + } }