crossmate

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

InviteCoordinator.swift (47002B)


      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 `AppActions`.
     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 store: GameStore
     32     private let syncEngine: SyncEngine
     33     private let announcements: AnnouncementCenter
     34     private let shareController: ShareController
     35     private let friendController: FriendController
     36     private let cloudService: CloudService
     37     /// Refreshes the app-icon badge — `BadgeCoordinator.refreshAppBadge` —
     38     /// whenever invite rows change (pending invites count toward the badge).
     39     private let refreshAppBadge: (String) async -> Void
     40     private let publishInvitePush: (String, UUID, String, String) async -> Void
     41 
     42     private var claimedPingRecordNames: Set<String> = []
     43     private var claimedPingRecordNameOrder: [String] = []
     44     private let claimedPingRecordNameCap = 200
     45 
     46     init(
     47         persistence: PersistenceController,
     48         identity: AuthorIdentity,
     49         preferences: PlayerPreferences,
     50         syncMonitor: SyncMonitor,
     51         eventLog: EventLog,
     52         store: GameStore,
     53         syncEngine: SyncEngine,
     54         announcements: AnnouncementCenter,
     55         shareController: ShareController,
     56         friendController: FriendController,
     57         cloudService: CloudService,
     58         refreshAppBadge: @escaping (String) async -> Void,
     59         publishInvitePush: @escaping (String, UUID, String, String) async -> Void
     60     ) {
     61         self.persistence = persistence
     62         self.identity = identity
     63         self.preferences = preferences
     64         self.syncMonitor = syncMonitor
     65         self.eventLog = eventLog
     66         self.store = store
     67         self.syncEngine = syncEngine
     68         self.announcements = announcements
     69         self.shareController = shareController
     70         self.friendController = friendController
     71         self.cloudService = cloudService
     72         self.refreshAppBadge = refreshAppBadge
     73         self.publishInvitePush = publishInvitePush
     74     }
     75 
     76     /// Re-invites an existing friend to a game: adds them as a participant on
     77     /// the game's `CKShare` and writes an `.invite` Ping into the friend zone.
     78     /// Surfaced to the UI via `AppActions`.
     79     func inviteFriend(gameID: UUID, friendAuthorID: String) async throws {
     80         guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else {
     81             throw FriendController.FriendError.friendNotFound
     82         }
     83         let ctx = persistence.viewContext
     84         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     85         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
     86         req.fetchLimit = 1
     87         let game = try? ctx.fetch(req).first
     88         let title = game?.title ?? ""
     89 
     90         // Encode the grid silhouette the same way share links do, so the
     91         // recipient can preview the puzzle in their "Invited" row. `nil` when
     92         // the layout cache is unpopulated, which simply gets no preview.
     93         let shape = shareController.gridSilhouette(for: gameID)
     94         let silhouette = shape.flatMap {
     95             GridSilhouette.encode(width: $0.width, height: $0.height, blocks: $0.blocks)
     96         }
     97 
     98         // Carry the puzzle's XD source in the invite. The recipient already
     99         // syncs the friend zone, so they receive it with the Ping — letting
    100         // the accept path build a playable game without waiting on the shared
    101         // zone fetch. Everything the recipient's GameEntity needs derives from
    102         // this source (as in `GameStore.createGame`); the canonical Game record
    103         // then merges in as a background update.
    104         let puzzleSource = Self.fastAcceptPuzzleSource(game?.puzzleSource)
    105 
    106         // Make sure the pair's mailbox handshake has completed before mutating
    107         // the game share. Otherwise the friend can be added as a participant but
    108         // never receive the invite Ping that tells them about the seat.
    109         try await friendController.ensureOutboxReady(friendAuthorID: friendAuthorID)
    110 
    111         let invitationShare = try await shareController.addFriendParticipant(
    112             toGameID: gameID,
    113             userRecordName: friendAuthorID
    114         )
    115         // `addFriendParticipant` saves the CKShare, which marks the game shared.
    116         // Ensure the invite carries the shared push credential minted for that
    117         // game so the recipient registers under the owner's worker namespace
    118         // before the canonical Game record necessarily arrives.
    119         let notification = store.ensurePushCredentials(for: gameID)
    120             .flatMap { try? $0.encoded() }
    121         do {
    122             try await friendController.sendInvite(
    123                 toFriendAuthorID: friendAuthorID,
    124                 gameID: gameID,
    125                 gameTitle: title,
    126                 inviterAuthorID: localAuthorID,
    127                 inviterName: preferences.name,
    128                 gameShareURL: invitationShare.url,
    129                 gridSilhouette: silhouette,
    130                 puzzleSource: puzzleSource,
    131                 notification: notification,
    132                 rollbackParticipantOnFailure: invitationShare.participantWasAdded
    133             )
    134         } catch {
    135             // Skip the inline seat rollback when the Ping is still in play: a
    136             // terminal `.deliveryFailed` rolls back later via the async
    137             // `.failed` delivery update, and a `.deliveryPending` timeout must
    138             // not disturb a seat whose invite is still queued to deliver.
    139             let pingStillOwnsRollback: Bool
    140             switch error as? SyncEngine.PingOutboxError {
    141             case .deliveryFailed, .deliveryPending:
    142                 pingStillOwnsRollback = true
    143             case .syncEngineUnavailable, nil:
    144                 pingStillOwnsRollback = false
    145             }
    146             if invitationShare.participantWasAdded,
    147                !(error is CancellationError),
    148                !pingStillOwnsRollback {
    149                 try? await shareController.removeFriendParticipant(
    150                     fromGameID: gameID,
    151                     userRecordName: friendAuthorID
    152                 )
    153             }
    154             throw error
    155         }
    156         await publishInvitePush(friendAuthorID, gameID, title, preferences.name)
    157     }
    158 
    159     /// Reverses the CKShare mutation attached to a Ping that CloudKit
    160     /// permanently rejected. The delivery payload records whether this
    161     /// particular invitation created the seat, so re-inviting an existing
    162     /// participant can never remove their earlier access.
    163     func rollbackUndeliveredInvite(gameID: UUID, friendAuthorID: String) async {
    164         do {
    165             try await shareController.removeFriendParticipant(
    166                 fromGameID: gameID,
    167                 userRecordName: friendAuthorID
    168             )
    169         } catch {
    170             syncMonitor.recordError("rollback undelivered invite", error)
    171         }
    172     }
    173 
    174     /// For each collaborative game with newly-known remote authors, asks the
    175     /// `FriendController` to bootstrap a friendship. `establish` is symmetric
    176     /// (both sides run it) and a no-op once this side's inbox is published (a
    177     /// defensive backstop — the caller already fires this only on a Player
    178     /// record's first sighting, so it runs about once per new collaborator).
    179     func reconcileFriendships(forGameIDs gameIDs: Set<UUID>) async {
    180         guard preferences.isICloudSyncEnabled,
    181               let localAuthorID = identity.currentID,
    182               !localAuthorID.isEmpty
    183         else { return }
    184 
    185         let ctx = persistence.container.newBackgroundContext()
    186         let candidates: [(gameID: UUID, remoteAuthorID: String)] = await ctx.perform {
    187             var result: [(UUID, String)] = []
    188             for gameID in gameIDs {
    189                 let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    190                 gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    191                 gReq.fetchLimit = 1
    192                 guard let game = try? ctx.fetch(gReq).first else { continue }
    193                 // Only collaborative games carry other authors.
    194                 guard game.databaseScope == 1 || game.ckShareRecordName != nil else { continue }
    195 
    196                 // Identity comes only from Player records — this feature is
    197                 // deliberately uninterested in Moves (the bootstrap trigger is
    198                 // the first sighting of a remote Player record). Display names
    199                 // are not gathered here: they ride `name` Decisions in the
    200                 // friend zone itself.
    201                 var remoteAuthorIDs = Set<String>()
    202                 let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    203                 pReq.predicate = NSPredicate(format: "game == %@", game)
    204                 for p in (try? ctx.fetch(pReq)) ?? [] {
    205                     guard let authorID = p.authorID else { continue }
    206                     remoteAuthorIDs.insert(authorID)
    207                 }
    208                 remoteAuthorIDs.remove(localAuthorID)
    209                 remoteAuthorIDs.remove(CKCurrentUserDefaultName)
    210                 remoteAuthorIDs.remove("")
    211                 for authorID in remoteAuthorIDs {
    212                     result.append((gameID, authorID))
    213                 }
    214             }
    215             return result
    216         }
    217 
    218         for (gameID, remoteAuthorID) in candidates {
    219             await friendController.establish(
    220                 localAuthorID: localAuthorID,
    221                 remoteAuthorID: remoteAuthorID,
    222                 localDisplayName: preferences.name,
    223                 viaGameID: gameID
    224             )
    225         }
    226     }
    227 
    228     /// Upserts a durable `InviteEntity` for each inbound `.invite` Ping so the
    229     /// Game List's "Invited" section survives the Ping being GC'd. Skips
    230     /// self-authored invites, invites not directed to this author, invites
    231     /// whose source zone doesn't authenticate the claimed inviter, invites
    232     /// from blocked friends, games already joined, and any `pingRecordName`
    233     /// already seen (a declined row is a tombstone that prevents
    234     /// resurrection).
    235     /// Returns the `pingRecordName`s of invites whose durable row was created
    236     /// for the first time by this call — i.e. invites that have just synced
    237     /// from the server. The caller uses this to notify exactly once: a pending
    238     /// invite's Ping is re-fetched on every cold start, but its row already
    239     /// exists by then, so it is absent from this set and isn't re-surfaced.
    240     @discardableResult
    241     private func applyInvitePings(_ pings: [Ping]) async -> Set<String> {
    242         let candidates = pings.filter {
    243             $0.kind == .invite &&
    244             $0.authorID != identity.currentID &&
    245             $0.addressee == identity.currentID
    246         }
    247         // `authorID`/`playerName` are self-asserted: any accepted friend can
    248         // write an invite Ping claiming to be from a *different* friend, so
    249         // the Invited row would read "Carol invited you" over the forger's
    250         // share URL. Authenticate the inviter by the zone the record was
    251         // written to, exactly as `applyDeclinePing` does for declines. A
    252         // rejected invite is never stored, so it also never banners — the
    253         // notification loop only surfaces record names this call inserted.
    254         let (invites, forged) = candidates.partitioned {
    255             Self.isAuthenticFriendZonePing($0, localAuthorID: identity.currentID ?? "")
    256         }
    257         for ping in forged {
    258             syncMonitor.note(
    259                 "ping(invite): rejected — zone \(ping.sourceZoneName) does not " +
    260                 "authenticate inviter \(ping.authorID) for \(ping.gameID.uuidString)"
    261             )
    262         }
    263         guard !invites.isEmpty else { return [] }
    264 
    265         let (inserted, saveError) = await Self.storeInvitePings(
    266             invites,
    267             persistence: persistence
    268         )
    269         if let saveError {
    270             eventLog.note("InviteCoordinator: applyInvitePings save failed — \(saveError)", level: "error")
    271         }
    272         return inserted
    273     }
    274 
    275     /// Persists already-validated invite Pings and reports only rows inserted
    276     /// by this ingest. Keeping the inserted-name result tied to the durable
    277     /// save is what makes a replay idempotent for both the Invited section and
    278     /// its banner: `presentPings` only notifies for names returned here.
    279     /// Internal so the retry contract can be covered without constructing the
    280     /// coordinator's unrelated CloudKit collaborators.
    281     static func storeInvitePings(
    282         _ invites: [Ping],
    283         persistence: PersistenceController
    284     ) async -> (inserted: Set<String>, saveError: Error?) {
    285         let ctx = persistence.container.newBackgroundContext()
    286         return await ctx.perform {
    287             var insertedPingRecordNames: Set<String> = []
    288             var handledPingRecordNames: Set<String> = []
    289             for ping in invites {
    290                 guard let payload = FriendZone.InvitePayload.decode(ping.payload) else { continue }
    291                 guard handledPingRecordNames.insert(ping.recordName).inserted else { continue }
    292 
    293                 let dupReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    294                 dupReq.predicate = NSPredicate(format: "pingRecordName == %@", ping.recordName)
    295                 dupReq.fetchLimit = 1
    296                 if ((try? ctx.count(for: dupReq)) ?? 0) > 0 { continue }
    297 
    298                 let invite = InviteEntity(context: ctx)
    299                 invite.gameID = ping.gameID
    300                 invite.gameTitle = ping.puzzleTitle
    301                 invite.inviterAuthorID = ping.authorID
    302                 invite.inviterName = ping.playerName
    303                 invite.shareURL = payload.gameShareURL
    304                 invite.gridSilhouette = payload.gridSilhouette
    305                 invite.puzzleSource = Self.fastAcceptPuzzleSource(payload.puzzleSource)
    306                 invite.notification = payload.notification
    307                 invite.pingRecordName = ping.recordName
    308                 invite.status = "pending"
    309                 invite.createdAt = Date()
    310                 insertedPingRecordNames.insert(ping.recordName)
    311             }
    312 
    313             // GC: a pending invite whose game now exists locally was joined by
    314             // some other path (a link, or accepted on another device), so the
    315             // "Invited" row is stale — drop it.
    316             let pendingReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    317             pendingReq.predicate = NSPredicate(format: "status == %@", "pending")
    318             for invite in (try? ctx.fetch(pendingReq)) ?? [] {
    319                 guard let gid = invite.gameID else { continue }
    320                 let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    321                 gReq.predicate = NSPredicate(format: "id == %@", gid as CVarArg)
    322                 gReq.fetchLimit = 1
    323                 if ((try? ctx.count(for: gReq)) ?? 0) > 0 { ctx.delete(invite) }
    324             }
    325 
    326             if ctx.hasChanges {
    327                 do {
    328                     try ctx.save()
    329                 } catch {
    330                     // The rows didn't persist, so treat none as "freshly
    331                     // recorded" — a later fetch will re-create and notify.
    332                     return ([], error)
    333                 }
    334             }
    335             return (insertedPingRecordNames, nil)
    336         }
    337     }
    338 
    339     /// Drops the pending invite row(s) for `gameID`. Called when the game's
    340     /// shared zone appears locally (joined here or on a sibling device): the
    341     /// "Invited" row is now redundant. `applyInvitePings` runs the same
    342     /// garbage-collection over every pending invite, but only when a ping is
    343     /// fetched — hooking zone arrival closes the window where a just-synced
    344     /// game and its stale invite show side by side in the library.
    345     func removePendingInvite(forGameID gameID: UUID) throws {
    346         let ctx = persistence.viewContext
    347         let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    348         req.predicate = NSPredicate(
    349             format: "gameID == %@ AND status == %@", gameID as CVarArg, "pending"
    350         )
    351         let invites = try ctx.fetch(req)
    352         guard !invites.isEmpty else { return }
    353         for invite in invites { ctx.delete(invite) }
    354         if ctx.hasChanges {
    355             try ctx.save()
    356         }
    357     }
    358 
    359     /// Drops pending invite rows whose source Ping was consumed elsewhere on
    360     /// this account. Matching by record name avoids conflating invite Pings
    361     /// with other Ping kinds for the same game.
    362     func removePendingInvites(forPingRecordNames recordNames: Set<String>) throws {
    363         guard !recordNames.isEmpty else { return }
    364         let ctx = persistence.viewContext
    365         let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    366         req.predicate = NSPredicate(
    367             format: "pingRecordName IN %@ AND status == %@",
    368             Array(recordNames),
    369             "pending"
    370         )
    371         let invites = try ctx.fetch(req)
    372         guard !invites.isEmpty else { return }
    373         for invite in invites { ctx.delete(invite) }
    374         if ctx.hasChanges {
    375             try ctx.save()
    376         }
    377     }
    378 
    379     /// Accepts a pending game invite: fetches the share metadata, joins via
    380     /// the existing share-accept path, then drops the local `InviteEntity`
    381     /// (the game now represents it). If CloudKit says the share URL no longer
    382     /// exists, the durable invite row is stale, so it is removed as well.
    383     /// Surfaced via `AppActions`.
    384     @discardableResult
    385     func acceptInvite(shareURL: String, pingRecordName: String) async throws -> CloudService.AcceptOutcome {
    386         guard let url = URL(string: shareURL) else {
    387             throw FriendController.FriendError.missingShareURLInPayload
    388         }
    389         // The invite carried the puzzle's XD source, so hand it to the accept
    390         // path: it builds a playable game from this without waiting on the
    391         // shared-zone fetch. nil for invites from older senders or already
    392         // consumed rows — the accept path then fetches as before.
    393         let prefetchedPuzzleSource = puzzleSource(forPingRecordName: pingRecordName)
    394         let prefetchedNotification = notification(forPingRecordName: pingRecordName)
    395         let outcome: CloudService.AcceptOutcome
    396         do {
    397             outcome = try await cloudService.acceptShare(
    398                 url: url,
    399                 prefetchedPuzzleSource: prefetchedPuzzleSource,
    400                 prefetchedNotification: prefetchedNotification
    401             )
    402         } catch let error as AcceptedShareError where error.kind == .removed {
    403             do {
    404                 try await deleteInviteAndPing(pingRecordName: pingRecordName)
    405                 syncMonitor.note("accept invite: removed unavailable invite \(pingRecordName)")
    406             } catch {
    407                 syncMonitor.note(
    408                     "accept invite: unavailable-invite cleanup failed for " +
    409                     "\(pingRecordName) — \(error)"
    410                 )
    411             }
    412             throw error
    413         } catch let error as CKError where error.code == .unknownItem || error.code == .zoneNotFound {
    414             // Stale share: the row needs to go away too, but the next
    415             // `applyInvitePings` will GC it if this cleanup itself fails.
    416             // The user-visible signal here is `.unavailable`, so don't let a
    417             // cleanup error clobber it — log and continue.
    418             do {
    419                 try await deleteInviteAndPing(pingRecordName: pingRecordName)
    420                 syncMonitor.note("accept invite: removed stale invite \(pingRecordName)")
    421             } catch {
    422                 syncMonitor.note("accept invite: stale-invite cleanup failed for \(pingRecordName) — \(error)")
    423             }
    424             throw InviteAcceptanceError.unavailable
    425         }
    426         try await deleteInviteAndPing(pingRecordName: pingRecordName)
    427         return outcome
    428     }
    429 
    430     /// The XD source recorded on the durable invite for `pingRecordName`, if
    431     /// the inviting build carried one. Read just before acceptance so the
    432     /// accept path can construct a playable game without the shared-zone fetch.
    433     private func puzzleSource(forPingRecordName pingRecordName: String) -> String? {
    434         let ctx = persistence.viewContext
    435         let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    436         req.predicate = NSPredicate(format: "pingRecordName == %@", pingRecordName)
    437         req.fetchLimit = 1
    438         let source = (try? ctx.fetch(req))?.first?.puzzleSource
    439         return Self.fastAcceptPuzzleSource(source)
    440     }
    441 
    442     nonisolated static func fastAcceptPuzzleSource(_ source: String?) -> String? {
    443         guard let source, !source.isEmpty else { return nil }
    444         guard source.utf8.count <= XD.maxSourceBytes else { return nil }
    445         return source
    446     }
    447 
    448     /// The game notification credential recorded on the durable invite, if the
    449     /// inviting build carried one. Used with the prefetched puzzle source so the
    450     /// accepted game is immediately registered under the owner's push namespace.
    451     private func notification(forPingRecordName pingRecordName: String) -> String? {
    452         let ctx = persistence.viewContext
    453         let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    454         req.predicate = NSPredicate(format: "pingRecordName == %@", pingRecordName)
    455         req.fetchLimit = 1
    456         guard let notification = (try? ctx.fetch(req))?.first?.notification,
    457               !notification.isEmpty
    458         else {
    459             return nil
    460         }
    461         return notification
    462     }
    463 
    464     private func deleteInviteAndPing(pingRecordName: String) async throws {
    465         let ctx = persistence.viewContext
    466         let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    467         req.predicate = NSPredicate(format: "pingRecordName == %@", pingRecordName)
    468         for invite in try ctx.fetch(req) {
    469             if let inviterAuthorID = invite.inviterAuthorID {
    470                 await friendController.deleteFriendZonePing(
    471                     fromFriendAuthorID: inviterAuthorID,
    472                     recordName: pingRecordName
    473                 )
    474             }
    475             ctx.delete(invite)
    476         }
    477         if ctx.hasChanges {
    478             try ctx.save()
    479             await refreshAppBadge("delete invite")
    480         }
    481     }
    482 
    483     /// Declines a pending game invite: marks the durable `InviteEntity` rows for
    484     /// `gameID` as a `"declined"` tombstone (which prevents the invite from
    485     /// resurrecting locally if CloudKit deletion is delayed), sends a `.decline`
    486     /// Ping back to each inviter so they free our seat and see a banner, consumes
    487     /// the source invite Ping so sibling devices clear their rows, and refreshes
    488     /// the badge. Surfaced via `AppActions`; the AppServices entry point
    489     /// keeps invite mutation and the badge refresh in one place, mirroring
    490     /// `acceptInvite`.
    491     func declineInvite(gameID: UUID) async throws {
    492         let ctx = persistence.viewContext
    493         let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    494         req.predicate = NSPredicate(
    495             format: "gameID == %@ AND status == %@", gameID as CVarArg, "pending"
    496         )
    497         let invites = try ctx.fetch(req)
    498         guard !invites.isEmpty else { return }
    499         let declined = invites.compactMap { invite -> (inviterAuthorID: String, pingRecordName: String, gameTitle: String)? in
    500             guard let inviterAuthorID = invite.inviterAuthorID,
    501                   let pingRecordName = invite.pingRecordName
    502             else { return nil }
    503             return (inviterAuthorID, pingRecordName, invite.gameTitle ?? "")
    504         }
    505         for invite in invites {
    506             invite.status = "declined"
    507         }
    508         if ctx.hasChanges {
    509             try ctx.save()
    510             await refreshAppBadge("decline invite")
    511         }
    512         let declinerAuthorID = identity.currentID
    513         let declinerName = preferences.name
    514         for (inviterAuthorID, pingRecordName, gameTitle) in declined {
    515             // Tell the inviter so they free our seat and get a banner. Best
    516             // effort — a failed send must not strand the local tombstone or
    517             // block the source-Ping cleanup; the inviter can always re-invite.
    518             if let declinerAuthorID, !declinerAuthorID.isEmpty {
    519                 do {
    520                     try await friendController.sendDecline(
    521                         toInviterAuthorID: inviterAuthorID,
    522                         gameID: gameID,
    523                         gameTitle: gameTitle,
    524                         declinerAuthorID: declinerAuthorID,
    525                         declinerName: declinerName
    526                     )
    527                 } catch {
    528                     syncMonitor.note("decline invite: send decline failed for \(gameID.uuidString) — \(error.localizedDescription)")
    529                 }
    530             }
    531             await friendController.deleteFriendZonePing(
    532                 fromFriendAuthorID: inviterAuthorID,
    533                 recordName: pingRecordName
    534             )
    535         }
    536     }
    537 
    538     /// Blocks a collaborator: downgrades them to read-only on the inbox we own
    539     /// (server-enforced, reversible — no teardown), hides every game they
    540     /// currently share with us (reversibly — the share/access is kept, the game
    541     /// just leaves the list), and drops their pending invites. Games we *own*
    542     /// that they joined are untouched. Surfaced via `AppActions`.
    543     func blockFriend(authorID: String) async {
    544         do {
    545             try await friendController.block(friendAuthorID: authorID)
    546         } catch {
    547             announcements.post(Announcement(
    548                 id: "block-friend-error-\(authorID)",
    549                 scope: .global,
    550                 severity: .error,
    551                 title: "Blocking Failed",
    552                 body: error.localizedDescription,
    553                 dismissal: .manual
    554             ))
    555             return
    556         }
    557 
    558         await reconcileGamesHiddenForBlockedFriends(authorID: authorID)
    559 
    560         // Drop their pending invites. Future inbound `.invite` Pings from a
    561         // blocked sender are caught by `consumeStaleInvites`, which deletes
    562         // the Ping so it doesn't re-fire across cold starts.
    563         let vctx = persistence.viewContext
    564         let iReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    565         iReq.predicate = NSPredicate(format: "inviterAuthorID == %@", authorID)
    566         for invite in (try? vctx.fetch(iReq)) ?? [] { vctx.delete(invite) }
    567         if vctx.hasChanges {
    568             try? vctx.save()
    569             await refreshAppBadge("block friend")
    570         }
    571     }
    572 
    573     /// Reverses `blockFriend`: upgrades the friend back to read-write on the
    574     /// inbox we own, so they can reach us again with no re-pairing, and reveals
    575     /// the games hidden at block time. Surfaced via `AppActions`.
    576     func unblockFriend(authorID: String) async {
    577         do {
    578             try await friendController.unblock(friendAuthorID: authorID)
    579         } catch {
    580             announcements.post(Announcement(
    581                 id: "unblock-friend-error-\(authorID)",
    582                 scope: .global,
    583                 severity: .error,
    584                 title: "Unblocking Failed",
    585                 body: error.localizedDescription,
    586                 dismissal: .manual
    587             ))
    588             return
    589         }
    590         await reconcileGamesHiddenForBlockedFriends(authorID: authorID)
    591         await refreshAppBadge("unblock friend")
    592     }
    593 
    594     /// Re-projects the local game-list hide flag from the durable block table.
    595     /// Block uses this instead of leaving shares, so unblock can restore games
    596     /// with no re-invite.
    597     private func reconcileGamesHiddenForBlockedFriends(authorID: String) async {
    598         let ctx = persistence.container.newBackgroundContext()
    599         await ctx.perform {
    600             _ = GameEntity.reconcileBlockedFriendHiddenGames(forAuthorIDs: [authorID], in: ctx)
    601             if ctx.hasChanges { try? ctx.save() }
    602         }
    603     }
    604 
    605     /// Deletes `.invite` Pings that are no longer actionable on this device —
    606     /// the game is already in the local library (joined here or on a sibling),
    607     /// or the inviter is blocked — and returns the surviving pings. Running
    608     /// this upstream of both `applyInvitePings` and the notification loop
    609     /// keeps the staleness rule in one place; without it, an orphaned invite
    610     /// Ping re-fires a notification on every cold start because the in-memory
    611     /// dedup caches reset.
    612     private func consumeStaleInvites(_ pings: [Ping]) async -> [Ping] {
    613         let candidates = pings.filter {
    614             $0.kind == .invite &&
    615             $0.authorID != identity.currentID &&
    616             $0.addressee == identity.currentID
    617         }
    618         guard !candidates.isEmpty else { return pings }
    619 
    620         let currentAuthorID = identity.currentID
    621         let ctx = persistence.container.newBackgroundContext()
    622         let staleNames: Set<String> = await ctx.perform {
    623             Self.staleInviteRecordNames(
    624                 among: candidates,
    625                 in: ctx,
    626                 currentAuthorID: currentAuthorID
    627             )
    628         }
    629         guard !staleNames.isEmpty else { return pings }
    630 
    631         for ping in candidates where staleNames.contains(ping.recordName) {
    632             await friendController.deleteFriendZonePing(
    633                 fromFriendAuthorID: ping.authorID,
    634                 recordName: ping.recordName
    635             )
    636             syncMonitor.note(
    637                 "ping(invite): consumed stale invite \(ping.recordName) for \(ping.gameID.uuidString)"
    638             )
    639         }
    640         return pings.filter { !staleNames.contains($0.recordName) }
    641     }
    642 
    643     nonisolated static func staleInviteRecordNames(
    644         among pings: [Ping],
    645         in ctx: NSManagedObjectContext,
    646         currentAuthorID: String?
    647     ) -> Set<String> {
    648         var names: Set<String> = []
    649         for ping in pings where ping.kind == .invite &&
    650             ping.authorID != currentAuthorID &&
    651             ping.addressee == currentAuthorID {
    652             guard FriendZone.InvitePayload.decode(ping.payload) != nil else {
    653                 names.insert(ping.recordName)
    654                 continue
    655             }
    656 
    657             let blockedReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    658             blockedReq.predicate = NSPredicate(
    659                 format: "authorID == %@ AND isBlocked == YES", ping.authorID
    660             )
    661             blockedReq.fetchLimit = 1
    662             if ((try? ctx.count(for: blockedReq)) ?? 0) > 0 {
    663                 names.insert(ping.recordName)
    664                 continue
    665             }
    666 
    667             let inviteReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
    668             inviteReq.predicate = NSPredicate(format: "pingRecordName == %@", ping.recordName)
    669             inviteReq.fetchLimit = 1
    670             if let invite = try? ctx.fetch(inviteReq).first,
    671                invite.status != "pending" {
    672                 names.insert(ping.recordName)
    673                 continue
    674             }
    675 
    676             let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    677             gameReq.predicate = NSPredicate(format: "id == %@", ping.gameID as CVarArg)
    678             gameReq.fetchLimit = 1
    679             if ((try? ctx.count(for: gameReq)) ?? 0) > 0 {
    680                 names.insert(ping.recordName)
    681             }
    682         }
    683         return names
    684     }
    685 
    686     func presentPings(_ pings: [Ping]) async {
    687         let claimed = claimPingsForHandling(pings)
    688         guard !claimed.isEmpty else { return }
    689         let pings = await consumeStaleInvites(claimed)
    690         guard !pings.isEmpty else { return }
    691         let newlyInvited = await applyInvitePings(pings)
    692         // Reflect any newly-stored pending invite in the app-icon badge now —
    693         // before the notification-authorization guard — so the badge updates
    694         // even when the banner is suppressed or unauthorized.
    695         await refreshAppBadge("present pings")
    696         // `.friend` is the friendship-bootstrap handshake. `.chronicled` is the
    697         // completed-game retirement handshake. `.join` and `.hail`
    698         // are legacy live-notification/bootstrap kinds; APNs and Game-record
    699         // engagement creds own those jobs now. System pings do not require
    700         // notification authorization.
    701         let (systemPings, playerFacingPings) = pings.partitioned {
    702             $0.kind == .friend || $0.kind == .join || $0.kind == .hail
    703                 || $0.kind == .chronicled
    704         }
    705         for ping in systemPings where ping.kind == .friend {
    706             await friendController.applyFriendPing(
    707                 ping,
    708                 localAuthorID: identity.currentID,
    709                 localDisplayName: preferences.name
    710             )
    711         }
    712         // Free the seat for any invitee who declined. Done before the
    713         // notification-authorization gate so the share frees even when the
    714         // banner can't be shown; the banner itself is queued by the loop below.
    715         for ping in playerFacingPings where ping.kind == .decline {
    716             await applyDeclinePing(ping)
    717         }
    718         guard !playerFacingPings.isEmpty else { return }
    719         guard await canPresentNotifications() else {
    720             syncMonitor.note("ping: local notification skipped — authorization not granted")
    721             return
    722         }
    723 
    724         let center = UNUserNotificationCenter.current()
    725         let delivered = await center.deliveredNotifications()
    726         for ping in playerFacingPings {
    727             if ping.kind == .invite, ping.addressee != identity.currentID {
    728                 continue
    729             }
    730             // A directed ping (`addressee` set) targets one player by
    731             // authorID. Ignore one addressed to someone else — another user's
    732             // device receives and consumes it. nil ⇒ broadcast, which is now
    733             // legacy and ignored for `.invite`.
    734             if let addressee = ping.addressee, addressee != identity.currentID {
    735                 continue
    736             }
    737             if ping.authorID == identity.currentID {
    738                 syncMonitor.note("ping(\(ping.kind.rawValue)): skipped self-authored record \(ping.recordName)")
    739                 continue
    740             }
    741             // A directed ping addressed to us is consumed by this account:
    742             // once handled — shown, suppressed, or a duplicate — delete it so
    743             // it stops re-notifying and the deletion withdraws any copy our
    744             // sibling devices showed. Broadcast pings are left as-is. `.decline`
    745             // is excluded: it lives in the friend zone, not the game zone this
    746             // path deletes from, so `applyDeclinePing` consumes it instead.
    747             let consume = ping.addressee != nil
    748                 && ping.kind != .invite
    749                 && ping.kind != .decline
    750             func consumeIfDirected() async {
    751                 guard consume else { return }
    752                 await syncEngine.deletePing(recordName: ping.recordName, gameID: ping.gameID)
    753             }
    754             if NotificationState.isSuppressed(gameID: ping.gameID) {
    755                 syncMonitor.note("ping(\(ping.kind.rawValue)): suppressed — puzzle is active for \(ping.gameID.uuidString)")
    756                 await consumeIfDirected()
    757                 continue
    758             }
    759             // Notify for an invite only the first time it syncs from the
    760             // server (its durable row was just created). A still-pending
    761             // invite's Ping is re-fetched on every cold start; without this
    762             // gate the banner would repeat on every app open until the invite
    763             // is accepted or declined. The Invited row itself is unaffected —
    764             // `applyInvitePings` keeps it regardless.
    765             if ping.kind == .invite, !newlyInvited.contains(ping.recordName) {
    766                 syncMonitor.note("ping(invite): already recorded, not re-notifying for \(ping.gameID.uuidString)")
    767                 continue
    768             }
    769             // Invite banners are user-toggleable; the invite row itself still
    770             // lands in the Invited section through `applyInvitePings`.
    771             if ping.kind == .invite, !preferences.notifiesInvitations {
    772                 syncMonitor.note("ping(invite): banner disabled in settings for \(ping.gameID.uuidString)")
    773                 continue
    774             }
    775             if ping.kind == .invite,
    776                Self.deliveredInvitePushExists(for: ping.gameID, in: delivered) {
    777                 syncMonitor.note("ping(invite): push already delivered, not queueing local notification for \(ping.gameID.uuidString)")
    778                 continue
    779             }
    780 
    781             let content = UNMutableNotificationContent()
    782             content.title = "Crossmate"
    783             // Local notifications never pass through the Notification Service
    784             // Extension, so the nickname substitution the NSE does for worker
    785             // pushes happens here instead, off the same App Group directory.
    786             content.body = Self.bodyText(
    787                 for: ping,
    788                 nickname: NicknameDirectory.entry(for: ping.authorID)?.nickname
    789             )
    790             content.sound = .default
    791             content.userInfo = [
    792                 "gameID": ping.gameID.uuidString,
    793                 "pingKind": ping.kind.rawValue
    794             ]
    795 
    796             let request = UNNotificationRequest(
    797                 identifier: "ping-\(ping.gameID.uuidString)-\(UUID().uuidString)",
    798                 content: content,
    799                 trigger: nil
    800             )
    801             do {
    802                 try await center.add(request)
    803                 syncMonitor.note("ping(\(ping.kind.rawValue)): queued local notification for \(ping.gameID.uuidString)")
    804                 await consumeIfDirected()
    805             } catch {
    806                 syncMonitor.note("ping(\(ping.kind.rawValue)): local notification failed — \(error.localizedDescription)")
    807             }
    808         }
    809     }
    810 
    811     /// Frees the seat held by an invitee who declined: asks `ShareController`
    812     /// to remove them from the game's `CKShare` so the owner can invite someone
    813     /// else, then consumes the `.decline` Ping from the friend zone so it stops
    814     /// re-firing. The decliner is the ping's author; only the addressed owner
    815     /// acts. The ping is consumed only on a successful free — a transient
    816     /// failure leaves it so the next sync retries rather than stranding the
    817     /// seat — which also means releasing the in-memory handling claim, since a
    818     /// claimed record is skipped on re-delivery and would otherwise suppress
    819     /// the retry until the app restarts. The banner is queued separately by
    820     /// `presentPings`.
    821     /// Whether a friend-zone Ping (`.invite` or `.decline`) genuinely came
    822     /// from the friend it names in `authorID`. Both facts checked are
    823     /// CloudKit-intrinsic fetch metadata, not writable record fields:
    824     /// - The record sits in `friend-<pairKey(us, ping.authorID)>` — besides us,
    825     ///   the only account that can write into that zone is that friend, so a
    826     ///   ping naming a third party — or one forged into some other friend's
    827     ///   inbox — fails.
    828     /// - It was fetched from the private database: a legitimate invite or
    829     ///   decline is always written into *our* inbox, a zone we own. A zone the
    830     ///   forger owns can carry any name he likes but only ever reaches us
    831     ///   through the shared database, so requiring `.private` holds even if a
    832     ///   mis-owned zone slips past acceptance-time checks. (Pings from friends
    833     ///   still on a pre-mailbox app version can arrive shared-scoped and are
    834     ///   rejected here; the rejection is logged by the callers.)
    835     /// Empty inputs (missing local identity, unparsed zone) and an unknown
    836     /// scope fail closed.
    837     static func isAuthenticFriendZonePing(_ ping: Ping, localAuthorID: String) -> Bool {
    838         guard !localAuthorID.isEmpty, !ping.authorID.isEmpty else { return false }
    839         guard ping.sourceDatabaseScope == .private else { return false }
    840         let expected = FriendZone.zoneName(
    841             pairKey: FriendZone.pairKey(localAuthorID, ping.authorID)
    842         )
    843         return ping.sourceZoneName == expected
    844     }
    845 
    846     private func applyDeclinePing(_ ping: Ping) async {
    847         guard let ownerAuthorID = identity.currentID,
    848               ping.addressee == ownerAuthorID,
    849               ping.authorID != ownerAuthorID,
    850               !ping.authorID.isEmpty
    851         else { return }
    852         // The decliner is self-reported in `authorID`, but that field is
    853         // attacker-writable: any accepted friend holds `.readWrite` on your
    854         // `friend-<pairKey>` inbox and could forge a decline naming a third
    855         // party to evict them from a game the forger was never in. Authenticate
    856         // the decliner by the zone the record was written to — only the decliner
    857         // can write to `friend-<pairKey(owner, decliner)>` — so a decline can
    858         // only ever free the decliner's own seat. (M3 / [P3-8].)
    859         guard Self.isAuthenticFriendZonePing(ping, localAuthorID: ownerAuthorID) else {
    860             syncMonitor.note(
    861                 "ping(decline): rejected — zone \(ping.sourceZoneName) does not " +
    862                 "authenticate decliner \(ping.authorID) for \(ping.gameID.uuidString)"
    863             )
    864             return
    865         }
    866         do {
    867             try await shareController.removeFriendParticipant(
    868                 fromGameID: ping.gameID,
    869                 userRecordName: ping.authorID
    870             )
    871             // Consume from the friend zone (not the game zone) — that's where a
    872             // decline lives, so the loop's game-zone consume path can't reach it.
    873             await friendController.deleteFriendZonePing(
    874                 fromFriendAuthorID: ping.authorID,
    875                 recordName: ping.recordName
    876             )
    877             syncMonitor.note("ping(decline): freed seat for \(ping.authorID) in \(ping.gameID.uuidString)")
    878         } catch {
    879             // Release the claim so the re-delivered decline reprocesses on the
    880             // next sync instead of being dropped as already-handled. A duplicate
    881             // decline banner on the retry is the acceptable cost of not
    882             // stranding the seat for the rest of the session.
    883             releaseHandlingClaim(ping.recordName)
    884             syncMonitor.note("ping(decline): free seat failed for \(ping.gameID.uuidString) — \(error.localizedDescription)")
    885         }
    886     }
    887 
    888     private func claimPingsForHandling(_ pings: [Ping]) -> [Ping] {
    889         var unclaimed: [Ping] = []
    890         for ping in pings {
    891             guard claimedPingRecordNames.insert(ping.recordName).inserted else {
    892                 syncMonitor.note("ping(\(ping.kind.rawValue)): already-handled record \(ping.recordName)")
    893                 continue
    894             }
    895             claimedPingRecordNameOrder.append(ping.recordName)
    896             unclaimed.append(ping)
    897         }
    898         if claimedPingRecordNameOrder.count > claimedPingRecordNameCap {
    899             let overflow = claimedPingRecordNameOrder.count - claimedPingRecordNameCap
    900             for recordName in claimedPingRecordNameOrder.prefix(overflow) {
    901                 claimedPingRecordNames.remove(recordName)
    902             }
    903             claimedPingRecordNameOrder.removeFirst(overflow)
    904         }
    905         return unclaimed
    906     }
    907 
    908     /// Drops a record from the handling claim so a later sync can reprocess it.
    909     /// Used when handling failed transiently and the source record was left in
    910     /// place for retry; without this the claim would skip the re-delivery.
    911     private func releaseHandlingClaim(_ recordName: String) {
    912         guard claimedPingRecordNames.remove(recordName) != nil else { return }
    913         claimedPingRecordNameOrder.removeAll { $0 == recordName }
    914     }
    915 
    916     private func canPresentNotifications() async -> Bool {
    917         let center = UNUserNotificationCenter.current()
    918         let settings = await center.notificationSettings()
    919         switch settings.authorizationStatus {
    920         case .authorized, .provisional, .ephemeral:
    921             return true
    922         default:
    923             return false
    924         }
    925     }
    926 
    927     private static func deliveredInvitePushExists(for gameID: UUID, in delivered: [UNNotification]) -> Bool {
    928         delivered.contains { notification in
    929             let userInfo = notification.request.content.userInfo
    930             guard let idString = userInfo["gameID"] as? String,
    931                   UUID(uuidString: idString) == gameID
    932             else { return false }
    933             return userInfo["kind"] as? String == PingKind.invite.rawValue
    934         }
    935     }
    936 
    937     nonisolated static func bodyText(for ping: Ping, nickname: String? = nil) -> String {
    938         let puzzleSuffix = ping.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(ping.puzzleTitle)'"
    939         switch ping.kind {
    940         case .invite:
    941             let player = nickname
    942                 ?? (ping.playerName.isEmpty ? "A player" : ping.playerName)
    943             return "\(player) invited you to \(puzzleSuffix)"
    944         case .decline:
    945             let player = nickname
    946                 ?? (ping.playerName.isEmpty ? "A player" : ping.playerName)
    947             return "\(player) declined your invitation to \(puzzleSuffix)"
    948         case .friend, .join, .hail, .chronicled:
    949             // System-only kinds handled by the friendship-bootstrap /
    950             // engagement paths; never presented as a notification. If this
    951             // text surfaces in a log or alert, `presentPings` dispatch has
    952             // broken.
    953             return "system-only ping should not be presented"
    954         }
    955     }
    956 }
    957 
    958 private extension Array {
    959     /// Splits the collection into `(matched, rejected)` in one pass.
    960     func partitioned(by predicate: (Element) -> Bool) -> ([Element], [Element]) {
    961         var matched: [Element] = []
    962         var rejected: [Element] = []
    963         for element in self {
    964             if predicate(element) {
    965                 matched.append(element)
    966             } else {
    967                 rejected.append(element)
    968             }
    969         }
    970         return (matched, rejected)
    971     }
    972 }