Der Regler war eine Pseudobedienung. Der SMC lehnt Schreibzugriffe auf CHLT ab — der Helfer hat es mit korrekter Größe versucht und „geschrieben (false), zurückgelesen 85" gemeldet. Das Sicherheitsnetz hat also die Wahrheit gesagt; die Oberfläche hat sie nur nicht gezeigt. Weiter zu raten hieße, im Lade-Subsystem Keys durchzuprobieren, und das ist die eine Operation in diesem Projekt, die Hardware dauerhaft beschädigen kann. Also: das Limit wird gelesen und angezeigt, mit einem Weg in die Systemeinstellungen, wo es tatsächlich geht. Der Schreibpfad ist aus dem privilegierten Helfer entfernt — ein Root-Dienst trägt nur, was benutzt wird. Die Akku-Kachel zeigte „40 %" und einen Verlauf über die letzten Sekunden. Der Ladestand ändert sich in Minuten, nicht in Sekunden; der Graph war eine waagerechte Linie. Jetzt: verbleibende Zeit bzw. Zeit bis voll, Leistung in Watt mit Richtung, Ladelimit, Zustand, Zyklen — und der Energiesparmodus. Der erklärt, warum die Maschine langsamer wirkt, und ohne diesen Hinweis sucht man den Grund woanders. In der Menüleiste standen fünf nackte Prozentzahlen nebeneinander. Man sah Zahlen und wusste nicht, welche wozu gehört. Zwei neue Darstellungen: Kürzel und Wert („CPU 34 %") sowie Symbol und Wert. Beim Akku stehen sie zuoberst in der Auswahl. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
266 lines
9.8 KiB
Swift
266 lines
9.8 KiB
Swift
import SwiftUI
|
||
import AppKit
|
||
import OnyxDesign
|
||
import OnyxWidgetKit
|
||
import OnyxMenuBar
|
||
import OnyxHelperProtocol
|
||
|
||
/// Die Lüfter — als Panel-Kachel und als Menüleistenmodul.
|
||
///
|
||
/// Beide zeigen dieselbe Ansicht auf derselben Datenquelle. Die Steuerung nur
|
||
/// in den Einstellungen zu verstecken war falsch: Lüfter regelt man, wenn es
|
||
/// gerade laut ist, und dann will man nicht erst ein Fenster suchen.
|
||
struct FanView: View {
|
||
let control: FanControl
|
||
/// Im Popover ist Platz für die Erklärung, in der Kachel nicht.
|
||
let roomy: Bool
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
switch control.installState {
|
||
case .installed:
|
||
if let report = control.report, !report.fans.isEmpty {
|
||
ForEach(Array(report.fans.enumerated()), id: \.offset) { index, fan in
|
||
FanRow(control: control, index: index, fan: fan, roomy: roomy)
|
||
}
|
||
if let hottest = report.hottestCelsius {
|
||
Divider().overlay(Onyx.Color.hairline)
|
||
HStack(spacing: 5) {
|
||
Image(systemName: "thermometer.medium")
|
||
.font(.system(size: 10))
|
||
Text("\(Int(hottest.rounded())) °C")
|
||
.font(Onyx.Font.metricSmall).monospacedDigit()
|
||
Spacer(minLength: 0)
|
||
}
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
}
|
||
} else {
|
||
notice("fans.noData", symbol: "fan")
|
||
}
|
||
case .requiresApproval:
|
||
notice("fans.approval.needed", symbol: "hand.raised") {
|
||
control.openApprovalSettings()
|
||
}
|
||
case .notInstalled:
|
||
notice("fans.notInstalled", symbol: "fan") { control.install() }
|
||
case .failed(let reason):
|
||
Text(reason)
|
||
.font(Onyx.Font.caption)
|
||
.foregroundStyle(Onyx.Color.critical)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
.onAppear { control.refreshInstallState() }
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func notice(_ key: LocalizedStringKey, symbol: String,
|
||
action: (() -> Void)? = nil) -> some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Image(systemName: symbol)
|
||
.font(.system(size: 15, weight: .light))
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
Text(key)
|
||
.font(Onyx.Font.caption)
|
||
.foregroundStyle(Onyx.Color.textSecondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
if let action {
|
||
Button(action: action) {
|
||
Text("fans.install")
|
||
.font(Onyx.Font.caption)
|
||
.foregroundStyle(Onyx.Color.surface)
|
||
.padding(.horizontal, 10).padding(.vertical, 4)
|
||
.background(Onyx.Color.accent, in: .capsule)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct FanRow: View {
|
||
let control: FanControl
|
||
let index: Int
|
||
let fan: FanStatus
|
||
let roomy: Bool
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack(spacing: 6) {
|
||
Text("fans.fan \(fan.index + 1)")
|
||
.font(Onyx.Font.caption)
|
||
.foregroundStyle(Onyx.Color.textSecondary)
|
||
.lineLimit(1)
|
||
Spacer(minLength: 4)
|
||
Text("\(Int(fan.currentRPM)) U/min")
|
||
.font(Onyx.Font.metricSmall).monospacedDigit()
|
||
.foregroundStyle(Onyx.Color.textPrimary)
|
||
}
|
||
|
||
HStack(spacing: 8) {
|
||
// Automatik ist der Ruhezustand und bleibt einen Klick entfernt:
|
||
// wer manuell geregelt hat, muss ohne Nachdenken zurückkönnen.
|
||
// Als gefüllte Pille, wenn sie greift — ein blauer Text sieht
|
||
// aus wie ein Link und nicht wie ein Zustand.
|
||
Button { control.setAutomatic(index: index) } label: {
|
||
Text("fans.auto")
|
||
.font(Onyx.Font.metricSmall)
|
||
.foregroundStyle(fan.isManual ? Onyx.Color.textTertiary
|
||
: Onyx.Color.surface)
|
||
.padding(.horizontal, 7)
|
||
.padding(.vertical, 2)
|
||
.background {
|
||
Capsule()
|
||
.fill(fan.isManual ? Color.clear : Onyx.Color.accent)
|
||
.overlay {
|
||
Capsule().strokeBorder(
|
||
fan.isManual ? Onyx.Color.hairline : .clear,
|
||
lineWidth: 1)
|
||
}
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!fan.isManual)
|
||
|
||
// Ein Regler über einen leeren Bereich stürzt ab. Meldet der
|
||
// Helfer keine brauchbaren Grenzen — etwa weil der Lüfter steht
|
||
// und der SMC nichts hergibt —, gibt es nichts zu ziehen.
|
||
if fan.limits.maximum > fan.limits.minimum {
|
||
Slider(value: Binding(
|
||
get: { min(max(fan.targetRPM, fan.limits.minimum), fan.limits.maximum) },
|
||
set: { control.setTarget(index: index, rpm: $0) }),
|
||
in: fan.limits.minimum...fan.limits.maximum)
|
||
.controlSize(.mini)
|
||
} else {
|
||
Text("fans.noRange")
|
||
.font(Onyx.Font.metricSmall)
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Panel-Kachel
|
||
|
||
struct FanWidget: OnyxWidget {
|
||
let id = "fans"
|
||
var displayName: String { String(localized: "widget.fans.name") }
|
||
let symbolName = "fan"
|
||
|
||
private let control: FanControl
|
||
|
||
init(control: FanControl) { self.control = control }
|
||
|
||
func makeView() -> AnyView {
|
||
AnyView(ScrollView { FanView(control: control, roomy: false) }
|
||
.scrollIndicators(.never))
|
||
}
|
||
}
|
||
|
||
// MARK: - Menüleistenmodul
|
||
|
||
@MainActor
|
||
final class FanMenuBarModule: MenuBarModule {
|
||
let id = "fans"
|
||
var displayName: String { String(localized: "widget.fans.name") }
|
||
|
||
var shortLabel: String { String(localized: "fans.menubar.short") }
|
||
|
||
private let control: FanControl
|
||
private var view: FanStatusView?
|
||
private var isActive = false
|
||
|
||
init(control: FanControl) { self.control = control }
|
||
|
||
func makeStatusView(presentation: MenuBarPresentation) -> NSView {
|
||
let view = FanStatusView(presentation: presentation)
|
||
view.update(rpm: control.report?.fans.map(\.currentRPM).max())
|
||
self.view = view
|
||
return view
|
||
}
|
||
|
||
func makePopoverView() -> AnyView {
|
||
AnyView(FanView(control: control, roomy: true)
|
||
.padding(14)
|
||
.frame(width: 260))
|
||
}
|
||
|
||
func activate() {
|
||
isActive = true
|
||
control.start()
|
||
startObserving()
|
||
}
|
||
|
||
func deactivate() {
|
||
isActive = false
|
||
view = nil
|
||
}
|
||
|
||
/// Keine eigene Messschleife: `FanControl` fragt den Helfer ohnehin ab, hier
|
||
/// wird nur zugehört. `withObservationTracking` meldet sich **einmal** und
|
||
/// muss danach neu eingerichtet werden.
|
||
private func startObserving() {
|
||
withObservationTracking {
|
||
_ = control.report
|
||
} onChange: {
|
||
Task { @MainActor [weak self] in
|
||
guard let self, isActive else { return }
|
||
view?.update(rpm: control.report?.fans.map(\.currentRPM).max())
|
||
startObserving()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Die Drehzahl in der Menüleiste.
|
||
private final class FanStatusView: NSView, WidthReporting {
|
||
private let presentation: MenuBarPresentation
|
||
private var rpm: Double?
|
||
|
||
init(presentation: MenuBarPresentation) {
|
||
self.presentation = presentation
|
||
super.init(frame: NSRect(x: 0, y: 0, width: 22, height: 22))
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) { fatalError() }
|
||
|
||
override var intrinsicContentSize: NSSize {
|
||
NSSize(width: MenuBarText.width(for: reference, includesSymbol: true), height: 22)
|
||
}
|
||
|
||
/// Die breiteste Zahl, die vorkommen kann — nicht die aktuelle. Sonst
|
||
/// änderte das Element bei jeder Messung seine Breite.
|
||
private var reference: String { presentation == .symbol ? "" : "9999" }
|
||
|
||
/// Der Lüfter zeigt ohnehin Symbol und Zahl — die neuen Darstellungen
|
||
/// ändern daran nichts, sie stehen nur in der Auswahl nicht zur Verfügung.
|
||
|
||
var onWidthChange: ((CGFloat) -> Void)?
|
||
|
||
func update(rpm: Double?) {
|
||
self.rpm = rpm
|
||
needsDisplay = true
|
||
}
|
||
|
||
override func draw(_ dirtyRect: NSRect) {
|
||
let color = NSColor.labelColor
|
||
|
||
if presentation == .symbol {
|
||
MenuBarText.drawSymbol("fan", color: color,
|
||
in: NSRect(x: bounds.midX - 7.5, y: bounds.midY - 7.5,
|
||
width: 15, height: 15))
|
||
return
|
||
}
|
||
|
||
MenuBarText.drawSymbol("fan", color: color,
|
||
in: NSRect(x: 0, y: bounds.midY - 6.5, width: 13, height: 13))
|
||
MenuBarText.draw(rpm.map { String(Int($0.rounded())) } ?? "–",
|
||
color: color,
|
||
in: NSRect(x: 15, y: 0, width: bounds.width - 15, height: bounds.height))
|
||
}
|
||
}
|