diff --git a/CHANGELOG.md b/CHANGELOG.md index 9063f9e7da..280ee12b94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Codex dashboard: show calendar-correct raw Today and 30-day credit totals without converting credits to billed dollars. Thanks @avenoxai! +- Cursor: read the signed-in app token on Linux, with explicit manual-cookie web-source support and XDG config paths. Thanks @DonnieFi! - Devin: show remaining extra-usage balance in menus, CLI, and widgets while respecting optional-usage visibility. Thanks @FNDEVVE! - Widgets: make Mistral available in provider selection and switching. Thanks @joeVenner! diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index 7f822a3b1b..e92adb45fc 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -606,6 +606,14 @@ extension CodexBarCLI { { return false } + #if os(Linux) + if provider == .cursor, + settings?.cursor?.cookieSource != .off + { + // Linux uses Cursor app auth and manual cookies; browser import remains macOS-only. + return false + } + #endif if provider == .sakana, sourceMode == .auto || sourceMode == .web, environment.map({ SakanaSettingsReader.cookieHeader(environment: $0) != nil }) == true diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift index c6f1ff35fa..983cae2dc9 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift @@ -33,7 +33,7 @@ public enum CursorProviderDescriptor { supportsTokenCost: false, noDataMessage: { "Cursor cost summary is not supported." }), fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .cli], + sourceModes: [.auto, .cli, .web], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CursorStatusFetchStrategy()] })), cli: ProviderCLIConfig( name: "cursor", diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index 6bb82fa037..bff2b00cb8 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -5,12 +5,18 @@ import FoundationNetworking import SweetCookieKit #if canImport(SQLite3) import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 #endif -#if os(macOS) +#if os(macOS) || os(Linux) +#if os(macOS) private let cursorCookieImportOrder: BrowserCookieImportOrder = ProviderDefaults.metadata[.cursor]?.browserCookieOrder ?? Browser.defaultImportOrder +#endif + +#if os(macOS) // MARK: - Cursor Cookie Importer @@ -186,6 +192,7 @@ public enum CursorCookieImporter { } } } +#endif // MARK: - Cursor API Models @@ -408,10 +415,7 @@ protocol CursorAppAuthSessionProviding: Sendable { } struct CursorAppAuthStore: CursorAppAuthSessionProviding { - private static let defaultDBPath: String = { - let home = NSHomeDirectory() - return "\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb" - }() + private static let defaultDBPath: String = Self.resolveDefaultDBPath() private let dbPath: String @@ -419,6 +423,36 @@ struct CursorAppAuthStore: CursorAppAuthSessionProviding { self.dbPath = dbPath ?? Self.defaultDBPath } + static func resolveDefaultDBPath( + home: String = NSHomeDirectory(), + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default) -> String + { + #if os(macOS) + _ = environment + _ = fileManager + return "\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb" + #elseif os(Linux) + let configHome = environment[CodexBarConfigStore.xdgConfigHomeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let expandedConfigHome = configHome.map { ($0 as NSString).expandingTildeInPath } + let base: String = if let expandedConfigHome, + !expandedConfigHome.isEmpty, + (expandedConfigHome as NSString).isAbsolutePath + { + expandedConfigHome + } else { + "\(home)/.config" + } + return "\(base)/Cursor/User/globalStorage/state.vscdb" + #else + _ = home + _ = environment + _ = fileManager + return "" + #endif + } + func loadSession() throws -> CursorAppAuthSession? { guard FileManager.default.fileExists(atPath: self.dbPath) else { return nil } @@ -705,15 +739,25 @@ public enum CursorStatusProbeError: LocalizedError, Sendable { public var errorDescription: String? { switch self { case .notLoggedIn: + #if os(macOS) "Not logged in to Cursor. Please log in via the CodexBar menu." + #else + "Not logged in to Cursor. Sign in to the Cursor app on this machine or paste a Cookie header copied " + + "from cursor.com into ~/.config/codexbar/config.json (legacy: ~/.codexbar/config.json)." + #endif case let .networkError(msg): "Cursor API error: \(msg)" case let .parseFailed(msg): "Could not parse Cursor usage: \(msg)" case .noSessionCookie: + #if os(macOS) "No Cursor session found. \(Self.safariFullDiskAccessHint) " + "Please log in to cursor.com in \(cursorCookieImportOrder.loginHint). " + "You can also sign in to Cursor from the CodexBar menu (Add / switch account)." + #else + "No Cursor session found. Sign in to the Cursor app on this machine or paste a Cookie header copied " + + "from cursor.com into ~/.config/codexbar/config.json (legacy: ~/.codexbar/config.json)." + #endif } } } @@ -860,7 +904,7 @@ public struct CursorStatusProbe: Sendable { baseURL: baseURL, timeout: timeout, browserDetection: browserDetection, - browserCookieImportOrder: cursorCookieImportOrder, + browserCookieImportOrder: Self.defaultBrowserCookieImportOrder, urlSession: urlSession, appAuthStore: CursorAppAuthStore()) } @@ -869,7 +913,7 @@ public struct CursorStatusProbe: Sendable { baseURL: URL = URL(string: "https://cursor.com")!, timeout: TimeInterval = 15.0, browserDetection: BrowserDetection, - browserCookieImportOrder: BrowserCookieImportOrder = cursorCookieImportOrder, + browserCookieImportOrder: BrowserCookieImportOrder = Self.defaultBrowserCookieImportOrder, urlSession: any ProviderHTTPTransport = ProviderHTTPClient.shared, appAuthStore: any CursorAppAuthSessionProviding) { @@ -927,6 +971,7 @@ public struct CursorStatusProbe: Sendable { } } + #if os(macOS) // Try each browser in order. The first browser that *has* session cookie names is not always valid // (e.g. stale Chrome tokens); keep trying until the API accepts a session or we run out of browsers. let browserCandidates = self.browserCookieImportOrder.cookieImportCandidates(using: self.browserDetection) @@ -965,6 +1010,7 @@ public struct CursorStatusProbe: Sendable { case let .exhausted(error): firstRecoverableError = error ?? firstRecoverableError } + #endif // Fall back to stored session cookies (from "Add Account" login flow) if allowCachedSessions { @@ -1022,6 +1068,7 @@ public struct CursorStatusProbe: Sendable { throw CursorStatusProbeError.noSessionCookie } + #if os(macOS) enum ImportedSessionFetchOutcome { case succeeded(CursorStatusSnapshot) case tryNextBrowser @@ -1104,6 +1151,7 @@ public struct CursorStatusProbe: Sendable { return .failed(.networkError(error.localizedDescription)) } } + #endif private func fetchWithCookieHeader( _ cookieHeader: String, @@ -1363,6 +1411,12 @@ public struct CursorStatusProbe: Sendable { requestsUsed: requestsUsed, requestsLimit: requestsLimit) } + + #if os(macOS) + private static let defaultBrowserCookieImportOrder: BrowserCookieImportOrder = cursorCookieImportOrder + #else + private static let defaultBrowserCookieImportOrder: BrowserCookieImportOrder = [] + #endif } #else diff --git a/TestsLinux/CursorLinuxTests.swift b/TestsLinux/CursorLinuxTests.swift new file mode 100644 index 0000000000..f78acb1ba5 --- /dev/null +++ b/TestsLinux/CursorLinuxTests.swift @@ -0,0 +1,66 @@ +#if os(Linux) +import Foundation +import Testing +@testable import CodexBarCLI +@testable import CodexBarCore + +struct CursorLinuxTests { + @Test + func `Cursor database path honors absolute XDG config home`() { + let path = CursorAppAuthStore.resolveDefaultDBPath( + home: "/home/test", + environment: ["XDG_CONFIG_HOME": "/custom/config"]) + #expect(path == "/custom/config/Cursor/User/globalStorage/state.vscdb") + } + + @Test + func `Cursor database path falls back to dot config`() { + let path = CursorAppAuthStore.resolveDefaultDBPath( + home: "/home/test", + environment: [:]) + #expect(path == "/home/test/.config/Cursor/User/globalStorage/state.vscdb") + } + + @Test + func `Cursor database path rejects relative XDG config home`() { + let path = CursorAppAuthStore.resolveDefaultDBPath( + home: "/home/test", + environment: ["XDG_CONFIG_HOME": "relative/config"]) + #expect(path == "/home/test/.config/Cursor/User/globalStorage/state.vscdb") + } + + @Test + func `Cursor automatic source does not require macOS web support`() { + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .cursor, + settings: ProviderSettingsSnapshot.make( + cursor: .init(cookieSource: .auto, manualCookieHeader: nil)))) + } + + @Test + func `Cursor descriptor accepts explicit web source`() { + #expect(CursorProviderDescriptor.descriptor.fetchPlan.sourceModes.contains(.web)) + } + + @Test + func `Cursor manual cookie does not require macOS web support`() { + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .cursor, + settings: ProviderSettingsSnapshot.make( + cursor: .init( + cookieSource: .manual, + manualCookieHeader: "WorkosCursorSessionToken=test")))) + } + + @Test + func `disabled Cursor web source still requires macOS web support`() { + #expect(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .cursor, + settings: ProviderSettingsSnapshot.make( + cursor: .init(cookieSource: .off, manualCookieHeader: nil)))) + } +} +#endif diff --git a/docs/cursor.md b/docs/cursor.md index d303fdfe96..24be1e7b59 100644 --- a/docs/cursor.md +++ b/docs/cursor.md @@ -31,8 +31,11 @@ Cursor is primarily web-backed. Usage is fetched via browser cookies or a stored 4) **Cursor.app local auth** (last fallback) - Reads Cursor.app's VS Code-style global state DB for the local app bearer token. - - File: `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb`. + - File: + - macOS: `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` + - Linux: `$XDG_CONFIG_HOME/Cursor/User/globalStorage/state.vscdb` (default `~/.config/Cursor/...`) - Used only after cookie/session sources fail so existing account-selection precedence stays stable. + - On Linux, this is the primary automatic source because browser import and the WebKit login flow are macOS-only. - Derives Cursor's first-party web-session cookie, then uses the same usage and account endpoints as browser sessions. - Account identity comes from that authenticated session; cached app profile fields are not mixed across accounts. @@ -53,8 +56,13 @@ Manual option: - Chrome/Chromium forks: `~/Library/Application Support/Google/Chrome/*/Cookies` - Firefox: `~/Library/Application Support/Firefox/Profiles/*/cookies.sqlite` +## Linux CLI +- `codexbar usage --provider cursor` reads the signed-in Cursor app's access token from the Linux global state DB and reuses the same `cursor.com` usage endpoints as macOS. +- Automatic browser cookie import and the in-app WebKit login flow remain macOS-only. +- Manual cookie headers from `~/.config/codexbar/config.json` (or legacy `~/.codexbar/config.json`) work on Linux. + ## Local storage footprint -When **Settings → Advanced → Track provider local storage** is enabled, CodexBar measures: +When **Settings → Advanced → Track provider local storage** is enabled on macOS, CodexBar measures: - `~/Library/Application Support/Cursor` - `~/Library/Application Support/Caches/cursor-updater` - `~/.cursor`