Der Schluss im letzten Commit war falsch. Aus „der Dienst liegt in der
Benutzer-Domäne" hatte ich geschlossen, dass `launchctl bootout` ihn dort
aushängt. Nachgemessen, diesmal ausführend:
Boot-out failed: 150: Operation not permitted while
System Integrity Protection is engaged
SIP schützt den Dienst, nicht nur den Dateipfad; die Domäne spielt keine Rolle.
Ein rein lesender Spike konnte das nicht zeigen — nur der Versuch. `SystemOSD`
fliegt deshalb wieder raus: Code, der behauptet zu wirken und es nicht tut, ist
schlimmer als keiner.
Stattdessen ein `CGEventTap` auf die Lautstärketasten. Onyx regelt selbst und
verbraucht das Ereignis — was das System nie erreicht, blendet es auch nicht
ein. Preis ist die Berechtigung „Bedienungshilfen"; ohne sie bleibt alles wie
vorher, und der Dialog wird angefragt statt stumm zu scheitern.
Vorteil gegenüber dem Aushängen: es verschwindet nur die Einblendung für die
Tasten, die Onyx auch bedient. Die Feststelltaste behält ihre — beim Aushängen
wäre sie mit verschwunden, und dafür hat Onyx keinen Ersatz.
Der Rückruf steht als eigene Funktion und nicht als Verschluss in `start()`: als
Verschluss brachte er Swift 6.3.3 im Durchgang `SendNonSendable` zum Absturz.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
715 lines
31 KiB
Swift
715 lines
31 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
import OnyxCore
|
|
import OnyxDesign
|
|
import OnyxNotch
|
|
import OnyxMenuBar
|
|
import OnyxWidgetKit
|
|
import CalendarProvider
|
|
import WeatherProvider
|
|
import MediaProvider
|
|
import MetricsProvider
|
|
import NetworkProvider
|
|
import AudioProvider
|
|
import ShelfProvider
|
|
import ClipboardProvider
|
|
import LockProvider
|
|
import FaceProvider
|
|
import NotesProvider
|
|
import ActivityProvider
|
|
import OSLog
|
|
|
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "App")
|
|
|
|
@main
|
|
struct OnyxApp: App {
|
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
|
|
|
var body: some Scene {
|
|
// Onyx hat kein Hauptfenster — alles lebt in der Notch und in der
|
|
// Menüleiste. Die Einstellungen sind die einzige Ausnahme; im Panel
|
|
// wäre dafür zu wenig Platz.
|
|
// Eine Szene ist Pflicht, benutzt wird sie nicht: das
|
|
// Einstellungsfenster führt SettingsWindowController selbst.
|
|
// Siehe die Begründung dort.
|
|
Settings { EmptyView() }
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
|
private(set) var model: AppModel?
|
|
private var coordinator: NotchCoordinator?
|
|
private var menuBar: MenuBarController?
|
|
private var onyxItem: NSStatusItem?
|
|
private var calendarModel: CalendarModel?
|
|
private var weatherModel: WeatherModel?
|
|
private var mediaModel: MediaModel?
|
|
private var metricsModel: MetricsModel?
|
|
|
|
/// Woran erkannt wird, dass sich etwas **geändert** hat. Ohne dieses
|
|
/// Gedächtnis meldete jede Messung dasselbe noch einmal.
|
|
/// Entscheidet, ob ein Messwert überhaupt ein Ereignis ist. Geprüft in
|
|
/// `PowerWatcherTests`.
|
|
private var powerWatcher = PowerWatcher()
|
|
/// Woran ein Titelwechsel erkannt wird — Titel und Interpret, nicht der
|
|
/// ganze Zustand: die Abspielposition wandert im Sekundentakt weiter, und
|
|
/// das ist kein Ereignis.
|
|
private var lastTrack: String?
|
|
|
|
/// Die Helligkeit des eingebauten Bildschirms. Auf Rechnern ohne lesbare
|
|
/// Helligkeit bleibt sie leer und meldet nie etwas.
|
|
private let brightness = DisplayBrightness()
|
|
/// Fängt die Lautstärketasten ab, damit Apples Einblendung nicht erscheint.
|
|
private let mediaKeys = MediaKeyTap()
|
|
|
|
/// 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?
|
|
private var networkModel: NetworkModel?
|
|
private var shelfStore: ShelfStore?
|
|
private var clipboardStore: ClipboardStore?
|
|
private var notesStore: NotesStore?
|
|
private let settingsWindow = SettingsWindowController()
|
|
private let fanControl = FanControl()
|
|
private let audioMixer = AudioMixer()
|
|
private let systemAudio = SystemAudio()
|
|
private let launchAtLogin = LaunchAtLogin()
|
|
private let onboarding = OnboardingWindowController()
|
|
private let galleryWindow = GalleryWindowController()
|
|
private let activities = ActivityPresenter()
|
|
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
NSApp.setActivationPolicy(.accessory)
|
|
|
|
// Werkstattschalter: die Erscheinung **nur dieser App** umstellen, um
|
|
// Fenster hell und dunkel nebeneinander prüfen zu können. Die
|
|
// Systemeinstellung dafür zu drehen wäre ein Eingriff in die Umgebung
|
|
// des Nutzers — für einen Blick auf ein Fenster zu viel.
|
|
//
|
|
// open Onyx.app --args -onyx.appearance light
|
|
//
|
|
// **Nur im Debug.** Der Schalter liegt im flüchtigen Argument-Bereich
|
|
// und überlebt keinen Neustart — aber eine ausgelieferte App, die sich
|
|
// über die Erscheinungswahl des Systems hinwegsetzen *kann*, ist eine
|
|
// Falle, die irgendwann jemand auslöst.
|
|
#if DEBUG
|
|
switch UserDefaults.standard.string(forKey: "onyx.appearance") {
|
|
case "light": NSApp.appearance = NSAppearance(named: .aqua)
|
|
case "dark": NSApp.appearance = NSAppearance(named: .darkAqua)
|
|
default: break
|
|
}
|
|
#endif
|
|
|
|
// Vor allem anderen: sicherstellen, dass wir allein sind. Zwei Instanzen
|
|
// bringen zwei Zustandsmaschinen und zwei Animationen auf dieselbe
|
|
// Notch — das sieht aus wie ein Ruckler und ist keiner.
|
|
if case .blocked(let pids) = SingleInstance.claim() {
|
|
SingleInstance.explainBlocked(by: pids)
|
|
NSApp.terminate(nil)
|
|
return
|
|
}
|
|
|
|
registerWidgets()
|
|
|
|
let model = AppModel()
|
|
self.model = model
|
|
// Die gespeicherte Wahl gleich anwenden — sonst steht Onyx nach einem
|
|
// Neustart wieder ohne Dock-Symbol da, obwohl es eingeschaltet ist.
|
|
NSApp.setActivationPolicy(model.showsDockIcon ? .regular : .accessory)
|
|
|
|
let coordinator = NotchCoordinator(policy: model.displayPolicy,
|
|
trigger: model.triggerArea) { displayID, presentation in
|
|
NotchHostingView(
|
|
rootView: NotchContainer(
|
|
presentation: presentation,
|
|
leading: { [weak self] in
|
|
NotchBandSlot(presentation: presentation) {
|
|
BandSlotView(model: model, edge: .leading,
|
|
weather: self?.weatherModel,
|
|
metrics: self?.metricsModel)
|
|
}
|
|
},
|
|
trailing: { [weak self] in
|
|
NotchBandSlot(presentation: presentation) {
|
|
BandSlotView(model: model, edge: .trailing,
|
|
weather: self?.weatherModel,
|
|
metrics: self?.metricsModel)
|
|
}
|
|
},
|
|
ambient: { [weak self] in
|
|
if let self { NotchAmbientSlot(presenter: activities) }
|
|
},
|
|
content: { NotchPanelContent(model: model, displayID: displayID) }),
|
|
presentation: presentation)
|
|
}
|
|
coordinator.showsIdleIsland = model.showsIdleIsland
|
|
coordinator.style = model.notchStyle
|
|
coordinator.virtualWidthOverrides = model.notchWidths
|
|
coordinator.start()
|
|
self.coordinator = coordinator
|
|
|
|
// Die Live Activities: der Presenter entscheidet, die Controller
|
|
// führen aus. Beides je Bildschirm, weil jede Notch ihr eigenes
|
|
// Fenster hat.
|
|
activities.onChange = { [weak self] activity in
|
|
guard let self, let coordinator = self.coordinator else { return }
|
|
for controller in coordinator.activeControllers {
|
|
controller.setAmbient(activity == nil ? nil : self.activities.contentSize)
|
|
}
|
|
}
|
|
observeVolume()
|
|
observePower()
|
|
observeMedia()
|
|
observeBrightness()
|
|
|
|
NotificationCenter.default.addObserver(
|
|
self, selector: #selector(screensChanged),
|
|
name: NSApplication.didChangeScreenParametersNotification, object: nil)
|
|
applyContentSize()
|
|
|
|
// Einstellungen wirken sofort. Eine Änderung, die erst nach einem
|
|
// Neustart greift, ist in einer App ohne Fenster praktisch unauffindbar.
|
|
model.onLayoutChanged = { [weak self] _ in self?.applyContentSize() }
|
|
model.onTriggerAreaChanged = { [weak self] area in
|
|
self?.coordinator?.trigger = area
|
|
}
|
|
model.onDisplayPolicyChanged = { [weak self] policy in
|
|
self?.coordinator?.policy = policy
|
|
self?.applyContentSize()
|
|
}
|
|
model.onShowsIdleIslandChanged = { [weak self] shows in
|
|
self?.coordinator?.showsIdleIsland = shows
|
|
}
|
|
model.onNotchStyleChanged = { [weak self] style in
|
|
self?.coordinator?.style = style
|
|
}
|
|
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.
|
|
applyMediaKeys(model.suppressSystemOSD)
|
|
model.onSuppressOSDChanged = { [weak self] on in self?.applyMediaKeys(on) }
|
|
|
|
lockController.usesFaceUnlock = model.faceUnlockEnabled
|
|
model.onFaceUnlockChanged = { [weak self] uses in
|
|
self?.lockController.usesFaceUnlock = uses
|
|
}
|
|
|
|
let menuBar = MenuBarController()
|
|
if let metricsModel {
|
|
for metric in MetricKind.allCases {
|
|
menuBar.register(MetricMenuBarModule(metric: metric, model: metricsModel),
|
|
settings: model.menuBarSettings(for: "metric.\(metric.rawValue)"))
|
|
}
|
|
}
|
|
if let networkModel {
|
|
menuBar.register(NetworkMenuBarModule(model: networkModel),
|
|
settings: model.menuBarSettings(for: "network"))
|
|
}
|
|
// Der Ausgangspegel muss die Programme auslassen, die der Mixer bereits
|
|
// abgreift — nur der Mixer weiß, welche das sind.
|
|
systemAudio.levels.mutedProcesses = { [weak audioMixer] in
|
|
audioMixer?.tappedProcessIDs ?? []
|
|
}
|
|
|
|
// Mixer und Lüfter gehören genauso in die Leiste wie die Messwerte:
|
|
// beides regelt man mitten in etwas anderem, und dafür ist der Weg
|
|
// über ein Fenster zu weit.
|
|
menuBar.register(MixerMenuBarModule(mixer: audioMixer, system: systemAudio),
|
|
settings: model.menuBarSettings(for: "audio"))
|
|
menuBar.register(FanMenuBarModule(control: fanControl),
|
|
settings: model.menuBarSettings(for: "fans"))
|
|
menuBar.start()
|
|
self.menuBar = menuBar
|
|
model.menuBar = menuBar
|
|
|
|
// Der Mixer läuft nur an, wenn das Häkchen gesetzt ist — er greift in
|
|
// den Tonweg ein, und das soll niemandem ungefragt passieren.
|
|
audioMixer.enableIfWanted()
|
|
|
|
installOnyxStatusItem()
|
|
observeMenuBarPreference()
|
|
requestPermissions()
|
|
|
|
if GalleryWindowController.opensAtLaunch {
|
|
galleryWindow.show(model: model)
|
|
}
|
|
|
|
// Derselbe Werkstattgriff wie beim Schaufenster: bauen, starten,
|
|
// hinsehen — ohne den Umweg übers Statusmenü.
|
|
//
|
|
// open Onyx.app --args -onyx.settings.open YES \
|
|
// -onyx.settings.section appearance
|
|
if UserDefaults.standard.bool(forKey: "onyx.settings.open") {
|
|
let name = UserDefaults.standard.string(forKey: "onyx.settings.section") ?? ""
|
|
showSettings(section: SettingsSection(rawValue: name) ?? .general)
|
|
}
|
|
}
|
|
|
|
/// Berechtigungen beim Start anfragen, nicht erst wenn ein Widget sichtbar
|
|
/// wird.
|
|
///
|
|
/// Das Panel ist beim Start zu. Eine an die View gebundene Anfrage kommt
|
|
/// deshalb erst, wenn jemand die Notch berührt — und bis dahin taucht Onyx
|
|
/// in den Systemeinstellungen gar nicht auf, es gibt also nichts, das man
|
|
/// dort freischalten könnte. Genau dieser Zustand ist beim Testen
|
|
/// aufgetreten.
|
|
private func requestPermissions() {
|
|
guard let calendarModel, let weatherModel else { return }
|
|
log.notice("Start: Einrichtung erledigt = \(self.onboarding.isDone, privacy: .public)")
|
|
// Immer einmal laden: mit Berechtigung kommen Termine, ohne sie zeigt
|
|
// das Widget den Berechtigungshinweis statt einer leeren Liste.
|
|
calendarModel.refresh()
|
|
|
|
// Die Einrichtung kommt beim ersten Start immer — auch wenn zufällig
|
|
// schon alles erlaubt ist. Sie erklärt nicht nur Berechtigungen,
|
|
// sondern auch den Helfer und den Autostart, und sie ist der einzige
|
|
// Moment, in dem jemand erfährt, was Onyx eigentlich alles kann.
|
|
guard onboarding.isDone else {
|
|
onboarding.show(calendarModel: calendarModel, weatherModel: weatherModel,
|
|
mixer: audioMixer, fanControl: fanControl,
|
|
launchAtLogin: launchAtLogin)
|
|
return
|
|
}
|
|
|
|
// Danach nur noch, wenn wirklich etwas fehlt.
|
|
let needsLocation = weatherModel.locationIsUndetermined
|
|
guard !calendarModel.hasAccess || needsLocation else { return }
|
|
|
|
// Nicht im Hintergrund anfragen.
|
|
//
|
|
// Onyx ist eine App ohne Fenster. TCC bereitet den Dialog zwar vor
|
|
// (promptType 1), entscheidet sich dann aber dagegen (promptPolicy 0),
|
|
// weil keine App im Vordergrund ist, an die er gehören könnte. Die
|
|
// Anfrage kommt kommentarlos mit `false` zurück, der Status bleibt
|
|
// `notDetermined` — und in den Systemeinstellungen taucht Onyx nie auf.
|
|
// `NSApp.activate` davor hilft nicht: Aktivierung wirkt asynchron und
|
|
// ist beim Aufruf noch nicht durch.
|
|
//
|
|
// Deshalb der ehrliche Weg: ein echtes Fenster im Vordergrund. Die
|
|
// Einrichtung lief schon einmal, hier fehlt nur noch etwas — dann
|
|
// genügt der Berechtigungsbereich der Einstellungen.
|
|
showSettings(section: .permissions)
|
|
}
|
|
|
|
/// Die Einrichtung noch einmal zeigen — aus den Einstellungen heraus.
|
|
private func showOnboarding() {
|
|
guard let calendarModel, let weatherModel else { return }
|
|
onboarding.show(calendarModel: calendarModel, weatherModel: weatherModel,
|
|
mixer: audioMixer, fanControl: fanControl,
|
|
launchAtLogin: launchAtLogin)
|
|
}
|
|
|
|
/// Ausdrücklich `false`, auch wenn es der Standard ist.
|
|
///
|
|
/// Onyx lebt in der Menüleiste und in der Notch. Dass das letzte Fenster
|
|
/// zugeht, sagt über das Beenden nichts aus — und die Voreinstellung darf
|
|
/// sich nicht unbemerkt ändern.
|
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
|
false
|
|
}
|
|
|
|
func applicationWillTerminate(_ notification: Notification) {
|
|
mediaKeys.stop()
|
|
coordinator?.stop()
|
|
menuBar?.stop()
|
|
// Der Herzschlag endet damit. Der Helfer stellt die Lüfter binnen fünf
|
|
// Sekunden auf Automatik zurück — auch wenn Onyx abstürzt statt sauber
|
|
// zu beenden.
|
|
fanControl.stop()
|
|
// Ohne das bleiben die abgegriffenen Programme stumm zurück: der Tap
|
|
// schaltet das Original stumm, und ohne laufenden Mixer gibt niemand
|
|
// mehr etwas aus.
|
|
audioMixer.disable()
|
|
}
|
|
|
|
private func registerWidgets() {
|
|
let calendarModel = CalendarModel()
|
|
self.calendarModel = calendarModel
|
|
WidgetRegistry.shared.register(CalendarWidget(model: calendarModel))
|
|
|
|
let weatherModel = WeatherModel()
|
|
self.weatherModel = weatherModel
|
|
WidgetRegistry.shared.register(WeatherWidget(model: weatherModel))
|
|
|
|
let mediaModel = MediaModel()
|
|
self.mediaModel = mediaModel
|
|
WidgetRegistry.shared.register(MediaWidget(model: mediaModel))
|
|
|
|
let metricsModel = MetricsModel()
|
|
// Der Akku bekommt seinen Ladelimit-Regler über den Helfer, den sonst
|
|
// die Lüfter benutzen — es ist derselbe SMC-Zugang.
|
|
metricsModel.chargeControl = fanControl
|
|
self.metricsModel = metricsModel
|
|
for metric in MetricKind.allCases {
|
|
WidgetRegistry.shared.register(MetricWidget(metric: metric, model: metricsModel))
|
|
}
|
|
|
|
let networkModel = NetworkModel()
|
|
self.networkModel = networkModel
|
|
WidgetRegistry.shared.register(NetworkWidget(model: networkModel))
|
|
|
|
WidgetRegistry.shared.register(AudioMixerWidget(mixer: audioMixer, system: systemAudio))
|
|
|
|
WidgetRegistry.shared.register(FanWidget(control: fanControl))
|
|
|
|
let shelfStore = ShelfStore()
|
|
self.shelfStore = shelfStore
|
|
// AirDrop steckt in der Ablage-Kachel — zwei eigene Kacheln für je
|
|
// eine leere Ablegefläche waren ein Drittel des Panels.
|
|
WidgetRegistry.shared.register(ShelfWidget(store: shelfStore))
|
|
|
|
let clipboardStore = ClipboardStore()
|
|
self.clipboardStore = clipboardStore
|
|
WidgetRegistry.shared.register(ClipboardWidget(store: clipboardStore))
|
|
|
|
let notesStore = NotesStore()
|
|
self.notesStore = notesStore
|
|
WidgetRegistry.shared.register(NotesWidget(store: notesStore))
|
|
}
|
|
|
|
/// Je Bildschirm eine eigene Größe.
|
|
///
|
|
/// Ein Fernseher mit 1280 Punkten Breite darf das Panel auf dem Notebook
|
|
/// nicht zusammendrücken.
|
|
private func applyContentSize() {
|
|
guard let model, let coordinator else { return }
|
|
for controller in coordinator.activeControllers {
|
|
controller.contentSize = model.panelContentSize(forDisplay: controller.displayID)
|
|
}
|
|
}
|
|
|
|
/// Bildschirm angesteckt, abgezogen oder umgestellt.
|
|
///
|
|
/// Ohne das blieb die einmal gerechnete Größe stehen — auch nachdem das
|
|
/// Gerät wieder weg war. Verzögert um einen Durchlauf, damit der Koordinator
|
|
/// seine Fenster vorher neu aufgebaut hat: sonst bekämen die alten die neue
|
|
/// Größe und die neuen gar keine.
|
|
@objc private func screensChanged() {
|
|
model?.noteScreensChanged()
|
|
Task { @MainActor [weak self] in self?.applyContentSize() }
|
|
}
|
|
|
|
// MARK: - Menüleiste
|
|
|
|
/// Das feste Onyx-Element.
|
|
///
|
|
/// **Kein „Panel öffnen" im Menü**, aber ganz normal ein Menü.
|
|
///
|
|
/// Der Eintrag beschrieb einen Umweg um die eigentliche Bedienung herum —
|
|
/// das Panel öffnet sich, wenn man die Notch berührt. Ihn durch „Linksklick
|
|
/// fährt das Panel aus" zu ersetzen war aber schlechter als das Problem:
|
|
/// dieser Klick ging als Fixieren durch, und danach stand das Panel offen
|
|
/// und reagierte auf nichts. Ein Statuselement zeigt bei einem Klick sein
|
|
/// Menü. Alles andere überrascht.
|
|
private func installOnyxStatusItem() {
|
|
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
|
item.autosaveName = "onyx.menubar.main"
|
|
item.button?.image = Self.menuBarIcon()
|
|
|
|
let menu = NSMenu()
|
|
menu.addItem(withTitle: String(localized: "menu.settings"),
|
|
action: #selector(openSettings), keyEquivalent: ",").target = self
|
|
menu.addItem(withTitle: String(localized: "settings.onboarding.show"),
|
|
action: #selector(openOnboarding), keyEquivalent: "").target = self
|
|
// Werkzeug, kein Programmteil: unlokalisiert, und im Release nur, wenn
|
|
// jemand den Schlüssel ausdrücklich setzt. Siehe
|
|
// `GalleryWindowController.isAvailable`.
|
|
if GalleryWindowController.isAvailable {
|
|
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
|
|
item.menu = menu
|
|
|
|
onyxItem = item
|
|
}
|
|
|
|
@objc private func openOnboarding() { showOnboarding() }
|
|
|
|
@objc private func lockNow() { lockController.lock() }
|
|
|
|
/// Schaltet die Übernahme der Lautstärketasten an oder aus.
|
|
///
|
|
/// **Apples Einblendung lässt sich nicht abschalten.** `launchctl bootout`
|
|
/// scheitert an SIP, egal in welcher Domäne — nachgemessen in
|
|
/// `docs/spikes/J-osd.md`. Was geht, ist ihr zuvorzukommen: das System
|
|
/// blendet nichts ein, wenn der Tastendruck es nie erreicht.
|
|
private func applyMediaKeys(_ on: Bool) {
|
|
guard on else { mediaKeys.stop(); return }
|
|
guard MediaKeyTap.isTrusted else {
|
|
// Ohne Bedienungshilfen tut der Abgriff nichts. Den Dialog anfragen
|
|
// statt stumm zu scheitern.
|
|
MediaKeyTap.requestTrust()
|
|
return
|
|
}
|
|
mediaKeys.onKey = { [weak self] key in
|
|
MainActor.assumeIsolated { self?.handleMediaKey(key) ?? false }
|
|
}
|
|
mediaKeys.start()
|
|
}
|
|
|
|
/// Wie macOS selbst: ein Sechzehntel je Anschlag.
|
|
private static let volumeStep = 1.0 / 16.0
|
|
|
|
private func handleMediaKey(_ key: MediaKeyTap.Key) -> Bool {
|
|
let current = systemAudio.outputVolume
|
|
switch key {
|
|
case .volumeUp:
|
|
systemAudio.setOutputVolume(min(1, current + Self.volumeStep))
|
|
case .volumeDown:
|
|
systemAudio.setOutputVolume(max(0, current - Self.volumeStep))
|
|
case .mute:
|
|
systemAudio.setOutputMuted(!systemAudio.outputMuted)
|
|
}
|
|
// Die Activity kommt von selbst: `observeVolume()` hört auf genau die
|
|
// Änderung, die wir hier gerade ausgelöst haben.
|
|
return true
|
|
}
|
|
|
|
/// Meldet jede Änderung der Systemlautstärke als Live Activity an.
|
|
///
|
|
/// Über `withObservationTracking` und nicht über einen eigenen Listener:
|
|
/// `SystemAudio` hängt bereits mit CoreAudio-Listenern an Lautstärke und
|
|
/// Stummschaltung. Ein zweiter Weg dorthin wäre eine zweite Wahrheit.
|
|
///
|
|
/// Die Meldung kommt **vor** dem Schreiben; der neue Wert steht erst
|
|
/// danach. Deshalb wird er in einer Aufgabe gelesen, nicht im Rückruf.
|
|
private func observeVolume() {
|
|
withObservationTracking {
|
|
_ = systemAudio.outputVolume
|
|
_ = systemAudio.outputMuted
|
|
} onChange: {
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
activities.raise(VolumeActivity(level: systemAudio.outputVolume,
|
|
isMuted: systemAudio.outputMuted))
|
|
observeVolume()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Meldet jeden **Wechsel** am Strom als Live Activity an.
|
|
///
|
|
/// Nicht jeden Messwert: der Ladestand ändert sich ständig, und eine Notch,
|
|
/// die das begleitet, wäre eine Leuchtreklame. Angemeldet wird nur, wenn
|
|
/// sich der Zustand ändert — und der Zustand ist das, was ``ChargeActivity``
|
|
/// aus Laden, Netz, Ladestand und Limit ableitet.
|
|
///
|
|
/// Der erste gelesene Zustand meldet **nichts**. Sonst begrüßte Onyx jeden
|
|
/// Start mit „am Netz" — einer Nachricht über nichts, das gerade geschehen
|
|
/// ist.
|
|
private func observePower() {
|
|
guard let metricsModel else { return }
|
|
withObservationTracking {
|
|
_ = metricsModel.snapshot.battery
|
|
} onChange: {
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
defer { observePower() }
|
|
guard let battery = self.metricsModel?.snapshot.battery else { return }
|
|
guard let event = self.powerWatcher.observe(
|
|
isCharging: battery.isCharging,
|
|
isPluggedIn: battery.isPluggedIn,
|
|
charge: battery.charge,
|
|
limit: self.metricsModel?.chargeControl?.chargeLimit)
|
|
else { return }
|
|
|
|
let kind: PowerActivity.Kind = switch event {
|
|
case .charging: .charging
|
|
case .plugged: .plugged
|
|
case .unplugged: .unplugged
|
|
case .atLimit(let limit): .atLimit(limit)
|
|
case .low: .low
|
|
}
|
|
self.activities.raise(PowerActivity(kind: kind,
|
|
charge: battery.charge,
|
|
isCharging: battery.isCharging,
|
|
isPluggedIn: battery.isPluggedIn))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Meldet jede Helligkeitsänderung als Live Activity an.
|
|
///
|
|
/// Nur, wenn dieser Rechner überhaupt eine lesbare Helligkeit hat — auf
|
|
/// einem Mini am Monitor gibt es keine, und dann soll auch nichts
|
|
/// erscheinen. Siehe ``DisplayBrightness``.
|
|
private func observeBrightness() {
|
|
guard brightness.isAvailable else { return }
|
|
withObservationTracking {
|
|
_ = brightness.level
|
|
} onChange: {
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
defer { observeBrightness() }
|
|
guard let level = self.brightness.level else { return }
|
|
|
|
guard let previous = self.lastBrightness else {
|
|
self.lastBrightness = level
|
|
return
|
|
}
|
|
self.lastBrightness = level
|
|
// Ein Hauch Unterschied ist kein Ereignis: die Meldung kommt
|
|
// auch bei automatischer Anpassung an das Umgebungslicht, und
|
|
// die kriecht in winzigen Schritten.
|
|
guard abs(level - previous) >= Self.brightnessStep else { return }
|
|
self.activities.raise(BrightnessActivity(level: level))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ab welchem Sprung eine Helligkeitsänderung als Handgriff gilt.
|
|
///
|
|
/// Ein Tastendruck ändert sie um rund ein Sechzehntel; das Umgebungslicht
|
|
/// kriecht in viel kleineren Schritten.
|
|
private static let brightnessStep = 0.02
|
|
|
|
/// Meldet jeden **Titelwechsel** als Live Activity an.
|
|
///
|
|
/// Nicht jede Änderung an `nowPlaying`: dort wandert die Abspielposition
|
|
/// im Sekundentakt weiter, und das ist kein Ereignis. Verglichen wird
|
|
/// deshalb Titel und Interpret, nicht der ganze Zustand.
|
|
///
|
|
/// Auch hier meldet der erste gelesene Titel nichts — beim Start läuft
|
|
/// vielleicht seit einer Stunde dasselbe Stück.
|
|
private func observeMedia() {
|
|
guard let mediaModel else { return }
|
|
withObservationTracking {
|
|
_ = mediaModel.nowPlaying
|
|
} onChange: {
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
defer { observeMedia() }
|
|
guard let playing = self.mediaModel?.nowPlaying, !playing.title.isEmpty else {
|
|
self.lastTrack = nil
|
|
return
|
|
}
|
|
|
|
let track = "\(playing.title)\u{1}\(playing.artist)"
|
|
guard let previous = self.lastTrack else {
|
|
self.lastTrack = track
|
|
return
|
|
}
|
|
guard track != previous else { return }
|
|
self.lastTrack = track
|
|
// Ein pausierter Titel wechselt nicht von selbst — was hier
|
|
// ankommt, während nichts läuft, ist ein Nachtrag der Quelle
|
|
// und kein Ereignis.
|
|
guard playing.isPlaying else { return }
|
|
self.activities.raise(MediaActivity(title: playing.title,
|
|
artist: playing.artist,
|
|
artwork: playing.artwork))
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc private func openGallery() {
|
|
guard let model else { return }
|
|
galleryWindow.show(model: model)
|
|
}
|
|
|
|
/// Das Glyph aus dem App-Icon, auf Menüleistengröße gebracht.
|
|
///
|
|
/// Nicht das vollständige App-Icon: das bringt die abgerundete Kachel samt
|
|
/// Farbverlauf mit, und die gehört in Dock und Finder, nicht in die
|
|
/// Menüleiste. Dort steht sie als farbiger Klotz zwischen lauter
|
|
/// Strichsymbolen.
|
|
///
|
|
/// **Eine eigene Konturfassung, nicht das App-Icon.**
|
|
///
|
|
/// Das Glyph des App-Icons besteht aus deckenden Flächen in sehr dunklen
|
|
/// Tönen. Auf dem blauen Verlauf des Icons liest es sich als Kristall,
|
|
/// allein auf einer dunklen Menüleiste ist es schwarz auf schwarz. Und als
|
|
/// Template-Bild — der einzige Weg, macOS zum Einfärben zu bewegen —
|
|
/// verschmelzen die Facetten zu einer flachen Silhouette, weil dabei nur
|
|
/// der Alphakanal zählt.
|
|
///
|
|
/// `MenuBarIcon.svg` ist deshalb als Kontur gezeichnet: zwischen den Linien
|
|
/// ist nichts, also bleibt beim Einfärben die Struktur erhalten. Das Symbol
|
|
/// wird damit automatisch weiß auf dunkler und schwarz auf heller
|
|
/// Menüleiste, ohne zweite Datei für den zweiten Fall.
|
|
///
|
|
/// 18 pt ist die übliche Höhe; die Leiste ist 22 pt hoch und braucht oben
|
|
/// und unten Luft.
|
|
private static func menuBarIcon() -> NSImage {
|
|
let size = NSSize(width: 18, height: 18)
|
|
|
|
if let url = Bundle.main.url(forResource: "MenuBarIcon", withExtension: "svg"),
|
|
let glyph = NSImage(contentsOf: url) {
|
|
let image = NSImage(size: size, flipped: false) { rect in
|
|
glyph.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
|
|
return true
|
|
}
|
|
image.isTemplate = true
|
|
image.accessibilityDescription = "Onyx"
|
|
return image
|
|
}
|
|
|
|
// Ein Menüleisteneintrag ohne Bild wäre unsichtbar und die App darüber
|
|
// unerreichbar — deshalb ein Rückfall statt eines leeren Knopfs.
|
|
let fallback = NSImage(systemSymbolName: "circle.hexagongrid.fill",
|
|
accessibilityDescription: "Onyx") ?? NSImage()
|
|
fallback.isTemplate = true
|
|
return fallback
|
|
}
|
|
|
|
private func observeMenuBarPreference() {
|
|
guard let model else { return }
|
|
withObservationTracking {
|
|
_ = model.showsMenuBarIcon
|
|
} onChange: {
|
|
Task { @MainActor [weak self] in
|
|
self?.onyxItem?.isVisible = model.showsMenuBarIcon
|
|
self?.observeMenuBarPreference()
|
|
}
|
|
}
|
|
onyxItem?.isVisible = model.showsMenuBarIcon
|
|
}
|
|
|
|
@objc private func togglePanel() { coordinator?.togglePanelUnderPointer() }
|
|
|
|
@objc private func openSettings() { showSettings(section: .general) }
|
|
|
|
private func showSettings(section: SettingsSection) {
|
|
guard let model, let calendarModel, let weatherModel else { return }
|
|
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)
|
|
}
|
|
|
|
@objc private func quit() { NSApp.terminate(nil) }
|
|
|
|
}
|
|
|
|
/// Der Inhalt des Panels. Liest `model.layout` und zeichnet sich damit neu,
|
|
/// sobald in den Einstellungen etwas geändert wird.
|
|
private struct NotchPanelContent: View {
|
|
let model: AppModel
|
|
let displayID: CGDirectDisplayID
|
|
|
|
var body: some View {
|
|
WidgetGrid(placements: model.layout,
|
|
availableWidth: model.availableWidth(forDisplay: displayID))
|
|
// Im Rumpf gelesen, damit ein Farbwechsel ankommt.
|
|
.tint(Onyx.Color.accent)
|
|
.labelStyle(.onyx)
|
|
}
|
|
}
|