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:
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"
+ )
+ )
+ }
}