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>
470 lines
18 KiB
Swift
470 lines
18 KiB
Swift
import SwiftUI
|
|
import OSLog
|
|
import EventKit
|
|
import OnyxDesign
|
|
import OnyxWidgetKit
|
|
|
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Calendar")
|
|
|
|
/// Hält die Termine für das Widget und lädt sie nach.
|
|
@MainActor
|
|
@Observable
|
|
public final class CalendarModel {
|
|
|
|
public private(set) var state: CalendarSourceState = .ready(.empty)
|
|
public private(set) var isLoading = false
|
|
|
|
/// 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()
|
|
}
|
|
}
|
|
|
|
/// Monatsraster statt Terminliste.
|
|
public var showsMonthGrid: Bool {
|
|
didSet { defaults.set(showsMonthGrid, forKey: Self.gridKey) }
|
|
}
|
|
|
|
/// Welche Kalender Daten liefern. `nil` heißt: alle.
|
|
///
|
|
/// Bewusst eine Liste der **abgewählten**, nicht der gewählten: kommt ein
|
|
/// neuer Kalender dazu, ist er dann automatisch dabei. Andersherum müsste
|
|
/// man ihn suchen und anhaken, ohne zu wissen, dass es ihn gibt.
|
|
public var hiddenCalendarIDs: Set<String> {
|
|
didSet {
|
|
guard hiddenCalendarIDs != oldValue else { return }
|
|
defaults.set(Array(hiddenCalendarIDs), forKey: Self.hiddenKey)
|
|
refresh()
|
|
}
|
|
}
|
|
|
|
/// Die Kalender, die zur Auswahl stehen.
|
|
public private(set) var availableCalendars: [OnyxCalendar] = []
|
|
|
|
private static let kindKey = "calendar.source"
|
|
private static let hiddenKey = "calendar.hiddenCalendars"
|
|
private static let gridKey = "calendar.showsMonthGrid"
|
|
|
|
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>?
|
|
/// `nil` = nicht auf Calendarr-Änderungen angemeldet.
|
|
private var calendarrObserver: DarwinObserver?
|
|
/// Das Sicherheitsnetz unter der Benachrichtigung.
|
|
private var calendarrTimer: Timer?
|
|
|
|
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)
|
|
.flatMap(CalendarSourceKind.init(rawValue:)) ?? .appleCalendar
|
|
self.showsMonthGrid = defaults.bool(forKey: Self.gridKey)
|
|
self.hiddenCalendarIDs = Set(defaults.stringArray(forKey: Self.hiddenKey) ?? [])
|
|
observeCalendarrIfNeeded()
|
|
}
|
|
|
|
/// Die gerade befragten Quellen.
|
|
private var activeSources: [CalendarSource] {
|
|
switch kind {
|
|
case .appleCalendar: [appleSource]
|
|
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
|
|
/// 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) { @Sendable [weak self] in
|
|
Task { @MainActor in self?.refresh() }
|
|
}
|
|
: nil
|
|
|
|
// Dazu ein Blick alle zwei Minuten.
|
|
//
|
|
// Die Benachrichtigung ist der schnelle Weg, aber sie ist flüchtig:
|
|
// schreibt Calendarr, während Onyx nicht läuft oder gerade startet,
|
|
// kommt sie nie an — und dann steht der Schnappschuss so lange still,
|
|
// 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: usesCalendarrServer ? 300 : 120,
|
|
repeats: true) { _ in
|
|
MainActor.assumeIsolated { [weak self] in self?.refresh() }
|
|
}
|
|
: nil
|
|
}
|
|
|
|
/// Fragt die Berechtigung an und lädt bei Erfolg gleich.
|
|
///
|
|
/// Wird beim Start gerufen, nicht erst wenn das Widget sichtbar wird. Das
|
|
/// Panel ist beim Start zu, also erscheint eine View-gebundene Anfrage erst,
|
|
/// wenn jemand die Notch berührt — und bis dahin steht in den
|
|
/// Systemeinstellungen kein Onyx, das man freischalten könnte.
|
|
@discardableResult
|
|
public func requestAccessNow() async -> Bool {
|
|
log.notice("Kalenderzugriff: Status vor der Anfrage = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)")
|
|
let granted = await appleSource.requestAccess()
|
|
log.notice("Kalenderzugriff: erteilt = \(granted, privacy: .public), Status danach = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)")
|
|
// Auch bei Ablehnung neu laden statt hart auf `denied` zu setzen: läuft
|
|
// Calendarr mit, hat Onyx weiterhin Termine zu zeigen.
|
|
refresh()
|
|
return granted
|
|
}
|
|
|
|
public var window: EventWindow? {
|
|
if case .ready(let window) = state { return window }
|
|
return nil
|
|
}
|
|
|
|
/// 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
|
|
guard let self else { return }
|
|
isLoading = true
|
|
defer { isLoading = false }
|
|
|
|
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 = CalendarSourceKind.merge(states).filteringHidden(hiddenCalendarIDs)
|
|
|
|
// Die Liste der wählbaren Kalender nebenbei mitführen — sie ändert
|
|
// sich, wenn jemand in Kalender einen anlegt oder die Quelle
|
|
// wechselt.
|
|
var found: [OnyxCalendar] = []
|
|
for source in activeSources where source.isAuthorized {
|
|
found.append(contentsOf: await source.calendars())
|
|
}
|
|
availableCalendars = found
|
|
}
|
|
}
|
|
|
|
public func stop() {
|
|
refreshTask?.cancel()
|
|
refreshTask = nil
|
|
}
|
|
|
|
public var upcoming: [OnyxEvent] {
|
|
guard let window else { return [] }
|
|
let now = Date()
|
|
return window.events
|
|
.filter { $0.end >= now }
|
|
.prefix(6)
|
|
.map { $0 }
|
|
}
|
|
}
|
|
|
|
// MARK: - Widget
|
|
|
|
public struct CalendarWidget: OnyxWidget {
|
|
public let id = "calendar"
|
|
public var displayName: String { String(localized: "widget.calendar.name", bundle: .module) }
|
|
public let symbolName = "calendar"
|
|
|
|
private let model: CalendarModel
|
|
|
|
public init(model: CalendarModel) {
|
|
self.model = model
|
|
}
|
|
|
|
public func makeView() -> AnyView {
|
|
AnyView(CalendarWidgetView(model: model))
|
|
}
|
|
}
|
|
|
|
private struct CalendarWidgetView: View {
|
|
let model: CalendarModel
|
|
|
|
var body: some View {
|
|
Group {
|
|
switch model.state {
|
|
case .ready(let window):
|
|
// Monatsraster oder Terminliste — das ist keine Frage der
|
|
// Größe, sondern was man sehen will. Beides zugleich passt in
|
|
// eine Kachel nicht, ohne dass eines davon unlesbar wird.
|
|
if model.showsMonthGrid {
|
|
LargeCalendar(window: window, upcoming: [])
|
|
} else {
|
|
AgendaList(events: model.upcoming, coverage: window.coverage)
|
|
}
|
|
case .denied:
|
|
CalendarNotice(symbol: "lock", text: "widget.calendar.denied")
|
|
case .neverWritten:
|
|
CalendarNotice(symbol: "clock.arrow.circlepath", text: "widget.calendar.neverWritten")
|
|
case .loggedOut:
|
|
CalendarNotice(symbol: "person.crop.circle.badge.xmark", text: "widget.calendar.loggedOut")
|
|
case .incompatible:
|
|
CalendarNotice(symbol: "exclamationmark.triangle", text: "widget.calendar.incompatible")
|
|
case .unreadable:
|
|
CalendarNotice(symbol: "questionmark.folder", text: "widget.calendar.unreadable")
|
|
}
|
|
}
|
|
.task { model.refresh() }
|
|
.onDisappear { model.stop() }
|
|
}
|
|
}
|
|
|
|
/// Kein Platzhalter, sondern eine Antwort: jeder Zustand sagt, was zu tun ist.
|
|
private struct CalendarNotice: View {
|
|
let symbol: String
|
|
let text: LocalizedStringKey
|
|
|
|
var body: some View {
|
|
VStack(spacing: 6) {
|
|
Image(systemName: symbol)
|
|
.font(.system(size: 16))
|
|
.foregroundStyle(Onyx.Color.textTertiary)
|
|
Text(text, bundle: .module)
|
|
.font(Onyx.Font.caption)
|
|
.foregroundStyle(Onyx.Color.textSecondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
// MARK: - Mini-Monat
|
|
|
|
private struct LargeCalendar: View {
|
|
let window: EventWindow
|
|
let upcoming: [OnyxEvent]
|
|
|
|
private var calendar: Calendar { .autoupdatingCurrent }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
MiniMonth(window: window)
|
|
Divider().overlay(Onyx.Color.hairline)
|
|
AgendaList(events: Array(upcoming.prefix(3)), coverage: window.coverage)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MiniMonth: View {
|
|
let window: EventWindow
|
|
|
|
private var calendar: Calendar { .autoupdatingCurrent }
|
|
|
|
var body: some View {
|
|
let grid = MonthGrid(containing: Date(), calendar: calendar)
|
|
let busy = window.events.busyDays(calendar: calendar)
|
|
let today = calendar.startOfDay(for: Date())
|
|
|
|
VStack(spacing: 3) {
|
|
HStack(spacing: 0) {
|
|
ForEach(weekdaySymbols, id: \.self) { symbol in
|
|
Text(symbol)
|
|
.font(.system(size: 8, weight: .medium))
|
|
.foregroundStyle(Onyx.Color.textTertiary)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
ForEach(0..<(grid.days.count / 7), id: \.self) { week in
|
|
HStack(spacing: 0) {
|
|
ForEach(grid.days[(week * 7)..<(week * 7 + 7)]) { day in
|
|
DayCell(day: day,
|
|
status: grid.status(for: day.date,
|
|
coverage: window.coverage,
|
|
busyDays: busy),
|
|
isToday: day.date == today)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Wochentagskürzel ab dem eingestellten Wochenanfang.
|
|
private var weekdaySymbols: [String] {
|
|
let symbols = calendar.veryShortStandaloneWeekdaySymbols
|
|
let shift = calendar.firstWeekday - 1
|
|
return Array(symbols[shift...] + symbols[..<shift])
|
|
}
|
|
}
|
|
|
|
private struct DayCell: View {
|
|
let day: MonthDay
|
|
let status: DayStatus
|
|
let isToday: Bool
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
if isToday {
|
|
Circle().fill(Onyx.Color.accent.opacity(0.9)).frame(width: 15, height: 15)
|
|
}
|
|
Text("\(Calendar.autoupdatingCurrent.component(.day, from: day.date))")
|
|
.font(.system(size: 9, weight: isToday ? .semibold : .regular))
|
|
.foregroundStyle(textColor)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 15)
|
|
.overlay(alignment: .bottom) {
|
|
// Ein Punkt heißt: hier ist etwas. Ein fehlender Punkt heißt nur
|
|
// dann „nichts", wenn der Tag überhaupt abgedeckt ist — deshalb
|
|
// wird `unknown` ausgegraut statt leer gelassen.
|
|
if status == .busy {
|
|
Circle()
|
|
.fill(isToday ? Color.white : Onyx.Color.accent)
|
|
.frame(width: 2.5, height: 2.5)
|
|
.offset(y: 2)
|
|
}
|
|
}
|
|
.opacity(status == .unknown ? 0.25 : 1)
|
|
.help(status == .unknown
|
|
? Text("widget.calendar.day.unknown", bundle: .module)
|
|
: Text(""))
|
|
}
|
|
|
|
private var textColor: Color {
|
|
if isToday { return .white }
|
|
return day.isInMonth ? Onyx.Color.textPrimary : Onyx.Color.textTertiary
|
|
}
|
|
}
|
|
|
|
// MARK: - Terminliste
|
|
|
|
private struct AgendaList: View {
|
|
let events: [OnyxEvent]
|
|
let coverage: DateInterval?
|
|
|
|
var body: some View {
|
|
if events.isEmpty {
|
|
Text("widget.calendar.noUpcoming", bundle: .module)
|
|
.font(Onyx.Font.caption)
|
|
.foregroundStyle(Onyx.Color.textTertiary)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
ForEach(events) { event in
|
|
EventRow(event: event)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct EventRow: View {
|
|
let event: OnyxEvent
|
|
|
|
var body: some View {
|
|
HStack(spacing: 6) {
|
|
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
|
|
.fill(Color(hex: event.colorHex) ?? Onyx.Color.accent)
|
|
.frame(width: 3)
|
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(event.title)
|
|
.font(Onyx.Font.caption)
|
|
.foregroundStyle(Onyx.Color.textPrimary)
|
|
.lineLimit(1)
|
|
Text(timeText)
|
|
.font(.system(size: 9))
|
|
.monospacedDigit()
|
|
.foregroundStyle(Onyx.Color.textTertiary)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(height: 24)
|
|
}
|
|
|
|
private var timeText: String {
|
|
if event.isAllDay {
|
|
return String(localized: "widget.calendar.allDay", bundle: .module)
|
|
}
|
|
return event.start.formatted(date: .omitted, time: .shortened)
|
|
}
|
|
}
|
|
|
|
extension Color {
|
|
/// `#RRGGBB` aus einer fremden Quelle. Ungültige Werte ergeben `nil`, damit
|
|
/// die Aufrufstelle auf die Onyx-Akzentfarbe zurückfallen kann.
|
|
init?(hex: String?) {
|
|
guard let hex else { return nil }
|
|
var value = hex.trimmingCharacters(in: .whitespaces)
|
|
if value.hasPrefix("#") { value.removeFirst() }
|
|
guard value.count == 6, let number = UInt32(value, radix: 16) else { return nil }
|
|
self.init(red: Double((number >> 16) & 0xFF) / 255,
|
|
green: Double((number >> 8) & 0xFF) / 255,
|
|
blue: Double(number & 0xFF) / 255)
|
|
}
|
|
}
|