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
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ as you cut a tag.

## [Unreleased]

## [0.1.5] - 2026-07-14

### Changed
- **Claude live limits are now opt-in.** The app no longer reads the Claude Code token from
the Keychain at launch, so no credential prompt appears when it starts. The Claude tab shows a
**Connect** banner (also a Connect/Disconnect control in Settings → Claude); clicking it reads
the token in a context you initiated — macOS asks once, then remembers. Your account and cost
data still show without connecting. **Upgrading from an earlier version?** Live Claude limits are
off until you click **Connect** once (it's a one-time, opt-in step now).
- Local `Scripts/build-app.sh` builds now auto-sign with an installed **Developer ID** (stable
signature) instead of ad-hoc, so rebuilding during development doesn't re-trigger the Keychain
prompt. Falls back to ad-hoc when no Developer ID is present.

## [0.1.4] - 2026-07-13

### Fixed
Expand Down Expand Up @@ -69,7 +82,8 @@ First public release — every AI-coding limit in your menu bar.
- **Settings window** — refresh cadence, menu-bar style, budget, notifications,
provider toggles, and data-location roots.

[Unreleased]: https://github.com/Balanced02/ai-usage-bar/compare/v0.1.4...HEAD
[Unreleased]: https://github.com/Balanced02/ai-usage-bar/compare/v0.1.5...HEAD
[0.1.5]: https://github.com/Balanced02/ai-usage-bar/compare/v0.1.4...v0.1.5
[0.1.4]: https://github.com/Balanced02/ai-usage-bar/compare/v0.1.3...v0.1.4
[0.1.3]: https://github.com/Balanced02/ai-usage-bar/compare/v0.1.2...v0.1.3
[0.1.2]: https://github.com/Balanced02/ai-usage-bar/compare/v0.1.1...v0.1.2
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Everything is local — no proxy, no telemetry, no account of ours.
| Provider | Source | Fidelity |
|---|---|---|
| **Codex** | Newest `token_count` event under the configured Codex root (defaults to `$CODEX_HOME`, then `~/.codex`) — real 5h/weekly %, resets, plan, credits, tokens. Zero-auth. | ✅ Full |
| **Claude** | `GET api.anthropic.com/api/oauth/usage` per profile (OAuth token from the Keychain, account matched via `/api/oauth/profile`). Falls back to local token-activity if the endpoint is unavailable. | ✅ Full % |
| **Claude** | `GET api.anthropic.com/api/oauth/usage` per profile (OAuth token from the Keychain **after you Connect**, account matched via `/api/oauth/profile`). Falls back to local token-activity if the endpoint is unavailable. | ✅ Full % |
| **Gemini** | Detects `gemini-cli` and reads the selected configuration root (defaults to `~/.gemini`); shows the plan cap or "not detected". gemini-cli persists no live quota. | ⚠️ Best-effort |
| **Custom** | Any folder of `.jsonl` logs + the dot-paths you configure (e.g. `rate_limit.used_percent`, `rate_limit.resets_at`). Newest file, last matching line. | ✅ Whatever the tool writes |

Expand Down Expand Up @@ -115,10 +115,11 @@ Scripts/build-app.sh --install # build → /Applications → launch
- `Scripts/build-app.sh --run` — build and launch in place
- `Scripts/build-app.sh --install` — copy to `/Applications` (recommended; needed for launch-at-login)

On first launch, macOS asks to **Allow** Keychain access (for the Claude token) and to send
**notifications** — approve both. The build is ad-hoc signed, so the Keychain prompt reappears
after a rebuild; install once and you won't see it again. For distribution to other Macs you'd
need an Apple Developer ID + notarization.
Nothing prompts for credentials at launch. To see **live** Claude limits, open the Claude tab and
click **Connect** (or Settings → Claude) — macOS then asks once to **Allow** Keychain access for
the Claude token; choose **Always Allow**. Your account and cost data show without connecting.
(Building locally auto-signs with an installed Developer ID for a stable signature; released builds
are notarized, so that one-time grant sticks.)

## Settings

Expand Down
13 changes: 12 additions & 1 deletion Scripts/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,18 @@ CONFIG="release"
# SIGN_IDENTITY "Developer ID Application: Name (TEAMID)" (default: - = ad-hoc)
# SPARKLE_FEED_URL appcast URL baked into Info.plist
# SPARKLE_PUBLIC_KEY base64 EdDSA public key (from Sparkle's generate_keys)
SIGN_IDENTITY="${SIGN_IDENTITY:--}"
# Prefer a stable Developer ID signature for local builds: without it, every
# ad-hoc rebuild is a "new app" to macOS, which re-prompts for Keychain access.
# Falls back to ad-hoc ("-") when no Developer ID is installed (e.g. contributors,
# CI without certs). Set SIGN_IDENTITY explicitly to override either way.
if [ -z "${SIGN_IDENTITY:-}" ]; then
# awk exits 0 even with no match (and `|| true` guards a security error), so
# `set -euo pipefail` never aborts the build when no Developer ID cert exists —
# the ad-hoc fallback below runs instead (contributors, CI without certs).
SIGN_IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null \
| awk -F'"' '/Developer ID Application/ { print $2; exit }' || true)"
SIGN_IDENTITY="${SIGN_IDENTITY:--}"
fi
SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://balanced02.github.io/ai-usage-bar/appcast.xml}"
SPARKLE_PUBLIC_KEY="${SPARKLE_PUBLIC_KEY:-}"

Expand Down
39 changes: 34 additions & 5 deletions Sources/AIUsageBarUI/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ public final class AppModel {
public var monthlyBudgetUSD: Double { didSet { settingChanged() } }
/// Masks emails + repo names in the panel (for screen-sharing).
public var maskAccounts: Bool { didSet { settingChanged() } }
/// Opt-in to reading the Claude Code token from the Keychain for live limits.
/// Off by default, so nothing prompts for credentials at launch — the user
/// connects explicitly via `connectClaude()`. Local account/cost still show.
public private(set) var claudeConnected: Bool { didSet { settingChanged(rebuildConfig: true) } }
private var providerSettings: ProviderSettings

/// Suppresses the reactive `didSet` handlers during a bulk `apply(_:)`.
Expand All @@ -104,12 +108,14 @@ public final class AppModel {
let menuBarStyle = MenuBarStyle(rawValue: defaults.string(forKey: "menuBarStyle") ?? "") ?? .text
let monthlyBudgetUSD = defaults.object(forKey: "monthlyBudgetUSD") as? Double ?? 0
let maskAccounts = defaults.object(forKey: "maskAccounts") as? Bool ?? false
let claudeConnected = defaults.object(forKey: "claudeConnected") as? Bool ?? false
let providerSettings = ProviderSettings.load(from: defaults)
let initialConfig = Self.usageConfig(
providerSettings: providerSettings,
codexEnabled: codexEnabled,
claudeEnabled: claudeEnabled,
geminiEnabled: geminiEnabled,
claudeConnected: claudeConnected,
discovered: discoverClaudeProfiles()
)

Expand All @@ -123,6 +129,7 @@ public final class AppModel {
self.menuBarStyle = menuBarStyle
self.monthlyBudgetUSD = monthlyBudgetUSD
self.maskAccounts = maskAccounts
self.claudeConnected = claudeConnected
self.providerSettings = providerSettings
self.appliedClaudeProfiles = Self.effectiveClaudeProfiles(for: initialConfig)
self.service = UsageService(config: initialConfig)
Expand Down Expand Up @@ -351,7 +358,7 @@ public final class AppModel {
} catch {
return error.localizedDescription
}
Self.applyRuntimeOverrides(to: &config)
Self.applyRuntimeOverrides(to: &config, connected: claudeConnected)
let effectiveClaudeProfiles = Self.effectiveClaudeProfiles(for: config)
let shouldClearClaudeCards = appliedClaudeProfiles != effectiveClaudeProfiles

Expand Down Expand Up @@ -386,9 +393,26 @@ public final class AppModel {
defaults.set(menuBarStyle.rawValue, forKey: "menuBarStyle")
defaults.set(monthlyBudgetUSD, forKey: "monthlyBudgetUSD")
defaults.set(maskAccounts, forKey: "maskAccounts")
defaults.set(claudeConnected, forKey: "claudeConnected")
providerSettings.save(to: defaults)
}

/// Opt in to live Claude limits. Reads the Claude Code token from the Keychain
/// (macOS asks once, then remembers) and refreshes immediately so the prompt
/// appears in response to the user's click, not silently at launch.
public func connectClaude() {
guard !claudeConnected else { return }
claudeConnected = true // didSet → reconfigure with allowKeychain = true
Task { await refresh() }
}

/// Stop reading the Keychain / live endpoint; keep local account + cost data.
public func disconnectClaude() {
guard claudeConnected else { return }
claudeConnected = false
Task { await refresh() }
}

Comment on lines +396 to +415

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Race condition between configuration update and refresh.

Mutating claudeConnected synchronously triggers its didSet handler, which eventually spawns an unstructured Task { await service.update(config:) }. Immediately after, connectClaude()/disconnectClaude() spawn another Task { await refresh() }.

Because Swift Concurrency does not guarantee the execution order of these tasks, refresh() may execute before service.update() completes. If this happens, the refresh will use the stale configuration (e.g. failing to read the Keychain right after connecting), missing the immediate UI update until the next polling cycle.

Bypass the didSet handler using isApplying to properly sequence the service update and refresh within a single Task.

🔒️ Proposed fix to sequence the tasks safely
     /// Opt in to live Claude limits. Reads the Claude Code token from the Keychain
     /// (macOS asks once, then remembers) and refreshes immediately so the prompt
     /// appears in response to the user's click, not silently at launch.
     public func connectClaude() {
         guard !claudeConnected else { return }
-        claudeConnected = true            // didSet → reconfigure with allowKeychain = true
-        Task { await refresh() }
+        updateConnectionState(true)
     }
 
     /// Stop reading the Keychain / live endpoint; keep local account + cost data.
     public func disconnectClaude() {
         guard claudeConnected else { return }
-        claudeConnected = false
-        Task { await refresh() }
+        updateConnectionState(false)
+    }
+
+    private func updateConnectionState(_ connected: Bool) {
+        isApplying = true
+        claudeConnected = connected
+        isApplying = false
+        persist()
+
+        let config = Self.usageConfig(providerSettings: providerSettings,
+                                      codexEnabled: codexEnabled,
+                                      claudeEnabled: claudeEnabled,
+                                      geminiEnabled: geminiEnabled,
+                                      claudeConnected: connected,
+                                      discovered: discoverClaudeProfiles())
+        appliedClaudeProfiles = Self.effectiveClaudeProfiles(for: config)
+        if !claudeEnabled { lastClaude = [] }
+        
+        Task {
+            await service.update(config: config)
+            await refresh()
+        }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
defaults.set(claudeConnected, forKey: "claudeConnected")
providerSettings.save(to: defaults)
}
/// Opt in to live Claude limits. Reads the Claude Code token from the Keychain
/// (macOS asks once, then remembers) and refreshes immediately so the prompt
/// appears in response to the user's click, not silently at launch.
public func connectClaude() {
guard !claudeConnected else { return }
claudeConnected = true // didSet → reconfigure with allowKeychain = true
Task { await refresh() }
}
/// Stop reading the Keychain / live endpoint; keep local account + cost data.
public func disconnectClaude() {
guard claudeConnected else { return }
claudeConnected = false
Task { await refresh() }
}
defaults.set(claudeConnected, forKey: "claudeConnected")
providerSettings.save(to: defaults)
}
/// Opt in to live Claude limits. Reads the Claude Code token from the Keychain
/// (macOS asks once, then remembers) and refreshes immediately so the prompt
/// appears in response to the user's click, not silently at launch.
public func connectClaude() {
guard !claudeConnected else { return }
updateConnectionState(true)
}
/// Stop reading the Keychain / live endpoint; keep local account + cost data.
public func disconnectClaude() {
guard claudeConnected else { return }
updateConnectionState(false)
}
private func updateConnectionState(_ connected: Bool) {
isApplying = true
claudeConnected = connected
isApplying = false
persist()
let config = Self.usageConfig(providerSettings: providerSettings,
codexEnabled: codexEnabled,
claudeEnabled: claudeEnabled,
geminiEnabled: geminiEnabled,
claudeConnected: connected,
discovered: discoverClaudeProfiles())
appliedClaudeProfiles = Self.effectiveClaudeProfiles(for: config)
if !claudeEnabled { lastClaude = [] }
Task {
await service.update(config: config)
await refresh()
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AIUsageBarUI/AppModel.swift` around lines 396 - 415, Update
connectClaude() and disconnectClaude() to use the existing isApplying mechanism
when changing claudeConnected, bypassing its didSet-triggered update task.
Within one Task, apply the configuration change through the service update
first, then await refresh(), ensuring both operations are sequenced; preserve
the existing guards and connection-state behavior.

/// Reactive path for the menu's live controls; a no-op during `apply(_:)`.
private func settingChanged(rebuildConfig: Bool = false) {
guard !isApplying else { return }
Expand All @@ -402,6 +426,7 @@ public final class AppModel {
codexEnabled: codexEnabled,
claudeEnabled: claudeEnabled,
geminiEnabled: geminiEnabled,
claudeConnected: claudeConnected,
discovered: discoverClaudeProfiles())
appliedClaudeProfiles = Self.effectiveClaudeProfiles(for: config)
if !claudeEnabled { lastClaude = [] }
Expand All @@ -412,6 +437,7 @@ public final class AppModel {
codexEnabled: Bool,
claudeEnabled: Bool,
geminiEnabled: Bool,
claudeConnected: Bool,
discovered: [ClaudeProfile] = ClaudeProfileDiscovery.discover()) -> UsageConfig {
var config = (try? providerSettings.usageConfig(
codexEnabled: codexEnabled,
Expand All @@ -424,17 +450,20 @@ public final class AppModel {
geminiEnabled: geminiEnabled,
claudeProfiles: discovered
)
applyRuntimeOverrides(to: &config)
applyRuntimeOverrides(to: &config, connected: claudeConnected)
return config
}

private static func effectiveClaudeProfiles(for config: UsageConfig) -> [ClaudeProfile] {
config.claudeEnabled ? config.claudeProfiles : []
}

private static func applyRuntimeOverrides(to config: inout UsageConfig) {
// Dev/test escape hatch: skip Keychain + live endpoint (Claude falls back
// to local token activity). Set AIUSAGEBAR_NO_KEYCHAIN=1 to enable.
private static func applyRuntimeOverrides(to config: inout UsageConfig, connected: Bool) {
// Claude live limits are opt-in: the Keychain (and the usage endpoint) are
// only read once the user has explicitly connected, so nothing prompts for
// credentials at launch. Local account/plan/cost still show either way.
config.allowKeychain = connected
// Dev/test escape hatch: force off regardless of the connect toggle.
if ProcessInfo.processInfo.environment["AIUSAGEBAR_NO_KEYCHAIN"] != nil {
config.allowKeychain = false
}
Expand Down
26 changes: 26 additions & 0 deletions Sources/AIUsageBarUI/MenuContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ public struct MenuContentView: View {
} else {
KindTabBar(kinds: model.kinds, worst: model.worstPercent(for:), selection: $model.selectedKind)
Divider()
if model.selectedKind == .claude && !model.claudeConnected {
ClaudeConnectBanner { model.connectClaude() }
}
KindDetailView(cards: model.cards(for: model.selectedKind),
history: { card, window in model.sparkline(card.id, window) },
budget: model.monthlyBudgetUSD,
Expand Down Expand Up @@ -95,6 +98,29 @@ public struct MenuContentView: View {
}
}

/// Shown in the Claude tab until the user opts in to live limits — this keeps the
/// Keychain read (and its one-time macOS prompt) out of the app's launch path.
struct ClaudeConnectBanner: View {
let action: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 7) {
Image(systemName: "bolt.horizontal.circle.fill").foregroundStyle(.tint)
Text("Connect Claude for live limits").font(.callout.weight(.semibold))
}
Text("Reads the token Claude Code already stored to show your 5-hour and weekly limits. macOS asks once — choose “Always Allow.” Your account and cost still show without connecting.")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button("Connect", action: action)
.buttonStyle(.borderedProminent).controlSize(.small)
}
.padding(11)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 10).fill(Color.accentColor.opacity(0.08)))
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Color.accentColor.opacity(0.25)))
}
}

/// Live-updating "updated Ns ago" label.
struct RelativeUpdatedLabel: View {
let date: Date
Expand Down
21 changes: 21 additions & 0 deletions Sources/AIUsageBarUI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public struct SettingsView: View {
ScrollView {
Form {
generalSection
claudeSection
providersSection
dataLocationsSection
customProvidersSection
Expand All @@ -35,6 +36,26 @@ public struct SettingsView: View {
.frame(minWidth: 580, minHeight: 600)
}

// Connect is an immediate action (it reads the Keychain now), so it's a button
// rather than a draft-backed toggle that would only take effect on Apply.
private var claudeSection: some View {
Section("Claude") {
LabeledContent("Live limits") {
if model.claudeConnected {
HStack(spacing: 10) {
Label("Connected", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green).labelStyle(.titleAndIcon).font(.callout)
Button("Disconnect") { model.disconnectClaude() }
}
} else {
Button("Connect…") { model.connectClaude() }
}
}
Text("Reads the token Claude Code already stored in your Keychain to show live 5-hour and weekly limits — macOS asks once. Your account and cost still show without connecting.")
.font(.caption).foregroundStyle(.secondary)
}
}

private var generalSection: some View {
Section("General") {
Picker("Refresh cadence", selection: $draft.cadenceSeconds) {
Expand Down
25 changes: 25 additions & 0 deletions Tests/AIUsageBarUITests/ProviderSettingsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,31 @@ final class ProviderSettingsTests: XCTestCase {
XCTAssertEqual(config.claudeProfiles.map(\.name), ["Work"])
}

@MainActor
func testClaudeConnectIsOptInAndPersisted() async {
let suite = "AppModelConnect.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
setenv("AIUSAGEBAR_NO_KEYCHAIN", "1", 1) // never touch the real Keychain in tests
defer { unsetenv("AIUSAGEBAR_NO_KEYCHAIN") }

// Off by default → nothing reads the Keychain at launch.
let model = AppModel(defaults: defaults)
XCTAssertFalse(model.claudeConnected)
XCTAssertFalse(defaults.bool(forKey: "claudeConnected"))

model.connectClaude()
XCTAssertTrue(model.claudeConnected)
XCTAssertTrue(defaults.bool(forKey: "claudeConnected"))

// Persists across a relaunch.
XCTAssertTrue(AppModel(defaults: defaults).claudeConnected)

model.disconnectClaude()
XCTAssertFalse(model.claudeConnected)
XCTAssertFalse(defaults.bool(forKey: "claudeConnected"))
}

@MainActor
func testAppModelApplyPersistsValidatedDraftWithoutReadingProviders() async {
let suite = "AppModelSettingsTests.\(UUID().uuidString)"
Expand Down
Loading