Files
onyx/Onyx/SettingsView.swift
Guido Schmit d73b789fb7 Phase 6: Lüftersteuerung über privilegierten Helfer
Das Sicherheitsnetz kommt zuerst und liegt als reine Funktion vor — 17 Tests,
kein Hardwarezugriff nötig. Es sitzt im Helfer, nicht in der Oberfläche: die
kann abstürzen, hängen oder beendet sein, während die Lüfter auf einem festen
Wert stehen. Genau dafür ist es da.

Vier Regeln, nicht abschaltbar: nie unter das Firmware-Minimum, Rückfall auf
Automatik ab 95 °C, Rückfall wenn der Herzschlag fünf Sekunden ausbleibt,
Rückfall beim Beenden. Ohne lesbare Temperatur wird gar nicht gesteuert —
blind eine feste Drehzahl zu halten wäre die falsche Antwort auf fehlende
Information.

Die App sagt dem Helfer NICHT, wie warm es ist. Er misst selbst. Sonst hinge
das Netz an der Ehrlichkeit eines Prozesses, der abstürzen, hängen oder — bei
einer manipulierten Kopie — schlicht lügen kann. Der Herzschlag sagt nur "ich
lebe noch".

Der Helfer entscheidet jede Sekunde neu, nicht nur beim Setzen: nur so greifen
Wachhund und Temperaturwächter auch dann, wenn von der App nie wieder etwas
kommt. Geschrieben wird nur bei Änderung — jeder SMC-Schreibvorgang ist ein
Eingriff.

Der Schreibpfad existiert ausschließlich im Helfer, als eigene Kopie statt
geteilter Bibliothek. Was im App-Prozess nicht vorhanden ist, kann dort auch
nicht versehentlich aufgerufen werden.

XPC prüft die Signatur in BEIDE Richtungen. Ohne das könnte jedes Programm auf
dem Rechner die Lüfter eines root-Dienstes steuern — der Mach-Dienst ist
systemweit sichtbar.

Drei Fallstricke beim Einbetten, alle nachgemessen:

Xcode signiert Kommandozeilenprogramme unter dem Dateinamen. Der Helfer hieß
damit "OnyxHelper" statt com.scarriffleservices.onyx.helper, und die App hätte
ihren eigenen Helfer abgelehnt. Behoben über eine in die Binary eingebettete
Info.plist (CREATE_INFOPLIST_SECTION_IN_BINARY).

Der Build-Schritt läuft nach Xcodes Signatur; das Kopieren bricht das Siegel
des App-Bundles. Also wird zum Schluss neu signiert — mit denselben
Entitlements, sonst gingen App-Group, WeatherKit und die TCC-Berechtigungen
verloren.

Mit deklarierten outputFiles übersprang Xcode den Schritt, obwohl sich das
Programm geändert hatte.

Geprüft: App-Siegel gültig (deep), beide Signaturanforderungen erfüllt,
alle sieben Entitlements erhalten. 162 Tests grün.
2026-08-10 22:49:41 +02:00

410 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import OnyxDesign
import OnyxNotch
import OnyxWidgetKit
import CalendarProvider
import OnyxMenuBar
import MetricsProvider
import OnyxHelperProtocol
import WeatherProvider
enum SettingsTab: Hashable {
case widgets, display, menubar, fans, permissions
}
struct SettingsView: View {
@Bindable var model: AppModel
let calendarModel: CalendarModel
let weatherModel: WeatherModel
let metricsModel: MetricsModel?
let fanControl: FanControl
@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, 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)
.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
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"),
]
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?
/// 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)
}
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"
}
}
}