macos-ai-menubar-usage/AIMenubarUsage/Views/SettingsView.swift
Felipe M. 00c4e33171
Initial commit: AI Menubar Usage — macOS menu bar app for Cursor and Claude.
Includes provider integrations, settings, NSStatusItem UI, tests, Makefile, and README with screenshot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 20:41:40 +02:00

292 lines
11 KiB
Swift

import SwiftUI
struct ProviderSettingsTab: View {
let providerID: ProviderID
@ObservedObject var settings: AppSettings
@ObservedObject var refreshService: UsageRefreshService
@State private var manualToken = ""
@State private var adminAPIKey = ""
@State private var connectionMessage: String?
@State private var isTesting = false
private var providerSettings: ProviderSettings {
settings.settings(for: providerID)
}
var body: some View {
Form {
Section("Provider") {
Toggle("Enabled", isOn: enabledBinding)
.disabled(!settings.canDisableProvider(providerID))
.onChange(of: providerSettings.enabled) { _, _ in
Task { await refreshService.refresh() }
}
if providerSettings.enabled, !settings.canDisableProvider(providerID) {
Text("At least one provider must stay enabled.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Section("Authentication") {
Picker("Mode", selection: binding(\.authMode)) {
ForEach(availableAuthModes) { mode in
Text(mode.displayName(for: providerID)).tag(mode)
}
}
if providerID == .claude, providerSettings.authMode == .auto {
Text("Runs `claude -p \"/usage\"` so the CLI handles authentication. No Keychain access from this app.")
.font(.caption)
.foregroundStyle(.secondary)
}
if providerSettings.authMode == .manualSession {
SecureField("Session token", text: $manualToken)
Text("For Cursor: WorkosCursorSessionToken value. For Claude: OAuth access token.")
.font(.caption)
.foregroundStyle(.secondary)
Button("Save Token") {
saveManualToken()
}
}
if providerSettings.authMode == .adminAPI {
SecureField("Admin API key", text: $adminAPIKey)
Text(adminAPIHelp)
.font(.caption)
.foregroundStyle(.secondary)
Button("Save API Key") {
saveAdminKey()
}
}
}
Section("Menu Bar") {
TextField("Label prefix", text: binding(\.menuBarLabel))
metricPicker(title: "Metrics", selection: binding(\.menuBarMetrics))
}
Section("Popover") {
metricPicker(title: "Metrics", selection: binding(\.popoverMetrics))
}
Section {
Button(isTesting ? "Testing…" : "Test Connection") {
Task { await testConnection() }
}
.disabled(isTesting)
if let connectionMessage {
Text(connectionMessage)
.font(.caption)
.foregroundStyle(connectionMessage.contains("Success") ? .green : .secondary)
}
}
}
.formStyle(.grouped)
.padding()
.onAppear {
loadKeychainValues()
}
.onChange(of: providerSettings.authMode) { _, newMode in
loadKeychainValues()
settings.updateSettings(for: providerID) { provider in
provider.menuBarMetrics = UsageMetric.defaults(for: providerID, location: .menuBar)
.filter { UsageMetric.metrics(for: providerID, authMode: newMode).contains($0) }
provider.popoverMetrics = UsageMetric.defaults(for: providerID, location: .popover)
.filter { UsageMetric.metrics(for: providerID, authMode: newMode).contains($0) }
}
}
}
private var availableAuthModes: [AuthMode] {
AuthMode.allCases
}
private var adminAPIHelp: String {
switch providerID {
case .cursor:
"Cursor Admin API key with admin:* scope from cursor.com/dashboard/api"
case .claude:
"Anthropic Admin API key (sk-ant-admin01-...) from console.anthropic.com"
}
}
private var enabledBinding: Binding<Bool> {
Binding(
get: { providerSettings.enabled },
set: { newValue in
settings.updateSettings(for: providerID) { $0.enabled = newValue }
}
)
}
private func binding<T>(_ keyPath: WritableKeyPath<ProviderSettings, T>) -> Binding<T> {
Binding(
get: { settings.settings(for: providerID)[keyPath: keyPath] },
set: { newValue in
settings.updateSettings(for: providerID) { $0[keyPath: keyPath] = newValue }
}
)
}
private func metricPicker(title: String, selection: Binding<[UsageMetric]>) -> some View {
let available = UsageMetric.metrics(for: providerID, authMode: providerSettings.authMode)
return VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
ForEach(available) { metric in
Toggle(metric.displayName, isOn: Binding(
get: { selection.wrappedValue.contains(metric) },
set: { isOn in
var current = selection.wrappedValue
if isOn {
if !current.contains(metric) { current.append(metric) }
} else {
current.removeAll { $0 == metric }
}
selection.wrappedValue = current
}
))
}
}
}
private func loadKeychainValues() {
let manualAccount = KeychainService.keychainAccount(provider: providerID, authMode: .manualSession)
manualToken = KeychainService.load(account: manualAccount) ?? ""
let adminAccount = KeychainService.keychainAccount(provider: providerID, authMode: .adminAPI)
adminAPIKey = KeychainService.load(account: adminAccount) ?? ""
}
private func saveManualToken() {
let account = KeychainService.keychainAccount(provider: providerID, authMode: .manualSession)
if manualToken.isEmpty {
KeychainService.delete(account: account)
connectionMessage = "Manual token removed."
} else {
try? KeychainService.save(manualToken, account: account)
connectionMessage = "Manual token saved to Keychain."
}
}
private func saveAdminKey() {
let account = KeychainService.keychainAccount(provider: providerID, authMode: .adminAPI)
if adminAPIKey.isEmpty {
KeychainService.delete(account: account)
connectionMessage = "Admin API key removed."
} else {
try? KeychainService.save(adminAPIKey, account: account)
connectionMessage = "Admin API key saved to Keychain."
}
}
private func testConnection() async {
isTesting = true
connectionMessage = nil
defer { isTesting = false }
let provider: any UsageProvider = providerID == .cursor ? CursorProvider() : ClaudeProvider()
do {
let credentials = try provider.resolveCredentials(authMode: providerSettings.authMode)
_ = try await provider.fetchUsage(credentials: credentials, cache: UsageCache.shared)
connectionMessage = "Success — connection works."
await refreshService.refresh()
} catch {
connectionMessage = error.localizedDescription
}
}
}
struct GeneralSettingsTab: View {
@ObservedObject var settings: AppSettings
@ObservedObject var refreshService: UsageRefreshService
var body: some View {
Form {
Section("Refresh") {
Picker("Interval", selection: $settings.refreshInterval) {
ForEach(RefreshInterval.allCases) { interval in
Text(interval.displayName).tag(interval)
}
}
.onChange(of: settings.refreshInterval) { _, _ in
refreshService.restartTimer()
}
}
Section("Behavior") {
Toggle("Launch at login", isOn: $settings.launchAtLogin)
Stepper(
"Stale warning after \(settings.staleWarningThreshold / 60) min",
value: $settings.staleWarningThreshold,
in: 300...7200,
step: 300
)
}
Section("Appearance") {
Picker("Theme", selection: $settings.appearanceMode) {
ForEach(AppearanceMode.allCases) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
}
}
.formStyle(.grouped)
.padding()
}
}
struct AboutSettingsTab: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("AI Menubar Usage")
.font(.title2)
.fontWeight(.semibold)
Text("Monitor Claude and Cursor usage from your menu bar.")
.foregroundStyle(.secondary)
Text("Authentication")
.font(.headline)
Text("Claude auto mode shells out to the Claude Code CLI (`claude -p \"/usage\"`), which handles login itself. Cursor auto mode reads the local Cursor IDE session. Manual tokens and Admin API keys are stored in this app's Keychain.")
Text("Unofficial APIs")
.font(.headline)
Text("Cursor dashboard and Claude OAuth usage endpoints are unofficial and may change without notice. Admin APIs require organization or team accounts.")
Spacer()
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
struct SettingsView: View {
@ObservedObject var settings: AppSettings
@ObservedObject var refreshService: UsageRefreshService
var body: some View {
TabView {
GeneralSettingsTab(settings: settings, refreshService: refreshService)
.tabItem { Label("General", systemImage: "gearshape") }
ProviderSettingsTab(providerID: .cursor, settings: settings, refreshService: refreshService)
.tabItem { Label("Cursor", image: ProviderIconAsset.name(for: .cursor)) }
ProviderSettingsTab(providerID: .claude, settings: settings, refreshService: refreshService)
.tabItem { Label("Claude", image: ProviderIconAsset.name(for: .claude)) }
AboutSettingsTab()
.tabItem { Label("About", systemImage: "info.circle") }
}
.frame(width: 520, height: 420)
.preferredColorScheme(settings.appearanceMode.colorScheme)
}
}