diff --git a/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift b/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift index d755401..0cffb53 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift @@ -28,6 +28,9 @@ public final class AudioMixer { private var refreshTimer: Timer? private var watchdogTimer: Timer? private let defaults: UserDefaults + /// Wann ein Prozess zuletzt Ton ausgegeben hat. Ohne dieses Gedächtnis + /// verschwindet ein pausiertes Programm sofort aus der Liste. + private var lastHeard: [String: Date] = [:] private enum Keys { static let volumes = "onyx.audio.volumes" @@ -81,7 +84,20 @@ public final class AudioMixer { // MARK: - Programme private func refresh() { - processes = Self.currentProcesses() + let all = Self.currentProcesses() + let now = Date() + for process in all where process.isPlaying { lastHeard[process.bundleID] = now } + + // Core Audio führt Dutzende Systemdienste, die theoretisch Ton ausgeben + // könnten. In einem Mischpult sind sie nur Rauschen — gezeigt wird, was + // klingt, kürzlich klang oder geregelt wird. + processes = all.filter { process in + ProcessVisibility.shouldShow( + isPlaying: process.isPlaying, + secondsSinceHeard: lastHeard[process.bundleID] + .map { now.timeIntervalSince($0) }, + isManaged: managed.contains(process.bundleID)) + } syncTaps() } @@ -194,6 +210,7 @@ public final class AudioMixer { // MARK: - Core Audio + @MainActor static func currentProcesses() -> [AudioProcess] { var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyProcessObjectList, @@ -225,7 +242,7 @@ public final class AudioMixer { guard !bundleID.hasPrefix("com.apple.audio"), !bundleID.hasPrefix("com.apple.core") else { return nil } - let name = NSRunningApplication(processIdentifier: pid)?.localizedName ?? bundleID + let name = ProcessNaming.displayName(bundleID: bundleID, pid: pid) let playing = (value(object, kAudioProcessPropertyIsRunningOutput, UInt32(0)) ?? 0) != 0 return AudioProcess(objectID: object, pid: pid, bundleID: bundleID, name: name, isPlaying: playing) diff --git a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift index 15bc12a..57a4bd8 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift @@ -93,6 +93,10 @@ private struct ProcessRow: View { .foregroundStyle(process.isPlaying ? Onyx.Color.textPrimary : Onyx.Color.textSecondary) .lineLimit(1) + // In der Mitte kürzen statt am Ende: bei zwei ähnlichen + // Namen ist das Ende oft das Unterscheidende. + .truncationMode(.middle) + .layoutPriority(1) if process.isPlaying { Circle().fill(Onyx.Color.positive).frame(width: 5, height: 5) @@ -100,11 +104,6 @@ private struct ProcessRow: View { Spacer(minLength: 0) if managed { - Text("\(Int(mixer.volume(for: process.bundleID) * 100)) %") - .font(Onyx.Font.metricSmall) - .foregroundStyle(mixer.volume(for: process.bundleID) > 1 - ? Onyx.Color.warning : Onyx.Color.textTertiary) - Button { mixer.setMuted(!mixer.isMuted(process.bundleID), for: process.bundleID) } label: { @@ -128,30 +127,43 @@ private struct ProcessRow: View { } if managed { - Slider(value: Binding( - get: { mixer.volume(for: process.bundleID) }, - set: { mixer.setVolume($0, for: process.bundleID) }), - in: 0...Gain.maximumVolume) - .controlSize(.mini) - .disabled(mixer.isMuted(process.bundleID)) - .overlay(alignment: .leading) { - // Markierung bei 100 %: darüber wird verstärkt, und - // man soll sehen, wann man diese Grenze überschreitet. - GeometryReader { geometry in - Rectangle() - .fill(Onyx.Color.hairline) - .frame(width: 1, height: 8) - .offset(x: geometry.size.width / Gain.maximumVolume, y: 4) + // Die Prozentzahl steht beim Regler, nicht beim Namen: oben ist + // sie die Breite, die den Namen abschneidet, hier gehört sie + // ohnehin hin. + HStack(spacing: 6) { + Slider(value: Binding( + get: { mixer.volume(for: process.bundleID) }, + set: { mixer.setVolume($0, for: process.bundleID) }), + in: 0...Gain.maximumVolume) + .controlSize(.mini) + .disabled(mixer.isMuted(process.bundleID)) + .overlay(alignment: .leading) { + // Markierung bei 100 %: darüber wird verstärkt, und + // man soll sehen, wann man diese Grenze überschreitet. + GeometryReader { geometry in + Rectangle() + .fill(Onyx.Color.hairline) + .frame(width: 1, height: 8) + .offset(x: geometry.size.width / Gain.maximumVolume, y: 4) + } } - } + + Text("\(Int(mixer.volume(for: process.bundleID) * 100)) %") + .font(Onyx.Font.metricSmall) + .foregroundStyle(mixer.volume(for: process.bundleID) > 1 + ? Onyx.Color.warning : Onyx.Color.textTertiary) + // Feste Breite, sonst wandert der Regler beim Ziehen. + .frame(width: 34, alignment: .trailing) + } } } } @ViewBuilder private var icon: some View { - if let app = NSRunningApplication(processIdentifier: process.pid), - let image = app.icon { + // Auch Hilfsprozesse bekommen das Symbol ihres Programms — sonst steht + // neben „Google Chrome" ein leerer Platzhalter. + if let image = ProcessNaming.icon(bundleID: process.bundleID, pid: process.pid) { Image(nsImage: image).resizable().frame(width: 14, height: 14) } else { Image(systemName: "app.dashed") diff --git a/Packages/OnyxKit/Sources/AudioProvider/ProcessNaming.swift b/Packages/OnyxKit/Sources/AudioProvider/ProcessNaming.swift new file mode 100644 index 0000000..36e5297 --- /dev/null +++ b/Packages/OnyxKit/Sources/AudioProvider/ProcessNaming.swift @@ -0,0 +1,112 @@ +import Foundation +import AppKit + +/// Macht aus Core-Audio-Prozessen etwas, das man wiedererkennt. +/// +/// Core Audio meldet **Prozesse**, nicht Programme. Was dort ankommt, heißt +/// `com.google.Chrome.helper` oder `com.apple.WebKit.GPU`. Technisch richtig, +/// im Mischpult unbrauchbar: wer „Spotify leiser" sucht, sucht nach „Spotify". +public enum ProcessNaming { + + /// Endungen, mit denen Programme ihre Hilfsprozesse benennen. + /// + /// Chromes Ton kommt aus einem solchen Prozess — abgegriffen wird also + /// zurecht der Helfer, angezeigt aber das Programm dahinter. + private static let helperSuffixes = [ + ".helper", ".Helper", ".ServiceExtension", ".Renderer", ".GPU", + ".plugin", ".xpc", ".Service", + ] + + /// Die Kennung des Programms, zu dem ein Prozess gehört. + public static func parentBundleID(for bundleID: String) -> String { + var result = bundleID + // Mehrfach kürzen: `com.google.Chrome.helper.Renderer` hat zwei. + var changed = true + while changed { + changed = false + for suffix in helperSuffixes where result.hasSuffix(suffix) { + result.removeLast(suffix.count) + changed = true + } + } + return result.isEmpty ? bundleID : result + } + + /// Der anzuzeigende Name, wenn das Programm selbst nicht gefragt werden kann. + public static func fallbackName(for bundleID: String) -> String { + guard !bundleID.isEmpty else { return "Unbekannt" } + let parent = parentBundleID(for: bundleID) + // Der letzte Bestandteil ist bei umgekehrter Domainschreibweise der + // Programmname — „com.spotify.client" ist die Ausnahme, die auf einen + // generischen Begriff endet. + let components = parent.split(separator: ".") + // Ein einzelnes Wort ist keine umgekehrte Domain, sondern ein + // Prozessname wie `systemsoundserverd`. Den groß zu schreiben macht ihn + // nicht lesbarer, nur falsch. + guard components.count > 1 else { return parent } + guard let last = components.last else { return bundleID } + + let generic = ["client", "app", "mac", "macos", "desktop"] + let chosen = generic.contains(last.lowercased()) && components.count > 1 + ? components[components.count - 2] + : last + + return chosen.prefix(1).uppercased() + chosen.dropFirst() + } + + /// Der beste verfügbare Name: erst das laufende Programm fragen, dann den + /// übergeordneten Prozess, dann die Kennung zerlegen. + @MainActor + public static func displayName(bundleID: String, pid: pid_t) -> String { + if let name = NSRunningApplication(processIdentifier: pid)?.localizedName { + return name + } + let parent = parentBundleID(for: bundleID) + if parent != bundleID, + let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: parent) { + return FileManager.default.displayName(atPath: url.path) + .replacingOccurrences(of: ".app", with: "") + } + if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { + return FileManager.default.displayName(atPath: url.path) + .replacingOccurrences(of: ".app", with: "") + } + return fallbackName(for: bundleID) + } + + /// Das Symbol des Programms — auch für Hilfsprozesse. + @MainActor + public static func icon(bundleID: String, pid: pid_t) -> NSImage? { + if let icon = NSRunningApplication(processIdentifier: pid)?.icon { return icon } + let parent = parentBundleID(for: bundleID) + if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: parent) { + return NSWorkspace.shared.icon(forFile: url.path) + } + return nil + } +} + +/// Wer im Mischpult auftaucht. +public enum ProcessVisibility { + + /// Wie lange ein verstummtes Programm noch sichtbar bleibt. + /// + /// Ohne diese Nachlaufzeit verschwindet Spotify beim Pausieren aus der + /// Liste — und man kann die Lautstärke genau dann nicht mehr einstellen, + /// wenn man es will. + public static let gracePeriod: TimeInterval = 120 + + /// - Parameters: + /// - isPlaying: gibt der Prozess gerade Ton aus? + /// - secondsSinceHeard: wie lange ist das letzte Mal her? `nil` = noch nie. + /// - isManaged: wird seine Lautstärke von Onyx geregelt? + public static func shouldShow(isPlaying: Bool, + secondsSinceHeard: TimeInterval?, + isManaged: Bool) -> Bool { + // Geregelte Programme immer: wer eine Lautstärke eingestellt hat, muss + // sie auch zurücknehmen können. + if isManaged || isPlaying { return true } + guard let seconds = secondsSinceHeard else { return false } + return seconds <= gracePeriod + } +} diff --git a/Packages/OnyxKit/Tests/AudioProviderTests/ProcessNameTests.swift b/Packages/OnyxKit/Tests/AudioProviderTests/ProcessNameTests.swift new file mode 100644 index 0000000..71255d5 --- /dev/null +++ b/Packages/OnyxKit/Tests/AudioProviderTests/ProcessNameTests.swift @@ -0,0 +1,100 @@ +import Testing +@testable import AudioProvider + +// Core Audio meldet Prozesse, nicht Programme. Was dort ankommt, heißt +// `com.google.Chrome.helper` oder `com.apple.WebKit.GPU` — Kennungen, die in +// einem Mischpult nichts zu suchen haben. Wer „Spotify leiser" sucht, sucht +// nach „Spotify". + +@Suite("Programmnamen") +struct ProcessNameTests { + + @Test("Aus einer Kennung wird ein lesbarer Name") + func plainIdentifier() { + #expect(ProcessNaming.fallbackName(for: "com.spotify.client") == "Spotify") + } + + @Test("Hilfsprozesse werden ihrem Programm zugeordnet") + func helpersMapToParent() { + // Chromes Ton kommt aus einem Hilfsprozess. Ihn als + // „com.google.Chrome.helper" zu zeigen wäre technisch richtig und + // praktisch nutzlos. + #expect(ProcessNaming.parentBundleID(for: "com.google.Chrome.helper") + == "com.google.Chrome") + #expect(ProcessNaming.parentBundleID(for: "com.google.Chrome.helper.Renderer") + == "com.google.Chrome") + } + + @Test("Weitere übliche Endungen von Hilfsprozessen") + func knownHelperSuffixes() { + #expect(ProcessNaming.parentBundleID(for: "net.whatsapp.WhatsApp.ServiceExtension") + == "net.whatsapp.WhatsApp") + #expect(ProcessNaming.parentBundleID(for: "com.example.App.Helper") + == "com.example.App") + } + + @Test("Eine gewöhnliche Kennung bleibt unverändert") + func plainIdentifierIsUntouched() { + #expect(ProcessNaming.parentBundleID(for: "com.spotify.client") == "com.spotify.client") + } + + @Test("Der Hilfsprozess einer Komponente zeigt die Komponente") + func helperShowsItsComponent() { + // `.GPU` ist eine Hilfsprozess-Endung, also bleibt WebKit übrig. Das + // ist die nützlichere Auskunft: „GPU" allein sagt niemandem etwas. + #expect(ProcessNaming.fallbackName(for: "com.apple.WebKit.GPU") == "WebKit") + #expect(ProcessNaming.fallbackName(for: "pro.betterdisplay.BetterDisplay") + == "BetterDisplay") + } + + @Test("Eine Kennung ohne Punkte bleibt sie selbst") + func singleComponent() { + #expect(ProcessNaming.fallbackName(for: "systemsoundserverd") == "systemsoundserverd") + } + + @Test("Eine leere Kennung ergibt keinen leeren Namen") + func emptyIsHandled() { + #expect(!ProcessNaming.fallbackName(for: "").isEmpty) + } +} + +@Suite("Sichtbarkeit im Mischpult") +struct ProcessVisibilityTests { + + @Test("Was gerade Ton ausgibt, wird gezeigt") + func playingIsVisible() { + #expect(ProcessVisibility.shouldShow(isPlaying: true, secondsSinceHeard: nil, + isManaged: false)) + } + + @Test("Was nie Ton ausgegeben hat, wird nicht gezeigt") + func neverHeardIsHidden() { + // Der eigentliche Punkt: Core Audio führt Dutzende Systemdienste, die + // theoretisch Ton ausgeben könnten. In einem Mischpult sind sie nur + // Rauschen. + #expect(!ProcessVisibility.shouldShow(isPlaying: false, secondsSinceHeard: nil, + isManaged: false)) + } + + @Test("Kurz nach dem Verstummen bleibt es sichtbar") + func recentlyHeardStaysVisible() { + // Sonst verschwindet Spotify beim Pausieren aus der Liste, und man kann + // die Lautstärke genau dann nicht mehr einstellen, wenn man es will. + #expect(ProcessVisibility.shouldShow(isPlaying: false, secondsSinceHeard: 30, + isManaged: false)) + } + + @Test("Lange danach verschwindet es") + func longSilentDisappears() { + #expect(!ProcessVisibility.shouldShow(isPlaying: false, secondsSinceHeard: 600, + isManaged: false)) + } + + @Test("Ein geregeltes Programm bleibt immer sichtbar") + func managedIsAlwaysVisible() { + // Wer die Lautstärke von etwas eingestellt hat, muss sie auch wieder + // zurücknehmen können — auch wenn gerade nichts läuft. + #expect(ProcessVisibility.shouldShow(isPlaying: false, secondsSinceHeard: 99999, + isManaged: true)) + } +}