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>
239 lines
8.0 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|