crossmate

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

commit 8307c563f88c16299284dc0edb042887a711ff6e
parent 44f13d93ba964f6607551b80b7ce51701c9e3089
Author: Michael Camilleri <[email protected]>
Date:   Fri, 26 Jun 2026 11:19:21 +0900

Harden link creation and decline retries against share races

This commit closes two edge cases in puzzle sharing surfaced in review.

A puzzle that already had a friend invited directly could still offer
Create Link for a brief window while the share sheet finished loading,
and createShareLink only checked seat capacity before proceeding. Under
capacity it would flip the existing direct-invite share to public
.readWrite, producing the mixed public/direct-participant state CloudKit
forbids. createShareLink now fetches the share without reconfiguring its
public permission and rejects the conversion when an under-capacity
share still carries direct invitees, throwing the new directInvitesExist
error. The link and direct-invite routes stay mutually exclusive
regardless of share-sheet timing.

When freeing a declined invitee's seat failed transiently,
applyDeclinePing left the decline ping in CloudKit so the next sync
could retry, but the record name stayed in the in-memory handling claim.
The re-delivered ping was then dropped as already-handled, stranding the
seat until the app restarted. The claim is now released on failure so
the retry actually reprocesses the decline; a duplicate decline banner
on the retry is the accepted cost.

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

Diffstat:
MCrossmate/Services/InviteCoordinator.swift | 18+++++++++++++++++-
MCrossmate/Sync/ShareController.swift | 23++++++++++++++++++++++-
2 files changed, 39 insertions(+), 2 deletions(-)

diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -694,7 +694,10 @@ final class InviteCoordinator { /// re-firing. The decliner is the ping's author; only the addressed owner /// acts. The ping is consumed only on a successful free — a transient /// failure leaves it so the next sync retries rather than stranding the - /// seat. The banner is queued separately by `presentPings`. + /// seat — which also means releasing the in-memory handling claim, since a + /// claimed record is skipped on re-delivery and would otherwise suppress + /// the retry until the app restarts. The banner is queued separately by + /// `presentPings`. private func applyDeclinePing(_ ping: Ping) async { guard ping.addressee == identity.currentID, ping.authorID != identity.currentID, @@ -713,6 +716,11 @@ final class InviteCoordinator { ) syncMonitor.note("ping(decline): freed seat for \(ping.authorID) in \(ping.gameID.uuidString)") } catch { + // Release the claim so the re-delivered decline reprocesses on the + // next sync instead of being dropped as already-handled. A duplicate + // decline banner on the retry is the acceptable cost of not + // stranding the seat for the rest of the session. + releaseHandlingClaim(ping.recordName) syncMonitor.note("ping(decline): free seat failed for \(ping.gameID.uuidString) — \(error.localizedDescription)") } } @@ -737,6 +745,14 @@ final class InviteCoordinator { return unclaimed } + /// Drops a record from the handling claim so a later sync can reprocess it. + /// Used when handling failed transiently and the source record was left in + /// place for retry; without this the claim would skip the re-delivery. + private func releaseHandlingClaim(_ recordName: String) { + guard claimedPingRecordNames.remove(recordName) != nil else { return } + claimedPingRecordNameOrder.removeAll { $0 == recordName } + } + private func canPresentNotifications() async -> Bool { let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift @@ -57,6 +57,7 @@ final class ShareController { case invalidGameRecord case missingShareURL case collaborationLimitReached(maxPeople: Int) + case directInvitesExist var errorDescription: String? { switch self { @@ -72,6 +73,8 @@ final class ShareController { "CloudKit did not return a share URL." case .collaborationLimitReached(let maxPeople): "This puzzle already has the maximum of \(maxPeople) people." + case .directInvitesExist: + "This puzzle already has direct invites, so a share link isn't available." } } } @@ -106,10 +109,28 @@ final class ShareController { func createShareLink(for gameID: UUID) async throws -> URL { syncMonitor?.recordStart("create share link") do { - let share = try await prepareShareRecord(for: gameID, publicPermission: .readWrite) + // Fetch the share as-is, without flipping its public permission + // yet, so an existing direct-invite share stays recognisable: it + // carries non-owner invitees while its public permission is still + // `.none`. A new share is created with `.readWrite` regardless. + let share = try await prepareShareRecord( + for: gameID, + publicPermission: .readWrite, + reconfigureExistingPublicPermission: false + ) guard Self.inviteeCount(in: share) < Self.maximumInviteesPerPuzzle else { throw ShareError.collaborationLimitReached(maxPeople: Self.maximumPeoplePerPuzzle) } + // Reject converting a direct-invite share into a public link. + // Under capacity, non-owner invitees on a `.none` share can only be + // friends added directly — a public link keeps `.readWrite` until it + // fills (handled by the capacity guard above). Turning it into a link + // would create the mixed public/direct-participant state CloudKit + // forbids, so the two routes stay mutually exclusive. + if share.publicPermission == .none, Self.inviteeCount(in: share) > 0 { + throw ShareError.directInvitesExist + } + share.publicPermission = .readWrite let savedShare: CKShare do { savedShare = try await saveShareForLink(share, for: gameID)