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:
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)