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>
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.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)
|
|
}
|
|
}
|
|
}
|
|
}
|