crossmate

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

commit b8525fe9112789ca00fc17fa05eb1f9435e626ba
parent a1d4e680ad9511d79fb402403776a79ea9ea3b3d
Author: Michael Camilleri <[email protected]>
Date:   Tue, 28 Apr 2026 12:46:35 +0900

Fix handling of shared games from links

Following a link to a shared game launches Crossmate but does not
successfully add the game to the player's list of in-progress games.
This commit attempts to fix that.

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

Diffstat:
MCrossmate/CrossmateApp.swift | 38++++++++++++++++++++++++++++++++++++--
MCrossmate/Persistence/GameStore.swift | 12++++++++++++
MCrossmate/Services/CloudService.swift | 16++++++++++++++++
3 files changed, 64 insertions(+), 2 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -32,7 +32,10 @@ struct CrossmateApp: App { final class AppDelegate: NSObject, UIApplicationDelegate, @unchecked Sendable { var onRemoteNotification: (() async -> Void)? - var onAcceptShare: ((CKShare.Metadata) async -> Void)? + var onAcceptShare: ((CKShare.Metadata) async -> Void)? { + didSet { flushPendingAcceptedShares() } + } + private var pendingAcceptedShares: [CKShare.Metadata] = [] func application( _ application: UIApplication, @@ -54,7 +57,20 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @unchecked Sendable { _ application: UIApplication, userDidAcceptCloudKitShareWith metadata: CKShare.Metadata ) { - Task { await onAcceptShare?(metadata) } + guard let onAcceptShare else { + pendingAcceptedShares.append(metadata) + return + } + Task { await onAcceptShare(metadata) } + } + + private func flushPendingAcceptedShares() { + guard let onAcceptShare, !pendingAcceptedShares.isEmpty else { return } + let metadatas = pendingAcceptedShares + pendingAcceptedShares.removeAll() + for metadata in metadatas { + Task { await onAcceptShare(metadata) } + } } } @@ -93,6 +109,13 @@ struct RootView: View { navigationPath.append(id) } } + .onReceive(NotificationCenter.default.publisher(for: .cloudShareAcceptanceStarted)) { _ in + UIApplication.shared.dismissPresentedViewControllers() + } + .onReceive(NotificationCenter.default.publisher(for: .cloudShareAcceptanceCompleted)) { notification in + guard let gameID = notification.userInfo?["gameID"] as? UUID else { return } + navigationPath.append(gameID) + } .onChange(of: scenePhase) { _, newPhase in switch newPhase { case .active: @@ -106,6 +129,17 @@ struct RootView: View { } } +private extension UIApplication { + func dismissPresentedViewControllers() { + for scene in connectedScenes { + guard let windowScene = scene as? UIWindowScene else { continue } + for window in windowScene.windows where window.isKeyWindow { + window.rootViewController?.dismiss(animated: true) + } + } + } +} + // MARK: - Game Destination /// Loads a game when navigated to. diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -336,6 +336,18 @@ final class GameStore { return (try? context.fetch(request).first?.id) } + /// Returns joined CloudKit-share games that have a usable puzzle payload. + /// Placeholders created from shared-zone discovery are intentionally + /// excluded until the root Game record has arrived. + func joinedSharedGameIDs() -> Set<UUID> { + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate( + format: "databaseScope == 1 AND puzzleSource != nil AND puzzleSource != %@", + "" + ) + return Set(((try? context.fetch(request)) ?? []).compactMap(\.id)) + } + // MARK: - Create a new game /// Creates a new game from XD source text. Returns the new game's UUID. diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift @@ -1,5 +1,10 @@ import CloudKit +extension Notification.Name { + static let cloudShareAcceptanceStarted = Notification.Name("cloudShareAcceptanceStarted") + static let cloudShareAcceptanceCompleted = Notification.Name("cloudShareAcceptanceCompleted") +} + @MainActor final class CloudService { private let ckContainer: CKContainer @@ -20,6 +25,8 @@ final class CloudService { } func acceptShare(metadata: CKShare.Metadata) async { + NotificationCenter.default.post(name: .cloudShareAcceptanceStarted, object: nil) + guard metadata.containerIdentifier == ckContainer.containerIdentifier else { syncMonitor.note( "acceptShare: container mismatch — metadata=\(metadata.containerIdentifier) " + @@ -27,6 +34,7 @@ final class CloudService { ) return } + let existingJoinedGameIDs = store.joinedSharedGameIDs() do { try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in let op = CKAcceptSharesOperation(shareMetadatas: [metadata]) @@ -37,6 +45,14 @@ final class CloudService { await syncMonitor.run("share-accept fetch") { try await syncEngine.fetchChanges() } + let joinedGameID = store.joinedSharedGameIDs() + .subtracting(existingJoinedGameIDs) + .first + NotificationCenter.default.post( + name: .cloudShareAcceptanceCompleted, + object: nil, + userInfo: joinedGameID.map { ["gameID": $0] } + ) } catch { syncMonitor.recordError("acceptShare", error) }