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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user