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>
223 lines
9.2 KiB
Swift
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)
|
|
}
|
|
}
|