From ed72880bbd89cc62d5fcf32f7fc220a03a67b888 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 13:00:17 +0530 Subject: [PATCH 1/2] Audio Switcher: save and apply input/output device profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching both halves of an audio setup — headset in and out, then back to speakers and the desk mic — is four clicks through two lists every time. A profile captures the pair under a name and replays it in one. Profiles pin devices by their CoreAudio UID, never by AudioDeviceID. The numeric id is assigned as the HAL enumerates hardware and is recycled across reboots and re-plugs, so a profile stored by id would eventually address whatever unrelated device inherited the number — and quietly switching the user's audio to the wrong device is the one failure this feature must never produce. Names are stored alongside the UID purely so a disconnected device can still be named in the UI; resolution never matches on them, because two identical USB headsets report the same name and a name fallback would pick whichever came first. Resolution is therefore richer than a bool. Each direction resolves to unpinned, resolved, missing, or wrong-direction: a device can be attached under the pinned UID and still have stopped serving that direction (an aggregate that lost a sub-device, an interface switched to a capture-only mode), and making it the default output would silently kill playback. Applying is all-or-nothing — a half-applied "Meeting" that moved playback but left the mic on the laptop is worse than one that refuses and says which device is missing, so the row is disabled and carries the reason instead. Capture refuses a leg whose device exposes no UID rather than pinning to an empty string, which would later match any other UID-less device, and refuses the whole profile when neither leg survives that check — a row that can never resolve is worse than an explanatory refusal at save time. The model, its resolution, and the UserDefaults store are pure and nonisolated, so the crux cases are tested headlessly: id reassignment across a reboot, two devices sharing a name, a missing device, a device that no longer serves its direction, an empty stored UID, the empty list, and round-trip persistence. The controller now publishes one device list carrying UIDs (enumerated through AudioSwitcherKit, which reads them) with the per-direction lists derived from it, so the two cannot drift; the assignment is guarded on an actual change for the same reason refreshVolumeAndMute's writes are. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 11 + Sources/DMonteCore/AppPreferences.swift | 5 + .../DMonteCore/AudioSwitcherProfiles.swift | 289 ++++++++++++++ Sources/DMonteCore/AudioSwitcherSizing.swift | 11 +- Sources/DMonteCore/AudioSwitcherView.swift | 342 +++++++++++++++- .../AudioSwitcherProfilesTests.swift | 371 ++++++++++++++++++ 6 files changed, 1017 insertions(+), 12 deletions(-) create mode 100644 Sources/DMonteCore/AudioSwitcherProfiles.swift create mode 100644 Tests/DMonteCoreTests/AudioSwitcherProfilesTests.swift 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..e7e5bb7 --- /dev/null +++ b/Sources/DMonteCore/AudioSwitcherProfiles.swift @@ -0,0 +1,289 @@ +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 + } + } +} + +/// 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..eb141de 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,78 @@ 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. Returns `false` without + /// touching anything if some pinned device is absent: switching only the half that + /// resolved would leave the user on a configuration they never asked for. + @discardableResult + public func applyProfile(_ profile: AudioDeviceProfile) -> Bool { + let resolution = resolution(for: profile) + guard resolution.isApplicable else { return false } + if let outputID = resolution.outputDeviceID { + AudioDeviceKit.setDefaultOutput(outputID) + } + if let inputID = resolution.inputDeviceID { + AudioDeviceKit.setDefaultInput(inputID) + } + refresh() + return true + } + + 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 +241,23 @@ 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? + /// 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 +278,17 @@ 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 + pendingDeleteProfileID = nil + focusedField = nil + } } // MARK: - Header @@ -214,6 +318,7 @@ public struct AudioSwitcherPopoverView: View { ScrollView { VStack(alignment: .leading, spacing: AudioSwitcherSizing.sectionSpacing) { volumeSection + profilesSection outputSection inputSection } @@ -268,6 +373,225 @@ 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) + } + } + } + } + + @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: { + pendingDeleteProfileID = nil + controller.applyProfile(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 + renameText = profile.name + renamingProfileID = profile.id + focusedField = .rename(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) + 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 + draftProfileName = suggestedProfileName() + focusedField = .newProfile + } + + /// 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 +643,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..be6a22c --- /dev/null +++ b/Tests/DMonteCoreTests/AudioSwitcherProfilesTests.swift @@ -0,0 +1,371 @@ +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: - 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" + ) + } +} From ea1da65f77c6855e2750b833fe65d878fac53958 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 23:10:26 +0530 Subject: [PATCH 2/2] Audio Switcher: make applying a profile actually all-or-nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyProfile threw away both setDefault return values and returned true no matter what, so the one failure the feature is built to prevent was also the one it could not report. A profile pinning the Bose for output and the MV7 for input resolves cleanly, the output write lands, and the input write is then refused — because the mic was unplugged in the window after resolution, or because CoreAudio simply rejects that device as a default, which some aggregates and virtual drivers do while still reporting streams. Playback moved, capture did not, the call said "applied", and the popover said nothing at all. In the all-writes-refused variant the row looked applicable forever and clicking it was a silent no-op. Applying now goes through AudioProfileApplier, which reads both results and puts back whichever half landed when the other is refused, so the user ends up where they started rather than on a half-switched setup they never asked for and never saw happen. It returns an outcome rather than a bool: the popover has to say which device the HAL rejected, and it has to distinguish a rollback that worked ("nothing was changed") from one that was refused too, where the defaults really are mixed and pointing the user at System Settings is the honest answer. The two writes are injected, so the refusal and rollback paths — which no CI machine can produce on demand — are tested headlessly. The name field also came up unfocused. Both entry points set the @FocusState in the same synchronous update that inserted the TextField, and SwiftUI evaluates .focused(_:equals:) at insertion, so the request named a view that did not exist yet and was dropped: the user clicked +, saw a pre-filled box, and typed into nothing until they clicked it, with Return not committing either. The field now asks for focus itself once it has appeared, a runloop turn later. Co-Authored-By: Claude Opus 4.8 --- .../DMonteCore/AudioSwitcherProfiles.swift | 91 ++++++++++ Sources/DMonteCore/AudioSwitcherView.swift | 78 ++++++-- .../AudioSwitcherProfilesTests.swift | 171 ++++++++++++++++++ 3 files changed, 321 insertions(+), 19 deletions(-) diff --git a/Sources/DMonteCore/AudioSwitcherProfiles.swift b/Sources/DMonteCore/AudioSwitcherProfiles.swift index e7e5bb7..ab46387 100644 --- a/Sources/DMonteCore/AudioSwitcherProfiles.swift +++ b/Sources/DMonteCore/AudioSwitcherProfiles.swift @@ -227,6 +227,97 @@ public struct AudioProfileResolution: Sendable, Equatable { } } +/// 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 diff --git a/Sources/DMonteCore/AudioSwitcherView.swift b/Sources/DMonteCore/AudioSwitcherView.swift index eb141de..3194276 100644 --- a/Sources/DMonteCore/AudioSwitcherView.swift +++ b/Sources/DMonteCore/AudioSwitcherView.swift @@ -172,21 +172,25 @@ public final class AudioSwitcherController: ObservableObject { profile.resolve(against: devices) } - /// Applies both halves of a profile, then re-reads state. Returns `false` without - /// touching anything if some pinned device is absent: switching only the half that - /// resolved would leave the user on a configuration they never asked for. + /// 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) -> Bool { - let resolution = resolution(for: profile) - guard resolution.isApplicable else { return false } - if let outputID = resolution.outputDeviceID { - AudioDeviceKit.setDefaultOutput(outputID) - } - if let inputID = resolution.inputDeviceID { - AudioDeviceKit.setDefaultInput(inputID) - } + 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 true + return outcome } public func renameProfile(id: UUID, to newName: String) { @@ -248,6 +252,10 @@ public struct AudioSwitcherPopoverView: View { @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? @@ -286,6 +294,7 @@ public struct AudioSwitcherPopoverView: View { draftProfileName = nil renamingProfileID = nil saveRefusalReason = nil + applyFailureReason = nil pendingDeleteProfileID = nil focusedField = nil } @@ -418,6 +427,13 @@ public struct AudioSwitcherPopoverView: View { profileRow(profile) } } + + if let applyFailureReason { + Text(applyFailureReason) + .font(.system(size: AudioSwitcherSizing.captionSize)) + .foregroundStyle(Color.orange) + .fixedSize(horizontal: false, vertical: true) + } } } @@ -434,10 +450,7 @@ public struct AudioSwitcherPopoverView: View { } else { let resolution = controller.resolution(for: profile) HStack(spacing: 6) { - Button(action: { - pendingDeleteProfileID = nil - controller.applyProfile(profile) - }) { + Button(action: { apply(profile) }) { profileRowLabel(profile, resolution: resolution) } .buttonStyle(.plain) @@ -446,9 +459,12 @@ public struct AudioSwitcherPopoverView: View { 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 - focusedField = .rename(profile.id) } profileActionButton( symbol: pendingDeleteProfileID == profile.id ? "trash.fill" : "trash", @@ -535,6 +551,15 @@ public struct AudioSwitcherPopoverView: View { .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)) @@ -556,8 +581,23 @@ public struct AudioSwitcherPopoverView: View { 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() - focusedField = .newProfile + } + + /// 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 diff --git a/Tests/DMonteCoreTests/AudioSwitcherProfilesTests.swift b/Tests/DMonteCoreTests/AudioSwitcherProfilesTests.swift index be6a22c..1d5527f 100644 --- a/Tests/DMonteCoreTests/AudioSwitcherProfilesTests.swift +++ b/Tests/DMonteCoreTests/AudioSwitcherProfilesTests.swift @@ -245,6 +245,177 @@ final class AudioSwitcherProfilesTests: XCTestCase { 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() {