on 2026.07.13.0032 help sign in
usage-menubar/UsageMenubar/ClaudeUsageClient.swift
476 lines 19 kB history raw
1import Foundation
3// MARK: - Usage
5/// Usage data from `GET https://api.anthropic.com/api/oauth/usage` — the endpoint
6/// behind Claude Code's own `/usage` command.
7///
8/// Every field is optional. The payload varies by plan, and Anthropic adds and
9/// removes windows without notice, so an unrecognized shape has to degrade to
10/// "nothing to show" rather than fail the whole decode.
11struct ClaudeUsage: Decodable, Equatable {
12 let fiveHour: UsageWindow?
13 let sevenDay: UsageWindow?
14 let sevenDayOpus: UsageWindow?
15 let limits: [Limit]?
17 private enum CodingKeys: String, CodingKey {
18 case fiveHour = "five_hour"
19 case sevenDay = "seven_day"
20 case sevenDayOpus = "seven_day_opus"
21 case limits
22 }
24 /// The limit the server itself flags as currently binding — the one worth leading
25 /// with. `nil` when it flags none, which is the normal state below the thresholds.
26 var activeLimit: Limit? {
27 limits?.first { $0.isActive }
28 }
30 /// Whether there is anything at all to render. The endpoint answers `{}` for an
31 /// account that has never used the plan, and a section header over nothing reads
32 /// as a bug.
33 var isEmpty: Bool {
34 fiveHour == nil && sevenDay == nil && sevenDayOpus == nil && (limits?.isEmpty ?? true)
35 }
38/// One usage window, e.g. the rolling 5-hour or 7-day allowance.
39struct UsageWindow: Decodable, Equatable, ResetCountdown {
40 /// Percent of the window consumed, 0–100.
41 let utilization: Double
43 /// ISO 8601, or `nil` when nothing has been used and so nothing is counting down.
44 let resetsAt: String?
46 private enum CodingKeys: String, CodingKey {
47 case utilization
48 case resetsAt = "resets_at"
49 }
51 init(utilization: Double, resetsAt: String? = nil) {
52 self.utilization = utilization
53 self.resetsAt = resetsAt
54 }
56 init(from decoder: Decoder) throws {
57 let container = try decoder.container(keyedBy: CodingKeys.self)
58 utilization = try container.decodeIfPresent(Double.self, forKey: .utilization) ?? 0
59 resetsAt = try container.decodeIfPresent(String.self, forKey: .resetsAt)
60 }
63/// One entry in the `limits` array: the server's own classification of a limit,
64/// including whether it is the one currently in effect.
65struct Limit: Decodable, Equatable, ResetCountdown {
66 let percent: Int
67 let resetsAt: String?
68 let isActive: Bool
70 private enum CodingKeys: String, CodingKey {
71 case percent
72 case resetsAt = "resets_at"
73 case isActive = "is_active"
74 }
76 init(percent: Int, resetsAt: String? = nil, isActive: Bool = false) {
77 self.percent = percent
78 self.resetsAt = resetsAt
79 self.isActive = isActive
80 }
82 init(from decoder: Decoder) throws {
83 let container = try decoder.container(keyedBy: CodingKeys.self)
84 // Decoded as a Double and rounded: the field is documented as an integer, but
85 // decoding it as one would throw on a fractional value and take the whole
86 // payload down with it.
87 let rawPercent = try container.decodeIfPresent(Double.self, forKey: .percent) ?? 0
88 percent = Int(rawPercent.rounded())
89 resetsAt = try container.decodeIfPresent(String.self, forKey: .resetsAt)
90 isActive = try container.decodeIfPresent(Bool.self, forKey: .isActive) ?? false
91 }
94// MARK: - Reset Countdown
96/// Anything carrying an ISO 8601 reset timestamp, and so able to render a countdown.
97protocol ResetCountdown {
98 var resetsAt: String? { get }
101extension ResetCountdown {
102 /// `resetsAt` as a date, or `nil` when absent or unparseable.
103 var resetsAtDate: Date? {
104 guard let resetsAt else { return nil }
105 return ISO8601.date(from: resetsAt)
106 }
108 /// How long until the window resets, e.g. `"3h 20m"`, `"1d 4h"` or `"12m"`.
109 /// `nil` once the reset time has passed, or when there is no timestamp at all.
110 ///
111 /// - Parameter reference: The instant to count from. Injectable for tests.
112 func resetsIn(from reference: Date = Date()) -> String? {
113 guard let date = resetsAtDate else { return nil }
115 let interval = date.timeIntervalSince(reference)
116 guard interval > 0 else { return nil }
118 // Rounded to the nearest second, so a window that is exactly "3h 20m" out
119 // isn't reported a minute short because of the milliseconds spent getting here.
120 let seconds = Int(interval.rounded())
121 let days = seconds / 86_400
122 let hours = (seconds % 86_400) / 3_600
123 let minutes = (seconds % 3_600) / 60
125 if days > 0 { return "\(days)d \(hours)h" }
126 if hours > 0 { return "\(hours)h \(minutes)m" }
127 return "\(minutes)m"
128 }
130 /// The countdown as of now.
131 var resetsInFormatted: String? {
132 resetsIn()
133 }
136/// Claude timestamps carry fractional seconds, but not always — both spellings parse.
137private enum ISO8601 {
138 private static let fractional: ISO8601DateFormatter = {
139 let formatter = ISO8601DateFormatter()
140 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
141 return formatter
142 }()
144 private static let whole: ISO8601DateFormatter = {
145 let formatter = ISO8601DateFormatter()
146 formatter.formatOptions = [.withInternetDateTime]
147 return formatter
148 }()
150 static func date(from string: String) -> Date? {
151 fractional.date(from: string) ?? whole.date(from: string)
152 }
155// MARK: - Credentials
157/// The subset of Claude Code's OAuth blob this app reads. The key names are Claude
158/// Code's, not ours — and reading is *all* we do with them.
159///
160/// Deliberately models neither `refreshToken` nor `expiresAt`. Both existed only to
161/// serve a token refresh, and refreshing is precisely what this app must never do: the
162/// token endpoint rotates the refresh token, so an app refreshing behind the CLI's back
163/// leaves the CLI holding a token the server has already retired — signing the user out
164/// of their own terminal. Not modelling the fields is the cheapest way to keep it that
165/// way. Every field Claude Code stores that isn't named here is simply ignored.
166struct ClaudeOAuthCredentials: Decodable, Equatable {
167 let claudeAiOauth: OAuthToken
169 struct OAuthToken: Decodable, Equatable {
170 let accessToken: String
171 let scopes: [String]?
172 /// `"pro"`, `"max"`, `"team"`, …
173 let subscriptionType: String?
174 let rateLimitTier: String?
175 }
178/// Reads the credentials Claude Code owns. Read-only by design — there is no `save`,
179/// because the CLI is the only thing allowed to rotate these tokens.
180/// Injectable so tests never go near the real Keychain or the real credentials file.
181protocol ClaudeCredentialStoring {
182 func load() -> ClaudeOAuthCredentials?
185/// The real store: Claude Code's login-Keychain item, falling back to
186/// `~/.claude/.credentials.json`.
187///
188/// The Keychain item belongs to Claude Code, so it is read through the `security`
189/// CLI rather than `SecItemCopyMatching`: the item's ACL trusts `/usr/bin/security`,
190/// and macOS asks the user once to allow it. Spawning a process and reading a file in
191/// the user's home is also why this app cannot be sandboxed.
192struct ClaudeCodeCredentialStore: ClaudeCredentialStoring {
193 static let defaultKeychainService = "Claude Code-credentials"
195 /// `NSHomeDirectory()` rather than `FileManager.homeDirectoryForCurrentUser`: the
196 /// latter can resolve to a container path, while Claude Code always writes to the
197 /// real home directory.
198 static var defaultCredentialsFile: URL {
199 URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".claude/.credentials.json")
200 }
202 private let keychainService: String
203 private let credentialsFile: URL
205 init(
206 keychainService: String = ClaudeCodeCredentialStore.defaultKeychainService,
207 credentialsFile: URL = ClaudeCodeCredentialStore.defaultCredentialsFile
208 ) {
209 self.keychainService = keychainService
210 self.credentialsFile = credentialsFile
211 }
213 func load() -> ClaudeOAuthCredentials? {
214 if let json = Self.runSecurity(["find-generic-password", "-s", keychainService, "-w"]),
215 let credentials = Self.decode(Data(json.utf8)) {
216 return credentials
217 }
219 guard let data = try? Data(contentsOf: credentialsFile) else { return nil }
220 return Self.decode(data)
221 }
223 private static func decode(_ data: Data) -> ClaudeOAuthCredentials? {
224 try? JSONDecoder().decode(ClaudeOAuthCredentials.self, from: data)
225 }
227 /// Runs `/usr/bin/security`, returning its trimmed stdout, or `nil` when it fails
228 /// or says nothing — which is the ordinary answer when Claude Code was never
229 /// signed in on this machine.
230 private static func runSecurity(_ arguments: [String]) -> String? {
231 let process = Process()
232 process.executableURL = URL(fileURLWithPath: "/usr/bin/security")
233 process.arguments = arguments
235 let output = Pipe()
236 process.standardOutput = output
237 // Discarded rather than piped: an undrained error pipe can fill and deadlock.
238 process.standardError = FileHandle.nullDevice
240 do {
241 try process.run()
242 } catch {
243 return nil
244 }
246 // Drained before waiting, for the same reason.
247 let data = output.fileHandleForReading.readDataToEndOfFile()
248 process.waitUntilExit()
250 guard process.terminationStatus == 0 else { return nil }
252 let text = String(data: data, encoding: .utf8)?
253 .trimmingCharacters(in: .whitespacesAndNewlines)
254 guard let text, !text.isEmpty else { return nil }
255 return text
256 }
259// MARK: - Errors
261/// Errors that can occur while fetching Claude usage.
262enum ClaudeUsageError: LocalizedError, Equatable {
263 /// Claude Code has never signed in here. Not an error the user needs to see — the
264 /// popover just leaves the section out.
265 case noCredentials
267 /// The token the CLI last stored has expired or been revoked. The app cannot fix
268 /// this itself — only the CLI may mint a new one — so the message sends the user
269 /// there.
270 case invalidCredentials
271 case requestFailed(String)
272 case decodingFailed(String)
274 var errorDescription: String? {
275 switch self {
276 case .noCredentials:
277 return "No Claude Code credentials found. Run `claude` in a terminal to sign in."
278 case .invalidCredentials:
279 return "Claude sign-in expired. Run `claude` in a terminal to sign in again."
280 case .requestFailed(let message):
281 return "Claude request failed: \(message)"
282 case .decodingFailed(let message):
283 return "Failed to decode Claude usage: \(message)"
284 }
285 }
288/// What a successful fetch yields: the usage itself, plus the plan it belongs to.
289struct ClaudeUsageReport: Equatable {
290 let usage: ClaudeUsage
292 /// `"pro"`, `"max"`, … straight from the OAuth credentials; `nil` when absent.
293 let subscriptionType: String?
296/// Protocol abstraction for fetching Claude usage, enabling test injection.
297protocol ClaudeUsageChecking {
298 func fetchUsage() async throws -> ClaudeUsageReport
301// MARK: - Client
303/// Client for Claude Code's subscription usage API, authenticating with whatever OAuth
304/// access token the CLI has most recently stored on this machine.
305///
306/// **A passenger, never a driver.** It reads the CLI's token and spends it as-is. It
307/// never refreshes: the token endpoint *rotates* the refresh token, and the CLI refreshes
308/// on its own schedule, so an app that also refreshed would race it — whichever of the two
309/// went second would present a refresh token the server had already retired, and the user
310/// would find themselves signed out of their own terminal. That bug is not worth a usage
311/// readout. When the token is spent, we say so and point at `claude`.
312///
313/// Credentials are re-read on every fetch (the app polls every 1–5 minutes), so a token
314/// the CLI refreshes is picked up on the next tick without the app lifting a finger.
315///
316/// Still an actor: it holds no mutable state now that the refresh is gone, but the
317/// overlapping fetches (timer, manual click, wake) share one instance, and an actor keeps
318/// that sharing trivially safe.
319actor ClaudeUsageClient: ClaudeUsageChecking {
320 static let usageEndpoint = URL(string: "https://api.anthropic.com/api/oauth/usage")!
322 /// Cloudflare fronts the API and answers 403 `browser_signature_banned` to anything
323 /// without a browser User-Agent.
324 static let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
325 + "(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
327 /// Retries *after* the initial attempt, so at most 3 requests per call.
328 static let maxRetries = 2
330 /// URL error codes worth retrying: the request never reached a server that had an
331 /// opinion about it, so the same request may well succeed a moment later.
332 private static let retryableURLErrorCodes: Set<URLError.Code> = [
333 .timedOut,
334 .cannotConnectToHost,
335 .cannotFindHost,
336 .dnsLookupFailed,
337 .networkConnectionLost,
338 .notConnectedToInternet,
339 .resourceUnavailable,
340 .badServerResponse,
341 ]
343 /// Client-error status codes that are nonetheless worth retrying: both are the
344 /// server telling us to come back later, not that the request itself is wrong.
345 private static let retryableStatusCodes: Set<Int> = [
346 408, // Request Timeout
347 429, // Too Many Requests
348 ]
350 private let session: URLSession
351 private let store: ClaudeCredentialStoring
352 private let retryBaseDelay: Duration
354 /// - Parameters:
355 /// - retryBaseDelay: Delay before the first retry; doubles on each subsequent
356 /// one (2s, then 4s). Injectable so tests need not wait in real time.
357 init(
358 session: URLSession? = nil,
359 store: ClaudeCredentialStoring = ClaudeCodeCredentialStore(),
360 retryBaseDelay: Duration = .seconds(2)
361 ) {
362 self.store = store
363 self.retryBaseDelay = retryBaseDelay
364 if let session {
365 self.session = session
366 } else {
367 let config = URLSessionConfiguration.default
368 config.timeoutIntervalForRequest = 10
369 config.timeoutIntervalForResource = 15
370 self.session = URLSession(configuration: config)
371 }
372 }
374 /// Fetches current usage with the CLI's current access token, exactly as stored.
375 ///
376 /// The token is not checked for expiry and not refreshed on rejection — see the type
377 /// comment. A spent token surfaces as `.invalidCredentials`, which asks the user to
378 /// run `claude`; doing so has the CLI mint a fresh token, which the next fetch picks
379 /// up on its own.
380 ///
381 /// - Throws: `ClaudeUsageError`. `.noCredentials` means Claude Code was never
382 /// signed in here, which callers should treat as "nothing to show" rather than
383 /// as a failure.
384 func fetchUsage() async throws -> ClaudeUsageReport {
385 guard let credentials = store.load() else { throw ClaudeUsageError.noCredentials }
387 let token = credentials.claudeAiOauth
388 let usage = try await requestUsage(token: token.accessToken)
389 return ClaudeUsageReport(usage: usage, subscriptionType: token.subscriptionType)
390 }
392 // MARK: - Usage Request
394 private func requestUsage(token: String) async throws -> ClaudeUsage {
395 var request = URLRequest(url: Self.usageEndpoint, cachePolicy: .reloadIgnoringLocalCacheData)
396 request.httpMethod = "GET"
397 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
398 request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta")
399 request.setValue("application/json", forHTTPHeaderField: "Accept")
400 request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent")
402 let data = try await send(request)
404 do {
405 return try JSONDecoder().decode(ClaudeUsage.self, from: data)
406 } catch {
407 throw ClaudeUsageError.decodingFailed(error.localizedDescription)
408 }
409 }
411 // MARK: - Transport
413 /// Sends a request, retrying transient failures with exponential backoff.
414 private func send(_ request: URLRequest) async throws -> Data {
415 var attempt = 0
417 while true {
418 do {
419 return try await performAttempt(request)
420 } catch let failure as AttemptFailure {
421 guard failure.isRetryable, attempt < Self.maxRetries else {
422 throw failure.underlying
423 }
424 try await Task.sleep(for: retryBaseDelay * (1 << attempt))
425 attempt += 1
426 }
427 }
428 }
430 /// Performs a single request, tagging the failure with whether a retry could
431 /// plausibly help.
432 private func performAttempt(_ request: URLRequest) async throws -> Data {
433 let data: Data
434 let response: URLResponse
435 do {
436 (data, response) = try await session.data(for: request)
437 } catch let urlError as URLError {
438 throw AttemptFailure(
439 underlying: .requestFailed(urlError.localizedDescription),
440 isRetryable: Self.retryableURLErrorCodes.contains(urlError.code)
441 )
442 } catch {
443 throw AttemptFailure(
444 underlying: .requestFailed(error.localizedDescription),
445 isRetryable: false
446 )
447 }
449 guard let httpResponse = response as? HTTPURLResponse else {
450 throw AttemptFailure(underlying: .requestFailed("Invalid response type"), isRetryable: false)
451 }
453 switch httpResponse.statusCode {
454 case 200...299:
455 return data
456 case 401, 403:
457 throw AttemptFailure(underlying: .invalidCredentials, isRetryable: false)
458 case 500...599:
459 throw AttemptFailure(
460 underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
461 isRetryable: true
462 )
463 default:
464 throw AttemptFailure(
465 underlying: .requestFailed("HTTP \(httpResponse.statusCode)"),
466 isRetryable: Self.retryableStatusCodes.contains(httpResponse.statusCode)
467 )
468 }
469 }
471 /// A failed attempt, carrying the error to surface plus whether retrying is worthwhile.
472 private struct AttemptFailure: Error {
473 let underlying: ClaudeUsageError
474 let isRetryable: Bool
475 }