Der Regler war eine Pseudobedienung. Der SMC lehnt Schreibzugriffe auf CHLT ab — der Helfer hat es mit korrekter Größe versucht und „geschrieben (false), zurückgelesen 85" gemeldet. Das Sicherheitsnetz hat also die Wahrheit gesagt; die Oberfläche hat sie nur nicht gezeigt. Weiter zu raten hieße, im Lade-Subsystem Keys durchzuprobieren, und das ist die eine Operation in diesem Projekt, die Hardware dauerhaft beschädigen kann. Also: das Limit wird gelesen und angezeigt, mit einem Weg in die Systemeinstellungen, wo es tatsächlich geht. Der Schreibpfad ist aus dem privilegierten Helfer entfernt — ein Root-Dienst trägt nur, was benutzt wird. Die Akku-Kachel zeigte „40 %" und einen Verlauf über die letzten Sekunden. Der Ladestand ändert sich in Minuten, nicht in Sekunden; der Graph war eine waagerechte Linie. Jetzt: verbleibende Zeit bzw. Zeit bis voll, Leistung in Watt mit Richtung, Ladelimit, Zustand, Zyklen — und der Energiesparmodus. Der erklärt, warum die Maschine langsamer wirkt, und ohne diesen Hinweis sucht man den Grund woanders. In der Menüleiste standen fünf nackte Prozentzahlen nebeneinander. Man sah Zahlen und wusste nicht, welche wozu gehört. Zwei neue Darstellungen: Kürzel und Wert („CPU 34 %") sowie Symbol und Wert. Beim Akku stehen sie zuoberst in der Auswahl. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
493 lines
18 KiB
Swift
493 lines
18 KiB
Swift
import SwiftUI
|
|
import OnyxDesign
|
|
import OnyxNotch
|
|
import OnyxWidgetKit
|
|
import CalendarProvider
|
|
import ShelfProvider
|
|
import OnyxMenuBar
|
|
import MetricsProvider
|
|
import OnyxHelperProtocol
|
|
import WeatherProvider
|
|
|
|
enum SettingsTab: Hashable {
|
|
case widgets, display, sources, menubar, fans, permissions
|
|
}
|
|
|
|
struct SettingsView: View {
|
|
@Bindable var model: AppModel
|
|
let calendarModel: CalendarModel
|
|
let weatherModel: WeatherModel
|
|
let metricsModel: MetricsModel?
|
|
let shelfStore: ShelfStore?
|
|
let fanControl: FanControl
|
|
let launchAtLogin: LaunchAtLogin
|
|
let showOnboarding: () -> Void
|
|
@Binding var selectedTab: SettingsTab
|
|
|
|
var body: some View {
|
|
TabView(selection: $selectedTab) {
|
|
WidgetSettings(model: model, shelfStore: shelfStore)
|
|
.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)
|
|
CalendarSourceSettings(model: calendarModel)
|
|
.tabItem { Label("settings.tab.sources", systemImage: "calendar.badge.clock") }
|
|
.tag(SettingsTab.sources)
|
|
MenuBarSettings(model: model, metricsModel: metricsModel)
|
|
.tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") }
|
|
.tag(SettingsTab.menubar)
|
|
FanSettingsView(control: fanControl)
|
|
.tabItem { Label("settings.tab.fans", systemImage: "fan") }
|
|
.tag(SettingsTab.fans)
|
|
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel,
|
|
launchAtLogin: launchAtLogin, showOnboarding: showOnboarding)
|
|
.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
|
|
let launchAtLogin: LaunchAtLogin
|
|
let showOnboarding: () -> Void
|
|
@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("settings.general") {
|
|
Toggle(isOn: Binding(
|
|
get: { launchAtLogin.isEnabled },
|
|
set: { _ = launchAtLogin.set($0) })) {
|
|
Text("settings.launchAtLogin")
|
|
}
|
|
.disabled(launchAtLogin.needsSystemSettings)
|
|
|
|
// Der Zustand, der sonst in eine Schleife führt: der Schalter
|
|
// steht auf aus, Drücken bewirkt nichts, weil das System den
|
|
// Eintrag blockiert. Also steht hier, wo es weitergeht.
|
|
if launchAtLogin.needsSystemSettings {
|
|
HStack {
|
|
Text("settings.launchAtLogin.blocked")
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
Spacer()
|
|
Button("settings.permissions.openSystem") {
|
|
launchAtLogin.openSystemSettings()
|
|
}
|
|
}
|
|
}
|
|
|
|
HStack {
|
|
Spacer()
|
|
Button("settings.onboarding.show", action: showOnboarding)
|
|
.buttonStyle(.borderless)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
launchAtLogin.refresh()
|
|
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
|
|
let metricsModel: MetricsModel?
|
|
|
|
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"),
|
|
("audio", "mixer.menubar.name", "slider.horizontal.3"),
|
|
("fans", "widget.fans.name", "fan"),
|
|
]
|
|
|
|
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,
|
|
metricsModel: metricsModel)
|
|
}
|
|
}
|
|
.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
|
|
let metricsModel: MetricsModel?
|
|
|
|
/// Welche Darstellungen dieses Modul beherrscht.
|
|
static func presentations(for id: String) -> [MenuBarPresentation] {
|
|
switch id {
|
|
// Ein Mischpult hat keinen einzelnen Messwert.
|
|
case "audio": []
|
|
// Drehzahl als Zahl oder nur das Symbol — ein Verlauf der Drehzahl
|
|
// sagt in 28 Punkten Breite nichts.
|
|
case "fans": [.value, .symbol]
|
|
// Der Akku kann alles, und gerade dort will man Symbol oder Kürzel:
|
|
// eine nackte Prozentzahl neben vier anderen sagt nichts.
|
|
case "metric.battery": [.symbolAndValue, .labelAndValue, .value, .symbol,
|
|
.graph, .valueAndGraph]
|
|
default: MenuBarPresentation.allCases
|
|
}
|
|
}
|
|
|
|
/// Zu welcher Hardwaregröße dieses Modul gehört — `nil` beim Netzwerk.
|
|
private var metricKind: MetricKind? {
|
|
guard id.hasPrefix("metric.") else { return nil }
|
|
return MetricKind(rawValue: String(id.dropFirst("metric.".count)))
|
|
}
|
|
|
|
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()
|
|
|
|
// Bei CPU und GPU zusätzlich wählbar, was gezeigt wird. Beide Werte
|
|
// stammen aus derselben Messung — ein zweites Element in der
|
|
// ohnehin knappen Menüleiste wäre Verschwendung.
|
|
if let metric = metricKind, metric.supportsDisplayModes, let metricsModel {
|
|
Picker("", selection: Binding(
|
|
get: { metricsModel.displayMode(for: metric) },
|
|
set: { metricsModel.setDisplayMode($0, for: metric) })) {
|
|
Text("metric.mode.usage").tag(MetricDisplayMode.usage)
|
|
Text("metric.mode.temperature").tag(MetricDisplayMode.temperature)
|
|
}
|
|
.labelsHidden()
|
|
.frame(width: 110)
|
|
.disabled(!settings.isEnabled)
|
|
}
|
|
|
|
// Nur anbieten, was das Modul auch zeichnen kann. Der Mixer hat
|
|
// keinen einzelnen Messwert, den man als Zahl oder Verlauf zeigen
|
|
// könnte — eine Auswahl ohne Wirkung wäre eine Falle.
|
|
if !Self.presentations(for: id).isEmpty {
|
|
Picker("", selection: Binding(
|
|
get: { settings.presentation },
|
|
set: { presentation in
|
|
var updated = settings
|
|
updated.presentation = presentation
|
|
model.setMenuBarSettings(updated, for: id)
|
|
})) {
|
|
ForEach(Self.presentations(for: id)) { 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"
|
|
case .labelAndValue: "settings.menubar.labelAndValue"
|
|
case .symbolAndValue: "settings.menubar.symbolAndValue"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Widgets
|
|
|
|
private struct WidgetSettings: View {
|
|
@Bindable var model: AppModel
|
|
let shelfStore: ShelfStore?
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let shelfStore {
|
|
Section("settings.shelf.title") {
|
|
ShelfSettingsRows(store: shelfStore)
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.inset)
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
/// Einstellungen der Ablage.
|
|
///
|
|
/// Automatisches Aufräumen ist ausdrücklich abschaltbar und standardmäßig aus:
|
|
/// eine Ablage, die ungefragt löscht, ist kein Zwischenlager, sondern ein
|
|
/// Papierkorb mit Zeitschaltuhr.
|
|
private struct ShelfSettingsRows: View {
|
|
@Bindable var store: ShelfStore
|
|
|
|
private static let choices: [Int?] = [nil, 1, 7, 30, 90]
|
|
|
|
var body: some View {
|
|
Picker(selection: $store.autoCleanupDays) {
|
|
ForEach(Self.choices, id: \.self) { days in
|
|
if let days {
|
|
Text("settings.shelf.days \(days)").tag(Optional(days))
|
|
} else {
|
|
Text("settings.shelf.never").tag(Int?.none)
|
|
}
|
|
}
|
|
} label: {
|
|
Text("settings.shelf.cleanup")
|
|
}
|
|
|
|
HStack {
|
|
Text("settings.shelf.count \(store.items.count)")
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Button("settings.shelf.reveal") { store.revealInFinder() }
|
|
.buttonStyle(.borderless)
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
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()
|
|
}
|
|
}
|