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:
Guido Schmit
2026-08-10 21:48:47 +02:00
parent a198d88104
commit 594d7c422a
21 changed files with 2897 additions and 350 deletions

View File

@@ -16,6 +16,8 @@ let package = Package(
.library(name: "CalendarProvider", targets: ["CalendarProvider"]),
.library(name: "WeatherProvider", targets: ["WeatherProvider"]),
.library(name: "MediaProvider", targets: ["MediaProvider"]),
.library(name: "MetricsProvider", targets: ["MetricsProvider"]),
.library(name: "NetworkProvider", targets: ["NetworkProvider"]),
],
targets: [
// Der String-Katalog muss ausdrücklich als Ressource stehen sonst gibt
@@ -46,5 +48,13 @@ let package = Package(
.target(name: "MediaProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "MediaProviderTests", dependencies: ["MediaProvider"]),
.target(name: "MetricsProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit", "OnyxMenuBar"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "MetricsProviderTests", dependencies: ["MetricsProvider"]),
.target(name: "NetworkProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit", "OnyxMenuBar", "MetricsProvider"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "NetworkProviderTests", dependencies: ["NetworkProvider"]),
]
)

View File

@@ -0,0 +1,115 @@
import Foundation
/// Die CPU-Zeitzähler eines Kerns, wie `host_processor_info` sie liefert.
///
/// Es sind **Summen seit dem Start**, keine Momentanwerte. Die Auslastung
/// ergibt sich erst aus der Differenz zweier Abtastungen.
public struct CPUTicks: Equatable, Sendable {
public let user: UInt32
public let system: UInt32
public let idle: UInt32
public let nice: UInt32
public init(user: UInt32, system: UInt32, idle: UInt32, nice: UInt32) {
self.user = user
self.system = system
self.idle = idle
self.nice = nice
}
/// Auslastung zwischen zwei Abtastungen, 0 bis 1.
public static func usage(from previous: CPUTicks, to current: CPUTicks) -> Double {
// `&-` statt `-`: die Zähler sind 32 Bit breit und laufen nach gut
// 400 Tagen über. Ein gewöhnlicher Abzug stürzt dabei ab oder liefert
// einen absurden Sprung; die überlaufende Subtraktion ergibt genau die
// richtige Differenz.
let user = UInt64(current.user &- previous.user)
let system = UInt64(current.system &- previous.system)
let nice = UInt64(current.nice &- previous.nice)
let idle = UInt64(current.idle &- previous.idle)
let busy = user + system + nice
let total = busy + idle
guard total > 0 else { return 0 }
return min(max(Double(busy) / Double(total), 0), 1)
}
}
/// Wie angespannt die Speicherlage ist.
public enum MemoryPressure: Equatable, Sendable {
case normal
case warning
case critical
/// - Parameters:
/// - free: Anteil wirklich freien Speichers, 0 bis 1.
/// - compressed: Anteil komprimierten Speichers, 0 bis 1.
/// - swapUsed: belegter Auslagerungsspeicher in Byte.
public static func classify(free: Double, compressed: Double, swapUsed: UInt64) -> MemoryPressure {
// Auslagerung schlägt alles andere: sobald sie läuft, bremst der
// Rechner spürbar auch wenn die Freispeicheranzeige harmlos aussieht.
// Ein bisschen Swap hat macOS fast immer angelegt, deshalb erst ab
// einem halben Gigabyte.
if swapUsed > 512 * 1024 * 1024 { return .critical }
if free < 0.10 || compressed > 0.20 { return .warning }
return .normal
}
}
/// Netzwerkdurchsatz aus zwei Zählerständen.
public enum Throughput {
/// Byte pro Sekunde zwischen zwei Abtastungen.
///
/// Die Normierung auf die tatsächlich verstrichene Zeit ist der Kern: die
/// Menüleisten-Module tasten je nach Einstellung alle 1 bis 10 Sekunden ab.
/// Ohne sie zeigte ein Modul mit 5 Sekunden Intervall das Fünffache an.
public static func rate(previous: UInt64, current: UInt64, elapsed: TimeInterval) -> Double {
guard elapsed > 0 else { return 0 }
// Zähler kleiner als vorher heißt: er wurde zurückgesetzt. Das passiert
// bei jedem Interface-Wechsel WLAN aus, VPN an und beim Neustart
// des Dienstes.
//
// Ein Überlauf wäre die andere denkbare Erklärung, kommt hier aber
// nicht vor: `if_data64` liefert 64-Bit-Zähler, die bei 10 Gbit/s
// erst nach rund 470 Jahren umlaufen. Der Versuch, beides zu
// unterscheiden, produziert nur den Ausreißer, den er verhindern soll
// deshalb null.
guard current >= previous else { return 0 }
return Double(current - previous) / elapsed
}
/// 2,4 MB/s" statt 2400000".
public static func formatted(_ bytesPerSecond: Double) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .binary
formatter.allowedUnits = [.useKB, .useMB, .useGB]
formatter.zeroPadsFractionDigits = false
// Ohne das steht bei null Zero KB/s" statt 0 KB/s" in einer Spalte
// aus Zahlen fällt ein Wort unangenehm auf und ändert die Breite.
formatter.allowsNonnumericFormatting = false
return formatter.string(fromByteCount: Int64(max(bytesPerSecond, 0))) + "/s"
}
}
/// Einheitliche Zahlendarstellung für alle Messwerte.
public enum MetricFormat {
/// Ganzzahlig. Nachkommastellen bei der Auslastung täuschen eine
/// Genauigkeit vor, die die Messung nicht hat und sie zappeln im
/// Sekundentakt, was die Anzeige unruhig macht.
public static func percent(_ fraction: Double) -> String {
"\(Int((min(max(fraction, 0), 1) * 100).rounded())) %"
}
public static func temperature(_ celsius: Double) -> String {
"\(Int(celsius.rounded()))°"
}
public static func watts(_ value: Double) -> String {
String(format: "%.1f W", max(value, 0))
}
}

View File

@@ -0,0 +1,134 @@
{
"sourceLanguage": "en",
"strings": {
"metric.cpu": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "CPU"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "CPU"
}
}
}
},
"metric.gpu": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "GPU"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "GPU"
}
}
}
},
"metric.memory": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Speicher"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Memory"
}
}
}
},
"metric.battery": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Akku"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Battery"
}
}
}
},
"metric.sensors": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Sensoren"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Sensors"
}
}
}
},
"metric.sensors.none": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Keine Sensoren lesbar"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No sensors readable"
}
}
}
},
"metric.battery.charging": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Lädt"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Charging"
}
}
}
},
"metric.cpu.perCore": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Je Kern"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Per core"
}
}
}
}
},
"version": "1.0"
}

View File

@@ -0,0 +1,282 @@
import AppKit
import SwiftUI
import OnyxDesign
import OnyxMenuBar
/// Ein Menüleisten-Modul je Hardwaregröße.
///
/// Teilt sich das Modell und damit die Messschleife mit dem Panel-Widget
/// derselben Größe. Wer CPU im Panel **und** in der Menüleiste zeigt, bekommt
/// trotzdem nur eine Messung.
@MainActor
public final class MetricMenuBarModule: MenuBarModule {
public let id: String
public var displayName: String {
String(localized: .init(metric.localizationKey), bundle: .module)
}
private let metric: MetricKind
private let model: MetricsModel
private var token: UUID?
private var view: MetricStatusView?
private var observation: (any NSObjectProtocol)?
public init(metric: MetricKind, model: MetricsModel) {
self.metric = metric
self.model = model
self.id = "metric.\(metric.rawValue)"
}
public func makeStatusView(presentation: MenuBarPresentation) -> NSView {
let view = MetricStatusView(metric: metric, presentation: presentation)
view.update(snapshot: model.snapshot, history: model.series(metric))
self.view = view
return view
}
public func makePopoverView() -> AnyView {
AnyView(MetricPopover(model: model, metric: metric))
}
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
observation = nil
view = nil
}
/// `withObservationTracking` meldet sich **einmal** und muss danach neu
/// eingerichtet werden sonst friert die Anzeige nach der ersten Messung ein.
private func startObserving() {
withObservationTracking {
_ = model.snapshot
} onChange: {
Task { @MainActor [weak self] in
guard let self, token != nil else { return }
view?.update(snapshot: model.snapshot, history: model.series(metric))
startObserving()
}
}
}
}
/// Zeichnet den Messwert in die Menüleiste.
///
/// Eigene `NSView` statt SwiftUI: die Menüleiste verlangt eine feste Breite je
/// Darstellungsart, sonst springt bei jeder Messung die ganze Leiste. Mit
/// SwiftUI müsste man diese Breite doppelt führen einmal für das Layout und
/// einmal für `NSStatusItem.length`.
final class MetricStatusView: NSView {
private let metric: MetricKind
private let presentation: MenuBarPresentation
private var snapshot = MetricsSnapshot()
private var history: [Double] = []
init(metric: MetricKind, presentation: MenuBarPresentation) {
self.metric = metric
self.presentation = presentation
super.init(frame: NSRect(x: 0, y: 0, width: presentation.width, height: 22))
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override var intrinsicContentSize: NSSize {
NSSize(width: presentation.width, height: 22)
}
func update(snapshot: MetricsSnapshot, history: [Double]) {
self.snapshot = snapshot
self.history = history
needsDisplay = true
}
override func draw(_ dirtyRect: NSRect) {
// `labelColor` statt der Onyx-Farben: die Menüleiste ist mal hell, mal
// dunkel, und nur die Systemfarbe passt sich beidem an.
let color = NSColor.labelColor
switch presentation {
case .value:
drawText(MetricSummary.value(metric, snapshot), color: color)
case .symbol:
drawSymbol(color: color)
case .graph:
drawGraph(color: color, in: bounds.insetBy(dx: 2, dy: 5))
case .bars:
drawBars(color: color)
case .valueAndGraph:
let split = bounds.width * 0.55
drawText(MetricSummary.value(metric, snapshot), color: color,
in: NSRect(x: 0, y: 0, width: split, height: bounds.height))
drawGraph(color: color,
in: NSRect(x: split + 2, y: 5,
width: bounds.width - split - 4, height: bounds.height - 10))
}
}
private func drawText(_ text: String, color: NSColor, in rect: NSRect? = nil) {
let attributes: [NSAttributedString.Key: Any] = [
// Feste Ziffernbreite: ohne sie wackelt der Text bei jeder Messung.
.font: NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular),
.foregroundColor: color,
]
let string = NSAttributedString(string: text, attributes: attributes)
let area = rect ?? bounds
let size = string.size()
string.draw(at: NSPoint(x: area.midX - size.width / 2,
y: area.midY - size.height / 2))
}
private func drawSymbol(color: NSColor) {
guard let image = NSImage(systemSymbolName: metric.symbolName,
accessibilityDescription: nil) else { return }
image.isTemplate = true
let side: CGFloat = 15
let rect = NSRect(x: bounds.midX - side / 2, y: bounds.midY - side / 2,
width: side, height: side)
color.set()
image.draw(in: rect)
}
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)
context.setLineJoin(.round)
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()
}
/// Ein Balken je Kern. Bei fünfzehn Kernen ist das kein Diagramm mehr,
/// sondern ein Muster aber genau daran erkennt man auf einen Blick, ob
/// eine einzelne Last läuft oder alles ausgelastet ist.
private func drawBars(color: NSColor) {
let values = metric == .cpu ? snapshot.cpu.perCore
: [MetricSummary.fraction(metric, snapshot)]
guard !values.isEmpty, let context = NSGraphicsContext.current?.cgContext else { return }
let area = bounds.insetBy(dx: 2, dy: 5)
let gap: CGFloat = values.count > 4 ? 1 : 2
let width = (area.width - gap * CGFloat(values.count - 1)) / CGFloat(values.count)
for (index, value) in values.enumerated() {
let height = max(area.height * CGFloat(min(max(value, 0), 1)), 1)
let rect = NSRect(x: area.minX + CGFloat(index) * (width + gap),
y: area.minY, width: width, height: height)
context.setFillColor(color.withAlphaComponent(0.85).cgColor)
context.fill(rect)
}
}
}
extension MenuBarPresentation {
/// Feste Breite je Darstellungsart.
///
/// Ohne sie ändert das Element bei jedem Messwert seine Größe und schiebt
/// alle Symbole rechts davon hin und her das fällt in der Menüleiste
/// sofort unangenehm auf.
var width: CGFloat {
switch self {
case .value: 42
case .symbol: 22
case .graph: 34
case .bars: 30
case .valueAndGraph: 74
}
}
}
public extension MetricSummary {
/// Der Messwert als Anteil von 0 bis 1 für Balken und Graphen.
static func fraction(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> Double {
switch metric {
case .cpu: snapshot.cpu.total
case .gpu: snapshot.gpu ?? 0
case .memory: snapshot.memory.usedFraction
case .battery: snapshot.battery?.charge ?? 0
case .temperature: (snapshot.sensors.map(\.celsius).max() ?? 0) / 100
}
}
}
// MARK: - Popover
private struct MetricPopover: View {
let model: MetricsModel
let metric: MetricKind
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: .init(metric.localizationKey), bundle: .module))
.font(.headline)
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(MetricSummary.value(metric, model.snapshot))
.font(.system(size: 26, weight: .medium).monospacedDigit())
if let detail = MetricSummary.detail(metric, model.snapshot) {
Text(detail).font(.callout).foregroundStyle(.secondary)
}
}
Sparkline(values: model.series(metric), tint: .accentColor)
.frame(width: 220, height: 44)
if metric == .cpu, !model.snapshot.cpu.perCore.isEmpty {
Text("metric.cpu.perCore", bundle: .module)
.font(.caption).foregroundStyle(.secondary)
CoreBars(values: model.snapshot.cpu.perCore)
.frame(height: 26)
}
if metric == .temperature {
ForEach(model.snapshot.sensors) { sensor in
HStack {
Text(sensor.name).font(.callout)
Spacer()
Text(MetricFormat.temperature(sensor.celsius))
.font(.callout.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
}
.padding(14)
.frame(width: 250)
}
}
private struct CoreBars: View {
let values: [Double]
var body: some View {
GeometryReader { geometry in
let gap: CGFloat = 2
let width = (geometry.size.width - gap * CGFloat(values.count - 1))
/ CGFloat(values.count)
HStack(alignment: .bottom, spacing: gap) {
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
RoundedRectangle(cornerRadius: 1, style: .continuous)
.fill(Color.accentColor)
.frame(width: width,
height: max(geometry.size.height * value, 1))
}
}
.frame(height: geometry.size.height, alignment: .bottom)
}
}
}

View File

@@ -0,0 +1,285 @@
import SwiftUI
import OnyxDesign
import OnyxWidgetKit
/// Ein Widget je Hardwaregröße. Alle teilen sich dasselbe Modell und damit
/// dieselbe Messschleife.
public struct MetricWidget: OnyxWidget {
public let id: String
public let metric: MetricKind
public var displayName: String {
String(localized: .init(metric.localizationKey), bundle: .module)
}
public var symbolName: String { metric.symbolName }
public let supportedSizes: [WidgetSize]
private let model: MetricsModel
public init(metric: MetricKind, model: MetricsModel) {
self.metric = metric
self.model = model
self.id = switch metric {
case .cpu: "cpu"
case .gpu: "gpu"
case .memory: "memory"
case .battery: "battery"
case .temperature: "sensors"
}
self.supportedSizes = metric == .temperature ? [.medium, .large] : [.small, .medium]
}
public func makeView(size: WidgetSize) -> AnyView {
AnyView(MetricWidgetView(model: model, metric: metric, size: size))
}
}
extension MetricKind {
var localizationKey: String {
switch self {
case .cpu: "metric.cpu"
case .gpu: "metric.gpu"
case .memory: "metric.memory"
case .battery: "metric.battery"
case .temperature: "metric.sensors"
}
}
}
private struct MetricWidgetView: View {
let model: MetricsModel
let metric: MetricKind
let size: WidgetSize
@State private var token: UUID?
var body: some View {
Group {
switch (metric, size) {
case (.temperature, _):
SensorList(snapshot: model.snapshot, detailed: size == .large)
case (_, .small):
CompactMetric(model: model, metric: metric)
default:
WideMetric(model: model, metric: metric)
}
}
.onAppear {
// Bei offenem Panel schneller messen: dort sieht man die Zahl,
// und ein Wert, der nur alle zwei Sekunden springt, wirkt träge.
token = model.addConsumer(interval: 1)
}
.onDisappear {
if let token { model.removeConsumer(token) }
token = nil
}
}
}
// MARK: - Bausteine
private struct CompactMetric: View {
let model: MetricsModel
let metric: MetricKind
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Label {
Text(String(localized: .init(metric.localizationKey), bundle: .module))
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
} icon: {
Image(systemName: metric.symbolName)
.font(.system(size: 11))
.foregroundStyle(Onyx.Color.accent)
}
Spacer(minLength: 0)
Text(MetricSummary.value(metric, model.snapshot))
.font(Onyx.Font.metric)
.foregroundStyle(MetricSummary.color(metric, model.snapshot))
.animation(Onyx.Motion.value, value: MetricSummary.value(metric, model.snapshot))
if let detail = MetricSummary.detail(metric, model.snapshot) {
Text(detail)
.font(.system(size: 9))
.monospacedDigit()
.foregroundStyle(Onyx.Color.textTertiary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
private struct WideMetric: View {
let model: MetricsModel
let metric: MetricKind
var body: some View {
HStack(spacing: 10) {
CompactMetric(model: model, metric: metric)
.frame(width: 84)
Sparkline(values: model.series(metric),
tint: MetricSummary.color(metric, model.snapshot))
}
}
}
/// Verlaufskurve. Bewusst ohne Achsen und Beschriftung: auf 90 × 40 Punkten
/// ist die Form die Information, nicht der Zahlenwert.
public struct Sparkline: View {
let values: [Double]
let tint: Color
public init(values: [Double], tint: Color) {
self.values = values
self.tint = tint
}
public var body: some View {
GeometryReader { geometry in
let points = Array(values.suffix(MetricsModel.historyLength))
if points.count > 1 {
let size = geometry.size
let step = size.width / CGFloat(points.count - 1)
let coordinates = points.enumerated().map { index, value in
CGPoint(x: CGFloat(index) * step,
y: size.height * (1 - CGFloat(min(max(value, 0), 1))))
}
let line = Path { path in
for (index, point) in coordinates.enumerated() {
index == 0 ? path.move(to: point) : path.addLine(to: point)
}
}
// Dieselbe Kurve, unten geschlossen die Fläche darunter gibt
// dem Verlauf Gewicht, ohne dass eine zweite Linie nötig wäre.
let area = Path { path in
path.move(to: CGPoint(x: 0, y: size.height))
coordinates.forEach { path.addLine(to: $0) }
path.addLine(to: CGPoint(x: size.width, y: size.height))
path.closeSubpath()
}
ZStack {
area.fill(LinearGradient(colors: [tint.opacity(0.22), .clear],
startPoint: .top, endPoint: .bottom))
line.stroke(tint, style: .init(lineWidth: 1.5, lineJoin: .round))
}
}
}
}
}
private struct SensorList: View {
let snapshot: MetricsSnapshot
let detailed: Bool
var body: some View {
VStack(alignment: .leading, spacing: 3) {
ForEach(snapshot.sensors.prefix(detailed ? 8 : 3)) { sensor in
HStack(spacing: 6) {
Text(sensor.name)
.font(.system(size: 10))
.foregroundStyle(Onyx.Color.textSecondary)
.lineLimit(1)
Spacer(minLength: 0)
Text(MetricFormat.temperature(sensor.celsius))
.font(Onyx.Font.metricSmall)
.foregroundStyle(MetricSummary.temperatureColor(sensor.celsius))
}
}
if detailed, !snapshot.fans.isEmpty {
Divider().overlay(Onyx.Color.hairline).padding(.vertical, 2)
ForEach(snapshot.fans) { fan in
HStack(spacing: 6) {
Image(systemName: "fan")
.font(.system(size: 9))
.foregroundStyle(Onyx.Color.textTertiary)
Text("\(Int(fan.rpm))")
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textSecondary)
Text(verbatim: "U/min")
.font(.system(size: 9))
.foregroundStyle(Onyx.Color.textTertiary)
Spacer(minLength: 0)
}
}
}
if snapshot.sensors.isEmpty {
Text("metric.sensors.none", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
/// Wie eine Größe zu Text und Farbe wird an einer Stelle, damit Panel und
/// Menüleiste nicht auseinanderlaufen.
public enum MetricSummary {
public static func value(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> String {
switch metric {
case .cpu: MetricFormat.percent(snapshot.cpu.total)
case .gpu: snapshot.gpu.map(MetricFormat.percent) ?? ""
case .memory: MetricFormat.percent(snapshot.memory.usedFraction)
case .battery: snapshot.battery.map { MetricFormat.percent($0.charge) } ?? ""
case .temperature: snapshot.sensors.map(\.celsius).max()
.map(MetricFormat.temperature) ?? ""
}
}
public static func detail(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> String? {
switch metric {
case .cpu:
return snapshot.cpu.watts.map(MetricFormat.watts)
case .memory:
return ByteCountFormatter.string(fromByteCount: Int64(snapshot.memory.used),
countStyle: .binary)
case .battery:
guard let battery = snapshot.battery else { return nil }
if battery.isCharging {
return String(localized: "metric.battery.charging", bundle: .module)
}
// Beim Entladen ist die Leistung negativ; der Betrag ist gemeint.
return battery.watts.map { MetricFormat.watts(abs($0)) }
case .gpu, .temperature:
return nil
}
}
public static func color(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> Color {
switch metric {
case .memory:
switch snapshot.memory.pressure {
case .normal: return Onyx.Color.textPrimary
case .warning: return Onyx.Color.warning
case .critical: return Onyx.Color.critical
}
case .battery:
guard let battery = snapshot.battery else { return Onyx.Color.textPrimary }
if battery.isCharging { return Onyx.Color.positive }
return battery.charge < 0.2 ? Onyx.Color.critical : Onyx.Color.textPrimary
case .temperature:
return temperatureColor(snapshot.sensors.map(\.celsius).max() ?? 0)
case .cpu, .gpu:
return Onyx.Color.textPrimary
}
}
public static func temperatureColor(_ celsius: Double) -> Color {
// Grenzwerte für Apple Silicon: unter 70 unauffällig, ab 90 wird
// gedrosselt. Dazwischen ist es warm, aber unproblematisch.
switch celsius {
case ..<70: Onyx.Color.textPrimary
case ..<90: Onyx.Color.warning
default: Onyx.Color.critical
}
}
}

View File

@@ -0,0 +1,108 @@
import Foundation
import SwiftUI
/// Ein Messwertstrom mit Referenzzählung.
///
/// Panel-Widget und Menüleisten-Modul greifen auf **dieselbe** Messschleife zu.
/// Ohne das liefe für jede Anzeige ein eigener Timer, der dieselben IOKit- und
/// SMC-Abfragen doppelt macht bei sechs Modulen und ebenso vielen Widgets
/// wäre das ein Vielfaches der nötigen Arbeit.
///
/// Meldet sich der letzte Konsument ab, hört die Schleife auf. Im Ruhezustand
/// misst Onyx nichts.
@MainActor
@Observable
public final class MetricsModel {
public private(set) var snapshot = MetricsSnapshot()
/// Verlauf für die Graphen, jüngster Wert zuletzt.
public private(set) var history: [MetricsSnapshot] = []
/// So viele Punkte zeigt ein Menüleisten-Graph. Mehr aufzuheben kostet
/// Speicher für etwas, das niemand sieht.
public static let historyLength = 60
private let source = SystemMetrics()
private var timer: Timer?
/// Je Konsument das gewünschte Intervall. Gemessen wird mit dem kürzesten.
private var demands: [UUID: TimeInterval] = [:]
public init() {}
/// Meldet Bedarf an. Der Rückgabewert wird zum Abmelden gebraucht.
@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()
}
/// Ändert das Intervall eines bestehenden Konsumenten, ohne die Schleife
/// anzuhalten etwa wenn das Panel aufgeht und schneller messen will.
public func updateConsumer(_ token: UUID, interval: TimeInterval) {
guard demands[token] != nil else { return }
demands[token] = min(max(interval, 1), 10)
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() }
}
// `.common`, sonst steht die Messung, während ein Menü offen ist.
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}
private func sample() {
let value = source.sample()
snapshot = value
history.append(value)
if history.count > Self.historyLength { history.removeFirst(history.count - Self.historyLength) }
}
}
public extension MetricsModel {
/// Verlauf einer einzelnen Größe, für die Mini-Graphen.
func series(_ metric: MetricKind) -> [Double] {
history.compactMap { snapshot in
switch metric {
case .cpu: snapshot.cpu.total
case .gpu: snapshot.gpu
case .memory: snapshot.memory.usedFraction
case .battery: snapshot.battery?.charge
case .temperature: snapshot.sensors.map(\.celsius).max().map { $0 / 100 }
}
}
}
}
public enum MetricKind: String, CaseIterable, Sendable, Identifiable {
case cpu, gpu, memory, battery, temperature
public var id: String { rawValue }
public var symbolName: String {
switch self {
case .cpu: "cpu"
case .gpu: "cpu.fill"
case .memory: "memorychip"
case .battery: "battery.100"
case .temperature: "thermometer"
}
}
}

View File

@@ -0,0 +1,143 @@
import Foundation
import IOKit
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "SMC")
/// Liest Werte aus dem System Management Controller. **Ausschließlich lesend.**
///
/// Es gibt hier bewusst keinen Schreibpfad. Lüfterdrehzahl und Ladegrenze
/// verlangen root und gehören in den privilegierten Helfer aus Phase 6 nicht
/// in einen Typ, der im normalen App-Prozess läuft und von jedem Widget
/// erreichbar ist.
///
/// Die Struktur und die Erkenntnisse stammen aus `docs/spikes/A-smc.md`, gemessen
/// auf Mac17,9. Wichtigster Punkt von dort: **alle Mehrbyte-Werte sind
/// little-endian.** Der verbreitete Beispielcode nimmt big-endian an er
/// stammt aus der Intel-Ära und liefert auf Apple Silicon Unsinn.
public final class SMCReader: @unchecked Sendable {
// MARK: - Aufbau des AppleSMC-UserClients
private struct Version { var major: UInt8 = 0; var minor: UInt8 = 0
var build: UInt8 = 0; var reserved: UInt8 = 0
var release: UInt16 = 0 }
private struct PLimitData { var version: UInt16 = 0; var length: UInt16 = 0
var cpuPLimit: UInt32 = 0; var gpuPLimit: UInt32 = 0
var memPLimit: UInt32 = 0 }
private struct KeyInfo { var dataSize: UInt32 = 0; var dataType: UInt32 = 0
var dataAttributes: UInt8 = 0 }
private struct Param {
var key: UInt32 = 0
var vers = Version()
var pLimitData = PLimitData()
var keyInfo = KeyInfo()
var padding: UInt16 = 0
var result: UInt8 = 0
var status: UInt8 = 0
var data8: UInt8 = 0
var data32: UInt32 = 0
var bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) =
(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
}
private static let handleYPCEvent: UInt32 = 2
private static let readKey: UInt8 = 5
private static let getKeyInfo: UInt8 = 9
private var connection: io_connect_t = 0
private let lock = NSLock()
public init?() {
let service = IOServiceGetMatchingService(kIOMainPortDefault,
IOServiceMatching("AppleSMC"))
guard service != 0 else {
log.error("AppleSMC nicht gefunden — Sensoren bleiben leer")
return nil
}
defer { IOObjectRelease(service) }
guard IOServiceOpen(service, mach_task_self_, 0, &connection) == kIOReturnSuccess else {
log.error("AppleSMC nicht zu öffnen")
return nil
}
}
deinit { if connection != 0 { IOServiceClose(connection) } }
// MARK: - Lesen
/// Ein `flt`-Wert (Lüfterdrehzahl, Temperatur, Leistung).
public func float(_ key: String) -> Double? {
guard let value = read(key), value.type == "flt ", value.bytes.count >= 4 else { return nil }
// Little-endian, siehe Klassenkommentar.
let bits = UInt32(value.bytes[3]) << 24 | UInt32(value.bytes[2]) << 16
| UInt32(value.bytes[1]) << 8 | UInt32(value.bytes[0])
return Double(Float(bitPattern: bits))
}
/// Ein vorzeichenloser Ganzzahlwert beliebiger Breite.
public func integer(_ key: String) -> UInt64? {
guard let value = read(key) else { return nil }
var result: UInt64 = 0
for (index, byte) in value.bytes.prefix(8).enumerated() {
result |= UInt64(byte) << (8 * index) // little-endian
}
return result
}
public func exists(_ key: String) -> Bool { read(key) != nil }
private struct Value { let type: String; let bytes: [UInt8] }
private func read(_ key: String) -> Value? {
lock.lock()
defer { lock.unlock() }
var info = Param()
info.key = Self.fourCC(key)
info.data8 = Self.getKeyInfo
guard let described = call(info) else { return nil }
var command = Param()
command.key = Self.fourCC(key)
command.keyInfo = described.keyInfo
command.data8 = Self.readKey
guard let output = call(command) else { return nil }
let size = Int(min(described.keyInfo.dataSize, 32))
let all = withUnsafeBytes(of: output.bytes) { Array($0) }
return Value(type: Self.fourCCString(described.keyInfo.dataType),
bytes: Array(all.prefix(size)))
}
private func call(_ input: Param) -> Param? {
var input = input
var output = Param()
var size = MemoryLayout<Param>.stride
let result = IOConnectCallStructMethod(connection, Self.handleYPCEvent,
&input, MemoryLayout<Param>.stride,
&output, &size)
// Ein nicht vorhandener Key meldet sich über `result` und ist kein
// Fehler: welche Keys es gibt, unterscheidet sich je nach Modell.
guard result == kIOReturnSuccess, output.result == 0 else { return nil }
return output
}
private static func fourCC(_ string: String) -> UInt32 {
var value: UInt32 = 0
for character in string.utf8.prefix(4) { value = value << 8 | UInt32(character) }
return value
}
private static func fourCCString(_ value: UInt32) -> String {
let bytes = [UInt8((value >> 24) & 0xFF), UInt8((value >> 16) & 0xFF),
UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)]
return String(bytes: bytes, encoding: .ascii) ?? "????"
}
}

View File

@@ -0,0 +1,294 @@
import Foundation
import IOKit
import IOKit.ps
import Darwin
/// Ein vollständiger Messwertsatz.
public struct MetricsSnapshot: Equatable, Sendable {
public var cpu = CPUReading()
public var gpu: Double?
public var memory = MemoryReading()
public var battery: BatteryReading?
public var sensors: [SensorReading] = []
public var fans: [FanReading] = []
public var asOf = Date()
}
public struct CPUReading: Equatable, Sendable {
/// Gesamtauslastung, 0 bis 1.
public var total: Double = 0
/// Auslastung je Kern, in der Reihenfolge des Systems.
public var perCore: [Double] = []
/// Leistungsaufnahme in Watt, falls messbar.
public var watts: Double?
}
public struct MemoryReading: Equatable, Sendable {
public var used: UInt64 = 0
public var total: UInt64 = 0
public var compressed: UInt64 = 0
public var swapUsed: UInt64 = 0
public var pressure: MemoryPressure = .normal
public var usedFraction: Double {
total > 0 ? Double(used) / Double(total) : 0
}
}
public struct BatteryReading: Equatable, Sendable {
public var charge: Double = 0 // 0 bis 1
public var isCharging = false
public var isPluggedIn = false
public var cycleCount: Int?
public var health: Double? // 0 bis 1
public var watts: Double? // negativ = Entladung
public var timeRemaining: TimeInterval?
}
public struct SensorReading: Equatable, Sendable, Identifiable {
public let key: String
public let name: String
public let celsius: Double
public var id: String { key }
}
public struct FanReading: Equatable, Sendable, Identifiable {
public let index: Int
public let rpm: Double
public let minimum: Double
public let maximum: Double
public var id: Int { index }
/// Anteil zwischen Minimum und Maximum, 0 bis 1.
public var fraction: Double {
guard maximum > minimum else { return 0 }
return min(max((rpm - minimum) / (maximum - minimum), 0), 1)
}
}
/// Liest die Hardwarewerte des Systems.
///
/// Bewusst kein Singleton mit Timer: das Abtasten steuert `MetricsModel`, weil
/// nur dort bekannt ist, wie viele Konsumenten es gerade gibt und wie schnell
/// sie es brauchen.
public final class SystemMetrics: @unchecked Sendable {
private let smc = SMCReader()
private var previousCPUTicks: [CPUTicks] = []
/// Seit dem Start unveränderlich einmal ermitteln reicht.
private static let pageSize: UInt64 = {
var size: UInt64 = 0
var length = MemoryLayout<UInt64>.size
guard sysctlbyname("hw.pagesize", &size, &length, nil, 0) == 0, size > 0 else {
return 16384 // Apple Silicon
}
return size
}()
public init() {}
public func sample() -> MetricsSnapshot {
var snapshot = MetricsSnapshot()
snapshot.cpu = readCPU()
snapshot.gpu = readGPU()
snapshot.memory = readMemory()
snapshot.battery = readBattery()
snapshot.sensors = readSensors()
snapshot.fans = readFans()
snapshot.cpu.watts = smc?.float("PSTR")
return snapshot
}
// MARK: - CPU
private func readCPU() -> CPUReading {
var count: natural_t = 0
var info: processor_info_array_t?
var infoCount: mach_msg_type_number_t = 0
guard host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO,
&count, &info, &infoCount) == KERN_SUCCESS,
let info else { return CPUReading() }
defer {
vm_deallocate(mach_task_self_, vm_address_t(bitPattern: info),
vm_size_t(infoCount) * vm_size_t(MemoryLayout<integer_t>.stride))
}
var current: [CPUTicks] = []
current.reserveCapacity(Int(count))
for core in 0..<Int(count) {
let base = core * Int(CPU_STATE_MAX)
current.append(CPUTicks(
user: UInt32(bitPattern: info[base + Int(CPU_STATE_USER)]),
system: UInt32(bitPattern: info[base + Int(CPU_STATE_SYSTEM)]),
idle: UInt32(bitPattern: info[base + Int(CPU_STATE_IDLE)]),
nice: UInt32(bitPattern: info[base + Int(CPU_STATE_NICE)])))
}
// Die erste Abtastung hat keinen Vorgänger und kann deshalb keine
// Auslastung ergeben. Null zu zeigen ist ehrlicher als der Anteil seit
// dem Systemstart, den man sonst bekäme der liegt immer bei ein paar
// Prozent und sieht aus wie ein Messwert.
defer { previousCPUTicks = current }
guard previousCPUTicks.count == current.count else { return CPUReading() }
let perCore = zip(previousCPUTicks, current).map(CPUTicks.usage(from:to:))
return CPUReading(total: perCore.isEmpty ? 0 : perCore.reduce(0, +) / Double(perCore.count),
perCore: perCore,
watts: nil)
}
// MARK: - GPU
private func readGPU() -> Double? {
var iterator: io_iterator_t = 0
guard IOServiceGetMatchingServices(kIOMainPortDefault,
IOServiceMatching("IOAccelerator"),
&iterator) == kIOReturnSuccess else { return nil }
defer { IOObjectRelease(iterator) }
while case let entry = IOIteratorNext(iterator), entry != 0 {
defer { IOObjectRelease(entry) }
var unmanaged: Unmanaged<CFMutableDictionary>?
guard IORegistryEntryCreateCFProperties(entry, &unmanaged, kCFAllocatorDefault, 0)
== kIOReturnSuccess,
let properties = unmanaged?.takeRetainedValue() as? [String: Any],
let statistics = properties["PerformanceStatistics"] as? [String: Any]
else { continue }
// Der Schlüssel heißt je nach Gerät unterschiedlich; auf Apple
// Silicon ist es Device Utilization %".
for name in ["Device Utilization %", "GPU Activity(%)", "Renderer Utilization %"] {
if let value = statistics[name] as? Int { return Double(value) / 100 }
}
}
return nil
}
// MARK: - Arbeitsspeicher
private func readMemory() -> MemoryReading {
var statistics = vm_statistics64_data_t()
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64_data_t>.size
/ MemoryLayout<integer_t>.size)
let result = withUnsafeMutablePointer(to: &statistics) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
}
}
guard result == KERN_SUCCESS else { return MemoryReading() }
// `vm_kernel_page_size` ist eine globale Variable und damit unter
// strenger Nebenläufigkeitsprüfung tabu. Der Wert steht seit dem Start
// fest, also einmal über sysctl holen.
let pageSize = Self.pageSize
let total = ProcessInfo.processInfo.physicalMemory
let compressed = UInt64(statistics.compressor_page_count) * pageSize
// Benutzt" wie im Aktivitätsmonitor: aktiv, verdrahtet und komprimiert.
// Der Dateicache (`external_page_count`) zählt nicht mit er wird bei
// Bedarf freigegeben, und ihn mitzuzählen ließe jeden Mac dauernd voll
// aussehen.
let used = UInt64(statistics.active_count) * pageSize
+ UInt64(statistics.wire_count) * pageSize
+ compressed
var swap = xsw_usage()
var swapSize = MemoryLayout<xsw_usage>.size
sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0)
let free = total > 0 ? 1 - Double(used) / Double(total) : 0
return MemoryReading(used: used, total: total, compressed: compressed,
swapUsed: swap.xsu_used,
pressure: MemoryPressure.classify(
free: free,
compressed: total > 0 ? Double(compressed) / Double(total) : 0,
swapUsed: swap.xsu_used))
}
// MARK: - Akku
private func readBattery() -> BatteryReading? {
guard let blob = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
let sources = IOPSCopyPowerSourcesList(blob)?.takeRetainedValue() as? [CFTypeRef],
let source = sources.first,
let description = IOPSGetPowerSourceDescription(blob, source)?
.takeUnretainedValue() as? [String: Any] else { return nil }
var reading = BatteryReading()
if let current = description[kIOPSCurrentCapacityKey] as? Int,
let maximum = description[kIOPSMaxCapacityKey] as? Int, maximum > 0 {
reading.charge = Double(current) / Double(maximum)
}
reading.isCharging = description[kIOPSIsChargingKey] as? Bool ?? false
reading.isPluggedIn = (description[kIOPSPowerSourceStateKey] as? String)
== kIOPSACPowerValue
if let seconds = description[kIOPSTimeToEmptyKey] as? Int, seconds > 0 {
reading.timeRemaining = TimeInterval(seconds * 60)
}
if let seconds = description[kIOPSTimeToFullChargeKey] as? Int, seconds > 0 {
reading.timeRemaining = TimeInterval(seconds * 60)
}
// Zyklen und Gesundheit stehen nicht in der Power-Source-Beschreibung,
// sondern in der Batterie selbst.
let service = IOServiceGetMatchingService(kIOMainPortDefault,
IOServiceMatching("AppleSmartBattery"))
if service != 0 {
defer { IOObjectRelease(service) }
var unmanaged: Unmanaged<CFMutableDictionary>?
if IORegistryEntryCreateCFProperties(service, &unmanaged, kCFAllocatorDefault, 0)
== kIOReturnSuccess,
let properties = unmanaged?.takeRetainedValue() as? [String: Any] {
reading.cycleCount = properties["CycleCount"] as? Int
if let maximum = properties["MaxCapacity"] as? Int, maximum > 0 {
reading.health = Double(maximum) / 100
}
}
}
// Leistung aus dem SMC: positiv beim Laden, negativ beim Entladen.
if let watts = smc?.float("PPBR") {
reading.watts = reading.isCharging ? watts : -watts
}
return reading
}
// MARK: - Sensoren und Lüfter
/// Die Temperaturfühler, die auf `Mac17,9` tatsächlich existieren.
///
/// Bewusst eine kurze Liste statt aller 3486 SMC-Keys: die meisten sind
/// unbenannt, und eine Wand aus Kürzeln ist keine Information. Was fehlt,
/// fällt beim Lesen einfach weg.
private static let temperatureKeys: [(String, String)] = [
("Ts0P", "Gehäuse vorn"),
("Ts1P", "Gehäuse hinten"),
("TB0T", "Akku"),
("TW0P", "WLAN"),
("TH0x", "SSD"),
]
private func readSensors() -> [SensorReading] {
guard let smc else { return [] }
return Self.temperatureKeys.compactMap { key, name in
guard let celsius = smc.float(key), celsius > 0, celsius < 150 else { return nil }
return SensorReading(key: key, name: name, celsius: celsius)
}
}
private func readFans() -> [FanReading] {
guard let smc, let count = smc.integer("FNum"), count > 0 else { return [] }
return (0..<Int(count)).compactMap { index in
guard let rpm = smc.float("F\(index)Ac") else { return nil }
return FanReading(index: index,
rpm: rpm,
minimum: smc.float("F\(index)Mn") ?? 0,
maximum: smc.float("F\(index)Mx") ?? 0)
}
}
}

View File

@@ -0,0 +1,26 @@
{
"sourceLanguage" : "en",
"strings" : {
"network.name" : { "localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Netzwerk" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Network" } } } },
"network.wifi.connected" : {
"comment" : "Wenn der Netzwerkname mangels Ortungsberechtigung fehlt",
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "WLAN verbunden" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Wi-Fi connected" } } } },
"network.offline" : { "localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Nicht verbunden" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Not connected" } } } },
"network.field.interface" : { "localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Schnittstelle" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Interface" } } } },
"network.field.ipv4" : { "localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "IP" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "IP" } } } },
"network.field.router" : { "localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Router" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Router" } } } }
},
"version" : "1.0"
}

View File

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

View File

@@ -0,0 +1,214 @@
import Foundation
import Darwin
import SystemConfiguration
import CoreWLAN
import MetricsProvider
public struct NetworkSnapshot: Equatable, Sendable {
/// Byte pro Sekunde.
public var downloadRate: Double = 0
public var uploadRate: Double = 0
/// Summen seit dem Systemstart.
public var totalReceived: UInt64 = 0
public var totalSent: UInt64 = 0
public var interfaceName: String?
public var interfaceKind: InterfaceKind = .unknown
public var ipv4: String?
public var ipv6: String?
public var router: String?
/// `nil`, solange die Ortungsberechtigung fehlt macOS gibt den Namen
/// sonst nicht heraus. Siehe `wifiName`.
public var ssid: String?
public var isWiFiConnected = false
public var asOf = Date()
}
public enum InterfaceKind: String, Equatable, Sendable {
case wifi, ethernet, vpn, cellular, loopback, unknown
public var symbolName: String {
switch self {
case .wifi: "wifi"
case .ethernet: "cable.connector"
case .vpn: "lock.shield"
case .cellular: "antenna.radiowaves.left.and.right"
case .loopback, .unknown: "network"
}
}
}
/// Liest Durchsatz, Adressen und Verbindungsart.
///
/// Alles ohne root und ohne private Schnittstellen. Der Durchsatz kommt aus den
/// Byte-Zählern des Kernels; sie sind Summen seit dem Start, die Rate ergibt
/// sich erst aus der Differenz siehe `Throughput.rate`.
public final class NetworkMetrics: @unchecked Sendable {
private var previousReceived: UInt64 = 0
private var previousSent: UInt64 = 0
private var previousSampleTime: Date?
public init() {}
public func sample() -> NetworkSnapshot {
var snapshot = NetworkSnapshot()
let counters = readCounters()
snapshot.totalReceived = counters.received
snapshot.totalSent = counters.sent
let now = Date()
if let previous = previousSampleTime {
let elapsed = now.timeIntervalSince(previous)
snapshot.downloadRate = Throughput.rate(previous: previousReceived,
current: counters.received, elapsed: elapsed)
snapshot.uploadRate = Throughput.rate(previous: previousSent,
current: counters.sent, elapsed: elapsed)
}
previousReceived = counters.received
previousSent = counters.sent
previousSampleTime = now
let primary = primaryInterface()
snapshot.interfaceName = primary.name
snapshot.interfaceKind = primary.kind
snapshot.router = primary.router
let addresses = localAddresses(for: primary.name)
snapshot.ipv4 = addresses.ipv4
snapshot.ipv6 = addresses.ipv6
snapshot.isWiFiConnected = primary.kind == .wifi
snapshot.ssid = wifiName()
return snapshot
}
// MARK: - Durchsatz
/// Summiert alle Interfaces außer Loopback.
///
/// Einzeln zu zählen wäre genauer, aber praktisch nutzlos: bei einem
/// Wechsel von WLAN auf VPN wandert der Verkehr auf ein anderes Interface,
/// und eine Anzeige, die dabei auf null fällt, ist falsch.
private func readCounters() -> (received: UInt64, sent: UInt64) {
var mib: [Int32] = [CTL_NET, PF_ROUTE, 0, 0, NET_RT_IFLIST2, 0]
var length = 0
guard sysctl(&mib, 6, nil, &length, nil, 0) == 0, length > 0 else { return (0, 0) }
var buffer = [UInt8](repeating: 0, count: length)
guard sysctl(&mib, 6, &buffer, &length, nil, 0) == 0 else { return (0, 0) }
var received: UInt64 = 0
var sent: UInt64 = 0
buffer.withUnsafeBytes { raw in
var offset = 0
while offset < length {
let header = raw.baseAddress!.advanced(by: offset)
.assumingMemoryBound(to: if_msghdr.self).pointee
guard header.ifm_msglen > 0 else { break }
defer { offset += Int(header.ifm_msglen) }
guard header.ifm_type == RTM_IFINFO2 else { continue }
let message = raw.baseAddress!.advanced(by: offset)
.assumingMemoryBound(to: if_msghdr2.self).pointee
// Loopback ausschließen: der lokale Verkehr zwischen Programmen
// hat mit was geht über die Leitung" nichts zu tun und kann
// ein Vielfaches davon sein.
guard message.ifm_data.ifi_type != UInt8(IFT_LOOP) else { continue }
received += message.ifm_data.ifi_ibytes
sent += message.ifm_data.ifi_obytes
}
}
return (received, sent)
}
// MARK: - Interface und Adressen
private struct Primary {
var name: String?
var kind: InterfaceKind = .unknown
var router: String?
}
private func primaryInterface() -> Primary {
var primary = Primary()
guard let store = SCDynamicStoreCreate(nil, "Onyx" as CFString, nil, nil),
let global = SCDynamicStoreCopyValue(store, "State:/Network/Global/IPv4" as CFString)
as? [String: Any] else { return primary }
primary.name = global["PrimaryInterface"] as? String
primary.router = global["Router"] as? String
primary.kind = kind(of: primary.name)
return primary
}
private func kind(of name: String?) -> InterfaceKind {
guard let name else { return .unknown }
// Die Namensschemata sind stabil: en0/en1 sind Ethernet oder WLAN,
// utun/ipsec/ppp gehören zu VPNs, lo0 ist die Loopback-Schnittstelle.
if name.hasPrefix("utun") || name.hasPrefix("ipsec") || name.hasPrefix("ppp") { return .vpn }
if name.hasPrefix("lo") { return .loopback }
if name.hasPrefix("pdp_ip") { return .cellular }
if name.hasPrefix("en") {
// en0 ist auf Notebooks das WLAN, aber nicht zwingend CoreWLAN
// weiß es genau.
let wifiNames = CWWiFiClient.interfaceNames() ?? []
return wifiNames.contains(name) ? .wifi : .ethernet
}
return .unknown
}
private func localAddresses(for interface: String?) -> (ipv4: String?, ipv6: String?) {
guard let interface else { return (nil, nil) }
var pointer: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&pointer) == 0, let first = pointer else { return (nil, nil) }
defer { freeifaddrs(pointer) }
var ipv4: String?
var ipv6: String?
for entry in sequence(first: first, next: { $0.pointee.ifa_next }) {
guard String(cString: entry.pointee.ifa_name) == interface,
let address = entry.pointee.ifa_addr else { continue }
var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
guard getnameinfo(address, socklen_t(address.pointee.sa_len),
&host, socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0
else { continue }
let text = String(cString: host)
switch Int32(address.pointee.sa_family) {
case AF_INET where ipv4 == nil:
ipv4 = text
case AF_INET6 where ipv6 == nil:
// Link-lokale Adressen (fe80::) tragen einen Zonen-Anhang und
// sagen dem Nutzer nichts.
if !text.hasPrefix("fe80") { ipv6 = text }
default:
break
}
}
return (ipv4, ipv6)
}
// MARK: - WLAN
/// Der Netzwerkname oder `nil`, wenn macOS ihn nicht herausgibt.
///
/// Seit macOS 14 liefert `ssid()` nur mit erteilter Ortungsberechtigung
/// einen Wert. Fehlt sie, kommt `nil` zurück, **ohne** dass ein Fehler
/// erscheint. Deshalb wird hier nicht geraten: die Oberfläche zeigt dann
/// WLAN verbunden" statt eines leeren Feldes, das nach einem Defekt
/// aussieht.
private func wifiName() -> String? {
CWWiFiClient.shared().interface()?.ssid()
}
}

View File

@@ -0,0 +1,193 @@
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
private let source = NetworkMetrics()
private var timer: Timer?
private var demands: [UUID: TimeInterval] = [:]
public init() {}
@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
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"
public let supportedSizes: [WidgetSize] = [.small, .medium, .large]
private let model: NetworkModel
public init(model: NetworkModel) { self.model = model }
public func makeView(size: WidgetSize) -> AnyView {
AnyView(NetworkWidgetView(model: model, size: size))
}
}
private struct NetworkWidgetView: View {
let model: NetworkModel
let size: WidgetSize
@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: size == .small)
if size != .small {
Sparkline(values: model.series(download: true), tint: Onyx.Color.accent)
.frame(height: 18)
}
if size == .large {
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)
}
}
}
}

View File

@@ -0,0 +1,180 @@
import Testing
import Foundation
@testable import MetricsProvider
// Alle Messwerte hier sind Differenzen zwischen zwei Abtastungen. Das klingt
// harmlos und ist die Stelle, an der Systemmonitore falsche Zahlen anzeigen:
// die Zähler sind 32 Bit breit und laufen über, Intervalle sind nie exakt
// gleich lang, und beim Interface-Wechsel fangen sie wieder bei null an.
@Suite("CPU-Auslastung")
struct CPUUsageTests {
@Test("Halb beschäftigt, halb untätig ergibt 50 Prozent")
func halfBusy() {
let before = CPUTicks(user: 100, system: 0, idle: 100, nice: 0)
let after = CPUTicks(user: 200, system: 0, idle: 200, nice: 0)
#expect(CPUTicks.usage(from: before, to: after) == 0.5)
}
@Test("Nur Leerlauf ergibt null")
func onlyIdle() {
let before = CPUTicks(user: 0, system: 0, idle: 0, nice: 0)
let after = CPUTicks(user: 0, system: 0, idle: 100, nice: 0)
#expect(CPUTicks.usage(from: before, to: after) == 0)
}
@Test("Kein Leerlauf ergibt volle Auslastung")
func fullyBusy() {
let before = CPUTicks(user: 0, system: 0, idle: 50, nice: 0)
let after = CPUTicks(user: 100, system: 0, idle: 50, nice: 0)
#expect(CPUTicks.usage(from: before, to: after) == 1)
}
@Test("System- und Nice-Zeit zählen als Beschäftigung mit")
func systemAndNiceCount() {
let before = CPUTicks(user: 0, system: 0, idle: 0, nice: 0)
let after = CPUTicks(user: 25, system: 25, idle: 50, nice: 0)
#expect(CPUTicks.usage(from: before, to: after) == 0.5)
}
@Test("Zwei identische Abtastungen ergeben null statt einer Division durch null")
func identicalSamples() {
let ticks = CPUTicks(user: 10, system: 10, idle: 10, nice: 10)
#expect(CPUTicks.usage(from: ticks, to: ticks) == 0)
}
@Test("Ein Zählerüberlauf ergibt keinen Ausreißer")
func counterWraparound() {
// 32-Bit-Zähler laufen nach gut 400 Tagen bei 100 Hz über. Rechnet man
// naiv, springt die Anzeige einmalig auf einen absurden Wert.
let before = CPUTicks(user: .max - 10, system: 0, idle: .max - 10, nice: 0)
let after = CPUTicks(user: 10, system: 0, idle: 10, nice: 0)
let usage = CPUUsageRange.contains(CPUTicks.usage(from: before, to: after))
#expect(usage)
}
@Test("Das Ergebnis liegt immer zwischen 0 und 1")
func alwaysNormalised() {
for _ in 0..<50 {
let before = CPUTicks(user: .random(in: 0...100_000), system: .random(in: 0...100_000),
idle: .random(in: 0...100_000), nice: .random(in: 0...100_000))
let after = CPUTicks(user: .random(in: 0...100_000), system: .random(in: 0...100_000),
idle: .random(in: 0...100_000), nice: .random(in: 0...100_000))
#expect(CPUUsageRange.contains(CPUTicks.usage(from: before, to: after)))
}
}
}
private let CPUUsageRange = 0.0...1.0
@Suite("Speicherdruck")
struct MemoryPressureTests {
@Test("Viel frei bedeutet grüner Bereich")
func plentyFree() {
#expect(MemoryPressure.classify(free: 0.5, compressed: 0.05, swapUsed: 0) == .normal)
}
@Test("Wenig frei und viel komprimiert bedeutet Warnbereich")
func compressedMeansWarning() {
#expect(MemoryPressure.classify(free: 0.08, compressed: 0.25, swapUsed: 0) == .warning)
}
@Test("Aktive Auslagerung ist immer kritisch, egal wie viel frei scheint")
func swapIsAlwaysCritical() {
// Sobald ausgelagert wird, bremst der Rechner spürbar auch wenn die
// Freispeicheranzeige noch harmlos aussieht.
#expect(MemoryPressure.classify(free: 0.4, compressed: 0.01,
swapUsed: 2 * 1024 * 1024 * 1024) == .critical)
}
@Test("Die Grenzwerte springen nicht bei winzigen Änderungen hin und her")
func classificationIsStable() {
let a = MemoryPressure.classify(free: 0.30, compressed: 0.10, swapUsed: 0)
let b = MemoryPressure.classify(free: 0.301, compressed: 0.101, swapUsed: 0)
#expect(a == b)
}
}
@Suite("Durchsatz")
struct ThroughputTests {
@Test("Tausend Byte in einer Sekunde sind tausend Byte pro Sekunde")
func simpleRate() {
#expect(Throughput.rate(previous: 0, current: 1000, elapsed: 1) == 1000)
}
@Test("Die Rate hängt nicht von der Abtastrate ab")
func rateIsIndependentOfInterval() {
// Der eigentliche Zweck: das Menüleisten-Modul tastet je nach
// Einstellung alle 1 bis 10 Sekunden ab. Ohne Normierung zeigte es bei
// 5 Sekunden das Fünffache an.
let oneSecond = Throughput.rate(previous: 0, current: 1000, elapsed: 1)
let fiveSeconds = Throughput.rate(previous: 0, current: 5000, elapsed: 5)
#expect(oneSecond == fiveSeconds)
}
@Test("Ein Zählerüberlauf ergibt keinen Ausreißer")
func wraparoundIsHandled() {
let rate = Throughput.rate(previous: UInt64(UInt32.max) - 100, current: 100, elapsed: 1)
#expect(rate >= 0)
#expect(rate < 1_000_000)
}
@Test("Ein zurückgesetzter Zähler ergibt null statt eines Sprungs")
func counterResetGivesZero() {
// Beim Interface-Wechsel WLAN aus, VPN an fangen die Zähler wieder
// bei null an. Ein naiver Vergleich zeigte dann einen riesigen Wert.
#expect(Throughput.rate(previous: 5_000_000, current: 1000, elapsed: 1) == 0)
}
@Test("Kein vergangenes Intervall ergibt null statt einer Division durch null")
func zeroElapsed() {
#expect(Throughput.rate(previous: 0, current: 1000, elapsed: 0) == 0)
}
@Test("Ein negatives Intervall wird verworfen")
func negativeElapsed() {
// Kann bei einer Zeitumstellung vorkommen.
#expect(Throughput.rate(previous: 0, current: 1000, elapsed: -5) == 0)
}
}
@Suite("Zahlenformat")
struct FormattingTests {
@Test("Byteraten wechseln die Einheit statt fünfstellig zu werden")
func rateUsesSensibleUnits() {
#expect(Throughput.formatted(0).contains("0"))
#expect(Throughput.formatted(2_400_000).contains("MB"))
#expect(Throughput.formatted(42_000).contains("KB"))
}
@Test("Die Rate trägt immer die Zeiteinheit — sonst liest man sie als Menge")
func rateAlwaysCarriesPerSecond() {
for value in [0.0, 500, 42_000, 2_400_000, 900_000_000] {
#expect(Throughput.formatted(value).hasSuffix("/s"))
}
}
@Test("Prozentwerte sind ganzzahlig")
func percentIsWhole() {
// Nachkommastellen bei der CPU-Auslastung täuschen eine Genauigkeit
// vor, die die Messung nicht hat und sie zappeln im Sekundentakt.
#expect(MetricFormat.percent(0.4567) == "46 %")
#expect(MetricFormat.percent(0) == "0 %")
#expect(MetricFormat.percent(1) == "100 %")
}
@Test("Temperaturen bekommen ein Gradzeichen und keine Nachkommastelle")
func temperatureFormat() {
#expect(MetricFormat.temperature(46.7) == "47°")
}
}

View File

@@ -0,0 +1 @@
// Platzhalter