crossmate

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

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:
MCrossmate.xcodeproj/project.pbxproj | 18++++++++++++++++++
MCrossmate/Models/PuzzleNotificationText.swift | 8++++----
MCrossmate/Services/AppServices.swift | 56++++++++++++++++++++++++++++++++++----------------------
MCrossmate/Services/PushClient.swift | 8+++++++-
ACrossmate/Services/SessionPushPlanner.swift | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MNotificationService/NotificationService.swift | 33++++++++++++++++++++++-----------
AShared/PushPayload.swift | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/PushPayloadTests.swift | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/SessionPushPlannerTests.swift | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MWorker/push-worker.js | 20+++++++++++++++-----
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";