Calendarr direkt vom Server

Bisher las Onyx nur den Schnappschuss aus dem gemeinsamen Ordner. Der ist so
aktuell wie der letzte Start der Calendarr-App — wer sie eine Woche nicht
öffnet, sieht wochenalte Termine, und Onyx kann nichts dafür und nichts
dagegen. Der Live-Weg stand im Plan als „bewusst weggelassen, später ohne
Umbau nachrüstbar". Das stimmte: das Quellenprotokoll hat gehalten.

Ein einziger Aufruf genügt. /api/caldav/events fasst CalDAV-Konten, lokale
Kalender, Geburtstage, iCal-Abos und Google bereits zusammen — nachgebaut wird
nichts.

Angemeldet wird mit Adresse, Benutzername und Passwort statt mit einem Token,
und der Grund steht im Backend:

  auth.py:17   ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7   # 7 days

Ein eingetragenes Token wäre nach einer Woche tot. Onyx holt sich seines
deshalb selbst und erneuert es, bevor es abläuft — die Gültigkeit liest es aus
dem Token, statt sieben Tage anzunehmen; die stehen heute so im Server und
morgen vielleicht nicht mehr. Kommt trotzdem ein 401, wird einmal neu
angemeldet und der Abruf wiederholt.

Adresse und Benutzername stehen in den Einstellungen, das Passwort im
Schlüsselbund — `defaults read` ist keine Stelle, an der ein Passwort
mitgelesen werden soll. Geprüft wird beim Verbinden, nicht erst beim nächsten
Laden: ein Tippfehler, den man am Folgetag bemerkt, ist ein vermeidbarer.

Der Schnappschuss bleibt das Netz darunter. Ohne Zugangsdaten ändert sich
nichts, und ohne Netzwerk hält er, was Calendarr zuletzt geschrieben hat.
Nachgesehen wird am Server alle fünf Minuten statt alle zwei: ein Dateizugriff
kostet nichts, eine Anfrage über das Netz schon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-14 13:34:57 +02:00
parent 3c36092712
commit e2b86c0216
6 changed files with 888 additions and 3 deletions

View File

@@ -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": {

View File

@@ -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
}
}
}
}

View File

@@ -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<Void, Never>?
@@ -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

View File

@@ -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<Void, Error> {
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
}
}

View File

@@ -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 doesnt 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": {

View File

@@ -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)
}
}