Commitok összehasonlítása
24 Commit-ok
9292166223
...
v1.2.0
| Szerző | SHA1 | Dátum | |
|---|---|---|---|
|
|
9810987155 | ||
|
|
c50443bdbc | ||
|
|
15c8b18b48 | ||
|
|
2685ed9667 | ||
|
|
1767491076 | ||
|
|
cf88044582 | ||
|
|
e8a982c00e | ||
|
|
82b55c9468 | ||
|
|
14ced8d11d | ||
|
|
c9cd87f221 | ||
|
|
6f60dd7ad8 | ||
|
|
6a72c7ba64 | ||
|
|
eb52891a8c | ||
|
|
05139408a7 | ||
|
|
50480c2a97 | ||
|
|
186ae31d01 | ||
|
|
d4db0a7972 | ||
|
|
2e3574f6e3 | ||
|
|
bc494af363 | ||
|
|
9b92fb715a | ||
|
|
1390eda468 | ||
|
|
222638fc98 | ||
|
|
d59bf2b7b0 | ||
|
|
5ec09d9279 |
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# Xcode
|
# Xcode
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*/build/*
|
*/build/*
|
||||||
|
/build/
|
||||||
*.pbxuser
|
*.pbxuser
|
||||||
!default.pbxuser
|
!default.pbxuser
|
||||||
*.mode1v3
|
*.mode1v3
|
||||||
|
|||||||
53
README.md
@@ -5,6 +5,15 @@ A native macOS system monitor application built with SwiftUI. TopManager provide
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
|
## Download
|
||||||
|
|
||||||
|
**[⬇️ 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.
|
||||||
|
All [releases](https://github.com/hariel1985/TopManager/releases).
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@@ -29,10 +38,13 @@ A native macOS system monitor application built with SwiftUI. TopManager provide
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Processes Tab
|
### Processes Tab
|
||||||
- View all running processes with CPU, memory, and thread information
|
- View all running processes with CPU, memory, thread, **energy-impact**, and
|
||||||
- Sort by any column (name, PID, CPU%, memory, threads, user, state)
|
**per-process disk I/O** information
|
||||||
|
- Sort by any column (name, PID, CPU/Core, CPU/Total, Energy, memory, Disk I/O, threads, user, state)
|
||||||
- Process states: Running, Sleeping, Stopped, Zombie
|
- Process states: Running, Sleeping, Stopped, Zombie
|
||||||
- Context menu to terminate, force kill, suspend, or resume processes
|
- Context menu to terminate, force kill, suspend, or resume processes
|
||||||
|
- **Deep-dive inspector** (double-click / ⌘I): full executable path, launch
|
||||||
|
arguments, open-file count, disk totals, energy, parent PID, Reveal in Finder
|
||||||
- Search processes by name or PID
|
- Search processes by name or PID
|
||||||
|
|
||||||
### Apps Tab
|
### Apps Tab
|
||||||
@@ -43,17 +55,38 @@ A native macOS system monitor application built with SwiftUI. TopManager provide
|
|||||||
|
|
||||||
### Performance Tab
|
### Performance Tab
|
||||||
- Real-time CPU usage graphs (global and per-core)
|
- Real-time CPU usage graphs (global and per-core)
|
||||||
|
- **Selectable time range** (Live / 5m / 30m / 1h / 24h) backed by persistent history
|
||||||
- Memory usage visualization with donut chart
|
- Memory usage visualization with donut chart
|
||||||
- Network throughput monitoring
|
- Network throughput monitoring
|
||||||
- Support for Apple Silicon P-cores and E-cores
|
- Support for Apple Silicon P-cores and E-cores
|
||||||
|
|
||||||
### Power & Storage Tab
|
### Power & Storage Tab
|
||||||
- System status: macOS version, uptime, thermal state
|
- **System Health score (0–100)** with plain-language diagnosis of what's wrong
|
||||||
|
- **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, 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
|
||||||
- Network interface statistics
|
- Network interface statistics
|
||||||
|
|
||||||
|
### Proactive monitoring
|
||||||
|
- **Alerts engine** with native macOS notifications on sustained high CPU,
|
||||||
|
critical memory pressure, disk almost full, thermal throttling, runaway
|
||||||
|
processes, and low battery — de-duplicated so you're never spammed
|
||||||
|
- **Persistent metric history** stored to disk; trends survive restarts
|
||||||
|
|
||||||
|
### Menu Bar
|
||||||
|
- Configurable menu-bar metric (CPU % / Memory % / Health / Download)
|
||||||
|
- Dropdown with live CPU/memory/network/GPU/battery, System Health score, and
|
||||||
|
**Top CPU Consumers** with one-click quit
|
||||||
|
|
||||||
|
### Settings (⌘,)
|
||||||
|
- **Launch at login** (SMAppService)
|
||||||
|
- Adjustable refresh interval (1–30 s)
|
||||||
|
- Notification toggle and alert thresholds (CPU, disk, low battery)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- macOS 13.0 or later
|
- macOS 13.0 or later
|
||||||
@@ -74,10 +107,20 @@ A native macOS system monitor application built with SwiftUI. TopManager provide
|
|||||||
|
|
||||||
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
|
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Logic is covered by an XCTest suite (`TopManagerTests`, 55 tests) run via
|
||||||
|
`scripts/test.sh test`. The full manual + automated process is documented in
|
||||||
|
[docs/TESTING.md](docs/TESTING.md), and the product direction in
|
||||||
|
[docs/VALUE_PLAN.md](docs/VALUE_PLAN.md).
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
Built with SwiftUI and native macOS APIs including:
|
Built with SwiftUI and native macOS APIs including:
|
||||||
- `libproc` for process information
|
- `libproc` / `rusage_info` for process information, disk I/O and energy signals
|
||||||
- `IOKit` for GPU and hardware monitoring
|
- `IOKit` for GPU, battery (AppleSmartBattery) and hardware monitoring
|
||||||
- `Metal` for GPU detection
|
- `Metal` for GPU detection
|
||||||
- `SystemConfiguration` for network monitoring
|
- `SystemConfiguration` for network monitoring
|
||||||
|
- `UserNotifications` for proactive alerts
|
||||||
|
- `ServiceManagement` (SMAppService) for launch-at-login
|
||||||
|
- Swift `Charts` for time-series visualization
|
||||||
|
|||||||
BINáris
Screenshots/performance-cpu.png
|
Before Width: | Height: | Méret: 229 KiB After Width: | Height: | Méret: 225 KiB |
|
Before Width: | Height: | Méret: 227 KiB After Width: | Height: | Méret: 241 KiB |
BINáris
Screenshots/power-storage.png
|
Before Width: | Height: | Méret: 236 KiB After Width: | Height: | Méret: 270 KiB |
BINáris
Screenshots/processes.png
|
Before Width: | Height: | Méret: 498 KiB After Width: | Height: | Méret: 616 KiB |
@@ -8,90 +8,148 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
00EC5A90B7B8C6C5015D90D1 /* PerformanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F80A817DCDA254349DEA6F06 /* PerformanceView.swift */; };
|
00EC5A90B7B8C6C5015D90D1 /* PerformanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F80A817DCDA254349DEA6F06 /* PerformanceView.swift */; };
|
||||||
2B3C4D5E6F7A8B9C0D1E2F3A /* AppsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F7A8B9C0D1E2F /* AppsView.swift */; };
|
|
||||||
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 */; };
|
||||||
|
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 */; };
|
||||||
266B888ADE2FDDBCEC8D28A5 /* MemoryLineChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */; };
|
266B888ADE2FDDBCEC8D28A5 /* MemoryLineChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */; };
|
||||||
|
2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332290DABD5326C87258723C /* ProcessDataTests.swift */; };
|
||||||
|
2DE14B65EA1D4E9B4D9DA5A6 /* BatteryMathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */; };
|
||||||
341C12CED58388D32ED6C5BB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F1ACD0492CAB741595DEAA /* ContentView.swift */; };
|
341C12CED58388D32ED6C5BB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F1ACD0492CAB741595DEAA /* ContentView.swift */; };
|
||||||
35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */; };
|
35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */; };
|
||||||
4427B8406B25B8FEDF27CDBA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 334573CEB47D60570A40B55D /* Assets.xcassets */; };
|
4427B8406B25B8FEDF27CDBA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 334573CEB47D60570A40B55D /* Assets.xcassets */; };
|
||||||
|
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 */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
8F9ED99AB6D8D90AE79B5179 /* DiskUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */; };
|
||||||
9146403BC02D18A234444DF6 /* ProcessMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 295232B4C746511757DCF8FE /* ProcessMonitor.swift */; };
|
9146403BC02D18A234444DF6 /* ProcessMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 295232B4C746511757DCF8FE /* ProcessMonitor.swift */; };
|
||||||
|
9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */; };
|
||||||
A0649565A902BA48DB10F592 /* CPUMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19ED582C412C63A0A2E2103E /* CPUMonitor.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 */; };
|
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 */; };
|
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 */; };
|
||||||
|
DA03FF0D25DB8B9D3B09BC82 /* MenuBarStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */; };
|
||||||
DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279B972C08A878053821285D /* SystemMonitor.swift */; };
|
DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279B972C08A878053821285D /* SystemMonitor.swift */; };
|
||||||
DE50ECC69FCE60B10364802C /* ByteFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */; };
|
DE50ECC69FCE60B10364802C /* ByteFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */; };
|
||||||
E379C349DC9617D6C505B74B /* GPUMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */; };
|
E379C349DC9617D6C505B74B /* GPUMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */; };
|
||||||
E40A4E80ED4092A3D3A9C219 /* MemoryDonutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F402CE13EF67DE550786B79 /* MemoryDonutView.swift */; };
|
E40A4E80ED4092A3D3A9C219 /* MemoryDonutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F402CE13EF67DE550786B79 /* MemoryDonutView.swift */; };
|
||||||
E4AF7F7CA1E1FE8638CA5A77 /* NetworkInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3010F605712E09175D9D8878 /* NetworkInfo.swift */; };
|
E4AF7F7CA1E1FE8638CA5A77 /* NetworkInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3010F605712E09175D9D8878 /* NetworkInfo.swift */; };
|
||||||
EB0D84848F42134927F9C9B1 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */; };
|
EB0D84848F42134927F9C9B1 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */; };
|
||||||
|
EB7EAB13C0DC7865BB1A99D5 /* Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D5B918C9462144E9EE7880A /* Alert.swift */; };
|
||||||
F5722D2B18EF7ABF43847EE4 /* MemoryMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */; };
|
F5722D2B18EF7ABF43847EE4 /* MemoryMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */; };
|
||||||
FA252F9182ADDA26AD1E54DE /* CPUChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D5A13CC5B9CF1B90CC2214 /* CPUChartView.swift */; };
|
FA252F9182ADDA26AD1E54DE /* CPUChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D5A13CC5B9CF1B90CC2214 /* CPUChartView.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
B01CE3A16D70795D608951EE /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 4BAFF5CBBA9A1302C6CCDCD2 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = F86DD5615146D759EB0D44EB;
|
||||||
|
remoteInfo = TopManager;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
01C19ABF49697A97F0C40378 /* SettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTests.swift; sourceTree = "<group>"; };
|
||||||
|
05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsStore.swift; sourceTree = "<group>"; };
|
||||||
|
0D5B918C9462144E9EE7880A /* Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alert.swift; sourceTree = "<group>"; };
|
||||||
|
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsStoreTests.swift; sourceTree = "<group>"; };
|
||||||
|
0F5A69E0EE7FDD44157911D7 /* TopManagerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TopManagerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
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>"; };
|
||||||
1A2B3C4D5E6F7A8B9C0D1E2F /* AppsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsView.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>"; };
|
||||||
295232B4C746511757DCF8FE /* ProcessMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessMonitor.swift; sourceTree = "<group>"; };
|
295232B4C746511757DCF8FE /* ProcessMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessMonitor.swift; sourceTree = "<group>"; };
|
||||||
3010F605712E09175D9D8878 /* NetworkInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkInfo.swift; sourceTree = "<group>"; };
|
3010F605712E09175D9D8878 /* NetworkInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkInfo.swift; sourceTree = "<group>"; };
|
||||||
|
301352EB5B362C732CE1A246 /* FormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattingTests.swift; sourceTree = "<group>"; };
|
||||||
|
332290DABD5326C87258723C /* ProcessDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessDataTests.swift; sourceTree = "<group>"; };
|
||||||
334573CEB47D60570A40B55D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
334573CEB47D60570A40B55D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
33BCE156CB39EDADFFC938F9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
33BCE156CB39EDADFFC938F9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
|
347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
|
||||||
352CA1BE2A3B43643A9DBD72 /* DiskInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskInfo.swift; sourceTree = "<group>"; };
|
352CA1BE2A3B43643A9DBD72 /* DiskInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskInfo.swift; sourceTree = "<group>"; };
|
||||||
44281079654C7E1C9E16806C /* CPUInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUInfo.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
||||||
|
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>"; };
|
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>"; };
|
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>"; };
|
94D5A13CC5B9CF1B90CC2214 /* CPUChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUChartView.swift; sourceTree = "<group>"; };
|
||||||
95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskUsageView.swift; sourceTree = "<group>"; };
|
95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskUsageView.swift; sourceTree = "<group>"; };
|
||||||
|
95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMonitor.swift; sourceTree = "<group>"; };
|
||||||
|
9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendChartView.swift; sourceTree = "<group>"; };
|
||||||
9D71CC63FC2FEF52A069372E /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = "<group>"; };
|
9D71CC63FC2FEF52A069372E /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = "<group>"; };
|
||||||
A3A3D0B1EB522C9975F71DA8 /* TopManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TopManager.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
A3A3D0B1EB522C9975F71DA8 /* TopManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TopManager.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
A404EBD588B7ACD36079A95D /* TopManagerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopManagerApp.swift; sourceTree = "<group>"; };
|
A404EBD588B7ACD36079A95D /* TopManagerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopManagerApp.swift; sourceTree = "<group>"; };
|
||||||
|
AACBF14FFC5EC68C9E438040 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
B6038CCDF3BDF0F8BC93C9D4 /* PowerStorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerStorageView.swift; sourceTree = "<group>"; };
|
B6038CCDF3BDF0F8BC93C9D4 /* PowerStorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerStorageView.swift; sourceTree = "<group>"; };
|
||||||
BBFADE83965AA863B775A910 /* NetworkChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkChartView.swift; sourceTree = "<group>"; };
|
BBFADE83965AA863B775A910 /* NetworkChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkChartView.swift; sourceTree = "<group>"; };
|
||||||
BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryInfo.swift; sourceTree = "<group>"; };
|
BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryInfo.swift; sourceTree = "<group>"; };
|
||||||
BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPUMonitor.swift; sourceTree = "<group>"; };
|
BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPUMonitor.swift; sourceTree = "<group>"; };
|
||||||
|
C2C96471F109FF1C20ABF59E /* AlertCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCenter.swift; sourceTree = "<group>"; };
|
||||||
C4F1ACD0492CAB741595DEAA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
C4F1ACD0492CAB741595DEAA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
|
C70E54E350F7C8C22D342594 /* HealthModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthModel.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 */
|
||||||
3C4D5E6F7A8B9C0D1E2F3A4B /* Apps */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
1A2B3C4D5E6F7A8B9C0D1E2F /* AppsView.swift */,
|
|
||||||
);
|
|
||||||
path = Apps;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
06D63CC4D5468CEC1E31137A /* Services */ = {
|
06D63CC4D5468CEC1E31137A /* Services */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
C2C96471F109FF1C20ABF59E /* AlertCenter.swift */,
|
||||||
|
892247B0F17363DFC146EDF8 /* AppSettings.swift */,
|
||||||
19ED582C412C63A0A2E2103E /* CPUMonitor.swift */,
|
19ED582C412C63A0A2E2103E /* CPUMonitor.swift */,
|
||||||
162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */,
|
162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */,
|
||||||
BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */,
|
BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */,
|
||||||
27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */,
|
27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */,
|
||||||
|
5B37A9AE9F6215C0C132B8D9 /* MenuBarStatus.swift */,
|
||||||
|
05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */,
|
||||||
347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */,
|
347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */,
|
||||||
|
95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */,
|
||||||
295232B4C746511757DCF8FE /* ProcessMonitor.swift */,
|
295232B4C746511757DCF8FE /* ProcessMonitor.swift */,
|
||||||
279B972C08A878053821285D /* SystemMonitor.swift */,
|
279B972C08A878053821285D /* SystemMonitor.swift */,
|
||||||
|
19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */,
|
||||||
|
8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -100,6 +158,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
36BB5A20B1330E54F2DCF357 /* TopManager */,
|
36BB5A20B1330E54F2DCF357 /* TopManager */,
|
||||||
|
D078BA97D4F498E5282AC5E0 /* TopManagerTests */,
|
||||||
E6438D9AF8EDEFEDCB087B38 /* Products */,
|
E6438D9AF8EDEFEDCB087B38 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -107,12 +166,16 @@
|
|||||||
32ADF00BA31F63CAE52B2D0A /* Models */ = {
|
32ADF00BA31F63CAE52B2D0A /* Models */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
0D5B918C9462144E9EE7880A /* Alert.swift */,
|
||||||
44281079654C7E1C9E16806C /* CPUInfo.swift */,
|
44281079654C7E1C9E16806C /* CPUInfo.swift */,
|
||||||
352CA1BE2A3B43643A9DBD72 /* DiskInfo.swift */,
|
352CA1BE2A3B43643A9DBD72 /* DiskInfo.swift */,
|
||||||
F481045B4FC6017F50849FF1 /* GPUInfo.swift */,
|
F481045B4FC6017F50849FF1 /* GPUInfo.swift */,
|
||||||
|
C70E54E350F7C8C22D342594 /* HealthModel.swift */,
|
||||||
BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */,
|
BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */,
|
||||||
3010F605712E09175D9D8878 /* NetworkInfo.swift */,
|
3010F605712E09175D9D8878 /* NetworkInfo.swift */,
|
||||||
|
51C512B13DC0C1024CF39A5E /* PowerInfo.swift */,
|
||||||
4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */,
|
4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */,
|
||||||
|
7A81F77752CFF49D5038B06E /* ThermalInfo.swift */,
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -141,6 +204,7 @@
|
|||||||
7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */,
|
7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */,
|
||||||
BBFADE83965AA863B775A910 /* NetworkChartView.swift */,
|
BBFADE83965AA863B775A910 /* NetworkChartView.swift */,
|
||||||
F80A817DCDA254349DEA6F06 /* PerformanceView.swift */,
|
F80A817DCDA254349DEA6F06 /* PerformanceView.swift */,
|
||||||
|
9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */,
|
||||||
);
|
);
|
||||||
path = Performance;
|
path = Performance;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -153,6 +217,14 @@
|
|||||||
path = Resources;
|
path = Resources;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
6901D0C7B3DA57547BCFAAFC /* Apps */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
813543165B39EBD213EB3BC7 /* AppsView.swift */,
|
||||||
|
);
|
||||||
|
path = Apps;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
9C38088C52BDE6A2BA1CEF73 /* Utilities */ = {
|
9C38088C52BDE6A2BA1CEF73 /* Utilities */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -166,7 +238,9 @@
|
|||||||
children = (
|
children = (
|
||||||
C4F1ACD0492CAB741595DEAA /* ContentView.swift */,
|
C4F1ACD0492CAB741595DEAA /* ContentView.swift */,
|
||||||
9D71CC63FC2FEF52A069372E /* MenuBarView.swift */,
|
9D71CC63FC2FEF52A069372E /* MenuBarView.swift */,
|
||||||
3C4D5E6F7A8B9C0D1E2F3A4B /* Apps */,
|
AACBF14FFC5EC68C9E438040 /* SettingsView.swift */,
|
||||||
|
6901D0C7B3DA57547BCFAAFC /* Apps */,
|
||||||
|
CD1E8360C279FD4EFAD85DF6 /* Health */,
|
||||||
4935484C55FD4C912A30D623 /* Performance */,
|
4935484C55FD4C912A30D623 /* Performance */,
|
||||||
CC52DA4C64987575E4D96A00 /* PowerStorage */,
|
CC52DA4C64987575E4D96A00 /* PowerStorage */,
|
||||||
F2616FC0A232C728E792C2E8 /* Processes */,
|
F2616FC0A232C728E792C2E8 /* Processes */,
|
||||||
@@ -183,10 +257,35 @@
|
|||||||
path = PowerStorage;
|
path = PowerStorage;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
CD1E8360C279FD4EFAD85DF6 /* Health */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
126A382E2755C5061E6D29F5 /* HealthView.swift */,
|
||||||
|
);
|
||||||
|
path = Health;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
D078BA97D4F498E5282AC5E0 /* TopManagerTests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
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>";
|
||||||
|
};
|
||||||
E6438D9AF8EDEFEDCB087B38 /* Products */ = {
|
E6438D9AF8EDEFEDCB087B38 /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A3A3D0B1EB522C9975F71DA8 /* TopManager.app */,
|
A3A3D0B1EB522C9975F71DA8 /* TopManager.app */,
|
||||||
|
0F5A69E0EE7FDD44157911D7 /* TopManagerTests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -195,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;
|
||||||
@@ -203,6 +303,24 @@
|
|||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
|
D28E798AA7BEAA0638DFD52E /* TopManagerTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 9D898AF16ACAC9453397ADCE /* Build configuration list for PBXNativeTarget "TopManagerTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
692062A41592E988A6547E4C /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
48A04DA713F139236C91459E /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = TopManagerTests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = TopManagerTests;
|
||||||
|
productReference = 0F5A69E0EE7FDD44157911D7 /* TopManagerTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
F86DD5615146D759EB0D44EB /* TopManager */ = {
|
F86DD5615146D759EB0D44EB /* TopManager */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 56829675F6738AEDCFCDEE52 /* Build configuration list for PBXNativeTarget "TopManager" */;
|
buildConfigurationList = 56829675F6738AEDCFCDEE52 /* Build configuration list for PBXNativeTarget "TopManager" */;
|
||||||
@@ -230,8 +348,12 @@
|
|||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastUpgradeCheck = 1500;
|
LastUpgradeCheck = 1500;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
|
D28E798AA7BEAA0638DFD52E = {
|
||||||
|
DevelopmentTeam = P3663392R2;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
F86DD5615146D759EB0D44EB = {
|
F86DD5615146D759EB0D44EB = {
|
||||||
DevelopmentTeam = "";
|
DevelopmentTeam = P3663392R2;
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -251,6 +373,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
F86DD5615146D759EB0D44EB /* TopManager */,
|
F86DD5615146D759EB0D44EB /* TopManager */,
|
||||||
|
D28E798AA7BEAA0638DFD52E /* TopManagerTests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -268,11 +391,30 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
692062A41592E988A6547E4C /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
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;
|
||||||
|
};
|
||||||
CF5286324D09B9417D3070C3 /* Sources */ = {
|
CF5286324D09B9417D3070C3 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
2B3C4D5E6F7A8B9C0D1E2F3A /* AppsView.swift in Sources */,
|
EB7EAB13C0DC7865BB1A99D5 /* Alert.swift in Sources */,
|
||||||
|
67F66E7EC7C01A6309DC060E /* AlertCenter.swift in Sources */,
|
||||||
|
7A6088469A2643D360B35B89 /* AppSettings.swift in Sources */,
|
||||||
|
A7EC9FB55D5088D669491C11 /* AppsView.swift in Sources */,
|
||||||
DE50ECC69FCE60B10364802C /* ByteFormatters.swift in Sources */,
|
DE50ECC69FCE60B10364802C /* ByteFormatters.swift in Sources */,
|
||||||
FA252F9182ADDA26AD1E54DE /* CPUChartView.swift in Sources */,
|
FA252F9182ADDA26AD1E54DE /* CPUChartView.swift in Sources */,
|
||||||
1F212CE158B7D14B29077B18 /* CPUInfo.swift in Sources */,
|
1F212CE158B7D14B29077B18 /* CPUInfo.swift in Sources */,
|
||||||
@@ -283,27 +425,47 @@
|
|||||||
8F9ED99AB6D8D90AE79B5179 /* DiskUsageView.swift in Sources */,
|
8F9ED99AB6D8D90AE79B5179 /* DiskUsageView.swift in Sources */,
|
||||||
1F25A84B560709AE71C27D23 /* GPUInfo.swift in Sources */,
|
1F25A84B560709AE71C27D23 /* GPUInfo.swift in Sources */,
|
||||||
E379C349DC9617D6C505B74B /* GPUMonitor.swift in Sources */,
|
E379C349DC9617D6C505B74B /* GPUMonitor.swift in Sources */,
|
||||||
|
79E837AE662021C521F7E74F /* HealthModel.swift in Sources */,
|
||||||
|
6A0810422D2EC8F9F5D191C4 /* HealthView.swift in Sources */,
|
||||||
E40A4E80ED4092A3D3A9C219 /* MemoryDonutView.swift in Sources */,
|
E40A4E80ED4092A3D3A9C219 /* MemoryDonutView.swift in Sources */,
|
||||||
7A1144E43159D96564C13164 /* MemoryInfo.swift in Sources */,
|
7A1144E43159D96564C13164 /* MemoryInfo.swift in Sources */,
|
||||||
266B888ADE2FDDBCEC8D28A5 /* MemoryLineChart.swift in Sources */,
|
266B888ADE2FDDBCEC8D28A5 /* MemoryLineChart.swift in Sources */,
|
||||||
F5722D2B18EF7ABF43847EE4 /* MemoryMonitor.swift in Sources */,
|
F5722D2B18EF7ABF43847EE4 /* MemoryMonitor.swift in Sources */,
|
||||||
|
AE29356C1D41F33A3E94B6B7 /* MenuBarStatus.swift in Sources */,
|
||||||
35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */,
|
35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */,
|
||||||
|
BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */,
|
||||||
CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */,
|
CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */,
|
||||||
E4AF7F7CA1E1FE8638CA5A77 /* NetworkInfo.swift in Sources */,
|
E4AF7F7CA1E1FE8638CA5A77 /* NetworkInfo.swift in Sources */,
|
||||||
EB0D84848F42134927F9C9B1 /* NetworkMonitor.swift in Sources */,
|
EB0D84848F42134927F9C9B1 /* NetworkMonitor.swift in Sources */,
|
||||||
00EC5A90B7B8C6C5015D90D1 /* PerformanceView.swift in Sources */,
|
00EC5A90B7B8C6C5015D90D1 /* PerformanceView.swift in Sources */,
|
||||||
|
B0165358B483B0ECC28DB4DE /* PowerInfo.swift in Sources */,
|
||||||
|
D9E882888E23C26B748E22CF /* PowerMonitor.swift in Sources */,
|
||||||
501C13D57386C179C0B3B14B /* PowerStorageView.swift in Sources */,
|
501C13D57386C179C0B3B14B /* PowerStorageView.swift in Sources */,
|
||||||
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 */,
|
||||||
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 */,
|
||||||
|
14D77C268F9F701E612E4FD3 /* WindowVisibility.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
48A04DA713F139236C91459E /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = F86DD5615146D759EB0D44EB /* TopManager */;
|
||||||
|
targetProxy = B01CE3A16D70795D608951EE /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
00F8AAE6BB8D4ED66312B6CB /* Release */ = {
|
00F8AAE6BB8D4ED66312B6CB /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
@@ -311,12 +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 = 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.2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.app;
|
||||||
PRODUCT_NAME = TopManager;
|
PRODUCT_NAME = TopManager;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
@@ -360,7 +524,7 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = P3663392R2;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
@@ -390,18 +554,38 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
|
C0DE23B98ECF14373EAC79A6 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
"@loader_path/../Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.TopManagerTests;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TopManager.app/Contents/MacOS/TopManager";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
C4D5AD1B7274BBED9D423AC0 /* Debug */ = {
|
C4D5AD1B7274BBED9D423AC0 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
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 = 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.2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.app;
|
||||||
PRODUCT_NAME = TopManager;
|
PRODUCT_NAME = TopManager;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
@@ -445,7 +629,7 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = P3663392R2;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
@@ -468,6 +652,24 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
EABC414CD1181D6B70269064 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
"@loader_path/../Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.TopManagerTests;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TopManager.app/Contents/MacOS/TopManager";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -480,6 +682,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Debug;
|
defaultConfigurationName = Debug;
|
||||||
};
|
};
|
||||||
|
9D898AF16ACAC9453397ADCE /* Build configuration list for PBXNativeTarget "TopManagerTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
EABC414CD1181D6B70269064 /* Debug */,
|
||||||
|
C0DE23B98ECF14373EAC79A6 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
A70528C1B1F0BAB178B0B048 /* Build configuration list for PBXProject "TopManager" */ = {
|
A70528C1B1F0BAB178B0B048 /* Build configuration list for PBXProject "TopManager" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
|
|||||||
118
TopManager.xcodeproj/xcshareddata/xcschemes/TopManager.xcscheme
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1500"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
runPostActionsOnFailure = "NO">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "F86DD5615146D759EB0D44EB"
|
||||||
|
BuildableName = "TopManager.app"
|
||||||
|
BlueprintName = "TopManager"
|
||||||
|
ReferencedContainer = "container:TopManager.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "NO"
|
||||||
|
buildForProfiling = "NO"
|
||||||
|
buildForArchiving = "NO"
|
||||||
|
buildForAnalyzing = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D28E798AA7BEAA0638DFD52E"
|
||||||
|
BuildableName = "TopManagerTests.xctest"
|
||||||
|
BlueprintName = "TopManagerTests"
|
||||||
|
ReferencedContainer = "container:TopManager.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "F86DD5615146D759EB0D44EB"
|
||||||
|
BuildableName = "TopManager.app"
|
||||||
|
BlueprintName = "TopManager"
|
||||||
|
ReferencedContainer = "container:TopManager.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D28E798AA7BEAA0638DFD52E"
|
||||||
|
BuildableName = "TopManagerTests.xctest"
|
||||||
|
BlueprintName = "TopManagerTests"
|
||||||
|
ReferencedContainer = "container:TopManager.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "F86DD5615146D759EB0D44EB"
|
||||||
|
BuildableName = "TopManager.app"
|
||||||
|
BlueprintName = "TopManager"
|
||||||
|
ReferencedContainer = "container:TopManager.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "F86DD5615146D759EB0D44EB"
|
||||||
|
BuildableName = "TopManager.app"
|
||||||
|
BlueprintName = "TopManager"
|
||||||
|
ReferencedContainer = "container:TopManager.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
68
TopManager/Models/Alert.swift
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AlertSeverity: Int, Comparable {
|
||||||
|
case info = 0, warning, critical
|
||||||
|
static func < (lhs: AlertSeverity, rhs: AlertSeverity) -> Bool { lhs.rawValue < rhs.rawValue }
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .info: return "Info"
|
||||||
|
case .warning: return "Warning"
|
||||||
|
case .critical: return "Critical"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var symbol: String {
|
||||||
|
switch self {
|
||||||
|
case .info: return "info.circle.fill"
|
||||||
|
case .warning: return "exclamationmark.triangle.fill"
|
||||||
|
case .critical: return "exclamationmark.octagon.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var colorName: String {
|
||||||
|
switch self {
|
||||||
|
case .info: return "blue"
|
||||||
|
case .warning: return "orange"
|
||||||
|
case .critical: return "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distinct alert categories. The kind is the de-dup key: at most one active
|
||||||
|
/// alert per kind at a time, so we never storm the user with duplicates.
|
||||||
|
enum AlertKind: String {
|
||||||
|
case cpuHigh, memoryPressure, diskFull, thermal, runawayProcess, lowBattery
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Named `SystemAlert` (not `Alert`) to avoid shadowing SwiftUI's `Alert` type
|
||||||
|
/// inside view files.
|
||||||
|
struct SystemAlert: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let kind: AlertKind
|
||||||
|
let title: String
|
||||||
|
let message: String
|
||||||
|
let severity: AlertSeverity
|
||||||
|
let timestamp: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-tunable thresholds (wired to Settings in a later increment; sensible
|
||||||
|
/// defaults here).
|
||||||
|
struct AlertThresholds: Equatable {
|
||||||
|
var cpuPercent: Double = 90
|
||||||
|
var cpuSustainedCycles: Int = 3
|
||||||
|
var memoryPressureCritical = true
|
||||||
|
var diskUsedFraction: Double = 0.95
|
||||||
|
var thermalSerious = true
|
||||||
|
var processCpuPercent: Double = 190 // per-core (~1.9 cores) sustained
|
||||||
|
var processSustainedCycles: Int = 3
|
||||||
|
var lowBatteryPercent = 15
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper for "sustained" conditions: a breach must persist for N
|
||||||
|
/// consecutive samples before it fires, and it clears as soon as it recovers.
|
||||||
|
/// Returned `count` is fed back in on the next call.
|
||||||
|
enum AlertEvaluator {
|
||||||
|
static func sustained(breached: Bool, previousCount: Int, requiredCycles: Int) -> (firing: Bool, count: Int) {
|
||||||
|
let count = breached ? previousCount + 1 : 0
|
||||||
|
return (count >= requiredCycles, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
TopManager/Models/HealthModel.swift
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Architecture-neutral thermal level so the pure health model doesn't depend on
|
||||||
|
/// Foundation's `ProcessInfo.ThermalState` (keeps it trivially unit-testable).
|
||||||
|
enum ThermalLevel {
|
||||||
|
case nominal, fair, serious, critical
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the health score needs, as plain values.
|
||||||
|
struct HealthInput {
|
||||||
|
let cpuUsage: Double // global CPU %
|
||||||
|
let memoryPressure: MemoryPressure
|
||||||
|
let swapUsedBytes: UInt64
|
||||||
|
let thermal: ThermalLevel
|
||||||
|
let diskUsedFraction: Double // 0…1 for the fullest volume
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes a 0–100 "system health" score and a plain-language diagnosis.
|
||||||
|
/// This is what turns raw numbers into "is my Mac OK, and if not, why?".
|
||||||
|
enum HealthModel {
|
||||||
|
static func score(_ input: HealthInput) -> Int {
|
||||||
|
var score = 100.0
|
||||||
|
|
||||||
|
// Sustained high CPU: lose up to ~20 points as usage climbs 60→100%.
|
||||||
|
if input.cpuUsage > 60 {
|
||||||
|
score -= min(20, (input.cpuUsage - 60) * 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch input.memoryPressure {
|
||||||
|
case .warning: score -= 15
|
||||||
|
case .critical: score -= 30
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.swapUsedBytes > 2_000_000_000 { score -= 10 }
|
||||||
|
else if input.swapUsedBytes > 500_000_000 { score -= 5 }
|
||||||
|
|
||||||
|
switch input.thermal {
|
||||||
|
case .fair: score -= 5
|
||||||
|
case .serious: score -= 20
|
||||||
|
case .critical: score -= 35
|
||||||
|
case .nominal: break
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.diskUsedFraction > 0.95 { score -= 15 }
|
||||||
|
else if input.diskUsedFraction > 0.90 { score -= 8 }
|
||||||
|
|
||||||
|
return max(0, min(100, Int(score.rounded())))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func diagnosis(_ input: HealthInput) -> [String] {
|
||||||
|
var issues: [String] = []
|
||||||
|
if input.cpuUsage > 85 {
|
||||||
|
issues.append("CPU is heavily loaded (\(Int(input.cpuUsage))%)")
|
||||||
|
}
|
||||||
|
switch input.memoryPressure {
|
||||||
|
case .critical: issues.append("Memory pressure is critical — the system is low on RAM")
|
||||||
|
case .warning: issues.append("Memory pressure is elevated")
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
if input.swapUsedBytes > 2_000_000_000 {
|
||||||
|
issues.append("High swap usage — closing apps may help")
|
||||||
|
}
|
||||||
|
switch input.thermal {
|
||||||
|
case .serious, .critical: issues.append("System is running hot and may be throttling")
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
if input.diskUsedFraction > 0.90 {
|
||||||
|
issues.append("Startup disk is nearly full (\(Int(input.diskUsedFraction * 100))%)")
|
||||||
|
}
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
|
||||||
|
static func rating(_ score: Int) -> String {
|
||||||
|
switch score {
|
||||||
|
case 85...: return "Excellent"
|
||||||
|
case 70..<85: return "Good"
|
||||||
|
case 50..<70: return "Fair"
|
||||||
|
default: return "Poor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
TopManager/Models/PowerInfo.swift
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Battery / power state. Mirrors what Activity Monitor's Energy tab and the
|
||||||
|
/// macOS battery menu surface, which TopManager currently lacks entirely.
|
||||||
|
struct PowerInfo {
|
||||||
|
let hasBattery: Bool
|
||||||
|
let currentCharge: Int // 0–100 %
|
||||||
|
let isCharging: Bool
|
||||||
|
let isPluggedIn: Bool // external power connected
|
||||||
|
let fullyCharged: Bool
|
||||||
|
let cycleCount: Int?
|
||||||
|
let designCapacity: Int? // mAh
|
||||||
|
let maxCapacity: Int? // mAh (current full-charge capacity)
|
||||||
|
let temperature: Double? // °C
|
||||||
|
let voltage: Double? // volts
|
||||||
|
let amperage: Int? // mA (negative while discharging)
|
||||||
|
let timeToEmpty: Int? // minutes (nil if unknown/charging)
|
||||||
|
let timeToFull: Int? // minutes (nil if unknown/discharging)
|
||||||
|
let adapterWatts: Int? // power adapter rating
|
||||||
|
|
||||||
|
/// Battery health as current-full-charge ÷ design capacity.
|
||||||
|
var healthPercent: Double? {
|
||||||
|
guard let maxCapacity, let designCapacity, designCapacity > 0 else { return nil }
|
||||||
|
return BatteryMath.healthPercent(maxCapacity: maxCapacity, designCapacity: designCapacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
var condition: BatteryCondition {
|
||||||
|
BatteryMath.condition(healthPercent: healthPercent, cycleCount: cycleCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instantaneous power flow in watts (positive = charging, negative = draining).
|
||||||
|
var powerWatts: Double? {
|
||||||
|
guard let voltage, let amperage else { return nil }
|
||||||
|
return BatteryMath.watts(volts: voltage, milliAmps: amperage)
|
||||||
|
}
|
||||||
|
|
||||||
|
var powerSourceLabel: String {
|
||||||
|
isPluggedIn ? "AC Power" : "Battery Power"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BatteryCondition: String {
|
||||||
|
case normal = "Normal"
|
||||||
|
case serviceRecommended = "Service Recommended"
|
||||||
|
case unknown = "Unknown"
|
||||||
|
|
||||||
|
var color: String {
|
||||||
|
switch self {
|
||||||
|
case .normal: return "green"
|
||||||
|
case .serviceRecommended: return "orange"
|
||||||
|
case .unknown: return "gray"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure, testable battery math (no IOKit, no clock).
|
||||||
|
enum BatteryMath {
|
||||||
|
static func healthPercent(maxCapacity: Int, designCapacity: Int) -> Double {
|
||||||
|
guard designCapacity > 0 else { return 0 }
|
||||||
|
return Double(maxCapacity) / Double(designCapacity) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apple flags "Service Recommended" as health degrades or cycles climb.
|
||||||
|
/// This approximates that classification from the raw numbers.
|
||||||
|
static func condition(healthPercent: Double?, cycleCount: Int?) -> BatteryCondition {
|
||||||
|
guard let healthPercent else { return .unknown }
|
||||||
|
if healthPercent < 80 { return .serviceRecommended }
|
||||||
|
if let cycleCount, cycleCount > 1000 { return .serviceRecommended }
|
||||||
|
return .normal
|
||||||
|
}
|
||||||
|
|
||||||
|
static func watts(volts: Double, milliAmps: Int) -> Double {
|
||||||
|
volts * Double(milliAmps) / 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a minutes duration as "2h 15m" / "45m"; nil-safe caller.
|
||||||
|
static func formatMinutes(_ minutes: Int) -> String {
|
||||||
|
guard minutes > 0 else { return "—" }
|
||||||
|
let h = minutes / 60
|
||||||
|
let m = minutes % 60
|
||||||
|
return h > 0 ? "\(h)h \(m)m" : "\(m)m"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,15 +8,25 @@ struct ProcessItem: Identifiable, Hashable {
|
|||||||
let user: String
|
let user: String
|
||||||
let cpuUsage: Double // Per-core: 100% = 1 core fully utilized
|
let cpuUsage: Double // Per-core: 100% = 1 core fully utilized
|
||||||
let cpuUsageTotal: Double // Normalized: 100% = all cores 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 threadCount: Int32
|
||||||
let state: ProcessState
|
let state: ProcessState
|
||||||
let icon: NSImage?
|
let icon: NSImage?
|
||||||
let parentPid: pid_t
|
let parentPid: pid_t
|
||||||
let startTime: Date?
|
let startTime: Date?
|
||||||
|
let diskReadRate: Double // bytes/sec read from disk
|
||||||
|
let diskWriteRate: Double // bytes/sec written to disk
|
||||||
|
let diskReadBytes: UInt64 // cumulative bytes read
|
||||||
|
let diskWriteBytes: UInt64 // cumulative bytes written
|
||||||
|
let energyImpact: Double // heuristic energy-impact score (proxy for Activity Monitor's)
|
||||||
|
let executablePath: String?
|
||||||
|
|
||||||
var iconPlaceholder: String { "" }
|
var iconPlaceholder: String { "" }
|
||||||
|
|
||||||
|
var diskTotalRate: Double { diskReadRate + diskWriteRate }
|
||||||
|
|
||||||
init(
|
init(
|
||||||
pid: pid_t,
|
pid: pid_t,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -28,7 +38,15 @@ struct ProcessItem: Identifiable, Hashable {
|
|||||||
state: ProcessState,
|
state: ProcessState,
|
||||||
icon: NSImage?,
|
icon: NSImage?,
|
||||||
parentPid: pid_t,
|
parentPid: pid_t,
|
||||||
startTime: Date?
|
startTime: Date?,
|
||||||
|
diskReadRate: Double = 0,
|
||||||
|
diskWriteRate: Double = 0,
|
||||||
|
diskReadBytes: UInt64 = 0,
|
||||||
|
diskWriteBytes: UInt64 = 0,
|
||||||
|
energyImpact: Double = 0,
|
||||||
|
executablePath: String? = nil,
|
||||||
|
residentMemory: Int64 = 0,
|
||||||
|
compressedMemory: Int64 = 0
|
||||||
) {
|
) {
|
||||||
self.id = pid
|
self.id = pid
|
||||||
self.pid = pid
|
self.pid = pid
|
||||||
@@ -37,11 +55,19 @@ struct ProcessItem: Identifiable, Hashable {
|
|||||||
self.cpuUsage = cpuUsage
|
self.cpuUsage = cpuUsage
|
||||||
self.cpuUsageTotal = cpuUsageTotal
|
self.cpuUsageTotal = cpuUsageTotal
|
||||||
self.memoryUsage = memoryUsage
|
self.memoryUsage = memoryUsage
|
||||||
|
self.residentMemory = residentMemory
|
||||||
|
self.compressedMemory = compressedMemory
|
||||||
self.threadCount = threadCount
|
self.threadCount = threadCount
|
||||||
self.state = state
|
self.state = state
|
||||||
self.icon = icon
|
self.icon = icon
|
||||||
self.parentPid = parentPid
|
self.parentPid = parentPid
|
||||||
self.startTime = startTime
|
self.startTime = startTime
|
||||||
|
self.diskReadRate = diskReadRate
|
||||||
|
self.diskWriteRate = diskWriteRate
|
||||||
|
self.diskReadBytes = diskReadBytes
|
||||||
|
self.diskWriteBytes = diskWriteBytes
|
||||||
|
self.energyImpact = energyImpact
|
||||||
|
self.executablePath = executablePath
|
||||||
}
|
}
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
@@ -53,8 +79,22 @@ struct ProcessItem: Identifiable, Hashable {
|
|||||||
lhs.cpuUsage == rhs.cpuUsage &&
|
lhs.cpuUsage == rhs.cpuUsage &&
|
||||||
lhs.cpuUsageTotal == rhs.cpuUsageTotal &&
|
lhs.cpuUsageTotal == rhs.cpuUsageTotal &&
|
||||||
lhs.memoryUsage == rhs.memoryUsage &&
|
lhs.memoryUsage == rhs.memoryUsage &&
|
||||||
|
lhs.residentMemory == rhs.residentMemory &&
|
||||||
|
lhs.compressedMemory == rhs.compressedMemory &&
|
||||||
lhs.threadCount == rhs.threadCount &&
|
lhs.threadCount == rhs.threadCount &&
|
||||||
lhs.state == rhs.state
|
lhs.state == rhs.state &&
|
||||||
|
lhs.diskReadRate == rhs.diskReadRate &&
|
||||||
|
lhs.diskWriteRate == rhs.diskWriteRate &&
|
||||||
|
lhs.energyImpact == rhs.energyImpact
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heuristic "energy impact" proxy. This is *not* Apple's exact private formula;
|
||||||
|
/// it approximates it: sustained CPU dominates, with a smaller penalty for idle
|
||||||
|
/// (timer) wakeups, which correlate with power draw even at low CPU.
|
||||||
|
enum EnergyModel {
|
||||||
|
static func impact(cpuPercent: Double, idleWakeupsPerSec: Double) -> Double {
|
||||||
|
max(0, cpuPercent) + max(0, idleWakeupsPerSec) * 0.045
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
136
TopManager/Services/AlertCenter.swift
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// The proactive brain: each refresh it computes the system health score and
|
||||||
|
/// evaluates alert conditions, firing a native notification + inbox entry on the
|
||||||
|
/// transition into each condition (never duplicating a still-active alert).
|
||||||
|
@MainActor
|
||||||
|
final class AlertCenter: ObservableObject {
|
||||||
|
static let shared = AlertCenter()
|
||||||
|
|
||||||
|
@Published var healthScore: Int = 100
|
||||||
|
@Published var diagnosis: [String] = []
|
||||||
|
@Published var alerts: [SystemAlert] = [] // inbox, newest first
|
||||||
|
@Published private(set) var activeKinds: Set<AlertKind> = []
|
||||||
|
|
||||||
|
@Published var thresholds = AlertThresholds()
|
||||||
|
@Published var notificationsEnabled = true
|
||||||
|
|
||||||
|
private let inboxLimit = 50
|
||||||
|
private var cpuCount = 0
|
||||||
|
private var procCount = 0
|
||||||
|
private var notificationsAuthorized = false
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
var activeAlertCount: Int { activeKinds.count }
|
||||||
|
|
||||||
|
func requestNotificationAuthorization() {
|
||||||
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
|
||||||
|
Task { @MainActor in self.notificationsAuthorized = granted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluate(cpuUsage: Double,
|
||||||
|
memory: MemoryInfo?,
|
||||||
|
disk: DiskInfo?,
|
||||||
|
thermal: ThermalLevel,
|
||||||
|
topProcess: ProcessItem?,
|
||||||
|
power: PowerInfo?) {
|
||||||
|
let diskFraction = disk?.volumes.map { $0.usagePercentage / 100 }.max() ?? 0
|
||||||
|
|
||||||
|
// Health score + diagnosis
|
||||||
|
let input = HealthInput(
|
||||||
|
cpuUsage: cpuUsage,
|
||||||
|
memoryPressure: memory?.memoryPressure ?? .unknown,
|
||||||
|
swapUsedBytes: memory?.swapUsed ?? 0,
|
||||||
|
thermal: thermal,
|
||||||
|
diskUsedFraction: diskFraction
|
||||||
|
)
|
||||||
|
// 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,
|
||||||
|
previousCount: cpuCount,
|
||||||
|
requiredCycles: thresholds.cpuSustainedCycles)
|
||||||
|
cpuCount = cpuEval.count
|
||||||
|
setActive(.cpuHigh, active: cpuEval.firing,
|
||||||
|
title: "High CPU usage",
|
||||||
|
message: String(format: "CPU has stayed above %.0f%% (now %.0f%%).", thresholds.cpuPercent, cpuUsage),
|
||||||
|
severity: .warning)
|
||||||
|
|
||||||
|
// Memory pressure
|
||||||
|
let memCritical = thresholds.memoryPressureCritical && memory?.memoryPressure == .critical
|
||||||
|
setActive(.memoryPressure, active: memCritical,
|
||||||
|
title: "Critical memory pressure",
|
||||||
|
message: "The system is very low on memory. Consider closing apps.",
|
||||||
|
severity: .critical)
|
||||||
|
|
||||||
|
// Disk
|
||||||
|
let diskFull = diskFraction >= thresholds.diskUsedFraction
|
||||||
|
setActive(.diskFull, active: diskFull,
|
||||||
|
title: "Disk almost full",
|
||||||
|
message: String(format: "A volume is %.0f%% full.", diskFraction * 100),
|
||||||
|
severity: .warning)
|
||||||
|
|
||||||
|
// Thermal
|
||||||
|
let thermalBad = thresholds.thermalSerious && (thermal == .serious || thermal == .critical)
|
||||||
|
setActive(.thermal, active: thermalBad,
|
||||||
|
title: "System is running hot",
|
||||||
|
message: "Thermal state is elevated; performance may be throttled.",
|
||||||
|
severity: thermal == .critical ? .critical : .warning)
|
||||||
|
|
||||||
|
// Runaway process (sustained)
|
||||||
|
let runaway = (topProcess?.cpuUsage ?? 0) >= thresholds.processCpuPercent
|
||||||
|
let procEval = AlertEvaluator.sustained(breached: runaway,
|
||||||
|
previousCount: procCount,
|
||||||
|
requiredCycles: thresholds.processSustainedCycles)
|
||||||
|
procCount = procEval.count
|
||||||
|
setActive(.runawayProcess, active: procEval.firing,
|
||||||
|
title: "Runaway process",
|
||||||
|
message: topProcess.map { "\($0.name) (PID \($0.pid)) is using \(Int($0.cpuUsage))% CPU." } ?? "",
|
||||||
|
severity: .warning)
|
||||||
|
|
||||||
|
// Low battery
|
||||||
|
let lowBattery = (power?.hasBattery ?? false)
|
||||||
|
&& !(power?.isPluggedIn ?? true)
|
||||||
|
&& (power?.currentCharge ?? 100) <= thresholds.lowBatteryPercent
|
||||||
|
setActive(.lowBattery, active: lowBattery,
|
||||||
|
title: "Low battery",
|
||||||
|
message: "Battery is at \(power?.currentCharge ?? 0)% and not charging.",
|
||||||
|
severity: .warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setActive(_ kind: AlertKind, active: Bool, title: String, message: String, severity: AlertSeverity) {
|
||||||
|
if active {
|
||||||
|
guard !activeKinds.contains(kind) else { return } // de-dup: already firing
|
||||||
|
activeKinds.insert(kind)
|
||||||
|
let alert = SystemAlert(kind: kind, title: title, message: message, severity: severity, timestamp: Date())
|
||||||
|
alerts.insert(alert, at: 0)
|
||||||
|
if alerts.count > inboxLimit { alerts.removeLast(alerts.count - inboxLimit) }
|
||||||
|
postNotification(alert)
|
||||||
|
} else {
|
||||||
|
activeKinds.remove(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func postNotification(_ alert: SystemAlert) {
|
||||||
|
guard notificationsEnabled, notificationsAuthorized else { return }
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = alert.title
|
||||||
|
content.body = alert.message
|
||||||
|
content.sound = alert.severity == .critical ? .defaultCritical : .default
|
||||||
|
let request = UNNotificationRequest(identifier: alert.id.uuidString, content: content, trigger: nil)
|
||||||
|
UNUserNotificationCenter.current().add(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearInbox() {
|
||||||
|
alerts.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
108
TopManager/Services/AppSettings.swift
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import Foundation
|
||||||
|
import ServiceManagement
|
||||||
|
|
||||||
|
/// What the menu-bar title shows at a glance.
|
||||||
|
enum MenuBarMetric: String, CaseIterable, Identifiable {
|
||||||
|
case cpu = "CPU %"
|
||||||
|
case memory = "Memory %"
|
||||||
|
case health = "Health"
|
||||||
|
case download = "Download"
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps the macOS login-item API (SMAppService, macOS 13+).
|
||||||
|
enum LoginItem {
|
||||||
|
static var isEnabled: Bool {
|
||||||
|
SMAppService.mainApp.status == .enabled
|
||||||
|
}
|
||||||
|
static func setEnabled(_ enabled: Bool) {
|
||||||
|
do {
|
||||||
|
if enabled {
|
||||||
|
if SMAppService.mainApp.status != .enabled { try SMAppService.mainApp.register() }
|
||||||
|
} else {
|
||||||
|
if SMAppService.mainApp.status == .enabled { try SMAppService.mainApp.unregister() }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
NSLog("TopManager: login item toggle failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted user preferences. Changes are written to `UserDefaults` and pushed
|
||||||
|
/// into the running services (monitor cadence, alert thresholds, notifications).
|
||||||
|
@MainActor
|
||||||
|
final class AppSettings: ObservableObject {
|
||||||
|
static let shared = AppSettings()
|
||||||
|
|
||||||
|
private let d = UserDefaults.standard
|
||||||
|
|
||||||
|
@Published var refreshInterval: Double {
|
||||||
|
didSet {
|
||||||
|
d.set(refreshInterval, forKey: Keys.refreshInterval)
|
||||||
|
SystemMonitor.shared.updateRefreshInterval(refreshInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Published var menuBarMetric: MenuBarMetric {
|
||||||
|
didSet { d.set(menuBarMetric.rawValue, forKey: Keys.menuBarMetric) }
|
||||||
|
}
|
||||||
|
@Published var notificationsEnabled: Bool {
|
||||||
|
didSet {
|
||||||
|
d.set(notificationsEnabled, forKey: Keys.notificationsEnabled)
|
||||||
|
AlertCenter.shared.notificationsEnabled = notificationsEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Published var cpuAlertThreshold: Double {
|
||||||
|
didSet {
|
||||||
|
d.set(cpuAlertThreshold, forKey: Keys.cpuAlertThreshold)
|
||||||
|
AlertCenter.shared.thresholds.cpuPercent = cpuAlertThreshold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Published var diskAlertPercent: Double {
|
||||||
|
didSet {
|
||||||
|
d.set(diskAlertPercent, forKey: Keys.diskAlertPercent)
|
||||||
|
AlertCenter.shared.thresholds.diskUsedFraction = diskAlertPercent / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Published var lowBatteryPercent: Int {
|
||||||
|
didSet {
|
||||||
|
d.set(lowBatteryPercent, forKey: Keys.lowBatteryPercent)
|
||||||
|
AlertCenter.shared.thresholds.lowBatteryPercent = lowBatteryPercent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Published var launchAtLogin: Bool {
|
||||||
|
didSet { LoginItem.setEnabled(launchAtLogin) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
refreshInterval = AppSettings.clampInterval(d.object(forKey: Keys.refreshInterval) as? Double ?? 3.0)
|
||||||
|
menuBarMetric = MenuBarMetric(rawValue: d.string(forKey: Keys.menuBarMetric) ?? "") ?? .cpu
|
||||||
|
notificationsEnabled = d.object(forKey: Keys.notificationsEnabled) as? Bool ?? true
|
||||||
|
cpuAlertThreshold = d.object(forKey: Keys.cpuAlertThreshold) as? Double ?? 90
|
||||||
|
diskAlertPercent = d.object(forKey: Keys.diskAlertPercent) as? Double ?? 95
|
||||||
|
lowBatteryPercent = d.object(forKey: Keys.lowBatteryPercent) as? Int ?? 15
|
||||||
|
launchAtLogin = LoginItem.isEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push all persisted values into the live services (call once on launch).
|
||||||
|
func applyAll() {
|
||||||
|
SystemMonitor.shared.updateRefreshInterval(refreshInterval)
|
||||||
|
AlertCenter.shared.notificationsEnabled = notificationsEnabled
|
||||||
|
AlertCenter.shared.thresholds.cpuPercent = cpuAlertThreshold
|
||||||
|
AlertCenter.shared.thresholds.diskUsedFraction = diskAlertPercent / 100
|
||||||
|
AlertCenter.shared.thresholds.lowBatteryPercent = lowBatteryPercent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh cadence must stay in a sane range (1–30s).
|
||||||
|
nonisolated static func clampInterval(_ value: Double) -> Double {
|
||||||
|
min(30, max(1, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Keys {
|
||||||
|
static let refreshInterval = "refreshInterval"
|
||||||
|
static let menuBarMetric = "menuBarMetric"
|
||||||
|
static let notificationsEnabled = "notificationsEnabled"
|
||||||
|
static let cpuAlertThreshold = "cpuAlertThreshold"
|
||||||
|
static let diskAlertPercent = "diskAlertPercent"
|
||||||
|
static let lowBatteryPercent = "lowBatteryPercent"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,14 +86,8 @@ final class GPUMonitor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func getAppleSiliconChipName() -> String? {
|
private static func getAppleSiliconChipName() -> String? {
|
||||||
let matchingDict = IOServiceMatching("IOPlatformExpertDevice")
|
|
||||||
var service: io_service_t = 0
|
|
||||||
|
|
||||||
guard IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &service) == KERN_SUCCESS else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let platformExpert = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice"))
|
let platformExpert = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice"))
|
||||||
|
guard platformExpert != 0 else { return nil }
|
||||||
defer { IOObjectRelease(platformExpert) }
|
defer { IOObjectRelease(platformExpert) }
|
||||||
|
|
||||||
if let modelData = IORegistryEntryCreateCFProperty(
|
if let modelData = IORegistryEntryCreateCFProperty(
|
||||||
@@ -125,6 +119,25 @@ final class GPUMonitor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Walks an `io_iterator_t` and returns the first non-`nil` value `body`
|
||||||
|
/// produces, releasing **every** object it pulled out of the iterator.
|
||||||
|
///
|
||||||
|
/// The obvious `while` + `defer { release; next() }` shape leaks one
|
||||||
|
/// `io_object_t` whenever the loop is left with `break`/`return`, because
|
||||||
|
/// the deferred `IOIteratorNext` hands back an object nobody releases. At a
|
||||||
|
/// GPU poll every few seconds that leaks tens of thousands of mach port
|
||||||
|
/// rights per day.
|
||||||
|
private static func firstMatch<T>(in iterator: io_iterator_t, _ body: (io_service_t) -> T?) -> T? {
|
||||||
|
var result: T?
|
||||||
|
while result == nil {
|
||||||
|
let service = IOIteratorNext(iterator)
|
||||||
|
guard service != 0 else { break }
|
||||||
|
result = body(service)
|
||||||
|
IOObjectRelease(service)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
private func fetchAppleSiliconGPUInfo() -> GPUInfo? {
|
private func fetchAppleSiliconGPUInfo() -> GPUInfo? {
|
||||||
// Apple Silicon uses unified memory - get memory pressure instead
|
// Apple Silicon uses unified memory - get memory pressure instead
|
||||||
let totalMemory = ProcessInfo.processInfo.physicalMemory
|
let totalMemory = ProcessInfo.processInfo.physicalMemory
|
||||||
@@ -139,19 +152,13 @@ final class GPUMonitor {
|
|||||||
if IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator) == KERN_SUCCESS {
|
if IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator) == KERN_SUCCESS {
|
||||||
defer { IOObjectRelease(iterator) }
|
defer { IOObjectRelease(iterator) }
|
||||||
|
|
||||||
var service = IOIteratorNext(iterator)
|
_ = Self.firstMatch(in: iterator) { service -> Bool? in
|
||||||
while service != 0 {
|
guard let props = IORegistryEntryCreateCFProperty(
|
||||||
defer {
|
|
||||||
IOObjectRelease(service)
|
|
||||||
service = IOIteratorNext(iterator)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let props = IORegistryEntryCreateCFProperty(
|
|
||||||
service,
|
service,
|
||||||
"PerformanceStatistics" as CFString,
|
"PerformanceStatistics" as CFString,
|
||||||
kCFAllocatorDefault,
|
kCFAllocatorDefault,
|
||||||
0
|
0
|
||||||
)?.takeRetainedValue() as? [String: Any] {
|
)?.takeRetainedValue() as? [String: Any] else { return nil }
|
||||||
|
|
||||||
// Try various keys for GPU memory on Apple Silicon
|
// Try various keys for GPU memory on Apple Silicon
|
||||||
if let inUse = props["In use system memory"] as? UInt64 {
|
if let inUse = props["In use system memory"] as? UInt64 {
|
||||||
@@ -169,8 +176,7 @@ final class GPUMonitor {
|
|||||||
utilizationPercent = Double(util)
|
utilizationPercent = Double(util)
|
||||||
}
|
}
|
||||||
|
|
||||||
break
|
return true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,13 +200,7 @@ final class GPUMonitor {
|
|||||||
}
|
}
|
||||||
defer { IOObjectRelease(iterator) }
|
defer { IOObjectRelease(iterator) }
|
||||||
|
|
||||||
var service = IOIteratorNext(iterator)
|
return Self.firstMatch(in: iterator) { service -> GPUInfo? in
|
||||||
while service != 0 {
|
|
||||||
defer {
|
|
||||||
IOObjectRelease(service)
|
|
||||||
service = IOIteratorNext(iterator)
|
|
||||||
}
|
|
||||||
|
|
||||||
var name = "GPU"
|
var name = "GPU"
|
||||||
if let modelData = IORegistryEntryCreateCFProperty(
|
if let modelData = IORegistryEntryCreateCFProperty(
|
||||||
service,
|
service,
|
||||||
@@ -246,8 +246,8 @@ final class GPUMonitor {
|
|||||||
isUnifiedMemory: false
|
isUnifiedMemory: false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
190
TopManager/Services/MetricsStore.swift
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A single point-in-time snapshot of system-wide metrics, persisted to disk so
|
||||||
|
/// history survives app restarts (the live in-RAM histories only hold ~60 points).
|
||||||
|
struct MetricsSample: Codable, Identifiable, Equatable {
|
||||||
|
let t: Date // timestamp
|
||||||
|
let cpu: Double // global CPU usage %
|
||||||
|
let memUsed: UInt64
|
||||||
|
let memTotal: UInt64
|
||||||
|
let netDown: Double // bytes/sec
|
||||||
|
let netUp: Double // bytes/sec
|
||||||
|
|
||||||
|
var id: Date { t }
|
||||||
|
|
||||||
|
var memPercent: Double {
|
||||||
|
memTotal > 0 ? Double(memUsed) / Double(memTotal) * 100 : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure, side-effect-free math over samples. Kept separate from `MetricsStore`
|
||||||
|
/// so it is trivially unit-testable without touching disk or the clock.
|
||||||
|
enum MetricsMath {
|
||||||
|
/// Drop samples older than `maxAge` (relative to `now`) and cap total count,
|
||||||
|
/// keeping the most recent `maxCount`.
|
||||||
|
static func trim(_ samples: [MetricsSample], now: Date, maxAge: TimeInterval, maxCount: Int) -> [MetricsSample] {
|
||||||
|
let cutoff = now.addingTimeInterval(-maxAge)
|
||||||
|
var result = samples.filter { $0.t >= cutoff }
|
||||||
|
if result.count > maxCount {
|
||||||
|
result = Array(result.suffix(maxCount))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Average samples into fixed-width time buckets so long ranges (e.g. 24h)
|
||||||
|
/// plot ~100 points instead of tens of thousands. `bucket <= 0` returns input.
|
||||||
|
static func downsample(_ samples: [MetricsSample], bucket: TimeInterval) -> [MetricsSample] {
|
||||||
|
guard bucket > 0, samples.count > 1 else { return samples }
|
||||||
|
|
||||||
|
var groups: [Int64: [MetricsSample]] = [:]
|
||||||
|
for s in samples {
|
||||||
|
let key = Int64((s.t.timeIntervalSince1970 / bucket).rounded(.down))
|
||||||
|
groups[key, default: []].append(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.keys.sorted().map { key in
|
||||||
|
let group = groups[key]!
|
||||||
|
let n = Double(group.count)
|
||||||
|
let midpoint = Date(timeIntervalSince1970: (Double(key) + 0.5) * bucket)
|
||||||
|
return MetricsSample(
|
||||||
|
t: midpoint,
|
||||||
|
cpu: group.reduce(0) { $0 + $1.cpu } / n,
|
||||||
|
memUsed: UInt64(group.reduce(0.0) { $0 + Double($1.memUsed) } / n),
|
||||||
|
memTotal: group.map { $0.memTotal }.max() ?? 0,
|
||||||
|
netDown: group.reduce(0) { $0 + $1.netDown } / n,
|
||||||
|
netUp: group.reduce(0) { $0 + $1.netUp } / n
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selectable time window for trend charts.
|
||||||
|
enum HistoryRange: String, CaseIterable, Identifiable {
|
||||||
|
case live = "Live"
|
||||||
|
case m5 = "5m"
|
||||||
|
case m30 = "30m"
|
||||||
|
case h1 = "1h"
|
||||||
|
case h24 = "24h"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
/// Window length in seconds; `nil` for the live (in-RAM) view.
|
||||||
|
var seconds: TimeInterval? {
|
||||||
|
switch self {
|
||||||
|
case .live: return nil
|
||||||
|
case .m5: return 5 * 60
|
||||||
|
case .m30: return 30 * 60
|
||||||
|
case .h1: return 60 * 60
|
||||||
|
case .h24: return 24 * 60 * 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downsample bucket size chosen to keep ~100–150 plotted points.
|
||||||
|
var bucket: TimeInterval {
|
||||||
|
switch self {
|
||||||
|
case .live: return 0
|
||||||
|
case .m5: return 0 // ~raw (refresh cadence already ~3s)
|
||||||
|
case .m30: return 15
|
||||||
|
case .h1: return 30
|
||||||
|
case .h24: return 600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thread-safe on-disk ring buffer of `MetricsSample`s.
|
||||||
|
///
|
||||||
|
/// Access to the backing array is guarded by a lock; file writes happen on a
|
||||||
|
/// background queue from a snapshot so the recording thread never blocks on I/O.
|
||||||
|
final class MetricsStore {
|
||||||
|
static let shared = MetricsStore()
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var _samples: [MetricsSample] = []
|
||||||
|
private let fileURL: URL
|
||||||
|
private let maxAge: TimeInterval
|
||||||
|
private let maxCount: Int
|
||||||
|
private let saveEvery: Int
|
||||||
|
private var unsaved = 0
|
||||||
|
private let ioQueue = DispatchQueue(label: "com.topmanager.metricsstore.io", qos: .utility)
|
||||||
|
|
||||||
|
/// - Note: `saveEvery` is deliberately coarse. Every save re-encodes the
|
||||||
|
/// *whole* history (up to `maxCount` samples), which costs a few hundred
|
||||||
|
/// thousand transient allocations; at the old cadence of 10 that ran twice
|
||||||
|
/// a minute and kept the malloc small zone needlessly dirty. 40 samples is
|
||||||
|
/// ~2 minutes at the default 3 s refresh, and `flush()` still runs on quit
|
||||||
|
/// and when the app goes to the background.
|
||||||
|
init(fileURL: URL? = nil,
|
||||||
|
maxAge: TimeInterval = 24 * 60 * 60,
|
||||||
|
maxCount: Int = 20_000,
|
||||||
|
saveEvery: Int = 40) {
|
||||||
|
self.fileURL = fileURL ?? Self.defaultFileURL()
|
||||||
|
self.maxAge = maxAge
|
||||||
|
self.maxCount = maxCount
|
||||||
|
self.saveEvery = saveEvery
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultFileURL() -> URL {
|
||||||
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||||
|
?? URL(fileURLWithPath: NSTemporaryDirectory())
|
||||||
|
let dir = base.appendingPathComponent("TopManager", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir.appendingPathComponent("metrics-history.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a sample, trim, and persist on a throttled cadence.
|
||||||
|
func record(_ sample: MetricsSample) {
|
||||||
|
lock.lock()
|
||||||
|
_samples.append(sample)
|
||||||
|
_samples = MetricsMath.trim(_samples, now: sample.t, maxAge: maxAge, maxCount: maxCount)
|
||||||
|
unsaved += 1
|
||||||
|
let snapshot: [MetricsSample]? = unsaved >= saveEvery ? _samples : nil
|
||||||
|
if snapshot != nil { unsaved = 0 }
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
if let snapshot { persist(snapshot) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func samples(since date: Date) -> [MetricsSample] {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return _samples.filter { $0.t >= date }
|
||||||
|
}
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return _samples.count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Samples for a chart range, downsampled for long windows.
|
||||||
|
func downsampled(for range: HistoryRange, now: Date = Date()) -> [MetricsSample] {
|
||||||
|
guard let secs = range.seconds else { return [] }
|
||||||
|
let windowed = samples(since: now.addingTimeInterval(-secs))
|
||||||
|
return MetricsMath.downsample(windowed, bucket: range.bucket)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force an immediate save (e.g. on stop / quit).
|
||||||
|
func flush() {
|
||||||
|
lock.lock()
|
||||||
|
let snapshot = _samples
|
||||||
|
unsaved = 0
|
||||||
|
lock.unlock()
|
||||||
|
persist(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persist(_ snapshot: [MetricsSample]) {
|
||||||
|
ioQueue.async { [fileURL] in
|
||||||
|
guard let data = try? JSONEncoder().encode(snapshot) else { return }
|
||||||
|
try? data.write(to: fileURL, options: .atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() {
|
||||||
|
guard let data = try? Data(contentsOf: fileURL),
|
||||||
|
let decoded = try? JSONDecoder().decode([MetricsSample].self, from: data) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lock.lock()
|
||||||
|
_samples = MetricsMath.trim(decoded, now: Date(), maxAge: maxAge, maxCount: maxCount)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
77
TopManager/Services/PowerMonitor.swift
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import Foundation
|
||||||
|
import IOKit
|
||||||
|
|
||||||
|
/// Reads battery / power state from the AppleSmartBattery IORegistry entry.
|
||||||
|
/// No entitlements or root required. On a desktop Mac (no battery) it reports
|
||||||
|
/// `hasBattery == false` and AC power.
|
||||||
|
final class PowerMonitor {
|
||||||
|
|
||||||
|
func fetchPowerInfo() -> PowerInfo {
|
||||||
|
let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery"))
|
||||||
|
guard service != 0 else {
|
||||||
|
// No battery (e.g. Mac mini / Studio / Pro): assume AC power.
|
||||||
|
return PowerInfo(
|
||||||
|
hasBattery: false, currentCharge: 100, isCharging: false, isPluggedIn: true,
|
||||||
|
fullyCharged: true, cycleCount: nil, designCapacity: nil, maxCapacity: nil,
|
||||||
|
temperature: nil, voltage: nil, amperage: nil, timeToEmpty: nil,
|
||||||
|
timeToFull: nil, adapterWatts: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
defer { IOObjectRelease(service) }
|
||||||
|
|
||||||
|
var unmanagedProps: Unmanaged<CFMutableDictionary>?
|
||||||
|
guard IORegistryEntryCreateCFProperties(service, &unmanagedProps, kCFAllocatorDefault, 0) == KERN_SUCCESS,
|
||||||
|
let props = unmanagedProps?.takeRetainedValue() as? [String: Any] else {
|
||||||
|
return PowerInfo(
|
||||||
|
hasBattery: false, currentCharge: 100, isCharging: false, isPluggedIn: true,
|
||||||
|
fullyCharged: true, cycleCount: nil, designCapacity: nil, maxCapacity: nil,
|
||||||
|
temperature: nil, voltage: nil, amperage: nil, timeToEmpty: nil,
|
||||||
|
timeToFull: nil, adapterWatts: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let int = { (key: String) -> Int? in props[key] as? Int }
|
||||||
|
let bool = { (key: String) -> Bool? in props[key] as? Bool }
|
||||||
|
|
||||||
|
// Percent charge: CurrentCapacity is 0–100 on Apple Silicon.
|
||||||
|
let currentCharge = int("CurrentCapacity") ?? 0
|
||||||
|
let isCharging = bool("IsCharging") ?? false
|
||||||
|
let isPluggedIn = bool("ExternalConnected") ?? false
|
||||||
|
let fullyCharged = bool("FullyCharged") ?? false
|
||||||
|
let cycleCount = int("CycleCount")
|
||||||
|
let designCapacity = int("DesignCapacity")
|
||||||
|
// Raw (mAh) capacity is the meaningful "full charge capacity" on AS.
|
||||||
|
let maxCapacity = int("AppleRawMaxCapacity") ?? int("MaxCapacity")
|
||||||
|
let temperature = int("Temperature").map { Double($0) / 100.0 } // centi-°C
|
||||||
|
let voltage = int("Voltage").map { Double($0) / 1000.0 } // mV → V
|
||||||
|
let amperage = int("Amperage")
|
||||||
|
|
||||||
|
let timeToEmpty = normalizedMinutes(int("AvgTimeToEmpty"), valid: !isPluggedIn)
|
||||||
|
let timeToFull = normalizedMinutes(int("AvgTimeToFull"), valid: isCharging)
|
||||||
|
|
||||||
|
let adapterWatts = (props["AdapterDetails"] as? [String: Any])?["Watts"] as? Int
|
||||||
|
|
||||||
|
return PowerInfo(
|
||||||
|
hasBattery: true,
|
||||||
|
currentCharge: currentCharge,
|
||||||
|
isCharging: isCharging,
|
||||||
|
isPluggedIn: isPluggedIn,
|
||||||
|
fullyCharged: fullyCharged,
|
||||||
|
cycleCount: cycleCount,
|
||||||
|
designCapacity: designCapacity,
|
||||||
|
maxCapacity: maxCapacity,
|
||||||
|
temperature: temperature,
|
||||||
|
voltage: voltage,
|
||||||
|
amperage: amperage,
|
||||||
|
timeToEmpty: timeToEmpty,
|
||||||
|
timeToFull: timeToFull,
|
||||||
|
adapterWatts: adapterWatts
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AppleSmartBattery reports 65535 (or 0) for "still calculating"; treat as unknown.
|
||||||
|
private func normalizedMinutes(_ value: Int?, valid: Bool) -> Int? {
|
||||||
|
guard valid, let value, value > 0, value != 65535 else { return nil }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,13 @@ import AppKit
|
|||||||
|
|
||||||
final class ProcessMonitor {
|
final class ProcessMonitor {
|
||||||
private var previousCPUTimes: [pid_t: (user: UInt64, system: UInt64, timestamp: Date)] = [:]
|
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 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 let iconCache = NSCache<NSNumber, NSImage>()
|
||||||
private var noIconPids: Set<pid_t> = [] // Cache for PIDs with no icon
|
private var noIconPids: Set<pid_t> = [] // Cache for PIDs with no icon
|
||||||
private var nameCache: [pid_t: String] = [:]
|
private var nameCache: [pid_t: String] = [:]
|
||||||
|
private var pathCache: [pid_t: String] = [:] // Full executable path
|
||||||
private var userCache: [uid_t: String] = [:]
|
private var userCache: [uid_t: String] = [:]
|
||||||
private let timebaseInfo: mach_timebase_info_data_t
|
private let timebaseInfo: mach_timebase_info_data_t
|
||||||
private var refreshCounter = 0
|
private var refreshCounter = 0
|
||||||
@@ -48,10 +51,13 @@ final class ProcessMonitor {
|
|||||||
let stalePids = Set(nameCache.keys).subtracting(currentPids)
|
let stalePids = Set(nameCache.keys).subtracting(currentPids)
|
||||||
for pid in stalePids {
|
for pid in stalePids {
|
||||||
nameCache.removeValue(forKey: pid)
|
nameCache.removeValue(forKey: pid)
|
||||||
|
pathCache.removeValue(forKey: pid)
|
||||||
iconCache.removeObject(forKey: NSNumber(value: pid))
|
iconCache.removeObject(forKey: NSNumber(value: pid))
|
||||||
previousCPUTimes.removeValue(forKey: pid)
|
previousCPUTimes.removeValue(forKey: pid)
|
||||||
|
previousDiskIO.removeValue(forKey: pid)
|
||||||
noIconPids.remove(pid)
|
noIconPids.remove(pid)
|
||||||
lastKnownCPU.removeValue(forKey: pid)
|
lastKnownCPU.removeValue(forKey: pid)
|
||||||
|
lastDetailed.removeValue(forKey: pid)
|
||||||
}
|
}
|
||||||
|
|
||||||
return processes
|
return processes
|
||||||
@@ -77,9 +83,7 @@ final class ProcessMonitor {
|
|||||||
let lastCPU = lastKnownCPU[pid] ?? 0
|
let lastCPU = lastKnownCPU[pid] ?? 0
|
||||||
let needsDetailedInfo = fullRefresh || lastCPU > 0.1
|
let needsDetailedInfo = fullRefresh || lastCPU > 0.1
|
||||||
|
|
||||||
let memoryUsage: Int64
|
let detail: DetailedSample
|
||||||
let threadCount: Int32
|
|
||||||
let cpuUsage: Double
|
|
||||||
|
|
||||||
if needsDetailedInfo {
|
if needsDetailedInfo {
|
||||||
var taskInfo = proc_taskinfo()
|
var taskInfo = proc_taskinfo()
|
||||||
@@ -89,25 +93,44 @@ final class ProcessMonitor {
|
|||||||
|
|
||||||
if hasTaskInfo {
|
if hasTaskInfo {
|
||||||
let rusageData = fetchRusageData(pid: pid)
|
let rusageData = fetchRusageData(pid: pid)
|
||||||
memoryUsage = rusageData.memory
|
let cpu = calculateCPUUsage(
|
||||||
threadCount = taskInfo.pti_threadnum
|
|
||||||
cpuUsage = calculateCPUUsage(
|
|
||||||
pid: pid,
|
pid: pid,
|
||||||
userTime: rusageData.userTime,
|
userTime: rusageData.userTime,
|
||||||
systemTime: rusageData.systemTime
|
systemTime: rusageData.systemTime
|
||||||
)
|
)
|
||||||
|
let extra = computeExtraRates(
|
||||||
|
pid: pid,
|
||||||
|
diskRead: rusageData.diskRead,
|
||||||
|
diskWrite: rusageData.diskWrite,
|
||||||
|
idleWakeups: rusageData.idleWakeups
|
||||||
|
)
|
||||||
|
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 {
|
} else {
|
||||||
memoryUsage = 0
|
detail = .zero
|
||||||
threadCount = 0
|
|
||||||
cpuUsage = 0
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Lightweight refresh - reuse last known values
|
// Lightweight refresh - reuse last known values. This used to emit
|
||||||
memoryUsage = 0
|
// zeros, so ~90% of rows showed "Zero KB" memory (and 0 threads/CPU)
|
||||||
threadCount = 0
|
// on two of every three updates.
|
||||||
cpuUsage = 0
|
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 state = determineProcessState(status: bsdInfo.pbi_status, cpuUsage: cpuUsage)
|
||||||
|
|
||||||
let icon = fetchIcon(pid: pid)
|
let icon = fetchIcon(pid: pid)
|
||||||
@@ -126,7 +149,15 @@ final class ProcessMonitor {
|
|||||||
state: state,
|
state: state,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
parentPid: parentPid,
|
parentPid: parentPid,
|
||||||
startTime: startTime
|
startTime: startTime,
|
||||||
|
diskReadRate: detail.diskReadRate,
|
||||||
|
diskWriteRate: detail.diskWriteRate,
|
||||||
|
diskReadBytes: detail.diskReadBytes,
|
||||||
|
diskWriteBytes: detail.diskWriteBytes,
|
||||||
|
energyImpact: detail.energy,
|
||||||
|
executablePath: pathCache[pid],
|
||||||
|
residentMemory: detail.resident,
|
||||||
|
compressedMemory: detail.compressed
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +181,7 @@ final class ProcessMonitor {
|
|||||||
// This protects against edge cases where buffer might not be null-terminated
|
// This protects against edge cases where buffer might not be null-terminated
|
||||||
let pathData = Data(bytes: pathBuffer, count: pathLength)
|
let pathData = Data(bytes: pathBuffer, count: pathLength)
|
||||||
if let path = String(data: pathData, encoding: .utf8) {
|
if let path = String(data: pathData, encoding: .utf8) {
|
||||||
|
pathCache[pid] = path
|
||||||
name = (path as NSString).lastPathComponent
|
name = (path as NSString).lastPathComponent
|
||||||
} else {
|
} else {
|
||||||
name = "Process \(pid)"
|
name = "Process \(pid)"
|
||||||
@@ -203,7 +235,35 @@ final class ProcessMonitor {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchRusageData(pid: pid_t) -> (memory: Int64, userTime: UInt64, systemTime: UInt64) {
|
/// 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
|
||||||
|
var diskWrite: UInt64 = 0
|
||||||
|
var idleWakeups: UInt64 = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchRusageData(pid: pid_t) -> RusageData {
|
||||||
var rusage = rusage_info_v4()
|
var rusage = rusage_info_v4()
|
||||||
let result = withUnsafeMutablePointer(to: &rusage) { ptr -> Int32 in
|
let result = withUnsafeMutablePointer(to: &rusage) { ptr -> Int32 in
|
||||||
ptr.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) { rusagePtr in
|
ptr.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) { rusagePtr in
|
||||||
@@ -212,25 +272,47 @@ final class ProcessMonitor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if result == 0 {
|
if result == 0 {
|
||||||
return (
|
return RusageData(
|
||||||
memory: Int64(rusage.ri_phys_footprint),
|
memory: Int64(rusage.ri_phys_footprint),
|
||||||
|
resident: Int64(rusage.ri_resident_size),
|
||||||
userTime: rusage.ri_user_time,
|
userTime: rusage.ri_user_time,
|
||||||
systemTime: rusage.ri_system_time
|
systemTime: rusage.ri_system_time,
|
||||||
|
diskRead: rusage.ri_diskio_bytesread,
|
||||||
|
diskWrite: rusage.ri_diskio_byteswritten,
|
||||||
|
idleWakeups: rusage.ri_pkg_idle_wkups
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to proc_taskinfo if rusage fails
|
// Fallback to proc_taskinfo if rusage fails (no disk/wakeup data available)
|
||||||
var taskInfo = proc_taskinfo()
|
var taskInfo = proc_taskinfo()
|
||||||
let taskInfoSize = Int32(MemoryLayout<proc_taskinfo>.size)
|
let taskInfoSize = Int32(MemoryLayout<proc_taskinfo>.size)
|
||||||
if proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, taskInfoSize) == taskInfoSize {
|
if proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, taskInfoSize) == taskInfoSize {
|
||||||
return (
|
return RusageData(
|
||||||
memory: Int64(taskInfo.pti_resident_size),
|
memory: Int64(taskInfo.pti_resident_size),
|
||||||
|
resident: Int64(taskInfo.pti_resident_size),
|
||||||
userTime: taskInfo.pti_total_user,
|
userTime: taskInfo.pti_total_user,
|
||||||
systemTime: taskInfo.pti_total_system
|
systemTime: taskInfo.pti_total_system
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (memory: 0, userTime: 0, systemTime: 0)
|
return RusageData()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes per-second disk read/write and idle-wakeup rates from cumulative
|
||||||
|
/// counters, using the previous snapshot. Mirrors the CPU delta approach.
|
||||||
|
private func computeExtraRates(pid: pid_t, diskRead: UInt64, diskWrite: UInt64, idleWakeups: UInt64)
|
||||||
|
-> (readRate: Double, writeRate: Double, idleWakeupsPerSec: Double) {
|
||||||
|
let now = Date()
|
||||||
|
defer { previousDiskIO[pid] = (diskRead, diskWrite, idleWakeups, now) }
|
||||||
|
|
||||||
|
guard let prev = previousDiskIO[pid] else { return (0, 0, 0) }
|
||||||
|
let dt = now.timeIntervalSince(prev.timestamp)
|
||||||
|
guard dt > 0 else { return (0, 0, 0) }
|
||||||
|
|
||||||
|
let readRate = diskRead >= prev.read ? Double(diskRead - prev.read) / dt : 0
|
||||||
|
let writeRate = diskWrite >= prev.write ? Double(diskWrite - prev.write) / dt : 0
|
||||||
|
let idleRate = idleWakeups >= prev.idle ? Double(idleWakeups - prev.idle) / dt : 0
|
||||||
|
return (readRate, writeRate, idleRate)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchBasicProcessInfo(pid: pid_t) -> ProcessItem? {
|
private func fetchBasicProcessInfo(pid: pid_t) -> ProcessItem? {
|
||||||
@@ -373,3 +455,78 @@ final class ProcessMonitor {
|
|||||||
previousCPUTimes.removeAll()
|
previousCPUTimes.removeAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - On-demand deep inspection (called only when the inspector is open)
|
||||||
|
|
||||||
|
/// Pure parser for a KERN_PROCARGS2 buffer, split out so it is unit-testable
|
||||||
|
/// without a live process. Layout: [Int32 argc][exec_path\0][\0 padding][argv…][envp…]
|
||||||
|
enum ProcArgs {
|
||||||
|
static func parse(_ bytes: [UInt8]) -> (path: String, args: [String])? {
|
||||||
|
guard bytes.count > 4 else { return nil }
|
||||||
|
// argc: first 4 bytes, host little-endian (arm64/x86_64)
|
||||||
|
let argc = Int(bytes[0]) | (Int(bytes[1]) << 8) | (Int(bytes[2]) << 16) | (Int(bytes[3]) << 24)
|
||||||
|
guard argc >= 0 else { return nil }
|
||||||
|
|
||||||
|
var i = 4
|
||||||
|
let pathStart = i
|
||||||
|
while i < bytes.count && bytes[i] != 0 { i += 1 }
|
||||||
|
let path = String(decoding: bytes[pathStart..<i], as: UTF8.self)
|
||||||
|
|
||||||
|
// Skip the NUL padding between exec_path and argv[0]
|
||||||
|
while i < bytes.count && bytes[i] == 0 { i += 1 }
|
||||||
|
|
||||||
|
var args: [String] = []
|
||||||
|
var consumed = 0
|
||||||
|
while consumed < argc && i < bytes.count {
|
||||||
|
let start = i
|
||||||
|
while i < bytes.count && bytes[i] != 0 { i += 1 }
|
||||||
|
args.append(String(decoding: bytes[start..<i], as: UTF8.self))
|
||||||
|
i += 1 // skip the NUL terminator
|
||||||
|
consumed += 1
|
||||||
|
}
|
||||||
|
return (path, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the launch arguments for a process (works for the current user's
|
||||||
|
/// processes without elevated privileges; returns [] when denied/unavailable).
|
||||||
|
func fetchProcessArguments(pid: pid_t) -> [String] {
|
||||||
|
var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, pid]
|
||||||
|
var size = 0
|
||||||
|
guard sysctl(&mib, 3, nil, &size, nil, 0) == 0, size > 0 else { return [] }
|
||||||
|
|
||||||
|
var buffer = [UInt8](repeating: 0, count: size)
|
||||||
|
guard sysctl(&mib, 3, &buffer, &size, nil, 0) == 0 else { return [] }
|
||||||
|
// sysctl may shrink `size`; only parse what was actually written.
|
||||||
|
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)
|
||||||
|
guard bufferSize > 0 else { return nil }
|
||||||
|
return Int(bufferSize) / MemoryLayout<proc_fdinfo>.stride
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ final class SystemMonitor: ObservableObject {
|
|||||||
|
|
||||||
private let backgroundQueue = DispatchQueue(label: "com.topmanager.monitor", qos: .userInitiated)
|
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 cpuInfo: CPUInfo?
|
||||||
@MainActor @Published var memoryInfo: MemoryInfo?
|
@MainActor @Published var memoryInfo: MemoryInfo?
|
||||||
@MainActor @Published var processes: [ProcessItem] = []
|
@MainActor @Published var processes: [ProcessItem] = []
|
||||||
@MainActor @Published var diskInfo: DiskInfo?
|
@MainActor @Published var diskInfo: DiskInfo?
|
||||||
@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 thermalInfo: ThermalInfo?
|
||||||
@MainActor @Published var lastError: String?
|
@MainActor @Published var lastError: String?
|
||||||
|
|
||||||
// History for charts
|
// History for charts
|
||||||
@@ -21,6 +24,25 @@ final class SystemMonitor: ObservableObject {
|
|||||||
@MainActor @Published var memoryHistory: [MemoryHistoryPoint] = []
|
@MainActor @Published var memoryHistory: [MemoryHistoryPoint] = []
|
||||||
@MainActor @Published var networkHistory: [NetworkHistoryPoint] = []
|
@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
|
// Sub-monitors
|
||||||
private let cpuMonitor = CPUMonitor()
|
private let cpuMonitor = CPUMonitor()
|
||||||
private let memoryMonitor = MemoryMonitor()
|
private let memoryMonitor = MemoryMonitor()
|
||||||
@@ -28,6 +50,8 @@ final class SystemMonitor: ObservableObject {
|
|||||||
private let diskMonitor = DiskMonitor()
|
private let diskMonitor = DiskMonitor()
|
||||||
private let networkMonitor = NetworkMonitor()
|
private let networkMonitor = NetworkMonitor()
|
||||||
private let gpuMonitor = GPUMonitor()
|
private let gpuMonitor = GPUMonitor()
|
||||||
|
private let powerMonitor = PowerMonitor()
|
||||||
|
private let temperatureMonitor = TemperatureMonitor()
|
||||||
|
|
||||||
// Timer
|
// Timer
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
@@ -45,6 +69,8 @@ final class SystemMonitor: ObservableObject {
|
|||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
@MainActor func startMonitoring() {
|
@MainActor func startMonitoring() {
|
||||||
|
guard timer == nil else { return }
|
||||||
|
|
||||||
// Immediate initial fetch
|
// Immediate initial fetch
|
||||||
backgroundQueue.async { [weak self] in
|
backgroundQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -63,24 +89,39 @@ final class SystemMonitor: ObservableObject {
|
|||||||
let processData = self.processMonitor.fetchProcesses()
|
let processData = self.processMonitor.fetchProcesses()
|
||||||
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 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)
|
processes: processData, disk: diskData, gpu: gpuData, power: powerData,
|
||||||
|
thermal: thermalData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start periodic refresh (3 second interval)
|
// Start periodic refresh at the user-configured cadence
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
scheduleTimer(interval: AppSettings.shared.refreshInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleTimer(interval: Double) {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
self?.backgroundQueue.async {
|
self?.backgroundQueue.async {
|
||||||
self?.refreshAllBackground()
|
self?.refreshAllBackground()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Change the refresh cadence at runtime (from Settings). No-op if not monitoring.
|
||||||
|
@MainActor func updateRefreshInterval(_ interval: Double) {
|
||||||
|
guard timer != nil else { return }
|
||||||
|
scheduleTimer(interval: AppSettings.clampInterval(interval))
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor func stopMonitoring() {
|
@MainActor func stopMonitoring() {
|
||||||
timer?.invalidate()
|
timer?.invalidate()
|
||||||
timer = nil
|
timer = nil
|
||||||
|
MetricsStore.shared.flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshAllBackground() {
|
private func refreshAllBackground() {
|
||||||
@@ -91,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
|
||||||
@@ -98,84 +140,134 @@ final class SystemMonitor: ObservableObject {
|
|||||||
processData = processMonitor.fetchProcesses()
|
processData = processMonitor.fetchProcesses()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch disk and GPU every 3 cycles
|
// Fetch disk, GPU and power every 3 cycles
|
||||||
var diskData: DiskInfo? = nil
|
var diskData: DiskInfo? = nil
|
||||||
var gpuData: GPUInfo? = nil
|
var gpuData: GPUInfo? = nil
|
||||||
|
var powerData: PowerInfo? = nil
|
||||||
if currentCount % 3 == 0 {
|
if currentCount % 3 == 0 {
|
||||||
diskData = diskMonitor.fetchDiskInfo()
|
diskData = diskMonitor.fetchDiskInfo()
|
||||||
gpuData = gpuMonitor.fetchGPUInfo()
|
gpuData = gpuMonitor.fetchGPUInfo()
|
||||||
|
powerData = powerMonitor.fetchPowerInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
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?, thermal: ThermalInfo?) {
|
||||||
if let info = cpu {
|
if let info = cpu {
|
||||||
cpuInfo = info
|
live.cpuInfo = info
|
||||||
let historyPoint = CPUHistoryPoint(
|
live.cpuHistory.append(CPUHistoryPoint(
|
||||||
timestamp: info.timestamp,
|
timestamp: info.timestamp,
|
||||||
usage: info.globalUsage,
|
usage: info.globalUsage,
|
||||||
userUsage: info.userUsage,
|
userUsage: info.userUsage,
|
||||||
systemUsage: info.systemUsage
|
systemUsage: info.systemUsage
|
||||||
)
|
))
|
||||||
cpuHistory.append(historyPoint)
|
if live.cpuHistory.count > historyLimit {
|
||||||
if cpuHistory.count > historyLimit {
|
live.cpuHistory.removeFirst()
|
||||||
cpuHistory.removeFirst()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for core in info.coreUsages {
|
for core in info.coreUsages {
|
||||||
let corePoint = CoreHistoryPoint(timestamp: info.timestamp, usage: core.usage)
|
live.coreHistories[core.id, default: []].append(
|
||||||
if coreHistories[core.id] == nil {
|
CoreHistoryPoint(timestamp: info.timestamp, usage: core.usage))
|
||||||
coreHistories[core.id] = []
|
if live.coreHistories[core.id, default: []].count > historyLimit {
|
||||||
}
|
live.coreHistories[core.id]?.removeFirst()
|
||||||
coreHistories[core.id]?.append(corePoint)
|
|
||||||
if coreHistories[core.id]?.count ?? 0 > historyLimit {
|
|
||||||
coreHistories[core.id]?.removeFirst()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let info = memory {
|
if let info = memory {
|
||||||
memoryInfo = info
|
live.memoryInfo = info
|
||||||
let historyPoint = MemoryHistoryPoint(
|
live.memoryHistory.append(MemoryHistoryPoint(
|
||||||
timestamp: info.timestamp,
|
timestamp: info.timestamp,
|
||||||
usedMemory: info.usedMemory,
|
usedMemory: info.usedMemory,
|
||||||
totalMemory: info.totalMemory
|
totalMemory: info.totalMemory
|
||||||
)
|
))
|
||||||
memoryHistory.append(historyPoint)
|
if live.memoryHistory.count > historyLimit {
|
||||||
if memoryHistory.count > historyLimit {
|
live.memoryHistory.removeFirst()
|
||||||
memoryHistory.removeFirst()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let info = network {
|
if let info = network {
|
||||||
networkInfo = info
|
live.networkInfo = info
|
||||||
let historyPoint = NetworkHistoryPoint(
|
live.networkHistory.append(NetworkHistoryPoint(
|
||||||
timestamp: info.timestamp,
|
timestamp: info.timestamp,
|
||||||
downloadRate: info.totalDownloadRate,
|
downloadRate: info.totalDownloadRate,
|
||||||
uploadRate: info.totalUploadRate
|
uploadRate: info.totalUploadRate
|
||||||
|
))
|
||||||
|
if live.networkHistory.count > historyLimit {
|
||||||
|
live.networkHistory.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 = live.cpuInfo, let m = live.memoryInfo {
|
||||||
|
MetricsStore.shared.record(MetricsSample(
|
||||||
|
t: c.timestamp,
|
||||||
|
cpu: c.globalUsage,
|
||||||
|
memUsed: m.usedMemory,
|
||||||
|
memTotal: m.totalMemory,
|
||||||
|
netDown: live.networkInfo?.totalDownloadRate ?? 0,
|
||||||
|
netUp: live.networkInfo?.totalUploadRate ?? 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proactive health + alert evaluation (runs every cycle).
|
||||||
|
AlertCenter.shared.evaluate(
|
||||||
|
cpuUsage: live.cpuInfo?.globalUsage ?? 0,
|
||||||
|
memory: live.memoryInfo,
|
||||||
|
disk: live.diskInfo,
|
||||||
|
thermal: currentThermalLevel(),
|
||||||
|
topProcess: live.processes.max { $0.cpuUsage < $1.cpuUsage },
|
||||||
|
power: live.powerInfo
|
||||||
)
|
)
|
||||||
networkHistory.append(historyPoint)
|
|
||||||
if networkHistory.count > historyLimit {
|
|
||||||
networkHistory.removeFirst()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let procs = processes {
|
/// Pause (`false`) or resume (`true`) publishing to views. Resuming catches
|
||||||
self.processes = procs
|
/// 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() }
|
||||||
}
|
}
|
||||||
|
|
||||||
if let info = disk {
|
@MainActor private func publishLive() {
|
||||||
diskInfo = info
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
if let info = gpu {
|
private func currentThermalLevel() -> ThermalLevel {
|
||||||
gpuInfo = info
|
switch ProcessInfo.processInfo.thermalState {
|
||||||
|
case .nominal: return .nominal
|
||||||
|
case .fair: return .fair
|
||||||
|
case .serious: return .serious
|
||||||
|
case .critical: return .critical
|
||||||
|
@unknown default: return .nominal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,73 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// Flushes persisted metrics history when the app is quit, and lets alert banners
|
||||||
|
/// appear even while TopManager is the foreground app (macOS suppresses an app's
|
||||||
|
/// own notifications in the foreground unless the delegate opts in).
|
||||||
|
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,
|
||||||
|
willPresent notification: UNNotification,
|
||||||
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||||
|
completionHandler([.banner, .sound, .list])
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
|
MetricsStore.shared.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct TopManagerApp: App {
|
struct TopManagerApp: App {
|
||||||
@StateObject private var monitor = SystemMonitor.shared
|
@StateObject private var monitor = SystemMonitor.shared
|
||||||
|
@StateObject private var settings = AppSettings.shared
|
||||||
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(monitor)
|
.environmentObject(monitor)
|
||||||
.onAppear {
|
.environmentObject(AlertCenter.shared)
|
||||||
monitor.startMonitoring()
|
.environmentObject(settings)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.windowStyle(.automatic)
|
.windowStyle(.automatic)
|
||||||
.defaultSize(width: 1000, height: 700)
|
.defaultSize(width: 1000, height: 700)
|
||||||
|
.onChange(of: scenePhase) { phase in
|
||||||
|
if phase == .background {
|
||||||
|
MetricsStore.shared.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
.commands {
|
.commands {
|
||||||
CommandGroup(replacing: .newItem) { }
|
CommandGroup(replacing: .newItem) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Menu Bar Extra showing CPU %
|
Settings {
|
||||||
|
SettingsView()
|
||||||
|
.environmentObject(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu Bar Extra with configurable metric
|
||||||
MenuBarExtra {
|
MenuBarExtra {
|
||||||
MenuBarView()
|
MenuBarView()
|
||||||
.environmentObject(monitor)
|
.environmentObject(monitor)
|
||||||
|
.environmentObject(AlertCenter.shared)
|
||||||
|
.environmentObject(settings)
|
||||||
} label: {
|
} label: {
|
||||||
if let cpu = monitor.cpuInfo {
|
MenuBarLabel(status: MenuBarStatus.shared, settings: settings, alerts: AlertCenter.shared)
|
||||||
Text(String(format: "%.0f%%", cpu.globalUsage))
|
|
||||||
.monospacedDigit()
|
|
||||||
} else {
|
|
||||||
Image(systemName: "cpu")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.menuBarExtraStyle(.window)
|
.menuBarExtraStyle(.window)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The main window's tab shell.
|
||||||
|
///
|
||||||
|
/// IMPORTANT: this view must stay free of any observable dependency
|
||||||
|
/// (`@EnvironmentObject`, `@StateObject`, `@State`, …). On macOS, every
|
||||||
|
/// re-evaluation of a body containing `.tabItem` permanently retains the
|
||||||
|
/// wrapped `AnyView(Label(…))` inside the AppKit tab bridge, so a body that
|
||||||
|
/// re-runs on the monitor's refresh cadence leaks ~6 KB per tick (≈440 MB over
|
||||||
|
/// three days). With no stored properties SwiftUI evaluates this body exactly
|
||||||
|
/// once. The tab *contents* observe `SystemMonitor` themselves.
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@EnvironmentObject var monitor: SystemMonitor
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
TabView {
|
||||||
ProcessView()
|
ProcessView()
|
||||||
|
|||||||
135
TopManager/Views/Health/HealthView.swift
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// System Health summary card: a 0–100 score, rating, and plain-language
|
||||||
|
/// diagnosis of what (if anything) is wrong right now.
|
||||||
|
struct HealthCardView: View {
|
||||||
|
@EnvironmentObject var alertCenter: AlertCenter
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GroupBox {
|
||||||
|
HStack(alignment: .top, spacing: 20) {
|
||||||
|
// Score ring
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.secondary.opacity(0.2), lineWidth: 10)
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: CGFloat(alertCenter.healthScore) / 100)
|
||||||
|
.stroke(scoreColor, style: StrokeStyle(lineWidth: 10, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text("\(alertCenter.healthScore)")
|
||||||
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||||
|
.monospacedDigit()
|
||||||
|
Text(HealthModel.rating(alertCenter.healthScore))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 96, height: 96)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Label("System Health", systemImage: "heart.text.square")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
if alertCenter.activeAlertCount > 0 {
|
||||||
|
Label("\(alertCenter.activeAlertCount) active", systemImage: "bell.badge.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if alertCenter.diagnosis.isEmpty {
|
||||||
|
Label("Everything looks healthy.", systemImage: "checkmark.circle.fill")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
} else {
|
||||||
|
ForEach(alertCenter.diagnosis, id: \.self) { issue in
|
||||||
|
Label(issue, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scoreColor: Color {
|
||||||
|
switch alertCenter.healthScore {
|
||||||
|
case 85...: return .green
|
||||||
|
case 70..<85: return .mint
|
||||||
|
case 50..<70: return .orange
|
||||||
|
default: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inbox of recent alerts fired by the AlertCenter.
|
||||||
|
struct AlertsInboxView: View {
|
||||||
|
@EnvironmentObject var alertCenter: AlertCenter
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GroupBox {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Label("Recent Alerts", systemImage: "bell")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
if !alertCenter.alerts.isEmpty {
|
||||||
|
Button("Clear") { alertCenter.clearInbox() }
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if alertCenter.alerts.isEmpty {
|
||||||
|
Text("No alerts. TopManager will notify you if something needs attention.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(alertCenter.alerts.prefix(8)) { alert in
|
||||||
|
AlertRowView(alert: alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AlertRowView: View {
|
||||||
|
let alert: SystemAlert
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: alert.severity.symbol)
|
||||||
|
.foregroundColor(severityColor)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack {
|
||||||
|
Text(alert.title).fontWeight(.medium)
|
||||||
|
Spacer()
|
||||||
|
Text(alert.timestamp, style: .time)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Text(alert.message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var severityColor: Color {
|
||||||
|
switch alert.severity {
|
||||||
|
case .info: return .blue
|
||||||
|
case .warning: return .orange
|
||||||
|
case .critical: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,57 @@
|
|||||||
import SwiftUI
|
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 status: MenuBarStatus
|
||||||
|
@ObservedObject var settings: AppSettings
|
||||||
|
@ObservedObject var alerts: AlertCenter
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch settings.menuBarMetric {
|
||||||
|
case .cpu:
|
||||||
|
textOrIcon(status.cpuText, icon: "cpu")
|
||||||
|
case .memory:
|
||||||
|
textOrIcon(status.memoryText, icon: "memorychip")
|
||||||
|
case .health:
|
||||||
|
Text("♥ \(alerts.healthScore)").monospacedDigit()
|
||||||
|
case .download:
|
||||||
|
textOrIcon(status.downloadText, icon: "arrow.down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func textOrIcon(_ text: String?, icon: String) -> some View {
|
||||||
|
if let text { Text(text).monospacedDigit() } else { Image(systemName: icon) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct MenuBarView: View {
|
struct MenuBarView: View {
|
||||||
@EnvironmentObject var monitor: SystemMonitor
|
@EnvironmentObject var monitor: SystemMonitor
|
||||||
|
@EnvironmentObject var alertCenter: AlertCenter
|
||||||
|
|
||||||
|
private var topCPU: [ProcessItem] {
|
||||||
|
monitor.processes.sorted { $0.cpuUsage > $1.cpuUsage }.prefix(3).map { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "heart.text.square")
|
||||||
|
.frame(width: 16)
|
||||||
|
.foregroundColor(healthColor)
|
||||||
|
Text("Health:")
|
||||||
|
Spacer()
|
||||||
|
Text("\(alertCenter.healthScore)/100")
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundColor(healthColor)
|
||||||
|
if alertCenter.activeAlertCount > 0 {
|
||||||
|
Image(systemName: "bell.badge.fill")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
|
||||||
if let cpu = monitor.cpuInfo {
|
if let cpu = monitor.cpuInfo {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "cpu")
|
Image(systemName: "cpu")
|
||||||
@@ -58,6 +105,46 @@ struct MenuBarView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let power = monitor.powerInfo, power.hasBattery {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: power.isCharging ? "battery.100.bolt" : "battery.100")
|
||||||
|
.frame(width: 16)
|
||||||
|
Text("Battery:")
|
||||||
|
Spacer()
|
||||||
|
Text("\(power.currentCharge)%")
|
||||||
|
.monospacedDigit()
|
||||||
|
if let toEmpty = power.timeToEmpty {
|
||||||
|
Text("(\(BatteryMath.formatMinutes(toEmpty)))")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !topCPU.isEmpty {
|
||||||
|
Divider()
|
||||||
|
Text("Top CPU Consumers")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
ForEach(topCPU) { process in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(process.name)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.0f%%", process.cpuUsage))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Button {
|
||||||
|
_ = monitor.terminateProcess(process.pid, expectedStartTime: process.startTime)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Quit \(process.name)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button("Open TopManager") {
|
Button("Open TopManager") {
|
||||||
@@ -73,7 +160,16 @@ struct MenuBarView: View {
|
|||||||
.keyboardShortcut("q")
|
.keyboardShortcut("q")
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.frame(width: 220)
|
.frame(width: 240)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var healthColor: Color {
|
||||||
|
switch alertCenter.healthScore {
|
||||||
|
case 85...: return .green
|
||||||
|
case 70..<85: return .mint
|
||||||
|
case 50..<70: return .orange
|
||||||
|
default: return .red
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,26 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Charts
|
import Charts
|
||||||
|
|
||||||
|
/// Sub-tab shell for the Performance tab. The selection is left to SwiftUI on
|
||||||
|
/// purpose: an explicit `@State` binding would re-evaluate this body — and so
|
||||||
|
/// leak its `.tabItem` labels — on every tab switch. See `ContentView`.
|
||||||
struct PerformanceView: View {
|
struct PerformanceView: View {
|
||||||
@State private var selectedTab = 0
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView {
|
||||||
CPUTabView()
|
CPUTabView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("CPU", systemImage: "cpu")
|
Label("CPU", systemImage: "cpu")
|
||||||
}
|
}
|
||||||
.tag(0)
|
|
||||||
|
|
||||||
MemoryTabView()
|
MemoryTabView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Memory", systemImage: "memorychip")
|
Label("Memory", systemImage: "memorychip")
|
||||||
}
|
}
|
||||||
.tag(1)
|
|
||||||
|
|
||||||
NetworkTabView()
|
NetworkTabView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Network", systemImage: "network")
|
Label("Network", systemImage: "network")
|
||||||
}
|
}
|
||||||
.tag(2)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,6 +29,7 @@ struct PerformanceView: View {
|
|||||||
|
|
||||||
struct CPUTabView: View {
|
struct CPUTabView: View {
|
||||||
@EnvironmentObject var monitor: SystemMonitor
|
@EnvironmentObject var monitor: SystemMonitor
|
||||||
|
@State private var range: HistoryRange = .live
|
||||||
|
|
||||||
private let coreColumns = [
|
private let coreColumns = [
|
||||||
GridItem(.flexible()),
|
GridItem(.flexible()),
|
||||||
@@ -48,6 +47,7 @@ struct CPUTabView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Text("Total CPU Usage")
|
Text("Total CPU Usage")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
HistoryRangePicker(range: $range)
|
||||||
Spacer()
|
Spacer()
|
||||||
if let cpu = monitor.cpuInfo {
|
if let cpu = monitor.cpuInfo {
|
||||||
Text(String(format: "%.1f%%", cpu.globalUsage))
|
Text(String(format: "%.1f%%", cpu.globalUsage))
|
||||||
@@ -57,8 +57,13 @@ struct CPUTabView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if range == .live {
|
||||||
CPULineChart(history: monitor.cpuHistory)
|
CPULineChart(history: monitor.cpuHistory)
|
||||||
.frame(height: 120)
|
.frame(height: 120)
|
||||||
|
} else {
|
||||||
|
MetricTrendChart(samples: MetricsStore.shared.downsampled(for: range), metric: .cpu)
|
||||||
|
.frame(height: 120)
|
||||||
|
}
|
||||||
|
|
||||||
// Legend
|
// Legend
|
||||||
HStack(spacing: 20) {
|
HStack(spacing: 20) {
|
||||||
@@ -201,6 +206,7 @@ struct CoreLineChartView: View {
|
|||||||
|
|
||||||
struct MemoryTabView: View {
|
struct MemoryTabView: View {
|
||||||
@EnvironmentObject var monitor: SystemMonitor
|
@EnvironmentObject var monitor: SystemMonitor
|
||||||
|
@State private var range: HistoryRange = .live
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -230,6 +236,7 @@ struct MemoryTabView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Text("Memory Usage Over Time")
|
Text("Memory Usage Over Time")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
HistoryRangePicker(range: $range)
|
||||||
Spacer()
|
Spacer()
|
||||||
if let mem = monitor.memoryInfo {
|
if let mem = monitor.memoryInfo {
|
||||||
Text(String(format: "%.1f%%", mem.usagePercentage))
|
Text(String(format: "%.1f%%", mem.usagePercentage))
|
||||||
@@ -239,8 +246,13 @@ struct MemoryTabView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if range == .live {
|
||||||
MemoryLineChart(history: monitor.memoryHistory)
|
MemoryLineChart(history: monitor.memoryHistory)
|
||||||
.frame(height: 150)
|
.frame(height: 150)
|
||||||
|
} else {
|
||||||
|
MetricTrendChart(samples: MetricsStore.shared.downsampled(for: range), metric: .memory)
|
||||||
|
.frame(height: 150)
|
||||||
|
}
|
||||||
|
|
||||||
if let mem = monitor.memoryInfo {
|
if let mem = monitor.memoryInfo {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -394,6 +406,7 @@ struct MemoryStatItem: View {
|
|||||||
|
|
||||||
struct NetworkTabView: View {
|
struct NetworkTabView: View {
|
||||||
@EnvironmentObject var monitor: SystemMonitor
|
@EnvironmentObject var monitor: SystemMonitor
|
||||||
|
@State private var range: HistoryRange = .live
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -405,6 +418,7 @@ struct NetworkTabView: View {
|
|||||||
Label("Download", systemImage: "arrow.down.circle")
|
Label("Download", systemImage: "arrow.down.circle")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(.blue)
|
.foregroundColor(.blue)
|
||||||
|
HistoryRangePicker(range: $range)
|
||||||
Spacer()
|
Spacer()
|
||||||
if let net = monitor.networkInfo {
|
if let net = monitor.networkInfo {
|
||||||
Text(formatBytesPerSecond(net.totalDownloadRate))
|
Text(formatBytesPerSecond(net.totalDownloadRate))
|
||||||
@@ -414,8 +428,13 @@ struct NetworkTabView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if range == .live {
|
||||||
DownloadLineChart(history: monitor.networkHistory)
|
DownloadLineChart(history: monitor.networkHistory)
|
||||||
.frame(height: 150)
|
.frame(height: 150)
|
||||||
|
} else {
|
||||||
|
MetricTrendChart(samples: MetricsStore.shared.downsampled(for: range), metric: .download)
|
||||||
|
.frame(height: 150)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
@@ -436,8 +455,13 @@ struct NetworkTabView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if range == .live {
|
||||||
UploadLineChart(history: monitor.networkHistory)
|
UploadLineChart(history: monitor.networkHistory)
|
||||||
.frame(height: 150)
|
.frame(height: 150)
|
||||||
|
} else {
|
||||||
|
MetricTrendChart(samples: MetricsStore.shared.downsampled(for: range), metric: .upload)
|
||||||
|
.frame(height: 150)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
|
|||||||
105
TopManager/Views/Performance/TrendChartView.swift
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
/// A range selector segmented control used above the live/trend charts.
|
||||||
|
struct HistoryRangePicker: View {
|
||||||
|
@Binding var range: HistoryRange
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Picker("", selection: $range) {
|
||||||
|
ForEach(HistoryRange.allCases) { r in
|
||||||
|
Text(r.rawValue).tag(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.labelsHidden()
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store-backed trend chart. Plots one metric of `MetricsSample` over a chosen
|
||||||
|
/// window. Used for the non-live ranges (5m / 30m / 1h / 24h).
|
||||||
|
struct MetricTrendChart: View {
|
||||||
|
enum Metric { case cpu, memory, download, upload }
|
||||||
|
|
||||||
|
let samples: [MetricsSample]
|
||||||
|
let metric: Metric
|
||||||
|
|
||||||
|
private var color: Color {
|
||||||
|
switch metric {
|
||||||
|
case .cpu: return .gray
|
||||||
|
case .memory: return .purple
|
||||||
|
case .download: return .blue
|
||||||
|
case .upload: return .green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func value(_ s: MetricsSample) -> Double {
|
||||||
|
switch metric {
|
||||||
|
case .cpu: return s.cpu
|
||||||
|
case .memory: return s.memPercent
|
||||||
|
case .download: return s.netDown
|
||||||
|
case .upload: return s.netUp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isPercent: Bool {
|
||||||
|
metric == .cpu || metric == .memory
|
||||||
|
}
|
||||||
|
|
||||||
|
private var yDomain: ClosedRange<Double> {
|
||||||
|
if isPercent { return -5...105 }
|
||||||
|
let maxV = max(samples.map(value).max() ?? 0, 1024)
|
||||||
|
return 0...(maxV * 1.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if samples.isEmpty {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
Text("Collecting history…")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
Chart {
|
||||||
|
ForEach(samples) { point in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Time", point.t),
|
||||||
|
y: .value("Value", value(point))
|
||||||
|
)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 1.5))
|
||||||
|
|
||||||
|
AreaMark(
|
||||||
|
x: .value("Time", point.t),
|
||||||
|
y: .value("Value", value(point))
|
||||||
|
)
|
||||||
|
.foregroundStyle(color.opacity(0.12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYScale(domain: yDomain)
|
||||||
|
.chartYAxis {
|
||||||
|
AxisMarks(position: .leading) { value in
|
||||||
|
AxisGridLine()
|
||||||
|
AxisValueLabel {
|
||||||
|
if let v = value.as(Double.self) {
|
||||||
|
Text(isPercent ? "\(Int(v))%" : formatBytesPerSecond(v))
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartXAxis {
|
||||||
|
AxisMarks(values: .automatic(desiredCount: 4)) { _ in
|
||||||
|
AxisGridLine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.drawingGroup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ struct PowerStorageView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
|
// System Health score + diagnosis
|
||||||
|
HealthCardView()
|
||||||
|
|
||||||
// System Status and GPU side by side
|
// System Status and GPU side by side
|
||||||
HStack(alignment: .top, spacing: 20) {
|
HStack(alignment: .top, spacing: 20) {
|
||||||
// System Status (left column)
|
// System Status (left column)
|
||||||
@@ -34,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:")
|
||||||
@@ -141,6 +156,11 @@ struct PowerStorageView: View {
|
|||||||
}
|
}
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
// Battery & Power
|
||||||
|
if let power = monitor.powerInfo {
|
||||||
|
BatteryStatusView(power: power)
|
||||||
|
}
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
GroupBox {
|
GroupBox {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -182,6 +202,118 @@ 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 {
|
||||||
|
let power: PowerInfo
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GroupBox {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Label("Battery & Power", systemImage: batteryIcon)
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: power.isPluggedIn ? "powerplug.fill" : "battery.100")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(power.powerSourceLabel)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if power.hasBattery {
|
||||||
|
HStack {
|
||||||
|
Text("Charge:")
|
||||||
|
Spacer()
|
||||||
|
if power.isCharging {
|
||||||
|
Image(systemName: "bolt.fill").foregroundColor(.yellow)
|
||||||
|
}
|
||||||
|
Text("\(power.currentCharge)%").monospacedDigit()
|
||||||
|
}
|
||||||
|
ProgressView(value: Double(power.currentCharge), total: 100)
|
||||||
|
.tint(chargeColor)
|
||||||
|
|
||||||
|
row("Condition") {
|
||||||
|
Text(power.condition.rawValue)
|
||||||
|
.foregroundColor(conditionColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let health = power.healthPercent {
|
||||||
|
row("Health (max/design)") {
|
||||||
|
Text(String(format: "%.0f%%", health)).monospacedDigit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let cycles = power.cycleCount {
|
||||||
|
row("Cycle Count") { Text("\(cycles)").monospacedDigit() }
|
||||||
|
}
|
||||||
|
if let temp = power.temperature {
|
||||||
|
row("Temperature") { Text(String(format: "%.1f °C", temp)).monospacedDigit() }
|
||||||
|
}
|
||||||
|
if let watts = power.powerWatts {
|
||||||
|
row(power.isCharging ? "Charge Power" : "Power Draw") {
|
||||||
|
Text(String(format: "%.1f W", abs(watts))).monospacedDigit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let toEmpty = power.timeToEmpty {
|
||||||
|
row("Time Remaining") { Text(BatteryMath.formatMinutes(toEmpty)).monospacedDigit() }
|
||||||
|
}
|
||||||
|
if let toFull = power.timeToFull {
|
||||||
|
row("Time to Full") { Text(BatteryMath.formatMinutes(toFull)).monospacedDigit() }
|
||||||
|
}
|
||||||
|
if let adapter = power.adapterWatts, adapter > 0 {
|
||||||
|
row("Power Adapter") { Text("\(adapter) W").monospacedDigit() }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("No battery detected — running on AC power.")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row<Content: View>(_ label: String, @ViewBuilder value: () -> Content) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(label + ":")
|
||||||
|
Spacer()
|
||||||
|
value()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var batteryIcon: String {
|
||||||
|
if !power.hasBattery { return "powerplug" }
|
||||||
|
if power.isCharging { return "battery.100.bolt" }
|
||||||
|
switch power.currentCharge {
|
||||||
|
case ..<15: return "battery.25"
|
||||||
|
case ..<50: return "battery.50"
|
||||||
|
case ..<85: return "battery.75"
|
||||||
|
default: return "battery.100"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chargeColor: Color {
|
||||||
|
if power.currentCharge < 15 { return .red }
|
||||||
|
if power.currentCharge < 30 { return .orange }
|
||||||
|
return .green
|
||||||
|
}
|
||||||
|
|
||||||
|
private var conditionColor: Color {
|
||||||
|
switch power.condition {
|
||||||
|
case .normal: return .green
|
||||||
|
case .serviceRecommended: return .orange
|
||||||
|
case .unknown: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ThermalStateView: View {
|
struct ThermalStateView: View {
|
||||||
|
|||||||
@@ -1,34 +1,68 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
|
||||||
struct ProcessDetailView: View {
|
/// Deep-dive inspector for a single process, presented as a sheet. Shows live
|
||||||
let process: ProcessItem
|
/// stats (looked up from the monitor by PID) plus on-demand details that are too
|
||||||
|
/// expensive to compute for every row every cycle: full path, launch arguments,
|
||||||
|
/// and open-file-descriptor count.
|
||||||
|
struct ProcessInspectorSheet: View {
|
||||||
|
let initialProcess: ProcessItem
|
||||||
|
@EnvironmentObject var monitor: SystemMonitor
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var arguments: [String] = []
|
||||||
|
@State private var openFiles: Int?
|
||||||
|
@State private var loadedDetails = false
|
||||||
|
|
||||||
|
/// Prefer the latest live snapshot for this PID; fall back to the one we opened with.
|
||||||
|
private var process: ProcessItem {
|
||||||
|
monitor.processes.first { $0.pid == initialProcess.pid } ?? initialProcess
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
// Header
|
header
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
liveStats
|
||||||
|
memoryExplanation
|
||||||
|
pathSection
|
||||||
|
detailsSection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 460, height: 560)
|
||||||
|
.task(id: initialProcess.pid) {
|
||||||
|
let pid = initialProcess.pid
|
||||||
|
let result = await Task.detached(priority: .userInitiated) {
|
||||||
|
(args: fetchProcessArguments(pid: pid), files: fetchOpenFileCount(pid: pid))
|
||||||
|
}.value
|
||||||
|
arguments = result.args
|
||||||
|
openFiles = result.files
|
||||||
|
loadedDetails = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
if let icon = process.icon {
|
if let icon = process.icon {
|
||||||
Image(nsImage: icon)
|
Image(nsImage: icon).resizable().frame(width: 44, height: 44)
|
||||||
.resizable()
|
|
||||||
.frame(width: 48, height: 48)
|
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "app.dashed")
|
Image(systemName: "app.dashed").font(.system(size: 40)).foregroundColor(.secondary)
|
||||||
.font(.system(size: 48))
|
|
||||||
}
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
VStack(alignment: .leading) {
|
Text(process.name).font(.title2).fontWeight(.semibold).lineLimit(1)
|
||||||
Text(process.name)
|
Text("PID \(process.pid) • \(process.user)").foregroundColor(.secondary)
|
||||||
.font(.title2)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
|
|
||||||
Text("PID: \(process.pid)")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
HStack(spacing: 4) {
|
||||||
VStack(alignment: .trailing) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: process.state.symbol)
|
Image(systemName: process.state.symbol)
|
||||||
Text(process.state.rawValue)
|
Text(process.state.rawValue)
|
||||||
}
|
}
|
||||||
@@ -36,28 +70,91 @@ struct ProcessDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
private var liveStats: some View {
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())],
|
||||||
// Details grid
|
alignment: .leading, spacing: 12) {
|
||||||
LazyVGrid(columns: [
|
DetailRow(label: "CPU (core)", value: String(format: "%.1f%%", process.cpuUsage))
|
||||||
GridItem(.flexible()),
|
DetailRow(label: "CPU (total)", value: String(format: "%.2f%%", process.cpuUsageTotal))
|
||||||
GridItem(.flexible())
|
DetailRow(label: "Energy", value: String(format: "%.1f", process.energyImpact))
|
||||||
], alignment: .leading, spacing: 12) {
|
|
||||||
DetailRow(label: "CPU Usage", value: String(format: "%.1f%%", process.cpuUsage))
|
|
||||||
DetailRow(label: "Memory", value: formatBytes(process.memoryUsage))
|
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: "Threads", value: "\(process.threadCount)")
|
||||||
DetailRow(label: "User", value: process.user)
|
DetailRow(label: "Open files", value: openFiles.map(String.init) ?? "…")
|
||||||
|
DetailRow(label: "Disk read/s", value: process.diskReadRate > 0 ? formatBytesPerSecond(process.diskReadRate) : "—")
|
||||||
|
DetailRow(label: "Disk write/s", value: process.diskWriteRate > 0 ? formatBytesPerSecond(process.diskWriteRate) : "—")
|
||||||
|
DetailRow(label: "Disk total", value: formatBytes(process.diskReadBytes + process.diskWriteBytes))
|
||||||
DetailRow(label: "Parent PID", value: "\(process.parentPid)")
|
DetailRow(label: "Parent PID", value: "\(process.parentPid)")
|
||||||
|
if let start = process.startTime {
|
||||||
if let startTime = process.startTime {
|
DetailRow(label: "Started", value: formatDate(start))
|
||||||
DetailRow(label: "Started", value: formatDate(startTime))
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
||||||
|
Text("Path").font(.caption).foregroundColor(.secondary)
|
||||||
|
Text(path)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.lineLimit(3)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var detailsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Launch arguments").font(.caption).foregroundColor(.secondary)
|
||||||
|
if !loadedDetails {
|
||||||
|
Text("Loading…").font(.caption).foregroundColor(.secondary)
|
||||||
|
} else if arguments.isEmpty {
|
||||||
|
Text("Unavailable (process may be owned by another user)")
|
||||||
|
.font(.caption).foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
Text(arguments.joined(separator: " "))
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
HStack {
|
||||||
|
if let path = process.executablePath {
|
||||||
|
Button {
|
||||||
|
NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: "")
|
||||||
|
} label: { Label("Reveal in Finder", systemImage: "folder") }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(path, forType: .string)
|
||||||
|
} label: { Label("Copy Path", systemImage: "doc.on.doc") }
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Button("Close") { dismiss() }.keyboardShortcut(.defaultAction)
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
.frame(minWidth: 300)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stateColor(_ state: ProcessState) -> Color {
|
private func stateColor(_ state: ProcessState) -> Color {
|
||||||
@@ -95,17 +192,14 @@ struct DetailRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ProcessDetailView(process: ProcessItem(
|
ProcessInspectorSheet(initialProcess: ProcessItem(
|
||||||
pid: 1234,
|
pid: 1234, name: "Safari", user: "ariel",
|
||||||
name: "Safari",
|
cpuUsage: 12.5, cpuUsageTotal: 1.56,
|
||||||
user: "ariel",
|
memoryUsage: 512 * 1024 * 1024, threadCount: 42,
|
||||||
cpuUsage: 12.5,
|
state: .running, icon: nil, parentPid: 1, startTime: Date(),
|
||||||
cpuUsageTotal: 1.56,
|
diskReadRate: 2048, diskWriteRate: 1024,
|
||||||
memoryUsage: 512 * 1024 * 1024,
|
diskReadBytes: 900_000, diskWriteBytes: 400_000,
|
||||||
threadCount: 42,
|
energyImpact: 14.2, executablePath: "/Applications/Safari.app/Contents/MacOS/Safari"
|
||||||
state: .running,
|
|
||||||
icon: nil,
|
|
||||||
parentPid: 1,
|
|
||||||
startTime: Date()
|
|
||||||
))
|
))
|
||||||
|
.environmentObject(SystemMonitor.shared)
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
import SwiftUI
|
||||||
|
|
||||||
enum ProcessSortColumn: String {
|
enum ProcessSortColumn: String {
|
||||||
case name, pid, cpu, cpuTotal, memory, threads, user, state
|
case name, pid, cpu, cpuTotal, memory, resident, compressed, threads, user, state, energy, disk
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ProcessView: View {
|
struct ProcessView: View {
|
||||||
@@ -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
|
||||||
@@ -30,6 +24,9 @@ struct ProcessView: View {
|
|||||||
@State private var errorTitle = ""
|
@State private var errorTitle = ""
|
||||||
@State private var errorMessage = ""
|
@State private var errorMessage = ""
|
||||||
|
|
||||||
|
// Deep-dive inspector
|
||||||
|
@State private var inspectorProcess: ProcessItem?
|
||||||
|
|
||||||
// Protected system processes that cannot be force-killed via UI
|
// Protected system processes that cannot be force-killed via UI
|
||||||
private static let protectedProcesses = ["kernel_task", "launchd", "WindowServer", "loginwindow"]
|
private static let protectedProcesses = ["kernel_task", "launchd", "WindowServer", "loginwindow"]
|
||||||
|
|
||||||
@@ -37,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:
|
||||||
@@ -57,12 +63,20 @@ struct ProcessView: View {
|
|||||||
comparison = lhs.cpuUsageTotal < rhs.cpuUsageTotal ? .orderedAscending : (lhs.cpuUsageTotal > rhs.cpuUsageTotal ? .orderedDescending : .orderedSame)
|
comparison = lhs.cpuUsageTotal < rhs.cpuUsageTotal ? .orderedAscending : (lhs.cpuUsageTotal > rhs.cpuUsageTotal ? .orderedDescending : .orderedSame)
|
||||||
case .memory:
|
case .memory:
|
||||||
comparison = lhs.memoryUsage < rhs.memoryUsage ? .orderedAscending : (lhs.memoryUsage > rhs.memoryUsage ? .orderedDescending : .orderedSame)
|
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:
|
case .threads:
|
||||||
comparison = lhs.threadCount < rhs.threadCount ? .orderedAscending : (lhs.threadCount > rhs.threadCount ? .orderedDescending : .orderedSame)
|
comparison = lhs.threadCount < rhs.threadCount ? .orderedAscending : (lhs.threadCount > rhs.threadCount ? .orderedDescending : .orderedSame)
|
||||||
case .user:
|
case .user:
|
||||||
comparison = lhs.user.localizedCaseInsensitiveCompare(rhs.user)
|
comparison = lhs.user.localizedCaseInsensitiveCompare(rhs.user)
|
||||||
case .state:
|
case .state:
|
||||||
comparison = lhs.state.rawValue.compare(rhs.state.rawValue)
|
comparison = lhs.state.rawValue.compare(rhs.state.rawValue)
|
||||||
|
case .energy:
|
||||||
|
comparison = lhs.energyImpact < rhs.energyImpact ? .orderedAscending : (lhs.energyImpact > rhs.energyImpact ? .orderedDescending : .orderedSame)
|
||||||
|
case .disk:
|
||||||
|
comparison = lhs.diskTotalRate < rhs.diskTotalRate ? .orderedAscending : (lhs.diskTotalRate > rhs.diskTotalRate ? .orderedDescending : .orderedSame)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primary sort
|
// Primary sort
|
||||||
@@ -75,161 +89,46 @@ 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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
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("") { process in
|
selection: $selectedProcess,
|
||||||
if let icon = process.icon {
|
sortColumn: sortColumn,
|
||||||
Image(nsImage: icon)
|
sortAscending: sortAscending,
|
||||||
.resizable()
|
onSort: { column, ascending in
|
||||||
.frame(width: 16, height: 16)
|
sortColumn = column
|
||||||
} else {
|
|
||||||
Image(systemName: "app.dashed")
|
|
||||||
.frame(width: 16, height: 16)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.width(24)
|
|
||||||
|
|
||||||
TableColumn("Name", value: \.name) { process in
|
|
||||||
Text(process.name)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
.width(min: 150, ideal: 200)
|
|
||||||
|
|
||||||
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("Memory", value: \.memoryUsage) { process in
|
|
||||||
Text(formatBytes(process.memoryUsage))
|
|
||||||
.monospacedDigit()
|
|
||||||
}
|
|
||||||
.width(80)
|
|
||||||
|
|
||||||
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("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 action
|
|
||||||
}
|
|
||||||
.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("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(
|
||||||
onCommandBackspace: { initiateForceKill() }
|
onCommandBackspace: { initiateForceKill() },
|
||||||
|
onCommandI: {
|
||||||
|
if let process = selectedProcessItem { inspectorProcess = process }
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.sheet(isPresented: $showTerminateConfirm) {
|
.sheet(isPresented: $showTerminateConfirm) {
|
||||||
@@ -268,10 +167,14 @@ struct ProcessView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(errorMessage)
|
Text(errorMessage)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $inspectorProcess) { process in
|
||||||
|
ProcessInspectorSheet(initialProcess: process)
|
||||||
|
.environmentObject(monitor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -281,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) {
|
||||||
@@ -327,38 +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 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 {
|
||||||
@@ -441,28 +312,39 @@ struct ConfirmationSheet: View {
|
|||||||
|
|
||||||
struct KeyboardShortcutHandler: NSViewRepresentable {
|
struct KeyboardShortcutHandler: NSViewRepresentable {
|
||||||
let onCommandBackspace: () -> Void
|
let onCommandBackspace: () -> Void
|
||||||
|
var onCommandI: (() -> Void)? = nil
|
||||||
|
|
||||||
func makeNSView(context: Context) -> NSView {
|
func makeNSView(context: Context) -> NSView {
|
||||||
let view = KeyCaptureView()
|
let view = KeyCaptureView()
|
||||||
view.onCommandBackspace = onCommandBackspace
|
view.onCommandBackspace = onCommandBackspace
|
||||||
|
view.onCommandI = onCommandI
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateNSView(_ nsView: NSView, context: Context) {
|
func updateNSView(_ nsView: NSView, context: Context) {
|
||||||
if let view = nsView as? KeyCaptureView {
|
if let view = nsView as? KeyCaptureView {
|
||||||
view.onCommandBackspace = onCommandBackspace
|
view.onCommandBackspace = onCommandBackspace
|
||||||
|
view.onCommandI = onCommandI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class KeyCaptureView: NSView {
|
class KeyCaptureView: NSView {
|
||||||
var onCommandBackspace: (() -> Void)?
|
var onCommandBackspace: (() -> Void)?
|
||||||
|
var onCommandI: (() -> Void)?
|
||||||
|
|
||||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||||
// Backspace = keyCode 51
|
guard event.modifierFlags.contains(.command) else {
|
||||||
if event.keyCode == 51 && event.modifierFlags.contains(.command) {
|
return super.performKeyEquivalent(with: event)
|
||||||
|
}
|
||||||
|
// Backspace = keyCode 51, "i" = keyCode 34
|
||||||
|
if event.keyCode == 51 {
|
||||||
onCommandBackspace?()
|
onCommandBackspace?()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if event.keyCode == 34 {
|
||||||
|
onCommandI?()
|
||||||
|
return true
|
||||||
|
}
|
||||||
return super.performKeyEquivalent(with: event)
|
return super.performKeyEquivalent(with: event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
97
TopManager/Views/SettingsView.swift
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Settings window shell. Like `ContentView`, it deliberately observes nothing:
|
||||||
|
/// a body containing `.tabItem` leaks its `AnyView(Label(…))` on every
|
||||||
|
/// re-evaluation, and dragging a threshold slider re-publishes `AppSettings`
|
||||||
|
/// on every step. The tabs below own the observation instead.
|
||||||
|
struct SettingsView: View {
|
||||||
|
var body: some View {
|
||||||
|
TabView {
|
||||||
|
GeneralSettingsTab()
|
||||||
|
.tabItem { Label("General", systemImage: "gearshape") }
|
||||||
|
AlertSettingsTab()
|
||||||
|
.tabItem { Label("Alerts", systemImage: "bell") }
|
||||||
|
}
|
||||||
|
.frame(width: 440, height: 340)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GeneralSettingsTab: View {
|
||||||
|
@EnvironmentObject var settings: AppSettings
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Toggle("Launch at login", isOn: $settings.launchAtLogin)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
HStack {
|
||||||
|
Text("Refresh interval")
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.0f s", settings.refreshInterval))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Slider(value: $settings.refreshInterval, in: 1...30, step: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Menu Bar") {
|
||||||
|
Picker("Show in menu bar", selection: $settings.menuBarMetric) {
|
||||||
|
ForEach(MenuBarMetric.allCases) { metric in
|
||||||
|
Text(metric.rawValue).tag(metric)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AlertSettingsTab: View {
|
||||||
|
@EnvironmentObject var settings: AppSettings
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Toggle("Enable notifications", isOn: $settings.notificationsEnabled)
|
||||||
|
} footer: {
|
||||||
|
Text("TopManager posts a macOS notification when a threshold is first crossed.")
|
||||||
|
.font(.caption).foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Thresholds") {
|
||||||
|
sliderRow(title: "High CPU", value: $settings.cpuAlertThreshold,
|
||||||
|
range: 50...100, unit: "%")
|
||||||
|
sliderRow(title: "Disk almost full", value: $settings.diskAlertPercent,
|
||||||
|
range: 80...99, unit: "%")
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
HStack {
|
||||||
|
Text("Low battery")
|
||||||
|
Spacer()
|
||||||
|
Text("\(settings.lowBatteryPercent)%").monospacedDigit().foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Slider(value: Binding(
|
||||||
|
get: { Double(settings.lowBatteryPercent) },
|
||||||
|
set: { settings.lowBatteryPercent = Int($0) }
|
||||||
|
), in: 5...50, step: 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sliderRow(title: String, value: Binding<Double>, range: ClosedRange<Double>, unit: String) -> some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.0f%@", value.wrappedValue, unit))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Slider(value: value, in: range, step: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
TopManagerTests/BatteryMathTests.swift
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import TopManager
|
||||||
|
|
||||||
|
final class BatteryMathTests: XCTestCase {
|
||||||
|
|
||||||
|
func testHealthPercent() {
|
||||||
|
// 4000 mAh current full-charge vs 5000 mAh design = 80%
|
||||||
|
XCTAssertEqual(BatteryMath.healthPercent(maxCapacity: 4000, designCapacity: 5000), 80, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHealthPercentZeroDesignIsSafe() {
|
||||||
|
XCTAssertEqual(BatteryMath.healthPercent(maxCapacity: 100, designCapacity: 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConditionNormalWhenHealthy() {
|
||||||
|
XCTAssertEqual(BatteryMath.condition(healthPercent: 92, cycleCount: 120), .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConditionServiceWhenHealthLow() {
|
||||||
|
XCTAssertEqual(BatteryMath.condition(healthPercent: 74, cycleCount: 120), .serviceRecommended)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConditionServiceWhenCyclesHigh() {
|
||||||
|
XCTAssertEqual(BatteryMath.condition(healthPercent: 95, cycleCount: 1200), .serviceRecommended)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConditionUnknownWhenNoHealth() {
|
||||||
|
XCTAssertEqual(BatteryMath.condition(healthPercent: nil, cycleCount: 100), .unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWattsPositiveWhenCharging() {
|
||||||
|
// 12 V * 2000 mA = 24 W
|
||||||
|
XCTAssertEqual(BatteryMath.watts(volts: 12, milliAmps: 2000), 24, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWattsNegativeWhenDischarging() {
|
||||||
|
XCTAssertEqual(BatteryMath.watts(volts: 12, milliAmps: -1500), -18, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFormatMinutes() {
|
||||||
|
XCTAssertEqual(BatteryMath.formatMinutes(135), "2h 15m")
|
||||||
|
XCTAssertEqual(BatteryMath.formatMinutes(45), "45m")
|
||||||
|
XCTAssertEqual(BatteryMath.formatMinutes(0), "—")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPowerInfoDerivedValues() {
|
||||||
|
let p = PowerInfo(
|
||||||
|
hasBattery: true, currentCharge: 66, isCharging: false, isPluggedIn: false,
|
||||||
|
fullyCharged: false, cycleCount: 150, designCapacity: 5000, maxCapacity: 4600,
|
||||||
|
temperature: 30.5, voltage: 12.0, amperage: -1500, timeToEmpty: 200,
|
||||||
|
timeToFull: nil, adapterWatts: nil
|
||||||
|
)
|
||||||
|
XCTAssertEqual(p.healthPercent ?? 0, 92, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(p.condition, .normal)
|
||||||
|
XCTAssertEqual(p.powerWatts ?? 0, -18, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(p.powerSourceLabel, "Battery Power")
|
||||||
|
}
|
||||||
|
}
|
||||||
114
TopManagerTests/FormattingTests.swift
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import TopManager
|
||||||
|
|
||||||
|
/// Baseline tests proving the test harness works and locking in the existing
|
||||||
|
/// formatting / model-math behavior so later refactors can't silently break it.
|
||||||
|
final class FormattingTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: - Byte formatting
|
||||||
|
|
||||||
|
func testFormatBytesZero() {
|
||||||
|
// ByteCountFormatter renders 0 as "Zero KB"/"Zero bytes" depending on OS/locale.
|
||||||
|
let s = formatBytes(UInt64(0)).lowercased()
|
||||||
|
XCTAssertTrue(s.contains("zero") || s.contains("0"), "Unexpected zero rendering: \(s)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFormatBytesGigabyteScale() {
|
||||||
|
// 2 GiB in binary style
|
||||||
|
let twoGiB = UInt64(2) * 1024 * 1024 * 1024
|
||||||
|
let s = formatBytes(twoGiB)
|
||||||
|
XCTAssertTrue(s.contains("GB"), "Expected GB unit, got \(s)")
|
||||||
|
XCTAssertTrue(s.contains("2"), "Expected value 2, got \(s)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFormatBytesNegativeDoubleClampsToZero() {
|
||||||
|
// Double overload clamps negatives to 0 rather than trapping on UInt64(negative)
|
||||||
|
let s = formatBytes(Double(-500)).lowercased()
|
||||||
|
XCTAssertTrue(s.contains("zero") || s.contains("0"), "Unexpected clamp rendering: \(s)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFormatBytesPerSecondBelowOne() {
|
||||||
|
XCTAssertEqual(formatBytesPerSecond(0.4), "0 B/s")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFormatBytesPerSecondSuffix() {
|
||||||
|
let s = formatBytesPerSecond(5 * 1024 * 1024) // 5 MiB/s
|
||||||
|
XCTAssertTrue(s.hasSuffix("/s"), "Expected /s suffix, got \(s)")
|
||||||
|
XCTAssertTrue(s.contains("MB"), "Expected MB unit, got \(s)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Percentage
|
||||||
|
|
||||||
|
func testFormatPercentageDefaultDecimals() {
|
||||||
|
XCTAssertEqual(formatPercentage(12.345), "12.3%")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFormatPercentageCustomDecimals() {
|
||||||
|
XCTAssertEqual(formatPercentage(12.345, decimals: 2), "12.35%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Uptime
|
||||||
|
|
||||||
|
func testFormatUptimeMinutesOnly() {
|
||||||
|
XCTAssertEqual(formatUptime(45 * 60), "45m")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFormatUptimeHoursAndMinutes() {
|
||||||
|
XCTAssertEqual(formatUptime(3 * 3600 + 12 * 60), "3h 12m")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFormatUptimeDays() {
|
||||||
|
XCTAssertEqual(formatUptime(2 * 86400 + 5 * 3600 + 9 * 60), "2d 5h 9m")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Model math
|
||||||
|
|
||||||
|
func testMemoryUsagePercentage() {
|
||||||
|
let mem = MemoryInfo(
|
||||||
|
totalMemory: 16_000_000_000,
|
||||||
|
usedMemory: 8_000_000_000,
|
||||||
|
freeMemory: 8_000_000_000,
|
||||||
|
activeMemory: 4_000_000_000,
|
||||||
|
inactiveMemory: 2_000_000_000,
|
||||||
|
wiredMemory: 2_000_000_000,
|
||||||
|
compressedMemory: 1_000_000_000,
|
||||||
|
cachedMemory: 1_000_000_000,
|
||||||
|
swapUsed: 0,
|
||||||
|
swapTotal: 0,
|
||||||
|
memoryPressure: .nominal
|
||||||
|
)
|
||||||
|
XCTAssertEqual(mem.usagePercentage, 50.0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(mem.appMemory, 6_000_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMemoryUsagePercentageZeroTotalIsSafe() {
|
||||||
|
let mem = MemoryInfo(
|
||||||
|
totalMemory: 0, usedMemory: 0, freeMemory: 0, activeMemory: 0,
|
||||||
|
inactiveMemory: 0, wiredMemory: 0, compressedMemory: 0, cachedMemory: 0,
|
||||||
|
swapUsed: 0, swapTotal: 0, memoryPressure: .unknown
|
||||||
|
)
|
||||||
|
XCTAssertEqual(mem.usagePercentage, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVolumeUsagePercentageAndUsedSpace() {
|
||||||
|
let vol = VolumeInfo(
|
||||||
|
name: "Macintosh HD",
|
||||||
|
mountPoint: "/",
|
||||||
|
totalSpace: 1000,
|
||||||
|
freeSpace: 250,
|
||||||
|
fileSystem: "APFS",
|
||||||
|
isRemovable: false,
|
||||||
|
isInternal: true
|
||||||
|
)
|
||||||
|
XCTAssertEqual(vol.usedSpace, 750)
|
||||||
|
XCTAssertEqual(vol.usagePercentage, 75.0, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVolumeFreeGreaterThanTotalClampsUsedToZero() {
|
||||||
|
let vol = VolumeInfo(
|
||||||
|
name: "weird", mountPoint: "/x", totalSpace: 100, freeSpace: 200,
|
||||||
|
fileSystem: "APFS", isRemovable: false, isInternal: true
|
||||||
|
)
|
||||||
|
XCTAssertEqual(vol.usedSpace, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
TopManagerTests/HealthAlertTests.swift
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import TopManager
|
||||||
|
|
||||||
|
final class HealthAlertTests: XCTestCase {
|
||||||
|
|
||||||
|
private func healthy() -> HealthInput {
|
||||||
|
HealthInput(cpuUsage: 10, memoryPressure: .nominal, swapUsedBytes: 0,
|
||||||
|
thermal: .nominal, diskUsedFraction: 0.4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Health score
|
||||||
|
|
||||||
|
func testHealthyScoreIsExcellent() {
|
||||||
|
let s = HealthModel.score(healthy())
|
||||||
|
XCTAssertEqual(s, 100)
|
||||||
|
XCTAssertEqual(HealthModel.rating(s), "Excellent")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHighCPULowersScore() {
|
||||||
|
var input = healthy()
|
||||||
|
input = HealthInput(cpuUsage: 100, memoryPressure: .nominal, swapUsedBytes: 0,
|
||||||
|
thermal: .nominal, diskUsedFraction: 0.4)
|
||||||
|
XCTAssertLessThan(HealthModel.score(input), HealthModel.score(healthy()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCriticalMemoryAndThermalCompound() {
|
||||||
|
let input = HealthInput(cpuUsage: 95, memoryPressure: .critical, swapUsedBytes: 3_000_000_000,
|
||||||
|
thermal: .critical, diskUsedFraction: 0.97)
|
||||||
|
let s = HealthModel.score(input)
|
||||||
|
XCTAssertLessThan(s, 50)
|
||||||
|
XCTAssertEqual(HealthModel.rating(s), "Poor")
|
||||||
|
XCTAssertGreaterThanOrEqual(s, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testScoreClampedToRange() {
|
||||||
|
// Absolute worst case must not underflow below 0.
|
||||||
|
let worst = HealthInput(cpuUsage: 100, memoryPressure: .critical, swapUsedBytes: .max,
|
||||||
|
thermal: .critical, diskUsedFraction: 1.0)
|
||||||
|
let s = HealthModel.score(worst)
|
||||||
|
XCTAssertTrue((0...100).contains(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDiagnosisEmptyWhenHealthy() {
|
||||||
|
XCTAssertTrue(HealthModel.diagnosis(healthy()).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDiagnosisReportsProblems() {
|
||||||
|
let input = HealthInput(cpuUsage: 95, memoryPressure: .critical, swapUsedBytes: 3_000_000_000,
|
||||||
|
thermal: .serious, diskUsedFraction: 0.93)
|
||||||
|
let d = HealthModel.diagnosis(input)
|
||||||
|
XCTAssertFalse(d.isEmpty)
|
||||||
|
XCTAssertTrue(d.contains { $0.localizedCaseInsensitiveContains("CPU") })
|
||||||
|
XCTAssertTrue(d.contains { $0.localizedCaseInsensitiveContains("memory") })
|
||||||
|
XCTAssertTrue(d.contains { $0.localizedCaseInsensitiveContains("disk") })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Alert sustained evaluator
|
||||||
|
|
||||||
|
func testSustainedFiresOnlyAfterRequiredCycles() {
|
||||||
|
var count = 0
|
||||||
|
var result = AlertEvaluator.sustained(breached: true, previousCount: count, requiredCycles: 3)
|
||||||
|
count = result.count
|
||||||
|
XCTAssertFalse(result.firing) // 1
|
||||||
|
result = AlertEvaluator.sustained(breached: true, previousCount: count, requiredCycles: 3)
|
||||||
|
count = result.count
|
||||||
|
XCTAssertFalse(result.firing) // 2
|
||||||
|
result = AlertEvaluator.sustained(breached: true, previousCount: count, requiredCycles: 3)
|
||||||
|
count = result.count
|
||||||
|
XCTAssertTrue(result.firing) // 3 → fires
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSustainedResetsWhenConditionClears() {
|
||||||
|
var count = 5
|
||||||
|
let result = AlertEvaluator.sustained(breached: false, previousCount: count, requiredCycles: 3)
|
||||||
|
count = result.count
|
||||||
|
XCTAssertFalse(result.firing)
|
||||||
|
XCTAssertEqual(count, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSeverityOrdering() {
|
||||||
|
XCTAssertTrue(AlertSeverity.info < AlertSeverity.warning)
|
||||||
|
XCTAssertTrue(AlertSeverity.warning < AlertSeverity.critical)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AlertCenter integration (de-dup + inbox)
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testAlertCenterDedupesAndClearsActiveKinds() {
|
||||||
|
let center = AlertCenter.shared
|
||||||
|
center.clearInbox()
|
||||||
|
|
||||||
|
let mem = MemoryInfo(totalMemory: 16, usedMemory: 15, freeMemory: 1, activeMemory: 8,
|
||||||
|
inactiveMemory: 2, wiredMemory: 3, compressedMemory: 2, cachedMemory: 0,
|
||||||
|
swapUsed: 0, swapTotal: 0, memoryPressure: .critical)
|
||||||
|
|
||||||
|
let before = center.alerts.count
|
||||||
|
// Two consecutive critical-pressure evaluations → only ONE alert (de-dup).
|
||||||
|
center.evaluate(cpuUsage: 5, memory: mem, disk: nil, thermal: .nominal, topProcess: nil, power: nil)
|
||||||
|
center.evaluate(cpuUsage: 5, memory: mem, disk: nil, thermal: .nominal, topProcess: nil, power: nil)
|
||||||
|
XCTAssertEqual(center.alerts.count, before + 1, "critical memory pressure should fire exactly once")
|
||||||
|
XCTAssertTrue(center.activeKinds.contains(.memoryPressure))
|
||||||
|
|
||||||
|
// Pressure recovers → active kind clears, no new alert added.
|
||||||
|
let ok = MemoryInfo(totalMemory: 16, usedMemory: 4, freeMemory: 12, activeMemory: 2,
|
||||||
|
inactiveMemory: 1, wiredMemory: 1, compressedMemory: 0, cachedMemory: 0,
|
||||||
|
swapUsed: 0, swapTotal: 0, memoryPressure: .nominal)
|
||||||
|
center.evaluate(cpuUsage: 5, memory: ok, disk: nil, thermal: .nominal, topProcess: nil, power: nil)
|
||||||
|
XCTAssertFalse(center.activeKinds.contains(.memoryPressure))
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
106
TopManagerTests/MetricsStoreTests.swift
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import TopManager
|
||||||
|
|
||||||
|
final class MetricsStoreTests: XCTestCase {
|
||||||
|
|
||||||
|
private func sample(_ secondsAgo: TimeInterval, cpu: Double = 0, memUsed: UInt64 = 0,
|
||||||
|
memTotal: UInt64 = 100, down: Double = 0, up: Double = 0,
|
||||||
|
now: Date) -> MetricsSample {
|
||||||
|
MetricsSample(t: now.addingTimeInterval(-secondsAgo), cpu: cpu,
|
||||||
|
memUsed: memUsed, memTotal: memTotal, netDown: down, netUp: up)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - trim
|
||||||
|
|
||||||
|
func testTrimDropsOldSamples() {
|
||||||
|
let now = Date()
|
||||||
|
let samples = [
|
||||||
|
sample(10_000, now: now), // older than 1h
|
||||||
|
sample(100, now: now), // within 1h
|
||||||
|
sample(50, now: now)
|
||||||
|
]
|
||||||
|
let trimmed = MetricsMath.trim(samples, now: now, maxAge: 3600, maxCount: 100)
|
||||||
|
XCTAssertEqual(trimmed.count, 2)
|
||||||
|
XCTAssertFalse(trimmed.contains { $0.t < now.addingTimeInterval(-3600) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTrimCapsCountKeepingNewest() {
|
||||||
|
let now = Date()
|
||||||
|
// 10 samples, 0..9 seconds ago; keep newest 5
|
||||||
|
let samples = (0..<10).map { sample(Double($0), cpu: Double($0), now: now) }
|
||||||
|
let trimmed = MetricsMath.trim(samples, now: now, maxAge: 3600, maxCount: 5)
|
||||||
|
XCTAssertEqual(trimmed.count, 5)
|
||||||
|
// suffix keeps the last 5 of the input order (cpu 5..9)
|
||||||
|
XCTAssertEqual(trimmed.map { $0.cpu }, [5, 6, 7, 8, 9])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - downsample
|
||||||
|
|
||||||
|
func testDownsampleZeroBucketIsPassthrough() {
|
||||||
|
let now = Date()
|
||||||
|
let samples = (0..<5).map { sample(Double($0), cpu: Double($0), now: now) }
|
||||||
|
XCTAssertEqual(MetricsMath.downsample(samples, bucket: 0).count, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDownsampleAveragesWithinBucket() {
|
||||||
|
// Two samples 0s and 5s into the same 60s bucket → averaged to one point.
|
||||||
|
let epoch = Date(timeIntervalSince1970: 1_000_000) // aligned into bucket 16666
|
||||||
|
let s1 = MetricsSample(t: epoch, cpu: 20, memUsed: 40, memTotal: 100, netDown: 100, netUp: 10)
|
||||||
|
let s2 = MetricsSample(t: epoch.addingTimeInterval(5), cpu: 40, memUsed: 60, memTotal: 100, netDown: 300, netUp: 30)
|
||||||
|
let out = MetricsMath.downsample([s1, s2], bucket: 60)
|
||||||
|
XCTAssertEqual(out.count, 1)
|
||||||
|
XCTAssertEqual(out[0].cpu, 30, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(out[0].memUsed, 50)
|
||||||
|
XCTAssertEqual(out[0].netDown, 200, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(out[0].netUp, 20, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDownsampleReducesPointCountForLongRange() {
|
||||||
|
let base = Date(timeIntervalSince1970: 2_000_000)
|
||||||
|
// 600 samples one second apart → into 60s buckets ≈ 10 points
|
||||||
|
let samples = (0..<600).map {
|
||||||
|
MetricsSample(t: base.addingTimeInterval(Double($0)), cpu: 1, memUsed: 1, memTotal: 100, netDown: 0, netUp: 0)
|
||||||
|
}
|
||||||
|
let out = MetricsMath.downsample(samples, bucket: 60)
|
||||||
|
XCTAssertLessThanOrEqual(out.count, 11)
|
||||||
|
XCTAssertGreaterThanOrEqual(out.count, 10)
|
||||||
|
// output must be time-sorted
|
||||||
|
XCTAssertEqual(out, out.sorted { $0.t < $1.t })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - persistence round-trip
|
||||||
|
|
||||||
|
func testStorePersistsAndReloads() {
|
||||||
|
let tmp = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||||
|
.appendingPathComponent("tm-test-\(UUID().uuidString).json")
|
||||||
|
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||||
|
|
||||||
|
let store = MetricsStore(fileURL: tmp, maxAge: 3600, maxCount: 100, saveEvery: 1)
|
||||||
|
let now = Date()
|
||||||
|
store.record(MetricsSample(t: now, cpu: 42, memUsed: 5, memTotal: 10, netDown: 1, netUp: 2))
|
||||||
|
|
||||||
|
// saveEvery: 1 persists on the background io queue; wait for the file to appear.
|
||||||
|
let deadline = Date().addingTimeInterval(2)
|
||||||
|
while !FileManager.default.fileExists(atPath: tmp.path), Date() < deadline {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.02))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: tmp.path), "history file was not written")
|
||||||
|
|
||||||
|
let reloaded = MetricsStore(fileURL: tmp, maxAge: 3600, maxCount: 100, saveEvery: 1)
|
||||||
|
XCTAssertEqual(reloaded.count, 1)
|
||||||
|
XCTAssertEqual(reloaded.samples(since: now.addingTimeInterval(-1)).first?.cpu, 42)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - range config sanity
|
||||||
|
|
||||||
|
func testHistoryRangeLiveHasNoWindow() {
|
||||||
|
XCTAssertNil(HistoryRange.live.seconds)
|
||||||
|
XCTAssertEqual(HistoryRange.live.bucket, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHistoryRangeWindowsIncrease() {
|
||||||
|
let windows = HistoryRange.allCases.compactMap { $0.seconds }
|
||||||
|
XCTAssertEqual(windows, windows.sorted())
|
||||||
|
XCTAssertEqual(HistoryRange.h24.seconds, 86_400)
|
||||||
|
}
|
||||||
|
}
|
||||||
128
TopManagerTests/ProcessDataTests.swift
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import TopManager
|
||||||
|
|
||||||
|
final class ProcessDataTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: - KERN_PROCARGS2 parsing
|
||||||
|
|
||||||
|
private func buildProcArgsBuffer(argc: Int32, path: String, args: [String], env: [String]) -> [UInt8] {
|
||||||
|
var bytes: [UInt8] = [
|
||||||
|
UInt8(argc & 0xff),
|
||||||
|
UInt8((argc >> 8) & 0xff),
|
||||||
|
UInt8((argc >> 16) & 0xff),
|
||||||
|
UInt8((argc >> 24) & 0xff)
|
||||||
|
]
|
||||||
|
bytes += Array(path.utf8) + [0]
|
||||||
|
bytes += [0, 0] // NUL padding before argv[0]
|
||||||
|
for a in args { bytes += Array(a.utf8) + [0] }
|
||||||
|
for e in env { bytes += Array(e.utf8) + [0] }
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProcArgsParseExtractsPathAndArgs() {
|
||||||
|
let buffer = buildProcArgsBuffer(argc: 2, path: "/bin/foo",
|
||||||
|
args: ["/bin/foo", "--flag"],
|
||||||
|
env: ["PATH=/usr/bin"])
|
||||||
|
let parsed = ProcArgs.parse(buffer)
|
||||||
|
XCTAssertEqual(parsed?.path, "/bin/foo")
|
||||||
|
XCTAssertEqual(parsed?.args, ["/bin/foo", "--flag"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProcArgsParseStopsAtArgcAndIgnoresEnv() {
|
||||||
|
// argc=1 → only the first arg is returned, env is not leaked into args
|
||||||
|
let buffer = buildProcArgsBuffer(argc: 1, path: "/usr/bin/x",
|
||||||
|
args: ["/usr/bin/x"],
|
||||||
|
env: ["SECRET=shh"])
|
||||||
|
let parsed = ProcArgs.parse(buffer)
|
||||||
|
XCTAssertEqual(parsed?.args, ["/usr/bin/x"])
|
||||||
|
XCTAssertFalse(parsed?.args.contains { $0.contains("SECRET") } ?? true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProcArgsParseZeroArgc() {
|
||||||
|
let buffer = buildProcArgsBuffer(argc: 0, path: "/bin/x", args: [], env: [])
|
||||||
|
let parsed = ProcArgs.parse(buffer)
|
||||||
|
XCTAssertEqual(parsed?.path, "/bin/x")
|
||||||
|
XCTAssertEqual(parsed?.args, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProcArgsParseTooShortReturnsNil() {
|
||||||
|
XCTAssertNil(ProcArgs.parse([1, 2, 3]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Energy model
|
||||||
|
|
||||||
|
func testEnergyImpactMonotonicInCPU() {
|
||||||
|
XCTAssertGreaterThan(
|
||||||
|
EnergyModel.impact(cpuPercent: 50, idleWakeupsPerSec: 0),
|
||||||
|
EnergyModel.impact(cpuPercent: 10, idleWakeupsPerSec: 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEnergyImpactMonotonicInWakeups() {
|
||||||
|
XCTAssertGreaterThan(
|
||||||
|
EnergyModel.impact(cpuPercent: 10, idleWakeupsPerSec: 500),
|
||||||
|
EnergyModel.impact(cpuPercent: 10, idleWakeupsPerSec: 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEnergyImpactClampsNegativeInputsToZero() {
|
||||||
|
XCTAssertEqual(EnergyModel.impact(cpuPercent: -5, idleWakeupsPerSec: -100), 0, accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEnergyImpactEqualsCPUWhenNoWakeups() {
|
||||||
|
XCTAssertEqual(EnergyModel.impact(cpuPercent: 33, idleWakeupsPerSec: 0), 33, accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ProcessItem derived + equality
|
||||||
|
|
||||||
|
func testDiskTotalRateSumsReadAndWrite() {
|
||||||
|
let p = ProcessItem(pid: 1, name: "a", user: "u", cpuUsage: 0, cpuUsageTotal: 0,
|
||||||
|
memoryUsage: 0, threadCount: 0, state: .running, icon: nil,
|
||||||
|
parentPid: 0, startTime: nil, diskReadRate: 100, diskWriteRate: 50)
|
||||||
|
XCTAssertEqual(p.diskTotalRate, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEqualityDetectsEnergyChange() {
|
||||||
|
func make(energy: Double) -> ProcessItem {
|
||||||
|
ProcessItem(pid: 1, name: "a", user: "u", cpuUsage: 1, cpuUsageTotal: 1,
|
||||||
|
memoryUsage: 1, threadCount: 1, state: .running, icon: nil,
|
||||||
|
parentPid: 0, startTime: nil, energyImpact: energy)
|
||||||
|
}
|
||||||
|
// Table refresh relies on == seeing energy/disk changes.
|
||||||
|
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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
27
TopManagerTests/SettingsTests.swift
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import TopManager
|
||||||
|
|
||||||
|
final class SettingsTests: XCTestCase {
|
||||||
|
|
||||||
|
func testClampIntervalBounds() {
|
||||||
|
XCTAssertEqual(AppSettings.clampInterval(0.2), 1)
|
||||||
|
XCTAssertEqual(AppSettings.clampInterval(100), 30)
|
||||||
|
XCTAssertEqual(AppSettings.clampInterval(5), 5)
|
||||||
|
XCTAssertEqual(AppSettings.clampInterval(1), 1)
|
||||||
|
XCTAssertEqual(AppSettings.clampInterval(30), 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMenuBarMetricRoundTrip() {
|
||||||
|
for metric in MenuBarMetric.allCases {
|
||||||
|
XCTAssertEqual(MenuBarMetric(rawValue: metric.rawValue), metric)
|
||||||
|
}
|
||||||
|
XCTAssertNil(MenuBarMetric(rawValue: "nonexistent"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMenuBarMetricCoversKeyMetrics() {
|
||||||
|
XCTAssertTrue(MenuBarMetric.allCases.contains(.cpu))
|
||||||
|
XCTAssertTrue(MenuBarMetric.allCases.contains(.memory))
|
||||||
|
XCTAssertTrue(MenuBarMetric.allCases.contains(.health))
|
||||||
|
XCTAssertTrue(MenuBarMetric.allCases.contains(.download))
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
docs/TESTING.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# TopManager — Testing Process
|
||||||
|
|
||||||
|
This document defines the repeatable test process used while evolving TopManager.
|
||||||
|
Every feature increment must pass all five gates below before it is considered done.
|
||||||
|
|
||||||
|
## Gate 1 — Compile
|
||||||
|
```bash
|
||||||
|
scripts/test.sh build
|
||||||
|
```
|
||||||
|
Fast feedback that the code compiles for `platform=macOS,arch=arm64`. First gate on every change.
|
||||||
|
|
||||||
|
## Gate 2 — Unit tests (automated, self-run)
|
||||||
|
```bash
|
||||||
|
scripts/test.sh test # or: xcodebuild ... test
|
||||||
|
```
|
||||||
|
Pure-logic is covered by XCTest in `TopManagerTests/` using `@testable import TopManager`.
|
||||||
|
The suite intentionally targets deterministic logic (no live system state):
|
||||||
|
|
||||||
|
- **Formatting / model math** — bytes, rates, percentages, uptime, usage %.
|
||||||
|
- **MetricsStore** — ring-buffer trim, persistence round-trip, downsampling per range.
|
||||||
|
- **Energy heuristic** — score is monotonic in CPU and wakeups, clamped to 0–100.
|
||||||
|
- **Alert engine** — threshold crossings fire/clear correctly, sustained-window logic,
|
||||||
|
de-duplication (no alert storms), severity ordering.
|
||||||
|
- **Health score** — 0–100, decreases with pressure/thermal/swap/disk load.
|
||||||
|
- **Battery math** — time-remaining and capacity-health formatting.
|
||||||
|
|
||||||
|
Tests must stay hermetic: no sleeping on real timers, no asserting on live CPU numbers.
|
||||||
|
|
||||||
|
## Gate 3 — Runtime smoke test (self-run on this machine)
|
||||||
|
Launch the built app, drive real load, confirm no crash and data populates.
|
||||||
|
```bash
|
||||||
|
# build a runnable .app
|
||||||
|
xcodebuild -project TopManager.xcodeproj -scheme TopManager -configuration Debug \
|
||||||
|
-destination 'platform=macOS,arch=arm64' build CODE_SIGNING_ALLOWED=NO
|
||||||
|
APP=$(find ~/Library/Developer/Xcode/DerivedData/TopManager-*/Build/Products/Debug -name TopManager.app | head -1)
|
||||||
|
open "$APP"
|
||||||
|
# generate CPU load in the background, then verify TopManager surfaces it
|
||||||
|
yes > /dev/null & LOAD=$!; sleep 8; kill $LOAD
|
||||||
|
```
|
||||||
|
Verify: process list shows the load generator near the top, charts move, no crash in
|
||||||
|
`Console`/stderr, menu bar updates, alerts fire when thresholds are crossed.
|
||||||
|
|
||||||
|
## Gate 4 — Ground-truth cross-checks
|
||||||
|
TopManager's numbers are validated against OS tools so we don't ship wrong data:
|
||||||
|
|
||||||
|
| TopManager metric | Ground truth command |
|
||||||
|
|------------------------|---------------------------------------------|
|
||||||
|
| Per-process CPU/mem | `ps -Ao pid,pcpu,pmem,comm -r | head` |
|
||||||
|
| System memory | `vm_stat`, `sysctl vm.swapusage` |
|
||||||
|
| Disk volumes / free | `df -h` |
|
||||||
|
| Battery / power | `pmset -g batt`, `ioreg -rn AppleSmartBattery` |
|
||||||
|
| Thermal state | `pmset -g therm` |
|
||||||
|
| Network throughput | `netstat -ib` |
|
||||||
|
|
||||||
|
Small deltas are expected (sampling windows differ); order-of-magnitude or ranking
|
||||||
|
mismatches are bugs.
|
||||||
|
|
||||||
|
## Gate 5 — Regression: original features intact
|
||||||
|
Manually confirm each shipped feature still works: Processes (sort/search/kill/suspend/
|
||||||
|
resume), Apps (activate/hide/quit/force-quit/copy bundle id), Performance (CPU/Mem/Net
|
||||||
|
charts), Power & Storage (system/GPU/storage/network), and the menu-bar readout.
|
||||||
|
|
||||||
|
## Loop
|
||||||
|
```
|
||||||
|
implement slice → Gate 1 → Gate 2 → Gate 3 → Gate 4 → Gate 5 → checkpoint commit
|
||||||
|
```
|
||||||
|
Repeat per increment until the value rubric in `docs/VALUE_PLAN.md` is met.
|
||||||
38
docs/VALUE_PLAN.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# TopManager — 10× Value Plan
|
||||||
|
|
||||||
|
## Thesis
|
||||||
|
TopManager today is a polished, **read-only Activity Monitor clone**: it *shows* numbers.
|
||||||
|
Its value ceiling is "I can see what's using my Mac and kill a process."
|
||||||
|
|
||||||
|
To make it ~10× more valuable we move it from **viewer → system-intelligence & control
|
||||||
|
center**: it watches the system for you, remembers its history, explains problems, exposes
|
||||||
|
data Activity Monitor hides, and lets you act — including always-on from the menu bar.
|
||||||
|
|
||||||
|
All existing features are preserved (several are improved).
|
||||||
|
|
||||||
|
## Value rubric (capability scoring)
|
||||||
|
| Dimension | Before | After (target) |
|
||||||
|
|---|---|---|
|
||||||
|
| Per-process depth | CPU, mem, threads, state | + disk I/O rate, energy impact, args, open files, full path, tree |
|
||||||
|
| Time memory | 60 in-RAM points, lost on quit | persistent history, minutes → 24h, survives restart |
|
||||||
|
| Proactivity | none | threshold alerts + native notifications + health score + diagnosis |
|
||||||
|
| Energy / battery | **absent** | battery health (cycles/condition/capacity) + per-process energy |
|
||||||
|
| Always-on surface | bare "12%" | rich menu-bar HUD: CPU/RAM/net + top consumers + quick-kill |
|
||||||
|
| Customization | none (all hardcoded) | Settings: refresh rate, menu metric, thresholds, launch-at-login |
|
||||||
|
|
||||||
|
## Increments
|
||||||
|
0. **Test harness** — XCTest target, `@testable import`, 5-gate process. ✅
|
||||||
|
1. **Persistent history** — `MetricsStore` ring buffer on disk; 1m/5m/1h/24h charts.
|
||||||
|
2. **Deep per-process data** — disk I/O, energy impact, args, open files, path; surface the
|
||||||
|
(currently unused) `ProcessDetailView` as a real inspector; new sortable columns.
|
||||||
|
3. **Battery + energy** — IOKit `PowerMonitor`; Energy/Battery UI; menu-bar battery.
|
||||||
|
4. **Alerts + health** — `AlertEngine`, `UserNotifications`, in-app inbox, 0–100 health score,
|
||||||
|
"why is my Mac slow?" diagnosis.
|
||||||
|
5. **Settings + rich menu bar** — configurable refresh/metric/thresholds, launch-at-login
|
||||||
|
(`SMAppService`), menu-bar HUD with top consumers + quick actions.
|
||||||
|
6. **Verification** — full test run, runtime smoke, ground-truth cross-checks, sign, commit.
|
||||||
|
|
||||||
|
## Non-negotiables
|
||||||
|
- Never break a shipped feature (regression Gate 5).
|
||||||
|
- No new entitlements / no root / no privileged helper — must run & sign as-is.
|
||||||
|
- Every increment passes all five gates in `docs/TESTING.md` before moving on.
|
||||||
42
project.yml
@@ -12,7 +12,7 @@ settings:
|
|||||||
SWIFT_VERSION: "5.9"
|
SWIFT_VERSION: "5.9"
|
||||||
CODE_SIGN_STYLE: Automatic
|
CODE_SIGN_STYLE: Automatic
|
||||||
ENABLE_HARDENED_RUNTIME: YES
|
ENABLE_HARDENED_RUNTIME: YES
|
||||||
DEVELOPMENT_TEAM: ""
|
DEVELOPMENT_TEAM: "P3663392R2"
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
TopManager:
|
TopManager:
|
||||||
@@ -31,7 +31,43 @@ targets:
|
|||||||
GENERATE_INFOPLIST_FILE: NO
|
GENERATE_INFOPLIST_FILE: NO
|
||||||
SWIFT_EMIT_LOC_STRINGS: YES
|
SWIFT_EMIT_LOC_STRINGS: YES
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
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:
|
info:
|
||||||
path: TopManager/Info.plist
|
path: TopManager/Info.plist
|
||||||
entitlements:
|
properties:
|
||||||
path: TopManager/TopManager.entitlements
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
|
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||||
|
|
||||||
|
TopManagerTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: macOS
|
||||||
|
sources:
|
||||||
|
- path: TopManagerTests
|
||||||
|
excludes:
|
||||||
|
- "**/.DS_Store"
|
||||||
|
dependencies:
|
||||||
|
- target: TopManager
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.topmanager.TopManagerTests
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/TopManager.app/Contents/MacOS/TopManager"
|
||||||
|
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
|
||||||
|
schemes:
|
||||||
|
TopManager:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
TopManager: all
|
||||||
|
TopManagerTests: [test]
|
||||||
|
run:
|
||||||
|
config: Debug
|
||||||
|
test:
|
||||||
|
config: Debug
|
||||||
|
targets:
|
||||||
|
- TopManagerTests
|
||||||
|
gatherCoverageData: false
|
||||||
|
|||||||
25
scripts/test.sh
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Fast build+test loop driver for TopManager.
|
||||||
|
# Usage:
|
||||||
|
# scripts/test.sh build # compile only (fast gate)
|
||||||
|
# scripts/test.sh test # compile + run unit tests
|
||||||
|
# scripts/test.sh # same as "test"
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
ACTION="${1:-test}"
|
||||||
|
DEST='platform=macOS,arch=arm64'
|
||||||
|
COMMON=(-project TopManager.xcodeproj -scheme TopManager -configuration Debug -destination "$DEST" CODE_SIGNING_ALLOWED=NO)
|
||||||
|
|
||||||
|
case "$ACTION" in
|
||||||
|
build)
|
||||||
|
xcodebuild "${COMMON[@]}" build 2>&1 | tail -20
|
||||||
|
;;
|
||||||
|
test)
|
||||||
|
xcodebuild "${COMMON[@]}" test 2>&1 \
|
||||||
|
| grep -E "Test Suite|Executed|error:|warning:.*unused|BUILD|TEST (SUCCEEDED|FAILED)|failed \(" \
|
||||||
|
| tail -40
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unknown action: $ACTION" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||