Die Netzwerkkachel war auf „↓ 999,9 MB/s" dimensioniert und stand damit neben „↓ 13 KB/s" zur Hälfte leer. Auf den schlimmsten Fall auszulegen ist für einen Wert, der sich um den Faktor tausend ändert, die falsche Antwort. Gemessen wird jetzt, was dasteht — mit unsymmetrischer Hysterese: sofort wachsen, denn abgeschnittene Messwerte sind falsche Messwerte, und erst nach zehn ruhigeren Messungen schrumpfen, damit ein Lastausschlag die Leiste nicht zum Pumpen bringt. Das Akkusymbol war zerquetscht. Ein Batteriesymbol ist doppelt so breit wie hoch und wurde in ein Quadrat gezeichnet. Symbole behalten jetzt ihr Seitenverhältnis, und die Breite des Elements richtet sich nach dem, was das Symbol tatsächlich braucht — sonst hätte ein breites Glyph dieselbe Spalte wie ein schmales. Bei den Sensoren stand stumm das Maximum über alle Punkte: eine Zahl, von der niemand weiß, woher sie kommt. Jetzt wählbar, und der gewählte Sensor gibt sein eigenes Kürzel — „Temp 46°" sagt weniger als „Heatpipe 46°". Voreinstellung bleibt der wärmste Punkt, weil das die häufigste Frage ist. Und die Lüfterkachel zeigte „2500" ohne Einheit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
6.9 KiB
Swift
188 lines
6.9 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)?
|
|
|
|
/// Welcher Sensor in der Menüleiste steht. `nil` = der wärmste.
|
|
///
|
|
/// Ohne diese Wahl zeigte das Modul stumm das Maximum über alle Sensoren —
|
|
/// eine Zahl, von der niemand weiß, woher sie kommt. „Der wärmste Punkt"
|
|
/// bleibt die Voreinstellung, weil das die Frage ist, die man meistens
|
|
/// hat; aber wer den Akku oder die GPU im Blick behalten will, soll das
|
|
/// sagen können.
|
|
public var selectedSensorKey: String? {
|
|
didSet {
|
|
guard selectedSensorKey != oldValue else { return }
|
|
defaults.set(selectedSensorKey, forKey: "onyx.metric.sensorKey")
|
|
}
|
|
}
|
|
|
|
/// Der Wert des gewählten Sensors — oder der wärmste, wenn keiner gewählt
|
|
/// ist oder der gewählte gerade nicht meldet.
|
|
public var selectedSensorCelsius: Double? {
|
|
if let key = selectedSensorKey,
|
|
let sensor = snapshot.sensors.first(where: { $0.key == key }) {
|
|
return sensor.celsius
|
|
}
|
|
return snapshot.sensors.map(\.celsius).max()
|
|
}
|
|
|
|
/// Wie der gewählte Sensor heißt — fürs Kürzel in der Menüleiste.
|
|
public var selectedSensorName: String? {
|
|
guard let key = selectedSensorKey else { return nil }
|
|
return snapshot.sensors.first(where: { $0.key == key })?.name
|
|
}
|
|
|
|
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.selectedSensorKey = defaults.string(forKey: "onyx.metric.sensorKey")
|
|
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 }
|
|
}
|