crossmate

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

commit 8c8eb3161eb6e539c71651ac7e216ecdb89d77f6
parent dec707454fa1c8badb7e280d2781c475f383f838
Author: Michael Camilleri <[email protected]>
Date:   Tue, 16 Jun 2026 22:23:37 +0900

Explain a share-link join that opens no puzzle

Tapping a share link that didn't open its puzzle returned the user to
the Game List with no explanation, leaving a tapped invitation looking
broken. Two different outcomes both ended there in silence: a link whose
share no longer exists, and a share that was accepted but whose puzzle
had not finished syncing.

This commit adds announcements to cover both cases. When the metadata
fetch or the accept fails because the share is gone — the sender deleted
or left the game, surfacing as .unknownItem or .zoneNotFound — a 'Puzzle
Unavailable' banner explains that the puzzle is no longer there. That
failure is also written to the diagnostics log, which previously
recorded nothing for it because the only error note sat past the point
that failed.

The second outcome is reassurance rather than failure. acceptShare now
reports its outcome — the puzzle opened, the join is still syncing, or
the user cancelled — so a caller can tell a genuine miss from a join
that has simply not caught up. When the accept succeeds but the puzzle
is not playable before the join wait times out, an 'Almost There' banner
notes that it is still syncing and will appear shortly; it clears
itself, since the game arrives on its own. A cancelled join stays
silent.

The reassurance is scoped to the joins that own the joining screen — the
link tap and the OS share-accept. The Invited section's accept ignores
the outcome and keeps its own error handling, so it does not double up.

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

Diffstat:
MCrossmate/CrossmateApp.swift | 32+++++++++++++++++++++++++++++++-
MCrossmate/Services/AnnouncementCenter.swift | 15+++++++++++++++
MCrossmate/Services/AppServices.swift | 7++++++-
MCrossmate/Services/CloudService.swift | 26++++++++++++++++++++++----
4 files changed, 74 insertions(+), 6 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -500,9 +500,39 @@ struct RootView: View { withAnimation { pendingJoin = PendingJoinPlaceholder(shape: route.shape) } joinTask = Task { do { - try await services.cloudService.acceptShare(url: route.iCloudShareURL) + let outcome = try await services.cloudService.acceptShare(url: route.iCloudShareURL) + // The share was accepted but its puzzle hasn't synced in + // yet — the joining screen timed out. The game still + // arrives in the list shortly, so reassure rather than + // leave the user wondering why nothing opened. + if case .pendingSync = outcome { + services.announcements.post(.puzzleStillSyncing()) + } } catch { withAnimation { pendingJoin = nil } + // A Cancel tap returns without throwing, so reaching + // here is a genuine failure to join. The common one is a + // dead link — the inviter deleted or left the game, so + // its share is gone, which the metadata fetch reports as + // `.unknownItem`/`.zoneNotFound`. Surface it on the Game + // List rather than bouncing the user back in silence. + guard !Task.isCancelled else { return } + let code = (error as? CKError)?.code + let gone = code == .unknownItem || code == .zoneNotFound + services.eventLog.note( + "share link join failed: \(error.localizedDescription)", + level: gone ? "info" : "error" + ) + services.announcements.post(Announcement( + id: "share-link-join-failed", + scope: .global, + severity: gone ? .warning : .error, + title: gone ? "Puzzle Unavailable" : "Couldn't Join Puzzle", + body: gone + ? "This puzzle is no longer available — the person who shared it deleted or left the game." + : error.localizedDescription, + dismissal: .manual + )) } } } diff --git a/Crossmate/Services/AnnouncementCenter.swift b/Crossmate/Services/AnnouncementCenter.swift @@ -109,6 +109,21 @@ extension Announcement { blocksInput: true ) } + + /// Reassurance shown on the Game List when a share was accepted but its + /// puzzle had not finished syncing before the join wait timed out. The game + /// surfaces on its own once sync settles, so this clears itself rather than + /// asking the user to act. + static func puzzleStillSyncing() -> Announcement { + Announcement( + id: "share-join-pending-sync", + scope: .global, + severity: .info, + title: "Almost There", + body: "This puzzle is still syncing and will appear in your list shortly.", + dismissal: .transient(after: 6) + ) + } } /// The persisted, open-relevant facts about a game — the input to diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1690,7 +1690,12 @@ final class AppServices { while !pendingShareMetadatas.isEmpty { let metadata = pendingShareMetadatas.removeFirst() do { - try await cloudService.acceptShare(metadata: metadata) + let outcome = try await cloudService.acceptShare(metadata: metadata) + // Accepted but the puzzle hasn't synced in yet — reassure the + // user it's coming, mirroring the link-tap path. + if case .pendingSync = outcome { + announcements.post(.puzzleStillSyncing()) + } } catch { // The CloudService already recorded the detailed CloudKit // failure; OS-delivered share acceptances have no caller to diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift @@ -32,11 +32,27 @@ final class CloudService { self.shareController = shareController } + /// The result of accepting a share, so callers can react to a join that + /// succeeded at the CloudKit level but hasn't produced a playable puzzle + /// yet. Navigation is still driven by `.cloudShareAcceptanceCompleted`; this + /// only lets a caller surface a "still syncing" message where appropriate. + enum AcceptOutcome { + /// A playable puzzle was joined; navigation has been posted. + case opened + /// The share was accepted but its puzzle hadn't synced before the wait + /// timed out. The game still surfaces in the Game List once sync + /// settles, so callers may reassure the user rather than report failure. + case pendingSync + /// The user cancelled the join (only the link tap can); no message. + case cancelled + } + /// Fetches share metadata for a URL and joins via `acceptShare(metadata:)`. /// Used by the "Invited" section and the universal-link tap, where the /// share URL arrived in an `.invite` Ping or a tapped link rather than from /// the OS share-accept handler. - func acceptShare(url: URL) async throws { + @discardableResult + func acceptShare(url: URL) async throws -> AcceptOutcome { let metadata = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<CKShare.Metadata, Error>) in var found: CKShare.Metadata? @@ -59,10 +75,11 @@ final class CloudService { } ckContainer.add(op) } - try await acceptShare(metadata: metadata) + return try await acceptShare(metadata: metadata) } - func acceptShare(metadata: CKShare.Metadata) async throws { + @discardableResult + func acceptShare(metadata: CKShare.Metadata) async throws -> AcceptOutcome { NotificationCenter.default.post(name: .cloudShareAcceptanceStarted, object: nil) guard metadata.containerIdentifier == ckContainer.containerIdentifier else { @@ -99,7 +116,7 @@ final class CloudService { // The user tapped Cancel on the joining screen — don't pull them // into the game. The joined zone still surfaces in the Game List on // its own once sync settles. - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { return .cancelled } if let joinedGameID { try await shareController.confirmSeatAfterJoin(gameID: joinedGameID) } @@ -111,6 +128,7 @@ final class CloudService { if let joinedGameID, let onShareJoined { await onShareJoined(joinedGameID) } + return joinedGameID == nil ? .pendingSync : .opened } catch { syncMonitor.recordError("acceptShare", error) throw error