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.
215 lines
7.9 KiB
Swift
215 lines
7.9 KiB
Swift
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()
|
|
}
|
|
}
|