From 40bd83e49e845a6dbdcde49a5dd3ac7d699dc7c2 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Mon, 10 Aug 2026 18:20:21 +0200 Subject: [PATCH] Berechtigungsdialog und Einstellungsfenster reparieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Onyx.xcodeproj/project.pbxproj | 6 + Onyx/Info.plist | 5 + Onyx/Localizable.xcstrings | 500 ++++++++++++++---- Onyx/OnyxApp.swift | 54 +- Onyx/SettingsView.swift | 71 ++- Onyx/SettingsWindowController.swift | 56 ++ .../CalendarProvider/CalendarSource.swift | 4 + .../CalendarProvider/CalendarWidget.swift | 29 +- .../CalendarProvider/EventKitSource.swift | 17 +- .../CalendarProvider/Localizable.xcstrings | 167 ++++-- project.yml | 6 + 11 files changed, 774 insertions(+), 141 deletions(-) create mode 100644 Onyx/SettingsWindowController.swift diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index e3ccc21..17628cc 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; }; 378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; }; 39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; }; 4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */ = {isa = PBXBuildFile; productRef = C64A111F449D0F9D36ED46FB /* OnyxCore */; }; @@ -27,6 +28,7 @@ 50A018673EE6BD9C50F1EFA0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 821B742C211265914B6CC44B /* OnyxKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = OnyxKit; path = Packages/OnyxKit; sourceTree = SOURCE_ROOT; }; 87597F09E971C14CF1E475DB /* Onyx.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Onyx.entitlements; sourceTree = ""; }; + 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = ""; }; B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderWidgets.swift; sourceTree = ""; }; F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnyxApp.swift; sourceTree = ""; }; @@ -92,6 +94,7 @@ F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */, CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */, 45BD2DF1B775C733C8F75635 /* SettingsView.swift */, + 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */, ); path = Onyx; sourceTree = ""; @@ -182,6 +185,7 @@ 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */, 5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */, 39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */, + 0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -244,6 +248,7 @@ CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = PP34X97WS3; + ENABLE_DEBUG_DYLIB = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -306,6 +311,7 @@ CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PP34X97WS3; + ENABLE_DEBUG_DYLIB = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; diff --git a/Onyx/Info.plist b/Onyx/Info.plist index cf85f58..698d4bf 100644 --- a/Onyx/Info.plist +++ b/Onyx/Info.plist @@ -29,6 +29,11 @@ + + NSCalendarsUsageDescription + Onyx zeigt deine Termine im Kalender-Widget an. Es wird ausschließlich gelesen, nie geschrieben. NSCalendarsFullAccessUsageDescription Onyx zeigt deine Termine im Kalender-Widget an. Es wird ausschließlich gelesen, nie geschrieben. NSLocationWhenInUseUsageDescription diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index aa4644a..aaf0488 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -1,135 +1,453 @@ { - "sourceLanguage" : "en", - "strings" : { - "menu.openPanel" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Panel öffnen" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Open Panel" } } + "sourceLanguage": "en", + "strings": { + "menu.openPanel": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Panel öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Panel" + } + } } }, - "menu.settings" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Einstellungen …" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Settings…" } } + "menu.quit": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx beenden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Quit Onyx" + } + } } }, - "menu.quit" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Onyx beenden" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Quit Onyx" } } + "menu.settings": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen …" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings…" + } + } } }, - - "settings.tab.widgets" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Widgets" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Widgets" } } + "settings.display.all": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Allen Displays" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "All displays" + } + } } }, - "settings.tab.display" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Anzeige" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Display" } } + "settings.display.builtInOnly": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur dem internen Display" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Built-in display only" + } + } } }, - "settings.widgets.hint" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Die Reihenfolge bestimmt die Anordnung im Panel. Jedes Widget rückt an die erste Stelle, an die es passt." } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "The order determines the arrangement in the panel. Each widget takes the first spot it fits into." } } + "settings.display.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf Displays ohne echte Notch erscheint oben mittig ein Balken, der sich genauso verhält. Ist der Deckel geschlossen und kein internes Display vorhanden, erscheint das Panel trotzdem auf einem externen — sonst wäre Onyx nicht erreichbar." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "On displays without a real notch, a bar appears at the top centre and behaves the same. With the lid closed and no built-in display present, the panel still appears on an external one — otherwise Onyx would be unreachable." + } + } } }, - "settings.widgets.inPanel" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Im Panel" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "In the panel" } } + "settings.display.menuBarIcon": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Symbol in der Menüleiste anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show icon in the menu bar" + } + } } }, - "settings.widgets.available" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Verfügbar" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Available" } } + "settings.display.menuBarIcon.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Über das Symbol erreichst du Panel, Einstellungen und Beenden. Ohne es kommst du nur über die Notch an Onyx heran." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The icon gives you the panel, settings and quit. Without it, the notch is the only way to reach Onyx." + } + } } }, - "settings.widgets.empty" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Keine Widgets im Panel. Das Panel bleibt leer, bis du eins hinzufügst." } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "No widgets in the panel. It stays empty until you add one." } } + "settings.display.policy": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Panel anzeigen auf" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show panel on" + } + } } }, - "settings.widgets.add" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Hinzufügen" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Add" } } + "settings.size.large": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Groß (2×2)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Large (2×2)" + } + } } }, - "settings.widgets.remove" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Aus dem Panel entfernen" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Remove from panel" } } + "settings.size.medium": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mittel (2×1)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Medium (2×1)" + } + } } }, - - "settings.size.small" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Klein (1×1)" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Small (1×1)" } } + "settings.size.small": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Klein (1×1)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Small (1×1)" + } + } } }, - "settings.size.medium" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Mittel (2×1)" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Medium (2×1)" } } + "settings.size.wide": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Breit (4×1)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Wide (4×1)" + } + } } }, - "settings.size.large" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Groß (2×2)" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Large (2×2)" } } + "settings.tab.display": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeige" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Display" + } + } } }, - "settings.size.wide" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Breit (4×1)" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Wide (4×1)" } } + "settings.tab.widgets": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Widgets" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Widgets" + } + } } }, - - "settings.display.policy" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Panel anzeigen auf" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Show panel on" } } + "settings.widgets.add": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hinzufügen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" + } + } } }, - "settings.display.builtInOnly" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Nur dem internen Display" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Built-in display only" } } + "settings.widgets.available": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verfügbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Available" + } + } } }, - "settings.display.all" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Allen Displays" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "All displays" } } + "settings.widgets.empty": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Widgets im Panel. Das Panel bleibt leer, bis du eins hinzufügst." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No widgets in the panel. It stays empty until you add one." + } + } } }, - "settings.display.hint" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Auf Displays ohne echte Notch erscheint oben mittig ein Balken, der sich genauso verhält. Ist der Deckel geschlossen und kein internes Display vorhanden, erscheint das Panel trotzdem auf einem externen — sonst wäre Onyx nicht erreichbar." } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "On displays without a real notch, a bar appears at the top centre and behaves the same. With the lid closed and no built-in display present, the panel still appears on an external one — otherwise Onyx would be unreachable." } } + "settings.widgets.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Reihenfolge bestimmt die Anordnung im Panel. Jedes Widget rückt an die erste Stelle, an die es passt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The order determines the arrangement in the panel. Each widget takes the first spot it fits into." + } + } } }, - "settings.display.menuBarIcon" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Symbol in der Menüleiste anzeigen" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Show icon in the menu bar" } } + "settings.widgets.inPanel": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Im Panel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "In the panel" + } + } } }, - "settings.display.menuBarIcon.hint" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Über das Symbol erreichst du Panel, Einstellungen und Beenden. Ohne es kommst du nur über die Notch an Onyx heran." } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "The icon gives you the panel, settings and quit. Without it, the notch is the only way to reach Onyx." } } + "settings.widgets.remove": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus dem Panel entfernen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove from panel" + } + } + } + }, + "settings.tab.permissions": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Berechtigungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Permissions" + } + } + } + }, + "settings.permissions.calendar": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalender" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendar" + } + } + } + }, + "settings.permissions.granted": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erteilt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Granted" + } + } + } + }, + "settings.permissions.request": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anfragen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Request" + } + } + } + }, + "settings.permissions.calendar.why": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx zeigt deine Termine im Kalender-Widget an. Es wird ausschließlich gelesen, nie geschrieben." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx shows your events in the calendar widget. It only ever reads, never writes." + } + } + } + }, + "settings.permissions.openSystem": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemeinstellungen öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open System Settings" + } + } } } }, - "version" : "1.0" -} + "version": "1.0" +} \ No newline at end of file diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index 7201bbd..f3f42d4 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -15,15 +15,10 @@ struct OnyxApp: App { // 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. - Settings { - if let model = delegate.model { - SettingsView(model: model) - } else { - // Nur sichtbar, wenn die Einzelinstanz-Prüfung uns abgewiesen - // hat und die App gerade wieder verschwindet. - ProgressView().frame(width: 520, height: 420) - } - } + // Eine Szene ist Pflicht, benutzt wird sie nicht: das + // Einstellungsfenster führt SettingsWindowController selbst. + // Siehe die Begründung dort. + Settings { EmptyView() } } } @@ -35,6 +30,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var menuBar: MenuBarController? private var onyxItem: NSStatusItem? private var calendarModel: CalendarModel? + private let settingsWindow = SettingsWindowController() func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) @@ -78,6 +74,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate { 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) { @@ -140,9 +168,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func togglePanel() { coordinator?.togglePanelUnderPointer() } - @objc private func openSettings() { - NSApp.activate(ignoringOtherApps: true) - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + @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) } diff --git a/Onyx/SettingsView.swift b/Onyx/SettingsView.swift index e71eb47..d900406 100644 --- a/Onyx/SettingsView.swift +++ b/Onyx/SettingsView.swift @@ -2,21 +2,90 @@ 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 { + 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 { diff --git a/Onyx/SettingsWindowController.swift b/Onyx/SettingsWindowController.swift new file mode 100644 index 0000000..62715b1 --- /dev/null +++ b/Onyx/SettingsWindowController.swift @@ -0,0 +1,56 @@ +import AppKit +import SwiftUI +import CalendarProvider + +/// Führt das Einstellungsfenster selbst. +/// +/// SwiftUIs `Settings`-Szene wird über `NSApp.sendAction(showSettingsWindow:)` +/// geöffnet — ein privater Selektor, dessen Name sich zwischen macOS-Versionen +/// schon geändert hat (`showPreferencesWindow:` davor) und der bei +/// `.accessory`-Apps ohne Menüleiste unzuverlässig ankommt. Genau das ist hier +/// passiert: der Menüeintrag tat nichts. +/// +/// Ein eigenes `NSWindow` hat diese Abhängigkeit nicht. Es kostet ein paar +/// Zeilen mehr und funktioniert dafür. +@MainActor +final class SettingsWindowController: NSObject, NSWindowDelegate { + + private var window: NSWindow? + /// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Reiter setzen kann. + private var selectedTab: SettingsTab = .widgets + + func show(model: AppModel, calendarModel: CalendarModel, tab: SettingsTab = .widgets) { + selectedTab = tab + + if window == nil { + let hosting = NSHostingController( + rootView: SettingsView(model: model, + calendarModel: calendarModel, + selectedTab: Binding( + get: { [weak self] in self?.selectedTab ?? .widgets }, + set: { [weak self] in self?.selectedTab = $0 }))) + + let window = NSWindow(contentViewController: hosting) + window.title = "Onyx" + window.styleMask = [.titled, .closable, .miniaturizable] + window.isReleasedWhenClosed = false + window.delegate = self + window.center() + // Über die Position freuen sich Nutzer mit mehreren Displays: + // beim zweiten Öffnen steht es wieder da, wo sie es hingeschoben haben. + window.setFrameAutosaveName("onyx.settings") + self.window = window + } + + // Ohne `activate` bleibt das Fenster einer .accessory-App hinter der + // aktiven App liegen und wirkt, als wäre nichts passiert. + NSApp.activate(ignoringOtherApps: true) + window?.makeKeyAndOrderFront(nil) + } + + func windowWillClose(_ notification: Notification) { + // Zurück in den Hintergrund: Onyx hat sonst weiter den Fokus, obwohl + // kein Fenster mehr offen ist. + NSApp.hide(nil) + } +} diff --git a/Packages/OnyxKit/Sources/CalendarProvider/CalendarSource.swift b/Packages/OnyxKit/Sources/CalendarProvider/CalendarSource.swift index 5d4df09..7cd807d 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/CalendarSource.swift +++ b/Packages/OnyxKit/Sources/CalendarProvider/CalendarSource.swift @@ -83,6 +83,10 @@ public enum CalendarSourceState: Equatable, Sendable { /// Eine Quelle für Termine. Apple-Kalender und Calendarr liefern beide dies. public protocol CalendarSource: Sendable { var id: String { get } + /// Ob bereits gelesen werden darf — **ohne** dabei nachzufragen. + /// Die Einstellungen brauchen das, um den Zustand anzuzeigen, ohne + /// nebenbei einen Systemdialog auszulösen. + var isAuthorized: Bool { get } func requestAccess() async -> Bool func calendars() async -> [OnyxCalendar] func load(_ interval: DateInterval) async -> CalendarSourceState diff --git a/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift b/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift index 7f450f9..9151e7f 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift +++ b/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift @@ -1,7 +1,11 @@ import SwiftUI +import OSLog +import EventKit import OnyxDesign import OnyxWidgetKit +private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Calendar") + /// Hält die Termine für das Widget und lädt sie nach. @MainActor @Observable @@ -19,6 +23,24 @@ public final class CalendarModel { self.calendar = calendar } + /// Ob gelesen werden darf. Fragt nicht nach — nur zum Anzeigen. + public var hasAccess: Bool { source.isAuthorized } + + /// Fragt die Berechtigung an und lädt bei Erfolg gleich. + /// + /// Wird beim Start gerufen, nicht erst wenn das Widget sichtbar wird. Das + /// Panel ist beim Start zu, also erscheint eine View-gebundene Anfrage erst, + /// wenn jemand die Notch berührt — und bis dahin steht in den + /// Systemeinstellungen kein Onyx, das man freischalten könnte. + @discardableResult + public func requestAccessNow() async -> Bool { + log.notice("Kalenderzugriff: Status vor der Anfrage = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)") + let granted = await source.requestAccess() + log.notice("Kalenderzugriff: erteilt = \(granted, privacy: .public), Status danach = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)") + if granted { refresh() } else { state = .denied } + return granted + } + public var window: EventWindow? { if case .ready(let window) = state { return window } return nil @@ -33,7 +55,12 @@ public final class CalendarModel { isLoading = true defer { isLoading = false } - guard await source.requestAccess() else { + // Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer + // App ohne Fenster heraus wird von TCC verworfen, ohne dass etwas + // passiert — die Anfrage muss aus dem Einstellungsfenster kommen. + // Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht + // eine leere Terminliste, die "nichts mehr heute" behaupten würde. + guard source.isAuthorized else { state = .denied return } diff --git a/Packages/OnyxKit/Sources/CalendarProvider/EventKitSource.swift b/Packages/OnyxKit/Sources/CalendarProvider/EventKitSource.swift index 8049c49..742988e 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/EventKitSource.swift +++ b/Packages/OnyxKit/Sources/CalendarProvider/EventKitSource.swift @@ -1,7 +1,10 @@ import Foundation +import OSLog import EventKit import AppKit +private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "EventKit") + /// Termine aus dem Apple-Kalender. Ausschließlich lesend. public final class EventKitSource: CalendarSource, @unchecked Sendable { @@ -13,6 +16,10 @@ public final class EventKitSource: CalendarSource, @unchecked Sendable { public init() {} + public var isAuthorized: Bool { + EKEventStore.authorizationStatus(for: .event) == .fullAccess + } + public func requestAccess() async -> Bool { switch EKEventStore.authorizationStatus(for: .event) { case .fullAccess: @@ -22,7 +29,15 @@ public final class EventKitSource: CalendarSource, @unchecked Sendable { case .notDetermined, .writeOnly: // `writeOnly` ist für uns wertlos — Onyx zeigt nur an. Der Nutzer // muss den Vollzugriff geben, sonst bleibt das Widget leer. - return (try? await store.requestFullAccessToEvents()) ?? false + do { + return try await store.requestFullAccessToEvents() + } catch { + // Nicht verschlucken: wenn EventKit hier wirft, erscheint gar + // kein Dialog und der Zustand bleibt `notDetermined` — von außen + // sieht das aus, als hätte die App nie gefragt. + log.error("requestFullAccessToEvents warf: \(error.localizedDescription, privacy: .public) — \(String(describing: error), privacy: .public)") + return false + } @unknown default: return false } diff --git a/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings index 763fbf2..75f06ea 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings @@ -1,62 +1,159 @@ { "sourceLanguage" : "en", "strings" : { - "widget.calendar.name" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Kalender" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Calendar" } } - } + "" : { + }, - "widget.calendar.noUpcoming" : { - "comment" : "Nur zeigen, wenn der Zeitraum wirklich abgedeckt ist", - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Nichts mehr für heute" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Nothing left today" } } - } + "%lld" : { + "comment" : "A day of the month. The argument is the day of the month.", + "isCommentAutoGenerated" : true }, "widget.calendar.allDay" : { "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Ganztägig" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "All day" } } + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ganztägig" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All day" + } + } } }, "widget.calendar.day.unknown" : { "comment" : "Erklärt einen ausgegrauten Tag: keine Information, nicht keine Termine", "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Für diesen Tag liegen keine Daten vor" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "No data available for this day" } } + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Für diesen Tag liegen keine Daten vor" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No data available for this day" + } + } } }, "widget.calendar.denied" : { "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Kein Kalenderzugriff.\nIn den Systemeinstellungen erlauben." } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "No calendar access.\nAllow it in System Settings." } } - } - }, - "widget.calendar.neverWritten" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Calendarr einmal öffnen,\ndamit Termine bereitstehen." } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Open Calendarr once\nso events become available." } } - } - }, - "widget.calendar.loggedOut" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "In Calendarr anmelden" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Sign in to Calendarr" } } + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Kalenderzugriff.\nIn den Systemeinstellungen erlauben." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No calendar access.\nAllow it in System Settings." + } + } } }, "widget.calendar.incompatible" : { "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Onyx ist zu alt für diese\nCalendarr-Version. Bitte aktualisieren." } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Onyx is too old for this\nCalendarr version. Please update." } } + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onyx ist zu alt für diese\nCalendarr-Version. Bitte aktualisieren." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onyx is too old for this\nCalendarr version. Please update." + } + } + } + }, + "widget.calendar.loggedOut" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In Calendarr anmelden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in to Calendarr" + } + } + } + }, + "widget.calendar.name" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kalender" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendar" + } + } + } + }, + "widget.calendar.neverWritten" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendarr einmal öffnen,\ndamit Termine bereitstehen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Calendarr once\nso events become available." + } + } + } + }, + "widget.calendar.noUpcoming" : { + "comment" : "Nur zeigen, wenn der Zeitraum wirklich abgedeckt ist", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nichts mehr für heute" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing left today" + } + } } }, "widget.calendar.unreadable" : { "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Termine nicht lesbar.\nKonfigurationsfehler." } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Events unreadable.\nConfiguration error." } } + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termine nicht lesbar.\nKonfigurationsfehler." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Events unreadable.\nConfiguration error." + } + } } } }, - "version" : "1.0" -} + "version" : "1.1" +} \ No newline at end of file diff --git a/project.yml b/project.yml index 7c34d75..3bdcd26 100644 --- a/project.yml +++ b/project.yml @@ -17,6 +17,12 @@ settings: # der mit Team-Zertifikat signierte Host sonst nicht laden darf. OTHER_CODE_SIGN_FLAGS: "--deep" SWIFT_STRICT_CONCURRENCY: complete + # Xcode lagert im Debug-Build den Programmcode in eine eigene dylib aus + # (für SwiftUI-Vorschauen). Das Hauptprogramm ist dann nur noch ein Rumpf — + # und TCC ordnet Berechtigungsanfragen dem Rumpf zu, der weder Info.plist + # noch Entitlements trägt. Ergebnis: requestFullAccessToEvents() liefert + # kommentarlos false, ohne dass je ein Dialog erscheint. + ENABLE_DEBUG_DYLIB: NO packages: OnyxKit: