513 lines · 20.0 KB · Swift
RawDownload
import AppKitimport CodexBarCoreimport Foundation private func normalizedCursorAccountID(_ value: String?) -> String? {    guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {        return nil    }    return value} private func normalizedCursorAccountEmail(_ value: String?) -> String? {    guard let value = value?        .trimmingCharacters(in: .whitespacesAndNewlines)        .lowercased(),        !value.isEmpty    else {        return nil    }    return value} /// Opens Cursor in a concrete browser and waits until that browser's cookie store exposes a session.@MainActorfinal class CursorLoginRunner {    struct AccountIdentity: Equatable, Sendable {        let accountID: String?        let email: String?         init(accountID: String? = nil, email: String?) {            self.accountID = accountID            self.email = email        }         fileprivate var hasIdentity: Bool {            normalizedCursorAccountID(self.accountID) != nil ||                normalizedCursorAccountEmail(self.email) != nil        }    }     struct AccountPolicy: Equatable, Sendable {        let priorAccount: AccountIdentity?        let requiresConfirmation: Bool    }     static func accountPolicy(        configuredSource: ProviderCookieSource,        identity: ProviderIdentitySnapshot?,        hasPriorSnapshot: Bool) -> AccountPolicy    {        guard hasPriorSnapshot else {            return AccountPolicy(priorAccount: nil, requiresConfirmation: false)        }        let account = AccountIdentity(accountID: identity?.accountID, email: identity?.accountEmail)        return AccountPolicy(            priorAccount: configuredSource == .auto ? account : nil,            requiresConfirmation: true)    }     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?    }     struct SnapshotLoadResult: Sendable {        let snapshot: CursorStatusSnapshot        let session: CursorStatusProbe.BrowserLoginSession?        let sourceLabel: String?         init(            snapshot: CursorStatusSnapshot,            session: CursorStatusProbe.BrowserLoginSession?,            sourceLabel: String? = nil)        {            self.snapshot = snapshot            self.session = session            self.sourceLabel = sourceLabel        }    }     typealias SnapshotLoader = @Sendable () async throws -> CursorStatusSnapshot    typealias BrowserLoginCandidatesLoader = @Sendable (URL, TimeInterval) async throws        -> [CursorStatusProbe.BrowserLoginResult]    typealias Sleeper = @Sendable (UInt64) async throws -> Void    typealias SessionCacheReplacer = @MainActor @Sendable (CursorStatusProbe.BrowserLoginSession) async -> Bool    typealias RouteLauncher = @MainActor (CursorLoginBrowserRouter.Route) async -> Bool    typealias BrowserApplicationResolver = @MainActor (URL) -> URL?    typealias RouteResolver = @MainActor (URL, URL?) -> CursorLoginBrowserRouter.Resolution    typealias AccountChooser = CursorLoginAccountSelector.Chooser     private enum CandidateSelection {        case none        case selected(SnapshotLoadResult)        case cancelled    }     private enum RoutePreparation {        case ready(CursorLoginBrowserRouter.Route)        case terminal(Result)    }     private let loadBrowserLoginCandidates: @Sendable (URL, TimeInterval) async throws -> [SnapshotLoadResult]    private let launchRoute: RouteLauncher    private let sleeper: Sleeper    private let replaceSessionCache: SessionCacheReplacer    private let priorAccount: AccountIdentity?    private let requiresAccountConfirmation: Bool    private let browserApplicationResolver: BrowserApplicationResolver    private let routeResolver: RouteResolver    private let accountChooser: AccountChooser?    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,        priorAccount: AccountIdentity? = nil,        requiresAccountConfirmation: Bool? = nil,        timeout: TimeInterval = 120,        pollInterval: TimeInterval = 2,        launchRoute: @escaping RouteLauncher = { route in await CursorLoginRunner.launch(route) },        loadSnapshot: SnapshotLoader? = nil,        loadBrowserLoginCandidates: BrowserLoginCandidatesLoader? = nil,        sleeper: @escaping Sleeper = { try await Task.sleep(nanoseconds: $0) },        browserApplicationResolver: @escaping BrowserApplicationResolver = {            NSWorkspace.shared.urlForApplication(toOpen: $0)        },        routeResolver: RouteResolver? = nil,        accountChooser: AccountChooser? = nil,        replaceSessionCache: @escaping SessionCacheReplacer = { session in            await CursorLoginRunner.replaceCachedSession(session)        })    {        self.priorAccount = priorAccount        self.requiresAccountConfirmation = requiresAccountConfirmation ?? (priorAccount != nil)        self.browserApplicationResolver = browserApplicationResolver        self.routeResolver = routeResolver ?? { loginURL, handlerApplicationURL in            CursorLoginBrowserRouter.resolve(                loginURL: loginURL,                handlerApplicationURL: handlerApplicationURL,                supportsBrowser: { applicationURL in                    CursorStatusProbe.supportsInteractiveLoginBrowser(                        applicationURL: applicationURL,                        browserDetection: browserDetection)                })        }        self.accountChooser = accountChooser        self.timeout = timeout        self.pollInterval = pollInterval        self.launchRoute = launchRoute        self.sleeper = sleeper        self.replaceSessionCache = replaceSessionCache        if let loadBrowserLoginCandidates {            self.loadBrowserLoginCandidates = { browserApplicationURL, timeout in                try await loadBrowserLoginCandidates(browserApplicationURL, timeout).map { result in                    SnapshotLoadResult(                        snapshot: result.snapshot,                        session: result.session,                        sourceLabel: result.sourceLabel)                }            }        } else if let loadSnapshot {            self.loadBrowserLoginCandidates = { _, _ in                let snapshot = try await loadSnapshot()                return [SnapshotLoadResult(snapshot: snapshot, session: nil)]            }        } else {            self.loadBrowserLoginCandidates = { browserApplicationURL, timeout in                let probe = CursorStatusProbe(browserDetection: browserDetection)                return try await probe.fetchBrowserLoginCandidates(                    browserApplicationURL: browserApplicationURL,                    timeout: timeout).map { result in                    SnapshotLoadResult(                        snapshot: result.snapshot,                        session: result.session,                        sourceLabel: result.sourceLabel)                }            }        }    }     func run(onPhaseChange: @escaping @MainActor (Phase) -> Void) async -> Result {        await BrowserCookieAccessGate.withExplicitRetry {            await ProviderInteractionContext.$current.withValue(.userInitiated) {                await self.runUserInitiated(onPhaseChange: onPhaseChange)            }        }    }     private func runUserInitiated(onPhaseChange: @escaping @MainActor (Phase) -> Void) async -> Result {        onPhaseChange(.loading)        self.logger.info("Cursor login started")        guard !Task.isCancelled else {            self.logger.info("Cursor login cancelled before cache ownership")            return Result(outcome: .cancelled, email: nil)        }         let cacheMutationGate = CookieHeaderCache.beginConditionalMutationGate(provider: .cursor)        defer { CookieHeaderCache.endConditionalMutationGate(cacheMutationGate) }         let route: CursorLoginBrowserRouter.Route        switch self.prepareRoute(onPhaseChange: onPhaseChange) {        case let .ready(preparedRoute):            route = preparedRoute        case let .terminal(result):            return result        }         guard !Task.isCancelled else {            return self.cancelAfterTaskCancellation()        }        let launched = await self.launchRoute(route)        guard !Task.isCancelled else {            return self.cancelAfterTaskCancellation()        }        guard launched 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 let cancellation = self.continuationCancellationResult() {                return cancellation            }             do {                let remainingTime = deadline.timeIntervalSinceNow                guard remainingTime > 0 else { break }                let loaded = try await self.loadBrowserLoginCandidates(                    route.browserApplicationURL,                    remainingTime)                if let cancellation = self.continuationCancellationResult() {                    return cancellation                }                if let result = await self.completeLoadedCandidates(                    loaded,                    onPhaseChange: onPhaseChange)                {                    return result                }            } catch {                if Task.isCancelled {                    return self.cancelAfterTaskCancellation()                }                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         if Task.isCancelled {            return self.cancelAfterTaskCancellation()        }        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 func continuationCancellationResult() -> Result? {        guard !Task.isCancelled else {            return self.cancelAfterTaskCancellation()        }        return nil    }     private func prepareRoute(onPhaseChange: @MainActor (Phase) -> Void) -> RoutePreparation {        let loginURL = Self.authURL        let handlerApplicationURL = self.browserApplicationResolver(loginURL)        let route: CursorLoginBrowserRouter.Route         switch self.routeResolver(loginURL, handlerApplicationURL) {        case let .route(resolvedRoute):            route = CursorLoginBrowserRouter.Route(                launchURL: loginURL,                browserApplicationURL: resolvedRoute.browserApplicationURL)        case .cancelled:            self.logger.info("Cursor login browser selection cancelled")            return .terminal(Result(outcome: .cancelled, email: nil))        case .unavailable:            let message = Self.unsupportedBrowserMessage(applicationURL: handlerApplicationURL)            onPhaseChange(.failed(message))            self.logger.error("Cursor login browser unavailable", metadata: ["error": message])            return .terminal(Result(outcome: .failed(message), email: nil))        }         return .ready(route)    }     private func completeLoadedCandidates(        _ loaded: [SnapshotLoadResult],        onPhaseChange: @MainActor (Phase) -> Void) async -> Result?    {        switch self.selectCandidate(from: loaded) {        case .none:            return nil        case .cancelled:            self.logger.info("Cursor login account selection cancelled")            return Result(outcome: .cancelled, email: nil)        case let .selected(candidate):            guard !Task.isCancelled else {                return self.cancelAfterTaskCancellation()            }            return await self.completeAcceptedLogin(                candidate,                onPhaseChange: onPhaseChange)        }    }     private func selectCandidate(from loaded: [SnapshotLoadResult]) -> CandidateSelection {        let candidates = self.deduplicatedCandidates(from: loaded)         guard !candidates.isEmpty else { return .none }        // A sole Add candidate is unambiguous.        // Switching still needs confirmation because browser profiles can be stale.        guard self.requiresAccountConfirmation || candidates.count > 1 else {            return .selected(candidates[0])        }         let presentedCandidates = candidates.enumerated().map { index, candidate in            CursorLoginAccountSelector.Candidate(                selectionID: "cursor-candidate-\(index)",                name: candidate.snapshot.accountName,                email: candidate.snapshot.accountEmail,                sourceLabel: candidate.sourceLabel ?? L("Browser"))        }        let selectedID: String? = if let accountChooser {            CursorLoginAccountSelector.selectCandidateID(                from: presentedCandidates,                chooser: accountChooser)        } else {            CursorLoginAccountSelector.selectCandidateID(from: presentedCandidates)        }        guard let selectedID,              let selectedIndex = presentedCandidates.firstIndex(where: { $0.selectionID == selectedID })        else {            return .cancelled        }        return .selected(candidates[selectedIndex])    }     private func deduplicatedCandidates(from loaded: [SnapshotLoadResult]) -> [SnapshotLoadResult] {        var candidates: [SnapshotLoadResult] = []         for candidate in loaded where Self.isAcceptableAccount(candidate.snapshot, priorAccount: self.priorAccount) {            let accountID = normalizedCursorAccountID(candidate.snapshot.accountID)            let email = normalizedCursorAccountEmail(candidate.snapshot.accountEmail)             if let accountID {                if candidates.contains(where: {                    normalizedCursorAccountID($0.snapshot.accountID) == accountID                }) {                    continue                }                if let email,                   let emailOnlyIndex = candidates.firstIndex(where: {                       normalizedCursorAccountID($0.snapshot.accountID) == nil &&                           normalizedCursorAccountEmail($0.snapshot.accountEmail) == email                   })                {                    candidates[emailOnlyIndex] = candidate                } else {                    candidates.append(candidate)                }                continue            }             guard let email else { continue }            if candidates.contains(where: {                normalizedCursorAccountEmail($0.snapshot.accountEmail) == email            }) {                continue            }            candidates.append(candidate)        }         return candidates    }     private func completeAcceptedLogin(        _ loaded: SnapshotLoadResult,        onPhaseChange: @MainActor (Phase) -> Void) async -> Result    {        let snapshot = loaded.snapshot        if let session = loaded.session {            guard await self.replaceSessionCache(session) else {                let message = L("Cursor login failed")                onPhaseChange(.failed(message))                self.logger.error("Cursor login session cache commit failed")                return Result(outcome: .failed(message), email: nil)            }        }        onPhaseChange(.success)        self.logger.info("Cursor login completed", metadata: ["outcome": "success"])        return Result(outcome: .success, email: snapshot.accountEmail)    }     private func cancelAfterTaskCancellation() -> Result {        self.logger.info("Cursor login cancelled")        return Result(outcome: .cancelled, email: nil)    }     @MainActor    static func replaceCachedSession(        _ session: CursorStatusProbe.BrowserLoginSession,        afterCommit: @MainActor () -> Void = {}) async -> Bool    {        // Candidate discovery is cache-independent. Keep both active stores intact until the replacement is durable.        guard CursorStatusProbe.commitBrowserLoginSession(session) else { return false }        afterCommit()        await CursorSessionStore.shared.clearCookies()        return true    }     private static func launch(_ route: CursorLoginBrowserRouter.Route) async -> Bool {        let configuration = NSWorkspace.OpenConfiguration()        configuration.activates = true        do {            _ = try await NSWorkspace.shared.open(                [route.launchURL],                withApplicationAt: route.browserApplicationURL,                configuration: configuration)            return true        } catch {            return false        }    }     private nonisolated static func isAcceptableAccount(        _ snapshot: CursorStatusSnapshot,        priorAccount: AccountIdentity?) -> Bool    {        guard let priorAccount else {            return normalizedCursorAccountID(snapshot.accountID) != nil ||                normalizedCursorAccountEmail(snapshot.accountEmail) != nil        }         guard priorAccount.hasIdentity else {            // Preserve Switch intent when the current usage response lacks identity metadata. The candidate still            // requires explicit confirmation because `selectCandidate` sees a non-nil prior account.            return normalizedCursorAccountID(snapshot.accountID) != nil ||                normalizedCursorAccountEmail(snapshot.accountEmail) != nil        }         if let priorAccountID = normalizedCursorAccountID(priorAccount.accountID),           let candidateAccountID = normalizedCursorAccountID(snapshot.accountID)        {            return candidateAccountID != priorAccountID        }         guard let priorEmail = normalizedCursorAccountEmail(priorAccount.email),              let candidateEmail = normalizedCursorAccountEmail(snapshot.accountEmail)        else { return false }        return candidateEmail != priorEmail    }     private func timeoutMessage(lastError: Error?) -> String {        if self.priorAccount != nil {            let hint = L("Finish switching to a different Cursor account in your browser, then try again.")            guard let lastError else {                return String(format: L("Timed out waiting for Cursor account switch. %@"), hint)            }            return String(                format: L("Timed out waiting for Cursor account switch. %@ Last error: %@"),                hint,                lastError.localizedDescription)        }         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)    } 

Lines 1–500 of 513.