From e3b215a1ddde7c862c7a1cbd68b92c053c952a24 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 5 Sep 2026 21:22:32 +0200 Subject: [PATCH] =?UTF-8?q?Ein=20eigener=20Sperrbildschirm=20=E2=80=94=20u?= =?UTF-8?q?nd=20ohne=20gespeichertes=20Passwort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kameraaufnahme, Anlernen über drei Posen, Fenster je Bildschirm auf Bildschirmschoner-Ebene, Passwortrückfall. **Das Anmeldepasswort wird nicht gespeichert.** Ein Spike hat den Weg freigeräumt: `ODRecord.verifyPassword` ist öffentliche API, die Eingabe geht direkt hinein, das Ergebnis ist ein `Bool`, danach ist die Zeichenkette wieder weg. Damit fällt der Grund weg, aus dem vergleichbare Programme sich das Passwort geben lassen und in die Keychain legen. Angelernt wird über drei Posen mit je fünf Aufnahmen. Der Merkmalsvektor ist ein allgemeiner Bildmerkmalsvektor und kein Gesichtsmerkmal — er reagiert auf Kopfhaltung und Beleuchtung mit. Eine einzige Aufnahme beschreibt genau eine Haltung bei genau einem Licht. Die Vektoren liegen in der Keychain, nicht in einer Datei: aus ihnen lässt sich kein Bild zurückgewinnen, aber wer sie hat, kann prüfen, ob eine bestimmte Person vor der Kamera stand. Die Einschränkung steht auf dem Sperrbildschirm selbst und nicht im Kleingedruckten: Bequemlichkeit, keine Sicherheitsgrenze; der Vergleich läuft über die Webcam und lässt sich mit einem Foto täuschen. Wer sich darauf verlässt, tut es dort. Co-Authored-By: Claude Opus 5 --- Onyx.xcodeproj/project.pbxproj | 18 + Onyx/AppModel.swift | 30 ++ Onyx/Info.plist | 2 + Onyx/Localizable.xcstrings | 400 ++++++++++++++++++ Onyx/LockSettingsView.swift | 136 ++++++ Onyx/Onyx.entitlements | 8 + Onyx/OnyxApp.swift | 18 + Onyx/SettingsSection.swift | 4 + Onyx/SettingsView.swift | 4 + Onyx/SettingsWindowController.swift | 3 + Packages/OnyxKit/Package.swift | 6 +- .../Sources/FaceProvider/FaceCapture.swift | 173 ++++++++ .../Sources/FaceProvider/FaceEnrollment.swift | 114 +++++ .../Sources/FaceProvider/FaceStore.swift | 71 ++++ .../FaceProvider/Localizable.xcstrings | 86 ++++ .../FaceProvider/PasswordVerifier.swift | 62 +++ .../LockProvider/Localizable.xcstrings | 118 ++++++ .../Sources/LockProvider/LockController.swift | 125 ++++++ .../Sources/LockProvider/LockScreenView.swift | 98 +++++ .../Sources/LockProvider/LockWindow.swift | 48 +++ Spikes/password-check-probe.swift | 40 ++ docs/spikes/I-passwortpruefung.md | 38 ++ project.yml | 4 + 23 files changed, 1605 insertions(+), 1 deletion(-) create mode 100644 Onyx/LockSettingsView.swift create mode 100644 Packages/OnyxKit/Sources/FaceProvider/FaceCapture.swift create mode 100644 Packages/OnyxKit/Sources/FaceProvider/FaceEnrollment.swift create mode 100644 Packages/OnyxKit/Sources/FaceProvider/FaceStore.swift create mode 100644 Packages/OnyxKit/Sources/FaceProvider/Localizable.xcstrings create mode 100644 Packages/OnyxKit/Sources/FaceProvider/PasswordVerifier.swift create mode 100644 Packages/OnyxKit/Sources/LockProvider/Localizable.xcstrings create mode 100644 Packages/OnyxKit/Sources/LockProvider/LockController.swift create mode 100644 Packages/OnyxKit/Sources/LockProvider/LockScreenView.swift create mode 100644 Packages/OnyxKit/Sources/LockProvider/LockWindow.swift create mode 100644 Spikes/password-check-probe.swift create mode 100644 docs/spikes/I-passwortpruefung.md diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index 39b6e8a..41144c3 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 00FC2E6D214B866B05D66F0F /* LockProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 3054FDAC34BBC6E684BCFE22 /* LockProvider */; }; 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 */; }; @@ -44,10 +45,12 @@ C1F651D2BA69358344927F26 /* MediaProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 349226D70F51196CCD1C94F8 /* MediaProvider */; }; C390C5B65769A007A49C3A80 /* MetricsProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 6CACA1C2A58DEFDE46F71499 /* MetricsProvider */; }; C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A5510538846C8427AEB831 /* OnboardingView.swift */; }; + CA8D4A7AD49FFA5E3055F3FA /* FaceProvider in Frameworks */ = {isa = PBXBuildFile; productRef = B4ABEE07102A87719470BBFF /* FaceProvider */; }; CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3FDFD387188D36AF8287DE /* FanControl.swift */; }; CD501EBAE39807F5D354C5B2 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; }; DD41F48611EAFC1DC5B28FD4 /* GalleryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C758A5ABC2F748BE7306038 /* GalleryView.swift */; }; DD98BEF77E614424C4A37291 /* GalleryWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F63DBABD5B2905865FD4FA2 /* GalleryWindowController.swift */; }; + E0986A76EB4F84978D9524EE /* LockSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4EEFE36C277DF75847BCB0 /* LockSettingsView.swift */; }; E7A8CABF31F3CC88C7CB0176 /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4472730A92A48657BDCEB9 /* SettingsSection.swift */; }; EA4D6312E8A96E177F10797F /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; }; EF4351407AC9DCDDE225C96F /* NotesProvider in Frameworks */ = {isa = PBXBuildFile; productRef = F8AC40480E05171EB8091269 /* NotesProvider */; }; @@ -95,6 +98,7 @@ B442E8A39C37C8FD92B4CA83 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; B8214C5599D1857E832447EC /* bolt-light.svg */ = {isa = PBXFileReference; path = "bolt-light.svg"; sourceTree = ""; }; B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; + DA4EEFE36C277DF75847BCB0 /* LockSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockSettingsView.swift; sourceTree = ""; }; DF4472730A92A48657BDCEB9 /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = ""; }; E2585137DB7F67F91768E0EF /* WindowPlacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowPlacement.swift; sourceTree = ""; }; F12041E330231599C6540C21 /* FanSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanSettingsView.swift; sourceTree = ""; }; @@ -123,6 +127,8 @@ 534B041763B7D4B03DFF53D4 /* ActivityProvider in Frameworks */, 39B06041FD90361FA72CB386 /* ClipboardProvider in Frameworks */, EF4351407AC9DCDDE225C96F /* NotesProvider in Frameworks */, + CA8D4A7AD49FFA5E3055F3FA /* FaceProvider in Frameworks */, + 00FC2E6D214B866B05D66F0F /* LockProvider in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -199,6 +205,7 @@ 50A018673EE6BD9C50F1EFA0 /* Info.plist */, 41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */, 508DECB5C05264E6130C373E /* Localizable.xcstrings */, + DA4EEFE36C277DF75847BCB0 /* LockSettingsView.swift */, 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */, 6115888EE7738877C9304138 /* MixerMenuBarModule.swift */, 0FDB63D9352C7D2F8406B1DF /* NotchAccessories.swift */, @@ -253,6 +260,8 @@ B9524DE253CBAF4344096790 /* ActivityProvider */, 913CFE7A8E299E11C2BF23CF /* ClipboardProvider */, F8AC40480E05171EB8091269 /* NotesProvider */, + B4ABEE07102A87719470BBFF /* FaceProvider */, + 3054FDAC34BBC6E684BCFE22 /* LockProvider */, ); productName = Onyx; productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */; @@ -392,6 +401,7 @@ DD41F48611EAFC1DC5B28FD4 /* GalleryView.swift in Sources */, DD98BEF77E614424C4A37291 /* GalleryWindowController.swift in Sources */, B309DD8966B5348250CEC6BD /* LaunchAtLogin.swift in Sources */, + E0986A76EB4F84978D9524EE /* LockSettingsView.swift in Sources */, 8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */, 033116546F93AE6C61A0D9D7 /* NotchAccessories.swift in Sources */, C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */, @@ -671,6 +681,10 @@ /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 3054FDAC34BBC6E684BCFE22 /* LockProvider */ = { + isa = XCSwiftPackageProductDependency; + productName = LockProvider; + }; 349226D70F51196CCD1C94F8 /* MediaProvider */ = { isa = XCSwiftPackageProductDependency; productName = MediaProvider; @@ -707,6 +721,10 @@ isa = XCSwiftPackageProductDependency; productName = OnyxMenuBar; }; + B4ABEE07102A87719470BBFF /* FaceProvider */ = { + isa = XCSwiftPackageProductDependency; + productName = FaceProvider; + }; B9524DE253CBAF4344096790 /* ActivityProvider */ = { isa = XCSwiftPackageProductDependency; productName = ActivityProvider; diff --git a/Onyx/AppModel.swift b/Onyx/AppModel.swift index e06b0ed..e7efbe7 100644 --- a/Onyx/AppModel.swift +++ b/Onyx/AppModel.swift @@ -23,6 +23,7 @@ final class AppModel { var onShowsIdleIslandChanged: ((Bool) -> Void)? var onNotchStyleChanged: ((NotchStyle) -> Void)? var onNotchWidthsChanged: (([CGDirectDisplayID: CGFloat]) -> Void)? + var onFaceUnlockChanged: ((Bool) -> Void)? var layout: [WidgetPlacement] { didSet { @@ -63,6 +64,27 @@ final class AppModel { onNotchWidthsChanged?(notchWidths) } + /// Ob Onyx einen eigenen Sperrbildschirm anbietet. + var lockScreenEnabled: Bool { + didSet { + guard lockScreenEnabled != oldValue else { return } + defaults.set(lockScreenEnabled, forKey: Keys.lockScreenEnabled) + } + } + + /// Ob dabei das Gesicht verglichen wird. + /// + /// Getrennt vom Sperrbildschirm: man kann ihn wollen und den + /// Gesichtsvergleich trotzdem nicht — er ist der Teil, der sich täuschen + /// lässt. + var faceUnlockEnabled: Bool { + didSet { + guard faceUnlockEnabled != oldValue else { return } + defaults.set(faceUnlockEnabled, forKey: Keys.faceUnlockEnabled) + onFaceUnlockChanged?(faceUnlockEnabled) + } + } + /// Was links neben der Notch steht. var bandLeading: BandAccessory { didSet { @@ -154,6 +176,8 @@ final class AppModel { static let showsIdleIsland = "onyx.showsIdleIsland" static let notchStyle = "onyx.notchStyle" static let notchWidths = "onyx.notchWidths" + static let lockScreenEnabled = "onyx.lock.enabled" + static let faceUnlockEnabled = "onyx.lock.face" static let bandLeading = "onyx.band.leading" static let bandTrailing = "onyx.band.trailing" static let showsMenuBarIcon = "onyx.showsMenuBarIcon" @@ -174,6 +198,12 @@ final class AppModel { self.showsIdleIsland = defaults.object(forKey: Keys.showsIdleIsland) as? Bool ?? true self.notchStyle = defaults.string(forKey: Keys.notchStyle) .flatMap(NotchStyle.init(rawValue:)) ?? .automatic + // Beides standardmäßig **aus**: ein Sperrbildschirm, den man nicht + // bestellt hat, ist ein Schreck, und ein Gesichtsvergleich ohne + // angelerntes Gesicht kann ohnehin nichts. + self.lockScreenEnabled = defaults.object(forKey: Keys.lockScreenEnabled) as? Bool ?? false + self.faceUnlockEnabled = defaults.object(forKey: Keys.faceUnlockEnabled) as? Bool ?? false + // Die bisherige feste Verdrahtung als Voreinstellung: wer nichts // einstellt, sieht, was vorher auch dastand. self.bandLeading = defaults.string(forKey: Keys.bandLeading) diff --git a/Onyx/Info.plist b/Onyx/Info.plist index 800b61c..a7fa234 100644 --- a/Onyx/Info.plist +++ b/Onyx/Info.plist @@ -48,6 +48,8 @@ Onyx zeigt dein Hintergrundbild in der Vorschau der Auslösefläche. Liegt das Bild in „Downloads", braucht Onyx dafür einmal deine Erlaubnis. NSMicrophoneUsageDescription Onyx zeigt im Mixer an, wie stark dein Mikrofon gerade ausschlägt. Gemessen wird nur, solange die Ansicht offen ist — aufgezeichnet wird nichts. + NSCameraUsageDescription + Onyx vergleicht dein Gesicht mit den Aufnahmen, die du selbst angelernt hast, um den Sperrbildschirm zu öffnen. Die Bilder verlassen deinen Mac nicht — gespeichert werden nur Merkmalsvektoren im Schlüsselbund, aus denen sich kein Bild zurückgewinnen lässt. NSAudioCaptureUsageDescription Onyx braucht Zugriff auf die Audioausgabe, um die Lautstärke einzelner Programme zu regeln. Es wird nichts aufgezeichnet. diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index 5a37403..1ef043e 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -1336,6 +1336,390 @@ } } }, + "lock.disclaimer.settings": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieser Sperrbildschirm ist Bequemlichkeit, keine Sicherheitsgrenze. Wer den Prozess beendet, sich per SSH anmeldet oder den Mac neu startet, kommt daran vorbei. Der Gesichtsvergleich läuft über die Webcam und lässt sich mit einem Foto täuschen — Face ID hat dafür einen Tiefensensor, die Mac-Kamera nicht. Für echten Schutz bleibt die Sperre von macOS zuständig." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This lock screen is convenience, not a security boundary. Quitting the process, logging in over SSH or restarting the Mac gets past it. The face check runs on the webcam and can be fooled by a photo — Face ID has a depth sensor for that, the Mac camera does not. Real protection remains macOS' own lock." + } + } + } + }, + "lock.enabled": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Eigenen Sperrbildschirm benutzen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Use Onyx's lock screen" + } + } + } + }, + "lock.enabled.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Legt sich über den Bildschirm, damit das Entsperren per Gesicht stattfinden kann." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Covers the screen so face unlock can happen." + } + } + } + }, + "lock.enroll.cancel": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Abbrechen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + } + } + }, + "lock.enroll.capture": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aufnehmen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Capture" + } + } + } + }, + "lock.enroll.close": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schließen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + } + } + }, + "lock.enroll.done": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fertig angelernt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enrolment complete." + } + } + } + }, + "lock.enroll.noFace": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Gesicht im Bild" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No face in frame" + } + } + } + }, + "lock.enroll.ready": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gesicht erkannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Face detected" + } + } + } + }, + "lock.face.again": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu anlernen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enrol again" + } + } + } + }, + "lock.face.denied": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kamerazugriff verweigert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Camera access denied" + } + } + } + }, + "lock.face.enabled": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gesicht vergleichen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Compare face" + } + } + } + }, + "lock.face.enroll": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anlernen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enrol" + } + } + } + }, + "lock.face.enrolled": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Angelerntes Gesicht" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enrolled face" + } + } + } + }, + "lock.face.forget": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Vergessen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Forget" + } + } + } + }, + "lock.face.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Braucht ein angelerntes Gesicht." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Requires an enrolled face." + } + } + } + }, + "lock.face.notEnrolled": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keines angelernt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "None enrolled yet." + } + } + } + }, + "lock.face.openSettings": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemeinstellungen öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open System Settings" + } + } + } + }, + "lock.now": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Jetzt sperren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Lock now" + } + } + } + }, + "lock.now.action": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sperren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Lock" + } + } + } + }, + "lock.section.face": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gesicht" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Face" + } + } + } + }, + "lock.section.screen": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sperrbildschirm" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Lock screen" + } + } + } + }, + "lock.section.what": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Was das ist — und was nicht" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "What this is — and what it isn't" + } + } + } + }, + "menu.lock": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildschirm sperren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Lock screen" + } + } + } + }, "menu.openPanel": { "extractionState": "stale", "localizations": { @@ -3468,6 +3852,22 @@ } } }, + "settings.section.lock": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sperrbildschirm" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Lock screen" + } + } + } + }, "settings.section.notch": { "localizations": { "de": { diff --git a/Onyx/LockSettingsView.swift b/Onyx/LockSettingsView.swift new file mode 100644 index 0000000..c2d0571 --- /dev/null +++ b/Onyx/LockSettingsView.swift @@ -0,0 +1,136 @@ +import SwiftUI +import AVFoundation +import OnyxDesign +import FaceProvider +import LockProvider + +/// Sperrbildschirm und Gesichtsvergleich. +/// +/// **Die Einschränkung steht oben und nicht unten.** Wer diese Seite öffnet, +/// soll zuerst lesen, was das hier ist und was nicht — nicht erst, nachdem er +/// sein Gesicht angelernt hat. +struct LockSettingsView: View { + + @Bindable var model: AppModel + let controller: LockController + + @State private var enrollment = FaceEnrollment() + @State private var isEnrolling = false + @State private var cameraDenied = false + + var body: some View { + SettingsPage { + OnyxFormCard(String(localized: "lock.section.what"), + hint: String(localized: "lock.disclaimer.settings")) { + EmptyView() + } + + OnyxFormCard(String(localized: "lock.section.screen")) { + OnyxFormRow(String(localized: "lock.enabled"), + hint: String(localized: "lock.enabled.hint")) { + Toggle("", isOn: $model.lockScreenEnabled).labelsHidden() + } + OnyxFormRow(String(localized: "lock.now")) { + Button(String(localized: "lock.now.action")) { controller.lock() } + } + } + + OnyxFormCard(String(localized: "lock.section.face")) { + OnyxFormRow(String(localized: "lock.face.enabled"), + hint: String(localized: "lock.face.hint")) { + Toggle("", isOn: $model.faceUnlockEnabled).labelsHidden() + .disabled(!controller.isEnrolled) + } + + OnyxFormRow(String(localized: "lock.face.enrolled"), + hint: controller.isEnrolled + ? nil : String(localized: "lock.face.notEnrolled")) { + HStack(spacing: Onyx.Space.lg) { + Button(controller.isEnrolled + ? String(localized: "lock.face.again") + : String(localized: "lock.face.enroll")) { + Task { await beginEnrollment() } + } + if controller.isEnrolled { + Button(String(localized: "lock.face.forget"), role: .destructive) { + enrollment.reset() + model.faceUnlockEnabled = false + } + } + } + } + + if cameraDenied { + OnyxFormRow(String(localized: "lock.face.denied")) { + Button(String(localized: "lock.face.openSettings")) { + NSWorkspace.shared.open(URL(string: + "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera")!) + } + } + } + } + } + .sheet(isPresented: $isEnrolling) { + EnrollmentSheet(enrollment: enrollment) { isEnrolling = false } + } + } + + private func beginEnrollment() async { + guard await FaceCapture.requestAccess() else { + cameraDenied = true + return + } + cameraDenied = false + enrollment.reset() + isEnrolling = true + enrollment.start() + } +} + +/// Das Anlernen: Vorschau, Anweisung, Auslöser. +private struct EnrollmentSheet: View { + let enrollment: FaceEnrollment + let onClose: () -> Void + + var body: some View { + VStack(spacing: Onyx.Space.xl) { + if let failure = enrollment.failure { + Text(failure).foregroundStyle(Onyx.Color.critical) + } else if enrollment.isFinished { + VStack(spacing: Onyx.Space.md) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: Onyx.Symbol.hero)) + .foregroundStyle(Onyx.Color.positive) + Text("lock.enroll.done") + } + } else { + CameraPreview(session: enrollment.session) + .frame(width: 320, height: 240) + .clipShape(RoundedRectangle(cornerRadius: Onyx.Radius.card, + style: .continuous)) + + Text(enrollment.pose.localizedName, bundle: .main) + .font(Onyx.Font.headline) + + Text(enrollment.seesFace ? "lock.enroll.ready" : "lock.enroll.noFace") + .font(Onyx.Font.footnote) + .foregroundStyle(enrollment.seesFace + ? Onyx.Color.positive : Onyx.Color.textTertiary) + + Button(String(localized: "lock.enroll.capture")) { + enrollment.captureCurrentPose() + } + .disabled(!enrollment.seesFace) + } + + Button(enrollment.isFinished + ? String(localized: "lock.enroll.close") + : String(localized: "lock.enroll.cancel")) { + enrollment.stop() + onClose() + } + } + .padding(Onyx.Space.xxl) + .frame(width: 420) + } +} diff --git a/Onyx/Onyx.entitlements b/Onyx/Onyx.entitlements index 8dc1daa..4e64b6c 100644 --- a/Onyx/Onyx.entitlements +++ b/Onyx/Onyx.entitlements @@ -24,6 +24,14 @@ com.apple.security.cs.disable-library-validation + + com.apple.security.device.camera + +