Commitok összehasonlítása
16 Commit-ok
9292166223
...
v1.1.0
| Szerző | SHA1 | Dátum | |
|---|---|---|---|
|
|
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
|
||||||
|
|||||||
51
README.md
@@ -5,6 +5,15 @@ A native macOS system monitor application built with SwiftUI. TopManager provide
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
|
## Download
|
||||||
|
|
||||||
|
**[⬇️ Download TopManager 1.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 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
|
- System status: macOS version, uptime, thermal state
|
||||||
- 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,137 @@
|
|||||||
|
|
||||||
/* 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 */; };
|
||||||
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 */; };
|
||||||
|
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 */; };
|
||||||
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 */; };
|
||||||
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 */; };
|
||||||
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>"; };
|
||||||
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>"; };
|
||||||
|
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>"; };
|
||||||
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>"; };
|
||||||
/* 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 */,
|
||||||
|
8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -100,6 +147,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
36BB5A20B1330E54F2DCF357 /* TopManager */,
|
36BB5A20B1330E54F2DCF357 /* TopManager */,
|
||||||
|
D078BA97D4F498E5282AC5E0 /* TopManagerTests */,
|
||||||
E6438D9AF8EDEFEDCB087B38 /* Products */,
|
E6438D9AF8EDEFEDCB087B38 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -107,11 +155,14 @@
|
|||||||
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 */,
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
@@ -141,6 +192,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 +205,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 +226,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 +245,33 @@
|
|||||||
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 */,
|
||||||
|
01C19ABF49697A97F0C40378 /* SettingsTests.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>";
|
||||||
@@ -203,6 +288,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 +333,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 +358,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
F86DD5615146D759EB0D44EB /* TopManager */,
|
F86DD5615146D759EB0D44EB /* TopManager */,
|
||||||
|
D28E798AA7BEAA0638DFD52E /* TopManagerTests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -268,11 +376,28 @@
|
|||||||
/* 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 */,
|
||||||
|
8789C5F4A1AF4B1E8E434D71 /* SettingsTests.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 +408,44 @@
|
|||||||
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 */,
|
||||||
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 */,
|
||||||
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;
|
||||||
@@ -360,7 +502,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,6 +532,24 @@
|
|||||||
};
|
};
|
||||||
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 = {
|
||||||
@@ -445,7 +605,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 +628,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 +658,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>1.1</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>2</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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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,15 @@ 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 lastError: String?
|
@MainActor @Published var lastError: String?
|
||||||
|
|
||||||
// History for charts
|
// History for charts
|
||||||
@@ -21,6 +23,24 @@ 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 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 +48,7 @@ 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()
|
||||||
|
|
||||||
// Timer
|
// Timer
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
@@ -45,6 +66,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 +86,37 @@ 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()
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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() {
|
||||||
@@ -98,84 +134,131 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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?) {
|
||||||
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 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
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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)
|
||||||
@@ -141,6 +144,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) {
|
||||||
@@ -184,6 +192,109 @@ struct PowerStorageView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
let state: ProcessInfo.ThermalState
|
let state: ProcessInfo.ThermalState
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,67 @@
|
|||||||
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
|
||||||
|
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 +69,72 @@ 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))
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 +172,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -30,6 +30,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"]
|
||||||
|
|
||||||
@@ -57,12 +60,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
|
||||||
@@ -80,6 +91,34 @@ struct ProcessView: View {
|
|||||||
return monitor.processes.first { $0.pid == pid }
|
return monitor.processes.first { $0.pid == pid }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Memory, RAM and Compressed as a single builder element: Table accepts at
|
||||||
|
/// most 10 columns per block, and inlining them also exceeds the type
|
||||||
|
/// checker's time limit for the table expression.
|
||||||
|
@TableColumnBuilder<ProcessItem, KeyPathComparator<ProcessItem>>
|
||||||
|
private var memoryColumns: some TableColumnContent<ProcessItem, KeyPathComparator<ProcessItem>> {
|
||||||
|
TableColumn("Memory", value: \.memoryUsage) { process in
|
||||||
|
Text(formatBytes(process.memoryUsage))
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
.width(80)
|
||||||
|
|
||||||
|
TableColumn("RAM", value: \.residentMemory) { process in
|
||||||
|
Text(formatBytes(process.residentMemory))
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
.width(80)
|
||||||
|
|
||||||
|
TableColumn("Compressed", value: \.compressedMemory) { process in
|
||||||
|
if process.compressedMemory > 0 {
|
||||||
|
Text(formatBytes(process.compressedMemory))
|
||||||
|
.monospacedDigit()
|
||||||
|
} else {
|
||||||
|
Text("—").foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width(85)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Summary bar - separate view to avoid re-rendering table
|
// Summary bar - separate view to avoid re-rendering table
|
||||||
@@ -89,7 +128,8 @@ struct ProcessView: View {
|
|||||||
|
|
||||||
// Process table
|
// Process table
|
||||||
Table(displayedProcesses, selection: $selectedProcess, sortOrder: $sortOrder) {
|
Table(displayedProcesses, selection: $selectedProcess, sortOrder: $sortOrder) {
|
||||||
TableColumn("") { process in
|
TableColumn("Name", value: \.name) { process in
|
||||||
|
HStack(spacing: 6) {
|
||||||
if let icon = process.icon {
|
if let icon = process.icon {
|
||||||
Image(nsImage: icon)
|
Image(nsImage: icon)
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -97,15 +137,13 @@ struct ProcessView: View {
|
|||||||
} else {
|
} else {
|
||||||
Image(systemName: "app.dashed")
|
Image(systemName: "app.dashed")
|
||||||
.frame(width: 16, height: 16)
|
.frame(width: 16, height: 16)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.width(24)
|
|
||||||
|
|
||||||
TableColumn("Name", value: \.name) { process in
|
|
||||||
Text(process.name)
|
Text(process.name)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
.width(min: 150, ideal: 200)
|
}
|
||||||
|
.width(min: 170, ideal: 220)
|
||||||
|
|
||||||
TableColumn("PID", value: \.pid) { process in
|
TableColumn("PID", value: \.pid) { process in
|
||||||
Text("\(process.pid)")
|
Text("\(process.pid)")
|
||||||
@@ -127,11 +165,24 @@ struct ProcessView: View {
|
|||||||
}
|
}
|
||||||
.width(70)
|
.width(70)
|
||||||
|
|
||||||
TableColumn("Memory", value: \.memoryUsage) { process in
|
TableColumn("Energy", value: \.energyImpact) { process in
|
||||||
Text(formatBytes(process.memoryUsage))
|
Text(String(format: "%.1f", process.energyImpact))
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
|
.foregroundColor(energyColor(process.energyImpact))
|
||||||
}
|
}
|
||||||
.width(80)
|
.width(60)
|
||||||
|
|
||||||
|
memoryColumns
|
||||||
|
|
||||||
|
TableColumn("Disk I/O", value: \.diskTotalRate) { process in
|
||||||
|
if process.diskTotalRate > 0 {
|
||||||
|
Text(formatBytesPerSecond(process.diskTotalRate))
|
||||||
|
.monospacedDigit()
|
||||||
|
} else {
|
||||||
|
Text("—").foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width(90)
|
||||||
|
|
||||||
TableColumn("Threads", value: \.threadCount) { process in
|
TableColumn("Threads", value: \.threadCount) { process in
|
||||||
Text("\(process.threadCount)")
|
Text("\(process.threadCount)")
|
||||||
@@ -157,6 +208,10 @@ struct ProcessView: View {
|
|||||||
.contextMenu(forSelectionType: ProcessItem.ID.self) { selection in
|
.contextMenu(forSelectionType: ProcessItem.ID.self) { selection in
|
||||||
if let pid = selection.first,
|
if let pid = selection.first,
|
||||||
let process = monitor.processes.first(where: { $0.pid == pid }) {
|
let process = monitor.processes.first(where: { $0.pid == pid }) {
|
||||||
|
Button("Get Info (⌘I)") {
|
||||||
|
inspectorProcess = process
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
Button("Terminate (⌫)") {
|
Button("Terminate (⌫)") {
|
||||||
initiateTerminate()
|
initiateTerminate()
|
||||||
}
|
}
|
||||||
@@ -177,7 +232,11 @@ struct ProcessView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} primaryAction: { selection in
|
} primaryAction: { selection in
|
||||||
// Double-click action
|
// Double-click opens the deep-dive inspector
|
||||||
|
if let pid = selection.first,
|
||||||
|
let process = monitor.processes.first(where: { $0.pid == pid }) {
|
||||||
|
inspectorProcess = process
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onDeleteCommand {
|
.onDeleteCommand {
|
||||||
initiateTerminate()
|
initiateTerminate()
|
||||||
@@ -211,6 +270,14 @@ struct ProcessView: View {
|
|||||||
sortColumn = .cpuTotal
|
sortColumn = .cpuTotal
|
||||||
} else if keyPathString.contains("cpuUsage") {
|
} else if keyPathString.contains("cpuUsage") {
|
||||||
sortColumn = .cpu
|
sortColumn = .cpu
|
||||||
|
} else if keyPathString.contains("energyImpact") {
|
||||||
|
sortColumn = .energy
|
||||||
|
} else if keyPathString.contains("diskTotalRate") {
|
||||||
|
sortColumn = .disk
|
||||||
|
} else if keyPathString.contains("residentMemory") {
|
||||||
|
sortColumn = .resident
|
||||||
|
} else if keyPathString.contains("compressedMemory") {
|
||||||
|
sortColumn = .compressed
|
||||||
} else if keyPathString.contains("memoryUsage") {
|
} else if keyPathString.contains("memoryUsage") {
|
||||||
sortColumn = .memory
|
sortColumn = .memory
|
||||||
} else if keyPathString.contains("name") {
|
} else if keyPathString.contains("name") {
|
||||||
@@ -229,7 +296,10 @@ struct ProcessView: View {
|
|||||||
}
|
}
|
||||||
.background(
|
.background(
|
||||||
KeyboardShortcutHandler(
|
KeyboardShortcutHandler(
|
||||||
onCommandBackspace: { initiateForceKill() }
|
onCommandBackspace: { initiateForceKill() },
|
||||||
|
onCommandI: {
|
||||||
|
if let process = selectedProcessItem { inspectorProcess = process }
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.sheet(isPresented: $showTerminateConfirm) {
|
.sheet(isPresented: $showTerminateConfirm) {
|
||||||
@@ -268,6 +338,10 @@ 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() {
|
||||||
@@ -350,6 +424,17 @@ struct ProcessView: View {
|
|||||||
return .primary
|
return .primary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func energyColor(_ impact: Double) -> Color {
|
||||||
|
if impact > 50 {
|
||||||
|
return .red
|
||||||
|
} else if impact > 20 {
|
||||||
|
return .orange
|
||||||
|
} else if impact > 8 {
|
||||||
|
return .yellow
|
||||||
|
}
|
||||||
|
return .primary
|
||||||
|
}
|
||||||
|
|
||||||
private func stateColor(_ state: ProcessState) -> Color {
|
private func stateColor(_ state: ProcessState) -> Color {
|
||||||
switch state {
|
switch state {
|
||||||
case .running: return .green
|
case .running: return .green
|
||||||
@@ -441,28 +526,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))
|
||||||
|
}
|
||||||
|
}
|
||||||
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
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.
|
||||||
35
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:
|
||||||
@@ -33,5 +33,34 @@ targets:
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
info:
|
info:
|
||||||
path: TopManager/Info.plist
|
path: TopManager/Info.plist
|
||||||
entitlements:
|
|
||||||
path: TopManager/TopManager.entitlements
|
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
|
||||||