Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60f4039013 | ||
|
|
125c489881 | ||
|
|
d75873fa30 | ||
|
|
3095bd106a | ||
|
|
d0c286e7c8 | ||
|
|
807fcae6b2 | ||
|
|
0e5af9d70d | ||
|
|
acf5d03277 | ||
|
|
9933c87004 | ||
|
|
4a8fd0e5ef | ||
|
|
bc735b57e6 | ||
|
|
b1c86f1ee8 | ||
|
|
a533ee1f9f | ||
|
|
26a65f0d47 | ||
|
|
7cb6c25ae2 | ||
|
|
d7b87ca917 | ||
|
|
431b2376c3 | ||
|
|
073d4036c7 | ||
|
|
a7f13542ae | ||
|
|
ba0138c188 | ||
|
|
493e80e9fe | ||
|
|
e7387a3ac4 | ||
|
|
7d2dd306ca | ||
|
|
6473a1a4b2 | ||
|
|
f34bb6bafe | ||
|
|
9b3f6ae50e | ||
|
|
53dad97c6c | ||
|
|
e49c471bad | ||
|
|
c8743e2578 | ||
|
|
544de0ff88 | ||
|
|
3224558ac4 | ||
|
|
6c34cf3c86 | ||
|
|
3c9e209289 | ||
|
|
6e07fad771 | ||
|
|
a46f91f2ad | ||
|
|
65468f6dfb |
+86
-13
@@ -2,16 +2,8 @@ name: MacMonitor CI/CD Pipeline
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
tags:
|
||||||
- main
|
- 'v*'
|
||||||
- develop
|
|
||||||
- 'milestone/**'
|
|
||||||
- 'feat/**'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
- 'milestone/**'
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -20,7 +12,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
build-and-test:
|
||||||
name: Build & Test (Intel x86_64)
|
name: Build & Test (Intel x86_64)
|
||||||
runs-on: [macos, intel]
|
runs-on: macos-14
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
@@ -59,12 +51,12 @@ jobs:
|
|||||||
if command -v xcbeautify &> /dev/null; then
|
if command -v xcbeautify &> /dev/null; then
|
||||||
xcodebuild clean build \
|
xcodebuild clean build \
|
||||||
-scheme MacMonitor \
|
-scheme MacMonitor \
|
||||||
-destination 'generic/platform=macOS,arch=x86_64' \
|
-destination 'platform=macOS,arch=x86_64' \
|
||||||
CODE_SIGNING_ALLOWED=NO | xcbeautify
|
CODE_SIGNING_ALLOWED=NO | xcbeautify
|
||||||
else
|
else
|
||||||
xcodebuild clean build \
|
xcodebuild clean build \
|
||||||
-scheme MacMonitor \
|
-scheme MacMonitor \
|
||||||
-destination 'generic/platform=macOS,arch=x86_64' \
|
-destination 'platform=macOS,arch=x86_64' \
|
||||||
CODE_SIGNING_ALLOWED=NO
|
CODE_SIGNING_ALLOWED=NO
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -82,3 +74,84 @@ jobs:
|
|||||||
-destination 'platform=macOS,arch=x86_64' \
|
-destination 'platform=macOS,arch=x86_64' \
|
||||||
CODE_SIGNING_ALLOWED=NO
|
CODE_SIGNING_ALLOWED=NO
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
release-package:
|
||||||
|
name: Package Release Artifact
|
||||||
|
needs: build-and-test
|
||||||
|
runs-on: macos-14
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Generate Xcode Project
|
||||||
|
run: |
|
||||||
|
if command -v xcodegen &> /dev/null; then
|
||||||
|
xcodegen generate
|
||||||
|
else
|
||||||
|
echo "xcodegen not preinstalled, using tracked MacMonitor.xcodeproj"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build Release (Intel x86_64)
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
xcodebuild clean build \
|
||||||
|
-scheme MacMonitor \
|
||||||
|
-configuration Release \
|
||||||
|
-destination 'platform=macOS,arch=x86_64' \
|
||||||
|
-derivedDataPath "$PWD/DerivedData" \
|
||||||
|
CODE_SIGNING_ALLOWED=NO
|
||||||
|
|
||||||
|
- name: Ad-hoc Sign Application Bundle
|
||||||
|
run: |
|
||||||
|
APP="$PWD/DerivedData/Build/Products/Release/MacMonitor.app"
|
||||||
|
# Ad-hoc signing is required for UNUserNotificationCenter authorization
|
||||||
|
# and banner delivery in distributed unsigned builds.
|
||||||
|
codesign --force --deep --sign - "$APP"
|
||||||
|
codesign --verify --verbose "$APP"
|
||||||
|
|
||||||
|
- name: Create Distribution Disk Image (.dmg)
|
||||||
|
run: |
|
||||||
|
APP="$PWD/DerivedData/Build/Products/Release/MacMonitor.app"
|
||||||
|
DMG_STAGING="$(mktemp -d)/staging"
|
||||||
|
mkdir -p "$DMG_STAGING"
|
||||||
|
cp -R "$APP" "$DMG_STAGING/"
|
||||||
|
ln -s /Applications "$DMG_STAGING/Applications"
|
||||||
|
hdiutil create -volname "MacMonitor" -srcfolder "$DMG_STAGING" -ov -format UDZO "$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.dmg"
|
||||||
|
rm -rf "$(dirname "$DMG_STAGING")"
|
||||||
|
ls -lh "$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.dmg"
|
||||||
|
|
||||||
|
- name: Upload Workflow Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
name: MacMonitor-intel-x86_64
|
||||||
|
path: MacMonitor-intel-x86_64.dmg
|
||||||
|
|
||||||
|
- name: Create Gitea Release & Attach Artifact
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
TAG="${{ github.ref_name }}"
|
||||||
|
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||||
|
AUTH="Authorization: token ${{ secrets.GITEA_TOKEN }}"
|
||||||
|
|
||||||
|
# Create the release (ignore conflict if it already exists)
|
||||||
|
curl -sS -X POST "$API/releases" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"MacMonitor $TAG\",\"draft\":false,\"prerelease\":false}" \
|
||||||
|
-o /tmp/release.json -w "create_release_http=%{http_code}\n"
|
||||||
|
|
||||||
|
# Resolve release ID (created above or pre-existing)
|
||||||
|
RELEASE_ID=$(grep -o '"id":[0-9]*' /tmp/release.json | head -1 | cut -d: -f2)
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
RELEASE_ID=$(curl -sS "$API/releases/tags/$TAG" -H "$AUTH" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||||
|
fi
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
echo "ERROR: could not resolve release ID for $TAG"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -sS -X POST "$API/releases/$RELEASE_ID/assets?name=MacMonitor-intel-x86_64.dmg" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/x-apple-diskimage" \
|
||||||
|
--data-binary @"$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.dmg" \
|
||||||
|
-o /tmp/asset.json -w "attach_asset_http=%{http_code}\n"
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
disabled_rules:
|
||||||
|
- type_body_length
|
||||||
|
- function_body_length
|
||||||
|
- file_length
|
||||||
|
- cyclomatic_complexity
|
||||||
|
- identifier_name
|
||||||
|
- line_length
|
||||||
|
- large_tuple
|
||||||
|
- multiple_closures_with_trailing_closure
|
||||||
|
- trailing_whitespace
|
||||||
|
- implicit_optional_initialization
|
||||||
|
|
||||||
|
opt_in_rules:
|
||||||
|
- empty_count
|
||||||
|
|
||||||
|
included:
|
||||||
|
- Sources
|
||||||
|
- Tests
|
||||||
|
|
||||||
|
excluded:
|
||||||
|
- MacMonitor.xcodeproj
|
||||||
|
- build
|
||||||
|
- DerivedData
|
||||||
@@ -132,7 +132,7 @@ Before opening any Pull Request, ensure that the project compiles cleanly for In
|
|||||||
```bash
|
```bash
|
||||||
xcodebuild build test \
|
xcodebuild build test \
|
||||||
-scheme MacMonitor \
|
-scheme MacMonitor \
|
||||||
-destination 'generic/platform=macOS,arch=x86_64' \
|
-destination 'platform=macOS,arch=x86_64' \
|
||||||
CODE_SIGNING_ALLOWED=NO
|
CODE_SIGNING_ALLOWED=NO
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -203,7 +203,7 @@ graph TD
|
|||||||
- **Goal:** Proactive health alerts, macOS Notification Center integration, end-to-end regression validation, and release packaging.
|
- **Goal:** Proactive health alerts, macOS Notification Center integration, end-to-end regression validation, and release packaging.
|
||||||
- **Execution Order:**
|
- **Execution Order:**
|
||||||
1. `#24` Threshold Alerts & Notification Center (`feat/24-threshold-alerts` - Depends on #3, #4, #8, #9)
|
1. `#24` Threshold Alerts & Notification Center (`feat/24-threshold-alerts` - Depends on #3, #4, #8, #9)
|
||||||
2. Full end-to-end integration testing and automated release archiving (`MacMonitor-intel-x86_64.zip`) on tagged commit.
|
2. Full end-to-end integration testing and automated release archiving (`MacMonitor-intel-x86_64.dmg`) on tagged commit.
|
||||||
- **Quality Gate M5:** Zero crashes, all unit test suites passing on Intel macOS runner, DMG/ZIP distribution artifact ready.
|
- **Quality Gate M5:** Zero crashes, all unit test suites passing on Intel macOS runner, DMG/ZIP distribution artifact ready.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -7,30 +7,69 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
0A59F7CA4F9C04C743ACCAE7 /* MMMenuBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93ED125FEC2602DE91D3983F /* MMMenuBarTests.swift */; };
|
||||||
0A7117B3B7CC7112F2527574 /* MMPowerTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 02F5FDE260999C39E978D849 /* MMPowerTelemetryProvider.m */; };
|
0A7117B3B7CC7112F2527574 /* MMPowerTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 02F5FDE260999C39E978D849 /* MMPowerTelemetryProvider.m */; };
|
||||||
|
11787A1288CC3D4CCC4F248F /* RollingRingBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E47D6CBFF7FF3465E9BC13 /* RollingRingBuffer.swift */; };
|
||||||
160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */; };
|
160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */; };
|
||||||
|
17B49F1C470C52AF01D75B0B /* AlertNotificationDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71662F61CE9176214130A5FD /* AlertNotificationDispatcher.swift */; };
|
||||||
1879A3DCF6E305D7F2CD472E /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */; };
|
1879A3DCF6E305D7F2CD472E /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */; };
|
||||||
1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78325870C7CD8312AECA2236 /* MMStorageTests.swift */; };
|
1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78325870C7CD8312AECA2236 /* MMStorageTests.swift */; };
|
||||||
1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; };
|
1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; };
|
||||||
|
22CA7351A745F7837E487051 /* MMPeripheralsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1486E1512594482380CB492 /* MMPeripheralsTests.swift */; };
|
||||||
|
2665E9DAE13C13239857B4BB /* MMSnapshotContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA79F8A259CA3056F9B131A9 /* MMSnapshotContractTests.swift */; };
|
||||||
|
2C0F7AB93691ED8AA86FBCF0 /* MMProcessDetailInspector.m in Sources */ = {isa = PBXBuildFile; fileRef = 52E641F8A664D456518EDBA4 /* MMProcessDetailInspector.m */; };
|
||||||
|
2E966D96347D1DDB24E18632 /* AlertModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B657679DB7D1F39AB4911B7B /* AlertModels.swift */; };
|
||||||
|
3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */; };
|
||||||
3C0AC14F803C813287D8A663 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 007F48A5A599AB4E8D216D16 /* IOKit.framework */; };
|
3C0AC14F803C813287D8A663 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 007F48A5A599AB4E8D216D16 /* IOKit.framework */; };
|
||||||
4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */; };
|
4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */; };
|
||||||
|
4752C49841375B88194F7635 /* MMPeripheralsProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 2E6A10A13A49E22E51862895 /* MMPeripheralsProvider.m */; };
|
||||||
|
475D6A2B1CF0E847DC39D331 /* TelemetryTrendChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A7DCFA63526AB6AC918F1CF /* TelemetryTrendChartView.swift */; };
|
||||||
|
4C1A92F4DEF4C824976115C2 /* MMNetworkSocketsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374B1BF631CC4738CB52CA3B /* MMNetworkSocketsTests.swift */; };
|
||||||
|
4D0DA9444669117CEFCB59F4 /* AlertConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989008FE4885C8BA90251A3E /* AlertConfiguration.swift */; };
|
||||||
|
4D904E0FF48270D8B7CE4B61 /* QuickGlancePopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D836CEC50067A77055EF224 /* QuickGlancePopoverView.swift */; };
|
||||||
57BA02AE1CE24DBD02BD03A4 /* MMComponentThermalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */; };
|
57BA02AE1CE24DBD02BD03A4 /* MMComponentThermalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */; };
|
||||||
|
5B64CB79018646EF8926DB5C /* AlertsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F64EA2A13630F0E2E6D382 /* AlertsSettingsView.swift */; };
|
||||||
|
5D31906EA986E2BA92E86FA8 /* MMNetworkBandwidthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C612287333D757C92A076A /* MMNetworkBandwidthTests.swift */; };
|
||||||
6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */; };
|
6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */; };
|
||||||
655366D7066478A02EDA91BB /* MMFanTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C9ED8F75E082D4F8AC2FF2F5 /* MMFanTelemetryProvider.m */; };
|
655366D7066478A02EDA91BB /* MMFanTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C9ED8F75E082D4F8AC2FF2F5 /* MMFanTelemetryProvider.m */; };
|
||||||
|
6A4FDB46AE5E0DAF85CCAB56 /* MMDiskIOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */; };
|
||||||
|
6D973241BBE61F0B053239DB /* MMGPUTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D61AB99733A4AA52459BA7C4 /* MMGPUTelemetryProvider.m */; };
|
||||||
|
75CF7FF817B7EB5011FBFC18 /* MMDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFB5FD360B39A2AE6DFAEDDA /* MMDisplayTests.swift */; };
|
||||||
|
7C9AE2ADAED52E593B52F3AE /* MMProcessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52471E871E5CC1E444318070 /* MMProcessTests.swift */; };
|
||||||
|
7D7568B99EC2C7C52726B510 /* MenuBarStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E4A682661D148FF733F6102 /* MenuBarStatusView.swift */; };
|
||||||
87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */; };
|
87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */; };
|
||||||
87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */; };
|
87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */; };
|
||||||
88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */; };
|
88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */; };
|
||||||
|
8EDDC2171C55B6E7FC999DAB /* MMBatteryTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 9C92D0D2B62B3081ECCA9920 /* MMBatteryTelemetryProvider.m */; };
|
||||||
|
901EF4853E785A2856E54BC9 /* MMKernelTelemetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE40072F9A1F9DBD1C48A3B1 /* MMKernelTelemetryTests.swift */; };
|
||||||
925245C5B5F8D8DA3C89C4BA /* MMComponentThermalProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C65F5835516BC2A65BCE25D2 /* MMComponentThermalProvider.m */; };
|
925245C5B5F8D8DA3C89C4BA /* MMComponentThermalProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C65F5835516BC2A65BCE25D2 /* MMComponentThermalProvider.m */; };
|
||||||
|
93EF83FE087C6DEAAE3EC0CC /* MMProcessTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 728C692778909D2B7451C07B /* MMProcessTelemetryProvider.m */; };
|
||||||
|
951DBA5D3BBAB753322A5DF9 /* MMLoadAverageProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = B5F2256DF514DD737306302B /* MMLoadAverageProvider.m */; };
|
||||||
9B5C651AD2AF7DE2738F0C44 /* MMPowerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48698681374BD25B980C000 /* MMPowerTests.swift */; };
|
9B5C651AD2AF7DE2738F0C44 /* MMPowerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48698681374BD25B980C000 /* MMPowerTests.swift */; };
|
||||||
|
9B882E4189597719436F18FB /* AlertsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7077FCC0D058CFE25C1ECBE3 /* AlertsView.swift */; };
|
||||||
9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */; };
|
9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */; };
|
||||||
|
9D12825C7904FB0DAE664CA0 /* MMAudioTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D16E1ABE6D28B676EA83FF /* MMAudioTests.swift */; };
|
||||||
|
A521961E035DD50ED8B0C1A9 /* MMKernelTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = F444F4543F0A7421C8F8E636 /* MMKernelTelemetryProvider.m */; };
|
||||||
|
A599E974E56F0B8D94A7E480 /* MMNetworkSocketsProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = D78F76C1996D7FCA241CE532 /* MMNetworkSocketsProvider.m */; };
|
||||||
A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */; };
|
A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */; };
|
||||||
A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E142AE61F9B87757997870DD /* ContentView.swift */; };
|
A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E142AE61F9B87757997870DD /* ContentView.swift */; };
|
||||||
|
AC0887D4877FFFE7CC5B515C /* AlertEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8530556B240CCDEA63009DB /* AlertEngine.swift */; };
|
||||||
|
ACC914C61AB3762EB0C40513 /* MMLoadAverageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB05C092C94C3972727AAA7 /* MMLoadAverageTests.swift */; };
|
||||||
|
AE3C28BDC3CEF40A9E8F2B20 /* MMGPUTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A0F723121CDF9597316F58 /* MMGPUTests.swift */; };
|
||||||
B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */; };
|
B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */; };
|
||||||
|
BC487DB86227F09CB0603894 /* MMBatteryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0EB61D811D989279BCBE478 /* MMBatteryTests.swift */; };
|
||||||
CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */; };
|
CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */ = {isa = PBXBuildFile; fileRef = 5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */; };
|
||||||
|
D1520F9ABB7474A5307AF483 /* MMHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 568622E0726EF1D5D2E705CE /* MMHistoryTests.swift */; };
|
||||||
|
D3863FD0563E8F4C1EAE279A /* MMAlertsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D17F38FFBF523575F41E68C6 /* MMAlertsTests.swift */; };
|
||||||
|
D7784A61FE4635275CFB3D6C /* MMAudioTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 3189D2403E6E984D4924AE4D /* MMAudioTelemetryProvider.m */; };
|
||||||
|
DD3E4F79A146C323CD849F2B /* MMDisplayManager.m in Sources */ = {isa = PBXBuildFile; fileRef = F698B0CC35A89EDEA03F08A5 /* MMDisplayManager.m */; };
|
||||||
|
E3CCE93A7D4D1EB0187D3E52 /* MMNetworkBandwidthProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = C539AEA27C794C6F428EA875 /* MMNetworkBandwidthProvider.m */; };
|
||||||
E6F2F695B8D3E7045967C082 /* MMFanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */; };
|
E6F2F695B8D3E7045967C082 /* MMFanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */; };
|
||||||
EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */; };
|
EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */; };
|
||||||
EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940D608A1AF80F75584E744E /* MMSMCTests.swift */; };
|
EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940D608A1AF80F75584E744E /* MMSMCTests.swift */; };
|
||||||
|
EEAAEA6AABAA55FA8DF3453B /* TelemetryHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 503315AEC1C8C51111AD765B /* TelemetryHistoryStore.swift */; };
|
||||||
F2F7A141F2452EC1B4FB45D8 /* MMStorageTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */; };
|
F2F7A141F2452EC1B4FB45D8 /* MMStorageTelemetryProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */; };
|
||||||
|
FED88F290A2F2FBD63DDEC31 /* MMDiskIOProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 3E13917CAF525640D856ECD9 /* MMDiskIOProvider.m */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -49,39 +88,90 @@
|
|||||||
02F5FDE260999C39E978D849 /* MMPowerTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMPowerTelemetryProvider.m; sourceTree = "<group>"; };
|
02F5FDE260999C39E978D849 /* MMPowerTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMPowerTelemetryProvider.m; sourceTree = "<group>"; };
|
||||||
09475741B5D0D4B89B863C3D /* MMComponentThermalProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMComponentThermalProvider.h; sourceTree = "<group>"; };
|
09475741B5D0D4B89B863C3D /* MMComponentThermalProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMComponentThermalProvider.h; sourceTree = "<group>"; };
|
||||||
099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMMemoryTests.swift; sourceTree = "<group>"; };
|
099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMMemoryTests.swift; sourceTree = "<group>"; };
|
||||||
|
0C94CCFBE8C9E1370368A28C /* MMNetworkSocketsProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMNetworkSocketsProvider.h; sourceTree = "<group>"; };
|
||||||
|
0D836CEC50067A77055EF224 /* QuickGlancePopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickGlancePopoverView.swift; sourceTree = "<group>"; };
|
||||||
0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMCPUThermalTests.swift; sourceTree = "<group>"; };
|
0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMCPUThermalTests.swift; sourceTree = "<group>"; };
|
||||||
|
10D16E1ABE6D28B676EA83FF /* MMAudioTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMAudioTests.swift; sourceTree = "<group>"; };
|
||||||
1AD9EEE571CD904BBCD1C96B /* MMSMCParser.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCParser.h; sourceTree = "<group>"; };
|
1AD9EEE571CD904BBCD1C96B /* MMSMCParser.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCParser.h; sourceTree = "<group>"; };
|
||||||
|
1B07A683AD0EFBA8E1FB6C9D /* MMProcessDetailInspector.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMProcessDetailInspector.h; sourceTree = "<group>"; };
|
||||||
2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
|
2345555FF9780B02A90CC7A9 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
|
||||||
|
27C612287333D757C92A076A /* MMNetworkBandwidthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMNetworkBandwidthTests.swift; sourceTree = "<group>"; };
|
||||||
|
2C55E977DBFBE7FF04621608 /* MMLoadAverageProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMLoadAverageProvider.h; sourceTree = "<group>"; };
|
||||||
|
2DC6E55C9D3F646A1C62133D /* MMBatteryTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMBatteryTelemetryProvider.h; sourceTree = "<group>"; };
|
||||||
|
2E6A10A13A49E22E51862895 /* MMPeripheralsProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMPeripheralsProvider.m; sourceTree = "<group>"; };
|
||||||
2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorTests.swift; sourceTree = "<group>"; };
|
2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorTests.swift; sourceTree = "<group>"; };
|
||||||
|
3189D2403E6E984D4924AE4D /* MMAudioTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMAudioTelemetryProvider.m; sourceTree = "<group>"; };
|
||||||
32340166F6100A08105308D3 /* MMTelemetryCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryCoordinator.h; sourceTree = "<group>"; };
|
32340166F6100A08105308D3 /* MMTelemetryCoordinator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryCoordinator.h; sourceTree = "<group>"; };
|
||||||
336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMFanTests.swift; sourceTree = "<group>"; };
|
336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMFanTests.swift; sourceTree = "<group>"; };
|
||||||
|
374B1BF631CC4738CB52CA3B /* MMNetworkSocketsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMNetworkSocketsTests.swift; sourceTree = "<group>"; };
|
||||||
375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMSMCParser.m; sourceTree = "<group>"; };
|
375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMSMCParser.m; sourceTree = "<group>"; };
|
||||||
|
3A14D7B97200EA4C102FEE67 /* MMNetworkBandwidthProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMNetworkBandwidthProvider.h; sourceTree = "<group>"; };
|
||||||
|
3A7DCFA63526AB6AC918F1CF /* TelemetryTrendChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryTrendChartView.swift; sourceTree = "<group>"; };
|
||||||
3CFC5B905E428A6B854E0AB4 /* MMFanTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMFanTelemetryProvider.h; sourceTree = "<group>"; };
|
3CFC5B905E428A6B854E0AB4 /* MMFanTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMFanTelemetryProvider.h; sourceTree = "<group>"; };
|
||||||
|
3E13917CAF525640D856ECD9 /* MMDiskIOProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMDiskIOProvider.m; sourceTree = "<group>"; };
|
||||||
|
41478843A6B013478D94F739 /* MMProcessTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMProcessTelemetryProvider.h; sourceTree = "<group>"; };
|
||||||
46DE3AE12DAE26C4FE58034C /* MMStorageTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMStorageTelemetryProvider.h; sourceTree = "<group>"; };
|
46DE3AE12DAE26C4FE58034C /* MMStorageTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMStorageTelemetryProvider.h; sourceTree = "<group>"; };
|
||||||
|
48E47D6CBFF7FF3465E9BC13 /* RollingRingBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RollingRingBuffer.swift; sourceTree = "<group>"; };
|
||||||
4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMCPULoadTests.swift; sourceTree = "<group>"; };
|
4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMCPULoadTests.swift; sourceTree = "<group>"; };
|
||||||
|
503315AEC1C8C51111AD765B /* TelemetryHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryHistoryStore.swift; sourceTree = "<group>"; };
|
||||||
|
52471E871E5CC1E444318070 /* MMProcessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMProcessTests.swift; sourceTree = "<group>"; };
|
||||||
|
52E641F8A664D456518EDBA4 /* MMProcessDetailInspector.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMProcessDetailInspector.m; sourceTree = "<group>"; };
|
||||||
5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMAppleSMCClient.m; sourceTree = "<group>"; };
|
5555436CD3CB8A73F5D6504F /* MMAppleSMCClient.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMAppleSMCClient.m; sourceTree = "<group>"; };
|
||||||
5635101F862DE680856BDE6E /* MMTelemetryDomain.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryDomain.h; sourceTree = "<group>"; };
|
5635101F862DE680856BDE6E /* MMTelemetryDomain.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryDomain.h; sourceTree = "<group>"; };
|
||||||
|
568622E0726EF1D5D2E705CE /* MMHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMHistoryTests.swift; sourceTree = "<group>"; };
|
||||||
56E285A0A1746AE659981F22 /* MacMonitorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacMonitorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
56E285A0A1746AE659981F22 /* MacMonitorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacMonitorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
57845310A3D4EC67E48A3B11 /* MMCPULoadProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMCPULoadProvider.h; sourceTree = "<group>"; };
|
57845310A3D4EC67E48A3B11 /* MMCPULoadProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMCPULoadProvider.h; sourceTree = "<group>"; };
|
||||||
|
5C45D1EB09E045A53A154136 /* MMAudioTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMAudioTelemetryProvider.h; sourceTree = "<group>"; };
|
||||||
62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPUThermalProvider.m; sourceTree = "<group>"; };
|
62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPUThermalProvider.m; sourceTree = "<group>"; };
|
||||||
6D0E5CAF25DFA9A1D2A698A0 /* MMTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryProvider.h; sourceTree = "<group>"; };
|
6D0E5CAF25DFA9A1D2A698A0 /* MMTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMTelemetryProvider.h; sourceTree = "<group>"; };
|
||||||
6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMStorageTelemetryProvider.m; sourceTree = "<group>"; };
|
6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMStorageTelemetryProvider.m; sourceTree = "<group>"; };
|
||||||
|
7077FCC0D058CFE25C1ECBE3 /* AlertsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertsView.swift; sourceTree = "<group>"; };
|
||||||
|
71662F61CE9176214130A5FD /* AlertNotificationDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertNotificationDispatcher.swift; sourceTree = "<group>"; };
|
||||||
|
7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMProcessDetailsTests.swift; sourceTree = "<group>"; };
|
||||||
726005CB5A6C86E7D80D3CA0 /* MMAppleSMCClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMAppleSMCClient.h; sourceTree = "<group>"; };
|
726005CB5A6C86E7D80D3CA0 /* MMAppleSMCClient.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMAppleSMCClient.h; sourceTree = "<group>"; };
|
||||||
|
728C692778909D2B7451C07B /* MMProcessTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMProcessTelemetryProvider.m; sourceTree = "<group>"; };
|
||||||
78325870C7CD8312AECA2236 /* MMStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMStorageTests.swift; sourceTree = "<group>"; };
|
78325870C7CD8312AECA2236 /* MMStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMStorageTests.swift; sourceTree = "<group>"; };
|
||||||
|
7EB05C092C94C3972727AAA7 /* MMLoadAverageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMLoadAverageTests.swift; sourceTree = "<group>"; };
|
||||||
|
80A0F723121CDF9597316F58 /* MMGPUTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMGPUTests.swift; sourceTree = "<group>"; };
|
||||||
8FBB436F3D9472194D8C6EDD /* MMSMCDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCDefines.h; sourceTree = "<group>"; };
|
8FBB436F3D9472194D8C6EDD /* MMSMCDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMSMCDefines.h; sourceTree = "<group>"; };
|
||||||
|
93ED125FEC2602DE91D3983F /* MMMenuBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMMenuBarTests.swift; sourceTree = "<group>"; };
|
||||||
940D608A1AF80F75584E744E /* MMSMCTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMSMCTests.swift; sourceTree = "<group>"; };
|
940D608A1AF80F75584E744E /* MMSMCTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMSMCTests.swift; sourceTree = "<group>"; };
|
||||||
|
989008FE4885C8BA90251A3E /* AlertConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertConfiguration.swift; sourceTree = "<group>"; };
|
||||||
|
98F64EA2A13630F0E2E6D382 /* AlertsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertsSettingsView.swift; sourceTree = "<group>"; };
|
||||||
991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMMemoryTelemetryProvider.m; sourceTree = "<group>"; };
|
991AEEAF262F3C6BE930BC77 /* MMMemoryTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMMemoryTelemetryProvider.m; sourceTree = "<group>"; };
|
||||||
|
9C92D0D2B62B3081ECCA9920 /* MMBatteryTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMBatteryTelemetryProvider.m; sourceTree = "<group>"; };
|
||||||
|
9DC54CCE276F93D5AF0A0C28 /* MMGPUTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMGPUTelemetryProvider.h; sourceTree = "<group>"; };
|
||||||
|
9E4A682661D148FF733F6102 /* MenuBarStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarStatusView.swift; sourceTree = "<group>"; };
|
||||||
ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorApp.swift; sourceTree = "<group>"; };
|
ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacMonitorApp.swift; sourceTree = "<group>"; };
|
||||||
|
AFA681E2464EA78CC19B54E0 /* MMDiskIOProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMDiskIOProvider.h; sourceTree = "<group>"; };
|
||||||
|
B0EB61D811D989279BCBE478 /* MMBatteryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMBatteryTests.swift; sourceTree = "<group>"; };
|
||||||
B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMComponentThermalTests.swift; sourceTree = "<group>"; };
|
B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMComponentThermalTests.swift; sourceTree = "<group>"; };
|
||||||
|
B5F2256DF514DD737306302B /* MMLoadAverageProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMLoadAverageProvider.m; sourceTree = "<group>"; };
|
||||||
|
B657679DB7D1F39AB4911B7B /* AlertModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertModels.swift; sourceTree = "<group>"; };
|
||||||
|
C539AEA27C794C6F428EA875 /* MMNetworkBandwidthProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMNetworkBandwidthProvider.m; sourceTree = "<group>"; };
|
||||||
C65F5835516BC2A65BCE25D2 /* MMComponentThermalProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMComponentThermalProvider.m; sourceTree = "<group>"; };
|
C65F5835516BC2A65BCE25D2 /* MMComponentThermalProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMComponentThermalProvider.m; sourceTree = "<group>"; };
|
||||||
|
C8530556B240CCDEA63009DB /* AlertEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertEngine.swift; sourceTree = "<group>"; };
|
||||||
C9ED8F75E082D4F8AC2FF2F5 /* MMFanTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMFanTelemetryProvider.m; sourceTree = "<group>"; };
|
C9ED8F75E082D4F8AC2FF2F5 /* MMFanTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMFanTelemetryProvider.m; sourceTree = "<group>"; };
|
||||||
CBA91906CA8B7936CC82A36B /* MMPowerTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMPowerTelemetryProvider.h; sourceTree = "<group>"; };
|
CBA91906CA8B7936CC82A36B /* MMPowerTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMPowerTelemetryProvider.h; sourceTree = "<group>"; };
|
||||||
|
CDE2777944B8F12EC7D2EAB5 /* MMPeripheralsProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMPeripheralsProvider.h; sourceTree = "<group>"; };
|
||||||
|
D1486E1512594482380CB492 /* MMPeripheralsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPeripheralsTests.swift; sourceTree = "<group>"; };
|
||||||
|
D17F38FFBF523575F41E68C6 /* MMAlertsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMAlertsTests.swift; sourceTree = "<group>"; };
|
||||||
D48698681374BD25B980C000 /* MMPowerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPowerTests.swift; sourceTree = "<group>"; };
|
D48698681374BD25B980C000 /* MMPowerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPowerTests.swift; sourceTree = "<group>"; };
|
||||||
|
D52C042B0AE785CF71C6F543 /* MMDisplayManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMDisplayManager.h; sourceTree = "<group>"; };
|
||||||
|
D61AB99733A4AA52459BA7C4 /* MMGPUTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMGPUTelemetryProvider.m; sourceTree = "<group>"; };
|
||||||
D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPULoadProvider.m; sourceTree = "<group>"; };
|
D68FC8100D08DB6202F46339 /* MMCPULoadProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMCPULoadProvider.m; sourceTree = "<group>"; };
|
||||||
D73CCC7B5E6BF1825EA68E2B /* MMCPUThermalProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMCPUThermalProvider.h; sourceTree = "<group>"; };
|
D73CCC7B5E6BF1825EA68E2B /* MMCPUThermalProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMCPUThermalProvider.h; sourceTree = "<group>"; };
|
||||||
|
D78F76C1996D7FCA241CE532 /* MMNetworkSocketsProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMNetworkSocketsProvider.m; sourceTree = "<group>"; };
|
||||||
|
DE40072F9A1F9DBD1C48A3B1 /* MMKernelTelemetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMKernelTelemetryTests.swift; sourceTree = "<group>"; };
|
||||||
|
DFB5FD360B39A2AE6DFAEDDA /* MMDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMDisplayTests.swift; sourceTree = "<group>"; };
|
||||||
E142AE61F9B87757997870DD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
E142AE61F9B87757997870DD /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTelemetryStore.swift; sourceTree = "<group>"; };
|
E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTelemetryStore.swift; sourceTree = "<group>"; };
|
||||||
|
E6D9BB71DFE7384D0F0E6AA3 /* MMKernelTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMKernelTelemetryProvider.h; sourceTree = "<group>"; };
|
||||||
|
E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMDiskIOTests.swift; sourceTree = "<group>"; };
|
||||||
|
EA79F8A259CA3056F9B131A9 /* MMSnapshotContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMSnapshotContractTests.swift; sourceTree = "<group>"; };
|
||||||
|
F444F4543F0A7421C8F8E636 /* MMKernelTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMKernelTelemetryProvider.m; sourceTree = "<group>"; };
|
||||||
F5EC59DCDA5FABDA409CDBCB /* MacMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
F5EC59DCDA5FABDA409CDBCB /* MacMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
F698B0CC35A89EDEA03F08A5 /* MMDisplayManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMDisplayManager.m; sourceTree = "<group>"; };
|
||||||
F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMTelemetryCoordinator.m; sourceTree = "<group>"; };
|
F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMTelemetryCoordinator.m; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
@@ -114,20 +204,43 @@
|
|||||||
4D8D8B40632C525CBC1D0E43 /* App */,
|
4D8D8B40632C525CBC1D0E43 /* App */,
|
||||||
54787E6270EA7B9A1BE11359 /* Core */,
|
54787E6270EA7B9A1BE11359 /* Core */,
|
||||||
9844EC526E3527F6A582179F /* Hardware */,
|
9844EC526E3527F6A582179F /* Hardware */,
|
||||||
|
4BD6F71AA668F3A4B161D0FA /* History */,
|
||||||
|
DB11C1CC50774018D85A1D79 /* Intelligence */,
|
||||||
8BF79E3BCB76E7A1669DC930 /* Telemetry */,
|
8BF79E3BCB76E7A1669DC930 /* Telemetry */,
|
||||||
3E4A0B53C583BD0B7A68EACE /* UI */,
|
3E4A0B53C583BD0B7A68EACE /* UI */,
|
||||||
);
|
);
|
||||||
path = Sources;
|
path = Sources;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
3A1C97833AFDC29469DCA9ED /* Charts */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3A7DCFA63526AB6AC918F1CF /* TelemetryTrendChartView.swift */,
|
||||||
|
);
|
||||||
|
path = Charts;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
3E4A0B53C583BD0B7A68EACE /* UI */ = {
|
3E4A0B53C583BD0B7A68EACE /* UI */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
E142AE61F9B87757997870DD /* ContentView.swift */,
|
E142AE61F9B87757997870DD /* ContentView.swift */,
|
||||||
|
B42842AC7D80A8B77A4A611D /* Alerts */,
|
||||||
|
3A1C97833AFDC29469DCA9ED /* Charts */,
|
||||||
|
BC3C9017978097F8240CEC63 /* MenuBar */,
|
||||||
|
E3A5B8BAED7E91EE8C6B3B2B /* Settings */,
|
||||||
);
|
);
|
||||||
path = UI;
|
path = UI;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
4BD6F71AA668F3A4B161D0FA /* History */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
48E47D6CBFF7FF3465E9BC13 /* RollingRingBuffer.swift */,
|
||||||
|
503315AEC1C8C51111AD765B /* TelemetryHistoryStore.swift */,
|
||||||
|
);
|
||||||
|
path = History;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
4D8D8B40632C525CBC1D0E43 /* App */ = {
|
4D8D8B40632C525CBC1D0E43 /* App */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -148,6 +261,15 @@
|
|||||||
path = Core;
|
path = Core;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
65251E6B5694734BAAF1C1F1 /* Peripherals */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CDE2777944B8F12EC7D2EAB5 /* MMPeripheralsProvider.h */,
|
||||||
|
2E6A10A13A49E22E51862895 /* MMPeripheralsProvider.m */,
|
||||||
|
);
|
||||||
|
path = Peripherals;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
78383CED205BCA772701AE48 /* CPU */ = {
|
78383CED205BCA772701AE48 /* CPU */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -157,28 +279,78 @@
|
|||||||
path = CPU;
|
path = CPU;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
8719744C366432DD7C2B9644 /* Process */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
1B07A683AD0EFBA8E1FB6C9D /* MMProcessDetailInspector.h */,
|
||||||
|
52E641F8A664D456518EDBA4 /* MMProcessDetailInspector.m */,
|
||||||
|
41478843A6B013478D94F739 /* MMProcessTelemetryProvider.h */,
|
||||||
|
728C692778909D2B7451C07B /* MMProcessTelemetryProvider.m */,
|
||||||
|
);
|
||||||
|
path = Process;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
8B31CE7F9319FEC373E6CDC6 /* Power */ = {
|
8B31CE7F9319FEC373E6CDC6 /* Power */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
2DC6E55C9D3F646A1C62133D /* MMBatteryTelemetryProvider.h */,
|
||||||
|
9C92D0D2B62B3081ECCA9920 /* MMBatteryTelemetryProvider.m */,
|
||||||
CBA91906CA8B7936CC82A36B /* MMPowerTelemetryProvider.h */,
|
CBA91906CA8B7936CC82A36B /* MMPowerTelemetryProvider.h */,
|
||||||
02F5FDE260999C39E978D849 /* MMPowerTelemetryProvider.m */,
|
02F5FDE260999C39E978D849 /* MMPowerTelemetryProvider.m */,
|
||||||
);
|
);
|
||||||
path = Power;
|
path = Power;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
8B972045B60442056C6456DF /* Display */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D52C042B0AE785CF71C6F543 /* MMDisplayManager.h */,
|
||||||
|
F698B0CC35A89EDEA03F08A5 /* MMDisplayManager.m */,
|
||||||
|
);
|
||||||
|
path = Display;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
8BF79E3BCB76E7A1669DC930 /* Telemetry */ = {
|
8BF79E3BCB76E7A1669DC930 /* Telemetry */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
92E1EF545807CF318620FEAA /* Audio */,
|
||||||
78383CED205BCA772701AE48 /* CPU */,
|
78383CED205BCA772701AE48 /* CPU */,
|
||||||
|
8B972045B60442056C6456DF /* Display */,
|
||||||
AD2995435E44FA3E1790A4F3 /* Fan */,
|
AD2995435E44FA3E1790A4F3 /* Fan */,
|
||||||
|
F99AF003017BA9F19F7AEB1E /* GPU */,
|
||||||
|
CA2F30473D63EC64CBD65437 /* Kernel */,
|
||||||
EE2F5EFC9DC33CEDA79A131F /* Memory */,
|
EE2F5EFC9DC33CEDA79A131F /* Memory */,
|
||||||
|
901D54FEAB6E9D6E787E599D /* Network */,
|
||||||
|
65251E6B5694734BAAF1C1F1 /* Peripherals */,
|
||||||
8B31CE7F9319FEC373E6CDC6 /* Power */,
|
8B31CE7F9319FEC373E6CDC6 /* Power */,
|
||||||
|
8719744C366432DD7C2B9644 /* Process */,
|
||||||
B6EB3F6545276C40212C2992 /* Storage */,
|
B6EB3F6545276C40212C2992 /* Storage */,
|
||||||
|
A8658D19E71E34F1DDD87097 /* System */,
|
||||||
DBDC37231C01F0A2D4A7FC73 /* Thermal */,
|
DBDC37231C01F0A2D4A7FC73 /* Thermal */,
|
||||||
);
|
);
|
||||||
path = Telemetry;
|
path = Telemetry;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
901D54FEAB6E9D6E787E599D /* Network */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3A14D7B97200EA4C102FEE67 /* MMNetworkBandwidthProvider.h */,
|
||||||
|
C539AEA27C794C6F428EA875 /* MMNetworkBandwidthProvider.m */,
|
||||||
|
0C94CCFBE8C9E1370368A28C /* MMNetworkSocketsProvider.h */,
|
||||||
|
D78F76C1996D7FCA241CE532 /* MMNetworkSocketsProvider.m */,
|
||||||
|
);
|
||||||
|
path = Network;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
92E1EF545807CF318620FEAA /* Audio */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
5C45D1EB09E045A53A154136 /* MMAudioTelemetryProvider.h */,
|
||||||
|
3189D2403E6E984D4924AE4D /* MMAudioTelemetryProvider.m */,
|
||||||
|
);
|
||||||
|
path = Audio;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
9844EC526E3527F6A582179F /* Hardware */ = {
|
9844EC526E3527F6A582179F /* Hardware */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -187,6 +359,15 @@
|
|||||||
path = Hardware;
|
path = Hardware;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A8658D19E71E34F1DDD87097 /* System */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2C55E977DBFBE7FF04621608 /* MMLoadAverageProvider.h */,
|
||||||
|
B5F2256DF514DD737306302B /* MMLoadAverageProvider.m */,
|
||||||
|
);
|
||||||
|
path = System;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
AD2995435E44FA3E1790A4F3 /* Fan */ = {
|
AD2995435E44FA3E1790A4F3 /* Fan */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -196,15 +377,34 @@
|
|||||||
path = Fan;
|
path = Fan;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
B42842AC7D80A8B77A4A611D /* Alerts */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7077FCC0D058CFE25C1ECBE3 /* AlertsView.swift */,
|
||||||
|
);
|
||||||
|
path = Alerts;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
B6EB3F6545276C40212C2992 /* Storage */ = {
|
B6EB3F6545276C40212C2992 /* Storage */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
AFA681E2464EA78CC19B54E0 /* MMDiskIOProvider.h */,
|
||||||
|
3E13917CAF525640D856ECD9 /* MMDiskIOProvider.m */,
|
||||||
46DE3AE12DAE26C4FE58034C /* MMStorageTelemetryProvider.h */,
|
46DE3AE12DAE26C4FE58034C /* MMStorageTelemetryProvider.h */,
|
||||||
6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */,
|
6DCBCEA450103E2404761B78 /* MMStorageTelemetryProvider.m */,
|
||||||
);
|
);
|
||||||
path = Storage;
|
path = Storage;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
BC3C9017978097F8240CEC63 /* MenuBar */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9E4A682661D148FF733F6102 /* MenuBarStatusView.swift */,
|
||||||
|
0D836CEC50067A77055EF224 /* QuickGlancePopoverView.swift */,
|
||||||
|
);
|
||||||
|
path = MenuBar;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
C80C3EEE8F912F4B214102E3 /* Products */ = {
|
C80C3EEE8F912F4B214102E3 /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -218,18 +418,54 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */,
|
2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */,
|
||||||
|
D17F38FFBF523575F41E68C6 /* MMAlertsTests.swift */,
|
||||||
|
10D16E1ABE6D28B676EA83FF /* MMAudioTests.swift */,
|
||||||
|
B0EB61D811D989279BCBE478 /* MMBatteryTests.swift */,
|
||||||
B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */,
|
B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */,
|
||||||
4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */,
|
4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */,
|
||||||
0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */,
|
0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */,
|
||||||
|
E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */,
|
||||||
|
DFB5FD360B39A2AE6DFAEDDA /* MMDisplayTests.swift */,
|
||||||
336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */,
|
336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */,
|
||||||
|
80A0F723121CDF9597316F58 /* MMGPUTests.swift */,
|
||||||
|
568622E0726EF1D5D2E705CE /* MMHistoryTests.swift */,
|
||||||
|
DE40072F9A1F9DBD1C48A3B1 /* MMKernelTelemetryTests.swift */,
|
||||||
|
7EB05C092C94C3972727AAA7 /* MMLoadAverageTests.swift */,
|
||||||
099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */,
|
099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */,
|
||||||
|
93ED125FEC2602DE91D3983F /* MMMenuBarTests.swift */,
|
||||||
|
27C612287333D757C92A076A /* MMNetworkBandwidthTests.swift */,
|
||||||
|
374B1BF631CC4738CB52CA3B /* MMNetworkSocketsTests.swift */,
|
||||||
|
D1486E1512594482380CB492 /* MMPeripheralsTests.swift */,
|
||||||
D48698681374BD25B980C000 /* MMPowerTests.swift */,
|
D48698681374BD25B980C000 /* MMPowerTests.swift */,
|
||||||
|
7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */,
|
||||||
|
52471E871E5CC1E444318070 /* MMProcessTests.swift */,
|
||||||
940D608A1AF80F75584E744E /* MMSMCTests.swift */,
|
940D608A1AF80F75584E744E /* MMSMCTests.swift */,
|
||||||
|
EA79F8A259CA3056F9B131A9 /* MMSnapshotContractTests.swift */,
|
||||||
78325870C7CD8312AECA2236 /* MMStorageTests.swift */,
|
78325870C7CD8312AECA2236 /* MMStorageTests.swift */,
|
||||||
);
|
);
|
||||||
path = Tests;
|
path = Tests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
CA2F30473D63EC64CBD65437 /* Kernel */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E6D9BB71DFE7384D0F0E6AA3 /* MMKernelTelemetryProvider.h */,
|
||||||
|
F444F4543F0A7421C8F8E636 /* MMKernelTelemetryProvider.m */,
|
||||||
|
);
|
||||||
|
path = Kernel;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
DB11C1CC50774018D85A1D79 /* Intelligence */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
989008FE4885C8BA90251A3E /* AlertConfiguration.swift */,
|
||||||
|
C8530556B240CCDEA63009DB /* AlertEngine.swift */,
|
||||||
|
B657679DB7D1F39AB4911B7B /* AlertModels.swift */,
|
||||||
|
71662F61CE9176214130A5FD /* AlertNotificationDispatcher.swift */,
|
||||||
|
);
|
||||||
|
path = Intelligence;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
DBDC37231C01F0A2D4A7FC73 /* Thermal */ = {
|
DBDC37231C01F0A2D4A7FC73 /* Thermal */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -253,6 +489,14 @@
|
|||||||
path = AppleSMC;
|
path = AppleSMC;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
E3A5B8BAED7E91EE8C6B3B2B /* Settings */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
98F64EA2A13630F0E2E6D382 /* AlertsSettingsView.swift */,
|
||||||
|
);
|
||||||
|
path = Settings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
E56B76815ED75B28A13D6814 /* Frameworks */ = {
|
E56B76815ED75B28A13D6814 /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -271,6 +515,15 @@
|
|||||||
path = Memory;
|
path = Memory;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
F99AF003017BA9F19F7AEB1E /* GPU */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9DC54CCE276F93D5AF0A0C28 /* MMGPUTelemetryProvider.h */,
|
||||||
|
D61AB99733A4AA52459BA7C4 /* MMGPUTelemetryProvider.m */,
|
||||||
|
);
|
||||||
|
path = GPU;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -346,13 +599,29 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
D3863FD0563E8F4C1EAE279A /* MMAlertsTests.swift in Sources */,
|
||||||
|
9D12825C7904FB0DAE664CA0 /* MMAudioTests.swift in Sources */,
|
||||||
|
BC487DB86227F09CB0603894 /* MMBatteryTests.swift in Sources */,
|
||||||
160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */,
|
160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */,
|
||||||
87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */,
|
87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */,
|
||||||
57BA02AE1CE24DBD02BD03A4 /* MMComponentThermalTests.swift in Sources */,
|
57BA02AE1CE24DBD02BD03A4 /* MMComponentThermalTests.swift in Sources */,
|
||||||
|
6A4FDB46AE5E0DAF85CCAB56 /* MMDiskIOTests.swift in Sources */,
|
||||||
|
75CF7FF817B7EB5011FBFC18 /* MMDisplayTests.swift in Sources */,
|
||||||
E6F2F695B8D3E7045967C082 /* MMFanTests.swift in Sources */,
|
E6F2F695B8D3E7045967C082 /* MMFanTests.swift in Sources */,
|
||||||
|
AE3C28BDC3CEF40A9E8F2B20 /* MMGPUTests.swift in Sources */,
|
||||||
|
D1520F9ABB7474A5307AF483 /* MMHistoryTests.swift in Sources */,
|
||||||
|
901EF4853E785A2856E54BC9 /* MMKernelTelemetryTests.swift in Sources */,
|
||||||
|
ACC914C61AB3762EB0C40513 /* MMLoadAverageTests.swift in Sources */,
|
||||||
6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */,
|
6201FD599FED061F6C736E8B /* MMMemoryTests.swift in Sources */,
|
||||||
|
0A59F7CA4F9C04C743ACCAE7 /* MMMenuBarTests.swift in Sources */,
|
||||||
|
5D31906EA986E2BA92E86FA8 /* MMNetworkBandwidthTests.swift in Sources */,
|
||||||
|
4C1A92F4DEF4C824976115C2 /* MMNetworkSocketsTests.swift in Sources */,
|
||||||
|
22CA7351A745F7837E487051 /* MMPeripheralsTests.swift in Sources */,
|
||||||
9B5C651AD2AF7DE2738F0C44 /* MMPowerTests.swift in Sources */,
|
9B5C651AD2AF7DE2738F0C44 /* MMPowerTests.swift in Sources */,
|
||||||
|
3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */,
|
||||||
|
7C9AE2ADAED52E593B52F3AE /* MMProcessTests.swift in Sources */,
|
||||||
EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */,
|
EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */,
|
||||||
|
2665E9DAE13C13239857B4BB /* MMSnapshotContractTests.swift in Sources */,
|
||||||
1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */,
|
1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */,
|
||||||
9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */,
|
9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
@@ -362,19 +631,42 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
4D0DA9444669117CEFCB59F4 /* AlertConfiguration.swift in Sources */,
|
||||||
|
AC0887D4877FFFE7CC5B515C /* AlertEngine.swift in Sources */,
|
||||||
|
2E966D96347D1DDB24E18632 /* AlertModels.swift in Sources */,
|
||||||
|
17B49F1C470C52AF01D75B0B /* AlertNotificationDispatcher.swift in Sources */,
|
||||||
|
5B64CB79018646EF8926DB5C /* AlertsSettingsView.swift in Sources */,
|
||||||
|
9B882E4189597719436F18FB /* AlertsView.swift in Sources */,
|
||||||
A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */,
|
A9FCAEC0BF27A419DB148C4A /* ContentView.swift in Sources */,
|
||||||
CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */,
|
CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */,
|
||||||
|
D7784A61FE4635275CFB3D6C /* MMAudioTelemetryProvider.m in Sources */,
|
||||||
|
8EDDC2171C55B6E7FC999DAB /* MMBatteryTelemetryProvider.m in Sources */,
|
||||||
4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */,
|
4419FE7554489B23976E2970 /* MMCPULoadProvider.m in Sources */,
|
||||||
87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */,
|
87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */,
|
||||||
925245C5B5F8D8DA3C89C4BA /* MMComponentThermalProvider.m in Sources */,
|
925245C5B5F8D8DA3C89C4BA /* MMComponentThermalProvider.m in Sources */,
|
||||||
|
FED88F290A2F2FBD63DDEC31 /* MMDiskIOProvider.m in Sources */,
|
||||||
|
DD3E4F79A146C323CD849F2B /* MMDisplayManager.m in Sources */,
|
||||||
655366D7066478A02EDA91BB /* MMFanTelemetryProvider.m in Sources */,
|
655366D7066478A02EDA91BB /* MMFanTelemetryProvider.m in Sources */,
|
||||||
|
6D973241BBE61F0B053239DB /* MMGPUTelemetryProvider.m in Sources */,
|
||||||
|
A521961E035DD50ED8B0C1A9 /* MMKernelTelemetryProvider.m in Sources */,
|
||||||
|
951DBA5D3BBAB753322A5DF9 /* MMLoadAverageProvider.m in Sources */,
|
||||||
B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */,
|
B9C2D6574E808B517BA34C83 /* MMMemoryTelemetryProvider.m in Sources */,
|
||||||
|
E3CCE93A7D4D1EB0187D3E52 /* MMNetworkBandwidthProvider.m in Sources */,
|
||||||
|
A599E974E56F0B8D94A7E480 /* MMNetworkSocketsProvider.m in Sources */,
|
||||||
|
4752C49841375B88194F7635 /* MMPeripheralsProvider.m in Sources */,
|
||||||
0A7117B3B7CC7112F2527574 /* MMPowerTelemetryProvider.m in Sources */,
|
0A7117B3B7CC7112F2527574 /* MMPowerTelemetryProvider.m in Sources */,
|
||||||
|
2C0F7AB93691ED8AA86FBCF0 /* MMProcessDetailInspector.m in Sources */,
|
||||||
|
93EF83FE087C6DEAAE3EC0CC /* MMProcessTelemetryProvider.m in Sources */,
|
||||||
1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */,
|
1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */,
|
||||||
F2F7A141F2452EC1B4FB45D8 /* MMStorageTelemetryProvider.m in Sources */,
|
F2F7A141F2452EC1B4FB45D8 /* MMStorageTelemetryProvider.m in Sources */,
|
||||||
EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */,
|
EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */,
|
||||||
88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */,
|
88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */,
|
||||||
|
7D7568B99EC2C7C52726B510 /* MenuBarStatusView.swift in Sources */,
|
||||||
|
4D904E0FF48270D8B7CE4B61 /* QuickGlancePopoverView.swift in Sources */,
|
||||||
|
11787A1288CC3D4CCC4F248F /* RollingRingBuffer.swift in Sources */,
|
||||||
A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */,
|
A7609CE56D49EC3419987468 /* SystemTelemetryStore.swift in Sources */,
|
||||||
|
EEAAEA6AABAA55FA8DF3453B /* TelemetryHistoryStore.swift in Sources */,
|
||||||
|
475D6A2B1CF0E847DC39D331 /* TelemetryTrendChartView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -473,6 +765,7 @@
|
|||||||
CODE_SIGNING_ALLOWED = NO;
|
CODE_SIGNING_ALLOWED = NO;
|
||||||
CODE_SIGN_IDENTITY = "";
|
CODE_SIGN_IDENTITY = "";
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
@@ -486,6 +779,7 @@
|
|||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
MARKETING_VERSION = 0.0.2;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = NO;
|
ONLY_ACTIVE_ARCH = NO;
|
||||||
@@ -558,6 +852,7 @@
|
|||||||
CODE_SIGNING_ALLOWED = NO;
|
CODE_SIGNING_ALLOWED = NO;
|
||||||
CODE_SIGN_IDENTITY = "";
|
CODE_SIGN_IDENTITY = "";
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
@@ -577,6 +872,7 @@
|
|||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
MARKETING_VERSION = 0.0.2;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = NO;
|
ONLY_ACTIVE_ARCH = NO;
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ git checkout develop
|
|||||||
# Build Release binary for Intel x86_64
|
# Build Release binary for Intel x86_64
|
||||||
xcodebuild build \
|
xcodebuild build \
|
||||||
-scheme MacMonitor \
|
-scheme MacMonitor \
|
||||||
-destination 'generic/platform=macOS,arch=x86_64' \
|
-destination 'platform=macOS,arch=x86_64' \
|
||||||
-configuration Release \
|
-configuration Release \
|
||||||
CODE_SIGNING_ALLOWED=NO
|
CODE_SIGNING_ALLOWED=NO
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Bridges app-lifecycle events into SwiftUI: requests Notification Center
|
||||||
|
/// authorization once at launch so threshold alerts can post banners.
|
||||||
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
if AlertEngine.shared.configuration.alertsEnabled {
|
||||||
|
AlertEngine.shared.requestNotificationAuthorization()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct MacMonitorApp: App {
|
struct MacMonitorApp: App {
|
||||||
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
@State private var store = SystemTelemetryStore.shared
|
@State private var store = SystemTelemetryStore.shared
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
@@ -11,5 +22,20 @@ struct MacMonitorApp: App {
|
|||||||
.windowStyle(.titleBar)
|
.windowStyle(.titleBar)
|
||||||
.windowToolbarStyle(.unified)
|
.windowToolbarStyle(.unified)
|
||||||
.defaultSize(width: 960, height: 640)
|
.defaultSize(width: 960, height: 640)
|
||||||
|
|
||||||
|
MenuBarExtra {
|
||||||
|
QuickGlancePopoverView(store: store)
|
||||||
|
} label: {
|
||||||
|
MenuBarStatusView(
|
||||||
|
cpuLoad: store.cpuLoad.totalLoad,
|
||||||
|
memoryPercent: store.memory.utilizationPercentage,
|
||||||
|
activeAlertCount: store.alertEngine.activeAlerts.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.menuBarExtraStyle(.window)
|
||||||
|
|
||||||
|
Settings {
|
||||||
|
AlertsSettingsView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,143 @@ public struct PowerMetrics: Sendable {
|
|||||||
public var sensorReadings: [(key: String, name: String, value: Double, unit: String)] = []
|
public var sensorReadings: [(key: String, name: String, value: Double, unit: String)] = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Milestone 3 Advanced Telemetry Models
|
||||||
|
|
||||||
|
public struct KernelCountersMetrics: Sendable {
|
||||||
|
public var contextSwitchesPerSec: Double = 0
|
||||||
|
public var syscallsPerSec: Double = 0
|
||||||
|
public var pageFaultsPerSec: Double = 0
|
||||||
|
public var cowFaultsPerSec: Double = 0
|
||||||
|
public var zeroFillFaultsPerSec: Double = 0
|
||||||
|
public var pageinsPerSec: Double = 0
|
||||||
|
public var pageoutsPerSec: Double = 0
|
||||||
|
public var totalContextSwitches: UInt64 = 0
|
||||||
|
public var totalSyscalls: UInt64 = 0
|
||||||
|
public var totalPageFaults: UInt64 = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SystemLoadMetrics: Sendable {
|
||||||
|
public var load1Min: Double = 0
|
||||||
|
public var load5Min: Double = 0
|
||||||
|
public var load15Min: Double = 0
|
||||||
|
public var taskCount: Int = 0
|
||||||
|
public var threadCount: Int = 0
|
||||||
|
public var machFactor: Double = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DiskIOItem: Identifiable, Sendable {
|
||||||
|
public var id: String { bsdName }
|
||||||
|
public let bsdName: String
|
||||||
|
public let readBps: Double
|
||||||
|
public let writeBps: Double
|
||||||
|
public let readIOPS: Double
|
||||||
|
public let writeIOPS: Double
|
||||||
|
public let totalBytesRead: UInt64
|
||||||
|
public let totalBytesWritten: UInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct NetworkBandwidthItem: Identifiable, Sendable {
|
||||||
|
public var id: String { interfaceName }
|
||||||
|
public let interfaceName: String
|
||||||
|
public let ipAddress: String
|
||||||
|
public let isUp: Bool
|
||||||
|
public let downloadBps: Double
|
||||||
|
public let uploadBps: Double
|
||||||
|
public let downloadPps: Double
|
||||||
|
public let uploadPps: Double
|
||||||
|
public let totalBytesIn: UInt64
|
||||||
|
public let totalBytesOut: UInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SocketItem: Identifiable, Sendable {
|
||||||
|
public var id: String { "\(pid)_\(fd)_\(localAddress):\(localPort)" }
|
||||||
|
public let pid: Int32
|
||||||
|
public let fd: Int32
|
||||||
|
public let processName: String
|
||||||
|
public let protocolName: String
|
||||||
|
public let localAddress: String
|
||||||
|
public let localPort: UInt16
|
||||||
|
public let remoteAddress: String
|
||||||
|
public let remotePort: UInt16
|
||||||
|
public let tcpState: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ProcessItem: Identifiable, Sendable {
|
||||||
|
public var id: Int32 { pid }
|
||||||
|
public let pid: Int32
|
||||||
|
public let ppid: Int32
|
||||||
|
public let name: String
|
||||||
|
public let cpuPercent: Double
|
||||||
|
public let residentSize: UInt64
|
||||||
|
public let virtualSize: UInt64
|
||||||
|
public let threadCount: Int32
|
||||||
|
public let uid: UInt32
|
||||||
|
public let username: String
|
||||||
|
public let isStopped: Bool
|
||||||
|
public let isZombie: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct GPUCardItem: Identifiable, Sendable {
|
||||||
|
public var id: String { name }
|
||||||
|
public let name: String
|
||||||
|
public let isDiscrete: Bool
|
||||||
|
public let isLowPower: Bool
|
||||||
|
public let vramTotalBytes: UInt64
|
||||||
|
public let vramUsedBytes: UInt64
|
||||||
|
public let utilizationPercent: Double
|
||||||
|
public let temperature: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BatteryHealthMetrics: Sendable {
|
||||||
|
public var hasBattery: Bool = false
|
||||||
|
public var installed: Bool = false
|
||||||
|
public var healthCondition: String = "Normal"
|
||||||
|
public var healthPercent: Double = 100.0
|
||||||
|
public var cycleCount: Int = 0
|
||||||
|
public var designCycleCount: Int = 1000
|
||||||
|
public var currentCapacity: Int = 0
|
||||||
|
public var maxCapacity: Int = 0
|
||||||
|
public var designCapacity: Int = 0
|
||||||
|
public var temperature: Double = 0
|
||||||
|
public var voltage: Double = 0
|
||||||
|
public var amperage: Double = 0
|
||||||
|
public var watts: Double = 0
|
||||||
|
public var isCharging: Bool = false
|
||||||
|
public var isCharged: Bool = false
|
||||||
|
public var externalConnected: Bool = false
|
||||||
|
public var timeRemainingMinutes: Int = -1
|
||||||
|
public var manufacturer: String = "Apple"
|
||||||
|
public var serial: String = ""
|
||||||
|
public var adapterWatts: Int = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PeripheralDeviceItem: Identifiable, Sendable {
|
||||||
|
public var id: String { "\(busType)_\(locationID)_\(name)" }
|
||||||
|
public let name: String
|
||||||
|
public let busType: String
|
||||||
|
public let vendorName: String
|
||||||
|
public let vendorID: UInt32
|
||||||
|
public let productID: UInt32
|
||||||
|
public let serialNumber: String
|
||||||
|
public let locationID: UInt64
|
||||||
|
public let isBuiltIn: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AudioDeviceItem: Identifiable, Sendable {
|
||||||
|
public var id: UInt32 { deviceID }
|
||||||
|
public let deviceID: UInt32
|
||||||
|
public let name: String
|
||||||
|
public let manufacturer: String
|
||||||
|
public let isInput: Bool
|
||||||
|
public let isOutput: Bool
|
||||||
|
public let isDefaultInput: Bool
|
||||||
|
public let isDefaultOutput: Bool
|
||||||
|
public let sampleRate: Double
|
||||||
|
public let channelCount: UInt32
|
||||||
|
public let volume: Float
|
||||||
|
public let isMuted: Bool
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - SystemTelemetryStore
|
// MARK: - SystemTelemetryStore
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
@@ -95,7 +232,7 @@ public final class SystemTelemetryStore {
|
|||||||
/// Raw snapshots organized by provider identifier
|
/// Raw snapshots organized by provider identifier
|
||||||
public private(set) var latestSnapshot: [String: [String: Any]] = [:]
|
public private(set) var latestSnapshot: [String: [String: Any]] = [:]
|
||||||
|
|
||||||
/// Structured Telemetry Metrics
|
/// Milestone 2 Structured Metrics
|
||||||
public private(set) var cpuLoad = CPULoadMetrics()
|
public private(set) var cpuLoad = CPULoadMetrics()
|
||||||
public private(set) var memory = MemoryMetrics()
|
public private(set) var memory = MemoryMetrics()
|
||||||
public private(set) var storageVolumes: [StorageVolumeItem] = []
|
public private(set) var storageVolumes: [StorageVolumeItem] = []
|
||||||
@@ -104,6 +241,31 @@ public final class SystemTelemetryStore {
|
|||||||
public private(set) var componentTemps: [ComponentThermalItem] = []
|
public private(set) var componentTemps: [ComponentThermalItem] = []
|
||||||
public private(set) var power = PowerMetrics()
|
public private(set) var power = PowerMetrics()
|
||||||
|
|
||||||
|
/// Milestone 3 Structured Metrics
|
||||||
|
public private(set) var kernelCounters = KernelCountersMetrics()
|
||||||
|
public private(set) var systemLoad = SystemLoadMetrics()
|
||||||
|
public private(set) var diskIO: [DiskIOItem] = []
|
||||||
|
public private(set) var networkBandwidth: [NetworkBandwidthItem] = []
|
||||||
|
public private(set) var networkSockets: [SocketItem] = []
|
||||||
|
public private(set) var processes: [ProcessItem] = []
|
||||||
|
public private(set) var gpus: [GPUCardItem] = []
|
||||||
|
public private(set) var batteryHealth = BatteryHealthMetrics()
|
||||||
|
public private(set) var peripherals: [PeripheralDeviceItem] = []
|
||||||
|
public private(set) var audioDevices: [AudioDeviceItem] = []
|
||||||
|
public private(set) var displays: [MMDisplayInfo] = []
|
||||||
|
|
||||||
|
/// Historical Telemetry Store
|
||||||
|
public let historyStore = TelemetryHistoryStore.shared
|
||||||
|
|
||||||
|
/// Threshold alert engine (evaluated after every decode pass)
|
||||||
|
public let alertEngine = AlertEngine.shared
|
||||||
|
|
||||||
|
/// Display Manager helper
|
||||||
|
public let displayManager = MMDisplayManager.shared()
|
||||||
|
|
||||||
|
/// Process detail inspection helper
|
||||||
|
public let processInspector = MMProcessDetailInspector()
|
||||||
|
|
||||||
/// System identification
|
/// System identification
|
||||||
public let hostModel: String
|
public let hostModel: String
|
||||||
public let osVersion: String
|
public let osVersion: String
|
||||||
@@ -125,7 +287,7 @@ public final class SystemTelemetryStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func registerDefaultProviders() {
|
private func registerDefaultProviders() {
|
||||||
// Register all Primary System & Thermal Telemetry providers (M2)
|
// Milestone 2 Primary System & Thermal Telemetry
|
||||||
coordinator.register(MMCPULoadProvider())
|
coordinator.register(MMCPULoadProvider())
|
||||||
coordinator.register(MMMemoryTelemetryProvider())
|
coordinator.register(MMMemoryTelemetryProvider())
|
||||||
coordinator.register(MMStorageTelemetryProvider())
|
coordinator.register(MMStorageTelemetryProvider())
|
||||||
@@ -133,6 +295,18 @@ public final class SystemTelemetryStore {
|
|||||||
coordinator.register(MMFanTelemetryProvider())
|
coordinator.register(MMFanTelemetryProvider())
|
||||||
coordinator.register(MMComponentThermalProvider())
|
coordinator.register(MMComponentThermalProvider())
|
||||||
coordinator.register(MMPowerTelemetryProvider())
|
coordinator.register(MMPowerTelemetryProvider())
|
||||||
|
|
||||||
|
// Milestone 3 Advanced Kernel, Process & Peripheral Telemetry
|
||||||
|
coordinator.register(MMKernelTelemetryProvider())
|
||||||
|
coordinator.register(MMLoadAverageProvider())
|
||||||
|
coordinator.register(MMDiskIOProvider())
|
||||||
|
coordinator.register(MMNetworkBandwidthProvider())
|
||||||
|
coordinator.register(MMNetworkSocketsProvider())
|
||||||
|
coordinator.register(MMProcessTelemetryProvider())
|
||||||
|
coordinator.register(MMGPUTelemetryProvider())
|
||||||
|
coordinator.register(MMBatteryTelemetryProvider())
|
||||||
|
coordinator.register(MMPeripheralsProvider())
|
||||||
|
coordinator.register(MMAudioTelemetryProvider())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupCoordinator() {
|
private func setupCoordinator() {
|
||||||
@@ -145,18 +319,18 @@ public final class SystemTelemetryStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func decodeSnapshot(_ snapshot: [String: [String: Any]]) {
|
func decodeSnapshot(_ snapshot: [String: [String: Any]]) {
|
||||||
// 1. CPU Load
|
// 1. CPU Load
|
||||||
if let cpuDict = snapshot["com.i3omb.macmonitor.telemetry.cpu"] {
|
if let cpuDict = snapshot["com.i3omb.macmonitor.telemetry.cpuload"] {
|
||||||
var m = CPULoadMetrics()
|
var m = CPULoadMetrics()
|
||||||
m.systemLoad = (cpuDict["systemLoad"] as? NSNumber)?.doubleValue ?? 0
|
m.systemLoad = (cpuDict["systemPercent"] as? NSNumber)?.doubleValue ?? 0
|
||||||
m.userLoad = (cpuDict["userLoad"] as? NSNumber)?.doubleValue ?? 0
|
m.userLoad = (cpuDict["userPercent"] as? NSNumber)?.doubleValue ?? 0
|
||||||
m.idleLoad = (cpuDict["idleLoad"] as? NSNumber)?.doubleValue ?? 100
|
m.idleLoad = (cpuDict["idlePercent"] as? NSNumber)?.doubleValue ?? 100
|
||||||
m.totalLoad = (cpuDict["totalLoad"] as? NSNumber)?.doubleValue ?? 0
|
m.totalLoad = (cpuDict["totalPercent"] as? NSNumber)?.doubleValue ?? 0
|
||||||
if let perCore = cpuDict["perCoreLoad"] as? [NSNumber] {
|
if let cores = cpuDict["cores"] as? [[String: Any]] {
|
||||||
m.perCoreLoad = perCore.map { $0.doubleValue }
|
m.perCoreLoad = cores.map { ($0["totalPercent"] as? NSNumber)?.doubleValue ?? 0 }
|
||||||
}
|
}
|
||||||
m.cpuFrequencyMHz = (cpuDict["cpuFrequencyMHz"] as? NSNumber)?.doubleValue ?? 0
|
m.cpuFrequencyMHz = ((cpuDict["frequencyHz"] as? NSNumber)?.doubleValue ?? 0) / 1e6
|
||||||
m.isThrottled = (cpuDict["isThrottled"] as? NSNumber)?.boolValue ?? false
|
m.isThrottled = (cpuDict["isThrottled"] as? NSNumber)?.boolValue ?? false
|
||||||
self.cpuLoad = m
|
self.cpuLoad = m
|
||||||
}
|
}
|
||||||
@@ -173,12 +347,14 @@ public final class SystemTelemetryStore {
|
|||||||
m.compressedBytes = (memDict["compressedBytes"] as? NSNumber)?.uint64Value ?? 0
|
m.compressedBytes = (memDict["compressedBytes"] as? NSNumber)?.uint64Value ?? 0
|
||||||
m.swapTotalBytes = (memDict["swapTotalBytes"] as? NSNumber)?.uint64Value ?? 0
|
m.swapTotalBytes = (memDict["swapTotalBytes"] as? NSNumber)?.uint64Value ?? 0
|
||||||
m.swapUsedBytes = (memDict["swapUsedBytes"] as? NSNumber)?.uint64Value ?? 0
|
m.swapUsedBytes = (memDict["swapUsedBytes"] as? NSNumber)?.uint64Value ?? 0
|
||||||
m.utilizationPercentage = (memDict["utilizationPercentage"] as? NSNumber)?.doubleValue ?? 0
|
m.utilizationPercentage = m.totalBytes > 0
|
||||||
m.pressureLevel = memDict["pressureLevel"] as? String ?? "Normal"
|
? Double(m.usedBytes) / Double(m.totalBytes) * 100.0 : 0
|
||||||
|
m.pressureLevel = memDict["memoryPressureStatus"] as? String
|
||||||
|
?? memDict["pressureLevel"] as? String ?? "Normal"
|
||||||
self.memory = m
|
self.memory = m
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Storage
|
// 3. Storage Volumes
|
||||||
if let stgDict = snapshot["com.i3omb.macmonitor.telemetry.storage"],
|
if let stgDict = snapshot["com.i3omb.macmonitor.telemetry.storage"],
|
||||||
let volArray = stgDict["volumes"] as? [[String: Any]] {
|
let volArray = stgDict["volumes"] as? [[String: Any]] {
|
||||||
self.storageVolumes = volArray.compactMap { dict in
|
self.storageVolumes = volArray.compactMap { dict in
|
||||||
@@ -186,11 +362,11 @@ public final class SystemTelemetryStore {
|
|||||||
return StorageVolumeItem(
|
return StorageVolumeItem(
|
||||||
mountPoint: mount,
|
mountPoint: mount,
|
||||||
volumeName: dict["volumeName"] as? String ?? mount,
|
volumeName: dict["volumeName"] as? String ?? mount,
|
||||||
fileSystem: dict["fileSystem"] as? String ?? "APFS",
|
fileSystem: dict["fsType"] as? String ?? "APFS",
|
||||||
totalBytes: (dict["totalBytes"] as? NSNumber)?.uint64Value ?? 0,
|
totalBytes: (dict["totalBytes"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
freeBytes: (dict["freeBytes"] as? NSNumber)?.uint64Value ?? 0,
|
freeBytes: (dict["freeBytes"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
usedBytes: (dict["usedBytes"] as? NSNumber)?.uint64Value ?? 0,
|
usedBytes: (dict["usedBytes"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
usedPercentage: (dict["usedPercentage"] as? NSNumber)?.doubleValue ?? 0,
|
usedPercentage: (dict["usedPercent"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
isReadOnly: (dict["isReadOnly"] as? NSNumber)?.boolValue ?? false
|
isReadOnly: (dict["isReadOnly"] as? NSNumber)?.boolValue ?? false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -219,7 +395,7 @@ public final class SystemTelemetryStore {
|
|||||||
if let fanDict = snapshot["com.i3omb.macmonitor.telemetry.fan"],
|
if let fanDict = snapshot["com.i3omb.macmonitor.telemetry.fan"],
|
||||||
let fanArray = fanDict["fans"] as? [[String: Any]] {
|
let fanArray = fanDict["fans"] as? [[String: Any]] {
|
||||||
self.fans = fanArray.compactMap { d in
|
self.fans = fanArray.compactMap { d in
|
||||||
let idx = (d["index"] as? NSNumber)?.intValue ?? 0
|
let idx = (d["fanIndex"] as? NSNumber)?.intValue ?? 0
|
||||||
return FanTelemetryItem(
|
return FanTelemetryItem(
|
||||||
index: idx,
|
index: idx,
|
||||||
name: d["name"] as? String ?? "Fan \(idx + 1)",
|
name: d["name"] as? String ?? "Fan \(idx + 1)",
|
||||||
@@ -227,7 +403,7 @@ public final class SystemTelemetryStore {
|
|||||||
minRPM: (d["minRPM"] as? NSNumber)?.doubleValue ?? 0,
|
minRPM: (d["minRPM"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
maxRPM: (d["maxRPM"] as? NSNumber)?.doubleValue ?? 0,
|
maxRPM: (d["maxRPM"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
targetRPM: (d["targetRPM"] as? NSNumber)?.doubleValue ?? 0,
|
targetRPM: (d["targetRPM"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
utilization: (d["utilization"] as? NSNumber)?.doubleValue ?? 0
|
utilization: (d["utilizationPercent"] as? NSNumber)?.doubleValue ?? 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,6 +445,279 @@ public final class SystemTelemetryStore {
|
|||||||
}
|
}
|
||||||
self.power = m
|
self.power = m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 8. Kernel Counters
|
||||||
|
if let kDict = snapshot["com.i3omb.macmonitor.telemetry.kernel.counters"] {
|
||||||
|
var m = KernelCountersMetrics()
|
||||||
|
m.contextSwitchesPerSec = (kDict["contextSwitchesRate"] as? NSNumber)?.doubleValue ?? 0
|
||||||
|
m.syscallsPerSec = (kDict["syscallsRate"] as? NSNumber)?.doubleValue ?? 0
|
||||||
|
m.pageFaultsPerSec = (kDict["pageFaultsRate"] as? NSNumber)?.doubleValue ?? 0
|
||||||
|
m.cowFaultsPerSec = (kDict["cowFaultsRate"] as? NSNumber)?.doubleValue ?? 0
|
||||||
|
m.zeroFillFaultsPerSec = (kDict["zeroFillsRate"] as? NSNumber)?.doubleValue ?? 0
|
||||||
|
m.pageinsPerSec = (kDict["pageinsRate"] as? NSNumber)?.doubleValue ?? 0
|
||||||
|
m.pageoutsPerSec = (kDict["pageoutsRate"] as? NSNumber)?.doubleValue ?? 0
|
||||||
|
m.totalContextSwitches = (kDict["cumulative_contextSwitches"] as? NSNumber)?.uint64Value ?? 0
|
||||||
|
m.totalSyscalls = (kDict["cumulative_syscalls"] as? NSNumber)?.uint64Value ?? 0
|
||||||
|
m.totalPageFaults = (kDict["cumulative_pageFaults"] as? NSNumber)?.uint64Value ?? 0
|
||||||
|
self.kernelCounters = m
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. System Load Average & Mach Factor
|
||||||
|
if let loadDict = snapshot["com.i3omb.macmonitor.telemetry.system.load"] {
|
||||||
|
var m = SystemLoadMetrics()
|
||||||
|
m.load1Min = (loadDict["load1m"] as? NSNumber)?.doubleValue ?? 0
|
||||||
|
m.load5Min = (loadDict["load5m"] as? NSNumber)?.doubleValue ?? 0
|
||||||
|
m.load15Min = (loadDict["load15m"] as? NSNumber)?.doubleValue ?? 0
|
||||||
|
m.taskCount = (loadDict["taskCount"] as? NSNumber)?.intValue ?? 0
|
||||||
|
m.threadCount = (loadDict["threadCount"] as? NSNumber)?.intValue ?? 0
|
||||||
|
m.machFactor = (loadDict["machFactor"] as? NSNumber)?.doubleValue ?? 0
|
||||||
|
self.systemLoad = m
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Disk I/O & IOPS
|
||||||
|
if let diskDict = snapshot["com.i3omb.macmonitor.telemetry.storage.io"],
|
||||||
|
let disks = diskDict["disks"] as? [[String: Any]] {
|
||||||
|
self.diskIO = disks.compactMap { d in
|
||||||
|
guard let bsd = d["bsdName"] as? String else { return nil }
|
||||||
|
return DiskIOItem(
|
||||||
|
bsdName: bsd,
|
||||||
|
readBps: (d["readBytesPerSec"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
|
writeBps: (d["writeBytesPerSec"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
|
readIOPS: (d["readIOPS"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
|
writeIOPS: (d["writeIOPS"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
|
totalBytesRead: (d["cumulativeReadBytes"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
|
totalBytesWritten: (d["cumulativeWriteBytes"] as? NSNumber)?.uint64Value ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Network Bandwidth
|
||||||
|
if let netDict = snapshot["com.i3omb.macmonitor.telemetry.network.bandwidth"],
|
||||||
|
let ifaces = netDict["interfaces"] as? [[String: Any]] {
|
||||||
|
self.networkBandwidth = ifaces.compactMap { d in
|
||||||
|
guard let name = d["name"] as? String else { return nil }
|
||||||
|
return NetworkBandwidthItem(
|
||||||
|
interfaceName: name,
|
||||||
|
ipAddress: d["ipv4Address"] as? String ?? "",
|
||||||
|
isUp: (d["isUp"] as? NSNumber)?.boolValue ?? false,
|
||||||
|
downloadBps: (d["downloadBytesPerSec"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
|
uploadBps: (d["uploadBytesPerSec"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
|
downloadPps: (d["downloadPacketsPerSec"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
|
uploadPps: (d["uploadPacketsPerSec"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
|
totalBytesIn: (d["cumulativeInBytes"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
|
totalBytesOut: (d["cumulativeOutBytes"] as? NSNumber)?.uint64Value ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Network Sockets
|
||||||
|
if let sockDict = snapshot["com.i3omb.macmonitor.telemetry.network.sockets"],
|
||||||
|
let socks = sockDict["sockets"] as? [[String: Any]] {
|
||||||
|
self.networkSockets = socks.compactMap { d in
|
||||||
|
let pid = (d["pid"] as? NSNumber)?.int32Value ?? 0
|
||||||
|
let fd = (d["fd"] as? NSNumber)?.int32Value ?? 0
|
||||||
|
return SocketItem(
|
||||||
|
pid: pid,
|
||||||
|
fd: fd,
|
||||||
|
processName: d["processName"] as? String ?? "",
|
||||||
|
protocolName: d["protocol"] as? String ?? "TCP",
|
||||||
|
localAddress: d["localAddress"] as? String ?? "*",
|
||||||
|
localPort: (d["localPort"] as? NSNumber)?.uint16Value ?? 0,
|
||||||
|
remoteAddress: d["remoteAddress"] as? String ?? "*",
|
||||||
|
remotePort: (d["remotePort"] as? NSNumber)?.uint16Value ?? 0,
|
||||||
|
tcpState: d["tcpState"] as? String ?? "ESTABLISHED"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. Processes
|
||||||
|
if let procDict = snapshot["com.i3omb.macmonitor.telemetry.process"],
|
||||||
|
let procs = procDict["processes"] as? [[String: Any]] {
|
||||||
|
self.processes = procs.compactMap { d in
|
||||||
|
guard let pid = (d["pid"] as? NSNumber)?.int32Value else { return nil }
|
||||||
|
return ProcessItem(
|
||||||
|
pid: pid,
|
||||||
|
ppid: (d["ppid"] as? NSNumber)?.int32Value ?? 0,
|
||||||
|
name: d["name"] as? String ?? "Unknown",
|
||||||
|
cpuPercent: (d["cpuPercent"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
|
residentSize: (d["residentBytes"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
|
virtualSize: (d["virtualBytes"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
|
threadCount: (d["threadCount"] as? NSNumber)?.int32Value ?? 1,
|
||||||
|
uid: (d["uid"] as? NSNumber)?.uint32Value ?? 0,
|
||||||
|
username: d["username"] as? String ?? "",
|
||||||
|
isStopped: (d["isStopped"] as? NSNumber)?.boolValue ?? false,
|
||||||
|
isZombie: (d["isZombie"] as? NSNumber)?.boolValue ?? false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 14. GPU Telemetry
|
||||||
|
if let gpuDict = snapshot["com.i3omb.macmonitor.telemetry.gpu"],
|
||||||
|
let cards = gpuDict["gpus"] as? [[String: Any]] {
|
||||||
|
self.gpus = cards.compactMap { d in
|
||||||
|
guard let name = d["name"] as? String else { return nil }
|
||||||
|
return GPUCardItem(
|
||||||
|
name: name,
|
||||||
|
isDiscrete: (d["isDiscrete"] as? NSNumber)?.boolValue ?? false,
|
||||||
|
isLowPower: (d["isLowPower"] as? NSNumber)?.boolValue ?? false,
|
||||||
|
vramTotalBytes: (d["vramTotalBytes"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
|
vramUsedBytes: (d["vramUsedBytes"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
|
utilizationPercent: (d["utilizationPercent"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
|
temperature: (d["temperature"] as? NSNumber)?.doubleValue ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15. Battery Health
|
||||||
|
if let battDict = snapshot["com.i3omb.macmonitor.telemetry.battery"] {
|
||||||
|
var m = BatteryHealthMetrics()
|
||||||
|
m.hasBattery = (battDict["hasBattery"] as? NSNumber)?.boolValue ?? false
|
||||||
|
m.installed = (battDict["installed"] as? NSNumber)?.boolValue ?? false
|
||||||
|
m.healthCondition = battDict["healthCondition"] as? String ?? "Normal"
|
||||||
|
m.healthPercent = (battDict["healthPercent"] as? NSNumber)?.doubleValue ?? 100.0
|
||||||
|
m.cycleCount = (battDict["cycleCount"] as? NSNumber)?.intValue ?? 0
|
||||||
|
m.designCycleCount = (battDict["designCycleCount"] as? NSNumber)?.intValue ?? 1000
|
||||||
|
m.currentCapacity = (battDict["currentCapacity"] as? NSNumber)?.intValue ?? 0
|
||||||
|
m.maxCapacity = (battDict["maxCapacity"] as? NSNumber)?.intValue ?? 0
|
||||||
|
m.designCapacity = (battDict["designCapacity"] as? NSNumber)?.intValue ?? 0
|
||||||
|
m.temperature = (battDict["temperature"] as? NSNumber)?.doubleValue ?? 0
|
||||||
|
m.voltage = (battDict["voltage"] as? NSNumber)?.doubleValue ?? 0
|
||||||
|
m.amperage = (battDict["amperage"] as? NSNumber)?.doubleValue ?? 0
|
||||||
|
m.watts = (battDict["watts"] as? NSNumber)?.doubleValue ?? 0
|
||||||
|
m.isCharging = (battDict["isCharging"] as? NSNumber)?.boolValue ?? false
|
||||||
|
m.isCharged = (battDict["isCharged"] as? NSNumber)?.boolValue ?? false
|
||||||
|
m.externalConnected = (battDict["externalConnected"] as? NSNumber)?.boolValue ?? false
|
||||||
|
m.timeRemainingMinutes = (battDict["timeRemainingMinutes"] as? NSNumber)?.intValue ?? -1
|
||||||
|
m.manufacturer = battDict["manufacturer"] as? String ?? "Apple"
|
||||||
|
m.serial = battDict["serial"] as? String ?? ""
|
||||||
|
m.adapterWatts = (battDict["adapterWatts"] as? NSNumber)?.intValue ?? 0
|
||||||
|
self.batteryHealth = m
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16. Connected Peripherals
|
||||||
|
if let periphDict = snapshot["com.i3omb.macmonitor.telemetry.peripherals"] {
|
||||||
|
var allDevs: [PeripheralDeviceItem] = []
|
||||||
|
if let usb = periphDict["usbDevices"] as? [[String: Any]] {
|
||||||
|
allDevs.append(contentsOf: usb.compactMap { d in
|
||||||
|
guard let name = d["name"] as? String else { return nil }
|
||||||
|
return PeripheralDeviceItem(
|
||||||
|
name: name,
|
||||||
|
busType: "USB",
|
||||||
|
vendorName: d["vendorName"] as? String ?? "",
|
||||||
|
vendorID: (d["vendorID"] as? NSNumber)?.uint32Value ?? 0,
|
||||||
|
productID: (d["productID"] as? NSNumber)?.uint32Value ?? 0,
|
||||||
|
serialNumber: d["serialNumber"] as? String ?? "",
|
||||||
|
locationID: (d["locationID"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
|
isBuiltIn: (d["isBuiltIn"] as? NSNumber)?.boolValue ?? false
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if let tb = periphDict["thunderboltDevices"] as? [[String: Any]] {
|
||||||
|
allDevs.append(contentsOf: tb.compactMap { d in
|
||||||
|
guard let name = d["name"] as? String else { return nil }
|
||||||
|
return PeripheralDeviceItem(
|
||||||
|
name: name,
|
||||||
|
busType: "Thunderbolt",
|
||||||
|
vendorName: d["vendorName"] as? String ?? "",
|
||||||
|
vendorID: (d["vendorID"] as? NSNumber)?.uint32Value ?? 0,
|
||||||
|
productID: (d["productID"] as? NSNumber)?.uint32Value ?? 0,
|
||||||
|
serialNumber: "",
|
||||||
|
locationID: 0,
|
||||||
|
isBuiltIn: false
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if let pci = periphDict["pciDevices"] as? [[String: Any]] {
|
||||||
|
allDevs.append(contentsOf: pci.compactMap { d in
|
||||||
|
guard let name = d["name"] as? String else { return nil }
|
||||||
|
return PeripheralDeviceItem(
|
||||||
|
name: name,
|
||||||
|
busType: "PCI",
|
||||||
|
vendorName: "Apple / Intel",
|
||||||
|
vendorID: (d["vendorID"] as? NSNumber)?.uint32Value ?? 0,
|
||||||
|
productID: (d["productID"] as? NSNumber)?.uint32Value ?? 0,
|
||||||
|
serialNumber: "",
|
||||||
|
locationID: 0,
|
||||||
|
isBuiltIn: true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
self.peripherals = allDevs
|
||||||
|
}
|
||||||
|
|
||||||
|
// 17. Audio Devices
|
||||||
|
if let audioDict = snapshot["com.i3omb.macmonitor.telemetry.audio"],
|
||||||
|
let devs = audioDict["devices"] as? [[String: Any]] {
|
||||||
|
self.audioDevices = devs.compactMap { d in
|
||||||
|
guard let devID = (d["deviceID"] as? NSNumber)?.uint32Value else { return nil }
|
||||||
|
return AudioDeviceItem(
|
||||||
|
deviceID: devID,
|
||||||
|
name: d["name"] as? String ?? "Audio Device",
|
||||||
|
manufacturer: d["manufacturer"] as? String ?? "Apple Inc.",
|
||||||
|
isInput: (d["isInput"] as? NSNumber)?.boolValue ?? false,
|
||||||
|
isOutput: (d["isOutput"] as? NSNumber)?.boolValue ?? false,
|
||||||
|
isDefaultInput: (d["isDefaultInput"] as? NSNumber)?.boolValue ?? false,
|
||||||
|
isDefaultOutput: (d["isDefaultOutput"] as? NSNumber)?.boolValue ?? false,
|
||||||
|
sampleRate: (d["sampleRate"] as? NSNumber)?.doubleValue ?? 44100.0,
|
||||||
|
channelCount: (d["channelCount"] as? NSNumber)?.uint32Value ?? 2,
|
||||||
|
volume: (d["volume"] as? NSNumber)?.floatValue ?? 0,
|
||||||
|
isMuted: (d["isMuted"] as? NSNumber)?.boolValue ?? false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 18. Display Hardware
|
||||||
|
self.displays = self.displayManager.activeDisplays()
|
||||||
|
|
||||||
|
// 19. Record to Historical Ring Buffers
|
||||||
|
let totalDownBps = self.networkBandwidth.reduce(0.0) { $0 + $1.downloadBps }
|
||||||
|
let totalUpBps = self.networkBandwidth.reduce(0.0) { $0 + $1.uploadBps }
|
||||||
|
let totalDiskReadBps = self.diskIO.reduce(0.0) { $0 + $1.readBps }
|
||||||
|
let totalDiskWriteBps = self.diskIO.reduce(0.0) { $0 + $1.writeBps }
|
||||||
|
|
||||||
|
let snapshotRecord = TelemetrySnapshotRecord(
|
||||||
|
cpuLoad: self.cpuLoad.totalLoad,
|
||||||
|
memoryUsedPercent: self.memory.utilizationPercentage,
|
||||||
|
networkInBps: totalDownBps,
|
||||||
|
networkOutBps: totalUpBps,
|
||||||
|
diskReadBps: totalDiskReadBps,
|
||||||
|
diskWriteBps: totalDiskWriteBps,
|
||||||
|
cpuTemp: self.cpuThermal.packageTemperature,
|
||||||
|
systemPowerWatts: self.power.systemTotalWatts,
|
||||||
|
timestamp: self.lastUpdateTimestamp
|
||||||
|
)
|
||||||
|
self.historyStore.record(snapshot: snapshotRecord)
|
||||||
|
|
||||||
|
// 20. Evaluate Threshold Alert Rules
|
||||||
|
var context = AlertEvaluationContext(timestamp: self.lastUpdateTimestamp)
|
||||||
|
context.peakCoreTemperature = self.cpuThermal.peakCoreTemperature
|
||||||
|
context.packageTemperature = self.cpuThermal.packageTemperature
|
||||||
|
context.fanRPMs = Dictionary(uniqueKeysWithValues: self.fans.map { ($0.index, $0.currentRPM) })
|
||||||
|
context.memoryFreeBytes = self.memory.freeBytes
|
||||||
|
context.memoryPressureLevel = self.memory.pressureLevel
|
||||||
|
context.volumes = self.storageVolumes.map {
|
||||||
|
StorageVolumeContext(
|
||||||
|
mountPoint: $0.mountPoint,
|
||||||
|
volumeName: $0.volumeName,
|
||||||
|
freeBytes: $0.freeBytes,
|
||||||
|
freePercent: $0.totalBytes > 0
|
||||||
|
? Double($0.freeBytes) / Double($0.totalBytes) * 100.0
|
||||||
|
: 0,
|
||||||
|
totalBytes: $0.totalBytes,
|
||||||
|
isReadOnly: $0.isReadOnly
|
||||||
|
)
|
||||||
|
}
|
||||||
|
context.batteryLevel = self.batteryHealth.maxCapacity > 0
|
||||||
|
? Double(self.batteryHealth.currentCapacity) / Double(self.batteryHealth.maxCapacity) * 100.0
|
||||||
|
: self.power.batteryLevel
|
||||||
|
context.hasBattery = self.batteryHealth.hasBattery
|
||||||
|
context.onExternalPower = self.batteryHealth.externalConnected
|
||||||
|
context.isCharging = self.batteryHealth.isCharging || self.power.isCharging
|
||||||
|
self.alertEngine.evaluate(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func terminateProcess(pid: pid_t, force: Bool) -> Bool {
|
||||||
|
return MMProcessTelemetryProvider.terminateProcess(withPID: pid, force: force)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func start() {
|
public func start() {
|
||||||
|
|||||||
@@ -25,5 +25,11 @@
|
|||||||
#import "MMNetworkBandwidthProvider.h"
|
#import "MMNetworkBandwidthProvider.h"
|
||||||
#import "MMNetworkSocketsProvider.h"
|
#import "MMNetworkSocketsProvider.h"
|
||||||
#import "MMProcessTelemetryProvider.h"
|
#import "MMProcessTelemetryProvider.h"
|
||||||
|
#import "MMProcessDetailInspector.h"
|
||||||
|
#import "MMGPUTelemetryProvider.h"
|
||||||
|
#import "MMBatteryTelemetryProvider.h"
|
||||||
|
#import "MMPeripheralsProvider.h"
|
||||||
|
#import "MMAudioTelemetryProvider.h"
|
||||||
|
#import "MMDisplayManager.h"
|
||||||
|
|
||||||
#endif /* MacMonitor_Bridging_Header_h */
|
#endif /* MacMonitor_Bridging_Header_h */
|
||||||
|
|||||||
@@ -57,13 +57,13 @@
|
|||||||
return YES;
|
return YES;
|
||||||
}
|
}
|
||||||
|
|
||||||
CFMutableDictionaryRef matching = IOServiceMatching("AppleSMCClient");
|
CFMutableDictionaryRef matching = IOServiceMatching("AppleSMC");
|
||||||
if (!matching) {
|
if (!matching) {
|
||||||
os_unfair_lock_unlock(&_lock);
|
os_unfair_lock_unlock(&_lock);
|
||||||
if (error) {
|
if (error) {
|
||||||
*error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC"
|
*error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC"
|
||||||
code:-1
|
code:-1
|
||||||
userInfo:@{NSLocalizedDescriptionKey: @"Failed to create AppleSMCClient matching dictionary"}];
|
userInfo:@{NSLocalizedDescriptionKey: @"Failed to create AppleSMC matching dictionary"}];
|
||||||
}
|
}
|
||||||
return NO;
|
return NO;
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
if (error) {
|
if (error) {
|
||||||
*error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC"
|
*error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC"
|
||||||
code:-2
|
code:-2
|
||||||
userInfo:@{NSLocalizedDescriptionKey: @"AppleSMCClient service not found (running on non-Mac or unsupported VM)"}];
|
userInfo:@{NSLocalizedDescriptionKey: @"AppleSMC service not found (running on non-Mac or unsupported VM)"}];
|
||||||
}
|
}
|
||||||
return NO;
|
return NO;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@
|
|||||||
#define kSMCGetKeyFromIndex 8
|
#define kSMCGetKeyFromIndex 8
|
||||||
#define kSMCGetKeyInfo 9
|
#define kSMCGetKeyInfo 9
|
||||||
|
|
||||||
#pragma pack(push, 1)
|
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
unsigned char major;
|
unsigned char major;
|
||||||
unsigned char minor;
|
unsigned char minor;
|
||||||
@@ -58,8 +56,6 @@ typedef struct {
|
|||||||
UInt8 bytes[32];
|
UInt8 bytes[32];
|
||||||
} SMCVal_t;
|
} SMCVal_t;
|
||||||
|
|
||||||
#pragma pack(pop)
|
|
||||||
|
|
||||||
static inline UInt32 MMSMCToFourCharCode(const char * _Nonnull str) {
|
static inline UInt32 MMSMCToFourCharCode(const char * _Nonnull str) {
|
||||||
UInt32 code = 0;
|
UInt32 code = 0;
|
||||||
for (int i = 0; i < 4 && str[i] != '\0'; i++) {
|
for (int i = 0; i < 4 && str[i] != '\0'; i++) {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct HistoricalSample<T>: Identifiable {
|
||||||
|
public let id: UUID
|
||||||
|
public let timestamp: Date
|
||||||
|
public let value: T
|
||||||
|
|
||||||
|
public init(timestamp: Date = Date(), value: T) {
|
||||||
|
self.id = UUID()
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class RollingRingBuffer<T> {
|
||||||
|
private var buffer: [HistoricalSample<T>]
|
||||||
|
private let capacity: Int
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
public init(capacity: Int = 60) {
|
||||||
|
self.capacity = max(1, capacity)
|
||||||
|
self.buffer = []
|
||||||
|
self.buffer.reserveCapacity(self.capacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func append(_ value: T, timestamp: Date = Date()) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
|
||||||
|
let sample = HistoricalSample(timestamp: timestamp, value: value)
|
||||||
|
if buffer.count >= capacity {
|
||||||
|
buffer.removeFirst()
|
||||||
|
}
|
||||||
|
buffer.append(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var samples: [HistoricalSample<T>] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
public var count: Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return buffer.count
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clear() {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
buffer.removeAll(keepingCapacity: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct TelemetrySnapshotRecord {
|
||||||
|
public let cpuLoad: Double
|
||||||
|
public let memoryUsedPercent: Double
|
||||||
|
public let networkInBps: Double
|
||||||
|
public let networkOutBps: Double
|
||||||
|
public let diskReadBps: Double
|
||||||
|
public let diskWriteBps: Double
|
||||||
|
public let cpuTemp: Double
|
||||||
|
public let systemPowerWatts: Double
|
||||||
|
public let timestamp: Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
cpuLoad: Double = 0.0,
|
||||||
|
memoryUsedPercent: Double = 0.0,
|
||||||
|
networkInBps: Double = 0.0,
|
||||||
|
networkOutBps: Double = 0.0,
|
||||||
|
diskReadBps: Double = 0.0,
|
||||||
|
diskWriteBps: Double = 0.0,
|
||||||
|
cpuTemp: Double = 0.0,
|
||||||
|
systemPowerWatts: Double = 0.0,
|
||||||
|
timestamp: Date = Date()
|
||||||
|
) {
|
||||||
|
self.cpuLoad = cpuLoad
|
||||||
|
self.memoryUsedPercent = memoryUsedPercent
|
||||||
|
self.networkInBps = networkInBps
|
||||||
|
self.networkOutBps = networkOutBps
|
||||||
|
self.diskReadBps = diskReadBps
|
||||||
|
self.diskWriteBps = diskWriteBps
|
||||||
|
self.cpuTemp = cpuTemp
|
||||||
|
self.systemPowerWatts = systemPowerWatts
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
public final class TelemetryHistoryStore {
|
||||||
|
public static let shared = TelemetryHistoryStore()
|
||||||
|
|
||||||
|
public let cpuHistory = RollingRingBuffer<Double>(capacity: 60)
|
||||||
|
public let memoryHistory = RollingRingBuffer<Double>(capacity: 60)
|
||||||
|
public let networkInHistory = RollingRingBuffer<Double>(capacity: 60)
|
||||||
|
public let networkOutHistory = RollingRingBuffer<Double>(capacity: 60)
|
||||||
|
public let diskReadHistory = RollingRingBuffer<Double>(capacity: 60)
|
||||||
|
public let diskWriteHistory = RollingRingBuffer<Double>(capacity: 60)
|
||||||
|
public let cpuTempHistory = RollingRingBuffer<Double>(capacity: 60)
|
||||||
|
public let powerHistory = RollingRingBuffer<Double>(capacity: 60)
|
||||||
|
|
||||||
|
public private(set) var sampleCounter: UInt64 = 0
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func record(snapshot: TelemetrySnapshotRecord) {
|
||||||
|
cpuHistory.append(snapshot.cpuLoad, timestamp: snapshot.timestamp)
|
||||||
|
memoryHistory.append(snapshot.memoryUsedPercent, timestamp: snapshot.timestamp)
|
||||||
|
networkInHistory.append(snapshot.networkInBps, timestamp: snapshot.timestamp)
|
||||||
|
networkOutHistory.append(snapshot.networkOutBps, timestamp: snapshot.timestamp)
|
||||||
|
diskReadHistory.append(snapshot.diskReadBps, timestamp: snapshot.timestamp)
|
||||||
|
diskWriteHistory.append(snapshot.diskWriteBps, timestamp: snapshot.timestamp)
|
||||||
|
cpuTempHistory.append(snapshot.cpuTemp, timestamp: snapshot.timestamp)
|
||||||
|
powerHistory.append(snapshot.systemPowerWatts, timestamp: snapshot.timestamp)
|
||||||
|
sampleCounter &+= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Alert Configuration
|
||||||
|
|
||||||
|
/// User-tunable threshold and cooldown settings for `AlertEngine`.
|
||||||
|
/// Persisted as a single JSON blob in `UserDefaults` so the engine can read
|
||||||
|
/// configuration outside of a SwiftUI context.
|
||||||
|
public struct AlertConfiguration: Codable, Equatable, Sendable {
|
||||||
|
/// Global switch — when false, no rules are evaluated.
|
||||||
|
public var alertsEnabled: Bool = true
|
||||||
|
|
||||||
|
/// Minimum minutes between two notifications for the same alert identifier
|
||||||
|
/// while a condition remains breached.
|
||||||
|
public var cooldownMinutes: Double = 15
|
||||||
|
|
||||||
|
// MARK: CPU Temperature
|
||||||
|
public var cpuTempEnabled: Bool = true
|
||||||
|
/// Degrees Celsius; breach when the highest core/package reading reaches this.
|
||||||
|
public var cpuTempThresholdC: Double = 95
|
||||||
|
|
||||||
|
// MARK: Fan Stall
|
||||||
|
public var fanStallEnabled: Bool = true
|
||||||
|
/// Degrees Celsius; a fan reading 0 RPM only alerts while the CPU is at or
|
||||||
|
/// above this temperature (distinguishes genuine stalls from idle spin-down).
|
||||||
|
public var fanStallTempThresholdC: Double = 75
|
||||||
|
|
||||||
|
// MARK: Memory Pressure
|
||||||
|
public var memoryEnabled: Bool = true
|
||||||
|
/// Free physical RAM threshold in megabytes.
|
||||||
|
public var memoryFreeMBThreshold: Double = 500
|
||||||
|
/// Also alert whenever the kernel pressure tier reports "Critical".
|
||||||
|
public var memoryAlertOnCriticalPressure: Bool = true
|
||||||
|
|
||||||
|
// MARK: Storage
|
||||||
|
public var storageEnabled: Bool = true
|
||||||
|
/// Free-space threshold per volume in gigabytes.
|
||||||
|
public var storageFreeGBThreshold: Double = 10
|
||||||
|
/// Free-space threshold per volume as a percentage of capacity.
|
||||||
|
public var storageFreePercentThreshold: Double = 10
|
||||||
|
|
||||||
|
// MARK: Battery
|
||||||
|
public var batteryEnabled: Bool = true
|
||||||
|
/// Charge percentage; only evaluated while discharging on battery power.
|
||||||
|
public var batteryPercentThreshold: Double = 10
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UserDefaults Persistence
|
||||||
|
|
||||||
|
public extension AlertConfiguration {
|
||||||
|
static let storageKey = "com.i3omb.macmonitor.alertConfiguration"
|
||||||
|
|
||||||
|
static func load(from defaults: UserDefaults = .standard) -> AlertConfiguration {
|
||||||
|
guard let data = defaults.data(forKey: storageKey),
|
||||||
|
let config = try? JSONDecoder().decode(AlertConfiguration.self, from: data) else {
|
||||||
|
return AlertConfiguration()
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(to defaults: UserDefaults = .standard) {
|
||||||
|
if let data = try? JSONEncoder().encode(self) {
|
||||||
|
defaults.set(data, forKey: AlertConfiguration.storageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
// MARK: - Alert Engine
|
||||||
|
|
||||||
|
/// Evaluates threshold rules against each decoded telemetry snapshot.
|
||||||
|
/// Runs on `@MainActor` — it performs only in-memory comparisons over
|
||||||
|
/// `AlertEvaluationContext` and never touches IOKit/Mach/sysctl, preserving
|
||||||
|
/// the zero-UI-blocking invariant.
|
||||||
|
///
|
||||||
|
/// Lifecycle per alert identifier:
|
||||||
|
/// breach edge -> fire notification (subject to cooldown), log `.triggered`
|
||||||
|
/// persistent -> re-fire only after `cooldownMinutes` elapse
|
||||||
|
/// recovery -> remove from active set, log `.resolved`, clear cooldown so
|
||||||
|
/// the next breach alerts immediately
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
public final class AlertEngine {
|
||||||
|
public static let shared = AlertEngine()
|
||||||
|
|
||||||
|
/// Currently breached conditions, refreshed every evaluation pass.
|
||||||
|
public private(set) var activeAlerts: [SystemAlert] = []
|
||||||
|
/// Newest-first audit log of trigger/resolve events (capped).
|
||||||
|
public private(set) var history: [AlertEvent] = []
|
||||||
|
|
||||||
|
/// Threshold settings. Mutations are persisted immediately.
|
||||||
|
public var configuration: AlertConfiguration {
|
||||||
|
didSet { configuration.save(to: defaults) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public let dispatcher: any AlertNotificationDispatching
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let historyLimit = 200
|
||||||
|
private var lastFiredAt: [String: Date] = [:]
|
||||||
|
private var breachedAlerts: [String: SystemAlert] = [:]
|
||||||
|
|
||||||
|
private convenience init() {
|
||||||
|
self.init(dispatcher: UNAlertNotificationDispatcher(), defaults: .standard)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(dispatcher: any AlertNotificationDispatching, defaults: UserDefaults) {
|
||||||
|
self.dispatcher = dispatcher
|
||||||
|
self.defaults = defaults
|
||||||
|
self.configuration = AlertConfiguration.load(from: defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forwards to the dispatcher; call once at launch (and from Settings).
|
||||||
|
public func requestNotificationAuthorization() {
|
||||||
|
dispatcher.requestAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func notificationAuthorizationStatus() async -> UNAuthorizationStatus {
|
||||||
|
await dispatcher.authorizationStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Evaluation
|
||||||
|
|
||||||
|
public func evaluate(_ context: AlertEvaluationContext) {
|
||||||
|
guard configuration.alertsEnabled else {
|
||||||
|
// Wipe silently so stale alerts do not linger while disabled.
|
||||||
|
activeAlerts = []
|
||||||
|
breachedAlerts = [:]
|
||||||
|
lastFiredAt = [:]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidates = buildCandidates(for: context)
|
||||||
|
var currentIDs = Set<String>()
|
||||||
|
|
||||||
|
for alert in candidates {
|
||||||
|
currentIDs.insert(alert.id)
|
||||||
|
breachedAlerts[alert.id] = alert
|
||||||
|
if shouldFire(alertID: alert.id, at: context.timestamp) {
|
||||||
|
fire(alert, at: context.timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedIDs = breachedAlerts.keys
|
||||||
|
.filter { !currentIDs.contains($0) }
|
||||||
|
.sorted()
|
||||||
|
for id in resolvedIDs {
|
||||||
|
let resolved = breachedAlerts.removeValue(forKey: id)
|
||||||
|
lastFiredAt.removeValue(forKey: id)
|
||||||
|
let kind = resolved?.kind ?? Self.kind(fromAlertID: id)
|
||||||
|
appendEvent(
|
||||||
|
alertID: id,
|
||||||
|
kind: kind,
|
||||||
|
outcome: .resolved,
|
||||||
|
message: String(localized: "\(resolved?.title ?? id) — condition cleared"),
|
||||||
|
at: context.timestamp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeAlerts = candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all active/breached state and the audit log.
|
||||||
|
public func reset() {
|
||||||
|
activeAlerts = []
|
||||||
|
history = []
|
||||||
|
breachedAlerts = [:]
|
||||||
|
lastFiredAt = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clearHistory() {
|
||||||
|
history = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Rule Evaluation
|
||||||
|
|
||||||
|
private func buildCandidates(for ctx: AlertEvaluationContext) -> [SystemAlert] {
|
||||||
|
let cfg = configuration
|
||||||
|
var alerts: [SystemAlert] = []
|
||||||
|
|
||||||
|
// 1. High CPU temperature
|
||||||
|
if cfg.cpuTempEnabled {
|
||||||
|
let temp = max(ctx.peakCoreTemperature, ctx.packageTemperature)
|
||||||
|
if temp >= cfg.cpuTempThresholdC {
|
||||||
|
alerts.append(SystemAlert(
|
||||||
|
id: AlertKind.cpuTemperature.rawValue,
|
||||||
|
kind: .cpuTemperature,
|
||||||
|
severity: .critical,
|
||||||
|
title: String(localized: "High CPU Temperature"),
|
||||||
|
message: String(localized: "CPU reached \(Int(temp))°C — threshold is \(Int(cfg.cpuTempThresholdC))°C"),
|
||||||
|
timestamp: ctx.timestamp
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Cooling fan stall — only meaningful when fans are reported and the
|
||||||
|
// CPU is hot enough that a working fan should be spinning.
|
||||||
|
if cfg.fanStallEnabled,
|
||||||
|
!ctx.fanRPMs.isEmpty,
|
||||||
|
ctx.packageTemperature >= cfg.fanStallTempThresholdC {
|
||||||
|
for (index, rpm) in ctx.fanRPMs.sorted(by: { $0.key < $1.key }) where rpm <= 0 {
|
||||||
|
alerts.append(SystemAlert(
|
||||||
|
id: "\(AlertKind.fanStall.rawValue):\(index)",
|
||||||
|
kind: .fanStall,
|
||||||
|
severity: .critical,
|
||||||
|
title: String(localized: "Cooling Fan Failure"),
|
||||||
|
message: String(localized: "Fan \(index + 1) reads 0 RPM while the CPU is at \(Int(ctx.packageTemperature))°C"),
|
||||||
|
timestamp: ctx.timestamp
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Low free memory / critical pressure
|
||||||
|
if cfg.memoryEnabled {
|
||||||
|
let freeMB = Double(ctx.memoryFreeBytes) / (1024 * 1024)
|
||||||
|
let critical = cfg.memoryAlertOnCriticalPressure
|
||||||
|
&& ctx.memoryPressureLevel.localizedCaseInsensitiveContains("critical")
|
||||||
|
if freeMB < cfg.memoryFreeMBThreshold || critical {
|
||||||
|
alerts.append(SystemAlert(
|
||||||
|
id: AlertKind.memoryPressure.rawValue,
|
||||||
|
kind: .memoryPressure,
|
||||||
|
severity: critical ? .critical : .warning,
|
||||||
|
title: String(localized: "Memory Pressure"),
|
||||||
|
message: String(localized: "Only \(Int(freeMB)) MB RAM free — pressure is \(ctx.memoryPressureLevel)"),
|
||||||
|
timestamp: ctx.timestamp
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Low storage space (per mounted volume)
|
||||||
|
if cfg.storageEnabled {
|
||||||
|
// Skip read-only mounts (DMGs, snapshots) and trivially small
|
||||||
|
// volumes where free-space thresholds are meaningless.
|
||||||
|
let minTotalBytes: UInt64 = 1_073_741_824 // 1 GB
|
||||||
|
for volume in ctx.volumes where !volume.isReadOnly && volume.totalBytes >= minTotalBytes {
|
||||||
|
let freeGB = Double(volume.freeBytes) / (1024 * 1024 * 1024)
|
||||||
|
if freeGB < cfg.storageFreeGBThreshold || volume.freePercent < cfg.storageFreePercentThreshold {
|
||||||
|
alerts.append(SystemAlert(
|
||||||
|
id: "\(AlertKind.lowStorage.rawValue):\(volume.mountPoint)",
|
||||||
|
kind: .lowStorage,
|
||||||
|
severity: .warning,
|
||||||
|
title: String(localized: "Low Disk Space"),
|
||||||
|
message: String(localized: "\"\(volume.volumeName)\" has \(String(format: "%.1f", freeGB)) GB free (\(Int(volume.freePercent))%)"),
|
||||||
|
timestamp: ctx.timestamp
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Low battery — only while actually discharging on battery power.
|
||||||
|
if cfg.batteryEnabled,
|
||||||
|
ctx.hasBattery,
|
||||||
|
!ctx.onExternalPower,
|
||||||
|
!ctx.isCharging,
|
||||||
|
ctx.batteryLevel <= cfg.batteryPercentThreshold {
|
||||||
|
alerts.append(SystemAlert(
|
||||||
|
id: AlertKind.lowBattery.rawValue,
|
||||||
|
kind: .lowBattery,
|
||||||
|
severity: .critical,
|
||||||
|
title: String(localized: "Low Battery"),
|
||||||
|
message: String(localized: "Battery is at \(Int(ctx.batteryLevel))% and not charging"),
|
||||||
|
timestamp: ctx.timestamp
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return alerts
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Cooldown & History
|
||||||
|
|
||||||
|
private func shouldFire(alertID: String, at date: Date) -> Bool {
|
||||||
|
guard let last = lastFiredAt[alertID] else { return true }
|
||||||
|
return date.timeIntervalSince(last) >= configuration.cooldownMinutes * 60
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fire(_ alert: SystemAlert, at date: Date) {
|
||||||
|
lastFiredAt[alert.id] = date
|
||||||
|
dispatcher.dispatch(alert)
|
||||||
|
appendEvent(alertID: alert.id, kind: alert.kind, outcome: .triggered, message: alert.message, at: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appendEvent(alertID: String, kind: AlertKind, outcome: AlertEventOutcome, message: String, at date: Date) {
|
||||||
|
history.insert(
|
||||||
|
AlertEvent(timestamp: date, alertID: alertID, kind: kind, outcome: outcome, message: message),
|
||||||
|
at: 0
|
||||||
|
)
|
||||||
|
if history.count > historyLimit {
|
||||||
|
history.removeLast(history.count - historyLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func kind(fromAlertID id: String) -> AlertKind {
|
||||||
|
let prefix = id.prefix { $0 != ":" }
|
||||||
|
return AlertKind(rawValue: String(prefix)) ?? .cpuTemperature
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Alert Models
|
||||||
|
|
||||||
|
/// Categories of threshold conditions evaluated by `AlertEngine`.
|
||||||
|
public enum AlertKind: String, Codable, CaseIterable, Sendable {
|
||||||
|
case cpuTemperature
|
||||||
|
case fanStall
|
||||||
|
case memoryPressure
|
||||||
|
case lowStorage
|
||||||
|
case lowBattery
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Visual and audible emphasis level for a raised alert.
|
||||||
|
public enum AlertSeverity: String, Codable, Sendable {
|
||||||
|
case warning
|
||||||
|
case critical
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single breached threshold condition.
|
||||||
|
public struct SystemAlert: Identifiable, Equatable, Sendable {
|
||||||
|
/// Stable identifier of the form `"<kind>"` or `"<kind>:<subject>"`
|
||||||
|
/// (e.g. `"lowStorage:/"`, `"fanStall:0"`) used for cooldown bookkeeping.
|
||||||
|
public let id: String
|
||||||
|
public let kind: AlertKind
|
||||||
|
public let severity: AlertSeverity
|
||||||
|
public let title: String
|
||||||
|
public let message: String
|
||||||
|
public let timestamp: Date
|
||||||
|
|
||||||
|
public init(id: String, kind: AlertKind, severity: AlertSeverity, title: String, message: String, timestamp: Date) {
|
||||||
|
self.id = id
|
||||||
|
self.kind = kind
|
||||||
|
self.severity = severity
|
||||||
|
self.title = title
|
||||||
|
self.message = message
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What happened to an alert condition during an evaluation pass.
|
||||||
|
public enum AlertEventOutcome: String, Codable, Sendable {
|
||||||
|
case triggered
|
||||||
|
case resolved
|
||||||
|
case suppressedCooldown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An entry in the in-memory alert history log.
|
||||||
|
public struct AlertEvent: Identifiable, Equatable, Sendable {
|
||||||
|
public let id: UUID
|
||||||
|
public let timestamp: Date
|
||||||
|
public let alertID: String
|
||||||
|
public let kind: AlertKind
|
||||||
|
public let outcome: AlertEventOutcome
|
||||||
|
public let message: String
|
||||||
|
|
||||||
|
public init(timestamp: Date, alertID: String, kind: AlertKind, outcome: AlertEventOutcome, message: String) {
|
||||||
|
self.id = UUID()
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.alertID = alertID
|
||||||
|
self.kind = kind
|
||||||
|
self.outcome = outcome
|
||||||
|
self.message = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-volume storage context consumed by the low-storage rule.
|
||||||
|
public struct StorageVolumeContext: Equatable, Sendable {
|
||||||
|
public let mountPoint: String
|
||||||
|
public let volumeName: String
|
||||||
|
public let freeBytes: UInt64
|
||||||
|
public let freePercent: Double
|
||||||
|
public let totalBytes: UInt64
|
||||||
|
/// Read-only mounts (DMG images, snapshots) are excluded from alerting.
|
||||||
|
public let isReadOnly: Bool
|
||||||
|
|
||||||
|
public init(mountPoint: String, volumeName: String, freeBytes: UInt64, freePercent: Double,
|
||||||
|
totalBytes: UInt64 = 0, isReadOnly: Bool = false) {
|
||||||
|
self.mountPoint = mountPoint
|
||||||
|
self.volumeName = volumeName
|
||||||
|
self.freeBytes = freeBytes
|
||||||
|
self.freePercent = freePercent
|
||||||
|
self.totalBytes = totalBytes
|
||||||
|
self.isReadOnly = isReadOnly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flat, immutable snapshot of the metrics the alert rules inspect.
|
||||||
|
/// Assembled by `SystemTelemetryStore` after each decode pass so the engine
|
||||||
|
/// stays decoupled from provider dictionary schemas and testable in isolation.
|
||||||
|
public struct AlertEvaluationContext: Sendable {
|
||||||
|
public var peakCoreTemperature: Double = 0
|
||||||
|
public var packageTemperature: Double = 0
|
||||||
|
/// Fan index -> current RPM. Empty when no fan telemetry is available.
|
||||||
|
public var fanRPMs: [Int: Double] = [:]
|
||||||
|
public var memoryFreeBytes: UInt64 = 0
|
||||||
|
public var memoryPressureLevel: String = "Normal"
|
||||||
|
public var volumes: [StorageVolumeContext] = []
|
||||||
|
/// Charge level 0-100 reported by the power provider.
|
||||||
|
public var batteryLevel: Double = 100
|
||||||
|
public var hasBattery: Bool = false
|
||||||
|
public var onExternalPower: Bool = true
|
||||||
|
public var isCharging: Bool = false
|
||||||
|
public var timestamp: Date = Date()
|
||||||
|
|
||||||
|
public init(
|
||||||
|
peakCoreTemperature: Double = 0,
|
||||||
|
packageTemperature: Double = 0,
|
||||||
|
fanRPMs: [Int: Double] = [:],
|
||||||
|
memoryFreeBytes: UInt64 = 0,
|
||||||
|
memoryPressureLevel: String = "Normal",
|
||||||
|
volumes: [StorageVolumeContext] = [],
|
||||||
|
batteryLevel: Double = 100,
|
||||||
|
hasBattery: Bool = false,
|
||||||
|
onExternalPower: Bool = true,
|
||||||
|
isCharging: Bool = false,
|
||||||
|
timestamp: Date = Date()
|
||||||
|
) {
|
||||||
|
self.peakCoreTemperature = peakCoreTemperature
|
||||||
|
self.packageTemperature = packageTemperature
|
||||||
|
self.fanRPMs = fanRPMs
|
||||||
|
self.memoryFreeBytes = memoryFreeBytes
|
||||||
|
self.memoryPressureLevel = memoryPressureLevel
|
||||||
|
self.volumes = volumes
|
||||||
|
self.batteryLevel = batteryLevel
|
||||||
|
self.hasBattery = hasBattery
|
||||||
|
self.onExternalPower = onExternalPower
|
||||||
|
self.isCharging = isCharging
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
// MARK: - Navigation Bus
|
||||||
|
|
||||||
|
/// Lightweight bus letting notification action handlers steer the main window:
|
||||||
|
/// tapping a notification (or its "View Processes" action) activates the app and
|
||||||
|
/// selects the requested sidebar tab inside `ContentView`.
|
||||||
|
@Observable
|
||||||
|
public final class AppNavigationBus {
|
||||||
|
public static let shared = AppNavigationBus()
|
||||||
|
|
||||||
|
/// Sidebar tag the main window should switch to (e.g. `"Processes"`).
|
||||||
|
/// `nil` means "just bring the window forward".
|
||||||
|
public private(set) var requestedTab: String?
|
||||||
|
/// Monotonic counter so repeated taps on the same tab still trigger `onChange`.
|
||||||
|
public private(set) var requestCounter: Int = 0
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
public func request(tab: String?) {
|
||||||
|
requestedTab = tab
|
||||||
|
requestCounter += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dispatch Protocol
|
||||||
|
|
||||||
|
/// Abstraction over the delivery channel used by `AlertEngine` so unit tests can
|
||||||
|
/// inject a mock and never touch the real `UNUserNotificationCenter`.
|
||||||
|
public protocol AlertNotificationDispatching: AnyObject {
|
||||||
|
/// Requests `.alert` + `.sound` permission (idempotent; no-op if determined).
|
||||||
|
func requestAuthorization()
|
||||||
|
/// Posts a banner notification for a breached threshold.
|
||||||
|
func dispatch(_ alert: SystemAlert)
|
||||||
|
/// Current authorization status, used to render the Settings pane.
|
||||||
|
func authorizationStatus() async -> UNAuthorizationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification Action Identifiers
|
||||||
|
|
||||||
|
public enum AlertNotificationAction {
|
||||||
|
public static let categoryIdentifier = "MM_ALERT"
|
||||||
|
public static let openAppIdentifier = "MM_ALERT_OPEN_APP"
|
||||||
|
public static let viewProcessesIdentifier = "MM_ALERT_VIEW_PROCESSES"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UserNotifications Implementation
|
||||||
|
|
||||||
|
/// Delivers threshold alerts through macOS Notification Center and routes
|
||||||
|
/// notification actions back into the UI via `AppNavigationBus`.
|
||||||
|
public final class UNAlertNotificationDispatcher: NSObject, AlertNotificationDispatching, UNUserNotificationCenterDelegate, @unchecked Sendable {
|
||||||
|
private let center: UNUserNotificationCenter
|
||||||
|
|
||||||
|
public override init() {
|
||||||
|
self.center = UNUserNotificationCenter.current()
|
||||||
|
super.init()
|
||||||
|
center.delegate = self
|
||||||
|
registerCategories()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerCategories() {
|
||||||
|
let open = UNNotificationAction(
|
||||||
|
identifier: AlertNotificationAction.openAppIdentifier,
|
||||||
|
title: String(localized: "Open MacMonitor"),
|
||||||
|
options: [.foreground]
|
||||||
|
)
|
||||||
|
let processes = UNNotificationAction(
|
||||||
|
identifier: AlertNotificationAction.viewProcessesIdentifier,
|
||||||
|
title: String(localized: "View Processes"),
|
||||||
|
options: [.foreground]
|
||||||
|
)
|
||||||
|
let category = UNNotificationCategory(
|
||||||
|
identifier: AlertNotificationAction.categoryIdentifier,
|
||||||
|
actions: [open, processes],
|
||||||
|
intentIdentifiers: []
|
||||||
|
)
|
||||||
|
center.setNotificationCategories([category])
|
||||||
|
}
|
||||||
|
|
||||||
|
public func requestAuthorization() {
|
||||||
|
center.requestAuthorization(options: [.alert, .sound]) { _, _ in }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func authorizationStatus() async -> UNAuthorizationStatus {
|
||||||
|
await center.notificationSettings().authorizationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
public func dispatch(_ alert: SystemAlert) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = alert.title
|
||||||
|
content.body = alert.message
|
||||||
|
content.sound = .default
|
||||||
|
content.categoryIdentifier = AlertNotificationAction.categoryIdentifier
|
||||||
|
content.userInfo = ["kind": alert.kind.rawValue, "severity": alert.severity.rawValue]
|
||||||
|
if alert.severity == .critical {
|
||||||
|
content.interruptionLevel = .timeSensitive
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(identifier: alert.id, content: content, trigger: nil)
|
||||||
|
center.add(request) { _ in }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: UNUserNotificationCenterDelegate
|
||||||
|
|
||||||
|
/// Show banners even while MacMonitor is frontmost — alerts are warnings the
|
||||||
|
/// user explicitly asked to see regardless of focus.
|
||||||
|
public func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification,
|
||||||
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler([.banner, .sound, .list])
|
||||||
|
}
|
||||||
|
|
||||||
|
public func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
didReceive response: UNNotificationResponse,
|
||||||
|
withCompletionHandler completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
let targetTab: String? = switch response.actionIdentifier {
|
||||||
|
case AlertNotificationAction.viewProcessesIdentifier:
|
||||||
|
"Processes"
|
||||||
|
default:
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
AppNavigationBus.shared.request(tab: targetTab)
|
||||||
|
}
|
||||||
|
completionHandler()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
//
|
||||||
|
// MMAudioTelemetryProvider.h
|
||||||
|
// MacMonitor
|
||||||
|
//
|
||||||
|
// Telemetry provider enumerating CoreAudio input/output devices, sample rates, volume, and active status.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import "MMTelemetryProvider.h"
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@interface MMAudioDevice : NSObject
|
||||||
|
|
||||||
|
@property (nonatomic, assign) uint32_t deviceID;
|
||||||
|
@property (nonatomic, copy) NSString *name;
|
||||||
|
@property (nonatomic, copy) NSString *manufacturer;
|
||||||
|
@property (nonatomic, copy) NSString *uid;
|
||||||
|
@property (nonatomic, assign) BOOL isInput;
|
||||||
|
@property (nonatomic, assign) BOOL isOutput;
|
||||||
|
@property (nonatomic, assign) BOOL isDefaultInput;
|
||||||
|
@property (nonatomic, assign) BOOL isDefaultOutput;
|
||||||
|
@property (nonatomic, assign) double sampleRate;
|
||||||
|
@property (nonatomic, assign) uint32_t channelCount;
|
||||||
|
@property (nonatomic, assign) float volume;
|
||||||
|
@property (nonatomic, assign) BOOL isMuted;
|
||||||
|
|
||||||
|
- (NSDictionary<NSString *, id> *)toDictionary;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface MMAudioTelemetryProvider : NSObject <MMTelemetryProvider>
|
||||||
|
|
||||||
|
- (instancetype)init;
|
||||||
|
|
||||||
|
+ (NSArray<MMAudioDevice *> *)sampleAudioDevices;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
//
|
||||||
|
// MMAudioTelemetryProvider.m
|
||||||
|
// MacMonitor
|
||||||
|
//
|
||||||
|
// Telemetry provider enumerating CoreAudio input/output devices, sample rates, volume, and active status.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "MMAudioTelemetryProvider.h"
|
||||||
|
#import <CoreAudio/CoreAudio.h>
|
||||||
|
|
||||||
|
@implementation MMAudioDevice
|
||||||
|
|
||||||
|
- (NSDictionary<NSString *, id> *)toDictionary {
|
||||||
|
return @{
|
||||||
|
@"deviceID": @(self.deviceID),
|
||||||
|
@"name": self.name ?: @"Unknown Audio Device",
|
||||||
|
@"manufacturer": self.manufacturer ?: @"Unknown",
|
||||||
|
@"uid": self.uid ?: @"",
|
||||||
|
@"isInput": @(self.isInput),
|
||||||
|
@"isOutput": @(self.isOutput),
|
||||||
|
@"isDefaultInput": @(self.isDefaultInput),
|
||||||
|
@"isDefaultOutput": @(self.isDefaultOutput),
|
||||||
|
@"sampleRate": @(self.sampleRate),
|
||||||
|
@"channelCount": @(self.channelCount),
|
||||||
|
@"volume": @(roundf(self.volume * 100.0f) / 100.0f),
|
||||||
|
@"isMuted": @(self.isMuted)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation MMAudioTelemetryProvider
|
||||||
|
|
||||||
|
- (instancetype)init {
|
||||||
|
self = [super init];
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (MMTelemetryDomain)domain {
|
||||||
|
return MMTelemetryDomainAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)providerIdentifier {
|
||||||
|
return @"com.i3omb.macmonitor.telemetry.audio";
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)isAvailable {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (AudioDeviceID)defaultDeviceForProperty:(AudioObjectPropertySelector)selector {
|
||||||
|
AudioObjectPropertyAddress address = {
|
||||||
|
.mSelector = selector,
|
||||||
|
.mScope = kAudioObjectPropertyScopeGlobal,
|
||||||
|
.mElement = kAudioObjectPropertyElementMain
|
||||||
|
};
|
||||||
|
AudioDeviceID deviceID = kAudioObjectUnknown;
|
||||||
|
UInt32 size = sizeof(AudioDeviceID);
|
||||||
|
OSStatus status = AudioObjectGetPropertyData(kAudioObjectSystemObject, &address, 0, NULL, &size, &deviceID);
|
||||||
|
if (status == noErr) {
|
||||||
|
return deviceID;
|
||||||
|
}
|
||||||
|
return kAudioObjectUnknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (NSArray<MMAudioDevice *> *)sampleAudioDevices {
|
||||||
|
AudioObjectPropertyAddress address = {
|
||||||
|
.mSelector = kAudioHardwarePropertyDevices,
|
||||||
|
.mScope = kAudioObjectPropertyScopeGlobal,
|
||||||
|
.mElement = kAudioObjectPropertyElementMain
|
||||||
|
};
|
||||||
|
|
||||||
|
UInt32 dataSize = 0;
|
||||||
|
OSStatus status = AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &address, 0, NULL, &dataSize);
|
||||||
|
if (status != noErr || dataSize == 0) {
|
||||||
|
return @[];
|
||||||
|
}
|
||||||
|
|
||||||
|
UInt32 deviceCount = dataSize / sizeof(AudioDeviceID);
|
||||||
|
AudioDeviceID *deviceIDs = (AudioDeviceID *)malloc(dataSize);
|
||||||
|
if (!deviceIDs) return @[];
|
||||||
|
|
||||||
|
status = AudioObjectGetPropertyData(kAudioObjectSystemObject, &address, 0, NULL, &dataSize, deviceIDs);
|
||||||
|
if (status != noErr) {
|
||||||
|
free(deviceIDs);
|
||||||
|
return @[];
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioDeviceID defaultIn = [self defaultDeviceForProperty:kAudioHardwarePropertyDefaultInputDevice];
|
||||||
|
AudioDeviceID defaultOut = [self defaultDeviceForProperty:kAudioHardwarePropertyDefaultOutputDevice];
|
||||||
|
|
||||||
|
NSMutableArray<MMAudioDevice *> *devices = [NSMutableArray arrayWithCapacity:deviceCount];
|
||||||
|
|
||||||
|
for (UInt32 i = 0; i < deviceCount; i++) {
|
||||||
|
AudioDeviceID devID = deviceIDs[i];
|
||||||
|
|
||||||
|
// Name
|
||||||
|
CFStringRef nameRef = NULL;
|
||||||
|
UInt32 propSize = sizeof(CFStringRef);
|
||||||
|
AudioObjectPropertyAddress nameAddr = {
|
||||||
|
.mSelector = kAudioObjectPropertyName,
|
||||||
|
.mScope = kAudioObjectPropertyScopeGlobal,
|
||||||
|
.mElement = kAudioObjectPropertyElementMain
|
||||||
|
};
|
||||||
|
NSString *name = @"Audio Device";
|
||||||
|
if (AudioObjectGetPropertyData(devID, &nameAddr, 0, NULL, &propSize, &nameRef) == noErr && nameRef) {
|
||||||
|
name = (__bridge_transfer NSString *)nameRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manufacturer
|
||||||
|
CFStringRef mfrRef = NULL;
|
||||||
|
propSize = sizeof(CFStringRef);
|
||||||
|
AudioObjectPropertyAddress mfrAddr = {
|
||||||
|
.mSelector = kAudioObjectPropertyManufacturer,
|
||||||
|
.mScope = kAudioObjectPropertyScopeGlobal,
|
||||||
|
.mElement = kAudioObjectPropertyElementMain
|
||||||
|
};
|
||||||
|
NSString *mfr = @"Apple Inc.";
|
||||||
|
if (AudioObjectGetPropertyData(devID, &mfrAddr, 0, NULL, &propSize, &mfrRef) == noErr && mfrRef) {
|
||||||
|
mfr = (__bridge_transfer NSString *)mfrRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UID
|
||||||
|
CFStringRef uidRef = NULL;
|
||||||
|
propSize = sizeof(CFStringRef);
|
||||||
|
AudioObjectPropertyAddress uidAddr = {
|
||||||
|
.mSelector = kAudioDevicePropertyDeviceUID,
|
||||||
|
.mScope = kAudioObjectPropertyScopeGlobal,
|
||||||
|
.mElement = kAudioObjectPropertyElementMain
|
||||||
|
};
|
||||||
|
NSString *uid = @"";
|
||||||
|
if (AudioObjectGetPropertyData(devID, &uidAddr, 0, NULL, &propSize, &uidRef) == noErr && uidRef) {
|
||||||
|
uid = (__bridge_transfer NSString *)uidRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channels & Input / Output determination
|
||||||
|
AudioObjectPropertyAddress inputStreamsAddr = {
|
||||||
|
.mSelector = kAudioDevicePropertyStreams,
|
||||||
|
.mScope = kAudioDevicePropertyScopeInput,
|
||||||
|
.mElement = kAudioObjectPropertyElementMain
|
||||||
|
};
|
||||||
|
UInt32 inStreamSize = 0;
|
||||||
|
AudioObjectGetPropertyDataSize(devID, &inputStreamsAddr, 0, NULL, &inStreamSize);
|
||||||
|
BOOL isInput = (inStreamSize > 0);
|
||||||
|
|
||||||
|
AudioObjectPropertyAddress outputStreamsAddr = {
|
||||||
|
.mSelector = kAudioDevicePropertyStreams,
|
||||||
|
.mScope = kAudioDevicePropertyScopeOutput,
|
||||||
|
.mElement = kAudioObjectPropertyElementMain
|
||||||
|
};
|
||||||
|
UInt32 outStreamSize = 0;
|
||||||
|
AudioObjectGetPropertyDataSize(devID, &outputStreamsAddr, 0, NULL, &outStreamSize);
|
||||||
|
BOOL isOutput = (outStreamSize > 0);
|
||||||
|
|
||||||
|
// Sample rate
|
||||||
|
Float64 sampleRate = 0.0;
|
||||||
|
propSize = sizeof(Float64);
|
||||||
|
AudioObjectPropertyAddress rateAddr = {
|
||||||
|
.mSelector = kAudioDevicePropertyNominalSampleRate,
|
||||||
|
.mScope = kAudioObjectPropertyScopeGlobal,
|
||||||
|
.mElement = kAudioObjectPropertyElementMain
|
||||||
|
};
|
||||||
|
AudioObjectGetPropertyData(devID, &rateAddr, 0, NULL, &propSize, &sampleRate);
|
||||||
|
|
||||||
|
// Volume
|
||||||
|
Float32 volume = 0.0f;
|
||||||
|
AudioObjectPropertyScope volScope = isOutput ? kAudioDevicePropertyScopeOutput : kAudioDevicePropertyScopeInput;
|
||||||
|
AudioObjectPropertyAddress volAddr = {
|
||||||
|
.mSelector = kAudioDevicePropertyVolumeScalar,
|
||||||
|
.mScope = volScope,
|
||||||
|
.mElement = kAudioObjectPropertyElementMain
|
||||||
|
};
|
||||||
|
propSize = sizeof(Float32);
|
||||||
|
if (AudioObjectHasProperty(devID, &volAddr)) {
|
||||||
|
AudioObjectGetPropertyData(devID, &volAddr, 0, NULL, &propSize, &volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mute status
|
||||||
|
UInt32 mute = 0;
|
||||||
|
AudioObjectPropertyAddress muteAddr = {
|
||||||
|
.mSelector = kAudioDevicePropertyMute,
|
||||||
|
.mScope = volScope,
|
||||||
|
.mElement = kAudioObjectPropertyElementMain
|
||||||
|
};
|
||||||
|
propSize = sizeof(UInt32);
|
||||||
|
if (AudioObjectHasProperty(devID, &muteAddr)) {
|
||||||
|
AudioObjectGetPropertyData(devID, &muteAddr, 0, NULL, &propSize, &mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
MMAudioDevice *device = [[MMAudioDevice alloc] init];
|
||||||
|
device.deviceID = devID;
|
||||||
|
device.name = name;
|
||||||
|
device.manufacturer = mfr;
|
||||||
|
device.uid = uid;
|
||||||
|
device.isInput = isInput;
|
||||||
|
device.isOutput = isOutput;
|
||||||
|
device.isDefaultInput = (devID == defaultIn);
|
||||||
|
device.isDefaultOutput = (devID == defaultOut);
|
||||||
|
device.sampleRate = (double)sampleRate;
|
||||||
|
device.channelCount = (inStreamSize / sizeof(AudioStreamID)) + (outStreamSize / sizeof(AudioStreamID));
|
||||||
|
device.volume = volume;
|
||||||
|
device.isMuted = (mute != 0);
|
||||||
|
|
||||||
|
[devices addObject:device];
|
||||||
|
}
|
||||||
|
|
||||||
|
free(deviceIDs);
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (nullable NSDictionary<NSString *, id> *)sampleTelemetryWithError:(NSError **)error {
|
||||||
|
NSArray<MMAudioDevice *> *devices = [MMAudioTelemetryProvider sampleAudioDevices];
|
||||||
|
NSMutableArray<NSDictionary<NSString *, id> *> *deviceList = [NSMutableArray arrayWithCapacity:devices.count];
|
||||||
|
|
||||||
|
MMAudioDevice *defaultIn = nil;
|
||||||
|
MMAudioDevice *defaultOut = nil;
|
||||||
|
|
||||||
|
for (MMAudioDevice *d in devices) {
|
||||||
|
[deviceList addObject:[d toDictionary]];
|
||||||
|
if (d.isDefaultInput) defaultIn = d;
|
||||||
|
if (d.isDefaultOutput) defaultOut = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
return @{
|
||||||
|
@"devices": deviceList,
|
||||||
|
@"deviceCount": @(devices.count),
|
||||||
|
@"defaultInputDevice": defaultIn ? [defaultIn toDictionary] : [NSNull null],
|
||||||
|
@"defaultOutputDevice": defaultOut ? [defaultOut toDictionary] : [NSNull null]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <CoreGraphics/CoreGraphics.h>
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@interface MMDisplayInfo : NSObject
|
||||||
|
|
||||||
|
@property (nonatomic, readonly) CGDirectDisplayID displayID;
|
||||||
|
@property (nonatomic, readonly, copy) NSString *name;
|
||||||
|
@property (nonatomic, readonly) BOOL isBuiltin;
|
||||||
|
@property (nonatomic, readonly) BOOL isMain;
|
||||||
|
@property (nonatomic, readonly) BOOL isOnline;
|
||||||
|
@property (nonatomic, readonly) uint32_t width;
|
||||||
|
@property (nonatomic, readonly) uint32_t height;
|
||||||
|
@property (nonatomic, readonly) double refreshRate;
|
||||||
|
@property (nonatomic, readonly) float brightness; // 0.0 to 1.0, or -1.0 if not supported
|
||||||
|
|
||||||
|
- (instancetype)initWithDisplayID:(CGDirectDisplayID)displayID
|
||||||
|
name:(NSString *)name
|
||||||
|
isBuiltin:(BOOL)isBuiltin
|
||||||
|
isMain:(BOOL)isMain
|
||||||
|
isOnline:(BOOL)isOnline
|
||||||
|
width:(uint32_t)width
|
||||||
|
height:(uint32_t)height
|
||||||
|
refreshRate:(double)refreshRate
|
||||||
|
brightness:(float)brightness NS_DESIGNATED_INITIALIZER;
|
||||||
|
|
||||||
|
- (instancetype)init NS_UNAVAILABLE;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface MMDisplayManager : NSObject
|
||||||
|
|
||||||
|
+ (instancetype)sharedManager;
|
||||||
|
|
||||||
|
- (NSArray<MMDisplayInfo *> *)activeDisplays;
|
||||||
|
- (float)brightnessForDisplay:(CGDirectDisplayID)displayID;
|
||||||
|
- (BOOL)setBrightness:(float)brightness forDisplay:(CGDirectDisplayID)displayID;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
#import "MMDisplayManager.h"
|
||||||
|
#import <IOKit/graphics/IOGraphicsLib.h>
|
||||||
|
#import <dlfcn.h>
|
||||||
|
|
||||||
|
// Private DisplayServices API signatures
|
||||||
|
typedef int (*DisplayServicesGetBrightnessFunc)(CGDirectDisplayID display, float *brightness);
|
||||||
|
typedef int (*DisplayServicesSetBrightnessFunc)(CGDirectDisplayID display, float brightness);
|
||||||
|
|
||||||
|
@implementation MMDisplayInfo
|
||||||
|
|
||||||
|
- (instancetype)initWithDisplayID:(CGDirectDisplayID)displayID
|
||||||
|
name:(NSString *)name
|
||||||
|
isBuiltin:(BOOL)isBuiltin
|
||||||
|
isMain:(BOOL)isMain
|
||||||
|
isOnline:(BOOL)isOnline
|
||||||
|
width:(uint32_t)width
|
||||||
|
height:(uint32_t)height
|
||||||
|
refreshRate:(double)refreshRate
|
||||||
|
brightness:(float)brightness {
|
||||||
|
self = [super init];
|
||||||
|
if (self) {
|
||||||
|
_displayID = displayID;
|
||||||
|
_name = [name copy] ?: @"Unknown Display";
|
||||||
|
_isBuiltin = isBuiltin;
|
||||||
|
_isMain = isMain;
|
||||||
|
_isOnline = isOnline;
|
||||||
|
_width = width;
|
||||||
|
_height = height;
|
||||||
|
_refreshRate = refreshRate;
|
||||||
|
_brightness = brightness;
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation MMDisplayManager {
|
||||||
|
void *_displayServicesHandle;
|
||||||
|
DisplayServicesGetBrightnessFunc _getBrightnessFunc;
|
||||||
|
DisplayServicesSetBrightnessFunc _setBrightnessFunc;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (instancetype)sharedManager {
|
||||||
|
static MMDisplayManager *sharedInstance = nil;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
sharedInstance = [[self alloc] init];
|
||||||
|
});
|
||||||
|
return sharedInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (instancetype)init {
|
||||||
|
self = [super init];
|
||||||
|
if (self) {
|
||||||
|
[self loadDisplayServices];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)dealloc {
|
||||||
|
if (_displayServicesHandle) {
|
||||||
|
dlclose(_displayServicesHandle);
|
||||||
|
_displayServicesHandle = NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)loadDisplayServices {
|
||||||
|
_displayServicesHandle = dlopen("/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices", RTLD_LAZY);
|
||||||
|
if (_displayServicesHandle) {
|
||||||
|
_getBrightnessFunc = (DisplayServicesGetBrightnessFunc)dlsym(_displayServicesHandle, "DisplayServicesGetBrightness");
|
||||||
|
_setBrightnessFunc = (DisplayServicesSetBrightnessFunc)dlsym(_displayServicesHandle, "DisplayServicesSetBrightness");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray<MMDisplayInfo *> *)activeDisplays {
|
||||||
|
uint32_t maxDisplays = 16;
|
||||||
|
CGDirectDisplayID onlineDisplays[16];
|
||||||
|
uint32_t displayCount = 0;
|
||||||
|
|
||||||
|
CGError err = CGGetOnlineDisplayList(maxDisplays, onlineDisplays, &displayCount);
|
||||||
|
if (err != kCGErrorSuccess || displayCount == 0) {
|
||||||
|
return @[];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableArray<MMDisplayInfo *> *result = [NSMutableArray arrayWithCapacity:displayCount];
|
||||||
|
CGDirectDisplayID mainDisplay = CGMainDisplayID();
|
||||||
|
|
||||||
|
for (uint32_t i = 0; i < displayCount; i++) {
|
||||||
|
CGDirectDisplayID dID = onlineDisplays[i];
|
||||||
|
BOOL isBuiltin = CGDisplayIsBuiltin(dID);
|
||||||
|
BOOL isMain = (dID == mainDisplay);
|
||||||
|
BOOL isOnline = CGDisplayIsOnline(dID);
|
||||||
|
|
||||||
|
uint32_t width = (uint32_t)CGDisplayPixelsWide(dID);
|
||||||
|
uint32_t height = (uint32_t)CGDisplayPixelsHigh(dID);
|
||||||
|
|
||||||
|
CGDisplayModeRef mode = CGDisplayCopyDisplayMode(dID);
|
||||||
|
double refreshRate = 0.0;
|
||||||
|
if (mode) {
|
||||||
|
refreshRate = CGDisplayModeGetRefreshRate(mode);
|
||||||
|
CGDisplayModeRelease(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *displayName = isBuiltin ? @"Built-in Retina Display" : [NSString stringWithFormat:@"External Display (%u)", (unsigned int)dID];
|
||||||
|
float brightness = [self brightnessForDisplay:dID];
|
||||||
|
|
||||||
|
MMDisplayInfo *info = [[MMDisplayInfo alloc] initWithDisplayID:dID
|
||||||
|
name:displayName
|
||||||
|
isBuiltin:isBuiltin
|
||||||
|
isMain:isMain
|
||||||
|
isOnline:isOnline
|
||||||
|
width:width
|
||||||
|
height:height
|
||||||
|
refreshRate:refreshRate
|
||||||
|
brightness:brightness];
|
||||||
|
[result addObject:info];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [result copy];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (float)brightnessForDisplay:(CGDirectDisplayID)displayID {
|
||||||
|
if (_getBrightnessFunc) {
|
||||||
|
float b = 0.0f;
|
||||||
|
int status = _getBrightnessFunc(displayID, &b);
|
||||||
|
if (status == 0) {
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback using IOKit
|
||||||
|
io_service_t service = CGDisplayIOServicePort(displayID);
|
||||||
|
if (service != MACH_PORT_NULL) {
|
||||||
|
float brightness = 0.0f;
|
||||||
|
CFStringRef key = CFSTR(kIODisplayBrightnessKey);
|
||||||
|
kern_return_t kr = IODisplayGetFloatParameter(service, kNilOptions, key, &brightness);
|
||||||
|
if (kr == kIOReturnSuccess) {
|
||||||
|
return brightness;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)setBrightness:(float)brightness forDisplay:(CGDirectDisplayID)displayID {
|
||||||
|
if (brightness < 0.0f) brightness = 0.0f;
|
||||||
|
if (brightness > 1.0f) brightness = 1.0f;
|
||||||
|
|
||||||
|
if (_setBrightnessFunc) {
|
||||||
|
int status = _setBrightnessFunc(displayID, brightness);
|
||||||
|
if (status == 0) {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
io_service_t service = CGDisplayIOServicePort(displayID);
|
||||||
|
if (service != MACH_PORT_NULL) {
|
||||||
|
CFStringRef key = CFSTR(kIODisplayBrightnessKey);
|
||||||
|
kern_return_t kr = IODisplaySetFloatParameter(service, kNilOptions, key, brightness);
|
||||||
|
if (kr == kIOReturnSuccess) {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -22,7 +22,7 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
/**
|
/**
|
||||||
* Static calculator method for deterministic unit testing.
|
* Static calculator method for deterministic unit testing.
|
||||||
*/
|
*/
|
||||||
+ (NSDictionary<NSString *, id> *)calculateFanMetricsFromFanList:(NSArray<NSDictionary<NSString *, NSNumber *> *> *)fanList;
|
+ (NSDictionary<NSString *, id> *)calculateFanMetricsFromFanList:(NSArray<NSDictionary<NSString *, id> *> *)fanList;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
return (_probedFanCount >= 0) ? _probedFanCount : 0;
|
return (_probedFanCount >= 0) ? _probedFanCount : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
+ (NSDictionary<NSString *, id> *)calculateFanMetricsFromFanList:(NSArray<NSDictionary<NSString *, NSNumber *> *> *)fanList {
|
+ (NSDictionary<NSString *, id> *)calculateFanMetricsFromFanList:(NSArray<NSDictionary<NSString *, id> *> *)fanList {
|
||||||
if (!fanList || fanList.count == 0) {
|
if (!fanList || fanList.count == 0) {
|
||||||
return @{
|
return @{
|
||||||
@"fanCount": @(0),
|
@"fanCount": @(0),
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
double peakRPM = 0.0;
|
double peakRPM = 0.0;
|
||||||
|
|
||||||
for (NSUInteger i = 0; i < fanList.count; i++) {
|
for (NSUInteger i = 0; i < fanList.count; i++) {
|
||||||
NSDictionary<NSString *, NSNumber *> *raw = fanList[i];
|
NSDictionary<NSString *, id> *raw = fanList[i];
|
||||||
double current = [raw[@"currentRPM"] doubleValue];
|
double current = [raw[@"currentRPM"] doubleValue];
|
||||||
double min = [raw[@"minRPM"] doubleValue];
|
double min = [raw[@"minRPM"] doubleValue];
|
||||||
double max = [raw[@"maxRPM"] doubleValue];
|
double max = [raw[@"maxRPM"] doubleValue];
|
||||||
@@ -96,6 +96,7 @@
|
|||||||
|
|
||||||
[resultList addObject:@{
|
[resultList addObject:@{
|
||||||
@"fanIndex": @(i),
|
@"fanIndex": @(i),
|
||||||
|
@"name": raw[@"name"] ?: [NSString stringWithFormat:@"Fan %lu", (unsigned long)(i + 1)],
|
||||||
@"currentRPM": @(current),
|
@"currentRPM": @(current),
|
||||||
@"minRPM": @(min),
|
@"minRPM": @(min),
|
||||||
@"maxRPM": @(max),
|
@"maxRPM": @(max),
|
||||||
@@ -120,20 +121,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
NSInteger count = [self fanCount];
|
NSInteger count = [self fanCount];
|
||||||
NSMutableArray<NSDictionary<NSString *, NSNumber *> *> *rawList = [NSMutableArray arrayWithCapacity:count];
|
NSMutableArray<NSDictionary<NSString *, id> *> *rawList = [NSMutableArray arrayWithCapacity:count];
|
||||||
|
|
||||||
for (NSInteger i = 0; i < count; i++) {
|
for (NSInteger i = 0; i < count; i++) {
|
||||||
NSString *acKey = [NSString stringWithFormat:@"F%ldAc", (long)i];
|
NSString *acKey = [NSString stringWithFormat:@"F%ldAc", (long)i];
|
||||||
NSString *mnKey = [NSString stringWithFormat:@"F%ldMn", (long)i];
|
NSString *mnKey = [NSString stringWithFormat:@"F%ldMn", (long)i];
|
||||||
NSString *mxKey = [NSString stringWithFormat:@"F%ldMx", (long)i];
|
NSString *mxKey = [NSString stringWithFormat:@"F%ldMx", (long)i];
|
||||||
NSString *tgKey = [NSString stringWithFormat:@"F%ldTg", (long)i];
|
NSString *tgKey = [NSString stringWithFormat:@"F%ldTg", (long)i];
|
||||||
|
NSString *idKey = [NSString stringWithFormat:@"F%ldID", (long)i];
|
||||||
|
|
||||||
NSNumber *ac = [_smcClient readNumericValueForKey:acKey error:nil] ?: @(0.0);
|
NSNumber *ac = [_smcClient readNumericValueForKey:acKey error:nil] ?: @(0.0);
|
||||||
NSNumber *mn = [_smcClient readNumericValueForKey:mnKey error:nil] ?: @(0.0);
|
NSNumber *mn = [_smcClient readNumericValueForKey:mnKey error:nil] ?: @(0.0);
|
||||||
NSNumber *mx = [_smcClient readNumericValueForKey:mxKey error:nil] ?: @(6000.0);
|
NSNumber *mx = [_smcClient readNumericValueForKey:mxKey error:nil] ?: @(6000.0);
|
||||||
NSNumber *tg = [_smcClient readNumericValueForKey:tgKey error:nil] ?: ac;
|
NSNumber *tg = [_smcClient readNumericValueForKey:tgKey error:nil] ?: ac;
|
||||||
|
|
||||||
|
NSString *fanName = [NSString stringWithFormat:@"Fan %ld", (long)(i + 1)];
|
||||||
|
NSData *idData = [_smcClient readBytesForKey:idKey error:nil];
|
||||||
|
if (idData.length > 0) {
|
||||||
|
NSString *raw = [[NSString alloc] initWithData:idData encoding:NSASCIIStringEncoding];
|
||||||
|
NSString *trimmed = [raw stringByTrimmingCharactersInSet:
|
||||||
|
[NSCharacterSet characterSetWithCharactersInString:@"\0 "]];
|
||||||
|
if (trimmed.length > 0) fanName = trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
[rawList addObject:@{
|
[rawList addObject:@{
|
||||||
|
@"name": fanName,
|
||||||
@"currentRPM": ac,
|
@"currentRPM": ac,
|
||||||
@"minRPM": mn,
|
@"minRPM": mn,
|
||||||
@"maxRPM": mx,
|
@"maxRPM": mx,
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#ifndef MMGPUTelemetryProvider_h
|
||||||
|
#define MMGPUTelemetryProvider_h
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import "MMTelemetryProvider.h"
|
||||||
|
#import "MMAppleSMCClient.h"
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MMGPUTelemetryProvider
|
||||||
|
* Samples Intel Integrated Graphics (Iris/UHD) and AMD Discrete Graphics (Radeon/Vega)
|
||||||
|
* utilization, VRAM allocation, and thermal metrics via IOKit and Metal.
|
||||||
|
*/
|
||||||
|
@interface MMGPUTelemetryProvider : NSObject <MMTelemetryProvider>
|
||||||
|
|
||||||
|
@property (nonatomic, strong, readonly) MMAppleSMCClient *smcClient;
|
||||||
|
|
||||||
|
- (instancetype)init;
|
||||||
|
- (instancetype)initWithSMCClient:(MMAppleSMCClient *)client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure calculator method for deterministic unit testing.
|
||||||
|
*/
|
||||||
|
+ (NSDictionary<NSString *, id> *)calculateGPUMetricsWithDevices:(NSArray<NSDictionary<NSString *, id> *> *)gpuDevices;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
|
|
||||||
|
#endif /* MMGPUTelemetryProvider_h */
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
#import "MMGPUTelemetryProvider.h"
|
||||||
|
#import <IOKit/IOKitLib.h>
|
||||||
|
#import <Metal/Metal.h>
|
||||||
|
|
||||||
|
@interface MMGPUTelemetryProvider () {
|
||||||
|
MMAppleSMCClient *_smcClient;
|
||||||
|
}
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation MMGPUTelemetryProvider
|
||||||
|
|
||||||
|
- (instancetype)init {
|
||||||
|
return [self initWithSMCClient:[MMAppleSMCClient sharedClient]];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (instancetype)initWithSMCClient:(MMAppleSMCClient *)client {
|
||||||
|
self = [super init];
|
||||||
|
if (self) {
|
||||||
|
_smcClient = client;
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (MMTelemetryDomain)domain {
|
||||||
|
return MMTelemetryDomainGPU;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)providerIdentifier {
|
||||||
|
return @"com.i3omb.macmonitor.telemetry.gpu";
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)isAvailable {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (MMAppleSMCClient *)smcClient {
|
||||||
|
return _smcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (NSDictionary<NSString *, id> *)calculateGPUMetricsWithDevices:(NSArray<NSDictionary<NSString *, id> *> *)gpuDevices {
|
||||||
|
double totalUtil = 0.0;
|
||||||
|
double peakUtil = 0.0;
|
||||||
|
NSString *activeGPU = @"None";
|
||||||
|
|
||||||
|
for (NSDictionary<NSString *, id> *gpu in gpuDevices) {
|
||||||
|
double util = [gpu[@"utilization"] doubleValue];
|
||||||
|
totalUtil += util;
|
||||||
|
if (util > peakUtil) {
|
||||||
|
peakUtil = util;
|
||||||
|
activeGPU = gpu[@"name"] ?: @"GPU";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double avgUtil = gpuDevices.count > 0 ? (totalUtil / (double)gpuDevices.count) : 0.0;
|
||||||
|
if ([activeGPU isEqualToString:@"None"] && gpuDevices.count > 0) {
|
||||||
|
activeGPU = gpuDevices[0][@"name"] ?: @"GPU";
|
||||||
|
}
|
||||||
|
|
||||||
|
return @{
|
||||||
|
@"gpuCount": @(gpuDevices.count),
|
||||||
|
@"averageUtilization": @(avgUtil),
|
||||||
|
@"peakUtilization": @(peakUtil),
|
||||||
|
@"activeGPUName": activeGPU,
|
||||||
|
@"devices": gpuDevices
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
- (nullable NSDictionary<NSString *, id> *)sampleTelemetryWithError:(NSError **)error {
|
||||||
|
// 1. Query IOKit IOAccelerator performance statistics
|
||||||
|
NSMutableDictionary<NSString *, NSDictionary *> *accelStatsByName = [NSMutableDictionary dictionary];
|
||||||
|
|
||||||
|
CFMutableDictionaryRef matching = IOServiceMatching("IOAccelerator");
|
||||||
|
io_iterator_t iterator = IO_OBJECT_NULL;
|
||||||
|
if (IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) == KERN_SUCCESS && iterator != IO_OBJECT_NULL) {
|
||||||
|
io_registry_entry_t entry;
|
||||||
|
while ((entry = IOIteratorNext(iterator)) != IO_OBJECT_NULL) {
|
||||||
|
CFMutableDictionaryRef props = NULL;
|
||||||
|
if (IORegistryEntryCreateCFProperties(entry, &props, kCFAllocatorDefault, 0) == KERN_SUCCESS && props) {
|
||||||
|
NSDictionary *dict = (__bridge NSDictionary *)props;
|
||||||
|
NSDictionary *stats = dict[@"PerformanceStatistics"];
|
||||||
|
io_name_t entryName;
|
||||||
|
if (IORegistryEntryGetName(entry, entryName) == KERN_SUCCESS && stats) {
|
||||||
|
accelStatsByName[[NSString stringWithUTF8String:entryName]] = stats;
|
||||||
|
}
|
||||||
|
CFRelease(props);
|
||||||
|
}
|
||||||
|
IOObjectRelease(entry);
|
||||||
|
}
|
||||||
|
IOObjectRelease(iterator);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Query SMC for GPU temperature fallback
|
||||||
|
NSNumber *smcGpuTemp = nil;
|
||||||
|
if (_smcClient.isAvailable) {
|
||||||
|
smcGpuTemp = [_smcClient readNumericValueForKey:@"TG0D" error:nil];
|
||||||
|
if (!smcGpuTemp) {
|
||||||
|
smcGpuTemp = [_smcClient readNumericValueForKey:@"TG0P" error:nil];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Enumerate Metal devices
|
||||||
|
NSArray<id<MTLDevice>> *metalDevices = MTLCopyAllDevices();
|
||||||
|
NSMutableArray<NSDictionary<NSString *, id> *> *deviceList = [NSMutableArray array];
|
||||||
|
|
||||||
|
for (id<MTLDevice> dev in metalDevices) {
|
||||||
|
NSString *name = dev.name;
|
||||||
|
BOOL isLowPower = dev.isLowPower;
|
||||||
|
BOOL isRemovable = dev.isRemovable;
|
||||||
|
uint64_t maxMem = dev.recommendedMaxWorkingSetSize;
|
||||||
|
|
||||||
|
// Find matching stats
|
||||||
|
double util = 0.0;
|
||||||
|
uint64_t vramUsed = 0;
|
||||||
|
uint64_t vramTotal = maxMem;
|
||||||
|
double temp = smcGpuTemp ? [smcGpuTemp doubleValue] : 0.0;
|
||||||
|
|
||||||
|
for (NSString *accelName in accelStatsByName) {
|
||||||
|
NSDictionary *stats = accelStatsByName[accelName];
|
||||||
|
|
||||||
|
// Check Intel stats
|
||||||
|
if ([name containsString:@"Intel"] || [accelName containsString:@"Intel"]) {
|
||||||
|
if (stats[@"Device Utilization %"]) {
|
||||||
|
util = [stats[@"Device Utilization %"] doubleValue];
|
||||||
|
}
|
||||||
|
if (stats[@"gartUsedBytes"]) {
|
||||||
|
vramUsed = [stats[@"gartUsedBytes"] unsignedLongLongValue];
|
||||||
|
}
|
||||||
|
if (stats[@"gartSizeBytes"]) {
|
||||||
|
vramTotal = [stats[@"gartSizeBytes"] unsignedLongLongValue];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// AMD / Discrete stats
|
||||||
|
if (stats[@"GPU Activity(%)"]) {
|
||||||
|
util = [stats[@"GPU Activity(%)"] doubleValue];
|
||||||
|
} else if (stats[@"Device Utilization %"]) {
|
||||||
|
util = [stats[@"Device Utilization %"] doubleValue];
|
||||||
|
}
|
||||||
|
if (stats[@"vramUsedBytes"]) {
|
||||||
|
vramUsed = [stats[@"vramUsedBytes"] unsignedLongLongValue];
|
||||||
|
}
|
||||||
|
if (stats[@"vramFreeBytes"]) {
|
||||||
|
uint64_t freeBytes = [stats[@"vramFreeBytes"] unsignedLongLongValue];
|
||||||
|
vramTotal = vramUsed + freeBytes;
|
||||||
|
}
|
||||||
|
if (stats[@"Temperature(C)"]) {
|
||||||
|
temp = [stats[@"Temperature(C)"] doubleValue];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *gpuType = isLowPower || [name containsString:@"Intel"] ? @"Integrated" : @"Discrete";
|
||||||
|
|
||||||
|
[deviceList addObject:@{
|
||||||
|
@"name": name,
|
||||||
|
@"type": gpuType,
|
||||||
|
@"utilization": @(util),
|
||||||
|
@"vramUsedBytes": @(vramUsed),
|
||||||
|
@"vramTotalBytes": @(vramTotal),
|
||||||
|
@"temperature": @(temp),
|
||||||
|
@"isLowPower": @(isLowPower),
|
||||||
|
@"isRemovable": @(isRemovable),
|
||||||
|
@"recommendedMaxWorkingSetBytes": @(maxMem)
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback if no Metal devices enumerated (headless / virtualization)
|
||||||
|
if (deviceList.count == 0) {
|
||||||
|
[deviceList addObject:@{
|
||||||
|
@"name": @"Default GPU",
|
||||||
|
@"type": @"Integrated",
|
||||||
|
@"utilization": @(0.0),
|
||||||
|
@"vramUsedBytes": @(0),
|
||||||
|
@"vramTotalBytes": @(1024 * 1024 * 1024),
|
||||||
|
@"temperature": @(0.0),
|
||||||
|
@"isLowPower": @(YES),
|
||||||
|
@"isRemovable": @(NO),
|
||||||
|
@"recommendedMaxWorkingSetBytes": @(1024 * 1024 * 1024)
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [MMGPUTelemetryProvider calculateGPUMetricsWithDevices:deviceList];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -21,7 +21,8 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
+ (NSDictionary<NSString *, id> *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)current
|
+ (NSDictionary<NSString *, id> *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)current
|
||||||
previousSnapshots:(nullable NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)previous
|
previousSnapshots:(nullable NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)previous
|
||||||
timeDelta:(NSTimeInterval)timeDelta
|
timeDelta:(NSTimeInterval)timeDelta
|
||||||
ipAddresses:(nullable NSDictionary<NSString *, NSString *> *)ipAddresses;
|
ipAddresses:(nullable NSDictionary<NSString *, NSString *> *)ipAddresses
|
||||||
|
interfaceUpFlags:(nullable NSDictionary<NSString *, NSNumber *> *)interfaceUpFlags;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
+ (NSDictionary<NSString *, id> *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)current
|
+ (NSDictionary<NSString *, id> *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)current
|
||||||
previousSnapshots:(nullable NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)previous
|
previousSnapshots:(nullable NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)previous
|
||||||
timeDelta:(NSTimeInterval)timeDelta
|
timeDelta:(NSTimeInterval)timeDelta
|
||||||
ipAddresses:(nullable NSDictionary<NSString *, NSString *> *)ipAddresses {
|
ipAddresses:(nullable NSDictionary<NSString *, NSString *> *)ipAddresses
|
||||||
|
interfaceUpFlags:(nullable NSDictionary<NSString *, NSNumber *> *)interfaceUpFlags {
|
||||||
double totalDownBps = 0.0;
|
double totalDownBps = 0.0;
|
||||||
double totalUpBps = 0.0;
|
double totalUpBps = 0.0;
|
||||||
double totalDownPps = 0.0;
|
double totalDownPps = 0.0;
|
||||||
@@ -99,9 +100,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
NSString *ip = ipAddresses[name] ?: @"";
|
NSString *ip = ipAddresses[name] ?: @"";
|
||||||
|
BOOL isUp = interfaceUpFlags[name] ? [interfaceUpFlags[name] boolValue] : NO;
|
||||||
|
|
||||||
[interfaces addObject:@{
|
[interfaces addObject:@{
|
||||||
@"name": name,
|
@"name": name,
|
||||||
|
@"isUp": @(isUp),
|
||||||
@"downloadBytesPerSec": @(downBps),
|
@"downloadBytesPerSec": @(downBps),
|
||||||
@"uploadBytesPerSec": @(upBps),
|
@"uploadBytesPerSec": @(upBps),
|
||||||
@"downloadPacketsPerSec": @(downPps),
|
@"downloadPacketsPerSec": @(downPps),
|
||||||
@@ -171,16 +174,18 @@
|
|||||||
if (buf) free(buf);
|
if (buf) free(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Query IPv4 addresses via getifaddrs
|
// 2. Query IPv4 addresses and interface flags via getifaddrs
|
||||||
NSMutableDictionary<NSString *, NSString *> *ipDict = [NSMutableDictionary dictionary];
|
NSMutableDictionary<NSString *, NSString *> *ipDict = [NSMutableDictionary dictionary];
|
||||||
|
NSMutableDictionary<NSString *, NSNumber *> *upDict = [NSMutableDictionary dictionary];
|
||||||
struct ifaddrs *ifap = NULL;
|
struct ifaddrs *ifap = NULL;
|
||||||
if (getifaddrs(&ifap) == 0) {
|
if (getifaddrs(&ifap) == 0) {
|
||||||
for (struct ifaddrs *ifa = ifap; ifa != NULL; ifa = ifa->ifa_next) {
|
for (struct ifaddrs *ifa = ifap; ifa != NULL; ifa = ifa->ifa_next) {
|
||||||
|
NSString *name = [NSString stringWithUTF8String:ifa->ifa_name];
|
||||||
|
upDict[name] = @((ifa->ifa_flags & IFF_UP) != 0);
|
||||||
if (ifa->ifa_addr && ifa->ifa_addr->sa_family == AF_INET) {
|
if (ifa->ifa_addr && ifa->ifa_addr->sa_family == AF_INET) {
|
||||||
char ipBuf[INET_ADDRSTRLEN] = {0};
|
char ipBuf[INET_ADDRSTRLEN] = {0};
|
||||||
struct sockaddr_in *sin = (struct sockaddr_in *)ifa->ifa_addr;
|
struct sockaddr_in *sin = (struct sockaddr_in *)ifa->ifa_addr;
|
||||||
if (inet_ntop(AF_INET, &sin->sin_addr, ipBuf, sizeof(ipBuf))) {
|
if (inet_ntop(AF_INET, &sin->sin_addr, ipBuf, sizeof(ipBuf))) {
|
||||||
NSString *name = [NSString stringWithUTF8String:ifa->ifa_name];
|
|
||||||
ipDict[name] = [NSString stringWithUTF8String:ipBuf];
|
ipDict[name] = [NSString stringWithUTF8String:ipBuf];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +210,8 @@
|
|||||||
return [MMNetworkBandwidthProvider calculateBandwidthMetricsWithCurrentSnapshots:currentSnapshots
|
return [MMNetworkBandwidthProvider calculateBandwidthMetricsWithCurrentSnapshots:currentSnapshots
|
||||||
previousSnapshots:prev
|
previousSnapshots:prev
|
||||||
timeDelta:timeDelta
|
timeDelta:timeDelta
|
||||||
ipAddresses:ipDict];
|
ipAddresses:ipDict
|
||||||
|
interfaceUpFlags:upDict];
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//
|
||||||
|
// MMPeripheralsProvider.h
|
||||||
|
// MacMonitor
|
||||||
|
//
|
||||||
|
// Telemetry provider enumerating connected USB, Thunderbolt, and PCI peripheral devices via IOKit.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import "MMTelemetryProvider.h"
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
typedef NS_ENUM(NSInteger, MMPeripheralBusType) {
|
||||||
|
MMPeripheralBusTypeUSB,
|
||||||
|
MMPeripheralBusTypeThunderbolt,
|
||||||
|
MMPeripheralBusTypePCI
|
||||||
|
};
|
||||||
|
|
||||||
|
@interface MMPeripheralDevice : NSObject
|
||||||
|
|
||||||
|
@property (nonatomic, copy) NSString *name;
|
||||||
|
@property (nonatomic, assign) MMPeripheralBusType busType;
|
||||||
|
@property (nonatomic, copy) NSString *vendorName;
|
||||||
|
@property (nonatomic, assign) uint32_t vendorID;
|
||||||
|
@property (nonatomic, assign) uint32_t productID;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *serialNumber;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *deviceClass;
|
||||||
|
@property (nonatomic, assign) uint64_t locationID;
|
||||||
|
@property (nonatomic, assign) BOOL isBuiltIn;
|
||||||
|
|
||||||
|
- (NSDictionary<NSString *, id> *)toDictionary;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface MMPeripheralsProvider : NSObject <MMTelemetryProvider>
|
||||||
|
|
||||||
|
- (instancetype)init;
|
||||||
|
|
||||||
|
+ (NSArray<MMPeripheralDevice *> *)sampleUSBDevices;
|
||||||
|
+ (NSArray<MMPeripheralDevice *> *)sampleThunderboltDevices;
|
||||||
|
+ (NSArray<MMPeripheralDevice *> *)samplePCIDevices;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
//
|
||||||
|
// MMPeripheralsProvider.m
|
||||||
|
// MacMonitor
|
||||||
|
//
|
||||||
|
// Telemetry provider enumerating connected USB, Thunderbolt, and PCI peripheral devices via IOKit.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "MMPeripheralsProvider.h"
|
||||||
|
#import <IOKit/IOKitLib.h>
|
||||||
|
|
||||||
|
@implementation MMPeripheralDevice
|
||||||
|
|
||||||
|
- (NSDictionary<NSString *, id> *)toDictionary {
|
||||||
|
NSString *busStr = @"USB";
|
||||||
|
if (self.busType == MMPeripheralBusTypeThunderbolt) {
|
||||||
|
busStr = @"Thunderbolt";
|
||||||
|
} else if (self.busType == MMPeripheralBusTypePCI) {
|
||||||
|
busStr = @"PCI";
|
||||||
|
}
|
||||||
|
|
||||||
|
return @{
|
||||||
|
@"name": self.name ?: @"Unknown Device",
|
||||||
|
@"busType": busStr,
|
||||||
|
@"vendorName": self.vendorName ?: @"Unknown",
|
||||||
|
@"vendorID": @(self.vendorID),
|
||||||
|
@"productID": @(self.productID),
|
||||||
|
@"serialNumber": self.serialNumber ?: @"",
|
||||||
|
@"deviceClass": self.deviceClass ?: @"",
|
||||||
|
@"locationID": @(self.locationID),
|
||||||
|
@"isBuiltIn": @(self.isBuiltIn)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation MMPeripheralsProvider
|
||||||
|
|
||||||
|
- (instancetype)init {
|
||||||
|
self = [super init];
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (MMTelemetryDomain)domain {
|
||||||
|
return MMTelemetryDomainPeripherals;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)providerIdentifier {
|
||||||
|
return @"com.i3omb.macmonitor.telemetry.peripherals";
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)isAvailable {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (NSArray<MMPeripheralDevice *> *)sampleUSBDevices {
|
||||||
|
NSMutableArray<MMPeripheralDevice *> *devices = [NSMutableArray array];
|
||||||
|
|
||||||
|
// Query both IOUSBHostDevice (macOS modern) and IOUSBDevice (legacy/fallback)
|
||||||
|
const char *classes[] = { "IOUSBHostDevice", "IOUSBDevice" };
|
||||||
|
NSMutableSet<NSNumber *> *seenRegistryIDs = [NSMutableSet set];
|
||||||
|
|
||||||
|
for (int i = 0; i < 2; i++) {
|
||||||
|
CFMutableDictionaryRef matching = IOServiceMatching(classes[i]);
|
||||||
|
if (!matching) continue;
|
||||||
|
|
||||||
|
io_iterator_t iterator = IO_OBJECT_NULL;
|
||||||
|
kern_return_t kr = IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator);
|
||||||
|
if (kr != KERN_SUCCESS || iterator == IO_OBJECT_NULL) continue;
|
||||||
|
|
||||||
|
io_service_t service = IO_OBJECT_NULL;
|
||||||
|
while ((service = IOIteratorNext(iterator)) != IO_OBJECT_NULL) {
|
||||||
|
uint64_t regID = 0;
|
||||||
|
IORegistryEntryGetRegistryEntryID(service, ®ID);
|
||||||
|
if ([seenRegistryIDs containsObject:@(regID)]) {
|
||||||
|
IOObjectRelease(service);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
[seenRegistryIDs addObject:@(regID)];
|
||||||
|
|
||||||
|
CFMutableDictionaryRef props = NULL;
|
||||||
|
if (IORegistryEntryCreateCFProperties(service, &props, kCFAllocatorDefault, 0) == KERN_SUCCESS && props) {
|
||||||
|
NSDictionary *dict = (__bridge NSDictionary *)props;
|
||||||
|
|
||||||
|
NSString *name = dict[@"USB Product Name"] ?: (dict[@"kUSBProductString"] ?: dict[@"IOName"]);
|
||||||
|
if (!name || name.length == 0) {
|
||||||
|
name = @"USB Device";
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *vendor = dict[@"USB Vendor Name"] ?: (dict[@"kUSBVendorString"] ?: @"");
|
||||||
|
uint32_t vendorID = [dict[@"idVendor"] unsignedIntValue];
|
||||||
|
uint32_t productID = [dict[@"idProduct"] unsignedIntValue];
|
||||||
|
NSString *serial = dict[@"USB Serial Number"] ?: dict[@"kUSBSerialNumberString"];
|
||||||
|
uint64_t locID = [dict[@"locationID"] unsignedLongLongValue];
|
||||||
|
|
||||||
|
BOOL builtIn = NO;
|
||||||
|
if (dict[@"Built-In"]) {
|
||||||
|
builtIn = [dict[@"Built-In"] boolValue];
|
||||||
|
} else if ([dict[@"non-removable"] isKindOfClass:[NSString class]]) {
|
||||||
|
builtIn = [dict[@"non-removable"] isEqualToString:@"yes"];
|
||||||
|
}
|
||||||
|
|
||||||
|
MMPeripheralDevice *dev = [[MMPeripheralDevice alloc] init];
|
||||||
|
dev.name = name;
|
||||||
|
dev.busType = MMPeripheralBusTypeUSB;
|
||||||
|
dev.vendorName = vendor;
|
||||||
|
dev.vendorID = vendorID;
|
||||||
|
dev.productID = productID;
|
||||||
|
dev.serialNumber = serial;
|
||||||
|
dev.locationID = locID;
|
||||||
|
dev.isBuiltIn = builtIn;
|
||||||
|
|
||||||
|
[devices addObject:dev];
|
||||||
|
CFRelease(props);
|
||||||
|
}
|
||||||
|
IOObjectRelease(service);
|
||||||
|
}
|
||||||
|
IOObjectRelease(iterator);
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (NSArray<MMPeripheralDevice *> *)sampleThunderboltDevices {
|
||||||
|
NSMutableArray<MMPeripheralDevice *> *devices = [NSMutableArray array];
|
||||||
|
|
||||||
|
CFMutableDictionaryRef matching = IOServiceMatching("IOThunderboltDevice");
|
||||||
|
if (!matching) return devices;
|
||||||
|
|
||||||
|
io_iterator_t iterator = IO_OBJECT_NULL;
|
||||||
|
kern_return_t kr = IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator);
|
||||||
|
if (kr != KERN_SUCCESS || iterator == IO_OBJECT_NULL) return devices;
|
||||||
|
|
||||||
|
io_service_t service = IO_OBJECT_NULL;
|
||||||
|
while ((service = IOIteratorNext(iterator)) != IO_OBJECT_NULL) {
|
||||||
|
CFMutableDictionaryRef props = NULL;
|
||||||
|
if (IORegistryEntryCreateCFProperties(service, &props, kCFAllocatorDefault, 0) == KERN_SUCCESS && props) {
|
||||||
|
NSDictionary *dict = (__bridge NSDictionary *)props;
|
||||||
|
|
||||||
|
NSString *name = dict[@"Device Name"] ?: dict[@"IOName"];
|
||||||
|
if (!name) name = @"Thunderbolt Device";
|
||||||
|
|
||||||
|
NSString *vendor = dict[@"Vendor Name"] ?: @"";
|
||||||
|
uint32_t vendorID = [dict[@"Vendor ID"] unsignedIntValue];
|
||||||
|
uint32_t productID = [dict[@"Device ID"] unsignedIntValue];
|
||||||
|
|
||||||
|
MMPeripheralDevice *dev = [[MMPeripheralDevice alloc] init];
|
||||||
|
dev.name = name;
|
||||||
|
dev.busType = MMPeripheralBusTypeThunderbolt;
|
||||||
|
dev.vendorName = vendor;
|
||||||
|
dev.vendorID = vendorID;
|
||||||
|
dev.productID = productID;
|
||||||
|
dev.isBuiltIn = NO;
|
||||||
|
|
||||||
|
[devices addObject:dev];
|
||||||
|
CFRelease(props);
|
||||||
|
}
|
||||||
|
IOObjectRelease(service);
|
||||||
|
}
|
||||||
|
IOObjectRelease(iterator);
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint32_t ParseDataUInt32(NSData *data) {
|
||||||
|
if (![data isKindOfClass:[NSData class]] || data.length == 0) return 0;
|
||||||
|
uint32_t val = 0;
|
||||||
|
size_t copyLen = MIN(sizeof(val), data.length);
|
||||||
|
memcpy(&val, data.bytes, copyLen);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (NSArray<MMPeripheralDevice *> *)samplePCIDevices {
|
||||||
|
NSMutableArray<MMPeripheralDevice *> *devices = [NSMutableArray array];
|
||||||
|
|
||||||
|
CFMutableDictionaryRef matching = IOServiceMatching("IOPCIDevice");
|
||||||
|
if (!matching) return devices;
|
||||||
|
|
||||||
|
io_iterator_t iterator = IO_OBJECT_NULL;
|
||||||
|
kern_return_t kr = IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator);
|
||||||
|
if (kr != KERN_SUCCESS || iterator == IO_OBJECT_NULL) return devices;
|
||||||
|
|
||||||
|
io_service_t service = IO_OBJECT_NULL;
|
||||||
|
while ((service = IOIteratorNext(iterator)) != IO_OBJECT_NULL) {
|
||||||
|
CFMutableDictionaryRef props = NULL;
|
||||||
|
if (IORegistryEntryCreateCFProperties(service, &props, kCFAllocatorDefault, 0) == KERN_SUCCESS && props) {
|
||||||
|
NSDictionary *dict = (__bridge NSDictionary *)props;
|
||||||
|
|
||||||
|
NSString *name = nil;
|
||||||
|
if ([dict[@"model"] isKindOfClass:[NSData class]]) {
|
||||||
|
name = [[NSString alloc] initWithData:dict[@"model"] encoding:NSUTF8StringEncoding];
|
||||||
|
name = [name stringByTrimmingCharactersInSet:[NSCharacterSet controlCharacterSet]];
|
||||||
|
}
|
||||||
|
if (!name || name.length == 0) {
|
||||||
|
name = dict[@"IOName"];
|
||||||
|
}
|
||||||
|
if (!name || name.length == 0) {
|
||||||
|
name = @"PCI Device";
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t vendorID = 0;
|
||||||
|
if ([dict[@"vendor-id"] isKindOfClass:[NSData class]]) {
|
||||||
|
vendorID = ParseDataUInt32(dict[@"vendor-id"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t deviceID = 0;
|
||||||
|
if ([dict[@"device-id"] isKindOfClass:[NSData class]]) {
|
||||||
|
deviceID = ParseDataUInt32(dict[@"device-id"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
MMPeripheralDevice *dev = [[MMPeripheralDevice alloc] init];
|
||||||
|
dev.name = name;
|
||||||
|
dev.busType = MMPeripheralBusTypePCI;
|
||||||
|
dev.vendorID = vendorID;
|
||||||
|
dev.productID = deviceID;
|
||||||
|
dev.isBuiltIn = YES;
|
||||||
|
|
||||||
|
[devices addObject:dev];
|
||||||
|
CFRelease(props);
|
||||||
|
}
|
||||||
|
IOObjectRelease(service);
|
||||||
|
}
|
||||||
|
IOObjectRelease(iterator);
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (nullable NSDictionary<NSString *, id> *)sampleTelemetryWithError:(NSError **)error {
|
||||||
|
NSArray<MMPeripheralDevice *> *usb = [MMPeripheralsProvider sampleUSBDevices];
|
||||||
|
NSArray<MMPeripheralDevice *> *tb = [MMPeripheralsProvider sampleThunderboltDevices];
|
||||||
|
NSArray<MMPeripheralDevice *> *pci = [MMPeripheralsProvider samplePCIDevices];
|
||||||
|
|
||||||
|
NSMutableArray<NSDictionary<NSString *, id> *> *usbList = [NSMutableArray arrayWithCapacity:usb.count];
|
||||||
|
for (MMPeripheralDevice *d in usb) {
|
||||||
|
[usbList addObject:[d toDictionary]];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableArray<NSDictionary<NSString *, id> *> *tbList = [NSMutableArray arrayWithCapacity:tb.count];
|
||||||
|
for (MMPeripheralDevice *d in tb) {
|
||||||
|
[tbList addObject:[d toDictionary]];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableArray<NSDictionary<NSString *, id> *> *pciList = [NSMutableArray arrayWithCapacity:pci.count];
|
||||||
|
for (MMPeripheralDevice *d in pci) {
|
||||||
|
[pciList addObject:[d toDictionary]];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSUInteger totalCount = usb.count + tb.count + pci.count;
|
||||||
|
|
||||||
|
return @{
|
||||||
|
@"totalDeviceCount": @(totalCount),
|
||||||
|
@"usbDevices": usbList,
|
||||||
|
@"thunderboltDevices": tbList,
|
||||||
|
@"pciDevices": pciList
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// MMBatteryTelemetryProvider.h
|
||||||
|
// MacMonitor
|
||||||
|
//
|
||||||
|
// Telemetry provider for AppleSmartBattery health, cycle count, capacity, and adapter info.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import "MMTelemetryProvider.h"
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@interface MMBatteryTelemetryProvider : NSObject <MMTelemetryProvider>
|
||||||
|
|
||||||
|
@property (nonatomic, readonly) BOOL hasBattery;
|
||||||
|
|
||||||
|
- (instancetype)init;
|
||||||
|
|
||||||
|
+ (NSDictionary<NSString *, id> *)calculateBatteryHealthWithProperties:(nullable NSDictionary<NSString *, id> *)rawProps;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
//
|
||||||
|
// MMBatteryTelemetryProvider.m
|
||||||
|
// MacMonitor
|
||||||
|
//
|
||||||
|
// Telemetry provider for AppleSmartBattery health, cycle count, capacity, and adapter info.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "MMBatteryTelemetryProvider.h"
|
||||||
|
#import <IOKit/IOKitLib.h>
|
||||||
|
#import <IOKit/ps/IOPowerSources.h>
|
||||||
|
#import <IOKit/ps/IOPSKeys.h>
|
||||||
|
|
||||||
|
@implementation MMBatteryTelemetryProvider
|
||||||
|
|
||||||
|
- (instancetype)init {
|
||||||
|
self = [super init];
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (MMTelemetryDomain)domain {
|
||||||
|
return MMTelemetryDomainPower;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)providerIdentifier {
|
||||||
|
return @"com.i3omb.macmonitor.telemetry.battery";
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)isAvailable {
|
||||||
|
io_service_t service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery"));
|
||||||
|
if (service != IO_OBJECT_NULL) {
|
||||||
|
IOObjectRelease(service);
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)hasBattery {
|
||||||
|
return [self isAvailable];
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (NSDictionary<NSString *, id> *)calculateBatteryHealthWithProperties:(nullable NSDictionary<NSString *, id> *)rawProps {
|
||||||
|
if (!rawProps || rawProps.count == 0) {
|
||||||
|
return @{
|
||||||
|
@"hasBattery": @(NO),
|
||||||
|
@"installed": @(NO),
|
||||||
|
@"healthCondition": @"No Battery",
|
||||||
|
@"healthPercent": @(0.0),
|
||||||
|
@"cycleCount": @(0),
|
||||||
|
@"designCycleCount": @(0),
|
||||||
|
@"currentCapacity": @(0),
|
||||||
|
@"maxCapacity": @(0),
|
||||||
|
@"designCapacity": @(0),
|
||||||
|
@"temperature": @(0.0),
|
||||||
|
@"voltage": @(0.0),
|
||||||
|
@"amperage": @(0.0),
|
||||||
|
@"watts": @(0.0),
|
||||||
|
@"isCharging": @(NO),
|
||||||
|
@"isCharged": @(NO),
|
||||||
|
@"externalConnected": @(NO),
|
||||||
|
@"timeRemainingMinutes": @(-1),
|
||||||
|
@"manufacturer": @"N/A",
|
||||||
|
@"serial": @"N/A",
|
||||||
|
@"deviceName": @"N/A"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL installed = [rawProps[@"BatteryInstalled"] boolValue];
|
||||||
|
NSInteger cycleCount = [rawProps[@"CycleCount"] integerValue];
|
||||||
|
NSInteger designCycleCount = rawProps[@"DesignCycleCount9C"] ? [rawProps[@"DesignCycleCount9C"] integerValue] : 1000;
|
||||||
|
|
||||||
|
NSInteger currentCapacity = [rawProps[@"CurrentCapacity"] integerValue];
|
||||||
|
NSInteger maxCapacity = [rawProps[@"MaxCapacity"] integerValue];
|
||||||
|
|
||||||
|
// DesignCapacity might be at top level or inside BatteryData
|
||||||
|
NSInteger designCapacity = [rawProps[@"DesignCapacity"] integerValue];
|
||||||
|
if (designCapacity <= 0 && [rawProps[@"BatteryData"] isKindOfClass:[NSDictionary class]]) {
|
||||||
|
designCapacity = [rawProps[@"BatteryData"][@"DesignCapacity"] integerValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health percentage = (MaxCapacity / DesignCapacity) * 100.0
|
||||||
|
double healthPercent = 0.0;
|
||||||
|
if (designCapacity > 0) {
|
||||||
|
healthPercent = ((double)maxCapacity / (double)designCapacity) * 100.0;
|
||||||
|
if (healthPercent > 100.0) healthPercent = 100.0;
|
||||||
|
} else if (maxCapacity > 0) {
|
||||||
|
healthPercent = 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health condition evaluation
|
||||||
|
NSString *condition = @"Normal";
|
||||||
|
BOOL permanentFailure = [rawProps[@"PermanentFailureStatus"] integerValue] != 0;
|
||||||
|
if (permanentFailure) {
|
||||||
|
condition = @"Permanent Failure";
|
||||||
|
} else if (healthPercent < 70.0 || (designCycleCount > 0 && cycleCount > designCycleCount)) {
|
||||||
|
condition = @"Service Recommended";
|
||||||
|
} else if (healthPercent < 80.0) {
|
||||||
|
condition = @"Fair";
|
||||||
|
} else {
|
||||||
|
condition = @"Normal";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temperature: AppleSmartBattery returns in units of 0.01 deg C or 0.1 deg C (e.g. 3151 = 31.51 deg C)
|
||||||
|
double rawTemp = [rawProps[@"Temperature"] doubleValue];
|
||||||
|
double tempC = rawTemp > 1000.0 ? (rawTemp / 100.0) : (rawTemp > 100.0 ? rawTemp / 10.0 : rawTemp);
|
||||||
|
|
||||||
|
double voltage = [rawProps[@"Voltage"] doubleValue] / 1000.0; // mV -> V
|
||||||
|
|
||||||
|
// Amperage can be signed 64-bit stored as unsigned in dict
|
||||||
|
int64_t rawAmp = [rawProps[@"InstantAmperage"] longLongValue];
|
||||||
|
if (rawAmp == 0) {
|
||||||
|
rawAmp = [rawProps[@"Amperage"] longLongValue];
|
||||||
|
}
|
||||||
|
int32_t signedAmp = (int32_t)rawAmp; // standard cast from uint32/uint64 representation
|
||||||
|
double amperage = ((double)signedAmp) / 1000.0; // mA -> A
|
||||||
|
double watts = fabs(voltage * amperage);
|
||||||
|
|
||||||
|
BOOL isCharging = [rawProps[@"IsCharging"] boolValue];
|
||||||
|
BOOL fullyCharged = [rawProps[@"FullyCharged"] boolValue];
|
||||||
|
BOOL externalConnected = [rawProps[@"ExternalConnected"] boolValue];
|
||||||
|
NSInteger timeRemaining = [rawProps[@"TimeRemaining"] integerValue];
|
||||||
|
if (timeRemaining == 65535) {
|
||||||
|
timeRemaining = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *manufacturer = rawProps[@"Manufacturer"] ?: @"Apple";
|
||||||
|
NSString *serial = rawProps[@"Serial"] ?: @"";
|
||||||
|
NSString *deviceName = rawProps[@"DeviceName"] ?: @"";
|
||||||
|
|
||||||
|
// Power adapter details
|
||||||
|
NSDictionary *adapterDetails = [rawProps[@"AdapterDetails"] isKindOfClass:[NSDictionary class]] ? rawProps[@"AdapterDetails"] : nil;
|
||||||
|
NSInteger adapterWatts = 0;
|
||||||
|
if (adapterDetails && adapterDetails[@"Watts"]) {
|
||||||
|
adapterWatts = [adapterDetails[@"Watts"] integerValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
return @{
|
||||||
|
@"hasBattery": @(YES),
|
||||||
|
@"installed": @(installed),
|
||||||
|
@"healthCondition": condition,
|
||||||
|
@"healthPercent": @(round(healthPercent * 10.0) / 10.0),
|
||||||
|
@"cycleCount": @(cycleCount),
|
||||||
|
@"designCycleCount": @(designCycleCount),
|
||||||
|
@"currentCapacity": @(currentCapacity),
|
||||||
|
@"maxCapacity": @(maxCapacity),
|
||||||
|
@"designCapacity": @(designCapacity),
|
||||||
|
@"temperature": @(round(tempC * 10.0) / 10.0),
|
||||||
|
@"voltage": @(round(voltage * 100.0) / 100.0),
|
||||||
|
@"amperage": @(round(amperage * 100.0) / 100.0),
|
||||||
|
@"watts": @(round(watts * 10.0) / 10.0),
|
||||||
|
@"isCharging": @(isCharging),
|
||||||
|
@"isCharged": @(fullyCharged),
|
||||||
|
@"externalConnected": @(externalConnected),
|
||||||
|
@"timeRemainingMinutes": @(timeRemaining),
|
||||||
|
@"manufacturer": manufacturer,
|
||||||
|
@"serial": serial,
|
||||||
|
@"deviceName": deviceName,
|
||||||
|
@"adapterWatts": @(adapterWatts)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
- (nullable NSDictionary<NSString *, id> *)sampleTelemetryWithError:(NSError **)error {
|
||||||
|
io_service_t service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery"));
|
||||||
|
if (service == IO_OBJECT_NULL) {
|
||||||
|
return [MMBatteryTelemetryProvider calculateBatteryHealthWithProperties:nil];
|
||||||
|
}
|
||||||
|
|
||||||
|
CFMutableDictionaryRef props = NULL;
|
||||||
|
kern_return_t kr = IORegistryEntryCreateCFProperties(service, &props, kCFAllocatorDefault, 0);
|
||||||
|
IOObjectRelease(service);
|
||||||
|
|
||||||
|
if (kr != KERN_SUCCESS || !props) {
|
||||||
|
return [MMBatteryTelemetryProvider calculateBatteryHealthWithProperties:nil];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSDictionary *dict = (__bridge_transfer NSDictionary *)props;
|
||||||
|
return [MMBatteryTelemetryProvider calculateBatteryHealthWithProperties:dict];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#ifndef MMProcessDetailInspector_h
|
||||||
|
#define MMProcessDetailInspector_h
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MMProcessDetailInspector
|
||||||
|
* Provides deep per-process inspection: open file descriptors, vnode file paths,
|
||||||
|
* active network sockets, and thread states.
|
||||||
|
*/
|
||||||
|
@interface MMProcessDetailInspector : NSObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspects a running process by PID.
|
||||||
|
*/
|
||||||
|
+ (nullable NSDictionary<NSString *, id> *)inspectProcessWithPID:(pid_t)pid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure summarizer method for deterministic unit testing.
|
||||||
|
*/
|
||||||
|
+ (NSDictionary<NSString *, id> *)summarizeInspectionResultsWithPID:(pid_t)pid
|
||||||
|
threads:(NSArray<NSDictionary<NSString *, id> *> *)threads
|
||||||
|
fds:(NSArray<NSDictionary<NSString *, id> *> *)fds
|
||||||
|
sockets:(NSArray<NSDictionary<NSString *, id> *> *)sockets;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
|
|
||||||
|
#endif /* MMProcessDetailInspector_h */
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
#import "MMProcessDetailInspector.h"
|
||||||
|
#import <libproc.h>
|
||||||
|
#import <sys/proc_info.h>
|
||||||
|
#import <arpa/inet.h>
|
||||||
|
|
||||||
|
@implementation MMProcessDetailInspector
|
||||||
|
|
||||||
|
+ (NSDictionary<NSString *, id> *)summarizeInspectionResultsWithPID:(pid_t)pid
|
||||||
|
threads:(NSArray<NSDictionary<NSString *, id> *> *)threads
|
||||||
|
fds:(NSArray<NSDictionary<NSString *, id> *> *)fds
|
||||||
|
sockets:(NSArray<NSDictionary<NSString *, id> *> *)sockets {
|
||||||
|
return @{
|
||||||
|
@"pid": @(pid),
|
||||||
|
@"threadCount": @(threads.count),
|
||||||
|
@"openFDCount": @(fds.count),
|
||||||
|
@"socketCount": @(sockets.count),
|
||||||
|
@"threads": threads,
|
||||||
|
@"fileDescriptors": fds,
|
||||||
|
@"sockets": sockets
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSString *runStateString(int state) {
|
||||||
|
switch (state) {
|
||||||
|
case 1: return @"Running";
|
||||||
|
case 2: return @"Stopped";
|
||||||
|
case 3: return @"Waiting";
|
||||||
|
case 4: return @"Uninterruptible";
|
||||||
|
case 5: return @"Halted";
|
||||||
|
default: return @"Unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSString *tcpStateString(int state) {
|
||||||
|
switch (state) {
|
||||||
|
case 0: return @"CLOSED";
|
||||||
|
case 1: return @"LISTEN";
|
||||||
|
case 2: return @"SYN_SENT";
|
||||||
|
case 3: return @"SYN_RCVD";
|
||||||
|
case 4: return @"ESTABLISHED";
|
||||||
|
case 5: return @"CLOSE_WAIT";
|
||||||
|
case 6: return @"FIN_WAIT_1";
|
||||||
|
case 7: return @"CLOSING";
|
||||||
|
case 8: return @"LAST_ACK";
|
||||||
|
case 9: return @"FIN_WAIT_2";
|
||||||
|
case 10: return @"TIME_WAIT";
|
||||||
|
default: return @"UNKNOWN";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (nullable NSDictionary<NSString *, id> *)inspectProcessWithPID:(pid_t)pid {
|
||||||
|
if (pid <= 0) return nil;
|
||||||
|
|
||||||
|
// 1. Enumerate threads
|
||||||
|
uint64_t threadIds[512];
|
||||||
|
int threadBytes = proc_pidinfo(pid, PROC_PIDLISTTHREADS, 0, threadIds, sizeof(threadIds));
|
||||||
|
int threadCount = threadBytes / sizeof(uint64_t);
|
||||||
|
|
||||||
|
NSMutableArray<NSDictionary<NSString *, id> *> *threads = [NSMutableArray arrayWithCapacity:threadCount];
|
||||||
|
for (int t = 0; t < threadCount; t++) {
|
||||||
|
uint64_t tid = threadIds[t];
|
||||||
|
struct proc_threadinfo thi;
|
||||||
|
if (proc_pidinfo(pid, PROC_PIDTHREADINFO, tid, &thi, sizeof(thi)) == sizeof(thi)) {
|
||||||
|
[threads addObject:@{
|
||||||
|
@"threadId": @(tid),
|
||||||
|
@"userTimeNs": @(thi.pth_user_time),
|
||||||
|
@"systemTimeNs": @(thi.pth_system_time),
|
||||||
|
@"cpuPercent": @(thi.pth_cpu_usage),
|
||||||
|
@"state": runStateString(thi.pth_run_state),
|
||||||
|
@"priority": @(thi.pth_priority),
|
||||||
|
@"currentPriority": @(thi.pth_curpri)
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Enumerate File Descriptors & Sockets
|
||||||
|
NSMutableArray<NSDictionary<NSString *, id> *> *fdsList = [NSMutableArray array];
|
||||||
|
NSMutableArray<NSDictionary<NSString *, id> *> *socketsList = [NSMutableArray array];
|
||||||
|
|
||||||
|
int sz = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, NULL, 0);
|
||||||
|
if (sz > 0) {
|
||||||
|
struct proc_fdinfo *fds = malloc(sz);
|
||||||
|
if (fds) {
|
||||||
|
int actual = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, fds, sz);
|
||||||
|
int fdCount = actual / sizeof(struct proc_fdinfo);
|
||||||
|
|
||||||
|
for (int j = 0; j < fdCount; j++) {
|
||||||
|
int fd = fds[j].proc_fd;
|
||||||
|
uint32_t fdType = fds[j].proc_fdtype;
|
||||||
|
|
||||||
|
if (fdType == PROX_FDTYPE_VNODE) {
|
||||||
|
struct vnode_fdinfowithpath vi;
|
||||||
|
if (proc_pidfdinfo(pid, fd, PROC_PIDFDVNODEPATHINFO, &vi, sizeof(vi)) == sizeof(vi)) {
|
||||||
|
NSString *path = [NSString stringWithUTF8String:vi.pvip.vip_path];
|
||||||
|
[fdsList addObject:@{
|
||||||
|
@"fd": @(fd),
|
||||||
|
@"type": @"File",
|
||||||
|
@"path": path ?: @"(unknown)"
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
} else if (fdType == PROX_FDTYPE_SOCKET) {
|
||||||
|
struct socket_fdinfo si;
|
||||||
|
if (proc_pidfdinfo(pid, fd, PROC_PIDFDSOCKETINFO, &si, sizeof(si)) == sizeof(si)) {
|
||||||
|
int family = si.psi.soi_family;
|
||||||
|
if (family == AF_INET || family == AF_INET6) {
|
||||||
|
char localIP[INET6_ADDRSTRLEN] = {0};
|
||||||
|
char remoteIP[INET6_ADDRSTRLEN] = {0};
|
||||||
|
int lport = 0, rport = 0;
|
||||||
|
NSString *proto = (si.psi.soi_type == SOCK_STREAM) ? @"TCP" : @"UDP";
|
||||||
|
NSString *state = @"NONE";
|
||||||
|
|
||||||
|
if (family == AF_INET) {
|
||||||
|
inet_ntop(AF_INET, &si.psi.soi_proto.pri_in.insi_laddr.ina_46.i46a_addr4, localIP, sizeof(localIP));
|
||||||
|
inet_ntop(AF_INET, &si.psi.soi_proto.pri_in.insi_faddr.ina_46.i46a_addr4, remoteIP, sizeof(remoteIP));
|
||||||
|
lport = ntohs(si.psi.soi_proto.pri_in.insi_lport);
|
||||||
|
rport = ntohs(si.psi.soi_proto.pri_in.insi_fport);
|
||||||
|
} else {
|
||||||
|
inet_ntop(AF_INET6, &si.psi.soi_proto.pri_in.insi_laddr.ina_6, localIP, sizeof(localIP));
|
||||||
|
inet_ntop(AF_INET6, &si.psi.soi_proto.pri_in.insi_faddr.ina_6, remoteIP, sizeof(remoteIP));
|
||||||
|
lport = ntohs(si.psi.soi_proto.pri_in.insi_lport);
|
||||||
|
rport = ntohs(si.psi.soi_proto.pri_in.insi_fport);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (si.psi.soi_type == SOCK_STREAM) {
|
||||||
|
state = tcpStateString(si.psi.soi_proto.pri_tcp.tcpsi_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
NSDictionary<NSString *, id> *sockDict = @{
|
||||||
|
@"fd": @(fd),
|
||||||
|
@"protocol": proto,
|
||||||
|
@"localAddress": [NSString stringWithUTF8String:localIP],
|
||||||
|
@"localPort": @(lport),
|
||||||
|
@"remoteAddress": [NSString stringWithUTF8String:remoteIP],
|
||||||
|
@"remotePort": @(rport),
|
||||||
|
@"state": state
|
||||||
|
};
|
||||||
|
|
||||||
|
[socketsList addObject:sockDict];
|
||||||
|
[fdsList addObject:@{
|
||||||
|
@"fd": @(fd),
|
||||||
|
@"type": @"Socket",
|
||||||
|
@"path": [NSString stringWithFormat:@"%@ %@:%d -> %@:%d", proto, [NSString stringWithUTF8String:localIP], lport, [NSString stringWithUTF8String:remoteIP], rport]
|
||||||
|
}];
|
||||||
|
} else {
|
||||||
|
[fdsList addObject:@{
|
||||||
|
@"fd": @(fd),
|
||||||
|
@"type": @"Socket (Unix/Other)",
|
||||||
|
@"path": @"Unix Domain Socket"
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (fdType == PROX_FDTYPE_PIPE) {
|
||||||
|
[fdsList addObject:@{
|
||||||
|
@"fd": @(fd),
|
||||||
|
@"type": @"Pipe",
|
||||||
|
@"path": @"FIFO/Pipe"
|
||||||
|
}];
|
||||||
|
} else {
|
||||||
|
[fdsList addObject:@{
|
||||||
|
@"fd": @(fd),
|
||||||
|
@"type": @"Other",
|
||||||
|
@"path": @""
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free(fds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [MMProcessDetailInspector summarizeInspectionResultsWithPID:pid
|
||||||
|
threads:threads
|
||||||
|
fds:fdsList
|
||||||
|
sockets:socketsList];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#import "MMProcessTelemetryProvider.h"
|
#import "MMProcessTelemetryProvider.h"
|
||||||
#import <libproc.h>
|
#import <libproc.h>
|
||||||
#import <sys/proc_info.h>
|
#import <sys/proc_info.h>
|
||||||
|
#import <sys/proc.h>
|
||||||
#import <pwd.h>
|
#import <pwd.h>
|
||||||
#import <signal.h>
|
#import <signal.h>
|
||||||
#import <mach/mach_time.h>
|
#import <mach/mach_time.h>
|
||||||
@@ -127,7 +128,9 @@
|
|||||||
@"residentBytes": @(tai.ptinfo.pti_resident_size),
|
@"residentBytes": @(tai.ptinfo.pti_resident_size),
|
||||||
@"virtualBytes": @(tai.ptinfo.pti_virtual_size),
|
@"virtualBytes": @(tai.ptinfo.pti_virtual_size),
|
||||||
@"threadCount": @(tai.ptinfo.pti_threadnum),
|
@"threadCount": @(tai.ptinfo.pti_threadnum),
|
||||||
@"runningThreads": @(tai.ptinfo.pti_numrunning)
|
@"runningThreads": @(tai.ptinfo.pti_numrunning),
|
||||||
|
@"isStopped": @(tai.pbsd.pbi_status == SSTOP),
|
||||||
|
@"isZombie": @(tai.pbsd.pbi_status == SZOMB)
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Alerts & Notifications View
|
||||||
|
|
||||||
|
/// Sidebar tab showing currently breached thresholds and the in-app
|
||||||
|
/// alert history log maintained by `AlertEngine`.
|
||||||
|
public struct AlertsView: View {
|
||||||
|
@State private var engine = AlertEngine.shared
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
activeAlertsCard
|
||||||
|
historyCard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Active Alerts
|
||||||
|
|
||||||
|
private var activeAlertsCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Label("Active Alerts", systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
if engine.activeAlerts.isEmpty {
|
||||||
|
Text("ALL CLEAR")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.green.opacity(0.15), in: Capsule())
|
||||||
|
} else {
|
||||||
|
Text("\(engine.activeAlerts.count) BREACHED")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.red.opacity(0.15), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if engine.activeAlerts.isEmpty {
|
||||||
|
Text("All monitored thresholds are within normal limits.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(engine.activeAlerts) { alert in
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: iconName(for: alert.kind))
|
||||||
|
.foregroundStyle(color(for: alert.severity))
|
||||||
|
.frame(width: 20)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(alert.title)
|
||||||
|
.font(.caption.bold())
|
||||||
|
Text(alert.message)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text(alert.severity.rawValue.uppercased())
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.foregroundStyle(color(for: alert.severity))
|
||||||
|
Text(alert.timestamp.formatted(date: .omitted, time: .standard))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(color(for: alert.severity).opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - History Log
|
||||||
|
|
||||||
|
private var historyCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Label("Alert History", systemImage: "clock.arrow.circlepath")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Button("Clear History") {
|
||||||
|
engine.clearHistory()
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(engine.history.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
if engine.history.isEmpty {
|
||||||
|
Text("No alert events recorded yet.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(engine.history) { event in
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: outcomeIcon(event.outcome))
|
||||||
|
.foregroundStyle(outcomeColor(event.outcome))
|
||||||
|
.frame(width: 16)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(event.message)
|
||||||
|
.font(.caption)
|
||||||
|
Text(event.alertID)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospaced()
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(event.timestamp.formatted(date: .abbreviated, time: .standard))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Presentation Helpers
|
||||||
|
|
||||||
|
private func iconName(for kind: AlertKind) -> String {
|
||||||
|
switch kind {
|
||||||
|
case .cpuTemperature: return "thermometer.high"
|
||||||
|
case .fanStall: return "fanblades"
|
||||||
|
case .memoryPressure: return "memorychip"
|
||||||
|
case .lowStorage: return "internaldrive"
|
||||||
|
case .lowBattery: return "battery.25"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func color(for severity: AlertSeverity) -> Color {
|
||||||
|
switch severity {
|
||||||
|
case .critical: return .red
|
||||||
|
case .warning: return .orange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func outcomeIcon(_ outcome: AlertEventOutcome) -> String {
|
||||||
|
switch outcome {
|
||||||
|
case .triggered: return "bell.fill"
|
||||||
|
case .resolved: return "checkmark.circle.fill"
|
||||||
|
case .suppressedCooldown: return "bell.slash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func outcomeColor(_ outcome: AlertEventOutcome) -> Color {
|
||||||
|
switch outcome {
|
||||||
|
case .triggered: return .red
|
||||||
|
case .resolved: return .green
|
||||||
|
case .suppressedCooldown: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
public struct TelemetryTrendChartView: View {
|
||||||
|
public let title: String
|
||||||
|
public let unit: String
|
||||||
|
public let color: Color
|
||||||
|
public let samples: [HistoricalSample<Double>]
|
||||||
|
public let maxY: Double?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
title: String,
|
||||||
|
unit: String,
|
||||||
|
color: Color,
|
||||||
|
samples: [HistoricalSample<Double>],
|
||||||
|
maxY: Double? = nil
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.unit = unit
|
||||||
|
self.color = color
|
||||||
|
self.samples = samples
|
||||||
|
self.maxY = maxY
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentValue: Double {
|
||||||
|
samples.last?.value ?? 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
private var averageValue: Double {
|
||||||
|
guard !samples.isEmpty else { return 0.0 }
|
||||||
|
return samples.reduce(0.0) { $0 + $1.value } / Double(samples.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var maxValue: Double {
|
||||||
|
samples.map(\.value).max() ?? 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(String(format: "Cur: %.1f %@", currentValue, unit))
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(color)
|
||||||
|
Text(String(format: "Avg: %.1f %@", averageValue, unit))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(String(format: "Max: %.1f %@", maxValue, unit))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Chart {
|
||||||
|
ForEach(Array(samples.enumerated()), id: \.element.id) { index, sample in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Sample", index),
|
||||||
|
y: .value("Value", sample.value)
|
||||||
|
)
|
||||||
|
.interpolationMethod(.monotone)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
|
||||||
|
AreaMark(
|
||||||
|
x: .value("Sample", index),
|
||||||
|
y: .value("Value", sample.value)
|
||||||
|
)
|
||||||
|
.interpolationMethod(.monotone)
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [color.opacity(0.35), color.opacity(0.05)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chartYScale(domain: 0...(maxY ?? max(1.0, maxValue * 1.15)))
|
||||||
|
.chartXAxis(.hidden)
|
||||||
|
.frame(height: 120)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(Color(NSColor.controlBackgroundColor))
|
||||||
|
.cornerRadius(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
+848
-191
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct MenuBarStatusView: View {
|
||||||
|
public let cpuLoad: Double
|
||||||
|
public let memoryPercent: Double
|
||||||
|
public let activeAlertCount: Int
|
||||||
|
|
||||||
|
public init(cpuLoad: Double, memoryPercent: Double, activeAlertCount: Int = 0) {
|
||||||
|
self.cpuLoad = cpuLoad
|
||||||
|
self.memoryPercent = memoryPercent
|
||||||
|
self.activeAlertCount = activeAlertCount
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
if activeAlertCount > 0 {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: "cpu")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
Text(String(format: "%.0f%%", cpuLoad))
|
||||||
|
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||||
|
|
||||||
|
Text("•")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Image(systemName: "memorychip")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
Text(String(format: "%.0f%%", memoryPercent))
|
||||||
|
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct QuickGlancePopoverView: View {
|
||||||
|
var store: SystemTelemetryStore
|
||||||
|
|
||||||
|
public init(store: SystemTelemetryStore) {
|
||||||
|
self.store = store
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
// Header
|
||||||
|
HStack {
|
||||||
|
Label("MacMonitor", systemImage: "macmini")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
if let window = NSApp.windows.first(where: { $0.canBecomeMain }) {
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.up.right.square")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Open Main Window")
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Core Metrics Grid
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
glanceRow(
|
||||||
|
icon: "cpu",
|
||||||
|
title: "CPU Activity",
|
||||||
|
value: String(format: "%.1f%%", store.cpuLoad.totalLoad),
|
||||||
|
subtitle: "\(store.logicalCpuCount) Cores"
|
||||||
|
)
|
||||||
|
|
||||||
|
glanceRow(
|
||||||
|
icon: "memorychip",
|
||||||
|
title: "Memory",
|
||||||
|
value: String(format: "%.1f%%", store.memory.utilizationPercentage),
|
||||||
|
subtitle: "\(formatBytes(store.memory.usedBytes)) / \(formatBytes(store.memory.totalBytes))"
|
||||||
|
)
|
||||||
|
|
||||||
|
glanceRow(
|
||||||
|
icon: "thermometer.medium",
|
||||||
|
title: "Thermals",
|
||||||
|
value: String(format: "%.1f°C", store.cpuThermal.packageTemperature),
|
||||||
|
subtitle: "Fans: \(store.fans.map { "\(Int($0.currentRPM)) RPM" }.joined(separator: ", "))"
|
||||||
|
)
|
||||||
|
|
||||||
|
glanceRow(
|
||||||
|
icon: "bolt.fill",
|
||||||
|
title: "Power",
|
||||||
|
value: String(format: "%.1f W", store.power.systemTotalWatts),
|
||||||
|
subtitle: store.batteryHealth.isCharging ? "Charging (\(Int(store.batteryHealth.healthPercent))%)" : "Battery (\(Int(store.batteryHealth.healthPercent))%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
glanceRow(
|
||||||
|
icon: "network",
|
||||||
|
title: "Network",
|
||||||
|
value: "↓ \(formatBps(totalDownloadBps)) / ↑ \(formatBps(totalUploadBps))",
|
||||||
|
subtitle: "\(store.networkBandwidth.count) active interfaces"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Footer action
|
||||||
|
HStack {
|
||||||
|
Text("Last updated: \(store.lastUpdateTimestamp.formatted(date: .omitted, time: .standard))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Button("Quit") {
|
||||||
|
NSApplication.shared.terminate(nil)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(width: 320)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalDownloadBps: Double {
|
||||||
|
store.networkBandwidth.reduce(0.0) { $0 + $1.downloadBps }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalUploadBps: Double {
|
||||||
|
store.networkBandwidth.reduce(0.0) { $0 + $1.uploadBps }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func glanceRow(icon: String, title: String, value: String, subtitle: String) -> some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.frame(width: 18)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(value)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.fontDesign(.monospaced)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatBytes(_ bytes: UInt64) -> String {
|
||||||
|
let formatter = ByteCountFormatter()
|
||||||
|
formatter.allowedUnits = [.useAll]
|
||||||
|
formatter.countStyle = .memory
|
||||||
|
return formatter.string(fromByteCount: Int64(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatBps(_ bps: Double) -> String {
|
||||||
|
if bps < 1024 {
|
||||||
|
return String(format: "%.0f B/s", bps)
|
||||||
|
} else if bps < 1024 * 1024 {
|
||||||
|
return String(format: "%.1f KB/s", bps / 1024)
|
||||||
|
} else {
|
||||||
|
return String(format: "%.2f MB/s", bps / (1024 * 1024))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
// MARK: - Alerts Settings Pane
|
||||||
|
|
||||||
|
/// Preferences UI for threshold alert rules. Bound directly to
|
||||||
|
/// `AlertEngine.shared.configuration`, which auto-persists to `UserDefaults`.
|
||||||
|
public struct AlertsSettingsView: View {
|
||||||
|
@Bindable private var engine = AlertEngine.shared
|
||||||
|
@State private var authorizationStatus: UNAuthorizationStatus = .notDetermined
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Notifications") {
|
||||||
|
Toggle("Enable Threshold Alerts", isOn: $engine.configuration.alertsEnabled)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Notification Permission")
|
||||||
|
Spacer()
|
||||||
|
Text(statusText)
|
||||||
|
.foregroundStyle(statusColor)
|
||||||
|
if authorizationStatus == .notDetermined {
|
||||||
|
Button("Request Permission") {
|
||||||
|
engine.requestNotificationAuthorization()
|
||||||
|
refreshAuthorizationStatus()
|
||||||
|
}
|
||||||
|
} else if authorizationStatus == .denied {
|
||||||
|
Button("Open System Settings") {
|
||||||
|
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Send Test Notification") {
|
||||||
|
engine.dispatcher.dispatch(SystemAlert(
|
||||||
|
id: "testNotification",
|
||||||
|
kind: .cpuTemperature,
|
||||||
|
severity: .warning,
|
||||||
|
title: String(localized: "MacMonitor Test Alert"),
|
||||||
|
message: String(localized: "Notifications are configured correctly."),
|
||||||
|
timestamp: Date()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
.disabled(!engine.configuration.alertsEnabled)
|
||||||
|
|
||||||
|
Picker("Alert Cooldown", selection: $engine.configuration.cooldownMinutes) {
|
||||||
|
Text("1 minute").tag(1.0)
|
||||||
|
Text("5 minutes").tag(5.0)
|
||||||
|
Text("15 minutes").tag(15.0)
|
||||||
|
Text("30 minutes").tag(30.0)
|
||||||
|
Text("1 hour").tag(60.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("CPU Temperature") {
|
||||||
|
Toggle("High CPU Temperature Alert", isOn: $engine.configuration.cpuTempEnabled)
|
||||||
|
LabeledContent("Threshold") {
|
||||||
|
Slider(value: $engine.configuration.cpuTempThresholdC, in: 60...110, step: 1) {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
Text("\(Int(engine.configuration.cpuTempThresholdC))°C")
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 48, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Cooling Fans") {
|
||||||
|
Toggle("Fan Stall Alert", isOn: $engine.configuration.fanStallEnabled)
|
||||||
|
LabeledContent("Alert when CPU above") {
|
||||||
|
Slider(value: $engine.configuration.fanStallTempThresholdC, in: 40...100, step: 1) {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
Text("\(Int(engine.configuration.fanStallTempThresholdC))°C")
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 48, alignment: .trailing)
|
||||||
|
}
|
||||||
|
Text("Triggers when a fan reports 0 RPM while the CPU is hot.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Memory") {
|
||||||
|
Toggle("Low Memory Alert", isOn: $engine.configuration.memoryEnabled)
|
||||||
|
LabeledContent("Free RAM below") {
|
||||||
|
Slider(value: $engine.configuration.memoryFreeMBThreshold, in: 100...4096, step: 50) {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
Text("\(Int(engine.configuration.memoryFreeMBThreshold)) MB")
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 64, alignment: .trailing)
|
||||||
|
}
|
||||||
|
Toggle("Alert on Critical Memory Pressure", isOn: $engine.configuration.memoryAlertOnCriticalPressure)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Storage") {
|
||||||
|
Toggle("Low Disk Space Alert", isOn: $engine.configuration.storageEnabled)
|
||||||
|
LabeledContent("Free space below") {
|
||||||
|
Slider(value: $engine.configuration.storageFreeGBThreshold, in: 1...100, step: 1) {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
Text("\(Int(engine.configuration.storageFreeGBThreshold)) GB")
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 48, alignment: .trailing)
|
||||||
|
}
|
||||||
|
LabeledContent("Free percent below") {
|
||||||
|
Slider(value: $engine.configuration.storageFreePercentThreshold, in: 1...50, step: 1) {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
Text("\(Int(engine.configuration.storageFreePercentThreshold))%")
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 48, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Battery") {
|
||||||
|
Toggle("Low Battery Alert", isOn: $engine.configuration.batteryEnabled)
|
||||||
|
LabeledContent("Charge below") {
|
||||||
|
Slider(value: $engine.configuration.batteryPercentThreshold, in: 1...50, step: 1) {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
Text("\(Int(engine.configuration.batteryPercentThreshold))%")
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 48, alignment: .trailing)
|
||||||
|
}
|
||||||
|
Text("Only fires while discharging on battery power.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.frame(minWidth: 480, minHeight: 520)
|
||||||
|
.task { await refreshAuthorizationStatus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusText: String {
|
||||||
|
switch authorizationStatus {
|
||||||
|
case .authorized, .provisional, .ephemeral: return "Authorized"
|
||||||
|
case .denied: return "Denied"
|
||||||
|
case .notDetermined: return "Not Requested"
|
||||||
|
@unknown default: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusColor: Color {
|
||||||
|
switch authorizationStatus {
|
||||||
|
case .authorized, .provisional, .ephemeral: return .green
|
||||||
|
case .denied: return .red
|
||||||
|
default: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshAuthorizationStatus() {
|
||||||
|
Task {
|
||||||
|
authorizationStatus = await engine.notificationAuthorizationStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshAuthorizationStatus() async {
|
||||||
|
authorizationStatus = await engine.notificationAuthorizationStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import XCTest
|
||||||
|
import UserNotifications
|
||||||
|
@testable import MacMonitor
|
||||||
|
|
||||||
|
// MARK: - Mock Dispatcher
|
||||||
|
|
||||||
|
final class MockNotificationDispatcher: AlertNotificationDispatching {
|
||||||
|
private(set) var dispatchedAlerts: [SystemAlert] = []
|
||||||
|
private(set) var authorizationRequests = 0
|
||||||
|
var stubbedStatus: UNAuthorizationStatus = .authorized
|
||||||
|
|
||||||
|
func requestAuthorization() {
|
||||||
|
authorizationRequests += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func dispatch(_ alert: SystemAlert) {
|
||||||
|
dispatchedAlerts.append(alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizationStatus() async -> UNAuthorizationStatus {
|
||||||
|
stubbedStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MMAlertsTests: XCTestCase {
|
||||||
|
private var mock: MockNotificationDispatcher!
|
||||||
|
private var engine: AlertEngine!
|
||||||
|
private var defaults: UserDefaults!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
mock = MockNotificationDispatcher()
|
||||||
|
defaults = UserDefaults(suiteName: "MMAlertsTests-\(UUID().uuidString)")!
|
||||||
|
engine = AlertEngine(dispatcher: mock, defaults: defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Helpers
|
||||||
|
|
||||||
|
private func context(
|
||||||
|
peakTemp: Double = 50,
|
||||||
|
packageTemp: Double = 50,
|
||||||
|
fans: [Int: Double] = [0: 2500],
|
||||||
|
freeMemMB: Double = 8192,
|
||||||
|
pressure: String = "Normal",
|
||||||
|
volumes: [StorageVolumeContext] = [],
|
||||||
|
battery: Double = 80,
|
||||||
|
hasBattery: Bool = false,
|
||||||
|
onAC: Bool = true,
|
||||||
|
charging: Bool = false,
|
||||||
|
at date: Date = Date()
|
||||||
|
) -> AlertEvaluationContext {
|
||||||
|
AlertEvaluationContext(
|
||||||
|
peakCoreTemperature: peakTemp,
|
||||||
|
packageTemperature: packageTemp,
|
||||||
|
fanRPMs: fans,
|
||||||
|
memoryFreeBytes: UInt64(freeMemMB * 1024 * 1024),
|
||||||
|
memoryPressureLevel: pressure,
|
||||||
|
volumes: volumes,
|
||||||
|
batteryLevel: battery,
|
||||||
|
hasBattery: hasBattery,
|
||||||
|
onExternalPower: onAC,
|
||||||
|
isCharging: charging,
|
||||||
|
timestamp: date
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: CPU Temperature
|
||||||
|
|
||||||
|
func testCPUTemperatureRuleTriggersAboveThreshold() {
|
||||||
|
engine.evaluate(context(peakTemp: 96))
|
||||||
|
XCTAssertEqual(engine.activeAlerts.count, 1)
|
||||||
|
XCTAssertEqual(engine.activeAlerts.first?.kind, .cpuTemperature)
|
||||||
|
XCTAssertEqual(engine.activeAlerts.first?.severity, .critical)
|
||||||
|
XCTAssertEqual(mock.dispatchedAlerts.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCPUTemperatureRuleUsesMaxOfPeakAndPackage() {
|
||||||
|
engine.evaluate(context(peakTemp: 40, packageTemp: 96))
|
||||||
|
XCTAssertEqual(engine.activeAlerts.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCPUTemperatureRuleDoesNotFireBelowThreshold() {
|
||||||
|
engine.evaluate(context(peakTemp: 94.9, packageTemp: 50))
|
||||||
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
||||||
|
XCTAssertTrue(mock.dispatchedAlerts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Fan Stall
|
||||||
|
|
||||||
|
func testFanStallTriggersWhenZeroRPMWhileHot() {
|
||||||
|
engine.evaluate(context(packageTemp: 80, fans: [0: 0, 1: 2200]))
|
||||||
|
XCTAssertEqual(engine.activeAlerts.count, 1)
|
||||||
|
XCTAssertEqual(engine.activeAlerts.first?.id, "fanStall:0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFanStallDoesNotTriggerWhileCPUIsCool() {
|
||||||
|
engine.evaluate(context(packageTemp: 40, fans: [0: 0]))
|
||||||
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFanStallSkippedWhenNoFanTelemetry() {
|
||||||
|
engine.evaluate(context(packageTemp: 90, fans: [:]))
|
||||||
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Memory
|
||||||
|
|
||||||
|
func testMemoryRuleTriggersOnLowFreeBytes() {
|
||||||
|
engine.evaluate(context(freeMemMB: 400))
|
||||||
|
XCTAssertEqual(engine.activeAlerts.first?.kind, .memoryPressure)
|
||||||
|
XCTAssertEqual(engine.activeAlerts.first?.severity, .warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMemoryRuleTriggersOnCriticalPressure() {
|
||||||
|
engine.evaluate(context(freeMemMB: 8192, pressure: "Critical"))
|
||||||
|
XCTAssertEqual(engine.activeAlerts.first?.kind, .memoryPressure)
|
||||||
|
XCTAssertEqual(engine.activeAlerts.first?.severity, .critical)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMemoryRuleDoesNotFireWhenHealthy() {
|
||||||
|
engine.evaluate(context(freeMemMB: 8192, pressure: "Normal"))
|
||||||
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Storage
|
||||||
|
|
||||||
|
func testStorageRuleTriggersPerVolume() {
|
||||||
|
let volumes = [
|
||||||
|
StorageVolumeContext(mountPoint: "/", volumeName: "Macintosh HD", freeBytes: 5 * 1_073_741_824,
|
||||||
|
freePercent: 5, totalBytes: 500 * 1_073_741_824),
|
||||||
|
StorageVolumeContext(mountPoint: "/Volumes/Data", volumeName: "Data", freeBytes: 500 * 1_073_741_824,
|
||||||
|
freePercent: 50, totalBytes: 1_000_000_000_000)
|
||||||
|
]
|
||||||
|
engine.evaluate(context(volumes: volumes))
|
||||||
|
XCTAssertEqual(engine.activeAlerts.count, 1)
|
||||||
|
XCTAssertEqual(engine.activeAlerts.first?.id, "lowStorage:/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStorageRuleTriggersOnLowPercent() {
|
||||||
|
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Big", volumeName: "Big",
|
||||||
|
freeBytes: 200 * 1_073_741_824, freePercent: 4,
|
||||||
|
totalBytes: 2_000_000_000_000)]
|
||||||
|
engine.evaluate(context(volumes: volumes))
|
||||||
|
XCTAssertEqual(engine.activeAlerts.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStorageRuleSkipsReadOnlyVolumes() {
|
||||||
|
// A read-only DMG mount (e.g. an app running from a mounted image)
|
||||||
|
// with ~0% free must not alert — issue #33 false positive.
|
||||||
|
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Ghostty", volumeName: "Ghostty",
|
||||||
|
freeBytes: 1_048_576, freePercent: 0.1,
|
||||||
|
totalBytes: 500_000_000_000, isReadOnly: true)]
|
||||||
|
engine.evaluate(context(volumes: volumes))
|
||||||
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
||||||
|
XCTAssertTrue(engine.history.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStorageRuleSkipsTinyVolumes() {
|
||||||
|
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Small", volumeName: "Small",
|
||||||
|
freeBytes: 1_048_576, freePercent: 0.5,
|
||||||
|
totalBytes: 200_000_000, isReadOnly: false)]
|
||||||
|
engine.evaluate(context(volumes: volumes))
|
||||||
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Battery
|
||||||
|
|
||||||
|
func testBatteryRuleTriggersOnlyWhileDischarging() {
|
||||||
|
engine.evaluate(context(battery: 8, hasBattery: true, onAC: false, charging: false))
|
||||||
|
XCTAssertEqual(engine.activeAlerts.first?.kind, .lowBattery)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBatteryRuleSkippedOnExternalPower() {
|
||||||
|
engine.evaluate(context(battery: 8, hasBattery: true, onAC: true, charging: false))
|
||||||
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBatteryRuleSkippedWhileCharging() {
|
||||||
|
engine.evaluate(context(battery: 8, hasBattery: true, onAC: false, charging: true))
|
||||||
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBatteryRuleSkippedWithoutBattery() {
|
||||||
|
engine.evaluate(context(battery: 5, hasBattery: false, onAC: false))
|
||||||
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Cooldown & Resolution
|
||||||
|
|
||||||
|
func testCooldownSuppressesRepeatNotifications() {
|
||||||
|
let t0 = Date()
|
||||||
|
engine.evaluate(context(peakTemp: 100, at: t0))
|
||||||
|
engine.evaluate(context(peakTemp: 100, at: t0.addingTimeInterval(60)))
|
||||||
|
XCTAssertEqual(mock.dispatchedAlerts.count, 1)
|
||||||
|
XCTAssertEqual(engine.activeAlerts.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCooldownExpiresAndRefires() {
|
||||||
|
let t0 = Date()
|
||||||
|
engine.evaluate(context(peakTemp: 100, at: t0))
|
||||||
|
engine.evaluate(context(peakTemp: 100, at: t0.addingTimeInterval(16 * 60)))
|
||||||
|
XCTAssertEqual(mock.dispatchedAlerts.count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResolutionClearsAlertAndAllowsImmediateRealert() {
|
||||||
|
let t0 = Date()
|
||||||
|
engine.evaluate(context(peakTemp: 100, at: t0))
|
||||||
|
engine.evaluate(context(peakTemp: 60, at: t0.addingTimeInterval(60)))
|
||||||
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
||||||
|
XCTAssertTrue(engine.history.contains { $0.outcome == .resolved })
|
||||||
|
engine.evaluate(context(peakTemp: 100, at: t0.addingTimeInterval(120)))
|
||||||
|
XCTAssertEqual(mock.dispatchedAlerts.count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHistoryRecordsTriggeredEvents() {
|
||||||
|
engine.evaluate(context(peakTemp: 100))
|
||||||
|
XCTAssertEqual(engine.history.count, 1)
|
||||||
|
XCTAssertEqual(engine.history.first?.outcome, .triggered)
|
||||||
|
XCTAssertEqual(engine.history.first?.alertID, AlertKind.cpuTemperature.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Configuration & Enablement
|
||||||
|
|
||||||
|
func testGlobalDisableSuppressesAllRules() {
|
||||||
|
engine.configuration.alertsEnabled = false
|
||||||
|
engine.evaluate(context(peakTemp: 120, fans: [0: 0], freeMemMB: 10, battery: 1, hasBattery: true, onAC: false))
|
||||||
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
||||||
|
XCTAssertTrue(mock.dispatchedAlerts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPerRuleDisableSuppressesThatRule() {
|
||||||
|
engine.configuration.cpuTempEnabled = false
|
||||||
|
engine.evaluate(context(peakTemp: 120))
|
||||||
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConfigurationPersistsToUserDefaults() {
|
||||||
|
engine.configuration.cpuTempThresholdC = 80
|
||||||
|
let reloaded = AlertConfiguration.load(from: defaults)
|
||||||
|
XCTAssertEqual(reloaded.cpuTempThresholdC, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConfigurationDefaults() {
|
||||||
|
let config = AlertConfiguration()
|
||||||
|
XCTAssertTrue(config.alertsEnabled)
|
||||||
|
XCTAssertEqual(config.cooldownMinutes, 15)
|
||||||
|
XCTAssertEqual(config.cpuTempThresholdC, 95)
|
||||||
|
XCTAssertEqual(config.memoryFreeMBThreshold, 500)
|
||||||
|
XCTAssertEqual(config.storageFreeGBThreshold, 10)
|
||||||
|
XCTAssertEqual(config.batteryPercentThreshold, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAuthorizationRequestForwarded() {
|
||||||
|
engine.requestNotificationAuthorization()
|
||||||
|
XCTAssertEqual(mock.authorizationRequests, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
//
|
||||||
|
// MMAudioTests.swift
|
||||||
|
// MacMonitorTests
|
||||||
|
//
|
||||||
|
// Unit and integration tests for MMAudioTelemetryProvider.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import MacMonitor
|
||||||
|
|
||||||
|
final class MMAudioTests: XCTestCase {
|
||||||
|
var provider: MMAudioTelemetryProvider!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
provider = MMAudioTelemetryProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
provider = nil
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDomainAndIdentifier() {
|
||||||
|
XCTAssertEqual(provider.domain, .audio)
|
||||||
|
XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.audio")
|
||||||
|
XCTAssertTrue(provider.isAvailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSampleAudioDevicesLive() {
|
||||||
|
do {
|
||||||
|
let sample = try provider.sampleTelemetry()
|
||||||
|
XCTAssertNotNil(sample)
|
||||||
|
|
||||||
|
let count = sample["deviceCount"] as? Int ?? 0
|
||||||
|
XCTAssertGreaterThanOrEqual(count, 0)
|
||||||
|
|
||||||
|
let devices = sample["devices"] as? [[String: Any]] ?? []
|
||||||
|
XCTAssertEqual(devices.count, count)
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
for dev in devices {
|
||||||
|
XCTAssertNotNil(dev["name"])
|
||||||
|
XCTAssertNotNil(dev["deviceID"])
|
||||||
|
XCTAssertNotNil(dev["isInput"])
|
||||||
|
XCTAssertNotNil(dev["isOutput"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
XCTFail("sampleTelemetry threw error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAudioDeviceModel() {
|
||||||
|
let dev = MMAudioDevice()
|
||||||
|
dev.deviceID = 42
|
||||||
|
dev.name = "External USB DAC"
|
||||||
|
dev.manufacturer = "FiiO"
|
||||||
|
dev.uid = "USB:1234"
|
||||||
|
dev.isInput = false
|
||||||
|
dev.isOutput = true
|
||||||
|
dev.isDefaultOutput = true
|
||||||
|
dev.isDefaultInput = false
|
||||||
|
dev.sampleRate = 96000.0
|
||||||
|
dev.channelCount = 2
|
||||||
|
dev.volume = 0.75
|
||||||
|
dev.isMuted = false
|
||||||
|
|
||||||
|
let dict = dev.toDictionary()
|
||||||
|
XCTAssertEqual(dict["deviceID"] as? UInt32, 42)
|
||||||
|
XCTAssertEqual(dict["name"] as? String, "External USB DAC")
|
||||||
|
XCTAssertEqual(dict["manufacturer"] as? String, "FiiO")
|
||||||
|
XCTAssertEqual(dict["isOutput"] as? Bool, true)
|
||||||
|
XCTAssertEqual(dict["isDefaultOutput"] as? Bool, true)
|
||||||
|
XCTAssertEqual(dict["sampleRate"] as? Double, 96000.0)
|
||||||
|
XCTAssertEqual(dict["channelCount"] as? UInt32, 2)
|
||||||
|
XCTAssertEqual(dict["volume"] as? Float, 0.75)
|
||||||
|
XCTAssertEqual(dict["isMuted"] as? Bool, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
//
|
||||||
|
// MMBatteryTests.swift
|
||||||
|
// MacMonitorTests
|
||||||
|
//
|
||||||
|
// Unit and integration tests for MMBatteryTelemetryProvider.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import MacMonitor
|
||||||
|
|
||||||
|
final class MMBatteryTests: XCTestCase {
|
||||||
|
var provider: MMBatteryTelemetryProvider!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
provider = MMBatteryTelemetryProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
provider = nil
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDomainAndIdentifier() {
|
||||||
|
XCTAssertEqual(provider.domain, .power)
|
||||||
|
XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.battery")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCalculateBatteryHealthWithSimulatedNormalBattery() {
|
||||||
|
let raw: [String: Any] = [
|
||||||
|
"BatteryInstalled": true,
|
||||||
|
"CycleCount": 250,
|
||||||
|
"DesignCycleCount9C": 1000,
|
||||||
|
"CurrentCapacity": 3000,
|
||||||
|
"MaxCapacity": 4000,
|
||||||
|
"DesignCapacity": 4400,
|
||||||
|
"Temperature": 3150, // 31.5 C
|
||||||
|
"Voltage": 11100, // 11.1 V
|
||||||
|
"Amperage": -1500, // -1.5 A
|
||||||
|
"IsCharging": false,
|
||||||
|
"FullyCharged": false,
|
||||||
|
"ExternalConnected": false,
|
||||||
|
"TimeRemaining": 120,
|
||||||
|
"Manufacturer": "SMP",
|
||||||
|
"Serial": "TEST123456",
|
||||||
|
"DeviceName": "bq20z451"
|
||||||
|
]
|
||||||
|
|
||||||
|
let health = MMBatteryTelemetryProvider.calculateBatteryHealth(withProperties: raw)
|
||||||
|
XCTAssertEqual(health["hasBattery"] as? Bool, true)
|
||||||
|
XCTAssertEqual(health["installed"] as? Bool, true)
|
||||||
|
XCTAssertEqual(health["healthCondition"] as? String, "Normal")
|
||||||
|
|
||||||
|
let healthPercent = (health["healthPercent"] as? Double) ?? 0.0
|
||||||
|
XCTAssertEqual(healthPercent, 90.9, accuracy: 0.1)
|
||||||
|
XCTAssertEqual(health["cycleCount"] as? Int, 250)
|
||||||
|
XCTAssertEqual(health["designCycleCount"] as? Int, 1000)
|
||||||
|
XCTAssertEqual(health["temperature"] as? Double, 31.5)
|
||||||
|
let watts = (health["watts"] as? Double) ?? 0.0
|
||||||
|
XCTAssertEqual(watts, 16.7, accuracy: 0.2)
|
||||||
|
XCTAssertEqual(health["manufacturer"] as? String, "SMP")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCalculateBatteryHealthWithServiceRecommended() {
|
||||||
|
let raw: [String: Any] = [
|
||||||
|
"BatteryInstalled": true,
|
||||||
|
"CycleCount": 1100,
|
||||||
|
"DesignCycleCount9C": 1000,
|
||||||
|
"CurrentCapacity": 1500,
|
||||||
|
"MaxCapacity": 2500,
|
||||||
|
"DesignCapacity": 4400, // ~56.8% health
|
||||||
|
"Temperature": 3200,
|
||||||
|
"Voltage": 10800,
|
||||||
|
"Amperage": -1000,
|
||||||
|
"IsCharging": false,
|
||||||
|
"FullyCharged": false,
|
||||||
|
"ExternalConnected": false,
|
||||||
|
"TimeRemaining": 90,
|
||||||
|
"Manufacturer": "Apple"
|
||||||
|
]
|
||||||
|
|
||||||
|
let health = MMBatteryTelemetryProvider.calculateBatteryHealth(withProperties: raw)
|
||||||
|
XCTAssertEqual(health["healthCondition"] as? String, "Service Recommended")
|
||||||
|
let healthPercent = (health["healthPercent"] as? Double) ?? 0.0
|
||||||
|
XCTAssertLessThan(healthPercent, 70.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCalculateBatteryHealthWithNil() {
|
||||||
|
let health = MMBatteryTelemetryProvider.calculateBatteryHealth(withProperties: nil)
|
||||||
|
XCTAssertEqual(health["hasBattery"] as? Bool, false)
|
||||||
|
XCTAssertEqual(health["installed"] as? Bool, false)
|
||||||
|
XCTAssertEqual(health["healthCondition"] as? String, "No Battery")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLiveSampleBattery() {
|
||||||
|
do {
|
||||||
|
let sample = try provider.sampleTelemetry()
|
||||||
|
XCTAssertNotNil(sample)
|
||||||
|
if provider.hasBattery {
|
||||||
|
XCTAssertEqual(sample["hasBattery"] as? Bool, true)
|
||||||
|
XCTAssertNotNil(sample["cycleCount"])
|
||||||
|
XCTAssertNotNil(sample["healthPercent"])
|
||||||
|
XCTAssertNotNil(sample["healthCondition"])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
XCTFail("sampleTelemetry threw error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import MacMonitor
|
||||||
|
|
||||||
|
final class MMDisplayTests: XCTestCase {
|
||||||
|
|
||||||
|
func testDisplayManagerActiveDisplays() {
|
||||||
|
let manager = MMDisplayManager.shared()
|
||||||
|
XCTAssertNotNil(manager, "MMDisplayManager instance should not be nil")
|
||||||
|
|
||||||
|
let displays = manager.activeDisplays()
|
||||||
|
XCTAssertNotNil(displays, "Active displays array should not be nil")
|
||||||
|
// In CI or headless/headless VM or real Mac, online display list can be checked
|
||||||
|
for display in displays {
|
||||||
|
XCTAssertGreaterThan(display.displayID, 0)
|
||||||
|
XCTAssertFalse(display.name.isEmpty)
|
||||||
|
if display.isOnline {
|
||||||
|
XCTAssertGreaterThan(display.width, 0)
|
||||||
|
XCTAssertGreaterThan(display.height, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDisplayInfoInitialization() {
|
||||||
|
let info = MMDisplayInfo(
|
||||||
|
displayID: 1001,
|
||||||
|
name: "Test Display",
|
||||||
|
isBuiltin: true,
|
||||||
|
isMain: true,
|
||||||
|
isOnline: true,
|
||||||
|
width: 2560,
|
||||||
|
height: 1600,
|
||||||
|
refreshRate: 60.0,
|
||||||
|
brightness: 0.75
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(info.displayID, 1001)
|
||||||
|
XCTAssertEqual(info.name, "Test Display")
|
||||||
|
XCTAssertTrue(info.isBuiltin)
|
||||||
|
XCTAssertTrue(info.isMain)
|
||||||
|
XCTAssertTrue(info.isOnline)
|
||||||
|
XCTAssertEqual(info.width, 2560)
|
||||||
|
XCTAssertEqual(info.height, 1600)
|
||||||
|
XCTAssertEqual(info.refreshRate, 60.0)
|
||||||
|
XCTAssertEqual(info.brightness, 0.75, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import MacMonitor
|
||||||
|
|
||||||
|
final class MMGPUTests: XCTestCase {
|
||||||
|
|
||||||
|
func testGPUMetricsCalculation() {
|
||||||
|
let mockGPUs: [[String: Any]] = [
|
||||||
|
[
|
||||||
|
"name": "Intel Iris Plus Graphics 655",
|
||||||
|
"type": "Integrated",
|
||||||
|
"utilization": 15.0,
|
||||||
|
"vramUsedBytes": 500_000_000,
|
||||||
|
"vramTotalBytes": 1_500_000_000,
|
||||||
|
"temperature": 45.0,
|
||||||
|
"isLowPower": true,
|
||||||
|
"isRemovable": false
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"name": "AMD Radeon Pro 560X",
|
||||||
|
"type": "Discrete",
|
||||||
|
"utilization": 75.0,
|
||||||
|
"vramUsedBytes": 3_000_000_000,
|
||||||
|
"vramTotalBytes": 4_000_000_000,
|
||||||
|
"temperature": 68.0,
|
||||||
|
"isLowPower": false,
|
||||||
|
"isRemovable": false
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
let metrics = MMGPUTelemetryProvider.calculateGPUMetrics(withDevices: mockGPUs)
|
||||||
|
|
||||||
|
let count = metrics["gpuCount"] as? Int ?? 0
|
||||||
|
let avg = metrics["averageUtilization"] as? Double ?? 0
|
||||||
|
let peak = metrics["peakUtilization"] as? Double ?? 0
|
||||||
|
let active = metrics["activeGPUName"] as? String ?? ""
|
||||||
|
|
||||||
|
XCTAssertEqual(count, 2)
|
||||||
|
XCTAssertEqual(avg, 45.0, accuracy: 0.1)
|
||||||
|
XCTAssertEqual(peak, 75.0, accuracy: 0.1)
|
||||||
|
XCTAssertEqual(active, "AMD Radeon Pro 560X")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLiveGPUProvider() throws {
|
||||||
|
let provider = MMGPUTelemetryProvider()
|
||||||
|
XCTAssertEqual(provider.domain, .gpu)
|
||||||
|
XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.gpu")
|
||||||
|
|
||||||
|
let sample = try provider.sampleTelemetry()
|
||||||
|
XCTAssertNotNil(sample)
|
||||||
|
XCTAssertNotNil(sample["gpuCount"])
|
||||||
|
XCTAssertNotNil(sample["devices"])
|
||||||
|
XCTAssertNotNil(sample["activeGPUName"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import MacMonitor
|
||||||
|
|
||||||
|
final class MMHistoryTests: XCTestCase {
|
||||||
|
|
||||||
|
func testRollingRingBufferCapacityAndEviction() {
|
||||||
|
let buffer = RollingRingBuffer<Double>(capacity: 3)
|
||||||
|
XCTAssertEqual(buffer.count, 0)
|
||||||
|
|
||||||
|
buffer.append(10.0)
|
||||||
|
buffer.append(20.0)
|
||||||
|
XCTAssertEqual(buffer.count, 2)
|
||||||
|
XCTAssertEqual(buffer.samples.map(\.value), [10.0, 20.0])
|
||||||
|
|
||||||
|
buffer.append(30.0)
|
||||||
|
XCTAssertEqual(buffer.count, 3)
|
||||||
|
XCTAssertEqual(buffer.samples.map(\.value), [10.0, 20.0, 30.0])
|
||||||
|
|
||||||
|
// 4th append should evict oldest (10.0)
|
||||||
|
buffer.append(40.0)
|
||||||
|
XCTAssertEqual(buffer.count, 3)
|
||||||
|
XCTAssertEqual(buffer.samples.map(\.value), [20.0, 30.0, 40.0])
|
||||||
|
|
||||||
|
buffer.clear()
|
||||||
|
XCTAssertEqual(buffer.count, 0)
|
||||||
|
XCTAssertTrue(buffer.samples.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTelemetryHistoryStoreRecord() {
|
||||||
|
let store = TelemetryHistoryStore()
|
||||||
|
let snapshot = TelemetrySnapshotRecord(
|
||||||
|
cpuLoad: 25.5,
|
||||||
|
memoryUsedPercent: 62.1,
|
||||||
|
networkInBps: 1024.0,
|
||||||
|
networkOutBps: 2048.0,
|
||||||
|
diskReadBps: 512.0,
|
||||||
|
diskWriteBps: 1024.0,
|
||||||
|
cpuTemp: 55.0,
|
||||||
|
systemPowerWatts: 18.5
|
||||||
|
)
|
||||||
|
store.record(snapshot: snapshot)
|
||||||
|
|
||||||
|
XCTAssertEqual(store.sampleCounter, 1)
|
||||||
|
XCTAssertEqual(store.cpuHistory.count, 1)
|
||||||
|
XCTAssertEqual(store.cpuHistory.samples.first?.value, 25.5)
|
||||||
|
XCTAssertEqual(store.memoryHistory.samples.first?.value, 62.1)
|
||||||
|
XCTAssertEqual(store.networkInHistory.samples.first?.value, 1024.0)
|
||||||
|
XCTAssertEqual(store.networkOutHistory.samples.first?.value, 2048.0)
|
||||||
|
XCTAssertEqual(store.diskReadHistory.samples.first?.value, 512.0)
|
||||||
|
XCTAssertEqual(store.diskWriteHistory.samples.first?.value, 1024.0)
|
||||||
|
XCTAssertEqual(store.cpuTempHistory.samples.first?.value, 55.0)
|
||||||
|
XCTAssertEqual(store.powerHistory.samples.first?.value, 18.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import XCTest
|
||||||
|
import SwiftUI
|
||||||
|
@testable import MacMonitor
|
||||||
|
|
||||||
|
final class MMMenuBarTests: XCTestCase {
|
||||||
|
|
||||||
|
func testMenuBarStatusViewInitialization() {
|
||||||
|
let view = MenuBarStatusView(cpuLoad: 24.5, memoryPercent: 55.0)
|
||||||
|
XCTAssertEqual(view.cpuLoad, 24.5)
|
||||||
|
XCTAssertEqual(view.memoryPercent, 55.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testQuickGlancePopoverViewInitialization() {
|
||||||
|
let store = SystemTelemetryStore.shared
|
||||||
|
let view = QuickGlancePopoverView(store: store)
|
||||||
|
XCTAssertNotNil(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,8 @@ final class MMNetworkBandwidthTests: XCTestCase {
|
|||||||
withCurrentSnapshots: curr,
|
withCurrentSnapshots: curr,
|
||||||
previousSnapshots: prev,
|
previousSnapshots: prev,
|
||||||
timeDelta: timeDelta,
|
timeDelta: timeDelta,
|
||||||
ipAddresses: ipDict
|
ipAddresses: ipDict,
|
||||||
|
interfaceUpFlags: ["en0": true, "lo0": true]
|
||||||
)
|
)
|
||||||
|
|
||||||
let totalDownBps = metrics["totalDownloadBytesPerSec"] as? Double ?? 0
|
let totalDownBps = metrics["totalDownloadBytesPerSec"] as? Double ?? 0
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//
|
||||||
|
// MMPeripheralsTests.swift
|
||||||
|
// MacMonitorTests
|
||||||
|
//
|
||||||
|
// Unit and integration tests for MMPeripheralsProvider.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import MacMonitor
|
||||||
|
|
||||||
|
final class MMPeripheralsTests: XCTestCase {
|
||||||
|
var provider: MMPeripheralsProvider!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
provider = MMPeripheralsProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
provider = nil
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDomainAndIdentifier() {
|
||||||
|
XCTAssertEqual(provider.domain, .peripherals)
|
||||||
|
XCTAssertEqual(provider.providerIdentifier, "com.i3omb.macmonitor.telemetry.peripherals")
|
||||||
|
XCTAssertTrue(provider.isAvailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSamplePeripheralsLive() {
|
||||||
|
do {
|
||||||
|
let sample = try provider.sampleTelemetry()
|
||||||
|
XCTAssertNotNil(sample)
|
||||||
|
|
||||||
|
let total = sample["totalDeviceCount"] as? Int ?? 0
|
||||||
|
XCTAssertGreaterThan(total, 0, "Host system should have at least 1 PCI/USB device")
|
||||||
|
|
||||||
|
let pci = sample["pciDevices"] as? [[String: Any]] ?? []
|
||||||
|
XCTAssertGreaterThan(pci.count, 0, "Intel Mac should have PCI devices (host bridge, GPU, etc.)")
|
||||||
|
|
||||||
|
if let firstPCI = pci.first {
|
||||||
|
XCTAssertNotNil(firstPCI["name"])
|
||||||
|
XCTAssertEqual(firstPCI["busType"] as? String, "PCI")
|
||||||
|
}
|
||||||
|
|
||||||
|
let usb = sample["usbDevices"] as? [[String: Any]] ?? []
|
||||||
|
for dev in usb {
|
||||||
|
XCTAssertEqual(dev["busType"] as? String, "USB")
|
||||||
|
XCTAssertNotNil(dev["name"])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
XCTFail("sampleTelemetry threw error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPeripheralDeviceModel() {
|
||||||
|
let dev = MMPeripheralDevice()
|
||||||
|
dev.name = "Magic Trackpad"
|
||||||
|
dev.busType = .USB
|
||||||
|
dev.vendorName = "Apple Inc."
|
||||||
|
dev.vendorID = 0x05ac
|
||||||
|
dev.productID = 0x0265
|
||||||
|
dev.isBuiltIn = false
|
||||||
|
|
||||||
|
let dict = dev.toDictionary()
|
||||||
|
XCTAssertEqual(dict["name"] as? String, "Magic Trackpad")
|
||||||
|
XCTAssertEqual(dict["busType"] as? String, "USB")
|
||||||
|
XCTAssertEqual(dict["vendorName"] as? String, "Apple Inc.")
|
||||||
|
XCTAssertEqual(dict["vendorID"] as? UInt32, 0x05ac)
|
||||||
|
XCTAssertEqual(dict["productID"] as? UInt32, 0x0265)
|
||||||
|
XCTAssertEqual(dict["isBuiltIn"] as? Bool, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import MacMonitor
|
||||||
|
|
||||||
|
final class MMProcessDetailsTests: XCTestCase {
|
||||||
|
|
||||||
|
func testInspectionSummaryCalculation() {
|
||||||
|
let mockThreads: [[String: Any]] = [
|
||||||
|
["threadId": 1, "state": "Running"],
|
||||||
|
["threadId": 2, "state": "Waiting"]
|
||||||
|
]
|
||||||
|
|
||||||
|
let mockFDs: [[String: Any]] = [
|
||||||
|
["fd": 0, "type": "File", "path": "/dev/null"],
|
||||||
|
["fd": 1, "type": "File", "path": "/tmp/test.log"],
|
||||||
|
["fd": 3, "type": "Socket", "path": "TCP 127.0.0.1:80 -> 127.0.0.1:50000"]
|
||||||
|
]
|
||||||
|
|
||||||
|
let mockSockets: [[String: Any]] = [
|
||||||
|
["fd": 3, "protocol": "TCP", "localAddress": "127.0.0.1", "localPort": 80, "state": "LISTEN"]
|
||||||
|
]
|
||||||
|
|
||||||
|
let summary = MMProcessDetailInspector.summarizeInspectionResults(
|
||||||
|
withPID: 1234,
|
||||||
|
threads: mockThreads,
|
||||||
|
fds: mockFDs,
|
||||||
|
sockets: mockSockets
|
||||||
|
)
|
||||||
|
|
||||||
|
let pid = summary["pid"] as? Int ?? 0
|
||||||
|
let threadCount = summary["threadCount"] as? Int ?? 0
|
||||||
|
let openFDCount = summary["openFDCount"] as? Int ?? 0
|
||||||
|
let socketCount = summary["socketCount"] as? Int ?? 0
|
||||||
|
|
||||||
|
XCTAssertEqual(pid, 1234)
|
||||||
|
XCTAssertEqual(threadCount, 2)
|
||||||
|
XCTAssertEqual(openFDCount, 3)
|
||||||
|
XCTAssertEqual(socketCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLiveProcessInspection() {
|
||||||
|
let currentPid = getpid()
|
||||||
|
let result = MMProcessDetailInspector.inspectProcess(withPID: currentPid)
|
||||||
|
|
||||||
|
XCTAssertNotNil(result)
|
||||||
|
let threadCount = result?["threadCount"] as? Int ?? 0
|
||||||
|
XCTAssertGreaterThan(threadCount, 0)
|
||||||
|
let fds = result?["fileDescriptors"] as? [[String: Any]]
|
||||||
|
XCTAssertNotNil(fds)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import MacMonitor
|
||||||
|
|
||||||
|
/// Snapshot contract conformance tests (Issues #30 / #31).
|
||||||
|
/// Feeds synthetic dictionaries keyed exactly as the Objective-C providers emit them
|
||||||
|
/// through `SystemTelemetryStore.decodeSnapshot` and asserts the decoded Swift models
|
||||||
|
/// populate — guarding against provider-identifier and field-name drift.
|
||||||
|
@MainActor
|
||||||
|
final class MMSnapshotContractTests: XCTestCase {
|
||||||
|
|
||||||
|
private var store: SystemTelemetryStore { SystemTelemetryStore.shared }
|
||||||
|
|
||||||
|
// MARK: - Provider identifier coverage (#30)
|
||||||
|
|
||||||
|
func testEmittedProviderIdentifiersAreDecoded() {
|
||||||
|
// The four identifiers that previously never reached the decoder.
|
||||||
|
let snapshot: [String: [String: Any]] = [
|
||||||
|
"com.i3omb.macmonitor.telemetry.cpuload": [
|
||||||
|
"userPercent": 25.0, "systemPercent": 15.0,
|
||||||
|
"idlePercent": 60.0, "totalPercent": 40.0,
|
||||||
|
"cores": [["totalPercent": 10.0], ["totalPercent": 90.0]],
|
||||||
|
"frequencyHz": 2.4e9, "isThrottled": false
|
||||||
|
],
|
||||||
|
"com.i3omb.macmonitor.telemetry.kernel.counters": [
|
||||||
|
"contextSwitchesRate": 1234.5, "syscallsRate": 9876.5,
|
||||||
|
"pageFaultsRate": 42.0, "cowFaultsRate": 3.0,
|
||||||
|
"zeroFillsRate": 7.0, "pageinsRate": 1.0, "pageoutsRate": 0.5,
|
||||||
|
"cumulative_contextSwitches": 999_999 as UInt64,
|
||||||
|
"cumulative_syscalls": 888_888 as UInt64,
|
||||||
|
"cumulative_pageFaults": 777_777 as UInt64
|
||||||
|
],
|
||||||
|
"com.i3omb.macmonitor.telemetry.system.load": [
|
||||||
|
"load1m": 1.5, "load5m": 2.0, "load15m": 2.5,
|
||||||
|
"taskCount": 400, "threadCount": 2000, "machFactor": 0.75
|
||||||
|
],
|
||||||
|
"com.i3omb.macmonitor.telemetry.storage.io": [
|
||||||
|
"disks": [[
|
||||||
|
"bsdName": "disk0",
|
||||||
|
"readBytesPerSec": 1_048_576.0, "writeBytesPerSec": 524_288.0,
|
||||||
|
"readIOPS": 120.0, "writeIOPS": 60.0,
|
||||||
|
"cumulativeReadBytes": 9_999_999 as UInt64,
|
||||||
|
"cumulativeWriteBytes": 8_888_888 as UInt64
|
||||||
|
]]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
store.decodeSnapshot(snapshot)
|
||||||
|
|
||||||
|
XCTAssertEqual(store.cpuLoad.userLoad, 25.0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.cpuLoad.systemLoad, 15.0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.cpuLoad.totalLoad, 40.0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.cpuLoad.perCoreLoad, [10.0, 90.0])
|
||||||
|
XCTAssertEqual(store.cpuLoad.cpuFrequencyMHz, 2400.0, accuracy: 0.001)
|
||||||
|
|
||||||
|
XCTAssertEqual(store.kernelCounters.contextSwitchesPerSec, 1234.5, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.kernelCounters.syscallsPerSec, 9876.5, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.kernelCounters.totalContextSwitches, 999_999)
|
||||||
|
XCTAssertEqual(store.kernelCounters.totalSyscalls, 888_888)
|
||||||
|
XCTAssertEqual(store.kernelCounters.totalPageFaults, 777_777)
|
||||||
|
|
||||||
|
XCTAssertEqual(store.systemLoad.load1Min, 1.5, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.systemLoad.load5Min, 2.0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.systemLoad.load15Min, 2.5, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.systemLoad.taskCount, 400)
|
||||||
|
XCTAssertEqual(store.systemLoad.threadCount, 2000)
|
||||||
|
|
||||||
|
XCTAssertEqual(store.diskIO.count, 1)
|
||||||
|
XCTAssertEqual(store.diskIO[0].bsdName, "disk0")
|
||||||
|
XCTAssertEqual(store.diskIO[0].readBps, 1_048_576.0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.diskIO[0].totalBytesRead, 9_999_999)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Field-name drift (#31)
|
||||||
|
|
||||||
|
func testMemoryUtilizationComputedFromUsedOverTotal() {
|
||||||
|
store.decodeSnapshot([
|
||||||
|
"com.i3omb.macmonitor.telemetry.memory": [
|
||||||
|
"totalBytes": 16_000_000_000 as UInt64,
|
||||||
|
"usedBytes": 8_000_000_000 as UInt64,
|
||||||
|
"freeBytes": 8_000_000_000 as UInt64,
|
||||||
|
"memoryPressureStatus": "Normal"
|
||||||
|
]
|
||||||
|
])
|
||||||
|
XCTAssertEqual(store.memory.utilizationPercentage, 50.0, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStorageVolumeEmitsUsedPercentAndFSType() {
|
||||||
|
store.decodeSnapshot([
|
||||||
|
"com.i3omb.macmonitor.telemetry.storage": [
|
||||||
|
"volumes": [[
|
||||||
|
"volumeName": "Macintosh HD", "mountPoint": "/",
|
||||||
|
"devicePath": "/dev/disk1s1", "fsType": "apfs",
|
||||||
|
"totalBytes": 500_000_000_000 as UInt64,
|
||||||
|
"usedBytes": 250_000_000_000 as UInt64,
|
||||||
|
"freeBytes": 250_000_000_000 as UInt64,
|
||||||
|
"usedPercent": 50.0, "isReadOnly": false
|
||||||
|
]]
|
||||||
|
]
|
||||||
|
])
|
||||||
|
XCTAssertEqual(store.storageVolumes.count, 1)
|
||||||
|
XCTAssertEqual(store.storageVolumes[0].usedPercentage, 50.0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.storageVolumes[0].fileSystem, "apfs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNetworkInterfaceEmittedKeys() {
|
||||||
|
store.decodeSnapshot([
|
||||||
|
"com.i3omb.macmonitor.telemetry.network.bandwidth": [
|
||||||
|
"interfaces": [[
|
||||||
|
"name": "en0", "isUp": true, "ipv4Address": "192.168.1.10",
|
||||||
|
"downloadBytesPerSec": 5_000.0, "uploadBytesPerSec": 1_000.0,
|
||||||
|
"downloadPacketsPerSec": 40.0, "uploadPacketsPerSec": 20.0,
|
||||||
|
"cumulativeInBytes": 10_000_000 as UInt64,
|
||||||
|
"cumulativeOutBytes": 5_000_000 as UInt64
|
||||||
|
]]
|
||||||
|
]
|
||||||
|
])
|
||||||
|
XCTAssertEqual(store.networkBandwidth.count, 1)
|
||||||
|
let iface = store.networkBandwidth[0]
|
||||||
|
XCTAssertEqual(iface.interfaceName, "en0")
|
||||||
|
XCTAssertTrue(iface.isUp)
|
||||||
|
XCTAssertEqual(iface.ipAddress, "192.168.1.10")
|
||||||
|
XCTAssertEqual(iface.downloadBps, 5_000.0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(iface.totalBytesIn, 10_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProcessResidentAndVirtualBytesDecode() {
|
||||||
|
store.decodeSnapshot([
|
||||||
|
"com.i3omb.macmonitor.telemetry.process": [
|
||||||
|
"processes": [[
|
||||||
|
"pid": 42, "ppid": 1, "name": "testproc",
|
||||||
|
"cpuPercent": 3.5,
|
||||||
|
"residentBytes": 100_000_000 as UInt64,
|
||||||
|
"virtualBytes": 400_000_000 as UInt64,
|
||||||
|
"threadCount": 5, "uid": 501, "username": "gordon",
|
||||||
|
"isStopped": false, "isZombie": false
|
||||||
|
]]
|
||||||
|
]
|
||||||
|
])
|
||||||
|
XCTAssertEqual(store.processes.count, 1)
|
||||||
|
XCTAssertEqual(store.processes[0].residentSize, 100_000_000)
|
||||||
|
XCTAssertEqual(store.processes[0].virtualSize, 400_000_000)
|
||||||
|
XCTAssertFalse(store.processes[0].isStopped)
|
||||||
|
XCTAssertFalse(store.processes[0].isZombie)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFanEmittedKeysDecode() {
|
||||||
|
store.decodeSnapshot([
|
||||||
|
"com.i3omb.macmonitor.telemetry.fan": [
|
||||||
|
"fans": [[
|
||||||
|
"fanIndex": 0, "name": "Exhaust",
|
||||||
|
"currentRPM": 2000.0, "minRPM": 1200.0,
|
||||||
|
"maxRPM": 6000.0, "targetRPM": 2000.0,
|
||||||
|
"utilizationPercent": 16.67
|
||||||
|
]]
|
||||||
|
]
|
||||||
|
])
|
||||||
|
XCTAssertEqual(store.fans.count, 1)
|
||||||
|
XCTAssertEqual(store.fans[0].index, 0)
|
||||||
|
XCTAssertEqual(store.fans[0].name, "Exhaust")
|
||||||
|
XCTAssertEqual(store.fans[0].utilization, 16.67, accuracy: 0.01)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ final class MacMonitorCoreTests: XCTestCase {
|
|||||||
func testSystemTelemetryStoreInitialStateAndProviders() {
|
func testSystemTelemetryStoreInitialStateAndProviders() {
|
||||||
let store = SystemTelemetryStore.shared
|
let store = SystemTelemetryStore.shared
|
||||||
XCTAssertNotNil(store)
|
XCTAssertNotNil(store)
|
||||||
XCTAssertEqual(store.registeredProviderCount, 7)
|
XCTAssertEqual(store.registeredProviderCount, 17)
|
||||||
XCTAssertFalse(store.hostModel.isEmpty)
|
XCTAssertFalse(store.hostModel.isEmpty)
|
||||||
XCTAssertFalse(store.kernelVersion.isEmpty)
|
XCTAssertFalse(store.kernelVersion.isEmpty)
|
||||||
XCTAssertGreaterThanOrEqual(store.physicalCpuCount, 1)
|
XCTAssertGreaterThanOrEqual(store.physicalCpuCount, 1)
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ settings:
|
|||||||
CLANG_ENABLE_OBJC_ARC: YES
|
CLANG_ENABLE_OBJC_ARC: YES
|
||||||
CLANG_ENABLE_MODULES: YES
|
CLANG_ENABLE_MODULES: YES
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
MARKETING_VERSION: "0.0.2"
|
||||||
|
CURRENT_PROJECT_VERSION: "2"
|
||||||
CODE_SIGNING_ALLOWED: NO
|
CODE_SIGNING_ALLOWED: NO
|
||||||
CODE_SIGN_IDENTITY: ""
|
CODE_SIGN_IDENTITY: ""
|
||||||
GCC_C_LANGUAGE_STANDARD: "gnu17"
|
GCC_C_LANGUAGE_STANDARD: "gnu17"
|
||||||
|
|||||||
Reference in New Issue
Block a user