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:
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.")