Die M-Serie-Konvention gilt auch auf dem M5: Tp* sind die CPU-Kerne, Tg* die GPU-Cluster. Gezeigt wird der wärmste Punkt, nicht der Durchschnitt — gedrosselt wird nach dem heißesten Kern. Gemessen: CPU 76 °C, GPU 70 °C. Die Fühlerliste wird zur Laufzeit gesucht statt fest verdrahtet: welche es gibt, hängt am Modell. Auf diesem Mac sind es 285 Temperaturfühler. Zwei Leistungsprobleme, gefunden bevor sie jemandem auffallen konnten: Die Suche geht alle 3486 Keys durch und braucht knapp eine Sekunde. Auf dem Hauptthread wäre das eine spürbare Startverzögerung — sie läuft jetzt im Hintergrund, bis dahin zeigt die Oberfläche "—" statt einer erfundenen Zahl. Fünfzig Fühler zu lesen kostete 34 ms je Abtastung, bei 1 Hz also 3,4 % CPU. Zwei Maßnahmen: Typ und Größe je Key werden zwischengespeichert (sie stehen in der Firmware fest, sie bei jedem Lesen zu erfragen verdoppelte die IOKit-Aufrufe), und Temperaturen werden höchstens alle zwei Sekunden neu gelesen — sie bewegen sich in einer Sekunde ohnehin kaum. Ergebnis: 6 ms Grundlast, 18 ms alle zwei Sekunden, im Mittel gut 1 %. Dabei noch eine Eigenheit gefunden: die Byte-Reihenfolge im SMC ist nicht durchgängig. Messwerte wie B0AV kommen little-endian aus dem Akku-Baustein, die Metadaten des SMC selbst dagegen big-endian. #KEY little-endian gelesen ergibt 2 651 652 096 statt 3486 — die Fühlersuche brach damit still ab und alle Temperaturen blieben leer. Lüfterdrehzahlen stehen jetzt im Sensor-Popover, mit Balken zwischen Minimum und Maximum der Firmware: "2321 U/min" allein sagt niemandem, ob das viel ist. Die Steuerung selbst kommt mit Phase 6 — Schreibzugriff auf den SMC verlangt root — und der Popover sagt das auch. 145 Tests grün.
405 lines
14 KiB
Swift
405 lines
14 KiB
Swift
import SwiftUI
|
||
import OnyxDesign
|
||
import OnyxNotch
|
||
import OnyxWidgetKit
|
||
import CalendarProvider
|
||
import OnyxMenuBar
|
||
import MetricsProvider
|
||
import WeatherProvider
|
||
|
||
enum SettingsTab: Hashable {
|
||
case widgets, display, menubar, permissions
|
||
}
|
||
|
||
struct SettingsView: View {
|
||
@Bindable var model: AppModel
|
||
let calendarModel: CalendarModel
|
||
let weatherModel: WeatherModel
|
||
let metricsModel: MetricsModel?
|
||
@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)
|
||
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"
|
||
}
|
||
}
|
||
}
|