Compare commits

...
Author SHA1 Message Date
gronod 9f2d0c58d8 Merge pull request 'release: v0.0.2 — M6 bugfix release' (#40) from develop into main
MacMonitor CI/CD Pipeline / Build & Test (Intel x86_64) (push) Successful in 49s
MacMonitor CI/CD Pipeline / Package Release Artifact (push) Successful in 1m4s
2026-09-08 15:19:51 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 60f4039013 chore(release): bump version to 0.0.2 (build 2)
M6 bugfix release: restores all telemetry sections (snapshot contract,
SMC service+struct fixes), alert false-positive filtering, and sidebar
selection.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 15:19:34 +01:00
gronod 125c489881 Merge pull request 'M6: Bugfix & Stability — milestone integration' (#39) from milestone/m6-bugfix-stability into develop 2026-09-08 15:18:52 +01:00
gronod d75873fa30 Merge pull request 'fix(ui): restore Alerts sidebar row selection (Issue #34)' (#38) from fix/34-alerts-sidebar-badge into milestone/m6-bugfix-stability 2026-09-08 15:18:25 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 3095bd106a fix(ui): restore Alerts sidebar row selection by replacing .badge (fixes #34)
Applying .badge after .tag on a List(selection:) row wrapped the tagged
label in a view that no longer carried the tag, making the "Alerts &
Notifications" row unselectable on macOS 14. Replaced with an explicit
Label + HStack row showing the active-alert count as a styled capsule —
tag stays on the row, count still renders when alerts are active.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 15:18:07 +01:00
gronod d0c286e7c8 Merge pull request 'fix(alerts): skip read-only/small volumes in low-storage rule (Issue #33)' (#37) from fix/33-readonly-storage-alerts into milestone/m6-bugfix-stability 2026-09-08 15:17:04 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 807fcae6b2 fix(alerts): skip read-only and trivially small volumes in low-storage rule (fixes #33)
Read-only mounts (DMG images like /Volumes/Ghostty, update snapshots) with
~0% free produced false-positive low-storage alerts. StorageVolumeContext
now carries isReadOnly and totalBytes; the rule skips read-only volumes and
volumes under 1 GB where thresholds are meaningless. Regression tests cover
both exclusions plus the normal trigger path.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 15:16:45 +01:00
gronod 0e5af9d70d Merge pull request 'fix(smc): correct IOKit service name + SMCKeyData_t layout (Issue #32)' (#36) from fix/32-smc-service-name into milestone/m6-bugfix-stability 2026-09-08 15:15:08 +01:00
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
9 changed files with 71 additions and 23 deletions
+4 -4
View File
@@ -765,7 +765,7 @@
CODE_SIGNING_ALLOWED = NO;
CODE_SIGN_IDENTITY = "";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -779,7 +779,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.0.1;
MARKETING_VERSION = 0.0.2;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = NO;
@@ -852,7 +852,7 @@
CODE_SIGNING_ALLOWED = NO;
CODE_SIGN_IDENTITY = "";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -872,7 +872,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.0.1;
MARKETING_VERSION = 0.0.2;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = NO;
+6 -2
View File
@@ -702,10 +702,14 @@ public final class SystemTelemetryStore {
freeBytes: $0.freeBytes,
freePercent: $0.totalBytes > 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.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++) {
+4 -1
View File
@@ -163,7 +163,10 @@ public final class AlertEngine {
// 4. Low storage space (per mounted volume)
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)
if freeGB < cfg.storageFreeGBThreshold || volume.freePercent < cfg.storageFreePercentThreshold {
alerts.append(SystemAlert(
+7 -1
View File
@@ -70,12 +70,18 @@ public struct StorageVolumeContext: Equatable, Sendable {
public let volumeName: String
public let freeBytes: UInt64
public let freePercent: Double
public let totalBytes: UInt64
/// Read-only mounts (DMG images, snapshots) are excluded from alerting.
public let isReadOnly: Bool
public init(mountPoint: String, volumeName: String, freeBytes: UInt64, freePercent: Double) {
public init(mountPoint: String, volumeName: String, freeBytes: UInt64, freePercent: Double,
totalBytes: UInt64 = 0, isReadOnly: Bool = false) {
self.mountPoint = mountPoint
self.volumeName = volumeName
self.freeBytes = freeBytes
self.freePercent = freePercent
self.totalBytes = totalBytes
self.isReadOnly = isReadOnly
}
}
+19 -3
View File
@@ -87,9 +87,25 @@ public struct ContentView: View {
}
Section("Intelligence") {
Label("Alerts & Notifications", systemImage: "bell.badge")
.tag("Alerts")
.badge(store.alertEngine.activeAlerts.isEmpty ? nil : Text("\(store.alertEngine.activeAlerts.count)"))
// Custom row instead of .badge() a badge applied after .tag
// detaches the tag from the list row and makes it unselectable.
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)
+26 -3
View File
@@ -128,8 +128,10 @@ final class MMAlertsTests: XCTestCase {
func testStorageRuleTriggersPerVolume() {
let volumes = [
StorageVolumeContext(mountPoint: "/", volumeName: "Macintosh HD", freeBytes: 5 * 1_073_741_824, freePercent: 5),
StorageVolumeContext(mountPoint: "/Volumes/Data", volumeName: "Data", freeBytes: 500 * 1_073_741_824, freePercent: 50)
StorageVolumeContext(mountPoint: "/", volumeName: "Macintosh HD", freeBytes: 5 * 1_073_741_824,
freePercent: 5, totalBytes: 500 * 1_073_741_824),
StorageVolumeContext(mountPoint: "/Volumes/Data", volumeName: "Data", freeBytes: 500 * 1_073_741_824,
freePercent: 50, totalBytes: 1_000_000_000_000)
]
engine.evaluate(context(volumes: volumes))
XCTAssertEqual(engine.activeAlerts.count, 1)
@@ -137,11 +139,32 @@ final class MMAlertsTests: XCTestCase {
}
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))
XCTAssertEqual(engine.activeAlerts.count, 1)
}
func testStorageRuleSkipsReadOnlyVolumes() {
// A read-only DMG mount (e.g. an app running from a mounted image)
// with ~0% free must not alert issue #33 false positive.
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Ghostty", volumeName: "Ghostty",
freeBytes: 1_048_576, freePercent: 0.1,
totalBytes: 500_000_000_000, isReadOnly: true)]
engine.evaluate(context(volumes: volumes))
XCTAssertTrue(engine.activeAlerts.isEmpty)
XCTAssertTrue(engine.history.isEmpty)
}
func testStorageRuleSkipsTinyVolumes() {
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Small", volumeName: "Small",
freeBytes: 1_048_576, freePercent: 0.5,
totalBytes: 200_000_000, isReadOnly: false)]
engine.evaluate(context(volumes: volumes))
XCTAssertTrue(engine.activeAlerts.isEmpty)
}
// MARK: Battery
func testBatteryRuleTriggersOnlyWhileDischarging() {
+2 -2
View File
@@ -16,8 +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"
MARKETING_VERSION: "0.0.2"
CURRENT_PROJECT_VERSION: "2"
CODE_SIGNING_ALLOWED: NO
CODE_SIGN_IDENTITY: ""
GCC_C_LANGUAGE_STANDARD: "gnu17"