Phase 5: Hardware- und Netzwerkmonitor, Panel und Menüleiste

Sechs neue Widgets (CPU, GPU, Speicher, Akku, Sensoren, Netzwerk) und dieselben
sechs als einzeln aktivierbare Menüleisten-Module mit fünf Darstellungsarten.

Panel-Widget und Menüleisten-Modul derselben Größe teilen sich die Messschleife
über Referenzzählung. Ohne das liefe für jede Anzeige ein eigener Timer mit
denselben IOKit- und SMC-Abfragen — bei sechs Modulen und ebenso vielen Widgets
ein Vielfaches der nötigen Arbeit. Meldet sich der letzte Konsument ab, hört die
Schleife auf; im Ruhezustand misst Onyx nichts.

Die Differenzrechnung ist testgetrieben, und die Tests haben zwei echte Fehler
gefunden. Erstens: mein Überlaufschutz beim Durchsatz hielt einen
zurückgesetzten Zähler (Interface-Wechsel) für einen Überlauf und zeigte
4,3 GB/s. Da if_data64 64-Bit-Zähler liefert, die bei 10 Gbit/s erst nach
470 Jahren umlaufen, ist ein kleinerer Wert immer ein Zurücksetzen — die
Unterscheidung produzierte nur den Ausreißer, den sie verhindern sollte.
Zweitens: ByteCountFormatter schrieb bei null „Zero KB/s" statt „0 KB/s".

Bei den CPU-Zählern bleibt der Überlaufschutz nötig: die sind 32 Bit breit und
laufen nach gut 400 Tagen wirklich um, deshalb `&-` statt `-`.

Sensoren kommen über den SMC-Leser aus Spike A statt über die private
IOHID-Schnittstelle — auf dieser Maschine nachweislich verifiziert, und die
dort gefundene Little-Endian-Eigenheit ist berücksichtigt. Nur eine kurze Liste
benannter Fühler statt aller 3486 Keys: eine Wand aus Kürzeln ist keine
Information.

Gegengemessen statt vermutet: 15 Kerne, 39,8 W, GPU 50 %, 16,6/24 GB mit
kritischem Druck, Akku 68 Zyklen bei 100 % Gesundheit, fünf Sensoren, zwei
Lüfter, en0 mit 661 KB/s.

Menüleisten-Module zeichnen in labelColor statt in den Onyx-Farben — nur die
Systemfarbe passt sich heller wie dunkler Leiste an. Feste Breite je
Darstellungsart, sonst schiebt jeder Messwert die halbe Leiste hin und her.

Standardmäßig ist kein Modul aktiv: sechs neue Symbole beim ersten Start wären
eine Zumutung.

145 Tests grün.
This commit is contained in:
Guido Schmit
2026-08-10 21:48:47 +02:00
parent a198d88104
commit 594d7c422a
21 changed files with 2897 additions and 350 deletions

View File

@@ -2,6 +2,7 @@ import SwiftUI
import AppKit
import OnyxNotch
import OnyxWidgetKit
import OnyxMenuBar
/// Der geteilte Zustand von Onyx: was im Panel liegt und auf welchen Displays.
///
@@ -54,7 +55,36 @@ final class AppModel {
}
}
/// Der Menüleisten-Controller. Wird nach dem Aufbau gesetzt, damit
/// Änderungen aus den Einstellungen sofort durchschlagen.
weak var menuBar: MenuBarController?
/// Die gespeicherten Einstellungen eines Menüleisten-Moduls.
///
/// Standardmäßig ist **kein** Modul an. Sechs Symbole beim ersten Start
/// wären eine Zumutung wer sie will, schaltet sie einzeln ein.
func menuBarSettings(for moduleID: String) -> MenuBarModuleSettings {
guard let data = defaults.data(forKey: Keys.menuBarModule(moduleID)),
let decoded = try? JSONDecoder().decode(MenuBarModuleSettings.self, from: data)
else { return MenuBarModuleSettings() }
return decoded
}
func setMenuBarSettings(_ settings: MenuBarModuleSettings, for moduleID: String) {
if let data = try? JSONEncoder().encode(settings) {
defaults.set(data, forKey: Keys.menuBarModule(moduleID))
}
menuBar?.setEnabled(settings.isEnabled, forModuleID: moduleID)
menuBar?.setPresentation(settings.presentation, forModuleID: moduleID)
menuBarRevision += 1
}
/// Zählt hoch, wenn sich ein Modul ändert die Einstellungen zeichnen sich
/// daran neu, ohne dass jedes Modul einzeln beobachtet werden müsste.
private(set) var menuBarRevision = 0
private enum Keys {
static func menuBarModule(_ id: String) -> String { "onyx.menubar.module.\(id)" }
static let displayPolicy = "onyx.displayPolicy"
static let showsMenuBarIcon = "onyx.showsMenuBarIcon"
static let showsDockIcon = "onyx.showsDockIcon"

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,8 @@ import OnyxWidgetKit
import CalendarProvider
import WeatherProvider
import MediaProvider
import MetricsProvider
import NetworkProvider
@main
struct OnyxApp: App {
@@ -34,6 +36,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var calendarModel: CalendarModel?
private var weatherModel: WeatherModel?
private var mediaModel: MediaModel?
private var metricsModel: MetricsModel?
private var networkModel: NetworkModel?
private let settingsWindow = SettingsWindowController()
func applicationDidFinishLaunching(_ notification: Notification) {
@@ -76,8 +80,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
let menuBar = MenuBarController()
if let metricsModel {
for metric in MetricKind.allCases {
menuBar.register(MetricMenuBarModule(metric: metric, model: metricsModel),
settings: model.menuBarSettings(for: "metric.\(metric.rawValue)"))
}
}
if let networkModel {
menuBar.register(NetworkMenuBarModule(model: networkModel),
settings: model.menuBarSettings(for: "network"))
}
menuBar.start()
self.menuBar = menuBar
model.menuBar = menuBar
installOnyxStatusItem()
observeMenuBarPreference()
@@ -136,6 +151,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self.mediaModel = mediaModel
WidgetRegistry.shared.register(MediaWidget(model: mediaModel))
let metricsModel = MetricsModel()
self.metricsModel = metricsModel
for metric in MetricKind.allCases {
WidgetRegistry.shared.register(MetricWidget(metric: metric, model: metricsModel))
}
let networkModel = NetworkModel()
self.networkModel = networkModel
WidgetRegistry.shared.register(NetworkWidget(model: networkModel))
PlaceholderWidgets.registerAll()
}

View File

@@ -44,18 +44,6 @@ enum PlaceholderWidgets {
/// damit gespeicherte Layouts weitergelten.
static func registerAll() {
let widgets: [PlaceholderWidget] = [
.init(id: "cpu", displayName: "CPU", symbolName: "cpu",
supportedSizes: [.small, .medium], phase: "Phase 5"),
.init(id: "gpu", displayName: "GPU", symbolName: "cpu.fill",
supportedSizes: [.small, .medium], phase: "Phase 5"),
.init(id: "memory", displayName: "Speicher", symbolName: "memorychip",
supportedSizes: [.small, .medium], phase: "Phase 5"),
.init(id: "battery", displayName: "Akku", symbolName: "battery.100",
supportedSizes: [.small, .medium], phase: "Phase 5"),
.init(id: "sensors", displayName: "Sensoren", symbolName: "thermometer",
supportedSizes: [.medium, .large], phase: "Phase 5"),
.init(id: "network", displayName: "Netzwerk", symbolName: "network",
supportedSizes: [.small, .medium, .large], phase: "Phase 5"),
.init(id: "fans", displayName: "Lüfter", symbolName: "fan",
supportedSizes: [.small, .medium], phase: "Phase 6"),
.init(id: "audio", displayName: "Audio-Mixer", symbolName: "slider.horizontal.3",

View File

@@ -3,10 +3,11 @@ import OnyxDesign
import OnyxNotch
import OnyxWidgetKit
import CalendarProvider
import OnyxMenuBar
import WeatherProvider
enum SettingsTab: Hashable {
case widgets, display, permissions
case widgets, display, menubar, permissions
}
struct SettingsView: View {
@@ -23,6 +24,9 @@ struct SettingsView: View {
DisplaySettings(model: model)
.tabItem { Label("settings.tab.display", systemImage: "macbook") }
.tag(SettingsTab.display)
MenuBarSettings(model: model)
.tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") }
.tag(SettingsTab.menubar)
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
.tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") }
.tag(SettingsTab.permissions)
@@ -147,6 +151,99 @@ private struct PermissionRow: View {
}
}
// MARK: - Menüleiste
/// Jedes Modul einzeln an- und abschaltbar, mit eigener Darstellungsart.
///
/// Standardmäßig ist keines an: sechs neue Symbole beim ersten Start wären eine
/// Zumutung, und die Menüleiste ist ohnehin knapp auf einem Notch-Display
/// blockiert die Kamera zusätzlich die Mitte.
private struct MenuBarSettings: View {
@Bindable var model: AppModel
private static let modules: [(id: String, label: LocalizedStringKey, symbol: String)] = [
("metric.cpu", "metric.cpu", "cpu"),
("metric.gpu", "metric.gpu", "cpu.fill"),
("metric.memory", "metric.memory", "memorychip"),
("metric.battery", "metric.battery", "battery.100"),
("metric.temperature", "metric.sensors", "thermometer"),
("network", "network.name", "network"),
]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("settings.menubar.hint")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
List {
ForEach(Self.modules, id: \.id) { module in
ModuleRow(model: model, id: module.id,
label: module.label, symbol: module.symbol)
}
}
.listStyle(.inset)
}
.padding()
// Der Zähler zwingt die Liste zum Neuzeichnen, wenn sich ein Modul
// ändert sonst müsste jede Zeile ihren eigenen Zustand beobachten.
.id(model.menuBarRevision)
}
}
private struct ModuleRow: View {
@Bindable var model: AppModel
let id: String
let label: LocalizedStringKey
let symbol: String
var body: some View {
let settings = model.menuBarSettings(for: id)
HStack {
Toggle(isOn: Binding(
get: { settings.isEnabled },
set: { enabled in
var updated = settings
updated.isEnabled = enabled
model.setMenuBarSettings(updated, for: id)
})) {
Label(label, systemImage: symbol)
}
Spacer()
Picker("", selection: Binding(
get: { settings.presentation },
set: { presentation in
var updated = settings
updated.presentation = presentation
model.setMenuBarSettings(updated, for: id)
})) {
ForEach(MenuBarPresentation.allCases) { presentation in
Text(presentation.localizedName).tag(presentation)
}
}
.labelsHidden()
.frame(width: 150)
.disabled(!settings.isEnabled)
}
}
}
extension MenuBarPresentation {
var localizedName: LocalizedStringKey {
switch self {
case .value: "settings.menubar.value"
case .graph: "settings.menubar.graph"
case .bars: "settings.menubar.bars"
case .valueAndGraph: "settings.menubar.valueAndGraph"
case .symbol: "settings.menubar.symbol"
}
}
}
// MARK: - Widgets
private struct WidgetSettings: View {