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.
197 lines
6.9 KiB
Swift
197 lines
6.9 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|