crossmate

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

commit 2e94cc30f10317ea8ca519fb23e549c59ca2d982
parent 435238d00501453337189463a9dab4beebdf30c4
Author: Michael Camilleri <[email protected]>
Date:   Mon, 22 Jun 2026 00:18:29 +0900

Stop a second game invite from revoking the first

Inviting two friends to a game in quick succession could revoke the
first person's invite the moment the second name was tapped. Each invite
runs as a read-modify-write on the game's zone-wide CKShare:
addFriendParticipant fetched the share, added the tapped friend, and
saved it back. CloudKit reads are not read-after-write consistent, so
the second invite's fetch could return a share that did not yet reflect
the participant added moments earlier — and saving that copy committed a
participant list missing the first invitee. Because the change tag still
matched, the save raised no serverRecordChanged conflict, so the
recovery path that would have merged against the true server copy never
ran.

This commit keeps an in-memory record of the author IDs invited to each
game this session and re-asserts the whole set on every invite. Before
saving, addFriendParticipant restores every prior invitee a stale fetch
dropped, so the save can never shrink the invitee list below what was
put there, and recoverFriendShareAfterConflict re-asserts the same set
against the server copy on conflict. enforceInviteCapacity now caps the
union of existing and intended invitees, so re-adding someone already
present does not double-count and trip the collaboration limit.

The set is in-memory by design: it guards the back-to-back invite window
within one session, and across a relaunch the server share has had time
to converge. A participant a fetched share does carry is never removed,
so a seat freed and reopened on another device still survives.

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

Diffstat:
MCrossmate/Sync/ShareController.swift | 51+++++++++++++++++++++++++++++++++++++--------------
1 file changed, 37 insertions(+), 14 deletions(-)

diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift @@ -41,6 +41,15 @@ final class ShareController { /// `isShared` flag) can flip without waiting for the user to re-open. var onShareSaved: (@MainActor (UUID) -> Void)? + /// Author IDs added as direct game participants during this app session, + /// keyed by game. Re-asserted on every invite save so an eventually- + /// consistent share fetch — which can omit a participant added moments + /// earlier — can't drop a prior invitee on the next save. In-memory by + /// design: it guards the back-to-back invite window within one session; + /// across a relaunch the server share has had time to converge, and we + /// still never *remove* a participant that a fetched share does carry. + private var sessionInvitedAuthorIDs: [UUID: Set<String>] = [:] + enum ShareError: LocalizedError { case gameNotFound case invalidShareRecord @@ -138,8 +147,19 @@ final class ShareController { publicPermission: .none, reconfigureExistingPublicPermission: false ) - try enforceInviteCapacity(on: share, adding: userRecordName) - try await addParticipantIfNeeded(userRecordName, to: share) + // Re-assert every invitee added this session, not just the new + // one. CloudKit reads are not read-after-write consistent, so the + // share fetched above can omit a participant added moments earlier + // (a second invite right after the first); saving that copy back + // would silently revoke them. Restoring the full intended set + // before the save guarantees it can never shrink the invitee list + // below what we put there. + var intended = sessionInvitedAuthorIDs[gameID] ?? [] + intended.insert(userRecordName) + try enforceInviteCapacity(on: share, addingAll: intended) + for authorID in intended { + try await addParticipantIfNeeded(authorID, to: share) + } revokePublicAccessIfFull(of: share) let saved: CKShare do { @@ -148,9 +168,10 @@ final class ShareController { saved = try await recoverFriendShareAfterConflict( error, gameID: gameID, - userRecordName: userRecordName + userRecordNames: intended ) } + sessionInvitedAuthorIDs[gameID] = intended let url = try shareURL(from: saved) syncMonitor?.recordSuccess("invite friend to game") return url @@ -173,14 +194,14 @@ final class ShareController { share.addParticipant(participant) } - private func enforceInviteCapacity(on share: CKShare, adding userRecordName: String) throws { - let already = share.participants.contains { - $0.userIdentity.userRecordID?.recordName == userRecordName - } - guard !already else { return } - - let inviteeCount = Self.inviteeCount(in: share) - guard inviteeCount < Self.maximumInviteesPerPuzzle else { + /// Caps the share at `maximumInviteesPerPuzzle` distinct invitees, counting + /// the union of those already on the share and everyone we intend to + /// (re-)add this save. Author IDs already present don't double-count, so + /// re-asserting a prior invitee never trips the limit. + private func enforceInviteCapacity(on share: CKShare, addingAll authorIDs: Set<String>) throws { + var invitees = Set(Self.inviteeAuthorIDs(in: share)) + invitees.formUnion(authorIDs) + guard invitees.count <= Self.maximumInviteesPerPuzzle else { throw ShareError.collaborationLimitReached(maxPeople: Self.maximumPeoplePerPuzzle) } } @@ -241,7 +262,7 @@ final class ShareController { private func recoverFriendShareAfterConflict( _ error: CKError, gameID: UUID, - userRecordName: String + userRecordNames: Set<String> ) async throws -> CKShare { let ctx = persistence.viewContext let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") @@ -261,8 +282,10 @@ final class ShareController { } // Keep the share's metadata current while applying the current link policy. configureShare(share, title: entity.title, publicPermission: nil) - try enforceInviteCapacity(on: share, adding: userRecordName) - try await addParticipantIfNeeded(userRecordName, to: share) + try enforceInviteCapacity(on: share, addingAll: userRecordNames) + for authorID in userRecordNames { + try await addParticipantIfNeeded(authorID, to: share) + } revokePublicAccessIfFull(of: share) return try await saveShareForLink(share, for: gameID) }