diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj
index 1f7d5cd..56579a3 100644
--- a/Onyx.xcodeproj/project.pbxproj
+++ b/Onyx.xcodeproj/project.pbxproj
@@ -20,6 +20,8 @@
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; };
B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; };
C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; };
+ C1F651D2BA69358344927F26 /* MetricsProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 6CACA1C2A58DEFDE46F71499 /* MetricsProvider */; };
+ C390C5B65769A007A49C3A80 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; };
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; };
EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 055263008CB3306A8D09E6D4 /* AppIcon.icon */; };
@@ -53,6 +55,8 @@
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */,
5BE38CAFCDA4B9CBE787CABA /* WeatherProvider in Frameworks */,
1E2D654EFDDF55AEA75DBB7C /* MediaProvider in Frameworks */,
+ C1F651D2BA69358344927F26 /* MetricsProvider in Frameworks */,
+ C390C5B65769A007A49C3A80 /* NetworkProvider in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -135,6 +139,8 @@
A3E3949D664131D593CBEEDC /* CalendarProvider */,
D4578EB64F7A3372B49BC50C /* WeatherProvider */,
349226D70F51196CCD1C94F8 /* MediaProvider */,
+ 6CACA1C2A58DEFDE46F71499 /* MetricsProvider */,
+ FF4E99C8BF0D8944C314A8AF /* NetworkProvider */,
);
productName = Onyx;
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
@@ -444,6 +450,10 @@
isa = XCSwiftPackageProductDependency;
productName = OnyxDesign;
};
+ 6CACA1C2A58DEFDE46F71499 /* MetricsProvider */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = MetricsProvider;
+ };
A3E3949D664131D593CBEEDC /* CalendarProvider */ = {
isa = XCSwiftPackageProductDependency;
productName = CalendarProvider;
@@ -464,6 +474,10 @@
isa = XCSwiftPackageProductDependency;
productName = OnyxWidgetKit;
};
+ FF4E99C8BF0D8944C314A8AF /* NetworkProvider */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = NetworkProvider;
+ };
/* End XCSwiftPackageProductDependency section */
};
rootObject = 32F5E88F3CF35AE8C0E00613 /* Project object */;
diff --git a/Onyx/AppModel.swift b/Onyx/AppModel.swift
index 0dbf7c1..0320293 100644
--- a/Onyx/AppModel.swift
+++ b/Onyx/AppModel.swift
@@ -2,6 +2,7 @@ import SwiftUI
import AppKit
import OnyxNotch
import OnyxWidgetKit
+import OnyxMenuBar
/// Der geteilte Zustand von Onyx: was im Panel liegt und auf welchen Displays.
///
@@ -54,7 +55,36 @@ final class AppModel {
}
}
+ /// Der Menüleisten-Controller. Wird nach dem Aufbau gesetzt, damit
+ /// Änderungen aus den Einstellungen sofort durchschlagen.
+ weak var menuBar: MenuBarController?
+
+ /// Die gespeicherten Einstellungen eines Menüleisten-Moduls.
+ ///
+ /// Standardmäßig ist **kein** Modul an. Sechs Symbole beim ersten Start
+ /// wären eine Zumutung — wer sie will, schaltet sie einzeln ein.
+ func menuBarSettings(for moduleID: String) -> MenuBarModuleSettings {
+ guard let data = defaults.data(forKey: Keys.menuBarModule(moduleID)),
+ let decoded = try? JSONDecoder().decode(MenuBarModuleSettings.self, from: data)
+ else { return MenuBarModuleSettings() }
+ return decoded
+ }
+
+ func setMenuBarSettings(_ settings: MenuBarModuleSettings, for moduleID: String) {
+ if let data = try? JSONEncoder().encode(settings) {
+ defaults.set(data, forKey: Keys.menuBarModule(moduleID))
+ }
+ menuBar?.setEnabled(settings.isEnabled, forModuleID: moduleID)
+ menuBar?.setPresentation(settings.presentation, forModuleID: moduleID)
+ menuBarRevision += 1
+ }
+
+ /// Zählt hoch, wenn sich ein Modul ändert — die Einstellungen zeichnen sich
+ /// daran neu, ohne dass jedes Modul einzeln beobachtet werden müsste.
+ private(set) var menuBarRevision = 0
+
private enum Keys {
+ static func menuBarModule(_ id: String) -> String { "onyx.menubar.module.\(id)" }
static let displayPolicy = "onyx.displayPolicy"
static let showsMenuBarIcon = "onyx.showsMenuBarIcon"
static let showsDockIcon = "onyx.showsDockIcon"
diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings
index aeed9f6..27575e1 100644
--- a/Onyx/Localizable.xcstrings
+++ b/Onyx/Localizable.xcstrings
@@ -1,526 +1,734 @@
{
- "sourceLanguage" : "en",
- "strings" : {
- "menu.openPanel" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Panel öffnen"
+ "sourceLanguage": "en",
+ "strings": {
+ "menu.openPanel": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Panel öffnen"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Open Panel"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Open Panel"
}
}
}
},
- "menu.quit" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Onyx beenden"
+ "menu.quit": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Onyx beenden"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Quit Onyx"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Quit Onyx"
}
}
}
},
- "menu.settings" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Einstellungen …"
+ "menu.settings": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Einstellungen …"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Settings…"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Settings…"
}
}
}
},
- "settings.display.all" : {
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Allen Displays"
+ "settings.display.all": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Allen Displays"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "All displays"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "All displays"
}
}
}
},
- "settings.display.builtInOnly" : {
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Nur dem internen Display"
+ "settings.display.builtInOnly": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Nur dem internen Display"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Built-in display only"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Built-in display only"
}
}
}
},
- "settings.display.dockIcon" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Symbol im Dock anzeigen"
+ "settings.display.dockIcon": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Symbol im Dock anzeigen"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Show icon in the Dock"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Show icon in the Dock"
}
}
}
},
- "settings.display.dockIcon.hint" : {
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Standardmäßig aus. Onyx lebt in der Notch und in der Menüleiste und hat kein Hauptfenster — im Dock wäre es ein Symbol, das beim Anklicken nichts öffnet, und es stünde im Programmumschalter zwischen den Programmen, mit denen du arbeitest."
+ "settings.display.dockIcon.hint": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Standardmäßig aus. Onyx lebt in der Notch und in der Menüleiste und hat kein Hauptfenster — im Dock wäre es ein Symbol, das beim Anklicken nichts öffnet, und es stünde im Programmumschalter zwischen den Programmen, mit denen du arbeitest."
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Off by default. Onyx lives in the notch and the menu bar and has no main window — in the Dock it would be an icon that opens nothing, and it would sit in the app switcher among the apps you actually work with."
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Off by default. Onyx lives in the notch and the menu bar and has no main window — in the Dock it would be an icon that opens nothing, and it would sit in the app switcher among the apps you actually work with."
}
}
}
},
- "settings.display.hint" : {
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Auf Displays ohne echte Notch erscheint oben mittig ein Balken, der sich genauso verhält. Ist der Deckel geschlossen und kein internes Display vorhanden, erscheint das Panel trotzdem auf einem externen — sonst wäre Onyx nicht erreichbar."
+ "settings.display.hint": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Auf Displays ohne echte Notch erscheint oben mittig ein Balken, der sich genauso verhält. Ist der Deckel geschlossen und kein internes Display vorhanden, erscheint das Panel trotzdem auf einem externen — sonst wäre Onyx nicht erreichbar."
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "On displays without a real notch, a bar appears at the top centre and behaves the same. With the lid closed and no built-in display present, the panel still appears on an external one — otherwise Onyx would be unreachable."
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "On displays without a real notch, a bar appears at the top centre and behaves the same. With the lid closed and no built-in display present, the panel still appears on an external one — otherwise Onyx would be unreachable."
}
}
}
},
- "settings.display.menuBarIcon" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Symbol in der Menüleiste anzeigen"
+ "settings.display.menuBarIcon": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Symbol in der Menüleiste anzeigen"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Show icon in the menu bar"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Show icon in the menu bar"
}
}
}
},
- "settings.display.menuBarIcon.hint" : {
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Über das Symbol erreichst du Panel, Einstellungen und Beenden. Ohne es kommst du nur über die Notch an Onyx heran."
+ "settings.display.menuBarIcon.hint": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Über das Symbol erreichst du Panel, Einstellungen und Beenden. Ohne es kommst du nur über die Notch an Onyx heran."
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "The icon gives you the panel, settings and quit. Without it, the notch is the only way to reach Onyx."
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "The icon gives you the panel, settings and quit. Without it, the notch is the only way to reach Onyx."
}
}
}
},
- "settings.display.policy" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Panel anzeigen auf"
+ "settings.display.policy": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Panel anzeigen auf"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Show panel on"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Show panel on"
}
}
}
},
- "settings.permissions.calendar" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Kalender"
+ "settings.permissions.calendar": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Kalender"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Calendar"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Calendar"
}
}
}
},
- "settings.permissions.calendar.why" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Onyx zeigt deine Termine im Kalender-Widget an. Es wird ausschließlich gelesen, nie geschrieben."
+ "settings.permissions.calendar.why": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Onyx zeigt deine Termine im Kalender-Widget an. Es wird ausschließlich gelesen, nie geschrieben."
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Onyx shows your events in the calendar widget. It only ever reads, never writes."
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Onyx shows your events in the calendar widget. It only ever reads, never writes."
}
}
}
},
- "settings.permissions.granted" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Erteilt"
+ "settings.permissions.granted": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Erteilt"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Granted"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Granted"
}
}
}
},
- "settings.permissions.location" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Standort"
+ "settings.permissions.location": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Standort"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Location"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Location"
}
}
}
},
- "settings.permissions.location.why" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Onyx zeigt das Wetter an deinem aktuellen Standort. Ohne Zugriff kannst du stattdessen einen festen Ort wählen."
+ "settings.permissions.location.why": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Onyx zeigt das Wetter an deinem aktuellen Standort. Ohne Zugriff kannst du stattdessen einen festen Ort wählen."
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Onyx shows the weather for your current location. Without access you can pick a fixed place instead."
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Onyx shows the weather for your current location. Without access you can pick a fixed place instead."
}
}
}
},
- "settings.permissions.openSystem" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Systemeinstellungen öffnen"
+ "settings.permissions.openSystem": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Systemeinstellungen öffnen"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Open System Settings"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Open System Settings"
}
}
}
},
- "settings.permissions.request" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Anfragen"
+ "settings.permissions.request": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Anfragen"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Request"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Request"
}
}
}
},
- "settings.size.large" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Groß (2×2)"
+ "settings.size.large": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Groß (2×2)"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Large (2×2)"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Large (2×2)"
}
}
}
},
- "settings.size.medium" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Mittel (2×1)"
+ "settings.size.medium": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Mittel (2×1)"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Medium (2×1)"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Medium (2×1)"
}
}
}
},
- "settings.size.small" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Klein (1×1)"
+ "settings.size.small": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Klein (1×1)"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Small (1×1)"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Small (1×1)"
}
}
}
},
- "settings.size.wide" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Breit (4×1)"
+ "settings.size.wide": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Breit (4×1)"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Wide (4×1)"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Wide (4×1)"
}
}
}
},
- "settings.tab.display" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Anzeige"
+ "settings.tab.display": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Anzeige"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Display"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Display"
}
}
}
},
- "settings.tab.permissions" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Berechtigungen"
+ "settings.tab.permissions": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Berechtigungen"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Permissions"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Permissions"
}
}
}
},
- "settings.tab.widgets" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Widgets"
+ "settings.tab.widgets": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Widgets"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Widgets"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Widgets"
}
}
}
},
- "settings.widgets.add" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Hinzufügen"
+ "settings.widgets.add": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Hinzufügen"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Add"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Add"
}
}
}
},
- "settings.widgets.available" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Verfügbar"
+ "settings.widgets.available": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Verfügbar"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Available"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Available"
}
}
}
},
- "settings.widgets.empty" : {
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Keine Widgets im Panel. Das Panel bleibt leer, bis du eins hinzufügst."
+ "settings.widgets.empty": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Keine Widgets im Panel. Das Panel bleibt leer, bis du eins hinzufügst."
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "No widgets in the panel. It stays empty until you add one."
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "No widgets in the panel. It stays empty until you add one."
}
}
}
},
- "settings.widgets.hint" : {
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Die Reihenfolge bestimmt die Anordnung im Panel. Jedes Widget rückt an die erste Stelle, an die es passt."
+ "settings.widgets.hint": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Die Reihenfolge bestimmt die Anordnung im Panel. Jedes Widget rückt an die erste Stelle, an die es passt."
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "The order determines the arrangement in the panel. Each widget takes the first spot it fits into."
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "The order determines the arrangement in the panel. Each widget takes the first spot it fits into."
}
}
}
},
- "settings.widgets.inPanel" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Im Panel"
+ "settings.widgets.inPanel": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Im Panel"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "In the panel"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "In the panel"
}
}
}
},
- "settings.widgets.remove" : {
- "extractionState" : "stale",
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Aus dem Panel entfernen"
+ "settings.widgets.remove": {
+ "extractionState": "stale",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Aus dem Panel entfernen"
}
},
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "Remove from panel"
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Remove from panel"
+ }
+ }
+ }
+ },
+ "settings.tab.menubar": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Menüleiste"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Menu bar"
+ }
+ }
+ }
+ },
+ "settings.menubar.hint": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Jedes Modul erscheint als eigenes Symbol in der Menüleiste und teilt sich die Messung mit dem Widget im Panel. Standardmäßig ist keines an — die Menüleiste ist knapp, auf einem Notch-Display zusätzlich in der Mitte blockiert."
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Each module appears as its own menu bar icon and shares its measurement with the panel widget. None are on by default — the menu bar is tight, and on a notched display the middle is blocked as well."
+ }
+ }
+ }
+ },
+ "settings.menubar.value": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Wert"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Value"
+ }
+ }
+ }
+ },
+ "settings.menubar.graph": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Verlauf"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Graph"
+ }
+ }
+ }
+ },
+ "settings.menubar.bars": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Balken"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Bars"
+ }
+ }
+ }
+ },
+ "settings.menubar.valueAndGraph": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Wert und Verlauf"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Value and graph"
+ }
+ }
+ }
+ },
+ "settings.menubar.symbol": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Nur Symbol"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Symbol only"
+ }
+ }
+ }
+ },
+ "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"
+ }
+ }
+ }
+ },
+ "network.name": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Netzwerk"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Network"
}
}
}
}
},
- "version" : "1.0"
+ "version": "1.0"
}
\ No newline at end of file
diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift
index 6832fe6..05e9829 100644
--- a/Onyx/OnyxApp.swift
+++ b/Onyx/OnyxApp.swift
@@ -8,6 +8,8 @@ import OnyxWidgetKit
import CalendarProvider
import WeatherProvider
import MediaProvider
+import MetricsProvider
+import NetworkProvider
@main
struct OnyxApp: App {
@@ -34,6 +36,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var calendarModel: CalendarModel?
private var weatherModel: WeatherModel?
private var mediaModel: MediaModel?
+ private var metricsModel: MetricsModel?
+ private var networkModel: NetworkModel?
private let settingsWindow = SettingsWindowController()
func applicationDidFinishLaunching(_ notification: Notification) {
@@ -76,8 +80,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
let menuBar = MenuBarController()
+ if let metricsModel {
+ for metric in MetricKind.allCases {
+ menuBar.register(MetricMenuBarModule(metric: metric, model: metricsModel),
+ settings: model.menuBarSettings(for: "metric.\(metric.rawValue)"))
+ }
+ }
+ if let networkModel {
+ menuBar.register(NetworkMenuBarModule(model: networkModel),
+ settings: model.menuBarSettings(for: "network"))
+ }
menuBar.start()
self.menuBar = menuBar
+ model.menuBar = menuBar
installOnyxStatusItem()
observeMenuBarPreference()
@@ -136,6 +151,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self.mediaModel = mediaModel
WidgetRegistry.shared.register(MediaWidget(model: mediaModel))
+ let metricsModel = MetricsModel()
+ self.metricsModel = metricsModel
+ for metric in MetricKind.allCases {
+ WidgetRegistry.shared.register(MetricWidget(metric: metric, model: metricsModel))
+ }
+
+ let networkModel = NetworkModel()
+ self.networkModel = networkModel
+ WidgetRegistry.shared.register(NetworkWidget(model: networkModel))
+
PlaceholderWidgets.registerAll()
}
diff --git a/Onyx/PlaceholderWidgets.swift b/Onyx/PlaceholderWidgets.swift
index e7882d9..77d27c5 100644
--- a/Onyx/PlaceholderWidgets.swift
+++ b/Onyx/PlaceholderWidgets.swift
@@ -44,18 +44,6 @@ enum PlaceholderWidgets {
/// damit gespeicherte Layouts weitergelten.
static func registerAll() {
let widgets: [PlaceholderWidget] = [
- .init(id: "cpu", displayName: "CPU", symbolName: "cpu",
- supportedSizes: [.small, .medium], phase: "Phase 5"),
- .init(id: "gpu", displayName: "GPU", symbolName: "cpu.fill",
- supportedSizes: [.small, .medium], phase: "Phase 5"),
- .init(id: "memory", displayName: "Speicher", symbolName: "memorychip",
- supportedSizes: [.small, .medium], phase: "Phase 5"),
- .init(id: "battery", displayName: "Akku", symbolName: "battery.100",
- supportedSizes: [.small, .medium], phase: "Phase 5"),
- .init(id: "sensors", displayName: "Sensoren", symbolName: "thermometer",
- supportedSizes: [.medium, .large], phase: "Phase 5"),
- .init(id: "network", displayName: "Netzwerk", symbolName: "network",
- supportedSizes: [.small, .medium, .large], phase: "Phase 5"),
.init(id: "fans", displayName: "Lüfter", symbolName: "fan",
supportedSizes: [.small, .medium], phase: "Phase 6"),
.init(id: "audio", displayName: "Audio-Mixer", symbolName: "slider.horizontal.3",
diff --git a/Onyx/SettingsView.swift b/Onyx/SettingsView.swift
index 3b27948..4ff51dd 100644
--- a/Onyx/SettingsView.swift
+++ b/Onyx/SettingsView.swift
@@ -3,10 +3,11 @@ import OnyxDesign
import OnyxNotch
import OnyxWidgetKit
import CalendarProvider
+import OnyxMenuBar
import WeatherProvider
enum SettingsTab: Hashable {
- case widgets, display, permissions
+ case widgets, display, menubar, permissions
}
struct SettingsView: View {
@@ -23,6 +24,9 @@ struct SettingsView: View {
DisplaySettings(model: model)
.tabItem { Label("settings.tab.display", systemImage: "macbook") }
.tag(SettingsTab.display)
+ MenuBarSettings(model: model)
+ .tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") }
+ .tag(SettingsTab.menubar)
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
.tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") }
.tag(SettingsTab.permissions)
@@ -147,6 +151,99 @@ private struct PermissionRow: View {
}
}
+// MARK: - Menüleiste
+
+/// Jedes Modul einzeln an- und abschaltbar, mit eigener Darstellungsart.
+///
+/// Standardmäßig ist keines an: sechs neue Symbole beim ersten Start wären eine
+/// Zumutung, und die Menüleiste ist ohnehin knapp — auf einem Notch-Display
+/// blockiert die Kamera zusätzlich die Mitte.
+private struct MenuBarSettings: View {
+ @Bindable var model: AppModel
+
+ private static let modules: [(id: String, label: LocalizedStringKey, symbol: String)] = [
+ ("metric.cpu", "metric.cpu", "cpu"),
+ ("metric.gpu", "metric.gpu", "cpu.fill"),
+ ("metric.memory", "metric.memory", "memorychip"),
+ ("metric.battery", "metric.battery", "battery.100"),
+ ("metric.temperature", "metric.sensors", "thermometer"),
+ ("network", "network.name", "network"),
+ ]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("settings.menubar.hint")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ List {
+ ForEach(Self.modules, id: \.id) { module in
+ ModuleRow(model: model, id: module.id,
+ label: module.label, symbol: module.symbol)
+ }
+ }
+ .listStyle(.inset)
+ }
+ .padding()
+ // Der Zähler zwingt die Liste zum Neuzeichnen, wenn sich ein Modul
+ // ändert — sonst müsste jede Zeile ihren eigenen Zustand beobachten.
+ .id(model.menuBarRevision)
+ }
+}
+
+private struct ModuleRow: View {
+ @Bindable var model: AppModel
+ let id: String
+ let label: LocalizedStringKey
+ let symbol: String
+
+ var body: some View {
+ let settings = model.menuBarSettings(for: id)
+
+ HStack {
+ Toggle(isOn: Binding(
+ get: { settings.isEnabled },
+ set: { enabled in
+ var updated = settings
+ updated.isEnabled = enabled
+ model.setMenuBarSettings(updated, for: id)
+ })) {
+ Label(label, systemImage: symbol)
+ }
+
+ Spacer()
+
+ Picker("", selection: Binding(
+ get: { settings.presentation },
+ set: { presentation in
+ var updated = settings
+ updated.presentation = presentation
+ model.setMenuBarSettings(updated, for: id)
+ })) {
+ ForEach(MenuBarPresentation.allCases) { presentation in
+ Text(presentation.localizedName).tag(presentation)
+ }
+ }
+ .labelsHidden()
+ .frame(width: 150)
+ .disabled(!settings.isEnabled)
+ }
+ }
+}
+
+extension MenuBarPresentation {
+ var localizedName: LocalizedStringKey {
+ switch self {
+ case .value: "settings.menubar.value"
+ case .graph: "settings.menubar.graph"
+ case .bars: "settings.menubar.bars"
+ case .valueAndGraph: "settings.menubar.valueAndGraph"
+ case .symbol: "settings.menubar.symbol"
+ }
+ }
+}
+
// MARK: - Widgets
private struct WidgetSettings: View {
diff --git a/Packages/OnyxKit/Package.swift b/Packages/OnyxKit/Package.swift
index e26a986..5c599db 100644
--- a/Packages/OnyxKit/Package.swift
+++ b/Packages/OnyxKit/Package.swift
@@ -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"]),
]
)
diff --git a/Packages/OnyxKit/Sources/MetricsProvider/Calculations.swift b/Packages/OnyxKit/Sources/MetricsProvider/Calculations.swift
new file mode 100644
index 0000000..9ce27de
--- /dev/null
+++ b/Packages/OnyxKit/Sources/MetricsProvider/Calculations.swift
@@ -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))
+ }
+}
diff --git a/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings
new file mode 100644
index 0000000..549de75
--- /dev/null
+++ b/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings
@@ -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"
+}
\ No newline at end of file
diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift
new file mode 100644
index 0000000..6a4d469
--- /dev/null
+++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift
@@ -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)
+ }
+ }
+}
diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift
new file mode 100644
index 0000000..1ee5965
--- /dev/null
+++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift
@@ -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
+ }
+ }
+}
diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift
new file mode 100644
index 0000000..a71c4ae
--- /dev/null
+++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift
@@ -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"
+ }
+ }
+}
diff --git a/Packages/OnyxKit/Sources/MetricsProvider/SMCReader.swift b/Packages/OnyxKit/Sources/MetricsProvider/SMCReader.swift
new file mode 100644
index 0000000..9be80cb
--- /dev/null
+++ b/Packages/OnyxKit/Sources/MetricsProvider/SMCReader.swift
@@ -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.stride
+
+ let result = IOConnectCallStructMethod(connection, Self.handleYPCEvent,
+ &input, MemoryLayout.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) ?? "????"
+ }
+}
diff --git a/Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift b/Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift
new file mode 100644
index 0000000..a1a07be
--- /dev/null
+++ b/Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift
@@ -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.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.stride))
+ }
+
+ var current: [CPUTicks] = []
+ current.reserveCapacity(Int(count))
+ for core in 0.. 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?
+ 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.size
+ / MemoryLayout.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.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?
+ 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.. 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)
+ }
+ }
+ }
+}
diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkMetrics.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkMetrics.swift
new file mode 100644
index 0000000..3a95635
--- /dev/null
+++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkMetrics.swift
@@ -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?
+ 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()
+ }
+}
diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift
new file mode 100644
index 0000000..7636826
--- /dev/null
+++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift
@@ -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)
+ }
+ }
+ }
+}
diff --git a/Packages/OnyxKit/Tests/MetricsProviderTests/CalculationTests.swift b/Packages/OnyxKit/Tests/MetricsProviderTests/CalculationTests.swift
new file mode 100644
index 0000000..fa89b8a
--- /dev/null
+++ b/Packages/OnyxKit/Tests/MetricsProviderTests/CalculationTests.swift
@@ -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°")
+ }
+}
diff --git a/Packages/OnyxKit/Tests/NetworkProviderTests/Placeholder.swift b/Packages/OnyxKit/Tests/NetworkProviderTests/Placeholder.swift
new file mode 100644
index 0000000..87713e1
--- /dev/null
+++ b/Packages/OnyxKit/Tests/NetworkProviderTests/Placeholder.swift
@@ -0,0 +1 @@
+// Platzhalter
diff --git a/project.yml b/project.yml
index c767de3..0aca630 100644
--- a/project.yml
+++ b/project.yml
@@ -51,6 +51,10 @@ targets:
product: WeatherProvider
- package: OnyxKit
product: MediaProvider
+ - package: OnyxKit
+ product: MetricsProvider
+ - package: OnyxKit
+ product: NetworkProvider
postBuildScripts:
# Der MediaRemote-Adapter wird aus dem mitgelieferten Quellcode gebaut,
# nicht als fertige Binary eingecheckt: so ist nachvollziehbar, was da