diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index 05d241f..39b6e8a 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -7,17 +7,22 @@ objects = { /* Begin PBXBuildFile section */ + 033116546F93AE6C61A0D9D7 /* NotchAccessories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FDB63D9352C7D2F8406B1DF /* NotchAccessories.swift */; }; + 08B6F17F5D3B33A8B0F06626 /* PowerActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EDDADF00C8A57744A63191 /* PowerActivity.swift */; }; 0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; }; 0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12041E330231599C6540C21 /* FanSettingsView.swift */; }; 1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */; }; 1E2D654EFDDF55AEA75DBB7C /* WeatherProvider in Frameworks */ = {isa = PBXBuildFile; productRef = D4578EB64F7A3372B49BC50C /* WeatherProvider */; }; + 29DEDC6D874DB6FF77DC4338 /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE37042F7F905EBDE0975279 /* AppearanceSettingsView.swift */; }; 2BB6AFD10DFB13FC4467BE6B /* SMCAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */; }; 3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */; }; 378A5A417112CCB31EF3F774 /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; }; 3991CB2BF41C6C014EF4528F /* chip.svg in Resources */ = {isa = PBXBuildFile; fileRef = 085D860E654E09E4CD6E535A /* chip.svg */; }; + 39B06041FD90361FA72CB386 /* ClipboardProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 913CFE7A8E299E11C2BF23CF /* ClipboardProvider */; }; 39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; }; 3C7D32A39B906BD7385B1231 /* bolt-light.svg in Resources */ = {isa = PBXBuildFile; fileRef = B8214C5599D1857E832447EC /* bolt-light.svg */; }; 4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */ = {isa = PBXBuildFile; productRef = C64A111F449D0F9D36ED46FB /* OnyxCore */; }; + 534B041763B7D4B03DFF53D4 /* ActivityProvider in Frameworks */ = {isa = PBXBuildFile; productRef = B9524DE253CBAF4344096790 /* ActivityProvider */; }; 574FDD46FEBAC96E38CDFC7A /* bolt-dark.svg in Resources */ = {isa = PBXBuildFile; fileRef = 7A866E72010907DF4BEF025C /* bolt-dark.svg */; }; 5BE38CAFCDA4B9CBE787CABA /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; }; 68EE5495435A82E20248EBC2 /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = 91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */; }; @@ -26,6 +31,7 @@ 75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 5D8203CC3A09D838A4AC385D /* AudioProvider */; }; 7CE2EDC6BC58678269BF27AD /* MenuBarIcon.svg in Resources */ = {isa = PBXBuildFile; fileRef = 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */; }; 8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6115888EE7738877C9304138 /* MixerMenuBarModule.swift */; }; + 98AE792A2A346EDAB358728A /* WindowPlacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2585137DB7F67F91768E0EF /* WindowPlacement.swift */; }; 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 */; }; @@ -40,8 +46,11 @@ C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A5510538846C8427AEB831 /* OnboardingView.swift */; }; CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3FDFD387188D36AF8287DE /* FanControl.swift */; }; CD501EBAE39807F5D354C5B2 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; }; - E8B839A850BE54F32410C2C8 /* ColorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */; }; + DD41F48611EAFC1DC5B28FD4 /* GalleryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C758A5ABC2F748BE7306038 /* GalleryView.swift */; }; + DD98BEF77E614424C4A37291 /* GalleryWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F63DBABD5B2905865FD4FA2 /* GalleryWindowController.swift */; }; + E7A8CABF31F3CC88C7CB0176 /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4472730A92A48657BDCEB9 /* SettingsSection.swift */; }; EA4D6312E8A96E177F10797F /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; }; + EF4351407AC9DCDDE225C96F /* NotesProvider in Frameworks */ = {isa = PBXBuildFile; productRef = F8AC40480E05171EB8091269 /* NotesProvider */; }; F2A3AD184864422EA14A8C64 /* graphic-card.svg in Resources */ = {isa = PBXBuildFile; fileRef = 1E6A1C208C7EA56971A055E1 /* graphic-card.svg */; }; FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 055263008CB3306A8D09E6D4 /* AppIcon.icon */; }; /* End PBXBuildFile section */ @@ -60,7 +69,9 @@ 055263008CB3306A8D09E6D4 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; 0804288DC4A8146DD9F3FC3E /* Onyx.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Onyx.app; sourceTree = BUILT_PRODUCTS_DIR; }; 085D860E654E09E4CD6E535A /* chip.svg */ = {isa = PBXFileReference; path = chip.svg; sourceTree = ""; }; + 0FDB63D9352C7D2F8406B1DF /* NotchAccessories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchAccessories.swift; sourceTree = ""; }; 16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetOptionsView.swift; sourceTree = ""; }; + 1C758A5ABC2F748BE7306038 /* GalleryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryView.swift; sourceTree = ""; }; 1E6A1C208C7EA56971A055E1 /* graphic-card.svg */ = {isa = PBXFileReference; path = "graphic-card.svg"; sourceTree = ""; }; 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */ = {isa = PBXFileReference; path = MenuBarIcon.svg; sourceTree = ""; }; 41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLogin.swift; sourceTree = ""; }; @@ -73,15 +84,19 @@ 63A5510538846C8427AEB831 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 7A866E72010907DF4BEF025C /* bolt-dark.svg */ = {isa = PBXFileReference; path = "bolt-dark.svg"; sourceTree = ""; }; 821B742C211265914B6CC44B /* OnyxKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = OnyxKit; path = Packages/OnyxKit; sourceTree = SOURCE_ROOT; }; + 85EDDADF00C8A57744A63191 /* PowerActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerActivity.swift; sourceTree = ""; }; 87597F09E971C14CF1E475DB /* Onyx.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Onyx.entitlements; sourceTree = ""; }; 89309D9F5722FA1B91CF2905 /* FanWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanWidget.swift; sourceTree = ""; }; 8B7ECCFCBA1BAAC6583D4A39 /* OnyxHelper */ = {isa = PBXFileReference; includeInIndex = 0; path = OnyxHelper; sourceTree = BUILT_PRODUCTS_DIR; }; 8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */ = {isa = PBXFileReference; path = "sound-waves.svg"; sourceTree = ""; }; 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = ""; }; + 8F63DBABD5B2905865FD4FA2 /* GalleryWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryWindowController.swift; sourceTree = ""; }; + AE37042F7F905EBDE0975279 /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = ""; }; B442E8A39C37C8FD92B4CA83 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; B8214C5599D1857E832447EC /* bolt-light.svg */ = {isa = PBXFileReference; path = "bolt-light.svg"; sourceTree = ""; }; B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; - DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorSettingsView.swift; sourceTree = ""; }; + DF4472730A92A48657BDCEB9 /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = ""; }; + E2585137DB7F67F91768E0EF /* WindowPlacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowPlacement.swift; sourceTree = ""; }; F12041E330231599C6540C21 /* FanSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanSettingsView.swift; sourceTree = ""; }; F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnyxApp.swift; sourceTree = ""; }; FC3FDFD387188D36AF8287DE /* FanControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanControl.swift; sourceTree = ""; }; @@ -105,6 +120,9 @@ 9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */, 75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */, A9617230F602C5D52F63A7E2 /* ShelfProvider in Frameworks */, + 534B041763B7D4B03DFF53D4 /* ActivityProvider in Frameworks */, + 39B06041FD90361FA72CB386 /* ClipboardProvider in Frameworks */, + EF4351407AC9DCDDE225C96F /* NotesProvider in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -166,29 +184,35 @@ isa = PBXGroup; children = ( 0FBA13B97C53F1D0D740A633 /* Resources */, + AE37042F7F905EBDE0975279 /* AppearanceSettingsView.swift */, 055263008CB3306A8D09E6D4 /* AppIcon.icon */, B83C4E3CB821F903F3977E88 /* AppModel.swift */, 7A866E72010907DF4BEF025C /* bolt-dark.svg */, B8214C5599D1857E832447EC /* bolt-light.svg */, 085D860E654E09E4CD6E535A /* chip.svg */, - DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */, FC3FDFD387188D36AF8287DE /* FanControl.swift */, F12041E330231599C6540C21 /* FanSettingsView.swift */, 89309D9F5722FA1B91CF2905 /* FanWidget.swift */, + 1C758A5ABC2F748BE7306038 /* GalleryView.swift */, + 8F63DBABD5B2905865FD4FA2 /* GalleryWindowController.swift */, 1E6A1C208C7EA56971A055E1 /* graphic-card.svg */, 50A018673EE6BD9C50F1EFA0 /* Info.plist */, 41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */, 508DECB5C05264E6130C373E /* Localizable.xcstrings */, 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */, 6115888EE7738877C9304138 /* MixerMenuBarModule.swift */, + 0FDB63D9352C7D2F8406B1DF /* NotchAccessories.swift */, 63A5510538846C8427AEB831 /* OnboardingView.swift */, 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */, 87597F09E971C14CF1E475DB /* Onyx.entitlements */, F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */, + 85EDDADF00C8A57744A63191 /* PowerActivity.swift */, + DF4472730A92A48657BDCEB9 /* SettingsSection.swift */, 45BD2DF1B775C733C8F75635 /* SettingsView.swift */, 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */, 8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */, 16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */, + E2585137DB7F67F91768E0EF /* WindowPlacement.swift */, ); path = Onyx; sourceTree = ""; @@ -226,6 +250,9 @@ FF4E99C8BF0D8944C314A8AF /* NetworkProvider */, 5D8203CC3A09D838A4AC385D /* AudioProvider */, FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */, + B9524DE253CBAF4344096790 /* ActivityProvider */, + 913CFE7A8E299E11C2BF23CF /* ClipboardProvider */, + F8AC40480E05171EB8091269 /* NotesProvider */, ); productName = Onyx; productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */; @@ -358,18 +385,24 @@ buildActionMask = 2147483647; files = ( C041F171FB69F4381309D978 /* AppModel.swift in Sources */, - E8B839A850BE54F32410C2C8 /* ColorSettingsView.swift in Sources */, + 29DEDC6D874DB6FF77DC4338 /* AppearanceSettingsView.swift in Sources */, CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */, 0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */, 6CD3F5666C39F54506B8430A /* FanWidget.swift in Sources */, + DD41F48611EAFC1DC5B28FD4 /* GalleryView.swift in Sources */, + DD98BEF77E614424C4A37291 /* GalleryWindowController.swift in Sources */, B309DD8966B5348250CEC6BD /* LaunchAtLogin.swift in Sources */, 8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */, + 033116546F93AE6C61A0D9D7 /* NotchAccessories.swift in Sources */, C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */, 1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */, 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */, + 08B6F17F5D3B33A8B0F06626 /* PowerActivity.swift in Sources */, + E7A8CABF31F3CC88C7CB0176 /* SettingsSection.swift in Sources */, 39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */, 0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */, 3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */, + 98AE792A2A346EDAB358728A /* WindowPlacement.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -658,6 +691,10 @@ isa = XCSwiftPackageProductDependency; productName = MetricsProvider; }; + 913CFE7A8E299E11C2BF23CF /* ClipboardProvider */ = { + isa = XCSwiftPackageProductDependency; + productName = ClipboardProvider; + }; 91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */ = { isa = XCSwiftPackageProductDependency; productName = OnyxHelperProtocol; @@ -670,6 +707,10 @@ isa = XCSwiftPackageProductDependency; productName = OnyxMenuBar; }; + B9524DE253CBAF4344096790 /* ActivityProvider */ = { + isa = XCSwiftPackageProductDependency; + productName = ActivityProvider; + }; C64A111F449D0F9D36ED46FB /* OnyxCore */ = { isa = XCSwiftPackageProductDependency; productName = OnyxCore; @@ -682,6 +723,10 @@ isa = XCSwiftPackageProductDependency; productName = OnyxWidgetKit; }; + F8AC40480E05171EB8091269 /* NotesProvider */ = { + isa = XCSwiftPackageProductDependency; + productName = NotesProvider; + }; FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */ = { isa = XCSwiftPackageProductDependency; productName = ShelfProvider; diff --git a/Packages/OnyxKit/Package.swift b/Packages/OnyxKit/Package.swift index 874057b..b1c72f0 100644 --- a/Packages/OnyxKit/Package.swift +++ b/Packages/OnyxKit/Package.swift @@ -21,6 +21,10 @@ let package = Package( .library(name: "NetworkProvider", targets: ["NetworkProvider"]), .library(name: "AudioProvider", targets: ["AudioProvider"]), .library(name: "ShelfProvider", targets: ["ShelfProvider"]), + .library(name: "ActivityProvider", targets: ["ActivityProvider"]), + .library(name: "ClipboardProvider", targets: ["ClipboardProvider"]), + .library(name: "NotesProvider", targets: ["NotesProvider"]), + .library(name: "FaceProvider", targets: ["FaceProvider"]), ], targets: [ // Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt @@ -73,8 +77,23 @@ let package = Package( resources: [.process("Localizable.xcstrings")]), .testTarget(name: "AudioProviderTests", dependencies: ["AudioProvider"]), + .target(name: "FaceProvider"), + .testTarget(name: "FaceProviderTests", dependencies: ["FaceProvider"]), + + .target(name: "NotesProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"], + resources: [.process("Localizable.xcstrings")]), + .testTarget(name: "NotesProviderTests", dependencies: ["NotesProvider"]), + + .target(name: "ClipboardProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"], + resources: [.process("Localizable.xcstrings")]), + .testTarget(name: "ClipboardProviderTests", dependencies: ["ClipboardProvider"]), + .target(name: "ShelfProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"], resources: [.process("Localizable.xcstrings")]), + // Live Activities: die Warteschlange ist reine Rechnung ohne AppKit, + // die Darstellung hängt am Notch-Panel. + .target(name: "ActivityProvider", dependencies: ["OnyxDesign", "OnyxNotch"]), .testTarget(name: "ShelfProviderTests", dependencies: ["ShelfProvider"]), + .testTarget(name: "ActivityProviderTests", dependencies: ["ActivityProvider"]), ] ) diff --git a/Packages/OnyxKit/Sources/ClipboardProvider/ClipboardItem.swift b/Packages/OnyxKit/Sources/ClipboardProvider/ClipboardItem.swift new file mode 100644 index 0000000..d963282 --- /dev/null +++ b/Packages/OnyxKit/Sources/ClipboardProvider/ClipboardItem.swift @@ -0,0 +1,132 @@ +import Foundation + +/// Ein gemerkter Eintrag der Zwischenablage. +/// +/// Der **Inhalt steht nicht hier drin**, sondern in einer Datei daneben. Was +/// hier steht, ist genug für die Liste — und wenig genug, dass die Indexdatei +/// klein bleibt, auch bei fünfhundert Einträgen. +public struct ClipboardItem: Identifiable, Equatable, Sendable, Codable { + + public enum Kind: String, Codable, Sendable { + case text + case image + } + + public let id: UUID + public let kind: Kind + /// Was in der Kachel zu lesen ist. Bei Bildern der Dateiname oder das Maß. + public let preview: String + public let addedAt: Date + /// Aus welchem Programm es kam — für die Ausschlussliste und die Anzeige. + public let sourceBundleID: String? + /// Erkennt Wiederholungen, ohne den ganzen Inhalt im Speicher zu halten. + /// + /// Dieselbe Zeile zweimal zu kopieren ist der Normalfall, nicht die + /// Ausnahme — sie soll nach oben rutschen und nicht ein zweites Mal + /// dastehen. + public let fingerprint: String + + public init(id: UUID = UUID(), kind: Kind, preview: String, addedAt: Date, + sourceBundleID: String?, fingerprint: String) { + self.id = id + self.kind = kind + self.preview = preview + self.addedAt = addedAt + self.sourceBundleID = sourceBundleID + self.fingerprint = fingerprint + } +} + +/// Die Regeln der Zwischenablage — ohne Pasteboard, ohne Dateisystem, ohne Uhr. +/// +/// Alles hier ist reine Rechnung und damit prüfbar. Was mit dem System zu tun +/// hat, steht in ``ClipboardStore``. +public enum ClipboardRules { + + /// Wie viele Einträge höchstens gemerkt werden. + /// + /// Eine Grenze muss es geben: ohne sie wächst der Verlauf, bis das Laden + /// spürbar wird — und niemand scrollt durch tausend Einträge. + public static let limit = 200 + + /// Wie lang eine Vorschauzeile höchstens ist. + public static let previewLength = 120 + + /// Typen, bei denen ausdrücklich **nicht** gemerkt werden soll. + /// + /// Passwortmanager setzen `ConcealedType`, wenn sie ein Passwort ablegen; + /// `TransientType` heißt „nur zum Durchreichen"; `AutoGeneratedType` setzen + /// Werkzeuge, die etwas ohne Zutun des Menschen kopieren. Diese drei Marken + /// sind eine Verabredung zwischen Programmen, kein Systemdienst — wer sie + /// missachtet, sammelt fremde Passwörter ein. + public static let ignoredTypes: Set = [ + "org.nspasteboard.TransientType", + "org.nspasteboard.ConcealedType", + "org.nspasteboard.AutoGeneratedType", + ] + + /// Ob eine Änderung überhaupt gemerkt werden darf. + public static func shouldRecord(types: [String], + sourceBundleID: String?, + excludedApps: Set) -> Bool { + guard types.allSatisfy({ !ignoredTypes.contains($0) }) else { return false } + guard let sourceBundleID else { return true } + return !excludedApps.contains(sourceBundleID) + } + + /// Die eine Zeile, die in der Liste steht. + /// + /// Zeilenumbrüche werden zu Leerzeichen: ein mehrzeiliger Ausschnitt würde + /// die Kachel sprengen, und die zweite Zeile sagt selten mehr als die + /// erste. + public static func preview(for text: String) -> String { + let flat = text + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + .replacingOccurrences(of: "\t", with: " ") + let collapsed = flat.split(separator: " ", omittingEmptySubsequences: true).joined(separator: " ") + guard collapsed.count > previewLength else { return collapsed } + return String(collapsed.prefix(previewLength)) + "…" + } + + /// Nimmt einen Eintrag auf: Wiederholungen rutschen nach oben, statt sich + /// anzureihen, und über der Grenze fällt das Älteste heraus. + /// + /// Gibt zusätzlich zurück, welche Einträge dabei verdrängt wurden — deren + /// Dateien muss der Aufrufer wegräumen. + public static func merge(_ items: [ClipboardItem], + adding item: ClipboardItem, + limit: Int = limit) -> (items: [ClipboardItem], + dropped: [ClipboardItem]) { + var dropped = items.filter { $0.fingerprint == item.fingerprint } + var kept = items.filter { $0.fingerprint != item.fingerprint } + kept.insert(item, at: 0) + + if kept.count > limit { + dropped.append(contentsOf: kept[limit...]) + kept = Array(kept.prefix(limit)) + } + return (kept, dropped) + } + + /// Was zu alt geworden ist. `nil` als Frist heißt: nichts verfällt. + public static func expired(_ items: [ClipboardItem], + olderThanDays days: Int?, + now: Date) -> [ClipboardItem] { + guard let days, days > 0 else { return [] } + let cutoff = now.addingTimeInterval(-Double(days) * 86_400) + return items.filter { $0.addedAt < cutoff } + } + + /// Neuestes zuerst. + /// + /// Die Kennung als Zweitschlüssel, damit zwei im selben Augenblick + /// gemerkte Einträge nicht bei jeder Anzeige die Plätze tauschen. + public static func sorted(_ items: [ClipboardItem]) -> [ClipboardItem] { + items.sorted { + $0.addedAt == $1.addedAt + ? $0.id.uuidString > $1.id.uuidString + : $0.addedAt > $1.addedAt + } + } +} diff --git a/Packages/OnyxKit/Sources/ClipboardProvider/ClipboardStore.swift b/Packages/OnyxKit/Sources/ClipboardProvider/ClipboardStore.swift new file mode 100644 index 0000000..dabb872 --- /dev/null +++ b/Packages/OnyxKit/Sources/ClipboardProvider/ClipboardStore.swift @@ -0,0 +1,247 @@ +import AppKit +import Observation +import CryptoKit +import os + +private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Clipboard") + +/// Der Verlauf der Zwischenablage. +/// +/// **Abgetastet, nicht gemeldet.** macOS kennt keine Benachrichtigung über eine +/// geänderte Zwischenablage — es gibt nur `changeCount`, einen Zähler, der +/// hochgeht. Zweimal je Sekunde nachzusehen ist der übliche Preis; dasselbe tut +/// die Lüftersteuerung mit dem SMC. +/// +/// **Inhalte gehen in Dateien, nie in `UserDefaults`.** Was dort steht, liest +/// jedes Programm des Benutzers mit `defaults read` mit — für Kopiertes, das +/// auch mal eine Adresse oder eine Kontonummer ist, ist das der falsche Ort. +@MainActor +@Observable +public final class ClipboardStore { + + public private(set) var items: [ClipboardItem] = [] + + /// Nach wie vielen Tagen Einträge verfallen. `nil` heißt: nie. + public var expiryDays: Int? { + didSet { + guard expiryDays != oldValue else { return } + defaults.set(expiryDays ?? 0, forKey: Keys.expiryDays) + purgeExpired() + } + } + + /// Programme, aus denen nie gemerkt wird. + /// + /// Über die `TransientType`-Marken hinaus: nicht jeder Passwortmanager + /// setzt sie, und manches Programm kopiert Dinge, die einen nichts angehen. + public var excludedApps: Set { + didSet { + guard excludedApps != oldValue else { return } + defaults.set(Array(excludedApps).sorted(), forKey: Keys.excludedApps) + } + } + + /// Ob überhaupt gemerkt wird. Aus heißt: der Verlauf bleibt stehen, es + /// kommt nur nichts Neues dazu. + public var isRecording: Bool { + didSet { + guard isRecording != oldValue else { return } + defaults.set(isRecording, forKey: Keys.isRecording) + } + } + + @ObservationIgnored private let directory: URL + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored private let pasteboard: NSPasteboard + @ObservationIgnored private var lastChangeCount: Int + @ObservationIgnored private var timer: Timer? + /// Was Onyx selbst in die Ablage gelegt hat — das soll nicht als neuer + /// Eintrag zurückkommen. + @ObservationIgnored private var ownChangeCount: Int? + + private enum Keys { + static let expiryDays = "onyx.clipboard.expiryDays" + static let excludedApps = "onyx.clipboard.excludedApps" + static let isRecording = "onyx.clipboard.isRecording" + } + + /// Zweimal je Sekunde. Öfter merkt niemand, seltener verpasst man einen + /// schnellen Doppelgriff. + private static let interval: TimeInterval = 0.5 + + public init(directory: URL? = nil, + defaults: UserDefaults = .standard, + pasteboard: NSPasteboard = .general) { + self.directory = directory ?? Self.defaultDirectory + self.defaults = defaults + self.pasteboard = pasteboard + self.lastChangeCount = pasteboard.changeCount + + let days = defaults.object(forKey: Keys.expiryDays) as? Int + // Null steht für „nie" — ein eigener Wert wäre ein zweiter Weg, dasselbe + // zu sagen. + self.expiryDays = (days == nil || days == 0) ? 7 : days + self.excludedApps = Set(defaults.stringArray(forKey: Keys.excludedApps) ?? []) + self.isRecording = defaults.object(forKey: Keys.isRecording) as? Bool ?? true + + try? FileManager.default.createDirectory(at: self.directory, + withIntermediateDirectories: true) + load() + purgeExpired() + start() + } + + public static var defaultDirectory: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Onyx/Clipboard", isDirectory: true) + } + + // MARK: - Abtasten + + private func start() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: Self.interval, repeats: true) { _ in + MainActor.assumeIsolated { [weak self] in self?.poll() } + } + } + + public func stop() { + timer?.invalidate() + timer = nil + } + + private func poll() { + let count = pasteboard.changeCount + guard count != lastChangeCount else { return } + lastChangeCount = count + + // Was Onyx selbst eingesetzt hat, ist kein neuer Fund. + if ownChangeCount == count { ownChangeCount = nil; return } + guard isRecording else { return } + capture() + } + + private func capture() { + let types = pasteboard.types?.map(\.rawValue) ?? [] + let source = NSWorkspace.shared.frontmostApplication?.bundleIdentifier + + guard ClipboardRules.shouldRecord(types: types, + sourceBundleID: source, + excludedApps: excludedApps) else { return } + + if let text = pasteboard.string(forType: .string), + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + add(kind: .text, preview: ClipboardRules.preview(for: text), + payload: Data(text.utf8), source: source) + } else if let image = pasteboard.data(forType: .tiff) ?? pasteboard.data(forType: .png) { + let size = NSImage(data: image)?.size ?? .zero + add(kind: .image, + preview: "\(Int(size.width)) × \(Int(size.height))", + payload: image, source: source) + } + } + + private func add(kind: ClipboardItem.Kind, preview: String, payload: Data, source: String?) { + let fingerprint = SHA256.hash(data: payload).map { String(format: "%02x", $0) }.joined() + let item = ClipboardItem(kind: kind, preview: preview, addedAt: Date(), + sourceBundleID: source, fingerprint: fingerprint) + + let result = ClipboardRules.merge(items, adding: item) + items = result.items + write(payload, for: item) + result.dropped.forEach(removeFile) + save() + } + + // MARK: - Zurücklegen + + /// Legt einen Eintrag wieder in die Zwischenablage. + public func copyBack(_ item: ClipboardItem) { + guard let data = try? Data(contentsOf: fileURL(for: item)) else { + log.error("Inhalt von \(item.id, privacy: .public) nicht lesbar") + return + } + pasteboard.clearContents() + switch item.kind { + case .text: + pasteboard.setString(String(decoding: data, as: UTF8.self), forType: .string) + case .image: + pasteboard.setData(data, forType: .tiff) + } + // Der eigene Griff soll nicht als neuer Eintrag zurückkommen. + ownChangeCount = pasteboard.changeCount + lastChangeCount = pasteboard.changeCount + } + + public func remove(_ item: ClipboardItem) { + items.removeAll { $0.id == item.id } + removeFile(item) + save() + } + + public func removeAll() { + items.forEach(removeFile) + items = [] + save() + } + + public func purgeExpired() { + let stale = ClipboardRules.expired(items, olderThanDays: expiryDays, now: Date()) + guard !stale.isEmpty else { return } + let ids = Set(stale.map(\.id)) + items.removeAll { ids.contains($0.id) } + stale.forEach(removeFile) + save() + } + + // MARK: - Ablage + + private var indexURL: URL { directory.appendingPathComponent("index.json") } + + private func fileURL(for item: ClipboardItem) -> URL { + directory.appendingPathComponent("\(item.id.uuidString).\(item.kind == .text ? "txt" : "tiff")") + } + + /// Die Indexdatei trägt ihre Fassung mit. + /// + /// Ohne Nummer ist eine spätere Änderung am Format nicht von einer kaputten + /// Datei zu unterscheiden — dasselbe Vorgehen wie in `LayoutStore`. + private struct Index: Codable { + var version = 1 + var items: [ClipboardItem] + } + + private func load() { + guard let data = try? Data(contentsOf: indexURL) else { return } + guard let index = try? JSONDecoder().decode(Index.self, from: data), index.version == 1 else { + log.notice("Unbekannte Fassung der Indexdatei — Verlauf beginnt neu") + return + } + // Nur, was auch wirklich noch daliegt: eine Zeile ohne Datei wäre ein + // Eintrag, der beim Anklicken nichts tut. + items = ClipboardRules.sorted(index.items.filter { + FileManager.default.fileExists(atPath: fileURL(for: $0).path) + }) + } + + private func save() { + do { + let data = try JSONEncoder().encode(Index(items: items)) + try data.write(to: indexURL, options: .atomic) + } catch { + log.error("Index nicht schreibbar: \(error.localizedDescription, privacy: .public)") + } + } + + private func write(_ payload: Data, for item: ClipboardItem) { + do { + try payload.write(to: fileURL(for: item), options: .atomic) + } catch { + log.error("Inhalt nicht schreibbar: \(error.localizedDescription, privacy: .public)") + } + } + + private func removeFile(_ item: ClipboardItem) { + try? FileManager.default.removeItem(at: fileURL(for: item)) + } +} diff --git a/Packages/OnyxKit/Sources/ClipboardProvider/ClipboardWidget.swift b/Packages/OnyxKit/Sources/ClipboardProvider/ClipboardWidget.swift new file mode 100644 index 0000000..820b481 --- /dev/null +++ b/Packages/OnyxKit/Sources/ClipboardProvider/ClipboardWidget.swift @@ -0,0 +1,106 @@ +import SwiftUI +import AppKit +import OnyxDesign +import OnyxWidgetKit + +/// Der Verlauf der Zwischenablage, zum Zurückholen. +public struct ClipboardView: View { + private let store: ClipboardStore + private let compact: Bool + @State private var hovered: UUID? + @State private var justCopied: UUID? + + public init(store: ClipboardStore, compact: Bool = false) { + self.store = store + self.compact = compact + } + + public var body: some View { + VStack(alignment: .leading, spacing: Onyx.Space.sm) { + if store.items.isEmpty { + OnyxNotice(symbol: "doc.on.clipboard", + text: store.isRecording + ? String(localized: "clipboard.empty", bundle: .module) + : String(localized: "clipboard.paused", bundle: .module)) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(store.items.prefix(compact ? 5 : 40).enumerated()), + id: \.element.id) { index, item in + if index > 0 { OnyxHairline() } + row(item) + } + } + } + .scrollIndicators(.never) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func row(_ item: ClipboardItem) -> some View { + // Die ganze Zeile ist der Knopf. Ein kleines Ziel neben dem Text wäre + // genauer zu treffen zu verlangen, als die Aufgabe es hergibt. + Button { copy(item) } label: { + HStack(spacing: Onyx.Space.row) { + Image(systemName: item.kind == .image ? "photo" : "text.alignleft") + .font(.system(size: Onyx.Symbol.small)) + .foregroundStyle(Onyx.Color.textTertiary) + .frame(width: 16) + + Text(item.preview) + .font(Onyx.Font.footnote) + .foregroundStyle(Onyx.Color.textPrimary) + .lineLimit(1) + .truncationMode(.tail) + + Spacer(minLength: Onyx.Space.xs) + + if justCopied == item.id { + // Eine Rückmeldung, die nichts verschiebt: ohne sie sieht + // ein Klick, der die Ablage füllt, wie ein Klick aus, der + // nichts getan hat. + Image(systemName: "checkmark") + .font(.system(size: Onyx.Symbol.caption)) + .foregroundStyle(Onyx.Color.positive) + } else if hovered == item.id { + Button { store.remove(item) } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: Onyx.Symbol.small)) + .foregroundStyle(Onyx.Color.textTertiary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, Onyx.Space.row) + .padding(.vertical, compact ? Onyx.Space.sm : Onyx.Space.md) + .contentShape(.rect) + } + .buttonStyle(.plain) + .background(hovered == item.id ? Onyx.Color.elevated : .clear) + .onHover { hovered = $0 ? item.id : (hovered == item.id ? nil : hovered) } + } + + private func copy(_ item: ClipboardItem) { + store.copyBack(item) + justCopied = item.id + Task { + try? await Task.sleep(for: .seconds(1.2)) + if justCopied == item.id { justCopied = nil } + } + } +} + +public struct ClipboardWidget: OnyxWidget { + public let id = "clipboard" + public var displayName: String { String(localized: "clipboard.name", bundle: .module) } + public let symbolName = "doc.on.clipboard" + + private let store: ClipboardStore + + public init(store: ClipboardStore) { self.store = store } + + public func makeView() -> AnyView { + AnyView(ClipboardView(store: store)) + } +} diff --git a/Packages/OnyxKit/Sources/ClipboardProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/ClipboardProvider/Localizable.xcstrings new file mode 100644 index 0000000..76fd7eb --- /dev/null +++ b/Packages/OnyxKit/Sources/ClipboardProvider/Localizable.xcstrings @@ -0,0 +1,54 @@ +{ + "sourceLanguage": "en", + "strings": { + "clipboard.name": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zwischenablage" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clipboard" + } + } + } + }, + "clipboard.empty": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Was du kopierst, erscheint hier." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "What you copy shows up here." + } + } + } + }, + "clipboard.paused": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Verlauf ist angehalten." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "History is paused." + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/Packages/OnyxKit/Sources/FaceProvider/FaceMatcher.swift b/Packages/OnyxKit/Sources/FaceProvider/FaceMatcher.swift new file mode 100644 index 0000000..9c2f402 --- /dev/null +++ b/Packages/OnyxKit/Sources/FaceProvider/FaceMatcher.swift @@ -0,0 +1,138 @@ +import Foundation + +/// Ein hinterlegtes Gesicht — ein Merkmalsvektor aus einer Aufnahme. +/// +/// Mehrere davon je Person, aus verschiedenen Posen. Ein einzelner Vektor +/// beschreibt eine Kopfhaltung bei einer Beleuchtung, nicht ein Gesicht. +public struct FaceTemplate: Equatable, Sendable, Codable { + public let id: UUID + /// Wofür die Aufnahme gedacht war — nur zur Anzeige beim Anlernen. + public let pose: String + /// Der Merkmalsvektor. 768 Elemente aus + /// `VNGenerateImageFeaturePrintRequest`; siehe `docs/spikes/G-gesichtsmerkmal.md`. + public let vector: [Float] + + public init(id: UUID = UUID(), pose: String, vector: [Float]) { + self.id = id + self.pose = pose + self.vector = vector + } +} + +/// Wie ein Vergleich ausgegangen ist. +public enum FaceDecision: Equatable, Sendable { + /// Erkannt — genug übereinstimmende Einzelbilder hintereinander. + case accepted + /// Noch nicht genug. Der Aufrufer soll weiter Bilder liefern. + case undecided(confirmations: Int) + /// Abgelehnt und gesperrt: ab hier hilft nur noch das Passwort. + case lockedOut +} + +/// Der Vergleich — reine Rechnung, ohne Kamera und ohne Vision. +/// +/// **Warum das ein eigener Typ ist und keine Methode am Kameracode.** Genau hier +/// entscheidet sich, ob ein Fremder hereinkommt. Das muss man mit abgelegten +/// Vektoren durchspielen können, ohne sich vor eine Kamera zu setzen — mit +/// Grenzfällen, die man vor einer Kamera gar nicht herstellen könnte. +/// +/// **Und warum die Schwelle streng ist.** Ein falsches Ja ist ein Fremder am +/// entsperrten Rechner. Ein falsches Nein ist ein Passwort, das man ohnehin +/// kennt. Die beiden Fehler sind nicht gleich teuer, also ist die Schwelle nicht +/// mittig. +public struct FaceMatcher: Sendable { + + /// Ab welchem Abstand zwei Vektoren als dieselbe Person gelten. + /// + /// Aus dem Spike: ähnliche Bilder lagen bei 0,13, deutlich verschiedene bei + /// 0,47. Die Schwelle liegt bewusst näher am Ähnlichen — siehe oben. + public static let defaultThreshold: Float = 0.22 + + /// Wie viele Einzelbilder hintereinander passen müssen. + /// + /// Eines genügt nicht: die Kamera liefert dreißig Bilder je Sekunde, und + /// unter dreißig ist irgendwann eines dabei, das zufällig passt. + public static let defaultConfirmations = 3 + + /// Nach wie vielen Fehlversuchen Schluss ist. + public static let defaultAttempts = 10 + + public let threshold: Float + public let requiredConfirmations: Int + public let maximumAttempts: Int + + private let templates: [FaceTemplate] + + public init(templates: [FaceTemplate], + threshold: Float = defaultThreshold, + requiredConfirmations: Int = defaultConfirmations, + maximumAttempts: Int = defaultAttempts) { + self.templates = templates + self.threshold = threshold + self.requiredConfirmations = requiredConfirmations + self.maximumAttempts = maximumAttempts + } + + /// Ob überhaupt ein Gesicht hinterlegt ist. + public var isEnrolled: Bool { !templates.isEmpty } + + /// Der kleinste Abstand zu allen hinterlegten Vektoren. + /// + /// Der kleinste und nicht der mittlere: die Posen sind absichtlich + /// verschieden, und ein Mittelwert über verschiedene Posen bestraft genau + /// die Vielfalt, für die sie aufgenommen wurden. + public func distance(to vector: [Float]) -> Float? { + templates.compactMap { Self.distance($0.vector, vector) }.min() + } + + /// Euklidischer Abstand. `nil` bei ungleicher Länge — zwei verschieden + /// lange Vektoren zu vergleichen ergäbe eine Zahl ohne Bedeutung. + public static func distance(_ a: [Float], _ b: [Float]) -> Float? { + guard a.count == b.count, !a.isEmpty else { return nil } + var sum: Float = 0 + for i in a.indices { + let d = a[i] - b[i] + sum += d * d + } + return sum.squareRoot() + } + + /// Der laufende Versuch: zählt Bestätigungen und Fehlversuche mit. + public struct Session: Sendable { + public private(set) var confirmations = 0 + public private(set) var attempts = 0 + public private(set) var isLockedOut = false + + public init() {} + + fileprivate mutating func confirm() { confirmations += 1 } + fileprivate mutating func fail(limit: Int) { + attempts += 1 + // Bestätigungen laufen **nicht** über Fehlversuche hinweg weiter: + // sonst summierten sich zufällige Treffer über Minuten zu einem Ja. + confirmations = 0 + if attempts >= limit { isLockedOut = true } + } + } + + /// Nimmt ein Einzelbild entgegen und sagt, wie es weitergeht. + public func evaluate(_ vector: [Float], in session: inout Session) -> FaceDecision { + guard !session.isLockedOut else { return .lockedOut } + // Ohne hinterlegtes Gesicht gibt es nichts zu vergleichen — dann ist + // jeder Versuch ein Fehlversuch und nicht etwa ein Freifahrtschein. + guard isEnrolled, let distance = distance(to: vector) else { + session.fail(limit: maximumAttempts) + return session.isLockedOut ? .lockedOut : .undecided(confirmations: 0) + } + + if distance <= threshold { + session.confirm() + return session.confirmations >= requiredConfirmations + ? .accepted + : .undecided(confirmations: session.confirmations) + } + + session.fail(limit: maximumAttempts) + return session.isLockedOut ? .lockedOut : .undecided(confirmations: 0) + } +} diff --git a/Packages/OnyxKit/Sources/NotesProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/NotesProvider/Localizable.xcstrings new file mode 100644 index 0000000..ed1dfd0 --- /dev/null +++ b/Packages/OnyxKit/Sources/NotesProvider/Localizable.xcstrings @@ -0,0 +1,38 @@ +{ + "sourceLanguage": "en", + "strings": { + "notes.name": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Notizen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Notes" + } + } + } + }, + "notes.empty": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch nichts notiert." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing noted yet." + } + } + } + } + }, + "version": "1.0" +} \ No newline at end of file diff --git a/Packages/OnyxKit/Sources/NotesProvider/Note.swift b/Packages/OnyxKit/Sources/NotesProvider/Note.swift new file mode 100644 index 0000000..881b33d --- /dev/null +++ b/Packages/OnyxKit/Sources/NotesProvider/Note.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Eine kurze Notiz. +/// +/// Kein Titel als eigenes Feld: wer eine Notiz schreibt, will schreiben und +/// nicht erst benennen. Die Überschrift ist die erste Zeile — dieselbe +/// Verabredung, die Apples Notizen seit jeher treffen. +public struct Note: Identifiable, Equatable, Sendable, Codable { + public let id: UUID + public var text: String + public var updatedAt: Date + + public init(id: UUID = UUID(), text: String = "", updatedAt: Date = Date()) { + self.id = id + self.text = text + self.updatedAt = updatedAt + } +} + +/// Die Regeln der Notizen — ohne Dateisystem und ohne Uhr. +public enum NoteRules { + + /// Wie viele Notizen höchstens. + /// + /// Die Kachel ist klein und die Liste soll überschaubar bleiben. Wer mehr + /// braucht, braucht ein Notizprogramm und keine Notch. + public static let limit = 50 + + /// Wie lang eine Überschrift höchstens ist. + public static let titleLength = 60 + + /// Die erste nichtleere Zeile, gekürzt. Leer bleibt leer — daraus macht die + /// Anzeige „Neue Notiz", und das ist ihre Sache, nicht unsere. + public static func title(of note: Note) -> String { + let line = note.text + .split(whereSeparator: \.isNewline) + .first { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + guard let line else { return "" } + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.count > titleLength else { return trimmed } + return String(trimmed.prefix(titleLength)) + "…" + } + + /// Ob eine Notiz überhaupt etwas enthält. + /// + /// Eine Notiz aus lauter Leerzeichen ist keine — sie entsteht, wenn man auf + /// „Neu" drückt und es sich anders überlegt, und soll dann von selbst + /// verschwinden statt als leere Zeile stehen zu bleiben. + public static func isEmpty(_ note: Note) -> Bool { + note.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Zuletzt geändert zuerst, mit fester Reihenfolge bei Gleichstand. + public static func sorted(_ notes: [Note]) -> [Note] { + notes.sorted { + $0.updatedAt == $1.updatedAt + ? $0.id.uuidString > $1.id.uuidString + : $0.updatedAt > $1.updatedAt + } + } + + /// Nimmt eine geänderte Notiz auf: leere fallen weg, über der Grenze fällt + /// die älteste heraus. + public static func merge(_ notes: [Note], updating note: Note, + limit: Int = limit) -> [Note] { + var kept = notes.filter { $0.id != note.id } + if !isEmpty(note) { kept.append(note) } + let sorted = sorted(kept) + return Array(sorted.prefix(limit)) + } +} diff --git a/Packages/OnyxKit/Sources/NotesProvider/NotesStore.swift b/Packages/OnyxKit/Sources/NotesProvider/NotesStore.swift new file mode 100644 index 0000000..0a44046 --- /dev/null +++ b/Packages/OnyxKit/Sources/NotesProvider/NotesStore.swift @@ -0,0 +1,77 @@ +import Foundation +import Observation +import os + +private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Notes") + +/// Die Notizen und ihre Ablage. +/// +/// Eine Datei für alles: Notizen sind kurz, und fünfzig davon sind ein paar +/// Kilobyte. Eine Datei je Notiz wäre der Ordnung wegen — nicht der Sache. +/// +/// **Nicht in `UserDefaults`.** Was dort steht, liest jedes Programm des +/// Benutzers mit `defaults read` mit, und eine Notiz ist oft genau das, was man +/// sich merkt, weil man es nicht im Kopf behalten will. +@MainActor +@Observable +public final class NotesStore { + + public private(set) var notes: [Note] = [] + + @ObservationIgnored private let fileURL: URL + + private struct Archive: Codable { + var version = 1 + var notes: [Note] + } + + public init(directory: URL? = nil) { + let folder = directory ?? Self.defaultDirectory + try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + fileURL = folder.appendingPathComponent("notes.json") + load() + } + + public static var defaultDirectory: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Onyx/Notes", isDirectory: true) + } + + /// Legt eine leere Notiz an und gibt sie zurück, damit die Anzeige den + /// Schreibcursor hineinsetzen kann. + /// + /// Sie steht noch **nicht** in der Liste: eine leere Notiz ist keine, und + /// wer auf „Neu" drückt und es sich anders überlegt, soll keine Leerzeile + /// hinterlassen. Erst der erste Buchstabe macht sie zur Notiz. + public func makeDraft() -> Note { Note() } + + public func update(_ note: Note) { + var updated = note + updated.updatedAt = Date() + notes = NoteRules.merge(notes, updating: updated) + save() + } + + public func remove(_ note: Note) { + notes.removeAll { $0.id == note.id } + save() + } + + private func load() { + guard let data = try? Data(contentsOf: fileURL) else { return } + guard let archive = try? JSONDecoder().decode(Archive.self, from: data), + archive.version == 1 else { + log.notice("Unbekannte Fassung der Notizdatei — sie bleibt unangetastet") + return + } + notes = NoteRules.sorted(archive.notes) + } + + private func save() { + do { + try JSONEncoder().encode(Archive(notes: notes)).write(to: fileURL, options: .atomic) + } catch { + log.error("Notizen nicht schreibbar: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/Packages/OnyxKit/Sources/NotesProvider/NotesWidget.swift b/Packages/OnyxKit/Sources/NotesProvider/NotesWidget.swift new file mode 100644 index 0000000..eed3b87 --- /dev/null +++ b/Packages/OnyxKit/Sources/NotesProvider/NotesWidget.swift @@ -0,0 +1,132 @@ +import SwiftUI +import OnyxDesign +import OnyxWidgetKit + +/// Kurze Notizen, direkt in der Kachel zu schreiben. +public struct NotesView: View { + private let store: NotesStore + private let compact: Bool + + @State private var editing: Note? + @State private var draft = "" + @FocusState private var isWriting: Bool + + public init(store: NotesStore, compact: Bool = false) { + self.store = store + self.compact = compact + } + + public var body: some View { + VStack(alignment: .leading, spacing: Onyx.Space.sm) { + if let editing { + editor(for: editing) + } else { + list + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var list: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("notes.name", bundle: .module) + .font(Onyx.Font.title) + .foregroundStyle(Onyx.Color.textPrimary) + Spacer() + Button { begin(store.makeDraft()) } label: { + Image(systemName: "square.and.pencil") + .font(.system(size: Onyx.Symbol.small)) + } + .buttonStyle(.plain) + .foregroundStyle(Onyx.Color.accent) + } + .padding(.bottom, Onyx.Space.sm) + + if store.notes.isEmpty { + OnyxNotice(symbol: "note.text", + text: String(localized: "notes.empty", bundle: .module)) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(store.notes.prefix(compact ? 4 : 20).enumerated()), + id: \.element.id) { index, note in + if index > 0 { OnyxHairline() } + Button { begin(note) } label: { + HStack { + Text(NoteRules.title(of: note)) + .font(Onyx.Font.footnote) + .foregroundStyle(Onyx.Color.textPrimary) + .lineLimit(1) + Spacer(minLength: Onyx.Space.xs) + } + .padding(.vertical, Onyx.Space.sm) + .contentShape(.rect) + } + .buttonStyle(.plain) + } + } + } + .scrollIndicators(.never) + } + } + } + + private func editor(for note: Note) -> some View { + VStack(alignment: .leading, spacing: Onyx.Space.sm) { + HStack { + Button { finish() } label: { + Image(systemName: "chevron.left") + .font(.system(size: Onyx.Symbol.small)) + } + .buttonStyle(.plain) + .foregroundStyle(Onyx.Color.accent) + + Spacer() + + Button { store.remove(note); editing = nil } label: { + Image(systemName: "trash") + .font(.system(size: Onyx.Symbol.small)) + } + .buttonStyle(.plain) + .foregroundStyle(Onyx.Color.textTertiary) + } + + // `TextEditor` ohne eigenen Hintergrund: der Standardhintergrund + // ist hell und stünde als weißer Block in der schwarzen Kachel. + TextEditor(text: $draft) + .font(Onyx.Font.footnote) + .foregroundStyle(Onyx.Color.textPrimary) + .scrollContentBackground(.hidden) + .focused($isWriting) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .onAppear { isWriting = true } + } + + private func begin(_ note: Note) { + editing = note + draft = note.text + } + + private func finish() { + if var note = editing { + note.text = draft + store.update(note) + } + editing = nil + draft = "" + } +} + +public struct NotesWidget: OnyxWidget { + public let id = "notes" + public var displayName: String { String(localized: "notes.name", bundle: .module) } + public let symbolName = "note.text" + + private let store: NotesStore + + public init(store: NotesStore) { self.store = store } + + public func makeView() -> AnyView { AnyView(NotesView(store: store)) } +} diff --git a/Packages/OnyxKit/Tests/ClipboardProviderTests/ClipboardRulesTests.swift b/Packages/OnyxKit/Tests/ClipboardProviderTests/ClipboardRulesTests.swift new file mode 100644 index 0000000..6db307a --- /dev/null +++ b/Packages/OnyxKit/Tests/ClipboardProviderTests/ClipboardRulesTests.swift @@ -0,0 +1,143 @@ +import Testing +import Foundation +@testable import ClipboardProvider + +@Suite("Zwischenablage: die Regeln") +struct ClipboardRulesTests { + + private func item(_ fingerprint: String, at seconds: TimeInterval = 0, + preview: String = "x") -> ClipboardItem { + ClipboardItem(kind: .text, preview: preview, + addedAt: Date(timeIntervalSince1970: seconds), + sourceBundleID: nil, fingerprint: fingerprint) + } + + // MARK: - Was nie gemerkt werden darf + + @Test("Ein als verborgen markierter Inhalt wird nicht gemerkt") + func concealedIsIgnored() { + // Passwortmanager setzen diese Marke. Sie zu missachten hieße, fremde + // Passwörter einzusammeln. + #expect(!ClipboardRules.shouldRecord( + types: ["public.utf8-plain-text", "org.nspasteboard.ConcealedType"], + sourceBundleID: nil, excludedApps: [])) + } + + @Test("Durchreichen und Selbsterzeugtes ebenso wenig") + func transientAndGeneratedAreIgnored() { + #expect(!ClipboardRules.shouldRecord(types: ["org.nspasteboard.TransientType"], + sourceBundleID: nil, excludedApps: [])) + #expect(!ClipboardRules.shouldRecord(types: ["org.nspasteboard.AutoGeneratedType"], + sourceBundleID: nil, excludedApps: [])) + } + + @Test("Gewöhnlicher Text schon") + func plainTextIsRecorded() { + #expect(ClipboardRules.shouldRecord(types: ["public.utf8-plain-text"], + sourceBundleID: "com.apple.TextEdit", + excludedApps: [])) + } + + @Test("Aus einem ausgeschlossenen Programm nicht") + func excludedAppIsIgnored() { + #expect(!ClipboardRules.shouldRecord(types: ["public.utf8-plain-text"], + sourceBundleID: "com.agilebits.onepassword7", + excludedApps: ["com.agilebits.onepassword7"])) + } + + @Test("Ohne bekanntes Herkunftsprogramm wird gemerkt") + func unknownSourceIsRecorded() { + // Sonst ginge alles verloren, was aus einem Hintergrunddienst kommt. + #expect(ClipboardRules.shouldRecord(types: ["public.utf8-plain-text"], + sourceBundleID: nil, + excludedApps: ["com.agilebits.onepassword7"])) + } + + // MARK: - Vorschau + + @Test("Zeilenumbrüche werden zu Leerzeichen") + func previewIsOneLine() { + #expect(ClipboardRules.preview(for: "erste\nzweite") == "erste zweite") + #expect(ClipboardRules.preview(for: "a\t\tb") == "a b") + } + + @Test("Zu langer Text wird gekürzt und bekommt Auslassungspunkte") + func previewIsTruncated() { + let long = String(repeating: "a", count: 500) + let preview = ClipboardRules.preview(for: long) + #expect(preview.count == ClipboardRules.previewLength + 1) + #expect(preview.hasSuffix("…")) + } + + @Test("Kurzer Text bleibt unangetastet") + func shortPreviewIsUnchanged() { + #expect(ClipboardRules.preview(for: "kurz") == "kurz") + } + + // MARK: - Aufnehmen + + @Test("Dasselbe noch einmal kopiert rutscht nach oben, statt sich anzureihen") + func duplicateMovesToFront() { + let existing = [item("a", at: 10), item("b", at: 20)] + let again = item("a", at: 30) + let result = ClipboardRules.merge(existing, adding: again) + + #expect(result.items.map(\.fingerprint) == ["a", "b"]) + #expect(result.items.first?.addedAt == Date(timeIntervalSince1970: 30)) + // Der alte Eintrag ist verdrängt — seine Datei muss weg. + #expect(result.dropped.count == 1) + } + + @Test("Über der Grenze fällt das Älteste heraus") + func limitDropsOldest() { + let existing = (0..<5).map { item("f\($0)", at: Double(5 - $0)) } + let result = ClipboardRules.merge(existing, adding: item("neu", at: 99), limit: 3) + + #expect(result.items.count == 3) + #expect(result.items.first?.fingerprint == "neu") + // Verdrängtes wird gemeldet, damit die Dateien nicht liegen bleiben. + #expect(result.dropped.count == 3) + } + + @Test("Verdrängtes wird vollständig gemeldet, auch die Wiederholung") + func droppedIncludesDuplicate() { + let existing = [item("a", at: 1), item("b", at: 2), item("c", at: 3)] + let result = ClipboardRules.merge(existing, adding: item("a", at: 4), limit: 2) + // „a" einmal als Wiederholung, „c" als das Älteste über der Grenze. + #expect(Set(result.dropped.map(\.fingerprint)) == ["a", "c"]) + } + + // MARK: - Verfall + + @Test("Ohne Frist verfällt nichts") + func noExpiryKeepsEverything() { + let old = [item("a", at: 0)] + #expect(ClipboardRules.expired(old, olderThanDays: nil, + now: Date(timeIntervalSince1970: 9_000_000)).isEmpty) + #expect(ClipboardRules.expired(old, olderThanDays: 0, + now: Date(timeIntervalSince1970: 9_000_000)).isEmpty) + } + + @Test("Was älter ist als die Frist, verfällt") + func oldItemsExpire() { + let now = Date(timeIntervalSince1970: 10 * 86_400) + let items = [item("alt", at: 0), item("neu", at: 9 * 86_400)] + let stale = ClipboardRules.expired(items, olderThanDays: 7, now: now) + #expect(stale.map(\.fingerprint) == ["alt"]) + } + + // MARK: - Reihenfolge + + @Test("Neuestes zuerst, und gleich alte in fester Reihenfolge") + func sortingIsStable() { + let a = item("a", at: 5) + let b = item("b", at: 5) + let c = item("c", at: 9) + let once = ClipboardRules.sorted([a, b, c]) + let twice = ClipboardRules.sorted([c, b, a]) + #expect(once.first?.fingerprint == "c") + // Ohne festen Zweitschlüssel tauschten gleich alte Einträge bei jeder + // Anzeige die Plätze. + #expect(once.map(\.id) == twice.map(\.id)) + } +} diff --git a/Packages/OnyxKit/Tests/FaceProviderTests/FaceMatcherTests.swift b/Packages/OnyxKit/Tests/FaceProviderTests/FaceMatcherTests.swift new file mode 100644 index 0000000..da87ac5 --- /dev/null +++ b/Packages/OnyxKit/Tests/FaceProviderTests/FaceMatcherTests.swift @@ -0,0 +1,126 @@ +import Testing +import Foundation +@testable import FaceProvider + +@Suite("Gesichtsvergleich") +struct FaceMatcherTests { + + /// Ein Vektor, der sich um `offset` von der Grundform unterscheidet. + private func vector(_ offset: Float, count: Int = 768) -> [Float] { + (0.. FaceMatcher { + FaceMatcher(templates: offsets.enumerated().map { + FaceTemplate(pose: "p\($0.offset)", vector: vector($0.element)) + }, + requiredConfirmations: confirmations, + maximumAttempts: attempts) + } + + // MARK: - Abstand + + @Test("Derselbe Vektor hat Abstand null") + func identicalIsZero() { + #expect(FaceMatcher.distance(vector(0), vector(0)) == 0) + } + + @Test("Verschieden lange Vektoren ergeben keinen Abstand") + func lengthMismatchIsNil() { + // Zwei verschieden lange Vektoren zu vergleichen ergäbe eine Zahl ohne + // Bedeutung — und die sähe aus wie ein sehr guter Treffer. + #expect(FaceMatcher.distance([1, 2, 3], [1, 2]) == nil) + #expect(FaceMatcher.distance([], []) == nil) + } + + @Test("Der kleinste Abstand über alle Posen zählt") + func closestPoseWins() { + // Die Posen sind absichtlich verschieden; ein Mittelwert bestrafte + // genau die Vielfalt, für die sie aufgenommen wurden. + let m = matcher(offsets: [0, 0.5]) + let d = m.distance(to: vector(0.5)) + #expect(d == 0) + } + + // MARK: - Entscheidung + + @Test("Erst nach genug Bestätigungen wird geöffnet") + func needsSeveralConfirmations() { + // Die Kamera liefert dreißig Bilder je Sekunde — unter dreißig ist + // irgendwann eines dabei, das zufällig passt. + let m = matcher(confirmations: 3) + var session = FaceMatcher.Session() + #expect(m.evaluate(vector(0), in: &session) == .undecided(confirmations: 1)) + #expect(m.evaluate(vector(0), in: &session) == .undecided(confirmations: 2)) + #expect(m.evaluate(vector(0), in: &session) == .accepted) + } + + @Test("Ein fremdes Gesicht wird abgelehnt") + func strangerIsRejected() { + let m = matcher() + var session = FaceMatcher.Session() + #expect(m.evaluate(vector(5), in: &session) == .undecided(confirmations: 0)) + #expect(session.attempts == 1) + } + + @Test("Ein Fehlversuch setzt die Bestätigungen zurück") + func failureResetsConfirmations() { + // Sonst summierten sich zufällige Treffer über Minuten zu einem Ja. + let m = matcher(confirmations: 3) + var session = FaceMatcher.Session() + _ = m.evaluate(vector(0), in: &session) + _ = m.evaluate(vector(0), in: &session) + #expect(session.confirmations == 2) + _ = m.evaluate(vector(5), in: &session) + #expect(session.confirmations == 0) + } + + @Test("Nach genug Fehlversuchen ist gesperrt") + func locksOutAfterAttempts() { + let m = matcher(attempts: 3) + var session = FaceMatcher.Session() + _ = m.evaluate(vector(5), in: &session) + _ = m.evaluate(vector(5), in: &session) + #expect(m.evaluate(vector(5), in: &session) == .lockedOut) + } + + @Test("Nach der Sperre hilft auch das richtige Gesicht nicht mehr") + func lockoutIsFinal() { + // Ab hier bleibt nur das Passwort — sonst wäre die Sperre eine + // Verzögerung und keine Grenze. + let m = matcher(attempts: 2) + var session = FaceMatcher.Session() + _ = m.evaluate(vector(5), in: &session) + _ = m.evaluate(vector(5), in: &session) + #expect(m.evaluate(vector(0), in: &session) == .lockedOut) + } + + @Test("Ohne hinterlegtes Gesicht öffnet nichts") + func noEnrollmentNeverOpens() { + // Ein leerer Vergleich darf kein Freifahrtschein sein. + let m = FaceMatcher(templates: []) + var session = FaceMatcher.Session() + #expect(!m.isEnrolled) + #expect(m.evaluate(vector(0), in: &session) == .undecided(confirmations: 0)) + #expect(session.attempts == 1) + } + + @Test("Ein Vektor falscher Länge gilt als Fehlversuch, nicht als Treffer") + func wrongLengthIsAFailure() { + let m = matcher() + var session = FaceMatcher.Session() + #expect(m.evaluate([1, 2, 3], in: &session) == .undecided(confirmations: 0)) + #expect(session.attempts == 1) + } + + @Test("Die Schwelle liegt näher am Ähnlichen als am Verschiedenen") + func thresholdLeansStrict() { + // Aus dem Spike: ähnlich 0,13 — verschieden 0,47. Ein falsches Ja ist + // ein Fremder am entsperrten Rechner, ein falsches Nein nur ein + // Passwort, das man ohnehin kennt. + #expect(FaceMatcher.defaultThreshold < (0.13 + 0.47) / 2) + #expect(FaceMatcher.defaultThreshold > 0.13) + } +} diff --git a/Packages/OnyxKit/Tests/NotesProviderTests/NoteRulesTests.swift b/Packages/OnyxKit/Tests/NotesProviderTests/NoteRulesTests.swift new file mode 100644 index 0000000..4e838a3 --- /dev/null +++ b/Packages/OnyxKit/Tests/NotesProviderTests/NoteRulesTests.swift @@ -0,0 +1,83 @@ +import Testing +import Foundation +@testable import NotesProvider + +@Suite("Notizen: die Regeln") +struct NoteRulesTests { + + @Test("Die Überschrift ist die erste nichtleere Zeile") + func titleIsFirstLine() { + let note = Note(text: "\n\n Einkaufen \nMilch\nBrot") + #expect(NoteRules.title(of: note) == "Einkaufen") + } + + @Test("Eine zu lange erste Zeile wird gekürzt") + func longTitleIsTruncated() { + let note = Note(text: String(repeating: "a", count: 200)) + let title = NoteRules.title(of: note) + #expect(title.count == NoteRules.titleLength + 1) + #expect(title.hasSuffix("…")) + } + + @Test("Eine leere Notiz hat keine Überschrift") + func emptyHasNoTitle() { + #expect(NoteRules.title(of: Note(text: " \n\n ")) == "") + } + + @Test("Eine Notiz aus lauter Leerzeichen zählt als leer") + func whitespaceIsEmpty() { + // Sie entsteht, wenn man auf „Neu" drückt und es sich anders überlegt. + #expect(NoteRules.isEmpty(Note(text: " \n\t "))) + #expect(!NoteRules.isEmpty(Note(text: "x"))) + } + + @Test("Eine leer gewordene Notiz verschwindet beim Speichern") + func emptyNoteIsDropped() { + let note = Note(text: "war mal etwas") + let kept = NoteRules.merge([], updating: note) + #expect(kept.count == 1) + + var emptied = note + emptied.text = " " + #expect(NoteRules.merge(kept, updating: emptied).isEmpty) + } + + @Test("Zuletzt geändert steht oben") + func newestFirst() { + let old = Note(text: "alt", updatedAt: Date(timeIntervalSince1970: 1)) + let new = Note(text: "neu", updatedAt: Date(timeIntervalSince1970: 2)) + #expect(NoteRules.sorted([old, new]).first?.text == "neu") + } + + @Test("Gleich alte Notizen behalten ihre Reihenfolge") + func sortingIsStable() { + let when = Date(timeIntervalSince1970: 5) + let a = Note(text: "a", updatedAt: when) + let b = Note(text: "b", updatedAt: when) + #expect(NoteRules.sorted([a, b]).map(\.id) == NoteRules.sorted([b, a]).map(\.id)) + } + + @Test("Eine bestehende Notiz wird ersetzt, nicht verdoppelt") + func updateReplaces() { + let note = Note(text: "erst") + var changed = note + changed.text = "dann" + let result = NoteRules.merge([note], updating: changed) + #expect(result.count == 1) + #expect(result.first?.text == "dann") + } + + @Test("Über der Grenze fällt die älteste heraus") + func limitDropsOldest() { + let existing = (0..<3).map { + Note(text: "n\($0)", updatedAt: Date(timeIntervalSince1970: Double($0))) + } + let result = NoteRules.merge(existing, + updating: Note(text: "neu", + updatedAt: Date(timeIntervalSince1970: 99)), + limit: 3) + #expect(result.count == 3) + #expect(result.first?.text == "neu") + #expect(!result.contains { $0.text == "n0" }) + } +} diff --git a/Spikes/faceprint-probe.swift b/Spikes/faceprint-probe.swift new file mode 100644 index 0000000..d27b8e3 --- /dev/null +++ b/Spikes/faceprint-probe.swift @@ -0,0 +1,67 @@ +import Vision +import AppKit + +// Gibt Vision ein Merkmal her, mit dem sich ein Gesicht *wiedererkennen* lässt? +// +// Öffentlich liefert Vision Rechtecke und Landmarken — beides sagt, wo ein +// Gesicht ist, nicht wessen. Für den Vergleich braucht es einen Vektor mit +// Abstandsmaß. Geprüft werden zwei Wege. + +print("=== Weg 1: VNGenerateFaceprintRequest (privat) ===") +for name in ["VNGenerateFaceprintRequest", "VNFaceprintObservation", + "VNFaceObservation", "VNGenerateImageFeaturePrintRequest"] { + print(" \(name): \(NSClassFromString(name) != nil ? "vorhanden" : "FEHLT")") +} + +print("\n=== Weg 2: VNGenerateImageFeaturePrintRequest (öffentlich) ===") + +/// Ein Testbild: eine Fläche mit einem Muster, damit die Merkmale nicht leer sind. +func testImage(hue: CGFloat, shift: CGFloat) -> CGImage { + let size = 224 + let space = CGColorSpaceCreateDeviceRGB() + let ctx = CGContext(data: nil, width: size, height: size, bitsPerComponent: 8, + bytesPerRow: 0, space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + ctx.setFillColor(NSColor(hue: hue, saturation: 0.6, brightness: 0.9, alpha: 1).cgColor) + ctx.fill(CGRect(x: 0, y: 0, width: size, height: size)) + ctx.setFillColor(NSColor.black.cgColor) + for i in 0..<6 { + ctx.fillEllipse(in: CGRect(x: 20 + CGFloat(i) * 30 + shift, y: 60 + shift, + width: 24, height: 34)) + } + return ctx.makeImage()! +} + +func featurePrint(_ image: CGImage) -> VNFeaturePrintObservation? { + let request = VNGenerateImageFeaturePrintRequest() + let handler = VNImageRequestHandler(cgImage: image, options: [:]) + do { + try handler.perform([request]) + return request.results?.first as? VNFeaturePrintObservation + } catch { + print(" Fehler: \(error.localizedDescription)") + return nil + } +} + +let a = featurePrint(testImage(hue: 0.1, shift: 0)) +let aAgain = featurePrint(testImage(hue: 0.1, shift: 2)) // fast dasselbe +let b = featurePrint(testImage(hue: 0.6, shift: 40)) // deutlich anders + +if let a, let aAgain, let b { + print(" Merkmalslänge: \(a.elementCount) Elemente, Typ \(a.elementType.rawValue)") + var near: Float = 0, far: Float = 0 + try? a.computeDistance(&near, to: aAgain) + try? a.computeDistance(&far, to: b) + print(" Abstand ähnlich: \(near)") + print(" Abstand verschieden: \(far)") + print(" Trennschärfe: \(far > near ? "gegeben" : "NICHT gegeben") (Verhältnis \(far / max(near, 0.0001)))") +} else { + print(" Merkmale nicht berechenbar") +} + +print("\n=== Gesichtserkennung selbst ===") +let request = VNDetectFaceLandmarksRequest() +let handler = VNImageRequestHandler(cgImage: testImage(hue: 0.1, shift: 0), options: [:]) +try? handler.perform([request]) +print(" Landmarken-Anfrage lief durch, \(request.results?.count ?? 0) Gesichter im Testmuster (0 erwartet)") diff --git a/docs/spikes/G-gesichtsmerkmal.md b/docs/spikes/G-gesichtsmerkmal.md new file mode 100644 index 0000000..ff41c10 --- /dev/null +++ b/docs/spikes/G-gesichtsmerkmal.md @@ -0,0 +1,48 @@ +# Spike G — Gesichtswiedererkennung + +> Im Plan heißt dieser Spike „F". Der Buchstabe war in `docs/spikes/` schon an die +> Frequenzmessung vergeben; hier läuft die vorhandene Reihe weiter. + +**Frage.** Vision liefert öffentlich Rechtecke und Landmarken, aber sagt damit nur, +*wo* ein Gesicht ist — nicht *wessen*. Für einen Vergleich braucht es einen Vektor +mit Abstandsmaß. Gibt es den, oder braucht es ein eigenes CoreML-Modell? + +**Gemessen am 05.09.2026**, `Spikes/faceprint-probe.swift`. + +## Ergebnis + +| Weg | Befund | +|---|---| +| `VNGenerateFaceprintRequest` | **existiert nicht** — auch nicht über `NSClassFromString` erreichbar | +| `VNFaceprintObservation` | **existiert nicht** | +| `VNGenerateImageFeaturePrintRequest` | **öffentlich und benutzbar** | + +Der öffentliche Merkmalsvektor hat **768 Elemente** und bringt `computeDistance` +mit. An zwei synthetischen Mustern: + +- ähnliche Bilder: Abstand **0,134** +- deutlich verschiedene: Abstand **0,473** + +Also Faktor 3,5 Trennschärfe. Ein eigenes CoreML-Modell ist **nicht** nötig. + +## Was das für die Bauform heißt + +1. `VNDetectFaceLandmarksRequest` findet das Gesicht und seine Landmarken. +2. Daraus wird ausgerichtet und zugeschnitten — ohne Ausrichtung misst man die + Kopfhaltung mit, nicht die Person. +3. `VNGenerateImageFeaturePrintRequest` auf dem Ausschnitt gibt den Vektor. +4. Verglichen wird über `computeDistance` gegen die hinterlegten Vektoren. + +## Die Einschränkung, die bleibt + +Ein **allgemeiner Bildmerkmalsvektor ist kein Gesichtsmerkmal.** Er reagiert auf +Beleuchtung, Kopfhaltung und Hintergrund mit, nicht nur auf die Person. Deshalb: + +- mehrere Posen beim Anlernen, nicht eine, +- Bestätigung über mehrere Einzelbilder, nicht eines, +- eine Schwelle, die lieber ablehnt als falsch zulässt, +- Sperre nach mehreren Fehlversuchen, Rückfall aufs Passwort. + +Und die Aussage aus dem Plan bleibt in voller Schärfe: **das ist Bequemlichkeit, +keine Sicherheitsgrenze.** Ein Foto vor die Kamera zu halten genügt — Face ID hat +dafür einen Tiefensensor, die Mac-Webcam hat keinen. diff --git a/project.yml b/project.yml index 9b597c2..77dbab4 100644 --- a/project.yml +++ b/project.yml @@ -99,6 +99,12 @@ targets: product: AudioProvider - package: OnyxKit product: ShelfProvider + - package: OnyxKit + product: ActivityProvider + - package: OnyxKit + product: ClipboardProvider + - package: OnyxKit + product: NotesProvider postBuildScripts: # Der Helfer und seine Plist müssen an genau diese Stellen: SMAppService # sucht die Plist unter Contents/Library/LaunchDaemons und liest von dort