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
5 changed files with 59 additions and 9 deletions
+3 -1
View File
@@ -702,7 +702,9 @@ 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.batteryHealth.maxCapacity > 0
+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() {