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