Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d75873fa30 | ||
|
|
3095bd106a | ||
|
|
d0c286e7c8 | ||
|
|
807fcae6b2 | ||
|
|
0e5af9d70d | ||
|
|
acf5d03277 | ||
|
|
9933c87004 |
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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++) {
|
||||||
|
|||||||
@@ -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
|
||||||
.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)
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user