crossmate

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

commit 96df9d6c01803806978a6944b2b8a980692c113f
parent a2b0b2e78467bb73a7ef000440f8b1f457764517
Author: Michael Camilleri <[email protected]>
Date:   Thu, 21 May 2026 22:40:30 +0900

Accept the CKShare when joining from an invite notification

This commit fixes a shared puzzle opened from an .invite ping notification
hanging on 'Joining shared puzzle...' and then failing with 'Couldn't load
puzzle'. Tapping the notification only navigated to PuzzleDisplayView; nothing
on that path accepted the CKShare, so the game's GameEntity never materialised
and the inviteJoinTimeout wait could only ever expire — surfacing gameNotFound
even though the invite was valid.

The join loop now accepts the pending invite itself. On the first gameNotFound
of the fromInvitePing path it calls AppServices.acceptPendingInvite, which
looks up the durable InviteEntity recorded when the .invite Ping arrived and
routes through the existing acceptInvite path. A successful accept fetches the
shared zone so the next loadGame finds the game; the join deadline is reset
afterwards because the accept can outlast the original window, and an accept
failure (stale share, offline) now surfaces immediately instead of after a dead
30s wait.

Because the join is now driven from inside the puzzle view, the
cloudShareAcceptanceCompleted handler would stack a duplicate screen by
appending the game again. It now skips navigation when that game is already the
active puzzle; the Invited-list Accept button, used from the game list, still
navigates as before.

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

Diffstat:
MCrossmate/CrossmateApp.swift | 47++++++++++++++++++++++++++++++++++++++---------
MCrossmate/Services/AppServices.swift | 24++++++++++++++++++++++++
2 files changed, 62 insertions(+), 9 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -309,6 +309,11 @@ struct RootView: View { } .onReceive(NotificationCenter.default.publisher(for: .cloudShareAcceptanceCompleted)) { notification in guard let gameID = notification.userInfo?["gameID"] as? UUID else { return } + // A join driven from inside the puzzle view (an `.invite` + // notification tap) is already showing this game — appending + // again would stack a duplicate screen. Navigate only when the + // accept came from elsewhere, e.g. the Invited list. + guard NotificationState.activePuzzleID() != gameID else { return } navigationPath.append(gameID) } .onChange(of: scenePhase) { _, newPhase in @@ -352,10 +357,10 @@ private struct PuzzleDisplayView: View { private static let activityWindow: TimeInterval = 30 private static let readLeaseRefreshInterval: TimeInterval = 5 * 60 /// 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. + /// `GameEntity` does not exist yet: the join path accepts the pending + /// CKShare and fetches its zone. Keep showing the join spinner and retry + /// the local load this long — measured from when the accept completes — + /// before giving up and surfacing the underlying error. private static let inviteJoinTimeout: TimeInterval = 30 private static let inviteJoinPollInterval: Duration = .seconds(1) @@ -432,13 +437,14 @@ private struct PuzzleDisplayView: View { Task { await services.dismissDeliveredNotifications(for: gameID) } // 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. + // before this game's `GameEntity` exists locally. Only for that + // explicitly-flagged case do we treat a missing game as "still + // joining": accept the pending CKShare (see below) and wait for + // the shared zone to land, instead of hard-erroring. let fromInvitePing = NotificationNavigationBroker.shared.consumeInviteOrigin(gameID) - let joinDeadline = Date().addingTimeInterval(Self.inviteJoinTimeout) + var joinDeadline = Date().addingTimeInterval(Self.inviteJoinTimeout) + var didAcceptInvite = false while !Task.isCancelled { do { @@ -465,6 +471,29 @@ private struct PuzzleDisplayView: View { && preferences.isICloudSyncEnabled && Date() < joinDeadline { loadingMessage = "Joining shared puzzle..." + + // Tapping the `.invite` notification only navigated here; + // it did not accept the CKShare. Do that ourselves, once — + // without it the game's `GameEntity` never materialises and + // the join can only time out. The accept also fetches the + // shared zone, so on success the next `loadGame` finds the + // game; the deadline is reset because the accept itself can + // outlast the original window. + if !didAcceptInvite { + didAcceptInvite = true + do { + try await services.acceptPendingInvite(gameID: gameID) + joinDeadline = Date() + .addingTimeInterval(Self.inviteJoinTimeout) + } catch { + if !Task.isCancelled { + loadError = error.localizedDescription + } + break + } + continue + } + do { try await Task.sleep(for: Self.inviteJoinPollInterval) } catch { diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1165,6 +1165,30 @@ final class AppServices { await deleteInviteAndPing(pingRecordName: pingRecordName) } + /// Accepts the pending invite for `gameID`, if one is still recorded + /// locally. The puzzle-display join path calls this when the user reached + /// a not-yet-joined shared game by tapping its `.invite` notification: + /// that tap only navigates, so the CKShare must still be accepted here. + /// The durable `InviteEntity` (written when the `.invite` Ping arrived) + /// carries the share URL. Throws `InviteAcceptanceError.unavailable` when + /// no such invite exists — the same error the stale-share case surfaces. + func acceptPendingInvite(gameID: UUID) async throws { + let ctx = persistence.viewContext + let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + req.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) + req.sortDescriptors = [ + NSSortDescriptor(keyPath: \InviteEntity.createdAt, ascending: false) + ] + req.fetchLimit = 1 + guard let invite = (try? ctx.fetch(req))?.first, + let shareURL = invite.shareURL, + let pingRecordName = invite.pingRecordName + else { + throw InviteAcceptanceError.unavailable + } + try await acceptInvite(shareURL: shareURL, pingRecordName: pingRecordName) + } + private func deleteInviteAndPing(pingRecordName: String) async { let ctx = persistence.viewContext let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")