Deine Zugangsdaten stimmten. Der Fehler lag im gewählten Endpunkt:
auth_router.py:81 if user.totp_enabled:
raise HTTPException(401, detail="2fa_required")
/api/auth/token nimmt nur ein Formular mit Benutzername und Passwort entgegen
und weist Konten mit zweitem Faktor grundsätzlich ab — mit demselben 401 wie
ein falsches Passwort. Deshalb stand da „Benutzername oder Passwort stimmen
nicht", obwohl beides stimmte.
Angemeldet wird jetzt über /api/auth/login: der Weg nimmt den Code entgegen
und kennt remember_me. Das ist hier keine Bequemlichkeit, sondern die
Bedingung — mit zweitem Faktor kann Onyx sich nicht selbst neu anmelden, den
Code hat nur der Mensch. Ein gewöhnliches Token hielte sieben Tage,
remember_me hält hundertachtzig.
Damit wandert auch das Token in den Schlüsselbund. Bei einem Konto mit
zweitem Faktor ist es das einzige, womit Onyx noch hineinkommt; ginge es beim
Beenden verloren, müsste man sich bei jedem Start neu anmelden.
Die drei Fälle sind jetzt auseinandergehalten und einzeln beschriftet:
falsches Passwort, fehlender Code, falscher Code. Der Unterschied ist der
zwischen „Passwort neu tippen" und „App aufmachen" — und er steckt beim
Server nur im Begleittext eines 401.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
482 lines
21 KiB
Swift
482 lines
21 KiB
Swift
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<String, Error> {
|
|
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
|
|
}
|
|
}
|