Commitok összehasonlítása
8 Commit-ok
| Szerző | SHA1 | Dátum | |
|---|---|---|---|
|
|
9810987155 | ||
|
|
c50443bdbc | ||
|
|
15c8b18b48 | ||
|
|
2685ed9667 | ||
|
|
1767491076 | ||
|
|
cf88044582 | ||
|
|
e8a982c00e | ||
|
|
82b55c9468 |
@@ -5,11 +5,11 @@ A native macOS system monitor application built with SwiftUI. TopManager provide
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Download
|
||||
|
||||
**[⬇️ Download TopManager 1.0 (.dmg)](https://github.com/hariel1985/TopManager/releases/latest)**
|
||||
**[⬇️ Download TopManager 1.2.0 (.dmg)](https://github.com/hariel1985/TopManager/releases/latest)**
|
||||
|
||||
Signed with a Developer ID and **notarized by Apple** — it opens without Gatekeeper
|
||||
warnings. Open the DMG and drag TopManager to your Applications folder.
|
||||
@@ -65,7 +65,7 @@ All [releases](https://github.com/hariel1985/TopManager/releases).
|
||||
- **Recent Alerts** inbox
|
||||
- **Battery & Power**: charge, health (cycle-adjusted), cycle count, condition,
|
||||
temperature, power draw, time remaining, adapter wattage (via IOKit)
|
||||
- System status: macOS version, uptime, thermal state
|
||||
- System status: macOS version, uptime, thermal state, CPU temperature (Apple Silicon)
|
||||
- CPU and GPU core counts
|
||||
- GPU memory/VRAM usage
|
||||
- Storage volumes with usage bars
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
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 */; };
|
||||
1929475349F9A25B0778DF9B /* ProcessTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19ADFC64E855FBDCDAF600E5 /* ProcessTableView.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 */; };
|
||||
@@ -23,12 +24,14 @@
|
||||
489E29F78A4093D3548673E6 /* HealthAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */; };
|
||||
49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301352EB5B362C732CE1A246 /* FormattingTests.swift */; };
|
||||
501C13D57386C179C0B3B14B /* PowerStorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6038CCDF3BDF0F8BC93C9D4 /* PowerStorageView.swift */; };
|
||||
5DB015294ABB26FF14B1D432 /* TemperatureMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */; };
|
||||
67F66E7EC7C01A6309DC060E /* AlertCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2C96471F109FF1C20ABF59E /* AlertCenter.swift */; };
|
||||
6A0810422D2EC8F9F5D191C4 /* HealthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126A382E2755C5061E6D29F5 /* HealthView.swift */; };
|
||||
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 */; };
|
||||
802A6F368A475126C1349A03 /* ThermalMathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9632A456D316A019CFA313 /* ThermalMathTests.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 */; };
|
||||
@@ -38,11 +41,13 @@
|
||||
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 */; };
|
||||
B02EE645434EF31AAE82825E /* ProcessTableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F822BA26055B319B7D13F8 /* ProcessTableTests.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 */; };
|
||||
CDA14A3B28EFF15D004D714F /* ThermalInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A81F77752CFF49D5038B06E /* ThermalInfo.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 */; };
|
||||
@@ -76,6 +81,8 @@
|
||||
110A314A63AE355A9E04990D /* ProcessDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessDetailView.swift; sourceTree = "<group>"; };
|
||||
126A382E2755C5061E6D29F5 /* HealthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthView.swift; sourceTree = "<group>"; };
|
||||
162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskMonitor.swift; sourceTree = "<group>"; };
|
||||
19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureMonitor.swift; sourceTree = "<group>"; };
|
||||
19ADFC64E855FBDCDAF600E5 /* ProcessTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessTableView.swift; sourceTree = "<group>"; };
|
||||
19ED582C412C63A0A2E2103E /* CPUMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUMonitor.swift; sourceTree = "<group>"; };
|
||||
279B972C08A878053821285D /* SystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitor.swift; sourceTree = "<group>"; };
|
||||
27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryMonitor.swift; sourceTree = "<group>"; };
|
||||
@@ -92,9 +99,11 @@
|
||||
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>"; };
|
||||
61F822BA26055B319B7D13F8 /* ProcessTableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessTableTests.swift; 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>"; };
|
||||
78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteFormatters.swift; sourceTree = "<group>"; };
|
||||
7A81F77752CFF49D5038B06E /* ThermalInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThermalInfo.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>"; };
|
||||
@@ -120,6 +129,7 @@
|
||||
E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthAlertTests.swift; sourceTree = "<group>"; };
|
||||
F481045B4FC6017F50849FF1 /* GPUInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPUInfo.swift; sourceTree = "<group>"; };
|
||||
F80A817DCDA254349DEA6F06 /* PerformanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceView.swift; sourceTree = "<group>"; };
|
||||
FD9632A456D316A019CFA313 /* ThermalMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThermalMathTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -138,6 +148,7 @@
|
||||
95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */,
|
||||
295232B4C746511757DCF8FE /* ProcessMonitor.swift */,
|
||||
279B972C08A878053821285D /* SystemMonitor.swift */,
|
||||
19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */,
|
||||
8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */,
|
||||
);
|
||||
path = Services;
|
||||
@@ -164,6 +175,7 @@
|
||||
3010F605712E09175D9D8878 /* NetworkInfo.swift */,
|
||||
51C512B13DC0C1024CF39A5E /* PowerInfo.swift */,
|
||||
4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */,
|
||||
7A81F77752CFF49D5038B06E /* ThermalInfo.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
@@ -262,7 +274,9 @@
|
||||
8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */,
|
||||
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */,
|
||||
332290DABD5326C87258723C /* ProcessDataTests.swift */,
|
||||
61F822BA26055B319B7D13F8 /* ProcessTableTests.swift */,
|
||||
01C19ABF49697A97F0C40378 /* SettingsTests.swift */,
|
||||
FD9632A456D316A019CFA313 /* ThermalMathTests.swift */,
|
||||
);
|
||||
path = TopManagerTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -280,6 +294,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
110A314A63AE355A9E04990D /* ProcessDetailView.swift */,
|
||||
19ADFC64E855FBDCDAF600E5 /* ProcessTableView.swift */,
|
||||
7B6F97E6EF65AA17A469F037 /* ProcessView.swift */,
|
||||
);
|
||||
path = Processes;
|
||||
@@ -386,7 +401,9 @@
|
||||
DA03FF0D25DB8B9D3B09BC82 /* MenuBarStatusTests.swift in Sources */,
|
||||
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */,
|
||||
2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */,
|
||||
B02EE645434EF31AAE82825E /* ProcessTableTests.swift in Sources */,
|
||||
8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */,
|
||||
802A6F368A475126C1349A03 /* ThermalMathTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -427,9 +444,12 @@
|
||||
C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */,
|
||||
744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */,
|
||||
9146403BC02D18A234444DF6 /* ProcessMonitor.swift in Sources */,
|
||||
1929475349F9A25B0778DF9B /* ProcessTableView.swift in Sources */,
|
||||
0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */,
|
||||
B89695749D32F394DB459C15 /* SettingsView.swift in Sources */,
|
||||
DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */,
|
||||
5DB015294ABB26FF14B1D432 /* TemperatureMonitor.swift in Sources */,
|
||||
CDA14A3B28EFF15D004D714F /* ThermalInfo.swift in Sources */,
|
||||
D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */,
|
||||
9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */,
|
||||
14D77C268F9F701E612E4FD3 /* WindowVisibility.swift in Sources */,
|
||||
@@ -453,12 +473,14 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = TopManager/TopManager.entitlements;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TopManager/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.app;
|
||||
PRODUCT_NAME = TopManager;
|
||||
SDKROOT = macosx;
|
||||
@@ -556,12 +578,14 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = TopManager/TopManager.entitlements;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TopManager/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.app;
|
||||
PRODUCT_NAME = TopManager;
|
||||
SDKROOT = macosx;
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.1</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
53
TopManager/Models/ThermalInfo.swift
Normal file
53
TopManager/Models/ThermalInfo.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
/// One temperature sensor as reported by the system (name as the hardware names it).
|
||||
struct TemperatureReading: Equatable {
|
||||
let name: String
|
||||
let celsius: Double
|
||||
}
|
||||
|
||||
/// CPU temperature summarised from the die sensors.
|
||||
struct ThermalInfo: Equatable {
|
||||
let cpuMax: Double // °C, hottest CPU die sensor
|
||||
let cpuAverage: Double // °C, mean of the CPU die sensors
|
||||
let cpuSensorCount: Int
|
||||
}
|
||||
|
||||
/// Pure, testable selection of CPU sensors (no IOKit).
|
||||
enum ThermalMath {
|
||||
/// CPU die sensors on Apple Silicon: "PMU tdie*" / "PMU2 tdie*" (M2 and later)
|
||||
/// and "pACC/eACC MTR Temp Sensor*" (M1). The "tdev" ones sit elsewhere on the
|
||||
/// package, "tcal" is a calibration value, "gas gauge battery" is the battery.
|
||||
static func isCPUDie(_ name: String) -> Bool {
|
||||
let n = name.lowercased()
|
||||
return n.contains("tdie") || n.contains("acc mtr temp")
|
||||
}
|
||||
|
||||
/// Readings no real sensor produces (unconnected ones report ≤ 0).
|
||||
static func isPlausible(_ celsius: Double) -> Bool {
|
||||
celsius > 0 && celsius < 150
|
||||
}
|
||||
|
||||
/// How hot the CPU die is. Apple Silicon runs into the 90s under sustained load
|
||||
/// and only throttles near 105 °C, so the bands sit high on purpose.
|
||||
static func level(cpuCelsius t: Double) -> ThermalLevel {
|
||||
switch t {
|
||||
case 100...: return .critical
|
||||
case 90..<100: return .serious
|
||||
case 80..<90: return .fair
|
||||
default: return .nominal
|
||||
}
|
||||
}
|
||||
|
||||
static func summarize(_ readings: [TemperatureReading]) -> ThermalInfo? {
|
||||
let cpu = readings
|
||||
.filter { isCPUDie($0.name) && isPlausible($0.celsius) }
|
||||
.map(\.celsius)
|
||||
guard let hottest = cpu.max() else { return nil }
|
||||
return ThermalInfo(
|
||||
cpuMax: hottest,
|
||||
cpuAverage: cpu.reduce(0, +) / Double(cpu.count),
|
||||
cpuSensorCount: cpu.count
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ final class SystemMonitor: ObservableObject {
|
||||
@MainActor @Published var networkInfo: NetworkInfo?
|
||||
@MainActor @Published var gpuInfo: GPUInfo?
|
||||
@MainActor @Published var powerInfo: PowerInfo?
|
||||
@MainActor @Published var thermalInfo: ThermalInfo?
|
||||
@MainActor @Published var lastError: String?
|
||||
|
||||
// History for charts
|
||||
@@ -33,6 +34,7 @@ final class SystemMonitor: ObservableObject {
|
||||
var networkInfo: NetworkInfo?
|
||||
var gpuInfo: GPUInfo?
|
||||
var powerInfo: PowerInfo?
|
||||
var thermalInfo: ThermalInfo?
|
||||
var cpuHistory: [CPUHistoryPoint] = []
|
||||
var coreHistories: [Int: [CoreHistoryPoint]] = [:]
|
||||
var memoryHistory: [MemoryHistoryPoint] = []
|
||||
@@ -49,6 +51,7 @@ final class SystemMonitor: ObservableObject {
|
||||
private let networkMonitor = NetworkMonitor()
|
||||
private let gpuMonitor = GPUMonitor()
|
||||
private let powerMonitor = PowerMonitor()
|
||||
private let temperatureMonitor = TemperatureMonitor()
|
||||
|
||||
// Timer
|
||||
private var timer: Timer?
|
||||
@@ -87,10 +90,12 @@ final class SystemMonitor: ObservableObject {
|
||||
let diskData = self.diskMonitor.fetchDiskInfo()
|
||||
let gpuData = self.gpuMonitor.fetchGPUInfo()
|
||||
let powerData = self.powerMonitor.fetchPowerInfo()
|
||||
let thermalData = self.temperatureMonitor.fetchThermalInfo()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.updateUI(cpu: cpuData, memory: memData, network: netData,
|
||||
processes: processData, disk: diskData, gpu: gpuData, power: powerData)
|
||||
processes: processData, disk: diskData, gpu: gpuData, power: powerData,
|
||||
thermal: thermalData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +132,7 @@ final class SystemMonitor: ObservableObject {
|
||||
let cpuData = cpuMonitor.fetchCPUInfo()
|
||||
let memData = memoryMonitor.fetchMemoryInfo()
|
||||
let netData = networkMonitor.fetchNetworkInfo()
|
||||
let thermalData = temperatureMonitor.fetchThermalInfo()
|
||||
|
||||
// Fetch processes: first 3 cycles always, then every other cycle
|
||||
var processData: [ProcessItem]? = nil
|
||||
@@ -147,13 +153,14 @@ final class SystemMonitor: ObservableObject {
|
||||
// Update UI on main thread
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.updateUI(cpu: cpuData, memory: memData, network: netData,
|
||||
processes: processData, disk: diskData, gpu: gpuData, power: powerData)
|
||||
processes: processData, disk: diskData, gpu: gpuData, power: powerData,
|
||||
thermal: thermalData)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private func updateUI(cpu: CPUInfo?, memory: MemoryInfo?, network: NetworkInfo?,
|
||||
processes: [ProcessItem]?, disk: DiskInfo?, gpu: GPUInfo?,
|
||||
power: PowerInfo?) {
|
||||
power: PowerInfo?, thermal: ThermalInfo?) {
|
||||
if let info = cpu {
|
||||
live.cpuInfo = info
|
||||
live.cpuHistory.append(CPUHistoryPoint(
|
||||
@@ -203,6 +210,7 @@ final class SystemMonitor: ObservableObject {
|
||||
if let info = disk { live.diskInfo = info }
|
||||
if let info = gpu { live.gpuInfo = info }
|
||||
if let info = power { live.powerInfo = info }
|
||||
if let info = thermal { live.thermalInfo = info }
|
||||
|
||||
if isUIVisible { publishLive() }
|
||||
MenuBarStatus.shared.update(cpu: live.cpuInfo, memory: live.memoryInfo, network: live.networkInfo)
|
||||
@@ -246,6 +254,7 @@ final class SystemMonitor: ObservableObject {
|
||||
networkInfo = live.networkInfo
|
||||
gpuInfo = live.gpuInfo
|
||||
powerInfo = live.powerInfo
|
||||
thermalInfo = live.thermalInfo
|
||||
cpuHistory = live.cpuHistory
|
||||
coreHistories = live.coreHistories
|
||||
memoryHistory = live.memoryHistory
|
||||
|
||||
71
TopManager/Services/TemperatureMonitor.swift
Normal file
71
TopManager/Services/TemperatureMonitor.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
import IOKit
|
||||
|
||||
// IOHIDEventSystemClient is the interface macOS itself uses for its thermal
|
||||
// sensors. It is not in the public SDK headers, but the symbols are exported by
|
||||
// IOKit and need neither root nor an entitlement (the app is not sandboxed).
|
||||
@_silgen_name("IOHIDEventSystemClientCreate")
|
||||
private func IOHIDEventSystemClientCreate(_ allocator: CFAllocator?) -> Unmanaged<AnyObject>?
|
||||
|
||||
@_silgen_name("IOHIDEventSystemClientSetMatching")
|
||||
private func IOHIDEventSystemClientSetMatching(_ client: AnyObject, _ matching: CFDictionary) -> Int32
|
||||
|
||||
@_silgen_name("IOHIDEventSystemClientCopyServices")
|
||||
private func IOHIDEventSystemClientCopyServices(_ client: AnyObject) -> Unmanaged<CFArray>?
|
||||
|
||||
@_silgen_name("IOHIDServiceClientCopyProperty")
|
||||
private func IOHIDServiceClientCopyProperty(_ service: AnyObject, _ key: CFString) -> Unmanaged<AnyObject>?
|
||||
|
||||
@_silgen_name("IOHIDServiceClientCopyEvent")
|
||||
private func IOHIDServiceClientCopyEvent(_ service: AnyObject, _ type: Int64, _ options: Int32,
|
||||
_ timestamp: Int64) -> Unmanaged<AnyObject>?
|
||||
|
||||
@_silgen_name("IOHIDEventGetFloatValue")
|
||||
private func IOHIDEventGetFloatValue(_ event: AnyObject, _ field: Int32) -> Double
|
||||
|
||||
/// Reads the Apple Silicon temperature sensors. On an Intel Mac there are no
|
||||
/// such sensors (those sit behind the SMC) and it reports nil.
|
||||
final class TemperatureMonitor {
|
||||
private static let temperatureEvent: Int64 = 15 // kIOHIDEventTypeTemperature
|
||||
private static let temperatureField = Int32(temperatureEvent << 16) // IOHIDEventFieldBase(type)
|
||||
|
||||
private let client: AnyObject?
|
||||
private var cachedSensors: [(name: String, service: AnyObject)]?
|
||||
|
||||
init() {
|
||||
client = IOHIDEventSystemClientCreate(kCFAllocatorDefault)?.takeRetainedValue()
|
||||
if let client {
|
||||
// Usage page 0xff00 (Apple vendor), usage 5: temperature sensors.
|
||||
_ = IOHIDEventSystemClientSetMatching(
|
||||
client, ["PrimaryUsagePage": 0xff00, "PrimaryUsage": 5] as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
func readSensors() -> [TemperatureReading] {
|
||||
sensors().compactMap { sensor in
|
||||
guard let event = IOHIDServiceClientCopyEvent(sensor.service, Self.temperatureEvent, 0, 0)?
|
||||
.takeRetainedValue() else { return nil }
|
||||
return TemperatureReading(name: sensor.name,
|
||||
celsius: IOHIDEventGetFloatValue(event, Self.temperatureField))
|
||||
}
|
||||
}
|
||||
|
||||
func fetchThermalInfo() -> ThermalInfo? {
|
||||
ThermalMath.summarize(readSensors())
|
||||
}
|
||||
|
||||
/// The sensor list doesn't change while the system runs, so it is built once.
|
||||
private func sensors() -> [(name: String, service: AnyObject)] {
|
||||
if let cachedSensors { return cachedSensors }
|
||||
guard let client,
|
||||
let services = IOHIDEventSystemClientCopyServices(client)?.takeRetainedValue() as? [AnyObject]
|
||||
else { return [] }
|
||||
let found = services.map { service in
|
||||
let name = IOHIDServiceClientCopyProperty(service, "Product" as CFString)?
|
||||
.takeRetainedValue() as? String
|
||||
return (name: name ?? "", service: service)
|
||||
}
|
||||
cachedSensors = found
|
||||
return found
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,18 @@ struct PowerStorageView: View {
|
||||
ThermalStateView(state: monitor.thermalState)
|
||||
}
|
||||
|
||||
if let thermal = monitor.thermalInfo {
|
||||
HStack {
|
||||
Text("CPU Temperature:")
|
||||
Spacer()
|
||||
Text(String(format: "%.0f °C", thermal.cpuMax))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(cpuTemperatureColor(thermal.cpuMax))
|
||||
}
|
||||
.help(String(format: "Hottest of %d CPU die sensors · average %.0f °C",
|
||||
thermal.cpuSensorCount, thermal.cpuAverage))
|
||||
}
|
||||
|
||||
if let cpu = monitor.cpuInfo {
|
||||
HStack {
|
||||
Text("CPU Usage:")
|
||||
@@ -190,6 +202,15 @@ struct PowerStorageView: View {
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func cpuTemperatureColor(_ celsius: Double) -> Color {
|
||||
switch ThermalMath.level(cpuCelsius: celsius) {
|
||||
case .nominal: return .primary
|
||||
case .fair: return .yellow
|
||||
case .serious: return .orange
|
||||
case .critical: return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BatteryStatusView: View {
|
||||
|
||||
@@ -28,6 +28,7 @@ struct ProcessInspectorSheet: View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
liveStats
|
||||
memoryExplanation
|
||||
pathSection
|
||||
detailsSection
|
||||
}
|
||||
@@ -90,6 +91,25 @@ struct ProcessInspectorSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain-language guide to the three memory figures; without it, RAM being
|
||||
/// higher than Memory (normal for any SwiftUI app) looks like a bug.
|
||||
private var memoryExplanation: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("About memory").font(.caption).foregroundColor(.secondary)
|
||||
Group {
|
||||
if process.memoryUsage == 0 && process.residentMemory == 0 {
|
||||
Text("Memory figures are unavailable for processes owned by another user or the system.")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text("**Memory** — what this process is responsible for: its own modified pages, whether in RAM, compressed or swapped out. This is its real memory cost.")
|
||||
Text("**RAM** — pages physically in RAM right now, including system framework code shared by every app. It can be higher than Memory, because shared pages take up RAM only once.")
|
||||
Text("**Compressed** — the part of Memory that macOS has compressed to save RAM, including pages swapped out to disk, shown at their original size.")
|
||||
}
|
||||
.font(.caption)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var pathSection: some View {
|
||||
if let path = process.executablePath {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
|
||||
477
TopManager/Views/Processes/ProcessTableView.swift
Normal file
477
TopManager/Views/Processes/ProcessTableView.swift
Normal file
@@ -0,0 +1,477 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
// MARK: - Presentation (pure, unit-tested)
|
||||
|
||||
/// Colour role of a cell's text or leading symbol.
|
||||
enum ProcessCellTone: Equatable {
|
||||
case primary, secondary, green, yellow, orange, red
|
||||
|
||||
var color: NSColor {
|
||||
switch self {
|
||||
case .primary: return .labelColor
|
||||
case .secondary: return .secondaryLabelColor
|
||||
case .green: return .systemGreen
|
||||
case .yellow: return .systemYellow
|
||||
case .orange: return .systemOrange
|
||||
case .red: return .systemRed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything one table cell displays. Cells only touch AppKit when this changes.
|
||||
struct ProcessCellContent: Equatable {
|
||||
enum Leading: Equatable {
|
||||
case none
|
||||
case icon(NSImage)
|
||||
case symbol(String, ProcessCellTone)
|
||||
|
||||
static func == (lhs: Leading, rhs: Leading) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.none, .none): return true
|
||||
case let (.icon(a), .icon(b)): return a === b
|
||||
case let (.symbol(a, aTone), .symbol(b, bTone)): return a == b && aTone == bTone
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var text: String
|
||||
var tone: ProcessCellTone = .primary
|
||||
var leading: Leading = .none
|
||||
var monospacedDigits = false
|
||||
}
|
||||
|
||||
/// The Processes table's columns, in display order.
|
||||
enum ProcessColumn: String, CaseIterable {
|
||||
case name, pid, cpu, cpuTotal, energy, memory, resident, compressed, disk, threads, user, state
|
||||
|
||||
var sortColumn: ProcessSortColumn {
|
||||
guard let column = ProcessSortColumn(rawValue: rawValue) else {
|
||||
preconditionFailure("ProcessColumn.\(rawValue) has no matching ProcessSortColumn")
|
||||
}
|
||||
return column
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .name: return "Name"
|
||||
case .pid: return "PID"
|
||||
case .cpu: return "CPU/Core"
|
||||
case .cpuTotal: return "CPU/Total"
|
||||
case .energy: return "Energy"
|
||||
case .memory: return "Memory"
|
||||
case .resident: return "RAM"
|
||||
case .compressed: return "Compressed"
|
||||
case .disk: return "Disk I/O"
|
||||
case .threads: return "Threads"
|
||||
case .user: return "User"
|
||||
case .state: return "State"
|
||||
}
|
||||
}
|
||||
|
||||
var width: CGFloat {
|
||||
switch self {
|
||||
case .name: return 220
|
||||
case .pid, .energy, .threads: return 60
|
||||
case .cpu, .cpuTotal: return 70
|
||||
case .memory, .resident, .user: return 80
|
||||
case .compressed: return 85
|
||||
case .disk, .state: return 90
|
||||
}
|
||||
}
|
||||
|
||||
/// Text columns sort A→Z on first click; figures sort largest first.
|
||||
var sortsAscendingFirst: Bool {
|
||||
switch self {
|
||||
case .name, .pid, .user, .state: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort order after a click on this column's header: the current column
|
||||
/// flips direction, any other column starts in its natural direction.
|
||||
func sortAfterClick(current: ProcessSortColumn, ascending: Bool) -> (column: ProcessSortColumn, ascending: Bool) {
|
||||
sortColumn == current ? (current, !ascending) : (sortColumn, sortsAscendingFirst)
|
||||
}
|
||||
|
||||
func content(for process: ProcessItem) -> ProcessCellContent {
|
||||
switch self {
|
||||
case .name:
|
||||
return ProcessCellContent(text: process.name,
|
||||
leading: process.icon.map { .icon($0) } ?? .symbol("app.dashed", .secondary))
|
||||
case .pid:
|
||||
return ProcessCellContent(text: String(process.pid), monospacedDigits: true)
|
||||
case .cpu:
|
||||
return ProcessCellContent(text: String(format: "%.1f%%", process.cpuUsage),
|
||||
tone: Self.tone(process.cpuUsage, yellow: 20, orange: 50, red: 80),
|
||||
monospacedDigits: true)
|
||||
case .cpuTotal:
|
||||
return ProcessCellContent(text: String(format: "%.2f%%", process.cpuUsageTotal),
|
||||
tone: Self.tone(process.cpuUsageTotal, yellow: 2, orange: 5, red: 10),
|
||||
monospacedDigits: true)
|
||||
case .energy:
|
||||
return ProcessCellContent(text: String(format: "%.1f", process.energyImpact),
|
||||
tone: Self.tone(process.energyImpact, yellow: 8, orange: 20, red: 50),
|
||||
monospacedDigits: true)
|
||||
case .memory:
|
||||
return ProcessCellContent(text: formatBytes(process.memoryUsage), monospacedDigits: true)
|
||||
case .resident:
|
||||
return ProcessCellContent(text: formatBytes(process.residentMemory), monospacedDigits: true)
|
||||
case .compressed:
|
||||
return process.compressedMemory > 0
|
||||
? ProcessCellContent(text: formatBytes(process.compressedMemory), monospacedDigits: true)
|
||||
: ProcessCellContent(text: "—", tone: .secondary)
|
||||
case .disk:
|
||||
return process.diskTotalRate > 0
|
||||
? ProcessCellContent(text: formatBytesPerSecond(process.diskTotalRate), monospacedDigits: true)
|
||||
: ProcessCellContent(text: "—", tone: .secondary)
|
||||
case .threads:
|
||||
return ProcessCellContent(text: String(process.threadCount), monospacedDigits: true)
|
||||
case .user:
|
||||
return ProcessCellContent(text: process.user)
|
||||
case .state:
|
||||
return ProcessCellContent(text: process.state.rawValue,
|
||||
leading: .symbol(process.state.symbol, Self.tone(for: process.state)))
|
||||
}
|
||||
}
|
||||
|
||||
static func tone(_ value: Double, yellow: Double, orange: Double, red: Double) -> ProcessCellTone {
|
||||
if value > red { return .red }
|
||||
if value > orange { return .orange }
|
||||
if value > yellow { return .yellow }
|
||||
return .primary
|
||||
}
|
||||
|
||||
static func tone(for state: ProcessState) -> ProcessCellTone {
|
||||
switch state {
|
||||
case .running: return .green
|
||||
case .stopped: return .orange
|
||||
case .zombie: return .red
|
||||
case .sleeping, .unknown: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Table
|
||||
|
||||
/// Callbacks from the table back into `ProcessView`.
|
||||
struct ProcessTableActions {
|
||||
var open: (ProcessItem) -> Void
|
||||
var terminate: (ProcessItem) -> Void
|
||||
var forceKill: (ProcessItem) -> Void
|
||||
var suspend: (ProcessItem) -> Void
|
||||
var resume: (ProcessItem) -> Void
|
||||
var deleteKey: () -> Void
|
||||
}
|
||||
|
||||
/// The process list as a plain AppKit `NSTableView`.
|
||||
///
|
||||
/// Replaces SwiftUI's `Table`, which on macOS 26 retained AutoLayout and
|
||||
/// view-graph objects on every data update — tens of MB an hour while the window
|
||||
/// was visible — on top of an NSHostingView per cell. Measured side by side
|
||||
/// (6 min, window visible, scrolling): heap growth fell from ~600 KB/min to
|
||||
/// ~35 KB/min and the starting footprint from 67 MB to 49 MB.
|
||||
/// Cells here are reused AppKit views, and an update rewrites only the fields
|
||||
/// that changed in rows that are on screen; the table reloads only once, on
|
||||
/// its first update.
|
||||
struct ProcessTableView: NSViewRepresentable {
|
||||
let processes: [ProcessItem]
|
||||
@Binding var selection: Set<ProcessItem.ID>
|
||||
let sortColumn: ProcessSortColumn
|
||||
let sortAscending: Bool
|
||||
let onSort: (ProcessSortColumn, Bool) -> Void
|
||||
let actions: ProcessTableActions
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
let coordinator = context.coordinator
|
||||
let table = ProcessNSTableView()
|
||||
table.style = .inset
|
||||
table.usesAlternatingRowBackgroundColors = true
|
||||
table.allowsMultipleSelection = true
|
||||
table.allowsColumnReordering = true
|
||||
table.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle
|
||||
table.rowHeight = 24
|
||||
|
||||
for column in ProcessColumn.allCases {
|
||||
let tableColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(column.rawValue))
|
||||
tableColumn.title = column.title
|
||||
tableColumn.width = column.width
|
||||
tableColumn.minWidth = column == .name ? 170 : 40
|
||||
tableColumn.resizingMask = column == .name ? [.autoresizingMask, .userResizingMask] : .userResizingMask
|
||||
table.addTableColumn(tableColumn)
|
||||
}
|
||||
// Remembers column widths and order between launches.
|
||||
table.autosaveName = "ProcessTable"
|
||||
table.autosaveTableColumns = true
|
||||
|
||||
table.dataSource = coordinator
|
||||
table.delegate = coordinator
|
||||
table.target = coordinator
|
||||
table.doubleAction = #selector(Coordinator.doubleClicked(_:))
|
||||
table.onDelete = { [weak coordinator] in coordinator?.parent.actions.deleteKey() }
|
||||
|
||||
let menu = NSMenu()
|
||||
menu.delegate = coordinator
|
||||
table.menu = menu
|
||||
|
||||
let scrollView = NSScrollView()
|
||||
scrollView.documentView = table
|
||||
scrollView.hasVerticalScroller = true
|
||||
scrollView.hasHorizontalScroller = true
|
||||
scrollView.autohidesScrollers = true
|
||||
|
||||
coordinator.tableView = table
|
||||
return scrollView
|
||||
}
|
||||
|
||||
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
context.coordinator.update()
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate {
|
||||
var parent: ProcessTableView
|
||||
weak var tableView: NSTableView?
|
||||
private var rows: [ProcessItem] = []
|
||||
private var isSyncing = false
|
||||
/// The first update must reload: right after `dataSource` is set, the
|
||||
/// table still has an initial load pending, and `noteNumberOfRowsChanged`
|
||||
/// on top of it doubles the row count (blank rows below the list).
|
||||
private var needsInitialLoad = true
|
||||
private var shownSort: (column: ProcessSortColumn, ascending: Bool)?
|
||||
|
||||
init(parent: ProcessTableView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
// MARK: Updates
|
||||
|
||||
func update() {
|
||||
guard let tableView else { return }
|
||||
isSyncing = true
|
||||
defer { isSyncing = false }
|
||||
|
||||
let newRows = parent.processes
|
||||
if needsInitialLoad {
|
||||
needsInitialLoad = false
|
||||
rows = newRows
|
||||
tableView.reloadData()
|
||||
} else if Self.rowsDiffer(rows, newRows) {
|
||||
let countChanged = newRows.count != rows.count
|
||||
rows = newRows
|
||||
if countChanged { tableView.noteNumberOfRowsChanged() }
|
||||
refreshPreparedCells(in: tableView)
|
||||
}
|
||||
|
||||
let selected = IndexSet(rows.indices.filter { parent.selection.contains(rows[$0].pid) })
|
||||
if selected != tableView.selectedRowIndexes {
|
||||
tableView.selectRowIndexes(selected, byExtendingSelection: false)
|
||||
}
|
||||
|
||||
if shownSort?.column != parent.sortColumn || shownSort?.ascending != parent.sortAscending {
|
||||
showSortIndicator(in: tableView)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorting is driven by `ProcessView`, not by NSTableView's sort descriptors:
|
||||
/// the built-in header toggling ignored the prototypes' direction, so figure
|
||||
/// columns started smallest-first.
|
||||
private func showSortIndicator(in tableView: NSTableView) {
|
||||
for tableColumn in tableView.tableColumns {
|
||||
tableView.setIndicatorImage(nil, in: tableColumn)
|
||||
}
|
||||
let identifier = NSUserInterfaceItemIdentifier(parent.sortColumn.rawValue)
|
||||
if let tableColumn = tableView.tableColumn(withIdentifier: identifier) {
|
||||
let name = parent.sortAscending ? "NSAscendingSortIndicator" : "NSDescendingSortIndicator"
|
||||
tableView.setIndicatorImage(NSImage(named: name), in: tableColumn)
|
||||
tableView.highlightedTableColumn = tableColumn
|
||||
}
|
||||
shownSort = (parent.sortColumn, parent.sortAscending)
|
||||
}
|
||||
|
||||
/// `ProcessItem ==` compares live figures only, so also catch a PID that
|
||||
/// now belongs to a different process.
|
||||
static func rowsDiffer(_ old: [ProcessItem], _ new: [ProcessItem]) -> Bool {
|
||||
guard old.count == new.count else { return true }
|
||||
for (a, b) in zip(old, new) where a != b || a.name != b.name || a.startTime != b.startTime || a.user != b.user {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Rewrites the cells AppKit currently holds views for (on screen plus its
|
||||
/// small overdraw margin). Rows outside get configured when they scroll in.
|
||||
private func refreshPreparedCells(in tableView: NSTableView) {
|
||||
let range = tableView.rows(in: tableView.preparedContentRect)
|
||||
guard range.length > 0 else { return }
|
||||
for row in range.location..<min(range.location + range.length, rows.count) {
|
||||
for (index, tableColumn) in tableView.tableColumns.enumerated() {
|
||||
guard let column = ProcessColumn(rawValue: tableColumn.identifier.rawValue),
|
||||
let cell = tableView.view(atColumn: index, row: row, makeIfNecessary: false) as? ProcessTableCellView
|
||||
else { continue }
|
||||
cell.apply(column.content(for: rows[row]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Data source & delegate
|
||||
|
||||
func numberOfRows(in tableView: NSTableView) -> Int { rows.count }
|
||||
|
||||
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||
guard let identifier = tableColumn?.identifier,
|
||||
let column = ProcessColumn(rawValue: identifier.rawValue),
|
||||
row < rows.count else { return nil }
|
||||
let cell = tableView.makeView(withIdentifier: identifier, owner: nil) as? ProcessTableCellView
|
||||
?? ProcessTableCellView(identifier: identifier)
|
||||
cell.apply(column.content(for: rows[row]))
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableViewSelectionDidChange(_ notification: Notification) {
|
||||
guard !isSyncing, let tableView else { return }
|
||||
parent.selection = Set(tableView.selectedRowIndexes.compactMap { $0 < rows.count ? rows[$0].pid : nil })
|
||||
}
|
||||
|
||||
func tableView(_ tableView: NSTableView, didClick tableColumn: NSTableColumn) {
|
||||
guard let column = ProcessColumn(rawValue: tableColumn.identifier.rawValue) else { return }
|
||||
let next = column.sortAfterClick(current: parent.sortColumn, ascending: parent.sortAscending)
|
||||
parent.onSort(next.column, next.ascending)
|
||||
}
|
||||
|
||||
@objc func doubleClicked(_ sender: NSTableView) {
|
||||
let row = sender.clickedRow
|
||||
guard row >= 0, row < rows.count else { return }
|
||||
parent.actions.open(rows[row])
|
||||
}
|
||||
|
||||
// MARK: Context menu
|
||||
|
||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||
menu.removeAllItems()
|
||||
guard let tableView else { return }
|
||||
// A right-click sets clickedRow; a menu opened from the keyboard or
|
||||
// VoiceOver has none, so fall back to the selection.
|
||||
let row = tableView.clickedRow >= 0 ? tableView.clickedRow : tableView.selectedRow
|
||||
guard row >= 0, row < rows.count else { return }
|
||||
if !tableView.selectedRowIndexes.contains(row) {
|
||||
tableView.selectRowIndexes([row], byExtendingSelection: false)
|
||||
}
|
||||
// Actions get the clicked process directly rather than reading the
|
||||
// selection back through SwiftUI state, which may not have updated yet.
|
||||
let process = rows[row]
|
||||
let actions = parent.actions
|
||||
addItem("Get Info (⌘I)", to: menu) { actions.open(process) }
|
||||
menu.addItem(.separator())
|
||||
addItem("Terminate (⌫)", to: menu) { actions.terminate(process) }
|
||||
addItem("Force Kill (⌘⌫)", to: menu) { actions.forceKill(process) }
|
||||
menu.addItem(.separator())
|
||||
addItem("Suspend", to: menu) { actions.suspend(process) }
|
||||
addItem("Resume", to: menu) { actions.resume(process) }
|
||||
menu.addItem(.separator())
|
||||
addItem("Copy PID", to: menu) {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(String(process.pid), forType: .string)
|
||||
}
|
||||
}
|
||||
|
||||
private final class MenuAction {
|
||||
let run: () -> Void
|
||||
init(_ run: @escaping () -> Void) { self.run = run }
|
||||
}
|
||||
|
||||
private func addItem(_ title: String, to menu: NSMenu, run: @escaping () -> Void) {
|
||||
let item = NSMenuItem(title: title, action: #selector(performMenuAction(_:)), keyEquivalent: "")
|
||||
item.target = self
|
||||
item.representedObject = MenuAction(run)
|
||||
menu.addItem(item)
|
||||
}
|
||||
|
||||
@objc private func performMenuAction(_ sender: NSMenuItem) {
|
||||
(sender.representedObject as? MenuAction)?.run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the plain Delete / Forward Delete key (⌘⌫ is handled by `KeyboardShortcutHandler`).
|
||||
final class ProcessNSTableView: NSTableView {
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask).subtracting([.function, .numericPad])
|
||||
if (event.keyCode == 51 || event.keyCode == 117) && modifiers.isEmpty {
|
||||
onDelete?()
|
||||
return
|
||||
}
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
/// A reusable cell: optional 16 pt leading image plus a label, laid out by hand
|
||||
/// (no constraints) and updated field by field.
|
||||
final class ProcessTableCellView: NSTableCellView {
|
||||
private static let font = NSFont.systemFont(ofSize: NSFont.systemFontSize)
|
||||
private static let digitFont = NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
|
||||
private static let lineHeight = ceil(NSLayoutManager().defaultLineHeight(for: font))
|
||||
|
||||
private let label = NSTextField(labelWithString: "")
|
||||
private let leadingView = NSImageView()
|
||||
private var content: ProcessCellContent?
|
||||
|
||||
init(identifier: NSUserInterfaceItemIdentifier) {
|
||||
super.init(frame: .zero)
|
||||
self.identifier = identifier
|
||||
label.lineBreakMode = .byTruncatingTail
|
||||
label.font = Self.font
|
||||
leadingView.imageScaling = .scaleProportionallyUpOrDown
|
||||
leadingView.isHidden = true
|
||||
addSubview(leadingView)
|
||||
addSubview(label)
|
||||
textField = label
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
|
||||
|
||||
func apply(_ new: ProcessCellContent) {
|
||||
guard new != content else { return }
|
||||
let old = content
|
||||
content = new
|
||||
|
||||
if new.text != old?.text { label.stringValue = new.text }
|
||||
if new.tone != old?.tone { label.textColor = new.tone.color }
|
||||
if new.monospacedDigits != old?.monospacedDigits {
|
||||
label.font = new.monospacedDigits ? Self.digitFont : Self.font
|
||||
}
|
||||
if new.leading != old?.leading {
|
||||
switch new.leading {
|
||||
case .none:
|
||||
leadingView.image = nil
|
||||
leadingView.isHidden = true
|
||||
case .icon(let image):
|
||||
leadingView.image = image
|
||||
leadingView.contentTintColor = nil
|
||||
leadingView.isHidden = false
|
||||
case .symbol(let name, let tone):
|
||||
leadingView.image = NSImage(systemSymbolName: name, accessibilityDescription: nil)
|
||||
leadingView.contentTintColor = tone.color
|
||||
leadingView.isHidden = false
|
||||
}
|
||||
needsLayout = true
|
||||
}
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
let height = bounds.height
|
||||
var x: CGFloat = 0
|
||||
if !leadingView.isHidden {
|
||||
leadingView.frame = NSRect(x: 0, y: floor((height - 16) / 2), width: 16, height: 16)
|
||||
x = 22
|
||||
}
|
||||
label.frame = NSRect(x: x, y: floor((height - Self.lineHeight) / 2),
|
||||
width: max(0, bounds.width - x), height: Self.lineHeight)
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,6 @@ struct ProcessView: View {
|
||||
@State private var selectedProcess: Set<ProcessItem.ID> = []
|
||||
@State private var sortColumn: ProcessSortColumn = .cpu
|
||||
@State private var sortAscending: Bool = false
|
||||
@State private var displayedProcesses: [ProcessItem] = []
|
||||
|
||||
// Keep sortOrder for Table binding compatibility
|
||||
@State private var sortOrder: [KeyPathComparator<ProcessItem>] = [
|
||||
.init(\.cpuUsage, order: .reverse)
|
||||
]
|
||||
|
||||
// Confirmation dialogs
|
||||
@State private var showTerminateConfirm = false
|
||||
@@ -40,14 +34,23 @@ struct ProcessView: View {
|
||||
Self.protectedProcesses.contains(name)
|
||||
}
|
||||
|
||||
private func updateDisplayedProcesses() {
|
||||
let filtered = monitor.processes.filter {
|
||||
searchText.isEmpty ||
|
||||
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
String($0.pid).contains(searchText)
|
||||
/// The rows on screen, derived from current state on every render. This used
|
||||
/// to be cached in @State and refreshed from `onChange` actions, which can read
|
||||
/// an earlier render's sort and search values — a data refresh right after a
|
||||
/// header click could put the table back into the previous order.
|
||||
private var displayedProcesses: [ProcessItem] {
|
||||
Self.filterAndSort(monitor.processes, search: searchText, by: sortColumn, ascending: sortAscending)
|
||||
}
|
||||
|
||||
static func filterAndSort(_ processes: [ProcessItem], search: String,
|
||||
by sortColumn: ProcessSortColumn, ascending sortAscending: Bool) -> [ProcessItem] {
|
||||
let filtered = processes.filter {
|
||||
search.isEmpty ||
|
||||
$0.name.localizedCaseInsensitiveContains(search) ||
|
||||
String($0.pid).contains(search)
|
||||
}
|
||||
|
||||
displayedProcesses = filtered.sorted { lhs, rhs in
|
||||
return filtered.sorted { lhs, rhs in
|
||||
let comparison: ComparisonResult
|
||||
switch sortColumn {
|
||||
case .name:
|
||||
@@ -86,213 +89,39 @@ struct ProcessView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Looked up among the rows on screen, so a selection hidden by the search
|
||||
/// filter can't be terminated by accident.
|
||||
var selectedProcessItem: ProcessItem? {
|
||||
guard let pid = selectedProcess.first else { return nil }
|
||||
return monitor.processes.first { $0.pid == pid }
|
||||
}
|
||||
|
||||
/// Memory, RAM and Compressed as a single builder element: Table accepts at
|
||||
/// most 10 columns per block, and inlining them also exceeds the type
|
||||
/// checker's time limit for the table expression.
|
||||
@TableColumnBuilder<ProcessItem, KeyPathComparator<ProcessItem>>
|
||||
private var memoryColumns: some TableColumnContent<ProcessItem, KeyPathComparator<ProcessItem>> {
|
||||
TableColumn("Memory", value: \.memoryUsage) { process in
|
||||
Text(formatBytes(process.memoryUsage))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(80)
|
||||
|
||||
TableColumn("RAM", value: \.residentMemory) { process in
|
||||
Text(formatBytes(process.residentMemory))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(80)
|
||||
|
||||
TableColumn("Compressed", value: \.compressedMemory) { process in
|
||||
if process.compressedMemory > 0 {
|
||||
Text(formatBytes(process.compressedMemory))
|
||||
.monospacedDigit()
|
||||
} else {
|
||||
Text("—").foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.width(85)
|
||||
return displayedProcesses.first { $0.pid == pid }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let rows = displayedProcesses
|
||||
VStack(spacing: 0) {
|
||||
// Summary bar - separate view to avoid re-rendering table
|
||||
ProcessSummaryBar(processCount: displayedProcesses.count, searchText: $searchText)
|
||||
ProcessSummaryBar(processCount: rows.count, searchText: $searchText)
|
||||
|
||||
Divider()
|
||||
|
||||
// Process table
|
||||
Table(displayedProcesses, selection: $selectedProcess, sortOrder: $sortOrder) {
|
||||
TableColumn("Name", value: \.name) { process in
|
||||
HStack(spacing: 6) {
|
||||
if let icon = process.icon {
|
||||
Image(nsImage: icon)
|
||||
.resizable()
|
||||
.frame(width: 16, height: 16)
|
||||
} else {
|
||||
Image(systemName: "app.dashed")
|
||||
.frame(width: 16, height: 16)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text(process.name)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.width(min: 170, ideal: 220)
|
||||
|
||||
TableColumn("PID", value: \.pid) { process in
|
||||
Text("\(process.pid)")
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(60)
|
||||
|
||||
TableColumn("CPU/Core", value: \.cpuUsage) { process in
|
||||
Text(String(format: "%.1f%%", process.cpuUsage))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(cpuColor(process.cpuUsage))
|
||||
}
|
||||
.width(70)
|
||||
|
||||
TableColumn("CPU/Total", value: \.cpuUsageTotal) { process in
|
||||
Text(String(format: "%.2f%%", process.cpuUsageTotal))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(cpuColorTotal(process.cpuUsageTotal))
|
||||
}
|
||||
.width(70)
|
||||
|
||||
TableColumn("Energy", value: \.energyImpact) { process in
|
||||
Text(String(format: "%.1f", process.energyImpact))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(energyColor(process.energyImpact))
|
||||
}
|
||||
.width(60)
|
||||
|
||||
memoryColumns
|
||||
|
||||
TableColumn("Disk I/O", value: \.diskTotalRate) { process in
|
||||
if process.diskTotalRate > 0 {
|
||||
Text(formatBytesPerSecond(process.diskTotalRate))
|
||||
.monospacedDigit()
|
||||
} else {
|
||||
Text("—").foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.width(90)
|
||||
|
||||
TableColumn("Threads", value: \.threadCount) { process in
|
||||
Text("\(process.threadCount)")
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(60)
|
||||
|
||||
TableColumn("User", value: \.user) { process in
|
||||
Text(process.user)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.width(80)
|
||||
|
||||
TableColumn("State", value: \.state.rawValue) { process in
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: process.state.symbol)
|
||||
.foregroundColor(stateColor(process.state))
|
||||
Text(process.state.rawValue)
|
||||
}
|
||||
}
|
||||
.width(90)
|
||||
}
|
||||
.contextMenu(forSelectionType: ProcessItem.ID.self) { selection in
|
||||
if let pid = selection.first,
|
||||
let process = monitor.processes.first(where: { $0.pid == pid }) {
|
||||
Button("Get Info (⌘I)") {
|
||||
inspectorProcess = process
|
||||
}
|
||||
Divider()
|
||||
Button("Terminate (⌫)") {
|
||||
initiateTerminate()
|
||||
}
|
||||
Button("Force Kill (⌘⌫)") {
|
||||
initiateForceKill()
|
||||
}
|
||||
Divider()
|
||||
Button("Suspend") {
|
||||
performSuspend(process: process)
|
||||
}
|
||||
Button("Resume") {
|
||||
performResume(process: process)
|
||||
}
|
||||
Divider()
|
||||
Button("Copy PID") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString("\(pid)", forType: .string)
|
||||
}
|
||||
}
|
||||
} primaryAction: { selection in
|
||||
// Double-click opens the deep-dive inspector
|
||||
if let pid = selection.first,
|
||||
let process = monitor.processes.first(where: { $0.pid == pid }) {
|
||||
inspectorProcess = process
|
||||
}
|
||||
}
|
||||
.onDeleteCommand {
|
||||
initiateTerminate()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
updateDisplayedProcesses()
|
||||
}
|
||||
.onChange(of: monitor.processes) { _ in
|
||||
updateDisplayedProcesses()
|
||||
}
|
||||
.onChange(of: searchText) { _ in
|
||||
updateDisplayedProcesses()
|
||||
}
|
||||
.onChange(of: sortColumn) { _ in
|
||||
updateDisplayedProcesses()
|
||||
}
|
||||
.onChange(of: sortAscending) { _ in
|
||||
updateDisplayedProcesses()
|
||||
}
|
||||
.onChange(of: sortOrder) { newOrder in
|
||||
guard let comparator = newOrder.first else { return }
|
||||
|
||||
// Detect which column and direction from the KeyPathComparator
|
||||
let ascending = comparator.order == .forward
|
||||
|
||||
// Use string representation of keypath to determine column
|
||||
let keyPathString = String(describing: comparator)
|
||||
|
||||
if keyPathString.contains("cpuUsageTotal") {
|
||||
sortColumn = .cpuTotal
|
||||
} else if keyPathString.contains("cpuUsage") {
|
||||
sortColumn = .cpu
|
||||
} else if keyPathString.contains("energyImpact") {
|
||||
sortColumn = .energy
|
||||
} else if keyPathString.contains("diskTotalRate") {
|
||||
sortColumn = .disk
|
||||
} else if keyPathString.contains("residentMemory") {
|
||||
sortColumn = .resident
|
||||
} else if keyPathString.contains("compressedMemory") {
|
||||
sortColumn = .compressed
|
||||
} else if keyPathString.contains("memoryUsage") {
|
||||
sortColumn = .memory
|
||||
} else if keyPathString.contains("name") {
|
||||
sortColumn = .name
|
||||
} else if keyPathString.contains("pid") {
|
||||
sortColumn = .pid
|
||||
} else if keyPathString.contains("threadCount") {
|
||||
sortColumn = .threads
|
||||
} else if keyPathString.contains("user") {
|
||||
sortColumn = .user
|
||||
} else if keyPathString.contains("state") {
|
||||
sortColumn = .state
|
||||
}
|
||||
|
||||
sortAscending = ascending
|
||||
ProcessTableView(
|
||||
processes: rows,
|
||||
selection: $selectedProcess,
|
||||
sortColumn: sortColumn,
|
||||
sortAscending: sortAscending,
|
||||
onSort: { column, ascending in
|
||||
sortColumn = column
|
||||
sortAscending = ascending
|
||||
},
|
||||
actions: ProcessTableActions(
|
||||
open: { inspectorProcess = $0 },
|
||||
terminate: { initiateTerminate($0) },
|
||||
forceKill: { initiateForceKill($0) },
|
||||
suspend: { performSuspend(process: $0) },
|
||||
resume: { performResume(process: $0) },
|
||||
deleteKey: { initiateTerminate() }
|
||||
)
|
||||
)
|
||||
}
|
||||
.background(
|
||||
KeyboardShortcutHandler(
|
||||
@@ -344,8 +173,8 @@ struct ProcessView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func initiateTerminate() {
|
||||
guard let process = selectedProcessItem else { return }
|
||||
private func initiateTerminate(_ target: ProcessItem? = nil) {
|
||||
guard let process = target ?? selectedProcessItem else { return }
|
||||
processToKill = process
|
||||
|
||||
if skipTerminateConfirm {
|
||||
@@ -355,8 +184,8 @@ struct ProcessView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func initiateForceKill() {
|
||||
guard let process = selectedProcessItem else { return }
|
||||
private func initiateForceKill(_ target: ProcessItem? = nil) {
|
||||
guard let process = target ?? selectedProcessItem else { return }
|
||||
|
||||
// SAFETY: Prevent force-killing critical system processes
|
||||
if isProtectedProcess(process.name) {
|
||||
@@ -401,49 +230,6 @@ struct ProcessView: View {
|
||||
showErrorAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
private func cpuColor(_ usage: Double) -> Color {
|
||||
if usage > 80 {
|
||||
return .red
|
||||
} else if usage > 50 {
|
||||
return .orange
|
||||
} else if usage > 20 {
|
||||
return .yellow
|
||||
}
|
||||
return .primary
|
||||
}
|
||||
|
||||
private func cpuColorTotal(_ usage: Double) -> Color {
|
||||
if usage > 10 {
|
||||
return .red
|
||||
} else if usage > 5 {
|
||||
return .orange
|
||||
} else if usage > 2 {
|
||||
return .yellow
|
||||
}
|
||||
return .primary
|
||||
}
|
||||
|
||||
private func energyColor(_ impact: Double) -> Color {
|
||||
if impact > 50 {
|
||||
return .red
|
||||
} else if impact > 20 {
|
||||
return .orange
|
||||
} else if impact > 8 {
|
||||
return .yellow
|
||||
}
|
||||
return .primary
|
||||
}
|
||||
|
||||
private func stateColor(_ state: ProcessState) -> Color {
|
||||
switch state {
|
||||
case .running: return .green
|
||||
case .sleeping: return .secondary
|
||||
case .stopped: return .orange
|
||||
case .zombie: return .red
|
||||
case .unknown: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfirmationSheet: View {
|
||||
|
||||
267
TopManagerTests/ProcessTableTests.swift
Normal file
267
TopManagerTests/ProcessTableTests.swift
Normal file
@@ -0,0 +1,267 @@
|
||||
import XCTest
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
@testable import TopManager
|
||||
|
||||
final class ProcessTableTests: XCTestCase {
|
||||
|
||||
private func item(pid: pid_t = 100, name: String = "proc", user: String = "ariel",
|
||||
cpu: Double = 0, cpuTotal: Double = 0, energy: Double = 0,
|
||||
memory: Int64 = 1_000_000, resident: Int64 = 2_000_000, compressed: Int64 = 0,
|
||||
diskRead: Double = 0, state: ProcessState = .sleeping, icon: NSImage? = nil,
|
||||
start: Date = Date(timeIntervalSince1970: 1_000)) -> ProcessItem {
|
||||
ProcessItem(pid: pid, name: name, user: user, cpuUsage: cpu, cpuUsageTotal: cpuTotal,
|
||||
memoryUsage: memory, threadCount: 4, state: state, icon: icon, parentPid: 1,
|
||||
startTime: start, diskReadRate: diskRead, energyImpact: energy,
|
||||
residentMemory: resident, compressedMemory: compressed)
|
||||
}
|
||||
|
||||
// MARK: Columns
|
||||
|
||||
func testColumnsInDisplayOrder() {
|
||||
XCTAssertEqual(ProcessColumn.allCases.map(\.title),
|
||||
["Name", "PID", "CPU/Core", "CPU/Total", "Energy", "Memory", "RAM",
|
||||
"Compressed", "Disk I/O", "Threads", "User", "State"])
|
||||
}
|
||||
|
||||
func testEveryColumnSortsByItsOwnKey() {
|
||||
for column in ProcessColumn.allCases {
|
||||
XCTAssertEqual(column.sortColumn.rawValue, column.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
func testTextColumnsSortAscendingFirstFiguresDescending() {
|
||||
XCTAssertTrue(ProcessColumn.name.sortsAscendingFirst)
|
||||
XCTAssertTrue(ProcessColumn.user.sortsAscendingFirst)
|
||||
XCTAssertFalse(ProcessColumn.cpu.sortsAscendingFirst)
|
||||
XCTAssertFalse(ProcessColumn.memory.sortsAscendingFirst)
|
||||
XCTAssertFalse(ProcessColumn.compressed.sortsAscendingFirst)
|
||||
}
|
||||
|
||||
func testHeaderClickFlipsCurrentColumnAndStartsOthersInNaturalDirection() {
|
||||
XCTAssertTrue(ProcessColumn.cpu.sortAfterClick(current: .cpu, ascending: false) == (.cpu, true))
|
||||
XCTAssertTrue(ProcessColumn.cpu.sortAfterClick(current: .cpu, ascending: true) == (.cpu, false))
|
||||
XCTAssertTrue(ProcessColumn.memory.sortAfterClick(current: .name, ascending: true) == (.memory, false))
|
||||
XCTAssertTrue(ProcessColumn.resident.sortAfterClick(current: .memory, ascending: false) == (.resident, false))
|
||||
XCTAssertTrue(ProcessColumn.name.sortAfterClick(current: .cpu, ascending: false) == (.name, true))
|
||||
}
|
||||
|
||||
// MARK: Cell content
|
||||
|
||||
func testCPUToneThresholdsMatchPreviousTable() {
|
||||
XCTAssertEqual(ProcessColumn.cpu.content(for: item(cpu: 20)).tone, .primary)
|
||||
XCTAssertEqual(ProcessColumn.cpu.content(for: item(cpu: 20.1)).tone, .yellow)
|
||||
XCTAssertEqual(ProcessColumn.cpu.content(for: item(cpu: 50.1)).tone, .orange)
|
||||
XCTAssertEqual(ProcessColumn.cpu.content(for: item(cpu: 80.1)).tone, .red)
|
||||
XCTAssertEqual(ProcessColumn.cpuTotal.content(for: item(cpuTotal: 5.5)).tone, .orange)
|
||||
XCTAssertEqual(ProcessColumn.energy.content(for: item(energy: 8.5)).tone, .yellow)
|
||||
}
|
||||
|
||||
func testFormatsFigures() {
|
||||
let p = item(pid: 89435, cpu: 25.54, cpuTotal: 3.184, energy: 25.6)
|
||||
XCTAssertEqual(ProcessColumn.pid.content(for: p).text, "89435")
|
||||
XCTAssertEqual(ProcessColumn.cpu.content(for: p).text, "25.5%")
|
||||
XCTAssertEqual(ProcessColumn.cpuTotal.content(for: p).text, "3.18%")
|
||||
XCTAssertEqual(ProcessColumn.energy.content(for: p).text, "25.6")
|
||||
XCTAssertEqual(ProcessColumn.memory.content(for: p).text, formatBytes(Int64(1_000_000)))
|
||||
XCTAssertEqual(ProcessColumn.resident.content(for: p).text, formatBytes(Int64(2_000_000)))
|
||||
XCTAssertTrue(ProcessColumn.memory.content(for: p).monospacedDigits)
|
||||
XCTAssertFalse(ProcessColumn.user.content(for: p).monospacedDigits)
|
||||
}
|
||||
|
||||
func testZeroCompressedAndIdleDiskShowSecondaryDash() {
|
||||
for column in [ProcessColumn.compressed, .disk] {
|
||||
let content = column.content(for: item())
|
||||
XCTAssertEqual(content.text, "—")
|
||||
XCTAssertEqual(content.tone, .secondary)
|
||||
}
|
||||
XCTAssertEqual(ProcessColumn.compressed.content(for: item(compressed: 5_000_000)).text,
|
||||
formatBytes(Int64(5_000_000)))
|
||||
XCTAssertNotEqual(ProcessColumn.disk.content(for: item(diskRead: 2048)).text, "—")
|
||||
}
|
||||
|
||||
func testNameShowsAppIconOrPlaceholderSymbol() {
|
||||
let icon = NSImage(size: NSSize(width: 16, height: 16))
|
||||
XCTAssertEqual(ProcessColumn.name.content(for: item(icon: icon)).leading, .icon(icon))
|
||||
XCTAssertEqual(ProcessColumn.name.content(for: item()).leading, .symbol("app.dashed", .secondary))
|
||||
// Icons compare by identity, so an equal-looking copy still counts as a change.
|
||||
XCTAssertNotEqual(ProcessCellContent.Leading.icon(icon), .icon(NSImage(size: icon.size)))
|
||||
}
|
||||
|
||||
func testStateShowsTintedSymbol() {
|
||||
let running = ProcessColumn.state.content(for: item(state: .running))
|
||||
XCTAssertEqual(running.text, "Running")
|
||||
XCTAssertEqual(running.leading, .symbol(ProcessState.running.symbol, .green))
|
||||
XCTAssertEqual(ProcessColumn.tone(for: .zombie), .red)
|
||||
XCTAssertEqual(ProcessColumn.tone(for: .sleeping), .secondary)
|
||||
}
|
||||
|
||||
// MARK: Update detection
|
||||
|
||||
func testRowsDifferDetectsValueOrderAndIdentityChanges() {
|
||||
let a = item(pid: 1), b = item(pid: 2)
|
||||
let differ = ProcessTableView.Coordinator.rowsDiffer
|
||||
XCTAssertFalse(differ([a, b], [a, b]))
|
||||
XCTAssertTrue(differ([a, b], [b, a])) // re-sorted
|
||||
XCTAssertTrue(differ([a, b], [a])) // process exited
|
||||
XCTAssertTrue(differ([a, b], [a, item(pid: 2, cpu: 9)])) // figure changed
|
||||
XCTAssertTrue(differ([a, b], [a, item(pid: 2, name: "new")])) // PID reused
|
||||
XCTAssertTrue(differ([a, b], [a, item(pid: 2, start: Date(timeIntervalSince1970: 2_000))]))
|
||||
}
|
||||
}
|
||||
|
||||
final class ProcessFilterSortTests: XCTestCase {
|
||||
|
||||
private func p(_ pid: pid_t, _ name: String, cpu: Double = 0, memory: Int64 = 0, compressed: Int64 = 0) -> ProcessItem {
|
||||
ProcessItem(pid: pid, name: name, user: "ariel", cpuUsage: cpu, cpuUsageTotal: cpu / 8, memoryUsage: memory,
|
||||
threadCount: 1, state: .sleeping, icon: nil, parentPid: 1, startTime: nil,
|
||||
compressedMemory: compressed)
|
||||
}
|
||||
|
||||
private lazy var sample = [
|
||||
p(1, "launchd"),
|
||||
p(300, "Finder", cpu: 12, memory: 200_000_000, compressed: 50_000_000),
|
||||
p(200, "Teams", cpu: 3, memory: 900_000_000, compressed: 800_000_000),
|
||||
p(400, "zsh", cpu: 40, memory: 5_000_000),
|
||||
]
|
||||
|
||||
private func pids(_ column: ProcessSortColumn, ascending: Bool, search: String = "") -> [pid_t] {
|
||||
ProcessView.filterAndSort(sample, search: search, by: column, ascending: ascending).map(\.pid)
|
||||
}
|
||||
|
||||
func testFigureColumnsSortBothWays() {
|
||||
XCTAssertEqual(pids(.compressed, ascending: false), [200, 300, 1, 400]) // ties fall back to PID
|
||||
XCTAssertEqual(pids(.compressed, ascending: true), [1, 400, 300, 200])
|
||||
XCTAssertEqual(pids(.memory, ascending: false), [200, 300, 400, 1])
|
||||
XCTAssertEqual(pids(.cpu, ascending: false), [400, 300, 200, 1])
|
||||
}
|
||||
|
||||
func testNameSortIsCaseInsensitive() {
|
||||
XCTAssertEqual(pids(.name, ascending: true), [300, 1, 200, 400])
|
||||
XCTAssertEqual(pids(.name, ascending: false), [400, 200, 1, 300])
|
||||
}
|
||||
|
||||
func testSearchMatchesNameOrPid() {
|
||||
XCTAssertEqual(pids(.pid, ascending: true, search: "fin"), [300])
|
||||
XCTAssertEqual(pids(.pid, ascending: true, search: "40"), [400])
|
||||
XCTAssertEqual(pids(.pid, ascending: true, search: ""), [1, 200, 300, 400])
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives the real coordinator against a real (window-less) NSTableView.
|
||||
final class ProcessTableCoordinatorTests: XCTestCase {
|
||||
|
||||
private final class Harness {
|
||||
var selection: Set<pid_t> = []
|
||||
var sort: (ProcessSortColumn, Bool) = (.cpu, false)
|
||||
var opened: [pid_t] = []
|
||||
var terminated: [pid_t] = []
|
||||
var deleteKeyPresses = 0
|
||||
let table = ProcessNSTableView()
|
||||
var coordinator: ProcessTableView.Coordinator!
|
||||
|
||||
func view(_ rows: [ProcessItem]) -> ProcessTableView {
|
||||
ProcessTableView(
|
||||
processes: rows,
|
||||
selection: Binding(get: { self.selection }, set: { self.selection = $0 }),
|
||||
sortColumn: sort.0, sortAscending: sort.1,
|
||||
onSort: { self.sort = ($0, $1) },
|
||||
actions: ProcessTableActions(open: { self.opened.append($0.pid) },
|
||||
terminate: { self.terminated.append($0.pid) },
|
||||
forceKill: { _ in }, suspend: { _ in }, resume: { _ in },
|
||||
deleteKey: { self.deleteKeyPresses += 1 }))
|
||||
}
|
||||
|
||||
/// Mirrors makeNSView + updateNSView.
|
||||
func show(_ rows: [ProcessItem]) {
|
||||
let view = view(rows)
|
||||
if coordinator == nil {
|
||||
coordinator = view.makeCoordinator()
|
||||
for column in ProcessColumn.allCases {
|
||||
table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier(column.rawValue)))
|
||||
}
|
||||
table.dataSource = coordinator
|
||||
table.delegate = coordinator
|
||||
table.onDelete = { self.deleteKeyPresses += 1 }
|
||||
coordinator.tableView = table
|
||||
}
|
||||
coordinator.parent = view
|
||||
coordinator.update()
|
||||
}
|
||||
}
|
||||
|
||||
private func item(_ pid: pid_t, _ name: String) -> ProcessItem {
|
||||
ProcessItem(pid: pid, name: name, user: "ariel", cpuUsage: 0, cpuUsageTotal: 0, memoryUsage: 0,
|
||||
threadCount: 1, state: .sleeping, icon: nil, parentPid: 1, startTime: nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSelectionFollowsTheProcessWhenRowsReorder() {
|
||||
let h = Harness()
|
||||
let a = item(10, "a"), b = item(20, "b"), c = item(30, "c")
|
||||
h.show([a, b, c])
|
||||
XCTAssertEqual(h.table.numberOfRows, 3, "first load must not double the row count")
|
||||
|
||||
h.table.selectRowIndexes([1], byExtendingSelection: false) // user clicks "b"
|
||||
XCTAssertEqual(h.selection, [20])
|
||||
|
||||
h.show([c, a, b]) // re-sorted by a refresh
|
||||
XCTAssertEqual(h.table.selectedRowIndexes, [2])
|
||||
XCTAssertEqual(h.selection, [20], "syncing the table must not write back into the binding")
|
||||
|
||||
h.show([c, a]) // "b" exited
|
||||
XCTAssertEqual(h.table.numberOfRows, 2)
|
||||
XCTAssertTrue(h.table.selectedRowIndexes.isEmpty)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testHeaderClickReportsNextSort() {
|
||||
let h = Harness()
|
||||
h.show([item(1, "a")])
|
||||
let memory = h.table.tableColumn(withIdentifier: NSUserInterfaceItemIdentifier("memory"))!
|
||||
h.coordinator.tableView(h.table, didClick: memory)
|
||||
XCTAssertTrue(h.sort == (.memory, false))
|
||||
h.show([item(1, "a")]) // SwiftUI re-renders with the new sort
|
||||
h.coordinator.tableView(h.table, didClick: memory)
|
||||
XCTAssertTrue(h.sort == (.memory, true))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testContextMenuUsesSelectedRowWithoutRightClick() throws {
|
||||
let h = Harness()
|
||||
h.show([item(10, "a"), item(20, "b")])
|
||||
|
||||
let empty = NSMenu()
|
||||
h.coordinator.menuNeedsUpdate(empty)
|
||||
XCTAssertTrue(empty.items.isEmpty, "no click and no selection: nothing to act on")
|
||||
|
||||
h.table.selectRowIndexes([1], byExtendingSelection: false)
|
||||
let menu = NSMenu()
|
||||
h.coordinator.menuNeedsUpdate(menu)
|
||||
XCTAssertEqual(menu.items.filter { !$0.isSeparatorItem }.map(\.title),
|
||||
["Get Info (⌘I)", "Terminate (⌫)", "Force Kill (⌘⌫)", "Suspend", "Resume", "Copy PID"])
|
||||
|
||||
for title in ["Get Info (⌘I)", "Terminate (⌫)"] {
|
||||
let entry = try XCTUnwrap(menu.items.first { $0.title == title })
|
||||
_ = (entry.target as? NSObject)?.perform(entry.action, with: entry)
|
||||
}
|
||||
XCTAssertEqual(h.opened, [20])
|
||||
XCTAssertEqual(h.terminated, [20])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testDeleteKeyTriggersTerminateButCommandDeleteDoesNot() throws {
|
||||
let h = Harness()
|
||||
h.show([item(1, "a")])
|
||||
func key(_ code: UInt16, _ flags: NSEvent.ModifierFlags) throws -> NSEvent {
|
||||
try XCTUnwrap(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
|
||||
windowNumber: 0, context: nil, characters: "\u{8}",
|
||||
charactersIgnoringModifiers: "\u{8}", isARepeat: false, keyCode: code))
|
||||
}
|
||||
h.table.keyDown(with: try key(51, []))
|
||||
h.table.keyDown(with: try key(117, [.function])) // forward delete
|
||||
XCTAssertEqual(h.deleteKeyPresses, 2)
|
||||
h.table.keyDown(with: try key(51, [.command])) // ⌘⌫ belongs to Force Kill
|
||||
XCTAssertEqual(h.deleteKeyPresses, 2)
|
||||
}
|
||||
}
|
||||
71
TopManagerTests/ThermalMathTests.swift
Normal file
71
TopManagerTests/ThermalMathTests.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import XCTest
|
||||
@testable import TopManager
|
||||
|
||||
final class ThermalMathTests: XCTestCase {
|
||||
|
||||
// Names as an M3 reports them (PMU / PMU2 dies, package, calibration, NAND, battery).
|
||||
private let m3: [TemperatureReading] = [
|
||||
.init(name: "PMU tdie1", celsius: 48.8),
|
||||
.init(name: "PMU tdie2", celsius: 46.4),
|
||||
.init(name: "PMU2 tdie1", celsius: 43.4),
|
||||
.init(name: "PMU tdev2", celsius: 36.2),
|
||||
.init(name: "PMU tdev1", celsius: -1.5),
|
||||
.init(name: "PMU tcal", celsius: 51.9),
|
||||
.init(name: "NAND CH0 temp", celsius: 34.0),
|
||||
.init(name: "gas gauge battery", celsius: 31.3),
|
||||
]
|
||||
|
||||
func testOnlyDieSensorsCount() {
|
||||
let info = ThermalMath.summarize(m3)
|
||||
XCTAssertEqual(info?.cpuSensorCount, 3)
|
||||
// tcal (51.9) is hotter than every die but is a calibration value, not the CPU.
|
||||
XCTAssertEqual(info?.cpuMax ?? 0, 48.8, accuracy: 0.001)
|
||||
XCTAssertEqual(info?.cpuAverage ?? 0, (48.8 + 46.4 + 43.4) / 3, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testM1ClusterSensorsCount() {
|
||||
let m1: [TemperatureReading] = [
|
||||
.init(name: "pACC MTR Temp Sensor0", celsius: 55),
|
||||
.init(name: "eACC MTR Temp Sensor1", celsius: 41),
|
||||
.init(name: "GPU MTR Temp Sensor1", celsius: 60),
|
||||
.init(name: "SOC MTR Temp Sensor0", celsius: 50),
|
||||
]
|
||||
let info = ThermalMath.summarize(m1)
|
||||
XCTAssertEqual(info?.cpuSensorCount, 2)
|
||||
XCTAssertEqual(info?.cpuMax ?? 0, 55, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testImplausibleDieReadingsAreDropped() {
|
||||
let readings: [TemperatureReading] = [
|
||||
.init(name: "PMU tdie1", celsius: 0),
|
||||
.init(name: "PMU tdie2", celsius: -2),
|
||||
.init(name: "PMU tdie3", celsius: 170),
|
||||
.init(name: "PMU tdie4", celsius: 44),
|
||||
]
|
||||
XCTAssertEqual(ThermalMath.summarize(readings)?.cpuSensorCount, 1)
|
||||
}
|
||||
|
||||
func testNoCPUSensorsMeansNil() {
|
||||
// An Intel Mac: no IOHID die sensors at all, or only non-CPU ones.
|
||||
XCTAssertNil(ThermalMath.summarize([]))
|
||||
XCTAssertNil(ThermalMath.summarize([.init(name: "gas gauge battery", celsius: 30)]))
|
||||
}
|
||||
|
||||
func testReadsRealSensorsOnAppleSilicon() throws {
|
||||
#if !arch(arm64)
|
||||
throw XCTSkip("Apple Silicon only: Intel Macs expose these sensors through the SMC")
|
||||
#else
|
||||
let info = try XCTUnwrap(TemperatureMonitor().fetchThermalInfo())
|
||||
XCTAssertGreaterThan(info.cpuSensorCount, 0)
|
||||
XCTAssertTrue((15...110).contains(info.cpuMax), "implausible CPU temperature \(info.cpuMax)")
|
||||
XCTAssertLessThanOrEqual(info.cpuAverage, info.cpuMax)
|
||||
#endif
|
||||
}
|
||||
|
||||
func testLevels() {
|
||||
XCTAssertEqual(ThermalMath.level(cpuCelsius: 48), .nominal)
|
||||
XCTAssertEqual(ThermalMath.level(cpuCelsius: 85), .fair)
|
||||
XCTAssertEqual(ThermalMath.level(cpuCelsius: 95), .serious)
|
||||
XCTAssertEqual(ThermalMath.level(cpuCelsius: 104), .critical)
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,15 @@ targets:
|
||||
GENERATE_INFOPLIST_FILE: NO
|
||||
SWIFT_EMIT_LOC_STRINGS: YES
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
# The app version lives here. `xcodegen generate` rewrites Info.plist from
|
||||
# the `info` block below, so a version typed into Info.plist gets reset.
|
||||
MARKETING_VERSION: "1.2.0"
|
||||
CURRENT_PROJECT_VERSION: "4"
|
||||
info:
|
||||
path: TopManager/Info.plist
|
||||
properties:
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
|
||||
TopManagerTests:
|
||||
type: bundle.unit-test
|
||||
|
||||
Reference in New Issue
Block a user