codeburn/mac/Sources/CodeBurnMenubar/Data/GeminiSubscriptionService.swift
Aditya Vikram Singh 84fea9d550
feat(menubar): add native Capacity Dock (#1167)
* feat(menubar): add native capacity dock

* fix(menubar): harden dock interaction boundaries

* feat(menubar): polish native Capacity Dock for launch

- Flush top-edge docking via NSHostingView safe-area opt-out; shielding
  window level so the rail necks into the physical top like the system notch
- Liquid Glass legibility scrim so labels stay readable on light wallpapers
- Weekly-else-monthly headline window; unified severity colour bands
  (green <50 / yellow / orange / red) across rings and detail bars
- Squircle gauge + graphite theme defaults; auto-show connected
  subscriptions (up to 5) until the user edits the set, then respect it
- Capacity Dock Settings menu item; re-centre the settings window; info
  glyph for the About row
- Trim dead provider-credential override fields down to apiKey only
- Reconcile and extend tests for all of the above

---------

Co-authored-by: iamtoruk <hello@agentseal.org>
2026-08-29 00:37:30 -07:00

376 lines
17 KiB
Swift

import Foundation
/// Live quota snapshot for a Gemini (Code Assist) subscription. The API
/// reports per-model quota buckets; `details` is sorted most-constrained
/// first so `primary` is the window that will throttle the user soonest.
struct GeminiUsage: Sendable, Equatable {
struct Window: Sendable, Equatable {
/// Model id reported by the quota endpoint (e.g. "gemini-2.5-pro").
let label: String
let usedPercent: Double // 0.0 ... 100.0
let resetsAt: Date?
}
/// Per-model windows, most-constrained first.
let details: [Window]
var primary: Window? { details.first }
/// Tier label derived from loadCodeAssist (Paid / Legacy / Workspace / Free).
let plan: String?
let fetchedAt: Date
}
/// Live Gemini quota via the OAuth-backed Code Assist APIs the Gemini CLI
/// itself calls (derived from the Google Code Assist client flow, which provides
/// the flow from the CLI's own traffic):
///
/// - POST .../v1internal:loadCodeAssist → tier (plan label) + quota project
/// - POST .../v1internal:retrieveUserQuota → per-model quota buckets
///
/// Credential: the Gemini CLI's own ~/.gemini/oauth_creds.json, read-only —
/// nothing is copied or written. Token refresh happens in memory only, and
/// only when the CLI's documented GEMINI_OAUTH_CLIENT_ID /
/// GEMINI_OAUTH_CLIENT_SECRET env overrides are set; without them an expired
/// token is terminal and the UI tells the user to run the Gemini CLI once.
enum GeminiSubscriptionService {
private static let codeAssistEndpoint = "https://cloudcode-pa.googleapis.com/v1internal"
private static let tokenEndpoint = "https://oauth2.googleapis.com/token"
private static let usageBlockedUntilKey = "codeburn.gemini.usage.blockedUntil"
enum FetchError: Error, LocalizedError {
case noCredentials
case tokenExpired
/// Google retired Gemini CLI OAuth for this account tier.
case accountTierRetired
case rateLimited(retryAt: Date)
case usageHTTPError(Int)
case network(Error)
var errorDescription: String? {
switch self {
case .noCredentials:
return "No Gemini credentials found. Sign in with the Gemini CLI first."
case .tokenExpired:
return "Gemini login expired. Run the Gemini CLI once to refresh, then try again."
case .accountTierRetired:
return "Google retired Gemini CLI OAuth for this account tier. Use Antigravity."
case let .rateLimited(retryAt):
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return "Gemini rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
case let .usageHTTPError(code):
return "Gemini quota fetch failed (HTTP \(code))."
case let .network(err):
return "Network error: \(err.localizedDescription)"
}
}
var isTerminal: Bool {
switch self {
case .noCredentials, .tokenExpired, .accountTierRetired:
return true
case let .usageHTTPError(code):
return (400..<500).contains(code)
case .rateLimited, .network:
return false
}
}
var rateLimitRetryAt: Date? {
if case let .rateLimited(retryAt) = self { return retryAt }
return nil
}
}
// MARK: - Injectable seams (tests drive fixtures through these)
struct Deps: Sendable {
var fetch: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
/// Read-only credential file load. Never writes.
var readFile: @Sendable (URL) -> Data?
var credentialURL: URL
var now: @Sendable () -> Date
/// OAuth client credentials from the CLI's documented env overrides.
var clientCredentials: @Sendable () -> (clientId: String, clientSecret: String)?
static let live = Deps(
fetch: { request in
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw FetchError.usageHTTPError(-1)
}
return (data, http)
},
readFile: { url in FileManager.default.contents(atPath: url.path) },
credentialURL: URL(fileURLWithPath: NSHomeDirectory() + "/.gemini/oauth_creds.json"),
now: { Date() },
clientCredentials: {
let env = ProcessInfo.processInfo.environment
guard let id = env["GEMINI_OAUTH_CLIENT_ID"], !id.isEmpty,
let secret = env["GEMINI_OAUTH_CLIENT_SECRET"], !secret.isEmpty else { return nil }
return (id, secret)
}
)
}
// MARK: - Credential file
private struct Credential {
var accessToken: String
let refreshToken: String?
let idToken: String?
/// Epoch milliseconds, as the Gemini CLI writes it. nil = never stale.
let expiryDate: Double?
static func parse(_ data: Data) -> Credential? {
guard let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let token = raw["access_token"] as? String, !token.isEmpty else { return nil }
let expiry: Double?
if let n = raw["expiry_date"] as? Double {
expiry = n
} else if let n = raw["expiry_date"] as? Int {
expiry = Double(n)
} else {
expiry = nil
}
return Credential(
accessToken: token,
refreshToken: raw["refresh_token"] as? String,
idToken: raw["id_token"] as? String,
expiryDate: expiry
)
}
}
private static var defaultCredentialURL: URL { Deps.live.credentialURL }
static var hasCredential: Bool {
FileManager.default.fileExists(atPath: defaultCredentialURL.path)
}
private static func readCredential(deps: Deps) throws -> Credential {
guard let data = deps.readFile(deps.credentialURL),
let credential = Credential.parse(data) else {
throw FetchError.noCredentials
}
return credential
}
// MARK: - Fetch
static func refresh(deps: Deps = .live) async throws -> GeminiUsage {
if let until = usageBlockedUntil(), until > deps.now() {
throw FetchError.rateLimited(retryAt: until)
}
var credential = try readCredential(deps: deps)
// The CLI considers a token stale 5 minutes before expiry.
if let expiry = credential.expiryDate,
expiry - deps.now().timeIntervalSince1970 * 1000 <= 5 * 60_000 {
guard let client = deps.clientCredentials(), credential.refreshToken != nil else {
// No refresh path: the Gemini CLI owns this file, so the user
// must run it once to mint a fresh token.
throw FetchError.tokenExpired
}
if let refreshed = try await refreshAccessToken(credential: credential, client: client, deps: deps) {
credential.accessToken = refreshed
}
// A failed refresh keeps the stale token; a 401 below degrades it.
}
var (assistData, assistResponse) = try await send(
postRequest(token: credential.accessToken, path: "loadCodeAssist",
body: ["metadata": ["ideType": "GEMINI_CLI", "pluginType": "GEMINI"]]),
deps: deps
)
if assistResponse.statusCode == 401 {
// The CLI may have refreshed the file while we ran; retry once
// with a newly-written token, otherwise the login is dead.
let reread = try readCredential(deps: deps)
guard reread.accessToken != credential.accessToken else {
throw FetchError.tokenExpired
}
credential = reread
(assistData, assistResponse) = try await send(
postRequest(token: credential.accessToken, path: "loadCodeAssist",
body: ["metadata": ["ideType": "GEMINI_CLI", "pluginType": "GEMINI"]]),
deps: deps
)
}
if assistResponse.statusCode == 429 {
throw FetchError.rateLimited(retryAt: recordUsageRateLimit(
retryAfterSeconds: parseRetryAfterHeader(assistResponse.value(forHTTPHeaderField: "Retry-After"))))
}
// Retired-tier sentinels ride inside the JSON error body of a non-200
// response, so parse before branching on status.
let assist = (try? JSONSerialization.jsonObject(with: assistData)) as? [String: Any] ?? [:]
if isTierRetired(assist) {
throw FetchError.accountTierRetired
}
guard (200..<300).contains(assistResponse.statusCode) else {
throw FetchError.usageHTTPError(assistResponse.statusCode)
}
let project = (assist["cloudaicompanionProject"] as? String).flatMap { $0.isEmpty ? nil : $0 }
let (quotaData, quotaResponse) = try await send(
postRequest(token: credential.accessToken, path: "retrieveUserQuota",
body: project.map { ["project": $0] } ?? [:]),
deps: deps
)
if quotaResponse.statusCode == 429 {
throw FetchError.rateLimited(retryAt: recordUsageRateLimit(
retryAfterSeconds: parseRetryAfterHeader(quotaResponse.value(forHTTPHeaderField: "Retry-After"))))
}
guard (200..<300).contains(quotaResponse.statusCode) else {
throw FetchError.usageHTTPError(quotaResponse.statusCode)
}
return decodeUsage(data: quotaData, plan: tierLabel(assist: assist, credential: credential), now: deps.now())
}
private static func postRequest(token: String, path: String, body: [String: Any]) -> URLRequest {
var request = URLRequest(url: URL(string: "\(codeAssistEndpoint):\(path)")!)
request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent")
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
return request
}
private static func send(_ request: URLRequest, deps: Deps) async throws -> (Data, HTTPURLResponse) {
do {
return try await deps.fetch(request)
} catch let error as FetchError {
throw error
} catch {
throw FetchError.network(error)
}
}
/// In-memory refresh through Google OAuth. Returns nil on a non-200 so
/// the caller can degrade via the stale token's 401 path.
private static func refreshAccessToken(
credential: Credential,
client: (clientId: String, clientSecret: String),
deps: Deps
) async throws -> String? {
guard let refreshToken = credential.refreshToken else { return nil }
var request = URLRequest(url: URL(string: tokenEndpoint)!)
request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpBody = [
"client_id=\(client.clientId)",
"client_secret=\(client.clientSecret)",
"refresh_token=\(refreshToken)",
"grant_type=refresh_token",
].joined(separator: "&").data(using: .utf8)
let (data, response) = try await send(request, deps: deps)
guard (200..<300).contains(response.statusCode),
let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let token = body["access_token"] as? String, !token.isEmpty else { return nil }
return token
}
// MARK: - Decode (internal so tests can drive fixtures)
static func decodeUsage(data: Data, plan: String?, now: Date = Date()) -> GeminiUsage {
let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:]
let buckets = root["buckets"] as? [[String: Any]] ?? []
let windows = buckets.compactMap(window(from:)).sorted { $0.usedPercent > $1.usedPercent }
return GeminiUsage(details: windows, plan: plan, fetchedAt: now)
}
private static func window(from bucket: [String: Any]) -> GeminiUsage.Window? {
guard let modelId = bucket["modelId"] as? String, !modelId.isEmpty,
let remaining = bucket["remainingFraction"] as? Double, remaining.isFinite else { return nil }
let clamped = min(1, max(0, remaining))
let resetsAt = (bucket["resetTime"] as? String).flatMap(parseResetTime)
return GeminiUsage.Window(label: modelId, usedPercent: (1 - clamped) * 100, resetsAt: resetsAt)
}
/// paidTier wins whenever present; otherwise whatever tier the account sits on.
private static func tierLabel(assist: [String: Any], credential: Credential) -> String? {
let paid = (assist["paidTier"] as? [String: Any])?["name"] as? String
let current = assist["currentTier"] as? [String: Any]
guard let raw = paid ?? (current?["name"] as? String) ?? (current?["id"] as? String) else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return nil }
let lower = trimmed.lowercased()
if lower == "standard-tier" { return "Paid" }
if lower == "legacy-tier" { return "Legacy" }
if lower.contains("free") {
return workspaceClaim(idToken: credential.idToken) ? "Workspace" : "Free"
}
return trimmed
}
/// The `hd` claim (hosted domain) separates Google Workspace logins from
/// personal ones, matching the provider's tier labeling.
private static func workspaceClaim(idToken: String?) -> Bool {
guard let idToken else { return false }
let parts = idToken.split(separator: ".")
guard parts.count > 1 else { return false }
var base64 = String(parts[1])
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
while base64.count % 4 != 0 { base64.append("=") }
guard let data = Data(base64Encoded: base64),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let hd = json["hd"] as? String else { return false }
return !hd.isEmpty
}
/// Google flags retired consumer tiers with these sentinels instead of
/// ordinary errors.
private static func isTierRetired(_ body: [String: Any]) -> Bool {
guard let error = body["error"] as? [String: Any] else { return false }
let status = error["status"] as? String ?? ""
let message = error["message"] as? String ?? ""
return status == "UNSUPPORTED_CLIENT" || message.contains("IneligibleTierError")
}
private static func parseResetTime(_ raw: String) -> Date? {
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = fractional.date(from: raw) { return date }
let plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
return plain.date(from: raw)
}
// MARK: - 429 backoff
private static func usageBlockedUntil() -> Date? {
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
}
private static func clearUsageBlock() {
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
}
private static func parseRetryAfterHeader(_ value: String?) -> Int? {
guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return nil }
if let seconds = Int(value), seconds >= 0 { return seconds }
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(secondsFromGMT: 0)
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
if let date = f.date(from: value) {
return max(0, Int(date.timeIntervalSinceNow))
}
return nil
}
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
let seconds = max(retryAfterSeconds ?? 300, 60)
let until = Date().addingTimeInterval(TimeInterval(seconds))
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
return until
}
static func disconnect() {
clearUsageBlock()
}
}