mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-25 08:09:35 +00:00
usage-summary for an Enterprise seat has no individualUsage.plan, a null individualUsage.overall.limit and teamUsage.onDemand instead of pooled, so decodeCursorUsage returned null and the adapter reported an unrecognized response. Fall back to the team on-demand meter, list it beside the monthly window when both exist, and decode meter amounts as Double in the menu bar so a fractional amount cannot fail the whole decode. Settings no longer appends sign-in guidance to transient failures. Reported in #1546.
394 lines
15 KiB
Swift
394 lines
15 KiB
Swift
import Foundation
|
|
import SQLite3
|
|
|
|
/// CodeBurn-owned Cursor quota adapter. It reads Cursor.app's existing local
|
|
/// session database in read-only mode, keeps the token in memory for one
|
|
/// request, and never copies it into CodeBurn's Keychain or support directory.
|
|
enum CursorSubscriptionService {
|
|
static let usageURL = URL(string: "https://cursor.com/api/usage-summary")!
|
|
private static let timeoutSeconds: TimeInterval = 15
|
|
|
|
enum FetchError: Error, Equatable, LocalizedError, Sendable {
|
|
case noCredentials
|
|
case expiredSession
|
|
case authenticationRejected
|
|
case appDataUnreadable
|
|
case rateLimited
|
|
case providerUnavailable
|
|
case parseFailure
|
|
case network
|
|
|
|
enum Classification: Equatable, Sendable {
|
|
case terminalAuth
|
|
case transient
|
|
case parseFailure
|
|
}
|
|
|
|
var classification: Classification {
|
|
switch self {
|
|
case .noCredentials, .expiredSession, .authenticationRejected:
|
|
return .terminalAuth
|
|
case .appDataUnreadable, .rateLimited, .providerUnavailable, .network:
|
|
return .transient
|
|
case .parseFailure:
|
|
return .parseFailure
|
|
}
|
|
}
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .noCredentials:
|
|
return "Sign in to the Cursor app, then click Retry."
|
|
case .expiredSession:
|
|
return "The Cursor app session is expired or invalid. Sign in again, then click Retry."
|
|
case .authenticationRejected:
|
|
return "Cursor rejected the current app session. Sign in again, then click Retry."
|
|
case .appDataUnreadable:
|
|
return "Could not read the Cursor app's local session data. Quit and reopen Cursor, then click Retry."
|
|
case .rateLimited:
|
|
return "Cursor rate-limited the quota request."
|
|
case .providerUnavailable:
|
|
return "Cursor is temporarily unavailable."
|
|
case .parseFailure:
|
|
return "Cursor returned an unrecognized quota response."
|
|
case .network:
|
|
return "Network error fetching Cursor quota."
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Deps: Sendable {
|
|
var loadAccessToken: @Sendable () throws -> String?
|
|
var fetch: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
|
|
|
|
static let live = Deps(
|
|
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 {
|
|
let accessToken: String
|
|
do {
|
|
// The store read is synchronous SQLite with a 250ms 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
|
|
}
|
|
|
|
let session = try session(from: accessToken)
|
|
var request = URLRequest(url: usageURL)
|
|
request.httpMethod = "GET"
|
|
request.timeoutInterval = timeoutSeconds
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.setValue(session.cookieHeader, forHTTPHeaderField: "Cookie")
|
|
|
|
let data: Data
|
|
let response: HTTPURLResponse
|
|
do {
|
|
(data, response) = try await deps.fetch(request)
|
|
} catch let error as FetchError {
|
|
throw error
|
|
} catch {
|
|
throw FetchError.network
|
|
}
|
|
|
|
switch response.statusCode {
|
|
case 200:
|
|
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 summary: UsageSummary
|
|
do {
|
|
summary = try JSONDecoder().decode(UsageSummary.self, from: data)
|
|
} catch {
|
|
throw FetchError.parseFailure
|
|
}
|
|
|
|
let plan = summary.individualUsage?.plan
|
|
let overall = summary.individualUsage?.overall
|
|
let pooled = summary.teamUsage?.pooled
|
|
let teamOnDemand = summary.teamUsage?.onDemand
|
|
let autoPercent = normalizedProviderPercent(plan?.autoPercentUsed)
|
|
let apiPercent = normalizedProviderPercent(plan?.apiPercentUsed)
|
|
|
|
let monthlyPercent: Double? = {
|
|
if let total = normalizedProviderPercent(plan?.totalPercentUsed) { return total }
|
|
// A capacity gauge must surface the pool that is about to run
|
|
// out; averaging would hide an exhausted pool behind an idle one.
|
|
if let autoPercent, let apiPercent { return max(autoPercent, apiPercent) }
|
|
if let apiPercent { return apiPercent }
|
|
if let autoPercent { return autoPercent }
|
|
if let ratio = ratio(used: plan?.used, limit: plan?.limit) { return ratio }
|
|
if let ratio = ratio(used: overall?.used, limit: overall?.limit) { return ratio }
|
|
if let ratio = ratio(used: pooled?.used, limit: pooled?.limit) { return ratio }
|
|
if summary.isUnlimited == true { return 0 }
|
|
return nil
|
|
}()
|
|
// Enterprise team seats report no individual meter; the team
|
|
// on-demand meter is the only structured used/limit pair they carry.
|
|
let teamOnDemandPercent = teamOnDemand?.enabled == false
|
|
? nil
|
|
: ratio(used: teamOnDemand?.used, limit: teamOnDemand?.limit)
|
|
guard let primaryPercent = monthlyPercent ?? teamOnDemandPercent else { throw FetchError.parseFailure }
|
|
|
|
let reset = parseDate(summary.billingCycleEnd)
|
|
let primary = QuotaSummary.Window(
|
|
label: monthlyPercent == nil ? "Team on-demand" : "Monthly",
|
|
percent: primaryPercent,
|
|
resetsAt: reset
|
|
)
|
|
var details = [primary]
|
|
if let autoPercent {
|
|
details.append(QuotaSummary.Window(label: "Auto", percent: autoPercent, resetsAt: reset))
|
|
}
|
|
if let apiPercent {
|
|
details.append(QuotaSummary.Window(label: "API", percent: apiPercent, resetsAt: reset))
|
|
}
|
|
if let onDemand = summary.individualUsage?.onDemand,
|
|
onDemand.enabled != false,
|
|
let percent = ratio(used: onDemand.used, limit: onDemand.limit) {
|
|
details.append(QuotaSummary.Window(label: "On-demand", percent: percent, resetsAt: reset))
|
|
}
|
|
if let percent = ratio(used: pooled?.used, limit: pooled?.limit) {
|
|
details.append(QuotaSummary.Window(label: "Team pool", percent: percent, resetsAt: reset))
|
|
}
|
|
if monthlyPercent != nil, let teamOnDemandPercent {
|
|
details.append(QuotaSummary.Window(label: "Team on-demand", percent: teamOnDemandPercent, resetsAt: reset))
|
|
}
|
|
|
|
return QuotaSummary(
|
|
providerFilter: .cursor,
|
|
connection: .connected,
|
|
primary: primary,
|
|
details: details,
|
|
planLabel: planLabel(summary.membershipType),
|
|
footerLines: ["Source: Cursor app"]
|
|
)
|
|
}
|
|
|
|
private struct Session {
|
|
let cookieHeader: String
|
|
}
|
|
|
|
private struct Claims: Decodable {
|
|
let sub: String?
|
|
let exp: Double?
|
|
}
|
|
|
|
private static func session(from token: String, now: Date = Date()) throws -> Session {
|
|
let components = token.split(separator: ".", omittingEmptySubsequences: false)
|
|
guard components.count == 3,
|
|
let payload = decodeBase64URL(String(components[1])),
|
|
let claims = try? JSONDecoder().decode(Claims.self, from: payload),
|
|
let subject = claims.sub,
|
|
let userID = subject.split(separator: "|", omittingEmptySubsequences: true).last.map(String.init),
|
|
!userID.isEmpty,
|
|
let expiration = claims.exp,
|
|
Date(timeIntervalSince1970: expiration).timeIntervalSince(now) > 60 else {
|
|
throw FetchError.expiredSession
|
|
}
|
|
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-"))
|
|
guard userID.unicodeScalars.allSatisfy(allowed.contains) else {
|
|
throw FetchError.expiredSession
|
|
}
|
|
return Session(cookieHeader: "WorkosCursorSessionToken=\(userID)%3A%3A\(token)")
|
|
}
|
|
|
|
private static func decodeBase64URL(_ value: String) -> Data? {
|
|
var base64 = value
|
|
.replacingOccurrences(of: "-", with: "+")
|
|
.replacingOccurrences(of: "_", with: "/")
|
|
base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4)
|
|
return Data(base64Encoded: base64)
|
|
}
|
|
|
|
private static func normalizedProviderPercent(_ value: Double?) -> Double? {
|
|
guard let value, value.isFinite, value >= 0 else { return nil }
|
|
return min(1, value / 100)
|
|
}
|
|
|
|
private static func ratio(used: Double?, limit: Double?) -> Double? {
|
|
guard let used, let limit, used.isFinite, limit.isFinite, used >= 0, limit > 0 else { return nil }
|
|
return min(1, max(0, used / limit))
|
|
}
|
|
|
|
private static func parseDate(_ raw: String?) -> Date? {
|
|
guard let raw 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 planLabel(_ raw: String?) -> String? {
|
|
guard let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
|
|
return nil
|
|
}
|
|
switch value.lowercased() {
|
|
case "pro": return "Pro"
|
|
case "pro_plus", "pro plus": return "Pro Plus"
|
|
case "business": return "Business"
|
|
case "enterprise": return "Enterprise"
|
|
case "hobby", "free": return "Hobby"
|
|
case "ultra": return "Ultra"
|
|
default: return value
|
|
}
|
|
}
|
|
|
|
private struct UsageSummary: Decodable {
|
|
let billingCycleEnd: String?
|
|
let membershipType: String?
|
|
let isUnlimited: Bool?
|
|
let individualUsage: IndividualUsage?
|
|
let teamUsage: TeamUsage?
|
|
}
|
|
|
|
private struct IndividualUsage: Decodable {
|
|
let plan: Meter?
|
|
let onDemand: Meter?
|
|
let overall: Meter?
|
|
}
|
|
|
|
private struct TeamUsage: Decodable {
|
|
let pooled: Meter?
|
|
let onDemand: Meter?
|
|
}
|
|
|
|
private struct Meter: Decodable {
|
|
let enabled: Bool?
|
|
// Double, not Int: one fractional amount would otherwise fail the
|
|
// whole decode and surface as an unrecognized response.
|
|
let used: Double?
|
|
let limit: Double?
|
|
let autoPercentUsed: Double?
|
|
let apiPercentUsed: Double?
|
|
let totalPercentUsed: Double?
|
|
}
|
|
}
|
|
|
|
/// Narrow read-only adapter for Cursor's own VS Code state database.
|
|
struct CursorAppSessionStore: Sendable {
|
|
private static let accessTokenKey = "cursorAuth/accessToken"
|
|
let databaseURL: URL
|
|
|
|
init(databaseURL: URL = Self.defaultDatabaseURL()) {
|
|
self.databaseURL = databaseURL
|
|
}
|
|
|
|
static func defaultDatabaseURL(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL {
|
|
home
|
|
.appendingPathComponent("Library/Application Support/Cursor/User/globalStorage", isDirectory: true)
|
|
.appendingPathComponent("state.vscdb", isDirectory: false)
|
|
}
|
|
|
|
func loadAccessToken() throws -> String? {
|
|
guard FileManager.default.fileExists(atPath: databaseURL.path) else { return nil }
|
|
do {
|
|
return try value(for: Self.accessTokenKey, immutable: false)
|
|
} catch let error as SQLiteFailure
|
|
where error.code == SQLITE_CANTOPEN && walSidecarsAreMissing {
|
|
return try value(for: Self.accessTokenKey, immutable: true)
|
|
} catch {
|
|
throw CursorAppSessionStoreError.unreadable
|
|
}
|
|
}
|
|
|
|
private func value(for key: String, immutable: Bool) throws -> String? {
|
|
var database: OpaquePointer?
|
|
let filename = immutable ? "\(databaseURL.absoluteURL.absoluteString)?immutable=1" : databaseURL.path
|
|
let flags = immutable ? SQLITE_OPEN_READONLY | SQLITE_OPEN_URI : SQLITE_OPEN_READONLY
|
|
let openResult = sqlite3_open_v2(filename, &database, flags, nil)
|
|
guard openResult == SQLITE_OK else {
|
|
let failure = SQLiteFailure(code: database.map(sqlite3_errcode) ?? openResult)
|
|
sqlite3_close(database)
|
|
throw failure
|
|
}
|
|
defer { sqlite3_close(database) }
|
|
sqlite3_busy_timeout(database, 250)
|
|
|
|
var statement: OpaquePointer?
|
|
let prepareResult = sqlite3_prepare_v2(
|
|
database,
|
|
"SELECT value FROM ItemTable WHERE key = ? LIMIT 1;",
|
|
-1,
|
|
&statement,
|
|
nil
|
|
)
|
|
guard prepareResult == SQLITE_OK else {
|
|
throw SQLiteFailure(code: sqlite3_errcode(database))
|
|
}
|
|
defer { sqlite3_finalize(statement) }
|
|
sqlite3_bind_text(statement, 1, key, -1, sqliteTransient)
|
|
|
|
let stepResult = sqlite3_step(statement)
|
|
if stepResult == SQLITE_DONE { return nil }
|
|
guard stepResult == SQLITE_ROW else {
|
|
throw SQLiteFailure(code: sqlite3_errcode(database))
|
|
}
|
|
switch sqlite3_column_type(statement, 0) {
|
|
case SQLITE_TEXT:
|
|
guard let text = sqlite3_column_text(statement, 0) else { return nil }
|
|
return String(cString: text)
|
|
case SQLITE_BLOB:
|
|
guard let bytes = sqlite3_column_blob(statement, 0) else { return nil }
|
|
let data = Data(bytes: bytes, count: Int(sqlite3_column_bytes(statement, 0)))
|
|
return String(data: data, encoding: .utf8)
|
|
?? String(data: data, encoding: .utf16LittleEndian)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private var walSidecarsAreMissing: Bool {
|
|
!FileManager.default.fileExists(atPath: databaseURL.path + "-wal") &&
|
|
!FileManager.default.fileExists(atPath: databaseURL.path + "-shm")
|
|
}
|
|
|
|
private struct SQLiteFailure: Error {
|
|
let code: Int32
|
|
}
|
|
}
|
|
|
|
private enum CursorAppSessionStoreError: Error {
|
|
case unreadable
|
|
}
|
|
|
|
private let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|