Files
onyx/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift
Guido Schmit 40bd83e49e Berechtigungsdialog und Einstellungsfenster reparieren
Zwei Fehler, beide durch Messen gefunden statt durch Raten — und einer davon
war meine eigene fehlerhafte Diagnose: `log show` wurde von zsh abgefangen, ich
hatte stderr umgeleitet und die Fehlermeldung nie gesehen. Meine Aussage "keine
TCC-Einträge, also wurde nie gefragt" stützte sich damit auf einen Befehl, der
gar nicht lief.

Einstellungsfenster: SwiftUIs Settings-Szene wird über den privaten Selektor
showSettingsWindow: geöffnet, dessen Name sich zwischen macOS-Versionen schon
geändert hat und der bei .accessory-Apps ohne Menüleiste unzuverlässig ankommt.
Der Menüeintrag tat schlicht nichts. Jetzt ein eigenes NSWindow.

Kalenderberechtigung, zwei Ursachen übereinander:

Xcode lagert im Debug-Build den Programmcode in eine eigene dylib aus (für
SwiftUI-Vorschauen). Das Hauptprogramm ist dann nur ein Rumpf, und TCC ordnete
die Anfrage dem Rumpf zu — kTCCServiceCalendar erreichte tccd überhaupt nicht.
ENABLE_DEBUG_DYLIB = NO behebt das; danach erscheint die Anfrage im Protokoll.

Danach immer noch kein Dialog: promptType 1, aber promptPolicy 0. TCC bereitet
den Dialog vor und entscheidet sich dagegen, weil keine App im Vordergrund ist,
an die er gehören könnte. NSApp.activate davor hilft nicht — Aktivierung wirkt
asynchron und ist beim Aufruf noch nicht durch. Deshalb wird beim ersten Start
das Einstellungsfenster auf dem Reiter Berechtigungen geöffnet: dort steht ein
echtes Fenster im Vordergrund, und der Nutzer weiß, warum gefragt wird.

Folgefehler dabei: ohne Berechtigung zeigte das Widget "Nichts mehr für heute"
— eine Aussage, die es ohne Zugriff gar nicht treffen kann. refresh() fragt
jetzt nicht mehr selbst nach und meldet ohne Berechtigung ehrlich `denied`.

Fehler aus requestFullAccessToEvents werden nicht mehr mit try? verschluckt.
2026-08-10 18:20:21 +02:00

324 lines
11 KiB
Swift

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
public final class CalendarModel {
public private(set) var state: CalendarSourceState = .ready(.empty)
public private(set) var isLoading = false
private let source: CalendarSource
private var calendar: Calendar
private var refreshTask: Task<Void, Never>?
public init(source: CalendarSource, calendar: Calendar = .autoupdatingCurrent) {
self.source = source
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
}
/// Termine für den sichtbaren Monat plus Puffer nach beiden Seiten, damit
/// die Randtage der Nachbarmonate im Raster nicht fälschlich leer wirken.
public func refresh(around date: Date = Date()) {
refreshTask?.cancel()
refreshTask = Task { [weak self] in
guard let self else { return }
isLoading = true
defer { isLoading = false }
// 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
}
let start = calendar.date(byAdding: .day, value: -14,
to: calendar.startOfDay(for: date))!
let end = calendar.date(byAdding: .day, value: 60, to: start)!
guard !Task.isCancelled else { return }
state = await source.load(DateInterval(start: start, end: end))
}
}
public func stop() {
refreshTask?.cancel()
refreshTask = nil
}
public var upcoming: [OnyxEvent] {
guard let window else { return [] }
let now = Date()
return window.events
.filter { $0.end >= now }
.prefix(6)
.map { $0 }
}
}
// MARK: - Widget
public struct CalendarWidget: OnyxWidget {
public let id = "calendar"
public var displayName: String { String(localized: "widget.calendar.name", bundle: .module) }
public let symbolName = "calendar"
public let supportedSizes: [WidgetSize] = [.medium, .large]
private let model: CalendarModel
public init(model: CalendarModel) {
self.model = model
}
public func makeView(size: WidgetSize) -> AnyView {
AnyView(CalendarWidgetView(model: model, size: size))
}
}
private struct CalendarWidgetView: View {
let model: CalendarModel
let size: WidgetSize
var body: some View {
Group {
switch model.state {
case .ready(let window):
if size == .large {
LargeCalendar(window: window, upcoming: model.upcoming)
} else {
AgendaList(events: model.upcoming, coverage: window.coverage)
}
case .denied:
CalendarNotice(symbol: "lock", text: "widget.calendar.denied")
case .neverWritten:
CalendarNotice(symbol: "clock.arrow.circlepath", text: "widget.calendar.neverWritten")
case .loggedOut:
CalendarNotice(symbol: "person.crop.circle.badge.xmark", text: "widget.calendar.loggedOut")
case .incompatible:
CalendarNotice(symbol: "exclamationmark.triangle", text: "widget.calendar.incompatible")
case .unreadable:
CalendarNotice(symbol: "questionmark.folder", text: "widget.calendar.unreadable")
}
}
.task { model.refresh() }
.onDisappear { model.stop() }
}
}
/// Kein Platzhalter, sondern eine Antwort: jeder Zustand sagt, was zu tun ist.
private struct CalendarNotice: View {
let symbol: String
let text: LocalizedStringKey
var body: some View {
VStack(spacing: 6) {
Image(systemName: symbol)
.font(.system(size: 16))
.foregroundStyle(Onyx.Color.textTertiary)
Text(text, bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Mini-Monat
private struct LargeCalendar: View {
let window: EventWindow
let upcoming: [OnyxEvent]
private var calendar: Calendar { .autoupdatingCurrent }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
MiniMonth(window: window)
Divider().overlay(Onyx.Color.hairline)
AgendaList(events: Array(upcoming.prefix(3)), coverage: window.coverage)
}
}
}
private struct MiniMonth: View {
let window: EventWindow
private var calendar: Calendar { .autoupdatingCurrent }
var body: some View {
let grid = MonthGrid(containing: Date(), calendar: calendar)
let busy = window.events.busyDays(calendar: calendar)
let today = calendar.startOfDay(for: Date())
VStack(spacing: 3) {
HStack(spacing: 0) {
ForEach(weekdaySymbols, id: \.self) { symbol in
Text(symbol)
.font(.system(size: 8, weight: .medium))
.foregroundStyle(Onyx.Color.textTertiary)
.frame(maxWidth: .infinity)
}
}
ForEach(0..<(grid.days.count / 7), id: \.self) { week in
HStack(spacing: 0) {
ForEach(grid.days[(week * 7)..<(week * 7 + 7)]) { day in
DayCell(day: day,
status: grid.status(for: day.date,
coverage: window.coverage,
busyDays: busy),
isToday: day.date == today)
}
}
}
}
}
/// Wochentagskürzel ab dem eingestellten Wochenanfang.
private var weekdaySymbols: [String] {
let symbols = calendar.veryShortStandaloneWeekdaySymbols
let shift = calendar.firstWeekday - 1
return Array(symbols[shift...] + symbols[..<shift])
}
}
private struct DayCell: View {
let day: MonthDay
let status: DayStatus
let isToday: Bool
var body: some View {
ZStack {
if isToday {
Circle().fill(Onyx.Color.accent.opacity(0.9)).frame(width: 15, height: 15)
}
Text("\(Calendar.autoupdatingCurrent.component(.day, from: day.date))")
.font(.system(size: 9, weight: isToday ? .semibold : .regular))
.foregroundStyle(textColor)
}
.frame(maxWidth: .infinity)
.frame(height: 15)
.overlay(alignment: .bottom) {
// Ein Punkt heißt: hier ist etwas. Ein fehlender Punkt heißt nur
// dann nichts", wenn der Tag überhaupt abgedeckt ist deshalb
// wird `unknown` ausgegraut statt leer gelassen.
if status == .busy {
Circle()
.fill(isToday ? Color.white : Onyx.Color.accent)
.frame(width: 2.5, height: 2.5)
.offset(y: 2)
}
}
.opacity(status == .unknown ? 0.25 : 1)
.help(status == .unknown
? Text("widget.calendar.day.unknown", bundle: .module)
: Text(""))
}
private var textColor: Color {
if isToday { return .white }
return day.isInMonth ? Onyx.Color.textPrimary : Onyx.Color.textTertiary
}
}
// MARK: - Terminliste
private struct AgendaList: View {
let events: [OnyxEvent]
let coverage: DateInterval?
var body: some View {
if events.isEmpty {
Text("widget.calendar.noUpcoming", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
} else {
VStack(alignment: .leading, spacing: 5) {
ForEach(events) { event in
EventRow(event: event)
}
Spacer(minLength: 0)
}
}
}
}
private struct EventRow: View {
let event: OnyxEvent
var body: some View {
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
.fill(Color(hex: event.colorHex) ?? Onyx.Color.accent)
.frame(width: 3)
VStack(alignment: .leading, spacing: 1) {
Text(event.title)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
Text(timeText)
.font(.system(size: 9))
.monospacedDigit()
.foregroundStyle(Onyx.Color.textTertiary)
.lineLimit(1)
}
Spacer(minLength: 0)
}
.frame(height: 24)
}
private var timeText: String {
if event.isAllDay {
return String(localized: "widget.calendar.allDay", bundle: .module)
}
return event.start.formatted(date: .omitted, time: .shortened)
}
}
extension Color {
/// `#RRGGBB` aus einer fremden Quelle. Ungültige Werte ergeben `nil`, damit
/// die Aufrufstelle auf die Onyx-Akzentfarbe zurückfallen kann.
init?(hex: String?) {
guard let hex else { return nil }
var value = hex.trimmingCharacters(in: .whitespaces)
if value.hasPrefix("#") { value.removeFirst() }
guard value.count == 6, let number = UInt32(value, radix: 16) else { return nil }
self.init(red: Double((number >> 16) & 0xFF) / 255,
green: Double((number >> 8) & 0xFF) / 255,
blue: Double(number & 0xFF) / 255)
}
}