Files
ScarriffleandClaude Opus 5 a3399d3a96 Zwei Anzeigen für einen Handgriff sind eine zu viel
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>
2026-09-05 22:26:41 +02:00

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