codeburn/mac/Sources/CodeBurnMenubar/Data/ClinePassSubscriptionService.swift
Resham Joshi 6ddd8b6735
fix(quota): say when Cline's saved login expired and how to renew it (#1736)
* fix(clinepass): say when the Cline login expired and how to renew it

An expired saved Cline login only said "Run cline to refresh it". It now
reads "Cline login expired 3h ago" (age from the saved expiry, when known)
and asks to send a message in Cline or run `cline auth cline`, then press
Retry. Still a retryable state, so the last quota is kept. CLI and menubar.

* fix(menubar): title an expired Cline login instead of "Retrying"

A provider failure can now carry a short status title. The expired Cline
login sets "Cline login expired", which the settings pane and the dock
caption show in place of the generic retrying text. It stays a retryable
state and keeps the last quota; the title clears with the error.

* fix(clinepass): match the expired-login hint to the visible control

"Then press Retry" only fits where a Retry button shows: the settings pane
with no last quota. Everywhere else, including the CLI, the hint now ends
with "CodeBurn picks up the new login on its next refresh."
2026-10-10 21:40:52 +02:00

301 lines
12 KiB
Swift

import Foundation
/// Live ClinePass quota from the public usage-limits HTTP contract.
/// GET https://api.cline.bot/api/v1/users/me/plan/usage-limits with a bearer
/// token: the caller's saved key, else CLINEPASS_API_KEY / CLINE_API_KEY, else
/// the session the Cline CLI keeps in ~/.cline/data/settings/providers.json.
/// That file is only read, never refreshed or written. Never reads Keychain.
enum ClinePassSubscriptionService {
static let usageURL = URL(string: "https://api.cline.bot/api/v1/users/me/plan/usage-limits")!
private static let timeoutSeconds: TimeInterval = 15
enum FetchError: Error, Equatable, LocalizedError, Sendable {
case noCredentials
case authenticationRejected
/// `age` is how long ago the saved login expired, when the file says.
case signInExpired(age: String?)
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, .authenticationRejected:
return .terminalAuth
// Running cline refreshes the session file this only reads, so the
// next scheduled read picks it up; keep the last quota until then.
case .signInExpired, .rateLimited, .providerUnavailable, .network:
return .transient
case .parseFailure:
return .parseFailure
}
}
var isTerminal: Bool { classification == .terminalAuth }
/// Replaces the generic "Retrying" status where a retry alone cannot help.
var statusTitle: String? {
if case .signInExpired = self { return L("Cline login expired") }
return nil
}
/// The same guidance for where a Retry button is on screen.
var retryMessage: String? {
guard case let .signInExpired(age) = self else { return nil }
return "\(expiredPrefix(age)) Send a message in Cline or run `cline auth cline`, then press Retry."
}
private func expiredPrefix(_ age: String?) -> String {
"Cline login expired\(age.map { " \($0)" } ?? "")."
}
var errorDescription: String? {
switch self {
case .noCredentials:
return "Sign in with Cline, or enter a ClinePass API key, then click Retry."
case .authenticationRejected:
return "ClinePass rejected this API key."
case let .signInExpired(age):
return "\(expiredPrefix(age)) Send a message in Cline or run `cline auth cline`; CodeBurn picks up the new login on its next refresh."
case .rateLimited:
return "ClinePass rate-limited the quota request."
case .providerUnavailable:
return "ClinePass is temporarily unavailable."
case .parseFailure:
return "ClinePass quota response was malformed."
case .network:
return "Network error fetching ClinePass quota."
}
}
}
struct Credential: Equatable, Sendable {
let token: String
let isOAuth: Bool
let expiresAt: Date?
}
struct Deps: Sendable {
var fetch: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
var loadAmbientCredential: @Sendable () -> Credential?
var now: @Sendable () -> Date
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.network
}
return (data, http)
},
loadAmbientCredential: { ambientCredential(environment: ProcessInfo.processInfo.environment) },
now: { Date() }
)
}
static func ambientCredential(environment: [String: String]) -> Credential? {
for name in ["CLINEPASS_API_KEY", "CLINE_API_KEY"] {
if let key = cleaned(environment[name]) {
return Credential(token: key, isOAuth: false, expiresAt: nil)
}
}
guard let data = try? Data(contentsOf: providersFileURL(environment: environment)) else { return nil }
return fileCredential(data)
}
/// Cline's own lookup order: a settings file override, then a data dir, then a Cline dir.
static func providersFileURL(
environment: [String: String],
home: URL = FileManager.default.homeDirectoryForCurrentUser
) -> URL {
func path(_ name: String) -> URL? {
guard let value = cleaned(environment[name]) else { return nil }
if value == "~" { return home }
if value.hasPrefix("~/") { return home.appendingPathComponent(String(value.dropFirst(2))) }
return URL(fileURLWithPath: value)
}
if let file = path("CLINE_PROVIDER_SETTINGS_PATH") { return file }
let dataDir = path("CLINE_DATA_DIR")
?? (path("CLINE_DIR") ?? home.appendingPathComponent(".cline")).appendingPathComponent("data")
return dataDir.appendingPathComponent("settings/providers.json")
}
/// Matches Cline's getApiKey: the OAuth access token wins over keys kept in the same entry.
static func fileCredential(_ data: Data) -> Credential? {
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let providers = root["providers"] as? [String: Any],
let cline = providers["cline"] as? [String: Any],
let settings = cline["settings"] as? [String: Any] else { return nil }
let auth = settings["auth"] as? [String: Any]
if let access = cleaned(auth?["accessToken"] as? String) {
let expiresAt = (auth?["expiresAt"] as? NSNumber).map { Date(timeIntervalSince1970: $0.doubleValue / 1000) }
return Credential(
token: access.hasPrefix("workos:") ? access : "workos:\(access)",
isOAuth: true,
expiresAt: expiresAt
)
}
guard let key = cleaned(settings["apiKey"] as? String) ?? cleaned(auth?["apiKey"] as? String) else {
return nil
}
return Credential(token: key, isOAuth: false, expiresAt: nil)
}
private static func cleaned(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
return nil
}
return trimmed
}
@MainActor
static func refresh(apiKey: String?, deps: Deps = .live) async throws -> QuotaSummary {
let credential: Credential
if let key = cleaned(apiKey) {
credential = Credential(token: key, isOAuth: false, expiresAt: nil)
} else {
let load = deps.loadAmbientCredential
guard let ambient = await Task.detached(operation: { load() }).value else {
throw FetchError.noCredentials
}
credential = ambient
}
let now = deps.now()
let expiredAge = credential.expiresAt.flatMap {
$0 <= now ? CodexBankedResetPresentation.compactAge(of: $0, now: now) : nil
}
if credential.isOAuth, let expiredAge {
throw FetchError.signInExpired(age: expiredAge)
}
var request = URLRequest(url: usageURL)
request.httpMethod = "GET"
request.timeoutInterval = timeoutSeconds
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("Bearer \(credential.token)", forHTTPHeaderField: "Authorization")
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 credential.isOAuth ? FetchError.signInExpired(age: expiredAge) : 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 parsed: Any
do {
parsed = try JSONSerialization.jsonObject(with: data)
} catch {
throw FetchError.parseFailure
}
guard let root = parsed as? [String: Any] else {
throw FetchError.parseFailure
}
guard let success = root["success"] as? Bool else {
throw FetchError.parseFailure
}
guard success else {
throw FetchError.parseFailure
}
guard let dataObject = root["data"] as? [String: Any] else {
throw FetchError.parseFailure
}
guard let limits = dataObject["limits"] as? [Any] else {
throw FetchError.parseFailure
}
var fiveHour: QuotaSummary.Window?
var weekly: QuotaSummary.Window?
var monthly: QuotaSummary.Window?
for raw in limits {
guard let limit = raw as? [String: Any] else {
throw FetchError.parseFailure
}
guard let type = limit["type"] as? String else {
throw FetchError.parseFailure
}
let label: String
switch type {
case "five_hour": label = "5-hour"
case "weekly": label = "Weekly"
case "monthly": label = "Monthly"
default: continue
}
guard let percentUsed = jsonNumber(limit["percentUsed"]) else {
throw FetchError.parseFailure
}
let percent = min(1, max(0, percentUsed / 100))
let resetsAt: Date?
if let rawReset = limit["resetsAt"], !(rawReset is NSNull) {
guard let stamp = rawReset as? String, let date = parseReset(stamp) else {
throw FetchError.parseFailure
}
resetsAt = date
} else {
resetsAt = nil
}
let window = QuotaSummary.Window(label: label, percent: percent, resetsAt: resetsAt)
switch type {
case "five_hour": fiveHour = window
case "weekly": weekly = window
case "monthly": monthly = window
default: break
}
}
let details = [fiveHour, weekly, monthly].compactMap { $0 }
guard !details.isEmpty else {
throw FetchError.parseFailure
}
return QuotaSummary(
providerFilter: .all,
connection: .connected,
primary: weekly ?? fiveHour ?? monthly,
details: details,
planLabel: nil,
footerLines: []
)
}
private static func jsonNumber(_ value: Any?) -> Double? {
if let value = value as? Double { return value }
if let value = value as? Int { return Double(value) }
if let value = value as? NSNumber { return value.doubleValue }
return nil
}
private static func parseReset(_ raw: String) -> Date? {
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = iso.date(from: raw) { return date }
iso.formatOptions = [.withInternetDateTime]
return iso.date(from: raw)
}
}