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