crossmate

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

commit a2b0b2e78467bb73a7ef000440f8b1f457764517
parent d9740405b3121ff93dd3c40636a0975fed26a356
Author: Michael Camilleri <[email protected]>
Date:   Thu, 21 May 2026 22:17:58 +0900

Show banners when a puzzle's game is revoked or removed

This commit fixes the access-revoked banner failing to appear when a shared
puzzle is reopened after its owner revoked access. The banner was posted only
on the live not-revoked to revoked transition, into the in-memory
AnnouncementCenter, which does not survive a process restart — so a puzzle
revoked in an earlier launch reopened silently even though its read-only state
persisted. Opening a puzzle now reconciles its banners from persisted game
state through OpenPuzzleBanner, a CaseIterable enum that maps an
OpenPuzzleState to the announcements that state warrants.

It also surfaces a banner when a game is hard-deleted out from under an open
puzzle — a private zone deletion, a private send failing with ZoneNotFound, or
a 'left' Decision synced from another device. handleRemoteRemoval now reports
whether the removed game was the one on screen; when it is, a sticky,
input-blocking, game-scoped banner freezes that puzzle until the user leaves
it. Removals of off-screen puzzles stay silent and simply drop from the game
list.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/CrossmateApp.swift | 12++++++++++++
MCrossmate/Persistence/GameStore.swift | 15++++++++++-----
MCrossmate/Services/AnnouncementCenter.swift | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/AppServices.swift | 11+++++++++--
MTests/Unit/AnnouncementCenterTests.swift | 17+++++++++++++++++
ATests/Unit/OpenPuzzleBannerTests.swift | 34++++++++++++++++++++++++++++++++++
7 files changed, 138 insertions(+), 7 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -63,6 +63,7 @@ 89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */; }; 8B356C953DA0FAF149C3391A /* Puzzles in Resources */ = {isa = PBXBuildFile; fileRef = BA67C509B467132D1B7510A4 /* Puzzles */; }; 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; }; + 903681985C17FCB5F97773A9 /* OpenPuzzleBannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */; }; 91703E54DB4679C1911BF994 /* Moves.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86470163BFF956F3DE438506 /* Moves.swift */; }; 9582AA583F5EA008FFC82B64 /* ZoneOrphaningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */; }; 9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0BF60C84D92A9024AC1A53FC /* Media.xcassets */; }; @@ -202,6 +203,7 @@ 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; }; 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdaterTests.swift; sourceTree = "<group>"; }; A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; }; + A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenPuzzleBannerTests.swift; sourceTree = "<group>"; }; A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoneOrphaningTests.swift; sourceTree = "<group>"; }; ACC295195602B3DDF7BB3895 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; }; AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleView.swift; sourceTree = "<group>"; }; @@ -306,6 +308,7 @@ 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */, ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */, C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */, + A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */, D491B7232333AA8957732387 /* PendingEditFlagTests.swift */, 5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */, 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */, @@ -582,6 +585,7 @@ AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */, 18D5BB584DBF92A2EC580AEA /* NotificationNavigationBrokerTests.swift in Sources */, E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */, + 903681985C17FCB5F97773A9 /* OpenPuzzleBannerTests.swift in Sources */, C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */, A458AF9CA8579AB51B695B08 /* PendingChangeReapTests.swift in Sources */, 8225918652DCC822CA1C862F /* PendingEditFlagTests.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -529,6 +529,18 @@ private struct PuzzleDisplayView: View { await loadedRoster.refresh() guard !Task.isCancelled, session === loadedSession else { return } + // Re-derive banners that hang off persisted game state (e.g. the + // access-revoked banner). They are otherwise posted only on the live + // sync transition that first produces them, which a puzzle opened in + // a later process never re-fires. + let openState = OpenPuzzleState( + gameID: gameID, + isAccessRevoked: loadedSession.mutator.isAccessRevoked + ) + for announcement in OpenPuzzleBanner.announcements(for: openState) { + services.announcements.post(announcement) + } + if isShared && preferences.isICloudSyncEnabled { services.syncMonitor.note( "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded shared roster" diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -828,16 +828,21 @@ final class GameStore { /// Called after the sync engine deletes a `GameEntity` in response to a /// remote private-DB zone deletion (the user removed this game on another /// device). The deletion itself has already been merged into the view - /// context; this method's only job is to drop the active references if - /// the open puzzle is the one that just disappeared, so the UI doesn't - /// dereference a deleted managed object. - func handleRemoteRemoval(gameID: UUID) { - if currentEntity?.id == gameID { + /// context; this method's job is to drop the active references if the + /// open puzzle is the one that just disappeared, so the UI doesn't + /// dereference a deleted managed object. Returns whether the removed game + /// was the one currently open, so the caller can surface an in-puzzle + /// notice only when there is a puzzle on screen to host it. + @discardableResult + func handleRemoteRemoval(gameID: UUID) -> Bool { + let wasOpen = currentEntity?.id == gameID + if wasOpen { currentGame = nil currentMutator = nil currentEntity = nil } onUnreadOtherMovesChanged?() + return wasOpen } /// Flips the active game's mutator to shared after `ShareController` diff --git a/Crossmate/Services/AnnouncementCenter.swift b/Crossmate/Services/AnnouncementCenter.swift @@ -89,6 +89,58 @@ extension Announcement { blocksInput: true ) } + + /// The sticky, input-blocking banner shown when the open puzzle's game is + /// hard-deleted out from under it — a solo puzzle's private zone vanished, + /// or a shared puzzle was left on another device. Game-scoped, so it only + /// surfaces in that puzzle's header, never the game list; the caller posts + /// it only when the removed game is the one on screen. The puzzle stays + /// open and frozen until the user backs out — by then it is already gone + /// from the list. + static func gameRemoved(gameID: UUID) -> Announcement { + Announcement( + id: "game-removed-\(gameID.uuidString)", + scope: .game(gameID), + severity: .error, + body: "This puzzle was removed.", + dismissal: .sticky, + blocksInput: true + ) + } +} + +/// The persisted, open-relevant facts about a game — the input to +/// `OpenPuzzleBanner.announcements(for:)`. Grouped into a value so the +/// reconciler can be unit-tested without standing up a `GameMutator`. +struct OpenPuzzleState { + let gameID: UUID + let isAccessRevoked: Bool +} + +/// A banner that may be (re)posted when a puzzle is opened, reconciled from +/// *persisted* game state rather than a live sync transition. Each such +/// banner is otherwise posted only on the event that first produces it, into +/// an in-memory `AnnouncementCenter` that does not survive a process restart +/// — so a puzzle opened in a later process would show nothing. Extend by +/// adding a case and its switch arm. +enum OpenPuzzleBanner: CaseIterable { + case accessRevoked + + /// The announcement this banner contributes for `state`, or `nil` when + /// the state does not warrant it. + func announcement(for state: OpenPuzzleState) -> Announcement? { + switch self { + case .accessRevoked: + state.isAccessRevoked ? .accessRevoked(gameID: state.gameID) : nil + } + } + + /// Every banner to (re)post for a puzzle opened in `state`. The caller + /// posts each one; `AnnouncementCenter.post` is idempotent by id, so + /// re-posting a banner that is already showing is a no-op. + static func announcements(for state: OpenPuzzleState) -> [Announcement] { + allCases.compactMap { $0.announcement(for: state) } + } } /// Single source of truth for transient banner-style status messages. Holds diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -310,9 +310,16 @@ final class AppServices { announcements.post(.accessRevoked(gameID: gameID)) } - await syncEngine.setOnGameRemoved { [store, sessionMonitor] gameID in - store.handleRemoteRemoval(gameID: gameID) + await syncEngine.setOnGameRemoved { [store, sessionMonitor, announcements] gameID in + let wasOpen = store.handleRemoteRemoval(gameID: gameID) await sessionMonitor.cancel(gameID: gameID) + // A hard-deleted game (private zone gone, or a shared game left + // elsewhere) only needs UI when its puzzle is on screen: a sticky, + // input-blocking banner freezes the now-orphaned puzzle until the + // user backs out. Off-screen removals just drop from the list. + if wasOpen { + announcements.post(.gameRemoved(gameID: gameID)) + } } // A sibling device consumed (deleted) a directed ping; withdraw any diff --git a/Tests/Unit/AnnouncementCenterTests.swift b/Tests/Unit/AnnouncementCenterTests.swift @@ -157,4 +157,21 @@ struct AnnouncementCenterTests { // Game-scoped: it must not block input on an unrelated puzzle. #expect(!center.isInputBlocked(forGame: UUID())) } + + @Test("The game-removed announcement is a sticky, game-scoped, input-blocking banner") + func gameRemovedAnnouncementBlocksInput() { + let center = AnnouncementCenter() + let gameID = UUID() + let announcement = Announcement.gameRemoved(gameID: gameID) + #expect(announcement.scope == .game(gameID)) + #expect(announcement.severity == .error) + #expect(announcement.dismissal == .sticky) + #expect(announcement.blocksInput) + center.post(announcement) + #expect(center.current(forGame: gameID)?.id == announcement.id) + #expect(center.isInputBlocked(forGame: gameID)) + // Game-scoped: never on the game list, never on an unrelated puzzle. + #expect(center.currentGlobal() == nil) + #expect(!center.isInputBlocked(forGame: UUID())) + } } diff --git a/Tests/Unit/OpenPuzzleBannerTests.swift b/Tests/Unit/OpenPuzzleBannerTests.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing + +@testable import Crossmate + +/// Covers the open-time banner reconciler. The access-revoked banner is +/// otherwise posted only on the live revocation transition; a puzzle revoked +/// in an earlier process must still surface it when reopened. +@Suite("OpenPuzzleBanner") +struct OpenPuzzleBannerTests { + + @Test("Opening a revoked game yields the sticky access-revoked banner") + func revokedGameYieldsBanner() throws { + let gameID = UUID() + let announcements = OpenPuzzleBanner.announcements( + for: OpenPuzzleState(gameID: gameID, isAccessRevoked: true) + ) + #expect(announcements.count == 1) + let banner = try #require(announcements.first) + #expect(banner.id == "access-revoked-\(gameID.uuidString)") + #expect(banner.scope == .game(gameID)) + #expect(banner.severity == .error) + #expect(banner.dismissal == .sticky) + #expect(banner.blocksInput) + } + + @Test("Opening a game with access intact yields no banners") + func intactGameYieldsNothing() { + let announcements = OpenPuzzleBanner.announcements( + for: OpenPuzzleState(gameID: UUID(), isAccessRevoked: false) + ) + #expect(announcements.isEmpty) + } +}