import Foundation import Security import OSLog private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Calendarr") /// Liest die Termine **direkt vom Calendarr-Server**. /// /// Der andere Weg — der Schnappschuss im gemeinsamen Ordner — hängt daran, dass /// Calendarr läuft und schreibt. Wer die Mac-App wochenlang nicht öffnet, sieht /// wochenalte Termine, und Onyx kann nichts dafür und nichts dagegen. /// /// Hier holt Onyx selbst. Ein einziger Aufruf genügt: `/api/caldav/events` fasst /// CalDAV-Konten, lokale Kalender, Geburtstage, iCal-Abos und Google bereits /// zusammen — nachgebaut wird nichts. public final class CalendarrLiveSource: CalendarSource { public let id = "calendarr.live" /// Keine Systemberechtigung im Spiel: hier entscheidet allein, ob /// Zugangsdaten hinterlegt sind. public var authorization: CalendarAuthorization { .notRequired } public var isAuthorized: Bool { store.account != nil } private let store: CalendarrCredentials private let session: URLSession private let tokens: TokenCache public init(store: CalendarrCredentials = CalendarrCredentials(), session: URLSession = .shared) { self.store = store self.session = session self.tokens = TokenCache() } public func requestAccess() async -> Bool { store.account != nil } /// Die Kalender kommen aus den Terminen selbst. /// /// Der Server hat keinen Endpunkt, der alle Quellen zusammen aufzählt — die /// gibt es je Art einzeln. Aus den Terminen abgeleitet stimmt die Liste /// dagegen immer mit dem überein, was tatsächlich zu sehen ist. Der Preis: /// ein Kalender ohne Termine im Fenster taucht nicht auf. Das ist der /// bessere von zwei Fehlern — man kann nichts ausblenden, was ohnehin /// nichts zeigt. public func calendars() async -> [OnyxCalendar] { guard case .ready(let window) = await load(Self.defaultWindow()) else { return [] } var seen: [String: OnyxCalendar] = [:] for event in window.events where seen[event.calendarID] == nil { seen[event.calendarID] = OnyxCalendar(id: event.calendarID, title: event.calendarTitle, colorHex: event.colorHex) } return seen.values.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } } private static func defaultWindow() -> DateInterval { let now = Date() return DateInterval(start: now.addingTimeInterval(-14 * 86400), end: now.addingTimeInterval(46 * 86400)) } public func load(_ interval: DateInterval) async -> CalendarSourceState { guard let account = store.account, let password = store.password() else { return .neverWritten } do { let token = try await tokens.token(for: account, password: password, session: session) let data: Data do { data = try await fetchEvents(interval, account: account, token: token) } catch CalendarrLiveError.unauthorized { // Abgelaufene Token sind der Normalfall, nicht die Ausnahme: // sie halten sieben Tage. Einmal neu anmelden und noch einmal // versuchen — scheitert auch das, stimmt etwas anderes nicht. await tokens.discard() let fresh = try await tokens.token(for: account, password: password, session: session) data = try await fetchEvents(interval, account: account, token: fresh) } let events = try CalendarrLiveDecoder.events(from: data) // Die Abdeckung ist genau das erfragte Fenster: mehr wurde nicht // gefragt, weniger hat der Server nicht geliefert. return .ready(EventWindow(events: events, coverage: interval, asOf: Date())) } catch CalendarrLiveError.unauthorized { return .loggedOut(asOf: nil) } catch { log.error("Calendarr-Server: \(error.localizedDescription, privacy: .public)") return .unreadable(reason: error.localizedDescription) } } private func fetchEvents(_ interval: DateInterval, account: CalendarrAccount, token: String) async throws -> Data { var request = URLRequest(url: try CalendarrLiveDecoder.eventsURL(server: account.server, interval: interval)) request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") request.timeoutInterval = 15 let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw CalendarrLiveError.badResponse } if http.statusCode == 401 || http.statusCode == 403 { throw CalendarrLiveError.unauthorized } guard http.statusCode == 200 else { throw CalendarrLiveError.status(http.statusCode) } return data } /// Prüft die Zugangsdaten, ohne etwas zu speichern — für den Knopf in den /// Einstellungen. Ein Feld, das man ausfüllt und bei dem man erst am /// nächsten Tag merkt, dass ein Tippfehler drin war, ist keins. public static func check(server: URL, username: String, password: String, session: URLSession = .shared) async -> Result { do { _ = try await TokenCache.fetchToken(server: server, username: username, password: password, session: session) return .success(()) } catch { return .failure(error) } } } // MARK: - Token /// Hält das Token, solange es gilt. /// /// Ein Actor, weil mehrere Widgets gleichzeitig laden können und sich sonst /// zwei Anmeldungen überholen. private actor TokenCache { private var token: String? private var expiry: Date? func discard() { token = nil; expiry = nil } func token(for account: CalendarrAccount, password: String, session: URLSession) async throws -> String { // Fünf Minuten Sicherheitsabstand: ein Token, das während des Abrufs // abläuft, kostet einen zweiten Durchgang. if let token, let expiry, expiry.timeIntervalSinceNow > 300 { return token } let fresh = try await Self.fetchToken(server: account.server, username: account.username, password: password, session: session) token = fresh expiry = CalendarrLiveDecoder.expiry(ofToken: fresh) ?? Date().addingTimeInterval(6 * 24 * 3600) return fresh } static func fetchToken(server: URL, username: String, password: String, session: URLSession) async throws -> String { var request = URLRequest(url: server.appendingPathComponent("api/auth/token")) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpBody = CalendarrLiveDecoder.formBody(username: username, password: password) request.timeoutInterval = 15 let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw CalendarrLiveError.badResponse } if http.statusCode == 401 { throw CalendarrLiveError.unauthorized } guard http.statusCode == 200 else { throw CalendarrLiveError.status(http.statusCode) } guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let token = object["access_token"] as? String, !token.isEmpty else { throw CalendarrLiveError.badResponse } return token } } public enum CalendarrLiveError: LocalizedError, Equatable { case unauthorized case badResponse case status(Int) case badServer public var errorDescription: String? { switch self { case .unauthorized: String(localized: "calendarr.live.error.credentials", bundle: .module) case .badResponse: String(localized: "calendarr.live.error.response", bundle: .module) case .status(let code): String(localized: "calendarr.live.error.status \(code)", bundle: .module) case .badServer: String(localized: "calendarr.live.error.server", bundle: .module) } } } // MARK: - Auswertung /// Alles, was ohne Netz prüfbar ist — und deshalb auch ohne Netz geprüft wird. public enum CalendarrLiveDecoder { public static func eventsURL(server: URL, interval: DateInterval) throws -> URL { guard var components = URLComponents(url: server.appendingPathComponent("api/caldav/events"), resolvingAgainstBaseURL: false) else { throw CalendarrLiveError.badServer } let formatter = ISO8601DateFormatter() components.queryItems = [ URLQueryItem(name: "start", value: formatter.string(from: interval.start)), URLQueryItem(name: "end", value: formatter.string(from: interval.end)), ] guard let url = components.url else { throw CalendarrLiveError.badServer } return url } /// Der Anmeldekörper. Prozentkodiert von Hand, weil `+` in einem Passwort /// sonst als Leerzeichen ankommt — ein Fehler, den man erst bei genau /// diesem einen Passwort bemerkt. public static func formBody(username: String, password: String) -> Data { var allowed = CharacterSet.alphanumerics allowed.insert(charactersIn: "-._~") func escape(_ text: String) -> String { text.addingPercentEncoding(withAllowedCharacters: allowed) ?? text } return Data("username=\(escape(username))&password=\(escape(password))".utf8) } public static func events(from data: Data) throws -> [OnyxEvent] { guard let object = try? JSONSerialization.jsonObject(with: data), let list = (object as? [String: Any])?["events"] as? [[String: Any]] ?? object as? [[String: Any]] else { throw CalendarrLiveError.badResponse } return list.compactMap(event(from:)) .sorted { $0.start < $1.start } } static func event(from item: [String: Any]) -> OnyxEvent? { guard let title = item["title"] as? String, let startText = item["start"] as? String, let start = date(from: startText) else { return nil } let isAllDay = item["allDay"] as? Bool ?? (item["all_day"] as? Bool ?? false) let end = (item["end"] as? String).flatMap(date(from:)) // Ohne Ende ist ein Termin einen Tag lang, wenn er ganztägig ist, // sonst eine Stunde. Beides ist geraten — aber ein Termin ohne // Länge wäre unsichtbar. ?? start.addingTimeInterval(isAllDay ? 86400 : 3600) let identifier = item["id"] as? String ?? item["uid"] as? String ?? "\(startText)#\(title)" return OnyxEvent( id: identifier, title: title, start: start, end: end, isAllDay: isAllDay, // Die Farbe des Termins schlägt die des Kalenders — so macht es // Calendarr selbst auch. colorHex: item["color"] as? String ?? item["calendarColor"] as? String, location: (item["location"] as? String).flatMap { $0.isEmpty ? nil : $0 }, calendarID: item["calendar_id"] as? String ?? "calendarr", calendarTitle: item["calendar_name"] as? String ?? "Calendarr") } /// Ganztägige Termine kommen als `2026-08-14`, alle anderen mit Uhrzeit. public static func date(from text: String) -> Date? { if text.count == 10 { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = .current let parts = text.split(separator: "-").compactMap { Int($0) } guard parts.count == 3 else { return nil } return calendar.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2])) } let withFraction = ISO8601DateFormatter() withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = withFraction.date(from: text) { return date } let plain = ISO8601DateFormatter() if let date = plain.date(from: text) { return date } // Ohne Zeitzone im Text: dann gilt die des Rechners. Calendarr liefert // lokale Zeiten für Termine ohne feste Zone. let local = DateFormatter() local.locale = Locale(identifier: "en_US_POSIX") local.timeZone = .current for format in ["yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd HH:mm:ss"] { local.dateFormat = format if let date = local.date(from: text) { return date } } return nil } /// Wann das Token abläuft — steht in ihm selbst. /// /// Ein JWT trägt seine Gültigkeit im Mittelteil, base64-kodiert. Die /// Alternative wäre, sieben Tage anzunehmen; das steht heute so im Server /// und morgen vielleicht nicht mehr. public static func expiry(ofToken token: String) -> Date? { let parts = token.split(separator: ".") guard parts.count == 3 else { return nil } var payload = String(parts[1]) .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") // Base64 ohne Auffüllzeichen, wie in JWT üblich. while payload.count % 4 != 0 { payload += "=" } guard let data = Data(base64Encoded: payload), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let seconds = object["exp"] as? Double else { return nil } return Date(timeIntervalSince1970: seconds) } /// Nimmt entgegen, was Menschen eintippen: „calendarr.example.com", /// „https://calendarr.example.com/" oder mit Pfad dahinter. public static func server(from text: String) -> URL? { var trimmed = text.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return nil } if !trimmed.contains("://") { trimmed = "https://" + trimmed } while trimmed.hasSuffix("/") { trimmed.removeLast() } guard let url = URL(string: trimmed), url.host != nil else { return nil } return url } } // MARK: - Zugangsdaten /// Adresse und Benutzername in den Einstellungen, das Passwort im Schlüsselbund. /// /// Getrennt, weil es zwei verschiedene Dinge sind: das eine will man sehen und /// nachlesen können, das andere darf nirgends stehen, wo man es versehentlich /// mitliest — und `defaults read` ist genau so eine Stelle. public struct CalendarrCredentials: @unchecked Sendable { /// `UserDefaults` gilt Swift nicht als übertragbar, ist aber seit jeher /// threadsicher. Die Ausnahme steht hier und nicht als Sperre um jeden /// Zugriff. private let defaults: UserDefaults private static let service = "com.scarriffleservices.onyx.calendarr" private enum Keys { static let server = "calendarr.live.server" static let username = "calendarr.live.username" } public init(defaults: UserDefaults = .standard) { self.defaults = defaults } public var account: CalendarrAccount? { guard let server = defaults.string(forKey: Keys.server).flatMap(URL.init(string:)), let username = defaults.string(forKey: Keys.username), !username.isEmpty else { return nil } return CalendarrAccount(server: server, username: username) } public func save(server: URL, username: String, password: String) { defaults.set(server.absoluteString, forKey: Keys.server) defaults.set(username, forKey: Keys.username) Self.setPassword(password, for: username) } public func clear() { if let username = defaults.string(forKey: Keys.username) { Self.setPassword(nil, for: username) } defaults.removeObject(forKey: Keys.server) defaults.removeObject(forKey: Keys.username) } public func password() -> String? { guard let username = defaults.string(forKey: Keys.username) else { return nil } var query = Self.query(for: username) query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne var item: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let data = item as? Data else { return nil } return String(data: data, encoding: .utf8) } private static func query(for username: String) -> [String: Any] { [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: username] } private static func setPassword(_ password: String?, for username: String) { let query = query(for: username) SecItemDelete(query as CFDictionary) guard let password, !password.isEmpty else { return } var item = query item[kSecValueData as String] = Data(password.utf8) // Nur auf diesem Gerät und nur nach dem ersten Entsperren: ein // Kalenderwidget braucht kein Passwort, solange niemand angemeldet ist. item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly let status = SecItemAdd(item as CFDictionary, nil) if status != errSecSuccess { log.error("Passwort ließ sich nicht sichern (\(status, privacy: .public))") } } } public struct CalendarrAccount: Equatable, Sendable { public let server: URL public let username: String public init(server: URL, username: String) { self.server = server self.username = username } }