diff --git a/MacMonitor.xcodeproj/project.pbxproj b/MacMonitor.xcodeproj/project.pbxproj index 4767b62..40965a8 100644 --- a/MacMonitor.xcodeproj/project.pbxproj +++ b/MacMonitor.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78325870C7CD8312AECA2236 /* MMStorageTests.swift */; }; 1B3F1C8ABF0ADFAA290FF6F6 /* MMSMCParser.m in Sources */ = {isa = PBXBuildFile; fileRef = 375AA781A2AA0A20B119C7B7 /* MMSMCParser.m */; }; 22CA7351A745F7837E487051 /* MMPeripheralsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1486E1512594482380CB492 /* MMPeripheralsTests.swift */; }; + 2665E9DAE13C13239857B4BB /* MMSnapshotContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA79F8A259CA3056F9B131A9 /* MMSnapshotContractTests.swift */; }; 2C0F7AB93691ED8AA86FBCF0 /* MMProcessDetailInspector.m in Sources */ = {isa = PBXBuildFile; fileRef = 52E641F8A664D456518EDBA4 /* MMProcessDetailInspector.m */; }; 2E966D96347D1DDB24E18632 /* AlertModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B657679DB7D1F39AB4911B7B /* AlertModels.swift */; }; 3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */; }; @@ -167,6 +168,7 @@ E4B4E94FB2886C60D5548A2A /* SystemTelemetryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemTelemetryStore.swift; sourceTree = ""; }; E6D9BB71DFE7384D0F0E6AA3 /* MMKernelTelemetryProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MMKernelTelemetryProvider.h; sourceTree = ""; }; E9DAC4D5C09C974EE833DC7C /* MMDiskIOTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMDiskIOTests.swift; sourceTree = ""; }; + EA79F8A259CA3056F9B131A9 /* MMSnapshotContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMSnapshotContractTests.swift; sourceTree = ""; }; F444F4543F0A7421C8F8E636 /* MMKernelTelemetryProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMKernelTelemetryProvider.m; sourceTree = ""; }; F5EC59DCDA5FABDA409CDBCB /* MacMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; }; F698B0CC35A89EDEA03F08A5 /* MMDisplayManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MMDisplayManager.m; sourceTree = ""; }; @@ -438,6 +440,7 @@ 7187632FF63B3B0D07FE9194 /* MMProcessDetailsTests.swift */, 52471E871E5CC1E444318070 /* MMProcessTests.swift */, 940D608A1AF80F75584E744E /* MMSMCTests.swift */, + EA79F8A259CA3056F9B131A9 /* MMSnapshotContractTests.swift */, 78325870C7CD8312AECA2236 /* MMStorageTests.swift */, ); path = Tests; @@ -618,6 +621,7 @@ 3BA6709B727B1AAC6C4310B5 /* MMProcessDetailsTests.swift in Sources */, 7C9AE2ADAED52E593B52F3AE /* MMProcessTests.swift in Sources */, EC9FA3E4A119277B89E73F8D /* MMSMCTests.swift in Sources */, + 2665E9DAE13C13239857B4BB /* MMSnapshotContractTests.swift in Sources */, 1B15F48F6B19E9EA4556ECD5 /* MMStorageTests.swift in Sources */, 9BCE74C5454F81EF80492E75 /* MacMonitorTests.swift in Sources */, ); diff --git a/Sources/App/SystemTelemetryStore.swift b/Sources/App/SystemTelemetryStore.swift index f56bc28..af963af 100644 --- a/Sources/App/SystemTelemetryStore.swift +++ b/Sources/App/SystemTelemetryStore.swift @@ -319,18 +319,18 @@ public final class SystemTelemetryStore { } } - private func decodeSnapshot(_ snapshot: [String: [String: Any]]) { + func decodeSnapshot(_ snapshot: [String: [String: Any]]) { // 1. CPU Load - if let cpuDict = snapshot["com.i3omb.macmonitor.telemetry.cpu"] { + if let cpuDict = snapshot["com.i3omb.macmonitor.telemetry.cpuload"] { var m = CPULoadMetrics() - m.systemLoad = (cpuDict["systemLoad"] as? NSNumber)?.doubleValue ?? 0 - m.userLoad = (cpuDict["userLoad"] as? NSNumber)?.doubleValue ?? 0 - m.idleLoad = (cpuDict["idleLoad"] as? NSNumber)?.doubleValue ?? 100 - m.totalLoad = (cpuDict["totalLoad"] as? NSNumber)?.doubleValue ?? 0 - if let perCore = cpuDict["perCoreLoad"] as? [NSNumber] { - m.perCoreLoad = perCore.map { $0.doubleValue } + m.systemLoad = (cpuDict["systemPercent"] as? NSNumber)?.doubleValue ?? 0 + m.userLoad = (cpuDict["userPercent"] as? NSNumber)?.doubleValue ?? 0 + m.idleLoad = (cpuDict["idlePercent"] as? NSNumber)?.doubleValue ?? 100 + m.totalLoad = (cpuDict["totalPercent"] as? NSNumber)?.doubleValue ?? 0 + if let cores = cpuDict["cores"] as? [[String: Any]] { + m.perCoreLoad = cores.map { ($0["totalPercent"] as? NSNumber)?.doubleValue ?? 0 } } - m.cpuFrequencyMHz = (cpuDict["cpuFrequencyMHz"] as? NSNumber)?.doubleValue ?? 0 + m.cpuFrequencyMHz = ((cpuDict["frequencyHz"] as? NSNumber)?.doubleValue ?? 0) / 1e6 m.isThrottled = (cpuDict["isThrottled"] as? NSNumber)?.boolValue ?? false self.cpuLoad = m } @@ -347,7 +347,8 @@ public final class SystemTelemetryStore { m.compressedBytes = (memDict["compressedBytes"] as? NSNumber)?.uint64Value ?? 0 m.swapTotalBytes = (memDict["swapTotalBytes"] as? NSNumber)?.uint64Value ?? 0 m.swapUsedBytes = (memDict["swapUsedBytes"] as? NSNumber)?.uint64Value ?? 0 - m.utilizationPercentage = (memDict["utilizationPercentage"] as? NSNumber)?.doubleValue ?? 0 + m.utilizationPercentage = m.totalBytes > 0 + ? Double(m.usedBytes) / Double(m.totalBytes) * 100.0 : 0 m.pressureLevel = memDict["memoryPressureStatus"] as? String ?? memDict["pressureLevel"] as? String ?? "Normal" self.memory = m @@ -361,11 +362,11 @@ public final class SystemTelemetryStore { return StorageVolumeItem( mountPoint: mount, volumeName: dict["volumeName"] as? String ?? mount, - fileSystem: dict["fileSystem"] as? String ?? "APFS", + fileSystem: dict["fsType"] as? String ?? "APFS", totalBytes: (dict["totalBytes"] as? NSNumber)?.uint64Value ?? 0, freeBytes: (dict["freeBytes"] as? NSNumber)?.uint64Value ?? 0, usedBytes: (dict["usedBytes"] as? NSNumber)?.uint64Value ?? 0, - usedPercentage: (dict["usedPercentage"] as? NSNumber)?.doubleValue ?? 0, + usedPercentage: (dict["usedPercent"] as? NSNumber)?.doubleValue ?? 0, isReadOnly: (dict["isReadOnly"] as? NSNumber)?.boolValue ?? false ) } @@ -394,7 +395,7 @@ public final class SystemTelemetryStore { if let fanDict = snapshot["com.i3omb.macmonitor.telemetry.fan"], let fanArray = fanDict["fans"] as? [[String: Any]] { self.fans = fanArray.compactMap { d in - let idx = (d["index"] as? NSNumber)?.intValue ?? 0 + let idx = (d["fanIndex"] as? NSNumber)?.intValue ?? 0 return FanTelemetryItem( index: idx, name: d["name"] as? String ?? "Fan \(idx + 1)", @@ -402,7 +403,7 @@ public final class SystemTelemetryStore { minRPM: (d["minRPM"] as? NSNumber)?.doubleValue ?? 0, maxRPM: (d["maxRPM"] as? NSNumber)?.doubleValue ?? 0, targetRPM: (d["targetRPM"] as? NSNumber)?.doubleValue ?? 0, - utilization: (d["utilization"] as? NSNumber)?.doubleValue ?? 0 + utilization: (d["utilizationPercent"] as? NSNumber)?.doubleValue ?? 0 ) } } @@ -446,27 +447,27 @@ public final class SystemTelemetryStore { } // 8. Kernel Counters - if let kDict = snapshot["com.i3omb.macmonitor.telemetry.kernel"] { + if let kDict = snapshot["com.i3omb.macmonitor.telemetry.kernel.counters"] { var m = KernelCountersMetrics() - m.contextSwitchesPerSec = (kDict["contextSwitchesPerSec"] as? NSNumber)?.doubleValue ?? 0 - m.syscallsPerSec = (kDict["syscallsPerSec"] as? NSNumber)?.doubleValue ?? 0 - m.pageFaultsPerSec = (kDict["pageFaultsPerSec"] as? NSNumber)?.doubleValue ?? 0 - m.cowFaultsPerSec = (kDict["cowFaultsPerSec"] as? NSNumber)?.doubleValue ?? 0 - m.zeroFillFaultsPerSec = (kDict["zeroFillFaultsPerSec"] as? NSNumber)?.doubleValue ?? 0 - m.pageinsPerSec = (kDict["pageinsPerSec"] as? NSNumber)?.doubleValue ?? 0 - m.pageoutsPerSec = (kDict["pageoutsPerSec"] as? NSNumber)?.doubleValue ?? 0 - m.totalContextSwitches = (kDict["totalContextSwitches"] as? NSNumber)?.uint64Value ?? 0 - m.totalSyscalls = (kDict["totalSyscalls"] as? NSNumber)?.uint64Value ?? 0 - m.totalPageFaults = (kDict["totalPageFaults"] as? NSNumber)?.uint64Value ?? 0 + m.contextSwitchesPerSec = (kDict["contextSwitchesRate"] as? NSNumber)?.doubleValue ?? 0 + m.syscallsPerSec = (kDict["syscallsRate"] as? NSNumber)?.doubleValue ?? 0 + m.pageFaultsPerSec = (kDict["pageFaultsRate"] as? NSNumber)?.doubleValue ?? 0 + m.cowFaultsPerSec = (kDict["cowFaultsRate"] as? NSNumber)?.doubleValue ?? 0 + m.zeroFillFaultsPerSec = (kDict["zeroFillsRate"] as? NSNumber)?.doubleValue ?? 0 + m.pageinsPerSec = (kDict["pageinsRate"] as? NSNumber)?.doubleValue ?? 0 + m.pageoutsPerSec = (kDict["pageoutsRate"] as? NSNumber)?.doubleValue ?? 0 + m.totalContextSwitches = (kDict["cumulative_contextSwitches"] as? NSNumber)?.uint64Value ?? 0 + m.totalSyscalls = (kDict["cumulative_syscalls"] as? NSNumber)?.uint64Value ?? 0 + m.totalPageFaults = (kDict["cumulative_pageFaults"] as? NSNumber)?.uint64Value ?? 0 self.kernelCounters = m } // 9. System Load Average & Mach Factor - if let loadDict = snapshot["com.i3omb.macmonitor.telemetry.systemload"] { + if let loadDict = snapshot["com.i3omb.macmonitor.telemetry.system.load"] { var m = SystemLoadMetrics() - m.load1Min = (loadDict["load1Min"] as? NSNumber)?.doubleValue ?? 0 - m.load5Min = (loadDict["load5Min"] as? NSNumber)?.doubleValue ?? 0 - m.load15Min = (loadDict["load15Min"] as? NSNumber)?.doubleValue ?? 0 + m.load1Min = (loadDict["load1m"] as? NSNumber)?.doubleValue ?? 0 + m.load5Min = (loadDict["load5m"] as? NSNumber)?.doubleValue ?? 0 + m.load15Min = (loadDict["load15m"] as? NSNumber)?.doubleValue ?? 0 m.taskCount = (loadDict["taskCount"] as? NSNumber)?.intValue ?? 0 m.threadCount = (loadDict["threadCount"] as? NSNumber)?.intValue ?? 0 m.machFactor = (loadDict["machFactor"] as? NSNumber)?.doubleValue ?? 0 @@ -474,18 +475,18 @@ public final class SystemTelemetryStore { } // 10. Disk I/O & IOPS - if let diskDict = snapshot["com.i3omb.macmonitor.telemetry.diskio"], + if let diskDict = snapshot["com.i3omb.macmonitor.telemetry.storage.io"], let disks = diskDict["disks"] as? [[String: Any]] { self.diskIO = disks.compactMap { d in guard let bsd = d["bsdName"] as? String else { return nil } return DiskIOItem( bsdName: bsd, - readBps: (d["readBps"] as? NSNumber)?.doubleValue ?? 0, - writeBps: (d["writeBps"] as? NSNumber)?.doubleValue ?? 0, + readBps: (d["readBytesPerSec"] as? NSNumber)?.doubleValue ?? 0, + writeBps: (d["writeBytesPerSec"] as? NSNumber)?.doubleValue ?? 0, readIOPS: (d["readIOPS"] as? NSNumber)?.doubleValue ?? 0, writeIOPS: (d["writeIOPS"] as? NSNumber)?.doubleValue ?? 0, - totalBytesRead: (d["totalBytesRead"] as? NSNumber)?.uint64Value ?? 0, - totalBytesWritten: (d["totalBytesWritten"] as? NSNumber)?.uint64Value ?? 0 + totalBytesRead: (d["cumulativeReadBytes"] as? NSNumber)?.uint64Value ?? 0, + totalBytesWritten: (d["cumulativeWriteBytes"] as? NSNumber)?.uint64Value ?? 0 ) } } @@ -494,17 +495,17 @@ public final class SystemTelemetryStore { if let netDict = snapshot["com.i3omb.macmonitor.telemetry.network.bandwidth"], let ifaces = netDict["interfaces"] as? [[String: Any]] { self.networkBandwidth = ifaces.compactMap { d in - guard let name = d["interfaceName"] as? String else { return nil } + guard let name = d["name"] as? String else { return nil } return NetworkBandwidthItem( interfaceName: name, - ipAddress: d["ipAddress"] as? String ?? "", + ipAddress: d["ipv4Address"] as? String ?? "", isUp: (d["isUp"] as? NSNumber)?.boolValue ?? false, - downloadBps: (d["downloadBps"] as? NSNumber)?.doubleValue ?? 0, - uploadBps: (d["uploadBps"] as? NSNumber)?.doubleValue ?? 0, - downloadPps: (d["downloadPps"] as? NSNumber)?.doubleValue ?? 0, - uploadPps: (d["uploadPps"] as? NSNumber)?.doubleValue ?? 0, - totalBytesIn: (d["totalBytesIn"] as? NSNumber)?.uint64Value ?? 0, - totalBytesOut: (d["totalBytesOut"] as? NSNumber)?.uint64Value ?? 0 + downloadBps: (d["downloadBytesPerSec"] as? NSNumber)?.doubleValue ?? 0, + uploadBps: (d["uploadBytesPerSec"] as? NSNumber)?.doubleValue ?? 0, + downloadPps: (d["downloadPacketsPerSec"] as? NSNumber)?.doubleValue ?? 0, + uploadPps: (d["uploadPacketsPerSec"] as? NSNumber)?.doubleValue ?? 0, + totalBytesIn: (d["cumulativeInBytes"] as? NSNumber)?.uint64Value ?? 0, + totalBytesOut: (d["cumulativeOutBytes"] as? NSNumber)?.uint64Value ?? 0 ) } } @@ -539,8 +540,8 @@ public final class SystemTelemetryStore { ppid: (d["ppid"] as? NSNumber)?.int32Value ?? 0, name: d["name"] as? String ?? "Unknown", cpuPercent: (d["cpuPercent"] as? NSNumber)?.doubleValue ?? 0, - residentSize: (d["residentSize"] as? NSNumber)?.uint64Value ?? 0, - virtualSize: (d["virtualSize"] as? NSNumber)?.uint64Value ?? 0, + residentSize: (d["residentBytes"] as? NSNumber)?.uint64Value ?? 0, + virtualSize: (d["virtualBytes"] as? NSNumber)?.uint64Value ?? 0, threadCount: (d["threadCount"] as? NSNumber)?.int32Value ?? 1, uid: (d["uid"] as? NSNumber)?.uint32Value ?? 0, username: d["username"] as? String ?? "", @@ -701,10 +702,14 @@ 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.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.onExternalPower = self.batteryHealth.externalConnected context.isCharging = self.batteryHealth.isCharging || self.power.isCharging diff --git a/Sources/Hardware/AppleSMC/MMAppleSMCClient.m b/Sources/Hardware/AppleSMC/MMAppleSMCClient.m index cd17faa..56525c8 100644 --- a/Sources/Hardware/AppleSMC/MMAppleSMCClient.m +++ b/Sources/Hardware/AppleSMC/MMAppleSMCClient.m @@ -57,13 +57,13 @@ return YES; } - CFMutableDictionaryRef matching = IOServiceMatching("AppleSMCClient"); + CFMutableDictionaryRef matching = IOServiceMatching("AppleSMC"); if (!matching) { os_unfair_lock_unlock(&_lock); if (error) { *error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC" code:-1 - userInfo:@{NSLocalizedDescriptionKey: @"Failed to create AppleSMCClient matching dictionary"}]; + userInfo:@{NSLocalizedDescriptionKey: @"Failed to create AppleSMC matching dictionary"}]; } return NO; } @@ -74,7 +74,7 @@ if (error) { *error = [NSError errorWithDomain:@"com.i3omb.MacMonitor.SMC" 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; } diff --git a/Sources/Hardware/AppleSMC/MMSMCDefines.h b/Sources/Hardware/AppleSMC/MMSMCDefines.h index 39c5123..fc993be 100644 --- a/Sources/Hardware/AppleSMC/MMSMCDefines.h +++ b/Sources/Hardware/AppleSMC/MMSMCDefines.h @@ -15,8 +15,6 @@ #define kSMCGetKeyFromIndex 8 #define kSMCGetKeyInfo 9 -#pragma pack(push, 1) - typedef struct { unsigned char major; unsigned char minor; @@ -58,8 +56,6 @@ typedef struct { UInt8 bytes[32]; } SMCVal_t; -#pragma pack(pop) - static inline UInt32 MMSMCToFourCharCode(const char * _Nonnull str) { UInt32 code = 0; for (int i = 0; i < 4 && str[i] != '\0'; i++) { 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/Sources/Telemetry/Fan/MMFanTelemetryProvider.h b/Sources/Telemetry/Fan/MMFanTelemetryProvider.h index 86844b3..2c9583a 100644 --- a/Sources/Telemetry/Fan/MMFanTelemetryProvider.h +++ b/Sources/Telemetry/Fan/MMFanTelemetryProvider.h @@ -22,7 +22,7 @@ NS_ASSUME_NONNULL_BEGIN /** * Static calculator method for deterministic unit testing. */ -+ (NSDictionary *)calculateFanMetricsFromFanList:(NSArray *> *)fanList; ++ (NSDictionary *)calculateFanMetricsFromFanList:(NSArray *> *)fanList; @end diff --git a/Sources/Telemetry/Fan/MMFanTelemetryProvider.m b/Sources/Telemetry/Fan/MMFanTelemetryProvider.m index 1fd8604..9fa845d 100644 --- a/Sources/Telemetry/Fan/MMFanTelemetryProvider.m +++ b/Sources/Telemetry/Fan/MMFanTelemetryProvider.m @@ -63,7 +63,7 @@ return (_probedFanCount >= 0) ? _probedFanCount : 0; } -+ (NSDictionary *)calculateFanMetricsFromFanList:(NSArray *> *)fanList { ++ (NSDictionary *)calculateFanMetricsFromFanList:(NSArray *> *)fanList { if (!fanList || fanList.count == 0) { return @{ @"fanCount": @(0), @@ -78,7 +78,7 @@ double peakRPM = 0.0; for (NSUInteger i = 0; i < fanList.count; i++) { - NSDictionary *raw = fanList[i]; + NSDictionary *raw = fanList[i]; double current = [raw[@"currentRPM"] doubleValue]; double min = [raw[@"minRPM"] doubleValue]; double max = [raw[@"maxRPM"] doubleValue]; @@ -96,6 +96,7 @@ [resultList addObject:@{ @"fanIndex": @(i), + @"name": raw[@"name"] ?: [NSString stringWithFormat:@"Fan %lu", (unsigned long)(i + 1)], @"currentRPM": @(current), @"minRPM": @(min), @"maxRPM": @(max), @@ -120,20 +121,31 @@ } NSInteger count = [self fanCount]; - NSMutableArray *> *rawList = [NSMutableArray arrayWithCapacity:count]; + NSMutableArray *> *rawList = [NSMutableArray arrayWithCapacity:count]; for (NSInteger i = 0; i < count; i++) { NSString *acKey = [NSString stringWithFormat:@"F%ldAc", (long)i]; NSString *mnKey = [NSString stringWithFormat:@"F%ldMn", (long)i]; NSString *mxKey = [NSString stringWithFormat:@"F%ldMx", (long)i]; NSString *tgKey = [NSString stringWithFormat:@"F%ldTg", (long)i]; + NSString *idKey = [NSString stringWithFormat:@"F%ldID", (long)i]; NSNumber *ac = [_smcClient readNumericValueForKey:acKey error:nil] ?: @(0.0); NSNumber *mn = [_smcClient readNumericValueForKey:mnKey error:nil] ?: @(0.0); NSNumber *mx = [_smcClient readNumericValueForKey:mxKey error:nil] ?: @(6000.0); NSNumber *tg = [_smcClient readNumericValueForKey:tgKey error:nil] ?: ac; + NSString *fanName = [NSString stringWithFormat:@"Fan %ld", (long)(i + 1)]; + NSData *idData = [_smcClient readBytesForKey:idKey error:nil]; + if (idData.length > 0) { + NSString *raw = [[NSString alloc] initWithData:idData encoding:NSASCIIStringEncoding]; + NSString *trimmed = [raw stringByTrimmingCharactersInSet: + [NSCharacterSet characterSetWithCharactersInString:@"\0 "]]; + if (trimmed.length > 0) fanName = trimmed; + } + [rawList addObject:@{ + @"name": fanName, @"currentRPM": ac, @"minRPM": mn, @"maxRPM": mx, diff --git a/Sources/Telemetry/Network/MMNetworkBandwidthProvider.h b/Sources/Telemetry/Network/MMNetworkBandwidthProvider.h index d91e8fb..ab92681 100644 --- a/Sources/Telemetry/Network/MMNetworkBandwidthProvider.h +++ b/Sources/Telemetry/Network/MMNetworkBandwidthProvider.h @@ -21,7 +21,8 @@ NS_ASSUME_NONNULL_BEGIN + (NSDictionary *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary *> *)current previousSnapshots:(nullable NSDictionary *> *)previous timeDelta:(NSTimeInterval)timeDelta - ipAddresses:(nullable NSDictionary *)ipAddresses; + ipAddresses:(nullable NSDictionary *)ipAddresses + interfaceUpFlags:(nullable NSDictionary *)interfaceUpFlags; @end diff --git a/Sources/Telemetry/Network/MMNetworkBandwidthProvider.m b/Sources/Telemetry/Network/MMNetworkBandwidthProvider.m index 582cb36..fcd3f40 100644 --- a/Sources/Telemetry/Network/MMNetworkBandwidthProvider.m +++ b/Sources/Telemetry/Network/MMNetworkBandwidthProvider.m @@ -44,7 +44,8 @@ + (NSDictionary *)calculateBandwidthMetricsWithCurrentSnapshots:(NSDictionary *> *)current previousSnapshots:(nullable NSDictionary *> *)previous timeDelta:(NSTimeInterval)timeDelta - ipAddresses:(nullable NSDictionary *)ipAddresses { + ipAddresses:(nullable NSDictionary *)ipAddresses + interfaceUpFlags:(nullable NSDictionary *)interfaceUpFlags { double totalDownBps = 0.0; double totalUpBps = 0.0; double totalDownPps = 0.0; @@ -99,9 +100,11 @@ } NSString *ip = ipAddresses[name] ?: @""; + BOOL isUp = interfaceUpFlags[name] ? [interfaceUpFlags[name] boolValue] : NO; [interfaces addObject:@{ @"name": name, + @"isUp": @(isUp), @"downloadBytesPerSec": @(downBps), @"uploadBytesPerSec": @(upBps), @"downloadPacketsPerSec": @(downPps), @@ -171,16 +174,18 @@ if (buf) free(buf); } - // 2. Query IPv4 addresses via getifaddrs + // 2. Query IPv4 addresses and interface flags via getifaddrs NSMutableDictionary *ipDict = [NSMutableDictionary dictionary]; + NSMutableDictionary *upDict = [NSMutableDictionary dictionary]; struct ifaddrs *ifap = NULL; if (getifaddrs(&ifap) == 0) { for (struct ifaddrs *ifa = ifap; ifa != NULL; ifa = ifa->ifa_next) { + NSString *name = [NSString stringWithUTF8String:ifa->ifa_name]; + upDict[name] = @((ifa->ifa_flags & IFF_UP) != 0); if (ifa->ifa_addr && ifa->ifa_addr->sa_family == AF_INET) { char ipBuf[INET_ADDRSTRLEN] = {0}; struct sockaddr_in *sin = (struct sockaddr_in *)ifa->ifa_addr; if (inet_ntop(AF_INET, &sin->sin_addr, ipBuf, sizeof(ipBuf))) { - NSString *name = [NSString stringWithUTF8String:ifa->ifa_name]; ipDict[name] = [NSString stringWithUTF8String:ipBuf]; } } @@ -205,7 +210,8 @@ return [MMNetworkBandwidthProvider calculateBandwidthMetricsWithCurrentSnapshots:currentSnapshots previousSnapshots:prev timeDelta:timeDelta - ipAddresses:ipDict]; + ipAddresses:ipDict + interfaceUpFlags:upDict]; } @end diff --git a/Sources/Telemetry/Process/MMProcessTelemetryProvider.m b/Sources/Telemetry/Process/MMProcessTelemetryProvider.m index bc82fa6..953d20e 100644 --- a/Sources/Telemetry/Process/MMProcessTelemetryProvider.m +++ b/Sources/Telemetry/Process/MMProcessTelemetryProvider.m @@ -1,6 +1,7 @@ #import "MMProcessTelemetryProvider.h" #import #import +#import #import #import #import @@ -127,7 +128,9 @@ @"residentBytes": @(tai.ptinfo.pti_resident_size), @"virtualBytes": @(tai.ptinfo.pti_virtual_size), @"threadCount": @(tai.ptinfo.pti_threadnum), - @"runningThreads": @(tai.ptinfo.pti_numrunning) + @"runningThreads": @(tai.ptinfo.pti_numrunning), + @"isStopped": @(tai.pbsd.pbi_status == SSTOP), + @"isZombie": @(tai.pbsd.pbi_status == SZOMB) }]; } diff --git a/Sources/UI/ContentView.swift b/Sources/UI/ContentView.swift index 24420af..5e76b6f 100644 --- a/Sources/UI/ContentView.swift +++ b/Sources/UI/ContentView.swift @@ -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) 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() { diff --git a/Tests/MMNetworkBandwidthTests.swift b/Tests/MMNetworkBandwidthTests.swift index c31967e..8c05cd6 100644 --- a/Tests/MMNetworkBandwidthTests.swift +++ b/Tests/MMNetworkBandwidthTests.swift @@ -49,7 +49,8 @@ final class MMNetworkBandwidthTests: XCTestCase { withCurrentSnapshots: curr, previousSnapshots: prev, timeDelta: timeDelta, - ipAddresses: ipDict + ipAddresses: ipDict, + interfaceUpFlags: ["en0": true, "lo0": true] ) let totalDownBps = metrics["totalDownloadBytesPerSec"] as? Double ?? 0 diff --git a/Tests/MMSnapshotContractTests.swift b/Tests/MMSnapshotContractTests.swift new file mode 100644 index 0000000..f7b15bd --- /dev/null +++ b/Tests/MMSnapshotContractTests.swift @@ -0,0 +1,162 @@ +import XCTest +@testable import MacMonitor + +/// Snapshot contract conformance tests (Issues #30 / #31). +/// Feeds synthetic dictionaries keyed exactly as the Objective-C providers emit them +/// through `SystemTelemetryStore.decodeSnapshot` and asserts the decoded Swift models +/// populate — guarding against provider-identifier and field-name drift. +@MainActor +final class MMSnapshotContractTests: XCTestCase { + + private var store: SystemTelemetryStore { SystemTelemetryStore.shared } + + // MARK: - Provider identifier coverage (#30) + + func testEmittedProviderIdentifiersAreDecoded() { + // The four identifiers that previously never reached the decoder. + let snapshot: [String: [String: Any]] = [ + "com.i3omb.macmonitor.telemetry.cpuload": [ + "userPercent": 25.0, "systemPercent": 15.0, + "idlePercent": 60.0, "totalPercent": 40.0, + "cores": [["totalPercent": 10.0], ["totalPercent": 90.0]], + "frequencyHz": 2.4e9, "isThrottled": false + ], + "com.i3omb.macmonitor.telemetry.kernel.counters": [ + "contextSwitchesRate": 1234.5, "syscallsRate": 9876.5, + "pageFaultsRate": 42.0, "cowFaultsRate": 3.0, + "zeroFillsRate": 7.0, "pageinsRate": 1.0, "pageoutsRate": 0.5, + "cumulative_contextSwitches": 999_999 as UInt64, + "cumulative_syscalls": 888_888 as UInt64, + "cumulative_pageFaults": 777_777 as UInt64 + ], + "com.i3omb.macmonitor.telemetry.system.load": [ + "load1m": 1.5, "load5m": 2.0, "load15m": 2.5, + "taskCount": 400, "threadCount": 2000, "machFactor": 0.75 + ], + "com.i3omb.macmonitor.telemetry.storage.io": [ + "disks": [[ + "bsdName": "disk0", + "readBytesPerSec": 1_048_576.0, "writeBytesPerSec": 524_288.0, + "readIOPS": 120.0, "writeIOPS": 60.0, + "cumulativeReadBytes": 9_999_999 as UInt64, + "cumulativeWriteBytes": 8_888_888 as UInt64 + ]] + ] + ] + + store.decodeSnapshot(snapshot) + + XCTAssertEqual(store.cpuLoad.userLoad, 25.0, accuracy: 0.001) + XCTAssertEqual(store.cpuLoad.systemLoad, 15.0, accuracy: 0.001) + XCTAssertEqual(store.cpuLoad.totalLoad, 40.0, accuracy: 0.001) + XCTAssertEqual(store.cpuLoad.perCoreLoad, [10.0, 90.0]) + XCTAssertEqual(store.cpuLoad.cpuFrequencyMHz, 2400.0, accuracy: 0.001) + + XCTAssertEqual(store.kernelCounters.contextSwitchesPerSec, 1234.5, accuracy: 0.001) + XCTAssertEqual(store.kernelCounters.syscallsPerSec, 9876.5, accuracy: 0.001) + XCTAssertEqual(store.kernelCounters.totalContextSwitches, 999_999) + XCTAssertEqual(store.kernelCounters.totalSyscalls, 888_888) + XCTAssertEqual(store.kernelCounters.totalPageFaults, 777_777) + + XCTAssertEqual(store.systemLoad.load1Min, 1.5, accuracy: 0.001) + XCTAssertEqual(store.systemLoad.load5Min, 2.0, accuracy: 0.001) + XCTAssertEqual(store.systemLoad.load15Min, 2.5, accuracy: 0.001) + XCTAssertEqual(store.systemLoad.taskCount, 400) + XCTAssertEqual(store.systemLoad.threadCount, 2000) + + XCTAssertEqual(store.diskIO.count, 1) + XCTAssertEqual(store.diskIO[0].bsdName, "disk0") + XCTAssertEqual(store.diskIO[0].readBps, 1_048_576.0, accuracy: 0.001) + XCTAssertEqual(store.diskIO[0].totalBytesRead, 9_999_999) + } + + // MARK: - Field-name drift (#31) + + func testMemoryUtilizationComputedFromUsedOverTotal() { + store.decodeSnapshot([ + "com.i3omb.macmonitor.telemetry.memory": [ + "totalBytes": 16_000_000_000 as UInt64, + "usedBytes": 8_000_000_000 as UInt64, + "freeBytes": 8_000_000_000 as UInt64, + "memoryPressureStatus": "Normal" + ] + ]) + XCTAssertEqual(store.memory.utilizationPercentage, 50.0, accuracy: 0.001) + } + + func testStorageVolumeEmitsUsedPercentAndFSType() { + store.decodeSnapshot([ + "com.i3omb.macmonitor.telemetry.storage": [ + "volumes": [[ + "volumeName": "Macintosh HD", "mountPoint": "/", + "devicePath": "/dev/disk1s1", "fsType": "apfs", + "totalBytes": 500_000_000_000 as UInt64, + "usedBytes": 250_000_000_000 as UInt64, + "freeBytes": 250_000_000_000 as UInt64, + "usedPercent": 50.0, "isReadOnly": false + ]] + ] + ]) + XCTAssertEqual(store.storageVolumes.count, 1) + XCTAssertEqual(store.storageVolumes[0].usedPercentage, 50.0, accuracy: 0.001) + XCTAssertEqual(store.storageVolumes[0].fileSystem, "apfs") + } + + func testNetworkInterfaceEmittedKeys() { + store.decodeSnapshot([ + "com.i3omb.macmonitor.telemetry.network.bandwidth": [ + "interfaces": [[ + "name": "en0", "isUp": true, "ipv4Address": "192.168.1.10", + "downloadBytesPerSec": 5_000.0, "uploadBytesPerSec": 1_000.0, + "downloadPacketsPerSec": 40.0, "uploadPacketsPerSec": 20.0, + "cumulativeInBytes": 10_000_000 as UInt64, + "cumulativeOutBytes": 5_000_000 as UInt64 + ]] + ] + ]) + XCTAssertEqual(store.networkBandwidth.count, 1) + let iface = store.networkBandwidth[0] + XCTAssertEqual(iface.interfaceName, "en0") + XCTAssertTrue(iface.isUp) + XCTAssertEqual(iface.ipAddress, "192.168.1.10") + XCTAssertEqual(iface.downloadBps, 5_000.0, accuracy: 0.001) + XCTAssertEqual(iface.totalBytesIn, 10_000_000) + } + + func testProcessResidentAndVirtualBytesDecode() { + store.decodeSnapshot([ + "com.i3omb.macmonitor.telemetry.process": [ + "processes": [[ + "pid": 42, "ppid": 1, "name": "testproc", + "cpuPercent": 3.5, + "residentBytes": 100_000_000 as UInt64, + "virtualBytes": 400_000_000 as UInt64, + "threadCount": 5, "uid": 501, "username": "gordon", + "isStopped": false, "isZombie": false + ]] + ] + ]) + XCTAssertEqual(store.processes.count, 1) + XCTAssertEqual(store.processes[0].residentSize, 100_000_000) + XCTAssertEqual(store.processes[0].virtualSize, 400_000_000) + XCTAssertFalse(store.processes[0].isStopped) + XCTAssertFalse(store.processes[0].isZombie) + } + + func testFanEmittedKeysDecode() { + store.decodeSnapshot([ + "com.i3omb.macmonitor.telemetry.fan": [ + "fans": [[ + "fanIndex": 0, "name": "Exhaust", + "currentRPM": 2000.0, "minRPM": 1200.0, + "maxRPM": 6000.0, "targetRPM": 2000.0, + "utilizationPercent": 16.67 + ]] + ] + ]) + XCTAssertEqual(store.fans.count, 1) + XCTAssertEqual(store.fans[0].index, 0) + XCTAssertEqual(store.fans[0].name, "Exhaust") + XCTAssertEqual(store.fans[0].utilization, 16.67, accuracy: 0.01) + } +}