diff --git a/CHANGELOG.md b/CHANGELOG.md index e892353..690addf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 2263ac8..3f5fa1e 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 diff --git a/Scripts/build-app.sh b/Scripts/build-app.sh index 94d8837..783bcb8 100755 --- a/Scripts/build-app.sh +++ b/Scripts/build-app.sh @@ -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:-}" diff --git a/Sources/AIUsageBarUI/AppModel.swift b/Sources/AIUsageBarUI/AppModel.swift index 6880795..58f9853 100644 --- a/Sources/AIUsageBarUI/AppModel.swift +++ b/Sources/AIUsageBarUI/AppModel.swift @@ -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(_:)`. @@ -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() ) @@ -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) @@ -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 @@ -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() } + } + /// Reactive path for the menu's live controls; a no-op during `apply(_:)`. private func settingChanged(rebuildConfig: Bool = false) { guard !isApplying else { return } @@ -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 = [] } @@ -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, @@ -424,7 +450,7 @@ public final class AppModel { geminiEnabled: geminiEnabled, claudeProfiles: discovered ) - applyRuntimeOverrides(to: &config) + applyRuntimeOverrides(to: &config, connected: claudeConnected) return config } @@ -432,9 +458,12 @@ public final class AppModel { 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 } diff --git a/Sources/AIUsageBarUI/MenuContentView.swift b/Sources/AIUsageBarUI/MenuContentView.swift index 0ed5caf..83e84d5 100644 --- a/Sources/AIUsageBarUI/MenuContentView.swift +++ b/Sources/AIUsageBarUI/MenuContentView.swift @@ -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, @@ -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 diff --git a/Sources/AIUsageBarUI/SettingsView.swift b/Sources/AIUsageBarUI/SettingsView.swift index 00776bf..d20309b 100644 --- a/Sources/AIUsageBarUI/SettingsView.swift +++ b/Sources/AIUsageBarUI/SettingsView.swift @@ -24,6 +24,7 @@ public struct SettingsView: View { ScrollView { Form { generalSection + claudeSection providersSection dataLocationsSection customProvidersSection @@ -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) { diff --git a/Tests/AIUsageBarUITests/ProviderSettingsTests.swift b/Tests/AIUsageBarUITests/ProviderSettingsTests.swift index ef203c5..9e51eaa 100644 --- a/Tests/AIUsageBarUITests/ProviderSettingsTests.swift +++ b/Tests/AIUsageBarUITests/ProviderSettingsTests.swift @@ -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)"