crossmate

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

commit 642d5ecccb5b231031cce6986b55a095756c2dbc
parent f3182bd155c30cfde0cfd32d7bb17b9181177a07
Author: Michael Camilleri <[email protected]>
Date:   Sun, 12 Apr 2026 11:09:20 +0900

Settings and NYT authentication

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 32++++++++++++++++++++++++++++++++
MCrossmate/CrossmateApp.swift | 5+++++
ACrossmate/Services/KeychainHelper.swift | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Services/NYTAuthService.swift | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Services/NYTPuzzleFetcher.swift | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Services/NYTToXDConverter.swift | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/GameListView.swift | 11+++++++++++
ACrossmate/Views/NYTLoginView.swift | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Views/SettingsView.swift | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 + } + } +}