crossmate

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

commit 88a4904b61a4e821f161cd561b8a3a5bb2e917c3
parent 1bf70453032d7b22cee4857ec39021d55ec1a93f
Author: Michael Camilleri <[email protected]>
Date:   Tue, 19 May 2026 16:02:54 +0900

Wait for an invited game to sync before erroring

Tapping an .invite ping notification navigates straight to the puzzle by game
ID, but the CKShare that materialises that game's GameEntity is accepted and
fetched on a separate path that can land several seconds after the tap. Prior
to this commit, PuzzleDisplayView treated the missing entity as terminal: it
showed 'Couldn't load puzzle / gameNotFound'.

The notification tap now carries an explicit invite-origin flag rather than the
screen inferring one. The crossmatePingKind userInfo is matched against
PingKind.invite, threaded through NotificationNavigationBroker (consumed
exactly once, so a later re-open from the library behaves normally), and read
by PuzzleDisplayView. Only for that flagged case, and only while iCloud sync is
on is a gameNotFound error treated as 'still joining': the view showing a
'Joining shared puzzle...' spinner and retrying the local load every second,
falling back to the original error after 30s.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/CrossmateApp.swift | 93++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
ATests/Unit/NotificationNavigationBrokerTests.swift | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 131 insertions(+), 20 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; }; 128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */; }; 17A754692F05B97DBDD645F2 /* PlayerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.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 */; }; 1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */; }; @@ -220,6 +221,7 @@ F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendAvatarView.swift; sourceTree = "<group>"; }; F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUZToXDConverterTests.swift; sourceTree = "<group>"; }; + FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationNavigationBrokerTests.swift; sourceTree = "<group>"; }; FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisherTests.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ @@ -268,6 +270,7 @@ D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */, 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */, 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */, + FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */, 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */, ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */, C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */, @@ -538,6 +541,7 @@ C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */, C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */, AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */, + 18D5BB584DBF92A2EC580AEA /* NotificationNavigationBrokerTests.swift in Sources */, E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */, C78698428F60300D25FC8694 /* OpenedLeaseTests.swift in Sources */, C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -115,8 +115,14 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser return } + let fromInvitePing = + (userInfo["crossmatePingKind"] as? String) == PingKind.invite.rawValue + Task { @MainActor in - NotificationNavigationBroker.shared.openGame(gameID) + NotificationNavigationBroker.shared.openGame( + gameID, + fromInvitePing: fromInvitePing + ) completionHandler() } } @@ -177,10 +183,17 @@ final class NotificationNavigationBroker { } private var pendingGameIDs: [UUID] = [] + /// Game IDs whose navigation was triggered by tapping an `.invite` ping + /// notification. The CKShare that materialises such a game is accepted on + /// a separate path and can land seconds after the tap, so the destination + /// view consumes this flag to know it should wait rather than hard-error + /// when the game isn't in the local store yet. + private var inviteOriginGameIDs: Set<UUID> = [] private init() {} - func openGame(_ gameID: UUID) { + func openGame(_ gameID: UUID, fromInvitePing: Bool = false) { + if fromInvitePing { inviteOriginGameIDs.insert(gameID) } guard let onOpenGame else { pendingGameIDs.append(gameID) return @@ -188,6 +201,13 @@ final class NotificationNavigationBroker { onOpenGame(gameID) } + /// Returns `true` exactly once if `gameID` was opened by tapping an + /// `.invite` ping notification, clearing the flag so a later re-open of + /// the same game (e.g. from the library list) behaves normally. + func consumeInviteOrigin(_ gameID: UUID) -> Bool { + inviteOriginGameIDs.remove(gameID) != nil + } + private func flushPendingGameIDs() { guard let onOpenGame, !pendingGameIDs.isEmpty else { return } let gameIDs = pendingGameIDs @@ -326,6 +346,13 @@ private struct PuzzleDisplayView: View { /// How recent a push must be to count as "active". Slightly longer than /// the active interval so a burst with brief pauses keeps tight polling. private static let activityWindow: TimeInterval = 30 + /// When opened from an `.invite` ping notification, the game's local + /// `GameEntity` may not exist yet — the CKShare is accepted and the zone + /// fetched on a separate path that can land seconds after the tap. Keep + /// showing the join spinner and retry the local load this long before + /// giving up and surfacing the underlying error. + private static let inviteJoinTimeout: TimeInterval = 30 + private static let inviteJoinPollInterval: Duration = .seconds(1) private var syncedID: UUID? { preferences.isICloudSyncEnabled && session != nil ? gameID : nil @@ -348,6 +375,7 @@ private struct PuzzleDisplayView: View { @State private var session: PlayerSession? @State private var roster: PlayerRoster? @State private var loadError: String? + @State private var loadingMessage = "Loading puzzle..." @State private var openPuzzleFollowUpTask: Task<Void, Never>? var body: some View { @@ -373,7 +401,7 @@ private struct PuzzleDisplayView: View { description: Text(loadError) ) } else { - ProgressView("Loading puzzle...") + ProgressView(loadingMessage) .frame(maxWidth: .infinity, maxHeight: .infinity) } } @@ -389,27 +417,52 @@ private struct PuzzleDisplayView: View { session = nil roster = nil loadError = nil + loadingMessage = "Loading puzzle..." updateActiveNotificationPuzzleID(for: scenePhase) Task { await services.dismissDeliveredNotifications(for: gameID) } - do { - let (game, mutator) = try store.loadGame(id: gameID) - let newSession = PlayerSession( - game: game, - mutator: mutator, - cursorStore: services.cursorStore - ) - let newRoster = services.makePlayerRoster(for: gameID, preferences: preferences) - roster = newRoster - session = newSession - openPuzzleFollowUpTask = Task { @MainActor in - await finishOpeningPuzzle( - session: newSession, - roster: newRoster, - isShared: mutator.isShared + + // Tapping an `.invite` ping notification navigates here at once, + // but the CKShare that materialises this game's `GameEntity` is + // accepted/fetched on a separate path that can land seconds later. + // Only for that explicitly-flagged case do we treat a missing + // game as "still joining" and wait, instead of hard-erroring. + let fromInvitePing = + NotificationNavigationBroker.shared.consumeInviteOrigin(gameID) + let joinDeadline = Date().addingTimeInterval(Self.inviteJoinTimeout) + + while !Task.isCancelled { + do { + let (game, mutator) = try store.loadGame(id: gameID) + let newSession = PlayerSession( + game: game, + mutator: mutator, + cursorStore: services.cursorStore ) + let newRoster = services.makePlayerRoster(for: gameID, preferences: preferences) + roster = newRoster + session = newSession + openPuzzleFollowUpTask = Task { @MainActor in + await finishOpeningPuzzle( + session: newSession, + roster: newRoster, + isShared: mutator.isShared + ) + } + break + } catch GameStore.LoadError.gameNotFound + where fromInvitePing + && preferences.isICloudSyncEnabled + && Date() < joinDeadline { + loadingMessage = "Joining shared puzzle..." + do { + try await Task.sleep(for: Self.inviteJoinPollInterval) + } catch { + break // task cancelled + } + } catch { + loadError = String(describing: error) + break } - } catch { - loadError = String(describing: error) } } .onChange(of: session?.mutator.isShared) { oldValue, newValue in diff --git a/Tests/Unit/NotificationNavigationBrokerTests.swift b/Tests/Unit/NotificationNavigationBrokerTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("Notification navigation broker", .serialized) +@MainActor +struct NotificationNavigationBrokerTests { + @Test("Invite-origin is reported exactly once, then cleared") + func inviteOriginConsumedOnce() { + let broker = NotificationNavigationBroker.shared + let gameID = UUID() + + broker.openGame(gameID, fromInvitePing: true) + + // First read sees the invite origin... + #expect(broker.consumeInviteOrigin(gameID)) + // ...and clears it, so a later re-open from the library list (which + // does not pass the flag) behaves like a normal navigation. + #expect(!broker.consumeInviteOrigin(gameID)) + } + + @Test("A non-invite open carries no invite origin") + func defaultOpenHasNoInviteOrigin() { + let broker = NotificationNavigationBroker.shared + let gameID = UUID() + + broker.openGame(gameID) + + #expect(!broker.consumeInviteOrigin(gameID)) + } + + @Test("Consuming an untouched game ID returns false") + func consumeWithoutOpenReturnsFalse() { + let broker = NotificationNavigationBroker.shared + + #expect(!broker.consumeInviteOrigin(UUID())) + } + + @Test("Invite origin is tracked per game ID") + func inviteOriginIsPerGame() { + let broker = NotificationNavigationBroker.shared + let invitedID = UUID() + let otherID = UUID() + + broker.openGame(invitedID, fromInvitePing: true) + broker.openGame(otherID, fromInvitePing: false) + + // Consuming the unrelated game neither reports nor disturbs the + // invited game's flag. + #expect(!broker.consumeInviteOrigin(otherID)) + #expect(broker.consumeInviteOrigin(invitedID)) + } +}