Commitok összehasonlítása

...

3 Commit-ok

Szerző SHA1 Üzenet Dátum
hariel1985
9810987155 Bump version to 1.2.0 (4)
Adds the CPU temperature on Apple Silicon and replaces the process table
with an NSTableView, which fixes the memory growth noted as a known issue
in 1.1.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:08:58 +02:00
hariel1985
c50443bdbc Draw the process list with NSTableView instead of SwiftUI's Table
On macOS 26 SwiftUI's Table retained AutoLayout and view-graph objects on
every data update, on top of an NSHostingView per cell: tens of MB an hour
while the window was visible. 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 are reused AppKit views and an update rewrites only the changed
fields of rows on screen. Column widths and order are remembered; the
context menu, double-click, Delete key and sort directions match the old
table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:06:18 +02:00
hariel1985
15c8b18b48 Show the CPU temperature on Apple Silicon
Power & Storage only had macOS's thermal state (a level, no degrees) and
the battery temperature. The CPU die sensors are now read through
IOHIDEventSystemClient (no root or entitlement needed outside the
sandbox): "PMU tdie*" from M2 on, "pACC/eACC MTR Temp Sensor*" on M1.
System Status shows the hottest one, with the average and sensor count in
the tooltip, coloured from 80/90/100 °C. Intel Macs have no such sensors
and the row stays hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:51:42 +02:00
11 fájl változott, egészen pontosan 1043 új sor hozzáadva és 268 régi sor törölve

Fájl megtekintése

@@ -5,11 +5,11 @@ A native macOS system monitor application built with SwiftUI. TopManager provide
![macOS](https://img.shields.io/badge/macOS-13.0+-blue) ![macOS](https://img.shields.io/badge/macOS-13.0+-blue)
![Swift](https://img.shields.io/badge/Swift-5.9-orange) ![Swift](https://img.shields.io/badge/Swift-5.9-orange)
![License](https://img.shields.io/badge/License-GPL--3.0-green) ![License](https://img.shields.io/badge/License-GPL--3.0-green)
![Release](https://img.shields.io/badge/release-v1.1.1-brightgreen) ![Release](https://img.shields.io/badge/release-v1.2.0-brightgreen)
## Download ## Download
**[⬇️ Download TopManager 1.1.1 (.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 Signed with a Developer ID and **notarized by Apple** — it opens without Gatekeeper
warnings. Open the DMG and drag TopManager to your Applications folder. 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 - **Recent Alerts** inbox
- **Battery & Power**: charge, health (cycle-adjusted), cycle count, condition, - **Battery & Power**: charge, health (cycle-adjusted), cycle count, condition,
temperature, power draw, time remaining, adapter wattage (via IOKit) 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 - CPU and GPU core counts
- GPU memory/VRAM usage - GPU memory/VRAM usage
- Storage volumes with usage bars - Storage volumes with usage bars

Fájl megtekintése

@@ -11,6 +11,7 @@
0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B6F97E6EF65AA17A469F037 /* ProcessView.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 */; }; 136DAD1FE90FD682DF666667 /* TopManager_icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 8E4A6E8E2305CEDC119AC6BC /* TopManager_icon.png */; };
14D77C268F9F701E612E4FD3 /* WindowVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */; }; 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 */; }; 1F212CE158B7D14B29077B18 /* CPUInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44281079654C7E1C9E16806C /* CPUInfo.swift */; };
1F25A84B560709AE71C27D23 /* GPUInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F481045B4FC6017F50849FF1 /* GPUInfo.swift */; }; 1F25A84B560709AE71C27D23 /* GPUInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F481045B4FC6017F50849FF1 /* GPUInfo.swift */; };
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */; }; 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */; };
@@ -23,12 +24,14 @@
489E29F78A4093D3548673E6 /* HealthAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */; }; 489E29F78A4093D3548673E6 /* HealthAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */; };
49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301352EB5B362C732CE1A246 /* FormattingTests.swift */; }; 49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301352EB5B362C732CE1A246 /* FormattingTests.swift */; };
501C13D57386C179C0B3B14B /* PowerStorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6038CCDF3BDF0F8BC93C9D4 /* PowerStorageView.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 */; }; 67F66E7EC7C01A6309DC060E /* AlertCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2C96471F109FF1C20ABF59E /* AlertCenter.swift */; };
6A0810422D2EC8F9F5D191C4 /* HealthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126A382E2755C5061E6D29F5 /* HealthView.swift */; }; 6A0810422D2EC8F9F5D191C4 /* HealthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126A382E2755C5061E6D29F5 /* HealthView.swift */; };
744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */; }; 744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */; };
79E837AE662021C521F7E74F /* HealthModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C70E54E350F7C8C22D342594 /* HealthModel.swift */; }; 79E837AE662021C521F7E74F /* HealthModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C70E54E350F7C8C22D342594 /* HealthModel.swift */; };
7A1144E43159D96564C13164 /* MemoryInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */; }; 7A1144E43159D96564C13164 /* MemoryInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */; };
7A6088469A2643D360B35B89 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892247B0F17363DFC146EDF8 /* AppSettings.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 */; }; 833976FD8DC4CCC45078CF9A /* DiskMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */; };
8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01C19ABF49697A97F0C40378 /* SettingsTests.swift */; }; 8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01C19ABF49697A97F0C40378 /* SettingsTests.swift */; };
8F9ED99AB6D8D90AE79B5179 /* DiskUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */; }; 8F9ED99AB6D8D90AE79B5179 /* DiskUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */; };
@@ -38,11 +41,13 @@
A7EC9FB55D5088D669491C11 /* AppsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813543165B39EBD213EB3BC7 /* AppsView.swift */; }; A7EC9FB55D5088D669491C11 /* AppsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813543165B39EBD213EB3BC7 /* AppsView.swift */; };
AE29356C1D41F33A3E94B6B7 /* MenuBarStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B37A9AE9F6215C0C132B8D9 /* MenuBarStatus.swift */; }; AE29356C1D41F33A3E94B6B7 /* MenuBarStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B37A9AE9F6215C0C132B8D9 /* MenuBarStatus.swift */; };
B0165358B483B0ECC28DB4DE /* PowerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C512B13DC0C1024CF39A5E /* PowerInfo.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 */; }; B62B18DF32A8734D5908B16B /* DiskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352CA1BE2A3B43643A9DBD72 /* DiskInfo.swift */; };
B89695749D32F394DB459C15 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AACBF14FFC5EC68C9E438040 /* SettingsView.swift */; }; B89695749D32F394DB459C15 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AACBF14FFC5EC68C9E438040 /* SettingsView.swift */; };
BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */; }; BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */; };
C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110A314A63AE355A9E04990D /* ProcessDetailView.swift */; }; C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110A314A63AE355A9E04990D /* ProcessDetailView.swift */; };
CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFADE83965AA863B775A910 /* NetworkChartView.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 */; }; D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A404EBD588B7ACD36079A95D /* TopManagerApp.swift */; };
D9E882888E23C26B748E22CF /* PowerMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */; }; D9E882888E23C26B748E22CF /* PowerMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */; };
DA03FF0D25DB8B9D3B09BC82 /* MenuBarStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */; }; DA03FF0D25DB8B9D3B09BC82 /* MenuBarStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */; };
@@ -76,6 +81,8 @@
110A314A63AE355A9E04990D /* ProcessDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessDetailView.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryMonitor.swift; sourceTree = "<group>"; };
@@ -92,9 +99,11 @@
51C512B13DC0C1024CF39A5E /* PowerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerInfo.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 892247B0F17363DFC146EDF8 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
@@ -120,6 +129,7 @@
E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthAlertTests.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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 */ /* End PBXFileReference section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@@ -138,6 +148,7 @@
95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */, 95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */,
295232B4C746511757DCF8FE /* ProcessMonitor.swift */, 295232B4C746511757DCF8FE /* ProcessMonitor.swift */,
279B972C08A878053821285D /* SystemMonitor.swift */, 279B972C08A878053821285D /* SystemMonitor.swift */,
19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */,
8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */, 8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */,
); );
path = Services; path = Services;
@@ -164,6 +175,7 @@
3010F605712E09175D9D8878 /* NetworkInfo.swift */, 3010F605712E09175D9D8878 /* NetworkInfo.swift */,
51C512B13DC0C1024CF39A5E /* PowerInfo.swift */, 51C512B13DC0C1024CF39A5E /* PowerInfo.swift */,
4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */, 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */,
7A81F77752CFF49D5038B06E /* ThermalInfo.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -262,7 +274,9 @@
8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */, 8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */,
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */, 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */,
332290DABD5326C87258723C /* ProcessDataTests.swift */, 332290DABD5326C87258723C /* ProcessDataTests.swift */,
61F822BA26055B319B7D13F8 /* ProcessTableTests.swift */,
01C19ABF49697A97F0C40378 /* SettingsTests.swift */, 01C19ABF49697A97F0C40378 /* SettingsTests.swift */,
FD9632A456D316A019CFA313 /* ThermalMathTests.swift */,
); );
path = TopManagerTests; path = TopManagerTests;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -280,6 +294,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
110A314A63AE355A9E04990D /* ProcessDetailView.swift */, 110A314A63AE355A9E04990D /* ProcessDetailView.swift */,
19ADFC64E855FBDCDAF600E5 /* ProcessTableView.swift */,
7B6F97E6EF65AA17A469F037 /* ProcessView.swift */, 7B6F97E6EF65AA17A469F037 /* ProcessView.swift */,
); );
path = Processes; path = Processes;
@@ -386,7 +401,9 @@
DA03FF0D25DB8B9D3B09BC82 /* MenuBarStatusTests.swift in Sources */, DA03FF0D25DB8B9D3B09BC82 /* MenuBarStatusTests.swift in Sources */,
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */, 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */,
2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */, 2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */,
B02EE645434EF31AAE82825E /* ProcessTableTests.swift in Sources */,
8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */, 8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */,
802A6F368A475126C1349A03 /* ThermalMathTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -427,9 +444,12 @@
C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */, C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */,
744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */, 744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */,
9146403BC02D18A234444DF6 /* ProcessMonitor.swift in Sources */, 9146403BC02D18A234444DF6 /* ProcessMonitor.swift in Sources */,
1929475349F9A25B0778DF9B /* ProcessTableView.swift in Sources */,
0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */, 0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */,
B89695749D32F394DB459C15 /* SettingsView.swift in Sources */, B89695749D32F394DB459C15 /* SettingsView.swift in Sources */,
DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */, DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */,
5DB015294ABB26FF14B1D432 /* TemperatureMonitor.swift in Sources */,
CDA14A3B28EFF15D004D714F /* ThermalInfo.swift in Sources */,
D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */, D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */,
9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */, 9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */,
14D77C268F9F701E612E4FD3 /* WindowVisibility.swift in Sources */, 14D77C268F9F701E612E4FD3 /* WindowVisibility.swift in Sources */,
@@ -453,14 +473,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = TopManager/TopManager.entitlements; CODE_SIGN_ENTITLEMENTS = TopManager/TopManager.entitlements;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 4;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TopManager/Info.plist; INFOPLIST_FILE = TopManager/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.1.1; MARKETING_VERSION = 1.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.app; PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.app;
PRODUCT_NAME = TopManager; PRODUCT_NAME = TopManager;
SDKROOT = macosx; SDKROOT = macosx;
@@ -558,14 +578,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = TopManager/TopManager.entitlements; CODE_SIGN_ENTITLEMENTS = TopManager/TopManager.entitlements;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 3; CURRENT_PROJECT_VERSION = 4;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = TopManager/Info.plist; INFOPLIST_FILE = TopManager/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.1.1; MARKETING_VERSION = 1.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.app; PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.app;
PRODUCT_NAME = TopManager; PRODUCT_NAME = TopManager;
SDKROOT = macosx; SDKROOT = macosx;

Fájl megtekintése

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

Fájl megtekintése

@@ -15,6 +15,7 @@ final class SystemMonitor: ObservableObject {
@MainActor @Published var networkInfo: NetworkInfo? @MainActor @Published var networkInfo: NetworkInfo?
@MainActor @Published var gpuInfo: GPUInfo? @MainActor @Published var gpuInfo: GPUInfo?
@MainActor @Published var powerInfo: PowerInfo? @MainActor @Published var powerInfo: PowerInfo?
@MainActor @Published var thermalInfo: ThermalInfo?
@MainActor @Published var lastError: String? @MainActor @Published var lastError: String?
// History for charts // History for charts
@@ -33,6 +34,7 @@ final class SystemMonitor: ObservableObject {
var networkInfo: NetworkInfo? var networkInfo: NetworkInfo?
var gpuInfo: GPUInfo? var gpuInfo: GPUInfo?
var powerInfo: PowerInfo? var powerInfo: PowerInfo?
var thermalInfo: ThermalInfo?
var cpuHistory: [CPUHistoryPoint] = [] var cpuHistory: [CPUHistoryPoint] = []
var coreHistories: [Int: [CoreHistoryPoint]] = [:] var coreHistories: [Int: [CoreHistoryPoint]] = [:]
var memoryHistory: [MemoryHistoryPoint] = [] var memoryHistory: [MemoryHistoryPoint] = []
@@ -49,6 +51,7 @@ final class SystemMonitor: ObservableObject {
private let networkMonitor = NetworkMonitor() private let networkMonitor = NetworkMonitor()
private let gpuMonitor = GPUMonitor() private let gpuMonitor = GPUMonitor()
private let powerMonitor = PowerMonitor() private let powerMonitor = PowerMonitor()
private let temperatureMonitor = TemperatureMonitor()
// Timer // Timer
private var timer: Timer? private var timer: Timer?
@@ -87,10 +90,12 @@ final class SystemMonitor: ObservableObject {
let diskData = self.diskMonitor.fetchDiskInfo() let diskData = self.diskMonitor.fetchDiskInfo()
let gpuData = self.gpuMonitor.fetchGPUInfo() let gpuData = self.gpuMonitor.fetchGPUInfo()
let powerData = self.powerMonitor.fetchPowerInfo() let powerData = self.powerMonitor.fetchPowerInfo()
let thermalData = self.temperatureMonitor.fetchThermalInfo()
DispatchQueue.main.async { DispatchQueue.main.async {
self.updateUI(cpu: cpuData, memory: memData, network: netData, self.updateUI(cpu: cpuData, memory: memData, network: netData,
processes: processData, disk: diskData, gpu: gpuData, power: powerData) processes: processData, disk: diskData, gpu: gpuData, power: powerData,
thermal: thermalData)
} }
} }
@@ -127,6 +132,7 @@ final class SystemMonitor: ObservableObject {
let cpuData = cpuMonitor.fetchCPUInfo() let cpuData = cpuMonitor.fetchCPUInfo()
let memData = memoryMonitor.fetchMemoryInfo() let memData = memoryMonitor.fetchMemoryInfo()
let netData = networkMonitor.fetchNetworkInfo() let netData = networkMonitor.fetchNetworkInfo()
let thermalData = temperatureMonitor.fetchThermalInfo()
// Fetch processes: first 3 cycles always, then every other cycle // Fetch processes: first 3 cycles always, then every other cycle
var processData: [ProcessItem]? = nil var processData: [ProcessItem]? = nil
@@ -147,13 +153,14 @@ final class SystemMonitor: ObservableObject {
// Update UI on main thread // Update UI on main thread
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.updateUI(cpu: cpuData, memory: memData, network: netData, 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?, @MainActor private func updateUI(cpu: CPUInfo?, memory: MemoryInfo?, network: NetworkInfo?,
processes: [ProcessItem]?, disk: DiskInfo?, gpu: GPUInfo?, processes: [ProcessItem]?, disk: DiskInfo?, gpu: GPUInfo?,
power: PowerInfo?) { power: PowerInfo?, thermal: ThermalInfo?) {
if let info = cpu { if let info = cpu {
live.cpuInfo = info live.cpuInfo = info
live.cpuHistory.append(CPUHistoryPoint( live.cpuHistory.append(CPUHistoryPoint(
@@ -203,6 +210,7 @@ final class SystemMonitor: ObservableObject {
if let info = disk { live.diskInfo = info } if let info = disk { live.diskInfo = info }
if let info = gpu { live.gpuInfo = info } if let info = gpu { live.gpuInfo = info }
if let info = power { live.powerInfo = info } if let info = power { live.powerInfo = info }
if let info = thermal { live.thermalInfo = info }
if isUIVisible { publishLive() } if isUIVisible { publishLive() }
MenuBarStatus.shared.update(cpu: live.cpuInfo, memory: live.memoryInfo, network: live.networkInfo) MenuBarStatus.shared.update(cpu: live.cpuInfo, memory: live.memoryInfo, network: live.networkInfo)
@@ -246,6 +254,7 @@ final class SystemMonitor: ObservableObject {
networkInfo = live.networkInfo networkInfo = live.networkInfo
gpuInfo = live.gpuInfo gpuInfo = live.gpuInfo
powerInfo = live.powerInfo powerInfo = live.powerInfo
thermalInfo = live.thermalInfo
cpuHistory = live.cpuHistory cpuHistory = live.cpuHistory
coreHistories = live.coreHistories coreHistories = live.coreHistories
memoryHistory = live.memoryHistory memoryHistory = live.memoryHistory

Fájl megtekintése

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

Fájl megtekintése

@@ -37,6 +37,18 @@ struct PowerStorageView: View {
ThermalStateView(state: monitor.thermalState) 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 { if let cpu = monitor.cpuInfo {
HStack { HStack {
Text("CPU Usage:") Text("CPU Usage:")
@@ -190,6 +202,15 @@ struct PowerStorageView: View {
.padding() .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 { struct BatteryStatusView: View {

Fájl megtekintése

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

Fájl megtekintése

@@ -10,12 +10,6 @@ struct ProcessView: View {
@State private var selectedProcess: Set<ProcessItem.ID> = [] @State private var selectedProcess: Set<ProcessItem.ID> = []
@State private var sortColumn: ProcessSortColumn = .cpu @State private var sortColumn: ProcessSortColumn = .cpu
@State private var sortAscending: Bool = false @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 // Confirmation dialogs
@State private var showTerminateConfirm = false @State private var showTerminateConfirm = false
@@ -40,14 +34,23 @@ struct ProcessView: View {
Self.protectedProcesses.contains(name) Self.protectedProcesses.contains(name)
} }
private func updateDisplayedProcesses() { /// The rows on screen, derived from current state on every render. This used
let filtered = monitor.processes.filter { /// to be cached in @State and refreshed from `onChange` actions, which can read
searchText.isEmpty || /// an earlier render's sort and search values — a data refresh right after a
$0.name.localizedCaseInsensitiveContains(searchText) || /// header click could put the table back into the previous order.
String($0.pid).contains(searchText) 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 let comparison: ComparisonResult
switch sortColumn { switch sortColumn {
case .name: case .name:
@@ -86,213 +89,39 @@ struct ProcessView: View {
} }
} }
/// Looked up among the rows on screen, so a selection hidden by the search
/// filter can't be terminated by accident.
var selectedProcessItem: ProcessItem? { var selectedProcessItem: ProcessItem? {
guard let pid = selectedProcess.first else { return nil } guard let pid = selectedProcess.first else { return nil }
return monitor.processes.first { $0.pid == pid } return displayedProcesses.first { $0.pid == pid }
}
/// Memory, RAM and Compressed as a single builder element: Table accepts at
/// most 10 columns per block, and inlining them also exceeds the type
/// checker's time limit for the table expression.
@TableColumnBuilder<ProcessItem, KeyPathComparator<ProcessItem>>
private var memoryColumns: some TableColumnContent<ProcessItem, KeyPathComparator<ProcessItem>> {
TableColumn("Memory", value: \.memoryUsage) { process in
Text(formatBytes(process.memoryUsage))
.monospacedDigit()
}
.width(80)
TableColumn("RAM", value: \.residentMemory) { process in
Text(formatBytes(process.residentMemory))
.monospacedDigit()
}
.width(80)
TableColumn("Compressed", value: \.compressedMemory) { process in
if process.compressedMemory > 0 {
Text(formatBytes(process.compressedMemory))
.monospacedDigit()
} else {
Text("—").foregroundColor(.secondary)
}
}
.width(85)
} }
var body: some View { var body: some View {
let rows = displayedProcesses
VStack(spacing: 0) { VStack(spacing: 0) {
// Summary bar - separate view to avoid re-rendering table // Summary bar - separate view to avoid re-rendering table
ProcessSummaryBar(processCount: displayedProcesses.count, searchText: $searchText) ProcessSummaryBar(processCount: rows.count, searchText: $searchText)
Divider() Divider()
// Process table ProcessTableView(
Table(displayedProcesses, selection: $selectedProcess, sortOrder: $sortOrder) { processes: rows,
TableColumn("Name", value: \.name) { process in selection: $selectedProcess,
HStack(spacing: 6) { sortColumn: sortColumn,
if let icon = process.icon { sortAscending: sortAscending,
Image(nsImage: icon) onSort: { column, ascending in
.resizable() sortColumn = column
.frame(width: 16, height: 16)
} else {
Image(systemName: "app.dashed")
.frame(width: 16, height: 16)
.foregroundColor(.secondary)
}
Text(process.name)
.lineLimit(1)
}
}
.width(min: 170, ideal: 220)
TableColumn("PID", value: \.pid) { process in
Text("\(process.pid)")
.monospacedDigit()
}
.width(60)
TableColumn("CPU/Core", value: \.cpuUsage) { process in
Text(String(format: "%.1f%%", process.cpuUsage))
.monospacedDigit()
.foregroundColor(cpuColor(process.cpuUsage))
}
.width(70)
TableColumn("CPU/Total", value: \.cpuUsageTotal) { process in
Text(String(format: "%.2f%%", process.cpuUsageTotal))
.monospacedDigit()
.foregroundColor(cpuColorTotal(process.cpuUsageTotal))
}
.width(70)
TableColumn("Energy", value: \.energyImpact) { process in
Text(String(format: "%.1f", process.energyImpact))
.monospacedDigit()
.foregroundColor(energyColor(process.energyImpact))
}
.width(60)
memoryColumns
TableColumn("Disk I/O", value: \.diskTotalRate) { process in
if process.diskTotalRate > 0 {
Text(formatBytesPerSecond(process.diskTotalRate))
.monospacedDigit()
} else {
Text("—").foregroundColor(.secondary)
}
}
.width(90)
TableColumn("Threads", value: \.threadCount) { process in
Text("\(process.threadCount)")
.monospacedDigit()
}
.width(60)
TableColumn("User", value: \.user) { process in
Text(process.user)
.lineLimit(1)
}
.width(80)
TableColumn("State", value: \.state.rawValue) { process in
HStack(spacing: 4) {
Image(systemName: process.state.symbol)
.foregroundColor(stateColor(process.state))
Text(process.state.rawValue)
}
}
.width(90)
}
.contextMenu(forSelectionType: ProcessItem.ID.self) { selection in
if let pid = selection.first,
let process = monitor.processes.first(where: { $0.pid == pid }) {
Button("Get Info (⌘I)") {
inspectorProcess = process
}
Divider()
Button("Terminate (⌫)") {
initiateTerminate()
}
Button("Force Kill (⌘⌫)") {
initiateForceKill()
}
Divider()
Button("Suspend") {
performSuspend(process: process)
}
Button("Resume") {
performResume(process: process)
}
Divider()
Button("Copy PID") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString("\(pid)", forType: .string)
}
}
} primaryAction: { selection in
// Double-click opens the deep-dive inspector
if let pid = selection.first,
let process = monitor.processes.first(where: { $0.pid == pid }) {
inspectorProcess = process
}
}
.onDeleteCommand {
initiateTerminate()
}
}
.onAppear {
updateDisplayedProcesses()
}
.onChange(of: monitor.processes) { _ in
updateDisplayedProcesses()
}
.onChange(of: searchText) { _ in
updateDisplayedProcesses()
}
.onChange(of: sortColumn) { _ in
updateDisplayedProcesses()
}
.onChange(of: sortAscending) { _ in
updateDisplayedProcesses()
}
.onChange(of: sortOrder) { newOrder in
guard let comparator = newOrder.first else { return }
// Detect which column and direction from the KeyPathComparator
let ascending = comparator.order == .forward
// Use string representation of keypath to determine column
let keyPathString = String(describing: comparator)
if keyPathString.contains("cpuUsageTotal") {
sortColumn = .cpuTotal
} else if keyPathString.contains("cpuUsage") {
sortColumn = .cpu
} else if keyPathString.contains("energyImpact") {
sortColumn = .energy
} else if keyPathString.contains("diskTotalRate") {
sortColumn = .disk
} else if keyPathString.contains("residentMemory") {
sortColumn = .resident
} else if keyPathString.contains("compressedMemory") {
sortColumn = .compressed
} else if keyPathString.contains("memoryUsage") {
sortColumn = .memory
} else if keyPathString.contains("name") {
sortColumn = .name
} else if keyPathString.contains("pid") {
sortColumn = .pid
} else if keyPathString.contains("threadCount") {
sortColumn = .threads
} else if keyPathString.contains("user") {
sortColumn = .user
} else if keyPathString.contains("state") {
sortColumn = .state
}
sortAscending = ascending sortAscending = ascending
},
actions: ProcessTableActions(
open: { inspectorProcess = $0 },
terminate: { initiateTerminate($0) },
forceKill: { initiateForceKill($0) },
suspend: { performSuspend(process: $0) },
resume: { performResume(process: $0) },
deleteKey: { initiateTerminate() }
)
)
} }
.background( .background(
KeyboardShortcutHandler( KeyboardShortcutHandler(
@@ -344,8 +173,8 @@ struct ProcessView: View {
} }
} }
private func initiateTerminate() { private func initiateTerminate(_ target: ProcessItem? = nil) {
guard let process = selectedProcessItem else { return } guard let process = target ?? selectedProcessItem else { return }
processToKill = process processToKill = process
if skipTerminateConfirm { if skipTerminateConfirm {
@@ -355,8 +184,8 @@ struct ProcessView: View {
} }
} }
private func initiateForceKill() { private func initiateForceKill(_ target: ProcessItem? = nil) {
guard let process = selectedProcessItem else { return } guard let process = target ?? selectedProcessItem else { return }
// SAFETY: Prevent force-killing critical system processes // SAFETY: Prevent force-killing critical system processes
if isProtectedProcess(process.name) { if isProtectedProcess(process.name) {
@@ -401,49 +230,6 @@ struct ProcessView: View {
showErrorAlert = true 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 { struct ConfirmationSheet: View {

Fájl megtekintése

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

Fájl megtekintése

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

Fájl megtekintése

@@ -33,8 +33,8 @@ targets:
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
# The app version lives here. `xcodegen generate` rewrites Info.plist from # The app version lives here. `xcodegen generate` rewrites Info.plist from
# the `info` block below, so a version typed into Info.plist gets reset. # the `info` block below, so a version typed into Info.plist gets reset.
MARKETING_VERSION: "1.1.1" MARKETING_VERSION: "1.2.0"
CURRENT_PROJECT_VERSION: "3" CURRENT_PROJECT_VERSION: "4"
info: info:
path: TopManager/Info.plist path: TopManager/Info.plist
properties: properties: