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:
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)
+ }
+}