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:
@@ -20,6 +20,8 @@
|
|||||||
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; };
|
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; };
|
||||||
B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; };
|
B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; };
|
||||||
C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; };
|
C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; };
|
||||||
|
C1F651D2BA69358344927F26 /* MetricsProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 6CACA1C2A58DEFDE46F71499 /* MetricsProvider */; };
|
||||||
|
C390C5B65769A007A49C3A80 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; };
|
||||||
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; };
|
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; };
|
||||||
EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
|
EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
|
||||||
FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 055263008CB3306A8D09E6D4 /* AppIcon.icon */; };
|
FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 055263008CB3306A8D09E6D4 /* AppIcon.icon */; };
|
||||||
@@ -53,6 +55,8 @@
|
|||||||
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */,
|
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */,
|
||||||
5BE38CAFCDA4B9CBE787CABA /* WeatherProvider in Frameworks */,
|
5BE38CAFCDA4B9CBE787CABA /* WeatherProvider in Frameworks */,
|
||||||
1E2D654EFDDF55AEA75DBB7C /* MediaProvider in Frameworks */,
|
1E2D654EFDDF55AEA75DBB7C /* MediaProvider in Frameworks */,
|
||||||
|
C1F651D2BA69358344927F26 /* MetricsProvider in Frameworks */,
|
||||||
|
C390C5B65769A007A49C3A80 /* NetworkProvider in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -135,6 +139,8 @@
|
|||||||
A3E3949D664131D593CBEEDC /* CalendarProvider */,
|
A3E3949D664131D593CBEEDC /* CalendarProvider */,
|
||||||
D4578EB64F7A3372B49BC50C /* WeatherProvider */,
|
D4578EB64F7A3372B49BC50C /* WeatherProvider */,
|
||||||
349226D70F51196CCD1C94F8 /* MediaProvider */,
|
349226D70F51196CCD1C94F8 /* MediaProvider */,
|
||||||
|
6CACA1C2A58DEFDE46F71499 /* MetricsProvider */,
|
||||||
|
FF4E99C8BF0D8944C314A8AF /* NetworkProvider */,
|
||||||
);
|
);
|
||||||
productName = Onyx;
|
productName = Onyx;
|
||||||
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
|
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
|
||||||
@@ -444,6 +450,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxDesign;
|
productName = OnyxDesign;
|
||||||
};
|
};
|
||||||
|
6CACA1C2A58DEFDE46F71499 /* MetricsProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = MetricsProvider;
|
||||||
|
};
|
||||||
A3E3949D664131D593CBEEDC /* CalendarProvider */ = {
|
A3E3949D664131D593CBEEDC /* CalendarProvider */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = CalendarProvider;
|
productName = CalendarProvider;
|
||||||
@@ -464,6 +474,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxWidgetKit;
|
productName = OnyxWidgetKit;
|
||||||
};
|
};
|
||||||
|
FF4E99C8BF0D8944C314A8AF /* NetworkProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = NetworkProvider;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 32F5E88F3CF35AE8C0E00613 /* Project object */;
|
rootObject = 32F5E88F3CF35AE8C0E00613 /* Project object */;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
import AppKit
|
import AppKit
|
||||||
import OnyxNotch
|
import OnyxNotch
|
||||||
import OnyxWidgetKit
|
import OnyxWidgetKit
|
||||||
|
import OnyxMenuBar
|
||||||
|
|
||||||
/// Der geteilte Zustand von Onyx: was im Panel liegt und auf welchen Displays.
|
/// 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 {
|
private enum Keys {
|
||||||
|
static func menuBarModule(_ id: String) -> String { "onyx.menubar.module.\(id)" }
|
||||||
static let displayPolicy = "onyx.displayPolicy"
|
static let displayPolicy = "onyx.displayPolicy"
|
||||||
static let showsMenuBarIcon = "onyx.showsMenuBarIcon"
|
static let showsMenuBarIcon = "onyx.showsMenuBarIcon"
|
||||||
static let showsDockIcon = "onyx.showsDockIcon"
|
static let showsDockIcon = "onyx.showsDockIcon"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ import OnyxWidgetKit
|
|||||||
import CalendarProvider
|
import CalendarProvider
|
||||||
import WeatherProvider
|
import WeatherProvider
|
||||||
import MediaProvider
|
import MediaProvider
|
||||||
|
import MetricsProvider
|
||||||
|
import NetworkProvider
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct OnyxApp: App {
|
struct OnyxApp: App {
|
||||||
@@ -34,6 +36,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
private var calendarModel: CalendarModel?
|
private var calendarModel: CalendarModel?
|
||||||
private var weatherModel: WeatherModel?
|
private var weatherModel: WeatherModel?
|
||||||
private var mediaModel: MediaModel?
|
private var mediaModel: MediaModel?
|
||||||
|
private var metricsModel: MetricsModel?
|
||||||
|
private var networkModel: NetworkModel?
|
||||||
private let settingsWindow = SettingsWindowController()
|
private let settingsWindow = SettingsWindowController()
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
@@ -76,8 +80,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let menuBar = MenuBarController()
|
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()
|
menuBar.start()
|
||||||
self.menuBar = menuBar
|
self.menuBar = menuBar
|
||||||
|
model.menuBar = menuBar
|
||||||
|
|
||||||
installOnyxStatusItem()
|
installOnyxStatusItem()
|
||||||
observeMenuBarPreference()
|
observeMenuBarPreference()
|
||||||
@@ -136,6 +151,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
self.mediaModel = mediaModel
|
self.mediaModel = mediaModel
|
||||||
WidgetRegistry.shared.register(MediaWidget(model: 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()
|
PlaceholderWidgets.registerAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,18 +44,6 @@ enum PlaceholderWidgets {
|
|||||||
/// damit gespeicherte Layouts weitergelten.
|
/// damit gespeicherte Layouts weitergelten.
|
||||||
static func registerAll() {
|
static func registerAll() {
|
||||||
let widgets: [PlaceholderWidget] = [
|
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",
|
.init(id: "fans", displayName: "Lüfter", symbolName: "fan",
|
||||||
supportedSizes: [.small, .medium], phase: "Phase 6"),
|
supportedSizes: [.small, .medium], phase: "Phase 6"),
|
||||||
.init(id: "audio", displayName: "Audio-Mixer", symbolName: "slider.horizontal.3",
|
.init(id: "audio", displayName: "Audio-Mixer", symbolName: "slider.horizontal.3",
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import OnyxDesign
|
|||||||
import OnyxNotch
|
import OnyxNotch
|
||||||
import OnyxWidgetKit
|
import OnyxWidgetKit
|
||||||
import CalendarProvider
|
import CalendarProvider
|
||||||
|
import OnyxMenuBar
|
||||||
import WeatherProvider
|
import WeatherProvider
|
||||||
|
|
||||||
enum SettingsTab: Hashable {
|
enum SettingsTab: Hashable {
|
||||||
case widgets, display, permissions
|
case widgets, display, menubar, permissions
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@@ -23,6 +24,9 @@ struct SettingsView: View {
|
|||||||
DisplaySettings(model: model)
|
DisplaySettings(model: model)
|
||||||
.tabItem { Label("settings.tab.display", systemImage: "macbook") }
|
.tabItem { Label("settings.tab.display", systemImage: "macbook") }
|
||||||
.tag(SettingsTab.display)
|
.tag(SettingsTab.display)
|
||||||
|
MenuBarSettings(model: model)
|
||||||
|
.tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") }
|
||||||
|
.tag(SettingsTab.menubar)
|
||||||
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
|
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
|
||||||
.tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") }
|
.tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") }
|
||||||
.tag(SettingsTab.permissions)
|
.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
|
// MARK: - Widgets
|
||||||
|
|
||||||
private struct WidgetSettings: View {
|
private struct WidgetSettings: View {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ let package = Package(
|
|||||||
.library(name: "CalendarProvider", targets: ["CalendarProvider"]),
|
.library(name: "CalendarProvider", targets: ["CalendarProvider"]),
|
||||||
.library(name: "WeatherProvider", targets: ["WeatherProvider"]),
|
.library(name: "WeatherProvider", targets: ["WeatherProvider"]),
|
||||||
.library(name: "MediaProvider", targets: ["MediaProvider"]),
|
.library(name: "MediaProvider", targets: ["MediaProvider"]),
|
||||||
|
.library(name: "MetricsProvider", targets: ["MetricsProvider"]),
|
||||||
|
.library(name: "NetworkProvider", targets: ["NetworkProvider"]),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt
|
// Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt
|
||||||
@@ -46,5 +48,13 @@ let package = Package(
|
|||||||
.target(name: "MediaProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
.target(name: "MediaProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
||||||
resources: [.process("Localizable.xcstrings")]),
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
.testTarget(name: "MediaProviderTests", dependencies: ["MediaProvider"]),
|
.testTarget(name: "MediaProviderTests", dependencies: ["MediaProvider"]),
|
||||||
|
|
||||||
|
.target(name: "MetricsProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit", "OnyxMenuBar"],
|
||||||
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
|
.testTarget(name: "MetricsProviderTests", dependencies: ["MetricsProvider"]),
|
||||||
|
|
||||||
|
.target(name: "NetworkProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit", "OnyxMenuBar", "MetricsProvider"],
|
||||||
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
|
.testTarget(name: "NetworkProviderTests", dependencies: ["NetworkProvider"]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
115
Packages/OnyxKit/Sources/MetricsProvider/Calculations.swift
Normal file
115
Packages/OnyxKit/Sources/MetricsProvider/Calculations.swift
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Die CPU-Zeitzähler eines Kerns, wie `host_processor_info` sie liefert.
|
||||||
|
///
|
||||||
|
/// Es sind **Summen seit dem Start**, keine Momentanwerte. Die Auslastung
|
||||||
|
/// ergibt sich erst aus der Differenz zweier Abtastungen.
|
||||||
|
public struct CPUTicks: Equatable, Sendable {
|
||||||
|
public let user: UInt32
|
||||||
|
public let system: UInt32
|
||||||
|
public let idle: UInt32
|
||||||
|
public let nice: UInt32
|
||||||
|
|
||||||
|
public init(user: UInt32, system: UInt32, idle: UInt32, nice: UInt32) {
|
||||||
|
self.user = user
|
||||||
|
self.system = system
|
||||||
|
self.idle = idle
|
||||||
|
self.nice = nice
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auslastung zwischen zwei Abtastungen, 0 bis 1.
|
||||||
|
public static func usage(from previous: CPUTicks, to current: CPUTicks) -> Double {
|
||||||
|
// `&-` statt `-`: die Zähler sind 32 Bit breit und laufen nach gut
|
||||||
|
// 400 Tagen über. Ein gewöhnlicher Abzug stürzt dabei ab oder liefert
|
||||||
|
// einen absurden Sprung; die überlaufende Subtraktion ergibt genau die
|
||||||
|
// richtige Differenz.
|
||||||
|
let user = UInt64(current.user &- previous.user)
|
||||||
|
let system = UInt64(current.system &- previous.system)
|
||||||
|
let nice = UInt64(current.nice &- previous.nice)
|
||||||
|
let idle = UInt64(current.idle &- previous.idle)
|
||||||
|
|
||||||
|
let busy = user + system + nice
|
||||||
|
let total = busy + idle
|
||||||
|
guard total > 0 else { return 0 }
|
||||||
|
|
||||||
|
return min(max(Double(busy) / Double(total), 0), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie angespannt die Speicherlage ist.
|
||||||
|
public enum MemoryPressure: Equatable, Sendable {
|
||||||
|
case normal
|
||||||
|
case warning
|
||||||
|
case critical
|
||||||
|
|
||||||
|
/// - Parameters:
|
||||||
|
/// - free: Anteil wirklich freien Speichers, 0 bis 1.
|
||||||
|
/// - compressed: Anteil komprimierten Speichers, 0 bis 1.
|
||||||
|
/// - swapUsed: belegter Auslagerungsspeicher in Byte.
|
||||||
|
public static func classify(free: Double, compressed: Double, swapUsed: UInt64) -> MemoryPressure {
|
||||||
|
// Auslagerung schlägt alles andere: sobald sie läuft, bremst der
|
||||||
|
// Rechner spürbar — auch wenn die Freispeicheranzeige harmlos aussieht.
|
||||||
|
// Ein bisschen Swap hat macOS fast immer angelegt, deshalb erst ab
|
||||||
|
// einem halben Gigabyte.
|
||||||
|
if swapUsed > 512 * 1024 * 1024 { return .critical }
|
||||||
|
if free < 0.10 || compressed > 0.20 { return .warning }
|
||||||
|
return .normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Netzwerkdurchsatz aus zwei Zählerständen.
|
||||||
|
public enum Throughput {
|
||||||
|
|
||||||
|
/// Byte pro Sekunde zwischen zwei Abtastungen.
|
||||||
|
///
|
||||||
|
/// Die Normierung auf die tatsächlich verstrichene Zeit ist der Kern: die
|
||||||
|
/// Menüleisten-Module tasten je nach Einstellung alle 1 bis 10 Sekunden ab.
|
||||||
|
/// Ohne sie zeigte ein Modul mit 5 Sekunden Intervall das Fünffache an.
|
||||||
|
public static func rate(previous: UInt64, current: UInt64, elapsed: TimeInterval) -> Double {
|
||||||
|
guard elapsed > 0 else { return 0 }
|
||||||
|
|
||||||
|
// Zähler kleiner als vorher heißt: er wurde zurückgesetzt. Das passiert
|
||||||
|
// bei jedem Interface-Wechsel — WLAN aus, VPN an — und beim Neustart
|
||||||
|
// des Dienstes.
|
||||||
|
//
|
||||||
|
// Ein Überlauf wäre die andere denkbare Erklärung, kommt hier aber
|
||||||
|
// nicht vor: `if_data64` liefert 64-Bit-Zähler, die bei 10 Gbit/s
|
||||||
|
// erst nach rund 470 Jahren umlaufen. Der Versuch, beides zu
|
||||||
|
// unterscheiden, produziert nur den Ausreißer, den er verhindern soll —
|
||||||
|
// deshalb null.
|
||||||
|
guard current >= previous else { return 0 }
|
||||||
|
|
||||||
|
return Double(current - previous) / elapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „2,4 MB/s" statt „2400000".
|
||||||
|
public static func formatted(_ bytesPerSecond: Double) -> String {
|
||||||
|
let formatter = ByteCountFormatter()
|
||||||
|
formatter.countStyle = .binary
|
||||||
|
formatter.allowedUnits = [.useKB, .useMB, .useGB]
|
||||||
|
formatter.zeroPadsFractionDigits = false
|
||||||
|
// Ohne das steht bei null „Zero KB/s" statt „0 KB/s" — in einer Spalte
|
||||||
|
// aus Zahlen fällt ein Wort unangenehm auf und ändert die Breite.
|
||||||
|
formatter.allowsNonnumericFormatting = false
|
||||||
|
return formatter.string(fromByteCount: Int64(max(bytesPerSecond, 0))) + "/s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einheitliche Zahlendarstellung für alle Messwerte.
|
||||||
|
public enum MetricFormat {
|
||||||
|
|
||||||
|
/// Ganzzahlig. Nachkommastellen bei der Auslastung täuschen eine
|
||||||
|
/// Genauigkeit vor, die die Messung nicht hat — und sie zappeln im
|
||||||
|
/// Sekundentakt, was die Anzeige unruhig macht.
|
||||||
|
public static func percent(_ fraction: Double) -> String {
|
||||||
|
"\(Int((min(max(fraction, 0), 1) * 100).rounded())) %"
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func temperature(_ celsius: Double) -> String {
|
||||||
|
"\(Int(celsius.rounded()))°"
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func watts(_ value: Double) -> String {
|
||||||
|
String(format: "%.1f W", max(value, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
134
Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings
Normal file
134
Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage": "en",
|
||||||
|
"strings": {
|
||||||
|
"metric.cpu": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "CPU"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "CPU"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metric.gpu": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "GPU"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "GPU"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metric.memory": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Speicher"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Memory"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metric.battery": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Akku"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Battery"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metric.sensors": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Sensoren"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Sensors"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metric.sensors.none": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Keine Sensoren lesbar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "No sensors readable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metric.battery.charging": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Lädt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Charging"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metric.cpu.perCore": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Je Kern"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Per core"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
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))
|
||||||
|
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
|
||||||
|
} onChange: {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, token != nil else { return }
|
||||||
|
view?.update(snapshot: model.snapshot, history: model.series(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] = []
|
||||||
|
|
||||||
|
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]) {
|
||||||
|
self.snapshot = snapshot
|
||||||
|
self.history = history
|
||||||
|
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), 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), 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) {
|
||||||
|
let values = metric == .cpu ? snapshot.cpu.perCore
|
||||||
|
: [MetricSummary.fraction(metric, snapshot)]
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
285
Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift
Normal file
285
Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import OnyxDesign
|
||||||
|
import OnyxWidgetKit
|
||||||
|
|
||||||
|
/// Ein Widget je Hardwaregröße. Alle teilen sich dasselbe Modell und damit
|
||||||
|
/// dieselbe Messschleife.
|
||||||
|
public struct MetricWidget: OnyxWidget {
|
||||||
|
public let id: String
|
||||||
|
public let metric: MetricKind
|
||||||
|
public var displayName: String {
|
||||||
|
String(localized: .init(metric.localizationKey), bundle: .module)
|
||||||
|
}
|
||||||
|
public var symbolName: String { metric.symbolName }
|
||||||
|
public let supportedSizes: [WidgetSize]
|
||||||
|
|
||||||
|
private let model: MetricsModel
|
||||||
|
|
||||||
|
public init(metric: MetricKind, model: MetricsModel) {
|
||||||
|
self.metric = metric
|
||||||
|
self.model = model
|
||||||
|
self.id = switch metric {
|
||||||
|
case .cpu: "cpu"
|
||||||
|
case .gpu: "gpu"
|
||||||
|
case .memory: "memory"
|
||||||
|
case .battery: "battery"
|
||||||
|
case .temperature: "sensors"
|
||||||
|
}
|
||||||
|
self.supportedSizes = metric == .temperature ? [.medium, .large] : [.small, .medium]
|
||||||
|
}
|
||||||
|
|
||||||
|
public func makeView(size: WidgetSize) -> AnyView {
|
||||||
|
AnyView(MetricWidgetView(model: model, metric: metric, size: size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MetricKind {
|
||||||
|
var localizationKey: String {
|
||||||
|
switch self {
|
||||||
|
case .cpu: "metric.cpu"
|
||||||
|
case .gpu: "metric.gpu"
|
||||||
|
case .memory: "metric.memory"
|
||||||
|
case .battery: "metric.battery"
|
||||||
|
case .temperature: "metric.sensors"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MetricWidgetView: View {
|
||||||
|
let model: MetricsModel
|
||||||
|
let metric: MetricKind
|
||||||
|
let size: WidgetSize
|
||||||
|
@State private var token: UUID?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch (metric, size) {
|
||||||
|
case (.temperature, _):
|
||||||
|
SensorList(snapshot: model.snapshot, detailed: size == .large)
|
||||||
|
case (_, .small):
|
||||||
|
CompactMetric(model: model, metric: metric)
|
||||||
|
default:
|
||||||
|
WideMetric(model: model, metric: metric)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Bei offenem Panel schneller messen: dort sieht man die Zahl,
|
||||||
|
// und ein Wert, der nur alle zwei Sekunden springt, wirkt träge.
|
||||||
|
token = model.addConsumer(interval: 1)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
if let token { model.removeConsumer(token) }
|
||||||
|
token = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bausteine
|
||||||
|
|
||||||
|
private struct CompactMetric: View {
|
||||||
|
let model: MetricsModel
|
||||||
|
let metric: MetricKind
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Label {
|
||||||
|
Text(String(localized: .init(metric.localizationKey), bundle: .module))
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: metric.symbolName)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Onyx.Color.accent)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
Text(MetricSummary.value(metric, model.snapshot))
|
||||||
|
.font(Onyx.Font.metric)
|
||||||
|
.foregroundStyle(MetricSummary.color(metric, model.snapshot))
|
||||||
|
.animation(Onyx.Motion.value, value: MetricSummary.value(metric, model.snapshot))
|
||||||
|
|
||||||
|
if let detail = MetricSummary.detail(metric, model.snapshot) {
|
||||||
|
Text(detail)
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WideMetric: View {
|
||||||
|
let model: MetricsModel
|
||||||
|
let metric: MetricKind
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
CompactMetric(model: model, metric: metric)
|
||||||
|
.frame(width: 84)
|
||||||
|
Sparkline(values: model.series(metric),
|
||||||
|
tint: MetricSummary.color(metric, model.snapshot))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verlaufskurve. Bewusst ohne Achsen und Beschriftung: auf 90 × 40 Punkten
|
||||||
|
/// ist die Form die Information, nicht der Zahlenwert.
|
||||||
|
public struct Sparkline: View {
|
||||||
|
let values: [Double]
|
||||||
|
let tint: Color
|
||||||
|
|
||||||
|
public init(values: [Double], tint: Color) {
|
||||||
|
self.values = values
|
||||||
|
self.tint = tint
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let points = Array(values.suffix(MetricsModel.historyLength))
|
||||||
|
if points.count > 1 {
|
||||||
|
let size = geometry.size
|
||||||
|
let step = size.width / CGFloat(points.count - 1)
|
||||||
|
let coordinates = points.enumerated().map { index, value in
|
||||||
|
CGPoint(x: CGFloat(index) * step,
|
||||||
|
y: size.height * (1 - CGFloat(min(max(value, 0), 1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = Path { path in
|
||||||
|
for (index, point) in coordinates.enumerated() {
|
||||||
|
index == 0 ? path.move(to: point) : path.addLine(to: point)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dieselbe Kurve, unten geschlossen — die Fläche darunter gibt
|
||||||
|
// dem Verlauf Gewicht, ohne dass eine zweite Linie nötig wäre.
|
||||||
|
let area = Path { path in
|
||||||
|
path.move(to: CGPoint(x: 0, y: size.height))
|
||||||
|
coordinates.forEach { path.addLine(to: $0) }
|
||||||
|
path.addLine(to: CGPoint(x: size.width, y: size.height))
|
||||||
|
path.closeSubpath()
|
||||||
|
}
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
area.fill(LinearGradient(colors: [tint.opacity(0.22), .clear],
|
||||||
|
startPoint: .top, endPoint: .bottom))
|
||||||
|
line.stroke(tint, style: .init(lineWidth: 1.5, lineJoin: .round))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SensorList: View {
|
||||||
|
let snapshot: MetricsSnapshot
|
||||||
|
let detailed: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
ForEach(snapshot.sensors.prefix(detailed ? 8 : 3)) { sensor in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(sensor.name)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(MetricFormat.temperature(sensor.celsius))
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(MetricSummary.temperatureColor(sensor.celsius))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if detailed, !snapshot.fans.isEmpty {
|
||||||
|
Divider().overlay(Onyx.Color.hairline).padding(.vertical, 2)
|
||||||
|
ForEach(snapshot.fans) { fan in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "fan")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
Text("\(Int(fan.rpm))")
|
||||||
|
.font(Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
Text(verbatim: "U/min")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot.sensors.isEmpty {
|
||||||
|
Text("metric.sensors.none", bundle: .module)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie eine Größe zu Text und Farbe wird — an einer Stelle, damit Panel und
|
||||||
|
/// Menüleiste nicht auseinanderlaufen.
|
||||||
|
public enum MetricSummary {
|
||||||
|
|
||||||
|
public static func value(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> String {
|
||||||
|
switch metric {
|
||||||
|
case .cpu: MetricFormat.percent(snapshot.cpu.total)
|
||||||
|
case .gpu: snapshot.gpu.map(MetricFormat.percent) ?? "—"
|
||||||
|
case .memory: MetricFormat.percent(snapshot.memory.usedFraction)
|
||||||
|
case .battery: snapshot.battery.map { MetricFormat.percent($0.charge) } ?? "—"
|
||||||
|
case .temperature: snapshot.sensors.map(\.celsius).max()
|
||||||
|
.map(MetricFormat.temperature) ?? "—"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func detail(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> String? {
|
||||||
|
switch metric {
|
||||||
|
case .cpu:
|
||||||
|
return snapshot.cpu.watts.map(MetricFormat.watts)
|
||||||
|
case .memory:
|
||||||
|
return ByteCountFormatter.string(fromByteCount: Int64(snapshot.memory.used),
|
||||||
|
countStyle: .binary)
|
||||||
|
case .battery:
|
||||||
|
guard let battery = snapshot.battery else { return nil }
|
||||||
|
if battery.isCharging {
|
||||||
|
return String(localized: "metric.battery.charging", bundle: .module)
|
||||||
|
}
|
||||||
|
// Beim Entladen ist die Leistung negativ; der Betrag ist gemeint.
|
||||||
|
return battery.watts.map { MetricFormat.watts(abs($0)) }
|
||||||
|
case .gpu, .temperature:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func color(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> Color {
|
||||||
|
switch metric {
|
||||||
|
case .memory:
|
||||||
|
switch snapshot.memory.pressure {
|
||||||
|
case .normal: return Onyx.Color.textPrimary
|
||||||
|
case .warning: return Onyx.Color.warning
|
||||||
|
case .critical: return Onyx.Color.critical
|
||||||
|
}
|
||||||
|
case .battery:
|
||||||
|
guard let battery = snapshot.battery else { return Onyx.Color.textPrimary }
|
||||||
|
if battery.isCharging { return Onyx.Color.positive }
|
||||||
|
return battery.charge < 0.2 ? Onyx.Color.critical : Onyx.Color.textPrimary
|
||||||
|
case .temperature:
|
||||||
|
return temperatureColor(snapshot.sensors.map(\.celsius).max() ?? 0)
|
||||||
|
case .cpu, .gpu:
|
||||||
|
return Onyx.Color.textPrimary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func temperatureColor(_ celsius: Double) -> Color {
|
||||||
|
// Grenzwerte für Apple Silicon: unter 70 unauffällig, ab 90 wird
|
||||||
|
// gedrosselt. Dazwischen ist es warm, aber unproblematisch.
|
||||||
|
switch celsius {
|
||||||
|
case ..<70: Onyx.Color.textPrimary
|
||||||
|
case ..<90: Onyx.Color.warning
|
||||||
|
default: Onyx.Color.critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
108
Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift
Normal file
108
Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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() {}
|
||||||
|
|
||||||
|
/// 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] {
|
||||||
|
history.compactMap { snapshot in
|
||||||
|
switch metric {
|
||||||
|
case .cpu: snapshot.cpu.total
|
||||||
|
case .gpu: snapshot.gpu
|
||||||
|
case .memory: snapshot.memory.usedFraction
|
||||||
|
case .battery: snapshot.battery?.charge
|
||||||
|
case .temperature: snapshot.sensors.map(\.celsius).max().map { $0 / 100 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
143
Packages/OnyxKit/Sources/MetricsProvider/SMCReader.swift
Normal file
143
Packages/OnyxKit/Sources/MetricsProvider/SMCReader.swift
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import Foundation
|
||||||
|
import IOKit
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "SMC")
|
||||||
|
|
||||||
|
/// Liest Werte aus dem System Management Controller. **Ausschließlich lesend.**
|
||||||
|
///
|
||||||
|
/// Es gibt hier bewusst keinen Schreibpfad. Lüfterdrehzahl und Ladegrenze
|
||||||
|
/// verlangen root und gehören in den privilegierten Helfer aus Phase 6 — nicht
|
||||||
|
/// in einen Typ, der im normalen App-Prozess läuft und von jedem Widget
|
||||||
|
/// erreichbar ist.
|
||||||
|
///
|
||||||
|
/// Die Struktur und die Erkenntnisse stammen aus `docs/spikes/A-smc.md`, gemessen
|
||||||
|
/// auf Mac17,9. Wichtigster Punkt von dort: **alle Mehrbyte-Werte sind
|
||||||
|
/// little-endian.** Der verbreitete Beispielcode nimmt big-endian an — er
|
||||||
|
/// stammt aus der Intel-Ära und liefert auf Apple Silicon Unsinn.
|
||||||
|
public final class SMCReader: @unchecked Sendable {
|
||||||
|
|
||||||
|
// MARK: - Aufbau des AppleSMC-UserClients
|
||||||
|
|
||||||
|
private struct Version { var major: UInt8 = 0; var minor: UInt8 = 0
|
||||||
|
var build: UInt8 = 0; var reserved: UInt8 = 0
|
||||||
|
var release: UInt16 = 0 }
|
||||||
|
private struct PLimitData { var version: UInt16 = 0; var length: UInt16 = 0
|
||||||
|
var cpuPLimit: UInt32 = 0; var gpuPLimit: UInt32 = 0
|
||||||
|
var memPLimit: UInt32 = 0 }
|
||||||
|
private struct KeyInfo { var dataSize: UInt32 = 0; var dataType: UInt32 = 0
|
||||||
|
var dataAttributes: UInt8 = 0 }
|
||||||
|
|
||||||
|
private struct Param {
|
||||||
|
var key: UInt32 = 0
|
||||||
|
var vers = Version()
|
||||||
|
var pLimitData = PLimitData()
|
||||||
|
var keyInfo = KeyInfo()
|
||||||
|
var padding: UInt16 = 0
|
||||||
|
var result: UInt8 = 0
|
||||||
|
var status: UInt8 = 0
|
||||||
|
var data8: UInt8 = 0
|
||||||
|
var data32: UInt32 = 0
|
||||||
|
var bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
|
||||||
|
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
|
||||||
|
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
|
||||||
|
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) =
|
||||||
|
(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let handleYPCEvent: UInt32 = 2
|
||||||
|
private static let readKey: UInt8 = 5
|
||||||
|
private static let getKeyInfo: UInt8 = 9
|
||||||
|
|
||||||
|
private var connection: io_connect_t = 0
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
public init?() {
|
||||||
|
let service = IOServiceGetMatchingService(kIOMainPortDefault,
|
||||||
|
IOServiceMatching("AppleSMC"))
|
||||||
|
guard service != 0 else {
|
||||||
|
log.error("AppleSMC nicht gefunden — Sensoren bleiben leer")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer { IOObjectRelease(service) }
|
||||||
|
guard IOServiceOpen(service, mach_task_self_, 0, &connection) == kIOReturnSuccess else {
|
||||||
|
log.error("AppleSMC nicht zu öffnen")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { if connection != 0 { IOServiceClose(connection) } }
|
||||||
|
|
||||||
|
// MARK: - Lesen
|
||||||
|
|
||||||
|
/// Ein `flt`-Wert (Lüfterdrehzahl, Temperatur, Leistung).
|
||||||
|
public func float(_ key: String) -> Double? {
|
||||||
|
guard let value = read(key), value.type == "flt ", value.bytes.count >= 4 else { return nil }
|
||||||
|
// Little-endian, siehe Klassenkommentar.
|
||||||
|
let bits = UInt32(value.bytes[3]) << 24 | UInt32(value.bytes[2]) << 16
|
||||||
|
| UInt32(value.bytes[1]) << 8 | UInt32(value.bytes[0])
|
||||||
|
return Double(Float(bitPattern: bits))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein vorzeichenloser Ganzzahlwert beliebiger Breite.
|
||||||
|
public func integer(_ key: String) -> UInt64? {
|
||||||
|
guard let value = read(key) else { return nil }
|
||||||
|
var result: UInt64 = 0
|
||||||
|
for (index, byte) in value.bytes.prefix(8).enumerated() {
|
||||||
|
result |= UInt64(byte) << (8 * index) // little-endian
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
public func exists(_ key: String) -> Bool { read(key) != nil }
|
||||||
|
|
||||||
|
private struct Value { let type: String; let bytes: [UInt8] }
|
||||||
|
|
||||||
|
private func read(_ key: String) -> Value? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
|
||||||
|
var info = Param()
|
||||||
|
info.key = Self.fourCC(key)
|
||||||
|
info.data8 = Self.getKeyInfo
|
||||||
|
guard let described = call(info) else { return nil }
|
||||||
|
|
||||||
|
var command = Param()
|
||||||
|
command.key = Self.fourCC(key)
|
||||||
|
command.keyInfo = described.keyInfo
|
||||||
|
command.data8 = Self.readKey
|
||||||
|
guard let output = call(command) else { return nil }
|
||||||
|
|
||||||
|
let size = Int(min(described.keyInfo.dataSize, 32))
|
||||||
|
let all = withUnsafeBytes(of: output.bytes) { Array($0) }
|
||||||
|
return Value(type: Self.fourCCString(described.keyInfo.dataType),
|
||||||
|
bytes: Array(all.prefix(size)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func call(_ input: Param) -> Param? {
|
||||||
|
var input = input
|
||||||
|
var output = Param()
|
||||||
|
var size = MemoryLayout<Param>.stride
|
||||||
|
|
||||||
|
let result = IOConnectCallStructMethod(connection, Self.handleYPCEvent,
|
||||||
|
&input, MemoryLayout<Param>.stride,
|
||||||
|
&output, &size)
|
||||||
|
// Ein nicht vorhandener Key meldet sich über `result` und ist kein
|
||||||
|
// Fehler: welche Keys es gibt, unterscheidet sich je nach Modell.
|
||||||
|
guard result == kIOReturnSuccess, output.result == 0 else { return nil }
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fourCC(_ string: String) -> UInt32 {
|
||||||
|
var value: UInt32 = 0
|
||||||
|
for character in string.utf8.prefix(4) { value = value << 8 | UInt32(character) }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fourCCString(_ value: UInt32) -> String {
|
||||||
|
let bytes = [UInt8((value >> 24) & 0xFF), UInt8((value >> 16) & 0xFF),
|
||||||
|
UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)]
|
||||||
|
return String(bytes: bytes, encoding: .ascii) ?? "????"
|
||||||
|
}
|
||||||
|
}
|
||||||
294
Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift
Normal file
294
Packages/OnyxKit/Sources/MetricsProvider/SystemMetrics.swift
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import Foundation
|
||||||
|
import IOKit
|
||||||
|
import IOKit.ps
|
||||||
|
import Darwin
|
||||||
|
|
||||||
|
/// Ein vollständiger Messwertsatz.
|
||||||
|
public struct MetricsSnapshot: Equatable, Sendable {
|
||||||
|
public var cpu = CPUReading()
|
||||||
|
public var gpu: Double?
|
||||||
|
public var memory = MemoryReading()
|
||||||
|
public var battery: BatteryReading?
|
||||||
|
public var sensors: [SensorReading] = []
|
||||||
|
public var fans: [FanReading] = []
|
||||||
|
public var asOf = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CPUReading: Equatable, Sendable {
|
||||||
|
/// Gesamtauslastung, 0 bis 1.
|
||||||
|
public var total: Double = 0
|
||||||
|
/// Auslastung je Kern, in der Reihenfolge des Systems.
|
||||||
|
public var perCore: [Double] = []
|
||||||
|
/// Leistungsaufnahme in Watt, falls messbar.
|
||||||
|
public var watts: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MemoryReading: Equatable, Sendable {
|
||||||
|
public var used: UInt64 = 0
|
||||||
|
public var total: UInt64 = 0
|
||||||
|
public var compressed: UInt64 = 0
|
||||||
|
public var swapUsed: UInt64 = 0
|
||||||
|
public var pressure: MemoryPressure = .normal
|
||||||
|
|
||||||
|
public var usedFraction: Double {
|
||||||
|
total > 0 ? Double(used) / Double(total) : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BatteryReading: Equatable, Sendable {
|
||||||
|
public var charge: Double = 0 // 0 bis 1
|
||||||
|
public var isCharging = false
|
||||||
|
public var isPluggedIn = false
|
||||||
|
public var cycleCount: Int?
|
||||||
|
public var health: Double? // 0 bis 1
|
||||||
|
public var watts: Double? // negativ = Entladung
|
||||||
|
public var timeRemaining: TimeInterval?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SensorReading: Equatable, Sendable, Identifiable {
|
||||||
|
public let key: String
|
||||||
|
public let name: String
|
||||||
|
public let celsius: Double
|
||||||
|
public var id: String { key }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct FanReading: Equatable, Sendable, Identifiable {
|
||||||
|
public let index: Int
|
||||||
|
public let rpm: Double
|
||||||
|
public let minimum: Double
|
||||||
|
public let maximum: Double
|
||||||
|
public var id: Int { index }
|
||||||
|
|
||||||
|
/// Anteil zwischen Minimum und Maximum, 0 bis 1.
|
||||||
|
public var fraction: Double {
|
||||||
|
guard maximum > minimum else { return 0 }
|
||||||
|
return min(max((rpm - minimum) / (maximum - minimum), 0), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest die Hardwarewerte des Systems.
|
||||||
|
///
|
||||||
|
/// Bewusst kein Singleton mit Timer: das Abtasten steuert `MetricsModel`, weil
|
||||||
|
/// nur dort bekannt ist, wie viele Konsumenten es gerade gibt und wie schnell
|
||||||
|
/// sie es brauchen.
|
||||||
|
public final class SystemMetrics: @unchecked Sendable {
|
||||||
|
|
||||||
|
private let smc = SMCReader()
|
||||||
|
private var previousCPUTicks: [CPUTicks] = []
|
||||||
|
|
||||||
|
/// Seit dem Start unveränderlich — einmal ermitteln reicht.
|
||||||
|
private static let pageSize: UInt64 = {
|
||||||
|
var size: UInt64 = 0
|
||||||
|
var length = MemoryLayout<UInt64>.size
|
||||||
|
guard sysctlbyname("hw.pagesize", &size, &length, nil, 0) == 0, size > 0 else {
|
||||||
|
return 16384 // Apple Silicon
|
||||||
|
}
|
||||||
|
return size
|
||||||
|
}()
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func sample() -> MetricsSnapshot {
|
||||||
|
var snapshot = MetricsSnapshot()
|
||||||
|
snapshot.cpu = readCPU()
|
||||||
|
snapshot.gpu = readGPU()
|
||||||
|
snapshot.memory = readMemory()
|
||||||
|
snapshot.battery = readBattery()
|
||||||
|
snapshot.sensors = readSensors()
|
||||||
|
snapshot.fans = readFans()
|
||||||
|
snapshot.cpu.watts = smc?.float("PSTR")
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CPU
|
||||||
|
|
||||||
|
private func readCPU() -> CPUReading {
|
||||||
|
var count: natural_t = 0
|
||||||
|
var info: processor_info_array_t?
|
||||||
|
var infoCount: mach_msg_type_number_t = 0
|
||||||
|
|
||||||
|
guard host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO,
|
||||||
|
&count, &info, &infoCount) == KERN_SUCCESS,
|
||||||
|
let info else { return CPUReading() }
|
||||||
|
|
||||||
|
defer {
|
||||||
|
vm_deallocate(mach_task_self_, vm_address_t(bitPattern: info),
|
||||||
|
vm_size_t(infoCount) * vm_size_t(MemoryLayout<integer_t>.stride))
|
||||||
|
}
|
||||||
|
|
||||||
|
var current: [CPUTicks] = []
|
||||||
|
current.reserveCapacity(Int(count))
|
||||||
|
for core in 0..<Int(count) {
|
||||||
|
let base = core * Int(CPU_STATE_MAX)
|
||||||
|
current.append(CPUTicks(
|
||||||
|
user: UInt32(bitPattern: info[base + Int(CPU_STATE_USER)]),
|
||||||
|
system: UInt32(bitPattern: info[base + Int(CPU_STATE_SYSTEM)]),
|
||||||
|
idle: UInt32(bitPattern: info[base + Int(CPU_STATE_IDLE)]),
|
||||||
|
nice: UInt32(bitPattern: info[base + Int(CPU_STATE_NICE)])))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die erste Abtastung hat keinen Vorgänger und kann deshalb keine
|
||||||
|
// Auslastung ergeben. Null zu zeigen ist ehrlicher als der Anteil seit
|
||||||
|
// dem Systemstart, den man sonst bekäme — der liegt immer bei ein paar
|
||||||
|
// Prozent und sieht aus wie ein Messwert.
|
||||||
|
defer { previousCPUTicks = current }
|
||||||
|
guard previousCPUTicks.count == current.count else { return CPUReading() }
|
||||||
|
|
||||||
|
let perCore = zip(previousCPUTicks, current).map(CPUTicks.usage(from:to:))
|
||||||
|
return CPUReading(total: perCore.isEmpty ? 0 : perCore.reduce(0, +) / Double(perCore.count),
|
||||||
|
perCore: perCore,
|
||||||
|
watts: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - GPU
|
||||||
|
|
||||||
|
private func readGPU() -> Double? {
|
||||||
|
var iterator: io_iterator_t = 0
|
||||||
|
guard IOServiceGetMatchingServices(kIOMainPortDefault,
|
||||||
|
IOServiceMatching("IOAccelerator"),
|
||||||
|
&iterator) == kIOReturnSuccess else { return nil }
|
||||||
|
defer { IOObjectRelease(iterator) }
|
||||||
|
|
||||||
|
while case let entry = IOIteratorNext(iterator), entry != 0 {
|
||||||
|
defer { IOObjectRelease(entry) }
|
||||||
|
|
||||||
|
var unmanaged: Unmanaged<CFMutableDictionary>?
|
||||||
|
guard IORegistryEntryCreateCFProperties(entry, &unmanaged, kCFAllocatorDefault, 0)
|
||||||
|
== kIOReturnSuccess,
|
||||||
|
let properties = unmanaged?.takeRetainedValue() as? [String: Any],
|
||||||
|
let statistics = properties["PerformanceStatistics"] as? [String: Any]
|
||||||
|
else { continue }
|
||||||
|
|
||||||
|
// Der Schlüssel heißt je nach Gerät unterschiedlich; auf Apple
|
||||||
|
// Silicon ist es „Device Utilization %".
|
||||||
|
for name in ["Device Utilization %", "GPU Activity(%)", "Renderer Utilization %"] {
|
||||||
|
if let value = statistics[name] as? Int { return Double(value) / 100 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Arbeitsspeicher
|
||||||
|
|
||||||
|
private func readMemory() -> MemoryReading {
|
||||||
|
var statistics = vm_statistics64_data_t()
|
||||||
|
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64_data_t>.size
|
||||||
|
/ MemoryLayout<integer_t>.size)
|
||||||
|
let result = withUnsafeMutablePointer(to: &statistics) {
|
||||||
|
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
||||||
|
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard result == KERN_SUCCESS else { return MemoryReading() }
|
||||||
|
|
||||||
|
// `vm_kernel_page_size` ist eine globale Variable und damit unter
|
||||||
|
// strenger Nebenläufigkeitsprüfung tabu. Der Wert steht seit dem Start
|
||||||
|
// fest, also einmal über sysctl holen.
|
||||||
|
let pageSize = Self.pageSize
|
||||||
|
let total = ProcessInfo.processInfo.physicalMemory
|
||||||
|
let compressed = UInt64(statistics.compressor_page_count) * pageSize
|
||||||
|
|
||||||
|
// „Benutzt" wie im Aktivitätsmonitor: aktiv, verdrahtet und komprimiert.
|
||||||
|
// Der Dateicache (`external_page_count`) zählt nicht mit — er wird bei
|
||||||
|
// Bedarf freigegeben, und ihn mitzuzählen ließe jeden Mac dauernd voll
|
||||||
|
// aussehen.
|
||||||
|
let used = UInt64(statistics.active_count) * pageSize
|
||||||
|
+ UInt64(statistics.wire_count) * pageSize
|
||||||
|
+ compressed
|
||||||
|
|
||||||
|
var swap = xsw_usage()
|
||||||
|
var swapSize = MemoryLayout<xsw_usage>.size
|
||||||
|
sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0)
|
||||||
|
|
||||||
|
let free = total > 0 ? 1 - Double(used) / Double(total) : 0
|
||||||
|
return MemoryReading(used: used, total: total, compressed: compressed,
|
||||||
|
swapUsed: swap.xsu_used,
|
||||||
|
pressure: MemoryPressure.classify(
|
||||||
|
free: free,
|
||||||
|
compressed: total > 0 ? Double(compressed) / Double(total) : 0,
|
||||||
|
swapUsed: swap.xsu_used))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Akku
|
||||||
|
|
||||||
|
private func readBattery() -> BatteryReading? {
|
||||||
|
guard let blob = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
|
||||||
|
let sources = IOPSCopyPowerSourcesList(blob)?.takeRetainedValue() as? [CFTypeRef],
|
||||||
|
let source = sources.first,
|
||||||
|
let description = IOPSGetPowerSourceDescription(blob, source)?
|
||||||
|
.takeUnretainedValue() as? [String: Any] else { return nil }
|
||||||
|
|
||||||
|
var reading = BatteryReading()
|
||||||
|
if let current = description[kIOPSCurrentCapacityKey] as? Int,
|
||||||
|
let maximum = description[kIOPSMaxCapacityKey] as? Int, maximum > 0 {
|
||||||
|
reading.charge = Double(current) / Double(maximum)
|
||||||
|
}
|
||||||
|
reading.isCharging = description[kIOPSIsChargingKey] as? Bool ?? false
|
||||||
|
reading.isPluggedIn = (description[kIOPSPowerSourceStateKey] as? String)
|
||||||
|
== kIOPSACPowerValue
|
||||||
|
|
||||||
|
if let seconds = description[kIOPSTimeToEmptyKey] as? Int, seconds > 0 {
|
||||||
|
reading.timeRemaining = TimeInterval(seconds * 60)
|
||||||
|
}
|
||||||
|
if let seconds = description[kIOPSTimeToFullChargeKey] as? Int, seconds > 0 {
|
||||||
|
reading.timeRemaining = TimeInterval(seconds * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zyklen und Gesundheit stehen nicht in der Power-Source-Beschreibung,
|
||||||
|
// sondern in der Batterie selbst.
|
||||||
|
let service = IOServiceGetMatchingService(kIOMainPortDefault,
|
||||||
|
IOServiceMatching("AppleSmartBattery"))
|
||||||
|
if service != 0 {
|
||||||
|
defer { IOObjectRelease(service) }
|
||||||
|
var unmanaged: Unmanaged<CFMutableDictionary>?
|
||||||
|
if IORegistryEntryCreateCFProperties(service, &unmanaged, kCFAllocatorDefault, 0)
|
||||||
|
== kIOReturnSuccess,
|
||||||
|
let properties = unmanaged?.takeRetainedValue() as? [String: Any] {
|
||||||
|
reading.cycleCount = properties["CycleCount"] as? Int
|
||||||
|
if let maximum = properties["MaxCapacity"] as? Int, maximum > 0 {
|
||||||
|
reading.health = Double(maximum) / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leistung aus dem SMC: positiv beim Laden, negativ beim Entladen.
|
||||||
|
if let watts = smc?.float("PPBR") {
|
||||||
|
reading.watts = reading.isCharging ? watts : -watts
|
||||||
|
}
|
||||||
|
return reading
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sensoren und Lüfter
|
||||||
|
|
||||||
|
/// Die Temperaturfühler, die auf `Mac17,9` tatsächlich existieren.
|
||||||
|
///
|
||||||
|
/// Bewusst eine kurze Liste statt aller 3486 SMC-Keys: die meisten sind
|
||||||
|
/// unbenannt, und eine Wand aus Kürzeln ist keine Information. Was fehlt,
|
||||||
|
/// fällt beim Lesen einfach weg.
|
||||||
|
private static let temperatureKeys: [(String, String)] = [
|
||||||
|
("Ts0P", "Gehäuse vorn"),
|
||||||
|
("Ts1P", "Gehäuse hinten"),
|
||||||
|
("TB0T", "Akku"),
|
||||||
|
("TW0P", "WLAN"),
|
||||||
|
("TH0x", "SSD"),
|
||||||
|
]
|
||||||
|
|
||||||
|
private func readSensors() -> [SensorReading] {
|
||||||
|
guard let smc else { return [] }
|
||||||
|
return Self.temperatureKeys.compactMap { key, name in
|
||||||
|
guard let celsius = smc.float(key), celsius > 0, celsius < 150 else { return nil }
|
||||||
|
return SensorReading(key: key, name: name, celsius: celsius)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readFans() -> [FanReading] {
|
||||||
|
guard let smc, let count = smc.integer("FNum"), count > 0 else { return [] }
|
||||||
|
return (0..<Int(count)).compactMap { index in
|
||||||
|
guard let rpm = smc.float("F\(index)Ac") else { return nil }
|
||||||
|
return FanReading(index: index,
|
||||||
|
rpm: rpm,
|
||||||
|
minimum: smc.float("F\(index)Mn") ?? 0,
|
||||||
|
maximum: smc.float("F\(index)Mx") ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage" : "en",
|
||||||
|
"strings" : {
|
||||||
|
"network.name" : { "localizations" : {
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Netzwerk" } },
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Network" } } } },
|
||||||
|
"network.wifi.connected" : {
|
||||||
|
"comment" : "Wenn der Netzwerkname mangels Ortungsberechtigung fehlt",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "WLAN verbunden" } },
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Wi-Fi connected" } } } },
|
||||||
|
"network.offline" : { "localizations" : {
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Nicht verbunden" } },
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Not connected" } } } },
|
||||||
|
"network.field.interface" : { "localizations" : {
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Schnittstelle" } },
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Interface" } } } },
|
||||||
|
"network.field.ipv4" : { "localizations" : {
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "IP" } },
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "IP" } } } },
|
||||||
|
"network.field.router" : { "localizations" : {
|
||||||
|
"de" : { "stringUnit" : { "state" : "translated", "value" : "Router" } },
|
||||||
|
"en" : { "stringUnit" : { "state" : "translated", "value" : "Router" } } } }
|
||||||
|
},
|
||||||
|
"version" : "1.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
import OnyxMenuBar
|
||||||
|
import MetricsProvider
|
||||||
|
|
||||||
|
/// Das Netzwerk in der Menüleiste.
|
||||||
|
///
|
||||||
|
/// Anders als die Hardwaregrößen hat es **zwei** Werte, die beide interessieren.
|
||||||
|
/// Sie untereinander zu setzen ist der einzige Weg, beide in der Höhe einer
|
||||||
|
/// Menüleiste unterzubringen — nebeneinander bräuchte es die doppelte Breite,
|
||||||
|
/// und die hat man dort nicht.
|
||||||
|
@MainActor
|
||||||
|
public final class NetworkMenuBarModule: MenuBarModule {
|
||||||
|
|
||||||
|
public let id = "network"
|
||||||
|
public var displayName: String { String(localized: "network.name", bundle: .module) }
|
||||||
|
|
||||||
|
private let model: NetworkModel
|
||||||
|
private var token: UUID?
|
||||||
|
private var view: NetworkStatusView?
|
||||||
|
|
||||||
|
public init(model: NetworkModel) { self.model = model }
|
||||||
|
|
||||||
|
public func makeStatusView(presentation: MenuBarPresentation) -> NSView {
|
||||||
|
let view = NetworkStatusView(presentation: presentation)
|
||||||
|
view.update(model.snapshot, history: model.series(download: true))
|
||||||
|
self.view = view
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
public func makePopoverView() -> AnyView {
|
||||||
|
AnyView(NetworkPopover(model: model))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
view = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startObserving() {
|
||||||
|
withObservationTracking {
|
||||||
|
_ = model.snapshot
|
||||||
|
} onChange: {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, token != nil else { return }
|
||||||
|
view?.update(model.snapshot, history: model.series(download: true))
|
||||||
|
startObserving()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class NetworkStatusView: NSView {
|
||||||
|
|
||||||
|
private let presentation: MenuBarPresentation
|
||||||
|
private var snapshot = NetworkSnapshot()
|
||||||
|
private var history: [Double] = []
|
||||||
|
|
||||||
|
init(presentation: MenuBarPresentation) {
|
||||||
|
self.presentation = presentation
|
||||||
|
super.init(frame: NSRect(x: 0, y: 0, width: Self.width(presentation), height: 22))
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
/// Breiter als die Hardwaremodule: „↓ 2,4 MB/s" braucht schlicht mehr Platz
|
||||||
|
/// als „46 %".
|
||||||
|
static func width(_ presentation: MenuBarPresentation) -> CGFloat {
|
||||||
|
switch presentation {
|
||||||
|
case .symbol: 22
|
||||||
|
case .graph: 34
|
||||||
|
case .bars: 30
|
||||||
|
case .value, .valueAndGraph: 76
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override var intrinsicContentSize: NSSize {
|
||||||
|
NSSize(width: Self.width(presentation), height: 22)
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(_ snapshot: NetworkSnapshot, history: [Double]) {
|
||||||
|
self.snapshot = snapshot
|
||||||
|
self.history = history
|
||||||
|
needsDisplay = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
|
let color = NSColor.labelColor
|
||||||
|
|
||||||
|
switch presentation {
|
||||||
|
case .symbol:
|
||||||
|
guard let image = NSImage(systemSymbolName: snapshot.interfaceKind.symbolName,
|
||||||
|
accessibilityDescription: nil) else { return }
|
||||||
|
image.isTemplate = true
|
||||||
|
color.set()
|
||||||
|
image.draw(in: NSRect(x: bounds.midX - 7.5, y: bounds.midY - 7.5,
|
||||||
|
width: 15, height: 15))
|
||||||
|
|
||||||
|
case .graph, .bars:
|
||||||
|
drawGraph(color: color, in: bounds.insetBy(dx: 2, dy: 5))
|
||||||
|
|
||||||
|
case .value, .valueAndGraph:
|
||||||
|
drawRates(color: color,
|
||||||
|
in: presentation == .value ? bounds
|
||||||
|
: NSRect(x: 0, y: 0, width: bounds.width - 26, height: bounds.height))
|
||||||
|
if presentation == .valueAndGraph {
|
||||||
|
drawGraph(color: color,
|
||||||
|
in: NSRect(x: bounds.width - 24, y: 5, width: 22, height: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zwei Zeilen à 9 pt — die einzige Art, Hoch und Runter in 22 pt Höhe
|
||||||
|
/// unterzubringen.
|
||||||
|
private func drawRates(color: NSColor, in rect: NSRect) {
|
||||||
|
let attributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular),
|
||||||
|
.foregroundColor: color,
|
||||||
|
]
|
||||||
|
let down = NSAttributedString(string: "↓ " + Throughput.formatted(snapshot.downloadRate),
|
||||||
|
attributes: attributes)
|
||||||
|
let up = NSAttributedString(string: "↑ " + Throughput.formatted(snapshot.uploadRate),
|
||||||
|
attributes: attributes)
|
||||||
|
down.draw(at: NSPoint(x: rect.minX + 2, y: rect.midY - 0.5))
|
||||||
|
up.draw(at: NSPoint(x: rect.minX + 2, y: rect.midY - 10.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NetworkPopover: View {
|
||||||
|
let model: NetworkModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Label(model.snapshot.ssid
|
||||||
|
?? model.snapshot.interfaceName
|
||||||
|
?? String(localized: "network.offline", bundle: .module),
|
||||||
|
systemImage: model.snapshot.interfaceKind.symbolName)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
rate("↓", Throughput.formatted(model.snapshot.downloadRate))
|
||||||
|
rate("↑", Throughput.formatted(model.snapshot.uploadRate))
|
||||||
|
}
|
||||||
|
|
||||||
|
Sparkline(values: model.series(download: true), tint: .accentColor)
|
||||||
|
.frame(width: 220, height: 40)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
field("network.field.interface", model.snapshot.interfaceName)
|
||||||
|
field("network.field.ipv4", model.snapshot.ipv4)
|
||||||
|
field("network.field.router", model.snapshot.router)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(width: 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rate(_ arrow: String, _ value: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(arrow).font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text(value).font(.body.monospacedDigit())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func field(_ label: LocalizedStringKey, _ value: String?) -> some View {
|
||||||
|
if let value {
|
||||||
|
HStack {
|
||||||
|
Text(label, bundle: .module).font(.callout).foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(value).font(.callout.monospacedDigit()).textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
214
Packages/OnyxKit/Sources/NetworkProvider/NetworkMetrics.swift
Normal file
214
Packages/OnyxKit/Sources/NetworkProvider/NetworkMetrics.swift
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import Foundation
|
||||||
|
import Darwin
|
||||||
|
import SystemConfiguration
|
||||||
|
import CoreWLAN
|
||||||
|
import MetricsProvider
|
||||||
|
|
||||||
|
public struct NetworkSnapshot: Equatable, Sendable {
|
||||||
|
/// Byte pro Sekunde.
|
||||||
|
public var downloadRate: Double = 0
|
||||||
|
public var uploadRate: Double = 0
|
||||||
|
/// Summen seit dem Systemstart.
|
||||||
|
public var totalReceived: UInt64 = 0
|
||||||
|
public var totalSent: UInt64 = 0
|
||||||
|
|
||||||
|
public var interfaceName: String?
|
||||||
|
public var interfaceKind: InterfaceKind = .unknown
|
||||||
|
public var ipv4: String?
|
||||||
|
public var ipv6: String?
|
||||||
|
public var router: String?
|
||||||
|
|
||||||
|
/// `nil`, solange die Ortungsberechtigung fehlt — macOS gibt den Namen
|
||||||
|
/// sonst nicht heraus. Siehe `wifiName`.
|
||||||
|
public var ssid: String?
|
||||||
|
public var isWiFiConnected = false
|
||||||
|
|
||||||
|
public var asOf = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum InterfaceKind: String, Equatable, Sendable {
|
||||||
|
case wifi, ethernet, vpn, cellular, loopback, unknown
|
||||||
|
|
||||||
|
public var symbolName: String {
|
||||||
|
switch self {
|
||||||
|
case .wifi: "wifi"
|
||||||
|
case .ethernet: "cable.connector"
|
||||||
|
case .vpn: "lock.shield"
|
||||||
|
case .cellular: "antenna.radiowaves.left.and.right"
|
||||||
|
case .loopback, .unknown: "network"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest Durchsatz, Adressen und Verbindungsart.
|
||||||
|
///
|
||||||
|
/// Alles ohne root und ohne private Schnittstellen. Der Durchsatz kommt aus den
|
||||||
|
/// Byte-Zählern des Kernels; sie sind Summen seit dem Start, die Rate ergibt
|
||||||
|
/// sich erst aus der Differenz — siehe `Throughput.rate`.
|
||||||
|
public final class NetworkMetrics: @unchecked Sendable {
|
||||||
|
|
||||||
|
private var previousReceived: UInt64 = 0
|
||||||
|
private var previousSent: UInt64 = 0
|
||||||
|
private var previousSampleTime: Date?
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func sample() -> NetworkSnapshot {
|
||||||
|
var snapshot = NetworkSnapshot()
|
||||||
|
|
||||||
|
let counters = readCounters()
|
||||||
|
snapshot.totalReceived = counters.received
|
||||||
|
snapshot.totalSent = counters.sent
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
if let previous = previousSampleTime {
|
||||||
|
let elapsed = now.timeIntervalSince(previous)
|
||||||
|
snapshot.downloadRate = Throughput.rate(previous: previousReceived,
|
||||||
|
current: counters.received, elapsed: elapsed)
|
||||||
|
snapshot.uploadRate = Throughput.rate(previous: previousSent,
|
||||||
|
current: counters.sent, elapsed: elapsed)
|
||||||
|
}
|
||||||
|
previousReceived = counters.received
|
||||||
|
previousSent = counters.sent
|
||||||
|
previousSampleTime = now
|
||||||
|
|
||||||
|
let primary = primaryInterface()
|
||||||
|
snapshot.interfaceName = primary.name
|
||||||
|
snapshot.interfaceKind = primary.kind
|
||||||
|
snapshot.router = primary.router
|
||||||
|
|
||||||
|
let addresses = localAddresses(for: primary.name)
|
||||||
|
snapshot.ipv4 = addresses.ipv4
|
||||||
|
snapshot.ipv6 = addresses.ipv6
|
||||||
|
|
||||||
|
snapshot.isWiFiConnected = primary.kind == .wifi
|
||||||
|
snapshot.ssid = wifiName()
|
||||||
|
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Durchsatz
|
||||||
|
|
||||||
|
/// Summiert alle Interfaces außer Loopback.
|
||||||
|
///
|
||||||
|
/// Einzeln zu zählen wäre genauer, aber praktisch nutzlos: bei einem
|
||||||
|
/// Wechsel von WLAN auf VPN wandert der Verkehr auf ein anderes Interface,
|
||||||
|
/// und eine Anzeige, die dabei auf null fällt, ist falsch.
|
||||||
|
private func readCounters() -> (received: UInt64, sent: UInt64) {
|
||||||
|
var mib: [Int32] = [CTL_NET, PF_ROUTE, 0, 0, NET_RT_IFLIST2, 0]
|
||||||
|
var length = 0
|
||||||
|
guard sysctl(&mib, 6, nil, &length, nil, 0) == 0, length > 0 else { return (0, 0) }
|
||||||
|
|
||||||
|
var buffer = [UInt8](repeating: 0, count: length)
|
||||||
|
guard sysctl(&mib, 6, &buffer, &length, nil, 0) == 0 else { return (0, 0) }
|
||||||
|
|
||||||
|
var received: UInt64 = 0
|
||||||
|
var sent: UInt64 = 0
|
||||||
|
|
||||||
|
buffer.withUnsafeBytes { raw in
|
||||||
|
var offset = 0
|
||||||
|
while offset < length {
|
||||||
|
let header = raw.baseAddress!.advanced(by: offset)
|
||||||
|
.assumingMemoryBound(to: if_msghdr.self).pointee
|
||||||
|
guard header.ifm_msglen > 0 else { break }
|
||||||
|
defer { offset += Int(header.ifm_msglen) }
|
||||||
|
|
||||||
|
guard header.ifm_type == RTM_IFINFO2 else { continue }
|
||||||
|
let message = raw.baseAddress!.advanced(by: offset)
|
||||||
|
.assumingMemoryBound(to: if_msghdr2.self).pointee
|
||||||
|
|
||||||
|
// Loopback ausschließen: der lokale Verkehr zwischen Programmen
|
||||||
|
// hat mit „was geht über die Leitung" nichts zu tun und kann
|
||||||
|
// ein Vielfaches davon sein.
|
||||||
|
guard message.ifm_data.ifi_type != UInt8(IFT_LOOP) else { continue }
|
||||||
|
|
||||||
|
received += message.ifm_data.ifi_ibytes
|
||||||
|
sent += message.ifm_data.ifi_obytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (received, sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Interface und Adressen
|
||||||
|
|
||||||
|
private struct Primary {
|
||||||
|
var name: String?
|
||||||
|
var kind: InterfaceKind = .unknown
|
||||||
|
var router: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private func primaryInterface() -> Primary {
|
||||||
|
var primary = Primary()
|
||||||
|
guard let store = SCDynamicStoreCreate(nil, "Onyx" as CFString, nil, nil),
|
||||||
|
let global = SCDynamicStoreCopyValue(store, "State:/Network/Global/IPv4" as CFString)
|
||||||
|
as? [String: Any] else { return primary }
|
||||||
|
|
||||||
|
primary.name = global["PrimaryInterface"] as? String
|
||||||
|
primary.router = global["Router"] as? String
|
||||||
|
primary.kind = kind(of: primary.name)
|
||||||
|
return primary
|
||||||
|
}
|
||||||
|
|
||||||
|
private func kind(of name: String?) -> InterfaceKind {
|
||||||
|
guard let name else { return .unknown }
|
||||||
|
// Die Namensschemata sind stabil: en0/en1 sind Ethernet oder WLAN,
|
||||||
|
// utun/ipsec/ppp gehören zu VPNs, lo0 ist die Loopback-Schnittstelle.
|
||||||
|
if name.hasPrefix("utun") || name.hasPrefix("ipsec") || name.hasPrefix("ppp") { return .vpn }
|
||||||
|
if name.hasPrefix("lo") { return .loopback }
|
||||||
|
if name.hasPrefix("pdp_ip") { return .cellular }
|
||||||
|
if name.hasPrefix("en") {
|
||||||
|
// en0 ist auf Notebooks das WLAN, aber nicht zwingend — CoreWLAN
|
||||||
|
// weiß es genau.
|
||||||
|
let wifiNames = CWWiFiClient.interfaceNames() ?? []
|
||||||
|
return wifiNames.contains(name) ? .wifi : .ethernet
|
||||||
|
}
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
private func localAddresses(for interface: String?) -> (ipv4: String?, ipv6: String?) {
|
||||||
|
guard let interface else { return (nil, nil) }
|
||||||
|
|
||||||
|
var pointer: UnsafeMutablePointer<ifaddrs>?
|
||||||
|
guard getifaddrs(&pointer) == 0, let first = pointer else { return (nil, nil) }
|
||||||
|
defer { freeifaddrs(pointer) }
|
||||||
|
|
||||||
|
var ipv4: String?
|
||||||
|
var ipv6: String?
|
||||||
|
|
||||||
|
for entry in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||||
|
guard String(cString: entry.pointee.ifa_name) == interface,
|
||||||
|
let address = entry.pointee.ifa_addr else { continue }
|
||||||
|
|
||||||
|
var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||||
|
guard getnameinfo(address, socklen_t(address.pointee.sa_len),
|
||||||
|
&host, socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0
|
||||||
|
else { continue }
|
||||||
|
|
||||||
|
let text = String(cString: host)
|
||||||
|
switch Int32(address.pointee.sa_family) {
|
||||||
|
case AF_INET where ipv4 == nil:
|
||||||
|
ipv4 = text
|
||||||
|
case AF_INET6 where ipv6 == nil:
|
||||||
|
// Link-lokale Adressen (fe80::) tragen einen Zonen-Anhang und
|
||||||
|
// sagen dem Nutzer nichts.
|
||||||
|
if !text.hasPrefix("fe80") { ipv6 = text }
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (ipv4, ipv6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WLAN
|
||||||
|
|
||||||
|
/// Der Netzwerkname — oder `nil`, wenn macOS ihn nicht herausgibt.
|
||||||
|
///
|
||||||
|
/// Seit macOS 14 liefert `ssid()` nur mit erteilter Ortungsberechtigung
|
||||||
|
/// einen Wert. Fehlt sie, kommt `nil` zurück, **ohne** dass ein Fehler
|
||||||
|
/// erscheint. Deshalb wird hier nicht geraten: die Oberfläche zeigt dann
|
||||||
|
/// „WLAN verbunden" statt eines leeren Feldes, das nach einem Defekt
|
||||||
|
/// aussieht.
|
||||||
|
private func wifiName() -> String? {
|
||||||
|
CWWiFiClient.shared().interface()?.ssid()
|
||||||
|
}
|
||||||
|
}
|
||||||
193
Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift
Normal file
193
Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import OnyxDesign
|
||||||
|
import OnyxWidgetKit
|
||||||
|
import MetricsProvider
|
||||||
|
|
||||||
|
/// Wie `MetricsModel`, aber fürs Netz: eine Messschleife für Panel und
|
||||||
|
/// Menüleiste, mit Referenzzählung.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class NetworkModel {
|
||||||
|
|
||||||
|
public private(set) var snapshot = NetworkSnapshot()
|
||||||
|
public private(set) var history: [NetworkSnapshot] = []
|
||||||
|
|
||||||
|
public static let historyLength = 60
|
||||||
|
|
||||||
|
private let source = NetworkMetrics()
|
||||||
|
private var timer: Timer?
|
||||||
|
private var demands: [UUID: TimeInterval] = [:]
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
@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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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() }
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verläufe, auf den bisherigen Höchstwert normiert.
|
||||||
|
///
|
||||||
|
/// Eine feste Obergrenze wäre unbrauchbar: bei 100 Mbit/s wäre eine
|
||||||
|
/// Video-Wiedergabe ein flacher Strich, und bei 1 Mbit/s ginge jeder
|
||||||
|
/// Download über den Rand. Die Kurve zeigt deshalb den Verlauf relativ zum
|
||||||
|
/// bisherigen Maximum — die absoluten Zahlen stehen daneben.
|
||||||
|
public func series(download: Bool) -> [Double] {
|
||||||
|
let values = history.map { download ? $0.downloadRate : $0.uploadRate }
|
||||||
|
guard let peak = values.max(), peak > 0 else { return values.map { _ in 0 } }
|
||||||
|
return values.map { $0 / peak }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget
|
||||||
|
|
||||||
|
public struct NetworkWidget: OnyxWidget {
|
||||||
|
public let id = "network"
|
||||||
|
public var displayName: String { String(localized: "network.name", bundle: .module) }
|
||||||
|
public let symbolName = "network"
|
||||||
|
public let supportedSizes: [WidgetSize] = [.small, .medium, .large]
|
||||||
|
|
||||||
|
private let model: NetworkModel
|
||||||
|
|
||||||
|
public init(model: NetworkModel) { self.model = model }
|
||||||
|
|
||||||
|
public func makeView(size: WidgetSize) -> AnyView {
|
||||||
|
AnyView(NetworkWidgetView(model: model, size: size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NetworkWidgetView: View {
|
||||||
|
let model: NetworkModel
|
||||||
|
let size: WidgetSize
|
||||||
|
@State private var token: UUID?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: model.snapshot.interfaceKind.symbolName)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Onyx.Color.accent)
|
||||||
|
Text(connectionName)
|
||||||
|
.font(Onyx.Font.caption)
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Rates(snapshot: model.snapshot, compact: size == .small)
|
||||||
|
|
||||||
|
if size != .small {
|
||||||
|
Sparkline(values: model.series(download: true), tint: Onyx.Color.accent)
|
||||||
|
.frame(height: 18)
|
||||||
|
}
|
||||||
|
|
||||||
|
if size == .large {
|
||||||
|
Divider().overlay(Onyx.Color.hairline)
|
||||||
|
Details(snapshot: model.snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.onAppear { token = model.addConsumer(interval: 1) }
|
||||||
|
.onDisappear {
|
||||||
|
if let token { model.removeConsumer(token) }
|
||||||
|
token = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Netzwerkname, wenn macOS ihn herausgibt — sonst die Verbindungsart.
|
||||||
|
///
|
||||||
|
/// Seit macOS 14 liefert die SSID nur mit Ortungsberechtigung einen Wert.
|
||||||
|
/// Fehlt sie, wäre ein leeres Feld eine Falschaussage: verbunden ist man ja.
|
||||||
|
private var connectionName: String {
|
||||||
|
if let ssid = model.snapshot.ssid { return ssid }
|
||||||
|
if model.snapshot.isWiFiConnected {
|
||||||
|
return String(localized: "network.wifi.connected", bundle: .module)
|
||||||
|
}
|
||||||
|
return model.snapshot.interfaceName ?? String(localized: "network.offline", bundle: .module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Rates: View {
|
||||||
|
let snapshot: NetworkSnapshot
|
||||||
|
let compact: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
row("arrow.down", Throughput.formatted(snapshot.downloadRate), Onyx.Color.accent)
|
||||||
|
row("arrow.up", Throughput.formatted(snapshot.uploadRate), Onyx.Color.positive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ symbol: String, _ text: String, _ tint: Color) -> some View {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
.font(.system(size: 8, weight: .bold))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
Text(text)
|
||||||
|
.font(compact ? Onyx.Font.metricSmall : Onyx.Font.metricSmall)
|
||||||
|
.foregroundStyle(Onyx.Color.textPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Details: View {
|
||||||
|
let snapshot: NetworkSnapshot
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
field("network.field.interface", snapshot.interfaceName)
|
||||||
|
field("network.field.ipv4", snapshot.ipv4)
|
||||||
|
field("network.field.router", snapshot.router)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func field(_ label: LocalizedStringKey, _ value: String?) -> some View {
|
||||||
|
if let value {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(label, bundle: .module)
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import MetricsProvider
|
||||||
|
|
||||||
|
// Alle Messwerte hier sind Differenzen zwischen zwei Abtastungen. Das klingt
|
||||||
|
// harmlos und ist die Stelle, an der Systemmonitore falsche Zahlen anzeigen:
|
||||||
|
// die Zähler sind 32 Bit breit und laufen über, Intervalle sind nie exakt
|
||||||
|
// gleich lang, und beim Interface-Wechsel fangen sie wieder bei null an.
|
||||||
|
|
||||||
|
@Suite("CPU-Auslastung")
|
||||||
|
struct CPUUsageTests {
|
||||||
|
|
||||||
|
@Test("Halb beschäftigt, halb untätig ergibt 50 Prozent")
|
||||||
|
func halfBusy() {
|
||||||
|
let before = CPUTicks(user: 100, system: 0, idle: 100, nice: 0)
|
||||||
|
let after = CPUTicks(user: 200, system: 0, idle: 200, nice: 0)
|
||||||
|
|
||||||
|
#expect(CPUTicks.usage(from: before, to: after) == 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Nur Leerlauf ergibt null")
|
||||||
|
func onlyIdle() {
|
||||||
|
let before = CPUTicks(user: 0, system: 0, idle: 0, nice: 0)
|
||||||
|
let after = CPUTicks(user: 0, system: 0, idle: 100, nice: 0)
|
||||||
|
|
||||||
|
#expect(CPUTicks.usage(from: before, to: after) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Kein Leerlauf ergibt volle Auslastung")
|
||||||
|
func fullyBusy() {
|
||||||
|
let before = CPUTicks(user: 0, system: 0, idle: 50, nice: 0)
|
||||||
|
let after = CPUTicks(user: 100, system: 0, idle: 50, nice: 0)
|
||||||
|
|
||||||
|
#expect(CPUTicks.usage(from: before, to: after) == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("System- und Nice-Zeit zählen als Beschäftigung mit")
|
||||||
|
func systemAndNiceCount() {
|
||||||
|
let before = CPUTicks(user: 0, system: 0, idle: 0, nice: 0)
|
||||||
|
let after = CPUTicks(user: 25, system: 25, idle: 50, nice: 0)
|
||||||
|
|
||||||
|
#expect(CPUTicks.usage(from: before, to: after) == 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Zwei identische Abtastungen ergeben null statt einer Division durch null")
|
||||||
|
func identicalSamples() {
|
||||||
|
let ticks = CPUTicks(user: 10, system: 10, idle: 10, nice: 10)
|
||||||
|
#expect(CPUTicks.usage(from: ticks, to: ticks) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein Zählerüberlauf ergibt keinen Ausreißer")
|
||||||
|
func counterWraparound() {
|
||||||
|
// 32-Bit-Zähler laufen nach gut 400 Tagen bei 100 Hz über. Rechnet man
|
||||||
|
// naiv, springt die Anzeige einmalig auf einen absurden Wert.
|
||||||
|
let before = CPUTicks(user: .max - 10, system: 0, idle: .max - 10, nice: 0)
|
||||||
|
let after = CPUTicks(user: 10, system: 0, idle: 10, nice: 0)
|
||||||
|
|
||||||
|
let usage = CPUUsageRange.contains(CPUTicks.usage(from: before, to: after))
|
||||||
|
#expect(usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Das Ergebnis liegt immer zwischen 0 und 1")
|
||||||
|
func alwaysNormalised() {
|
||||||
|
for _ in 0..<50 {
|
||||||
|
let before = CPUTicks(user: .random(in: 0...100_000), system: .random(in: 0...100_000),
|
||||||
|
idle: .random(in: 0...100_000), nice: .random(in: 0...100_000))
|
||||||
|
let after = CPUTicks(user: .random(in: 0...100_000), system: .random(in: 0...100_000),
|
||||||
|
idle: .random(in: 0...100_000), nice: .random(in: 0...100_000))
|
||||||
|
#expect(CPUUsageRange.contains(CPUTicks.usage(from: before, to: after)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let CPUUsageRange = 0.0...1.0
|
||||||
|
|
||||||
|
@Suite("Speicherdruck")
|
||||||
|
struct MemoryPressureTests {
|
||||||
|
|
||||||
|
@Test("Viel frei bedeutet grüner Bereich")
|
||||||
|
func plentyFree() {
|
||||||
|
#expect(MemoryPressure.classify(free: 0.5, compressed: 0.05, swapUsed: 0) == .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Wenig frei und viel komprimiert bedeutet Warnbereich")
|
||||||
|
func compressedMeansWarning() {
|
||||||
|
#expect(MemoryPressure.classify(free: 0.08, compressed: 0.25, swapUsed: 0) == .warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Aktive Auslagerung ist immer kritisch, egal wie viel frei scheint")
|
||||||
|
func swapIsAlwaysCritical() {
|
||||||
|
// Sobald ausgelagert wird, bremst der Rechner spürbar — auch wenn die
|
||||||
|
// Freispeicheranzeige noch harmlos aussieht.
|
||||||
|
#expect(MemoryPressure.classify(free: 0.4, compressed: 0.01,
|
||||||
|
swapUsed: 2 * 1024 * 1024 * 1024) == .critical)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die Grenzwerte springen nicht bei winzigen Änderungen hin und her")
|
||||||
|
func classificationIsStable() {
|
||||||
|
let a = MemoryPressure.classify(free: 0.30, compressed: 0.10, swapUsed: 0)
|
||||||
|
let b = MemoryPressure.classify(free: 0.301, compressed: 0.101, swapUsed: 0)
|
||||||
|
#expect(a == b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("Durchsatz")
|
||||||
|
struct ThroughputTests {
|
||||||
|
|
||||||
|
@Test("Tausend Byte in einer Sekunde sind tausend Byte pro Sekunde")
|
||||||
|
func simpleRate() {
|
||||||
|
#expect(Throughput.rate(previous: 0, current: 1000, elapsed: 1) == 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die Rate hängt nicht von der Abtastrate ab")
|
||||||
|
func rateIsIndependentOfInterval() {
|
||||||
|
// Der eigentliche Zweck: das Menüleisten-Modul tastet je nach
|
||||||
|
// Einstellung alle 1 bis 10 Sekunden ab. Ohne Normierung zeigte es bei
|
||||||
|
// 5 Sekunden das Fünffache an.
|
||||||
|
let oneSecond = Throughput.rate(previous: 0, current: 1000, elapsed: 1)
|
||||||
|
let fiveSeconds = Throughput.rate(previous: 0, current: 5000, elapsed: 5)
|
||||||
|
|
||||||
|
#expect(oneSecond == fiveSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein Zählerüberlauf ergibt keinen Ausreißer")
|
||||||
|
func wraparoundIsHandled() {
|
||||||
|
let rate = Throughput.rate(previous: UInt64(UInt32.max) - 100, current: 100, elapsed: 1)
|
||||||
|
#expect(rate >= 0)
|
||||||
|
#expect(rate < 1_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein zurückgesetzter Zähler ergibt null statt eines Sprungs")
|
||||||
|
func counterResetGivesZero() {
|
||||||
|
// Beim Interface-Wechsel — WLAN aus, VPN an — fangen die Zähler wieder
|
||||||
|
// bei null an. Ein naiver Vergleich zeigte dann einen riesigen Wert.
|
||||||
|
#expect(Throughput.rate(previous: 5_000_000, current: 1000, elapsed: 1) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Kein vergangenes Intervall ergibt null statt einer Division durch null")
|
||||||
|
func zeroElapsed() {
|
||||||
|
#expect(Throughput.rate(previous: 0, current: 1000, elapsed: 0) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein negatives Intervall wird verworfen")
|
||||||
|
func negativeElapsed() {
|
||||||
|
// Kann bei einer Zeitumstellung vorkommen.
|
||||||
|
#expect(Throughput.rate(previous: 0, current: 1000, elapsed: -5) == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("Zahlenformat")
|
||||||
|
struct FormattingTests {
|
||||||
|
|
||||||
|
@Test("Byteraten wechseln die Einheit statt fünfstellig zu werden")
|
||||||
|
func rateUsesSensibleUnits() {
|
||||||
|
#expect(Throughput.formatted(0).contains("0"))
|
||||||
|
#expect(Throughput.formatted(2_400_000).contains("MB"))
|
||||||
|
#expect(Throughput.formatted(42_000).contains("KB"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die Rate trägt immer die Zeiteinheit — sonst liest man sie als Menge")
|
||||||
|
func rateAlwaysCarriesPerSecond() {
|
||||||
|
for value in [0.0, 500, 42_000, 2_400_000, 900_000_000] {
|
||||||
|
#expect(Throughput.formatted(value).hasSuffix("/s"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Prozentwerte sind ganzzahlig")
|
||||||
|
func percentIsWhole() {
|
||||||
|
// Nachkommastellen bei der CPU-Auslastung täuschen eine Genauigkeit
|
||||||
|
// vor, die die Messung nicht hat — und sie zappeln im Sekundentakt.
|
||||||
|
#expect(MetricFormat.percent(0.4567) == "46 %")
|
||||||
|
#expect(MetricFormat.percent(0) == "0 %")
|
||||||
|
#expect(MetricFormat.percent(1) == "100 %")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Temperaturen bekommen ein Gradzeichen und keine Nachkommastelle")
|
||||||
|
func temperatureFormat() {
|
||||||
|
#expect(MetricFormat.temperature(46.7) == "47°")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// Platzhalter
|
||||||
@@ -51,6 +51,10 @@ targets:
|
|||||||
product: WeatherProvider
|
product: WeatherProvider
|
||||||
- package: OnyxKit
|
- package: OnyxKit
|
||||||
product: MediaProvider
|
product: MediaProvider
|
||||||
|
- package: OnyxKit
|
||||||
|
product: MetricsProvider
|
||||||
|
- package: OnyxKit
|
||||||
|
product: NetworkProvider
|
||||||
postBuildScripts:
|
postBuildScripts:
|
||||||
# Der MediaRemote-Adapter wird aus dem mitgelieferten Quellcode gebaut,
|
# Der MediaRemote-Adapter wird aus dem mitgelieferten Quellcode gebaut,
|
||||||
# nicht als fertige Binary eingecheckt: so ist nachvollziehbar, was da
|
# nicht als fertige Binary eingecheckt: so ist nachvollziehbar, was da
|
||||||
|
|||||||
Reference in New Issue
Block a user