Files
onyx/Packages/OnyxKit/Sources/CalendarProvider/CalendarrSource.swift
Scarriffle 8308536227 Phase 8: Ablage, AirDrop und Calendarr
Calendarr liefert Termine über den Gruppencontainer. Streng lesend: keine
eigene Anmeldung, kein Netz, kein Schreiben zurück.

Die Abdeckung ist der Kern und kein Detail. Der Schnappschuss reicht 49
Tage nach vorne, und das steht nirgends in der Datei — es ist zugesagtes
Verhalten des Schreibers. Außerhalb liegt keine Information vor, nicht
keine Termine; das Monatsraster graut solche Tage aus, statt „frei" zu
behaupten. Verlockend wäre, die Abdeckung aus dem frühesten Termin
abzuleiten. Das ist falsch, und ein Test hält es fest: mehrtägige Termine
ragen ins Fenster hinein und werden mitgeschrieben, obwohl ihr Anfang
nicht abgedeckt ist.

Bei „beide Quellen" ist die Abdeckung der Schnitt, nicht die Vereinigung.
Ein Tag gilt nur als bekannt, wenn ihn jede Quelle abdeckt. Umgekehrt
darf ein Ausfall einer Quelle die andere nicht mitreißen: Calendarr nie
geöffnet zu haben knipst den Apple-Kalender nicht aus.

Die Ablage kopiert, statt zu verweisen. Anhänge aus Mail oder WhatsApp
liegen in temporären Verzeichnissen — ein Verweis wäre in Stunden tot,
und die Ablage wäre leer, wenn man sie braucht. Aus demselben Grund nimmt
die Fläche UTType.item statt public.file-url an: solche Anhänge kommen
als Dateiversprechen, und loadFileRepresentation löst beides ein. Die
übergebene Datei existiert nur innerhalb des Rückrufs, also wird sie dort
sofort gesichert statt später auf dem MainActor.

Gelöscht wird in den Papierkorb. Automatisches Aufräumen ist standardmäßig
aus — eine Ablage, die ungefragt löscht, ist ein Papierkorb mit
Zeitschaltuhr.

Nebenbei: „mixer.on" und „network.vpn.active" standen als rohe Schlüssel
in der Oberfläche. Label(_:systemImage:) kennt kein Bundle und sucht immer
im Hauptbundle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:16:19 +02:00

223 lines
9.2 KiB
Swift

import Foundation
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Calendarr")
/// Liest den Schnappschuss, den Calendarr in den Gruppencontainer legt.
///
/// Streng lesend. Onyx meldet sich nie selbst an, holt nichts aus dem Netz und
/// schreibt nie in den Container zurück der Schnappschuss gehört Calendarr,
/// und zwei Schreiber auf einer Datei wären ein Fehler, den man erst bemerkt,
/// wenn Termine verschwinden.
public enum CalendarrSnapshotReader {
/// Wie weit nach vorne der Schnappschuss reicht.
///
/// Steht nicht in der Datei, sondern ist zugesagtes Verhalten des
/// Schreibers. Läge der Wert zu niedrig, würde Onyx zu viel ausgrauen
/// unschön, aber ehrlich. Läge er zu hoch, würde es leere Tage als frei"
/// behaupten, für die nie jemand Daten geschrieben hat. Von beiden Fehlern
/// ist nur der erste vertretbar, deshalb wird hier nicht großzügig gerundet.
public static let coverageDays = 49
/// Höchste Formatfassung, deren Bedeutung Onyx kennt.
public static let maxSupportedVersion = 1
/// Der Zeitraum, über den der Schnappschuss etwas aussagt.
///
/// Beginnt am Tag des Schreibens, nicht früher: Calendarr schreibt nach
/// vorne. Ein vergangener Tag ohne Termine kann war nichts" heißen oder
/// wurde nie geschrieben" das ist von außen nicht unterscheidbar, also
/// wird es auch nicht behauptet.
public static func coverage(writtenAt: Date, calendar: Calendar) -> DateInterval {
let start = calendar.startOfDay(for: writtenAt)
let end = calendar.date(byAdding: .day, value: coverageDays, to: start) ?? start
return DateInterval(start: start, end: end)
}
/// Wertet Schnappschuss und Kalenderliste aus. Rein ohne Dateisystem,
/// damit jeder Zustand ohne installierte Calendarr-App prüfbar bleibt.
public static func state(fromSnapshot snapshot: Data?,
calendars: Data?,
calendar: Calendar = .autoupdatingCurrent) -> CalendarSourceState {
// Keine Datei heißt nicht kaputt", sondern Calendarr war noch nie
// offen". Dafür gibt es einen eigenen, hilfreicheren Hinweis.
guard let snapshot else { return .neverWritten }
let raw: RawSnapshot
do {
raw = try JSONDecoder().decode(RawSnapshot.self, from: snapshot)
} catch {
log.error("Schnappschuss unlesbar: \(error.localizedDescription, privacy: .public)")
return .unreadable(reason: error.localizedDescription)
}
let version = raw.version ?? 1
guard version <= maxSupportedVersion else {
return .incompatible(found: version, maxSupported: maxSupportedVersion)
}
let parser = DateParser()
let writtenAt = raw.writtenAtDate(parser)
// Abgemeldet: die vorhandenen Termine sind alt und sehen trotzdem
// aktuell aus. Genau deshalb werden sie verworfen statt angezeigt.
if raw.loggedOut == true { return .loggedOut(asOf: writtenAt) }
let titles = calendarTitles(from: calendars)
let events = (raw.events ?? [])
.compactMap { $0.asOnyxEvent(titles: titles, parser: parser) }
.sorted { $0.start < $1.start }
return .ready(EventWindow(
events: events,
coverage: writtenAt.map { coverage(writtenAt: $0, calendar: calendar) },
asOf: writtenAt))
}
/// Die Kalenderliste als eigene Datei sie trägt die Namen, die im
/// Schnappschuss selbst nur als Kennung stehen.
public static func calendars(from data: Data?) -> [OnyxCalendar] {
guard let data,
let raw = try? JSONDecoder().decode([RawCalendar].self, from: data)
else { return [] }
return raw.map { OnyxCalendar(id: $0.id, title: $0.name, colorHex: $0.colorHex) }
}
private static func calendarTitles(from data: Data?) -> [String: String] {
Dictionary(calendars(from: data).map { ($0.id, $0.title) },
uniquingKeysWith: { first, _ in first })
}
// MARK: - Rohformat
private struct RawSnapshot: Decodable {
let version: Int?
let loggedOut: Bool?
let writtenAt: String?
let events: [RawEvent]?
func writtenAtDate(_ parser: DateParser) -> Date? {
writtenAt.flatMap(parser.date(from:))
}
}
private struct RawCalendar: Decodable {
let id: String
let name: String
let colorHex: String?
}
private struct RawEvent: Decodable {
let id: String
let title: String
let start: String
let end: String
let isAllDay: Bool?
let location: String?
let calendarKey: String?
let colorHex: String?
func asOnyxEvent(titles: [String: String], parser: DateParser) -> OnyxEvent? {
guard let start = parser.date(from: start),
let end = parser.date(from: end) else { return nil }
let key = calendarKey ?? ""
return OnyxEvent(
id: id, title: title, start: start, end: end,
isAllDay: isAllDay ?? false,
colorHex: colorHex,
// Ein leerer Ort ist kein Ort sonst steht unter jedem Termin
// eine leere Zeile mit Stecknadel.
location: (location?.isEmpty ?? true) ? nil : location,
calendarID: key,
calendarTitle: titles[key] ?? key)
}
}
/// Calendarr schreibt ISO 8601 in UTC, mal mit und mal ohne Sekundenbruch.
///
/// Bewusst kein gemeinsamer statischer Formatierer: `ISO8601DateFormatter`
/// ist nicht `Sendable`, und ein `nonisolated(unsafe)` wäre hier eine
/// Behauptung, die niemand prüft. Einmal je Durchlauf gebaut und
/// durchgereicht kostet nichts und ist nachweislich richtig.
struct DateParser {
private let plain: ISO8601DateFormatter
private let withFraction: ISO8601DateFormatter
init() {
plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
withFraction = ISO8601DateFormatter()
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
}
func date(from string: String) -> Date? {
withFraction.date(from: string) ?? plain.date(from: string)
}
}
}
// MARK: - Quelle
/// Calendarr als Terminquelle.
public final class CalendarrSource: CalendarSource, @unchecked Sendable {
public let id = "calendarr"
/// Der Gruppencontainer, den Calendarr und Onyx teilen.
///
/// Onyx läuft ohne Sandbox der Container ist trotzdem erreichbar, weil
/// die Gruppe im Entitlement steht und beide Apps im selben Team signiert
/// sind. Siehe docs/spikes/D-appgroup.md.
public static let appGroup = "PP34X97WS3.group.com.scarriffleservices.calendarr"
/// Die Nachricht, mit der Calendarr auf einen neuen Schnappschuss hinweist.
public static let changeNotification = "com.scarriffleservices.calendarr.snapshot-changed"
private let containerURL: URL?
private let calendar: Calendar
public init(calendar: Calendar = .autoupdatingCurrent) {
self.containerURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroup)
self.calendar = calendar
if containerURL == nil {
log.error("Gruppencontainer \(Self.appGroup, privacy: .public) nicht erreichbar")
}
}
/// Calendarr braucht keinen Systemdialog der Container ist entweder da
/// oder die App ist nicht installiert.
public var authorization: CalendarAuthorization { .notRequired }
public var isAuthorized: Bool { true }
public func requestAccess() async -> Bool { true }
public func calendars() async -> [OnyxCalendar] {
CalendarrSnapshotReader.calendars(from: read("widget-calendars.json"))
}
/// Das angefragte Intervall wird zum Filtern benutzt, nicht zum Abfragen:
/// der Schnappschuss liegt fertig da, seine Reichweite bestimmt Calendarr.
public func load(_ interval: DateInterval) async -> CalendarSourceState {
guard containerURL != nil else {
return .unreadable(reason: "Gruppencontainer nicht erreichbar")
}
let state = CalendarrSnapshotReader.state(fromSnapshot: read("widget-cache.json"),
calendars: read("widget-calendars.json"),
calendar: calendar)
guard case .ready(let window) = state else { return state }
// Die Abdeckung bleibt die des Schnappschusses und wird nicht auf das
// angefragte Fenster eingeengt sonst würde ein schmaler Ausschnitt
// wie eine schmale Datenlage aussehen.
return .ready(EventWindow(
events: window.events.filter { $0.end >= interval.start && $0.start < interval.end },
coverage: window.coverage,
asOf: window.asOf))
}
private func read(_ name: String) -> Data? {
guard let url = containerURL?.appendingPathComponent(name) else { return nil }
return try? Data(contentsOf: url)
}
}