Gemessen über kAudioProcessPropertyIsRunningInput: mit laufendem Mixer nimmt Onyx auf, ohne ihn nicht. Ein Process Tap ist für macOS eine Tonaufnahme — auch wenn damit nur die Lautstärke geregelt wird. Solange der Mixer Chrome und Firefox abgreift, steht das orange Symbol in der Leiste. Der Mixer stand seit der Fehlersuche am Wochenende auf „automatisch starten", deshalb war es dauerhaft. Das Häkchen steht jetzt wieder aus. Ein Hinweis unter dem Häkchen sagt das jetzt, wo die Entscheidung fällt: wer das Symbol ohne Erklärung sieht, sucht den Fehler bei sich oder hält die App für unsauber. Dazu ein echter Fund an derselben Stelle: der Mikrofonpegel hing an `onDisappear` der View — und das bleibt bei einem NSPopover aus. Dessen Controller lebt nach dem Schließen weiter, die View verschwindet nie aus der Hierarchie. Die Messung wäre also beim ersten Öffnen angegangen und nie wieder aus. Sie hängt jetzt am Popover selbst: `MenuBarModule` bekommt `popoverDidOpen`/`popoverDidClose`, gemeldet auch dann, wenn das Popover von selbst zugeht — beim Wechsel in eine andere App etwa. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
253 lines
10 KiB
Swift
253 lines
10 KiB
Swift
import AppKit
|
|
import OnyxDesign
|
|
import SwiftUI
|
|
|
|
/// Verwaltet beliebig viele Menüleisten-Elemente, je eines pro aktiviertem Modul.
|
|
///
|
|
/// Die Reihenfolge merkt sich macOS selbst über `autosaveName`; Onyx speichert sie
|
|
/// nicht doppelt. Was Onyx speichert, ist welche Module an sind und wie sie
|
|
/// aussehen sollen.
|
|
@MainActor
|
|
public final class MenuBarController {
|
|
|
|
/// Hält den Delegaten am Leben — `NSPopover` merkt ihn sich nur schwach.
|
|
private var popoverWatcher: PopoverWatcher?
|
|
|
|
/// Ein Modul, dessen Element keine Breite bekommen hat.
|
|
///
|
|
/// Die Menüleiste ist endlich, und auf einem Notch-Display ist die Mitte
|
|
/// zusätzlich blockiert. Ein Element, das dort nicht mehr hinpasst, wird von
|
|
/// macOS kommentarlos weggelassen — für den Nutzer sieht das aus, als wäre
|
|
/// die Einstellung wirkungslos. Deshalb wird der Fall erkannt und gemeldet.
|
|
public private(set) var hiddenModuleIDs: Set<String> = []
|
|
|
|
/// Wird gerufen, wenn sich `hiddenModuleIDs` ändert — die Einstellungen
|
|
/// zeigen daraufhin einen Hinweis am betroffenen Modul.
|
|
public var onVisibilityChanged: ((Set<String>) -> Void)?
|
|
|
|
private struct Entry {
|
|
let module: any MenuBarModule
|
|
let item: NSStatusItem
|
|
let popover: NSPopover
|
|
var settings: MenuBarModuleSettings
|
|
}
|
|
|
|
private var entries: [String: Entry] = [:]
|
|
private var visibilityTimer: Timer?
|
|
|
|
public init() {}
|
|
|
|
public func start() {
|
|
// Ob ein Element sichtbar ist, meldet AppKit nicht. Es bleibt nur,
|
|
// gelegentlich nachzusehen.
|
|
visibilityTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
|
|
MainActor.assumeIsolated { [weak self] in self?.checkVisibility() }
|
|
}
|
|
}
|
|
|
|
public func stop() {
|
|
closeOpenPopover()
|
|
visibilityTimer?.invalidate()
|
|
visibilityTimer = nil
|
|
for id in entries.keys { setEnabled(false, forModuleID: id) }
|
|
}
|
|
|
|
// MARK: - Module
|
|
|
|
public func register(_ module: any MenuBarModule, settings: MenuBarModuleSettings) {
|
|
guard entries[module.id] == nil else { return }
|
|
|
|
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
item.autosaveName = "onyx.menubar.\(module.id)"
|
|
item.isVisible = false
|
|
|
|
let popover = NSPopover()
|
|
popover.behavior = .transient
|
|
popover.animates = true
|
|
|
|
entries[module.id] = Entry(module: module, item: item, popover: popover, settings: settings)
|
|
apply(settings, toModuleID: module.id)
|
|
}
|
|
|
|
public func setEnabled(_ enabled: Bool, forModuleID id: String) {
|
|
guard var entry = entries[id] else { return }
|
|
entry.settings.isEnabled = enabled
|
|
entries[id] = entry
|
|
apply(entry.settings, toModuleID: id)
|
|
}
|
|
|
|
public func setPresentation(_ presentation: MenuBarPresentation, forModuleID id: String) {
|
|
guard var entry = entries[id] else { return }
|
|
entry.settings.presentation = presentation
|
|
entries[id] = entry
|
|
apply(entry.settings, toModuleID: id)
|
|
}
|
|
|
|
/// Welches Popover gerade offen ist. Höchstens eines.
|
|
private var openPopoverID: String?
|
|
private var globalMonitor: Any?
|
|
private var localMonitor: Any?
|
|
private var keyMonitor: Any?
|
|
|
|
public func settings(forModuleID id: String) -> MenuBarModuleSettings? {
|
|
entries[id]?.settings
|
|
}
|
|
|
|
private func apply(_ settings: MenuBarModuleSettings, toModuleID id: String) {
|
|
guard let entry = entries[id] else { return }
|
|
|
|
entry.item.isVisible = settings.isEnabled
|
|
|
|
if settings.isEnabled {
|
|
let view = entry.module.makeStatusView(presentation: settings.presentation)
|
|
if let button = entry.item.button {
|
|
button.subviews.forEach { $0.removeFromSuperview() }
|
|
view.frame = button.bounds
|
|
view.autoresizingMask = [.width, .height]
|
|
button.addSubview(view)
|
|
button.target = self
|
|
button.action = #selector(statusItemClicked(_:))
|
|
// Beide Tasten annehmen: rechts öffnet später das Kontextmenü.
|
|
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
|
button.identifier = NSUserInterfaceItemIdentifier(id)
|
|
}
|
|
applyWidth(view.intrinsicContentSize.width, to: entry)
|
|
// Die nötige Breite steht nicht von Anfang an fest: die Kernzahl
|
|
// kennt das Modul erst nach der ersten Messung, und ein Moduswechsel
|
|
// ändert „100 %" in „100 °C".
|
|
(view as? WidthReporting)?.onWidthChange = { [weak self] width in
|
|
guard let self, let entry = entries[id] else { return }
|
|
applyWidth(width, to: entry)
|
|
}
|
|
entry.module.activate()
|
|
} else {
|
|
entry.module.deactivate()
|
|
}
|
|
checkVisibility()
|
|
}
|
|
|
|
private func applyWidth(_ width: CGFloat, to entry: Entry) {
|
|
entry.item.length = width > 0 ? width : NSStatusItem.variableLength
|
|
}
|
|
|
|
// MARK: - Interaktion
|
|
|
|
@objc private func statusItemClicked(_ sender: NSStatusBarButton) {
|
|
guard let id = sender.identifier?.rawValue, let entry = entries[id] else { return }
|
|
|
|
// Immer nur eines offen. Zwei Popover nebeneinander sehen aus wie ein
|
|
// Fehler, und das zweite verdeckt oft das erste.
|
|
if openPopoverID == id {
|
|
closeOpenPopover()
|
|
return
|
|
}
|
|
closeOpenPopover()
|
|
|
|
// Die Akzentfarbe hier und nicht in jedem Popover einzeln: `.tint`
|
|
// vererbt sich nach unten, und ohne sie fielen Haken, Regler und
|
|
// Auswahlfelder auf die Systemfarbe zurück — das war der Grund, warum
|
|
// die Popovers grün waren und die Widgets blau.
|
|
entry.popover.contentViewController = NSHostingController(
|
|
rootView: entry.module.makePopoverView()
|
|
.tint(Onyx.Color.accent)
|
|
.labelStyle(.onyx))
|
|
let watcher = PopoverWatcher { [weak self] in self?.popoverClosedByItself() }
|
|
popoverWatcher = watcher
|
|
entry.popover.delegate = watcher
|
|
entry.popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY)
|
|
entry.module.popoverDidOpen()
|
|
// Ohne das bleibt das Popover hinter dem aktiven Fenster.
|
|
entry.popover.contentViewController?.view.window?.makeKey()
|
|
openPopoverID = id
|
|
startWatchingForOutsideClicks()
|
|
}
|
|
|
|
// MARK: - Schließen bei Klick daneben
|
|
|
|
/// `NSPopover.behavior = .transient` genügt hier **nicht**.
|
|
///
|
|
/// Es schließt zuverlässig, solange die eigene App aktiv ist. Onyx läuft
|
|
/// aber als `.accessory` und wird durch einen Klick auf ein Statuselement
|
|
/// nicht aktiviert — ein Klick in ein fremdes Fenster erreicht das Popover
|
|
/// dann gar nicht, und es bleibt offen stehen. Ein globaler Beobachter sieht
|
|
/// solche Klicks trotzdem.
|
|
private func startWatchingForOutsideClicks() {
|
|
guard globalMonitor == nil else { return }
|
|
globalMonitor = NSEvent.addGlobalMonitorForEvents(
|
|
matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] _ in
|
|
Task { @MainActor in self?.closeOpenPopover() }
|
|
}
|
|
// Der globale Beobachter sieht keine Klicks in die eigene App — dafür
|
|
// der lokale. Er reicht das Ereignis weiter, damit Knöpfe im Popover
|
|
// weiterhin funktionieren.
|
|
localMonitor = NSEvent.addLocalMonitorForEvents(
|
|
matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
|
|
guard let self, let id = openPopoverID, let entry = entries[id] else { return event }
|
|
// Klicks **im** Popover schließen es nicht.
|
|
if event.window !== entry.popover.contentViewController?.view.window,
|
|
event.window !== entry.item.button?.window {
|
|
closeOpenPopover()
|
|
}
|
|
return event
|
|
}
|
|
// Auch die Escape-Taste und ein Bildschirmwechsel beenden es.
|
|
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
|
if event.keyCode == 53 { self?.closeOpenPopover(); return nil }
|
|
return event
|
|
}
|
|
}
|
|
|
|
/// Zugegangen, ohne dass wir es veranlasst haben.
|
|
private func popoverClosedByItself() {
|
|
guard let id = openPopoverID, let entry = entries[id] else { return }
|
|
entry.module.popoverDidClose()
|
|
openPopoverID = nil
|
|
}
|
|
|
|
private func closeOpenPopover() {
|
|
if let id = openPopoverID, let entry = entries[id] {
|
|
entry.popover.performClose(nil)
|
|
}
|
|
// Über denselben Weg wie das Zugehen von selbst: der räumt
|
|
// `openPopoverID` ab und ist damit genau einmal wirksam, egal ob der
|
|
// Delegat vorher, nachher oder gar nicht meldet.
|
|
popoverClosedByItself()
|
|
globalMonitor.map(NSEvent.removeMonitor)
|
|
localMonitor.map(NSEvent.removeMonitor)
|
|
keyMonitor.map(NSEvent.removeMonitor)
|
|
globalMonitor = nil
|
|
localMonitor = nil
|
|
keyMonitor = nil
|
|
}
|
|
|
|
private func checkVisibility() {
|
|
var hidden = Set<String>()
|
|
for (id, entry) in entries where entry.settings.isEnabled {
|
|
// `isVisible` bleibt true, auch wenn das Element mangels Platz nicht
|
|
// gezeichnet wird. Der verlässliche Hinweis ist ein Knopf ohne Fenster.
|
|
if entry.item.button?.window == nil { hidden.insert(id) }
|
|
}
|
|
guard hidden != hiddenModuleIDs else { return }
|
|
hiddenModuleIDs = hidden
|
|
onVisibilityChanged?(hidden)
|
|
}
|
|
}
|
|
|
|
/// Horcht, ob das Popover zugegangen ist.
|
|
///
|
|
/// Ein eigener Typ, weil ein Delegat von AppKit ein `NSObject` sein muss und
|
|
/// der Controller keines ist. Nötig ist er trotzdem: das Popover geht auch
|
|
/// ohne Zutun zu — beim Wechsel in eine andere App etwa —, und ohne diese
|
|
/// Meldung erfährt eine laufende Messung nie, dass niemand mehr hinsieht.
|
|
final class PopoverWatcher: NSObject, NSPopoverDelegate {
|
|
private let onClose: @MainActor () -> Void
|
|
|
|
init(onClose: @escaping @MainActor () -> Void) {
|
|
self.onClose = onClose
|
|
}
|
|
|
|
func popoverDidClose(_ notification: Notification) {
|
|
MainActor.assumeIsolated { onClose() }
|
|
}
|
|
}
|