Files
onyx/Packages/OnyxKit/Sources/LockProvider/LockController.swift
T
ScarriffleandClaude Opus 5 e3b215a1dd Ein eigener Sperrbildschirm — und ohne gespeichertes Passwort
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 <noreply@anthropic.com>
2026-09-05 21:22:32 +02:00

126 lines
3.7 KiB
Swift

import AppKit
import SwiftUI
import Observation
import FaceProvider
import os
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Lock")
/// Führt den Sperrbildschirm: Fenster je Bildschirm, Kamera, Passwortrückfall.
@MainActor
@Observable
public final class LockController {
public private(set) var isLocked = false
/// Was gerade zu sehen sein soll.
public private(set) var status: LockStatus = .idle
public enum LockStatus: Equatable, Sendable {
/// Nicht gesperrt.
case idle
/// Kamera sucht.
case looking
/// Erkannt, gleich offen.
case recognised
/// Kein Gesicht hinterlegt oder Kamera nicht verfügbar — nur Passwort.
case passwordOnly
/// Zu viele Fehlversuche.
case lockedOut
/// Ein falsches Passwort.
case wrongPassword
}
@ObservationIgnored private var windows: [LockWindow] = []
@ObservationIgnored private let capture = FaceCapture()
@ObservationIgnored private let store = FaceStore()
@ObservationIgnored private let verifier = PasswordVerifier()
@ObservationIgnored private var matcher: FaceMatcher?
@ObservationIgnored private var session = FaceMatcher.Session()
/// Ob Gesichtserkennung überhaupt benutzt werden soll.
public var usesFaceUnlock = true
public init() {}
/// Ob ein Gesicht angelernt ist.
public var isEnrolled: Bool { !store.load().isEmpty }
// MARK: - Sperren
public func lock() {
guard !isLocked else { return }
isLocked = true
session = FaceMatcher.Session()
let templates = store.load()
matcher = FaceMatcher(templates: templates)
for screen in NSScreen.screens {
let window = LockWindow(screen: screen)
window.contentView = NSHostingView(rootView: LockScreenView(controller: self))
window.orderFrontRegardless()
windows.append(window)
}
// Nur eines soll die Eingabe bekommen — sonst tippt man ins Nichts.
windows.first?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
guard usesFaceUnlock, !templates.isEmpty, FaceCapture.hasCamera else {
status = .passwordOnly
return
}
startLooking()
}
public func unlock() {
guard isLocked else { return }
capture.stop()
windows.forEach { $0.orderOut(nil) }
windows.removeAll()
isLocked = false
status = .idle
}
// MARK: - Gesicht
private func startLooking() {
status = .looking
capture.onVector = { [weak self] vector in self?.evaluate(vector) }
capture.onNoFace = { }
capture.start()
}
private func evaluate(_ vector: [Float]) {
guard let matcher, isLocked else { return }
switch matcher.evaluate(vector, in: &session) {
case .accepted:
status = .recognised
capture.stop()
// Ein Augenblick, damit man die Rückmeldung sieht — sonst
// verschwindet der Bildschirm, bevor klar ist, warum.
Task {
try? await Task.sleep(for: .milliseconds(400))
self.unlock()
}
case .undecided:
break
case .lockedOut:
log.notice("Gesichtserkennung gesperrt — nur noch Passwort")
status = .lockedOut
capture.stop()
}
}
// MARK: - Passwort
/// Prüft die Eingabe gegen die echte Anmeldung. Gespeichert wird sie nicht.
public func submit(password: String) {
guard verifier.verify(password) else {
status = status == .lockedOut ? .lockedOut : .wrongPassword
return
}
unlock()
}
}