macos-ai-menubar-usage/AIMenubarUsage/Models/AppSettings.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

219 lines
6.8 KiB
Swift

import Foundation
import Combine
import SwiftUI
enum AuthMode: String, Codable, CaseIterable, Identifiable, Sendable {
case auto
case manualSession
case adminAPI
var id: String { rawValue }
var displayName: String {
switch self {
case .auto: "Auto"
case .manualSession: "Manual token"
case .adminAPI: "Admin API key"
}
}
func displayName(for provider: ProviderID) -> String {
switch (self, provider) {
case (.auto, .claude):
"Claude Code CLI"
case (.auto, .cursor):
"Auto-detect (Cursor IDE)"
default:
displayName
}
}
}
enum AppearanceMode: String, CaseIterable, Identifiable, Codable {
case system
case light
case dark
var id: String { rawValue }
var displayName: String {
switch self {
case .system: "System"
case .light: "Light"
case .dark: "Dark"
}
}
var colorScheme: ColorScheme? {
switch self {
case .system: nil
case .light: .light
case .dark: .dark
}
}
}
enum RefreshInterval: Int, CaseIterable, Identifiable, Codable {
case oneMinute = 60
case fiveMinutes = 300
case fifteenMinutes = 900
var id: Int { rawValue }
var displayName: String {
switch self {
case .oneMinute: "1 minute"
case .fiveMinutes: "5 minutes"
case .fifteenMinutes: "15 minutes"
}
}
}
struct ProviderSettings: Codable, Equatable, Sendable {
var enabled: Bool
var authMode: AuthMode
var menuBarMetrics: [UsageMetric]
var popoverMetrics: [UsageMetric]
var menuBarLabel: String
static func defaults(for provider: ProviderID) -> ProviderSettings {
ProviderSettings(
enabled: true,
authMode: .auto,
menuBarMetrics: UsageMetric.defaults(for: provider, location: .menuBar),
popoverMetrics: UsageMetric.defaults(for: provider, location: .popover),
menuBarLabel: provider.defaultMenuBarLabel
)
}
}
@MainActor
final class AppSettings: ObservableObject {
static let shared = AppSettings()
private enum Keys {
static let providerSettings = "providerSettings"
static let refreshInterval = "refreshInterval"
static let launchAtLogin = "launchAtLogin"
static let staleWarningThreshold = "staleWarningThreshold"
static let appearanceMode = "appearanceMode"
}
@Published var providerSettings: [ProviderID: ProviderSettings] {
didSet { saveProviderSettings() }
}
@Published private(set) var revision = 0
@Published var refreshInterval: RefreshInterval {
didSet { UserDefaults.standard.set(refreshInterval.rawValue, forKey: Keys.refreshInterval) }
}
@Published var launchAtLogin: Bool {
didSet {
UserDefaults.standard.set(launchAtLogin, forKey: Keys.launchAtLogin)
LaunchAtLoginService.setEnabled(launchAtLogin)
}
}
@Published var staleWarningThreshold: Int {
didSet { UserDefaults.standard.set(staleWarningThreshold, forKey: Keys.staleWarningThreshold) }
}
@Published var appearanceMode: AppearanceMode {
didSet { UserDefaults.standard.set(appearanceMode.rawValue, forKey: Keys.appearanceMode) }
}
private init() {
if let data = UserDefaults.standard.data(forKey: Keys.providerSettings),
let decoded = try? JSONDecoder().decode([ProviderID: ProviderSettings].self, from: data) {
providerSettings = decoded
} else {
providerSettings = Dictionary(
uniqueKeysWithValues: ProviderID.allCases.map { ($0, ProviderSettings.defaults(for: $0)) }
)
}
let intervalRaw = UserDefaults.standard.integer(forKey: Keys.refreshInterval)
refreshInterval = RefreshInterval(rawValue: intervalRaw == 0 ? RefreshInterval.fiveMinutes.rawValue : intervalRaw)
?? .fiveMinutes
let savedLaunchAtLogin = UserDefaults.standard.bool(forKey: Keys.launchAtLogin)
launchAtLogin = savedLaunchAtLogin
let stale = UserDefaults.standard.integer(forKey: Keys.staleWarningThreshold)
staleWarningThreshold = stale == 0 ? 3600 : stale
if let savedAppearance = UserDefaults.standard.string(forKey: Keys.appearanceMode),
let mode = AppearanceMode(rawValue: savedAppearance) {
appearanceMode = mode
} else {
appearanceMode = .system
}
if savedLaunchAtLogin {
LaunchAtLoginService.setEnabled(true)
}
ensureAtLeastOneProviderEnabled()
}
func settings(for provider: ProviderID) -> ProviderSettings {
providerSettings[provider] ?? ProviderSettings.defaults(for: provider)
}
func updateSettings(for providerID: ProviderID, _ update: (inout ProviderSettings) -> Void) {
var updated = providerSettings
var providerConfig = updated[providerID] ?? ProviderSettings.defaults(for: providerID)
let wasEnabled = providerConfig.enabled
update(&providerConfig)
if wasEnabled, !providerConfig.enabled, wouldDisableLastProvider(disabling: providerID, in: updated) {
return
}
updated[providerID] = providerConfig
providerSettings = updated
revision += 1
}
var enabledProviders: [ProviderID] {
ProviderID.allCases.filter { settings(for: $0).enabled }
}
/// Providers shown in the menu bar — always at least one.
var menuBarProviders: [ProviderID] {
let enabled = enabledProviders
return enabled.isEmpty ? [.cursor] : enabled
}
var canDisableProvider: (ProviderID) -> Bool {
{ providerID in
guard self.settings(for: providerID).enabled else { return true }
return self.enabledProviders.count > 1
}
}
private func wouldDisableLastProvider(disabling providerID: ProviderID, in settings: [ProviderID: ProviderSettings]) -> Bool {
let enabledCount = ProviderID.allCases.filter { id in
if id == providerID { return false }
return settings[id]?.enabled ?? ProviderSettings.defaults(for: id).enabled
}.count
return enabledCount == 0
}
private func ensureAtLeastOneProviderEnabled() {
guard enabledProviders.isEmpty else { return }
var updated = providerSettings
for providerID in ProviderID.allCases {
var config = updated[providerID] ?? ProviderSettings.defaults(for: providerID)
config.enabled = true
updated[providerID] = config
}
providerSettings = updated
revision += 1
}
private func saveProviderSettings() {
guard let data = try? JSONEncoder().encode(providerSettings) else { return }
UserDefaults.standard.set(data, forKey: Keys.providerSettings)
}
}