crossmate

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

commit abef15d53bbeb091d96513bc4821b8dfb5b4ede2
parent dc67961e056fcd810bc6754a5ab354388e151419
Author: Michael Camilleri <[email protected]>
Date:   Tue, 14 Apr 2026 18:42:13 +0900

Improve robustness of puzzle selection

This commit improves the robustness of puzzle selection. If the cookies
used to access an external source become invalid, the user is prompted
to sign in again. If the user is selecting a puzzle they have already
played, a warning checks that the user intends to create a copy of an
existing puzzle.

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

Diffstat:
MCrossmate/Persistence/GameStore.swift | 11+++++++++++
MCrossmate/Services/NYTPuzzleFetcher.swift | 15++++++++++++++-
MCrossmate/Views/BundledBrowseView.swift | 7++-----
MCrossmate/Views/ImportedBrowseView.swift | 9++-------
MCrossmate/Views/NYTBrowseView.swift | 17+++++++++++++----
MCrossmate/Views/NewGameSheet.swift | 54++++++++++++++++++++++++++++++++++++++++++++++++------
6 files changed, 90 insertions(+), 23 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -146,6 +146,17 @@ final class GameStore { return (game, mutator) } + // MARK: - Duplicate detection + + /// Returns the ID of an existing game whose stored `puzzleSource` + /// matches `source` exactly, or nil if no such game exists. + func findGameID(matching source: String) -> UUID? { + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate(format: "puzzleSource == %@", source) + request.fetchLimit = 1 + return (try? context.fetch(request).first?.id) + } + // MARK: - Create a new game /// Creates a new game from XD source text. Returns the new game's UUID. diff --git a/Crossmate/Services/NYTPuzzleFetcher.swift b/Crossmate/Services/NYTPuzzleFetcher.swift @@ -116,7 +116,14 @@ actor NYTPuzzleFetcher { } print("=== END FETCH RESPONSE ===") - guard httpResponse.statusCode == 200 else { + switch httpResponse.statusCode { + case 200: + break + case 401, 403: + throw NYTFetchError.unauthorized + case 429: + throw NYTFetchError.rateLimited + default: throw NYTFetchError.httpError(statusCode: httpResponse.statusCode) } @@ -134,6 +141,8 @@ actor NYTPuzzleFetcher { enum NYTFetchError: LocalizedError { case notSignedIn case invalidResponse + case unauthorized + case rateLimited case httpError(statusCode: Int) var errorDescription: String? { @@ -142,6 +151,10 @@ enum NYTFetchError: LocalizedError { "Not signed in to NYT." case .invalidResponse: "Received an invalid response." + case .unauthorized: + "Your NYT session has expired. Sign in again from Settings." + case .rateLimited: + "Too many requests. Wait a moment and try again." case .httpError(let statusCode): "Fetch failed (HTTP \(statusCode))." } diff --git a/Crossmate/Views/BundledBrowseView.swift b/Crossmate/Views/BundledBrowseView.swift @@ -1,8 +1,7 @@ import SwiftUI struct BundledBrowseView: View { - let store: GameStore - let onCreated: (UUID) -> Void + let onSelected: (String) -> Void private var puzzles: [PuzzleCatalog.Entry] { PuzzleCatalog.bundledPuzzles() @@ -11,9 +10,7 @@ struct BundledBrowseView: View { var body: some View { List(puzzles) { entry in Button { - if let id = try? store.createGame(from: entry.source) { - onCreated(id) - } + onSelected(entry.source) } label: { Text(entry.title) .foregroundStyle(.primary) diff --git a/Crossmate/Views/ImportedBrowseView.swift b/Crossmate/Views/ImportedBrowseView.swift @@ -1,8 +1,7 @@ import SwiftUI struct ImportedBrowseView: View { - let store: GameStore - let onCreated: (UUID) -> Void + let onSelected: (String) -> Void @Environment(UbiquityMonitor.self) private var monitor @State private var errorMessage: String? @@ -51,11 +50,7 @@ struct ImportedBrowseView: View { } do { let source = try monitor.readSource(at: item.url) - if let id = try? store.createGame(from: source) { - onCreated(id) - } else { - errorMessage = "The selected file isn't a valid .xd puzzle." - } + onSelected(source) } catch { errorMessage = error.localizedDescription } diff --git a/Crossmate/Views/NYTBrowseView.swift b/Crossmate/Views/NYTBrowseView.swift @@ -1,14 +1,15 @@ import SwiftUI struct NYTBrowseView: View { - let store: GameStore - let onCreated: (UUID) -> Void + let onSelected: (String) -> Void @Environment(\.nytPuzzleFetcher) private var fetcher + @Environment(NYTAuthService.self) private var nytAuth @State private var displayedMonth: Date = NYTBrowseView.startOfCurrentMonth() @State private var selectedDate: Date? @State private var isLoading = false @State private var errorMessage: String? + @State private var sessionExpired = false private static let nytTimeZone = TimeZone(identifier: "America/New_York")! @@ -64,6 +65,13 @@ struct NYTBrowseView: View { } message: { message in Text(message) } + .alert("NYT Session Expired", isPresented: $sessionExpired) { + Button("OK", role: .cancel) { + nytAuth.signOut() + } + } message: { + Text("Your NYT session has expired. Sign in again from Settings to resume fetching puzzles.") + } } private var monthHeader: some View { @@ -231,8 +239,9 @@ struct NYTBrowseView: View { defer { isLoading = false } do { let source = try await fetcher.fetchPuzzle(for: date) - let id = try store.createGame(from: source) - onCreated(id) + onSelected(source) + } catch NYTFetchError.unauthorized { + sessionExpired = true } catch { errorMessage = error.localizedDescription } diff --git a/Crossmate/Views/NewGameSheet.swift b/Crossmate/Views/NewGameSheet.swift @@ -8,6 +8,8 @@ struct NewGameSheet: View { @Environment(NYTAuthService.self) private var nytAuth @AppStorage("lastPuzzleSource") private var storedSource: PuzzleSource = .bundled @State private var selection: PuzzleSource = .bundled + @State private var duplicateSource: String? + @State private var createError: String? private var availableSources: [PuzzleSource] { nytAuth.isSignedIn ? [.bundled, .imported, .nyt] : [.bundled, .imported] @@ -27,11 +29,11 @@ struct NewGameSheet: View { Group { switch selection { case .bundled: - BundledBrowseView(store: store, onCreated: handleCreated) + BundledBrowseView(onSelected: handleSelected) case .imported: - ImportedBrowseView(store: store, onCreated: handleCreated) + ImportedBrowseView(onSelected: handleSelected) case .nyt: - NYTBrowseView(store: store, onCreated: handleCreated) + NYTBrowseView(onSelected: handleSelected) } } } @@ -49,10 +51,50 @@ struct NewGameSheet: View { .onChange(of: selection) { _, newValue in storedSource = newValue } + .alert( + "Puzzle Already in Library", + isPresented: .init( + get: { duplicateSource != nil }, + set: { if !$0 { duplicateSource = nil } } + ), + presenting: duplicateSource + ) { source in + Button("Create Copy") { + create(from: source) + } + Button("Cancel", role: .cancel) {} + } message: { _ in + Text("You already have a game for this puzzle. Cancel and resume it from the library, or create a new copy.") + } + .alert( + "Couldn't Create Game", + isPresented: .init( + get: { createError != nil }, + set: { if !$0 { createError = nil } } + ), + presenting: createError + ) { _ in + Button("OK", role: .cancel) {} + } message: { message in + Text(message) + } } - private func handleCreated(_ id: UUID) { - onCreated(id) - dismiss() + private func handleSelected(_ source: String) { + if store.findGameID(matching: source) != nil { + duplicateSource = source + } else { + create(from: source) + } + } + + private func create(from source: String) { + do { + let id = try store.createGame(from: source) + onCreated(id) + dismiss() + } catch { + createError = error.localizedDescription + } } }