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

@@ -0,0 +1,144 @@
import SwiftUI
import MetricsProvider
@MainActor
struct NetworkPopoverContent: View {
let model: NetworkModel
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top) {
rate(model.snapshot.downloadRate, "network.download", .green)
Spacer()
rate(model.snapshot.uploadRate, "network.upload", .pink)
}
TrafficChart(history: model.history)
.frame(height: 56)
HStack {
peak(model.history.map(\.downloadRate).max() ?? 0, "network.peak.download")
Spacer()
peak(model.history.map(\.uploadRate).max() ?? 0, "network.peak.upload")
}
Divider()
Label(connectionName, systemImage: model.snapshot.interfaceKind.symbolName)
.font(.callout)
if model.snapshot.interfaceKind == .vpn {
// Ein VPN verändert, was die Zahlen darüber bedeuten der
// Verkehr läuft dann durch den Tunnel, nicht direkt.
Label("network.vpn.active", systemImage: "lock.shield")
.font(.caption)
.foregroundStyle(.secondary)
}
section("network.addresses")
field("network.field.interface", model.snapshot.interfaceName)
field("network.field.ipv4", model.snapshot.ipv4)
field("network.field.ipv6", model.snapshot.ipv6)
field("network.field.router", model.snapshot.router)
section("network.transferred")
field("network.total.received", bytes(model.snapshot.totalReceived))
field("network.total.sent", bytes(model.snapshot.totalSent))
// Kein Durchsatz je Programm: dafür gibt es ohne root keine
// Schnittstelle, und `nettop` im Sekundentakt zu starten kostete
// mehr als der gesamte übrige Monitor.
Text("network.perProcess.unavailable", bundle: .module)
.font(.caption2)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(14)
.frame(width: 280)
}
private var connectionName: String {
if let ssid = model.snapshot.ssid { return ssid }
if model.snapshot.isWiFiConnected {
return String(localized: "network.wifi.connected", bundle: .module)
}
return model.snapshot.interfaceName
?? String(localized: "network.offline", bundle: .module)
}
private func rate(_ value: Double, _ key: LocalizedStringKey, _ tint: Color) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(Throughput.formatted(value))
.font(.system(size: 18, weight: .medium).monospacedDigit())
HStack(spacing: 4) {
Circle().fill(tint).frame(width: 6, height: 6)
Text(key, bundle: .module).font(.caption).foregroundStyle(.secondary)
}
}
}
private func peak(_ value: Double, _ key: LocalizedStringKey) -> some View {
HStack(spacing: 4) {
Text(key, bundle: .module).font(.caption2).foregroundStyle(.secondary)
Text(Throughput.formatted(value)).font(.caption2.monospacedDigit())
}
}
private func section(_ key: LocalizedStringKey) -> some View {
Text(key, bundle: .module)
.font(.caption.weight(.semibold))
.foregroundStyle(.tint)
.textCase(.uppercase)
}
@ViewBuilder
private func field(_ key: LocalizedStringKey, _ value: String?) -> some View {
if let value {
HStack {
Text(key, bundle: .module).font(.callout).foregroundStyle(.secondary)
Spacer(minLength: 12)
Text(value).font(.callout.monospacedDigit()).textSelection(.enabled)
}
}
}
private func bytes(_ value: UInt64) -> String {
ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .binary)
}
}
/// Verlauf in beide Richtungen: Download nach unten, Upload nach oben.
///
/// Zwei getrennte Kurven übereinander wären platzsparender, aber schwerer zu
/// lesen hier sieht man sofort, ob gerade geladen oder gesendet wird, und in
/// welchem Verhältnis. Beide Richtungen teilen sich denselben Maßstab, sonst
/// sähe ein Upload von 20 KB/s genauso hoch aus wie ein Download von 20 MB/s.
private struct TrafficChart: View {
let history: [NetworkSnapshot]
var body: some View {
GeometryReader { geometry in
let points = history.suffix(60)
let peak = max(points.map { max($0.downloadRate, $0.uploadRate) }.max() ?? 1, 1)
let width = geometry.size.width / CGFloat(max(points.count, 1))
let half = geometry.size.height / 2
HStack(alignment: .center, spacing: 1) {
ForEach(Array(points.enumerated()), id: \.offset) { _, snapshot in
VStack(spacing: 1) {
Rectangle()
.fill(.pink)
.frame(height: max(half * CGFloat(snapshot.uploadRate / peak), 0.5))
.frame(maxHeight: .infinity, alignment: .bottom)
Rectangle()
.fill(.green)
.frame(height: max(half * CGFloat(snapshot.downloadRate / peak), 0.5))
.frame(maxHeight: .infinity, alignment: .top)
}
.frame(width: max(width - 1, 1))
}
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
}