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 } } public extension ProcessVisibility { /// Programme, die gar nicht erst in der Liste auftauchen. /// /// **Onyx selbst gehört dazu**, und nicht nur der Ordnung halber: sobald der /// Mixer ein Programm bearbeitet, gibt *er* dessen Ton aus — und steht damit /// als Tonquelle in seiner eigenen Liste. Greift er sich dort ab, hört er /// seine eigene Ausgabe wieder mit und gibt sie erneut aus. Gemessen klettert /// der Pegel dabei binnen Sekunden von 0,37 auf 2,83: eine Rückkopplung, die /// man nicht bauen, sondern ausschließen muss. /// /// Dazu die Systemdienste: `coreaudiod` und die Sprachausgabe sind in einer /// Liste, in der man „Spotify leiser" sucht, nur Rauschen. static func isExcluded(bundleID: String, ownBundleID: String?) -> Bool { if let ownBundleID, !ownBundleID.isEmpty, bundleID == ownBundleID { return true } return bundleID.hasPrefix("com.apple.audio") || bundleID.hasPrefix("com.apple.core") } }