commit 9d21b809d4771521ef6c4b4c6ff68cb299f036f0
parent 01e25da243c14f1bdde4ba58636af01007d085f7
Author: Michael Camilleri <[email protected]>
Date: Sun, 31 May 2026 14:07:50 +0900
Carry session-push semantics in an opaque payload
Two problems shared one root: the play and pause pushes encoded their
meaning in the top-level kind and a prebaked body string. The
notification service extension could therefore only badge off the coarse
kind, and the sender could only gate per-kind. Peers got 'is solving'
afresh on every background bounce, and the pause push — diffed per
recipient against readAt — dropped anyone already caught up, so a
collaborator who had seen every letter never learned the session ended.
This commit adds Shared/PushPayload, a small versioned Codable carrying
the structured event (play; pause with added/cleared counts; win;
resign) per recipient. PushClient encodes it alongside the body; the
worker forwards the blob verbatim without inspecting it, so notification
meaning can change without touching the worker. The extension decodes it
and marks a game unread only when the payload reports unseen content,
falling back to the old kind check when the field is absent so a
mixed-version rollout never misbadges. An unrecognised event decodes to
.unknown rather than throwing.
In addition, session end now reaches every addressable peer, not just
those with unseen cells: a caught-up recipient gets a presence-only
'stopped solving' body whose zero-count payload keeps it from bumping
the badge. Presence is no longer conflated with unread content.
The begin push fires once per session. SessionAnnouncementLog tracks
whether a game's session has been announced and clears only when a stop
is actually sent, so a brief background bounce that produced no pause no
longer re-announces 'is solving'. Keying off the sent stop rather than a
pending timer is what makes this hold even when a stop carries nothing
unseen. The per-recipient fan-out and the announce gate move into a pure
SessionPushPlanner, so both are unit-tested directly without standing up
the full service stack.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
10 files changed, 466 insertions(+), 43 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -14,6 +14,7 @@
02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */; };
04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */; };
06AE6DF7AA3274480C591E47 /* EngagementStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09D52DB46731E92C3E9297C /* EngagementStore.swift */; };
+ 07A46496EE0B12FD526F36FB /* SessionPushPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C040D5EBC73B1ED47C2C9D4 /* SessionPushPlannerTests.swift */; };
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 */; };
@@ -38,6 +39,7 @@
3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; };
3C54AE4AA04342CCF5705B20 /* PlayerNamePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */; };
40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */; };
+ 43E311FBD68B7D35A4D29743 /* PushPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2C9D3E7FCE2D42C5B7E3856 /* PushPayload.swift */; };
449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */; };
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; };
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; };
@@ -87,6 +89,7 @@
9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.swift */; };
A133A4B4A0C95AF8708BD7E6 /* PushClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */; };
A458AF9CA8579AB51B695B08 /* PendingChangeReapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */; };
+ A78FF09708EDED7ED50BB55B /* PushPayloadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E87E28DC9402A4369647DE50 /* PushPayloadTests.swift */; };
A7FA870D794CA00F7F3F05D2 /* EngagementHostEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400B3C87248F3FCA3F76400B /* EngagementHostEnvironment.swift */; };
A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */; };
AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */; };
@@ -96,6 +99,7 @@
AD50D3E3401C7BF9ED012768 /* AnnouncementBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61687BB3C75A4E1E6ABC82CA /* AnnouncementBanner.swift */; };
AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */; };
AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; };
+ B48DE7079BE2F31D2367C5F7 /* SessionPushPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA49DA9E0ADEB11A920787FA /* SessionPushPlanner.swift */; };
B5F78A55C9BCCD24E44D865F /* JournalReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27ECEA51DE42D07495744EF8 /* JournalReplay.swift */; };
B6AB531F4E0C4031B627C539 /* PlayerSelectionPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */; };
B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */; };
@@ -138,6 +142,7 @@
F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */; };
F5F333B36654AEAF69A3C220 /* MovesJournalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C92190C4A344EC319A0F88 /* MovesJournalTests.swift */; };
F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */; };
+ F8A4B3A1F9601654C60550B3 /* PushPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2C9D3E7FCE2D42C5B7E3856 /* PushPayload.swift */; };
F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */; };
FFBE2EC8A3A60E119A0D314F /* NYTBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */; };
/* End PBXBuildFile section */
@@ -248,6 +253,7 @@
88E8AACB638FE5724B534B41 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
89B1FFD2F90141EA949A8540 /* JournalReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalReplayTests.swift; sourceTree = "<group>"; };
8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushClient.swift; sourceTree = "<group>"; };
+ 8C040D5EBC73B1ED47C2C9D4 /* SessionPushPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPushPlannerTests.swift; sourceTree = "<group>"; };
8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStore.swift; sourceTree = "<group>"; };
8FDE03B4A77A8095ED2C23AB /* EngagementCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementCoordinator.swift; sourceTree = "<group>"; };
927186458ED03FD0C5660765 /* CrossmateModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CrossmateModel.xcdatamodel; sourceTree = "<group>"; };
@@ -284,8 +290,10 @@
BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverter.swift; sourceTree = "<group>"; };
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.swift; sourceTree = "<group>"; };
C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayCell.swift; sourceTree = "<group>"; };
+ C2C9D3E7FCE2D42C5B7E3856 /* PushPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayload.swift; sourceTree = "<group>"; };
C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverterTests.swift; sourceTree = "<group>"; };
C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationTextTests.swift; sourceTree = "<group>"; };
+ CA49DA9E0ADEB11A920787FA /* SessionPushPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPushPlanner.swift; sourceTree = "<group>"; };
CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; };
CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; };
CE54EF557E8D808BAA20EA54 /* NYTPuzzleUpgrader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleUpgrader.swift; sourceTree = "<group>"; };
@@ -300,6 +308,7 @@
E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordEditorView.swift; sourceTree = "<group>"; };
E655698481325C92EF5C348B /* FriendController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendController.swift; sourceTree = "<group>"; };
E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSource.swift; sourceTree = "<group>"; };
+ E87E28DC9402A4369647DE50 /* PushPayloadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayloadTests.swift; sourceTree = "<group>"; };
E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClueList.swift; sourceTree = "<group>"; };
EAC61E2582D94B1E6EC67136 /* XDFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDFileType.swift; sourceTree = "<group>"; };
ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthServiceTests.swift; sourceTree = "<group>"; };
@@ -386,11 +395,13 @@
1813630FA05C194AFF43855C /* PlayerRosterTests.swift */,
FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */,
46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */,
+ E87E28DC9402A4369647DE50 /* PushPayloadTests.swift */,
FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */,
B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */,
C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */,
443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */,
7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */,
+ 8C040D5EBC73B1ED47C2C9D4 /* SessionPushPlannerTests.swift */,
4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */,
ABB371EF2574E95782CB05FD /* Sync */,
);
@@ -495,6 +506,7 @@
isa = PBXGroup;
children = (
2D2FD896D75863554E31654C /* NotificationState.swift */,
+ C2C9D3E7FCE2D42C5B7E3856 /* PushPayload.swift */,
);
path = Shared;
sourceTree = "<group>";
@@ -563,6 +575,7 @@
71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */,
8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */,
B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */,
+ CA49DA9E0ADEB11A920787FA /* SessionPushPlanner.swift */,
);
path = Services;
sourceTree = "<group>";
@@ -688,6 +701,7 @@
files = (
7D4A56FBB1C5D5F89271B77F /* NotificationService.swift in Sources */,
88BACA1689459AC9AED20D43 /* NotificationState.swift in Sources */,
+ F8A4B3A1F9601654C60550B3 /* PushPayload.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -728,11 +742,13 @@
04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */,
309457EC2DFEC476253D54D2 /* PlayerSelectionPublisherTests.swift in Sources */,
00F2108848ADC7B4BF3AA0AE /* PlayerSessionNavigationTests.swift in Sources */,
+ A78FF09708EDED7ED50BB55B /* PushPayloadTests.swift in Sources */,
F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */,
7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */,
89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */,
ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */,
AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */,
+ 07A46496EE0B12FD526F36FB /* SessionPushPlannerTests.swift in Sources */,
BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */,
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */,
31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */,
@@ -812,6 +828,7 @@
8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */,
E354A588DBA74627A9CD5591 /* Presence.swift in Sources */,
A133A4B4A0C95AF8708BD7E6 /* PushClient.swift in Sources */,
+ 43E311FBD68B7D35A4D29743 /* PushPayload.swift in Sources */,
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */,
350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */,
40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */,
@@ -822,6 +839,7 @@
D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */,
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */,
5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */,
+ B48DE7079BE2F31D2367C5F7 /* SessionPushPlanner.swift in Sources */,
54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */,
BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */,
AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */,
diff --git a/Crossmate/Models/PuzzleNotificationText.swift b/Crossmate/Models/PuzzleNotificationText.swift
@@ -20,10 +20,10 @@ enum PuzzleNotificationText {
/// `added` / `cleared` counts describe cells *that recipient* hasn't
/// seen yet (cells in the author's merged-across-devices Moves whose
/// `updatedAt` is newer than that recipient's last-known
- /// `Player.readAt`). When both counts are zero the recipient should be
- /// dropped from the push entirely — the returned no-edits wording is a
- /// fallback used for the worker's top-level broadcast body, which is
- /// never the visible text under the per-recipient contract.
+ /// `Player.readAt`). When both counts are zero the recipient still gets
+ /// the push as a presence signal ("stopped solving") — the session end is
+ /// worth surfacing even with nothing unseen — but the payload's zero
+ /// counts keep it from bumping the badge.
static func pauseBody(
playerName: String,
puzzleTitle: String,
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -88,6 +88,9 @@ final class AppServices {
/// 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()
let shareController: ShareController
let friendController: FriendController
let cursorStore: GameCursorStore
@@ -691,6 +694,14 @@ final class AppServices {
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
@@ -713,7 +724,9 @@ final class AppServices {
return
}
let addressees = plan.recipients.compactMap { recipient in
- recipient.pushAddress.map { PushClient.Addressee(address: $0) }
+ recipient.pushAddress.map {
+ PushClient.Addressee(address: $0, payload: PushPayload(event: .play))
+ }
}
guard !addressees.isEmpty else {
syncMonitor.note("push(play): skipped (no addressable recipients)")
@@ -728,6 +741,9 @@ final class AppServices {
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
@@ -850,27 +866,17 @@ final class AppServices {
return
}
let mergedCells = store.mergedAuthorCells(for: gameID, by: localAuthorID)
- var addressees: [PushClient.Addressee] = []
- for recipient in plan.recipients {
- // No capability published yet → unaddressable, drop it.
- guard let address = recipient.pushAddress else { continue }
- let readAt = recipient.readAt ?? .distantPast
- var added = 0
- var cleared = 0
- for cell in mergedCells where cell.updatedAt > readAt {
- if cell.letter.isEmpty { cleared += 1 } else { added += 1 }
- }
- guard added + cleared > 0 else { continue }
- let body = PuzzleNotificationText.pauseBody(
- playerName: preferences.name,
- puzzleTitle: plan.title,
- added: added,
- cleared: cleared
- )
- addressees.append(PushClient.Addressee(address: address, body: body))
- }
+ // 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,
+ mergedCells: mergedCells,
+ playerName: preferences.name,
+ puzzleTitle: plan.title
+ )
guard !addressees.isEmpty else {
- syncMonitor.note("push(pause): skipped (all recipients up to date)")
+ syncMonitor.note("push(pause): skipped (no addressable recipients)")
return
}
// Top-level broadcast body is the worker's fallback if an addressee
@@ -889,6 +895,9 @@ final class AppServices {
title: "Crossmate",
body: fallbackBody
)
+ // Peers have now been told the session ended, so a fresh "play" is
+ // allowed again (see `publishSessionBeginPush`).
+ sessionAnnouncements.noteEndAnnounced(gameID)
}
private func publishCompletionPush(gameID: UUID, resigned: Bool) async {
@@ -906,8 +915,11 @@ final class AppServices {
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) }
+ recipient.pushAddress.map {
+ PushClient.Addressee(address: $0, payload: PushPayload(event: event))
+ }
}
guard !addressees.isEmpty else {
syncMonitor.note("push(\(kindLabel)): skipped (no addressable recipients)")
diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift
@@ -122,10 +122,15 @@ final class PushClient {
struct Addressee: Sendable, Equatable {
let address: String
let body: String?
+ /// Structured semantics for this recipient, encoded into the wire
+ /// `payload` field. The worker forwards it opaquely; the notification
+ /// service extension decodes it (e.g. to decide the badge).
+ let payload: PushPayload?
- init(address: String, body: String? = nil) {
+ init(address: String, body: String? = nil, payload: PushPayload? = nil) {
self.address = address
self.body = body
+ self.payload = payload
}
}
@@ -145,6 +150,7 @@ final class PushClient {
let addresseePayloads: [[String: Any]] = addressees.map { addressee in
var entry: [String: Any] = ["address": addressee.address]
if let body = addressee.body { entry["body"] = body }
+ if let payload = addressee.payload?.encodedString() { entry["payload"] = payload }
return entry
}
let payload: [String: Any] = [
diff --git a/Crossmate/Services/SessionPushPlanner.swift b/Crossmate/Services/SessionPushPlanner.swift
@@ -0,0 +1,70 @@
+import Foundation
+
+/// Pure decision logic for session presence pushes, factored out of
+/// `AppServices` so it can be unit-tested without standing up the full service
+/// stack. `AppServices` still owns the state and the network call; these types
+/// only decide *whether* and *what* to send.
+
+/// Per-game record of whether a live play session has been announced to peers
+/// (a "play" push went out) and not yet closed by a "pause" push.
+///
+/// The begin push consults this so "is solving" fires once per session: a
+/// brief background bounce that never produced a stop must not re-announce.
+/// State is keyed off the *sent stop* — `noteEndAnnounced` is called when a
+/// pause is actually published — not off a pending timer, so it stays accurate
+/// even when a stop carries no unseen content.
+struct SessionAnnouncementLog {
+ private var announced: Set<UUID> = []
+
+ /// True when a begin push should fire: this session isn't already announced.
+ func shouldAnnounceBegin(_ gameID: UUID) -> Bool {
+ !announced.contains(gameID)
+ }
+
+ /// Record that a "play" push has been sent for `gameID`.
+ mutating func noteBeginAnnounced(_ gameID: UUID) {
+ announced.insert(gameID)
+ }
+
+ /// Record that a "pause" push has been sent for `gameID`, re-arming the
+ /// next begin push.
+ mutating func noteEndAnnounced(_ gameID: UUID) {
+ announced.remove(gameID)
+ }
+}
+
+enum SessionPushPlanner {
+ /// Builds the per-recipient addressees for a session-end push. Every
+ /// addressable recipient is included — caught-up recipients too, with zero
+ /// counts — because a session end is a presence signal worth delivering
+ /// even when nothing changed. The per-recipient `PushPayload` counts let
+ /// the extension decide the badge; the body renders "stopped solving" at
+ /// zero. Recipients with no published push capability are dropped.
+ static func sessionEndAddressees(
+ recipients: [AppServices.PushRecipient],
+ mergedCells: [TimestampedCell],
+ playerName: String,
+ puzzleTitle: String
+ ) -> [PushClient.Addressee] {
+ recipients.compactMap { recipient -> PushClient.Addressee? in
+ guard let address = recipient.pushAddress else { return nil }
+ let readAt = recipient.readAt ?? .distantPast
+ var added = 0
+ var cleared = 0
+ for cell in mergedCells where cell.updatedAt > readAt {
+ if cell.letter.isEmpty { cleared += 1 } else { added += 1 }
+ }
+ let body = PuzzleNotificationText.pauseBody(
+ playerName: playerName,
+ puzzleTitle: puzzleTitle,
+ added: added,
+ cleared: cleared
+ )
+ return PushClient.Addressee(
+ address: address,
+ body: body,
+ payload: PushPayload(event: .pause(added: added, cleared: cleared))
+ )
+ }
+ }
+}
diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift
@@ -12,16 +12,16 @@ import UserNotifications
/// notification's `badge` field. Once the main app foregrounds it unions
/// Core Data ground truth into the same set and re-stamps.
///
-/// The sender does all gating before publishing — pause pushes are diffed
-/// per recipient against that recipient's last-known `Player.readAt`, and a
-/// recipient with no unseen cells is dropped from the addressee list
-/// entirely. Anything that reaches the NSE is therefore worth flagging:
-/// - `pause` / `win` / `resign` — bump the unread set for `gameID`. The
-/// set semantics make repeats idempotent (a pause followed by a win for
-/// the same game is one badge unit, not two).
-/// - `play` — presence ping; the grid hasn't changed yet, so there's
-/// nothing to mark unread. Stamp the current count to keep the badge in
-/// sync without growing it.
+/// Whether a push marks its game unread is decided from the per-recipient
+/// `PushPayload` the sender encodes (forwarded opaquely by the worker):
+/// - a `pause` with unseen cells, or a `win` / `resign` — mark `gameID`
+/// unread. The set semantics make repeats idempotent (a pause followed by
+/// a win for the same game is one badge unit, not two).
+/// - a `pause` with zero counts (a presence-only "stopped solving") or a
+/// `play` — presence only; the grid has nothing unseen for this recipient,
+/// so stamp the current count without growing it.
+/// When the payload is absent (an older sender, or the worker not yet
+/// forwarding it) we fall back to the coarse top-level `kind`.
final class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ((UNNotificationContent) -> Void)?
@@ -42,6 +42,7 @@ final class NotificationService: UNNotificationServiceExtension {
let userInfo = request.content.userInfo
let kind = userInfo["kind"] as? String
let gameID = (userInfo["gameID"] as? String).flatMap(UUID.init(uuidString:))
+ let payload = PushPayload.decode(from: userInfo["payload"] as? String)
VisibleNotificationReceiptLog.record(
body: bestAttemptContent.body,
@@ -51,7 +52,17 @@ final class NotificationService: UNNotificationServiceExtension {
updatedUserInfo["crossmateNSELogged"] = true
bestAttemptContent.userInfo = updatedUserInfo
- if let gameID, kind == "pause" || kind == "win" || kind == "resign" {
+ // Whether this push represents grid changes the recipient hasn't seen.
+ // Prefer the structured payload; fall back to the coarse `kind` when it
+ // is absent — an older sender, or the worker not yet forwarding it.
+ let marksUnread: Bool
+ if let payload {
+ marksUnread = payload.marksUnread
+ } else {
+ marksUnread = kind == "pause" || kind == "win" || kind == "resign"
+ }
+
+ if let gameID, marksUnread {
let count = BadgeState.markUnread(gameID: gameID)
bestAttemptContent.badge = NSNumber(value: count)
} else {
diff --git a/Shared/PushPayload.swift b/Shared/PushPayload.swift
@@ -0,0 +1,119 @@
+import Foundation
+
+/// Structured, app-defined semantics for a push, carried as an opaque
+/// base64-encoded JSON blob in the APNs `payload` userInfo field. Shared
+/// between the sender (the app) and the notification service extension; the
+/// push worker forwards it without inspecting it. Keeping the meaning here —
+/// not in the worker — is what lets notification behaviour change without a
+/// worker deploy.
+///
+/// Decoding is deliberately tolerant. A newer build may send an `Event` this
+/// build doesn't recognise; it decodes to `.unknown` rather than throwing, so
+/// a mixed-version rollout never drops a notification. A missing or
+/// unparseable field (an older sender, or the worker not yet forwarding it)
+/// is handled by the caller falling back to the coarse top-level `kind`.
+struct PushPayload: Codable, Sendable, Equatable {
+ /// Bumped only on a breaking shape change, so a future reader can gate
+ /// behaviour. The current schema is version 1.
+ static let currentVersion = 1
+
+ var version: Int
+ var event: Event
+
+ init(version: Int = PushPayload.currentVersion, event: Event) {
+ self.version = version
+ self.event = event
+ }
+
+ enum Event: Sendable, Equatable {
+ case play
+ case pause(added: Int, cleared: Int)
+ case win
+ case resign
+ /// An event introduced by a newer build. Treated as carrying no
+ /// unseen content for badge purposes.
+ case unknown
+
+ /// True when the event represents grid changes the recipient hasn't
+ /// seen — the sole input to whether a delivered push marks its game
+ /// unread (and so bumps the app-icon badge).
+ var marksUnread: Bool {
+ switch self {
+ case .pause(let added, let cleared): return added + cleared > 0
+ case .win, .resign: return true
+ case .play, .unknown: return false
+ }
+ }
+ }
+
+ /// Whether a push carrying this payload should mark its game unread.
+ var marksUnread: Bool { event.marksUnread }
+}
+
+extension PushPayload {
+ /// Base64-encoded JSON for the per-addressee `payload` field on the wire.
+ func encodedString() -> String? {
+ guard let data = try? JSONEncoder().encode(self) else { return nil }
+ return data.base64EncodedString()
+ }
+
+ /// Decodes the APNs `payload` userInfo field. Returns `nil` when the field
+ /// is absent or unparseable, leaving the caller to fall back to `kind`.
+ static func decode(from string: String?) -> PushPayload? {
+ guard let string,
+ let data = Data(base64Encoded: string),
+ let payload = try? JSONDecoder().decode(PushPayload.self, from: data)
+ else { return nil }
+ return payload
+ }
+}
+
+extension PushPayload.Event: Codable {
+ private enum CodingKeys: String, CodingKey {
+ case type, added, cleared
+ }
+
+ private enum Discriminator: String {
+ case play, pause, win, resign
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let raw = try container.decode(String.self, forKey: .type)
+ switch Discriminator(rawValue: raw) {
+ case .play:
+ self = .play
+ case .pause:
+ let added = try container.decodeIfPresent(Int.self, forKey: .added) ?? 0
+ let cleared = try container.decodeIfPresent(Int.self, forKey: .cleared) ?? 0
+ self = .pause(added: added, cleared: cleared)
+ case .win:
+ self = .win
+ case .resign:
+ self = .resign
+ case nil:
+ // A discriminator this build doesn't know — a newer sender.
+ self = .unknown
+ }
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ switch self {
+ case .play:
+ try container.encode(Discriminator.play.rawValue, forKey: .type)
+ case .pause(let added, let cleared):
+ try container.encode(Discriminator.pause.rawValue, forKey: .type)
+ try container.encode(added, forKey: .added)
+ try container.encode(cleared, forKey: .cleared)
+ case .win:
+ try container.encode(Discriminator.win.rawValue, forKey: .type)
+ case .resign:
+ try container.encode(Discriminator.resign.rawValue, forKey: .type)
+ case .unknown:
+ // Not produced as an outgoing event by this build; encode a stable
+ // marker so an `.unknown` round-trips back to `.unknown`.
+ try container.encode("unknown", forKey: .type)
+ }
+ }
+}
diff --git a/Tests/Unit/PushPayloadTests.swift b/Tests/Unit/PushPayloadTests.swift
@@ -0,0 +1,58 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("Push payload")
+struct PushPayloadTests {
+ private func roundTrip(_ payload: PushPayload) throws -> PushPayload {
+ let encoded = try #require(payload.encodedString())
+ return try #require(PushPayload.decode(from: encoded))
+ }
+
+ @Test("Events round-trip through the wire encoding")
+ func eventsRoundTrip() throws {
+ let cases: [PushPayload.Event] = [
+ .play,
+ .pause(added: 3, cleared: 2),
+ .pause(added: 0, cleared: 0),
+ .win,
+ .resign
+ ]
+ for event in cases {
+ let decoded = try roundTrip(PushPayload(event: event))
+ #expect(decoded == PushPayload(event: event))
+ }
+ }
+
+ @Test("An unrecognised event decodes to .unknown rather than failing")
+ func unknownEventTolerated() throws {
+ // Simulates a payload a newer build might send.
+ let json = #"{"version":2,"event":{"type":"sparkle","intensity":5}}"#
+ let encoded = Data(json.utf8).base64EncodedString()
+
+ let decoded = try #require(PushPayload.decode(from: encoded))
+
+ #expect(decoded.event == .unknown)
+ #expect(decoded.version == 2)
+ }
+
+ @Test("Absent or malformed payload decodes to nil")
+ func absentPayloadIsNil() {
+ #expect(PushPayload.decode(from: nil) == nil)
+ #expect(PushPayload.decode(from: "not base64 $$$") == nil)
+ #expect(PushPayload.decode(from: Data("plain text".utf8).base64EncodedString()) == nil)
+ }
+
+ @Test("Only unseen content marks a game unread")
+ func marksUnreadMatrix() {
+ #expect(PushPayload(event: .pause(added: 1, cleared: 0)).marksUnread)
+ #expect(PushPayload(event: .pause(added: 0, cleared: 2)).marksUnread)
+ #expect(PushPayload(event: .win).marksUnread)
+ #expect(PushPayload(event: .resign).marksUnread)
+
+ #expect(!PushPayload(event: .pause(added: 0, cleared: 0)).marksUnread)
+ #expect(!PushPayload(event: .play).marksUnread)
+ #expect(!PushPayload(event: .unknown).marksUnread)
+ }
+}
diff --git a/Tests/Unit/SessionPushPlannerTests.swift b/Tests/Unit/SessionPushPlannerTests.swift
@@ -0,0 +1,119 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("Session announcement log")
+struct SessionAnnouncementLogTests {
+ @Test("Begin fires once, then only again after a stop is recorded")
+ func beginGateCycle() {
+ var log = SessionAnnouncementLog()
+ let game = UUID()
+
+ #expect(log.shouldAnnounceBegin(game)) // fresh session announces
+ log.noteBeginAnnounced(game)
+ #expect(!log.shouldAnnounceBegin(game)) // re-entry within session: suppressed
+ log.noteEndAnnounced(game)
+ #expect(log.shouldAnnounceBegin(game)) // a stop was sent: re-armed
+ }
+
+ @Test("Games are tracked independently")
+ func independentPerGame() {
+ var log = SessionAnnouncementLog()
+ let a = UUID()
+ let b = UUID()
+
+ log.noteBeginAnnounced(a)
+
+ #expect(!log.shouldAnnounceBegin(a))
+ #expect(log.shouldAnnounceBegin(b))
+ }
+}
+
+@Suite("Session push planner")
+struct SessionPushPlannerTests {
+ private func cell(_ letter: String, at updatedAt: Date) -> TimestampedCell {
+ TimestampedCell(
+ letter: letter,
+ markKind: 0,
+ checkedRight: false,
+ checkedWrong: false,
+ updatedAt: updatedAt,
+ authorID: "author"
+ )
+ }
+
+ private func recipient(_ address: String?, readAt: Date?) -> AppServices.PushRecipient {
+ AppServices.PushRecipient(authorID: "peer", readAt: readAt, pushAddress: address)
+ }
+
+ @Test("A caught-up recipient is still addressed, with a presence-only payload")
+ func caughtUpRecipientIncluded() {
+ let edit = Date(timeIntervalSince1970: 1_000)
+ let cells = [cell("X", at: edit), cell("", at: edit)]
+ let seenEverything = recipient("addr-1", readAt: edit.addingTimeInterval(60))
+
+ let addressees = SessionPushPlanner.sessionEndAddressees(
+ recipients: [seenEverything],
+ mergedCells: cells,
+ playerName: "Bunny",
+ puzzleTitle: "Tuesday"
+ )
+
+ #expect(addressees.count == 1)
+ #expect(addressees[0].payload == PushPayload(event: .pause(added: 0, cleared: 0)))
+ #expect(addressees[0].payload?.marksUnread == false)
+ #expect(addressees[0].body == "Bunny stopped solving the puzzle 'Tuesday'.")
+ }
+
+ @Test("A behind recipient gets the unseen counts and a badge-marking payload")
+ func behindRecipientCounts() {
+ let edit = Date(timeIntervalSince1970: 1_000)
+ let cells = [cell("X", at: edit), cell("", at: edit)] // 1 added, 1 cleared
+ let behind = recipient("addr-2", readAt: edit.addingTimeInterval(-60))
+
+ let addressees = SessionPushPlanner.sessionEndAddressees(
+ recipients: [behind],
+ mergedCells: cells,
+ playerName: "Bunny",
+ puzzleTitle: "Tuesday"
+ )
+
+ #expect(addressees.count == 1)
+ #expect(addressees[0].payload == PushPayload(event: .pause(added: 1, cleared: 1)))
+ #expect(addressees[0].payload?.marksUnread == true)
+ }
+
+ @Test("Recipients without a push capability are dropped")
+ func unaddressableDropped() {
+ let edit = Date(timeIntervalSince1970: 1_000)
+ let addressees = SessionPushPlanner.sessionEndAddressees(
+ recipients: [recipient(nil, readAt: nil)],
+ mergedCells: [cell("X", at: edit)],
+ playerName: "Bunny",
+ puzzleTitle: "Tuesday"
+ )
+
+ #expect(addressees.isEmpty)
+ }
+
+ @Test("Caught-up and behind recipients are both addressed in one fan-out")
+ func mixedRecipientsAllIncluded() {
+ let edit = Date(timeIntervalSince1970: 1_000)
+ let cells = [cell("X", at: edit)]
+ let caughtUp = recipient("addr-caught-up", readAt: edit.addingTimeInterval(60))
+ let behind = recipient("addr-behind", readAt: edit.addingTimeInterval(-60))
+
+ let addressees = SessionPushPlanner.sessionEndAddressees(
+ recipients: [caughtUp, behind],
+ mergedCells: cells,
+ playerName: "Bunny",
+ puzzleTitle: "Tuesday"
+ )
+
+ let byAddress = Dictionary(uniqueKeysWithValues: addressees.map { ($0.address, $0) })
+ #expect(addressees.count == 2)
+ #expect(byAddress["addr-caught-up"]?.payload?.marksUnread == false)
+ #expect(byAddress["addr-behind"]?.payload?.marksUnread == true)
+ }
+}
diff --git a/Worker/push-worker.js b/Worker/push-worker.js
@@ -97,7 +97,8 @@ export class PushRegistry {
gameID,
fromAuthorID,
title,
- body: target.body || alertBody
+ body: target.body || alertBody,
+ payload: target.payload
});
if (result === "ok") delivered += 1;
else if (result === "drop") {
@@ -115,6 +116,11 @@ export class PushRegistry {
for (const addressee of addressees) {
if (!addressee || !addressee.address) continue;
const body = typeof addressee.body === "string" ? addressee.body : undefined;
+ // Opaque, app-encoded semantics (base64 JSON of `PushPayload`). The
+ // worker never inspects it — it just forwards it into the APNs userInfo
+ // so the notification service extension can decode it. Keeping it opaque
+ // is what lets the app evolve notification meaning without a worker deploy.
+ const payload = typeof addressee.payload === "string" ? addressee.payload : undefined;
const prefix = `addr:${addressee.address}:`;
const map = await this.state.storage.list({ prefix });
for (const [key, value] of map) {
@@ -123,6 +129,7 @@ export class PushRegistry {
address: addressee.address,
deviceID,
body,
+ payload,
...value
});
}
@@ -139,12 +146,15 @@ export class PushRegistry {
const alert = {};
if (message.title) alert.title = message.title;
if (message.body) alert.body = message.body;
- const payload = {
+ const apnsPayload = {
aps: { alert, sound: "default", "mutable-content": 1 },
kind: message.kind
};
- if (message.gameID) payload.gameID = message.gameID;
- if (message.fromAuthorID) payload.fromAuthorID = message.fromAuthorID;
+ if (message.gameID) apnsPayload.gameID = message.gameID;
+ if (message.fromAuthorID) apnsPayload.fromAuthorID = message.fromAuthorID;
+ // Forward the opaque app payload verbatim when present. Absent for older
+ // app builds, which the extension handles by falling back to `kind`.
+ if (message.payload) apnsPayload.payload = message.payload;
const response = await fetch(`https://${host}/3/device/${target.token}`, {
method: "POST",
@@ -156,7 +166,7 @@ export class PushRegistry {
"apns-expiration": "0",
"content-type": "application/json"
},
- body: JSON.stringify(payload)
+ body: JSON.stringify(apnsPayload)
});
if (response.status === 200) return "ok";