Zwischenablage, Notizen und der Gesichtsvergleich
Drei neue Module nach dem Bauplan von `ShelfProvider`: reine Regeln getrennt vom Zustand, versionierte Indexdatei, Inhalte in Dateien statt in `UserDefaults` — was dort steht, liest jedes Programm mit `defaults read` mit. Die Zwischenablage achtet `ConcealedType`, `TransientType` und `AutoGeneratedType`. Diese Marken sind eine Verabredung zwischen Programmen und kein Systemdienst; wer sie missachtet, sammelt fremde Passwörter ein. Der Gesichtsvergleich stand im Plan als der eine echte Unsicherheitsfaktor. Ein Spike hat ihn erledigt: `VNGenerateFaceprintRequest` gibt es nicht, aber `VNGenerateImageFeaturePrintRequest` ist öffentlich, liefert 768 Elemente und ein Abstandsmaß — Faktor 3,5 Trennschärfe. Kein eigenes CoreML-Modell nötig. `FaceMatcher` entscheidet über mehrere Posen, mehrere Einzelbilder und eine Sperre nach Fehlversuchen. Die Schwelle liegt bewusst nicht mittig: ein falsches Ja ist ein Fremder am entsperrten Rechner, ein falsches Nein nur ein Passwort, das man ohnehin kennt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f289d02c37
commit
3da673662f
@@ -7,17 +7,22 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; };
|
||||||
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12041E330231599C6540C21 /* FanSettingsView.swift */; };
|
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12041E330231599C6540C21 /* FanSettingsView.swift */; };
|
||||||
1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */; };
|
1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */; };
|
||||||
1E2D654EFDDF55AEA75DBB7C /* WeatherProvider in Frameworks */ = {isa = PBXBuildFile; productRef = D4578EB64F7A3372B49BC50C /* WeatherProvider */; };
|
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 */; };
|
2BB6AFD10DFB13FC4467BE6B /* SMCAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */; };
|
||||||
3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */; };
|
3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */; };
|
||||||
378A5A417112CCB31EF3F774 /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
|
378A5A417112CCB31EF3F774 /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
|
||||||
3991CB2BF41C6C014EF4528F /* chip.svg in Resources */ = {isa = PBXBuildFile; fileRef = 085D860E654E09E4CD6E535A /* chip.svg */; };
|
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 */; };
|
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; };
|
||||||
3C7D32A39B906BD7385B1231 /* bolt-light.svg in Resources */ = {isa = PBXBuildFile; fileRef = B8214C5599D1857E832447EC /* bolt-light.svg */; };
|
3C7D32A39B906BD7385B1231 /* bolt-light.svg in Resources */ = {isa = PBXBuildFile; fileRef = B8214C5599D1857E832447EC /* bolt-light.svg */; };
|
||||||
4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */ = {isa = PBXBuildFile; productRef = C64A111F449D0F9D36ED46FB /* OnyxCore */; };
|
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 */; };
|
574FDD46FEBAC96E38CDFC7A /* bolt-dark.svg in Resources */ = {isa = PBXBuildFile; fileRef = 7A866E72010907DF4BEF025C /* bolt-dark.svg */; };
|
||||||
5BE38CAFCDA4B9CBE787CABA /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; };
|
5BE38CAFCDA4B9CBE787CABA /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; };
|
||||||
68EE5495435A82E20248EBC2 /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = 91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */; };
|
68EE5495435A82E20248EBC2 /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = 91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */; };
|
||||||
@@ -26,6 +31,7 @@
|
|||||||
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 5D8203CC3A09D838A4AC385D /* AudioProvider */; };
|
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 5D8203CC3A09D838A4AC385D /* AudioProvider */; };
|
||||||
7CE2EDC6BC58678269BF27AD /* MenuBarIcon.svg in Resources */ = {isa = PBXBuildFile; fileRef = 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */; };
|
7CE2EDC6BC58678269BF27AD /* MenuBarIcon.svg in Resources */ = {isa = PBXBuildFile; fileRef = 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */; };
|
||||||
8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6115888EE7738877C9304138 /* MixerMenuBarModule.swift */; };
|
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 */; };
|
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; };
|
||||||
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; };
|
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; };
|
||||||
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; };
|
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; };
|
||||||
@@ -40,8 +46,11 @@
|
|||||||
C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A5510538846C8427AEB831 /* OnboardingView.swift */; };
|
C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A5510538846C8427AEB831 /* OnboardingView.swift */; };
|
||||||
CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3FDFD387188D36AF8287DE /* FanControl.swift */; };
|
CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3FDFD387188D36AF8287DE /* FanControl.swift */; };
|
||||||
CD501EBAE39807F5D354C5B2 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; };
|
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 */; };
|
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 */; };
|
F2A3AD184864422EA14A8C64 /* graphic-card.svg in Resources */ = {isa = PBXBuildFile; fileRef = 1E6A1C208C7EA56971A055E1 /* graphic-card.svg */; };
|
||||||
FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 055263008CB3306A8D09E6D4 /* AppIcon.icon */; };
|
FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 055263008CB3306A8D09E6D4 /* AppIcon.icon */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@@ -60,7 +69,9 @@
|
|||||||
055263008CB3306A8D09E6D4 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
055263008CB3306A8D09E6D4 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
||||||
0804288DC4A8146DD9F3FC3E /* Onyx.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Onyx.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
085D860E654E09E4CD6E535A /* chip.svg */ = {isa = PBXFileReference; path = chip.svg; sourceTree = "<group>"; };
|
||||||
|
0FDB63D9352C7D2F8406B1DF /* NotchAccessories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchAccessories.swift; sourceTree = "<group>"; };
|
||||||
16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetOptionsView.swift; sourceTree = "<group>"; };
|
16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetOptionsView.swift; sourceTree = "<group>"; };
|
||||||
|
1C758A5ABC2F748BE7306038 /* GalleryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryView.swift; sourceTree = "<group>"; };
|
||||||
1E6A1C208C7EA56971A055E1 /* graphic-card.svg */ = {isa = PBXFileReference; path = "graphic-card.svg"; sourceTree = "<group>"; };
|
1E6A1C208C7EA56971A055E1 /* graphic-card.svg */ = {isa = PBXFileReference; path = "graphic-card.svg"; sourceTree = "<group>"; };
|
||||||
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */ = {isa = PBXFileReference; path = MenuBarIcon.svg; sourceTree = "<group>"; };
|
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */ = {isa = PBXFileReference; path = MenuBarIcon.svg; sourceTree = "<group>"; };
|
||||||
41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLogin.swift; sourceTree = "<group>"; };
|
41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLogin.swift; sourceTree = "<group>"; };
|
||||||
@@ -73,15 +84,19 @@
|
|||||||
63A5510538846C8427AEB831 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
63A5510538846C8427AEB831 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||||
7A866E72010907DF4BEF025C /* bolt-dark.svg */ = {isa = PBXFileReference; path = "bolt-dark.svg"; sourceTree = "<group>"; };
|
7A866E72010907DF4BEF025C /* bolt-dark.svg */ = {isa = PBXFileReference; path = "bolt-dark.svg"; sourceTree = "<group>"; };
|
||||||
821B742C211265914B6CC44B /* OnyxKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = OnyxKit; path = Packages/OnyxKit; sourceTree = SOURCE_ROOT; };
|
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 = "<group>"; };
|
||||||
87597F09E971C14CF1E475DB /* Onyx.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Onyx.entitlements; sourceTree = "<group>"; };
|
87597F09E971C14CF1E475DB /* Onyx.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Onyx.entitlements; sourceTree = "<group>"; };
|
||||||
89309D9F5722FA1B91CF2905 /* FanWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanWidget.swift; sourceTree = "<group>"; };
|
89309D9F5722FA1B91CF2905 /* FanWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanWidget.swift; sourceTree = "<group>"; };
|
||||||
8B7ECCFCBA1BAAC6583D4A39 /* OnyxHelper */ = {isa = PBXFileReference; includeInIndex = 0; path = OnyxHelper; sourceTree = BUILT_PRODUCTS_DIR; };
|
8B7ECCFCBA1BAAC6583D4A39 /* OnyxHelper */ = {isa = PBXFileReference; includeInIndex = 0; path = OnyxHelper; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */ = {isa = PBXFileReference; path = "sound-waves.svg"; sourceTree = "<group>"; };
|
8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */ = {isa = PBXFileReference; path = "sound-waves.svg"; sourceTree = "<group>"; };
|
||||||
8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = "<group>"; };
|
8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = "<group>"; };
|
||||||
|
8F63DBABD5B2905865FD4FA2 /* GalleryWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryWindowController.swift; sourceTree = "<group>"; };
|
||||||
|
AE37042F7F905EBDE0975279 /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = "<group>"; };
|
||||||
B442E8A39C37C8FD92B4CA83 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
B442E8A39C37C8FD92B4CA83 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
||||||
B8214C5599D1857E832447EC /* bolt-light.svg */ = {isa = PBXFileReference; path = "bolt-light.svg"; sourceTree = "<group>"; };
|
B8214C5599D1857E832447EC /* bolt-light.svg */ = {isa = PBXFileReference; path = "bolt-light.svg"; sourceTree = "<group>"; };
|
||||||
B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
||||||
DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorSettingsView.swift; sourceTree = "<group>"; };
|
DF4472730A92A48657BDCEB9 /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = "<group>"; };
|
||||||
|
E2585137DB7F67F91768E0EF /* WindowPlacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowPlacement.swift; sourceTree = "<group>"; };
|
||||||
F12041E330231599C6540C21 /* FanSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanSettingsView.swift; sourceTree = "<group>"; };
|
F12041E330231599C6540C21 /* FanSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanSettingsView.swift; sourceTree = "<group>"; };
|
||||||
F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnyxApp.swift; sourceTree = "<group>"; };
|
F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnyxApp.swift; sourceTree = "<group>"; };
|
||||||
FC3FDFD387188D36AF8287DE /* FanControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanControl.swift; sourceTree = "<group>"; };
|
FC3FDFD387188D36AF8287DE /* FanControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanControl.swift; sourceTree = "<group>"; };
|
||||||
@@ -105,6 +120,9 @@
|
|||||||
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */,
|
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */,
|
||||||
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */,
|
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */,
|
||||||
A9617230F602C5D52F63A7E2 /* ShelfProvider in Frameworks */,
|
A9617230F602C5D52F63A7E2 /* ShelfProvider in Frameworks */,
|
||||||
|
534B041763B7D4B03DFF53D4 /* ActivityProvider in Frameworks */,
|
||||||
|
39B06041FD90361FA72CB386 /* ClipboardProvider in Frameworks */,
|
||||||
|
EF4351407AC9DCDDE225C96F /* NotesProvider in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -166,29 +184,35 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0FBA13B97C53F1D0D740A633 /* Resources */,
|
0FBA13B97C53F1D0D740A633 /* Resources */,
|
||||||
|
AE37042F7F905EBDE0975279 /* AppearanceSettingsView.swift */,
|
||||||
055263008CB3306A8D09E6D4 /* AppIcon.icon */,
|
055263008CB3306A8D09E6D4 /* AppIcon.icon */,
|
||||||
B83C4E3CB821F903F3977E88 /* AppModel.swift */,
|
B83C4E3CB821F903F3977E88 /* AppModel.swift */,
|
||||||
7A866E72010907DF4BEF025C /* bolt-dark.svg */,
|
7A866E72010907DF4BEF025C /* bolt-dark.svg */,
|
||||||
B8214C5599D1857E832447EC /* bolt-light.svg */,
|
B8214C5599D1857E832447EC /* bolt-light.svg */,
|
||||||
085D860E654E09E4CD6E535A /* chip.svg */,
|
085D860E654E09E4CD6E535A /* chip.svg */,
|
||||||
DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */,
|
|
||||||
FC3FDFD387188D36AF8287DE /* FanControl.swift */,
|
FC3FDFD387188D36AF8287DE /* FanControl.swift */,
|
||||||
F12041E330231599C6540C21 /* FanSettingsView.swift */,
|
F12041E330231599C6540C21 /* FanSettingsView.swift */,
|
||||||
89309D9F5722FA1B91CF2905 /* FanWidget.swift */,
|
89309D9F5722FA1B91CF2905 /* FanWidget.swift */,
|
||||||
|
1C758A5ABC2F748BE7306038 /* GalleryView.swift */,
|
||||||
|
8F63DBABD5B2905865FD4FA2 /* GalleryWindowController.swift */,
|
||||||
1E6A1C208C7EA56971A055E1 /* graphic-card.svg */,
|
1E6A1C208C7EA56971A055E1 /* graphic-card.svg */,
|
||||||
50A018673EE6BD9C50F1EFA0 /* Info.plist */,
|
50A018673EE6BD9C50F1EFA0 /* Info.plist */,
|
||||||
41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */,
|
41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */,
|
||||||
508DECB5C05264E6130C373E /* Localizable.xcstrings */,
|
508DECB5C05264E6130C373E /* Localizable.xcstrings */,
|
||||||
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */,
|
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */,
|
||||||
6115888EE7738877C9304138 /* MixerMenuBarModule.swift */,
|
6115888EE7738877C9304138 /* MixerMenuBarModule.swift */,
|
||||||
|
0FDB63D9352C7D2F8406B1DF /* NotchAccessories.swift */,
|
||||||
63A5510538846C8427AEB831 /* OnboardingView.swift */,
|
63A5510538846C8427AEB831 /* OnboardingView.swift */,
|
||||||
5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */,
|
5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */,
|
||||||
87597F09E971C14CF1E475DB /* Onyx.entitlements */,
|
87597F09E971C14CF1E475DB /* Onyx.entitlements */,
|
||||||
F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */,
|
F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */,
|
||||||
|
85EDDADF00C8A57744A63191 /* PowerActivity.swift */,
|
||||||
|
DF4472730A92A48657BDCEB9 /* SettingsSection.swift */,
|
||||||
45BD2DF1B775C733C8F75635 /* SettingsView.swift */,
|
45BD2DF1B775C733C8F75635 /* SettingsView.swift */,
|
||||||
8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */,
|
8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */,
|
||||||
8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */,
|
8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */,
|
||||||
16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */,
|
16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */,
|
||||||
|
E2585137DB7F67F91768E0EF /* WindowPlacement.swift */,
|
||||||
);
|
);
|
||||||
path = Onyx;
|
path = Onyx;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -226,6 +250,9 @@
|
|||||||
FF4E99C8BF0D8944C314A8AF /* NetworkProvider */,
|
FF4E99C8BF0D8944C314A8AF /* NetworkProvider */,
|
||||||
5D8203CC3A09D838A4AC385D /* AudioProvider */,
|
5D8203CC3A09D838A4AC385D /* AudioProvider */,
|
||||||
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */,
|
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */,
|
||||||
|
B9524DE253CBAF4344096790 /* ActivityProvider */,
|
||||||
|
913CFE7A8E299E11C2BF23CF /* ClipboardProvider */,
|
||||||
|
F8AC40480E05171EB8091269 /* NotesProvider */,
|
||||||
);
|
);
|
||||||
productName = Onyx;
|
productName = Onyx;
|
||||||
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
|
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
|
||||||
@@ -358,18 +385,24 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
C041F171FB69F4381309D978 /* AppModel.swift in Sources */,
|
C041F171FB69F4381309D978 /* AppModel.swift in Sources */,
|
||||||
E8B839A850BE54F32410C2C8 /* ColorSettingsView.swift in Sources */,
|
29DEDC6D874DB6FF77DC4338 /* AppearanceSettingsView.swift in Sources */,
|
||||||
CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */,
|
CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */,
|
||||||
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */,
|
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */,
|
||||||
6CD3F5666C39F54506B8430A /* FanWidget.swift in Sources */,
|
6CD3F5666C39F54506B8430A /* FanWidget.swift in Sources */,
|
||||||
|
DD41F48611EAFC1DC5B28FD4 /* GalleryView.swift in Sources */,
|
||||||
|
DD98BEF77E614424C4A37291 /* GalleryWindowController.swift in Sources */,
|
||||||
B309DD8966B5348250CEC6BD /* LaunchAtLogin.swift in Sources */,
|
B309DD8966B5348250CEC6BD /* LaunchAtLogin.swift in Sources */,
|
||||||
8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */,
|
8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */,
|
||||||
|
033116546F93AE6C61A0D9D7 /* NotchAccessories.swift in Sources */,
|
||||||
C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */,
|
C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */,
|
||||||
1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */,
|
1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */,
|
||||||
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */,
|
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */,
|
||||||
|
08B6F17F5D3B33A8B0F06626 /* PowerActivity.swift in Sources */,
|
||||||
|
E7A8CABF31F3CC88C7CB0176 /* SettingsSection.swift in Sources */,
|
||||||
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */,
|
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */,
|
||||||
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */,
|
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */,
|
||||||
3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */,
|
3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */,
|
||||||
|
98AE792A2A346EDAB358728A /* WindowPlacement.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -658,6 +691,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = MetricsProvider;
|
productName = MetricsProvider;
|
||||||
};
|
};
|
||||||
|
913CFE7A8E299E11C2BF23CF /* ClipboardProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = ClipboardProvider;
|
||||||
|
};
|
||||||
91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */ = {
|
91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxHelperProtocol;
|
productName = OnyxHelperProtocol;
|
||||||
@@ -670,6 +707,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxMenuBar;
|
productName = OnyxMenuBar;
|
||||||
};
|
};
|
||||||
|
B9524DE253CBAF4344096790 /* ActivityProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = ActivityProvider;
|
||||||
|
};
|
||||||
C64A111F449D0F9D36ED46FB /* OnyxCore */ = {
|
C64A111F449D0F9D36ED46FB /* OnyxCore */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxCore;
|
productName = OnyxCore;
|
||||||
@@ -682,6 +723,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OnyxWidgetKit;
|
productName = OnyxWidgetKit;
|
||||||
};
|
};
|
||||||
|
F8AC40480E05171EB8091269 /* NotesProvider */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = NotesProvider;
|
||||||
|
};
|
||||||
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */ = {
|
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = ShelfProvider;
|
productName = ShelfProvider;
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ let package = Package(
|
|||||||
.library(name: "NetworkProvider", targets: ["NetworkProvider"]),
|
.library(name: "NetworkProvider", targets: ["NetworkProvider"]),
|
||||||
.library(name: "AudioProvider", targets: ["AudioProvider"]),
|
.library(name: "AudioProvider", targets: ["AudioProvider"]),
|
||||||
.library(name: "ShelfProvider", targets: ["ShelfProvider"]),
|
.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: [
|
targets: [
|
||||||
// Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt
|
// Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt
|
||||||
@@ -73,8 +77,23 @@ let package = Package(
|
|||||||
resources: [.process("Localizable.xcstrings")]),
|
resources: [.process("Localizable.xcstrings")]),
|
||||||
.testTarget(name: "AudioProviderTests", dependencies: ["AudioProvider"]),
|
.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"],
|
.target(name: "ShelfProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
|
||||||
resources: [.process("Localizable.xcstrings")]),
|
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: "ShelfProviderTests", dependencies: ["ShelfProvider"]),
|
||||||
|
.testTarget(name: "ActivityProviderTests", dependencies: ["ActivityProvider"]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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<String> = [
|
||||||
|
"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<String>) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)) }
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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..<count).map { Float($0 % 7) * 0.01 + offset }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matcher(offsets: [Float] = [0],
|
||||||
|
confirmations: Int = 3,
|
||||||
|
attempts: Int = 10) -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
@@ -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.
|
||||||
@@ -99,6 +99,12 @@ targets:
|
|||||||
product: AudioProvider
|
product: AudioProvider
|
||||||
- package: OnyxKit
|
- package: OnyxKit
|
||||||
product: ShelfProvider
|
product: ShelfProvider
|
||||||
|
- package: OnyxKit
|
||||||
|
product: ActivityProvider
|
||||||
|
- package: OnyxKit
|
||||||
|
product: ClipboardProvider
|
||||||
|
- package: OnyxKit
|
||||||
|
product: NotesProvider
|
||||||
postBuildScripts:
|
postBuildScripts:
|
||||||
# Der Helfer und seine Plist müssen an genau diese Stellen: SMAppService
|
# Der Helfer und seine Plist müssen an genau diese Stellen: SMAppService
|
||||||
# sucht die Plist unter Contents/Library/LaunchDaemons und liest von dort
|
# sucht die Plist unter Contents/Library/LaunchDaemons und liest von dort
|
||||||
|
|||||||
Reference in New Issue
Block a user