Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,13 @@ History — read before re-stepping on these rakes:

- `Sources/Window/IslandWindowController.swift` — borderless overlay window. Listens to `NSApplication.didChangeScreenParametersNotification` to reposition on display changes; prefers the screen with `safeAreaInsets.top > 0` (the notched display).
- `Sources/Update/UpdaterController.swift` — wraps Sparkle's `SPUStandardUpdaterController`. Reads `SUFeedURL` / `SUPublicEDKey` from Info.plist (injected by `build.sh`). Auto-check state is stored by Sparkle itself in `NSUserDefaults` under `SU*` keys.
- `Sources/Usage/UsageFetcher.swift` — Codex (`/wham/usage`) and Claude (`/api/oauth/usage`) fetchers. Claude requires the `claude-code/X.Y.Z` User-Agent + `oauth-2025-04-20` beta header. Claude token handling is STRICTLY READ-ONLY (`ClaudeCredentials`): the app never calls the OAuth refresh endpoint and never writes the keychain. Anthropic rotates the refresh token on every refresh call and revokes the whole token family on old-token reuse, so a second refresher racing Claude Code invalidates the user's CLI login (this happened — do not reintroduce refresh). A 401 on the cached access token re-reads the store and retries once in the same pass (Claude Code rotates the token ~8h and our in-memory copy goes stale); only when the store itself holds a dead token does the app surface "token expired — run claude" until Claude Code refreshes it. Desktop-app Claude Code never maintains the CLI store (it injects a host-refreshed `CLAUDE_CODE_OAUTH_TOKEN` into its embedded CLI; its own tokens live in Chromium Safe Storage the app must not read), so on desktop-only days that expiry is permanent — `UsageStore` then spawns ONE detached `claude -p "ok" --model haiku --strict-mcp-config` ping per expiry episode to make the CLI refresh + write back itself, and a metadata-only credential-store fingerprint watch (5s tick, never prompts) refetches the moment the store changes. The ping is the CLI refreshing its own family — it is NOT the app calling the refresh endpoint, which stays forbidden. Credential sources, in order: env token → keychain items DISCOVERED by attributes-only enumeration matching service `Claude Code-credentials` or `Claude Code-credentials-*` (the CLI hashes a suffix per custom `CLAUDE_CONFIG_DIR`; we match what exists instead of recomputing its private formula, and secret reads go through `/usr/bin/security` FIRST — see the rake table) → `$CLAUDE_CONFIG_DIR/.credentials.json` as fallback (Claude Code 2.x maintains the keychain as primary on macOS and deletes/strands the file when the keychain works, so a coexisting file is the stale store). The usage endpoint also requires the `user:profile` scope as of mid-2026 — tokens from older logins return 403 and the only fix is `claude /login`.
- `Sources/Usage/UsageFetcher.swift` - Codex (`/wham/usage`) and Claude (`/api/oauth/usage`) fetchers. Claude requires the `claude-code/X.Y.Z` User-Agent + `oauth-2025-04-20` beta header.
Claude token handling is STRICTLY READ-ONLY (`ClaudeCredentials`): the app never calls the OAuth refresh endpoint and never writes the keychain. Anthropic rotates the refresh token on every refresh call and revokes the whole token family on old-token reuse, so a second refresher racing Claude Code invalidates the user's CLI login. This happened before, so do not reintroduce refresh.
A 401 on the cached access token re-reads the store and retries once in the same pass. Claude Code rotates the token about every eight hours while our in-memory copy goes stale. Only when the store itself holds a dead token does the app surface the terminal expired-token state until Claude Code refreshes it.
Desktop-app Claude Code never maintains the CLI store. It injects a host-refreshed `CLAUDE_CODE_OAUTH_TOKEN` into its embedded CLI, while its own tokens live in Chromium Safe Storage that the app must not read. On desktop-only days that expiry is permanent, so `UsageStore` spawns ONE detached `claude -p "ok" --model haiku --strict-mcp-config` ping per expiry episode to make the CLI refresh and write back itself.
A metadata-only credential-store watch is active only while Claude is selected. Broad Claude-item discovery runs at normal refresh boundaries; its 5-second tick queries only the discovered service/account pairs. Store changes invalidate the cached secret promptly, reset any old-account cooldown, and coalesce the next network fetch at least five minutes after the prior Claude request.
The ping is the CLI refreshing its own family. It is NOT the app calling the refresh endpoint, which stays forbidden. Credential sources, in order: env token, discovered keychain items matching service `Claude Code-credentials` or `Claude Code-credentials-*`, then `$CLAUDE_CONFIG_DIR/.credentials.json` as fallback. The CLI hashes a suffix per custom `CLAUDE_CONFIG_DIR`; we match what exists instead of recomputing its private formula. Secret reads use `/usr/bin/security` first. See the rake table.
The usage endpoint also requires the `user:profile` scope as of mid-2026. Tokens from older logins return 403 and the only fix is `claude /login`.
- `Sources/Usage/AppUsage.swift` — `plan` field carries Claude's `subscriptionType` (from keychain) or Codex's `plan_type` (from API top-level). Surfaced as the chip badge in `SettingsView` + `UsageView`.

## Build details
Expand Down
68 changes: 58 additions & 10 deletions Sources/Usage/ClaudeCredentials.swift
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,9 @@ enum ClaudeCredentials {
/// Last successful keychain read, held so ordinary polls don't re-trigger
/// the keychain ACL prompt every cycle. Only a successful read is cached
/// (nil results retry on the next poll). Invalidation: an unauthorized or
/// scope-insufficient probe clears it in `resolveUsage` — the token was
/// rotated or re-minted externally and the cached copy is stale — and the
/// in-app re-auth poll loop clears it once the store fingerprint changes.
/// scope-insufficient probe clears it in `resolveUsage` - the token was
/// rotated or re-minted externally and the cached copy is stale - and the
/// credential-store watcher clears it once the fingerprint changes.
/// Lock-guarded: the poll-timer fetch and the re-auth poll fetch run as
/// separate tasks off the main actor and can interleave here. Internal
/// (not private) so ResolveUsageTests can prime it and assert clearing.
Expand All @@ -235,11 +235,21 @@ enum ClaudeCredentials {
/// pops the ACL prompt on the machine running the tests.
static var keychainCandidatesProvider: () -> [KeychainCandidate] = readClaudeKeychainCandidates
static var keychainModificationDatesProvider: () -> [Date] = claudeKeychainModificationDates
private struct KeychainTarget: Hashable {
let service: String
let account: String
}
private static let keychainTargetsLock = NSLock()
private static var _keychainTargets: [KeychainTarget] = []

static func clearCache() {
cachedClaudeCreds = nil
}

static func refreshCredentialStoreTargets() {
_ = claudeKeychainItems()
}

/// Reads Claude Code's login from the keychain or file store, or nil if
/// there isn't a usable one — the caller then falls through to the next
/// token source. The KEYCHAIN comes first: Claude Code 2.x reads the
Expand Down Expand Up @@ -352,7 +362,7 @@ enum ClaudeCredentials {
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let items = result as? [[String: Any]] else { return [] }
return items
let claudeItems = items
.compactMap { item -> (service: String, account: String, modified: Date?)? in
guard let service = item[kSecAttrService as String] as? String,
isClaudeCredentialService(service),
Expand All @@ -363,14 +373,15 @@ enum ClaudeCredentials {
(lhs.service == claudeServiceBase ? 0 : 1, lhs.service)
< (rhs.service == claudeServiceBase ? 0 : 1, rhs.service)
}
let targets = claudeItems.map { KeychainTarget(service: $0.service, account: $0.account) }
keychainTargetsLock.withLock { _keychainTargets = targets }
return claudeItems
}

/// Prompt-free "has the credential store changed?" snapshot: the newest
/// of the credentials file's mtime and the keychain items' modification
/// dates, both from metadata-only reads that never trip the ACL prompt.
/// The re-auth poll loop compares snapshots so it pays the secret read
/// (and its possible prompt) only once `claude auth login` has actually
/// written new credentials — not on every 5s tick.
/// of the credentials file's mtime and targeted metadata queries for the
/// Claude items discovered at a normal refresh boundary. It never reads
/// every generic-password item on the watcher's 5-second tick.
static func credentialStoreFingerprint() -> Date? {
var dates = keychainModificationDatesProvider()
if let attrs = try? FileManager.default.attributesOfItem(atPath: claudeCredentialsFilePath()),
Expand All @@ -380,8 +391,45 @@ enum ClaudeCredentials {
return dates.max()
}

/// Metadata-only watch state whose baseline is captured synchronously
/// before a usage fetch can read the credential cache. Advancing the
/// baseline before clearing makes one watcher safe to keep across
/// event-driven refetches and later external account switches.
final class CredentialStoreWatch {
private var baseline: Date?

init() {
baseline = ClaudeCredentials.credentialStoreFingerprint()
}

/// Invalidate a previously read secret only after a prompt-free
/// metadata snapshot proves Claude Code changed its store. The old
/// access token can remain valid after an account switch, so HTTP auth
/// failures alone are not a sufficient invalidation signal (#103).
func invalidateCachedCredentialsIfStoreChanged() -> Bool {
let current = ClaudeCredentials.credentialStoreFingerprint()
guard current != baseline else { return false }
baseline = current
ClaudeCredentials.clearCache()
return true
}
}

private static func claudeKeychainModificationDates() -> [Date] {
claudeKeychainItems().compactMap { $0.modified }
let targets = keychainTargetsLock.withLock { _keychainTargets }
return targets.compactMap { target in
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: target.service,
kSecAttrAccount as String: target.account,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let item = result as? [String: Any] else { return nil }
return item[kSecAttrModificationDate as String] as? Date
}
}

/// Decoded JSON blob of one account's item, or nil on any read/parse error.
Expand Down
17 changes: 17 additions & 0 deletions Sources/Usage/ClaudeUsageCooldown.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation

struct ClaudeUsageCooldown {
private(set) var deadline: Date?

func isActive(at date: Date) -> Bool {
deadline.map { date < $0 } ?? false
}

mutating func arm(now: Date, duration: TimeInterval) {
deadline = now.addingTimeInterval(duration)
}

mutating func clear() {
deadline = nil
}
}
35 changes: 35 additions & 0 deletions Sources/Usage/ClaudeUsageScheduling.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Foundation

struct ClaudeRequestGate {
static let minimumInterval: TimeInterval = 300
private(set) var lastRequestAt: Date?

func delayUntilAllowed(at date: Date) -> TimeInterval {
guard let lastRequestAt else { return 0 }
return max(0, lastRequestAt.addingTimeInterval(Self.minimumInterval).timeIntervalSince(date))
}

mutating func claim(at date: Date) -> Bool {
guard delayUntilAllowed(at: date) == 0 else { return false }
lastRequestAt = date
return true
}
}

enum ClaudeCredentialWatchAction: Equatable {
case start
case keep
case stop
case none
}

enum ClaudeCredentialWatchPolicy {
static func action(claudeSelected: Bool, watchRunning: Bool) -> ClaudeCredentialWatchAction {
switch (claudeSelected, watchRunning) {
case (true, false): return .start
case (true, true): return .keep
case (false, true): return .stop
case (false, false): return .none
}
}
}
Loading