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 }