diff --git a/AudioPriorityBar.xcodeproj/project.pbxproj b/AudioPriorityBar.xcodeproj/project.pbxproj index 4ed325d..6c85a37 100644 --- a/AudioPriorityBar.xcodeproj/project.pbxproj +++ b/AudioPriorityBar.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ A10000000000000000000005 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000005 /* MenuBarView.swift */; }; A10000000000000000000006 /* DeviceListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000006 /* DeviceListView.swift */; }; A10000000000000000000007 /* LaunchAtLoginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* LaunchAtLoginManager.swift */; }; + A10000000000000000000021 /* WirelessLinkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000021 /* WirelessLinkMonitor.swift */; }; A10000000000000000000009 /* Headphones.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000009 /* Headphones.swift */; }; A10000000000000000000010 /* CoreAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A20000000000000000000010 /* CoreAudio.framework */; }; A10000000000000000000020 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000020 /* Assets.xcassets */; }; @@ -28,6 +29,7 @@ A20000000000000000000006 /* DeviceListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceListView.swift; sourceTree = ""; }; A20000000000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A20000000000000000000008 /* LaunchAtLoginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginManager.swift; sourceTree = ""; }; + A20000000000000000000021 /* WirelessLinkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WirelessLinkMonitor.swift; sourceTree = ""; }; A20000000000000000000009 /* Headphones.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Headphones.swift; sourceTree = ""; }; A20000000000000000000010 /* CoreAudio.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreAudio.framework; path = System/Library/Frameworks/CoreAudio.framework; sourceTree = SDKROOT; }; A20000000000000000000020 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -83,6 +85,7 @@ A20000000000000000000003 /* AudioDeviceService.swift */, A20000000000000000000004 /* PriorityManager.swift */, A20000000000000000000008 /* LaunchAtLoginManager.swift */, + A20000000000000000000021 /* WirelessLinkMonitor.swift */, ); path = Services; sourceTree = ""; @@ -189,6 +192,7 @@ A10000000000000000000005 /* MenuBarView.swift in Sources */, A10000000000000000000006 /* DeviceListView.swift in Sources */, A10000000000000000000007 /* LaunchAtLoginManager.swift in Sources */, + A10000000000000000000021 /* WirelessLinkMonitor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/AudioPriorityBar/AudioPriorityBarApp.swift b/AudioPriorityBar/AudioPriorityBarApp.swift index 3f455e9..367c6aa 100644 --- a/AudioPriorityBar/AudioPriorityBarApp.swift +++ b/AudioPriorityBar/AudioPriorityBarApp.swift @@ -89,6 +89,7 @@ class AudioManager: ObservableObject { @Published var micFlashState: Bool = false private let deviceService = AudioDeviceService() + private let linkMonitor = WirelessLinkMonitor() private var micFlashTimer: Timer? let priorityManager = PriorityManager() private var connectedDeviceUIDs: Set = [] @@ -167,12 +168,27 @@ class AudioManager: ObservableObject { refreshMuteStatus() setupDeviceChangeListener() setupMuteVolumeListener() + setupLinkMonitor() if !isCustomMode { applyHighestPriorityInput() applyHighestPriorityOutput() } } + private func setupLinkMonitor() { + linkMonitor.onLinkChange = { [weak self] in + Task { @MainActor in + guard let self else { return } + self.refreshDevices() + if !self.isCustomMode { + self.applyHighestPriorityInput() + self.applyHighestPriorityOutput() + } + } + } + linkMonitor.start() + } + private func setupMuteVolumeListener() { deviceService.onMuteOrVolumeChanged = { [weak self] in Task { @MainActor in @@ -253,6 +269,13 @@ class AudioManager: ObservableObject { connectedDeviceUIDs.contains(device.uid) } + /// A dongle that is plugged in but whose headset is not linked to it: present in + /// CoreAudio, yet unable to carry audio, so it is skipped when auto-selecting. + /// A device with no known link signal is never reported as down. + func isLinkDown(_ device: AudioDevice) -> Bool { + device.isConnected && !linkMonitor.isUsable(deviceNamed: device.name) + } + /// Tracks device UIDs from the previous refresh to detect new connections private var previousConnectedUIDs: Set = [] @@ -396,14 +419,14 @@ class AudioManager: ObservableObject { } private func applyHighestPriorityInput() { - if let first = inputDevices.first(where: { $0.isConnected && !priorityManager.isNeverUse($0) }) { + if let first = inputDevices.first(where: { $0.isConnected && !priorityManager.isNeverUse($0) && !isLinkDown($0) }) { applyInputDevice(first) } } private func applyHighestPriorityOutput() { let devices = activeOutputDevices - if let first = devices.first(where: { $0.isConnected && !priorityManager.isNeverUse($0) }) { + if let first = devices.first(where: { $0.isConnected && !priorityManager.isNeverUse($0) && !isLinkDown($0) }) { applyOutputDevice(first) } refreshMuteStatus() diff --git a/AudioPriorityBar/Services/WirelessLinkMonitor.swift b/AudioPriorityBar/Services/WirelessLinkMonitor.swift new file mode 100644 index 0000000..ae08c88 --- /dev/null +++ b/AudioPriorityBar/Services/WirelessLinkMonitor.swift @@ -0,0 +1,203 @@ +import Foundation +import IOKit +import IOKit.hid + +/// Reports whether a wireless headset is actually linked to its USB dongle. +/// +/// A dongle publishes its CoreAudio devices whenever it is plugged in, whether or not +/// the headset it pairs with is powered on. Nothing in CoreAudio separates the two +/// states — measured on a Jabra Link 390 with the headset off and then on, the driver +/// never publishes `kAudioDevicePropertyJackIsConnected`, and `DeviceIsAlive`, channel +/// counts, available sample rates, stream formats and `CanBeDefaultDevice` are all +/// identical. Priority selection therefore picks the dongle and sends audio into a +/// dead link: it shows as Active while nothing comes out. +/// +/// Some dongles do report the truth over HID. This monitor reads that bit for the +/// dongles it knows about. +/// +/// **A device this monitor does not recognise is always reported as usable**, so +/// behaviour is unchanged for every other piece of hardware. +final class WirelessLinkMonitor { + + /// A dongle whose HID interface exposes a headset-link bit. + struct DongleProfile { + let vendorID: Int + /// Matched against both the HID product string and the CoreAudio device name. + let nameFragment: String + let reportID: UInt8 + let byteIndex: Int + let bitMask: UInt8 + } + + /// Jabra Link 390. HID Telephony usage 0x2A ("Line"), report 0x04 byte 1 bit 3. + /// It reads 1 only while the headset is linked to the dongle. + /// + /// Verified against the hardware: powering the headset on over Bluetooth left the + /// bit at 0 while the headset's Bluetooth devices appeared, and when the headset + /// later moved its link to the dongle the bit went to 1 and the Bluetooth devices + /// were removed 1.7 seconds later. + static let knownDongles: [DongleProfile] = [ + DongleProfile(vendorID: 0x0B0E, + nameFragment: "Jabra Link 390", + reportID: 0x04, + byteIndex: 1, + bitMask: 0x08) + ] + + enum LinkState { + case up // headset is linked to the dongle + case down // dongle present, headset not linked + case unknown // no matching dongle, or the bit could not be read + } + + /// How long the link must stay down before the device is treated as unusable. + /// Rides out a momentary drop so a brief glitch cannot pull audio away mid-call. + private static let downDebounce: TimeInterval = 3.0 + + /// Called when a link goes up or down, on the main queue. + var onLinkChange: (() -> Void)? + + private let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) + private var attached: [IOHIDDevice: DongleProfile] = [:] + private var buffers: [UnsafeMutablePointer] = [] + private var states: [String: LinkState] = [:] // keyed by nameFragment + private var downSince: [String: Date] = [:] + private var pollTimer: Timer? + + // MARK: - Lifecycle + + func start() { + let criteria = Self.knownDongles.map { [kIOHIDVendorIDKey: $0.vendorID] } + IOHIDManagerSetDeviceMatchingMultiple(manager, criteria as CFArray) + + let context = Unmanaged.passUnretained(self).toOpaque() + IOHIDManagerRegisterDeviceMatchingCallback(manager, { context, _, _, device in + guard let context else { return } + Unmanaged.fromOpaque(context).takeUnretainedValue().attach(device) + }, context) + IOHIDManagerRegisterDeviceRemovalCallback(manager, { context, _, _, device in + guard let context else { return } + Unmanaged.fromOpaque(context).takeUnretainedValue().detach(device) + }, context) + + IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) + IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) + + if let devices = IOHIDManagerCopyDevices(manager) as? Set { + for device in devices { attach(device) } + } + + // Backstop for a missed report, which would otherwise leave a device gated + // until the next time the headset is toggled. + pollTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in + self?.poll() + } + } + + deinit { + pollTimer?.invalidate() + IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone)) + for buffer in buffers { buffer.deallocate() } + } + + // MARK: - Queries + + /// Link state for a CoreAudio device, matched by name. + func linkState(forDeviceNamed name: String) -> LinkState { + guard let profile = Self.knownDongles.first(where: { name.contains($0.nameFragment) }) else { + return .unknown + } + return states[profile.nameFragment] ?? .unknown + } + + /// Whether a device may be auto-selected. Only a settled `down` blocks it; anything + /// unrecognised or unreadable stays selectable. + func isUsable(deviceNamed name: String) -> Bool { + guard case .down = linkState(forDeviceNamed: name) else { return true } + guard let profile = Self.knownDongles.first(where: { name.contains($0.nameFragment) }), + let since = downSince[profile.nameFragment] else { return true } + return Date().timeIntervalSince(since) < Self.downDebounce + } + + // MARK: - HID + + private func profile(for device: IOHIDDevice) -> DongleProfile? { + guard let product = IOHIDDeviceGetProperty(device, kIOHIDProductKey as CFString) as? String else { + return nil + } + return Self.knownDongles.first { product.contains($0.nameFragment) } + } + + /// The matching callback also fires for devices already connected at startup, so + /// without this guard the explicit enumeration would attach each device twice. + private func attach(_ device: IOHIDDevice) { + guard attached[device] == nil, let profile = profile(for: device) else { return } + attached[device] = profile + + IOHIDDeviceOpen(device, IOOptionBits(kIOHIDOptionsTypeNone)) + let size = (IOHIDDeviceGetProperty(device, kIOHIDMaxInputReportSizeKey as CFString) as? Int) ?? 64 + let buffer = UnsafeMutablePointer.allocate(capacity: size) + buffers.append(buffer) + + let context = Unmanaged.passUnretained(self).toOpaque() + IOHIDDeviceRegisterInputReportCallback(device, buffer, size, { context, _, sender, _, reportID, report, length in + guard let context, let sender else { return } + let monitor = Unmanaged.fromOpaque(context).takeUnretainedValue() + let device = Unmanaged.fromOpaque(sender).takeUnretainedValue() + guard let profile = monitor.attached[device], + UInt8(reportID) == profile.reportID, + Int(length) > profile.byteIndex else { return } + monitor.update(profile: profile, isLinked: (report[profile.byteIndex] & profile.bitMask) != 0) + }, context) + IOHIDDeviceScheduleWithRunLoop(device, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) + + if let isLinked = read(device, profile) { + // A link already down at startup gets no grace period: the debounce is for + // riding out a drop during use. + states[profile.nameFragment] = isLinked ? .up : .down + downSince[profile.nameFragment] = isLinked ? nil : .distantPast + } + onLinkChange?() + } + + private func detach(_ device: IOHIDDevice) { + guard let profile = attached.removeValue(forKey: device) else { return } + states[profile.nameFragment] = .unknown + downSince[profile.nameFragment] = nil + onLinkChange?() + } + + private func read(_ device: IOHIDDevice, _ profile: DongleProfile) -> Bool? { + var buffer = [UInt8](repeating: 0, count: 64) + var length = CFIndex(buffer.count) + let result = IOHIDDeviceGetReport(device, kIOHIDReportTypeInput, + CFIndex(profile.reportID), &buffer, &length) + guard result == kIOReturnSuccess, Int(length) > profile.byteIndex else { return nil } + return (buffer[profile.byteIndex] & profile.bitMask) != 0 + } + + private func poll() { + for (device, profile) in attached { + if let isLinked = read(device, profile) { + update(profile: profile, isLinked: isLinked) + } + } + } + + private func update(profile: DongleProfile, isLinked: Bool) { + let newState: LinkState = isLinked ? .up : .down + guard states[profile.nameFragment] != newState else { return } + states[profile.nameFragment] = newState + + if isLinked { + downSince[profile.nameFragment] = nil + } else { + downSince[profile.nameFragment] = Date() + // No further HID report will arrive, so re-evaluate once the debounce ends. + DispatchQueue.main.asyncAfter(deadline: .now() + Self.downDebounce + 0.1) { [weak self] in + self?.onLinkChange?() + } + } + onLinkChange?() + } +} diff --git a/AudioPriorityBar/Views/DeviceListView.swift b/AudioPriorityBar/Views/DeviceListView.swift index 4077372..003bd91 100644 --- a/AudioPriorityBar/Views/DeviceListView.swift +++ b/AudioPriorityBar/Views/DeviceListView.swift @@ -114,7 +114,13 @@ struct DraggableDeviceRow: View { } var isGrayed: Bool { - isDisconnected || isHiddenSection + isDisconnected || isHiddenSection || isLinkDown + } + + /// Plugged in, but the wireless headset is not linked to it, so it cannot + /// carry audio and is skipped when picking the highest priority device. + var isLinkDown: Bool { + audioManager.isLinkDown(device) } var isNeverUse: Bool { @@ -124,6 +130,8 @@ struct DraggableDeviceRow: View { var statusIcon: String? { if isDisconnected { return "wifi.slash" + } else if isLinkDown { + return "antenna.radiowaves.left.and.right.slash" } else if isIgnored && audioManager.isEditMode { return "eye.slash" } else if isNeverUse { @@ -140,6 +148,10 @@ struct DraggableDeviceRow: View { return stored.lastSeenRelative } + var linkStatusText: String? { + isLinkDown ? "not linked" : nil + } + var isMuted: Bool { device.isConnected && audioManager.isDeviceMuted(device) } @@ -209,6 +221,12 @@ struct DraggableDeviceRow: View { .foregroundColor(.secondary.opacity(0.6)) } + if let linkStatus = linkStatusText { + Text(linkStatus) + .font(.system(size: 10)) + .foregroundColor(.secondary.opacity(0.6)) + } + if isMuted { Text("Muted") .font(.system(size: 9, weight: .semibold)) diff --git a/README.md b/README.md index eea547b..d18eb06 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A native macOS menu bar app that automatically manages audio device priorities. - **Separate speaker/headphone modes**: Output devices are categorized as either speakers or headphones, each with their own priority list. - **Manual override**: Enable "Custom" mode (hand icon) to disable auto-switching and select devices freely. - **Device memory**: Remembers all devices you've ever connected, even when disconnected. Edit mode shows disconnected devices with "last seen" timestamps. +- **Wireless link awareness**: A USB headset dongle publishes its audio devices whenever it is plugged in, even when the headset is off. Recognised dongles are skipped while no headset is linked to them, so audio is not sent into a dead link. Devices without a known link signal are unaffected. - **Per-category ignore**: Hide devices from specific categories without affecting others. - **Drag-to-reorder**: Reorder devices by dragging or using up/down arrows. - **Volume control**: Adjust volume with slider or scroll wheel. @@ -85,6 +86,7 @@ Click "Edit" in the footer to: 2. **Priority Storage**: Device priorities are stored in UserDefaults, keyed by device UID (stable across reconnects). 3. **Auto-Switching**: When devices connect/disconnect, the app automatically selects the highest-priority available device for the current mode. 4. **Categories**: Each output device is assigned to either "speaker" or "headphone" category, with separate priority lists. +5. **Link Checking**: For dongles listed in `WirelessLinkMonitor.knownDongles`, an HID bit reports whether the headset is actually linked. CoreAudio cannot tell: it reports the dongle as alive with the same channels, sample rates and stream formats either way. A device with no known link signal is always treated as usable. ## Project Structure @@ -95,7 +97,8 @@ AudioPriorityBar/ │ └── AudioDevice.swift # Device model, OutputCategory enum ├── Services/ │ ├── AudioDeviceService.swift # CoreAudio wrapper -│ └── PriorityManager.swift # Priority persistence +│ ├── PriorityManager.swift # Priority persistence +│ └── WirelessLinkMonitor.swift # HID headset-link detection for USB dongles └── Views/ ├── MenuBarView.swift # Main popover UI └── DeviceListView.swift # Device list and row components