Commitok összehasonlítása
12 Commit-ok
eb52891a8c
...
main
| Szerző | SHA1 | Dátum | |
|---|---|---|---|
|
|
9810987155 | ||
|
|
c50443bdbc | ||
|
|
15c8b18b48 | ||
|
|
2685ed9667 | ||
|
|
1767491076 | ||
|
|
cf88044582 | ||
|
|
e8a982c00e | ||
|
|
82b55c9468 | ||
|
|
14ced8d11d | ||
|
|
c9cd87f221 | ||
|
|
6f60dd7ad8 | ||
|
|
6a72c7ba64 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
# Xcode
|
||||
.DS_Store
|
||||
*/build/*
|
||||
/build/
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
*.mode1v3
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
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 */; };
|
||||
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 */; };
|
||||
@@ -22,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 */; };
|
||||
@@ -35,14 +39,18 @@
|
||||
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 */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
@@ -73,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>"; };
|
||||
@@ -87,13 +97,18 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -114,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 */
|
||||
@@ -126,11 +142,14 @@
|
||||
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 */,
|
||||
19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */,
|
||||
8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
@@ -156,6 +175,7 @@
|
||||
3010F605712E09175D9D8878 /* NetworkInfo.swift */,
|
||||
51C512B13DC0C1024CF39A5E /* PowerInfo.swift */,
|
||||
4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */,
|
||||
7A81F77752CFF49D5038B06E /* ThermalInfo.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
@@ -251,9 +271,12 @@
|
||||
70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */,
|
||||
301352EB5B362C732CE1A246 /* FormattingTests.swift */,
|
||||
E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */,
|
||||
8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */,
|
||||
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */,
|
||||
332290DABD5326C87258723C /* ProcessDataTests.swift */,
|
||||
61F822BA26055B319B7D13F8 /* ProcessTableTests.swift */,
|
||||
01C19ABF49697A97F0C40378 /* SettingsTests.swift */,
|
||||
FD9632A456D316A019CFA313 /* ThermalMathTests.swift */,
|
||||
);
|
||||
path = TopManagerTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -271,6 +294,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
110A314A63AE355A9E04990D /* ProcessDetailView.swift */,
|
||||
19ADFC64E855FBDCDAF600E5 /* ProcessTableView.swift */,
|
||||
7B6F97E6EF65AA17A469F037 /* ProcessView.swift */,
|
||||
);
|
||||
path = Processes;
|
||||
@@ -374,9 +398,12 @@
|
||||
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 */,
|
||||
B02EE645434EF31AAE82825E /* ProcessTableTests.swift in Sources */,
|
||||
8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */,
|
||||
802A6F368A475126C1349A03 /* ThermalMathTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -404,6 +431,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 */,
|
||||
@@ -416,11 +444,15 @@
|
||||
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 */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -441,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;
|
||||
@@ -544,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.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -8,7 +8,9 @@ struct ProcessItem: Identifiable, Hashable {
|
||||
let user: String
|
||||
let cpuUsage: Double // Per-core: 100% = 1 core fully utilized
|
||||
let cpuUsageTotal: Double // Normalized: 100% = all cores fully utilized
|
||||
let memoryUsage: Int64
|
||||
let memoryUsage: Int64 // physical footprint (Activity Monitor's "Memory")
|
||||
let residentMemory: Int64 // pages currently in physical RAM, shared ones included
|
||||
let compressedMemory: Int64 // original size of pages in the compressor — in RAM or swapped out
|
||||
let threadCount: Int32
|
||||
let state: ProcessState
|
||||
let icon: NSImage?
|
||||
@@ -42,7 +44,9 @@ struct ProcessItem: Identifiable, Hashable {
|
||||
diskReadBytes: UInt64 = 0,
|
||||
diskWriteBytes: UInt64 = 0,
|
||||
energyImpact: Double = 0,
|
||||
executablePath: String? = nil
|
||||
executablePath: String? = nil,
|
||||
residentMemory: Int64 = 0,
|
||||
compressedMemory: Int64 = 0
|
||||
) {
|
||||
self.id = pid
|
||||
self.pid = pid
|
||||
@@ -51,6 +55,8 @@ struct ProcessItem: Identifiable, Hashable {
|
||||
self.cpuUsage = cpuUsage
|
||||
self.cpuUsageTotal = cpuUsageTotal
|
||||
self.memoryUsage = memoryUsage
|
||||
self.residentMemory = residentMemory
|
||||
self.compressedMemory = compressedMemory
|
||||
self.threadCount = threadCount
|
||||
self.state = state
|
||||
self.icon = icon
|
||||
@@ -73,6 +79,8 @@ struct ProcessItem: Identifiable, Hashable {
|
||||
lhs.cpuUsage == rhs.cpuUsage &&
|
||||
lhs.cpuUsageTotal == rhs.cpuUsageTotal &&
|
||||
lhs.memoryUsage == rhs.memoryUsage &&
|
||||
lhs.residentMemory == rhs.residentMemory &&
|
||||
lhs.compressedMemory == rhs.compressedMemory &&
|
||||
lhs.threadCount == rhs.threadCount &&
|
||||
lhs.state == rhs.state &&
|
||||
lhs.diskReadRate == rhs.diskReadRate &&
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,33 @@ 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,
|
||||
resident: rusageData.resident,
|
||||
compressed: fetchCompressedMemory(pid: pid) ?? 0,
|
||||
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,12 +150,14 @@ final class ProcessMonitor {
|
||||
icon: icon,
|
||||
parentPid: parentPid,
|
||||
startTime: startTime,
|
||||
diskReadRate: diskReadRate,
|
||||
diskWriteRate: diskWriteRate,
|
||||
diskReadBytes: diskReadBytes,
|
||||
diskWriteBytes: diskWriteBytes,
|
||||
energyImpact: energyImpact,
|
||||
executablePath: pathCache[pid]
|
||||
diskReadRate: detail.diskReadRate,
|
||||
diskWriteRate: detail.diskWriteRate,
|
||||
diskReadBytes: detail.diskReadBytes,
|
||||
diskWriteBytes: detail.diskWriteBytes,
|
||||
energyImpact: detail.energy,
|
||||
executablePath: pathCache[pid],
|
||||
residentMemory: detail.resident,
|
||||
compressedMemory: detail.compressed
|
||||
)
|
||||
}
|
||||
|
||||
@@ -230,8 +235,27 @@ final class ProcessMonitor {
|
||||
return name
|
||||
}
|
||||
|
||||
/// The expensive per-process readings (rusage + task info + derived rates).
|
||||
private struct DetailedSample {
|
||||
var memory: Int64
|
||||
var resident: Int64
|
||||
var compressed: 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, resident: 0, compressed: 0, threads: 0, cpu: 0,
|
||||
diskReadRate: 0, diskWriteRate: 0,
|
||||
diskReadBytes: 0, diskWriteBytes: 0, energy: 0)
|
||||
}
|
||||
|
||||
private struct RusageData {
|
||||
var memory: Int64 = 0
|
||||
var resident: Int64 = 0
|
||||
var userTime: UInt64 = 0
|
||||
var systemTime: UInt64 = 0
|
||||
var diskRead: UInt64 = 0
|
||||
@@ -250,6 +274,7 @@ final class ProcessMonitor {
|
||||
if result == 0 {
|
||||
return RusageData(
|
||||
memory: Int64(rusage.ri_phys_footprint),
|
||||
resident: Int64(rusage.ri_resident_size),
|
||||
userTime: rusage.ri_user_time,
|
||||
systemTime: rusage.ri_system_time,
|
||||
diskRead: rusage.ri_diskio_bytesread,
|
||||
@@ -264,6 +289,7 @@ final class ProcessMonitor {
|
||||
if proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, taskInfoSize) == taskInfoSize {
|
||||
return RusageData(
|
||||
memory: Int64(taskInfo.pti_resident_size),
|
||||
resident: Int64(taskInfo.pti_resident_size),
|
||||
userTime: taskInfo.pti_total_user,
|
||||
systemTime: taskInfo.pti_total_system
|
||||
)
|
||||
@@ -475,6 +501,29 @@ func fetchProcessArguments(pid: pid_t) -> [String] {
|
||||
return ProcArgs.parse(Array(buffer.prefix(size)))?.args ?? []
|
||||
}
|
||||
|
||||
/// Memory of a process currently held by the compressor, at its original
|
||||
/// (uncompressed) size. macOS does not track swap per process: when compressor
|
||||
/// segments are paged out to disk they stay counted here, so this is the
|
||||
/// closest per-process "swapped" figure that exists — Activity Monitor's
|
||||
/// "Compressed Memory" and `top`'s CMPRS show the same value.
|
||||
///
|
||||
/// Needs only a task *name* port, which the kernel hands out for processes of
|
||||
/// the same user; returns `nil` for other users' and system processes.
|
||||
func fetchCompressedMemory(pid: pid_t) -> Int64? {
|
||||
var port: mach_port_name_t = 0
|
||||
guard task_name_for_pid(mach_task_self_, pid, &port) == KERN_SUCCESS else { return nil }
|
||||
defer { mach_port_deallocate(mach_task_self_, port) }
|
||||
|
||||
var info = task_vm_info_data_t()
|
||||
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.stride / MemoryLayout<natural_t>.stride)
|
||||
let result = withUnsafeMutablePointer(to: &info) {
|
||||
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
||||
task_info(port, task_flavor_t(TASK_VM_INFO), $0, &count)
|
||||
}
|
||||
}
|
||||
return result == KERN_SUCCESS ? Int64(info.compressed) : nil
|
||||
}
|
||||
|
||||
/// Counts open file descriptors for a process (files, sockets, pipes, …).
|
||||
func fetchOpenFileCount(pid: pid_t) -> Int? {
|
||||
let bufferSize = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 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] = []
|
||||
@@ -14,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
|
||||
@@ -22,6 +24,25 @@ 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 thermalInfo: ThermalInfo?
|
||||
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()
|
||||
@@ -30,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?
|
||||
@@ -47,6 +69,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 }
|
||||
@@ -66,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,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
|
||||
@@ -126,103 +153,114 @@ 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 {
|
||||
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 = thermal { live.thermalInfo = 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
|
||||
thermalInfo = live.thermalInfo
|
||||
cpuHistory = live.cpuHistory
|
||||
coreHistories = live.coreHistories
|
||||
memoryHistory = live.memoryHistory
|
||||
networkHistory = live.networkHistory
|
||||
}
|
||||
|
||||
private func currentThermalLevel() -> ThermalLevel {
|
||||
switch ProcessInfo.processInfo.thermalState {
|
||||
case .nominal: return .nominal
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -76,6 +77,8 @@ struct ProcessInspectorSheet: View {
|
||||
DetailRow(label: "CPU (total)", value: String(format: "%.2f%%", process.cpuUsageTotal))
|
||||
DetailRow(label: "Energy", value: String(format: "%.1f", process.energyImpact))
|
||||
DetailRow(label: "Memory", value: formatBytes(process.memoryUsage))
|
||||
DetailRow(label: "RAM", value: formatBytes(process.residentMemory))
|
||||
DetailRow(label: "Compressed", value: process.compressedMemory > 0 ? formatBytes(process.compressedMemory) : "—")
|
||||
DetailRow(label: "Threads", value: "\(process.threadCount)")
|
||||
DetailRow(label: "Open files", value: openFiles.map(String.init) ?? "…")
|
||||
DetailRow(label: "Disk read/s", value: process.diskReadRate > 0 ? formatBytesPerSecond(process.diskReadRate) : "—")
|
||||
@@ -88,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)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
enum ProcessSortColumn: String {
|
||||
case name, pid, cpu, cpuTotal, memory, threads, user, state, energy, disk
|
||||
case name, pid, cpu, cpuTotal, memory, resident, compressed, threads, user, state, energy, disk
|
||||
}
|
||||
|
||||
struct ProcessView: View {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
displayedProcesses = filtered.sorted { lhs, rhs in
|
||||
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)
|
||||
}
|
||||
|
||||
return filtered.sorted { lhs, rhs in
|
||||
let comparison: ComparisonResult
|
||||
switch sortColumn {
|
||||
case .name:
|
||||
@@ -60,6 +63,10 @@ struct ProcessView: View {
|
||||
comparison = lhs.cpuUsageTotal < rhs.cpuUsageTotal ? .orderedAscending : (lhs.cpuUsageTotal > rhs.cpuUsageTotal ? .orderedDescending : .orderedSame)
|
||||
case .memory:
|
||||
comparison = lhs.memoryUsage < rhs.memoryUsage ? .orderedAscending : (lhs.memoryUsage > rhs.memoryUsage ? .orderedDescending : .orderedSame)
|
||||
case .resident:
|
||||
comparison = lhs.residentMemory < rhs.residentMemory ? .orderedAscending : (lhs.residentMemory > rhs.residentMemory ? .orderedDescending : .orderedSame)
|
||||
case .compressed:
|
||||
comparison = lhs.compressedMemory < rhs.compressedMemory ? .orderedAscending : (lhs.compressedMemory > rhs.compressedMemory ? .orderedDescending : .orderedSame)
|
||||
case .threads:
|
||||
comparison = lhs.threadCount < rhs.threadCount ? .orderedAscending : (lhs.threadCount > rhs.threadCount ? .orderedDescending : .orderedSame)
|
||||
case .user:
|
||||
@@ -82,185 +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 }
|
||||
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)
|
||||
|
||||
TableColumn("Memory", value: \.memoryUsage) { process in
|
||||
Text(formatBytes(process.memoryUsage))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(80)
|
||||
|
||||
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("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
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -312,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 {
|
||||
@@ -323,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) {
|
||||
@@ -369,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 {
|
||||
|
||||
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,37 @@ 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")
|
||||
let zeroRAM = own.filter { $0.residentMemory == 0 }.count
|
||||
XCTAssertLessThan(zeroRAM, max(3, own.count / 10),
|
||||
"fetch \(fetch): \(zeroRAM) of \(own.count) own processes reported 0 RAM")
|
||||
}
|
||||
}
|
||||
|
||||
func testOwnProcessReportsResidentMemory() {
|
||||
let item = ProcessMonitor().fetchProcesses().first { $0.pid == getpid() }
|
||||
XCTAssertNotNil(item)
|
||||
XCTAssertGreaterThan(item?.residentMemory ?? 0, 0)
|
||||
XCTAssertGreaterThan(item?.memoryUsage ?? 0, 0)
|
||||
}
|
||||
|
||||
/// A task name port is granted for our own processes only; launchd runs as root.
|
||||
func testCompressedMemoryReadableForOwnProcessesOnly() {
|
||||
XCTAssertNotNil(fetchCompressedMemory(pid: getpid()))
|
||||
XCTAssertNil(fetchCompressedMemory(pid: 1))
|
||||
}
|
||||
}
|
||||
|
||||
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