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>
This commit is contained in:
Scarriffle
2026-09-05 21:22:32 +02:00
co-authored by Claude Opus 5
parent 3da673662f
commit e3b215a1dd
23 changed files with 1605 additions and 1 deletions
+30
View File
@@ -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)
+2
View File
@@ -48,6 +48,8 @@
<string>Onyx zeigt dein Hintergrundbild in der Vorschau der Auslösefläche. Liegt das Bild in „Downloads", braucht Onyx dafür einmal deine Erlaubnis.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Onyx zeigt im Mixer an, wie stark dein Mikrofon gerade ausschlägt. Gemessen wird nur, solange die Ansicht offen ist — aufgezeichnet wird nichts.</string>
<key>NSCameraUsageDescription</key>
<string>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.</string>
<key>NSAudioCaptureUsageDescription</key>
<string>Onyx braucht Zugriff auf die Audioausgabe, um die Lautstärke einzelner Programme zu regeln. Es wird nichts aufgezeichnet.</string>
+400
View File
@@ -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": {
+136
View File
@@ -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)
}
}
+8
View File
@@ -24,6 +24,14 @@
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Kamera für den Gesichtsvergleich. Ohne dieses Entitlement **und**
NSCameraUsageDescription erscheint der Dialog nicht: die Anfrage
kommt kommentarlos mit `false` zurück, und in den Systemeinstellungen
taucht Onyx nie auf. Derselbe Fallstrick wie beim Kalender, siehe
docs/spikes/E-tcc-hardened-runtime.md. -->
<key>com.apple.security.device.camera</key>
<true/>
<!-- Bei aktivierter Hardened Runtime verlangt TCC diese Entitlements —
auch ohne Sandbox. Das ist die verbreitete Fehlannahme: sie gelten
nicht nur für sandboxed Apps.
+18
View File
@@ -13,6 +13,8 @@ import NetworkProvider
import AudioProvider
import ShelfProvider
import ClipboardProvider
import LockProvider
import FaceProvider
import NotesProvider
import ActivityProvider
import OSLog
@@ -59,6 +61,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
/// Die Helligkeit des eingebauten Bildschirms. Auf Rechnern ohne lesbare
/// Helligkeit bleibt sie leer und meldet nie etwas.
private let brightness = DisplayBrightness()
/// Der Sperrbildschirm. Steht bereit, sperrt aber nur auf Verlangen.
private let lockController = LockController()
/// Der zuletzt gemeldete Stand — der erste meldet nichts, sonst begrüßte
/// Onyx jeden Start mit der Helligkeit, die ohnehin schon eingestellt war.
private var lastBrightness: Double?
@@ -183,6 +187,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
model.onNotchWidthsChanged = { [weak self] widths in
self?.coordinator?.virtualWidthOverrides = widths
}
// Ohne diese Zeile wäre der Schalter in den Einstellungen eine
// Behauptung: gespeichert, aber ohne Wirkung.
lockController.usesFaceUnlock = model.faceUnlockEnabled
model.onFaceUnlockChanged = { [weak self] uses in
self?.lockController.usesFaceUnlock = uses
}
let menuBar = MenuBarController()
if let metricsModel {
@@ -407,6 +417,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
menu.addItem(withTitle: "Schaufenster",
action: #selector(openGallery), keyEquivalent: "").target = self
}
if model?.lockScreenEnabled == true {
menu.addItem(.separator())
menu.addItem(withTitle: String(localized: "menu.lock"),
action: #selector(lockNow), keyEquivalent: "l").target = self
}
menu.addItem(.separator())
menu.addItem(withTitle: String(localized: "menu.quit"),
action: #selector(quit), keyEquivalent: "q").target = self
@@ -417,6 +432,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func openOnboarding() { showOnboarding() }
@objc private func lockNow() { lockController.lock() }
/// Meldet jede Änderung der Systemlautstärke als Live Activity an.
///
/// Über `withObservationTracking` und nicht über einen eigenen Listener:
@@ -626,6 +643,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
settingsWindow.show(model: model, calendarModel: calendarModel,
weatherModel: weatherModel, metricsModel: metricsModel,
shelfStore: shelfStore, fanControl: fanControl,
lockController: lockController,
launchAtLogin: launchAtLogin,
showOnboarding: { [weak self] in self?.showOnboarding() },
section: section)
+4
View File
@@ -19,6 +19,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
case appearance
case menubar
case fans
case lockScreen
case permissions
var id: Self { self }
@@ -32,6 +33,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
case .appearance: String(localized: "settings.section.appearance")
case .menubar: String(localized: "settings.tab.menubar")
case .fans: String(localized: "settings.tab.fans")
case .lockScreen: String(localized: "settings.section.lock")
case .permissions: String(localized: "settings.tab.permissions")
}
}
@@ -45,6 +47,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
case .appearance: "paintpalette.fill"
case .menubar: "menubar.rectangle"
case .fans: "fan.fill"
case .lockScreen: "faceid"
case .permissions: "lock.fill"
}
}
@@ -63,6 +66,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
case .appearance: Color(red: 0.90, green: 0.60, blue: 0.28)
case .menubar: Color(red: 0.85, green: 0.45, blue: 0.66)
case .fans: Color(red: 0.33, green: 0.68, blue: 0.85)
case .lockScreen: Color(red: 0.45, green: 0.44, blue: 0.80)
case .permissions: Color(red: 0.82, green: 0.37, blue: 0.35)
}
}
+4
View File
@@ -1,6 +1,7 @@
import SwiftUI
import OnyxDesign
import OnyxNotch
import LockProvider
import OnyxWidgetKit
import CalendarProvider
import ShelfProvider
@@ -16,6 +17,7 @@ struct SettingsView: View {
let metricsModel: MetricsModel?
let shelfStore: ShelfStore?
let fanControl: FanControl
let lockController: LockController
let launchAtLogin: LaunchAtLogin
let showOnboarding: () -> Void
/// Der gewählte Bereich liegt im Fenster-Controller, damit ein erneutes
@@ -103,6 +105,8 @@ struct SettingsView: View {
MenuBarSettings(model: model, metricsModel: metricsModel)
case .fans:
FanSettingsView(control: fanControl)
case .lockScreen:
LockSettingsView(model: model, controller: lockController)
case .permissions:
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
}
+3
View File
@@ -5,6 +5,7 @@ import CalendarProvider
import WeatherProvider
import MetricsProvider
import ShelfProvider
import LockProvider
/// Führt das Einstellungsfenster selbst.
///
@@ -37,6 +38,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl,
lockController: LockController,
launchAtLogin: LaunchAtLogin, showOnboarding: @escaping () -> Void,
section: SettingsSection = .general) {
self.section = section
@@ -58,6 +60,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
metricsModel: metricsModel,
shelfStore: shelfStore,
fanControl: fanControl,
lockController: lockController,
launchAtLogin: launchAtLogin,
showOnboarding: showOnboarding,
section: Binding(