diff --git a/FineTune/Audio/Engine/AudioEngine.swift b/FineTune/Audio/Engine/AudioEngine.swift index 062c99f0..12fbb308 100644 --- a/FineTune/Audio/Engine/AudioEngine.swift +++ b/FineTune/Audio/Engine/AudioEngine.swift @@ -169,6 +169,34 @@ final class AudioEngine { } } + /// What an output device's connection should do to the system default and to + /// follows-default app routing. Pure decision so it can be tested in isolation. + enum ConnectedOutputDefaultAction: Equatable { + /// Connected device is the highest-priority *connected* device — ensure it is the + /// system default and re-route follows-default app taps to it. Covers both "not yet + /// default" and "macOS already auto-switched here", since reEvaluateOutputDefault sets + /// the default only when it differs and always re-points the taps. + case ensureHighestPriorityDefault + /// A lower-priority device that macOS auto-switched to — restore the device the user was on. + case restorePrevious + /// Lower-priority device that isn't the current default — leave routing as-is. + case none + } + + static func connectedOutputDefaultAction( + connectedDeviceUID: String, + highestPriorityConnectedUID: String?, + currentDefaultUID: String? + ) -> ConnectedOutputDefaultAction { + if connectedDeviceUID == highestPriorityConnectedUID { + return .ensureHighestPriorityDefault + } + if connectedDeviceUID == currentDefaultUID { + return .restorePrevious + } + return .none + } + init( permission: AudioRecordingPermission? = nil, @@ -1513,11 +1541,11 @@ final class AudioEngine { // the user chose it. We still enter PENDING_AUTOSWITCH to guard against macOS // auto-switching to the new device. let currentDefault = deviceVolumeMonitor.defaultDeviceUID - let isNewDeviceHigherPriority = (deviceUID == Self.resolveHighestPriority( + let highestPriorityUID = Self.resolveHighestPriority( priorityOrder: settingsManager.devicePriorityOrder, connectedDevices: outputDevices, isAlive: isAliveCheck - )?.uid) + )?.uid // If this device is present but not alive, watch for it to become alive if let device = deviceMonitor.device(for: deviceUID), @@ -1525,13 +1553,24 @@ final class AudioEngine { installAliveWatcher(deviceID: device.id, uid: deviceUID, name: deviceName) } - if isNewDeviceHigherPriority, deviceUID != currentDefault { - // A higher-priority device reconnected — switch to it + switch Self.connectedOutputDefaultAction( + connectedDeviceUID: deviceUID, + highestPriorityConnectedUID: highestPriorityUID, + currentDefaultUID: currentDefault + ) { + case .ensureHighestPriorityDefault: + // Highest-priority connected device (re)connected. Ensure it is the system default + // and route follows-default apps to it. reEvaluateOutputDefault sets the default + // only if it differs (a no-op when macOS already auto-switched here) and always + // re-points follows-default taps — fixing the BT-reconnect desync where the system + // default moved to the new device but app audio stayed stranded on the previous one. reEvaluateOutputDefault() - } else if !isNewDeviceHigherPriority, currentDefault == deviceUID { - // macOS already auto-switched to the lower-priority device — restore + case .restorePrevious: + // macOS already auto-switched to a lower-priority device — restore // what the user was on (not highest priority — they may have chosen a mid-priority device) restoreConfirmedDefault() + case .none: + break } // Cancel any existing PENDING_AUTOSWITCH before entering a new one. diff --git a/FineTuneTests/OutputDeviceReconnectRoutingTests.swift b/FineTuneTests/OutputDeviceReconnectRoutingTests.swift new file mode 100644 index 00000000..d93842cd --- /dev/null +++ b/FineTuneTests/OutputDeviceReconnectRoutingTests.swift @@ -0,0 +1,116 @@ +// FineTuneTests/OutputDeviceReconnectRoutingTests.swift +import Testing +import Foundation +@testable import FineTune + +/// Regression coverage for the Bluetooth-reconnect routing desync: +/// when a headset reconnects and macOS auto-switches the system default to it, +/// the highest-priority connected device must be ensured as the default so +/// follows-default app taps are re-routed to it — not left stranded on the +/// previous device. +@Suite("Connected output default action") +@MainActor +struct OutputDeviceReconnectRoutingTests { + + // The bug: headset is highest connected priority AND macOS already made it the + // default. The old code did nothing here, leaving app taps on the old device. + @Test("Highest-priority device already made default by macOS → ensure default + re-route apps") + func highestPriorityAlreadyDefault() { + let action = AudioEngine.connectedOutputDefaultAction( + connectedDeviceUID: "buds", + highestPriorityConnectedUID: "buds", + currentDefaultUID: "buds" + ) + #expect(action == .ensureHighestPriorityDefault) + } + + @Test("Highest-priority device reconnects while default is still the old device → ensure default") + func highestPriorityNotYetDefault() { + let action = AudioEngine.connectedOutputDefaultAction( + connectedDeviceUID: "buds", + highestPriorityConnectedUID: "buds", + currentDefaultUID: "speakers" + ) + #expect(action == .ensureHighestPriorityDefault) + } + + @Test("Highest-priority device connects with no default set yet (nil) → ensure default") + func highestPriorityWithNoCurrentDefault() { + let action = AudioEngine.connectedOutputDefaultAction( + connectedDeviceUID: "buds", + highestPriorityConnectedUID: "buds", + currentDefaultUID: nil + ) + #expect(action == .ensureHighestPriorityDefault) + } + + // The user's real setup: WH-1000XM5 is #1 in the priority list but disconnected, + // Buds4 (#2) and built-in speakers (#3) are connected. The disconnected #1 must be + // skipped so the highest *connected* device (the Buds) is what reconnect logic acts on. + @Test("resolveHighestPriority skips a disconnected #1 and returns the highest connected device") + func highestPrioritySkipsDisconnectedTopDevice() { + let buds = AudioDevice(id: 2, uid: "buds", name: "Buds4 Pro", icon: nil, supportsAutoEQ: false) + let speakers = AudioDevice(id: 3, uid: "speakers", name: "MacBook Pro Speakers", icon: nil, supportsAutoEQ: false) + + // wh1000xm5 (#1) is disconnected → absent from the connected set entirely. + let resolved = AudioEngine.resolveHighestPriority( + priorityOrder: ["wh1000xm5", "buds", "speakers"], + connectedDevices: [buds, speakers], + isAlive: { _ in true } + ) + #expect(resolved?.uid == "buds") + + // …and that resolved device, once macOS makes it default, must be ensured. + let action = AudioEngine.connectedOutputDefaultAction( + connectedDeviceUID: "buds", + highestPriorityConnectedUID: resolved?.uid, + currentDefaultUID: "buds" + ) + #expect(action == .ensureHighestPriorityDefault) + } + + // Protection behaviour must be preserved: a *lower*-priority device that macOS + // hijacked the default to should be reverted to the device the user was on. + @Test("Lower-priority device that macOS auto-switched to → restore previous") + func lowerPriorityHijackRestores() { + let action = AudioEngine.connectedOutputDefaultAction( + connectedDeviceUID: "airpods", + highestPriorityConnectedUID: "wh1000xm5", + currentDefaultUID: "airpods" + ) + #expect(action == .restorePrevious) + } + + @Test("Lower-priority device connects but default unchanged → do nothing") + func lowerPriorityNonDefaultIsNoop() { + let action = AudioEngine.connectedOutputDefaultAction( + connectedDeviceUID: "airpods", + highestPriorityConnectedUID: "wh1000xm5", + currentDefaultUID: "wh1000xm5" + ) + #expect(action == .none) + } + + @Test("No connected devices resolved (nil highest) and not default → do nothing") + func nilHighestNonDefaultIsNoop() { + let action = AudioEngine.connectedOutputDefaultAction( + connectedDeviceUID: "buds", + highestPriorityConnectedUID: nil, + currentDefaultUID: "speakers" + ) + #expect(action == .none) + } + + // Defensive: a live connected device normally makes resolveHighestPriority non-nil + // (its fallback returns any alive device), so nil-highest-while-default shouldn't occur + // in practice — but pin the behaviour so a future refactor can't silently change it. + @Test("Nil highest but device is current default → restore previous") + func nilHighestButIsDefaultRestores() { + let action = AudioEngine.connectedOutputDefaultAction( + connectedDeviceUID: "buds", + highestPriorityConnectedUID: nil, + currentDefaultUID: "buds" + ) + #expect(action == .restorePrevious) + } +}