Files
onyx/Onyx/OnyxApp.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

190 lines
7.1 KiB
Swift

import SwiftUI
import AppKit
import OnyxCore
import OnyxDesign
import OnyxNotch
import OnyxMenuBar
import OnyxWidgetKit
import CalendarProvider
@main
struct OnyxApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
var body: some Scene {
// Onyx hat kein Hauptfenster alles lebt in der Notch und in der
// Menüleiste. Die Einstellungen sind die einzige Ausnahme; im Panel
// wäre dafür zu wenig Platz.
// Eine Szene ist Pflicht, benutzt wird sie nicht: das
// Einstellungsfenster führt SettingsWindowController selbst.
// Siehe die Begründung dort.
Settings { EmptyView() }
}
}
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private(set) var model: AppModel?
private var coordinator: NotchCoordinator?
private var menuBar: MenuBarController?
private var onyxItem: NSStatusItem?
private var calendarModel: CalendarModel?
private let settingsWindow = SettingsWindowController()
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
// Vor allem anderen: sicherstellen, dass wir allein sind. Zwei Instanzen
// bringen zwei Zustandsmaschinen und zwei Animationen auf dieselbe
// Notch das sieht aus wie ein Ruckler und ist keiner.
if case .blocked(let pids) = SingleInstance.claim() {
SingleInstance.explainBlocked(by: pids)
NSApp.terminate(nil)
return
}
registerWidgets()
let model = AppModel()
self.model = model
let coordinator = NotchCoordinator(policy: model.displayPolicy) { _, presentation in
NotchHostingView(
rootView: NotchContainer(presentation: presentation) {
NotchPanelContent(model: model)
},
presentation: presentation)
}
coordinator.start()
self.coordinator = coordinator
applyContentSize()
// Einstellungen wirken sofort. Eine Änderung, die erst nach einem
// Neustart greift, ist in einer App ohne Fenster praktisch unauffindbar.
model.onLayoutChanged = { [weak self] _ in self?.applyContentSize() }
model.onDisplayPolicyChanged = { [weak self] policy in
self?.coordinator?.policy = policy
self?.applyContentSize()
}
let menuBar = MenuBarController()
menuBar.start()
self.menuBar = menuBar
installOnyxStatusItem()
observeMenuBarPreference()
requestPermissions()
}
/// Berechtigungen beim Start anfragen, nicht erst wenn ein Widget sichtbar
/// wird.
///
/// Das Panel ist beim Start zu. Eine an die View gebundene Anfrage kommt
/// deshalb erst, wenn jemand die Notch berührt und bis dahin taucht Onyx
/// in den Systemeinstellungen gar nicht auf, es gibt also nichts, das man
/// dort freischalten könnte. Genau dieser Zustand ist beim Testen
/// aufgetreten.
private func requestPermissions() {
guard let calendarModel else { return }
// Immer einmal laden: mit Berechtigung kommen Termine, ohne sie zeigt
// das Widget den Berechtigungshinweis statt einer leeren Liste.
calendarModel.refresh()
guard !calendarModel.hasAccess else { return }
// Nicht im Hintergrund anfragen.
//
// Onyx ist eine App ohne Fenster. TCC bereitet den Dialog zwar vor
// (promptType 1), entscheidet sich dann aber dagegen (promptPolicy 0),
// weil keine App im Vordergrund ist, an die er gehören könnte. Die
// Anfrage kommt kommentarlos mit `false` zurück, der Status bleibt
// `notDetermined` und in den Systemeinstellungen taucht Onyx nie auf.
// `NSApp.activate` davor hilft nicht: Aktivierung wirkt asynchron und
// ist beim Aufruf noch nicht durch.
//
// Deshalb der ehrliche Weg: beim ersten Start das Einstellungsfenster
// öffnen. Dort ist ein echtes Fenster im Vordergrund, der Dialog
// erscheint zuverlässig und der Nutzer weiß, warum gefragt wird.
showSettings(tab: .permissions)
}
func applicationWillTerminate(_ notification: Notification) {
coordinator?.stop()
menuBar?.stop()
}
/// Echte Widgets zuerst, danach die Platzhalter für alles, was noch fehlt.
private func registerWidgets() {
let calendarModel = CalendarModel(source: EventKitSource())
self.calendarModel = calendarModel
WidgetRegistry.shared.register(CalendarWidget(model: calendarModel))
PlaceholderWidgets.registerAll()
}
private func applyContentSize() {
guard let model, let coordinator else { return }
let size = model.panelContentSize
coordinator.activeControllers.forEach { $0.contentSize = size }
}
// MARK: - Menüleiste
/// Das feste Onyx-Element: Panel öffnen, Einstellungen, Beenden. Es ist auch
/// das Sicherheitsnetz, falls die Notch einmal nicht reagiert.
private func installOnyxStatusItem() {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
item.autosaveName = "onyx.menubar.main"
item.button?.image = NSImage(systemSymbolName: "circle.hexagongrid.fill",
accessibilityDescription: "Onyx")
item.button?.image?.isTemplate = true
let menu = NSMenu()
menu.addItem(withTitle: String(localized: "menu.openPanel"),
action: #selector(togglePanel), keyEquivalent: "").target = self
menu.addItem(.separator())
menu.addItem(withTitle: String(localized: "menu.settings"),
action: #selector(openSettings), keyEquivalent: ",").target = self
menu.addItem(.separator())
menu.addItem(withTitle: String(localized: "menu.quit"),
action: #selector(quit), keyEquivalent: "q").target = self
item.menu = menu
onyxItem = item
}
private func observeMenuBarPreference() {
guard let model else { return }
withObservationTracking {
_ = model.showsMenuBarIcon
} onChange: {
Task { @MainActor [weak self] in
self?.onyxItem?.isVisible = model.showsMenuBarIcon
self?.observeMenuBarPreference()
}
}
onyxItem?.isVisible = model.showsMenuBarIcon
}
@objc private func togglePanel() { coordinator?.togglePanelUnderPointer() }
@objc private func openSettings() { showSettings(tab: .widgets) }
private func showSettings(tab: SettingsTab) {
guard let model, let calendarModel else { return }
settingsWindow.show(model: model, calendarModel: calendarModel, tab: tab)
}
@objc private func quit() { NSApp.terminate(nil) }
}
/// Der Inhalt des Panels. Liest `model.layout` und zeichnet sich damit neu,
/// sobald in den Einstellungen etwas geändert wird.
private struct NotchPanelContent: View {
let model: AppModel
var body: some View {
WidgetGrid(placements: model.layout)
}
}