codeburn/mac/Sources/CodeBurnMenubar/Data/CursorSubscriptionService.swift
Resham Joshi 82469eab68
fix(cursor): connect Enterprise team seats on the team on-demand meter (#1553)
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.
2026-09-25 01:33:41 +02:00

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)