Files
onyx/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift
Scarriffle 2aa60eedf9 Farben einstellbar — und erst mal überhaupt eine Farbe
Das Grün, das überall auftauchte, war die System-Akzentfarbe: `.tint` und
`.accentColor` werden ohne eigene Angabe dorthin durchgereicht, und das
betraf alle Popovers und das ganze Einstellungsfenster. Die Notch-Widgets
dagegen benutzten Onyx' eigenes Blau. Zwei Farben, ungewollt.

Jetzt eine, und die ist einstellbar. Ein neuer Reiter „Farben": Akzentfarbe
mit sechs Vorschlägen und einem Knopf, der die Systemfarbe übernimmt, dazu
das Paar für Herunter- und Hochladen samt Tauschknopf.

Die Signalfarben bleiben fest und stehen nur zur Ansicht dort. Gelb heißt
Warnung, Rot heißt kritisch, Grün heißt in Ordnung — bei Temperatur, Akku,
Lüfter und Fehlern. Wer sie umstellen kann, kann ihre Bedeutung zerstören;
ein rotes „alles gut" liest niemand richtig. Sichtbar sind sie trotzdem,
sonst sucht man die Warnfarbe im Reiter und findet sie nirgends.

Umgesetzt über die Tokens statt über vierzig Fundstellen: `Onyx.Color.accent`
ist keine Konstante mehr, sondern liest bei jedem Zugriff aus `OnyxTheme`.
Damit merkt SwiftUI die Abhängigkeit und zeichnet neu, sobald die Farbe sich
ändert. Dazu ein `.tint` an den vier Wurzeln — Panel, Popovers, Einstellungen,
Einrichtung —, damit auch Haken, Regler und Auswahlfelder mitziehen.

Nebenbei die Beschriftung: „Je Programm" heißt jetzt „Programme". Großgesetzt
las sich das als englisches „J-E".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:24:40 +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.download)
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.download,
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.download)
row("arrow.up", Throughput.formatted(snapshot.uploadRate), Onyx.Color.upload)
}
}
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)
}
}
}
}