crossmate

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

commit 96c41c6a6ec95155afb6a4d684abb2f5ffaf649e
parent 96df9d6c01803806978a6944b2b8a980692c113f
Author: Michael Camilleri <[email protected]>
Date:   Thu, 21 May 2026 23:36:12 +0900

Drop a pending invite when its game's zone syncs in

Prior to this commit, a stale 'Invited' row lingering in the Game List after
the game it relates to has already been joined. A pending InviteEntity was
garbage-collected only inside applyInvitePings, a sweep that piggybacks on a
ping fetch — so when a freshly-joined shared game synced in without a ping
closely following, its now-redundant invite stayed visible alongside it. On a
device that had been asleep while the share was accepted elsewhere, the GC
sweep could also lose the race with the game record landing and miss it
entirely until the next ping arrived.

Invite cleanup now also keys off the shared zone arriving. SyncEngine gains an
onGameJoined callback, fired for each newly-discovered shared zone in
handleFetchedDatabaseChanges — the same rejoinedIDs already used to void a
stale 'left' decision. AppServices wires it to removePendingInvite, which drops
the pending invite rows for that game. The callback only touches Core Data, so
it is awaited directly from the delegate callback; enqueueDecisionDeletion
still defers its CKSyncEngine work via Task as before. The applyInvitePings
sweep stays as the fallback for paths that do not go through database-change
discovery.

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

Diffstat:
MCrossmate/Services/AppServices.swift | 28++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 17++++++++++++++---
2 files changed, 42 insertions(+), 3 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -322,6 +322,16 @@ final class AppServices { } } + await syncEngine.setOnGameJoined { [weak self] gameID in + guard let self else { return } + // A shared zone just synced in for this game — joined here or on + // a sibling device. Its "Invited" row is now redundant; drop it + // so a freshly-synced game and its stale invite don't show side + // by side. `applyInvitePings` GCs the same row, but only when a + // ping is next fetched. + self.removePendingInvite(forGameID: gameID) + } + // A sibling device consumed (deleted) a directed ping; withdraw any // copy of that game's notification we delivered before the deletion // reached us. @@ -1146,6 +1156,24 @@ final class AppServices { } } + /// Drops the pending invite row(s) for `gameID`. Called when the game's + /// shared zone appears locally (joined here or on a sibling device): the + /// "Invited" row is now redundant. `applyInvitePings` runs the same + /// garbage-collection over every pending invite, but only when a ping is + /// fetched — hooking zone arrival closes the window where a just-synced + /// game and its stale invite show side by side in the library. + func removePendingInvite(forGameID gameID: UUID) { + let ctx = persistence.viewContext + let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + req.predicate = NSPredicate( + format: "gameID == %@ AND status == %@", gameID as CVarArg, "pending" + ) + let invites = (try? ctx.fetch(req)) ?? [] + guard !invites.isEmpty else { return } + for invite in invites { ctx.delete(invite) } + if ctx.hasChanges { try? ctx.save() } + } + /// Accepts a pending game invite: fetches the share metadata, joins via /// the existing share-accept path, then drops the local `InviteEntity` /// (the game now represents it). If CloudKit says the share URL no longer diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -89,6 +89,10 @@ actor SyncEngine { private var onAccountChange: (@MainActor @Sendable () async -> Void)? private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)? private var onGameRemoved: (@MainActor @Sendable (UUID) async -> Void)? + /// Fires with the game ID of a shared zone that just appeared locally — + /// the user joined the game here or on a sibling device. Drives cleanup + /// of the now-redundant pending invite row. + private var onGameJoined: (@MainActor @Sendable (UUID) async -> Void)? /// Fires with the game IDs whose Ping record(s) were just deleted on the /// server (a sibling device consumed a directed ping). Drives cross-device /// withdrawal of the notification this device may have shown for it. @@ -150,6 +154,10 @@ actor SyncEngine { onGameRemoved = cb } + func setOnGameJoined(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) { + onGameJoined = cb + } + func setOnPingDeleted(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { onPingDeleted = cb } @@ -796,7 +804,8 @@ actor SyncEngine { // (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. + // durable fact — and any pending invite row for it is + // now redundant. if let gid { rejoined.append(gid) } } } @@ -836,10 +845,12 @@ 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. + // enqueueDecisionDeletion defers its CKSyncEngine work via Task — it + // never awaits sync from this delegate callback's context. onGameJoined + // only touches Core Data, so awaiting it directly is safe. for id in rejoinedIDs { enqueueDecisionDeletion(kind: "left", key: id.uuidString) + if let cb = onGameJoined { await cb(id) } } }