crossmate

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

ShareController.swift (47346B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 
      5 /// Manages the lifecycle of `CKShare` objects for per-game zones. Responsible
      6 /// for creating zone-scoped shares and saving them to CloudKit, refreshing
      7 /// existing shares on re-present, and letting participants leave a shared game.
      8 @MainActor
      9 final class ShareController {
     10     private static let zoneWideShareRecordName = "cloudkit.zoneshare"
     11     static let maximumPeoplePerPuzzle = 3
     12     private static var maximumInviteesPerPuzzle: Int { maximumPeoplePerPuzzle - 1 }
     13     private static let ticketPayloadField = "payload"
     14     private static let countedTicketVersion = 2
     15 
     16     private struct TicketPayload: Codable {
     17         var version: Int
     18         var remainingSeats: Int
     19         var claimedAuthorIDs: [String]
     20     }
     21 
     22     /// The seat ticket for public-link sharing: a `ticket`-kind Ping the owner
     23     /// mints into the game zone alongside the link. Current tickets carry their
     24     /// remaining-seat count in the existing `payload` string and joiners consume
     25     /// a seat by saving a decremented record under CloudKit's optimistic lock;
     26     /// legacy one-seat tickets had no count and are still consumed by deletion.
     27     /// The `ticket` kind is unknown to `PingKind`, so `Ping.parseRecord` drops
     28     /// the record everywhere Pings are surfaced.
     29     private static let ticketPingKind = "ticket"
     30     private static func ticketRecordName(for gameID: UUID) -> String {
     31         "ticket-\(gameID.uuidString)"
     32     }
     33 
     34     let container: CKContainer
     35     private let persistence: PersistenceController
     36     private let syncEngine: SyncEngine
     37     private let syncMonitor: SyncMonitor?
     38 
     39     /// Fired after `persistShareName` has saved the local entity's
     40     /// `ckShareRecordName`, so dependent state (e.g. the open game's mutator
     41     /// `isShared` flag) can flip without waiting for the user to re-open.
     42     var onShareSaved: (@MainActor (UUID) -> Void)?
     43 
     44     /// Author IDs added as direct game participants during this app session,
     45     /// keyed by game. Re-asserted on every invite save so an eventually-
     46     /// consistent share fetch — which can omit a participant added moments
     47     /// earlier — can't drop a prior invitee on the next save. In-memory by
     48     /// design: it guards the back-to-back invite window within one session;
     49     /// across a relaunch the server share has had time to converge, and we
     50     /// still never *remove* a participant that a fetched share does carry.
     51     private var sessionInvitedAuthorIDs: [UUID: Set<String>] = [:]
     52 
     53     enum ShareError: LocalizedError {
     54         case gameNotFound
     55         case invalidShareRecord
     56         case notAnOwner
     57         case invalidGameRecord
     58         case missingShareURL
     59         case collaborationLimitReached(maxPeople: Int)
     60         case directInvitesExist
     61 
     62         var errorDescription: String? {
     63             switch self {
     64             case .gameNotFound:
     65                 "Puzzle not found."
     66             case .invalidShareRecord:
     67                 "Invalid share record."
     68             case .notAnOwner:
     69                 "Only the owner can share this puzzle."
     70             case .invalidGameRecord:
     71                 "Invalid puzzle record."
     72             case .missingShareURL:
     73                 "CloudKit did not return a share URL."
     74             case .collaborationLimitReached(let maxPeople):
     75                 "This puzzle already has the maximum of \(maxPeople) people."
     76             case .directInvitesExist:
     77                 "This puzzle already has direct invites, so a share link isn't available."
     78             }
     79         }
     80     }
     81 
     82     init(
     83         container: CKContainer,
     84         persistence: PersistenceController,
     85         syncEngine: SyncEngine,
     86         syncMonitor: SyncMonitor? = nil
     87     ) {
     88         self.container = container
     89         self.persistence = persistence
     90         self.syncEngine = syncEngine
     91         self.syncMonitor = syncMonitor
     92     }
     93 
     94     /// Returns the `CKShare` and container for `UICloudSharingController`'s
     95     /// preparation handler. For a first-time share, the returned share is
     96     /// *unsaved* — `UICloudSharingController` saves it when the user submits
     97     /// participants. Call `persistShareName(_:for:)` from the controller's
     98     /// `didSaveShare` delegate callback to record the saved share's name.
     99     /// For an existing share, the saved share is fetched and returned.
    100     func prepareShare(for gameID: UUID) async throws -> (CKShare, CKContainer) {
    101         let share = try await prepareShareRecord(for: gameID, publicPermission: .none)
    102         return (share, container)
    103     }
    104 
    105     /// Creates or updates the game's CloudKit share as a public collaboration
    106     /// link and returns the generated URL. This avoids the participant
    107     /// management UI and lets Crossmate capture the CloudKit save error
    108     /// directly when link creation fails.
    109     func createShareLink(for gameID: UUID) async throws -> URL {
    110         syncMonitor?.recordStart("create share link")
    111         do {
    112             // Fetch the share as-is, without flipping its public permission
    113             // yet, so an existing direct-invite share stays recognisable: it
    114             // carries non-owner invitees while its public permission is still
    115             // `.none`. A new share is created with `.readWrite` regardless.
    116             let share = try await prepareShareRecord(
    117                 for: gameID,
    118                 publicPermission: .readWrite,
    119                 reconfigureExistingPublicPermission: false
    120             )
    121             guard Self.inviteeCount(in: share) < Self.maximumInviteesPerPuzzle else {
    122                 throw ShareError.collaborationLimitReached(maxPeople: Self.maximumPeoplePerPuzzle)
    123             }
    124             // Reject converting a direct-invite share into a public link.
    125             // Under capacity, non-owner invitees on a `.none` share can only be
    126             // friends added directly — a public link keeps `.readWrite` until it
    127             // fills (handled by the capacity guard above). Turning it into a link
    128             // would create the mixed public/direct-participant state CloudKit
    129             // forbids, so the two routes stay mutually exclusive.
    130             if share.publicPermission == .none, Self.inviteeCount(in: share) > 0 {
    131                 throw ShareError.directInvitesExist
    132             }
    133             share.publicPermission = .readWrite
    134             let savedShare: CKShare
    135             do {
    136                 savedShare = try await saveShareForLink(share, for: gameID)
    137             } catch let error as CKError where error.code == .serverRecordChanged {
    138                 savedShare = try await recoverShareLinkAfterSaveConflict(error, for: gameID)
    139             }
    140             try await setTicketSeats(
    141                 max(0, Self.maximumInviteesPerPuzzle - Self.inviteeCount(in: savedShare)),
    142                 claimedAuthorIDs: Self.inviteeAuthorIDs(in: savedShare),
    143                 for: gameID,
    144                 in: savedShare.recordID.zoneID
    145             )
    146             let url = try shareURL(from: savedShare)
    147             syncMonitor?.note("share link created for \(gameID.uuidString): \(url.absoluteString)")
    148             syncMonitor?.recordSuccess("create share link")
    149             return url
    150         } catch {
    151             syncMonitor?.recordError("create share link", error)
    152             throw error
    153         }
    154     }
    155 
    156     /// Ensures the game's `CKShare` exists and adds `userRecordName` as a
    157     /// `.readWrite` participant. Returns the share URL so the caller can hand
    158     /// it to the friend via an `.invite` Ping. Idempotent: re-inviting an
    159     /// already-added participant is a no-op re-save.
    160     func addFriendParticipant(
    161         toGameID gameID: UUID,
    162         userRecordName: String
    163     ) async throws -> URL {
    164         syncMonitor?.recordStart("invite friend to game")
    165         do {
    166             let share = try await prepareShareRecord(
    167                 for: gameID,
    168                 publicPermission: .none,
    169                 reconfigureExistingPublicPermission: false
    170             )
    171             // Re-assert every invitee added this session, not just the new
    172             // one. CloudKit reads are not read-after-write consistent, so the
    173             // share fetched above can omit a participant added moments earlier
    174             // (a second invite right after the first); saving that copy back
    175             // would silently revoke them. Restoring the full intended set
    176             // before the save guarantees it can never shrink the invitee list
    177             // below what we put there.
    178             var intended = sessionInvitedAuthorIDs[gameID] ?? []
    179             intended.insert(userRecordName)
    180             try enforceInviteCapacity(on: share, addingAll: intended)
    181             for authorID in intended {
    182                 try await addParticipantIfNeeded(authorID, to: share)
    183             }
    184             revokePublicAccessIfFull(of: share)
    185             let saved: CKShare
    186             do {
    187                 saved = try await saveShareForLink(share, for: gameID)
    188             } catch let error as CKError where error.code == .serverRecordChanged {
    189                 saved = try await recoverFriendShareAfterConflict(
    190                     error,
    191                     gameID: gameID,
    192                     userRecordNames: intended
    193                 )
    194             }
    195             sessionInvitedAuthorIDs[gameID] = intended
    196             let url = try shareURL(from: saved)
    197             syncMonitor?.recordSuccess("invite friend to game")
    198             return url
    199         } catch {
    200             syncMonitor?.recordError("invite friend to game", error)
    201             throw error
    202         }
    203     }
    204 
    205     /// Removes `userRecordName` from the game's `CKShare`, freeing the seat they
    206     /// held so the owner can invite someone else. Called on the owner's device
    207     /// when an invitee declines (an inbound `.decline` Ping): only the owner can
    208     /// manage participants, so the decline rounds back here to do it. No-ops for
    209     /// a game we don't own, an unshared game, or a participant who isn't on the
    210     /// share. Idempotent.
    211     func removeFriendParticipant(
    212         fromGameID gameID: UUID,
    213         userRecordName: String
    214     ) async throws {
    215         syncMonitor?.recordStart("free declined seat")
    216         do {
    217             let ctx = persistence.viewContext
    218             let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    219             request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    220             request.fetchLimit = 1
    221             guard let entity = try ctx.fetch(request).first, entity.databaseScope == 0 else {
    222                 // Not the owner (or the game is gone) — nothing to manage.
    223                 syncMonitor?.recordSuccess("free declined seat")
    224                 return
    225             }
    226             // Drop the session re-assert first so a concurrent invite save can't
    227             // resurrect the declined participant via the intended-set restore.
    228             sessionInvitedAuthorIDs[gameID]?.remove(userRecordName)
    229 
    230             let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
    231             guard let share = try await fetchZoneWideShareIfPresent(zoneName: zoneName),
    232                   removeParticipant(userRecordName, from: share)
    233             else {
    234                 // No share, or they aren't on it (already removed / never added).
    235                 syncMonitor?.recordSuccess("free declined seat")
    236                 return
    237             }
    238             do {
    239                 _ = try await saveShareForLink(share, for: gameID)
    240             } catch let error as CKError where error.code == .serverRecordChanged {
    241                 guard let serverShare = (error as NSError)
    242                     .userInfo[CKRecordChangedErrorServerRecordKey] as? CKShare else {
    243                     throw error
    244                 }
    245                 if removeParticipant(userRecordName, from: serverShare) {
    246                     _ = try await saveShareForLink(serverShare, for: gameID)
    247                 }
    248             }
    249             syncMonitor?.recordSuccess("free declined seat")
    250         } catch {
    251             syncMonitor?.recordError("free declined seat", error)
    252             throw error
    253         }
    254     }
    255 
    256     private func addParticipantIfNeeded(
    257         _ userRecordName: String,
    258         to share: CKShare
    259     ) async throws {
    260         let already = share.participants.contains {
    261             $0.userIdentity.userRecordID?.recordName == userRecordName
    262         }
    263         guard !already else { return }
    264         let participant = try await fetchParticipant(forUserRecordName: userRecordName)
    265         participant.permission = .readWrite
    266         share.addParticipant(participant)
    267     }
    268 
    269     /// Removes the non-owner participant matching `userRecordName` from `share`,
    270     /// returning whether one was found. Idempotent: a participant already gone
    271     /// returns `false` so the caller can skip a redundant save.
    272     private func removeParticipant(
    273         _ userRecordName: String,
    274         from share: CKShare
    275     ) -> Bool {
    276         guard let participant = share.participants.first(where: {
    277             $0.role != .owner
    278                 && $0.userIdentity.userRecordID?.recordName == userRecordName
    279         }) else { return false }
    280         share.removeParticipant(participant)
    281         return true
    282     }
    283 
    284     /// Caps the share at `maximumInviteesPerPuzzle` distinct invitees, counting
    285     /// the union of those already on the share and everyone we intend to
    286     /// (re-)add this save. Author IDs already present don't double-count, so
    287     /// re-asserting a prior invitee never trips the limit.
    288     private func enforceInviteCapacity(on share: CKShare, addingAll authorIDs: Set<String>) throws {
    289         var invitees = Set(Self.inviteeAuthorIDs(in: share))
    290         invitees.formUnion(authorIDs)
    291         guard invitees.count <= Self.maximumInviteesPerPuzzle else {
    292             throw ShareError.collaborationLimitReached(maxPeople: Self.maximumPeoplePerPuzzle)
    293         }
    294     }
    295 
    296     /// A full puzzle offers no public link: once an invite commits the last
    297     /// seat, the same save revokes any outstanding link so it stops admitting
    298     /// joiners at the CloudKit level.
    299     private func revokePublicAccessIfFull(of share: CKShare) {
    300         guard Self.inviteeCount(in: share) >= Self.maximumInviteesPerPuzzle else { return }
    301         share.publicPermission = .none
    302     }
    303 
    304     private static func inviteeCount(in share: CKShare) -> Int {
    305         inviteeParticipants(in: share).count
    306     }
    307 
    308     private static func inviteeAuthorIDs(in share: CKShare) -> [String] {
    309         inviteeParticipants(in: share).compactMap {
    310             $0.userIdentity.userRecordID?.recordName
    311         }
    312     }
    313 
    314     private static func inviteeParticipants(in share: CKShare) -> [CKShare.Participant] {
    315         share.participants.filter { participant in
    316             participant.role != .owner
    317                 && participant.acceptanceStatus != .removed
    318         }
    319     }
    320 
    321     private func fetchParticipant(
    322         forUserRecordName recordName: String
    323     ) async throws -> CKShare.Participant {
    324         let lookup = CKUserIdentity.LookupInfo(
    325             userRecordID: CKRecord.ID(recordName: recordName)
    326         )
    327         return try await withCheckedThrowingContinuation { cont in
    328             var found: CKShare.Participant?
    329             let op = CKFetchShareParticipantsOperation(userIdentityLookupInfos: [lookup])
    330             op.perShareParticipantResultBlock = { _, result in
    331                 if case .success(let participant) = result { found = participant }
    332             }
    333             op.fetchShareParticipantsResultBlock = { result in
    334                 switch result {
    335                 case .success:
    336                     if let found {
    337                         cont.resume(returning: found)
    338                     } else {
    339                         cont.resume(throwing: ShareError.invalidShareRecord)
    340                     }
    341                 case .failure(let error):
    342                     cont.resume(throwing: error)
    343                 }
    344             }
    345             self.container.add(op)
    346         }
    347     }
    348 
    349     private func recoverFriendShareAfterConflict(
    350         _ error: CKError,
    351         gameID: UUID,
    352         userRecordNames: Set<String>
    353     ) async throws -> CKShare {
    354         let ctx = persistence.viewContext
    355         let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    356         request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    357         request.fetchLimit = 1
    358         guard let entity = try ctx.fetch(request).first else {
    359             throw ShareError.gameNotFound
    360         }
    361         let share: CKShare
    362         if let serverShare = (error as NSError).userInfo[CKRecordChangedErrorServerRecordKey] as? CKShare {
    363             share = serverShare
    364         } else {
    365             share = try await fetchExistingShare(
    366                 recordName: Self.zoneWideShareRecordName,
    367                 zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)"
    368             )
    369         }
    370         // Keep the share's metadata current while applying the current link policy.
    371         configureShare(share, title: entity.title, publicPermission: nil)
    372         try enforceInviteCapacity(on: share, addingAll: userRecordNames)
    373         for authorID in userRecordNames {
    374             try await addParticipantIfNeeded(authorID, to: share)
    375         }
    376         revokePublicAccessIfFull(of: share)
    377         return try await saveShareForLink(share, for: gameID)
    378     }
    379 
    380     /// Returns the saved public share URL for a game, if Crossmate already
    381     /// knows about its `CKShare`. Stale local share references are cleared so
    382     /// the caller can safely offer to create a fresh link.
    383     func existingShareLink(for gameID: UUID) async throws -> URL? {
    384         let ctx = persistence.viewContext
    385         let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    386         request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    387         request.fetchLimit = 1
    388         guard let entity = try ctx.fetch(request).first else {
    389             throw ShareError.gameNotFound
    390         }
    391         guard entity.databaseScope == 0 else {
    392             throw ShareError.notAnOwner
    393         }
    394         guard let existingName = entity.ckShareRecordName else {
    395             let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
    396             do {
    397                 let share = try await fetchExistingShare(
    398                     recordName: Self.zoneWideShareRecordName,
    399                     zoneName: zoneName
    400                 )
    401                 entity.ckShareRecordName = share.recordID.recordName
    402                 try ctx.save()
    403                 return try await publicLinkURL(from: share, for: gameID)
    404             } catch let error as CKError where isMissingShare(error) {
    405                 return nil
    406             }
    407         }
    408 
    409         do {
    410             let share = try await fetchExistingShare(
    411                 recordName: existingName,
    412                 zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)"
    413             )
    414             return try await publicLinkURL(from: share, for: gameID)
    415         } catch let error as CKError where error.code == .unknownItem {
    416             entity.ckShareRecordName = nil
    417             try ctx.save()
    418             return nil
    419         }
    420     }
    421 
    422     /// Resolves a fetched share to its live public link. A full puzzle has no
    423     /// link to offer — any lingering public permission is revoked so the old
    424     /// URL stops admitting joiners — and a share without public access
    425     /// reports `nil` so the caller can offer to create a fresh link.
    426     private func publicLinkURL(from share: CKShare, for gameID: UUID) async throws -> URL? {
    427         guard Self.inviteeCount(in: share) < Self.maximumInviteesPerPuzzle else {
    428             try await disablePublicLinkIfNeeded(share, for: gameID)
    429             return nil
    430         }
    431         guard share.publicPermission != .none else { return nil }
    432         return share.url
    433     }
    434 
    435     /// Whether the puzzle's invitee seat is already taken, per the share's
    436     /// actual participant list (a pending invite counts — the seat is
    437     /// committed once offered). Seeds the share sheet so a full game opens
    438     /// with invites already disabled instead of surfacing the limit as a
    439     /// tap-time error. Best-effort: an unshared game or a transient fetch
    440     /// failure reports `false`, and the capacity check in
    441     /// `addFriendParticipant` remains the authoritative gate.
    442     func isAtInviteCapacity(for gameID: UUID) async -> Bool {
    443         let ctx = persistence.viewContext
    444         let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    445         request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    446         request.fetchLimit = 1
    447         guard let entity = try? ctx.fetch(request).first,
    448               entity.databaseScope == 0 else { return false }
    449         let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
    450         guard let share = (try? await fetchZoneWideShareIfPresent(zoneName: zoneName)) ?? nil
    451         else { return false }
    452         return Self.inviteeCount(in: share) >= Self.maximumInviteesPerPuzzle
    453     }
    454 
    455     /// The author IDs already holding an invitee seat on the game's share.
    456     /// Seeds the invite UI so a re-opened share sheet shows everyone you've
    457     /// already added with a checkmark instead of an un-invited glyph, which
    458     /// otherwise tempts a redundant second invite. Unions the share's current
    459     /// invitee participants with the author IDs added this session, since an
    460     /// eventually-consistent share fetch can omit a participant added moments
    461     /// earlier. Best-effort: an unshared game or a transient fetch failure
    462     /// falls back to the session set alone.
    463     func invitedAuthorIDs(for gameID: UUID) async -> Set<String> {
    464         var invited = invitedAuthorIDsKnownThisSession(for: gameID)
    465         let ctx = persistence.viewContext
    466         let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    467         request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    468         request.fetchLimit = 1
    469         guard let entity = try? ctx.fetch(request).first,
    470               entity.databaseScope == 0 else { return invited }
    471         let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
    472         if let share = (try? await fetchZoneWideShareIfPresent(zoneName: zoneName)) ?? nil {
    473             invited.formUnion(Self.inviteeAuthorIDs(in: share))
    474         }
    475         return invited
    476     }
    477 
    478     /// The author IDs added as invitees during this app session, readable
    479     /// synchronously so a re-presented share screen can render their checkmark
    480     /// on the first frame — no await, no animated transition — before the
    481     /// async invitedAuthorIDs(for:) backfills anyone invited on another device
    482     /// or in a prior session.
    483     func invitedAuthorIDsKnownThisSession(for gameID: UUID) -> Set<String> {
    484         sessionInvitedAuthorIDs[gameID] ?? []
    485     }
    486 
    487     /// The game's grid silhouette for share-link previews, read from the
    488     /// cached block layout so it costs nothing at link-creation time. Returns
    489     /// `nil` when the cache hasn't been populated, in which case the link simply
    490     /// carries no shape segment.
    491     func gridSilhouette(for gameID: UUID) -> GridSilhouette.Grid? {
    492         let ctx = persistence.viewContext
    493         let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    494         request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    495         request.fetchLimit = 1
    496         guard let entity = try? ctx.fetch(request).first else { return nil }
    497         let width = Int(entity.gridWidth)
    498         let height = Int(entity.gridHeight)
    499         guard width > 0, height > 0,
    500               let mask = entity.blockMask, mask.count == width * height else {
    501             return nil
    502         }
    503         return GridSilhouette.Grid(width: width, height: height, blocks: mask.map { $0 != 0 })
    504     }
    505 
    506     private func prepareShareRecord(
    507         for gameID: UUID,
    508         publicPermission: CKShare.ParticipantPermission,
    509         reconfigureExistingPublicPermission: Bool = true
    510     ) async throws -> CKShare {
    511         // For an *existing* share the friend-invite path passes `false`: the
    512         // share keeps whatever public permission it already had (a brand-new
    513         // share is still created with the requested `publicPermission`).
    514         let existingPermission: CKShare.ParticipantPermission? =
    515             reconfigureExistingPublicPermission ? publicPermission : nil
    516         let ctx = persistence.viewContext
    517         let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    518         request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    519         request.fetchLimit = 1
    520         guard let entity = try ctx.fetch(request).first else {
    521             throw ShareError.gameNotFound
    522         }
    523         guard entity.databaseScope == 0 else {
    524             throw ShareError.notAnOwner
    525         }
    526 
    527         if let existingName = entity.ckShareRecordName {
    528             do {
    529                 let existing = try await fetchExistingShare(
    530                     recordName: existingName,
    531                     zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)"
    532                 )
    533                 return configureShare(existing, title: entity.title, publicPermission: existingPermission)
    534             } catch let error as CKError where error.code == .unknownItem {
    535                 entity.ckShareRecordName = nil
    536                 try ctx.save()
    537             }
    538         }
    539 
    540         let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
    541         let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
    542 
    543         // Create the zone directly rather than going through CKSyncEngine.sendChanges(),
    544         // which can block on post-reset state (stale tokens, in-flight operations).
    545         // Zone creation is idempotent so this is safe even if the engine already created it.
    546         try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
    547             let op = CKModifyRecordZonesOperation(
    548                 recordZonesToSave: [CKRecordZone(zoneID: zoneID)],
    549                 recordZoneIDsToDelete: nil
    550             )
    551             op.qualityOfService = .userInitiated
    552             op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) }
    553             self.container.privateCloudDatabase.add(op)
    554         }
    555 
    556         if let existing = try await fetchZoneWideShareIfPresent(zoneName: zoneName) {
    557             entity.ckShareRecordName = existing.recordID.recordName
    558             try ctx.save()
    559             return configureShare(existing, title: entity.title, publicPermission: existingPermission)
    560         }
    561 
    562         try await ensureGameRecordExists(for: entity, in: zoneID)
    563 
    564         let share = CKShare(recordZoneID: zoneID)
    565         return configureShare(share, title: entity.title, publicPermission: publicPermission)
    566     }
    567 
    568     /// Records the share's CloudKit record name on the local entity so future
    569     /// invocations of `prepareShare` fetch the existing share. Also enqueues a
    570     /// Game record push so other owner-devices receive the share marker via
    571     /// `RecordSerializer.applyGameRecord` and flip their `isShared` flag.
    572     /// Idempotent.
    573     func persistShareName(_ recordName: String, for gameID: UUID) async throws {
    574         let ctx = persistence.viewContext
    575         let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    576         request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    577         request.fetchLimit = 1
    578         guard let entity = try ctx.fetch(request).first else { return }
    579         guard entity.ckShareRecordName != recordName else { return }
    580         entity.ckShareRecordName = recordName
    581         entity.hasPendingSave = true
    582         try ctx.save()
    583         if let ckRecordName = entity.ckRecordName {
    584             await syncEngine.enqueueGame(ckRecordName: ckRecordName)
    585         }
    586         onShareSaved?(gameID)
    587     }
    588 
    589     /// Removes the current user's participation from a shared game and deletes
    590     /// the local entity. No-ops if the game is not a shared (participant) game.
    591     func leaveShare(gameID: UUID) async throws {
    592         let ctx = persistence.viewContext
    593         let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    594         request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    595         request.fetchLimit = 1
    596         guard let entity = try ctx.fetch(request).first,
    597               entity.databaseScope == 1 else { return }
    598 
    599         let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
    600         let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
    601         let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)
    602         let shareID = CKRecord.ID(recordName: Self.zoneWideShareRecordName, zoneID: zoneID)
    603 
    604         // A participant leaves a zone-wide share by deleting the CKShare record
    605         // from the shared database; deleting the zone itself is rejected with
    606         // "Zone delete not allowed".
    607         do {
    608             try await container.sharedCloudDatabase.deleteRecord(withID: shareID)
    609         } catch let error as CKError where error.code == .unknownItem || error.code == .zoneNotFound {
    610             // Already gone — proceed to clean up local state.
    611         }
    612 
    613         // Delete the invite Ping that brought us in, if it's still around.
    614         // It's durable and its usual cleanup (`consumeStaleInvites`) keys off
    615         // the local GameEntity we're about to remove, so leaving it behind
    616         // lets the invite resurrect on the next cold start and on sibling
    617         // devices. Done before the local delete so a query failure can't strand
    618         // a half-left game.
    619         await syncEngine.deleteInvitePingsAfterLeave(forGameID: gameID)
    620 
    621         // Record the leave as a durable per-user fact so the user's other
    622         // devices hard-delete this game too. Without it, a sibling sees only
    623         // the shared-zone deletion — indistinguishable from the owner
    624         // revoking access — and would mislabel the row "no longer have
    625         // access" instead of removing it. Best-effort but self-healing: the
    626         // record is re-consulted on every sync, not consumed once.
    627         await syncEngine.enqueueDecision(kind: "left", key: gameID.uuidString)
    628 
    629         ctx.delete(entity)
    630         try ctx.save()
    631     }
    632 
    633     /// Best-effort cleanup for terminal games. Deleting a game deletes its
    634     /// CloudKit zone and therefore the ticket; completion keeps the zone around
    635     /// for replay/archive, so close the public-link seat explicitly.
    636     func closeTicketForCompletedGame(gameID: UUID) async {
    637         let ctx = persistence.viewContext
    638         let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    639         request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    640         request.fetchLimit = 1
    641         guard let entity = try? ctx.fetch(request).first,
    642               entity.completedAt != nil else { return }
    643 
    644         let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
    645         let ownerName = entity.databaseScope == 0
    646             ? CKCurrentUserDefaultName
    647             : (entity.ckZoneOwnerName ?? CKCurrentUserDefaultName)
    648         let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)
    649         let database = entity.databaseScope == 1
    650             ? container.sharedCloudDatabase
    651             : container.privateCloudDatabase
    652         let ticketID = CKRecord.ID(recordName: Self.ticketRecordName(for: gameID), zoneID: zoneID)
    653 
    654         do {
    655             try await database.deleteRecord(withID: ticketID)
    656             syncMonitor?.note("ticket closed for completed game \(gameID.uuidString)")
    657         } catch let error as CKError where error.code == .unknownItem || error.code == .zoneNotFound {
    658             // Already gone, or the zone was deleted; either way the link seat is closed.
    659         } catch {
    660             syncMonitor?.note(
    661                 "ticket close skipped for \(gameID.uuidString): \(error.localizedDescription)"
    662             )
    663         }
    664     }
    665 
    666     /// Joiner-side seat check, run right after a share acceptance has synced
    667     /// the new zone. Cooperative by design: CloudKit cannot enforce a
    668     /// participant cap, so an over-cap joiner leaves voluntarily and a client
    669     /// that skips the check keeps access until the owner intervenes.
    670     ///
    671     /// Directly invited friends always keep their seat — the owner added them
    672     /// by identity, so the participant list itself is the gate. Link joiners
    673     /// are admitted while the share is under the cap; simultaneous joiners
    674     /// that cannot see each other in the participant list yet are settled by
    675     /// consuming one slot from the zone's ticket Ping. A missing or exhausted
    676     /// ticket without a prior Player-record footprint means the seat went to
    677     /// someone else (or the link predates tickets and is considered dead), so
    678     /// the joiner leaves.
    679     ///
    680     /// Only `ShareError.collaborationLimitReached` escapes. A transient
    681     /// CloudKit failure is traced and the join stands — a failed check must
    682     /// not turn a successful join into a reported failure; the cap then rests
    683     /// on the other cooperating clients.
    684     func confirmSeatAfterJoin(gameID: UUID) async throws {
    685         let ctx = persistence.viewContext
    686         let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    687         request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    688         request.fetchLimit = 1
    689         guard let entity = try? ctx.fetch(request).first,
    690               entity.databaseScope == 1 else { return }
    691 
    692         let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
    693         let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
    694         let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)
    695 
    696         let seatLost: Bool
    697         do {
    698             seatLost = try await hasLostSeat(gameID: gameID, zoneID: zoneID)
    699         } catch {
    700             syncMonitor?.note(
    701                 "join seat check skipped for \(gameID.uuidString): \(error.localizedDescription)"
    702             )
    703             return
    704         }
    705         guard seatLost else { return }
    706 
    707         // Best-effort: if leaving fails the local row lingers, but the limit
    708         // error is still the truthful outcome to surface for this join.
    709         try? await leaveShare(gameID: gameID)
    710         throw ShareError.collaborationLimitReached(maxPeople: Self.maximumPeoplePerPuzzle)
    711     }
    712 
    713     private func hasLostSeat(gameID: UUID, zoneID: CKRecordZone.ID) async throws -> Bool {
    714         let database = container.sharedCloudDatabase
    715         let shareID = CKRecord.ID(recordName: Self.zoneWideShareRecordName, zoneID: zoneID)
    716         guard let share = try await database.record(for: shareID) as? CKShare else {
    717             return false
    718         }
    719         if share.currentUserParticipant?.role == .privateUser { return false }
    720         if Self.inviteeCount(in: share) > Self.maximumInviteesPerPuzzle { return true }
    721         if try await hasNoPlayerFootprint(gameID: gameID, zoneID: zoneID, in: database) == false {
    722             return false
    723         }
    724 
    725         // Under the cap. A simultaneous link joiner may not be visible in the
    726         // participant list yet; consuming a ticket seat settles it atomically.
    727         let ticketID = CKRecord.ID(
    728             recordName: Self.ticketRecordName(for: gameID),
    729             zoneID: zoneID
    730         )
    731         return try await consumeTicketSeat(ticketID: ticketID, in: database) == false
    732     }
    733 
    734     private func hasNoPlayerFootprint(
    735         gameID: UUID,
    736         zoneID: CKRecordZone.ID,
    737         in database: CKDatabase
    738     ) async throws -> Bool {
    739         let myRecordName = try await container.userRecordID().recordName
    740         let playerID = CKRecord.ID(
    741             recordName: RecordSerializer.recordName(
    742                 forPlayerInGame: gameID,
    743                 authorID: myRecordName
    744             ),
    745             zoneID: zoneID
    746         )
    747         do {
    748             _ = try await database.record(for: playerID)
    749             return false
    750         } catch let error as CKError where error.code == .unknownItem {
    751             return true
    752         }
    753     }
    754 
    755     // MARK: - Helpers
    756 
    757     private func fetchExistingShare(
    758         recordName: String,
    759         zoneName: String
    760     ) async throws -> CKShare {
    761         let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
    762         let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID)
    763         let record = try await container.privateCloudDatabase.record(for: recordID)
    764         guard let share = record as? CKShare else {
    765             throw ShareError.invalidShareRecord
    766         }
    767         return share
    768     }
    769 
    770     private func fetchZoneWideShareIfPresent(zoneName: String) async throws -> CKShare? {
    771         do {
    772             return try await fetchExistingShare(
    773                 recordName: Self.zoneWideShareRecordName,
    774                 zoneName: zoneName
    775             )
    776         } catch let error as CKError where isMissingShare(error) {
    777             return nil
    778         }
    779     }
    780 
    781     private func isMissingShare(_ error: CKError) -> Bool {
    782         error.code == .unknownItem || error.code == .zoneNotFound
    783     }
    784 
    785     @discardableResult
    786     private func configureShare(
    787         _ share: CKShare,
    788         title: String?,
    789         publicPermission: CKShare.ParticipantPermission?
    790     ) -> CKShare {
    791         // `nil` leaves the existing public permission untouched — the
    792         // friend-invite path uses it so re-saving a share doesn't disturb a
    793         // link the owner created separately while the seat is still open.
    794         if let publicPermission {
    795             share.publicPermission = publicPermission
    796         }
    797         share[CKShare.SystemFieldKey.title] = title as CKRecordValue?
    798         return share
    799     }
    800 
    801     /// Saves the zone's counted seat ticket. Recreating a public link resets the
    802     /// count to the share's currently available invitee seats, which reopens a
    803     /// freed seat after a participant is removed while keeping a full game closed.
    804     private func setTicketSeats(
    805         _ seats: Int,
    806         claimedAuthorIDs: [String],
    807         for gameID: UUID,
    808         in zoneID: CKRecordZone.ID
    809     ) async throws {
    810         let ticketID = CKRecord.ID(
    811             recordName: Self.ticketRecordName(for: gameID),
    812             zoneID: zoneID
    813         )
    814         let ticket: CKRecord
    815         do {
    816             ticket = try await container.privateCloudDatabase.record(for: ticketID)
    817         } catch let error as CKError where error.code == .unknownItem {
    818             ticket = CKRecord(recordType: "Ping", recordID: ticketID)
    819         }
    820         ticket["kind"] = Self.ticketPingKind as CKRecordValue
    821         ticket["authorID"] = try await container.userRecordID().recordName as CKRecordValue
    822         try Self.setTicketPayload(
    823             TicketPayload(
    824                 version: Self.countedTicketVersion,
    825                 remainingSeats: seats,
    826                 claimedAuthorIDs: Self.normalizedClaimedAuthorIDs(claimedAuthorIDs)
    827             ),
    828             on: ticket
    829         )
    830         do {
    831             _ = try await container.privateCloudDatabase.save(ticket)
    832         } catch let error as CKError where error.code == .serverRecordChanged {
    833             guard let serverTicket = (error as NSError).userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord else {
    834                 throw error
    835             }
    836             serverTicket["kind"] = Self.ticketPingKind as CKRecordValue
    837             serverTicket["authorID"] = try await container.userRecordID().recordName as CKRecordValue
    838             try Self.setTicketPayload(
    839                 TicketPayload(
    840                     version: Self.countedTicketVersion,
    841                     remainingSeats: seats,
    842                     claimedAuthorIDs: Self.normalizedClaimedAuthorIDs(claimedAuthorIDs)
    843                 ),
    844                 on: serverTicket
    845             )
    846             _ = try await container.privateCloudDatabase.save(serverTicket)
    847         }
    848     }
    849 
    850     /// Returns true when this joiner successfully consumed a public-link seat.
    851     /// Legacy tickets have no seat count and are consumed by deleting the record.
    852     private func consumeTicketSeat(ticketID: CKRecord.ID, in database: CKDatabase) async throws -> Bool {
    853         let myRecordName = try await container.userRecordID().recordName
    854         var attempts = 0
    855         var ticket: CKRecord?
    856         while attempts < 4 {
    857             attempts += 1
    858             let record: CKRecord
    859             if let ticket {
    860                 record = ticket
    861             } else {
    862                 do {
    863                     record = try await database.record(for: ticketID)
    864                 } catch let error as CKError where error.code == .unknownItem {
    865                     return false
    866                 }
    867             }
    868 
    869             guard var payload = Self.ticketPayload(in: record) else {
    870                 do {
    871                     try await database.deleteRecord(withID: ticketID)
    872                     return true
    873                 } catch let error as CKError where error.code == .unknownItem {
    874                     return false
    875                 }
    876             }
    877 
    878             var claimedAuthorIDs = Set(payload.claimedAuthorIDs)
    879             if claimedAuthorIDs.contains(myRecordName) {
    880                 return true
    881             }
    882 
    883             guard payload.remainingSeats > 0 else { return false }
    884             claimedAuthorIDs.insert(myRecordName)
    885             payload.version = Self.countedTicketVersion
    886             payload.remainingSeats -= 1
    887             payload.claimedAuthorIDs = Self.normalizedClaimedAuthorIDs(Array(claimedAuthorIDs))
    888             try Self.setTicketPayload(payload, on: record)
    889             do {
    890                 _ = try await database.save(record)
    891                 return true
    892             } catch let error as CKError where error.code == .serverRecordChanged {
    893                 ticket = (error as NSError).userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord
    894             }
    895         }
    896         return false
    897     }
    898 
    899     private static func setTicketPayload(_ payload: TicketPayload, on ticket: CKRecord) throws {
    900         let data = try JSONEncoder().encode(payload)
    901         ticket[ticketPayloadField] = String(decoding: data, as: UTF8.self) as CKRecordValue
    902     }
    903 
    904     private static func ticketPayload(in ticket: CKRecord) -> TicketPayload? {
    905         if let value = ticket[ticketPayloadField] as? String,
    906            let data = value.data(using: .utf8),
    907            let payload = try? JSONDecoder().decode(TicketPayload.self, from: data) {
    908             return payload
    909         }
    910         return nil
    911     }
    912 
    913     private static func normalizedClaimedAuthorIDs(_ authorIDs: [String]) -> [String] {
    914         authorIDs.filter { !$0.isEmpty }.sorted()
    915     }
    916 
    917     private func disablePublicLinkIfNeeded(_ share: CKShare, for gameID: UUID) async throws {
    918         guard share.publicPermission != .none else { return }
    919         share.publicPermission = .none
    920         _ = try await saveShareForLink(share, for: gameID)
    921     }
    922 
    923     private func saveShareForLink(_ share: CKShare, for gameID: UUID) async throws -> CKShare {
    924         let savedRecord = try await container.privateCloudDatabase.save(share)
    925         guard let savedShare = savedRecord as? CKShare else {
    926             throw ShareError.invalidShareRecord
    927         }
    928         try await persistShareName(savedShare.recordID.recordName, for: gameID)
    929         return savedShare
    930     }
    931 
    932     private func shareURL(from share: CKShare) throws -> URL {
    933         guard let url = share.url else {
    934             throw ShareError.missingShareURL
    935         }
    936         return url
    937     }
    938 
    939     private func recoverShareLinkAfterSaveConflict(
    940         _ error: CKError,
    941         for gameID: UUID
    942     ) async throws -> CKShare {
    943         let ctx = persistence.viewContext
    944         let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    945         request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    946         request.fetchLimit = 1
    947         guard let entity = try ctx.fetch(request).first else {
    948             throw ShareError.gameNotFound
    949         }
    950 
    951         let share: CKShare
    952         if let serverShare = (error as NSError).userInfo[CKRecordChangedErrorServerRecordKey] as? CKShare {
    953             share = serverShare
    954         } else {
    955             share = try await fetchExistingShare(
    956                 recordName: Self.zoneWideShareRecordName,
    957                 zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)"
    958             )
    959         }
    960 
    961         // The conflicting save may have been a sibling device committing the
    962         // seat; re-check capacity against the server share before re-opening
    963         // public access.
    964         guard Self.inviteeCount(in: share) < Self.maximumInviteesPerPuzzle else {
    965             throw ShareError.collaborationLimitReached(maxPeople: Self.maximumPeoplePerPuzzle)
    966         }
    967         configureShare(share, title: entity.title, publicPermission: .readWrite)
    968         return try await saveShareForLink(share, for: gameID)
    969     }
    970 
    971     /// CloudKit requires the initial records covered by a new share to already
    972     /// exist on the server or be saved with the share. `CKShareTransferRepresentation`
    973     /// only returns the share, so save the root game record before handing the
    974     /// zone-wide share to the system UI.
    975     private func ensureGameRecordExists(
    976         for entity: GameEntity,
    977         in zoneID: CKRecordZone.ID
    978     ) async throws {
    979         guard let recordName = entity.ckRecordName else {
    980             throw ShareError.invalidGameRecord
    981         }
    982         let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID)
    983         let record: CKRecord
    984         let includePuzzleSource: Bool
    985 
    986         do {
    987             record = try await container.privateCloudDatabase.record(for: recordID)
    988             includePuzzleSource = record["puzzleSource"] == nil
    989         } catch let error as CKError where error.code == .unknownItem {
    990             guard let newRecord = RecordSerializer.gameRecord(
    991                 from: entity,
    992                 recordID: recordID,
    993                 includePuzzleSource: true
    994             ) else {
    995                 throw ShareError.invalidGameRecord
    996             }
    997             record = newRecord
    998             includePuzzleSource = true
    999         }
   1000 
   1001         RecordSerializer.populateGameRecord(
   1002             record,
   1003             from: entity,
   1004             includePuzzleSource: includePuzzleSource
   1005         )
   1006         let saved: CKRecord
   1007         do {
   1008             saved = try await container.privateCloudDatabase.save(record)
   1009         } catch let error as CKError where error.code == .serverRecordChanged {
   1010             let serverRecord: CKRecord
   1011             if let conflictRecord = (error as NSError).userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord {
   1012                 serverRecord = conflictRecord
   1013             } else {
   1014                 serverRecord = try await container.privateCloudDatabase.record(for: recordID)
   1015             }
   1016             RecordSerializer.populateGameRecord(
   1017                 serverRecord,
   1018                 from: entity,
   1019                 includePuzzleSource: serverRecord["puzzleSource"] == nil
   1020             )
   1021             saved = try await container.privateCloudDatabase.save(serverRecord)
   1022         }
   1023         entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: saved)
   1024         entity.lastSyncedAt = Date()
   1025         if entity.ckZoneName == nil {
   1026             entity.ckZoneName = zoneID.zoneName
   1027         }
   1028         try persistence.viewContext.save()
   1029     }
   1030 }