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() } }