Audio Switcher: save and apply input/output device profiles - #29
Open
havokentity wants to merge 2 commits into
Open
Audio Switcher: save and apply input/output device profiles#29havokentity wants to merge 2 commits into
havokentity wants to merge 2 commits into
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wave 2 Phase A.
Implementation: Audio Switcher: save and apply input/output device profiles
Review fixes: Audio Switcher: make applying a profile actually all-or-nothing
Built by an isolated agent, then adversarially reviewed by an independent agent that checked the branch out and ran
swift build+ the full suite itself. Every review finding was fixed, with a regression test added for each high and medium.🤖 Generated with Claude Code