TCC zeigt den Dialog nur, wenn eine App im Vordergrund ist, an die er gehören kann. Bei einer App ohne Fenster verpufft die Anfrage kommentarlos: sie kommt mit false zurück, der Status bleibt notDetermined, und in den Systemeinstellungen taucht die App nie auf — dort lässt sich auch nichts von Hand hinzufügen, Einträge entstehen ausschließlich durch eine echte Anfrage. Deshalb fragt der Reiter Berechtigungen jetzt selbst, sobald er sichtbar ist, mit 400 ms Vorlauf: Aktivierung wirkt asynchron, ohne die Pause fragt Onyx während es formal noch im Hintergrund liegt. CalendarAuthorization trennt undetermined von denied. Im ersten Fall lohnt eine Anfrage, im zweiten zeigt macOS nie wieder einen Dialog und es hilft nur der Weg über die Systemeinstellungen — dorthin führt der Knopf dann auch. Korrektur einer eigenen Fehldiagnose: promptPolicy = 0 im tccd-Protokoll gehört zu kTCCServiceReminders, nicht zum Kalender. Der bekommt durchgehend promptPolicy = 4, Nachfragen war also die ganze Zeit erlaubt. Die tatsächlichen Ursachen waren die ausgelagerte Debug-dylib und ein verklemmter TCC-Eintrag.
228 lines
7.9 KiB
Swift
228 lines
7.9 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()
|
||
.task {
|
||
granted = calendarModel.hasAccess
|
||
guard !granted, !didAutoRequest,
|
||
calendarModel.authorization == .undetermined else { return }
|
||
didAutoRequest = true
|
||
|
||
// Kurz warten, bis das Fenster wirklich vorne steht. TCC zeigt den
|
||
// Dialog nur, wenn eine App im Vordergrund ist, an die er gehören
|
||
// kann — und Aktivierung wirkt asynchron. Ohne diese Pause fragt
|
||
// Onyx, während es formal noch im Hintergrund liegt, und die
|
||
// Anfrage verpufft kommentarlos.
|
||
try? await Task.sleep(for: .milliseconds(400))
|
||
granted = await calendarModel.requestAccessNow()
|
||
}
|
||
}
|
||
|
||
@State private var didAutoRequest = false
|
||
|
||
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"
|
||
}
|
||
}
|
||
}
|