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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user