crossmate

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

commit 6c25968e297c75234550735019ae7699851fab3a
parent 7a41b6bbe1f700d10db63d9f6d62f6ed66e299d9
Author: Michael Camilleri <[email protected]>
Date:   Wed, 10 Jun 2026 17:11:59 +0900

Split AppServices into composed session/engagement/badge services

AppServices was a composition of root, session lifecycle, push
publisher, engagement reconciler, badge coordinator and invite manager
in one @MainActor class. This commit extracts the three top areas by
payoff into composed services, moving code verbatim with method names
unchanged so cross-referencing comments stay valid:

- SessionCoordinator: begin/end grace timers and their background
  assertions, the play/pause/win/resign/replay pushes with
  SessionAnnouncementLog, pushPlan, and the puzzle-open/left +
  catch-up-banner lifecycle. PushRecipient moves to
  SessionPushPlanner.swift as a top-level type.

- EngagementLifecycle: reconcileEngagement and room minting, the
  teardown/reconnect/lease-expiry timers, channel event/message
  handling, and ownership of the EngagementCoordinator. AppServices
  keeps the host/status/store objects (shared with PlayerRoster and the
  selection publisher) and injects isAppForeground, read-lease renewal,
  and ensureICloudSyncStarted as closures.

- BadgeCoordinator: refreshAppBadge, dismissDeliveredNotifications, the
  ledger reconcile, and the notification log helpers, with
  readLeaseDuration and the account-seen fan-out injected.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 12++++++++++++
MCrossmate/CrossmateApp.swift | 44++++++++++++++++++++++----------------------
MCrossmate/Services/AppServices.swift | 1173+++++--------------------------------------------------------------------------
ACrossmate/Services/BadgeCoordinator.swift | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Services/EngagementLifecycle.swift | 377+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Services/SessionCoordinator.swift | 610++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/SessionPushPlanner.swift | 30+++++++++++++++++++++++++++++-
MTests/Unit/SessionPushPlannerTests.swift | 4++--
MTests/Unit/Sync/AppServicesAnnouncementTests.swift | 10+++++-----
9 files changed, 1340 insertions(+), 1137 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; }; 1016604FBD4D63A0B9AAE503 /* CloudQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */; }; 128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */; }; + 15768439C1783A1780FBB824 /* SessionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7BD8DFDB41BFBA694A0933 /* SessionCoordinator.swift */; }; 17A754692F05B97DBDD645F2 /* PlayerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */; }; 18D5BB584DBF92A2EC580AEA /* NotificationNavigationBrokerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */; }; 197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */; }; @@ -132,6 +133,7 @@ CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; }; D13ECFAE05DB508577D2FF66 /* RecordBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */; }; D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */; }; + D240BF6498A9148855DB7734 /* EngagementLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB0580C9B7C778F34BE6AC2 /* EngagementLifecycle.swift */; }; D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */; }; D519F54F8CE0BD53D9C6144C /* AppServicesAnnouncementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9998739ED0875A17271B7899 /* AppServicesAnnouncementTests.swift */; }; D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; }; @@ -147,6 +149,7 @@ E454051BA4797000C8AD2B48 /* MovesCodecLegacyDecodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B33C21324E1474BCC126AA0 /* MovesCodecLegacyDecodeTests.swift */; }; E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */; }; E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */; }; + EB6E99226D5EE27668787008 /* BadgeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5B1E8E12B86DF6CA478F65 /* BadgeCoordinator.swift */; }; ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */; }; ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */; }; F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */; }; @@ -217,6 +220,7 @@ 27ECEA51DE42D07495744EF8 /* JournalReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalReplay.swift; sourceTree = "<group>"; }; 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; }; 2D2FD896D75863554E31654C /* NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationState.swift; sourceTree = "<group>"; }; + 2D5B1E8E12B86DF6CA478F65 /* BadgeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeCoordinator.swift; sourceTree = "<group>"; }; 2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalUploadTests.swift; sourceTree = "<group>"; }; 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnreadMovesTests.swift; sourceTree = "<group>"; }; 3292748EAE27B608C769D393 /* PlayerRoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRoster.swift; sourceTree = "<group>"; }; @@ -235,6 +239,7 @@ 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStateTests.swift; sourceTree = "<group>"; }; 4AF633D73818BD59F759FAC4 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; }; 4B33C21324E1474BCC126AA0 /* MovesCodecLegacyDecodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesCodecLegacyDecodeTests.swift; sourceTree = "<group>"; }; + 4DB0580C9B7C778F34BE6AC2 /* EngagementLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementLifecycle.swift; sourceTree = "<group>"; }; 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; }; 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDAcceptTests.swift; sourceTree = "<group>"; }; 507B4DC893CE8AC4778CBACE /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; @@ -262,6 +267,7 @@ 78C92190C4A344EC319A0F88 /* MovesJournalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesJournalTests.swift; sourceTree = "<group>"; }; 7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMovesSnapshot.swift; sourceTree = "<group>"; }; 7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendZone.swift; sourceTree = "<group>"; }; + 7A7BD8DFDB41BFBA694A0933 /* SessionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCoordinator.swift; sourceTree = "<group>"; }; 7B3E1A382B24A7803701D947 /* Crossmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Crossmate.entitlements; sourceTree = "<group>"; }; 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; }; 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdater.swift; sourceTree = "<group>"; }; @@ -593,11 +599,13 @@ children = ( 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */, CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */, + 2D5B1E8E12B86DF6CA478F65 /* BadgeCoordinator.swift */, 56BC76178319D0D669CD50FF /* CloudService.swift */, 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */, 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */, 18C701DAE36000DE19F7CC95 /* EngagementHost.swift */, 400B3C87248F3FCA3F76400B /* EngagementHostEnvironment.swift */, + 4DB0580C9B7C778F34BE6AC2 /* EngagementLifecycle.swift */, 462CE0FD356F6137C9BFD30F /* ImportService.swift */, 6BDD06460A76D4AF31077732 /* InputMonitor.swift */, 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */, @@ -608,6 +616,7 @@ 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */, 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */, B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */, + 7A7BD8DFDB41BFBA694A0933 /* SessionCoordinator.swift */, CA49DA9E0ADEB11A920787FA /* SessionPushPlanner.swift */, ); path = Services; @@ -807,6 +816,7 @@ 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */, A65F99414F8CF6704567BB07 /* Archive.swift in Sources */, 197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */, + EB6E99226D5EE27668787008 /* BadgeCoordinator.swift in Sources */, AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */, C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */, 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */, @@ -826,6 +836,7 @@ CABF8BFAA30B9F26C482FAB9 /* EngagementCoordinator.swift in Sources */, 267ED5B329F05A30430B73A0 /* EngagementHost.swift in Sources */, A7FA870D794CA00F7F3F05D2 /* EngagementHostEnvironment.swift in Sources */, + D240BF6498A9148855DB7734 /* EngagementLifecycle.swift in Sources */, 06AE6DF7AA3274480C591E47 /* EngagementStore.swift in Sources */, 4C874B69A9D9187490BC2C42 /* FriendAvatarView.swift in Sources */, C8ACF431021E7BEE61A99153 /* FriendController.swift in Sources */, @@ -882,6 +893,7 @@ D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */, CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */, E1FBC33E3348547D4DF946C4 /* ReplayControls.swift in Sources */, + 15768439C1783A1780FBB824 /* SessionCoordinator.swift in Sources */, 5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */, B48DE7079BE2F31D2367C5F7 /* SessionPushPlanner.swift in Sources */, 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -55,7 +55,7 @@ struct CrossmateApp: App { await services.blockFriend(authorID: friendAuthorID) }) .environment(\.sendResignPings, { gameID in - await services.sendCompletionPings(gameID: gameID, resigned: true) + await services.sessions.sendCompletionPings(gameID: gameID, resigned: true) }) } } @@ -467,14 +467,14 @@ private struct PuzzleDisplayView: View { if changed { if notifyPeers { Task { - await services.sendCompletionPings(gameID: gameID, resigned: false) + await services.sessions.sendCompletionPings(gameID: gameID, resigned: false) } } } // The game is done — drop the other player's cursor // and tear the live room down. Idempotent, so the // repeated observed/on-appear completions are safe. - Task { await services.endEngagement(gameID: gameID) } + Task { await services.engagement.endEngagement(gameID: gameID) } } catch { services.announcements.post(Announcement( id: "mark-completed-error-\(gameID.uuidString)", @@ -489,7 +489,7 @@ private struct PuzzleDisplayView: View { onResign: { try store.resignGame(id: gameID) Task { - await services.sendCompletionPings(gameID: gameID, resigned: true) + await services.sessions.sendCompletionPings(gameID: gameID, resigned: true) } }, onDelete: { try store.deleteGame(id: gameID) }, @@ -562,7 +562,7 @@ private struct PuzzleDisplayView: View { loadError = nil loadingMessage = "Loading puzzle..." updateActiveNotificationPuzzleID(for: scenePhase) - Task { await services.dismissDeliveredNotifications(for: gameID) } + Task { await services.badge.dismissDeliveredNotifications(for: gameID) } // Tapping an `.invite` ping notification navigates here at once, // before this game's `GameEntity` exists locally. Only for that @@ -675,7 +675,7 @@ private struct PuzzleDisplayView: View { // were away never comes back on its own. Re-offer on // resume; this is a no-op when the channel is still live // (the coordinator only acts from an idle state). - await services.startEngagementIfPossible(gameID: id) + await services.engagement.startEngagementIfPossible(gameID: id) } case .background: // Stop the engagement reconnect loop so it doesn't keep @@ -683,7 +683,7 @@ private struct PuzzleDisplayView: View { // (Re-leasing `readAt` in the background is now prevented // centrally by publishReadCursor's foreground gate, not here.) // `.active` re-arms the loop via `startEngagementIfPossible`. - services.cancelEngagementReconnectRetry(gameID: id) + services.engagement.cancelEngagementReconnectRetry(gameID: id) Task { await services.publishReadCursor(for: id, mode: .currentTime) } case .inactive: break @@ -700,11 +700,11 @@ private struct PuzzleDisplayView: View { let id = gameID // Navigating away is a leave: commit the catch-up baseline and drop // any pending banner timer (idempotent with the .background path). - services.handlePuzzleLeft(gameID: id) - services.scheduleEngagementEnd(gameID: id) + services.sessions.handlePuzzleLeft(gameID: id) + services.engagement.scheduleEngagementEnd(gameID: id) // A visit that never outlasted the begin grace was never announced // to peers, so there's no play session to pause on the way out. - let neverAnnounced = services.cancelPendingSessionBeginPush(gameID: id) + let neverAnnounced = services.sessions.cancelPendingSessionBeginPush(gameID: id) Task { await movesUpdater.flush() // The clear-cursor and close-lease writes both enqueue without @@ -717,7 +717,7 @@ private struct PuzzleDisplayView: View { // skip-if-zero guard inside publishSessionEndPush keeps the // close-after-background case from firing a second push. if !neverAnnounced { - await services.publishSessionEndPush(gameID: id) + await services.sessions.publishSessionEndPush(gameID: id) } } } @@ -768,32 +768,32 @@ private struct PuzzleDisplayView: View { // push — peers should see one continuous session, not a // pause/play pair, for a brief absence (phone sleep, a call, // app-switcher peek that escalated to background). - let resumed = services.cancelPendingSessionEndPush(gameID: id) + let resumed = services.sessions.cancelPendingSessionEndPush(gameID: id) // Schedules the catch-up banner after a short settle (open or // resume); the matching commit happens on leave, below. - services.handlePuzzleOpened(gameID: id) + services.sessions.handlePuzzleOpened(gameID: id) if !resumed { // Defer the play push so a quick pop-in/pop-out reaches no one; // backing out before the grace elapses cancels it below. - services.scheduleSessionBeginPush( + services.sessions.scheduleSessionBeginPush( gameID: id, - after: AppServices.sessionBeginGrace + after: SessionCoordinator.sessionBeginGrace ) } case .background: NotificationState.clearActivePuzzleID(if: gameID) // Leaving the puzzle: commit the catch-up baseline (the user has // seen what's on screen) and drop a pending banner timer. - services.handlePuzzleLeft(gameID: id) + services.sessions.handlePuzzleLeft(gameID: id) // A session still inside its begin grace was never announced, so // there's nothing to pause — drop the deferred play push and skip // the matching pause rather than firing an unpaired one. - if services.cancelPendingSessionBeginPush(gameID: id) { + if services.sessions.cancelPendingSessionBeginPush(gameID: id) { break } - services.scheduleSessionEndPush( + services.sessions.scheduleSessionEndPush( gameID: id, - after: AppServices.sessionEndGrace + after: SessionCoordinator.sessionEndGrace ) case .inactive: // Transient (lock animation, app switcher, Control Center, @@ -846,7 +846,7 @@ private struct PuzzleDisplayView: View { ) if let track = session.currentCursorTrack { await selectionPublisher.publishImmediately(track) - await services.noteLocalSelection(track, gameID: gameID) + await services.engagement.noteLocalSelection(track, gameID: gameID) } if let burstScope { await syncEngine.endPlayerSendBurst(scope: burstScope) @@ -854,13 +854,13 @@ private struct PuzzleDisplayView: View { // Register this device under the (now-minted) address set so the // just-opened game can deliver pushes here immediately. await services.reconcilePushRegistration() - await services.startEngagementIfPossible(gameID: gameID) + await services.engagement.startEngagementIfPossible(gameID: gameID) let services = self.services let eventGameID = gameID session.onSelectionChanged = { selection in Task { await selectionPublisher.publish(selection) - await services.noteLocalSelection(selection, gameID: eventGameID) + await services.engagement.noteLocalSelection(selection, gameID: eventGameID) } } // check/reveal no longer ping peers; cell state propagates through diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -24,19 +24,6 @@ final class AppServices { private static let readLeaseDuration: TimeInterval = 10 * 60 private static let readLeaseRefreshFloor: TimeInterval = 5 * 60 - private static let engagementTeardownDelaySeconds = 120 - private static let engagementTeardownDelay: Duration = .seconds(engagementTeardownDelaySeconds) - /// How often a foregrounded shared puzzle re-runs the engagement reconnect - /// check. Engagement auto-connect is otherwise edge-triggered (channel - /// close, foreground, a peer's cursor move), so a drop whose re-connect - /// edge never lands — a failed connect, a `readAt`-only lease refresh, an - /// edge lost to suspension — stays disconnected until the user nudges it - /// manually. This timer is the backstop. It re-runs `peerPresenceMayHave\ - /// Changed`, which is a no-op unless the coordinator is idle *and* a peer - /// holds a live lease, so a steady-state live (or peerless) session does - /// no work and writes nothing; the coordinator's connecting-state guard - /// caps any actual re-hail rate. - private static let engagementReconnectInterval: Duration = .seconds(30) private static let accountPushAddressDefaultsPrefix = "push.accountAddress." private static let accountPushSecretDefaultsPrefix = "push.accountSecret." /// Generation of the locally-held push secret. Bumped on a deliberate @@ -77,34 +64,9 @@ final class AppServices { let playerSelectionPublisher: PlayerSelectionPublisher let identity: AuthorIdentity let pushClient: PushClient? - /// Grace window before opening a puzzle is announced to peers as a play - /// session. A user who pops in and out — opens the wrong puzzle, peeks at - /// a shared grid, then leaves — within this window should fan out nothing. - /// The begin push is deferred by this much and cancelled if the user backs - /// out (app backgrounded or navigated away) before it elapses. - static let sessionBeginGrace: TimeInterval = 10 - private var pendingSessionBeginTasks: [UUID: Task<Void, Never>] = [:] - /// Grace window before a backgrounded session is treated as ended. A - /// briefly-backgrounded puzzle (phone sleep, app switcher peek, taking a - /// call) should not fan out pause/play pings to peers on every flicker — - /// only a sustained absence does. - static let sessionEndGrace: TimeInterval = 30 - private var pendingSessionEndTasks: [UUID: Task<Void, Never>] = [:] - /// Settle delay before the catch-up banner is computed on open. Lets the - /// `.appeared` grid freshen land peer moves first, so the diff reflects the - /// settled grid rather than a half-synced snapshot; cancelled if the user - /// leaves before it elapses. - static let sessionSummaryBannerDelay: TimeInterval = 3 - private var pendingSessionSummaryBannerTasks: [UUID: Task<Void, Never>] = [:] - /// Background-execution assertions keeping the matching grace timer alive - /// after the app is backgrounded. iOS grants only a limited budget (often - /// well under `sessionEndGrace`), so the assertion's expiration handler - /// fires the pause early rather than letting suspension drop it. Keyed by - /// game so a per-game timer owns exactly one assertion. - private var sessionEndBackgroundTasks: [UUID: UIBackgroundTaskIdentifier] = [:] - /// Per-game "session announced to peers" state machine driving the - /// once-per-session begin push; see `SessionAnnouncementLog`. - private var sessionAnnouncements = SessionAnnouncementLog() + /// Per-game play-session lifecycle: begin/end grace timers, sender-side + /// session pushes, and the catch-up banner. See `SessionCoordinator`. + let sessions: SessionCoordinator let shareController: ShareController let friendController: FriendController let gameArchiver: GameArchiver @@ -114,13 +76,35 @@ final class AppServices { let importService: ImportService let engagementHost: EngagementHost let engagementStatus = EngagementStatus() - private lazy var engagementCoordinator = EngagementCoordinator( - host: engagementHost, - localAuthorID: { [weak self] in - await MainActor.run { self?.identity.currentID } + /// Live-channel lifecycle (room reconcile/mint, teardown/reconnect/ + /// lease-expiry timers, inbound channel events); see `EngagementLifecycle`. + /// Lazy so its callbacks into the read-cursor and sync-start paths can + /// capture `self`. + private(set) lazy var engagement = EngagementLifecycle( + preferences: preferences, + persistence: persistence, + store: store, + identity: identity, + syncMonitor: syncMonitor, + engagementHost: engagementHost, + engagementStatus: engagementStatus, + engagementStore: engagementStore, + isAppForeground: { [weak self] in self?.isAppForeground ?? false }, + renewReadLease: { [weak self] gameID in + await self?.publishReadCursor(for: gameID, mode: .activeLease) }, - log: { [weak self] message in - await MainActor.run { self?.syncMonitor.note(message) } + ensureICloudSyncStarted: { [weak self] in + await self?.ensureICloudSyncStarted() ?? false + } + ) + /// App-icon badge + delivered-notification reconciliation; see + /// `BadgeCoordinator`. Lazy so the account-seen fan-out can capture `self`. + private(set) lazy var badge = BadgeCoordinator( + store: store, + syncMonitor: syncMonitor, + readLeaseDuration: Self.readLeaseDuration, + publishAccountSeenPush: { [weak self] gameID, readAt in + await self?.publishAccountSeenPush(gameID: gameID, readAt: readAt) } ) @@ -183,10 +167,6 @@ final class AppServices { /// `true` because the app launches into the foreground and `.onChange` does /// not fire for the initial phase. private(set) var isAppForeground = true - private var latestLocalSelections: [UUID: PlayerSelection] = [:] - private var scheduledEngagementEndTasks: [UUID: Task<Void, Never>] = [:] - private var engagementReconnectTasks: [UUID: Task<Void, Never>] = [:] - private var engagementLeaseExpiryTasks: [UUID: Task<Void, Never>] = [:] init() { let preferences = PlayerPreferences() @@ -287,10 +267,23 @@ final class AppServices { ) self.store = store - self.sessionMonitor = SessionMonitor( + let sessionMonitor = SessionMonitor( store: store, localAuthorIDProvider: { identity.currentID } ) + self.sessionMonitor = sessionMonitor + + self.sessions = SessionCoordinator( + persistence: persistence, + store: store, + syncEngine: syncEngine, + syncMonitor: self.syncMonitor, + sessionMonitor: sessionMonitor, + announcements: self.announcements, + identity: identity, + preferences: preferences, + pushClient: self.pushClient + ) self.shareController = ShareController( container: self.ckContainer, @@ -348,17 +341,13 @@ final class AppServices { self.importService = ImportService(store: store, driveMonitor: self.driveMonitor) self.engagementHost = EngagementHost() self.engagementHost.onEvent = { [weak self] event in - self?.handleEngagementEvent(event) + self?.engagement.handleEngagementEvent(event) } self.store.onLocalCellEdit = { [weak self] edit in - guard let self else { return } - guard self.engagementStatus.isLive(gameID: edit.gameID) else { return } - Task { await self.engagementCoordinator.sendCellEdit(edit) } + self?.engagement.sendLocalCellEdit(edit) } self.store.onLocalCellEditBatch = { [weak self] edits in - guard let self, let gameID = edits.first?.gameID else { return } - guard self.engagementStatus.isLive(gameID: gameID) else { return } - Task { await self.engagementCoordinator.sendCellEdits(edits) } + self?.engagement.sendLocalCellEdits(edits) } self.store.onJournalComplete = { [weak self] gameID, authorID in self?.beginCompletionJournalUpload(gameID: gameID, authorID: authorID) @@ -380,11 +369,11 @@ final class AppServices { store.onUnreadOtherMovesChanged = { [weak self] in guard let self else { return } - Task { await self.refreshAppBadge() } + Task { await self.badge.refreshAppBadge() } } importVisibleNotificationReceipts() - await refreshAppBadge() - await logNotificationStartupSnapshot() + await badge.refreshAppBadge() + await badge.logNotificationStartupSnapshot() appDelegate.onRemoteNotification = { summary, scope, event, gameID, kind, senderDeviceID, readAt, isBackground in @@ -467,7 +456,7 @@ final class AppServices { await self?.playerSelectionPublisher.peerPresenceMayHaveChanged(gameIDs: gameIDs) guard let self else { return } for gameID in gameIDs { - await self.reconcileEngagement(gameID: gameID) + await self.engagement.reconcileEngagement(gameID: gameID) } } @@ -477,7 +466,7 @@ final class AppServices { await syncEngine.setOnRemoteEngagementChanged { [weak self] gameIDs in guard let self else { return } for gameID in gameIDs { - await self.reconcileEngagement(gameID: gameID) + await self.engagement.reconcileEngagement(gameID: gameID) } } @@ -505,7 +494,7 @@ final class AppServices { sessionMonitor.applyMovesBaseline(map, for: gameID) } if readAt > now { - await self?.dismissDeliveredNotifications( + await self?.badge.dismissDeliveredNotifications( for: gameID, seenAt: readAt, publishAccountSeen: false @@ -617,7 +606,7 @@ final class AppServices { // The local row is gone, so drop its badge ledger entry: a seen // horizon can't clear it once there's no game left to open. BadgeState.forget(gameID: gameID) - await self?.refreshAppBadge() + await self?.badge.refreshAppBadge() // A hard-deleted game (private zone gone, or a shared game left // elsewhere) only needs UI when its puzzle is on screen: a sticky, // input-blocking banner freezes the now-orphaned puzzle until the @@ -637,7 +626,7 @@ final class AppServices { do { try self.removePendingInvite(forGameID: gameID) // The pending invite (if any) is gone; drop it from the badge. - await self.refreshAppBadge() + await self.badge.refreshAppBadge() } catch { self.announcements.post(Announcement( id: "remove-pending-invite-error-\(gameID.uuidString)", @@ -661,9 +650,9 @@ final class AppServices { await syncEngine.setOnPingDeleted { [weak self] pings in guard let self else { return } try? self.removePendingInvites(forPingRecordNames: Set(pings.map { $0.recordName })) - await self.refreshAppBadge() + await self.badge.refreshAppBadge() for gameID in Set(pings.map { $0.gameID }) { - await self.dismissDeliveredNotifications( + await self.badge.dismissDeliveredNotifications( for: gameID, publishAccountSeen: false ) @@ -805,15 +794,6 @@ final class AppServices { } } - /// Completion fan-out, delivered through the push worker. Win sets - /// `completedAt`/`completedBy` on the local Game record; resign leaves - /// `completedBy` nil and reveals the remaining cells through the Moves - /// stream (peers' grids fill in once the Moves push lands). - func sendCompletionPings(gameID: UUID, resigned: Bool) async { - await publishCompletionPush(gameID: gameID, resigned: resigned) - await publishReplayPush(gameID: gameID) - } - /// Ensures this device is registered with the push worker under the /// account's per-game push address for every shared game, minting and /// publishing any address that doesn't exist yet so peers learn where to @@ -1011,714 +991,6 @@ final class AppServices { ) } - /// Defer the session-begin push by `seconds`, replacing any pending timer - /// for the same game. The opening device owns the notification timing, but - /// a brief visit shouldn't reach peers at all, so the "Alice is solving X" - /// push waits out `sessionBeginGrace`; backing out before it elapses (app - /// backgrounded, or the puzzle view disappearing) calls - /// `cancelPendingSessionBeginPush` to drop it silently. Unlike the pause - /// timer this holds no background assertion: the user is foregrounded and - /// looking at the puzzle for the whole window, and any departure cancels. - func scheduleSessionBeginPush(gameID: UUID, after seconds: TimeInterval) { - pendingSessionBeginTasks.removeValue(forKey: gameID)?.cancel() - pendingSessionBeginTasks[gameID] = Task { [weak self] in - try? await Task.sleep(for: .seconds(seconds)) - guard !Task.isCancelled, let self else { return } - self.pendingSessionBeginTasks.removeValue(forKey: gameID) - await self.publishSessionBeginPush(gameID: gameID) - } - } - - /// Drop a begin push still waiting out its grace window. Returns whether - /// one was pending — i.e. the session was never announced to peers — so - /// the caller can skip the matching pause push (there's no play to pause). - @discardableResult - func cancelPendingSessionBeginPush(gameID: UUID) -> Bool { - guard let task = pendingSessionBeginTasks.removeValue(forKey: gameID) else { - return false - } - task.cancel() - return true - } - - /// Sender-side session-begin push. Peers get "Alice is solving X" once the - /// open has outlasted `sessionBeginGrace` (see `scheduleSessionBeginPush`). - /// Kind is `play` because the user-facing event is "started playing", not - /// the durable share-acceptance fact "joined". - func publishSessionBeginPush(gameID: UUID) async { - guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { - syncMonitor.note("push(play): skipped (no authorID)") - return - } - // Already announced this session and no stop has been sent since, so - // we're inside the same continuous session (e.g. a brief background - // bounce). Re-announcing "is solving" here is the notification spam - // we're suppressing — the next genuine stop clears this. - guard sessionAnnouncements.shouldAnnounceBegin(gameID) else { - syncMonitor.note("push(play): skipped (session already announced)") - return - } - guard let pushClient else { - syncMonitor.note("push(play): skipped (no pushClient)") - return - } - let plan = pushPlan(for: gameID, excluding: localAuthorID) - guard !plan.recipients.isEmpty else { - syncMonitor.note("push(play): skipped (no recipients)") - return - } - // Opening a finished puzzle (review, share view) isn't a play - // session — peers don't need a "solving the puzzle" alert. Same for - // a shared game we no longer have access to: the owner already - // unshared or deleted it, and the worker would just bounce the push. - guard plan.completedAt == nil else { - syncMonitor.note("push(play): skipped (game completed)") - return - } - guard !plan.isAccessRevoked else { - syncMonitor.note("push(play): skipped (access revoked)") - return - } - let addressees = plan.recipients.compactMap { recipient in - recipient.pushAddress.map { - PushClient.Addressee(address: $0, payload: PushPayload(event: .play)) - } - } - guard !addressees.isEmpty else { - syncMonitor.note("push(play): skipped (no addressable recipients)") - return - } - let playerName = preferences.name.isEmpty ? "A player" : preferences.name - let puzzleSuffix = plan.title.isEmpty ? "the puzzle" : "the puzzle '\(plan.title)'" - await pushClient.publish( - kind: "play", - gameID: gameID, - addressees: addressees, - title: "Crossmate", - body: "\(playerName) is solving \(puzzleSuffix)" - ) - // Session is now announced to peers; a "play" won't fire again until a - // "pause" is actually sent (see `publishSessionEndPush`). - sessionAnnouncements.noteBeginAnnounced(gameID) - } - - /// Defer the session-end push by `seconds`. Cancels any previously - /// scheduled pause for the same game. If the user resumes within the - /// grace window, call `cancelPendingSessionEndPush` to drop the timer - /// and skip the matching session-begin push so peers don't get a - /// pause/play pair for a brief absence. The wall-clock at scheduling - /// time is passed through so the fire-time peer-device-active check has - /// a stable reference point for "did anyone other than me write to - /// Player during the grace window." - func scheduleSessionEndPush(gameID: UUID, after seconds: TimeInterval) { - let pauseStart = Date() - cancelPendingSessionEndPush(gameID: gameID) - // Hold a background-execution assertion so the grace timer keeps - // running once the app is backgrounded. If iOS is about to reclaim - // us before the timer elapses, the expiration handler fires the - // pause early (best effort) instead of letting suspension drop it. - sessionEndBackgroundTasks[gameID] = UIApplication.shared.beginBackgroundTask( - withName: "session-end-\(gameID.uuidString)" - ) { [weak self] in - self?.fireSessionEndPush(gameID: gameID, pauseStart: pauseStart, expedited: true) - } - pendingSessionEndTasks[gameID] = Task { [weak self] in - try? await Task.sleep(for: .seconds(seconds)) - guard !Task.isCancelled else { return } - self?.fireSessionEndPush(gameID: gameID, pauseStart: pauseStart, expedited: false) - } - } - - /// Single fire path for the deferred session-end push, shared by the grace - /// timer and the background-assertion expiration handler. The pending-task - /// entry doubles as a "not yet fired" flag, so this is idempotent: whichever - /// caller wins removes it, and the loser falls through to releasing the - /// assertion only. `expedited` marks the early fire forced by an imminent - /// suspension, purely for diagnostics. - private func fireSessionEndPush(gameID: UUID, pauseStart: Date, expedited: Bool) { - guard let task = pendingSessionEndTasks.removeValue(forKey: gameID) else { - endSessionEndBackgroundTask(gameID: gameID) - return - } - task.cancel() - if expedited { - syncMonitor.note("push(pause): firing early (background expiring)") - } - Task { [weak self] in - guard let self else { return } - await self.publishSessionEndPush(gameID: gameID, pauseStart: pauseStart) - self.endSessionEndBackgroundTask(gameID: gameID) - } - } - - /// Releases the background-execution assertion for `gameID`, if one is - /// held. Safe to call repeatedly — a missing entry is a no-op. - private func endSessionEndBackgroundTask(gameID: UUID) { - guard let id = sessionEndBackgroundTasks.removeValue(forKey: gameID), - id != .invalid else { return } - UIApplication.shared.endBackgroundTask(id) - } - - /// Cancel any pending scheduled session-end push for `gameID`. Returns - /// `true` if a pending task was dropped, i.e. the caller is inside the - /// grace window and should suppress the matching session-begin push. - @discardableResult - func cancelPendingSessionEndPush(gameID: UUID) -> Bool { - endSessionEndBackgroundTask(gameID: gameID) - guard let task = pendingSessionEndTasks.removeValue(forKey: gameID) else { - return false - } - task.cancel() - return true - } - - /// Sender-side session-end push. For each recipient, counts cells in - /// the author's merged-across-devices Moves whose `updatedAt` is newer - /// than that recipient's last-known `Player.readAt`, and ships a body - /// describing only what *that* recipient hasn't seen. Recipients whose - /// readAt already covers every author cell are dropped — they have - /// nothing unseen, so a banner-and-badge for them would be misleading. - /// - /// Suppresses the push when a peer device of this author wrote to - /// Player during the grace window — that device is still playing and - /// will publish its own pause when it stops. - func publishSessionEndPush(gameID: UUID, pauseStart: Date = Date()) async { - // A direct call (e.g. from `.onDisappear`) supersedes any pending - // grace-window timer for this game — drop it so we don't fire a - // second pause once the timer elapses. - pendingSessionEndTasks.removeValue(forKey: gameID)?.cancel() - guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { - syncMonitor.note("push(pause): skipped (no authorID)") - return - } - // During the grace window this device wrote nothing to Player - // (any local activity would have reset the timer via - // `cancelPendingSessionEndPush`). A Player `updatedAt` newer than - // pauseStart therefore came from another device of this author — - // that device is still active, so let its eventual pause cover - // the session. - if let updatedAt = store.playerUpdatedAt(for: gameID, by: localAuthorID), - updatedAt > pauseStart { - syncMonitor.note("push(pause): skipped (peer device active)") - return - } - guard let pushClient else { - syncMonitor.note("push(pause): skipped (no pushClient)") - return - } - let plan = pushPlan(for: gameID, excluding: localAuthorID) - guard !plan.recipients.isEmpty else { - syncMonitor.note("push(pause): skipped (no recipients)") - return - } - // Symmetric with `publishSessionBeginPush`: a finished or revoked - // game has no live play session, so a pause summary is meaningless. - guard plan.completedAt == nil else { - syncMonitor.note("push(pause): skipped (game completed)") - return - } - guard !plan.isAccessRevoked else { - syncMonitor.note("push(pause): skipped (access revoked)") - return - } - // The pause counts are derived from this device's own journal (gesture - // history), not the merged grid, so the summary can name fills/clears/ - // checks/reveals. The merged-grid measurements still ride the - // diagnostics block below for context. - let journalEntries = store.localJournalEntries(for: gameID) - // Sender-side diagnostics: store-derived measurements plus this - // device's clock and the session-start it announced. Rides the - // per-recipient payload (the planner stamps each recipient's readAt) - // so the receiver can log why the counts came out as they did. - var diagnostics = store.movesDiagnostics(for: gameID, by: localAuthorID) - ?? PushPayload.Diagnostics() - diagnostics.senderNow = Date() - diagnostics.sessionStart = sessionAnnouncements.beginTime(gameID) - // Caught-up recipients are *not* dropped: a session end is a presence - // signal worth delivering even with nothing unseen (see - // `SessionPushPlanner.sessionEndAddressees`). - let addressees = SessionPushPlanner.sessionEndAddressees( - recipients: plan.recipients, - journalEntries: journalEntries, - playerName: preferences.name, - puzzleTitle: plan.title, - diagnostics: diagnostics - ) - guard !addressees.isEmpty else { - syncMonitor.note("push(pause): skipped (no addressable recipients)") - return - } - // Top-level broadcast body is the worker's fallback if an addressee - // carries no per-recipient body. Under the new contract every - // addressee has one, but the field is still required. - let fallbackBody = PuzzleNotificationText.pauseBody( - playerName: preferences.name, - puzzleTitle: plan.title, - fills: 0, - clears: 0, - checks: 0, - reveals: 0 - ) - await pushClient.publish( - kind: "pause", - gameID: gameID, - addressees: addressees, - title: "Crossmate", - body: fallbackBody - ) - // Peers have now been told the session ended, so a fresh "play" is - // allowed again (see `publishSessionBeginPush`). - sessionAnnouncements.noteEndAnnounced(gameID) - // Advance each addressed recipient's notified-through watermark to the - // latest move this pause reported. A later pause windows its counts to - // the later of this and the recipient's readAt, so a bounce that adds - // no new move re-tallies to zero ("stopped solving") instead of - // repeating the same summary. Recipients we couldn't address (no push - // capability) keep their old watermark and catch up when reachable. - if let notifiedThrough = journalEntries.map(\.timestamp).max() { - let addressed = plan.recipients - .filter { $0.pushAddress != nil } - .map(\.authorID) - store.recordNotified(gameID: gameID, authorIDs: addressed, through: notifiedThrough) - } - } - - private func publishCompletionPush(gameID: UUID, resigned: Bool) async { - let kindLabel = resigned ? "resign" : "win" - guard let pushClient else { - syncMonitor.note("push(\(kindLabel)): skipped (no pushClient)") - return - } - guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { - syncMonitor.note("push(\(kindLabel)): skipped (no authorID)") - return - } - let plan = pushPlan(for: gameID, excluding: localAuthorID) - guard !plan.recipients.isEmpty else { - syncMonitor.note("push(\(kindLabel)): skipped (no recipients)") - return - } - let event: PushPayload.Event = resigned ? .resign : .win - let addressees = plan.recipients.compactMap { recipient in - recipient.pushAddress.map { - PushClient.Addressee(address: $0, payload: PushPayload(event: event)) - } - } - guard !addressees.isEmpty else { - syncMonitor.note("push(\(kindLabel)): skipped (no addressable recipients)") - return - } - let playerName = preferences.name.isEmpty ? "A player" : preferences.name - let puzzleSuffix = plan.title.isEmpty ? "the puzzle" : "the puzzle '\(plan.title)'" - let kind: String - let body: String - if resigned { - kind = "resign" - body = "\(playerName) resigned \(puzzleSuffix)." - } else { - kind = "win" - body = "\(playerName) solved \(puzzleSuffix)" - } - await pushClient.publish( - kind: kind, - gameID: gameID, - addressees: addressees, - title: "Crossmate", - body: body - ) - } - - private func publishReplayPush(gameID: UUID) async { - guard let pushClient else { - syncMonitor.note("push(replay): skipped (no pushClient)") - return - } - let plan = pushPlan(for: gameID) - guard !plan.recipients.isEmpty else { - syncMonitor.note("push(replay): skipped (no recipients)") - return - } - let addressees = plan.recipients.compactMap { recipient in - recipient.pushAddress.map { - PushClient.Addressee(address: $0, payload: PushPayload(event: .replay)) - } - } - guard !addressees.isEmpty else { - syncMonitor.note("push(replay): skipped (no addressable recipients)") - return - } - await pushClient.publish( - kind: "replay", - gameID: gameID, - addressees: addressees, - title: "", - body: "", - background: true - ) - } - - /// Everything a sender-side push helper needs to know about a game in - /// one Core Data round-trip: the roster authors to notify (each with the - /// last-known `Player.readAt` so the pause path can compute a - /// per-recipient diff), the puzzle's display title, and the gating flags - /// callers consult before emitting. - struct PushRecipient: Sendable, Equatable { - let authorID: String - /// The recipient's read watermark (`Player.readThrough`): the latest - /// other-author move time they've actually seen. The session-end tally - /// windows on this — never the forward-dated presence lease — so a peer - /// who was "present" (leased) but backgrounded before our moves still - /// gets a summary for what they missed. `nil` when they've recorded no - /// read yet, which tallies their whole backlog. - let readThrough: Date? - /// Sender-local watermark: the latest authored move we've already told - /// this recipient about via a previous pause. The session-end tally - /// windows on the later of this and `readAt`, so we never re-report a - /// move the recipient has already seen *or* already been notified of. - /// `nil` when we've never paused to them. Never synced — see - /// `PlayerEntity.notifiedThrough`. - let notifiedThrough: Date? - /// The recipient's per-(account, game) push capability, read off their - /// Player record. `nil` when they haven't published one yet (older - /// build, or not-yet-synced) — such a recipient can't be addressed and - /// is dropped from the push. - let pushAddress: String? - } - - private struct PushPlan { - let recipients: [PushRecipient] - let title: String - let completedAt: Date? - let isAccessRevoked: Bool - - static let empty = PushPlan( - recipients: [], - title: "", - completedAt: nil, - isAccessRevoked: false - ) - } - - private func pushPlan( - for gameID: UUID, - excluding authorID: String? = nil - ) -> PushPlan { - let ctx = persistence.container.newBackgroundContext() - return ctx.performAndWait { - 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 { return .empty } - var byAuthor: [String: (readThrough: Date?, notifiedThrough: Date?, pushAddress: String?)] = [:] - let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") - pReq.predicate = NSPredicate(format: "game == %@", game) - for p in (try? ctx.fetch(pReq)) ?? [] { - guard let a = p.authorID, - a != CKCurrentUserDefaultName, - !a.isEmpty - else { continue } - if let authorID, a == authorID { continue } - byAuthor[a] = (p.readThrough, p.notifiedThrough, p.pushAddress) - } - let recipients = byAuthor.map { - PushRecipient( - authorID: $0.key, - readThrough: $0.value.readThrough, - notifiedThrough: $0.value.notifiedThrough, - pushAddress: $0.value.pushAddress - ) - } - return PushPlan( - recipients: recipients, - title: PuzzleNotificationText.title(for: game), - completedAt: game.completedAt, - isAccessRevoked: game.isAccessRevoked - ) - } - } - - /// Drives one game's live connection toward the room its Game record - /// advertises. Reads the shared `engagement` creds and whether any peer - /// holds a live read lease; if a peer is present (or `force`) and no creds - /// exist yet, mints a room and writes it to the Game record (any present - /// participant may — record-level LWW converges concurrent mints). Then - /// hands the desired creds to the coordinator, which connects, migrates, or - /// tears down to match. `force` is the manual "start a room now" path, - /// which mints and connects without waiting to see a present peer. - func reconcileEngagement(gameID: UUID) async { - guard preferences.isICloudSyncEnabled else { return } - // A completed game is not a live session. Never connect (this is also - // the reopen-from-Completed path, which re-runs through - // `startEngagementIfPossible`) and tear down anything still up. - guard !store.isCompleted(gameID: gameID) else { - cancelEngagementLeaseExpiry(gameID: gameID) - await engagementCoordinator.reconcile(gameID: gameID, creds: nil, hasPeer: false) - return - } - // A live socket is a foreground-only affair — the same rule - // `publishReadCursor` enforces for the read lease. Inbound presence / - // engagement changes also arrive on background CKSyncEngine wakes - // (`onRemotePlayerPresenceChanged` / `onRemoteEngagementChanged`), and a - // background reconcile used to re-dial the socket on every wake. That is - // futile: the `.default` URLSession WebSocket can't survive suspension, - // so it just storms connect → abort → reconnect until the next push. - // When not foreground, leave the current connection (and the - // scheduled-end grace that rides out a transient `.inactive`) untouched - // and refuse to escalate; the foreground `.active` path re-reconciles - // via `startEngagementIfPossible`. Teardown still flows from socket - // abort, `scheduleEngagementEnd`, and the completed-game guard above. - guard isAppForeground else { - syncMonitor.note("engagement: reconcile skipped for \(gameID.uuidString): backgrounded") - return - } - let soonestLease = await Self.soonestPeerLease( - persistence: persistence, - gameID: gameID, - localAuthorID: identity.currentID - ) - let hasPeer = soonestLease != nil - var creds = EngagementRoomCredentials.decode(store.engagement(for: gameID)) - if creds == nil, hasPeer { - if let minted = try? EngagementRoomCredentials.fresh(), - let encoded = try? minted.encoded(), - store.setEngagement(encoded, for: gameID) { - creds = minted - syncMonitor.note( - "engagement: minted room \(minted.roomID.uuidString) for \(gameID.uuidString)" - ) - } - } - await engagementCoordinator.reconcile(gameID: gameID, creds: creds, hasPeer: hasPeer) - // When the soonest present peer drops out of presence, re-reconcile: - // tear down (dropping the bolt) if no renewal arrived, or reschedule - // onto the new horizon if one did. That instant is the lease plus the - // presence grace, not the bare lease — a peer stays present through the - // grace, so tearing down at the raw lease would race it. The 30s - // reconnect tick remains the coarse backstop. A no-peer reconcile - // has no lease to watch, so this just clears any prior wake. - scheduleEngagementLeaseExpiry( - gameID: gameID, - at: soonestLease?.addingTimeInterval(PeerPresence.presenceGrace) - ) - } - - func startEngagementIfPossible(gameID: UUID) async { - cancelScheduledEngagementEnd(gameID: gameID) - guard preferences.isICloudSyncEnabled else { return } - guard await ensureICloudSyncStarted() else { return } - await reconcileEngagement(gameID: gameID) - startEngagementReconnectRetry(gameID: gameID) - } - - func endEngagement(gameID: UUID) async { - cancelScheduledEngagementEnd(gameID: gameID) - cancelEngagementReconnectRetry(gameID: gameID) - cancelEngagementLeaseExpiry(gameID: gameID) - syncMonitor.note("engagement: ending for \(gameID.uuidString)") - engagementStatus.setLive(false, gameID: gameID) - latestLocalSelections[gameID] = nil - engagementStore.clear(gameID: gameID) - await engagementCoordinator.teardown(gameID: gameID) - } - - func scheduleEngagementEnd(gameID: UUID) { - cancelScheduledEngagementEnd(gameID: gameID) - // Leaving the puzzle stops the reconnect backstop regardless of - // whether a channel ever went live — otherwise a puzzle that was - // opened but never connected would tick forever. A quick return - // re-arms it via `startEngagementIfPossible`. - cancelEngagementReconnectRetry(gameID: gameID) - cancelEngagementLeaseExpiry(gameID: gameID) - guard engagementStatus.isLive(gameID: gameID) else { return } - syncMonitor.note( - "engagement: scheduled ending for \(gameID.uuidString) " + - "in \(Self.engagementTeardownDelaySeconds)s" - ) - scheduledEngagementEndTasks[gameID] = Task { [weak self] in - do { - try await Task.sleep(for: Self.engagementTeardownDelay) - } catch { - return - } - await self?.finishScheduledEngagementEnd(gameID: gameID) - } - } - - func cancelScheduledEngagementEnd(gameID: UUID) { - guard let task = scheduledEngagementEndTasks.removeValue(forKey: gameID) else { return } - task.cancel() - syncMonitor.note("engagement: cancelled scheduled ending for \(gameID.uuidString)") - } - - private func finishScheduledEngagementEnd(gameID: UUID) async { - guard scheduledEngagementEndTasks.removeValue(forKey: gameID) != nil else { return } - syncMonitor.note("engagement: scheduled ending fired for \(gameID.uuidString)") - await endEngagement(gameID: gameID) - } - - /// Arms the periodic engagement-presence tick for `gameID`, replacing any - /// prior timer for the same game (so a re-arm on foreground doesn't stack). - /// Each tick does two things: - /// - /// 1. Renews our own read lease (`publishReadCursor(.activeLease)`, which - /// is floor-gated so it writes at most ~once per 5 min). This is what - /// makes `readAt` a true "foregrounded on this puzzle" heartbeat: a - /// peer's own typing never advances their own lease, so without this an - /// active solo driver would lapse mid-session and look absent. - /// 2. Re-runs the coordinator's presence check, which both reconnects a - /// dropped channel and tears down a live channel whose peer's lease has - /// expired — neither of which is safe until (1) keeps present peers - /// from falsely lapsing. - /// - /// Both are no-ops in steady state (lease has >5 min left; coordinator is - /// live with a present peer). Cancelled on leave via `scheduleEngagement - /// End`/`endEngagement`; goes dormant under suspension (the sleep can't - /// advance) and is re-armed by the foreground `startEngagementIfPossible`. - private func startEngagementReconnectRetry(gameID: UUID) { - engagementReconnectTasks[gameID]?.cancel() - engagementReconnectTasks[gameID] = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: Self.engagementReconnectInterval) - guard !Task.isCancelled, let self else { return } - await self.publishReadCursor(for: gameID, mode: .activeLease) - await self.reconcileEngagement(gameID: gameID) - } - } - } - - func cancelEngagementReconnectRetry(gameID: UUID) { - guard let task = engagementReconnectTasks.removeValue(forKey: gameID) else { return } - task.cancel() - syncMonitor.note("engagement: reconnect backstop cancelled for \(gameID.uuidString)") - } - - /// Arms a one-shot reconcile at `expiry` — the soonest moment a present - /// peer's lease lapses — replacing any prior wake for this game. When it - /// fires, `reconcileEngagement` tears the channel down if no renewal landed - /// (so the bolt drops at expiry, not up to a tick later) or reschedules - /// onto the renewed horizon. `nil` (no-peer) just clears the wake. - private func scheduleEngagementLeaseExpiry(gameID: UUID, at expiry: Date?) { - engagementLeaseExpiryTasks[gameID]?.cancel() - engagementLeaseExpiryTasks[gameID] = nil - guard let expiry else { return } - let interval = max(0, expiry.timeIntervalSinceNow) - engagementLeaseExpiryTasks[gameID] = Task { [weak self] in - try? await Task.sleep(for: .seconds(interval)) - guard !Task.isCancelled, let self else { return } - await self.reconcileEngagement(gameID: gameID) - } - } - - private func cancelEngagementLeaseExpiry(gameID: UUID) { - guard let task = engagementLeaseExpiryTasks.removeValue(forKey: gameID) else { return } - task.cancel() - } - - func noteLocalSelection(_ selection: PlayerSelection, gameID: UUID) async { - latestLocalSelections[gameID] = selection - guard engagementStatus.isLive(gameID: gameID), - let localAuthorID = identity.currentID, - !localAuthorID.isEmpty - else { return } - let update = EngagementSelectionUpdate( - gameID: gameID, - authorID: localAuthorID, - deviceID: RecordSerializer.localDeviceID, - selection: selection - ) - await engagementCoordinator.sendSelection(update) - } - - private func handleEngagementEvent(_ event: EngagementHost.Event) { - switch event { - case .channelOpen(let engagementID): - syncMonitor.note("engagement: channel open \(engagementID.uuidString)") - Task { [weak self] in - guard let self, - let gameID = await self.engagementCoordinator.channelOpened(engagementID: engagementID) - else { return } - self.engagementStatus.setLive(true, gameID: gameID) - if let selection = self.latestLocalSelections[gameID] { - await self.noteLocalSelection(selection, gameID: gameID) - } - } - case .channelMessage(let engagementID, let message): - if let envelope = EngagementMessage.decode(message) { - handleEngagementMessage(envelope, engagementID: engagementID) - } else { - syncMonitor.note("engagement: channel message \(engagementID.uuidString) bytes=\(message.count)") - } - case .channelClose(let engagementID): - syncMonitor.note("engagement: channel close \(engagementID.uuidString)") - Task { [weak self] in - guard let self, - let gameID = await self.engagementCoordinator.channelClosed(engagementID: engagementID) - else { return } - self.engagementStatus.setLive(false, gameID: gameID) - self.engagementStore.clear(gameID: gameID) - await self.startEngagementIfPossible(gameID: gameID) - } - case .diagnostic(let engagementID, let message): - syncMonitor.note( - "engagement: diagnostic \(engagementID?.uuidString ?? "unknown"): \(message)" - ) - case .error(let engagementID, let message): - syncMonitor.note("engagement: error \(engagementID?.uuidString ?? "unknown"): \(message)") - } - } - - private func handleEngagementMessage(_ envelope: EngagementMessage, engagementID: UUID) { - let latencyMs = max(0, Int(Date().timeIntervalSince(envelope.sentAt) * 1000)) - switch envelope.kind { - case .debugText: - syncMonitor.note( - "engagement: received \(envelope.kind.rawValue) \(engagementID.uuidString) " + - "latency=\(latencyMs)ms: \(envelope.text)" - ) - case .cellEdit: - guard let edit = envelope.cellEdit else { - syncMonitor.note("engagement: ignored malformed cellEdit \(engagementID.uuidString)") - return - } - let applied = store.applyRealtimeCellEdit(edit) - if applied { - syncMonitor.note( - "engagement: applied cellEdit \(engagementID.uuidString) " + - "r=\(edit.row) c=\(edit.col) device=\(edit.deviceID.prefix(8)) " + - "latency=\(latencyMs)ms" - ) - } else { - syncMonitor.note( - "engagement: rejected cellEdit \(engagementID.uuidString) " + - "r=\(edit.row) c=\(edit.col) device=\(edit.deviceID.prefix(8)) " + - "latency=\(latencyMs)ms" - ) - } - case .cellEditBatch: - guard let edits = envelope.cellEdits, !edits.isEmpty else { - syncMonitor.note("engagement: ignored malformed cellEditBatch \(engagementID.uuidString)") - return - } - let applied = store.applyRealtimeCellEdits(edits) - syncMonitor.note( - "engagement: applied cellEditBatch \(engagementID.uuidString) " + - "applied=\(applied)/\(edits.count) device=\(edits[0].deviceID.prefix(8)) " + - "latency=\(latencyMs)ms" - ) - case .selection: - guard let selection = envelope.selection else { - syncMonitor.note("engagement: ignored malformed selection \(engagementID.uuidString)") - return - } - engagementStore.set(selection) - syncMonitor.note( - "engagement: received selection \(engagementID.uuidString) " + - "r=\(selection.row) c=\(selection.col) device=\(selection.deviceID.prefix(8)) " + - "latency=\(latencyMs)ms" - ) - } - } - /// Pull-to-refresh action for the library. Discovers any zones the /// device hasn't seen yet on both database scopes, then runs the normal /// engine fetch so any in-flight changes also catch up. Bypasses @@ -1772,8 +1044,8 @@ final class AppServices { // the freshen settles) clears orphans like a game whose push stamped // the ledger but was since opened — the divergence that otherwise pins // the badge above the count the library list shows. - await reconcileBadgeLedgerWithDeliveredNotifications() - await refreshAppBadge() + await badge.reconcileBadgeLedgerWithDeliveredNotifications() + await badge.refreshAppBadge() await reconcilePendingJournalUploads() if shouldRunColdLaunchArchiveReconcile { shouldRunColdLaunchArchiveReconcile = false @@ -1908,7 +1180,7 @@ final class AppServices { try await self.syncEngine.fetchFriendInvitesDirect(scope: scope) } if inviteResult != nil { - await refreshAppBadge() + await badge.refreshAppBadge() } if catchUpResult != nil { noteGameListFreshenCompleted(scope: scope) @@ -2270,7 +1542,7 @@ final class AppServices { // accurate, on the sibling's `Player.sessionSnapshot` via the // record sync this push's companion DB change triggers. This fast // push is now only the cross-device notification-dismissal signal. - await dismissDeliveredNotifications( + await badge.dismissDeliveredNotifications( for: gameID, seenAt: readAt, publishAccountSeen: false @@ -2712,7 +1984,7 @@ final class AppServices { } if ctx.hasChanges { try ctx.save() - await refreshAppBadge() + await badge.refreshAppBadge() } } @@ -2742,7 +2014,7 @@ final class AppServices { } if ctx.hasChanges { try ctx.save() - await refreshAppBadge() + await badge.refreshAppBadge() } for (inviterAuthorID, pingRecordName) in pingsToDelete { await friendController.deleteInvitePing( @@ -2807,7 +2079,7 @@ final class AppServices { for invite in (try? vctx.fetch(iReq)) ?? [] { vctx.delete(invite) } if vctx.hasChanges { try? vctx.save() - await refreshAppBadge() + await badge.refreshAppBadge() } } @@ -2900,7 +2172,7 @@ final class AppServices { // 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() + 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 @@ -3018,133 +2290,6 @@ final class AppServices { } } - /// Hand-off called when the puzzle becomes active. Pulls any pending - /// session-end tallies out of SessionMonitor and posts them as a - /// transient announcement on the puzzle header, in lieu of the - /// local notification that would otherwise have fired in a few - /// minutes' time. No-op if nothing was accumulated. - func handlePuzzleOpened(gameID: UUID) { - logLocalPauseDiagnostics(for: gameID) - // Defer the banner so the open's `.appeared` grid freshen can land peer - // moves first; otherwise it would diff against a half-synced grid and - // under-report. The baseline is not touched here — it advances only on - // leave (`handlePuzzleLeft`) — so this is a pure read and re-running it - // on a later foreground is harmless. - scheduleSessionSummaryBanner(gameID: gameID, after: Self.sessionSummaryBannerDelay) - } - - /// Called when the user leaves the puzzle (backgrounded or navigated away). - /// Drops a still-pending banner timer and commits the per-peer baseline — - /// the user has now seen what's on screen, so the next open diffs against - /// this state — then ships that baseline to sibling devices on this - /// account's own `Player.sessionSnapshot`, so they adopt it rather than - /// recomputing from their own view. Returns the committed snapshots. - @discardableResult - func handlePuzzleLeft(gameID: UUID) -> [String: LocalMovesSnapshot] { - cancelPendingSessionSummaryBanner(gameID: gameID) - let committed = sessionMonitor.commitMovesBaseline(for: gameID) - guard let authorID = identity.currentID, !authorID.isEmpty, - !committed.isEmpty, - let data = try? JSONEncoder().encode(committed) - else { return committed } - // Write it onto our own Player record and enqueue the send. This also - // rides the leave's read-cursor Player write, but enqueuing directly - // guarantees it ships even when that write is a no-op. - store.setSessionSnapshot(data, gameID: gameID, authorID: authorID) - let syncEngine = self.syncEngine - // Leave-path Player write: enqueue durably but don't force a drain that - // would race the suspension budget — siblings adopt the snapshot on the - // next CKSyncEngine sync. - Task { await syncEngine.enqueuePlayer(gameID: gameID, authorID: authorID, reason: "sessionSnapshot", drain: false) } - return committed - } - - /// Defer the catch-up banner by `seconds`, replacing any pending timer for - /// the same game. Leaving the puzzle (`handlePuzzleLeft`) cancels it. - func scheduleSessionSummaryBanner(gameID: UUID, after seconds: TimeInterval) { - pendingSessionSummaryBannerTasks.removeValue(forKey: gameID)?.cancel() - pendingSessionSummaryBannerTasks[gameID] = Task { [weak self] in - try? await Task.sleep(for: .seconds(seconds)) - guard !Task.isCancelled, let self else { return } - self.pendingSessionSummaryBannerTasks.removeValue(forKey: gameID) - self.postSessionSummaryBanner(gameID: gameID, reason: "open") - } - } - - func cancelPendingSessionSummaryBanner(gameID: UUID) { - pendingSessionSummaryBannerTasks.removeValue(forKey: gameID)?.cancel() - } - - /// Computes the receiver-side catch-up summary for `gameID` and, when a peer - /// has unseen activity, posts (or replaces, by stable id) the "Puzzle - /// Updated" banner. Read-only — the baseline advances on leave, not here — - /// so it is safe to recompute on every foreground. Logs the per-peer counts - /// it surfaces so a missing or wrong banner is diagnosable after the fact. - func postSessionSummaryBanner(gameID: UUID, reason: String) { - let summaries = sessionMonitor.movesSummaries(for: gameID) - guard !summaries.isEmpty else { return } - let detail = summaries.map { summary -> String in - let who = summary.playerName.isEmpty - ? String(summary.authorID.prefix(8)) - : summary.playerName - return "\(who) +\(summary.added)/-\(summary.cleared)\(summary.isFirstObservation ? " first" : "")" - }.joined(separator: ", ") - syncMonitor.note( - "session summary[\(gameID.uuidString.prefix(8))] \(reason): \(detail)" - ) - announcements.post(Announcement( - id: "session-summary-\(gameID.uuidString)", - scope: .game(gameID), - severity: .info, - title: "Puzzle Updated", - body: Self.formatSummaryBanner(summaries), - dismissal: .transient(after: 6) - )) - } - - /// Logs this device's own view of each peer's Moves for `gameID`, using the - /// same `movesDiagnostics` computation the sender embeds in a pause push. - /// Pairs with the `pause-diagnostics` receipt the NSE records: a suspicious - /// pushed count can be diffed field-for-field against local ground truth. - /// Phantom cells that actually synced surface here too; ones that stayed - /// local to the sender (un-uploaded churn) won't — which is itself the - /// answer. `recipientReadAt` carries this device's *actual* cursor, to - /// compare against the value the peer's pushed diagnostics claimed it saw. - private func logLocalPauseDiagnostics(for gameID: UUID) { - let localAuthorID = identity.currentID - let selfReadAt = localAuthorID.flatMap { store.readAt(for: gameID, by: $0) } - for peerAuthorID in store.peerAuthorIDs(for: gameID, excluding: localAuthorID) { - guard var diagnostics = store.movesDiagnostics(for: gameID, by: peerAuthorID) - else { continue } - diagnostics.senderNow = Date() - diagnostics.recipientReadAt = selfReadAt - syncMonitor.note( - "local pause diag peer=\(peerAuthorID.prefix(8)): \(diagnostics.summaryLine)" - ) - } - } - - nonisolated static func formatSummaryBanner(_ summaries: [SessionMonitor.SessionSummary]) -> String { - guard !summaries.isEmpty else { return "" } - let phrases: [String] = summaries.map { summary in - let name = summary.playerName.isEmpty ? "A player" : summary.playerName - if summary.isFirstObservation { - let count = summary.added - return "\(name) added \(count) \(count == 1 ? "letter" : "letters") while you were away" - } - var parts: [String] = [] - if summary.added > 0 { - parts.append("added \(summary.added) \(summary.added == 1 ? "letter" : "letters")") - } - if summary.cleared > 0 { - parts.append("cleared \(summary.cleared) \(summary.cleared == 1 ? "letter" : "letters")") - } - let action = parts.isEmpty ? "made changes" : parts.joined(separator: " and ") - return "\(name) \(action)" - } - return "\(phrases.joined(separator: "; "))." - } - /// 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 @@ -3221,53 +2366,6 @@ final class AppServices { syncMonitor.updateSnapshot(snapshot) } - /// Removes any already-delivered local notifications for `gameID` from - /// this device's Notification Center. Sibling devices of the same iCloud - /// account learn about the dismissal indirectly: a directed ping is - /// deleted on consumption (the `onPingDeleted` path then withdraws their - /// copy), and the unread-moves badge converges via `Player.readAt`. - /// - /// Every dismissal path is also a "user has seen this game" signal, so - /// we advance the App Group badge ledger's seen horizon and refresh the - /// app-icon badge. Without this, pause/win/resign entries added by the - /// Notification Service Extension would otherwise linger past the point - /// where their banners have already been withdrawn. - func dismissDeliveredNotifications( - for gameID: UUID, - seenAt explicitSeenAt: Date? = nil, - publishAccountSeen: Bool = true - ) async { - let center = UNUserNotificationCenter.current() - let delivered = await center.deliveredNotifications() - let identifiers = delivered.compactMap { notification -> String? in - let userInfo = notification.request.content.userInfo - guard let raw = userInfo["gameID"] as? String, - raw == gameID.uuidString - else { return nil } - return notification.request.identifier - } - if !identifiers.isEmpty { - center.removeDeliveredNotifications(withIdentifiers: identifiers) - syncMonitor.note("notif: dismissed \(identifiers.count) delivered for \(gameID.uuidString)") - } - // While viewing, the horizon is the local lease (forward-dated); the - // ledger adoption splits it — watermark to now, suppression to the - // lease — so pushes landing mid-session don't badge, yet the leave - // path's `collapseSuppression` can still un-swallow anything that - // arrives after the user stops looking. Writing the raw horizon into - // `markSeen` here is what used to pin the badge dark for the rest of - // the lease window after backgrounding. - let horizon = explicitSeenAt - ?? (NotificationState.isSuppressed(gameID: gameID) - ? Date().addingTimeInterval(Self.readLeaseDuration) - : Date()) - BadgeState.adoptReadHorizon(gameID: gameID, horizon: horizon) - await refreshAppBadge() - if publishAccountSeen { - await publishAccountSeenPush(gameID: gameID, readAt: horizon) - } - } - /// Publishes this account's read horizon for other-author moves by /// updating `GameEntity.lastReadOtherMoveAt` and re-enqueuing its Player /// record. Active puzzle sessions write a future lease and refresh it @@ -3413,145 +2511,6 @@ final class AppServices { } } - /// Sets the app icon badge to Core Data ground truth (the - /// `hasUnreadOtherMoves` heuristic that drives the per-row dot in the - /// library list) unioned with provisional push-side unread entries the - /// Notification Service Extension added since the last refresh. - /// - /// Core Data unread games are seeded into `BadgeState` as `unreadAt` - /// horizons stamped with each game's newest unseen other-author move time. - /// The NSE can't reach Core Data, so without this seed a push landing on a - /// suspended app would re-stamp the badge from the ledger alone and drop - /// any game whose unread state arrived purely via CloudKit sync. Seeding is - /// safe under horizon semantics — a game the user has since opened carries a - /// newer `seenAt` and won't resurrect — which the old set-based ledger - /// couldn't express, hence why this write-back was previously dropped. - func refreshAppBadge() async { - let coreDataUnread = store.unreadOtherMovesGameTimes() - BadgeState.seedUnread(coreDataUnread) - // Pending invites are binary (not a read horizon), so publish them as a - // dedicated authoritative set the NSE unions into its count. Disjoint - // from the unread-moves set, so the union below never double-counts. - let pendingInvites = store.pendingInviteGameIDs() - BadgeState.setPendingInvites(pendingInvites) - let merged = BadgeState.unreadGameIDs() - .union(coreDataUnread.keys) - .union(pendingInvites) - syncMonitor.note( - "app badge refresh: count=\(merged.count) " - + "ledger=\(BadgeState.unreadGameIDs().count) [\(shortIDs(BadgeState.unreadGameIDs()))] " - + "coreData=\(coreDataUnread.count) [\(shortIDs(coreDataUnread.keys))] " - + "pendingInvites=\(pendingInvites.count) [\(shortIDs(pendingInvites))]" - ) - do { - try await UNUserNotificationCenter.current().setBadgeCount(merged.count) - } catch { - syncMonitor.note("app badge update failed: \(error.localizedDescription)") - } - } - - /// One startup-only snapshot for badge debugging. Kept out of - /// `refreshAppBadge` so normal notification and sync churn does not flood - /// diagnostics. - private func logNotificationStartupSnapshot() async { - let delivered = await UNUserNotificationCenter.current().deliveredNotifications() - let ledgerUnread = BadgeState.unreadGameIDs() - let coreDataUnread = store.unreadOtherMovesGameTimes() - let merged = ledgerUnread.union(coreDataUnread.keys) - syncMonitor.note( - "notif startup: delivered=\(delivered.count) " - + "badgeLedger=\(ledgerUnread.count) [\(shortIDs(ledgerUnread))] " - + "coreDataUnread=\(coreDataUnread.count) [\(shortIDs(coreDataUnread.keys))] " - + "merged=\(merged.count) [\(shortIDs(merged))]" - ) - for notification in delivered.sorted(by: { $0.date < $1.date }) { - syncMonitor.note("notif delivered: \(notificationSummary(notification))") - } - } - - /// Clears stale provisional badge-ledger entries that are no longer backed - /// by either synced unread state or a delivered badge-worthy notification. - /// This keeps presence-only notifications (`play`, `replay`, zero-count - /// pause) from keeping an old ledger entry alive while still preserving - /// push-ahead-of-sync unread entries when their delivered notification is - /// visible on the device. - private func reconcileBadgeLedgerWithDeliveredNotifications() async { - let ledgerUnread = BadgeState.unreadGameIDs() - guard !ledgerUnread.isEmpty else { return } - let coreDataUnread = Set(store.unreadOtherMovesGameTimes().keys) - let delivered = await UNUserNotificationCenter.current().deliveredNotifications() - let deliveredUnread = Set(delivered.compactMap { notification -> UUID? in - guard notificationMarksUnread(notification), - let gameID = notificationGameID(from: notification.request.content.userInfo) - else { return nil } - return gameID - }) - let stale = ledgerUnread.subtracting(coreDataUnread).subtracting(deliveredUnread) - guard !stale.isEmpty else { return } - for gameID in stale { - BadgeState.forget(gameID: gameID) - } - syncMonitor.note( - "app badge ledger reconcile: cleared=\(stale.count) [\(shortIDs(stale))] " - + "coreData=\(coreDataUnread.count) [\(shortIDs(coreDataUnread))] " - + "deliveredUnread=\(deliveredUnread.count) [\(shortIDs(deliveredUnread))]" - ) - } - - private func notificationSummary(_ notification: UNNotification) -> String { - let request = notification.request - let content = request.content - let userInfo = content.userInfo - let gameID = notificationGameID(from: userInfo) - let kind = userInfo["kind"] as? String - let pingKind = userInfo["pingKind"] as? String - let nseLogged = (userInfo["crossmateNSELogged"] as? Bool) == true - return "id=\(request.identifier)" - + " date=\(notification.date.ISO8601Format())" - + " game=\(gameID.map { shortID($0) } ?? "nil")" - + " kind=\(kind ?? "nil")" - + " pingKind=\(pingKind ?? "nil")" - + " badge=\(content.badge?.stringValue ?? "nil")" - + " nseLogged=\(nseLogged)" - + " title=\"\(logEscaped(content.title))\"" - + " body=\"\(logEscaped(content.body))\"" - } - - private func notificationMarksUnread(_ notification: UNNotification) -> Bool { - let userInfo = notification.request.content.userInfo - if let payload = PushPayload.decode(from: userInfo["payload"] as? String) { - return payload.marksUnread - } - let kind = userInfo["kind"] as? String - return kind == "pause" || kind == "win" || kind == "resign" - } - - private func notificationGameID(from userInfo: [AnyHashable: Any]) -> UUID? { - if let raw = userInfo["gameID"] as? String, - let id = UUID(uuidString: raw) { - return id - } - guard let ck = userInfo["ck"] as? [AnyHashable: Any], - let qry = ck["qry"] as? [AnyHashable: Any], - let zoneName = qry["zid"] as? String, - zoneName.hasPrefix("game-") - else { return nil } - return UUID(uuidString: String(zoneName.dropFirst("game-".count))) - } - - private func shortIDs<S: Sequence>(_ ids: S) -> String where S.Element == UUID { - ids.map(shortID).sorted().joined(separator: ",") - } - - private func shortID(_ id: UUID) -> String { - String(id.uuidString.prefix(8)) - } - - private func logEscaped(_ text: String) -> String { - text.replacingOccurrences(of: "\n", with: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - } - /// Assembled replay timelines, keyed by game. A finished game's journals are /// frozen (edit-lockout), so its timeline never changes once built — caching /// it here lets a `ReplayControls` instance recreated by rapid diff --git a/Crossmate/Services/BadgeCoordinator.swift b/Crossmate/Services/BadgeCoordinator.swift @@ -0,0 +1,217 @@ +import Foundation +import UserNotifications + +/// Reconciles the app-icon badge and its push-side ledger, extracted from +/// `AppServices`: setting the badge from Core Data ground truth unioned with +/// the NSE's provisional `BadgeState` entries, withdrawing delivered +/// notifications when a game is seen (here or on a sibling device), and +/// pruning ledger entries no longer backed by anything. +@MainActor +final class BadgeCoordinator { + private let store: GameStore + private let syncMonitor: SyncMonitor + /// Length of the presence lease (`AppServices.readLeaseDuration`): how far + /// forward the suppression horizon is stamped while the puzzle is open. + private let readLeaseDuration: TimeInterval + /// Fans the seen horizon out to sibling devices — + /// `AppServices.publishAccountSeenPush(gameID:readAt:)`. + private let publishAccountSeenPush: (UUID, Date) async -> Void + + init( + store: GameStore, + syncMonitor: SyncMonitor, + readLeaseDuration: TimeInterval, + publishAccountSeenPush: @escaping (UUID, Date) async -> Void + ) { + self.store = store + self.syncMonitor = syncMonitor + self.readLeaseDuration = readLeaseDuration + self.publishAccountSeenPush = publishAccountSeenPush + } + + /// Removes any already-delivered local notifications for `gameID` from + /// this device's Notification Center. Sibling devices of the same iCloud + /// account learn about the dismissal indirectly: a directed ping is + /// deleted on consumption (the `onPingDeleted` path then withdraws their + /// copy), and the unread-moves badge converges via `Player.readAt`. + /// + /// Every dismissal path is also a "user has seen this game" signal, so + /// we advance the App Group badge ledger's seen horizon and refresh the + /// app-icon badge. Without this, pause/win/resign entries added by the + /// Notification Service Extension would otherwise linger past the point + /// where their banners have already been withdrawn. + func dismissDeliveredNotifications( + for gameID: UUID, + seenAt explicitSeenAt: Date? = nil, + publishAccountSeen: Bool = true + ) async { + let center = UNUserNotificationCenter.current() + let delivered = await center.deliveredNotifications() + let identifiers = delivered.compactMap { notification -> String? in + let userInfo = notification.request.content.userInfo + guard let raw = userInfo["gameID"] as? String, + raw == gameID.uuidString + else { return nil } + return notification.request.identifier + } + if !identifiers.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: identifiers) + syncMonitor.note("notif: dismissed \(identifiers.count) delivered for \(gameID.uuidString)") + } + // While viewing, the horizon is the local lease (forward-dated); the + // ledger adoption splits it — watermark to now, suppression to the + // lease — so pushes landing mid-session don't badge, yet the leave + // path's `collapseSuppression` can still un-swallow anything that + // arrives after the user stops looking. Writing the raw horizon into + // `markSeen` here is what used to pin the badge dark for the rest of + // the lease window after backgrounding. + let horizon = explicitSeenAt + ?? (NotificationState.isSuppressed(gameID: gameID) + ? Date().addingTimeInterval(readLeaseDuration) + : Date()) + BadgeState.adoptReadHorizon(gameID: gameID, horizon: horizon) + await refreshAppBadge() + if publishAccountSeen { + await publishAccountSeenPush(gameID, horizon) + } + } + + /// Sets the app icon badge to Core Data ground truth (the + /// `hasUnreadOtherMoves` heuristic that drives the per-row dot in the + /// library list) unioned with provisional push-side unread entries the + /// Notification Service Extension added since the last refresh. + /// + /// Core Data unread games are seeded into `BadgeState` as `unreadAt` + /// horizons stamped with each game's newest unseen other-author move time. + /// The NSE can't reach Core Data, so without this seed a push landing on a + /// suspended app would re-stamp the badge from the ledger alone and drop + /// any game whose unread state arrived purely via CloudKit sync. Seeding is + /// safe under horizon semantics — a game the user has since opened carries a + /// newer `seenAt` and won't resurrect — which the old set-based ledger + /// couldn't express, hence why this write-back was previously dropped. + func refreshAppBadge() async { + let coreDataUnread = store.unreadOtherMovesGameTimes() + BadgeState.seedUnread(coreDataUnread) + // Pending invites are binary (not a read horizon), so publish them as a + // dedicated authoritative set the NSE unions into its count. Disjoint + // from the unread-moves set, so the union below never double-counts. + let pendingInvites = store.pendingInviteGameIDs() + BadgeState.setPendingInvites(pendingInvites) + let merged = BadgeState.unreadGameIDs() + .union(coreDataUnread.keys) + .union(pendingInvites) + syncMonitor.note( + "app badge refresh: count=\(merged.count) " + + "ledger=\(BadgeState.unreadGameIDs().count) [\(shortIDs(BadgeState.unreadGameIDs()))] " + + "coreData=\(coreDataUnread.count) [\(shortIDs(coreDataUnread.keys))] " + + "pendingInvites=\(pendingInvites.count) [\(shortIDs(pendingInvites))]" + ) + do { + try await UNUserNotificationCenter.current().setBadgeCount(merged.count) + } catch { + syncMonitor.note("app badge update failed: \(error.localizedDescription)") + } + } + + /// One startup-only snapshot for badge debugging. Kept out of + /// `refreshAppBadge` so normal notification and sync churn does not flood + /// diagnostics. + func logNotificationStartupSnapshot() async { + let delivered = await UNUserNotificationCenter.current().deliveredNotifications() + let ledgerUnread = BadgeState.unreadGameIDs() + let coreDataUnread = store.unreadOtherMovesGameTimes() + let merged = ledgerUnread.union(coreDataUnread.keys) + syncMonitor.note( + "notif startup: delivered=\(delivered.count) " + + "badgeLedger=\(ledgerUnread.count) [\(shortIDs(ledgerUnread))] " + + "coreDataUnread=\(coreDataUnread.count) [\(shortIDs(coreDataUnread.keys))] " + + "merged=\(merged.count) [\(shortIDs(merged))]" + ) + for notification in delivered.sorted(by: { $0.date < $1.date }) { + syncMonitor.note("notif delivered: \(notificationSummary(notification))") + } + } + + /// Clears stale provisional badge-ledger entries that are no longer backed + /// by either synced unread state or a delivered badge-worthy notification. + /// This keeps presence-only notifications (`play`, `replay`, zero-count + /// pause) from keeping an old ledger entry alive while still preserving + /// push-ahead-of-sync unread entries when their delivered notification is + /// visible on the device. + func reconcileBadgeLedgerWithDeliveredNotifications() async { + let ledgerUnread = BadgeState.unreadGameIDs() + guard !ledgerUnread.isEmpty else { return } + let coreDataUnread = Set(store.unreadOtherMovesGameTimes().keys) + let delivered = await UNUserNotificationCenter.current().deliveredNotifications() + let deliveredUnread = Set(delivered.compactMap { notification -> UUID? in + guard notificationMarksUnread(notification), + let gameID = notificationGameID(from: notification.request.content.userInfo) + else { return nil } + return gameID + }) + let stale = ledgerUnread.subtracting(coreDataUnread).subtracting(deliveredUnread) + guard !stale.isEmpty else { return } + for gameID in stale { + BadgeState.forget(gameID: gameID) + } + syncMonitor.note( + "app badge ledger reconcile: cleared=\(stale.count) [\(shortIDs(stale))] " + + "coreData=\(coreDataUnread.count) [\(shortIDs(coreDataUnread))] " + + "deliveredUnread=\(deliveredUnread.count) [\(shortIDs(deliveredUnread))]" + ) + } + + private func notificationSummary(_ notification: UNNotification) -> String { + let request = notification.request + let content = request.content + let userInfo = content.userInfo + let gameID = notificationGameID(from: userInfo) + let kind = userInfo["kind"] as? String + let pingKind = userInfo["pingKind"] as? String + let nseLogged = (userInfo["crossmateNSELogged"] as? Bool) == true + return "id=\(request.identifier)" + + " date=\(notification.date.ISO8601Format())" + + " game=\(gameID.map { shortID($0) } ?? "nil")" + + " kind=\(kind ?? "nil")" + + " pingKind=\(pingKind ?? "nil")" + + " badge=\(content.badge?.stringValue ?? "nil")" + + " nseLogged=\(nseLogged)" + + " title=\"\(logEscaped(content.title))\"" + + " body=\"\(logEscaped(content.body))\"" + } + + private func notificationMarksUnread(_ notification: UNNotification) -> Bool { + let userInfo = notification.request.content.userInfo + if let payload = PushPayload.decode(from: userInfo["payload"] as? String) { + return payload.marksUnread + } + let kind = userInfo["kind"] as? String + return kind == "pause" || kind == "win" || kind == "resign" + } + + private func notificationGameID(from userInfo: [AnyHashable: Any]) -> UUID? { + if let raw = userInfo["gameID"] as? String, + let id = UUID(uuidString: raw) { + return id + } + guard let ck = userInfo["ck"] as? [AnyHashable: Any], + let qry = ck["qry"] as? [AnyHashable: Any], + let zoneName = qry["zid"] as? String, + zoneName.hasPrefix("game-") + else { return nil } + return UUID(uuidString: String(zoneName.dropFirst("game-".count))) + } + + private func shortIDs<S: Sequence>(_ ids: S) -> String where S.Element == UUID { + ids.map(shortID).sorted().joined(separator: ",") + } + + private func shortID(_ id: UUID) -> String { + String(id.uuidString.prefix(8)) + } + + private func logEscaped(_ text: String) -> String { + text.replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Crossmate/Services/EngagementLifecycle.swift b/Crossmate/Services/EngagementLifecycle.swift @@ -0,0 +1,377 @@ +import Foundation + +/// Drives the live engagement (websocket) lifecycle for open puzzles, +/// extracted from `AppServices`: room reconcile/mint, the scheduled-teardown, +/// reconnect-backstop, and lease-expiry timers, and inbound channel +/// event/message handling. `AppServices` composes one instance and remains +/// the owner of the host/status/store objects it shares with other surfaces +/// (`PlayerRoster`, the selection publisher, the grid-freshen debounce). +@MainActor +final class EngagementLifecycle { + static let engagementTeardownDelaySeconds = 120 + static let engagementTeardownDelay: Duration = .seconds(engagementTeardownDelaySeconds) + /// How often a foregrounded shared puzzle re-runs the engagement reconnect + /// check. Engagement auto-connect is otherwise edge-triggered (channel + /// close, foreground, a peer's cursor move), so a drop whose re-connect + /// edge never lands — a failed connect, a `readAt`-only lease refresh, an + /// edge lost to suspension — stays disconnected until the user nudges it + /// manually. This timer is the backstop. It re-runs `peerPresenceMayHave\ + /// Changed`, which is a no-op unless the coordinator is idle *and* a peer + /// holds a live lease, so a steady-state live (or peerless) session does + /// no work and writes nothing; the coordinator's connecting-state guard + /// caps any actual re-hail rate. + static let engagementReconnectInterval: Duration = .seconds(30) + + private let preferences: PlayerPreferences + private let persistence: PersistenceController + private let store: GameStore + private let identity: AuthorIdentity + private let syncMonitor: SyncMonitor + private let engagementHost: EngagementHost + private let engagementStatus: EngagementStatus + private let engagementStore: EngagementStore + /// Whether the app is foreground-active — `AppServices.isAppForeground`, + /// the single source of truth a background CKSyncEngine wake can't spoof. + private let isAppForeground: () -> Bool + /// Renews this device's read lease for the game — + /// `AppServices.publishReadCursor(for:mode:.activeLease)`. + private let renewReadLease: (UUID) async -> Void + private let ensureICloudSyncStarted: () async -> Bool + + private lazy var engagementCoordinator = EngagementCoordinator( + host: engagementHost, + localAuthorID: { [weak self] in + await MainActor.run { self?.identity.currentID } + }, + log: { [weak self] message in + await MainActor.run { self?.syncMonitor.note(message) } + } + ) + + private var latestLocalSelections: [UUID: PlayerSelection] = [:] + private var scheduledEngagementEndTasks: [UUID: Task<Void, Never>] = [:] + private var engagementReconnectTasks: [UUID: Task<Void, Never>] = [:] + private var engagementLeaseExpiryTasks: [UUID: Task<Void, Never>] = [:] + + init( + preferences: PlayerPreferences, + persistence: PersistenceController, + store: GameStore, + identity: AuthorIdentity, + syncMonitor: SyncMonitor, + engagementHost: EngagementHost, + engagementStatus: EngagementStatus, + engagementStore: EngagementStore, + isAppForeground: @escaping () -> Bool, + renewReadLease: @escaping (UUID) async -> Void, + ensureICloudSyncStarted: @escaping () async -> Bool + ) { + self.preferences = preferences + self.persistence = persistence + self.store = store + self.identity = identity + self.syncMonitor = syncMonitor + self.engagementHost = engagementHost + self.engagementStatus = engagementStatus + self.engagementStore = engagementStore + self.isAppForeground = isAppForeground + self.renewReadLease = renewReadLease + self.ensureICloudSyncStarted = ensureICloudSyncStarted + } + + /// Drives one game's live connection toward the room its Game record + /// advertises. Reads the shared `engagement` creds and whether any peer + /// holds a live read lease; if a peer is present (or `force`) and no creds + /// exist yet, mints a room and writes it to the Game record (any present + /// participant may — record-level LWW converges concurrent mints). Then + /// hands the desired creds to the coordinator, which connects, migrates, or + /// tears down to match. `force` is the manual "start a room now" path, + /// which mints and connects without waiting to see a present peer. + func reconcileEngagement(gameID: UUID) async { + guard preferences.isICloudSyncEnabled else { return } + // A completed game is not a live session. Never connect (this is also + // the reopen-from-Completed path, which re-runs through + // `startEngagementIfPossible`) and tear down anything still up. + guard !store.isCompleted(gameID: gameID) else { + cancelEngagementLeaseExpiry(gameID: gameID) + await engagementCoordinator.reconcile(gameID: gameID, creds: nil, hasPeer: false) + return + } + // A live socket is a foreground-only affair — the same rule + // `publishReadCursor` enforces for the read lease. Inbound presence / + // engagement changes also arrive on background CKSyncEngine wakes + // (`onRemotePlayerPresenceChanged` / `onRemoteEngagementChanged`), and a + // background reconcile used to re-dial the socket on every wake. That is + // futile: the `.default` URLSession WebSocket can't survive suspension, + // so it just storms connect → abort → reconnect until the next push. + // When not foreground, leave the current connection (and the + // scheduled-end grace that rides out a transient `.inactive`) untouched + // and refuse to escalate; the foreground `.active` path re-reconciles + // via `startEngagementIfPossible`. Teardown still flows from socket + // abort, `scheduleEngagementEnd`, and the completed-game guard above. + guard isAppForeground() else { + syncMonitor.note("engagement: reconcile skipped for \(gameID.uuidString): backgrounded") + return + } + let soonestLease = await AppServices.soonestPeerLease( + persistence: persistence, + gameID: gameID, + localAuthorID: identity.currentID + ) + let hasPeer = soonestLease != nil + var creds = EngagementRoomCredentials.decode(store.engagement(for: gameID)) + if creds == nil, hasPeer { + if let minted = try? EngagementRoomCredentials.fresh(), + let encoded = try? minted.encoded(), + store.setEngagement(encoded, for: gameID) { + creds = minted + syncMonitor.note( + "engagement: minted room \(minted.roomID.uuidString) for \(gameID.uuidString)" + ) + } + } + await engagementCoordinator.reconcile(gameID: gameID, creds: creds, hasPeer: hasPeer) + // When the soonest present peer drops out of presence, re-reconcile: + // tear down (dropping the bolt) if no renewal arrived, or reschedule + // onto the new horizon if one did. That instant is the lease plus the + // presence grace, not the bare lease — a peer stays present through the + // grace, so tearing down at the raw lease would race it. The 30s + // reconnect tick remains the coarse backstop. A no-peer reconcile + // has no lease to watch, so this just clears any prior wake. + scheduleEngagementLeaseExpiry( + gameID: gameID, + at: soonestLease?.addingTimeInterval(PeerPresence.presenceGrace) + ) + } + + func startEngagementIfPossible(gameID: UUID) async { + cancelScheduledEngagementEnd(gameID: gameID) + guard preferences.isICloudSyncEnabled else { return } + guard await ensureICloudSyncStarted() else { return } + await reconcileEngagement(gameID: gameID) + startEngagementReconnectRetry(gameID: gameID) + } + + func endEngagement(gameID: UUID) async { + cancelScheduledEngagementEnd(gameID: gameID) + cancelEngagementReconnectRetry(gameID: gameID) + cancelEngagementLeaseExpiry(gameID: gameID) + syncMonitor.note("engagement: ending for \(gameID.uuidString)") + engagementStatus.setLive(false, gameID: gameID) + latestLocalSelections[gameID] = nil + engagementStore.clear(gameID: gameID) + await engagementCoordinator.teardown(gameID: gameID) + } + + func scheduleEngagementEnd(gameID: UUID) { + cancelScheduledEngagementEnd(gameID: gameID) + // Leaving the puzzle stops the reconnect backstop regardless of + // whether a channel ever went live — otherwise a puzzle that was + // opened but never connected would tick forever. A quick return + // re-arms it via `startEngagementIfPossible`. + cancelEngagementReconnectRetry(gameID: gameID) + cancelEngagementLeaseExpiry(gameID: gameID) + guard engagementStatus.isLive(gameID: gameID) else { return } + syncMonitor.note( + "engagement: scheduled ending for \(gameID.uuidString) " + + "in \(Self.engagementTeardownDelaySeconds)s" + ) + scheduledEngagementEndTasks[gameID] = Task { [weak self] in + do { + try await Task.sleep(for: Self.engagementTeardownDelay) + } catch { + return + } + await self?.finishScheduledEngagementEnd(gameID: gameID) + } + } + + func cancelScheduledEngagementEnd(gameID: UUID) { + guard let task = scheduledEngagementEndTasks.removeValue(forKey: gameID) else { return } + task.cancel() + syncMonitor.note("engagement: cancelled scheduled ending for \(gameID.uuidString)") + } + + private func finishScheduledEngagementEnd(gameID: UUID) async { + guard scheduledEngagementEndTasks.removeValue(forKey: gameID) != nil else { return } + syncMonitor.note("engagement: scheduled ending fired for \(gameID.uuidString)") + await endEngagement(gameID: gameID) + } + + /// Arms the periodic engagement-presence tick for `gameID`, replacing any + /// prior timer for the same game (so a re-arm on foreground doesn't stack). + /// Each tick does two things: + /// + /// 1. Renews our own read lease (`publishReadCursor(.activeLease)`, which + /// is floor-gated so it writes at most ~once per 5 min). This is what + /// makes `readAt` a true "foregrounded on this puzzle" heartbeat: a + /// peer's own typing never advances their own lease, so without this an + /// active solo driver would lapse mid-session and look absent. + /// 2. Re-runs the coordinator's presence check, which both reconnects a + /// dropped channel and tears down a live channel whose peer's lease has + /// expired — neither of which is safe until (1) keeps present peers + /// from falsely lapsing. + /// + /// Both are no-ops in steady state (lease has >5 min left; coordinator is + /// live with a present peer). Cancelled on leave via `scheduleEngagement + /// End`/`endEngagement`; goes dormant under suspension (the sleep can't + /// advance) and is re-armed by the foreground `startEngagementIfPossible`. + private func startEngagementReconnectRetry(gameID: UUID) { + engagementReconnectTasks[gameID]?.cancel() + engagementReconnectTasks[gameID] = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: Self.engagementReconnectInterval) + guard !Task.isCancelled, let self else { return } + await self.renewReadLease(gameID) + await self.reconcileEngagement(gameID: gameID) + } + } + } + + func cancelEngagementReconnectRetry(gameID: UUID) { + guard let task = engagementReconnectTasks.removeValue(forKey: gameID) else { return } + task.cancel() + syncMonitor.note("engagement: reconnect backstop cancelled for \(gameID.uuidString)") + } + + /// Arms a one-shot reconcile at `expiry` — the soonest moment a present + /// peer's lease lapses — replacing any prior wake for this game. When it + /// fires, `reconcileEngagement` tears the channel down if no renewal landed + /// (so the bolt drops at expiry, not up to a tick later) or reschedules + /// onto the renewed horizon. `nil` (no-peer) just clears the wake. + private func scheduleEngagementLeaseExpiry(gameID: UUID, at expiry: Date?) { + engagementLeaseExpiryTasks[gameID]?.cancel() + engagementLeaseExpiryTasks[gameID] = nil + guard let expiry else { return } + let interval = max(0, expiry.timeIntervalSinceNow) + engagementLeaseExpiryTasks[gameID] = Task { [weak self] in + try? await Task.sleep(for: .seconds(interval)) + guard !Task.isCancelled, let self else { return } + await self.reconcileEngagement(gameID: gameID) + } + } + + private func cancelEngagementLeaseExpiry(gameID: UUID) { + guard let task = engagementLeaseExpiryTasks.removeValue(forKey: gameID) else { return } + task.cancel() + } + + func noteLocalSelection(_ selection: PlayerSelection, gameID: UUID) async { + latestLocalSelections[gameID] = selection + guard engagementStatus.isLive(gameID: gameID), + let localAuthorID = identity.currentID, + !localAuthorID.isEmpty + else { return } + let update = EngagementSelectionUpdate( + gameID: gameID, + authorID: localAuthorID, + deviceID: RecordSerializer.localDeviceID, + selection: selection + ) + await engagementCoordinator.sendSelection(update) + } + + /// Forwards a locally-applied cell edit over the live channel. No-op while + /// no channel is live for the game — the durable Moves sync covers it. + func sendLocalCellEdit(_ edit: RealtimeCellEdit) { + guard engagementStatus.isLive(gameID: edit.gameID) else { return } + Task { await engagementCoordinator.sendCellEdit(edit) } + } + + /// Batch companion to `sendLocalCellEdit`. + func sendLocalCellEdits(_ edits: [RealtimeCellEdit]) { + guard let gameID = edits.first?.gameID else { return } + guard engagementStatus.isLive(gameID: gameID) else { return } + Task { await engagementCoordinator.sendCellEdits(edits) } + } + + func handleEngagementEvent(_ event: EngagementHost.Event) { + switch event { + case .channelOpen(let engagementID): + syncMonitor.note("engagement: channel open \(engagementID.uuidString)") + Task { [weak self] in + guard let self, + let gameID = await self.engagementCoordinator.channelOpened(engagementID: engagementID) + else { return } + self.engagementStatus.setLive(true, gameID: gameID) + if let selection = self.latestLocalSelections[gameID] { + await self.noteLocalSelection(selection, gameID: gameID) + } + } + case .channelMessage(let engagementID, let message): + if let envelope = EngagementMessage.decode(message) { + handleEngagementMessage(envelope, engagementID: engagementID) + } else { + syncMonitor.note("engagement: channel message \(engagementID.uuidString) bytes=\(message.count)") + } + case .channelClose(let engagementID): + syncMonitor.note("engagement: channel close \(engagementID.uuidString)") + Task { [weak self] in + guard let self, + let gameID = await self.engagementCoordinator.channelClosed(engagementID: engagementID) + else { return } + self.engagementStatus.setLive(false, gameID: gameID) + self.engagementStore.clear(gameID: gameID) + await self.startEngagementIfPossible(gameID: gameID) + } + case .diagnostic(let engagementID, let message): + syncMonitor.note( + "engagement: diagnostic \(engagementID?.uuidString ?? "unknown"): \(message)" + ) + case .error(let engagementID, let message): + syncMonitor.note("engagement: error \(engagementID?.uuidString ?? "unknown"): \(message)") + } + } + + private func handleEngagementMessage(_ envelope: EngagementMessage, engagementID: UUID) { + let latencyMs = max(0, Int(Date().timeIntervalSince(envelope.sentAt) * 1000)) + switch envelope.kind { + case .debugText: + syncMonitor.note( + "engagement: received \(envelope.kind.rawValue) \(engagementID.uuidString) " + + "latency=\(latencyMs)ms: \(envelope.text)" + ) + case .cellEdit: + guard let edit = envelope.cellEdit else { + syncMonitor.note("engagement: ignored malformed cellEdit \(engagementID.uuidString)") + return + } + let applied = store.applyRealtimeCellEdit(edit) + if applied { + syncMonitor.note( + "engagement: applied cellEdit \(engagementID.uuidString) " + + "r=\(edit.row) c=\(edit.col) device=\(edit.deviceID.prefix(8)) " + + "latency=\(latencyMs)ms" + ) + } else { + syncMonitor.note( + "engagement: rejected cellEdit \(engagementID.uuidString) " + + "r=\(edit.row) c=\(edit.col) device=\(edit.deviceID.prefix(8)) " + + "latency=\(latencyMs)ms" + ) + } + case .cellEditBatch: + guard let edits = envelope.cellEdits, !edits.isEmpty else { + syncMonitor.note("engagement: ignored malformed cellEditBatch \(engagementID.uuidString)") + return + } + let applied = store.applyRealtimeCellEdits(edits) + syncMonitor.note( + "engagement: applied cellEditBatch \(engagementID.uuidString) " + + "applied=\(applied)/\(edits.count) device=\(edits[0].deviceID.prefix(8)) " + + "latency=\(latencyMs)ms" + ) + case .selection: + guard let selection = envelope.selection else { + syncMonitor.note("engagement: ignored malformed selection \(engagementID.uuidString)") + return + } + engagementStore.set(selection) + syncMonitor.note( + "engagement: received selection \(engagementID.uuidString) " + + "r=\(selection.row) c=\(selection.col) device=\(selection.deviceID.prefix(8)) " + + "latency=\(latencyMs)ms" + ) + } + } +} diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift @@ -0,0 +1,610 @@ +import CoreData +import CloudKit +import Foundation +import UIKit + +/// Owns the per-game play-session lifecycle that used to live in +/// `AppServices`: the begin/end grace timers and their background-execution +/// assertions, the sender-side session pushes (play / pause / win / resign / +/// replay) with their once-per-session announcement state, and the +/// receiver-side catch-up banner posted when a puzzle is opened. `AppServices` +/// composes one instance; `CrossmateApp`'s scene-phase and puzzle-lifecycle +/// handlers drive it directly. +@MainActor +final class SessionCoordinator { + /// Grace window before opening a puzzle is announced to peers as a play + /// session. A user who pops in and out — opens the wrong puzzle, peeks at + /// a shared grid, then leaves — within this window should fan out nothing. + /// The begin push is deferred by this much and cancelled if the user backs + /// out (app backgrounded or navigated away) before it elapses. + static let sessionBeginGrace: TimeInterval = 10 + /// Grace window before a backgrounded session is treated as ended. A + /// briefly-backgrounded puzzle (phone sleep, app switcher peek, taking a + /// call) should not fan out pause/play pings to peers on every flicker — + /// only a sustained absence does. + static let sessionEndGrace: TimeInterval = 30 + /// Settle delay before the catch-up banner is computed on open. Lets the + /// `.appeared` grid freshen land peer moves first, so the diff reflects the + /// settled grid rather than a half-synced snapshot; cancelled if the user + /// leaves before it elapses. + static let sessionSummaryBannerDelay: TimeInterval = 3 + + private let persistence: PersistenceController + private let store: GameStore + private let syncEngine: SyncEngine + private let syncMonitor: SyncMonitor + private let sessionMonitor: SessionMonitor + private let announcements: AnnouncementCenter + private let identity: AuthorIdentity + private let preferences: PlayerPreferences + private let pushClient: PushClient? + + private var pendingSessionBeginTasks: [UUID: Task<Void, Never>] = [:] + private var pendingSessionEndTasks: [UUID: Task<Void, Never>] = [:] + private var pendingSessionSummaryBannerTasks: [UUID: Task<Void, Never>] = [:] + /// Background-execution assertions keeping the matching grace timer alive + /// after the app is backgrounded. iOS grants only a limited budget (often + /// well under `sessionEndGrace`), so the assertion's expiration handler + /// fires the pause early rather than letting suspension drop it. Keyed by + /// game so a per-game timer owns exactly one assertion. + private var sessionEndBackgroundTasks: [UUID: UIBackgroundTaskIdentifier] = [:] + /// Per-game "session announced to peers" state machine driving the + /// once-per-session begin push; see `SessionAnnouncementLog`. + private var sessionAnnouncements = SessionAnnouncementLog() + + init( + persistence: PersistenceController, + store: GameStore, + syncEngine: SyncEngine, + syncMonitor: SyncMonitor, + sessionMonitor: SessionMonitor, + announcements: AnnouncementCenter, + identity: AuthorIdentity, + preferences: PlayerPreferences, + pushClient: PushClient? + ) { + self.persistence = persistence + self.store = store + self.syncEngine = syncEngine + self.syncMonitor = syncMonitor + self.sessionMonitor = sessionMonitor + self.announcements = announcements + self.identity = identity + self.preferences = preferences + self.pushClient = pushClient + } + + /// Completion fan-out, delivered through the push worker. Win sets + /// `completedAt`/`completedBy` on the local Game record; resign leaves + /// `completedBy` nil and reveals the remaining cells through the Moves + /// stream (peers' grids fill in once the Moves push lands). + func sendCompletionPings(gameID: UUID, resigned: Bool) async { + await publishCompletionPush(gameID: gameID, resigned: resigned) + await publishReplayPush(gameID: gameID) + } + + /// Defer the session-begin push by `seconds`, replacing any pending timer + /// for the same game. The opening device owns the notification timing, but + /// a brief visit shouldn't reach peers at all, so the "Alice is solving X" + /// push waits out `sessionBeginGrace`; backing out before it elapses (app + /// backgrounded, or the puzzle view disappearing) calls + /// `cancelPendingSessionBeginPush` to drop it silently. Unlike the pause + /// timer this holds no background assertion: the user is foregrounded and + /// looking at the puzzle for the whole window, and any departure cancels. + func scheduleSessionBeginPush(gameID: UUID, after seconds: TimeInterval) { + pendingSessionBeginTasks.removeValue(forKey: gameID)?.cancel() + pendingSessionBeginTasks[gameID] = Task { [weak self] in + try? await Task.sleep(for: .seconds(seconds)) + guard !Task.isCancelled, let self else { return } + self.pendingSessionBeginTasks.removeValue(forKey: gameID) + await self.publishSessionBeginPush(gameID: gameID) + } + } + + /// Drop a begin push still waiting out its grace window. Returns whether + /// one was pending — i.e. the session was never announced to peers — so + /// the caller can skip the matching pause push (there's no play to pause). + @discardableResult + func cancelPendingSessionBeginPush(gameID: UUID) -> Bool { + guard let task = pendingSessionBeginTasks.removeValue(forKey: gameID) else { + return false + } + task.cancel() + return true + } + + /// Sender-side session-begin push. Peers get "Alice is solving X" once the + /// open has outlasted `sessionBeginGrace` (see `scheduleSessionBeginPush`). + /// Kind is `play` because the user-facing event is "started playing", not + /// the durable share-acceptance fact "joined". + func publishSessionBeginPush(gameID: UUID) async { + guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { + syncMonitor.note("push(play): skipped (no authorID)") + return + } + // Already announced this session and no stop has been sent since, so + // we're inside the same continuous session (e.g. a brief background + // bounce). Re-announcing "is solving" here is the notification spam + // we're suppressing — the next genuine stop clears this. + guard sessionAnnouncements.shouldAnnounceBegin(gameID) else { + syncMonitor.note("push(play): skipped (session already announced)") + return + } + guard let pushClient else { + syncMonitor.note("push(play): skipped (no pushClient)") + return + } + let plan = pushPlan(for: gameID, excluding: localAuthorID) + guard !plan.recipients.isEmpty else { + syncMonitor.note("push(play): skipped (no recipients)") + return + } + // Opening a finished puzzle (review, share view) isn't a play + // session — peers don't need a "solving the puzzle" alert. Same for + // a shared game we no longer have access to: the owner already + // unshared or deleted it, and the worker would just bounce the push. + guard plan.completedAt == nil else { + syncMonitor.note("push(play): skipped (game completed)") + return + } + guard !plan.isAccessRevoked else { + syncMonitor.note("push(play): skipped (access revoked)") + return + } + let addressees = plan.recipients.compactMap { recipient in + recipient.pushAddress.map { + PushClient.Addressee(address: $0, payload: PushPayload(event: .play)) + } + } + guard !addressees.isEmpty else { + syncMonitor.note("push(play): skipped (no addressable recipients)") + return + } + let playerName = preferences.name.isEmpty ? "A player" : preferences.name + let puzzleSuffix = plan.title.isEmpty ? "the puzzle" : "the puzzle '\(plan.title)'" + await pushClient.publish( + kind: "play", + gameID: gameID, + addressees: addressees, + title: "Crossmate", + body: "\(playerName) is solving \(puzzleSuffix)" + ) + // Session is now announced to peers; a "play" won't fire again until a + // "pause" is actually sent (see `publishSessionEndPush`). + sessionAnnouncements.noteBeginAnnounced(gameID) + } + + /// Defer the session-end push by `seconds`. Cancels any previously + /// scheduled pause for the same game. If the user resumes within the + /// grace window, call `cancelPendingSessionEndPush` to drop the timer + /// and skip the matching session-begin push so peers don't get a + /// pause/play pair for a brief absence. The wall-clock at scheduling + /// time is passed through so the fire-time peer-device-active check has + /// a stable reference point for "did anyone other than me write to + /// Player during the grace window." + func scheduleSessionEndPush(gameID: UUID, after seconds: TimeInterval) { + let pauseStart = Date() + cancelPendingSessionEndPush(gameID: gameID) + // Hold a background-execution assertion so the grace timer keeps + // running once the app is backgrounded. If iOS is about to reclaim + // us before the timer elapses, the expiration handler fires the + // pause early (best effort) instead of letting suspension drop it. + sessionEndBackgroundTasks[gameID] = UIApplication.shared.beginBackgroundTask( + withName: "session-end-\(gameID.uuidString)" + ) { [weak self] in + self?.fireSessionEndPush(gameID: gameID, pauseStart: pauseStart, expedited: true) + } + pendingSessionEndTasks[gameID] = Task { [weak self] in + try? await Task.sleep(for: .seconds(seconds)) + guard !Task.isCancelled else { return } + self?.fireSessionEndPush(gameID: gameID, pauseStart: pauseStart, expedited: false) + } + } + + /// Single fire path for the deferred session-end push, shared by the grace + /// timer and the background-assertion expiration handler. The pending-task + /// entry doubles as a "not yet fired" flag, so this is idempotent: whichever + /// caller wins removes it, and the loser falls through to releasing the + /// assertion only. `expedited` marks the early fire forced by an imminent + /// suspension, purely for diagnostics. + private func fireSessionEndPush(gameID: UUID, pauseStart: Date, expedited: Bool) { + guard let task = pendingSessionEndTasks.removeValue(forKey: gameID) else { + endSessionEndBackgroundTask(gameID: gameID) + return + } + task.cancel() + if expedited { + syncMonitor.note("push(pause): firing early (background expiring)") + } + Task { [weak self] in + guard let self else { return } + await self.publishSessionEndPush(gameID: gameID, pauseStart: pauseStart) + self.endSessionEndBackgroundTask(gameID: gameID) + } + } + + /// Releases the background-execution assertion for `gameID`, if one is + /// held. Safe to call repeatedly — a missing entry is a no-op. + private func endSessionEndBackgroundTask(gameID: UUID) { + guard let id = sessionEndBackgroundTasks.removeValue(forKey: gameID), + id != .invalid else { return } + UIApplication.shared.endBackgroundTask(id) + } + + /// Cancel any pending scheduled session-end push for `gameID`. Returns + /// `true` if a pending task was dropped, i.e. the caller is inside the + /// grace window and should suppress the matching session-begin push. + @discardableResult + func cancelPendingSessionEndPush(gameID: UUID) -> Bool { + endSessionEndBackgroundTask(gameID: gameID) + guard let task = pendingSessionEndTasks.removeValue(forKey: gameID) else { + return false + } + task.cancel() + return true + } + + /// Sender-side session-end push. For each recipient, counts cells in + /// the author's merged-across-devices Moves whose `updatedAt` is newer + /// than that recipient's last-known `Player.readAt`, and ships a body + /// describing only what *that* recipient hasn't seen. Recipients whose + /// readAt already covers every author cell are dropped — they have + /// nothing unseen, so a banner-and-badge for them would be misleading. + /// + /// Suppresses the push when a peer device of this author wrote to + /// Player during the grace window — that device is still playing and + /// will publish its own pause when it stops. + func publishSessionEndPush(gameID: UUID, pauseStart: Date = Date()) async { + // A direct call (e.g. from `.onDisappear`) supersedes any pending + // grace-window timer for this game — drop it so we don't fire a + // second pause once the timer elapses. + pendingSessionEndTasks.removeValue(forKey: gameID)?.cancel() + guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { + syncMonitor.note("push(pause): skipped (no authorID)") + return + } + // During the grace window this device wrote nothing to Player + // (any local activity would have reset the timer via + // `cancelPendingSessionEndPush`). A Player `updatedAt` newer than + // pauseStart therefore came from another device of this author — + // that device is still active, so let its eventual pause cover + // the session. + if let updatedAt = store.playerUpdatedAt(for: gameID, by: localAuthorID), + updatedAt > pauseStart { + syncMonitor.note("push(pause): skipped (peer device active)") + return + } + guard let pushClient else { + syncMonitor.note("push(pause): skipped (no pushClient)") + return + } + let plan = pushPlan(for: gameID, excluding: localAuthorID) + guard !plan.recipients.isEmpty else { + syncMonitor.note("push(pause): skipped (no recipients)") + return + } + // Symmetric with `publishSessionBeginPush`: a finished or revoked + // game has no live play session, so a pause summary is meaningless. + guard plan.completedAt == nil else { + syncMonitor.note("push(pause): skipped (game completed)") + return + } + guard !plan.isAccessRevoked else { + syncMonitor.note("push(pause): skipped (access revoked)") + return + } + // The pause counts are derived from this device's own journal (gesture + // history), not the merged grid, so the summary can name fills/clears/ + // checks/reveals. The merged-grid measurements still ride the + // diagnostics block below for context. + let journalEntries = store.localJournalEntries(for: gameID) + // Sender-side diagnostics: store-derived measurements plus this + // device's clock and the session-start it announced. Rides the + // per-recipient payload (the planner stamps each recipient's readAt) + // so the receiver can log why the counts came out as they did. + var diagnostics = store.movesDiagnostics(for: gameID, by: localAuthorID) + ?? PushPayload.Diagnostics() + diagnostics.senderNow = Date() + diagnostics.sessionStart = sessionAnnouncements.beginTime(gameID) + // Caught-up recipients are *not* dropped: a session end is a presence + // signal worth delivering even with nothing unseen (see + // `SessionPushPlanner.sessionEndAddressees`). + let addressees = SessionPushPlanner.sessionEndAddressees( + recipients: plan.recipients, + journalEntries: journalEntries, + playerName: preferences.name, + puzzleTitle: plan.title, + diagnostics: diagnostics + ) + guard !addressees.isEmpty else { + syncMonitor.note("push(pause): skipped (no addressable recipients)") + return + } + // Top-level broadcast body is the worker's fallback if an addressee + // carries no per-recipient body. Under the new contract every + // addressee has one, but the field is still required. + let fallbackBody = PuzzleNotificationText.pauseBody( + playerName: preferences.name, + puzzleTitle: plan.title, + fills: 0, + clears: 0, + checks: 0, + reveals: 0 + ) + await pushClient.publish( + kind: "pause", + gameID: gameID, + addressees: addressees, + title: "Crossmate", + body: fallbackBody + ) + // Peers have now been told the session ended, so a fresh "play" is + // allowed again (see `publishSessionBeginPush`). + sessionAnnouncements.noteEndAnnounced(gameID) + // Advance each addressed recipient's notified-through watermark to the + // latest move this pause reported. A later pause windows its counts to + // the later of this and the recipient's readAt, so a bounce that adds + // no new move re-tallies to zero ("stopped solving") instead of + // repeating the same summary. Recipients we couldn't address (no push + // capability) keep their old watermark and catch up when reachable. + if let notifiedThrough = journalEntries.map(\.timestamp).max() { + let addressed = plan.recipients + .filter { $0.pushAddress != nil } + .map(\.authorID) + store.recordNotified(gameID: gameID, authorIDs: addressed, through: notifiedThrough) + } + } + + private func publishCompletionPush(gameID: UUID, resigned: Bool) async { + let kindLabel = resigned ? "resign" : "win" + guard let pushClient else { + syncMonitor.note("push(\(kindLabel)): skipped (no pushClient)") + return + } + guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { + syncMonitor.note("push(\(kindLabel)): skipped (no authorID)") + return + } + let plan = pushPlan(for: gameID, excluding: localAuthorID) + guard !plan.recipients.isEmpty else { + syncMonitor.note("push(\(kindLabel)): skipped (no recipients)") + return + } + let event: PushPayload.Event = resigned ? .resign : .win + let addressees = plan.recipients.compactMap { recipient in + recipient.pushAddress.map { + PushClient.Addressee(address: $0, payload: PushPayload(event: event)) + } + } + guard !addressees.isEmpty else { + syncMonitor.note("push(\(kindLabel)): skipped (no addressable recipients)") + return + } + let playerName = preferences.name.isEmpty ? "A player" : preferences.name + let puzzleSuffix = plan.title.isEmpty ? "the puzzle" : "the puzzle '\(plan.title)'" + let kind: String + let body: String + if resigned { + kind = "resign" + body = "\(playerName) resigned \(puzzleSuffix)." + } else { + kind = "win" + body = "\(playerName) solved \(puzzleSuffix)" + } + await pushClient.publish( + kind: kind, + gameID: gameID, + addressees: addressees, + title: "Crossmate", + body: body + ) + } + + private func publishReplayPush(gameID: UUID) async { + guard let pushClient else { + syncMonitor.note("push(replay): skipped (no pushClient)") + return + } + let plan = pushPlan(for: gameID) + guard !plan.recipients.isEmpty else { + syncMonitor.note("push(replay): skipped (no recipients)") + return + } + let addressees = plan.recipients.compactMap { recipient in + recipient.pushAddress.map { + PushClient.Addressee(address: $0, payload: PushPayload(event: .replay)) + } + } + guard !addressees.isEmpty else { + syncMonitor.note("push(replay): skipped (no addressable recipients)") + return + } + await pushClient.publish( + kind: "replay", + gameID: gameID, + addressees: addressees, + title: "", + body: "", + background: true + ) + } + + private struct PushPlan { + let recipients: [PushRecipient] + let title: String + let completedAt: Date? + let isAccessRevoked: Bool + + static let empty = PushPlan( + recipients: [], + title: "", + completedAt: nil, + isAccessRevoked: false + ) + } + + private func pushPlan( + for gameID: UUID, + excluding authorID: String? = nil + ) -> PushPlan { + let ctx = persistence.container.newBackgroundContext() + return ctx.performAndWait { + 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 { return .empty } + var byAuthor: [String: (readThrough: Date?, notifiedThrough: Date?, pushAddress: String?)] = [:] + let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + pReq.predicate = NSPredicate(format: "game == %@", game) + for p in (try? ctx.fetch(pReq)) ?? [] { + guard let a = p.authorID, + a != CKCurrentUserDefaultName, + !a.isEmpty + else { continue } + if let authorID, a == authorID { continue } + byAuthor[a] = (p.readThrough, p.notifiedThrough, p.pushAddress) + } + let recipients = byAuthor.map { + PushRecipient( + authorID: $0.key, + readThrough: $0.value.readThrough, + notifiedThrough: $0.value.notifiedThrough, + pushAddress: $0.value.pushAddress + ) + } + return PushPlan( + recipients: recipients, + title: PuzzleNotificationText.title(for: game), + completedAt: game.completedAt, + isAccessRevoked: game.isAccessRevoked + ) + } + } + + /// Hand-off called when the puzzle becomes active. Pulls any pending + /// session-end tallies out of SessionMonitor and posts them as a + /// transient announcement on the puzzle header, in lieu of the + /// local notification that would otherwise have fired in a few + /// minutes' time. No-op if nothing was accumulated. + func handlePuzzleOpened(gameID: UUID) { + logLocalPauseDiagnostics(for: gameID) + // Defer the banner so the open's `.appeared` grid freshen can land peer + // moves first; otherwise it would diff against a half-synced grid and + // under-report. The baseline is not touched here — it advances only on + // leave (`handlePuzzleLeft`) — so this is a pure read and re-running it + // on a later foreground is harmless. + scheduleSessionSummaryBanner(gameID: gameID, after: Self.sessionSummaryBannerDelay) + } + + /// Called when the user leaves the puzzle (backgrounded or navigated away). + /// Drops a still-pending banner timer and commits the per-peer baseline — + /// the user has now seen what's on screen, so the next open diffs against + /// this state — then ships that baseline to sibling devices on this + /// account's own `Player.sessionSnapshot`, so they adopt it rather than + /// recomputing from their own view. Returns the committed snapshots. + @discardableResult + func handlePuzzleLeft(gameID: UUID) -> [String: LocalMovesSnapshot] { + cancelPendingSessionSummaryBanner(gameID: gameID) + let committed = sessionMonitor.commitMovesBaseline(for: gameID) + guard let authorID = identity.currentID, !authorID.isEmpty, + !committed.isEmpty, + let data = try? JSONEncoder().encode(committed) + else { return committed } + // Write it onto our own Player record and enqueue the send. This also + // rides the leave's read-cursor Player write, but enqueuing directly + // guarantees it ships even when that write is a no-op. + store.setSessionSnapshot(data, gameID: gameID, authorID: authorID) + let syncEngine = self.syncEngine + // Leave-path Player write: enqueue durably but don't force a drain that + // would race the suspension budget — siblings adopt the snapshot on the + // next CKSyncEngine sync. + Task { await syncEngine.enqueuePlayer(gameID: gameID, authorID: authorID, reason: "sessionSnapshot", drain: false) } + return committed + } + + /// Defer the catch-up banner by `seconds`, replacing any pending timer for + /// the same game. Leaving the puzzle (`handlePuzzleLeft`) cancels it. + func scheduleSessionSummaryBanner(gameID: UUID, after seconds: TimeInterval) { + pendingSessionSummaryBannerTasks.removeValue(forKey: gameID)?.cancel() + pendingSessionSummaryBannerTasks[gameID] = Task { [weak self] in + try? await Task.sleep(for: .seconds(seconds)) + guard !Task.isCancelled, let self else { return } + self.pendingSessionSummaryBannerTasks.removeValue(forKey: gameID) + self.postSessionSummaryBanner(gameID: gameID, reason: "open") + } + } + + func cancelPendingSessionSummaryBanner(gameID: UUID) { + pendingSessionSummaryBannerTasks.removeValue(forKey: gameID)?.cancel() + } + + /// Computes the receiver-side catch-up summary for `gameID` and, when a peer + /// has unseen activity, posts (or replaces, by stable id) the "Puzzle + /// Updated" banner. Read-only — the baseline advances on leave, not here — + /// so it is safe to recompute on every foreground. Logs the per-peer counts + /// it surfaces so a missing or wrong banner is diagnosable after the fact. + func postSessionSummaryBanner(gameID: UUID, reason: String) { + let summaries = sessionMonitor.movesSummaries(for: gameID) + guard !summaries.isEmpty else { return } + let detail = summaries.map { summary -> String in + let who = summary.playerName.isEmpty + ? String(summary.authorID.prefix(8)) + : summary.playerName + return "\(who) +\(summary.added)/-\(summary.cleared)\(summary.isFirstObservation ? " first" : "")" + }.joined(separator: ", ") + syncMonitor.note( + "session summary[\(gameID.uuidString.prefix(8))] \(reason): \(detail)" + ) + announcements.post(Announcement( + id: "session-summary-\(gameID.uuidString)", + scope: .game(gameID), + severity: .info, + title: "Puzzle Updated", + body: Self.formatSummaryBanner(summaries), + dismissal: .transient(after: 6) + )) + } + + /// Logs this device's own view of each peer's Moves for `gameID`, using the + /// same `movesDiagnostics` computation the sender embeds in a pause push. + /// Pairs with the `pause-diagnostics` receipt the NSE records: a suspicious + /// pushed count can be diffed field-for-field against local ground truth. + /// Phantom cells that actually synced surface here too; ones that stayed + /// local to the sender (un-uploaded churn) won't — which is itself the + /// answer. `recipientReadAt` carries this device's *actual* cursor, to + /// compare against the value the peer's pushed diagnostics claimed it saw. + private func logLocalPauseDiagnostics(for gameID: UUID) { + let localAuthorID = identity.currentID + let selfReadAt = localAuthorID.flatMap { store.readAt(for: gameID, by: $0) } + for peerAuthorID in store.peerAuthorIDs(for: gameID, excluding: localAuthorID) { + guard var diagnostics = store.movesDiagnostics(for: gameID, by: peerAuthorID) + else { continue } + diagnostics.senderNow = Date() + diagnostics.recipientReadAt = selfReadAt + syncMonitor.note( + "local pause diag peer=\(peerAuthorID.prefix(8)): \(diagnostics.summaryLine)" + ) + } + } + + nonisolated static func formatSummaryBanner(_ summaries: [SessionMonitor.SessionSummary]) -> String { + guard !summaries.isEmpty else { return "" } + let phrases: [String] = summaries.map { summary in + let name = summary.playerName.isEmpty ? "A player" : summary.playerName + if summary.isFirstObservation { + let count = summary.added + return "\(name) added \(count) \(count == 1 ? "letter" : "letters") while you were away" + } + var parts: [String] = [] + if summary.added > 0 { + parts.append("added \(summary.added) \(summary.added == 1 ? "letter" : "letters")") + } + if summary.cleared > 0 { + parts.append("cleared \(summary.cleared) \(summary.cleared == 1 ? "letter" : "letters")") + } + let action = parts.isEmpty ? "made changes" : parts.joined(separator: " and ") + return "\(name) \(action)" + } + return "\(phrases.joined(separator: "; "))." + } +} diff --git a/Crossmate/Services/SessionPushPlanner.swift b/Crossmate/Services/SessionPushPlanner.swift @@ -40,6 +40,34 @@ struct SessionAnnouncementLog { } } +/// Everything a sender-side push helper needs to know about a game in +/// one Core Data round-trip: the roster authors to notify (each with the +/// last-known `Player.readAt` so the pause path can compute a +/// per-recipient diff), the puzzle's display title, and the gating flags +/// callers consult before emitting. +struct PushRecipient: Sendable, Equatable { + let authorID: String + /// The recipient's read watermark (`Player.readThrough`): the latest + /// other-author move time they've actually seen. The session-end tally + /// windows on this — never the forward-dated presence lease — so a peer + /// who was "present" (leased) but backgrounded before our moves still + /// gets a summary for what they missed. `nil` when they've recorded no + /// read yet, which tallies their whole backlog. + let readThrough: Date? + /// Sender-local watermark: the latest authored move we've already told + /// this recipient about via a previous pause. The session-end tally + /// windows on the later of this and `readAt`, so we never re-report a + /// move the recipient has already seen *or* already been notified of. + /// `nil` when we've never paused to them. Never synced — see + /// `PlayerEntity.notifiedThrough`. + let notifiedThrough: Date? + /// The recipient's per-(account, game) push capability, read off their + /// Player record. `nil` when they haven't published one yet (older + /// build, or not-yet-synced) — such a recipient can't be addressed and + /// is dropped from the push. + let pushAddress: String? +} + enum SessionPushPlanner { /// Builds the per-recipient addressees for a session-end push. Every /// addressable recipient is included — caught-up recipients too, with zero @@ -57,7 +85,7 @@ enum SessionPushPlanner { /// is described by *that* device's own pause; "eventual consistency is OK" /// covers the gap. static func sessionEndAddressees( - recipients: [AppServices.PushRecipient], + recipients: [PushRecipient], journalEntries: [JournalValue], playerName: String, puzzleTitle: String, diff --git a/Tests/Unit/SessionPushPlannerTests.swift b/Tests/Unit/SessionPushPlannerTests.swift @@ -74,8 +74,8 @@ struct SessionPushPlannerTests { _ address: String?, readThrough: Date?, notifiedThrough: Date? = nil - ) -> AppServices.PushRecipient { - AppServices.PushRecipient( + ) -> PushRecipient { + PushRecipient( authorID: "peer", readThrough: readThrough, notifiedThrough: notifiedThrough, diff --git a/Tests/Unit/Sync/AppServicesAnnouncementTests.swift b/Tests/Unit/Sync/AppServicesAnnouncementTests.swift @@ -4,7 +4,7 @@ import Testing @testable import Crossmate -@Suite("AppServices.formatSummaryBanner") +@Suite("SessionCoordinator.formatSummaryBanner") struct AppServicesAnnouncementTests { private let gameID = UUID() @@ -30,7 +30,7 @@ struct AppServicesAnnouncementTests { @Test("Single-author summary omits the puzzle suffix (the user is already in the puzzle)") func singleAuthor() { - let body = AppServices.formatSummaryBanner([ + let body = SessionCoordinator.formatSummaryBanner([ summary(author: "a", playerName: "Alice", added: 4), ]) #expect(body == "Alice added 4 letters.") @@ -38,7 +38,7 @@ struct AppServicesAnnouncementTests { @Test("Multi-author summary joins author phrases with '; '") func multipleAuthors() { - let body = AppServices.formatSummaryBanner([ + let body = SessionCoordinator.formatSummaryBanner([ summary(author: "a", playerName: "Alice", added: 4), summary(author: "b", playerName: "Bob", added: 1, cleared: 2), ]) @@ -47,7 +47,7 @@ struct AppServicesAnnouncementTests { @Test("Missing player name falls back to 'A player'") func fallbacks() { - let body = AppServices.formatSummaryBanner([ + let body = SessionCoordinator.formatSummaryBanner([ summary(author: "a", playerName: "", added: 3), ]) #expect(body == "A player added 3 letters.") @@ -55,7 +55,7 @@ struct AppServicesAnnouncementTests { @Test("First-observation summary uses away wording") func firstObservation() { - let body = AppServices.formatSummaryBanner([ + let body = SessionCoordinator.formatSummaryBanner([ summary(author: "a", playerName: "Alice", added: 4, isFirstObservation: true), ]) #expect(body == "Alice added 4 letters while you were away.")