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.
379 lines
13 KiB
Swift
379 lines
13 KiB
Swift
import SwiftUI
|
||
import OnyxDesign
|
||
import OnyxNotch
|
||
import OnyxWidgetKit
|
||
import CalendarProvider
|
||
import OnyxMenuBar
|
||
import WeatherProvider
|
||
|
||
enum SettingsTab: Hashable {
|
||
case widgets, display, menubar, permissions
|
||
}
|
||
|
||
struct SettingsView: View {
|
||
@Bindable var model: AppModel
|
||
let calendarModel: CalendarModel
|
||
let weatherModel: WeatherModel
|
||
@Binding var selectedTab: SettingsTab
|
||
|
||
var body: some View {
|
||
TabView(selection: $selectedTab) {
|
||
WidgetSettings(model: model)
|
||
.tabItem { Label("settings.tab.widgets", systemImage: "square.grid.2x2") }
|
||
.tag(SettingsTab.widgets)
|
||
DisplaySettings(model: model)
|
||
.tabItem { Label("settings.tab.display", systemImage: "macbook") }
|
||
.tag(SettingsTab.display)
|
||
MenuBarSettings(model: model)
|
||
.tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") }
|
||
.tag(SettingsTab.menubar)
|
||
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
|
||
.tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") }
|
||
.tag(SettingsTab.permissions)
|
||
}
|
||
.frame(width: 520, height: 420)
|
||
}
|
||
}
|
||
|
||
// MARK: - Berechtigungen
|
||
|
||
/// Zeigt den Zustand, statt ihn nur spürbar zu machen.
|
||
///
|
||
/// Eine verweigerte Berechtigung äußert sich sonst als leeres Widget, und der
|
||
/// Weg zurück ist nicht offensichtlich: hat macOS die Anfrage einmal
|
||
/// abgelehnt bekommen, fragt es nie wieder — es bleibt nur der Gang in die
|
||
/// Systemeinstellungen.
|
||
private struct PermissionSettings: View {
|
||
let calendarModel: CalendarModel
|
||
let weatherModel: WeatherModel
|
||
@State private var calendarGranted = false
|
||
|
||
private var locationState: PermissionRow.State {
|
||
switch weatherModel.locationAuthorization {
|
||
case .granted: .granted
|
||
case .denied: .denied
|
||
case .undetermined: .undetermined
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
PermissionRow(
|
||
title: "settings.permissions.calendar",
|
||
symbol: "calendar",
|
||
state: calendarGranted ? .granted
|
||
: (calendarModel.authorization == .denied ? .denied : .undetermined),
|
||
explanation: "settings.permissions.calendar.why",
|
||
pane: .calendar,
|
||
request: { calendarGranted = await calendarModel.requestAccessNow() })
|
||
}
|
||
|
||
Section {
|
||
PermissionRow(
|
||
title: "settings.permissions.location",
|
||
symbol: "location",
|
||
// Direkt aus dem beobachtbaren Wert: der Zustand ändert
|
||
// sich, während dieses Fenster offen steht.
|
||
state: locationState,
|
||
explanation: "settings.permissions.location.why",
|
||
pane: .location,
|
||
request: { weatherModel.requestLocationAuthorization() })
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
.padding()
|
||
.task {
|
||
calendarGranted = calendarModel.hasAccess
|
||
guard !calendarGranted, calendarModel.authorization == .undetermined else { return }
|
||
// Kurz warten, bis das Fenster wirklich vorne steht: TCC zeigt den
|
||
// Dialog nur, wenn eine App im Vordergrund ist, und Aktivierung
|
||
// wirkt asynchron.
|
||
try? await Task.sleep(for: .milliseconds(400))
|
||
calendarGranted = await calendarModel.requestAccessNow()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Eine Berechtigung mit ihrem Zustand und dem Weg, der von dort weiterführt.
|
||
///
|
||
/// Die drei Zustände brauchen drei verschiedene Antworten, und genau daran ist
|
||
/// die erste Fassung gescheitert: „noch nie gefragt" lässt sich mit einer
|
||
/// Anfrage lösen, „abgelehnt" nur über die Systemeinstellungen, und „erteilt"
|
||
/// braucht gar nichts.
|
||
private struct PermissionRow: View {
|
||
enum State { case granted, denied, undetermined }
|
||
enum Pane {
|
||
case calendar, location
|
||
|
||
/// Jede Berechtigung führt in ihren eigenen Bereich. Ein gemeinsamer
|
||
/// Knopf, der immer im Kalender landet, ist schlimmer als keiner.
|
||
var url: URL? {
|
||
switch self {
|
||
case .calendar:
|
||
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars")
|
||
case .location:
|
||
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices")
|
||
}
|
||
}
|
||
}
|
||
|
||
let title: LocalizedStringKey
|
||
let symbol: String
|
||
let state: State
|
||
let explanation: LocalizedStringKey
|
||
let pane: Pane
|
||
let request: () async -> Void
|
||
|
||
var body: some View {
|
||
HStack {
|
||
Label(title, systemImage: symbol)
|
||
Spacer()
|
||
switch state {
|
||
case .granted:
|
||
Label("settings.permissions.granted", systemImage: "checkmark.circle.fill")
|
||
.foregroundStyle(.green)
|
||
.labelStyle(.titleAndIcon)
|
||
case .undetermined:
|
||
Button("settings.permissions.request") { Task { await request() } }
|
||
case .denied:
|
||
// macOS fragt nach einer Ablehnung nicht mehr — ein
|
||
// Anfragen-Knopf wäre hier eine Lüge.
|
||
Button("settings.permissions.openSystem") {
|
||
if let url = pane.url { NSWorkspace.shared.open(url) }
|
||
}
|
||
}
|
||
}
|
||
Text(explanation)
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
|
||
// MARK: - Menüleiste
|
||
|
||
/// Jedes Modul einzeln an- und abschaltbar, mit eigener Darstellungsart.
|
||
///
|
||
/// Standardmäßig ist keines an: sechs neue Symbole beim ersten Start wären eine
|
||
/// Zumutung, und die Menüleiste ist ohnehin knapp — auf einem Notch-Display
|
||
/// blockiert die Kamera zusätzlich die Mitte.
|
||
private struct MenuBarSettings: View {
|
||
@Bindable var model: AppModel
|
||
|
||
private static let modules: [(id: String, label: LocalizedStringKey, symbol: String)] = [
|
||
("metric.cpu", "metric.cpu", "cpu"),
|
||
("metric.gpu", "metric.gpu", "cpu.fill"),
|
||
("metric.memory", "metric.memory", "memorychip"),
|
||
("metric.battery", "metric.battery", "battery.100"),
|
||
("metric.temperature", "metric.sensors", "thermometer"),
|
||
("network", "network.name", "network"),
|
||
]
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("settings.menubar.hint")
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
List {
|
||
ForEach(Self.modules, id: \.id) { module in
|
||
ModuleRow(model: model, id: module.id,
|
||
label: module.label, symbol: module.symbol)
|
||
}
|
||
}
|
||
.listStyle(.inset)
|
||
}
|
||
.padding()
|
||
// Der Zähler zwingt die Liste zum Neuzeichnen, wenn sich ein Modul
|
||
// ändert — sonst müsste jede Zeile ihren eigenen Zustand beobachten.
|
||
.id(model.menuBarRevision)
|
||
}
|
||
}
|
||
|
||
private struct ModuleRow: View {
|
||
@Bindable var model: AppModel
|
||
let id: String
|
||
let label: LocalizedStringKey
|
||
let symbol: String
|
||
|
||
var body: some View {
|
||
let settings = model.menuBarSettings(for: id)
|
||
|
||
HStack {
|
||
Toggle(isOn: Binding(
|
||
get: { settings.isEnabled },
|
||
set: { enabled in
|
||
var updated = settings
|
||
updated.isEnabled = enabled
|
||
model.setMenuBarSettings(updated, for: id)
|
||
})) {
|
||
Label(label, systemImage: symbol)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
Picker("", selection: Binding(
|
||
get: { settings.presentation },
|
||
set: { presentation in
|
||
var updated = settings
|
||
updated.presentation = presentation
|
||
model.setMenuBarSettings(updated, for: id)
|
||
})) {
|
||
ForEach(MenuBarPresentation.allCases) { presentation in
|
||
Text(presentation.localizedName).tag(presentation)
|
||
}
|
||
}
|
||
.labelsHidden()
|
||
.frame(width: 150)
|
||
.disabled(!settings.isEnabled)
|
||
}
|
||
}
|
||
}
|
||
|
||
extension MenuBarPresentation {
|
||
var localizedName: LocalizedStringKey {
|
||
switch self {
|
||
case .value: "settings.menubar.value"
|
||
case .graph: "settings.menubar.graph"
|
||
case .bars: "settings.menubar.bars"
|
||
case .valueAndGraph: "settings.menubar.valueAndGraph"
|
||
case .symbol: "settings.menubar.symbol"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Widgets
|
||
|
||
private struct WidgetSettings: View {
|
||
@Bindable var model: AppModel
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("settings.widgets.hint")
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
|
||
List {
|
||
Section("settings.widgets.inPanel") {
|
||
// Die Reihenfolge hier ist die Reihenfolge im Panel: die
|
||
// Layout-Engine setzt die Widgets von oben links der Reihe
|
||
// nach ein.
|
||
ForEach(model.layout) { placement in
|
||
if let widget = WidgetRegistry.shared.widget(id: placement.widgetID) {
|
||
PlacementRow(model: model, placement: placement, widget: widget)
|
||
}
|
||
}
|
||
.onMove { model.move(fromOffsets: $0, toOffset: $1) }
|
||
|
||
if model.layout.isEmpty {
|
||
Text("settings.widgets.empty")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
let available = WidgetRegistry.shared.all.filter { !model.isInPanel($0.id) }
|
||
if !available.isEmpty {
|
||
Section("settings.widgets.available") {
|
||
ForEach(available, id: \.id) { widget in
|
||
HStack {
|
||
Label(widget.displayName, systemImage: widget.symbolName)
|
||
Spacer()
|
||
Button("settings.widgets.add") { model.add(widget) }
|
||
.buttonStyle(.borderless)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.inset)
|
||
}
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
private struct PlacementRow: View {
|
||
@Bindable var model: AppModel
|
||
let placement: WidgetPlacement
|
||
let widget: any OnyxWidget
|
||
|
||
var body: some View {
|
||
HStack {
|
||
Label(widget.displayName, systemImage: widget.symbolName)
|
||
|
||
Spacer()
|
||
|
||
// Nur die Größen anbieten, die das Widget auch sinnvoll ausfüllt.
|
||
// Ein Mini-Monat in 1×1 wäre unleserlich.
|
||
Picker("", selection: Binding(
|
||
get: { placement.size },
|
||
set: { model.setSize($0, for: placement.widgetID) })) {
|
||
ForEach(widget.supportedSizes) { size in
|
||
Text(size.localizedName).tag(size)
|
||
}
|
||
}
|
||
.labelsHidden()
|
||
.frame(width: 130)
|
||
|
||
Button {
|
||
model.remove(placement.widgetID)
|
||
} label: {
|
||
Image(systemName: "minus.circle")
|
||
}
|
||
.buttonStyle(.borderless)
|
||
.help("settings.widgets.remove")
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Anzeige
|
||
|
||
private struct DisplaySettings: View {
|
||
@Bindable var model: AppModel
|
||
|
||
var body: some View {
|
||
Form {
|
||
Picker("settings.display.policy", selection: $model.displayPolicy) {
|
||
Text("settings.display.builtInOnly").tag(DisplayPolicy.builtInOnly)
|
||
Text("settings.display.all").tag(DisplayPolicy.allDisplays)
|
||
}
|
||
.pickerStyle(.radioGroup)
|
||
|
||
Text("settings.display.hint")
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
Divider()
|
||
|
||
Toggle("settings.display.menuBarIcon", isOn: $model.showsMenuBarIcon)
|
||
Text("settings.display.menuBarIcon.hint")
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
Divider()
|
||
|
||
Toggle("settings.display.dockIcon", isOn: $model.showsDockIcon)
|
||
Text("settings.display.dockIcon.hint")
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
.formStyle(.grouped)
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
extension WidgetSize {
|
||
var localizedName: LocalizedStringKey {
|
||
switch self {
|
||
case .small: "settings.size.small"
|
||
case .medium: "settings.size.medium"
|
||
case .large: "settings.size.large"
|
||
case .wide: "settings.size.wide"
|
||
}
|
||
}
|
||
}
|