commit 9cffe6dde15b02b312bf36858aa02ea0aeab299d
parent 98a82ded559cc63e190ae12176cc0757be9ca45e
Author: Michael Camilleri <[email protected]>
Date: Sat, 18 Jul 2026 15:18:40 +0900
Make access revocation a local mutation gate
Revocation previously only suppressed the durable and sync emit: an open
puzzle whose access had been revoked still mutated the in-memory grid
through the toolbar's Entry and Hints menus and the VoiceOver
check/reveal actions, so a revoked participant could reveal the last
squares, trigger a local solve, and persist completedAt — turning the
read-only revoked copy into a terminal completed game.
This commit blocks the mutation itself. GameMutator now derives a single
isEditable predicate — not completed, not revoked, sync protocol
supported — and every mutating entry point guards on it, including the
single-cell writes and bulk check/reveal/clear paths that previously
checked only completion. persistCompletion and resignGame likewise
refuse revoked games, so no path can latch a revoked copy into a
completed state even if a solve event fires.
The editing surfaces now disable from the same predicate: the Entry,
Hints, pencil, Nudge and Resign controls, the hardware-key handler and
app menu, and the VoiceOver check/reveal actions. PuzzleView reads the
mutator's flag directly rather than relying on the 'Puzzle Not Shared'
banner having been posted; the Players menu stays available so a revoked
participant can still see the roster and leave the puzzle.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
8 files changed, 205 insertions(+), 30 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -43,6 +43,7 @@
1AAFF86B40CBBFF1EC9ADF9F /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B1F07B5DDE2A8B49B28392A /* GridThumbnailView.swift */; };
1D08DDEDEF5433912CC6D4DB /* GameViewedStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9406C27662147CD3C0783644 /* GameViewedStoreTests.swift */; };
1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; };
+ 205894D447BFE2CCEB07575D /* GameStoreRevocationLockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8E8D1BC7DF9EBB08F8CABD /* GameStoreRevocationLockTests.swift */; };
24F7ED458A1C09F8CF309B35 /* PuzzleNotificationText+GameEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DF9C2FEF0D3584864DFC967 /* PuzzleNotificationText+GameEntity.swift */; };
2571BA6482B3E896A80FF393 /* CompactSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B024B2FFB11E51E9724BBE23 /* CompactSlider.swift */; };
262A9CE8C3CB93869190CFF1 /* GameStoreMergedAuthorCellsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */; };
@@ -485,6 +486,7 @@
DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; };
DB851649DE78AAAC5A928C52 /* Square.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Square.swift; sourceTree = "<group>"; };
DBD2570A5A3573D66B3C4A52 /* GameRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameRowView.swift; sourceTree = "<group>"; };
+ DD8E8D1BC7DF9EBB08F8CABD /* GameStoreRevocationLockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreRevocationLockTests.swift; sourceTree = "<group>"; };
E18FF14E0D73B0D2DB427F08 /* JoiningPuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoiningPuzzleView.swift; sourceTree = "<group>"; };
E25A040EA4DC9672C895A7AC /* InviteEntity+DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InviteEntity+DisplayName.swift"; sourceTree = "<group>"; };
E2ED0D601BB574618C15B5EF /* ShareLinkRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLinkRoute.swift; sourceTree = "<group>"; };
@@ -607,6 +609,7 @@
09EA25E2AE98ACD029EC0129 /* GameStoreContributingDevicesTests.swift */,
122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */,
9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */,
+ DD8E8D1BC7DF9EBB08F8CABD /* GameStoreRevocationLockTests.swift */,
31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */,
E5EEBF169823C172000FC45B /* GameSummaryThumbnailTests.swift */,
9406C27662147CD3C0783644 /* GameViewedStoreTests.swift */,
@@ -1067,6 +1070,7 @@
4D7BF839BA71E1BF0AE9BCE9 /* GameStoreContributingDevicesTests.swift in Sources */,
262A9CE8C3CB93869190CFF1 /* GameStoreMergedAuthorCellsTests.swift in Sources */,
2C5A15054CCCBF9FD626AFBB /* GameStorePushAddressTests.swift in Sources */,
+ 205894D447BFE2CCEB07575D /* GameStoreRevocationLockTests.swift in Sources */,
449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */,
7714B1C2FBCBBAD9BE8FEAF8 /* GameSummaryThumbnailTests.swift in Sources */,
1D08DDEDEF5433912CC6D4DB /* GameViewedStoreTests.swift in Sources */,
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -40,8 +40,9 @@ final class GameMutator {
var isShared: Bool
/// Set to `true` when the owner has revoked the current user's access to
- /// a shared game. `emitMove` becomes a no-op and `PuzzleView` shows a
- /// read-only banner.
+ /// a shared game. Revocation makes the game read-only: every mutating
+ /// entry point below is a no-op (via `isEditable`) and `PuzzleView` shows
+ /// a read-only banner.
var isAccessRevoked: Bool
/// Whether this app implements the sync protocol selected by the game.
@@ -58,12 +59,19 @@ final class GameMutator {
/// game is terminal and read-only: every mutating entry point below becomes
/// a no-op, so the grid can't be edited or "re-solved" after the fact. Set
/// at construction from `completedAt` and flipped live when completion
- /// latches mid-session. Unlike `isAccessRevoked` (which only suppresses the
- /// durable/sync emit) this also blocks the in-memory mutation, so no letter
- /// even appears. The gate keys off this latched fact, not the live
+ /// latches mid-session. The gate keys off this latched fact, not the live
/// `completionState`, so a grid that drifted after completion stays locked.
var isCompleted: Bool
+ /// The single read-only predicate: local mutations are accepted only while
+ /// the game is not terminal, the local user still has access, and this app
+ /// understands the game's sync protocol. Every mutating entry point below
+ /// guards on this — blocking the in-memory mutation, not just the sync
+ /// emit — and the editing UI (keyboard, toolbar menus, hardware keys,
+ /// VoiceOver actions) disables itself from the same flag, so no surface
+ /// can mutate a game the model would refuse.
+ var isEditable: Bool { !isCompleted && !isAccessRevoked && isSyncSupported }
+
init(
game: Game,
gameID: UUID,
@@ -109,7 +117,7 @@ final class GameMutator {
// MARK: - Single-cell mutations
func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool, direction: Puzzle.Direction? = nil) {
- guard !isCompleted, isSyncSupported else { return }
+ guard isEditable else { return }
let before = cellState(atRow: row, atCol: col)
game.setLetter(letter, atRow: row, atCol: col, pencil: pencil, authorID: authorIDProvider?())
emitMove(
@@ -122,7 +130,7 @@ final class GameMutator {
}
func clearLetter(atRow row: Int, atCol col: Int, direction: Puzzle.Direction? = nil) {
- guard !isCompleted, isSyncSupported else { return }
+ guard isEditable else { return }
let before = cellState(atRow: row, atCol: col)
game.clearLetter(atRow: row, atCol: col)
emitMove(
@@ -159,10 +167,10 @@ final class GameMutator {
kind: JournalKind,
_ mutate: ([Puzzle.Cell]) -> Void
) {
- // A completed game is read-only. `resignGame` reveals through a freshly
- // loaded mutator whose `isCompleted` is still false (it sets
- // `completedAt` only afterwards), so its reveal is unaffected.
- guard !isCompleted, isSyncSupported else { return }
+ // A completed or revoked game is read-only. `resignGame` reveals
+ // through a freshly loaded mutator whose `isCompleted` is still false
+ // (it sets `completedAt` only afterwards), so its reveal is unaffected.
+ guard isEditable else { return }
let applicable = cells.filter { !$0.isBlock }
guard !applicable.isEmpty else { return }
let before = applicable.map { cellState(atRow: $0.row, atCol: $0.col) }
@@ -199,13 +207,13 @@ final class GameMutator {
/// enabled state of the undo/redo controls.
var canUndo: Bool {
_ = journalRevision
- guard !isAccessRevoked, isSyncSupported, !isCompleted, let movesJournal else { return false }
+ guard isEditable, let movesJournal else { return false }
return movesJournal.canUndo(gameID: gameID)
}
var canRedo: Bool {
_ = journalRevision
- guard !isAccessRevoked, isSyncSupported, !isCompleted, let movesJournal else { return false }
+ guard isEditable, let movesJournal else { return false }
return movesJournal.canRedo(gameID: gameID)
}
@@ -229,7 +237,7 @@ final class GameMutator {
/// `clear` (no single target) or when nothing was undone.
@discardableResult
func undo() -> CursorLanding? {
- guard !isAccessRevoked, isSyncSupported, !isCompleted, let movesJournal else { return nil }
+ guard isEditable, let movesJournal else { return nil }
while let plan = movesJournal.planUndo(gameID: gameID) {
if applyRestores(plan.restores, kind: .undo) { return cursorTarget(for: plan) }
movesJournal.markUndoConsumed(stepID: plan.stepID, gameID: gameID)
@@ -241,7 +249,7 @@ final class GameMutator {
/// Re-applies the most recently undone move. Mirror of `undo()`.
@discardableResult
func redo() -> CursorLanding? {
- guard !isAccessRevoked, isSyncSupported, !isCompleted, let movesJournal else { return nil }
+ guard isEditable, let movesJournal else { return nil }
while let plan = movesJournal.planRedo(gameID: gameID) {
if applyRestores(plan.restores, kind: .redo) { return cursorTarget(for: plan) }
movesJournal.markRedoConsumed(stepID: plan.stepID, gameID: gameID)
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -1366,9 +1366,12 @@ final class GameStore {
// MARK: - Resign a game
- /// Reveals all cells and marks the game as completed (resigned).
+ /// Reveals all cells and marks the game as completed (resigned). A
+ /// revoked participant cannot resign: that would turn the read-only
+ /// revoked copy into a terminal completed game.
func resignGame(id: UUID) throws {
guard let existing = fetchGameEntity(id: id),
+ !existing.isAccessRevoked,
GameSyncVersion.supports(existing.syncVersion)
else { return }
let (game, mutator) = try loadGame(id: id)
@@ -1482,6 +1485,9 @@ final class GameStore {
request.fetchLimit = 1
guard let entity = try context.fetch(request).first,
entity.completedAt == nil,
+ // A revoked participant can't legitimately finish the game, so
+ // never latch a revoked copy into a terminal completed state.
+ !entity.isAccessRevoked,
GameSyncVersion.supports(entity.syncVersion)
else { return false }
entity.completedAt = Date()
diff --git a/Crossmate/Views/Puzzle/GridAccessibility.swift b/Crossmate/Views/Puzzle/GridAccessibility.swift
@@ -492,11 +492,13 @@ private struct PuzzleGridAccessibility: ViewModifier {
.accessibilityRotorEntry(id: pos, in: rotorNamespace)
// These act on the session's *selected* cell, which the
// focus sync keeps equal to the focused element. Check and
- // reveal disappear on a solved grid (the mutator rejects
- // them anyway); Switch Direction stays for navigation.
+ // reveal disappear on a solved grid or a read-only mutator
+ // — revoked access or an unsupported protocol (which would
+ // reject them anyway); Switch Direction stays for
+ // navigation.
.accessibilityActions {
Button("Switch Direction") { session.toggleDirection() }
- if session.game.completionState != .solved {
+ if session.mutator.isEditable, session.game.completionState != .solved {
Button("Check Square") { checkAndAnnounce(wholeWord: false) }
Button("Check Word") { checkAndAnnounce(wholeWord: true) }
Button("Reveal Square") { revealAndAnnounce() }
diff --git a/Crossmate/Views/Puzzle/PuzzleModifiers.swift b/Crossmate/Views/Puzzle/PuzzleModifiers.swift
@@ -5,6 +5,11 @@ struct PuzzleToolbarModifier: ViewModifier {
let roster: PlayerRoster
let shareController: ShareController?
let isSolved: Bool
+ /// The single read-only gate for the editing controls: solved, access
+ /// revoked, unsupported protocol, or an input-blocking banner. The Players
+ /// menu stays on `isSolved` alone so a revoked participant can still see
+ /// the roster and leave the puzzle.
+ let isEditingBlocked: Bool
let canResign: Bool
let canDelete: Bool
/// Sends a broadcast nudge to the other players; `nil` hides the button
@@ -67,16 +72,16 @@ struct PuzzleToolbarModifier: ViewModifier {
.foregroundStyle(pencilButtonForeground)
.padding(6)
.pencilGlass(
- isActive: !isSolved && session.isPencilMode,
+ isActive: !isEditingBlocked && session.isPencilMode,
tint: preferences.color.tint
)
}
.accessibilityLabel(session.isPencilMode ? "Turn Off Draft" : "Turn On Draft")
- .disabled(isSolved)
+ .disabled(isEditingBlocked)
}
private var pencilButtonForeground: Color {
- if isSolved {
+ if isEditingBlocked {
return .secondary
}
if session.isPencilMode {
@@ -119,7 +124,7 @@ struct PuzzleToolbarModifier: ViewModifier {
} label: {
Label("Entry", systemImage: "squareshape.split.2x2")
}
- .disabled(isSolved)
+ .disabled(isEditingBlocked)
}
private var hintsMenu: some View {
@@ -141,7 +146,7 @@ struct PuzzleToolbarModifier: ViewModifier {
} label: {
Label("Hints", systemImage: "lightbulb")
}
- .disabled(isSolved)
+ .disabled(isEditingBlocked)
}
private func confirmReveal(_ scope: RevealScope) {
@@ -201,7 +206,7 @@ struct PuzzleToolbarModifier: ViewModifier {
Button("Nudge Players") {
Task { await onNudge() }
}
- .disabled(isSolved || nudgeReadyAt() != nil)
+ .disabled(isEditingBlocked || nudgeReadyAt() != nil)
}
}
}
@@ -252,7 +257,7 @@ struct PuzzleToolbarModifier: ViewModifier {
Button("Resign Puzzle", role: .destructive) {
isConfirmingResign = true
}
- .disabled(isSolved || !canResign)
+ .disabled(isEditingBlocked || !canResign)
if session.mutator.isShared && !session.mutator.isOwned {
Button("Leave Puzzle", role: .destructive) {
diff --git a/Crossmate/Views/Puzzle/PuzzleView.swift b/Crossmate/Views/Puzzle/PuzzleView.swift
@@ -121,11 +121,13 @@ struct PuzzleView: View {
// when the locally merged grid drifted and no longer reads `.solved`.
private var isSolved: Bool { hasSolved || session.mutator.isCompleted }
- /// Whether the game's protocol is unsupported or a sticky, input-blocking
- /// announcement is showing. Greys out the custom keyboard and makes the
- /// hardware-key handler a no-op.
+ /// Whether the mutator is read-only (access revoked, unsupported protocol,
+ /// completed) or a sticky, input-blocking announcement is showing. Greys
+ /// out the custom keyboard, makes the hardware-key handler a no-op, and
+ /// disables the toolbar's editing menus. Reads the mutator's own predicate
+ /// so the block holds even if the matching banner was never posted.
private var isInputBlocked: Bool {
- !session.mutator.isSyncSupported
+ !session.mutator.isEditable
|| announcements.isInputBlocked(forGame: session.mutator.gameID)
}
@@ -173,6 +175,7 @@ struct PuzzleView: View {
roster: roster,
shareController: shareController,
isSolved: isSolved,
+ isEditingBlocked: isSolved || isInputBlocked,
canResign: onResign != nil,
canDelete: onDelete != nil,
onNudge: onNudge,
diff --git a/Tests/Unit/GameMutatorTests.swift b/Tests/Unit/GameMutatorTests.swift
@@ -132,6 +132,36 @@ struct GameMutatorTests {
#expect(mutator.redo() == nil)
}
+ @Test("A revoked mutator rejects every mutation")
+ func revokedMutatorIsReadOnly() throws {
+ let (game, _, _, persistence) = try makeTestGame()
+ let mutator = GameMutator(
+ game: game,
+ gameID: UUID(),
+ movesUpdater: nil,
+ movesJournal: MovesJournal(persistence: persistence),
+ isAccessRevoked: true
+ )
+
+ // Revocation blocks the in-memory mutation itself, not just the sync
+ // emit — no letter may even appear on the revoked copy.
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
+ #expect(game.squares[0][0].entry == "")
+
+ // Bulk help/clear gestures are no-ops too.
+ mutator.revealCells([game.puzzle.cells[0][0]])
+ #expect(game.squares[0][0].entry == "")
+ mutator.checkCells([game.puzzle.cells[0][2]])
+ mutator.clearCells([game.puzzle.cells[0][2]])
+ #expect(game.squares[0][2].entry == "")
+
+ // Undo/redo are disabled and inert.
+ #expect(mutator.canUndo == false)
+ #expect(mutator.canRedo == false)
+ #expect(mutator.undo() == nil)
+ #expect(mutator.redo() == nil)
+ }
+
@Test("An unsupported sync version rejects every mutation")
func unsupportedSyncVersionIsReadOnly() throws {
let (game, _, _, persistence) = try makeTestGame()
diff --git a/Tests/Unit/GameStoreRevocationLockTests.swift b/Tests/Unit/GameStoreRevocationLockTests.swift
@@ -0,0 +1,117 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// Revocation makes a shared game read-only for the removed participant.
+/// The mutator refuses every local mutation — not just the sync emit — and
+/// the store refuses to resign or latch a revoked copy into a terminal
+/// completed state, so a revoked open puzzle can't fabricate progress or a
+/// completion the shared game never reached.
+@Suite("GameStore revocation lock", .isolatedNotificationState)
+@MainActor
+struct GameStoreRevocationLockTests {
+
+ private static let puzzleSource = """
+ Title: Test Puzzle
+ Author: Test
+
+
+ AB
+ CD
+
+
+ A1. Across 1 ~ AB
+ A3. Across 3 ~ CD
+ D1. Down 1 ~ AC
+ D2. Down 2 ~ BD
+ """
+
+ private func makeSharedGame(
+ revoked: Bool,
+ in ctx: NSManagedObjectContext
+ ) throws -> (GameEntity, UUID) {
+ let xd = try XD.parse(Self.puzzleSource)
+ let puzzle = Puzzle(xd: xd)
+ let gameID = UUID()
+ let entity = GameEntity(context: ctx)
+ entity.id = gameID
+ entity.title = "Test"
+ entity.puzzleSource = Self.puzzleSource
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ entity.ckRecordName = "game-\(gameID.uuidString)"
+ entity.databaseScope = DatabaseScope.shared.rawValue
+ entity.isAccessRevoked = revoked
+ entity.populateCachedSummaryFields(from: puzzle)
+ try ctx.save()
+ return (entity, gameID)
+ }
+
+ @Test("Loading a revoked game returns a read-only mutator")
+ func loadedRevokedMutatorIsLocked() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let (_, gameID) = try makeSharedGame(revoked: true, in: persistence.viewContext)
+
+ let (game, mutator) = try store.loadGame(id: gameID)
+ #expect(mutator.isAccessRevoked)
+ #expect(!mutator.isEditable)
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
+ mutator.revealCells(game.puzzle.cells.flatMap { $0 })
+ #expect(game.squares.allSatisfy { row in row.allSatisfy { $0.entry.isEmpty } })
+ }
+
+ @Test("Mid-session revocation locks the open mutator against further input")
+ func liveRevocationLocksOpenMutator() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let (_, gameID) = try makeSharedGame(revoked: false, in: persistence.viewContext)
+
+ let (game, mutator) = try store.loadGame(id: gameID)
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
+ #expect(game.squares[0][0].entry == "A")
+
+ store.markAccessRevoked(gameID: gameID)
+
+ mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false)
+ mutator.revealCells([game.puzzle.cells[1][1]])
+ #expect(game.squares[0][1].entry == "")
+ #expect(game.squares[1][1].entry == "")
+ }
+
+ @Test("A revoked game cannot be marked completed or observed-completed")
+ func revokedGameRefusesCompletion() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(
+ persistence: persistence,
+ authorIDProvider: { "alice" }
+ )
+ let (entity, gameID) = try makeSharedGame(revoked: true, in: persistence.viewContext)
+ var journalCompletions = 0
+ store.onJournalComplete = { _, _, _, _ in journalCompletions += 1 }
+
+ #expect(try store.markCompleted(id: gameID) == false)
+ #expect(try store.markCompletedFromObservedSolvedState(id: gameID) == false)
+
+ #expect(entity.completedAt == nil)
+ #expect(entity.completedBy == nil)
+ #expect(!entity.hasPendingSave)
+ #expect(journalCompletions == 0)
+ }
+
+ @Test("A revoked game cannot be resigned")
+ func revokedGameRefusesResignation() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let (entity, gameID) = try makeSharedGame(revoked: true, in: persistence.viewContext)
+
+ try store.resignGame(id: gameID)
+
+ #expect(entity.completedAt == nil)
+ let (game, _) = try store.loadGame(id: gameID)
+ #expect(game.squares.allSatisfy { row in row.allSatisfy { $0.entry.isEmpty } })
+ }
+}