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>
303 lines
12 KiB
Swift
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)")
|
|
}
|
|
}
|
|
}
|