diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index 27575e1..f93bc61 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -1,734 +1,746 @@ { - "sourceLanguage": "en", - "strings": { - "menu.openPanel": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Panel öffnen" + "sourceLanguage" : "en", + "strings" : { + "menu.openPanel" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Panel öffnen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Open Panel" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Panel" } } } }, - "menu.quit": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Onyx beenden" + "menu.quit" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onyx beenden" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Quit Onyx" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quit Onyx" } } } }, - "menu.settings": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Einstellungen …" + "menu.settings" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen …" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Settings…" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings…" } } } }, - "settings.display.all": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Allen Displays" + "metric.battery" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akku" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "All displays" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Battery" } } } }, - "settings.display.builtInOnly": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Nur dem internen Display" + "metric.cpu" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Built-in display only" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU" } } } }, - "settings.display.dockIcon": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Symbol im Dock anzeigen" + "metric.gpu" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Show icon in the Dock" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU" } } } }, - "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." + "metric.memory" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speicher" } }, - "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" : "Memory" } } } }, - "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." + "metric.sensors" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensoren" } }, - "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" : "Sensors" } } } }, - "settings.display.menuBarIcon": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Symbol in der Menüleiste anzeigen" + "network.name" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Netzwerk" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Show icon in the menu bar" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Network" } } } }, - "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.all" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allen Displays" } }, - "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" : "All displays" } } } }, - "settings.display.policy": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Panel anzeigen auf" + "settings.display.builtInOnly" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur dem internen Display" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Show panel on" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Built-in display only" } } } }, - "settings.permissions.calendar": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Kalender" + "settings.display.dockIcon" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Symbol im Dock anzeigen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Calendar" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show icon in the Dock" } } } }, - "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.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": "Onyx shows your events in the calendar widget. It only ever reads, never writes." + "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.permissions.granted": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Erteilt" + "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": "Granted" + "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.permissions.location": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Standort" + "settings.display.menuBarIcon" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Symbol in der Menüleiste anzeigen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Location" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show icon in the menu bar" } } } }, - "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.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": "Onyx shows the weather for your current location. Without access you can pick a fixed place instead." + "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.permissions.openSystem": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Systemeinstellungen öffnen" + "settings.display.policy" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Panel anzeigen auf" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Open System Settings" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show panel on" } } } }, - "settings.permissions.request": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Anfragen" + "settings.menubar.bars" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Balken" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Request" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bars" } } } }, - "settings.size.large": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Groß (2×2)" + "settings.menubar.graph" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verlauf" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Large (2×2)" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Graph" } } } }, - "settings.size.medium": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Mittel (2×1)" + "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": "Medium (2×1)" + "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.size.small": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Klein (1×1)" + "settings.menubar.symbol" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur Symbol" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Small (1×1)" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Symbol only" } } } }, - "settings.size.wide": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Breit (4×1)" + "settings.menubar.value" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wert" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Wide (4×1)" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Value" } } } }, - "settings.tab.display": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Anzeige" + "settings.menubar.valueAndGraph" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wert und Verlauf" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Display" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Value and graph" } } } }, - "settings.tab.permissions": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Berechtigungen" + "settings.permissions.calendar" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kalender" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Permissions" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendar" } } } }, - "settings.tab.widgets": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Widgets" + "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": "Widgets" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onyx shows your events in the calendar widget. It only ever reads, never writes." } } } }, - "settings.widgets.add": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Hinzufügen" + "settings.permissions.granted" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erteilt" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Add" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Granted" } } } }, - "settings.widgets.available": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Verfügbar" + "settings.permissions.location" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standort" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Available" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Location" } } } }, - "settings.widgets.empty": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Keine Widgets im Panel. Das Panel bleibt leer, bis du eins hinzufügst." + "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": "No widgets in the panel. It stays empty until you add one." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onyx shows the weather for your current location. Without access you can pick a fixed place instead." } } } }, - "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.permissions.openSystem" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Systemeinstellungen öffnen" } }, - "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" : "Open System Settings" } } } }, - "settings.widgets.inPanel": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Im Panel" + "settings.permissions.request" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anfragen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "In the panel" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Request" } } } }, - "settings.widgets.remove": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Aus dem Panel entfernen" + "settings.size.large" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Groß (2×2)" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Remove from panel" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Large (2×2)" } } } }, - "settings.tab.menubar": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Menüleiste" + "settings.size.medium" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mittel (2×1)" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Menu bar" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium (2×1)" } } } }, - "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.size.small" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klein (1×1)" } }, - "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" : "Small (1×1)" } } } }, - "settings.menubar.value": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Wert" + "settings.size.wide" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Breit (4×1)" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Value" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wide (4×1)" } } } }, - "settings.menubar.graph": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Verlauf" + "settings.tab.display" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anzeige" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Graph" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Display" } } } }, - "settings.menubar.bars": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Balken" + "settings.tab.menubar" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menüleiste" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Bars" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menu bar" } } } }, - "settings.menubar.valueAndGraph": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Wert und Verlauf" + "settings.tab.permissions" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Berechtigungen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Value and graph" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permissions" } } } }, - "settings.menubar.symbol": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Nur Symbol" + "settings.tab.widgets" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widgets" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Symbol only" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widgets" } } } }, - "metric.cpu": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "CPU" + "settings.widgets.add" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hinzufügen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "CPU" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add" } } } }, - "metric.gpu": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "GPU" + "settings.widgets.available" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verfügbar" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "GPU" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Available" } } } }, - "metric.memory": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Speicher" + "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": "Memory" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No widgets in the panel. It stays empty until you add one." } } } }, - "metric.battery": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Akku" + "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": "Battery" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The order determines the arrangement in the panel. Each widget takes the first spot it fits into." } } } }, - "metric.sensors": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Sensoren" + "settings.widgets.inPanel" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Im Panel" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sensors" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "In the panel" } } } }, - "network.name": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Netzwerk" + "settings.widgets.remove" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aus dem Panel entfernen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Network" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove from panel" } } } } }, - "version": "1.0" + "version" : "1.0" } \ No newline at end of file diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index 05e9829..2506e65 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -258,7 +258,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func showSettings(tab: SettingsTab) { guard let model, let calendarModel, let weatherModel else { return } settingsWindow.show(model: model, calendarModel: calendarModel, - weatherModel: weatherModel, tab: tab) + weatherModel: weatherModel, metricsModel: metricsModel, tab: tab) } @objc private func quit() { NSApp.terminate(nil) } diff --git a/Onyx/SettingsView.swift b/Onyx/SettingsView.swift index 4ff51dd..595424e 100644 --- a/Onyx/SettingsView.swift +++ b/Onyx/SettingsView.swift @@ -4,6 +4,7 @@ import OnyxNotch import OnyxWidgetKit import CalendarProvider import OnyxMenuBar +import MetricsProvider import WeatherProvider enum SettingsTab: Hashable { @@ -14,6 +15,7 @@ struct SettingsView: View { @Bindable var model: AppModel let calendarModel: CalendarModel let weatherModel: WeatherModel + let metricsModel: MetricsModel? @Binding var selectedTab: SettingsTab var body: some View { @@ -24,7 +26,7 @@ struct SettingsView: View { DisplaySettings(model: model) .tabItem { Label("settings.tab.display", systemImage: "macbook") } .tag(SettingsTab.display) - MenuBarSettings(model: model) + MenuBarSettings(model: model, metricsModel: metricsModel) .tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") } .tag(SettingsTab.menubar) PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel) @@ -160,6 +162,7 @@ private struct PermissionRow: View { /// blockiert die Kamera zusätzlich die Mitte. private struct MenuBarSettings: View { @Bindable var model: AppModel + let metricsModel: MetricsModel? private static let modules: [(id: String, label: LocalizedStringKey, symbol: String)] = [ ("metric.cpu", "metric.cpu", "cpu"), @@ -180,7 +183,8 @@ private struct MenuBarSettings: View { List { ForEach(Self.modules, id: \.id) { module in ModuleRow(model: model, id: module.id, - label: module.label, symbol: module.symbol) + label: module.label, symbol: module.symbol, + metricsModel: metricsModel) } } .listStyle(.inset) @@ -197,6 +201,13 @@ private struct ModuleRow: View { let id: String let label: LocalizedStringKey let symbol: String + let metricsModel: MetricsModel? + + /// Zu welcher Hardwaregröße dieses Modul gehört — `nil` beim Netzwerk. + private var metricKind: MetricKind? { + guard id.hasPrefix("metric.") else { return nil } + return MetricKind(rawValue: String(id.dropFirst("metric.".count))) + } var body: some View { let settings = model.menuBarSettings(for: id) @@ -214,6 +225,21 @@ private struct ModuleRow: View { Spacer() + // Bei CPU und GPU zusätzlich wählbar, was gezeigt wird. Beide Werte + // stammen aus derselben Messung — ein zweites Element in der + // ohnehin knappen Menüleiste wäre Verschwendung. + if let metric = metricKind, metric.supportsDisplayModes, let metricsModel { + Picker("", selection: Binding( + get: { metricsModel.displayMode(for: metric) }, + set: { metricsModel.setDisplayMode($0, for: metric) })) { + Text("metric.mode.usage").tag(MetricDisplayMode.usage) + Text("metric.mode.temperature").tag(MetricDisplayMode.temperature) + } + .labelsHidden() + .frame(width: 110) + .disabled(!settings.isEnabled) + } + Picker("", selection: Binding( get: { settings.presentation }, set: { presentation in diff --git a/Onyx/SettingsWindowController.swift b/Onyx/SettingsWindowController.swift index 6f8befd..4280728 100644 --- a/Onyx/SettingsWindowController.swift +++ b/Onyx/SettingsWindowController.swift @@ -2,6 +2,7 @@ import AppKit import SwiftUI import CalendarProvider import WeatherProvider +import MetricsProvider /// Führt das Einstellungsfenster selbst. /// @@ -25,7 +26,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { var keepsDockIcon = false func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel, - tab: SettingsTab = .widgets) { + metricsModel: MetricsModel?, tab: SettingsTab = .widgets) { selectedTab = tab keepsDockIcon = model.showsDockIcon @@ -42,6 +43,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { rootView: SettingsView(model: model, calendarModel: calendarModel, weatherModel: weatherModel, + metricsModel: metricsModel, selectedTab: Binding( get: { [weak self] in self?.selectedTab ?? .widgets }, set: { [weak self] in self?.selectedTab = $0 }))) diff --git a/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings index 549de75..f9e6938 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings @@ -1,6 +1,42 @@ { "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 + }, + "metric.battery": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Akku" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Battery" + } + } + } + }, + "metric.battery.charging": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lädt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Charging" + } + } + } + }, "metric.cpu": { "localizations": { "de": { @@ -17,6 +53,22 @@ } } }, + "metric.cpu.perCore": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Je Kern" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Per core" + } + } + } + }, "metric.gpu": { "localizations": { "de": { @@ -49,22 +101,6 @@ } } }, - "metric.battery": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Akku" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Battery" - } - } - } - }, "metric.sensors": { "localizations": { "de": { @@ -97,38 +133,102 @@ } } }, - "metric.battery.charging": { + "metric.show": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Lädt" + "value": "Anzeigen" } }, "en": { "stringUnit": { "state": "translated", - "value": "Charging" + "value": "Show" } } } }, - "metric.cpu.perCore": { + "metric.mode.usage": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Je Kern" + "value": "Auslastung" } }, "en": { "stringUnit": { "state": "translated", - "value": "Per core" + "value": "Usage" + } + } + } + }, + "metric.mode.temperature": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Temperatur" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Temperature" + } + } + } + }, + "metric.rpm": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "U/min" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "rpm" + } + } + } + }, + "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.fan %lld": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter %lld" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan %lld" } } } } }, - "version": "1.0" + "version": "1.1" } \ No newline at end of file diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift index 6a4d469..b68e9a0 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift @@ -30,7 +30,8 @@ public final class MetricMenuBarModule: MenuBarModule { public func makeStatusView(presentation: MenuBarPresentation) -> NSView { let view = MetricStatusView(metric: metric, presentation: presentation) - view.update(snapshot: model.snapshot, history: model.series(metric)) + view.update(snapshot: model.snapshot, history: model.series(metric), + mode: model.displayMode(for: metric)) self.view = view return view } @@ -57,10 +58,12 @@ public final class MetricMenuBarModule: MenuBarModule { private func startObserving() { withObservationTracking { _ = model.snapshot + _ = model.displayModes // auch auf den Moduswechsel reagieren } onChange: { Task { @MainActor [weak self] in guard let self, token != nil else { return } - view?.update(snapshot: model.snapshot, history: model.series(metric)) + view?.update(snapshot: model.snapshot, history: model.series(metric), + mode: model.displayMode(for: metric)) startObserving() } } @@ -79,6 +82,7 @@ final class MetricStatusView: NSView { private let presentation: MenuBarPresentation private var snapshot = MetricsSnapshot() private var history: [Double] = [] + private var mode: MetricDisplayMode = .usage init(metric: MetricKind, presentation: MenuBarPresentation) { self.metric = metric @@ -93,9 +97,10 @@ final class MetricStatusView: NSView { NSSize(width: presentation.width, height: 22) } - func update(snapshot: MetricsSnapshot, history: [Double]) { + func update(snapshot: MetricsSnapshot, history: [Double], mode: MetricDisplayMode) { self.snapshot = snapshot self.history = history + self.mode = mode needsDisplay = true } @@ -106,7 +111,7 @@ final class MetricStatusView: NSView { switch presentation { case .value: - drawText(MetricSummary.value(metric, snapshot), color: color) + drawText(MetricSummary.value(metric, snapshot, mode: mode), color: color) case .symbol: drawSymbol(color: color) case .graph: @@ -115,7 +120,7 @@ final class MetricStatusView: NSView { drawBars(color: color) case .valueAndGraph: let split = bounds.width * 0.55 - drawText(MetricSummary.value(metric, snapshot), color: color, + drawText(MetricSummary.value(metric, snapshot, mode: mode), color: color, in: NSRect(x: 0, y: 0, width: split, height: bounds.height)) drawGraph(color: color, in: NSRect(x: split + 2, y: 5, @@ -166,8 +171,11 @@ final class MetricStatusView: NSView { /// sondern ein Muster — aber genau daran erkennt man auf einen Blick, ob /// eine einzelne Last läuft oder alles ausgelastet ist. private func drawBars(color: NSColor) { - let values = metric == .cpu ? snapshot.cpu.perCore - : [MetricSummary.fraction(metric, snapshot)] + // Bei der CPU-Auslastung ein Balken je Kern; sonst — auch bei der + // CPU-Temperatur — ein einzelner Balken. + let values = (metric == .cpu && mode == .usage) + ? snapshot.cpu.perCore + : [MetricSummary.fraction(metric, snapshot, mode: mode)] guard !values.isEmpty, let context = NSGraphicsContext.current?.cgContext else { return } let area = bounds.insetBy(dx: 2, dy: 5) @@ -253,6 +261,42 @@ private struct MetricPopover: View { .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) diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift index 1ee5965..fa88343 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift @@ -80,6 +80,8 @@ private struct CompactMetric: View { let model: MetricsModel let metric: MetricKind + private var mode: MetricDisplayMode { model.displayMode(for: metric) } + var body: some View { VStack(alignment: .leading, spacing: 4) { Label { @@ -94,10 +96,11 @@ private struct CompactMetric: View { Spacer(minLength: 0) - Text(MetricSummary.value(metric, model.snapshot)) + Text(MetricSummary.value(metric, model.snapshot, mode: mode)) .font(Onyx.Font.metric) - .foregroundStyle(MetricSummary.color(metric, model.snapshot)) - .animation(Onyx.Motion.value, value: MetricSummary.value(metric, model.snapshot)) + .foregroundStyle(MetricSummary.color(metric, model.snapshot, mode: mode)) + .animation(Onyx.Motion.value, + value: MetricSummary.value(metric, model.snapshot, mode: mode)) if let detail = MetricSummary.detail(metric, model.snapshot) { Text(detail) @@ -120,7 +123,8 @@ private struct WideMetric: View { CompactMetric(model: model, metric: metric) .frame(width: 84) Sparkline(values: model.series(metric), - tint: MetricSummary.color(metric, model.snapshot)) + tint: MetricSummary.color(metric, model.snapshot, + mode: model.displayMode(for: metric))) } } } @@ -224,6 +228,37 @@ private struct SensorList: View { /// Menüleiste nicht auseinanderlaufen. public enum MetricSummary { + /// Der anzuzeigende Wert, abhängig vom gewählten Modus. + public static func value(_ metric: MetricKind, _ snapshot: MetricsSnapshot, + mode: MetricDisplayMode) -> String { + guard mode == .temperature, metric.supportsDisplayModes else { + return value(metric, snapshot) + } + let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature + // Solange die Fühlersuche im Hintergrund läuft, gibt es keinen Wert. + // „—" ist dann die richtige Auskunft, nicht eine 0. + return celsius.map(MetricFormat.temperature) ?? "—" + } + + public static func color(_ metric: MetricKind, _ snapshot: MetricsSnapshot, + mode: MetricDisplayMode) -> Color { + guard mode == .temperature, metric.supportsDisplayModes else { + return color(metric, snapshot) + } + let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature + return temperatureColor(celsius ?? 0) + } + + public static func fraction(_ metric: MetricKind, _ snapshot: MetricsSnapshot, + mode: MetricDisplayMode) -> Double { + guard mode == .temperature, metric.supportsDisplayModes else { + return fraction(metric, snapshot) + } + let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature + // Auf 100 °C normiert: darüber wird ohnehin gedrosselt. + return min(max((celsius ?? 0) / 100, 0), 1) + } + public static func value(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> String { switch metric { case .cpu: MetricFormat.percent(snapshot.cpu.total) diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift index a71c4ae..496f79b 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift @@ -22,12 +22,37 @@ public final class MetricsModel { /// Speicher für etwas, das niemand sieht. public static let historyLength = 60 + /// Was CPU und GPU zeigen. Beobachtbar, damit Panel und Menüleiste sofort + /// umschalten. + public private(set) var displayModes: [MetricKind: MetricDisplayMode] = [:] + + private let defaults: UserDefaults private let source = SystemMetrics() private var timer: Timer? /// Je Konsument das gewünschte Intervall. Gemessen wird mit dem kürzesten. private var demands: [UUID: TimeInterval] = [:] - public init() {} + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + for metric in MetricKind.allCases where metric.supportsDisplayModes { + let stored = defaults.string(forKey: Self.key(metric)) + displayModes[metric] = stored.flatMap(MetricDisplayMode.init(rawValue:)) ?? .usage + } + } + + public func displayMode(for metric: MetricKind) -> MetricDisplayMode { + displayModes[metric] ?? .usage + } + + public func setDisplayMode(_ mode: MetricDisplayMode, for metric: MetricKind) { + guard metric.supportsDisplayModes else { return } + displayModes[metric] = mode + defaults.set(mode.rawValue, forKey: Self.key(metric)) + } + + private static func key(_ metric: MetricKind) -> String { + "onyx.metric.\(metric.rawValue).displayMode" + } /// Meldet Bedarf an. Der Rückgabewert wird zum Abmelden gebraucht. @discardableResult @@ -80,18 +105,33 @@ public final class MetricsModel { public extension MetricsModel { /// Verlauf einer einzelnen Größe, für die Mini-Graphen. func series(_ metric: MetricKind) -> [Double] { - history.compactMap { snapshot in + let mode = displayMode(for: metric) + return history.compactMap { snapshot in + if mode == .temperature, metric.supportsDisplayModes { + let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature + return celsius.map { $0 / 100 } + } switch metric { - case .cpu: snapshot.cpu.total - case .gpu: snapshot.gpu - case .memory: snapshot.memory.usedFraction - case .battery: snapshot.battery?.charge - case .temperature: snapshot.sensors.map(\.celsius).max().map { $0 / 100 } + case .cpu: return snapshot.cpu.total + case .gpu: return snapshot.gpu + case .memory: return snapshot.memory.usedFraction + case .battery: return snapshot.battery?.charge + case .temperature: return snapshot.sensors.map(\.celsius).max().map { $0 / 100 } } } } } +/// Was ein CPU- oder GPU-Element zeigt. +/// +/// Beide Werte kommen aus derselben Messung — die Umschaltung kostet nichts und +/// erspart ein zweites Element in einer ohnehin knappen Menüleiste. +public enum MetricDisplayMode: String, Codable, Sendable, CaseIterable, Identifiable { + case usage + case temperature + public var id: String { rawValue } +} + public enum MetricKind: String, CaseIterable, Sendable, Identifiable { case cpu, gpu, memory, battery, temperature public var id: String { rawValue } @@ -105,4 +145,8 @@ public enum MetricKind: String, CaseIterable, Sendable, Identifiable { case .temperature: "thermometer" } } + + /// Nur CPU und GPU haben beides. Speicher und Akku haben keine eigene + /// Temperatur, die Sensorkarte zeigt ohnehin nur Temperaturen. + public var supportsDisplayModes: Bool { self == .cpu || self == .gpu } } diff --git a/Packages/OnyxKit/Sources/MetricsProvider/SMCReader.swift b/Packages/OnyxKit/Sources/MetricsProvider/SMCReader.swift index 9be80cb..29f16ff 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/SMCReader.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/SMCReader.swift @@ -48,11 +48,18 @@ public final class SMCReader: @unchecked Sendable { private static let handleYPCEvent: UInt32 = 2 private static let readKey: UInt8 = 5 + private static let getKeyFromIndex: UInt8 = 8 private static let getKeyInfo: UInt8 = 9 private var connection: io_connect_t = 0 private let lock = NSLock() + /// Typ und Größe je Key. Beides steht in der Firmware fest und ändert sich + /// zur Laufzeit nicht — es bei jedem Lesen erneut zu erfragen verdoppelt + /// die Zahl der IOKit-Aufrufe. Bei fünfzig Temperaturfühlern im Sekundentakt + /// ist das der Unterschied zwischen spürbar und unmerklich. + private var keyInfoCache: [String: KeyInfo] = [:] + public init?() { let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSMC")) @@ -92,26 +99,75 @@ public final class SMCReader: @unchecked Sendable { public func exists(_ key: String) -> Bool { read(key) != nil } + /// Alle Keys, deren Name mit einem der Präfixe beginnt. + /// + /// Wird einmal beim Start aufgerufen. Fest verdrahtete Listen wären hier + /// falsch: welche Fühler es gibt, hängt am Modell — auf dem M5 sind es + /// 285 Temperaturfühler, auf einem anderen Mac ganz andere. + public func keys(matching prefixes: [String]) -> [String] { + guard let total = keyCount() else { return [] } + + var found: [String] = [] + for index in 0.. UInt32? { + guard let value = read("#KEY"), value.bytes.count >= 4 else { return nil } + let count = UInt32(value.bytes[0]) << 24 | UInt32(value.bytes[1]) << 16 + | UInt32(value.bytes[2]) << 8 | UInt32(value.bytes[3]) + return (0..<100_000).contains(count) ? count : nil + } + + private func key(atIndex index: UInt32) -> String? { + lock.lock() + defer { lock.unlock() } + + var command = Param() + command.data8 = Self.getKeyFromIndex + command.data32 = index + guard let output = call(command) else { return nil } + return Self.fourCCString(output.key) + } + private struct Value { let type: String; let bytes: [UInt8] } private func read(_ key: String) -> Value? { lock.lock() defer { lock.unlock() } - var info = Param() - info.key = Self.fourCC(key) - info.data8 = Self.getKeyInfo - guard let described = call(info) else { return nil } + let described: KeyInfo + if let cached = keyInfoCache[key] { + described = cached + } else { + var info = Param() + info.key = Self.fourCC(key) + info.data8 = Self.getKeyInfo + guard let result = call(info) else { return nil } + described = result.keyInfo + keyInfoCache[key] = described + } var command = Param() command.key = Self.fourCC(key) - command.keyInfo = described.keyInfo + command.keyInfo = described command.data8 = Self.readKey guard let output = call(command) else { return nil } - let size = Int(min(described.keyInfo.dataSize, 32)) + let size = Int(min(described.dataSize, 32)) let all = withUnsafeBytes(of: output.bytes) { Array($0) } - return Value(type: Self.fourCCString(described.keyInfo.dataType), + return Value(type: Self.fourCCString(described.dataType), bytes: Array(all.prefix(size))) } diff --git a/Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift b/Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift index a1a07be..686bef3 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift @@ -7,6 +7,8 @@ import Darwin public struct MetricsSnapshot: Equatable, Sendable { public var cpu = CPUReading() public var gpu: Double? + /// Wärmster Punkt der GPU in Grad Celsius. + public var gpuTemperature: Double? public var memory = MemoryReading() public var battery: BatteryReading? public var sensors: [SensorReading] = [] @@ -21,6 +23,12 @@ public struct CPUReading: Equatable, Sendable { public var perCore: [Double] = [] /// Leistungsaufnahme in Watt, falls messbar. public var watts: Double? + /// Der wärmste Kern in Grad Celsius. + /// + /// Apple Silicon hat pro Kern einen Fühler — auf dem M5 Pro sind es + /// über zwanzig. Der Höchstwert ist der aussagekräftige: gedrosselt wird + /// nach dem heißesten Punkt, nicht nach dem Durchschnitt. + public var temperature: Double? } public struct MemoryReading: Equatable, Sendable { @@ -76,6 +84,26 @@ public final class SystemMetrics: @unchecked Sendable { private let smc = SMCReader() private var previousCPUTicks: [CPUTicks] = [] + /// Welche Kern- und GPU-Fühler dieses Modell hat. `Tp*` sind die + /// CPU-Kerne, `Tg*` die GPU-Cluster — die Konvention gilt für die ganze + /// M-Serie. + /// + /// Die Suche läuft im Hintergrund: sie geht alle 3486 Keys durch und + /// braucht dafür knapp eine Sekunde. Auf dem Hauptthread wäre das eine + /// spürbare Startverzögerung für etwas, das nach dem ersten Messwert + /// niemandem auffällt. + private var temperatureKeys: (cpu: [String], gpu: [String]) = ([], []) + private let keysLock = NSLock() + + /// Zwischenspeicher für die Temperaturen. + /// + /// Fünfzig Fühler zu lesen kostet gut 15 ms. Bei 1 Hz wären das anderthalb + /// Prozent CPU für Werte, die sich in einer Sekunde kaum bewegen — deshalb + /// höchstens alle zwei Sekunden, unabhängig von der Abtastrate ringsum. + private var cachedTemperatures: (cpu: Double?, gpu: Double?) = (nil, nil) + private var temperaturesReadAt = Date.distantPast + private static let temperatureInterval: TimeInterval = 2 + /// Seit dem Start unveränderlich — einmal ermitteln reicht. private static let pageSize: UInt64 = { var size: UInt64 = 0 @@ -86,7 +114,21 @@ public final class SystemMetrics: @unchecked Sendable { return size }() - public init() {} + public init() { + // Nicht im Initialisierer blockieren: der läuft beim App-Start. + // `DispatchQueue` statt `Task.detached`: die Suche ist blockierende + // IOKit-Arbeit ohne Suspendierungspunkt, sie gehört nicht in den + // Nebenläufigkeitspool von Swift — und `NSLock` ist aus einem + // asynchronen Kontext ohnehin nicht erlaubt. + DispatchQueue.global(qos: .utility).async { [weak self] in + guard let self, let smc else { return } + let cpu = smc.keys(matching: ["Tp"]) + let gpu = smc.keys(matching: ["Tg"]) + keysLock.lock() + temperatureKeys = (cpu, gpu) + keysLock.unlock() + } + } public func sample() -> MetricsSnapshot { var snapshot = MetricsSnapshot() @@ -97,6 +139,9 @@ public final class SystemMetrics: @unchecked Sendable { snapshot.sensors = readSensors() snapshot.fans = readFans() snapshot.cpu.watts = smc?.float("PSTR") + let temperatures = readTemperatures() + snapshot.cpu.temperature = temperatures.cpu + snapshot.gpuTemperature = temperatures.gpu return snapshot } @@ -273,6 +318,36 @@ public final class SystemMetrics: @unchecked Sendable { ("TH0x", "SSD"), ] + private func readTemperatures() -> (cpu: Double?, gpu: Double?) { + guard Date().timeIntervalSince(temperaturesReadAt) >= Self.temperatureInterval else { + return cachedTemperatures + } + keysLock.lock() + let keys = temperatureKeys + keysLock.unlock() + + // Solange die Suche im Hintergrund läuft, bleiben die Werte leer — die + // Oberfläche zeigt dann „—" statt einer erfundenen Zahl. + guard !keys.cpu.isEmpty || !keys.gpu.isEmpty else { return (nil, nil) } + + cachedTemperatures = (hottest(of: keys.cpu), hottest(of: keys.gpu)) + temperaturesReadAt = Date() + return cachedTemperatures + } + + /// Der höchste plausible Messwert einer Fühlergruppe. + /// + /// Die Plausibilitätsgrenze ist nötig: einzelne Fühler liefern gelegentlich + /// 0 oder Werte jenseits von 200 °C, und ein einziger Ausreißer würde die + /// Anzeige übernehmen, weil hier das Maximum gebildet wird. + private func hottest(of keys: [String]) -> Double? { + guard let smc, !keys.isEmpty else { return nil } + return keys.compactMap { key -> Double? in + guard let value = smc.float(key), value > 5, value < 150 else { return nil } + return value + }.max() + } + private func readSensors() -> [SensorReading] { guard let smc else { return [] } return Self.temperatureKeys.compactMap { key, name in