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,196 @@
import AppKit
import SwiftUI
import OnyxMenuBar
import MetricsProvider
/// Das Netzwerk in der Menüleiste.
///
/// Anders als die Hardwaregrößen hat es **zwei** Werte, die beide interessieren.
/// Sie untereinander zu setzen ist der einzige Weg, beide in der Höhe einer
/// Menüleiste unterzubringen nebeneinander bräuchte es die doppelte Breite,
/// und die hat man dort nicht.
@MainActor
public final class NetworkMenuBarModule: MenuBarModule {
public let id = "network"
public var displayName: String { String(localized: "network.name", bundle: .module) }
private let model: NetworkModel
private var token: UUID?
private var view: NetworkStatusView?
public init(model: NetworkModel) { self.model = model }
public func makeStatusView(presentation: MenuBarPresentation) -> NSView {
let view = NetworkStatusView(presentation: presentation)
view.update(model.snapshot, history: model.series(download: true))
self.view = view
return view
}
public func makePopoverView() -> AnyView {
AnyView(NetworkPopover(model: model))
}
public func activate() {
guard token == nil else { return }
token = model.addConsumer(interval: 2)
startObserving()
}
public func deactivate() {
if let token { model.removeConsumer(token) }
token = nil
view = nil
}
private func startObserving() {
withObservationTracking {
_ = model.snapshot
} onChange: {
Task { @MainActor [weak self] in
guard let self, token != nil else { return }
view?.update(model.snapshot, history: model.series(download: true))
startObserving()
}
}
}
}
final class NetworkStatusView: NSView {
private let presentation: MenuBarPresentation
private var snapshot = NetworkSnapshot()
private var history: [Double] = []
init(presentation: MenuBarPresentation) {
self.presentation = presentation
super.init(frame: NSRect(x: 0, y: 0, width: Self.width(presentation), height: 22))
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
/// Breiter als die Hardwaremodule: 2,4 MB/s" braucht schlicht mehr Platz
/// als 46 %".
static func width(_ presentation: MenuBarPresentation) -> CGFloat {
switch presentation {
case .symbol: 22
case .graph: 34
case .bars: 30
case .value, .valueAndGraph: 76
}
}
override var intrinsicContentSize: NSSize {
NSSize(width: Self.width(presentation), height: 22)
}
func update(_ snapshot: NetworkSnapshot, history: [Double]) {
self.snapshot = snapshot
self.history = history
needsDisplay = true
}
override func draw(_ dirtyRect: NSRect) {
let color = NSColor.labelColor
switch presentation {
case .symbol:
guard let image = NSImage(systemSymbolName: snapshot.interfaceKind.symbolName,
accessibilityDescription: nil) else { return }
image.isTemplate = true
color.set()
image.draw(in: NSRect(x: bounds.midX - 7.5, y: bounds.midY - 7.5,
width: 15, height: 15))
case .graph, .bars:
drawGraph(color: color, in: bounds.insetBy(dx: 2, dy: 5))
case .value, .valueAndGraph:
drawRates(color: color,
in: presentation == .value ? bounds
: NSRect(x: 0, y: 0, width: bounds.width - 26, height: bounds.height))
if presentation == .valueAndGraph {
drawGraph(color: color,
in: NSRect(x: bounds.width - 24, y: 5, width: 22, height: 12))
}
}
}
/// Zwei Zeilen à 9 pt die einzige Art, Hoch und Runter in 22 pt Höhe
/// unterzubringen.
private func drawRates(color: NSColor, in rect: NSRect) {
let attributes: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular),
.foregroundColor: color,
]
let down = NSAttributedString(string: "" + Throughput.formatted(snapshot.downloadRate),
attributes: attributes)
let up = NSAttributedString(string: "" + Throughput.formatted(snapshot.uploadRate),
attributes: attributes)
down.draw(at: NSPoint(x: rect.minX + 2, y: rect.midY - 0.5))
up.draw(at: NSPoint(x: rect.minX + 2, y: rect.midY - 10.5))
}
private func drawGraph(color: NSColor, in rect: NSRect) {
guard history.count > 1, let context = NSGraphicsContext.current?.cgContext else { return }
let step = rect.width / CGFloat(history.count - 1)
context.setStrokeColor(color.withAlphaComponent(0.85).cgColor)
context.setLineWidth(1)
for (index, value) in history.enumerated() {
let point = CGPoint(x: rect.minX + CGFloat(index) * step,
y: rect.minY + rect.height * CGFloat(min(max(value, 0), 1)))
index == 0 ? context.move(to: point) : context.addLine(to: point)
}
context.strokePath()
}
}
private struct NetworkPopover: View {
let model: NetworkModel
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Label(model.snapshot.ssid
?? model.snapshot.interfaceName
?? String(localized: "network.offline", bundle: .module),
systemImage: model.snapshot.interfaceKind.symbolName)
.font(.headline)
HStack(spacing: 16) {
rate("", Throughput.formatted(model.snapshot.downloadRate))
rate("", Throughput.formatted(model.snapshot.uploadRate))
}
Sparkline(values: model.series(download: true), tint: .accentColor)
.frame(width: 220, height: 40)
Divider()
field("network.field.interface", model.snapshot.interfaceName)
field("network.field.ipv4", model.snapshot.ipv4)
field("network.field.router", model.snapshot.router)
}
.padding(14)
.frame(width: 250)
}
private func rate(_ arrow: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 0) {
Text(arrow).font(.caption).foregroundStyle(.secondary)
Text(value).font(.body.monospacedDigit())
}
}
@ViewBuilder
private func field(_ label: LocalizedStringKey, _ value: String?) -> some View {
if let value {
HStack {
Text(label, bundle: .module).font(.callout).foregroundStyle(.secondary)
Spacer()
Text(value).font(.callout.monospacedDigit()).textSelection(.enabled)
}
}
}
}