From b2f69f34aefd82b8aaa6e40ed15d474524fe8de9 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 11 Aug 2026 12:01:50 +0200 Subject: [PATCH] Ladelimit: Key gemessen, Schreibpfad gebaut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Key heißt CHLT, drei Bytes. Identifiziert per Differenzmessung gegen macOS' eigene Ladebegrenzung, ohne einen einzigen Schreibzugriff: 80 % → 50 01 00 95 % → 5F 01 00 85 % → 55 01 00 Erstes Byte ist die Prozentzahl, zweites offenbar „Limit aktiv", drittes unbenutzt. Drei Messpunkte, weil einer Zufall sein kann: BACC hatte beim ersten Vergleich ebenfalls gepasst — ein Zähler, in dem gerade 50 stand. ChargerConfiguration aus ioreg sah zunächst wie eine zweite Quelle aus (niedriges Byte 0x50 = 80), blieb aber unverändert, als CHLT längst auf 95 stand. Also Zufall, und gut, dass darauf nichts gebaut wurde. Geschrieben wird nur das erste Byte, und nur zwischen 80 und 100 — dem Bereich, den macOS selbst anbietet und in dem gemessen wurde. Darunter ist ungemessenes Gebiet. Bei den Lüftern liefert die Firmware mit F0Mn/F0Mx eigene Grenzen mit, an denen sich ein Sicherheitsnetz festhalten kann; hier gibt es das nicht, hier gibt es nur die Messung. Ein falscher Wert im Lade-Subsystem ist die eine Operation in diesem Projekt, die den Akku dauerhaft beschädigen kann. Nach jedem Schreiben wird zurückgelesen und gemeldet, was tatsächlich steht — nicht, was gewünscht war. „Kein Limit" heißt 100 Prozent und nicht das Aktiv-Byte auf null: diese Kodierung hat macOS nie geschrieben, sie wäre also ungemessen. Bedienung im Lüfter-Bereich der Einstellungen und in der Lüfterkachel, also auch im Menüleisten-Popover. Co-Authored-By: Claude Opus 5 --- Onyx.xcodeproj/project.pbxproj | 4 + Onyx/ChargeLimitView.swift | 68 + Onyx/FanControl.swift | 22 + Onyx/FanSettingsView.swift | 6 + Onyx/FanWidget.swift | 3 + Onyx/Localizable.xcstrings | 2414 +++++++++-------- OnyxHelper/SMCAccess.swift | 12 + OnyxHelper/main.swift | 29 + .../OnyxHelperProtocol/ChargeSafety.swift | 68 + .../OnyxHelperProtocol/HelperProtocol.swift | 9 + .../ChargeSafetyTests.swift | 117 + 11 files changed, 1577 insertions(+), 1175 deletions(-) create mode 100644 Onyx/ChargeLimitView.swift create mode 100644 Packages/OnyxKit/Sources/OnyxHelperProtocol/ChargeSafety.swift create mode 100644 Packages/OnyxKit/Tests/OnyxHelperProtocolTests/ChargeSafetyTests.swift diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index dbd2164..b31ba4b 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; }; 0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12041E330231599C6540C21 /* FanSettingsView.swift */; }; + 1017511BBC848C7E57CECE83 /* ChargeLimitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6054F5AFB96492E51CAFCE1 /* ChargeLimitView.swift */; }; 1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */; }; 1E2D654EFDDF55AEA75DBB7C /* WeatherProvider in Frameworks */ = {isa = PBXBuildFile; productRef = D4578EB64F7A3372B49BC50C /* WeatherProvider */; }; 2BB6AFD10DFB13FC4467BE6B /* SMCAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */; }; @@ -69,6 +70,7 @@ B442E8A39C37C8FD92B4CA83 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; F12041E330231599C6540C21 /* FanSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanSettingsView.swift; sourceTree = ""; }; + F6054F5AFB96492E51CAFCE1 /* ChargeLimitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeLimitView.swift; sourceTree = ""; }; F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnyxApp.swift; sourceTree = ""; }; FC3FDFD387188D36AF8287DE /* FanControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanControl.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -154,6 +156,7 @@ 0FBA13B97C53F1D0D740A633 /* Resources */, 055263008CB3306A8D09E6D4 /* AppIcon.icon */, B83C4E3CB821F903F3977E88 /* AppModel.swift */, + F6054F5AFB96492E51CAFCE1 /* ChargeLimitView.swift */, FC3FDFD387188D36AF8287DE /* FanControl.swift */, F12041E330231599C6540C21 /* FanSettingsView.swift */, 89309D9F5722FA1B91CF2905 /* FanWidget.swift */, @@ -332,6 +335,7 @@ buildActionMask = 2147483647; files = ( C041F171FB69F4381309D978 /* AppModel.swift in Sources */, + 1017511BBC848C7E57CECE83 /* ChargeLimitView.swift in Sources */, CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */, 0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */, 6CD3F5666C39F54506B8430A /* FanWidget.swift in Sources */, diff --git a/Onyx/ChargeLimitView.swift b/Onyx/ChargeLimitView.swift new file mode 100644 index 0000000..74d5310 --- /dev/null +++ b/Onyx/ChargeLimitView.swift @@ -0,0 +1,68 @@ +import SwiftUI +import OnyxDesign +import OnyxHelperProtocol + +/// Das Ladelimit. +/// +/// Ein Akku, der dauerhaft bei 100 % am Netz hängt, altert schneller. Deshalb +/// gibt es die Einstellung — und deshalb ist sie so schmal: 80 bis 100 % ist +/// der Bereich, den macOS selbst anbietet und in dem gemessen wurde, wie der +/// zuständige SMC-Key aussieht. Darunter wäre geraten. +struct ChargeLimitView: View { + let control: FanControl + /// In der Kachel ist es eng, im Einstellungsfenster nicht. + let roomy: Bool + + @State private var pending: Double? + + private var current: Double { + pending ?? Double(control.chargeLimit ?? ChargeSafety.noLimit) + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "battery.75percent") + .font(.system(size: 11)) + .foregroundStyle(Onyx.Color.textTertiary) + Text("charge.limit") + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textSecondary) + Spacer(minLength: 4) + // Hundert Prozent ist kein Limit — und soll auch nicht so + // heißen, sonst sucht man den Ausschalter. + Group { + if current >= Double(ChargeSafety.noLimit) { + Text("charge.limit.off") + } else { + Text(verbatim: "\(Int(current)) %") + } + } + .font(Onyx.Font.metricSmall).monospacedDigit() + .foregroundStyle(Onyx.Color.textPrimary) + } + + Slider(value: Binding(get: { current }, set: { pending = $0.rounded() }), + in: Double(ChargeSafety.minimumPercent)...Double(ChargeSafety.maximumPercent), + step: 5, + onEditingChanged: { editing in + // Erst beim Loslassen schreiben. Bei jedem Zwischenwert + // in den Ladepfad zu schreiben wäre ein Dutzend + // Schreibvorgänge für eine einzige Entscheidung. + guard !editing, let pending else { return } + control.setChargeLimit(Int(pending)) + self.pending = nil + }) + .controlSize(.mini) + .disabled(control.installState != .installed) + + if roomy { + Text("charge.limit.hint") + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + .onAppear { control.refreshChargeLimit() } + } +} diff --git a/Onyx/FanControl.swift b/Onyx/FanControl.swift index 7019f19..12f1944 100644 --- a/Onyx/FanControl.swift +++ b/Onyx/FanControl.swift @@ -119,6 +119,7 @@ public final class FanControl { /// abstürzt, beendet wird oder hängt — fallen die Lüfter im Helfer nach /// fünf Sekunden auf Automatik zurück. public func start() { + refreshChargeLimit() guard installState == .installed, heartbeatTimer == nil else { return } heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in @@ -160,4 +161,25 @@ public final class FanControl { Task { @MainActor in self?.apply(data) } } } + + // MARK: - Ladelimit + + /// Das eingestellte Ladelimit in Prozent. `nil` heißt: keines, oder noch + /// nicht gelesen. + public private(set) var chargeLimit: Int? + + public func refreshChargeLimit() { + proxy()?.chargeLimit { [weak self] percent in + Task { @MainActor in self?.chargeLimit = percent > 0 ? percent : nil } + } + } + + /// Setzt das Limit und übernimmt, was der Helfer **zurückgelesen** hat — + /// nicht das, was gewünscht war. Ein Schreibvorgang, den der SMC verwirft, + /// darf sich in der Oberfläche nicht als Erfolg zeigen. + public func setChargeLimit(_ percent: Int) { + proxy()?.setChargeLimit(percent: percent) { [weak self] result in + Task { @MainActor in self?.chargeLimit = result > 0 ? result : nil } + } + } } diff --git a/Onyx/FanSettingsView.swift b/Onyx/FanSettingsView.swift index 90d8a5f..8617619 100644 --- a/Onyx/FanSettingsView.swift +++ b/Onyx/FanSettingsView.swift @@ -18,6 +18,12 @@ struct FanSettingsView: View { approvalSection case .installed: controlSection + // Das Ladelimit hängt am selben Helfer wie die Lüfter — beides + // ist Hardwaresteuerung über den SMC, und beides ist erst + // erreichbar, wenn der Helfer läuft. + Section("charge.section") { + ChargeLimitView(control: control, roomy: true) + } } } .formStyle(.grouped) diff --git a/Onyx/FanWidget.swift b/Onyx/FanWidget.swift index 32c5b4f..8546e51 100644 --- a/Onyx/FanWidget.swift +++ b/Onyx/FanWidget.swift @@ -23,6 +23,9 @@ struct FanView: View { ForEach(Array(report.fans.enumerated()), id: \.offset) { index, fan in FanRow(control: control, index: index, fan: fan, roomy: roomy) } + Divider().overlay(Onyx.Color.hairline) + ChargeLimitView(control: control, roomy: roomy) + if let hottest = report.hottestCelsius { Divider().overlay(Onyx.Color.hairline) HStack(spacing: 5) { diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index 8f9f6c1..47b8e44 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -1,1825 +1,1889 @@ { - "sourceLanguage" : "en", - "strings" : { - "%@" : { - "comment" : "A label showing the minimum RPM of a fan.", - "isCommentAutoGenerated" : true, - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@" + "sourceLanguage": "en", + "strings": { + "%@": { + "comment": "A label showing the minimum RPM of a fan.", + "isCommentAutoGenerated": true, + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%@" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@" + "en": { + "stringUnit": { + "state": "translated", + "value": "%@" } } } }, - "%@ °C" : { - "comment" : "A label showing the temperature of the hottest fan in the report, in degrees Celsius. The argument is the temperature in degrees Celsius.", - "isCommentAutoGenerated" : true, - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ °C" + "%@ U/min": { + "comment": "A label that shows the current speed of a fan. The argument is the current speed of the fan.", + "isCommentAutoGenerated": true, + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ U/min" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ °C" + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ U/min" } } } }, - "%@ U/min" : { - "comment" : "A label that shows the current speed of a fan. The argument is the current speed of the fan.", - "isCommentAutoGenerated" : true, - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ U/min" + "%@ °C": { + "comment": "A label showing the temperature of the hottest fan in the report, in degrees Celsius. The argument is the temperature in degrees Celsius.", + "isCommentAutoGenerated": true, + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ °C" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ U/min" + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ °C" } } } }, - "%@°" : { - "comment" : "A temperature in °C.", - "isCommentAutoGenerated" : true, - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@°" + "%@°": { + "comment": "A temperature in °C.", + "isCommentAutoGenerated": true, + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%@°" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@°" + "en": { + "stringUnit": { + "state": "translated", + "value": "%@°" } } } }, - "fans.approval.needed" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "macOS wartet auf deine Freigabe. Öffne die Systemeinstellungen und erlaube den Hintergrunddienst von Onyx." + "charge.limit": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladelimit" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "macOS is waiting for your approval. Open System Settings and allow Onyx's background service." + "en": { + "stringUnit": { + "state": "translated", + "value": "Charge limit" } } } }, - "fans.approval.open" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Systemeinstellungen öffnen" + "charge.limit.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Akku, der dauerhaft bei 100 % am Netz hängt, altert schneller. 80 bis 100 % ist der Bereich, den macOS selbst anbietet — darunter ist für dieses Gerät ungemessen." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open System Settings" + "en": { + "stringUnit": { + "state": "translated", + "value": "A battery kept at 100 % on power ages faster. 80 to 100 % is the range macOS itself offers — below that is untested for this machine." } } } }, - "fans.approval.recheck" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Erneut prüfen" + "charge.limit.off": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Check again" + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" } } } }, - "fans.auto" : { - "comment" : "A button to set a fan to automatic mode.", - "isCommentAutoGenerated" : true, - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auto" + "charge.section": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Akku" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auto" + "en": { + "stringUnit": { + "state": "translated", + "value": "Battery" } } } }, - "fans.connecting" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verbinde mit dem Hilfsdienst …" + "fans.approval.needed": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "macOS wartet auf deine Freigabe. Öffne die Systemeinstellungen und erlaube den Hintergrunddienst von Onyx." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Connecting to the helper…" + "en": { + "stringUnit": { + "state": "translated", + "value": "macOS is waiting for your approval. Open System Settings and allow Onyx's background service." } } } }, - "fans.fan %@" : { - "comment" : "A label showing the index of a fan. The argument is the index of the fan.", - "isCommentAutoGenerated" : true, - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lüfter %@" + "fans.approval.open": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemeinstellungen öffnen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fan %@" + "en": { + "stringUnit": { + "state": "translated", + "value": "Open System Settings" } } } }, - "fans.fan %lld" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lüfter %lld" + "fans.approval.recheck": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erneut prüfen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fan %lld" + "en": { + "stringUnit": { + "state": "translated", + "value": "Check again" } } } }, - "fans.hottest" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wärmster Kern" + "fans.auto": { + "comment": "A button to set a fan to automatic mode.", + "isCommentAutoGenerated": true, + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auto" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hottest core" + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto" } } } }, - "fans.install" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Installieren" + "fans.connecting": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbinde mit dem Hilfsdienst …" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Install" + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting to the helper…" } } } }, - "fans.intro" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx kann die Lüfterdrehzahl fest vorgeben, statt sie macOS zu überlassen. Dafür wird ein Hilfsdienst mit Systemrechten eingerichtet — Schreibzugriff auf den SMC geht nicht anders." + "fans.fan %@": { + "comment": "A label showing the index of a fan. The argument is the index of the fan.", + "isCommentAutoGenerated": true, + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter %@" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx can set a fixed fan speed instead of leaving it to macOS. This requires a helper service with system privileges — writing to the SMC is not possible otherwise." + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan %@" } } } }, - "fans.left" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lüfter links" + "fans.fan %lld": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter %lld" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Left fan" + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan %lld" } } } }, - "fans.manual" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Manuell" + "fans.hottest": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wärmster Kern" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Manual" + "en": { + "stringUnit": { + "state": "translated", + "value": "Hottest core" } } } }, - "fans.mode.auto" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Automatik" + "fans.install": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Installieren" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Automatic" + "en": { + "stringUnit": { + "state": "translated", + "value": "Install" } } } }, - "fans.mode.manual" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Manuell" + "fans.intro": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx kann die Lüfterdrehzahl fest vorgeben, statt sie macOS zu überlassen. Dafür wird ein Hilfsdienst mit Systemrechten eingerichtet — Schreibzugriff auf den SMC geht nicht anders." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Manual" + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx can set a fixed fan speed instead of leaving it to macOS. This requires a helper service with system privileges — writing to the SMC is not possible otherwise." } } } }, - "fans.noData" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Noch keine Lüfterdaten." + "fans.left": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter links" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No fan data yet." + "en": { + "stringUnit": { + "state": "translated", + "value": "Left fan" } } } }, - "fans.noRange" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kein Regelbereich" + "fans.manual": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Manuell" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No adjustable range" + "en": { + "stringUnit": { + "state": "translated", + "value": "Manual" } } } }, - "fans.notInstalled" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Die Lüftersteuerung braucht einen kleinen privilegierten Dienst." + "fans.mode.auto": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatik" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fan control needs a small privileged service." + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic" } } } }, - "fans.reason.noTemperature" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zurück auf Automatik: keine Temperatur messbar, damit lässt sich Sicherheit nicht beurteilen." + "fans.mode.manual": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Manuell" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Back to automatic: no temperature reading, so safety cannot be judged." + "en": { + "stringUnit": { + "state": "translated", + "value": "Manual" } } } }, - "fans.reason.other" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zurück auf Automatik." + "fans.noData": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keine Lüfterdaten." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Back to automatic." + "en": { + "stringUnit": { + "state": "translated", + "value": "No fan data yet." } } } }, - "fans.reason.temperature" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zurück auf Automatik: zu heiß für eine feste Drehzahl." + "fans.noRange": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Regelbereich" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Back to automatic: too hot for a fixed speed." + "en": { + "stringUnit": { + "state": "translated", + "value": "No adjustable range" } } } }, - "fans.reason.unknownLimits" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zurück auf Automatik: der erlaubte Drehzahlbereich ist nicht lesbar." + "fans.notInstalled": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Lüftersteuerung braucht einen kleinen privilegierten Dienst." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Back to automatic: the permitted speed range cannot be read." + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan control needs a small privileged service." } } } }, - "fans.reason.watchdog" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zurück auf Automatik: Onyx hat sich zu lange nicht gemeldet." + "fans.reason.noTemperature": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück auf Automatik: keine Temperatur messbar, damit lässt sich Sicherheit nicht beurteilen." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Back to automatic: Onyx went silent for too long." + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to automatic: no temperature reading, so safety cannot be judged." } } } }, - "fans.right" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lüfter rechts" + "fans.reason.other": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück auf Automatik." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Right fan" + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to automatic." } } } }, - "fans.safety" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Deshalb greift ein Sicherheitsnetz, das sich nicht abschalten lässt: nie unter das Minimum der Firmware, Rückfall auf Automatik ab 95 °C, Rückfall wenn Onyx sich fünf Sekunden nicht meldet, und Rückfall beim Beenden." + "fans.reason.temperature": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück auf Automatik: zu heiß für eine feste Drehzahl." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "A safety net you cannot switch off is therefore always active: never below the firmware minimum, back to automatic above 95 °C, back to automatic if Onyx goes silent for five seconds, and back to automatic on quit." + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to automatic: too hot for a fixed speed." } } } }, - "fans.safety.active" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Das Sicherheitsnetz läuft im Hilfsdienst, nicht in Onyx. Es greift auch dann, wenn Onyx abstürzt oder beendet wird." + "fans.reason.unknownLimits": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück auf Automatik: der erlaubte Drehzahlbereich ist nicht lesbar." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The safety net runs in the helper, not in Onyx. It works even if Onyx crashes or is quit." + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to automatic: the permitted speed range cannot be read." } } } }, - "fans.uninstall" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hilfsdienst entfernen" + "fans.reason.watchdog": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück auf Automatik: Onyx hat sich zu lange nicht gemeldet." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Remove helper" + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to automatic: Onyx went silent for too long." } } } }, - "fans.warning" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Eine zu niedrige Drehzahl kann den Rechner überhitzen lassen. Die Automatik regelt nach Temperatur, ein fester Wert nicht." + "fans.right": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter rechts" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "A fan speed set too low can let the Mac overheat. The automatic control adjusts to temperature; a fixed value does not." + "en": { + "stringUnit": { + "state": "translated", + "value": "Right fan" } } } }, - "menu.openPanel" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Panel öffnen" + "fans.safety": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Deshalb greift ein Sicherheitsnetz, das sich nicht abschalten lässt: nie unter das Minimum der Firmware, Rückfall auf Automatik ab 95 °C, Rückfall wenn Onyx sich fünf Sekunden nicht meldet, und Rückfall beim Beenden." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open Panel" + "en": { + "stringUnit": { + "state": "translated", + "value": "A safety net you cannot switch off is therefore always active: never below the firmware minimum, back to automatic above 95 °C, back to automatic if Onyx goes silent for five seconds, and back to automatic on quit." } } } }, - "menu.quit" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx beenden" + "fans.safety.active": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Sicherheitsnetz läuft im Hilfsdienst, nicht in Onyx. Es greift auch dann, wenn Onyx abstürzt oder beendet wird." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Quit Onyx" + "en": { + "stringUnit": { + "state": "translated", + "value": "The safety net runs in the helper, not in Onyx. It works even if Onyx crashes or is quit." } } } }, - "menu.settings" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Einstellungen …" + "fans.uninstall": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hilfsdienst entfernen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Settings…" + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove helper" } } } }, - "metric.battery" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Akku" + "fans.warning": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Eine zu niedrige Drehzahl kann den Rechner überhitzen lassen. Die Automatik regelt nach Temperatur, ein fester Wert nicht." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Battery" + "en": { + "stringUnit": { + "state": "translated", + "value": "A fan speed set too low can let the Mac overheat. The automatic control adjusts to temperature; a fixed value does not." } } } }, - "metric.cpu" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "CPU" + "menu.openPanel": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Panel öffnen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "CPU" + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Panel" } } } }, - "metric.gpu" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "GPU" + "menu.quit": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx beenden" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "GPU" + "en": { + "stringUnit": { + "state": "translated", + "value": "Quit Onyx" } } } }, - "metric.memory" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Speicher" + "menu.settings": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen …" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Memory" + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings…" } } } }, - "metric.mode.temperature" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temperatur" + "metric.battery": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Akku" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temperature" + "en": { + "stringUnit": { + "state": "translated", + "value": "Battery" } } } }, - "metric.mode.usage" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auslastung" + "metric.cpu": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "CPU" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Usage" + "en": { + "stringUnit": { + "state": "translated", + "value": "CPU" } } } }, - "metric.sensors" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sensoren" + "metric.gpu": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "GPU" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sensors" + "en": { + "stringUnit": { + "state": "translated", + "value": "GPU" } } } }, - "mixer.menubar.name" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lautstärkemixer" + "metric.memory": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Speicher" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Volume mixer" + "en": { + "stringUnit": { + "state": "translated", + "value": "Memory" } } } }, - "network.name" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Netzwerk" + "metric.mode.temperature": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Temperatur" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Network" + "en": { + "stringUnit": { + "state": "translated", + "value": "Temperature" } } } }, - "onboarding.allow" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Erlauben" + "metric.mode.usage": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auslastung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Allow" + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage" } } } }, - "onboarding.audio" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Systemton" + "metric.sensors": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sensoren" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "System audio" + "en": { + "stringUnit": { + "state": "translated", + "value": "Sensors" } } } }, - "onboarding.audio.why" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nötig für die Lautstärke einzelner Programme. Onyx leitet den Ton der geregelten Programme um — und nur deren." + "mixer.menubar.name": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lautstärkemixer" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Needed for per-app volume. Onyx routes the audio of the apps you control, and only those." + "en": { + "stringUnit": { + "state": "translated", + "value": "Volume mixer" } } } }, - "onboarding.calendar" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kalender" + "network.name": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzwerk" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calendar" + "en": { + "stringUnit": { + "state": "translated", + "value": "Network" } } } }, - "onboarding.calendar.why" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zeigt deine Termine im Panel. Nur lesend — Onyx ändert nie einen Kalender." + "onboarding.allow": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erlauben" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Shows your appointments in the panel. Read-only — Onyx never changes a calendar." + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow" } } } }, - "onboarding.done" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fertig" + "onboarding.audio": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemton" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Done" + "en": { + "stringUnit": { + "state": "translated", + "value": "System audio" } } } }, - "onboarding.helper" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lüftersteuerung" + "onboarding.audio.why": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nötig für die Lautstärke einzelner Programme. Onyx leitet den Ton der geregelten Programme um — und nur deren." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fan control" + "en": { + "stringUnit": { + "state": "translated", + "value": "Needed for per-app volume. Onyx routes the audio of the apps you control, and only those." } } } }, - "onboarding.helper.why" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Installiert einen kleinen privilegierten Dienst. Er setzt nur Lüfterdrehzahlen und fällt auf Automatik zurück, wenn Onyx schweigt." + "onboarding.calendar": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalender" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Installs a small privileged service. It only sets fan speeds and falls back to automatic if Onyx goes silent." + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendar" } } } }, - "onboarding.launchAtLogin" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx beim Anmelden starten" + "onboarding.calendar.why": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigt deine Termine im Panel. Nur lesend — Onyx ändert nie einen Kalender." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Start Onyx at login" + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows your appointments in the panel. Read-only — Onyx never changes a calendar." } } } }, - "onboarding.location" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Standort" + "onboarding.done": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fertig" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Location" + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" } } } }, - "onboarding.location.why" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wetter für deinen Aufenthaltsort. Alternativ lässt sich je Widget ein fester Ort einstellen." + "onboarding.helper": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüftersteuerung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Weather for where you are. You can also set a fixed place per widget instead." + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan control" } } } }, - "onboarding.openSettings" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Systemeinstellungen öffnen" + "onboarding.helper.why": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Installiert einen kleinen privilegierten Dienst. Er setzt nur Lüfterdrehzahlen und fällt auf Automatik zurück, wenn Onyx schweigt." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open System Settings" + "en": { + "stringUnit": { + "state": "translated", + "value": "Installs a small privileged service. It only sets fan speeds and falls back to automatic if Onyx goes silent." } } } }, - "onboarding.subtitle" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx braucht vier Dinge vom System. Jedes für etwas anderes, und keines davon ist Pflicht — erlaube, was du nutzen möchtest." + "onboarding.launchAtLogin": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx beim Anmelden starten" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx needs four things from the system. Each one is for something different, and none of them is required — grant what you want to use." + "en": { + "stringUnit": { + "state": "translated", + "value": "Start Onyx at login" } } } }, - "onboarding.title" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Willkommen bei Onyx" + "onboarding.location": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Standort" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Welcome to Onyx" + "en": { + "stringUnit": { + "state": "translated", + "value": "Location" } } } }, - "settings.display.all" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Allen Displays" + "onboarding.location.why": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wetter für deinen Aufenthaltsort. Alternativ lässt sich je Widget ein fester Ort einstellen." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "All displays" + "en": { + "stringUnit": { + "state": "translated", + "value": "Weather for where you are. You can also set a fixed place per widget instead." } } } }, - "settings.display.builtInOnly" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nur dem internen Display" + "onboarding.openSettings": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemeinstellungen öffnen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Built-in display only" + "en": { + "stringUnit": { + "state": "translated", + "value": "Open System Settings" } } } }, - "settings.display.dockIcon" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Symbol im Dock anzeigen" + "onboarding.subtitle": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx braucht vier Dinge vom System. Jedes für etwas anderes, und keines davon ist Pflicht — erlaube, was du nutzen möchtest." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show icon in the Dock" + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx needs four things from the system. Each one is for something different, and none of them is required — grant what you want to use." } } } }, - "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." + "onboarding.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Willkommen bei Onyx" } }, - "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": "Welcome to Onyx" } } } }, - "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.all": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Allen Displays" } }, - "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": "All displays" } } } }, - "settings.display.menuBarIcon" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Symbol in der Menüleiste anzeigen" + "settings.display.builtInOnly": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur dem internen Display" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show icon in the menu bar" + "en": { + "stringUnit": { + "state": "translated", + "value": "Built-in display only" } } } }, - "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.dockIcon": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Symbol im Dock anzeigen" } }, - "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": "Show icon in the Dock" } } } }, - "settings.display.policy" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Panel anzeigen auf" + "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 panel on" + "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.general" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Allgemein" + "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" : "General" + "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.launchAtLogin" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Beim Anmelden starten" + "settings.display.menuBarIcon": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Symbol in der Menüleiste anzeigen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Start at login" + "en": { + "stringUnit": { + "state": "translated", + "value": "Show icon in the menu bar" } } } }, - "settings.launchAtLogin.blocked" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anmeldeobjekte wurden für Onyx in den Systemeinstellungen abgeschaltet." + "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" : "Login items were turned off for Onyx in System Settings." + "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.bars" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Balken" + "settings.display.policy": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Panel anzeigen auf" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bars" + "en": { + "stringUnit": { + "state": "translated", + "value": "Show panel on" } } } }, - "settings.menubar.graph" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verlauf" + "settings.general": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Allgemein" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Graph" + "en": { + "stringUnit": { + "state": "translated", + "value": "General" } } } }, - "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.launchAtLogin": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Beim Anmelden starten" } }, - "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": "Start at login" } } } }, - "settings.menubar.symbol" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nur Symbol" + "settings.launchAtLogin.blocked": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anmeldeobjekte wurden für Onyx in den Systemeinstellungen abgeschaltet." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Symbol only" + "en": { + "stringUnit": { + "state": "translated", + "value": "Login items were turned off for Onyx in System Settings." } } } }, - "settings.menubar.value" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wert" + "settings.menubar.bars": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Balken" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Value" + "en": { + "stringUnit": { + "state": "translated", + "value": "Bars" } } } }, - "settings.menubar.valueAndGraph" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wert und Verlauf" + "settings.menubar.graph": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlauf" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Value and graph" + "en": { + "stringUnit": { + "state": "translated", + "value": "Graph" } } } }, - "settings.onboarding.show" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Einrichtung erneut zeigen" + "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" : "Show setup again" + "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.permissions.calendar" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kalender" + "settings.menubar.symbol": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur Symbol" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calendar" + "en": { + "stringUnit": { + "state": "translated", + "value": "Symbol only" } } } }, - "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.value": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wert" } }, - "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" } } } }, - "settings.permissions.granted" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Erteilt" + "settings.menubar.valueAndGraph": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wert und Verlauf" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Granted" + "en": { + "stringUnit": { + "state": "translated", + "value": "Value and graph" } } } }, - "settings.permissions.location" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Standort" + "settings.onboarding.show": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Einrichtung erneut zeigen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Location" + "en": { + "stringUnit": { + "state": "translated", + "value": "Show setup again" } } } }, - "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.calendar": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalender" } }, - "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": "Calendar" } } } }, - "settings.permissions.openSystem" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Systemeinstellungen öffnen" + "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" : "Open System Settings" + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx shows your events in the calendar widget. It only ever reads, never writes." } } } }, - "settings.permissions.request" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anfragen" + "settings.permissions.granted": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erteilt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Request" + "en": { + "stringUnit": { + "state": "translated", + "value": "Granted" } } } }, - "settings.shelf.cleanup" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Automatisch löschen" + "settings.permissions.location": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Standort" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Delete automatically" + "en": { + "stringUnit": { + "state": "translated", + "value": "Location" } } } }, - "settings.shelf.count %@" : { - "comment" : "A label showing the number of items in the shelf. The argument is the number of items in the shelf.", - "isCommentAutoGenerated" : true, - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Dateien abgelegt" + "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" : "%@ files parked" + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx shows the weather for your current location. Without access you can pick a fixed place instead." } } } }, - "settings.shelf.count %lld" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld Dateien abgelegt" + "settings.permissions.openSystem": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemeinstellungen öffnen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld files parked" + "en": { + "stringUnit": { + "state": "translated", + "value": "Open System Settings" } } } }, - "settings.shelf.days %@" : { - "comment" : "A label that lets the user select how many days to wait before automatically removing items from the shelf. The argument is the number of days.", - "isCommentAutoGenerated" : true, - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nach %@ Tagen" + "settings.permissions.request": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anfragen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "After %@ days" + "en": { + "stringUnit": { + "state": "translated", + "value": "Request" } } } }, - "settings.shelf.days %lld" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nach %lld Tagen" + "settings.shelf.cleanup": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch löschen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "After %lld days" + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete automatically" } } } }, - "settings.shelf.never" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nie" + "settings.shelf.count %@": { + "comment": "A label showing the number of items in the shelf. The argument is the number of items in the shelf.", + "isCommentAutoGenerated": true, + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ Dateien abgelegt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Never" + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ files parked" } } } }, - "settings.shelf.reveal" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Im Finder zeigen" + "settings.shelf.count %lld": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Dateien abgelegt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show in Finder" + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld files parked" } } } }, - "settings.shelf.title" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ablage" + "settings.shelf.days %@": { + "comment": "A label that lets the user select how many days to wait before automatically removing items from the shelf. The argument is the number of days.", + "isCommentAutoGenerated": true, + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach %@ Tagen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Shelf" + "en": { + "stringUnit": { + "state": "translated", + "value": "After %@ days" } } } }, - "settings.size.large" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Groß (2×2)" + "settings.shelf.days %lld": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach %lld Tagen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Large (2×2)" + "en": { + "stringUnit": { + "state": "translated", + "value": "After %lld days" } } } }, - "settings.size.medium" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mittel (2×1)" + "settings.shelf.never": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nie" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Medium (2×1)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Never" } } } }, - "settings.size.small" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Klein (1×1)" + "settings.shelf.reveal": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Im Finder zeigen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Small (1×1)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Show in Finder" } } } }, - "settings.size.wide" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Breit (4×1)" + "settings.shelf.title": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ablage" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wide (4×1)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Shelf" } } } }, - "settings.tab.display" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anzeige" + "settings.size.large": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Groß (2×2)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Display" + "en": { + "stringUnit": { + "state": "translated", + "value": "Large (2×2)" } } } }, - "settings.tab.fans" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lüfter" + "settings.size.medium": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mittel (2×1)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fans" + "en": { + "stringUnit": { + "state": "translated", + "value": "Medium (2×1)" } } } }, - "settings.tab.menubar" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Menüleiste" + "settings.size.small": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Klein (1×1)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Menu bar" + "en": { + "stringUnit": { + "state": "translated", + "value": "Small (1×1)" } } } }, - "settings.tab.permissions" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Berechtigungen" + "settings.size.wide": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Breit (4×1)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Permissions" + "en": { + "stringUnit": { + "state": "translated", + "value": "Wide (4×1)" } } } }, - "settings.tab.sources" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Quellen" + "settings.tab.display": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeige" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sources" + "en": { + "stringUnit": { + "state": "translated", + "value": "Display" } } } }, - "settings.tab.widgets" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Widgets" + "settings.tab.fans": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Widgets" + "en": { + "stringUnit": { + "state": "translated", + "value": "Fans" } } } }, - "settings.widgets.add" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hinzufügen" + "settings.tab.menubar": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Menüleiste" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Add" + "en": { + "stringUnit": { + "state": "translated", + "value": "Menu bar" } } } }, - "settings.widgets.available" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verfügbar" + "settings.tab.permissions": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Berechtigungen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Available" + "en": { + "stringUnit": { + "state": "translated", + "value": "Permissions" } } } }, - "settings.widgets.empty" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Widgets im Panel. Das Panel bleibt leer, bis du eins hinzufügst." + "settings.tab.sources": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Quellen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No widgets in the panel. It stays empty until you add one." + "en": { + "stringUnit": { + "state": "translated", + "value": "Sources" } } } }, - "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.tab.widgets": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Widgets" } }, - "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": "Widgets" } } } }, - "settings.widgets.hint.order" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Alle Kacheln sind gleich groß. Die Reihenfolge hier ist die Reihenfolge im Panel — zum Sortieren ziehen." + "settings.widgets.add": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hinzufügen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "All tiles are the same size. The order here is the order in the panel — drag to sort." + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" } } } }, - "settings.widgets.inPanel" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Im Panel" + "settings.widgets.available": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verfügbar" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "In the panel" + "en": { + "stringUnit": { + "state": "translated", + "value": "Available" } } } }, - "settings.widgets.remove" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aus dem Panel entfernen" + "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" : "Remove from panel" + "en": { + "stringUnit": { + "state": "translated", + "value": "No widgets in the panel. It stays empty until you add one." } } } }, - "widget.fans.name" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lüfter" + "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" : "Fans" + "en": { + "stringUnit": { + "state": "translated", + "value": "The order determines the arrangement in the panel. Each widget takes the first spot it fits into." + } + } + } + }, + "settings.widgets.hint.order": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Kacheln sind gleich groß. Die Reihenfolge hier ist die Reihenfolge im Panel — zum Sortieren ziehen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "All tiles are the same size. The order here is the order in the panel — drag to sort." + } + } + } + }, + "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" + } + } + } + }, + "widget.fans.name": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fans" } } } } }, - "version" : "1.1" -} \ No newline at end of file + "version": "1.1" +} diff --git a/OnyxHelper/SMCAccess.swift b/OnyxHelper/SMCAccess.swift index 4935bfc..4fe0a4c 100644 --- a/OnyxHelper/SMCAccess.swift +++ b/OnyxHelper/SMCAccess.swift @@ -104,6 +104,12 @@ final class SMCAccess { write(key, bytes: [value]) } + /// Schreibt beliebige Bytes — für `CHLT`, das drei davon hat. + @discardableResult + func writeBytes(_ key: String, _ bytes: [UInt8]) -> Bool { + write(key, bytes: bytes) + } + private func write(_ key: String, bytes: [UInt8]) -> Bool { guard let info = keyInfo(for: key) else { return false } // Nur schreiben, wenn die Größe zur Firmware passt. Ein zu kurzer oder @@ -134,6 +140,12 @@ final class SMCAccess { return output.keyInfo } + /// Liest die Rohbytes eines Keys — für `CHLT`, dessen Typ `hex_` ist und + /// das sich damit keiner der Zahlenfassungen zuordnen lässt. + func readBytes(_ key: String) -> [UInt8]? { + read(key)?.bytes + } + private func read(_ key: String) -> Value? { guard let info = keyInfo(for: key) else { return nil } var command = Param() diff --git a/OnyxHelper/main.swift b/OnyxHelper/main.swift index b3b60c8..a6e47be 100644 --- a/OnyxHelper/main.swift +++ b/OnyxHelper/main.swift @@ -124,6 +124,35 @@ final class FanService: NSObject, OnyxHelperProtocol { reply(report()) } + // MARK: - Ladelimit + + /// Der Key trägt drei Bytes; nur das erste wird verändert. Siehe + /// `ChargeSafety` für die Messung, auf der das beruht. + func chargeLimit(reply: @escaping (Int) -> Void) { + lastHeartbeat = Date() + guard let smc, let bytes = smc.readBytes(ChargeSafety.key), + let percent = ChargeSafety.decode(bytes) else { + reply(-1) + return + } + reply(percent) + } + + func setChargeLimit(percent: Int, reply: @escaping (Int) -> Void) { + lastHeartbeat = Date() + guard let smc else { reply(-1); return } + + let wanted = ChargeSafety.clamp(percent) + let written = smc.writeBytes(ChargeSafety.key, ChargeSafety.encode(wanted)) + + // Immer zurücklesen. Ein Schreibvorgang, den der SMC stillschweigend + // verwirft, darf nicht als Erfolg gemeldet werden — sonst zeigt die + // Oberfläche eine Grenze an, die es nicht gibt. + let readBack = smc.readBytes(ChargeSafety.key).flatMap(ChargeSafety.decode) ?? -1 + log.notice("Ladelimit: \(percent, privacy: .public) % gewünscht, \(wanted, privacy: .public) % geschrieben (\(written, privacy: .public)), zurückgelesen \(readBack, privacy: .public) %") + reply(readBack) + } + func setFanAutomatic(index: Int, reply: @escaping (Data?) -> Void) { lastHeartbeat = Date() requests[index] = nil diff --git a/Packages/OnyxKit/Sources/OnyxHelperProtocol/ChargeSafety.swift b/Packages/OnyxKit/Sources/OnyxHelperProtocol/ChargeSafety.swift new file mode 100644 index 0000000..937d2d6 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxHelperProtocol/ChargeSafety.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Das Ladelimit — Kodierung und harte Grenzen. +/// +/// Der SMC-Key heißt `CHLT`, drei Bytes. Gemessen am 11.08.2026 auf Mac17,9, +/// rein lesend, gegen macOS' eigene Ladebegrenzung: +/// +/// | Einstellung | `CHLT` | +/// |---|---| +/// | 80 % | `50 01 00` | +/// | 85 % | `55 01 00` | +/// | 95 % | `5F 01 00` | +/// +/// Erstes Byte ist die Prozentzahl, das zweite offenbar „Limit aktiv", das +/// dritte unbenutzt. Mehr ist nicht belegt, und mehr wird hier auch nicht +/// angenommen. +/// +/// **Warum das strenger gehandhabt wird als die Lüfter:** dort liefert die +/// Firmware mit `F0Mn`/`F0Mx` ihre eigenen Grenzen mit, an denen sich das +/// Sicherheitsnetz festhalten kann. Hier gibt es nichts dergleichen — es gibt +/// nur die Werte, die wir gemessen haben. Ein falscher Wert im Lade-Subsystem +/// ist die eine Operation in diesem Projekt, die den Akku dauerhaft +/// beschädigen kann. +public enum ChargeSafety { + + /// Der SMC-Key. + public static let key = "CHLT" + + /// Untergrenze. macOS selbst bietet nichts darunter an; darunter ist + /// ungemessenes Gebiet. + public static let minimumPercent = 80 + public static let maximumPercent = 100 + + /// „Kein Limit" heißt hundert Prozent. + /// + /// Und nicht: das Aktiv-Byte auf null setzen. Diese Kodierung hat macOS nie + /// geschrieben, sie ist also ungemessen — hundert Prozent erreicht dasselbe + /// innerhalb dessen, was belegt ist. + public static let noLimit = 100 + + public static func clamp(_ percent: Int) -> Int { + // Anheben statt ablehnen: ein Regler, der bei einer Fehleingabe gar + // nichts tut, wirkt kaputt. + min(max(percent, minimumPercent), maximumPercent) + } + + /// Die drei Bytes für einen Prozentwert. + /// + /// Nur das erste Byte trägt den Wunsch. Die beiden anderen bekommen exakt + /// das, was macOS dort schreibt — ungemessene Bytes im Ladepfad werden + /// nicht angefasst. + public static func encode(_ percent: Int) -> [UInt8] { + [UInt8(clamp(percent)), 0x01, 0x00] + } + + /// Der Prozentwert aus den gelesenen Bytes. `nil`, wenn kein Limit gilt + /// oder die Daten nicht plausibel sind. + public static func decode(_ bytes: [UInt8]) -> Int? { + guard bytes.count >= 2 else { return nil } + // Zweites Byte null: kein Limit, egal was im ersten steht. + guard bytes[1] != 0 else { return nil } + let percent = Int(bytes[0]) + // Ein Wert außerhalb des Möglichen wird nicht angezeigt. „Unbekannt" + // ist eine ehrlichere Auskunft als eine Zahl, die es nicht geben kann. + guard (1...100).contains(percent) else { return nil } + return percent + } +} diff --git a/Packages/OnyxKit/Sources/OnyxHelperProtocol/HelperProtocol.swift b/Packages/OnyxKit/Sources/OnyxHelperProtocol/HelperProtocol.swift index e942de8..1911e79 100644 --- a/Packages/OnyxKit/Sources/OnyxHelperProtocol/HelperProtocol.swift +++ b/Packages/OnyxKit/Sources/OnyxHelperProtocol/HelperProtocol.swift @@ -28,6 +28,15 @@ import Foundation /// „Ich lebe noch." Bleibt er aus, fallen alle Lüfter auf Automatik zurück. func heartbeat(reply: @escaping (Bool) -> Void) + + /// Das aktuelle Ladelimit in Prozent, `-1` wenn keines gilt. + /// + /// `Int` statt `Int?`: Optionals sind über `@objc` nicht darstellbar. + func chargeLimit(reply: @escaping (Int) -> Void) + + /// Setzt das Ladelimit. Der Helfer begrenzt auf den gemessenen Bereich und + /// liest nach dem Schreiben zurück — gemeldet wird, was tatsächlich steht. + func setChargeLimit(percent: Int, reply: @escaping (Int) -> Void) } /// Was der Helfer über einen Lüfter berichtet. diff --git a/Packages/OnyxKit/Tests/OnyxHelperProtocolTests/ChargeSafetyTests.swift b/Packages/OnyxKit/Tests/OnyxHelperProtocolTests/ChargeSafetyTests.swift new file mode 100644 index 0000000..3dd7816 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxHelperProtocolTests/ChargeSafetyTests.swift @@ -0,0 +1,117 @@ +import Testing +@testable import OnyxHelperProtocol + +// Gemessen am 11.08.2026 auf Mac17,9, rein lesend, gegen macOS' eigene +// Ladebegrenzung: +// +// Limit 80 % → CHLT = 50 01 00 +// Limit 95 % → CHLT = 5F 01 00 +// Limit 85 % → CHLT = 55 01 00 +// +// Erstes Byte ist die Prozentzahl, zweites offenbar „Limit aktiv", drittes +// unbenutzt. Was hier steht, ist gemessen — nicht abgeleitet. + +@Suite("Ladelimit — Kodierung") +struct ChargeEncodingTests { + + @Test("Die drei gemessenen Werte werden richtig kodiert") + func encodesMeasuredValues() { + #expect(ChargeSafety.encode(80) == [0x50, 0x01, 0x00]) + #expect(ChargeSafety.encode(95) == [0x5F, 0x01, 0x00]) + #expect(ChargeSafety.encode(85) == [0x55, 0x01, 0x00]) + } + + @Test("Und wieder gelesen ergeben sie dasselbe") + func decodesMeasuredValues() { + #expect(ChargeSafety.decode([0x50, 0x01, 0x00]) == 80) + #expect(ChargeSafety.decode([0x5F, 0x01, 0x00]) == 95) + #expect(ChargeSafety.decode([0x55, 0x01, 0x00]) == 85) + } + + @Test("Ein abgeschaltetes Limit liest sich als kein Limit") + func disabledReadsAsNoLimit() { + // Zweites Byte auf null: macOS schreibt das zwar nicht selbst, aber + // fremde Werkzeuge könnten es. Dann gilt kein Limit, egal was im + // ersten Byte steht. + #expect(ChargeSafety.decode([0x50, 0x00, 0x00]) == nil) + } + + @Test("Zu kurze Daten ergeben kein Ergebnis statt eines falschen") + func shortDataIsRefused() { + #expect(ChargeSafety.decode([0x50]) == nil) + #expect(ChargeSafety.decode([]) == nil) + } + + @Test("Ein unsinniger Wert im Speicher wird nicht als Limit ausgegeben") + func nonsenseIsRefused() { + // 200 % ist kein Ladestand. Lieber „unbekannt" melden, als eine Zahl + // anzuzeigen, die es nicht geben kann. + #expect(ChargeSafety.decode([200, 0x01, 0x00]) == nil) + } +} + +@Suite("Ladelimit — Sicherheitsnetz") +struct ChargeSafetyTests { + + @Test("Der gemessene Bereich wird eingehalten") + func staysInMeasuredRange() { + // macOS selbst lässt nur 80 bis 100 zu. Darunter ist ungemessenes + // Gebiet, und im Lade-Subsystem wird nicht geraten: das ist die eine + // Stelle in diesem Projekt, an der ein falscher Wert die Hardware + // dauerhaft beschädigen kann. + #expect(ChargeSafety.minimumPercent == 80) + #expect(ChargeSafety.maximumPercent == 100) + } + + @Test("Zu kleine Werte werden angehoben, nicht abgelehnt") + func lowValuesAreClamped() { + // Anheben statt ablehnen: ein Regler, der bei einer Fehleingabe gar + // nichts tut, wirkt kaputt. + #expect(ChargeSafety.clamp(50) == 80) + #expect(ChargeSafety.clamp(0) == 80) + #expect(ChargeSafety.clamp(-10) == 80) + } + + @Test("Zu große Werte werden gedeckelt") + func highValuesAreClamped() { + #expect(ChargeSafety.clamp(120) == 100) + #expect(ChargeSafety.clamp(255) == 100) + } + + @Test("Werte im Bereich bleiben unverändert") + func validValuesPass() { + for percent in 80...100 { + #expect(ChargeSafety.clamp(percent) == percent) + } + } + + @Test("Kein Limit heißt hundert Prozent") + func noLimitIsHundred() { + // Und nicht: das Aktiv-Byte auf null setzen. Diese Kodierung hat macOS + // nie geschrieben, also ist sie ungemessen — hundert Prozent erreicht + // dasselbe innerhalb dessen, was wir belegt haben. + #expect(ChargeSafety.encode(ChargeSafety.noLimit) == [0x64, 0x01, 0x00]) + #expect(ChargeSafety.noLimit == 100) + } + + @Test("Geschriebene Bytes behalten immer die Form, die macOS benutzt") + func writtenShapeIsAlwaysTheSame() { + // Nur das erste Byte wird verändert. Alles andere ist ungemessen, und + // ungemessene Bytes im Ladepfad werden nicht angefasst. + for percent in [80, 85, 90, 95, 100] { + let bytes = ChargeSafety.encode(percent) + #expect(bytes.count == 3) + #expect(bytes[1] == 0x01) + #expect(bytes[2] == 0x00) + } + } + + @Test("Was geschrieben wurde, muss auch zurückgelesen werden können") + func writeIsVerifiable() { + // Der Helfer prüft nach dem Schreiben nach. Bleibt der Wert aus, hat + // der SMC ihn abgelehnt — und das muss auffallen, statt als Erfolg + // gemeldet zu werden. + let bytes = ChargeSafety.encode(85) + #expect(ChargeSafety.decode(bytes) == 85) + } +}