Merge pull request #338 from getagentseal/fix/dormant-keychain-prompt
Some checks are pending
CI / semgrep (push) Waiting to run

Defer keychain access until user clicks Connect on plan tab
This commit is contained in:
Resham Joshi 2026-05-17 00:31:52 -07:00 committed by GitHub
commit 7e0c1de086
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 35 additions and 12 deletions

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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')