Increment 5: Settings, launch-at-login & rich menu-bar HUD
- AppSettings (@MainActor, UserDefaults-persisted): refresh interval, menu-bar metric, notifications toggle, CPU/disk/low-battery alert thresholds, launch-at-login; pushes changes into live services; nonisolated clampInterval - LoginItem via SMAppService (macOS 13+), register/unregister with error catch - SystemMonitor: runtime-configurable refresh cadence (was hardcoded 3s) - SettingsView (⌘,): General (login, interval, menu-bar metric) + Alerts (notifications + thresholds) tabs - MenuBarLabel: user-selectable menu-bar metric (CPU/Memory/Health/Download) - Menu-bar dropdown: Top CPU Consumers with one-click quit - 3 new tests (55 total); smoke: runs clean, Settings… menu registered Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,9 @@
|
||||
744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */; };
|
||||
79E837AE662021C521F7E74F /* HealthModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C70E54E350F7C8C22D342594 /* HealthModel.swift */; };
|
||||
7A1144E43159D96564C13164 /* MemoryInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */; };
|
||||
7A6088469A2643D360B35B89 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892247B0F17363DFC146EDF8 /* AppSettings.swift */; };
|
||||
833976FD8DC4CCC45078CF9A /* DiskMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */; };
|
||||
8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01C19ABF49697A97F0C40378 /* SettingsTests.swift */; };
|
||||
8F9ED99AB6D8D90AE79B5179 /* DiskUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */; };
|
||||
9146403BC02D18A234444DF6 /* ProcessMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 295232B4C746511757DCF8FE /* ProcessMonitor.swift */; };
|
||||
9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */; };
|
||||
@@ -35,6 +37,7 @@
|
||||
A7EC9FB55D5088D669491C11 /* AppsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813543165B39EBD213EB3BC7 /* AppsView.swift */; };
|
||||
B0165358B483B0ECC28DB4DE /* PowerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C512B13DC0C1024CF39A5E /* PowerInfo.swift */; };
|
||||
B62B18DF32A8734D5908B16B /* DiskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352CA1BE2A3B43643A9DBD72 /* DiskInfo.swift */; };
|
||||
B89695749D32F394DB459C15 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AACBF14FFC5EC68C9E438040 /* SettingsView.swift */; };
|
||||
BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */; };
|
||||
C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110A314A63AE355A9E04990D /* ProcessDetailView.swift */; };
|
||||
CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFADE83965AA863B775A910 /* NetworkChartView.swift */; };
|
||||
@@ -62,6 +65,7 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
01C19ABF49697A97F0C40378 /* SettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTests.swift; sourceTree = "<group>"; };
|
||||
05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsStore.swift; sourceTree = "<group>"; };
|
||||
0D5B918C9462144E9EE7880A /* Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alert.swift; sourceTree = "<group>"; };
|
||||
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsStoreTests.swift; sourceTree = "<group>"; };
|
||||
@@ -89,6 +93,7 @@
|
||||
78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteFormatters.swift; sourceTree = "<group>"; };
|
||||
7B6F97E6EF65AA17A469F037 /* ProcessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessView.swift; sourceTree = "<group>"; };
|
||||
813543165B39EBD213EB3BC7 /* AppsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsView.swift; sourceTree = "<group>"; };
|
||||
892247B0F17363DFC146EDF8 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
|
||||
8E4A6E8E2305CEDC119AC6BC /* TopManager_icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = TopManager_icon.png; sourceTree = "<group>"; };
|
||||
8F402CE13EF67DE550786B79 /* MemoryDonutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryDonutView.swift; sourceTree = "<group>"; };
|
||||
94D5A13CC5B9CF1B90CC2214 /* CPUChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUChartView.swift; sourceTree = "<group>"; };
|
||||
@@ -98,6 +103,7 @@
|
||||
9D71CC63FC2FEF52A069372E /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = "<group>"; };
|
||||
A3A3D0B1EB522C9975F71DA8 /* TopManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TopManager.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A404EBD588B7ACD36079A95D /* TopManagerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopManagerApp.swift; sourceTree = "<group>"; };
|
||||
AACBF14FFC5EC68C9E438040 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
B6038CCDF3BDF0F8BC93C9D4 /* PowerStorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerStorageView.swift; sourceTree = "<group>"; };
|
||||
BBFADE83965AA863B775A910 /* NetworkChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkChartView.swift; sourceTree = "<group>"; };
|
||||
BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryInfo.swift; sourceTree = "<group>"; };
|
||||
@@ -115,6 +121,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C2C96471F109FF1C20ABF59E /* AlertCenter.swift */,
|
||||
892247B0F17363DFC146EDF8 /* AppSettings.swift */,
|
||||
19ED582C412C63A0A2E2103E /* CPUMonitor.swift */,
|
||||
162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */,
|
||||
BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */,
|
||||
@@ -211,6 +218,7 @@
|
||||
children = (
|
||||
C4F1ACD0492CAB741595DEAA /* ContentView.swift */,
|
||||
9D71CC63FC2FEF52A069372E /* MenuBarView.swift */,
|
||||
AACBF14FFC5EC68C9E438040 /* SettingsView.swift */,
|
||||
6901D0C7B3DA57547BCFAAFC /* Apps */,
|
||||
CD1E8360C279FD4EFAD85DF6 /* Health */,
|
||||
4935484C55FD4C912A30D623 /* Performance */,
|
||||
@@ -245,6 +253,7 @@
|
||||
E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */,
|
||||
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */,
|
||||
332290DABD5326C87258723C /* ProcessDataTests.swift */,
|
||||
01C19ABF49697A97F0C40378 /* SettingsTests.swift */,
|
||||
);
|
||||
path = TopManagerTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -367,6 +376,7 @@
|
||||
489E29F78A4093D3548673E6 /* HealthAlertTests.swift in Sources */,
|
||||
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */,
|
||||
2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */,
|
||||
8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -376,6 +386,7 @@
|
||||
files = (
|
||||
EB7EAB13C0DC7865BB1A99D5 /* Alert.swift in Sources */,
|
||||
67F66E7EC7C01A6309DC060E /* AlertCenter.swift in Sources */,
|
||||
7A6088469A2643D360B35B89 /* AppSettings.swift in Sources */,
|
||||
A7EC9FB55D5088D669491C11 /* AppsView.swift in Sources */,
|
||||
DE50ECC69FCE60B10364802C /* ByteFormatters.swift in Sources */,
|
||||
FA252F9182ADDA26AD1E54DE /* CPUChartView.swift in Sources */,
|
||||
@@ -406,6 +417,7 @@
|
||||
744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */,
|
||||
9146403BC02D18A234444DF6 /* ProcessMonitor.swift in Sources */,
|
||||
0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */,
|
||||
B89695749D32F394DB459C15 /* SettingsView.swift in Sources */,
|
||||
DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */,
|
||||
D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */,
|
||||
9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */,
|
||||
|
||||
108
TopManager/Services/AppSettings.swift
Normal file
108
TopManager/Services/AppSettings.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
import ServiceManagement
|
||||
|
||||
/// What the menu-bar title shows at a glance.
|
||||
enum MenuBarMetric: String, CaseIterable, Identifiable {
|
||||
case cpu = "CPU %"
|
||||
case memory = "Memory %"
|
||||
case health = "Health"
|
||||
case download = "Download"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
/// Wraps the macOS login-item API (SMAppService, macOS 13+).
|
||||
enum LoginItem {
|
||||
static var isEnabled: Bool {
|
||||
SMAppService.mainApp.status == .enabled
|
||||
}
|
||||
static func setEnabled(_ enabled: Bool) {
|
||||
do {
|
||||
if enabled {
|
||||
if SMAppService.mainApp.status != .enabled { try SMAppService.mainApp.register() }
|
||||
} else {
|
||||
if SMAppService.mainApp.status == .enabled { try SMAppService.mainApp.unregister() }
|
||||
}
|
||||
} catch {
|
||||
NSLog("TopManager: login item toggle failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted user preferences. Changes are written to `UserDefaults` and pushed
|
||||
/// into the running services (monitor cadence, alert thresholds, notifications).
|
||||
@MainActor
|
||||
final class AppSettings: ObservableObject {
|
||||
static let shared = AppSettings()
|
||||
|
||||
private let d = UserDefaults.standard
|
||||
|
||||
@Published var refreshInterval: Double {
|
||||
didSet {
|
||||
d.set(refreshInterval, forKey: Keys.refreshInterval)
|
||||
SystemMonitor.shared.updateRefreshInterval(refreshInterval)
|
||||
}
|
||||
}
|
||||
@Published var menuBarMetric: MenuBarMetric {
|
||||
didSet { d.set(menuBarMetric.rawValue, forKey: Keys.menuBarMetric) }
|
||||
}
|
||||
@Published var notificationsEnabled: Bool {
|
||||
didSet {
|
||||
d.set(notificationsEnabled, forKey: Keys.notificationsEnabled)
|
||||
AlertCenter.shared.notificationsEnabled = notificationsEnabled
|
||||
}
|
||||
}
|
||||
@Published var cpuAlertThreshold: Double {
|
||||
didSet {
|
||||
d.set(cpuAlertThreshold, forKey: Keys.cpuAlertThreshold)
|
||||
AlertCenter.shared.thresholds.cpuPercent = cpuAlertThreshold
|
||||
}
|
||||
}
|
||||
@Published var diskAlertPercent: Double {
|
||||
didSet {
|
||||
d.set(diskAlertPercent, forKey: Keys.diskAlertPercent)
|
||||
AlertCenter.shared.thresholds.diskUsedFraction = diskAlertPercent / 100
|
||||
}
|
||||
}
|
||||
@Published var lowBatteryPercent: Int {
|
||||
didSet {
|
||||
d.set(lowBatteryPercent, forKey: Keys.lowBatteryPercent)
|
||||
AlertCenter.shared.thresholds.lowBatteryPercent = lowBatteryPercent
|
||||
}
|
||||
}
|
||||
@Published var launchAtLogin: Bool {
|
||||
didSet { LoginItem.setEnabled(launchAtLogin) }
|
||||
}
|
||||
|
||||
private init() {
|
||||
refreshInterval = AppSettings.clampInterval(d.object(forKey: Keys.refreshInterval) as? Double ?? 3.0)
|
||||
menuBarMetric = MenuBarMetric(rawValue: d.string(forKey: Keys.menuBarMetric) ?? "") ?? .cpu
|
||||
notificationsEnabled = d.object(forKey: Keys.notificationsEnabled) as? Bool ?? true
|
||||
cpuAlertThreshold = d.object(forKey: Keys.cpuAlertThreshold) as? Double ?? 90
|
||||
diskAlertPercent = d.object(forKey: Keys.diskAlertPercent) as? Double ?? 95
|
||||
lowBatteryPercent = d.object(forKey: Keys.lowBatteryPercent) as? Int ?? 15
|
||||
launchAtLogin = LoginItem.isEnabled
|
||||
}
|
||||
|
||||
/// Push all persisted values into the live services (call once on launch).
|
||||
func applyAll() {
|
||||
SystemMonitor.shared.updateRefreshInterval(refreshInterval)
|
||||
AlertCenter.shared.notificationsEnabled = notificationsEnabled
|
||||
AlertCenter.shared.thresholds.cpuPercent = cpuAlertThreshold
|
||||
AlertCenter.shared.thresholds.diskUsedFraction = diskAlertPercent / 100
|
||||
AlertCenter.shared.thresholds.lowBatteryPercent = lowBatteryPercent
|
||||
}
|
||||
|
||||
/// Refresh cadence must stay in a sane range (1–30s).
|
||||
nonisolated static func clampInterval(_ value: Double) -> Double {
|
||||
min(30, max(1, value))
|
||||
}
|
||||
|
||||
private enum Keys {
|
||||
static let refreshInterval = "refreshInterval"
|
||||
static let menuBarMetric = "menuBarMetric"
|
||||
static let notificationsEnabled = "notificationsEnabled"
|
||||
static let cpuAlertThreshold = "cpuAlertThreshold"
|
||||
static let diskAlertPercent = "diskAlertPercent"
|
||||
static let lowBatteryPercent = "lowBatteryPercent"
|
||||
}
|
||||
}
|
||||
@@ -73,14 +73,25 @@ final class SystemMonitor: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Start periodic refresh (3 second interval)
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
||||
// Start periodic refresh at the user-configured cadence
|
||||
scheduleTimer(interval: AppSettings.shared.refreshInterval)
|
||||
}
|
||||
|
||||
private func scheduleTimer(interval: Double) {
|
||||
timer?.invalidate()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
self?.backgroundQueue.async {
|
||||
self?.refreshAllBackground()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the refresh cadence at runtime (from Settings). No-op if not monitoring.
|
||||
@MainActor func updateRefreshInterval(_ interval: Double) {
|
||||
guard timer != nil else { return }
|
||||
scheduleTimer(interval: AppSettings.clampInterval(interval))
|
||||
}
|
||||
|
||||
@MainActor func stopMonitoring() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
|
||||
@@ -11,6 +11,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
@main
|
||||
struct TopManagerApp: App {
|
||||
@StateObject private var monitor = SystemMonitor.shared
|
||||
@StateObject private var settings = AppSettings.shared
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@@ -19,8 +20,10 @@ struct TopManagerApp: App {
|
||||
ContentView()
|
||||
.environmentObject(monitor)
|
||||
.environmentObject(AlertCenter.shared)
|
||||
.environmentObject(settings)
|
||||
.onAppear {
|
||||
monitor.startMonitoring()
|
||||
settings.applyAll()
|
||||
AlertCenter.shared.requestNotificationAuthorization()
|
||||
}
|
||||
}
|
||||
@@ -35,18 +38,19 @@ struct TopManagerApp: App {
|
||||
CommandGroup(replacing: .newItem) { }
|
||||
}
|
||||
|
||||
// Menu Bar Extra showing CPU %
|
||||
Settings {
|
||||
SettingsView()
|
||||
.environmentObject(settings)
|
||||
}
|
||||
|
||||
// Menu Bar Extra with configurable metric
|
||||
MenuBarExtra {
|
||||
MenuBarView()
|
||||
.environmentObject(monitor)
|
||||
.environmentObject(AlertCenter.shared)
|
||||
.environmentObject(settings)
|
||||
} label: {
|
||||
if let cpu = monitor.cpuInfo {
|
||||
Text(String(format: "%.0f%%", cpu.globalUsage))
|
||||
.monospacedDigit()
|
||||
} else {
|
||||
Image(systemName: "cpu")
|
||||
}
|
||||
MenuBarLabel(monitor: monitor, settings: settings, alerts: AlertCenter.shared)
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The compact menu-bar title. Renders whichever metric the user picked.
|
||||
struct MenuBarLabel: View {
|
||||
@ObservedObject var monitor: SystemMonitor
|
||||
@ObservedObject var settings: AppSettings
|
||||
@ObservedObject var alerts: AlertCenter
|
||||
|
||||
var body: some View {
|
||||
switch settings.menuBarMetric {
|
||||
case .cpu:
|
||||
textOrIcon(monitor.cpuInfo.map { String(format: "%.0f%%", $0.globalUsage) }, icon: "cpu")
|
||||
case .memory:
|
||||
textOrIcon(monitor.memoryInfo.map { String(format: "%.0f%%", $0.usagePercentage) }, icon: "memorychip")
|
||||
case .health:
|
||||
Text("♥ \(alerts.healthScore)").monospacedDigit()
|
||||
case .download:
|
||||
textOrIcon(monitor.networkInfo.map { formatBytesPerSecond($0.totalDownloadRate) }, icon: "arrow.down")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func textOrIcon(_ text: String?, icon: String) -> some View {
|
||||
if let text { Text(text).monospacedDigit() } else { Image(systemName: icon) }
|
||||
}
|
||||
}
|
||||
|
||||
struct MenuBarView: View {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
@EnvironmentObject var alertCenter: AlertCenter
|
||||
|
||||
private var topCPU: [ProcessItem] {
|
||||
monitor.processes.sorted { $0.cpuUsage > $1.cpuUsage }.prefix(3).map { $0 }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
@@ -90,6 +118,31 @@ struct MenuBarView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if !topCPU.isEmpty {
|
||||
Divider()
|
||||
Text("Top CPU Consumers")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
ForEach(topCPU) { process in
|
||||
HStack(spacing: 6) {
|
||||
Text(process.name)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Text(String(format: "%.0f%%", process.cpuUsage))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(.secondary)
|
||||
Button {
|
||||
_ = monitor.terminateProcess(process.pid, expectedStartTime: process.startTime)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Quit \(process.name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Open TopManager") {
|
||||
|
||||
87
TopManager/Views/SettingsView.swift
Normal file
87
TopManager/Views/SettingsView.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
generalTab
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
alertsTab
|
||||
.tabItem { Label("Alerts", systemImage: "bell") }
|
||||
}
|
||||
.frame(width: 440, height: 340)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var generalTab: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Launch at login", isOn: $settings.launchAtLogin)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text("Refresh interval")
|
||||
Spacer()
|
||||
Text(String(format: "%.0f s", settings.refreshInterval))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Slider(value: $settings.refreshInterval, in: 1...30, step: 1)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Menu Bar") {
|
||||
Picker("Show in menu bar", selection: $settings.menuBarMetric) {
|
||||
ForEach(MenuBarMetric.allCases) { metric in
|
||||
Text(metric.rawValue).tag(metric)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private var alertsTab: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Enable notifications", isOn: $settings.notificationsEnabled)
|
||||
} footer: {
|
||||
Text("TopManager posts a macOS notification when a threshold is first crossed.")
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Section("Thresholds") {
|
||||
sliderRow(title: "High CPU", value: $settings.cpuAlertThreshold,
|
||||
range: 50...100, unit: "%")
|
||||
sliderRow(title: "Disk almost full", value: $settings.diskAlertPercent,
|
||||
range: 80...99, unit: "%")
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text("Low battery")
|
||||
Spacer()
|
||||
Text("\(settings.lowBatteryPercent)%").monospacedDigit().foregroundColor(.secondary)
|
||||
}
|
||||
Slider(value: Binding(
|
||||
get: { Double(settings.lowBatteryPercent) },
|
||||
set: { settings.lowBatteryPercent = Int($0) }
|
||||
), in: 5...50, step: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private func sliderRow(title: String, value: Binding<Double>, range: ClosedRange<Double>, unit: String) -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
Text(String(format: "%.0f%@", value.wrappedValue, unit))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Slider(value: value, in: range, step: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
27
TopManagerTests/SettingsTests.swift
Normal file
27
TopManagerTests/SettingsTests.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import XCTest
|
||||
@testable import TopManager
|
||||
|
||||
final class SettingsTests: XCTestCase {
|
||||
|
||||
func testClampIntervalBounds() {
|
||||
XCTAssertEqual(AppSettings.clampInterval(0.2), 1)
|
||||
XCTAssertEqual(AppSettings.clampInterval(100), 30)
|
||||
XCTAssertEqual(AppSettings.clampInterval(5), 5)
|
||||
XCTAssertEqual(AppSettings.clampInterval(1), 1)
|
||||
XCTAssertEqual(AppSettings.clampInterval(30), 30)
|
||||
}
|
||||
|
||||
func testMenuBarMetricRoundTrip() {
|
||||
for metric in MenuBarMetric.allCases {
|
||||
XCTAssertEqual(MenuBarMetric(rawValue: metric.rawValue), metric)
|
||||
}
|
||||
XCTAssertNil(MenuBarMetric(rawValue: "nonexistent"))
|
||||
}
|
||||
|
||||
func testMenuBarMetricCoversKeyMetrics() {
|
||||
XCTAssertTrue(MenuBarMetric.allCases.contains(.cpu))
|
||||
XCTAssertTrue(MenuBarMetric.allCases.contains(.memory))
|
||||
XCTAssertTrue(MenuBarMetric.allCases.contains(.health))
|
||||
XCTAssertTrue(MenuBarMetric.allCases.contains(.download))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user