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.
213 lines
7.2 KiB
Swift
213 lines
7.2 KiB
Swift
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"
|
||
}
|
||
}
|
||
}
|