diff --git a/Onyx/AppModel.swift b/Onyx/AppModel.swift index 0d91b83..0dbf7c1 100644 --- a/Onyx/AppModel.swift +++ b/Onyx/AppModel.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppKit import OnyxNotch import OnyxWidgetKit @@ -39,9 +40,24 @@ final class AppModel { didSet { defaults.set(showsMenuBarIcon, forKey: Keys.showsMenuBarIcon) } } + /// Ob Onyx im Dock auftaucht. + /// + /// Standardmäßig nicht: Onyx ist ein Werkzeug, das in der Notch und in der + /// Menüleiste lebt, hat kein Hauptfenster und wäre im Dock ein Symbol, das + /// beim Anklicken nichts öffnet. Es taucht dann auch im Programmumschalter + /// auf und steht dort zwischen den Programmen, mit denen man arbeitet. + var showsDockIcon: Bool { + didSet { + guard showsDockIcon != oldValue else { return } + defaults.set(showsDockIcon, forKey: Keys.showsDockIcon) + NSApp.setActivationPolicy(showsDockIcon ? .regular : .accessory) + } + } + private enum Keys { static let displayPolicy = "onyx.displayPolicy" static let showsMenuBarIcon = "onyx.showsMenuBarIcon" + static let showsDockIcon = "onyx.showsDockIcon" } init(store: LayoutStore = .standard(), defaults: UserDefaults = .standard) { @@ -57,6 +73,7 @@ final class AppModel { // Standardmäßig sichtbar: das Menüleistensymbol ist der einzige Weg // zurück, wenn die Notch einmal nicht reagiert. self.showsMenuBarIcon = defaults.object(forKey: Keys.showsMenuBarIcon) as? Bool ?? true + self.showsDockIcon = defaults.object(forKey: Keys.showsDockIcon) as? Bool ?? false } // MARK: - Widgets diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index 4eccdd7..9c1d777 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -1,491 +1,523 @@ { - "sourceLanguage" : "en", - "strings" : { - "menu.openPanel" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Panel öffnen" + "sourceLanguage": "en", + "strings": { + "menu.openPanel": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Panel öffnen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open Panel" + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Panel" } } } }, - "menu.quit" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx beenden" + "menu.quit": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx beenden" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Quit Onyx" + "en": { + "stringUnit": { + "state": "translated", + "value": "Quit Onyx" } } } }, - "menu.settings" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Einstellungen …" + "menu.settings": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen …" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Settings…" + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings…" } } } }, - "settings.display.all" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Allen Displays" + "settings.display.all": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Allen Displays" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "All displays" + "en": { + "stringUnit": { + "state": "translated", + "value": "All displays" } } } }, - "settings.display.builtInOnly" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nur dem internen Display" + "settings.display.builtInOnly": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur dem internen Display" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Built-in display only" + "en": { + "stringUnit": { + "state": "translated", + "value": "Built-in display only" } } } }, - "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." + "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." + "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.display.menuBarIcon" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Symbol in der Menüleiste anzeigen" + "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" + "en": { + "stringUnit": { + "state": "translated", + "value": "Show icon in the menu bar" } } } }, - "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." + "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." + "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.display.policy" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Panel anzeigen auf" + "settings.display.policy": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Panel anzeigen auf" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show panel on" + "en": { + "stringUnit": { + "state": "translated", + "value": "Show panel on" } } } }, - "settings.permissions.calendar" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kalender" + "settings.permissions.calendar": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalender" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calendar" + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendar" } } } }, - "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." + "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." + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx shows your events in the calendar widget. It only ever reads, never writes." } } } }, - "settings.permissions.granted" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Erteilt" + "settings.permissions.granted": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erteilt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Granted" + "en": { + "stringUnit": { + "state": "translated", + "value": "Granted" } } } }, - "settings.permissions.location" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Standort" + "settings.permissions.location": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Standort" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Location" + "en": { + "stringUnit": { + "state": "translated", + "value": "Location" } } } }, - "settings.permissions.location.why" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx zeigt das Wetter an deinem aktuellen Standort. Ohne Zugriff kannst du stattdessen einen festen Ort wählen." + "settings.permissions.location.why": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx zeigt das Wetter an deinem aktuellen Standort. Ohne Zugriff kannst du stattdessen einen festen Ort wählen." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx shows the weather for your current location. Without access you can pick a fixed place instead." + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx shows the weather for your current location. Without access you can pick a fixed place instead." } } } }, - "settings.permissions.openSystem" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Systemeinstellungen öffnen" + "settings.permissions.openSystem": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemeinstellungen öffnen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open System Settings" + "en": { + "stringUnit": { + "state": "translated", + "value": "Open System Settings" } } } }, - "settings.permissions.request" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anfragen" + "settings.permissions.request": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anfragen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Request" + "en": { + "stringUnit": { + "state": "translated", + "value": "Request" } } } }, - "settings.size.large" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Groß (2×2)" + "settings.size.large": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Groß (2×2)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Large (2×2)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Large (2×2)" } } } }, - "settings.size.medium" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mittel (2×1)" + "settings.size.medium": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mittel (2×1)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Medium (2×1)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Medium (2×1)" } } } }, - "settings.size.small" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Klein (1×1)" + "settings.size.small": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Klein (1×1)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Small (1×1)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Small (1×1)" } } } }, - "settings.size.wide" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Breit (4×1)" + "settings.size.wide": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Breit (4×1)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wide (4×1)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Wide (4×1)" } } } }, - "settings.tab.display" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anzeige" + "settings.tab.display": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeige" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Display" + "en": { + "stringUnit": { + "state": "translated", + "value": "Display" } } } }, - "settings.tab.permissions" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Berechtigungen" + "settings.tab.permissions": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Berechtigungen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Permissions" + "en": { + "stringUnit": { + "state": "translated", + "value": "Permissions" } } } }, - "settings.tab.widgets" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Widgets" + "settings.tab.widgets": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Widgets" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Widgets" + "en": { + "stringUnit": { + "state": "translated", + "value": "Widgets" } } } }, - "settings.widgets.add" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hinzufügen" + "settings.widgets.add": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hinzufügen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Add" + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" } } } }, - "settings.widgets.available" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verfügbar" + "settings.widgets.available": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verfügbar" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Available" + "en": { + "stringUnit": { + "state": "translated", + "value": "Available" } } } }, - "settings.widgets.empty" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Widgets im Panel. Das Panel bleibt leer, bis du eins hinzufügst." + "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." + "en": { + "stringUnit": { + "state": "translated", + "value": "No widgets in the panel. It stays empty until you add one." } } } }, - "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." + "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." + "en": { + "stringUnit": { + "state": "translated", + "value": "The order determines the arrangement in the panel. Each widget takes the first spot it fits into." } } } }, - "settings.widgets.inPanel" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Im Panel" + "settings.widgets.inPanel": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Im Panel" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "In the panel" + "en": { + "stringUnit": { + "state": "translated", + "value": "In the panel" } } } }, - "settings.widgets.remove" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aus dem Panel entfernen" + "settings.widgets.remove": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus dem Panel entfernen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Remove from panel" + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove from panel" + } + } + } + }, + "settings.display.dockIcon": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Symbol im Dock anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show icon in the Dock" + } + } + } + }, + "settings.display.dockIcon.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Standardmäßig aus. Onyx lebt in der Notch und in der Menüleiste und hat kein Hauptfenster — im Dock wäre es ein Symbol, das beim Anklicken nichts öffnet, und es stünde im Programmumschalter zwischen den Programmen, mit denen du arbeitest." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Off by default. Onyx lives in the notch and the menu bar and has no main window — in the Dock it would be an icon that opens nothing, and it would sit in the app switcher among the apps you actually work with." } } } } }, - "version" : "1.0" + "version": "1.0" } \ No newline at end of file diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index 7d31cfa..6832fe6 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -52,6 +52,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let model = AppModel() self.model = model + // Die gespeicherte Wahl gleich anwenden — sonst steht Onyx nach einem + // Neustart wieder ohne Dock-Symbol da, obwohl es eingeschaltet ist. + NSApp.setActivationPolicy(model.showsDockIcon ? .regular : .accessory) let coordinator = NotchCoordinator(policy: model.displayPolicy) { _, presentation in NotchHostingView( diff --git a/Onyx/SettingsView.swift b/Onyx/SettingsView.swift index b3103cc..3b27948 100644 --- a/Onyx/SettingsView.swift +++ b/Onyx/SettingsView.swift @@ -42,96 +42,108 @@ struct SettingsView: View { private struct PermissionSettings: View { let calendarModel: CalendarModel let weatherModel: WeatherModel - @State private var granted = false - @State private var locationGranted = false + @State private var calendarGranted = false + + private var locationState: PermissionRow.State { + switch weatherModel.locationAuthorization { + case .granted: .granted + case .denied: .denied + case .undetermined: .undetermined + } + } 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) + PermissionRow( + title: "settings.permissions.calendar", + symbol: "calendar", + state: calendarGranted ? .granted + : (calendarModel.authorization == .denied ? .denied : .undetermined), + explanation: "settings.permissions.calendar.why", + pane: .calendar, + request: { calendarGranted = await calendarModel.requestAccessNow() }) } Section { - HStack { - Label("settings.permissions.location", systemImage: "location") - Spacer() - if locationGranted { - Label("settings.permissions.granted", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - .labelStyle(.titleAndIcon) - } else if weatherModel.locationIsDenied { - Button("settings.permissions.openSystem") { openLocationSettings() } - } else { - Button("settings.permissions.request") { - // Muss aus diesem Fenster kommen: macOS zeigt den - // Dialog nur, wenn eine App im Vordergrund ist. - weatherModel.requestLocationAuthorization() - } - } - } - Text("settings.permissions.location.why") - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Section { - Button("settings.permissions.openSystem") { openPrivacySettings() } + PermissionRow( + title: "settings.permissions.location", + symbol: "location", + // Direkt aus dem beobachtbaren Wert: der Zustand ändert + // sich, während dieses Fenster offen steht. + state: locationState, + explanation: "settings.permissions.location.why", + pane: .location, + request: { weatherModel.requestLocationAuthorization() }) } } .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. + calendarGranted = calendarModel.hasAccess + guard !calendarGranted, calendarModel.authorization == .undetermined else { return } + // Kurz warten, bis das Fenster wirklich vorne steht: TCC zeigt den + // Dialog nur, wenn eine App im Vordergrund ist, und Aktivierung + // wirkt asynchron. try? await Task.sleep(for: .milliseconds(400)) - granted = await calendarModel.requestAccessNow() + calendarGranted = await calendarModel.requestAccessNow() + } + } +} + +/// Eine Berechtigung mit ihrem Zustand und dem Weg, der von dort weiterführt. +/// +/// Die drei Zustände brauchen drei verschiedene Antworten, und genau daran ist +/// die erste Fassung gescheitert: „noch nie gefragt" lässt sich mit einer +/// Anfrage lösen, „abgelehnt" nur über die Systemeinstellungen, und „erteilt" +/// braucht gar nichts. +private struct PermissionRow: View { + enum State { case granted, denied, undetermined } + enum Pane { + case calendar, location + + /// Jede Berechtigung führt in ihren eigenen Bereich. Ein gemeinsamer + /// Knopf, der immer im Kalender landet, ist schlimmer als keiner. + var url: URL? { + switch self { + case .calendar: + URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") + case .location: + URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") + } } } - @State private var didAutoRequest = false + let title: LocalizedStringKey + let symbol: String + let state: State + let explanation: LocalizedStringKey + let pane: Pane + let request: () async -> Void - private func openLocationSettings() { - guard let url = URL(string: - "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") else { return } - NSWorkspace.shared.open(url) - } - - private func openPrivacySettings() { - guard let url = URL(string: - "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") else { return } - NSWorkspace.shared.open(url) + var body: some View { + HStack { + Label(title, systemImage: symbol) + Spacer() + switch state { + case .granted: + Label("settings.permissions.granted", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .labelStyle(.titleAndIcon) + case .undetermined: + Button("settings.permissions.request") { Task { await request() } } + case .denied: + // macOS fragt nach einer Ablehnung nicht mehr — ein + // Anfragen-Knopf wäre hier eine Lüge. + Button("settings.permissions.openSystem") { + if let url = pane.url { NSWorkspace.shared.open(url) } + } + } + } + Text(explanation) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } } @@ -243,6 +255,14 @@ private struct DisplaySettings: View { .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) + + Divider() + + Toggle("settings.display.dockIcon", isOn: $model.showsDockIcon) + Text("settings.display.dockIcon.hint") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } .formStyle(.grouped) .padding() diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherKitSource.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherKitSource.swift index 11d7519..875da54 100644 --- a/Packages/OnyxKit/Sources/WeatherProvider/WeatherKitSource.swift +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherKitSource.swift @@ -15,6 +15,8 @@ final class LocationProvider: NSObject, CLLocationManagerDelegate { private let manager = CLLocationManager() private var pending: [CheckedContinuation] = [] + /// Wird gerufen, wenn der Nutzer die Erlaubnis erteilt oder entzieht. + var onAuthorizationChange: (() -> Void)? override init() { super.init() @@ -59,6 +61,14 @@ final class LocationProvider: NSObject, CLLocationManagerDelegate { Task { @MainActor in self.resume(with: locations.last) } } + /// Ohne diesen Rückruf merkt niemand, dass der Nutzer gerade zugestimmt + /// hat: `authorizationStatus` ist eine Abfrage, keine Beobachtung. Die + /// Einstellungen zeigten deshalb weiter „Anfragen", obwohl die Ortung + /// längst lief. + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + Task { @MainActor in self.onAuthorizationChange?() } + } + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { Task { @MainActor in @@ -78,6 +88,12 @@ public final class WeatherKitSource { public init() {} + /// Weiterreichen, damit das Modell einen beobachtbaren Wert daraus machen kann. + public var onLocationAuthorizationChange: (() -> Void)? { + get { location.onAuthorizationChange } + set { location.onAuthorizationChange = newValue } + } + public var locationIsDenied: Bool { location.isDenied } public var locationIsUndetermined: Bool { location.isUndetermined } public var locationIsAuthorized: Bool { !location.isDenied && !location.isUndetermined } diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift index efbd363..14ca6fe 100644 --- a/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift @@ -3,12 +3,21 @@ import AppKit import OnyxDesign import OnyxWidgetKit +/// Der Berechtigungszustand der Ortung — als beobachtbarer Wert. +public enum LocationAuthorization: Equatable, Sendable { + case granted, denied, undetermined +} + /// Hält den Wetterstand für das Widget. @MainActor @Observable public final class WeatherModel { public private(set) var state: WeatherState = .idle + /// Beobachtbar, damit die Einstellungen mitbekommen, wenn der Nutzer + /// zustimmt. Eine Abfrage auf `CLLocationManager` täte das nicht — sie + /// liefert nur den Wert zum Zeitpunkt des Lesens. + public private(set) var locationAuthorization: LocationAuthorization = .undetermined public var place: WeatherPlace { didSet { guard place != oldValue else { return }; load(force: true) } } @@ -20,22 +29,30 @@ public final class WeatherModel { public init(source: WeatherKitSource = WeatherKitSource(), place: WeatherPlace = .current) { self.source = source self.place = place + syncAuthorization() + source.onLocationAuthorizationChange = { [weak self] in + guard let self else { return } + syncAuthorization() + // Nach der Zustimmung sofort laden statt auf den nächsten Anlauf + // zu warten. + if locationAuthorization == .granted { load(force: true) } + } } - public var locationIsDenied: Bool { source.locationIsDenied } - public var locationIsUndetermined: Bool { source.locationIsUndetermined } - public var locationIsAuthorized: Bool { source.locationIsAuthorized } + private func syncAuthorization() { + locationAuthorization = if source.locationIsDenied { .denied } + else if source.locationIsUndetermined { .undetermined } + else { .granted } + } - /// Fragt die Ortungsberechtigung an und lädt danach. + public var locationIsDenied: Bool { locationAuthorization == .denied } + public var locationIsUndetermined: Bool { locationAuthorization == .undetermined } + public var locationIsAuthorized: Bool { locationAuthorization == .granted } + + /// Fragt die Ortungsberechtigung an. Das Ergebnis kommt über den + /// Delegaten zurück und aktualisiert `locationAuthorization` von selbst. public func requestLocationAuthorization() { source.requestLocationAuthorization() - // Die Entscheidung fällt asynchron. Kurz warten und dann laden — die - // Alternative wäre ein Delegat quer durch drei Schichten für einen - // Vorgang, der einmal im Leben der App passiert. - Task { [weak self] in - try? await Task.sleep(for: .seconds(2)) - self?.load(force: true) - } } /// Lädt, wenn der Zwischenspeicher abgelaufen ist.