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 { Binding( get: { providerSettings.enabled }, set: { newValue in settings.updateSettings(for: providerID) { $0.enabled = newValue } } ) } private func binding(_ keyPath: WritableKeyPath) -> Binding { 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) } }