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,26 @@
{
"sourceLanguage" : "en",
"strings" : {
"network.name" : { "localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Netzwerk" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Network" } } } },
"network.wifi.connected" : {
"comment" : "Wenn der Netzwerkname mangels Ortungsberechtigung fehlt",
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "WLAN verbunden" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Wi-Fi connected" } } } },
"network.offline" : { "localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Nicht verbunden" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Not connected" } } } },
"network.field.interface" : { "localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Schnittstelle" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Interface" } } } },
"network.field.ipv4" : { "localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "IP" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "IP" } } } },
"network.field.router" : { "localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Router" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Router" } } } }
},
"version" : "1.0"
}

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)
}
}
}
}

View File

@@ -0,0 +1,214 @@
import Foundation
import Darwin
import SystemConfiguration
import CoreWLAN
import MetricsProvider
public struct NetworkSnapshot: Equatable, Sendable {
/// Byte pro Sekunde.
public var downloadRate: Double = 0
public var uploadRate: Double = 0
/// Summen seit dem Systemstart.
public var totalReceived: UInt64 = 0
public var totalSent: UInt64 = 0
public var interfaceName: String?
public var interfaceKind: InterfaceKind = .unknown
public var ipv4: String?
public var ipv6: String?
public var router: String?
/// `nil`, solange die Ortungsberechtigung fehlt macOS gibt den Namen
/// sonst nicht heraus. Siehe `wifiName`.
public var ssid: String?
public var isWiFiConnected = false
public var asOf = Date()
}
public enum InterfaceKind: String, Equatable, Sendable {
case wifi, ethernet, vpn, cellular, loopback, unknown
public var symbolName: String {
switch self {
case .wifi: "wifi"
case .ethernet: "cable.connector"
case .vpn: "lock.shield"
case .cellular: "antenna.radiowaves.left.and.right"
case .loopback, .unknown: "network"
}
}
}
/// Liest Durchsatz, Adressen und Verbindungsart.
///
/// Alles ohne root und ohne private Schnittstellen. Der Durchsatz kommt aus den
/// Byte-Zählern des Kernels; sie sind Summen seit dem Start, die Rate ergibt
/// sich erst aus der Differenz siehe `Throughput.rate`.
public final class NetworkMetrics: @unchecked Sendable {
private var previousReceived: UInt64 = 0
private var previousSent: UInt64 = 0
private var previousSampleTime: Date?
public init() {}
public func sample() -> NetworkSnapshot {
var snapshot = NetworkSnapshot()
let counters = readCounters()
snapshot.totalReceived = counters.received
snapshot.totalSent = counters.sent
let now = Date()
if let previous = previousSampleTime {
let elapsed = now.timeIntervalSince(previous)
snapshot.downloadRate = Throughput.rate(previous: previousReceived,
current: counters.received, elapsed: elapsed)
snapshot.uploadRate = Throughput.rate(previous: previousSent,
current: counters.sent, elapsed: elapsed)
}
previousReceived = counters.received
previousSent = counters.sent
previousSampleTime = now
let primary = primaryInterface()
snapshot.interfaceName = primary.name
snapshot.interfaceKind = primary.kind
snapshot.router = primary.router
let addresses = localAddresses(for: primary.name)
snapshot.ipv4 = addresses.ipv4
snapshot.ipv6 = addresses.ipv6
snapshot.isWiFiConnected = primary.kind == .wifi
snapshot.ssid = wifiName()
return snapshot
}
// MARK: - Durchsatz
/// Summiert alle Interfaces außer Loopback.
///
/// Einzeln zu zählen wäre genauer, aber praktisch nutzlos: bei einem
/// Wechsel von WLAN auf VPN wandert der Verkehr auf ein anderes Interface,
/// und eine Anzeige, die dabei auf null fällt, ist falsch.
private func readCounters() -> (received: UInt64, sent: UInt64) {
var mib: [Int32] = [CTL_NET, PF_ROUTE, 0, 0, NET_RT_IFLIST2, 0]
var length = 0
guard sysctl(&mib, 6, nil, &length, nil, 0) == 0, length > 0 else { return (0, 0) }
var buffer = [UInt8](repeating: 0, count: length)
guard sysctl(&mib, 6, &buffer, &length, nil, 0) == 0 else { return (0, 0) }
var received: UInt64 = 0
var sent: UInt64 = 0
buffer.withUnsafeBytes { raw in
var offset = 0
while offset < length {
let header = raw.baseAddress!.advanced(by: offset)
.assumingMemoryBound(to: if_msghdr.self).pointee
guard header.ifm_msglen > 0 else { break }
defer { offset += Int(header.ifm_msglen) }
guard header.ifm_type == RTM_IFINFO2 else { continue }
let message = raw.baseAddress!.advanced(by: offset)
.assumingMemoryBound(to: if_msghdr2.self).pointee
// Loopback ausschließen: der lokale Verkehr zwischen Programmen
// hat mit was geht über die Leitung" nichts zu tun und kann
// ein Vielfaches davon sein.
guard message.ifm_data.ifi_type != UInt8(IFT_LOOP) else { continue }
received += message.ifm_data.ifi_ibytes
sent += message.ifm_data.ifi_obytes
}
}
return (received, sent)
}
// MARK: - Interface und Adressen
private struct Primary {
var name: String?
var kind: InterfaceKind = .unknown
var router: String?
}
private func primaryInterface() -> Primary {
var primary = Primary()
guard let store = SCDynamicStoreCreate(nil, "Onyx" as CFString, nil, nil),
let global = SCDynamicStoreCopyValue(store, "State:/Network/Global/IPv4" as CFString)
as? [String: Any] else { return primary }
primary.name = global["PrimaryInterface"] as? String
primary.router = global["Router"] as? String
primary.kind = kind(of: primary.name)
return primary
}
private func kind(of name: String?) -> InterfaceKind {
guard let name else { return .unknown }
// Die Namensschemata sind stabil: en0/en1 sind Ethernet oder WLAN,
// utun/ipsec/ppp gehören zu VPNs, lo0 ist die Loopback-Schnittstelle.
if name.hasPrefix("utun") || name.hasPrefix("ipsec") || name.hasPrefix("ppp") { return .vpn }
if name.hasPrefix("lo") { return .loopback }
if name.hasPrefix("pdp_ip") { return .cellular }
if name.hasPrefix("en") {
// en0 ist auf Notebooks das WLAN, aber nicht zwingend CoreWLAN
// weiß es genau.
let wifiNames = CWWiFiClient.interfaceNames() ?? []
return wifiNames.contains(name) ? .wifi : .ethernet
}
return .unknown
}
private func localAddresses(for interface: String?) -> (ipv4: String?, ipv6: String?) {
guard let interface else { return (nil, nil) }
var pointer: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&pointer) == 0, let first = pointer else { return (nil, nil) }
defer { freeifaddrs(pointer) }
var ipv4: String?
var ipv6: String?
for entry in sequence(first: first, next: { $0.pointee.ifa_next }) {
guard String(cString: entry.pointee.ifa_name) == interface,
let address = entry.pointee.ifa_addr else { continue }
var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
guard getnameinfo(address, socklen_t(address.pointee.sa_len),
&host, socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0
else { continue }
let text = String(cString: host)
switch Int32(address.pointee.sa_family) {
case AF_INET where ipv4 == nil:
ipv4 = text
case AF_INET6 where ipv6 == nil:
// Link-lokale Adressen (fe80::) tragen einen Zonen-Anhang und
// sagen dem Nutzer nichts.
if !text.hasPrefix("fe80") { ipv6 = text }
default:
break
}
}
return (ipv4, ipv6)
}
// MARK: - WLAN
/// Der Netzwerkname oder `nil`, wenn macOS ihn nicht herausgibt.
///
/// Seit macOS 14 liefert `ssid()` nur mit erteilter Ortungsberechtigung
/// einen Wert. Fehlt sie, kommt `nil` zurück, **ohne** dass ein Fehler
/// erscheint. Deshalb wird hier nicht geraten: die Oberfläche zeigt dann
/// WLAN verbunden" statt eines leeren Feldes, das nach einem Defekt
/// aussieht.
private func wifiName() -> String? {
CWWiFiClient.shared().interface()?.ssid()
}
}

View File

@@ -0,0 +1,193 @@
import SwiftUI
import OnyxDesign
import OnyxWidgetKit
import MetricsProvider
/// Wie `MetricsModel`, aber fürs Netz: eine Messschleife für Panel und
/// Menüleiste, mit Referenzzählung.
@MainActor
@Observable
public final class NetworkModel {
public private(set) var snapshot = NetworkSnapshot()
public private(set) var history: [NetworkSnapshot] = []
public static let historyLength = 60
private let source = NetworkMetrics()
private var timer: Timer?
private var demands: [UUID: TimeInterval] = [:]
public init() {}
@discardableResult
public func addConsumer(interval: TimeInterval = 2) -> UUID {
let token = UUID()
demands[token] = min(max(interval, 1), 10)
restartTimer()
return token
}
public func removeConsumer(_ token: UUID) {
demands[token] = nil
restartTimer()
}
private func restartTimer() {
timer?.invalidate()
timer = nil
guard let interval = demands.values.min() else {
history.removeAll()
return
}
sample()
let timer = Timer(timeInterval: interval, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.sample() }
}
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}
private func sample() {
let value = source.sample()
snapshot = value
history.append(value)
if history.count > Self.historyLength {
history.removeFirst(history.count - Self.historyLength)
}
}
/// Verläufe, auf den bisherigen Höchstwert normiert.
///
/// Eine feste Obergrenze wäre unbrauchbar: bei 100 Mbit/s wäre eine
/// Video-Wiedergabe ein flacher Strich, und bei 1 Mbit/s ginge jeder
/// Download über den Rand. Die Kurve zeigt deshalb den Verlauf relativ zum
/// bisherigen Maximum die absoluten Zahlen stehen daneben.
public func series(download: Bool) -> [Double] {
let values = history.map { download ? $0.downloadRate : $0.uploadRate }
guard let peak = values.max(), peak > 0 else { return values.map { _ in 0 } }
return values.map { $0 / peak }
}
}
// MARK: - Widget
public struct NetworkWidget: OnyxWidget {
public let id = "network"
public var displayName: String { String(localized: "network.name", bundle: .module) }
public let symbolName = "network"
public let supportedSizes: [WidgetSize] = [.small, .medium, .large]
private let model: NetworkModel
public init(model: NetworkModel) { self.model = model }
public func makeView(size: WidgetSize) -> AnyView {
AnyView(NetworkWidgetView(model: model, size: size))
}
}
private struct NetworkWidgetView: View {
let model: NetworkModel
let size: WidgetSize
@State private var token: UUID?
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Image(systemName: model.snapshot.interfaceKind.symbolName)
.font(.system(size: 11))
.foregroundStyle(Onyx.Color.accent)
Text(connectionName)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.lineLimit(1)
}
Rates(snapshot: model.snapshot, compact: size == .small)
if size != .small {
Sparkline(values: model.series(download: true), tint: Onyx.Color.accent)
.frame(height: 18)
}
if size == .large {
Divider().overlay(Onyx.Color.hairline)
Details(snapshot: model.snapshot)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear { token = model.addConsumer(interval: 1) }
.onDisappear {
if let token { model.removeConsumer(token) }
token = nil
}
}
/// Der Netzwerkname, wenn macOS ihn herausgibt sonst die Verbindungsart.
///
/// Seit macOS 14 liefert die SSID nur mit Ortungsberechtigung einen Wert.
/// Fehlt sie, wäre ein leeres Feld eine Falschaussage: verbunden ist man ja.
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 struct Rates: View {
let snapshot: NetworkSnapshot
let compact: Bool
var body: some View {
VStack(alignment: .leading, spacing: 1) {
row("arrow.down", Throughput.formatted(snapshot.downloadRate), Onyx.Color.accent)
row("arrow.up", Throughput.formatted(snapshot.uploadRate), Onyx.Color.positive)
}
}
private func row(_ symbol: String, _ text: String, _ tint: Color) -> some View {
HStack(spacing: 3) {
Image(systemName: symbol)
.font(.system(size: 8, weight: .bold))
.foregroundStyle(tint)
Text(text)
.font(compact ? Onyx.Font.metricSmall : Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
}
}
}
private struct Details: View {
let snapshot: NetworkSnapshot
var body: some View {
VStack(alignment: .leading, spacing: 2) {
field("network.field.interface", snapshot.interfaceName)
field("network.field.ipv4", snapshot.ipv4)
field("network.field.router", snapshot.router)
}
}
@ViewBuilder
private func field(_ label: LocalizedStringKey, _ value: String?) -> some View {
if let value {
HStack(spacing: 4) {
Text(label, bundle: .module)
.font(.system(size: 9))
.foregroundStyle(Onyx.Color.textTertiary)
Text(value)
.font(.system(size: 9))
.monospacedDigit()
.foregroundStyle(Onyx.Color.textSecondary)
.textSelection(.enabled)
.lineLimit(1)
}
}
}
}