diff --git a/Sources/App/SystemTelemetryStore.swift b/Sources/App/SystemTelemetryStore.swift index 7deb773..af963af 100644 --- a/Sources/App/SystemTelemetryStore.swift +++ b/Sources/App/SystemTelemetryStore.swift @@ -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 diff --git a/Sources/Intelligence/AlertEngine.swift b/Sources/Intelligence/AlertEngine.swift index 3600c33..a1670e7 100644 --- a/Sources/Intelligence/AlertEngine.swift +++ b/Sources/Intelligence/AlertEngine.swift @@ -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( diff --git a/Sources/Intelligence/AlertModels.swift b/Sources/Intelligence/AlertModels.swift index c1135fc..a8a5290 100644 --- a/Sources/Intelligence/AlertModels.swift +++ b/Sources/Intelligence/AlertModels.swift @@ -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 } } diff --git a/Tests/MMAlertsTests.swift b/Tests/MMAlertsTests.swift index c30063a..353be56 100644 --- a/Tests/MMAlertsTests.swift +++ b/Tests/MMAlertsTests.swift @@ -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() {