Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d75873fa30 | ||
|
|
3095bd106a | ||
|
|
d0c286e7c8 | ||
|
|
807fcae6b2 | ||
|
|
0e5af9d70d |
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,9 +87,25 @@ public struct ContentView: View {
|
||||
}
|
||||
|
||||
Section("Intelligence") {
|
||||
Label("Alerts & Notifications", systemImage: "bell.badge")
|
||||
// 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")
|
||||
.badge(store.alertEngine.activeAlerts.isEmpty ? nil : Text("\(store.alertEngine.activeAlerts.count)"))
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user