Die M-Serie-Konvention gilt auch auf dem M5: Tp* sind die CPU-Kerne, Tg* die GPU-Cluster. Gezeigt wird der wärmste Punkt, nicht der Durchschnitt — gedrosselt wird nach dem heißesten Kern. Gemessen: CPU 76 °C, GPU 70 °C. Die Fühlerliste wird zur Laufzeit gesucht statt fest verdrahtet: welche es gibt, hängt am Modell. Auf diesem Mac sind es 285 Temperaturfühler. Zwei Leistungsprobleme, gefunden bevor sie jemandem auffallen konnten: Die Suche geht alle 3486 Keys durch und braucht knapp eine Sekunde. Auf dem Hauptthread wäre das eine spürbare Startverzögerung — sie läuft jetzt im Hintergrund, bis dahin zeigt die Oberfläche "—" statt einer erfundenen Zahl. Fünfzig Fühler zu lesen kostete 34 ms je Abtastung, bei 1 Hz also 3,4 % CPU. Zwei Maßnahmen: Typ und Größe je Key werden zwischengespeichert (sie stehen in der Firmware fest, sie bei jedem Lesen zu erfragen verdoppelte die IOKit-Aufrufe), und Temperaturen werden höchstens alle zwei Sekunden neu gelesen — sie bewegen sich in einer Sekunde ohnehin kaum. Ergebnis: 6 ms Grundlast, 18 ms alle zwei Sekunden, im Mittel gut 1 %. Dabei noch eine Eigenheit gefunden: die Byte-Reihenfolge im SMC ist nicht durchgängig. Messwerte wie B0AV kommen little-endian aus dem Akku-Baustein, die Metadaten des SMC selbst dagegen big-endian. #KEY little-endian gelesen ergibt 2 651 652 096 statt 3486 — die Fühlersuche brach damit still ab und alle Temperaturen blieben leer. Lüfterdrehzahlen stehen jetzt im Sensor-Popover, mit Balken zwischen Minimum und Maximum der Firmware: "2321 U/min" allein sagt niemandem, ob das viel ist. Die Steuerung selbst kommt mit Phase 6 — Schreibzugriff auf den SMC verlangt root — und der Popover sagt das auch. 145 Tests grün.
327 lines
12 KiB
Swift
327 lines
12 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import OnyxDesign
|
|
import OnyxMenuBar
|
|
|
|
/// Ein Menüleisten-Modul je Hardwaregröße.
|
|
///
|
|
/// Teilt sich das Modell — und damit die Messschleife — mit dem Panel-Widget
|
|
/// derselben Größe. Wer CPU im Panel **und** in der Menüleiste zeigt, bekommt
|
|
/// trotzdem nur eine Messung.
|
|
@MainActor
|
|
public final class MetricMenuBarModule: MenuBarModule {
|
|
|
|
public let id: String
|
|
public var displayName: String {
|
|
String(localized: .init(metric.localizationKey), bundle: .module)
|
|
}
|
|
|
|
private let metric: MetricKind
|
|
private let model: MetricsModel
|
|
private var token: UUID?
|
|
private var view: MetricStatusView?
|
|
private var observation: (any NSObjectProtocol)?
|
|
|
|
public init(metric: MetricKind, model: MetricsModel) {
|
|
self.metric = metric
|
|
self.model = model
|
|
self.id = "metric.\(metric.rawValue)"
|
|
}
|
|
|
|
public func makeStatusView(presentation: MenuBarPresentation) -> NSView {
|
|
let view = MetricStatusView(metric: metric, presentation: presentation)
|
|
view.update(snapshot: model.snapshot, history: model.series(metric),
|
|
mode: model.displayMode(for: metric))
|
|
self.view = view
|
|
return view
|
|
}
|
|
|
|
public func makePopoverView() -> AnyView {
|
|
AnyView(MetricPopover(model: model, metric: metric))
|
|
}
|
|
|
|
public func activate() {
|
|
guard token == nil else { return }
|
|
token = model.addConsumer(interval: 2)
|
|
startObserving()
|
|
}
|
|
|
|
public func deactivate() {
|
|
if let token { model.removeConsumer(token) }
|
|
token = nil
|
|
observation = nil
|
|
view = nil
|
|
}
|
|
|
|
/// `withObservationTracking` meldet sich **einmal** und muss danach neu
|
|
/// eingerichtet werden — sonst friert die Anzeige nach der ersten Messung ein.
|
|
private func startObserving() {
|
|
withObservationTracking {
|
|
_ = model.snapshot
|
|
_ = model.displayModes // auch auf den Moduswechsel reagieren
|
|
} onChange: {
|
|
Task { @MainActor [weak self] in
|
|
guard let self, token != nil else { return }
|
|
view?.update(snapshot: model.snapshot, history: model.series(metric),
|
|
mode: model.displayMode(for: metric))
|
|
startObserving()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Zeichnet den Messwert in die Menüleiste.
|
|
///
|
|
/// Eigene `NSView` statt SwiftUI: die Menüleiste verlangt eine feste Breite je
|
|
/// Darstellungsart, sonst springt bei jeder Messung die ganze Leiste. Mit
|
|
/// SwiftUI müsste man diese Breite doppelt führen — einmal für das Layout und
|
|
/// einmal für `NSStatusItem.length`.
|
|
final class MetricStatusView: NSView {
|
|
|
|
private let metric: MetricKind
|
|
private let presentation: MenuBarPresentation
|
|
private var snapshot = MetricsSnapshot()
|
|
private var history: [Double] = []
|
|
private var mode: MetricDisplayMode = .usage
|
|
|
|
init(metric: MetricKind, presentation: MenuBarPresentation) {
|
|
self.metric = metric
|
|
self.presentation = presentation
|
|
super.init(frame: NSRect(x: 0, y: 0, width: presentation.width, height: 22))
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError() }
|
|
|
|
override var intrinsicContentSize: NSSize {
|
|
NSSize(width: presentation.width, height: 22)
|
|
}
|
|
|
|
func update(snapshot: MetricsSnapshot, history: [Double], mode: MetricDisplayMode) {
|
|
self.snapshot = snapshot
|
|
self.history = history
|
|
self.mode = mode
|
|
needsDisplay = true
|
|
}
|
|
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
// `labelColor` statt der Onyx-Farben: die Menüleiste ist mal hell, mal
|
|
// dunkel, und nur die Systemfarbe passt sich beidem an.
|
|
let color = NSColor.labelColor
|
|
|
|
switch presentation {
|
|
case .value:
|
|
drawText(MetricSummary.value(metric, snapshot, mode: mode), color: color)
|
|
case .symbol:
|
|
drawSymbol(color: color)
|
|
case .graph:
|
|
drawGraph(color: color, in: bounds.insetBy(dx: 2, dy: 5))
|
|
case .bars:
|
|
drawBars(color: color)
|
|
case .valueAndGraph:
|
|
let split = bounds.width * 0.55
|
|
drawText(MetricSummary.value(metric, snapshot, mode: mode), color: color,
|
|
in: NSRect(x: 0, y: 0, width: split, height: bounds.height))
|
|
drawGraph(color: color,
|
|
in: NSRect(x: split + 2, y: 5,
|
|
width: bounds.width - split - 4, height: bounds.height - 10))
|
|
}
|
|
}
|
|
|
|
private func drawText(_ text: String, color: NSColor, in rect: NSRect? = nil) {
|
|
let attributes: [NSAttributedString.Key: Any] = [
|
|
// Feste Ziffernbreite: ohne sie wackelt der Text bei jeder Messung.
|
|
.font: NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular),
|
|
.foregroundColor: color,
|
|
]
|
|
let string = NSAttributedString(string: text, attributes: attributes)
|
|
let area = rect ?? bounds
|
|
let size = string.size()
|
|
string.draw(at: NSPoint(x: area.midX - size.width / 2,
|
|
y: area.midY - size.height / 2))
|
|
}
|
|
|
|
private func drawSymbol(color: NSColor) {
|
|
guard let image = NSImage(systemSymbolName: metric.symbolName,
|
|
accessibilityDescription: nil) else { return }
|
|
image.isTemplate = true
|
|
let side: CGFloat = 15
|
|
let rect = NSRect(x: bounds.midX - side / 2, y: bounds.midY - side / 2,
|
|
width: side, height: side)
|
|
color.set()
|
|
image.draw(in: rect)
|
|
}
|
|
|
|
private func drawGraph(color: NSColor, in rect: NSRect) {
|
|
guard history.count > 1, let context = NSGraphicsContext.current?.cgContext else { return }
|
|
let step = rect.width / CGFloat(history.count - 1)
|
|
|
|
context.setStrokeColor(color.withAlphaComponent(0.85).cgColor)
|
|
context.setLineWidth(1)
|
|
context.setLineJoin(.round)
|
|
for (index, value) in history.enumerated() {
|
|
let point = CGPoint(x: rect.minX + CGFloat(index) * step,
|
|
y: rect.minY + rect.height * CGFloat(min(max(value, 0), 1)))
|
|
index == 0 ? context.move(to: point) : context.addLine(to: point)
|
|
}
|
|
context.strokePath()
|
|
}
|
|
|
|
/// Ein Balken je Kern. Bei fünfzehn Kernen ist das kein Diagramm mehr,
|
|
/// sondern ein Muster — aber genau daran erkennt man auf einen Blick, ob
|
|
/// eine einzelne Last läuft oder alles ausgelastet ist.
|
|
private func drawBars(color: NSColor) {
|
|
// Bei der CPU-Auslastung ein Balken je Kern; sonst — auch bei der
|
|
// CPU-Temperatur — ein einzelner Balken.
|
|
let values = (metric == .cpu && mode == .usage)
|
|
? snapshot.cpu.perCore
|
|
: [MetricSummary.fraction(metric, snapshot, mode: mode)]
|
|
guard !values.isEmpty, let context = NSGraphicsContext.current?.cgContext else { return }
|
|
|
|
let area = bounds.insetBy(dx: 2, dy: 5)
|
|
let gap: CGFloat = values.count > 4 ? 1 : 2
|
|
let width = (area.width - gap * CGFloat(values.count - 1)) / CGFloat(values.count)
|
|
|
|
for (index, value) in values.enumerated() {
|
|
let height = max(area.height * CGFloat(min(max(value, 0), 1)), 1)
|
|
let rect = NSRect(x: area.minX + CGFloat(index) * (width + gap),
|
|
y: area.minY, width: width, height: height)
|
|
context.setFillColor(color.withAlphaComponent(0.85).cgColor)
|
|
context.fill(rect)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension MenuBarPresentation {
|
|
/// Feste Breite je Darstellungsart.
|
|
///
|
|
/// Ohne sie ändert das Element bei jedem Messwert seine Größe und schiebt
|
|
/// alle Symbole rechts davon hin und her — das fällt in der Menüleiste
|
|
/// sofort unangenehm auf.
|
|
var width: CGFloat {
|
|
switch self {
|
|
case .value: 42
|
|
case .symbol: 22
|
|
case .graph: 34
|
|
case .bars: 30
|
|
case .valueAndGraph: 74
|
|
}
|
|
}
|
|
}
|
|
|
|
public extension MetricSummary {
|
|
/// Der Messwert als Anteil von 0 bis 1 — für Balken und Graphen.
|
|
static func fraction(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> Double {
|
|
switch metric {
|
|
case .cpu: snapshot.cpu.total
|
|
case .gpu: snapshot.gpu ?? 0
|
|
case .memory: snapshot.memory.usedFraction
|
|
case .battery: snapshot.battery?.charge ?? 0
|
|
case .temperature: (snapshot.sensors.map(\.celsius).max() ?? 0) / 100
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Popover
|
|
|
|
private struct MetricPopover: View {
|
|
let model: MetricsModel
|
|
let metric: MetricKind
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(String(localized: .init(metric.localizationKey), bundle: .module))
|
|
.font(.headline)
|
|
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
Text(MetricSummary.value(metric, model.snapshot))
|
|
.font(.system(size: 26, weight: .medium).monospacedDigit())
|
|
if let detail = MetricSummary.detail(metric, model.snapshot) {
|
|
Text(detail).font(.callout).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Sparkline(values: model.series(metric), tint: .accentColor)
|
|
.frame(width: 220, height: 44)
|
|
|
|
if metric == .cpu, !model.snapshot.cpu.perCore.isEmpty {
|
|
Text("metric.cpu.perCore", bundle: .module)
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
CoreBars(values: model.snapshot.cpu.perCore)
|
|
.frame(height: 26)
|
|
}
|
|
|
|
if metric == .temperature {
|
|
ForEach(model.snapshot.sensors) { sensor in
|
|
HStack {
|
|
Text(sensor.name).font(.callout)
|
|
Spacer()
|
|
Text(MetricFormat.temperature(sensor.celsius))
|
|
.font(.callout.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
if !model.snapshot.fans.isEmpty {
|
|
Divider()
|
|
ForEach(model.snapshot.fans) { fan in
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack {
|
|
Label("Lüfter \(fan.index + 1)", systemImage: "fan")
|
|
.font(.callout)
|
|
Spacer()
|
|
Text("\(Int(fan.rpm)) U/min")
|
|
.font(.callout.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
// Der Balken zeigt die Drehzahl zwischen Minimum und
|
|
// Maximum der Firmware — „2321 U/min" allein sagt
|
|
// niemandem, ob das viel ist.
|
|
ProgressView(value: fan.fraction).controlSize(.small)
|
|
}
|
|
}
|
|
Text("metric.fan.controlComing", bundle: .module)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
if metric.supportsDisplayModes {
|
|
Divider()
|
|
Picker("", selection: Binding(
|
|
get: { model.displayMode(for: metric) },
|
|
set: { model.setDisplayMode($0, for: metric) })) {
|
|
Text("metric.mode.usage", bundle: .module).tag(MetricDisplayMode.usage)
|
|
Text("metric.mode.temperature", bundle: .module)
|
|
.tag(MetricDisplayMode.temperature)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
}
|
|
}
|
|
.padding(14)
|
|
.frame(width: 250)
|
|
}
|
|
}
|
|
|
|
private struct CoreBars: View {
|
|
let values: [Double]
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let gap: CGFloat = 2
|
|
let width = (geometry.size.width - gap * CGFloat(values.count - 1))
|
|
/ CGFloat(values.count)
|
|
HStack(alignment: .bottom, spacing: gap) {
|
|
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
|
|
RoundedRectangle(cornerRadius: 1, style: .continuous)
|
|
.fill(Color.accentColor)
|
|
.frame(width: width,
|
|
height: max(geometry.size.height * value, 1))
|
|
}
|
|
}
|
|
.frame(height: geometry.size.height, alignment: .bottom)
|
|
}
|
|
}
|
|
}
|