Files
onyx/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift
Scarriffle 386eb6ae70 Ladelimit ist eine Anzeige, Akku-Kachel sagt etwas, Menüleiste bekommt Kürzel
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>
2026-08-11 12:37:04 +02:00

157 lines
5.6 KiB
Swift

import Foundation
import SwiftUI
/// Ein Messwertstrom mit Referenzzählung.
///
/// Panel-Widget und Menüleisten-Modul greifen auf **dieselbe** Messschleife zu.
/// Ohne das liefe für jede Anzeige ein eigener Timer, der dieselben IOKit- und
/// SMC-Abfragen doppelt macht bei sechs Modulen und ebenso vielen Widgets
/// wäre das ein Vielfaches der nötigen Arbeit.
///
/// Meldet sich der letzte Konsument ab, hört die Schleife auf. Im Ruhezustand
/// misst Onyx nichts.
@MainActor
@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 ChargeLimitReading)?
public private(set) var snapshot = MetricsSnapshot()
/// Verlauf für die Graphen, jüngster Wert zuletzt.
public private(set) var history: [MetricsSnapshot] = []
/// So viele Punkte zeigt ein Menüleisten-Graph. Mehr aufzuheben kostet
/// Speicher für etwas, das niemand sieht.
public static let historyLength = 60
/// Was CPU und GPU zeigen. Beobachtbar, damit Panel und Menüleiste sofort
/// umschalten.
public private(set) var displayModes: [MetricKind: MetricDisplayMode] = [:]
private let defaults: UserDefaults
private let source = SystemMetrics()
private var timer: Timer?
/// Je Konsument das gewünschte Intervall. Gemessen wird mit dem kürzesten.
private var demands: [UUID: TimeInterval] = [:]
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
for metric in MetricKind.allCases where metric.supportsDisplayModes {
let stored = defaults.string(forKey: Self.key(metric))
displayModes[metric] = stored.flatMap(MetricDisplayMode.init(rawValue:)) ?? .usage
}
}
public func displayMode(for metric: MetricKind) -> MetricDisplayMode {
displayModes[metric] ?? .usage
}
public func setDisplayMode(_ mode: MetricDisplayMode, for metric: MetricKind) {
guard metric.supportsDisplayModes else { return }
displayModes[metric] = mode
defaults.set(mode.rawValue, forKey: Self.key(metric))
}
private static func key(_ metric: MetricKind) -> String {
"onyx.metric.\(metric.rawValue).displayMode"
}
/// Meldet Bedarf an. Der Rückgabewert wird zum Abmelden gebraucht.
@discardableResult
public func addConsumer(interval: TimeInterval = 2) -> UUID {
let token = UUID()
demands[token] = min(max(interval, 1), 10)
restartTimer()
return token
}
public func removeConsumer(_ token: UUID) {
demands[token] = nil
restartTimer()
}
/// Ändert das Intervall eines bestehenden Konsumenten, ohne die Schleife
/// anzuhalten etwa wenn das Panel aufgeht und schneller messen will.
public func updateConsumer(_ token: UUID, interval: TimeInterval) {
guard demands[token] != nil else { return }
demands[token] = min(max(interval, 1), 10)
restartTimer()
}
private func restartTimer() {
timer?.invalidate()
timer = nil
guard let interval = demands.values.min() else {
history.removeAll()
return
}
sample()
let timer = Timer(timeInterval: interval, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.sample() }
}
// `.common`, sonst steht die Messung, während ein Menü offen ist.
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}
private func sample() {
let value = source.sample()
snapshot = value
history.append(value)
if history.count > Self.historyLength { history.removeFirst(history.count - Self.historyLength) }
}
}
public extension MetricsModel {
/// Verlauf einer einzelnen Größe, für die Mini-Graphen.
func series(_ metric: MetricKind) -> [Double] {
let mode = displayMode(for: metric)
return history.compactMap { snapshot in
if mode == .temperature, metric.supportsDisplayModes {
let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature
return celsius.map { $0 / 100 }
}
switch metric {
case .cpu: return snapshot.cpu.total
case .gpu: return snapshot.gpu
case .memory: return snapshot.memory.usedFraction
case .battery: return snapshot.battery?.charge
case .temperature: return snapshot.sensors.map(\.celsius).max().map { $0 / 100 }
}
}
}
}
/// Was ein CPU- oder GPU-Element zeigt.
///
/// Beide Werte kommen aus derselben Messung die Umschaltung kostet nichts und
/// erspart ein zweites Element in einer ohnehin knappen Menüleiste.
public enum MetricDisplayMode: String, Codable, Sendable, CaseIterable, Identifiable {
case usage
case temperature
public var id: String { rawValue }
}
public enum MetricKind: String, CaseIterable, Sendable, Identifiable {
case cpu, gpu, memory, battery, temperature
public var id: String { rawValue }
public var symbolName: String {
switch self {
case .cpu: "cpu"
case .gpu: "cpu.fill"
case .memory: "memorychip"
case .battery: "battery.100"
case .temperature: "thermometer"
}
}
/// Nur CPU und GPU haben beides. Speicher und Akku haben keine eigene
/// Temperatur, die Sensorkarte zeigt ohnehin nur Temperaturen.
public var supportsDisplayModes: Bool { self == .cpu || self == .gpu }
}