crossmate

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

InviteCoordinator.swift (38084B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import UserNotifications
      5 
      6 /// Owns the friend-zone traffic that used to live in `AppServices`: outbound
      7 /// game invites, inbound `Ping` handling (claim/dedup, staleness GC, local
      8 /// notification presentation, friendship-bootstrap dispatch), the durable
      9 /// `InviteEntity` rows behind the library's "Invited" section, and friend
     10 /// blocking. `AppServices` composes one instance and forwards the
     11 /// `SyncEngine` ping callbacks into it; the accept/decline/block entry
     12 /// points are surfaced to the UI through environment closures.
     13 @MainActor
     14 final class InviteCoordinator {
     15     enum InviteAcceptanceError: LocalizedError {
     16         case unavailable
     17 
     18         var errorDescription: String? {
     19             switch self {
     20             case .unavailable:
     21                 return "This invite is no longer available. Ask the sender to invite you again."
     22             }
     23         }
     24     }
     25 
     26     private let persistence: PersistenceController
     27     private let identity: AuthorIdentity
     28     private let preferences: PlayerPreferences
     29     private let syncMonitor: SyncMonitor
     30     private let eventLog: EventLog
     31     private let syncEngine: SyncEngine
     32     private let announcements: AnnouncementCenter
     33     private let shareController: ShareController
     34     private let friendController: FriendController
     35     private let cloudService: CloudService
     36     /// Refreshes the app-icon badge — `BadgeCoordinator.refreshAppBadge` —
     37     /// whenever invite rows change (pending invites count toward the badge).
     38     private let refreshAppBadge: (String) async -> Void
     39 
     40     private var claimedPingRecordNames: Set<String> = []
     41     private var claimedPingRecordNameOrder: [String] = []
     42     private let claimedPingRecordNameCap = 200
     43 
     44     init(
     45         persistence: PersistenceController,
     46         identity: AuthorIdentity,
     47         preferences: PlayerPreferences,
     48         syncMonitor: SyncMonitor,
     49         eventLog: EventLog,
     50         syncEngine: SyncEngine,
     51         announcements: AnnouncementCenter,
     52         shareController: ShareController,
     53         friendController: FriendController,
     54         cloudService: CloudService,
     55         refreshAppBadge: @escaping (String) async -> Void
     56     ) {
     57         self.persistence = persistence
     58         self.identity = identity
     59         self.preferences = preferences
     60         self.syncMonitor = syncMonitor
     61         self.eventLog = eventLog
     62         self.syncEngine = syncEngine
     63         self.announcements = announcements
     64         self.shareController = shareController
     65         self.friendController = friendController
     66         self.cloudService = cloudService
     67         self.refreshAppBadge = refreshAppBadge
     68     }
     69 
     70     /// Re-invites an existing friend to a game: adds them as a participant on
     71     /// the game's `CKShare` and writes an `.invite` Ping into the friend zone.
     72     /// Surfaced to the UI via the `\.inviteFriend` environment closure.
     73     func inviteFriend(gameID: UUID, friendAuthorID: String) async throws {
     74         guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else {
     75             throw FriendController.FriendError.friendNotFound
     76         }
     77         let ctx = persistence.viewContext
     78         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     79         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
     80         req.fetchLimit = 1
     81         let game = try? ctx.fetch(req).first
     82         let title = game?.title ?? ""
     83 
     84         // Encode the grid silhouette the same way share links do, so the
     85         // recipient can preview the puzzle in their "Invited" row. `nil` when
     86         // the layout cache is unpopulated, which simply gets no preview.
     87         let shape = shareController.gridSilhouette(for: gameID)
     88         let silhouette = shape.flatMap {
     89             GridSilhouette.encode(width: $0.width, height: $0.height, blocks: $0.blocks)
     90         }
     91 
     92         // Carry the puzzle's XD source in the invite. The recipient already
     93         // syncs the friend zone, so they receive it with the Ping — letting
     94         // the accept path build a playable game without waiting on the shared
     95         // zone fetch. Everything the recipient's GameEntity needs derives from
     96         // this source (as in `GameStore.createGame`); the canonical Game record
     97         // then merges in as a background update.
     98         let puzzleSource = game?.puzzleSource
     99 
    100         let url = try await shareController.addFriendParticipant(
    101             toGameID: gameID,
    102             userRecordName: friendAuthorID
    103         )
    104         try await friendController.sendInvite(
    105             toFriendAuthorID: friendAuthorID,
    106             gameID: gameID,
    107             gameTitle: title,
    108             inviterAuthorID: localAuthorID,
    109             inviterName: preferences.name,
    110             gameShareURL: url,
    111             gridSilhouette: silhouette,
    112             puzzleSource: puzzleSource
    113         )
    114     }
    115 
    116     /// For each collaborative game with newly-known remote authors, asks the
    117     /// `FriendController` to bootstrap a friendship. `establishIfOwner` is a
    118     /// no-op for the non-owner and for already-established pairs (a defensive
    119     /// backstop — the caller already fires this only on a Player record's
    120     /// first sighting, so it runs about once per new collaborator).
    121     func reconcileFriendships(forGameIDs gameIDs: Set<UUID>) async {
    122         guard preferences.isICloudSyncEnabled,
    123               let localAuthorID = identity.currentID,
    124               !localAuthorID.isEmpty
    125         else { return }
    126 
    127         let ctx = persistence.container.newBackgroundContext()
    128         let candidates: [(gameID: UUID, remoteAuthorID: String)] = ctx.performAndWait {
    129             var result: [(UUID, String)] = []
    130             for gameID in gameIDs {
    131                 let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    132                 gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    133                 gReq.fetchLimit = 1
    134                 guard let game = try? ctx.fetch(gReq).first else { continue }
    135                 // Only collaborative games carry other authors.
    136                 guard game.databaseScope == 1 || game.ckShareRecordName != nil else { continue }
    137 
    138                 // Identity comes only from Player records — this feature is
    139                 // deliberately uninterested in Moves (the bootstrap trigger is
    140                 // the first sighting of a remote Player record). Display names
    141                 // are not gathered here: they ride `name` Decisions in the
    142                 // friend zone itself.
    143                 var remoteAuthorIDs = Set<String>()
    144                 let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    145                 pReq.predicate = NSPredicate(format: "game == %@", game)
    146                 for p in (try? ctx.fetch(pReq)) ?? [] {
    147                     guard let authorID = p.authorID else { continue }
    148                     remoteAuthorIDs.insert(authorID)
    149                 }
    150                 remoteAuthorIDs.remove(localAuthorID)
    151                 remoteAuthorIDs.remove(CKCurrentUserDefaultName)
    152                 remoteAuthorIDs.remove("")
    153                 for authorID in remoteAuthorIDs {
    154                     result.append((gameID, authorID))
    155                 }
    156             }
    157             return result
    158         }
    159 
    160         for (gameID, remoteAuthorID) in candidates {
    161             await friendController.establishIfOwner(
    162                 localAuthorID: localAuthorID,
    163                 remoteAuthorID: remoteAuthorID,
    164                 localDisplayName: preferences.name,
    165                 viaGameID: gameID
    166             )
    167         }
    168     }
    169 
    170     /// Upserts a durable `InviteEntity` for each inbound `.invite` Ping so the
    171     /// Game List's "Invited" section survives the Ping being GC'd. Skips
    172     /// self-authored invites, invites not directed to this author, invites
    173     /// from blocked friends, games already joined, and any `pingRecordName`
    174     /// already seen (a declined row is a tombstone that prevents
    175     /// resurrection).
    176     /// Returns the `pingRecordName`s of invites whose durable row was created
    177     /// for the first time by this call — i.e. invites that have just synced
    178     /// from the server. The caller uses this to notify exactly once: a pending
    179     /// invite's Ping is re-fetched on every cold start, but its row already
    180     /// exists by then, so it is absent from this set and isn't re-surfaced.
    181     @discardableResult
    182     private func applyInvitePings(_ pings: [Ping]) -> Set<String> {
    183         let invites = pings.filter {
    184             $0.kind == .invite &&
    185             $0.authorID != identity.currentID &&
    186             $0.addressee == identity.currentID
    187         }
    188         guard !invites.isEmpty else { return [] }
    189 
    190         let ctx = persistence.container.newBackgroundContext()
    191         return ctx.performAndWait {
    192             var insertedPingRecordNames: Set<String> = []
    193             for ping in invites {
    194                 guard let payload = FriendZone.InvitePayload.decode(ping.payload) else { continue }
    195 
    196                 let dupReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    197                 dupReq.predicate = NSPredicate(format: "pingRecordName == %@", ping.recordName)
    198                 dupReq.fetchLimit = 1
    199                 if ((try? ctx.count(for: dupReq)) ?? 0) > 0 { continue }
    200 
    201                 let invite = InviteEntity(context: ctx)
    202                 invite.gameID = ping.gameID
    203                 invite.gameTitle = ping.puzzleTitle
    204                 invite.inviterAuthorID = ping.authorID
    205                 invite.inviterName = ping.playerName
    206                 invite.shareURL = payload.gameShareURL
    207                 invite.gridSilhouette = payload.gridSilhouette
    208                 invite.puzzleSource = payload.puzzleSource
    209                 invite.pingRecordName = ping.recordName
    210                 invite.status = "pending"
    211                 invite.createdAt = Date()
    212                 insertedPingRecordNames.insert(ping.recordName)
    213             }
    214 
    215             // GC: a pending invite whose game now exists locally was joined by
    216             // some other path (a link, or accepted on another device), so the
    217             // "Invited" row is stale — drop it.
    218             let pendingReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    219             pendingReq.predicate = NSPredicate(format: "status == %@", "pending")
    220             for invite in (try? ctx.fetch(pendingReq)) ?? [] {
    221                 guard let gid = invite.gameID else { continue }
    222                 let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    223                 gReq.predicate = NSPredicate(format: "id == %@", gid as CVarArg)
    224                 gReq.fetchLimit = 1
    225                 if ((try? ctx.count(for: gReq)) ?? 0) > 0 { ctx.delete(invite) }
    226             }
    227 
    228             if ctx.hasChanges {
    229                 do {
    230                     try ctx.save()
    231                 } catch {
    232                     // The rows didn't persist, so treat none as "freshly
    233                     // recorded" — a later fetch will re-create and notify.
    234                     insertedPingRecordNames.removeAll()
    235                     Task { @MainActor [weak self] in
    236                         self?.eventLog.note("InviteCoordinator: applyInvitePings save failed — \(error)", level: "error")
    237                     }
    238                 }
    239             }
    240             return insertedPingRecordNames
    241         }
    242     }
    243 
    244     /// Drops the pending invite row(s) for `gameID`. Called when the game's
    245     /// shared zone appears locally (joined here or on a sibling device): the
    246     /// "Invited" row is now redundant. `applyInvitePings` runs the same
    247     /// garbage-collection over every pending invite, but only when a ping is
    248     /// fetched — hooking zone arrival closes the window where a just-synced
    249     /// game and its stale invite show side by side in the library.
    250     func removePendingInvite(forGameID gameID: UUID) throws {
    251         let ctx = persistence.viewContext
    252         let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    253         req.predicate = NSPredicate(
    254             format: "gameID == %@ AND status == %@", gameID as CVarArg, "pending"
    255         )
    256         let invites = try ctx.fetch(req)
    257         guard !invites.isEmpty else { return }
    258         for invite in invites { ctx.delete(invite) }
    259         if ctx.hasChanges {
    260             try ctx.save()
    261         }
    262     }
    263 
    264     /// Drops pending invite rows whose source Ping was consumed elsewhere on
    265     /// this account. Matching by record name avoids conflating invite Pings
    266     /// with other Ping kinds for the same game.
    267     func removePendingInvites(forPingRecordNames recordNames: Set<String>) throws {
    268         guard !recordNames.isEmpty else { return }
    269         let ctx = persistence.viewContext
    270         let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    271         req.predicate = NSPredicate(
    272             format: "pingRecordName IN %@ AND status == %@",
    273             Array(recordNames),
    274             "pending"
    275         )
    276         let invites = try ctx.fetch(req)
    277         guard !invites.isEmpty else { return }
    278         for invite in invites { ctx.delete(invite) }
    279         if ctx.hasChanges {
    280             try ctx.save()
    281         }
    282     }
    283 
    284     /// Accepts a pending game invite: fetches the share metadata, joins via
    285     /// the existing share-accept path, then drops the local `InviteEntity`
    286     /// (the game now represents it). If CloudKit says the share URL no longer
    287     /// exists, the durable invite row is stale, so it is removed as well.
    288     /// Surfaced via `\.acceptInvite`.
    289     @discardableResult
    290     func acceptInvite(shareURL: String, pingRecordName: String) async throws -> CloudService.AcceptOutcome {
    291         guard let url = URL(string: shareURL) else {
    292             throw FriendController.FriendError.missingShareURLInPayload
    293         }
    294         // The invite carried the puzzle's XD source, so hand it to the accept
    295         // path: it builds a playable game from this without waiting on the
    296         // shared-zone fetch. nil for invites from older senders or already
    297         // consumed rows — the accept path then fetches as before.
    298         let prefetchedPuzzleSource = puzzleSource(forPingRecordName: pingRecordName)
    299         let outcome: CloudService.AcceptOutcome
    300         do {
    301             outcome = try await cloudService.acceptShare(
    302                 url: url,
    303                 prefetchedPuzzleSource: prefetchedPuzzleSource
    304             )
    305         } catch let error as CKError where error.code == .unknownItem {
    306             // Stale share: the row needs to go away too, but the next
    307             // `applyInvitePings` will GC it if this cleanup itself fails.
    308             // The user-visible signal here is `.unavailable`, so don't let a
    309             // cleanup error clobber it — log and continue.
    310             do {
    311                 try await deleteInviteAndPing(pingRecordName: pingRecordName)
    312                 syncMonitor.note("accept invite: removed stale invite \(pingRecordName)")
    313             } catch {
    314                 syncMonitor.note("accept invite: stale-invite cleanup failed for \(pingRecordName) — \(error)")
    315             }
    316             throw InviteAcceptanceError.unavailable
    317         }
    318         try await deleteInviteAndPing(pingRecordName: pingRecordName)
    319         return outcome
    320     }
    321 
    322     /// Accepts the pending invite for `gameID`, if one is still recorded
    323     /// locally. The puzzle-display join path calls this when the user reached
    324     /// a not-yet-joined shared game by tapping its `.invite` notification:
    325     /// that tap only navigates, so the CKShare must still be accepted here.
    326     /// The durable `InviteEntity` (written when the `.invite` Ping arrived)
    327     /// carries the share URL. Throws `InviteAcceptanceError.unavailable` when
    328     /// no such invite exists — the same error the stale-share case surfaces.
    329     func acceptPendingInvite(gameID: UUID) async throws {
    330         let ctx = persistence.viewContext
    331         let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    332         req.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg)
    333         req.sortDescriptors = [
    334             NSSortDescriptor(keyPath: \InviteEntity.createdAt, ascending: false)
    335         ]
    336         req.fetchLimit = 1
    337         guard let invite = (try? ctx.fetch(req))?.first,
    338               let shareURL = invite.shareURL,
    339               let pingRecordName = invite.pingRecordName
    340         else {
    341             throw InviteAcceptanceError.unavailable
    342         }
    343         try await acceptInvite(shareURL: shareURL, pingRecordName: pingRecordName)
    344     }
    345 
    346     /// The XD source recorded on the durable invite for `pingRecordName`, if
    347     /// the inviting build carried one. Read just before acceptance so the
    348     /// accept path can construct a playable game without the shared-zone fetch.
    349     private func puzzleSource(forPingRecordName pingRecordName: String) -> String? {
    350         let ctx = persistence.viewContext
    351         let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    352         req.predicate = NSPredicate(format: "pingRecordName == %@", pingRecordName)
    353         req.fetchLimit = 1
    354         guard let source = (try? ctx.fetch(req))?.first?.puzzleSource, !source.isEmpty else {
    355             return nil
    356         }
    357         return source
    358     }
    359 
    360     private func deleteInviteAndPing(pingRecordName: String) async throws {
    361         let ctx = persistence.viewContext
    362         let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    363         req.predicate = NSPredicate(format: "pingRecordName == %@", pingRecordName)
    364         for invite in try ctx.fetch(req) {
    365             if let inviterAuthorID = invite.inviterAuthorID {
    366                 await friendController.deleteFriendZonePing(
    367                     fromFriendAuthorID: inviterAuthorID,
    368                     recordName: pingRecordName
    369                 )
    370             }
    371             ctx.delete(invite)
    372         }
    373         if ctx.hasChanges {
    374             try ctx.save()
    375             await refreshAppBadge("delete invite")
    376         }
    377     }
    378 
    379     /// Declines a pending game invite: marks the durable `InviteEntity` rows for
    380     /// `gameID` as a `"declined"` tombstone (which prevents the invite from
    381     /// resurrecting locally if CloudKit deletion is delayed), sends a `.decline`
    382     /// Ping back to each inviter so they free our seat and see a banner, consumes
    383     /// the source invite Ping so sibling devices clear their rows, and refreshes
    384     /// the badge. Surfaced via `\.declineInvite`; the AppServices entry point
    385     /// keeps invite mutation and the badge refresh in one place, mirroring
    386     /// `acceptInvite`.
    387     func declineInvite(gameID: UUID) async throws {
    388         let ctx = persistence.viewContext
    389         let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    390         req.predicate = NSPredicate(
    391             format: "gameID == %@ AND status == %@", gameID as CVarArg, "pending"
    392         )
    393         let invites = try ctx.fetch(req)
    394         guard !invites.isEmpty else { return }
    395         let declined = invites.compactMap { invite -> (inviterAuthorID: String, pingRecordName: String, gameTitle: String)? in
    396             guard let inviterAuthorID = invite.inviterAuthorID,
    397                   let pingRecordName = invite.pingRecordName
    398             else { return nil }
    399             return (inviterAuthorID, pingRecordName, invite.gameTitle ?? "")
    400         }
    401         for invite in invites {
    402             invite.status = "declined"
    403         }
    404         if ctx.hasChanges {
    405             try ctx.save()
    406             await refreshAppBadge("decline invite")
    407         }
    408         let declinerAuthorID = identity.currentID
    409         let declinerName = preferences.name
    410         for (inviterAuthorID, pingRecordName, gameTitle) in declined {
    411             // Tell the inviter so they free our seat and get a banner. Best
    412             // effort — a failed send must not strand the local tombstone or
    413             // block the source-Ping cleanup; the inviter can always re-invite.
    414             if let declinerAuthorID, !declinerAuthorID.isEmpty {
    415                 do {
    416                     try await friendController.sendDecline(
    417                         toInviterAuthorID: inviterAuthorID,
    418                         gameID: gameID,
    419                         gameTitle: gameTitle,
    420                         declinerAuthorID: declinerAuthorID,
    421                         declinerName: declinerName
    422                     )
    423                 } catch {
    424                     syncMonitor.note("decline invite: send decline failed for \(gameID.uuidString) — \(error.localizedDescription)")
    425                 }
    426             }
    427             await friendController.deleteFriendZonePing(
    428                 fromFriendAuthorID: inviterAuthorID,
    429                 recordName: pingRecordName
    430             )
    431         }
    432     }
    433 
    434     /// Blocks a collaborator: marks the friendship blocked and tears down the
    435     /// friend zone, leaves every game they currently share with us, and drops
    436     /// their pending invites. Games we *own* that they joined are untouched.
    437     /// Surfaced via `\.blockFriend`.
    438     func blockFriend(authorID: String) async {
    439         do {
    440             try await friendController.blockAndTeardown(friendAuthorID: authorID)
    441         } catch {
    442             announcements.post(Announcement(
    443                 id: "block-friend-error-\(authorID)",
    444                 scope: .global,
    445                 severity: .error,
    446                 title: "Blocking Failed",
    447                 body: error.localizedDescription,
    448                 dismissal: .manual
    449             ))
    450             return
    451         }
    452 
    453         let ctx = persistence.container.newBackgroundContext()
    454         let gameIDsToLeave: [UUID] = ctx.performAndWait {
    455             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    456             req.predicate = NSPredicate(format: "databaseScope == 1")
    457             var ids: [UUID] = []
    458             for game in (try? ctx.fetch(req)) ?? [] {
    459                 guard let gid = game.id else { continue }
    460                 var authors = Set<String>()
    461                 if let owner = game.ckZoneOwnerName { authors.insert(owner) }
    462                 let mReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    463                 mReq.predicate = NSPredicate(format: "game == %@", game)
    464                 for m in (try? ctx.fetch(mReq)) ?? [] {
    465                     if let a = m.authorID { authors.insert(a) }
    466                 }
    467                 let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    468                 pReq.predicate = NSPredicate(format: "game == %@", game)
    469                 for p in (try? ctx.fetch(pReq)) ?? [] {
    470                     if let a = p.authorID { authors.insert(a) }
    471                 }
    472                 if authors.contains(authorID) { ids.append(gid) }
    473             }
    474             return ids
    475         }
    476         for gid in gameIDsToLeave {
    477             try? await shareController.leaveShare(gameID: gid)
    478         }
    479 
    480         // Drop their pending invites. Future inbound `.invite` Pings from a
    481         // blocked sender are caught by `consumeStaleInvites`, which deletes
    482         // the Ping so it doesn't re-fire across cold starts.
    483         let vctx = persistence.viewContext
    484         let iReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    485         iReq.predicate = NSPredicate(format: "inviterAuthorID == %@", authorID)
    486         for invite in (try? vctx.fetch(iReq)) ?? [] { vctx.delete(invite) }
    487         if vctx.hasChanges {
    488             try? vctx.save()
    489             await refreshAppBadge("block friend")
    490         }
    491     }
    492 
    493     /// Deletes `.invite` Pings that are no longer actionable on this device —
    494     /// the game is already in the local library (joined here or on a sibling),
    495     /// or the inviter is blocked — and returns the surviving pings. Running
    496     /// this upstream of both `applyInvitePings` and the notification loop
    497     /// keeps the staleness rule in one place; without it, an orphaned invite
    498     /// Ping re-fires a notification on every cold start because the in-memory
    499     /// dedup caches reset.
    500     private func consumeStaleInvites(_ pings: [Ping]) async -> [Ping] {
    501         let candidates = pings.filter {
    502             $0.kind == .invite &&
    503             $0.authorID != identity.currentID &&
    504             $0.addressee == identity.currentID
    505         }
    506         guard !candidates.isEmpty else { return pings }
    507 
    508         let currentAuthorID = identity.currentID
    509         let ctx = persistence.container.newBackgroundContext()
    510         let staleNames: Set<String> = ctx.performAndWait {
    511             Self.staleInviteRecordNames(
    512                 among: candidates,
    513                 in: ctx,
    514                 currentAuthorID: currentAuthorID
    515             )
    516         }
    517         guard !staleNames.isEmpty else { return pings }
    518 
    519         for ping in candidates where staleNames.contains(ping.recordName) {
    520             await friendController.deleteFriendZonePing(
    521                 fromFriendAuthorID: ping.authorID,
    522                 recordName: ping.recordName
    523             )
    524             syncMonitor.note(
    525                 "ping(invite): consumed stale invite \(ping.recordName) for \(ping.gameID.uuidString)"
    526             )
    527         }
    528         return pings.filter { !staleNames.contains($0.recordName) }
    529     }
    530 
    531     nonisolated static func staleInviteRecordNames(
    532         among pings: [Ping],
    533         in ctx: NSManagedObjectContext,
    534         currentAuthorID: String?
    535     ) -> Set<String> {
    536         var names: Set<String> = []
    537         for ping in pings where ping.kind == .invite &&
    538             ping.authorID != currentAuthorID &&
    539             ping.addressee == currentAuthorID {
    540             guard FriendZone.InvitePayload.decode(ping.payload) != nil else {
    541                 names.insert(ping.recordName)
    542                 continue
    543             }
    544 
    545             let blockedReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    546             blockedReq.predicate = NSPredicate(
    547                 format: "authorID == %@ AND isBlocked == YES", ping.authorID
    548             )
    549             blockedReq.fetchLimit = 1
    550             if ((try? ctx.count(for: blockedReq)) ?? 0) > 0 {
    551                 names.insert(ping.recordName)
    552                 continue
    553             }
    554 
    555             let inviteReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    556             inviteReq.predicate = NSPredicate(format: "pingRecordName == %@", ping.recordName)
    557             inviteReq.fetchLimit = 1
    558             if let invite = try? ctx.fetch(inviteReq).first,
    559                invite.status != "pending" {
    560                 names.insert(ping.recordName)
    561                 continue
    562             }
    563 
    564             let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    565             gameReq.predicate = NSPredicate(format: "id == %@", ping.gameID as CVarArg)
    566             gameReq.fetchLimit = 1
    567             if ((try? ctx.count(for: gameReq)) ?? 0) > 0 {
    568                 names.insert(ping.recordName)
    569             }
    570         }
    571         return names
    572     }
    573 
    574     func presentPings(_ pings: [Ping]) async {
    575         let claimed = claimPingsForHandling(pings)
    576         guard !claimed.isEmpty else { return }
    577         let pings = await consumeStaleInvites(claimed)
    578         guard !pings.isEmpty else { return }
    579         let newlyInvited = applyInvitePings(pings)
    580         // Reflect any newly-stored pending invite in the app-icon badge now —
    581         // before the notification-authorization guard — so the badge updates
    582         // even when the banner is suppressed or unauthorized.
    583         await refreshAppBadge("present pings")
    584         // `.friend` is the friendship-bootstrap handshake. `.join` and `.hail`
    585         // are legacy live-notification/bootstrap kinds; APNs and Game-record
    586         // engagement creds own those jobs now. System pings do not require
    587         // notification authorization.
    588         let (systemPings, playerFacingPings) = pings.partitioned {
    589             $0.kind == .friend || $0.kind == .join || $0.kind == .hail
    590         }
    591         for ping in systemPings where ping.kind == .friend {
    592             await friendController.applyFriendPing(
    593                 ping,
    594                 localAuthorID: identity.currentID,
    595                 localDisplayName: preferences.name
    596             )
    597         }
    598         // Free the seat for any invitee who declined. Done before the
    599         // notification-authorization gate so the share frees even when the
    600         // banner can't be shown; the banner itself is queued by the loop below.
    601         for ping in playerFacingPings where ping.kind == .decline {
    602             await applyDeclinePing(ping)
    603         }
    604         guard !playerFacingPings.isEmpty else { return }
    605         guard await canPresentNotifications() else {
    606             syncMonitor.note("ping: local notification skipped — authorization not granted")
    607             return
    608         }
    609 
    610         let center = UNUserNotificationCenter.current()
    611         for ping in playerFacingPings {
    612             if ping.kind == .invite, ping.addressee != identity.currentID {
    613                 continue
    614             }
    615             // A directed ping (`addressee` set) targets one player by
    616             // authorID. Ignore one addressed to someone else — another user's
    617             // device receives and consumes it. nil ⇒ broadcast, which is now
    618             // legacy and ignored for `.invite`.
    619             if let addressee = ping.addressee, addressee != identity.currentID {
    620                 continue
    621             }
    622             if ping.authorID == identity.currentID {
    623                 syncMonitor.note("ping(\(ping.kind.rawValue)): skipped self-authored record \(ping.recordName)")
    624                 continue
    625             }
    626             // A directed ping addressed to us is consumed by this account:
    627             // once handled — shown, suppressed, or a duplicate — delete it so
    628             // it stops re-notifying and the deletion withdraws any copy our
    629             // sibling devices showed. Broadcast pings are left as-is. `.decline`
    630             // is excluded: it lives in the friend zone, not the game zone this
    631             // path deletes from, so `applyDeclinePing` consumes it instead.
    632             let consume = ping.addressee != nil
    633                 && ping.kind != .invite
    634                 && ping.kind != .decline
    635             func consumeIfDirected() async {
    636                 guard consume else { return }
    637                 await syncEngine.deletePing(recordName: ping.recordName, gameID: ping.gameID)
    638             }
    639             if NotificationState.isSuppressed(gameID: ping.gameID) {
    640                 syncMonitor.note("ping(\(ping.kind.rawValue)): suppressed — puzzle is active for \(ping.gameID.uuidString)")
    641                 await consumeIfDirected()
    642                 continue
    643             }
    644             // Notify for an invite only the first time it syncs from the
    645             // server (its durable row was just created). A still-pending
    646             // invite's Ping is re-fetched on every cold start; without this
    647             // gate the banner would repeat on every app open until the invite
    648             // is accepted or declined. The Invited row itself is unaffected —
    649             // `applyInvitePings` keeps it regardless.
    650             if ping.kind == .invite, !newlyInvited.contains(ping.recordName) {
    651                 syncMonitor.note("ping(invite): already recorded, not re-notifying for \(ping.gameID.uuidString)")
    652                 continue
    653             }
    654             // Invite banners are user-toggleable; the invite row itself still
    655             // lands in the Invited section through `applyInvitePings`.
    656             if ping.kind == .invite, !preferences.notifiesInvitations {
    657                 syncMonitor.note("ping(invite): banner disabled in settings for \(ping.gameID.uuidString)")
    658                 continue
    659             }
    660 
    661             let content = UNMutableNotificationContent()
    662             content.title = "Crossmate"
    663             // Local notifications never pass through the Notification Service
    664             // Extension, so the nickname substitution the NSE does for worker
    665             // pushes happens here instead, off the same App Group directory.
    666             content.body = Self.bodyText(
    667                 for: ping,
    668                 nickname: NicknameDirectory.entry(for: ping.authorID)?.nickname
    669             )
    670             content.sound = .default
    671             content.userInfo = [
    672                 "gameID": ping.gameID.uuidString,
    673                 "pingKind": ping.kind.rawValue
    674             ]
    675 
    676             let request = UNNotificationRequest(
    677                 identifier: "ping-\(ping.gameID.uuidString)-\(UUID().uuidString)",
    678                 content: content,
    679                 trigger: nil
    680             )
    681             do {
    682                 try await center.add(request)
    683                 syncMonitor.note("ping(\(ping.kind.rawValue)): queued local notification for \(ping.gameID.uuidString)")
    684                 await consumeIfDirected()
    685             } catch {
    686                 syncMonitor.note("ping(\(ping.kind.rawValue)): local notification failed — \(error.localizedDescription)")
    687             }
    688         }
    689     }
    690 
    691     /// Frees the seat held by an invitee who declined: asks `ShareController`
    692     /// to remove them from the game's `CKShare` so the owner can invite someone
    693     /// else, then consumes the `.decline` Ping from the friend zone so it stops
    694     /// re-firing. The decliner is the ping's author; only the addressed owner
    695     /// acts. The ping is consumed only on a successful free — a transient
    696     /// failure leaves it so the next sync retries rather than stranding the
    697     /// seat — which also means releasing the in-memory handling claim, since a
    698     /// claimed record is skipped on re-delivery and would otherwise suppress
    699     /// the retry until the app restarts. The banner is queued separately by
    700     /// `presentPings`.
    701     private func applyDeclinePing(_ ping: Ping) async {
    702         guard ping.addressee == identity.currentID,
    703               ping.authorID != identity.currentID,
    704               !ping.authorID.isEmpty
    705         else { return }
    706         do {
    707             try await shareController.removeFriendParticipant(
    708                 fromGameID: ping.gameID,
    709                 userRecordName: ping.authorID
    710             )
    711             // Consume from the friend zone (not the game zone) — that's where a
    712             // decline lives, so the loop's game-zone consume path can't reach it.
    713             await friendController.deleteFriendZonePing(
    714                 fromFriendAuthorID: ping.authorID,
    715                 recordName: ping.recordName
    716             )
    717             syncMonitor.note("ping(decline): freed seat for \(ping.authorID) in \(ping.gameID.uuidString)")
    718         } catch {
    719             // Release the claim so the re-delivered decline reprocesses on the
    720             // next sync instead of being dropped as already-handled. A duplicate
    721             // decline banner on the retry is the acceptable cost of not
    722             // stranding the seat for the rest of the session.
    723             releaseHandlingClaim(ping.recordName)
    724             syncMonitor.note("ping(decline): free seat failed for \(ping.gameID.uuidString) — \(error.localizedDescription)")
    725         }
    726     }
    727 
    728     private func claimPingsForHandling(_ pings: [Ping]) -> [Ping] {
    729         var unclaimed: [Ping] = []
    730         for ping in pings {
    731             guard claimedPingRecordNames.insert(ping.recordName).inserted else {
    732                 syncMonitor.note("ping(\(ping.kind.rawValue)): already-handled record \(ping.recordName)")
    733                 continue
    734             }
    735             claimedPingRecordNameOrder.append(ping.recordName)
    736             unclaimed.append(ping)
    737         }
    738         if claimedPingRecordNameOrder.count > claimedPingRecordNameCap {
    739             let overflow = claimedPingRecordNameOrder.count - claimedPingRecordNameCap
    740             for recordName in claimedPingRecordNameOrder.prefix(overflow) {
    741                 claimedPingRecordNames.remove(recordName)
    742             }
    743             claimedPingRecordNameOrder.removeFirst(overflow)
    744         }
    745         return unclaimed
    746     }
    747 
    748     /// Drops a record from the handling claim so a later sync can reprocess it.
    749     /// Used when handling failed transiently and the source record was left in
    750     /// place for retry; without this the claim would skip the re-delivery.
    751     private func releaseHandlingClaim(_ recordName: String) {
    752         guard claimedPingRecordNames.remove(recordName) != nil else { return }
    753         claimedPingRecordNameOrder.removeAll { $0 == recordName }
    754     }
    755 
    756     private func canPresentNotifications() async -> Bool {
    757         let center = UNUserNotificationCenter.current()
    758         let settings = await center.notificationSettings()
    759         switch settings.authorizationStatus {
    760         case .authorized, .provisional, .ephemeral:
    761             return true
    762         default:
    763             return false
    764         }
    765     }
    766 
    767     nonisolated static func bodyText(for ping: Ping, nickname: String? = nil) -> String {
    768         let puzzleSuffix = ping.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(ping.puzzleTitle)'"
    769         switch ping.kind {
    770         case .invite:
    771             let player = nickname
    772                 ?? (ping.playerName.isEmpty ? "A player" : ping.playerName)
    773             return "\(player) invited you to \(puzzleSuffix)"
    774         case .decline:
    775             let player = nickname
    776                 ?? (ping.playerName.isEmpty ? "A player" : ping.playerName)
    777             return "\(player) declined your invitation to \(puzzleSuffix)"
    778         case .friend, .join, .hail:
    779             // System-only kinds handled by the friendship-bootstrap /
    780             // engagement paths; never presented as a notification. If this
    781             // text surfaces in a log or alert, `presentPings` dispatch has
    782             // broken.
    783             return "system-only ping should not be presented"
    784         }
    785     }
    786 }
    787 
    788 private extension Array {
    789     /// Splits the collection into `(matched, rejected)` in one pass.
    790     func partitioned(by predicate: (Element) -> Bool) -> ([Element], [Element]) {
    791         var matched: [Element] = []
    792         var rejected: [Element] = []
    793         for element in self {
    794             if predicate(element) {
    795                 matched.append(element)
    796             } else {
    797                 rejected.append(element)
    798             }
    799         }
    800         return (matched, rejected)
    801     }
    802 }