Files
onyx/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift
Scarriffle 4c96aced5a Diagramme rechnen mit der vollen Länge, eigene Symbole für CPU und GPU
Frisch gestartet hat der Verlauf fünf Messwerte statt sechzig. Gerechnet
wurde aber „Breite geteilt durch Anzahl" — also war jeder Balken ein
Fünftel breit, und mit jedem weiteren wurden sie schmaler. Das Diagramm sah
nach einem Ausschlag aus und bedeutete nichts.

Jetzt kommt die Schrittweite aus der vollen Länge. Fehlende Werte bleiben
als freie Fläche links stehen; der Verlauf wächst nach links weg, wie bei
jedem Zeitdiagramm.

Das steckte an **drei** Stellen, alle mit derselben Rechnung: dem
Balkendiagramm im Netzwerk-Popover, der Sparkline in den Kacheln und der
Linie in den Menüleisten-Modulen. Die gemeinsame Rechnung liegt jetzt in
`GraphLayout` und ist geprüft, statt dreimal danebenzugehen.

Nebenbei: die Fläche unter der Sparkline begann am linken Rand statt am
ersten Messwert — ein Keil über die ganze Breite, wo noch nichts gemessen
war.

CPU und GPU bekommen eigene Symbole aus chip.svg und graphic-card.svg. SF
Symbols hat für beides nur denselben Chip, und „cpu" neben „cpu.fill" ist
kein Unterschied, den man in der Menüleiste erkennt.

Die Beschriftung unter den Ringen klebte am Rand und sah aus, als gehörte
sie noch hinein — sechs Punkte Abstand statt zwei. Und die Popover für CPU
und GPU tragen jetzt einen Titel: bei drei gleich aussehenden Ringen sieht
man sonst nicht, vor welchem man steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:33:28 +02:00

239 lines
8.0 KiB
Swift

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
/// Durchsatz je Programm nur gefüllt, solange ein Popover offen ist.
public private(set) var processes: [ProcessTraffic] = []
/// Die öffentliche Adresse, wenn eingeschaltet.
public let publicAddress = PublicAddressLookup()
/// Ob die öffentliche Adresse überhaupt abgefragt werden darf.
public var showsPublicAddress: Bool {
didSet {
guard showsPublicAddress != oldValue else { return }
defaults.set(showsPublicAddress, forKey: Self.publicAddressKey)
showsPublicAddress ? publicAddress.refresh() : publicAddress.clear()
}
}
private static let publicAddressKey = "onyx.network.showsPublicAddress"
private let defaults: UserDefaults
private let source = NetworkMetrics()
private let processSampler = ProcessTrafficSampler()
private var timer: Timer?
private var demands: [UUID: TimeInterval] = [:]
/// Zählt die offenen Popovers. Die Prozessliste kostet einen Fremdprozess
/// je Messung sie läuft nur, während wirklich jemand hinsieht.
private var detailConsumers = 0
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.showsPublicAddress = defaults.bool(forKey: Self.publicAddressKey)
}
/// Meldet ein offenes Popover an. Erst dann werden Prozesse abgefragt.
public func addDetailConsumer() {
detailConsumers += 1
if showsPublicAddress { publicAddress.refresh() }
sampleProcesses()
}
public func removeDetailConsumer() {
detailConsumers = max(detailConsumers - 1, 0)
if detailConsumers == 0 { processes = [] }
}
private func sampleProcesses() {
guard detailConsumers > 0 else { return }
// `nettop` blockiert kurz nicht auf dem Hauptthread.
let sampler = processSampler
Task.detached(priority: .utility) {
let result = sampler.sample()
await MainActor.run { [weak self] in
guard let self, detailConsumers > 0 else { return }
processes = result
}
}
}
@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
sampleProcesses()
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"
private let model: NetworkModel
public init(model: NetworkModel) { self.model = model }
public func makeView() -> AnyView {
AnyView(NetworkWidgetView(model: model))
}
}
private struct NetworkWidgetView: View {
let model: NetworkModel
@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: false)
Sparkline(values: model.series(download: true), tint: Onyx.Color.accent,
capacity: NetworkModel.historyLength)
.frame(height: 18)
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)
}
}
}
}