mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-25 16:19:39 +00:00
* 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>
376 lines
17 KiB
Swift
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()
|
|
}
|
|
}
|