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 else { return .neverWritten } let password = store.password() await tokens.adopt(store.token()) 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, totpCode: String?, session: URLSession = .shared) async -> Result { do { let token = try await TokenCache.fetchToken(server: server, username: username, password: password, totpCode: totpCode, session: session) return .success(token) } 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 adopt(_ stored: String?) { guard token == nil, let stored else { return } token = stored expiry = CalendarrLiveDecoder.expiry(ofToken: stored) } 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 } // Ohne Passwort — oder mit zweitem Faktor — kann sich niemand von // selbst neu anmelden. Dann muss der Mensch ran, und das sagt der // Fehler auch. guard let password, !password.isEmpty else { throw CalendarrLiveError.twoFactorRequired } let fresh = try await Self.fetchToken(server: account.server, username: account.username, password: password, totpCode: nil, session: session) token = fresh expiry = CalendarrLiveDecoder.expiry(ofToken: fresh) ?? Date().addingTimeInterval(6 * 24 * 3600) return fresh } /// Meldet an und gibt ein Langzeit-Token zurück. /// /// Über `/api/auth/login` statt `/api/auth/token`: das Formular-Endstück /// kennt keinen zweiten Faktor und antwortet Konten mit aktivem 2FA /// **immer** mit „falsches Passwort" — auch wenn es stimmt. Der JSON-Weg /// nimmt den Code entgegen und kann obendrein `remember_me`. /// /// Und das ist hier keine Bequemlichkeit, sondern die Bedingung: mit 2FA /// kann Onyx sich nicht selbst neu anmelden, wenn ein Token abläuft — den /// Code hat nur der Mensch. Ein gewöhnliches Token hielte sieben Tage, /// `remember_me` hält hundertachtzig. static func fetchToken(server: URL, username: String, password: String, totpCode: String?, session: URLSession) async throws -> String { var request = URLRequest(url: server.appendingPathComponent("api/auth/login")) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") var body: [String: Any] = ["username": username, "password": password, "remember_me": true] if let totpCode, !totpCode.isEmpty { body["totp_code"] = totpCode } request.httpBody = try JSONSerialization.data(withJSONObject: body) request.timeoutInterval = 15 let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw CalendarrLiveError.badResponse } let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] if let failure = CalendarrLiveDecoder.loginFailure(status: http.statusCode, detail: object?["detail"] as? String) { throw failure } guard let token = object?["access_token"] as? String, !token.isEmpty else { throw CalendarrLiveError.badResponse } return token } } public enum CalendarrLiveError: LocalizedError, Equatable { case unauthorized /// Das Konto verlangt einen zweiten Faktor — ohne Code geht hier nichts. case twoFactorRequired case twoFactorWrong case badResponse case status(Int) case badServer public var errorDescription: String? { switch self { case .unauthorized: String(localized: "calendarr.live.error.credentials", bundle: .module) case .twoFactorRequired: String(localized: "calendarr.live.error.twoFactor", bundle: .module) case .twoFactorWrong: String(localized: "calendarr.live.error.twoFactorWrong", 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 } /// Was eine abgelehnte Anmeldung bedeutet. /// /// Der Server unterscheidet die Fälle nur im Begleittext, und der /// Unterschied ist der zwischen „Passwort neu tippen" und „App aufmachen". /// Ohne diese Unterscheidung stand bei einem Konto mit zweitem Faktor /// „Benutzername oder Passwort stimmen nicht" — obwohl beides stimmte. public static func loginFailure(status: Int, detail: String?) -> CalendarrLiveError? { switch status { case 200: return nil case 401: let detail = detail ?? "" if detail == "2fa_required" { return .twoFactorRequired } if detail.localizedCaseInsensitiveContains("2fa") { return .twoFactorWrong } return .unauthorized default: return .status(status) } } /// 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) } /// Das Token gehört mit in den Schlüsselbund, nicht nur ins Gedächtnis des /// laufenden Programms. /// /// Bei einem Konto mit zweitem Faktor ist es das einzige, womit Onyx /// überhaupt noch hineinkommt: neu anmelden ginge nur mit einem Code, und /// den hat nur der Mensch. Ginge es beim Beenden verloren, müsste man sich /// bei jedem Start neu anmelden. public func save(server: URL, username: String, password: String, token: String?) { defaults.set(server.absoluteString, forKey: Keys.server) defaults.set(username, forKey: Keys.username) Self.setSecret(password, for: username, kind: .password) Self.setSecret(token, for: username, kind: .token) } public func clear() { if let username = defaults.string(forKey: Keys.username) { Self.setSecret(nil, for: username, kind: .password) Self.setSecret(nil, for: username, kind: .token) } defaults.removeObject(forKey: Keys.server) defaults.removeObject(forKey: Keys.username) } public func password() -> String? { secret(.password) } public func token() -> String? { secret(.token) } private func secret(_ kind: Secret) -> String? { guard let username = defaults.string(forKey: Keys.username) else { return nil } var query = Self.query(for: username, kind: kind) 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 enum Secret: String { case password case token } private static func query(for username: String, kind: Secret) -> [String: Any] { [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service + "." + kind.rawValue, kSecAttrAccount as String: username] } private static func setSecret(_ value: String?, for username: String, kind: Secret) { let query = query(for: username, kind: kind) SecItemDelete(query as CFDictionary) guard let value, !value.isEmpty else { return } var item = query item[kSecValueData as String] = Data(value.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 } }