crossmate

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

commit acc376df3e73dc267d7c19250b2ca230edb446ed
parent f86caf34c9679a8a0823640fc1bd9f286763dc86
Author: Michael Camilleri <[email protected]>
Date:   Mon, 29 Jun 2026 14:18:00 +0900

Send game invitations via push notifications

Game invitations could remain invisible until the invitee next launched
and synced the app, leaving the App Store or TestFlight update path with
no reliable user-facing signal. This commit gives friend zones a
reusable encryptionKey Decision so each side can publish a friend-scoped
push address and content key before any individual game invite is
accepted.

Invite sends now keep the CloudKit Ping as the durable source of truth
while also attempting an encrypted worker push to the friend's
registered address. The Notification Service Extension opens that
invite payload with the sender's friend key, Game List taps show a short
'Syncing Invitation' banner while the row catches up, and the local
invite notification is skipped when a matching push tile has already
been delivered. The existing invitation notification preference also
mutes the new worker invite kind.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 6++++++
MCrossmate/CrossmateApp.swift | 42++++++++++++++++++++++++++++++++++++++----
MCrossmate/Services/AccountPushCoordinator.swift | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
MCrossmate/Services/AppServices.swift | 9+++++++++
MCrossmate/Services/InviteCoordinator.swift | 22+++++++++++++++++++++-
MCrossmate/Services/PushClient.swift | 3++-
MCrossmate/Sync/RecordSerializer.swift | 17+++++++++++++++++
MCrossmate/Views/GameList/GameListView.swift | 26++++++++++++++++++++++++++
MNotificationService/NotificationService.swift | 7+++++++
AShared/FriendEncryptionKeyDirectory.swift | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MShared/PushPayload.swift | 17+++++++++++++++--
MShared/PuzzleNotificationText.swift | 5+++++
MTests/Unit/NotificationNavigationBrokerTests.swift | 39+++++++++++++++++++++++++++++++++++++++
MTests/Unit/PushPayloadCipherTests.swift | 21+++++++++++++++++++++
MTests/Unit/PuzzleNotificationTextTests.swift | 29+++++++++++++++++++++--------
MTests/Unit/RecordSerializerTests.swift | 29+++++++++++++++++++++++++++++
16 files changed, 462 insertions(+), 18 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 15768439C1783A1780FBB824 /* SessionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7BD8DFDB41BFBA694A0933 /* SessionCoordinator.swift */; }; 16D7328AE0BB1F7ED46235C8 /* GameEntity+ContentKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A4B5C8EC4A46906C07F819 /* GameEntity+ContentKey.swift */; }; 17A754692F05B97DBDD645F2 /* PlayerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */; }; + 18CA29E61C4F1471A455FEC8 /* FriendEncryptionKeyDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833AAFBA0094BAB27E13FE29 /* FriendEncryptionKeyDirectory.swift */; }; 18D5BB584DBF92A2EC580AEA /* NotificationNavigationBrokerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */; }; 197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */; }; 1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BDD06460A76D4AF31077732 /* InputMonitor.swift */; }; @@ -105,6 +106,7 @@ 7BD1A9F69953F9C3288969AF /* PlayerRecordPresenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */; }; 7D4A56FBB1C5D5F89271B77F /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507B4DC893CE8AC4778CBACE /* NotificationService.swift */; }; 7D9337A19747C79070AB3D59 /* InviteEntity+DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25A040EA4DC9672C895A7AC /* InviteEntity+DisplayName.swift */; }; + 7E6ED856CE78A774B061CA60 /* FriendEncryptionKeyDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833AAFBA0094BAB27E13FE29 /* FriendEncryptionKeyDirectory.swift */; }; 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */; }; 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; }; 8225918652DCC822CA1C862F /* PendingEditFlagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D491B7232333AA8957732387 /* PendingEditFlagTests.swift */; }; @@ -358,6 +360,7 @@ 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>"; }; 80B377D237AC14B9856579E1 /* RecentChangesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentChangesTests.swift; sourceTree = "<group>"; }; + 833AAFBA0094BAB27E13FE29 /* FriendEncryptionKeyDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendEncryptionKeyDirectory.swift; sourceTree = "<group>"; }; 836B8D4B351C9225162A82C0 /* Layouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Layouts.swift; sourceTree = "<group>"; }; 847415468DBB1C566D18BC17 /* ReplayControlsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayControlsTests.swift; sourceTree = "<group>"; }; 86470163BFF956F3DE438506 /* Moves.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Moves.swift; sourceTree = "<group>"; }; @@ -730,6 +733,7 @@ isa = PBXGroup; children = ( EAFA466405AABA1C06272795 /* ContentKeyDirectory.swift */, + 833AAFBA0094BAB27E13FE29 /* FriendEncryptionKeyDirectory.swift */, 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */, 2D2FD896D75863554E31654C /* NotificationState.swift */, C2C9D3E7FCE2D42C5B7E3856 /* PushPayload.swift */, @@ -956,6 +960,7 @@ buildActionMask = 2147483647; files = ( 95170ECF07E94E7581C2B66F /* ContentKeyDirectory.swift in Sources */, + 18CA29E61C4F1471A455FEC8 /* FriendEncryptionKeyDirectory.swift in Sources */, A0977C7B0B0D928DA569C326 /* NicknameDirectory.swift in Sources */, 7D4A56FBB1C5D5F89271B77F /* NotificationService.swift in Sources */, 88BACA1689459AC9AED20D43 /* NotificationState.swift in Sources */, @@ -1080,6 +1085,7 @@ 06AE6DF7AA3274480C591E47 /* EngagementStore.swift in Sources */, BD317ECC09C9099AC29B8C5D /* FriendAvatarView.swift in Sources */, C8ACF431021E7BEE61A99153 /* FriendController.swift in Sources */, + 7E6ED856CE78A774B061CA60 /* FriendEncryptionKeyDirectory.swift in Sources */, 00A25F5D8DFF62EFA0C4D1D7 /* FriendEntity+DisplayName.swift in Sources */, 884BC090D4E2D416AA52D6FD /* FriendPickerView.swift in Sources */, 2AF2550B08CE79F8615B3076 /* FriendZone.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -180,7 +180,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNU // shows in the "Invited" section there. Suppress it while no puzzle is // active (the user is on the list), but still present it when the user // is inside a different puzzle and wouldn't see that row. - let isInvite = (userInfo["pingKind"] as? String) == PingKind.invite.rawValue + let isInvite = Self.isInviteNotification(userInfo) if isInvite, NotificationState.activePuzzleID() == nil { completionHandler([]) return @@ -205,8 +205,8 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNU } Task { @MainActor in - if (userInfo["pingKind"] as? String) == PingKind.invite.rawValue { - NotificationNavigationBroker.shared.openGameList() + if Self.isInviteNotification(userInfo) { + NotificationNavigationBroker.shared.openGameList(inviteGameID: gameID) } else { NotificationNavigationBroker.shared.openGame(gameID) } @@ -227,6 +227,11 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNU return UUID(uuidString: String(zoneName.dropFirst("game-".count))) } + private static func isInviteNotification(_ userInfo: [AnyHashable: Any]) -> Bool { + (userInfo["pingKind"] as? String) == PingKind.invite.rawValue + || (userInfo["kind"] as? String) == PingKind.invite.rawValue + } + private func logForegroundVisibleNotification( _ notification: UNNotification, source: String @@ -308,9 +313,13 @@ final class NotificationNavigationBroker { var onOpenGameList: (() -> Void)? { didSet { flushPendingGameListOpen() } } + var onOpenInviteInGameList: ((UUID) -> Void)? { + didSet { flushPendingInviteGameListOpen() } + } private var pendingGameIDs: [UUID] = [] private var pendingGameListOpen = false + private var pendingInviteGameIDs: [UUID] = [] private init() {} @@ -322,7 +331,15 @@ final class NotificationNavigationBroker { onOpenGame(gameID) } - func openGameList() { + func openGameList(inviteGameID: UUID? = nil) { + if let inviteGameID { + guard let onOpenInviteInGameList else { + pendingInviteGameIDs.append(inviteGameID) + return + } + onOpenInviteInGameList(inviteGameID) + return + } guard let onOpenGameList else { pendingGameListOpen = true return @@ -344,6 +361,15 @@ final class NotificationNavigationBroker { pendingGameListOpen = false onOpenGameList() } + + private func flushPendingInviteGameListOpen() { + guard let onOpenInviteInGameList, !pendingInviteGameIDs.isEmpty else { return } + let gameIDs = pendingInviteGameIDs + pendingInviteGameIDs.removeAll() + for gameID in gameIDs { + onOpenInviteInGameList(gameID) + } + } } @MainActor @@ -467,6 +493,7 @@ struct RootView: View { @Environment(\.scenePhase) private var scenePhase @State private var navigationPath = NavigationPath() @State private var pendingJoin: PendingJoinPlaceholder? + @State private var pendingInviteNotificationGameID: UUID? /// The in-flight share-accept driven by a tapped link, retained so the /// joining screen's Cancel can stop it (its poll unwinds on cancellation). @State private var joinTask: Task<Void, Error>? @@ -487,6 +514,7 @@ struct RootView: View { shape: shape ) }, + pendingInviteNotificationGameID: $pendingInviteNotificationGameID, navigationPath: $navigationPath ) .navigationDestination(for: UUID.self) { gameID in @@ -520,6 +548,12 @@ struct RootView: View { NotificationNavigationBroker.shared.onOpenGameList = { UIApplication.shared.dismissPresentedViewControllers() navigationPath = NavigationPath() + pendingInviteNotificationGameID = nil + } + NotificationNavigationBroker.shared.onOpenInviteInGameList = { gameID in + UIApplication.shared.dismissPresentedViewControllers() + navigationPath = NavigationPath() + pendingInviteNotificationGameID = gameID } // A tapped Crossmate share link (universal link), routed here by the // `SceneDelegate` through `ShareLinkBroker` — `.onContinueUserActivity` diff --git a/Crossmate/Services/AccountPushCoordinator.swift b/Crossmate/Services/AccountPushCoordinator.swift @@ -1,3 +1,5 @@ +import CloudKit +import CoreData import Foundation import Observation @@ -11,6 +13,7 @@ import Observation final class AccountPushCoordinator { private static let accountPushAddressDefaultsPrefix = "push.accountAddress." private static let accountPushSecretDefaultsPrefix = "push.accountSecret." + private static let friendEncryptionKeyDefaultsPrefix = "push.friendEncryptionKey." /// Generation of the locally-held push secret. Bumped on a deliberate /// rotation and tracked so a stale inbound copy can't supersede the current /// value (see `RecordSerializer.decisionVersion`). @@ -21,6 +24,7 @@ final class AccountPushCoordinator { private let identity: AuthorIdentity private let preferences: PlayerPreferences + private let persistence: PersistenceController private let store: GameStore private let syncEngine: SyncEngine private let syncMonitor: SyncMonitor @@ -33,6 +37,7 @@ final class AccountPushCoordinator { init( identity: AuthorIdentity, preferences: PlayerPreferences, + persistence: PersistenceController, store: GameStore, syncEngine: SyncEngine, syncMonitor: SyncMonitor, @@ -40,6 +45,7 @@ final class AccountPushCoordinator { ) { self.identity = identity self.preferences = preferences + self.persistence = persistence self.store = store self.syncEngine = syncEngine self.syncMonitor = syncMonitor @@ -56,13 +62,15 @@ final class AccountPushCoordinator { nudges: Bool, joins: Bool, pauses: Bool, - completions: Bool + completions: Bool, + invitations: Bool ) -> Set<String> { var muted: Set<String> = [] if !nudges { muted.insert("nudge") } if !joins { muted.insert("join") } if !pauses { muted.insert("pause") } if !completions { muted.formUnion(["win", "resign"]) } + if !invitations { muted.insert(PingKind.invite.rawValue) } return muted } @@ -71,7 +79,8 @@ final class AccountPushCoordinator { nudges: preferences.notifiesNudges, joins: preferences.notifiesJoins, pauses: preferences.notifiesPauses, - completions: preferences.notifiesCompletions + completions: preferences.notifiesCompletions, + invitations: preferences.notifiesInvitations ) } @@ -89,6 +98,7 @@ final class AccountPushCoordinator { _ = self.preferences.notifiesJoins _ = self.preferences.notifiesPauses _ = self.preferences.notifiesCompletions + _ = self.preferences.notifiesInvitations } onChange: { cont.resume() } @@ -160,9 +170,63 @@ final class AccountPushCoordinator { // The account-scoped sibling address carries no game credential. var bindings = Set(result.bindings) bindings.insert(PushAddressBinding(address: accountAddress)) + bindings.formUnion(await reconcileFriendEncryptionKeys(authorID: authorID)) pushClient.setAddresses(bindings) } + private func reconcileFriendEncryptionKeys(authorID: String) async -> Set<PushAddressBinding> { + let friends = friendEncryptionKeyTargets(localAuthorID: authorID) + var bindings = Set<PushAddressBinding>() + for friend in friends { + let payload = ensureFriendEncryptionKey(pairKey: friend.pairKey) + bindings.insert(PushAddressBinding(address: payload.address)) + guard let encoded = payload.encodedString() else { continue } + await syncEngine.enqueueFriendDecision( + kind: RecordSerializer.encryptionKeyDecisionKind, + key: authorID, + payload: encoded, + friendZoneID: friend.zoneID, + friendZoneScope: friend.scope + ) + } + return bindings + } + + private func friendEncryptionKeyTargets( + localAuthorID: String + ) -> [(pairKey: String, zoneID: CKRecordZone.ID, scope: Int16)] { + let ctx = persistence.container.newBackgroundContext() + return ctx.performAndWait { + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + req.predicate = NSPredicate(format: "isBlocked == NO") + return ((try? ctx.fetch(req)) ?? []).compactMap { friend in + guard let friendAuthorID = friend.authorID, + !friendAuthorID.isEmpty, + let zoneName = friend.friendZoneName, + let ownerName = friend.friendZoneOwnerName + else { return nil } + let pairKey = friend.pairKey ?? FriendZone.pairKey(localAuthorID, friendAuthorID) + return ( + pairKey, + CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), + friend.databaseScope + ) + } + } + } + + private func ensureFriendEncryptionKey(pairKey: String) -> FriendEncryptionKeyPayload { + let key = Self.friendEncryptionKeyDefaultsPrefix + pairKey + if let existing = FriendEncryptionKeyPayload.decode(UserDefaults.standard.string(forKey: key)) { + return existing + } + guard let fresh = FriendEncryptionKeyPayload.fresh() else { + preconditionFailure("Unable to mint friend encryption key") + } + UserDefaults.standard.set(fresh.encodedString(), forKey: key) + return fresh + } + /// Stamps `gameID`'s local Player row with the derived push address inside /// the puzzle-open send burst, so it ships on the same Player-record write /// as the read-cursor lease and display name. @@ -324,6 +388,57 @@ final class AccountPushCoordinator { await publishAccountEvent(kind: Self.accountJoinedPushKind, gameID: gameID) } + func publishInvitePush( + to friendAuthorID: String, + gameID: UUID, + puzzleTitle: String, + inviterName: String + ) async { + guard let pushClient else { + syncMonitor.note("push(invite): skipped (no pushClient)") + return + } + guard let authorID = identity.currentID, !authorID.isEmpty else { + syncMonitor.note("push(invite): skipped (no local identity)") + return + } + // The friend's directory entry only supplies their registered push + // address. The payload must be sealed with *our own* per-pair key: the + // friend's Notification Service Extension opens an invite push with the + // sender's key, looked up by `fromAuthorID` — i.e. the key we minted and + // mirrored into their directory under our authorID, not their own key. + guard let remote = FriendEncryptionKeyDirectory.payload(for: friendAuthorID) else { + syncMonitor.note("push(invite): skipped (no encryption key for \(friendAuthorID))") + return + } + guard let key = ensureFriendEncryptionKey( + pairKey: FriendZone.pairKey(authorID, friendAuthorID) + ).symmetricKey else { + syncMonitor.note("push(invite): skipped (no local encryption key for \(friendAuthorID))") + return + } + await pushClient.publish( + kind: PingKind.invite.rawValue, + gameID: gameID, + addressees: [ + PushClient.Addressee( + address: remote.address, + payload: PushPayload( + event: .invite, + puzzleTitle: puzzleTitle, + playerName: inviterName + ) + ) + ], + title: "Crossmate", + puzzleTitle: puzzleTitle, + gameCredentialed: false, + payloadKey: key, + collapseID: PushClient.gameCollapseID(gameID), + body: PushClient.genericAlertBody + ) + } + func publishAccountSeenPush(gameID: UUID, readAt: Date) async { guard let pushClient else { syncMonitor.note("push(\(Self.accountSeenPushKind)): skipped (no pushClient)") diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -309,6 +309,14 @@ final class AppServices { cloudService: cloudService, refreshAppBadge: { [weak self] reason in await self?.badge.refreshAppBadge(reason: reason) + }, + publishInvitePush: { [weak self] friendAuthorID, gameID, title, inviterName in + await self?.accountPush.publishInvitePush( + to: friendAuthorID, + gameID: gameID, + puzzleTitle: title, + inviterName: inviterName + ) } ) @@ -545,6 +553,7 @@ final class AppServices { self.accountPush = AccountPushCoordinator( identity: identity, preferences: preferences, + persistence: persistence, store: store, syncEngine: syncEngine, syncMonitor: self.syncMonitor, diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -36,6 +36,7 @@ final class InviteCoordinator { /// Refreshes the app-icon badge — `BadgeCoordinator.refreshAppBadge` — /// whenever invite rows change (pending invites count toward the badge). private let refreshAppBadge: (String) async -> Void + private let publishInvitePush: (String, UUID, String, String) async -> Void private var claimedPingRecordNames: Set<String> = [] private var claimedPingRecordNameOrder: [String] = [] @@ -52,7 +53,8 @@ final class InviteCoordinator { shareController: ShareController, friendController: FriendController, cloudService: CloudService, - refreshAppBadge: @escaping (String) async -> Void + refreshAppBadge: @escaping (String) async -> Void, + publishInvitePush: @escaping (String, UUID, String, String) async -> Void ) { self.persistence = persistence self.identity = identity @@ -65,6 +67,7 @@ final class InviteCoordinator { self.friendController = friendController self.cloudService = cloudService self.refreshAppBadge = refreshAppBadge + self.publishInvitePush = publishInvitePush } /// Re-invites an existing friend to a game: adds them as a participant on @@ -111,6 +114,7 @@ final class InviteCoordinator { gridSilhouette: silhouette, puzzleSource: puzzleSource ) + await publishInvitePush(friendAuthorID, gameID, title, preferences.name) } /// For each collaborative game with newly-known remote authors, asks the @@ -584,6 +588,7 @@ final class InviteCoordinator { } let center = UNUserNotificationCenter.current() + let delivered = await center.deliveredNotifications() for ping in playerFacingPings { if ping.kind == .invite, ping.addressee != identity.currentID { continue @@ -633,6 +638,11 @@ final class InviteCoordinator { syncMonitor.note("ping(invite): banner disabled in settings for \(ping.gameID.uuidString)") continue } + if ping.kind == .invite, + Self.deliveredInvitePushExists(for: ping.gameID, in: delivered) { + syncMonitor.note("ping(invite): push already delivered, not queueing local notification for \(ping.gameID.uuidString)") + continue + } let content = UNMutableNotificationContent() content.title = "Crossmate" @@ -740,6 +750,16 @@ final class InviteCoordinator { } } + private static func deliveredInvitePushExists(for gameID: UUID, in delivered: [UNNotification]) -> Bool { + delivered.contains { notification in + let userInfo = notification.request.content.userInfo + guard let idString = userInfo["gameID"] as? String, + UUID(uuidString: idString) == gameID + else { return false } + return userInfo["kind"] as? String == PingKind.invite.rawValue + } + } + nonisolated static func bodyText(for ping: Ping, nickname: String? = nil) -> String { let puzzleSuffix = ping.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(ping.puzzleTitle)'" switch ping.kind { diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift @@ -243,6 +243,7 @@ final class PushClient { broadcast: Bool = false, excludeAddress: String? = nil, broadcastPayload: PushPayload? = nil, + payloadKey: SymmetricKey? = nil, collapseID: String? = nil, extra: [String: Any] = [:], body: String @@ -271,7 +272,7 @@ final class PushClient { // Resolve (and mint) the game's content key only when there is a payload // to seal, so account-scoped pushes that carry none don't mint one. let needsContentKey = broadcastPayload != nil || addressees.contains { $0.payload != nil } - let contentKey = needsContentKey ? contentKeyResolver?(gameID) : nil + let contentKey = needsContentKey ? (payloadKey ?? contentKeyResolver?(gameID)) : nil // Encrypt each addressee's structured payload under the content key and // ship it as `enc`. The per-recipient cleartext `body` (personalised // pause counts) is deliberately *not* sent: those counts live in the diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -189,6 +189,7 @@ enum RecordSerializer { /// user's monotonic rename generation for that friend. Lives only in the /// account zone — it's the user's own label, never shared with the friend. static let nicknameDecisionKind = "nickname" + static let encryptionKeyDecisionKind = "encryptionKey" static let accountDecisionKind = "account" static let accountPushAddressDecisionKey = "pushAddress" @@ -1150,6 +1151,22 @@ enum RecordSerializer { friend.nickname = (nickname?.isEmpty == false) ? nickname : nil friend.nicknameVersion = version return true + case encryptionKeyDecisionKind: + // Friend-zone message encryption material. Each participant writes + // only their own key record, named by authorID; the peer mirrors it + // to the App Group so the Notification Service Extension can + // decrypt invite pushes before the game zone exists locally. + guard let localAuthorID, !localAuthorID.isEmpty, + key != localAuthorID, + let payload = FriendEncryptionKeyPayload.decode(record["payload"] as? String) + else { return false } + let pairKey = FriendZone.pairKey(localAuthorID, key) + let zoneID = record.recordID.zoneID + guard zoneID.zoneName == FriendZone.zoneName(pairKey: pairKey) else { + return false + } + FriendEncryptionKeyDirectory.upsert(payload, for: key) + return false default: // Unknown kind from a newer build — ignore rather than guess. return false diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift @@ -9,6 +9,7 @@ struct GameListView: View { let onAppear: () async -> Void let onDisappear: () -> Void let onAcceptInvite: ((String, String, GridSilhouette.Grid?) async throws -> Void)? + @Binding var pendingInviteNotificationGameID: UUID? @Binding var navigationPath: NavigationPath @Environment(\.managedObjectContext) private var viewContext @@ -143,6 +144,13 @@ struct GameListView: View { } .task { await onAppear() + reconcilePendingInviteNotification() + } + .onChange(of: pendingInviteNotificationGameID) { _, _ in + reconcilePendingInviteNotification() + } + .onChange(of: pendingInvites.count) { _, _ in + reconcilePendingInviteNotification() } .onDisappear { onDisappear() @@ -268,6 +276,23 @@ struct GameListView: View { } } + private func reconcilePendingInviteNotification() { + guard let gameID = pendingInviteNotificationGameID else { return } + if pendingInvites.contains(where: { $0.gameID == gameID }) { + announcements.dismiss(id: Self.inviteSyncID) + pendingInviteNotificationGameID = nil + return + } + announcements.post(Announcement( + id: Self.inviteSyncID, + scope: .global, + severity: .info, + title: "Syncing Invitation", + body: "An invitation is still syncing. It should appear shortly.", + dismissal: .transient(after: 8) + )) + } + private func presentQueuedFriendNewGame() { guard let target = queuedNewGameInviteTarget else { return } queuedNewGameInviteTarget = nil @@ -690,6 +715,7 @@ struct GameListView: View { /// Single-slot id for the invite-accept failure banner — a fresh /// failure replaces the prior one rather than stacking. private static let inviteErrorID = "invite-accept-error" + private static let inviteSyncID = "invite-sync-in-progress" /// Single-slot id for failures after starting a puzzle from the Crossmates /// sheet — scoped to the new puzzle, where the user can retry from its diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift @@ -56,6 +56,13 @@ final class NotificationService: UNNotificationServiceExtension { let opened = PushPayloadCipher.open(encrypted, key: key) { return opened } + if let encrypted, + kind == "invite", + let fromAuthorID = (userInfo["fromAuthorID"] as? String).flatMap({ $0.isEmpty ? nil : $0 }), + let key = FriendEncryptionKeyDirectory.key(for: fromAuthorID), + let opened = PushPayloadCipher.open(encrypted, key: key) { + return opened + } return PushPayload.decode(from: userInfo["payload"] as? String) }() diff --git a/Shared/FriendEncryptionKeyDirectory.swift b/Shared/FriendEncryptionKeyDirectory.swift @@ -0,0 +1,89 @@ +import CryptoKit +import Foundation + +struct FriendEncryptionKeyPayload: Codable, Equatable, Sendable { + static let currentVersion = 1 + + var version: Int + var address: String + var contentKey: String + + init(version: Int = Self.currentVersion, address: String, contentKey: String) { + self.version = version + self.address = address + self.contentKey = contentKey + } + + func encodedString() -> String? { + guard let data = try? JSONEncoder().encode(self) else { return nil } + return String(data: data, encoding: .utf8) + } + + static func decode(_ raw: String?) -> FriendEncryptionKeyPayload? { + guard let raw, let data = raw.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(Self.self, from: data) + } + + static func fresh() -> FriendEncryptionKeyPayload? { + var bytes = [UInt8](repeating: 0, count: 32) + guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { + return nil + } + return FriendEncryptionKeyPayload( + address: "friend-\(UUID().uuidString)", + contentKey: Data(bytes).base64EncodedString() + ) + } + + var symmetricKey: SymmetricKey? { + PushPayloadCipher.key(fromBase64: contentKey) + } +} + +/// App Group-shared directory of friend-channel encryption keys, keyed by the +/// friend's authorID. The main app mirrors keys learned from friend-zone +/// `Decision` records here so the Notification Service Extension can decrypt +/// an invite push before the game itself exists locally. +enum FriendEncryptionKeyDirectory { + @TaskLocal static var testingFileURL: URL? + + private static var fileURL: URL? { + if let testingFileURL { return testingFileURL } + return FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: NotificationState.appGroup)? + .appendingPathComponent("friend-encryption-key-directory.json") + } + + static func load() -> [String: FriendEncryptionKeyPayload] { + guard let url = fileURL, + let data = try? Data(contentsOf: url), + let directory = try? JSONDecoder().decode([String: FriendEncryptionKeyPayload].self, from: data) + else { return [:] } + return directory + } + + static func save(_ directory: [String: FriendEncryptionKeyPayload]) { + guard let url = fileURL else { return } + guard !directory.isEmpty else { + try? FileManager.default.removeItem(at: url) + return + } + guard let data = try? JSONEncoder().encode(directory) else { return } + try? data.write(to: url, options: .atomic) + } + + static func upsert(_ payload: FriendEncryptionKeyPayload, for authorID: String) { + guard !authorID.isEmpty else { return } + var directory = load() + directory[authorID] = payload + save(directory) + } + + static func payload(for authorID: String) -> FriendEncryptionKeyPayload? { + load()[authorID] + } + + static func key(for authorID: String) -> SymmetricKey? { + payload(for: authorID)?.symmetricKey + } +} diff --git a/Shared/PushPayload.swift b/Shared/PushPayload.swift @@ -144,6 +144,10 @@ struct PushPayload: Codable, Sendable, Equatable { /// announced to everyone already in the room. Presence only — joining /// changes no grid cells — so it never marks the game unread. case join + /// A friend invited this account to a shared puzzle. The invite row + /// itself arrives through CloudKit; the push is only a user-visible + /// heads-up and never marks game progress unread. + case invite /// An event introduced by a newer build. Treated as carrying no /// unseen content for badge purposes. case unknown @@ -158,7 +162,7 @@ struct PushPayload: Codable, Sendable, Equatable { case .pause(let fills, let clears, let checks, let reveals): return fills + clears + checks + reveals > 0 case .win, .resign: return true - case .replay, .nudge, .join, .unknown: return false + case .replay, .nudge, .join, .invite, .unknown: return false } } } @@ -210,6 +214,11 @@ extension PushPayload { playerName: playerName, puzzleTitle: puzzleTitle ) + case .invite: + return PuzzleNotificationText.inviteBody( + playerName: playerName, + puzzleTitle: puzzleTitle + ) case .replay, .unknown: return nil } @@ -312,7 +321,7 @@ extension PushPayload.Event: Codable { } private enum Discriminator: String { - case pause, win, resign, replay, nudge, join + case pause, win, resign, replay, nudge, join, invite } init(from decoder: Decoder) throws { @@ -335,6 +344,8 @@ extension PushPayload.Event: Codable { self = .nudge case .join: self = .join + case .invite: + self = .invite case nil: // A discriminator this build doesn't know — a newer sender. self = .unknown @@ -360,6 +371,8 @@ extension PushPayload.Event: Codable { try container.encode(Discriminator.nudge.rawValue, forKey: .type) case .join: try container.encode(Discriminator.join.rawValue, forKey: .type) + case .invite: + try container.encode(Discriminator.invite.rawValue, forKey: .type) case .unknown: // Not produced as an outgoing event by this build; encode a stable // marker so an `.unknown` round-trips back to `.unknown`. diff --git a/Shared/PuzzleNotificationText.swift b/Shared/PuzzleNotificationText.swift @@ -28,6 +28,11 @@ enum PuzzleNotificationText { "\(resolvedName(playerName)) joined \(puzzleSuffix(puzzleTitle))" } + /// Body for an invite push: "Alice invited you to the puzzle 'X'". + static func inviteBody(playerName: String, puzzleTitle: String) -> String { + "\(resolvedName(playerName)) invited you to \(puzzleSuffix(puzzleTitle))" + } + /// Body for a completion push — "Alice solved …" or, when `resigned`, /// "Alice resigned …" (the resign sentence ends in a full stop to match the /// app's existing wording). diff --git a/Tests/Unit/NotificationNavigationBrokerTests.swift b/Tests/Unit/NotificationNavigationBrokerTests.swift @@ -11,6 +11,7 @@ struct NotificationNavigationBrokerTests { let broker = NotificationNavigationBroker.shared broker.onOpenGame = nil broker.onOpenGameList = nil + broker.onOpenInviteInGameList = nil let gameID = UUID() var openedGameID: UUID? var openedList = false @@ -28,6 +29,7 @@ struct NotificationNavigationBrokerTests { let broker = NotificationNavigationBroker.shared broker.onOpenGame = nil broker.onOpenGameList = nil + broker.onOpenInviteInGameList = nil let gameID = UUID() var openedGameIDs: [UUID] = [] @@ -44,6 +46,7 @@ struct NotificationNavigationBrokerTests { let broker = NotificationNavigationBroker.shared broker.onOpenGame = nil broker.onOpenGameList = nil + broker.onOpenInviteInGameList = nil var openedGameID: UUID? var openedList = false @@ -60,6 +63,7 @@ struct NotificationNavigationBrokerTests { let broker = NotificationNavigationBroker.shared broker.onOpenGame = nil broker.onOpenGameList = nil + broker.onOpenInviteInGameList = nil var openedListCount = 0 broker.openGameList() @@ -69,4 +73,39 @@ struct NotificationNavigationBrokerTests { #expect(openedListCount == 1) } + + @Test("Invite Game List opens are delivered to the invite handler") + func inviteGameListOpenDelivered() { + let broker = NotificationNavigationBroker.shared + broker.onOpenGame = nil + broker.onOpenGameList = nil + broker.onOpenInviteInGameList = nil + let gameID = UUID() + var openedInviteGameID: UUID? + var openedList = false + + broker.onOpenGameList = { openedList = true } + broker.onOpenInviteInGameList = { openedInviteGameID = $0 } + broker.openGameList(inviteGameID: gameID) + + #expect(openedInviteGameID == gameID) + #expect(!openedList) + } + + @Test("Invite Game List opens are buffered until the invite handler is installed") + func inviteGameListOpenBuffered() { + let broker = NotificationNavigationBroker.shared + broker.onOpenGame = nil + broker.onOpenGameList = nil + broker.onOpenInviteInGameList = nil + let gameID = UUID() + var openedInviteGameIDs: [UUID] = [] + + broker.openGameList(inviteGameID: gameID) + #expect(openedInviteGameIDs.isEmpty) + + broker.onOpenInviteInGameList = { openedInviteGameIDs.append($0) } + + #expect(openedInviteGameIDs == [gameID]) + } } diff --git a/Tests/Unit/PushPayloadCipherTests.swift b/Tests/Unit/PushPayloadCipherTests.swift @@ -97,6 +97,27 @@ struct PushPayloadCipherTests { } } + @Test("friend encryption directory resolves a key that opens invite payloads") + func friendEncryptionDirectoryRoundTrip() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("friend-key-directory-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + try await FriendEncryptionKeyDirectory.$testingFileURL.withValue(url) { + let payload = try #require(FriendEncryptionKeyPayload.fresh()) + FriendEncryptionKeyDirectory.upsert(payload, for: "_alice") + #expect(FriendEncryptionKeyDirectory.payload(for: "_alice") == payload) + + let key = try #require(FriendEncryptionKeyDirectory.key(for: "_alice")) + let invite = PushPayload( + event: .invite, + puzzleTitle: "Saturday", + playerName: "Alice" + ) + let sealed = try #require(PushPayloadCipher.seal(invite, key: key)) + #expect(PushPayloadCipher.open(sealed, key: key) == invite) + } + } + // MARK: - Rebuild from Core Data @Test("rebuildContentKeyDirectory mirrors games whose credential carries a key") diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift @@ -248,7 +248,8 @@ struct NotificationMutedKindsTests { nudges: true, joins: true, pauses: true, - completions: true + completions: true, + invitations: true ) #expect(muted.isEmpty) @@ -260,38 +261,50 @@ struct NotificationMutedKindsTests { nudges: false, joins: true, pauses: true, - completions: true + completions: true, + invitations: true ) == ["nudge"]) #expect(AccountPushCoordinator.mutedPushKinds( nudges: true, joins: false, pauses: true, - completions: true + completions: true, + invitations: true ) == ["join"]) #expect(AccountPushCoordinator.mutedPushKinds( nudges: true, joins: true, pauses: false, - completions: true + completions: true, + invitations: true ) == ["pause"]) #expect(AccountPushCoordinator.mutedPushKinds( nudges: true, joins: true, pauses: true, - completions: false + completions: false, + invitations: true ) == ["win", "resign"]) + #expect(AccountPushCoordinator.mutedPushKinds( + nudges: true, + joins: true, + pauses: true, + completions: true, + invitations: false + ) == ["invite"]) } - @Test("Muted kinds never include background or invite kinds") + @Test("Muted kinds never include background kinds") func neverMutesBackgroundKinds() { let muted = AccountPushCoordinator.mutedPushKinds( nudges: false, joins: false, pauses: false, - completions: false + completions: false, + invitations: false ) - #expect(muted == ["join", "nudge", "pause", "win", "resign"]) + #expect(muted == ["invite", "join", "nudge", "pause", "win", "resign"]) #expect(!muted.contains("replay")) #expect(!muted.contains("accountJoined")) #expect(!muted.contains("accountSeen")) diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -901,6 +901,35 @@ struct RecordSerializerTests { #expect(try ctx.count(for: req) == 0) } + @Test("applyDecisionRecord(.encryptionKey) mirrors a peer key from its pair zone") + @MainActor func applyEncryptionKeyDecisionMirrorsPeerKey() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("friend-key-directory-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + + try await FriendEncryptionKeyDirectory.$testingFileURL.withValue(url) { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let payload = try #require(FriendEncryptionKeyPayload.fresh()) + let record = RecordSerializer.decisionRecord( + kind: RecordSerializer.encryptionKeyDecisionKind, + key: "_bob", + payload: payload.encodedString(), + zone: friendZoneID(local: "_alice", remote: "_bob") + ) + + let wrote = RecordSerializer.applyDecisionRecord( + record, + to: ctx, + localAuthorID: "_alice" + ) + #expect(!wrote) + #expect(FriendEncryptionKeyDirectory.payload(for: "_bob") == payload) + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + #expect(try ctx.count(for: req) == 0) + } + } + // MARK: - Name decisions /// The pairwise friend zone for (`local`, `remote`) — the only zone a