commit d6dd57f293fb8b545143caeb51e2818ae9be0992
parent cbed8247852072c30b58bae734816fb96bd7797a
Author: Michael Camilleri <[email protected]>
Date: Mon, 8 Jun 2026 23:20:13 +0900
Preserve unknown provider sessions
This commit makes external provider authentication distinguish a
temporarily unavailable keychain read from a real signed-out state. A
startup read that returns `errSecInteractionNotAllowed` now leaves the
session as unknown instead of clearing the signed-in UI, so a
locked-device launch does not make Crossmate appear logged out. Cookie
reads used by the provider fetcher carry the same state through to
user-facing errors rather than collapsing every missing cookie value
into 'not signed in'.
Settings and the puzzle catalog now keep provider tab visible while the
session status is unknown and offer a Try Again action to re-read the
stored cookie after the device is available. The external provider
session cookies are also stored with an explicit after-first-unlock
keychain accessibility class, and existing readable cookies are migrated
in place.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
8 files changed, 242 insertions(+), 54 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -198,7 +198,7 @@ final class AppServices {
self.nytAuth = NYTAuthService(log: { message in
eventLog.note(message)
})
- self.nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookie() }
+ self.nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookieResult() }
self.inputMonitor = InputMonitor()
let identity = AuthorIdentity()
self.identity = identity
diff --git a/Crossmate/Services/KeychainHelper.swift b/Crossmate/Services/KeychainHelper.swift
@@ -11,11 +11,20 @@ enum KeychainHelper {
kSecAttrAccount as String: key,
]
- // Delete any existing item first.
- SecItemDelete(query as CFDictionary)
+ let update: [String: Any] = [
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ ]
+ let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
+ if updateStatus == errSecSuccess { return }
+ guard updateStatus == errSecItemNotFound else {
+ throw KeychainError.unhandledError(status: updateStatus)
+ }
var attributes = query
- attributes[kSecValueData as String] = data
+ for (key, value) in update {
+ attributes[key] = value
+ }
let status = SecItemAdd(attributes as CFDictionary, nil)
guard status == errSecSuccess else {
diff --git a/Crossmate/Services/NYTAuthService.swift b/Crossmate/Services/NYTAuthService.swift
@@ -3,10 +3,21 @@ import Security
import UIKit
import WebKit
+enum NYTSessionState: Equatable {
+ case unknown
+ case signedOut
+ case signedIn(email: String?)
+}
+
+enum NYTCookieLoadResult: Equatable, Sendable {
+ case available(String)
+ case missing
+ case temporarilyUnavailable
+}
+
@MainActor @Observable
final class NYTAuthService {
- private(set) var isSignedIn = false
- private(set) var signedInEmail: String?
+ private(set) var sessionState: NYTSessionState = .unknown
private(set) var isLoading = false
var errorMessage: String?
private let log: ((String) -> Void)?
@@ -20,8 +31,13 @@ final class NYTAuthService {
/// Thread-safe read of the stored NYT cookie for use from non-MainActor contexts.
nonisolated static func currentCookie() -> String? {
- guard let data = KeychainHelper.load(key: cookieKey) else { return nil }
- return String(data: data, encoding: .utf8)
+ guard case .available(let cookie) = currentCookieResult() else { return nil }
+ return cookie
+ }
+
+ /// Thread-safe read of the stored NYT cookie that preserves transient keychain failures.
+ nonisolated static func currentCookieResult() -> NYTCookieLoadResult {
+ cookieLoadResult(from: KeychainHelper.loadWithStatus(key: cookieKey))
}
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")!
@@ -29,20 +45,15 @@ final class NYTAuthService {
func loadStoredSession() {
let cookieResult = KeychainHelper.loadWithStatus(key: Self.cookieKey)
logCookieKeychainLoad(result: cookieResult)
- guard cookieResult.data != nil else {
- isSignedIn = false
- signedInEmail = nil
- return
- }
- isSignedIn = true
- let emailResult = KeychainHelper.loadWithStatus(key: Self.emailKey)
- if let emailData = emailResult.data,
- let email = String(data: emailData, encoding: .utf8),
- !email.isEmpty {
- signedInEmail = email
- } else {
- signedInEmail = nil
+ switch Self.cookieLoadResult(from: cookieResult) {
+ case .available(let cookie):
+ migrateCookieAccessibility(cookie)
+ sessionState = .signedIn(email: storedEmail())
+ case .missing:
+ sessionState = .signedOut
+ case .temporarilyUnavailable:
+ sessionState = .unknown
}
}
@@ -82,8 +93,7 @@ final class NYTAuthService {
} else {
KeychainHelper.delete(key: Self.emailKey)
}
- isSignedIn = true
- signedInEmail = email
+ sessionState = .signedIn(email: email)
} catch {
errorMessage = error.localizedDescription
}
@@ -92,14 +102,35 @@ final class NYTAuthService {
func signOut() {
KeychainHelper.delete(key: Self.emailKey)
KeychainHelper.delete(key: Self.cookieKey)
- isSignedIn = false
- signedInEmail = nil
+ sessionState = .signedOut
errorMessage = nil
}
var cookie: String? {
- guard let data = KeychainHelper.load(key: Self.cookieKey) else { return nil }
- return String(data: data, encoding: .utf8)
+ Self.currentCookie()
+ }
+
+ var isSignedIn: Bool {
+ if case .signedIn = sessionState { return true }
+ return false
+ }
+
+ var signedInEmail: String? {
+ if case .signedIn(let email) = sessionState { return email }
+ return nil
+ }
+
+ var canAttemptNYTFetch: Bool {
+ sessionState != .signedOut
+ }
+
+ var sessionStatusMessage: String? {
+ switch sessionState {
+ case .unknown:
+ "Crossmate could not determine whether you are signed in to NYT. Unlock the device and try again."
+ case .signedOut, .signedIn:
+ nil
+ }
}
// MARK: - Private
@@ -127,6 +158,52 @@ final class NYTAuthService {
return "OSStatus(\(status))"
}
+ nonisolated static func sessionState(
+ forCookieLoadResult result: NYTCookieLoadResult,
+ email: String? = nil
+ ) -> NYTSessionState {
+ switch result {
+ case .available:
+ .signedIn(email: email)
+ case .missing:
+ .signedOut
+ case .temporarilyUnavailable:
+ .unknown
+ }
+ }
+
+ nonisolated private static func cookieLoadResult(
+ from result: (data: Data?, status: OSStatus)
+ ) -> NYTCookieLoadResult {
+ guard result.status == errSecSuccess,
+ let data = result.data,
+ let cookie = String(data: data, encoding: .utf8),
+ !cookie.isEmpty else {
+ return result.status == errSecInteractionNotAllowed
+ ? .temporarilyUnavailable
+ : .missing
+ }
+ return .available(cookie)
+ }
+
+ private func storedEmail() -> String? {
+ let emailResult = KeychainHelper.loadWithStatus(key: Self.emailKey)
+ guard let emailData = emailResult.data,
+ let email = String(data: emailData, encoding: .utf8),
+ !email.isEmpty else {
+ return nil
+ }
+ return email
+ }
+
+ private func migrateCookieAccessibility(_ cookie: String) {
+ do {
+ try KeychainHelper.save(key: Self.cookieKey, data: Data(cookie.utf8))
+ } catch {
+ log?("NYT session cookie keychain migration failed: \(error)")
+ }
+ }
+
private func fetchUserEmail(cookies: [HTTPCookie]) async throws -> String? {
let cookieHeader = Self.cookieHeader(for: cookies)
guard let configuration = try await fetchAccountGraphQLConfiguration(
diff --git a/Crossmate/Services/NYTPuzzleFetcher.swift b/Crossmate/Services/NYTPuzzleFetcher.swift
@@ -6,18 +6,16 @@ extension EnvironmentValues {
}
actor NYTPuzzleFetcher {
- private let cookieProvider: @Sendable () -> String?
+ private let cookieProvider: @Sendable () -> NYTCookieLoadResult
- init(cookieProvider: @escaping @Sendable () -> String?) {
+ init(cookieProvider: @escaping @Sendable () -> NYTCookieLoadResult) {
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 cookie = try currentCookie()
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
@@ -84,9 +82,7 @@ actor NYTPuzzleFetcher {
}
func fetchPuzzle(for date: Date) async throws -> String {
- guard let cookie = cookieProvider() else {
- throw NYTFetchError.notSignedIn
- }
+ let cookie = try currentCookie()
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
@@ -136,10 +132,22 @@ actor NYTPuzzleFetcher {
return xdSource
}
+
+ private func currentCookie() throws -> String {
+ switch cookieProvider() {
+ case .available(let cookie):
+ cookie
+ case .missing:
+ throw NYTFetchError.notSignedIn
+ case .temporarilyUnavailable:
+ throw NYTFetchError.sessionStatusUnavailable
+ }
+ }
}
enum NYTFetchError: LocalizedError {
case notSignedIn
+ case sessionStatusUnavailable
case invalidResponse
case unauthorized
case rateLimited
@@ -149,6 +157,8 @@ enum NYTFetchError: LocalizedError {
switch self {
case .notSignedIn:
"Not signed in to NYT."
+ case .sessionStatusUnavailable:
+ "Crossmate could not determine whether you are signed in to NYT. Unlock the device and try again."
case .invalidResponse:
"Received an invalid response."
case .unauthorized:
diff --git a/Crossmate/Views/NYTBrowseView.swift b/Crossmate/Views/NYTBrowseView.swift
@@ -91,21 +91,14 @@ struct NYTBrowseView: View {
private let weekdaySymbols = ["S", "M", "T", "W", "T", "F", "S"]
var body: some View {
- VStack(spacing: 16) {
- monthHeader
- weekdayHeader
- dayGrid
- Spacer()
- confirmButton
- randomButton
- }
- .padding()
- .disabled(isLoading)
- .overlay {
- if isLoading {
- ProgressView("Fetching puzzle…")
- .padding()
- .background(.regularMaterial, in: .rect(cornerRadius: 12))
+ Group {
+ switch nytAuth.sessionState {
+ case .unknown:
+ unavailableSessionView
+ case .signedIn:
+ browserView
+ case .signedOut:
+ signedOutView
}
}
.alert(
@@ -129,6 +122,64 @@ struct NYTBrowseView: View {
}
}
+ private var browserView: some View {
+ VStack(spacing: 16) {
+ monthHeader
+ weekdayHeader
+ dayGrid
+ Spacer()
+ confirmButton
+ randomButton
+ }
+ .padding()
+ .disabled(isLoading)
+ .overlay {
+ if isLoading {
+ ProgressView("Fetching puzzle…")
+ .padding()
+ .background(.regularMaterial, in: .rect(cornerRadius: 12))
+ }
+ }
+ }
+
+ private var unavailableSessionView: some View {
+ VStack(spacing: 16) {
+ Spacer()
+ Image(systemName: "lock.rotation")
+ .font(.largeTitle)
+ .foregroundStyle(.secondary)
+ Text(nytAuth.sessionStatusMessage ?? "Crossmate could not determine whether you are signed in to NYT.")
+ .font(.body)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ Button {
+ nytAuth.loadStoredSession()
+ } label: {
+ Label("Try Again", systemImage: "arrow.clockwise")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ Spacer()
+ }
+ .padding()
+ }
+
+ private var signedOutView: some View {
+ VStack(spacing: 16) {
+ Spacer()
+ Image(systemName: "person.crop.circle.badge.exclamationmark")
+ .font(.largeTitle)
+ .foregroundStyle(.secondary)
+ Text("Sign in to NYT from Settings to fetch crossword puzzles.")
+ .font(.body)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ Spacer()
+ }
+ .padding()
+ }
+
private var monthHeader: some View {
HStack {
Button {
diff --git a/Crossmate/Views/NewGameSheet.swift b/Crossmate/Views/NewGameSheet.swift
@@ -18,7 +18,7 @@ struct NewGameSheet: View {
sources.append(.debug)
}
sources.append(.imported)
- if nytAuth.isSignedIn {
+ if nytAuth.canAttemptNYTFetch {
sources.append(.nyt)
}
return sources
diff --git a/Crossmate/Views/SettingsView.swift b/Crossmate/Views/SettingsView.swift
@@ -29,10 +29,13 @@ struct SettingsView: View {
case nil:
EmptyView()
case .newYorkTimes:
- if nytAuth.isSignedIn {
+ switch nytAuth.sessionState {
+ case .signedIn:
signedInView
- } else {
+ case .signedOut:
signInView
+ case .unknown:
+ unknownSessionView
}
}
}
@@ -102,7 +105,7 @@ struct SettingsView: View {
NYTLoginView()
}
.onAppear {
- if externalSource == nil, nytAuth.isSignedIn {
+ if externalSource == nil, nytAuth.canAttemptNYTFetch {
externalSource = .newYorkTimes
}
}
@@ -146,6 +149,20 @@ struct SettingsView: View {
showingNYTLogin = true
}
}
+
+ @ViewBuilder
+ private var unknownSessionView: some View {
+ if let message = nytAuth.sessionStatusMessage {
+ Text(message)
+ .foregroundStyle(.secondary)
+ .font(.caption)
+ }
+ Button {
+ nytAuth.loadStoredSession()
+ } label: {
+ Label("Try Again", systemImage: "arrow.clockwise")
+ }
+ }
}
/// Live tuning for the finish-banner replay autoplay, backed by the same
diff --git a/Tests/Unit/NYTAuthServiceTests.swift b/Tests/Unit/NYTAuthServiceTests.swift
@@ -5,6 +5,30 @@ import Testing
@Suite("NYT auth service")
struct NYTAuthServiceTests {
+ @Test("Maps temporary keychain failures to unknown session state")
+ func mapsTemporaryKeychainFailuresToUnknownState() {
+ let state = NYTAuthService.sessionState(forCookieLoadResult: .temporarilyUnavailable)
+
+ #expect(state == .unknown)
+ }
+
+ @Test("Maps missing cookie to signed out session state")
+ func mapsMissingCookieToSignedOutState() {
+ let state = NYTAuthService.sessionState(forCookieLoadResult: .missing)
+
+ #expect(state == .signedOut)
+ }
+
+ @Test("Maps available cookie to signed in session state")
+ func mapsAvailableCookieToSignedInState() {
+ let state = NYTAuthService.sessionState(
+ forCookieLoadResult: .available("cookie"),
+ email: "[email protected]"
+ )
+
+ #expect(state == .signedIn(email: "[email protected]"))
+ }
+
@Test("Extracts email from GraphQL profile")
func extractsEmailFromGraphQLProfile() throws {
let data = Data("""