crossmate

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

commit fe6c93ba1a4a83722738ee1cc0c27241dde0d652
parent 24d7723a0939b8631669737bb1cff839f525bfa4
Author: Michael Camilleri <[email protected]>
Date:   Thu, 25 Jun 2026 19:55:39 +0900

Build the joined game from the invite to skip the share fetch

Accepting a friend's puzzle invitation held the joining screen while the
shared zone was fetched and its puzzleSource asset downloaded, even
though the recipient could reach the playable grid the moment the share
was accepted. A friend already syncs the pairwise friend zone, so the
invite can carry everything needed to play.

This commit serialises the puzzle's XD source into the invite Ping's
existing payload field and, on accept, builds the complete participant
game from it. constructJoinedGame derives the whole GameEntity from the
source — title, cached summary fields, puzzleResourceID — exactly as
createGame does, stamped with the share's zone identity. The canonical
Game record, Moves and Players then fetch in the background and merge
into the same row, so the join is playable after the accept round-trip
alone rather than after the shared-zone read.

The constructed row leaves ckSystemFields nil, so the first canonical
sync adopts the server etag and updates in place — matched by
ckRecordName in fetchOrCreate — instead of creating a duplicate, and it
enqueues no push because the participant does not own the zone. Invites
from older senders, share-link taps, and the OS share handler carry no
source and fall back to the inline fetch unchanged.

The puzzle source rides the payload field that already carries the
invite, so no CloudKit record changes shape; even an oversized Sunday is
a few KB, well under the per-record limit, and payload is not indexed.
InviteEntity gains a local, optional puzzleSource attribute so the
source survives the Ping being collected before the user accepts.

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

Diffstat:
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 1+
MCrossmate/Persistence/GameStore.swift | 39+++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/CloudService.swift | 51+++++++++++++++++++++++++++++++++++++++++++--------
MCrossmate/Services/InviteCoordinator.swift | 39++++++++++++++++++++++++++++++++++++---
MCrossmate/Sync/FriendController.swift | 6++++--
MCrossmate/Sync/FriendZone.swift | 16++++++++++++----
6 files changed, 135 insertions(+), 17 deletions(-)

diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -120,6 +120,7 @@ <attribute name="inviterAuthorID" attributeType="String"/> <attribute name="inviterName" optional="YES" attributeType="String" defaultValueString=""/> <attribute name="pingRecordName" attributeType="String"/> + <attribute name="puzzleSource" optional="YES" attributeType="String"/> <attribute name="shareURL" attributeType="String"/> <attribute name="status" attributeType="String" defaultValueString="pending"/> <fetchIndex name="byPingRecordName"> diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -954,6 +954,45 @@ final class GameStore { return gameID } + /// Builds a complete participant game (`databaseScope == 1`) from an + /// invite's serialised XD source, so a freshly-accepted shared game is + /// immediately playable and fully listed without waiting on the shared-zone + /// fetch. Everything derives from the source exactly as `createGame` does; + /// only the zone identity comes from the share. Unlike `createGame` it + /// enqueues no push — the participant doesn't own this zone — and it leaves + /// `ckSystemFields` nil, so the first canonical Game-record sync adopts the + /// server etag and updates this row in place (matched by `ckRecordName` in + /// `RecordSerializer.fetchOrCreate`) rather than creating a duplicate. + /// No-ops if a row for the game already exists — a sibling device or an + /// earlier sync got there first. + func constructJoinedGame(gameID: UUID, zoneID: CKRecordZone.ID, source: String) throws { + let recordName = "game-\(gameID.uuidString)" + let existing = NSFetchRequest<GameEntity>(entityName: "GameEntity") + existing.predicate = NSPredicate(format: "ckRecordName == %@", recordName) + existing.fetchLimit = 1 + if ((try? context.count(for: existing)) ?? 0) > 0 { return } + + let xd = try XD.parse(source) + let puzzle = Puzzle(xd: xd) + let now = Date() + let entity = GameEntity(context: context) + entity.id = gameID + entity.title = puzzle.title + entity.puzzleSource = source + entity.puzzleCmVersion = Int64(XD.currentCmVersion) + entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) + entity.createdAt = now + entity.updatedAt = now + entity.ckRecordName = recordName + entity.ckZoneName = zoneID.zoneName + entity.ckZoneOwnerName = + zoneID.ownerName == CKCurrentUserDefaultName ? nil : zoneID.ownerName + entity.databaseScope = 1 + entity.populateCachedSummaryFields(from: puzzle) + + try context.save() + } + // MARK: - Delete a game func deleteGame(id: UUID) throws { diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift @@ -52,7 +52,7 @@ final class CloudService { /// share URL arrived in an `.invite` Ping or a tapped link rather than from /// the OS share-accept handler. @discardableResult - func acceptShare(url: URL) async throws -> AcceptOutcome { + func acceptShare(url: URL, prefetchedPuzzleSource: String? = nil) async throws -> AcceptOutcome { let metadata = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<CKShare.Metadata, Error>) in var found: CKShare.Metadata? @@ -75,11 +75,14 @@ final class CloudService { } ckContainer.add(op) } - return try await acceptShare(metadata: metadata) + return try await acceptShare(metadata: metadata, prefetchedPuzzleSource: prefetchedPuzzleSource) } @discardableResult - func acceptShare(metadata: CKShare.Metadata) async throws -> AcceptOutcome { + func acceptShare( + metadata: CKShare.Metadata, + prefetchedPuzzleSource: String? = nil + ) async throws -> AcceptOutcome { NotificationCenter.default.post(name: .cloudShareAcceptanceStarted, object: nil) guard metadata.containerIdentifier == ckContainer.containerIdentifier else { @@ -105,11 +108,43 @@ final class CloudService { ckContainer.add(op) } if let sharedGameID { - syncMonitor.note("Share accepted — fetching shared game") - _ = try await syncEngine.fetchAcceptedSharedGameDirect( - gameID: sharedGameID, - zoneID: sharedZoneID - ) + // When the invite carried the puzzle source, build the playable + // game from it now and pull the canonical Game record, Moves and + // Players in the background — they merge into this same row + // (matched by record name) as a pure update. The user reaches + // the grid after just the accept round-trip rather than waiting + // on the shared-zone fetch. + var constructed = false + if let prefetchedPuzzleSource, !prefetchedPuzzleSource.isEmpty { + do { + try store.constructJoinedGame( + gameID: sharedGameID, + zoneID: sharedZoneID, + source: prefetchedPuzzleSource + ) + constructed = true + syncMonitor.note("Share accepted — built game from invite; fetching remainder in background") + } catch { + syncMonitor.note("acceptShare: invite-source construct failed — \(error); fetching inline") + } + } + if constructed { + Task { @MainActor [weak self] in + guard let self else { return } + _ = await self.syncMonitor.run("share-accept background remainder fetch") { + try await self.syncEngine.fetchAcceptedSharedGameDirect( + gameID: sharedGameID, + zoneID: sharedZoneID + ) + } + } + } else { + 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") { diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -78,7 +78,8 @@ final class InviteCoordinator { let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) req.fetchLimit = 1 - let title = (try? ctx.fetch(req).first)?.title ?? "" + let game = try? ctx.fetch(req).first + let title = game?.title ?? "" // Encode the grid silhouette the same way share links do, so the // recipient can preview the puzzle in their "Invited" row. `nil` when @@ -88,6 +89,14 @@ final class InviteCoordinator { GridSilhouette.encode(width: $0.width, height: $0.height, blocks: $0.blocks) } + // Carry the puzzle's XD source in the invite. The recipient already + // syncs the friend zone, so they receive it with the Ping — letting + // the accept path build a playable game without waiting on the shared + // zone fetch. Everything the recipient's GameEntity needs derives from + // this source (as in `GameStore.createGame`); the canonical Game record + // then merges in as a background update. + let puzzleSource = game?.puzzleSource + let url = try await shareController.addFriendParticipant( toGameID: gameID, userRecordName: friendAuthorID @@ -99,7 +108,8 @@ final class InviteCoordinator { inviterAuthorID: localAuthorID, inviterName: preferences.name, gameShareURL: url, - gridSilhouette: silhouette + gridSilhouette: silhouette, + puzzleSource: puzzleSource ) } @@ -195,6 +205,7 @@ final class InviteCoordinator { invite.inviterName = ping.playerName invite.shareURL = payload.gameShareURL invite.gridSilhouette = payload.gridSilhouette + invite.puzzleSource = payload.puzzleSource invite.pingRecordName = ping.recordName invite.status = "pending" invite.createdAt = Date() @@ -280,9 +291,17 @@ final class InviteCoordinator { guard let url = URL(string: shareURL) else { throw FriendController.FriendError.missingShareURLInPayload } + // The invite carried the puzzle's XD source, so hand it to the accept + // path: it builds a playable game from this without waiting on the + // shared-zone fetch. nil for invites from older senders or already + // consumed rows — the accept path then fetches as before. + let prefetchedPuzzleSource = puzzleSource(forPingRecordName: pingRecordName) let outcome: CloudService.AcceptOutcome do { - outcome = try await cloudService.acceptShare(url: url) + outcome = try await cloudService.acceptShare( + url: url, + prefetchedPuzzleSource: prefetchedPuzzleSource + ) } catch let error as CKError where error.code == .unknownItem { // Stale share: the row needs to go away too, but the next // `applyInvitePings` will GC it if this cleanup itself fails. @@ -324,6 +343,20 @@ final class InviteCoordinator { try await acceptInvite(shareURL: shareURL, pingRecordName: pingRecordName) } + /// The XD source recorded on the durable invite for `pingRecordName`, if + /// the inviting build carried one. Read just before acceptance so the + /// accept path can construct a playable game without the shared-zone fetch. + private func puzzleSource(forPingRecordName pingRecordName: String) -> String? { + let ctx = persistence.viewContext + let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + req.predicate = NSPredicate(format: "pingRecordName == %@", pingRecordName) + req.fetchLimit = 1 + guard let source = (try? ctx.fetch(req))?.first?.puzzleSource, !source.isEmpty else { + return nil + } + return source + } + private func deleteInviteAndPing(pingRecordName: String) async throws { let ctx = persistence.viewContext let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -256,7 +256,8 @@ final class FriendController { inviterAuthorID: String, inviterName: String, gameShareURL: URL, - gridSilhouette: String? = nil + gridSilhouette: String? = nil, + puzzleSource: String? = nil ) async throws { let ctx = persistence.viewContext let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") @@ -272,7 +273,8 @@ final class FriendController { let payload = FriendZone.InvitePayload( gameShareURL: gameShareURL.absoluteString, - gridSilhouette: gridSilhouette + gridSilhouette: gridSilhouette, + puzzleSource: puzzleSource ) guard let encoded = payload.encodedString() else { throw FriendError.payloadEncodingFailed diff --git a/Crossmate/Sync/FriendZone.swift b/Crossmate/Sync/FriendZone.swift @@ -82,13 +82,21 @@ enum FriendZone { /// the silhouette segment carried in share links. `nil` for non-square /// grids (which get no preview) and for invites from older senders. let gridSilhouette: String? + /// The puzzle's full XD source. The recipient already syncs the friend + /// zone, so this arrives with the Ping and lets the accept path build a + /// playable game immediately — the shared-zone fetch then only updates + /// it. Even an oversized Sunday is a few KB, well under CloudKit's + /// per-record limit, and `payload` is not an indexed field. `nil` for + /// invites from older senders, which fall back to the fetch. + let puzzleSource: String? - // An explicit init keeps `gridSilhouette` out of the inline default - // (which would exclude it from Codable) while letting existing call - // sites omit it; a missing JSON key decodes to `nil`. - init(gameShareURL: String, gridSilhouette: String? = nil) { + // An explicit init keeps the optionals out of the inline default + // (which would exclude them from Codable) while letting existing call + // sites omit them; a missing JSON key decodes to `nil`. + init(gameShareURL: String, gridSilhouette: String? = nil, puzzleSource: String? = nil) { self.gameShareURL = gameShareURL self.gridSilhouette = gridSilhouette + self.puzzleSource = puzzleSource } func encodedString() -> String? {