commit 24c45b27add4882006c862fe81c0661dc55fd25e
parent 6c25968e297c75234550735019ae7699851fab3a
Author: Michael Camilleri <[email protected]>
Date: Wed, 10 Jun 2026 17:30:35 +0900
Extract the invite/ping manager from AppServices
This commit moves the friend-zone traffic into a composed
InviteCoordinator, the fourth extraction from AppServices: outbound game
invites (inviteFriend), inbound Ping handling (claim/dedup, staleness
GC, local notification presentation, friendship-bootstrap dispatch), the
durable InviteEntity rows behind the library's 'Invited' section,
accept/decline and friend blocking.
Code is moved verbatim with method names unchanged. AppServices keeps the
SyncEngine callback wiring and forwards into the coordinator; the badge
refresh is injected as a closure since pending invites count toward the
app-icon badge. The private Array.partitioned(by:) helper moves along
with its only caller, and InviteAcceptanceError now lives on the
coordinator.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
6 files changed, 672 insertions(+), 595 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -54,6 +54,7 @@
50C02D37A41D55CFA5D307E2 /* NYTPuzzleUpgraderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */; };
51E6F7F2FC52C2AA87B9DB45 /* PeerPresenceGraceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08E8592B1CB1336E63498706 /* PeerPresenceGraceTests.swift */; };
54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; };
+ 59230713D85AE6895852B06A /* InviteCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10064D171DB7C48D3DE1E769 /* InviteCoordinator.swift */; };
5992AD4A06D7C6440825E9C6 /* GameArchiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B7539E0AD285C5A3AC3DDA2 /* GameArchiver.swift */; };
5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */; };
5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */; };
@@ -205,6 +206,7 @@
0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreCompletionLockTests.swift; sourceTree = "<group>"; };
0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMonitor.swift; sourceTree = "<group>"; };
0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckResult.swift; sourceTree = "<group>"; };
+ 10064D171DB7C48D3DE1E769 /* InviteCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteCoordinator.swift; sourceTree = "<group>"; };
11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisher.swift; sourceTree = "<group>"; };
122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreMergedAuthorCellsTests.swift; sourceTree = "<group>"; };
14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridStateMerger.swift; sourceTree = "<group>"; };
@@ -608,6 +610,7 @@
4DB0580C9B7C778F34BE6AC2 /* EngagementLifecycle.swift */,
462CE0FD356F6137C9BFD30F /* ImportService.swift */,
6BDD06460A76D4AF31077732 /* InputMonitor.swift */,
+ 10064D171DB7C48D3DE1E769 /* InviteCoordinator.swift */,
33878A29B09A6154C7A63C82 /* KeychainHelper.swift */,
A253416F4FEA271A80B22A73 /* NYTAuthService.swift */,
B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */,
@@ -856,6 +859,7 @@
4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */,
8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */,
1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */,
+ 59230713D85AE6895852B06A /* InviteCoordinator.swift in Sources */,
9502840161DB88BB6BB409D5 /* Journal.swift in Sources */,
B5F78A55C9BCCD24E44D865F /* JournalReplay.swift in Sources */,
F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -43,16 +43,16 @@ struct CrossmateApp: App {
}
})
.environment(\.inviteFriend, { gameID, friendAuthorID in
- try await services.inviteFriend(gameID: gameID, friendAuthorID: friendAuthorID)
+ try await services.invites.inviteFriend(gameID: gameID, friendAuthorID: friendAuthorID)
})
.environment(\.acceptInvite, { shareURL, pingRecordName in
- try await services.acceptInvite(shareURL: shareURL, pingRecordName: pingRecordName)
+ try await services.invites.acceptInvite(shareURL: shareURL, pingRecordName: pingRecordName)
})
.environment(\.declineInvite, { gameID in
- try await services.declineInvite(gameID: gameID)
+ try await services.invites.declineInvite(gameID: gameID)
})
.environment(\.blockFriend, { friendAuthorID in
- await services.blockFriend(authorID: friendAuthorID)
+ await services.invites.blockFriend(authorID: friendAuthorID)
})
.environment(\.sendResignPings, { gameID in
await services.sessions.sendCompletionPings(gameID: gameID, resigned: true)
@@ -624,7 +624,7 @@ private struct PuzzleDisplayView: View {
if !didAcceptInvite {
didAcceptInvite = true
do {
- try await services.acceptPendingInvite(gameID: gameID)
+ try await services.invites.acceptPendingInvite(gameID: gameID)
joinDeadline = Date()
.addingTimeInterval(Self.inviteJoinTimeout)
} catch {
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -6,17 +6,6 @@ import UserNotifications
@MainActor
final class AppServices {
- enum InviteAcceptanceError: LocalizedError {
- case unavailable
-
- var errorDescription: String? {
- switch self {
- case .unavailable:
- return "This invite is no longer available. Ask the sender to invite you again."
- }
- }
- }
-
enum ReadCursorPublishMode {
case activeLease
case currentTime
@@ -107,6 +96,24 @@ final class AppServices {
await self?.publishAccountSeenPush(gameID: gameID, readAt: readAt)
}
)
+ /// Friend-zone traffic — outbound invites, inbound ping handling, durable
+ /// invite rows, friendship bootstrap, blocking; see `InviteCoordinator`.
+ /// Lazy so the badge refresh can capture `self`.
+ private(set) lazy var invites = InviteCoordinator(
+ persistence: persistence,
+ identity: identity,
+ preferences: preferences,
+ syncMonitor: syncMonitor,
+ eventLog: eventLog,
+ syncEngine: syncEngine,
+ announcements: announcements,
+ shareController: shareController,
+ friendController: friendController,
+ cloudService: cloudService,
+ refreshAppBadge: { [weak self] in
+ await self?.badge.refreshAppBadge()
+ }
+ )
let preferences: PlayerPreferences
@@ -137,9 +144,6 @@ final class AppServices {
/// after the first cold-launch game-list freshen, not on every foreground,
/// manual refresh, or remote-triggered refresh.
private var shouldRunColdLaunchArchiveReconcile = true
- private var claimedPingRecordNames: Set<String> = []
- private var claimedPingRecordNameOrder: [String] = []
- private let claimedPingRecordNameCap = 200
/// Wall-clock timestamp of the last successful game-list freshen per
/// scope, used to suppress redundant polls when no inbound push has
/// arrived since. Pushes own freshness; the freshen (zone discovery +
@@ -440,7 +444,7 @@ final class AppServices {
// Player record (their identity) — fires once per new collaborator,
// not on moves and not on their later name / cursor updates.
await syncEngine.setOnRemotePlayersUpdated { [weak self] gameIDs in
- await self?.reconcileFriendships(forGameIDs: gameIDs)
+ await self?.invites.reconcileFriendships(forGameIDs: gameIDs)
// A newly-arrived shared game means a new address slot to mint and
// a token to register under it (so this device can receive the
// game's pushes without opening it first).
@@ -574,7 +578,7 @@ final class AppServices {
await syncEngine.setOnPings { [weak self] pings in
guard let self else { return }
- await self.presentPings(pings)
+ await self.invites.presentPings(pings)
}
await syncEngine.setOnAccountChange { [weak self] in
@@ -624,7 +628,7 @@ final class AppServices {
// by side. `applyInvitePings` GCs the same row, but only when a
// ping is next fetched.
do {
- try self.removePendingInvite(forGameID: gameID)
+ try self.invites.removePendingInvite(forGameID: gameID)
// The pending invite (if any) is gone; drop it from the badge.
await self.badge.refreshAppBadge()
} catch {
@@ -649,7 +653,7 @@ final class AppServices {
// reached us, and clear any durable invite row backed by that Ping.
await syncEngine.setOnPingDeleted { [weak self] pings in
guard let self else { return }
- try? self.removePendingInvites(forPingRecordNames: Set(pings.map { $0.recordName }))
+ try? self.invites.removePendingInvites(forPingRecordNames: Set(pings.map { $0.recordName }))
await self.badge.refreshAppBadge()
for gameID in Set(pings.map { $0.gameID }) {
await self.badge.dismissDeliveredNotifications(
@@ -1738,558 +1742,6 @@ final class AppServices {
return result
}
- /// Re-invites an existing friend to a game: adds them as a participant on
- /// the game's `CKShare` and writes an `.invite` Ping into the friend zone.
- /// Surfaced to the UI via the `\.inviteFriend` environment closure.
- func inviteFriend(gameID: UUID, friendAuthorID: String) async throws {
- guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else {
- throw FriendController.FriendError.friendNotFound
- }
- let ctx = persistence.viewContext
- let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
- req.fetchLimit = 1
- let title = (try? ctx.fetch(req).first)?.title ?? ""
-
- let url = try await shareController.addFriendParticipant(
- toGameID: gameID,
- userRecordName: friendAuthorID
- )
- try await friendController.sendInvite(
- toFriendAuthorID: friendAuthorID,
- gameID: gameID,
- gameTitle: title,
- inviterAuthorID: localAuthorID,
- inviterName: preferences.name,
- gameShareURL: url
- )
- }
-
- /// For each collaborative game with newly-known remote authors, asks the
- /// `FriendController` to bootstrap a friendship. `establishIfOwner` is a
- /// no-op for the non-owner and for already-established pairs (a defensive
- /// backstop — the caller already fires this only on a Player record's
- /// first sighting, so it runs about once per new collaborator).
- private func reconcileFriendships(forGameIDs gameIDs: Set<UUID>) async {
- guard preferences.isICloudSyncEnabled,
- let localAuthorID = identity.currentID,
- !localAuthorID.isEmpty
- else { return }
-
- let ctx = persistence.container.newBackgroundContext()
- let candidates: [(gameID: UUID, remoteAuthorID: String, remoteDisplayName: String?)] = ctx.performAndWait {
- var result: [(UUID, String, String?)] = []
- for gameID in gameIDs {
- let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
- gReq.fetchLimit = 1
- guard let game = try? ctx.fetch(gReq).first else { continue }
- // Only collaborative games carry other authors.
- guard game.databaseScope == 1 || game.ckShareRecordName != nil else { continue }
-
- // Identity comes only from Player records — this feature is
- // deliberately uninterested in Moves (the bootstrap trigger is
- // the first sighting of a remote Player record).
- var playersByAuthorID: [String: String?] = [:]
- let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
- pReq.predicate = NSPredicate(format: "game == %@", game)
- for p in (try? ctx.fetch(pReq)) ?? [] {
- guard let authorID = p.authorID else { continue }
- playersByAuthorID[authorID] = p.name
- }
- playersByAuthorID.removeValue(forKey: localAuthorID)
- playersByAuthorID.removeValue(forKey: CKCurrentUserDefaultName)
- playersByAuthorID.removeValue(forKey: "")
- for (authorID, name) in playersByAuthorID {
- result.append((gameID, authorID, name))
- }
- }
- return result
- }
-
- for (gameID, remoteAuthorID, remoteDisplayName) in candidates {
- await friendController.establishIfOwner(
- localAuthorID: localAuthorID,
- remoteAuthorID: remoteAuthorID,
- localDisplayName: preferences.name,
- remoteDisplayName: remoteDisplayName,
- viaGameID: gameID
- )
- }
- }
-
- /// Upserts a durable `InviteEntity` for each inbound `.invite` Ping so the
- /// Game List's "Invited" section survives the Ping being GC'd. Skips
- /// self-authored invites, invites not directed to this author, invites
- /// from blocked friends, games already joined, and any `pingRecordName`
- /// already seen (a declined row is a tombstone that prevents
- /// resurrection).
- private func applyInvitePings(_ pings: [Ping]) {
- let invites = pings.filter {
- $0.kind == .invite &&
- $0.authorID != identity.currentID &&
- $0.addressee == identity.currentID
- }
- guard !invites.isEmpty else { return }
-
- let ctx = persistence.container.newBackgroundContext()
- ctx.performAndWait {
- for ping in invites {
- guard let payload = FriendZone.InvitePayload.decode(ping.payload) else { continue }
-
- let dupReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
- dupReq.predicate = NSPredicate(format: "pingRecordName == %@", ping.recordName)
- dupReq.fetchLimit = 1
- if ((try? ctx.count(for: dupReq)) ?? 0) > 0 { continue }
-
- let invite = InviteEntity(context: ctx)
- invite.gameID = ping.gameID
- invite.gameTitle = ping.puzzleTitle
- invite.inviterAuthorID = ping.authorID
- invite.inviterName = ping.playerName
- invite.shareURL = payload.gameShareURL
- invite.pingRecordName = ping.recordName
- invite.status = "pending"
- invite.createdAt = Date()
- }
-
- // GC: a pending invite whose game now exists locally was joined by
- // some other path (a link, or accepted on another device), so the
- // "Invited" row is stale — drop it.
- let pendingReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
- pendingReq.predicate = NSPredicate(format: "status == %@", "pending")
- for invite in (try? ctx.fetch(pendingReq)) ?? [] {
- guard let gid = invite.gameID else { continue }
- let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- gReq.predicate = NSPredicate(format: "id == %@", gid as CVarArg)
- gReq.fetchLimit = 1
- if ((try? ctx.count(for: gReq)) ?? 0) > 0 { ctx.delete(invite) }
- }
-
- if ctx.hasChanges {
- do {
- try ctx.save()
- } catch {
- Task { @MainActor [weak self] in
- self?.eventLog.note("AppServices: applyInvitePings save failed — \(error)", level: "error")
- }
- }
- }
- }
- }
-
- /// Drops the pending invite row(s) for `gameID`. Called when the game's
- /// shared zone appears locally (joined here or on a sibling device): the
- /// "Invited" row is now redundant. `applyInvitePings` runs the same
- /// garbage-collection over every pending invite, but only when a ping is
- /// fetched — hooking zone arrival closes the window where a just-synced
- /// game and its stale invite show side by side in the library.
- func removePendingInvite(forGameID gameID: UUID) throws {
- let ctx = persistence.viewContext
- let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
- req.predicate = NSPredicate(
- format: "gameID == %@ AND status == %@", gameID as CVarArg, "pending"
- )
- let invites = try ctx.fetch(req)
- guard !invites.isEmpty else { return }
- for invite in invites { ctx.delete(invite) }
- if ctx.hasChanges {
- try ctx.save()
- }
- }
-
- /// Drops pending invite rows whose source Ping was consumed elsewhere on
- /// this account. Matching by record name avoids conflating invite Pings
- /// with other Ping kinds for the same game.
- func removePendingInvites(forPingRecordNames recordNames: Set<String>) throws {
- guard !recordNames.isEmpty else { return }
- let ctx = persistence.viewContext
- let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
- req.predicate = NSPredicate(
- format: "pingRecordName IN %@ AND status == %@",
- Array(recordNames),
- "pending"
- )
- let invites = try ctx.fetch(req)
- guard !invites.isEmpty else { return }
- for invite in invites { ctx.delete(invite) }
- if ctx.hasChanges {
- try ctx.save()
- }
- }
-
- /// Accepts a pending game invite: fetches the share metadata, joins via
- /// the existing share-accept path, then drops the local `InviteEntity`
- /// (the game now represents it). If CloudKit says the share URL no longer
- /// exists, the durable invite row is stale, so it is removed as well.
- /// Surfaced via `\.acceptInvite`.
- func acceptInvite(shareURL: String, pingRecordName: String) async throws {
- guard let url = URL(string: shareURL) else {
- throw FriendController.FriendError.missingShareURLInPayload
- }
- do {
- try await cloudService.acceptShare(url: url)
- } catch let error as CKError where error.code == .unknownItem {
- // Stale share: the row needs to go away too, but the next
- // `applyInvitePings` will GC it if this cleanup itself fails.
- // The user-visible signal here is `.unavailable`, so don't let a
- // cleanup error clobber it — log and continue.
- do {
- try await deleteInviteAndPing(pingRecordName: pingRecordName)
- syncMonitor.note("accept invite: removed stale invite \(pingRecordName)")
- } catch {
- syncMonitor.note("accept invite: stale-invite cleanup failed for \(pingRecordName) — \(error)")
- }
- throw InviteAcceptanceError.unavailable
- }
- try await deleteInviteAndPing(pingRecordName: pingRecordName)
- }
-
- /// Accepts the pending invite for `gameID`, if one is still recorded
- /// locally. The puzzle-display join path calls this when the user reached
- /// a not-yet-joined shared game by tapping its `.invite` notification:
- /// that tap only navigates, so the CKShare must still be accepted here.
- /// The durable `InviteEntity` (written when the `.invite` Ping arrived)
- /// carries the share URL. Throws `InviteAcceptanceError.unavailable` when
- /// no such invite exists — the same error the stale-share case surfaces.
- func acceptPendingInvite(gameID: UUID) async throws {
- let ctx = persistence.viewContext
- let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
- req.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg)
- req.sortDescriptors = [
- NSSortDescriptor(keyPath: \InviteEntity.createdAt, ascending: false)
- ]
- req.fetchLimit = 1
- guard let invite = (try? ctx.fetch(req))?.first,
- let shareURL = invite.shareURL,
- let pingRecordName = invite.pingRecordName
- else {
- throw InviteAcceptanceError.unavailable
- }
- try await acceptInvite(shareURL: shareURL, pingRecordName: pingRecordName)
- }
-
- private func deleteInviteAndPing(pingRecordName: String) async throws {
- let ctx = persistence.viewContext
- let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
- req.predicate = NSPredicate(format: "pingRecordName == %@", pingRecordName)
- for invite in try ctx.fetch(req) {
- if let inviterAuthorID = invite.inviterAuthorID {
- await friendController.deleteInvitePing(
- fromFriendAuthorID: inviterAuthorID,
- recordName: pingRecordName
- )
- }
- ctx.delete(invite)
- }
- if ctx.hasChanges {
- try ctx.save()
- await badge.refreshAppBadge()
- }
- }
-
- /// Declines a pending game invite: marks the durable `InviteEntity` rows for
- /// `gameID` as a `"declined"` tombstone (which prevents the invite from
- /// resurrecting locally if CloudKit deletion is delayed), consumes the
- /// source invite Ping so sibling devices clear their rows, and refreshes
- /// the badge. Surfaced via `\.declineInvite`; the AppServices entry point
- /// keeps invite mutation and the badge refresh in one place, mirroring
- /// `acceptInvite`.
- func declineInvite(gameID: UUID) async throws {
- let ctx = persistence.viewContext
- let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
- req.predicate = NSPredicate(
- format: "gameID == %@ AND status == %@", gameID as CVarArg, "pending"
- )
- let invites = try ctx.fetch(req)
- guard !invites.isEmpty else { return }
- let pingsToDelete = invites.compactMap { invite -> (String, String)? in
- guard let inviterAuthorID = invite.inviterAuthorID,
- let pingRecordName = invite.pingRecordName
- else { return nil }
- return (inviterAuthorID, pingRecordName)
- }
- for invite in invites {
- invite.status = "declined"
- }
- if ctx.hasChanges {
- try ctx.save()
- await badge.refreshAppBadge()
- }
- for (inviterAuthorID, pingRecordName) in pingsToDelete {
- await friendController.deleteInvitePing(
- fromFriendAuthorID: inviterAuthorID,
- recordName: pingRecordName
- )
- }
- }
-
- /// Blocks a collaborator: marks the friendship blocked and tears down the
- /// friend zone, leaves every game they currently share with us, and drops
- /// their pending invites. Games we *own* that they joined are untouched.
- /// Surfaced via `\.blockFriend`.
- func blockFriend(authorID: String) async {
- do {
- try await friendController.blockAndTeardown(friendAuthorID: authorID)
- } catch {
- announcements.post(Announcement(
- id: "block-friend-error-\(authorID)",
- scope: .global,
- severity: .error,
- title: "Blocking Failed",
- body: error.localizedDescription,
- dismissal: .manual
- ))
- return
- }
-
- let ctx = persistence.container.newBackgroundContext()
- let gameIDsToLeave: [UUID] = ctx.performAndWait {
- let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- req.predicate = NSPredicate(format: "databaseScope == 1")
- var ids: [UUID] = []
- for game in (try? ctx.fetch(req)) ?? [] {
- guard let gid = game.id else { continue }
- var authors = Set<String>()
- if let owner = game.ckZoneOwnerName { authors.insert(owner) }
- let mReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
- mReq.predicate = NSPredicate(format: "game == %@", game)
- for m in (try? ctx.fetch(mReq)) ?? [] {
- if let a = m.authorID { authors.insert(a) }
- }
- let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
- pReq.predicate = NSPredicate(format: "game == %@", game)
- for p in (try? ctx.fetch(pReq)) ?? [] {
- if let a = p.authorID { authors.insert(a) }
- }
- if authors.contains(authorID) { ids.append(gid) }
- }
- return ids
- }
- for gid in gameIDsToLeave {
- try? await shareController.leaveShare(gameID: gid)
- }
-
- // Drop their pending invites. Future inbound `.invite` Pings from a
- // blocked sender are caught by `consumeStaleInvites`, which deletes
- // the Ping so it doesn't re-fire across cold starts.
- let vctx = persistence.viewContext
- let iReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
- iReq.predicate = NSPredicate(format: "inviterAuthorID == %@", authorID)
- for invite in (try? vctx.fetch(iReq)) ?? [] { vctx.delete(invite) }
- if vctx.hasChanges {
- try? vctx.save()
- await badge.refreshAppBadge()
- }
- }
-
- /// Deletes `.invite` Pings that are no longer actionable on this device —
- /// the game is already in the local library (joined here or on a sibling),
- /// or the inviter is blocked — and returns the surviving pings. Running
- /// this upstream of both `applyInvitePings` and the notification loop
- /// keeps the staleness rule in one place; without it, an orphaned invite
- /// Ping re-fires a notification on every cold start because the in-memory
- /// dedup caches reset.
- private func consumeStaleInvites(_ pings: [Ping]) async -> [Ping] {
- let candidates = pings.filter {
- $0.kind == .invite &&
- $0.authorID != identity.currentID &&
- $0.addressee == identity.currentID
- }
- guard !candidates.isEmpty else { return pings }
-
- let ctx = persistence.container.newBackgroundContext()
- let staleNames: Set<String> = ctx.performAndWait {
- Self.staleInviteRecordNames(
- among: candidates,
- in: ctx,
- currentAuthorID: identity.currentID
- )
- }
- guard !staleNames.isEmpty else { return pings }
-
- for ping in candidates where staleNames.contains(ping.recordName) {
- await friendController.deleteInvitePing(
- fromFriendAuthorID: ping.authorID,
- recordName: ping.recordName
- )
- syncMonitor.note(
- "ping(invite): consumed stale invite \(ping.recordName) for \(ping.gameID.uuidString)"
- )
- }
- return pings.filter { !staleNames.contains($0.recordName) }
- }
-
- static func staleInviteRecordNames(
- among pings: [Ping],
- in ctx: NSManagedObjectContext,
- currentAuthorID: String?
- ) -> Set<String> {
- var names: Set<String> = []
- for ping in pings where ping.kind == .invite &&
- ping.authorID != currentAuthorID &&
- ping.addressee == currentAuthorID {
- guard FriendZone.InvitePayload.decode(ping.payload) != nil else {
- names.insert(ping.recordName)
- continue
- }
-
- let blockedReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
- blockedReq.predicate = NSPredicate(
- format: "authorID == %@ AND isBlocked == YES", ping.authorID
- )
- blockedReq.fetchLimit = 1
- if ((try? ctx.count(for: blockedReq)) ?? 0) > 0 {
- names.insert(ping.recordName)
- continue
- }
-
- let inviteReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
- inviteReq.predicate = NSPredicate(format: "pingRecordName == %@", ping.recordName)
- inviteReq.fetchLimit = 1
- if let invite = try? ctx.fetch(inviteReq).first,
- invite.status != "pending" {
- names.insert(ping.recordName)
- continue
- }
-
- let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- gameReq.predicate = NSPredicate(format: "id == %@", ping.gameID as CVarArg)
- gameReq.fetchLimit = 1
- if ((try? ctx.count(for: gameReq)) ?? 0) > 0 {
- names.insert(ping.recordName)
- }
- }
- return names
- }
-
- private func presentPings(_ pings: [Ping]) async {
- let claimed = claimPingsForHandling(pings)
- guard !claimed.isEmpty else { return }
- let pings = await consumeStaleInvites(claimed)
- guard !pings.isEmpty else { return }
- applyInvitePings(pings)
- // Reflect any newly-stored pending invite in the app-icon badge now —
- // before the notification-authorization guard — so the badge updates
- // even when the banner is suppressed or unauthorized.
- await badge.refreshAppBadge()
- // `.friend` is the friendship-bootstrap handshake. `.join` and `.hail`
- // are legacy live-notification/bootstrap kinds; APNs and Game-record
- // engagement creds own those jobs now. System pings do not require
- // notification authorization.
- let (systemPings, playerFacingPings) = pings.partitioned {
- $0.kind == .friend || $0.kind == .join || $0.kind == .hail
- }
- for ping in systemPings where ping.kind == .friend {
- await friendController.applyFriendPing(ping)
- }
- guard !playerFacingPings.isEmpty else { return }
- guard await canPresentNotifications() else {
- syncMonitor.note("ping: local notification skipped — authorization not granted")
- return
- }
-
- let center = UNUserNotificationCenter.current()
- for ping in playerFacingPings {
- if ping.kind == .invite, ping.addressee != identity.currentID {
- continue
- }
- // A directed ping (`addressee` set) targets one player by
- // authorID. Ignore one addressed to someone else — another user's
- // device receives and consumes it. nil ⇒ broadcast, which is now
- // legacy and ignored for `.invite`.
- if let addressee = ping.addressee, addressee != identity.currentID {
- continue
- }
- if ping.authorID == identity.currentID {
- syncMonitor.note("ping(\(ping.kind.rawValue)): skipped self-authored record \(ping.recordName)")
- continue
- }
- // A directed ping addressed to us is consumed by this account:
- // once handled — shown, suppressed, or a duplicate — delete it so
- // it stops re-notifying and the deletion withdraws any copy our
- // sibling devices showed. Broadcast pings are left as-is.
- let consume = ping.addressee != nil && ping.kind != .invite
- func consumeIfDirected() async {
- guard consume else { return }
- await syncEngine.deletePing(recordName: ping.recordName, gameID: ping.gameID)
- }
- if NotificationState.isSuppressed(gameID: ping.gameID) {
- syncMonitor.note("ping(\(ping.kind.rawValue)): suppressed — puzzle is active for \(ping.gameID.uuidString)")
- await consumeIfDirected()
- continue
- }
-
- let content = UNMutableNotificationContent()
- content.title = "Crossmate"
- content.body = Self.bodyText(for: ping)
- content.sound = .default
- content.userInfo = [
- "gameID": ping.gameID.uuidString,
- "pingKind": ping.kind.rawValue
- ]
-
- let request = UNNotificationRequest(
- identifier: "ping-\(ping.gameID.uuidString)-\(UUID().uuidString)",
- content: content,
- trigger: nil
- )
- do {
- try await center.add(request)
- syncMonitor.note("ping(\(ping.kind.rawValue)): queued local notification for \(ping.gameID.uuidString)")
- await consumeIfDirected()
- } catch {
- syncMonitor.note("ping(\(ping.kind.rawValue)): local notification failed — \(error.localizedDescription)")
- }
- }
- }
-
- private func claimPingsForHandling(_ pings: [Ping]) -> [Ping] {
- var unclaimed: [Ping] = []
- for ping in pings {
- guard claimedPingRecordNames.insert(ping.recordName).inserted else {
- syncMonitor.note("ping(\(ping.kind.rawValue)): already-handled record \(ping.recordName)")
- continue
- }
- claimedPingRecordNameOrder.append(ping.recordName)
- unclaimed.append(ping)
- }
- if claimedPingRecordNameOrder.count > claimedPingRecordNameCap {
- let overflow = claimedPingRecordNameOrder.count - claimedPingRecordNameCap
- for recordName in claimedPingRecordNameOrder.prefix(overflow) {
- claimedPingRecordNames.remove(recordName)
- }
- claimedPingRecordNameOrder.removeFirst(overflow)
- }
- return unclaimed
- }
-
- private func canPresentNotifications() async -> Bool {
- let center = UNUserNotificationCenter.current()
- let settings = await center.notificationSettings()
- switch settings.authorizationStatus {
- case .authorized, .provisional, .ephemeral:
- return true
- default:
- return false
- }
- }
-
- nonisolated static func bodyText(for ping: Ping) -> String {
- let puzzleSuffix = ping.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(ping.puzzleTitle)'"
- switch ping.kind {
- case .invite:
- let player = ping.playerName.isEmpty ? "A player" : ping.playerName
- return "\(player) invited you to \(puzzleSuffix)"
- case .friend, .join, .hail:
- // System-only kinds handled by the friendship-bootstrap /
- // engagement paths; never presented as a notification. If this
- // text surfaces in a log or alert, `presentPings` dispatch has
- // broken.
- return "system-only ping should not be presented"
- }
- }
-
/// Parses the silent-push payload into a short, human-readable summary
/// (database scope, notification type, subscription ID, pruned flag).
/// Used by the diagnostics log to confirm whether shared-DB pushes are
@@ -2733,19 +2185,3 @@ final class AppServices {
}
}
-
-private extension Array {
- /// Splits the collection into `(matched, rejected)` in one pass.
- func partitioned(by predicate: (Element) -> Bool) -> ([Element], [Element]) {
- var matched: [Element] = []
- var rejected: [Element] = []
- for element in self {
- if predicate(element) {
- matched.append(element)
- } else {
- rejected.append(element)
- }
- }
- return (matched, rejected)
- }
-}
diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift
@@ -0,0 +1,637 @@
+import CloudKit
+import CoreData
+import Foundation
+import UserNotifications
+
+/// Owns the friend-zone traffic that used to live in `AppServices`: outbound
+/// game invites, inbound `Ping` handling (claim/dedup, staleness GC, local
+/// notification presentation, friendship-bootstrap dispatch), the durable
+/// `InviteEntity` rows behind the library's "Invited" section, and friend
+/// blocking. `AppServices` composes one instance and forwards the
+/// `SyncEngine` ping callbacks into it; the accept/decline/block entry
+/// points are surfaced to the UI through environment closures.
+@MainActor
+final class InviteCoordinator {
+ enum InviteAcceptanceError: LocalizedError {
+ case unavailable
+
+ var errorDescription: String? {
+ switch self {
+ case .unavailable:
+ return "This invite is no longer available. Ask the sender to invite you again."
+ }
+ }
+ }
+
+ private let persistence: PersistenceController
+ private let identity: AuthorIdentity
+ private let preferences: PlayerPreferences
+ private let syncMonitor: SyncMonitor
+ private let eventLog: EventLog
+ private let syncEngine: SyncEngine
+ private let announcements: AnnouncementCenter
+ private let shareController: ShareController
+ private let friendController: FriendController
+ private let cloudService: CloudService
+ /// Refreshes the app-icon badge — `BadgeCoordinator.refreshAppBadge` —
+ /// whenever invite rows change (pending invites count toward the badge).
+ private let refreshAppBadge: () async -> Void
+
+ private var claimedPingRecordNames: Set<String> = []
+ private var claimedPingRecordNameOrder: [String] = []
+ private let claimedPingRecordNameCap = 200
+
+ init(
+ persistence: PersistenceController,
+ identity: AuthorIdentity,
+ preferences: PlayerPreferences,
+ syncMonitor: SyncMonitor,
+ eventLog: EventLog,
+ syncEngine: SyncEngine,
+ announcements: AnnouncementCenter,
+ shareController: ShareController,
+ friendController: FriendController,
+ cloudService: CloudService,
+ refreshAppBadge: @escaping () async -> Void
+ ) {
+ self.persistence = persistence
+ self.identity = identity
+ self.preferences = preferences
+ self.syncMonitor = syncMonitor
+ self.eventLog = eventLog
+ self.syncEngine = syncEngine
+ self.announcements = announcements
+ self.shareController = shareController
+ self.friendController = friendController
+ self.cloudService = cloudService
+ self.refreshAppBadge = refreshAppBadge
+ }
+
+ /// Re-invites an existing friend to a game: adds them as a participant on
+ /// the game's `CKShare` and writes an `.invite` Ping into the friend zone.
+ /// Surfaced to the UI via the `\.inviteFriend` environment closure.
+ func inviteFriend(gameID: UUID, friendAuthorID: String) async throws {
+ guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else {
+ throw FriendController.FriendError.friendNotFound
+ }
+ let ctx = persistence.viewContext
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ req.fetchLimit = 1
+ let title = (try? ctx.fetch(req).first)?.title ?? ""
+
+ let url = try await shareController.addFriendParticipant(
+ toGameID: gameID,
+ userRecordName: friendAuthorID
+ )
+ try await friendController.sendInvite(
+ toFriendAuthorID: friendAuthorID,
+ gameID: gameID,
+ gameTitle: title,
+ inviterAuthorID: localAuthorID,
+ inviterName: preferences.name,
+ gameShareURL: url
+ )
+ }
+
+ /// For each collaborative game with newly-known remote authors, asks the
+ /// `FriendController` to bootstrap a friendship. `establishIfOwner` is a
+ /// no-op for the non-owner and for already-established pairs (a defensive
+ /// backstop — the caller already fires this only on a Player record's
+ /// first sighting, so it runs about once per new collaborator).
+ func reconcileFriendships(forGameIDs gameIDs: Set<UUID>) async {
+ guard preferences.isICloudSyncEnabled,
+ let localAuthorID = identity.currentID,
+ !localAuthorID.isEmpty
+ else { return }
+
+ let ctx = persistence.container.newBackgroundContext()
+ let candidates: [(gameID: UUID, remoteAuthorID: String, remoteDisplayName: String?)] = ctx.performAndWait {
+ var result: [(UUID, String, String?)] = []
+ for gameID in gameIDs {
+ let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ gReq.fetchLimit = 1
+ guard let game = try? ctx.fetch(gReq).first else { continue }
+ // Only collaborative games carry other authors.
+ guard game.databaseScope == 1 || game.ckShareRecordName != nil else { continue }
+
+ // Identity comes only from Player records — this feature is
+ // deliberately uninterested in Moves (the bootstrap trigger is
+ // the first sighting of a remote Player record).
+ var playersByAuthorID: [String: String?] = [:]
+ let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ pReq.predicate = NSPredicate(format: "game == %@", game)
+ for p in (try? ctx.fetch(pReq)) ?? [] {
+ guard let authorID = p.authorID else { continue }
+ playersByAuthorID[authorID] = p.name
+ }
+ playersByAuthorID.removeValue(forKey: localAuthorID)
+ playersByAuthorID.removeValue(forKey: CKCurrentUserDefaultName)
+ playersByAuthorID.removeValue(forKey: "")
+ for (authorID, name) in playersByAuthorID {
+ result.append((gameID, authorID, name))
+ }
+ }
+ return result
+ }
+
+ for (gameID, remoteAuthorID, remoteDisplayName) in candidates {
+ await friendController.establishIfOwner(
+ localAuthorID: localAuthorID,
+ remoteAuthorID: remoteAuthorID,
+ localDisplayName: preferences.name,
+ remoteDisplayName: remoteDisplayName,
+ viaGameID: gameID
+ )
+ }
+ }
+
+ /// Upserts a durable `InviteEntity` for each inbound `.invite` Ping so the
+ /// Game List's "Invited" section survives the Ping being GC'd. Skips
+ /// self-authored invites, invites not directed to this author, invites
+ /// from blocked friends, games already joined, and any `pingRecordName`
+ /// already seen (a declined row is a tombstone that prevents
+ /// resurrection).
+ private func applyInvitePings(_ pings: [Ping]) {
+ let invites = pings.filter {
+ $0.kind == .invite &&
+ $0.authorID != identity.currentID &&
+ $0.addressee == identity.currentID
+ }
+ guard !invites.isEmpty else { return }
+
+ let ctx = persistence.container.newBackgroundContext()
+ ctx.performAndWait {
+ for ping in invites {
+ guard let payload = FriendZone.InvitePayload.decode(ping.payload) else { continue }
+
+ let dupReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
+ dupReq.predicate = NSPredicate(format: "pingRecordName == %@", ping.recordName)
+ dupReq.fetchLimit = 1
+ if ((try? ctx.count(for: dupReq)) ?? 0) > 0 { continue }
+
+ let invite = InviteEntity(context: ctx)
+ invite.gameID = ping.gameID
+ invite.gameTitle = ping.puzzleTitle
+ invite.inviterAuthorID = ping.authorID
+ invite.inviterName = ping.playerName
+ invite.shareURL = payload.gameShareURL
+ invite.pingRecordName = ping.recordName
+ invite.status = "pending"
+ invite.createdAt = Date()
+ }
+
+ // GC: a pending invite whose game now exists locally was joined by
+ // some other path (a link, or accepted on another device), so the
+ // "Invited" row is stale — drop it.
+ let pendingReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
+ pendingReq.predicate = NSPredicate(format: "status == %@", "pending")
+ for invite in (try? ctx.fetch(pendingReq)) ?? [] {
+ guard let gid = invite.gameID else { continue }
+ let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gReq.predicate = NSPredicate(format: "id == %@", gid as CVarArg)
+ gReq.fetchLimit = 1
+ if ((try? ctx.count(for: gReq)) ?? 0) > 0 { ctx.delete(invite) }
+ }
+
+ if ctx.hasChanges {
+ do {
+ try ctx.save()
+ } catch {
+ Task { @MainActor [weak self] in
+ self?.eventLog.note("InviteCoordinator: applyInvitePings save failed — \(error)", level: "error")
+ }
+ }
+ }
+ }
+ }
+
+ /// Drops the pending invite row(s) for `gameID`. Called when the game's
+ /// shared zone appears locally (joined here or on a sibling device): the
+ /// "Invited" row is now redundant. `applyInvitePings` runs the same
+ /// garbage-collection over every pending invite, but only when a ping is
+ /// fetched — hooking zone arrival closes the window where a just-synced
+ /// game and its stale invite show side by side in the library.
+ func removePendingInvite(forGameID gameID: UUID) throws {
+ let ctx = persistence.viewContext
+ let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
+ req.predicate = NSPredicate(
+ format: "gameID == %@ AND status == %@", gameID as CVarArg, "pending"
+ )
+ let invites = try ctx.fetch(req)
+ guard !invites.isEmpty else { return }
+ for invite in invites { ctx.delete(invite) }
+ if ctx.hasChanges {
+ try ctx.save()
+ }
+ }
+
+ /// Drops pending invite rows whose source Ping was consumed elsewhere on
+ /// this account. Matching by record name avoids conflating invite Pings
+ /// with other Ping kinds for the same game.
+ func removePendingInvites(forPingRecordNames recordNames: Set<String>) throws {
+ guard !recordNames.isEmpty else { return }
+ let ctx = persistence.viewContext
+ let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
+ req.predicate = NSPredicate(
+ format: "pingRecordName IN %@ AND status == %@",
+ Array(recordNames),
+ "pending"
+ )
+ let invites = try ctx.fetch(req)
+ guard !invites.isEmpty else { return }
+ for invite in invites { ctx.delete(invite) }
+ if ctx.hasChanges {
+ try ctx.save()
+ }
+ }
+
+ /// Accepts a pending game invite: fetches the share metadata, joins via
+ /// the existing share-accept path, then drops the local `InviteEntity`
+ /// (the game now represents it). If CloudKit says the share URL no longer
+ /// exists, the durable invite row is stale, so it is removed as well.
+ /// Surfaced via `\.acceptInvite`.
+ func acceptInvite(shareURL: String, pingRecordName: String) async throws {
+ guard let url = URL(string: shareURL) else {
+ throw FriendController.FriendError.missingShareURLInPayload
+ }
+ do {
+ try await cloudService.acceptShare(url: url)
+ } catch let error as CKError where error.code == .unknownItem {
+ // Stale share: the row needs to go away too, but the next
+ // `applyInvitePings` will GC it if this cleanup itself fails.
+ // The user-visible signal here is `.unavailable`, so don't let a
+ // cleanup error clobber it — log and continue.
+ do {
+ try await deleteInviteAndPing(pingRecordName: pingRecordName)
+ syncMonitor.note("accept invite: removed stale invite \(pingRecordName)")
+ } catch {
+ syncMonitor.note("accept invite: stale-invite cleanup failed for \(pingRecordName) — \(error)")
+ }
+ throw InviteAcceptanceError.unavailable
+ }
+ try await deleteInviteAndPing(pingRecordName: pingRecordName)
+ }
+
+ /// Accepts the pending invite for `gameID`, if one is still recorded
+ /// locally. The puzzle-display join path calls this when the user reached
+ /// a not-yet-joined shared game by tapping its `.invite` notification:
+ /// that tap only navigates, so the CKShare must still be accepted here.
+ /// The durable `InviteEntity` (written when the `.invite` Ping arrived)
+ /// carries the share URL. Throws `InviteAcceptanceError.unavailable` when
+ /// no such invite exists — the same error the stale-share case surfaces.
+ func acceptPendingInvite(gameID: UUID) async throws {
+ let ctx = persistence.viewContext
+ let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
+ req.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg)
+ req.sortDescriptors = [
+ NSSortDescriptor(keyPath: \InviteEntity.createdAt, ascending: false)
+ ]
+ req.fetchLimit = 1
+ guard let invite = (try? ctx.fetch(req))?.first,
+ let shareURL = invite.shareURL,
+ let pingRecordName = invite.pingRecordName
+ else {
+ throw InviteAcceptanceError.unavailable
+ }
+ try await acceptInvite(shareURL: shareURL, pingRecordName: pingRecordName)
+ }
+
+ private func deleteInviteAndPing(pingRecordName: String) async throws {
+ let ctx = persistence.viewContext
+ let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
+ req.predicate = NSPredicate(format: "pingRecordName == %@", pingRecordName)
+ for invite in try ctx.fetch(req) {
+ if let inviterAuthorID = invite.inviterAuthorID {
+ await friendController.deleteInvitePing(
+ fromFriendAuthorID: inviterAuthorID,
+ recordName: pingRecordName
+ )
+ }
+ ctx.delete(invite)
+ }
+ if ctx.hasChanges {
+ try ctx.save()
+ await refreshAppBadge()
+ }
+ }
+
+ /// Declines a pending game invite: marks the durable `InviteEntity` rows for
+ /// `gameID` as a `"declined"` tombstone (which prevents the invite from
+ /// resurrecting locally if CloudKit deletion is delayed), consumes the
+ /// source invite Ping so sibling devices clear their rows, and refreshes
+ /// the badge. Surfaced via `\.declineInvite`; the AppServices entry point
+ /// keeps invite mutation and the badge refresh in one place, mirroring
+ /// `acceptInvite`.
+ func declineInvite(gameID: UUID) async throws {
+ let ctx = persistence.viewContext
+ let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
+ req.predicate = NSPredicate(
+ format: "gameID == %@ AND status == %@", gameID as CVarArg, "pending"
+ )
+ let invites = try ctx.fetch(req)
+ guard !invites.isEmpty else { return }
+ let pingsToDelete = invites.compactMap { invite -> (String, String)? in
+ guard let inviterAuthorID = invite.inviterAuthorID,
+ let pingRecordName = invite.pingRecordName
+ else { return nil }
+ return (inviterAuthorID, pingRecordName)
+ }
+ for invite in invites {
+ invite.status = "declined"
+ }
+ if ctx.hasChanges {
+ try ctx.save()
+ await refreshAppBadge()
+ }
+ for (inviterAuthorID, pingRecordName) in pingsToDelete {
+ await friendController.deleteInvitePing(
+ fromFriendAuthorID: inviterAuthorID,
+ recordName: pingRecordName
+ )
+ }
+ }
+
+ /// Blocks a collaborator: marks the friendship blocked and tears down the
+ /// friend zone, leaves every game they currently share with us, and drops
+ /// their pending invites. Games we *own* that they joined are untouched.
+ /// Surfaced via `\.blockFriend`.
+ func blockFriend(authorID: String) async {
+ do {
+ try await friendController.blockAndTeardown(friendAuthorID: authorID)
+ } catch {
+ announcements.post(Announcement(
+ id: "block-friend-error-\(authorID)",
+ scope: .global,
+ severity: .error,
+ title: "Blocking Failed",
+ body: error.localizedDescription,
+ dismissal: .manual
+ ))
+ return
+ }
+
+ let ctx = persistence.container.newBackgroundContext()
+ let gameIDsToLeave: [UUID] = ctx.performAndWait {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "databaseScope == 1")
+ var ids: [UUID] = []
+ for game in (try? ctx.fetch(req)) ?? [] {
+ guard let gid = game.id else { continue }
+ var authors = Set<String>()
+ if let owner = game.ckZoneOwnerName { authors.insert(owner) }
+ let mReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
+ mReq.predicate = NSPredicate(format: "game == %@", game)
+ for m in (try? ctx.fetch(mReq)) ?? [] {
+ if let a = m.authorID { authors.insert(a) }
+ }
+ let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ pReq.predicate = NSPredicate(format: "game == %@", game)
+ for p in (try? ctx.fetch(pReq)) ?? [] {
+ if let a = p.authorID { authors.insert(a) }
+ }
+ if authors.contains(authorID) { ids.append(gid) }
+ }
+ return ids
+ }
+ for gid in gameIDsToLeave {
+ try? await shareController.leaveShare(gameID: gid)
+ }
+
+ // Drop their pending invites. Future inbound `.invite` Pings from a
+ // blocked sender are caught by `consumeStaleInvites`, which deletes
+ // the Ping so it doesn't re-fire across cold starts.
+ let vctx = persistence.viewContext
+ let iReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
+ iReq.predicate = NSPredicate(format: "inviterAuthorID == %@", authorID)
+ for invite in (try? vctx.fetch(iReq)) ?? [] { vctx.delete(invite) }
+ if vctx.hasChanges {
+ try? vctx.save()
+ await refreshAppBadge()
+ }
+ }
+
+ /// Deletes `.invite` Pings that are no longer actionable on this device —
+ /// the game is already in the local library (joined here or on a sibling),
+ /// or the inviter is blocked — and returns the surviving pings. Running
+ /// this upstream of both `applyInvitePings` and the notification loop
+ /// keeps the staleness rule in one place; without it, an orphaned invite
+ /// Ping re-fires a notification on every cold start because the in-memory
+ /// dedup caches reset.
+ private func consumeStaleInvites(_ pings: [Ping]) async -> [Ping] {
+ let candidates = pings.filter {
+ $0.kind == .invite &&
+ $0.authorID != identity.currentID &&
+ $0.addressee == identity.currentID
+ }
+ guard !candidates.isEmpty else { return pings }
+
+ let ctx = persistence.container.newBackgroundContext()
+ let staleNames: Set<String> = ctx.performAndWait {
+ Self.staleInviteRecordNames(
+ among: candidates,
+ in: ctx,
+ currentAuthorID: identity.currentID
+ )
+ }
+ guard !staleNames.isEmpty else { return pings }
+
+ for ping in candidates where staleNames.contains(ping.recordName) {
+ await friendController.deleteInvitePing(
+ fromFriendAuthorID: ping.authorID,
+ recordName: ping.recordName
+ )
+ syncMonitor.note(
+ "ping(invite): consumed stale invite \(ping.recordName) for \(ping.gameID.uuidString)"
+ )
+ }
+ return pings.filter { !staleNames.contains($0.recordName) }
+ }
+
+ static func staleInviteRecordNames(
+ among pings: [Ping],
+ in ctx: NSManagedObjectContext,
+ currentAuthorID: String?
+ ) -> Set<String> {
+ var names: Set<String> = []
+ for ping in pings where ping.kind == .invite &&
+ ping.authorID != currentAuthorID &&
+ ping.addressee == currentAuthorID {
+ guard FriendZone.InvitePayload.decode(ping.payload) != nil else {
+ names.insert(ping.recordName)
+ continue
+ }
+
+ let blockedReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
+ blockedReq.predicate = NSPredicate(
+ format: "authorID == %@ AND isBlocked == YES", ping.authorID
+ )
+ blockedReq.fetchLimit = 1
+ if ((try? ctx.count(for: blockedReq)) ?? 0) > 0 {
+ names.insert(ping.recordName)
+ continue
+ }
+
+ let inviteReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
+ inviteReq.predicate = NSPredicate(format: "pingRecordName == %@", ping.recordName)
+ inviteReq.fetchLimit = 1
+ if let invite = try? ctx.fetch(inviteReq).first,
+ invite.status != "pending" {
+ names.insert(ping.recordName)
+ continue
+ }
+
+ let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gameReq.predicate = NSPredicate(format: "id == %@", ping.gameID as CVarArg)
+ gameReq.fetchLimit = 1
+ if ((try? ctx.count(for: gameReq)) ?? 0) > 0 {
+ names.insert(ping.recordName)
+ }
+ }
+ return names
+ }
+
+ func presentPings(_ pings: [Ping]) async {
+ let claimed = claimPingsForHandling(pings)
+ guard !claimed.isEmpty else { return }
+ let pings = await consumeStaleInvites(claimed)
+ guard !pings.isEmpty else { return }
+ applyInvitePings(pings)
+ // Reflect any newly-stored pending invite in the app-icon badge now —
+ // before the notification-authorization guard — so the badge updates
+ // even when the banner is suppressed or unauthorized.
+ await refreshAppBadge()
+ // `.friend` is the friendship-bootstrap handshake. `.join` and `.hail`
+ // are legacy live-notification/bootstrap kinds; APNs and Game-record
+ // engagement creds own those jobs now. System pings do not require
+ // notification authorization.
+ let (systemPings, playerFacingPings) = pings.partitioned {
+ $0.kind == .friend || $0.kind == .join || $0.kind == .hail
+ }
+ for ping in systemPings where ping.kind == .friend {
+ await friendController.applyFriendPing(ping)
+ }
+ guard !playerFacingPings.isEmpty else { return }
+ guard await canPresentNotifications() else {
+ syncMonitor.note("ping: local notification skipped — authorization not granted")
+ return
+ }
+
+ let center = UNUserNotificationCenter.current()
+ for ping in playerFacingPings {
+ if ping.kind == .invite, ping.addressee != identity.currentID {
+ continue
+ }
+ // A directed ping (`addressee` set) targets one player by
+ // authorID. Ignore one addressed to someone else — another user's
+ // device receives and consumes it. nil ⇒ broadcast, which is now
+ // legacy and ignored for `.invite`.
+ if let addressee = ping.addressee, addressee != identity.currentID {
+ continue
+ }
+ if ping.authorID == identity.currentID {
+ syncMonitor.note("ping(\(ping.kind.rawValue)): skipped self-authored record \(ping.recordName)")
+ continue
+ }
+ // A directed ping addressed to us is consumed by this account:
+ // once handled — shown, suppressed, or a duplicate — delete it so
+ // it stops re-notifying and the deletion withdraws any copy our
+ // sibling devices showed. Broadcast pings are left as-is.
+ let consume = ping.addressee != nil && ping.kind != .invite
+ func consumeIfDirected() async {
+ guard consume else { return }
+ await syncEngine.deletePing(recordName: ping.recordName, gameID: ping.gameID)
+ }
+ if NotificationState.isSuppressed(gameID: ping.gameID) {
+ syncMonitor.note("ping(\(ping.kind.rawValue)): suppressed — puzzle is active for \(ping.gameID.uuidString)")
+ await consumeIfDirected()
+ continue
+ }
+
+ let content = UNMutableNotificationContent()
+ content.title = "Crossmate"
+ content.body = Self.bodyText(for: ping)
+ content.sound = .default
+ content.userInfo = [
+ "gameID": ping.gameID.uuidString,
+ "pingKind": ping.kind.rawValue
+ ]
+
+ let request = UNNotificationRequest(
+ identifier: "ping-\(ping.gameID.uuidString)-\(UUID().uuidString)",
+ content: content,
+ trigger: nil
+ )
+ do {
+ try await center.add(request)
+ syncMonitor.note("ping(\(ping.kind.rawValue)): queued local notification for \(ping.gameID.uuidString)")
+ await consumeIfDirected()
+ } catch {
+ syncMonitor.note("ping(\(ping.kind.rawValue)): local notification failed — \(error.localizedDescription)")
+ }
+ }
+ }
+
+ private func claimPingsForHandling(_ pings: [Ping]) -> [Ping] {
+ var unclaimed: [Ping] = []
+ for ping in pings {
+ guard claimedPingRecordNames.insert(ping.recordName).inserted else {
+ syncMonitor.note("ping(\(ping.kind.rawValue)): already-handled record \(ping.recordName)")
+ continue
+ }
+ claimedPingRecordNameOrder.append(ping.recordName)
+ unclaimed.append(ping)
+ }
+ if claimedPingRecordNameOrder.count > claimedPingRecordNameCap {
+ let overflow = claimedPingRecordNameOrder.count - claimedPingRecordNameCap
+ for recordName in claimedPingRecordNameOrder.prefix(overflow) {
+ claimedPingRecordNames.remove(recordName)
+ }
+ claimedPingRecordNameOrder.removeFirst(overflow)
+ }
+ return unclaimed
+ }
+
+ private func canPresentNotifications() async -> Bool {
+ let center = UNUserNotificationCenter.current()
+ let settings = await center.notificationSettings()
+ switch settings.authorizationStatus {
+ case .authorized, .provisional, .ephemeral:
+ return true
+ default:
+ return false
+ }
+ }
+
+ nonisolated static func bodyText(for ping: Ping) -> String {
+ let puzzleSuffix = ping.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(ping.puzzleTitle)'"
+ switch ping.kind {
+ case .invite:
+ let player = ping.playerName.isEmpty ? "A player" : ping.playerName
+ return "\(player) invited you to \(puzzleSuffix)"
+ case .friend, .join, .hail:
+ // System-only kinds handled by the friendship-bootstrap /
+ // engagement paths; never presented as a notification. If this
+ // text surfaces in a log or alert, `presentPings` dispatch has
+ // broken.
+ return "system-only ping should not be presented"
+ }
+ }
+}
+
+private extension Array {
+ /// Splits the collection into `(matched, rejected)` in one pass.
+ func partitioned(by predicate: (Element) -> Bool) -> ([Element], [Element]) {
+ var matched: [Element] = []
+ var rejected: [Element] = []
+ for element in self {
+ if predicate(element) {
+ matched.append(element)
+ } else {
+ rejected.append(element)
+ }
+ }
+ return (matched, rejected)
+ }
+}
diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift
@@ -29,7 +29,7 @@ struct PuzzleNotificationTextTests {
addressee: nil
)
- #expect(AppServices.bodyText(for: ping) == "system-only ping should not be presented")
+ #expect(InviteCoordinator.bodyText(for: ping) == "system-only ping should not be presented")
}
@Test("pauseBody combines fills and clears counts when both are non-zero")
@@ -148,6 +148,6 @@ struct PuzzleNotificationTextTests {
addressee: "bob"
)
- #expect(AppServices.bodyText(for: ping) == "Alice invited you to the puzzle 'Saturday Puzzle – 1 January 2001'")
+ #expect(InviteCoordinator.bodyText(for: ping) == "Alice invited you to the puzzle 'Saturday Puzzle – 1 January 2001'")
}
}
diff --git a/Tests/Unit/Sync/FriendModelTests.swift b/Tests/Unit/Sync/FriendModelTests.swift
@@ -108,7 +108,7 @@ struct FriendModelTests {
addressee: "_me"
)
- let stale = AppServices.staleInviteRecordNames(
+ let stale = InviteCoordinator.staleInviteRecordNames(
among: [ping],
in: ctx,
currentAuthorID: "_me"
@@ -133,7 +133,7 @@ struct FriendModelTests {
addressee: "_me"
)
- let stale = AppServices.staleInviteRecordNames(
+ let stale = InviteCoordinator.staleInviteRecordNames(
among: [ping],
in: ctx,
currentAuthorID: "_me"