Includes provider integrations, settings, NSStatusItem UI, tests, Makefile, and README with screenshot. Co-authored-by: Cursor <cursoragent@cursor.com>
292 lines
11 KiB
Swift
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)
|
|
}
|
|
}
|