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:
@@ -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
|
||||
|
||||
409
Packages/OnyxKit/Sources/CalendarProvider/CalendarrLive.swift
Normal file
409
Packages/OnyxKit/Sources/CalendarProvider/CalendarrLive.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user