diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index 9229791..4c607d7 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -1636,6 +1636,102 @@ } } }, + "options.calendar.server.address": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Serveradresse" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Server address" + } + } + } + }, + "options.calendar.server.connect": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbinden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect" + } + } + } + }, + "options.calendar.server.disconnect": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Trennen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Disconnect" + } + } + } + }, + "options.calendar.server.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ohne Verbindung liest Onyx den Schnappschuss, den die Calendarr-App schreibt — der ist nur so aktuell wie ihr letzter Start." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Without a connection Onyx reads the snapshot the Calendarr app writes — only as current as its last launch." + } + } + } + }, + "options.calendar.server.password": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Password" + } + } + } + }, + "options.calendar.server.username": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzername" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Username" + } + } + } + }, "options.calendar.source": { "localizations": { "de": { diff --git a/Onyx/WidgetOptionsView.swift b/Onyx/WidgetOptionsView.swift index 7a30227..c8d7b82 100644 --- a/Onyx/WidgetOptionsView.swift +++ b/Onyx/WidgetOptionsView.swift @@ -90,6 +90,7 @@ private struct CalendarOptions: View { } if model.kind != .appleCalendar { + CalendarrServerSettings(model: model) CalendarrStatus(state: calendarrState, isLoading: model.isLoading, onRefresh: { model.refresh() }) @@ -271,3 +272,101 @@ private extension Color { blue: Double(value & 0xFF) / 255) } } + +/// Die Anmeldung am Calendarr-Server. +/// +/// Der Schnappschuss im gemeinsamen Ordner ist nur so aktuell wie der letzte +/// Start der Calendarr-App. Wer die Zugangsdaten hier einträgt, holt Onyx die +/// Termine selbst — und dann ist es egal, ob Calendarr überhaupt läuft. +/// +/// Adresse und Benutzername stehen in den Einstellungen, das Passwort im +/// Schlüsselbund. +private struct CalendarrServerSettings: View { + + let model: CalendarModel + + @State private var credentials = CalendarrCredentials() + @State private var server = "" + @State private var username = "" + @State private var password = "" + @State private var isChecking = false + @State private var failure: String? + + private var account: CalendarrAccount? { credentials.account } + + var body: some View { + if let account { + LabeledContent { + HStack(spacing: 10) { + Text(account.username).foregroundStyle(.secondary) + Button { + credentials.clear() + model.calendarrAccountChanged() + } label: { + Text("options.calendar.server.disconnect") + } + } + } label: { + Label { + Text(account.server.host() ?? account.server.absoluteString) + } icon: { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Onyx.Color.positive) + } + } + } else { + // Drei Felder und ein Knopf. Geprüft wird beim Verbinden, nicht + // erst beim nächsten Laden: ein Tippfehler, den man am Folgetag + // bemerkt, ist ein vermeidbarer. + TextField(text: $server) { Text("options.calendar.server.address") } + .textContentType(.URL) + TextField(text: $username) { Text("options.calendar.server.username") } + .textContentType(.username) + SecureField(text: $password) { Text("options.calendar.server.password") } + .textContentType(.password) + + HStack { + if let failure { + Text(failure).font(.callout).foregroundStyle(Onyx.Color.warning) + .fixedSize(horizontal: false, vertical: true) + } else { + Text("options.calendar.server.hint") + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 12) + Button(action: connect) { + if isChecking { + ProgressView().controlSize(.small) + } else { + Text("options.calendar.server.connect") + } + } + .disabled(isChecking || server.isEmpty || username.isEmpty || password.isEmpty) + } + } + } + + private func connect() { + guard let url = CalendarrLiveDecoder.server(from: server) else { + failure = CalendarrLiveError.badServer.errorDescription + return + } + isChecking = true + failure = nil + Task { + defer { isChecking = false } + switch await CalendarrLiveSource.check(server: url, username: username, + password: password) { + case .success: + credentials.save(server: url, username: username, password: password) + // Das Passwort nicht im Formular stehen lassen — es liegt jetzt + // im Schlüsselbund, und ein zweiter Ort ist einer zu viel. + password = "" + model.calendarrAccountChanged() + case .failure(let error): + failure = error.localizedDescription + } + } + } +} diff --git a/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift b/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift index 7f67b99..57cb0d7 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift +++ b/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift @@ -51,6 +51,10 @@ public final class CalendarModel { private let appleSource: CalendarSource private let calendarrSource: CalendarSource + /// Der Weg über den Server. Wird benutzt, sobald Zugangsdaten hinterlegt + /// sind — dann hängt die Aktualität nicht mehr daran, ob jemand die + /// Calendarr-App geöffnet hat. + private let calendarrLive: CalendarrLiveSource private let defaults: UserDefaults private var calendar: Calendar private var refreshTask: Task? @@ -61,10 +65,12 @@ public final class CalendarModel { public init(appleSource: CalendarSource = EventKitSource(), calendarrSource: CalendarSource = CalendarrSource(), + calendarrLive: CalendarrLiveSource = CalendarrLiveSource(), defaults: UserDefaults = .standard, calendar: Calendar = .autoupdatingCurrent) { self.appleSource = appleSource self.calendarrSource = calendarrSource + self.calendarrLive = calendarrLive self.defaults = defaults self.calendar = calendar self.kind = defaults.string(forKey: Self.kindKey) @@ -78,11 +84,23 @@ public final class CalendarModel { private var activeSources: [CalendarSource] { switch kind { case .appleCalendar: [appleSource] - case .calendarr: [calendarrSource] - case .both: [appleSource, calendarrSource] + case .calendarr: [calendarrChoice] + case .both: [appleSource, calendarrChoice] } } + /// Server oder Schnappschuss. + /// + /// Sind Zugangsdaten hinterlegt, gilt der Server — er ist die genauere + /// Auskunft. Der Schnappschuss bleibt das Netz darunter: er braucht kein + /// Netzwerk und hält, was Calendarr zuletzt geschrieben hat. + private var calendarrChoice: CalendarSource { + calendarrLive.isAuthorized ? calendarrLive : calendarrSource + } + + /// Ob gerade direkt vom Server gelesen wird. + public var usesCalendarrServer: Bool { calendarrLive.isAuthorized } + /// Ob gelesen werden darf. Fragt nicht nach — nur zum Anzeigen. /// /// **Eine** berechtigte Quelle genügt: bei „beide" darf ein gesperrter @@ -119,9 +137,12 @@ public final class CalendarModel { // bis Calendarr das nächste Mal schreibt. Das Nachsehen kostet das // Einlesen einer Datei von wenigen Kilobyte; das ist billiger als eine // Terminliste, die aus unerfindlichen Gründen von gestern ist. + // Am Server seltener als an der Datei: ein Dateizugriff kostet nichts, + // eine Anfrage über das Netz schon. calendarrTimer?.invalidate() calendarrTimer = wanted - ? Timer.scheduledTimer(withTimeInterval: 120, repeats: true) { _ in + ? Timer.scheduledTimer(withTimeInterval: usesCalendarrServer ? 300 : 120, + repeats: true) { _ in MainActor.assumeIsolated { [weak self] in self?.refresh() } } : nil @@ -151,6 +172,15 @@ public final class CalendarModel { /// Termine für den sichtbaren Monat plus Puffer nach beiden Seiten, damit /// die Randtage der Nachbarmonate im Raster nicht fälschlich leer wirken. + /// Nach dem Verbinden oder Trennen: Quelle und Takt neu bestimmen. + public func calendarrAccountChanged() { + calendarrObserver = nil + calendarrTimer?.invalidate() + calendarrTimer = nil + observeCalendarrIfNeeded() + refresh() + } + public func refresh(around date: Date = Date()) { refreshTask?.cancel() refreshTask = Task { [weak self] in diff --git a/Packages/OnyxKit/Sources/CalendarProvider/CalendarrLive.swift b/Packages/OnyxKit/Sources/CalendarProvider/CalendarrLive.swift new file mode 100644 index 0000000..6091a8c --- /dev/null +++ b/Packages/OnyxKit/Sources/CalendarProvider/CalendarrLive.swift @@ -0,0 +1,409 @@ +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 + } +} diff --git a/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings index 06a730d..1d0ca24 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings @@ -253,6 +253,70 @@ } } }, + "calendarr.live.error.credentials": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzername oder Passwort stimmen nicht." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Wrong username or password." + } + } + } + }, + "calendarr.live.error.response": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Server hat geantwortet, aber nicht verständlich." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The server replied, but not intelligibly." + } + } + } + }, + "calendarr.live.error.server": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Adresse ergibt keinen Server." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "That address doesn’t resolve to a server." + } + } + } + }, + "calendarr.live.error.status %lld": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Server meldet Fehler %lld." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The server reports error %lld." + } + } + } + }, "widget.calendar.allDay": { "localizations": { "de": { diff --git a/Packages/OnyxKit/Tests/CalendarProviderTests/CalendarrLiveTests.swift b/Packages/OnyxKit/Tests/CalendarProviderTests/CalendarrLiveTests.swift new file mode 100644 index 0000000..157a9da --- /dev/null +++ b/Packages/OnyxKit/Tests/CalendarProviderTests/CalendarrLiveTests.swift @@ -0,0 +1,187 @@ +import Testing +import Foundation +@testable import CalendarProvider + +@Suite("Calendarr direkt vom Server") +struct CalendarrLiveTests { + + // MARK: - Adresse + + @Test("Eine eingetippte Adresse wird zu einer brauchbaren") + func serverNormalisation() { + // Niemand tippt „https://" mit. Und wer es tut, hängt gern noch einen + // Schrägstrich an. + #expect(CalendarrLiveDecoder.server(from: "calendarr.example.com")?.absoluteString + == "https://calendarr.example.com") + #expect(CalendarrLiveDecoder.server(from: "https://calendarr.example.com/")?.absoluteString + == "https://calendarr.example.com") + #expect(CalendarrLiveDecoder.server(from: " http://192.168.1.5:8080 ")?.absoluteString + == "http://192.168.1.5:8080") + } + + @Test("Unsinn ergibt keine Adresse") + func serverRejectsGarbage() { + #expect(CalendarrLiveDecoder.server(from: "") == nil) + #expect(CalendarrLiveDecoder.server(from: " ") == nil) + } + + @Test("Der Abruf trägt den Zeitraum in der Adresse") + func eventsURLCarriesInterval() throws { + let url = try CalendarrLiveDecoder.eventsURL( + server: URL(string: "https://c.example.com")!, + interval: DateInterval(start: Date(timeIntervalSince1970: 0), + end: Date(timeIntervalSince1970: 86400))) + #expect(url.absoluteString.contains("/api/caldav/events")) + #expect(url.absoluteString.contains("start=1970-01-01")) + #expect(url.absoluteString.contains("end=1970-01-02")) + } + + // MARK: - Anmeldung + + @Test("Sonderzeichen im Passwort überstehen die Anmeldung") + func passwordEscaping() { + // Der Klassiker: `+` bedeutet im Formularkörper ein Leerzeichen. Ohne + // eigene Kodierung meldet sich niemand mit „a+b" jemals an — und der + // Fehler tritt nur bei genau diesem Passwort auf. + let body = String(decoding: CalendarrLiveDecoder.formBody(username: "guido", + password: "a+b c&d"), + as: UTF8.self) + #expect(body == "username=guido&password=a%2Bb%20c%26d") + } + + @Test("Die Gültigkeit steht im Token selbst") + func tokenExpiry() throws { + // Ein JWT trägt sie im Mittelteil. Die Alternative — sieben Tage + // annehmen — steht heute so im Server und morgen vielleicht nicht mehr. + let payload = try JSONSerialization.data(withJSONObject: ["exp": 1_800_000_000.0]) + let encoded = payload.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + let token = "kopf.\(encoded).unterschrift" + + #expect(CalendarrLiveDecoder.expiry(ofToken: token) + == Date(timeIntervalSince1970: 1_800_000_000)) + } + + @Test("Ein kaputtes Token ergibt kein Datum") + func brokenTokenHasNoExpiry() { + #expect(CalendarrLiveDecoder.expiry(ofToken: "nur-ein-wort") == nil) + #expect(CalendarrLiveDecoder.expiry(ofToken: "a.b.c") == nil) + } + + // MARK: - Termine + + private func json(_ object: Any) throws -> Data { + try JSONSerialization.data(withJSONObject: object) + } + + @Test("Termine kommen aus dem Umschlag heraus") + func decodesEnvelope() throws { + let data = try json([ + "events": [[ + "id": "abc", "title": "Zahnarzt", + "start": "2026-08-14T09:00:00+02:00", "end": "2026-08-14T10:00:00+02:00", + "allDay": false, "location": "Praxis", + "color": "#FF0000", "calendar_id": "local-3", "calendar_name": "Persönlich", + ]], + "errors": [], + ]) + let events = try CalendarrLiveDecoder.events(from: data) + #expect(events.count == 1) + #expect(events[0].title == "Zahnarzt") + #expect(events[0].location == "Praxis") + #expect(events[0].calendarID == "local-3") + #expect(events[0].calendarTitle == "Persönlich") + #expect(events[0].colorHex == "#FF0000") + #expect(!events[0].isAllDay) + } + + @Test("Ganztägige Termine kommen ohne Uhrzeit") + func decodesAllDay() throws { + let data = try json(["events": [[ + "id": "b", "title": "Urlaub", "start": "2026-08-14", "end": "2026-08-16", + "allDay": true, "calendar_id": "local-1", "calendar_name": "Familie", + ]]]) + let events = try CalendarrLiveDecoder.events(from: data) + #expect(events.count == 1) + #expect(events[0].isAllDay) + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + #expect(calendar.component(.day, from: events[0].start) == 14) + #expect(calendar.component(.hour, from: events[0].start) == 0) + } + + @Test("Ohne Ende bekommt ein Termin trotzdem eine Länge") + func missingEndGetsALength() throws { + // Sonst wäre er null Sekunden lang — und damit unsichtbar. + let data = try json(["events": [ + ["id": "c", "title": "Kurz", "start": "2026-08-14T09:00:00Z", + "calendar_id": "x", "calendar_name": "X"], + ["id": "d", "title": "Ganztags", "start": "2026-08-15", "allDay": true, + "calendar_id": "x", "calendar_name": "X"], + ]]) + let events = try CalendarrLiveDecoder.events(from: data) + #expect(events[0].end.timeIntervalSince(events[0].start) == 3600) + #expect(events[1].end.timeIntervalSince(events[1].start) == 86400) + } + + @Test("Die Kalenderfarbe springt ein, wenn der Termin keine hat") + func calendarColourIsTheFallback() throws { + let data = try json(["events": [[ + "id": "e", "title": "T", "start": "2026-08-14T09:00:00Z", + "calendarColor": "#00FF00", "calendar_id": "x", "calendar_name": "X", + ]]]) + #expect(try CalendarrLiveDecoder.events(from: data)[0].colorHex == "#00FF00") + } + + @Test("Termine kommen sortiert an") + func eventsAreSorted() throws { + let data = try json(["events": [ + ["id": "spät", "title": "B", "start": "2026-08-16T09:00:00Z", + "calendar_id": "x", "calendar_name": "X"], + ["id": "früh", "title": "A", "start": "2026-08-14T09:00:00Z", + "calendar_id": "x", "calendar_name": "X"], + ]]) + #expect(try CalendarrLiveDecoder.events(from: data).map(\.title) == ["A", "B"]) + } + + @Test("Ein Eintrag ohne Anfang fällt heraus, der Rest bleibt") + func brokenEntriesAreSkipped() throws { + // Ein einziger kaputter Termin darf nicht die ganze Liste kosten. + let data = try json(["events": [ + ["id": "kaputt", "title": "Ohne Anfang", "calendar_id": "x", "calendar_name": "X"], + ["id": "heil", "title": "Mit Anfang", "start": "2026-08-14T09:00:00Z", + "calendar_id": "x", "calendar_name": "X"], + ]]) + #expect(try CalendarrLiveDecoder.events(from: data).map(\.title) == ["Mit Anfang"]) + } + + @Test("Eine Antwort ohne Umschlag geht auch") + func bareArrayWorks() throws { + let data = try json([["id": "f", "title": "T", "start": "2026-08-14T09:00:00Z", + "calendar_id": "x", "calendar_name": "X"]]) + #expect(try CalendarrLiveDecoder.events(from: data).count == 1) + } + + @Test("Was kein JSON ist, wird als solches gemeldet") + func garbageThrows() { + #expect(throws: CalendarrLiveError.badResponse) { + try CalendarrLiveDecoder.events(from: Data("kein json".utf8)) + } + } + + // MARK: - Zeitangaben + + @Test("Die verbreiteten Zeitformate werden alle gelesen") + func dateFormats() { + #expect(CalendarrLiveDecoder.date(from: "2026-08-14T09:00:00Z") != nil) + #expect(CalendarrLiveDecoder.date(from: "2026-08-14T09:00:00.123Z") != nil) + #expect(CalendarrLiveDecoder.date(from: "2026-08-14T09:00:00+02:00") != nil) + // Ohne Zeitzone: dann gilt die des Rechners. + #expect(CalendarrLiveDecoder.date(from: "2026-08-14T09:00:00") != nil) + #expect(CalendarrLiveDecoder.date(from: "2026-08-14") != nil) + #expect(CalendarrLiveDecoder.date(from: "irgendwann") == nil) + } +}