mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-18 23:37:13 +00:00
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.
This commit is contained in:
parent
dcbf6dcfbf
commit
bf0c7cc993
4 changed files with 35 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue