commit 642d5ecccb5b231031cce6986b55a095756c2dbc
parent f3182bd155c30cfde0cfd32d7bb17b9181177a07
Author: Michael Camilleri <[email protected]>
Date: Sun, 12 Apr 2026 11:09:20 +0900
Settings and NYT authentication
Diffstat:
9 files changed, 740 insertions(+), 0 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -7,13 +7,17 @@
objects = {
/* Begin PBXBuildFile section */
+ 0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */; };
+ 1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; };
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; };
2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */; };
350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */; };
+ 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; };
3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; };
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; };
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; };
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; };
+ 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; };
6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; };
765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; };
77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; };
@@ -27,11 +31,13 @@
AA992F67F509EC8EFDDFC7CB /* morning.xd in Resources */ = {isa = PBXBuildFile; fileRef = 0B73A791FD061430AE286E11 /* morning.xd */; };
AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; };
B42454D72FAA219D60DEA334 /* garden.xd in Resources */ = {isa = PBXBuildFile; fileRef = 50992CDA4082429EBB17F65C /* garden.xd */; };
+ B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */; };
C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; };
C6E0E5128565D3B822A41605 /* PendingChangePayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */; };
C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; };
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */; };
CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; };
+ D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */; };
D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; };
D66C1A4FDEA5E912E00FB742 /* PendingChange+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = E524780E360E008FACE4F213 /* PendingChange+Helpers.swift */; };
DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */; };
@@ -52,10 +58,12 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
+ 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTLoginView.swift; sourceTree = "<group>"; };
0B73A791FD061430AE286E11 /* morning.xd */ = {isa = PBXFileReference; path = morning.xd; sourceTree = "<group>"; };
0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; };
14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; };
20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; };
+ 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
38DDAD9D6470A894C3FD6F90 /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; };
43DC132D49361C56DE79C13E /* GameMutator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutator.swift; sourceTree = "<group>"; };
465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; };
@@ -65,6 +73,7 @@
5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangeTests.swift; sourceTree = "<group>"; };
64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; };
73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; };
+ 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
7B3E1A382B24A7803701D947 /* Crossmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Crossmate.entitlements; sourceTree = "<group>"; };
7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; };
7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerTests.swift; sourceTree = "<group>"; };
@@ -72,11 +81,14 @@
93EE5BA78566EDED68D846AB /* GameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStore.swift; sourceTree = "<group>"; };
9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; };
+ A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.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>"; };
+ B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleFetcher.swift; sourceTree = "<group>"; };
B135C285570F91181595B405 /* CellMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMark.swift; sourceTree = "<group>"; };
B689A7138429641E61E9E558 /* Crossmate.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Crossmate.app; sourceTree = BUILT_PRODUCTS_DIR; };
B9031A1574C21866940F6A2C /* XD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XD.swift; sourceTree = "<group>"; };
+ BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverter.swift; sourceTree = "<group>"; };
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.swift; sourceTree = "<group>"; };
CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; };
D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -167,6 +179,7 @@
41DB2417FF67A47FE6890256 /* Models */,
565DBAFC8DB2589B3F0AF90E /* Persistence */,
F53443E4827221C62DB7AA36 /* Resources */,
+ D8F0E3376B2616B4E917129C /* Services */,
074C2962E79CAE6C0EA6431A /* Sync */,
84445EA9CACB6AAAEDE6965F /* Views */,
);
@@ -182,7 +195,9 @@
CAB4BB9E160C3A59C653E7A9 /* GridView.swift */,
7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */,
F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */,
+ 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */,
AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */,
+ 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */,
);
path = Views;
sourceTree = "<group>";
@@ -197,6 +212,17 @@
);
sourceTree = "<group>";
};
+ D8F0E3376B2616B4E917129C /* Services */ = {
+ isa = PBXGroup;
+ children = (
+ 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */,
+ A253416F4FEA271A80B22A73 /* NYTAuthService.swift */,
+ B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */,
+ BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */,
+ );
+ path = Services;
+ sourceTree = "<group>";
+ };
F53443E4827221C62DB7AA36 /* Resources */ = {
isa = PBXGroup;
children = (
@@ -325,6 +351,11 @@
C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */,
765B50552B13175F91A25EA1 /* GridView.swift in Sources */,
F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */,
+ 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */,
+ 1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */,
+ D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */,
+ 0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */,
+ B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */,
DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */,
D66C1A4FDEA5E912E00FB742 /* PendingChange+Helpers.swift in Sources */,
C6E0E5128565D3B822A41605 /* PendingChangePayload.swift in Sources */,
@@ -335,6 +366,7 @@
350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */,
2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */,
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */,
+ 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */,
AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */,
82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */,
F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -7,6 +7,7 @@ struct CrossmateApp: App {
@State private var store: GameStore
@State private var syncEngine: SyncEngine
+ @State private var nytAuth = NYTAuthService()
private let persistence: PersistenceController
init() {
@@ -24,6 +25,7 @@ struct CrossmateApp: App {
var body: some Scene {
WindowGroup {
RootView(store: store, syncEngine: syncEngine, appDelegate: appDelegate)
+ .environment(nytAuth)
}
}
}
@@ -57,6 +59,7 @@ struct RootView: View {
let syncEngine: SyncEngine
let appDelegate: AppDelegate
+ @Environment(NYTAuthService.self) private var nytAuth
@Environment(\.scenePhase) private var scenePhase
@State private var syncBootstrapped = false
@State private var lastVisitedGameID: UUID?
@@ -86,6 +89,8 @@ struct RootView: View {
guard !syncBootstrapped else { return }
syncBootstrapped = true
+ nytAuth.loadStoredSession()
+
// Wire app delegate → sync engine fetch
appDelegate.onRemoteNotification = { [syncEngine] in
try? await syncEngine.fetchChanges()
diff --git a/Crossmate/Services/KeychainHelper.swift b/Crossmate/Services/KeychainHelper.swift
@@ -0,0 +1,53 @@
+import Foundation
+import Security
+
+enum KeychainHelper {
+ private static let service = "net.inqk.crossmate"
+
+ static func save(key: String, data: Data) throws {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: key,
+ ]
+
+ // Delete any existing item first.
+ SecItemDelete(query as CFDictionary)
+
+ var attributes = query
+ attributes[kSecValueData as String] = data
+
+ let status = SecItemAdd(attributes as CFDictionary, nil)
+ guard status == errSecSuccess else {
+ throw KeychainError.unhandledError(status: status)
+ }
+ }
+
+ static func load(key: String) -> Data? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: key,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+ guard status == errSecSuccess else { return nil }
+ return result as? Data
+ }
+
+ static func delete(key: String) {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: key,
+ ]
+ SecItemDelete(query as CFDictionary)
+ }
+}
+
+enum KeychainError: Error {
+ case unhandledError(status: OSStatus)
+}
diff --git a/Crossmate/Services/NYTAuthService.swift b/Crossmate/Services/NYTAuthService.swift
@@ -0,0 +1,124 @@
+import Foundation
+import UIKit
+import WebKit
+
+@MainActor @Observable
+final class NYTAuthService {
+ private(set) var isSignedIn = false
+ private(set) var signedInEmail: String?
+ private(set) var isLoading = false
+ var errorMessage: String?
+
+ private static let emailKey = "nyt-email"
+ private static let cookieKey = "nyt-cookie"
+
+ static let loginURL = URL(string: "https://myaccount.nytimes.com/auth/login?response_type=cookie&client_id=games&redirect_uri=https%3A%2F%2Fwww.nytimes.com%2Fcrosswords")!
+
+ func loadStoredSession() {
+ guard let emailData = KeychainHelper.load(key: Self.emailKey),
+ let email = String(data: emailData, encoding: .utf8),
+ KeychainHelper.load(key: Self.cookieKey) != nil
+ else {
+ isSignedIn = false
+ signedInEmail = nil
+ return
+ }
+ isSignedIn = true
+ signedInEmail = email
+ }
+
+ /// Called by the web login view after the user completes sign-in and the
+ /// WKWebView navigates to the redirect URL. Extracts the NYT-S cookie
+ /// from the provided cookie store.
+ func completeSignIn(cookieStore: WKHTTPCookieStore) async {
+ isLoading = true
+ errorMessage = nil
+ defer { isLoading = false }
+
+ do {
+ let cookies = await cookieStore.allCookies()
+
+ print("=== COOKIES FROM WEBVIEW ===")
+ for cookie in cookies {
+ print("\(cookie.name) = \(cookie.value.prefix(40))... (domain: \(cookie.domain))")
+ }
+ print("=== END COOKIES ===")
+
+ guard let nytsCookie = cookies.first(where: { $0.name == "NYT-S" }) else {
+ throw NYTAuthError.missingCookie
+ }
+
+ let cookieValue = nytsCookie.value
+ let email = try await fetchUserEmail(cookie: cookieValue)
+
+ try KeychainHelper.save(
+ key: Self.emailKey,
+ data: Data(email.utf8)
+ )
+ try KeychainHelper.save(
+ key: Self.cookieKey,
+ data: Data(cookieValue.utf8)
+ )
+ isSignedIn = true
+ signedInEmail = email
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func signOut() {
+ KeychainHelper.delete(key: Self.emailKey)
+ KeychainHelper.delete(key: Self.cookieKey)
+ isSignedIn = false
+ signedInEmail = nil
+ errorMessage = nil
+ }
+
+ var cookie: String? {
+ guard let data = KeychainHelper.load(key: Self.cookieKey) else { return nil }
+ return String(data: data, encoding: .utf8)
+ }
+
+ // MARK: - Private
+
+ private func fetchUserEmail(cookie: String) async throws -> String {
+ let url = URL(string: "https://myaccount.nytimes.com/svc/ios/v2/user")!
+ var request = URLRequest(url: url)
+ request.setValue("NYT-S=\(cookie)", forHTTPHeaderField: "Cookie")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse,
+ httpResponse.statusCode == 200 else {
+ return "NYT User"
+ }
+
+ if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let dataDict = json["data"] as? [String: Any],
+ let email = dataDict["email"] as? String {
+ return email
+ }
+
+ return "NYT User"
+ }
+}
+
+enum NYTAuthError: LocalizedError {
+ case invalidResponse
+ case httpError(statusCode: Int)
+ case missingCookie
+ case serverError(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidResponse:
+ "Received an invalid response from the server."
+ case .httpError(let statusCode):
+ "Sign in failed (HTTP \(statusCode))."
+ case .missingCookie:
+ "Sign in succeeded but the session cookie was not returned."
+ case .serverError(let message):
+ message
+ }
+ }
+}
diff --git a/Crossmate/Services/NYTPuzzleFetcher.swift b/Crossmate/Services/NYTPuzzleFetcher.swift
@@ -0,0 +1,144 @@
+import Foundation
+
+actor NYTPuzzleFetcher {
+ private let cookieProvider: @Sendable () -> String?
+
+ init(cookieProvider: @escaping @Sendable () -> String?) {
+ self.cookieProvider = cookieProvider
+ }
+
+ /// Fetches the puzzle list for a given date to get the puzzle ID,
+ /// then fetches the game data. For debugging, logs both responses.
+ func fetchPuzzleList(for date: Date) async throws -> String {
+ guard let cookie = cookieProvider() else {
+ throw NYTFetchError.notSignedIn
+ }
+
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd"
+ formatter.timeZone = TimeZone(identifier: "America/New_York")
+ let dateString = formatter.string(from: date)
+
+ // Step 1: Get puzzle ID from list endpoint
+ let listURL = URL(string: "https://www.nytimes.com/svc/crosswords/v3/36569100/puzzles.json?publish_type=daily&date_start=\(dateString)&date_end=\(dateString)&limit=1")!
+
+ var listRequest = URLRequest(url: listURL)
+ listRequest.setValue("NYT-S=\(cookie)", forHTTPHeaderField: "Cookie")
+
+ let (listData, listResponse) = try await URLSession.shared.data(for: listRequest)
+
+ guard let listHTTP = listResponse as? HTTPURLResponse else {
+ throw NYTFetchError.invalidResponse
+ }
+
+ print("=== NYT LIST RESPONSE ===")
+ print("Status: \(listHTTP.statusCode)")
+ if let body = String(data: listData, encoding: .utf8) {
+ print("Body: \(body.prefix(2000))")
+ }
+ print("=== END LIST RESPONSE ===")
+
+ guard listHTTP.statusCode == 200 else {
+ throw NYTFetchError.httpError(statusCode: listHTTP.statusCode)
+ }
+
+ // Extract puzzle ID from the list
+ guard let listJSON = try? JSONSerialization.jsonObject(with: listData) as? [String: Any],
+ let results = listJSON["results"] as? [[String: Any]],
+ let first = results.first,
+ let puzzleID = first["puzzle_id"] as? Int else {
+ throw NYTFetchError.invalidResponse
+ }
+
+ print("=== PUZZLE ID: \(puzzleID) ===")
+
+ // Step 2: Fetch game data using the puzzle ID
+ let gameURL = URL(string: "https://www.nytimes.com/svc/crosswords/v6/game/\(puzzleID).json")!
+
+ var gameRequest = URLRequest(url: gameURL)
+ gameRequest.setValue("NYT-S=\(cookie)", forHTTPHeaderField: "Cookie")
+
+ let (gameData, gameResponse) = try await URLSession.shared.data(for: gameRequest)
+
+ guard let gameHTTP = gameResponse as? HTTPURLResponse else {
+ throw NYTFetchError.invalidResponse
+ }
+
+ print("=== NYT GAME RESPONSE ===")
+ print("Status: \(gameHTTP.statusCode)")
+ if let body = String(data: gameData, encoding: .utf8) {
+ print("Body: \(body.prefix(3000))")
+ }
+ print("=== END GAME RESPONSE ===")
+
+ guard gameHTTP.statusCode == 200 else {
+ throw NYTFetchError.httpError(statusCode: gameHTTP.statusCode)
+ }
+
+ return String(data: gameData, encoding: .utf8) ?? ""
+ }
+
+ func fetchPuzzle(for date: Date) async throws -> String {
+ guard let cookie = cookieProvider() else {
+ throw NYTFetchError.notSignedIn
+ }
+
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd"
+ formatter.timeZone = TimeZone(identifier: "America/New_York")
+ let dateString = formatter.string(from: date)
+
+ let url = URL(string: "https://www.nytimes.com/svc/crosswords/v6/puzzle/daily/\(dateString).json")!
+
+ var request = URLRequest(url: url)
+ request.setValue("NYT-S=\(cookie)", forHTTPHeaderField: "Cookie")
+
+ print("=== NYT FETCH REQUEST ===")
+ print("URL: \(url)")
+ print("Cookie header: NYT-S=\(cookie.prefix(40))...")
+ print("=== END FETCH REQUEST ===")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw NYTFetchError.invalidResponse
+ }
+
+ print("=== NYT FETCH RESPONSE ===")
+ print("Status: \(httpResponse.statusCode)")
+ if httpResponse.statusCode != 200, let body = String(data: data, encoding: .utf8) {
+ print("Body: \(body.prefix(1000))")
+ }
+ print("=== END FETCH RESPONSE ===")
+
+ guard httpResponse.statusCode == 200 else {
+ throw NYTFetchError.httpError(statusCode: httpResponse.statusCode)
+ }
+
+ // Convert NYT JSON to .xd format
+ let xdSource = try NYTToXDConverter.convert(jsonData: data)
+
+ print("=== CONVERTED XD ===")
+ print(xdSource)
+ print("=== END XD ===")
+
+ return xdSource
+ }
+}
+
+enum NYTFetchError: LocalizedError {
+ case notSignedIn
+ case invalidResponse
+ case httpError(statusCode: Int)
+
+ var errorDescription: String? {
+ switch self {
+ case .notSignedIn:
+ "Not signed in to NYT."
+ case .invalidResponse:
+ "Received an invalid response."
+ case .httpError(let statusCode):
+ "Fetch failed (HTTP \(statusCode))."
+ }
+ }
+}
diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift
@@ -0,0 +1,195 @@
+import Foundation
+
+/// Converts NYT puzzle JSON (from `/v6/puzzle/daily/{date}.json`) to `.xd` format.
+enum NYTToXDConverter {
+ struct ConversionError: LocalizedError {
+ let message: String
+ var errorDescription: String? { message }
+ }
+
+ /// Converts raw JSON data from the NYT puzzle endpoint to an `.xd` source string.
+ static func convert(jsonData: Data) throws -> String {
+ guard let root = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else {
+ throw ConversionError(message: "Invalid JSON root.")
+ }
+
+ // -- Metadata --
+
+ let publicationDate = root["publicationDate"] as? String ?? ""
+ let constructors = root["constructors"] as? [String] ?? []
+ let editor = root["editor"] as? String
+ let copyright = root["copyright"] as? String
+
+ guard let bodyArray = root["body"] as? [[String: Any]],
+ let body = bodyArray.first else {
+ throw ConversionError(message: "Missing body in puzzle JSON.")
+ }
+
+ guard let dimensions = body["dimensions"] as? [String: Int],
+ let width = dimensions["width"],
+ let height = dimensions["height"] else {
+ throw ConversionError(message: "Missing dimensions.")
+ }
+
+ guard let cells = body["cells"] as? [Any] else {
+ throw ConversionError(message: "Missing cells.")
+ }
+
+ guard cells.count == width * height else {
+ throw ConversionError(message: "Cell count (\(cells.count)) does not match dimensions (\(width)x\(height)).")
+ }
+
+ guard let clues = body["clues"] as? [[String: Any]] else {
+ throw ConversionError(message: "Missing clues.")
+ }
+
+ // -- Parse cells into answers --
+
+ // Each cell is either an empty dict (block) or a dict with "answer", "type", etc.
+ var answers: [String?] = [] // nil = block, String = answer
+ var nextRebusKey: Character = "1"
+
+ for cell in cells {
+ guard let dict = cell as? [String: Any], !dict.isEmpty else {
+ answers.append(nil)
+ continue
+ }
+
+ let answer = dict["answer"] as? String ?? ""
+ if answer.isEmpty {
+ answers.append(nil)
+ } else {
+ answers.append(answer)
+ }
+ }
+
+ // -- Build rebus header if needed --
+
+ // Check for multi-character answers.
+ var rebusEntries: [(key: Character, value: String)] = []
+ var rebusLookup: [String: Character] = [:]
+
+ for answer in answers {
+ guard let answer, answer.count > 1 else { continue }
+ if rebusLookup[answer] == nil {
+ let key = nextRebusKey
+ rebusLookup[answer] = key
+ rebusEntries.append((key: key, value: answer))
+ // Advance to next digit/letter for rebus key
+ nextRebusKey = Character(UnicodeScalar(nextRebusKey.asciiValue! + 1))
+ }
+ }
+
+ // -- Build grid lines --
+
+ var gridLines: [String] = []
+ for row in 0..<height {
+ var line = ""
+ for col in 0..<width {
+ let index = row * width + col
+ guard let answer = answers[index] else {
+ line += "#"
+ continue
+ }
+ if answer.count > 1 {
+ // Rebus: use the placeholder character
+ line += String(rebusLookup[answer]!)
+ } else {
+ line += answer
+ }
+ }
+ gridLines.append(line)
+ }
+
+ // -- Build clue lines --
+
+ // Sort clues: Across first, then Down; within each group, by label number.
+ let sortedClues = clues.sorted { a, b in
+ let dirA = (a["direction"] as? String) ?? ""
+ let dirB = (b["direction"] as? String) ?? ""
+ if dirA != dirB { return dirA == "Across" }
+ let labelA = intValue(a["label"]) ?? 0
+ let labelB = intValue(b["label"]) ?? 0
+ return labelA < labelB
+ }
+
+ var acrossClueLines: [String] = []
+ var downClueLines: [String] = []
+
+ for clue in sortedClues {
+ let direction = clue["direction"] as? String ?? ""
+ let label = intValue(clue["label"]) ?? 0
+
+ // Extract clue text from the nested structure:
+ // "text": [{"plain": "Clue text"}]
+ let clueText: String
+ if let textArray = clue["text"] as? [[String: Any]],
+ let firstText = textArray.first,
+ let plain = firstText["plain"] as? String {
+ clueText = plain
+ } else {
+ clueText = ""
+ }
+
+ // Build answer from cell indices
+ let cellIndices = clue["cells"] as? [Int] ?? []
+ let answerStr = cellIndices.compactMap { answers[$0] }.joined()
+
+ let prefix = direction == "Across" ? "A" : "D"
+ let line = "\(prefix)\(label). \(clueText) ~ \(answerStr)"
+
+ if direction == "Across" {
+ acrossClueLines.append(line)
+ } else {
+ downClueLines.append(line)
+ }
+ }
+
+ // -- Assemble .xd source --
+
+ var sections: [String] = []
+
+ // Metadata section
+ var metadata: [String] = []
+ let title = constructors.isEmpty
+ ? "NYT Crossword \(publicationDate)"
+ : "NYT Crossword \(publicationDate)"
+ metadata.append("Title: \(title)")
+ if !constructors.isEmpty {
+ metadata.append("Author: \(constructors.joined(separator: ", "))")
+ }
+ if let editor {
+ metadata.append("Editor: \(editor)")
+ }
+ if let copyright {
+ metadata.append("Copyright: \(copyright)")
+ }
+
+ if !rebusEntries.isEmpty {
+ let rebusStr = rebusEntries.map { "\($0.key)=\($0.value)" }.joined(separator: " ")
+ metadata.append("Rebus: \(rebusStr)")
+ }
+
+ sections.append(metadata.joined(separator: "\n"))
+
+ // Grid section
+ sections.append(gridLines.joined(separator: "\n"))
+
+ // Clue sections (across then down, separated by blank line)
+ let allClueLines = acrossClueLines + [""] + downClueLines
+ sections.append(allClueLines.joined(separator: "\n"))
+
+ // The .xd parser splits sections on two or more consecutive blank lines,
+ // so we need two blank lines (three newlines) between sections.
+ return sections.joined(separator: "\n\n\n")
+ }
+
+ /// Extracts an Int from a JSON value that may be NSNumber, Int, or Double.
+ private static func intValue(_ value: Any?) -> Int? {
+ if let n = value as? Int { return n }
+ if let s = value as? String { return Int(s) }
+ if let n = value as? NSNumber { return n.intValue }
+ if let n = value as? Double { return Int(n) }
+ return nil
+ }
+}
diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift
@@ -9,6 +9,7 @@ struct GameListView: View {
@State private var games: [GameStore.GameSummary] = []
@State private var showingNewGame = false
+ @State private var showingSettings = false
@State private var deleteTarget: GameStore.GameSummary?
@State private var resignTarget: GameStore.GameSummary?
@State private var loaded = false
@@ -76,6 +77,13 @@ struct GameListView: View {
}
.navigationTitle("Crossmate")
.toolbar {
+ ToolbarItem(placement: .topBarLeading) {
+ Button {
+ showingSettings = true
+ } label: {
+ Image(systemName: "gearshape")
+ }
+ }
ToolbarItem(placement: .topBarTrailing) {
Button {
showingNewGame = true
@@ -84,6 +92,9 @@ struct GameListView: View {
}
}
}
+ .sheet(isPresented: $showingSettings) {
+ SettingsView()
+ }
.sheet(isPresented: $showingNewGame) {
NewGameSheet(store: store) { id in
if let summary = store.gameSummary(forID: id) {
diff --git a/Crossmate/Views/NYTLoginView.swift b/Crossmate/Views/NYTLoginView.swift
@@ -0,0 +1,79 @@
+import SwiftUI
+import WebKit
+
+/// Presents the NYT login page in a WKWebView. After the user signs in,
+/// the NYT site redirects to nytimes.com/crosswords. We detect that
+/// redirect and extract the NYT-S cookie from the web view's cookie store.
+struct NYTLoginView: View {
+ @Environment(NYTAuthService.self) private var nytAuth
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ NYTWebView(
+ url: NYTAuthService.loginURL,
+ onSignInDetected: { cookieStore in
+ await nytAuth.completeSignIn(cookieStore: cookieStore)
+ dismiss()
+ }
+ )
+ .ignoresSafeArea(edges: .bottom)
+ .navigationTitle("Sign In")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ }
+ }
+ }
+}
+
+// MARK: - WKWebView wrapper
+
+private struct NYTWebView: UIViewRepresentable {
+ let url: URL
+ let onSignInDetected: @MainActor (WKHTTPCookieStore) async -> Void
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(onSignInDetected: onSignInDetected)
+ }
+
+ func makeUIView(context: Context) -> WKWebView {
+ let config = WKWebViewConfiguration()
+ let webView = WKWebView(frame: .zero, configuration: config)
+ webView.navigationDelegate = context.coordinator
+ webView.load(URLRequest(url: url))
+ return webView
+ }
+
+ func updateUIView(_ uiView: WKWebView, context: Context) {}
+
+ final class Coordinator: NSObject, WKNavigationDelegate {
+ let onSignInDetected: @MainActor (WKHTTPCookieStore) async -> Void
+ private var didComplete = false
+
+ init(onSignInDetected: @escaping @MainActor (WKHTTPCookieStore) async -> Void) {
+ self.onSignInDetected = onSignInDetected
+ }
+
+ func webView(
+ _ webView: WKWebView,
+ decidePolicyFor navigationAction: WKNavigationAction
+ ) async -> WKNavigationActionPolicy {
+ guard !didComplete,
+ let url = navigationAction.request.url,
+ url.host?.contains("nytimes.com") == true,
+ url.path.hasPrefix("/crosswords") else {
+ return .allow
+ }
+
+ // The user has signed in and is being redirected to the crosswords
+ // page. Extract cookies and signal completion.
+ didComplete = true
+ let cookieStore = webView.configuration.websiteDataStore.httpCookieStore
+ await onSignInDetected(cookieStore)
+ return .cancel
+ }
+ }
+}
diff --git a/Crossmate/Views/SettingsView.swift b/Crossmate/Views/SettingsView.swift
@@ -0,0 +1,97 @@
+import SwiftUI
+
+struct SettingsView: View {
+ @Environment(NYTAuthService.self) private var nytAuth
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var showingNYTLogin = false
+ @State private var fetchResult: String?
+ @State private var isFetching = false
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("NYT Account") {
+ if nytAuth.isSignedIn {
+ signedInView
+ } else {
+ signInView
+ }
+ }
+ }
+ .navigationTitle("Settings")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") { dismiss() }
+ }
+ }
+ .sheet(isPresented: $showingNYTLogin) {
+ NYTLoginView()
+ }
+ }
+ }
+
+ // MARK: - Subviews
+
+ @ViewBuilder
+ private var signedInView: some View {
+ LabeledContent("Email", value: nytAuth.signedInEmail ?? "")
+ Button("Sign Out", role: .destructive) {
+ nytAuth.signOut()
+ }
+ Button {
+ isFetching = true
+ fetchResult = nil
+ let cookie = nytAuth.cookie
+ Task.detached {
+ let fetcher = NYTPuzzleFetcher { cookie }
+ do {
+ let xdSource = try await fetcher.fetchPuzzle(for: .now)
+ // Verify the XD parses correctly
+ let xd = try XD.parse(xdSource)
+ let title = xd.title ?? "Untitled"
+ let clueCount = xd.acrossClues.count + xd.downClues.count
+ await MainActor.run {
+ fetchResult = "Success: \"\(title)\" — \(clueCount) clues. Check console."
+ isFetching = false
+ }
+ } catch {
+ print("=== FETCH/PARSE ERROR ===")
+ print(error)
+ print("=== END ERROR ===")
+ await MainActor.run {
+ fetchResult = "Error: \(error.localizedDescription)"
+ isFetching = false
+ }
+ }
+ }
+ } label: {
+ HStack {
+ Text("Fetch Today's Puzzle")
+ if isFetching {
+ Spacer()
+ ProgressView()
+ }
+ }
+ }
+ .disabled(isFetching)
+ if let fetchResult {
+ Text(fetchResult)
+ .font(.caption)
+ .foregroundStyle(fetchResult.hasPrefix("Success") ? .green : .red)
+ }
+ }
+
+ @ViewBuilder
+ private var signInView: some View {
+ if let errorMessage = nytAuth.errorMessage {
+ Text(errorMessage)
+ .foregroundStyle(.red)
+ .font(.caption)
+ }
+ Button("Sign In with NYT") {
+ showingNYTLogin = true
+ }
+ }
+}