From 594d7c422af0a5c64b71851d6ab715b1ba4787f0 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Mon, 10 Aug 2026 21:48:47 +0200 Subject: [PATCH] =?UTF-8?q?Phase=205:=20Hardware-=20und=20Netzwerkmonitor,?= =?UTF-8?q?=20Panel=20und=20Men=C3=BCleiste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sechs neue Widgets (CPU, GPU, Speicher, Akku, Sensoren, Netzwerk) und dieselben sechs als einzeln aktivierbare Menüleisten-Module mit fünf Darstellungsarten. Panel-Widget und Menüleisten-Modul derselben Größe teilen sich die Messschleife über Referenzzählung. Ohne das liefe für jede Anzeige ein eigener Timer mit denselben IOKit- und SMC-Abfragen — bei sechs Modulen und ebenso vielen Widgets ein Vielfaches der nötigen Arbeit. Meldet sich der letzte Konsument ab, hört die Schleife auf; im Ruhezustand misst Onyx nichts. Die Differenzrechnung ist testgetrieben, und die Tests haben zwei echte Fehler gefunden. Erstens: mein Überlaufschutz beim Durchsatz hielt einen zurückgesetzten Zähler (Interface-Wechsel) für einen Überlauf und zeigte 4,3 GB/s. Da if_data64 64-Bit-Zähler liefert, die bei 10 Gbit/s erst nach 470 Jahren umlaufen, ist ein kleinerer Wert immer ein Zurücksetzen — die Unterscheidung produzierte nur den Ausreißer, den sie verhindern sollte. Zweitens: ByteCountFormatter schrieb bei null „Zero KB/s" statt „0 KB/s". Bei den CPU-Zählern bleibt der Überlaufschutz nötig: die sind 32 Bit breit und laufen nach gut 400 Tagen wirklich um, deshalb `&-` statt `-`. Sensoren kommen über den SMC-Leser aus Spike A statt über die private IOHID-Schnittstelle — auf dieser Maschine nachweislich verifiziert, und die dort gefundene Little-Endian-Eigenheit ist berücksichtigt. Nur eine kurze Liste benannter Fühler statt aller 3486 Keys: eine Wand aus Kürzeln ist keine Information. Gegengemessen statt vermutet: 15 Kerne, 39,8 W, GPU 50 %, 16,6/24 GB mit kritischem Druck, Akku 68 Zyklen bei 100 % Gesundheit, fünf Sensoren, zwei Lüfter, en0 mit 661 KB/s. Menüleisten-Module zeichnen in labelColor statt in den Onyx-Farben — nur die Systemfarbe passt sich heller wie dunkler Leiste an. Feste Breite je Darstellungsart, sonst schiebt jeder Messwert die halbe Leiste hin und her. Standardmäßig ist kein Modul aktiv: sechs neue Symbole beim ersten Start wären eine Zumutung. 145 Tests grün. --- Onyx.xcodeproj/project.pbxproj | 14 + Onyx/AppModel.swift | 30 + Onyx/Localizable.xcstrings | 882 +++++++++++------- Onyx/OnyxApp.swift | 25 + Onyx/PlaceholderWidgets.swift | 12 - Onyx/SettingsView.swift | 99 +- Packages/OnyxKit/Package.swift | 10 + .../MetricsProvider/Calculations.swift | 115 +++ .../MetricsProvider/Localizable.xcstrings | 134 +++ .../MetricsProvider/MetricMenuBarModule.swift | 282 ++++++ .../MetricsProvider/MetricWidgets.swift | 285 ++++++ .../MetricsProvider/MetricsModel.swift | 108 +++ .../Sources/MetricsProvider/SMCReader.swift | 143 +++ .../MetricsProvider/SystemMetrics.swift | 294 ++++++ .../NetworkProvider/Localizable.xcstrings | 26 + .../NetworkMenuBarModule.swift | 196 ++++ .../NetworkProvider/NetworkMetrics.swift | 214 +++++ .../NetworkProvider/NetworkWidget.swift | 193 ++++ .../CalculationTests.swift | 180 ++++ .../NetworkProviderTests/Placeholder.swift | 1 + project.yml | 4 + 21 files changed, 2897 insertions(+), 350 deletions(-) create mode 100644 Packages/OnyxKit/Sources/MetricsProvider/Calculations.swift create mode 100644 Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings create mode 100644 Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift create mode 100644 Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift create mode 100644 Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift create mode 100644 Packages/OnyxKit/Sources/MetricsProvider/SMCReader.swift create mode 100644 Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift create mode 100644 Packages/OnyxKit/Sources/NetworkProvider/Localizable.xcstrings create mode 100644 Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift create mode 100644 Packages/OnyxKit/Sources/NetworkProvider/NetworkMetrics.swift create mode 100644 Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift create mode 100644 Packages/OnyxKit/Tests/MetricsProviderTests/CalculationTests.swift create mode 100644 Packages/OnyxKit/Tests/NetworkProviderTests/Placeholder.swift 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