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>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
/// Woher die Termine kommen sollen.
|
||||
public enum CalendarSourceKind: String, CaseIterable, Sendable {
|
||||
case appleCalendar
|
||||
case calendarr
|
||||
case both
|
||||
|
||||
public var localizedName: String {
|
||||
switch self {
|
||||
case .appleCalendar: String(localized: "calendar.source.apple", bundle: .module)
|
||||
case .calendarr: String(localized: "calendar.source.calendarr", bundle: .module)
|
||||
case .both: String(localized: "calendar.source.both", bundle: .module)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fasst die Zustände mehrerer Quellen zu einem zusammen.
|
||||
///
|
||||
/// Solange **eine** Quelle liefert, wird geliefert. Calendarr nie geöffnet
|
||||
/// zu haben darf den Apple-Kalender nicht mit ausknipsen — sonst wäre
|
||||
/// „beide" schlechter als jede einzelne Quelle für sich.
|
||||
public static func merge(_ states: [CalendarSourceState]) -> CalendarSourceState {
|
||||
guard !states.isEmpty else { return .ready(.empty) }
|
||||
let windows = states.compactMap { state -> EventWindow? in
|
||||
if case .ready(let window) = state { return window }
|
||||
return nil
|
||||
}
|
||||
// Keine einzige Quelle liefert: dann ist der erste Grund die Antwort.
|
||||
// Irgendeine Begründung ist besser als eine leere Liste, die aussieht,
|
||||
// als wäre nichts los.
|
||||
guard !windows.isEmpty else { return states[0] }
|
||||
return .ready(.merged(windows))
|
||||
}
|
||||
}
|
||||
|
||||
public extension EventWindow {
|
||||
|
||||
/// Führt mehrere Fenster zu einem zusammen.
|
||||
static func merged(_ windows: [EventWindow]) -> EventWindow {
|
||||
guard !windows.isEmpty else { return .empty }
|
||||
guard windows.count > 1 else { return windows[0] }
|
||||
|
||||
let events = windows.flatMap(\.events).sorted { $0.start < $1.start }
|
||||
|
||||
// Die Abdeckung ist der **Schnitt**, nicht die Vereinigung: ein Tag
|
||||
// gilt nur als bekannt, wenn ihn jede beteiligte Quelle abdeckt. Deckt
|
||||
// Calendarr den März nicht ab, ist der März unbekannt — auch wenn der
|
||||
// Apple-Kalender dafür Auskunft geben könnte. Sonst zeigte das Raster
|
||||
// einen halben März als „frei", der in Wahrheit nur halb bekannt ist.
|
||||
var coverage: DateInterval?
|
||||
for window in windows {
|
||||
// `nil` heißt unbegrenzt und schränkt daher nichts ein.
|
||||
guard let next = window.coverage else { continue }
|
||||
coverage = coverage.map { current in
|
||||
let start = max(current.start, next.start)
|
||||
let end = min(current.end, next.end)
|
||||
// Ohne Überschneidung bleibt nichts übrig — und das ist die
|
||||
// richtige Antwort, nicht ein beliebiger der beiden Zeiträume.
|
||||
return DateInterval(start: start, end: max(start, end))
|
||||
} ?? next
|
||||
}
|
||||
|
||||
// Der älteste Stand begrenzt die Verlässlichkeit des Ganzen. Den
|
||||
// neueren zu zeigen behauptete eine Aktualität, die nicht alle haben.
|
||||
let asOf = windows.compactMap(\.asOf).min()
|
||||
|
||||
return EventWindow(events: events, coverage: coverage, asOf: asOf)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die Wahl der Terminquelle — samt ehrlicher Auskunft darüber, was Calendarr
|
||||
/// gerade liefert.
|
||||
///
|
||||
/// Der Zustandstext ist kein Beiwerk: bleibt das Widget leer, ist genau hier
|
||||
/// die Antwort auf das Warum. Ohne ihn sieht „Calendarr nie geöffnet" genauso
|
||||
/// aus wie „keine Termine".
|
||||
public struct CalendarSourceSettings: View {
|
||||
|
||||
@Bindable private var model: CalendarModel
|
||||
@State private var calendarrState: CalendarSourceState?
|
||||
|
||||
public init(model: CalendarModel) {
|
||||
self.model = model
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Picker(selection: $model.kind) {
|
||||
ForEach(CalendarSourceKind.allCases, id: \.self) { kind in
|
||||
Text(kind.localizedName).tag(kind)
|
||||
}
|
||||
} label: {
|
||||
Text("calendar.source.title", bundle: .module)
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
}
|
||||
|
||||
if model.kind != .appleCalendar {
|
||||
Section {
|
||||
LabeledContent {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
statusText
|
||||
Text("calendar.source.readOnly", bundle: .module)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
} label: {
|
||||
Text(verbatim: "Calendarr")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.task(id: model.kind) { await loadCalendarrState() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusText: some View {
|
||||
switch calendarrState {
|
||||
case .ready(let window):
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("calendar.source.status.ready \(window.events.count.formatted()) \(asOfText(window.asOf))",
|
||||
bundle: .module)
|
||||
if let coverage = window.coverage {
|
||||
// Die Grenze der Abdeckung gehört sichtbar hin: dahinter
|
||||
// zeigt Onyx bewusst „unbekannt" statt „frei", und das
|
||||
// soll niemand für einen Fehler halten.
|
||||
Text("calendar.source.status.coverage \(coverage.end.formatted(date: .abbreviated, time: .omitted))",
|
||||
bundle: .module)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.font(.callout)
|
||||
case .neverWritten:
|
||||
notice("calendar.source.status.neverWritten")
|
||||
case .loggedOut:
|
||||
notice("calendar.source.status.loggedOut")
|
||||
case .incompatible:
|
||||
notice("calendar.source.status.incompatible")
|
||||
case .unreadable(let reason):
|
||||
Text("calendar.source.status.unreadable \(reason)", bundle: .module)
|
||||
.font(.callout).foregroundStyle(.orange)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
case .denied, .none:
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private func notice(_ key: String.LocalizationValue) -> some View {
|
||||
Text(String(localized: key, bundle: .module))
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
private func asOfText(_ date: Date?) -> String {
|
||||
date?.formatted(date: .abbreviated, time: .shortened) ?? "–"
|
||||
}
|
||||
|
||||
private func loadCalendarrState() async {
|
||||
guard model.kind != .appleCalendar else { return }
|
||||
// Bewusst über die Quelle selbst und nicht über das Modell: hier soll
|
||||
// der Zustand von Calendarr allein stehen, nicht die Mischung aus
|
||||
// beiden Quellen. Sonst verdeckt ein funktionierender Apple-Kalender,
|
||||
// dass Calendarr gar nichts liefert.
|
||||
let source = CalendarrSource()
|
||||
let now = Date()
|
||||
calendarrState = await source.load(
|
||||
DateInterval(start: now.addingTimeInterval(-14 * 86_400),
|
||||
end: now.addingTimeInterval(60 * 86_400)))
|
||||
}
|
||||
}
|
||||
@@ -14,18 +14,76 @@ public final class CalendarModel {
|
||||
public private(set) var state: CalendarSourceState = .ready(.empty)
|
||||
public private(set) var isLoading = false
|
||||
|
||||
private let source: CalendarSource
|
||||
/// Welche Quellen gefragt werden. Umschalten lädt sofort neu.
|
||||
public var kind: CalendarSourceKind {
|
||||
didSet {
|
||||
guard kind != oldValue else { return }
|
||||
defaults.set(kind.rawValue, forKey: Self.kindKey)
|
||||
observeCalendarrIfNeeded()
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private static let kindKey = "calendar.source"
|
||||
|
||||
private let appleSource: CalendarSource
|
||||
private let calendarrSource: CalendarSource
|
||||
private let defaults: UserDefaults
|
||||
private var calendar: Calendar
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
/// `nil` = nicht auf Calendarr-Änderungen angemeldet.
|
||||
private var calendarrObserver: DarwinObserver?
|
||||
|
||||
public init(source: CalendarSource, calendar: Calendar = .autoupdatingCurrent) {
|
||||
self.source = source
|
||||
public init(appleSource: CalendarSource = EventKitSource(),
|
||||
calendarrSource: CalendarSource = CalendarrSource(),
|
||||
defaults: UserDefaults = .standard,
|
||||
calendar: Calendar = .autoupdatingCurrent) {
|
||||
self.appleSource = appleSource
|
||||
self.calendarrSource = calendarrSource
|
||||
self.defaults = defaults
|
||||
self.calendar = calendar
|
||||
self.kind = defaults.string(forKey: Self.kindKey)
|
||||
.flatMap(CalendarSourceKind.init(rawValue:)) ?? .appleCalendar
|
||||
observeCalendarrIfNeeded()
|
||||
}
|
||||
|
||||
/// Die gerade befragten Quellen.
|
||||
private var activeSources: [CalendarSource] {
|
||||
switch kind {
|
||||
case .appleCalendar: [appleSource]
|
||||
case .calendarr: [calendarrSource]
|
||||
case .both: [appleSource, calendarrSource]
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob gelesen werden darf. Fragt nicht nach — nur zum Anzeigen.
|
||||
public var hasAccess: Bool { source.isAuthorized }
|
||||
public var authorization: CalendarAuthorization { source.authorization }
|
||||
///
|
||||
/// **Eine** berechtigte Quelle genügt: bei „beide" darf ein gesperrter
|
||||
/// Apple-Kalender nicht dazu führen, dass auch Calendarr stumm bleibt.
|
||||
public var hasAccess: Bool { activeSources.contains(where: \.isAuthorized) }
|
||||
|
||||
/// Der Berechtigungszustand, der die Oberfläche interessiert — also der des
|
||||
/// Apple-Kalenders, denn nur der kann überhaupt einen Dialog auslösen.
|
||||
public var authorization: CalendarAuthorization {
|
||||
kind == .calendarr ? .notRequired : appleSource.authorization
|
||||
}
|
||||
|
||||
// MARK: - Calendarr-Benachrichtigung
|
||||
|
||||
/// Calendarr meldet neue Schnappschüsse per Darwin-Nachricht — deshalb muss
|
||||
/// Onyx die Datei nicht abfragen, sondern erfährt Änderungen.
|
||||
///
|
||||
/// Nur angemeldet, solange Calendarr überhaupt gefragt wird. Wer nur den
|
||||
/// Apple-Kalender nutzt, soll von Calendarr auch nicht geweckt werden.
|
||||
private func observeCalendarrIfNeeded() {
|
||||
let wanted = kind != .appleCalendar
|
||||
guard wanted != (calendarrObserver != nil) else { return }
|
||||
calendarrObserver = wanted
|
||||
? DarwinObserver(name: CalendarrSource.changeNotification) { [weak self] in
|
||||
Task { @MainActor in self?.refresh() }
|
||||
}
|
||||
: nil
|
||||
}
|
||||
|
||||
/// Fragt die Berechtigung an und lädt bei Erfolg gleich.
|
||||
///
|
||||
@@ -36,9 +94,11 @@ public final class CalendarModel {
|
||||
@discardableResult
|
||||
public func requestAccessNow() async -> Bool {
|
||||
log.notice("Kalenderzugriff: Status vor der Anfrage = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)")
|
||||
let granted = await source.requestAccess()
|
||||
let granted = await appleSource.requestAccess()
|
||||
log.notice("Kalenderzugriff: erteilt = \(granted, privacy: .public), Status danach = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)")
|
||||
if granted { refresh() } else { state = .denied }
|
||||
// Auch bei Ablehnung neu laden statt hart auf `denied` zu setzen: läuft
|
||||
// Calendarr mit, hat Onyx weiterhin Termine zu zeigen.
|
||||
refresh()
|
||||
return granted
|
||||
}
|
||||
|
||||
@@ -56,20 +116,23 @@ public final class CalendarModel {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
// Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer
|
||||
// App ohne Fenster heraus wird von TCC verworfen, ohne dass etwas
|
||||
// passiert — die Anfrage muss aus dem Einstellungsfenster kommen.
|
||||
// Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht
|
||||
// eine leere Terminliste, die "nichts mehr heute" behaupten würde.
|
||||
guard source.isAuthorized else {
|
||||
state = .denied
|
||||
return
|
||||
}
|
||||
let start = calendar.date(byAdding: .day, value: -14,
|
||||
to: calendar.startOfDay(for: date))!
|
||||
let end = calendar.date(byAdding: .day, value: 60, to: start)!
|
||||
let interval = DateInterval(start: start, end: end)
|
||||
|
||||
var states: [CalendarSourceState] = []
|
||||
for source in activeSources {
|
||||
guard !Task.isCancelled else { return }
|
||||
// Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer
|
||||
// App ohne Fenster heraus wird von TCC verworfen, ohne dass etwas
|
||||
// passiert — die Anfrage muss aus dem Einstellungsfenster kommen.
|
||||
// Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht
|
||||
// eine leere Terminliste, die "nichts mehr heute" behaupten würde.
|
||||
states.append(source.isAuthorized ? await source.load(interval) : .denied)
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
state = await source.load(DateInterval(start: start, end: end))
|
||||
state = CalendarSourceKind.merge(states)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
222
Packages/OnyxKit/Sources/CalendarProvider/CalendarrSource.swift
Normal file
222
Packages/OnyxKit/Sources/CalendarProvider/CalendarrSource.swift
Normal file
@@ -0,0 +1,222 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
/// Hört auf eine Darwin-Nachricht und meldet sich beim Abräumen wieder ab.
|
||||
///
|
||||
/// Warum ein eigenes Objekt und nicht einfach ein Feld im Modell: das Modell
|
||||
/// liegt auf dem MainActor, sein `deinit` aber nicht — es dürfte den Beobachter
|
||||
/// dort gar nicht mehr anfassen. Ein Objekt, dessen einzige Aufgabe die
|
||||
/// Anmeldung ist, bindet das Abmelden an genau die richtige Lebensdauer.
|
||||
/// Auf `nil` setzen genügt zum Abmelden.
|
||||
///
|
||||
/// `@unchecked Sendable` ist hier belegbar und keine Behauptung ins Blaue:
|
||||
/// `handler` wird nach `init` nie mehr geschrieben, und der Rückruf liest ihn
|
||||
/// nur. Andere veränderliche Zustände gibt es nicht.
|
||||
final class DarwinObserver: @unchecked Sendable {
|
||||
|
||||
private let name: CFString
|
||||
private let handler: @Sendable () -> Void
|
||||
|
||||
/// - Parameter handler: läuft auf dem Thread, der die Nachricht zustellt —
|
||||
/// nicht zwingend dem Hauptthread. Wer UI anfasst, hüpft selbst hinüber.
|
||||
init(name: String, handler: @escaping @Sendable () -> Void) {
|
||||
self.name = name as CFString
|
||||
self.handler = handler
|
||||
|
||||
CFNotificationCenterAddObserver(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
Unmanaged.passUnretained(self).toOpaque(),
|
||||
{ _, observer, _, _, _ in
|
||||
guard let observer else { return }
|
||||
// Unretained: der Beobachter wird beim Abmelden entfernt, also
|
||||
// kann hier kein toter Zeiger ankommen — und ein starker
|
||||
// Verweis hielte das Objekt ewig am Leben.
|
||||
Unmanaged<DarwinObserver>.fromOpaque(observer)
|
||||
.takeUnretainedValue().handler()
|
||||
},
|
||||
self.name, nil, .deliverImmediately)
|
||||
}
|
||||
|
||||
deinit {
|
||||
CFNotificationCenterRemoveObserver(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
Unmanaged.passUnretained(self).toOpaque(),
|
||||
CFNotificationName(name), nil)
|
||||
}
|
||||
}
|
||||
@@ -1,159 +1,333 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"" : {
|
||||
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"": {},
|
||||
"%lld": {
|
||||
"comment": "A day of the month. The argument is the day of the month.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"%lld" : {
|
||||
"comment" : "A day of the month. The argument is the day of the month.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"widget.calendar.allDay" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Ganztägig"
|
||||
"calendar.source.apple": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Apple Kalender"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "All day"
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Apple Calendar"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.day.unknown" : {
|
||||
"comment" : "Erklärt einen ausgegrauten Tag: keine Information, nicht keine Termine",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Für diesen Tag liegen keine Daten vor"
|
||||
"calendar.source.both": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Beide"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "No data available for this day"
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Both"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.denied" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Kein Kalenderzugriff.\nIn den Systemeinstellungen erlauben."
|
||||
"calendar.source.calendarr": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Calendarr"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "No calendar access.\nAllow it in System Settings."
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Calendarr"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.incompatible" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Onyx ist zu alt für diese\nCalendarr-Version. Bitte aktualisieren."
|
||||
"calendar.source.readOnly": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Onyx liest nur. Es meldet sich nie selbst an und schreibt nie nach Calendarr."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Onyx is too old for this\nCalendarr version. Please update."
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Onyx only reads. It never signs in and never writes to Calendarr."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.loggedOut" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "In Calendarr anmelden"
|
||||
"calendar.source.status.coverage": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Bekannt bis %@"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Sign in to Calendarr"
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Known through %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.name" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Kalender"
|
||||
"calendar.source.status.incompatible": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Calendarr schreibt ein neueres Format. Bitte Onyx aktualisieren."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Calendar"
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Calendarr writes a newer format. Please update Onyx."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.neverWritten" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Calendarr einmal öffnen,\ndamit Termine bereitstehen."
|
||||
"calendar.source.status.loggedOut": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "In Calendarr nicht angemeldet. Alte Termine werden nicht gezeigt."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Open Calendarr once\nso events become available."
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Not signed in to Calendarr. Old events are not shown."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.noUpcoming" : {
|
||||
"comment" : "Nur zeigen, wenn der Zeitraum wirklich abgedeckt ist",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nichts mehr für heute"
|
||||
"calendar.source.status.neverWritten": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Calendarr einmal öffnen, damit ein Schnappschuss geschrieben wird."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nothing left today"
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Open Calendarr once so it can write a snapshot."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.unreadable" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Termine nicht lesbar.\nKonfigurationsfehler."
|
||||
"calendar.source.status.ready": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%@ Termine, Stand %@"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Events unreadable.\nConfiguration error."
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%@ events, as of %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar.source.status.unreadable": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Schnappschuss unlesbar: %@"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Snapshot unreadable: %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"calendar.source.title": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Terminquelle"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Calendar source"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.allDay": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Ganztägig"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "All day"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.day.unknown": {
|
||||
"comment": "Erklärt einen ausgegrauten Tag: keine Information, nicht keine Termine",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Für diesen Tag liegen keine Daten vor"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "No data available for this day"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.denied": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Kein Kalenderzugriff.\nIn den Systemeinstellungen erlauben."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "No calendar access.\nAllow it in System Settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.incompatible": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Onyx ist zu alt für diese\nCalendarr-Version. Bitte aktualisieren."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Onyx is too old for this\nCalendarr version. Please update."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.loggedOut": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "In Calendarr anmelden"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Sign in to Calendarr"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.name": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Kalender"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Calendar"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.neverWritten": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Calendarr einmal öffnen,\ndamit Termine bereitstehen."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Open Calendarr once\nso events become available."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.noUpcoming": {
|
||||
"comment": "Nur zeigen, wenn der Zeitraum wirklich abgedeckt ist",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Nichts mehr für heute"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Nothing left today"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"widget.calendar.unreadable": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Termine nicht lesbar.\nKonfigurationsfehler."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Events unreadable.\nConfiguration error."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
}
|
||||
"version": "1.1"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user