Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05669f1b41 | ||
|
|
8dafffdd6e | ||
|
|
68e6df2938 | ||
|
|
7473e95635 | ||
|
|
57e2ddd225 | ||
|
|
33bee371f1 | ||
|
|
bb91042da7 | ||
|
|
f8f2cc0b90 | ||
|
|
9f5481a1a9 | ||
|
|
5676279a4c | ||
|
|
7ba4284be1 | ||
|
|
01e871dc2c | ||
|
|
beabf016b0 | ||
|
|
60f4039013 | ||
|
|
125c489881 | ||
|
|
d75873fa30 | ||
|
|
3095bd106a | ||
|
|
d0c286e7c8 | ||
|
|
807fcae6b2 | ||
|
|
0e5af9d70d | ||
|
|
acf5d03277 | ||
|
|
9933c87004 | ||
|
|
4a8fd0e5ef | ||
|
|
bc735b57e6 |
+14
-10
@@ -12,7 +12,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
build-and-test:
|
||||||
name: Build & Test (Intel x86_64)
|
name: Build & Test (Intel x86_64)
|
||||||
runs-on: macos-14
|
runs-on: macos-12
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
release-package:
|
release-package:
|
||||||
name: Package Release Artifact
|
name: Package Release Artifact
|
||||||
needs: build-and-test
|
needs: build-and-test
|
||||||
runs-on: macos-14
|
runs-on: macos-12
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -111,19 +111,23 @@ jobs:
|
|||||||
codesign --force --deep --sign - "$APP"
|
codesign --force --deep --sign - "$APP"
|
||||||
codesign --verify --verbose "$APP"
|
codesign --verify --verbose "$APP"
|
||||||
|
|
||||||
- name: Create Distribution Archive
|
- name: Create Distribution Disk Image (.dmg)
|
||||||
run: |
|
run: |
|
||||||
APP="$PWD/DerivedData/Build/Products/Release/MacMonitor.app"
|
APP="$PWD/DerivedData/Build/Products/Release/MacMonitor.app"
|
||||||
cd "$(dirname "$APP")"
|
DMG_STAGING="$(mktemp -d)/staging"
|
||||||
ditto -c -k --sequesterRsrc --keepParent "MacMonitor.app" "$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.zip"
|
mkdir -p "$DMG_STAGING"
|
||||||
ls -lh "$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.zip"
|
cp -R "$APP" "$DMG_STAGING/"
|
||||||
|
ln -s /Applications "$DMG_STAGING/Applications"
|
||||||
|
hdiutil create -volname "MacMonitor" -srcfolder "$DMG_STAGING" -ov -format UDZO "$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.dmg"
|
||||||
|
rm -rf "$(dirname "$DMG_STAGING")"
|
||||||
|
ls -lh "$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.dmg"
|
||||||
|
|
||||||
- name: Upload Workflow Artifact
|
- name: Upload Workflow Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
name: MacMonitor-intel-x86_64
|
name: MacMonitor-intel-x86_64
|
||||||
path: MacMonitor-intel-x86_64.zip
|
path: MacMonitor-intel-x86_64.dmg
|
||||||
|
|
||||||
- name: Create Gitea Release & Attach Artifact
|
- name: Create Gitea Release & Attach Artifact
|
||||||
run: |
|
run: |
|
||||||
@@ -147,7 +151,7 @@ jobs:
|
|||||||
echo "ERROR: could not resolve release ID for $TAG"; exit 1
|
echo "ERROR: could not resolve release ID for $TAG"; exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
curl -sS -X POST "$API/releases/$RELEASE_ID/assets?name=MacMonitor-intel-x86_64.zip" \
|
curl -sS -X POST "$API/releases/$RELEASE_ID/assets?name=MacMonitor-intel-x86_64.dmg" \
|
||||||
-H "$AUTH" -H "Content-Type: application/zip" \
|
-H "$AUTH" -H "Content-Type: application/x-apple-diskimage" \
|
||||||
--data-binary @"$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.zip" \
|
--data-binary @"$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.dmg" \
|
||||||
-o /tmp/asset.json -w "attach_asset_http=%{http_code}\n"
|
-o /tmp/asset.json -w "attach_asset_http=%{http_code}\n"
|
||||||
|
|||||||
@@ -25,3 +25,7 @@ xcuserdata/
|
|||||||
*.dmg
|
*.dmg
|
||||||
*.zip
|
*.zip
|
||||||
*.app
|
*.app
|
||||||
|
FIRST-PASS-REFERENCE.md
|
||||||
|
SECOND-PASS-REFERENCE.md
|
||||||
|
Docs/AppleSilicon-plan.md
|
||||||
|
Docs/Monterey-plan.md
|
||||||
|
|||||||
+4
-4
@@ -3,7 +3,7 @@
|
|||||||
## 1. Project Specifications & Architectural Constraints
|
## 1. Project Specifications & Architectural Constraints
|
||||||
|
|
||||||
- **Platform Target:** macOS for Intel (`x86_64`).
|
- **Platform Target:** macOS for Intel (`x86_64`).
|
||||||
- **Minimum OS Version:** macOS 14.0 (Sonoma) or higher.
|
- **Minimum OS Version:** macOS 12.0 (Monterey) or higher.
|
||||||
- **Language Stack:** SwiftUI (Presentation & ViewModels) + Objective-C / C (Low-level Mach, IOKit, SMC, BSD Sysctl APIs).
|
- **Language Stack:** SwiftUI (Presentation & ViewModels) + Objective-C / C (Low-level Mach, IOKit, SMC, BSD Sysctl APIs).
|
||||||
- **Interoperability:** Modular Objective-C bridging layer (`MacMonitor-Bridging-Header.h`) exposing thread-safe C primitives to Swift.
|
- **Interoperability:** Modular Objective-C bridging layer (`MacMonitor-Bridging-Header.h`) exposing thread-safe C primitives to Swift.
|
||||||
- **CI/CD Platform:** Gitea Actions executing on Intel macOS runners (`[macos, intel]`).
|
- **CI/CD Platform:** Gitea Actions executing on Intel macOS runners (`[macos, intel]`).
|
||||||
@@ -144,7 +144,7 @@ graph TD
|
|||||||
2. **Phase 1.2 (Parallel Tracks):**
|
2. **Phase 1.2 (Parallel Tracks):**
|
||||||
- **Track 1.2A:** `#2` AppleSMC Client (`feat/2-smc-client`). Low-level SMC key reader, data parsers (`sp78`, `fpe2`, `flt`).
|
- **Track 1.2A:** `#2` AppleSMC Client (`feat/2-smc-client`). Low-level SMC key reader, data parsers (`sp78`, `fpe2`, `flt`).
|
||||||
- **Track 1.2B:** `#25` Gitea CI/CD Pipeline (`feat/25-gitea-ci`). Create `.gitea/workflows/build.yml` targeting Intel runners.
|
- **Track 1.2B:** `#25` Gitea CI/CD Pipeline (`feat/25-gitea-ci`). Create `.gitea/workflows/build.yml` targeting Intel runners.
|
||||||
- **Quality Gate M1:** Successful build and execution of sample test suite on Gitea Actions runner compiling for `x86_64` macOS 14.
|
- **Quality Gate M1:** Successful build and execution of sample test suite on Gitea Actions runner compiling for `x86_64` macOS 12.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ graph TD
|
|||||||
|
|
||||||
### Milestone 4: Presentation, Visuals & Menu Bar Integration
|
### Milestone 4: Presentation, Visuals & Menu Bar Integration
|
||||||
- **Integration Branch:** `milestone/m4-presentation`
|
- **Integration Branch:** `milestone/m4-presentation`
|
||||||
- **Goal:** User-facing presentation layer: macOS 14 Menu Bar Extra, popover widgets, display brightness management, and Swift Charts rolling graphs.
|
- **Goal:** User-facing presentation layer: macOS 12 Menu Bar Extra, popover widgets, display brightness management, and Swift Charts rolling graphs.
|
||||||
- **Execution Order:**
|
- **Execution Order:**
|
||||||
1. `#13` Screen Brightness & Display Management (`feat/13-display-brightness`)
|
1. `#13` Screen Brightness & Display Management (`feat/13-display-brightness`)
|
||||||
2. `#22` Status Bar Menu Bar Extra & Popover (`feat/22-menubar-popover`)
|
2. `#22` Status Bar Menu Bar Extra & Popover (`feat/22-menubar-popover`)
|
||||||
@@ -203,7 +203,7 @@ graph TD
|
|||||||
- **Goal:** Proactive health alerts, macOS Notification Center integration, end-to-end regression validation, and release packaging.
|
- **Goal:** Proactive health alerts, macOS Notification Center integration, end-to-end regression validation, and release packaging.
|
||||||
- **Execution Order:**
|
- **Execution Order:**
|
||||||
1. `#24` Threshold Alerts & Notification Center (`feat/24-threshold-alerts` - Depends on #3, #4, #8, #9)
|
1. `#24` Threshold Alerts & Notification Center (`feat/24-threshold-alerts` - Depends on #3, #4, #8, #9)
|
||||||
2. Full end-to-end integration testing and automated release archiving (`MacMonitor-intel-x86_64.zip`) on tagged commit.
|
2. Full end-to-end integration testing and automated release archiving (`MacMonitor-intel-x86_64.dmg`) on tagged commit.
|
||||||
- **Quality Gate M5:** Zero crashes, all unit test suites passing on Intel macOS runner, DMG/ZIP distribution artifact ready.
|
- **Quality Gate M5:** Zero crashes, all unit test suites passing on Intel macOS runner, DMG/ZIP distribution artifact ready.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78325870C7CD8312AECA2236 /* MMStorageTests.swift */; };
|
1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78325870C7CD8312AECA2236 /* MMStorageTests.swift */; };
|
||||||
1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; };
|
1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; };
|
||||||
22CA7351A745F7837E487051 /* MMPeripheralsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1486E1512594482380CB492 /* MMPeripheralsTests.swift */; };
|
22CA7351A745F7837E487051 /* MMPeripheralsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1486E1512594482380CB492 /* MMPeripheralsTests.swift */; };
|
||||||
|
2665E9DAE13C13239857B4BB /* MMSnapshotContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA79F8A259CA3056F9B131A9 /* MMSnapshotContractTests.swift */; };
|
||||||
2C0F7AB93691ED8AA86FBCF0 /* MMProcessDetailInspector.m in Sources */ = {isa = PBXBuildFile; fileRef = 52E641F8A664D456518EDBA4 /* MMProcessDetailInspector.m */; };
|
2C0F7AB93691ED8AA86FBCF0 /* MMProcessDetailInspector.m in Sources */ = {isa = PBXBuildFile; fileRef = 52E641F8A664D456518EDBA4 /* MMProcessDetailInspector.m */; };
|
||||||
2E966D96347D1DDB24E18632 /* AlertModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B657679DB7D1F39AB4911B7B /* AlertModels.swift */; };
|
2E966D96347D1DDB24E18632 /* AlertModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B657679DB7D1F39AB4911B7B /* AlertModels.swift */; };
|
||||||
3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */; };
|
3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */; };
|
||||||
@@ -167,6 +168,7 @@
|
|||||||
E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTelemetryStore.swift; sourceTree = "<group>"; };
|
E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTelemetryStore.swift; sourceTree = "<group>"; };
|
||||||
E6D9BB71DFE7384D0F0E6AA3 /* MMKernelTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMKernelTelemetryProvider.h; sourceTree = "<group>"; };
|
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>"; };
|
E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMDiskIOTests.swift; sourceTree = "<group>"; };
|
||||||
|
EA79F8A259CA3056F9B131A9 /* MMSnapshotContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMSnapshotContractTests.swift; sourceTree = "<group>"; };
|
||||||
F444F4543F0A7421C8F8E636 /* MMKernelTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMKernelTelemetryProvider.m; sourceTree = "<group>"; };
|
F444F4543F0A7421C8F8E636 /* MMKernelTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMKernelTelemetryProvider.m; sourceTree = "<group>"; };
|
||||||
F5EC59DCDA5FABDA409CDBCB /* MacMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
F5EC59DCDA5FABDA409CDBCB /* MacMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
F698B0CC35A89EDEA03F08A5 /* MMDisplayManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMDisplayManager.m; sourceTree = "<group>"; };
|
F698B0CC35A89EDEA03F08A5 /* MMDisplayManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMDisplayManager.m; sourceTree = "<group>"; };
|
||||||
@@ -438,6 +440,7 @@
|
|||||||
7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */,
|
7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */,
|
||||||
52471E871E5CC1E444318070 /* MMProcessTests.swift */,
|
52471E871E5CC1E444318070 /* MMProcessTests.swift */,
|
||||||
940D608A1AF80F75584E744E /* MMSMCTests.swift */,
|
940D608A1AF80F75584E744E /* MMSMCTests.swift */,
|
||||||
|
EA79F8A259CA3056F9B131A9 /* MMSnapshotContractTests.swift */,
|
||||||
78325870C7CD8312AECA2236 /* MMStorageTests.swift */,
|
78325870C7CD8312AECA2236 /* MMStorageTests.swift */,
|
||||||
);
|
);
|
||||||
path = Tests;
|
path = Tests;
|
||||||
@@ -618,6 +621,7 @@
|
|||||||
3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */,
|
3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */,
|
||||||
7C9AE2ADAED52E593B52F3AE /* MMProcessTests.swift in Sources */,
|
7C9AE2ADAED52E593B52F3AE /* MMProcessTests.swift in Sources */,
|
||||||
EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */,
|
EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */,
|
||||||
|
2665E9DAE13C13239857B4BB /* MMSnapshotContractTests.swift in Sources */,
|
||||||
1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */,
|
1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */,
|
||||||
9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */,
|
9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
@@ -694,7 +698,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitor;
|
PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitor;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h";
|
||||||
@@ -716,7 +720,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
"@loader_path/../Frameworks",
|
"@loader_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitorTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitorTests;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h";
|
||||||
@@ -761,7 +765,7 @@
|
|||||||
CODE_SIGNING_ALLOWED = NO;
|
CODE_SIGNING_ALLOWED = NO;
|
||||||
CODE_SIGN_IDENTITY = "";
|
CODE_SIGN_IDENTITY = "";
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
@@ -774,8 +778,8 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||||
MARKETING_VERSION = 0.0.1;
|
MARKETING_VERSION = 0.0.2;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = NO;
|
ONLY_ACTIVE_ARCH = NO;
|
||||||
@@ -803,7 +807,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
"@loader_path/../Frameworks",
|
"@loader_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitorTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitorTests;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h";
|
||||||
@@ -848,7 +852,7 @@
|
|||||||
CODE_SIGNING_ALLOWED = NO;
|
CODE_SIGNING_ALLOWED = NO;
|
||||||
CODE_SIGN_IDENTITY = "";
|
CODE_SIGN_IDENTITY = "";
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
@@ -867,8 +871,8 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||||
MARKETING_VERSION = 0.0.1;
|
MARKETING_VERSION = 0.0.2;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = NO;
|
ONLY_ACTIVE_ARCH = NO;
|
||||||
@@ -898,7 +902,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitor;
|
PRODUCT_BUNDLE_IDENTIFIER = com.i3omb.MacMonitor;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Sources/Bridging/MacMonitor-Bridging-Header.h";
|
||||||
|
|||||||
+108
-13
@@ -1,19 +1,67 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
/// Bridges app-lifecycle events into SwiftUI: requests Notification Center
|
/// Bridges app-lifecycle events into SwiftUI: requests Notification Center
|
||||||
/// authorization once at launch so threshold alerts can post banners.
|
/// authorization once at launch so threshold alerts can post banners.
|
||||||
|
@MainActor
|
||||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
var statusItem: NSStatusItem?
|
||||||
|
var popover: NSPopover?
|
||||||
|
var cancellable: AnyCancellable?
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
if AlertEngine.shared.configuration.alertsEnabled {
|
if AlertEngine.shared.configuration.alertsEnabled {
|
||||||
AlertEngine.shared.requestNotificationAuthorization()
|
AlertEngine.shared.requestNotificationAuthorization()
|
||||||
}
|
}
|
||||||
|
setupMenuBar()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupMenuBar() {
|
||||||
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
|
|
||||||
|
let popover = NSPopover()
|
||||||
|
popover.contentSize = NSSize(width: 320, height: 400)
|
||||||
|
popover.behavior = .transient
|
||||||
|
popover.contentViewController = NSHostingController(rootView: QuickGlancePopoverView(store: SystemTelemetryStore.shared))
|
||||||
|
self.popover = popover
|
||||||
|
|
||||||
|
if let button = statusItem?.button {
|
||||||
|
button.action = #selector(togglePopover(_:))
|
||||||
|
button.target = self
|
||||||
|
}
|
||||||
|
|
||||||
|
cancellable = SystemTelemetryStore.shared.$latestSnapshot.sink { [weak self] _ in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.updateMenuBarLabel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateMenuBarLabel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func togglePopover(_ sender: AnyObject?) {
|
||||||
|
guard let button = statusItem?.button, let popover = popover else { return }
|
||||||
|
if popover.isShown {
|
||||||
|
popover.performClose(sender)
|
||||||
|
} else {
|
||||||
|
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateMenuBarLabel() {
|
||||||
|
guard let button = statusItem?.button else { return }
|
||||||
|
let cpu = SystemTelemetryStore.shared.cpuLoad.totalLoad
|
||||||
|
let mem = SystemTelemetryStore.shared.memory.utilizationPercentage
|
||||||
|
let alerts = AlertEngine.shared.activeAlerts.count
|
||||||
|
|
||||||
|
let alertStr = alerts > 0 ? "⚠️ " : ""
|
||||||
|
button.title = String(format: "%@CPU: %.0f%% • RAM: %.0f%%", alertStr, cpu, mem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct MacMonitorApp: App {
|
struct MacMonitorApp: App {
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
@State private var store = SystemTelemetryStore.shared
|
@StateObject private var store = SystemTelemetryStore.shared
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
@@ -21,21 +69,68 @@ struct MacMonitorApp: App {
|
|||||||
}
|
}
|
||||||
.windowStyle(.titleBar)
|
.windowStyle(.titleBar)
|
||||||
.windowToolbarStyle(.unified)
|
.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 {
|
Settings {
|
||||||
AlertsSettingsView()
|
AlertsSettingsView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - UI Shims (macOS 12 Support)
|
||||||
|
|
||||||
|
public struct MMLabeledContent<Label: View, Content: View>: View {
|
||||||
|
public let label: Label
|
||||||
|
public let content: Content
|
||||||
|
|
||||||
|
public init(@ViewBuilder content: () -> Content, @ViewBuilder label: () -> Label) {
|
||||||
|
self.label = label()
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ titleKey: LocalizedStringKey, @ViewBuilder content: () -> Content) where Label == Text {
|
||||||
|
self.label = Text(titleKey)
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init<S: StringProtocol>(_ title: S, @ViewBuilder content: () -> Content) where Label == Text {
|
||||||
|
self.label = Text(title)
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack {
|
||||||
|
label
|
||||||
|
Spacer()
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension View {
|
||||||
|
@ViewBuilder
|
||||||
|
func mmMonospaced() -> some View {
|
||||||
|
if #available(macOS 13.0, *) {
|
||||||
|
self.monospaced()
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func mmMonospacedDigit() -> some View {
|
||||||
|
if #available(macOS 13.0, *) {
|
||||||
|
self.monospacedDigit()
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func mmFormStyleGrouped() -> some View {
|
||||||
|
if #available(macOS 13.0, *) {
|
||||||
|
self.formStyle(.grouped)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Observation
|
import Combine
|
||||||
|
|
||||||
// MARK: - Typed Telemetry Models
|
// MARK: - Typed Telemetry Models
|
||||||
|
|
||||||
@@ -220,39 +220,38 @@ public struct AudioDeviceItem: Identifiable, Sendable {
|
|||||||
|
|
||||||
// MARK: - SystemTelemetryStore
|
// MARK: - SystemTelemetryStore
|
||||||
|
|
||||||
@Observable
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class SystemTelemetryStore {
|
public final class SystemTelemetryStore: ObservableObject {
|
||||||
public static let shared = SystemTelemetryStore()
|
public static let shared = SystemTelemetryStore()
|
||||||
|
|
||||||
public private(set) var isRunning: Bool = false
|
@Published public private(set) var isRunning: Bool = false
|
||||||
public private(set) var lastUpdateTimestamp: Date = .now
|
@Published public private(set) var lastUpdateTimestamp: Date = .now
|
||||||
public private(set) var sampleInterval: TimeInterval = 1.0
|
@Published public private(set) var sampleInterval: TimeInterval = 1.0
|
||||||
|
|
||||||
/// Raw snapshots organized by provider identifier
|
/// Raw snapshots organized by provider identifier
|
||||||
public private(set) var latestSnapshot: [String: [String: Any]] = [:]
|
@Published public private(set) var latestSnapshot: [String: [String: Any]] = [:]
|
||||||
|
|
||||||
/// Milestone 2 Structured Metrics
|
/// Milestone 2 Structured Metrics
|
||||||
public private(set) var cpuLoad = CPULoadMetrics()
|
@Published public private(set) var cpuLoad = CPULoadMetrics()
|
||||||
public private(set) var memory = MemoryMetrics()
|
@Published public private(set) var memory = MemoryMetrics()
|
||||||
public private(set) var storageVolumes: [StorageVolumeItem] = []
|
@Published public private(set) var storageVolumes: [StorageVolumeItem] = []
|
||||||
public private(set) var cpuThermal = CPUThermalMetrics()
|
@Published public private(set) var cpuThermal = CPUThermalMetrics()
|
||||||
public private(set) var fans: [FanTelemetryItem] = []
|
@Published public private(set) var fans: [FanTelemetryItem] = []
|
||||||
public private(set) var componentTemps: [ComponentThermalItem] = []
|
@Published public private(set) var componentTemps: [ComponentThermalItem] = []
|
||||||
public private(set) var power = PowerMetrics()
|
@Published public private(set) var power = PowerMetrics()
|
||||||
|
|
||||||
/// Milestone 3 Structured Metrics
|
/// Milestone 3 Structured Metrics
|
||||||
public private(set) var kernelCounters = KernelCountersMetrics()
|
@Published public private(set) var kernelCounters = KernelCountersMetrics()
|
||||||
public private(set) var systemLoad = SystemLoadMetrics()
|
@Published public private(set) var systemLoad = SystemLoadMetrics()
|
||||||
public private(set) var diskIO: [DiskIOItem] = []
|
@Published public private(set) var diskIO: [DiskIOItem] = []
|
||||||
public private(set) var networkBandwidth: [NetworkBandwidthItem] = []
|
@Published public private(set) var networkBandwidth: [NetworkBandwidthItem] = []
|
||||||
public private(set) var networkSockets: [SocketItem] = []
|
@Published public private(set) var networkSockets: [SocketItem] = []
|
||||||
public private(set) var processes: [ProcessItem] = []
|
@Published public private(set) var processes: [ProcessItem] = []
|
||||||
public private(set) var gpus: [GPUCardItem] = []
|
@Published public private(set) var gpus: [GPUCardItem] = []
|
||||||
public private(set) var batteryHealth = BatteryHealthMetrics()
|
@Published public private(set) var batteryHealth = BatteryHealthMetrics()
|
||||||
public private(set) var peripherals: [PeripheralDeviceItem] = []
|
@Published public private(set) var peripherals: [PeripheralDeviceItem] = []
|
||||||
public private(set) var audioDevices: [AudioDeviceItem] = []
|
@Published public private(set) var audioDevices: [AudioDeviceItem] = []
|
||||||
public private(set) var displays: [MMDisplayInfo] = []
|
@Published public private(set) var displays: [MMDisplayInfo] = []
|
||||||
|
|
||||||
/// Historical Telemetry Store
|
/// Historical Telemetry Store
|
||||||
public let historyStore = TelemetryHistoryStore.shared
|
public let historyStore = TelemetryHistoryStore.shared
|
||||||
@@ -319,18 +318,18 @@ public final class SystemTelemetryStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func decodeSnapshot(_ snapshot: [String: [String: Any]]) {
|
func decodeSnapshot(_ snapshot: [String: [String: Any]]) {
|
||||||
// 1. CPU Load
|
// 1. CPU Load
|
||||||
if let cpuDict = snapshot["com.i3omb.macmonitor.telemetry.cpu"] {
|
if let cpuDict = snapshot["com.i3omb.macmonitor.telemetry.cpuload"] {
|
||||||
var m = CPULoadMetrics()
|
var m = CPULoadMetrics()
|
||||||
m.systemLoad = (cpuDict["systemLoad"] as? NSNumber)?.doubleValue ?? 0
|
m.systemLoad = (cpuDict["systemPercent"] as? NSNumber)?.doubleValue ?? 0
|
||||||
m.userLoad = (cpuDict["userLoad"] as? NSNumber)?.doubleValue ?? 0
|
m.userLoad = (cpuDict["userPercent"] as? NSNumber)?.doubleValue ?? 0
|
||||||
m.idleLoad = (cpuDict["idleLoad"] as? NSNumber)?.doubleValue ?? 100
|
m.idleLoad = (cpuDict["idlePercent"] as? NSNumber)?.doubleValue ?? 100
|
||||||
m.totalLoad = (cpuDict["totalLoad"] as? NSNumber)?.doubleValue ?? 0
|
m.totalLoad = (cpuDict["totalPercent"] as? NSNumber)?.doubleValue ?? 0
|
||||||
if let perCore = cpuDict["perCoreLoad"] as? [NSNumber] {
|
if let cores = cpuDict["cores"] as? [[String: Any]] {
|
||||||
m.perCoreLoad = perCore.map { $0.doubleValue }
|
m.perCoreLoad = cores.map { ($0["totalPercent"] as? NSNumber)?.doubleValue ?? 0 }
|
||||||
}
|
}
|
||||||
m.cpuFrequencyMHz = (cpuDict["cpuFrequencyMHz"] as? NSNumber)?.doubleValue ?? 0
|
m.cpuFrequencyMHz = ((cpuDict["frequencyHz"] as? NSNumber)?.doubleValue ?? 0) / 1e6
|
||||||
m.isThrottled = (cpuDict["isThrottled"] as? NSNumber)?.boolValue ?? false
|
m.isThrottled = (cpuDict["isThrottled"] as? NSNumber)?.boolValue ?? false
|
||||||
self.cpuLoad = m
|
self.cpuLoad = m
|
||||||
}
|
}
|
||||||
@@ -347,7 +346,8 @@ public final class SystemTelemetryStore {
|
|||||||
m.compressedBytes = (memDict["compressedBytes"] as? NSNumber)?.uint64Value ?? 0
|
m.compressedBytes = (memDict["compressedBytes"] as? NSNumber)?.uint64Value ?? 0
|
||||||
m.swapTotalBytes = (memDict["swapTotalBytes"] as? NSNumber)?.uint64Value ?? 0
|
m.swapTotalBytes = (memDict["swapTotalBytes"] as? NSNumber)?.uint64Value ?? 0
|
||||||
m.swapUsedBytes = (memDict["swapUsedBytes"] as? NSNumber)?.uint64Value ?? 0
|
m.swapUsedBytes = (memDict["swapUsedBytes"] as? NSNumber)?.uint64Value ?? 0
|
||||||
m.utilizationPercentage = (memDict["utilizationPercentage"] as? NSNumber)?.doubleValue ?? 0
|
m.utilizationPercentage = m.totalBytes > 0
|
||||||
|
? Double(m.usedBytes) / Double(m.totalBytes) * 100.0 : 0
|
||||||
m.pressureLevel = memDict["memoryPressureStatus"] as? String
|
m.pressureLevel = memDict["memoryPressureStatus"] as? String
|
||||||
?? memDict["pressureLevel"] as? String ?? "Normal"
|
?? memDict["pressureLevel"] as? String ?? "Normal"
|
||||||
self.memory = m
|
self.memory = m
|
||||||
@@ -361,11 +361,11 @@ public final class SystemTelemetryStore {
|
|||||||
return StorageVolumeItem(
|
return StorageVolumeItem(
|
||||||
mountPoint: mount,
|
mountPoint: mount,
|
||||||
volumeName: dict["volumeName"] as? String ?? mount,
|
volumeName: dict["volumeName"] as? String ?? mount,
|
||||||
fileSystem: dict["fileSystem"] as? String ?? "APFS",
|
fileSystem: dict["fsType"] as? String ?? "APFS",
|
||||||
totalBytes: (dict["totalBytes"] as? NSNumber)?.uint64Value ?? 0,
|
totalBytes: (dict["totalBytes"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
freeBytes: (dict["freeBytes"] as? NSNumber)?.uint64Value ?? 0,
|
freeBytes: (dict["freeBytes"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
usedBytes: (dict["usedBytes"] as? NSNumber)?.uint64Value ?? 0,
|
usedBytes: (dict["usedBytes"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
usedPercentage: (dict["usedPercentage"] as? NSNumber)?.doubleValue ?? 0,
|
usedPercentage: (dict["usedPercent"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
isReadOnly: (dict["isReadOnly"] as? NSNumber)?.boolValue ?? false
|
isReadOnly: (dict["isReadOnly"] as? NSNumber)?.boolValue ?? false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -394,7 +394,7 @@ public final class SystemTelemetryStore {
|
|||||||
if let fanDict = snapshot["com.i3omb.macmonitor.telemetry.fan"],
|
if let fanDict = snapshot["com.i3omb.macmonitor.telemetry.fan"],
|
||||||
let fanArray = fanDict["fans"] as? [[String: Any]] {
|
let fanArray = fanDict["fans"] as? [[String: Any]] {
|
||||||
self.fans = fanArray.compactMap { d in
|
self.fans = fanArray.compactMap { d in
|
||||||
let idx = (d["index"] as? NSNumber)?.intValue ?? 0
|
let idx = (d["fanIndex"] as? NSNumber)?.intValue ?? 0
|
||||||
return FanTelemetryItem(
|
return FanTelemetryItem(
|
||||||
index: idx,
|
index: idx,
|
||||||
name: d["name"] as? String ?? "Fan \(idx + 1)",
|
name: d["name"] as? String ?? "Fan \(idx + 1)",
|
||||||
@@ -402,7 +402,7 @@ public final class SystemTelemetryStore {
|
|||||||
minRPM: (d["minRPM"] as? NSNumber)?.doubleValue ?? 0,
|
minRPM: (d["minRPM"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
maxRPM: (d["maxRPM"] as? NSNumber)?.doubleValue ?? 0,
|
maxRPM: (d["maxRPM"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
targetRPM: (d["targetRPM"] as? NSNumber)?.doubleValue ?? 0,
|
targetRPM: (d["targetRPM"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
utilization: (d["utilization"] as? NSNumber)?.doubleValue ?? 0
|
utilization: (d["utilizationPercent"] as? NSNumber)?.doubleValue ?? 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -446,27 +446,27 @@ public final class SystemTelemetryStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8. Kernel Counters
|
// 8. Kernel Counters
|
||||||
if let kDict = snapshot["com.i3omb.macmonitor.telemetry.kernel"] {
|
if let kDict = snapshot["com.i3omb.macmonitor.telemetry.kernel.counters"] {
|
||||||
var m = KernelCountersMetrics()
|
var m = KernelCountersMetrics()
|
||||||
m.contextSwitchesPerSec = (kDict["contextSwitchesPerSec"] as? NSNumber)?.doubleValue ?? 0
|
m.contextSwitchesPerSec = (kDict["contextSwitchesRate"] as? NSNumber)?.doubleValue ?? 0
|
||||||
m.syscallsPerSec = (kDict["syscallsPerSec"] as? NSNumber)?.doubleValue ?? 0
|
m.syscallsPerSec = (kDict["syscallsRate"] as? NSNumber)?.doubleValue ?? 0
|
||||||
m.pageFaultsPerSec = (kDict["pageFaultsPerSec"] as? NSNumber)?.doubleValue ?? 0
|
m.pageFaultsPerSec = (kDict["pageFaultsRate"] as? NSNumber)?.doubleValue ?? 0
|
||||||
m.cowFaultsPerSec = (kDict["cowFaultsPerSec"] as? NSNumber)?.doubleValue ?? 0
|
m.cowFaultsPerSec = (kDict["cowFaultsRate"] as? NSNumber)?.doubleValue ?? 0
|
||||||
m.zeroFillFaultsPerSec = (kDict["zeroFillFaultsPerSec"] as? NSNumber)?.doubleValue ?? 0
|
m.zeroFillFaultsPerSec = (kDict["zeroFillsRate"] as? NSNumber)?.doubleValue ?? 0
|
||||||
m.pageinsPerSec = (kDict["pageinsPerSec"] as? NSNumber)?.doubleValue ?? 0
|
m.pageinsPerSec = (kDict["pageinsRate"] as? NSNumber)?.doubleValue ?? 0
|
||||||
m.pageoutsPerSec = (kDict["pageoutsPerSec"] as? NSNumber)?.doubleValue ?? 0
|
m.pageoutsPerSec = (kDict["pageoutsRate"] as? NSNumber)?.doubleValue ?? 0
|
||||||
m.totalContextSwitches = (kDict["totalContextSwitches"] as? NSNumber)?.uint64Value ?? 0
|
m.totalContextSwitches = (kDict["cumulative_contextSwitches"] as? NSNumber)?.uint64Value ?? 0
|
||||||
m.totalSyscalls = (kDict["totalSyscalls"] as? NSNumber)?.uint64Value ?? 0
|
m.totalSyscalls = (kDict["cumulative_syscalls"] as? NSNumber)?.uint64Value ?? 0
|
||||||
m.totalPageFaults = (kDict["totalPageFaults"] as? NSNumber)?.uint64Value ?? 0
|
m.totalPageFaults = (kDict["cumulative_pageFaults"] as? NSNumber)?.uint64Value ?? 0
|
||||||
self.kernelCounters = m
|
self.kernelCounters = m
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. System Load Average & Mach Factor
|
// 9. System Load Average & Mach Factor
|
||||||
if let loadDict = snapshot["com.i3omb.macmonitor.telemetry.systemload"] {
|
if let loadDict = snapshot["com.i3omb.macmonitor.telemetry.system.load"] {
|
||||||
var m = SystemLoadMetrics()
|
var m = SystemLoadMetrics()
|
||||||
m.load1Min = (loadDict["load1Min"] as? NSNumber)?.doubleValue ?? 0
|
m.load1Min = (loadDict["load1m"] as? NSNumber)?.doubleValue ?? 0
|
||||||
m.load5Min = (loadDict["load5Min"] as? NSNumber)?.doubleValue ?? 0
|
m.load5Min = (loadDict["load5m"] as? NSNumber)?.doubleValue ?? 0
|
||||||
m.load15Min = (loadDict["load15Min"] as? NSNumber)?.doubleValue ?? 0
|
m.load15Min = (loadDict["load15m"] as? NSNumber)?.doubleValue ?? 0
|
||||||
m.taskCount = (loadDict["taskCount"] as? NSNumber)?.intValue ?? 0
|
m.taskCount = (loadDict["taskCount"] as? NSNumber)?.intValue ?? 0
|
||||||
m.threadCount = (loadDict["threadCount"] as? NSNumber)?.intValue ?? 0
|
m.threadCount = (loadDict["threadCount"] as? NSNumber)?.intValue ?? 0
|
||||||
m.machFactor = (loadDict["machFactor"] as? NSNumber)?.doubleValue ?? 0
|
m.machFactor = (loadDict["machFactor"] as? NSNumber)?.doubleValue ?? 0
|
||||||
@@ -474,18 +474,18 @@ public final class SystemTelemetryStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 10. Disk I/O & IOPS
|
// 10. Disk I/O & IOPS
|
||||||
if let diskDict = snapshot["com.i3omb.macmonitor.telemetry.diskio"],
|
if let diskDict = snapshot["com.i3omb.macmonitor.telemetry.storage.io"],
|
||||||
let disks = diskDict["disks"] as? [[String: Any]] {
|
let disks = diskDict["disks"] as? [[String: Any]] {
|
||||||
self.diskIO = disks.compactMap { d in
|
self.diskIO = disks.compactMap { d in
|
||||||
guard let bsd = d["bsdName"] as? String else { return nil }
|
guard let bsd = d["bsdName"] as? String else { return nil }
|
||||||
return DiskIOItem(
|
return DiskIOItem(
|
||||||
bsdName: bsd,
|
bsdName: bsd,
|
||||||
readBps: (d["readBps"] as? NSNumber)?.doubleValue ?? 0,
|
readBps: (d["readBytesPerSec"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
writeBps: (d["writeBps"] as? NSNumber)?.doubleValue ?? 0,
|
writeBps: (d["writeBytesPerSec"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
readIOPS: (d["readIOPS"] as? NSNumber)?.doubleValue ?? 0,
|
readIOPS: (d["readIOPS"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
writeIOPS: (d["writeIOPS"] as? NSNumber)?.doubleValue ?? 0,
|
writeIOPS: (d["writeIOPS"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
totalBytesRead: (d["totalBytesRead"] as? NSNumber)?.uint64Value ?? 0,
|
totalBytesRead: (d["cumulativeReadBytes"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
totalBytesWritten: (d["totalBytesWritten"] as? NSNumber)?.uint64Value ?? 0
|
totalBytesWritten: (d["cumulativeWriteBytes"] as? NSNumber)?.uint64Value ?? 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -494,17 +494,17 @@ public final class SystemTelemetryStore {
|
|||||||
if let netDict = snapshot["com.i3omb.macmonitor.telemetry.network.bandwidth"],
|
if let netDict = snapshot["com.i3omb.macmonitor.telemetry.network.bandwidth"],
|
||||||
let ifaces = netDict["interfaces"] as? [[String: Any]] {
|
let ifaces = netDict["interfaces"] as? [[String: Any]] {
|
||||||
self.networkBandwidth = ifaces.compactMap { d in
|
self.networkBandwidth = ifaces.compactMap { d in
|
||||||
guard let name = d["interfaceName"] as? String else { return nil }
|
guard let name = d["name"] as? String else { return nil }
|
||||||
return NetworkBandwidthItem(
|
return NetworkBandwidthItem(
|
||||||
interfaceName: name,
|
interfaceName: name,
|
||||||
ipAddress: d["ipAddress"] as? String ?? "",
|
ipAddress: d["ipv4Address"] as? String ?? "",
|
||||||
isUp: (d["isUp"] as? NSNumber)?.boolValue ?? false,
|
isUp: (d["isUp"] as? NSNumber)?.boolValue ?? false,
|
||||||
downloadBps: (d["downloadBps"] as? NSNumber)?.doubleValue ?? 0,
|
downloadBps: (d["downloadBytesPerSec"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
uploadBps: (d["uploadBps"] as? NSNumber)?.doubleValue ?? 0,
|
uploadBps: (d["uploadBytesPerSec"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
downloadPps: (d["downloadPps"] as? NSNumber)?.doubleValue ?? 0,
|
downloadPps: (d["downloadPacketsPerSec"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
uploadPps: (d["uploadPps"] as? NSNumber)?.doubleValue ?? 0,
|
uploadPps: (d["uploadPacketsPerSec"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
totalBytesIn: (d["totalBytesIn"] as? NSNumber)?.uint64Value ?? 0,
|
totalBytesIn: (d["cumulativeInBytes"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
totalBytesOut: (d["totalBytesOut"] as? NSNumber)?.uint64Value ?? 0
|
totalBytesOut: (d["cumulativeOutBytes"] as? NSNumber)?.uint64Value ?? 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -539,8 +539,8 @@ public final class SystemTelemetryStore {
|
|||||||
ppid: (d["ppid"] as? NSNumber)?.int32Value ?? 0,
|
ppid: (d["ppid"] as? NSNumber)?.int32Value ?? 0,
|
||||||
name: d["name"] as? String ?? "Unknown",
|
name: d["name"] as? String ?? "Unknown",
|
||||||
cpuPercent: (d["cpuPercent"] as? NSNumber)?.doubleValue ?? 0,
|
cpuPercent: (d["cpuPercent"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
residentSize: (d["residentSize"] as? NSNumber)?.uint64Value ?? 0,
|
residentSize: (d["residentBytes"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
virtualSize: (d["virtualSize"] as? NSNumber)?.uint64Value ?? 0,
|
virtualSize: (d["virtualBytes"] as? NSNumber)?.uint64Value ?? 0,
|
||||||
threadCount: (d["threadCount"] as? NSNumber)?.int32Value ?? 1,
|
threadCount: (d["threadCount"] as? NSNumber)?.int32Value ?? 1,
|
||||||
uid: (d["uid"] as? NSNumber)?.uint32Value ?? 0,
|
uid: (d["uid"] as? NSNumber)?.uint32Value ?? 0,
|
||||||
username: d["username"] as? String ?? "",
|
username: d["username"] as? String ?? "",
|
||||||
@@ -701,10 +701,14 @@ public final class SystemTelemetryStore {
|
|||||||
freeBytes: $0.freeBytes,
|
freeBytes: $0.freeBytes,
|
||||||
freePercent: $0.totalBytes > 0
|
freePercent: $0.totalBytes > 0
|
||||||
? Double($0.freeBytes) / Double($0.totalBytes) * 100.0
|
? Double($0.freeBytes) / Double($0.totalBytes) * 100.0
|
||||||
: 0
|
: 0,
|
||||||
|
totalBytes: $0.totalBytes,
|
||||||
|
isReadOnly: $0.isReadOnly
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
context.batteryLevel = self.power.batteryLevel
|
context.batteryLevel = self.batteryHealth.maxCapacity > 0
|
||||||
|
? Double(self.batteryHealth.currentCapacity) / Double(self.batteryHealth.maxCapacity) * 100.0
|
||||||
|
: self.power.batteryLevel
|
||||||
context.hasBattery = self.batteryHealth.hasBattery
|
context.hasBattery = self.batteryHealth.hasBattery
|
||||||
context.onExternalPower = self.batteryHealth.externalConnected
|
context.onExternalPower = self.batteryHealth.externalConnected
|
||||||
context.isCharging = self.batteryHealth.isCharging || self.power.isCharging
|
context.isCharging = self.batteryHealth.isCharging || self.power.isCharging
|
||||||
|
|||||||
@@ -57,13 +57,13 @@
|
|||||||
return YES;
|
return YES;
|
||||||
}
|
}
|
||||||
|
|
||||||
CFMutableDictionaryRef matching = IOServiceMatching("AppleSMCClient");
|
CFMutableDictionaryRef matching = IOServiceMatching("AppleSMC");
|
||||||
if (!matching) {
|
if (!matching) {
|
||||||
os_unfair_lock_unlock(&_lock);
|
os_unfair_lock_unlock(&_lock);
|
||||||
if (error) {
|
if (error) {
|
||||||
*error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC"
|
*error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC"
|
||||||
code:-1
|
code:-1
|
||||||
userInfo:@{NSLocalizedDescriptionKey: @"Failed to create AppleSMCClient matching dictionary"}];
|
userInfo:@{NSLocalizedDescriptionKey: @"Failed to create AppleSMC matching dictionary"}];
|
||||||
}
|
}
|
||||||
return NO;
|
return NO;
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
if (error) {
|
if (error) {
|
||||||
*error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC"
|
*error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC"
|
||||||
code:-2
|
code:-2
|
||||||
userInfo:@{NSLocalizedDescriptionKey: @"AppleSMCClient service not found (running on non-Mac or unsupported VM)"}];
|
userInfo:@{NSLocalizedDescriptionKey: @"AppleSMC service not found (running on non-Mac or unsupported VM)"}];
|
||||||
}
|
}
|
||||||
return NO;
|
return NO;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@
|
|||||||
#define kSMCGetKeyFromIndex 8
|
#define kSMCGetKeyFromIndex 8
|
||||||
#define kSMCGetKeyInfo 9
|
#define kSMCGetKeyInfo 9
|
||||||
|
|
||||||
#pragma pack(push, 1)
|
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
unsigned char major;
|
unsigned char major;
|
||||||
unsigned char minor;
|
unsigned char minor;
|
||||||
@@ -58,8 +56,6 @@ typedef struct {
|
|||||||
UInt8 bytes[32];
|
UInt8 bytes[32];
|
||||||
} SMCVal_t;
|
} SMCVal_t;
|
||||||
|
|
||||||
#pragma pack(pop)
|
|
||||||
|
|
||||||
static inline UInt32 MMSMCToFourCharCode(const char * _Nonnull str) {
|
static inline UInt32 MMSMCToFourCharCode(const char * _Nonnull str) {
|
||||||
UInt32 code = 0;
|
UInt32 code = 0;
|
||||||
for (int i = 0; i < 4 && str[i] != '\0'; i++) {
|
for (int i = 0; i < 4 && str[i] != '\0'; i++) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
public struct TelemetrySnapshotRecord {
|
public struct TelemetrySnapshotRecord {
|
||||||
public let cpuLoad: Double
|
public let cpuLoad: Double
|
||||||
@@ -35,8 +36,7 @@ public struct TelemetrySnapshotRecord {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Observable
|
public final class TelemetryHistoryStore: ObservableObject {
|
||||||
public final class TelemetryHistoryStore {
|
|
||||||
public static let shared = TelemetryHistoryStore()
|
public static let shared = TelemetryHistoryStore()
|
||||||
|
|
||||||
public let cpuHistory = RollingRingBuffer<Double>(capacity: 60)
|
public let cpuHistory = RollingRingBuffer<Double>(capacity: 60)
|
||||||
@@ -48,7 +48,7 @@ public final class TelemetryHistoryStore {
|
|||||||
public let cpuTempHistory = RollingRingBuffer<Double>(capacity: 60)
|
public let cpuTempHistory = RollingRingBuffer<Double>(capacity: 60)
|
||||||
public let powerHistory = RollingRingBuffer<Double>(capacity: 60)
|
public let powerHistory = RollingRingBuffer<Double>(capacity: 60)
|
||||||
|
|
||||||
public private(set) var sampleCounter: UInt64 = 0
|
@Published public private(set) var sampleCounter: UInt64 = 0
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import Combine
|
||||||
|
|
||||||
// MARK: - Alert Engine
|
// MARK: - Alert Engine
|
||||||
|
|
||||||
@@ -13,18 +14,17 @@ import UserNotifications
|
|||||||
/// persistent -> re-fire only after `cooldownMinutes` elapse
|
/// persistent -> re-fire only after `cooldownMinutes` elapse
|
||||||
/// recovery -> remove from active set, log `.resolved`, clear cooldown so
|
/// recovery -> remove from active set, log `.resolved`, clear cooldown so
|
||||||
/// the next breach alerts immediately
|
/// the next breach alerts immediately
|
||||||
@Observable
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class AlertEngine {
|
public final class AlertEngine: ObservableObject {
|
||||||
public static let shared = AlertEngine()
|
public static let shared = AlertEngine()
|
||||||
|
|
||||||
/// Currently breached conditions, refreshed every evaluation pass.
|
/// Currently breached conditions, refreshed every evaluation pass.
|
||||||
public private(set) var activeAlerts: [SystemAlert] = []
|
@Published public private(set) var activeAlerts: [SystemAlert] = []
|
||||||
/// Newest-first audit log of trigger/resolve events (capped).
|
/// Newest-first audit log of trigger/resolve events (capped).
|
||||||
public private(set) var history: [AlertEvent] = []
|
@Published public private(set) var history: [AlertEvent] = []
|
||||||
|
|
||||||
/// Threshold settings. Mutations are persisted immediately.
|
/// Threshold settings. Mutations are persisted immediately.
|
||||||
public var configuration: AlertConfiguration {
|
@Published public var configuration: AlertConfiguration {
|
||||||
didSet { configuration.save(to: defaults) }
|
didSet { configuration.save(to: defaults) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +163,10 @@ public final class AlertEngine {
|
|||||||
|
|
||||||
// 4. Low storage space (per mounted volume)
|
// 4. Low storage space (per mounted volume)
|
||||||
if cfg.storageEnabled {
|
if cfg.storageEnabled {
|
||||||
for volume in ctx.volumes {
|
// Skip read-only mounts (DMGs, snapshots) and trivially small
|
||||||
|
// volumes where free-space thresholds are meaningless.
|
||||||
|
let minTotalBytes: UInt64 = 1_073_741_824 // 1 GB
|
||||||
|
for volume in ctx.volumes where !volume.isReadOnly && volume.totalBytes >= minTotalBytes {
|
||||||
let freeGB = Double(volume.freeBytes) / (1024 * 1024 * 1024)
|
let freeGB = Double(volume.freeBytes) / (1024 * 1024 * 1024)
|
||||||
if freeGB < cfg.storageFreeGBThreshold || volume.freePercent < cfg.storageFreePercentThreshold {
|
if freeGB < cfg.storageFreeGBThreshold || volume.freePercent < cfg.storageFreePercentThreshold {
|
||||||
alerts.append(SystemAlert(
|
alerts.append(SystemAlert(
|
||||||
|
|||||||
@@ -70,12 +70,18 @@ public struct StorageVolumeContext: Equatable, Sendable {
|
|||||||
public let volumeName: String
|
public let volumeName: String
|
||||||
public let freeBytes: UInt64
|
public let freeBytes: UInt64
|
||||||
public let freePercent: Double
|
public let freePercent: Double
|
||||||
|
public let totalBytes: UInt64
|
||||||
|
/// Read-only mounts (DMG images, snapshots) are excluded from alerting.
|
||||||
|
public let isReadOnly: Bool
|
||||||
|
|
||||||
public init(mountPoint: String, volumeName: String, freeBytes: UInt64, freePercent: Double) {
|
public init(mountPoint: String, volumeName: String, freeBytes: UInt64, freePercent: Double,
|
||||||
|
totalBytes: UInt64 = 0, isReadOnly: Bool = false) {
|
||||||
self.mountPoint = mountPoint
|
self.mountPoint = mountPoint
|
||||||
self.volumeName = volumeName
|
self.volumeName = volumeName
|
||||||
self.freeBytes = freeBytes
|
self.freeBytes = freeBytes
|
||||||
self.freePercent = freePercent
|
self.freePercent = freePercent
|
||||||
|
self.totalBytes = totalBytes
|
||||||
|
self.isReadOnly = isReadOnly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import Combine
|
||||||
|
|
||||||
// MARK: - Navigation Bus
|
// MARK: - Navigation Bus
|
||||||
|
|
||||||
/// Lightweight bus letting notification action handlers steer the main window:
|
/// Lightweight bus letting notification action handlers steer the main window:
|
||||||
/// tapping a notification (or its "View Processes" action) activates the app and
|
/// tapping a notification (or its "View Processes" action) activates the app and
|
||||||
/// selects the requested sidebar tab inside `ContentView`.
|
/// selects the requested sidebar tab inside `ContentView`.
|
||||||
@Observable
|
public final class AppNavigationBus: ObservableObject {
|
||||||
public final class AppNavigationBus {
|
|
||||||
public static let shared = AppNavigationBus()
|
public static let shared = AppNavigationBus()
|
||||||
|
|
||||||
/// Sidebar tag the main window should switch to (e.g. `"Processes"`).
|
/// Sidebar tag the main window should switch to (e.g. `"Processes"`).
|
||||||
/// `nil` means "just bring the window forward".
|
/// `nil` means "just bring the window forward".
|
||||||
public private(set) var requestedTab: String?
|
@Published public private(set) var requestedTab: String?
|
||||||
/// Monotonic counter so repeated taps on the same tab still trigger `onChange`.
|
/// Monotonic counter so repeated taps on the same tab still trigger `onChange`.
|
||||||
public private(set) var requestCounter: Int = 0
|
@Published public private(set) var requestCounter: Int = 0
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
@@ -118,11 +118,12 @@ public final class UNAlertNotificationDispatcher: NSObject, AlertNotificationDis
|
|||||||
didReceive response: UNNotificationResponse,
|
didReceive response: UNNotificationResponse,
|
||||||
withCompletionHandler completionHandler: @escaping () -> Void
|
withCompletionHandler completionHandler: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
let targetTab: String? = switch response.actionIdentifier {
|
let targetTab: String?
|
||||||
|
switch response.actionIdentifier {
|
||||||
case AlertNotificationAction.viewProcessesIdentifier:
|
case AlertNotificationAction.viewProcessesIdentifier:
|
||||||
"Processes"
|
targetTab = "Processes"
|
||||||
default:
|
default:
|
||||||
nil
|
targetTab = nil
|
||||||
}
|
}
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
AppNavigationBus.shared.request(tab: targetTab)
|
AppNavigationBus.shared.request(tab: targetTab)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
/**
|
/**
|
||||||
* Static calculator method for deterministic unit testing.
|
* Static calculator method for deterministic unit testing.
|
||||||
*/
|
*/
|
||||||
+ (NSDictionary<NSString *, id> *)calculateFanMetricsFromFanList:(NSArray<NSDictionary<NSString *, NSNumber *> *> *)fanList;
|
+ (NSDictionary<NSString *, id> *)calculateFanMetricsFromFanList:(NSArray<NSDictionary<NSString *, id> *> *)fanList;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
return (_probedFanCount >= 0) ? _probedFanCount : 0;
|
return (_probedFanCount >= 0) ? _probedFanCount : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
+ (NSDictionary<NSString *, id> *)calculateFanMetricsFromFanList:(NSArray<NSDictionary<NSString *, NSNumber *> *> *)fanList {
|
+ (NSDictionary<NSString *, id> *)calculateFanMetricsFromFanList:(NSArray<NSDictionary<NSString *, id> *> *)fanList {
|
||||||
if (!fanList || fanList.count == 0) {
|
if (!fanList || fanList.count == 0) {
|
||||||
return @{
|
return @{
|
||||||
@"fanCount": @(0),
|
@"fanCount": @(0),
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
double peakRPM = 0.0;
|
double peakRPM = 0.0;
|
||||||
|
|
||||||
for (NSUInteger i = 0; i < fanList.count; i++) {
|
for (NSUInteger i = 0; i < fanList.count; i++) {
|
||||||
NSDictionary<NSString *, NSNumber *> *raw = fanList[i];
|
NSDictionary<NSString *, id> *raw = fanList[i];
|
||||||
double current = [raw[@"currentRPM"] doubleValue];
|
double current = [raw[@"currentRPM"] doubleValue];
|
||||||
double min = [raw[@"minRPM"] doubleValue];
|
double min = [raw[@"minRPM"] doubleValue];
|
||||||
double max = [raw[@"maxRPM"] doubleValue];
|
double max = [raw[@"maxRPM"] doubleValue];
|
||||||
@@ -96,6 +96,7 @@
|
|||||||
|
|
||||||
[resultList addObject:@{
|
[resultList addObject:@{
|
||||||
@"fanIndex": @(i),
|
@"fanIndex": @(i),
|
||||||
|
@"name": raw[@"name"] ?: [NSString stringWithFormat:@"Fan %lu", (unsigned long)(i + 1)],
|
||||||
@"currentRPM": @(current),
|
@"currentRPM": @(current),
|
||||||
@"minRPM": @(min),
|
@"minRPM": @(min),
|
||||||
@"maxRPM": @(max),
|
@"maxRPM": @(max),
|
||||||
@@ -120,20 +121,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
NSInteger count = [self fanCount];
|
NSInteger count = [self fanCount];
|
||||||
NSMutableArray<NSDictionary<NSString *, NSNumber *> *> *rawList = [NSMutableArray arrayWithCapacity:count];
|
NSMutableArray<NSDictionary<NSString *, id> *> *rawList = [NSMutableArray arrayWithCapacity:count];
|
||||||
|
|
||||||
for (NSInteger i = 0; i < count; i++) {
|
for (NSInteger i = 0; i < count; i++) {
|
||||||
NSString *acKey = [NSString stringWithFormat:@"F%ldAc", (long)i];
|
NSString *acKey = [NSString stringWithFormat:@"F%ldAc", (long)i];
|
||||||
NSString *mnKey = [NSString stringWithFormat:@"F%ldMn", (long)i];
|
NSString *mnKey = [NSString stringWithFormat:@"F%ldMn", (long)i];
|
||||||
NSString *mxKey = [NSString stringWithFormat:@"F%ldMx", (long)i];
|
NSString *mxKey = [NSString stringWithFormat:@"F%ldMx", (long)i];
|
||||||
NSString *tgKey = [NSString stringWithFormat:@"F%ldTg", (long)i];
|
NSString *tgKey = [NSString stringWithFormat:@"F%ldTg", (long)i];
|
||||||
|
NSString *idKey = [NSString stringWithFormat:@"F%ldID", (long)i];
|
||||||
|
|
||||||
NSNumber *ac = [_smcClient readNumericValueForKey:acKey error:nil] ?: @(0.0);
|
NSNumber *ac = [_smcClient readNumericValueForKey:acKey error:nil] ?: @(0.0);
|
||||||
NSNumber *mn = [_smcClient readNumericValueForKey:mnKey error:nil] ?: @(0.0);
|
NSNumber *mn = [_smcClient readNumericValueForKey:mnKey error:nil] ?: @(0.0);
|
||||||
NSNumber *mx = [_smcClient readNumericValueForKey:mxKey error:nil] ?: @(6000.0);
|
NSNumber *mx = [_smcClient readNumericValueForKey:mxKey error:nil] ?: @(6000.0);
|
||||||
NSNumber *tg = [_smcClient readNumericValueForKey:tgKey error:nil] ?: ac;
|
NSNumber *tg = [_smcClient readNumericValueForKey:tgKey error:nil] ?: ac;
|
||||||
|
|
||||||
|
NSString *fanName = [NSString stringWithFormat:@"Fan %ld", (long)(i + 1)];
|
||||||
|
NSData *idData = [_smcClient readBytesForKey:idKey error:nil];
|
||||||
|
if (idData.length > 0) {
|
||||||
|
NSString *raw = [[NSString alloc] initWithData:idData encoding:NSASCIIStringEncoding];
|
||||||
|
NSString *trimmed = [raw stringByTrimmingCharactersInSet:
|
||||||
|
[NSCharacterSet characterSetWithCharactersInString:@"\0 "]];
|
||||||
|
if (trimmed.length > 0) fanName = trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
[rawList addObject:@{
|
[rawList addObject:@{
|
||||||
|
@"name": fanName,
|
||||||
@"currentRPM": ac,
|
@"currentRPM": ac,
|
||||||
@"minRPM": mn,
|
@"minRPM": mn,
|
||||||
@"maxRPM": mx,
|
@"maxRPM": mx,
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
+ (NSDictionary<NSString *, id> *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)current
|
+ (NSDictionary<NSString *, id> *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)current
|
||||||
previousSnapshots:(nullable NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)previous
|
previousSnapshots:(nullable NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)previous
|
||||||
timeDelta:(NSTimeInterval)timeDelta
|
timeDelta:(NSTimeInterval)timeDelta
|
||||||
ipAddresses:(nullable NSDictionary<NSString *, NSString *> *)ipAddresses;
|
ipAddresses:(nullable NSDictionary<NSString *, NSString *> *)ipAddresses
|
||||||
|
interfaceUpFlags:(nullable NSDictionary<NSString *, NSNumber *> *)interfaceUpFlags;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
+ (NSDictionary<NSString *, id> *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)current
|
+ (NSDictionary<NSString *, id> *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)current
|
||||||
previousSnapshots:(nullable NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)previous
|
previousSnapshots:(nullable NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)previous
|
||||||
timeDelta:(NSTimeInterval)timeDelta
|
timeDelta:(NSTimeInterval)timeDelta
|
||||||
ipAddresses:(nullable NSDictionary<NSString *, NSString *> *)ipAddresses {
|
ipAddresses:(nullable NSDictionary<NSString *, NSString *> *)ipAddresses
|
||||||
|
interfaceUpFlags:(nullable NSDictionary<NSString *, NSNumber *> *)interfaceUpFlags {
|
||||||
double totalDownBps = 0.0;
|
double totalDownBps = 0.0;
|
||||||
double totalUpBps = 0.0;
|
double totalUpBps = 0.0;
|
||||||
double totalDownPps = 0.0;
|
double totalDownPps = 0.0;
|
||||||
@@ -99,9 +100,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
NSString *ip = ipAddresses[name] ?: @"";
|
NSString *ip = ipAddresses[name] ?: @"";
|
||||||
|
BOOL isUp = interfaceUpFlags[name] ? [interfaceUpFlags[name] boolValue] : NO;
|
||||||
|
|
||||||
[interfaces addObject:@{
|
[interfaces addObject:@{
|
||||||
@"name": name,
|
@"name": name,
|
||||||
|
@"isUp": @(isUp),
|
||||||
@"downloadBytesPerSec": @(downBps),
|
@"downloadBytesPerSec": @(downBps),
|
||||||
@"uploadBytesPerSec": @(upBps),
|
@"uploadBytesPerSec": @(upBps),
|
||||||
@"downloadPacketsPerSec": @(downPps),
|
@"downloadPacketsPerSec": @(downPps),
|
||||||
@@ -171,16 +174,18 @@
|
|||||||
if (buf) free(buf);
|
if (buf) free(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Query IPv4 addresses via getifaddrs
|
// 2. Query IPv4 addresses and interface flags via getifaddrs
|
||||||
NSMutableDictionary<NSString *, NSString *> *ipDict = [NSMutableDictionary dictionary];
|
NSMutableDictionary<NSString *, NSString *> *ipDict = [NSMutableDictionary dictionary];
|
||||||
|
NSMutableDictionary<NSString *, NSNumber *> *upDict = [NSMutableDictionary dictionary];
|
||||||
struct ifaddrs *ifap = NULL;
|
struct ifaddrs *ifap = NULL;
|
||||||
if (getifaddrs(&ifap) == 0) {
|
if (getifaddrs(&ifap) == 0) {
|
||||||
for (struct ifaddrs *ifa = ifap; ifa != NULL; ifa = ifa->ifa_next) {
|
for (struct ifaddrs *ifa = ifap; ifa != NULL; ifa = ifa->ifa_next) {
|
||||||
|
NSString *name = [NSString stringWithUTF8String:ifa->ifa_name];
|
||||||
|
upDict[name] = @((ifa->ifa_flags & IFF_UP) != 0);
|
||||||
if (ifa->ifa_addr && ifa->ifa_addr->sa_family == AF_INET) {
|
if (ifa->ifa_addr && ifa->ifa_addr->sa_family == AF_INET) {
|
||||||
char ipBuf[INET_ADDRSTRLEN] = {0};
|
char ipBuf[INET_ADDRSTRLEN] = {0};
|
||||||
struct sockaddr_in *sin = (struct sockaddr_in *)ifa->ifa_addr;
|
struct sockaddr_in *sin = (struct sockaddr_in *)ifa->ifa_addr;
|
||||||
if (inet_ntop(AF_INET, &sin->sin_addr, ipBuf, sizeof(ipBuf))) {
|
if (inet_ntop(AF_INET, &sin->sin_addr, ipBuf, sizeof(ipBuf))) {
|
||||||
NSString *name = [NSString stringWithUTF8String:ifa->ifa_name];
|
|
||||||
ipDict[name] = [NSString stringWithUTF8String:ipBuf];
|
ipDict[name] = [NSString stringWithUTF8String:ipBuf];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +210,8 @@
|
|||||||
return [MMNetworkBandwidthProvider calculateBandwidthMetricsWithCurrentSnapshots:currentSnapshots
|
return [MMNetworkBandwidthProvider calculateBandwidthMetricsWithCurrentSnapshots:currentSnapshots
|
||||||
previousSnapshots:prev
|
previousSnapshots:prev
|
||||||
timeDelta:timeDelta
|
timeDelta:timeDelta
|
||||||
ipAddresses:ipDict];
|
ipAddresses:ipDict
|
||||||
|
interfaceUpFlags:upDict];
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#import "MMProcessTelemetryProvider.h"
|
#import "MMProcessTelemetryProvider.h"
|
||||||
#import <libproc.h>
|
#import <libproc.h>
|
||||||
#import <sys/proc_info.h>
|
#import <sys/proc_info.h>
|
||||||
|
#import <sys/proc.h>
|
||||||
#import <pwd.h>
|
#import <pwd.h>
|
||||||
#import <signal.h>
|
#import <signal.h>
|
||||||
#import <mach/mach_time.h>
|
#import <mach/mach_time.h>
|
||||||
@@ -127,7 +128,9 @@
|
|||||||
@"residentBytes": @(tai.ptinfo.pti_resident_size),
|
@"residentBytes": @(tai.ptinfo.pti_resident_size),
|
||||||
@"virtualBytes": @(tai.ptinfo.pti_virtual_size),
|
@"virtualBytes": @(tai.ptinfo.pti_virtual_size),
|
||||||
@"threadCount": @(tai.ptinfo.pti_threadnum),
|
@"threadCount": @(tai.ptinfo.pti_threadnum),
|
||||||
@"runningThreads": @(tai.ptinfo.pti_numrunning)
|
@"runningThreads": @(tai.ptinfo.pti_numrunning),
|
||||||
|
@"isStopped": @(tai.pbsd.pbi_status == SSTOP),
|
||||||
|
@"isZombie": @(tai.pbsd.pbi_status == SZOMB)
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import SwiftUI
|
|||||||
|
|
||||||
/// Sidebar tab showing currently breached thresholds and the in-app
|
/// Sidebar tab showing currently breached thresholds and the in-app
|
||||||
/// alert history log maintained by `AlertEngine`.
|
/// alert history log maintained by `AlertEngine`.
|
||||||
|
@MainActor
|
||||||
public struct AlertsView: View {
|
public struct AlertsView: View {
|
||||||
@State private var engine = AlertEngine.shared
|
@ObservedObject private var engine = AlertEngine.shared
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
@@ -109,7 +110,7 @@ public struct AlertsView: View {
|
|||||||
Text(event.alertID)
|
Text(event.alertID)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.monospaced()
|
.mmMonospaced()
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(event.timestamp.formatted(date: .abbreviated, time: .standard))
|
Text(event.timestamp.formatted(date: .abbreviated, time: .standard))
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if canImport(Charts)
|
||||||
import Charts
|
import Charts
|
||||||
|
#endif
|
||||||
|
|
||||||
public struct TelemetryTrendChartView: View {
|
public struct TelemetryTrendChartView: View {
|
||||||
public let title: String
|
public let title: String
|
||||||
@@ -43,8 +45,7 @@ public struct TelemetryTrendChartView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Text(String(format: "Cur: %.1f %@", currentValue, unit))
|
Text(String(format: "Cur: %.1f %@", currentValue, unit))
|
||||||
.font(.caption)
|
.font(.system(size: 11, weight: .semibold))
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
Text(String(format: "Avg: %.1f %@", averageValue, unit))
|
Text(String(format: "Avg: %.1f %@", averageValue, unit))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -55,35 +56,78 @@ public struct TelemetryTrendChartView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Chart {
|
#if canImport(Charts)
|
||||||
ForEach(Array(samples.enumerated()), id: \.element.id) { index, sample in
|
if #available(macOS 13.0, *) {
|
||||||
LineMark(
|
Chart {
|
||||||
x: .value("Sample", index),
|
ForEach(Array(samples.enumerated()), id: \.element.id) { index, sample in
|
||||||
y: .value("Value", sample.value)
|
LineMark(
|
||||||
)
|
x: .value("Sample", index),
|
||||||
.interpolationMethod(.monotone)
|
y: .value("Value", sample.value)
|
||||||
.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
|
|
||||||
)
|
)
|
||||||
)
|
.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)
|
||||||
|
} else {
|
||||||
|
fallbackChart
|
||||||
}
|
}
|
||||||
.chartYScale(domain: 0...(maxY ?? max(1.0, maxValue * 1.15)))
|
#else
|
||||||
.chartXAxis(.hidden)
|
fallbackChart
|
||||||
.frame(height: 120)
|
#endif
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.background(Color(NSColor.controlBackgroundColor))
|
.background(Color(NSColor.controlBackgroundColor))
|
||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var fallbackChart: some View {
|
||||||
|
Canvas { context, size in
|
||||||
|
guard samples.count > 1 else { return }
|
||||||
|
let effectiveMaxY = maxY ?? max(1.0, maxValue * 1.15)
|
||||||
|
let dx = size.width / CGFloat(samples.count - 1)
|
||||||
|
var path = Path()
|
||||||
|
var areaPath = Path()
|
||||||
|
|
||||||
|
for (index, sample) in samples.enumerated() {
|
||||||
|
let x = CGFloat(index) * dx
|
||||||
|
let y = size.height - (CGFloat(sample.value) / CGFloat(effectiveMaxY)) * size.height
|
||||||
|
|
||||||
|
if index == 0 {
|
||||||
|
path.move(to: CGPoint(x: x, y: y))
|
||||||
|
areaPath.move(to: CGPoint(x: x, y: size.height))
|
||||||
|
areaPath.addLine(to: CGPoint(x: x, y: y))
|
||||||
|
} else {
|
||||||
|
path.addLine(to: CGPoint(x: x, y: y))
|
||||||
|
areaPath.addLine(to: CGPoint(x: x, y: y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastIndex = samples.indices.last {
|
||||||
|
let lastX = CGFloat(lastIndex) * dx
|
||||||
|
areaPath.addLine(to: CGPoint(x: lastX, y: size.height))
|
||||||
|
areaPath.closeSubpath()
|
||||||
|
}
|
||||||
|
|
||||||
|
context.fill(areaPath, with: .linearGradient(Gradient(colors: [color.opacity(0.35), color.opacity(0.05)]), startPoint: .zero, endPoint: CGPoint(x: 0, y: size.height)))
|
||||||
|
context.stroke(path, with: .color(color), lineWidth: 2)
|
||||||
|
}
|
||||||
|
.frame(height: 120)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
public struct ContentView: View {
|
public struct ContentView: View {
|
||||||
@State private var store = SystemTelemetryStore.shared
|
@StateObject private var store = SystemTelemetryStore.shared
|
||||||
@State private var selectedTab: String = "Dashboard"
|
@State private var selectedTab: String? = "Dashboard"
|
||||||
@State private var processSearchText: String = ""
|
@State private var processSearchText: String = ""
|
||||||
@State private var socketSearchText: String = ""
|
@State private var socketSearchText: String = ""
|
||||||
@State private var selectedPID: Int32? = nil
|
@State private var selectedPID: Int32? = nil
|
||||||
@State private var showingProcessDetail: Bool = false
|
@State private var showingProcessDetail: Bool = false
|
||||||
@State private var navBus = AppNavigationBus.shared
|
@StateObject private var navBus = AppNavigationBus.shared
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
NavigationSplitView {
|
NavigationView {
|
||||||
sidebarContent
|
sidebarContent
|
||||||
} detail: {
|
|
||||||
detailContent
|
detailContent
|
||||||
}
|
}
|
||||||
.frame(minWidth: 1000, minHeight: 700)
|
.frame(minWidth: 1000, minHeight: 700)
|
||||||
@@ -26,7 +26,7 @@ public struct ContentView: View {
|
|||||||
ProcessDetailSheet(inspector: store.processInspector, pid: pid)
|
ProcessDetailSheet(inspector: store.processInspector, pid: pid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: navBus.requestCounter) { _, _ in
|
.onChange(of: navBus.requestCounter) { _ in
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
if let window = NSApp.windows.first(where: { $0.canBecomeMain }) {
|
if let window = NSApp.windows.first(where: { $0.canBecomeMain }) {
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
@@ -87,13 +87,29 @@ public struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Intelligence") {
|
Section("Intelligence") {
|
||||||
Label("Alerts & Notifications", systemImage: "bell.badge")
|
// Custom row instead of .badge() — a badge applied after .tag
|
||||||
.tag("Alerts")
|
// detaches the tag from the list row and makes it unselectable.
|
||||||
.badge(store.alertEngine.activeAlerts.isEmpty ? nil : Text("\(store.alertEngine.activeAlerts.count)"))
|
Label {
|
||||||
|
HStack {
|
||||||
|
Text("Alerts & Notifications")
|
||||||
|
Spacer()
|
||||||
|
if !store.alertEngine.activeAlerts.isEmpty {
|
||||||
|
Text("\(store.alertEngine.activeAlerts.count)")
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.red.opacity(0.85), in: Capsule())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "bell.badge")
|
||||||
|
}
|
||||||
|
.tag("Alerts")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
.navigationSplitViewColumnWidth(min: 210, ideal: 240, max: 300)
|
.frame(minWidth: 210, idealWidth: 240, maxWidth: 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Detail Content Router
|
// MARK: - Detail Content Router
|
||||||
@@ -104,7 +120,7 @@ public struct ContentView: View {
|
|||||||
headerCard
|
headerCard
|
||||||
engineControlCard
|
engineControlCard
|
||||||
|
|
||||||
switch selectedTab {
|
switch selectedTab ?? "Dashboard" {
|
||||||
case "Dashboard":
|
case "Dashboard":
|
||||||
dashboardView
|
dashboardView
|
||||||
case "Architecture":
|
case "Architecture":
|
||||||
@@ -153,7 +169,7 @@ public struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.padding(24)
|
.padding(24)
|
||||||
}
|
}
|
||||||
.navigationTitle("MacMonitor — \(selectedTab)")
|
.navigationTitle("MacMonitor — \(selectedTab ?? "Dashboard")")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Dashboard Composite View
|
// MARK: - Dashboard Composite View
|
||||||
@@ -342,20 +358,20 @@ public struct ContentView: View {
|
|||||||
Label("Kernel & VM Telemetry", systemImage: "waveform.path.ecg")
|
Label("Kernel & VM Telemetry", systemImage: "waveform.path.ecg")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
GridRow {
|
HStack {
|
||||||
Text("Context Switches:").foregroundStyle(.secondary)
|
Text("Context Switches:").foregroundStyle(.secondary)
|
||||||
Text("\(Int(store.kernelCounters.contextSwitchesPerSec))/s").bold().monospacedDigit()
|
Text("\(Int(store.kernelCounters.contextSwitchesPerSec))/s").bold().monospacedDigit()
|
||||||
Text("Syscalls:").foregroundStyle(.secondary)
|
Text("Syscalls:").foregroundStyle(.secondary)
|
||||||
Text("\(Int(store.kernelCounters.syscallsPerSec))/s").bold().monospacedDigit()
|
Text("\(Int(store.kernelCounters.syscallsPerSec))/s").bold().monospacedDigit()
|
||||||
}
|
}
|
||||||
GridRow {
|
HStack {
|
||||||
Text("Page Faults:").foregroundStyle(.secondary)
|
Text("Page Faults:").foregroundStyle(.secondary)
|
||||||
Text("\(Int(store.kernelCounters.pageFaultsPerSec))/s").bold().monospacedDigit()
|
Text("\(Int(store.kernelCounters.pageFaultsPerSec))/s").bold().monospacedDigit()
|
||||||
Text("COW Faults:").foregroundStyle(.secondary)
|
Text("COW Faults:").foregroundStyle(.secondary)
|
||||||
Text("\(Int(store.kernelCounters.cowFaultsPerSec))/s").bold().monospacedDigit()
|
Text("\(Int(store.kernelCounters.cowFaultsPerSec))/s").bold().monospacedDigit()
|
||||||
}
|
}
|
||||||
GridRow {
|
HStack {
|
||||||
Text("Pageins / Pageouts:").foregroundStyle(.secondary)
|
Text("Pageins / Pageouts:").foregroundStyle(.secondary)
|
||||||
Text("\(Int(store.kernelCounters.pageinsPerSec)) / \(Int(store.kernelCounters.pageoutsPerSec)) /s").bold().monospacedDigit()
|
Text("\(Int(store.kernelCounters.pageinsPerSec)) / \(Int(store.kernelCounters.pageoutsPerSec)) /s").bold().monospacedDigit()
|
||||||
Text("Zero-Fill:").foregroundStyle(.secondary)
|
Text("Zero-Fill:").foregroundStyle(.secondary)
|
||||||
@@ -430,20 +446,20 @@ public struct ContentView: View {
|
|||||||
ProgressView(value: min(store.memory.utilizationPercentage / 100.0, 1.0))
|
ProgressView(value: min(store.memory.utilizationPercentage / 100.0, 1.0))
|
||||||
.tint(store.memory.utilizationPercentage > 85 ? .red : .blue)
|
.tint(store.memory.utilizationPercentage > 85 ? .red : .blue)
|
||||||
|
|
||||||
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
GridRow {
|
HStack {
|
||||||
Text("Used:").foregroundStyle(.secondary)
|
Text("Used:").foregroundStyle(.secondary)
|
||||||
Text(formatBytes(store.memory.usedBytes)).bold()
|
Text(formatBytes(store.memory.usedBytes)).bold()
|
||||||
Text("Free:").foregroundStyle(.secondary)
|
Text("Free:").foregroundStyle(.secondary)
|
||||||
Text(formatBytes(store.memory.freeBytes)).bold()
|
Text(formatBytes(store.memory.freeBytes)).bold()
|
||||||
}
|
}
|
||||||
GridRow {
|
HStack {
|
||||||
Text("Wired:").foregroundStyle(.secondary)
|
Text("Wired:").foregroundStyle(.secondary)
|
||||||
Text(formatBytes(store.memory.wiredBytes))
|
Text(formatBytes(store.memory.wiredBytes))
|
||||||
Text("Compressed:").foregroundStyle(.secondary)
|
Text("Compressed:").foregroundStyle(.secondary)
|
||||||
Text(formatBytes(store.memory.compressedBytes))
|
Text(formatBytes(store.memory.compressedBytes))
|
||||||
}
|
}
|
||||||
GridRow {
|
HStack {
|
||||||
Text("Swap Used:").foregroundStyle(.secondary)
|
Text("Swap Used:").foregroundStyle(.secondary)
|
||||||
Text(formatBytes(store.memory.swapUsedBytes))
|
Text(formatBytes(store.memory.swapUsedBytes))
|
||||||
Text("Swap Total:").foregroundStyle(.secondary)
|
Text("Swap Total:").foregroundStyle(.secondary)
|
||||||
@@ -587,10 +603,10 @@ public struct ContentView: View {
|
|||||||
Text(s.protocolName)
|
Text(s.protocolName)
|
||||||
}
|
}
|
||||||
TableColumn("Local Endpoint") { s in
|
TableColumn("Local Endpoint") { s in
|
||||||
Text("\(s.localAddress):\(s.localPort)").monospaced()
|
Text("\(s.localAddress):\(s.localPort)").mmMonospaced()
|
||||||
}
|
}
|
||||||
TableColumn("Remote Endpoint") { s in
|
TableColumn("Remote Endpoint") { s in
|
||||||
Text("\(s.remoteAddress):\(s.remotePort)").monospaced()
|
Text("\(s.remoteAddress):\(s.remotePort)").mmMonospaced()
|
||||||
}
|
}
|
||||||
TableColumn("State") { s in
|
TableColumn("State") { s in
|
||||||
Text(s.tcpState).font(.caption).foregroundStyle(s.tcpState == "ESTABLISHED" ? .green : .secondary)
|
Text(s.tcpState).font(.caption).foregroundStyle(s.tcpState == "ESTABLISHED" ? .green : .secondary)
|
||||||
@@ -736,20 +752,20 @@ public struct ContentView: View {
|
|||||||
if !store.batteryHealth.hasBattery {
|
if !store.batteryHealth.hasBattery {
|
||||||
Text("Desktop Mac / No battery installed").font(.caption).foregroundStyle(.secondary)
|
Text("Desktop Mac / No battery installed").font(.caption).foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
GridRow {
|
HStack {
|
||||||
Text("Cycle Count:").foregroundStyle(.secondary)
|
Text("Cycle Count:").foregroundStyle(.secondary)
|
||||||
Text("\(store.batteryHealth.cycleCount) / \(store.batteryHealth.designCycleCount)").bold()
|
Text("\(store.batteryHealth.cycleCount) / \(store.batteryHealth.designCycleCount)").bold()
|
||||||
Text("Temperature:").foregroundStyle(.secondary)
|
Text("Temperature:").foregroundStyle(.secondary)
|
||||||
Text(String(format: "%.1f°C", store.batteryHealth.temperature)).bold()
|
Text(String(format: "%.1f°C", store.batteryHealth.temperature)).bold()
|
||||||
}
|
}
|
||||||
GridRow {
|
HStack {
|
||||||
Text("Voltage / Power:").foregroundStyle(.secondary)
|
Text("Voltage / Power:").foregroundStyle(.secondary)
|
||||||
Text("\(String(format: "%.2f V", store.batteryHealth.voltage)) (\(String(format: "%.1f W", store.batteryHealth.watts)))")
|
Text("\(String(format: "%.2f V", store.batteryHealth.voltage)) (\(String(format: "%.1f W", store.batteryHealth.watts)))")
|
||||||
Text("Power Adapter:").foregroundStyle(.secondary)
|
Text("Power Adapter:").foregroundStyle(.secondary)
|
||||||
Text(store.batteryHealth.externalConnected ? "\(store.batteryHealth.adapterWatts)W Connected" : "On Battery")
|
Text(store.batteryHealth.externalConnected ? "\(store.batteryHealth.adapterWatts)W Connected" : "On Battery")
|
||||||
}
|
}
|
||||||
GridRow {
|
HStack {
|
||||||
Text("Capacity:").foregroundStyle(.secondary)
|
Text("Capacity:").foregroundStyle(.secondary)
|
||||||
Text("\(store.batteryHealth.currentCapacity) / \(store.batteryHealth.maxCapacity) mAh")
|
Text("\(store.batteryHealth.currentCapacity) / \(store.batteryHealth.maxCapacity) mAh")
|
||||||
Text("Manufacturer:").foregroundStyle(.secondary)
|
Text("Manufacturer:").foregroundStyle(.secondary)
|
||||||
@@ -782,10 +798,10 @@ public struct ContentView: View {
|
|||||||
.background(Color.secondary.opacity(0.15), in: Capsule())
|
.background(Color.secondary.opacity(0.15), in: Capsule())
|
||||||
}
|
}
|
||||||
TableColumn("Vendor ID") { d in
|
TableColumn("Vendor ID") { d in
|
||||||
Text(String(format: "0x%04x", d.vendorID)).monospaced()
|
Text(String(format: "0x%04x", d.vendorID)).mmMonospaced()
|
||||||
}
|
}
|
||||||
TableColumn("Product ID") { d in
|
TableColumn("Product ID") { d in
|
||||||
Text(String(format: "0x%04x", d.productID)).monospaced()
|
Text(String(format: "0x%04x", d.productID)).mmMonospaced()
|
||||||
}
|
}
|
||||||
TableColumn("Built-In") { d in
|
TableColumn("Built-In") { d in
|
||||||
Text(d.isBuiltIn ? "Yes" : "External").foregroundStyle(d.isBuiltIn ? Color.secondary : Color.blue)
|
Text(d.isBuiltIn ? "Yes" : "External").foregroundStyle(d.isBuiltIn ? Color.secondary : Color.blue)
|
||||||
@@ -946,14 +962,14 @@ public struct ContentView: View {
|
|||||||
.font(.title3.bold().monospacedDigit())
|
.font(.title3.bold().monospacedDigit())
|
||||||
}
|
}
|
||||||
|
|
||||||
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
GridRow {
|
HStack {
|
||||||
Text("CPU Power:").foregroundStyle(.secondary)
|
Text("CPU Power:").foregroundStyle(.secondary)
|
||||||
Text(String(format: "%.1f W", store.power.cpuWatts)).bold()
|
Text(String(format: "%.1f W", store.power.cpuWatts)).bold()
|
||||||
Text("GPU Power:").foregroundStyle(.secondary)
|
Text("GPU Power:").foregroundStyle(.secondary)
|
||||||
Text(String(format: "%.1f W", store.power.gpuWatts)).bold()
|
Text(String(format: "%.1f W", store.power.gpuWatts)).bold()
|
||||||
}
|
}
|
||||||
GridRow {
|
HStack {
|
||||||
Text("CPU Voltage:").foregroundStyle(.secondary)
|
Text("CPU Voltage:").foregroundStyle(.secondary)
|
||||||
Text(String(format: "%.2f V", store.power.cpuVoltage))
|
Text(String(format: "%.2f V", store.power.cpuVoltage))
|
||||||
Text("CPU Current:").foregroundStyle(.secondary)
|
Text("CPU Current:").foregroundStyle(.secondary)
|
||||||
@@ -974,20 +990,20 @@ public struct ContentView: View {
|
|||||||
Text("Host Architecture & Kernel")
|
Text("Host Architecture & Kernel")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
GridRow {
|
HStack {
|
||||||
Text("Hardware Model:").foregroundStyle(.secondary)
|
Text("Hardware Model:").foregroundStyle(.secondary)
|
||||||
Text(store.hostModel).bold()
|
Text(store.hostModel).bold()
|
||||||
Text("Physical Cores:").foregroundStyle(.secondary)
|
Text("Physical Cores:").foregroundStyle(.secondary)
|
||||||
Text("\(store.physicalCpuCount)").bold()
|
Text("\(store.physicalCpuCount)").bold()
|
||||||
}
|
}
|
||||||
GridRow {
|
HStack {
|
||||||
Text("Darwin Kernel:").foregroundStyle(.secondary)
|
Text("Darwin Kernel:").foregroundStyle(.secondary)
|
||||||
Text(store.kernelVersion).bold()
|
Text(store.kernelVersion).bold()
|
||||||
Text("Logical Cores:").foregroundStyle(.secondary)
|
Text("Logical Cores:").foregroundStyle(.secondary)
|
||||||
Text("\(store.logicalCpuCount)").bold()
|
Text("\(store.logicalCpuCount)").bold()
|
||||||
}
|
}
|
||||||
GridRow {
|
HStack {
|
||||||
Text("Target Architecture:").foregroundStyle(.secondary)
|
Text("Target Architecture:").foregroundStyle(.secondary)
|
||||||
Text("Intel x86_64").bold()
|
Text("Intel x86_64").bold()
|
||||||
Text("Deployment Target:").foregroundStyle(.secondary)
|
Text("Deployment Target:").foregroundStyle(.secondary)
|
||||||
@@ -1205,6 +1221,7 @@ public struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Process Detail Inspector Sheet
|
// MARK: - Process Detail Inspector Sheet
|
||||||
|
@MainActor
|
||||||
struct ProcessDetailSheet: View {
|
struct ProcessDetailSheet: View {
|
||||||
let inspector: MMProcessDetailInspector
|
let inspector: MMProcessDetailInspector
|
||||||
let pid: Int32
|
let pid: Int32
|
||||||
@@ -1233,8 +1250,9 @@ struct ProcessDetailSheet: View {
|
|||||||
if selectedDetailTab == "Threads" {
|
if selectedDetailTab == "Threads" {
|
||||||
List(threads.indices, id: \.self) { idx in
|
List(threads.indices, id: \.self) { idx in
|
||||||
let t = threads[idx]
|
let t = threads[idx]
|
||||||
|
let threadID = "\(t["threadID"] ?? idx)"
|
||||||
HStack {
|
HStack {
|
||||||
Text("Thread \(t["threadID"] ?? idx)").bold()
|
Text("Thread \(threadID)").bold()
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("CPU: \(String(format: "%.1f%%", (t["cpuPercent"] as? Double) ?? 0.0))").monospacedDigit()
|
Text("CPU: \(String(format: "%.1f%%", (t["cpuPercent"] as? Double) ?? 0.0))").monospacedDigit()
|
||||||
Text(t["state"] as? String ?? "").foregroundStyle(.secondary)
|
Text(t["state"] as? String ?? "").foregroundStyle(.secondary)
|
||||||
@@ -1247,11 +1265,16 @@ struct ProcessDetailSheet: View {
|
|||||||
} else {
|
} else {
|
||||||
List(sockets.indices, id: \.self) { idx in
|
List(sockets.indices, id: \.self) { idx in
|
||||||
let s = sockets[idx]
|
let s = sockets[idx]
|
||||||
|
let proto = s["protocol"] as? String ?? "TCP"
|
||||||
|
let localAddr = (s["localAddress"] as? String) ?? ""
|
||||||
|
let localPort = (s["localPort"] as? Int) ?? 0
|
||||||
|
let remoteAddr = (s["remoteAddress"] as? String) ?? ""
|
||||||
|
let remotePort = (s["remotePort"] as? Int) ?? 0
|
||||||
HStack {
|
HStack {
|
||||||
Text(s["protocol"] as? String ?? "TCP").bold()
|
Text(proto).bold()
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(s["localAddress"] ?? ""):\(s["localPort"] ?? 0) → \(s["remoteAddress"] ?? ""):\(s["remotePort"] ?? 0)")
|
Text("\(localAddr):\(localPort) → \(remoteAddr):\(remotePort)")
|
||||||
.monospaced()
|
.mmMonospaced()
|
||||||
Text(s["tcpState"] as? String ?? "").foregroundStyle(.secondary)
|
Text(s["tcpState"] as? String ?? "").foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
public struct QuickGlancePopoverView: View {
|
public struct QuickGlancePopoverView: View {
|
||||||
var store: SystemTelemetryStore
|
@ObservedObject var store: SystemTelemetryStore
|
||||||
|
|
||||||
public init(store: SystemTelemetryStore) {
|
public init(store: SystemTelemetryStore) {
|
||||||
self.store = store
|
self.store = store
|
||||||
@@ -109,9 +110,7 @@ public struct QuickGlancePopoverView: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.subheadline)
|
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||||
.fontWeight(.semibold)
|
|
||||||
.fontDesign(.monospaced)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import UserNotifications
|
|||||||
|
|
||||||
/// Preferences UI for threshold alert rules. Bound directly to
|
/// Preferences UI for threshold alert rules. Bound directly to
|
||||||
/// `AlertEngine.shared.configuration`, which auto-persists to `UserDefaults`.
|
/// `AlertEngine.shared.configuration`, which auto-persists to `UserDefaults`.
|
||||||
|
@MainActor
|
||||||
public struct AlertsSettingsView: View {
|
public struct AlertsSettingsView: View {
|
||||||
@Bindable private var engine = AlertEngine.shared
|
@ObservedObject private var engine = AlertEngine.shared
|
||||||
@State private var authorizationStatus: UNAuthorizationStatus = .notDetermined
|
@State private var authorizationStatus: UNAuthorizationStatus = .notDetermined
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
@@ -58,24 +59,24 @@ public struct AlertsSettingsView: View {
|
|||||||
|
|
||||||
Section("CPU Temperature") {
|
Section("CPU Temperature") {
|
||||||
Toggle("High CPU Temperature Alert", isOn: $engine.configuration.cpuTempEnabled)
|
Toggle("High CPU Temperature Alert", isOn: $engine.configuration.cpuTempEnabled)
|
||||||
LabeledContent("Threshold") {
|
MMLabeledContent("Threshold") {
|
||||||
Slider(value: $engine.configuration.cpuTempThresholdC, in: 60...110, step: 1) {
|
Slider(value: $engine.configuration.cpuTempThresholdC, in: 60...110, step: 1) {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
Text("\(Int(engine.configuration.cpuTempThresholdC))°C")
|
Text("\(Int(engine.configuration.cpuTempThresholdC))°C")
|
||||||
.monospacedDigit()
|
.mmMonospacedDigit()
|
||||||
.frame(width: 48, alignment: .trailing)
|
.frame(width: 48, alignment: .trailing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Cooling Fans") {
|
Section("Cooling Fans") {
|
||||||
Toggle("Fan Stall Alert", isOn: $engine.configuration.fanStallEnabled)
|
Toggle("Fan Stall Alert", isOn: $engine.configuration.fanStallEnabled)
|
||||||
LabeledContent("Alert when CPU above") {
|
MMLabeledContent("Alert when CPU above") {
|
||||||
Slider(value: $engine.configuration.fanStallTempThresholdC, in: 40...100, step: 1) {
|
Slider(value: $engine.configuration.fanStallTempThresholdC, in: 40...100, step: 1) {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
Text("\(Int(engine.configuration.fanStallTempThresholdC))°C")
|
Text("\(Int(engine.configuration.fanStallTempThresholdC))°C")
|
||||||
.monospacedDigit()
|
.mmMonospacedDigit()
|
||||||
.frame(width: 48, alignment: .trailing)
|
.frame(width: 48, alignment: .trailing)
|
||||||
}
|
}
|
||||||
Text("Triggers when a fan reports 0 RPM while the CPU is hot.")
|
Text("Triggers when a fan reports 0 RPM while the CPU is hot.")
|
||||||
@@ -85,12 +86,12 @@ public struct AlertsSettingsView: View {
|
|||||||
|
|
||||||
Section("Memory") {
|
Section("Memory") {
|
||||||
Toggle("Low Memory Alert", isOn: $engine.configuration.memoryEnabled)
|
Toggle("Low Memory Alert", isOn: $engine.configuration.memoryEnabled)
|
||||||
LabeledContent("Free RAM below") {
|
MMLabeledContent("Free RAM below") {
|
||||||
Slider(value: $engine.configuration.memoryFreeMBThreshold, in: 100...4096, step: 50) {
|
Slider(value: $engine.configuration.memoryFreeMBThreshold, in: 100...4096, step: 50) {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
Text("\(Int(engine.configuration.memoryFreeMBThreshold)) MB")
|
Text("\(Int(engine.configuration.memoryFreeMBThreshold)) MB")
|
||||||
.monospacedDigit()
|
.mmMonospacedDigit()
|
||||||
.frame(width: 64, alignment: .trailing)
|
.frame(width: 64, alignment: .trailing)
|
||||||
}
|
}
|
||||||
Toggle("Alert on Critical Memory Pressure", isOn: $engine.configuration.memoryAlertOnCriticalPressure)
|
Toggle("Alert on Critical Memory Pressure", isOn: $engine.configuration.memoryAlertOnCriticalPressure)
|
||||||
@@ -98,32 +99,32 @@ public struct AlertsSettingsView: View {
|
|||||||
|
|
||||||
Section("Storage") {
|
Section("Storage") {
|
||||||
Toggle("Low Disk Space Alert", isOn: $engine.configuration.storageEnabled)
|
Toggle("Low Disk Space Alert", isOn: $engine.configuration.storageEnabled)
|
||||||
LabeledContent("Free space below") {
|
MMLabeledContent("Free space below") {
|
||||||
Slider(value: $engine.configuration.storageFreeGBThreshold, in: 1...100, step: 1) {
|
Slider(value: $engine.configuration.storageFreeGBThreshold, in: 1...100, step: 1) {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
Text("\(Int(engine.configuration.storageFreeGBThreshold)) GB")
|
Text("\(Int(engine.configuration.storageFreeGBThreshold)) GB")
|
||||||
.monospacedDigit()
|
.mmMonospacedDigit()
|
||||||
.frame(width: 48, alignment: .trailing)
|
.frame(width: 48, alignment: .trailing)
|
||||||
}
|
}
|
||||||
LabeledContent("Free percent below") {
|
MMLabeledContent("Free percent below") {
|
||||||
Slider(value: $engine.configuration.storageFreePercentThreshold, in: 1...50, step: 1) {
|
Slider(value: $engine.configuration.storageFreePercentThreshold, in: 1...50, step: 1) {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
Text("\(Int(engine.configuration.storageFreePercentThreshold))%")
|
Text("\(Int(engine.configuration.storageFreePercentThreshold))%")
|
||||||
.monospacedDigit()
|
.mmMonospacedDigit()
|
||||||
.frame(width: 48, alignment: .trailing)
|
.frame(width: 48, alignment: .trailing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Battery") {
|
Section("Battery") {
|
||||||
Toggle("Low Battery Alert", isOn: $engine.configuration.batteryEnabled)
|
Toggle("Low Battery Alert", isOn: $engine.configuration.batteryEnabled)
|
||||||
LabeledContent("Charge below") {
|
MMLabeledContent("Charge below") {
|
||||||
Slider(value: $engine.configuration.batteryPercentThreshold, in: 1...50, step: 1) {
|
Slider(value: $engine.configuration.batteryPercentThreshold, in: 1...50, step: 1) {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
Text("\(Int(engine.configuration.batteryPercentThreshold))%")
|
Text("\(Int(engine.configuration.batteryPercentThreshold))%")
|
||||||
.monospacedDigit()
|
.mmMonospacedDigit()
|
||||||
.frame(width: 48, alignment: .trailing)
|
.frame(width: 48, alignment: .trailing)
|
||||||
}
|
}
|
||||||
Text("Only fires while discharging on battery power.")
|
Text("Only fires while discharging on battery power.")
|
||||||
@@ -131,7 +132,7 @@ public struct AlertsSettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.mmFormStyleGrouped()
|
||||||
.frame(minWidth: 480, minHeight: 520)
|
.frame(minWidth: 480, minHeight: 520)
|
||||||
.task { await refreshAuthorizationStatus() }
|
.task { await refreshAuthorizationStatus() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,8 +128,10 @@ final class MMAlertsTests: XCTestCase {
|
|||||||
|
|
||||||
func testStorageRuleTriggersPerVolume() {
|
func testStorageRuleTriggersPerVolume() {
|
||||||
let volumes = [
|
let volumes = [
|
||||||
StorageVolumeContext(mountPoint: "/", volumeName: "Macintosh HD", freeBytes: 5 * 1_073_741_824, freePercent: 5),
|
StorageVolumeContext(mountPoint: "/", volumeName: "Macintosh HD", freeBytes: 5 * 1_073_741_824,
|
||||||
StorageVolumeContext(mountPoint: "/Volumes/Data", volumeName: "Data", freeBytes: 500 * 1_073_741_824, freePercent: 50)
|
freePercent: 5, totalBytes: 500 * 1_073_741_824),
|
||||||
|
StorageVolumeContext(mountPoint: "/Volumes/Data", volumeName: "Data", freeBytes: 500 * 1_073_741_824,
|
||||||
|
freePercent: 50, totalBytes: 1_000_000_000_000)
|
||||||
]
|
]
|
||||||
engine.evaluate(context(volumes: volumes))
|
engine.evaluate(context(volumes: volumes))
|
||||||
XCTAssertEqual(engine.activeAlerts.count, 1)
|
XCTAssertEqual(engine.activeAlerts.count, 1)
|
||||||
@@ -137,11 +139,32 @@ final class MMAlertsTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testStorageRuleTriggersOnLowPercent() {
|
func testStorageRuleTriggersOnLowPercent() {
|
||||||
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Big", volumeName: "Big", freeBytes: 200 * 1_073_741_824, freePercent: 4)]
|
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Big", volumeName: "Big",
|
||||||
|
freeBytes: 200 * 1_073_741_824, freePercent: 4,
|
||||||
|
totalBytes: 2_000_000_000_000)]
|
||||||
engine.evaluate(context(volumes: volumes))
|
engine.evaluate(context(volumes: volumes))
|
||||||
XCTAssertEqual(engine.activeAlerts.count, 1)
|
XCTAssertEqual(engine.activeAlerts.count, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testStorageRuleSkipsReadOnlyVolumes() {
|
||||||
|
// A read-only DMG mount (e.g. an app running from a mounted image)
|
||||||
|
// with ~0% free must not alert — issue #33 false positive.
|
||||||
|
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Ghostty", volumeName: "Ghostty",
|
||||||
|
freeBytes: 1_048_576, freePercent: 0.1,
|
||||||
|
totalBytes: 500_000_000_000, isReadOnly: true)]
|
||||||
|
engine.evaluate(context(volumes: volumes))
|
||||||
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
||||||
|
XCTAssertTrue(engine.history.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStorageRuleSkipsTinyVolumes() {
|
||||||
|
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Small", volumeName: "Small",
|
||||||
|
freeBytes: 1_048_576, freePercent: 0.5,
|
||||||
|
totalBytes: 200_000_000, isReadOnly: false)]
|
||||||
|
engine.evaluate(context(volumes: volumes))
|
||||||
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Battery
|
// MARK: Battery
|
||||||
|
|
||||||
func testBatteryRuleTriggersOnlyWhileDischarging() {
|
func testBatteryRuleTriggersOnlyWhileDischarging() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import XCTest
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
@testable import MacMonitor
|
@testable import MacMonitor
|
||||||
|
|
||||||
|
@MainActor
|
||||||
final class MMMenuBarTests: XCTestCase {
|
final class MMMenuBarTests: XCTestCase {
|
||||||
|
|
||||||
func testMenuBarStatusViewInitialization() {
|
func testMenuBarStatusViewInitialization() {
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ final class MMNetworkBandwidthTests: XCTestCase {
|
|||||||
withCurrentSnapshots: curr,
|
withCurrentSnapshots: curr,
|
||||||
previousSnapshots: prev,
|
previousSnapshots: prev,
|
||||||
timeDelta: timeDelta,
|
timeDelta: timeDelta,
|
||||||
ipAddresses: ipDict
|
ipAddresses: ipDict,
|
||||||
|
interfaceUpFlags: ["en0": true, "lo0": true]
|
||||||
)
|
)
|
||||||
|
|
||||||
let totalDownBps = metrics["totalDownloadBytesPerSec"] as? Double ?? 0
|
let totalDownBps = metrics["totalDownloadBytesPerSec"] as? Double ?? 0
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import MacMonitor
|
||||||
|
|
||||||
|
/// Snapshot contract conformance tests (Issues #30 / #31).
|
||||||
|
/// Feeds synthetic dictionaries keyed exactly as the Objective-C providers emit them
|
||||||
|
/// through `SystemTelemetryStore.decodeSnapshot` and asserts the decoded Swift models
|
||||||
|
/// populate — guarding against provider-identifier and field-name drift.
|
||||||
|
@MainActor
|
||||||
|
final class MMSnapshotContractTests: XCTestCase {
|
||||||
|
|
||||||
|
private var store: SystemTelemetryStore { SystemTelemetryStore.shared }
|
||||||
|
|
||||||
|
// MARK: - Provider identifier coverage (#30)
|
||||||
|
|
||||||
|
func testEmittedProviderIdentifiersAreDecoded() {
|
||||||
|
// The four identifiers that previously never reached the decoder.
|
||||||
|
let snapshot: [String: [String: Any]] = [
|
||||||
|
"com.i3omb.macmonitor.telemetry.cpuload": [
|
||||||
|
"userPercent": 25.0, "systemPercent": 15.0,
|
||||||
|
"idlePercent": 60.0, "totalPercent": 40.0,
|
||||||
|
"cores": [["totalPercent": 10.0], ["totalPercent": 90.0]],
|
||||||
|
"frequencyHz": 2.4e9, "isThrottled": false
|
||||||
|
],
|
||||||
|
"com.i3omb.macmonitor.telemetry.kernel.counters": [
|
||||||
|
"contextSwitchesRate": 1234.5, "syscallsRate": 9876.5,
|
||||||
|
"pageFaultsRate": 42.0, "cowFaultsRate": 3.0,
|
||||||
|
"zeroFillsRate": 7.0, "pageinsRate": 1.0, "pageoutsRate": 0.5,
|
||||||
|
"cumulative_contextSwitches": 999_999 as UInt64,
|
||||||
|
"cumulative_syscalls": 888_888 as UInt64,
|
||||||
|
"cumulative_pageFaults": 777_777 as UInt64
|
||||||
|
],
|
||||||
|
"com.i3omb.macmonitor.telemetry.system.load": [
|
||||||
|
"load1m": 1.5, "load5m": 2.0, "load15m": 2.5,
|
||||||
|
"taskCount": 400, "threadCount": 2000, "machFactor": 0.75
|
||||||
|
],
|
||||||
|
"com.i3omb.macmonitor.telemetry.storage.io": [
|
||||||
|
"disks": [[
|
||||||
|
"bsdName": "disk0",
|
||||||
|
"readBytesPerSec": 1_048_576.0, "writeBytesPerSec": 524_288.0,
|
||||||
|
"readIOPS": 120.0, "writeIOPS": 60.0,
|
||||||
|
"cumulativeReadBytes": 9_999_999 as UInt64,
|
||||||
|
"cumulativeWriteBytes": 8_888_888 as UInt64
|
||||||
|
]]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
store.decodeSnapshot(snapshot)
|
||||||
|
|
||||||
|
XCTAssertEqual(store.cpuLoad.userLoad, 25.0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.cpuLoad.systemLoad, 15.0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.cpuLoad.totalLoad, 40.0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.cpuLoad.perCoreLoad, [10.0, 90.0])
|
||||||
|
XCTAssertEqual(store.cpuLoad.cpuFrequencyMHz, 2400.0, accuracy: 0.001)
|
||||||
|
|
||||||
|
XCTAssertEqual(store.kernelCounters.contextSwitchesPerSec, 1234.5, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.kernelCounters.syscallsPerSec, 9876.5, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.kernelCounters.totalContextSwitches, 999_999)
|
||||||
|
XCTAssertEqual(store.kernelCounters.totalSyscalls, 888_888)
|
||||||
|
XCTAssertEqual(store.kernelCounters.totalPageFaults, 777_777)
|
||||||
|
|
||||||
|
XCTAssertEqual(store.systemLoad.load1Min, 1.5, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.systemLoad.load5Min, 2.0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.systemLoad.load15Min, 2.5, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.systemLoad.taskCount, 400)
|
||||||
|
XCTAssertEqual(store.systemLoad.threadCount, 2000)
|
||||||
|
|
||||||
|
XCTAssertEqual(store.diskIO.count, 1)
|
||||||
|
XCTAssertEqual(store.diskIO[0].bsdName, "disk0")
|
||||||
|
XCTAssertEqual(store.diskIO[0].readBps, 1_048_576.0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.diskIO[0].totalBytesRead, 9_999_999)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Field-name drift (#31)
|
||||||
|
|
||||||
|
func testMemoryUtilizationComputedFromUsedOverTotal() {
|
||||||
|
store.decodeSnapshot([
|
||||||
|
"com.i3omb.macmonitor.telemetry.memory": [
|
||||||
|
"totalBytes": 16_000_000_000 as UInt64,
|
||||||
|
"usedBytes": 8_000_000_000 as UInt64,
|
||||||
|
"freeBytes": 8_000_000_000 as UInt64,
|
||||||
|
"memoryPressureStatus": "Normal"
|
||||||
|
]
|
||||||
|
])
|
||||||
|
XCTAssertEqual(store.memory.utilizationPercentage, 50.0, accuracy: 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStorageVolumeEmitsUsedPercentAndFSType() {
|
||||||
|
store.decodeSnapshot([
|
||||||
|
"com.i3omb.macmonitor.telemetry.storage": [
|
||||||
|
"volumes": [[
|
||||||
|
"volumeName": "Macintosh HD", "mountPoint": "/",
|
||||||
|
"devicePath": "/dev/disk1s1", "fsType": "apfs",
|
||||||
|
"totalBytes": 500_000_000_000 as UInt64,
|
||||||
|
"usedBytes": 250_000_000_000 as UInt64,
|
||||||
|
"freeBytes": 250_000_000_000 as UInt64,
|
||||||
|
"usedPercent": 50.0, "isReadOnly": false
|
||||||
|
]]
|
||||||
|
]
|
||||||
|
])
|
||||||
|
XCTAssertEqual(store.storageVolumes.count, 1)
|
||||||
|
XCTAssertEqual(store.storageVolumes[0].usedPercentage, 50.0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(store.storageVolumes[0].fileSystem, "apfs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNetworkInterfaceEmittedKeys() {
|
||||||
|
store.decodeSnapshot([
|
||||||
|
"com.i3omb.macmonitor.telemetry.network.bandwidth": [
|
||||||
|
"interfaces": [[
|
||||||
|
"name": "en0", "isUp": true, "ipv4Address": "192.168.1.10",
|
||||||
|
"downloadBytesPerSec": 5_000.0, "uploadBytesPerSec": 1_000.0,
|
||||||
|
"downloadPacketsPerSec": 40.0, "uploadPacketsPerSec": 20.0,
|
||||||
|
"cumulativeInBytes": 10_000_000 as UInt64,
|
||||||
|
"cumulativeOutBytes": 5_000_000 as UInt64
|
||||||
|
]]
|
||||||
|
]
|
||||||
|
])
|
||||||
|
XCTAssertEqual(store.networkBandwidth.count, 1)
|
||||||
|
let iface = store.networkBandwidth[0]
|
||||||
|
XCTAssertEqual(iface.interfaceName, "en0")
|
||||||
|
XCTAssertTrue(iface.isUp)
|
||||||
|
XCTAssertEqual(iface.ipAddress, "192.168.1.10")
|
||||||
|
XCTAssertEqual(iface.downloadBps, 5_000.0, accuracy: 0.001)
|
||||||
|
XCTAssertEqual(iface.totalBytesIn, 10_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProcessResidentAndVirtualBytesDecode() {
|
||||||
|
store.decodeSnapshot([
|
||||||
|
"com.i3omb.macmonitor.telemetry.process": [
|
||||||
|
"processes": [[
|
||||||
|
"pid": 42, "ppid": 1, "name": "testproc",
|
||||||
|
"cpuPercent": 3.5,
|
||||||
|
"residentBytes": 100_000_000 as UInt64,
|
||||||
|
"virtualBytes": 400_000_000 as UInt64,
|
||||||
|
"threadCount": 5, "uid": 501, "username": "gordon",
|
||||||
|
"isStopped": false, "isZombie": false
|
||||||
|
]]
|
||||||
|
]
|
||||||
|
])
|
||||||
|
XCTAssertEqual(store.processes.count, 1)
|
||||||
|
XCTAssertEqual(store.processes[0].residentSize, 100_000_000)
|
||||||
|
XCTAssertEqual(store.processes[0].virtualSize, 400_000_000)
|
||||||
|
XCTAssertFalse(store.processes[0].isStopped)
|
||||||
|
XCTAssertFalse(store.processes[0].isZombie)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFanEmittedKeysDecode() {
|
||||||
|
store.decodeSnapshot([
|
||||||
|
"com.i3omb.macmonitor.telemetry.fan": [
|
||||||
|
"fans": [[
|
||||||
|
"fanIndex": 0, "name": "Exhaust",
|
||||||
|
"currentRPM": 2000.0, "minRPM": 1200.0,
|
||||||
|
"maxRPM": 6000.0, "targetRPM": 2000.0,
|
||||||
|
"utilizationPercent": 16.67
|
||||||
|
]]
|
||||||
|
]
|
||||||
|
])
|
||||||
|
XCTAssertEqual(store.fans.count, 1)
|
||||||
|
XCTAssertEqual(store.fans[0].index, 0)
|
||||||
|
XCTAssertEqual(store.fans[0].name, "Exhaust")
|
||||||
|
XCTAssertEqual(store.fans[0].utilization, 16.67, accuracy: 0.01)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -16,8 +16,8 @@ settings:
|
|||||||
CLANG_ENABLE_OBJC_ARC: YES
|
CLANG_ENABLE_OBJC_ARC: YES
|
||||||
CLANG_ENABLE_MODULES: YES
|
CLANG_ENABLE_MODULES: YES
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
MARKETING_VERSION: "0.0.1"
|
MARKETING_VERSION: "0.0.2"
|
||||||
CURRENT_PROJECT_VERSION: "1"
|
CURRENT_PROJECT_VERSION: "2"
|
||||||
CODE_SIGNING_ALLOWED: NO
|
CODE_SIGNING_ALLOWED: NO
|
||||||
CODE_SIGN_IDENTITY: ""
|
CODE_SIGN_IDENTITY: ""
|
||||||
GCC_C_LANGUAGE_STANDARD: "gnu17"
|
GCC_C_LANGUAGE_STANDARD: "gnu17"
|
||||||
|
|||||||
Reference in New Issue
Block a user