diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index b31ba4b..dbd2164 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* 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 */; }; @@ -70,7 +69,6 @@ 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 */ @@ -156,7 +154,6 @@ 0FBA13B97C53F1D0D740A633 /* Resources */, 055263008CB3306A8D09E6D4 /* AppIcon.icon */, B83C4E3CB821F903F3977E88 /* AppModel.swift */, - F6054F5AFB96492E51CAFCE1 /* ChargeLimitView.swift */, FC3FDFD387188D36AF8287DE /* FanControl.swift */, F12041E330231599C6540C21 /* FanSettingsView.swift */, 89309D9F5722FA1B91CF2905 /* FanWidget.swift */, @@ -335,7 +332,6 @@ 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 deleted file mode 100644 index 74d5310..0000000 --- a/Onyx/ChargeLimitView.swift +++ /dev/null @@ -1,68 +0,0 @@ -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 12f1944..c64472c 100644 --- a/Onyx/FanControl.swift +++ b/Onyx/FanControl.swift @@ -2,6 +2,7 @@ import Foundation import ServiceManagement import OSLog import OnyxHelperProtocol +import MetricsProvider private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "FanControl") @@ -12,7 +13,7 @@ private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Fa /// selbst misst. Diese Klasse hält nur die Verbindung und den Herzschlag. @MainActor @Observable -public final class FanControl { +public final class FanControl: ChargeLimitControlling { public enum InstallState: Equatable { case notInstalled @@ -177,6 +178,10 @@ public final class FanControl { /// 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. + /// Ohne laufenden Helfer lässt sich nichts schreiben — der Regler wird + /// dann angezeigt, aber gesperrt, statt still zu versagen. + public var canControlCharge: Bool { installState == .installed } + 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 8617619..04d1302 100644 --- a/Onyx/FanSettingsView.swift +++ b/Onyx/FanSettingsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import MetricsProvider import OnyxHelperProtocol /// Die Lüftersteuerung in den Einstellungen. @@ -22,7 +23,7 @@ struct FanSettingsView: View { // ist Hardwaresteuerung über den SMC, und beides ist erst // erreichbar, wenn der Helfer läuft. Section("charge.section") { - ChargeLimitView(control: control, roomy: true) + ChargeLimitControl(control: control, roomy: true) } } } diff --git a/Onyx/FanWidget.swift b/Onyx/FanWidget.swift index 8546e51..32c5b4f 100644 --- a/Onyx/FanWidget.swift +++ b/Onyx/FanWidget.swift @@ -23,9 +23,6 @@ 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/OnyxApp.swift b/Onyx/OnyxApp.swift index 5520c3e..7caf61f 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -206,6 +206,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { WidgetRegistry.shared.register(MediaWidget(model: mediaModel)) let metricsModel = MetricsModel() + // Der Akku bekommt seinen Ladelimit-Regler über den Helfer, den sonst + // die Lüfter benutzen — es ist derselbe SMC-Zugang. + metricsModel.chargeControl = fanControl self.metricsModel = metricsModel for metric in MetricKind.allCases { WidgetRegistry.shared.register(MetricWidget(metric: metric, model: metricsModel)) diff --git a/Packages/OnyxKit/Package.swift b/Packages/OnyxKit/Package.swift index ab78a64..29f928d 100644 --- a/Packages/OnyxKit/Package.swift +++ b/Packages/OnyxKit/Package.swift @@ -58,7 +58,7 @@ let package = Package( resources: [.process("Localizable.xcstrings")]), .testTarget(name: "MediaProviderTests", dependencies: ["MediaProvider"]), - .target(name: "MetricsProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit", "OnyxMenuBar"], + .target(name: "MetricsProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit", "OnyxMenuBar", "OnyxHelperProtocol"], resources: [.process("Localizable.xcstrings")]), .testTarget(name: "MetricsProviderTests", dependencies: ["MetricsProvider"]), diff --git a/Packages/OnyxKit/Sources/MetricsProvider/ChargeLimitView.swift b/Packages/OnyxKit/Sources/MetricsProvider/ChargeLimitView.swift new file mode 100644 index 0000000..aa06122 --- /dev/null +++ b/Packages/OnyxKit/Sources/MetricsProvider/ChargeLimitView.swift @@ -0,0 +1,92 @@ +import SwiftUI +import OnyxDesign +import OnyxHelperProtocol + +/// Wer das Ladelimit setzen kann. +/// +/// Ein Protokoll und kein direkter Zugriff: das Limit schreibt der privilegierte +/// Helfer, und der gehört zur App, nicht zu diesem Package. Die Akku-Ansicht +/// soll ihn bedienen können, ohne ihn zu kennen. +@MainActor +public protocol ChargeLimitControlling: AnyObject { + /// Das eingestellte Limit in Prozent, `nil` wenn keines gilt. + var chargeLimit: Int? { get } + /// Ob überhaupt geschrieben werden kann — ohne Helfer geht nichts. + var canControlCharge: Bool { get } + func refreshChargeLimit() + func setChargeLimit(_ percent: Int) +} + +/// Der Ladelimit-Regler, wie er im Akku-Widget und im Akku-Popover steht. +/// +/// Ein Akku, der dauerhaft bei 100 % am Netz hängt, altert schneller. Deshalb +/// gibt es die Einstellung — und deshalb ist ihr Bereich so schmal: 80 bis +/// 100 % ist, was macOS selbst anbietet und wofür gemessen wurde, wie der +/// zuständige SMC-Key aussieht. Darunter wäre geraten. +public struct ChargeLimitControl: View { + + private let control: any ChargeLimitControlling + /// Im Popover ist Platz für die Erklärung, in der Kachel nicht. + private let roomy: Bool + + @State private var pending: Double? + + public init(control: any ChargeLimitControlling, roomy: Bool) { + self.control = control + self.roomy = roomy + } + + private var current: Double { + pending ?? Double(control.chargeLimit ?? ChargeSafety.noLimit) + } + + private var isOff: Bool { current >= Double(ChargeSafety.noLimit) } + + public var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Text("charge.limit", bundle: .module) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textSecondary) + Spacer(minLength: 4) + Group { + // Hundert Prozent ist kein Limit — und soll auch nicht so + // heißen, sonst sucht man den Ausschalter. + if isOff { + Text("charge.limit.off", bundle: .module) + } else { + Text(verbatim: "\(Int(current)) %") + } + } + .font(Onyx.Font.metricSmall).monospacedDigit() + .foregroundStyle(isOff ? Onyx.Color.textTertiary : Onyx.Color.accent) + } + + Slider(value: Binding(get: { current }, set: { pending = ($0 / 5).rounded() * 5 }), + in: Double(ChargeSafety.minimumPercent)...Double(ChargeSafety.maximumPercent), + 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.canControlCharge) + + if !control.canControlCharge { + Text("charge.limit.needsHelper", bundle: .module) + .font(Onyx.Font.metricSmall) + .foregroundStyle(Onyx.Color.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } else if roomy { + Text("charge.limit.hint", bundle: .module) + .font(Onyx.Font.metricSmall) + .foregroundStyle(Onyx.Color.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + .onAppear { control.refreshChargeLimit() } + } +} diff --git a/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings index 8450250..3d2ac54 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings @@ -56,6 +56,70 @@ } } }, + "charge.limit": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladelimit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Charge limit" + } + } + } + }, + "charge.limit.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "80 bis 100 % — der Bereich, den macOS selbst anbietet." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "80 to 100 % — the range macOS itself offers." + } + } + } + }, + "charge.limit.needsHelper": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Braucht den privilegierten Dienst aus Einstellungen → Lüfter." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Needs the privileged service from Settings → Fans." + } + } + } + }, + "charge.limit.off": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + } + } + }, "metric.battery": { "localizations": { "de": { diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift index c07aeb9..2c7cab5 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift @@ -1,4 +1,5 @@ import SwiftUI +import OnyxDesign /// Die Detailansichten hinter den Menüleisten-Symbolen. /// @@ -15,7 +16,7 @@ struct MetricPopoverContent: View { switch metric { case .cpu, .gpu: ProcessorDetail(model: model, metric: metric) case .memory: MemoryDetail(snapshot: model.snapshot) - case .battery: BatteryDetail(snapshot: model.snapshot) + case .battery: BatteryDetail(snapshot: model.snapshot, control: model.chargeControl) case .temperature: SensorDetail(snapshot: model.snapshot) } } @@ -231,6 +232,7 @@ private struct MemoryDetail: View { private struct BatteryDetail: View { let snapshot: MetricsSnapshot + let control: (any ChargeLimitControlling)? var body: some View { if let battery = snapshot.battery { @@ -256,6 +258,14 @@ private struct BatteryDetail: View { } } + // Das Ladelimit steht hier und nicht in den Einstellungen: es ist + // eine Akkusache, und man greift danach, während man auf den + // Ladestand schaut. + if let control { + Divider().overlay(Onyx.Color.hairline) + ChargeLimitControl(control: control, roomy: true) + } + SectionTitle(text: "popover.battery.condition") if let health = battery.health { Row(label: String(localized: "popover.battery.health", bundle: .module), diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift index 278797d..44bfdce 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift @@ -54,6 +54,14 @@ private struct MetricWidgetView: View { // alles andere zeigt Zahl und Verlauf. if metric == .temperature { SensorList(snapshot: model.snapshot, detailed: true) + } else if metric == .battery, let control = model.chargeControl { + // Die Akku-Kachel bekommt den Ladelimit-Regler direkt: dort + // schaut man ohnehin hin, wenn man ihn braucht. + VStack(alignment: .leading, spacing: 8) { + WideMetric(model: model, metric: metric) + Divider().overlay(Onyx.Color.hairline) + ChargeLimitControl(control: control, roomy: false) + } } else { WideMetric(model: model, metric: metric) } diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift index 496f79b..331683e 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift @@ -14,6 +14,10 @@ import SwiftUI @Observable public final class MetricsModel { + /// Wer das Ladelimit setzen kann — die App reicht den Helfer herein. + /// `nil` heißt: kein Regler, weil es nichts zu regeln gibt. + public weak var chargeControl: (any ChargeLimitControlling)? + public private(set) var snapshot = MetricsSnapshot() /// Verlauf für die Graphen, jüngster Wert zuletzt. public private(set) var history: [MetricsSnapshot] = [] diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift index b66cdbd..19aee62 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift @@ -66,7 +66,11 @@ public final class NotchScreenController { // MARK: - Eingaben /// Zeigerposition in globalen Bildschirmkoordinaten. + /// Wo der Zeiger beim letzten Verlassen stand — nur fürs Protokoll. + private var lastPoint: CGPoint = .zero + public func pointerMoved(to point: CGPoint) { + lastPoint = point // Nur die tatsächlich sichtbare Fläche zählt. Das Fenster ist immer so // groß wie das ausgefahrene Panel, damit die Animation Platz hat — // würde man `panel.frame` nehmen, hielte ein Zeiger in der noch leeren @@ -119,6 +123,12 @@ public final class NotchScreenController { // umgeschaltet hat oder ob die Unterdrückung dazwischenkam. if machine.phase != before { log.notice("Notch \(self.displayID, privacy: .public): \(String(describing: before), privacy: .public) → \(String(describing: self.machine.phase), privacy: .public) durch \(String(describing: input), privacy: .public)") + // Beim Schließen zusätzlich, **warum** der Zeiger als draußen galt. + // Ohne diese drei Zahlen bleibt „es ging zu, obwohl ich noch drin + // war" eine Behauptung gegen eine andere. + if before == .open, self.machine.phase == .closing { + log.notice(" Zeiger \(Int(self.lastPoint.x), privacy: .public)/\(Int(self.lastPoint.y), privacy: .public), Auslösefläche \(String(describing: self.geometry.triggerRect), privacy: .public), Fenster \(String(describing: self.panel.frame), privacy: .public), ausgefahren \(self.presentation.isExpanded, privacy: .public)") + } } if machine.isVisible != wasVisible { onVisibilityChanged?(machine.isVisible) } }