Pause view updates while no window is visible; fix zero-value flicker
The window left open all day grew the app by ~360 MB in 24 h even after the .tabItem fix. Isolating each piece in lab builds showed the growth is the sum of several small framework leaks on macOS 26.6 that fire on every view update: SwiftUI Table cell updates, AppKit text-cell KVO dependencies (NSKeyValueDependency) and an ObservationRegistrar created inside DesignLibrary. With the window closed every counter stayed flat; minimized or fully covered, SwiftUI kept updating and kept leaking. - WindowVisibility tracks whether any TopManager window is on screen (occlusion state; the status item's host window is ignored). - SystemMonitor records into an always-current LiveState and only mirrors it into the @Published properties while something is visible. Resuming publishes everything at once, so views catch up immediately. Metrics history, alerts and the menu bar keep running while paused. - MenuBarStatus carries the pre-formatted menu-bar strings, republished only when the visible text changes. - AlertCenter assigns healthScore/diagnosis only when they change. - Monitoring now starts in applicationDidFinishLaunching instead of the main window's onAppear, so it also runs when the app launches with no window (state restoration after the window was closed). Also fixes a visible bug: ProcessMonitor's lightweight refresh (2 of every 3 fetches) emitted 0 for memory, CPU and threads instead of the last known values, so ~90% of rows showed "Zero KB" most of the time. Verified in an isolated lab instance: minimized or covered, all leak counters stay flat while the menu-bar title keeps updating; a partially visible window updates every tick. Growth while the window is actually visible (~190 KB/min) is unchanged — those leaks are in Apple's frameworks. 58/58 tests pass, including a regression test that fails on the old code (472 of 487 own processes reported 0 memory). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessInfo.swift; sourceTree = "<group>"; };
|
||||
51C512B13DC0C1024CF39A5E /* PowerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerInfo.swift; sourceTree = "<group>"; };
|
||||
5B37A9AE9F6215C0C132B8D9 /* MenuBarStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarStatus.swift; sourceTree = "<group>"; };
|
||||
5E1D2EA8E1AD11B42FF225EB /* TopManager.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TopManager.entitlements; sourceTree = "<group>"; };
|
||||
70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryMathTests.swift; sourceTree = "<group>"; };
|
||||
7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLineChart.swift; sourceTree = "<group>"; };
|
||||
@@ -94,6 +98,8 @@
|
||||
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>"; };
|
||||
8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowVisibility.swift; sourceTree = "<group>"; };
|
||||
8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarStatusTests.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>"; };
|
||||
@@ -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 = "<group>";
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
30
TopManager/Services/MenuBarStatus.swift
Normal file
30
TopManager/Services/MenuBarStatus.swift
Normal file
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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<NSNumber, NSImage>()
|
||||
private var noIconPids: Set<pid_t> = [] // 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
|
||||
|
||||
@@ -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
|
||||
|
||||
52
TopManager/Services/WindowVisibility.swift
Normal file
52
TopManager/Services/WindowVisibility.swift
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
35
TopManagerTests/MenuBarStatusTests.swift
Normal file
35
TopManagerTests/MenuBarStatusTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user