Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
branches: [main]
pull_request:
branches: [main]
# Fork-side verification: upstream PR runs sit at `action_required` because
# GitHub requires maintainer approval before executing workflows from a fork.
# Dispatching here runs the same build against our own fork instead.
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand Down
18 changes: 15 additions & 3 deletions FineTune/Audio/Monitors/BluetoothDeviceMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ final class BluetoothDeviceMonitor {
return []
}

var result: [RawPairedDevice] = []
var devicesByAddress: [String: RawPairedDevice] = [:]

for device in all {
guard !device.isConnected() else { continue }
Expand All @@ -237,16 +237,28 @@ final class BluetoothDeviceMonitor {

let hasA2DP = device.getServiceRecord(for: a2dpSinkUUID) != nil
let hasHFP = device.getServiceRecord(for: hfpUUID) != nil
guard hasA2DP || hasHFP else { continue }
let hasAudioClass = Self.isAudioDeviceClass(major: device.deviceClassMajor)
guard hasA2DP || hasHFP || hasAudioClass else { continue }

let name = device.name ?? mac
result.append(RawPairedDevice(mac: mac, name: name, iconName: suggestedIconName(for: name)))
devicesByAddress[mac.uppercased()] = RawPairedDevice(
mac: mac,
name: name,
iconName: suggestedIconName(for: name)
)
}

var result = Array(devicesByAddress.values)
result.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
return result
}

/// macOS may omit cached A2DP/HFP service records for disconnected devices after restart.
/// The Class of Device remains available and identifies audio hardware independently.
nonisolated static func isAudioDeviceClass(major: UInt32) -> Bool {
major == kBluetoothDeviceClassMajorAudio
}

/// Pure function — safe to call from any thread.
private nonisolated static func suggestedIconName(for name: String) -> String {
if name.contains("AirPods Pro") { return "airpodspro" }
Expand Down
60 changes: 56 additions & 4 deletions FineTune/Views/MenuBarPopupView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ struct MenuBarPopupView: View {
/// Whether Bluetooth hardware is powered on
@State private var isBluetoothOn = false

/// Paired device names awaiting their matching CoreAudio output after connect.
@State private var pendingBluetoothOutputs: [String: String] = [:]

/// Cleanup tasks for Bluetooth connections that never surface in CoreAudio.
@State private var pendingBluetoothTimeouts: [String: Task<Void, Never>] = [:]

/// Whether edit mode is active (affects both device priority and app visibility)
@State private var isEditingDevicePriority = false

Expand Down Expand Up @@ -156,12 +162,13 @@ struct MenuBarPopupView: View {
// before the popup is actually shown, and setting isVisible here
// would suppress the HUD on the first media key at cold launch.
}
.onChange(of: audioEngine.outputDevices) { _, _ in
.onChange(of: audioEngine.outputDevices) { _, newDevices in
if isEditingDevicePriority && !wasEditingInputDevices {
mergeDeviceChanges(from: audioEngine.outputDevices)
}
updateSortedDevices()
syncNavOrder()
selectPendingBluetoothOutput(from: newDevices)
}
.onChange(of: audioEngine.inputDevices) { _, _ in
if isEditingDevicePriority && wasEditingInputDevices {
Expand Down Expand Up @@ -537,9 +544,7 @@ struct MenuBarPopupView: View {
device: device,
isConnecting: audioEngine.bluetoothDeviceMonitor.connectingIDs.contains(device.id),
errorMessage: audioEngine.bluetoothDeviceMonitor.connectionErrors[device.id],
onConnect: {
audioEngine.bluetoothDeviceMonitor.connect(device: device)
}
onConnect: { connectAndSelect(device) }
)
}
}
Expand Down Expand Up @@ -624,10 +629,57 @@ struct MenuBarPopupView: View {
.id(PopupKeyboardNavModel.RowID.device(uid: device.uid))
}

if !isBluetoothOn {
Text("Turn on Bluetooth to connect devices")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.top, DesignTokens.Spacing.xs)
} else {
let connectedNames = Set(sortedDevices.map(\.name))
let filteredPaired = pairedDevices.filter { !connectedNames.contains($0.name) }
if !filteredPaired.isEmpty {
SectionHeader(title: "Paired")
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, DesignTokens.Spacing.xs)

ForEach(filteredPaired) { device in
PairedDeviceRow(
device: device,
isConnecting: audioEngine.bluetoothDeviceMonitor.connectingIDs.contains(device.id),
errorMessage: audioEngine.bluetoothDeviceMonitor.connectionErrors[device.id],
onConnect: { connectAndSelect(device) }
)
}
}
}

}
}
}

private func connectAndSelect(_ device: PairedBluetoothDevice) {
pendingBluetoothOutputs[device.id] = device.name
pendingBluetoothTimeouts[device.id]?.cancel()
pendingBluetoothTimeouts[device.id] = Task {
try? await Task.sleep(for: .seconds(12))
guard !Task.isCancelled else { return }
pendingBluetoothOutputs.removeValue(forKey: device.id)
pendingBluetoothTimeouts.removeValue(forKey: device.id)
}
audioEngine.bluetoothDeviceMonitor.connect(device: device)
}

private func selectPendingBluetoothOutput(from devices: [AudioDevice]) {
guard let match = devices.first(where: { pendingBluetoothOutputs.values.contains($0.name) }),
let pending = pendingBluetoothOutputs.first(where: { $0.value == match.name })
else { return }

pendingBluetoothTimeouts[pending.key]?.cancel()
pendingBluetoothTimeouts.removeValue(forKey: pending.key)
pendingBluetoothOutputs.removeValue(forKey: pending.key)
audioEngine.setDefaultOutputDevice(match.id)
}

/// Builds a single row for the priority-edit list. Extracted from
/// `devicesContent` because the inline expression exceeded Swift's
/// type-check budget once hide + expand + drop-destination were combined.
Expand Down
16 changes: 16 additions & 0 deletions FineTuneTests/BluetoothDeviceDiscoveryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Testing
@testable import FineTune

@Suite("Bluetooth paired device discovery")
struct BluetoothDeviceDiscoveryTests {

@Test("Audio hardware is recognized from its cached device class")
func recognizesAudioHardware() {
#expect(BluetoothDeviceMonitor.isAudioDeviceClass(major: 4))
}

@Test("Non-audio paired peripherals remain excluded", arguments: [UInt32(0), 1, 5])
func rejectsNonAudioHardware(deviceClassMajor: UInt32) {
#expect(!BluetoothDeviceMonitor.isAudioDeviceClass(major: deviceClassMajor))
}
}