Apples eigene Einblendung lässt sich aushängen, ohne Root und ohne SIP anzufassen: `com.apple.OSDUIHelper` liegt in der GUI-Domäne des angemeldeten Benutzers, nicht im System. `launchctl bootout` dort hinein genügt. Standardmäßig aus, denn es verschwindet **jede** Einblendung — auch Helligkeit und Feststelltaste, und für die Feststelltaste hat Onyx keinen Ersatz. Beim Beenden wird wieder eingehängt: eine Einstellung darf nicht überleben, was sie eingestellt hat. Dazu zwei Fehler im Anlernen behoben. Der Posenname stand als roher Schlüssel da — die Zeichenketten liegen im Bündel von `FaceProvider`, gesucht wurden sie im Hauptbündel. Und „Gesicht vergleichen" blieb nach dem Anlernen grau, weil `isEnrolled` bei jedem Aufruf den Schlüsselbund las: richtig, aber für SwiftUI unsichtbar, weil es nichts gab, woran eine Ansicht die Änderung hätte bemerken können. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
140 lines
4.3 KiB
Swift
140 lines
4.3 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() {
|
|
refreshEnrollment()
|
|
}
|
|
|
|
/// Ob ein Gesicht angelernt ist.
|
|
///
|
|
/// **Gespeichert und nicht berechnet.** Als berechnete Eigenschaft las sie
|
|
/// bei jedem Aufruf den Schlüsselbund — richtig, aber für SwiftUI
|
|
/// unsichtbar: es gab nichts, woran eine Ansicht hätte merken können, dass
|
|
/// gerade ein Gesicht angelernt wurde. Der Schalter „Gesicht vergleichen"
|
|
/// blieb deshalb grau, bis man das Fenster neu öffnete.
|
|
public private(set) var isEnrolled = false
|
|
|
|
/// Liest nach, ob ein Gesicht hinterlegt ist. Nach dem Anlernen und nach
|
|
/// dem Vergessen zu rufen.
|
|
public func refreshEnrollment() {
|
|
isEnrolled = !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()
|
|
}
|
|
}
|