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

|
||||

|
||||

|
||||

|
||||
|
||||
## Download
|
||||
|
||||
**[⬇️ Download TopManager 1.1 (.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 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 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,137 @@
|
||||
|
||||
/* 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 */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
/* 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 */,
|
||||
8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
@@ -100,6 +147,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
36BB5A20B1330E54F2DCF357 /* TopManager */,
|
||||
D078BA97D4F498E5282AC5E0 /* TopManagerTests */,
|
||||
E6438D9AF8EDEFEDCB087B38 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -107,11 +155,14 @@
|
||||
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 */,
|
||||
);
|
||||
path = Models;
|
||||
@@ -141,6 +192,7 @@
|
||||
7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */,
|
||||
BBFADE83965AA863B775A910 /* NetworkChartView.swift */,
|
||||
F80A817DCDA254349DEA6F06 /* PerformanceView.swift */,
|
||||
9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */,
|
||||
);
|
||||
path = Performance;
|
||||
sourceTree = "<group>";
|
||||
@@ -153,6 +205,14 @@
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6901D0C7B3DA57547BCFAAFC /* Apps */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
813543165B39EBD213EB3BC7 /* AppsView.swift */,
|
||||
);
|
||||
path = Apps;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9C38088C52BDE6A2BA1CEF73 /* Utilities */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -166,7 +226,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 +245,33 @@
|
||||
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 */,
|
||||
01C19ABF49697A97F0C40378 /* SettingsTests.swift */,
|
||||
);
|
||||
path = TopManagerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E6438D9AF8EDEFEDCB087B38 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A3A3D0B1EB522C9975F71DA8 /* TopManager.app */,
|
||||
0F5A69E0EE7FDD44157911D7 /* TopManagerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -203,6 +288,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 +333,12 @@
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1500;
|
||||
TargetAttributes = {
|
||||
D28E798AA7BEAA0638DFD52E = {
|
||||
DevelopmentTeam = P3663392R2;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
F86DD5615146D759EB0D44EB = {
|
||||
DevelopmentTeam = "";
|
||||
DevelopmentTeam = P3663392R2;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
@@ -251,6 +358,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
F86DD5615146D759EB0D44EB /* TopManager */,
|
||||
D28E798AA7BEAA0638DFD52E /* TopManagerTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -268,11 +376,28 @@
|
||||
/* 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 */,
|
||||
8789C5F4A1AF4B1E8E434D71 /* SettingsTests.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 +408,44 @@
|
||||
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 */,
|
||||
0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */,
|
||||
B89695749D32F394DB459C15 /* SettingsView.swift in Sources */,
|
||||
DB3603F30B6F559BE66E4349 /* SystemMonitor.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 +453,14 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = TopManager/TopManager.entitlements;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TopManager/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.app;
|
||||
PRODUCT_NAME = TopManager;
|
||||
SDKROOT = macosx;
|
||||
@@ -360,7 +504,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 +534,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 = 3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TopManager/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.app;
|
||||
PRODUCT_NAME = TopManager;
|
||||
SDKROOT = macosx;
|
||||
@@ -445,7 +609,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 +632,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 +662,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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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,38 +152,31 @@ 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 {
|
||||
inUseSystemMemory = inUse
|
||||
} else if let inUse = props["Alloc system memory"] as? UInt64 {
|
||||
inUseSystemMemory = inUse
|
||||
} else if let inUse = props["inUseSystemMemory"] as? UInt64 {
|
||||
inUseSystemMemory = inUse
|
||||
}
|
||||
|
||||
// GPU utilization
|
||||
if let util = props["Device Utilization %"] as? Int {
|
||||
utilizationPercent = Double(util)
|
||||
} else if let util = props["GPU Activity(%)"] as? Int {
|
||||
utilizationPercent = Double(util)
|
||||
}
|
||||
|
||||
break
|
||||
// Try various keys for GPU memory on Apple Silicon
|
||||
if let inUse = props["In use system memory"] as? UInt64 {
|
||||
inUseSystemMemory = inUse
|
||||
} else if let inUse = props["Alloc system memory"] as? UInt64 {
|
||||
inUseSystemMemory = inUse
|
||||
} else if let inUse = props["inUseSystemMemory"] as? UInt64 {
|
||||
inUseSystemMemory = inUse
|
||||
}
|
||||
|
||||
// GPU utilization
|
||||
if let util = props["Device Utilization %"] as? Int {
|
||||
utilizationPercent = Double(util)
|
||||
} else if let util = props["GPU Activity(%)"] as? Int {
|
||||
utilizationPercent = Double(util)
|
||||
}
|
||||
|
||||
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
|
||||
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,15 @@ 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 lastError: String?
|
||||
|
||||
// History for charts
|
||||
@@ -21,6 +23,24 @@ 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 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 +48,7 @@ final class SystemMonitor: ObservableObject {
|
||||
private let diskMonitor = DiskMonitor()
|
||||
private let networkMonitor = NetworkMonitor()
|
||||
private let gpuMonitor = GPUMonitor()
|
||||
private let powerMonitor = PowerMonitor()
|
||||
|
||||
// Timer
|
||||
private var timer: Timer?
|
||||
@@ -45,6 +66,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 +86,37 @@ final class SystemMonitor: ObservableObject {
|
||||
let processData = self.processMonitor.fetchProcesses()
|
||||
let diskData = self.diskMonitor.fetchDiskInfo()
|
||||
let gpuData = self.gpuMonitor.fetchGPUInfo()
|
||||
let powerData = self.powerMonitor.fetchPowerInfo()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
@@ -98,84 +134,131 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private func updateUI(cpu: CPUInfo?, memory: MemoryInfo?, network: NetworkInfo?,
|
||||
processes: [ProcessItem]?, disk: DiskInfo?, gpu: GPUInfo?) {
|
||||
processes: [ProcessItem]?, disk: DiskInfo?, gpu: GPUInfo?,
|
||||
power: PowerInfo?) {
|
||||
if let info = cpu {
|
||||
cpuInfo = info
|
||||
let historyPoint = CPUHistoryPoint(
|
||||
live.cpuInfo = info
|
||||
live.cpuHistory.append(CPUHistoryPoint(
|
||||
timestamp: info.timestamp,
|
||||
usage: info.globalUsage,
|
||||
userUsage: info.userUsage,
|
||||
systemUsage: info.systemUsage
|
||||
)
|
||||
cpuHistory.append(historyPoint)
|
||||
if cpuHistory.count > historyLimit {
|
||||
cpuHistory.removeFirst()
|
||||
))
|
||||
if live.cpuHistory.count > historyLimit {
|
||||
live.cpuHistory.removeFirst()
|
||||
}
|
||||
|
||||
for core in info.coreUsages {
|
||||
let corePoint = CoreHistoryPoint(timestamp: info.timestamp, usage: core.usage)
|
||||
if coreHistories[core.id] == nil {
|
||||
coreHistories[core.id] = []
|
||||
}
|
||||
coreHistories[core.id]?.append(corePoint)
|
||||
if coreHistories[core.id]?.count ?? 0 > historyLimit {
|
||||
coreHistories[core.id]?.removeFirst()
|
||||
live.coreHistories[core.id, default: []].append(
|
||||
CoreHistoryPoint(timestamp: info.timestamp, usage: core.usage))
|
||||
if live.coreHistories[core.id, default: []].count > historyLimit {
|
||||
live.coreHistories[core.id]?.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let info = memory {
|
||||
memoryInfo = info
|
||||
let historyPoint = MemoryHistoryPoint(
|
||||
live.memoryInfo = info
|
||||
live.memoryHistory.append(MemoryHistoryPoint(
|
||||
timestamp: info.timestamp,
|
||||
usedMemory: info.usedMemory,
|
||||
totalMemory: info.totalMemory
|
||||
)
|
||||
memoryHistory.append(historyPoint)
|
||||
if memoryHistory.count > historyLimit {
|
||||
memoryHistory.removeFirst()
|
||||
))
|
||||
if live.memoryHistory.count > historyLimit {
|
||||
live.memoryHistory.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
if let info = network {
|
||||
networkInfo = info
|
||||
let historyPoint = NetworkHistoryPoint(
|
||||
live.networkInfo = info
|
||||
live.networkHistory.append(NetworkHistoryPoint(
|
||||
timestamp: info.timestamp,
|
||||
downloadRate: info.totalDownloadRate,
|
||||
uploadRate: info.totalUploadRate
|
||||
)
|
||||
networkHistory.append(historyPoint)
|
||||
if networkHistory.count > historyLimit {
|
||||
networkHistory.removeFirst()
|
||||
))
|
||||
if live.networkHistory.count > historyLimit {
|
||||
live.networkHistory.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
if let procs = processes {
|
||||
self.processes = procs
|
||||
if let procs = processes { live.processes = procs }
|
||||
if let info = disk { live.diskInfo = info }
|
||||
if let info = gpu { live.gpuInfo = info }
|
||||
if let info = power { live.powerInfo = info }
|
||||
|
||||
if 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
|
||||
))
|
||||
}
|
||||
|
||||
if let info = disk {
|
||||
diskInfo = info
|
||||
}
|
||||
// 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
|
||||
)
|
||||
}
|
||||
|
||||
if let info = gpu {
|
||||
gpuInfo = info
|
||||
/// Pause (`false`) or resume (`true`) publishing to views. Resuming catches
|
||||
/// the views up at once with everything recorded while they were hidden.
|
||||
@MainActor func setUIVisible(_ visible: Bool) {
|
||||
guard visible != isUIVisible else { return }
|
||||
isUIVisible = visible
|
||||
if visible { publishLive() }
|
||||
}
|
||||
|
||||
@MainActor private func publishLive() {
|
||||
cpuInfo = live.cpuInfo
|
||||
memoryInfo = live.memoryInfo
|
||||
processes = live.processes
|
||||
diskInfo = live.diskInfo
|
||||
networkInfo = live.networkInfo
|
||||
gpuInfo = live.gpuInfo
|
||||
powerInfo = live.powerInfo
|
||||
cpuHistory = live.cpuHistory
|
||||
coreHistories = live.coreHistories
|
||||
memoryHistory = live.memoryHistory
|
||||
networkHistory = live.networkHistory
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
}
|
||||
}
|
||||
|
||||
CPULineChart(history: monitor.cpuHistory)
|
||||
.frame(height: 120)
|
||||
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 {
|
||||
}
|
||||
}
|
||||
|
||||
MemoryLineChart(history: monitor.memoryHistory)
|
||||
.frame(height: 150)
|
||||
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 {
|
||||
}
|
||||
}
|
||||
|
||||
DownloadLineChart(history: monitor.networkHistory)
|
||||
.frame(height: 150)
|
||||
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 {
|
||||
}
|
||||
}
|
||||
|
||||
UploadLineChart(history: monitor.networkHistory)
|
||||
.frame(height: 150)
|
||||
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)
|
||||
@@ -141,6 +144,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) {
|
||||
@@ -184,6 +192,109 @@ struct PowerStorageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct BatteryStatusView: View {
|
||||
let power: PowerInfo
|
||||
|
||||
var body: some View {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Label("Battery & Power", systemImage: batteryIcon)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: power.isPluggedIn ? "powerplug.fill" : "battery.100")
|
||||
.foregroundColor(.secondary)
|
||||
Text(power.powerSourceLabel)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if power.hasBattery {
|
||||
HStack {
|
||||
Text("Charge:")
|
||||
Spacer()
|
||||
if power.isCharging {
|
||||
Image(systemName: "bolt.fill").foregroundColor(.yellow)
|
||||
}
|
||||
Text("\(power.currentCharge)%").monospacedDigit()
|
||||
}
|
||||
ProgressView(value: Double(power.currentCharge), total: 100)
|
||||
.tint(chargeColor)
|
||||
|
||||
row("Condition") {
|
||||
Text(power.condition.rawValue)
|
||||
.foregroundColor(conditionColor)
|
||||
}
|
||||
|
||||
if let health = power.healthPercent {
|
||||
row("Health (max/design)") {
|
||||
Text(String(format: "%.0f%%", health)).monospacedDigit()
|
||||
}
|
||||
}
|
||||
if let cycles = power.cycleCount {
|
||||
row("Cycle Count") { Text("\(cycles)").monospacedDigit() }
|
||||
}
|
||||
if let temp = power.temperature {
|
||||
row("Temperature") { Text(String(format: "%.1f °C", temp)).monospacedDigit() }
|
||||
}
|
||||
if let watts = power.powerWatts {
|
||||
row(power.isCharging ? "Charge Power" : "Power Draw") {
|
||||
Text(String(format: "%.1f W", abs(watts))).monospacedDigit()
|
||||
}
|
||||
}
|
||||
if let toEmpty = power.timeToEmpty {
|
||||
row("Time Remaining") { Text(BatteryMath.formatMinutes(toEmpty)).monospacedDigit() }
|
||||
}
|
||||
if let toFull = power.timeToFull {
|
||||
row("Time to Full") { Text(BatteryMath.formatMinutes(toFull)).monospacedDigit() }
|
||||
}
|
||||
if let adapter = power.adapterWatts, adapter > 0 {
|
||||
row("Power Adapter") { Text("\(adapter) W").monospacedDigit() }
|
||||
}
|
||||
} else {
|
||||
Text("No battery detected — running on AC power.")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private func row<Content: View>(_ label: String, @ViewBuilder value: () -> Content) -> some View {
|
||||
HStack {
|
||||
Text(label + ":")
|
||||
Spacer()
|
||||
value()
|
||||
}
|
||||
}
|
||||
|
||||
private var batteryIcon: String {
|
||||
if !power.hasBattery { return "powerplug" }
|
||||
if power.isCharging { return "battery.100.bolt" }
|
||||
switch power.currentCharge {
|
||||
case ..<15: return "battery.25"
|
||||
case ..<50: return "battery.50"
|
||||
case ..<85: return "battery.75"
|
||||
default: return "battery.100"
|
||||
}
|
||||
}
|
||||
|
||||
private var chargeColor: Color {
|
||||
if power.currentCharge < 15 { return .red }
|
||||
if power.currentCharge < 30 { return .orange }
|
||||
return .green
|
||||
}
|
||||
|
||||
private var conditionColor: Color {
|
||||
switch power.condition {
|
||||
case .normal: return .green
|
||||
case .serviceRecommended: return .orange
|
||||
case .unknown: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ThermalStateView: View {
|
||||
let state: ProcessInfo.ThermalState
|
||||
|
||||
|
||||
@@ -1,63 +1,160 @@
|
||||
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
|
||||
HStack(spacing: 12) {
|
||||
if let icon = process.icon {
|
||||
Image(nsImage: icon)
|
||||
.resizable()
|
||||
.frame(width: 48, height: 48)
|
||||
} else {
|
||||
Image(systemName: "app.dashed")
|
||||
.font(.system(size: 48))
|
||||
}
|
||||
header
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(process.name)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
Divider()
|
||||
|
||||
Text("PID: \(process.pid)")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing) {
|
||||
HStack {
|
||||
Image(systemName: process.state.symbol)
|
||||
Text(process.state.rawValue)
|
||||
}
|
||||
.foregroundColor(stateColor(process.state))
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
liveStats
|
||||
memoryExplanation
|
||||
pathSection
|
||||
detailsSection
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Details grid
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible())
|
||||
], alignment: .leading, spacing: 12) {
|
||||
DetailRow(label: "CPU Usage", value: String(format: "%.1f%%", process.cpuUsage))
|
||||
DetailRow(label: "Memory", value: formatBytes(process.memoryUsage))
|
||||
DetailRow(label: "Threads", value: "\(process.threadCount)")
|
||||
DetailRow(label: "User", value: process.user)
|
||||
DetailRow(label: "Parent PID", value: "\(process.parentPid)")
|
||||
|
||||
if let startTime = process.startTime {
|
||||
DetailRow(label: "Started", value: formatDate(startTime))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
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: 44, height: 44)
|
||||
} else {
|
||||
Image(systemName: "app.dashed").font(.system(size: 40)).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()
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: process.state.symbol)
|
||||
Text(process.state.rawValue)
|
||||
}
|
||||
.foregroundColor(stateColor(process.state))
|
||||
}
|
||||
}
|
||||
|
||||
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: "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 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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -30,6 +30,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"]
|
||||
|
||||
@@ -57,12 +60,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
|
||||
@@ -80,6 +91,34 @@ struct ProcessView: View {
|
||||
return monitor.processes.first { $0.pid == pid }
|
||||
}
|
||||
|
||||
/// Memory, RAM and Compressed as a single builder element: Table accepts at
|
||||
/// most 10 columns per block, and inlining them also exceeds the type
|
||||
/// checker's time limit for the table expression.
|
||||
@TableColumnBuilder<ProcessItem, KeyPathComparator<ProcessItem>>
|
||||
private var memoryColumns: some TableColumnContent<ProcessItem, KeyPathComparator<ProcessItem>> {
|
||||
TableColumn("Memory", value: \.memoryUsage) { process in
|
||||
Text(formatBytes(process.memoryUsage))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(80)
|
||||
|
||||
TableColumn("RAM", value: \.residentMemory) { process in
|
||||
Text(formatBytes(process.residentMemory))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(80)
|
||||
|
||||
TableColumn("Compressed", value: \.compressedMemory) { process in
|
||||
if process.compressedMemory > 0 {
|
||||
Text(formatBytes(process.compressedMemory))
|
||||
.monospacedDigit()
|
||||
} else {
|
||||
Text("—").foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.width(85)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Summary bar - separate view to avoid re-rendering table
|
||||
@@ -89,23 +128,22 @@ struct ProcessView: View {
|
||||
|
||||
// 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)
|
||||
TableColumn("Name", value: \.name) { process in
|
||||
HStack(spacing: 6) {
|
||||
if let icon = process.icon {
|
||||
Image(nsImage: icon)
|
||||
.resizable()
|
||||
.frame(width: 16, height: 16)
|
||||
} else {
|
||||
Image(systemName: "app.dashed")
|
||||
.frame(width: 16, height: 16)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text(process.name)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.width(24)
|
||||
|
||||
TableColumn("Name", value: \.name) { process in
|
||||
Text(process.name)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.width(min: 150, ideal: 200)
|
||||
.width(min: 170, ideal: 220)
|
||||
|
||||
TableColumn("PID", value: \.pid) { process in
|
||||
Text("\(process.pid)")
|
||||
@@ -127,11 +165,24 @@ struct ProcessView: View {
|
||||
}
|
||||
.width(70)
|
||||
|
||||
TableColumn("Memory", value: \.memoryUsage) { process in
|
||||
Text(formatBytes(process.memoryUsage))
|
||||
TableColumn("Energy", value: \.energyImpact) { process in
|
||||
Text(String(format: "%.1f", process.energyImpact))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(energyColor(process.energyImpact))
|
||||
}
|
||||
.width(80)
|
||||
.width(60)
|
||||
|
||||
memoryColumns
|
||||
|
||||
TableColumn("Disk I/O", value: \.diskTotalRate) { process in
|
||||
if process.diskTotalRate > 0 {
|
||||
Text(formatBytesPerSecond(process.diskTotalRate))
|
||||
.monospacedDigit()
|
||||
} else {
|
||||
Text("—").foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.width(90)
|
||||
|
||||
TableColumn("Threads", value: \.threadCount) { process in
|
||||
Text("\(process.threadCount)")
|
||||
@@ -157,6 +208,10 @@ struct ProcessView: View {
|
||||
.contextMenu(forSelectionType: ProcessItem.ID.self) { selection in
|
||||
if let pid = selection.first,
|
||||
let process = monitor.processes.first(where: { $0.pid == pid }) {
|
||||
Button("Get Info (⌘I)") {
|
||||
inspectorProcess = process
|
||||
}
|
||||
Divider()
|
||||
Button("Terminate (⌫)") {
|
||||
initiateTerminate()
|
||||
}
|
||||
@@ -177,7 +232,11 @@ struct ProcessView: View {
|
||||
}
|
||||
}
|
||||
} primaryAction: { selection in
|
||||
// Double-click action
|
||||
// Double-click opens the deep-dive inspector
|
||||
if let pid = selection.first,
|
||||
let process = monitor.processes.first(where: { $0.pid == pid }) {
|
||||
inspectorProcess = process
|
||||
}
|
||||
}
|
||||
.onDeleteCommand {
|
||||
initiateTerminate()
|
||||
@@ -211,6 +270,14 @@ struct ProcessView: View {
|
||||
sortColumn = .cpuTotal
|
||||
} else if keyPathString.contains("cpuUsage") {
|
||||
sortColumn = .cpu
|
||||
} else if keyPathString.contains("energyImpact") {
|
||||
sortColumn = .energy
|
||||
} else if keyPathString.contains("diskTotalRate") {
|
||||
sortColumn = .disk
|
||||
} else if keyPathString.contains("residentMemory") {
|
||||
sortColumn = .resident
|
||||
} else if keyPathString.contains("compressedMemory") {
|
||||
sortColumn = .compressed
|
||||
} else if keyPathString.contains("memoryUsage") {
|
||||
sortColumn = .memory
|
||||
} else if keyPathString.contains("name") {
|
||||
@@ -229,7 +296,10 @@ struct ProcessView: View {
|
||||
}
|
||||
.background(
|
||||
KeyboardShortcutHandler(
|
||||
onCommandBackspace: { initiateForceKill() }
|
||||
onCommandBackspace: { initiateForceKill() },
|
||||
onCommandI: {
|
||||
if let process = selectedProcessItem { inspectorProcess = process }
|
||||
}
|
||||
)
|
||||
)
|
||||
.sheet(isPresented: $showTerminateConfirm) {
|
||||
@@ -268,6 +338,10 @@ struct ProcessView: View {
|
||||
} message: {
|
||||
Text(errorMessage)
|
||||
}
|
||||
.sheet(item: $inspectorProcess) { process in
|
||||
ProcessInspectorSheet(initialProcess: process)
|
||||
.environmentObject(monitor)
|
||||
}
|
||||
}
|
||||
|
||||
private func initiateTerminate() {
|
||||
@@ -350,6 +424,17 @@ struct ProcessView: View {
|
||||
return .primary
|
||||
}
|
||||
|
||||
private func energyColor(_ impact: Double) -> Color {
|
||||
if impact > 50 {
|
||||
return .red
|
||||
} else if impact > 20 {
|
||||
return .orange
|
||||
} else if impact > 8 {
|
||||
return .yellow
|
||||
}
|
||||
return .primary
|
||||
}
|
||||
|
||||
private func stateColor(_ state: ProcessState) -> Color {
|
||||
switch state {
|
||||
case .running: return .green
|
||||
@@ -441,28 +526,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))
|
||||
}
|
||||
}
|
||||
27
TopManagerTests/SettingsTests.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import XCTest
|
||||
@testable import TopManager
|
||||
|
||||
final class SettingsTests: XCTestCase {
|
||||
|
||||
func testClampIntervalBounds() {
|
||||
XCTAssertEqual(AppSettings.clampInterval(0.2), 1)
|
||||
XCTAssertEqual(AppSettings.clampInterval(100), 30)
|
||||
XCTAssertEqual(AppSettings.clampInterval(5), 5)
|
||||
XCTAssertEqual(AppSettings.clampInterval(1), 1)
|
||||
XCTAssertEqual(AppSettings.clampInterval(30), 30)
|
||||
}
|
||||
|
||||
func testMenuBarMetricRoundTrip() {
|
||||
for metric in MenuBarMetric.allCases {
|
||||
XCTAssertEqual(MenuBarMetric(rawValue: metric.rawValue), metric)
|
||||
}
|
||||
XCTAssertNil(MenuBarMetric(rawValue: "nonexistent"))
|
||||
}
|
||||
|
||||
func testMenuBarMetricCoversKeyMetrics() {
|
||||
XCTAssertTrue(MenuBarMetric.allCases.contains(.cpu))
|
||||
XCTAssertTrue(MenuBarMetric.allCases.contains(.memory))
|
||||
XCTAssertTrue(MenuBarMetric.allCases.contains(.health))
|
||||
XCTAssertTrue(MenuBarMetric.allCases.contains(.download))
|
||||
}
|
||||
}
|
||||
67
docs/TESTING.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# TopManager — Testing Process
|
||||
|
||||
This document defines the repeatable test process used while evolving TopManager.
|
||||
Every feature increment must pass all five gates below before it is considered done.
|
||||
|
||||
## Gate 1 — Compile
|
||||
```bash
|
||||
scripts/test.sh build
|
||||
```
|
||||
Fast feedback that the code compiles for `platform=macOS,arch=arm64`. First gate on every change.
|
||||
|
||||
## Gate 2 — Unit tests (automated, self-run)
|
||||
```bash
|
||||
scripts/test.sh test # or: xcodebuild ... test
|
||||
```
|
||||
Pure-logic is covered by XCTest in `TopManagerTests/` using `@testable import TopManager`.
|
||||
The suite intentionally targets deterministic logic (no live system state):
|
||||
|
||||
- **Formatting / model math** — bytes, rates, percentages, uptime, usage %.
|
||||
- **MetricsStore** — ring-buffer trim, persistence round-trip, downsampling per range.
|
||||
- **Energy heuristic** — score is monotonic in CPU and wakeups, clamped to 0–100.
|
||||
- **Alert engine** — threshold crossings fire/clear correctly, sustained-window logic,
|
||||
de-duplication (no alert storms), severity ordering.
|
||||
- **Health score** — 0–100, decreases with pressure/thermal/swap/disk load.
|
||||
- **Battery math** — time-remaining and capacity-health formatting.
|
||||
|
||||
Tests must stay hermetic: no sleeping on real timers, no asserting on live CPU numbers.
|
||||
|
||||
## Gate 3 — Runtime smoke test (self-run on this machine)
|
||||
Launch the built app, drive real load, confirm no crash and data populates.
|
||||
```bash
|
||||
# build a runnable .app
|
||||
xcodebuild -project TopManager.xcodeproj -scheme TopManager -configuration Debug \
|
||||
-destination 'platform=macOS,arch=arm64' build CODE_SIGNING_ALLOWED=NO
|
||||
APP=$(find ~/Library/Developer/Xcode/DerivedData/TopManager-*/Build/Products/Debug -name TopManager.app | head -1)
|
||||
open "$APP"
|
||||
# generate CPU load in the background, then verify TopManager surfaces it
|
||||
yes > /dev/null & LOAD=$!; sleep 8; kill $LOAD
|
||||
```
|
||||
Verify: process list shows the load generator near the top, charts move, no crash in
|
||||
`Console`/stderr, menu bar updates, alerts fire when thresholds are crossed.
|
||||
|
||||
## Gate 4 — Ground-truth cross-checks
|
||||
TopManager's numbers are validated against OS tools so we don't ship wrong data:
|
||||
|
||||
| TopManager metric | Ground truth command |
|
||||
|------------------------|---------------------------------------------|
|
||||
| Per-process CPU/mem | `ps -Ao pid,pcpu,pmem,comm -r | head` |
|
||||
| System memory | `vm_stat`, `sysctl vm.swapusage` |
|
||||
| Disk volumes / free | `df -h` |
|
||||
| Battery / power | `pmset -g batt`, `ioreg -rn AppleSmartBattery` |
|
||||
| Thermal state | `pmset -g therm` |
|
||||
| Network throughput | `netstat -ib` |
|
||||
|
||||
Small deltas are expected (sampling windows differ); order-of-magnitude or ranking
|
||||
mismatches are bugs.
|
||||
|
||||
## Gate 5 — Regression: original features intact
|
||||
Manually confirm each shipped feature still works: Processes (sort/search/kill/suspend/
|
||||
resume), Apps (activate/hide/quit/force-quit/copy bundle id), Performance (CPU/Mem/Net
|
||||
charts), Power & Storage (system/GPU/storage/network), and the menu-bar readout.
|
||||
|
||||
## Loop
|
||||
```
|
||||
implement slice → Gate 1 → Gate 2 → Gate 3 → Gate 4 → Gate 5 → checkpoint commit
|
||||
```
|
||||
Repeat per increment until the value rubric in `docs/VALUE_PLAN.md` is met.
|
||||
38
docs/VALUE_PLAN.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# TopManager — 10× Value Plan
|
||||
|
||||
## Thesis
|
||||
TopManager today is a polished, **read-only Activity Monitor clone**: it *shows* numbers.
|
||||
Its value ceiling is "I can see what's using my Mac and kill a process."
|
||||
|
||||
To make it ~10× more valuable we move it from **viewer → system-intelligence & control
|
||||
center**: it watches the system for you, remembers its history, explains problems, exposes
|
||||
data Activity Monitor hides, and lets you act — including always-on from the menu bar.
|
||||
|
||||
All existing features are preserved (several are improved).
|
||||
|
||||
## Value rubric (capability scoring)
|
||||
| Dimension | Before | After (target) |
|
||||
|---|---|---|
|
||||
| Per-process depth | CPU, mem, threads, state | + disk I/O rate, energy impact, args, open files, full path, tree |
|
||||
| Time memory | 60 in-RAM points, lost on quit | persistent history, minutes → 24h, survives restart |
|
||||
| Proactivity | none | threshold alerts + native notifications + health score + diagnosis |
|
||||
| Energy / battery | **absent** | battery health (cycles/condition/capacity) + per-process energy |
|
||||
| Always-on surface | bare "12%" | rich menu-bar HUD: CPU/RAM/net + top consumers + quick-kill |
|
||||
| Customization | none (all hardcoded) | Settings: refresh rate, menu metric, thresholds, launch-at-login |
|
||||
|
||||
## Increments
|
||||
0. **Test harness** — XCTest target, `@testable import`, 5-gate process. ✅
|
||||
1. **Persistent history** — `MetricsStore` ring buffer on disk; 1m/5m/1h/24h charts.
|
||||
2. **Deep per-process data** — disk I/O, energy impact, args, open files, path; surface the
|
||||
(currently unused) `ProcessDetailView` as a real inspector; new sortable columns.
|
||||
3. **Battery + energy** — IOKit `PowerMonitor`; Energy/Battery UI; menu-bar battery.
|
||||
4. **Alerts + health** — `AlertEngine`, `UserNotifications`, in-app inbox, 0–100 health score,
|
||||
"why is my Mac slow?" diagnosis.
|
||||
5. **Settings + rich menu bar** — configurable refresh/metric/thresholds, launch-at-login
|
||||
(`SMAppService`), menu-bar HUD with top consumers + quick actions.
|
||||
6. **Verification** — full test run, runtime smoke, ground-truth cross-checks, sign, commit.
|
||||
|
||||
## Non-negotiables
|
||||
- Never break a shipped feature (regression Gate 5).
|
||||
- No new entitlements / no root / no privileged helper — must run & sign as-is.
|
||||
- Every increment passes all five gates in `docs/TESTING.md` before moving on.
|
||||
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.1.1"
|
||||
CURRENT_PROJECT_VERSION: "3"
|
||||
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
|
||||