Files
steipete--codexbar/Sources/CodexBar/CursorLoginRunner.swift
T
2026-07-13 12:22:33 +08:00

118 lines
4.2 KiB
Swift

import AppKit
import CodexBarCore
import Foundation
/// Opens Cursor in the user's browser and waits until the normal browser-cookie importer can read a session.
@MainActor
final class CursorLoginRunner {
enum Phase {
case loading
case waitingLogin
case success
case failed(String)
}
struct Result {
enum Outcome {
case success
case cancelled
case failed(String)
}
let outcome: Outcome
let email: String?
}
typealias SnapshotLoader = @Sendable () async throws -> CursorStatusSnapshot
typealias Sleeper = @Sendable (UInt64) async throws -> Void
typealias SessionCacheResetter = @Sendable () async -> Void
private let loadSnapshot: SnapshotLoader
private let openURL: @MainActor (URL) -> Bool
private let sleeper: Sleeper
private let resetSessionCache: SessionCacheResetter
private let timeout: TimeInterval
private let pollInterval: TimeInterval
private let logger = CodexBarLog.logger(LogCategories.cursorLogin)
static let authURL = URL(string: "https://authenticator.cursor.sh/")!
init(
browserDetection: BrowserDetection,
timeout: TimeInterval = 120,
pollInterval: TimeInterval = 2,
openURL: @escaping @MainActor (URL) -> Bool = { NSWorkspace.shared.open($0) },
loadSnapshot: SnapshotLoader? = nil,
sleeper: @escaping Sleeper = { try await Task.sleep(nanoseconds: $0) },
resetSessionCache: @escaping SessionCacheResetter = {
CookieHeaderCache.clear(provider: .cursor)
CursorSessionStore.shared.clearCookies()
})
{
self.timeout = timeout
self.pollInterval = pollInterval
self.openURL = openURL
self.sleeper = sleeper
self.resetSessionCache = resetSessionCache
self.loadSnapshot = loadSnapshot ?? {
let probe = CursorStatusProbe(browserDetection: browserDetection)
return try await probe.fetch(
allowCachedSessions: false,
allowAppAuthFallback: false)
}
}
func run(onPhaseChange: @escaping @MainActor (Phase) -> Void) async -> Result {
onPhaseChange(.loading)
self.logger.info("Cursor login started")
await self.resetSessionCache()
guard self.openURL(Self.authURL) else {
let message = L("Could not open Cursor login in your browser.")
onPhaseChange(.failed(message))
self.logger.error("Cursor login browser launch failed")
return Result(outcome: .failed(message), email: nil)
}
onPhaseChange(.waitingLogin)
let deadline = Date().addingTimeInterval(self.timeout)
var lastError: Error?
repeat {
if Task.isCancelled {
self.logger.info("Cursor login cancelled")
return Result(outcome: .cancelled, email: nil)
}
do {
let snapshot = try await self.loadSnapshot()
onPhaseChange(.success)
self.logger.info("Cursor login completed", metadata: ["outcome": "success"])
return Result(outcome: .success, email: snapshot.accountEmail)
} catch {
lastError = error
}
guard Date() < deadline else { break }
let delay = UInt64(max(0.1, self.pollInterval) * 1_000_000_000)
try? await self.sleeper(delay)
} while true
let message = Self.timeoutMessage(lastError: lastError)
onPhaseChange(.failed(message))
self.logger.warning("Cursor login timed out", metadata: ["error": message])
return Result(outcome: .failed(message), email: nil)
}
private static func timeoutMessage(lastError: Error?) -> String {
let hint = L("Sign in to cursor.com in your browser, then refresh Cursor in CodexBar.")
guard let lastError else {
return String(format: L("Timed out waiting for Cursor login. %@"), hint)
}
return String(
format: L("Timed out waiting for Cursor login. %@ Last error: %@"),
hint,
lastError.localizedDescription)
}
}