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:
@@ -23,6 +23,7 @@
|
|||||||
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; };
|
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; };
|
||||||
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; };
|
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; };
|
||||||
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; };
|
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 */; };
|
B6CD48B41866A468AE7A1BC7 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; };
|
||||||
C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; };
|
C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; };
|
||||||
C15B441081E9CCF74B52CADA /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */; };
|
C15B441081E9CCF74B52CADA /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */; };
|
||||||
@@ -81,6 +82,7 @@
|
|||||||
C390C5B65769A007A49C3A80 /* MetricsProvider in Frameworks */,
|
C390C5B65769A007A49C3A80 /* MetricsProvider in Frameworks */,
|
||||||
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */,
|
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */,
|
||||||
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */,
|
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */,
|
||||||
|
A9617230F602C5D52F63A7E2 /* ShelfProvider in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -190,6 +192,7 @@
|
|||||||
6CACA1C2A58DEFDE46F71499 /* MetricsProvider */,
|
6CACA1C2A58DEFDE46F71499 /* MetricsProvider */,
|
||||||
FF4E99C8BF0D8944C314A8AF /* NetworkProvider */,
|
FF4E99C8BF0D8944C314A8AF /* NetworkProvider */,
|
||||||
5D8203CC3A09D838A4AC385D /* AudioProvider */,
|
5D8203CC3A09D838A4AC385D /* AudioProvider */,
|
||||||
|
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */,
|
||||||
);
|
);
|
||||||
productName = Onyx;
|
productName = Onyx;
|
||||||
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
|
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
|
||||||
@@ -635,6 +638,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxWidgetKit;
|
productName = OnyxWidgetKit;
|
||||||
};
|
};
|
||||||
|
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = ShelfProvider;
|
||||||
|
};
|
||||||
FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */ = {
|
FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxHelperProtocol;
|
productName = OnyxHelperProtocol;
|
||||||
|
|||||||
@@ -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": {
|
"settings.size.large": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -1019,6 +1115,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.tab.sources": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Quellen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Sources"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.tab.widgets": {
|
"settings.tab.widgets": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import MediaProvider
|
|||||||
import MetricsProvider
|
import MetricsProvider
|
||||||
import NetworkProvider
|
import NetworkProvider
|
||||||
import AudioProvider
|
import AudioProvider
|
||||||
|
import ShelfProvider
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct OnyxApp: App {
|
struct OnyxApp: App {
|
||||||
@@ -39,6 +40,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
private var mediaModel: MediaModel?
|
private var mediaModel: MediaModel?
|
||||||
private var metricsModel: MetricsModel?
|
private var metricsModel: MetricsModel?
|
||||||
private var networkModel: NetworkModel?
|
private var networkModel: NetworkModel?
|
||||||
|
private var shelfStore: ShelfStore?
|
||||||
private let settingsWindow = SettingsWindowController()
|
private let settingsWindow = SettingsWindowController()
|
||||||
private let fanControl = FanControl()
|
private let fanControl = FanControl()
|
||||||
private let audioMixer = AudioMixer()
|
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.
|
/// Echte Widgets zuerst, danach die Platzhalter für alles, was noch fehlt.
|
||||||
private func registerWidgets() {
|
private func registerWidgets() {
|
||||||
let calendarModel = CalendarModel(source: EventKitSource())
|
let calendarModel = CalendarModel()
|
||||||
self.calendarModel = calendarModel
|
self.calendarModel = calendarModel
|
||||||
WidgetRegistry.shared.register(CalendarWidget(model: calendarModel))
|
WidgetRegistry.shared.register(CalendarWidget(model: calendarModel))
|
||||||
|
|
||||||
@@ -174,6 +176,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
WidgetRegistry.shared.register(AudioMixerWidget(mixer: audioMixer))
|
WidgetRegistry.shared.register(AudioMixerWidget(mixer: audioMixer))
|
||||||
|
|
||||||
|
let shelfStore = ShelfStore()
|
||||||
|
self.shelfStore = shelfStore
|
||||||
|
WidgetRegistry.shared.register(ShelfWidget(store: shelfStore))
|
||||||
|
WidgetRegistry.shared.register(AirDropWidget())
|
||||||
|
|
||||||
PlaceholderWidgets.registerAll()
|
PlaceholderWidgets.registerAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +279,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
guard let model, let calendarModel, let weatherModel else { return }
|
guard let model, let calendarModel, let weatherModel else { return }
|
||||||
settingsWindow.show(model: model, calendarModel: calendarModel,
|
settingsWindow.show(model: model, calendarModel: calendarModel,
|
||||||
weatherModel: weatherModel, metricsModel: metricsModel,
|
weatherModel: weatherModel, metricsModel: metricsModel,
|
||||||
fanControl: fanControl, tab: tab)
|
shelfStore: shelfStore, fanControl: fanControl, tab: tab)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func quit() { NSApp.terminate(nil) }
|
@objc private func quit() { NSApp.terminate(nil) }
|
||||||
|
|||||||
@@ -46,10 +46,6 @@ enum PlaceholderWidgets {
|
|||||||
let widgets: [PlaceholderWidget] = [
|
let widgets: [PlaceholderWidget] = [
|
||||||
.init(id: "fans", displayName: "Lüfter", symbolName: "fan",
|
.init(id: "fans", displayName: "Lüfter", symbolName: "fan",
|
||||||
supportedSizes: [.small, .medium], phase: "Phase 6"),
|
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) }
|
widgets.forEach { WidgetRegistry.shared.register($0) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import OnyxDesign
|
|||||||
import OnyxNotch
|
import OnyxNotch
|
||||||
import OnyxWidgetKit
|
import OnyxWidgetKit
|
||||||
import CalendarProvider
|
import CalendarProvider
|
||||||
|
import ShelfProvider
|
||||||
import OnyxMenuBar
|
import OnyxMenuBar
|
||||||
import MetricsProvider
|
import MetricsProvider
|
||||||
import OnyxHelperProtocol
|
import OnyxHelperProtocol
|
||||||
import WeatherProvider
|
import WeatherProvider
|
||||||
|
|
||||||
enum SettingsTab: Hashable {
|
enum SettingsTab: Hashable {
|
||||||
case widgets, display, menubar, fans, permissions
|
case widgets, display, sources, menubar, fans, permissions
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@@ -17,17 +18,21 @@ struct SettingsView: View {
|
|||||||
let calendarModel: CalendarModel
|
let calendarModel: CalendarModel
|
||||||
let weatherModel: WeatherModel
|
let weatherModel: WeatherModel
|
||||||
let metricsModel: MetricsModel?
|
let metricsModel: MetricsModel?
|
||||||
|
let shelfStore: ShelfStore?
|
||||||
let fanControl: FanControl
|
let fanControl: FanControl
|
||||||
@Binding var selectedTab: SettingsTab
|
@Binding var selectedTab: SettingsTab
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
WidgetSettings(model: model)
|
WidgetSettings(model: model, shelfStore: shelfStore)
|
||||||
.tabItem { Label("settings.tab.widgets", systemImage: "square.grid.2x2") }
|
.tabItem { Label("settings.tab.widgets", systemImage: "square.grid.2x2") }
|
||||||
.tag(SettingsTab.widgets)
|
.tag(SettingsTab.widgets)
|
||||||
DisplaySettings(model: model)
|
DisplaySettings(model: model)
|
||||||
.tabItem { Label("settings.tab.display", systemImage: "macbook") }
|
.tabItem { Label("settings.tab.display", systemImage: "macbook") }
|
||||||
.tag(SettingsTab.display)
|
.tag(SettingsTab.display)
|
||||||
|
CalendarSourceSettings(model: calendarModel)
|
||||||
|
.tabItem { Label("settings.tab.sources", systemImage: "calendar.badge.clock") }
|
||||||
|
.tag(SettingsTab.sources)
|
||||||
MenuBarSettings(model: model, metricsModel: metricsModel)
|
MenuBarSettings(model: model, metricsModel: metricsModel)
|
||||||
.tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") }
|
.tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") }
|
||||||
.tag(SettingsTab.menubar)
|
.tag(SettingsTab.menubar)
|
||||||
@@ -279,6 +284,7 @@ extension MenuBarPresentation {
|
|||||||
|
|
||||||
private struct WidgetSettings: View {
|
private struct WidgetSettings: View {
|
||||||
@Bindable var model: AppModel
|
@Bindable var model: AppModel
|
||||||
|
let shelfStore: ShelfStore?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
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)
|
.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 {
|
private struct PlacementRow: View {
|
||||||
@Bindable var model: AppModel
|
@Bindable var model: AppModel
|
||||||
let placement: WidgetPlacement
|
let placement: WidgetPlacement
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
import CalendarProvider
|
import CalendarProvider
|
||||||
import WeatherProvider
|
import WeatherProvider
|
||||||
import MetricsProvider
|
import MetricsProvider
|
||||||
|
import ShelfProvider
|
||||||
|
|
||||||
/// Führt das Einstellungsfenster selbst.
|
/// Führt das Einstellungsfenster selbst.
|
||||||
///
|
///
|
||||||
@@ -26,7 +27,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
|||||||
var keepsDockIcon = false
|
var keepsDockIcon = false
|
||||||
|
|
||||||
func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
|
func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
|
||||||
metricsModel: MetricsModel?, fanControl: FanControl,
|
metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl,
|
||||||
tab: SettingsTab = .widgets) {
|
tab: SettingsTab = .widgets) {
|
||||||
selectedTab = tab
|
selectedTab = tab
|
||||||
keepsDockIcon = model.showsDockIcon
|
keepsDockIcon = model.showsDockIcon
|
||||||
@@ -45,6 +46,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
|||||||
calendarModel: calendarModel,
|
calendarModel: calendarModel,
|
||||||
weatherModel: weatherModel,
|
weatherModel: weatherModel,
|
||||||
metricsModel: metricsModel,
|
metricsModel: metricsModel,
|
||||||
|
shelfStore: shelfStore,
|
||||||
fanControl: fanControl,
|
fanControl: fanControl,
|
||||||
selectedTab: Binding(
|
selectedTab: Binding(
|
||||||
get: { [weak self] in self?.selectedTab ?? .widgets },
|
get: { [weak self] in self?.selectedTab ?? .widgets },
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ let package = Package(
|
|||||||
.library(name: "MetricsProvider", targets: ["MetricsProvider"]),
|
.library(name: "MetricsProvider", targets: ["MetricsProvider"]),
|
||||||
.library(name: "NetworkProvider", targets: ["NetworkProvider"]),
|
.library(name: "NetworkProvider", targets: ["NetworkProvider"]),
|
||||||
.library(name: "AudioProvider", targets: ["AudioProvider"]),
|
.library(name: "AudioProvider", targets: ["AudioProvider"]),
|
||||||
|
.library(name: "ShelfProvider", targets: ["ShelfProvider"]),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt
|
// Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt
|
||||||
@@ -68,5 +69,9 @@ let package = Package(
|
|||||||
.target(name: "AudioProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
.target(name: "AudioProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
||||||
resources: [.process("Localizable.xcstrings")]),
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
.testTarget(name: "AudioProviderTests", dependencies: ["AudioProvider"]),
|
.testTarget(name: "AudioProviderTests", dependencies: ["AudioProvider"]),
|
||||||
|
|
||||||
|
.target(name: "ShelfProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
||||||
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
|
.testTarget(name: "ShelfProviderTests", dependencies: ["ShelfProvider"]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,8 +36,14 @@ public struct MixerView: View {
|
|||||||
// Der Notausschalter gehört sichtbar dazu, nicht in ein Menü:
|
// Der Notausschalter gehört sichtbar dazu, nicht in ein Menü:
|
||||||
// wenn der Ton weg ist, ist das der Knopf, den man sucht.
|
// wenn der Ton weg ist, ist das der Knopf, den man sucht.
|
||||||
Button { mixer.disable() } label: {
|
Button { mixer.disable() } label: {
|
||||||
Label("mixer.off", systemImage: "power")
|
// Label(_:systemImage:) kennt kein Bundle und sucht immer
|
||||||
.font(Onyx.Font.caption)
|
// 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)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(Onyx.Color.textSecondary)
|
.foregroundStyle(Onyx.Color.textSecondary)
|
||||||
@@ -69,7 +75,11 @@ private struct OffState: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Button { mixer.enable() } label: {
|
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)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(Onyx.Color.accent)
|
.foregroundStyle(Onyx.Color.accent)
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Woher die Termine kommen sollen.
|
||||||
|
public enum CalendarSourceKind: String, CaseIterable, Sendable {
|
||||||
|
case appleCalendar
|
||||||
|
case calendarr
|
||||||
|
case both
|
||||||
|
|
||||||
|
public var localizedName: String {
|
||||||
|
switch self {
|
||||||
|
case .appleCalendar: String(localized: "calendar.source.apple", bundle: .module)
|
||||||
|
case .calendarr: String(localized: "calendar.source.calendarr", bundle: .module)
|
||||||
|
case .both: String(localized: "calendar.source.both", bundle: .module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fasst die Zustände mehrerer Quellen zu einem zusammen.
|
||||||
|
///
|
||||||
|
/// Solange **eine** Quelle liefert, wird geliefert. Calendarr nie geöffnet
|
||||||
|
/// zu haben darf den Apple-Kalender nicht mit ausknipsen — sonst wäre
|
||||||
|
/// „beide" schlechter als jede einzelne Quelle für sich.
|
||||||
|
public static func merge(_ states: [CalendarSourceState]) -> CalendarSourceState {
|
||||||
|
guard !states.isEmpty else { return .ready(.empty) }
|
||||||
|
let windows = states.compactMap { state -> EventWindow? in
|
||||||
|
if case .ready(let window) = state { return window }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Keine einzige Quelle liefert: dann ist der erste Grund die Antwort.
|
||||||
|
// Irgendeine Begründung ist besser als eine leere Liste, die aussieht,
|
||||||
|
// als wäre nichts los.
|
||||||
|
guard !windows.isEmpty else { return states[0] }
|
||||||
|
return .ready(.merged(windows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension EventWindow {
|
||||||
|
|
||||||
|
/// Führt mehrere Fenster zu einem zusammen.
|
||||||
|
static func merged(_ windows: [EventWindow]) -> EventWindow {
|
||||||
|
guard !windows.isEmpty else { return .empty }
|
||||||
|
guard windows.count > 1 else { return windows[0] }
|
||||||
|
|
||||||
|
let events = windows.flatMap(\.events).sorted { $0.start < $1.start }
|
||||||
|
|
||||||
|
// Die Abdeckung ist der **Schnitt**, nicht die Vereinigung: ein Tag
|
||||||
|
// gilt nur als bekannt, wenn ihn jede beteiligte Quelle abdeckt. Deckt
|
||||||
|
// Calendarr den März nicht ab, ist der März unbekannt — auch wenn der
|
||||||
|
// Apple-Kalender dafür Auskunft geben könnte. Sonst zeigte das Raster
|
||||||
|
// einen halben März als „frei", der in Wahrheit nur halb bekannt ist.
|
||||||
|
var coverage: DateInterval?
|
||||||
|
for window in windows {
|
||||||
|
// `nil` heißt unbegrenzt und schränkt daher nichts ein.
|
||||||
|
guard let next = window.coverage else { continue }
|
||||||
|
coverage = coverage.map { current in
|
||||||
|
let start = max(current.start, next.start)
|
||||||
|
let end = min(current.end, next.end)
|
||||||
|
// Ohne Überschneidung bleibt nichts übrig — und das ist die
|
||||||
|
// richtige Antwort, nicht ein beliebiger der beiden Zeiträume.
|
||||||
|
return DateInterval(start: start, end: max(start, end))
|
||||||
|
} ?? next
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der älteste Stand begrenzt die Verlässlichkeit des Ganzen. Den
|
||||||
|
// neueren zu zeigen behauptete eine Aktualität, die nicht alle haben.
|
||||||
|
let asOf = windows.compactMap(\.asOf).min()
|
||||||
|
|
||||||
|
return EventWindow(events: events, coverage: coverage, asOf: asOf)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Die Wahl der Terminquelle — samt ehrlicher Auskunft darüber, was Calendarr
|
||||||
|
/// gerade liefert.
|
||||||
|
///
|
||||||
|
/// Der Zustandstext ist kein Beiwerk: bleibt das Widget leer, ist genau hier
|
||||||
|
/// die Antwort auf das Warum. Ohne ihn sieht „Calendarr nie geöffnet" genauso
|
||||||
|
/// aus wie „keine Termine".
|
||||||
|
public struct CalendarSourceSettings: View {
|
||||||
|
|
||||||
|
@Bindable private var model: CalendarModel
|
||||||
|
@State private var calendarrState: CalendarSourceState?
|
||||||
|
|
||||||
|
public init(model: CalendarModel) {
|
||||||
|
self.model = model
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Picker(selection: $model.kind) {
|
||||||
|
ForEach(CalendarSourceKind.allCases, id: \.self) { kind in
|
||||||
|
Text(kind.localizedName).tag(kind)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("calendar.source.title", bundle: .module)
|
||||||
|
}
|
||||||
|
.pickerStyle(.radioGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.kind != .appleCalendar {
|
||||||
|
Section {
|
||||||
|
LabeledContent {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
statusText
|
||||||
|
Text("calendar.source.readOnly", bundle: .module)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(verbatim: "Calendarr")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.task(id: model.kind) { await loadCalendarrState() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var statusText: some View {
|
||||||
|
switch calendarrState {
|
||||||
|
case .ready(let window):
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("calendar.source.status.ready \(window.events.count.formatted()) \(asOfText(window.asOf))",
|
||||||
|
bundle: .module)
|
||||||
|
if let coverage = window.coverage {
|
||||||
|
// Die Grenze der Abdeckung gehört sichtbar hin: dahinter
|
||||||
|
// zeigt Onyx bewusst „unbekannt" statt „frei", und das
|
||||||
|
// soll niemand für einen Fehler halten.
|
||||||
|
Text("calendar.source.status.coverage \(coverage.end.formatted(date: .abbreviated, time: .omitted))",
|
||||||
|
bundle: .module)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
case .neverWritten:
|
||||||
|
notice("calendar.source.status.neverWritten")
|
||||||
|
case .loggedOut:
|
||||||
|
notice("calendar.source.status.loggedOut")
|
||||||
|
case .incompatible:
|
||||||
|
notice("calendar.source.status.incompatible")
|
||||||
|
case .unreadable(let reason):
|
||||||
|
Text("calendar.source.status.unreadable \(reason)", bundle: .module)
|
||||||
|
.font(.callout).foregroundStyle(.orange)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
case .denied, .none:
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notice(_ key: String.LocalizationValue) -> some View {
|
||||||
|
Text(String(localized: key, bundle: .module))
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func asOfText(_ date: Date?) -> String {
|
||||||
|
date?.formatted(date: .abbreviated, time: .shortened) ?? "–"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadCalendarrState() async {
|
||||||
|
guard model.kind != .appleCalendar else { return }
|
||||||
|
// Bewusst über die Quelle selbst und nicht über das Modell: hier soll
|
||||||
|
// der Zustand von Calendarr allein stehen, nicht die Mischung aus
|
||||||
|
// beiden Quellen. Sonst verdeckt ein funktionierender Apple-Kalender,
|
||||||
|
// dass Calendarr gar nichts liefert.
|
||||||
|
let source = CalendarrSource()
|
||||||
|
let now = Date()
|
||||||
|
calendarrState = await source.load(
|
||||||
|
DateInterval(start: now.addingTimeInterval(-14 * 86_400),
|
||||||
|
end: now.addingTimeInterval(60 * 86_400)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,18 +14,76 @@ public final class CalendarModel {
|
|||||||
public private(set) var state: CalendarSourceState = .ready(.empty)
|
public private(set) var state: CalendarSourceState = .ready(.empty)
|
||||||
public private(set) var isLoading = false
|
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 calendar: Calendar
|
||||||
private var refreshTask: Task<Void, Never>?
|
private var refreshTask: Task<Void, Never>?
|
||||||
|
/// `nil` = nicht auf Calendarr-Änderungen angemeldet.
|
||||||
|
private var calendarrObserver: DarwinObserver?
|
||||||
|
|
||||||
public init(source: CalendarSource, calendar: Calendar = .autoupdatingCurrent) {
|
public init(appleSource: CalendarSource = EventKitSource(),
|
||||||
self.source = source
|
calendarrSource: CalendarSource = CalendarrSource(),
|
||||||
|
defaults: UserDefaults = .standard,
|
||||||
|
calendar: Calendar = .autoupdatingCurrent) {
|
||||||
|
self.appleSource = appleSource
|
||||||
|
self.calendarrSource = calendarrSource
|
||||||
|
self.defaults = defaults
|
||||||
self.calendar = calendar
|
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.
|
/// 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.
|
/// Fragt die Berechtigung an und lädt bei Erfolg gleich.
|
||||||
///
|
///
|
||||||
@@ -36,9 +94,11 @@ public final class CalendarModel {
|
|||||||
@discardableResult
|
@discardableResult
|
||||||
public func requestAccessNow() async -> Bool {
|
public func requestAccessNow() async -> Bool {
|
||||||
log.notice("Kalenderzugriff: Status vor der Anfrage = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)")
|
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)")
|
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
|
return granted
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,20 +116,23 @@ public final class CalendarModel {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
defer { isLoading = false }
|
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
|
// Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer
|
||||||
// App ohne Fenster heraus wird von TCC verworfen, ohne dass etwas
|
// App ohne Fenster heraus wird von TCC verworfen, ohne dass etwas
|
||||||
// passiert — die Anfrage muss aus dem Einstellungsfenster kommen.
|
// passiert — die Anfrage muss aus dem Einstellungsfenster kommen.
|
||||||
// Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht
|
// Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht
|
||||||
// eine leere Terminliste, die "nichts mehr heute" behaupten würde.
|
// eine leere Terminliste, die "nichts mehr heute" behaupten würde.
|
||||||
guard source.isAuthorized else {
|
states.append(source.isAuthorized ? await source.load(interval) : .denied)
|
||||||
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)!
|
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
state = await source.load(DateInterval(start: start, end: end))
|
state = CalendarSourceKind.merge(states)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
222
Packages/OnyxKit/Sources/CalendarProvider/CalendarrSource.swift
Normal file
222
Packages/OnyxKit/Sources/CalendarProvider/CalendarrSource.swift
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Calendarr")
|
||||||
|
|
||||||
|
/// Liest den Schnappschuss, den Calendarr in den Gruppencontainer legt.
|
||||||
|
///
|
||||||
|
/// Streng lesend. Onyx meldet sich nie selbst an, holt nichts aus dem Netz und
|
||||||
|
/// schreibt nie in den Container zurück — der Schnappschuss gehört Calendarr,
|
||||||
|
/// und zwei Schreiber auf einer Datei wären ein Fehler, den man erst bemerkt,
|
||||||
|
/// wenn Termine verschwinden.
|
||||||
|
public enum CalendarrSnapshotReader {
|
||||||
|
|
||||||
|
/// Wie weit nach vorne der Schnappschuss reicht.
|
||||||
|
///
|
||||||
|
/// Steht nicht in der Datei, sondern ist zugesagtes Verhalten des
|
||||||
|
/// Schreibers. Läge der Wert zu niedrig, würde Onyx zu viel ausgrauen —
|
||||||
|
/// unschön, aber ehrlich. Läge er zu hoch, würde es leere Tage als „frei"
|
||||||
|
/// behaupten, für die nie jemand Daten geschrieben hat. Von beiden Fehlern
|
||||||
|
/// ist nur der erste vertretbar, deshalb wird hier nicht großzügig gerundet.
|
||||||
|
public static let coverageDays = 49
|
||||||
|
|
||||||
|
/// Höchste Formatfassung, deren Bedeutung Onyx kennt.
|
||||||
|
public static let maxSupportedVersion = 1
|
||||||
|
|
||||||
|
/// Der Zeitraum, über den der Schnappschuss etwas aussagt.
|
||||||
|
///
|
||||||
|
/// Beginnt am Tag des Schreibens, nicht früher: Calendarr schreibt nach
|
||||||
|
/// vorne. Ein vergangener Tag ohne Termine kann „war nichts" heißen oder
|
||||||
|
/// „wurde nie geschrieben" — das ist von außen nicht unterscheidbar, also
|
||||||
|
/// wird es auch nicht behauptet.
|
||||||
|
public static func coverage(writtenAt: Date, calendar: Calendar) -> DateInterval {
|
||||||
|
let start = calendar.startOfDay(for: writtenAt)
|
||||||
|
let end = calendar.date(byAdding: .day, value: coverageDays, to: start) ?? start
|
||||||
|
return DateInterval(start: start, end: end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wertet Schnappschuss und Kalenderliste aus. Rein — ohne Dateisystem,
|
||||||
|
/// damit jeder Zustand ohne installierte Calendarr-App prüfbar bleibt.
|
||||||
|
public static func state(fromSnapshot snapshot: Data?,
|
||||||
|
calendars: Data?,
|
||||||
|
calendar: Calendar = .autoupdatingCurrent) -> CalendarSourceState {
|
||||||
|
// Keine Datei heißt nicht „kaputt", sondern „Calendarr war noch nie
|
||||||
|
// offen". Dafür gibt es einen eigenen, hilfreicheren Hinweis.
|
||||||
|
guard let snapshot else { return .neverWritten }
|
||||||
|
|
||||||
|
let raw: RawSnapshot
|
||||||
|
do {
|
||||||
|
raw = try JSONDecoder().decode(RawSnapshot.self, from: snapshot)
|
||||||
|
} catch {
|
||||||
|
log.error("Schnappschuss unlesbar: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return .unreadable(reason: error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = raw.version ?? 1
|
||||||
|
guard version <= maxSupportedVersion else {
|
||||||
|
return .incompatible(found: version, maxSupported: maxSupportedVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
let parser = DateParser()
|
||||||
|
let writtenAt = raw.writtenAtDate(parser)
|
||||||
|
|
||||||
|
// Abgemeldet: die vorhandenen Termine sind alt und sehen trotzdem
|
||||||
|
// aktuell aus. Genau deshalb werden sie verworfen statt angezeigt.
|
||||||
|
if raw.loggedOut == true { return .loggedOut(asOf: writtenAt) }
|
||||||
|
|
||||||
|
let titles = calendarTitles(from: calendars)
|
||||||
|
let events = (raw.events ?? [])
|
||||||
|
.compactMap { $0.asOnyxEvent(titles: titles, parser: parser) }
|
||||||
|
.sorted { $0.start < $1.start }
|
||||||
|
|
||||||
|
return .ready(EventWindow(
|
||||||
|
events: events,
|
||||||
|
coverage: writtenAt.map { coverage(writtenAt: $0, calendar: calendar) },
|
||||||
|
asOf: writtenAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Kalenderliste als eigene Datei — sie trägt die Namen, die im
|
||||||
|
/// Schnappschuss selbst nur als Kennung stehen.
|
||||||
|
public static func calendars(from data: Data?) -> [OnyxCalendar] {
|
||||||
|
guard let data,
|
||||||
|
let raw = try? JSONDecoder().decode([RawCalendar].self, from: data)
|
||||||
|
else { return [] }
|
||||||
|
return raw.map { OnyxCalendar(id: $0.id, title: $0.name, colorHex: $0.colorHex) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func calendarTitles(from data: Data?) -> [String: String] {
|
||||||
|
Dictionary(calendars(from: data).map { ($0.id, $0.title) },
|
||||||
|
uniquingKeysWith: { first, _ in first })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rohformat
|
||||||
|
|
||||||
|
private struct RawSnapshot: Decodable {
|
||||||
|
let version: Int?
|
||||||
|
let loggedOut: Bool?
|
||||||
|
let writtenAt: String?
|
||||||
|
let events: [RawEvent]?
|
||||||
|
|
||||||
|
func writtenAtDate(_ parser: DateParser) -> Date? {
|
||||||
|
writtenAt.flatMap(parser.date(from:))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RawCalendar: Decodable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let colorHex: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RawEvent: Decodable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let start: String
|
||||||
|
let end: String
|
||||||
|
let isAllDay: Bool?
|
||||||
|
let location: String?
|
||||||
|
let calendarKey: String?
|
||||||
|
let colorHex: String?
|
||||||
|
|
||||||
|
func asOnyxEvent(titles: [String: String], parser: DateParser) -> OnyxEvent? {
|
||||||
|
guard let start = parser.date(from: start),
|
||||||
|
let end = parser.date(from: end) else { return nil }
|
||||||
|
let key = calendarKey ?? ""
|
||||||
|
return OnyxEvent(
|
||||||
|
id: id, title: title, start: start, end: end,
|
||||||
|
isAllDay: isAllDay ?? false,
|
||||||
|
colorHex: colorHex,
|
||||||
|
// Ein leerer Ort ist kein Ort — sonst steht unter jedem Termin
|
||||||
|
// eine leere Zeile mit Stecknadel.
|
||||||
|
location: (location?.isEmpty ?? true) ? nil : location,
|
||||||
|
calendarID: key,
|
||||||
|
calendarTitle: titles[key] ?? key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calendarr schreibt ISO 8601 in UTC, mal mit und mal ohne Sekundenbruch.
|
||||||
|
///
|
||||||
|
/// Bewusst kein gemeinsamer statischer Formatierer: `ISO8601DateFormatter`
|
||||||
|
/// ist nicht `Sendable`, und ein `nonisolated(unsafe)` wäre hier eine
|
||||||
|
/// Behauptung, die niemand prüft. Einmal je Durchlauf gebaut und
|
||||||
|
/// durchgereicht kostet nichts und ist nachweislich richtig.
|
||||||
|
struct DateParser {
|
||||||
|
private let plain: ISO8601DateFormatter
|
||||||
|
private let withFraction: ISO8601DateFormatter
|
||||||
|
|
||||||
|
init() {
|
||||||
|
plain = ISO8601DateFormatter()
|
||||||
|
plain.formatOptions = [.withInternetDateTime]
|
||||||
|
withFraction = ISO8601DateFormatter()
|
||||||
|
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
}
|
||||||
|
|
||||||
|
func date(from string: String) -> Date? {
|
||||||
|
withFraction.date(from: string) ?? plain.date(from: string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Quelle
|
||||||
|
|
||||||
|
/// Calendarr als Terminquelle.
|
||||||
|
public final class CalendarrSource: CalendarSource, @unchecked Sendable {
|
||||||
|
|
||||||
|
public let id = "calendarr"
|
||||||
|
|
||||||
|
/// Der Gruppencontainer, den Calendarr und Onyx teilen.
|
||||||
|
///
|
||||||
|
/// Onyx läuft ohne Sandbox — der Container ist trotzdem erreichbar, weil
|
||||||
|
/// die Gruppe im Entitlement steht und beide Apps im selben Team signiert
|
||||||
|
/// sind. Siehe docs/spikes/D-appgroup.md.
|
||||||
|
public static let appGroup = "PP34X97WS3.group.com.scarriffleservices.calendarr"
|
||||||
|
|
||||||
|
/// Die Nachricht, mit der Calendarr auf einen neuen Schnappschuss hinweist.
|
||||||
|
public static let changeNotification = "com.scarriffleservices.calendarr.snapshot-changed"
|
||||||
|
|
||||||
|
private let containerURL: URL?
|
||||||
|
private let calendar: Calendar
|
||||||
|
|
||||||
|
public init(calendar: Calendar = .autoupdatingCurrent) {
|
||||||
|
self.containerURL = FileManager.default
|
||||||
|
.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroup)
|
||||||
|
self.calendar = calendar
|
||||||
|
if containerURL == nil {
|
||||||
|
log.error("Gruppencontainer \(Self.appGroup, privacy: .public) nicht erreichbar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calendarr braucht keinen Systemdialog — der Container ist entweder da
|
||||||
|
/// oder die App ist nicht installiert.
|
||||||
|
public var authorization: CalendarAuthorization { .notRequired }
|
||||||
|
public var isAuthorized: Bool { true }
|
||||||
|
public func requestAccess() async -> Bool { true }
|
||||||
|
|
||||||
|
public func calendars() async -> [OnyxCalendar] {
|
||||||
|
CalendarrSnapshotReader.calendars(from: read("widget-calendars.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Das angefragte Intervall wird zum Filtern benutzt, nicht zum Abfragen:
|
||||||
|
/// der Schnappschuss liegt fertig da, seine Reichweite bestimmt Calendarr.
|
||||||
|
public func load(_ interval: DateInterval) async -> CalendarSourceState {
|
||||||
|
guard containerURL != nil else {
|
||||||
|
return .unreadable(reason: "Gruppencontainer nicht erreichbar")
|
||||||
|
}
|
||||||
|
let state = CalendarrSnapshotReader.state(fromSnapshot: read("widget-cache.json"),
|
||||||
|
calendars: read("widget-calendars.json"),
|
||||||
|
calendar: calendar)
|
||||||
|
guard case .ready(let window) = state else { return state }
|
||||||
|
// Die Abdeckung bleibt die des Schnappschusses und wird nicht auf das
|
||||||
|
// angefragte Fenster eingeengt — sonst würde ein schmaler Ausschnitt
|
||||||
|
// wie eine schmale Datenlage aussehen.
|
||||||
|
return .ready(EventWindow(
|
||||||
|
events: window.events.filter { $0.end >= interval.start && $0.start < interval.end },
|
||||||
|
coverage: window.coverage,
|
||||||
|
asOf: window.asOf))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func read(_ name: String) -> Data? {
|
||||||
|
guard let url = containerURL?.appendingPathComponent(name) else { return nil }
|
||||||
|
return try? Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Hört auf eine Darwin-Nachricht und meldet sich beim Abräumen wieder ab.
|
||||||
|
///
|
||||||
|
/// Warum ein eigenes Objekt und nicht einfach ein Feld im Modell: das Modell
|
||||||
|
/// liegt auf dem MainActor, sein `deinit` aber nicht — es dürfte den Beobachter
|
||||||
|
/// dort gar nicht mehr anfassen. Ein Objekt, dessen einzige Aufgabe die
|
||||||
|
/// Anmeldung ist, bindet das Abmelden an genau die richtige Lebensdauer.
|
||||||
|
/// Auf `nil` setzen genügt zum Abmelden.
|
||||||
|
///
|
||||||
|
/// `@unchecked Sendable` ist hier belegbar und keine Behauptung ins Blaue:
|
||||||
|
/// `handler` wird nach `init` nie mehr geschrieben, und der Rückruf liest ihn
|
||||||
|
/// nur. Andere veränderliche Zustände gibt es nicht.
|
||||||
|
final class DarwinObserver: @unchecked Sendable {
|
||||||
|
|
||||||
|
private let name: CFString
|
||||||
|
private let handler: @Sendable () -> Void
|
||||||
|
|
||||||
|
/// - Parameter handler: läuft auf dem Thread, der die Nachricht zustellt —
|
||||||
|
/// nicht zwingend dem Hauptthread. Wer UI anfasst, hüpft selbst hinüber.
|
||||||
|
init(name: String, handler: @escaping @Sendable () -> Void) {
|
||||||
|
self.name = name as CFString
|
||||||
|
self.handler = handler
|
||||||
|
|
||||||
|
CFNotificationCenterAddObserver(
|
||||||
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||||
|
Unmanaged.passUnretained(self).toOpaque(),
|
||||||
|
{ _, observer, _, _, _ in
|
||||||
|
guard let observer else { return }
|
||||||
|
// Unretained: der Beobachter wird beim Abmelden entfernt, also
|
||||||
|
// kann hier kein toter Zeiger ankommen — und ein starker
|
||||||
|
// Verweis hielte das Objekt ewig am Leben.
|
||||||
|
Unmanaged<DarwinObserver>.fromOpaque(observer)
|
||||||
|
.takeUnretainedValue().handler()
|
||||||
|
},
|
||||||
|
self.name, nil, .deliverImmediately)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
CFNotificationCenterRemoveObserver(
|
||||||
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||||
|
Unmanaged.passUnretained(self).toOpaque(),
|
||||||
|
CFNotificationName(name), nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,187 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage": "en",
|
"sourceLanguage": "en",
|
||||||
"strings": {
|
"strings": {
|
||||||
"" : {
|
"": {},
|
||||||
|
|
||||||
},
|
|
||||||
"%lld": {
|
"%lld": {
|
||||||
"comment": "A day of the month. The argument is the day of the month.",
|
"comment": "A day of the month. The argument is the day of the month.",
|
||||||
"isCommentAutoGenerated": true
|
"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": {
|
"widget.calendar.allDay": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
|
|||||||
@@ -30,7 +30,13 @@ struct NetworkPopoverContent: View {
|
|||||||
if model.snapshot.interfaceKind == .vpn {
|
if model.snapshot.interfaceKind == .vpn {
|
||||||
// Ein VPN verändert, was die Zahlen darüber bedeuten — der
|
// Ein VPN verändert, was die Zahlen darüber bedeuten — der
|
||||||
// Verkehr läuft dann durch den Tunnel, nicht direkt.
|
// 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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
77
Packages/OnyxKit/Sources/ShelfProvider/AirDropWidget.swift
Normal file
77
Packages/OnyxKit/Sources/ShelfProvider/AirDropWidget.swift
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
79
Packages/OnyxKit/Sources/ShelfProvider/FileDropView.swift
Normal file
79
Packages/OnyxKit/Sources/ShelfProvider/FileDropView.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
166
Packages/OnyxKit/Sources/ShelfProvider/Localizable.xcstrings
Normal file
166
Packages/OnyxKit/Sources/ShelfProvider/Localizable.xcstrings
Normal 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"
|
||||||
|
}
|
||||||
83
Packages/OnyxKit/Sources/ShelfProvider/ShelfItem.swift
Normal file
83
Packages/OnyxKit/Sources/ShelfProvider/ShelfItem.swift
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
137
Packages/OnyxKit/Sources/ShelfProvider/ShelfStore.swift
Normal file
137
Packages/OnyxKit/Sources/ShelfProvider/ShelfStore.swift
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
203
Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift
Normal file
203
Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
112
Packages/OnyxKit/Tests/ShelfProviderTests/ShelfLogicTests.swift
Normal file
112
Packages/OnyxKit/Tests/ShelfProviderTests/ShelfLogicTests.swift
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,8 @@ targets:
|
|||||||
product: NetworkProvider
|
product: NetworkProvider
|
||||||
- package: OnyxKit
|
- package: OnyxKit
|
||||||
product: AudioProvider
|
product: AudioProvider
|
||||||
|
- package: OnyxKit
|
||||||
|
product: ShelfProvider
|
||||||
postBuildScripts:
|
postBuildScripts:
|
||||||
# Der Helfer und seine Plist müssen an genau diese Stellen: SMAppService
|
# Der Helfer und seine Plist müssen an genau diese Stellen: SMAppService
|
||||||
# sucht die Plist unter Contents/Library/LaunchDaemons und liest von dort
|
# sucht die Plist unter Contents/Library/LaunchDaemons und liest von dort
|
||||||
|
|||||||
Reference in New Issue
Block a user