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