From bf0c7cc9930a548ab339baf62fbd40d2e884e4ff Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sun, 17 May 2026 00:31:33 -0700 Subject: [PATCH] Defer keychain access until user explicitly connects on plan tab Adds a `dormant` state to SubscriptionLoadState so the menubar never prompts for keychain permission on launch. Users must navigate to the plan tab and click "Load Quota" / "Connect" to trigger credential access. Also fixes a flaky TZ-boundary test (cli-status-menubar) by widening the time offset to avoid generating timestamps in the future at UTC hour 0. --- mac/Sources/CodeBurnMenubar/AppStore.swift | 27 ++++++++++++++----- .../Views/HeatmapSection.swift | 4 +-- .../CodeBurnMenubar/Views/SettingsView.swift | 14 ++++++++-- tests/cli-status-menubar.test.ts | 2 +- 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index c901362f..b88ccedd 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -36,12 +36,12 @@ final class AppStore { private var lastErrorByKey: [PayloadCacheKey: String] = [:] var subscription: SubscriptionUsage? var subscriptionError: String? - var subscriptionLoadState: SubscriptionLoadState = ClaudeCredentialStore.isBootstrapCompleted ? .loading : .notBootstrapped + var subscriptionLoadState: SubscriptionLoadState = ClaudeCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped var capacityEstimates: [String: CapacityEstimate] = [:] var codexUsage: CodexUsage? var codexError: String? - var codexLoadState: SubscriptionLoadState = CodexCredentialStore.isBootstrapCompleted ? .loading : .notBootstrapped + var codexLoadState: SubscriptionLoadState = CodexCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped /// Generation tokens for the in-flight refresh tasks. Incremented on every /// disconnect / reset so a fetch that started before the disconnect cannot @@ -405,8 +405,18 @@ final class AppStore { } /// User-initiated. Reads Claude's source (this is what triggers the macOS keychain - /// prompt for `Claude Code-credentials`). Once successful, subsequent background - /// refreshes go through our own keychain item without prompting. + func activateClaudeFromDormant() async { + guard case .dormant = subscriptionLoadState else { return } + subscriptionLoadState = .loading + _ = await refreshSubscriptionReportingSuccess() + } + + func activateCodexFromDormant() async { + guard case .dormant = codexLoadState else { return } + codexLoadState = .loading + _ = await refreshCodexReportingSuccess() + } + func bootstrapSubscription() async { subscriptionLoadState = .bootstrapping do { @@ -434,6 +444,7 @@ final class AppStore { /// rather than every attempt. @discardableResult func refreshSubscriptionReportingSuccess() async -> Bool { + if case .dormant = subscriptionLoadState { return false } guard ClaudeCredentialStore.isBootstrapCompleted else { if subscriptionLoadState != .notBootstrapped { subscriptionLoadState = .notBootstrapped @@ -511,6 +522,7 @@ final class AppStore { @discardableResult func refreshCodexReportingSuccess() async -> Bool { + if case .dormant = codexLoadState { return false } guard CodexCredentialStore.isBootstrapCompleted else { if codexLoadState != .notBootstrapped { codexLoadState = .notBootstrapped } return false @@ -640,7 +652,7 @@ final class AppStore { private func shouldIncludeCachedQuota(loadState: SubscriptionLoadState) -> Bool { switch loadState { - case .notBootstrapped, .bootstrapping, .noCredentials: + case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return false case .loading, .loaded, .failed, .terminalFailure, .transientFailure: return true @@ -662,7 +674,7 @@ final class AppStore { let connection: QuotaSummary.Connection = { switch subscriptionLoadState { - case .notBootstrapped, .bootstrapping, .noCredentials: return .disconnected + case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected case .loading: return subscription == nil ? .loading : .stale case .loaded: return .connected case .failed: return subscription == nil ? .loading : .stale @@ -700,7 +712,7 @@ final class AppStore { let connection: QuotaSummary.Connection = { switch codexLoadState { - case .notBootstrapped, .bootstrapping, .noCredentials: return .disconnected + case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected case .loading: return codexUsage == nil ? .loading : .stale case .loaded: return .connected case .failed: return codexUsage == nil ? .loading : .stale @@ -914,6 +926,7 @@ extension Notification.Name { enum SubscriptionLoadState: Sendable, Equatable { case notBootstrapped // no Keychain access yet — waiting for user to click Connect + case dormant // previously bootstrapped; keychain not yet accessed this session case bootstrapping // user clicked Connect; reading Claude's keychain (PROMPTS) case loading // background fetch in progress (subscription may already be populated) case loaded // success; subscription is populated diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index 3374bd93..751adbdf 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -900,7 +900,7 @@ private struct PlanInsight: View { var body: some View { Group { switch store.subscriptionLoadState { - case .notBootstrapped: + case .notBootstrapped, .dormant: PlanConnectView { Task { await store.bootstrapSubscription() } } case .bootstrapping: PlanLoadingView() @@ -1174,7 +1174,7 @@ private struct CodexPlanInsight: View { var body: some View { Group { switch store.codexLoadState { - case .notBootstrapped: + case .notBootstrapped, .dormant: PlanConnectView { Task { await store.bootstrapCodex() } } case .bootstrapping: PlanLoadingView() diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index a3173803..dae7406d 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -133,7 +133,7 @@ private struct ClaudeConnectionRow: View { case .terminalFailure: return "exclamationmark.triangle.fill" case .transientFailure: return "clock.arrow.circlepath" case .bootstrapping, .loading: return "ellipsis.circle" - case .notBootstrapped, .noCredentials: return "link.circle" + case .notBootstrapped, .dormant, .noCredentials: return "link.circle" case .failed: return "xmark.circle" } } @@ -154,6 +154,7 @@ private struct ClaudeConnectionRow: View { case .transientFailure: return "Backing off" case .bootstrapping: return "Connecting…" case .loading: return "Refreshing…" + case .dormant: return "Ready" case .notBootstrapped, .noCredentials: return "Not connected" case .failed: return "Couldn't load plan data" } @@ -170,6 +171,7 @@ private struct ClaudeConnectionRow: View { case .transientFailure: return store.subscriptionError ?? "Anthropic rate-limited; auto-retrying." case .bootstrapping: return "macOS may ask permission to read your credentials." case .loading: return "Background refresh in progress." + case .dormant: return "Tap Load Quota to fetch live usage from Anthropic." case .notBootstrapped, .noCredentials: return "Click Connect to read your Claude Code credentials and start tracking quota." case .failed: return store.subscriptionError ?? "" } @@ -194,6 +196,9 @@ private struct ClaudeConnectionRow: View { case .terminalFailure, .noCredentials, .failed: Button("Reconnect") { Task { await store.bootstrapSubscription() } } .buttonStyle(.borderedProminent) + case .dormant: + Button("Load Quota") { Task { await store.activateClaudeFromDormant() } } + .buttonStyle(.borderedProminent) case .notBootstrapped: Button("Connect") { Task { await store.bootstrapSubscription() } } .buttonStyle(.borderedProminent) @@ -256,7 +261,7 @@ private struct CodexConnectionRow: View { case .terminalFailure: return "exclamationmark.triangle.fill" case .transientFailure: return "clock.arrow.circlepath" case .bootstrapping, .loading: return "ellipsis.circle" - case .notBootstrapped, .noCredentials: return "link.circle" + case .notBootstrapped, .dormant, .noCredentials: return "link.circle" case .failed: return "xmark.circle" } } @@ -277,6 +282,7 @@ private struct CodexConnectionRow: View { case .transientFailure: return "Backing off" case .bootstrapping: return "Connecting…" case .loading: return "Refreshing…" + case .dormant: return "Ready" case .notBootstrapped, .noCredentials: return "Not connected" case .failed: return "Couldn't load Codex quota" } @@ -300,6 +306,7 @@ private struct CodexConnectionRow: View { case .transientFailure: return store.codexError ?? "ChatGPT rate-limited; auto-retrying." case .bootstrapping: return "Reading ~/.codex/auth.json." case .loading: return "Background refresh in progress." + case .dormant: return "Tap Load Quota to fetch live usage from chatgpt.com." case .notBootstrapped, .noCredentials: return "Click Connect to read your Codex CLI credentials. If Connect fails, run `codex login` in your terminal first to create ~/.codex/auth.json." case .failed: return store.codexError ?? "" @@ -325,6 +332,9 @@ private struct CodexConnectionRow: View { case .terminalFailure, .noCredentials, .failed: Button("Reconnect") { Task { await store.bootstrapCodex() } } .buttonStyle(.borderedProminent) + case .dormant: + Button("Load Quota") { Task { await store.activateCodexFromDormant() } } + .buttonStyle(.borderedProminent) case .notBootstrapped: Button("Connect") { Task { await store.bootstrapCodex() } } .buttonStyle(.borderedProminent) diff --git a/tests/cli-status-menubar.test.ts b/tests/cli-status-menubar.test.ts index 1513b5c3..74a16f9b 100644 --- a/tests/cli-status-menubar.test.ts +++ b/tests/cli-status-menubar.test.ts @@ -57,7 +57,7 @@ describe('codeburn status --format menubar-json', () => { const now = new Date() const h = now.getUTCHours() - const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 60_000) + const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000) const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z') const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z') const ts3 = new Date(base.getTime() + 120_000).toISOString().replace(/\.\d+Z$/, 'Z')