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

|
||||

|
||||

|
||||

|
||||
|
||||
## Download
|
||||
|
||||
**[⬇️ Download TopManager 1.2.0 (.dmg)](https://github.com/hariel1985/TopManager/releases/latest)**
|
||||
|
||||
Signed with a Developer ID and **notarized by Apple** — it opens without Gatekeeper
|
||||
warnings. Open the DMG and drag TopManager to your Applications folder.
|
||||
All [releases](https://github.com/hariel1985/TopManager/releases).
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -29,10 +38,13 @@ A native macOS system monitor application built with SwiftUI. TopManager provide
|
||||
## Features
|
||||
|
||||
### Processes Tab
|
||||
- View all running processes with CPU, memory, and thread information
|
||||
- Sort by any column (name, PID, CPU%, memory, threads, user, state)
|
||||
- View all running processes with CPU, memory, thread, **energy-impact**, and
|
||||
**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
|
||||
- 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
|
||||
|
||||
### Apps Tab
|
||||
@@ -43,17 +55,38 @@ A native macOS system monitor application built with SwiftUI. TopManager provide
|
||||
|
||||
### Performance Tab
|
||||
- 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
|
||||
- Network throughput monitoring
|
||||
- Support for Apple Silicon P-cores and E-cores
|
||||
|
||||
### Power & Storage Tab
|
||||
- System status: macOS version, uptime, thermal state
|
||||
- **System Health score (0–100)** with plain-language diagnosis of what's wrong
|
||||
- **Recent Alerts** inbox
|
||||
- **Battery & Power**: charge, health (cycle-adjusted), cycle count, condition,
|
||||
temperature, power draw, time remaining, adapter wattage (via IOKit)
|
||||
- System status: macOS version, uptime, thermal state, CPU temperature (Apple Silicon)
|
||||
- CPU and GPU core counts
|
||||
- GPU memory/VRAM usage
|
||||
- Storage volumes with usage bars
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
|
||||
Built with SwiftUI and native macOS APIs including:
|
||||
- `libproc` for process information
|
||||
- `IOKit` for GPU and hardware monitoring
|
||||
- `libproc` / `rusage_info` for process information, disk I/O and energy signals
|
||||
- `IOKit` for GPU, battery (AppleSmartBattery) and hardware monitoring
|
||||
- `Metal` for GPU detection
|
||||
- `SystemConfiguration` for network monitoring
|
||||
- `UserNotifications` for proactive alerts
|
||||
- `ServiceManagement` (SMAppService) for launch-at-login
|
||||
- Swift `Charts` for time-series visualization
|
||||
|
||||
BINáris
Screenshots/performance-cpu.png
|
Before Width: | Height: | Méret: 229 KiB After Width: | Height: | Méret: 225 KiB |
|
Before Width: | Height: | Méret: 227 KiB After Width: | Height: | Méret: 241 KiB |
BINáris
Screenshots/power-storage.png
|
Before Width: | Height: | Méret: 236 KiB After Width: | Height: | Méret: 270 KiB |
BINáris
Screenshots/processes.png
|
Before Width: | Height: | Méret: 498 KiB After Width: | Height: | Méret: 616 KiB |
@@ -8,90 +8,148 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
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 */; };
|
||||
136DAD1FE90FD682DF666667 /* TopManager_icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 8E4A6E8E2305CEDC119AC6BC /* TopManager_icon.png */; };
|
||||
14D77C268F9F701E612E4FD3 /* WindowVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */; };
|
||||
1929475349F9A25B0778DF9B /* ProcessTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19ADFC64E855FBDCDAF600E5 /* ProcessTableView.swift */; };
|
||||
1F212CE158B7D14B29077B18 /* CPUInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44281079654C7E1C9E16806C /* CPUInfo.swift */; };
|
||||
1F25A84B560709AE71C27D23 /* GPUInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F481045B4FC6017F50849FF1 /* GPUInfo.swift */; };
|
||||
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */; };
|
||||
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 */; };
|
||||
35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */; };
|
||||
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 */; };
|
||||
5DB015294ABB26FF14B1D432 /* TemperatureMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */; };
|
||||
67F66E7EC7C01A6309DC060E /* AlertCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2C96471F109FF1C20ABF59E /* AlertCenter.swift */; };
|
||||
6A0810422D2EC8F9F5D191C4 /* HealthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126A382E2755C5061E6D29F5 /* HealthView.swift */; };
|
||||
744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */; };
|
||||
79E837AE662021C521F7E74F /* HealthModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C70E54E350F7C8C22D342594 /* HealthModel.swift */; };
|
||||
7A1144E43159D96564C13164 /* MemoryInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */; };
|
||||
7A6088469A2643D360B35B89 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892247B0F17363DFC146EDF8 /* AppSettings.swift */; };
|
||||
802A6F368A475126C1349A03 /* ThermalMathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9632A456D316A019CFA313 /* ThermalMathTests.swift */; };
|
||||
833976FD8DC4CCC45078CF9A /* DiskMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */; };
|
||||
8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01C19ABF49697A97F0C40378 /* SettingsTests.swift */; };
|
||||
8F9ED99AB6D8D90AE79B5179 /* DiskUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */; };
|
||||
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 */; };
|
||||
A7EC9FB55D5088D669491C11 /* AppsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813543165B39EBD213EB3BC7 /* AppsView.swift */; };
|
||||
AE29356C1D41F33A3E94B6B7 /* MenuBarStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B37A9AE9F6215C0C132B8D9 /* MenuBarStatus.swift */; };
|
||||
B0165358B483B0ECC28DB4DE /* PowerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C512B13DC0C1024CF39A5E /* PowerInfo.swift */; };
|
||||
B02EE645434EF31AAE82825E /* ProcessTableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F822BA26055B319B7D13F8 /* ProcessTableTests.swift */; };
|
||||
B62B18DF32A8734D5908B16B /* DiskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352CA1BE2A3B43643A9DBD72 /* DiskInfo.swift */; };
|
||||
B89695749D32F394DB459C15 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AACBF14FFC5EC68C9E438040 /* SettingsView.swift */; };
|
||||
BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */; };
|
||||
C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110A314A63AE355A9E04990D /* ProcessDetailView.swift */; };
|
||||
CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFADE83965AA863B775A910 /* NetworkChartView.swift */; };
|
||||
CDA14A3B28EFF15D004D714F /* ThermalInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A81F77752CFF49D5038B06E /* ThermalInfo.swift */; };
|
||||
D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A404EBD588B7ACD36079A95D /* TopManagerApp.swift */; };
|
||||
D9E882888E23C26B748E22CF /* PowerMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */; };
|
||||
DA03FF0D25DB8B9D3B09BC82 /* MenuBarStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */; };
|
||||
DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279B972C08A878053821285D /* SystemMonitor.swift */; };
|
||||
DE50ECC69FCE60B10364802C /* ByteFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */; };
|
||||
E379C349DC9617D6C505B74B /* GPUMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */; };
|
||||
E40A4E80ED4092A3D3A9C219 /* MemoryDonutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F402CE13EF67DE550786B79 /* MemoryDonutView.swift */; };
|
||||
E4AF7F7CA1E1FE8638CA5A77 /* NetworkInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3010F605712E09175D9D8878 /* NetworkInfo.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 */; };
|
||||
FA252F9182ADDA26AD1E54DE /* CPUChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D5A13CC5B9CF1B90CC2214 /* CPUChartView.swift */; };
|
||||
/* 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 */
|
||||
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>"; };
|
||||
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>"; };
|
||||
19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureMonitor.swift; sourceTree = "<group>"; };
|
||||
19ADFC64E855FBDCDAF600E5 /* ProcessTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessTableView.swift; sourceTree = "<group>"; };
|
||||
19ED582C412C63A0A2E2103E /* CPUMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUMonitor.swift; sourceTree = "<group>"; };
|
||||
279B972C08A878053821285D /* SystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitor.swift; sourceTree = "<group>"; };
|
||||
27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryMonitor.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessInfo.swift; sourceTree = "<group>"; };
|
||||
51C512B13DC0C1024CF39A5E /* PowerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerInfo.swift; sourceTree = "<group>"; };
|
||||
5B37A9AE9F6215C0C132B8D9 /* MenuBarStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarStatus.swift; sourceTree = "<group>"; };
|
||||
5E1D2EA8E1AD11B42FF225EB /* TopManager.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TopManager.entitlements; sourceTree = "<group>"; };
|
||||
61F822BA26055B319B7D13F8 /* ProcessTableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessTableTests.swift; sourceTree = "<group>"; };
|
||||
70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryMathTests.swift; sourceTree = "<group>"; };
|
||||
7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLineChart.swift; sourceTree = "<group>"; };
|
||||
78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteFormatters.swift; sourceTree = "<group>"; };
|
||||
7A81F77752CFF49D5038B06E /* ThermalInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThermalInfo.swift; sourceTree = "<group>"; };
|
||||
7B6F97E6EF65AA17A469F037 /* ProcessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessView.swift; sourceTree = "<group>"; };
|
||||
813543165B39EBD213EB3BC7 /* AppsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsView.swift; sourceTree = "<group>"; };
|
||||
892247B0F17363DFC146EDF8 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
|
||||
8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowVisibility.swift; sourceTree = "<group>"; };
|
||||
8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarStatusTests.swift; sourceTree = "<group>"; };
|
||||
8E4A6E8E2305CEDC119AC6BC /* TopManager_icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = TopManager_icon.png; sourceTree = "<group>"; };
|
||||
8F402CE13EF67DE550786B79 /* MemoryDonutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryDonutView.swift; sourceTree = "<group>"; };
|
||||
94D5A13CC5B9CF1B90CC2214 /* CPUChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUChartView.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
F80A817DCDA254349DEA6F06 /* PerformanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceView.swift; sourceTree = "<group>"; };
|
||||
FD9632A456D316A019CFA313 /* ThermalMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThermalMathTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
3C4D5E6F7A8B9C0D1E2F3A4B /* Apps */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1A2B3C4D5E6F7A8B9C0D1E2F /* AppsView.swift */,
|
||||
);
|
||||
path = Apps;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
06D63CC4D5468CEC1E31137A /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C2C96471F109FF1C20ABF59E /* AlertCenter.swift */,
|
||||
892247B0F17363DFC146EDF8 /* AppSettings.swift */,
|
||||
19ED582C412C63A0A2E2103E /* CPUMonitor.swift */,
|
||||
162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */,
|
||||
BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */,
|
||||
27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */,
|
||||
5B37A9AE9F6215C0C132B8D9 /* MenuBarStatus.swift */,
|
||||
05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */,
|
||||
347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */,
|
||||
95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */,
|
||||
295232B4C746511757DCF8FE /* ProcessMonitor.swift */,
|
||||
279B972C08A878053821285D /* SystemMonitor.swift */,
|
||||
19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */,
|
||||
8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
@@ -100,6 +158,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
36BB5A20B1330E54F2DCF357 /* TopManager */,
|
||||
D078BA97D4F498E5282AC5E0 /* TopManagerTests */,
|
||||
E6438D9AF8EDEFEDCB087B38 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -107,12 +166,16 @@
|
||||
32ADF00BA31F63CAE52B2D0A /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0D5B918C9462144E9EE7880A /* Alert.swift */,
|
||||
44281079654C7E1C9E16806C /* CPUInfo.swift */,
|
||||
352CA1BE2A3B43643A9DBD72 /* DiskInfo.swift */,
|
||||
F481045B4FC6017F50849FF1 /* GPUInfo.swift */,
|
||||
C70E54E350F7C8C22D342594 /* HealthModel.swift */,
|
||||
BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */,
|
||||
3010F605712E09175D9D8878 /* NetworkInfo.swift */,
|
||||
51C512B13DC0C1024CF39A5E /* PowerInfo.swift */,
|
||||
4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */,
|
||||
7A81F77752CFF49D5038B06E /* ThermalInfo.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
@@ -141,6 +204,7 @@
|
||||
7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */,
|
||||
BBFADE83965AA863B775A910 /* NetworkChartView.swift */,
|
||||
F80A817DCDA254349DEA6F06 /* PerformanceView.swift */,
|
||||
9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */,
|
||||
);
|
||||
path = Performance;
|
||||
sourceTree = "<group>";
|
||||
@@ -153,6 +217,14 @@
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6901D0C7B3DA57547BCFAAFC /* Apps */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
813543165B39EBD213EB3BC7 /* AppsView.swift */,
|
||||
);
|
||||
path = Apps;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9C38088C52BDE6A2BA1CEF73 /* Utilities */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -166,7 +238,9 @@
|
||||
children = (
|
||||
C4F1ACD0492CAB741595DEAA /* ContentView.swift */,
|
||||
9D71CC63FC2FEF52A069372E /* MenuBarView.swift */,
|
||||
3C4D5E6F7A8B9C0D1E2F3A4B /* Apps */,
|
||||
AACBF14FFC5EC68C9E438040 /* SettingsView.swift */,
|
||||
6901D0C7B3DA57547BCFAAFC /* Apps */,
|
||||
CD1E8360C279FD4EFAD85DF6 /* Health */,
|
||||
4935484C55FD4C912A30D623 /* Performance */,
|
||||
CC52DA4C64987575E4D96A00 /* PowerStorage */,
|
||||
F2616FC0A232C728E792C2E8 /* Processes */,
|
||||
@@ -183,10 +257,35 @@
|
||||
path = PowerStorage;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CD1E8360C279FD4EFAD85DF6 /* Health */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
126A382E2755C5061E6D29F5 /* HealthView.swift */,
|
||||
);
|
||||
path = Health;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D078BA97D4F498E5282AC5E0 /* TopManagerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */,
|
||||
301352EB5B362C732CE1A246 /* FormattingTests.swift */,
|
||||
E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */,
|
||||
8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */,
|
||||
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */,
|
||||
332290DABD5326C87258723C /* ProcessDataTests.swift */,
|
||||
61F822BA26055B319B7D13F8 /* ProcessTableTests.swift */,
|
||||
01C19ABF49697A97F0C40378 /* SettingsTests.swift */,
|
||||
FD9632A456D316A019CFA313 /* ThermalMathTests.swift */,
|
||||
);
|
||||
path = TopManagerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E6438D9AF8EDEFEDCB087B38 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A3A3D0B1EB522C9975F71DA8 /* TopManager.app */,
|
||||
0F5A69E0EE7FDD44157911D7 /* TopManagerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -195,6 +294,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
110A314A63AE355A9E04990D /* ProcessDetailView.swift */,
|
||||
19ADFC64E855FBDCDAF600E5 /* ProcessTableView.swift */,
|
||||
7B6F97E6EF65AA17A469F037 /* ProcessView.swift */,
|
||||
);
|
||||
path = Processes;
|
||||
@@ -203,6 +303,24 @@
|
||||
/* End PBXGroup 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 */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 56829675F6738AEDCFCDEE52 /* Build configuration list for PBXNativeTarget "TopManager" */;
|
||||
@@ -230,8 +348,12 @@
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1500;
|
||||
TargetAttributes = {
|
||||
D28E798AA7BEAA0638DFD52E = {
|
||||
DevelopmentTeam = P3663392R2;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
F86DD5615146D759EB0D44EB = {
|
||||
DevelopmentTeam = "";
|
||||
DevelopmentTeam = P3663392R2;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
@@ -251,6 +373,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
F86DD5615146D759EB0D44EB /* TopManager */,
|
||||
D28E798AA7BEAA0638DFD52E /* TopManagerTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -268,11 +391,30 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
692062A41592E988A6547E4C /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2DE14B65EA1D4E9B4D9DA5A6 /* BatteryMathTests.swift in Sources */,
|
||||
49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */,
|
||||
489E29F78A4093D3548673E6 /* HealthAlertTests.swift in Sources */,
|
||||
DA03FF0D25DB8B9D3B09BC82 /* MenuBarStatusTests.swift in Sources */,
|
||||
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */,
|
||||
2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */,
|
||||
B02EE645434EF31AAE82825E /* ProcessTableTests.swift in Sources */,
|
||||
8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */,
|
||||
802A6F368A475126C1349A03 /* ThermalMathTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
CF5286324D09B9417D3070C3 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
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 */,
|
||||
FA252F9182ADDA26AD1E54DE /* CPUChartView.swift in Sources */,
|
||||
1F212CE158B7D14B29077B18 /* CPUInfo.swift in Sources */,
|
||||
@@ -283,27 +425,47 @@
|
||||
8F9ED99AB6D8D90AE79B5179 /* DiskUsageView.swift in Sources */,
|
||||
1F25A84B560709AE71C27D23 /* GPUInfo.swift in Sources */,
|
||||
E379C349DC9617D6C505B74B /* GPUMonitor.swift in Sources */,
|
||||
79E837AE662021C521F7E74F /* HealthModel.swift in Sources */,
|
||||
6A0810422D2EC8F9F5D191C4 /* HealthView.swift in Sources */,
|
||||
E40A4E80ED4092A3D3A9C219 /* MemoryDonutView.swift in Sources */,
|
||||
7A1144E43159D96564C13164 /* MemoryInfo.swift in Sources */,
|
||||
266B888ADE2FDDBCEC8D28A5 /* MemoryLineChart.swift in Sources */,
|
||||
F5722D2B18EF7ABF43847EE4 /* MemoryMonitor.swift in Sources */,
|
||||
AE29356C1D41F33A3E94B6B7 /* MenuBarStatus.swift in Sources */,
|
||||
35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */,
|
||||
BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */,
|
||||
CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */,
|
||||
E4AF7F7CA1E1FE8638CA5A77 /* NetworkInfo.swift in Sources */,
|
||||
EB0D84848F42134927F9C9B1 /* NetworkMonitor.swift in Sources */,
|
||||
00EC5A90B7B8C6C5015D90D1 /* PerformanceView.swift in Sources */,
|
||||
B0165358B483B0ECC28DB4DE /* PowerInfo.swift in Sources */,
|
||||
D9E882888E23C26B748E22CF /* PowerMonitor.swift in Sources */,
|
||||
501C13D57386C179C0B3B14B /* PowerStorageView.swift in Sources */,
|
||||
C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */,
|
||||
744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */,
|
||||
9146403BC02D18A234444DF6 /* ProcessMonitor.swift in Sources */,
|
||||
1929475349F9A25B0778DF9B /* ProcessTableView.swift in Sources */,
|
||||
0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */,
|
||||
B89695749D32F394DB459C15 /* SettingsView.swift in Sources */,
|
||||
DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */,
|
||||
5DB015294ABB26FF14B1D432 /* TemperatureMonitor.swift in Sources */,
|
||||
CDA14A3B28EFF15D004D714F /* ThermalInfo.swift in Sources */,
|
||||
D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */,
|
||||
9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */,
|
||||
14D77C268F9F701E612E4FD3 /* WindowVisibility.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
48A04DA713F139236C91459E /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = F86DD5615146D759EB0D44EB /* TopManager */;
|
||||
targetProxy = B01CE3A16D70795D608951EE /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
00F8AAE6BB8D4ED66312B6CB /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
@@ -311,12 +473,14 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = TopManager/TopManager.entitlements;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TopManager/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.app;
|
||||
PRODUCT_NAME = TopManager;
|
||||
SDKROOT = macosx;
|
||||
@@ -360,7 +524,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = P3663392R2;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
@@ -390,18 +554,38 @@
|
||||
};
|
||||
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 */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = TopManager/TopManager.entitlements;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TopManager/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.app;
|
||||
PRODUCT_NAME = TopManager;
|
||||
SDKROOT = macosx;
|
||||
@@ -445,7 +629,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = P3663392R2;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
@@ -468,6 +652,24 @@
|
||||
};
|
||||
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 */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -480,6 +682,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
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" */ = {
|
||||
isa = XCConfigurationList;
|
||||
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>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
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 cpuUsage: Double // Per-core: 100% = 1 core fully utilized
|
||||
let cpuUsageTotal: Double // Normalized: 100% = all cores fully utilized
|
||||
let memoryUsage: Int64
|
||||
let memoryUsage: Int64 // physical footprint (Activity Monitor's "Memory")
|
||||
let residentMemory: Int64 // pages currently in physical RAM, shared ones included
|
||||
let compressedMemory: Int64 // original size of pages in the compressor — in RAM or swapped out
|
||||
let threadCount: Int32
|
||||
let state: ProcessState
|
||||
let icon: NSImage?
|
||||
let parentPid: pid_t
|
||||
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 diskTotalRate: Double { diskReadRate + diskWriteRate }
|
||||
|
||||
init(
|
||||
pid: pid_t,
|
||||
name: String,
|
||||
@@ -28,7 +38,15 @@ struct ProcessItem: Identifiable, Hashable {
|
||||
state: ProcessState,
|
||||
icon: NSImage?,
|
||||
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.pid = pid
|
||||
@@ -37,11 +55,19 @@ struct ProcessItem: Identifiable, Hashable {
|
||||
self.cpuUsage = cpuUsage
|
||||
self.cpuUsageTotal = cpuUsageTotal
|
||||
self.memoryUsage = memoryUsage
|
||||
self.residentMemory = residentMemory
|
||||
self.compressedMemory = compressedMemory
|
||||
self.threadCount = threadCount
|
||||
self.state = state
|
||||
self.icon = icon
|
||||
self.parentPid = parentPid
|
||||
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) {
|
||||
@@ -53,8 +79,22 @@ struct ProcessItem: Identifiable, Hashable {
|
||||
lhs.cpuUsage == rhs.cpuUsage &&
|
||||
lhs.cpuUsageTotal == rhs.cpuUsageTotal &&
|
||||
lhs.memoryUsage == rhs.memoryUsage &&
|
||||
lhs.residentMemory == rhs.residentMemory &&
|
||||
lhs.compressedMemory == rhs.compressedMemory &&
|
||||
lhs.threadCount == rhs.threadCount &&
|
||||
lhs.state == rhs.state
|
||||
lhs.state == rhs.state &&
|
||||
lhs.diskReadRate == rhs.diskReadRate &&
|
||||
lhs.diskWriteRate == rhs.diskWriteRate &&
|
||||
lhs.energyImpact == rhs.energyImpact
|
||||
}
|
||||
}
|
||||
|
||||
/// Heuristic "energy impact" proxy. This is *not* Apple's exact private formula;
|
||||
/// it approximates it: sustained CPU dominates, with a smaller penalty for idle
|
||||
/// (timer) wakeups, which correlate with power draw even at low CPU.
|
||||
enum EnergyModel {
|
||||
static func impact(cpuPercent: Double, idleWakeupsPerSec: Double) -> Double {
|
||||
max(0, cpuPercent) + max(0, idleWakeupsPerSec) * 0.045
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
53
TopManager/Models/ThermalInfo.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
/// One temperature sensor as reported by the system (name as the hardware names it).
|
||||
struct TemperatureReading: Equatable {
|
||||
let name: String
|
||||
let celsius: Double
|
||||
}
|
||||
|
||||
/// CPU temperature summarised from the die sensors.
|
||||
struct ThermalInfo: Equatable {
|
||||
let cpuMax: Double // °C, hottest CPU die sensor
|
||||
let cpuAverage: Double // °C, mean of the CPU die sensors
|
||||
let cpuSensorCount: Int
|
||||
}
|
||||
|
||||
/// Pure, testable selection of CPU sensors (no IOKit).
|
||||
enum ThermalMath {
|
||||
/// CPU die sensors on Apple Silicon: "PMU tdie*" / "PMU2 tdie*" (M2 and later)
|
||||
/// and "pACC/eACC MTR Temp Sensor*" (M1). The "tdev" ones sit elsewhere on the
|
||||
/// package, "tcal" is a calibration value, "gas gauge battery" is the battery.
|
||||
static func isCPUDie(_ name: String) -> Bool {
|
||||
let n = name.lowercased()
|
||||
return n.contains("tdie") || n.contains("acc mtr temp")
|
||||
}
|
||||
|
||||
/// Readings no real sensor produces (unconnected ones report ≤ 0).
|
||||
static func isPlausible(_ celsius: Double) -> Bool {
|
||||
celsius > 0 && celsius < 150
|
||||
}
|
||||
|
||||
/// How hot the CPU die is. Apple Silicon runs into the 90s under sustained load
|
||||
/// and only throttles near 105 °C, so the bands sit high on purpose.
|
||||
static func level(cpuCelsius t: Double) -> ThermalLevel {
|
||||
switch t {
|
||||
case 100...: return .critical
|
||||
case 90..<100: return .serious
|
||||
case 80..<90: return .fair
|
||||
default: return .nominal
|
||||
}
|
||||
}
|
||||
|
||||
static func summarize(_ readings: [TemperatureReading]) -> ThermalInfo? {
|
||||
let cpu = readings
|
||||
.filter { isCPUDie($0.name) && isPlausible($0.celsius) }
|
||||
.map(\.celsius)
|
||||
guard let hottest = cpu.max() else { return nil }
|
||||
return ThermalInfo(
|
||||
cpuMax: hottest,
|
||||
cpuAverage: cpu.reduce(0, +) / Double(cpu.count),
|
||||
cpuSensorCount: cpu.count
|
||||
)
|
||||
}
|
||||
}
|
||||
136
TopManager/Services/AlertCenter.swift
Normal file
@@ -0,0 +1,136 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import UserNotifications
|
||||
|
||||
/// The proactive brain: each refresh it computes the system health score and
|
||||
/// evaluates alert conditions, firing a native notification + inbox entry on the
|
||||
/// transition into each condition (never duplicating a still-active alert).
|
||||
@MainActor
|
||||
final class AlertCenter: ObservableObject {
|
||||
static let shared = AlertCenter()
|
||||
|
||||
@Published var healthScore: Int = 100
|
||||
@Published var diagnosis: [String] = []
|
||||
@Published var alerts: [SystemAlert] = [] // inbox, newest first
|
||||
@Published private(set) var activeKinds: Set<AlertKind> = []
|
||||
|
||||
@Published var thresholds = AlertThresholds()
|
||||
@Published var notificationsEnabled = true
|
||||
|
||||
private let inboxLimit = 50
|
||||
private var cpuCount = 0
|
||||
private var procCount = 0
|
||||
private var notificationsAuthorized = false
|
||||
|
||||
private init() {}
|
||||
|
||||
var activeAlertCount: Int { activeKinds.count }
|
||||
|
||||
func requestNotificationAuthorization() {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
|
||||
Task { @MainActor in self.notificationsAuthorized = granted }
|
||||
}
|
||||
}
|
||||
|
||||
func evaluate(cpuUsage: Double,
|
||||
memory: MemoryInfo?,
|
||||
disk: DiskInfo?,
|
||||
thermal: ThermalLevel,
|
||||
topProcess: ProcessItem?,
|
||||
power: PowerInfo?) {
|
||||
let diskFraction = disk?.volumes.map { $0.usagePercentage / 100 }.max() ?? 0
|
||||
|
||||
// Health score + diagnosis
|
||||
let input = HealthInput(
|
||||
cpuUsage: cpuUsage,
|
||||
memoryPressure: memory?.memoryPressure ?? .unknown,
|
||||
swapUsedBytes: memory?.swapUsed ?? 0,
|
||||
thermal: thermal,
|
||||
diskUsedFraction: diskFraction
|
||||
)
|
||||
// Assign only on change: @Published fires on every set, and re-assigning
|
||||
// equal values each tick re-rendered every health view on every refresh.
|
||||
let score = HealthModel.score(input)
|
||||
if score != healthScore { healthScore = score }
|
||||
let findings = HealthModel.diagnosis(input)
|
||||
if findings != diagnosis { diagnosis = findings }
|
||||
|
||||
// CPU (sustained)
|
||||
let cpuEval = AlertEvaluator.sustained(breached: cpuUsage >= thresholds.cpuPercent,
|
||||
previousCount: cpuCount,
|
||||
requiredCycles: thresholds.cpuSustainedCycles)
|
||||
cpuCount = cpuEval.count
|
||||
setActive(.cpuHigh, active: cpuEval.firing,
|
||||
title: "High CPU usage",
|
||||
message: String(format: "CPU has stayed above %.0f%% (now %.0f%%).", thresholds.cpuPercent, cpuUsage),
|
||||
severity: .warning)
|
||||
|
||||
// Memory pressure
|
||||
let memCritical = thresholds.memoryPressureCritical && memory?.memoryPressure == .critical
|
||||
setActive(.memoryPressure, active: memCritical,
|
||||
title: "Critical memory pressure",
|
||||
message: "The system is very low on memory. Consider closing apps.",
|
||||
severity: .critical)
|
||||
|
||||
// Disk
|
||||
let diskFull = diskFraction >= thresholds.diskUsedFraction
|
||||
setActive(.diskFull, active: diskFull,
|
||||
title: "Disk almost full",
|
||||
message: String(format: "A volume is %.0f%% full.", diskFraction * 100),
|
||||
severity: .warning)
|
||||
|
||||
// Thermal
|
||||
let thermalBad = thresholds.thermalSerious && (thermal == .serious || thermal == .critical)
|
||||
setActive(.thermal, active: thermalBad,
|
||||
title: "System is running hot",
|
||||
message: "Thermal state is elevated; performance may be throttled.",
|
||||
severity: thermal == .critical ? .critical : .warning)
|
||||
|
||||
// Runaway process (sustained)
|
||||
let runaway = (topProcess?.cpuUsage ?? 0) >= thresholds.processCpuPercent
|
||||
let procEval = AlertEvaluator.sustained(breached: runaway,
|
||||
previousCount: procCount,
|
||||
requiredCycles: thresholds.processSustainedCycles)
|
||||
procCount = procEval.count
|
||||
setActive(.runawayProcess, active: procEval.firing,
|
||||
title: "Runaway process",
|
||||
message: topProcess.map { "\($0.name) (PID \($0.pid)) is using \(Int($0.cpuUsage))% CPU." } ?? "",
|
||||
severity: .warning)
|
||||
|
||||
// Low battery
|
||||
let lowBattery = (power?.hasBattery ?? false)
|
||||
&& !(power?.isPluggedIn ?? true)
|
||||
&& (power?.currentCharge ?? 100) <= thresholds.lowBatteryPercent
|
||||
setActive(.lowBattery, active: lowBattery,
|
||||
title: "Low battery",
|
||||
message: "Battery is at \(power?.currentCharge ?? 0)% and not charging.",
|
||||
severity: .warning)
|
||||
}
|
||||
|
||||
private func setActive(_ kind: AlertKind, active: Bool, title: String, message: String, severity: AlertSeverity) {
|
||||
if active {
|
||||
guard !activeKinds.contains(kind) else { return } // de-dup: already firing
|
||||
activeKinds.insert(kind)
|
||||
let alert = SystemAlert(kind: kind, title: title, message: message, severity: severity, timestamp: Date())
|
||||
alerts.insert(alert, at: 0)
|
||||
if alerts.count > inboxLimit { alerts.removeLast(alerts.count - inboxLimit) }
|
||||
postNotification(alert)
|
||||
} else {
|
||||
activeKinds.remove(kind)
|
||||
}
|
||||
}
|
||||
|
||||
private func postNotification(_ alert: SystemAlert) {
|
||||
guard notificationsEnabled, notificationsAuthorized else { return }
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = alert.title
|
||||
content.body = alert.message
|
||||
content.sound = alert.severity == .critical ? .defaultCritical : .default
|
||||
let request = UNNotificationRequest(identifier: alert.id.uuidString, content: content, trigger: nil)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
|
||||
func clearInbox() {
|
||||
alerts.removeAll()
|
||||
}
|
||||
}
|
||||
108
TopManager/Services/AppSettings.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
import ServiceManagement
|
||||
|
||||
/// What the menu-bar title shows at a glance.
|
||||
enum MenuBarMetric: String, CaseIterable, Identifiable {
|
||||
case cpu = "CPU %"
|
||||
case memory = "Memory %"
|
||||
case health = "Health"
|
||||
case download = "Download"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
/// Wraps the macOS login-item API (SMAppService, macOS 13+).
|
||||
enum LoginItem {
|
||||
static var isEnabled: Bool {
|
||||
SMAppService.mainApp.status == .enabled
|
||||
}
|
||||
static func setEnabled(_ enabled: Bool) {
|
||||
do {
|
||||
if enabled {
|
||||
if SMAppService.mainApp.status != .enabled { try SMAppService.mainApp.register() }
|
||||
} else {
|
||||
if SMAppService.mainApp.status == .enabled { try SMAppService.mainApp.unregister() }
|
||||
}
|
||||
} catch {
|
||||
NSLog("TopManager: login item toggle failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted user preferences. Changes are written to `UserDefaults` and pushed
|
||||
/// into the running services (monitor cadence, alert thresholds, notifications).
|
||||
@MainActor
|
||||
final class AppSettings: ObservableObject {
|
||||
static let shared = AppSettings()
|
||||
|
||||
private let d = UserDefaults.standard
|
||||
|
||||
@Published var refreshInterval: Double {
|
||||
didSet {
|
||||
d.set(refreshInterval, forKey: Keys.refreshInterval)
|
||||
SystemMonitor.shared.updateRefreshInterval(refreshInterval)
|
||||
}
|
||||
}
|
||||
@Published var menuBarMetric: MenuBarMetric {
|
||||
didSet { d.set(menuBarMetric.rawValue, forKey: Keys.menuBarMetric) }
|
||||
}
|
||||
@Published var notificationsEnabled: Bool {
|
||||
didSet {
|
||||
d.set(notificationsEnabled, forKey: Keys.notificationsEnabled)
|
||||
AlertCenter.shared.notificationsEnabled = notificationsEnabled
|
||||
}
|
||||
}
|
||||
@Published var cpuAlertThreshold: Double {
|
||||
didSet {
|
||||
d.set(cpuAlertThreshold, forKey: Keys.cpuAlertThreshold)
|
||||
AlertCenter.shared.thresholds.cpuPercent = cpuAlertThreshold
|
||||
}
|
||||
}
|
||||
@Published var diskAlertPercent: Double {
|
||||
didSet {
|
||||
d.set(diskAlertPercent, forKey: Keys.diskAlertPercent)
|
||||
AlertCenter.shared.thresholds.diskUsedFraction = diskAlertPercent / 100
|
||||
}
|
||||
}
|
||||
@Published var lowBatteryPercent: Int {
|
||||
didSet {
|
||||
d.set(lowBatteryPercent, forKey: Keys.lowBatteryPercent)
|
||||
AlertCenter.shared.thresholds.lowBatteryPercent = lowBatteryPercent
|
||||
}
|
||||
}
|
||||
@Published var launchAtLogin: Bool {
|
||||
didSet { LoginItem.setEnabled(launchAtLogin) }
|
||||
}
|
||||
|
||||
private init() {
|
||||
refreshInterval = AppSettings.clampInterval(d.object(forKey: Keys.refreshInterval) as? Double ?? 3.0)
|
||||
menuBarMetric = MenuBarMetric(rawValue: d.string(forKey: Keys.menuBarMetric) ?? "") ?? .cpu
|
||||
notificationsEnabled = d.object(forKey: Keys.notificationsEnabled) as? Bool ?? true
|
||||
cpuAlertThreshold = d.object(forKey: Keys.cpuAlertThreshold) as? Double ?? 90
|
||||
diskAlertPercent = d.object(forKey: Keys.diskAlertPercent) as? Double ?? 95
|
||||
lowBatteryPercent = d.object(forKey: Keys.lowBatteryPercent) as? Int ?? 15
|
||||
launchAtLogin = LoginItem.isEnabled
|
||||
}
|
||||
|
||||
/// Push all persisted values into the live services (call once on launch).
|
||||
func applyAll() {
|
||||
SystemMonitor.shared.updateRefreshInterval(refreshInterval)
|
||||
AlertCenter.shared.notificationsEnabled = notificationsEnabled
|
||||
AlertCenter.shared.thresholds.cpuPercent = cpuAlertThreshold
|
||||
AlertCenter.shared.thresholds.diskUsedFraction = diskAlertPercent / 100
|
||||
AlertCenter.shared.thresholds.lowBatteryPercent = lowBatteryPercent
|
||||
}
|
||||
|
||||
/// Refresh cadence must stay in a sane range (1–30s).
|
||||
nonisolated static func clampInterval(_ value: Double) -> Double {
|
||||
min(30, max(1, value))
|
||||
}
|
||||
|
||||
private enum Keys {
|
||||
static let refreshInterval = "refreshInterval"
|
||||
static let menuBarMetric = "menuBarMetric"
|
||||
static let notificationsEnabled = "notificationsEnabled"
|
||||
static let cpuAlertThreshold = "cpuAlertThreshold"
|
||||
static let diskAlertPercent = "diskAlertPercent"
|
||||
static let lowBatteryPercent = "lowBatteryPercent"
|
||||
}
|
||||
}
|
||||
@@ -86,14 +86,8 @@ final class GPUMonitor {
|
||||
}
|
||||
|
||||
private static func getAppleSiliconChipName() -> String? {
|
||||
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"))
|
||||
guard platformExpert != 0 else { return nil }
|
||||
defer { IOObjectRelease(platformExpert) }
|
||||
|
||||
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? {
|
||||
// Apple Silicon uses unified memory - get memory pressure instead
|
||||
let totalMemory = ProcessInfo.processInfo.physicalMemory
|
||||
@@ -139,19 +152,13 @@ final class GPUMonitor {
|
||||
if IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator) == KERN_SUCCESS {
|
||||
defer { IOObjectRelease(iterator) }
|
||||
|
||||
var service = IOIteratorNext(iterator)
|
||||
while service != 0 {
|
||||
defer {
|
||||
IOObjectRelease(service)
|
||||
service = IOIteratorNext(iterator)
|
||||
}
|
||||
|
||||
if let props = IORegistryEntryCreateCFProperty(
|
||||
_ = Self.firstMatch(in: iterator) { service -> Bool? in
|
||||
guard let props = IORegistryEntryCreateCFProperty(
|
||||
service,
|
||||
"PerformanceStatistics" as CFString,
|
||||
kCFAllocatorDefault,
|
||||
0
|
||||
)?.takeRetainedValue() as? [String: Any] {
|
||||
)?.takeRetainedValue() as? [String: Any] else { return nil }
|
||||
|
||||
// Try various keys for GPU memory on Apple Silicon
|
||||
if let inUse = props["In use system memory"] as? UInt64 {
|
||||
@@ -169,8 +176,7 @@ final class GPUMonitor {
|
||||
utilizationPercent = Double(util)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,13 +200,7 @@ final class GPUMonitor {
|
||||
}
|
||||
defer { IOObjectRelease(iterator) }
|
||||
|
||||
var service = IOIteratorNext(iterator)
|
||||
while service != 0 {
|
||||
defer {
|
||||
IOObjectRelease(service)
|
||||
service = IOIteratorNext(iterator)
|
||||
}
|
||||
|
||||
return Self.firstMatch(in: iterator) { service -> GPUInfo? in
|
||||
var name = "GPU"
|
||||
if let modelData = IORegistryEntryCreateCFProperty(
|
||||
service,
|
||||
@@ -246,8 +246,8 @@ final class GPUMonitor {
|
||||
isUnifiedMemory: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
private var previousCPUTimes: [pid_t: (user: UInt64, system: UInt64, timestamp: Date)] = [:]
|
||||
private var previousDiskIO: [pid_t: (read: UInt64, write: UInt64, idle: UInt64, timestamp: Date)] = [:]
|
||||
private var lastKnownCPU: [pid_t: Double] = [:] // Cache last known CPU usage
|
||||
private var lastDetailed: [pid_t: DetailedSample] = [:] // Reused on lightweight refreshes
|
||||
private let iconCache = NSCache<NSNumber, NSImage>()
|
||||
private var noIconPids: Set<pid_t> = [] // Cache for PIDs with no icon
|
||||
private var nameCache: [pid_t: String] = [:]
|
||||
private var pathCache: [pid_t: String] = [:] // Full executable path
|
||||
private var userCache: [uid_t: String] = [:]
|
||||
private let timebaseInfo: mach_timebase_info_data_t
|
||||
private var refreshCounter = 0
|
||||
@@ -48,10 +51,13 @@ final class ProcessMonitor {
|
||||
let stalePids = Set(nameCache.keys).subtracting(currentPids)
|
||||
for pid in stalePids {
|
||||
nameCache.removeValue(forKey: pid)
|
||||
pathCache.removeValue(forKey: pid)
|
||||
iconCache.removeObject(forKey: NSNumber(value: pid))
|
||||
previousCPUTimes.removeValue(forKey: pid)
|
||||
previousDiskIO.removeValue(forKey: pid)
|
||||
noIconPids.remove(pid)
|
||||
lastKnownCPU.removeValue(forKey: pid)
|
||||
lastDetailed.removeValue(forKey: pid)
|
||||
}
|
||||
|
||||
return processes
|
||||
@@ -77,9 +83,7 @@ final class ProcessMonitor {
|
||||
let lastCPU = lastKnownCPU[pid] ?? 0
|
||||
let needsDetailedInfo = fullRefresh || lastCPU > 0.1
|
||||
|
||||
let memoryUsage: Int64
|
||||
let threadCount: Int32
|
||||
let cpuUsage: Double
|
||||
let detail: DetailedSample
|
||||
|
||||
if needsDetailedInfo {
|
||||
var taskInfo = proc_taskinfo()
|
||||
@@ -89,25 +93,44 @@ final class ProcessMonitor {
|
||||
|
||||
if hasTaskInfo {
|
||||
let rusageData = fetchRusageData(pid: pid)
|
||||
memoryUsage = rusageData.memory
|
||||
threadCount = taskInfo.pti_threadnum
|
||||
cpuUsage = calculateCPUUsage(
|
||||
let cpu = calculateCPUUsage(
|
||||
pid: pid,
|
||||
userTime: rusageData.userTime,
|
||||
systemTime: rusageData.systemTime
|
||||
)
|
||||
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 {
|
||||
memoryUsage = 0
|
||||
threadCount = 0
|
||||
cpuUsage = 0
|
||||
detail = .zero
|
||||
}
|
||||
} else {
|
||||
// Lightweight refresh - reuse last known values
|
||||
memoryUsage = 0
|
||||
threadCount = 0
|
||||
cpuUsage = 0
|
||||
// Lightweight refresh - reuse last known values. This used to emit
|
||||
// zeros, so ~90% of rows showed "Zero KB" memory (and 0 threads/CPU)
|
||||
// on two of every three updates.
|
||||
detail = lastDetailed[pid] ?? .zero
|
||||
}
|
||||
|
||||
let memoryUsage = detail.memory
|
||||
let threadCount = detail.threads
|
||||
let cpuUsage = detail.cpu
|
||||
|
||||
let state = determineProcessState(status: bsdInfo.pbi_status, cpuUsage: cpuUsage)
|
||||
|
||||
let icon = fetchIcon(pid: pid)
|
||||
@@ -126,7 +149,15 @@ final class ProcessMonitor {
|
||||
state: state,
|
||||
icon: icon,
|
||||
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
|
||||
let pathData = Data(bytes: pathBuffer, count: pathLength)
|
||||
if let path = String(data: pathData, encoding: .utf8) {
|
||||
pathCache[pid] = path
|
||||
name = (path as NSString).lastPathComponent
|
||||
} else {
|
||||
name = "Process \(pid)"
|
||||
@@ -203,7 +235,35 @@ final class ProcessMonitor {
|
||||
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()
|
||||
let result = withUnsafeMutablePointer(to: &rusage) { ptr -> Int32 in
|
||||
ptr.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) { rusagePtr in
|
||||
@@ -212,25 +272,47 @@ final class ProcessMonitor {
|
||||
}
|
||||
|
||||
if result == 0 {
|
||||
return (
|
||||
return RusageData(
|
||||
memory: Int64(rusage.ri_phys_footprint),
|
||||
resident: Int64(rusage.ri_resident_size),
|
||||
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()
|
||||
let taskInfoSize = Int32(MemoryLayout<proc_taskinfo>.size)
|
||||
if proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, taskInfoSize) == taskInfoSize {
|
||||
return (
|
||||
return RusageData(
|
||||
memory: Int64(taskInfo.pti_resident_size),
|
||||
resident: Int64(taskInfo.pti_resident_size),
|
||||
userTime: taskInfo.pti_total_user,
|
||||
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? {
|
||||
@@ -373,3 +455,78 @@ final class ProcessMonitor {
|
||||
previousCPUTimes.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - On-demand deep inspection (called only when the inspector is open)
|
||||
|
||||
/// Pure parser for a KERN_PROCARGS2 buffer, split out so it is unit-testable
|
||||
/// without a live process. Layout: [Int32 argc][exec_path\0][\0 padding][argv…][envp…]
|
||||
enum ProcArgs {
|
||||
static func parse(_ bytes: [UInt8]) -> (path: String, args: [String])? {
|
||||
guard bytes.count > 4 else { return nil }
|
||||
// argc: first 4 bytes, host little-endian (arm64/x86_64)
|
||||
let argc = Int(bytes[0]) | (Int(bytes[1]) << 8) | (Int(bytes[2]) << 16) | (Int(bytes[3]) << 24)
|
||||
guard argc >= 0 else { return nil }
|
||||
|
||||
var i = 4
|
||||
let pathStart = i
|
||||
while i < bytes.count && bytes[i] != 0 { i += 1 }
|
||||
let path = String(decoding: bytes[pathStart..<i], as: UTF8.self)
|
||||
|
||||
// Skip the NUL padding between exec_path and argv[0]
|
||||
while i < bytes.count && bytes[i] == 0 { i += 1 }
|
||||
|
||||
var args: [String] = []
|
||||
var consumed = 0
|
||||
while consumed < argc && i < bytes.count {
|
||||
let start = i
|
||||
while i < bytes.count && bytes[i] != 0 { i += 1 }
|
||||
args.append(String(decoding: bytes[start..<i], as: UTF8.self))
|
||||
i += 1 // skip the NUL terminator
|
||||
consumed += 1
|
||||
}
|
||||
return (path, args)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the launch arguments for a process (works for the current user's
|
||||
/// processes without elevated privileges; returns [] when denied/unavailable).
|
||||
func fetchProcessArguments(pid: pid_t) -> [String] {
|
||||
var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, pid]
|
||||
var size = 0
|
||||
guard sysctl(&mib, 3, nil, &size, nil, 0) == 0, size > 0 else { return [] }
|
||||
|
||||
var buffer = [UInt8](repeating: 0, count: size)
|
||||
guard sysctl(&mib, 3, &buffer, &size, nil, 0) == 0 else { return [] }
|
||||
// sysctl may shrink `size`; only parse what was actually written.
|
||||
return ProcArgs.parse(Array(buffer.prefix(size)))?.args ?? []
|
||||
}
|
||||
|
||||
/// Memory of a process currently held by the compressor, at its original
|
||||
/// (uncompressed) size. macOS does not track swap per process: when compressor
|
||||
/// segments are paged out to disk they stay counted here, so this is the
|
||||
/// closest per-process "swapped" figure that exists — Activity Monitor's
|
||||
/// "Compressed Memory" and `top`'s CMPRS show the same value.
|
||||
///
|
||||
/// Needs only a task *name* port, which the kernel hands out for processes of
|
||||
/// the same user; returns `nil` for other users' and system processes.
|
||||
func fetchCompressedMemory(pid: pid_t) -> Int64? {
|
||||
var port: mach_port_name_t = 0
|
||||
guard task_name_for_pid(mach_task_self_, pid, &port) == KERN_SUCCESS else { return nil }
|
||||
defer { mach_port_deallocate(mach_task_self_, port) }
|
||||
|
||||
var info = task_vm_info_data_t()
|
||||
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.stride / MemoryLayout<natural_t>.stride)
|
||||
let result = withUnsafeMutablePointer(to: &info) {
|
||||
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
||||
task_info(port, task_flavor_t(TASK_VM_INFO), $0, &count)
|
||||
}
|
||||
}
|
||||
return result == KERN_SUCCESS ? Int64(info.compressed) : nil
|
||||
}
|
||||
|
||||
/// Counts open file descriptors for a process (files, sockets, pipes, …).
|
||||
func fetchOpenFileCount(pid: pid_t) -> Int? {
|
||||
let bufferSize = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0)
|
||||
guard bufferSize > 0 else { return nil }
|
||||
return Int(bufferSize) / MemoryLayout<proc_fdinfo>.stride
|
||||
}
|
||||
|
||||
@@ -6,13 +6,16 @@ final class SystemMonitor: ObservableObject {
|
||||
|
||||
private let backgroundQueue = DispatchQueue(label: "com.topmanager.monitor", qos: .userInitiated)
|
||||
|
||||
// Published properties (must be updated on main thread)
|
||||
// Published properties (must be updated on main thread). They only follow
|
||||
// `live` while a TopManager window is on screen — see `setUIVisible`.
|
||||
@MainActor @Published var cpuInfo: CPUInfo?
|
||||
@MainActor @Published var memoryInfo: MemoryInfo?
|
||||
@MainActor @Published var processes: [ProcessItem] = []
|
||||
@MainActor @Published var diskInfo: DiskInfo?
|
||||
@MainActor @Published var networkInfo: NetworkInfo?
|
||||
@MainActor @Published var gpuInfo: GPUInfo?
|
||||
@MainActor @Published var powerInfo: PowerInfo?
|
||||
@MainActor @Published var thermalInfo: ThermalInfo?
|
||||
@MainActor @Published var lastError: String?
|
||||
|
||||
// History for charts
|
||||
@@ -21,6 +24,25 @@ final class SystemMonitor: ObservableObject {
|
||||
@MainActor @Published var memoryHistory: [MemoryHistoryPoint] = []
|
||||
@MainActor @Published var networkHistory: [NetworkHistoryPoint] = []
|
||||
|
||||
/// Always-current readings and chart histories. Recording, alerts and the
|
||||
/// menu-bar title read from here, so they keep running while views are paused.
|
||||
private struct LiveState {
|
||||
var cpuInfo: CPUInfo?
|
||||
var memoryInfo: MemoryInfo?
|
||||
var processes: [ProcessItem] = []
|
||||
var diskInfo: DiskInfo?
|
||||
var networkInfo: NetworkInfo?
|
||||
var gpuInfo: GPUInfo?
|
||||
var powerInfo: PowerInfo?
|
||||
var thermalInfo: ThermalInfo?
|
||||
var cpuHistory: [CPUHistoryPoint] = []
|
||||
var coreHistories: [Int: [CoreHistoryPoint]] = [:]
|
||||
var memoryHistory: [MemoryHistoryPoint] = []
|
||||
var networkHistory: [NetworkHistoryPoint] = []
|
||||
}
|
||||
@MainActor private var live = LiveState()
|
||||
@MainActor private var isUIVisible = true
|
||||
|
||||
// Sub-monitors
|
||||
private let cpuMonitor = CPUMonitor()
|
||||
private let memoryMonitor = MemoryMonitor()
|
||||
@@ -28,6 +50,8 @@ final class SystemMonitor: ObservableObject {
|
||||
private let diskMonitor = DiskMonitor()
|
||||
private let networkMonitor = NetworkMonitor()
|
||||
private let gpuMonitor = GPUMonitor()
|
||||
private let powerMonitor = PowerMonitor()
|
||||
private let temperatureMonitor = TemperatureMonitor()
|
||||
|
||||
// Timer
|
||||
private var timer: Timer?
|
||||
@@ -45,6 +69,8 @@ final class SystemMonitor: ObservableObject {
|
||||
private init() {}
|
||||
|
||||
@MainActor func startMonitoring() {
|
||||
guard timer == nil else { return }
|
||||
|
||||
// Immediate initial fetch
|
||||
backgroundQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -63,24 +89,39 @@ final class SystemMonitor: ObservableObject {
|
||||
let processData = self.processMonitor.fetchProcesses()
|
||||
let diskData = self.diskMonitor.fetchDiskInfo()
|
||||
let gpuData = self.gpuMonitor.fetchGPUInfo()
|
||||
let powerData = self.powerMonitor.fetchPowerInfo()
|
||||
let thermalData = self.temperatureMonitor.fetchThermalInfo()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.updateUI(cpu: cpuData, memory: memData, network: netData,
|
||||
processes: processData, disk: diskData, gpu: gpuData)
|
||||
processes: processData, disk: diskData, gpu: gpuData, power: powerData,
|
||||
thermal: thermalData)
|
||||
}
|
||||
}
|
||||
|
||||
// Start periodic refresh (3 second interval)
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
||||
// Start periodic refresh at the user-configured cadence
|
||||
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?.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() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
MetricsStore.shared.flush()
|
||||
}
|
||||
|
||||
private func refreshAllBackground() {
|
||||
@@ -91,6 +132,7 @@ final class SystemMonitor: ObservableObject {
|
||||
let cpuData = cpuMonitor.fetchCPUInfo()
|
||||
let memData = memoryMonitor.fetchMemoryInfo()
|
||||
let netData = networkMonitor.fetchNetworkInfo()
|
||||
let thermalData = temperatureMonitor.fetchThermalInfo()
|
||||
|
||||
// Fetch processes: first 3 cycles always, then every other cycle
|
||||
var processData: [ProcessItem]? = nil
|
||||
@@ -98,84 +140,134 @@ final class SystemMonitor: ObservableObject {
|
||||
processData = processMonitor.fetchProcesses()
|
||||
}
|
||||
|
||||
// Fetch disk and GPU every 3 cycles
|
||||
// Fetch disk, GPU and power every 3 cycles
|
||||
var diskData: DiskInfo? = nil
|
||||
var gpuData: GPUInfo? = nil
|
||||
var powerData: PowerInfo? = nil
|
||||
if currentCount % 3 == 0 {
|
||||
diskData = diskMonitor.fetchDiskInfo()
|
||||
gpuData = gpuMonitor.fetchGPUInfo()
|
||||
powerData = powerMonitor.fetchPowerInfo()
|
||||
}
|
||||
|
||||
// Update UI on main thread
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.updateUI(cpu: cpuData, memory: memData, network: netData,
|
||||
processes: processData, disk: diskData, gpu: gpuData)
|
||||
processes: processData, disk: diskData, gpu: gpuData, power: powerData,
|
||||
thermal: thermalData)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private func updateUI(cpu: CPUInfo?, memory: MemoryInfo?, network: NetworkInfo?,
|
||||
processes: [ProcessItem]?, disk: DiskInfo?, gpu: GPUInfo?) {
|
||||
processes: [ProcessItem]?, disk: DiskInfo?, gpu: GPUInfo?,
|
||||
power: PowerInfo?, thermal: ThermalInfo?) {
|
||||
if let info = cpu {
|
||||
cpuInfo = info
|
||||
let historyPoint = CPUHistoryPoint(
|
||||
live.cpuInfo = info
|
||||
live.cpuHistory.append(CPUHistoryPoint(
|
||||
timestamp: info.timestamp,
|
||||
usage: info.globalUsage,
|
||||
userUsage: info.userUsage,
|
||||
systemUsage: info.systemUsage
|
||||
)
|
||||
cpuHistory.append(historyPoint)
|
||||
if cpuHistory.count > historyLimit {
|
||||
cpuHistory.removeFirst()
|
||||
))
|
||||
if live.cpuHistory.count > historyLimit {
|
||||
live.cpuHistory.removeFirst()
|
||||
}
|
||||
|
||||
for core in info.coreUsages {
|
||||
let corePoint = CoreHistoryPoint(timestamp: info.timestamp, usage: core.usage)
|
||||
if coreHistories[core.id] == nil {
|
||||
coreHistories[core.id] = []
|
||||
}
|
||||
coreHistories[core.id]?.append(corePoint)
|
||||
if coreHistories[core.id]?.count ?? 0 > historyLimit {
|
||||
coreHistories[core.id]?.removeFirst()
|
||||
live.coreHistories[core.id, default: []].append(
|
||||
CoreHistoryPoint(timestamp: info.timestamp, usage: core.usage))
|
||||
if live.coreHistories[core.id, default: []].count > historyLimit {
|
||||
live.coreHistories[core.id]?.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let info = memory {
|
||||
memoryInfo = info
|
||||
let historyPoint = MemoryHistoryPoint(
|
||||
live.memoryInfo = info
|
||||
live.memoryHistory.append(MemoryHistoryPoint(
|
||||
timestamp: info.timestamp,
|
||||
usedMemory: info.usedMemory,
|
||||
totalMemory: info.totalMemory
|
||||
)
|
||||
memoryHistory.append(historyPoint)
|
||||
if memoryHistory.count > historyLimit {
|
||||
memoryHistory.removeFirst()
|
||||
))
|
||||
if live.memoryHistory.count > historyLimit {
|
||||
live.memoryHistory.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
if let info = network {
|
||||
networkInfo = info
|
||||
let historyPoint = NetworkHistoryPoint(
|
||||
live.networkInfo = info
|
||||
live.networkHistory.append(NetworkHistoryPoint(
|
||||
timestamp: info.timestamp,
|
||||
downloadRate: info.totalDownloadRate,
|
||||
uploadRate: info.totalUploadRate
|
||||
))
|
||||
if live.networkHistory.count > historyLimit {
|
||||
live.networkHistory.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
if let procs = processes { live.processes = procs }
|
||||
if let info = disk { live.diskInfo = info }
|
||||
if let info = gpu { live.gpuInfo = info }
|
||||
if let info = power { live.powerInfo = info }
|
||||
if let info = thermal { live.thermalInfo = info }
|
||||
|
||||
if isUIVisible { publishLive() }
|
||||
MenuBarStatus.shared.update(cpu: live.cpuInfo, memory: live.memoryInfo, network: live.networkInfo)
|
||||
|
||||
// Persist a system-wide sample so history survives restarts and long ranges.
|
||||
if let c = live.cpuInfo, let m = live.memoryInfo {
|
||||
MetricsStore.shared.record(MetricsSample(
|
||||
t: c.timestamp,
|
||||
cpu: c.globalUsage,
|
||||
memUsed: m.usedMemory,
|
||||
memTotal: m.totalMemory,
|
||||
netDown: live.networkInfo?.totalDownloadRate ?? 0,
|
||||
netUp: live.networkInfo?.totalUploadRate ?? 0
|
||||
))
|
||||
}
|
||||
|
||||
// Proactive health + alert evaluation (runs every cycle).
|
||||
AlertCenter.shared.evaluate(
|
||||
cpuUsage: live.cpuInfo?.globalUsage ?? 0,
|
||||
memory: live.memoryInfo,
|
||||
disk: live.diskInfo,
|
||||
thermal: currentThermalLevel(),
|
||||
topProcess: live.processes.max { $0.cpuUsage < $1.cpuUsage },
|
||||
power: live.powerInfo
|
||||
)
|
||||
networkHistory.append(historyPoint)
|
||||
if networkHistory.count > historyLimit {
|
||||
networkHistory.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
if let procs = processes {
|
||||
self.processes = procs
|
||||
/// Pause (`false`) or resume (`true`) publishing to views. Resuming catches
|
||||
/// the views up at once with everything recorded while they were hidden.
|
||||
@MainActor func setUIVisible(_ visible: Bool) {
|
||||
guard visible != isUIVisible else { return }
|
||||
isUIVisible = visible
|
||||
if visible { publishLive() }
|
||||
}
|
||||
|
||||
if let info = disk {
|
||||
diskInfo = info
|
||||
@MainActor private func publishLive() {
|
||||
cpuInfo = live.cpuInfo
|
||||
memoryInfo = live.memoryInfo
|
||||
processes = live.processes
|
||||
diskInfo = live.diskInfo
|
||||
networkInfo = live.networkInfo
|
||||
gpuInfo = live.gpuInfo
|
||||
powerInfo = live.powerInfo
|
||||
thermalInfo = live.thermalInfo
|
||||
cpuHistory = live.cpuHistory
|
||||
coreHistories = live.coreHistories
|
||||
memoryHistory = live.memoryHistory
|
||||
networkHistory = live.networkHistory
|
||||
}
|
||||
|
||||
if let info = gpu {
|
||||
gpuInfo = info
|
||||
private func currentThermalLevel() -> ThermalLevel {
|
||||
switch ProcessInfo.processInfo.thermalState {
|
||||
case .nominal: return .nominal
|
||||
case .fair: return .fair
|
||||
case .serious: return .serious
|
||||
case .critical: return .critical
|
||||
@unknown default: return .nominal
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
71
TopManager/Services/TemperatureMonitor.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
import IOKit
|
||||
|
||||
// IOHIDEventSystemClient is the interface macOS itself uses for its thermal
|
||||
// sensors. It is not in the public SDK headers, but the symbols are exported by
|
||||
// IOKit and need neither root nor an entitlement (the app is not sandboxed).
|
||||
@_silgen_name("IOHIDEventSystemClientCreate")
|
||||
private func IOHIDEventSystemClientCreate(_ allocator: CFAllocator?) -> Unmanaged<AnyObject>?
|
||||
|
||||
@_silgen_name("IOHIDEventSystemClientSetMatching")
|
||||
private func IOHIDEventSystemClientSetMatching(_ client: AnyObject, _ matching: CFDictionary) -> Int32
|
||||
|
||||
@_silgen_name("IOHIDEventSystemClientCopyServices")
|
||||
private func IOHIDEventSystemClientCopyServices(_ client: AnyObject) -> Unmanaged<CFArray>?
|
||||
|
||||
@_silgen_name("IOHIDServiceClientCopyProperty")
|
||||
private func IOHIDServiceClientCopyProperty(_ service: AnyObject, _ key: CFString) -> Unmanaged<AnyObject>?
|
||||
|
||||
@_silgen_name("IOHIDServiceClientCopyEvent")
|
||||
private func IOHIDServiceClientCopyEvent(_ service: AnyObject, _ type: Int64, _ options: Int32,
|
||||
_ timestamp: Int64) -> Unmanaged<AnyObject>?
|
||||
|
||||
@_silgen_name("IOHIDEventGetFloatValue")
|
||||
private func IOHIDEventGetFloatValue(_ event: AnyObject, _ field: Int32) -> Double
|
||||
|
||||
/// Reads the Apple Silicon temperature sensors. On an Intel Mac there are no
|
||||
/// such sensors (those sit behind the SMC) and it reports nil.
|
||||
final class TemperatureMonitor {
|
||||
private static let temperatureEvent: Int64 = 15 // kIOHIDEventTypeTemperature
|
||||
private static let temperatureField = Int32(temperatureEvent << 16) // IOHIDEventFieldBase(type)
|
||||
|
||||
private let client: AnyObject?
|
||||
private var cachedSensors: [(name: String, service: AnyObject)]?
|
||||
|
||||
init() {
|
||||
client = IOHIDEventSystemClientCreate(kCFAllocatorDefault)?.takeRetainedValue()
|
||||
if let client {
|
||||
// Usage page 0xff00 (Apple vendor), usage 5: temperature sensors.
|
||||
_ = IOHIDEventSystemClientSetMatching(
|
||||
client, ["PrimaryUsagePage": 0xff00, "PrimaryUsage": 5] as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
func readSensors() -> [TemperatureReading] {
|
||||
sensors().compactMap { sensor in
|
||||
guard let event = IOHIDServiceClientCopyEvent(sensor.service, Self.temperatureEvent, 0, 0)?
|
||||
.takeRetainedValue() else { return nil }
|
||||
return TemperatureReading(name: sensor.name,
|
||||
celsius: IOHIDEventGetFloatValue(event, Self.temperatureField))
|
||||
}
|
||||
}
|
||||
|
||||
func fetchThermalInfo() -> ThermalInfo? {
|
||||
ThermalMath.summarize(readSensors())
|
||||
}
|
||||
|
||||
/// The sensor list doesn't change while the system runs, so it is built once.
|
||||
private func sensors() -> [(name: String, service: AnyObject)] {
|
||||
if let cachedSensors { return cachedSensors }
|
||||
guard let client,
|
||||
let services = IOHIDEventSystemClientCopyServices(client)?.takeRetainedValue() as? [AnyObject]
|
||||
else { return [] }
|
||||
let found = services.map { service in
|
||||
let name = IOHIDServiceClientCopyProperty(service, "Product" as CFString)?
|
||||
.takeRetainedValue() as? String
|
||||
return (name: name ?? "", service: service)
|
||||
}
|
||||
cachedSensors = found
|
||||
return found
|
||||
}
|
||||
}
|
||||
52
TopManager/Services/WindowVisibility.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
import AppKit
|
||||
|
||||
/// Reports whether any TopManager window is actually on screen: the main window,
|
||||
/// the menu-bar popover or Settings — as opposed to closed, minimized, fully
|
||||
/// covered, or on another Space.
|
||||
///
|
||||
/// Why this exists: on macOS 26 several framework paths leak a little on every
|
||||
/// view update (SwiftUI `Table` cell updates, AppKit text-cell KVO dependencies,
|
||||
/// an Observation registrar created inside DesignLibrary). SwiftUI keeps updating
|
||||
/// a minimized or covered window, so leaving the main window open all day grew
|
||||
/// the app by several hundred MB. `SystemMonitor` stops publishing while this
|
||||
/// reports `false`, which confines that growth to the time someone is looking.
|
||||
@MainActor
|
||||
final class WindowVisibility {
|
||||
static let shared = WindowVisibility()
|
||||
|
||||
private(set) var isAnyWindowVisible = true
|
||||
private var onChange: ((Bool) -> Void)?
|
||||
private var tokens: [NSObjectProtocol] = []
|
||||
|
||||
private init() {}
|
||||
|
||||
func start(onChange: @escaping (Bool) -> Void) {
|
||||
guard tokens.isEmpty else { return }
|
||||
self.onChange = onChange
|
||||
|
||||
let names = [NSWindow.didChangeOcclusionStateNotification, NSWindow.willCloseNotification]
|
||||
for name in names {
|
||||
tokens.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||
// willClose arrives while the window is still on screen, so
|
||||
// re-check on a later turn, once it is actually gone.
|
||||
Task { @MainActor in self?.recompute() }
|
||||
})
|
||||
}
|
||||
recompute()
|
||||
}
|
||||
|
||||
private func recompute() {
|
||||
let visible = NSApp.windows.contains(where: Self.countsAsVisible)
|
||||
guard visible != isAnyWindowVisible else { return }
|
||||
isAnyWindowVisible = visible
|
||||
onChange?(visible)
|
||||
}
|
||||
|
||||
/// The status item's host window is permanently on screen; counting it would
|
||||
/// keep the UI publishing forever.
|
||||
private static func countsAsVisible(_ window: NSWindow) -> Bool {
|
||||
window.isVisible
|
||||
&& window.occlusionState.contains(.visible)
|
||||
&& !String(describing: type(of: window)).contains("StatusBarWindow")
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,73 @@
|
||||
import SwiftUI
|
||||
import 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
|
||||
struct TopManagerApp: App {
|
||||
@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 {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(monitor)
|
||||
.onAppear {
|
||||
monitor.startMonitoring()
|
||||
}
|
||||
.environmentObject(AlertCenter.shared)
|
||||
.environmentObject(settings)
|
||||
}
|
||||
.windowStyle(.automatic)
|
||||
.defaultSize(width: 1000, height: 700)
|
||||
.onChange(of: scenePhase) { phase in
|
||||
if phase == .background {
|
||||
MetricsStore.shared.flush()
|
||||
}
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) { }
|
||||
}
|
||||
|
||||
// Menu Bar Extra showing CPU %
|
||||
Settings {
|
||||
SettingsView()
|
||||
.environmentObject(settings)
|
||||
}
|
||||
|
||||
// Menu Bar Extra with configurable metric
|
||||
MenuBarExtra {
|
||||
MenuBarView()
|
||||
.environmentObject(monitor)
|
||||
.environmentObject(AlertCenter.shared)
|
||||
.environmentObject(settings)
|
||||
} label: {
|
||||
if let cpu = monitor.cpuInfo {
|
||||
Text(String(format: "%.0f%%", cpu.globalUsage))
|
||||
.monospacedDigit()
|
||||
} else {
|
||||
Image(systemName: "cpu")
|
||||
}
|
||||
MenuBarLabel(status: MenuBarStatus.shared, settings: settings, alerts: AlertCenter.shared)
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
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 {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
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
|
||||
|
||||
/// 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 {
|
||||
@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 {
|
||||
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 {
|
||||
HStack {
|
||||
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()
|
||||
|
||||
Button("Open TopManager") {
|
||||
@@ -73,7 +160,16 @@ struct MenuBarView: View {
|
||||
.keyboardShortcut("q")
|
||||
}
|
||||
.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 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 {
|
||||
@State private var selectedTab = 0
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
TabView {
|
||||
CPUTabView()
|
||||
.tabItem {
|
||||
Label("CPU", systemImage: "cpu")
|
||||
}
|
||||
.tag(0)
|
||||
|
||||
MemoryTabView()
|
||||
.tabItem {
|
||||
Label("Memory", systemImage: "memorychip")
|
||||
}
|
||||
.tag(1)
|
||||
|
||||
NetworkTabView()
|
||||
.tabItem {
|
||||
Label("Network", systemImage: "network")
|
||||
}
|
||||
.tag(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +29,7 @@ struct PerformanceView: View {
|
||||
|
||||
struct CPUTabView: View {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
@State private var range: HistoryRange = .live
|
||||
|
||||
private let coreColumns = [
|
||||
GridItem(.flexible()),
|
||||
@@ -48,6 +47,7 @@ struct CPUTabView: View {
|
||||
HStack {
|
||||
Text("Total CPU Usage")
|
||||
.font(.headline)
|
||||
HistoryRangePicker(range: $range)
|
||||
Spacer()
|
||||
if let cpu = monitor.cpuInfo {
|
||||
Text(String(format: "%.1f%%", cpu.globalUsage))
|
||||
@@ -57,8 +57,13 @@ struct CPUTabView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if range == .live {
|
||||
CPULineChart(history: monitor.cpuHistory)
|
||||
.frame(height: 120)
|
||||
} else {
|
||||
MetricTrendChart(samples: MetricsStore.shared.downsampled(for: range), metric: .cpu)
|
||||
.frame(height: 120)
|
||||
}
|
||||
|
||||
// Legend
|
||||
HStack(spacing: 20) {
|
||||
@@ -201,6 +206,7 @@ struct CoreLineChartView: View {
|
||||
|
||||
struct MemoryTabView: View {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
@State private var range: HistoryRange = .live
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -230,6 +236,7 @@ struct MemoryTabView: View {
|
||||
HStack {
|
||||
Text("Memory Usage Over Time")
|
||||
.font(.headline)
|
||||
HistoryRangePicker(range: $range)
|
||||
Spacer()
|
||||
if let mem = monitor.memoryInfo {
|
||||
Text(String(format: "%.1f%%", mem.usagePercentage))
|
||||
@@ -239,8 +246,13 @@ struct MemoryTabView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if range == .live {
|
||||
MemoryLineChart(history: monitor.memoryHistory)
|
||||
.frame(height: 150)
|
||||
} else {
|
||||
MetricTrendChart(samples: MetricsStore.shared.downsampled(for: range), metric: .memory)
|
||||
.frame(height: 150)
|
||||
}
|
||||
|
||||
if let mem = monitor.memoryInfo {
|
||||
HStack {
|
||||
@@ -394,6 +406,7 @@ struct MemoryStatItem: View {
|
||||
|
||||
struct NetworkTabView: View {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
@State private var range: HistoryRange = .live
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -405,6 +418,7 @@ struct NetworkTabView: View {
|
||||
Label("Download", systemImage: "arrow.down.circle")
|
||||
.font(.headline)
|
||||
.foregroundColor(.blue)
|
||||
HistoryRangePicker(range: $range)
|
||||
Spacer()
|
||||
if let net = monitor.networkInfo {
|
||||
Text(formatBytesPerSecond(net.totalDownloadRate))
|
||||
@@ -414,8 +428,13 @@ struct NetworkTabView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if range == .live {
|
||||
DownloadLineChart(history: monitor.networkHistory)
|
||||
.frame(height: 150)
|
||||
} else {
|
||||
MetricTrendChart(samples: MetricsStore.shared.downsampled(for: range), metric: .download)
|
||||
.frame(height: 150)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
@@ -436,8 +455,13 @@ struct NetworkTabView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if range == .live {
|
||||
UploadLineChart(history: monitor.networkHistory)
|
||||
.frame(height: 150)
|
||||
} else {
|
||||
MetricTrendChart(samples: MetricsStore.shared.downsampled(for: range), metric: .upload)
|
||||
.frame(height: 150)
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// System Health score + diagnosis
|
||||
HealthCardView()
|
||||
|
||||
// System Status and GPU side by side
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
// System Status (left column)
|
||||
@@ -34,6 +37,18 @@ struct PowerStorageView: View {
|
||||
ThermalStateView(state: monitor.thermalState)
|
||||
}
|
||||
|
||||
if let thermal = monitor.thermalInfo {
|
||||
HStack {
|
||||
Text("CPU Temperature:")
|
||||
Spacer()
|
||||
Text(String(format: "%.0f °C", thermal.cpuMax))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(cpuTemperatureColor(thermal.cpuMax))
|
||||
}
|
||||
.help(String(format: "Hottest of %d CPU die sensors · average %.0f °C",
|
||||
thermal.cpuSensorCount, thermal.cpuAverage))
|
||||
}
|
||||
|
||||
if let cpu = monitor.cpuInfo {
|
||||
HStack {
|
||||
Text("CPU Usage:")
|
||||
@@ -141,6 +156,11 @@ struct PowerStorageView: View {
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// Battery & Power
|
||||
if let power = monitor.powerInfo {
|
||||
BatteryStatusView(power: power)
|
||||
}
|
||||
|
||||
// Storage
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@@ -182,6 +202,118 @@ struct PowerStorageView: View {
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func cpuTemperatureColor(_ celsius: Double) -> Color {
|
||||
switch ThermalMath.level(cpuCelsius: celsius) {
|
||||
case .nominal: return .primary
|
||||
case .fair: return .yellow
|
||||
case .serious: return .orange
|
||||
case .critical: return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BatteryStatusView: View {
|
||||
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 {
|
||||
|
||||
@@ -1,34 +1,68 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct ProcessDetailView: View {
|
||||
let process: ProcessItem
|
||||
/// Deep-dive inspector for a single process, presented as a sheet. Shows live
|
||||
/// 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 {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Header
|
||||
header
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
liveStats
|
||||
memoryExplanation
|
||||
pathSection
|
||||
detailsSection
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 460, height: 560)
|
||||
.task(id: initialProcess.pid) {
|
||||
let pid = initialProcess.pid
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
(args: fetchProcessArguments(pid: pid), files: fetchOpenFileCount(pid: pid))
|
||||
}.value
|
||||
arguments = result.args
|
||||
openFiles = result.files
|
||||
loadedDetails = true
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
if let icon = process.icon {
|
||||
Image(nsImage: icon)
|
||||
.resizable()
|
||||
.frame(width: 48, height: 48)
|
||||
Image(nsImage: icon).resizable().frame(width: 44, height: 44)
|
||||
} else {
|
||||
Image(systemName: "app.dashed")
|
||||
.font(.system(size: 48))
|
||||
Image(systemName: "app.dashed").font(.system(size: 40)).foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(process.name)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text("PID: \(process.pid)")
|
||||
.foregroundColor(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(process.name).font(.title2).fontWeight(.semibold).lineLimit(1)
|
||||
Text("PID \(process.pid) • \(process.user)").foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing) {
|
||||
HStack {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: process.state.symbol)
|
||||
Text(process.state.rawValue)
|
||||
}
|
||||
@@ -36,28 +70,91 @@ struct ProcessDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Details grid
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible())
|
||||
], alignment: .leading, spacing: 12) {
|
||||
DetailRow(label: "CPU Usage", value: String(format: "%.1f%%", process.cpuUsage))
|
||||
private var liveStats: some View {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())],
|
||||
alignment: .leading, spacing: 12) {
|
||||
DetailRow(label: "CPU (core)", value: String(format: "%.1f%%", process.cpuUsage))
|
||||
DetailRow(label: "CPU (total)", value: String(format: "%.2f%%", process.cpuUsageTotal))
|
||||
DetailRow(label: "Energy", value: String(format: "%.1f", process.energyImpact))
|
||||
DetailRow(label: "Memory", value: formatBytes(process.memoryUsage))
|
||||
DetailRow(label: "RAM", value: formatBytes(process.residentMemory))
|
||||
DetailRow(label: "Compressed", value: process.compressedMemory > 0 ? formatBytes(process.compressedMemory) : "—")
|
||||
DetailRow(label: "Threads", value: "\(process.threadCount)")
|
||||
DetailRow(label: "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)")
|
||||
|
||||
if let startTime = process.startTime {
|
||||
DetailRow(label: "Started", value: formatDate(startTime))
|
||||
if let start = process.startTime {
|
||||
DetailRow(label: "Started", value: formatDate(start))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain-language guide to the three memory figures; without it, RAM being
|
||||
/// higher than Memory (normal for any SwiftUI app) looks like a bug.
|
||||
private var memoryExplanation: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("About memory").font(.caption).foregroundColor(.secondary)
|
||||
Group {
|
||||
if process.memoryUsage == 0 && process.residentMemory == 0 {
|
||||
Text("Memory figures are unavailable for processes owned by another user or the system.")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text("**Memory** — what this process is responsible for: its own modified pages, whether in RAM, compressed or swapped out. This is its real memory cost.")
|
||||
Text("**RAM** — pages physically in RAM right now, including system framework code shared by every app. It can be higher than Memory, because shared pages take up RAM only once.")
|
||||
Text("**Compressed** — the part of Memory that macOS has compressed to save RAM, including pages swapped out to disk, shown at their original size.")
|
||||
}
|
||||
.font(.caption)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var pathSection: some View {
|
||||
if let path = process.executablePath {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Path").font(.caption).foregroundColor(.secondary)
|
||||
Text(path)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(3)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var detailsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Launch arguments").font(.caption).foregroundColor(.secondary)
|
||||
if !loadedDetails {
|
||||
Text("Loading…").font(.caption).foregroundColor(.secondary)
|
||||
} else if arguments.isEmpty {
|
||||
Text("Unavailable (process may be owned by another user)")
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
} else {
|
||||
Text(arguments.joined(separator: " "))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
if let path = process.executablePath {
|
||||
Button {
|
||||
NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: "")
|
||||
} label: { Label("Reveal in Finder", systemImage: "folder") }
|
||||
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(path, forType: .string)
|
||||
} label: { Label("Copy Path", systemImage: "doc.on.doc") }
|
||||
}
|
||||
Spacer()
|
||||
Button("Close") { dismiss() }.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 300)
|
||||
}
|
||||
|
||||
private func stateColor(_ state: ProcessState) -> Color {
|
||||
@@ -95,17 +192,14 @@ struct DetailRow: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ProcessDetailView(process: ProcessItem(
|
||||
pid: 1234,
|
||||
name: "Safari",
|
||||
user: "ariel",
|
||||
cpuUsage: 12.5,
|
||||
cpuUsageTotal: 1.56,
|
||||
memoryUsage: 512 * 1024 * 1024,
|
||||
threadCount: 42,
|
||||
state: .running,
|
||||
icon: nil,
|
||||
parentPid: 1,
|
||||
startTime: Date()
|
||||
ProcessInspectorSheet(initialProcess: ProcessItem(
|
||||
pid: 1234, name: "Safari", user: "ariel",
|
||||
cpuUsage: 12.5, cpuUsageTotal: 1.56,
|
||||
memoryUsage: 512 * 1024 * 1024, threadCount: 42,
|
||||
state: .running, icon: nil, parentPid: 1, startTime: Date(),
|
||||
diskReadRate: 2048, diskWriteRate: 1024,
|
||||
diskReadBytes: 900_000, diskWriteBytes: 400_000,
|
||||
energyImpact: 14.2, executablePath: "/Applications/Safari.app/Contents/MacOS/Safari"
|
||||
))
|
||||
.environmentObject(SystemMonitor.shared)
|
||||
}
|
||||
|
||||
477
TopManager/Views/Processes/ProcessTableView.swift
Normal file
@@ -0,0 +1,477 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
// MARK: - Presentation (pure, unit-tested)
|
||||
|
||||
/// Colour role of a cell's text or leading symbol.
|
||||
enum ProcessCellTone: Equatable {
|
||||
case primary, secondary, green, yellow, orange, red
|
||||
|
||||
var color: NSColor {
|
||||
switch self {
|
||||
case .primary: return .labelColor
|
||||
case .secondary: return .secondaryLabelColor
|
||||
case .green: return .systemGreen
|
||||
case .yellow: return .systemYellow
|
||||
case .orange: return .systemOrange
|
||||
case .red: return .systemRed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything one table cell displays. Cells only touch AppKit when this changes.
|
||||
struct ProcessCellContent: Equatable {
|
||||
enum Leading: Equatable {
|
||||
case none
|
||||
case icon(NSImage)
|
||||
case symbol(String, ProcessCellTone)
|
||||
|
||||
static func == (lhs: Leading, rhs: Leading) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.none, .none): return true
|
||||
case let (.icon(a), .icon(b)): return a === b
|
||||
case let (.symbol(a, aTone), .symbol(b, bTone)): return a == b && aTone == bTone
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var text: String
|
||||
var tone: ProcessCellTone = .primary
|
||||
var leading: Leading = .none
|
||||
var monospacedDigits = false
|
||||
}
|
||||
|
||||
/// The Processes table's columns, in display order.
|
||||
enum ProcessColumn: String, CaseIterable {
|
||||
case name, pid, cpu, cpuTotal, energy, memory, resident, compressed, disk, threads, user, state
|
||||
|
||||
var sortColumn: ProcessSortColumn {
|
||||
guard let column = ProcessSortColumn(rawValue: rawValue) else {
|
||||
preconditionFailure("ProcessColumn.\(rawValue) has no matching ProcessSortColumn")
|
||||
}
|
||||
return column
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .name: return "Name"
|
||||
case .pid: return "PID"
|
||||
case .cpu: return "CPU/Core"
|
||||
case .cpuTotal: return "CPU/Total"
|
||||
case .energy: return "Energy"
|
||||
case .memory: return "Memory"
|
||||
case .resident: return "RAM"
|
||||
case .compressed: return "Compressed"
|
||||
case .disk: return "Disk I/O"
|
||||
case .threads: return "Threads"
|
||||
case .user: return "User"
|
||||
case .state: return "State"
|
||||
}
|
||||
}
|
||||
|
||||
var width: CGFloat {
|
||||
switch self {
|
||||
case .name: return 220
|
||||
case .pid, .energy, .threads: return 60
|
||||
case .cpu, .cpuTotal: return 70
|
||||
case .memory, .resident, .user: return 80
|
||||
case .compressed: return 85
|
||||
case .disk, .state: return 90
|
||||
}
|
||||
}
|
||||
|
||||
/// Text columns sort A→Z on first click; figures sort largest first.
|
||||
var sortsAscendingFirst: Bool {
|
||||
switch self {
|
||||
case .name, .pid, .user, .state: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort order after a click on this column's header: the current column
|
||||
/// flips direction, any other column starts in its natural direction.
|
||||
func sortAfterClick(current: ProcessSortColumn, ascending: Bool) -> (column: ProcessSortColumn, ascending: Bool) {
|
||||
sortColumn == current ? (current, !ascending) : (sortColumn, sortsAscendingFirst)
|
||||
}
|
||||
|
||||
func content(for process: ProcessItem) -> ProcessCellContent {
|
||||
switch self {
|
||||
case .name:
|
||||
return ProcessCellContent(text: process.name,
|
||||
leading: process.icon.map { .icon($0) } ?? .symbol("app.dashed", .secondary))
|
||||
case .pid:
|
||||
return ProcessCellContent(text: String(process.pid), monospacedDigits: true)
|
||||
case .cpu:
|
||||
return ProcessCellContent(text: String(format: "%.1f%%", process.cpuUsage),
|
||||
tone: Self.tone(process.cpuUsage, yellow: 20, orange: 50, red: 80),
|
||||
monospacedDigits: true)
|
||||
case .cpuTotal:
|
||||
return ProcessCellContent(text: String(format: "%.2f%%", process.cpuUsageTotal),
|
||||
tone: Self.tone(process.cpuUsageTotal, yellow: 2, orange: 5, red: 10),
|
||||
monospacedDigits: true)
|
||||
case .energy:
|
||||
return ProcessCellContent(text: String(format: "%.1f", process.energyImpact),
|
||||
tone: Self.tone(process.energyImpact, yellow: 8, orange: 20, red: 50),
|
||||
monospacedDigits: true)
|
||||
case .memory:
|
||||
return ProcessCellContent(text: formatBytes(process.memoryUsage), monospacedDigits: true)
|
||||
case .resident:
|
||||
return ProcessCellContent(text: formatBytes(process.residentMemory), monospacedDigits: true)
|
||||
case .compressed:
|
||||
return process.compressedMemory > 0
|
||||
? ProcessCellContent(text: formatBytes(process.compressedMemory), monospacedDigits: true)
|
||||
: ProcessCellContent(text: "—", tone: .secondary)
|
||||
case .disk:
|
||||
return process.diskTotalRate > 0
|
||||
? ProcessCellContent(text: formatBytesPerSecond(process.diskTotalRate), monospacedDigits: true)
|
||||
: ProcessCellContent(text: "—", tone: .secondary)
|
||||
case .threads:
|
||||
return ProcessCellContent(text: String(process.threadCount), monospacedDigits: true)
|
||||
case .user:
|
||||
return ProcessCellContent(text: process.user)
|
||||
case .state:
|
||||
return ProcessCellContent(text: process.state.rawValue,
|
||||
leading: .symbol(process.state.symbol, Self.tone(for: process.state)))
|
||||
}
|
||||
}
|
||||
|
||||
static func tone(_ value: Double, yellow: Double, orange: Double, red: Double) -> ProcessCellTone {
|
||||
if value > red { return .red }
|
||||
if value > orange { return .orange }
|
||||
if value > yellow { return .yellow }
|
||||
return .primary
|
||||
}
|
||||
|
||||
static func tone(for state: ProcessState) -> ProcessCellTone {
|
||||
switch state {
|
||||
case .running: return .green
|
||||
case .stopped: return .orange
|
||||
case .zombie: return .red
|
||||
case .sleeping, .unknown: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Table
|
||||
|
||||
/// Callbacks from the table back into `ProcessView`.
|
||||
struct ProcessTableActions {
|
||||
var open: (ProcessItem) -> Void
|
||||
var terminate: (ProcessItem) -> Void
|
||||
var forceKill: (ProcessItem) -> Void
|
||||
var suspend: (ProcessItem) -> Void
|
||||
var resume: (ProcessItem) -> Void
|
||||
var deleteKey: () -> Void
|
||||
}
|
||||
|
||||
/// The process list as a plain AppKit `NSTableView`.
|
||||
///
|
||||
/// Replaces SwiftUI's `Table`, which on macOS 26 retained AutoLayout and
|
||||
/// view-graph objects on every data update — tens of MB an hour while the window
|
||||
/// was visible — on top of an NSHostingView per cell. Measured side by side
|
||||
/// (6 min, window visible, scrolling): heap growth fell from ~600 KB/min to
|
||||
/// ~35 KB/min and the starting footprint from 67 MB to 49 MB.
|
||||
/// Cells here are reused AppKit views, and an update rewrites only the fields
|
||||
/// that changed in rows that are on screen; the table reloads only once, on
|
||||
/// its first update.
|
||||
struct ProcessTableView: NSViewRepresentable {
|
||||
let processes: [ProcessItem]
|
||||
@Binding var selection: Set<ProcessItem.ID>
|
||||
let sortColumn: ProcessSortColumn
|
||||
let sortAscending: Bool
|
||||
let onSort: (ProcessSortColumn, Bool) -> Void
|
||||
let actions: ProcessTableActions
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
let coordinator = context.coordinator
|
||||
let table = ProcessNSTableView()
|
||||
table.style = .inset
|
||||
table.usesAlternatingRowBackgroundColors = true
|
||||
table.allowsMultipleSelection = true
|
||||
table.allowsColumnReordering = true
|
||||
table.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle
|
||||
table.rowHeight = 24
|
||||
|
||||
for column in ProcessColumn.allCases {
|
||||
let tableColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(column.rawValue))
|
||||
tableColumn.title = column.title
|
||||
tableColumn.width = column.width
|
||||
tableColumn.minWidth = column == .name ? 170 : 40
|
||||
tableColumn.resizingMask = column == .name ? [.autoresizingMask, .userResizingMask] : .userResizingMask
|
||||
table.addTableColumn(tableColumn)
|
||||
}
|
||||
// Remembers column widths and order between launches.
|
||||
table.autosaveName = "ProcessTable"
|
||||
table.autosaveTableColumns = true
|
||||
|
||||
table.dataSource = coordinator
|
||||
table.delegate = coordinator
|
||||
table.target = coordinator
|
||||
table.doubleAction = #selector(Coordinator.doubleClicked(_:))
|
||||
table.onDelete = { [weak coordinator] in coordinator?.parent.actions.deleteKey() }
|
||||
|
||||
let menu = NSMenu()
|
||||
menu.delegate = coordinator
|
||||
table.menu = menu
|
||||
|
||||
let scrollView = NSScrollView()
|
||||
scrollView.documentView = table
|
||||
scrollView.hasVerticalScroller = true
|
||||
scrollView.hasHorizontalScroller = true
|
||||
scrollView.autohidesScrollers = true
|
||||
|
||||
coordinator.tableView = table
|
||||
return scrollView
|
||||
}
|
||||
|
||||
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
context.coordinator.update()
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate {
|
||||
var parent: ProcessTableView
|
||||
weak var tableView: NSTableView?
|
||||
private var rows: [ProcessItem] = []
|
||||
private var isSyncing = false
|
||||
/// The first update must reload: right after `dataSource` is set, the
|
||||
/// table still has an initial load pending, and `noteNumberOfRowsChanged`
|
||||
/// on top of it doubles the row count (blank rows below the list).
|
||||
private var needsInitialLoad = true
|
||||
private var shownSort: (column: ProcessSortColumn, ascending: Bool)?
|
||||
|
||||
init(parent: ProcessTableView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
// MARK: Updates
|
||||
|
||||
func update() {
|
||||
guard let tableView else { return }
|
||||
isSyncing = true
|
||||
defer { isSyncing = false }
|
||||
|
||||
let newRows = parent.processes
|
||||
if needsInitialLoad {
|
||||
needsInitialLoad = false
|
||||
rows = newRows
|
||||
tableView.reloadData()
|
||||
} else if Self.rowsDiffer(rows, newRows) {
|
||||
let countChanged = newRows.count != rows.count
|
||||
rows = newRows
|
||||
if countChanged { tableView.noteNumberOfRowsChanged() }
|
||||
refreshPreparedCells(in: tableView)
|
||||
}
|
||||
|
||||
let selected = IndexSet(rows.indices.filter { parent.selection.contains(rows[$0].pid) })
|
||||
if selected != tableView.selectedRowIndexes {
|
||||
tableView.selectRowIndexes(selected, byExtendingSelection: false)
|
||||
}
|
||||
|
||||
if shownSort?.column != parent.sortColumn || shownSort?.ascending != parent.sortAscending {
|
||||
showSortIndicator(in: tableView)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorting is driven by `ProcessView`, not by NSTableView's sort descriptors:
|
||||
/// the built-in header toggling ignored the prototypes' direction, so figure
|
||||
/// columns started smallest-first.
|
||||
private func showSortIndicator(in tableView: NSTableView) {
|
||||
for tableColumn in tableView.tableColumns {
|
||||
tableView.setIndicatorImage(nil, in: tableColumn)
|
||||
}
|
||||
let identifier = NSUserInterfaceItemIdentifier(parent.sortColumn.rawValue)
|
||||
if let tableColumn = tableView.tableColumn(withIdentifier: identifier) {
|
||||
let name = parent.sortAscending ? "NSAscendingSortIndicator" : "NSDescendingSortIndicator"
|
||||
tableView.setIndicatorImage(NSImage(named: name), in: tableColumn)
|
||||
tableView.highlightedTableColumn = tableColumn
|
||||
}
|
||||
shownSort = (parent.sortColumn, parent.sortAscending)
|
||||
}
|
||||
|
||||
/// `ProcessItem ==` compares live figures only, so also catch a PID that
|
||||
/// now belongs to a different process.
|
||||
static func rowsDiffer(_ old: [ProcessItem], _ new: [ProcessItem]) -> Bool {
|
||||
guard old.count == new.count else { return true }
|
||||
for (a, b) in zip(old, new) where a != b || a.name != b.name || a.startTime != b.startTime || a.user != b.user {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Rewrites the cells AppKit currently holds views for (on screen plus its
|
||||
/// small overdraw margin). Rows outside get configured when they scroll in.
|
||||
private func refreshPreparedCells(in tableView: NSTableView) {
|
||||
let range = tableView.rows(in: tableView.preparedContentRect)
|
||||
guard range.length > 0 else { return }
|
||||
for row in range.location..<min(range.location + range.length, rows.count) {
|
||||
for (index, tableColumn) in tableView.tableColumns.enumerated() {
|
||||
guard let column = ProcessColumn(rawValue: tableColumn.identifier.rawValue),
|
||||
let cell = tableView.view(atColumn: index, row: row, makeIfNecessary: false) as? ProcessTableCellView
|
||||
else { continue }
|
||||
cell.apply(column.content(for: rows[row]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Data source & delegate
|
||||
|
||||
func numberOfRows(in tableView: NSTableView) -> Int { rows.count }
|
||||
|
||||
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||
guard let identifier = tableColumn?.identifier,
|
||||
let column = ProcessColumn(rawValue: identifier.rawValue),
|
||||
row < rows.count else { return nil }
|
||||
let cell = tableView.makeView(withIdentifier: identifier, owner: nil) as? ProcessTableCellView
|
||||
?? ProcessTableCellView(identifier: identifier)
|
||||
cell.apply(column.content(for: rows[row]))
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableViewSelectionDidChange(_ notification: Notification) {
|
||||
guard !isSyncing, let tableView else { return }
|
||||
parent.selection = Set(tableView.selectedRowIndexes.compactMap { $0 < rows.count ? rows[$0].pid : nil })
|
||||
}
|
||||
|
||||
func tableView(_ tableView: NSTableView, didClick tableColumn: NSTableColumn) {
|
||||
guard let column = ProcessColumn(rawValue: tableColumn.identifier.rawValue) else { return }
|
||||
let next = column.sortAfterClick(current: parent.sortColumn, ascending: parent.sortAscending)
|
||||
parent.onSort(next.column, next.ascending)
|
||||
}
|
||||
|
||||
@objc func doubleClicked(_ sender: NSTableView) {
|
||||
let row = sender.clickedRow
|
||||
guard row >= 0, row < rows.count else { return }
|
||||
parent.actions.open(rows[row])
|
||||
}
|
||||
|
||||
// MARK: Context menu
|
||||
|
||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||
menu.removeAllItems()
|
||||
guard let tableView else { return }
|
||||
// A right-click sets clickedRow; a menu opened from the keyboard or
|
||||
// VoiceOver has none, so fall back to the selection.
|
||||
let row = tableView.clickedRow >= 0 ? tableView.clickedRow : tableView.selectedRow
|
||||
guard row >= 0, row < rows.count else { return }
|
||||
if !tableView.selectedRowIndexes.contains(row) {
|
||||
tableView.selectRowIndexes([row], byExtendingSelection: false)
|
||||
}
|
||||
// Actions get the clicked process directly rather than reading the
|
||||
// selection back through SwiftUI state, which may not have updated yet.
|
||||
let process = rows[row]
|
||||
let actions = parent.actions
|
||||
addItem("Get Info (⌘I)", to: menu) { actions.open(process) }
|
||||
menu.addItem(.separator())
|
||||
addItem("Terminate (⌫)", to: menu) { actions.terminate(process) }
|
||||
addItem("Force Kill (⌘⌫)", to: menu) { actions.forceKill(process) }
|
||||
menu.addItem(.separator())
|
||||
addItem("Suspend", to: menu) { actions.suspend(process) }
|
||||
addItem("Resume", to: menu) { actions.resume(process) }
|
||||
menu.addItem(.separator())
|
||||
addItem("Copy PID", to: menu) {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(String(process.pid), forType: .string)
|
||||
}
|
||||
}
|
||||
|
||||
private final class MenuAction {
|
||||
let run: () -> Void
|
||||
init(_ run: @escaping () -> Void) { self.run = run }
|
||||
}
|
||||
|
||||
private func addItem(_ title: String, to menu: NSMenu, run: @escaping () -> Void) {
|
||||
let item = NSMenuItem(title: title, action: #selector(performMenuAction(_:)), keyEquivalent: "")
|
||||
item.target = self
|
||||
item.representedObject = MenuAction(run)
|
||||
menu.addItem(item)
|
||||
}
|
||||
|
||||
@objc private func performMenuAction(_ sender: NSMenuItem) {
|
||||
(sender.representedObject as? MenuAction)?.run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the plain Delete / Forward Delete key (⌘⌫ is handled by `KeyboardShortcutHandler`).
|
||||
final class ProcessNSTableView: NSTableView {
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask).subtracting([.function, .numericPad])
|
||||
if (event.keyCode == 51 || event.keyCode == 117) && modifiers.isEmpty {
|
||||
onDelete?()
|
||||
return
|
||||
}
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
/// A reusable cell: optional 16 pt leading image plus a label, laid out by hand
|
||||
/// (no constraints) and updated field by field.
|
||||
final class ProcessTableCellView: NSTableCellView {
|
||||
private static let font = NSFont.systemFont(ofSize: NSFont.systemFontSize)
|
||||
private static let digitFont = NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
|
||||
private static let lineHeight = ceil(NSLayoutManager().defaultLineHeight(for: font))
|
||||
|
||||
private let label = NSTextField(labelWithString: "")
|
||||
private let leadingView = NSImageView()
|
||||
private var content: ProcessCellContent?
|
||||
|
||||
init(identifier: NSUserInterfaceItemIdentifier) {
|
||||
super.init(frame: .zero)
|
||||
self.identifier = identifier
|
||||
label.lineBreakMode = .byTruncatingTail
|
||||
label.font = Self.font
|
||||
leadingView.imageScaling = .scaleProportionallyUpOrDown
|
||||
leadingView.isHidden = true
|
||||
addSubview(leadingView)
|
||||
addSubview(label)
|
||||
textField = label
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
|
||||
|
||||
func apply(_ new: ProcessCellContent) {
|
||||
guard new != content else { return }
|
||||
let old = content
|
||||
content = new
|
||||
|
||||
if new.text != old?.text { label.stringValue = new.text }
|
||||
if new.tone != old?.tone { label.textColor = new.tone.color }
|
||||
if new.monospacedDigits != old?.monospacedDigits {
|
||||
label.font = new.monospacedDigits ? Self.digitFont : Self.font
|
||||
}
|
||||
if new.leading != old?.leading {
|
||||
switch new.leading {
|
||||
case .none:
|
||||
leadingView.image = nil
|
||||
leadingView.isHidden = true
|
||||
case .icon(let image):
|
||||
leadingView.image = image
|
||||
leadingView.contentTintColor = nil
|
||||
leadingView.isHidden = false
|
||||
case .symbol(let name, let tone):
|
||||
leadingView.image = NSImage(systemSymbolName: name, accessibilityDescription: nil)
|
||||
leadingView.contentTintColor = tone.color
|
||||
leadingView.isHidden = false
|
||||
}
|
||||
needsLayout = true
|
||||
}
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
let height = bounds.height
|
||||
var x: CGFloat = 0
|
||||
if !leadingView.isHidden {
|
||||
leadingView.frame = NSRect(x: 0, y: floor((height - 16) / 2), width: 16, height: 16)
|
||||
x = 22
|
||||
}
|
||||
label.frame = NSRect(x: x, y: floor((height - Self.lineHeight) / 2),
|
||||
width: max(0, bounds.width - x), height: Self.lineHeight)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
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 {
|
||||
@@ -10,12 +10,6 @@ struct ProcessView: View {
|
||||
@State private var selectedProcess: Set<ProcessItem.ID> = []
|
||||
@State private var sortColumn: ProcessSortColumn = .cpu
|
||||
@State private var sortAscending: Bool = false
|
||||
@State private var displayedProcesses: [ProcessItem] = []
|
||||
|
||||
// Keep sortOrder for Table binding compatibility
|
||||
@State private var sortOrder: [KeyPathComparator<ProcessItem>] = [
|
||||
.init(\.cpuUsage, order: .reverse)
|
||||
]
|
||||
|
||||
// Confirmation dialogs
|
||||
@State private var showTerminateConfirm = false
|
||||
@@ -30,6 +24,9 @@ struct ProcessView: View {
|
||||
@State private var errorTitle = ""
|
||||
@State private var errorMessage = ""
|
||||
|
||||
// Deep-dive inspector
|
||||
@State private var inspectorProcess: ProcessItem?
|
||||
|
||||
// Protected system processes that cannot be force-killed via UI
|
||||
private static let protectedProcesses = ["kernel_task", "launchd", "WindowServer", "loginwindow"]
|
||||
|
||||
@@ -37,14 +34,23 @@ struct ProcessView: View {
|
||||
Self.protectedProcesses.contains(name)
|
||||
}
|
||||
|
||||
private func updateDisplayedProcesses() {
|
||||
let filtered = monitor.processes.filter {
|
||||
searchText.isEmpty ||
|
||||
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
String($0.pid).contains(searchText)
|
||||
/// The rows on screen, derived from current state on every render. This used
|
||||
/// to be cached in @State and refreshed from `onChange` actions, which can read
|
||||
/// an earlier render's sort and search values — a data refresh right after a
|
||||
/// header click could put the table back into the previous order.
|
||||
private var displayedProcesses: [ProcessItem] {
|
||||
Self.filterAndSort(monitor.processes, search: searchText, by: sortColumn, ascending: sortAscending)
|
||||
}
|
||||
|
||||
displayedProcesses = filtered.sorted { lhs, rhs in
|
||||
static func filterAndSort(_ processes: [ProcessItem], search: String,
|
||||
by sortColumn: ProcessSortColumn, ascending sortAscending: Bool) -> [ProcessItem] {
|
||||
let filtered = processes.filter {
|
||||
search.isEmpty ||
|
||||
$0.name.localizedCaseInsensitiveContains(search) ||
|
||||
String($0.pid).contains(search)
|
||||
}
|
||||
|
||||
return filtered.sorted { lhs, rhs in
|
||||
let comparison: ComparisonResult
|
||||
switch sortColumn {
|
||||
case .name:
|
||||
@@ -57,12 +63,20 @@ struct ProcessView: View {
|
||||
comparison = lhs.cpuUsageTotal < rhs.cpuUsageTotal ? .orderedAscending : (lhs.cpuUsageTotal > rhs.cpuUsageTotal ? .orderedDescending : .orderedSame)
|
||||
case .memory:
|
||||
comparison = lhs.memoryUsage < rhs.memoryUsage ? .orderedAscending : (lhs.memoryUsage > rhs.memoryUsage ? .orderedDescending : .orderedSame)
|
||||
case .resident:
|
||||
comparison = lhs.residentMemory < rhs.residentMemory ? .orderedAscending : (lhs.residentMemory > rhs.residentMemory ? .orderedDescending : .orderedSame)
|
||||
case .compressed:
|
||||
comparison = lhs.compressedMemory < rhs.compressedMemory ? .orderedAscending : (lhs.compressedMemory > rhs.compressedMemory ? .orderedDescending : .orderedSame)
|
||||
case .threads:
|
||||
comparison = lhs.threadCount < rhs.threadCount ? .orderedAscending : (lhs.threadCount > rhs.threadCount ? .orderedDescending : .orderedSame)
|
||||
case .user:
|
||||
comparison = lhs.user.localizedCaseInsensitiveCompare(rhs.user)
|
||||
case .state:
|
||||
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
|
||||
@@ -75,161 +89,46 @@ struct ProcessView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Looked up among the rows on screen, so a selection hidden by the search
|
||||
/// filter can't be terminated by accident.
|
||||
var selectedProcessItem: ProcessItem? {
|
||||
guard let pid = selectedProcess.first else { return nil }
|
||||
return monitor.processes.first { $0.pid == pid }
|
||||
return displayedProcesses.first { $0.pid == pid }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let rows = displayedProcesses
|
||||
VStack(spacing: 0) {
|
||||
// Summary bar - separate view to avoid re-rendering table
|
||||
ProcessSummaryBar(processCount: displayedProcesses.count, searchText: $searchText)
|
||||
ProcessSummaryBar(processCount: rows.count, searchText: $searchText)
|
||||
|
||||
Divider()
|
||||
|
||||
// Process table
|
||||
Table(displayedProcesses, selection: $selectedProcess, sortOrder: $sortOrder) {
|
||||
TableColumn("") { process in
|
||||
if let icon = process.icon {
|
||||
Image(nsImage: icon)
|
||||
.resizable()
|
||||
.frame(width: 16, height: 16)
|
||||
} else {
|
||||
Image(systemName: "app.dashed")
|
||||
.frame(width: 16, height: 16)
|
||||
}
|
||||
}
|
||||
.width(24)
|
||||
|
||||
TableColumn("Name", value: \.name) { process in
|
||||
Text(process.name)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.width(min: 150, ideal: 200)
|
||||
|
||||
TableColumn("PID", value: \.pid) { process in
|
||||
Text("\(process.pid)")
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(60)
|
||||
|
||||
TableColumn("CPU/Core", value: \.cpuUsage) { process in
|
||||
Text(String(format: "%.1f%%", process.cpuUsage))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(cpuColor(process.cpuUsage))
|
||||
}
|
||||
.width(70)
|
||||
|
||||
TableColumn("CPU/Total", value: \.cpuUsageTotal) { process in
|
||||
Text(String(format: "%.2f%%", process.cpuUsageTotal))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(cpuColorTotal(process.cpuUsageTotal))
|
||||
}
|
||||
.width(70)
|
||||
|
||||
TableColumn("Memory", value: \.memoryUsage) { process in
|
||||
Text(formatBytes(process.memoryUsage))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(80)
|
||||
|
||||
TableColumn("Threads", value: \.threadCount) { process in
|
||||
Text("\(process.threadCount)")
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(60)
|
||||
|
||||
TableColumn("User", value: \.user) { process in
|
||||
Text(process.user)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.width(80)
|
||||
|
||||
TableColumn("State", value: \.state.rawValue) { process in
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: process.state.symbol)
|
||||
.foregroundColor(stateColor(process.state))
|
||||
Text(process.state.rawValue)
|
||||
}
|
||||
}
|
||||
.width(90)
|
||||
}
|
||||
.contextMenu(forSelectionType: ProcessItem.ID.self) { selection in
|
||||
if let pid = selection.first,
|
||||
let process = monitor.processes.first(where: { $0.pid == pid }) {
|
||||
Button("Terminate (⌫)") {
|
||||
initiateTerminate()
|
||||
}
|
||||
Button("Force Kill (⌘⌫)") {
|
||||
initiateForceKill()
|
||||
}
|
||||
Divider()
|
||||
Button("Suspend") {
|
||||
performSuspend(process: process)
|
||||
}
|
||||
Button("Resume") {
|
||||
performResume(process: process)
|
||||
}
|
||||
Divider()
|
||||
Button("Copy PID") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString("\(pid)", forType: .string)
|
||||
}
|
||||
}
|
||||
} primaryAction: { selection in
|
||||
// Double-click action
|
||||
}
|
||||
.onDeleteCommand {
|
||||
initiateTerminate()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
updateDisplayedProcesses()
|
||||
}
|
||||
.onChange(of: monitor.processes) { _ in
|
||||
updateDisplayedProcesses()
|
||||
}
|
||||
.onChange(of: searchText) { _ in
|
||||
updateDisplayedProcesses()
|
||||
}
|
||||
.onChange(of: sortColumn) { _ in
|
||||
updateDisplayedProcesses()
|
||||
}
|
||||
.onChange(of: sortAscending) { _ in
|
||||
updateDisplayedProcesses()
|
||||
}
|
||||
.onChange(of: sortOrder) { newOrder in
|
||||
guard let comparator = newOrder.first else { return }
|
||||
|
||||
// Detect which column and direction from the KeyPathComparator
|
||||
let ascending = comparator.order == .forward
|
||||
|
||||
// Use string representation of keypath to determine column
|
||||
let keyPathString = String(describing: comparator)
|
||||
|
||||
if keyPathString.contains("cpuUsageTotal") {
|
||||
sortColumn = .cpuTotal
|
||||
} else if keyPathString.contains("cpuUsage") {
|
||||
sortColumn = .cpu
|
||||
} else if keyPathString.contains("memoryUsage") {
|
||||
sortColumn = .memory
|
||||
} else if keyPathString.contains("name") {
|
||||
sortColumn = .name
|
||||
} else if keyPathString.contains("pid") {
|
||||
sortColumn = .pid
|
||||
} else if keyPathString.contains("threadCount") {
|
||||
sortColumn = .threads
|
||||
} else if keyPathString.contains("user") {
|
||||
sortColumn = .user
|
||||
} else if keyPathString.contains("state") {
|
||||
sortColumn = .state
|
||||
}
|
||||
|
||||
ProcessTableView(
|
||||
processes: rows,
|
||||
selection: $selectedProcess,
|
||||
sortColumn: sortColumn,
|
||||
sortAscending: sortAscending,
|
||||
onSort: { column, ascending in
|
||||
sortColumn = column
|
||||
sortAscending = ascending
|
||||
},
|
||||
actions: ProcessTableActions(
|
||||
open: { inspectorProcess = $0 },
|
||||
terminate: { initiateTerminate($0) },
|
||||
forceKill: { initiateForceKill($0) },
|
||||
suspend: { performSuspend(process: $0) },
|
||||
resume: { performResume(process: $0) },
|
||||
deleteKey: { initiateTerminate() }
|
||||
)
|
||||
)
|
||||
}
|
||||
.background(
|
||||
KeyboardShortcutHandler(
|
||||
onCommandBackspace: { initiateForceKill() }
|
||||
onCommandBackspace: { initiateForceKill() },
|
||||
onCommandI: {
|
||||
if let process = selectedProcessItem { inspectorProcess = process }
|
||||
}
|
||||
)
|
||||
)
|
||||
.sheet(isPresented: $showTerminateConfirm) {
|
||||
@@ -268,10 +167,14 @@ struct ProcessView: View {
|
||||
} message: {
|
||||
Text(errorMessage)
|
||||
}
|
||||
.sheet(item: $inspectorProcess) { process in
|
||||
ProcessInspectorSheet(initialProcess: process)
|
||||
.environmentObject(monitor)
|
||||
}
|
||||
}
|
||||
|
||||
private func initiateTerminate() {
|
||||
guard let process = selectedProcessItem else { return }
|
||||
private func initiateTerminate(_ target: ProcessItem? = nil) {
|
||||
guard let process = target ?? selectedProcessItem else { return }
|
||||
processToKill = process
|
||||
|
||||
if skipTerminateConfirm {
|
||||
@@ -281,8 +184,8 @@ struct ProcessView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func initiateForceKill() {
|
||||
guard let process = selectedProcessItem else { return }
|
||||
private func initiateForceKill(_ target: ProcessItem? = nil) {
|
||||
guard let process = target ?? selectedProcessItem else { return }
|
||||
|
||||
// SAFETY: Prevent force-killing critical system processes
|
||||
if isProtectedProcess(process.name) {
|
||||
@@ -327,38 +230,6 @@ struct ProcessView: View {
|
||||
showErrorAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
private func cpuColor(_ usage: Double) -> Color {
|
||||
if usage > 80 {
|
||||
return .red
|
||||
} else if usage > 50 {
|
||||
return .orange
|
||||
} else if usage > 20 {
|
||||
return .yellow
|
||||
}
|
||||
return .primary
|
||||
}
|
||||
|
||||
private func cpuColorTotal(_ usage: Double) -> Color {
|
||||
if usage > 10 {
|
||||
return .red
|
||||
} else if usage > 5 {
|
||||
return .orange
|
||||
} else if usage > 2 {
|
||||
return .yellow
|
||||
}
|
||||
return .primary
|
||||
}
|
||||
|
||||
private func stateColor(_ state: ProcessState) -> Color {
|
||||
switch state {
|
||||
case .running: return .green
|
||||
case .sleeping: return .secondary
|
||||
case .stopped: return .orange
|
||||
case .zombie: return .red
|
||||
case .unknown: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfirmationSheet: View {
|
||||
@@ -441,28 +312,39 @@ struct ConfirmationSheet: View {
|
||||
|
||||
struct KeyboardShortcutHandler: NSViewRepresentable {
|
||||
let onCommandBackspace: () -> Void
|
||||
var onCommandI: (() -> Void)? = nil
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = KeyCaptureView()
|
||||
view.onCommandBackspace = onCommandBackspace
|
||||
view.onCommandI = onCommandI
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
if let view = nsView as? KeyCaptureView {
|
||||
view.onCommandBackspace = onCommandBackspace
|
||||
view.onCommandI = onCommandI
|
||||
}
|
||||
}
|
||||
|
||||
class KeyCaptureView: NSView {
|
||||
var onCommandBackspace: (() -> Void)?
|
||||
var onCommandI: (() -> Void)?
|
||||
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
// Backspace = keyCode 51
|
||||
if event.keyCode == 51 && event.modifierFlags.contains(.command) {
|
||||
guard event.modifierFlags.contains(.command) else {
|
||||
return super.performKeyEquivalent(with: event)
|
||||
}
|
||||
// Backspace = keyCode 51, "i" = keyCode 34
|
||||
if event.keyCode == 51 {
|
||||
onCommandBackspace?()
|
||||
return true
|
||||
}
|
||||
if event.keyCode == 34 {
|
||||
onCommandI?()
|
||||
return true
|
||||
}
|
||||
return super.performKeyEquivalent(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
97
TopManager/Views/SettingsView.swift
Normal file
@@ -0,0 +1,97 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Settings window shell. Like `ContentView`, it deliberately observes nothing:
|
||||
/// a body containing `.tabItem` leaks its `AnyView(Label(…))` on every
|
||||
/// re-evaluation, and dragging a threshold slider re-publishes `AppSettings`
|
||||
/// on every step. The tabs below own the observation instead.
|
||||
struct SettingsView: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
GeneralSettingsTab()
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
AlertSettingsTab()
|
||||
.tabItem { Label("Alerts", systemImage: "bell") }
|
||||
}
|
||||
.frame(width: 440, height: 340)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private struct GeneralSettingsTab: View {
|
||||
@EnvironmentObject var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Launch at login", isOn: $settings.launchAtLogin)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text("Refresh interval")
|
||||
Spacer()
|
||||
Text(String(format: "%.0f s", settings.refreshInterval))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Slider(value: $settings.refreshInterval, in: 1...30, step: 1)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Menu Bar") {
|
||||
Picker("Show in menu bar", selection: $settings.menuBarMetric) {
|
||||
ForEach(MenuBarMetric.allCases) { metric in
|
||||
Text(metric.rawValue).tag(metric)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AlertSettingsTab: View {
|
||||
@EnvironmentObject var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Enable notifications", isOn: $settings.notificationsEnabled)
|
||||
} footer: {
|
||||
Text("TopManager posts a macOS notification when a threshold is first crossed.")
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Section("Thresholds") {
|
||||
sliderRow(title: "High CPU", value: $settings.cpuAlertThreshold,
|
||||
range: 50...100, unit: "%")
|
||||
sliderRow(title: "Disk almost full", value: $settings.diskAlertPercent,
|
||||
range: 80...99, unit: "%")
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text("Low battery")
|
||||
Spacer()
|
||||
Text("\(settings.lowBatteryPercent)%").monospacedDigit().foregroundColor(.secondary)
|
||||
}
|
||||
Slider(value: Binding(
|
||||
get: { Double(settings.lowBatteryPercent) },
|
||||
set: { settings.lowBatteryPercent = Int($0) }
|
||||
), in: 5...50, step: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private func sliderRow(title: String, value: Binding<Double>, range: ClosedRange<Double>, unit: String) -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
Text(String(format: "%.0f%@", value.wrappedValue, unit))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Slider(value: value, in: range, step: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
58
TopManagerTests/BatteryMathTests.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import XCTest
|
||||
@testable import TopManager
|
||||
|
||||
final class BatteryMathTests: XCTestCase {
|
||||
|
||||
func testHealthPercent() {
|
||||
// 4000 mAh current full-charge vs 5000 mAh design = 80%
|
||||
XCTAssertEqual(BatteryMath.healthPercent(maxCapacity: 4000, designCapacity: 5000), 80, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testHealthPercentZeroDesignIsSafe() {
|
||||
XCTAssertEqual(BatteryMath.healthPercent(maxCapacity: 100, designCapacity: 0), 0)
|
||||
}
|
||||
|
||||
func testConditionNormalWhenHealthy() {
|
||||
XCTAssertEqual(BatteryMath.condition(healthPercent: 92, cycleCount: 120), .normal)
|
||||
}
|
||||
|
||||
func testConditionServiceWhenHealthLow() {
|
||||
XCTAssertEqual(BatteryMath.condition(healthPercent: 74, cycleCount: 120), .serviceRecommended)
|
||||
}
|
||||
|
||||
func testConditionServiceWhenCyclesHigh() {
|
||||
XCTAssertEqual(BatteryMath.condition(healthPercent: 95, cycleCount: 1200), .serviceRecommended)
|
||||
}
|
||||
|
||||
func testConditionUnknownWhenNoHealth() {
|
||||
XCTAssertEqual(BatteryMath.condition(healthPercent: nil, cycleCount: 100), .unknown)
|
||||
}
|
||||
|
||||
func testWattsPositiveWhenCharging() {
|
||||
// 12 V * 2000 mA = 24 W
|
||||
XCTAssertEqual(BatteryMath.watts(volts: 12, milliAmps: 2000), 24, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testWattsNegativeWhenDischarging() {
|
||||
XCTAssertEqual(BatteryMath.watts(volts: 12, milliAmps: -1500), -18, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testFormatMinutes() {
|
||||
XCTAssertEqual(BatteryMath.formatMinutes(135), "2h 15m")
|
||||
XCTAssertEqual(BatteryMath.formatMinutes(45), "45m")
|
||||
XCTAssertEqual(BatteryMath.formatMinutes(0), "—")
|
||||
}
|
||||
|
||||
func testPowerInfoDerivedValues() {
|
||||
let p = PowerInfo(
|
||||
hasBattery: true, currentCharge: 66, isCharging: false, isPluggedIn: false,
|
||||
fullyCharged: false, cycleCount: 150, designCapacity: 5000, maxCapacity: 4600,
|
||||
temperature: 30.5, voltage: 12.0, amperage: -1500, timeToEmpty: 200,
|
||||
timeToFull: nil, adapterWatts: nil
|
||||
)
|
||||
XCTAssertEqual(p.healthPercent ?? 0, 92, accuracy: 0.001)
|
||||
XCTAssertEqual(p.condition, .normal)
|
||||
XCTAssertEqual(p.powerWatts ?? 0, -18, accuracy: 0.001)
|
||||
XCTAssertEqual(p.powerSourceLabel, "Battery Power")
|
||||
}
|
||||
}
|
||||
114
TopManagerTests/FormattingTests.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
import XCTest
|
||||
@testable import TopManager
|
||||
|
||||
/// Baseline tests proving the test harness works and locking in the existing
|
||||
/// formatting / model-math behavior so later refactors can't silently break it.
|
||||
final class FormattingTests: XCTestCase {
|
||||
|
||||
// MARK: - Byte formatting
|
||||
|
||||
func testFormatBytesZero() {
|
||||
// ByteCountFormatter renders 0 as "Zero KB"/"Zero bytes" depending on OS/locale.
|
||||
let s = formatBytes(UInt64(0)).lowercased()
|
||||
XCTAssertTrue(s.contains("zero") || s.contains("0"), "Unexpected zero rendering: \(s)")
|
||||
}
|
||||
|
||||
func testFormatBytesGigabyteScale() {
|
||||
// 2 GiB in binary style
|
||||
let twoGiB = UInt64(2) * 1024 * 1024 * 1024
|
||||
let s = formatBytes(twoGiB)
|
||||
XCTAssertTrue(s.contains("GB"), "Expected GB unit, got \(s)")
|
||||
XCTAssertTrue(s.contains("2"), "Expected value 2, got \(s)")
|
||||
}
|
||||
|
||||
func testFormatBytesNegativeDoubleClampsToZero() {
|
||||
// Double overload clamps negatives to 0 rather than trapping on UInt64(negative)
|
||||
let s = formatBytes(Double(-500)).lowercased()
|
||||
XCTAssertTrue(s.contains("zero") || s.contains("0"), "Unexpected clamp rendering: \(s)")
|
||||
}
|
||||
|
||||
func testFormatBytesPerSecondBelowOne() {
|
||||
XCTAssertEqual(formatBytesPerSecond(0.4), "0 B/s")
|
||||
}
|
||||
|
||||
func testFormatBytesPerSecondSuffix() {
|
||||
let s = formatBytesPerSecond(5 * 1024 * 1024) // 5 MiB/s
|
||||
XCTAssertTrue(s.hasSuffix("/s"), "Expected /s suffix, got \(s)")
|
||||
XCTAssertTrue(s.contains("MB"), "Expected MB unit, got \(s)")
|
||||
}
|
||||
|
||||
// MARK: - Percentage
|
||||
|
||||
func testFormatPercentageDefaultDecimals() {
|
||||
XCTAssertEqual(formatPercentage(12.345), "12.3%")
|
||||
}
|
||||
|
||||
func testFormatPercentageCustomDecimals() {
|
||||
XCTAssertEqual(formatPercentage(12.345, decimals: 2), "12.35%")
|
||||
}
|
||||
|
||||
// MARK: - Uptime
|
||||
|
||||
func testFormatUptimeMinutesOnly() {
|
||||
XCTAssertEqual(formatUptime(45 * 60), "45m")
|
||||
}
|
||||
|
||||
func testFormatUptimeHoursAndMinutes() {
|
||||
XCTAssertEqual(formatUptime(3 * 3600 + 12 * 60), "3h 12m")
|
||||
}
|
||||
|
||||
func testFormatUptimeDays() {
|
||||
XCTAssertEqual(formatUptime(2 * 86400 + 5 * 3600 + 9 * 60), "2d 5h 9m")
|
||||
}
|
||||
|
||||
// MARK: - Model math
|
||||
|
||||
func testMemoryUsagePercentage() {
|
||||
let mem = MemoryInfo(
|
||||
totalMemory: 16_000_000_000,
|
||||
usedMemory: 8_000_000_000,
|
||||
freeMemory: 8_000_000_000,
|
||||
activeMemory: 4_000_000_000,
|
||||
inactiveMemory: 2_000_000_000,
|
||||
wiredMemory: 2_000_000_000,
|
||||
compressedMemory: 1_000_000_000,
|
||||
cachedMemory: 1_000_000_000,
|
||||
swapUsed: 0,
|
||||
swapTotal: 0,
|
||||
memoryPressure: .nominal
|
||||
)
|
||||
XCTAssertEqual(mem.usagePercentage, 50.0, accuracy: 0.001)
|
||||
XCTAssertEqual(mem.appMemory, 6_000_000_000)
|
||||
}
|
||||
|
||||
func testMemoryUsagePercentageZeroTotalIsSafe() {
|
||||
let mem = MemoryInfo(
|
||||
totalMemory: 0, usedMemory: 0, freeMemory: 0, activeMemory: 0,
|
||||
inactiveMemory: 0, wiredMemory: 0, compressedMemory: 0, cachedMemory: 0,
|
||||
swapUsed: 0, swapTotal: 0, memoryPressure: .unknown
|
||||
)
|
||||
XCTAssertEqual(mem.usagePercentage, 0)
|
||||
}
|
||||
|
||||
func testVolumeUsagePercentageAndUsedSpace() {
|
||||
let vol = VolumeInfo(
|
||||
name: "Macintosh HD",
|
||||
mountPoint: "/",
|
||||
totalSpace: 1000,
|
||||
freeSpace: 250,
|
||||
fileSystem: "APFS",
|
||||
isRemovable: false,
|
||||
isInternal: true
|
||||
)
|
||||
XCTAssertEqual(vol.usedSpace, 750)
|
||||
XCTAssertEqual(vol.usagePercentage, 75.0, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testVolumeFreeGreaterThanTotalClampsUsedToZero() {
|
||||
let vol = VolumeInfo(
|
||||
name: "weird", mountPoint: "/x", totalSpace: 100, freeSpace: 200,
|
||||
fileSystem: "APFS", isRemovable: false, isInternal: true
|
||||
)
|
||||
XCTAssertEqual(vol.usedSpace, 0)
|
||||
}
|
||||
}
|
||||
110
TopManagerTests/HealthAlertTests.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
import XCTest
|
||||
@testable import TopManager
|
||||
|
||||
final class HealthAlertTests: XCTestCase {
|
||||
|
||||
private func healthy() -> HealthInput {
|
||||
HealthInput(cpuUsage: 10, memoryPressure: .nominal, swapUsedBytes: 0,
|
||||
thermal: .nominal, diskUsedFraction: 0.4)
|
||||
}
|
||||
|
||||
// MARK: - Health score
|
||||
|
||||
func testHealthyScoreIsExcellent() {
|
||||
let s = HealthModel.score(healthy())
|
||||
XCTAssertEqual(s, 100)
|
||||
XCTAssertEqual(HealthModel.rating(s), "Excellent")
|
||||
}
|
||||
|
||||
func testHighCPULowersScore() {
|
||||
var input = healthy()
|
||||
input = HealthInput(cpuUsage: 100, memoryPressure: .nominal, swapUsedBytes: 0,
|
||||
thermal: .nominal, diskUsedFraction: 0.4)
|
||||
XCTAssertLessThan(HealthModel.score(input), HealthModel.score(healthy()))
|
||||
}
|
||||
|
||||
func testCriticalMemoryAndThermalCompound() {
|
||||
let input = HealthInput(cpuUsage: 95, memoryPressure: .critical, swapUsedBytes: 3_000_000_000,
|
||||
thermal: .critical, diskUsedFraction: 0.97)
|
||||
let s = HealthModel.score(input)
|
||||
XCTAssertLessThan(s, 50)
|
||||
XCTAssertEqual(HealthModel.rating(s), "Poor")
|
||||
XCTAssertGreaterThanOrEqual(s, 0)
|
||||
}
|
||||
|
||||
func testScoreClampedToRange() {
|
||||
// Absolute worst case must not underflow below 0.
|
||||
let worst = HealthInput(cpuUsage: 100, memoryPressure: .critical, swapUsedBytes: .max,
|
||||
thermal: .critical, diskUsedFraction: 1.0)
|
||||
let s = HealthModel.score(worst)
|
||||
XCTAssertTrue((0...100).contains(s))
|
||||
}
|
||||
|
||||
func testDiagnosisEmptyWhenHealthy() {
|
||||
XCTAssertTrue(HealthModel.diagnosis(healthy()).isEmpty)
|
||||
}
|
||||
|
||||
func testDiagnosisReportsProblems() {
|
||||
let input = HealthInput(cpuUsage: 95, memoryPressure: .critical, swapUsedBytes: 3_000_000_000,
|
||||
thermal: .serious, diskUsedFraction: 0.93)
|
||||
let d = HealthModel.diagnosis(input)
|
||||
XCTAssertFalse(d.isEmpty)
|
||||
XCTAssertTrue(d.contains { $0.localizedCaseInsensitiveContains("CPU") })
|
||||
XCTAssertTrue(d.contains { $0.localizedCaseInsensitiveContains("memory") })
|
||||
XCTAssertTrue(d.contains { $0.localizedCaseInsensitiveContains("disk") })
|
||||
}
|
||||
|
||||
// MARK: - Alert sustained evaluator
|
||||
|
||||
func testSustainedFiresOnlyAfterRequiredCycles() {
|
||||
var count = 0
|
||||
var result = AlertEvaluator.sustained(breached: true, previousCount: count, requiredCycles: 3)
|
||||
count = result.count
|
||||
XCTAssertFalse(result.firing) // 1
|
||||
result = AlertEvaluator.sustained(breached: true, previousCount: count, requiredCycles: 3)
|
||||
count = result.count
|
||||
XCTAssertFalse(result.firing) // 2
|
||||
result = AlertEvaluator.sustained(breached: true, previousCount: count, requiredCycles: 3)
|
||||
count = result.count
|
||||
XCTAssertTrue(result.firing) // 3 → fires
|
||||
}
|
||||
|
||||
func testSustainedResetsWhenConditionClears() {
|
||||
var count = 5
|
||||
let result = AlertEvaluator.sustained(breached: false, previousCount: count, requiredCycles: 3)
|
||||
count = result.count
|
||||
XCTAssertFalse(result.firing)
|
||||
XCTAssertEqual(count, 0)
|
||||
}
|
||||
|
||||
func testSeverityOrdering() {
|
||||
XCTAssertTrue(AlertSeverity.info < AlertSeverity.warning)
|
||||
XCTAssertTrue(AlertSeverity.warning < AlertSeverity.critical)
|
||||
}
|
||||
|
||||
// MARK: - AlertCenter integration (de-dup + inbox)
|
||||
|
||||
@MainActor
|
||||
func testAlertCenterDedupesAndClearsActiveKinds() {
|
||||
let center = AlertCenter.shared
|
||||
center.clearInbox()
|
||||
|
||||
let mem = MemoryInfo(totalMemory: 16, usedMemory: 15, freeMemory: 1, activeMemory: 8,
|
||||
inactiveMemory: 2, wiredMemory: 3, compressedMemory: 2, cachedMemory: 0,
|
||||
swapUsed: 0, swapTotal: 0, memoryPressure: .critical)
|
||||
|
||||
let before = center.alerts.count
|
||||
// Two consecutive critical-pressure evaluations → only ONE alert (de-dup).
|
||||
center.evaluate(cpuUsage: 5, memory: mem, disk: nil, thermal: .nominal, topProcess: nil, power: nil)
|
||||
center.evaluate(cpuUsage: 5, memory: mem, disk: nil, thermal: .nominal, topProcess: nil, power: nil)
|
||||
XCTAssertEqual(center.alerts.count, before + 1, "critical memory pressure should fire exactly once")
|
||||
XCTAssertTrue(center.activeKinds.contains(.memoryPressure))
|
||||
|
||||
// Pressure recovers → active kind clears, no new alert added.
|
||||
let ok = MemoryInfo(totalMemory: 16, usedMemory: 4, freeMemory: 12, activeMemory: 2,
|
||||
inactiveMemory: 1, wiredMemory: 1, compressedMemory: 0, cachedMemory: 0,
|
||||
swapUsed: 0, swapTotal: 0, memoryPressure: .nominal)
|
||||
center.evaluate(cpuUsage: 5, memory: ok, disk: nil, thermal: .nominal, topProcess: nil, power: nil)
|
||||
XCTAssertFalse(center.activeKinds.contains(.memoryPressure))
|
||||
}
|
||||
}
|
||||
35
TopManagerTests/MenuBarStatusTests.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import XCTest
|
||||
@testable import TopManager
|
||||
|
||||
final class MenuBarStatusTests: XCTestCase {
|
||||
|
||||
private func cpu(_ usage: Double) -> CPUInfo {
|
||||
CPUInfo(globalUsage: usage, userUsage: usage, systemUsage: 0, idleUsage: 100 - usage, coreUsages: [])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testFormatsVisibleText() {
|
||||
let status = MenuBarStatus()
|
||||
status.update(cpu: cpu(41.6), memory: nil, network: nil)
|
||||
XCTAssertEqual(status.cpuText, "42%")
|
||||
XCTAssertNil(status.memoryText)
|
||||
XCTAssertNil(status.downloadText)
|
||||
}
|
||||
|
||||
/// Every publish re-measures the status item, so a refresh that renders the
|
||||
/// same text must not publish at all.
|
||||
@MainActor
|
||||
func testRepublishesOnlyWhenVisibleTextChanges() {
|
||||
let status = MenuBarStatus()
|
||||
var publishes = 0
|
||||
let subscription = status.objectWillChange.sink { publishes += 1 }
|
||||
defer { subscription.cancel() }
|
||||
|
||||
status.update(cpu: cpu(41.6), memory: nil, network: nil)
|
||||
XCTAssertEqual(publishes, 1)
|
||||
status.update(cpu: cpu(42.4), memory: nil, network: nil) // still "42%"
|
||||
XCTAssertEqual(publishes, 1)
|
||||
status.update(cpu: cpu(57), memory: nil, network: nil)
|
||||
XCTAssertEqual(publishes, 2)
|
||||
}
|
||||
}
|
||||
106
TopManagerTests/MetricsStoreTests.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import XCTest
|
||||
@testable import TopManager
|
||||
|
||||
final class MetricsStoreTests: XCTestCase {
|
||||
|
||||
private func sample(_ secondsAgo: TimeInterval, cpu: Double = 0, memUsed: UInt64 = 0,
|
||||
memTotal: UInt64 = 100, down: Double = 0, up: Double = 0,
|
||||
now: Date) -> MetricsSample {
|
||||
MetricsSample(t: now.addingTimeInterval(-secondsAgo), cpu: cpu,
|
||||
memUsed: memUsed, memTotal: memTotal, netDown: down, netUp: up)
|
||||
}
|
||||
|
||||
// MARK: - trim
|
||||
|
||||
func testTrimDropsOldSamples() {
|
||||
let now = Date()
|
||||
let samples = [
|
||||
sample(10_000, now: now), // older than 1h
|
||||
sample(100, now: now), // within 1h
|
||||
sample(50, now: now)
|
||||
]
|
||||
let trimmed = MetricsMath.trim(samples, now: now, maxAge: 3600, maxCount: 100)
|
||||
XCTAssertEqual(trimmed.count, 2)
|
||||
XCTAssertFalse(trimmed.contains { $0.t < now.addingTimeInterval(-3600) })
|
||||
}
|
||||
|
||||
func testTrimCapsCountKeepingNewest() {
|
||||
let now = Date()
|
||||
// 10 samples, 0..9 seconds ago; keep newest 5
|
||||
let samples = (0..<10).map { sample(Double($0), cpu: Double($0), now: now) }
|
||||
let trimmed = MetricsMath.trim(samples, now: now, maxAge: 3600, maxCount: 5)
|
||||
XCTAssertEqual(trimmed.count, 5)
|
||||
// suffix keeps the last 5 of the input order (cpu 5..9)
|
||||
XCTAssertEqual(trimmed.map { $0.cpu }, [5, 6, 7, 8, 9])
|
||||
}
|
||||
|
||||
// MARK: - downsample
|
||||
|
||||
func testDownsampleZeroBucketIsPassthrough() {
|
||||
let now = Date()
|
||||
let samples = (0..<5).map { sample(Double($0), cpu: Double($0), now: now) }
|
||||
XCTAssertEqual(MetricsMath.downsample(samples, bucket: 0).count, 5)
|
||||
}
|
||||
|
||||
func testDownsampleAveragesWithinBucket() {
|
||||
// Two samples 0s and 5s into the same 60s bucket → averaged to one point.
|
||||
let epoch = Date(timeIntervalSince1970: 1_000_000) // aligned into bucket 16666
|
||||
let s1 = MetricsSample(t: epoch, cpu: 20, memUsed: 40, memTotal: 100, netDown: 100, netUp: 10)
|
||||
let s2 = MetricsSample(t: epoch.addingTimeInterval(5), cpu: 40, memUsed: 60, memTotal: 100, netDown: 300, netUp: 30)
|
||||
let out = MetricsMath.downsample([s1, s2], bucket: 60)
|
||||
XCTAssertEqual(out.count, 1)
|
||||
XCTAssertEqual(out[0].cpu, 30, accuracy: 0.001)
|
||||
XCTAssertEqual(out[0].memUsed, 50)
|
||||
XCTAssertEqual(out[0].netDown, 200, accuracy: 0.001)
|
||||
XCTAssertEqual(out[0].netUp, 20, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testDownsampleReducesPointCountForLongRange() {
|
||||
let base = Date(timeIntervalSince1970: 2_000_000)
|
||||
// 600 samples one second apart → into 60s buckets ≈ 10 points
|
||||
let samples = (0..<600).map {
|
||||
MetricsSample(t: base.addingTimeInterval(Double($0)), cpu: 1, memUsed: 1, memTotal: 100, netDown: 0, netUp: 0)
|
||||
}
|
||||
let out = MetricsMath.downsample(samples, bucket: 60)
|
||||
XCTAssertLessThanOrEqual(out.count, 11)
|
||||
XCTAssertGreaterThanOrEqual(out.count, 10)
|
||||
// output must be time-sorted
|
||||
XCTAssertEqual(out, out.sorted { $0.t < $1.t })
|
||||
}
|
||||
|
||||
// MARK: - persistence round-trip
|
||||
|
||||
func testStorePersistsAndReloads() {
|
||||
let tmp = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("tm-test-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||
|
||||
let store = MetricsStore(fileURL: tmp, maxAge: 3600, maxCount: 100, saveEvery: 1)
|
||||
let now = Date()
|
||||
store.record(MetricsSample(t: now, cpu: 42, memUsed: 5, memTotal: 10, netDown: 1, netUp: 2))
|
||||
|
||||
// saveEvery: 1 persists on the background io queue; wait for the file to appear.
|
||||
let deadline = Date().addingTimeInterval(2)
|
||||
while !FileManager.default.fileExists(atPath: tmp.path), Date() < deadline {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.02))
|
||||
}
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: tmp.path), "history file was not written")
|
||||
|
||||
let reloaded = MetricsStore(fileURL: tmp, maxAge: 3600, maxCount: 100, saveEvery: 1)
|
||||
XCTAssertEqual(reloaded.count, 1)
|
||||
XCTAssertEqual(reloaded.samples(since: now.addingTimeInterval(-1)).first?.cpu, 42)
|
||||
}
|
||||
|
||||
// MARK: - range config sanity
|
||||
|
||||
func testHistoryRangeLiveHasNoWindow() {
|
||||
XCTAssertNil(HistoryRange.live.seconds)
|
||||
XCTAssertEqual(HistoryRange.live.bucket, 0)
|
||||
}
|
||||
|
||||
func testHistoryRangeWindowsIncrease() {
|
||||
let windows = HistoryRange.allCases.compactMap { $0.seconds }
|
||||
XCTAssertEqual(windows, windows.sorted())
|
||||
XCTAssertEqual(HistoryRange.h24.seconds, 86_400)
|
||||
}
|
||||
}
|
||||
128
TopManagerTests/ProcessDataTests.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
import XCTest
|
||||
@testable import TopManager
|
||||
|
||||
final class ProcessDataTests: XCTestCase {
|
||||
|
||||
// MARK: - KERN_PROCARGS2 parsing
|
||||
|
||||
private func buildProcArgsBuffer(argc: Int32, path: String, args: [String], env: [String]) -> [UInt8] {
|
||||
var bytes: [UInt8] = [
|
||||
UInt8(argc & 0xff),
|
||||
UInt8((argc >> 8) & 0xff),
|
||||
UInt8((argc >> 16) & 0xff),
|
||||
UInt8((argc >> 24) & 0xff)
|
||||
]
|
||||
bytes += Array(path.utf8) + [0]
|
||||
bytes += [0, 0] // NUL padding before argv[0]
|
||||
for a in args { bytes += Array(a.utf8) + [0] }
|
||||
for e in env { bytes += Array(e.utf8) + [0] }
|
||||
return bytes
|
||||
}
|
||||
|
||||
func testProcArgsParseExtractsPathAndArgs() {
|
||||
let buffer = buildProcArgsBuffer(argc: 2, path: "/bin/foo",
|
||||
args: ["/bin/foo", "--flag"],
|
||||
env: ["PATH=/usr/bin"])
|
||||
let parsed = ProcArgs.parse(buffer)
|
||||
XCTAssertEqual(parsed?.path, "/bin/foo")
|
||||
XCTAssertEqual(parsed?.args, ["/bin/foo", "--flag"])
|
||||
}
|
||||
|
||||
func testProcArgsParseStopsAtArgcAndIgnoresEnv() {
|
||||
// argc=1 → only the first arg is returned, env is not leaked into args
|
||||
let buffer = buildProcArgsBuffer(argc: 1, path: "/usr/bin/x",
|
||||
args: ["/usr/bin/x"],
|
||||
env: ["SECRET=shh"])
|
||||
let parsed = ProcArgs.parse(buffer)
|
||||
XCTAssertEqual(parsed?.args, ["/usr/bin/x"])
|
||||
XCTAssertFalse(parsed?.args.contains { $0.contains("SECRET") } ?? true)
|
||||
}
|
||||
|
||||
func testProcArgsParseZeroArgc() {
|
||||
let buffer = buildProcArgsBuffer(argc: 0, path: "/bin/x", args: [], env: [])
|
||||
let parsed = ProcArgs.parse(buffer)
|
||||
XCTAssertEqual(parsed?.path, "/bin/x")
|
||||
XCTAssertEqual(parsed?.args, [])
|
||||
}
|
||||
|
||||
func testProcArgsParseTooShortReturnsNil() {
|
||||
XCTAssertNil(ProcArgs.parse([1, 2, 3]))
|
||||
}
|
||||
|
||||
// MARK: - Energy model
|
||||
|
||||
func testEnergyImpactMonotonicInCPU() {
|
||||
XCTAssertGreaterThan(
|
||||
EnergyModel.impact(cpuPercent: 50, idleWakeupsPerSec: 0),
|
||||
EnergyModel.impact(cpuPercent: 10, idleWakeupsPerSec: 0)
|
||||
)
|
||||
}
|
||||
|
||||
func testEnergyImpactMonotonicInWakeups() {
|
||||
XCTAssertGreaterThan(
|
||||
EnergyModel.impact(cpuPercent: 10, idleWakeupsPerSec: 500),
|
||||
EnergyModel.impact(cpuPercent: 10, idleWakeupsPerSec: 0)
|
||||
)
|
||||
}
|
||||
|
||||
func testEnergyImpactClampsNegativeInputsToZero() {
|
||||
XCTAssertEqual(EnergyModel.impact(cpuPercent: -5, idleWakeupsPerSec: -100), 0, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testEnergyImpactEqualsCPUWhenNoWakeups() {
|
||||
XCTAssertEqual(EnergyModel.impact(cpuPercent: 33, idleWakeupsPerSec: 0), 33, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
// MARK: - ProcessItem derived + equality
|
||||
|
||||
func testDiskTotalRateSumsReadAndWrite() {
|
||||
let p = ProcessItem(pid: 1, name: "a", user: "u", cpuUsage: 0, cpuUsageTotal: 0,
|
||||
memoryUsage: 0, threadCount: 0, state: .running, icon: nil,
|
||||
parentPid: 0, startTime: nil, diskReadRate: 100, diskWriteRate: 50)
|
||||
XCTAssertEqual(p.diskTotalRate, 150)
|
||||
}
|
||||
|
||||
func testEqualityDetectsEnergyChange() {
|
||||
func make(energy: Double) -> ProcessItem {
|
||||
ProcessItem(pid: 1, name: "a", user: "u", cpuUsage: 1, cpuUsageTotal: 1,
|
||||
memoryUsage: 1, threadCount: 1, state: .running, icon: nil,
|
||||
parentPid: 0, startTime: nil, energyImpact: energy)
|
||||
}
|
||||
// Table refresh relies on == seeing energy/disk changes.
|
||||
XCTAssertNotEqual(make(energy: 5), make(energy: 9))
|
||||
XCTAssertEqual(make(energy: 5), make(energy: 5))
|
||||
}
|
||||
|
||||
// MARK: - Live sampling
|
||||
|
||||
/// Fetches 1–3 are full refreshes; 4 and 5 are lightweight ones, which used
|
||||
/// to report 0 memory for every idle process (~90% of rows). Only processes
|
||||
/// spawned between fetches may legitimately have no reading yet.
|
||||
func testLightweightRefreshKeepsLastKnownMemory() {
|
||||
let monitor = ProcessMonitor()
|
||||
let me = NSUserName()
|
||||
for fetch in 1...6 {
|
||||
let own = monitor.fetchProcesses().filter { $0.user == me }
|
||||
XCTAssertFalse(own.isEmpty)
|
||||
let zero = own.filter { $0.memoryUsage == 0 }.count
|
||||
XCTAssertLessThan(zero, max(3, own.count / 10),
|
||||
"fetch \(fetch): \(zero) of \(own.count) own processes reported 0 memory")
|
||||
let zeroRAM = own.filter { $0.residentMemory == 0 }.count
|
||||
XCTAssertLessThan(zeroRAM, max(3, own.count / 10),
|
||||
"fetch \(fetch): \(zeroRAM) of \(own.count) own processes reported 0 RAM")
|
||||
}
|
||||
}
|
||||
|
||||
func testOwnProcessReportsResidentMemory() {
|
||||
let item = ProcessMonitor().fetchProcesses().first { $0.pid == getpid() }
|
||||
XCTAssertNotNil(item)
|
||||
XCTAssertGreaterThan(item?.residentMemory ?? 0, 0)
|
||||
XCTAssertGreaterThan(item?.memoryUsage ?? 0, 0)
|
||||
}
|
||||
|
||||
/// A task name port is granted for our own processes only; launchd runs as root.
|
||||
func testCompressedMemoryReadableForOwnProcessesOnly() {
|
||||
XCTAssertNotNil(fetchCompressedMemory(pid: getpid()))
|
||||
XCTAssertNil(fetchCompressedMemory(pid: 1))
|
||||
}
|
||||
}
|
||||
267
TopManagerTests/ProcessTableTests.swift
Normal file
@@ -0,0 +1,267 @@
|
||||
import XCTest
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
@testable import TopManager
|
||||
|
||||
final class ProcessTableTests: XCTestCase {
|
||||
|
||||
private func item(pid: pid_t = 100, name: String = "proc", user: String = "ariel",
|
||||
cpu: Double = 0, cpuTotal: Double = 0, energy: Double = 0,
|
||||
memory: Int64 = 1_000_000, resident: Int64 = 2_000_000, compressed: Int64 = 0,
|
||||
diskRead: Double = 0, state: ProcessState = .sleeping, icon: NSImage? = nil,
|
||||
start: Date = Date(timeIntervalSince1970: 1_000)) -> ProcessItem {
|
||||
ProcessItem(pid: pid, name: name, user: user, cpuUsage: cpu, cpuUsageTotal: cpuTotal,
|
||||
memoryUsage: memory, threadCount: 4, state: state, icon: icon, parentPid: 1,
|
||||
startTime: start, diskReadRate: diskRead, energyImpact: energy,
|
||||
residentMemory: resident, compressedMemory: compressed)
|
||||
}
|
||||
|
||||
// MARK: Columns
|
||||
|
||||
func testColumnsInDisplayOrder() {
|
||||
XCTAssertEqual(ProcessColumn.allCases.map(\.title),
|
||||
["Name", "PID", "CPU/Core", "CPU/Total", "Energy", "Memory", "RAM",
|
||||
"Compressed", "Disk I/O", "Threads", "User", "State"])
|
||||
}
|
||||
|
||||
func testEveryColumnSortsByItsOwnKey() {
|
||||
for column in ProcessColumn.allCases {
|
||||
XCTAssertEqual(column.sortColumn.rawValue, column.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
func testTextColumnsSortAscendingFirstFiguresDescending() {
|
||||
XCTAssertTrue(ProcessColumn.name.sortsAscendingFirst)
|
||||
XCTAssertTrue(ProcessColumn.user.sortsAscendingFirst)
|
||||
XCTAssertFalse(ProcessColumn.cpu.sortsAscendingFirst)
|
||||
XCTAssertFalse(ProcessColumn.memory.sortsAscendingFirst)
|
||||
XCTAssertFalse(ProcessColumn.compressed.sortsAscendingFirst)
|
||||
}
|
||||
|
||||
func testHeaderClickFlipsCurrentColumnAndStartsOthersInNaturalDirection() {
|
||||
XCTAssertTrue(ProcessColumn.cpu.sortAfterClick(current: .cpu, ascending: false) == (.cpu, true))
|
||||
XCTAssertTrue(ProcessColumn.cpu.sortAfterClick(current: .cpu, ascending: true) == (.cpu, false))
|
||||
XCTAssertTrue(ProcessColumn.memory.sortAfterClick(current: .name, ascending: true) == (.memory, false))
|
||||
XCTAssertTrue(ProcessColumn.resident.sortAfterClick(current: .memory, ascending: false) == (.resident, false))
|
||||
XCTAssertTrue(ProcessColumn.name.sortAfterClick(current: .cpu, ascending: false) == (.name, true))
|
||||
}
|
||||
|
||||
// MARK: Cell content
|
||||
|
||||
func testCPUToneThresholdsMatchPreviousTable() {
|
||||
XCTAssertEqual(ProcessColumn.cpu.content(for: item(cpu: 20)).tone, .primary)
|
||||
XCTAssertEqual(ProcessColumn.cpu.content(for: item(cpu: 20.1)).tone, .yellow)
|
||||
XCTAssertEqual(ProcessColumn.cpu.content(for: item(cpu: 50.1)).tone, .orange)
|
||||
XCTAssertEqual(ProcessColumn.cpu.content(for: item(cpu: 80.1)).tone, .red)
|
||||
XCTAssertEqual(ProcessColumn.cpuTotal.content(for: item(cpuTotal: 5.5)).tone, .orange)
|
||||
XCTAssertEqual(ProcessColumn.energy.content(for: item(energy: 8.5)).tone, .yellow)
|
||||
}
|
||||
|
||||
func testFormatsFigures() {
|
||||
let p = item(pid: 89435, cpu: 25.54, cpuTotal: 3.184, energy: 25.6)
|
||||
XCTAssertEqual(ProcessColumn.pid.content(for: p).text, "89435")
|
||||
XCTAssertEqual(ProcessColumn.cpu.content(for: p).text, "25.5%")
|
||||
XCTAssertEqual(ProcessColumn.cpuTotal.content(for: p).text, "3.18%")
|
||||
XCTAssertEqual(ProcessColumn.energy.content(for: p).text, "25.6")
|
||||
XCTAssertEqual(ProcessColumn.memory.content(for: p).text, formatBytes(Int64(1_000_000)))
|
||||
XCTAssertEqual(ProcessColumn.resident.content(for: p).text, formatBytes(Int64(2_000_000)))
|
||||
XCTAssertTrue(ProcessColumn.memory.content(for: p).monospacedDigits)
|
||||
XCTAssertFalse(ProcessColumn.user.content(for: p).monospacedDigits)
|
||||
}
|
||||
|
||||
func testZeroCompressedAndIdleDiskShowSecondaryDash() {
|
||||
for column in [ProcessColumn.compressed, .disk] {
|
||||
let content = column.content(for: item())
|
||||
XCTAssertEqual(content.text, "—")
|
||||
XCTAssertEqual(content.tone, .secondary)
|
||||
}
|
||||
XCTAssertEqual(ProcessColumn.compressed.content(for: item(compressed: 5_000_000)).text,
|
||||
formatBytes(Int64(5_000_000)))
|
||||
XCTAssertNotEqual(ProcessColumn.disk.content(for: item(diskRead: 2048)).text, "—")
|
||||
}
|
||||
|
||||
func testNameShowsAppIconOrPlaceholderSymbol() {
|
||||
let icon = NSImage(size: NSSize(width: 16, height: 16))
|
||||
XCTAssertEqual(ProcessColumn.name.content(for: item(icon: icon)).leading, .icon(icon))
|
||||
XCTAssertEqual(ProcessColumn.name.content(for: item()).leading, .symbol("app.dashed", .secondary))
|
||||
// Icons compare by identity, so an equal-looking copy still counts as a change.
|
||||
XCTAssertNotEqual(ProcessCellContent.Leading.icon(icon), .icon(NSImage(size: icon.size)))
|
||||
}
|
||||
|
||||
func testStateShowsTintedSymbol() {
|
||||
let running = ProcessColumn.state.content(for: item(state: .running))
|
||||
XCTAssertEqual(running.text, "Running")
|
||||
XCTAssertEqual(running.leading, .symbol(ProcessState.running.symbol, .green))
|
||||
XCTAssertEqual(ProcessColumn.tone(for: .zombie), .red)
|
||||
XCTAssertEqual(ProcessColumn.tone(for: .sleeping), .secondary)
|
||||
}
|
||||
|
||||
// MARK: Update detection
|
||||
|
||||
func testRowsDifferDetectsValueOrderAndIdentityChanges() {
|
||||
let a = item(pid: 1), b = item(pid: 2)
|
||||
let differ = ProcessTableView.Coordinator.rowsDiffer
|
||||
XCTAssertFalse(differ([a, b], [a, b]))
|
||||
XCTAssertTrue(differ([a, b], [b, a])) // re-sorted
|
||||
XCTAssertTrue(differ([a, b], [a])) // process exited
|
||||
XCTAssertTrue(differ([a, b], [a, item(pid: 2, cpu: 9)])) // figure changed
|
||||
XCTAssertTrue(differ([a, b], [a, item(pid: 2, name: "new")])) // PID reused
|
||||
XCTAssertTrue(differ([a, b], [a, item(pid: 2, start: Date(timeIntervalSince1970: 2_000))]))
|
||||
}
|
||||
}
|
||||
|
||||
final class ProcessFilterSortTests: XCTestCase {
|
||||
|
||||
private func p(_ pid: pid_t, _ name: String, cpu: Double = 0, memory: Int64 = 0, compressed: Int64 = 0) -> ProcessItem {
|
||||
ProcessItem(pid: pid, name: name, user: "ariel", cpuUsage: cpu, cpuUsageTotal: cpu / 8, memoryUsage: memory,
|
||||
threadCount: 1, state: .sleeping, icon: nil, parentPid: 1, startTime: nil,
|
||||
compressedMemory: compressed)
|
||||
}
|
||||
|
||||
private lazy var sample = [
|
||||
p(1, "launchd"),
|
||||
p(300, "Finder", cpu: 12, memory: 200_000_000, compressed: 50_000_000),
|
||||
p(200, "Teams", cpu: 3, memory: 900_000_000, compressed: 800_000_000),
|
||||
p(400, "zsh", cpu: 40, memory: 5_000_000),
|
||||
]
|
||||
|
||||
private func pids(_ column: ProcessSortColumn, ascending: Bool, search: String = "") -> [pid_t] {
|
||||
ProcessView.filterAndSort(sample, search: search, by: column, ascending: ascending).map(\.pid)
|
||||
}
|
||||
|
||||
func testFigureColumnsSortBothWays() {
|
||||
XCTAssertEqual(pids(.compressed, ascending: false), [200, 300, 1, 400]) // ties fall back to PID
|
||||
XCTAssertEqual(pids(.compressed, ascending: true), [1, 400, 300, 200])
|
||||
XCTAssertEqual(pids(.memory, ascending: false), [200, 300, 400, 1])
|
||||
XCTAssertEqual(pids(.cpu, ascending: false), [400, 300, 200, 1])
|
||||
}
|
||||
|
||||
func testNameSortIsCaseInsensitive() {
|
||||
XCTAssertEqual(pids(.name, ascending: true), [300, 1, 200, 400])
|
||||
XCTAssertEqual(pids(.name, ascending: false), [400, 200, 1, 300])
|
||||
}
|
||||
|
||||
func testSearchMatchesNameOrPid() {
|
||||
XCTAssertEqual(pids(.pid, ascending: true, search: "fin"), [300])
|
||||
XCTAssertEqual(pids(.pid, ascending: true, search: "40"), [400])
|
||||
XCTAssertEqual(pids(.pid, ascending: true, search: ""), [1, 200, 300, 400])
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives the real coordinator against a real (window-less) NSTableView.
|
||||
final class ProcessTableCoordinatorTests: XCTestCase {
|
||||
|
||||
private final class Harness {
|
||||
var selection: Set<pid_t> = []
|
||||
var sort: (ProcessSortColumn, Bool) = (.cpu, false)
|
||||
var opened: [pid_t] = []
|
||||
var terminated: [pid_t] = []
|
||||
var deleteKeyPresses = 0
|
||||
let table = ProcessNSTableView()
|
||||
var coordinator: ProcessTableView.Coordinator!
|
||||
|
||||
func view(_ rows: [ProcessItem]) -> ProcessTableView {
|
||||
ProcessTableView(
|
||||
processes: rows,
|
||||
selection: Binding(get: { self.selection }, set: { self.selection = $0 }),
|
||||
sortColumn: sort.0, sortAscending: sort.1,
|
||||
onSort: { self.sort = ($0, $1) },
|
||||
actions: ProcessTableActions(open: { self.opened.append($0.pid) },
|
||||
terminate: { self.terminated.append($0.pid) },
|
||||
forceKill: { _ in }, suspend: { _ in }, resume: { _ in },
|
||||
deleteKey: { self.deleteKeyPresses += 1 }))
|
||||
}
|
||||
|
||||
/// Mirrors makeNSView + updateNSView.
|
||||
func show(_ rows: [ProcessItem]) {
|
||||
let view = view(rows)
|
||||
if coordinator == nil {
|
||||
coordinator = view.makeCoordinator()
|
||||
for column in ProcessColumn.allCases {
|
||||
table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier(column.rawValue)))
|
||||
}
|
||||
table.dataSource = coordinator
|
||||
table.delegate = coordinator
|
||||
table.onDelete = { self.deleteKeyPresses += 1 }
|
||||
coordinator.tableView = table
|
||||
}
|
||||
coordinator.parent = view
|
||||
coordinator.update()
|
||||
}
|
||||
}
|
||||
|
||||
private func item(_ pid: pid_t, _ name: String) -> ProcessItem {
|
||||
ProcessItem(pid: pid, name: name, user: "ariel", cpuUsage: 0, cpuUsageTotal: 0, memoryUsage: 0,
|
||||
threadCount: 1, state: .sleeping, icon: nil, parentPid: 1, startTime: nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSelectionFollowsTheProcessWhenRowsReorder() {
|
||||
let h = Harness()
|
||||
let a = item(10, "a"), b = item(20, "b"), c = item(30, "c")
|
||||
h.show([a, b, c])
|
||||
XCTAssertEqual(h.table.numberOfRows, 3, "first load must not double the row count")
|
||||
|
||||
h.table.selectRowIndexes([1], byExtendingSelection: false) // user clicks "b"
|
||||
XCTAssertEqual(h.selection, [20])
|
||||
|
||||
h.show([c, a, b]) // re-sorted by a refresh
|
||||
XCTAssertEqual(h.table.selectedRowIndexes, [2])
|
||||
XCTAssertEqual(h.selection, [20], "syncing the table must not write back into the binding")
|
||||
|
||||
h.show([c, a]) // "b" exited
|
||||
XCTAssertEqual(h.table.numberOfRows, 2)
|
||||
XCTAssertTrue(h.table.selectedRowIndexes.isEmpty)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testHeaderClickReportsNextSort() {
|
||||
let h = Harness()
|
||||
h.show([item(1, "a")])
|
||||
let memory = h.table.tableColumn(withIdentifier: NSUserInterfaceItemIdentifier("memory"))!
|
||||
h.coordinator.tableView(h.table, didClick: memory)
|
||||
XCTAssertTrue(h.sort == (.memory, false))
|
||||
h.show([item(1, "a")]) // SwiftUI re-renders with the new sort
|
||||
h.coordinator.tableView(h.table, didClick: memory)
|
||||
XCTAssertTrue(h.sort == (.memory, true))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testContextMenuUsesSelectedRowWithoutRightClick() throws {
|
||||
let h = Harness()
|
||||
h.show([item(10, "a"), item(20, "b")])
|
||||
|
||||
let empty = NSMenu()
|
||||
h.coordinator.menuNeedsUpdate(empty)
|
||||
XCTAssertTrue(empty.items.isEmpty, "no click and no selection: nothing to act on")
|
||||
|
||||
h.table.selectRowIndexes([1], byExtendingSelection: false)
|
||||
let menu = NSMenu()
|
||||
h.coordinator.menuNeedsUpdate(menu)
|
||||
XCTAssertEqual(menu.items.filter { !$0.isSeparatorItem }.map(\.title),
|
||||
["Get Info (⌘I)", "Terminate (⌫)", "Force Kill (⌘⌫)", "Suspend", "Resume", "Copy PID"])
|
||||
|
||||
for title in ["Get Info (⌘I)", "Terminate (⌫)"] {
|
||||
let entry = try XCTUnwrap(menu.items.first { $0.title == title })
|
||||
_ = (entry.target as? NSObject)?.perform(entry.action, with: entry)
|
||||
}
|
||||
XCTAssertEqual(h.opened, [20])
|
||||
XCTAssertEqual(h.terminated, [20])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testDeleteKeyTriggersTerminateButCommandDeleteDoesNot() throws {
|
||||
let h = Harness()
|
||||
h.show([item(1, "a")])
|
||||
func key(_ code: UInt16, _ flags: NSEvent.ModifierFlags) throws -> NSEvent {
|
||||
try XCTUnwrap(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
|
||||
windowNumber: 0, context: nil, characters: "\u{8}",
|
||||
charactersIgnoringModifiers: "\u{8}", isARepeat: false, keyCode: code))
|
||||
}
|
||||
h.table.keyDown(with: try key(51, []))
|
||||
h.table.keyDown(with: try key(117, [.function])) // forward delete
|
||||
XCTAssertEqual(h.deleteKeyPresses, 2)
|
||||
h.table.keyDown(with: try key(51, [.command])) // ⌘⌫ belongs to Force Kill
|
||||
XCTAssertEqual(h.deleteKeyPresses, 2)
|
||||
}
|
||||
}
|
||||
27
TopManagerTests/SettingsTests.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import XCTest
|
||||
@testable import TopManager
|
||||
|
||||
final class SettingsTests: XCTestCase {
|
||||
|
||||
func testClampIntervalBounds() {
|
||||
XCTAssertEqual(AppSettings.clampInterval(0.2), 1)
|
||||
XCTAssertEqual(AppSettings.clampInterval(100), 30)
|
||||
XCTAssertEqual(AppSettings.clampInterval(5), 5)
|
||||
XCTAssertEqual(AppSettings.clampInterval(1), 1)
|
||||
XCTAssertEqual(AppSettings.clampInterval(30), 30)
|
||||
}
|
||||
|
||||
func testMenuBarMetricRoundTrip() {
|
||||
for metric in MenuBarMetric.allCases {
|
||||
XCTAssertEqual(MenuBarMetric(rawValue: metric.rawValue), metric)
|
||||
}
|
||||
XCTAssertNil(MenuBarMetric(rawValue: "nonexistent"))
|
||||
}
|
||||
|
||||
func testMenuBarMetricCoversKeyMetrics() {
|
||||
XCTAssertTrue(MenuBarMetric.allCases.contains(.cpu))
|
||||
XCTAssertTrue(MenuBarMetric.allCases.contains(.memory))
|
||||
XCTAssertTrue(MenuBarMetric.allCases.contains(.health))
|
||||
XCTAssertTrue(MenuBarMetric.allCases.contains(.download))
|
||||
}
|
||||
}
|
||||
71
TopManagerTests/ThermalMathTests.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import XCTest
|
||||
@testable import TopManager
|
||||
|
||||
final class ThermalMathTests: XCTestCase {
|
||||
|
||||
// Names as an M3 reports them (PMU / PMU2 dies, package, calibration, NAND, battery).
|
||||
private let m3: [TemperatureReading] = [
|
||||
.init(name: "PMU tdie1", celsius: 48.8),
|
||||
.init(name: "PMU tdie2", celsius: 46.4),
|
||||
.init(name: "PMU2 tdie1", celsius: 43.4),
|
||||
.init(name: "PMU tdev2", celsius: 36.2),
|
||||
.init(name: "PMU tdev1", celsius: -1.5),
|
||||
.init(name: "PMU tcal", celsius: 51.9),
|
||||
.init(name: "NAND CH0 temp", celsius: 34.0),
|
||||
.init(name: "gas gauge battery", celsius: 31.3),
|
||||
]
|
||||
|
||||
func testOnlyDieSensorsCount() {
|
||||
let info = ThermalMath.summarize(m3)
|
||||
XCTAssertEqual(info?.cpuSensorCount, 3)
|
||||
// tcal (51.9) is hotter than every die but is a calibration value, not the CPU.
|
||||
XCTAssertEqual(info?.cpuMax ?? 0, 48.8, accuracy: 0.001)
|
||||
XCTAssertEqual(info?.cpuAverage ?? 0, (48.8 + 46.4 + 43.4) / 3, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testM1ClusterSensorsCount() {
|
||||
let m1: [TemperatureReading] = [
|
||||
.init(name: "pACC MTR Temp Sensor0", celsius: 55),
|
||||
.init(name: "eACC MTR Temp Sensor1", celsius: 41),
|
||||
.init(name: "GPU MTR Temp Sensor1", celsius: 60),
|
||||
.init(name: "SOC MTR Temp Sensor0", celsius: 50),
|
||||
]
|
||||
let info = ThermalMath.summarize(m1)
|
||||
XCTAssertEqual(info?.cpuSensorCount, 2)
|
||||
XCTAssertEqual(info?.cpuMax ?? 0, 55, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testImplausibleDieReadingsAreDropped() {
|
||||
let readings: [TemperatureReading] = [
|
||||
.init(name: "PMU tdie1", celsius: 0),
|
||||
.init(name: "PMU tdie2", celsius: -2),
|
||||
.init(name: "PMU tdie3", celsius: 170),
|
||||
.init(name: "PMU tdie4", celsius: 44),
|
||||
]
|
||||
XCTAssertEqual(ThermalMath.summarize(readings)?.cpuSensorCount, 1)
|
||||
}
|
||||
|
||||
func testNoCPUSensorsMeansNil() {
|
||||
// An Intel Mac: no IOHID die sensors at all, or only non-CPU ones.
|
||||
XCTAssertNil(ThermalMath.summarize([]))
|
||||
XCTAssertNil(ThermalMath.summarize([.init(name: "gas gauge battery", celsius: 30)]))
|
||||
}
|
||||
|
||||
func testReadsRealSensorsOnAppleSilicon() throws {
|
||||
#if !arch(arm64)
|
||||
throw XCTSkip("Apple Silicon only: Intel Macs expose these sensors through the SMC")
|
||||
#else
|
||||
let info = try XCTUnwrap(TemperatureMonitor().fetchThermalInfo())
|
||||
XCTAssertGreaterThan(info.cpuSensorCount, 0)
|
||||
XCTAssertTrue((15...110).contains(info.cpuMax), "implausible CPU temperature \(info.cpuMax)")
|
||||
XCTAssertLessThanOrEqual(info.cpuAverage, info.cpuMax)
|
||||
#endif
|
||||
}
|
||||
|
||||
func testLevels() {
|
||||
XCTAssertEqual(ThermalMath.level(cpuCelsius: 48), .nominal)
|
||||
XCTAssertEqual(ThermalMath.level(cpuCelsius: 85), .fair)
|
||||
XCTAssertEqual(ThermalMath.level(cpuCelsius: 95), .serious)
|
||||
XCTAssertEqual(ThermalMath.level(cpuCelsius: 104), .critical)
|
||||
}
|
||||
}
|
||||
67
docs/TESTING.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# TopManager — Testing Process
|
||||
|
||||
This document defines the repeatable test process used while evolving TopManager.
|
||||
Every feature increment must pass all five gates below before it is considered done.
|
||||
|
||||
## Gate 1 — Compile
|
||||
```bash
|
||||
scripts/test.sh build
|
||||
```
|
||||
Fast feedback that the code compiles for `platform=macOS,arch=arm64`. First gate on every change.
|
||||
|
||||
## Gate 2 — Unit tests (automated, self-run)
|
||||
```bash
|
||||
scripts/test.sh test # or: xcodebuild ... test
|
||||
```
|
||||
Pure-logic is covered by XCTest in `TopManagerTests/` using `@testable import TopManager`.
|
||||
The suite intentionally targets deterministic logic (no live system state):
|
||||
|
||||
- **Formatting / model math** — bytes, rates, percentages, uptime, usage %.
|
||||
- **MetricsStore** — ring-buffer trim, persistence round-trip, downsampling per range.
|
||||
- **Energy heuristic** — score is monotonic in CPU and wakeups, clamped to 0–100.
|
||||
- **Alert engine** — threshold crossings fire/clear correctly, sustained-window logic,
|
||||
de-duplication (no alert storms), severity ordering.
|
||||
- **Health score** — 0–100, decreases with pressure/thermal/swap/disk load.
|
||||
- **Battery math** — time-remaining and capacity-health formatting.
|
||||
|
||||
Tests must stay hermetic: no sleeping on real timers, no asserting on live CPU numbers.
|
||||
|
||||
## Gate 3 — Runtime smoke test (self-run on this machine)
|
||||
Launch the built app, drive real load, confirm no crash and data populates.
|
||||
```bash
|
||||
# build a runnable .app
|
||||
xcodebuild -project TopManager.xcodeproj -scheme TopManager -configuration Debug \
|
||||
-destination 'platform=macOS,arch=arm64' build CODE_SIGNING_ALLOWED=NO
|
||||
APP=$(find ~/Library/Developer/Xcode/DerivedData/TopManager-*/Build/Products/Debug -name TopManager.app | head -1)
|
||||
open "$APP"
|
||||
# generate CPU load in the background, then verify TopManager surfaces it
|
||||
yes > /dev/null & LOAD=$!; sleep 8; kill $LOAD
|
||||
```
|
||||
Verify: process list shows the load generator near the top, charts move, no crash in
|
||||
`Console`/stderr, menu bar updates, alerts fire when thresholds are crossed.
|
||||
|
||||
## Gate 4 — Ground-truth cross-checks
|
||||
TopManager's numbers are validated against OS tools so we don't ship wrong data:
|
||||
|
||||
| TopManager metric | Ground truth command |
|
||||
|------------------------|---------------------------------------------|
|
||||
| Per-process CPU/mem | `ps -Ao pid,pcpu,pmem,comm -r | head` |
|
||||
| System memory | `vm_stat`, `sysctl vm.swapusage` |
|
||||
| Disk volumes / free | `df -h` |
|
||||
| Battery / power | `pmset -g batt`, `ioreg -rn AppleSmartBattery` |
|
||||
| Thermal state | `pmset -g therm` |
|
||||
| Network throughput | `netstat -ib` |
|
||||
|
||||
Small deltas are expected (sampling windows differ); order-of-magnitude or ranking
|
||||
mismatches are bugs.
|
||||
|
||||
## Gate 5 — Regression: original features intact
|
||||
Manually confirm each shipped feature still works: Processes (sort/search/kill/suspend/
|
||||
resume), Apps (activate/hide/quit/force-quit/copy bundle id), Performance (CPU/Mem/Net
|
||||
charts), Power & Storage (system/GPU/storage/network), and the menu-bar readout.
|
||||
|
||||
## Loop
|
||||
```
|
||||
implement slice → Gate 1 → Gate 2 → Gate 3 → Gate 4 → Gate 5 → checkpoint commit
|
||||
```
|
||||
Repeat per increment until the value rubric in `docs/VALUE_PLAN.md` is met.
|
||||
38
docs/VALUE_PLAN.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# TopManager — 10× Value Plan
|
||||
|
||||
## Thesis
|
||||
TopManager today is a polished, **read-only Activity Monitor clone**: it *shows* numbers.
|
||||
Its value ceiling is "I can see what's using my Mac and kill a process."
|
||||
|
||||
To make it ~10× more valuable we move it from **viewer → system-intelligence & control
|
||||
center**: it watches the system for you, remembers its history, explains problems, exposes
|
||||
data Activity Monitor hides, and lets you act — including always-on from the menu bar.
|
||||
|
||||
All existing features are preserved (several are improved).
|
||||
|
||||
## Value rubric (capability scoring)
|
||||
| Dimension | Before | After (target) |
|
||||
|---|---|---|
|
||||
| Per-process depth | CPU, mem, threads, state | + disk I/O rate, energy impact, args, open files, full path, tree |
|
||||
| Time memory | 60 in-RAM points, lost on quit | persistent history, minutes → 24h, survives restart |
|
||||
| Proactivity | none | threshold alerts + native notifications + health score + diagnosis |
|
||||
| Energy / battery | **absent** | battery health (cycles/condition/capacity) + per-process energy |
|
||||
| Always-on surface | bare "12%" | rich menu-bar HUD: CPU/RAM/net + top consumers + quick-kill |
|
||||
| Customization | none (all hardcoded) | Settings: refresh rate, menu metric, thresholds, launch-at-login |
|
||||
|
||||
## Increments
|
||||
0. **Test harness** — XCTest target, `@testable import`, 5-gate process. ✅
|
||||
1. **Persistent history** — `MetricsStore` ring buffer on disk; 1m/5m/1h/24h charts.
|
||||
2. **Deep per-process data** — disk I/O, energy impact, args, open files, path; surface the
|
||||
(currently unused) `ProcessDetailView` as a real inspector; new sortable columns.
|
||||
3. **Battery + energy** — IOKit `PowerMonitor`; Energy/Battery UI; menu-bar battery.
|
||||
4. **Alerts + health** — `AlertEngine`, `UserNotifications`, in-app inbox, 0–100 health score,
|
||||
"why is my Mac slow?" diagnosis.
|
||||
5. **Settings + rich menu bar** — configurable refresh/metric/thresholds, launch-at-login
|
||||
(`SMAppService`), menu-bar HUD with top consumers + quick actions.
|
||||
6. **Verification** — full test run, runtime smoke, ground-truth cross-checks, sign, commit.
|
||||
|
||||
## Non-negotiables
|
||||
- Never break a shipped feature (regression Gate 5).
|
||||
- No new entitlements / no root / no privileged helper — must run & sign as-is.
|
||||
- Every increment passes all five gates in `docs/TESTING.md` before moving on.
|
||||
42
project.yml
@@ -12,7 +12,7 @@ settings:
|
||||
SWIFT_VERSION: "5.9"
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
ENABLE_HARDENED_RUNTIME: YES
|
||||
DEVELOPMENT_TEAM: ""
|
||||
DEVELOPMENT_TEAM: "P3663392R2"
|
||||
|
||||
targets:
|
||||
TopManager:
|
||||
@@ -31,7 +31,43 @@ targets:
|
||||
GENERATE_INFOPLIST_FILE: NO
|
||||
SWIFT_EMIT_LOC_STRINGS: YES
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
# The app version lives here. `xcodegen generate` rewrites Info.plist from
|
||||
# the `info` block below, so a version typed into Info.plist gets reset.
|
||||
MARKETING_VERSION: "1.2.0"
|
||||
CURRENT_PROJECT_VERSION: "4"
|
||||
info:
|
||||
path: TopManager/Info.plist
|
||||
entitlements:
|
||||
path: TopManager/TopManager.entitlements
|
||||
properties:
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
|
||||
TopManagerTests:
|
||||
type: bundle.unit-test
|
||||
platform: macOS
|
||||
sources:
|
||||
- path: TopManagerTests
|
||||
excludes:
|
||||
- "**/.DS_Store"
|
||||
dependencies:
|
||||
- target: TopManager
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.topmanager.TopManagerTests
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/TopManager.app/Contents/MacOS/TopManager"
|
||||
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
|
||||
schemes:
|
||||
TopManager:
|
||||
build:
|
||||
targets:
|
||||
TopManager: all
|
||||
TopManagerTests: [test]
|
||||
run:
|
||||
config: Debug
|
||||
test:
|
||||
config: Debug
|
||||
targets:
|
||||
- TopManagerTests
|
||||
gatherCoverageData: false
|
||||
|
||||
25
scripts/test.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fast build+test loop driver for TopManager.
|
||||
# Usage:
|
||||
# scripts/test.sh build # compile only (fast gate)
|
||||
# scripts/test.sh test # compile + run unit tests
|
||||
# scripts/test.sh # same as "test"
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
ACTION="${1:-test}"
|
||||
DEST='platform=macOS,arch=arm64'
|
||||
COMMON=(-project TopManager.xcodeproj -scheme TopManager -configuration Debug -destination "$DEST" CODE_SIGNING_ALLOWED=NO)
|
||||
|
||||
case "$ACTION" in
|
||||
build)
|
||||
xcodebuild "${COMMON[@]}" build 2>&1 | tail -20
|
||||
;;
|
||||
test)
|
||||
xcodebuild "${COMMON[@]}" test 2>&1 \
|
||||
| grep -E "Test Suite|Executed|error:|warning:.*unused|BUILD|TEST (SUCCEEDED|FAILED)|failed \(" \
|
||||
| tail -40
|
||||
;;
|
||||
*)
|
||||
echo "unknown action: $ACTION" >&2; exit 2 ;;
|
||||
esac
|
||||