From d85425671ddc8eb83e960a4f66bd1daa43b3363b Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Mon, 10 Aug 2026 22:17:13 +0200 Subject: [PATCH] =?UTF-8?q?Aussagekr=C3=A4ftige=20Popovers=20f=C3=BCr=20CP?= =?UTF-8?q?U,=20Speicher,=20Akku=20und=20Netzwerk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Popover, das nur den Wert größer wiederholt, den man gerade angeklickt hat, ist verschenkter Platz. Hier ist Raum — anders als in der Menüleiste, wo jede Zahl um Breite kämpft. Prozessor: Ringe für Auslastung, Temperatur und Lüfter, alle Kerne als Raster statt als Balkenreihe (bei fünfzehn Kernen wird eine Reihe unleserlich schmal), Leistungsaufschlüsselung und beide Lüfter mit Namen. Die Leistungsschlüssel sind gegen iStat Menus abgeglichen: PHPC liefert 14,02 W und deckt sich mit deren CPU-Wert, PSTR mit der Gesamtleistung. Frequenzen fehlen bewusst — die stehen nicht im SMC, dafür bräuchte es IOReport. Speicher: Ring mit Aufschlüsselung nach Programmen, Reserviertem, Komprimiertem und Freiem, Auslagerung mit Gesamtgröße und die fünf größten Verbraucher über phys_footprint — derselbe Wert, den der Aktivitätsmonitor zeigt, nicht der virtuelle Adressraum. Akku: Restzeit, Kapazität, Zyklen, Temperatur, Lade- oder Entladeleistung, Netzteilleistung und Spannung. Netzwerk: Verlauf in beide Richtungen auf gemeinsamem Maßstab — getrennte Maßstäbe ließen einen Upload von 20 KB/s so hoch aussehen wie einen Download von 20 MB/s. Dazu Spitzenwerte, VPN-Hinweis, alle Adressen und die Summen seit dem Start. Was fehlt, wird benannt statt weggelassen: Durchsatz je Programm gibt es ohne root nicht, das Ladelimit braucht den privilegierten Helfer und einen Key, der auf diesem Modell noch nicht identifiziert ist. Sensorliste erweitert und benannt: CPU-Kerne und Grafik stehen vorn, dazu Luftstrom, Thunderbolt links und rechts. Lüfter heißen links und rechts statt 0 und 1. Nebenbei ein Übersetzungsfehler behoben: die Modus-Auswahl in den Einstellungen zeigte die Rohschlüssel, weil die Texte im MetricsProvider-Bündel liegen und die Einstellungen im App-Bündel suchen. 145 Tests grün. --- Onyx/Localizable.xcstrings | 990 +++++++++--------- .../MetricsProvider/Localizable.xcstrings | 512 ++++++++- .../MetricsProvider/MetricMenuBarModule.swift | 104 +- .../MetricsProvider/MetricPopovers.swift | 352 +++++++ .../MetricsProvider/SystemMetrics.swift | 116 +- .../NetworkProvider/Localizable.xcstrings | 301 +++++- .../NetworkMenuBarModule.swift | 49 +- .../NetworkProvider/NetworkPopover.swift | 144 +++ 8 files changed, 1883 insertions(+), 685 deletions(-) create mode 100644 Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift create mode 100644 Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index f93bc61..fe39f48 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -1,746 +1,778 @@ { - "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…" } } } }, - "metric.battery" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Akku" + "metric.battery": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Akku" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Battery" + "en": { + "stringUnit": { + "state": "translated", + "value": "Battery" } } } }, - "metric.cpu" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "CPU" + "metric.cpu": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "CPU" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "CPU" + "en": { + "stringUnit": { + "state": "translated", + "value": "CPU" } } } }, - "metric.gpu" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "GPU" + "metric.gpu": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "GPU" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "GPU" + "en": { + "stringUnit": { + "state": "translated", + "value": "GPU" } } } }, - "metric.memory" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Speicher" + "metric.memory": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Speicher" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Memory" + "en": { + "stringUnit": { + "state": "translated", + "value": "Memory" } } } }, - "metric.sensors" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sensoren" + "metric.mode.temperature": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Temperatur" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sensors" + "en": { + "stringUnit": { + "state": "translated", + "value": "Temperature" } } } }, - "network.name" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Netzwerk" + "metric.mode.usage": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auslastung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Network" + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage" } } } }, - "settings.display.all" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Allen Displays" + "metric.sensors": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sensoren" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "All displays" + "en": { + "stringUnit": { + "state": "translated", + "value": "Sensors" } } } }, - "settings.display.builtInOnly" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nur dem internen Display" + "network.name": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzwerk" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Built-in display only" + "en": { + "stringUnit": { + "state": "translated", + "value": "Network" } } } }, - "settings.display.dockIcon" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Symbol im Dock anzeigen" + "settings.display.all": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Allen Displays" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show icon in the Dock" + "en": { + "stringUnit": { + "state": "translated", + "value": "All displays" } } } }, - "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.builtInOnly": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur dem internen Display" } }, - "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": "Built-in display only" } } } }, - "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.dockIcon": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Symbol im Dock anzeigen" } }, - "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": "Show icon in the Dock" } } } }, - "settings.display.menuBarIcon" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Symbol in der Menüleiste anzeigen" + "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" : "Show icon in the menu bar" + "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.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.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" : "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": "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.policy" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Panel anzeigen auf" + "settings.display.menuBarIcon": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Symbol in der Menüleiste anzeigen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show panel on" + "en": { + "stringUnit": { + "state": "translated", + "value": "Show icon in the menu bar" } } } }, - "settings.menubar.bars" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Balken" + "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" : "Bars" + "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.menubar.graph" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verlauf" + "settings.display.policy": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Panel anzeigen auf" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Graph" + "en": { + "stringUnit": { + "state": "translated", + "value": "Show panel on" } } } }, - "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." + "settings.menubar.bars": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Balken" } }, - "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." + "en": { + "stringUnit": { + "state": "translated", + "value": "Bars" } } } }, - "settings.menubar.symbol" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nur Symbol" + "settings.menubar.graph": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlauf" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Symbol only" + "en": { + "stringUnit": { + "state": "translated", + "value": "Graph" } } } }, - "settings.menubar.value" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wert" + "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" : "Value" + "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.valueAndGraph" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wert und Verlauf" + "settings.menubar.symbol": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur Symbol" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Value and graph" + "en": { + "stringUnit": { + "state": "translated", + "value": "Symbol only" } } } }, - "settings.permissions.calendar" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kalender" + "settings.menubar.value": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wert" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calendar" + "en": { + "stringUnit": { + "state": "translated", + "value": "Value" } } } }, - "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.menubar.valueAndGraph": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wert und Verlauf" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx shows your events in the calendar widget. It only ever reads, never writes." + "en": { + "stringUnit": { + "state": "translated", + "value": "Value and graph" } } } }, - "settings.permissions.granted" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Erteilt" + "settings.permissions.calendar": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalender" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Granted" + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendar" } } } }, - "settings.permissions.location" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Standort" + "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" : "Location" + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx shows your events in the calendar widget. It only ever reads, never writes." } } } }, - "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.granted": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erteilt" } }, - "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": "Granted" } } } }, - "settings.permissions.openSystem" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Systemeinstellungen öffnen" + "settings.permissions.location": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Standort" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open System Settings" + "en": { + "stringUnit": { + "state": "translated", + "value": "Location" } } } }, - "settings.permissions.request" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anfragen" + "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" : "Request" + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx shows the weather for your current location. Without access you can pick a fixed place instead." } } } }, - "settings.size.large" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Groß (2×2)" + "settings.permissions.openSystem": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemeinstellungen öffnen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Large (2×2)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Open System Settings" } } } }, - "settings.size.medium" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mittel (2×1)" + "settings.permissions.request": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anfragen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Medium (2×1)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Request" } } } }, - "settings.size.small" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Klein (1×1)" + "settings.size.large": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Groß (2×2)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Small (1×1)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Large (2×2)" } } } }, - "settings.size.wide" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Breit (4×1)" + "settings.size.medium": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mittel (2×1)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wide (4×1)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Medium (2×1)" } } } }, - "settings.tab.display" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anzeige" + "settings.size.small": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Klein (1×1)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Display" + "en": { + "stringUnit": { + "state": "translated", + "value": "Small (1×1)" } } } }, - "settings.tab.menubar" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Menüleiste" + "settings.size.wide": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Breit (4×1)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Menu bar" + "en": { + "stringUnit": { + "state": "translated", + "value": "Wide (4×1)" } } } }, - "settings.tab.permissions" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Berechtigungen" + "settings.tab.display": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeige" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Permissions" + "en": { + "stringUnit": { + "state": "translated", + "value": "Display" } } } }, - "settings.tab.widgets" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Widgets" + "settings.tab.menubar": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Menüleiste" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Widgets" + "en": { + "stringUnit": { + "state": "translated", + "value": "Menu bar" } } } }, - "settings.widgets.add" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hinzufügen" + "settings.tab.permissions": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Berechtigungen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Add" + "en": { + "stringUnit": { + "state": "translated", + "value": "Permissions" } } } }, - "settings.widgets.available" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verfügbar" + "settings.tab.widgets": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Widgets" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Available" + "en": { + "stringUnit": { + "state": "translated", + "value": "Widgets" } } } }, - "settings.widgets.empty" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Widgets im Panel. Das Panel bleibt leer, bis du eins hinzufügst." + "settings.widgets.add": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hinzufügen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No widgets in the panel. It stays empty until you add one." + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" } } } }, - "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.available": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verfügbar" } }, - "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": "Available" } } } }, - "settings.widgets.inPanel" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Im Panel" + "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" : "In the panel" + "en": { + "stringUnit": { + "state": "translated", + "value": "No widgets in the panel. It stays empty until you add one." } } } }, - "settings.widgets.remove" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aus dem Panel entfernen" + "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" : "Remove from panel" + "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" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "In the panel" + } + } + } + }, + "settings.widgets.remove": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus dem Panel entfernen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove from panel" } } } } }, - "version" : "1.0" + "version": "1.1" } \ No newline at end of file diff --git a/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings index f9e6938..73810cc 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings @@ -1,10 +1,19 @@ { "sourceLanguage": "en", "strings": { + "": {}, "%lld": { "comment": "A label displaying the speed of a fan. The argument is the speed of the fan, in revolutions per minute.", "isCommentAutoGenerated": true }, + "%lld U/min": { + "comment": "A label that shows the fan speed in revolutions per minute. The value is the fan speed in revolutions per minute.", + "isCommentAutoGenerated": true + }, + "Lüfter %lld": { + "comment": "A label that shows the name of a fan. The argument is the index of the fan.", + "isCommentAutoGenerated": true + }, "metric.battery": { "localizations": { "de": { @@ -69,6 +78,39 @@ } } }, + "metric.fan %lld": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter %lld" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan %lld" + } + } + } + }, + "metric.fan.controlComing": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Lüftersteuerung kommt mit dem privilegierten Helfer — Schreibzugriff auf den SMC verlangt root." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan control arrives with the privileged helper — writing to the SMC requires root." + } + } + } + }, "metric.gpu": { "localizations": { "de": { @@ -101,6 +143,55 @@ } } }, + "metric.mode.temperature": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Temperatur" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Temperature" + } + } + } + }, + "metric.mode.usage": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auslastung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage" + } + } + } + }, + "metric.rpm": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "U/min" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "rpm" + } + } + } + }, "metric.sensors": { "localizations": { "de": { @@ -134,6 +225,7 @@ } }, "metric.show": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -149,23 +241,7 @@ } } }, - "metric.mode.usage": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Auslastung" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Usage" - } - } - } - }, - "metric.mode.temperature": { + "popover.temperature": { "localizations": { "de": { "stringUnit": { @@ -181,50 +257,434 @@ } } }, - "metric.rpm": { + "popover.fans": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "U/min" + "value": "Lüfter" } }, "en": { "stringUnit": { "state": "translated", - "value": "rpm" + "value": "Fans" } } } }, - "metric.fan.controlComing": { + "popover.power": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Die Lüftersteuerung kommt mit dem privilegierten Helfer — Schreibzugriff auf den SMC verlangt root." + "value": "Leistung" } }, "en": { "stringUnit": { "state": "translated", - "value": "Fan control arrives with the privileged helper — writing to the SMC requires root." + "value": "Power" } } } }, - "metric.fan %lld": { + "popover.power.cpu": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Lüfter %lld" + "value": "CPU" } }, "en": { "stringUnit": { "state": "translated", - "value": "Fan %lld" + "value": "CPU" + } + } + } + }, + "popover.power.display": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeige" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Display" + } + } + } + }, + "popover.power.total": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gesamt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Total" + } + } + } + }, + "popover.power.adapter": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzteil" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Adapter" + } + } + } + }, + "popover.perCore": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Je Kern" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Per core" + } + } + } + }, + "popover.processes": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Größte Verbraucher" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Top consumers" + } + } + } + }, + "popover.memory.used": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Belegt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Used" + } + } + } + }, + "popover.memory.app": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Programme" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Apps" + } + } + } + }, + "popover.memory.wired": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Reserviert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Wired" + } + } + } + }, + "popover.memory.compressed": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Komprimiert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Compressed" + } + } + } + }, + "popover.memory.free": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Frei" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Free" + } + } + } + }, + "popover.memory.swap": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auslagerung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Swap" + } + } + } + }, + "popover.memory.swapUsed": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Used" + } + } + } + }, + "popover.battery.condition": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zustand" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Condition" + } + } + } + }, + "popover.battery.health": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kapazität" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Capacity" + } + } + } + }, + "popover.battery.cycles": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladezyklen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cycles" + } + } + } + }, + "popover.battery.remaining": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "verbleibend" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "remaining" + } + } + } + }, + "popover.battery.untilFull": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "bis voll" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "until full" + } + } + } + }, + "popover.battery.pluggedIn": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Am Netzteil" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "On adapter" + } + } + } + }, + "popover.battery.calculating": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird berechnet …" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Calculating…" + } + } + } + }, + "popover.battery.charging": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lädt mit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Charging at" + } + } + } + }, + "popover.battery.draining": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbrauch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Draw" + } + } + } + }, + "popover.battery.voltage": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Spannung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Voltage" + } + } + } + }, + "popover.battery.none": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Akku gefunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No battery found" + } + } + } + }, + "popover.battery.limitComing": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Ladelimit kommt mit dem privilegierten Helfer. Der zuständige SMC-Key ist auf diesem Modell noch nicht identifiziert — siehe docs/spikes/A-smc.md." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The charge limit arrives with the privileged helper. The relevant SMC key is not yet identified on this model — see docs/spikes/A-smc.md." } } } diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift index b68e9a0..8a63b8a 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift @@ -37,7 +37,7 @@ public final class MetricMenuBarModule: MenuBarModule { } public func makePopoverView() -> AnyView { - AnyView(MetricPopover(model: model, metric: metric)) + AnyView(MetricPopoverContent(model: model, metric: metric)) } public func activate() { @@ -222,105 +222,3 @@ public extension MetricSummary { } } -// 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) - } - } - - if !model.snapshot.fans.isEmpty { - Divider() - ForEach(model.snapshot.fans) { fan in - VStack(alignment: .leading, spacing: 2) { - HStack { - Label("Lüfter \(fan.index + 1)", systemImage: "fan") - .font(.callout) - Spacer() - Text("\(Int(fan.rpm)) U/min") - .font(.callout.monospacedDigit()) - .foregroundStyle(.secondary) - } - // Der Balken zeigt die Drehzahl zwischen Minimum und - // Maximum der Firmware — „2321 U/min" allein sagt - // niemandem, ob das viel ist. - ProgressView(value: fan.fraction).controlSize(.small) - } - } - Text("metric.fan.controlComing", bundle: .module) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - if metric.supportsDisplayModes { - Divider() - Picker("", selection: Binding( - get: { model.displayMode(for: metric) }, - set: { model.setDisplayMode($0, for: metric) })) { - Text("metric.mode.usage", bundle: .module).tag(MetricDisplayMode.usage) - Text("metric.mode.temperature", bundle: .module) - .tag(MetricDisplayMode.temperature) - } - .pickerStyle(.segmented) - .labelsHidden() - } - } - .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/MetricPopovers.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift new file mode 100644 index 0000000..c07aeb9 --- /dev/null +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift @@ -0,0 +1,352 @@ +import SwiftUI + +/// Die Detailansichten hinter den Menüleisten-Symbolen. +/// +/// Hier ist ausdrücklich Platz — anders als in der Menüleiste, wo jede Zahl um +/// Breite kämpft. Ein Popover, das nur den Wert größer wiederholt, den man +/// gerade angeklickt hat, ist verschenkt. +@MainActor +struct MetricPopoverContent: View { + let model: MetricsModel + let metric: MetricKind + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + switch metric { + case .cpu, .gpu: ProcessorDetail(model: model, metric: metric) + case .memory: MemoryDetail(snapshot: model.snapshot) + case .battery: BatteryDetail(snapshot: model.snapshot) + case .temperature: SensorDetail(snapshot: model.snapshot) + } + } + .padding(14) + .frame(width: 280) + } +} + +// MARK: - Bausteine + +/// Überschrift eines Abschnitts. Ohne sie ist eine lange Werteliste nur eine +/// lange Werteliste. +private struct SectionTitle: View { + let text: LocalizedStringKey + + var body: some View { + Text(text, bundle: .module) + .font(.caption.weight(.semibold)) + .foregroundStyle(.tint) + .textCase(.uppercase) + } +} + +private struct Row: View { + let label: String + let value: String + var emphasis: Color? + + var body: some View { + HStack { + Text(label).font(.callout) + Spacer(minLength: 12) + Text(value) + .font(.callout.monospacedDigit()) + .foregroundStyle(emphasis ?? .secondary) + } + } +} + +/// Ein Ring mit Zahl in der Mitte. +private struct Dial: View { + let value: Double + let caption: String + let text: String + var tint: Color = .accentColor + + var body: some View { + VStack(spacing: 2) { + ZStack { + Circle().stroke(.quaternary, lineWidth: 5) + Circle() + .trim(from: 0, to: min(max(value, 0), 1)) + .stroke(tint, style: .init(lineWidth: 5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + Text(text).font(.system(size: 15, weight: .medium).monospacedDigit()) + } + .frame(width: 62, height: 62) + Text(caption).font(.caption2).foregroundStyle(.secondary) + } + } +} + +// MARK: - Prozessor + +private struct ProcessorDetail: View { + let model: MetricsModel + let metric: MetricKind + + var body: some View { + let snapshot = model.snapshot + + HStack(spacing: 14) { + Dial(value: metric == .cpu ? snapshot.cpu.total : (snapshot.gpu ?? 0), + caption: metric == .cpu ? "CPU" : "GPU", + text: MetricFormat.percent(metric == .cpu ? snapshot.cpu.total + : (snapshot.gpu ?? 0))) + if let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature { + Dial(value: celsius / 100, + caption: String(localized: "popover.temperature", bundle: .module), + text: MetricFormat.temperature(celsius), + tint: MetricSummary.temperatureColor(celsius)) + } + if let fan = snapshot.fans.first { + Dial(value: fan.fraction, + caption: String(localized: "popover.fans", bundle: .module), + text: MetricFormat.percent(fan.fraction)) + } + } + .frame(maxWidth: .infinity) + + if metric == .cpu, !model.snapshot.cpu.perCore.isEmpty { + SectionTitle(text: "popover.perCore") + CoreGrid(values: model.snapshot.cpu.perCore) + } + + SectionTitle(text: "popover.power") + if let cpu = snapshot.power.cpu { + Row(label: String(localized: "popover.power.cpu", bundle: .module), + value: MetricFormat.watts(cpu)) + } + if let display = snapshot.power.display { + Row(label: String(localized: "popover.power.display", bundle: .module), + value: MetricFormat.watts(display)) + } + if let total = snapshot.power.total { + Row(label: String(localized: "popover.power.total", bundle: .module), + value: MetricFormat.watts(total), emphasis: .primary) + } + + if !snapshot.fans.isEmpty { + SectionTitle(text: "popover.fans") + ForEach(snapshot.fans) { fan in + Row(label: fan.name, value: "\(Int(fan.rpm)) U/min") + } + } + + Divider() + Picker("", selection: Binding( + get: { model.displayMode(for: metric) }, + set: { model.setDisplayMode($0, for: metric) })) { + Text("metric.mode.usage", bundle: .module).tag(MetricDisplayMode.usage) + Text("metric.mode.temperature", bundle: .module).tag(MetricDisplayMode.temperature) + } + .pickerStyle(.segmented) + .labelsHidden() + } +} + +/// Ein Kästchen je Kern statt einer Balkenreihe. +/// +/// Bei fünfzehn Kernen wird eine Reihe unleserlich schmal. Als Raster bleibt +/// jeder Kern erkennbar, und man sieht auf einen Blick, ob eine einzelne Last +/// läuft oder alles arbeitet. +private struct CoreGrid: View { + let values: [Double] + + var body: some View { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 3), count: 8), + spacing: 3) { + ForEach(Array(values.enumerated()), id: \.offset) { _, value in + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(Color.accentColor.opacity(0.18)) + .overlay(alignment: .bottom) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(Color.accentColor) + .frame(height: max(14 * value, 1.5)) + } + .frame(height: 14) + } + } + } +} + +// MARK: - Speicher + +private struct MemoryDetail: View { + let snapshot: MetricsSnapshot + + var body: some View { + let memory = snapshot.memory + + HStack(spacing: 14) { + Dial(value: memory.usedFraction, + caption: String(localized: "popover.memory.used", bundle: .module), + text: MetricFormat.percent(memory.usedFraction), + tint: pressureColor) + VStack(alignment: .leading, spacing: 3) { + legend(.accentColor, "popover.memory.app", memory.app) + legend(.purple, "popover.memory.wired", memory.wired) + legend(.teal, "popover.memory.compressed", memory.compressed) + legend(.gray, "popover.memory.free", memory.free) + } + } + + SectionTitle(text: "popover.memory.swap") + // Auslagerung ist der Wert, der zählt: solange nicht ausgelagert wird, + // ist ein voller Speicher kein Problem — macOS füllt ihn absichtlich. + Row(label: String(localized: "popover.memory.swapUsed", bundle: .module), + value: "\(bytes(memory.swapUsed)) / \(bytes(memory.swapTotal))", + emphasis: memory.swapUsed > 512 * 1024 * 1024 ? .orange : nil) + + if !snapshot.topMemoryProcesses.isEmpty { + SectionTitle(text: "popover.processes") + ForEach(snapshot.topMemoryProcesses) { process in + Row(label: process.name, value: bytes(process.bytes)) + } + } + } + + private var pressureColor: Color { + switch snapshot.memory.pressure { + case .normal: .green + case .warning: .orange + case .critical: .red + } + } + + private func legend(_ color: Color, _ key: LocalizedStringKey, _ value: UInt64) -> some View { + HStack(spacing: 5) { + Circle().fill(color).frame(width: 7, height: 7) + Text(key, bundle: .module).font(.caption) + Spacer(minLength: 8) + Text(bytes(value)).font(.caption.monospacedDigit()).foregroundStyle(.secondary) + } + } + + private func bytes(_ value: UInt64) -> String { + ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .binary) + } +} + +// MARK: - Akku + +private struct BatteryDetail: View { + let snapshot: MetricsSnapshot + + var body: some View { + if let battery = snapshot.battery { + HStack(spacing: 14) { + Dial(value: battery.charge, + caption: String(localized: "metric.battery", bundle: .module), + text: MetricFormat.percent(battery.charge), + tint: battery.isCharging ? .green + : (battery.charge < 0.2 ? .red : .accentColor)) + VStack(alignment: .leading, spacing: 4) { + if let remaining = battery.timeRemaining { + Text(duration(remaining)).font(.title3.monospacedDigit()) + Text(battery.isCharging ? "popover.battery.untilFull" + : "popover.battery.remaining", + bundle: .module) + .font(.caption).foregroundStyle(.secondary) + } else { + Text(battery.isPluggedIn ? "popover.battery.pluggedIn" + : "popover.battery.calculating", + bundle: .module) + .font(.callout).foregroundStyle(.secondary) + } + } + } + + SectionTitle(text: "popover.battery.condition") + if let health = battery.health { + Row(label: String(localized: "popover.battery.health", bundle: .module), + value: MetricFormat.percent(health)) + } + if let cycles = battery.cycleCount { + Row(label: String(localized: "popover.battery.cycles", bundle: .module), + value: "\(cycles)") + } + if let sensor = snapshot.sensors.first(where: { $0.key == "TB0T" }) { + Row(label: String(localized: "popover.temperature", bundle: .module), + value: MetricFormat.temperature(sensor.celsius)) + } + + SectionTitle(text: "popover.power") + if let watts = battery.watts { + Row(label: String(localized: battery.isCharging ? "popover.battery.charging" + : "popover.battery.draining", + bundle: .module), + value: MetricFormat.watts(abs(watts)), emphasis: .primary) + } + if let adapter = snapshot.power.adapter, adapter > 0 { + Row(label: String(localized: "popover.power.adapter", bundle: .module), + value: MetricFormat.watts(adapter)) + } + if let voltage = snapshot.power.busVoltage { + Row(label: String(localized: "popover.battery.voltage", bundle: .module), + value: String(format: "%.2f V", voltage)) + } + + Divider() + // Ehrlich benennen, was fehlt: das Ladelimit braucht Schreibzugriff + // auf den SMC und damit den privilegierten Helfer — und der Key ist + // auf diesem Modell noch nicht identifiziert. + Text("popover.battery.limitComing", bundle: .module) + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } else { + Text("popover.battery.none", bundle: .module).foregroundStyle(.secondary) + } + } + + private func duration(_ seconds: TimeInterval) -> String { + let total = Int(seconds / 60) + return total >= 60 ? "\(total / 60) h \(total % 60) min" : "\(total) min" + } +} + +// MARK: - Sensoren + +private struct SensorDetail: View { + let snapshot: MetricsSnapshot + + var body: some View { + SectionTitle(text: "popover.temperature") + ForEach(snapshot.sensors) { sensor in + HStack { + Text(sensor.name).font(.callout) + Spacer(minLength: 12) + Text(MetricFormat.temperature(sensor.celsius)) + .font(.callout.monospacedDigit()) + .foregroundStyle(MetricSummary.temperatureColor(sensor.celsius)) + // Der Ring macht aus einer Zahl einen Vergleich: 45° neben 79° + // sieht man schneller als man es liest. + Circle() + .trim(from: 0, to: min(sensor.celsius / 100, 1)) + .stroke(MetricSummary.temperatureColor(sensor.celsius), + style: .init(lineWidth: 2, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .frame(width: 12, height: 12) + } + } + + if !snapshot.fans.isEmpty { + SectionTitle(text: "popover.fans") + ForEach(snapshot.fans) { fan in + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(fan.name).font(.callout) + Spacer(minLength: 12) + Text("\(Int(fan.rpm)) U/min").font(.callout.monospacedDigit()) + .foregroundStyle(.secondary) + } + // Ohne Bezug sagt eine Drehzahl nichts: der Balken zeigt sie + // zwischen dem Minimum und Maximum der Firmware. + ProgressView(value: fan.fraction).controlSize(.small) + } + } + Text("metric.fan.controlComing", bundle: .module) + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift b/Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift index 686bef3..7580b69 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift @@ -13,9 +13,24 @@ public struct MetricsSnapshot: Equatable, Sendable { public var battery: BatteryReading? public var sensors: [SensorReading] = [] public var fans: [FanReading] = [] + public var power = PowerReading() + public var topMemoryProcesses: [ProcessMemory] = [] public var asOf = Date() } +/// Wohin die Leistung geht. +/// +/// Die Schlüssel sind gegen den Aktivitätsmonitor und gegen iStat Menus +/// abgeglichen: `PHPC` deckt sich mit deren CPU-Wert, `PSTR` mit der +/// Gesamtleistung. +public struct PowerReading: Equatable, Sendable { + public var total: Double? + public var cpu: Double? + public var display: Double? + public var adapter: Double? + public var busVoltage: Double? +} + public struct CPUReading: Equatable, Sendable { /// Gesamtauslastung, 0 bis 1. public var total: Double = 0 @@ -36,6 +51,12 @@ public struct MemoryReading: Equatable, Sendable { public var total: UInt64 = 0 public var compressed: UInt64 = 0 public var swapUsed: UInt64 = 0 + public var swapTotal: UInt64 = 0 + /// Von Programmen belegt (aktiv + inaktiv). + public var app: UInt64 = 0 + /// Vom Kernel festgehalten, nicht auslagerbar. + public var wired: UInt64 = 0 + public var free: UInt64 = 0 public var pressure: MemoryPressure = .normal public var usedFraction: Double { @@ -53,6 +74,14 @@ public struct BatteryReading: Equatable, Sendable { public var timeRemaining: TimeInterval? } +/// Ein Programm mit seinem Speicherverbrauch. +public struct ProcessMemory: Equatable, Sendable, Identifiable { + public let pid: pid_t + public let name: String + public let bytes: UInt64 + public var id: pid_t { pid } +} + public struct SensorReading: Equatable, Sendable, Identifiable { public let key: String public let name: String @@ -62,6 +91,7 @@ public struct SensorReading: Equatable, Sendable, Identifiable { public struct FanReading: Equatable, Sendable, Identifiable { public let index: Int + public let name: String public let rpm: Double public let minimum: Double public let maximum: Double @@ -138,7 +168,9 @@ public final class SystemMetrics: @unchecked Sendable { snapshot.battery = readBattery() snapshot.sensors = readSensors() snapshot.fans = readFans() - snapshot.cpu.watts = smc?.float("PSTR") + snapshot.topMemoryProcesses = readTopMemoryProcesses() + snapshot.power = readPower() + snapshot.cpu.watts = snapshot.power.cpu ?? snapshot.power.total let temperatures = readTemperatures() snapshot.cpu.temperature = temperatures.cpu snapshot.gpuTemperature = temperatures.gpu @@ -245,9 +277,14 @@ public final class SystemMetrics: @unchecked Sendable { var swapSize = MemoryLayout.size sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0) + let app = UInt64(statistics.active_count + statistics.inactive_count) * pageSize + let wired = UInt64(statistics.wire_count) * pageSize + let freeBytes = UInt64(statistics.free_count) * pageSize + let free = total > 0 ? 1 - Double(used) / Double(total) : 0 return MemoryReading(used: used, total: total, compressed: compressed, - swapUsed: swap.xsu_used, + swapUsed: swap.xsu_used, swapTotal: swap.xsu_total, + app: app, wired: wired, free: freeBytes, pressure: MemoryPressure.classify( free: free, compressed: total > 0 ? Double(compressed) / Double(total) : 0, @@ -303,6 +340,61 @@ public final class SystemMetrics: @unchecked Sendable { return reading } + /// Bei zwei Lüftern sind es links und rechts — so beschriftet Apple sie + /// selbst. Bei einer anderen Anzahl bleibt die Nummer, weil die Anordnung + /// dann nicht bekannt ist. + private static func fanName(index: Int, of count: Int) -> String { + guard count == 2 else { return "Lüfter \(index + 1)" } + return index == 0 ? "Lüfter links" : "Lüfter rechts" + } + + // MARK: - Leistung + + private func readPower() -> PowerReading { + guard let smc else { return PowerReading() } + return PowerReading(total: smc.float("PSTR"), + cpu: smc.float("PHPC"), + display: smc.float("PDBR"), + adapter: smc.float("PDTR"), + busVoltage: smc.float("VP0R")) + } + + // MARK: - Speicher je Programm + + /// Die größten Speicherverbraucher. + /// + /// `phys_footprint` ist derselbe Wert, den der Aktivitätsmonitor unter + /// „Speicher" zeigt — nicht der virtuelle Adressraum, der bei modernen + /// Programmen sinnlos groß ist. Fremde Nutzer bleiben außen vor: dafür + /// bräuchte es root, und für einen Blick aufs eigene System reicht das hier. + private func readTopMemoryProcesses(limit: Int = 5) -> [ProcessMemory] { + var pids = [pid_t](repeating: 0, count: 4096) + let size = proc_listpids(UInt32(PROC_ALL_PIDS), 0, &pids, + Int32(pids.count * MemoryLayout.size)) + guard size > 0 else { return [] } + + let count = Int(size) / MemoryLayout.size + var results: [ProcessMemory] = [] + + for pid in pids.prefix(count) where pid > 0 { + var usage = rusage_info_v4() + let result = withUnsafeMutablePointer(to: &usage) { + $0.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) { + proc_pid_rusage(pid, RUSAGE_INFO_V4, $0) + } + } + guard result == 0, usage.ri_phys_footprint > 64 * 1024 * 1024 else { continue } + + var nameBuffer = [CChar](repeating: 0, count: Int(MAXPATHLEN)) + proc_name(pid, &nameBuffer, UInt32(nameBuffer.count)) + let name = String(cString: nameBuffer) + guard !name.isEmpty else { continue } + + results.append(ProcessMemory(pid: pid, name: name, bytes: usage.ri_phys_footprint)) + } + return Array(results.sorted { $0.bytes > $1.bytes }.prefix(limit)) + } + // MARK: - Sensoren und Lüfter /// Die Temperaturfühler, die auf `Mac17,9` tatsächlich existieren. @@ -311,11 +403,14 @@ public final class SystemMetrics: @unchecked Sendable { /// 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"), + ("Ts0P", "Handballenauflage"), + ("Ts1P", "Gehäuse hinten"), ("TW0P", "WLAN"), ("TH0x", "SSD"), + ("TCMb", "Luftstrom"), + ("TDeL", "Thunderbolt links"), + ("TDeR", "Thunderbolt rechts"), ] private func readTemperatures() -> (cpu: Double?, gpu: Double?) { @@ -350,10 +445,20 @@ public final class SystemMetrics: @unchecked Sendable { private func readSensors() -> [SensorReading] { guard let smc else { return [] } - return Self.temperatureKeys.compactMap { key, name in + let named = Self.temperatureKeys.compactMap { key, name -> SensorReading? in guard let celsius = smc.float(key), celsius > 0, celsius < 150 else { return nil } return SensorReading(key: key, name: name, celsius: celsius) } + // CPU und GPU nach vorn: das sind die Werte, wegen derer man hinschaut. + let temperatures = readTemperatures() + var result: [SensorReading] = [] + if let cpu = temperatures.cpu { + result.append(SensorReading(key: "cpu", name: "CPU-Kerne", celsius: cpu)) + } + if let gpu = temperatures.gpu { + result.append(SensorReading(key: "gpu", name: "Grafik", celsius: gpu)) + } + return result + named } private func readFans() -> [FanReading] { @@ -361,6 +466,7 @@ public final class SystemMetrics: @unchecked Sendable { return (0.. AnyView { - AnyView(NetworkPopover(model: model)) + AnyView(NetworkPopoverContent(model: model)) } public func activate() { @@ -147,50 +147,3 @@ final class NetworkStatusView: NSView { } } -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/NetworkPopover.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift new file mode 100644 index 0000000..b5aa9e6 --- /dev/null +++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift @@ -0,0 +1,144 @@ +import SwiftUI +import MetricsProvider + +@MainActor +struct NetworkPopoverContent: View { + let model: NetworkModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top) { + rate(model.snapshot.downloadRate, "network.download", .green) + Spacer() + rate(model.snapshot.uploadRate, "network.upload", .pink) + } + + TrafficChart(history: model.history) + .frame(height: 56) + + HStack { + peak(model.history.map(\.downloadRate).max() ?? 0, "network.peak.download") + Spacer() + peak(model.history.map(\.uploadRate).max() ?? 0, "network.peak.upload") + } + + Divider() + + Label(connectionName, systemImage: model.snapshot.interfaceKind.symbolName) + .font(.callout) + + if model.snapshot.interfaceKind == .vpn { + // Ein VPN verändert, was die Zahlen darüber bedeuten — der + // Verkehr läuft dann durch den Tunnel, nicht direkt. + Label("network.vpn.active", systemImage: "lock.shield") + .font(.caption) + .foregroundStyle(.secondary) + } + + section("network.addresses") + field("network.field.interface", model.snapshot.interfaceName) + field("network.field.ipv4", model.snapshot.ipv4) + field("network.field.ipv6", model.snapshot.ipv6) + field("network.field.router", model.snapshot.router) + + section("network.transferred") + field("network.total.received", bytes(model.snapshot.totalReceived)) + field("network.total.sent", bytes(model.snapshot.totalSent)) + + // Kein Durchsatz je Programm: dafür gibt es ohne root keine + // Schnittstelle, und `nettop` im Sekundentakt zu starten kostete + // mehr als der gesamte übrige Monitor. + Text("network.perProcess.unavailable", bundle: .module) + .font(.caption2) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(14) + .frame(width: 280) + } + + 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 func rate(_ value: Double, _ key: LocalizedStringKey, _ tint: Color) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(Throughput.formatted(value)) + .font(.system(size: 18, weight: .medium).monospacedDigit()) + HStack(spacing: 4) { + Circle().fill(tint).frame(width: 6, height: 6) + Text(key, bundle: .module).font(.caption).foregroundStyle(.secondary) + } + } + } + + private func peak(_ value: Double, _ key: LocalizedStringKey) -> some View { + HStack(spacing: 4) { + Text(key, bundle: .module).font(.caption2).foregroundStyle(.secondary) + Text(Throughput.formatted(value)).font(.caption2.monospacedDigit()) + } + } + + private func section(_ key: LocalizedStringKey) -> some View { + Text(key, bundle: .module) + .font(.caption.weight(.semibold)) + .foregroundStyle(.tint) + .textCase(.uppercase) + } + + @ViewBuilder + private func field(_ key: LocalizedStringKey, _ value: String?) -> some View { + if let value { + HStack { + Text(key, bundle: .module).font(.callout).foregroundStyle(.secondary) + Spacer(minLength: 12) + Text(value).font(.callout.monospacedDigit()).textSelection(.enabled) + } + } + } + + private func bytes(_ value: UInt64) -> String { + ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .binary) + } +} + +/// Verlauf in beide Richtungen: Download nach unten, Upload nach oben. +/// +/// Zwei getrennte Kurven übereinander wären platzsparender, aber schwerer zu +/// lesen — hier sieht man sofort, ob gerade geladen oder gesendet wird, und in +/// welchem Verhältnis. Beide Richtungen teilen sich denselben Maßstab, sonst +/// sähe ein Upload von 20 KB/s genauso hoch aus wie ein Download von 20 MB/s. +private struct TrafficChart: View { + let history: [NetworkSnapshot] + + var body: some View { + GeometryReader { geometry in + let points = history.suffix(60) + let peak = max(points.map { max($0.downloadRate, $0.uploadRate) }.max() ?? 1, 1) + let width = geometry.size.width / CGFloat(max(points.count, 1)) + let half = geometry.size.height / 2 + + HStack(alignment: .center, spacing: 1) { + ForEach(Array(points.enumerated()), id: \.offset) { _, snapshot in + VStack(spacing: 1) { + Rectangle() + .fill(.pink) + .frame(height: max(half * CGFloat(snapshot.uploadRate / peak), 0.5)) + .frame(maxHeight: .infinity, alignment: .bottom) + Rectangle() + .fill(.green) + .frame(height: max(half * CGFloat(snapshot.downloadRate / peak), 0.5)) + .frame(maxHeight: .infinity, alignment: .top) + } + .frame(width: max(width - 1, 1)) + } + } + .frame(width: geometry.size.width, height: geometry.size.height) + } + } +}