crossmate

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

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:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Persistence/GameMutator.swift | 38+++++++++++++++++++++++---------------
MCrossmate/Persistence/GameStore.swift | 8+++++++-
MCrossmate/Views/Puzzle/GridAccessibility.swift | 8+++++---
MCrossmate/Views/Puzzle/PuzzleModifiers.swift | 19++++++++++++-------
MCrossmate/Views/Puzzle/PuzzleView.swift | 11+++++++----
MTests/Unit/GameMutatorTests.swift | 30++++++++++++++++++++++++++++++
ATests/Unit/GameStoreRevocationLockTests.swift | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 } }) + } +}