import SwiftUI import AppKit import OnyxCore import OnyxDesign import OnyxNotch import OnyxMenuBar import OnyxWidgetKit import CalendarProvider import WeatherProvider import MediaProvider import MetricsProvider import NetworkProvider @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? private var networkModel: NetworkModel? private let settingsWindow = SettingsWindowController() private let fanControl = FanControl() func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) // 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) { _, presentation in NotchHostingView( rootView: NotchContainer(presentation: presentation) { NotchPanelContent(model: model) }, presentation: presentation) } coordinator.start() self.coordinator = coordinator 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.onDisplayPolicyChanged = { [weak self] policy in self?.coordinator?.policy = policy self?.applyContentSize() } 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")) } menuBar.start() self.menuBar = menuBar model.menuBar = menuBar installOnyxStatusItem() observeMenuBarPreference() requestPermissions() } /// 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 else { return } // Immer einmal laden: mit Berechtigung kommen Termine, ohne sie zeigt // das Widget den Berechtigungshinweis statt einer leeren Liste. calendarModel.refresh() // Fenster öffnen, solange irgendeine Berechtigung noch aussteht. let needsLocation = weatherModel?.locationIsUndetermined ?? false 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: beim ersten Start das Einstellungsfenster // öffnen. Dort ist ein echtes Fenster im Vordergrund, der Dialog // erscheint zuverlässig — und der Nutzer weiß, warum gefragt wird. showSettings(tab: .permissions) } func applicationWillTerminate(_ notification: Notification) { 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() } /// Echte Widgets zuerst, danach die Platzhalter für alles, was noch fehlt. private func registerWidgets() { let calendarModel = CalendarModel(source: EventKitSource()) 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() 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)) PlaceholderWidgets.registerAll() } private func applyContentSize() { guard let model, let coordinator else { return } let size = model.panelContentSize coordinator.activeControllers.forEach { $0.contentSize = size } } // MARK: - Menüleiste /// Das feste Onyx-Element: Panel öffnen, Einstellungen, Beenden. Es ist auch /// das Sicherheitsnetz, falls die Notch einmal nicht reagiert. 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.openPanel"), action: #selector(togglePanel), keyEquivalent: "").target = self menu.addItem(.separator()) menu.addItem(withTitle: String(localized: "menu.settings"), action: #selector(openSettings), keyEquivalent: ",").target = self menu.addItem(.separator()) menu.addItem(withTitle: String(localized: "menu.quit"), action: #selector(quit), keyEquivalent: "q").target = self item.menu = menu onyxItem = item } /// 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(tab: .widgets) } private func showSettings(tab: SettingsTab) { guard let model, let calendarModel, let weatherModel else { return } settingsWindow.show(model: model, calendarModel: calendarModel, weatherModel: weatherModel, metricsModel: metricsModel, fanControl: fanControl, tab: tab) } @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 var body: some View { WidgetGrid(placements: model.layout) } }