Compare commits

..
Author SHA1 Message Date
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
7 changed files with 65 additions and 17 deletions
+6 -2
View File
@@ -702,10 +702,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
+3 -3
View File
@@ -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;
} }
-4
View File
@@ -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++) {
+4 -1
View File
@@ -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(
+7 -1
View File
@@ -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
} }
} }
+19 -3
View File
@@ -87,9 +87,25 @@ 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)
+26 -3
View File
@@ -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() {