Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f2d0c58d8 | ||
|
|
60f4039013 | ||
|
|
125c489881 | ||
|
|
d75873fa30 | ||
|
|
3095bd106a | ||
|
|
d0c286e7c8 | ||
|
|
807fcae6b2 | ||
|
|
0e5af9d70d |
@@ -765,7 +765,7 @@
|
|||||||
CODE_SIGNING_ALLOWED = NO;
|
CODE_SIGNING_ALLOWED = NO;
|
||||||
CODE_SIGN_IDENTITY = "";
|
CODE_SIGN_IDENTITY = "";
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
@@ -779,7 +779,7 @@
|
|||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 0.0.1;
|
MARKETING_VERSION = 0.0.2;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = NO;
|
ONLY_ACTIVE_ARCH = NO;
|
||||||
@@ -852,7 +852,7 @@
|
|||||||
CODE_SIGNING_ALLOWED = NO;
|
CODE_SIGNING_ALLOWED = NO;
|
||||||
CODE_SIGN_IDENTITY = "";
|
CODE_SIGN_IDENTITY = "";
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
@@ -872,7 +872,7 @@
|
|||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 0.0.1;
|
MARKETING_VERSION = 0.0.2;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = NO;
|
ONLY_ACTIVE_ARCH = NO;
|
||||||
|
|||||||
@@ -702,7 +702,9 @@ 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.batteryHealth.maxCapacity > 0
|
context.batteryLevel = self.batteryHealth.maxCapacity > 0
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
// 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")
|
.tag("Alerts")
|
||||||
.badge(store.alertEngine.activeAlerts.isEmpty ? nil : Text("\(store.alertEngine.activeAlerts.count)"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
+2
-2
@@ -16,8 +16,8 @@ settings:
|
|||||||
CLANG_ENABLE_OBJC_ARC: YES
|
CLANG_ENABLE_OBJC_ARC: YES
|
||||||
CLANG_ENABLE_MODULES: YES
|
CLANG_ENABLE_MODULES: YES
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
MARKETING_VERSION: "0.0.1"
|
MARKETING_VERSION: "0.0.2"
|
||||||
CURRENT_PROJECT_VERSION: "1"
|
CURRENT_PROJECT_VERSION: "2"
|
||||||
CODE_SIGNING_ALLOWED: NO
|
CODE_SIGNING_ALLOWED: NO
|
||||||
CODE_SIGN_IDENTITY: ""
|
CODE_SIGN_IDENTITY: ""
|
||||||
GCC_C_LANGUAGE_STANDARD: "gnu17"
|
GCC_C_LANGUAGE_STANDARD: "gnu17"
|
||||||
|
|||||||
Reference in New Issue
Block a user