crossmate

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

commit c122f9035d95123d9f479a49dc896b45d6c7b4b9
parent bb617626d8923ec896dcc0d9acac0d0c30b9f1d1
Author: Michael Camilleri <[email protected]>
Date:   Tue, 16 Jun 2026 23:24:31 +0900

Fetch the accepted share zone directly

Joining a puzzle from a link could spend extra time discovering every
unknown shared zone before opening the one the user had just accepted.

This commit uses the accepted CKShare metadata's zone ID to fetch that
shared game's Game, Moves and Player records directly. The existing
full shared-zone discovery remains as the fallback for unexpected share
metadata, but normal link joins now avoid the broader discovery pass and
begin the playable-puzzle poll immediately instead of sleeping before
the first retry.

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

Diffstat:
MCrossmate/Services/CloudService.swift | 45+++++++++++++++++++++++++++++++++------------
MCrossmate/Sync/CloudQuery.swift | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 95 insertions(+), 12 deletions(-)

diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift @@ -94,8 +94,9 @@ final class CloudService { // This replaces a fragile before/after join diff that came up empty // whenever the game was already present — a re-tapped link, a sibling // device, or a directly-invited friend added by identity before Accept. + let sharedZoneID = metadata.share.recordID.zoneID let sharedGameID = RecordSerializer.gameID( - fromGameRecordName: metadata.share.recordID.zoneID.zoneName + fromGameRecordName: sharedZoneID.zoneName ) do { try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in @@ -103,16 +104,27 @@ final class CloudService { op.acceptSharesResultBlock = { result in cont.resume(with: result) } ckContainer.add(op) } - syncMonitor.note("Share accepted — discovering shared zone") - await syncMonitor.run("share-accept shared discovery") { - _ = try await syncEngine.discoverNewZonesDirect(scope: .shared) + if let sharedGameID { + syncMonitor.note("Share accepted — fetching shared game") + _ = try await syncEngine.fetchAcceptedSharedGameDirect( + gameID: sharedGameID, + zoneID: sharedZoneID + ) + } else { + syncMonitor.note("Share accepted — discovering shared zone") + await syncMonitor.run("share-accept shared discovery") { + _ = try await syncEngine.discoverNewZonesDirect(scope: .shared) + } } // Navigate once the game's puzzle has actually synced and is // playable. The caller holds the join placeholder up for the whole // of this call, so waiting here keeps the user on the joining screen // through a slow sync rather than dropping them back at the Game // List with an unopened game. - let joinedGameID = await waitForPlayablePuzzle(gameID: sharedGameID) + let joinedGameID = await waitForPlayablePuzzle( + gameID: sharedGameID, + zoneID: sharedZoneID + ) // 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. @@ -143,22 +155,31 @@ final class CloudService { /// Polls the just-joined game's own zone until its puzzle is playable, so /// the joining screen holds through a slow sync rather than dropping the - /// user back at the Game List. Discovery downloads the Game record and its - /// `puzzleSource` asset inline, so this returns on the first check in the - /// common case. Returns nil on timeout, or when the join `Task` is - /// cancelled (the user tapped Cancel) — the caller then doesn't navigate. - private func waitForPlayablePuzzle(gameID: UUID?) async -> UUID? { + /// user back at the Game List. The accepted-zone fetch downloads the Game + /// record and its `puzzleSource` asset inline, so this returns on the first + /// check in the common case. Returns nil on timeout, or when the join + /// `Task` is cancelled (the user tapped Cancel) — the caller then doesn't + /// navigate. + private func waitForPlayablePuzzle(gameID: UUID?, zoneID: CKRecordZone.ID?) async -> UUID? { guard let gameID else { return nil } if store.joinedSharedGameIDs().contains(gameID) { return gameID } let deadline = Date().addingTimeInterval(Self.joinSyncTimeout) while Date() < deadline { + if let zoneID { + _ = try? await syncEngine.fetchAcceptedSharedGameDirect( + gameID: gameID, + zoneID: zoneID + ) + } else { + _ = try? await syncEngine.fetchGameDirect(scope: .shared, gameID: gameID) + } + if store.joinedSharedGameIDs().contains(gameID) { return gameID } + do { try await Task.sleep(for: Self.joinSyncPollInterval) } catch { return nil // cancelled } - _ = try? await syncEngine.fetchGameDirect(scope: .shared, gameID: gameID) - if store.joinedSharedGameIDs().contains(gameID) { return gameID } } syncMonitor.note( "acceptShare: puzzle not playable within \(Int(Self.joinSyncTimeout))s " + diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift @@ -720,6 +720,68 @@ extension SyncEngine { return true } + /// Pulls a just-accepted shared game by the zone ID CloudKit returned in + /// the share metadata. This is the latency-sensitive join path: the game + /// is not known locally yet, so `fetchGameDirect(scope:gameID:)` cannot + /// find its zone, and a full shared-zone discovery would query every + /// unknown shared zone before opening the one the user just tapped. + @discardableResult + func fetchAcceptedSharedGameDirect(gameID: UUID, zoneID: CKRecordZone.ID) async throws -> Bool { + let database = container.sharedCloudDatabase + let gameRecordID = CKRecord.ID( + recordName: RecordSerializer.recordName(forGameID: gameID), + zoneID: zoneID + ) + + let gameResults: [CKRecord.ID: Result<CKRecord, Error>] + let moves: [CKRecord] + let players: [CKRecord] + do { + async let gameResultsTask = database.records( + for: [gameRecordID], + desiredKeys: ["title", "completedAt", "completedBy", "shareRecordName", "engagement", "notification", "puzzleSource"] + ) + async let movesTask = queryLiveRecords( + type: "Moves", + database: database, + zoneID: zoneID, + since: nil, + desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] + ) + async let playersTask = queryLiveRecords( + type: "Player", + database: database, + zoneID: zoneID, + since: nil, + desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "readThrough", "sessionSnapshot", "pushAddress"] + ) + (gameResults, moves, players) = try await (gameResultsTask, movesTask, playersTask) + } catch { + if isZoneNotFoundError(error) { return false } + throw error + } + + guard case .success(let game)? = gameResults[gameRecordID] else { + return false + } + + let records = moves + players + [game] + if let latestModification = records.compactMap(\.modificationDate).max() { + setLiveQueryCheckpoint(latestModification, scopeValue: 1, gameID: gameID) + } + + await applyDirectRecordZoneChanges( + records: records, + deletions: [], + scopeValue: 1 + ) + await trace( + "shared accepted-game fetch: \(gameID.uuidString.prefix(8)), " + + "game=1, moves=\(moves.count), players=\(players.count)" + ) + return true + } + nonisolated func isZoneNotFoundError(_ error: Error) -> Bool { let nsError = error as NSError return nsError.domain == CKErrorDomain &&