crossmate

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

commit c3f601eb3ebb0df25ca9317e12ed2de612f5bd0a
parent b58a2b4e7c02c6c7e73aadc8378fb528555dc0f6
Author: Michael Camilleri <[email protected]>
Date:   Sat, 15 Aug 2026 17:34:12 +0900

Hand an open puzzle over to its Chronicle when its zone retires

A finished shared puzzle left open when the owner retired its zone came
back as an error: the user resumed into the sticky, input-blocking
'Puzzle Not Shared' banner and had to back out and find the Chronicle in
the Game List themselves. The zone deletion is expected — the owner
retires it once every participant has chronicled the game, which this
device had already done.

This commit hands the open puzzle over instead. promoteRevoked returns
the Chronicle's game id, and the new
NotificationNavigationBroker.replaceOpenGame substitutes it at the top
of the navigation stack in one assignment with animations disabled, so
the puzzle is replaced rather than popped and pushed. The banner is now
reserved for revocations that are genuinely news: an unfinished game, or
a finished one with no complete Chronicle behind it.

The handover requires a stored Chronicle reporting its replay as
available, since a zone also retires once the fourteen-day retry window
lapses and a device away that long holds a grid that never caught up.
Retiring the live row under a mounted PuzzleView would strand it on a
dead GameEntity, so it is marked isSupersededByChronicle and swept from
gameListAppeared instead.

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

Diffstat:
MCrossmate/CrossmateApp.swift | 31+++++++++++++++++++++++++++----
MCrossmate/Services/AppServices.swift | 50++++++++++++++++++++++++++++++++++++++++++++------
MCrossmate/Sync/GameArchiver.swift | 99++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
MCrossmate/Views/GameList/GameListView.swift | 2+-
4 files changed, 158 insertions(+), 24 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -424,6 +424,11 @@ final class NotificationNavigationBroker { var onOpenInviteInGameList: ((UUID) -> Void)? { didSet { flushPendingInviteGameListOpen() } } + /// Swaps the puzzle on top of the stack for another row representing the + /// same puzzle — the live game → Chronicle handover. Deliberately unbuffered, + /// unlike the open handlers above: with no handler installed there is no + /// navigation stack showing that puzzle, so there is nothing to correct. + var onReplaceOpenGame: ((_ from: UUID, _ to: UUID) -> Void)? private var pendingGameIDs: [UUID] = [] private var pendingGameListOpen = false @@ -439,6 +444,10 @@ final class NotificationNavigationBroker { onOpenGame(gameID) } + func replaceOpenGame(_ from: UUID, with to: UUID) { + onReplaceOpenGame?(from, to) + } + func openGameList(inviteGameID: UUID? = nil) { if let inviteGameID { guard let onOpenInviteInGameList else { @@ -599,7 +608,7 @@ struct RootView: View { let appDelegate: AppDelegate @Environment(\.scenePhase) private var scenePhase - @State private var navigationPath = NavigationPath() + @State private var navigationPath: [UUID] = [] @State private var pendingJoin: PendingJoinPlaceholder? @State private var pendingInviteNotificationGameID: UUID? /// The in-flight share-accept driven by a tapped link, retained so the @@ -656,19 +665,33 @@ struct RootView: View { NotificationState.setActivePuzzleID(nil) NotificationNavigationBroker.shared.onOpenGame = { gameID in UIApplication.shared.dismissPresentedViewControllers() - navigationPath = NavigationPath() + navigationPath = [] navigationPath.append(gameID) } NotificationNavigationBroker.shared.onOpenGameList = { UIApplication.shared.dismissPresentedViewControllers() - navigationPath = NavigationPath() + navigationPath = [] pendingInviteNotificationGameID = nil } NotificationNavigationBroker.shared.onOpenInviteInGameList = { gameID in UIApplication.shared.dismissPresentedViewControllers() - navigationPath = NavigationPath() + navigationPath = [] pendingInviteNotificationGameID = gameID } + NotificationNavigationBroker.shared.onReplaceOpenGame = { from, to in + // Only the puzzle actually on top is swapped; anything deeper (or + // a stack the user has since left) is left alone. + guard navigationPath.last == from else { return } + // One assignment, so the stack goes straight from the live game + // to its Chronicle rather than popping and pushing. Animations + // off for the same reason: this is a substitution of one + // representation for another, not a navigation the user made. + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + navigationPath[navigationPath.count - 1] = to + } + } // A tapped Crossmate share link (universal link), routed here by the // `SceneDelegate` through `ShareLinkBroker` — `.onContinueUserActivity` // never fires once a custom scene delegate is installed. Show the diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1257,15 +1257,38 @@ final class AppServices { // Supersede any pending catch-up banner: advancing the view baseline // to now leaves nothing for the next open to diff against. gameViewedStore.advance(Date(), forGame: gameID) + // The owner deleted the shared zone. For a *finished* game, swap the + // revoked tombstone for a durable owned copy rebuilt from the + // private-zone archive; in-progress games are left as revoked rows. + // + // With that puzzle on screen the live row is kept (and hidden) rather + // than deleted, so the mounted view isn't left on a dead + // `GameEntity`; the open puzzle is then handed over to the Chronicle, + // which shows its own load rather than a banner over a dead game. + let isOnScreen = self?.isPuzzleOnScreen(gameID: gameID) == true + let chronicleID = await gameArchiver.promoteRevoked( + gameID: gameID, + retiringLiveRow: !isOnScreen + ) + if let chronicleID, isOnScreen { + NotificationNavigationBroker.shared.replaceOpenGame( + gameID, + with: chronicleID + ) + } // Surface the revocation as a sticky, input-blocking banner on // the open puzzle, replacing the former AccessRevokedBanner // overlay. Game-scoped, so it only shows for this puzzle. - announcements.post(.accessRevoked(gameID: gameID)) - // The owner deleted the shared zone. For a *finished* game, swap the - // revoked tombstone for a durable owned copy rebuilt from the - // private-zone archive (and from the still-present local data); - // in-progress games are left as revoked rows. - await gameArchiver.promoteRevoked(gameID: gameID) + // + // A completed handover earns no banner: the zone is retired by design + // once every participant has chronicled the game, so a finished puzzle + // passing to a complete Chronicle is not news. Everything else is — + // an unfinished game, or one whose Chronicle never completed before + // the owner hit the retention deadline, which leaves a revoked row + // the user can still open and `OpenPuzzleBanner` re-banners. + if chronicleID == nil { + announcements.post(.accessRevoked(gameID: gameID)) + } await self?.accountPush.reconcilePushRegistration() } @@ -1495,8 +1518,23 @@ final class AppServices { } } + /// Whether `gameID` is the puzzle currently pushed on screen. `currentEntity` + /// alone is stale after a close (nothing clears it), so it is paired with the + /// Game List's own visibility: the list is showing exactly when no puzzle is. + /// Backgrounding doesn't fire either view's appearance callbacks, so a puzzle + /// left open while the app is away still reads as on screen — which is the + /// case that matters here. + private func isPuzzleOnScreen(gameID: UUID) -> Bool { + !isGameListVisible && store.currentEntity?.id == gameID + } + func gameListAppeared() async { isGameListVisible = true + // Land any Chronicle swap that was deferred while its puzzle was on + // screen, before the list renders, so the row goes straight from live + // game to Chronicle without a revoked state in between. Also the + // cold-launch backstop for a revocation that arrived while terminated. + await gameArchiver.promoteRevokedCompleted() await freshenGameList(reason: .appeared) } diff --git a/Crossmate/Sync/GameArchiver.swift b/Crossmate/Sync/GameArchiver.swift @@ -429,29 +429,102 @@ final class GameArchiver { } } + /// Sweeps every finished game whose shared zone has gone, promoting each to + /// its Chronicle and retiring the live row behind it. Catches up the rows + /// `promoteRevoked` left standing because their puzzle was on screen, and + /// backstops both a revocation the app never got to act on because it was + /// terminated and one whose Chronicle could not be read at the time. + func promoteRevokedCompleted() async { + let ctx = persistence.container.newBackgroundContext() + let ids: [UUID] = await ctx.perform { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate( + format: "completedAt != nil AND isAccessRevoked == YES " + + "AND ckRecordName BEGINSWITH %@", + "game-" + ) + return ((try? ctx.fetch(req)) ?? []).compactMap(\.id) + } + for id in ids { + await promoteRevoked(gameID: id) + } + } + /// Promotes a participant's private archive when the owner retires the live - /// shared zone. Falls back to the local snapshot only when CloudKit has not - /// delivered the compact record yet. - func promoteRevoked(gameID: UUID) async { + /// shared zone, returning the Chronicle's game id when the handover lands. + /// + /// Only a *complete* history earns the handover. The owner normally retires + /// the zone on quorum — every participant having chronicled the game, which + /// each only does once its own Chronicle saved complete — but it also retires + /// unconditionally once `archiveRetryWindow` has passed since completion. A + /// device that was away for that whole stretch can reach here holding a grid + /// that never caught up, and `Archive.merging` fills a Chronicle's frozen + /// cells from the local rows alone: peer journals extend the replay, never + /// the board. Freezing that would present a half-filled grid as a finished + /// puzzle, with the zone gone and no way back. Those stay revoked rows, a + /// state the user is actually shown. + /// + /// `retiringLiveRow` is false while that puzzle is on screen. The Chronicle + /// is materialized either way — the caller hands the open view over to it — + /// but deleting the row underneath a mounted `PuzzleView` would strand it on + /// a dead `GameEntity`, so the live row is hidden now and swept later by + /// `promoteRevokedCompleted`. + @discardableResult + func promoteRevoked(gameID: UUID, retiringLiveRow: Bool = true) async -> UUID? { + // A game this device never saw finish is a genuine mid-play revocation, + // whatever the account's Chronicle says; leave it as a revoked row. let ctx = persistence.container.newBackgroundContext() - let local: Archive.Snapshot? = await ctx.perform { + let isCompleted = await ctx.perform { let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) req.fetchLimit = 1 - guard (try? ctx.fetch(req).first)?.completedAt != nil else { return nil } - return Archive.snapshot(forGameID: gameID, in: ctx) + return (try? ctx.fetch(req).first)?.completedAt != nil } - guard let local else { return } - let payload = await fetchArchive(originalGameID: gameID)?.payload - ?? Archive.payload(from: local, replayState: .unavailable) + guard isCompleted else { return nil } + + // The stored Chronicle is the only witness worth trusting. An + // acknowledgement would seem to be a second one, but a device that read + // a *sibling* device's complete Chronicle acknowledges too, without its + // own cells ever catching up — so it attests to the account's history, + // not this row's. And an acknowledgement implies a complete Chronicle + // was written (`reconcileArchive` bails before acking if the write + // fails), so reading it back costs nothing but a retry when offline. + guard let payload = await fetchArchive(originalGameID: gameID)?.payload, + payload.replayState == .available + else { + syncMonitor?.note( + "archive \(gameID.uuidString.prefix(8)): no complete Chronicle to " + + "hand over to; staying revoked" + ) + return nil + } + let promoteCtx = persistence.container.newBackgroundContext() - await promoteCtx.perform { - guard Archive.materialize(payload, in: promoteCtx) != nil else { return } + return await promoteCtx.perform { + guard let chronicle = Archive.materialize(payload, in: promoteCtx), + let chronicleID = chronicle.id + else { return nil } let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) req.fetchLimit = 1 - if let original = try? promoteCtx.fetch(req).first { promoteCtx.delete(original) } - if promoteCtx.hasChanges { try? promoteCtx.save() } + if let original = try? promoteCtx.fetch(req).first { + if retiringLiveRow { + promoteCtx.delete(original) + } else { + // Keep the row for the mounted view to finish with, but take + // it out of the library now: the Chronicle is already the + // visible representation, and they share a list identity. + original.isSupersededByChronicle = true + } + } + if promoteCtx.hasChanges { + do { + try promoteCtx.save() + } catch { + return nil + } + } + return chronicleID } } diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift @@ -12,7 +12,7 @@ struct GameListView: View { let onDisappear: () -> Void let onAcceptInvite: ((String, String, GridSilhouette.Grid?) async throws -> Void)? @Binding var pendingInviteNotificationGameID: UUID? - @Binding var navigationPath: NavigationPath + @Binding var navigationPath: [UUID] @Environment(\.accessibilityVoiceOverEnabled) private var isVoiceOverEnabled @Environment(\.dynamicTypeSize) private var dynamicTypeSize