crossmate

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

commit 47424d7da9685b5e4b53cd8046ec8470764f277d
parent ddcc100e4a8df32fac06d22514404ab2a5834a8d
Author: Michael Camilleri <[email protected]>
Date:   Wed,  1 Jul 2026 17:24:52 +0900

Carry notification credentials through direct invites

Direct friend invites could let the recipient accept and play from the
fat invite before the shared Game record had delivered the owner's
notification credential. That left the recipient able to publish a pause
push under a credential namespace that had no owner devices registered,
so the worker accepted the publish but delivered it to zero targets.

This commit carries the owner's GamePushCredentials in InvitePayload
once the game share has been saved and the game is known to be shared.
The accept path stores that durable invite field and passes it alongside
the prefetched puzzle source, allowing constructJoinedGame to seed the
joined GameEntity and refresh the Notification Service Extension
content-key mirror before the canonical Game record catches up. Older
invites still decode with nil credentials and use the existing
shared-record fallback.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 1+
MCrossmate/Persistence/GameStore.swift | 20++++++++++++++++++--
MCrossmate/Services/AppServices.swift | 1+
MCrossmate/Services/CloudService.swift | 18++++++++++++++----
MCrossmate/Services/InviteCoordinator.swift | 33+++++++++++++++++++++++++++++++--
MCrossmate/Sync/FriendController.swift | 6++++--
MCrossmate/Sync/FriendZone.swift | 13++++++++++++-
MTests/Unit/GameStorePushAddressTests.swift | 38++++++++++++++++++++++++++++++++++++++
MTests/Unit/Sync/FriendZoneTests.swift | 16++++++++++++++++
9 files changed, 135 insertions(+), 11 deletions(-)

diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -120,6 +120,7 @@ <attribute name="gridSilhouette" optional="YES" attributeType="String"/> <attribute name="inviterAuthorID" attributeType="String"/> <attribute name="inviterName" optional="YES" attributeType="String" defaultValueString=""/> + <attribute name="notification" optional="YES" attributeType="String"/> <attribute name="pingRecordName" attributeType="String"/> <attribute name="puzzleSource" optional="YES" attributeType="String"/> <attribute name="shareURL" attributeType="String"/> diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -1199,12 +1199,24 @@ final class GameStore { /// `RecordSerializer.fetchOrCreate`) rather than creating a duplicate. /// No-ops if a row for the game already exists — a sibling device or an /// earlier sync got there first. - func constructJoinedGame(gameID: UUID, zoneID: CKRecordZone.ID, source: String) throws { + func constructJoinedGame( + gameID: UUID, + zoneID: CKRecordZone.ID, + source: String, + notification: String? = nil + ) throws { let recordName = "game-\(gameID.uuidString)" let existing = NSFetchRequest<GameEntity>(entityName: "GameEntity") existing.predicate = NSPredicate(format: "ckRecordName == %@", recordName) existing.fetchLimit = 1 - if ((try? context.count(for: existing)) ?? 0) > 0 { return } + if let existingEntity = try? context.fetch(existing).first { + if existingEntity.notification == nil, let notification { + existingEntity.notification = notification + GameEntity.rebuildContentKeyDirectory(in: context) + try context.save() + } + return + } let xd = try XD.parse(source) let puzzle = Puzzle(xd: xd) @@ -1222,7 +1234,11 @@ final class GameStore { entity.ckZoneOwnerName = zoneID.ownerName == CKCurrentUserDefaultName ? nil : zoneID.ownerName entity.databaseScope = 1 + entity.notification = notification entity.populateCachedSummaryFields(from: puzzle) + if notification != nil { + GameEntity.rebuildContentKeyDirectory(in: context) + } try context.save() } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -299,6 +299,7 @@ final class AppServices { preferences: preferences, syncMonitor: syncMonitor, eventLog: eventLog, + store: store, syncEngine: syncEngine, announcements: announcements, shareController: shareController, diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift @@ -52,7 +52,11 @@ final class CloudService { /// share URL arrived in an `.invite` Ping or a tapped link rather than from /// the OS share-accept handler. @discardableResult - func acceptShare(url: URL, prefetchedPuzzleSource: String? = nil) async throws -> AcceptOutcome { + func acceptShare( + url: URL, + prefetchedPuzzleSource: String? = nil, + prefetchedNotification: String? = nil + ) async throws -> AcceptOutcome { let metadata = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<CKShare.Metadata, Error>) in var found: CKShare.Metadata? @@ -75,13 +79,18 @@ final class CloudService { } ckContainer.add(op) } - return try await acceptShare(metadata: metadata, prefetchedPuzzleSource: prefetchedPuzzleSource) + return try await acceptShare( + metadata: metadata, + prefetchedPuzzleSource: prefetchedPuzzleSource, + prefetchedNotification: prefetchedNotification + ) } @discardableResult func acceptShare( metadata: CKShare.Metadata, - prefetchedPuzzleSource: String? = nil + prefetchedPuzzleSource: String? = nil, + prefetchedNotification: String? = nil ) async throws -> AcceptOutcome { NotificationCenter.default.post(name: .cloudShareAcceptanceStarted, object: nil) @@ -120,7 +129,8 @@ final class CloudService { try store.constructJoinedGame( gameID: sharedGameID, zoneID: sharedZoneID, - source: prefetchedPuzzleSource + source: prefetchedPuzzleSource, + notification: prefetchedNotification ) constructed = true syncMonitor.note("Share accepted — built game from invite; fetching remainder in background") diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -28,6 +28,7 @@ final class InviteCoordinator { private let preferences: PlayerPreferences private let syncMonitor: SyncMonitor private let eventLog: EventLog + private let store: GameStore private let syncEngine: SyncEngine private let announcements: AnnouncementCenter private let shareController: ShareController @@ -48,6 +49,7 @@ final class InviteCoordinator { preferences: PlayerPreferences, syncMonitor: SyncMonitor, eventLog: EventLog, + store: GameStore, syncEngine: SyncEngine, announcements: AnnouncementCenter, shareController: ShareController, @@ -61,6 +63,7 @@ final class InviteCoordinator { self.preferences = preferences self.syncMonitor = syncMonitor self.eventLog = eventLog + self.store = store self.syncEngine = syncEngine self.announcements = announcements self.shareController = shareController @@ -109,6 +112,12 @@ final class InviteCoordinator { toGameID: gameID, userRecordName: friendAuthorID ) + // `addFriendParticipant` saves the CKShare, which marks the game shared. + // Ensure the invite carries the shared push credential minted for that + // game so the recipient registers under the owner's worker namespace + // before the canonical Game record necessarily arrives. + let notification = store.ensurePushCredentials(for: gameID) + .flatMap { try? $0.encoded() } try await friendController.sendInvite( toFriendAuthorID: friendAuthorID, gameID: gameID, @@ -117,7 +126,8 @@ final class InviteCoordinator { inviterName: preferences.name, gameShareURL: url, gridSilhouette: silhouette, - puzzleSource: puzzleSource + puzzleSource: puzzleSource, + notification: notification ) await publishInvitePush(friendAuthorID, gameID, title, preferences.name) } @@ -215,6 +225,7 @@ final class InviteCoordinator { invite.shareURL = payload.gameShareURL invite.gridSilhouette = payload.gridSilhouette invite.puzzleSource = payload.puzzleSource + invite.notification = payload.notification invite.pingRecordName = ping.recordName invite.status = "pending" invite.createdAt = Date() @@ -305,11 +316,13 @@ final class InviteCoordinator { // shared-zone fetch. nil for invites from older senders or already // consumed rows — the accept path then fetches as before. let prefetchedPuzzleSource = puzzleSource(forPingRecordName: pingRecordName) + let prefetchedNotification = notification(forPingRecordName: pingRecordName) let outcome: CloudService.AcceptOutcome do { outcome = try await cloudService.acceptShare( url: url, - prefetchedPuzzleSource: prefetchedPuzzleSource + prefetchedPuzzleSource: prefetchedPuzzleSource, + prefetchedNotification: prefetchedNotification ) } catch let error as CKError where error.code == .unknownItem { // Stale share: the row needs to go away too, but the next @@ -342,6 +355,22 @@ final class InviteCoordinator { return source } + /// The game notification credential recorded on the durable invite, if the + /// inviting build carried one. Used with the prefetched puzzle source so the + /// accepted game is immediately registered under the owner's push namespace. + private func notification(forPingRecordName pingRecordName: String) -> String? { + let ctx = persistence.viewContext + let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + req.predicate = NSPredicate(format: "pingRecordName == %@", pingRecordName) + req.fetchLimit = 1 + guard let notification = (try? ctx.fetch(req))?.first?.notification, + !notification.isEmpty + else { + return nil + } + return notification + } + private func deleteInviteAndPing(pingRecordName: String) async throws { let ctx = persistence.viewContext let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -354,7 +354,8 @@ final class FriendController { inviterName: String, gameShareURL: URL, gridSilhouette: String? = nil, - puzzleSource: String? = nil + puzzleSource: String? = nil, + notification: String? = nil ) async throws { let ctx = persistence.viewContext let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") @@ -370,7 +371,8 @@ final class FriendController { let payload = FriendZone.InvitePayload( gameShareURL: gameShareURL.absoluteString, gridSilhouette: gridSilhouette, - puzzleSource: puzzleSource + puzzleSource: puzzleSource, + notification: notification ) guard let encoded = payload.encodedString() else { throw FriendError.payloadEncodingFailed diff --git a/Crossmate/Sync/FriendZone.swift b/Crossmate/Sync/FriendZone.swift @@ -132,14 +132,25 @@ enum FriendZone { /// per-record limit, and `payload` is not an indexed field. `nil` for /// invites from older senders, which fall back to the fetch. let puzzleSource: String? + /// The owner's shared game notification credential. Direct invites can + /// build a playable game before the shared-zone Game record has synced; + /// carrying the credential here lets the joiner register under the same + /// worker namespace immediately instead of minting a temporary one. + let notification: String? // An explicit init keeps the optionals out of the inline default // (which would exclude them from Codable) while letting existing call // sites omit them; a missing JSON key decodes to `nil`. - init(gameShareURL: String, gridSilhouette: String? = nil, puzzleSource: String? = nil) { + init( + gameShareURL: String, + gridSilhouette: String? = nil, + puzzleSource: String? = nil, + notification: String? = nil + ) { self.gameShareURL = gameShareURL self.gridSilhouette = gridSilhouette self.puzzleSource = puzzleSource + self.notification = notification } func encodedString() -> String? { diff --git a/Tests/Unit/GameStorePushAddressTests.swift b/Tests/Unit/GameStorePushAddressTests.swift @@ -25,6 +25,13 @@ struct GameStorePushAddressTests { AB CD + + + A1. Top row ~ AB + A3. Bottom row ~ CD + + D1. Left column ~ AC + D2. Right column ~ BD """ @discardableResult @@ -264,6 +271,37 @@ struct GameStorePushAddressTests { #expect(Data(base64URLEncoded: first.secret)?.count == 32) } + @Test("constructJoinedGame seeds the invite-carried notification credential") + func constructJoinedGameSeedsNotificationCredential() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("joined-game-content-key-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + + try await ContentKeyDirectory.$testingFileURL.withValue(url) { + try await MainActor.run { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let gameID = UUID() + let credentials = try GamePushCredentials.fresh() + let encoded = try credentials.encoded() + let zoneID = CKRecordZone.ID( + zoneName: "game-\(gameID.uuidString)", + ownerName: "_owner" + ) + + try store.constructJoinedGame( + gameID: gameID, + zoneID: zoneID, + source: Self.puzzleSource, + notification: encoded + ) + + #expect(store.notification(for: gameID) == encoded) + #expect(ContentKeyDirectory.load()[gameID.uuidString] == credentials.contentKey) + } + } + } + @Test("ensurePushCredentials returns nil for a non-shared or missing game") func ensurePushCredentialsNonShared() throws { let persistence = makeTestPersistence() diff --git a/Tests/Unit/Sync/FriendZoneTests.swift b/Tests/Unit/Sync/FriendZoneTests.swift @@ -139,6 +139,20 @@ struct FriendZoneTests { #expect(decoded?.gridSilhouette == "sf//AA") } + @Test("InvitePayload round-trips fast-accept game data") + func invitePayloadRoundTripWithFastAcceptData() { + let payload = FriendZone.InvitePayload( + gameShareURL: "https://www.icloud.com/share/xyz#abc", + gridSilhouette: "sf//AA", + puzzleSource: "Title: Fast\n\n\nAB\n\n\nA1. _ ~ AB", + notification: "encoded-notification-credential" + ) + let decoded = FriendZone.InvitePayload.decode(payload.encodedString()) + #expect(decoded == payload) + #expect(decoded?.puzzleSource == payload.puzzleSource) + #expect(decoded?.notification == "encoded-notification-credential") + } + @Test("InvitePayload from an older sender decodes with a nil silhouette") func invitePayloadDecodesLegacyWithoutSilhouette() { // Payloads written before the silhouette field omit the key entirely. @@ -146,6 +160,8 @@ struct FriendZoneTests { let decoded = FriendZone.InvitePayload.decode(legacy) #expect(decoded?.gameShareURL == "https://www.icloud.com/share/xyz") #expect(decoded?.gridSilhouette == nil) + #expect(decoded?.puzzleSource == nil) + #expect(decoded?.notification == nil) } @Test("InvitePayload.decode tolerates nil and malformed input")