crossmate

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

commit e25402049e53ea079fa26499d65496239a7d7f04
parent 2e13827eba4c67baf94e70e4741cf6b8580b7ef6
Author: Michael Camilleri <[email protected]>
Date:   Fri, 12 Jun 2026 22:23:30 +0900

Settle public-link joins with a consumable seat ticket

This commit replaces the participant-count backstop in the accept path
with a cooperative seat protocol for public share links. When the owner
creates a link, a ticket-kind Ping record is minted into the game zone;
the first link joiner consumes it by deletion, which CloudKit serializes
so simultaneous joiners settle on exactly one winner even before they
can see each other in the share's participant list.

The joiner-side check runs in order: directly invited friends always
keep their seat (the participant list is the gate), over-cap joiners
leave, and under-cap link joiners consume the ticket. A missing ticket
falls back to the joiner's own Player-record footprint, so an invitee
re-accepting a link after a reinstall or on a new device recognizes the
game as already theirs instead of self-evicting. Losers leave the share
and surface the existing collaboration-limit error.

Only the limit error can escape the seat check. Transient CloudKit
failures are traced and the join stands, fixing a bug where a flaky
share fetch turned a successful join into a reported failure.

The ticket reuses the Ping record type with an unknown kind, so no
schema change is needed and every existing Ping consumer drops it at
parse. Tickets are only minted by createShareLink, which remains behind
the disabled link-sharing flag, so the protocol is dormant until links
return. Links created by pre-ticket builds evict their joiners (no
ticket, no footprint), matching the policy that legacy public links are
dead.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate/Services/CloudService.swift | 2+-
MCrossmate/Sync/ShareController.swift | 137++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
2 files changed, 127 insertions(+), 12 deletions(-)

diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift @@ -86,7 +86,7 @@ final class CloudService { .subtracting(existingJoinedGameIDs) .first if let joinedGameID { - try await shareController.leaveShareIfParticipantLimitExceeded(gameID: joinedGameID) + try await shareController.confirmSeatAfterJoin(gameID: joinedGameID) } NotificationCenter.default.post( name: .cloudShareAcceptanceCompleted, diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift @@ -12,6 +12,18 @@ final class ShareController { static let isPublicLinkSharingEnabled = false private static var maximumInviteesPerPuzzle: Int { maximumPeoplePerPuzzle - 1 } + /// The seat ticket for public-link sharing: a `ticket`-kind Ping the owner + /// mints into the game zone alongside the link, and the first link joiner + /// consumes by deleting it. Deletion is atomic — CloudKit rejects the + /// second deleter with `.unknownItem` — so simultaneous joiners settle on + /// exactly one winner even before they can see each other in the share's + /// participant list. The `ticket` kind is unknown to `PingKind`, so + /// `Ping.parseRecord` drops the record everywhere Pings are surfaced. + private static let ticketPingKind = "ticket" + private static func ticketRecordName(for gameID: UUID) -> String { + "ticket-\(gameID.uuidString)" + } + let container: CKContainer private let persistence: PersistenceController private let syncEngine: SyncEngine @@ -85,12 +97,16 @@ final class ShareController { throw ShareError.shareLinksDisabled(maxPeople: Self.maximumPeoplePerPuzzle) } let share = try await prepareShareRecord(for: gameID, publicPermission: .readWrite) + guard Self.inviteeCount(in: share) < Self.maximumInviteesPerPuzzle else { + throw ShareError.collaborationLimitReached(maxPeople: Self.maximumPeoplePerPuzzle) + } let savedShare: CKShare do { savedShare = try await saveShareForLink(share, for: gameID) } catch let error as CKError where error.code == .serverRecordChanged { savedShare = try await recoverShareLinkAfterSaveConflict(error, for: gameID) } + try await mintTicketIfAbsent(for: gameID, in: savedShare.recordID.zoneID) let url = try shareURL(from: savedShare) syncMonitor?.note("share link created for \(gameID.uuidString): \(url.absoluteString)") syncMonitor?.recordSuccess("create share link") @@ -402,30 +418,103 @@ final class ShareController { try ctx.save() } - /// Called immediately after accepting a share. If an old public link let - /// this account join a puzzle that is already at the TestFlight cap, leave - /// the share before the rest of the app treats the join as successful. - func leaveShareIfParticipantLimitExceeded(gameID: UUID) async throws { + /// Joiner-side seat check, run right after a share acceptance has synced + /// the new zone. Cooperative by design: CloudKit cannot enforce a + /// participant cap, so an over-cap joiner leaves voluntarily and a client + /// that skips the check keeps access until the owner intervenes. + /// + /// Directly invited friends always keep their seat — the owner added them + /// by identity, so the participant list itself is the gate. Link joiners + /// are admitted while the share is under the cap; simultaneous joiners + /// that cannot see each other in the participant list yet are settled by + /// consuming the zone's ticket Ping. A missing ticket without a prior + /// Player-record footprint means the seat went to someone else (or the + /// link predates tickets and is considered dead), so the joiner leaves. + /// + /// Only `ShareError.collaborationLimitReached` escapes. A transient + /// CloudKit failure is traced and the join stands — a failed check must + /// not turn a successful join into a reported failure; the cap then rests + /// on the other cooperating clients. + func confirmSeatAfterJoin(gameID: UUID) async throws { let ctx = persistence.viewContext let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) request.fetchLimit = 1 - guard let entity = try ctx.fetch(request).first, + guard let entity = try? ctx.fetch(request).first, entity.databaseScope == 1 else { return } let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName) - let shareID = CKRecord.ID(recordName: Self.zoneWideShareRecordName, zoneID: zoneID) - let record = try await container.sharedCloudDatabase.record(for: shareID) - guard let share = record as? CKShare, - Self.inviteeCount(in: share) > Self.maximumInviteesPerPuzzle - else { return } - try await leaveShare(gameID: gameID) + let seatLost: Bool + do { + seatLost = try await hasLostSeat(gameID: gameID, zoneID: zoneID) + } catch { + syncMonitor?.note( + "join seat check skipped for \(gameID.uuidString): \(error.localizedDescription)" + ) + return + } + guard seatLost else { return } + + // Best-effort: if leaving fails the local row lingers, but the limit + // error is still the truthful outcome to surface for this join. + try? await leaveShare(gameID: gameID) throw ShareError.collaborationLimitReached(maxPeople: Self.maximumPeoplePerPuzzle) } + private func hasLostSeat(gameID: UUID, zoneID: CKRecordZone.ID) async throws -> Bool { + let database = container.sharedCloudDatabase + let shareID = CKRecord.ID(recordName: Self.zoneWideShareRecordName, zoneID: zoneID) + guard let share = try await database.record(for: shareID) as? CKShare else { + return false + } + if share.currentUserParticipant?.role == .privateUser { return false } + if Self.inviteeCount(in: share) > Self.maximumInviteesPerPuzzle { return true } + + // Under the cap. A simultaneous link joiner may not be visible in the + // participant list yet; consuming the ticket settles it atomically. + let ticketID = CKRecord.ID( + recordName: Self.ticketRecordName(for: gameID), + zoneID: zoneID + ) + do { + try await database.deleteRecord(withID: ticketID) + return false + } catch let error as CKError where error.code == .unknownItem { + // Ticket already consumed. Rejoining a game this account + // previously won leaves a Player-record footprint; without one, + // the seat belongs to someone else. + return try await hasNoPlayerFootprint( + gameID: gameID, + zoneID: zoneID, + in: database + ) + } + } + + private func hasNoPlayerFootprint( + gameID: UUID, + zoneID: CKRecordZone.ID, + in database: CKDatabase + ) async throws -> Bool { + let myRecordName = try await container.userRecordID().recordName + let playerID = CKRecord.ID( + recordName: RecordSerializer.recordName( + forPlayerInGame: gameID, + authorID: myRecordName + ), + zoneID: zoneID + ) + do { + _ = try await database.record(for: playerID) + return false + } catch let error as CKError where error.code == .unknownItem { + return true + } + } + // MARK: - Helpers private func fetchExistingShare( @@ -474,6 +563,32 @@ final class ShareController { return share } + /// Saves the zone's seat ticket if it doesn't exist yet. Minted only when + /// a public link is created, and only while the puzzle has no invitee + /// (`createShareLink` guards capacity first), so a consumed ticket is + /// reissued exactly when its seat frees up again. Never overwrites an + /// existing ticket: an unconsumed one still guards the open seat. + private func mintTicketIfAbsent(for gameID: UUID, in zoneID: CKRecordZone.ID) async throws { + let ticketID = CKRecord.ID( + recordName: Self.ticketRecordName(for: gameID), + zoneID: zoneID + ) + do { + _ = try await container.privateCloudDatabase.record(for: ticketID) + return + } catch let error as CKError where error.code == .unknownItem { + // No ticket — mint one below. + } + let ticket = CKRecord(recordType: "Ping", recordID: ticketID) + ticket["kind"] = Self.ticketPingKind as CKRecordValue + ticket["authorID"] = try await container.userRecordID().recordName as CKRecordValue + do { + _ = try await container.privateCloudDatabase.save(ticket) + } catch let error as CKError where error.code == .serverRecordChanged { + // A sibling device minted it concurrently — the ticket exists. + } + } + private func disablePublicLinkIfNeeded(_ share: CKShare, for gameID: UUID) async throws { guard share.publicPermission != .none else { return } share.publicPermission = .none