Berechtigungen zeigen ihren Zustand live, Dock-Symbol wird wählbar

Die Einstellungen zeigten weiter "Anfragen", obwohl die Ortung längst lief:
authorizationStatus ist eine Abfrage, keine Beobachtung, und ich hatte sie
einmalig beim Öffnen gelesen. Jetzt meldet locationManagerDidChangeAuthorization
die Änderung, WeatherModel führt daraus einen beobachtbaren Wert, und die
Einstellungen aktualisieren sich, während das Fenster offen steht. Nach der
Zustimmung wird sofort geladen statt beim nächsten Anlauf.

Der Knopf "Systemeinstellungen öffnen" ganz unten zeigte fest auf den
Kalenderbereich — wer ihn beim Standort drückte, landete am falschen Ort. Es
gibt ihn nicht mehr; stattdessen führt jede Berechtigung in ihren eigenen
Bereich, und der Knopf erscheint nur im Zustand "abgelehnt". Bei "noch nie
gefragt" steht dort Anfragen, bei "erteilt" gar nichts: drei Zustände, drei
Antworten. Genau daran ist die erste Fassung gescheitert.

Dock-Symbol ist jetzt eine Einstellung, standardmäßig aus. Onyx läuft als
LSUIElement — das war Absicht und keine Auslassung: es hat kein Hauptfenster
und wäre im Dock ein Symbol, das beim Anklicken nichts öffnet, und es stünde im
Programmumschalter zwischen den Programmen, mit denen man arbeitet. Wer es
trotzdem will, schaltet es ein; die Wahl wird beim Start wieder angewendet.
This commit is contained in:
Guido Schmit
2026-08-10 21:24:49 +02:00
parent 7f6ded7de0
commit c0874c83b7
6 changed files with 503 additions and 398 deletions

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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(

View File

@@ -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)
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) }
}
private func openPrivacySettings() {
guard let url = URL(string:
"x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") else { return }
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()

View File

@@ -15,6 +15,8 @@ final class LocationProvider: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var pending: [CheckedContinuation<CLLocation?, Never>] = []
/// 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 }

View File

@@ -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.