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