crossmate

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

commit cbf3e003e957cb298273fd49604c40ab3f9841ca
parent 4d9520d40c0c704139e160efc51a808682e0e04e
Author: Michael Camilleri <[email protected]>
Date:   Wed, 27 May 2026 09:38:12 +0900

Move user-facing notifications to APNs via a Cloudflare Worker

This commit replaces the receiver-side local-notification scheduling for
win/session-start/session-end events with sender-formatted APNs pushes routed
through a second Cloudflare Worker (a sibling to the engagement Worker). The
prior design grew brittle: pings were unreliable for time-critical user-facing
events, and SessionMonitor had accumulated heuristics — presentBegins, a
3-minute quiescence-window scheduler, scheduled-end identifiers — to fake
real-time notification on top of an eventually-consistent transport. With APNs
in the loop the sender knows exactly when to fire and what to say, and the
receiver no longer needs to guess. PushClient registers (authorID, deviceID,
token, environment) with the Worker's PushRegistry Durable Object; AppServices
publishes win/resign/join/leave kinds at the natural fire sites. The Worker
signs APNs JWTs in-instance (cached ~40 min to stay clear of
TooManyProviderTokenUpdates), picks sandbox vs production per token, and fans
out to every device registered for an addressee. Strings are formatted on the
sender — Crossmate is English-only and standing up Localizable.strings just to
feed loc-key/loc-args wasn't considered worth it at this stage.

The .check / .reveal / .resign pings are gone entirely. Per-cell check state
now rides inside the Moves stream as a checked: CheckResult? on CellMark, so
collaborators see each other's check marks via the same record path that
already carries letters; resign reveals fan out via the existing reveal
mechanism. The on-disk encoding keeps two parallel checkedRight/checkedWrong
booleans (translation lives at the GameMutator/GameStore boundary) so Core Data
auto-migrates and the wire format stays additive — older clients decode new
records cleanly and just drop the new flag when re-encoding. SessionMonitor is
slimmed to what the on-open catch-up banner actually needs: bucket
accumulation, consumeOnOpen, cancel and bodyText. The 3-minute quiescence
timer, the SessionNotificationScheduling protocol, the begin/end identifier
helpers, and the notificationCenter/notificationAuthorization constructor
params all go with it. PlayerSession.onPlayerEvent and its six fire sites are
removed — anyone reintroducing peer-visible per-action notifications would need
to wire a new path.

The Worker bearer baked into the binary is the only abuse gate, which matches
the existing comfort level for a non-adversarial user base; rotation via
wrangler secret is the fallback if it leaks. Push registration is
fire-and-fetch-error — the Worker is idempotent, so the next launch's APNs
callback naturally retries. publishSessionEndPush runs from a Task at
scenePhase background and relies on iOS's ~5–10s suspension grace; if drops
appear in the field, switch to BGTaskScheduler or a background URLSession. The
.friend, .invite, .hail, and invite-accept .join pings remain — they are
friendship/sharing handshakes, not user-facing event notifications, and don't
benefit from the APNs path. No CloudKit Dashboard changes are required: the new
checkedRight boolean rides inside the opaque Moves blob.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 16++++++++++++++++
MCrossmate/CrossmateApp.swift | 29++++++++++++++++++-----------
MCrossmate/Info.plist | 4++++
MCrossmate/Models/CellMark.swift | 30+++++++++++++++++-------------
ACrossmate/Models/CheckResult.swift | 9+++++++++
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 1+
MCrossmate/Models/Game.swift | 29++++++++++++++++-------------
MCrossmate/Models/PlayerSession.swift | 12------------
MCrossmate/Persistence/GameMutator.swift | 22++++++++++++++--------
MCrossmate/Persistence/GameStore.swift | 31+++++++++++++++++++++++++------
MCrossmate/Services/AppServices.swift | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
ACrossmate/Services/LocalSessionTracker.swift | 38++++++++++++++++++++++++++++++++++++++
ACrossmate/Services/PushClient.swift | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/CloudZones.swift | 14+++++++-------
MCrossmate/Sync/FriendController.swift | 1-
MCrossmate/Sync/GridStateMerger.swift | 1+
MCrossmate/Sync/Moves.swift | 41+++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/MovesUpdater.swift | 5+++++
MCrossmate/Sync/Presence.swift | 70+++++++++++-----------------------------------------------------------
MCrossmate/Sync/RecordBuilder.swift | 1-
MCrossmate/Sync/RecordSerializer.swift | 19+++++--------------
MCrossmate/Sync/SessionMonitor.swift | 261++++++++-----------------------------------------------------------------------
MCrossmate/Sync/SyncEngine.swift | 23++++++++++-------------
MScripts/select-simulator.sh | 0
AScripts/wrangle-push.sh | 7+++++++
MTests/Unit/GameMutatorTests.swift | 15++++++++-------
MTests/Unit/GameStoreUnreadMovesTests.swift | 3+++
MTests/Unit/GridStateMergerTests.swift | 10+++++-----
MTests/Unit/MovesUpdaterTests.swift | 28++++++++++++++--------------
MTests/Unit/PendingEditFlagTests.swift | 1+
MTests/Unit/PuzzleNotificationTextTests.swift | 51++++++---------------------------------------------
MTests/Unit/RecordSerializerMovesTests.swift | 4++--
MTests/Unit/RecordSerializerTests.swift | 41+++++------------------------------------
MTests/Unit/Sync/AuthorDeltaTests.swift | 4++--
MTests/Unit/Sync/EngagementCoordinatorTests.swift | 2+-
MTests/Unit/Sync/MovesInboundTests.swift | 26+++++++++++++-------------
MTests/Unit/Sync/PendingChangeReapTests.swift | 3+--
MTests/Unit/Sync/SessionMonitorTests.swift | 274+++++--------------------------------------------------------------------------
MTests/Unit/XDAcceptTests.swift | 2+-
AWorker/push-worker.js | 272+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AWorker/wrangler.push.toml | 16++++++++++++++++
Mproject.yml | 4++++
42 files changed, 951 insertions(+), 833 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; }; 5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */; }; 5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */; }; + 5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */; }; 6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B766E872B12DC79ECCD80941 /* FriendModelTests.swift */; }; 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; }; 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; }; @@ -75,6 +76,7 @@ 978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */; }; 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; }; 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 */; }; A7FA870D794CA00F7F3F05D2 /* EngagementHostEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400B3C87248F3FCA3F76400B /* EngagementHostEnvironment.swift */; }; A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */; }; @@ -123,6 +125,7 @@ ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */; }; F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */; }; F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */; }; + F63FCB29A7E9EE5208ED2C7E /* LocalSessionTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19C3ED4479869ADC66808C7D /* LocalSessionTracker.swift */; }; F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */; }; F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */; }; FFBE2EC8A3A60E119A0D314F /* NYTBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */; }; @@ -145,6 +148,7 @@ 0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; }; 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; }; 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMonitor.swift; sourceTree = "<group>"; }; + 0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckResult.swift; sourceTree = "<group>"; }; 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisher.swift; sourceTree = "<group>"; }; 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridStateMerger.swift; sourceTree = "<group>"; }; 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; }; @@ -152,6 +156,7 @@ 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebuggingMonitors.swift; sourceTree = "<group>"; }; 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRosterTests.swift; sourceTree = "<group>"; }; 18C701DAE36000DE19F7CC95 /* EngagementHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementHost.swift; sourceTree = "<group>"; }; + 19C3ED4479869ADC66808C7D /* LocalSessionTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalSessionTracker.swift; sourceTree = "<group>"; }; 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementCenter.swift; sourceTree = "<group>"; }; 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; }; 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; @@ -201,6 +206,7 @@ 800CCFBE90554F287E765755 /* FriendZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendZoneTests.swift; sourceTree = "<group>"; }; 86470163BFF956F3DE438506 /* Moves.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Moves.swift; sourceTree = "<group>"; }; 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportedBrowseView.swift; sourceTree = "<group>"; }; + 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushClient.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>"; }; @@ -346,6 +352,7 @@ isa = PBXGroup; children = ( B135C285570F91181595B405 /* CellMark.swift */, + 0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */, B09D52DB46731E92C3E9297C /* EngagementStore.swift */, 465F2BB469EFE84CF3733398 /* Game.swift */, 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */, @@ -484,11 +491,13 @@ 462CE0FD356F6137C9BFD30F /* ImportService.swift */, 6BDD06460A76D4AF31077732 /* InputMonitor.swift */, 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */, + 19C3ED4479869ADC66808C7D /* LocalSessionTracker.swift */, A253416F4FEA271A80B22A73 /* NYTAuthService.swift */, B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */, CE54EF557E8D808BAA20EA54 /* NYTPuzzleUpgrader.swift */, BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */, 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */, + 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */, B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */, ); path = Services; @@ -642,6 +651,7 @@ C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */, 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */, CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */, + 5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */, E15A40AA623B60279E8DDF43 /* CloudDiagnostics.swift in Sources */, 1016604FBD4D63A0B9AAE503 /* CloudQuery.swift in Sources */, CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */, @@ -675,6 +685,7 @@ 1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */, F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */, 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */, + F63FCB29A7E9EE5208ED2C7E /* LocalSessionTracker.swift in Sources */, 91703E54DB4679C1911BF994 /* Moves.swift in Sources */, 4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */, 1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */, @@ -695,6 +706,7 @@ B6AB531F4E0C4031B627C539 /* PlayerSelectionPublisher.swift in Sources */, 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */, E354A588DBA74627A9CD5591 /* Presence.swift in Sources */, + A133A4B4A0C95AF8708BD7E6 /* PushClient.swift in Sources */, 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */, 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */, 40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */, @@ -835,6 +847,8 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CROSSMATE_ENGAGEMENT_SOCKET_URL = "$(inherited)"; + CROSSMATE_PUSH_BASE_URL = "$(inherited)"; + CROSSMATE_PUSH_BEARER = "$(inherited)"; INFOPLIST_FILE = Crossmate/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -855,6 +869,8 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CROSSMATE_ENGAGEMENT_SOCKET_URL = "$(inherited)"; + CROSSMATE_PUSH_BASE_URL = "$(inherited)"; + CROSSMATE_PUSH_BEARER = "$(inherited)"; INFOPLIST_FILE = Crossmate/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -71,6 +71,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser /// channel) is visible rather than silently degrading sync to the /// CKSyncEngine poll cadence. var onAPNsRegistrationResult: ((String) -> Void)? + /// Delivers the raw APNs token to `PushClient` so it can register with the + /// Crossmate push worker. Fires on every successful APNs registration — + /// the worker dedupes unchanged triples server-side. + var onAPNsToken: ((Data) -> Void)? func application( _ application: UIApplication, @@ -88,6 +92,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser let hex = deviceToken.map { String(format: "%02x", $0) }.joined() let prefix = hex.prefix(12) onAPNsRegistrationResult?("APNs registered token=\(prefix)… (\(deviceToken.count) bytes)") + onAPNsToken?(deviceToken) } func application( @@ -588,6 +593,10 @@ private struct PuzzleDisplayView: View { if let burstScope { await syncEngine.endPlayerSendBurst(scope: burstScope) } + // Backgrounding may have already drained the tracker; the + // skip-if-zero guard inside publishSessionEndPush keeps the + // close-after-background case from firing a second push. + await services.publishSessionEndPush(gameID: id) } } } @@ -627,15 +636,19 @@ private struct PuzzleDisplayView: View { } private func updateActiveNotificationPuzzleID(for phase: ScenePhase) { + let id = gameID if phase == .active { NotificationState.setActivePuzzleID(gameID) // Hand any pending end-of-session tallies off to the // in-puzzle banner instead of letting the local notification // fire a few minutes from now. - let id = gameID - Task { await services.handlePuzzleOpened(gameID: id) } + Task { + await services.handlePuzzleOpened(gameID: id) + await services.publishSessionStartPush(gameID: id) + } } else { NotificationState.clearActivePuzzleID(if: gameID) + Task { await services.publishSessionEndPush(gameID: id) } } } @@ -689,15 +702,9 @@ private struct PuzzleDisplayView: View { await services.noteLocalSelection(selection, gameID: eventGameID) } } - session.onPlayerEvent = { kind, scope in - Task { - await services.sendPlayerEventPings( - kind: kind, - scope: scope, - gameID: eventGameID - ) - } - } + // check/reveal no longer ping peers; cell state propagates through + // Moves (the `checkedRight`/`checkedWrong` flags + revealed mark do + // the work). } } diff --git a/Crossmate/Info.plist b/Crossmate/Info.plist @@ -40,6 +40,10 @@ <true/> <key>CrossmateEngagementSocketURL</key> <string>$(CROSSMATE_ENGAGEMENT_SOCKET_URL)</string> + <key>CrossmatePushBaseURL</key> + <string>$(CROSSMATE_PUSH_BASE_URL)</string> + <key>CrossmatePushBearer</key> + <string>$(CROSSMATE_PUSH_BEARER)</string> <key>ITSAppUsesNonExemptEncryption</key> <false/> <key>LSRequiresIPhoneOS</key> diff --git a/Crossmate/Models/CellMark.swift b/Crossmate/Models/CellMark.swift @@ -4,19 +4,20 @@ import Foundation /// `Game.entries`; `CellMark` describes how that letter (or the absence of /// one) should be interpreted and rendered. /// -/// Cases model two orthogonal dimensions — whether the entry is pen or pencil, -/// and whether a check has flagged it as wrong — plus `revealed`, which is a -/// distinct, locked state. `.none` is the canonical "no mark" value; we never -/// store `.pen(checkedWrong: false)` because it's semantically equivalent to -/// `.none`. `.pencil(checkedWrong: false)` is a real state (a tentative entry -/// that hasn't been checked) and is preserved. +/// `.pen` / `.pencil` carry an optional check result that's orthogonal to +/// pen-vs-pencil styling: `nil` means the cell hasn't been checked, `.right` +/// means a check confirmed the entry, `.wrong` means a check flagged it. +/// `.revealed` is a distinct locked state. `.none` is the canonical "no +/// mark" value; we never store `.pen(checked: nil)` because it's +/// semantically equivalent to `.none`. `.pencil(checked: nil)` is a real +/// state (a tentative entry that hasn't been checked) and is preserved. /// /// All marks are shared state — they live on `Game` alongside `entries` so -/// both players in a collaborative session see the same marks. +/// every player in a collaborative session sees the same marks. enum CellMark: Sendable, Equatable { case none - case pen(checkedWrong: Bool) - case pencil(checkedWrong: Bool) + case pen(checked: CheckResult?) + case pencil(checked: CheckResult?) case revealed var isRevealed: Bool { @@ -29,12 +30,15 @@ enum CellMark: Sendable, Equatable { return false } - var isCheckedWrong: Bool { + var checked: CheckResult? { switch self { - case .pen(let wrong), .pencil(let wrong): - return wrong + case .pen(let result), .pencil(let result): + return result case .none, .revealed: - return false + return nil } } + + var isCheckedRight: Bool { checked == .right } + var isCheckedWrong: Bool { checked == .wrong } } diff --git a/Crossmate/Models/CheckResult.swift b/Crossmate/Models/CheckResult.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Outcome of a check action on a single cell. `nil` (an optional +/// `CheckResult?`) is the canonical "unchecked" value — there's no `.none` +/// case because the absence of a check is already representable. +enum CheckResult: Sendable, Equatable, Codable { + case right + case wrong +} diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -62,6 +62,7 @@ </fetchIndex> </entity> <entity name="CellEntity" representedClassName="CellEntity" syncable="YES" codeGenerationType="class"> + <attribute name="checkedRight" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="checkedWrong" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="ckRecordName" optional="YES" attributeType="String"/> <attribute name="ckSystemFields" optional="YES" attributeType="Binary"/> diff --git a/Crossmate/Models/Game.swift b/Crossmate/Models/Game.swift @@ -29,11 +29,12 @@ final class Game { // MARK: - Letter writes /// Writes `letter` into `(row, col)`. If `pencil` is true the cell is - /// marked `.pencil(checkedWrong: false)`; otherwise the mark is cleared - /// to `.none`. Revealed cells are locked — writes are silently ignored. - /// When the new letter matches the existing entry, `letterAuthorID` is - /// preserved — typing over an existing answer (common when filling a - /// crossing word) shouldn't reattribute the square to the second typist. + /// marked `.pencil` with both check flags cleared; otherwise the mark is + /// cleared to `.none`. Revealed cells are locked — writes are silently + /// ignored. When the new letter matches the existing entry, + /// `letterAuthorID` is preserved — typing over an existing answer + /// (common when filling a crossing word) shouldn't reattribute the + /// square to the second typist. func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool, authorID: String? = nil) { let cell = puzzle.cells[row][col] guard !cell.isBlock else { return } @@ -41,7 +42,7 @@ final class Game { let oldEntry = squares[row][col].entry let newEntry = letter.uppercased() squares[row][col].entry = newEntry - squares[row][col].mark = pencil ? .pencil(checkedWrong: false) : .none + squares[row][col].mark = pencil ? .pencil(checked: nil) : .none if newEntry != oldEntry { squares[row][col].letterAuthorID = authorID } @@ -63,10 +64,12 @@ final class Game { // MARK: - Check / Reveal / Clear /// For each non-empty, non-revealed target cell, compares the current - /// entry against the solution. Wrong entries get `checkedWrong = true` - /// (preserving pen-vs-pencil style); correct entries have the wrong mark - /// cleared and are promoted out of pencil mode. Empty cells and cells - /// without a known solution are skipped. + /// entry against the solution. Wrong entries get `checkedWrong = true`; + /// correct entries get `checkedRight = true` (was previously cleared to + /// `.none` — the new flag carries the verification through so peers can + /// see what their partner has already checked in a co-solving session). + /// Pen-vs-pencil style is preserved across the check. Empty cells and + /// cells without a known solution are skipped. func checkCells(_ cells: [Puzzle.Cell]) { for cell in cells { guard !cell.isBlock else { continue } @@ -75,12 +78,12 @@ final class Game { guard !entry.isEmpty else { continue } guard !squares[cell.row][cell.col].mark.isRevealed else { continue } - let isWrong = !cell.accepts(entry) + let result: CheckResult = cell.accepts(entry) ? .right : .wrong switch squares[cell.row][cell.col].mark { case .pencil: - squares[cell.row][cell.col].mark = isWrong ? .pencil(checkedWrong: true) : .none + squares[cell.row][cell.col].mark = .pencil(checked: result) case .none, .pen: - squares[cell.row][cell.col].mark = isWrong ? .pen(checkedWrong: true) : .none + squares[cell.row][cell.col].mark = .pen(checked: result) case .revealed: break // unreachable; guarded above } diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift @@ -51,12 +51,6 @@ final class PlayerSession { @ObservationIgnored var onCompletionStateChanged: ((Game.CompletionState) -> Void)? - /// Optional sink fired for collaborator-visible events (check, reveal, win) - /// so the puzzle screen can enqueue a CloudKit ping for other participants. - /// Set only for shared games with iCloud sync enabled; nil for solo games. - @ObservationIgnored - var onPlayerEvent: ((PingKind, PingScope?) -> Void)? - /// Rebus mode lets the player type a multi-character value into a single /// cell (e.g. "STAR" or "♥"). While active, keyboard input accumulates in /// `rebusBuffer` rather than going straight to `Game.squares`; on commit @@ -258,36 +252,30 @@ final class PlayerSession { let cell = puzzle.cells[selectedRow][selectedCol] guard !cell.isBlock else { return } mutator.checkCells([cell]) - onPlayerEvent?(.check, .square) } func checkCurrentWord() { mutator.checkCells(currentWordCells()) - onPlayerEvent?(.check, .word) } func checkPuzzle() { mutator.checkCells(puzzle.cells.flatMap { $0 }) - onPlayerEvent?(.check, .puzzle) } func revealSquare() { let cell = puzzle.cells[selectedRow][selectedCol] guard !cell.isBlock else { return } mutator.revealCells([cell]) - onPlayerEvent?(.reveal, .square) publishTerminalCompletionState() } func revealCurrentWord() { mutator.revealCells(currentWordCells()) - onPlayerEvent?(.reveal, .word) publishTerminalCompletionState() } func revealPuzzle() { mutator.revealCells(puzzle.cells.flatMap { $0 }) - onPlayerEvent?(.reveal, .puzzle) publishTerminalCompletionState() } diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift @@ -98,7 +98,7 @@ final class GameMutator { private func emitMove(atRow row: Int, atCol col: Int) { guard let movesUpdater, !isAccessRevoked else { return } let square = game.squares[row][col] - let (markKind, checkedWrong) = encodeMark(square.mark) + let (markKind, checkedRight, checkedWrong) = encodeMark(square.mark) let id = gameID let letter = square.entry // The cell's `letterAuthorID` is the canonical author for the square — @@ -124,6 +124,7 @@ final class GameMutator { col: col, letter: letter, markKind: markKind, + checkedRight: checkedRight, checkedWrong: checkedWrong, updatedAt: enqueuedAt, cellAuthorID: cellAuthorID @@ -135,6 +136,7 @@ final class GameMutator { row: row, col: col, letter: letter, markKind: markKind, + checkedRight: checkedRight, checkedWrong: checkedWrong, authorID: cellAuthorID, actingAuthorID: actingAuthorID, @@ -143,16 +145,20 @@ final class GameMutator { } } - private func encodeMark(_ mark: CellMark) -> (kind: Int16, checkedWrong: Bool) { + /// Flattens `CellMark` into the (kind, checkedRight, checkedWrong) triple + /// that the Moves wire format and Core Data persistence store. The pair + /// of booleans is the on-disk encoding of an optional `CheckResult`: + /// (false, false) = nil, (true, false) = .right, (false, true) = .wrong. + private func encodeMark(_ mark: CellMark) -> (kind: Int16, checkedRight: Bool, checkedWrong: Bool) { switch mark { case .none: - return (0, false) - case .pen(let wrong): - return (1, wrong) - case .pencil(let wrong): - return (2, wrong) + return (0, false, false) + case .pen(let check): + return (1, check == .right, check == .wrong) + case .pencil(let check): + return (2, check == .right, check == .wrong) case .revealed: - return (3, false) + return (3, false, false) } } } diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -393,6 +393,7 @@ final class GameStore { let incoming = TimestampedCell( letter: edit.letter, markKind: edit.markKind, + checkedRight: edit.checkedRight, checkedWrong: edit.checkedWrong, updatedAt: edit.updatedAt, authorID: edit.cellAuthorID @@ -591,8 +592,8 @@ final class GameStore { guard let entity = try context.fetch(request).first, entity.completedAt == nil else { return } entity.completedAt = Date() - // A win: stamp the solver so the Game record can distinguish wins from - // resignations and drive the directed `.win` ping's body. + // A win: stamp the solver so the Game record can distinguish wins + // from resignations and the completion APN body can name them. entity.completedBy = authorIDProvider() entity.hasPendingSave = true try context.save() @@ -874,7 +875,11 @@ final class GameStore { game.squares[r][c].enqueuedAt = nil } game.squares[r][c].entry = cell.letter - game.squares[r][c].mark = decodeMark(kind: cell.markKind, checkedWrong: cell.checkedWrong) + game.squares[r][c].mark = decodeMark( + kind: cell.markKind, + checkedRight: cell.checkedRight, + checkedWrong: cell.checkedWrong + ) game.squares[r][c].letterAuthorID = cell.authorID } game.recomputeCompletionCache() @@ -934,6 +939,7 @@ final class GameStore { } ce.letter = cell.letter ce.markKind = cell.markKind + ce.checkedRight = cell.checkedRight ce.checkedWrong = cell.checkedWrong ce.letterAuthorID = cell.authorID } @@ -941,6 +947,7 @@ final class GameStore { for (position, ce) in existing where grid[position] == nil { ce.letter = "" ce.markKind = 0 + ce.checkedRight = false ce.checkedWrong = false ce.letterAuthorID = nil } @@ -1032,10 +1039,22 @@ final class GameStore { // MARK: - CellMark coding - private func decodeMark(kind: Int16, checkedWrong: Bool) -> CellMark { + /// Inverse of `GameMutator.encodeMark`. The (checkedRight, checkedWrong) + /// pair on disk maps to an optional `CheckResult`; `checkedWrong` takes + /// precedence if both somehow ended up true (shouldn't happen, but the + /// invariant is enforced at the construction sites in `Game`, not here). + private func decodeMark(kind: Int16, checkedRight: Bool, checkedWrong: Bool) -> CellMark { + let check: CheckResult? + if checkedWrong { + check = .wrong + } else if checkedRight { + check = .right + } else { + check = nil + } switch kind { - case 1: return .pen(checkedWrong: checkedWrong) - case 2: return .pencil(checkedWrong: checkedWrong) + case 1: return .pen(checked: check) + case 2: return .pencil(checked: check) case 3: return .revealed default: return .none } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -56,6 +56,8 @@ final class AppServices { let announcements: AnnouncementCenter let playerSelectionPublisher: PlayerSelectionPublisher let identity: AuthorIdentity + let pushClient: PushClient? + let localSessionTracker = LocalSessionTracker() let shareController: ShareController let friendController: FriendController let cursorStore: GameCursorStore @@ -147,6 +149,11 @@ final class AppServices { self.inputMonitor = InputMonitor() let identity = AuthorIdentity() self.identity = identity + let pushSyncMonitor = self.syncMonitor + self.pushClient = PushClient(log: { message in + Task { @MainActor in pushSyncMonitor.note(message) } + }) + self.pushClient?.updateAuthorID(identity.currentID) let movesUpdater = MovesUpdater( debounceInterval: .milliseconds(500), @@ -275,9 +282,12 @@ final class AppServices { self?.handleEngagementEvent(event) } self.store.onLocalCellEdit = { [weak self] edit in - guard let self, - self.engagementStatus.isLive(gameID: edit.gameID) - else { return } + guard let self else { return } + self.localSessionTracker.noteEdit( + gameID: edit.gameID, + emptied: edit.letter.isEmpty + ) + guard self.engagementStatus.isLive(gameID: edit.gameID) else { return } Task { await self.engagementCoordinator.sendCellEdit(edit) } } } @@ -305,6 +315,9 @@ final class AppServices { appDelegate.onAPNsRegistrationResult = { [syncMonitor] message in syncMonitor.note(message) } + appDelegate.onAPNsToken = { [weak self] data in + Task { @MainActor in self?.pushClient?.updateAPNsToken(data) } + } CloudShareAcceptanceBroker.shared.onAcceptShare = { metadata in await self.enqueueShareAcceptance(metadata) } @@ -377,6 +390,7 @@ final class AppServices { await syncEngine.setOnAccountChange { [weak self] in guard let self else { return } await self.identity.refresh(using: self.ckContainer) + self.pushClient?.updateAuthorID(self.identity.currentID) } await syncEngine.setOnGameAccessRevoked { [store, sessionMonitor, announcements] gameID in @@ -437,7 +451,7 @@ final class AppServices { // a collaboration. Mirrors the owner path in `onShareSaved` so the // app is in Settings > Notifications before any inbound moves. await AppDelegate.requestNotificationAuthorizationIfNeeded() - await self.enqueueDirectedPings(kind: .join, scope: nil, gameID: gameID) + await self.enqueueDirectedPings(kind: .join, gameID: gameID) } // PlayerNamePublisher fans out name changes to active shared/joined @@ -534,7 +548,6 @@ final class AppServices { /// sends nothing; the lost notice is an accepted eventual-consistency cost. private func enqueueDirectedPings( kind: PingKind, - scope: PingScope?, gameID: UUID ) async { guard preferences.isICloudSyncEnabled, @@ -567,7 +580,6 @@ final class AppServices { for recipient in recipients { await syncEngine.enqueuePing( kind: kind, - scope: scope, gameID: gameID, authorID: localAuthorID, playerName: playerName, @@ -576,21 +588,120 @@ final class AppServices { } } - /// Completion fan-out: `.win` on a solve, `.resign` when the player gave - /// up. The completing device has already written `completedAt`/ - /// `completedBy` to its own Game record. + /// 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 enqueueDirectedPings( - kind: resigned ? .resign : .win, - scope: nil, - gameID: gameID + await publishCompletionPush(gameID: gameID, resigned: resigned) + } + + /// Sender-side session-start push. Replaces the receiver-side + /// `SessionMonitor.presentBegins(...)` path: the leaving/joining device + /// owns the notification timing, so peers get "Alice is solving X" the + /// instant Alice opens the puzzle. Also resets the local edit tracker so + /// the matching leave push counts only this segment. + func publishSessionStartPush(gameID: UUID) async { + localSessionTracker.begin(gameID: gameID) + guard let pushClient, + let localAuthorID = identity.currentID, !localAuthorID.isEmpty + else { return } + let lookup = recipientsAndTitle(forGameID: gameID, excluding: localAuthorID) + guard !lookup.recipients.isEmpty else { return } + let playerName = preferences.name.isEmpty ? "A player" : preferences.name + let puzzleSuffix = lookup.title.isEmpty ? "the puzzle" : "the puzzle '\(lookup.title)'" + await pushClient.publish( + kind: "join", + gameID: gameID, + addressees: lookup.recipients, + title: "Crossmate", + body: "\(playerName) is solving \(puzzleSuffix)" + ) + } + + /// Sender-side session-end push. Drains the local edit tracker, formats + /// the same summary text the receiver-side quiescence path used to build + /// (`SessionMonitor.bodyText`), and ships it. A session with no edits + /// (opened and closed without typing, or already drained by a prior + /// background-publish) skips the push entirely. + func publishSessionEndPush(gameID: UUID) async { + let counts = localSessionTracker.consume(gameID: gameID) + guard counts.added > 0 || counts.cleared > 0 else { return } + guard let pushClient, + let localAuthorID = identity.currentID, !localAuthorID.isEmpty + else { return } + let lookup = recipientsAndTitle(forGameID: gameID, excluding: localAuthorID) + guard !lookup.recipients.isEmpty else { return } + let playerName = preferences.name.isEmpty ? "A player" : preferences.name + let puzzleSuffix = lookup.title.isEmpty ? "the puzzle" : "the puzzle '\(lookup.title)'" + var parts: [String] = [] + if counts.added > 0 { + parts.append("added \(counts.added) \(counts.added == 1 ? "letter" : "letters")") + } + if counts.cleared > 0 { + parts.append("cleared \(counts.cleared) \(counts.cleared == 1 ? "letter" : "letters")") + } + let action = parts.joined(separator: " and ") + await pushClient.publish( + kind: "leave", + gameID: gameID, + addressees: lookup.recipients, + title: "Crossmate", + body: "\(playerName) \(action) in \(puzzleSuffix)" ) } - /// Activity fan-out for `.join`/`.check`/`.reveal` — same directed, - /// self-cleaning model as completion. - func sendPlayerEventPings(kind: PingKind, scope: PingScope?, gameID: UUID) async { - await enqueueDirectedPings(kind: kind, scope: scope, gameID: gameID) + private func publishCompletionPush(gameID: UUID, resigned: Bool) async { + guard let pushClient, + let localAuthorID = identity.currentID, !localAuthorID.isEmpty + else { return } + let lookup = recipientsAndTitle(forGameID: gameID, excluding: localAuthorID) + guard !lookup.recipients.isEmpty else { return } + let playerName = preferences.name.isEmpty ? "A player" : preferences.name + let puzzleSuffix = lookup.title.isEmpty ? "the puzzle" : "the puzzle '\(lookup.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: lookup.recipients, + title: "Crossmate", + body: body + ) + } + + /// One Core Data round-trip to fetch both pieces every push needs: who to + /// notify (other roster authors, excluding the local user and the + /// CKShare placeholder author) and the puzzle's display title. + private func recipientsAndTitle( + forGameID gameID: UUID, + excluding localAuthorID: String + ) -> (recipients: [String], title: String) { + 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 ([], "") } + var authors = Set<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 else { continue } + authors.insert(a) + } + authors.remove(localAuthorID) + authors.remove(CKCurrentUserDefaultName) + authors.remove("") + return (Array(authors), PuzzleNotificationText.title(for: game)) + } } private func sendHailPing(gameID: UUID, payload: String, addressee: String) async { @@ -601,7 +712,6 @@ final class AppServices { guard await ensureICloudSyncStarted() else { return } await syncEngine.enqueuePing( kind: .hail, - scope: nil, gameID: gameID, authorID: localAuthorID, playerName: preferences.name, @@ -1085,9 +1195,12 @@ final class AppServices { try await syncEngine.pushChanges() } } - for note in await sessionMonitor.presentBegins(result.1) { - syncMonitor.note(note) - } + // Session-start notifications now ride on sender-side APNs + // (see `publishSessionStartPush`); the receiver-side + // `presentBegins` path is no longer wired up. The catch-up + // banner that summarises peer adds/clears still consumes the + // SessionMonitor buckets via `consumeOnOpen` — see + // `handlePuzzleOpened`. } scheduleBackgroundPushCatchUp(scope: scope) await refreshSnapshot() @@ -1301,6 +1414,7 @@ final class AppServices { let task = Task { @MainActor in await identity.refresh(using: ckContainer) + pushClient?.updateAuthorID(identity.currentID) await syncEngine.start() syncStarted = true @@ -1617,13 +1731,6 @@ final class AppServices { private func presentPings(_ pings: [Ping]) async { let pings = claimPingsForHandling(pings) guard !pings.isEmpty else { return } - // Cancel any pending end-of-session summary that a stronger signal - // (the author solved or gave up) is about to supersede. Runs before - // notification authorization is checked so the cancel happens even - // when the system-only `.friend` ping path skips presentation. - for ping in pings where ping.kind == .win || ping.kind == .resign { - await sessionMonitor.cancel(gameID: ping.gameID, authorID: ping.authorID) - } applyInvitePings(pings) // `.friend` is the friendship-bootstrap handshake and `.hail` is // engagement room bootstrap. Both are system-only: no alert, no @@ -1736,33 +1843,13 @@ final class AppServices { switch ping.kind { case .join: return "\(player) joined \(puzzleSuffix)" - case .win: - return "\(player) solved \(puzzleSuffix)" - case .resign: - return "\(player) gave up on \(puzzleSuffix)" - case .check: - switch ping.scope { - case .square: return "\(player) checked a square in \(puzzleSuffix)" - case .word: return "\(player) checked a word in \(puzzleSuffix)" - case .puzzle: return "\(player) checked all of \(puzzleSuffix)" - case .none: return "\(player) checked \(puzzleSuffix)" - } - case .reveal: - switch ping.scope { - case .square: return "\(player) revealed a square in \(puzzleSuffix)" - case .word: return "\(player) revealed a word in \(puzzleSuffix)" - case .puzzle, .none: return "\(player) revealed \(puzzleSuffix)" - } - case .friend: - // System-only kind handled by the friendship-bootstrap path; - // never presented as a notification. If this text surfaces in - // a log or alert, `presentPings` dispatch has broken. - return "system-only ping should not be presented" case .invite: return "\(player) invited you to \(puzzleSuffix)" - case .hail: - // System-only kind handled by the engagement path; never - // presented as a notification. + case .friend, .hail: + // System-only kinds handled by the friendship-bootstrap / + // engagement paths; never presented as a notification. If this + // text surfaces in a log or alert, `presentPings` dispatch has + // broken. return "system-only ping should not be presented" } } diff --git a/Crossmate/Services/LocalSessionTracker.swift b/Crossmate/Services/LocalSessionTracker.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Per-session running tally of the local player's adds and clears, used to +/// build the body text of the leave APN. Reset on every `begin(gameID:)` so a +/// background/foreground cycle on the same puzzle starts each segment from +/// zero — the prior segment's counts already shipped in its own leave push. +@MainActor +final class LocalSessionTracker { + private(set) var activeGameID: UUID? + private var added: Int = 0 + private var cleared: Int = 0 + + func begin(gameID: UUID) { + activeGameID = gameID + added = 0 + cleared = 0 + } + + func noteEdit(gameID: UUID, emptied: Bool) { + guard gameID == activeGameID else { return } + if emptied { + cleared += 1 + } else { + added += 1 + } + } + + /// Returns the running tally for `gameID` and resets the counters. A nil + /// or mismatched `gameID` returns zero counts and leaves state untouched, + /// so a stray end-call from a stale view doesn't drain a fresh session. + func consume(gameID: UUID) -> (added: Int, cleared: Int) { + guard gameID == activeGameID else { return (0, 0) } + let result = (added, cleared) + added = 0 + cleared = 0 + return result + } +} diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift @@ -0,0 +1,171 @@ +import Foundation + +/// Uploads this device's APNs token to the Crossmate push worker and keeps +/// the registration in sync with the current iCloud authorID. The worker +/// itself is idempotent — re-posting an unchanged triple is a no-op — so the +/// only client-side state is a small dedup cache to avoid redundant network +/// chatter on every cold launch. +@MainActor +final class PushClient { + enum Environment: String { + case sandbox + case production + } + + private let baseURL: URL + private let bearer: String + private let deviceID: String + private let environment: Environment + private let session: URLSession + private let log: (String) -> Void + + private var apnsToken: String? + private var authorID: String? + private var lastRegistered: Registration? + + private struct Registration: Equatable { + let authorID: String + let deviceID: String + let token: String + } + + /// `nil` when the worker isn't configured (e.g. a fresh checkout without a + /// `Local.xcconfig`). The rest of the app treats a nil PushClient as "push + /// notifications are disabled" rather than crashing. + init?( + deviceID: String = RecordSerializer.localDeviceID, + session: URLSession = .shared, + log: @escaping (String) -> Void = { _ in } + ) { + guard + let rawBase = Bundle.main.object(forInfoDictionaryKey: "CrossmatePushBaseURL") as? String, + case let trimmedBase = rawBase.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmedBase.isEmpty, + let base = URL(string: trimmedBase), + let bearer = Bundle.main.object(forInfoDictionaryKey: "CrossmatePushBearer") as? String, + !bearer.isEmpty + else { return nil } + self.baseURL = base + self.bearer = bearer + self.deviceID = deviceID + self.session = session + self.log = log + #if DEBUG + self.environment = .sandbox + #else + self.environment = .production + #endif + } + + func updateAPNsToken(_ data: Data) { + let hex = data.map { String(format: "%02x", $0) }.joined() + if hex == apnsToken { return } + apnsToken = hex + Task { await reconcile() } + } + + func updateAuthorID(_ newAuthorID: String?) { + let normalized = newAuthorID?.trimmingCharacters(in: .whitespaces) + let next = (normalized?.isEmpty == false) ? normalized : nil + if next == authorID { return } + // Account switch: drop the previous (authorID, deviceID) so the worker + // stops pushing to the wrong identity on this device. + if let previous = authorID, previous != next { + Task { await unregister(authorID: previous) } + lastRegistered = nil + } + authorID = next + Task { await reconcile() } + } + + private func reconcile() async { + guard let authorID, let apnsToken else { return } + let triple = Registration(authorID: authorID, deviceID: deviceID, token: apnsToken) + if lastRegistered == triple { return } + do { + try await register(triple) + lastRegistered = triple + } catch { + // Worker is idempotent; the next token delivery or authorID change + // will retry. Bubble the failure into diagnostics rather than + // surfacing a user-facing error. + log("Push register failed: \(error.localizedDescription)") + } + } + + /// Fire-and-forget publish. The worker fans the push out to every + /// addressee author's registered devices. Failures are logged but never + /// surfaced — pushes are advisory and the next event will retry the + /// underlying state on its own. + func publish( + kind: String, + gameID: UUID, + addressees: [String], + title: String, + body: String + ) async { + guard !addressees.isEmpty else { return } + let payload: [String: Any] = [ + "kind": kind, + "gameID": gameID.uuidString, + "fromAuthorID": authorID ?? "", + "title": title, + "alertBody": body, + "addressees": addressees.map { ["authorID": $0] } + ] + var request = URLRequest(url: baseURL.appendingPathComponent("publish")) + request.httpMethod = "POST" + applyAuth(&request) + do { + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + let (_, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw URLError(.badServerResponse) + } + } catch { + log("Push publish (\(kind)) failed: \(error.localizedDescription)") + } + } + + private func register(_ triple: Registration) async throws { + var request = URLRequest(url: baseURL.appendingPathComponent("register")) + request.httpMethod = "POST" + applyAuth(&request) + let body: [String: String] = [ + "authorID": triple.authorID, + "deviceID": triple.deviceID, + "token": triple.token, + "environment": environment.rawValue + ] + request.httpBody = try JSONEncoder().encode(body) + let (_, response) = try await session.data(for: request) + try assert204(response) + } + + private func unregister(authorID: String) async { + var request = URLRequest(url: baseURL.appendingPathComponent("register")) + request.httpMethod = "DELETE" + applyAuth(&request) + request.httpBody = try? JSONEncoder().encode([ + "authorID": authorID, + "deviceID": deviceID + ]) + do { + let (_, response) = try await session.data(for: request) + try assert204(response) + } catch { + log("Push unregister failed: \(error.localizedDescription)") + } + } + + private func applyAuth(_ request: inout URLRequest) { + request.setValue("Bearer \(bearer)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + private func assert204(_ response: URLResponse) throws { + guard let http = response as? HTTPURLResponse, http.statusCode == 204 else { + throw URLError(.badServerResponse) + } + } +} diff --git a/Crossmate/Sync/CloudZones.swift b/Crossmate/Sync/CloudZones.swift @@ -55,13 +55,13 @@ extension SyncEngine { // // `onlyIncomplete` additionally drops finished puzzles' game // zones (keeps only completedAt == nil). Only the ping fast path - // passes it: that path is a - // latency shortcut, and a completed puzzle has no live - // collaboration, so a late .win/.opened there still arrives via - // CKSyncEngine's own push-driven fetchedRecordZoneChanges, which - // surfaces Ping records for every tracked zone regardless of - // completion. It must stay opt-in — discoverNewZonesDirect diffs - // the *full* known set against the server to spot new zones, so + // passes it: that path is a latency shortcut, and a completed + // puzzle has no live collaboration, so a late `.invite`/`.hail` + // ping there still arrives via CKSyncEngine's own push-driven + // fetchedRecordZoneChanges, which surfaces Ping records for + // every tracked zone regardless of completion. It must stay + // opt-in — discoverNewZonesDirect diffs the *full* known set + // against the server to spot new zones, so // excluding completed games there would make every finished zone // look new and re-pull it on each discovery. The account and // friend zones below are appended unconditionally: they carry diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -123,7 +123,6 @@ final class FriendController { ) await syncEngine.enqueuePing( kind: .friend, - scope: nil, gameID: viaGameID, authorID: localAuthorID, playerName: localDisplayName ?? "", diff --git a/Crossmate/Sync/GridStateMerger.swift b/Crossmate/Sync/GridStateMerger.swift @@ -15,6 +15,7 @@ enum GridStateMerger { grid[position] = GridCell( letter: winner.cell.letter, markKind: winner.cell.markKind, + checkedRight: winner.cell.checkedRight, checkedWrong: winner.cell.checkedWrong, authorID: winner.cell.authorID ) diff --git a/Crossmate/Sync/Moves.swift b/Crossmate/Sync/Moves.swift @@ -12,6 +12,7 @@ struct GridPosition: Hashable, Sendable, Codable { struct GridCell: Equatable, Sendable, Codable { var letter: String var markKind: Int16 + var checkedRight: Bool var checkedWrong: Bool var authorID: String? } @@ -40,6 +41,7 @@ struct MovesValue: Equatable, Sendable { struct TimestampedCell: Equatable, Sendable { var letter: String var markKind: Int16 + var checkedRight: Bool var checkedWrong: Bool var updatedAt: Date var authorID: String? @@ -53,6 +55,7 @@ struct RealtimeCellEdit: Codable, Equatable, Sendable { var col: Int var letter: String var markKind: Int16 + var checkedRight: Bool var checkedWrong: Bool var updatedAt: Date var cellAuthorID: String? @@ -62,15 +65,51 @@ enum MovesCodec { /// Wire format for `MovesValue.cells`. Each entry's `authorID` is the /// preserved cell-level author — distinct from the parent record's /// authorID, which identifies the iCloud user who wrote the record. + /// `checkedRight` was added after the initial release; the custom + /// `init(from:)` defaults it to `false` so records written by older + /// clients still decode cleanly. struct Payload: Codable, Equatable { struct Entry: Codable, Equatable { let row: Int let col: Int let letter: String let markKind: Int16 + let checkedRight: Bool let checkedWrong: Bool let updatedAt: Date let authorID: String? + + init( + row: Int, + col: Int, + letter: String, + markKind: Int16, + checkedRight: Bool, + checkedWrong: Bool, + updatedAt: Date, + authorID: String? + ) { + self.row = row + self.col = col + self.letter = letter + self.markKind = markKind + self.checkedRight = checkedRight + self.checkedWrong = checkedWrong + self.updatedAt = updatedAt + self.authorID = authorID + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + row = try c.decode(Int.self, forKey: .row) + col = try c.decode(Int.self, forKey: .col) + letter = try c.decode(String.self, forKey: .letter) + markKind = try c.decode(Int16.self, forKey: .markKind) + checkedWrong = try c.decode(Bool.self, forKey: .checkedWrong) + checkedRight = (try? c.decode(Bool.self, forKey: .checkedRight)) ?? false + updatedAt = try c.decode(Date.self, forKey: .updatedAt) + authorID = try? c.decode(String.self, forKey: .authorID) + } } let entries: [Entry] } @@ -83,6 +122,7 @@ enum MovesCodec { col: position.col, letter: cell.letter, markKind: cell.markKind, + checkedRight: cell.checkedRight, checkedWrong: cell.checkedWrong, updatedAt: cell.updatedAt, authorID: cell.authorID @@ -102,6 +142,7 @@ enum MovesCodec { cells[position] = TimestampedCell( letter: entry.letter, markKind: entry.markKind, + checkedRight: entry.checkedRight, checkedWrong: entry.checkedWrong, updatedAt: entry.updatedAt, authorID: entry.authorID diff --git a/Crossmate/Sync/MovesUpdater.swift b/Crossmate/Sync/MovesUpdater.swift @@ -27,6 +27,7 @@ actor MovesUpdater { private struct Pending { var letter: String var markKind: Int16 + var checkedRight: Bool var checkedWrong: Bool var authorID: String? var enqueuedAt: Date @@ -68,6 +69,7 @@ actor MovesUpdater { col: Int, letter: String, markKind: Int16, + checkedRight: Bool, checkedWrong: Bool, authorID: String?, actingAuthorID: String? = nil, @@ -77,6 +79,7 @@ actor MovesUpdater { buffer[key] = Pending( letter: letter, markKind: markKind, + checkedRight: checkedRight, checkedWrong: checkedWrong, authorID: authorID, enqueuedAt: enqueuedAt @@ -174,6 +177,7 @@ actor MovesUpdater { let newCell = TimestampedCell( letter: pending.letter, markKind: pending.markKind, + checkedRight: pending.checkedRight, checkedWrong: pending.checkedWrong, updatedAt: pending.enqueuedAt, authorID: pending.authorID @@ -286,6 +290,7 @@ actor MovesUpdater { } cell.letter = pending.letter cell.markKind = pending.markKind + cell.checkedRight = pending.checkedRight cell.checkedWrong = pending.checkedWrong cell.letterAuthorID = pending.authorID } diff --git a/Crossmate/Sync/Presence.swift b/Crossmate/Sync/Presence.swift @@ -2,18 +2,14 @@ import CloudKit import Foundation /// What a Ping record represents. Stored as a string in the CKRecord's -/// `kind` field. Every kind flows between players in a per-game zone (or, for -/// `.invite`, in a friend zone); there is no longer an own-devices presence -/// kind — `Player.readAt` carries cross-device read horizon state instead. +/// `kind` field. Pings now only cover bootstrap/side-channel events that +/// don't need APN reliability — the user-facing play events (win/resign/ +/// check/reveal/session-start/session-end) all ride on the push worker. enum PingKind: String, Sendable { + /// Collaborator just accepted a share invite. Written into the joining + /// device's view of the game zone after a successful CKShare accept. + /// Surfaces as a "joined" announcement on the inviter's device. case join - case win - /// The completing player gave up (revealed the grid). Directed, like - /// `.win`: one per other roster player via `addressee`. The Game record's - /// `completedBy` stays nil so resigned games stay distinguishable. - case resign - case check - case reveal /// Friendship bootstrap. Written into a shared *game* zone; carries the /// friend-zone share URL in `payload`. System-only — never user-facing. case friend @@ -26,25 +22,6 @@ enum PingKind: String, Sendable { case hail } -/// Granularity of a check/reveal action; nil for kinds where it doesn't apply. -/// -/// LEGACY SCHEMA NOTE: this used to be its own `Ping.scope` CKRecord field, -/// predating the generic `payload` slot. It now rides in `payload` as -/// `{"scope":"…"}` like every other kind-specific datum. The old `Ping.scope` -/// CKRecord field is DEAD: no code writes or reads it. It still exists in the -/// deployed production CloudKit schema only because CloudKit cannot delete -/// fields from a deployed type. -/// -/// ┌─────────────────────────────────────────────────────────────────┐ -/// │ When the CloudKit container/schema is next rebuilt clean, do NOT │ -/// │ recreate the `Ping.scope` field. It is intentionally gone. │ -/// └─────────────────────────────────────────────────────────────────┘ -enum PingScope: String, Sendable { - case square - case word - case puzzle -} - struct Ping: Sendable { let recordName: String let gameID: UUID @@ -53,14 +30,13 @@ struct Ping: Sendable { let playerName: String let puzzleTitle: String let kind: PingKind - let scope: PingScope? /// Kind-specific JSON. `.friend`: `{friendShareURL,pairKey,ownerAuthorID}`; /// `.invite`: `{gameShareURL}`; `.hail` carries engagement room bootstrap; - /// `.check`/`.reveal`: `{scope}` (see PingScope). nil for join/win/resign. + /// nil for `.join`. let payload: String? - /// Recipient authorID for a directed ping (`.win`/`.resign`); for `.hail` - /// this is `authorID:deviceID` so only one of an author's devices acts on - /// the room bootstrap. nil ⇒ broadcast — every recipient acts on it. + /// Recipient authorID for a directed ping. For `.hail` this is + /// `authorID:deviceID` so only one of an author's devices acts on the + /// room bootstrap. nil ⇒ broadcast — every recipient acts on it. let addressee: String? static func parseRecord(_ record: CKRecord) -> Ping? { @@ -79,11 +55,6 @@ struct Ping: Sendable { let kindRaw = record["kind"] as? String, let kind = PingKind(rawValue: kindRaw) else { return nil } - // Scope rides in `payload` as `{"scope":"…"}`. The old top-level - // `scope` field is dead — nothing reads or writes it (see PingScope). - let payloadString = record["payload"] as? String - let scope: PingScope? = - PingScopePayload.decode(payloadString).flatMap { PingScope(rawValue: $0.scope) } // Legacy records written before the schema added `deviceID` won't have // the field. Parse-tolerant: empty string can never equal a real // localDeviceID, so the self-send filter stays safe. @@ -96,8 +67,7 @@ struct Ping: Sendable { playerName: (record["playerName"] as? String) ?? "", puzzleTitle: (record["puzzleTitle"] as? String) ?? "", kind: kind, - scope: scope, - payload: payloadString, + payload: record["payload"] as? String, addressee: record["addressee"] as? String ) } @@ -131,21 +101,3 @@ struct Session: Sendable { ) } } - - -/// `payload` JSON for `.check`/`.reveal` pings — the check/reveal granularity -/// that used to live in the legacy `Ping.scope` field (see PingScope). The -/// `scope` key is required, so decoding a different kind's payload (lease, -/// friend, invite) yields nil rather than a false match. -struct PingScopePayload: Codable, Sendable { - let scope: String - - func encoded() -> String? { - (try? JSONEncoder().encode(self)).flatMap { String(data: $0, encoding: .utf8) } - } - - static func decode(_ string: String?) -> PingScopePayload? { - guard let data = string?.data(using: .utf8) else { return nil } - return try? JSONDecoder().decode(PingScopePayload.self, from: data) - } -} diff --git a/Crossmate/Sync/RecordBuilder.swift b/Crossmate/Sync/RecordBuilder.swift @@ -23,7 +23,6 @@ extension SyncEngine { puzzleTitle: payload.puzzleTitle, eventTimestampMs: payload.eventTimestampMs, kind: payload.kind, - scope: payload.scope, payload: payload.payload, addressee: payload.addressee, zone: zoneID diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -178,11 +178,8 @@ enum RecordSerializer { /// authorID alone is insufficient for kinds (e.g. `.opened`) that fire /// between a single user's own devices, where authorID is identical. /// - `playerName` and `puzzleTitle` let receivers render the alert body. - /// - `kind` distinguishes join/win/check/reveal/opened events. - /// - `scope` (check/reveal granularity) is folded into `payload` as - /// `{"scope":"…"}` — the legacy top-level `scope` field is no longer - /// written (see PingScope). `scope`/`payload` are mutually exclusive - /// across kinds, so there is never anything to merge. + /// - `kind` distinguishes the remaining bootstrap kinds (.join / + /// .friend / .invite / .hail). static func pingRecord( gameID: UUID, authorID: String, @@ -191,7 +188,6 @@ enum RecordSerializer { puzzleTitle: String, eventTimestampMs: Int64, kind: PingKind, - scope: PingScope?, payload: String? = nil, addressee: String? = nil, zone: CKRecordZone.ID @@ -210,17 +206,12 @@ enum RecordSerializer { record["puzzleTitle"] = puzzleTitle as CKRecordValue record["kind"] = kind.rawValue as CKRecordValue // Directed pings target one player by authorID; nil ⇒ broadcast (every - // recipient acts on it), which keeps mixed-version peers working. + // recipient acts on it). if let addressee { record["addressee"] = addressee as CKRecordValue } - // `scope` and `payload` never co-occur across kinds; fold a legacy - // `scope` into the generic payload rather than writing the now-frozen - // top-level field. - let outPayload = payload - ?? scope.flatMap { PingScopePayload(scope: $0.rawValue).encoded() } - if let outPayload { - record["payload"] = outPayload as CKRecordValue + if let payload { + record["payload"] = payload as CKRecordValue } return record } diff --git a/Crossmate/Sync/SessionMonitor.swift b/Crossmate/Sync/SessionMonitor.swift @@ -1,35 +1,21 @@ import CoreData import Foundation -import UserNotifications -/// Indirection over the slice of `UNUserNotificationCenter` SessionMonitor -/// uses, so tests can substitute an in-memory recorder. The production -/// implementation is the `UNUserNotificationCenter` extension below. -protocol SessionNotificationScheduling: Sendable { - func add(_ request: UNNotificationRequest) async throws - func removePendingNotificationRequests(withIdentifiers identifiers: [String]) - func removeDeliveredNotifications(withIdentifiers identifiers: [String]) -} - -extension UNUserNotificationCenter: SessionNotificationScheduling {} - -/// Receiver-side observer that turns a stream of remote cell deltas -/// (`AuthorDelta`) into one end-of-session local notification per -/// `(gameID, authorID)`. Each delta bumps an in-memory running tally and -/// (re)schedules a `UNNotificationRequest` with a stable identifier and a -/// quiescence-window time trigger — so a quick burst of incoming Moves -/// records collapses to one delivery, and a force-quit between deltas -/// still fires the last-scheduled summary (the OS holds time-trigger -/// requests even when the app is not running). +/// Receiver-side observer that accumulates a per-`(gameID, authorID)` tally +/// of unseen peer activity. The accumulated tally is drained by +/// `consumeOnOpen(gameID:)` when the user opens a puzzle, producing the +/// catch-up announcement banner ("Alice added 12 letters; Bob added 5"). /// -/// Multi-device collaborators fall out for free: the bucket is keyed by -/// the writing iCloud author, not the device, so any of Alice's devices -/// streaming Moves resets the timer until *all* of her devices are quiet. +/// Time-triggered end-of-session local notifications used to live here too, +/// but those have been replaced by the sender-side leave APN +/// (`AppServices.publishSessionEndPush`). The peer's device fires the leave +/// push the instant they close the puzzle (or background the app), which is +/// both timelier and more accurate than the 3-minute quiescence heuristic +/// this monitor used to maintain. actor SessionMonitor { - /// Wall-clock window of inactivity before the end-of-session - /// notification fires. Also used by `RecordApplier.authorDeltas` to gate - /// stale cell writes out of cold-launch backfill so they don't queue a - /// notification for a session that ended hours ago. + /// Wall-clock window used by `RecordApplier.authorDeltas` to gate stale + /// cell writes out of cold-launch backfill so they don't show up in the + /// catch-up banner as if they had just happened. static let quiescenceWindow: TimeInterval = 180 private struct Key: Hashable { @@ -40,46 +26,23 @@ actor SessionMonitor { private struct Bucket { var added: Int var cleared: Int - /// Wall-clock time at which the currently-scheduled - /// `UNNotificationRequest` will fire. On the next note() this is - /// compared against `now` — if the trigger has already elapsed, the - /// tally is treated as consumed and the new delta starts a fresh - /// session. - var scheduledFor: Date } private var buckets: [Key: Bucket] = [:] private let persistence: PersistenceController - private let notificationCenter: SessionNotificationScheduling private let nameLookup: @Sendable (UUID, String) async -> (playerName: String, puzzleTitle: String) private let suppressionGate: @Sendable (UUID) -> Bool private let localAuthorIDProvider: @Sendable () async -> String? - private let notificationAuthorization: @Sendable () async -> Bool - private let clock: @Sendable () -> Date init( persistence: PersistenceController, localAuthorIDProvider: @escaping @Sendable () async -> String?, - notificationCenter: SessionNotificationScheduling = UNUserNotificationCenter.current(), nameLookup: (@Sendable (UUID, String) async -> (playerName: String, puzzleTitle: String))? = nil, - suppressionGate: @escaping @Sendable (UUID) -> Bool = { NotificationState.isSuppressed(gameID: $0) }, - notificationAuthorization: @escaping @Sendable () async -> Bool = { - let settings = await UNUserNotificationCenter.current().notificationSettings() - switch settings.authorizationStatus { - case .authorized, .provisional, .ephemeral: - return true - default: - return false - } - }, - clock: @escaping @Sendable () -> Date = { Date() } + suppressionGate: @escaping @Sendable (UUID) -> Bool = { NotificationState.isSuppressed(gameID: $0) } ) { self.persistence = persistence self.localAuthorIDProvider = localAuthorIDProvider - self.notificationCenter = notificationCenter self.suppressionGate = suppressionGate - self.notificationAuthorization = notificationAuthorization - self.clock = clock if let nameLookup { self.nameLookup = nameLookup } else { @@ -93,72 +56,9 @@ actor SessionMonitor { } } - /// Presents inferred session-start notifications from recently changed - /// Player records. This owns the full policy for "X is solving": local - /// author filtering, completed-game suppression, active-puzzle - /// suppression, per-author dedup, request identifiers, and body text. - /// - /// Returns diagnostics messages for the caller's sync log; scheduling - /// decisions remain internal to the monitor. - func presentBegins(_ sessions: [Session]) async -> [String] { - guard !sessions.isEmpty else { return [] } - guard await notificationAuthorization() else { - return ["session-begin: local notification skipped — authorization not granted"] - } - - let localAuthorID = await localAuthorIDProvider() - let completedByAuthor = completedAuthorKeys(for: Set(sessions.map(\.gameID))) - var notes: [String] = [] - for session in sessions { - if session.authorID == localAuthorID { - notes.append("session-begin: skipped self-authored record \(session.recordName)") - continue - } - if completedByAuthor.contains(Self.compositeKey(gameID: session.gameID, authorID: session.authorID)) { - notes.append("session-begin: suppressed — author completed \(session.gameID.uuidString)") - continue - } - if suppressionGate(session.gameID) { - NotificationState.recordShown(gameID: session.gameID, authorID: session.authorID) - notes.append("session-begin: suppressed — puzzle is active for \(session.gameID.uuidString)") - continue - } - if NotificationState.wasRecentlyShown(gameID: session.gameID, authorID: session.authorID) { - NotificationState.recordShown(gameID: session.gameID, authorID: session.authorID) - notes.append("session-begin: dedup-suppressed for \(session.gameID.uuidString) author=\(session.authorID)") - continue - } - - let content = UNMutableNotificationContent() - content.title = "Crossmate" - content.body = Self.bodyText(for: session) - content.sound = .default - content.userInfo = [ - "crossmateGameID": session.gameID.uuidString, - "crossmateAuthorID": session.authorID, - "crossmateActivity": "session-begin" - ] - - let request = UNNotificationRequest( - identifier: Self.beginIdentifier(for: session.gameID, authorID: session.authorID), - content: content, - trigger: nil - ) - do { - try await notificationCenter.add(request) - NotificationState.recordShown(gameID: session.gameID, authorID: session.authorID) - notes.append("session-begin: queued local notification for \(session.gameID.uuidString) author=\(session.authorID)") - } catch { - notes.append("session-begin: local notification failed — \(error.localizedDescription)") - } - } - return notes - } - - /// Folds one batch of receiver-side deltas into the per-author tallies - /// and reschedules each affected end-of-session notification. Drops - /// self-authored deltas (sibling device of the same iCloud account) and - /// deltas for puzzles the user is currently viewing. + /// Folds one batch of receiver-side deltas into the per-author tallies. + /// Drops self-authored deltas (sibling device of the same iCloud account) + /// and deltas for puzzles the user is currently viewing. func ingest(_ deltas: [AuthorDelta]) async { guard !deltas.isEmpty else { return } let localAuthorID = await localAuthorIDProvider() @@ -167,25 +67,10 @@ actor SessionMonitor { if delta.authorID == localAuthorID { continue } if suppressionGate(delta.gameID) { continue } let key = Key(gameID: delta.gameID, authorID: delta.authorID) - let now = clock() - var bucket = buckets[key] ?? Bucket(added: 0, cleared: 0, scheduledFor: now) - // Prior trigger has already fired (or never existed) — start a - // fresh session rather than accumulating into the old summary. - if now >= bucket.scheduledFor { - bucket = Bucket(added: 0, cleared: 0, scheduledFor: now) - } + var bucket = buckets[key] ?? Bucket(added: 0, cleared: 0) bucket.added += delta.added bucket.cleared += delta.cleared - // Schedule the trigger relative to the latest typing time, not - // batch arrival, so a late-arriving batch doesn't push the - // notification further into the future than it should be. - let scheduledFor = max( - now.addingTimeInterval(1), - delta.latestUpdate.addingTimeInterval(Self.quiescenceWindow) - ) - bucket.scheduledFor = scheduledFor buckets[key] = bucket - await scheduleEnd(key: key, bucket: bucket, now: now) } } @@ -203,10 +88,7 @@ actor SessionMonitor { } /// Pulls the running tallies for every author in `gameID` out as - /// summaries, drops their buckets, and withdraws the pending - /// end-of-session notifications. Called from the puzzle-open path — - /// the banner supersedes the would-have-fired local notification, so - /// we don't want it queued up for delivery a few minutes later. + /// summaries and drops their buckets. func consumeOnOpen(gameID: UUID) async -> [SessionSummary] { let keysForGame = buckets.keys.filter { $0.gameID == gameID } guard !keysForGame.isEmpty else { return [] } @@ -227,17 +109,13 @@ actor SessionMonitor { for key in keysForGame { buckets.removeValue(forKey: key) } - let identifiers = keysForGame.map(Self.endIdentifier(for:)) - notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers) - notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers) return summaries } - /// Withdraws the pending end-of-session notification and tally for - /// `(gameID, authorID)`. Pass `authorID == nil` to drop every author's - /// bucket for the game — used when the user opens the puzzle, a `.win` - /// or `.resign` ping arrives, or the Game gains a `completedBy` (the - /// summary is superseded by a stronger signal). + /// Drops the running tally for `(gameID, authorID)`. Pass `authorID == nil` + /// to drop every author's bucket for the game — used when the user opens + /// the puzzle or the Game gains a `completedBy` (the catch-up banner is + /// superseded by a stronger signal). func cancel(gameID: UUID, authorID: String? = nil) async { let keysToCancel: [Key] = buckets.keys.filter { key in key.gameID == gameID && (authorID == nil || key.authorID == authorID) @@ -246,9 +124,6 @@ actor SessionMonitor { for key in keysToCancel { buckets.removeValue(forKey: key) } - let identifiers = keysToCancel.map(Self.endIdentifier(for:)) - notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers) - notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers) } /// Number of distinct `(gameID, authorID)` buckets currently being @@ -257,50 +132,9 @@ actor SessionMonitor { /// Snapshot of the running tally for `(gameID, authorID)`, if any. /// Exposed for tests; not part of the runtime API. - func tally(gameID: UUID, authorID: String) -> (added: Int, cleared: Int, scheduledFor: Date)? { + func tally(gameID: UUID, authorID: String) -> (added: Int, cleared: Int)? { guard let bucket = buckets[Key(gameID: gameID, authorID: authorID)] else { return nil } - return (bucket.added, bucket.cleared, bucket.scheduledFor) - } - - private func scheduleEnd(key: Key, bucket: Bucket, now: Date) async { - let (playerName, puzzleTitle) = await nameLookup(key.gameID, key.authorID) - let content = UNMutableNotificationContent() - content.title = "Crossmate" - content.body = Self.bodyText( - playerName: playerName, - puzzleTitle: puzzleTitle, - added: bucket.added, - cleared: bucket.cleared - ) - content.sound = .default - content.userInfo = [ - "crossmateGameID": key.gameID.uuidString, - "crossmateAuthorID": key.authorID, - "crossmateActivity": "session-end" - ] - let interval = max(bucket.scheduledFor.timeIntervalSince(now), 1) - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: interval, repeats: false) - let request = UNNotificationRequest( - identifier: Self.endIdentifier(for: key), - content: content, - trigger: trigger - ) - do { - try await notificationCenter.add(request) - // The session-begin notification (if still on screen) is - // superseded by this richer end-of-session summary. Keep the - // begin dedup gate intact, though: this scheduling happens while - // the current session is still active, and clearing it here lets - // later Player-record pushes re-fire "started playing" banners - // for the same session. - notificationCenter.removeDeliveredNotifications( - withIdentifiers: [Self.beginIdentifier(for: key)] - ) - } catch { - // Best-effort: a scheduling failure leaves the running tally - // intact, so the next note() will retry the add() with the - // accumulated totals. - } + return (bucket.added, bucket.cleared) } private static func coreDataNameLookup( @@ -330,45 +164,6 @@ actor SessionMonitor { } } - private func completedAuthorKeys(for gameIDs: Set<UUID>) -> Set<String> { - let ctx = persistence.container.newBackgroundContext() - return ctx.performAndWait { - var keys = Set<String>() - for gameID in gameIDs { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - req.fetchLimit = 1 - guard let game = try? ctx.fetch(req).first, - game.completedAt != nil, - let authorID = game.completedBy, - !authorID.isEmpty - else { continue } - keys.insert(Self.compositeKey(gameID: gameID, authorID: authorID)) - } - return keys - } - } - - private static func compositeKey(gameID: UUID, authorID: String) -> String { - "\(gameID.uuidString)|\(authorID)" - } - - static func endIdentifier(for gameID: UUID, authorID: String) -> String { - "session-end-\(gameID.uuidString)-\(authorID)" - } - - private static func endIdentifier(for key: Key) -> String { - endIdentifier(for: key.gameID, authorID: key.authorID) - } - - static func beginIdentifier(for gameID: UUID, authorID: String) -> String { - "session-begin-\(gameID.uuidString)-\(authorID)" - } - - private static func beginIdentifier(for key: Key) -> String { - beginIdentifier(for: key.gameID, authorID: key.authorID) - } - static func bodyText( playerName: String, puzzleTitle: String, @@ -387,10 +182,4 @@ actor SessionMonitor { let action = parts.isEmpty ? "made changes" : parts.joined(separator: " and ") return "\(name) \(action) in \(suffix)" } - - static func bodyText(for session: Session) -> String { - let player = session.playerName.isEmpty ? "A player" : session.playerName - let puzzleSuffix = session.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(session.puzzleTitle)'" - return "\(player) is solving \(puzzleSuffix)" - } } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -13,8 +13,8 @@ extension EnvironmentValues { /// `(friendAuthorID)` — blocks a collaborator: suppress future invites, /// leave their shared games, tear down the friend zone. @Entry var blockFriend: ((String) async -> Void)? = nil - /// `(gameID)` — fans out the directed `.resign` ping after a game is - /// resigned from the library (the in-puzzle path uses `onResign` directly). + /// `(gameID)` — fires the completion APN after a game is resigned from + /// the library (the in-puzzle path uses `onResign` directly). @Entry var sendResignPings: ((UUID) async -> Void)? = nil } @@ -59,7 +59,6 @@ actor SyncEngine { let puzzleTitle: String let eventTimestampMs: Int64 let kind: PingKind - let scope: PingScope? let payload: String? let addressee: String? } @@ -418,13 +417,13 @@ actor SyncEngine { sendChangesDetached(on: engine) } - /// Registers a Ping record as a pending send. Pings cover join, win, - /// check, and reveal events; sender-only state — the payload is - /// stashed in `pendingPings` and only used to build the outgoing - /// `CKRecord`; nothing is persisted. + /// Registers a Ping record as a pending send. Pings now only cover + /// bootstrap kinds (`.join` / `.friend` / `.invite` / `.hail`) — the + /// user-facing play events go through the push worker. Sender-only + /// state: the payload is stashed in `pendingPings` and only used to + /// build the outgoing `CKRecord`; nothing is persisted. func enqueuePing( kind: PingKind, - scope: PingScope?, gameID: UUID, authorID: String, playerName: String, @@ -445,7 +444,7 @@ actor SyncEngine { Task { await trace( "ping send: SKIPPED kind=\(kind.rawValue) " + - "scope=\(scope?.rawValue ?? "none") game=\(gameID.uuidString) " + + "game=\(gameID.uuidString) " + "— no zone info (game not yet synced/shared on this device)" ) } @@ -456,7 +455,7 @@ actor SyncEngine { Task { await trace( "ping send: SKIPPED kind=\(kind.rawValue) " + - "scope=\(scope?.rawValue ?? "none") game=\(gameID.uuidString) " + + "game=\(gameID.uuidString) " + "— no CKSyncEngine for " + "\(zoneAndTitle.info.scope == 1 ? "shared" : "private") scope" ) @@ -479,7 +478,6 @@ actor SyncEngine { puzzleTitle: zoneAndTitle.title, eventTimestampMs: eventTimestampMs, kind: kind, - scope: scope, payload: payload, addressee: addressee ) @@ -488,7 +486,7 @@ actor SyncEngine { Task { await trace( "ping send: enqueued kind=\(kind.rawValue) " + - "scope=\(scope?.rawValue ?? "none") game=\(gameID.uuidString) " + + "game=\(gameID.uuidString) " + "target=\(zoneAndTitle.info.scope == 1 ? "shared" : "private") " + "zone=\(zoneAndTitle.info.zoneID.zoneName) record=\(recordName)" ) @@ -719,7 +717,6 @@ actor SyncEngine { puzzleTitle: gameTitle, eventTimestampMs: eventTimestampMs, kind: .invite, - scope: nil, payload: payload, addressee: addressee ) diff --git a/Scripts/select-simulator.sh b/Scripts/select-simulator.sh diff --git a/Scripts/wrangle-push.sh b/Scripts/wrangle-push.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +exec wrangler --config "$REPO_ROOT/Worker/wrangler.push.toml" "$@" diff --git a/Tests/Unit/GameMutatorTests.swift b/Tests/Unit/GameMutatorTests.swift @@ -27,7 +27,7 @@ struct GameMutatorTests { mutator.setLetter("B", atRow: 0, atCol: 1, pencil: true) #expect(game.squares[0][1].entry == "B") - #expect(game.squares[0][1].mark == .pencil(checkedWrong: false)) + #expect(game.squares[0][1].mark == .pencil(checked: nil)) } @Test("clearLetter clears entry and mark") @@ -52,18 +52,19 @@ struct GameMutatorTests { mutator.setLetter("Z", atRow: 0, atCol: 0, pencil: false) mutator.checkCells([game.puzzle.cells[0][0]]) - #expect(game.squares[0][0].mark == .pen(checkedWrong: true)) + #expect(game.squares[0][0].mark == .pen(checked: .wrong)) } - @Test("checkCells promotes correct pencil entries to pen") - func checkCellsPromotesCorrectPencilEntries() throws { + @Test("checkCells stamps correct pencil entries with .right and keeps the pencil style") + func checkCellsStampsCorrectPencilEntries() throws { let (game, mutator, _, _) = try makeTestGame() - // Cell (0,0) has solution "A"; checking a correct draft commits it. + // Cell (0,0) has solution "A"; checking a correct draft now records + // the verification on the cell so peers can see what's been checked. mutator.setLetter("A", atRow: 0, atCol: 0, pencil: true) mutator.checkCells([game.puzzle.cells[0][0]]) - #expect(game.squares[0][0].mark == .none) + #expect(game.squares[0][0].mark == .pencil(checked: .right)) } @Test("revealCells sets entry to solution and marks revealed") @@ -96,7 +97,7 @@ struct GameMutatorTests { mutator.revealCells([game.puzzle.cells[0][0]]) #expect(game.squares[0][0].entry == "A") - #expect(game.squares[0][0].mark == .pencil(checkedWrong: false)) + #expect(game.squares[0][0].mark == .pencil(checked: nil)) } @Test("revealCells overwrites wrong entries and marks them revealed") diff --git a/Tests/Unit/GameStoreUnreadMovesTests.swift b/Tests/Unit/GameStoreUnreadMovesTests.swift @@ -426,6 +426,7 @@ struct GameStoreUnreadMovesTests { col: 0, letter: "Q", markKind: 0, + checkedRight: false, checkedWrong: false, updatedAt: updatedAt, cellAuthorID: Self.localAuthorID @@ -456,6 +457,7 @@ struct GameStoreUnreadMovesTests { col: 0, letter: "Q", markKind: 0, + checkedRight: false, checkedWrong: false, updatedAt: later, cellAuthorID: Self.localAuthorID @@ -468,6 +470,7 @@ struct GameStoreUnreadMovesTests { col: 0, letter: "R", markKind: 0, + checkedRight: false, checkedWrong: false, updatedAt: earlier, cellAuthorID: Self.localAuthorID diff --git a/Tests/Unit/GridStateMergerTests.swift b/Tests/Unit/GridStateMergerTests.swift @@ -20,7 +20,7 @@ struct GridStateMergerTests { dict[GridPosition(row: entry.row, col: entry.col)] = TimestampedCell( letter: entry.letter, markKind: 0, - checkedWrong: false, + checkedRight: false, checkedWrong: false, updatedAt: entry.updatedAt, authorID: resolvedCellAuthor ) @@ -185,14 +185,14 @@ struct MovesCodecTests { GridPosition(row: 0, col: 0): TimestampedCell( letter: "A", markKind: 2, - checkedWrong: true, + checkedRight: false, checkedWrong: true, updatedAt: Date(timeIntervalSince1970: 1_700_000_000), authorID: "alice" ), GridPosition(row: 4, col: 7): TimestampedCell( letter: "", markKind: 0, - checkedWrong: false, + checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 1_700_000_500), authorID: nil ), @@ -206,12 +206,12 @@ struct MovesCodecTests { func encodingIsDeterministic() throws { let cells: [GridPosition: TimestampedCell] = [ GridPosition(row: 2, col: 1): TimestampedCell( - letter: "X", markKind: 0, checkedWrong: false, + letter: "X", markKind: 0, checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 1), authorID: "alice" ), GridPosition(row: 0, col: 0): TimestampedCell( - letter: "A", markKind: 0, checkedWrong: false, + letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 2), authorID: "bob" ), diff --git a/Tests/Unit/MovesUpdaterTests.swift b/Tests/Unit/MovesUpdaterTests.swift @@ -100,9 +100,9 @@ struct MovesUpdaterTests { let capture = Capture() let updater = makeUpdater(persistence: persistence, capture: capture) - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: "alice") - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", markKind: 0, checkedWrong: false, authorID: "alice") - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "C", markKind: 0, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "C", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") await updater.flush() let cells = try decodedCells(gameID: gameID, persistence: persistence) @@ -116,8 +116,8 @@ struct MovesUpdaterTests { let capture = Capture() let updater = makeUpdater(persistence: persistence, capture: capture) - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: "alice") - await updater.enqueue(gameID: gameID, row: 0, col: 1, letter: "B", markKind: 0, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 1, letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") // The cell change should NOT have triggered a flush — both edits are // still buffered until the debounce (or explicit flush) fires. @@ -143,8 +143,8 @@ struct MovesUpdaterTests { sleep: manualSleep.sleepFn ) - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: "alice") - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", markKind: 0, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") // Both enqueues are buffered; the second enqueue cancelled the // first debounce task. Releasing the manual sleep lets the live @@ -180,7 +180,7 @@ struct MovesUpdaterTests { writerAuthorID: "bob" ) - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") await updater.flush() let cells = try decodedCells(gameID: gameID, persistence: persistence) @@ -209,8 +209,8 @@ struct MovesUpdaterTests { let capture = Capture() let updater = makeUpdater(persistence: persistence, capture: capture) - await updater.enqueue(gameID: g1, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: "alice") - await updater.enqueue(gameID: g2, row: 0, col: 0, letter: "B", markKind: 0, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: g1, row: 0, col: 0, letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: g2, row: 0, col: 0, letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") await updater.flush() let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") @@ -227,11 +227,11 @@ struct MovesUpdaterTests { let capture = Capture() let updater = makeUpdater(persistence: persistence, capture: capture) - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") await updater.flush() let firstObjectID = try #require(movesEntity(gameID: gameID, persistence: persistence)).objectID - await updater.enqueue(gameID: gameID, row: 1, col: 1, letter: "B", markKind: 0, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 1, col: 1, letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, authorID: "alice") await updater.flush() let entity = try #require(movesEntity(gameID: gameID, persistence: persistence)) @@ -248,7 +248,7 @@ struct MovesUpdaterTests { let capture = Capture() let updater = makeUpdater(persistence: persistence, capture: capture) - await updater.enqueue(gameID: gameID, row: 2, col: 3, letter: "Q", markKind: 1, checkedWrong: true, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 2, col: 3, letter: "Q", markKind: 1, checkedRight: false, checkedWrong: true, authorID: "alice") await updater.flush() let ctx = persistence.viewContext @@ -275,7 +275,7 @@ struct MovesUpdaterTests { sink: { await capture.append($0) } ) - await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: nil) + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, authorID: nil) await updater.flush() #expect(await capture.flushCount == 0) diff --git a/Tests/Unit/PendingEditFlagTests.swift b/Tests/Unit/PendingEditFlagTests.swift @@ -78,6 +78,7 @@ struct PendingEditFlagTests { GridPosition(row: 0, col: 0): TimestampedCell( letter: letter, markKind: 1, + checkedRight: false, checkedWrong: false, updatedAt: updatedAt, authorID: authorID diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift @@ -15,7 +15,7 @@ struct PuzzleNotificationTextTests { #expect(title.contains("2001")) } - @Test("Notification body quotes puzzle title") + @Test("Join notification body quotes puzzle title") func bodyQuotesPuzzleTitle() { let ping = Ping( recordName: "ping-test-1", @@ -25,7 +25,6 @@ struct PuzzleNotificationTextTests { playerName: "Alice", puzzleTitle: "Saturday Puzzle – 1 January 2001", kind: .join, - scope: nil, payload: nil, addressee: nil ) @@ -33,8 +32,8 @@ struct PuzzleNotificationTextTests { #expect(AppServices.bodyText(for: ping) == "Alice joined the puzzle 'Saturday Puzzle – 1 January 2001'") } - @Test("Puzzle check says all of the puzzle") - func puzzleCheckSaysAllOfPuzzle() { + @Test("Invite body names the inviter and puzzle") + func inviteBody() { let ping = Ping( recordName: "ping-test-2", gameID: UUID(), @@ -42,49 +41,11 @@ struct PuzzleNotificationTextTests { deviceID: "device-a", playerName: "Alice", puzzleTitle: "Saturday Puzzle – 1 January 2001", - kind: .check, - scope: .puzzle, + kind: .invite, payload: nil, - addressee: nil - ) - - #expect(AppServices.bodyText(for: ping) == "Alice checked all of the puzzle 'Saturday Puzzle – 1 January 2001'") - } - - @Test("Win and resign render distinct bodies") - func winAndResignBodies() { - func ping(_ kind: PingKind) -> Ping { - Ping( - recordName: "ping-test-\(kind.rawValue)", - gameID: UUID(), - authorID: "alice", - deviceID: "device-a", - playerName: "Alice", - puzzleTitle: "Saturday Puzzle – 1 January 2001", - kind: kind, - scope: nil, - payload: nil, - addressee: "bob" - ) - } - - #expect(AppServices.bodyText(for: ping(.win)) - == "Alice solved the puzzle 'Saturday Puzzle – 1 January 2001'") - #expect(AppServices.bodyText(for: ping(.resign)) - == "Alice gave up on the puzzle 'Saturday Puzzle – 1 January 2001'") - } - - @Test("Session activity says player is solving puzzle") - func sessionActivitySaysPlayerIsSolvingPuzzle() { - let session = Session( - recordName: "player-test-1", - gameID: UUID(), - authorID: "alice", - playerName: "Alice", - puzzleTitle: "Saturday Puzzle – 1 January 2001", - updatedAt: Date() + addressee: "bob" ) - #expect(SessionMonitor.bodyText(for: session) == "Alice is solving the puzzle 'Saturday Puzzle – 1 January 2001'") + #expect(AppServices.bodyText(for: ping) == "Alice invited you to the puzzle 'Saturday Puzzle – 1 January 2001'") } } diff --git a/Tests/Unit/RecordSerializerMovesTests.swift b/Tests/Unit/RecordSerializerMovesTests.swift @@ -37,12 +37,12 @@ struct RecordSerializerMovesTests { func movesRecordRoundTrip() throws { let cells: [GridPosition: TimestampedCell] = [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "A", markKind: 0, checkedWrong: false, + letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 1_700_000_000), authorID: "alice" ), GridPosition(row: 1, col: 2): TimestampedCell( - letter: "B", markKind: 2, checkedWrong: true, + letter: "B", markKind: 2, checkedRight: false, checkedWrong: true, updatedAt: Date(timeIntervalSince1970: 1_700_000_500), authorID: nil ), diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -129,13 +129,12 @@ struct RecordSerializerTests { playerName: "Alice", puzzleTitle: "Puzzle", eventTimestampMs: 1700000000000, - kind: .win, - scope: nil, + kind: .join, zone: zone ) #expect(record["authorID"] as? String == "alice") #expect(record["deviceID"] as? String == "deviceA") - #expect(record["kind"] as? String == "win") + #expect(record["kind"] as? String == "join") } @Test("pingRecord writes payload when provided and omits it when nil") @@ -149,7 +148,6 @@ struct RecordSerializerTests { puzzleTitle: "Puzzle", eventTimestampMs: 1700000000000, kind: .invite, - scope: nil, payload: #"{"gameShareURL":"https://x"}"#, zone: zone ) @@ -164,7 +162,6 @@ struct RecordSerializerTests { puzzleTitle: "Puzzle", eventTimestampMs: 1700000000000, kind: .join, - scope: nil, zone: zone ) #expect(withoutPayload["payload"] == nil) @@ -180,13 +177,12 @@ struct RecordSerializerTests { playerName: "Alice", puzzleTitle: "Puzzle", eventTimestampMs: 1700000000000, - kind: .win, - scope: nil, + kind: .invite, addressee: "bob", zone: zone ) #expect(directed["addressee"] as? String == "bob") - #expect(directed["kind"] as? String == "win") + #expect(directed["kind"] as? String == "invite") let broadcast = RecordSerializer.pingRecord( gameID: UUID(), @@ -195,8 +191,7 @@ struct RecordSerializerTests { playerName: "Alice", puzzleTitle: "Puzzle", eventTimestampMs: 1700000000000, - kind: .check, - scope: .square, + kind: .join, zone: zone ) #expect(broadcast["addressee"] == nil) @@ -215,7 +210,6 @@ struct RecordSerializerTests { puzzleTitle: "Puzzle", eventTimestampMs: 1700000000000, kind: .hail, - scope: nil, payload: payload, addressee: "bob:deviceB", zone: zone @@ -228,7 +222,6 @@ struct RecordSerializerTests { #expect(parsed.playerName == "Alice") #expect(parsed.puzzleTitle == "Puzzle") #expect(parsed.kind == .hail) - #expect(parsed.scope == nil) #expect(parsed.payload == payload) #expect(parsed.addressee == "bob:deviceB") } @@ -245,7 +238,6 @@ struct RecordSerializerTests { puzzleTitle: "Puzzle", eventTimestampMs: 1700000000000, kind: .hail, - scope: nil, payload: #"{"role":"offer","engagementID":"01234567-89AB-CDEF-0123-456789ABCDEF","sdp":"v=0\r\n","candidates":[],"ver":1}"#, addressee: "alice", zone: zone @@ -255,29 +247,6 @@ struct RecordSerializerTests { #expect(parsed.addressee == "alice") } - @Test("check/reveal scope is folded into payload, not the legacy field") - func pingRecordFoldsScopeIntoPayload() throws { - let zone = CKRecordZone.ID(zoneName: "z", ownerName: CKCurrentUserDefaultName) - let record = RecordSerializer.pingRecord( - gameID: UUID(), - authorID: "alice", - deviceID: "deviceA", - playerName: "Alice", - puzzleTitle: "Puzzle", - eventTimestampMs: 1700000000000, - kind: .check, - scope: .word, - zone: zone - ) - // The frozen top-level field is no longer written. - #expect(record["scope"] == nil) - let decoded = try #require( - PingScopePayload.decode(record["payload"] as? String) - ) - #expect(decoded.scope == "word") - #expect(PingScope(rawValue: decoded.scope) == .word) - } - @Test("accountZoneID is named 'account' in the current user's private DB") func accountZoneIDShape() { let zone = RecordSerializer.accountZoneID diff --git a/Tests/Unit/Sync/AuthorDeltaTests.swift b/Tests/Unit/Sync/AuthorDeltaTests.swift @@ -18,7 +18,7 @@ struct GridStateMergerProvenanceTests { dict[GridPosition(row: entry.row, col: entry.col)] = TimestampedCell( letter: entry.letter, markKind: 0, - checkedWrong: false, + checkedRight: false, checkedWrong: false, updatedAt: entry.updatedAt, authorID: author ) @@ -91,7 +91,7 @@ struct AuthorDeltaTests { cell: TimestampedCell( letter: entry.letter, markKind: 0, - checkedWrong: false, + checkedRight: false, checkedWrong: false, updatedAt: entry.updatedAt, authorID: entry.writer ), diff --git a/Tests/Unit/Sync/EngagementCoordinatorTests.swift b/Tests/Unit/Sync/EngagementCoordinatorTests.swift @@ -99,6 +99,7 @@ struct EngagementCoordinatorTests { col: 2, letter: "Z", markKind: 2, + checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 456), cellAuthorID: "alice" @@ -335,7 +336,6 @@ struct EngagementCoordinatorTests { playerName: authorID, puzzleTitle: "Puzzle", kind: .hail, - scope: nil, payload: payload, addressee: addressee ) diff --git a/Tests/Unit/Sync/MovesInboundTests.swift b/Tests/Unit/Sync/MovesInboundTests.swift @@ -50,7 +50,7 @@ struct MovesInboundTests { deviceID: "deadbeef", cells: [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "A", markKind: 0, checkedWrong: false, + letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 1_700_000_000) ), ], @@ -80,7 +80,7 @@ struct MovesInboundTests { let cells1: [GridPosition: TimestampedCell] = [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "A", markKind: 0, checkedWrong: false, + letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 1_700_000_000), authorID: "alice" ), @@ -99,12 +99,12 @@ struct MovesInboundTests { // (matched by ckRecordName), not create a duplicate. let cells2: [GridPosition: TimestampedCell] = [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "B", markKind: 0, checkedWrong: false, + letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 1_700_000_500), authorID: "alice" ), GridPosition(row: 1, col: 1): TimestampedCell( - letter: "C", markKind: 0, checkedWrong: false, + letter: "C", markKind: 0, checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 1_700_000_500), authorID: "alice" ), @@ -141,7 +141,7 @@ struct MovesInboundTests { let localCells: [GridPosition: TimestampedCell] = [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "B", markKind: 0, checkedWrong: false, + letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 20), authorID: "alice" ), @@ -161,7 +161,7 @@ struct MovesInboundTests { let serverCells: [GridPosition: TimestampedCell] = [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "A", markKind: 0, checkedWrong: false, + letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 10), authorID: "alice" ), @@ -203,12 +203,12 @@ struct MovesInboundTests { let cachedCells: [GridPosition: TimestampedCell] = [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "B", markKind: 0, checkedWrong: false, + letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 20), authorID: "bob" ), GridPosition(row: 1, col: 1): TimestampedCell( - letter: "", markKind: 0, checkedWrong: false, + letter: "", markKind: 0, checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 30), authorID: "bob" ), @@ -228,17 +228,17 @@ struct MovesInboundTests { let serverCells: [GridPosition: TimestampedCell] = [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "A", markKind: 0, checkedWrong: false, + letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 10), authorID: "bob" ), GridPosition(row: 1, col: 1): TimestampedCell( - letter: "Z", markKind: 0, checkedWrong: false, + letter: "Z", markKind: 0, checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 15), authorID: "bob" ), GridPosition(row: 2, col: 2): TimestampedCell( - letter: "C", markKind: 0, checkedWrong: false, + letter: "C", markKind: 0, checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 40), authorID: "bob" ), @@ -277,7 +277,7 @@ struct MovesInboundTests { deviceID: "phone", cells: [ GridPosition(row: 0, col: 0): TimestampedCell( - letter: "A", markKind: 0, checkedWrong: false, + letter: "A", markKind: 0, checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 1), authorID: "alice" ), @@ -290,7 +290,7 @@ struct MovesInboundTests { deviceID: "ipad", cells: [ GridPosition(row: 1, col: 1): TimestampedCell( - letter: "B", markKind: 0, checkedWrong: false, + letter: "B", markKind: 0, checkedRight: false, checkedWrong: false, updatedAt: Date(timeIntervalSince1970: 2), authorID: "alice" ), diff --git a/Tests/Unit/Sync/PendingChangeReapTests.swift b/Tests/Unit/Sync/PendingChangeReapTests.swift @@ -77,8 +77,7 @@ struct PendingChangeReapTests { let engine = await makeEngine(persistence: persistence) await engine.enqueuePing( - kind: .win, - scope: nil, + kind: .join, gameID: gameID, authorID: "_localAuthor", playerName: "Local" diff --git a/Tests/Unit/Sync/SessionMonitorTests.swift b/Tests/Unit/Sync/SessionMonitorTests.swift @@ -1,51 +1,18 @@ import Foundation import Testing -import UserNotifications @testable import Crossmate -/// In-memory recorder for the slice of UNUserNotificationCenter that -/// SessionMonitor uses. Lets tests inspect what would have been scheduled -/// or withdrawn without involving the system notification center. -final class RecordingNotificationScheduler: SessionNotificationScheduling, @unchecked Sendable { - private let lock = NSLock() - private var _added: [UNNotificationRequest] = [] - private var _removedPending: [String] = [] - private var _removedDelivered: [String] = [] - - var added: [UNNotificationRequest] { lock.withLock { _added } } - var removedPending: [String] { lock.withLock { _removedPending } } - var removedDelivered: [String] { lock.withLock { _removedDelivered } } - - func add(_ request: UNNotificationRequest) async throws { - lock.withLock { _added.append(request) } - } - - func removePendingNotificationRequests(withIdentifiers identifiers: [String]) { - lock.withLock { _removedPending.append(contentsOf: identifiers) } - } - - func removeDeliveredNotifications(withIdentifiers identifiers: [String]) { - lock.withLock { _removedDelivered.append(contentsOf: identifiers) } - } -} - @MainActor private func makeMonitor( - scheduler: RecordingNotificationScheduler, localAuthorID: String? = nil, - suppressionGate: @escaping @Sendable (UUID) -> Bool = { _ in false }, - notificationAuthorization: @escaping @Sendable () async -> Bool = { true }, - clock: @escaping @Sendable () -> Date = { Date() } + suppressionGate: @escaping @Sendable (UUID) -> Bool = { _ in false } ) -> SessionMonitor { SessionMonitor( persistence: makeTestPersistence(), localAuthorIDProvider: { localAuthorID }, - notificationCenter: scheduler, nameLookup: { _, authorID in ("Player \(authorID)", "Puzzle") }, - suppressionGate: suppressionGate, - notificationAuthorization: notificationAuthorization, - clock: clock + suppressionGate: suppressionGate ) } @@ -68,159 +35,60 @@ private func makeDelta( @Suite("SessionMonitor", .isolatedNotificationState) struct SessionMonitorTests { - @Test("First delta opens a bucket and schedules the end-of-session notification") - @MainActor func ingestSchedulesNotification() async { - let scheduler = RecordingNotificationScheduler() - let monitor = makeMonitor(scheduler: scheduler) - let gameID = UUID() - await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 3)]) - - #expect(scheduler.added.count == 1) - let request = scheduler.added.first! - #expect(request.identifier == SessionMonitor.endIdentifier(for: gameID, authorID: "alice")) - #expect(request.content.body.contains("3")) - #expect(request.trigger is UNTimeIntervalNotificationTrigger) - } - - @Test("Session-begin presentation schedules once and dedups repeated sightings") - @MainActor func presentBeginSchedulesAndDedups() async { - let scheduler = RecordingNotificationScheduler() - let monitor = makeMonitor(scheduler: scheduler) - let gameID = UUID() - NotificationState.clearShown(gameID: gameID, authorID: "alice") - let session = Session( - recordName: "player-\(gameID.uuidString)-alice", - gameID: gameID, - authorID: "alice", - playerName: "Alice", - puzzleTitle: "Puzzle", - updatedAt: Date() - ) - - let firstNotes = await monitor.presentBegins([session]) - let secondNotes = await monitor.presentBegins([session]) - - #expect(scheduler.added.count == 1) - #expect(scheduler.added.first?.identifier == SessionMonitor.beginIdentifier(for: gameID, authorID: "alice")) - #expect(firstNotes.contains("session-begin: queued local notification for \(gameID.uuidString) author=alice")) - #expect(secondNotes.contains("session-begin: dedup-suppressed for \(gameID.uuidString) author=alice")) - NotificationState.clearShown(gameID: gameID, authorID: "alice") - } - - @Test("Session-begin presentation respects notification authorization") - @MainActor func presentBeginAuthorizationSkipped() async { - let scheduler = RecordingNotificationScheduler() - let monitor = makeMonitor( - scheduler: scheduler, - notificationAuthorization: { false } - ) - let gameID = UUID() - let notes = await monitor.presentBegins([Session( - recordName: "player-\(gameID.uuidString)-alice", - gameID: gameID, - authorID: "alice", - playerName: "Alice", - puzzleTitle: "Puzzle", - updatedAt: Date() - )]) - - #expect(scheduler.added.isEmpty) - #expect(notes == ["session-begin: local notification skipped — authorization not granted"]) - } - @Test("Successive deltas for the same key accumulate; the bucket totals grow") @MainActor func tallyAccumulates() async { - let scheduler = RecordingNotificationScheduler() - // Anchor every batch at a fresh "now" so the prior trigger never - // elapses between calls — exercises the accumulation path, not the - // session-rollover path. - let monitor = makeMonitor(scheduler: scheduler, clock: { Date() }) + let monitor = makeMonitor() let gameID = UUID() - await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 2, latestUpdate: Date())]) - await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 1, cleared: 4, latestUpdate: Date())]) + await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 2)]) + await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 1, cleared: 4)]) let tally = await monitor.tally(gameID: gameID, authorID: "alice") #expect(tally?.added == 3) #expect(tally?.cleared == 4) - // Each ingest reschedules: two add() calls, both with the same id. - #expect(scheduler.added.count == 2) } @Test("Self-authored deltas (sibling device) are dropped") @MainActor func selfAuthoredSkipped() async { - let scheduler = RecordingNotificationScheduler() - let monitor = makeMonitor(scheduler: scheduler, localAuthorID: "me") + let monitor = makeMonitor(localAuthorID: "me") let gameID = UUID() await monitor.ingest([ makeDelta(gameID: gameID, authorID: "me", added: 99), makeDelta(gameID: gameID, authorID: "alice", added: 1), ]) - #expect(scheduler.added.count == 1) - #expect(scheduler.added.first?.identifier - == SessionMonitor.endIdentifier(for: gameID, authorID: "alice")) let selfTally = await monitor.tally(gameID: gameID, authorID: "me") + let aliceTally = await monitor.tally(gameID: gameID, authorID: "alice") #expect(selfTally == nil) + #expect(aliceTally?.added == 1) } @Test("Zero-count deltas are dropped") @MainActor func zeroCountSkipped() async { - let scheduler = RecordingNotificationScheduler() - let monitor = makeMonitor(scheduler: scheduler) + let monitor = makeMonitor() let gameID = UUID() await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 0, cleared: 0)]) - #expect(scheduler.added.isEmpty) let tally = await monitor.tally(gameID: gameID, authorID: "alice") #expect(tally == nil) } @Test("Active-puzzle suppression skips ingestion for that game") @MainActor func suppressionGateBlocks() async { - let scheduler = RecordingNotificationScheduler() let suppressed = UUID() let other = UUID() - let monitor = makeMonitor( - scheduler: scheduler, - suppressionGate: { $0 == suppressed } - ) + let monitor = makeMonitor(suppressionGate: { $0 == suppressed }) await monitor.ingest([ makeDelta(gameID: suppressed, authorID: "alice", added: 5), makeDelta(gameID: other, authorID: "alice", added: 5), ]) - #expect(scheduler.added.count == 1) - #expect(scheduler.added.first?.identifier - == SessionMonitor.endIdentifier(for: other, authorID: "alice")) + #expect(await monitor.tally(gameID: suppressed, authorID: "alice") == nil) + #expect(await monitor.tally(gameID: other, authorID: "alice")?.added == 5) } - @Test("Delta whose latestUpdate predates the quiescence window starts a fresh bucket after rollover") - @MainActor func sessionRollover() async { - let scheduler = RecordingNotificationScheduler() - let t0 = Date(timeIntervalSince1970: 1_000_000) - // After the first ingest the scheduledFor is at t0+window. Bump - // the clock past it; the next ingest must reset rather than - // accumulate. - let clock = MutableClock(initial: t0) - let monitor = makeMonitor(scheduler: scheduler, clock: clock.now) - let gameID = UUID() - await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 4, latestUpdate: t0)]) - clock.set(t0.addingTimeInterval(SessionMonitor.quiescenceWindow + 10)) - await monitor.ingest([makeDelta( - gameID: gameID, - authorID: "alice", - added: 1, - latestUpdate: clock.now() - )]) - let tally = await monitor.tally(gameID: gameID, authorID: "alice") - // Second session — first session's tally is treated as consumed. - #expect(tally?.added == 1) - } - - @Test("cancel(gameID:) drops every author's bucket for the game and withdraws the requests") + @Test("cancel(gameID:) drops every author's bucket for the game") @MainActor func cancelDropsAllAuthors() async { - let scheduler = RecordingNotificationScheduler() - let monitor = makeMonitor(scheduler: scheduler) + let monitor = makeMonitor() let gameID = UUID() await monitor.ingest([ makeDelta(gameID: gameID, authorID: "alice", added: 1), @@ -230,16 +98,11 @@ struct SessionMonitorTests { await monitor.cancel(gameID: gameID) #expect(await monitor.pendingBucketCount == 0) - - let removed = Set(scheduler.removedPending).union(scheduler.removedDelivered) - #expect(removed.contains(SessionMonitor.endIdentifier(for: gameID, authorID: "alice"))) - #expect(removed.contains(SessionMonitor.endIdentifier(for: gameID, authorID: "bob"))) } @Test("cancel(gameID:authorID:) only drops the named author's bucket") @MainActor func cancelTargetsOneAuthor() async { - let scheduler = RecordingNotificationScheduler() - let monitor = makeMonitor(scheduler: scheduler) + let monitor = makeMonitor() let gameID = UUID() await monitor.ingest([ makeDelta(gameID: gameID, authorID: "alice", added: 1), @@ -253,91 +116,11 @@ struct SessionMonitorTests { #expect(bobTally?.added == 1) } - @Test("Scheduling a new end-of-session withdraws the matching session-begin banner") - @MainActor func endWithdrawsBegin() async { - let scheduler = RecordingNotificationScheduler() - let monitor = makeMonitor(scheduler: scheduler) - let gameID = UUID() - await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 1)]) - - #expect(scheduler.removedDelivered.contains( - SessionMonitor.beginIdentifier(for: gameID, authorID: "alice") - )) - } - - @Test("Scheduling an end-of-session summary keeps the session-begin dedup gate") - @MainActor func endSchedulingKeepsBeginDedup() async { - let scheduler = RecordingNotificationScheduler() - let monitor = makeMonitor(scheduler: scheduler) - let gameID = UUID() - NotificationState.clearShown(gameID: gameID, authorID: "alice") - let session = Session( - recordName: "player-\(gameID.uuidString)-alice", - gameID: gameID, - authorID: "alice", - playerName: "Alice", - puzzleTitle: "Puzzle", - updatedAt: Date() - ) - - _ = await monitor.presentBegins([session]) - await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 1)]) - let notes = await monitor.presentBegins([session]) - - #expect(scheduler.added.filter { $0.identifier == SessionMonitor.beginIdentifier(for: gameID, authorID: "alice") }.count == 1) - #expect(notes.contains("session-begin: dedup-suppressed for \(gameID.uuidString) author=alice")) - NotificationState.clearShown(gameID: gameID, authorID: "alice") - } - - @Test("End-of-session summary uses the same puzzle title as session start") - @MainActor func endSummaryUsesFormattedPuzzleTitle() async throws { - let persistence = makeTestPersistence() - let context = persistence.viewContext - let gameID = UUID() - let date = try #require(Calendar(identifier: .gregorian).date( - from: DateComponents(year: 2001, month: 1, day: 1) - )) - - let game = GameEntity(context: context) - game.id = gameID - game.title = "Saturday Puzzle" - game.cachedPublisher = "The Daily Crossword" - game.cachedPuzzleDate = date - game.puzzleSource = "" - game.createdAt = date - game.updatedAt = date - game.ckRecordName = "game-\(gameID.uuidString)" - - let player = PlayerEntity(context: context) - player.authorID = "alice" - player.ckRecordName = "player-\(gameID.uuidString)-alice" - player.name = "Alice" - player.updatedAt = date - player.game = game - - try context.save() - - let scheduler = RecordingNotificationScheduler() - let monitor = SessionMonitor( - persistence: persistence, - localAuthorIDProvider: { nil }, - notificationCenter: scheduler - ) - - await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 1)]) - - let body = try #require(scheduler.added.first?.content.body) - let expectedTitle = PuzzleNotificationText.title(for: game) - #expect(body == "Alice added 1 letter in the puzzle '\(expectedTitle)'") - } - @Test("consumeOnOpen returns hydrated summaries and clears the buckets") @MainActor func consumeOnOpenReturnsAndClears() async { - let scheduler = RecordingNotificationScheduler() let monitor = SessionMonitor( persistence: makeTestPersistence(), localAuthorIDProvider: { nil }, - notificationCenter: scheduler, nameLookup: { _, authorID in ("Player \(authorID)", "Tuesday Mini") }, suppressionGate: { _ in false } ) @@ -354,18 +137,12 @@ struct SessionMonitorTests { #expect(byAuthor["alice"]?.puzzleTitle == "Tuesday Mini") #expect(byAuthor["bob"]?.added == 1) #expect(byAuthor["bob"]?.cleared == 2) - - // Buckets are drained, scheduled requests withdrawn. #expect(await monitor.pendingBucketCount == 0) - let withdrawn = Set(scheduler.removedPending).union(scheduler.removedDelivered) - #expect(withdrawn.contains(SessionMonitor.endIdentifier(for: gameID, authorID: "alice"))) - #expect(withdrawn.contains(SessionMonitor.endIdentifier(for: gameID, authorID: "bob"))) } @Test("consumeOnOpen on a game with no pending tallies returns an empty list") @MainActor func consumeOnOpenEmpty() async { - let scheduler = RecordingNotificationScheduler() - let monitor = makeMonitor(scheduler: scheduler) + let monitor = makeMonitor() let summaries = await monitor.consumeOnOpen(gameID: UUID()) #expect(summaries.isEmpty) } @@ -380,22 +157,3 @@ struct SessionMonitorTests { #expect(nameless == "A player added 3 letters in the puzzle") } } - -/// Mutable wall-clock for tests that need to advance time deterministically. -private final class MutableClock: @unchecked Sendable { - private let lock = NSLock() - private var current: Date - - init(initial: Date) { self.current = initial } - - func set(_ date: Date) { - lock.withLock { current = date } - } - - var now: @Sendable () -> Date { - { [weak self] in - guard let self else { return Date() } - return self.lock.withLock { self.current } - } - } -} diff --git a/Tests/Unit/XDAcceptTests.swift b/Tests/Unit/XDAcceptTests.swift @@ -259,6 +259,6 @@ struct XDAcceptTests { game.setLetter("IO", atRow: 0, atCol: 0, pencil: false) game.checkCells([game.puzzle.cells[0][0]]) - #expect(game.squares[0][0].mark == .none) + #expect(game.squares[0][0].mark == .pen(checked: .right)) } } diff --git a/Worker/push-worker.js b/Worker/push-worker.js @@ -0,0 +1,272 @@ +export class PushRegistry { + constructor(state, env) { + this.state = state; + this.env = env; + this.cachedJWT = null; + this.cachedJWTExpiresAt = 0; + } + + async fetch(request) { + const url = new URL(request.url); + const auth = this.authenticate(request); + if (!auth.ok) { + return new Response(auth.message, { status: auth.status }); + } + + if (url.pathname === "/register" && request.method === "POST") { + return this.handleRegister(request); + } + if (url.pathname === "/register" && request.method === "DELETE") { + return this.handleUnregister(request); + } + if (url.pathname === "/publish" && request.method === "POST") { + return this.handlePublish(request); + } + return new Response("Not found", { status: 404 }); + } + + authenticate(request) { + const header = request.headers.get("Authorization") || ""; + const expected = `Bearer ${this.env.PUSH_BEARER || ""}`; + if (!this.env.PUSH_BEARER) { + return { ok: false, status: 500, message: "Worker missing PUSH_BEARER" }; + } + if (!timingSafeEqual(header, expected)) { + return { ok: false, status: 401, message: "Bad bearer" }; + } + return { ok: true }; + } + + async handleRegister(request) { + const body = await readJSON(request); + if (!body) return badRequest("Body must be JSON"); + const { authorID, deviceID, token, environment } = body; + if (!authorID || !deviceID || !token) { + return badRequest("authorID, deviceID, token required"); + } + if (environment !== "sandbox" && environment !== "production") { + return badRequest("environment must be 'sandbox' or 'production'"); + } + const key = `token:${authorID}:${deviceID}`; + await this.state.storage.put(key, { + token, + environment, + updatedAt: Date.now() + }); + return new Response(null, { status: 204 }); + } + + async handleUnregister(request) { + const body = await readJSON(request); + if (!body) return badRequest("Body must be JSON"); + const { authorID, deviceID } = body; + if (!authorID || !deviceID) { + return badRequest("authorID and deviceID required"); + } + await this.state.storage.delete(`token:${authorID}:${deviceID}`); + return new Response(null, { status: 204 }); + } + + async handlePublish(request) { + const body = await readJSON(request); + if (!body) return badRequest("Body must be JSON"); + const { kind, addressees, gameID, fromAuthorID, title, alertBody } = body; + if (!kind || !Array.isArray(addressees) || addressees.length === 0) { + return badRequest("kind and non-empty addressees required"); + } + + const targets = await this.resolveTargets(addressees); + if (targets.length === 0) { + return Response.json({ delivered: 0, removed: 0, failed: 0 }); + } + + let delivered = 0; + let removed = 0; + let failed = 0; + for (const target of targets) { + const result = await this.sendOne(target, { + kind, + gameID, + fromAuthorID, + title, + body: alertBody + }); + if (result === "ok") delivered += 1; + else if (result === "drop") { + await this.state.storage.delete(`token:${target.authorID}:${target.deviceID}`); + removed += 1; + } else { + failed += 1; + } + } + return Response.json({ delivered, removed, failed }); + } + + async resolveTargets(addressees) { + const targets = []; + for (const addressee of addressees) { + if (!addressee || !addressee.authorID) continue; + if (addressee.deviceID) { + const stored = await this.state.storage.get( + `token:${addressee.authorID}:${addressee.deviceID}` + ); + if (stored) { + targets.push({ + authorID: addressee.authorID, + deviceID: addressee.deviceID, + ...stored + }); + } + continue; + } + const map = await this.state.storage.list({ + prefix: `token:${addressee.authorID}:` + }); + for (const [key, value] of map) { + const deviceID = key.slice(`token:${addressee.authorID}:`.length); + targets.push({ + authorID: addressee.authorID, + deviceID, + ...value + }); + } + } + return targets; + } + + async sendOne(target, message) { + const topic = this.env.APNS_TOPIC || "net.inqk.crossmate"; + const host = target.environment === "sandbox" + ? "api.sandbox.push.apple.com" + : "api.push.apple.com"; + const jwt = await this.providerJWT(); + const alert = {}; + if (message.title) alert.title = message.title; + if (message.body) alert.body = message.body; + const payload = { + aps: { alert, sound: "default" }, + kind: message.kind + }; + if (message.gameID) payload.gameID = message.gameID; + if (message.fromAuthorID) payload.fromAuthorID = message.fromAuthorID; + + const response = await fetch(`https://${host}/3/device/${target.token}`, { + method: "POST", + headers: { + authorization: `bearer ${jwt}`, + "apns-topic": topic, + "apns-push-type": "alert", + "apns-priority": "10", + "apns-expiration": "0", + "content-type": "application/json" + }, + body: JSON.stringify(payload) + }); + + if (response.status === 200) return "ok"; + if (response.status === 410) return "drop"; + if (response.status === 400) { + const text = await response.text(); + if (text.includes("BadDeviceToken") || text.includes("DeviceTokenNotForTopic")) { + return "drop"; + } + } + return "fail"; + } + + async providerJWT() { + const nowSeconds = Math.floor(Date.now() / 1000); + if (this.cachedJWT && nowSeconds < this.cachedJWTExpiresAt - 60) { + return this.cachedJWT; + } + const jwt = await signProviderJWT({ + keyPEM: this.env.APNS_KEY, + keyID: this.env.APNS_KEY_ID, + teamID: this.env.APNS_TEAM_ID, + issuedAt: nowSeconds + }); + this.cachedJWT = jwt; + // Refresh well before APNs' 1-hour ceiling; the rate-limit floor is ~20 min. + this.cachedJWTExpiresAt = nowSeconds + 40 * 60; + return jwt; + } +} + +export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === "/health") { + return new Response("ok"); + } + const id = env.PUSH_REGISTRY.idFromName("registry"); + return env.PUSH_REGISTRY.get(id).fetch(request); + } +}; + +async function readJSON(request) { + try { + return await request.json(); + } catch { + return null; + } +} + +function badRequest(message) { + return new Response(message, { status: 400 }); +} + +async function signProviderJWT({ keyPEM, keyID, teamID, issuedAt }) { + if (!keyPEM || !keyID || !teamID) { + throw new Error("APNS_KEY, APNS_KEY_ID, APNS_TEAM_ID must all be set"); + } + const key = await importP8(keyPEM); + const header = base64URLEncode(new TextEncoder().encode(JSON.stringify({ + alg: "ES256", + kid: keyID + }))); + const claims = base64URLEncode(new TextEncoder().encode(JSON.stringify({ + iss: teamID, + iat: issuedAt + }))); + const signingInput = `${header}.${claims}`; + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + key, + new TextEncoder().encode(signingInput) + ); + return `${signingInput}.${base64URLEncode(new Uint8Array(signature))}`; +} + +async function importP8(pem) { + const stripped = pem + .replace(/-----BEGIN PRIVATE KEY-----/g, "") + .replace(/-----END PRIVATE KEY-----/g, "") + .replace(/\s+/g, ""); + const der = Uint8Array.from(atob(stripped), (char) => char.charCodeAt(0)); + return crypto.subtle.importKey( + "pkcs8", + der, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign"] + ); +} + +function base64URLEncode(bytes) { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function timingSafeEqual(a, b) { + const left = new TextEncoder().encode(a); + const right = new TextEncoder().encode(b); + if (left.length !== right.length) return false; + let diff = 0; + for (let index = 0; index < left.length; index += 1) { + diff |= left[index] ^ right[index]; + } + return diff === 0; +} diff --git a/Worker/wrangler.push.toml b/Worker/wrangler.push.toml @@ -0,0 +1,16 @@ +name = "crossmate-push" +main = "push-worker.js" +compatibility_date = "2026-05-25" +workers_dev = true +preview_urls = false + +[vars] +APNS_TOPIC = "net.inqk.crossmate" + +[[durable_objects.bindings]] +name = "PUSH_REGISTRY" +class_name = "PushRegistry" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["PushRegistry"] diff --git a/project.yml b/project.yml @@ -68,6 +68,8 @@ targets: - com.litsoft.puz CKSharingSupported: true CrossmateEngagementSocketURL: $(CROSSMATE_ENGAGEMENT_SOCKET_URL) + CrossmatePushBaseURL: $(CROSSMATE_PUSH_BASE_URL) + CrossmatePushBearer: $(CROSSMATE_PUSH_BEARER) LSSupportsOpeningDocumentsInPlace: false UILaunchScreen: {} UISupportedInterfaceOrientations: @@ -81,6 +83,8 @@ targets: INFOPLIST_FILE: Crossmate/Info.plist CODE_SIGN_ENTITLEMENTS: Crossmate/Crossmate.entitlements CROSSMATE_ENGAGEMENT_SOCKET_URL: $(inherited) + CROSSMATE_PUSH_BASE_URL: $(inherited) + CROSSMATE_PUSH_BEARER: $(inherited) TARGETED_DEVICE_FAMILY: "1,2" CODE_SIGN_STYLE: Automatic configs: