From ee848b86cca97abe5473215bd7ef68332f8823b2 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 14 Aug 2026 15:20:07 +0200 Subject: [PATCH] Das Aufnahmesymbol kam vom Mixer, nicht vom Pegel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Onyx/MixerMenuBarModule.swift | 9 +++++ .../AudioProvider/Localizable.xcstrings | 16 ++++++++ .../Sources/AudioProvider/MixerView.swift | 20 +++++++--- .../OnyxMenuBar/MenuBarController.swift | 37 ++++++++++++++++++- .../Sources/OnyxMenuBar/MenuBarModule.swift | 17 +++++++++ 5 files changed, 93 insertions(+), 6 deletions(-) diff --git a/Onyx/MixerMenuBarModule.swift b/Onyx/MixerMenuBarModule.swift index 4a8214f..bb206b7 100644 --- a/Onyx/MixerMenuBarModule.swift +++ b/Onyx/MixerMenuBarModule.swift @@ -33,6 +33,15 @@ final class MixerMenuBarModule: MenuBarModule { return view } + /// Pegel nur, solange das Popover offen ist. + /// + /// Der Mikrofonpegel schaltet den orangen Punkt an — das darf keine + /// Sekunde länger dauern als das Hinsehen. An der View ließ es sich nicht + /// festmachen: `onDisappear` bleibt beim Popover aus, und der Punkt blieb + /// dauerhaft stehen. + func popoverDidOpen() { system.levels.addConsumer() } + func popoverDidClose() { system.levels.removeConsumer() } + func makePopoverView() -> AnyView { AnyView( ScrollView { diff --git a/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings index f0589d6..95266e3 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings @@ -196,6 +196,22 @@ } } }, + "mixer.recordingHint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Solange der Mixer läuft, zeigt macOS das orange Aufnahmesymbol: Ton abgreifen heißt für das System aufnehmen, auch wenn nur die Lautstärke geregelt wird." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "While the mixer runs, macOS shows the orange recording dot: tapping audio counts as recording to the system, even when only the volume is adjusted." + } + } + } + }, "mixer.release": { "localizations": { "de": { diff --git a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift index 4eabde4..ee0ee48 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift @@ -86,11 +86,10 @@ public struct MixerView: View { } .frame(maxWidth: .infinity, alignment: .leading) .animation(Onyx.Motion.value, value: mixer.groups.count) - // Die Pegel laufen nur, solange die Ansicht sichtbar ist. Der - // Mikrofonpegel schaltet dabei den orangen Punkt an — ein Messgerät, - // das im Hintergrund weiterläuft, wäre hier das Falsche. - .onAppear { system.levels.addConsumer() } - .onDisappear { system.levels.removeConsumer() } + // Kein `onAppear` für die Pegel: die Messung hängt am Popover, nicht + // an dieser View. `onDisappear` bleibt bei einem NSPopover aus, und + // ein Mikrofon, das nur beim Öffnen an- und nie wieder ausgeht, ist + // schlimmer als eines, das gar nicht misst. } } @@ -107,6 +106,17 @@ extension MixerView { } .toggleStyle(.checkbox) .controlSize(.small) + + // Der Hinweis gehört hierher, weil hier die Entscheidung fällt. + // + // Ein Tap ist für macOS eine Tonaufnahme — auch wenn nur die + // Lautstärke geregelt wird. Solange der Mixer läuft, steht deshalb das + // orange Aufnahmesymbol in der Leiste. Wer das ohne Erklärung sieht, + // sucht den Fehler bei sich oder hält die App für unsauber. + Text("mixer.recordingHint", bundle: .module) + .font(Onyx.Font.metricSmall) + .foregroundStyle(Onyx.Color.textTertiary) + .fixedSize(horizontal: false, vertical: true) } } diff --git a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift index 5a267d5..c8e926d 100644 --- a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift +++ b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift @@ -10,6 +10,9 @@ import SwiftUI @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 @@ -148,7 +151,11 @@ public final class MenuBarController { 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 @@ -190,11 +197,21 @@ public final class MenuBarController { } } + /// 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) } - openPopoverID = 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) @@ -215,3 +232,21 @@ public final class MenuBarController { 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() } + } +} diff --git a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift index 1835a96..ec18453 100644 --- a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift +++ b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift @@ -42,6 +42,17 @@ public protocol MenuBarModule: AnyObject { /// Der Inhalt des Popovers beim Klick. func makePopoverView() -> AnyView + /// Wird gerufen, wenn das Popover dieses Moduls auf- und wieder zugeht. + /// + /// **Die einzige verlässliche Stelle für alles, was nur laufen darf, + /// solange jemand hinsieht.** SwiftUIs `onDisappear` bleibt bei einem + /// `NSPopover` aus: dessen Controller lebt nach dem Schließen weiter, die + /// View verschwindet nie aus der Hierarchie. Wer eine Messung daran hängt, + /// hängt sie an etwas, das nie eintritt — im Fall des Mikrofons mit dem + /// orangen Punkt als Beweis. + func popoverDidOpen() + func popoverDidClose() + /// Wird gerufen, wenn das Modul sichtbar wird bzw. verschwindet — hier /// meldet sich das Modul beim Provider an und wieder ab. func activate() @@ -72,3 +83,9 @@ extension TimeInterval { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } } + +public extension MenuBarModule { + // Die meisten Module haben nichts abzuräumen. + func popoverDidOpen() {} + func popoverDidClose() {} +}