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:
214
Packages/OnyxKit/Sources/NetworkProvider/NetworkMetrics.swift
Normal file
214
Packages/OnyxKit/Sources/NetworkProvider/NetworkMetrics.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user