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>
330 lines
14 KiB
Swift
330 lines
14 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 onSuppressOSDChanged: ((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 Apples eigene Einblendung unterdrückt wird.
|
|
///
|
|
/// Standardmäßig aus: es verschwindet **jede** Einblendung, auch die für
|
|
/// Helligkeit und Feststelltaste. Das ist eine Entscheidung, die man
|
|
/// treffen soll, nicht eine, die man vorfindet.
|
|
var suppressSystemOSD: Bool {
|
|
didSet {
|
|
guard suppressSystemOSD != oldValue else { return }
|
|
defaults.set(suppressSystemOSD, forKey: Keys.suppressSystemOSD)
|
|
onSuppressOSDChanged?(suppressSystemOSD)
|
|
}
|
|
}
|
|
|
|
/// 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, in dieser Reihenfolge.
|
|
///
|
|
/// Eine Liste und keine einzelne Wahl: der Arm ist bei fünf Kacheln rund
|
|
/// 376 Punkte breit, und ein Wert braucht davon knapp sechzig.
|
|
var bandLeading: [BandAccessory] {
|
|
didSet {
|
|
guard bandLeading != oldValue else { return }
|
|
defaults.set(bandLeading.map(\.rawValue), forKey: Keys.bandLeading)
|
|
}
|
|
}
|
|
|
|
/// Was rechts daneben steht.
|
|
var bandTrailing: [BandAccessory] {
|
|
didSet {
|
|
guard bandTrailing != oldValue else { return }
|
|
defaults.set(bandTrailing.map(\.rawValue), forKey: Keys.bandTrailing)
|
|
}
|
|
}
|
|
|
|
/// Wie viele Elemente je Seite höchstens.
|
|
///
|
|
/// Vier passen bei fünf Kacheln bequem. Wer weniger Kacheln zeigt, hat
|
|
/// schmalere Arme — dann wird das Letzte abgeschnitten, und das ist die
|
|
/// ehrlichere Grenze als eine Zahl, die für jede Panelbreite stimmen müsste.
|
|
static let bandLimit = 4
|
|
|
|
/// 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 suppressSystemOSD = "onyx.suppressSystemOSD"
|
|
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
|
|
self.suppressSystemOSD = defaults.object(forKey: Keys.suppressSystemOSD) as? Bool ?? false
|
|
|
|
// 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.stringArray(forKey: Keys.bandLeading)
|
|
.map { $0.compactMap(BandAccessory.init(rawValue:)) }) ?? [.weather]
|
|
self.bandTrailing = (defaults.stringArray(forKey: Keys.bandTrailing)
|
|
.map { $0.compactMap(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)")
|
|
}
|
|
}
|
|
}
|