Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
073d4036c7 | ||
|
|
a7f13542ae | ||
|
|
ba0138c188 | ||
|
|
493e80e9fe | ||
|
|
e7387a3ac4 | ||
|
|
7d2dd306ca | ||
|
|
6473a1a4b2 | ||
|
|
f34bb6bafe | ||
|
|
9b3f6ae50e | ||
|
|
53dad97c6c | ||
|
|
e49c471bad |
+82
-13
@@ -2,16 +2,8 @@ name: MacMonitor CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- 'milestone/**'
|
||||
- 'feat/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- 'milestone/**'
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -20,7 +12,7 @@ concurrency:
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Build & Test (Intel x86_64)
|
||||
runs-on: [macos, intel]
|
||||
runs-on: macos-14
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
@@ -59,12 +51,12 @@ jobs:
|
||||
if command -v xcbeautify &> /dev/null; then
|
||||
xcodebuild clean build \
|
||||
-scheme MacMonitor \
|
||||
-destination 'generic/platform=macOS,arch=x86_64' \
|
||||
-destination 'platform=macOS,arch=x86_64' \
|
||||
CODE_SIGNING_ALLOWED=NO | xcbeautify
|
||||
else
|
||||
xcodebuild clean build \
|
||||
-scheme MacMonitor \
|
||||
-destination 'generic/platform=macOS,arch=x86_64' \
|
||||
-destination 'platform=macOS,arch=x86_64' \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
fi
|
||||
|
||||
@@ -82,3 +74,80 @@ jobs:
|
||||
-destination 'platform=macOS,arch=x86_64' \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
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 'generic/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 Archive
|
||||
run: |
|
||||
APP="$PWD/DerivedData/Build/Products/Release/MacMonitor.app"
|
||||
cd "$(dirname "$APP")"
|
||||
ditto -c -k --sequesterRsrc --keepParent "MacMonitor.app" "$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.zip"
|
||||
ls -lh "$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.zip"
|
||||
|
||||
- name: Upload Workflow Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: MacMonitor-intel-x86_64
|
||||
path: MacMonitor-intel-x86_64.zip
|
||||
|
||||
- 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.zip" \
|
||||
-H "$AUTH" -H "Content-Type: application/zip" \
|
||||
--data-binary @"$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.zip" \
|
||||
-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
|
||||
@@ -7,25 +7,35 @@
|
||||
objects = {
|
||||
|
||||
/* 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 */; };
|
||||
11787A1288CC3D4CCC4F248F /* RollingRingBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E47D6CBFF7FF3465E9BC13 /* RollingRingBuffer.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 */; };
|
||||
1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78325870C7CD8312AECA2236 /* MMStorageTests.swift */; };
|
||||
1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; };
|
||||
22CA7351A745F7837E487051 /* MMPeripheralsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1486E1512594482380CB492 /* MMPeripheralsTests.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 */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 62C377EFD025D6A6B5563E77 /* MMCPUThermalProvider.m */; };
|
||||
88387CDAE2AC1CC74EF89B57 /* MacMonitorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEA6BD38BA8D887B1DB64A9 /* MacMonitorApp.swift */; };
|
||||
@@ -35,22 +45,28 @@
|
||||
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 */; };
|
||||
9B882E4189597719436F18FB /* AlertsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7077FCC0D058CFE25C1ECBE3 /* AlertsView.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 */; };
|
||||
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 */; };
|
||||
BC487DB86227F09CB0603894 /* MMBatteryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0EB61D811D989279BCBE478 /* MMBatteryTests.swift */; };
|
||||
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 */; };
|
||||
EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m in Sources */ = {isa = PBXBuildFile; fileRef = F9F9AD1268F906761032AF4D /* MMTelemetryCoordinator.m */; };
|
||||
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 */; };
|
||||
FED88F290A2F2FBD63DDEC31 /* MMDiskIOProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 3E13917CAF525640D856ECD9 /* MMDiskIOProvider.m */; };
|
||||
/* End PBXBuildFile section */
|
||||
@@ -72,6 +88,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -88,21 +105,27 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
728C692778909D2B7451C07B /* MMProcessTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMProcessTelemetryProvider.m; sourceTree = "<group>"; };
|
||||
@@ -110,33 +133,43 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -169,20 +202,43 @@
|
||||
4D8D8B40632C525CBC1D0E43 /* App */,
|
||||
54787E6270EA7B9A1BE11359 /* Core */,
|
||||
9844EC526E3527F6A582179F /* Hardware */,
|
||||
4BD6F71AA668F3A4B161D0FA /* History */,
|
||||
DB11C1CC50774018D85A1D79 /* Intelligence */,
|
||||
8BF79E3BCB76E7A1669DC930 /* Telemetry */,
|
||||
3E4A0B53C583BD0B7A68EACE /* UI */,
|
||||
);
|
||||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3A1C97833AFDC29469DCA9ED /* Charts */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3A7DCFA63526AB6AC918F1CF /* TelemetryTrendChartView.swift */,
|
||||
);
|
||||
path = Charts;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3E4A0B53C583BD0B7A68EACE /* UI */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E142AE61F9B87757997870DD /* ContentView.swift */,
|
||||
B42842AC7D80A8B77A4A611D /* Alerts */,
|
||||
3A1C97833AFDC29469DCA9ED /* Charts */,
|
||||
BC3C9017978097F8240CEC63 /* MenuBar */,
|
||||
E3A5B8BAED7E91EE8C6B3B2B /* Settings */,
|
||||
);
|
||||
path = UI;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4BD6F71AA668F3A4B161D0FA /* History */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
48E47D6CBFF7FF3465E9BC13 /* RollingRingBuffer.swift */,
|
||||
503315AEC1C8C51111AD765B /* TelemetryHistoryStore.swift */,
|
||||
);
|
||||
path = History;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4D8D8B40632C525CBC1D0E43 /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -243,11 +299,21 @@
|
||||
path = Power;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8B972045B60442056C6456DF /* Display */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D52C042B0AE785CF71C6F543 /* MMDisplayManager.h */,
|
||||
F698B0CC35A89EDEA03F08A5 /* MMDisplayManager.m */,
|
||||
);
|
||||
path = Display;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8BF79E3BCB76E7A1669DC930 /* Telemetry */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
92E1EF545807CF318620FEAA /* Audio */,
|
||||
78383CED205BCA772701AE48 /* CPU */,
|
||||
8B972045B60442056C6456DF /* Display */,
|
||||
AD2995435E44FA3E1790A4F3 /* Fan */,
|
||||
F99AF003017BA9F19F7AEB1E /* GPU */,
|
||||
CA2F30473D63EC64CBD65437 /* Kernel */,
|
||||
@@ -309,6 +375,14 @@
|
||||
path = Fan;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B42842AC7D80A8B77A4A611D /* Alerts */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7077FCC0D058CFE25C1ECBE3 /* AlertsView.swift */,
|
||||
);
|
||||
path = Alerts;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B6EB3F6545276C40212C2992 /* Storage */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -320,6 +394,15 @@
|
||||
path = Storage;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BC3C9017978097F8240CEC63 /* MenuBar */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9E4A682661D148FF733F6102 /* MenuBarStatusView.swift */,
|
||||
0D836CEC50067A77055EF224 /* QuickGlancePopoverView.swift */,
|
||||
);
|
||||
path = MenuBar;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C80C3EEE8F912F4B214102E3 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -333,17 +416,21 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2EC3EDFCD5C50CB352FCED87 /* MacMonitorTests.swift */,
|
||||
D17F38FFBF523575F41E68C6 /* MMAlertsTests.swift */,
|
||||
10D16E1ABE6D28B676EA83FF /* MMAudioTests.swift */,
|
||||
B0EB61D811D989279BCBE478 /* MMBatteryTests.swift */,
|
||||
B288C7402E2C00E75F8CD016 /* MMComponentThermalTests.swift */,
|
||||
4CE92B1A8D7FDBB6499448E1 /* MMCPULoadTests.swift */,
|
||||
0F2BD3777A674DC4EB442890 /* MMCPUThermalTests.swift */,
|
||||
E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */,
|
||||
DFB5FD360B39A2AE6DFAEDDA /* MMDisplayTests.swift */,
|
||||
336CEE35A745F3C7E9F70F43 /* MMFanTests.swift */,
|
||||
80A0F723121CDF9597316F58 /* MMGPUTests.swift */,
|
||||
568622E0726EF1D5D2E705CE /* MMHistoryTests.swift */,
|
||||
DE40072F9A1F9DBD1C48A3B1 /* MMKernelTelemetryTests.swift */,
|
||||
7EB05C092C94C3972727AAA7 /* MMLoadAverageTests.swift */,
|
||||
099DCF7EF03A58ADA40A94A1 /* MMMemoryTests.swift */,
|
||||
93ED125FEC2602DE91D3983F /* MMMenuBarTests.swift */,
|
||||
27C612287333D757C92A076A /* MMNetworkBandwidthTests.swift */,
|
||||
374B1BF631CC4738CB52CA3B /* MMNetworkSocketsTests.swift */,
|
||||
D1486E1512594482380CB492 /* MMPeripheralsTests.swift */,
|
||||
@@ -365,6 +452,17 @@
|
||||
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 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -388,6 +486,14 @@
|
||||
path = AppleSMC;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E3A5B8BAED7E91EE8C6B3B2B /* Settings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
98F64EA2A13630F0E2E6D382 /* AlertsSettingsView.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E56B76815ED75B28A13D6814 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -490,17 +596,21 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D3863FD0563E8F4C1EAE279A /* MMAlertsTests.swift in Sources */,
|
||||
9D12825C7904FB0DAE664CA0 /* MMAudioTests.swift in Sources */,
|
||||
BC487DB86227F09CB0603894 /* MMBatteryTests.swift in Sources */,
|
||||
160334CC765E091A7CBE21AA /* MMCPULoadTests.swift in Sources */,
|
||||
87818E46CEA5145A8215D0E3 /* MMCPUThermalTests.swift in Sources */,
|
||||
57BA02AE1CE24DBD02BD03A4 /* MMComponentThermalTests.swift in Sources */,
|
||||
6A4FDB46AE5E0DAF85CCAB56 /* MMDiskIOTests.swift in Sources */,
|
||||
75CF7FF817B7EB5011FBFC18 /* MMDisplayTests.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 */,
|
||||
0A59F7CA4F9C04C743ACCAE7 /* MMMenuBarTests.swift in Sources */,
|
||||
5D31906EA986E2BA92E86FA8 /* MMNetworkBandwidthTests.swift in Sources */,
|
||||
4C1A92F4DEF4C824976115C2 /* MMNetworkSocketsTests.swift in Sources */,
|
||||
22CA7351A745F7837E487051 /* MMPeripheralsTests.swift in Sources */,
|
||||
@@ -517,6 +627,12 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
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 */,
|
||||
CD91FC663B19DA1FEA3D49D7 /* MMAppleSMCClient.m in Sources */,
|
||||
D7784A61FE4635275CFB3D6C /* MMAudioTelemetryProvider.m in Sources */,
|
||||
@@ -525,6 +641,7 @@
|
||||
87A9E18362486D0D385BBC4A /* MMCPUThermalProvider.m in Sources */,
|
||||
925245C5B5F8D8DA3C89C4BA /* MMComponentThermalProvider.m in Sources */,
|
||||
FED88F290A2F2FBD63DDEC31 /* MMDiskIOProvider.m in Sources */,
|
||||
DD3E4F79A146C323CD849F2B /* MMDisplayManager.m in Sources */,
|
||||
655366D7066478A02EDA91BB /* MMFanTelemetryProvider.m in Sources */,
|
||||
6D973241BBE61F0B053239DB /* MMGPUTelemetryProvider.m in Sources */,
|
||||
A521961E035DD50ED8B0C1A9 /* MMKernelTelemetryProvider.m in Sources */,
|
||||
@@ -540,7 +657,12 @@
|
||||
F2F7A141F2452EC1B4FB45D8 /* MMStorageTelemetryProvider.m in Sources */,
|
||||
EAFAF5A10502D4ACC64F4720 /* MMTelemetryCoordinator.m 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 */,
|
||||
EEAAEA6AABAA55FA8DF3453B /* TelemetryHistoryStore.swift in Sources */,
|
||||
475D6A2B1CF0E847DC39D331 /* TelemetryTrendChartView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
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
|
||||
struct MacMonitorApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@State private var store = SystemTelemetryStore.shared
|
||||
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
@@ -11,5 +22,20 @@ struct MacMonitorApp: App {
|
||||
.windowStyle(.titleBar)
|
||||
.windowToolbarStyle(.unified)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +252,16 @@ public final class SystemTelemetryStore {
|
||||
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()
|
||||
@@ -338,7 +348,8 @@ public final class SystemTelemetryStore {
|
||||
m.swapTotalBytes = (memDict["swapTotalBytes"] as? NSNumber)?.uint64Value ?? 0
|
||||
m.swapUsedBytes = (memDict["swapUsedBytes"] as? NSNumber)?.uint64Value ?? 0
|
||||
m.utilizationPercentage = (memDict["utilizationPercentage"] as? NSNumber)?.doubleValue ?? 0
|
||||
m.pressureLevel = memDict["pressureLevel"] as? String ?? "Normal"
|
||||
m.pressureLevel = memDict["memoryPressureStatus"] as? String
|
||||
?? memDict["pressureLevel"] as? String ?? "Normal"
|
||||
self.memory = m
|
||||
}
|
||||
|
||||
@@ -653,6 +664,51 @@ public final class SystemTelemetryStore {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
context.batteryLevel = 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 {
|
||||
|
||||
@@ -30,5 +30,6 @@
|
||||
#import "MMBatteryTelemetryProvider.h"
|
||||
#import "MMPeripheralsProvider.h"
|
||||
#import "MMAudioTelemetryProvider.h"
|
||||
#import "MMDisplayManager.h"
|
||||
|
||||
#endif /* MacMonitor_Bridging_Header_h */
|
||||
|
||||
@@ -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,227 @@
|
||||
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 {
|
||||
for volume in ctx.volumes {
|
||||
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,125 @@
|
||||
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 init(mountPoint: String, volumeName: String, freeBytes: UInt64, freePercent: Double) {
|
||||
self.mountPoint = mountPoint
|
||||
self.volumeName = volumeName
|
||||
self.freeBytes = freeBytes
|
||||
self.freePercent = freePercent
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ public struct ContentView: View {
|
||||
@State private var socketSearchText: String = ""
|
||||
@State private var selectedPID: Int32? = nil
|
||||
@State private var showingProcessDetail: Bool = false
|
||||
@State private var navBus = AppNavigationBus.shared
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -25,6 +26,15 @@ public struct ContentView: View {
|
||||
ProcessDetailSheet(inspector: store.processInspector, pid: pid)
|
||||
}
|
||||
}
|
||||
.onChange(of: navBus.requestCounter) { _, _ in
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if let window = NSApp.windows.first(where: { $0.canBecomeMain }) {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
if let tab = navBus.requestedTab {
|
||||
selectedTab = tab
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sidebar
|
||||
@@ -60,6 +70,8 @@ public struct ContentView: View {
|
||||
}
|
||||
|
||||
Section("Hardware & Devices") {
|
||||
Label("Displays & Brightness", systemImage: "sun.max")
|
||||
.tag("Displays")
|
||||
Label("Process Explorer", systemImage: "list.bullet.rectangle")
|
||||
.tag("Processes")
|
||||
Label("Graphics (GPU)", systemImage: "display")
|
||||
@@ -70,6 +82,14 @@ public struct ContentView: View {
|
||||
.tag("Peripherals")
|
||||
Label("Audio Devices", systemImage: "speaker.wave.3.fill")
|
||||
.tag("Audio")
|
||||
Label("Historical Trends", systemImage: "chart.line.uptrend.xyaxis")
|
||||
.tag("Trends")
|
||||
}
|
||||
|
||||
Section("Intelligence") {
|
||||
Label("Alerts & Notifications", systemImage: "bell.badge")
|
||||
.tag("Alerts")
|
||||
.badge(store.alertEngine.activeAlerts.isEmpty ? nil : Text("\(store.alertEngine.activeAlerts.count)"))
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
@@ -119,8 +139,14 @@ public struct ContentView: View {
|
||||
powerCard
|
||||
case "Peripherals":
|
||||
peripheralsCard
|
||||
case "Displays":
|
||||
displaysCard
|
||||
case "Audio":
|
||||
audioDevicesCard
|
||||
case "Trends":
|
||||
historicalTrendsView
|
||||
case "Alerts":
|
||||
AlertsView()
|
||||
default:
|
||||
dashboardView
|
||||
}
|
||||
@@ -153,6 +179,7 @@ public struct ContentView: View {
|
||||
gpuTelemetryCard
|
||||
batteryHealthCard
|
||||
}
|
||||
displaysCard
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +202,24 @@ public struct ContentView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if !store.alertEngine.activeAlerts.isEmpty {
|
||||
Button {
|
||||
selectedTab = "Alerts"
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
Text("\(store.alertEngine.activeAlerts.count)")
|
||||
.font(.caption.bold().monospacedDigit())
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.red.opacity(0.15), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("\(store.alertEngine.activeAlerts.count) active alert(s) — click to review")
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(store.isRunning ? Color.green : Color.red)
|
||||
@@ -990,6 +1035,146 @@ public struct ContentView: View {
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
||||
}
|
||||
|
||||
// MARK: - Displays Card
|
||||
private var displaysCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Label("Connected Displays & Brightness", systemImage: "sun.max")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text("\(store.displays.count) Display\(store.displays.count == 1 ? "" : "s")")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if store.displays.isEmpty {
|
||||
Text("No active displays detected.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(store.displays, id: \.displayID) { display in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: display.isBuiltin ? "laptopcomputer" : "display")
|
||||
.foregroundStyle(.blue)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(display.name)
|
||||
.font(.subheadline.bold())
|
||||
if display.isMain {
|
||||
Text("MAIN")
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.blue.opacity(0.15), in: Capsule())
|
||||
}
|
||||
}
|
||||
Text("\(display.width) × \(display.height) @ \(Int(display.refreshRate))Hz")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if display.brightness >= 0 {
|
||||
Text("\(Int(display.brightness * 100))%")
|
||||
.font(.subheadline.bold().monospacedDigit())
|
||||
}
|
||||
}
|
||||
|
||||
if display.brightness >= 0 {
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(display.brightness) },
|
||||
set: { newBrightness in
|
||||
_ = store.displayManager.setBrightness(Float(newBrightness), forDisplay: display.displayID)
|
||||
store.triggerManualSample()
|
||||
}
|
||||
),
|
||||
in: 0.0...1.0
|
||||
)
|
||||
} else {
|
||||
Text("Hardware brightness control not supported for this monitor.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(.background, in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
|
||||
}
|
||||
|
||||
// MARK: - Historical Trends View
|
||||
private var historicalTrendsView: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
TelemetryTrendChartView(
|
||||
title: "CPU Utilization History",
|
||||
unit: "%",
|
||||
color: .blue,
|
||||
samples: store.historyStore.cpuHistory.samples,
|
||||
maxY: 100.0
|
||||
)
|
||||
|
||||
TelemetryTrendChartView(
|
||||
title: "Memory Utilization History",
|
||||
unit: "%",
|
||||
color: .purple,
|
||||
samples: store.historyStore.memoryHistory.samples,
|
||||
maxY: 100.0
|
||||
)
|
||||
|
||||
TelemetryTrendChartView(
|
||||
title: "CPU Temperature History",
|
||||
unit: "°C",
|
||||
color: .orange,
|
||||
samples: store.historyStore.cpuTempHistory.samples,
|
||||
maxY: 110.0
|
||||
)
|
||||
|
||||
TelemetryTrendChartView(
|
||||
title: "System Power Draw History",
|
||||
unit: "W",
|
||||
color: .yellow,
|
||||
samples: store.historyStore.powerHistory.samples
|
||||
)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
TelemetryTrendChartView(
|
||||
title: "Network Download Rate",
|
||||
unit: "B/s",
|
||||
color: .green,
|
||||
samples: store.historyStore.networkInHistory.samples
|
||||
)
|
||||
|
||||
TelemetryTrendChartView(
|
||||
title: "Network Upload Rate",
|
||||
unit: "B/s",
|
||||
color: .teal,
|
||||
samples: store.historyStore.networkOutHistory.samples
|
||||
)
|
||||
}
|
||||
|
||||
HStack(spacing: 16) {
|
||||
TelemetryTrendChartView(
|
||||
title: "Disk Read Throughput",
|
||||
unit: "B/s",
|
||||
color: .indigo,
|
||||
samples: store.historyStore.diskReadHistory.samples
|
||||
)
|
||||
|
||||
TelemetryTrendChartView(
|
||||
title: "Disk Write Throughput",
|
||||
unit: "B/s",
|
||||
color: .pink,
|
||||
samples: store.historyStore.diskWriteHistory.samples
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Formatting Helpers
|
||||
private func formatBytes(_ bytes: UInt64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
|
||||
@@ -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,236 @@
|
||||
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),
|
||||
StorageVolumeContext(mountPoint: "/Volumes/Data", volumeName: "Data", freeBytes: 500 * 1_073_741_824, freePercent: 50)
|
||||
]
|
||||
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)]
|
||||
engine.evaluate(context(volumes: volumes))
|
||||
XCTAssertEqual(engine.activeAlerts.count, 1)
|
||||
}
|
||||
|
||||
// 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,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 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user