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