crossmate

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

PlayerRoster.swift (26008B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Observation
      5 
      6 /// Observable view-model that represents all participants (local + remote)
      7 /// in a single shared game. Drives the "Players" menu in `PuzzleView`.
      8 @Observable
      9 @MainActor
     10 final class PlayerRoster {
     11 
     12     struct Entry: Equatable, Identifiable {
     13         let authorID: String
     14         let name: String
     15         let color: PlayerColor
     16         let isLocal: Bool
     17         var id: String { authorID }
     18     }
     19 
     20     struct RemoteSelection: Equatable {
     21         let authorID: String
     22         let row: Int
     23         let col: Int
     24         let direction: Puzzle.Direction
     25         let color: PlayerColor
     26         let updatedAt: Date
     27     }
     28 
     29     private struct RawSelection {
     30         let authorID: String
     31         let row: Int
     32         let col: Int
     33         let direction: Puzzle.Direction
     34         let updatedAt: Date
     35     }
     36 
     37     /// The Core Data snapshot read off the background context in `refresh()`.
     38     /// A struct rather than a tuple so the empty-game path, the populated
     39     /// return, and the consumers stay in sync by name rather than position.
     40     private struct FetchedRoster {
     41         /// The stable game identity used to seed collaborator colours. A
     42         /// materialized Chronicle has its own derived entity ID, but must keep
     43         /// using the original live game's ID or its palette changes at the
     44         /// live-game → Chronicle boundary.
     45         var colorGameID: UUID?
     46         var databaseScope: Int16 = 0
     47         var ckShareRecordName: String?
     48         var ckZoneName: String?
     49         var ckZoneOwnerName: String?
     50         var namesMap: [String: String] = [:]
     51         var playerAuthorIDs: [String] = []
     52         /// The user's private nicknames (`FriendEntity.nickname`), keyed by
     53         /// authorID. A nickname overrides the peer's own published name
     54         /// everywhere the roster surfaces it (players menu, cursor chips,
     55         /// presence traces).
     56         var nicknamesByAuthor: [String: String] = [:]
     57         var moveAuthorIDs: [String] = []
     58         var rawSelections: [RawSelection] = []
     59         var presenceUntilByAuthor: [String: Date] = [:]
     60         var finalSolveSeconds: Int64?
     61         var completedAt: Date?
     62         var timeLogs: [TimeLog] = []
     63     }
     64 
     65     /// Last-known peer cursor tracks keyed by `authorID`, from the synced
     66     /// Player record. Held unconditionally; visibility is decided at read time
     67     /// by the presence gate in `remoteSelections`. The local player is never
     68     /// present in this map.
     69     private var persistedRemoteSelections: [String: RemoteSelection] = [:]
     70 
     71     /// Each non-local peer's active-session lease (`Player.presenceUntil`). A cursor
     72     /// is shown only while its peer is present (`PeerPresence.isPresent`), so a
     73     /// peer who pauses keeps their cursor and a departed peer's cursor clears
     74     /// when the lease lapses — the same heuristic that gates engagement.
     75     private var remotePresenceUntil: [String: Date] = [:]
     76     private var finalSolveSeconds: Int64?
     77     private var completedAt: Date?
     78     private var timeLogs: [TimeLog] = []
     79 
     80     /// The non-local authorIDs whose lease last read as present, so each
     81     /// present↔absent edge is logged once rather than on every refresh. This
     82     /// is the same gate that shows/hides a peer's cursor, so the log records
     83     /// *when* a remote player actually left — the one thing a co-solve log was
     84     /// previously blind to.
     85     private var lastPresentAuthors: Set<String> = []
     86 
     87     var remoteSelections: [String: RemoteSelection] {
     88         var merged = persistedRemoteSelections
     89         let colorByAuthor = Dictionary(
     90             uniqueKeysWithValues: entries.filter { !$0.isLocal }.map { ($0.authorID, $0.color) }
     91         )
     92         for (authorID, engagement) in engagementStore.selections(for: gameID) {
     93             guard let color = colorByAuthor[authorID] else { continue }
     94             let selection = RemoteSelection(
     95                 authorID: authorID,
     96                 row: engagement.row,
     97                 col: engagement.col,
     98                 direction: engagement.direction,
     99                 color: color,
    100                 updatedAt: engagement.updatedAt
    101             )
    102             if merged[authorID].map({ $0.updatedAt <= selection.updatedAt }) ?? true {
    103                 merged[authorID] = selection
    104             }
    105         }
    106         let now = Date()
    107         return merged.filter { PeerPresence.isPresent(presenceUntil: remotePresenceUntil[$0.key], asOf: now) }
    108     }
    109 
    110     private(set) var entries: [Entry] = []
    111     private(set) var localAuthorID: String?
    112 
    113     /// Active solve time across every player in this game as of `now` — the
    114     /// length of the union of all devices' play intervals, so simultaneous
    115     /// co-solving counts once and disjoint play sums. In-progress sessions are
    116     /// extrapolated to `now`, so the puzzle clock ticks between syncs simply by
    117     /// re-reading this with a fresh date. Once the game is finished the union is
    118     /// bounded at `completedAt`, freezing the displayed value at the win.
    119     func solveTime(asOf now: Date = Date()) -> TimeInterval {
    120         guard !isStaticPreview else { return 0 }
    121         // A materialised archive has no `timeLog` rows to union — it carries the
    122         // frozen final time (whole seconds) the live clock reached.
    123         if let finalSolveSeconds {
    124             return TimeInterval(finalSolveSeconds)
    125         }
    126         let asOf = completedAt.map { min(now, $0) } ?? now
    127         return TimeLog.accumulatedSeconds(
    128             forLogs: timeLogs,
    129             localDeviceID: RecordSerializer.localDeviceID,
    130             asOf: asOf
    131         )
    132     }
    133 
    134     private let gameID: UUID
    135     private let authorIdentity: AuthorIdentity
    136     private let preferences: PlayerPreferences
    137     private let persistence: PersistenceController
    138     private let container: CKContainer
    139     private let engagementStore: EngagementStore
    140     private let tracer: (@MainActor @Sendable (String) -> Void)?
    141     private let isStaticPreview: Bool
    142 
    143     private var cachedShare: CKShare?
    144     private var observationTasks: [Task<Void, Never>] = []
    145     private var lastTracedSignature: String?
    146     private var refreshGeneration = 0
    147     /// One-shot recompute scheduled for the soonest peer lease expiry, so a
    148     /// departed peer's ghost cursor clears precisely when its lease lapses
    149     /// rather than waiting for an unrelated record to nudge a refresh.
    150     private var leaseExpiryTask: Task<Void, Never>?
    151 
    152     init(
    153         gameID: UUID,
    154         authorIdentity: AuthorIdentity,
    155         preferences: PlayerPreferences,
    156         persistence: PersistenceController,
    157         container: CKContainer,
    158         engagementStore: EngagementStore = EngagementStore(),
    159         tracer: (@MainActor @Sendable (String) -> Void)? = nil
    160     ) {
    161         self.gameID = gameID
    162         self.authorIdentity = authorIdentity
    163         self.preferences = preferences
    164         self.persistence = persistence
    165         self.container = container
    166         self.engagementStore = engagementStore
    167         self.tracer = tracer
    168         self.isStaticPreview = false
    169         startObserving()
    170     }
    171 
    172     #if DEBUG
    173     init(
    174         previewGameID gameID: UUID,
    175         localName: String,
    176         localColor: PlayerColor,
    177         remoteSelection: RemoteSelection?
    178     ) {
    179         self.gameID = gameID
    180         self.authorIdentity = AuthorIdentity(testing: "marketing-local")
    181         self.preferences = PlayerPreferences()
    182         self.persistence = PersistenceController(inMemory: true)
    183         self.container = CloudContainer.container
    184         self.engagementStore = EngagementStore()
    185         self.tracer = nil
    186         self.isStaticPreview = true
    187         self.localAuthorID = "marketing-local"
    188         var entries = [
    189             Entry(authorID: "marketing-local", name: localName, color: localColor, isLocal: true),
    190         ]
    191         if let remoteSelection {
    192             entries.append(Entry(
    193                 authorID: remoteSelection.authorID,
    194                 name: "Teammate",
    195                 color: remoteSelection.color,
    196                 isLocal: false
    197             ))
    198             self.persistedRemoteSelections = [remoteSelection.authorID: remoteSelection]
    199             self.remotePresenceUntil = [remoteSelection.authorID: Date().addingTimeInterval(600)]
    200         } else {
    201             self.persistedRemoteSelections = [:]
    202             self.remotePresenceUntil = [:]
    203         }
    204         self.entries = entries
    205     }
    206     #endif
    207 
    208     isolated deinit {
    209         for task in observationTasks {
    210             task.cancel()
    211         }
    212         leaseExpiryTask?.cancel()
    213     }
    214 
    215     // MARK: - Observation
    216 
    217     private func startObserving() {
    218         let gameID = self.gameID
    219 
    220         // Roster-relevant remote changes for this game — a peer's Player
    221         // record (name / cursor), a Game record, a deletion, or a new
    222         // contributor's first Moves row (see `BatchEffects.rosterRelevant`).
    223         // `refresh()` discovers participants from PlayerEntity, the CKShare,
    224         // and Moves authorIDs, so a brand-new collaborator surfaces here even
    225         // before their Player record arrives; repeat moves from a known author
    226         // don't post, since they add nothing to the roster. Invalidate the
    227         // cached share so the next refresh re-fetches it; participant lists
    228         // may have moved.
    229         observationTasks.append(
    230             Task { [weak self] in
    231                 for await note in NotificationCenter.default.notifications(
    232                     named: .playerRosterShouldRefresh
    233                 ) {
    234                     guard let self else { return }
    235                     guard let ids = note.userInfo?["gameIDs"] as? Set<UUID>,
    236                           ids.contains(gameID) else { continue }
    237                     self.cachedShare = nil
    238                     await self.refresh()
    239                 }
    240             }
    241         )
    242     }
    243 
    244     // MARK: - Refresh
    245 
    246     /// Loads the durable local roster without waiting on CKShare. Used before
    247     /// presenting a puzzle so returning to a game never briefly renders an
    248     /// empty Players list while CloudKit metadata is fetched.
    249     func preload() async {
    250         await refresh(includingShare: false)
    251     }
    252 
    253     func refresh() async {
    254         await refresh(includingShare: true)
    255     }
    256 
    257     private func refresh(includingShare: Bool) async {
    258         guard !isStaticPreview else { return }
    259         refreshGeneration += 1
    260         let generation = refreshGeneration
    261         // Without a known local authorID we can't classify any participant as
    262         // self vs. remote, so the only safe answer is an empty roster. The
    263         // next refresh (after AuthorIdentity populates) will do the real work.
    264         guard let localAuthorID = authorIdentity.currentID else {
    265             self.localAuthorID = nil
    266             entries = []
    267             persistedRemoteSelections = [:]
    268             remotePresenceUntil = [:]
    269             finalSolveSeconds = nil
    270             completedAt = nil
    271             timeLogs = []
    272             leaseExpiryTask?.cancel()
    273             leaseExpiryTask = nil
    274             return
    275         }
    276         self.localAuthorID = localAuthorID
    277 
    278         // Pull Core Data fields off a background context without blocking the
    279         // main actor: `await perform` hops to the context's queue and suspends
    280         // (vs. `performAndWait`, which stalls main until the fetch returns).
    281         let ctx = persistence.container.newBackgroundContext()
    282         let fetched = await ctx.perform { [gameID] () -> FetchedRoster in
    283             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    284             req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    285             req.fetchLimit = 1
    286             guard let entity = try? ctx.fetch(req).first else {
    287                 return FetchedRoster()
    288             }
    289             let nameReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    290             nameReq.predicate = NSPredicate(format: "game == %@", entity)
    291             let nameEntities = (try? ctx.fetch(nameReq)) ?? []
    292             var namesMap: [String: String] = [:]
    293             var playerAuthorIDs: [String] = []
    294             var selections: [RawSelection] = []
    295             var presenceUntilByAuthor: [String: Date] = [:]
    296             var timeLogs: [TimeLog] = []
    297             for nr in nameEntities {
    298                 timeLogs.append(TimeLog.decode(nr.timeLog))
    299                 guard let aid = nr.authorID, !aid.isEmpty else { continue }
    300                 playerAuthorIDs.append(aid)
    301                 if let name = nr.name, !name.isEmpty {
    302                     namesMap[aid] = name
    303                 }
    304                 if aid == localAuthorID { continue }
    305                 if let presenceUntil = nr.presenceUntil {
    306                     presenceUntilByAuthor[aid] = presenceUntil
    307                 }
    308                 if let row = nr.selRow,
    309                    let col = nr.selCol,
    310                    let dir = nr.selDir,
    311                    let direction = Puzzle.Direction(rawValue: dir.intValue),
    312                    let updatedAt = nr.updatedAt {
    313                     selections.append(RawSelection(
    314                         authorID: aid,
    315                         row: row.intValue,
    316                         col: col.intValue,
    317                         direction: direction,
    318                         updatedAt: updatedAt
    319                     ))
    320                 }
    321             }
    322             let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    323             movesReq.predicate = NSPredicate(format: "game == %@", entity)
    324             let movesEntities = (try? ctx.fetch(movesReq)) ?? []
    325             var contributorAuthorIDs = Set(movesEntities.compactMap(\.authorID))
    326             // Chronicles retain authorship in cached final cells and replay
    327             // journals rather than live Moves rows. Reading all three sources
    328             // also repairs projections materialised by the first Chronicle
    329             // build, which did not create Player rows.
    330             contributorAuthorIDs.formUnion(
    331                 ((entity.cells as? Set<CellEntity>) ?? [])
    332                     .compactMap(\.letterAuthorID)
    333             )
    334             contributorAuthorIDs.formUnion(
    335                 ((entity.journal as? Set<JournalEntity>) ?? [])
    336                     .compactMap(\.sourceAuthorID)
    337             )
    338             let authorIDs = Array(
    339                 contributorAuthorIDs
    340                     .subtracting([localAuthorID, CKCurrentUserDefaultName, ""])
    341             )
    342             let nicknameReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    343             nicknameReq.predicate = NSPredicate(
    344                 format: "isBlocked == NO AND nickname != nil AND nickname != %@", ""
    345             )
    346             var nicknamesByAuthor: [String: String] = [:]
    347             for friend in (try? ctx.fetch(nicknameReq)) ?? [] {
    348                 guard let aid = friend.authorID, !aid.isEmpty,
    349                       let nickname = friend.nickname, !nickname.isEmpty
    350                 else { continue }
    351                 nicknamesByAuthor[aid] = nickname
    352             }
    353             return FetchedRoster(
    354                 colorGameID: entity.ckRecordName.flatMap(
    355                     Archive.originalGameID(fromName:)
    356                 ) ?? entity.id,
    357                 databaseScope: entity.databaseScope,
    358                 ckShareRecordName: entity.ckShareRecordName,
    359                 ckZoneName: entity.ckZoneName,
    360                 ckZoneOwnerName: entity.ckZoneOwnerName,
    361                 namesMap: namesMap,
    362                 playerAuthorIDs: playerAuthorIDs,
    363                 nicknamesByAuthor: nicknamesByAuthor,
    364                 moveAuthorIDs: authorIDs,
    365                 rawSelections: selections,
    366                 presenceUntilByAuthor: presenceUntilByAuthor,
    367                 finalSolveSeconds: entity.finalSolveSeconds?.int64Value,
    368                 completedAt: entity.completedAt,
    369                 timeLogs: timeLogs
    370             )
    371         }
    372 
    373         guard generation == refreshGeneration else { return }
    374         applyRoster(localAuthorID: localAuthorID, fetched: fetched, share: nil)
    375         scheduleLeaseExpiryRecompute()
    376 
    377         guard includingShare else { return }
    378 
    379         // Fetch the CKShare if not already cached. This can be noticeably
    380         // slower on device, so publish the local Core Data roster first and
    381         // then refine names/participants if share metadata arrives.
    382         let share = await fetchShare(
    383             databaseScope: fetched.databaseScope,
    384             ckShareRecordName: fetched.ckShareRecordName,
    385             ckZoneName: fetched.ckZoneName,
    386             ckZoneOwnerName: fetched.ckZoneOwnerName,
    387             generation: generation
    388         )
    389         guard generation == refreshGeneration else { return }
    390         applyRoster(localAuthorID: localAuthorID, fetched: fetched, share: share)
    391         scheduleLeaseExpiryRecompute()
    392         // Trace only the post-fetch roster. The interim publish above always
    393         // has `share: nil` and would otherwise emit a second signature on
    394         // every refresh, defeating the dedup and producing the
    395         // `share=[]` ↔ `share=[…]` flicker seen in the logs.
    396         traceRoster(
    397             localAuthorID: localAuthorID,
    398             namesMap: fetched.namesMap,
    399             moveAuthorIDs: fetched.moveAuthorIDs,
    400             share: share
    401         )
    402     }
    403 
    404     private func applyRoster(
    405         localAuthorID: String,
    406         fetched: FetchedRoster,
    407         share: CKShare?
    408     ) {
    409         var shareAuthorIDs: [String] = []
    410         if let share {
    411             for participant in share.participants {
    412                 guard participant.acceptanceStatus == .accepted,
    413                       let recordName = participant.userIdentity.userRecordID?.recordName
    414                 else { continue }
    415                 shareAuthorIDs.append(recordName)
    416             }
    417         }
    418 
    419         // Assign each collaborator a colour for this game. Colours are derived
    420         // from (authorID, gameID) and de-conflicted against each other and the
    421         // local user's stable colour, so they are distinct within the game but
    422         // deliberately vary from game to game — see
    423         // `ParticipantSummaries.remoteParticipants`.
    424         let remoteEntries = ParticipantSummaries.remoteParticipants(
    425             gameID: fetched.colorGameID ?? gameID,
    426             namesByAuthor: fetched.namesMap,
    427             moveAuthorIDs: fetched.moveAuthorIDs,
    428             nicknamesByAuthor: fetched.nicknamesByAuthor,
    429             localAuthorID: localAuthorID,
    430             localColor: preferences.color,
    431             additionalAuthorIDs: fetched.playerAuthorIDs + shareAuthorIDs
    432         ).map { participant in
    433             Entry(
    434                 authorID: participant.authorID,
    435                 name: participant.name,
    436                 color: participant.color,
    437                 isLocal: false
    438             )
    439         }
    440 
    441         let localEntry = Entry(
    442             authorID: localAuthorID,
    443             name: preferences.name,
    444             color: preferences.color,
    445             isLocal: true
    446         )
    447         let updatedEntries = [localEntry] + remoteEntries
    448         if entries != updatedEntries {
    449             entries = updatedEntries
    450         }
    451 
    452         // Map raw cursor tracks to the resolved colour from the entry list,
    453         // dropping anything with no matching entry. Visibility (the presence
    454         // gate) is applied lazily in `remoteSelections`, so the last-known
    455         // track is retained here regardless of age.
    456         let colorByAuthor = Dictionary(
    457             uniqueKeysWithValues: remoteEntries.map { ($0.authorID, $0.color) }
    458         )
    459         var tracks: [String: RemoteSelection] = [:]
    460         for raw in fetched.rawSelections {
    461             guard let color = colorByAuthor[raw.authorID] else { continue }
    462             tracks[raw.authorID] = RemoteSelection(
    463                 authorID: raw.authorID,
    464                 row: raw.row,
    465                 col: raw.col,
    466                 direction: raw.direction,
    467                 color: color,
    468                 updatedAt: raw.updatedAt
    469             )
    470         }
    471         persistedRemoteSelections = tracks
    472         remotePresenceUntil = fetched.presenceUntilByAuthor
    473         finalSolveSeconds = fetched.finalSolveSeconds
    474         completedAt = fetched.completedAt
    475         timeLogs = fetched.timeLogs
    476         logPresenceTransitions()
    477     }
    478 
    479     /// Emits a tracer line on each non-local peer's present↔absent edge — the
    480     /// same `presenceUntil`-lease gate that drives their cursor (`remoteSelections`)
    481     /// and the `leaseExpiryTask` recompute. Deduped via `lastPresentAuthors`,
    482     /// so the interim and post-share `applyRoster` of one refresh, plus the
    483     /// lease-expiry refresh, log a transition only once. A departure prints the
    484     /// lapsed lease and how long ago it expired, so the log can answer "when
    485     /// did the peer leave?" instead of leaving it to inference.
    486     private func logPresenceTransitions() {
    487         guard let tracer else { return }
    488         let now = Date()
    489         let present = Set(remotePresenceUntil.keys.filter {
    490             PeerPresence.isPresent(presenceUntil: remotePresenceUntil[$0], asOf: now)
    491         })
    492         guard present != lastPresentAuthors else { return }
    493         let nameByAuthor = Dictionary(
    494             uniqueKeysWithValues: entries.filter { !$0.isLocal }.map { ($0.authorID, $0.name) }
    495         )
    496         let prefix = "PlayerRoster[\(gameID.uuidString.prefix(8))]"
    497         func describe(_ authorID: String) -> String {
    498             "\(authorID.prefix(8)) (\(nameByAuthor[authorID] ?? "?"))"
    499         }
    500         func iso(_ date: Date) -> String {
    501             ISO8601DateFormatter().string(from: date)
    502         }
    503         for authorID in present.subtracting(lastPresentAuthors).sorted() {
    504             let lease = remotePresenceUntil[authorID]
    505             let until = lease.map(iso) ?? "—"
    506             let secs = lease.map { Int($0.timeIntervalSince(now)) } ?? 0
    507             tracer("\(prefix): peer \(describe(authorID)) present (lease until \(until), +\(secs)s)")
    508         }
    509         for authorID in lastPresentAuthors.subtracting(present).sorted() {
    510             let lease = remotePresenceUntil[authorID]
    511             let lapsed = lease.map(iso) ?? "—"
    512             let ago = lease.map { Int(now.timeIntervalSince($0)) } ?? 0
    513             tracer("\(prefix): peer \(describe(authorID)) no longer present (lease \(lapsed) lapsed \(ago)s ago)")
    514         }
    515         lastPresentAuthors = present
    516     }
    517 
    518     /// Schedules a single recompute at the soonest moment a present peer drops
    519     /// out of presence — its `presenceUntil` plus the presence grace, since a lapsed
    520     /// lease still counts as present through the grace. When it fires,
    521     /// `refresh()` re-reads `presenceUntil` (reassigning `remotePresenceUntil`, which triggers
    522     /// observation so the cursor and engagement icon re-evaluate) and
    523     /// reschedules for the next-soonest. No-op when no peer is present.
    524     private func scheduleLeaseExpiryRecompute() {
    525         leaseExpiryTask?.cancel()
    526         leaseExpiryTask = nil
    527         let now = Date()
    528         guard let soonest = remotePresenceUntil.values
    529             .filter({ PeerPresence.isPresent(presenceUntil: $0, asOf: now) })
    530             .min() else { return }
    531         let interval = max(0, soonest.addingTimeInterval(PeerPresence.presenceGrace).timeIntervalSince(now))
    532         leaseExpiryTask = Task { [weak self] in
    533             try? await Task.sleep(for: .seconds(interval))
    534             guard !Task.isCancelled, let self else { return }
    535             await self.refresh()
    536         }
    537     }
    538 
    539     // MARK: - Private helpers
    540 
    541     /// Diagnostic — surfaces the inputs that drive the entries list so we
    542     /// can tell whether a "ghost" authorID came from a stale `PlayerEntity`,
    543     /// a stray `MovesEntity`, or the share's participant list. Called once
    544     /// per refresh after the final `applyRoster` so the interim `share: nil`
    545     /// publish doesn't push a second signature through and defeat the dedup.
    546     private func traceRoster(
    547         localAuthorID: String,
    548         namesMap: [String: String],
    549         moveAuthorIDs: [String],
    550         share: CKShare?
    551     ) {
    552         guard let tracer else { return }
    553         let participantIDs: [String] = share?.participants.compactMap {
    554             $0.userIdentity.userRecordID?.recordName
    555         } ?? []
    556         let signature = "local=\(localAuthorID) | names=\(namesMap.keys.sorted()) | moves=\(moveAuthorIDs.sorted()) | share=\(participantIDs.sorted())"
    557         guard signature != lastTracedSignature else { return }
    558         lastTracedSignature = signature
    559         tracer("PlayerRoster[\(gameID.uuidString.prefix(8))]: \(signature)")
    560     }
    561 
    562     private func fetchShare(
    563         databaseScope: Int16,
    564         ckShareRecordName: String?,
    565         ckZoneName: String?,
    566         ckZoneOwnerName: String?,
    567         generation: Int
    568     ) async -> CKShare? {
    569         if let cached = cachedShare { return cached }
    570         guard let zoneName = ckZoneName else { return nil }
    571         let ownerName = ckZoneOwnerName ?? CKCurrentUserDefaultName
    572         let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)
    573         do {
    574             if databaseScope == 0, let shareRecordName = ckShareRecordName {
    575                 let shareID = CKRecord.ID(recordName: shareRecordName, zoneID: zoneID)
    576                 let share = try await container.privateCloudDatabase.record(for: shareID) as? CKShare
    577                 if generation == refreshGeneration {
    578                     cachedShare = share
    579                 }
    580                 return share
    581             } else if databaseScope == 1 {
    582                 let shareID = CKRecord.ID(recordName: CKRecordNameZoneWideShare, zoneID: zoneID)
    583                 let share = try await container.sharedCloudDatabase.record(for: shareID) as? CKShare
    584                 if generation == refreshGeneration {
    585                     cachedShare = share
    586                 }
    587                 return share
    588             }
    589         } catch {
    590             // Best effort — proceed without share metadata.
    591         }
    592         return nil
    593     }
    594 
    595 }