Files
onyx/Packages/OnyxKit/Sources/CalendarProvider/EventKitSource.swift
Guido Schmit 873c763d50 Kalenderberechtigung: Anfrage aus dem Vordergrundfenster
TCC zeigt den Dialog nur, wenn eine App im Vordergrund ist, an die er gehören
kann. Bei einer App ohne Fenster verpufft die Anfrage kommentarlos: sie kommt
mit false zurück, der Status bleibt notDetermined, und in den
Systemeinstellungen taucht die App nie auf — dort lässt sich auch nichts von
Hand hinzufügen, Einträge entstehen ausschließlich durch eine echte Anfrage.

Deshalb fragt der Reiter Berechtigungen jetzt selbst, sobald er sichtbar ist,
mit 400 ms Vorlauf: Aktivierung wirkt asynchron, ohne die Pause fragt Onyx
während es formal noch im Hintergrund liegt.

CalendarAuthorization trennt undetermined von denied. Im ersten Fall lohnt eine
Anfrage, im zweiten zeigt macOS nie wieder einen Dialog und es hilft nur der
Weg über die Systemeinstellungen — dorthin führt der Knopf dann auch.

Korrektur einer eigenen Fehldiagnose: promptPolicy = 0 im tccd-Protokoll gehört
zu kTCCServiceReminders, nicht zum Kalender. Der bekommt durchgehend
promptPolicy = 4, Nachfragen war also die ganze Zeit erlaubt. Die tatsächlichen
Ursachen waren die ausgelagerte Debug-dylib und ein verklemmter TCC-Eintrag.
2026-08-10 18:26:10 +02:00

119 lines
4.9 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 authorization: CalendarAuthorization {
switch EKEventStore.authorizationStatus(for: .event) {
case .fullAccess: .granted
case .denied, .restricted: .denied
// `writeOnly` ist für uns so gut wie nichts Onyx zeigt nur an.
case .notDetermined, .writeOnly: .undetermined
@unknown default: .denied
}
}
public var isAuthorized: Bool { authorization == .granted }
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)))
}
}