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.
119 lines
4.9 KiB
Swift
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)))
|
|
}
|
|
}
|