Files
onyx/Onyx/SettingsView.swift
Guido Schmit 40bd83e49e Berechtigungsdialog und Einstellungsfenster reparieren
Zwei Fehler, beide durch Messen gefunden statt durch Raten — und einer davon
war meine eigene fehlerhafte Diagnose: `log show` wurde von zsh abgefangen, ich
hatte stderr umgeleitet und die Fehlermeldung nie gesehen. Meine Aussage "keine
TCC-Einträge, also wurde nie gefragt" stützte sich damit auf einen Befehl, der
gar nicht lief.

Einstellungsfenster: SwiftUIs Settings-Szene wird über den privaten Selektor
showSettingsWindow: geöffnet, dessen Name sich zwischen macOS-Versionen schon
geändert hat und der bei .accessory-Apps ohne Menüleiste unzuverlässig ankommt.
Der Menüeintrag tat schlicht nichts. Jetzt ein eigenes NSWindow.

Kalenderberechtigung, zwei Ursachen übereinander:

Xcode lagert im Debug-Build den Programmcode in eine eigene dylib aus (für
SwiftUI-Vorschauen). Das Hauptprogramm ist dann nur ein Rumpf, und TCC ordnete
die Anfrage dem Rumpf zu — kTCCServiceCalendar erreichte tccd überhaupt nicht.
ENABLE_DEBUG_DYLIB = NO behebt das; danach erscheint die Anfrage im Protokoll.

Danach immer noch kein Dialog: promptType 1, aber promptPolicy 0. TCC bereitet
den Dialog vor und entscheidet sich dagegen, weil keine App im Vordergrund ist,
an die er gehören könnte. NSApp.activate davor hilft nicht — Aktivierung wirkt
asynchron und ist beim Aufruf noch nicht durch. Deshalb wird beim ersten Start
das Einstellungsfenster auf dem Reiter Berechtigungen geöffnet: dort steht ein
echtes Fenster im Vordergrund, und der Nutzer weiß, warum gefragt wird.

Folgefehler dabei: ohne Berechtigung zeigte das Widget "Nichts mehr für heute"
— eine Aussage, die es ohne Zugriff gar nicht treffen kann. refresh() fragt
jetzt nicht mehr selbst nach und meldet ohne Berechtigung ehrlich `denied`.

Fehler aus requestFullAccessToEvents werden nicht mehr mit try? verschluckt.
2026-08-10 18:20:21 +02:00

213 lines
7.2 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
enum SettingsTab: Hashable {
case widgets, display, permissions
}
struct SettingsView: View {
@Bindable var model: AppModel
let calendarModel: CalendarModel
@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)
PermissionSettings(calendarModel: calendarModel)
.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
@State private var granted = false
var body: some View {
Form {
Section {
HStack {
Label("settings.permissions.calendar", systemImage: "calendar")
Spacer()
if granted {
Label("settings.permissions.granted", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.labelStyle(.titleAndIcon)
} else {
Button("settings.permissions.request") {
Task {
let ok = await calendarModel.requestAccessNow()
granted = ok
// macOS fragt nach einer Ablehnung nicht erneut.
// Dann hilft nur noch der Weg über die
// Systemeinstellungen also dorthin führen.
if !ok { openPrivacySettings() }
}
}
}
}
Text("settings.permissions.calendar.why")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Section {
Button("settings.permissions.openSystem") { openPrivacySettings() }
}
}
.formStyle(.grouped)
.padding()
.onAppear { granted = calendarModel.hasAccess }
}
private func openPrivacySettings() {
guard let url = URL(string:
"x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") else { return }
NSWorkspace.shared.open(url)
}
}
// 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)
}
.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"
}
}
}