diff --git a/CHANGELOG.md b/CHANGELOG.md index d4e9936..8ff1e3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ adheres to [Semantic Versioning](https://semver.org) and the ## [Unreleased] +### Added +- **Audio Switcher** now saves device profiles: name the input/output pair you + are on — "Headset", "Desk", "Meeting" — and switch back to it with one click. + Profiles remember devices by their CoreAudio UID rather than the numeric + device id the system recycles across reboots and re-plugs, so a profile keeps + pointing at the headset you saved and never at whatever inherited its number. + A profile whose device is unplugged is shown greyed out with the reason + (Output “Bose QC 35” isn’t connected) and cannot be applied at all, rather + than half-applying and leaving you on a setup you did not ask for. Profiles + can be renamed and deleted in the popover. + ## [0.14.0] — 2026-07-21 ### Added diff --git a/Sources/DMonteCore/AppPreferences.swift b/Sources/DMonteCore/AppPreferences.swift index 8a9a1ec..dd6526f 100644 --- a/Sources/DMonteCore/AppPreferences.swift +++ b/Sources/DMonteCore/AppPreferences.swift @@ -68,6 +68,11 @@ public enum DefaultsKey { public static let volumeMixerStoppedApps = "tool.volumeMixer.stoppedApps" public static let windowManagerShortcuts = "tool.windowManager.shortcuts" public static let audioRouterPresets = "tool.audioRouter.presets" + /// JSON blob of the Audio Switcher's saved input/output device profiles. Not in + /// `register(defaults:)`: `AudioProfileStore.load` already reads "absent" as "no + /// profiles", and a registered placeholder would only be a second thing to keep + /// in sync with the encoder. + public static let audioSwitcherProfiles = "tool.audioSwitcher.profiles" public static let qrTemplate = "tool.qr.template" /// Calendars the user has hidden. Excluded rather than included, so a calendar added later shows diff --git a/Sources/DMonteCore/AudioSwitcherProfiles.swift b/Sources/DMonteCore/AudioSwitcherProfiles.swift new file mode 100644 index 0000000..ab46387 --- /dev/null +++ b/Sources/DMonteCore/AudioSwitcherProfiles.swift @@ -0,0 +1,380 @@ +import CoreAudio +import Foundation + +/// A saved (input, output) pairing the user can re-apply in one click — "Headset", +/// "Desk", "Meeting". +/// +/// Devices are pinned by their CoreAudio UID, never by `AudioDeviceID`. The numeric +/// id is assigned by the HAL as it enumerates hardware and is recycled freely across +/// reboots and re-plugs, so a profile stored by id would eventually point at whatever +/// unrelated device inherited the number — and switching audio to the wrong device is +/// exactly the failure this feature must never produce. Names are stored alongside the +/// UID, but only so a *disconnected* device can still be named in the UI; they play no +/// part in matching. +public struct AudioDeviceProfile: Codable, Identifiable, Sendable, Equatable { + + /// One direction's pinned device: the stable UID plus the name the device had when + /// the profile was captured. + public struct DeviceRef: Codable, Sendable, Equatable { + public let uid: String + public var name: String + + public init(uid: String, name: String) { + self.uid = uid + self.name = name + } + } + + public let id: UUID + public var name: String + /// The pinned output device, or `nil` when the profile leaves playback untouched. + public var output: DeviceRef? + /// The pinned input device, or `nil` when the profile leaves capture untouched. + public var input: DeviceRef? + + public init(id: UUID = UUID(), name: String, output: DeviceRef? = nil, input: DeviceRef? = nil) { + self.id = id + self.name = name + self.output = output + self.input = input + } + + // MARK: - Capture + + /// Builds a profile from a snapshot of the current defaults, or `nil` if there is + /// nothing worth saving. + /// + /// A direction is pinned only when the current default device is present in + /// `devices` *and* exposes a non-empty UID; some virtual drivers publish no UID at + /// all, and a leg pinned to an empty string could later match any other UID-less + /// device. When neither direction survives that check the profile could never + /// resolve to anything, so we refuse to create it rather than persist a permanently + /// broken row. A blank name is refused for the same reason: it produces a row the + /// user cannot identify. + public static func capture( + name: String, + devices: [AudioDevice], + defaultOutputID: AudioDeviceID?, + defaultInputID: AudioDeviceID?, + id: UUID = UUID() + ) -> AudioDeviceProfile? { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let output = deviceRef(for: defaultOutputID, in: devices) + let input = deviceRef(for: defaultInputID, in: devices) + guard output != nil || input != nil else { return nil } + return AudioDeviceProfile(id: id, name: trimmed, output: output, input: input) + } + + private static func deviceRef(for deviceID: AudioDeviceID?, in devices: [AudioDevice]) -> DeviceRef? { + guard let deviceID, + let device = devices.first(where: { $0.id == deviceID }), + !device.uid.isEmpty else { return nil } + return DeviceRef(uid: device.uid, name: device.name) + } + + // MARK: - Resolution + + /// Maps this profile's pinned UIDs onto the devices currently attached. + public func resolve(against devices: [AudioDevice]) -> AudioProfileResolution { + AudioProfileResolution( + output: Self.resolveLeg(output, in: devices, servingDirection: \.hasOutput), + input: Self.resolveLeg(input, in: devices, servingDirection: \.hasInput) + ) + } + + private static func resolveLeg( + _ ref: DeviceRef?, + in devices: [AudioDevice], + servingDirection: KeyPath + ) -> AudioProfileResolution.Leg { + guard let ref else { return .unpinned } + // A stored empty UID can only come from a corrupt or hand-edited blob. Treat it + // as missing: falling through would let it match any UID-less device. + guard !ref.uid.isEmpty else { return .missing(name: ref.name) } + // Match on UID and nothing else. Two identical USB headsets report the same + // name, so a name-based fallback would cheerfully pick the wrong one. + guard let device = devices.first(where: { $0.uid == ref.uid }) else { + return .missing(name: ref.name) + } + // The device is here but may have been reconfigured — an aggregate that lost a + // sub-device, an interface switched to a capture-only mode. Setting it as the + // default for a direction it no longer serves is a silent mis-switch. + guard device[keyPath: servingDirection] else { + return .wrongDirection(name: device.name) + } + return .resolved(deviceID: device.id, name: device.name) + } + + /// Whether this profile describes the defaults that are already in force. Both + /// pinned legs must match; an unpinned leg imposes no constraint because applying + /// the profile would not have touched it. + public func matchesDefaults(outputUID: String?, inputUID: String?) -> Bool { + if let output, output.uid != outputUID { return false } + if let input, input.uid != inputUID { return false } + return output != nil || input != nil + } +} + +/// What a profile's pinned devices map to against the currently attached hardware. +/// +/// This is deliberately richer than a bool: the popover has to explain *why* a profile +/// cannot be used, and "Bose QC isn't connected" is a very different message from +/// "Loopback has no output channels". +public struct AudioProfileResolution: Sendable, Equatable { + + /// The outcome for one direction. + public enum Leg: Sendable, Equatable { + /// The profile pins nothing here; applying it leaves this direction alone. + case unpinned + /// The pinned UID matched a connected device that still serves this direction. + case resolved(deviceID: AudioDeviceID, name: String) + /// No connected device carries the pinned UID. + case missing(name: String) + /// A device with the pinned UID is connected but no longer offers streams in + /// this direction. + case wrongDirection(name: String) + } + + public let output: Leg + public let input: Leg + + public init(output: Leg, input: Leg) { + self.output = output + self.input = input + } + + /// `true` only when every pinned leg resolved. Applying is all-or-nothing: a + /// half-applied "Meeting" that moved playback but left the mic on the laptop is + /// worse than a profile that plainly refuses and says what is missing. + public var isApplicable: Bool { + Self.isSatisfied(output) && Self.isSatisfied(input) + } + + /// The device to make the default output, or `nil` if this direction is unpinned or + /// unresolved. + public var outputDeviceID: AudioDeviceID? { + if case .resolved(let deviceID, _) = output { return deviceID } + return nil + } + + /// The device to make the default input, or `nil` if this direction is unpinned or + /// unresolved. + public var inputDeviceID: AudioDeviceID? { + if case .resolved(let deviceID, _) = input { return deviceID } + return nil + } + + /// A one-line explanation of why the profile cannot be applied, or `nil` when it + /// can. Both directions are reported when both are broken, so unplugging a combined + /// headset does not send the user hunting for a second problem after fixing the + /// first. + public var unavailableReason: String? { + let parts = [ + Self.reason(for: output, direction: .output), + Self.reason(for: input, direction: .input) + ].compactMap { $0 } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + /// "Studio Display · Shure MV7" — the device names for the pinned directions, using + /// the live name when the device is attached so a renamed device reads correctly. + public var deviceSummary: String { + [Self.name(of: output), Self.name(of: input)] + .compactMap { $0 } + .joined(separator: " · ") + } + + // MARK: - Private + + private static func isSatisfied(_ leg: Leg) -> Bool { + switch leg { + case .unpinned, .resolved: return true + case .missing, .wrongDirection: return false + } + } + + private enum Direction: String { + case output = "Output" + case input = "Input" + + var channelNoun: String { + switch self { + case .output: return "output" + case .input: return "input" + } + } + } + + private static func reason(for leg: Leg, direction: Direction) -> String? { + switch leg { + case .unpinned, .resolved: + return nil + case .missing(let name): + return "\(direction.rawValue) “\(name)” isn’t connected" + case .wrongDirection(let name): + return "\(direction.rawValue) “\(name)” has no \(direction.channelNoun) channels" + } + } + + private static func name(of leg: Leg) -> String? { + switch leg { + case .unpinned: + return nil + case .resolved(_, let name), .missing(let name), .wrongDirection(let name): + return name + } + } +} + +/// What came of applying a profile. +/// +/// Richer than a bool because a resolved profile can still fail to apply: CoreAudio +/// refuses `kAudioHardwarePropertyDefaultInputDevice` for some aggregates and virtual +/// drivers that nonetheless report streams, and a device can be unplugged in the window +/// between resolution and the write. Reporting either as success would leave playback +/// moved, capture where it was, and nothing on screen to explain it. +public enum AudioProfileApplyOutcome: Sendable, Equatable { + /// Every pinned direction was written. + case applied + /// Nothing was attempted: a pinned device is absent or no longer serves its + /// direction. `reason` is the same text the disabled row carries. + case notApplicable(reason: String) + /// The HAL refused at least one write. `restored` is `true` when the defaults are + /// back where they started — either nothing landed, or the half that did was put + /// back — and `false` when that rollback was refused too, so the defaults really are + /// mixed and the user has to be told. + case refused(message: String, restored: Bool) +} + +/// Writes a resolved profile to the system defaults. +/// +/// The two CoreAudio writes are injected rather than called directly so the paths that +/// matter — a write the HAL refuses, and the rollback that follows it — are testable +/// headlessly. No CI machine has hardware that rejects a default-device write on cue. +public enum AudioProfileApplier { + + /// Applies both pinned directions, undoing whichever one landed if the other was + /// refused. The setters return `false` when CoreAudio rejects the write. + /// + /// The rollback is the whole point: a half-applied "Meeting" that moved playback to + /// the headset but left capture on the laptop mic is a configuration the user never + /// asked for and never saw happen, which is exactly what the all-or-nothing promise + /// in `AudioProfileResolution.isApplicable` exists to prevent. + public static func apply( + _ resolution: AudioProfileResolution, + currentOutputID: AudioDeviceID?, + currentInputID: AudioDeviceID?, + setDefaultOutput: (AudioDeviceID) -> Bool, + setDefaultInput: (AudioDeviceID) -> Bool + ) -> AudioProfileApplyOutcome { + guard resolution.isApplicable else { + return .notApplicable( + reason: resolution.unavailableReason ?? "This profile has nothing left to switch to." + ) + } + + var refusals: [String] = [] + var outputWritten = false + var inputWritten = false + + if case .resolved(let deviceID, let name) = resolution.output { + if setDefaultOutput(deviceID) { + outputWritten = true + } else { + refusals.append(refusal(name: name, direction: "output")) + } + } + if case .resolved(let deviceID, let name) = resolution.input { + if setDefaultInput(deviceID) { + inputWritten = true + } else { + refusals.append(refusal(name: name, direction: "input")) + } + } + guard !refusals.isEmpty else { return .applied } + + // A direction that had no previous default cannot be put back at all, so it + // counts as an unrestored change just like a rollback the HAL refuses. + var restored = true + if outputWritten { + let undone = currentOutputID.map { setDefaultOutput($0) } ?? false + if !undone { restored = false } + } + if inputWritten { + let undone = currentInputID.map { setDefaultInput($0) } ?? false + if !undone { restored = false } + } + + let detail = refusals.joined(separator: " · ") + let epilogue = restored + ? "Nothing was changed." + : "Part of the switch could not be undone — check Sound in System Settings." + return .refused(message: "\(detail). \(epilogue)", restored: restored) + } + + private static func refusal(name: String, direction: String) -> String { + "macOS wouldn’t make “\(name)” the default \(direction)" + } +} + +/// Persists the user's saved device profiles as JSON in `UserDefaults`. +/// +/// Every operation is pure with respect to CoreAudio and fully unit-testable. Mutators +/// return the resulting list so a caller holding an `@Published` copy stays in sync +/// without a second read of the defaults. +public enum AudioProfileStore { + private static let key = DefaultsKey.audioSwitcherProfiles + + /// All saved profiles, in insertion order. Returns `[]` when nothing is stored or + /// the stored blob is unreadable — a corrupt value is treated as "no profiles" + /// rather than crashing a menu-bar helper at launch. + public static func load(defaults: UserDefaults) -> [AudioDeviceProfile] { + guard let data = defaults.data(forKey: key), + let profiles = try? JSONDecoder().decode([AudioDeviceProfile].self, from: data) else { + return [] + } + return profiles + } + + /// Overwrites the stored profiles with `profiles`. + public static func save(_ profiles: [AudioDeviceProfile], defaults: UserDefaults) { + guard let data = try? JSONEncoder().encode(profiles) else { return } + defaults.set(data, forKey: key) + } + + /// Appends a new profile, or replaces the existing one with the same `id` in place + /// so an edit does not reorder the list under the user's cursor. + @discardableResult + public static func upsert(_ profile: AudioDeviceProfile, defaults: UserDefaults) -> [AudioDeviceProfile] { + var profiles = load(defaults: defaults) + if let index = profiles.firstIndex(where: { $0.id == profile.id }) { + profiles[index] = profile + } else { + profiles.append(profile) + } + save(profiles, defaults: defaults) + return profiles + } + + /// Renames a profile. A blank name is rejected and nothing is written: an unnamed + /// row is unidentifiable, and silently accepting it would lose the old name too. + @discardableResult + public static func rename(id: UUID, to newName: String, defaults: UserDefaults) -> [AudioDeviceProfile] { + var profiles = load(defaults: defaults) + let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let index = profiles.firstIndex(where: { $0.id == id }) else { return profiles } + profiles[index].name = trimmed + save(profiles, defaults: defaults) + return profiles + } + + /// Removes the profile with the given `id`, if present. + @discardableResult + public static func remove(id: UUID, defaults: UserDefaults) -> [AudioDeviceProfile] { + let profiles = load(defaults: defaults).filter { $0.id != id } + save(profiles, defaults: defaults) + return profiles + } +} diff --git a/Sources/DMonteCore/AudioSwitcherSizing.swift b/Sources/DMonteCore/AudioSwitcherSizing.swift index 5972366..99de954 100644 --- a/Sources/DMonteCore/AudioSwitcherSizing.swift +++ b/Sources/DMonteCore/AudioSwitcherSizing.swift @@ -23,9 +23,11 @@ public enum AudioSwitcherSizing { NSSize(width: panelWidth.rounded(), height: panelHeight.rounded()) } - // Base canvas + // Base canvas. The extra 60pt over the original 460 is the profiles section: the + // chrome outside the scroll view is unchanged, so `scrollMaxHeight` grew by the + // same amount. public static var panelWidth: CGFloat { s(340) } - public static var panelHeight: CGFloat { s(460) } + public static var panelHeight: CGFloat { s(520) } // Spacing scale public static var outerPadding: CGFloat { s(16) } @@ -47,5 +49,8 @@ public enum AudioSwitcherSizing { // Controls public static var iconButtonSize: CGFloat { s(28) } public static var checkmarkSize: CGFloat { s(13) } - public static var scrollMaxHeight: CGFloat { s(240) } + public static var scrollMaxHeight: CGFloat { s(300) } + /// Trailing rename/delete glyphs on a profile row. Smaller than `checkmarkSize` so + /// they read as secondary actions next to the row's primary "apply" tap target. + public static var profileActionSize: CGFloat { s(11) } } diff --git a/Sources/DMonteCore/AudioSwitcherView.swift b/Sources/DMonteCore/AudioSwitcherView.swift index 8f6d792..3194276 100644 --- a/Sources/DMonteCore/AudioSwitcherView.swift +++ b/Sources/DMonteCore/AudioSwitcherView.swift @@ -7,14 +7,23 @@ import SwiftUI /// Refreshes live when audio hardware is added or removed. @MainActor public final class AudioSwitcherController: ObservableObject { - @Published public private(set) var outputDevices: [AudioDeviceKit.AudioDevice] = [] - @Published public private(set) var inputDevices: [AudioDeviceKit.AudioDevice] = [] + /// Every attached device, each carrying its stable UID. Profiles are resolved + /// against this list, so it is the one source of truth; the per-direction lists + /// below are filtered views of it rather than separately published state that + /// could drift. + @Published public private(set) var devices: [AudioDevice] = [] @Published public private(set) var defaultOutputID: AudioDeviceID? @Published public private(set) var defaultInputID: AudioDeviceID? @Published public var volume: Float = 0 @Published public private(set) var volumeSupported: Bool = false @Published public var isMuted: Bool = false @Published public private(set) var muteSupported: Bool = false + @Published public private(set) var profiles: [AudioDeviceProfile] = [] + + public var outputDevices: [AudioDevice] { devices.filter { $0.hasOutput } } + public var inputDevices: [AudioDevice] { devices.filter { $0.hasInput } } + + private let defaults: UserDefaults /// True while the user is dragging the volume slider. Our own `setVolume` /// fires the HAL volume listener, whose refresh would otherwise stamp the @@ -30,6 +39,7 @@ public final class AudioSwitcherController: ObservableObject { private let outputStateListener: DefaultDeviceStateListener public init() { + defaults = AppDefaults.shared // Install the listeners before `self` is fully initialized by capturing // only plain functions, then refresh on the main actor. listeners = [ @@ -52,6 +62,7 @@ public final class AudioSwitcherController: ObservableObject { AudioSwitcherController.notifyOutputStateChanged() } } + profiles = AudioProfileStore.load(defaults: defaults) refresh() AudioSwitcherController.activeController = self } @@ -78,9 +89,13 @@ public final class AudioSwitcherController: ObservableObject { /// Reloads the full device list, defaults, and volume/mute state. public func refresh() { - let all = AudioDeviceKit.allDevices() - outputDevices = all.filter { $0.hasOutput } - inputDevices = all.filter { $0.hasInput } + // Enumerated through `AudioSwitcherKit` rather than `AudioDeviceKit` because + // profiles need each device's UID, and only the former reads it. Guarded on an + // actual change for the same reason `refreshVolumeAndMute` is: the HAL fires the + // default-device notification liberally, and republishing an identical list just + // re-renders the popover. + let all = AudioSwitcherKit.devices() + if devices != all { devices = all } defaultOutputID = AudioDeviceKit.defaultOutputDeviceID() defaultInputID = AudioDeviceKit.defaultInputDeviceID() // Volume/mute are properties of the default output device, so the @@ -125,16 +140,82 @@ public final class AudioSwitcherController: ObservableObject { } } - public func selectOutput(_ device: AudioDeviceKit.AudioDevice) { + public func selectOutput(_ device: AudioDevice) { AudioDeviceKit.setDefaultOutput(device.id) refresh() } - public func selectInput(_ device: AudioDeviceKit.AudioDevice) { + public func selectInput(_ device: AudioDevice) { AudioDeviceKit.setDefaultInput(device.id) refresh() } + // MARK: - Profiles + + /// Captures the live defaults as a named profile. Returns `nil` — and saves nothing + /// — when the name is blank or neither current default exposes a UID we could pin + /// to, so the caller can say why instead of showing a row that never works. + @discardableResult + public func saveProfile(named name: String) -> AudioDeviceProfile? { + guard let profile = AudioDeviceProfile.capture( + name: name, + devices: devices, + defaultOutputID: defaultOutputID, + defaultInputID: defaultInputID + ) else { return nil } + profiles = AudioProfileStore.upsert(profile, defaults: defaults) + return profile + } + + /// How `profile` maps onto the hardware attached right now. + public func resolution(for profile: AudioDeviceProfile) -> AudioProfileResolution { + profile.resolve(against: devices) + } + + /// Applies both halves of a profile, then re-reads state. + /// + /// Nothing is written when a pinned device is absent, and whichever half landed is + /// put back when the HAL refuses the other: switching only the half that worked + /// would leave the user on a configuration they never asked for. The outcome is + /// returned rather than swallowed because a refused write looks identical to a + /// successful one from the popover — the row stays enabled and the click reads as a + /// no-op — unless the controller says so. + @discardableResult + public func applyProfile(_ profile: AudioDeviceProfile) -> AudioProfileApplyOutcome { + let outcome = AudioProfileApplier.apply( + resolution(for: profile), + currentOutputID: defaultOutputID, + currentInputID: defaultInputID, + setDefaultOutput: AudioDeviceKit.setDefaultOutput, + setDefaultInput: AudioDeviceKit.setDefaultInput + ) + refresh() + return outcome + } + + public func renameProfile(id: UUID, to newName: String) { + profiles = AudioProfileStore.rename(id: id, to: newName, defaults: defaults) + } + + public func deleteProfile(id: UUID) { + profiles = AudioProfileStore.remove(id: id, defaults: defaults) + } + + /// The profile that already describes the live defaults, if any, so the popover can + /// mark it the way it marks the selected device rows. + public var activeProfileID: UUID? { + let outputUID = uid(of: defaultOutputID) + let inputUID = uid(of: defaultInputID) + return profiles.first { $0.matchesDefaults(outputUID: outputUID, inputUID: inputUID) }?.id + } + + /// The stable UID of a live device id. An empty UID is reported as `nil`: it cannot + /// identify anything, so it must not compare equal to another UID-less device. + private func uid(of deviceID: AudioDeviceID?) -> String? { + guard let deviceID, let device = devices.first(where: { $0.id == deviceID }) else { return nil } + return device.uid.isEmpty ? nil : device.uid + } + /// Applies a slider value to the current default output device. public func applyVolume(_ value: Float) { guard let outputID = defaultOutputID, volumeSupported else { return } @@ -164,6 +245,27 @@ public struct AudioSwitcherPopoverView: View { @StateObject private var controller = AudioSwitcherController() private let onQuit: () -> Void + /// Non-`nil` while the "save current setup" field is open; holds the draft name. + @State private var draftProfileName: String? + /// The profile being renamed, plus its draft name. + @State private var renamingProfileID: UUID? + @State private var renameText: String = "" + /// Why the last save was refused, e.g. no addressable device to pin to. + @State private var saveRefusalReason: String? + /// Why the last apply did not take: a device that vanished between the row being + /// drawn and the click, or a write CoreAudio simply refused. Without this the row + /// looks applicable, the click changes nothing, and the popover says nothing. + @State private var applyFailureReason: String? + /// Delete is armed on first click and performed on the second. A profile takes real + /// effort to reconstruct, and the trash sits next to the row's apply target. + @State private var pendingDeleteProfileID: UUID? + @FocusState private var focusedField: ProfileField? + + private enum ProfileField: Hashable { + case newProfile + case rename(UUID) + } + public init(onQuit: @escaping () -> Void) { self.onQuit = onQuit } @@ -184,7 +286,18 @@ public struct AudioSwitcherPopoverView: View { // the controller is a `@StateObject` that outlives an open/close cycle, volume would stop // tracking the hardware for the rest of the process. Releasing it here also re-reads the // device, so the next open shows the true level rather than the value the drag left behind. - .onDisappear { controller.setVolumeEditing(false) } + .onDisappear { + controller.setVolumeEditing(false) + // The popover is a long-lived view that is merely hidden, so an abandoned + // name field or a half-armed delete would still be sitting there on the next + // open — including a red trash the user could hit by accident. + draftProfileName = nil + renamingProfileID = nil + saveRefusalReason = nil + applyFailureReason = nil + pendingDeleteProfileID = nil + focusedField = nil + } } // MARK: - Header @@ -214,6 +327,7 @@ public struct AudioSwitcherPopoverView: View { ScrollView { VStack(alignment: .leading, spacing: AudioSwitcherSizing.sectionSpacing) { volumeSection + profilesSection outputSection inputSection } @@ -268,6 +382,256 @@ public struct AudioSwitcherPopoverView: View { } } + // MARK: - Profiles + + private var profilesSection: some View { + VStack(alignment: .leading, spacing: AudioSwitcherSizing.rowSpacing) { + HStack(spacing: 6) { + sectionHeader("PROFILES") + Spacer() + Button(action: beginSavingProfile) { + Image(systemName: "plus.circle") + .font(.system(size: AudioSwitcherSizing.bodySize, weight: .medium)) + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + .help("Save the current input and output as a profile") + } + + if draftProfileName != nil { + profileNameField( + text: Binding( + get: { draftProfileName ?? "" }, + set: { draftProfileName = $0 } + ), + field: .newProfile, + placeholder: "Profile name", + onCommit: commitNewProfile, + onCancel: { draftProfileName = nil; saveRefusalReason = nil } + ) + } + + if let saveRefusalReason { + Text(saveRefusalReason) + .font(.system(size: AudioSwitcherSizing.captionSize)) + .foregroundStyle(Color.orange) + .fixedSize(horizontal: false, vertical: true) + } + + if controller.profiles.isEmpty { + if draftProfileName == nil { + emptyRow("No saved profiles") + } + } else { + ForEach(controller.profiles) { profile in + profileRow(profile) + } + } + + if let applyFailureReason { + Text(applyFailureReason) + .font(.system(size: AudioSwitcherSizing.captionSize)) + .foregroundStyle(Color.orange) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + @ViewBuilder + private func profileRow(_ profile: AudioDeviceProfile) -> some View { + if renamingProfileID == profile.id { + profileNameField( + text: $renameText, + field: .rename(profile.id), + placeholder: profile.name, + onCommit: { commitRename(of: profile) }, + onCancel: { renamingProfileID = nil } + ) + } else { + let resolution = controller.resolution(for: profile) + HStack(spacing: 6) { + Button(action: { apply(profile) }) { + profileRowLabel(profile, resolution: resolution) + } + .buttonStyle(.plain) + .disabled(!resolution.isApplicable) + .help(resolution.unavailableReason ?? "Switch to “\(profile.name)”") + + profileActionButton(symbol: "pencil", tint: .secondary, help: "Rename") { + pendingDeleteProfileID = nil + applyFailureReason = nil + renameText = profile.name + // Focus is not requested here: the field does not exist yet in this + // update, so the request would be dropped. `profileNameField` asks + // for it once the field has actually appeared. + renamingProfileID = profile.id + } + profileActionButton( + symbol: pendingDeleteProfileID == profile.id ? "trash.fill" : "trash", + tint: pendingDeleteProfileID == profile.id ? .red : .secondary, + help: pendingDeleteProfileID == profile.id ? "Click again to delete" : "Delete profile" + ) { + if pendingDeleteProfileID == profile.id { + controller.deleteProfile(id: profile.id) + pendingDeleteProfileID = nil + } else { + pendingDeleteProfileID = profile.id + } + } + } + } + } + + private func profileRowLabel( + _ profile: AudioDeviceProfile, + resolution: AudioProfileResolution + ) -> some View { + let isActive = controller.activeProfileID == profile.id + return HStack(spacing: 8) { + Image(systemName: isActive ? "checkmark.circle.fill" : profileIconName(for: resolution)) + .font(.system(size: AudioSwitcherSizing.checkmarkSize, weight: .medium)) + .foregroundStyle(profileIconColor(isActive: isActive, resolution: resolution)) + VStack(alignment: .leading, spacing: 1) { + Text(profile.name) + .font(.system(size: AudioSwitcherSizing.bodySize, weight: isActive ? .semibold : .regular)) + .foregroundStyle(resolution.isApplicable ? Color.primary : Color.secondary) + .lineLimit(1) + .truncationMode(.middle) + Text(resolution.unavailableReason ?? resolution.deviceSummary) + .font(.system(size: AudioSwitcherSizing.captionSize)) + .foregroundStyle(resolution.isApplicable ? Color.secondary : Color.orange) + .lineLimit(2) + .truncationMode(.middle) + } + Spacer(minLength: 0) + } + .padding(.vertical, AudioSwitcherSizing.rowVerticalPadding) + .padding(.horizontal, AudioSwitcherSizing.rowHorizontalPadding) + .frame(minHeight: AudioSwitcherSizing.rowMinHeight) + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackground(isSelected: isActive)) + } + + private func profileIconName(for resolution: AudioProfileResolution) -> String { + resolution.isApplicable ? "square.stack.3d.up" : "exclamationmark.triangle" + } + + private func profileIconColor(isActive: Bool, resolution: AudioProfileResolution) -> Color { + if isActive { return .accentColor } + return resolution.isApplicable ? .secondary : .orange + } + + private func profileActionButton( + symbol: String, + tint: Color, + help: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: AudioSwitcherSizing.profileActionSize, weight: .medium)) + .foregroundStyle(tint) + .frame(width: AudioSwitcherSizing.iconButtonSize * 0.7, height: AudioSwitcherSizing.rowMinHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(help) + } + + private func profileNameField( + text: Binding, + field: ProfileField, + placeholder: String, + onCommit: @escaping () -> Void, + onCancel: @escaping () -> Void + ) -> some View { + HStack(spacing: 6) { + TextField(placeholder, text: text) + .textFieldStyle(.roundedBorder) + .font(.system(size: AudioSwitcherSizing.bodySize)) + .focused($focusedField, equals: field) + .onSubmit(onCommit) + // SwiftUI evaluates `.focused(_:equals:)` as it inserts the field, so a + // focus request made in the *same* state update that creates the field + // targets a view that does not exist yet and is quietly dropped: the + // user hits +, sees a pre-filled box, and types into nothing until they + // click it — and Return does not commit either. Asking after the field + // has appeared, one runloop turn later, is a request SwiftUI honours. + .onAppear { + Task { @MainActor in focusedField = field } + } + Button(action: onCommit) { + Image(systemName: "checkmark") + .font(.system(size: AudioSwitcherSizing.profileActionSize, weight: .semibold)) + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + .help("Save") + Button(action: onCancel) { + Image(systemName: "xmark") + .font(.system(size: AudioSwitcherSizing.profileActionSize, weight: .semibold)) + .foregroundStyle(Color.secondary) + } + .buttonStyle(.plain) + .help("Cancel") + } + } + + private func beginSavingProfile() { + pendingDeleteProfileID = nil + renamingProfileID = nil + saveRefusalReason = nil + applyFailureReason = nil + // Focus is deliberately not set here — see `profileNameField`, which asks for it + // once the field it belongs to actually exists. + draftProfileName = suggestedProfileName() + } + + /// Switches to `profile` and surfaces anything that stopped the switch from taking. + private func apply(_ profile: AudioDeviceProfile) { + pendingDeleteProfileID = nil + switch controller.applyProfile(profile) { + case .applied: + applyFailureReason = nil + case .notApplicable(let reason): + applyFailureReason = reason + case .refused(let message, _): + applyFailureReason = message + } + } + + /// Seeds the field with the current output's name — the thing the user is most + /// likely to be naming the profile after — falling back to a numbered default. + private func suggestedProfileName() -> String { + if let output = controller.devices.first(where: { $0.id == controller.defaultOutputID }), + !output.name.isEmpty, + !controller.profiles.contains(where: { $0.name == output.name }) { + return output.name + } + return "Profile \(controller.profiles.count + 1)" + } + + private func commitNewProfile() { + guard let name = draftProfileName else { return } + if controller.saveProfile(named: name) != nil { + draftProfileName = nil + saveRefusalReason = nil + focusedField = nil + } else { + saveRefusalReason = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "Give the profile a name." + : "Nothing to save: the current devices report no stable identifier." + } + } + + private func commitRename(of profile: AudioDeviceProfile) { + controller.renameProfile(id: profile.id, to: renameText) + renamingProfileID = nil + focusedField = nil + } + + // MARK: - Devices + private var outputSection: some View { VStack(alignment: .leading, spacing: AudioSwitcherSizing.rowSpacing) { sectionHeader("OUTPUT") @@ -319,7 +683,7 @@ public struct AudioSwitcherPopoverView: View { } private func deviceRow( - device: AudioDeviceKit.AudioDevice, + device: AudioDevice, isSelected: Bool, action: @escaping () -> Void ) -> some View { diff --git a/Tests/DMonteCoreTests/AudioSwitcherProfilesTests.swift b/Tests/DMonteCoreTests/AudioSwitcherProfilesTests.swift new file mode 100644 index 0000000..1d5527f --- /dev/null +++ b/Tests/DMonteCoreTests/AudioSwitcherProfilesTests.swift @@ -0,0 +1,542 @@ +import CoreAudio +import XCTest +@testable import DMonteCore + +/// Pure tests for the Audio Switcher's device profiles. Nothing here touches CoreAudio: +/// capture, resolution, and persistence all operate on a supplied device list and a +/// throwaway `UserDefaults` suite, so the whole feature is exercised headlessly and the +/// user's real audio configuration is never read or written. +final class AudioSwitcherProfilesTests: XCTestCase { + + // MARK: - Fixtures + + private func device( + id: AudioDeviceID, + name: String, + uid: String, + hasOutput: Bool = false, + hasInput: Bool = false + ) -> AudioDevice { + AudioDevice(id: id, name: name, uid: uid, hasOutput: hasOutput, hasInput: hasInput) + } + + /// A throwaway defaults suite, torn down after the test so nothing leaks into the + /// shared suite the shipping app reads. + private func makeDefaults() -> UserDefaults { + let suiteName = "AudioSwitcherProfilesTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + preconditionFailure("Could not create a test defaults suite") + } + addTeardownBlock { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) } + return defaults + } + + // MARK: - Capture + + func testCapturePinsUIDsRatherThanDeviceIDs() throws { + let devices = [ + device(id: 41, name: "Studio Display", uid: "AppleDisplay:5F2A", hasOutput: true), + device(id: 42, name: "Shure MV7", uid: "USB:MV7-8891", hasInput: true) + ] + + let profile = try XCTUnwrap( + AudioDeviceProfile.capture( + name: "Desk", + devices: devices, + defaultOutputID: 41, + defaultInputID: 42 + ) + ) + + XCTAssertEqual(profile.name, "Desk") + XCTAssertEqual(profile.output?.uid, "AppleDisplay:5F2A") + XCTAssertEqual(profile.output?.name, "Studio Display") + XCTAssertEqual(profile.input?.uid, "USB:MV7-8891") + XCTAssertEqual(profile.input?.name, "Shure MV7") + } + + func testCaptureTrimsTheNameAndRefusesABlankOne() { + let devices = [device(id: 1, name: "Speakers", uid: "built-in", hasOutput: true)] + + XCTAssertEqual( + AudioDeviceProfile.capture(name: " Desk ", devices: devices, defaultOutputID: 1, defaultInputID: nil)?.name, + "Desk" + ) + XCTAssertNil( + AudioDeviceProfile.capture(name: " ", devices: devices, defaultOutputID: 1, defaultInputID: nil) + ) + } + + func testCaptureLeavesADirectionUnpinnedWhenThereIsNoDefaultForIt() throws { + let devices = [device(id: 1, name: "Speakers", uid: "built-in", hasOutput: true)] + + let profile = try XCTUnwrap( + AudioDeviceProfile.capture(name: "Speakers only", devices: devices, defaultOutputID: 1, defaultInputID: nil) + ) + + XCTAssertNotNil(profile.output) + XCTAssertNil(profile.input, "A direction with no current default must stay unpinned") + } + + func testCaptureRefusesWhenNoDefaultDeviceExposesAUID() { + // Some virtual drivers publish no kAudioDevicePropertyDeviceUID at all. A leg + // pinned to "" could later match any other UID-less device, so nothing is saved. + let devices = [device(id: 7, name: "Nameless Virtual", uid: "", hasOutput: true, hasInput: true)] + + XCTAssertNil( + AudioDeviceProfile.capture(name: "Broken", devices: devices, defaultOutputID: 7, defaultInputID: 7) + ) + } + + func testCaptureRefusesWhenTheDefaultIsNotInTheDeviceList() { + let devices = [device(id: 1, name: "Speakers", uid: "built-in", hasOutput: true)] + + XCTAssertNil( + AudioDeviceProfile.capture(name: "Ghost", devices: devices, defaultOutputID: 99, defaultInputID: 99) + ) + } + + // MARK: - Resolution + + func testResolutionFollowsTheUIDWhenDeviceIDsAreReassigned() throws { + let atCaptureTime = [ + device(id: 41, name: "Studio Display", uid: "AppleDisplay:5F2A", hasOutput: true), + device(id: 42, name: "Shure MV7", uid: "USB:MV7-8891", hasInput: true) + ] + let profile = try XCTUnwrap( + AudioDeviceProfile.capture(name: "Desk", devices: atCaptureTime, defaultOutputID: 41, defaultInputID: 42) + ) + + // After a reboot the HAL hands out different ids, and 41 now belongs to an + // entirely unrelated device. Matching by id would route audio to the AirPods. + let afterReboot = [ + device(id: 41, name: "AirPods Pro", uid: "BT:AIRPODS-0001", hasOutput: true, hasInput: true), + device(id: 88, name: "Studio Display", uid: "AppleDisplay:5F2A", hasOutput: true), + device(id: 90, name: "Shure MV7", uid: "USB:MV7-8891", hasInput: true) + ] + + let resolution = profile.resolve(against: afterReboot) + + XCTAssertTrue(resolution.isApplicable) + XCTAssertEqual(resolution.outputDeviceID, 88) + XCTAssertEqual(resolution.inputDeviceID, 90) + XCTAssertNil(resolution.unavailableReason) + } + + func testResolutionDistinguishesTwoDevicesSharingAName() throws { + // Two identical USB headsets: same product name, different UIDs. Any + // name-based fallback in resolution would pick whichever came first. + let devices = [ + device(id: 10, name: "USB Audio Device", uid: "USB:SERIAL-AAA", hasOutput: true, hasInput: true), + device(id: 11, name: "USB Audio Device", uid: "USB:SERIAL-BBB", hasOutput: true, hasInput: true) + ] + let profile = try XCTUnwrap( + AudioDeviceProfile.capture(name: "Second headset", devices: devices, defaultOutputID: 11, defaultInputID: 11) + ) + XCTAssertEqual(profile.output?.uid, "USB:SERIAL-BBB") + + let resolution = profile.resolve(against: devices) + + XCTAssertEqual(resolution.outputDeviceID, 11) + XCTAssertEqual(resolution.inputDeviceID, 11) + + // And with the two swapped in the list, it still follows the UID. + let reordered = [devices[1], devices[0]] + XCTAssertEqual(profile.resolve(against: reordered).outputDeviceID, 11) + } + + func testResolutionReportsAMissingDeviceByNameAndBlocksApplication() { + let profile = AudioDeviceProfile( + name: "Meeting", + output: .init(uid: "BT:QC35-77", name: "Bose QC 35"), + input: .init(uid: "USB:MV7-8891", name: "Shure MV7") + ) + let devices = [device(id: 3, name: "Shure MV7", uid: "USB:MV7-8891", hasInput: true)] + + let resolution = profile.resolve(against: devices) + + XCTAssertFalse(resolution.isApplicable) + XCTAssertNil(resolution.outputDeviceID, "An unresolved leg must not offer a device to switch to") + XCTAssertEqual(resolution.inputDeviceID, 3) + let reason = resolution.unavailableReason + XCTAssertNotNil(reason) + XCTAssertTrue(reason?.contains("Bose QC 35") == true, "Reason should name the missing device: \(reason ?? "nil")") + XCTAssertTrue(reason?.contains("Output") == true, "Reason should say which direction is broken: \(reason ?? "nil")") + } + + func testResolutionRejectsADeviceThatNoLongerServesTheDirection() { + // The aggregate is still attached under the same UID but lost its output + // sub-device; making it the default output would silently kill playback. + let profile = AudioDeviceProfile( + name: "Rig", + output: .init(uid: "AGG:9001", name: "Studio Aggregate") + ) + let devices = [device(id: 5, name: "Studio Aggregate", uid: "AGG:9001", hasOutput: false, hasInput: true)] + + let resolution = profile.resolve(against: devices) + + XCTAssertFalse(resolution.isApplicable) + XCTAssertNil(resolution.outputDeviceID) + XCTAssertEqual(resolution.unavailableReason, "Output “Studio Aggregate” has no output channels") + } + + func testResolutionTreatsAnEmptyStoredUIDAsMissing() { + // Only a corrupt or hand-edited blob can produce this, but "" must never match + // a UID-less device that happens to be attached. + let profile = AudioDeviceProfile(name: "Corrupt", output: .init(uid: "", name: "Ghost")) + let devices = [device(id: 2, name: "Nameless Virtual", uid: "", hasOutput: true)] + + let resolution = profile.resolve(against: devices) + + XCTAssertFalse(resolution.isApplicable) + XCTAssertNil(resolution.outputDeviceID) + } + + func testResolutionReportsBothBrokenLegsAtOnce() { + let profile = AudioDeviceProfile( + name: "Headset", + output: .init(uid: "BT:QC35-77", name: "Bose QC 35"), + input: .init(uid: "BT:QC35-77-in", name: "Bose QC 35 Mic") + ) + + let reason = profile.resolve(against: []).unavailableReason + + XCTAssertTrue(reason?.contains("Bose QC 35”") == true, "Missing output should be named: \(reason ?? "nil")") + XCTAssertTrue(reason?.contains("Bose QC 35 Mic") == true, "Missing input should be named: \(reason ?? "nil")") + } + + func testAnUnpinnedDirectionDoesNotBlockApplication() { + let profile = AudioDeviceProfile(name: "Speakers only", output: .init(uid: "built-in", name: "Speakers")) + let devices = [device(id: 1, name: "Speakers", uid: "built-in", hasOutput: true)] + + let resolution = profile.resolve(against: devices) + + XCTAssertTrue(resolution.isApplicable) + XCTAssertEqual(resolution.outputDeviceID, 1) + XCTAssertNil(resolution.inputDeviceID, "An unpinned direction must be left alone, not switched") + XCTAssertEqual(resolution.input, .unpinned) + } + + func testDeviceSummaryPrefersTheLiveNameOverTheCapturedOne() { + let profile = AudioDeviceProfile( + name: "Desk", + output: .init(uid: "AppleDisplay:5F2A", name: "Studio Display"), + input: .init(uid: "USB:MV7-8891", name: "Shure MV7") + ) + let devices = [ + device(id: 1, name: "Studio Display (Office)", uid: "AppleDisplay:5F2A", hasOutput: true), + device(id: 2, name: "Shure MV7", uid: "USB:MV7-8891", hasInput: true) + ] + + XCTAssertEqual(profile.resolve(against: devices).deviceSummary, "Studio Display (Office) · Shure MV7") + } + + func testEmptyDeviceListLeavesEveryPinnedLegMissing() { + let profile = AudioDeviceProfile( + name: "Desk", + output: .init(uid: "a", name: "A"), + input: .init(uid: "b", name: "B") + ) + + let resolution = profile.resolve(against: []) + + XCTAssertEqual(resolution.output, .missing(name: "A")) + XCTAssertEqual(resolution.input, .missing(name: "B")) + XCTAssertFalse(resolution.isApplicable) + } + + // MARK: - Applying + + /// Records every default-device write and answers each one from a scripted list of + /// results, so a HAL that accepts the output write and refuses the input one — the + /// case no CI machine can produce on demand — is exercised headlessly. + private final class FakeHAL { + private(set) var outputWrites: [AudioDeviceID] = [] + private(set) var inputWrites: [AudioDeviceID] = [] + var outputResults: [Bool] + var inputResults: [Bool] + + init(outputResults: [Bool] = [], inputResults: [Bool] = []) { + self.outputResults = outputResults + self.inputResults = inputResults + } + + /// Later writes (the rollback) reuse the last scripted result, so a test only has + /// to say what the interesting first write does. + func setOutput(_ id: AudioDeviceID) -> Bool { + outputWrites.append(id) + return next(&outputResults) + } + + func setInput(_ id: AudioDeviceID) -> Bool { + inputWrites.append(id) + return next(&inputResults) + } + + private func next(_ results: inout [Bool]) -> Bool { + guard let first = results.first else { return true } + if results.count > 1 { results.removeFirst() } + return first + } + } + + private func applyResolved( + output: AudioDeviceID?, + input: AudioDeviceID?, + currentOutputID: AudioDeviceID?, + currentInputID: AudioDeviceID?, + hal: FakeHAL + ) -> AudioProfileApplyOutcome { + let resolution = AudioProfileResolution( + output: output.map { .resolved(deviceID: $0, name: "Bose QC 35") } ?? .unpinned, + input: input.map { .resolved(deviceID: $0, name: "Shure MV7") } ?? .unpinned + ) + return AudioProfileApplier.apply( + resolution, + currentOutputID: currentOutputID, + currentInputID: currentInputID, + setDefaultOutput: hal.setOutput, + setDefaultInput: hal.setInput + ) + } + + func testApplyWritesBothDirectionsWhenTheHALAcceptsThem() { + let hal = FakeHAL(outputResults: [true], inputResults: [true]) + + let outcome = applyResolved( + output: 8, input: 9, currentOutputID: 1, currentInputID: 2, hal: hal + ) + + XCTAssertEqual(outcome, .applied) + XCTAssertEqual(hal.outputWrites, [8]) + XCTAssertEqual(hal.inputWrites, [9]) + } + + func testApplyRollsTheOutputBackWhenTheInputWriteIsRefused() { + // Both legs resolved, so the row was enabled — but CoreAudio rejects the mic as a + // default (some aggregates report streams and still refuse the write). Reporting + // success here would leave playback on the headset and capture on the laptop. + let hal = FakeHAL(outputResults: [true], inputResults: [false]) + + let outcome = applyResolved( + output: 8, input: 9, currentOutputID: 1, currentInputID: 2, hal: hal + ) + + guard case .refused(let message, let restored) = outcome else { + return XCTFail("A refused write must not be reported as success: \(outcome)") + } + XCTAssertTrue(restored) + XCTAssertTrue(message.contains("Shure MV7"), "The refusal should name the device: \(message)") + XCTAssertTrue(message.contains("input"), "The refusal should say which direction: \(message)") + XCTAssertEqual(hal.outputWrites, [8, 1], "The output that landed must be put back") + XCTAssertEqual(hal.inputWrites, [9], "A write that never landed must not be undone") + } + + func testApplyRollsTheInputBackWhenTheOutputWriteIsRefused() { + let hal = FakeHAL(outputResults: [false], inputResults: [true]) + + let outcome = applyResolved( + output: 8, input: 9, currentOutputID: 1, currentInputID: 2, hal: hal + ) + + guard case .refused(let message, let restored) = outcome else { + return XCTFail("A refused write must not be reported as success: \(outcome)") + } + XCTAssertTrue(restored) + XCTAssertTrue(message.contains("Bose QC 35"), "The refusal should name the device: \(message)") + XCTAssertEqual(hal.outputWrites, [8]) + XCTAssertEqual(hal.inputWrites, [9, 2], "The input that landed must be put back") + } + + func testApplyReportsThatARefusedRollbackLeftTheDefaultsMixed() { + // The output write lands, the input write is refused, and so is the attempt to + // put the output back. The defaults really are half-applied now, and saying + // "nothing was changed" would send the user looking in the wrong place. + let hal = FakeHAL(outputResults: [true, false], inputResults: [false]) + + let outcome = applyResolved( + output: 8, input: 9, currentOutputID: 1, currentInputID: 2, hal: hal + ) + + guard case .refused(_, let restored) = outcome else { + return XCTFail("Expected a refusal, got \(outcome)") + } + XCTAssertFalse(restored) + XCTAssertEqual(hal.outputWrites, [8, 1]) + } + + func testApplyCannotRestoreADirectionThatHadNoPreviousDefault() { + let hal = FakeHAL(outputResults: [true], inputResults: [false]) + + let outcome = applyResolved( + output: 8, input: 9, currentOutputID: nil, currentInputID: nil, hal: hal + ) + + guard case .refused(_, let restored) = outcome else { + return XCTFail("Expected a refusal, got \(outcome)") + } + XCTAssertFalse(restored, "With no previous default there is nothing to roll back to") + XCTAssertEqual(hal.outputWrites, [8], "Nothing to write back, so no second write") + } + + func testApplyLeavesAnUnpinnedDirectionUntouched() { + let hal = FakeHAL(outputResults: [true], inputResults: [true]) + + let outcome = applyResolved( + output: 8, input: nil, currentOutputID: 1, currentInputID: 2, hal: hal + ) + + XCTAssertEqual(outcome, .applied) + XCTAssertEqual(hal.outputWrites, [8]) + XCTAssertTrue(hal.inputWrites.isEmpty, "An unpinned direction must never be written") + } + + func testApplyWritesNothingWhenAPinnedDeviceIsGone() { + // The row was drawn while the headset was attached and clicked after it was + // unplugged: the stale resolution must not switch the half that still resolves. + let hal = FakeHAL() + let resolution = AudioProfileResolution( + output: .missing(name: "Bose QC 35"), + input: .resolved(deviceID: 9, name: "Shure MV7") + ) + + let outcome = AudioProfileApplier.apply( + resolution, + currentOutputID: 1, + currentInputID: 2, + setDefaultOutput: hal.setOutput, + setDefaultInput: hal.setInput + ) + + guard case .notApplicable(let reason) = outcome else { + return XCTFail("An unresolved leg must block the whole apply, got \(outcome)") + } + XCTAssertTrue(reason.contains("Bose QC 35"), "The user needs to be told what is missing: \(reason)") + XCTAssertTrue(hal.outputWrites.isEmpty) + XCTAssertTrue(hal.inputWrites.isEmpty, "Half-applying is exactly what this refusal prevents") + } + + // MARK: - Active-profile matching + + func testMatchesDefaultsComparesUIDsOnly() { + let profile = AudioDeviceProfile( + name: "Second headset", + output: .init(uid: "USB:SERIAL-BBB", name: "USB Audio Device"), + input: .init(uid: "USB:SERIAL-BBB", name: "USB Audio Device") + ) + + XCTAssertTrue(profile.matchesDefaults(outputUID: "USB:SERIAL-BBB", inputUID: "USB:SERIAL-BBB")) + // The identically-named twin must not count as a match. + XCTAssertFalse(profile.matchesDefaults(outputUID: "USB:SERIAL-AAA", inputUID: "USB:SERIAL-AAA")) + XCTAssertFalse(profile.matchesDefaults(outputUID: nil, inputUID: nil)) + } + + func testMatchesDefaultsIgnoresUnpinnedDirections() { + let profile = AudioDeviceProfile(name: "Speakers only", output: .init(uid: "built-in", name: "Speakers")) + + XCTAssertTrue(profile.matchesDefaults(outputUID: "built-in", inputUID: "anything-at-all")) + XCTAssertFalse(profile.matchesDefaults(outputUID: "somewhere-else", inputUID: nil)) + } + + // MARK: - Persistence + + func testEmptyProfileListLoadsAsEmptyAndMutatorsAreSafe() { + let defaults = makeDefaults() + + XCTAssertEqual(AudioProfileStore.load(defaults: defaults), []) + XCTAssertEqual(AudioProfileStore.remove(id: UUID(), defaults: defaults), []) + XCTAssertEqual(AudioProfileStore.rename(id: UUID(), to: "Nope", defaults: defaults), []) + XCTAssertEqual(AudioProfileStore.load(defaults: defaults), []) + } + + func testProfilesRoundTripThroughDefaults() { + let defaults = makeDefaults() + let profiles = [ + AudioDeviceProfile( + name: "Desk", + output: .init(uid: "AppleDisplay:5F2A", name: "Studio Display"), + input: .init(uid: "USB:MV7-8891", name: "Shure MV7") + ), + AudioDeviceProfile(name: "Speakers only", output: .init(uid: "built-in", name: "Speakers")), + AudioDeviceProfile(name: "Mic only", input: .init(uid: "USB:MV7-8891", name: "Shure MV7")) + ] + + AudioProfileStore.save(profiles, defaults: defaults) + + let loaded = AudioProfileStore.load(defaults: defaults) + XCTAssertEqual(loaded, profiles, "Ids, names, and both device refs must survive the round trip") + XCTAssertNil(loaded[2].output, "An unpinned direction must decode back as unpinned, not as an empty UID") + } + + func testProfileRoundTripsThroughJSONDirectly() throws { + let profile = AudioDeviceProfile( + name: "Meeting", + output: .init(uid: "BT:QC35-77", name: "Bose QC 35"), + input: .init(uid: "BT:QC35-77-in", name: "Bose QC 35 Mic") + ) + + let data = try JSONEncoder().encode(profile) + let decoded = try JSONDecoder().decode(AudioDeviceProfile.self, from: data) + + XCTAssertEqual(decoded, profile) + XCTAssertEqual(decoded.id, profile.id, "The id is the identity used for rename/delete and must persist") + } + + func testUpsertAppendsThenReplacesInPlace() { + let defaults = makeDefaults() + let first = AudioDeviceProfile(name: "Desk", output: .init(uid: "a", name: "A")) + let second = AudioDeviceProfile(name: "Meeting", output: .init(uid: "b", name: "B")) + + AudioProfileStore.upsert(first, defaults: defaults) + AudioProfileStore.upsert(second, defaults: defaults) + + var edited = first + edited.output = .init(uid: "c", name: "C") + let result = AudioProfileStore.upsert(edited, defaults: defaults) + + XCTAssertEqual(result.count, 2) + XCTAssertEqual(result.map(\.id), [first.id, second.id], "An edit must not reorder the list") + XCTAssertEqual(result[0].output?.uid, "c") + XCTAssertEqual(AudioProfileStore.load(defaults: defaults), result) + } + + func testRenameTrimsWhitespaceAndRefusesABlankName() { + let defaults = makeDefaults() + let profile = AudioDeviceProfile(name: "Desk", output: .init(uid: "a", name: "A")) + AudioProfileStore.upsert(profile, defaults: defaults) + + XCTAssertEqual(AudioProfileStore.rename(id: profile.id, to: " Studio ", defaults: defaults).first?.name, "Studio") + XCTAssertEqual(AudioProfileStore.rename(id: profile.id, to: " ", defaults: defaults).first?.name, "Studio") + XCTAssertEqual(AudioProfileStore.load(defaults: defaults).first?.name, "Studio") + } + + func testRenamingAnUnknownIDChangesNothing() { + let defaults = makeDefaults() + let profile = AudioDeviceProfile(name: "Desk", output: .init(uid: "a", name: "A")) + AudioProfileStore.upsert(profile, defaults: defaults) + + XCTAssertEqual(AudioProfileStore.rename(id: UUID(), to: "Other", defaults: defaults), [profile]) + } + + func testRemoveDeletesOnlyTheNamedProfile() { + let defaults = makeDefaults() + let keep = AudioDeviceProfile(name: "Desk", output: .init(uid: "a", name: "A")) + let drop = AudioDeviceProfile(name: "Meeting", output: .init(uid: "b", name: "B")) + AudioProfileStore.save([keep, drop], defaults: defaults) + + let remaining = AudioProfileStore.remove(id: drop.id, defaults: defaults) + + XCTAssertEqual(remaining, [keep]) + XCTAssertEqual(AudioProfileStore.load(defaults: defaults), [keep]) + } + + func testCorruptStoredBlobLoadsAsNoProfiles() { + let defaults = makeDefaults() + defaults.set(Data("not json".utf8), forKey: DefaultsKey.audioSwitcherProfiles) + + XCTAssertEqual( + AudioProfileStore.load(defaults: defaults), [], + "A corrupt blob must read as 'no profiles' rather than crash a menu-bar helper at launch" + ) + } +}