crossmate

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

commit ec02ded59714bbc711837313cdc421f630f84206
parent 31758bb8b0807a5c998a3e41e15e3a960845c8bc
Author: Michael Camilleri <[email protected]>
Date:   Sat, 16 May 2026 16:56:18 +0900

Add friend invites for past collaborators

Priot to this commit, the only way to bring someone into a Crossmate game was
by providing an iCloud link. As a result, replaying with a prior collaborator
meant generating and hand-delivering a fresh universal link. This commit lets a
user re-invite anyone they have already played with (and block a collaborator
so they receive nothing further from that user).

The channel is a durable pairwise 'friend zone': a custom zone carrying a
zone-wide CKShare with publicPermission = .none, mirroring the private
account zone but living in the shared database so both friends can write to it.
The friendship bootstrap and the re-invite both ride new .friend / .invite
PingKind cases on the existing Ping record type rather than new record types —
the only CloudKit schema change is one new payload String field on Ping.

Bootstrap keys off the first sighting of a remote Player record. Identity is
taken purely from Player records.  Owner election is the lexicographically
smaller authorID; the friend-<pairKey> zone name is a deterministic hash of the
two userRecordIDs so both sides converge on a single zone even if they race.

An inbound .invite Ping upserts a local-only InviteEntity (keyed by Ping record
name, so a declined row is a tombstone) surfaced in a new 'Invited' section of
the Game List plus a notification; Accept reuses the existing CKShare accept
path. Block sets isBlocked, leaves every shared game from that author, and
tears down the friend zone (owner deletes the zone, the participant deletes the
zone-wide share); re-friending after a block is out of scope for v1 and is
deliberately permanent.

This adds FriendController/FriendZone/FriendPickerView, FriendEntity and
InviteEntity to the model, and unit coverage for pair-key determinism and
symmetry, Ping payload round-trip for .friend/.invite and the friend/ invite
Core Data predicates.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 20++++++++++++++++++++
MCrossmate/CrossmateApp.swift | 9+++++++++
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 32++++++++++++++++++++++++++++++++
MCrossmate/Persistence/GameStore.swift | 9+++++++++
MCrossmate/Services/AppServices.swift | 241+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
MCrossmate/Services/CloudService.swift | 29+++++++++++++++++++++++++++++
ACrossmate/Sync/FriendController.swift | 362+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Sync/FriendZone.swift | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/RecordSerializer.swift | 4++++
MCrossmate/Sync/ShareController.swift | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
MCrossmate/Sync/SyncEngine.swift | 150++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
ACrossmate/Views/FriendPickerView.swift | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/GameListView.swift | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/GameShareItem.swift | 10++++++++++
MTests/Unit/PuzzleNotificationTextTests.swift | 6++++--
MTests/Unit/RecordSerializerTests.swift | 32++++++++++++++++++++++++++++++++
ATests/Unit/Sync/FriendModelTests.swift | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/Sync/FriendZoneTests.swift | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
18 files changed, 1529 insertions(+), 30 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 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 */; }; + 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 */; }; @@ -37,8 +38,10 @@ 4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */; }; 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; }; + 6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B766E872B12DC79ECCD80941 /* FriendModelTests.swift */; }; 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; }; 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; }; + 712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800CCFBE90554F287E765755 /* FriendZoneTests.swift */; }; 740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B23A692318044351247606DF /* SuccessPanel.swift */; }; 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; }; @@ -51,6 +54,7 @@ 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */; }; 849970A21D62C34EC382A27E /* GameShareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABB557BA10CBE9909056882 /* GameShareItem.swift */; }; 85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */; }; + 886A451A134D88B9324D9A1C /* FriendPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE3412F437AABD2988B6976D /* FriendPickerView.swift */; }; 89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */; }; 8B356C953DA0FAF149C3391A /* Puzzles in Resources */ = {isa = PBXBuildFile; fileRef = BA67C509B467132D1B7510A4 /* Puzzles */; }; 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; }; @@ -76,6 +80,7 @@ C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; }; C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; }; 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 */; }; CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BC76178319D0D669CD50FF /* CloudService.swift */; }; CCF3867C32C3F36E4F69A59E /* DebuggingMonitors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */; }; @@ -155,10 +160,12 @@ 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisher.swift; sourceTree = "<group>"; }; 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; }; 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; + 7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendZone.swift; sourceTree = "<group>"; }; 7B3E1A382B24A7803701D947 /* Crossmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Crossmate.entitlements; sourceTree = "<group>"; }; 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; }; 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdater.swift; sourceTree = "<group>"; }; 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerTests.swift; sourceTree = "<group>"; }; + 800CCFBE90554F287E765755 /* FriendZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendZoneTests.swift; sourceTree = "<group>"; }; 86470163BFF956F3DE438506 /* Moves.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Moves.swift; sourceTree = "<group>"; }; 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportedBrowseView.swift; sourceTree = "<group>"; }; 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStore.swift; sourceTree = "<group>"; }; @@ -180,6 +187,7 @@ B23A692318044351247606DF /* SuccessPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessPanel.swift; sourceTree = "<group>"; }; B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUZToXDConverter.swift; sourceTree = "<group>"; }; B689A7138429641E61E9E558 /* Crossmate.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Crossmate.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B766E872B12DC79ECCD80941 /* FriendModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendModelTests.swift; sourceTree = "<group>"; }; B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalogTests.swift; sourceTree = "<group>"; }; B9031A1574C21866940F6A2C /* XD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XD.swift; sourceTree = "<group>"; }; BA67C509B467132D1B7510A4 /* Puzzles */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Puzzles; sourceTree = SOURCE_ROOT; }; @@ -198,11 +206,13 @@ DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; }; DB851649DE78AAAC5A928C52 /* Square.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Square.swift; sourceTree = "<group>"; }; E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordEditorView.swift; sourceTree = "<group>"; }; + E655698481325C92EF5C348B /* FriendController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendController.swift; sourceTree = "<group>"; }; E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSource.swift; sourceTree = "<group>"; }; E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClueList.swift; sourceTree = "<group>"; }; EAC61E2582D94B1E6EC67136 /* XDFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDFileType.swift; sourceTree = "<group>"; }; EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePlayerColorStoreTests.swift; sourceTree = "<group>"; }; ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthServiceTests.swift; sourceTree = "<group>"; }; + EE3412F437AABD2988B6976D /* FriendPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendPickerView.swift; sourceTree = "<group>"; }; EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesInboundTests.swift; sourceTree = "<group>"; }; F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = "<group>"; }; F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; }; @@ -225,6 +235,8 @@ isa = PBXGroup; children = ( B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */, + E655698481325C92EF5C348B /* FriendController.swift */, + 7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */, 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */, 86470163BFF956F3DE438506 /* Moves.swift */, 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */, @@ -342,6 +354,7 @@ F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */, E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */, 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */, + EE3412F437AABD2988B6976D /* FriendPickerView.swift */, 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */, 5ABB557BA10CBE9909056882 /* GameShareItem.swift */, D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */, @@ -373,6 +386,8 @@ children = ( 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */, 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */, + B766E872B12DC79ECCD80941 /* FriendModelTests.swift */, + 800CCFBE90554F287E765755 /* FriendZoneTests.swift */, EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */, 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */, 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */, @@ -510,6 +525,8 @@ files = ( A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */, 02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */, + 6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */, + 712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */, 85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */, 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */, 170D481E47CE5CB17BB8619E /* GamePlayerColorStoreTests.swift in Sources */, @@ -556,6 +573,9 @@ CCF3867C32C3F36E4F69A59E /* DebuggingMonitors.swift in Sources */, 1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */, 978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */, + C8ACF431021E7BEE61A99153 /* FriendController.swift in Sources */, + 886A451A134D88B9324D9A1C /* FriendPickerView.swift in Sources */, + 2AF2550B08CE79F8615B3076 /* FriendZone.swift in Sources */, 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */, 128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */, 818B1F2693962832BE14578E /* GameListView.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -26,6 +26,15 @@ struct CrossmateApp: App { .environment(services.nytAuth) .environment(\.nytPuzzleFetcher, services.nytFetcher) .environment(\.resetDatabase, { await services.cloudService.resetAllData() }) + .environment(\.inviteFriend, { gameID, friendAuthorID in + try await services.inviteFriend(gameID: gameID, friendAuthorID: friendAuthorID) + }) + .environment(\.acceptInvite, { shareURL, pingRecordName in + try await services.acceptInvite(shareURL: shareURL, pingRecordName: pingRecordName) + }) + .environment(\.blockFriend, { friendAuthorID in + await services.blockFriend(authorID: friendAuthorID) + }) } } } diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -78,4 +78,36 @@ <attribute name="id" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> </entity> + <entity name="FriendEntity" representedClassName="FriendEntity" syncable="YES" codeGenerationType="class"> + <attribute name="authorID" attributeType="String"/> + <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="databaseScope" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="displayName" optional="YES" attributeType="String" defaultValueString=""/> + <attribute name="friendZoneName" attributeType="String"/> + <attribute name="friendZoneOwnerName" attributeType="String"/> + <attribute name="isBlocked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="pairKey" attributeType="String"/> + <fetchIndex name="byPairKey"> + <fetchIndexElement property="pairKey" type="Binary" order="ascending"/> + </fetchIndex> + <fetchIndex name="byAuthorID"> + <fetchIndexElement property="authorID" type="Binary" order="ascending"/> + </fetchIndex> + </entity> + <entity name="InviteEntity" representedClassName="InviteEntity" syncable="YES" codeGenerationType="class"> + <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="gameID" attributeType="UUID" usesScalarValueType="NO"/> + <attribute name="gameTitle" optional="YES" attributeType="String" defaultValueString=""/> + <attribute name="inviterAuthorID" attributeType="String"/> + <attribute name="inviterName" optional="YES" attributeType="String" defaultValueString=""/> + <attribute name="pingRecordName" attributeType="String"/> + <attribute name="shareURL" attributeType="String"/> + <attribute name="status" attributeType="String" defaultValueString="pending"/> + <fetchIndex name="byPingRecordName"> + <fetchIndexElement property="pingRecordName" type="Binary" order="ascending"/> + </fetchIndex> + <fetchIndex name="byStatus"> + <fetchIndexElement property="status" type="Binary" order="ascending"/> + </fetchIndex> + </entity> </model> diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -534,6 +534,15 @@ final class GameStore { for entity in (try? context.fetch(NSFetchRequest<SyncStateEntity>(entityName: "SyncStateEntity"))) ?? [] { context.delete(entity) } + // Friend zones themselves are removed by CloudService.resetAllData's + // wholesale private-zone delete / shared-zone leave; clear the local + // friendship + invite rows so a reset is a clean slate. + for entity in (try? context.fetch(NSFetchRequest<FriendEntity>(entityName: "FriendEntity"))) ?? [] { + context.delete(entity) + } + for entity in (try? context.fetch(NSFetchRequest<InviteEntity>(entityName: "InviteEntity"))) ?? [] { + context.delete(entity) + } try? context.save() currentGame = nil currentMutator = nil diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -35,6 +35,7 @@ final class AppServices { let playerSelectionPublisher: PlayerSelectionPublisher let identity: AuthorIdentity let shareController: ShareController + let friendController: FriendController let colorStore: GamePlayerColorStore let cursorStore: GameCursorStore let cloudService: CloudService @@ -182,6 +183,12 @@ final class AppServices { await syncEngine.enqueuePlayerRecord(gameID: gameID, authorID: authorID) } ) + self.friendController = FriendController( + container: self.ckContainer, + persistence: persistence, + syncEngine: syncEngine, + syncMonitor: self.syncMonitor + ) self.cloudService = CloudService( container: self.ckContainer, syncEngine: syncEngine, @@ -237,6 +244,13 @@ final class AppServices { } } + // Friendship bootstrap keys off the *first* sight of a collaborator's + // Player record (their identity) — fires once per new collaborator, + // not on moves and not on their later name / cursor updates. + await syncEngine.setOnRemotePlayersUpdated { [weak self] gameIDs in + await self?.reconcileFriendships(forGameIDs: gameIDs) + } + await syncEngine.setOnPings { [weak self] pings in guard let self else { return } await self.presentPings(pings) @@ -876,13 +890,217 @@ final class AppServices { return result } + /// Re-invites an existing friend to a game: adds them as a participant on + /// the game's `CKShare` and writes an `.invite` Ping into the friend zone. + /// Surfaced to the UI via the `\.inviteFriend` environment closure. + func inviteFriend(gameID: UUID, friendAuthorID: String) async throws { + guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { + throw FriendController.FriendError.friendNotFound + } + let ctx = persistence.viewContext + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + let title = (try? ctx.fetch(req).first)?.title ?? "" + + let url = try await shareController.addFriendParticipant( + toGameID: gameID, + userRecordName: friendAuthorID + ) + try await friendController.sendInvite( + toFriendAuthorID: friendAuthorID, + gameID: gameID, + gameTitle: title, + inviterAuthorID: localAuthorID, + inviterName: preferences.name, + gameShareURL: url + ) + } + + /// For each collaborative game with newly-known remote authors, asks the + /// `FriendController` to bootstrap a friendship. `establishIfOwner` is a + /// no-op for the non-owner and for already-established pairs (a defensive + /// backstop — the caller already fires this only on a Player record's + /// first sighting, so it runs about once per new collaborator). + private func reconcileFriendships(forGameIDs gameIDs: Set<UUID>) async { + guard preferences.isICloudSyncEnabled, + let localAuthorID = identity.currentID, + !localAuthorID.isEmpty + else { return } + + let ctx = persistence.container.newBackgroundContext() + let candidates: [(gameID: UUID, remoteAuthorID: String)] = ctx.performAndWait { + var result: [(UUID, String)] = [] + for gameID in gameIDs { + let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + gReq.fetchLimit = 1 + guard let game = try? ctx.fetch(gReq).first else { continue } + // Only collaborative games carry other authors. + guard game.databaseScope == 1 || game.ckShareRecordName != nil else { continue } + + // Identity comes only from Player records — this feature is + // deliberately uninterested in Moves (the bootstrap trigger is + // the first sighting of a remote Player record). + var authorIDs = Set<String>() + let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + pReq.predicate = NSPredicate(format: "game == %@", game) + for p in (try? ctx.fetch(pReq)) ?? [] { + if let a = p.authorID { authorIDs.insert(a) } + } + authorIDs.subtract([localAuthorID, CKCurrentUserDefaultName, ""]) + for a in authorIDs { result.append((gameID, a)) } + } + return result + } + + for (gameID, remoteAuthorID) in candidates { + await friendController.establishIfOwner( + localAuthorID: localAuthorID, + remoteAuthorID: remoteAuthorID, + viaGameID: gameID + ) + } + } + + /// Upserts a durable `InviteEntity` for each inbound `.invite` Ping so the + /// Game List's "Invited" section survives the Ping being GC'd. Skips + /// self-authored invites, invites from blocked friends, games already + /// joined, and any `pingRecordName` already seen (a declined row is a + /// tombstone that prevents resurrection). + private func applyInvitePings(_ pings: [Ping]) { + let invites = pings.filter { + $0.kind == .invite && $0.authorID != identity.currentID + } + guard !invites.isEmpty else { return } + + let ctx = persistence.container.newBackgroundContext() + ctx.performAndWait { + for ping in invites { + guard let payload = FriendZone.InvitePayload.decode(ping.payload) else { continue } + + let blockedReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + blockedReq.predicate = NSPredicate( + format: "authorID == %@ AND isBlocked == YES", ping.authorID + ) + blockedReq.fetchLimit = 1 + if ((try? ctx.count(for: blockedReq)) ?? 0) > 0 { continue } + + let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameReq.predicate = NSPredicate(format: "id == %@", ping.gameID as CVarArg) + gameReq.fetchLimit = 1 + if ((try? ctx.count(for: gameReq)) ?? 0) > 0 { continue } + + let dupReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + dupReq.predicate = NSPredicate(format: "pingRecordName == %@", ping.recordName) + dupReq.fetchLimit = 1 + if ((try? ctx.count(for: dupReq)) ?? 0) > 0 { continue } + + let invite = InviteEntity(context: ctx) + invite.gameID = ping.gameID + invite.gameTitle = ping.puzzleTitle + invite.inviterAuthorID = ping.authorID + invite.inviterName = ping.playerName + invite.shareURL = payload.gameShareURL + invite.pingRecordName = ping.recordName + invite.status = "pending" + invite.createdAt = Date() + } + + // GC: a pending invite whose game now exists locally was joined by + // some other path (a link, or accepted on another device), so the + // "Invited" row is stale — drop it. + let pendingReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + pendingReq.predicate = NSPredicate(format: "status == %@", "pending") + for invite in (try? ctx.fetch(pendingReq)) ?? [] { + guard let gid = invite.gameID else { continue } + let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gReq.predicate = NSPredicate(format: "id == %@", gid as CVarArg) + gReq.fetchLimit = 1 + if ((try? ctx.count(for: gReq)) ?? 0) > 0 { ctx.delete(invite) } + } + + if ctx.hasChanges { try? ctx.save() } + } + } + + /// Accepts a pending game invite: fetches the share metadata, joins via + /// the existing share-accept path, then drops the local `InviteEntity` + /// (the game now represents it). Surfaced via `\.acceptInvite`. + func acceptInvite(shareURL: String, pingRecordName: String) async throws { + guard let url = URL(string: shareURL) else { + throw FriendController.FriendError.missingShareURLInPayload + } + try await cloudService.acceptShare(url: url) + let ctx = persistence.viewContext + let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + req.predicate = NSPredicate(format: "pingRecordName == %@", pingRecordName) + for invite in (try? ctx.fetch(req)) ?? [] { ctx.delete(invite) } + if ctx.hasChanges { try? ctx.save() } + } + + /// Blocks a collaborator: marks the friendship blocked and tears down the + /// friend zone, leaves every game they currently share with us, and drops + /// their pending invites. Games we *own* that they joined are untouched. + /// Surfaced via `\.blockFriend`. + func blockFriend(authorID: String) async { + await friendController.blockAndTeardown(friendAuthorID: authorID) + + let ctx = persistence.container.newBackgroundContext() + let gameIDsToLeave: [UUID] = ctx.performAndWait { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "databaseScope == 1") + var ids: [UUID] = [] + for game in (try? ctx.fetch(req)) ?? [] { + guard let gid = game.id else { continue } + var authors = Set<String>() + if let owner = game.ckZoneOwnerName { authors.insert(owner) } + let mReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + mReq.predicate = NSPredicate(format: "game == %@", game) + for m in (try? ctx.fetch(mReq)) ?? [] { + if let a = m.authorID { authors.insert(a) } + } + let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + pReq.predicate = NSPredicate(format: "game == %@", game) + for p in (try? ctx.fetch(pReq)) ?? [] { + if let a = p.authorID { authors.insert(a) } + } + if authors.contains(authorID) { ids.append(gid) } + } + return ids + } + for gid in gameIDsToLeave { + try? await shareController.leaveShare(gameID: gid) + } + + // Drop their pending invites. `applyInvitePings` skips blocked + // senders, so these won't be recreated by a re-fetched Ping. + let vctx = persistence.viewContext + let iReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + iReq.predicate = NSPredicate(format: "inviterAuthorID == %@", authorID) + for invite in (try? vctx.fetch(iReq)) ?? [] { vctx.delete(invite) } + if vctx.hasChanges { try? vctx.save() } + } + private func presentPings(_ pings: [Ping]) async { guard !pings.isEmpty else { return } - // `.opened` is system-only (notification dismissal, badge agreement). - // It runs without authorization since it doesn't show alerts. - let (openedPings, playerFacingPings) = pings.partitioned { $0.kind == .opened } - for ping in openedPings { - await applyOpenedPing(ping) + applyInvitePings(pings) + // `.opened` and `.friend` are system-only — no alert, so they run + // without notification authorization. `.opened` reconciles + // cross-device notification dismissal / badge; `.friend` is the + // friendship-bootstrap handshake. + let (systemPings, playerFacingPings) = pings.partitioned { + $0.kind == .opened || $0.kind == .friend + } + for ping in systemPings { + switch ping.kind { + case .opened: + await applyOpenedPing(ping) + case .friend: + await friendController.applyFriendPing(ping) + default: + break + } } guard !playerFacingPings.isEmpty else { return } guard await canPresentNotifications() else { @@ -1025,11 +1243,14 @@ final class AppServices { case .word: return "\(player) revealed a word in \(puzzleSuffix)" case .puzzle, .none: return "\(player) revealed \(puzzleSuffix)" } - case .opened: - // `.opened` pings are system-only — handled by `applyOpenedPing`, - // never presented as a notification. If this text ever surfaces - // in a log or alert, the dispatch in `presentPings` has broken. - return "opened ping should not be in shared zone" + case .opened, .friend: + // System-only kinds — `.opened` is handled by `applyOpenedPing` + // and `.friend` by the friendship-bootstrap path; neither is ever + // presented as a notification. If this text surfaces in a log or + // alert, the dispatch in `presentPings` has broken. + return "system-only ping should not be presented" + case .invite: + return "\(player) invited you to \(puzzleSuffix)" } } diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift @@ -29,6 +29,35 @@ final class CloudService { self.store = store } + /// Fetches share metadata for a URL and joins via `acceptShare(metadata:)`. + /// Used by the "Invited" section, where the share URL arrived in an + /// `.invite` Ping rather than from the OS share-accept handler. + func acceptShare(url: URL) async throws { + let metadata = try await withCheckedThrowingContinuation { + (cont: CheckedContinuation<CKShare.Metadata, Error>) in + var found: CKShare.Metadata? + let op = CKFetchShareMetadataOperation(shareURLs: [url]) + op.shouldFetchRootRecord = false + op.perShareMetadataResultBlock = { _, result in + if case .success(let m) = result { found = m } + } + op.fetchShareMetadataResultBlock = { result in + switch result { + case .success: + if let found { + cont.resume(returning: found) + } else { + cont.resume(throwing: CKError(.unknownItem)) + } + case .failure(let error): + cont.resume(throwing: error) + } + } + ckContainer.add(op) + } + await acceptShare(metadata: metadata) + } + func acceptShare(metadata: CKShare.Metadata) async { NotificationCenter.default.post(name: .cloudShareAcceptanceStarted, object: nil) diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -0,0 +1,362 @@ +import CloudKit +import CoreData +import Foundation + +/// Sibling of `ShareController`, but for *friend* zones rather than game +/// zones. A friendship is a durable, pairwise channel: one custom zone +/// (`friend-<pairKey>`) carrying a zone-wide `CKShare` with the other user +/// added as a `.readWrite` participant. The zone is created in the elected +/// owner's private database and accepted into the other user's shared +/// database; thereafter either side can write `.invite` `Ping`s into it. +/// +/// Bootstrap rides the *game* zone the two users already share: the owner +/// enqueues a `.friend` `Ping` whose `payload` carries the friend-zone share +/// URL. The other device applies it (`applyFriendPing`) and accepts the +/// share without any out-of-band link. +@MainActor +final class FriendController { + let container: CKContainer + private let persistence: PersistenceController + private let syncEngine: SyncEngine + private let syncMonitor: SyncMonitor? + + init( + container: CKContainer, + persistence: PersistenceController, + syncEngine: SyncEngine, + syncMonitor: SyncMonitor? = nil + ) { + self.container = container + self.persistence = persistence + self.syncEngine = syncEngine + self.syncMonitor = syncMonitor + } + + enum FriendError: Error { + case invalidShareRecord + case missingShareURL + case participantNotFound + case missingShareURLInPayload + case friendNotFound + case friendBlocked + case payloadEncodingFailed + } + + // MARK: - Owner side + + /// Creates the friendship if this device is the elected owner and no + /// friendship for the pair exists yet. No-ops for the non-owner (it + /// waits for the `.friend` Ping) and for an already-established pair. + /// `viaGameID` is the shared game whose zone carries the bootstrap Ping. + func establishIfOwner( + localAuthorID: String, + remoteAuthorID: String, + viaGameID: UUID + ) async { + guard !remoteAuthorID.isEmpty, + localAuthorID != remoteAuthorID, + FriendZone.isOwner(localAuthorID: localAuthorID, remoteAuthorID: remoteAuthorID) + else { return } + + let pairKey = FriendZone.pairKey(localAuthorID, remoteAuthorID) + if friendExists(pairKey: pairKey) { return } + + syncMonitor?.recordStart("establish friendship") + do { + let zoneName = FriendZone.zoneName(pairKey: pairKey) + let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName) + + try await createZone(zoneID) + let share = try await saveZoneWideShare(zoneID: zoneID, addingParticipant: remoteAuthorID) + guard let url = share.url else { throw FriendError.missingShareURL } + + persistFriend( + authorID: remoteAuthorID, + pairKey: pairKey, + zoneName: zoneName, + zoneOwnerName: CKCurrentUserDefaultName, + databaseScope: 0 + ) + + let payload = FriendZone.BootstrapPayload( + friendShareURL: url.absoluteString, + pairKey: pairKey, + ownerAuthorID: localAuthorID + ) + await syncEngine.enqueuePing( + kind: .friend, + scope: nil, + gameID: viaGameID, + authorID: localAuthorID, + playerName: "", + payload: payload.encodedString() + ) + syncMonitor?.recordSuccess("establish friendship") + } catch { + syncMonitor?.recordError("establish friendship", error) + } + } + + // MARK: - Participant side + + /// Handles an inbound `.friend` Ping: accepts the friend-zone share and + /// records the friendship. Idempotent — a duplicate Ping for an + /// already-established pair is dropped. + func applyFriendPing(_ ping: Ping) async { + guard ping.kind == .friend, + let payload = FriendZone.BootstrapPayload.decode(ping.payload) + else { return } + if friendExists(pairKey: payload.pairKey) { return } + guard let url = URL(string: payload.friendShareURL) else { return } + + syncMonitor?.recordStart("accept friendship") + do { + let metadata = try await fetchShareMetadata(url: url) + try await accept(metadata) + let zoneID = metadata.share.recordID.zoneID + persistFriend( + authorID: payload.ownerAuthorID, + pairKey: payload.pairKey, + zoneName: zoneID.zoneName, + zoneOwnerName: zoneID.ownerName, + databaseScope: 1 + ) + await syncMonitor?.run("friendship accept fetch") { + try await self.syncEngine.fetchChanges() + } + syncMonitor?.recordSuccess("accept friendship") + } catch { + syncMonitor?.recordError("accept friendship", error) + } + } + + // MARK: - Re-invite + + /// Writes an `.invite` Ping carrying the game's share URL into the friend + /// zone. The friend must already be added as a participant on the game's + /// `CKShare` (the caller does that via `ShareController` and passes the + /// resulting URL in). No-ops for an unknown or blocked friend. + func sendInvite( + toFriendAuthorID friendAuthorID: String, + gameID: UUID, + gameTitle: String, + inviterAuthorID: String, + inviterName: String, + gameShareURL: URL + ) async throws { + let ctx = persistence.viewContext + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID) + req.fetchLimit = 1 + guard let friend = try ctx.fetch(req).first else { + throw FriendError.friendNotFound + } + guard !friend.isBlocked else { throw FriendError.friendBlocked } + guard let zoneName = friend.friendZoneName, + let ownerName = friend.friendZoneOwnerName + else { throw FriendError.friendNotFound } + + let payload = FriendZone.InvitePayload(gameShareURL: gameShareURL.absoluteString) + guard let encoded = payload.encodedString() else { + throw FriendError.payloadEncodingFailed + } + + await syncEngine.enqueueFriendInvitePing( + gameID: gameID, + gameTitle: gameTitle, + authorID: inviterAuthorID, + playerName: inviterName, + friendZoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), + friendZoneScope: friend.databaseScope, + payload: encoded + ) + } + + // MARK: - Block + + /// Marks the friend blocked and tears down the channel so nothing further + /// can arrive from them: the owner deletes the friend zone outright; a + /// participant leaves by deleting the zone-wide share. The `FriendEntity` + /// row is kept (as a blocked tombstone) so future `.invite` Pings are + /// suppressed and the zone stays out of `knownZones`. + func blockAndTeardown(friendAuthorID: String) async { + let ctx = persistence.viewContext + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID) + req.fetchLimit = 1 + guard let friend = try? ctx.fetch(req).first else { return } + let scope = friend.databaseScope + let zoneName = friend.friendZoneName + let ownerName = friend.friendZoneOwnerName + friend.isBlocked = true + try? ctx.save() + + guard let zoneName, let ownerName else { return } + let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName) + syncMonitor?.recordStart("block friend") + do { + if scope == 0 { + // We own the friend zone — deleting it revokes the share for + // both sides. + try await deleteZone(zoneID, in: container.privateCloudDatabase) + } else { + // Participant — leave by deleting the zone-wide share record. + let shareID = CKRecord.ID( + recordName: CKRecordNameZoneWideShare, + zoneID: zoneID + ) + do { + try await container.sharedCloudDatabase.deleteRecord(withID: shareID) + } catch let error as CKError + where error.code == .unknownItem || error.code == .zoneNotFound { + // Already gone — nothing to do. + } + } + syncMonitor?.recordSuccess("block friend") + } catch { + syncMonitor?.recordError("block friend", error) + } + } + + // MARK: - CloudKit helpers + + private func deleteZone( + _ zoneID: CKRecordZone.ID, + in database: CKDatabase + ) async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in + let op = CKModifyRecordZonesOperation( + recordZonesToSave: nil, + recordZoneIDsToDelete: [zoneID] + ) + op.qualityOfService = .userInitiated + op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) } + database.add(op) + } + } + + private func createZone(_ zoneID: CKRecordZone.ID) async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in + let op = CKModifyRecordZonesOperation( + recordZonesToSave: [CKRecordZone(zoneID: zoneID)], + recordZoneIDsToDelete: nil + ) + op.qualityOfService = .userInitiated + op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) } + self.container.privateCloudDatabase.add(op) + } + } + + private func saveZoneWideShare( + zoneID: CKRecordZone.ID, + addingParticipant remoteAuthorID: String + ) async throws -> CKShare { + let share = CKShare(recordZoneID: zoneID) + share.publicPermission = .none + let participant = try await fetchParticipant(forUserRecordName: remoteAuthorID) + participant.permission = .readWrite + share.addParticipant(participant) + let saved = try await container.privateCloudDatabase.save(share) + guard let savedShare = saved as? CKShare else { + throw FriendError.invalidShareRecord + } + return savedShare + } + + private func fetchParticipant( + forUserRecordName recordName: String + ) async throws -> CKShare.Participant { + let lookup = CKUserIdentity.LookupInfo( + userRecordID: CKRecord.ID(recordName: recordName) + ) + return try await withCheckedThrowingContinuation { cont in + var found: CKShare.Participant? + let op = CKFetchShareParticipantsOperation(userIdentityLookupInfos: [lookup]) + op.perShareParticipantResultBlock = { _, result in + if case .success(let participant) = result { found = participant } + } + op.fetchShareParticipantsResultBlock = { result in + switch result { + case .success: + if let found { + cont.resume(returning: found) + } else { + cont.resume(throwing: FriendError.participantNotFound) + } + case .failure(let error): + cont.resume(throwing: error) + } + } + self.container.add(op) + } + } + + private func fetchShareMetadata(url: URL) async throws -> CKShare.Metadata { + try await withCheckedThrowingContinuation { cont in + var metadata: CKShare.Metadata? + let op = CKFetchShareMetadataOperation(shareURLs: [url]) + op.shouldFetchRootRecord = false + op.perShareMetadataResultBlock = { _, result in + if case .success(let m) = result { metadata = m } + } + op.fetchShareMetadataResultBlock = { result in + switch result { + case .success: + if let metadata { + cont.resume(returning: metadata) + } else { + cont.resume(throwing: FriendError.invalidShareRecord) + } + case .failure(let error): + cont.resume(throwing: error) + } + } + self.container.add(op) + } + } + + private func accept(_ metadata: CKShare.Metadata) async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in + let op = CKAcceptSharesOperation(shareMetadatas: [metadata]) + op.acceptSharesResultBlock = { result in cont.resume(with: result) } + self.container.add(op) + } + } + + // MARK: - Core Data + + /// True if *any* `FriendEntity` for the pair exists — including a blocked + /// one. This is deliberate for v1: a blocked friendship is permanent, so + /// `establishIfOwner` / `applyFriendPing` short-circuit here and never + /// re-bootstrap a channel the user has torn down. Re-friending a blocked + /// collaborator is intentionally out of scope; lifting it later means + /// adding an explicit unblock path, not changing this guard. + private func friendExists(pairKey: String) -> Bool { + let ctx = persistence.viewContext + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + req.predicate = NSPredicate(format: "pairKey == %@", pairKey) + req.fetchLimit = 1 + return ((try? ctx.count(for: req)) ?? 0) > 0 + } + + private func persistFriend( + authorID: String, + pairKey: String, + zoneName: String, + zoneOwnerName: String, + databaseScope: Int16 + ) { + let ctx = persistence.viewContext + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + req.predicate = NSPredicate(format: "pairKey == %@", pairKey) + req.fetchLimit = 1 + let entity = (try? ctx.fetch(req).first) ?? FriendEntity(context: ctx) + entity.authorID = authorID + entity.pairKey = pairKey + entity.friendZoneName = zoneName + entity.friendZoneOwnerName = zoneOwnerName + entity.databaseScope = databaseScope + if entity.createdAt == nil { entity.createdAt = Date() } + try? ctx.save() + } +} diff --git a/Crossmate/Sync/FriendZone.swift b/Crossmate/Sync/FriendZone.swift @@ -0,0 +1,80 @@ +import CryptoKit +import Foundation + +/// Pure helpers for the friendship channel. A friendship is realised as one +/// custom CloudKit zone (`friend-<pairKey>`) carrying a zone-wide `CKShare` +/// with the other user added as a `.readWrite` participant. The zone lives in +/// the *owner's* private database and appears in the *participant's* shared +/// database; both sides can write `Ping` records into it. +/// +/// Everything here is deterministic and side-effect-free so both devices +/// derive the same zone name and elect the same owner without coordination. +/// The CloudKit lifecycle lives in `FriendController`. +enum FriendZone { + /// Zone-name prefix. The shared-DB sync paths branch on this to keep a + /// friend zone from being mistaken for a `game-<UUID>` zone. + static let zonePrefix = "friend-" + + /// Stable, symmetric key for the unordered pair of iCloud user record + /// names. `pairKey(a, b) == pairKey(b, a)` and the same inputs always + /// produce the same bounded-length string, so both devices independently + /// derive the same friend-zone name. + static func pairKey(_ a: String, _ b: String) -> String { + let joined = [a, b].sorted().joined(separator: "\u{1}") + let digest = SHA256.hash(data: Data(joined.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } + + /// Friend-zone name for a pair key. Valid CKRecordZone name: prefix plus + /// 64 lowercase hex characters. + static func zoneName(pairKey: String) -> String { + "\(zonePrefix)\(pairKey)" + } + + static func isFriendZone(_ zoneName: String) -> Bool { + zoneName.hasPrefix(zonePrefix) + } + + /// Owner election: the user whose record name sorts first creates and + /// owns the zone; the other accepts the share. Deterministic on both + /// devices. Equal IDs (same user) can never be friends — returns false. + static func isOwner(localAuthorID: String, remoteAuthorID: String) -> Bool { + localAuthorID != remoteAuthorID && localAuthorID < remoteAuthorID + } + + /// Payload carried in a `.friend` Ping (written into the *game* zone) so + /// the non-owner can accept the friend-zone share without an out-of-band + /// link. + struct BootstrapPayload: Codable, Equatable { + let friendShareURL: String + let pairKey: String + let ownerAuthorID: String + + func encodedString() -> String? { + guard let data = try? JSONEncoder().encode(self) else { return nil } + return String(data: data, encoding: .utf8) + } + + static func decode(_ raw: String?) -> BootstrapPayload? { + guard let raw, let data = raw.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(BootstrapPayload.self, from: data) + } + } + + /// Payload carried in an `.invite` Ping (written into the *friend* zone) + /// so the recipient can accept the game's `CKShare` from the "Invited" + /// section without an out-of-band link. + struct InvitePayload: Codable, Equatable { + let gameShareURL: String + + func encodedString() -> String? { + guard let data = try? JSONEncoder().encode(self) else { return nil } + return String(data: data, encoding: .utf8) + } + + static func decode(_ raw: String?) -> InvitePayload? { + guard let raw, let data = raw.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(InvitePayload.self, from: data) + } + } +} diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -163,6 +163,7 @@ enum RecordSerializer { eventTimestampMs: Int64, kind: PingKind, scope: PingScope?, + payload: String? = nil, zone: CKRecordZone.ID ) -> CKRecord { let name = recordName( @@ -181,6 +182,9 @@ enum RecordSerializer { if let scope { record["scope"] = scope.rawValue as CKRecordValue } + if let payload { + record["payload"] = payload as CKRecordValue + } return record } diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift @@ -74,6 +74,111 @@ final class ShareController { } } + /// Ensures the game's `CKShare` exists and adds `userRecordName` as a + /// `.readWrite` participant, without changing an existing share's public + /// permission (a brand-new share is created private, `.none`). Returns the + /// share URL so the caller can hand it to the friend via an `.invite` + /// Ping. Idempotent: re-inviting an already-added participant is a no-op + /// re-save. + func addFriendParticipant( + toGameID gameID: UUID, + userRecordName: String + ) async throws -> URL { + syncMonitor?.recordStart("invite friend to game") + do { + let share = try await prepareShareRecord( + for: gameID, + publicPermission: .none, + reconfigureExistingPublicPermission: false + ) + try await addParticipantIfNeeded(userRecordName, to: share) + let saved: CKShare + do { + saved = try await saveShareForLink(share, for: gameID) + } catch let error as CKError where error.code == .serverRecordChanged { + saved = try await recoverFriendShareAfterConflict( + error, + gameID: gameID, + userRecordName: userRecordName + ) + } + let url = try shareURL(from: saved) + syncMonitor?.recordSuccess("invite friend to game") + return url + } catch { + syncMonitor?.recordError("invite friend to game", error) + throw error + } + } + + private func addParticipantIfNeeded( + _ userRecordName: String, + to share: CKShare + ) async throws { + let already = share.participants.contains { + $0.userIdentity.userRecordID?.recordName == userRecordName + } + guard !already else { return } + let participant = try await fetchParticipant(forUserRecordName: userRecordName) + participant.permission = .readWrite + share.addParticipant(participant) + } + + private func fetchParticipant( + forUserRecordName recordName: String + ) async throws -> CKShare.Participant { + let lookup = CKUserIdentity.LookupInfo( + userRecordID: CKRecord.ID(recordName: recordName) + ) + return try await withCheckedThrowingContinuation { cont in + var found: CKShare.Participant? + let op = CKFetchShareParticipantsOperation(userIdentityLookupInfos: [lookup]) + op.perShareParticipantResultBlock = { _, result in + if case .success(let participant) = result { found = participant } + } + op.fetchShareParticipantsResultBlock = { result in + switch result { + case .success: + if let found { + cont.resume(returning: found) + } else { + cont.resume(throwing: ShareError.invalidShareRecord) + } + case .failure(let error): + cont.resume(throwing: error) + } + } + self.container.add(op) + } + } + + private func recoverFriendShareAfterConflict( + _ error: CKError, + gameID: UUID, + userRecordName: String + ) async throws -> CKShare { + let ctx = persistence.viewContext + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + request.fetchLimit = 1 + guard let entity = try ctx.fetch(request).first else { + throw ShareError.gameNotFound + } + let share: CKShare + if let serverShare = (error as NSError).userInfo[CKRecordChangedErrorServerRecordKey] as? CKShare { + share = serverShare + } else { + share = try await fetchExistingShare( + recordName: Self.zoneWideShareRecordName, + zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)" + ) + } + // Preserve whatever public permission the server share has. + configureShare(share, title: entity.title, publicPermission: nil) + try await addParticipantIfNeeded(userRecordName, to: share) + return try await saveShareForLink(share, for: gameID) + } + /// Returns the saved public share URL for a game, if Crossmate already /// knows about its `CKShare`. Stale local share references are cleared so /// the caller can safely offer to create a fresh link. @@ -118,8 +223,14 @@ final class ShareController { private func prepareShareRecord( for gameID: UUID, - publicPermission: CKShare.ParticipantPermission + publicPermission: CKShare.ParticipantPermission, + reconfigureExistingPublicPermission: Bool = true ) async throws -> CKShare { + // For an *existing* share the friend-invite path passes `false`: the + // share keeps whatever public permission it already had (a brand-new + // share is still created with the requested `publicPermission`). + let existingPermission: CKShare.ParticipantPermission? = + reconfigureExistingPublicPermission ? publicPermission : nil let ctx = persistence.viewContext let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) @@ -137,7 +248,7 @@ final class ShareController { recordName: existingName, zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)" ) - return configureShare(existing, title: entity.title, publicPermission: publicPermission) + return configureShare(existing, title: entity.title, publicPermission: existingPermission) } catch let error as CKError where error.code == .unknownItem { entity.ckShareRecordName = nil try ctx.save() @@ -163,7 +274,7 @@ final class ShareController { if let existing = try await fetchZoneWideShareIfPresent(zoneName: zoneName) { entity.ckShareRecordName = existing.recordID.recordName try ctx.save() - return configureShare(existing, title: entity.title, publicPermission: publicPermission) + return configureShare(existing, title: entity.title, publicPermission: existingPermission) } try await ensureGameRecordExists(for: entity, in: zoneID) @@ -255,9 +366,14 @@ final class ShareController { private func configureShare( _ share: CKShare, title: String?, - publicPermission: CKShare.ParticipantPermission + publicPermission: CKShare.ParticipantPermission? ) -> CKShare { - share.publicPermission = publicPermission + // `nil` means "leave the existing public permission untouched" — used + // by the friend-invite path so adding a private participant never + // silently revokes a public link the user created earlier. + if let publicPermission { + share.publicPermission = publicPermission + } share[CKShare.SystemFieldKey.title] = title as CKRecordValue? return share } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -6,6 +6,13 @@ import SwiftUI extension EnvironmentValues { @Entry var syncEngine: SyncEngine? = nil @Entry var resetDatabase: (() async -> Void)? = nil + /// `(gameID, friendAuthorID)` — re-invites an existing friend to a game. + @Entry var inviteFriend: ((UUID, String) async throws -> Void)? = nil + /// `(shareURL, pingRecordName)` — accepts a pending game invite. + @Entry var acceptInvite: ((String, String) async throws -> Void)? = nil + /// `(friendAuthorID)` — blocks a collaborator: suppress future invites, + /// leave their shared games, tear down the friend zone. + @Entry var blockFriend: ((String) async -> Void)? = nil } extension Notification.Name { @@ -26,6 +33,12 @@ enum PingKind: String, Sendable { case check case reveal case opened + /// Friendship bootstrap. Written into a shared *game* zone; carries the + /// friend-zone share URL in `payload`. System-only — never user-facing. + case friend + /// 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 } /// Granularity of a check/reveal action. Stored as a string in the CKRecord's @@ -45,6 +58,9 @@ struct Ping: Sendable { let puzzleTitle: String let kind: PingKind let scope: PingScope? + /// Kind-specific JSON. `.friend`: `{friendShareURL,pairKey,ownerAuthorID}`; + /// `.invite`: `{gameShareURL}`. nil for join/win/check/reveal/opened. + let payload: String? } struct Session: Sendable { @@ -89,6 +105,7 @@ actor SyncEngine { let eventTimestampMs: Int64 let kind: PingKind let scope: PingScope? + let payload: String? } /// Label for the in-flight fetch — surfaced in traces so the diagnostics @@ -101,6 +118,12 @@ actor SyncEngine { private var loggedFirstSharedPushPayload = false private var onRemoteMovesUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)? + /// Fires with the game IDs for which a collaborator's `Player` record was + /// seen for the **first time** (a new `PlayerEntity` was created) — not on + /// their subsequent name / cursor updates. Independent of moves; the + /// friendship bootstrap keys off this so a collaborator becomes a friend + /// once, as soon as their identity syncs, without waiting for a move. + private var onRemotePlayersUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)? private var onPings: (@MainActor @Sendable ([Ping]) async -> Void)? private var onAccountChange: (@MainActor @Sendable () async -> Void)? private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)? @@ -126,6 +149,10 @@ actor SyncEngine { onRemoteMovesUpdated = cb } + func setOnRemotePlayersUpdated(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { + onRemotePlayersUpdated = cb + } + func setOnPings(_ cb: @MainActor @Sendable @escaping ([Ping]) async -> Void) { onPings = cb } @@ -330,7 +357,8 @@ actor SyncEngine { scope: PingScope?, gameID: UUID, authorID: String, - playerName: String + playerName: String, + payload: String? = nil ) { let ctx = persistence.container.newBackgroundContext() let zoneAndTitle: (info: ZoneInfo, title: String)? = ctx.performAndWait { @@ -361,7 +389,8 @@ actor SyncEngine { puzzleTitle: zoneAndTitle.title, eventTimestampMs: eventTimestampMs, kind: kind, - scope: scope + scope: scope, + payload: payload ) let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneAndTitle.info.zoneID) engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) @@ -402,7 +431,8 @@ actor SyncEngine { puzzleTitle: title, eventTimestampMs: eventTimestampMs, kind: .opened, - scope: nil + scope: nil, + payload: nil ) let zoneID = RecordSerializer.accountZoneID // Make sure the zone exists before the record write. CKSyncEngine @@ -413,6 +443,49 @@ actor SyncEngine { Task { try? await engine.sendChanges() } } + /// 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 + /// joined the friend zone (it lives in our shared DB → shared engine); + /// `scope == 0` means we own it (private DB → private engine). The zone + /// already exists by the time an invite is possible, so no `saveZone`. + /// `gameID` is the *invited* game; it rides the record name so the + /// recipient resolves it without reading the game zone. + func enqueueFriendInvitePing( + gameID: UUID, + gameTitle: String, + authorID: String, + playerName: String, + friendZoneID: CKRecordZone.ID, + friendZoneScope: Int16, + payload: String + ) { + let engine = friendZoneScope == 1 ? sharedEngine : privateEngine + guard let engine else { return } + let deviceID = RecordSerializer.localDeviceID + let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000) + let recordName = RecordSerializer.recordName( + forPingInGame: gameID, + authorID: authorID, + deviceID: deviceID, + eventTimestampMs: eventTimestampMs + ) + pendingPings[recordName] = PingPayload( + gameID: gameID, + authorID: authorID, + deviceID: deviceID, + playerName: playerName, + puzzleTitle: gameTitle, + eventTimestampMs: eventTimestampMs, + kind: .invite, + scope: nil, + payload: payload + ) + let recordID = CKRecord.ID(recordName: recordName, zoneID: friendZoneID) + engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) + Task { try? await engine.sendChanges() } + } + /// Deletes transient Ping records for a completed owned game while keeping /// every `.win` ping. Participants see the owner's zone through the share, /// so the owner-side deletion removes the records from the cooperative @@ -678,7 +751,7 @@ actor SyncEngine { database: database, zoneID: zoneID, since: since, - desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "scope"] + desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "scope", "payload"] ) return PerZonePings(records: records, orphanedZone: nil) } catch { @@ -772,7 +845,7 @@ actor SyncEngine { database: database, zoneID: zone.zoneID, since: since, - desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "scope"] + desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "scope", "payload"] ) async let playerRecords = self.queryLiveRecords( type: "Player", @@ -1275,9 +1348,11 @@ actor SyncEngine { let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump let localAuthorID = await currentLocalAuthorID() - let (movesUpdatedGameIDs, affectedGameIDs): (Set<UUID>, Set<UUID>) = ctx.performAndWait { + let (movesUpdatedGameIDs, affectedGameIDs, playersUpdatedGameIDs): + (Set<UUID>, Set<UUID>, Set<UUID>) = ctx.performAndWait { var movesUpdated = Set<UUID>() var affected = Set<UUID>() + var playersUpdated = Set<UUID>() for record in records { switch record.recordType { case "Game": @@ -1296,7 +1371,7 @@ actor SyncEngine { } case "Player": if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) { - self.applyPlayerRecord(record, in: ctx) + self.applyPlayerRecord(record, in: ctx) { playersUpdated.insert($0) } affected.insert(gameID) } default: @@ -1328,12 +1403,15 @@ actor SyncEngine { ) } } - return (movesUpdated, affected) + return (movesUpdated, affected, playersUpdated) } if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty { await onRemoteMovesUpdated(movesUpdatedGameIDs) } + if let onRemotePlayersUpdated, !playersUpdatedGameIDs.isEmpty { + await onRemotePlayersUpdated(playersUpdatedGameIDs) + } if !affectedGameIDs.isEmpty { NotificationCenter.default.post( name: .playerRosterShouldRefresh, @@ -1631,6 +1709,29 @@ actor SyncEngine { result.append((zoneID, Date(timeIntervalSince1970: 0))) } } + // Friend zones carry `.invite` / `.friend` pings but no + // GameEntity, so — like the account zone — they're appended + // explicitly. The owner sees the zone in the private DB + // (scope 0); the participant sees it in the shared DB + // (scope 1). Blocked friends are skipped so we stop reading + // anything from them. Floor is `.distantPast`: any unseen + // invite should be processed. + let friendReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + friendReq.predicate = NSPredicate( + format: "databaseScope == %d AND isBlocked == NO", + scope + ) + for friend in (try? ctx.fetch(friendReq)) ?? [] { + guard let zoneName = friend.friendZoneName, + let ownerName = friend.friendZoneOwnerName + else { continue } + let key = "\(ownerName)|\(zoneName)" + guard seen.insert(key).inserted else { continue } + result.append(( + CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), + Date(timeIntervalSince1970: 0) + )) + } return result } } @@ -1722,6 +1823,7 @@ actor SyncEngine { eventTimestampMs: payload.eventTimestampMs, kind: payload.kind, scope: payload.scope, + payload: payload.payload, zone: zoneID ) } @@ -1799,9 +1901,22 @@ actor SyncEngine { // MARK: - Incoming record application + /// Applies a remote `Player` record. When this is the **first** time we've + /// seen this player (a new `PlayerEntity` row is created) — the single + /// point at which a collaborator's identity becomes known, and the trigger + /// for friendship bootstrap — `onFirstTime` is invoked with the game ID + /// just before the function returns. It is *not* called on the player's + /// later name / cursor updates. + /// + /// `onFirstTime` only records *that* the sighting happened; the actual + /// bootstrap runs later, from the post-save `onRemotePlayersUpdated` + /// callback, because the freshly created `PlayerEntity` is not visible to + /// that work until this background context has been saved and merged into + /// the view context. private nonisolated func applyPlayerRecord( _ record: CKRecord, - in ctx: NSManagedObjectContext + in ctx: NSManagedObjectContext, + onFirstTime: (UUID) -> Void ) { let ckName = record.recordID.recordName guard let (gameID, authorID) = RecordSerializer.parsePlayerRecordName(ckName) else { @@ -1870,6 +1985,9 @@ actor SyncEngine { entity.selCol = nil entity.selDir = nil } + if !foundExisting { + onFirstTime(gameID) + } } /// Merges every device's `MovesEntity` row for `gameID` and reconciles the @@ -2043,10 +2161,12 @@ actor SyncEngine { let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump let localAuthorID = await currentLocalAuthorID() - let (movesUpdatedGameIDs, affectedGameIDs, pings): (Set<UUID>, Set<UUID>, [Ping]) = ctx.performAndWait { + let (movesUpdatedGameIDs, affectedGameIDs, pings, playersUpdatedGameIDs): + (Set<UUID>, Set<UUID>, [Ping], Set<UUID>) = ctx.performAndWait { var movesUpdated = Set<UUID>() var affected = Set<UUID>() var pings: [Ping] = [] + var playersUpdated = Set<UUID>() for mod in event.modifications { let record = mod.record switch record.recordType { @@ -2066,7 +2186,7 @@ actor SyncEngine { } case "Player": if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) { - self.applyPlayerRecord(record, in: ctx) + self.applyPlayerRecord(record, in: ctx) { playersUpdated.insert($0) } affected.insert(gameID) } case "Ping": @@ -2107,12 +2227,15 @@ actor SyncEngine { ) } } - return (movesUpdated, affected, pings) + return (movesUpdated, affected, pings, playersUpdated) } if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty { await onRemoteMovesUpdated(movesUpdatedGameIDs) } + if let onRemotePlayersUpdated, !playersUpdatedGameIDs.isEmpty { + await onRemotePlayersUpdated(playersUpdatedGameIDs) + } if let onPings, !pings.isEmpty { await onPings(pings) } @@ -2154,7 +2277,8 @@ actor SyncEngine { playerName: (record["playerName"] as? String) ?? "", puzzleTitle: (record["puzzleTitle"] as? String) ?? "", kind: kind, - scope: scope + scope: scope, + payload: record["payload"] as? String ) } diff --git a/Crossmate/Views/FriendPickerView.swift b/Crossmate/Views/FriendPickerView.swift @@ -0,0 +1,93 @@ +import SwiftUI + +/// Lists existing (non-blocked) friends so the user can re-invite one to a +/// game without generating and sending a link. Friends are accumulated +/// automatically the first time you collaborate with someone (see +/// `FriendController`). +struct FriendPickerView: View { + let gameID: UUID + + @Environment(\.inviteFriend) private var inviteFriend + @Environment(\.dismiss) private var dismiss + + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \FriendEntity.createdAt, ascending: true)], + predicate: NSPredicate(format: "isBlocked == NO"), + animation: .default + ) + private var friends: FetchedResults<FriendEntity> + + @State private var invitingAuthorID: String? + @State private var invitedAuthorIDs: Set<String> = [] + @State private var errorMessage: String? + + var body: some View { + List { + if friends.isEmpty { + Section { + Text("You haven't played with anyone yet. Share a game via a link first — once someone joins, they'll appear here for one-tap invites next time.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } else { + Section { + ForEach(friends, id: \.authorID) { friend in + row(for: friend) + } + } footer: { + Text("The friend is added to this game in iCloud and notified. They choose whether to accept from their Invited list.") + } + } + + if let errorMessage { + Section("Error") { + Text(errorMessage) + .font(.caption.monospaced()) + .foregroundStyle(.red) + .textSelection(.enabled) + } + } + } + .navigationTitle("Invite a Friend") + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private func row(for friend: FriendEntity) -> some View { + let authorID = friend.authorID ?? "" + let invited = invitedAuthorIDs.contains(authorID) + Button { + Task { await invite(authorID) } + } label: { + HStack { + Label(displayName(for: friend), systemImage: "person.crop.circle") + Spacer() + if invitingAuthorID == authorID { + ProgressView() + } else if invited { + Image(systemName: "checkmark") + .foregroundStyle(.secondary) + } + } + } + .disabled(authorID.isEmpty || invitingAuthorID != nil || invited) + } + + private func displayName(for friend: FriendEntity) -> String { + if let name = friend.displayName, !name.isEmpty { return name } + return "Player" + } + + private func invite(_ authorID: String) async { + guard !authorID.isEmpty, let inviteFriend else { return } + invitingAuthorID = authorID + errorMessage = nil + defer { invitingAuthorID = nil } + do { + try await inviteFriend(gameID, authorID) + invitedAuthorIDs.insert(authorID) + } catch { + errorMessage = String(describing: error) + } + } +} diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -17,6 +17,25 @@ struct GameListView: View { ) private var games: FetchedResults<GameEntity> + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \InviteEntity.createdAt, ascending: true)], + predicate: NSPredicate(format: "status == %@", "pending"), + animation: .default + ) + private var pendingInvites: FetchedResults<InviteEntity> + + @FetchRequest( + sortDescriptors: [], + predicate: NSPredicate(format: "isBlocked == YES") + ) + private var blockedFriends: FetchedResults<FriendEntity> + + @Environment(\.acceptInvite) private var acceptInvite + @Environment(\.blockFriend) private var blockFriend + @State private var inviteError: String? + @State private var acceptingInviteID: NSManagedObjectID? + @State private var blockTarget: InviteEntity? + @State private var showingNewGame = false @State private var showingSettings = false @State private var deleteTarget: GameSummary? @@ -111,6 +130,31 @@ struct GameListView: View { } } } + .alert("Couldn't Accept Invite", isPresented: .init( + get: { inviteError != nil }, + set: { if !$0 { inviteError = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + if let inviteError { + Text(inviteError) + } + } + .alert("Block This Player?", isPresented: .init( + get: { blockTarget != nil }, + set: { if !$0 { blockTarget = nil } } + )) { + Button("Block", role: .destructive) { + if let target = blockTarget, let authorID = target.inviterAuthorID { + Task { await blockFriend?(authorID) } + } + } + Button("Cancel", role: .cancel) {} + } message: { + let name = (blockTarget?.inviterName?.isEmpty == false) + ? blockTarget!.inviterName! : "this player" + Text("You won't receive further invites from \(name), and any games they currently share with you will be removed from this device.") + } } @ViewBuilder @@ -126,7 +170,23 @@ struct GameListView: View { let visibleCompleted = Array(completed.prefix(visibleCount)) let hasMore = visibleCount < completed.count + let blockedIDs = Set(blockedFriends.compactMap { $0.authorID }) + let visibleInvites = pendingInvites.filter { + guard let inviter = $0.inviterAuthorID else { return true } + return !blockedIDs.contains(inviter) + } + List { + if !visibleInvites.isEmpty { + Section { + ForEach(visibleInvites, id: \.objectID) { invite in + inviteRow(for: invite) + } + } header: { + Text("Invited") + } + } + if !inProgress.isEmpty { Section { ForEach(inProgress) { game in @@ -189,6 +249,53 @@ struct GameListView: View { } @ViewBuilder + private func inviteRow(for invite: InviteEntity) -> some View { + let inviter = (invite.inviterName?.isEmpty == false) ? invite.inviterName! : "A player" + let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle" + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.body.weight(.medium)) + Text("Invited by \(inviter)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if acceptingInviteID == invite.objectID { + ProgressView() + } else { + Button("Accept") { Task { await accept(invite) } } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .swipeActions(edge: .trailing) { + Button("Decline") { decline(invite) } + .tint(.gray) + Button("Block", role: .destructive) { blockTarget = invite } + } + } + + private func accept(_ invite: InviteEntity) async { + guard let acceptInvite, + let url = invite.shareURL, + let ping = invite.pingRecordName + else { return } + acceptingInviteID = invite.objectID + inviteError = nil + defer { acceptingInviteID = nil } + do { + try await acceptInvite(url, ping) + } catch { + inviteError = String(describing: error) + } + } + + private func decline(_ invite: InviteEntity) { + invite.status = "declined" + try? viewContext.save() + } + + @ViewBuilder private func rowView(for game: GameSummary, usesRoomierType: Bool) -> some View { GameRowView( game: game, diff --git a/Crossmate/Views/GameShareItem.swift b/Crossmate/Views/GameShareItem.swift @@ -83,6 +83,16 @@ struct GameShareSheet: View { } } + Section { + NavigationLink { + FriendPickerView(gameID: gameID) + } label: { + Label("Invite a Friend", systemImage: "person.badge.plus") + } + } footer: { + Text("Re-invite someone you've played with before — no link to send.") + } + if let errorMessage { Section("Error") { Text(errorMessage) diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift @@ -25,7 +25,8 @@ struct PuzzleNotificationTextTests { playerName: "Alice", puzzleTitle: "Saturday Puzzle – 1 January 2001", kind: .join, - scope: nil + scope: nil, + payload: nil ) #expect(AppServices.bodyText(for: ping) == "Alice joined the puzzle 'Saturday Puzzle – 1 January 2001'") @@ -41,7 +42,8 @@ struct PuzzleNotificationTextTests { playerName: "Alice", puzzleTitle: "Saturday Puzzle – 1 January 2001", kind: .check, - scope: .puzzle + scope: .puzzle, + payload: nil ) #expect(AppServices.bodyText(for: ping) == "Alice checked all of the puzzle 'Saturday Puzzle – 1 January 2001'") diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -102,6 +102,38 @@ struct RecordSerializerTests { #expect(record["kind"] as? String == "opened") } + @Test("pingRecord writes payload when provided and omits it when nil") + func pingRecordPayloadRoundTrip() { + let zone = CKRecordZone.ID(zoneName: "z", ownerName: CKCurrentUserDefaultName) + let withPayload = RecordSerializer.pingRecord( + gameID: UUID(), + authorID: "alice", + deviceID: "deviceA", + playerName: "Alice", + puzzleTitle: "Puzzle", + eventTimestampMs: 1700000000000, + kind: .invite, + scope: nil, + payload: #"{"gameShareURL":"https://x"}"#, + zone: zone + ) + #expect(withPayload["payload"] as? String == #"{"gameShareURL":"https://x"}"#) + #expect(withPayload["kind"] as? String == "invite") + + let withoutPayload = RecordSerializer.pingRecord( + gameID: UUID(), + authorID: "alice", + deviceID: "deviceA", + playerName: "Alice", + puzzleTitle: "Puzzle", + eventTimestampMs: 1700000000000, + kind: .join, + scope: nil, + zone: zone + ) + #expect(withoutPayload["payload"] == nil) + } + @Test("accountZoneID is named 'account' in the current user's private DB") func accountZoneIDShape() { let zone = RecordSerializer.accountZoneID diff --git a/Tests/Unit/Sync/FriendModelTests.swift b/Tests/Unit/Sync/FriendModelTests.swift @@ -0,0 +1,152 @@ +import CloudKit +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +/// Pins the Core Data shape the friend/invite UI and ingest paths rely on: +/// the new `FriendEntity` / `InviteEntity` types exist with the expected +/// attributes, and the predicates used by `GameListView` and +/// `ingestInvitePings` select the right rows. +@Suite("FriendModel") +@MainActor +struct FriendModelTests { + + @Test("isBlocked predicate filters blocked friends") + func blockedFriendPredicate() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + let active = FriendEntity(context: ctx) + active.authorID = "_active" + active.pairKey = "k1" + active.friendZoneName = "friend-k1" + active.friendZoneOwnerName = CKCurrentUserDefaultName + active.databaseScope = 0 + active.isBlocked = false + active.createdAt = Date() + + let blocked = FriendEntity(context: ctx) + blocked.authorID = "_blocked" + blocked.pairKey = "k2" + blocked.friendZoneName = "friend-k2" + blocked.friendZoneOwnerName = CKCurrentUserDefaultName + blocked.databaseScope = 1 + blocked.isBlocked = true + blocked.createdAt = Date() + try ctx.save() + + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + req.predicate = NSPredicate(format: "isBlocked == NO") + let result = try ctx.fetch(req) + #expect(result.map { $0.authorID } == ["_active"]) + } + + @Test("pending-status predicate and pingRecordName dedup work") + func invitePredicates() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + let pending = InviteEntity(context: ctx) + pending.gameID = UUID() + pending.gameTitle = "Saturday" + pending.inviterAuthorID = "_alice" + pending.inviterName = "Alice" + pending.shareURL = "https://www.icloud.com/share/abc" + pending.pingRecordName = "ping-1" + pending.status = "pending" + pending.createdAt = Date() + + let declined = InviteEntity(context: ctx) + declined.gameID = UUID() + declined.inviterAuthorID = "_bob" + declined.shareURL = "https://www.icloud.com/share/def" + declined.pingRecordName = "ping-2" + declined.status = "declined" + declined.createdAt = Date() + try ctx.save() + + let pendingReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + pendingReq.predicate = NSPredicate(format: "status == %@", "pending") + #expect(try ctx.fetch(pendingReq).map { $0.pingRecordName } == ["ping-1"]) + + // A declined tombstone is still found by the dedup lookup, so a + // re-fetched Ping won't resurrect it. + let dupReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + dupReq.predicate = NSPredicate(format: "pingRecordName == %@", "ping-2") + #expect(try ctx.count(for: dupReq) == 1) + } + + @Test("knownZones predicate excludes a blocked friend zone") + func blockedFriendExcludedFromKnownZones() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + for (suffix, blocked) in [("ok", false), ("no", true)] { + let f = FriendEntity(context: ctx) + f.authorID = "_\(suffix)" + f.pairKey = suffix + f.friendZoneName = "friend-\(suffix)" + f.friendZoneOwnerName = "_owner" + f.databaseScope = 1 + f.isBlocked = blocked + f.createdAt = Date() + } + try ctx.save() + + // Exactly the predicate used by SyncEngine.knownZones. + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + req.predicate = NSPredicate(format: "databaseScope == %d AND isBlocked == NO", 1) + #expect(try ctx.fetch(req).map { $0.friendZoneName } == ["friend-ok"]) + } + + @Test("invites are scoped by inviterAuthorID for block cleanup") + func invitesByInviterPredicate() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + for (i, inviter) in ["_alice", "_alice", "_bob"].enumerated() { + let invite = InviteEntity(context: ctx) + invite.gameID = UUID() + invite.inviterAuthorID = inviter + invite.shareURL = "https://x/\(i)" + invite.pingRecordName = "ping-\(i)" + invite.status = "pending" + invite.createdAt = Date() + } + try ctx.save() + + let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + req.predicate = NSPredicate(format: "inviterAuthorID == %@", "_alice") + #expect(try ctx.count(for: req) == 2) + } + + @Test("a pending invite whose game exists locally is detectable for GC") + func staleInviteDetectableForGC() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + + let invite = InviteEntity(context: ctx) + invite.gameID = gameID + invite.inviterAuthorID = "_alice" + invite.shareURL = "https://x" + invite.pingRecordName = "ping-1" + invite.status = "pending" + invite.createdAt = Date() + + let game = GameEntity(context: ctx) + game.id = gameID + game.title = "Joined" + game.puzzleSource = "" + game.createdAt = Date() + game.updatedAt = Date() + try ctx.save() + + // The exact GC lookup from applyInvitePings. + let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + #expect(try ctx.count(for: gReq) == 1) + } +} diff --git a/Tests/Unit/Sync/FriendZoneTests.swift b/Tests/Unit/Sync/FriendZoneTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("FriendZone") +struct FriendZoneTests { + + @Test("pairKey is symmetric in its arguments") + func pairKeySymmetric() { + let a = "_alice0000000000000000000000000000" + let b = "_bob00000000000000000000000000000000" + #expect(FriendZone.pairKey(a, b) == FriendZone.pairKey(b, a)) + } + + @Test("pairKey is deterministic across calls") + func pairKeyDeterministic() { + let a = "_alice" + let b = "_bob" + #expect(FriendZone.pairKey(a, b) == FriendZone.pairKey(a, b)) + } + + @Test("pairKey distinguishes different pairs") + func pairKeyDistinct() { + #expect(FriendZone.pairKey("_a", "_b") != FriendZone.pairKey("_a", "_c")) + } + + @Test("pairKey is 64 lowercase hex characters") + func pairKeyShape() { + let key = FriendZone.pairKey("_a", "_b") + #expect(key.count == 64) + #expect(key.allSatisfy { "0123456789abcdef".contains($0) }) + } + + @Test("zoneName carries the prefix and is recognised as a friend zone") + func zoneNameRoundTrip() { + let key = FriendZone.pairKey("_a", "_b") + let name = FriendZone.zoneName(pairKey: key) + #expect(name == "friend-\(key)") + #expect(FriendZone.isFriendZone(name)) + #expect(!FriendZone.isFriendZone("game-\(UUID().uuidString)")) + #expect(!FriendZone.isFriendZone("account")) + } + + @Test("owner election is deterministic, asymmetric, and excludes self") + func ownerElection() { + let small = "_aaa" + let large = "_zzz" + #expect(FriendZone.isOwner(localAuthorID: small, remoteAuthorID: large)) + #expect(!FriendZone.isOwner(localAuthorID: large, remoteAuthorID: small)) + // Exactly one side owns. + #expect( + FriendZone.isOwner(localAuthorID: small, remoteAuthorID: large) + != FriendZone.isOwner(localAuthorID: large, remoteAuthorID: small) + ) + // Same user can never be a friend. + #expect(!FriendZone.isOwner(localAuthorID: small, remoteAuthorID: small)) + } + + @Test("BootstrapPayload round-trips through its string encoding") + func bootstrapPayloadRoundTrip() { + let payload = FriendZone.BootstrapPayload( + friendShareURL: "https://www.icloud.com/share/abc#xyz", + pairKey: FriendZone.pairKey("_a", "_b"), + ownerAuthorID: "_a" + ) + let encoded = payload.encodedString() + #expect(encoded != nil) + #expect(FriendZone.BootstrapPayload.decode(encoded) == payload) + } + + @Test("BootstrapPayload.decode tolerates nil and malformed input") + func bootstrapPayloadDecodeTolerant() { + #expect(FriendZone.BootstrapPayload.decode(nil) == nil) + #expect(FriendZone.BootstrapPayload.decode("") == nil) + #expect(FriendZone.BootstrapPayload.decode("not json") == nil) + #expect(FriendZone.BootstrapPayload.decode(#"{"friendShareURL":"x"}"#) == nil) + } + + @Test("InvitePayload round-trips through its string encoding") + func invitePayloadRoundTrip() { + let payload = FriendZone.InvitePayload( + gameShareURL: "https://www.icloud.com/share/xyz#abc" + ) + let encoded = payload.encodedString() + #expect(encoded != nil) + #expect(FriendZone.InvitePayload.decode(encoded) == payload) + } + + @Test("InvitePayload.decode tolerates nil and malformed input") + func invitePayloadDecodeTolerant() { + #expect(FriendZone.InvitePayload.decode(nil) == nil) + #expect(FriendZone.InvitePayload.decode("") == nil) + #expect(FriendZone.InvitePayload.decode("garbage") == nil) + #expect(FriendZone.InvitePayload.decode(#"{"x":1}"#) == nil) + } +}