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 ChargeLimitControlling)? 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 } }