From c4f6c03fb51dd94108157cfca2fb4e383d705602 Mon Sep 17 00:00:00 2001 From: Xuan Nguyen Date: Tue, 25 Aug 2026 01:29:08 -0700 Subject: [PATCH 1/2] feat: enhance output device selection with transport detection, persistence, and instant switching --- Sources/AudioOutputDevice.swift | 92 ++++++++++++++++++++++++++++++++- Sources/AudioTapManager.swift | 45 ++++++++++++++-- Sources/VolumeControlView.swift | 71 ++++++++++++------------- 3 files changed, 164 insertions(+), 44 deletions(-) diff --git a/Sources/AudioOutputDevice.swift b/Sources/AudioOutputDevice.swift index 6d05594..99b0623 100644 --- a/Sources/AudioOutputDevice.swift +++ b/Sources/AudioOutputDevice.swift @@ -1,17 +1,105 @@ +import Foundation import CoreAudio +// ============================================================================= +// MARK: - Audio Device Transport Type +// ============================================================================= + +/// Hardware transport mechanism used to connect the audio output device. +enum AudioDeviceTransportType: String, Equatable, CaseIterable, Sendable { + case builtIn = "Built-in" + case bluetooth = "Bluetooth" + case usb = "USB" + case hdmi = "HDMI" + case displayPort = "DisplayPort" + case airPlay = "AirPlay" + case thunderbolt = "Thunderbolt" + case pci = "PCI" + case fireWire = "FireWire" + case virtual = "Virtual" + case unknown = "Audio Device" + + /// Maps CoreAudio 4-character transport codes (`kAudioDevicePropertyTransportType`) to typed enum. + static func from(fourCC: UInt32) -> AudioDeviceTransportType { + switch fourCC { + case kAudioDeviceTransportTypeBuiltIn: + return .builtIn + case kAudioDeviceTransportTypeBluetooth, kAudioDeviceTransportTypeBluetoothLE: + return .bluetooth + case kAudioDeviceTransportTypeUSB: + return .usb + case kAudioDeviceTransportTypeHDMI: + return .hdmi + case kAudioDeviceTransportTypeDisplayPort: + return .displayPort + case kAudioDeviceTransportTypeAirPlay: + return .airPlay + case kAudioDeviceTransportTypeThunderbolt: + return .thunderbolt + case kAudioDeviceTransportTypePCI: + return .pci + case kAudioDeviceTransportTypeFireWire: + return .fireWire + case kAudioDeviceTransportTypeVirtual, kAudioDeviceTransportTypeAggregate, kAudioDeviceTransportTypeAutoAggregate: + return .virtual + default: + return .unknown + } + } +} + // ============================================================================= // MARK: - Audio Output Device Model // ============================================================================= /// Encapsulates a hardware audio output device (e.g. MacBook Speakers, AirPods, External DAC). -struct AudioOutputDevice: Identifiable, Hashable, Equatable { +struct AudioOutputDevice: Identifiable, Hashable, Equatable, Sendable { /// CoreAudio AudioDeviceID (HAL integer identifier). let id: AudioDeviceID /// User-friendly name (e.g. "MacBook Pro Speakers", "AirPods Pro"). let name: String - /// Unique persistent string identifier (e.g. "BuiltInSpeakerDevice", Bluetooth address). + /// Unique persistent string identifier (e.g. "BuiltInSpeakerDevice", Bluetooth MAC address). let uid: String + /// Hardware transport bus type. + let transportType: AudioDeviceTransportType + + init(id: AudioDeviceID, name: String, uid: String, transportType: AudioDeviceTransportType = .unknown) { + self.id = id + self.name = name + self.uid = uid + self.transportType = transportType + } + + /// Resolves an appropriate SF Symbol icon name based on transport type and device name. + var iconName: String { + let lower = name.lowercased() + if lower.contains("airpod max") { + return "headphones" + } else if lower.contains("airpod") { + return "airpodspro" + } else if lower.contains("beats") || lower.contains("headphone") || lower.contains("headset") || lower.contains("earphone") { + return "headphones" + } else if lower.contains("homepod") { + return "homepod.fill" + } else if lower.contains("hdmi") || lower.contains("tv") || lower.contains("television") { + return "tv" + } else if lower.contains("studio display") || lower.contains("pro display") || lower.contains("displayport") || lower.contains("monitor") { + return "display" + } else if transportType == .bluetooth { + return "beats.headphones" + } else if transportType == .usb { + return "hifispeaker.fill" + } else if transportType == .builtIn { + if lower.contains("imac") || lower.contains("mac pro") || lower.contains("mac mini") || lower.contains("desktop") { + return "desktopcomputer" + } + return "laptopcomputer" + } else if transportType == .airPlay { + return "airplayaudio" + } else { + return "speaker.wave.2.fill" + } + } static func == (lhs: AudioOutputDevice, rhs: AudioOutputDevice) -> Bool { lhs.id == rhs.id && lhs.uid == rhs.uid diff --git a/Sources/AudioTapManager.swift b/Sources/AudioTapManager.swift index a07a699..bdcf957 100644 --- a/Sources/AudioTapManager.swift +++ b/Sources/AudioTapManager.swift @@ -77,6 +77,9 @@ class AudioTapManager: NSObject, ObservableObject { /// List of all detected output-capable audio devices on the system. @Published var availableOutputDevices: [AudioOutputDevice] = [] + /// UserDefaults key for storing user's preferred output device UID. + private let preferredOutputDeviceUIDKey = "MySoundPreferredOutputDeviceUID" + /// Dynamically resolved pointer to private `responsibility_get_pid_responsible_for_pid` symbol in libproc. /// This allows mapping sandboxed helper processes (like Chrome Helper or Safari WebContent) to their parent app. private nonisolated static let getResponsiblePID: (@convention(c) (pid_t) -> pid_t)? = { @@ -263,10 +266,31 @@ class AudioTapManager: NSObject, ObservableObject { /// Refreshes available output devices and recreates taps if the active output device changed. func refreshOutputDevices() { let devices = getAvailableOutputDevices() - let current = getDefaultOutputDevice() + var current = getDefaultOutputDevice() + + // If a previously preferred device reconnected (e.g. AirPods), automatically switch to it + if let preferredUID = UserDefaults.standard.string(forKey: preferredOutputDeviceUIDKey), + let preferredDevice = devices.first(where: { $0.uid == preferredUID }), + current?.uid != preferredUID { + var devID = preferredDevice.id + let propertySize = UInt32(MemoryLayout.size) + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + if AudioObjectSetPropertyData(AudioObjectID(kAudioObjectSystemObject), &propertyAddress, 0, nil, propertySize, &devID) == noErr { + current = preferredDevice + AppLogger.shared.log("Restored preferred output device: \(preferredDevice.name) (\(preferredUID))") + } + } + let deviceChanged = self.currentOutputDevice?.uid != current?.uid self.availableOutputDevices = devices self.currentOutputDevice = current + if let currentUID = current?.uid { + UserDefaults.standard.set(currentUID, forKey: preferredOutputDeviceUIDKey) + } if deviceChanged && current != nil { // Re-bind all active taps to the new physical output device recreateAllTaps() @@ -688,7 +712,18 @@ class AudioTapManager: NSObject, ObservableObject { deviceUID = uidStr } - outputDevices.append(AudioOutputDevice(id: deviceID, name: deviceName, uid: deviceUID)) + // Get transport type + var transportAddr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyTransportType, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var transportTypeFourCC: UInt32 = 0 + var transportSize = UInt32(MemoryLayout.size) + let transportStatus = AudioObjectGetPropertyData(deviceID, &transportAddr, 0, nil, &transportSize, &transportTypeFourCC) + let transportType: AudioDeviceTransportType = (transportStatus == noErr) ? .from(fourCC: transportTypeFourCC) : .unknown + + outputDevices.append(AudioOutputDevice(id: deviceID, name: deviceName, uid: deviceUID, transportType: transportType)) } return outputDevices @@ -723,8 +758,12 @@ class AudioTapManager: NSObject, ObservableObject { let status = AudioObjectSetPropertyData(AudioObjectID(kAudioObjectSystemObject), &propertyAddress, 0, nil, propertySize, &devID) if status == noErr { self.currentOutputDevice = device + UserDefaults.standard.set(device.uid, forKey: preferredOutputDeviceUIDKey) + AppLogger.shared.log("Switched default output device to \(device.name) (\(device.uid))") + // Re-bind all active taps to the new physical output device + recreateAllTaps() } else { - print("MySound: Failed to set default output device \(device.name) (status: \(status))") + AppLogger.shared.log("Failed to set default output device \(device.name) (status: \(status))") } } diff --git a/Sources/VolumeControlView.swift b/Sources/VolumeControlView.swift index 89db0eb..3c1e5ce 100644 --- a/Sources/VolumeControlView.swift +++ b/Sources/VolumeControlView.swift @@ -278,43 +278,52 @@ struct VolumeControlView: View { // MARK: Header & Master Volume // ----------------------------------------------------------------- VStack(alignment: .leading, spacing: 8) { - HStack { + HStack(alignment: .center) { // Output device selection dropdown - if tapManager.availableOutputDevices.count > 1 { - Menu { + Menu { + Section(header: Text("Switch Output Device")) { ForEach(tapManager.availableOutputDevices) { device in Button(action: { tapManager.setDefaultOutputDevice(device) }) { HStack { - Text(device.name) + Label { + Text(device.name) + } icon: { + Image(systemName: device.iconName) + } if tapManager.currentOutputDevice?.id == device.id { Image(systemName: "checkmark") } } } } - } label: { - HStack(spacing: 4) { - Text(tapManager.currentOutputDevice?.name ?? "Output Device") - .font(.headline) - .foregroundColor(.primary) - .lineLimit(1) - .truncationMode(.tail) + } + } label: { + HStack(spacing: 5) { + Image(systemName: tapManager.currentOutputDevice?.iconName ?? "laptopcomputer") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.blue) + + Text(tapManager.currentOutputDevice?.name ?? "Output Device") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.primary) + .lineLimit(1) + .truncationMode(.tail) + + if tapManager.availableOutputDevices.count > 1 { Image(systemName: "chevron.up.chevron.down") - .font(.caption2) + .font(.system(size: 9, weight: .bold)) .foregroundColor(.secondary) } } - .menuStyle(.borderlessButton) - .accessibilityLabel("Select Output Device, currently \(tapManager.currentOutputDevice?.name ?? "Output Device")") - } else { - Text(tapManager.currentOutputDevice?.name ?? "Output Device") - .font(.headline) - .foregroundColor(.primary) - .lineLimit(1) - .truncationMode(.tail) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.primary.opacity(0.04)) + .cornerRadius(6) } + .menuStyle(.borderlessButton) + .accessibilityLabel("Select Output Device, currently \(tapManager.currentOutputDevice?.name ?? "Output Device")") Spacer() @@ -334,7 +343,7 @@ struct VolumeControlView: View { // Master Volume Slider Row: [Device Icon] [Speaker Mute Button] [Slider] [Percentage] HStack(spacing: 6) { // Device Icon - Image(systemName: deviceIconName(for: tapManager.currentOutputDevice?.name)) + Image(systemName: tapManager.currentOutputDevice?.iconName ?? "laptopcomputer") .font(.system(size: 13)) .foregroundColor(.secondary) .frame(width: 18, height: 18) @@ -648,25 +657,9 @@ struct VolumeControlView: View { isLaunchAtLogin = SMAppService.mainApp.status == .enabled } - /// Resolves an appropriate SF Symbol icon name based on the audio device name. + /// Resolves an appropriate SF Symbol icon name based on the active audio device. private func deviceIconName(for name: String?) -> String { - guard let name = name?.lowercased() else { return "laptopcomputer" } - if name.contains("airpod") { - return "airpodspro" - } else if name.contains("headphone") || name.contains("headset") || name.contains("earphone") { - return "headphones" - } else if name.contains("hdmi") || name.contains("tv") || name.contains("displayport") || name.contains("monitor") { - return "tv" - } else if name.contains("homepod") { - return "homepod.fill" - } else if name.contains("studio") || name.contains("pro display") { - return "display" - } else if name.contains("imac") || name.contains("mac pro") || name.contains("mac mini") || name.contains("desktop") { - return "desktopcomputer" - } else { - // Default built-in / MacBook Air / MacBook Pro speakers - return "laptopcomputer" - } + tapManager.currentOutputDevice?.iconName ?? "laptopcomputer" } // MARK: - CoreAudio Volume/Mute Property Listeners From 4f50e3bc16a10a3fa95f673b63e5feffcfacd214 Mon Sep 17 00:00:00 2001 From: Xuan Nguyen Date: Tue, 25 Aug 2026 01:32:22 -0700 Subject: [PATCH 2/2] fix: only auto-restore preferred output device on reconnect, respecting external manual switching --- Sources/AudioTapManager.swift | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/Sources/AudioTapManager.swift b/Sources/AudioTapManager.swift index bdcf957..ad79e62 100644 --- a/Sources/AudioTapManager.swift +++ b/Sources/AudioTapManager.swift @@ -80,6 +80,9 @@ class AudioTapManager: NSObject, ObservableObject { /// UserDefaults key for storing user's preferred output device UID. private let preferredOutputDeviceUIDKey = "MySoundPreferredOutputDeviceUID" + /// Tracks known device UIDs between HAL callbacks to distinguish reconnected devices from existing devices. + private var previousKnownDeviceUIDs: Set = [] + /// Dynamically resolved pointer to private `responsibility_get_pid_responsible_for_pid` symbol in libproc. /// This allows mapping sandboxed helper processes (like Chrome Helper or Safari WebContent) to their parent app. private nonisolated static let getResponsiblePID: (@convention(c) (pid_t) -> pid_t)? = { @@ -266,10 +269,15 @@ class AudioTapManager: NSObject, ObservableObject { /// Refreshes available output devices and recreates taps if the active output device changed. func refreshOutputDevices() { let devices = getAvailableOutputDevices() + let currentUIDs = Set(devices.map { $0.uid }) + let newlyConnectedUIDs = previousKnownDeviceUIDs.isEmpty ? [] : currentUIDs.subtracting(previousKnownDeviceUIDs) + previousKnownDeviceUIDs = currentUIDs + var current = getDefaultOutputDevice() - // If a previously preferred device reconnected (e.g. AirPods), automatically switch to it + // Auto-restore preferred device ONLY if it was disconnected and just reconnected if let preferredUID = UserDefaults.standard.string(forKey: preferredOutputDeviceUIDKey), + newlyConnectedUIDs.contains(preferredUID), let preferredDevice = devices.first(where: { $0.uid == preferredUID }), current?.uid != preferredUID { var devID = preferredDevice.id @@ -281,16 +289,26 @@ class AudioTapManager: NSObject, ObservableObject { ) if AudioObjectSetPropertyData(AudioObjectID(kAudioObjectSystemObject), &propertyAddress, 0, nil, propertySize, &devID) == noErr { current = preferredDevice - AppLogger.shared.log("Restored preferred output device: \(preferredDevice.name) (\(preferredUID))") + AppLogger.shared.log("Auto-restored newly reconnected preferred output device: \(preferredDevice.name) (\(preferredUID))") } } let deviceChanged = self.currentOutputDevice?.uid != current?.uid self.availableOutputDevices = devices self.currentOutputDevice = current + + // If the current device is present, record it as user's active preference + // (unless the preferred device is merely disconnected) if let currentUID = current?.uid { - UserDefaults.standard.set(currentUID, forKey: preferredOutputDeviceUIDKey) + if let preferredUID = UserDefaults.standard.string(forKey: preferredOutputDeviceUIDKey) { + if currentUIDs.contains(preferredUID) { + UserDefaults.standard.set(currentUID, forKey: preferredOutputDeviceUIDKey) + } + } else { + UserDefaults.standard.set(currentUID, forKey: preferredOutputDeviceUIDKey) + } } + if deviceChanged && current != nil { // Re-bind all active taps to the new physical output device recreateAllTaps()