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