commit a0bfc5724aa5e592d1aa337f5e8e50a78dbdb918
parent 0bc11f38b39306bad211020566e1eec5e4668814
Author: Michael Camilleri <[email protected]>
Date: Mon, 25 May 2026 16:04:53 +0900
Add WebRTC engagement bridge for co-solving engagements
An engagement is a per-game peer-to-peer WebRTC bridge that runs alongside the
CKSyncEngine path. When two players are in the same shared game and both hold a
fresh Player.readAt lease, one of them — chosen deterministically by author-ID
ordering — sends a new `.hail` Ping carrying an SDP offer; the other replies in
the same form and the pair upgrades to a direct RTCDataChannel. CloudKit only
carries signaling from that point on, so the steady-state cost of co-solving
collapses from one CKSyncEngine round trip per cell edit to a UDP datagram.
This first cut uses only Cloudflare's STUN server; a TURN fallback for
symmetric NATs is not yet implemented, so some pairs on hostile networks will
silently fall back to the existing sync path.
The peer connection itself is hosted in a WKWebView because WebRTC isn't a
first-class Apple framework. EngagementHost.html exposes a small JavaScript
shim (createOffer, acceptOfferAndReply, acceptReply, send, teardown) and
message-channels base64-encoded payloads back through EngagementHost.swift.
EngagementCoordinator is an actor that owns the offered/replied/live state
machine per game, resolves glare with a deterministic author/device tie-break
and ages hails out at 120s so a retried offer can't drag a stale handshake back
to life. Hail addressees are encoded 'authorID:deviceID' so a single author
with multiple devices replies from exactly one.
Two message kinds ride the channel today: cellEdit and selection. Cell edits
hook in at GameMutator.applyLetter, which forwards a RealtimeCellEdit to
GameStore.onLocalCellEdit whenever an engagement is live; the peer's
GameStore.applyRealtimeCellEdit walks the same MovesEntity path the CloudKit
ingest uses, so the local store remains the single source of truth and any
missed datagram is reconciled the moment the next CloudKit fetch lands.
Selection updates flow through AppServices.noteLocalSelection, merge into
PlayerRoster.remoteSelections via the new EngagementStore, and respect the same
freshness window as the persisted peer cursors — the rendered reticle is
whichever source last updated.
UI integration is intentionally small. The puzzle header shows a green
bolt.circle overlay next to the title while the channel is live; the puzzle
debug menu carries Offer Engagement and Send Engagement Test Message for manual
exercising. Relatedly, Settings → Debug Tools gains a WebRTC Host Test page
(EngagementDebugView) that drives the host in isolation. Channel teardown and
EngagementStore.clear are wired into the puzzle's onDisappear so closing the
puzzle also closes the bridge.
A pair of one-shot device-local migrations are included in this commit but are
intended to be removed later. purgeStaleHailPings_v1 clears any .hail Ping
records lingering in game zones from earlier internal builds before the current
deletion-as-ack path settled, and purgeDebugPreviewFriends_v1 removes leftover
friend-debug-preview-* rows whose zones no longer exist (they were poisoning
the ping fast-path query on every poll). Both gate on NotificationState flags
so they run once per install.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
24 files changed, 2882 insertions(+), 34 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -12,6 +12,7 @@
0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */; };
02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */; };
04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */; };
+ 06AE6DF7AA3274480C591E47 /* EngagementStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09D52DB46731E92C3E9297C /* EngagementStore.swift */; };
0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; };
1016604FBD4D63A0B9AAE503 /* CloudQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */; };
128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */; };
@@ -21,12 +22,16 @@
1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BDD06460A76D4AF31077732 /* InputMonitor.swift */; };
1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */; };
1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; };
+ 1F6644A367B3B8DD1F9CEB7B /* EngagementDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D854041D0A7EF28B5F730D91 /* EngagementDebugView.swift */; };
+ 267ED5B329F05A30430B73A0 /* EngagementHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C701DAE36000DE19F7CC95 /* EngagementHost.swift */; };
+ 2AA16C362CE4161447C21161 /* EngagementHost.html in Resources */ = {isa = PBXBuildFile; fileRef = 1BA85E43D4C590126DE84C5A /* EngagementHost.html */; };
2AF2550B08CE79F8615B3076 /* FriendZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */; };
2B03A1A36AB55495ED0E8684 /* HardwareKeyboardInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947102E58EFCF898D258AC3E /* HardwareKeyboardInputView.swift */; };
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; };
2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */; };
309457EC2DFEC476253D54D2 /* PlayerSelectionPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */; };
31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */; };
+ 328309D8CC72CCB5623FB2A1 /* EngagementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67CFF96D54D2DE9C44EB120A /* EngagementCoordinatorTests.swift */; };
350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */; };
38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; };
3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; };
@@ -73,6 +78,7 @@
98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; };
9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.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 */; };
AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */; };
AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */; };
@@ -94,6 +100,7 @@
C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */; };
C8ACF431021E7BEE61A99153 /* FriendController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E655698481325C92EF5C348B /* FriendController.swift */; };
C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */; };
+ CABF8BFAA30B9F26C482FAB9 /* EngagementCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FDE03B4A77A8095ED2C23AB /* EngagementCoordinator.swift */; };
CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BC76178319D0D669CD50FF /* CloudService.swift */; };
CCF3867C32C3F36E4F69A59E /* DebuggingMonitors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */; };
CEDF853009D0C367035F1F76 /* PlayerNamePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */; };
@@ -145,6 +152,8 @@
16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudQuery.swift; sourceTree = "<group>"; };
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>"; };
+ 1BA85E43D4C590126DE84C5A /* EngagementHost.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = EngagementHost.html; 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>"; };
@@ -156,6 +165,7 @@
3292748EAE27B608C769D393 /* PlayerRoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRoster.swift; sourceTree = "<group>"; };
33878A29B09A6154C7A63C82 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
38DDAD9D6470A894C3FD6F90 /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; };
+ 400B3C87248F3FCA3F76400B /* EngagementHostEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementHostEnvironment.swift; sourceTree = "<group>"; };
434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsView.swift; sourceTree = "<group>"; };
43DC132D49361C56DE79C13E /* GameMutator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutator.swift; sourceTree = "<group>"; };
443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerMovesTests.swift; sourceTree = "<group>"; };
@@ -177,6 +187,7 @@
60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStoreTests.swift; sourceTree = "<group>"; };
61687BB3C75A4E1E6ABC82CA /* AnnouncementBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementBanner.swift; sourceTree = "<group>"; };
64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; };
+ 67CFF96D54D2DE9C44EB120A /* EngagementCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementCoordinatorTests.swift; sourceTree = "<group>"; };
68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareRoutingTests.swift; sourceTree = "<group>"; };
6BDD06460A76D4AF31077732 /* InputMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMonitor.swift; sourceTree = "<group>"; };
6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridStateMergerTests.swift; sourceTree = "<group>"; };
@@ -194,6 +205,7 @@
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>"; };
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>"; };
93EE5BA78566EDED68D846AB /* GameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStore.swift; sourceTree = "<group>"; };
9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
@@ -210,6 +222,7 @@
ACC295195602B3DDF7BB3895 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleView.swift; sourceTree = "<group>"; };
B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleFetcher.swift; sourceTree = "<group>"; };
+ B09D52DB46731E92C3E9297C /* EngagementStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementStore.swift; sourceTree = "<group>"; };
B135C285570F91181595B405 /* CellMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMark.swift; sourceTree = "<group>"; };
B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorIdentity.swift; sourceTree = "<group>"; };
B23A692318044351247606DF /* SuccessPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessPanel.swift; sourceTree = "<group>"; };
@@ -233,6 +246,7 @@
CFC4FF046BF772646B5CA73F /* Presence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presence.swift; sourceTree = "<group>"; };
D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationText.swift; sourceTree = "<group>"; };
D491B7232333AA8957732387 /* PendingEditFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingEditFlagTests.swift; sourceTree = "<group>"; };
+ D854041D0A7EF28B5F730D91 /* EngagementDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementDebugView.swift; sourceTree = "<group>"; };
D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; };
DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; };
@@ -272,6 +286,7 @@
07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */,
16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */,
44F86F0F1883A93F9622FB67 /* CloudZones.swift */,
+ 8FDE03B4A77A8095ED2C23AB /* EngagementCoordinator.swift */,
E655698481325C92EF5C348B /* FriendController.swift */,
7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */,
14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */,
@@ -335,6 +350,7 @@
isa = PBXGroup;
children = (
B135C285570F91181595B405 /* CellMark.swift */,
+ B09D52DB46731E92C3E9297C /* EngagementStore.swift */,
465F2BB469EFE84CF3733398 /* Game.swift */,
8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */,
DB55FC337CF72C650373210A /* PlayerColor.swift */,
@@ -398,6 +414,7 @@
F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */,
E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */,
434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */,
+ D854041D0A7EF28B5F730D91 /* EngagementDebugView.swift */,
F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */,
EE3412F437AABD2988B6976D /* FriendPickerView.swift */,
38DDAD9D6470A894C3FD6F90 /* GameListView.swift */,
@@ -432,6 +449,7 @@
9998739ED0875A17271B7899 /* AppServicesAnnouncementTests.swift */,
2570F892610F1834C92F7F6E /* AuthorDeltaTests.swift */,
457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */,
+ 67CFF96D54D2DE9C44EB120A /* EngagementCoordinatorTests.swift */,
94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */,
B766E872B12DC79ECCD80941 /* FriendModelTests.swift */,
800CCFBE90554F287E765755 /* FriendZoneTests.swift */,
@@ -466,6 +484,9 @@
56BC76178319D0D669CD50FF /* CloudService.swift */,
16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */,
70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */,
+ 1BA85E43D4C590126DE84C5A /* EngagementHost.html */,
+ 18C701DAE36000DE19F7CC95 /* EngagementHost.swift */,
+ 400B3C87248F3FCA3F76400B /* EngagementHostEnvironment.swift */,
462CE0FD356F6137C9BFD30F /* ImportService.swift */,
6BDD06460A76D4AF31077732 /* InputMonitor.swift */,
33878A29B09A6154C7A63C82 /* KeychainHelper.swift */,
@@ -562,6 +583,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 2AA16C362CE4161447C21161 /* EngagementHost.html in Resources */,
9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */,
8B356C953DA0FAF149C3391A /* Puzzles in Resources */,
);
@@ -578,6 +600,7 @@
D519F54F8CE0BD53D9C6144C /* AppServicesAnnouncementTests.swift in Sources */,
C2D8A9C79D75DBEF45720927 /* AuthorDeltaTests.swift in Sources */,
A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */,
+ 328309D8CC72CCB5623FB2A1 /* EngagementCoordinatorTests.swift in Sources */,
02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */,
6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */,
712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */,
@@ -636,6 +659,11 @@
CCF3867C32C3F36E4F69A59E /* DebuggingMonitors.swift in Sources */,
1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */,
978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */,
+ CABF8BFAA30B9F26C482FAB9 /* EngagementCoordinator.swift in Sources */,
+ 1F6644A367B3B8DD1F9CEB7B /* EngagementDebugView.swift in Sources */,
+ 267ED5B329F05A30430B73A0 /* EngagementHost.swift in Sources */,
+ A7FA870D794CA00F7F3F05D2 /* EngagementHostEnvironment.swift in Sources */,
+ 06AE6DF7AA3274480C591E47 /* EngagementStore.swift in Sources */,
4C874B69A9D9187490BC2C42 /* FriendAvatarView.swift in Sources */,
C8ACF431021E7BEE61A99153 /* FriendController.swift in Sources */,
886A451A134D88B9324D9A1C /* FriendPickerView.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -25,6 +25,14 @@ struct CrossmateApp: App {
.environment(services.syncMonitor)
.environment(services.eventLog)
.environment(\.syncEngine, services.syncEngine)
+ .environment(\.engagementHost, services.engagementHost)
+ .environment(\.engagementStatus, services.engagementStatus)
+ .environment(\.offerEngagement, { gameID in
+ await services.offerEngagement(gameID: gameID)
+ })
+ .environment(\.sendEngagementTestMessage, { gameID in
+ await services.sendEngagementTestMessage(gameID: gameID)
+ })
.environment(services.nytAuth)
.environment(\.nytPuzzleFetcher, services.nytFetcher)
.environment(\.resetDatabase, {
@@ -366,6 +374,11 @@ private struct PuzzleDisplayView: View {
/// Long safety-belt cadence used when no pushes have arrived for a while.
/// Trades latency-on-missed-push for far fewer CloudKit reads at idle.
private static let idlePollingInterval: Duration = .seconds(60)
+ /// Opening a puzzle is the highest-probability moment for an engagement
+ /// handshake. Simulators and same-account devices do not always deliver a
+ /// useful CloudKit push, so keep the catch-up poll tight briefly before
+ /// falling back to the idle cadence.
+ private static let openPuzzleWarmupInterval: TimeInterval = 2 * 60
/// How recent a push must be to count as "active". Slightly longer than
/// the active interval so a burst with brief pauses keeps tight polling.
private static let activityWindow: TimeInterval = 30
@@ -583,6 +596,7 @@ private struct PuzzleDisplayView: View {
let syncEngine = services.syncEngine
let id = gameID
Task {
+ await services.endEngagement(gameID: id)
await movesUpdater.flush()
// Mirror the open-burst pattern: the clear-cursor and
// close-lease enqueues both target the same Player record,
@@ -681,15 +695,20 @@ private struct PuzzleDisplayView: View {
)
if let track = session.currentCursorTrack {
await selectionPublisher.publishImmediately(track)
+ await services.noteLocalSelection(track, gameID: gameID)
}
if let burstScope {
await syncEngine.endPlayerSendBurst(scope: burstScope)
}
- session.onSelectionChanged = { selection in
- Task { await selectionPublisher.publish(selection) }
- }
+ await services.startEngagementIfPossible(gameID: gameID)
let services = self.services
let eventGameID = gameID
+ session.onSelectionChanged = { selection in
+ Task {
+ await selectionPublisher.publish(selection)
+ await services.noteLocalSelection(selection, gameID: eventGameID)
+ }
+ }
session.onPlayerEvent = { kind, scope in
Task {
await services.sendPlayerEventPings(
@@ -704,9 +723,11 @@ private struct PuzzleDisplayView: View {
private func pollOpenSyncedPuzzle() async {
guard let scope = syncedScope else { return }
var lastReadLeaseRefresh = Date.distantPast
+ let openedAt = Date()
await services.freshenPuzzleGrid(gameID: gameID, scope: scope, reason: .appeared)
while !Task.isCancelled {
- let interval = services.hadRecentRemoteNotification(within: Self.activityWindow)
+ let isWarm = Date().timeIntervalSince(openedAt) <= Self.openPuzzleWarmupInterval
+ let interval = isWarm || services.hadRecentRemoteNotification(within: Self.activityWindow)
? Self.activePollingInterval
: Self.idlePollingInterval
do {
diff --git a/Crossmate/Models/EngagementStore.swift b/Crossmate/Models/EngagementStore.swift
@@ -0,0 +1,74 @@
+import Foundation
+import Observation
+
+struct EngagementSelectionUpdate: Codable, Equatable, Sendable {
+ var gameID: UUID
+ var authorID: String
+ var deviceID: String
+ var row: Int
+ var col: Int
+ var directionRaw: Int
+ var updatedAt: Date
+
+ init(
+ gameID: UUID,
+ authorID: String,
+ deviceID: String,
+ selection: PlayerSelection,
+ updatedAt: Date = Date()
+ ) {
+ self.gameID = gameID
+ self.authorID = authorID
+ self.deviceID = deviceID
+ self.row = selection.row
+ self.col = selection.col
+ self.directionRaw = selection.direction.rawValue
+ self.updatedAt = updatedAt
+ }
+
+ var selection: PlayerSelection? {
+ guard let direction = Puzzle.Direction(rawValue: directionRaw) else { return nil }
+ return PlayerSelection(row: row, col: col, direction: direction)
+ }
+}
+
+@MainActor
+@Observable
+final class EngagementStore {
+ struct Entry: Equatable, Sendable {
+ let authorID: String
+ let row: Int
+ let col: Int
+ let direction: Puzzle.Direction
+ let updatedAt: Date
+ }
+
+ private var selectionsByGame: [UUID: [String: Entry]] = [:]
+
+ func set(_ update: EngagementSelectionUpdate) {
+ guard !update.authorID.isEmpty,
+ !update.deviceID.isEmpty,
+ update.deviceID != RecordSerializer.localDeviceID,
+ let selection = update.selection
+ else { return }
+
+ let entry = Entry(
+ authorID: update.authorID,
+ row: selection.row,
+ col: selection.col,
+ direction: selection.direction,
+ updatedAt: update.updatedAt
+ )
+ let current = selectionsByGame[update.gameID]?[update.authorID]
+ guard current.map({ $0.updatedAt <= entry.updatedAt }) ?? true else { return }
+ selectionsByGame[update.gameID, default: [:]][update.authorID] = entry
+ }
+
+ func selections(for gameID: UUID) -> [String: Entry] {
+ selectionsByGame[gameID] ?? [:]
+ }
+
+ func clear(gameID: UUID) {
+ selectionsByGame[gameID] = nil
+ }
+}
diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift
@@ -37,7 +37,33 @@ final class PlayerRoster {
/// Peer cursor tracks keyed by `authorID`. Stale entries (older than the
/// freshness window) are dropped on each refresh. The local player is
/// never present in this map.
- private(set) var remoteSelections: [String: RemoteSelection] = [:]
+ private var persistedRemoteSelections: [String: RemoteSelection] = [:]
+
+ var remoteSelections: [String: RemoteSelection] {
+ var merged = persistedRemoteSelections
+ let colorByAuthor = Dictionary(
+ uniqueKeysWithValues: entries.filter { !$0.isLocal }.map { ($0.authorID, $0.color) }
+ )
+ let now = Date()
+ for (authorID, engagement) in engagementStore.selections(for: gameID) {
+ guard let color = colorByAuthor[authorID] else { continue }
+ guard now.timeIntervalSince(engagement.updatedAt) < selectionFreshnessWindow else {
+ continue
+ }
+ let selection = RemoteSelection(
+ authorID: authorID,
+ row: engagement.row,
+ col: engagement.col,
+ direction: engagement.direction,
+ color: color,
+ updatedAt: engagement.updatedAt
+ )
+ if merged[authorID].map({ $0.updatedAt <= selection.updatedAt }) ?? true {
+ merged[authorID] = selection
+ }
+ }
+ return merged
+ }
/// Selections older than this are treated as stale and hidden — covers
/// the case where the peer crashed or lost connectivity without writing
@@ -53,6 +79,7 @@ final class PlayerRoster {
private let preferences: PlayerPreferences
private let persistence: PersistenceController
private let container: CKContainer
+ private let engagementStore: EngagementStore
private let tracer: (@MainActor @Sendable (String) -> Void)?
private var cachedShare: CKShare?
@@ -65,6 +92,7 @@ final class PlayerRoster {
preferences: PlayerPreferences,
persistence: PersistenceController,
container: CKContainer,
+ engagementStore: EngagementStore = EngagementStore(),
tracer: (@MainActor @Sendable (String) -> Void)? = nil
) {
self.gameID = gameID
@@ -72,6 +100,7 @@ final class PlayerRoster {
self.preferences = preferences
self.persistence = persistence
self.container = container
+ self.engagementStore = engagementStore
self.tracer = tracer
startObserving()
}
@@ -116,7 +145,7 @@ final class PlayerRoster {
guard let localAuthorID = authorIdentity.currentID else {
self.localAuthorID = nil
entries = []
- remoteSelections = [:]
+ persistedRemoteSelections = [:]
return
}
self.localAuthorID = localAuthorID
@@ -279,7 +308,7 @@ final class PlayerRoster {
updatedAt: raw.updatedAt
)
}
- remoteSelections = fresh
+ persistedRemoteSelections = fresh
}
// MARK: - Private helpers
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -17,6 +17,7 @@ final class GameMutator {
let gameID: UUID
private let movesUpdater: MovesUpdater?
private let authorIDProvider: (@MainActor () -> String?)?
+ private let onLocalCellEdit: (@MainActor (RealtimeCellEdit) -> Void)?
/// `true` when the current user owns the CloudKit zone for this game.
let isOwned: Bool
@@ -36,6 +37,7 @@ final class GameMutator {
gameID: UUID,
movesUpdater: MovesUpdater?,
authorIDProvider: (@MainActor () -> String?)? = nil,
+ onLocalCellEdit: (@MainActor (RealtimeCellEdit) -> Void)? = nil,
isOwned: Bool = true,
isShared: Bool = false,
isAccessRevoked: Bool = false
@@ -44,6 +46,7 @@ final class GameMutator {
self.gameID = gameID
self.movesUpdater = movesUpdater
self.authorIDProvider = authorIDProvider
+ self.onLocalCellEdit = onLocalCellEdit
self.isOwned = isOwned
self.isShared = isShared
self.isAccessRevoked = isAccessRevoked
@@ -112,6 +115,20 @@ final class GameMutator {
// `restore` later recognises the edit has landed and retires the flag.
let enqueuedAt = Date()
game.squares[row][col].enqueuedAt = enqueuedAt
+ if let actingAuthorID, !actingAuthorID.isEmpty {
+ onLocalCellEdit?(RealtimeCellEdit(
+ gameID: id,
+ authorID: actingAuthorID,
+ deviceID: RecordSerializer.localDeviceID,
+ row: row,
+ col: col,
+ letter: letter,
+ markKind: markKind,
+ checkedWrong: checkedWrong,
+ updatedAt: enqueuedAt,
+ cellAuthorID: cellAuthorID
+ ))
+ }
Task {
await movesUpdater.enqueue(
gameID: id,
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -235,6 +235,8 @@ final class GameStore {
/// deleted). Consumers refresh the app-icon badge from here.
@ObservationIgnored
var onUnreadOtherMovesChanged: (() -> Void)?
+ @ObservationIgnored
+ var onLocalCellEdit: (@MainActor (RealtimeCellEdit) -> Void)?
private let eventLog: EventLog?
@@ -362,6 +364,59 @@ final class GameStore {
onUnreadOtherMovesChanged?()
}
+ @discardableResult
+ func applyRealtimeCellEdit(_ edit: RealtimeCellEdit) -> Bool {
+ guard !edit.authorID.isEmpty,
+ !edit.deviceID.isEmpty,
+ edit.deviceID != RecordSerializer.localDeviceID,
+ let entity = fetchGameEntity(id: edit.gameID)
+ else { return false }
+
+ let recordName = RecordSerializer.recordName(
+ forMovesInGame: edit.gameID,
+ authorID: edit.authorID,
+ deviceID: edit.deviceID
+ )
+ let movesEntity = ensureMovesEntity(
+ recordName: recordName,
+ game: entity,
+ authorID: edit.authorID,
+ deviceID: edit.deviceID
+ )
+
+ var cells: [GridPosition: TimestampedCell] = [:]
+ if let data = movesEntity.cells, !data.isEmpty {
+ cells = (try? MovesCodec.decode(data)) ?? [:]
+ }
+
+ let position = GridPosition(row: edit.row, col: edit.col)
+ let incoming = TimestampedCell(
+ letter: edit.letter,
+ markKind: edit.markKind,
+ checkedWrong: edit.checkedWrong,
+ updatedAt: edit.updatedAt,
+ authorID: edit.cellAuthorID
+ )
+ if let current = cells[position], current.updatedAt > incoming.updatedAt {
+ return false
+ }
+
+ cells[position] = incoming
+ movesEntity.cells = (try? MovesCodec.encode(cells)) ?? Data()
+ if (movesEntity.updatedAt ?? .distantPast) < edit.updatedAt {
+ movesEntity.updatedAt = edit.updatedAt
+ }
+ if (entity.updatedAt ?? .distantPast) < edit.updatedAt {
+ entity.updatedAt = edit.updatedAt
+ }
+ saveContext("applyRealtimeCellEdit")
+ if currentEntity?.id == edit.gameID {
+ refreshCurrentGame()
+ }
+ onUnreadOtherMovesChanged?()
+ return true
+ }
+
/// Number of shared games with unseen other-author moves — the same
/// `hasUnreadOtherMoves` heuristic the library list uses, aggregated as
/// a count for the app-icon badge.
@@ -935,12 +990,45 @@ final class GameStore {
gameID: gameID,
movesUpdater: movesUpdater,
authorIDProvider: authorIDProvider,
+ onLocalCellEdit: { [weak self] edit in
+ self?.onLocalCellEdit?(edit)
+ },
isOwned: entity.databaseScope == 0,
isShared: entity.ckShareRecordName != nil || entity.databaseScope == 1,
isAccessRevoked: entity.isAccessRevoked
)
}
+ private func fetchGameEntity(id gameID: UUID) -> GameEntity? {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ req.fetchLimit = 1
+ return try? context.fetch(req).first
+ }
+
+ private func ensureMovesEntity(
+ recordName: String,
+ game: GameEntity,
+ authorID: String,
+ deviceID: String
+ ) -> MovesEntity {
+ let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
+ req.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
+ req.fetchLimit = 1
+ if let existing = try? context.fetch(req).first {
+ return existing
+ }
+
+ let entity = MovesEntity(context: context)
+ entity.game = game
+ entity.ckRecordName = recordName
+ entity.authorID = authorID
+ entity.deviceID = deviceID
+ entity.cells = Data()
+ entity.updatedAt = Date()
+ return entity
+ }
+
// MARK: - CellMark coding
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -59,8 +59,34 @@ final class AppServices {
let shareController: ShareController
let friendController: FriendController
let cursorStore: GameCursorStore
+ let engagementStore: EngagementStore
let cloudService: CloudService
let importService: ImportService
+ let engagementHost: EngagementHost
+ let engagementStatus = EngagementStatus()
+ private lazy var engagementCoordinator = EngagementCoordinator(
+ host: engagementHost,
+ localAuthorID: { [weak self] in
+ await MainActor.run { self?.identity.currentID }
+ },
+ presentPeers: { [weak self] gameIDs in
+ guard let self else { return [:] }
+ return await Self.presentPeers(
+ persistence: self.persistence,
+ gameIDs: gameIDs,
+ localAuthorID: self.identity.currentID
+ )
+ },
+ sendHail: { [weak self] gameID, payload, addressee in
+ await self?.sendHailPing(gameID: gameID, payload: payload, addressee: addressee)
+ },
+ deletePing: { [weak self] recordName, gameID in
+ await self?.syncEngine.deletePing(recordName: recordName, gameID: gameID)
+ },
+ log: { [weak self] message in
+ await MainActor.run { self?.syncMonitor.note(message) }
+ }
+ )
let preferences: PlayerPreferences
@@ -99,6 +125,7 @@ final class AppServices {
private let gameListFreshenCooldown: TimeInterval = 300
private var fresheningPuzzleGridKeys: Set<String> = []
private var isGameListVisible = false
+ private var latestLocalSelections: [UUID: PlayerSelection] = [:]
init() {
let preferences = PlayerPreferences()
@@ -160,6 +187,8 @@ final class AppServices {
let cursorStore = GameCursorStore()
self.cursorStore = cursorStore
+ let engagementStore = EngagementStore()
+ self.engagementStore = engagementStore
let onGameDeletedHandler = Self.makeOnGameDeleted(
syncEngine: syncEngine,
cursorStore: cursorStore
@@ -237,6 +266,16 @@ final class AppServices {
store: store
)
self.importService = ImportService(store: store, driveMonitor: self.driveMonitor)
+ self.engagementHost = EngagementHost()
+ self.engagementHost.onEvent = { [weak self] event in
+ self?.handleEngagementEvent(event)
+ }
+ self.store.onLocalCellEdit = { [weak self] edit in
+ guard let self,
+ self.engagementStatus.isLive(gameID: edit.gameID)
+ else { return }
+ Task { await self.engagementCoordinator.sendCellEdit(edit) }
+ }
}
func start(appDelegate: AppDelegate) async {
@@ -305,6 +344,7 @@ final class AppServices {
// lease; nudge the selection publisher to re-evaluate its gate
// and ship any selection it was holding for an absent audience.
await self?.playerSelectionPublisher.peerPresenceMayHaveChanged(gameIDs: gameIDs)
+ await self?.engagementCoordinator.peerPresenceMayHaveChanged(gameIDs: gameIDs)
}
// A sibling device of the same iCloud account has published its read
@@ -445,6 +485,16 @@ final class AppServices {
await syncMonitor.run("foreground fetch") {
try await syncEngine.fetchChanges(source: "foreground")
}
+ _ = await fetchPushPingsDirect(
+ scope: .private,
+ phase: "foreground private ping fast-path",
+ pushAfterHandling: false
+ )
+ _ = await fetchPushPingsDirect(
+ scope: .shared,
+ phase: "foreground shared ping fast-path",
+ pushAfterHandling: false
+ )
await syncMonitor.run("foreground push") {
try await syncEngine.pushChanges()
}
@@ -539,6 +589,136 @@ final class AppServices {
await enqueueDirectedPings(kind: kind, scope: scope, gameID: gameID)
}
+ private func sendHailPing(gameID: UUID, payload: String, addressee: String) async {
+ guard preferences.isICloudSyncEnabled,
+ let localAuthorID = identity.currentID,
+ !localAuthorID.isEmpty
+ else { return }
+ guard await ensureICloudSyncStarted() else { return }
+ await syncEngine.enqueuePing(
+ kind: .hail,
+ scope: nil,
+ gameID: gameID,
+ authorID: localAuthorID,
+ playerName: preferences.name,
+ payload: payload,
+ addressee: addressee
+ )
+ }
+
+ func offerEngagement(gameID: UUID) async {
+ guard preferences.isICloudSyncEnabled else {
+ syncMonitor.note("engagement: manual offer skipped, iCloud sync is disabled")
+ return
+ }
+ guard await ensureICloudSyncStarted() else {
+ syncMonitor.note("engagement: manual offer skipped, iCloud sync is unavailable")
+ return
+ }
+ syncMonitor.note(
+ "engagement: manual offer requested for \(gameID.uuidString) " +
+ "device=\(RecordSerializer.localDeviceID.prefix(8))"
+ )
+ await engagementCoordinator.offerEngagement(gameID: gameID)
+ }
+
+ func startEngagementIfPossible(gameID: UUID) async {
+ guard preferences.isICloudSyncEnabled else { return }
+ guard await ensureICloudSyncStarted() else { return }
+ await engagementCoordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
+ }
+
+ func sendEngagementTestMessage(gameID: UUID) async {
+ let text = "Engagement test from \(RecordSerializer.localDeviceID.prefix(8))"
+ await engagementCoordinator.sendDebugMessage(gameID: gameID, text: text)
+ }
+
+ func endEngagement(gameID: UUID) async {
+ syncMonitor.note("engagement: ending for \(gameID.uuidString)")
+ engagementStatus.setLive(false, gameID: gameID)
+ latestLocalSelections[gameID] = nil
+ engagementStore.clear(gameID: gameID)
+ await engagementCoordinator.teardown(gameID: gameID)
+ }
+
+ func noteLocalSelection(_ selection: PlayerSelection, gameID: UUID) async {
+ latestLocalSelections[gameID] = selection
+ guard engagementStatus.isLive(gameID: gameID),
+ let localAuthorID = identity.currentID,
+ !localAuthorID.isEmpty
+ else { return }
+ let update = EngagementSelectionUpdate(
+ gameID: gameID,
+ authorID: localAuthorID,
+ deviceID: RecordSerializer.localDeviceID,
+ selection: selection
+ )
+ await engagementCoordinator.sendSelection(update)
+ }
+
+ private func handleEngagementEvent(_ event: EngagementHost.Event) {
+ switch event {
+ case .signal(let engagementID, _):
+ syncMonitor.note("engagement: signal generated \(engagementID.uuidString)")
+ case .channelOpen(let engagementID):
+ syncMonitor.note("engagement: channel open \(engagementID.uuidString)")
+ Task { [weak self] in
+ guard let self,
+ let gameID = await self.engagementCoordinator.channelOpened(engagementID: engagementID)
+ else { return }
+ self.engagementStatus.setLive(true, gameID: gameID)
+ if let selection = self.latestLocalSelections[gameID] {
+ await self.noteLocalSelection(selection, gameID: gameID)
+ }
+ }
+ case .channelMessage(let engagementID, let message):
+ if let envelope = EngagementMessage.decode(message) {
+ handleEngagementMessage(envelope, engagementID: engagementID)
+ } else {
+ syncMonitor.note("engagement: channel message \(engagementID.uuidString) bytes=\(message.count)")
+ }
+ case .channelClose(let engagementID):
+ syncMonitor.note("engagement: channel close \(engagementID.uuidString)")
+ Task { [weak self] in
+ guard let self,
+ let gameID = await self.engagementCoordinator.channelClosed(engagementID: engagementID)
+ else { return }
+ self.engagementStatus.setLive(false, gameID: gameID)
+ self.engagementStore.clear(gameID: gameID)
+ await self.startEngagementIfPossible(gameID: gameID)
+ }
+ case .error(let engagementID, let message):
+ syncMonitor.note("engagement: error \(engagementID?.uuidString ?? "unknown"): \(message)")
+ }
+ }
+
+ private func handleEngagementMessage(_ envelope: EngagementMessage, engagementID: UUID) {
+ switch envelope.kind {
+ case .debugText:
+ syncMonitor.note(
+ "engagement: received \(envelope.kind.rawValue) \(engagementID.uuidString): \(envelope.text)"
+ )
+ case .cellEdit:
+ guard let edit = envelope.cellEdit else {
+ syncMonitor.note("engagement: ignored malformed cellEdit \(engagementID.uuidString)")
+ return
+ }
+ let applied = store.applyRealtimeCellEdit(edit)
+ if applied {
+ syncMonitor.note(
+ "engagement: applied cellEdit \(engagementID.uuidString) " +
+ "r=\(edit.row) c=\(edit.col) device=\(edit.deviceID.prefix(8))"
+ )
+ }
+ case .selection:
+ guard let selection = envelope.selection else {
+ syncMonitor.note("engagement: ignored malformed selection \(engagementID.uuidString)")
+ return
+ }
+ engagementStore.set(selection)
+ }
+ }
+
/// Pull-to-refresh action for the library. Discovers any zones the
/// device hasn't seen yet on both database scopes, then runs the normal
/// engine fetch so any in-flight changes also catch up. Bypasses
@@ -748,9 +928,30 @@ final class AppServices {
try await syncEngine.fetchChanges(source: "puzzle grid \(label)")
}
}
+ _ = await fetchPushPingsDirect(
+ scope: scope,
+ phase: "freshen puzzle grid \(label) ping fast-path"
+ )
await refreshSnapshot()
}
+ @discardableResult
+ private func fetchPushPingsDirect(
+ scope: CKDatabase.Scope,
+ phase: String,
+ pushAfterHandling: Bool = true
+ ) async -> Int {
+ let count = await syncMonitor.run(phase) {
+ try await syncEngine.fetchPushPingsDirect(scope: scope)
+ } ?? 0
+ if pushAfterHandling && count > 0 {
+ await syncMonitor.run("\(phase) push") {
+ try await syncEngine.pushChanges()
+ }
+ }
+ return count
+ }
+
private func beginPuzzleGridFreshen(
gameID: UUID,
scope: CKDatabase.Scope,
@@ -795,6 +996,7 @@ final class AppServices {
preferences: preferences,
persistence: persistence,
container: ckContainer,
+ engagementStore: engagementStore,
tracer: { [syncMonitor] message in syncMonitor.note(message) }
)
}
@@ -816,6 +1018,14 @@ final class AppServices {
await syncMonitor.run("remote-notification fetch") {
try await syncEngine.fetchChanges(source: "push")
}
+ _ = await fetchPushPingsDirect(
+ scope: .private,
+ phase: "remote-notification private ping fast-path"
+ )
+ _ = await fetchPushPingsDirect(
+ scope: .shared,
+ phase: "remote-notification shared ping fast-path"
+ )
await refreshSnapshot()
return
}
@@ -831,6 +1041,11 @@ final class AppServices {
}
if let result {
await presentPings(result.0)
+ if !result.0.isEmpty {
+ await syncMonitor.run("remote-notification background ping push") {
+ try await syncEngine.pushChanges()
+ }
+ }
for note in await sessionMonitor.presentBegins(result.1) {
syncMonitor.note(note)
}
@@ -841,9 +1056,10 @@ final class AppServices {
}
if isGameListVisible {
- async let pingFastPath: Void = syncMonitor.run("remote-notification ping fast-path") {
- _ = try await self.syncEngine.fetchPushPingsDirect(scope: scope)
- }
+ async let pingFastPath: Int = fetchPushPingsDirect(
+ scope: scope,
+ phase: "remote-notification ping fast-path"
+ )
async let gameListFreshen: Void = freshenGameList(
scope: scope,
reason: .remote
@@ -867,9 +1083,10 @@ final class AppServices {
scope: scope,
reason: .remote
)
- async let pingFastPath: Void = syncMonitor.run("remote-notification ping fast-path") {
- _ = try await self.syncEngine.fetchPushPingsDirect(scope: scope)
- }
+ async let pingFastPath: Int = fetchPushPingsDirect(
+ scope: scope,
+ phase: "remote-notification ping fast-path"
+ )
_ = await (activeFetch, pingFastPath)
} else {
// Cold path: no puzzle open. Discover any zones this device
@@ -879,9 +1096,10 @@ final class AppServices {
await syncMonitor.run("remote-notification zone discovery") {
_ = try await self.syncEngine.discoverNewZonesDirect(scope: scope)
}
- async let pingFastPath: Void = syncMonitor.run("remote-notification ping fast-path") {
- _ = try await self.syncEngine.fetchPushPingsDirect(scope: scope)
- }
+ async let pingFastPath: Int = fetchPushPingsDirect(
+ scope: scope,
+ phase: "remote-notification ping fast-path"
+ )
async let gameMovesCatchUp: Void = syncMonitor.run("remote-notification game/moves catch-up") {
_ = try await self.syncEngine.fetchKnownGameMovesDirect(scope: scope)
}
@@ -1367,15 +1585,19 @@ final class AppServices {
await sessionMonitor.cancel(gameID: ping.gameID, authorID: ping.authorID)
}
applyInvitePings(pings)
- // `.friend` is the friendship-bootstrap handshake — system-only, no
- // alert, runs without notification authorization. Everything else
- // (the player-facing kinds) goes through the alert path below.
+ // `.friend` is the friendship-bootstrap handshake and `.hail` is RTC
+ // signaling. Both are system-only: no alert, no notification
+ // authorization dependency. Everything else goes through the alert
+ // path below.
let (systemPings, playerFacingPings) = pings.partitioned {
- $0.kind == .friend
+ $0.kind == .friend || $0.kind == .hail
}
for ping in systemPings where ping.kind == .friend {
await friendController.applyFriendPing(ping)
}
+ for ping in systemPings where ping.kind == .hail {
+ await engagementCoordinator.handle(ping)
+ }
guard !playerFacingPings.isEmpty else { return }
guard await canPresentNotifications() else {
syncMonitor.note("ping: local notification skipped — authorization not granted")
@@ -1489,6 +1711,10 @@ final class AppServices {
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.
+ return "system-only ping should not be presented"
}
}
@@ -1719,6 +1945,38 @@ final class AppServices {
}
}
+ static func presentPeers(
+ persistence: PersistenceController,
+ gameIDs: Set<UUID>?,
+ localAuthorID: String?
+ ) async -> [UUID: [String]] {
+ let context = persistence.container.newBackgroundContext()
+ return await withCheckedContinuation { continuation in
+ context.perform {
+ let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ var predicates = [
+ NSPredicate(format: "readAt != nil AND readAt > %@", Date() as NSDate)
+ ]
+ if let gameIDs, !gameIDs.isEmpty {
+ predicates.append(NSPredicate(format: "game.id IN %@", Array(gameIDs)))
+ }
+ if let localAuthorID, !localAuthorID.isEmpty {
+ predicates.append(NSPredicate(format: "authorID != %@", localAuthorID))
+ }
+ req.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
+
+ var result: [UUID: Set<String>] = [:]
+ for player in (try? context.fetch(req)) ?? [] {
+ guard let gameID = player.game?.id,
+ let authorID = player.authorID,
+ !authorID.isEmpty else { continue }
+ result[gameID, default: []].insert(authorID)
+ }
+ continuation.resume(returning: result.mapValues { Array($0) })
+ }
+ }
+ }
+
}
private extension Array {
diff --git a/Crossmate/Services/EngagementHost.html b/Crossmate/Services/EngagementHost.html
@@ -0,0 +1,230 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+<script>
+(() => {
+ const peers = new Map();
+ const closedEngagementIDs = new Set();
+
+ function post(message) {
+ window.webkit.messageHandlers.engagement.postMessage(message);
+ }
+
+ function postError(engagementID, error) {
+ post({
+ type: "onError",
+ engagementID,
+ message: error && error.message ? error.message : String(error)
+ });
+ }
+
+ function iceServers() {
+ return [{ urls: "stun:stun.cloudflare.com:3478" }];
+ }
+
+ function createPeer(engagementID) {
+ teardown(engagementID);
+ closedEngagementIDs.delete(engagementID);
+
+ const pc = new RTCPeerConnection({
+ iceServers: iceServers()
+ });
+ const peer = { pc, channel: null, closed: false };
+ peers.set(engagementID, peer);
+
+ pc.ondatachannel = (event) => {
+ attachChannel(engagementID, event.channel);
+ };
+ pc.onconnectionstatechange = () => {
+ if (pc.connectionState === "failed" ||
+ pc.connectionState === "disconnected" ||
+ pc.connectionState === "closed") {
+ postClose(engagementID);
+ }
+ };
+ pc.oniceconnectionstatechange = () => {
+ if (pc.iceConnectionState === "failed" ||
+ pc.iceConnectionState === "disconnected" ||
+ pc.iceConnectionState === "closed") {
+ postClose(engagementID);
+ }
+ };
+ return peer;
+ }
+
+ function attachChannel(engagementID, channel) {
+ const peer = peers.get(engagementID);
+ if (!peer) {
+ channel.close();
+ return;
+ }
+ peer.channel = channel;
+ channel.binaryType = "arraybuffer";
+ channel.onopen = () => post({ type: "onChannelOpen", engagementID });
+ channel.onclose = () => postClose(engagementID);
+ channel.onerror = (event) => postError(engagementID, event.error || "Data channel error");
+ channel.onmessage = async (event) => {
+ try {
+ let bytes;
+ if (event.data instanceof ArrayBuffer) {
+ bytes = new Uint8Array(event.data);
+ } else if (event.data instanceof Blob) {
+ bytes = new Uint8Array(await event.data.arrayBuffer());
+ } else {
+ bytes = new TextEncoder().encode(String(event.data));
+ }
+ post({
+ type: "onChannelMessage",
+ engagementID,
+ message: bytesToBase64(bytes)
+ });
+ } catch (error) {
+ postError(engagementID, error);
+ }
+ };
+ }
+
+ async function waitForIceComplete(pc) {
+ if (pc.iceGatheringState === "complete") {
+ return;
+ }
+ await new Promise((resolve) => {
+ const onChange = () => {
+ if (pc.iceGatheringState === "complete") {
+ pc.removeEventListener("icegatheringstatechange", onChange);
+ resolve();
+ }
+ };
+ pc.addEventListener("icegatheringstatechange", onChange);
+ });
+ }
+
+ function signalFromLocalDescription(pc) {
+ const sdp = pc.localDescription ? pc.localDescription.sdp : "";
+ return {
+ sdp,
+ candidates: candidatesFromSDP(sdp)
+ };
+ }
+
+ function candidatesFromSDP(sdp) {
+ return sdp
+ .split(/\r?\n/)
+ .filter((line) => line.startsWith("a=candidate:"))
+ .map((line) => line.slice("a=".length));
+ }
+
+ function bytesToBase64(bytes) {
+ let binary = "";
+ for (let i = 0; i < bytes.length; i += 1) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary);
+ }
+
+ function base64ToBytes(base64) {
+ const binary = atob(base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes;
+ }
+
+ function postClose(engagementID) {
+ if (closedEngagementIDs.has(engagementID)) {
+ return;
+ }
+ closedEngagementIDs.add(engagementID);
+ const peer = peers.get(engagementID);
+ if (peer) {
+ peer.closed = true;
+ }
+ post({ type: "onChannelClose", engagementID });
+ }
+
+ async function createOffer(engagementID) {
+ try {
+ const peer = createPeer(engagementID);
+ const channel = peer.pc.createDataChannel("crossmate", {
+ ordered: false,
+ maxRetransmits: 0
+ });
+ attachChannel(engagementID, channel);
+ await peer.pc.setLocalDescription(await peer.pc.createOffer());
+ await waitForIceComplete(peer.pc);
+ const signal = signalFromLocalDescription(peer.pc);
+ post({ type: "onSignal", engagementID, signal });
+ return signal;
+ } catch (error) {
+ postError(engagementID, error);
+ throw error;
+ }
+ }
+
+ async function acceptOfferAndReply(engagementID, signal) {
+ try {
+ const peer = createPeer(engagementID);
+ await peer.pc.setRemoteDescription({ type: "offer", sdp: signal.sdp });
+ await peer.pc.setLocalDescription(await peer.pc.createAnswer());
+ await waitForIceComplete(peer.pc);
+ const reply = signalFromLocalDescription(peer.pc);
+ post({ type: "onSignal", engagementID, signal: reply });
+ return reply;
+ } catch (error) {
+ postError(engagementID, error);
+ throw error;
+ }
+ }
+
+ async function acceptReply(engagementID, signal) {
+ try {
+ const peer = peers.get(engagementID);
+ if (!peer) {
+ throw new Error("No peer connection for engagement " + engagementID);
+ }
+ await peer.pc.setRemoteDescription({ type: "answer", sdp: signal.sdp });
+ return true;
+ } catch (error) {
+ postError(engagementID, error);
+ throw error;
+ }
+ }
+
+ function send(engagementID, base64Message) {
+ const peer = peers.get(engagementID);
+ if (!peer || !peer.channel || peer.channel.readyState !== "open") {
+ throw new Error("Engagement channel is not open");
+ }
+ peer.channel.send(base64ToBytes(base64Message));
+ return true;
+ }
+
+ function teardown(engagementID) {
+ const peer = peers.get(engagementID);
+ if (!peer) {
+ return true;
+ }
+ postClose(engagementID);
+ if (peer.channel) {
+ peer.channel.close();
+ }
+ peer.pc.close();
+ peers.delete(engagementID);
+ return true;
+ }
+
+ window.crossmateEngagement = {
+ createOffer,
+ acceptOfferAndReply,
+ acceptReply,
+ send,
+ teardown
+ };
+})();
+</script>
+</body>
+</html>
diff --git a/Crossmate/Services/EngagementHost.swift b/Crossmate/Services/EngagementHost.swift
@@ -0,0 +1,284 @@
+import Foundation
+import WebKit
+
+@MainActor
+final class EngagementHost: NSObject {
+ typealias Signal = EngagementSignal
+
+ enum Event: Equatable, Sendable {
+ case signal(engagementID: UUID, signal: Signal)
+ case channelOpen(engagementID: UUID)
+ case channelMessage(engagementID: UUID, message: Data)
+ case channelClose(engagementID: UUID)
+ case error(engagementID: UUID?, message: String)
+ }
+
+ var onEvent: (@MainActor (Event) -> Void)?
+
+ private let webView: WKWebView
+ private var loadTask: Task<Void, Error>?
+ private var pendingSignalContinuations: [UUID: CheckedContinuation<Signal, Error>] = [:]
+ private var closingEngagementIDs: Set<UUID> = []
+ private var closedEngagementIDs: Set<UUID> = []
+
+ override init() {
+ let contentController = WKUserContentController()
+ let configuration = WKWebViewConfiguration()
+ configuration.userContentController = contentController
+ configuration.websiteDataStore = .nonPersistent()
+ webView = WKWebView(frame: .zero, configuration: configuration)
+ super.init()
+ contentController.add(WeakScriptMessageHandler(target: self), name: "engagement")
+ webView.navigationDelegate = self
+ }
+
+ func createOffer(engagementID: UUID) async throws -> Signal {
+ beginEngagement(engagementID)
+ return try await callSignalFunction(
+ "createOffer",
+ engagementID: engagementID,
+ signal: nil
+ )
+ }
+
+ func acceptOfferAndReply(engagementID: UUID, signal: Signal) async throws -> Signal {
+ beginEngagement(engagementID)
+ return try await callSignalFunction(
+ "acceptOfferAndReply",
+ engagementID: engagementID,
+ signal: signal
+ )
+ }
+
+ func acceptReply(engagementID: UUID, signal: Signal) async throws {
+ try await callVoidFunction(
+ "acceptReply",
+ engagementID: engagementID,
+ argument: signal
+ )
+ }
+
+ func send(engagementID: UUID, message: Data) throws {
+ Task { @MainActor in
+ do {
+ try await callVoidFunction(
+ "send",
+ engagementID: engagementID,
+ argument: message.base64EncodedString()
+ )
+ } catch {
+ onEvent?(.error(
+ engagementID: engagementID,
+ message: error.localizedDescription
+ ))
+ }
+ }
+ }
+
+ func teardown(engagementID: UUID) {
+ closingEngagementIDs.insert(engagementID)
+ Task { @MainActor in
+ try? await callVoidFunction(
+ "teardown",
+ engagementID: engagementID,
+ argument: Optional<String>.none
+ )
+ }
+ }
+
+ private func beginEngagement(_ engagementID: UUID) {
+ closingEngagementIDs.remove(engagementID)
+ closedEngagementIDs.remove(engagementID)
+ }
+
+ private func noteChannelClose(_ engagementID: UUID) {
+ guard closedEngagementIDs.insert(engagementID).inserted else { return }
+ closingEngagementIDs.remove(engagementID)
+ onEvent?(.channelClose(engagementID: engagementID))
+ }
+
+ private func shouldSuppressError(engagementID: UUID?, message: String) -> Bool {
+ guard let engagementID else { return false }
+ guard closingEngagementIDs.contains(engagementID) || closedEngagementIDs.contains(engagementID) else {
+ return false
+ }
+ return message.contains("Close called")
+ }
+
+ private func callVoidFunction<T: Encodable>(
+ _ name: String,
+ engagementID: UUID,
+ argument: T?
+ ) async throws {
+ try await loadIfNeeded()
+ let encodedArgument = try Self.javascriptLiteral(argument)
+ let script = "window.crossmateEngagement.\(name)(\"\(engagementID.uuidString)\", \(encodedArgument)); undefined"
+ _ = try await webView.evaluateJavaScript(script)
+ }
+
+ private func callSignalFunction(
+ _ name: String,
+ engagementID: UUID,
+ signal: Signal?
+ ) async throws -> Signal {
+ try await loadIfNeeded()
+ let encodedArgument = try Self.javascriptLiteral(signal)
+ let script = "window.crossmateEngagement.\(name)(\"\(engagementID.uuidString)\", \(encodedArgument)); undefined"
+
+ return try await withCheckedThrowingContinuation { continuation in
+ pendingSignalContinuations[engagementID] = continuation
+ Task { @MainActor in
+ do {
+ _ = try await webView.evaluateJavaScript(script)
+ } catch {
+ pendingSignalContinuations.removeValue(forKey: engagementID)?
+ .resume(throwing: error)
+ }
+ }
+ }
+ }
+
+ @discardableResult
+ private func callFunction<T: Encodable>(
+ _ name: String,
+ engagementID: UUID,
+ argument: T?
+ ) async throws -> Any {
+ try await loadIfNeeded()
+ let encodedArgument = try Self.javascriptLiteral(argument)
+ let script = "window.crossmateEngagement.\(name)(\"\(engagementID.uuidString)\", \(encodedArgument))"
+ return try await webView.evaluateJavaScript(script) as Any
+ }
+
+ private func loadIfNeeded() async throws {
+ if let loadTask {
+ try await loadTask.value
+ return
+ }
+ let task = Task { @MainActor in
+ guard let url = Bundle.main.url(
+ forResource: "EngagementHost",
+ withExtension: "html"
+ ) else {
+ throw EngagementHostError.missingResource
+ }
+ let html = try String(contentsOf: url, encoding: .utf8)
+ webView.loadHTMLString(html, baseURL: Bundle.main.resourceURL)
+ try await withCheckedThrowingContinuation { continuation in
+ self.loadContinuation = continuation
+ }
+ }
+ loadTask = task
+ try await task.value
+ }
+
+ private var loadContinuation: CheckedContinuation<Void, Error>?
+
+ private static func javascriptLiteral<T: Encodable>(_ value: T?) throws -> String {
+ guard let value else { return "null" }
+ let data = try JSONEncoder().encode(value)
+ guard let string = String(data: data, encoding: .utf8) else {
+ throw EngagementHostError.invalidArgument
+ }
+ return string
+ }
+}
+
+extension EngagementHost: EngagementHosting, @unchecked Sendable {}
+
+extension EngagementHost: WKNavigationDelegate {
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ loadContinuation?.resume()
+ loadContinuation = nil
+ }
+
+ func webView(
+ _ webView: WKWebView,
+ didFail navigation: WKNavigation!,
+ withError error: Error
+ ) {
+ loadContinuation?.resume(throwing: error)
+ loadContinuation = nil
+ }
+}
+
+extension EngagementHost: WKScriptMessageHandler {
+ func userContentController(
+ _ userContentController: WKUserContentController,
+ didReceive message: WKScriptMessage
+ ) {
+ guard let body = message.body as? [String: Any],
+ let type = body["type"] as? String else { return }
+ let engagementID = (body["engagementID"] as? String).flatMap(UUID.init(uuidString:))
+
+ switch type {
+ case "onSignal":
+ guard let engagementID,
+ let signalObject = body["signal"],
+ let data = try? JSONSerialization.data(withJSONObject: signalObject),
+ let signal = try? JSONDecoder().decode(Signal.self, from: data) else { return }
+ pendingSignalContinuations.removeValue(forKey: engagementID)?.resume(returning: signal)
+ onEvent?(.signal(engagementID: engagementID, signal: signal))
+ case "onChannelOpen":
+ guard let engagementID else { return }
+ onEvent?(.channelOpen(engagementID: engagementID))
+ case "onChannelMessage":
+ guard let engagementID,
+ let base64 = body["message"] as? String,
+ let data = Data(base64Encoded: base64) else { return }
+ onEvent?(.channelMessage(engagementID: engagementID, message: data))
+ case "onChannelClose":
+ guard let engagementID else { return }
+ noteChannelClose(engagementID)
+ case "onError":
+ let errorMessage = (body["message"] as? String) ?? "Unknown engagement host error"
+ if shouldSuppressError(engagementID: engagementID, message: errorMessage) {
+ return
+ }
+ if let engagementID {
+ pendingSignalContinuations.removeValue(forKey: engagementID)?
+ .resume(throwing: EngagementHostError.javascriptError(
+ errorMessage
+ ))
+ }
+ onEvent?(.error(
+ engagementID: engagementID,
+ message: errorMessage
+ ))
+ default:
+ break
+ }
+ }
+}
+
+private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
+ weak var target: WKScriptMessageHandler?
+
+ init(target: WKScriptMessageHandler) {
+ self.target = target
+ }
+
+ func userContentController(
+ _ userContentController: WKUserContentController,
+ didReceive message: WKScriptMessage
+ ) {
+ target?.userContentController(userContentController, didReceive: message)
+ }
+}
+
+enum EngagementHostError: LocalizedError {
+ case missingResource
+ case invalidArgument
+ case javascriptError(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .missingResource:
+ "EngagementHost.html is missing from the app bundle."
+ case .invalidArgument:
+ "Unable to encode the engagement host argument."
+ case .javascriptError(let message):
+ message
+ }
+ }
+}
diff --git a/Crossmate/Services/EngagementHostEnvironment.swift b/Crossmate/Services/EngagementHostEnvironment.swift
@@ -0,0 +1,57 @@
+import SwiftUI
+
+@MainActor
+@Observable
+final class EngagementStatus {
+ private(set) var liveGameIDs: Set<UUID> = []
+
+ func isLive(gameID: UUID) -> Bool {
+ liveGameIDs.contains(gameID)
+ }
+
+ func setLive(_ isLive: Bool, gameID: UUID) {
+ if isLive {
+ liveGameIDs.insert(gameID)
+ } else {
+ liveGameIDs.remove(gameID)
+ }
+ }
+}
+
+private struct EngagementHostKey: EnvironmentKey {
+ static let defaultValue: EngagementHost? = nil
+}
+
+private struct EngagementStatusKey: EnvironmentKey {
+ static let defaultValue: EngagementStatus? = nil
+}
+
+private struct OfferEngagementKey: EnvironmentKey {
+ static let defaultValue: (@Sendable (UUID) async -> Void)? = nil
+}
+
+private struct SendEngagementTestMessageKey: EnvironmentKey {
+ static let defaultValue: (@Sendable (UUID) async -> Void)? = nil
+}
+
+extension EnvironmentValues {
+ var engagementHost: EngagementHost? {
+ get { self[EngagementHostKey.self] }
+ set { self[EngagementHostKey.self] = newValue }
+ }
+
+ var engagementStatus: EngagementStatus? {
+ get { self[EngagementStatusKey.self] }
+ set { self[EngagementStatusKey.self] = newValue }
+ }
+
+ var offerEngagement: (@Sendable (UUID) async -> Void)? {
+ get { self[OfferEngagementKey.self] }
+ set { self[OfferEngagementKey.self] = newValue }
+ }
+
+ var sendEngagementTestMessage: (@Sendable (UUID) async -> Void)? {
+ get { self[SendEngagementTestMessageKey.self] }
+ set { self[SendEngagementTestMessageKey.self] = newValue }
+ }
+}
diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift
@@ -164,7 +164,7 @@ extension SyncEngine {
database: database,
zoneID: zoneID,
since: since,
- desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "payload"]
+ desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "payload", "addressee"]
)
return PerZonePings(records: records, orphanedZone: nil)
} catch {
@@ -285,7 +285,7 @@ extension SyncEngine {
database: database,
zoneID: zone.zoneID,
since: since,
- desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "payload"]
+ desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "payload", "addressee"]
)
async let playerRecords = self.queryLiveRecords(
type: "Player",
diff --git a/Crossmate/Sync/EngagementCoordinator.swift b/Crossmate/Sync/EngagementCoordinator.swift
@@ -0,0 +1,436 @@
+import Foundation
+
+struct EngagementSignal: Codable, Equatable, Sendable {
+ var sdp: String
+ var candidates: [String]
+}
+
+struct EngagementAddressee: Equatable, Sendable {
+ var authorID: String
+ var deviceID: String?
+
+ var rawValue: String {
+ if let deviceID, !deviceID.isEmpty {
+ return "\(authorID):\(deviceID)"
+ }
+ return authorID
+ }
+
+ static func parse(_ rawValue: String?) -> EngagementAddressee? {
+ guard let rawValue, !rawValue.isEmpty else { return nil }
+ let parts = rawValue.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
+ guard let author = parts.first, !author.isEmpty else { return nil }
+ let device = parts.count == 2 ? String(parts[1]) : nil
+ return EngagementAddressee(authorID: String(author), deviceID: device?.isEmpty == true ? nil : device)
+ }
+
+ func matches(authorID: String, deviceID: String) -> Bool {
+ guard self.authorID == authorID else { return false }
+ guard let targetDeviceID = self.deviceID else { return true }
+ return targetDeviceID == deviceID
+ }
+}
+
+struct EngagementHailPayload: Codable, Equatable, Sendable {
+ enum Role: String, Codable, Sendable {
+ case offer
+ case reply
+ }
+
+ var role: Role
+ var engagementID: UUID
+ var sdp: String
+ var candidates: [String]
+ var ver: Int
+
+ init(role: Role, engagementID: UUID, signal: EngagementSignal, ver: Int = 1) {
+ self.role = role
+ self.engagementID = engagementID
+ self.sdp = signal.sdp
+ self.candidates = signal.candidates
+ self.ver = ver
+ }
+
+ var signal: EngagementSignal {
+ EngagementSignal(sdp: sdp, candidates: candidates)
+ }
+
+ func encoded() throws -> String {
+ let data = try JSONEncoder().encode(self)
+ guard let string = String(data: data, encoding: .utf8) else {
+ throw EngagementCoordinatorError.invalidPayloadEncoding
+ }
+ return string
+ }
+
+ static func decode(_ string: String?) -> EngagementHailPayload? {
+ guard let data = string?.data(using: .utf8) else { return nil }
+ return try? JSONDecoder().decode(EngagementHailPayload.self, from: data)
+ }
+}
+
+struct EngagementMessage: Codable, Equatable, Sendable {
+ enum Kind: String, Codable, Sendable {
+ case debugText
+ case cellEdit
+ case selection
+ }
+
+ var kind: Kind
+ var text: String
+ var cellEdit: RealtimeCellEdit?
+ var selection: EngagementSelectionUpdate?
+ var sentAt: Date
+ var ver: Int
+
+ init(
+ kind: Kind = .debugText,
+ text: String,
+ cellEdit: RealtimeCellEdit? = nil,
+ selection: EngagementSelectionUpdate? = nil,
+ sentAt: Date = Date(),
+ ver: Int = 1
+ ) {
+ self.kind = kind
+ self.text = text
+ self.cellEdit = cellEdit
+ self.selection = selection
+ self.sentAt = sentAt
+ self.ver = ver
+ }
+
+ init(cellEdit: RealtimeCellEdit, sentAt: Date = Date(), ver: Int = 1) {
+ self.kind = .cellEdit
+ self.text = ""
+ self.cellEdit = cellEdit
+ self.selection = nil
+ self.sentAt = sentAt
+ self.ver = ver
+ }
+
+ init(selection: EngagementSelectionUpdate, sentAt: Date = Date(), ver: Int = 1) {
+ self.kind = .selection
+ self.text = ""
+ self.cellEdit = nil
+ self.selection = selection
+ self.sentAt = sentAt
+ self.ver = ver
+ }
+
+ func encodedData() throws -> Data {
+ try JSONEncoder().encode(self)
+ }
+
+ static func decode(_ data: Data) -> EngagementMessage? {
+ try? JSONDecoder().decode(EngagementMessage.self, from: data)
+ }
+}
+
+@MainActor
+protocol EngagementHosting: AnyObject, Sendable {
+ func createOffer(engagementID: UUID) async throws -> EngagementSignal
+ func acceptOfferAndReply(engagementID: UUID, signal: EngagementSignal) async throws -> EngagementSignal
+ func acceptReply(engagementID: UUID, signal: EngagementSignal) async throws
+ func send(engagementID: UUID, message: Data) throws
+ func teardown(engagementID: UUID)
+}
+
+actor EngagementCoordinator {
+ typealias PresentPeers = @Sendable (Set<UUID>?) async -> [UUID: [String]]
+ typealias SendHail = @Sendable (
+ _ gameID: UUID,
+ _ payload: String,
+ _ addressee: String
+ ) async -> Void
+ typealias DeletePing = @Sendable (_ recordName: String, _ gameID: UUID) async -> Void
+ typealias Log = @Sendable (_ message: String) async -> Void
+
+ private enum State: Equatable {
+ case idle
+ case offered(peerAuthorID: String, engagementID: UUID)
+ case replied(peerAuthorID: String, engagementID: UUID)
+ case live(peerAuthorID: String, engagementID: UUID)
+
+ var engagementID: UUID? {
+ switch self {
+ case .idle:
+ nil
+ case .offered(_, let engagementID),
+ .replied(_, let engagementID),
+ .live(_, let engagementID):
+ engagementID
+ }
+ }
+ }
+
+ private let host: any EngagementHosting
+ private let localAuthorID: @Sendable () async -> String?
+ private let localDeviceID: String
+ private let presentPeers: PresentPeers
+ private let sendHail: SendHail
+ private let deletePing: DeletePing
+ private let log: Log
+ private let now: @Sendable () -> Date
+ private let hailMaxAge: TimeInterval
+ private var states: [UUID: State] = [:]
+
+ init(
+ host: any EngagementHosting,
+ localAuthorID: @escaping @Sendable () async -> String?,
+ localDeviceID: String = RecordSerializer.localDeviceID,
+ presentPeers: @escaping PresentPeers,
+ sendHail: @escaping SendHail,
+ deletePing: @escaping DeletePing,
+ log: @escaping Log = { _ in },
+ now: @escaping @Sendable () -> Date = Date.init,
+ hailMaxAge: TimeInterval = 120
+ ) {
+ self.host = host
+ self.localAuthorID = localAuthorID
+ self.localDeviceID = localDeviceID
+ self.presentPeers = presentPeers
+ self.sendHail = sendHail
+ self.deletePing = deletePing
+ self.log = log
+ self.now = now
+ self.hailMaxAge = hailMaxAge
+ }
+
+ func peerPresenceMayHaveChanged(gameIDs: Set<UUID>? = nil) async {
+ guard let localAuthorID = await localAuthorID(), !localAuthorID.isEmpty else { return }
+ let peersByGame = await presentPeers(gameIDs)
+ for (gameID, peers) in peersByGame {
+ guard state(for: gameID) == .idle else { continue }
+ guard let peerAuthorID = peers.sorted().first(where: { localAuthorID < $0 }) else { continue }
+ await createOffer(gameID: gameID, peerAuthorID: peerAuthorID)
+ }
+ }
+
+ func offerEngagement(gameID: UUID) async {
+ guard let localAuthorID = await localAuthorID(), !localAuthorID.isEmpty else { return }
+ guard state(for: gameID) == .idle else {
+ await log("engagement: manual offer skipped for \(gameID.uuidString), state is not idle")
+ return
+ }
+ await createOffer(gameID: gameID, peerAuthorID: localAuthorID)
+ }
+
+ func handle(_ ping: Ping) async {
+ guard ping.kind == .hail else { return }
+ guard let localAuthorID = await localAuthorID(), !localAuthorID.isEmpty else {
+ await log("engagement: ignored hail \(ping.recordName), missing local author")
+ return
+ }
+ if let timestamp = Self.eventTimestamp(from: ping.recordName),
+ now().timeIntervalSince(timestamp) > hailMaxAge {
+ await deletePing(ping.recordName, ping.gameID)
+ await log("engagement: deleted stale hail \(ping.recordName)")
+ return
+ }
+ guard ping.authorID != localAuthorID || ping.deviceID != localDeviceID else {
+ await log("engagement: ignored own hail \(ping.recordName)")
+ return
+ }
+ guard let addressee = EngagementAddressee.parse(ping.addressee) else {
+ await log("engagement: ignored hail \(ping.recordName), missing addressee")
+ return
+ }
+ guard addressee.matches(authorID: localAuthorID, deviceID: localDeviceID) else {
+ await log("engagement: ignored hail \(ping.recordName), addressed to \(addressee.rawValue)")
+ return
+ }
+ guard let payload = EngagementHailPayload.decode(ping.payload), payload.ver == 1 else {
+ await log("engagement: ignored malformed hail \(ping.recordName)")
+ return
+ }
+
+ switch payload.role {
+ case .offer:
+ await acceptOffer(ping: ping, payload: payload, localAuthorID: localAuthorID)
+ case .reply:
+ await acceptReply(ping: ping, payload: payload)
+ }
+ }
+
+ func teardown(gameID: UUID) async {
+ let state = state(for: gameID)
+ states[gameID] = .idle
+ if let engagementID = state.engagementID {
+ await host.teardown(engagementID: engagementID)
+ }
+ }
+
+ func channelOpened(engagementID: UUID) async -> UUID? {
+ guard let (gameID, state) = stateEntry(for: engagementID) else { return nil }
+ switch state {
+ case .idle:
+ return nil
+ case .offered(let peerAuthorID, _),
+ .replied(let peerAuthorID, _),
+ .live(let peerAuthorID, _):
+ states[gameID] = .live(peerAuthorID: peerAuthorID, engagementID: engagementID)
+ return gameID
+ }
+ }
+
+ func channelClosed(engagementID: UUID) async -> UUID? {
+ guard let (gameID, state) = stateEntry(for: engagementID) else { return nil }
+ if state.engagementID == engagementID {
+ states[gameID] = .idle
+ return gameID
+ }
+ return nil
+ }
+
+ func sendDebugMessage(gameID: UUID, text: String) async {
+ guard case .live(_, let engagementID) = state(for: gameID) else {
+ await log("engagement: test message skipped for \(gameID.uuidString), channel is not live")
+ return
+ }
+ do {
+ let message = EngagementMessage(text: text)
+ try await host.send(engagementID: engagementID, message: message.encodedData())
+ await log("engagement: sent test message \(engagementID.uuidString)")
+ } catch {
+ await log("engagement: test message failed \(engagementID.uuidString): \(error.localizedDescription)")
+ }
+ }
+
+ func sendCellEdit(_ edit: RealtimeCellEdit) async {
+ guard case .live(_, let engagementID) = state(for: edit.gameID) else { return }
+ do {
+ let message = EngagementMessage(cellEdit: edit)
+ try await host.send(engagementID: engagementID, message: message.encodedData())
+ } catch {
+ await log("engagement: cell edit send failed \(engagementID.uuidString): \(error.localizedDescription)")
+ }
+ }
+
+ func sendSelection(_ selection: EngagementSelectionUpdate) async {
+ guard case .live(_, let engagementID) = state(for: selection.gameID) else { return }
+ do {
+ let message = EngagementMessage(selection: selection)
+ try await host.send(engagementID: engagementID, message: message.encodedData())
+ } catch {
+ await log("engagement: selection send failed \(engagementID.uuidString): \(error.localizedDescription)")
+ }
+ }
+
+ private func createOffer(gameID: UUID, peerAuthorID: String) async {
+ let engagementID = UUID()
+ states[gameID] = .offered(peerAuthorID: peerAuthorID, engagementID: engagementID)
+ do {
+ let signal = try await host.createOffer(engagementID: engagementID)
+ let payload = try EngagementHailPayload(
+ role: .offer,
+ engagementID: engagementID,
+ signal: signal
+ ).encoded()
+ await sendHail(gameID, payload, EngagementAddressee(authorID: peerAuthorID).rawValue)
+ await log("engagement: sent offer for \(gameID.uuidString) to \(peerAuthorID)")
+ } catch {
+ states[gameID] = .idle
+ await log("engagement: offer failed for \(gameID.uuidString): \(error.localizedDescription)")
+ }
+ }
+
+ private func acceptOffer(ping: Ping, payload: EngagementHailPayload, localAuthorID: String) async {
+ let currentState = state(for: ping.gameID)
+ if currentState != .idle {
+ if currentState.engagementID == payload.engagementID {
+ await log("engagement: ignored duplicate offer \(payload.engagementID.uuidString)")
+ return
+ }
+ if case .offered = currentState,
+ !incomingOfferWinsOverLocalOffer(ping: ping, localAuthorID: localAuthorID) {
+ await deletePing(ping.recordName, ping.gameID)
+ await log("engagement: deleted losing offer \(payload.engagementID.uuidString)")
+ return
+ }
+ if let engagementID = currentState.engagementID {
+ await host.teardown(engagementID: engagementID)
+ }
+ await log(
+ "engagement: replacing \(currentState.engagementID?.uuidString ?? "unknown") " +
+ "with offer \(payload.engagementID.uuidString)"
+ )
+ }
+ states[ping.gameID] = .replied(peerAuthorID: ping.authorID, engagementID: payload.engagementID)
+ do {
+ let reply = try await host.acceptOfferAndReply(
+ engagementID: payload.engagementID,
+ signal: payload.signal
+ )
+ let replyPayload = try EngagementHailPayload(
+ role: .reply,
+ engagementID: payload.engagementID,
+ signal: reply
+ ).encoded()
+ let addressee = EngagementAddressee(
+ authorID: ping.authorID,
+ deviceID: ping.deviceID.isEmpty ? nil : ping.deviceID
+ )
+ await sendHail(ping.gameID, replyPayload, addressee.rawValue)
+ await deletePing(ping.recordName, ping.gameID)
+ await log("engagement: replied to offer \(payload.engagementID.uuidString)")
+ } catch {
+ states[ping.gameID] = .idle
+ await log("engagement: reply failed for \(ping.gameID.uuidString): \(error.localizedDescription)")
+ }
+ }
+
+ private func acceptReply(ping: Ping, payload: EngagementHailPayload) async {
+ guard case .offered(let peerAuthorID, payload.engagementID) = state(for: ping.gameID),
+ peerAuthorID == ping.authorID else {
+ await deletePing(ping.recordName, ping.gameID)
+ await log("engagement: deleted unmatched reply \(payload.engagementID.uuidString)")
+ return
+ }
+ do {
+ try await host.acceptReply(
+ engagementID: payload.engagementID,
+ signal: payload.signal
+ )
+ states[ping.gameID] = .live(peerAuthorID: ping.authorID, engagementID: payload.engagementID)
+ await deletePing(ping.recordName, ping.gameID)
+ await log("engagement: live \(payload.engagementID.uuidString)")
+ } catch {
+ states[ping.gameID] = .idle
+ await log("engagement: accept reply failed for \(ping.gameID.uuidString): \(error.localizedDescription)")
+ }
+ }
+
+ private func state(for gameID: UUID) -> State {
+ states[gameID] ?? .idle
+ }
+
+ private func stateEntry(for engagementID: UUID) -> (UUID, State)? {
+ states.first { _, state in
+ state.engagementID == engagementID
+ }
+ }
+
+ private func incomingOfferWinsOverLocalOffer(ping: Ping, localAuthorID: String) -> Bool {
+ if ping.authorID == localAuthorID {
+ return ping.deviceID < localDeviceID
+ }
+ return ping.authorID < localAuthorID
+ }
+
+ private static func eventTimestamp(from recordName: String) -> Date? {
+ guard let timestampString = recordName.split(separator: "-").last,
+ let milliseconds = TimeInterval(timestampString) else { return nil }
+ return Date(timeIntervalSince1970: milliseconds / 1000)
+ }
+}
+
+enum EngagementCoordinatorError: LocalizedError {
+ case invalidPayloadEncoding
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidPayloadEncoding:
+ "Unable to encode engagement hail payload."
+ }
+ }
+}
diff --git a/Crossmate/Sync/Moves.swift b/Crossmate/Sync/Moves.swift
@@ -45,6 +45,19 @@ struct TimestampedCell: Equatable, Sendable {
var authorID: String?
}
+struct RealtimeCellEdit: Codable, Equatable, Sendable {
+ var gameID: UUID
+ var authorID: String
+ var deviceID: String
+ var row: Int
+ var col: Int
+ var letter: String
+ var markKind: Int16
+ var checkedWrong: Bool
+ var updatedAt: Date
+ var cellAuthorID: String?
+}
+
enum MovesCodec {
/// Wire format for `MovesValue.cells`. Each entry's `authorID` is the
/// preserved cell-level author — distinct from the parent record's
diff --git a/Crossmate/Sync/Presence.swift b/Crossmate/Sync/Presence.swift
@@ -20,6 +20,10 @@ enum PingKind: String, Sendable {
/// Re-invite to a game. Written into a *friend* zone; carries the game's
/// share URL in `payload`. Surfaces in the "Invited" section.
case invite
+ /// WebRTC engagement signaling. Written into a shared game zone; carries
+ /// `{"role":"offer"|"reply","engagementID":"…","sdp":"…","candidates":[…],"ver":1}`
+ /// in `payload` and targets a specific author/device via `addressee`.
+ case hail
}
/// Granularity of a check/reveal action; nil for kinds where it doesn't apply.
@@ -51,12 +55,12 @@ struct Ping: Sendable {
let kind: PingKind
let scope: PingScope?
/// Kind-specific JSON. `.friend`: `{friendShareURL,pairKey,ownerAuthorID}`;
- /// `.invite`: `{gameShareURL}`; `.check`/`.reveal`: `{scope}` (see
- /// PingScope). nil for join/win/resign.
+ /// `.invite`: `{gameShareURL}`; `.hail` carries engagement signaling;
+ /// `.check`/`.reveal`: `{scope}` (see PingScope). nil for join/win/resign.
let payload: String?
- /// Recipient authorID for a directed ping (`.win`/`.resign`); nil ⇒
- /// broadcast — every recipient acts on it. A device ignores a ping whose
- /// `addressee` is set to someone other than its own author.
+ /// 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 engagement signal. nil ⇒ broadcast — every recipient acts on it.
let addressee: String?
static func parseRecord(_ record: CKRecord) -> Ping? {
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -236,6 +236,8 @@ actor SyncEngine {
Task { await ensureDatabaseSubscriptions() }
Task { await purgeLegacyLeasePings_v1() }
Task { await purgeLegacyInvitePings_v1() }
+ Task { await purgeStaleHailPings_v1() }
+ Task { await purgeDebugPreviewFriends_v1() }
}
private func ensureDatabaseSubscriptions() async {
@@ -583,6 +585,104 @@ actor SyncEngine {
return deleted
}
+ /// One-shot cleanup of stale WebRTC `.hail` signaling records from known
+ /// game zones. Hails are ephemeral offer/reply envelopes; any records
+ /// written before the current cleanup/deletion path shipped can only
+ /// replay obsolete handshakes, so remove them once per device.
+ func purgeStaleHailPings_v1() async {
+ guard NotificationState.staleHailPurgeNeeded() else { return }
+ do {
+ let privateDeleted = try await purgeStaleHailPings(
+ in: gameZoneIDs(forScope: 0),
+ database: container.privateCloudDatabase
+ )
+ let sharedDeleted = try await purgeStaleHailPings(
+ in: gameZoneIDs(forScope: 1),
+ database: container.sharedCloudDatabase
+ )
+ NotificationState.markStaleHailPurged()
+ let total = privateDeleted + sharedDeleted
+ await trace("stale-hail purge: deleted \(total) record(s)")
+ } catch {
+ await trace("stale-hail purge failed: \(describe(error))")
+ }
+ }
+
+ private func purgeStaleHailPings(
+ in zoneIDs: [CKRecordZone.ID],
+ database: CKDatabase
+ ) async throws -> Int {
+ var deleted = 0
+ let predicate = NSPredicate(format: "kind == %@", PingKind.hail.rawValue)
+ for zoneID in zoneIDs {
+ let records = try await queryRecords(
+ type: "Ping",
+ database: database,
+ zoneID: zoneID,
+ predicate: predicate,
+ desiredKeys: []
+ )
+ try await deleteRecords(withIDs: records.map(\.recordID), in: database)
+ deleted += records.count
+ }
+ return deleted
+ }
+
+ private func gameZoneIDs(forScope scope: Int16) -> [CKRecordZone.ID] {
+ let ctx = persistence.container.newBackgroundContext()
+ return ctx.performAndWait {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(
+ format: "databaseScope == %d AND isAccessRevoked == NO",
+ scope
+ )
+ var seen = Set<String>()
+ var result: [CKRecordZone.ID] = []
+ for entity in (try? ctx.fetch(req)) ?? [] {
+ guard let gameID = entity.id else { continue }
+ let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
+ let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
+ let key = "\(ownerName)|\(zoneName)"
+ guard seen.insert(key).inserted else { continue }
+ result.append(CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName))
+ }
+ return result
+ }
+ }
+
+ /// One-shot local cleanup for pre-release debug friend rows whose
+ /// `friend-debug-preview-*` zones no longer exist or have invalid
+ /// participants. These rows are local metadata, but while present they
+ /// make the ping fast-path query dead zones on every poll.
+ func purgeDebugPreviewFriends_v1() async {
+ guard NotificationState.debugPreviewFriendPurgeNeeded() else { return }
+ let ctx = persistence.container.newBackgroundContext()
+ let deleted = ctx.performAndWait {
+ let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
+ req.predicate = NSPredicate(format: "friendZoneName BEGINSWITH %@", "friend-debug-preview-")
+ let rows = (try? ctx.fetch(req)) ?? []
+ for row in rows {
+ ctx.delete(row)
+ }
+ do {
+ if ctx.hasChanges {
+ try ctx.save()
+ }
+ return rows.count
+ } catch {
+ ctx.rollback()
+ return -1
+ }
+ }
+
+ guard deleted >= 0 else {
+ await trace("debug-preview friend purge failed")
+ return
+ }
+ NotificationState.markDebugPreviewFriendPurged()
+ await trace("debug-preview friend purge: deleted \(deleted) row(s)")
+ }
+
/// Registers an `.invite` Ping into an existing *friend* zone. Unlike
/// `enqueuePing`, the target zone is the friend zone (not the game zone),
/// so the zone and engine are passed in explicitly: `scope == 1` means we
diff --git a/Crossmate/Views/DiagnosticsView.swift b/Crossmate/Views/DiagnosticsView.swift
@@ -150,6 +150,12 @@ struct DiagnosticsView: View {
await syncMonitor.run("manual fetch") {
try await syncEngine.fetchChanges()
}
+ await syncMonitor.run("manual private ping fetch") {
+ _ = try await syncEngine.fetchPushPingsDirect(scope: .private)
+ }
+ await syncMonitor.run("manual shared ping fetch") {
+ _ = try await syncEngine.fetchPushPingsDirect(scope: .shared)
+ }
await syncMonitor.run("manual push") {
try await syncEngine.pushChanges()
}
@@ -212,4 +218,3 @@ struct DiagnosticsView: View {
return lines.joined(separator: "\n")
}
}
-
diff --git a/Crossmate/Views/EngagementDebugView.swift b/Crossmate/Views/EngagementDebugView.swift
@@ -0,0 +1,237 @@
+import Foundation
+import SwiftUI
+import UIKit
+
+struct EngagementDebugView: View {
+ @Environment(\.engagementHost) private var host
+
+ @State private var engagementID = UUID()
+ @State private var localSignalJSON = ""
+ @State private var remoteSignalJSON = ""
+ @State private var outboundMessage = "hello from Crossmate"
+ @State private var events: [String] = []
+ @State private var isBusy = false
+
+ private let encoder: JSONEncoder = {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ return encoder
+ }()
+
+ var body: some View {
+ List {
+ Section("Engagement ID") {
+ Text(engagementID.uuidString)
+ .font(.caption.monospaced())
+ .textSelection(.enabled)
+
+ Button("New Engagement ID") {
+ engagementID = UUID()
+ localSignalJSON = ""
+ remoteSignalJSON = ""
+ appendEvent("New engagement ID")
+ }
+ .disabled(isBusy)
+ }
+
+ Section("Local Signal") {
+ debugTextEditor(text: $localSignalJSON, minHeight: 150)
+
+ Button("Create Offer") {
+ Task { await createOffer() }
+ }
+ .disabled(isBusy || host == nil)
+
+ Button("Copy Local Signal") {
+ UIPasteboard.general.string = localSignalJSON
+ }
+ .disabled(localSignalJSON.isEmpty)
+ }
+
+ Section("Remote Signal") {
+ debugTextEditor(text: $remoteSignalJSON, minHeight: 150)
+
+ Button("Paste Remote Signal") {
+ remoteSignalJSON = UIPasteboard.general.string ?? ""
+ }
+
+ Button("Accept Offer and Create Reply") {
+ Task { await acceptOfferAndReply() }
+ }
+ .disabled(isBusy || host == nil || remoteSignalJSON.isEmpty)
+
+ Button("Accept Reply") {
+ Task { await acceptReply() }
+ }
+ .disabled(isBusy || host == nil || remoteSignalJSON.isEmpty)
+ }
+
+ Section("Data Channel") {
+ TextField("Message", text: $outboundMessage)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+
+ Button("Send Message") {
+ sendMessage()
+ }
+ .disabled(host == nil || outboundMessage.isEmpty)
+
+ Button("Teardown") {
+ host?.teardown(engagementID: engagementID)
+ appendEvent("Teardown requested")
+ }
+ .disabled(host == nil)
+ }
+
+ Section("Events") {
+ if events.isEmpty {
+ Text("No engagement events yet.")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(events.indices.reversed(), id: \.self) { index in
+ Text(events[index])
+ .font(.caption.monospaced())
+ .textSelection(.enabled)
+ }
+ }
+ }
+ }
+ .navigationTitle("WebRTC Host Test")
+ .navigationBarTitleDisplayMode(.inline)
+ .onAppear {
+ host?.onEvent = { event in
+ handle(event)
+ }
+ }
+ .onDisappear {
+ host?.teardown(engagementID: engagementID)
+ }
+ }
+
+ @ViewBuilder
+ private func debugTextEditor(text: Binding<String>, minHeight: CGFloat) -> some View {
+ TextEditor(text: text)
+ .font(.caption.monospaced())
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .frame(minHeight: minHeight)
+ }
+
+ private func createOffer() async {
+ guard let host else { return }
+ await run("create offer") {
+ let signal = try await host.createOffer(engagementID: engagementID)
+ localSignalJSON = try encode(signal)
+ }
+ }
+
+ private func acceptOfferAndReply() async {
+ guard let host else { return }
+ await run("accept offer") {
+ let offer = try decodeSignal(remoteSignalJSON)
+ let reply = try await host.acceptOfferAndReply(
+ engagementID: engagementID,
+ signal: offer
+ )
+ localSignalJSON = try encode(reply)
+ }
+ }
+
+ private func acceptReply() async {
+ guard let host else { return }
+ await run("accept reply") {
+ let reply = try decodeSignal(remoteSignalJSON)
+ try await host.acceptReply(engagementID: engagementID, signal: reply)
+ }
+ }
+
+ private func sendMessage() {
+ guard let data = outboundMessage.data(using: .utf8) else { return }
+ do {
+ try host?.send(engagementID: engagementID, message: data)
+ appendEvent("Sent message: \(outboundMessage)")
+ } catch {
+ appendEvent("Send failed: \(error.localizedDescription)")
+ }
+ }
+
+ private func run(_ label: String, operation: () async throws -> Void) async {
+ guard !isBusy else { return }
+ isBusy = true
+ appendEvent("Starting \(label)")
+ defer { isBusy = false }
+
+ do {
+ try await operation()
+ appendEvent("Finished \(label)")
+ } catch {
+ appendEvent("\(label) failed: \(error.localizedDescription)")
+ }
+ }
+
+ private func handle(_ event: EngagementHost.Event) {
+ switch event {
+ case .signal(let id, let signal):
+ guard id == engagementID else { return }
+ if let json = try? encode(signal) {
+ localSignalJSON = json
+ }
+ appendEvent("Signal generated")
+ case .channelOpen(let id):
+ guard id == engagementID else { return }
+ appendEvent("Channel open")
+ case .channelMessage(let id, let message):
+ guard id == engagementID else { return }
+ let text = String(data: message, encoding: .utf8)
+ ?? message.base64EncodedString()
+ appendEvent("Received message: \(text)")
+ case .channelClose(let id):
+ guard id == engagementID else { return }
+ appendEvent("Channel closed")
+ case .error(let id, let message):
+ guard id == nil || id == engagementID else { return }
+ appendEvent("Error: \(message)")
+ }
+ }
+
+ private func encode(_ signal: EngagementHost.Signal) throws -> String {
+ let data = try encoder.encode(signal)
+ guard let string = String(data: data, encoding: .utf8) else {
+ throw EngagementDebugError.invalidUTF8
+ }
+ return string
+ }
+
+ private func decodeSignal(_ string: String) throws -> EngagementHost.Signal {
+ guard let data = string.data(using: .utf8) else {
+ throw EngagementDebugError.invalidUTF8
+ }
+ return try JSONDecoder().decode(EngagementHost.Signal.self, from: data)
+ }
+
+ private func appendEvent(_ message: String) {
+ let time = Self.eventTimeFormatter.string(from: Date())
+ events.append("\(time) \(message)")
+ if events.count > 100 {
+ events.removeFirst(events.count - 100)
+ }
+ }
+
+ private static let eventTimeFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .none
+ formatter.timeStyle = .medium
+ return formatter
+ }()
+}
+
+private enum EngagementDebugError: LocalizedError {
+ case invalidUTF8
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidUTF8:
+ "The signal could not be encoded as UTF-8."
+ }
+ }
+}
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -46,6 +46,7 @@ struct PuzzleView: View {
@State private var isShowingShareSheet = false
@State private var hasSolved = false
@State private var padLayout: PadLayout?
+ @Environment(\.engagementStatus) private var engagementStatus
private enum PadLayout {
case landscape
@@ -279,7 +280,8 @@ struct PuzzleView: View {
title: titleParts.title,
subtitle: titleParts.subtitle,
showsScoreboard: padLayout == nil,
- gameID: session.mutator.gameID
+ gameID: session.mutator.gameID,
+ isEngagementLive: engagementStatus?.isLive(gameID: session.mutator.gameID) == true
)
GridView(
session: session,
@@ -761,6 +763,8 @@ private struct PuzzleToolbarModifier: ViewModifier {
@Binding var pendingRevealScope: RevealScope
@Binding var isShowingShareSheet: Bool
@Environment(PlayerPreferences.self) private var preferences
+ @Environment(\.offerEngagement) private var offerEngagement
+ @Environment(\.sendEngagementTestMessage) private var sendEngagementTestMessage
@AppStorage("debugMode") private var debugMode = false
func body(content: Content) -> some View {
@@ -819,6 +823,16 @@ private struct PuzzleToolbarModifier: ViewModifier {
} label: {
Text("Diagnostics Log")
}
+
+ Button("Offer Engagement") {
+ Task { await offerEngagement?(session.mutator.gameID) }
+ }
+ .disabled(offerEngagement == nil)
+
+ Button("Send Engagement Test Message") {
+ Task { await sendEngagementTestMessage?(session.mutator.gameID) }
+ }
+ .disabled(sendEngagementTestMessage == nil)
}
}
@@ -1125,6 +1139,7 @@ private struct PuzzleHeader: View {
let subtitle: String?
let showsScoreboard: Bool
let gameID: UUID
+ let isEngagementLive: Bool
@Environment(AnnouncementCenter.self) private var announcements
@State private var selection: Page = .title
/// Holds off looking at the announcement queue for a moment after
@@ -1195,6 +1210,7 @@ private struct PuzzleHeader: View {
.frame(height: 80)
.padding(.bottom, 14)
.animation(.easeInOut(duration: 0.3), value: visibleAnnouncement)
+ .animation(.easeInOut(duration: 0.2), value: isEngagementLive)
.task {
// 1-second hold lets the puzzle settle visually before any
// banner animates in. Posts that arrive during the hold are
@@ -1232,7 +1248,7 @@ private struct PuzzleHeader: View {
private func pageContent(_ page: Page) -> some View {
switch page {
case .title:
- PuzzleTitle(title: title, subtitle: subtitle)
+ PuzzleTitle(title: title, subtitle: subtitle, isEngagementLive: isEngagementLive)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
case .scoreboard:
PuzzleScoreboard(session: session, roster: roster, layout: .horizontal)
@@ -1247,12 +1263,21 @@ private struct PuzzleHeader: View {
private struct PuzzleTitle: View {
let title: String
let subtitle: String?
+ let isEngagementLive: Bool
+ @State private var showsEngagementIcon = false
var body: some View {
VStack(spacing: 2) {
Text(title)
.font(.headline)
.lineLimit(2)
+ .overlay(alignment: .trailing) {
+ engagementIcon
+ .offset(x: 28)
+ .opacity(showsEngagementIcon ? 1 : 0)
+ .accessibilityLabel("Engagement live")
+ .accessibilityHidden(!showsEngagementIcon)
+ }
if let subtitle {
Text(subtitle)
.font(.subheadline)
@@ -1263,6 +1288,22 @@ private struct PuzzleTitle: View {
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.horizontal)
+ .animation(.easeInOut(duration: 0.2), value: showsEngagementIcon)
+ .onAppear {
+ showsEngagementIcon = isEngagementLive
+ }
+ .onChange(of: isEngagementLive) { _, isLive in
+ withAnimation(.easeInOut(duration: 0.2)) {
+ showsEngagementIcon = isLive
+ }
+ }
+ }
+
+ private var engagementIcon: some View {
+ Image(systemName: "bolt.circle")
+ .font(.headline)
+ .foregroundStyle(.green)
+ .symbolRenderingMode(.monochrome)
}
}
diff --git a/Crossmate/Views/SettingsView.swift b/Crossmate/Views/SettingsView.swift
@@ -47,6 +47,9 @@ struct SettingsView: View {
NavigationLink("Record Editor") {
RecordEditorView()
}
+ NavigationLink("WebRTC Host Test") {
+ EngagementDebugView()
+ }
Button("Reset Database", role: .destructive) {
showResetConfirmation = true
diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift
@@ -226,6 +226,8 @@ enum NotificationState {
private static let legacyLeasePurgeKey = "migration.legacyLeasePurge.v1"
private static let legacyInvitePurgeKey = "migration.legacyInvitePurge.v1"
+ private static let staleHailPurgeKey = "migration.staleHailPurge.v1"
+ private static let debugPreviewFriendPurgeKey = "migration.debugPreviewFriendPurge.v1"
/// True if the one-shot cleanup of legacy `.opened`/`.closed` lease pings
/// has not yet run successfully on this device. The flag is per-device
@@ -252,4 +254,27 @@ enum NotificationState {
static func markLegacyInvitePurged() {
defaults?.set(true, forKey: legacyInvitePurgeKey)
}
+
+ /// True if the one-shot cleanup of pre-migration `.hail` pings from game
+ /// zones has not yet run successfully on this device.
+ static func staleHailPurgeNeeded() -> Bool {
+ defaults?.bool(forKey: staleHailPurgeKey) == false
+ }
+
+ /// Records that the stale-hail purge completed successfully so the next
+ /// launch skips it.
+ static func markStaleHailPurged() {
+ defaults?.set(true, forKey: staleHailPurgeKey)
+ }
+
+ /// True if the one-shot cleanup of local debug-preview friend rows has
+ /// not yet run successfully on this device.
+ static func debugPreviewFriendPurgeNeeded() -> Bool {
+ defaults?.bool(forKey: debugPreviewFriendPurgeKey) == false
+ }
+
+ /// Records that the debug-preview friend cleanup completed successfully.
+ static func markDebugPreviewFriendPurged() {
+ defaults?.set(true, forKey: debugPreviewFriendPurgeKey)
+ }
}
diff --git a/Tests/Unit/GameStoreUnreadMovesTests.swift b/Tests/Unit/GameStoreUnreadMovesTests.swift
@@ -407,6 +407,75 @@ struct GameStoreUnreadMovesTests {
#expect(store.unreadOtherMovesGameCount() == 0)
}
+ @Test("Realtime cell edit updates the open game through the move merger")
+ func realtimeCellEditUpdatesOpenGame() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(
+ persistence: persistence,
+ authorIDProvider: { Self.localAuthorID }
+ )
+ let (_, gameID) = try makeSharedGame(in: persistence.viewContext)
+ let (game, _) = try store.loadGame(id: gameID)
+ let updatedAt = Date()
+
+ let applied = store.applyRealtimeCellEdit(RealtimeCellEdit(
+ gameID: gameID,
+ authorID: Self.localAuthorID,
+ deviceID: "remote-device",
+ row: 0,
+ col: 0,
+ letter: "Q",
+ markKind: 0,
+ checkedWrong: false,
+ updatedAt: updatedAt,
+ cellAuthorID: Self.localAuthorID
+ ))
+
+ #expect(applied)
+ #expect(game.squares[0][0].entry == "Q")
+ #expect(game.squares[0][0].letterAuthorID == Self.localAuthorID)
+ }
+
+ @Test("Older realtime cell edit from the same device is ignored")
+ func olderRealtimeCellEditIsIgnored() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(
+ persistence: persistence,
+ authorIDProvider: { Self.localAuthorID }
+ )
+ let (_, gameID) = try makeSharedGame(in: persistence.viewContext)
+ let (game, _) = try store.loadGame(id: gameID)
+ let later = Date(timeIntervalSince1970: 200)
+ let earlier = Date(timeIntervalSince1970: 100)
+
+ #expect(store.applyRealtimeCellEdit(RealtimeCellEdit(
+ gameID: gameID,
+ authorID: Self.localAuthorID,
+ deviceID: "remote-device",
+ row: 0,
+ col: 0,
+ letter: "Q",
+ markKind: 0,
+ checkedWrong: false,
+ updatedAt: later,
+ cellAuthorID: Self.localAuthorID
+ )))
+ #expect(!store.applyRealtimeCellEdit(RealtimeCellEdit(
+ gameID: gameID,
+ authorID: Self.localAuthorID,
+ deviceID: "remote-device",
+ row: 0,
+ col: 0,
+ letter: "R",
+ markKind: 0,
+ checkedWrong: false,
+ updatedAt: earlier,
+ cellAuthorID: Self.localAuthorID
+ )))
+
+ #expect(game.squares[0][0].entry == "Q")
+ }
+
@Test("Opening a stale CmVer game reparses source and records current CmVer")
func openingStaleCmVerGameReparsesSource() throws {
let persistence = makeTestPersistence()
diff --git a/Tests/Unit/PlayerRosterTests.swift b/Tests/Unit/PlayerRosterTests.swift
@@ -60,7 +60,9 @@ struct PlayerRosterTests {
authorID: String,
name: String,
gameID: UUID,
- persistence: PersistenceController
+ persistence: PersistenceController,
+ selection: PlayerSelection? = nil,
+ updatedAt: Date = Date()
) {
let ctx = persistence.viewContext
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
@@ -71,14 +73,20 @@ struct PlayerRosterTests {
player.authorID = authorID
player.name = name
player.ckRecordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
- player.updatedAt = Date()
+ player.updatedAt = updatedAt
+ if let selection {
+ player.selRow = NSNumber(value: selection.row)
+ player.selCol = NSNumber(value: selection.col)
+ player.selDir = NSNumber(value: selection.direction.rawValue)
+ }
try? ctx.save()
}
private func makeRoster(
gameID: UUID,
persistence: PersistenceController,
- preferences: PlayerPreferences? = nil
+ preferences: PlayerPreferences? = nil,
+ engagementStore: EngagementStore = EngagementStore()
) -> PlayerRoster {
let prefs = preferences ?? PlayerPreferences(
local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
@@ -89,7 +97,8 @@ struct PlayerRosterTests {
authorIdentity: AuthorIdentity(testing: "_Local"),
preferences: prefs,
persistence: persistence,
- container: container
+ container: container,
+ engagementStore: engagementStore
)
}
@@ -182,4 +191,46 @@ struct PlayerRosterTests {
#expect(!roster.entries.contains { $0.authorID == CKCurrentUserDefaultName })
#expect(roster.entries.map(\.authorID).contains("_B"))
}
+
+ @Test("Engagement selection overrides persisted cursor track")
+ func engagementSelectionOverridesPersistedCursorTrack() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let now = Date()
+ addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence)
+ addPlayerEntity(
+ authorID: "_B",
+ name: "Alice",
+ gameID: gameID,
+ persistence: persistence,
+ selection: PlayerSelection(row: 1, col: 2, direction: .across),
+ updatedAt: now.addingTimeInterval(-5)
+ )
+ let engagementStore = EngagementStore()
+ engagementStore.set(EngagementSelectionUpdate(
+ gameID: gameID,
+ authorID: "_B",
+ deviceID: "remote-device",
+ selection: PlayerSelection(row: 3, col: 4, direction: .down),
+ updatedAt: now
+ ))
+
+ let roster = makeRoster(
+ gameID: gameID,
+ persistence: persistence,
+ engagementStore: engagementStore
+ )
+ await roster.refresh()
+
+ let selection = roster.remoteSelections["_B"]
+ #expect(selection?.row == 3)
+ #expect(selection?.col == 4)
+ #expect(selection?.direction == .down)
+
+ engagementStore.clear(gameID: gameID)
+
+ let fallback = roster.remoteSelections["_B"]
+ #expect(fallback?.row == 1)
+ #expect(fallback?.col == 2)
+ #expect(fallback?.direction == .across)
+ }
}
diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift
@@ -202,6 +202,59 @@ struct RecordSerializerTests {
#expect(broadcast["addressee"] == nil)
}
+ @Test("hail ping round-trips payload and device addressee")
+ func hailPingRoundTrip() throws {
+ let gameID = UUID()
+ let zone = RecordSerializer.zoneID(for: gameID)
+ let payload = #"{"role":"offer","engagementID":"01234567-89AB-CDEF-0123-456789ABCDEF","sdp":"v=0\r\n","candidates":["candidate:1"],"ver":1}"#
+ let record = RecordSerializer.pingRecord(
+ gameID: gameID,
+ authorID: "alice",
+ deviceID: "deviceA",
+ playerName: "Alice",
+ puzzleTitle: "Puzzle",
+ eventTimestampMs: 1700000000000,
+ kind: .hail,
+ scope: nil,
+ payload: payload,
+ addressee: "bob:deviceB",
+ zone: zone
+ )
+
+ let parsed = try #require(Ping.parseRecord(record))
+ #expect(parsed.gameID == gameID)
+ #expect(parsed.authorID == "alice")
+ #expect(parsed.deviceID == "deviceA")
+ #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")
+ }
+
+ @Test("hail ping parse requires fetched addressee for routing")
+ func hailPingRequiresFetchedAddressee() throws {
+ let gameID = UUID()
+ let zone = RecordSerializer.zoneID(for: gameID)
+ let record = RecordSerializer.pingRecord(
+ gameID: gameID,
+ authorID: "alice",
+ deviceID: "deviceA",
+ playerName: "Alice",
+ 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
+ )
+
+ let parsed = try #require(Ping.parseRecord(record))
+ #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)
diff --git a/Tests/Unit/Sync/EngagementCoordinatorTests.swift b/Tests/Unit/Sync/EngagementCoordinatorTests.swift
@@ -0,0 +1,725 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("EngagementCoordinator")
+struct EngagementCoordinatorTests {
+ @Test("hail payload encodes role, engagement ID, and signal")
+ func hailPayloadRoundTrip() throws {
+ let engagementID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!
+ let signal = EngagementSignal(sdp: "offer-sdp", candidates: ["candidate-a"])
+ let payload = EngagementHailPayload(role: .offer, engagementID: engagementID, signal: signal)
+
+ let decoded = EngagementHailPayload.decode(try payload.encoded())
+
+ #expect(decoded?.role == .offer)
+ #expect(decoded?.engagementID == engagementID)
+ #expect(decoded?.signal == signal)
+ #expect(decoded?.ver == 1)
+ }
+
+ @Test("addressee matches author-wide and device-specific hails")
+ func addresseeMatching() {
+ #expect(EngagementAddressee.parse("alice")?.matches(authorID: "alice", deviceID: "phone") == true)
+ #expect(EngagementAddressee.parse("alice:phone")?.matches(authorID: "alice", deviceID: "phone") == true)
+ #expect(EngagementAddressee.parse("alice:pad")?.matches(authorID: "alice", deviceID: "phone") == false)
+ #expect(EngagementAddressee.parse("bob:phone")?.matches(authorID: "alice", deviceID: "phone") == false)
+ #expect(EngagementAddressee.parse(nil) == nil)
+ }
+
+ @Test("debug message envelope round trips")
+ func debugMessageRoundTrip() throws {
+ let sentAt = Date(timeIntervalSince1970: 123)
+ let message = EngagementMessage(text: "hello", sentAt: sentAt)
+
+ let decoded = try #require(EngagementMessage.decode(try message.encodedData()))
+
+ #expect(decoded.kind == .debugText)
+ #expect(decoded.text == "hello")
+ #expect(decoded.sentAt == sentAt)
+ #expect(decoded.ver == 1)
+ }
+
+ @Test("cell edit message envelope round trips")
+ func cellEditMessageRoundTrip() throws {
+ let edit = RealtimeCellEdit(
+ gameID: UUID(uuidString: "12121212-1212-1212-1212-121212121212")!,
+ authorID: "alice",
+ deviceID: "deviceA",
+ row: 1,
+ col: 2,
+ letter: "Z",
+ markKind: 2,
+ checkedWrong: false,
+ updatedAt: Date(timeIntervalSince1970: 456),
+ cellAuthorID: "alice"
+ )
+ let message = EngagementMessage(cellEdit: edit, sentAt: Date(timeIntervalSince1970: 789))
+
+ let decoded = try #require(EngagementMessage.decode(try message.encodedData()))
+
+ #expect(decoded.kind == .cellEdit)
+ #expect(decoded.text == "")
+ #expect(decoded.cellEdit == edit)
+ #expect(decoded.sentAt == Date(timeIntervalSince1970: 789))
+ }
+
+ @Test("selection message envelope round trips")
+ func selectionMessageRoundTrip() throws {
+ let gameID = UUID(uuidString: "13131313-1313-1313-1313-131313131313")!
+ let update = EngagementSelectionUpdate(
+ gameID: gameID,
+ authorID: "alice",
+ deviceID: "deviceA",
+ selection: PlayerSelection(row: 3, col: 4, direction: .down),
+ updatedAt: Date(timeIntervalSince1970: 321)
+ )
+ let message = EngagementMessage(selection: update, sentAt: Date(timeIntervalSince1970: 654))
+
+ let decoded = try #require(EngagementMessage.decode(try message.encodedData()))
+
+ #expect(decoded.kind == .selection)
+ #expect(decoded.text == "")
+ #expect(decoded.selection == update)
+ #expect(decoded.sentAt == Date(timeIntervalSince1970: 654))
+ }
+
+ @Test("present peer with greater author ID causes a directed offer")
+ @MainActor
+ func presentPeerCreatesOffer() async throws {
+ let gameID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")!
+ let host = MockEngagementHost()
+ let sink = EngagementCoordinatorTestSink()
+ let coordinator = EngagementCoordinator(
+ host: host,
+ localAuthorID: { "alice" },
+ localDeviceID: "deviceA",
+ presentPeers: { _ in [gameID: ["bob", "aardvark"]] },
+ sendHail: { gameID, payload, addressee in
+ await sink.send(gameID: gameID, payload: payload, addressee: addressee)
+ },
+ deletePing: { recordName, gameID in
+ await sink.delete(recordName: recordName, gameID: gameID)
+ }
+ )
+
+ await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
+
+ #expect(host.createdOffers.count == 1)
+ let sent = await sink.sentHails()
+ #expect(sent.count == 1)
+ #expect(sent.first?.gameID == gameID)
+ #expect(sent.first?.addressee == "bob")
+ let payload = EngagementHailPayload.decode(sent.first?.payload)
+ #expect(payload?.role == .offer)
+ #expect(payload?.signal == host.offerSignal)
+ }
+
+ @Test("manual offer addresses the local author for same-account device testing")
+ @MainActor
+ func manualOfferAddressesLocalAuthor() async throws {
+ let gameID = UUID(uuidString: "66666666-6666-6666-6666-666666666666")!
+ let host = MockEngagementHost()
+ let sink = EngagementCoordinatorTestSink()
+ let coordinator = EngagementCoordinator(
+ host: host,
+ localAuthorID: { "alice" },
+ localDeviceID: "deviceA",
+ presentPeers: { _ in [:] },
+ sendHail: { gameID, payload, addressee in
+ await sink.send(gameID: gameID, payload: payload, addressee: addressee)
+ },
+ deletePing: { recordName, gameID in
+ await sink.delete(recordName: recordName, gameID: gameID)
+ }
+ )
+
+ await coordinator.offerEngagement(gameID: gameID)
+
+ #expect(host.createdOffers.count == 1)
+ let sent = await sink.sentHails()
+ #expect(sent.count == 1)
+ #expect(sent.first?.gameID == gameID)
+ #expect(sent.first?.addressee == "alice")
+ #expect(EngagementHailPayload.decode(sent.first?.payload)?.role == .offer)
+ }
+
+ @Test("inbound offer sends device-specific reply and deletes offer ping")
+ @MainActor
+ func inboundOfferSendsReply() async throws {
+ let gameID = UUID(uuidString: "33333333-3333-3333-3333-333333333333")!
+ let engagementID = UUID(uuidString: "44444444-4444-4444-4444-444444444444")!
+ let host = MockEngagementHost()
+ let sink = EngagementCoordinatorTestSink()
+ let coordinator = EngagementCoordinator(
+ host: host,
+ localAuthorID: { "bob" },
+ localDeviceID: "deviceB",
+ presentPeers: { _ in [:] },
+ sendHail: { gameID, payload, addressee in
+ await sink.send(gameID: gameID, payload: payload, addressee: addressee)
+ },
+ deletePing: { recordName, gameID in
+ await sink.delete(recordName: recordName, gameID: gameID)
+ }
+ )
+ let offer = EngagementHailPayload(
+ role: .offer,
+ engagementID: engagementID,
+ signal: EngagementSignal(sdp: "offer-sdp", candidates: ["offer-candidate"])
+ )
+
+ await coordinator.handle(ping(
+ recordName: "offer-record",
+ gameID: gameID,
+ authorID: "alice",
+ deviceID: "deviceA",
+ payload: try offer.encoded(),
+ addressee: "bob:deviceB"
+ ))
+
+ #expect(host.acceptedOffers == [engagementID])
+ let sent = await sink.sentHails()
+ #expect(sent.count == 1)
+ #expect(sent.first?.addressee == "alice:deviceA")
+ #expect(EngagementHailPayload.decode(sent.first?.payload)?.role == .reply)
+ #expect(EngagementHailPayload.decode(sent.first?.payload)?.signal == host.replySignal)
+ #expect(await sink.deletedPings() == [
+ DeletedPing(recordName: "offer-record", gameID: gameID)
+ ])
+ }
+
+ @Test("inbound replacement offer tears down old engagement and replies")
+ @MainActor
+ func inboundReplacementOfferSendsReply() async throws {
+ let gameID = UUID(uuidString: "ABABABAB-ABAB-ABAB-ABAB-ABABABABABAB")!
+ let oldEngagementID = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!
+ let newEngagementID = UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")!
+ let host = MockEngagementHost()
+ let sink = EngagementCoordinatorTestSink()
+ let coordinator = EngagementCoordinator(
+ host: host,
+ localAuthorID: { "bob" },
+ localDeviceID: "deviceB",
+ presentPeers: { _ in [:] },
+ sendHail: { gameID, payload, addressee in
+ await sink.send(gameID: gameID, payload: payload, addressee: addressee)
+ },
+ deletePing: { recordName, gameID in
+ await sink.delete(recordName: recordName, gameID: gameID)
+ }
+ )
+ let oldOffer = EngagementHailPayload(
+ role: .offer,
+ engagementID: oldEngagementID,
+ signal: EngagementSignal(sdp: "old-offer-sdp", candidates: [])
+ )
+ let newOffer = EngagementHailPayload(
+ role: .offer,
+ engagementID: newEngagementID,
+ signal: EngagementSignal(sdp: "new-offer-sdp", candidates: [])
+ )
+
+ await coordinator.handle(ping(
+ recordName: "old-offer-record",
+ gameID: gameID,
+ authorID: "alice",
+ deviceID: "deviceA",
+ payload: try oldOffer.encoded(),
+ addressee: "bob:deviceB"
+ ))
+ await coordinator.handle(ping(
+ recordName: "new-offer-record",
+ gameID: gameID,
+ authorID: "alice",
+ deviceID: "deviceA",
+ payload: try newOffer.encoded(),
+ addressee: "bob:deviceB"
+ ))
+
+ #expect(host.tornDown == [oldEngagementID])
+ #expect(host.acceptedOffers == [oldEngagementID, newEngagementID])
+ let sent = await sink.sentHails()
+ #expect(sent.count == 2)
+ #expect(EngagementHailPayload.decode(sent.last?.payload)?.engagementID == newEngagementID)
+ #expect(await sink.deletedPings() == [
+ DeletedPing(recordName: "old-offer-record", gameID: gameID),
+ DeletedPing(recordName: "new-offer-record", gameID: gameID)
+ ])
+ }
+
+ @Test("stale hails are ignored before current offers")
+ @MainActor
+ func staleHailIsIgnored() async throws {
+ let gameID = UUID(uuidString: "77777777-7777-7777-7777-777777777777")!
+ let host = MockEngagementHost()
+ let sink = EngagementCoordinatorTestSink()
+ let coordinator = EngagementCoordinator(
+ host: host,
+ localAuthorID: { "bob" },
+ localDeviceID: "deviceB",
+ presentPeers: { _ in [:] },
+ sendHail: { gameID, payload, addressee in
+ await sink.send(gameID: gameID, payload: payload, addressee: addressee)
+ },
+ deletePing: { recordName, gameID in
+ await sink.delete(recordName: recordName, gameID: gameID)
+ },
+ now: { Date(timeIntervalSince1970: 1_000) },
+ hailMaxAge: 120
+ )
+ let staleOffer = EngagementHailPayload(
+ role: .offer,
+ engagementID: UUID(uuidString: "88888888-8888-8888-8888-888888888888")!,
+ signal: EngagementSignal(sdp: "stale-sdp", candidates: [])
+ )
+ let currentOffer = EngagementHailPayload(
+ role: .offer,
+ engagementID: UUID(uuidString: "99999999-9999-9999-9999-999999999999")!,
+ signal: EngagementSignal(sdp: "current-sdp", candidates: [])
+ )
+
+ await coordinator.handle(ping(
+ recordName: recordName(gameID: gameID, authorID: "alice", deviceID: "deviceA", timestampMs: 800_000),
+ gameID: gameID,
+ authorID: "alice",
+ deviceID: "deviceA",
+ payload: try staleOffer.encoded(),
+ addressee: "bob:deviceB"
+ ))
+ await coordinator.handle(ping(
+ recordName: recordName(gameID: gameID, authorID: "alice", deviceID: "deviceA", timestampMs: 999_000),
+ gameID: gameID,
+ authorID: "alice",
+ deviceID: "deviceA",
+ payload: try currentOffer.encoded(),
+ addressee: "bob:deviceB"
+ ))
+
+ #expect(host.acceptedOffers == [currentOffer.engagementID])
+ #expect(await sink.sentHails().count == 1)
+ #expect(await sink.deletedPings() == [
+ DeletedPing(
+ recordName: recordName(
+ gameID: gameID,
+ authorID: "alice",
+ deviceID: "deviceA",
+ timestampMs: 800_000
+ ),
+ gameID: gameID
+ ),
+ DeletedPing(
+ recordName: recordName(
+ gameID: gameID,
+ authorID: "alice",
+ deviceID: "deviceA",
+ timestampMs: 999_000
+ ),
+ gameID: gameID
+ )
+ ])
+ }
+
+ @Test("inbound reply accepts matching offered engagement and deletes reply ping")
+ @MainActor
+ func inboundReplyAcceptsOffer() async throws {
+ let gameID = UUID(uuidString: "55555555-5555-5555-5555-555555555555")!
+ let host = MockEngagementHost()
+ let sink = EngagementCoordinatorTestSink()
+ let coordinator = EngagementCoordinator(
+ host: host,
+ localAuthorID: { "alice" },
+ localDeviceID: "deviceA",
+ presentPeers: { _ in [gameID: ["bob"]] },
+ sendHail: { gameID, payload, addressee in
+ await sink.send(gameID: gameID, payload: payload, addressee: addressee)
+ },
+ deletePing: { recordName, gameID in
+ await sink.delete(recordName: recordName, gameID: gameID)
+ }
+ )
+ await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
+ let offer = try #require(EngagementHailPayload.decode(await sink.sentHails().first?.payload))
+ let reply = EngagementHailPayload(
+ role: .reply,
+ engagementID: offer.engagementID,
+ signal: EngagementSignal(sdp: "reply-sdp", candidates: ["reply-candidate"])
+ )
+
+ await coordinator.handle(ping(
+ recordName: "reply-record",
+ gameID: gameID,
+ authorID: "bob",
+ deviceID: "deviceB",
+ payload: try reply.encoded(),
+ addressee: "alice:deviceA"
+ ))
+
+ #expect(host.acceptedReplies == [offer.engagementID])
+ #expect(await sink.deletedPings() == [
+ DeletedPing(recordName: "reply-record", gameID: gameID)
+ ])
+ }
+
+ @Test("unmatched reply is deleted")
+ @MainActor
+ func unmatchedReplyIsDeleted() async throws {
+ let gameID = UUID(uuidString: "CDCDCDCD-CDCD-CDCD-CDCD-CDCDCDCDCDCD")!
+ let host = MockEngagementHost()
+ let sink = EngagementCoordinatorTestSink()
+ let coordinator = EngagementCoordinator(
+ host: host,
+ localAuthorID: { "alice" },
+ localDeviceID: "deviceA",
+ presentPeers: { _ in [:] },
+ sendHail: { gameID, payload, addressee in
+ await sink.send(gameID: gameID, payload: payload, addressee: addressee)
+ },
+ deletePing: { recordName, gameID in
+ await sink.delete(recordName: recordName, gameID: gameID)
+ }
+ )
+ let reply = EngagementHailPayload(
+ role: .reply,
+ engagementID: UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")!,
+ signal: EngagementSignal(sdp: "reply-sdp", candidates: [])
+ )
+
+ await coordinator.handle(ping(
+ recordName: "unmatched-reply-record",
+ gameID: gameID,
+ authorID: "bob",
+ deviceID: "deviceB",
+ payload: try reply.encoded(),
+ addressee: "alice:deviceA"
+ ))
+
+ #expect(host.acceptedReplies.isEmpty)
+ #expect(await sink.deletedPings() == [
+ DeletedPing(recordName: "unmatched-reply-record", gameID: gameID)
+ ])
+ }
+
+ @Test("same-account losing offer is deleted while local offer stays pending")
+ @MainActor
+ func sameAccountLosingOfferIsDeleted() async throws {
+ let gameID = UUID(uuidString: "E1E1E1E1-E1E1-E1E1-E1E1-E1E1E1E1E1E1")!
+ let remoteEngagementID = UUID(uuidString: "E2E2E2E2-E2E2-E2E2-E2E2-E2E2E2E2E2E2")!
+ let host = MockEngagementHost()
+ let sink = EngagementCoordinatorTestSink()
+ let coordinator = EngagementCoordinator(
+ host: host,
+ localAuthorID: { "alice" },
+ localDeviceID: "aaa-device",
+ presentPeers: { _ in [:] },
+ sendHail: { gameID, payload, addressee in
+ await sink.send(gameID: gameID, payload: payload, addressee: addressee)
+ },
+ deletePing: { recordName, gameID in
+ await sink.delete(recordName: recordName, gameID: gameID)
+ }
+ )
+ let remoteOffer = EngagementHailPayload(
+ role: .offer,
+ engagementID: remoteEngagementID,
+ signal: EngagementSignal(sdp: "remote-offer-sdp", candidates: [])
+ )
+
+ await coordinator.offerEngagement(gameID: gameID)
+ await coordinator.handle(ping(
+ recordName: "remote-offer-record",
+ gameID: gameID,
+ authorID: "alice",
+ deviceID: "zzz-device",
+ payload: try remoteOffer.encoded(),
+ addressee: "alice:aaa-device"
+ ))
+
+ #expect(host.createdOffers.count == 1)
+ #expect(host.acceptedOffers.isEmpty)
+ #expect(host.tornDown.isEmpty)
+ #expect(await sink.sentHails().count == 1)
+ #expect(await sink.deletedPings() == [
+ DeletedPing(recordName: "remote-offer-record", gameID: gameID)
+ ])
+ }
+
+ @Test("same-account winning offer replaces local offer and sends reply")
+ @MainActor
+ func sameAccountWinningOfferReplacesLocalOffer() async throws {
+ let gameID = UUID(uuidString: "F1F1F1F1-F1F1-F1F1-F1F1-F1F1F1F1F1F1")!
+ let remoteEngagementID = UUID(uuidString: "F2F2F2F2-F2F2-F2F2-F2F2-F2F2F2F2F2F2")!
+ let host = MockEngagementHost()
+ let sink = EngagementCoordinatorTestSink()
+ let coordinator = EngagementCoordinator(
+ host: host,
+ localAuthorID: { "alice" },
+ localDeviceID: "zzz-device",
+ presentPeers: { _ in [:] },
+ sendHail: { gameID, payload, addressee in
+ await sink.send(gameID: gameID, payload: payload, addressee: addressee)
+ },
+ deletePing: { recordName, gameID in
+ await sink.delete(recordName: recordName, gameID: gameID)
+ }
+ )
+ let remoteOffer = EngagementHailPayload(
+ role: .offer,
+ engagementID: remoteEngagementID,
+ signal: EngagementSignal(sdp: "remote-offer-sdp", candidates: [])
+ )
+
+ await coordinator.offerEngagement(gameID: gameID)
+ let localOffer = try #require(EngagementHailPayload.decode(await sink.sentHails().first?.payload))
+ await coordinator.handle(ping(
+ recordName: "remote-offer-record",
+ gameID: gameID,
+ authorID: "alice",
+ deviceID: "aaa-device",
+ payload: try remoteOffer.encoded(),
+ addressee: "alice:zzz-device"
+ ))
+
+ #expect(host.tornDown == [localOffer.engagementID])
+ #expect(host.acceptedOffers == [remoteEngagementID])
+ let sent = await sink.sentHails()
+ #expect(sent.count == 2)
+ #expect(sent.last?.addressee == "alice:aaa-device")
+ #expect(EngagementHailPayload.decode(sent.last?.payload)?.role == .reply)
+ #expect(EngagementHailPayload.decode(sent.last?.payload)?.engagementID == remoteEngagementID)
+ #expect(await sink.deletedPings() == [
+ DeletedPing(recordName: "remote-offer-record", gameID: gameID)
+ ])
+ }
+
+ @Test("debug message sends over live engagement")
+ @MainActor
+ func debugMessageSendsOverLiveEngagement() async throws {
+ let gameID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!
+ let host = MockEngagementHost()
+ let sink = EngagementCoordinatorTestSink()
+ let coordinator = EngagementCoordinator(
+ host: host,
+ localAuthorID: { "alice" },
+ localDeviceID: "deviceA",
+ presentPeers: { _ in [gameID: ["bob"]] },
+ sendHail: { gameID, payload, addressee in
+ await sink.send(gameID: gameID, payload: payload, addressee: addressee)
+ },
+ deletePing: { recordName, gameID in
+ await sink.delete(recordName: recordName, gameID: gameID)
+ }
+ )
+ await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
+ let offer = try #require(EngagementHailPayload.decode(await sink.sentHails().first?.payload))
+ let reply = EngagementHailPayload(
+ role: .reply,
+ engagementID: offer.engagementID,
+ signal: EngagementSignal(sdp: "reply-sdp", candidates: [])
+ )
+ await coordinator.handle(ping(
+ recordName: "reply-record",
+ gameID: gameID,
+ authorID: "bob",
+ deviceID: "deviceB",
+ payload: try reply.encoded(),
+ addressee: "alice:deviceA"
+ ))
+
+ await coordinator.sendDebugMessage(gameID: gameID, text: "debug hello")
+
+ #expect(host.sentMessages.count == 1)
+ #expect(host.sentMessages.first?.engagementID == offer.engagementID)
+ #expect(EngagementMessage.decode(try #require(host.sentMessages.first?.message))?.text == "debug hello")
+ }
+
+ @Test("selection sends over live engagement")
+ @MainActor
+ func selectionSendsOverLiveEngagement() async throws {
+ let gameID = UUID(uuidString: "A1A1A1A1-A1A1-A1A1-A1A1-A1A1A1A1A1A1")!
+ let host = MockEngagementHost()
+ let sink = EngagementCoordinatorTestSink()
+ let coordinator = EngagementCoordinator(
+ host: host,
+ localAuthorID: { "alice" },
+ localDeviceID: "deviceA",
+ presentPeers: { _ in [gameID: ["bob"]] },
+ sendHail: { gameID, payload, addressee in
+ await sink.send(gameID: gameID, payload: payload, addressee: addressee)
+ },
+ deletePing: { recordName, gameID in
+ await sink.delete(recordName: recordName, gameID: gameID)
+ }
+ )
+ await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
+ let offer = try #require(EngagementHailPayload.decode(await sink.sentHails().first?.payload))
+ let reply = EngagementHailPayload(
+ role: .reply,
+ engagementID: offer.engagementID,
+ signal: EngagementSignal(sdp: "reply-sdp", candidates: [])
+ )
+ await coordinator.handle(ping(
+ recordName: "reply-record",
+ gameID: gameID,
+ authorID: "bob",
+ deviceID: "deviceB",
+ payload: try reply.encoded(),
+ addressee: "alice:deviceA"
+ ))
+ let selection = EngagementSelectionUpdate(
+ gameID: gameID,
+ authorID: "alice",
+ deviceID: "deviceA",
+ selection: PlayerSelection(row: 2, col: 3, direction: .across),
+ updatedAt: Date(timeIntervalSince1970: 123)
+ )
+
+ await coordinator.sendSelection(selection)
+
+ #expect(host.sentMessages.count == 1)
+ #expect(host.sentMessages.first?.engagementID == offer.engagementID)
+ #expect(EngagementMessage.decode(try #require(host.sentMessages.first?.message))?.selection == selection)
+ }
+
+ @Test("closed engagement can re-offer when peer remains present")
+ @MainActor
+ func channelCloseAllowsReconnectOffer() async throws {
+ let gameID = UUID(uuidString: "B0B0B0B0-B0B0-B0B0-B0B0-B0B0B0B0B0B0")!
+ let host = MockEngagementHost()
+ let sink = EngagementCoordinatorTestSink()
+ let coordinator = EngagementCoordinator(
+ host: host,
+ localAuthorID: { "alice" },
+ localDeviceID: "deviceA",
+ presentPeers: { _ in [gameID: ["bob"]] },
+ sendHail: { gameID, payload, addressee in
+ await sink.send(gameID: gameID, payload: payload, addressee: addressee)
+ },
+ deletePing: { recordName, gameID in
+ await sink.delete(recordName: recordName, gameID: gameID)
+ }
+ )
+ await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
+ let firstOffer = try #require(EngagementHailPayload.decode(await sink.sentHails().first?.payload))
+ let reply = EngagementHailPayload(
+ role: .reply,
+ engagementID: firstOffer.engagementID,
+ signal: EngagementSignal(sdp: "reply-sdp", candidates: [])
+ )
+ await coordinator.handle(ping(
+ recordName: "reply-record",
+ gameID: gameID,
+ authorID: "bob",
+ deviceID: "deviceB",
+ payload: try reply.encoded(),
+ addressee: "alice:deviceA"
+ ))
+
+ #expect(await coordinator.channelClosed(engagementID: firstOffer.engagementID) == gameID)
+ await coordinator.peerPresenceMayHaveChanged(gameIDs: [gameID])
+
+ #expect(host.createdOffers.count == 2)
+ let sent = await sink.sentHails()
+ #expect(sent.count == 2)
+ let secondOffer = try #require(EngagementHailPayload.decode(sent.last?.payload))
+ #expect(secondOffer.role == .offer)
+ #expect(secondOffer.engagementID != firstOffer.engagementID)
+ }
+
+ private func ping(
+ recordName: String,
+ gameID: UUID,
+ authorID: String,
+ deviceID: String,
+ payload: String,
+ addressee: String
+ ) -> Ping {
+ Ping(
+ recordName: recordName,
+ gameID: gameID,
+ authorID: authorID,
+ deviceID: deviceID,
+ playerName: authorID,
+ puzzleTitle: "Puzzle",
+ kind: .hail,
+ scope: nil,
+ payload: payload,
+ addressee: addressee
+ )
+ }
+
+ private func recordName(
+ gameID: UUID,
+ authorID: String,
+ deviceID: String,
+ timestampMs: Int64
+ ) -> String {
+ "ping-\(gameID.uuidString)-\(authorID)-\(deviceID)-\(timestampMs)"
+ }
+}
+
+private struct SentHail: Equatable, Sendable {
+ var gameID: UUID
+ var payload: String
+ var addressee: String
+}
+
+private struct DeletedPing: Equatable, Sendable {
+ var recordName: String
+ var gameID: UUID
+}
+
+private actor EngagementCoordinatorTestSink {
+ private var sent: [SentHail] = []
+ private var deleted: [DeletedPing] = []
+
+ func send(gameID: UUID, payload: String, addressee: String) {
+ sent.append(SentHail(gameID: gameID, payload: payload, addressee: addressee))
+ }
+
+ func delete(recordName: String, gameID: UUID) {
+ deleted.append(DeletedPing(recordName: recordName, gameID: gameID))
+ }
+
+ func sentHails() -> [SentHail] {
+ sent
+ }
+
+ func deletedPings() -> [DeletedPing] {
+ deleted
+ }
+}
+
+@MainActor
+private final class MockEngagementHost: EngagementHosting, @unchecked Sendable {
+ let offerSignal = EngagementSignal(sdp: "local-offer-sdp", candidates: ["local-offer-candidate"])
+ let replySignal = EngagementSignal(sdp: "local-reply-sdp", candidates: ["local-reply-candidate"])
+ var createdOffers: [UUID] = []
+ var acceptedOffers: [UUID] = []
+ var acceptedReplies: [UUID] = []
+ var tornDown: [UUID] = []
+ var sentMessages: [(engagementID: UUID, message: Data)] = []
+
+ func createOffer(engagementID: UUID) async throws -> EngagementSignal {
+ createdOffers.append(engagementID)
+ return offerSignal
+ }
+
+ func acceptOfferAndReply(engagementID: UUID, signal: EngagementSignal) async throws -> EngagementSignal {
+ acceptedOffers.append(engagementID)
+ return replySignal
+ }
+
+ func acceptReply(engagementID: UUID, signal: EngagementSignal) async throws {
+ acceptedReplies.append(engagementID)
+ }
+
+ func send(engagementID: UUID, message: Data) throws {
+ sentMessages.append((engagementID, message))
+ }
+
+ func teardown(engagementID: UUID) {
+ tornDown.append(engagementID)
+ }
+}