Phase 5: Hardware- und Netzwerkmonitor, Panel und Menüleiste

Sechs neue Widgets (CPU, GPU, Speicher, Akku, Sensoren, Netzwerk) und dieselben
sechs als einzeln aktivierbare Menüleisten-Module mit fünf Darstellungsarten.

Panel-Widget und Menüleisten-Modul derselben Größe teilen sich die Messschleife
über Referenzzählung. Ohne das liefe für jede Anzeige ein eigener Timer mit
denselben IOKit- und SMC-Abfragen — bei sechs Modulen und ebenso vielen Widgets
ein Vielfaches der nötigen Arbeit. Meldet sich der letzte Konsument ab, hört die
Schleife auf; im Ruhezustand misst Onyx nichts.

Die Differenzrechnung ist testgetrieben, und die Tests haben zwei echte Fehler
gefunden. Erstens: mein Überlaufschutz beim Durchsatz hielt einen
zurückgesetzten Zähler (Interface-Wechsel) für einen Überlauf und zeigte
4,3 GB/s. Da if_data64 64-Bit-Zähler liefert, die bei 10 Gbit/s erst nach
470 Jahren umlaufen, ist ein kleinerer Wert immer ein Zurücksetzen — die
Unterscheidung produzierte nur den Ausreißer, den sie verhindern sollte.
Zweitens: ByteCountFormatter schrieb bei null „Zero KB/s" statt „0 KB/s".

Bei den CPU-Zählern bleibt der Überlaufschutz nötig: die sind 32 Bit breit und
laufen nach gut 400 Tagen wirklich um, deshalb `&-` statt `-`.

Sensoren kommen über den SMC-Leser aus Spike A statt über die private
IOHID-Schnittstelle — auf dieser Maschine nachweislich verifiziert, und die
dort gefundene Little-Endian-Eigenheit ist berücksichtigt. Nur eine kurze Liste
benannter Fühler statt aller 3486 Keys: eine Wand aus Kürzeln ist keine
Information.

Gegengemessen statt vermutet: 15 Kerne, 39,8 W, GPU 50 %, 16,6/24 GB mit
kritischem Druck, Akku 68 Zyklen bei 100 % Gesundheit, fünf Sensoren, zwei
Lüfter, en0 mit 661 KB/s.

Menüleisten-Module zeichnen in labelColor statt in den Onyx-Farben — nur die
Systemfarbe passt sich heller wie dunkler Leiste an. Feste Breite je
Darstellungsart, sonst schiebt jeder Messwert die halbe Leiste hin und her.

Standardmäßig ist kein Modul aktiv: sechs neue Symbole beim ersten Start wären
eine Zumutung.

145 Tests grün.
This commit is contained in:
Guido Schmit
2026-08-10 21:48:47 +02:00
parent a198d88104
commit 594d7c422a
21 changed files with 2897 additions and 350 deletions

View File

@@ -0,0 +1,115 @@
import Foundation
/// Die CPU-Zeitzähler eines Kerns, wie `host_processor_info` sie liefert.
///
/// Es sind **Summen seit dem Start**, keine Momentanwerte. Die Auslastung
/// ergibt sich erst aus der Differenz zweier Abtastungen.
public struct CPUTicks: Equatable, Sendable {
public let user: UInt32
public let system: UInt32
public let idle: UInt32
public let nice: UInt32
public init(user: UInt32, system: UInt32, idle: UInt32, nice: UInt32) {
self.user = user
self.system = system
self.idle = idle
self.nice = nice
}
/// Auslastung zwischen zwei Abtastungen, 0 bis 1.
public static func usage(from previous: CPUTicks, to current: CPUTicks) -> Double {
// `&-` statt `-`: die Zähler sind 32 Bit breit und laufen nach gut
// 400 Tagen über. Ein gewöhnlicher Abzug stürzt dabei ab oder liefert
// einen absurden Sprung; die überlaufende Subtraktion ergibt genau die
// richtige Differenz.
let user = UInt64(current.user &- previous.user)
let system = UInt64(current.system &- previous.system)
let nice = UInt64(current.nice &- previous.nice)
let idle = UInt64(current.idle &- previous.idle)
let busy = user + system + nice
let total = busy + idle
guard total > 0 else { return 0 }
return min(max(Double(busy) / Double(total), 0), 1)
}
}
/// Wie angespannt die Speicherlage ist.
public enum MemoryPressure: Equatable, Sendable {
case normal
case warning
case critical
/// - Parameters:
/// - free: Anteil wirklich freien Speichers, 0 bis 1.
/// - compressed: Anteil komprimierten Speichers, 0 bis 1.
/// - swapUsed: belegter Auslagerungsspeicher in Byte.
public static func classify(free: Double, compressed: Double, swapUsed: UInt64) -> MemoryPressure {
// Auslagerung schlägt alles andere: sobald sie läuft, bremst der
// Rechner spürbar auch wenn die Freispeicheranzeige harmlos aussieht.
// Ein bisschen Swap hat macOS fast immer angelegt, deshalb erst ab
// einem halben Gigabyte.
if swapUsed > 512 * 1024 * 1024 { return .critical }
if free < 0.10 || compressed > 0.20 { return .warning }
return .normal
}
}
/// Netzwerkdurchsatz aus zwei Zählerständen.
public enum Throughput {
/// Byte pro Sekunde zwischen zwei Abtastungen.
///
/// Die Normierung auf die tatsächlich verstrichene Zeit ist der Kern: die
/// Menüleisten-Module tasten je nach Einstellung alle 1 bis 10 Sekunden ab.
/// Ohne sie zeigte ein Modul mit 5 Sekunden Intervall das Fünffache an.
public static func rate(previous: UInt64, current: UInt64, elapsed: TimeInterval) -> Double {
guard elapsed > 0 else { return 0 }
// Zähler kleiner als vorher heißt: er wurde zurückgesetzt. Das passiert
// bei jedem Interface-Wechsel WLAN aus, VPN an und beim Neustart
// des Dienstes.
//
// Ein Überlauf wäre die andere denkbare Erklärung, kommt hier aber
// nicht vor: `if_data64` liefert 64-Bit-Zähler, die bei 10 Gbit/s
// erst nach rund 470 Jahren umlaufen. Der Versuch, beides zu
// unterscheiden, produziert nur den Ausreißer, den er verhindern soll
// deshalb null.
guard current >= previous else { return 0 }
return Double(current - previous) / elapsed
}
/// 2,4 MB/s" statt 2400000".
public static func formatted(_ bytesPerSecond: Double) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .binary
formatter.allowedUnits = [.useKB, .useMB, .useGB]
formatter.zeroPadsFractionDigits = false
// Ohne das steht bei null Zero KB/s" statt 0 KB/s" in einer Spalte
// aus Zahlen fällt ein Wort unangenehm auf und ändert die Breite.
formatter.allowsNonnumericFormatting = false
return formatter.string(fromByteCount: Int64(max(bytesPerSecond, 0))) + "/s"
}
}
/// Einheitliche Zahlendarstellung für alle Messwerte.
public enum MetricFormat {
/// Ganzzahlig. Nachkommastellen bei der Auslastung täuschen eine
/// Genauigkeit vor, die die Messung nicht hat und sie zappeln im
/// Sekundentakt, was die Anzeige unruhig macht.
public static func percent(_ fraction: Double) -> String {
"\(Int((min(max(fraction, 0), 1) * 100).rounded())) %"
}
public static func temperature(_ celsius: Double) -> String {
"\(Int(celsius.rounded()))°"
}
public static func watts(_ value: Double) -> String {
String(format: "%.1f W", max(value, 0))
}
}