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:
hariel1985
2026-07-02 20:25:43 +02:00
szülő bc494af363
commit 2e3574f6e3
7 fájl változott, egészen pontosan 311 új sor hozzáadva és 9 régi sor törölve

Fájl megtekintése

@@ -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 */,

Fájl megtekintése

@@ -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"
}
}

Fájl megtekintése

@@ -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

Fájl megtekintése

@@ -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)
}

Fájl megtekintése

@@ -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") {

Fájl megtekintése

@@ -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)
}
}
}

Fájl megtekintése

@@ -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))
}
}