From 8308536227ddfbebd11a047c3d007731771437e1 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 11 Aug 2026 10:16:19 +0200 Subject: [PATCH] Phase 8: Ablage, AirDrop und Calendarr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Onyx.xcodeproj/project.pbxproj | 7 + Onyx/Localizable.xcstrings | 1580 +++++++++-------- Onyx/OnyxApp.swift | 11 +- Onyx/PlaceholderWidgets.swift | 4 - Onyx/SettingsView.swift | 49 +- Onyx/SettingsWindowController.swift | 4 +- Packages/OnyxKit/Package.swift | 5 + .../Sources/AudioProvider/MixerView.swift | 16 +- .../CalendarProvider/CalendarSourceKind.swift | 69 + .../CalendarSourceSettings.swift | 106 ++ .../CalendarProvider/CalendarWidget.swift | 97 +- .../CalendarProvider/CalendarrSource.swift | 222 +++ .../CalendarProvider/DarwinObserver.swift | 45 + .../CalendarProvider/Localizable.xcstrings | 378 ++-- .../NetworkProvider/NetworkPopover.swift | 12 +- .../Sources/ShelfProvider/AirDropWidget.swift | 77 + .../Sources/ShelfProvider/FileDropView.swift | 79 + .../ShelfProvider/Localizable.xcstrings | 166 ++ .../Sources/ShelfProvider/ShelfItem.swift | 83 + .../Sources/ShelfProvider/ShelfStore.swift | 137 ++ .../Sources/ShelfProvider/ShelfWidget.swift | 203 +++ .../CalendarrSnapshotTests.swift | 198 +++ .../SourceSelectionTests.swift | 123 ++ .../ShelfProviderTests/ShelfLogicTests.swift | 112 ++ project.yml | 2 + 25 files changed, 2917 insertions(+), 868 deletions(-) create mode 100644 Packages/OnyxKit/Sources/CalendarProvider/CalendarSourceKind.swift create mode 100644 Packages/OnyxKit/Sources/CalendarProvider/CalendarSourceSettings.swift create mode 100644 Packages/OnyxKit/Sources/CalendarProvider/CalendarrSource.swift create mode 100644 Packages/OnyxKit/Sources/CalendarProvider/DarwinObserver.swift create mode 100644 Packages/OnyxKit/Sources/ShelfProvider/AirDropWidget.swift create mode 100644 Packages/OnyxKit/Sources/ShelfProvider/FileDropView.swift create mode 100644 Packages/OnyxKit/Sources/ShelfProvider/Localizable.xcstrings create mode 100644 Packages/OnyxKit/Sources/ShelfProvider/ShelfItem.swift create mode 100644 Packages/OnyxKit/Sources/ShelfProvider/ShelfStore.swift create mode 100644 Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift create mode 100644 Packages/OnyxKit/Tests/CalendarProviderTests/CalendarrSnapshotTests.swift create mode 100644 Packages/OnyxKit/Tests/CalendarProviderTests/SourceSelectionTests.swift create mode 100644 Packages/OnyxKit/Tests/ShelfProviderTests/ShelfLogicTests.swift diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index 134151a..8741db4 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; }; 9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; }; A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; }; + A9617230F602C5D52F63A7E2 /* ShelfProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */; }; B6CD48B41866A468AE7A1BC7 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; }; C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; }; C15B441081E9CCF74B52CADA /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */; }; @@ -81,6 +82,7 @@ C390C5B65769A007A49C3A80 /* MetricsProvider in Frameworks */, 9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */, 75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */, + A9617230F602C5D52F63A7E2 /* ShelfProvider in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -190,6 +192,7 @@ 6CACA1C2A58DEFDE46F71499 /* MetricsProvider */, FF4E99C8BF0D8944C314A8AF /* NetworkProvider */, 5D8203CC3A09D838A4AC385D /* AudioProvider */, + FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */, ); productName = Onyx; productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */; @@ -635,6 +638,10 @@ isa = XCSwiftPackageProductDependency; productName = OnyxWidgetKit; }; + FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */ = { + isa = XCSwiftPackageProductDependency; + productName = ShelfProvider; + }; FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */ = { isa = XCSwiftPackageProductDependency; productName = OnyxHelperProtocol; diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index dae9319..622fc5b 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -1,1141 +1,1253 @@ { - "sourceLanguage" : "en", - "strings" : { - "%@" : { - "comment" : "A label showing the minimum RPM of a fan.", - "isCommentAutoGenerated" : true + "sourceLanguage": "en", + "strings": { + "%@": { + "comment": "A label showing the minimum RPM of a fan.", + "isCommentAutoGenerated": true }, - "%@ U/min" : { - "comment" : "A label that shows the current speed of a fan. The argument is the current speed of the fan.", - "isCommentAutoGenerated" : true + "%@ U/min": { + "comment": "A label that shows the current speed of a fan. The argument is the current speed of the fan.", + "isCommentAutoGenerated": true }, - "%@°" : { - "comment" : "A temperature in °C.", - "isCommentAutoGenerated" : true + "%@°": { + "comment": "A temperature in °C.", + "isCommentAutoGenerated": true }, - "fans.approval.needed" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "macOS wartet auf deine Freigabe. Öffne die Systemeinstellungen und erlaube den Hintergrunddienst von Onyx." + "fans.approval.needed": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "macOS wartet auf deine Freigabe. Öffne die Systemeinstellungen und erlaube den Hintergrunddienst von Onyx." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "macOS is waiting for your approval. Open System Settings and allow Onyx's background service." + "en": { + "stringUnit": { + "state": "translated", + "value": "macOS is waiting for your approval. Open System Settings and allow Onyx's background service." } } } }, - "fans.approval.open" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Systemeinstellungen öffnen" + "fans.approval.open": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemeinstellungen öffnen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open System Settings" + "en": { + "stringUnit": { + "state": "translated", + "value": "Open System Settings" } } } }, - "fans.approval.recheck" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Erneut prüfen" + "fans.approval.recheck": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erneut prüfen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Check again" + "en": { + "stringUnit": { + "state": "translated", + "value": "Check again" } } } }, - "fans.connecting" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verbinde mit dem Hilfsdienst …" + "fans.connecting": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbinde mit dem Hilfsdienst …" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Connecting to the helper…" + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting to the helper…" } } } }, - "fans.hottest" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wärmster Kern" + "fans.hottest": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wärmster Kern" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hottest core" + "en": { + "stringUnit": { + "state": "translated", + "value": "Hottest core" } } } }, - "fans.install" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hilfsdienst einrichten" + "fans.install": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hilfsdienst einrichten" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Install helper" + "en": { + "stringUnit": { + "state": "translated", + "value": "Install helper" } } } }, - "fans.intro" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx kann die Lüfterdrehzahl fest vorgeben, statt sie macOS zu überlassen. Dafür wird ein Hilfsdienst mit Systemrechten eingerichtet — Schreibzugriff auf den SMC geht nicht anders." + "fans.intro": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx kann die Lüfterdrehzahl fest vorgeben, statt sie macOS zu überlassen. Dafür wird ein Hilfsdienst mit Systemrechten eingerichtet — Schreibzugriff auf den SMC geht nicht anders." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx can set a fixed fan speed instead of leaving it to macOS. This requires a helper service with system privileges — writing to the SMC is not possible otherwise." + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx can set a fixed fan speed instead of leaving it to macOS. This requires a helper service with system privileges — writing to the SMC is not possible otherwise." } } } }, - "fans.left" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lüfter links" + "fans.left": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter links" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Left fan" + "en": { + "stringUnit": { + "state": "translated", + "value": "Left fan" } } } }, - "fans.mode.auto" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Automatik" + "fans.mode.auto": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatik" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Automatic" + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic" } } } }, - "fans.mode.manual" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Manuell" + "fans.mode.manual": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Manuell" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Manual" + "en": { + "stringUnit": { + "state": "translated", + "value": "Manual" } } } }, - "fans.reason.noTemperature" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zurück auf Automatik: keine Temperatur messbar, damit lässt sich Sicherheit nicht beurteilen." + "fans.reason.noTemperature": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück auf Automatik: keine Temperatur messbar, damit lässt sich Sicherheit nicht beurteilen." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Back to automatic: no temperature reading, so safety cannot be judged." + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to automatic: no temperature reading, so safety cannot be judged." } } } }, - "fans.reason.other" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zurück auf Automatik." + "fans.reason.other": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück auf Automatik." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Back to automatic." + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to automatic." } } } }, - "fans.reason.temperature" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zurück auf Automatik: zu heiß für eine feste Drehzahl." + "fans.reason.temperature": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück auf Automatik: zu heiß für eine feste Drehzahl." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Back to automatic: too hot for a fixed speed." + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to automatic: too hot for a fixed speed." } } } }, - "fans.reason.unknownLimits" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zurück auf Automatik: der erlaubte Drehzahlbereich ist nicht lesbar." + "fans.reason.unknownLimits": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück auf Automatik: der erlaubte Drehzahlbereich ist nicht lesbar." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Back to automatic: the permitted speed range cannot be read." + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to automatic: the permitted speed range cannot be read." } } } }, - "fans.reason.watchdog" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zurück auf Automatik: Onyx hat sich zu lange nicht gemeldet." + "fans.reason.watchdog": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück auf Automatik: Onyx hat sich zu lange nicht gemeldet." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Back to automatic: Onyx went silent for too long." + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to automatic: Onyx went silent for too long." } } } }, - "fans.right" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lüfter rechts" + "fans.right": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter rechts" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Right fan" + "en": { + "stringUnit": { + "state": "translated", + "value": "Right fan" } } } }, - "fans.safety" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Deshalb greift ein Sicherheitsnetz, das sich nicht abschalten lässt: nie unter das Minimum der Firmware, Rückfall auf Automatik ab 95 °C, Rückfall wenn Onyx sich fünf Sekunden nicht meldet, und Rückfall beim Beenden." + "fans.safety": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Deshalb greift ein Sicherheitsnetz, das sich nicht abschalten lässt: nie unter das Minimum der Firmware, Rückfall auf Automatik ab 95 °C, Rückfall wenn Onyx sich fünf Sekunden nicht meldet, und Rückfall beim Beenden." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "A safety net you cannot switch off is therefore always active: never below the firmware minimum, back to automatic above 95 °C, back to automatic if Onyx goes silent for five seconds, and back to automatic on quit." + "en": { + "stringUnit": { + "state": "translated", + "value": "A safety net you cannot switch off is therefore always active: never below the firmware minimum, back to automatic above 95 °C, back to automatic if Onyx goes silent for five seconds, and back to automatic on quit." } } } }, - "fans.safety.active" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Das Sicherheitsnetz läuft im Hilfsdienst, nicht in Onyx. Es greift auch dann, wenn Onyx abstürzt oder beendet wird." + "fans.safety.active": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Sicherheitsnetz läuft im Hilfsdienst, nicht in Onyx. Es greift auch dann, wenn Onyx abstürzt oder beendet wird." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The safety net runs in the helper, not in Onyx. It works even if Onyx crashes or is quit." + "en": { + "stringUnit": { + "state": "translated", + "value": "The safety net runs in the helper, not in Onyx. It works even if Onyx crashes or is quit." } } } }, - "fans.uninstall" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hilfsdienst entfernen" + "fans.uninstall": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hilfsdienst entfernen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Remove helper" + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove helper" } } } }, - "fans.warning" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Eine zu niedrige Drehzahl kann den Rechner überhitzen lassen. Die Automatik regelt nach Temperatur, ein fester Wert nicht." + "fans.warning": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Eine zu niedrige Drehzahl kann den Rechner überhitzen lassen. Die Automatik regelt nach Temperatur, ein fester Wert nicht." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "A fan speed set too low can let the Mac overheat. The automatic control adjusts to temperature; a fixed value does not." + "en": { + "stringUnit": { + "state": "translated", + "value": "A fan speed set too low can let the Mac overheat. The automatic control adjusts to temperature; a fixed value does not." } } } }, - "menu.openPanel" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Panel öffnen" + "menu.openPanel": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Panel öffnen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open Panel" + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Panel" } } } }, - "menu.quit" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx beenden" + "menu.quit": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx beenden" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Quit Onyx" + "en": { + "stringUnit": { + "state": "translated", + "value": "Quit Onyx" } } } }, - "menu.settings" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Einstellungen …" + "menu.settings": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen …" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Settings…" + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings…" } } } }, - "metric.battery" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Akku" + "metric.battery": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Akku" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Battery" + "en": { + "stringUnit": { + "state": "translated", + "value": "Battery" } } } }, - "metric.cpu" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "CPU" + "metric.cpu": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "CPU" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "CPU" + "en": { + "stringUnit": { + "state": "translated", + "value": "CPU" } } } }, - "metric.gpu" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "GPU" + "metric.gpu": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "GPU" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "GPU" + "en": { + "stringUnit": { + "state": "translated", + "value": "GPU" } } } }, - "metric.memory" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Speicher" + "metric.memory": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Speicher" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Memory" + "en": { + "stringUnit": { + "state": "translated", + "value": "Memory" } } } }, - "metric.mode.temperature" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temperatur" + "metric.mode.temperature": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Temperatur" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temperature" + "en": { + "stringUnit": { + "state": "translated", + "value": "Temperature" } } } }, - "metric.mode.usage" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auslastung" + "metric.mode.usage": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auslastung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Usage" + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage" } } } }, - "metric.sensors" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sensoren" + "metric.sensors": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sensoren" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sensors" + "en": { + "stringUnit": { + "state": "translated", + "value": "Sensors" } } } }, - "network.name" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Netzwerk" + "network.name": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzwerk" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Network" + "en": { + "stringUnit": { + "state": "translated", + "value": "Network" } } } }, - "settings.display.all" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Allen Displays" + "settings.display.all": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Allen Displays" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "All displays" + "en": { + "stringUnit": { + "state": "translated", + "value": "All displays" } } } }, - "settings.display.builtInOnly" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nur dem internen Display" + "settings.display.builtInOnly": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur dem internen Display" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Built-in display only" + "en": { + "stringUnit": { + "state": "translated", + "value": "Built-in display only" } } } }, - "settings.display.dockIcon" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Symbol im Dock anzeigen" + "settings.display.dockIcon": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Symbol im Dock anzeigen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show icon in the Dock" + "en": { + "stringUnit": { + "state": "translated", + "value": "Show icon in the Dock" } } } }, - "settings.display.dockIcon.hint" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Standardmäßig aus. Onyx lebt in der Notch und in der Menüleiste und hat kein Hauptfenster — im Dock wäre es ein Symbol, das beim Anklicken nichts öffnet, und es stünde im Programmumschalter zwischen den Programmen, mit denen du arbeitest." + "settings.display.dockIcon.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Standardmäßig aus. Onyx lebt in der Notch und in der Menüleiste und hat kein Hauptfenster — im Dock wäre es ein Symbol, das beim Anklicken nichts öffnet, und es stünde im Programmumschalter zwischen den Programmen, mit denen du arbeitest." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Off by default. Onyx lives in the notch and the menu bar and has no main window — in the Dock it would be an icon that opens nothing, and it would sit in the app switcher among the apps you actually work with." + "en": { + "stringUnit": { + "state": "translated", + "value": "Off by default. Onyx lives in the notch and the menu bar and has no main window — in the Dock it would be an icon that opens nothing, and it would sit in the app switcher among the apps you actually work with." } } } }, - "settings.display.hint" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auf Displays ohne echte Notch erscheint oben mittig ein Balken, der sich genauso verhält. Ist der Deckel geschlossen und kein internes Display vorhanden, erscheint das Panel trotzdem auf einem externen — sonst wäre Onyx nicht erreichbar." + "settings.display.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf Displays ohne echte Notch erscheint oben mittig ein Balken, der sich genauso verhält. Ist der Deckel geschlossen und kein internes Display vorhanden, erscheint das Panel trotzdem auf einem externen — sonst wäre Onyx nicht erreichbar." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "On displays without a real notch, a bar appears at the top centre and behaves the same. With the lid closed and no built-in display present, the panel still appears on an external one — otherwise Onyx would be unreachable." + "en": { + "stringUnit": { + "state": "translated", + "value": "On displays without a real notch, a bar appears at the top centre and behaves the same. With the lid closed and no built-in display present, the panel still appears on an external one — otherwise Onyx would be unreachable." } } } }, - "settings.display.menuBarIcon" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Symbol in der Menüleiste anzeigen" + "settings.display.menuBarIcon": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Symbol in der Menüleiste anzeigen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show icon in the menu bar" + "en": { + "stringUnit": { + "state": "translated", + "value": "Show icon in the menu bar" } } } }, - "settings.display.menuBarIcon.hint" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Über das Symbol erreichst du Panel, Einstellungen und Beenden. Ohne es kommst du nur über die Notch an Onyx heran." + "settings.display.menuBarIcon.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Über das Symbol erreichst du Panel, Einstellungen und Beenden. Ohne es kommst du nur über die Notch an Onyx heran." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The icon gives you the panel, settings and quit. Without it, the notch is the only way to reach Onyx." + "en": { + "stringUnit": { + "state": "translated", + "value": "The icon gives you the panel, settings and quit. Without it, the notch is the only way to reach Onyx." } } } }, - "settings.display.policy" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Panel anzeigen auf" + "settings.display.policy": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Panel anzeigen auf" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show panel on" + "en": { + "stringUnit": { + "state": "translated", + "value": "Show panel on" } } } }, - "settings.menubar.bars" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Balken" + "settings.menubar.bars": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Balken" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bars" + "en": { + "stringUnit": { + "state": "translated", + "value": "Bars" } } } }, - "settings.menubar.graph" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verlauf" + "settings.menubar.graph": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlauf" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Graph" + "en": { + "stringUnit": { + "state": "translated", + "value": "Graph" } } } }, - "settings.menubar.hint" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jedes Modul erscheint als eigenes Symbol in der Menüleiste und teilt sich die Messung mit dem Widget im Panel. Standardmäßig ist keines an — die Menüleiste ist knapp, auf einem Notch-Display zusätzlich in der Mitte blockiert." + "settings.menubar.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Jedes Modul erscheint als eigenes Symbol in der Menüleiste und teilt sich die Messung mit dem Widget im Panel. Standardmäßig ist keines an — die Menüleiste ist knapp, auf einem Notch-Display zusätzlich in der Mitte blockiert." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Each module appears as its own menu bar icon and shares its measurement with the panel widget. None are on by default — the menu bar is tight, and on a notched display the middle is blocked as well." + "en": { + "stringUnit": { + "state": "translated", + "value": "Each module appears as its own menu bar icon and shares its measurement with the panel widget. None are on by default — the menu bar is tight, and on a notched display the middle is blocked as well." } } } }, - "settings.menubar.symbol" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nur Symbol" + "settings.menubar.symbol": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur Symbol" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Symbol only" + "en": { + "stringUnit": { + "state": "translated", + "value": "Symbol only" } } } }, - "settings.menubar.value" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wert" + "settings.menubar.value": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wert" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Value" + "en": { + "stringUnit": { + "state": "translated", + "value": "Value" } } } }, - "settings.menubar.valueAndGraph" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wert und Verlauf" + "settings.menubar.valueAndGraph": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wert und Verlauf" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Value and graph" + "en": { + "stringUnit": { + "state": "translated", + "value": "Value and graph" } } } }, - "settings.permissions.calendar" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kalender" + "settings.permissions.calendar": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalender" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calendar" + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendar" } } } }, - "settings.permissions.calendar.why" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx zeigt deine Termine im Kalender-Widget an. Es wird ausschließlich gelesen, nie geschrieben." + "settings.permissions.calendar.why": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx zeigt deine Termine im Kalender-Widget an. Es wird ausschließlich gelesen, nie geschrieben." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx shows your events in the calendar widget. It only ever reads, never writes." + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx shows your events in the calendar widget. It only ever reads, never writes." } } } }, - "settings.permissions.granted" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Erteilt" + "settings.permissions.granted": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erteilt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Granted" + "en": { + "stringUnit": { + "state": "translated", + "value": "Granted" } } } }, - "settings.permissions.location" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Standort" + "settings.permissions.location": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Standort" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Location" + "en": { + "stringUnit": { + "state": "translated", + "value": "Location" } } } }, - "settings.permissions.location.why" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx zeigt das Wetter an deinem aktuellen Standort. Ohne Zugriff kannst du stattdessen einen festen Ort wählen." + "settings.permissions.location.why": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx zeigt das Wetter an deinem aktuellen Standort. Ohne Zugriff kannst du stattdessen einen festen Ort wählen." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx shows the weather for your current location. Without access you can pick a fixed place instead." + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx shows the weather for your current location. Without access you can pick a fixed place instead." } } } }, - "settings.permissions.openSystem" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Systemeinstellungen öffnen" + "settings.permissions.openSystem": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemeinstellungen öffnen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open System Settings" + "en": { + "stringUnit": { + "state": "translated", + "value": "Open System Settings" } } } }, - "settings.permissions.request" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anfragen" + "settings.permissions.request": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anfragen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Request" + "en": { + "stringUnit": { + "state": "translated", + "value": "Request" } } } }, - "settings.size.large" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Groß (2×2)" + "settings.shelf.cleanup": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch löschen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Large (2×2)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete automatically" } } } }, - "settings.size.medium" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mittel (2×1)" + "settings.shelf.count %lld": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Dateien abgelegt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Medium (2×1)" + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld files parked" } } } }, - "settings.size.small" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Klein (1×1)" + "settings.shelf.days %lld": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach %lld Tagen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Small (1×1)" + "en": { + "stringUnit": { + "state": "translated", + "value": "After %lld days" } } } }, - "settings.size.wide" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Breit (4×1)" + "settings.shelf.never": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nie" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wide (4×1)" + "en": { + "stringUnit": { + "state": "translated", + "value": "Never" } } } }, - "settings.tab.display" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anzeige" + "settings.shelf.reveal": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Im Finder zeigen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Display" + "en": { + "stringUnit": { + "state": "translated", + "value": "Show in Finder" } } } }, - "settings.tab.fans" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lüfter" + "settings.shelf.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ablage" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fans" + "en": { + "stringUnit": { + "state": "translated", + "value": "Shelf" } } } }, - "settings.tab.menubar" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Menüleiste" + "settings.size.large": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Groß (2×2)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Menu bar" + "en": { + "stringUnit": { + "state": "translated", + "value": "Large (2×2)" } } } }, - "settings.tab.permissions" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Berechtigungen" + "settings.size.medium": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mittel (2×1)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Permissions" + "en": { + "stringUnit": { + "state": "translated", + "value": "Medium (2×1)" } } } }, - "settings.tab.widgets" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Widgets" + "settings.size.small": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Klein (1×1)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Widgets" + "en": { + "stringUnit": { + "state": "translated", + "value": "Small (1×1)" } } } }, - "settings.widgets.add" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hinzufügen" + "settings.size.wide": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Breit (4×1)" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Add" + "en": { + "stringUnit": { + "state": "translated", + "value": "Wide (4×1)" } } } }, - "settings.widgets.available" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verfügbar" + "settings.tab.display": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeige" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Available" + "en": { + "stringUnit": { + "state": "translated", + "value": "Display" } } } }, - "settings.widgets.empty" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Widgets im Panel. Das Panel bleibt leer, bis du eins hinzufügst." + "settings.tab.fans": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No widgets in the panel. It stays empty until you add one." + "en": { + "stringUnit": { + "state": "translated", + "value": "Fans" } } } }, - "settings.widgets.hint" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Die Reihenfolge bestimmt die Anordnung im Panel. Jedes Widget rückt an die erste Stelle, an die es passt." + "settings.tab.menubar": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Menüleiste" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The order determines the arrangement in the panel. Each widget takes the first spot it fits into." + "en": { + "stringUnit": { + "state": "translated", + "value": "Menu bar" } } } }, - "settings.widgets.inPanel" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Im Panel" + "settings.tab.permissions": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Berechtigungen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "In the panel" + "en": { + "stringUnit": { + "state": "translated", + "value": "Permissions" } } } }, - "settings.widgets.remove" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aus dem Panel entfernen" + "settings.tab.sources": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Quellen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Remove from panel" + "en": { + "stringUnit": { + "state": "translated", + "value": "Sources" + } + } + } + }, + "settings.tab.widgets": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Widgets" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Widgets" + } + } + } + }, + "settings.widgets.add": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hinzufügen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" + } + } + } + }, + "settings.widgets.available": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verfügbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Available" + } + } + } + }, + "settings.widgets.empty": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Widgets im Panel. Das Panel bleibt leer, bis du eins hinzufügst." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No widgets in the panel. It stays empty until you add one." + } + } + } + }, + "settings.widgets.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Reihenfolge bestimmt die Anordnung im Panel. Jedes Widget rückt an die erste Stelle, an die es passt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The order determines the arrangement in the panel. Each widget takes the first spot it fits into." + } + } + } + }, + "settings.widgets.inPanel": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Im Panel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "In the panel" + } + } + } + }, + "settings.widgets.remove": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus dem Panel entfernen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove from panel" } } } } }, - "version" : "1.1" -} \ No newline at end of file + "version": "1.1" +} diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index 12a3bbc..30221a8 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -11,6 +11,7 @@ import MediaProvider import MetricsProvider import NetworkProvider import AudioProvider +import ShelfProvider @main struct OnyxApp: App { @@ -39,6 +40,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var mediaModel: MediaModel? private var metricsModel: MetricsModel? private var networkModel: NetworkModel? + private var shelfStore: ShelfStore? private let settingsWindow = SettingsWindowController() private let fanControl = FanControl() private let audioMixer = AudioMixer() @@ -150,7 +152,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Echte Widgets zuerst, danach die Platzhalter für alles, was noch fehlt. private func registerWidgets() { - let calendarModel = CalendarModel(source: EventKitSource()) + let calendarModel = CalendarModel() self.calendarModel = calendarModel WidgetRegistry.shared.register(CalendarWidget(model: calendarModel)) @@ -174,6 +176,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { WidgetRegistry.shared.register(AudioMixerWidget(mixer: audioMixer)) + let shelfStore = ShelfStore() + self.shelfStore = shelfStore + WidgetRegistry.shared.register(ShelfWidget(store: shelfStore)) + WidgetRegistry.shared.register(AirDropWidget()) + PlaceholderWidgets.registerAll() } @@ -272,7 +279,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard let model, let calendarModel, let weatherModel else { return } settingsWindow.show(model: model, calendarModel: calendarModel, weatherModel: weatherModel, metricsModel: metricsModel, - fanControl: fanControl, tab: tab) + shelfStore: shelfStore, fanControl: fanControl, tab: tab) } @objc private func quit() { NSApp.terminate(nil) } diff --git a/Onyx/PlaceholderWidgets.swift b/Onyx/PlaceholderWidgets.swift index 83c11c2..a8be2d5 100644 --- a/Onyx/PlaceholderWidgets.swift +++ b/Onyx/PlaceholderWidgets.swift @@ -46,10 +46,6 @@ enum PlaceholderWidgets { let widgets: [PlaceholderWidget] = [ .init(id: "fans", displayName: "Lüfter", symbolName: "fan", supportedSizes: [.small, .medium], phase: "Phase 6"), - .init(id: "shelf", displayName: "Ablage", symbolName: "tray.full", - supportedSizes: [.small, .medium], phase: "Phase 8"), - .init(id: "airdrop", displayName: "AirDrop", symbolName: "shareplay", - supportedSizes: [.small], phase: "Phase 8"), ] widgets.forEach { WidgetRegistry.shared.register($0) } } diff --git a/Onyx/SettingsView.swift b/Onyx/SettingsView.swift index 8599b1a..fe8dda5 100644 --- a/Onyx/SettingsView.swift +++ b/Onyx/SettingsView.swift @@ -3,13 +3,14 @@ import OnyxDesign import OnyxNotch import OnyxWidgetKit import CalendarProvider +import ShelfProvider import OnyxMenuBar import MetricsProvider import OnyxHelperProtocol import WeatherProvider enum SettingsTab: Hashable { - case widgets, display, menubar, fans, permissions + case widgets, display, sources, menubar, fans, permissions } struct SettingsView: View { @@ -17,17 +18,21 @@ struct SettingsView: View { let calendarModel: CalendarModel let weatherModel: WeatherModel let metricsModel: MetricsModel? + let shelfStore: ShelfStore? let fanControl: FanControl @Binding var selectedTab: SettingsTab var body: some View { TabView(selection: $selectedTab) { - WidgetSettings(model: model) + WidgetSettings(model: model, shelfStore: shelfStore) .tabItem { Label("settings.tab.widgets", systemImage: "square.grid.2x2") } .tag(SettingsTab.widgets) DisplaySettings(model: model) .tabItem { Label("settings.tab.display", systemImage: "macbook") } .tag(SettingsTab.display) + CalendarSourceSettings(model: calendarModel) + .tabItem { Label("settings.tab.sources", systemImage: "calendar.badge.clock") } + .tag(SettingsTab.sources) MenuBarSettings(model: model, metricsModel: metricsModel) .tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") } .tag(SettingsTab.menubar) @@ -279,6 +284,7 @@ extension MenuBarPresentation { private struct WidgetSettings: View { @Bindable var model: AppModel + let shelfStore: ShelfStore? var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -317,6 +323,12 @@ private struct WidgetSettings: View { } } } + + if let shelfStore { + Section("settings.shelf.title") { + ShelfSettingsRows(store: shelfStore) + } + } } .listStyle(.inset) } @@ -324,6 +336,39 @@ private struct WidgetSettings: View { } } +/// Einstellungen der Ablage. +/// +/// Automatisches Aufräumen ist ausdrücklich abschaltbar und standardmäßig aus: +/// eine Ablage, die ungefragt löscht, ist kein Zwischenlager, sondern ein +/// Papierkorb mit Zeitschaltuhr. +private struct ShelfSettingsRows: View { + @Bindable var store: ShelfStore + + private static let choices: [Int?] = [nil, 1, 7, 30, 90] + + var body: some View { + Picker(selection: $store.autoCleanupDays) { + ForEach(Self.choices, id: \.self) { days in + if let days { + Text("settings.shelf.days \(days)").tag(Optional(days)) + } else { + Text("settings.shelf.never").tag(Int?.none) + } + } + } label: { + Text("settings.shelf.cleanup") + } + + HStack { + Text("settings.shelf.count \(store.items.count)") + .foregroundStyle(.secondary) + Spacer() + Button("settings.shelf.reveal") { store.revealInFinder() } + .buttonStyle(.borderless) + } + } +} + private struct PlacementRow: View { @Bindable var model: AppModel let placement: WidgetPlacement diff --git a/Onyx/SettingsWindowController.swift b/Onyx/SettingsWindowController.swift index 89f9048..929c6e5 100644 --- a/Onyx/SettingsWindowController.swift +++ b/Onyx/SettingsWindowController.swift @@ -3,6 +3,7 @@ import SwiftUI import CalendarProvider import WeatherProvider import MetricsProvider +import ShelfProvider /// Führt das Einstellungsfenster selbst. /// @@ -26,7 +27,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { var keepsDockIcon = false func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel, - metricsModel: MetricsModel?, fanControl: FanControl, + metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl, tab: SettingsTab = .widgets) { selectedTab = tab keepsDockIcon = model.showsDockIcon @@ -45,6 +46,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { calendarModel: calendarModel, weatherModel: weatherModel, metricsModel: metricsModel, + shelfStore: shelfStore, fanControl: fanControl, selectedTab: Binding( get: { [weak self] in self?.selectedTab ?? .widgets }, diff --git a/Packages/OnyxKit/Package.swift b/Packages/OnyxKit/Package.swift index aa7cae9..ab78a64 100644 --- a/Packages/OnyxKit/Package.swift +++ b/Packages/OnyxKit/Package.swift @@ -20,6 +20,7 @@ let package = Package( .library(name: "MetricsProvider", targets: ["MetricsProvider"]), .library(name: "NetworkProvider", targets: ["NetworkProvider"]), .library(name: "AudioProvider", targets: ["AudioProvider"]), + .library(name: "ShelfProvider", targets: ["ShelfProvider"]), ], targets: [ // Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt @@ -68,5 +69,9 @@ let package = Package( .target(name: "AudioProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"], resources: [.process("Localizable.xcstrings")]), .testTarget(name: "AudioProviderTests", dependencies: ["AudioProvider"]), + + .target(name: "ShelfProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"], + resources: [.process("Localizable.xcstrings")]), + .testTarget(name: "ShelfProviderTests", dependencies: ["ShelfProvider"]), ] ) diff --git a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift index 57a4bd8..2f3dc8d 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift @@ -36,8 +36,14 @@ public struct MixerView: View { // Der Notausschalter gehört sichtbar dazu, nicht in ein Menü: // wenn der Ton weg ist, ist das der Knopf, den man sucht. Button { mixer.disable() } label: { - Label("mixer.off", systemImage: "power") - .font(Onyx.Font.caption) + // Label(_:systemImage:) kennt kein Bundle und sucht immer + // im Hauptbundle - die Strings liegen aber im Modulbundle. + // Ohne den Umweg über Text steht im Knopf „mixer.off". + Label { + Text("mixer.off", bundle: .module).font(Onyx.Font.caption) + } icon: { + Image(systemName: "power") + } } .buttonStyle(.plain) .foregroundStyle(Onyx.Color.textSecondary) @@ -69,7 +75,11 @@ private struct OffState: View { } Button { mixer.enable() } label: { - Label("mixer.on", systemImage: "slider.horizontal.3").font(Onyx.Font.caption) + Label { + Text("mixer.on", bundle: .module).font(Onyx.Font.caption) + } icon: { + Image(systemName: "slider.horizontal.3") + } } .buttonStyle(.plain) .foregroundStyle(Onyx.Color.accent) diff --git a/Packages/OnyxKit/Sources/CalendarProvider/CalendarSourceKind.swift b/Packages/OnyxKit/Sources/CalendarProvider/CalendarSourceKind.swift new file mode 100644 index 0000000..23cc6a6 --- /dev/null +++ b/Packages/OnyxKit/Sources/CalendarProvider/CalendarSourceKind.swift @@ -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) + } +} diff --git a/Packages/OnyxKit/Sources/CalendarProvider/CalendarSourceSettings.swift b/Packages/OnyxKit/Sources/CalendarProvider/CalendarSourceSettings.swift new file mode 100644 index 0000000..4c105c1 --- /dev/null +++ b/Packages/OnyxKit/Sources/CalendarProvider/CalendarSourceSettings.swift @@ -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))) + } +} diff --git a/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift b/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift index 25bc877..8caf517 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift +++ b/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift @@ -14,18 +14,76 @@ public final class CalendarModel { public private(set) var state: CalendarSourceState = .ready(.empty) public private(set) var isLoading = false - private let source: CalendarSource + /// Welche Quellen gefragt werden. Umschalten lädt sofort neu. + public var kind: CalendarSourceKind { + didSet { + guard kind != oldValue else { return } + defaults.set(kind.rawValue, forKey: Self.kindKey) + observeCalendarrIfNeeded() + refresh() + } + } + + private static let kindKey = "calendar.source" + + private let appleSource: CalendarSource + private let calendarrSource: CalendarSource + private let defaults: UserDefaults private var calendar: Calendar private var refreshTask: Task? + /// `nil` = nicht auf Calendarr-Änderungen angemeldet. + private var calendarrObserver: DarwinObserver? - public init(source: CalendarSource, calendar: Calendar = .autoupdatingCurrent) { - self.source = source + public init(appleSource: CalendarSource = EventKitSource(), + calendarrSource: CalendarSource = CalendarrSource(), + defaults: UserDefaults = .standard, + calendar: Calendar = .autoupdatingCurrent) { + self.appleSource = appleSource + self.calendarrSource = calendarrSource + self.defaults = defaults self.calendar = calendar + self.kind = defaults.string(forKey: Self.kindKey) + .flatMap(CalendarSourceKind.init(rawValue:)) ?? .appleCalendar + observeCalendarrIfNeeded() + } + + /// Die gerade befragten Quellen. + private var activeSources: [CalendarSource] { + switch kind { + case .appleCalendar: [appleSource] + case .calendarr: [calendarrSource] + case .both: [appleSource, calendarrSource] + } } /// Ob gelesen werden darf. Fragt nicht nach — nur zum Anzeigen. - public var hasAccess: Bool { source.isAuthorized } - public var authorization: CalendarAuthorization { source.authorization } + /// + /// **Eine** berechtigte Quelle genügt: bei „beide" darf ein gesperrter + /// Apple-Kalender nicht dazu führen, dass auch Calendarr stumm bleibt. + public var hasAccess: Bool { activeSources.contains(where: \.isAuthorized) } + + /// Der Berechtigungszustand, der die Oberfläche interessiert — also der des + /// Apple-Kalenders, denn nur der kann überhaupt einen Dialog auslösen. + public var authorization: CalendarAuthorization { + kind == .calendarr ? .notRequired : appleSource.authorization + } + + // MARK: - Calendarr-Benachrichtigung + + /// Calendarr meldet neue Schnappschüsse per Darwin-Nachricht — deshalb muss + /// Onyx die Datei nicht abfragen, sondern erfährt Änderungen. + /// + /// Nur angemeldet, solange Calendarr überhaupt gefragt wird. Wer nur den + /// Apple-Kalender nutzt, soll von Calendarr auch nicht geweckt werden. + private func observeCalendarrIfNeeded() { + let wanted = kind != .appleCalendar + guard wanted != (calendarrObserver != nil) else { return } + calendarrObserver = wanted + ? DarwinObserver(name: CalendarrSource.changeNotification) { [weak self] in + Task { @MainActor in self?.refresh() } + } + : nil + } /// Fragt die Berechtigung an und lädt bei Erfolg gleich. /// @@ -36,9 +94,11 @@ public final class CalendarModel { @discardableResult public func requestAccessNow() async -> Bool { log.notice("Kalenderzugriff: Status vor der Anfrage = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)") - let granted = await source.requestAccess() + let granted = await appleSource.requestAccess() log.notice("Kalenderzugriff: erteilt = \(granted, privacy: .public), Status danach = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)") - if granted { refresh() } else { state = .denied } + // Auch bei Ablehnung neu laden statt hart auf `denied` zu setzen: läuft + // Calendarr mit, hat Onyx weiterhin Termine zu zeigen. + refresh() return granted } @@ -56,20 +116,23 @@ public final class CalendarModel { isLoading = true defer { isLoading = false } - // Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer - // App ohne Fenster heraus wird von TCC verworfen, ohne dass etwas - // passiert — die Anfrage muss aus dem Einstellungsfenster kommen. - // Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht - // eine leere Terminliste, die "nichts mehr heute" behaupten würde. - guard source.isAuthorized else { - state = .denied - return - } let start = calendar.date(byAdding: .day, value: -14, to: calendar.startOfDay(for: date))! let end = calendar.date(byAdding: .day, value: 60, to: start)! + let interval = DateInterval(start: start, end: end) + + var states: [CalendarSourceState] = [] + for source in activeSources { + guard !Task.isCancelled else { return } + // Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer + // App ohne Fenster heraus wird von TCC verworfen, ohne dass etwas + // passiert — die Anfrage muss aus dem Einstellungsfenster kommen. + // Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht + // eine leere Terminliste, die "nichts mehr heute" behaupten würde. + states.append(source.isAuthorized ? await source.load(interval) : .denied) + } guard !Task.isCancelled else { return } - state = await source.load(DateInterval(start: start, end: end)) + state = CalendarSourceKind.merge(states) } } diff --git a/Packages/OnyxKit/Sources/CalendarProvider/CalendarrSource.swift b/Packages/OnyxKit/Sources/CalendarProvider/CalendarrSource.swift new file mode 100644 index 0000000..1a9ca93 --- /dev/null +++ b/Packages/OnyxKit/Sources/CalendarProvider/CalendarrSource.swift @@ -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) + } +} diff --git a/Packages/OnyxKit/Sources/CalendarProvider/DarwinObserver.swift b/Packages/OnyxKit/Sources/CalendarProvider/DarwinObserver.swift new file mode 100644 index 0000000..2848b2f --- /dev/null +++ b/Packages/OnyxKit/Sources/CalendarProvider/DarwinObserver.swift @@ -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.fromOpaque(observer) + .takeUnretainedValue().handler() + }, + self.name, nil, .deliverImmediately) + } + + deinit { + CFNotificationCenterRemoveObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + Unmanaged.passUnretained(self).toOpaque(), + CFNotificationName(name), nil) + } +} diff --git a/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings index 75f06ea..f1dca70 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings @@ -1,159 +1,333 @@ { - "sourceLanguage" : "en", - "strings" : { - "" : { - + "sourceLanguage": "en", + "strings": { + "": {}, + "%lld": { + "comment": "A day of the month. The argument is the day of the month.", + "isCommentAutoGenerated": true }, - "%lld" : { - "comment" : "A day of the month. The argument is the day of the month.", - "isCommentAutoGenerated" : true - }, - "widget.calendar.allDay" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ganztägig" + "calendar.source.apple": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Apple Kalender" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "All day" + "en": { + "stringUnit": { + "state": "translated", + "value": "Apple Calendar" } } } }, - "widget.calendar.day.unknown" : { - "comment" : "Erklärt einen ausgegrauten Tag: keine Information, nicht keine Termine", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Für diesen Tag liegen keine Daten vor" + "calendar.source.both": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Beide" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No data available for this day" + "en": { + "stringUnit": { + "state": "translated", + "value": "Both" } } } }, - "widget.calendar.denied" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kein Kalenderzugriff.\nIn den Systemeinstellungen erlauben." + "calendar.source.calendarr": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Calendarr" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No calendar access.\nAllow it in System Settings." + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendarr" } } } }, - "widget.calendar.incompatible" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx ist zu alt für diese\nCalendarr-Version. Bitte aktualisieren." + "calendar.source.readOnly": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx liest nur. Es meldet sich nie selbst an und schreibt nie nach Calendarr." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Onyx is too old for this\nCalendarr version. Please update." + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx only reads. It never signs in and never writes to Calendarr." } } } }, - "widget.calendar.loggedOut" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "In Calendarr anmelden" + "calendar.source.status.coverage": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bekannt bis %@" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sign in to Calendarr" + "en": { + "stringUnit": { + "state": "translated", + "value": "Known through %@" } } } }, - "widget.calendar.name" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kalender" + "calendar.source.status.incompatible": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Calendarr schreibt ein neueres Format. Bitte Onyx aktualisieren." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calendar" + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendarr writes a newer format. Please update Onyx." } } } }, - "widget.calendar.neverWritten" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calendarr einmal öffnen,\ndamit Termine bereitstehen." + "calendar.source.status.loggedOut": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "In Calendarr nicht angemeldet. Alte Termine werden nicht gezeigt." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open Calendarr once\nso events become available." + "en": { + "stringUnit": { + "state": "translated", + "value": "Not signed in to Calendarr. Old events are not shown." } } } }, - "widget.calendar.noUpcoming" : { - "comment" : "Nur zeigen, wenn der Zeitraum wirklich abgedeckt ist", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nichts mehr für heute" + "calendar.source.status.neverWritten": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Calendarr einmal öffnen, damit ein Schnappschuss geschrieben wird." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nothing left today" + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Calendarr once so it can write a snapshot." } } } }, - "widget.calendar.unreadable" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Termine nicht lesbar.\nKonfigurationsfehler." + "calendar.source.status.ready": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ Termine, Stand %@" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Events unreadable.\nConfiguration error." + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ events, as of %@" + } + } + } + }, + "calendar.source.status.unreadable": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schnappschuss unlesbar: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Snapshot unreadable: %@" + } + } + } + }, + "calendar.source.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminquelle" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendar source" + } + } + } + }, + "widget.calendar.allDay": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ganztägig" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "All day" + } + } + } + }, + "widget.calendar.day.unknown": { + "comment": "Erklärt einen ausgegrauten Tag: keine Information, nicht keine Termine", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Für diesen Tag liegen keine Daten vor" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No data available for this day" + } + } + } + }, + "widget.calendar.denied": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Kalenderzugriff.\nIn den Systemeinstellungen erlauben." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No calendar access.\nAllow it in System Settings." + } + } + } + }, + "widget.calendar.incompatible": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx ist zu alt für diese\nCalendarr-Version. Bitte aktualisieren." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx is too old for this\nCalendarr version. Please update." + } + } + } + }, + "widget.calendar.loggedOut": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "In Calendarr anmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in to Calendarr" + } + } + } + }, + "widget.calendar.name": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalender" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendar" + } + } + } + }, + "widget.calendar.neverWritten": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Calendarr einmal öffnen,\ndamit Termine bereitstehen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Calendarr once\nso events become available." + } + } + } + }, + "widget.calendar.noUpcoming": { + "comment": "Nur zeigen, wenn der Zeitraum wirklich abgedeckt ist", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nichts mehr für heute" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing left today" + } + } + } + }, + "widget.calendar.unreadable": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Termine nicht lesbar.\nKonfigurationsfehler." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Events unreadable.\nConfiguration error." } } } } }, - "version" : "1.1" -} \ No newline at end of file + "version": "1.1" +} diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift index 830acb3..42ca614 100644 --- a/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift +++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift @@ -30,9 +30,15 @@ struct NetworkPopoverContent: View { if model.snapshot.interfaceKind == .vpn { // Ein VPN verändert, was die Zahlen darüber bedeuten — der // Verkehr läuft dann durch den Tunnel, nicht direkt. - Label("network.vpn.active", systemImage: "lock.shield") - .font(.caption) - .foregroundStyle(.secondary) + // Über Text statt Label(_:systemImage:): letzteres kennt kein + // Bundle und zeigt sonst den rohen Schlüssel an. + Label { + Text("network.vpn.active", bundle: .module) + } icon: { + Image(systemName: "lock.shield") + } + .font(.caption) + .foregroundStyle(.secondary) } section("network.addresses") diff --git a/Packages/OnyxKit/Sources/ShelfProvider/AirDropWidget.swift b/Packages/OnyxKit/Sources/ShelfProvider/AirDropWidget.swift new file mode 100644 index 0000000..255996f --- /dev/null +++ b/Packages/OnyxKit/Sources/ShelfProvider/AirDropWidget.swift @@ -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()) + } +} diff --git a/Packages/OnyxKit/Sources/ShelfProvider/FileDropView.swift b/Packages/OnyxKit/Sources/ShelfProvider/FileDropView.swift new file mode 100644 index 0000000..5b6511d --- /dev/null +++ b/Packages/OnyxKit/Sources/ShelfProvider/FileDropView.swift @@ -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, 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, + perform: @escaping ([URL]) -> Void) -> some View { + modifier(FileDropModifier(isTargeted: isTargeted, onFiles: perform)) + } +} diff --git a/Packages/OnyxKit/Sources/ShelfProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/ShelfProvider/Localizable.xcstrings new file mode 100644 index 0000000..fdcdfec --- /dev/null +++ b/Packages/OnyxKit/Sources/ShelfProvider/Localizable.xcstrings @@ -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" +} diff --git a/Packages/OnyxKit/Sources/ShelfProvider/ShelfItem.swift b/Packages/OnyxKit/Sources/ShelfProvider/ShelfItem.swift new file mode 100644 index 0000000..4cf38ee --- /dev/null +++ b/Packages/OnyxKit/Sources/ShelfProvider/ShelfItem.swift @@ -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 { + 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.. [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 } + } +} diff --git a/Packages/OnyxKit/Sources/ShelfProvider/ShelfStore.swift b/Packages/OnyxKit/Sources/ShelfProvider/ShelfStore.swift new file mode 100644 index 0000000..f0543ea --- /dev/null +++ b/Packages/OnyxKit/Sources/ShelfProvider/ShelfStore.swift @@ -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]) + } +} diff --git a/Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift b/Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift new file mode 100644 index 0000000..ec8ddff --- /dev/null +++ b/Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift @@ -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 = [] + + 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)) + } +} diff --git a/Packages/OnyxKit/Tests/CalendarProviderTests/CalendarrSnapshotTests.swift b/Packages/OnyxKit/Tests/CalendarProviderTests/CalendarrSnapshotTests.swift new file mode 100644 index 0000000..fe17257 --- /dev/null +++ b/Packages/OnyxKit/Tests/CalendarProviderTests/CalendarrSnapshotTests.swift @@ -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)) + } +} diff --git a/Packages/OnyxKit/Tests/CalendarProviderTests/SourceSelectionTests.swift b/Packages/OnyxKit/Tests/CalendarProviderTests/SourceSelectionTests.swift new file mode 100644 index 0000000..7031683 --- /dev/null +++ b/Packages/OnyxKit/Tests/CalendarProviderTests/SourceSelectionTests.swift @@ -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)) + } +} diff --git a/Packages/OnyxKit/Tests/ShelfProviderTests/ShelfLogicTests.swift b/Packages/OnyxKit/Tests/ShelfProviderTests/ShelfLogicTests.swift new file mode 100644 index 0000000..ff8ca64 --- /dev/null +++ b/Packages/OnyxKit/Tests/ShelfProviderTests/ShelfLogicTests.swift @@ -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 = ["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"]) + } +} diff --git a/project.yml b/project.yml index 457ea0c..41a6d13 100644 --- a/project.yml +++ b/project.yml @@ -97,6 +97,8 @@ targets: product: NetworkProvider - package: OnyxKit product: AudioProvider + - package: OnyxKit + product: ShelfProvider postBuildScripts: # Der Helfer und seine Plist müssen an genau diese Stellen: SMAppService # sucht die Plist unter Contents/Library/LaunchDaemons und liest von dort