Files
onyx/Packages/OnyxKit/Sources/CalendarProvider/EventKitSource.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

111 lines
4.6 KiB
Swift

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 {
public let id = "eventkit"
private let store = EKEventStore()
/// Kalender, die der Nutzer ausgeblendet hat. Leer heißt: alle anzeigen.
public var excludedCalendarIDs: Set<String> = []
public init() {}
public var isAuthorized: Bool {
EKEventStore.authorizationStatus(for: .event) == .fullAccess
}
public func requestAccess() async -> Bool {
switch EKEventStore.authorizationStatus(for: .event) {
case .fullAccess:
return true
case .denied, .restricted:
return false
case .notDetermined, .writeOnly:
// `writeOnly` ist für uns wertlos Onyx zeigt nur an. Der Nutzer
// muss den Vollzugriff geben, sonst bleibt das Widget leer.
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
}
}
public func calendars() async -> [OnyxCalendar] {
guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { return [] }
return store.calendars(for: .event).map {
OnyxCalendar(id: $0.calendarIdentifier,
title: $0.title,
colorHex: $0.cgColor.map(Self.hex(from:)))
}
}
public func load(_ interval: DateInterval) async -> CalendarSourceState {
guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else {
return .denied
}
let calendars = store.calendars(for: .event)
.filter { !excludedCalendarIDs.contains($0.calendarIdentifier) }
// Ohne Kalender liefert EventKit alle statt keiner der Unterschied
// zwischen nichts ausgewählt" und nicht gefiltert" geht sonst verloren.
guard !calendars.isEmpty else {
return .ready(EventWindow(events: [], coverage: interval))
}
let predicate = store.predicateForEvents(withStart: interval.start,
end: interval.end,
calendars: calendars)
let events = store.events(matching: predicate).map(Self.convert)
// EventKit wird direkt abgefragt: was hier zurückkommt, ist vollständig.
// Die Abdeckung ist deshalb genau der angefragte Zeitraum anders als
// bei Calendarr, das aus einem begrenzten Zwischenspeicher liest.
return .ready(EventWindow(events: events.sorted { $0.start < $1.start },
coverage: interval,
asOf: Date()))
}
// MARK: - Umwandlung
private static func convert(_ event: EKEvent) -> OnyxEvent {
OnyxEvent(
// `eventIdentifier` ist bei Serienterminen für alle Vorkommen
// gleich. Zusammen mit dem Beginn wird daraus eine eindeutige
// Kennung sonst kollabiert eine Terminliste mit einer täglichen
// Serie auf einen einzigen Eintrag.
id: "\(event.eventIdentifier ?? UUID().uuidString)@\(event.startDate.timeIntervalSince1970)",
title: event.title ?? "",
start: event.startDate,
end: event.endDate,
isAllDay: event.isAllDay,
colorHex: event.calendar.cgColor.map(hex(from:)),
location: event.location?.isEmpty == false ? event.location : nil,
calendarID: event.calendar.calendarIdentifier,
calendarTitle: event.calendar.title)
}
private static func hex(from color: CGColor) -> String {
guard let converted = NSColor(cgColor: color)?.usingColorSpace(.sRGB) else {
return "#8E8E93"
}
return String(format: "#%02X%02X%02X",
Int(round(converted.redComponent * 255)),
Int(round(converted.greenComponent * 255)),
Int(round(converted.blueComponent * 255)))
}
}