Skip to content

Commit 56a3bc6

Browse files
authored
fix: stop repeated keychain prompts during session (#13)
The 1-hour cache TTL on the keychain read caused macOS to re-prompt for keychain access every hour while the app was running. Since the OAuth token only changes when Claude Code refreshes it, remove the TTL entirely — cache once per session, invalidate only on 401. - Remove cacheTTL and cacheTimestamp - Simplify cachedOrReadKeychainJSON to a simple nil-check - One keychain prompt per app launch, zero while running
1 parent 3b62170 commit 56a3bc6

File tree

1 file changed

+34
-3
lines changed

1 file changed

+34
-3
lines changed

TokenMeter/TokenMeter/Services/UsageAPIService.swift

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ struct CredentialMeta {
4040
actor UsageAPIService {
4141
private let endpoint = URL(string: "https://api.anthropic.com/api/oauth/usage")!
4242

43+
// In-memory cache — read keychain once per app session, not on every refresh.
44+
// Only invalidated on 401 (Claude Code refreshed the token).
45+
// No TTL: the token doesn't change unless Claude Code rotates it.
46+
private var cachedOAuth: [String: Any]?
47+
4348
func fetchUsage() async -> APIUsageResponse? {
4449
guard let token = readOAuthToken() else { return nil }
4550

@@ -52,7 +57,16 @@ actor UsageAPIService {
5257

5358
do {
5459
let (data, response) = try await URLSession.shared.data(for: request)
55-
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
60+
guard let http = response as? HTTPURLResponse else {
61+
return nil
62+
}
63+
if http.statusCode == 401 {
64+
// Token was refreshed by Claude Code — clear cache so next
65+
// refresh reads the new token from keychain
66+
invalidateCache()
67+
return nil
68+
}
69+
guard http.statusCode == 200 else {
5670
return nil
5771
}
5872
return try JSONDecoder().decode(APIUsageResponse.self, from: data)
@@ -62,14 +76,31 @@ actor UsageAPIService {
6276
}
6377

6478
func readCredentialMeta() -> CredentialMeta? {
65-
guard let json = readKeychainJSON() else { return nil }
79+
guard let json = cachedOrReadKeychainJSON() else { return nil }
6680
let tier = json["rateLimitTier"] as? String
6781
let sub = json["subscriptionType"] as? String
6882
return CredentialMeta(rateLimitTier: tier, subscriptionType: sub)
6983
}
7084

85+
func invalidateCache() {
86+
cachedOAuth = nil
87+
}
88+
7189
// MARK: - Keychain
7290

91+
private func cachedOrReadKeychainJSON() -> [String: Any]? {
92+
if let cached = cachedOAuth {
93+
return cached
94+
}
95+
96+
guard let json = readKeychainJSON() else {
97+
return nil
98+
}
99+
100+
cachedOAuth = json
101+
return json
102+
}
103+
73104
private func readKeychainJSON() -> [String: Any]? {
74105
let query: [String: Any] = [
75106
kSecClass as String: kSecClassGenericPassword,
@@ -91,6 +122,6 @@ actor UsageAPIService {
91122
}
92123

93124
private func readOAuthToken() -> String? {
94-
readKeychainJSON()?["accessToken"] as? String
125+
cachedOrReadKeychainJSON()?["accessToken"] as? String
95126
}
96127
}

0 commit comments

Comments
 (0)