codeburn/mac/Sources/CodeBurnMenubar/Data/GrokBotSubscriptionService.swift
Resham Joshi 3684f1d5f0
Some checks are pending
CI / semgrep (push) Waiting to run
macOS Menubar CI / test (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Tests / desktop (push) Waiting to run
Grok Bot: sessions, estimated spend and the weekly allowance on all three surfaces (#1462)
Adds xAI's Grok Bot desktop app as a provider: per-bot sessions with estimated usage, and the weekly allowance read through the Cursor IDE login on the CLI, desktop Plans screen and menubar. No row unless the app is installed. Two independent review rounds on real data.
2026-09-16 21:17:48 +02:00

227 lines
9.2 KiB
Swift

import Foundation
/// Live Grok Bot weekly allowance, read from the same Cursor dashboard call the
/// desktop app makes for its own "Weekly usage" reading.
///
/// The credential is the Cursor IDE's own access token, which
/// `CursorAppSessionStore` already reads read-only; Grok Bot keeps its own copy
/// of the same session in Electron safe storage, which CodeBurn never decrypts.
/// So this is Grok Bot's reading only while the Cursor app is signed into the
/// account Grok Bot uses — with Cursor signed out the row reports the same
/// signed-out state the Cursor row does.
enum GrokBotSubscriptionService {
static let usageURL = URL(
string: "https://api2.cursor.sh/aiserver.v1.DashboardService/GetSandUsageStatus"
)!
static let appBundleName = "Grok Bot.app"
private static let timeoutSeconds: TimeInterval = 15
/// The app ships as a normal drag-install, so either Applications folder
/// counts; `~/.grokbot` is its data root, which survives a moved bundle.
static func isInstalled(
home: URL = FileManager.default.homeDirectoryForCurrentUser,
systemApplications: URL = URL(fileURLWithPath: "/Applications", isDirectory: true)
) -> Bool {
[
systemApplications.appendingPathComponent(appBundleName, isDirectory: true),
home.appendingPathComponent("Applications/\(appBundleName)", isDirectory: true),
home.appendingPathComponent(".grokbot", isDirectory: true),
].contains { FileManager.default.fileExists(atPath: $0.path) }
}
enum FetchError: Error, Equatable, LocalizedError, Sendable {
case notInstalled
case noCredentials
case authenticationRejected
case appDataUnreadable
case noIncludedAllowance
case pooledAllowance
case rateLimited
case providerUnavailable
case parseFailure
case network
enum Classification: Equatable, Sendable {
case terminalAuth
case transient
case parseFailure
}
var classification: Classification {
switch self {
case .notInstalled, .noCredentials, .authenticationRejected,
.noIncludedAllowance, .pooledAllowance:
.terminalAuth
case .appDataUnreadable, .rateLimited, .providerUnavailable, .network:
.transient
case .parseFailure:
.parseFailure
}
}
var errorDescription: String? {
switch self {
case .notInstalled:
L("Grok Bot is not installed. Install the Grok Bot app, then click Retry.")
case .noCredentials:
L("Sign in to the Cursor app with the account Grok Bot uses, then click Retry.")
case .authenticationRejected:
L("Cursor rejected the current app session. Sign in again, then click Retry.")
case .appDataUnreadable:
L("Could not read the Cursor app's local session data. Quit and reopen Cursor, then click Retry.")
case .noIncludedAllowance:
L("This account has no included Grok Bot allowance.")
case .pooledAllowance:
L("Grok Bot usage is drawn from a pooled enterprise allowance, which has no per-account reading.")
case .rateLimited:
L("Cursor rate-limited the quota request.")
case .providerUnavailable:
L("Grok Bot quota is temporarily unavailable.")
case .parseFailure:
L("Cursor returned an unrecognized Grok Bot quota response.")
case .network:
L("Network error fetching Grok Bot quota.")
}
}
}
struct Deps: Sendable {
var isInstalled: @Sendable () -> Bool
var loadAccessToken: @Sendable () throws -> String?
var fetch: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
static let live = Deps(
isInstalled: { GrokBotSubscriptionService.isInstalled() },
loadAccessToken: {
try CursorAppSessionStore().loadAccessToken()
},
fetch: { request in
let configuration = URLSessionConfiguration.ephemeral
configuration.httpCookieStorage = nil
configuration.httpShouldSetCookies = false
configuration.urlCache = nil
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
let session = URLSession(configuration: configuration)
defer { session.invalidateAndCancel() }
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw FetchError.network }
return (data, http)
}
)
}
@MainActor
static func refresh(deps: Deps = .live) async throws -> QuotaSummary {
guard deps.isInstalled() else { throw FetchError.notInstalled }
let accessToken: String
do {
// Synchronous SQLite with a busy timeout: keep it off the main
// actor so a lock held by Cursor cannot stall the UI.
let loadAccessToken = deps.loadAccessToken
let raw = try await Task.detached { try loadAccessToken() }.value
guard let loaded = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
!loaded.isEmpty else {
throw FetchError.noCredentials
}
accessToken = loaded
} catch let error as FetchError {
throw error
} catch {
throw FetchError.appDataUnreadable
}
var request = URLRequest(url: usageURL)
request.httpMethod = "POST"
request.timeoutInterval = timeoutSeconds
request.httpBody = Data("{}".utf8)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("1", forHTTPHeaderField: "connect-protocol-version")
request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent")
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await deps.fetch(request)
} catch let error as FetchError {
throw error
} catch is CancellationError {
throw CancellationError()
} catch {
throw FetchError.network
}
switch response.statusCode {
case 200...299:
break
case 401, 403:
throw FetchError.authenticationRejected
case 429:
throw FetchError.rateLimited
case 500...599:
throw FetchError.providerUnavailable
default:
throw FetchError.parseFailure
}
return try decode(data)
}
static func decode(_ data: Data) throws -> QuotaSummary {
let status: UsageStatus
do {
status = try JSONDecoder().decode(UsageStatus.self, from: data)
} catch {
throw FetchError.parseFailure
}
// Both refusals mirror the app itself, which shows no reading rather
// than a zero the account cannot act on.
if status.usesPooledEnterpriseAllowance == true { throw FetchError.pooledAllowance }
if status.hasNonZeroIncludedLimit == false { throw FetchError.noIncludedAllowance }
guard let raw = status.usagePercent, raw.isFinite, raw >= 0 else {
throw FetchError.parseFailure
}
// No window length: the reset timestamps are seven days apart, but the
// percentage is a capacity reading rather than a metered window, so the
// early-reset monitor must stay quiet about it (#1339).
let window = QuotaSummary.Window(
label: "Weekly usage",
percent: min(1, raw / 100),
resetsAt: parseDate(status.nextResetTimestampUtc)
)
return QuotaSummary(
providerFilter: .all,
connection: .connected,
primary: window,
details: [window],
planLabel: normalizedPlan(status.grokPlanLabel),
footerLines: ["Source: Cursor dashboard (the account the Cursor app is signed into)"]
)
}
private struct UsageStatus: Decodable {
let usagePercent: Double?
let nextResetTimestampUtc: String?
let hasNonZeroIncludedLimit: Bool?
let usesPooledEnterpriseAllowance: Bool?
let grokPlanLabel: String?
}
private static func parseDate(_ raw: String?) -> Date? {
guard let raw, !raw.isEmpty else { return nil }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: raw) { return date }
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: raw)
}
private static func normalizedPlan(_ raw: String?) -> String? {
guard let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty else { return nil }
return value
}
}