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:
Scarriffle
2026-08-11 10:16:19 +02:00
parent 28b1b4270e
commit 8308536227
25 changed files with 2917 additions and 868 deletions

View File

@@ -23,6 +23,7 @@
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; };
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; };
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; };
A9617230F602C5D52F63A7E2 /* ShelfProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */; };
B6CD48B41866A468AE7A1BC7 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; };
C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; };
C15B441081E9CCF74B52CADA /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */; };
@@ -81,6 +82,7 @@
C390C5B65769A007A49C3A80 /* MetricsProvider in Frameworks */,
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */,
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */,
A9617230F602C5D52F63A7E2 /* ShelfProvider in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -190,6 +192,7 @@
6CACA1C2A58DEFDE46F71499 /* MetricsProvider */,
FF4E99C8BF0D8944C314A8AF /* NetworkProvider */,
5D8203CC3A09D838A4AC385D /* AudioProvider */,
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */,
);
productName = Onyx;
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
@@ -635,6 +638,10 @@
isa = XCSwiftPackageProductDependency;
productName = OnyxWidgetKit;
};
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */ = {
isa = XCSwiftPackageProductDependency;
productName = ShelfProvider;
};
FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */ = {
isa = XCSwiftPackageProductDependency;
productName = OnyxHelperProtocol;

View File

@@ -883,6 +883,102 @@
}
}
},
"settings.shelf.cleanup": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Automatisch löschen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Delete automatically"
}
}
}
},
"settings.shelf.count %lld": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "%lld Dateien abgelegt"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "%lld files parked"
}
}
}
},
"settings.shelf.days %lld": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nach %lld Tagen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "After %lld days"
}
}
}
},
"settings.shelf.never": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nie"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Never"
}
}
}
},
"settings.shelf.reveal": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Im Finder zeigen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Show in Finder"
}
}
}
},
"settings.shelf.title": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Ablage"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Shelf"
}
}
}
},
"settings.size.large": {
"extractionState": "stale",
"localizations": {
@@ -1019,6 +1115,22 @@
}
}
},
"settings.tab.sources": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Quellen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Sources"
}
}
}
},
"settings.tab.widgets": {
"extractionState": "stale",
"localizations": {

View File

@@ -11,6 +11,7 @@ import MediaProvider
import MetricsProvider
import NetworkProvider
import AudioProvider
import ShelfProvider
@main
struct OnyxApp: App {
@@ -39,6 +40,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var mediaModel: MediaModel?
private var metricsModel: MetricsModel?
private var networkModel: NetworkModel?
private var shelfStore: ShelfStore?
private let settingsWindow = SettingsWindowController()
private let fanControl = FanControl()
private let audioMixer = AudioMixer()
@@ -150,7 +152,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
/// Echte Widgets zuerst, danach die Platzhalter für alles, was noch fehlt.
private func registerWidgets() {
let calendarModel = CalendarModel(source: EventKitSource())
let calendarModel = CalendarModel()
self.calendarModel = calendarModel
WidgetRegistry.shared.register(CalendarWidget(model: calendarModel))
@@ -174,6 +176,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
WidgetRegistry.shared.register(AudioMixerWidget(mixer: audioMixer))
let shelfStore = ShelfStore()
self.shelfStore = shelfStore
WidgetRegistry.shared.register(ShelfWidget(store: shelfStore))
WidgetRegistry.shared.register(AirDropWidget())
PlaceholderWidgets.registerAll()
}
@@ -272,7 +279,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
guard let model, let calendarModel, let weatherModel else { return }
settingsWindow.show(model: model, calendarModel: calendarModel,
weatherModel: weatherModel, metricsModel: metricsModel,
fanControl: fanControl, tab: tab)
shelfStore: shelfStore, fanControl: fanControl, tab: tab)
}
@objc private func quit() { NSApp.terminate(nil) }

View File

@@ -46,10 +46,6 @@ enum PlaceholderWidgets {
let widgets: [PlaceholderWidget] = [
.init(id: "fans", displayName: "Lüfter", symbolName: "fan",
supportedSizes: [.small, .medium], phase: "Phase 6"),
.init(id: "shelf", displayName: "Ablage", symbolName: "tray.full",
supportedSizes: [.small, .medium], phase: "Phase 8"),
.init(id: "airdrop", displayName: "AirDrop", symbolName: "shareplay",
supportedSizes: [.small], phase: "Phase 8"),
]
widgets.forEach { WidgetRegistry.shared.register($0) }
}

View File

@@ -3,13 +3,14 @@ import OnyxDesign
import OnyxNotch
import OnyxWidgetKit
import CalendarProvider
import ShelfProvider
import OnyxMenuBar
import MetricsProvider
import OnyxHelperProtocol
import WeatherProvider
enum SettingsTab: Hashable {
case widgets, display, menubar, fans, permissions
case widgets, display, sources, menubar, fans, permissions
}
struct SettingsView: View {
@@ -17,17 +18,21 @@ struct SettingsView: View {
let calendarModel: CalendarModel
let weatherModel: WeatherModel
let metricsModel: MetricsModel?
let shelfStore: ShelfStore?
let fanControl: FanControl
@Binding var selectedTab: SettingsTab
var body: some View {
TabView(selection: $selectedTab) {
WidgetSettings(model: model)
WidgetSettings(model: model, shelfStore: shelfStore)
.tabItem { Label("settings.tab.widgets", systemImage: "square.grid.2x2") }
.tag(SettingsTab.widgets)
DisplaySettings(model: model)
.tabItem { Label("settings.tab.display", systemImage: "macbook") }
.tag(SettingsTab.display)
CalendarSourceSettings(model: calendarModel)
.tabItem { Label("settings.tab.sources", systemImage: "calendar.badge.clock") }
.tag(SettingsTab.sources)
MenuBarSettings(model: model, metricsModel: metricsModel)
.tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") }
.tag(SettingsTab.menubar)
@@ -279,6 +284,7 @@ extension MenuBarPresentation {
private struct WidgetSettings: View {
@Bindable var model: AppModel
let shelfStore: ShelfStore?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -317,6 +323,12 @@ private struct WidgetSettings: View {
}
}
}
if let shelfStore {
Section("settings.shelf.title") {
ShelfSettingsRows(store: shelfStore)
}
}
}
.listStyle(.inset)
}
@@ -324,6 +336,39 @@ private struct WidgetSettings: View {
}
}
/// Einstellungen der Ablage.
///
/// Automatisches Aufräumen ist ausdrücklich abschaltbar und standardmäßig aus:
/// eine Ablage, die ungefragt löscht, ist kein Zwischenlager, sondern ein
/// Papierkorb mit Zeitschaltuhr.
private struct ShelfSettingsRows: View {
@Bindable var store: ShelfStore
private static let choices: [Int?] = [nil, 1, 7, 30, 90]
var body: some View {
Picker(selection: $store.autoCleanupDays) {
ForEach(Self.choices, id: \.self) { days in
if let days {
Text("settings.shelf.days \(days)").tag(Optional(days))
} else {
Text("settings.shelf.never").tag(Int?.none)
}
}
} label: {
Text("settings.shelf.cleanup")
}
HStack {
Text("settings.shelf.count \(store.items.count)")
.foregroundStyle(.secondary)
Spacer()
Button("settings.shelf.reveal") { store.revealInFinder() }
.buttonStyle(.borderless)
}
}
}
private struct PlacementRow: View {
@Bindable var model: AppModel
let placement: WidgetPlacement

View File

@@ -3,6 +3,7 @@ import SwiftUI
import CalendarProvider
import WeatherProvider
import MetricsProvider
import ShelfProvider
/// Führt das Einstellungsfenster selbst.
///
@@ -26,7 +27,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
var keepsDockIcon = false
func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
metricsModel: MetricsModel?, fanControl: FanControl,
metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl,
tab: SettingsTab = .widgets) {
selectedTab = tab
keepsDockIcon = model.showsDockIcon
@@ -45,6 +46,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
calendarModel: calendarModel,
weatherModel: weatherModel,
metricsModel: metricsModel,
shelfStore: shelfStore,
fanControl: fanControl,
selectedTab: Binding(
get: { [weak self] in self?.selectedTab ?? .widgets },

View File

@@ -20,6 +20,7 @@ let package = Package(
.library(name: "MetricsProvider", targets: ["MetricsProvider"]),
.library(name: "NetworkProvider", targets: ["NetworkProvider"]),
.library(name: "AudioProvider", targets: ["AudioProvider"]),
.library(name: "ShelfProvider", targets: ["ShelfProvider"]),
],
targets: [
// Der String-Katalog muss ausdrücklich als Ressource stehen sonst gibt
@@ -68,5 +69,9 @@ let package = Package(
.target(name: "AudioProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "AudioProviderTests", dependencies: ["AudioProvider"]),
.target(name: "ShelfProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "ShelfProviderTests", dependencies: ["ShelfProvider"]),
]
)

View File

@@ -36,8 +36,14 @@ public struct MixerView: View {
// Der Notausschalter gehört sichtbar dazu, nicht in ein Menü:
// wenn der Ton weg ist, ist das der Knopf, den man sucht.
Button { mixer.disable() } label: {
Label("mixer.off", systemImage: "power")
.font(Onyx.Font.caption)
// Label(_:systemImage:) kennt kein Bundle und sucht immer
// im Hauptbundle - die Strings liegen aber im Modulbundle.
// Ohne den Umweg über Text steht im Knopf mixer.off".
Label {
Text("mixer.off", bundle: .module).font(Onyx.Font.caption)
} icon: {
Image(systemName: "power")
}
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textSecondary)
@@ -69,7 +75,11 @@ private struct OffState: View {
}
Button { mixer.enable() } label: {
Label("mixer.on", systemImage: "slider.horizontal.3").font(Onyx.Font.caption)
Label {
Text("mixer.on", bundle: .module).font(Onyx.Font.caption)
} icon: {
Image(systemName: "slider.horizontal.3")
}
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.accent)

View File

@@ -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)
}
}

View File

@@ -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)))
}
}

View File

@@ -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 }
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.
guard source.isAuthorized else {
state = .denied
return
states.append(source.isAuthorized ? await source.load(interval) : .denied)
}
let start = calendar.date(byAdding: .day, value: -14,
to: calendar.startOfDay(for: date))!
let end = calendar.date(byAdding: .day, value: 60, to: start)!
guard !Task.isCancelled else { return }
state = await source.load(DateInterval(start: start, end: end))
state = CalendarSourceKind.merge(states)
}
}

View 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)
}
}

View File

@@ -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)
}
}

View File

@@ -1,13 +1,187 @@
{
"sourceLanguage": "en",
"strings": {
"" : {
},
"": {},
"%lld": {
"comment": "A day of the month. The argument is the day of the month.",
"isCommentAutoGenerated": true
},
"calendar.source.apple": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Apple Kalender"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Apple Calendar"
}
}
}
},
"calendar.source.both": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Beide"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Both"
}
}
}
},
"calendar.source.calendarr": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Calendarr"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Calendarr"
}
}
}
},
"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 only reads. It never signs in and never writes to Calendarr."
}
}
}
},
"calendar.source.status.coverage": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Bekannt bis %@"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Known through %@"
}
}
}
},
"calendar.source.status.incompatible": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Calendarr schreibt ein neueres Format. Bitte Onyx aktualisieren."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Calendarr writes a newer format. Please update Onyx."
}
}
}
},
"calendar.source.status.loggedOut": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "In Calendarr nicht angemeldet. Alte Termine werden nicht gezeigt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Not signed in to Calendarr. Old events are not shown."
}
}
}
},
"calendar.source.status.neverWritten": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Calendarr einmal öffnen, damit ein Schnappschuss geschrieben wird."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Open Calendarr once so it can write a snapshot."
}
}
}
},
"calendar.source.status.ready": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "%@ Termine, Stand %@"
}
},
"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": {

View File

@@ -30,7 +30,13 @@ struct NetworkPopoverContent: View {
if model.snapshot.interfaceKind == .vpn {
// Ein VPN verändert, was die Zahlen darüber bedeuten der
// Verkehr läuft dann durch den Tunnel, nicht direkt.
Label("network.vpn.active", systemImage: "lock.shield")
// Über Text statt Label(_:systemImage:): letzteres kennt kein
// Bundle und zeigt sonst den rohen Schlüssel an.
Label {
Text("network.vpn.active", bundle: .module)
} icon: {
Image(systemName: "lock.shield")
}
.font(.caption)
.foregroundStyle(.secondary)
}

View File

@@ -0,0 +1,77 @@
import SwiftUI
import AppKit
import OnyxDesign
import OnyxWidgetKit
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "AirDrop")
/// Der Weg zur Empfängerauswahl.
public enum AirDrop {
/// Öffnet AirDrop mit den übergebenen Dateien.
///
/// - Returns: ob AirDrop überhaupt zur Verfügung stand.
@MainActor
@discardableResult
public static func send(_ urls: [URL]) -> Bool {
guard !urls.isEmpty else { return false }
guard let service = NSSharingService(named: .sendViaAirDrop) else {
log.error("AirDrop steht nicht zur Verfügung")
return false
}
guard service.canPerform(withItems: urls) else {
log.error("AirDrop kann diese Auswahl nicht senden")
return false
}
service.perform(withItems: urls)
return true
}
}
/// Eine Fläche, auf die man Dateien fallen lässt, um sie zu verschicken.
public struct AirDropView: View {
@State private var isTargeted = false
@State private var failed = false
public init() {}
public var body: some View {
VStack(spacing: 6) {
Image(systemName: "shareplay")
.font(.system(size: 20, weight: .light))
.foregroundStyle(isTargeted ? Onyx.Color.accent : Onyx.Color.textTertiary)
Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(failed ? Onyx.Color.warning : Onyx.Color.textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(isTargeted ? Onyx.Color.accent : Onyx.Color.hairline,
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
}
// Derselbe Weg wie in der Ablage: auch hier kommen Anhänge als
// Versprechen an, nicht als fertige Datei.
.contentShape(.rect)
.onFileDrop(isTargeted: $isTargeted) { urls in failed = !AirDrop.send(urls) }
.animation(Onyx.Motion.value, value: isTargeted)
}
}
// MARK: - Widget
public struct AirDropWidget: OnyxWidget {
public let id = "airdrop"
public var displayName: String { String(localized: "airdrop.name", bundle: .module) }
public let symbolName = "shareplay"
public let supportedSizes: [WidgetSize] = [.small, .medium]
public init() {}
public func makeView(size: WidgetSize) -> AnyView {
AnyView(AirDropView())
}
}

View File

@@ -0,0 +1,79 @@
import SwiftUI
import UniformTypeIdentifiers
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Shelf")
/// Nimmt gezogene Dateien an auch die, die es noch gar nicht gibt.
///
/// Mail, WhatsApp und die meisten Chat-Programme ziehen Anhänge nicht als
/// fertige Datei heraus, sondern als **Versprechen**: die Datei entsteht erst,
/// wenn das Ziel sagt, wohin. Genau diese Anhänge sind der Hauptgrund für eine
/// Ablage, also darf die Fläche nicht nur `public.file-url` annehmen.
///
/// `loadFileRepresentation` ist der Weg, der beides abdeckt: bei einer echten
/// Datei reicht es sie durch, bei einem Versprechen löst es dieses ein. Deshalb
/// `UTType.item` als angenommener Typ die Wurzel, der alles entspricht.
/// Was sich nicht als Datei materialisieren lässt, fällt hinten heraus.
public struct FileDropModifier: ViewModifier {
@Binding private var isTargeted: Bool
private let onFiles: ([URL]) -> Void
public init(isTargeted: Binding<Bool>, onFiles: @escaping ([URL]) -> Void) {
self._isTargeted = isTargeted
self.onFiles = onFiles
}
public func body(content: Content) -> some View {
content.onDrop(of: [.item], isTargeted: $isTargeted) { providers in
for provider in providers { receive(provider) }
return !providers.isEmpty
}
}
private func receive(_ provider: NSItemProvider) {
provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier) { url, error in
if let error {
log.error("Ablage: Datei nicht erhalten: \(error.localizedDescription, privacy: .public)")
return
}
guard let url else { return }
// Die übergebene Datei existiert **nur innerhalb dieses Blocks**;
// danach räumt das System sie weg. Wer sie erst auf dem MainActor
// anfassen will, greift ins Leere also hier und sofort sichern.
guard let staged = Self.stage(url) else { return }
Task { @MainActor in onFiles([staged]) }
}
}
/// Rettet die Datei aus dem flüchtigen Verzeichnis in einen eigenen
/// Zwischenordner.
private static func stage(_ url: URL) -> URL? {
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("Onyx-Drop-\(UUID().uuidString)", isDirectory: true)
do {
try FileManager.default.createDirectory(at: directory,
withIntermediateDirectories: true)
let target = directory.appendingPathComponent(url.lastPathComponent)
// Verschieben, wenn möglich: bei einem Video von zwei Gigabyte ist
// der Unterschied zum Kopieren spürbar. Über Datenträgergrenzen
// hinweg scheitert es, dann wird kopiert.
do { try FileManager.default.moveItem(at: url, to: target) }
catch { try FileManager.default.copyItem(at: url, to: target) }
return target
} catch {
log.error("Ablage: Zwischenspeichern gescheitert: \(error.localizedDescription, privacy: .public)")
return nil
}
}
}
public extension View {
/// Macht die ganze Fläche zum Ziel für gezogene Dateien.
func onFileDrop(isTargeted: Binding<Bool>,
perform: @escaping ([URL]) -> Void) -> some View {
modifier(FileDropModifier(isTargeted: isTargeted, onFiles: perform))
}
}

View File

@@ -0,0 +1,166 @@
{
"sourceLanguage": "en",
"strings": {
"airdrop.hint": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Dateien zum Senden ablegen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Drop files to send"
}
}
}
},
"airdrop.name": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "AirDrop"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "AirDrop"
}
}
}
},
"airdrop.unavailable": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "AirDrop kann das nicht senden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "AirDrop cannot send this"
}
}
}
},
"shelf.airdrop": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Per AirDrop senden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Send via AirDrop"
}
}
}
},
"shelf.clear": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Alles leeren"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Clear all"
}
}
}
},
"shelf.delete": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "In den Papierkorb"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Move to Trash"
}
}
}
},
"shelf.empty": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Dateien hierhin ziehen, um sie zu parken."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Drop files here to park them."
}
}
}
},
"shelf.name": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Ablage"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Shelf"
}
}
}
},
"shelf.open": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Öffnen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Open"
}
}
}
},
"shelf.reveal": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Im Finder zeigen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Show in Finder"
}
}
}
}
},
"version": "1.1"
}

View File

@@ -0,0 +1,83 @@
import Foundation
/// Eine abgelegte Datei.
public struct ShelfItem: Identifiable, Equatable, Sendable {
public let url: URL
public let name: String
public let size: Int64
public let addedAt: Date
public var id: String { url.path }
public init(url: URL, name: String, size: Int64, addedAt: Date) {
self.url = url
self.name = name
self.size = size
self.addedAt = addedAt
}
/// Neuestes zuerst.
///
/// Der Name als Zweitschlüssel ist kein Detail: das Dateisystem sagt keine
/// Reihenfolge zu, und ohne festen zweiten Vergleich springen gleich alte
/// Dateien bei jeder Aktualisierung durcheinander.
public static func sorted(_ items: [ShelfItem]) -> [ShelfItem] {
items.sorted {
$0.addedAt == $1.addedAt ? $0.name < $1.name : $0.addedAt > $1.addedAt
}
}
}
/// Findet freie Namen, ohne je etwas zu überschreiben.
public enum ShelfNaming {
/// Der erste freie Name in der Reihe `Vertrag.pdf`, `Vertrag 2.pdf`,
///
/// Gleichnamige Dateien sind der Normalfall `IMG_0001.jpg` aus zwei
/// verschiedenen Chats. Überschreiben wäre stiller Datenverlust.
public static func uniqueName(for name: String, taken: Set<String>) -> String {
let name = name.isEmpty ? "Datei" : name
guard taken.contains(name) else { return name }
let (base, ext) = split(name)
var counter = 2
while true {
let candidate = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)"
if !taken.contains(candidate) { return candidate }
counter += 1
}
}
/// Zerlegt in Namensteil und Endung.
///
/// Ein führender Punkt zählt nicht als Endung sonst würde aus
/// `.gitignore` ein ` 2.gitignore` mit führendem Leerzeichen. Und bei
/// `archiv.tar.gz` zählt nur die letzte Endung: `archiv 2.tar.gz` wäre
/// zwar hübscher, aber `archiv.tar 2.gz` ist die Regel, die auch bei
/// unbekannten Doppelendungen nichts kaputt macht.
private static func split(_ name: String) -> (base: String, ext: String) {
guard let dot = name.lastIndex(of: "."), dot != name.startIndex else {
return (name, "")
}
return (String(name[name.startIndex..<dot]),
String(name[name.index(after: dot)...]))
}
}
/// Räumt alte Dateien weg wenn man es ausdrücklich möchte.
public enum ShelfCleanup {
/// Was über die Frist hinaus liegt.
///
/// `nil` und `0` heißen beide gar nicht". Eine Ablage, die ungefragt
/// löscht, ist keine Ablage; und ein Fehlgriff im Einstellungsfeld darf
/// nicht der Verlust aller Dateien sein.
public static func expired(_ items: [ShelfItem], now: Date,
afterDays: Int?) -> [ShelfItem] {
guard let days = afterDays, days > 0 else { return [] }
let limit = now.addingTimeInterval(-Double(days) * 86_400)
// Streng älter: genau auf der Frist bleibt es liegen. Löschen ist
// unwiderruflich, einen Tag länger warten ist es nicht.
return items.filter { $0.addedAt < limit }
}
}

View File

@@ -0,0 +1,137 @@
import Foundation
import AppKit
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Shelf")
/// Die Ablage: ein Ordner, in dem Dateien zwischenlagern.
///
/// Kopiert wird, nicht verwiesen. Anhänge aus Mail oder WhatsApp liegen in
/// temporären Verzeichnissen, die das System aufräumt ein Verweis darauf
/// wäre in Stunden tot, und die Ablage wäre genau dann leer, wenn man sie
/// braucht.
@MainActor
@Observable
public final class ShelfStore {
public private(set) var items: [ShelfItem] = []
public private(set) var lastError: String?
/// Automatisches Aufräumen nach N Tagen. `nil` = aus, und das ist Standard.
public var autoCleanupDays: Int? {
didSet {
defaults.set(autoCleanupDays ?? 0, forKey: Self.cleanupKey)
cleanUp()
}
}
private static let cleanupKey = "shelf.cleanupDays"
public let directory: URL
private let defaults: UserDefaults
public init(defaults: UserDefaults = .standard, directory: URL? = nil) {
self.defaults = defaults
self.directory = directory ?? FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Onyx/Shelf", isDirectory: true)
let stored = defaults.integer(forKey: Self.cleanupKey)
self.autoCleanupDays = stored > 0 ? stored : nil
try? FileManager.default.createDirectory(at: self.directory,
withIntermediateDirectories: true)
reload()
cleanUp()
}
// MARK: - Lesen
public func reload() {
let keys: [URLResourceKey] = [.fileSizeKey, .addedToDirectoryDateKey,
.contentModificationDateKey, .isDirectoryKey]
guard let urls = try? FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: keys,
options: [.skipsHiddenFiles]) else {
items = []
return
}
items = ShelfItem.sorted(urls.compactMap { url in
let values = try? url.resourceValues(forKeys: Set(keys))
return ShelfItem(
url: url,
name: url.lastPathComponent,
size: Int64(values?.fileSize ?? 0),
// Wann die Datei **hier** ankam, nicht wann sie erzeugt wurde.
// Ein zehn Jahre altes Foto von gestern soll nicht sofort
// ablaufen, nur weil die Kamera ein altes Datum trägt.
addedAt: values?.addedToDirectoryDate
?? values?.contentModificationDate ?? Date())
})
}
// MARK: - Hinzufügen
/// Legt Kopien in der Ablage an und meldet, was tatsächlich ankam.
@discardableResult
public func add(_ urls: [URL]) -> [ShelfItem] {
var added: [ShelfItem] = []
var taken = Set(items.map(\.name))
for source in urls {
// Dateien aus fremden Apps kommen als sicherheitsbereichte URLs.
// Ohne das Paar aus Start und Stop scheitert das Kopieren mit
// Berechtigung verweigert" und zwar erst bei manchen Quellen,
// was den Fehler besonders unangenehm macht.
let scoped = source.startAccessingSecurityScopedResource()
defer { if scoped { source.stopAccessingSecurityScopedResource() } }
let name = ShelfNaming.uniqueName(for: source.lastPathComponent, taken: taken)
let target = directory.appendingPathComponent(name)
do {
try FileManager.default.copyItem(at: source, to: target)
taken.insert(name)
let size = (try? target.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
added.append(ShelfItem(url: target, name: name,
size: Int64(size), addedAt: Date()))
} catch {
log.error("Ablage: \(source.lastPathComponent, privacy: .public) nicht kopiert: \(error.localizedDescription, privacy: .public)")
lastError = error.localizedDescription
}
}
if !added.isEmpty { reload() }
return added
}
// MARK: - Entfernen
public func remove(_ item: ShelfItem) {
// In den Papierkorb statt endgültig: die Ablage ist ein Zwischenlager,
// und ein Fehlklick darf keine Datei vernichten.
NSWorkspace.shared.recycle([item.url]) { [weak self] _, error in
if let error { log.error("Ablage: nicht gelöscht: \(error.localizedDescription, privacy: .public)") }
Task { @MainActor in self?.reload() }
}
}
public func removeAll() {
guard !items.isEmpty else { return }
NSWorkspace.shared.recycle(items.map(\.url)) { [weak self] _, _ in
Task { @MainActor in self?.reload() }
}
}
/// Räumt ab, was über die eingestellte Frist hinaus liegt.
public func cleanUp(now: Date = Date()) {
let expired = ShelfCleanup.expired(items, now: now, afterDays: autoCleanupDays)
guard !expired.isEmpty else { return }
log.notice("Ablage: \(expired.count, privacy: .public) Dateien abgelaufen")
NSWorkspace.shared.recycle(expired.map(\.url)) { [weak self] _, _ in
Task { @MainActor in self?.reload() }
}
}
/// Öffnet den Ablageordner im Finder der Notausgang, wenn die Oberfläche
/// nicht das kann, was man gerade braucht.
public func revealInFinder() {
NSWorkspace.shared.activateFileViewerSelecting([directory])
}
}

View File

@@ -0,0 +1,203 @@
import SwiftUI
import AppKit
import QuickLookThumbnailing
import OnyxDesign
import OnyxWidgetKit
/// Vorschaubilder, einmal erzeugt und behalten.
///
/// `QLThumbnailGenerator` ist nicht billig, und die Ablage zeichnet sich bei
/// jeder Änderung neu. Ohne diesen Zwischenspeicher würde für jedes Bild bei
/// jedem Tastendruck ein neuer Auftrag laufen.
@MainActor
@Observable
final class ThumbnailCache {
private var images: [String: NSImage] = [:]
private var pending: Set<String> = []
func image(for item: ShelfItem, size: CGFloat) -> NSImage? {
if let cached = images[item.id] { return cached }
guard !pending.contains(item.id) else { return nil }
pending.insert(item.id)
let request = QLThumbnailGenerator.Request(
fileAt: item.url, size: CGSize(width: size, height: size),
scale: NSScreen.main?.backingScaleFactor ?? 2,
representationTypes: .all)
QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { rep, _ in
guard let rep else { return }
let image = NSImage(cgImage: rep.cgImage,
size: CGSize(width: size, height: size))
Task { @MainActor [weak self] in
self?.images[item.id] = image
self?.pending.remove(item.id)
}
}
return nil
}
}
/// Die Ablage.
public struct ShelfView: View {
private let store: ShelfStore
private let compact: Bool
@State private var isTargeted = false
@State private var thumbnails = ThumbnailCache()
public init(store: ShelfStore, compact: Bool = false) {
self.store = store
self.compact = compact
}
public var body: some View {
VStack(alignment: .leading, spacing: 6) {
if store.items.isEmpty {
EmptyShelf()
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 4) {
ForEach(store.items.prefix(compact ? 4 : 20)) { item in
ShelfRow(item: item, store: store, thumbnails: thumbnails)
}
}
}
.scrollIndicators(.never)
HStack(spacing: 10) {
Button { store.removeAll() } label: {
Text("shelf.clear", bundle: .module).font(Onyx.Font.caption)
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textSecondary)
Button { store.revealInFinder() } label: {
Text("shelf.reveal", bundle: .module).font(Onyx.Font.caption)
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textSecondary)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// Die ganze Kachel nimmt Dateien an, nicht nur ein Streifen darin.
.contentShape(.rect)
.onFileDrop(isTargeted: $isTargeted) { urls in store.add(urls) }
.overlay {
if isTargeted {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Onyx.Color.accent, style: StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
.background(Onyx.Color.accent.opacity(0.08),
in: .rect(cornerRadius: 10, style: .continuous))
.allowsHitTesting(false)
}
}
.animation(Onyx.Motion.value, value: isTargeted)
}
}
private struct EmptyShelf: View {
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Image(systemName: "tray")
.font(.system(size: 16))
.foregroundStyle(Onyx.Color.textTertiary)
Text("shelf.empty", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
}
private struct ShelfRow: View {
let item: ShelfItem
let store: ShelfStore
let thumbnails: ThumbnailCache
@State private var isHovered = false
var body: some View {
HStack(spacing: 8) {
thumbnail
VStack(alignment: .leading, spacing: 1) {
Text(item.name)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
Text(item.size.formatted(.byteCount(style: .file)))
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
}
Spacer(minLength: 0)
if isHovered {
Button { AirDrop.send([item.url]) } label: {
Image(systemName: "shareplay").font(.system(size: 10))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary)
Button { store.remove(item) } label: {
Image(systemName: "xmark.circle.fill").font(.system(size: 11))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary)
}
}
.padding(.vertical, 2)
.contentShape(.rect)
.onHover { isHovered = $0 }
// `NSItemProvider(contentsOf:)` statt `.draggable(item.url)`: letzteres
// reicht die URL als Text weiter, und im Finder landet eine
// Internetverknüpfung statt der Datei.
.onDrag { NSItemProvider(contentsOf: item.url) ?? NSItemProvider() }
.onTapGesture(count: 2) { NSWorkspace.shared.open(item.url) }
.contextMenu {
Button { NSWorkspace.shared.open(item.url) } label: {
Text("shelf.open", bundle: .module)
}
Button { NSWorkspace.shared.activateFileViewerSelecting([item.url]) } label: {
Text("shelf.reveal", bundle: .module)
}
Button { AirDrop.send([item.url]) } label: {
Text("shelf.airdrop", bundle: .module)
}
Divider()
Button(role: .destructive) { store.remove(item) } label: {
Text("shelf.delete", bundle: .module)
}
}
}
@ViewBuilder
private var thumbnail: some View {
if let image = thumbnails.image(for: item, size: 22) {
Image(nsImage: image)
.resizable().aspectRatio(contentMode: .fit)
.frame(width: 22, height: 22)
.clipShape(.rect(cornerRadius: 3, style: .continuous))
} else {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(Onyx.Color.elevated)
.frame(width: 22, height: 22)
}
}
}
// MARK: - Widget
public struct ShelfWidget: OnyxWidget {
public let id = "shelf"
public var displayName: String { String(localized: "shelf.name", bundle: .module) }
public let symbolName = "tray.full"
public let supportedSizes: [WidgetSize] = [.medium, .large]
private let store: ShelfStore
public init(store: ShelfStore) { self.store = store }
public func makeView(size: WidgetSize) -> AnyView {
AnyView(ShelfView(store: store, compact: size == .medium))
}
}

View File

@@ -0,0 +1,198 @@
import Testing
import Foundation
@testable import CalendarProvider
// Calendarr schreibt einen Schnappschuss in den Gruppencontainer. Onyx liest
// ihn und nur ihn: keine eigene Anmeldung, kein eigenes Netz, kein Schreiben.
// Die Regeln hier sind die aus der Integrationsanleitung, als Test festgehalten
// statt als Konvention.
private func snapshotJSON(writtenAt: String = "2026-08-10T15:22:56Z",
events: String = "[]",
extra: String = "") -> Data {
Data("""
{
"language": "system",
"textColorHex": "#FFFFFF",
"primaryColorHex": "#58B900",
"backgroundColorHex": "#000000",
"todayColorHex": "#6FB669",
"lineColorHex": "#3B3B3D",
"accentColorHex": "#45A148",
"writtenAt": "\(writtenAt)",
"events": \(events)\(extra)
}
""".utf8)
}
private let oneEvent = """
[{"id": "abc", "title": "Hasen hüten", "start": "2026-08-12T08:00:00Z",
"end": "2026-08-12T09:00:00Z", "isAllDay": false, "location": "",
"calendarKey": "local:2", "colorHex": "#43af6a"}]
"""
@Suite("Calendarr-Schnappschuss")
struct CalendarrSnapshotTests {
@Test("Ein gültiger Schnappschuss wird gelesen")
func readsValidSnapshot() throws {
let state = CalendarrSnapshotReader.state(fromSnapshot: snapshotJSON(events: oneEvent),
calendars: nil)
guard case .ready(let window) = state else {
Issue.record("Erwartet .ready, bekommen \(state)"); return
}
#expect(window.events.count == 1)
#expect(window.events.first?.title == "Hasen hüten")
#expect(window.events.first?.colorHex == "#43af6a")
#expect(window.asOf != nil)
}
@Test("Ein leerer Ort ist kein Ort")
func emptyLocationIsNil() throws {
let state = CalendarrSnapshotReader.state(fromSnapshot: snapshotJSON(events: oneEvent),
calendars: nil)
guard case .ready(let window) = state else { Issue.record("nicht bereit"); return }
// Sonst zeigt das Widget eine leere Ortszeile unter jedem Termin.
#expect(window.events.first?.location == nil)
}
@Test("Der Kalendername kommt aus der Kalenderliste")
func joinsCalendarTitle() throws {
let calendars = Data("""
[{"id": "local:2", "name": "Persönlich", "colorHex": "#43af6a"}]
""".utf8)
let state = CalendarrSnapshotReader.state(fromSnapshot: snapshotJSON(events: oneEvent),
calendars: calendars)
guard case .ready(let window) = state else { Issue.record("nicht bereit"); return }
#expect(window.events.first?.calendarTitle == "Persönlich")
#expect(window.events.first?.calendarID == "local:2")
}
@Test("Ohne Kalenderliste bleibt die Kennung stehen")
func withoutCalendarsKeepsKey() throws {
let state = CalendarrSnapshotReader.state(fromSnapshot: snapshotJSON(events: oneEvent),
calendars: nil)
guard case .ready(let window) = state else { Issue.record("nicht bereit"); return }
#expect(window.events.first?.calendarID == "local:2")
}
@Test("Keine Datei heißt: noch nie geschrieben")
func missingFileIsNeverWritten() {
// Nicht `.unreadable`: Calendarr ist vielleicht installiert, aber noch
// nie gestartet worden. Der richtige Hinweis lautet einmal öffnen".
#expect(CalendarrSnapshotReader.state(fromSnapshot: nil, calendars: nil)
== .neverWritten)
}
@Test("Unlesbares bleibt unlesbar und wird nicht als leer ausgegeben")
func garbageIsUnreadable() {
let state = CalendarrSnapshotReader.state(fromSnapshot: Data("kein JSON".utf8),
calendars: nil)
guard case .unreadable = state else {
Issue.record("Erwartet .unreadable, bekommen \(state)"); return
}
}
@Test("Ein Schnappschuss ohne Termine ist bereit, nicht kaputt")
func emptyEventsIsReady() {
// Der wichtige Unterschied: nichts los" ist eine Auskunft,
// nichts bekannt" ist keine. Hier liegt eine vor.
let state = CalendarrSnapshotReader.state(fromSnapshot: snapshotJSON(), calendars: nil)
guard case .ready(let window) = state else {
Issue.record("Erwartet .ready, bekommen \(state)"); return
}
#expect(window.events.isEmpty)
#expect(window.coverage != nil)
}
@Test("Ein zu neues Format wird als solches gemeldet")
func futureVersionIsIncompatible() {
// Lieber bitte Onyx aktualisieren" als Felder still zu verschlucken,
// deren Bedeutung sich geändert hat.
let data = snapshotJSON(extra: ", \"version\": 99")
guard case .incompatible(let found, let max) =
CalendarrSnapshotReader.state(fromSnapshot: data, calendars: nil) else {
Issue.record("Erwartet .incompatible"); return
}
#expect(found == 99)
#expect(max == CalendarrSnapshotReader.maxSupportedVersion)
}
@Test("Die heutige Fassung ohne Versionsfeld gilt als Version 1")
func missingVersionIsVersionOne() {
let state = CalendarrSnapshotReader.state(fromSnapshot: snapshotJSON(), calendars: nil)
guard case .ready = state else { Issue.record("Erwartet .ready"); return }
}
@Test("Abgemeldet verwirft die alten Termine")
func loggedOutDropsEvents() {
// Alte Termine eines abgemeldeten Kontos weiter anzuzeigen wäre die
// schlimmste Variante: sie sehen aktuell aus und sind es nicht.
let data = snapshotJSON(events: oneEvent, extra: ", \"loggedOut\": true")
guard case .loggedOut(let asOf) =
CalendarrSnapshotReader.state(fromSnapshot: data, calendars: nil) else {
Issue.record("Erwartet .loggedOut"); return
}
#expect(asOf != nil)
}
}
@Suite("Abdeckung des Calendarr-Fensters")
struct CalendarrCoverageTests {
private let writtenAt = ISO8601DateFormatter().date(from: "2026-08-10T15:22:56Z")!
private let calendar = Calendar(identifier: .gregorian)
@Test("Die Abdeckung beginnt am Tag des Schreibens")
func coverageStartsAtWriteDay() {
let coverage = CalendarrSnapshotReader.coverage(writtenAt: writtenAt, calendar: calendar)
#expect(coverage.start == calendar.startOfDay(for: writtenAt))
}
@Test("Die Abdeckung endet nach dem bekannten Fenster")
func coverageEndsAfterWindow() {
let coverage = CalendarrSnapshotReader.coverage(writtenAt: writtenAt, calendar: calendar)
let expected = calendar.date(byAdding: .day,
value: CalendarrSnapshotReader.coverageDays,
to: calendar.startOfDay(for: writtenAt))!
#expect(coverage.end == expected)
}
@Test("Vergangene Tage gelten als unbekannt, nicht als frei")
func pastIsUnknown() {
// Der eigentliche Punkt. Calendarr schreibt nach vorne. Ein Tag vor dem
// Schreibzeitpunkt ohne Termine kann bedeuten war nichts" oder
// wurde nie geschrieben". Beides sieht gleich aus, also wird es nicht
// als frei behauptet.
let coverage = CalendarrSnapshotReader.coverage(writtenAt: writtenAt, calendar: calendar)
let yesterday = calendar.date(byAdding: .day, value: -1, to: writtenAt)!
#expect(!coverage.contains(yesterday))
}
@Test("Ein weit entfernter Termin liegt außerhalb der Abdeckung")
func farFutureIsOutside() {
let coverage = CalendarrSnapshotReader.coverage(writtenAt: writtenAt, calendar: calendar)
let far = calendar.date(byAdding: .day, value: 120, to: writtenAt)!
#expect(!coverage.contains(far))
}
@Test("Ein mehrtägiger Termin von früher weitet die Abdeckung nicht aus")
func overlappingEventDoesNotWidenCoverage() {
// Verlockend wäre, die Abdeckung aus dem frühesten Termin abzuleiten.
// Das ist falsch: mehrtägige Termine ragen in das Fenster hinein und
// werden mitgeschrieben, obwohl ihr Anfang gar nicht abgedeckt ist.
let events = """
[{"id": "x", "title": "Urlaub", "start": "2026-07-17T22:00:00Z",
"end": "2026-08-12T22:00:00Z", "isAllDay": true, "location": "",
"calendarKey": "local:2", "colorHex": "#000000"}]
"""
let state = CalendarrSnapshotReader.state(
fromSnapshot: snapshotJSON(events: events), calendars: nil,
calendar: calendar)
guard case .ready(let window) = state, let coverage = window.coverage else {
Issue.record("Erwartet .ready mit Abdeckung"); return
}
let julyDay = ISO8601DateFormatter().date(from: "2026-07-20T12:00:00Z")!
#expect(!coverage.contains(julyDay))
}
}

View File

@@ -0,0 +1,123 @@
import Testing
import Foundation
@testable import CalendarProvider
private let day: TimeInterval = 86_400
private let base = Date(timeIntervalSince1970: 1_770_000_000)
private func event(_ id: String, at offset: TimeInterval) -> OnyxEvent {
OnyxEvent(id: id, title: id, start: base + offset, end: base + offset + 3600,
isAllDay: false, colorHex: nil, location: nil,
calendarID: "c", calendarTitle: "C")
}
@Suite("Beide Quellen zusammen")
struct SourceMergeTests {
@Test("Termine beider Quellen erscheinen, nach Zeit sortiert")
func mergesAndSorts() {
let apple = EventWindow(events: [event("spät", at: 2 * day)])
let calendarr = EventWindow(events: [event("früh", at: day)],
coverage: DateInterval(start: base, duration: 49 * day))
let merged = EventWindow.merged([apple, calendarr])
#expect(merged.events.map(\.id) == ["früh", "spät"])
}
@Test("Die Abdeckung ist der Schnitt, nicht die Vereinigung")
func coverageIsIntersection() {
// Der Kern: ein Tag gilt nur dann als bekannt, wenn ihn **jede**
// aktive Quelle abdeckt. Deckt Calendarr den März nicht ab, dann ist
// der März unbekannt auch wenn der Apple-Kalender dafür Auskunft
// geben könnte. Sonst zeigt das Raster einen halben März als frei".
let unbounded = EventWindow(events: [], coverage: nil)
let narrow = EventWindow(events: [],
coverage: DateInterval(start: base, duration: 49 * day))
let merged = EventWindow.merged([unbounded, narrow])
#expect(merged.coverage == DateInterval(start: base, duration: 49 * day))
}
@Test("Zwei begrenzte Quellen ergeben die gemeinsame Schnittmenge")
func twoBoundedIntersect() {
let a = EventWindow(events: [], coverage: DateInterval(start: base, duration: 30 * day))
let b = EventWindow(events: [],
coverage: DateInterval(start: base + 10 * day, duration: 30 * day))
let merged = EventWindow.merged([a, b])
#expect(merged.coverage?.start == base + 10 * day)
#expect(merged.coverage?.end == base + 30 * day)
}
@Test("Ohne Überschneidung ist nichts abgedeckt")
func disjointCoverageIsEmpty() {
let a = EventWindow(events: [], coverage: DateInterval(start: base, duration: day))
let b = EventWindow(events: [],
coverage: DateInterval(start: base + 10 * day, duration: day))
let merged = EventWindow.merged([a, b])
#expect(merged.coverage?.duration == 0)
}
@Test("Nur unbegrenzte Quellen bleiben unbegrenzt")
func allUnboundedStaysUnbounded() {
let merged = EventWindow.merged([EventWindow(events: []), EventWindow(events: [])])
#expect(merged.coverage == nil)
}
@Test("Der Stand ist der älteste — er begrenzt die Verlässlichkeit")
func asOfIsTheOldest() {
// Zeigt man den neueren Stand, behauptet man eine Aktualität, die
// die andere Quelle nicht hat.
let a = EventWindow(events: [], asOf: base)
let b = EventWindow(events: [], asOf: base + day)
#expect(EventWindow.merged([a, b]).asOf == base)
}
@Test("Eine einzelne Quelle bleibt unverändert")
func singleIsUnchanged() {
let only = EventWindow(events: [event("x", at: 0)],
coverage: DateInterval(start: base, duration: day), asOf: base)
#expect(EventWindow.merged([only]) == only)
}
@Test("Gar keine Quelle ergibt leer, nicht abgedeckt")
func noneIsEmpty() {
#expect(EventWindow.merged([]) == .empty)
}
}
@Suite("Zustand beider Quellen")
struct SourceStateMergeTests {
@Test("Ein Fehler einer Quelle verdeckt die andere nicht")
func oneFailureKeepsTheOther() {
// Calendarr nie gestartet zu haben darf den Apple-Kalender nicht
// ausknipsen sonst ist beide" schlechter als jede einzelne.
let merged = CalendarSourceKind.merge([.ready(EventWindow(events: [event("a", at: 0)])),
.neverWritten])
guard case .ready(let window) = merged else {
Issue.record("Erwartet .ready, bekommen \(merged)"); return
}
#expect(window.events.count == 1)
}
@Test("Fällt eine Quelle aus, gilt nur noch die Abdeckung der anderen")
func failedSourceContributesNoCoverage() {
let good = EventWindow(events: [],
coverage: DateInterval(start: base, duration: 10 * day))
guard case .ready(let window) =
CalendarSourceKind.merge([.ready(good), .denied]) else {
Issue.record("Erwartet .ready"); return
}
#expect(window.coverage == good.coverage)
}
@Test("Fallen alle Quellen aus, wird der erste Grund gemeldet")
func allFailedReportsFirstReason() {
// Irgendeine Begründung ist besser als eine leere Liste, die aussieht,
// als wäre nichts los.
#expect(CalendarSourceKind.merge([.denied, .neverWritten]) == .denied)
}
@Test("Ohne Quellen ist der Zustand leer und bereit")
func noSourcesIsReadyEmpty() {
#expect(CalendarSourceKind.merge([]) == .ready(.empty))
}
}

View File

@@ -0,0 +1,112 @@
import Testing
import Foundation
@testable import ShelfProvider
private let day: TimeInterval = 86_400
private let now = Date(timeIntervalSince1970: 1_770_000_000)
private func item(_ name: String, age: TimeInterval = 0) -> ShelfItem {
ShelfItem(url: URL(fileURLWithPath: "/tmp/\(name)"), name: name,
size: 1, addedAt: now - age)
}
@Suite("Namensvergabe in der Ablage")
struct ShelfNamingTests {
@Test("Ein freier Name bleibt, wie er ist")
func freeNameIsKept() {
#expect(ShelfNaming.uniqueName(for: "Vertrag.pdf", taken: []) == "Vertrag.pdf")
}
@Test("Ein belegter Name wird durchnummeriert")
func takenNameIsNumbered() {
// Zwei Dateien gleichen Namens sind der Normalfall, nicht die Ausnahme:
// IMG_0001.jpg" aus zwei Chats. Überschreiben wäre Datenverlust.
#expect(ShelfNaming.uniqueName(for: "Vertrag.pdf", taken: ["Vertrag.pdf"])
== "Vertrag 2.pdf")
}
@Test("Die Nummer zählt weiter, bis etwas frei ist")
func numberingContinues() {
let taken: Set<String> = ["Vertrag.pdf", "Vertrag 2.pdf", "Vertrag 3.pdf"]
#expect(ShelfNaming.uniqueName(for: "Vertrag.pdf", taken: taken) == "Vertrag 4.pdf")
}
@Test("Ohne Endung wird trotzdem nummeriert")
func noExtension() {
#expect(ShelfNaming.uniqueName(for: "README", taken: ["README"]) == "README 2")
}
@Test("Ein führender Punkt ist keine Endung")
func leadingDotIsNotAnExtension() {
// Sonst würde aus .gitignore" ein 2.gitignore" mit führendem
// Leerzeichen ein Name, der nirgends gut aussieht.
#expect(ShelfNaming.uniqueName(for: ".gitignore", taken: [".gitignore"])
== ".gitignore 2")
}
@Test("Mehrfache Endungen bleiben zusammen")
func doubleExtension() {
// `.tar.gz` als `archiv.tar 2.gz` zu zerlegen wäre technisch möglich
// und praktisch verwirrend. Nur die letzte Endung zählt.
#expect(ShelfNaming.uniqueName(for: "archiv.tar.gz", taken: ["archiv.tar.gz"])
== "archiv.tar 2.gz")
}
@Test("Ein leerer Name bekommt einen Ersatz")
func emptyNameGetsFallback() {
#expect(!ShelfNaming.uniqueName(for: "", taken: []).isEmpty)
}
}
@Suite("Automatisches Aufräumen")
struct ShelfCleanupTests {
@Test("Nichts läuft ab, wenn Aufräumen aus ist")
func disabledKeepsEverything() {
// Standard ist aus. Eine Ablage, die ungefragt löscht, ist keine
// Ablage, sondern ein Papierkorb mit Zeitschaltuhr.
let items = [item("alt", age: 400 * day)]
#expect(ShelfCleanup.expired(items, now: now, afterDays: nil).isEmpty)
}
@Test("Was älter als die Frist ist, läuft ab")
func olderThanLimitExpires() {
let items = [item("alt", age: 31 * day), item("neu", age: 2 * day)]
let expired = ShelfCleanup.expired(items, now: now, afterDays: 30)
#expect(expired.map(\.name) == ["alt"])
}
@Test("Genau auf der Frist bleibt es liegen")
func exactlyAtLimitStays() {
// Aufgerundet zugunsten des Nutzers: löschen ist unwiderruflich,
// einen Tag länger liegen zu lassen ist es nicht.
#expect(ShelfCleanup.expired([item("grenze", age: 30 * day)],
now: now, afterDays: 30).isEmpty)
}
@Test("Eine Frist von null löscht nicht sofort alles")
func zeroDaysIsTreatedAsOff() {
// Sonst wäre ein Fehlgriff im Einstellungsfeld der Verlust der Ablage.
#expect(ShelfCleanup.expired([item("neu")], now: now, afterDays: 0).isEmpty)
}
}
@Suite("Reihenfolge in der Ablage")
struct ShelfOrderTests {
@Test("Das Neueste liegt oben")
func newestFirst() {
let items = [item("alt", age: 3 * day), item("neu", age: 1 * day),
item("mittel", age: 2 * day)]
#expect(ShelfItem.sorted(items).map(\.name) == ["neu", "mittel", "alt"])
}
@Test("Gleich alte Dateien stehen stabil nach Namen")
func tiesBreakByName() {
// Ohne festen Zweitschlüssel springt die Liste bei jeder Aktualisierung
// umher, weil das Dateisystem keine Reihenfolge zusagt.
let items = [item("b"), item("a")]
#expect(ShelfItem.sorted(items).map(\.name) == ["a", "b"])
}
}

View File

@@ -97,6 +97,8 @@ targets:
product: NetworkProvider
- package: OnyxKit
product: AudioProvider
- package: OnyxKit
product: ShelfProvider
postBuildScripts:
# Der Helfer und seine Plist müssen an genau diese Stellen: SMAppService
# sucht die Plist unter Contents/Library/LaunchDaemons und liest von dort