Aussagekräftige Popovers für CPU, Speicher, Akku und Netzwerk

Ein Popover, das nur den Wert größer wiederholt, den man gerade angeklickt hat,
ist verschenkter Platz. Hier ist Raum — anders als in der Menüleiste, wo jede
Zahl um Breite kämpft.

Prozessor: Ringe für Auslastung, Temperatur und Lüfter, alle Kerne als Raster
statt als Balkenreihe (bei fünfzehn Kernen wird eine Reihe unleserlich schmal),
Leistungsaufschlüsselung und beide Lüfter mit Namen.

Die Leistungsschlüssel sind gegen iStat Menus abgeglichen: PHPC liefert 14,02 W
und deckt sich mit deren CPU-Wert, PSTR mit der Gesamtleistung. Frequenzen
fehlen bewusst — die stehen nicht im SMC, dafür bräuchte es IOReport.

Speicher: Ring mit Aufschlüsselung nach Programmen, Reserviertem, Komprimiertem
und Freiem, Auslagerung mit Gesamtgröße und die fünf größten Verbraucher über
phys_footprint — derselbe Wert, den der Aktivitätsmonitor zeigt, nicht der
virtuelle Adressraum.

Akku: Restzeit, Kapazität, Zyklen, Temperatur, Lade- oder Entladeleistung,
Netzteilleistung und Spannung.

Netzwerk: Verlauf in beide Richtungen auf gemeinsamem Maßstab — getrennte
Maßstäbe ließen einen Upload von 20 KB/s so hoch aussehen wie einen Download
von 20 MB/s. Dazu Spitzenwerte, VPN-Hinweis, alle Adressen und die Summen seit
dem Start.

Was fehlt, wird benannt statt weggelassen: Durchsatz je Programm gibt es ohne
root nicht, das Ladelimit braucht den privilegierten Helfer und einen Key, der
auf diesem Modell noch nicht identifiziert ist.

Sensorliste erweitert und benannt: CPU-Kerne und Grafik stehen vorn, dazu
Luftstrom, Thunderbolt links und rechts. Lüfter heißen links und rechts statt
0 und 1.

Nebenbei ein Übersetzungsfehler behoben: die Modus-Auswahl in den Einstellungen
zeigte die Rohschlüssel, weil die Texte im MetricsProvider-Bündel liegen und
die Einstellungen im App-Bündel suchen.

145 Tests grün.
This commit is contained in:
Guido Schmit
2026-08-10 22:17:13 +02:00
parent a4cc75ab6e
commit d85425671d
8 changed files with 1883 additions and 685 deletions

View File

@@ -13,9 +13,24 @@ public struct MetricsSnapshot: Equatable, Sendable {
public var battery: BatteryReading?
public var sensors: [SensorReading] = []
public var fans: [FanReading] = []
public var power = PowerReading()
public var topMemoryProcesses: [ProcessMemory] = []
public var asOf = Date()
}
/// Wohin die Leistung geht.
///
/// Die Schlüssel sind gegen den Aktivitätsmonitor und gegen iStat Menus
/// abgeglichen: `PHPC` deckt sich mit deren CPU-Wert, `PSTR` mit der
/// Gesamtleistung.
public struct PowerReading: Equatable, Sendable {
public var total: Double?
public var cpu: Double?
public var display: Double?
public var adapter: Double?
public var busVoltage: Double?
}
public struct CPUReading: Equatable, Sendable {
/// Gesamtauslastung, 0 bis 1.
public var total: Double = 0
@@ -36,6 +51,12 @@ public struct MemoryReading: Equatable, Sendable {
public var total: UInt64 = 0
public var compressed: UInt64 = 0
public var swapUsed: UInt64 = 0
public var swapTotal: UInt64 = 0
/// Von Programmen belegt (aktiv + inaktiv).
public var app: UInt64 = 0
/// Vom Kernel festgehalten, nicht auslagerbar.
public var wired: UInt64 = 0
public var free: UInt64 = 0
public var pressure: MemoryPressure = .normal
public var usedFraction: Double {
@@ -53,6 +74,14 @@ public struct BatteryReading: Equatable, Sendable {
public var timeRemaining: TimeInterval?
}
/// Ein Programm mit seinem Speicherverbrauch.
public struct ProcessMemory: Equatable, Sendable, Identifiable {
public let pid: pid_t
public let name: String
public let bytes: UInt64
public var id: pid_t { pid }
}
public struct SensorReading: Equatable, Sendable, Identifiable {
public let key: String
public let name: String
@@ -62,6 +91,7 @@ public struct SensorReading: Equatable, Sendable, Identifiable {
public struct FanReading: Equatable, Sendable, Identifiable {
public let index: Int
public let name: String
public let rpm: Double
public let minimum: Double
public let maximum: Double
@@ -138,7 +168,9 @@ public final class SystemMetrics: @unchecked Sendable {
snapshot.battery = readBattery()
snapshot.sensors = readSensors()
snapshot.fans = readFans()
snapshot.cpu.watts = smc?.float("PSTR")
snapshot.topMemoryProcesses = readTopMemoryProcesses()
snapshot.power = readPower()
snapshot.cpu.watts = snapshot.power.cpu ?? snapshot.power.total
let temperatures = readTemperatures()
snapshot.cpu.temperature = temperatures.cpu
snapshot.gpuTemperature = temperatures.gpu
@@ -245,9 +277,14 @@ public final class SystemMetrics: @unchecked Sendable {
var swapSize = MemoryLayout<xsw_usage>.size
sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0)
let app = UInt64(statistics.active_count + statistics.inactive_count) * pageSize
let wired = UInt64(statistics.wire_count) * pageSize
let freeBytes = UInt64(statistics.free_count) * pageSize
let free = total > 0 ? 1 - Double(used) / Double(total) : 0
return MemoryReading(used: used, total: total, compressed: compressed,
swapUsed: swap.xsu_used,
swapUsed: swap.xsu_used, swapTotal: swap.xsu_total,
app: app, wired: wired, free: freeBytes,
pressure: MemoryPressure.classify(
free: free,
compressed: total > 0 ? Double(compressed) / Double(total) : 0,
@@ -303,6 +340,61 @@ public final class SystemMetrics: @unchecked Sendable {
return reading
}
/// Bei zwei Lüftern sind es links und rechts so beschriftet Apple sie
/// selbst. Bei einer anderen Anzahl bleibt die Nummer, weil die Anordnung
/// dann nicht bekannt ist.
private static func fanName(index: Int, of count: Int) -> String {
guard count == 2 else { return "Lüfter \(index + 1)" }
return index == 0 ? "Lüfter links" : "Lüfter rechts"
}
// MARK: - Leistung
private func readPower() -> PowerReading {
guard let smc else { return PowerReading() }
return PowerReading(total: smc.float("PSTR"),
cpu: smc.float("PHPC"),
display: smc.float("PDBR"),
adapter: smc.float("PDTR"),
busVoltage: smc.float("VP0R"))
}
// MARK: - Speicher je Programm
/// Die größten Speicherverbraucher.
///
/// `phys_footprint` ist derselbe Wert, den der Aktivitätsmonitor unter
/// Speicher" zeigt nicht der virtuelle Adressraum, der bei modernen
/// Programmen sinnlos groß ist. Fremde Nutzer bleiben außen vor: dafür
/// bräuchte es root, und für einen Blick aufs eigene System reicht das hier.
private func readTopMemoryProcesses(limit: Int = 5) -> [ProcessMemory] {
var pids = [pid_t](repeating: 0, count: 4096)
let size = proc_listpids(UInt32(PROC_ALL_PIDS), 0, &pids,
Int32(pids.count * MemoryLayout<pid_t>.size))
guard size > 0 else { return [] }
let count = Int(size) / MemoryLayout<pid_t>.size
var results: [ProcessMemory] = []
for pid in pids.prefix(count) where pid > 0 {
var usage = rusage_info_v4()
let result = withUnsafeMutablePointer(to: &usage) {
$0.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) {
proc_pid_rusage(pid, RUSAGE_INFO_V4, $0)
}
}
guard result == 0, usage.ri_phys_footprint > 64 * 1024 * 1024 else { continue }
var nameBuffer = [CChar](repeating: 0, count: Int(MAXPATHLEN))
proc_name(pid, &nameBuffer, UInt32(nameBuffer.count))
let name = String(cString: nameBuffer)
guard !name.isEmpty else { continue }
results.append(ProcessMemory(pid: pid, name: name, bytes: usage.ri_phys_footprint))
}
return Array(results.sorted { $0.bytes > $1.bytes }.prefix(limit))
}
// MARK: - Sensoren und Lüfter
/// Die Temperaturfühler, die auf `Mac17,9` tatsächlich existieren.
@@ -311,11 +403,14 @@ public final class SystemMetrics: @unchecked Sendable {
/// unbenannt, und eine Wand aus Kürzeln ist keine Information. Was fehlt,
/// fällt beim Lesen einfach weg.
private static let temperatureKeys: [(String, String)] = [
("Ts0P", "Gehäuse vorn"),
("Ts1P", "Gehäuse hinten"),
("TB0T", "Akku"),
("Ts0P", "Handballenauflage"),
("Ts1P", "Gehäuse hinten"),
("TW0P", "WLAN"),
("TH0x", "SSD"),
("TCMb", "Luftstrom"),
("TDeL", "Thunderbolt links"),
("TDeR", "Thunderbolt rechts"),
]
private func readTemperatures() -> (cpu: Double?, gpu: Double?) {
@@ -350,10 +445,20 @@ public final class SystemMetrics: @unchecked Sendable {
private func readSensors() -> [SensorReading] {
guard let smc else { return [] }
return Self.temperatureKeys.compactMap { key, name in
let named = Self.temperatureKeys.compactMap { key, name -> SensorReading? in
guard let celsius = smc.float(key), celsius > 0, celsius < 150 else { return nil }
return SensorReading(key: key, name: name, celsius: celsius)
}
// CPU und GPU nach vorn: das sind die Werte, wegen derer man hinschaut.
let temperatures = readTemperatures()
var result: [SensorReading] = []
if let cpu = temperatures.cpu {
result.append(SensorReading(key: "cpu", name: "CPU-Kerne", celsius: cpu))
}
if let gpu = temperatures.gpu {
result.append(SensorReading(key: "gpu", name: "Grafik", celsius: gpu))
}
return result + named
}
private func readFans() -> [FanReading] {
@@ -361,6 +466,7 @@ public final class SystemMetrics: @unchecked Sendable {
return (0..<Int(count)).compactMap { index in
guard let rpm = smc.float("F\(index)Ac") else { return nil }
return FanReading(index: index,
name: Self.fanName(index: index, of: Int(count)),
rpm: rpm,
minimum: smc.float("F\(index)Mn") ?? 0,
maximum: smc.float("F\(index)Mx") ?? 0)