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