diff --git a/TopManager.xcodeproj/project.pbxproj b/TopManager.xcodeproj/project.pbxproj index 69f7cb4..36534fc 100644 --- a/TopManager.xcodeproj/project.pbxproj +++ b/TopManager.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 00EC5A90B7B8C6C5015D90D1 /* PerformanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F80A817DCDA254349DEA6F06 /* PerformanceView.swift */; }; 0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B6F97E6EF65AA17A469F037 /* ProcessView.swift */; }; 136DAD1FE90FD682DF666667 /* TopManager_icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 8E4A6E8E2305CEDC119AC6BC /* TopManager_icon.png */; }; + 14D77C268F9F701E612E4FD3 /* WindowVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */; }; 1F212CE158B7D14B29077B18 /* CPUInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44281079654C7E1C9E16806C /* CPUInfo.swift */; }; 1F25A84B560709AE71C27D23 /* GPUInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F481045B4FC6017F50849FF1 /* GPUInfo.swift */; }; 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */; }; @@ -35,6 +36,7 @@ 9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */; }; A0649565A902BA48DB10F592 /* CPUMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19ED582C412C63A0A2E2103E /* CPUMonitor.swift */; }; A7EC9FB55D5088D669491C11 /* AppsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813543165B39EBD213EB3BC7 /* AppsView.swift */; }; + AE29356C1D41F33A3E94B6B7 /* MenuBarStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B37A9AE9F6215C0C132B8D9 /* MenuBarStatus.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 */; }; @@ -43,6 +45,7 @@ CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFADE83965AA863B775A910 /* NetworkChartView.swift */; }; D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A404EBD588B7ACD36079A95D /* TopManagerApp.swift */; }; D9E882888E23C26B748E22CF /* PowerMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */; }; + DA03FF0D25DB8B9D3B09BC82 /* MenuBarStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */; }; DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279B972C08A878053821285D /* SystemMonitor.swift */; }; DE50ECC69FCE60B10364802C /* ByteFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */; }; E379C349DC9617D6C505B74B /* GPUMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */; }; @@ -87,6 +90,7 @@ 44281079654C7E1C9E16806C /* CPUInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUInfo.swift; sourceTree = ""; }; 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessInfo.swift; sourceTree = ""; }; 51C512B13DC0C1024CF39A5E /* PowerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerInfo.swift; sourceTree = ""; }; + 5B37A9AE9F6215C0C132B8D9 /* MenuBarStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarStatus.swift; sourceTree = ""; }; 5E1D2EA8E1AD11B42FF225EB /* TopManager.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TopManager.entitlements; sourceTree = ""; }; 70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryMathTests.swift; sourceTree = ""; }; 7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLineChart.swift; sourceTree = ""; }; @@ -94,6 +98,8 @@ 7B6F97E6EF65AA17A469F037 /* ProcessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessView.swift; sourceTree = ""; }; 813543165B39EBD213EB3BC7 /* AppsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsView.swift; sourceTree = ""; }; 892247B0F17363DFC146EDF8 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; + 8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowVisibility.swift; sourceTree = ""; }; + 8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarStatusTests.swift; sourceTree = ""; }; 8E4A6E8E2305CEDC119AC6BC /* TopManager_icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = TopManager_icon.png; sourceTree = ""; }; 8F402CE13EF67DE550786B79 /* MemoryDonutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryDonutView.swift; sourceTree = ""; }; 94D5A13CC5B9CF1B90CC2214 /* CPUChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUChartView.swift; sourceTree = ""; }; @@ -126,11 +132,13 @@ 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */, BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */, 27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */, + 5B37A9AE9F6215C0C132B8D9 /* MenuBarStatus.swift */, 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */, 347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */, 95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */, 295232B4C746511757DCF8FE /* ProcessMonitor.swift */, 279B972C08A878053821285D /* SystemMonitor.swift */, + 8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */, ); path = Services; sourceTree = ""; @@ -251,6 +259,7 @@ 70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */, 301352EB5B362C732CE1A246 /* FormattingTests.swift */, E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */, + 8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */, 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */, 332290DABD5326C87258723C /* ProcessDataTests.swift */, 01C19ABF49697A97F0C40378 /* SettingsTests.swift */, @@ -374,6 +383,7 @@ 2DE14B65EA1D4E9B4D9DA5A6 /* BatteryMathTests.swift in Sources */, 49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */, 489E29F78A4093D3548673E6 /* HealthAlertTests.swift in Sources */, + DA03FF0D25DB8B9D3B09BC82 /* MenuBarStatusTests.swift in Sources */, 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */, 2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */, 8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */, @@ -404,6 +414,7 @@ 7A1144E43159D96564C13164 /* MemoryInfo.swift in Sources */, 266B888ADE2FDDBCEC8D28A5 /* MemoryLineChart.swift in Sources */, F5722D2B18EF7ABF43847EE4 /* MemoryMonitor.swift in Sources */, + AE29356C1D41F33A3E94B6B7 /* MenuBarStatus.swift in Sources */, 35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */, BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */, CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */, @@ -421,6 +432,7 @@ DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */, D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */, 9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */, + 14D77C268F9F701E612E4FD3 /* WindowVisibility.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/TopManager/Services/AlertCenter.swift b/TopManager/Services/AlertCenter.swift index f672547..a449452 100644 --- a/TopManager/Services/AlertCenter.swift +++ b/TopManager/Services/AlertCenter.swift @@ -48,8 +48,12 @@ final class AlertCenter: ObservableObject { thermal: thermal, diskUsedFraction: diskFraction ) - healthScore = HealthModel.score(input) - diagnosis = HealthModel.diagnosis(input) + // Assign only on change: @Published fires on every set, and re-assigning + // equal values each tick re-rendered every health view on every refresh. + let score = HealthModel.score(input) + if score != healthScore { healthScore = score } + let findings = HealthModel.diagnosis(input) + if findings != diagnosis { diagnosis = findings } // CPU (sustained) let cpuEval = AlertEvaluator.sustained(breached: cpuUsage >= thresholds.cpuPercent, diff --git a/TopManager/Services/MenuBarStatus.swift b/TopManager/Services/MenuBarStatus.swift new file mode 100644 index 0000000..25f88b3 --- /dev/null +++ b/TopManager/Services/MenuBarStatus.swift @@ -0,0 +1,30 @@ +import Foundation + +/// The few always-live strings the menu-bar title shows. +/// +/// Kept apart from `SystemMonitor` because the monitor stops publishing while no +/// TopManager window is on screen (see `SystemMonitor.setUIVisible`), and the +/// title must keep ticking regardless. Values are pre-formatted and only +/// re-published when the visible text actually changes: `@Published` fires on +/// every assignment, and each firing re-measures the status item. +@MainActor +final class MenuBarStatus: ObservableObject { + static let shared = MenuBarStatus() + + @Published private(set) var cpuText: String? + @Published private(set) var memoryText: String? + @Published private(set) var downloadText: String? + + init() {} + + func update(cpu: CPUInfo?, memory: MemoryInfo?, network: NetworkInfo?) { + let cpu = cpu.map { String(format: "%.0f%%", $0.globalUsage) } + if cpu != cpuText { cpuText = cpu } + + let memory = memory.map { String(format: "%.0f%%", $0.usagePercentage) } + if memory != memoryText { memoryText = memory } + + let download = network.map { formatBytesPerSecond($0.totalDownloadRate) } + if download != downloadText { downloadText = download } + } +} diff --git a/TopManager/Services/ProcessMonitor.swift b/TopManager/Services/ProcessMonitor.swift index 44740eb..0d09598 100644 --- a/TopManager/Services/ProcessMonitor.swift +++ b/TopManager/Services/ProcessMonitor.swift @@ -6,6 +6,7 @@ final class ProcessMonitor { private var previousCPUTimes: [pid_t: (user: UInt64, system: UInt64, timestamp: Date)] = [:] private var previousDiskIO: [pid_t: (read: UInt64, write: UInt64, idle: UInt64, timestamp: Date)] = [:] private var lastKnownCPU: [pid_t: Double] = [:] // Cache last known CPU usage + private var lastDetailed: [pid_t: DetailedSample] = [:] // Reused on lightweight refreshes private let iconCache = NSCache() private var noIconPids: Set = [] // Cache for PIDs with no icon private var nameCache: [pid_t: String] = [:] @@ -56,6 +57,7 @@ final class ProcessMonitor { previousDiskIO.removeValue(forKey: pid) noIconPids.remove(pid) lastKnownCPU.removeValue(forKey: pid) + lastDetailed.removeValue(forKey: pid) } return processes @@ -81,14 +83,7 @@ final class ProcessMonitor { let lastCPU = lastKnownCPU[pid] ?? 0 let needsDetailedInfo = fullRefresh || lastCPU > 0.1 - let memoryUsage: Int64 - let threadCount: Int32 - let cpuUsage: Double - var diskReadRate: Double = 0 - var diskWriteRate: Double = 0 - var diskReadBytes: UInt64 = 0 - var diskWriteBytes: UInt64 = 0 - var energyImpact: Double = 0 + let detail: DetailedSample if needsDetailedInfo { var taskInfo = proc_taskinfo() @@ -98,9 +93,7 @@ final class ProcessMonitor { if hasTaskInfo { let rusageData = fetchRusageData(pid: pid) - memoryUsage = rusageData.memory - threadCount = taskInfo.pti_threadnum - cpuUsage = calculateCPUUsage( + let cpu = calculateCPUUsage( pid: pid, userTime: rusageData.userTime, systemTime: rusageData.systemTime @@ -111,23 +104,31 @@ final class ProcessMonitor { diskWrite: rusageData.diskWrite, idleWakeups: rusageData.idleWakeups ) - diskReadRate = extra.readRate - diskWriteRate = extra.writeRate - diskReadBytes = rusageData.diskRead - diskWriteBytes = rusageData.diskWrite - energyImpact = EnergyModel.impact(cpuPercent: cpuUsage, idleWakeupsPerSec: extra.idleWakeupsPerSec) + detail = DetailedSample( + memory: rusageData.memory, + threads: taskInfo.pti_threadnum, + cpu: cpu, + diskReadRate: extra.readRate, + diskWriteRate: extra.writeRate, + diskReadBytes: rusageData.diskRead, + diskWriteBytes: rusageData.diskWrite, + energy: EnergyModel.impact(cpuPercent: cpu, idleWakeupsPerSec: extra.idleWakeupsPerSec) + ) + lastDetailed[pid] = detail } else { - memoryUsage = 0 - threadCount = 0 - cpuUsage = 0 + detail = .zero } } else { - // Lightweight refresh - reuse last known values - memoryUsage = 0 - threadCount = 0 - cpuUsage = 0 + // Lightweight refresh - reuse last known values. This used to emit + // zeros, so ~90% of rows showed "Zero KB" memory (and 0 threads/CPU) + // on two of every three updates. + detail = lastDetailed[pid] ?? .zero } + let memoryUsage = detail.memory + let threadCount = detail.threads + let cpuUsage = detail.cpu + let state = determineProcessState(status: bsdInfo.pbi_status, cpuUsage: cpuUsage) let icon = fetchIcon(pid: pid) @@ -147,11 +148,11 @@ final class ProcessMonitor { icon: icon, parentPid: parentPid, startTime: startTime, - diskReadRate: diskReadRate, - diskWriteRate: diskWriteRate, - diskReadBytes: diskReadBytes, - diskWriteBytes: diskWriteBytes, - energyImpact: energyImpact, + diskReadRate: detail.diskReadRate, + diskWriteRate: detail.diskWriteRate, + diskReadBytes: detail.diskReadBytes, + diskWriteBytes: detail.diskWriteBytes, + energyImpact: detail.energy, executablePath: pathCache[pid] ) } @@ -230,6 +231,21 @@ final class ProcessMonitor { return name } + /// The expensive per-process readings (rusage + task info + derived rates). + private struct DetailedSample { + var memory: Int64 + var threads: Int32 + var cpu: Double + var diskReadRate: Double + var diskWriteRate: Double + var diskReadBytes: UInt64 + var diskWriteBytes: UInt64 + var energy: Double + + static let zero = DetailedSample(memory: 0, threads: 0, cpu: 0, diskReadRate: 0, diskWriteRate: 0, + diskReadBytes: 0, diskWriteBytes: 0, energy: 0) + } + private struct RusageData { var memory: Int64 = 0 var userTime: UInt64 = 0 diff --git a/TopManager/Services/SystemMonitor.swift b/TopManager/Services/SystemMonitor.swift index 8fee94e..5af5f60 100644 --- a/TopManager/Services/SystemMonitor.swift +++ b/TopManager/Services/SystemMonitor.swift @@ -6,7 +6,8 @@ final class SystemMonitor: ObservableObject { private let backgroundQueue = DispatchQueue(label: "com.topmanager.monitor", qos: .userInitiated) - // Published properties (must be updated on main thread) + // Published properties (must be updated on main thread). They only follow + // `live` while a TopManager window is on screen — see `setUIVisible`. @MainActor @Published var cpuInfo: CPUInfo? @MainActor @Published var memoryInfo: MemoryInfo? @MainActor @Published var processes: [ProcessItem] = [] @@ -22,6 +23,24 @@ final class SystemMonitor: ObservableObject { @MainActor @Published var memoryHistory: [MemoryHistoryPoint] = [] @MainActor @Published var networkHistory: [NetworkHistoryPoint] = [] + /// Always-current readings and chart histories. Recording, alerts and the + /// menu-bar title read from here, so they keep running while views are paused. + private struct LiveState { + var cpuInfo: CPUInfo? + var memoryInfo: MemoryInfo? + var processes: [ProcessItem] = [] + var diskInfo: DiskInfo? + var networkInfo: NetworkInfo? + var gpuInfo: GPUInfo? + var powerInfo: PowerInfo? + var cpuHistory: [CPUHistoryPoint] = [] + var coreHistories: [Int: [CoreHistoryPoint]] = [:] + var memoryHistory: [MemoryHistoryPoint] = [] + var networkHistory: [NetworkHistoryPoint] = [] + } + @MainActor private var live = LiveState() + @MainActor private var isUIVisible = true + // Sub-monitors private let cpuMonitor = CPUMonitor() private let memoryMonitor = MemoryMonitor() @@ -47,6 +66,8 @@ final class SystemMonitor: ObservableObject { private init() {} @MainActor func startMonitoring() { + guard timer == nil else { return } + // Immediate initial fetch backgroundQueue.async { [weak self] in guard let self = self else { return } @@ -134,95 +155,103 @@ final class SystemMonitor: ObservableObject { processes: [ProcessItem]?, disk: DiskInfo?, gpu: GPUInfo?, power: PowerInfo?) { if let info = cpu { - cpuInfo = info - let historyPoint = CPUHistoryPoint( + live.cpuInfo = info + live.cpuHistory.append(CPUHistoryPoint( timestamp: info.timestamp, usage: info.globalUsage, userUsage: info.userUsage, systemUsage: info.systemUsage - ) - cpuHistory.append(historyPoint) - if cpuHistory.count > historyLimit { - cpuHistory.removeFirst() + )) + if live.cpuHistory.count > historyLimit { + live.cpuHistory.removeFirst() } for core in info.coreUsages { - let corePoint = CoreHistoryPoint(timestamp: info.timestamp, usage: core.usage) - if coreHistories[core.id] == nil { - coreHistories[core.id] = [] - } - coreHistories[core.id]?.append(corePoint) - if coreHistories[core.id]?.count ?? 0 > historyLimit { - coreHistories[core.id]?.removeFirst() + live.coreHistories[core.id, default: []].append( + CoreHistoryPoint(timestamp: info.timestamp, usage: core.usage)) + if live.coreHistories[core.id, default: []].count > historyLimit { + live.coreHistories[core.id]?.removeFirst() } } } if let info = memory { - memoryInfo = info - let historyPoint = MemoryHistoryPoint( + live.memoryInfo = info + live.memoryHistory.append(MemoryHistoryPoint( timestamp: info.timestamp, usedMemory: info.usedMemory, totalMemory: info.totalMemory - ) - memoryHistory.append(historyPoint) - if memoryHistory.count > historyLimit { - memoryHistory.removeFirst() + )) + if live.memoryHistory.count > historyLimit { + live.memoryHistory.removeFirst() } } if let info = network { - networkInfo = info - let historyPoint = NetworkHistoryPoint( + live.networkInfo = info + live.networkHistory.append(NetworkHistoryPoint( timestamp: info.timestamp, downloadRate: info.totalDownloadRate, uploadRate: info.totalUploadRate - ) - networkHistory.append(historyPoint) - if networkHistory.count > historyLimit { - networkHistory.removeFirst() + )) + if live.networkHistory.count > historyLimit { + live.networkHistory.removeFirst() } } - if let procs = processes { - self.processes = procs - } + if let procs = processes { live.processes = procs } + if let info = disk { live.diskInfo = info } + if let info = gpu { live.gpuInfo = info } + if let info = power { live.powerInfo = info } - if let info = disk { - diskInfo = info - } - - if let info = gpu { - gpuInfo = info - } - - if let info = power { - powerInfo = info - } + if isUIVisible { publishLive() } + MenuBarStatus.shared.update(cpu: live.cpuInfo, memory: live.memoryInfo, network: live.networkInfo) // Persist a system-wide sample so history survives restarts and long ranges. - if let c = cpuInfo, let m = memoryInfo { + if let c = live.cpuInfo, let m = live.memoryInfo { MetricsStore.shared.record(MetricsSample( t: c.timestamp, cpu: c.globalUsage, memUsed: m.usedMemory, memTotal: m.totalMemory, - netDown: networkInfo?.totalDownloadRate ?? 0, - netUp: networkInfo?.totalUploadRate ?? 0 + netDown: live.networkInfo?.totalDownloadRate ?? 0, + netUp: live.networkInfo?.totalUploadRate ?? 0 )) } // Proactive health + alert evaluation (runs every cycle). AlertCenter.shared.evaluate( - cpuUsage: cpuInfo?.globalUsage ?? 0, - memory: memoryInfo, - disk: diskInfo, + cpuUsage: live.cpuInfo?.globalUsage ?? 0, + memory: live.memoryInfo, + disk: live.diskInfo, thermal: currentThermalLevel(), - topProcess: self.processes.max { $0.cpuUsage < $1.cpuUsage }, - power: powerInfo + topProcess: live.processes.max { $0.cpuUsage < $1.cpuUsage }, + power: live.powerInfo ) } + /// Pause (`false`) or resume (`true`) publishing to views. Resuming catches + /// the views up at once with everything recorded while they were hidden. + @MainActor func setUIVisible(_ visible: Bool) { + guard visible != isUIVisible else { return } + isUIVisible = visible + if visible { publishLive() } + } + + @MainActor private func publishLive() { + cpuInfo = live.cpuInfo + memoryInfo = live.memoryInfo + processes = live.processes + diskInfo = live.diskInfo + networkInfo = live.networkInfo + gpuInfo = live.gpuInfo + powerInfo = live.powerInfo + cpuHistory = live.cpuHistory + coreHistories = live.coreHistories + memoryHistory = live.memoryHistory + networkHistory = live.networkHistory + } + private func currentThermalLevel() -> ThermalLevel { switch ProcessInfo.processInfo.thermalState { case .nominal: return .nominal diff --git a/TopManager/Services/WindowVisibility.swift b/TopManager/Services/WindowVisibility.swift new file mode 100644 index 0000000..8fc1f7a --- /dev/null +++ b/TopManager/Services/WindowVisibility.swift @@ -0,0 +1,52 @@ +import AppKit + +/// Reports whether any TopManager window is actually on screen: the main window, +/// the menu-bar popover or Settings — as opposed to closed, minimized, fully +/// covered, or on another Space. +/// +/// Why this exists: on macOS 26 several framework paths leak a little on every +/// view update (SwiftUI `Table` cell updates, AppKit text-cell KVO dependencies, +/// an Observation registrar created inside DesignLibrary). SwiftUI keeps updating +/// a minimized or covered window, so leaving the main window open all day grew +/// the app by several hundred MB. `SystemMonitor` stops publishing while this +/// reports `false`, which confines that growth to the time someone is looking. +@MainActor +final class WindowVisibility { + static let shared = WindowVisibility() + + private(set) var isAnyWindowVisible = true + private var onChange: ((Bool) -> Void)? + private var tokens: [NSObjectProtocol] = [] + + private init() {} + + func start(onChange: @escaping (Bool) -> Void) { + guard tokens.isEmpty else { return } + self.onChange = onChange + + let names = [NSWindow.didChangeOcclusionStateNotification, NSWindow.willCloseNotification] + for name in names { + tokens.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + // willClose arrives while the window is still on screen, so + // re-check on a later turn, once it is actually gone. + Task { @MainActor in self?.recompute() } + }) + } + recompute() + } + + private func recompute() { + let visible = NSApp.windows.contains(where: Self.countsAsVisible) + guard visible != isAnyWindowVisible else { return } + isAnyWindowVisible = visible + onChange?(visible) + } + + /// The status item's host window is permanently on screen; counting it would + /// keep the UI publishing forever. + private static func countsAsVisible(_ window: NSWindow) -> Bool { + window.isVisible + && window.occlusionState.contains(.visible) + && !String(describing: type(of: window)).contains("StatusBarWindow") + } +} diff --git a/TopManager/TopManagerApp.swift b/TopManager/TopManagerApp.swift index 214b72c..ab35d67 100644 --- a/TopManager/TopManagerApp.swift +++ b/TopManager/TopManagerApp.swift @@ -7,6 +7,16 @@ import UserNotifications final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { func applicationDidFinishLaunching(_ notification: Notification) { UNUserNotificationCenter.current().delegate = self + + // Start here rather than in the main window's onAppear: the app can launch + // with no window at all (state restoration after the window was closed), + // and the menu bar, alerts and history must run regardless. + Task { @MainActor in + SystemMonitor.shared.startMonitoring() + AppSettings.shared.applyAll() + AlertCenter.shared.requestNotificationAuthorization() + WindowVisibility.shared.start { SystemMonitor.shared.setUIVisible($0) } + } } func userNotificationCenter(_ center: UNUserNotificationCenter, @@ -33,11 +43,6 @@ struct TopManagerApp: App { .environmentObject(monitor) .environmentObject(AlertCenter.shared) .environmentObject(settings) - .onAppear { - monitor.startMonitoring() - settings.applyAll() - AlertCenter.shared.requestNotificationAuthorization() - } } .windowStyle(.automatic) .defaultSize(width: 1000, height: 700) @@ -62,7 +67,7 @@ struct TopManagerApp: App { .environmentObject(AlertCenter.shared) .environmentObject(settings) } label: { - MenuBarLabel(monitor: monitor, settings: settings, alerts: AlertCenter.shared) + MenuBarLabel(status: MenuBarStatus.shared, settings: settings, alerts: AlertCenter.shared) } .menuBarExtraStyle(.window) } diff --git a/TopManager/Views/MenuBarView.swift b/TopManager/Views/MenuBarView.swift index 1c5be4c..de3be42 100644 --- a/TopManager/Views/MenuBarView.swift +++ b/TopManager/Views/MenuBarView.swift @@ -1,21 +1,23 @@ import SwiftUI /// The compact menu-bar title. Renders whichever metric the user picked. +/// Reads `MenuBarStatus`, not `SystemMonitor`, so it keeps updating while the +/// monitor's view publishing is paused. struct MenuBarLabel: View { - @ObservedObject var monitor: SystemMonitor + @ObservedObject var status: MenuBarStatus @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") + textOrIcon(status.cpuText, icon: "cpu") case .memory: - textOrIcon(monitor.memoryInfo.map { String(format: "%.0f%%", $0.usagePercentage) }, icon: "memorychip") + textOrIcon(status.memoryText, icon: "memorychip") case .health: Text("♥ \(alerts.healthScore)").monospacedDigit() case .download: - textOrIcon(monitor.networkInfo.map { formatBytesPerSecond($0.totalDownloadRate) }, icon: "arrow.down") + textOrIcon(status.downloadText, icon: "arrow.down") } } diff --git a/TopManagerTests/MenuBarStatusTests.swift b/TopManagerTests/MenuBarStatusTests.swift new file mode 100644 index 0000000..a9b6d8a --- /dev/null +++ b/TopManagerTests/MenuBarStatusTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import TopManager + +final class MenuBarStatusTests: XCTestCase { + + private func cpu(_ usage: Double) -> CPUInfo { + CPUInfo(globalUsage: usage, userUsage: usage, systemUsage: 0, idleUsage: 100 - usage, coreUsages: []) + } + + @MainActor + func testFormatsVisibleText() { + let status = MenuBarStatus() + status.update(cpu: cpu(41.6), memory: nil, network: nil) + XCTAssertEqual(status.cpuText, "42%") + XCTAssertNil(status.memoryText) + XCTAssertNil(status.downloadText) + } + + /// Every publish re-measures the status item, so a refresh that renders the + /// same text must not publish at all. + @MainActor + func testRepublishesOnlyWhenVisibleTextChanges() { + let status = MenuBarStatus() + var publishes = 0 + let subscription = status.objectWillChange.sink { publishes += 1 } + defer { subscription.cancel() } + + status.update(cpu: cpu(41.6), memory: nil, network: nil) + XCTAssertEqual(publishes, 1) + status.update(cpu: cpu(42.4), memory: nil, network: nil) // still "42%" + XCTAssertEqual(publishes, 1) + status.update(cpu: cpu(57), memory: nil, network: nil) + XCTAssertEqual(publishes, 2) + } +} diff --git a/TopManagerTests/ProcessDataTests.swift b/TopManagerTests/ProcessDataTests.swift index ee2d482..498a405 100644 --- a/TopManagerTests/ProcessDataTests.swift +++ b/TopManagerTests/ProcessDataTests.swift @@ -92,4 +92,21 @@ final class ProcessDataTests: XCTestCase { XCTAssertNotEqual(make(energy: 5), make(energy: 9)) XCTAssertEqual(make(energy: 5), make(energy: 5)) } + + // MARK: - Live sampling + + /// Fetches 1–3 are full refreshes; 4 and 5 are lightweight ones, which used + /// to report 0 memory for every idle process (~90% of rows). Only processes + /// spawned between fetches may legitimately have no reading yet. + func testLightweightRefreshKeepsLastKnownMemory() { + let monitor = ProcessMonitor() + let me = NSUserName() + for fetch in 1...6 { + let own = monitor.fetchProcesses().filter { $0.user == me } + XCTAssertFalse(own.isEmpty) + let zero = own.filter { $0.memoryUsage == 0 }.count + XCTAssertLessThan(zero, max(3, own.count / 10), + "fetch \(fetch): \(zero) of \(own.count) own processes reported 0 memory") + } + } }