crossmate

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

ShareController.swift (53956B)


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