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