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.
111 lines
4.6 KiB
Swift
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)))
|
|
}
|
|
}
|