mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-23 15:19:37 +00:00
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
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.
227 lines
9.2 KiB
Swift
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
|
|
}
|
|
}
|