Files
onyx/Onyx/AppModel.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

303 lines
12 KiB
Swift

import SwiftUI
import AppKit
import OnyxNotch
import OnyxWidgetKit
import OnyxMenuBar
/// Der geteilte Zustand von Onyx: was im Panel liegt und auf welchen Displays.
///
/// Änderungen wirken sofort. Eine Einstellung, die erst nach einem Neustart
/// greift, ist in einer App ohne Fenster praktisch unauffindbar — man sieht ja
/// nicht, dass etwas passiert ist.
@MainActor
@Observable
final class AppModel {
private let store: LayoutStore
private let defaults: UserDefaults
/// Wird gerufen, wenn sich die Panelgröße geändert haben könnte.
var onLayoutChanged: (([WidgetPlacement]) -> Void)?
/// Wird gerufen, wenn Panels auf anderen Displays erscheinen sollen.
var onDisplayPolicyChanged: ((DisplayPolicy) -> Void)?
var onShowsIdleIslandChanged: ((Bool) -> Void)?
var onNotchStyleChanged: ((NotchStyle) -> Void)?
var onNotchWidthsChanged: (([CGDirectDisplayID: CGFloat]) -> Void)?
var onFaceUnlockChanged: ((Bool) -> Void)?
var layout: [WidgetPlacement] {
didSet {
guard layout != oldValue else { return }
persistLayout()
onLayoutChanged?(layout)
}
}
var displayPolicy: DisplayPolicy {
didSet {
guard displayPolicy != oldValue else { return }
defaults.set(displayPolicy.rawValue, forKey: Keys.displayPolicy)
onDisplayPolicyChanged?(displayPolicy)
}
}
/// Abweichende Inselbreiten, je Bildschirm — leer heißt „Standardbreite".
///
/// Als Wörterbuch über die `CGDirectDisplayID` und nicht als Liste über den
/// Index in `NSScreen.screens`: der Index ändert sich beim Umstecken, die
/// Kennung nicht. Sonst gälte die Einstellung des einen Monitors plötzlich
/// für den anderen.
private(set) var notchWidths: [CGDirectDisplayID: CGFloat] = [:]
/// Die eingestellte Breite dieses Bildschirms, falls es eine gibt.
func notchWidth(for displayID: CGDirectDisplayID) -> CGFloat? {
notchWidths[displayID]
}
/// Setzt eine abweichende Breite — oder nimmt sie mit `nil` zurück.
func setNotchWidth(_ width: CGFloat?, for displayID: CGDirectDisplayID) {
if let width { notchWidths[displayID] = width } else { notchWidths[displayID] = nil }
// Als Zeichenkette gespeichert, weil `UserDefaults` keine Wörterbücher
// mit Zahlenschlüsseln kann.
let raw = notchWidths.reduce(into: [String: Double]()) { $0[String($1.key)] = Double($1.value) }
defaults.set(raw, forKey: Keys.notchWidths)
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 {
guard bandLeading != oldValue else { return }
defaults.set(bandLeading.rawValue, forKey: Keys.bandLeading)
}
}
/// Was rechts daneben steht.
var bandTrailing: BandAccessory {
didSet {
guard bandTrailing != oldValue else { return }
defaults.set(bandTrailing.rawValue, forKey: Keys.bandTrailing)
}
}
/// Notch oder Insel — die Form, die Onyx oben annimmt.
var notchStyle: NotchStyle {
didSet {
guard notchStyle != oldValue else { return }
defaults.set(notchStyle.rawValue, forKey: Keys.notchStyle)
onNotchStyleChanged?(notchStyle)
}
}
/// Ob die eingeklappte Insel sichtbar stehen bleibt.
///
/// Standardmäßig an: ohne sie wächst das Panel auf externen Bildschirmen
/// aus dem Nichts. Abschaltbar, weil ein schwarzer Balken auf vier
/// Monitoren eine Geschmacksfrage ist und keine Voreinstellung sein darf,
/// von der man nicht wegkommt.
var showsIdleIsland: Bool {
didSet {
guard showsIdleIsland != oldValue else { return }
defaults.set(showsIdleIsland, forKey: Keys.showsIdleIsland)
onShowsIdleIslandChanged?(showsIdleIsland)
}
}
var showsMenuBarIcon: Bool {
didSet { defaults.set(showsMenuBarIcon, forKey: Keys.showsMenuBarIcon) }
}
/// Ob Onyx im Dock auftaucht.
///
/// Standardmäßig nicht: Onyx ist ein Werkzeug, das in der Notch und in der
/// Menüleiste lebt, hat kein Hauptfenster und wäre im Dock ein Symbol, das
/// beim Anklicken nichts öffnet. Es taucht dann auch im Programmumschalter
/// auf und steht dort zwischen den Programmen, mit denen man arbeitet.
var showsDockIcon: Bool {
didSet {
guard showsDockIcon != oldValue else { return }
defaults.set(showsDockIcon, forKey: Keys.showsDockIcon)
NSApp.setActivationPolicy(showsDockIcon ? .regular : .accessory)
}
}
/// Der Menüleisten-Controller. Wird nach dem Aufbau gesetzt, damit
/// Änderungen aus den Einstellungen sofort durchschlagen.
weak var menuBar: MenuBarController?
/// Die gespeicherten Einstellungen eines Menüleisten-Moduls.
///
/// Standardmäßig ist **kein** Modul an. Sechs Symbole beim ersten Start
/// wären eine Zumutung — wer sie will, schaltet sie einzeln ein.
func menuBarSettings(for moduleID: String) -> MenuBarModuleSettings {
guard let data = defaults.data(forKey: Keys.menuBarModule(moduleID)),
let decoded = try? JSONDecoder().decode(MenuBarModuleSettings.self, from: data)
else { return MenuBarModuleSettings() }
return decoded
}
func setMenuBarSettings(_ settings: MenuBarModuleSettings, for moduleID: String) {
if let data = try? JSONEncoder().encode(settings) {
defaults.set(data, forKey: Keys.menuBarModule(moduleID))
}
menuBar?.setEnabled(settings.isEnabled, forModuleID: moduleID)
menuBar?.setPresentation(settings.presentation, forModuleID: moduleID)
menuBarRevision += 1
}
/// Zählt hoch, wenn sich ein Modul ändert — die Einstellungen zeichnen sich
/// daran neu, ohne dass jedes Modul einzeln beobachtet werden müsste.
private(set) var menuBarRevision = 0
private enum Keys {
static func menuBarModule(_ id: String) -> String { "onyx.menubar.module.\(id)" }
static let displayPolicy = "onyx.displayPolicy"
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"
static let showsDockIcon = "onyx.showsDockIcon"
static let triggerArea = "onyx.triggerArea"
}
init(store: LayoutStore = .standard(), defaults: UserDefaults = .standard) {
self.store = store
self.defaults = defaults
let known = Set(WidgetRegistry.shared.all.map(\.id))
self.layout = store.load(knownWidgetIDs: known)
self.displayPolicy = defaults.string(forKey: Keys.displayPolicy)
.flatMap(DisplayPolicy.init(rawValue:)) ?? .builtInOnly
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)
.flatMap(BandAccessory.init(rawValue:)) ?? .weather
self.bandTrailing = defaults.string(forKey: Keys.bandTrailing)
.flatMap(BandAccessory.init(rawValue:)) ?? .battery
self.notchWidths = (defaults.dictionary(forKey: Keys.notchWidths) as? [String: Double] ?? [:])
.reduce(into: [CGDirectDisplayID: CGFloat]()) { result, entry in
guard let id = CGDirectDisplayID(entry.key) else { return }
result[id] = CGFloat(entry.value)
}
// Standardmäßig sichtbar: das Menüleistensymbol ist der einzige Weg
// zurück, wenn die Notch einmal nicht reagiert.
self.showsMenuBarIcon = defaults.object(forKey: Keys.showsMenuBarIcon) as? Bool ?? true
self.showsDockIcon = defaults.object(forKey: Keys.showsDockIcon) as? Bool ?? false
self.triggerArea = (defaults.data(forKey: Keys.triggerArea)
.flatMap { try? JSONDecoder().decode(TriggerArea.self, from: $0) }) ?? .standard
}
// MARK: - Widgets
/// Die Auslösefläche der Notch. Wirkt sofort.
var triggerArea: TriggerArea {
didSet {
guard triggerArea != oldValue else { return }
if let data = try? JSONEncoder().encode(triggerArea) {
defaults.set(data, forKey: Keys.triggerArea)
}
onTriggerAreaChanged?(triggerArea)
}
}
var onTriggerAreaChanged: ((TriggerArea) -> Void)?
/// Zählt Bildschirmwechsel mit.
///
/// Nur dafür da, beobachtbar zu sein: die Breite kommt aus `NSScreen`, und
/// davon erfährt SwiftUI von sich aus nichts. Ohne diesen Zähler bliebe ein
/// Panel, das einmal für einen schmalen Fernseher gerechnet wurde, für
/// immer schmal — auch nach dem Abstecken.
private(set) var screenGeneration = 0
func noteScreensChanged() { screenGeneration += 1 }
/// Wie breit das Panel auf **diesem** Bildschirm werden darf.
///
/// Je Bildschirm und nicht für alle zusammen. Vorher gab der schmalste
/// aktive Bildschirm den Ausschlag — mit der Begründung, dasselbe Panel
/// erscheine überall. Der Fall, der das widerlegt: ein Fernseher mit 1280
/// Punkten Breite drückte das Panel auch auf dem Notebook zusammen, die
/// Kacheln brachen um, und es wurde hoch statt breit. Ein Panel, das auf
/// dem eigenen Bildschirm nicht mehr passt, weil woanders ein Gerät hängt,
/// ist der schlechtere Kompromiss.
///
/// Der Abzug lässt Luft zum Rand — ein Panel, das exakt bis zur Kante
/// geht, wirkt eingeklemmt.
func availableWidth(forDisplay id: CGDirectDisplayID) -> CGFloat {
_ = screenGeneration
let width = NSScreen.screens.first { $0.displayID == id }?.frame.width
?? NSScreen.screens.map(\.frame.width).min()
?? 1440
return width - 120
}
func panelContentSize(forDisplay id: CGDirectDisplayID) -> CGSize {
WidgetGrid.panelSize(for: layout, availableWidth: availableWidth(forDisplay: id))
}
func isInPanel(_ widgetID: String) -> Bool {
layout.contains { $0.widgetID == widgetID }
}
func add(_ widget: any OnyxWidget) {
guard !isInPanel(widget.id) else { return }
layout.append(WidgetPlacement(widgetID: widget.id))
}
func remove(_ widgetID: String) {
layout.removeAll { $0.widgetID == widgetID }
}
func move(fromOffsets source: IndexSet, toOffset destination: Int) {
layout.move(fromOffsets: source, toOffset: destination)
}
private func persistLayout() {
do {
try store.save(layout)
} catch {
// Ein fehlgeschlagener Schreibvorgang darf die App nicht anhalten;
// beim nächsten Start greift dann das Standardlayout.
NSLog("Onyx: Layout konnte nicht gespeichert werden — \(error.localizedDescription)")
}
}
}