Compare commits

..
Author SHA1 Message Date
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> acf5d03277 fix(smc): correct IOKit service name and SMCKeyData_t layout (fixes #32)
Two latent bugs kept all SMC telemetry dead:
- IOServiceMatching("AppleSMCClient") never matched — the kernel service
  is AppleSMC, so openWithError always failed and every SMC-gated provider
  (thermal, fan, component, power) reported unavailable.
- #pragma pack(1) shrank SMCKeyData_t to 74 bytes; the SMC user client
  requires the canonical 80-byte layout and rejected every
  IOConnectCallStructMethod with kIOReturnBadArgument. Verified live on
  Intel hardware: unpacked calls succeed and return key data.

Also sources the alert engine's batteryLevel from the SMC-independent
batteryHealth path (currentCapacity/maxCapacity), falling back to the
power provider.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 15:14:41 +01:00
gronod 9933c87004 Merge pull request 'fix(telemetry): snapshot contract realignment (Issues #30 + #31)' (#35) from fix/30-31-snapshot-contract into milestone/m6-bugfix-stability 2026-09-08 15:09:46 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 4a8fd0e5ef fix(telemetry): realign snapshot contract — provider IDs and dictionary field keys (fixes #30, fixes #31)
Providers emitted identifiers and field keys that decodeSnapshot never
looked up, leaving CPU/kernel/load/disk sections zeroed and memory%,
storage%, network, process RSS, and fan fields decoded as zero.

Store-side decode now reads the emitted schema; providers gain the
previously-missing fields: network isUp (IFF_UP via getifaddrs), process
isStopped/isZombie (pbi_status), fan name (F{i}ID). Memory utilization is
computed as used/total in the decoder. decodeSnapshot is internal for
@testable access; new MMSnapshotContractTests cover the emitted-key
contract with synthetic dictionaries (93 tests total, 0 failures).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 15:09:19 +01:00
gronod bc735b57e6 ci: produce release distribution artifact as .dmg file 2026-09-08 15:01:08 +01:00
gronod b1c86f1ee8 Merge pull request 'fix(ci): correct release-package destination specifier for v0.0.1' (#29) from develop into main
MacMonitor CI/CD Pipeline / Build & Test (Intel x86_64) (push) Successful in 1m40s
MacMonitor CI/CD Pipeline / Package Release Artifact (push) Successful in 2m25s
2026-09-08 14:15:23 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> a533ee1f9f fix(ci): use platform=macOS,arch=x86_64 destination in release job
The generic 'generic/platform=macOS,arch=x86_64' destination is rejected by
the runner's Xcode ("Any Mac" does not support the arch option), failing the
release-package build. Match the working build-and-test destination.
Also correct the same specifier in README.md and AGENTS.md.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 14:15:00 +01:00
gronod 26a65f0d47 Merge pull request 'release: v0.0.1 — promote develop to main' (#28) from develop into main
MacMonitor CI/CD Pipeline / Build & Test (Intel x86_64) (push) Successful in 1m36s
MacMonitor CI/CD Pipeline / Package Release Artifact (push) Failing after 45s
2026-09-08 14:09:46 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7cb6c25ae2 chore(release): set app version to 0.0.1 (build 1)
Add MARKETING_VERSION and CURRENT_PROJECT_VERSION to project.yml so the
generated Info.plist carries CFBundleShortVersionString/CFBundleVersion for
the v0.0.1 release artifacts.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 14:09:22 +01:00
gronod d7b87ca917 Merge pull request 'M5: Intelligence, Alerts & Hardening — milestone integration' (#27) from milestone/m5-alerts-hardening into develop 2026-09-08 14:04:47 +01:00
gronod 431b2376c3 Merge pull request 'feat(alerts): Threshold Alert Engine, Notification Center & Release Packaging (M5 / Issue #24)' (#26) from feat/24-threshold-alerts into milestone/m5-alerts-hardening 2026-09-08 14:04:17 +01:00
16 changed files with 273 additions and 75 deletions
+13 -9
View File
@@ -99,7 +99,7 @@ jobs:
xcodebuild clean build \
-scheme MacMonitor \
-configuration Release \
-destination 'generic/platform=macOS,arch=x86_64' \
-destination 'platform=macOS,arch=x86_64' \
-derivedDataPath "$PWD/DerivedData" \
CODE_SIGNING_ALLOWED=NO
@@ -111,19 +111,23 @@ jobs:
codesign --force --deep --sign - "$APP"
codesign --verify --verbose "$APP"
- name: Create Distribution Archive
- name: Create Distribution Disk Image (.dmg)
run: |
APP="$PWD/DerivedData/Build/Products/Release/MacMonitor.app"
cd "$(dirname "$APP")"
ditto -c -k --sequesterRsrc --keepParent "MacMonitor.app" "$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.zip"
ls -lh "$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.zip"
DMG_STAGING="$(mktemp -d)/staging"
mkdir -p "$DMG_STAGING"
cp -R "$APP" "$DMG_STAGING/"
ln -s /Applications "$DMG_STAGING/Applications"
hdiutil create -volname "MacMonitor" -srcfolder "$DMG_STAGING" -ov -format UDZO "$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.dmg"
rm -rf "$(dirname "$DMG_STAGING")"
ls -lh "$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.dmg"
- name: Upload Workflow Artifact
uses: actions/upload-artifact@v4
continue-on-error: true
with:
name: MacMonitor-intel-x86_64
path: MacMonitor-intel-x86_64.zip
path: MacMonitor-intel-x86_64.dmg
- name: Create Gitea Release & Attach Artifact
run: |
@@ -147,7 +151,7 @@ jobs:
echo "ERROR: could not resolve release ID for $TAG"; exit 1
fi
curl -sS -X POST "$API/releases/$RELEASE_ID/assets?name=MacMonitor-intel-x86_64.zip" \
-H "$AUTH" -H "Content-Type: application/zip" \
--data-binary @"$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.zip" \
curl -sS -X POST "$API/releases/$RELEASE_ID/assets?name=MacMonitor-intel-x86_64.dmg" \
-H "$AUTH" -H "Content-Type: application/x-apple-diskimage" \
--data-binary @"$GITHUB_WORKSPACE/MacMonitor-intel-x86_64.dmg" \
-o /tmp/asset.json -w "attach_asset_http=%{http_code}\n"
+1 -1
View File
@@ -132,7 +132,7 @@ Before opening any Pull Request, ensure that the project compiles cleanly for In
```bash
xcodebuild build test \
-scheme MacMonitor \
-destination 'generic/platform=macOS,arch=x86_64' \
-destination 'platform=macOS,arch=x86_64' \
CODE_SIGNING_ALLOWED=NO
```
+1 -1
View File
@@ -203,7 +203,7 @@ graph TD
- **Goal:** Proactive health alerts, macOS Notification Center integration, end-to-end regression validation, and release packaging.
- **Execution Order:**
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.
---
+8
View File
@@ -16,6 +16,7 @@
1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78325870C7CD8312AECA2236 /* MMStorageTests.swift */; };
1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; };
22CA7351A745F7837E487051 /* MMPeripheralsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1486E1512594482380CB492 /* MMPeripheralsTests.swift */; };
2665E9DAE13C13239857B4BB /* MMSnapshotContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA79F8A259CA3056F9B131A9 /* MMSnapshotContractTests.swift */; };
2C0F7AB93691ED8AA86FBCF0 /* MMProcessDetailInspector.m in Sources */ = {isa = PBXBuildFile; fileRef = 52E641F8A664D456518EDBA4 /* MMProcessDetailInspector.m */; };
2E966D96347D1DDB24E18632 /* AlertModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B657679DB7D1F39AB4911B7B /* AlertModels.swift */; };
3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */; };
@@ -167,6 +168,7 @@
E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTelemetryStore.swift; sourceTree = "<group>"; };
E6D9BB71DFE7384D0F0E6AA3 /* MMKernelTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMKernelTelemetryProvider.h; sourceTree = "<group>"; };
E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMDiskIOTests.swift; sourceTree = "<group>"; };
EA79F8A259CA3056F9B131A9 /* MMSnapshotContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMSnapshotContractTests.swift; sourceTree = "<group>"; };
F444F4543F0A7421C8F8E636 /* MMKernelTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMKernelTelemetryProvider.m; sourceTree = "<group>"; };
F5EC59DCDA5FABDA409CDBCB /* MacMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; };
F698B0CC35A89EDEA03F08A5 /* MMDisplayManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMDisplayManager.m; sourceTree = "<group>"; };
@@ -438,6 +440,7 @@
7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */,
52471E871E5CC1E444318070 /* MMProcessTests.swift */,
940D608A1AF80F75584E744E /* MMSMCTests.swift */,
EA79F8A259CA3056F9B131A9 /* MMSnapshotContractTests.swift */,
78325870C7CD8312AECA2236 /* MMStorageTests.swift */,
);
path = Tests;
@@ -618,6 +621,7 @@
3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */,
7C9AE2ADAED52E593B52F3AE /* MMProcessTests.swift in Sources */,
EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */,
2665E9DAE13C13239857B4BB /* MMSnapshotContractTests.swift in Sources */,
1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */,
9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */,
);
@@ -761,6 +765,7 @@
CODE_SIGNING_ALLOWED = NO;
CODE_SIGN_IDENTITY = "";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -774,6 +779,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.0.1;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = NO;
@@ -846,6 +852,7 @@
CODE_SIGNING_ALLOWED = NO;
CODE_SIGN_IDENTITY = "";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -865,6 +872,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.0.1;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = NO;
+1 -1
View File
@@ -158,7 +158,7 @@ git checkout develop
# Build Release binary for Intel x86_64
xcodebuild build \
-scheme MacMonitor \
-destination 'generic/platform=macOS,arch=x86_64' \
-destination 'platform=macOS,arch=x86_64' \
-configuration Release \
CODE_SIGNING_ALLOWED=NO
```
+48 -45
View File
@@ -319,18 +319,18 @@ public final class SystemTelemetryStore {
}
}
private func decodeSnapshot(_ snapshot: [String: [String: Any]]) {
func decodeSnapshot(_ snapshot: [String: [String: Any]]) {
// 1. CPU Load
if let cpuDict = snapshot["com.i3omb.macmonitor.telemetry.cpu"] {
if let cpuDict = snapshot["com.i3omb.macmonitor.telemetry.cpuload"] {
var m = CPULoadMetrics()
m.systemLoad = (cpuDict["systemLoad"] as? NSNumber)?.doubleValue ?? 0
m.userLoad = (cpuDict["userLoad"] as? NSNumber)?.doubleValue ?? 0
m.idleLoad = (cpuDict["idleLoad"] as? NSNumber)?.doubleValue ?? 100
m.totalLoad = (cpuDict["totalLoad"] as? NSNumber)?.doubleValue ?? 0
if let perCore = cpuDict["perCoreLoad"] as? [NSNumber] {
m.perCoreLoad = perCore.map { $0.doubleValue }
m.systemLoad = (cpuDict["systemPercent"] as? NSNumber)?.doubleValue ?? 0
m.userLoad = (cpuDict["userPercent"] as? NSNumber)?.doubleValue ?? 0
m.idleLoad = (cpuDict["idlePercent"] as? NSNumber)?.doubleValue ?? 100
m.totalLoad = (cpuDict["totalPercent"] as? NSNumber)?.doubleValue ?? 0
if let cores = cpuDict["cores"] as? [[String: Any]] {
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
self.cpuLoad = m
}
@@ -347,7 +347,8 @@ public final class SystemTelemetryStore {
m.compressedBytes = (memDict["compressedBytes"] as? NSNumber)?.uint64Value ?? 0
m.swapTotalBytes = (memDict["swapTotalBytes"] as? NSNumber)?.uint64Value ?? 0
m.swapUsedBytes = (memDict["swapUsedBytes"] as? NSNumber)?.uint64Value ?? 0
m.utilizationPercentage = (memDict["utilizationPercentage"] as? NSNumber)?.doubleValue ?? 0
m.utilizationPercentage = m.totalBytes > 0
? Double(m.usedBytes) / Double(m.totalBytes) * 100.0 : 0
m.pressureLevel = memDict["memoryPressureStatus"] as? String
?? memDict["pressureLevel"] as? String ?? "Normal"
self.memory = m
@@ -361,11 +362,11 @@ public final class SystemTelemetryStore {
return StorageVolumeItem(
mountPoint: 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,
freeBytes: (dict["freeBytes"] 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
)
}
@@ -394,7 +395,7 @@ public final class SystemTelemetryStore {
if let fanDict = snapshot["com.i3omb.macmonitor.telemetry.fan"],
let fanArray = fanDict["fans"] as? [[String: Any]] {
self.fans = fanArray.compactMap { d in
let idx = (d["index"] as? NSNumber)?.intValue ?? 0
let idx = (d["fanIndex"] as? NSNumber)?.intValue ?? 0
return FanTelemetryItem(
index: idx,
name: d["name"] as? String ?? "Fan \(idx + 1)",
@@ -402,7 +403,7 @@ public final class SystemTelemetryStore {
minRPM: (d["minRPM"] as? NSNumber)?.doubleValue ?? 0,
maxRPM: (d["maxRPM"] 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 +447,27 @@ public final class SystemTelemetryStore {
}
// 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()
m.contextSwitchesPerSec = (kDict["contextSwitchesPerSec"] as? NSNumber)?.doubleValue ?? 0
m.syscallsPerSec = (kDict["syscallsPerSec"] as? NSNumber)?.doubleValue ?? 0
m.pageFaultsPerSec = (kDict["pageFaultsPerSec"] as? NSNumber)?.doubleValue ?? 0
m.cowFaultsPerSec = (kDict["cowFaultsPerSec"] as? NSNumber)?.doubleValue ?? 0
m.zeroFillFaultsPerSec = (kDict["zeroFillFaultsPerSec"] as? NSNumber)?.doubleValue ?? 0
m.pageinsPerSec = (kDict["pageinsPerSec"] as? NSNumber)?.doubleValue ?? 0
m.pageoutsPerSec = (kDict["pageoutsPerSec"] as? NSNumber)?.doubleValue ?? 0
m.totalContextSwitches = (kDict["totalContextSwitches"] as? NSNumber)?.uint64Value ?? 0
m.totalSyscalls = (kDict["totalSyscalls"] as? NSNumber)?.uint64Value ?? 0
m.totalPageFaults = (kDict["totalPageFaults"] as? NSNumber)?.uint64Value ?? 0
m.contextSwitchesPerSec = (kDict["contextSwitchesRate"] as? NSNumber)?.doubleValue ?? 0
m.syscallsPerSec = (kDict["syscallsRate"] as? NSNumber)?.doubleValue ?? 0
m.pageFaultsPerSec = (kDict["pageFaultsRate"] as? NSNumber)?.doubleValue ?? 0
m.cowFaultsPerSec = (kDict["cowFaultsRate"] as? NSNumber)?.doubleValue ?? 0
m.zeroFillFaultsPerSec = (kDict["zeroFillsRate"] as? NSNumber)?.doubleValue ?? 0
m.pageinsPerSec = (kDict["pageinsRate"] as? NSNumber)?.doubleValue ?? 0
m.pageoutsPerSec = (kDict["pageoutsRate"] as? NSNumber)?.doubleValue ?? 0
m.totalContextSwitches = (kDict["cumulative_contextSwitches"] as? NSNumber)?.uint64Value ?? 0
m.totalSyscalls = (kDict["cumulative_syscalls"] as? NSNumber)?.uint64Value ?? 0
m.totalPageFaults = (kDict["cumulative_pageFaults"] as? NSNumber)?.uint64Value ?? 0
self.kernelCounters = m
}
// 9. System Load Average & Mach Factor
if let loadDict = snapshot["com.i3omb.macmonitor.telemetry.systemload"] {
if let loadDict = snapshot["com.i3omb.macmonitor.telemetry.system.load"] {
var m = SystemLoadMetrics()
m.load1Min = (loadDict["load1Min"] as? NSNumber)?.doubleValue ?? 0
m.load5Min = (loadDict["load5Min"] as? NSNumber)?.doubleValue ?? 0
m.load15Min = (loadDict["load15Min"] as? NSNumber)?.doubleValue ?? 0
m.load1Min = (loadDict["load1m"] as? NSNumber)?.doubleValue ?? 0
m.load5Min = (loadDict["load5m"] as? NSNumber)?.doubleValue ?? 0
m.load15Min = (loadDict["load15m"] as? NSNumber)?.doubleValue ?? 0
m.taskCount = (loadDict["taskCount"] as? NSNumber)?.intValue ?? 0
m.threadCount = (loadDict["threadCount"] as? NSNumber)?.intValue ?? 0
m.machFactor = (loadDict["machFactor"] as? NSNumber)?.doubleValue ?? 0
@@ -474,18 +475,18 @@ public final class SystemTelemetryStore {
}
// 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]] {
self.diskIO = disks.compactMap { d in
guard let bsd = d["bsdName"] as? String else { return nil }
return DiskIOItem(
bsdName: bsd,
readBps: (d["readBps"] as? NSNumber)?.doubleValue ?? 0,
writeBps: (d["writeBps"] as? NSNumber)?.doubleValue ?? 0,
readBps: (d["readBytesPerSec"] as? NSNumber)?.doubleValue ?? 0,
writeBps: (d["writeBytesPerSec"] as? NSNumber)?.doubleValue ?? 0,
readIOPS: (d["readIOPS"] as? NSNumber)?.doubleValue ?? 0,
writeIOPS: (d["writeIOPS"] as? NSNumber)?.doubleValue ?? 0,
totalBytesRead: (d["totalBytesRead"] as? NSNumber)?.uint64Value ?? 0,
totalBytesWritten: (d["totalBytesWritten"] as? NSNumber)?.uint64Value ?? 0
totalBytesRead: (d["cumulativeReadBytes"] as? NSNumber)?.uint64Value ?? 0,
totalBytesWritten: (d["cumulativeWriteBytes"] as? NSNumber)?.uint64Value ?? 0
)
}
}
@@ -494,17 +495,17 @@ public final class SystemTelemetryStore {
if let netDict = snapshot["com.i3omb.macmonitor.telemetry.network.bandwidth"],
let ifaces = netDict["interfaces"] as? [[String: Any]] {
self.networkBandwidth = ifaces.compactMap { d in
guard let name = d["interfaceName"] as? String else { return nil }
guard let name = d["name"] as? String else { return nil }
return NetworkBandwidthItem(
interfaceName: name,
ipAddress: d["ipAddress"] as? String ?? "",
ipAddress: d["ipv4Address"] as? String ?? "",
isUp: (d["isUp"] as? NSNumber)?.boolValue ?? false,
downloadBps: (d["downloadBps"] as? NSNumber)?.doubleValue ?? 0,
uploadBps: (d["uploadBps"] as? NSNumber)?.doubleValue ?? 0,
downloadPps: (d["downloadPps"] as? NSNumber)?.doubleValue ?? 0,
uploadPps: (d["uploadPps"] as? NSNumber)?.doubleValue ?? 0,
totalBytesIn: (d["totalBytesIn"] as? NSNumber)?.uint64Value ?? 0,
totalBytesOut: (d["totalBytesOut"] as? NSNumber)?.uint64Value ?? 0
downloadBps: (d["downloadBytesPerSec"] as? NSNumber)?.doubleValue ?? 0,
uploadBps: (d["uploadBytesPerSec"] as? NSNumber)?.doubleValue ?? 0,
downloadPps: (d["downloadPacketsPerSec"] as? NSNumber)?.doubleValue ?? 0,
uploadPps: (d["uploadPacketsPerSec"] as? NSNumber)?.doubleValue ?? 0,
totalBytesIn: (d["cumulativeInBytes"] as? NSNumber)?.uint64Value ?? 0,
totalBytesOut: (d["cumulativeOutBytes"] as? NSNumber)?.uint64Value ?? 0
)
}
}
@@ -539,8 +540,8 @@ public final class SystemTelemetryStore {
ppid: (d["ppid"] as? NSNumber)?.int32Value ?? 0,
name: d["name"] as? String ?? "Unknown",
cpuPercent: (d["cpuPercent"] as? NSNumber)?.doubleValue ?? 0,
residentSize: (d["residentSize"] as? NSNumber)?.uint64Value ?? 0,
virtualSize: (d["virtualSize"] as? NSNumber)?.uint64Value ?? 0,
residentSize: (d["residentBytes"] as? NSNumber)?.uint64Value ?? 0,
virtualSize: (d["virtualBytes"] as? NSNumber)?.uint64Value ?? 0,
threadCount: (d["threadCount"] as? NSNumber)?.int32Value ?? 1,
uid: (d["uid"] as? NSNumber)?.uint32Value ?? 0,
username: d["username"] as? String ?? "",
@@ -704,7 +705,9 @@ public final class SystemTelemetryStore {
: 0
)
}
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.onExternalPower = self.batteryHealth.externalConnected
context.isCharging = self.batteryHealth.isCharging || self.power.isCharging
+3 -3
View File
@@ -57,13 +57,13 @@
return YES;
}
CFMutableDictionaryRef matching = IOServiceMatching("AppleSMCClient");
CFMutableDictionaryRef matching = IOServiceMatching("AppleSMC");
if (!matching) {
os_unfair_lock_unlock(&_lock);
if (error) {
*error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC"
code:-1
userInfo:@{NSLocalizedDescriptionKey: @"Failed to create AppleSMCClient matching dictionary"}];
userInfo:@{NSLocalizedDescriptionKey: @"Failed to create AppleSMC matching dictionary"}];
}
return NO;
}
@@ -74,7 +74,7 @@
if (error) {
*error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC"
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;
}
-4
View File
@@ -15,8 +15,6 @@
#define kSMCGetKeyFromIndex 8
#define kSMCGetKeyInfo 9
#pragma pack(push, 1)
typedef struct {
unsigned char major;
unsigned char minor;
@@ -58,8 +56,6 @@ typedef struct {
UInt8 bytes[32];
} SMCVal_t;
#pragma pack(pop)
static inline UInt32 MMSMCToFourCharCode(const char * _Nonnull str) {
UInt32 code = 0;
for (int i = 0; i < 4 && str[i] != '\0'; i++) {
@@ -22,7 +22,7 @@ NS_ASSUME_NONNULL_BEGIN
/**
* 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
+15 -3
View File
@@ -63,7 +63,7 @@
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) {
return @{
@"fanCount": @(0),
@@ -78,7 +78,7 @@
double peakRPM = 0.0;
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 min = [raw[@"minRPM"] doubleValue];
double max = [raw[@"maxRPM"] doubleValue];
@@ -96,6 +96,7 @@
[resultList addObject:@{
@"fanIndex": @(i),
@"name": raw[@"name"] ?: [NSString stringWithFormat:@"Fan %lu", (unsigned long)(i + 1)],
@"currentRPM": @(current),
@"minRPM": @(min),
@"maxRPM": @(max),
@@ -120,20 +121,31 @@
}
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++) {
NSString *acKey = [NSString stringWithFormat:@"F%ldAc", (long)i];
NSString *mnKey = [NSString stringWithFormat:@"F%ldMn", (long)i];
NSString *mxKey = [NSString stringWithFormat:@"F%ldMx", (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 *mn = [_smcClient readNumericValueForKey:mnKey error:nil] ?: @(0.0);
NSNumber *mx = [_smcClient readNumericValueForKey:mxKey error:nil] ?: @(6000.0);
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:@{
@"name": fanName,
@"currentRPM": ac,
@"minRPM": mn,
@"maxRPM": mx,
@@ -21,7 +21,8 @@ NS_ASSUME_NONNULL_BEGIN
+ (NSDictionary<NSString *, id> *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)current
previousSnapshots:(nullable NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)previous
timeDelta:(NSTimeInterval)timeDelta
ipAddresses:(nullable NSDictionary<NSString *, NSString *> *)ipAddresses;
ipAddresses:(nullable NSDictionary<NSString *, NSString *> *)ipAddresses
interfaceUpFlags:(nullable NSDictionary<NSString *, NSNumber *> *)interfaceUpFlags;
@end
@@ -44,7 +44,8 @@
+ (NSDictionary<NSString *, id> *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)current
previousSnapshots:(nullable NSDictionary<NSString *, NSDictionary<NSString *, NSNumber *> *> *)previous
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 totalUpBps = 0.0;
double totalDownPps = 0.0;
@@ -99,9 +100,11 @@
}
NSString *ip = ipAddresses[name] ?: @"";
BOOL isUp = interfaceUpFlags[name] ? [interfaceUpFlags[name] boolValue] : NO;
[interfaces addObject:@{
@"name": name,
@"isUp": @(isUp),
@"downloadBytesPerSec": @(downBps),
@"uploadBytesPerSec": @(upBps),
@"downloadPacketsPerSec": @(downPps),
@@ -171,16 +174,18 @@
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 *, NSNumber *> *upDict = [NSMutableDictionary dictionary];
struct ifaddrs *ifap = NULL;
if (getifaddrs(&ifap) == 0) {
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) {
char ipBuf[INET_ADDRSTRLEN] = {0};
struct sockaddr_in *sin = (struct sockaddr_in *)ifa->ifa_addr;
if (inet_ntop(AF_INET, &sin->sin_addr, ipBuf, sizeof(ipBuf))) {
NSString *name = [NSString stringWithUTF8String:ifa->ifa_name];
ipDict[name] = [NSString stringWithUTF8String:ipBuf];
}
}
@@ -205,7 +210,8 @@
return [MMNetworkBandwidthProvider calculateBandwidthMetricsWithCurrentSnapshots:currentSnapshots
previousSnapshots:prev
timeDelta:timeDelta
ipAddresses:ipDict];
ipAddresses:ipDict
interfaceUpFlags:upDict];
}
@end
@@ -1,6 +1,7 @@
#import "MMProcessTelemetryProvider.h"
#import <libproc.h>
#import <sys/proc_info.h>
#import <sys/proc.h>
#import <pwd.h>
#import <signal.h>
#import <mach/mach_time.h>
@@ -127,7 +128,9 @@
@"residentBytes": @(tai.ptinfo.pti_resident_size),
@"virtualBytes": @(tai.ptinfo.pti_virtual_size),
@"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)
}];
}
+2 -1
View File
@@ -49,7 +49,8 @@ final class MMNetworkBandwidthTests: XCTestCase {
withCurrentSnapshots: curr,
previousSnapshots: prev,
timeDelta: timeDelta,
ipAddresses: ipDict
ipAddresses: ipDict,
interfaceUpFlags: ["en0": true, "lo0": true]
)
let totalDownBps = metrics["totalDownloadBytesPerSec"] as? Double ?? 0
+162
View File
@@ -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
View File
@@ -16,6 +16,8 @@ settings:
CLANG_ENABLE_OBJC_ARC: YES
CLANG_ENABLE_MODULES: YES
GENERATE_INFOPLIST_FILE: YES
MARKETING_VERSION: "0.0.1"
CURRENT_PROJECT_VERSION: "1"
CODE_SIGNING_ALLOWED: NO
CODE_SIGN_IDENTITY: ""
GCC_C_LANGUAGE_STANDARD: "gnu17"