diff --git a/CHANGELOG.md b/CHANGELOG.md index 0931340..19a20f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.1] - 2026-11-01 + +### Fixed + +- A Windows or audio driver update that replaced a device's endpoint id broke the toggle with "nothing to switch to: neither ... is connected and enabled", although both devices were connected. Toggle Audio now also saves each device's stable id (Windows 11 24H2 and later). When a saved id stops working it finds the device by stable id, or by its exact name if exactly one connected device has that name, and updates the configuration itself. Existing configurations repair themselves on the next press. The normal toggle path makes no additional calls. +- A device that really cannot be found is no longer reported as disconnected: the message says that Windows no longer has it and to choose a replacement in Settings. +- The settings dialog opens with such a device selected at its new endpoint instead of listing it as "(not connected)". + ### Added - winget manifests for `Hotdogee.ToggleAudio` under `installer/winget/` and a `winget` workflow that submits each new release to microsoft/winget-pkgs. @@ -36,5 +44,6 @@ Initial native release; replaces the PowerShell proof of concept (`Switch-Audio. - Documentation: README with install, hotkey set-up (G HUB and other launchers), command-line and configuration reference, compatibility and troubleshooting notes; `docs/benchmarks.md`; `docs/packaging.md`. - Continuous integration (format, lint, tests, release build with a static C runtime check, benchmark builds, MSI build and validation) and tag-driven release automation with the MSI, a portable zip and `SHA256SUMS.txt`. -[Unreleased]: https://github.com/hotdogee/toggle-audio/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/hotdogee/toggle-audio/compare/v0.1.1...HEAD +[0.1.1]: https://github.com/hotdogee/toggle-audio/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/hotdogee/toggle-audio/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index 2cf7265..2d5e4ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,7 +193,7 @@ dependencies = [ [[package]] name = "toggle-audio" -version = "0.1.0" +version = "0.1.1" dependencies = [ "embed-resource", "serde", diff --git a/Cargo.toml b/Cargo.toml index d261286..4c1aa05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toggle-audio" -version = "0.1.0" +version = "0.1.1" edition = "2024" rust-version = "1.85" description = "Flip Windows audio output between two devices in milliseconds" diff --git a/README.md b/README.md index c002dbf..1250f79 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,11 @@ Toggle Audio does one thing: each run switches the Windows default playback devi **Verify the download.** Each release publishes `SHA256SUMS.txt` next to the MSI and the zip. The two values must match: ```powershell -(Get-FileHash .\toggle-audio-0.1.0-x64.msi -Algorithm SHA256).Hash.ToLower() -Select-String toggle-audio-0.1.0-x64.msi .\SHA256SUMS.txt +(Get-FileHash .\toggle-audio-0.1.1-x64.msi -Algorithm SHA256).Hash.ToLower() +Select-String toggle-audio-0.1.1-x64.msi .\SHA256SUMS.txt ``` -For silent installs (`msiexec /i toggle-audio-0.1.0-x64.msi /qn` from an elevated terminal), installing without the PATH entry, upgrades and uninstalling, see [docs/packaging.md](docs/packaging.md). Your settings in `%APPDATA%\toggle-audio\` are kept on upgrade and uninstall. +For silent installs (`msiexec /i toggle-audio-0.1.1-x64.msi /qn` from an elevated terminal), installing without the PATH entry, upgrades and uninstalling, see [docs/packaging.md](docs/packaging.md). Your settings in `%APPDATA%\toggle-audio\` are kept on upgrade and uninstall. ### Portable zip @@ -144,7 +144,7 @@ The two are equally fast (see the [benchmarks](docs/benchmarks.md#g-hub-launch-c `toggle-audio --help` prints: ```text -toggle-audio 0.1.0 +toggle-audio 0.1.1 Flip the Windows default playback device between two configured devices. Usage: @@ -232,12 +232,15 @@ Settings live in `%APPDATA%\toggle-audio\config.json`, one file per Windows user | --- | --- | | `version` | Format version, currently `1`. | | `device1.id`, `device2.id` | Endpoint ids, as printed by `toggle-audio list`. These are what the program uses. | -| `device1.name`, `device2.name` | The friendly name when the device was saved. It is for information only, used in error messages and in the dialog when the device is not connected. | +| `device1.name`, `device2.name` | The friendly name when the device was saved. It is used in error messages, in the dialog when the device is not connected, and as the last resort for finding the device. | +| `device1.stable_id`, `device2.stable_id` | Written by the program. Finds the device when its id stops working. Empty when Windows has none for the device. Leave it out when you edit the file by hand, and the next toggle fills it in. | | `switch_communications` | `true` (the default when the field is missing) also switches the default communications device. `--comm` and `--no-comm` override it for one run. | Unknown fields are ignored, and spaces around ids are trimmed. -**Ids, not names.** Windows gives every playback endpoint a stable id. Names can change: you can rename a device in Sound settings, and the "Speakers" part is translated (on a Chinese system it reads `喇叭`). The id stays the same as long as the device uses the same driver and port, so the setting survives renames and language changes. Reinstalling a driver, or plugging a USB device into another port, can create a new endpoint; then choose the device again in Settings. +**Ids, then names.** Devices are saved by their Windows endpoint id, because names can change: you can rename a device in Sound settings, and the "Speakers" part is translated (on a Chinese system it reads `喇叭`). The id is not permanent either: a Windows or audio driver update can give the same device a new one. So the stable id that Windows 11 24H2 and later provide is saved too, and when a saved id stops working, Toggle Audio finds the device by its stable id, or by its exact name if exactly one connected device has that name, and updates the configuration itself. A normal press only uses the saved id, so this costs no time. + +If none of that finds the device, for example after you plug a USB device into another port, choose it again in Settings. Toggle Audio does not guess: an unplugged device is never replaced by another one with the same name. **What a toggle does:** @@ -311,7 +314,7 @@ Full results, method and caveats are in [docs/benchmarks.md](docs/benchmarks.md) | Symptom | What to do | | --- | --- | | A device shows as **"(not connected)"** in Settings | Windows reports it as unplugged, off or disabled. Turn it on, or check Sound settings > All sound devices. If it is connected but still listed that way, Windows has created a new endpoint for it (after a driver reinstall or a different USB port): choose it again and Save. | -| A hotkey press shows an **error message box** | The message names the problem. "nothing to switch to" means that neither configured device is available. A missing or broken configuration opens Settings instead: choose the devices, Save, and press the key again (that press does not toggle). | +| A hotkey press shows an **error message box** | The message names the problem. "nothing to switch to" means that neither configured device is available. "Windows no longer has the saved device" means it could not be found by id, stable id or name: open Settings, choose it again and Save. A missing or broken configuration opens Settings instead: choose the devices, Save, and press the key again (that press does not toggle). | | **SmartScreen** blocks the MSI or the exe | Releases so far are unsigned ([Code signing policy](#code-signing-policy)). Click **More info > Run anyway**, after you have checked the hash against `SHA256SUMS.txt` ([Install](#install)). A signed release can still get this warning for a while after it is published, until SmartScreen has seen enough clean installs. | | **A console window flashes** on each G HUB press | You are on Windows 10 or Windows 11 before 24H2. Bind `toggle-audiow.exe` instead of `toggle-audio.exe`. | | Device names show as `?` or garbled text in `toggle-audio list \| ...` | When PowerShell pipes a native program's output, it decodes it with the console code page, so on a non-UTF-8 code page (for example Traditional Chinese, 950) CJK names break. Run `[Console]::OutputEncoding = [Text.UTF8Encoding]::new()` first. Output redirected to a file (`> devices.txt`) is always UTF-8, and plain console output is always correct. Endpoint ids are ASCII, so have scripts match on ids rather than names. | diff --git a/THIRD-PARTY-NOTICES.txt b/THIRD-PARTY-NOTICES.txt index 92e60fd..e61a998 100644 --- a/THIRD-PARTY-NOTICES.txt +++ b/THIRD-PARTY-NOTICES.txt @@ -1,4 +1,4 @@ -Third-party notices for Toggle Audio 0.1.0 +Third-party notices for Toggle Audio 0.1.1 toggle-audio.exe and toggle-audiow.exe are licensed under the MIT License (see LICENSE). They are statically linked with the Rust crates listed below. Each crate is available under diff --git a/assets/app.manifest b/assets/app.manifest index 3b245d6..325b3b5 100644 --- a/assets/app.manifest +++ b/assets/app.manifest @@ -1,7 +1,7 @@ - + diff --git a/bench/run-bench.ps1 b/bench/run-bench.ps1 index 4f76362..d2a7e28 100644 --- a/bench/run-bench.ps1 +++ b/bench/run-bench.ps1 @@ -146,8 +146,8 @@ if ($ToggleRuns % 2) { throw '-ToggleRuns must be even, so every toggle row ends $Bench = $PSScriptRoot $Repo = Split-Path -Parent $Bench -$Pg = '{0.0.0.00000000}.{739b3554-bfed-4d61-b407-a818b317c991}' # PG42UQ (NVIDIA High Definition Audio) -$Phl = '{0.0.0.00000000}.{5b124733-5d8f-428c-b83c-ee05ce6467fb}' # PHL BDM4065 (NVIDIA High Definition Audio) +$Pg = '{0.0.0.00000000}.{c9f7cc37-c71e-486e-beee-5010ed8616b7}' # PG42UQ (NVIDIA High Definition Audio) +$Phl = '{0.0.0.00000000}.{6a85b4c1-ef96-4850-bddf-afb4a21bbee2}' # PHL BDM4065 (NVIDIA High Definition Audio) $Spdif = '{0.0.0.00000000}.{0b317ab3-7e08-4d56-975a-f33b90d5b57a}' # Digital Audio (S/PDIF), legacy script only $Bogus = '{0.0.0.00000000}.{00000000-0000-0000-0000-000000000000}' $Legacy = $LegacyExe diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 412bd3c..2f7c2a4 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -70,6 +70,14 @@ choose_target(current: Option<&str>, device1, device2, is_active: impl Fn(&str) Mirrors the proof of concept (device1 unless device1 is current) but survives an unplugged device without a silent no-op. `is_active` is `IMMDeviceEnumerator::GetDevice(id)` + `GetState() == DEVICE_STATE_ACTIVE` (GetDevice succeeds for unplugged/disabled devices, so the state check is mandatory). +**Finding the configured devices.** Endpoint ids are not stable. Microsoft documents that an operating system or audio driver update can give the same peripheral a new id, and one did on the reference machine (2026-10-31, build 26300: four of five playback endpoints, all with their stable ids unchanged). Before `choose_target` runs, each configured device is located. Each step runs only when the one before it failed: + +1. `GetDevice(id)`. An active endpoint ends the search. This is the hot path and it makes no call that it did not make before. +2. `GetDevice(stable_id)`, with the saved `PKEY_AudioEndpoint_StableId` (`{1da5d803-d492-4edd-8c23-e0c0ffee7f0e},12`, Windows 11 24H2 and later; opaque and case-sensitive). This also runs for a known but inactive endpoint, because an update can leave the old endpoint behind as not present: the endpoint it finds wins only when it is active and different. +3. Only when Windows knows neither id: the exact friendly name, when exactly one active endpoint has it and that endpoint is not the other configured device. Microsoft calls the stable id best effort, so this covers the peripherals whose stable id changes too, and configurations saved before stable ids were recorded. A known but inactive device never reaches this step, so unplugging a device cannot retarget the toggle to an identical one. + +After the switch, a device found under a new id is written back (id, live name, fresh stable id) and a missing `stable_id` is recorded once; `""` means Windows has none, so it is not asked again. Neither can delay or fail the switch: a failed read or write is retried on the next toggle. A device that cannot be found is lost, not disconnected, and `NoDeviceAvailable` and the fallback warning say so instead of claiming it is unplugged. The settings dialog opens with the same search applied and reads stable ids on Save. + Roles: `eConsole` and `eMultimedia` always; `eCommunications` when `switch_communications` is true (default true). Order console, multimedia, communications. Skip a role whose current default already is the target. ## 7. Core Audio access (`src/audio.rs`, the only module allowed to touch COM besides the GUI) @@ -80,17 +88,25 @@ Roles: `eConsole` and `eMultimedia` always; `eCommunications` when `switch_commu - Set: `CoCreateInstance(CLSID {870af99c-171d-4f9e-af0d-e63df40c2bc9})` → `IPolicyConfig` `{f8679f50-850a-41cf-9c72-430f290290c8}`, `SetDefaultEndpoint(PCWSTR, ERole)` at 0-based vtable slot 13 (verified against AudioSes.dll symbols and five sources). Fallback if that fails: CLSID `{294935CE-F637-4E7C-A41B-AB255460B862}` → `IPolicyConfigVista` `{568b9108-44bf-40b4-9006-86afe5b5a620}`, slot 12. Declared with `windows_core::interface` as in `docs/research/core-audio-api.md` §C.2 (do not copy the Vista layouts from SoundSwitch/AudioDeviceCmdlets; they carry an extra method). - Public surface (sketch): `struct Endpoint { id: String, name: String }`, `struct AudioSystem` (owns the COM init guard and enumerator) with `list_active() -> Result>`, `default_for(role) -> Result>`, `is_active(id) -> Result`, `name_of(id) -> Result>`, `set_default(id, &[Role]) -> Result<()>`. Errors carry the HRESULT and the failing call. -Hot path for `toggle`: parse args → read config → `AudioSystem::new()` → `default_for(Console)` → `choose_target` with `is_active` → `set_default`. No full enumeration. +Hot path for `toggle`: parse args → read config → `AudioSystem::new()` → `default_for(Console)` → `choose_target` with `is_active` → `set_default`. No full enumeration (only step 3 of "Finding the configured devices" in section 6 enumerates, once, when a saved device is lost). ## 8. Configuration (`src/config.rs`) -Path: `%APPDATA%\toggle-audio\config.json` (`SHGetKnownFolderPath(FOLDERID_RoamingAppData)`, falling back to the `APPDATA` variable). Written atomically (write `config.json.tmp`, then `MoveFileExW` replace). Unknown fields ignored; `switch_communications` defaults to true; `name` is informational (used in errors and in the dialog when the device is not connected). +Path: `%APPDATA%\toggle-audio\config.json` (`SHGetKnownFolderPath(FOLDERID_RoamingAppData)`, falling back to the `APPDATA` variable). Written atomically (write `config.json.tmp`, then `MoveFileExW` replace). Unknown fields ignored; `switch_communications` defaults to true; `name` is used in errors, in the dialog when the device is not connected, and as the last resort for finding a device (section 6); `stable_id` is optional and written by the program. It needs no version bump: older builds ignore it. ```json { "version": 1, - "device1": { "id": "{0.0.0.00000000}.{739b3554-bfed-4d61-b407-a818b317c991}", "name": "PG42UQ (NVIDIA High Definition Audio)" }, - "device2": { "id": "{0.0.0.00000000}.{30045f40-8cfd-4441-bb89-0d13fc19b589}", "name": "喇叭 (FiiO BTA30 PRO)" }, + "device1": { + "id": "{0.0.0.00000000}.{739b3554-bfed-4d61-b407-a818b317c991}", + "name": "PG42UQ (NVIDIA High Definition Audio)", + "stable_id": "{0.0.0.00000000}.{001.{31E3A048-D22C-4C75-853B-3DC39707E1A7}}" + }, + "device2": { + "id": "{0.0.0.00000000}.{30045f40-8cfd-4441-bb89-0d13fc19b589}", + "name": "喇叭 (FiiO BTA30 PRO)", + "stable_id": "{0.0.0.00000000}.{001.{19DD8A43-B8DD-4C72-B721-7B506462F9FA}}" + }, "switch_communications": true } ``` @@ -177,7 +193,7 @@ Decided during implementation and review; the sections above keep the original t - §4: `toggle-audiow.exe` compiles `assets/app-windowed.rc`, which includes `app.rc` with its own `OriginalFilename`. On Windows versions that ignore `consoleAllocationPolicy` (before 11 24H2), `toggle-audio.exe` frees a console that no other process shares (`GetConsoleProcessList` = 1), so a hotkey launch still reports errors in a message box and opens Settings. - §5: a toggle without a usable configuration (missing, unreadable or invalid) opens Settings only when there is no console; from a terminal it prints the error with a hint to run `settings` and exits 3. After a first-run Save a message box says to press the hotkey again; that press does not toggle. `set` treats a broken configuration as a warning and uses the default roles. `--timing` starts after parsing (the parsed flag is the only source of truth); stamps still count from process creation. Exit 4 also covers a name that matches several devices: `set` lists the matches (name and id) and asks for the full name or the id. A hotkey press while a Settings dialog is already open (also during first-run setup) brings that dialog to the front and exits 0; only Cancel exits 3. `set` prints the endpoint id when the device has no friendly name. -- §6: "nothing to switch to" names the unavailable device and, when that is the reason, the device that already is the default. Before deciding, the toggle looks up both configured ids (`AudioSystem::lookup`: `GetDevice`, `GetId`, `GetState`), so the comparison with the current default uses Windows' own spelling of each id (another case, or a Windows 11 24H2 stable id that `GetDevice` also accepts, still matches); an id Windows does not know counts as not connected. +- §6: "nothing to switch to" names the unavailable device and, when that is the reason, the device that already is the default. Before deciding, the toggle looks up both configured ids (`AudioSystem::lookup`: `GetDevice`, `GetId`, `GetState`), so the comparison with the current default uses Windows' own spelling of each id (another case, or a Windows 11 24H2 stable id that `GetDevice` also accepts, still matches); a device that cannot be found counts as not active, and the message says Windows no longer has it. - §7: only `eRender` endpoints are accepted (`IMMEndpoint::GetDataFlow`); a recording endpoint id behaves like an unknown id, so `set` can never change the default microphone. The `IPolicyConfigVista` fallback also covers a failing `IPolicyConfig::SetDefaultEndpoint`, not only a missing class. The hot path reads only the current default's id (`default_id`), not its name, and passes the console default it read on to `set_default`, which skips reading it again (each read is a call into the audio service, about 1.2 ms). - §8: the `APPDATA` variable is read first and `SHGetKnownFolderPath` is the fallback: it saves about 2 ms per toggle and lets tests redirect the configuration. The atomic replace uses `std::fs::rename` (`MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`). Whitespace around ids is trimmed on load. - §9: Save closes the dialog at once, so no "Saved to" status is shown. Copy copies the plain, unquoted exe path for the Path field of G HUB's Launch Application action (its Browse button produces the same form; still to be confirmed against G HUB). A named mutex (`Local\Hotdogee.ToggleAudio.Settings`) keeps one dialog per session and brings an open one to the front. The dialog uses `DS_SETFOREGROUND` plus a topmost round trip, a three-line status, and appends the end of the endpoint id to friendly names that occur twice. diff --git a/docs/research/core-audio-api.md b/docs/research/core-audio-api.md index d9e5532..9718113 100644 --- a/docs/research/core-audio-api.md +++ b/docs/research/core-audio-api.md @@ -106,6 +106,7 @@ col->Release(); en->Release(); CoUninitialize() - The value is `VT_LPWSTR` or `VT_EMPTY`. Treat it as opaque and case-sensitive. - **The SDK 10.0.26100 header and `windows` 0.62.2 do not define this key yet.** Dumping all property keys on this PC showed one string property per endpoint at **`{1da5d803-d492-4edd-8c23-e0c0ffee7f0e}`, pid 12**, with a value like `{0.0.0.00000000}.{001.{19DD8A43-B8DD-4C72-B721-7B506462F9FA}}`. Passing that string to `GetDevice` resolved to the matching endpoint for all 5 active endpoints [verified]. **Inference (not yet confirmed by a header): PKEY_AudioEndpoint_StableId = {1da5d803-…},12** (same fmtid as all other `PKEY_AudioEndpoint_*`, next free pid after `Min_VolumeInDb`=11). - Recommendation: in config, store `id` (required) and `stable_id` (optional). At toggle time try `GetDevice(id)`. On `E_NOTFOUND`, try `GetDevice(stable_id)` and, if that succeeds, re-save the new `id`. Always pass the **ordinary ID (from `GetId`)** to `SetDefaultEndpoint`; nobody has verified that it accepts StableIds. +- Update 2026-11-01: the 10.0.26100 header on this PC now has `DEFINE_PROPERTYKEY(PKEY_AudioEndpoint_StableId, 0x1da5d803, 0xd492, 0x4edd, 0x8c, 0x23, 0xe0, 0xc0, 0xff, 0xee, 0x7f, 0x0e, 12)`, which confirms the inference above. The Windows update of 2026-10-31 (build 26300) replaced the ordinary ID of 4 of the 5 active render endpoints [verified]: Kernel-PnP events 420 and 442 show them deleted and not migrated. All 5 StableIds were unchanged from the 2026-10-04 property dump [verified], so this recommendation should have been implemented in 0.1.0; it shipped in 0.1.1. Learn calls preservation best effort ("A small percentage of peripherals might receive a different stable ID"), so a name match remains as a last resort. Not-present endpoints of the same monitors have different StableIds, so expect a StableId not to follow a monitor to another GPU port. --- diff --git a/scripts/e2e.ps1 b/scripts/e2e.ps1 index b0879da..19757ff 100644 --- a/scripts/e2e.ps1 +++ b/scripts/e2e.ps1 @@ -9,7 +9,9 @@ --version, list and get once more through toggle-audiow.exe. 3. Unless -SkipToggle: writes a temporary configuration with -Device1 / -Device2, runs "toggle" 2 x -Rounds times and "set" by name and by id, and verifies the default and default - communications devices with Get-AudioDevice after every step. + communications devices with Get-AudioDevice after every step. It then saves ids that do not + exist, as a Windows update leaves them, and checks that the devices are found again by name + and by stable id and that the configuration is repaired. Every run of the program gets APPDATA pointed at a scratch directory, so the user's real configuration (%APPDATA%\toggle-audio\config.json) is never read or written. A finally block @@ -46,8 +48,8 @@ #> [CmdletBinding()] param( - [string]$Device1 = '{0.0.0.00000000}.{739b3554-bfed-4d61-b407-a818b317c991}', - [string]$Device2 = '{0.0.0.00000000}.{5b124733-5d8f-428c-b83c-ee05ce6467fb}', + [string]$Device1 = '{0.0.0.00000000}.{c9f7cc37-c71e-486e-beee-5010ed8616b7}', + [string]$Device2 = '{0.0.0.00000000}.{6a85b4c1-ef96-4850-bddf-afb4a21bbee2}', [ValidateRange(1, 100)][int]$Rounds = 2, [switch]$SkipToggle, [switch]$SkipBuild, @@ -294,6 +296,41 @@ if ($SkipToggle) { Test-Check ($r.ExitCode -eq 0 -and (Test-SameId (Get-OracleDefault) $second.ID)) "set '$($second.Name)' makes it the default (exit $($r.ExitCode))" $r = Invoke-Exe @('set', $first.ID) Test-Check ($r.ExitCode -eq 0 -and (Test-SameId (Get-OracleDefault) $first.ID)) "set makes it the default (exit $($r.ExitCode))" + + Write-Step 'saved ids that Windows no longer has' + # What a Windows update leaves behind: well-formed ids that no endpoint has any more. + $dead1 = '{0.0.0.00000000}.{00000000-0000-0000-0000-00000000dead}' + $dead2 = '{0.0.0.00000000}.{00000000-0000-0000-0000-00000000beef}' + function Write-TestConfig($DeviceOne, $DeviceTwo) { + $text = [ordered]@{ version = 1; device1 = $DeviceOne; device2 = $DeviceTwo; switch_communications = $true } | ConvertTo-Json -Depth 3 + [IO.File]::WriteAllText($configPath, $text, (New-Object System.Text.UTF8Encoding($false))) + } + function Read-TestConfig { return [IO.File]::ReadAllText($configPath) | ConvertFrom-Json } + + Write-TestConfig ([ordered]@{ id = $dead1; name = $first.Name }) ([ordered]@{ id = $dead2; name = $second.Name }) + $r = Invoke-Exe @('toggle') + Test-Check ($r.ExitCode -eq 0 -and (Test-SameId (Get-OracleDefault) $second.ID)) "dead ids with no stable id: found by name and switched (exit $($r.ExitCode))" + $saved = Read-TestConfig + Test-Check ((Test-SameId $saved.device1.id $first.ID) -and (Test-SameId $saved.device2.id $second.ID)) 'the configuration now has the current ids' + Test-Check ($null -ne $saved.device1.stable_id -and $null -ne $saved.device2.stable_id) 'the configuration records both stable ids' + + if ($saved.device1.stable_id -and $saved.device2.stable_id) { + # Names no endpoint has, so only the stable ids can find the devices. + Write-TestConfig ([ordered]@{ id = $dead1; name = 'renamed one'; stable_id = $saved.device1.stable_id }) ([ordered]@{ id = $dead2; name = 'renamed two'; stable_id = $saved.device2.stable_id }) + $r = Invoke-Exe @('toggle') + Test-Check ($r.ExitCode -eq 0 -and (Test-SameId (Get-OracleDefault) $first.ID)) "dead ids with stable ids: found and switched (exit $($r.ExitCode))" + $healed = Read-TestConfig + Test-Check ((Test-SameId $healed.device1.id $first.ID) -and $healed.device1.name -eq $first.Name) 'the configuration has the current id and the live name again' + } else { + Write-Host ' no stable ids on this Windows version; skipping that check' + Invoke-Exe @('set', $first.ID) | Out-Null + } + + Write-TestConfig ([ordered]@{ id = $dead1; name = 'no such device' }) ([ordered]@{ id = $dead2; name = 'no such device either' }) + $before = [IO.File]::ReadAllText($configPath) + $r = Invoke-Exe @('toggle') + Test-Check ($r.ExitCode -eq 4 -and $r.Stderr -match 'Windows no longer has the saved devices' -and $r.Stderr -notmatch 'connected') "nothing to find: exit 4 and an honest message (exit $($r.ExitCode))" + Test-Check ((Test-SameId (Get-OracleDefault) $first.ID) -and [IO.File]::ReadAllText($configPath) -ceq $before) 'nothing to find: default and configuration unchanged' } finally { Write-Step 'restore' Remove-Item -LiteralPath $script:AppData -Recurse -Force -ErrorAction SilentlyContinue diff --git a/src/audio.rs b/src/audio.rs index 20f78c3..c93031f 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -18,7 +18,7 @@ use std::ffi::c_void; use std::marker::PhantomData; use windows::Win32::Devices::FunctionDiscovery::PKEY_Device_FriendlyName; -use windows::Win32::Foundation::{E_INVALIDARG, ERROR_NOT_FOUND, RPC_E_CHANGED_MODE}; +use windows::Win32::Foundation::{E_INVALIDARG, ERROR_NOT_FOUND, PROPERTYKEY, RPC_E_CHANGED_MODE}; use windows::Win32::Media::Audio::{ DEVICE_STATE_ACTIVE, ERole, IMMDevice, IMMDeviceEnumerator, IMMEndpoint, MMDeviceEnumerator, eCommunications, eConsole, eMultimedia, eRender, @@ -45,6 +45,13 @@ const CLSID_POLICY_CONFIG_CLIENT: GUID = GUID::from_u128(0x870a_f99c_171d_4f9e_a const CLSID_POLICY_CONFIG_VISTA_CLIENT: GUID = GUID::from_u128(0x2949_35ce_f637_4e7c_a41b_ab25_5460_b862); +/// `PKEY_AudioEndpoint_StableId` from `mmdeviceapi.h` (Windows 11 24H2 and later), which the +/// `windows` crate does not define yet. +const PKEY_AUDIO_ENDPOINT_STABLE_ID: PROPERTYKEY = PROPERTYKEY { + fmtid: GUID::from_u128(0x1da5_d803_d492_4edd_8c23_e0c0_ffee_7f0e), + pid: 12, +}; + /// A Windows audio device role (`ERole`). /// /// Windows keeps one default playback device per role. The Sound settings "Set as default" button @@ -89,7 +96,8 @@ pub fn roles_for(switch_communications: bool) -> &'static [Role] { #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Endpoint { /// Endpoint id from `IMMDevice::GetId`, for example - /// `{0.0.0.00000000}.{739b3554-bfed-4d61-b407-a818b317c991}`. Opaque; this is the identity. + /// `{0.0.0.00000000}.{739b3554-bfed-4d61-b407-a818b317c991}`. Opaque; this is the identity + /// until a Windows or driver update replaces it ([`AudioSystem::stable_id_of`]). pub id: String, /// `PKEY_Device_FriendlyName`, exactly what Sound settings shows, for example /// `喇叭 (FiiO BTA30 PRO)`. Not unique and user-renamable; display only. Empty in the unusual @@ -236,6 +244,28 @@ impl AudioSystem { Ok((!name.is_empty()).then_some(name)) } + /// The stable id (`PKEY_AudioEndpoint_StableId`) of playback endpoint `id` in any state. + /// + /// Microsoft: the id from `IMMDevice::GetId` "is not stable. An operating system update or an + /// audio driver update can cause the same physical peripheral to be assigned a different + /// endpoint ID", while Windows attempts to preserve the stable id across those updates. The + /// value is opaque and case-sensitive, and `GetDevice` accepts it, so [`AudioSystem::lookup`] + /// resolves it to the endpoint's current id. + /// + /// `None` when `id` is unknown, malformed or names a recording endpoint, or Windows has no + /// stable id for the endpoint (not every endpoint has one, and Windows before 11 24H2 has none). + /// + /// # Errors + /// + /// [`Error::Com`] for any other failure, including a failed property store read. + pub fn stable_id_of(&self, id: &str) -> Result> { + let Some(device) = self.device(id)? else { + return Ok(None); + }; + let stable_id = string_property(&device, &PKEY_AUDIO_ENDPOINT_STABLE_ID)?; + Ok((!stable_id.is_empty()).then_some(stable_id)) + } + /// Makes playback endpoint `id` the default for each of `roles`, in order, and returns whether /// any role actually changed. /// @@ -684,14 +714,18 @@ fn id_of(device: &IMMDevice) -> Result { /// `PKEY_Device_FriendlyName` of `device`, or `""` when the property is missing or not a string. fn friendly_name(device: &IMMDevice) -> Result { + string_property(device, &PKEY_Device_FriendlyName) +} + +/// The string property `key` of `device`, or `""` when it is missing or not a string. +fn string_property(device: &IMMDevice, key: &PROPERTYKEY) -> Result { // SAFETY: `device` is a live interface pointer and `STGM_READ` is a valid access mode. let store = unsafe { device.OpenPropertyStore(STGM_READ) } .map_err(Error::com("IMMDevice::OpenPropertyStore"))?; // The windows crate's `Drop for PROPVARIANT` calls `PropVariantClear`, so `value` (and the // string it owns) is freed on every path out of this function. - // SAFETY: `store` is a live interface pointer and the key is a static PROPERTYKEY. - let value = unsafe { store.GetValue(&PKEY_Device_FriendlyName) } - .map_err(Error::com("IPropertyStore::GetValue"))?; + // SAFETY: `store` is a live interface pointer and `key` is a valid PROPERTYKEY reference. + let value = unsafe { store.GetValue(key) }.map_err(Error::com("IPropertyStore::GetValue"))?; if value.vt() != VT_LPWSTR { return Ok(String::new()); } diff --git a/src/config.rs b/src/config.rs index 9499a77..f4f2606 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,15 +2,24 @@ //! //! Stored as JSON at `%APPDATA%\toggle-audio\config.json` ([`default_path`]) and written //! atomically ([`save`]). Unknown fields are ignored, `switch_communications` defaults to `true` -//! and a device `name` defaults to empty; the name is informational only (error messages, and the -//! settings dialog when the device is not connected). Whitespace around a device id (a stray space -//! from hand editing) is removed when the file is loaded. Example: +//! and a device `name` defaults to empty. The name is shown in messages, and it is the last resort +//! for finding a device whose ids stopped working ([`crate::toggle`]). `stable_id` is optional and +//! written by the program. Whitespace around a device id (a stray space from hand editing) is +//! removed when the file is loaded. Example: //! //! ```json //! { //! "version": 1, -//! "device1": { "id": "{0.0.0.00000000}.{739b3554-bfed-4d61-b407-a818b317c991}", "name": "PG42UQ (NVIDIA High Definition Audio)" }, -//! "device2": { "id": "{0.0.0.00000000}.{30045f40-8cfd-4441-bb89-0d13fc19b589}", "name": "喇叭 (FiiO BTA30 PRO)" }, +//! "device1": { +//! "id": "{0.0.0.00000000}.{739b3554-bfed-4d61-b407-a818b317c991}", +//! "name": "PG42UQ (NVIDIA High Definition Audio)", +//! "stable_id": "{0.0.0.00000000}.{001.{31E3A048-D22C-4C75-853B-3DC39707E1A7}}" +//! }, +//! "device2": { +//! "id": "{0.0.0.00000000}.{30045f40-8cfd-4441-bb89-0d13fc19b589}", +//! "name": "喇叭 (FiiO BTA30 PRO)", +//! "stable_id": "{0.0.0.00000000}.{001.{19DD8A43-B8DD-4C72-B721-7B506462F9FA}}" +//! }, //! "switch_communications": true //! } //! ``` @@ -46,11 +55,21 @@ const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF"; /// A configured playback endpoint. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct DeviceRef { - /// Endpoint id (`IMMDevice::GetId`). This is the identity used for switching. + /// Endpoint id (`IMMDevice::GetId`). This is the identity used for switching. A Windows or + /// driver update can replace it, in which case the toggle finds the device again and rewrites + /// this field. pub id: String, - /// Friendly name at the time the device was chosen. Informational only. + /// Friendly name at the time the device was chosen. Shown in messages, and the last resort + /// for finding the device when neither id works. #[serde(default)] pub name: String, + /// `PKEY_AudioEndpoint_StableId`, which finds the device when `id` stops working. Opaque and + /// case-sensitive, so it is never trimmed or normalized. + /// + /// `None`: not read yet. `Some("")`: read, and Windows has none for this endpoint, which keeps + /// the toggle from asking again on every press. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stable_id: Option, } /// The whole configuration file. @@ -289,6 +308,7 @@ mod tests { DeviceRef { id: id.to_owned(), name: name.to_owned(), + stable_id: None, } } @@ -320,6 +340,22 @@ mod tests { } } + #[test] + fn stable_ids_round_trip_and_stay_out_of_the_file_until_known() { + let dir = TempDir::new("stable"); + let path = dir.file("config.json"); + let mut config = sample(); + save(&path, &config).unwrap(); + let text = fs::read_to_string(&path).unwrap(); + assert!(!text.contains("stable_id"), "{text}"); + + // Case and surrounding spaces survive: the value is opaque and case-sensitive. + config.device1.stable_id = Some(" {0.0.0.00000000}.{001.{AbC}} ".to_owned()); + config.device2.stable_id = Some(String::new()); + save(&path, &config).unwrap(); + assert_eq!(load(&path).unwrap(), Some(config)); + } + #[test] fn missing_optional_fields_take_their_defaults() { let config: Config = @@ -328,6 +364,7 @@ mod tests { assert_eq!(config.version, CURRENT_VERSION); assert!(config.switch_communications); assert_eq!(config.device1.name, ""); + assert_eq!(config.device1.stable_id, None); } #[test] diff --git a/src/error.rs b/src/error.rs index f3e10bb..522855f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -65,12 +65,16 @@ pub enum Error { /// either not active or already the default. The strings are display names (the friendly /// name, or the id when the configuration has no name). NoDeviceAvailable { - /// The configured devices that are not active (unplugged, disabled or unknown): one or - /// two, preferred device first. + /// The configured devices that are known to Windows but not active (unplugged or + /// disabled), preferred device first. unavailable: Vec, /// The configured device that already is the default, if that is why the other one could /// not be used as a fallback. already_default: Option, + /// The configured devices Windows no longer knows under any saved id, and that could not + /// be found by name either. They may well be connected, so the message must not say that + /// they are not. + lost: Vec, }, /// Windows reports no default playback device (`GetDefaultAudioEndpoint` returned `E_NOTFOUND`). NoDefaultDevice, @@ -193,19 +197,36 @@ impl fmt::Display for Error { Self::NoDeviceAvailable { unavailable, already_default, + lost, } => { f.write_str("nothing to switch to: ")?; - match unavailable.as_slice() { - [device] => write!(f, "\"{device}\" is not connected or is disabled")?, - [first, second] => write!( + match (unavailable.as_slice(), lost.as_slice()) { + ([device], []) => write!(f, "\"{device}\" is not connected or is disabled")?, + ([first, second], []) => write!( f, "neither \"{first}\" nor \"{second}\" is connected and enabled" )?, + ([], [device]) => { + write!(f, "Windows no longer has the saved device \"{device}\"")?; + } + ([], [first, second]) => write!( + f, + "Windows no longer has the saved devices \"{first}\" and \"{second}\"" + )?, + ([device], [gone]) => write!( + f, + "\"{device}\" is not connected or is disabled, and Windows no longer has \ + the saved device \"{gone}\"" + )?, _ => f.write_str("no configured device is connected and enabled")?, } - match already_default { - Some(device) => write!(f, ", and \"{device}\" is already the default"), - None => Ok(()), + if let Some(device) = already_default { + write!(f, ", and \"{device}\" is already the default")?; + } + if lost.is_empty() { + Ok(()) + } else { + f.write_str(". Open Settings to choose a replacement") } } Self::NoDefaultDevice => f.write_str("Windows reports no default playback device"), @@ -322,6 +343,7 @@ mod tests { Error::NoDeviceAvailable { unavailable: vec!["a".into(), "b".into()], already_default: None, + lost: Vec::new(), }, EXIT_DEVICE, ), @@ -397,27 +419,56 @@ mod tests { ); } + /// The message of a `NoDeviceAvailable` error. + fn no_device_text( + unavailable: &[&str], + already_default: Option<&str>, + lost: &[&str], + ) -> String { + let owned = |names: &[&str]| names.iter().map(|&name| name.to_owned()).collect(); + Error::NoDeviceAvailable { + unavailable: owned(unavailable), + already_default: already_default.map(str::to_owned), + lost: owned(lost), + } + .to_string() + } + #[test] fn no_device_available_names_the_precise_reason() { - let text = |unavailable: &[&str], already_default: Option<&str>| { - Error::NoDeviceAvailable { - unavailable: unavailable.iter().map(|&name| name.to_owned()).collect(), - already_default: already_default.map(str::to_owned), - } - .to_string() - }; + let text = no_device_text; assert_eq!( - text(&["B"], Some("A")), + text(&["B"], Some("A"), &[]), "nothing to switch to: \"B\" is not connected or is disabled, and \"A\" is already \ the default" ); assert_eq!( - text(&["A", "B"], None), + text(&["A", "B"], None, &[]), "nothing to switch to: neither \"A\" nor \"B\" is connected and enabled" ); assert_eq!( - text(&[], None), + text(&[], None, &[]), "nothing to switch to: no configured device is connected and enabled" ); } + + #[test] + fn devices_windows_forgot_are_not_called_disconnected() { + let text = no_device_text; + assert_eq!( + text(&[], None, &["A", "B"]), + "nothing to switch to: Windows no longer has the saved devices \"A\" and \"B\". Open \ + Settings to choose a replacement" + ); + assert_eq!( + text(&[], Some("A"), &["B"]), + "nothing to switch to: Windows no longer has the saved device \"B\", and \"A\" is \ + already the default. Open Settings to choose a replacement" + ); + assert_eq!( + text(&["A"], None, &["B"]), + "nothing to switch to: \"A\" is not connected or is disabled, and Windows no longer \ + has the saved device \"B\". Open Settings to choose a replacement" + ); + } } diff --git a/src/gui/dialog.rs b/src/gui/dialog.rs index ff9d03d..1d1340f 100644 --- a/src/gui/dialog.rs +++ b/src/gui/dialog.rs @@ -332,10 +332,13 @@ impl State { ) -> Self { let unconfigured = existing.is_none(); let (configured, switch_communications) = match existing { - Some(config) => ( - vec![config.device1, config.device2], - config.switch_communications, - ), + Some(config) => { + // A device whose saved id stopped working opens selected where it is now. Nothing + // is written until Save. + let (device1, device2) = toggle::relocate(&audio, &config.device1, &config.device2) + .unwrap_or((config.device1, config.device2)); + (vec![device1, device2], config.switch_communications) + } None => (Vec::new(), true), }; let choices = build_choices(active, &configured, |id| audio.name_of(id).ok().flatten()); @@ -386,6 +389,7 @@ fn build_choices( device: DeviceRef { id: endpoint.id, name: endpoint.name, + stable_id: None, }, connected: true, }) @@ -401,6 +405,7 @@ fn build_choices( device: DeviceRef { id: device.id.clone(), name, + stable_id: device.stable_id.clone(), }, connected: false, }); @@ -621,9 +626,18 @@ fn on_save(dialog: HWND, state: &State) { return; } }; + // Best effort: a device Windows cannot find keeps what was saved, and the next toggle records + // a stable id that is still missing. + let with_stable_id = |device: &DeviceRef| { + let mut device = device.clone(); + if let Ok(Some(stable_id)) = state.audio.stable_id_of(&device.id) { + device.stable_id = Some(stable_id); + } + device + }; let config = Config::new( - device1.clone(), - device2.clone(), + with_stable_id(device1), + with_stable_id(device2), is_checked(dialog, IDC_COMMS), ); if let Err(problem) = config.validate() { @@ -910,6 +924,7 @@ mod tests { DeviceRef { id: id.to_owned(), name: name.to_owned(), + stable_id: None, } } @@ -1104,6 +1119,7 @@ mod tests { let now = Outcome { device: device("b", "喇叭 (FiiO BTA30 PRO)"), warning: None, + moved: [None, None], }; assert_eq!( test_status(&Ok(now)), @@ -1115,6 +1131,7 @@ mod tests { warning: Some(Warning::PreferredUnavailable { preferred: device("a", "Headset"), }), + moved: [None, None], }; assert_eq!( test_status(&Ok(fallback)), diff --git a/src/gui/mod.rs b/src/gui/mod.rs index cf03e10..a2c69e4 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -64,7 +64,8 @@ pub enum DialogOutcome { /// /// - `config_path`: where Save writes the configuration (atomically, via [`crate::config::save`]). /// - `existing`: the current configuration, used to preselect the combos and the checkbox. A -/// configured device that is not active is listed as `(not connected) ` and stays +/// device whose saved id stopped working is preselected where [`crate::toggle::relocate`] finds +/// it. A configured device that is not active is listed as `(not connected) ` and stays /// selectable. /// - `reason`: why the dialog opens, which selects the initial status text. /// - `exe_path`: the executable to show and copy for the G HUB binding (normally diff --git a/src/lib.rs b/src/lib.rs index edb03aa..d24aa4d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -105,9 +105,14 @@ fn dispatch(options: &Options, output: &Output, timing: &mut Timing) -> Result Result { let path = config::default_path()?; - let config = match config::load(&path) { + let mut config = match config::load(&path) { Ok(Some(config)) => config, Ok(None) => { let missing = Error::NoConfig(path.clone()); @@ -144,6 +149,20 @@ fn toggle(options: &Options, output: &Output, timing: &mut Timing) -> Result write!( + f, + "Windows no longer has the saved device \"{}\"; switched to the other device \ + instead. Open Settings to choose a replacement", + display_name(preferred) + ), } } } @@ -52,6 +81,9 @@ pub struct Outcome { pub device: DeviceRef, /// Set when the preferred device was unavailable and `device` is the fallback. pub warning: Option, + /// The new ids of Device 1 and Device 2, for a device found under a different id than the + /// configured one. Pass it to [`update_config`]. + pub moved: [Option; 2], } /// Decides which configured device a toggle switches to. @@ -95,6 +127,7 @@ pub fn choose_target<'a>( return Err(Error::NoDeviceAvailable { unavailable: vec![name(preferred)], already_default: Some(name(device1)), + lost: Vec::new(), }); }; // Skip the lookup when the fallback already is the default: switching to it would be a silent @@ -103,6 +136,7 @@ pub fn choose_target<'a>( return Err(Error::NoDeviceAvailable { unavailable: vec![name(preferred)], already_default: Some(name(fallback)), + lost: Vec::new(), }); } if is_active(&fallback.id)? { @@ -116,16 +150,20 @@ pub fn choose_target<'a>( Err(Error::NoDeviceAvailable { unavailable: vec![name(preferred), name(fallback)], already_default: None, + lost: Vec::new(), }) } /// Performs a toggle: reads the current console default's id, applies [`choose_target`] and /// makes the chosen device the default for [`roles_for`]`(switch_communications)`. /// -/// The configured ids are first looked up with [`AudioSystem::lookup`], so the decision compares -/// ids as Windows spells them: a configured id in another case, or a stable id that `GetDevice` -/// also accepts, is still recognized as the current default. A configured id that Windows does -/// not know is kept as written and counts as inactive. +/// The configured devices are first looked up as the module documentation describes, so the +/// decision compares ids as Windows spells them: a configured id in another case, or a stable id +/// that `GetDevice` also accepts, is still recognized as the current default. A device that cannot +/// be found keeps its configured id and counts as inactive. +/// +/// [`Outcome::moved`] carries any new ids. The configuration is the caller's to update, because +/// the settings dialog's Test toggle runs on unsaved selections. /// /// `mark` is called with the name of each phase as it ends (`"default_read"`, /// `"target_chosen"`, `"set_done"`), which is how `--timing` stamps the hot path; pass `|_| {}` @@ -144,11 +182,15 @@ pub fn perform( ) -> Result { let current = audio.default_id(Role::Console)?; mark("default_read"); - let (windows1, active1) = as_windows_knows_it(audio, device1)?; - let (windows2, active2) = as_windows_knows_it(audio, device2)?; - let target = choose_target(current.as_deref(), &windows1, &windows2, |id| { - Ok(if id == windows1.id { active1 } else { active2 }) - })?; + let (found1, found2) = locate_both(audio, device1, device2)?; + let target = choose_target(current.as_deref(), &found1.device, &found2.device, |id| { + Ok(if id == found1.device.id { + found1.active + } else { + found2.active + }) + }) + .map_err(|error| separate_lost(error, [&found1, &found2]))?; mark("target_chosen"); // The console default was read just above; passing it on saves a second read (~1 ms). audio.set_default_known( @@ -157,35 +199,224 @@ pub fn perform( Some((Role::Console, current.as_deref())), )?; mark("set_done"); - let (configured, other) = if std::ptr::eq(target.device, std::ptr::from_ref(&windows1)) { - (device1, device2) - } else { - (device2, device1) - }; + let (chosen, configured, not_chosen, other) = + if std::ptr::eq(target.device, std::ptr::from_ref(&found1.device)) { + (&found1, device1, &found2, device2) + } else { + (&found2, device2, &found1, device1) + }; // Report the configured devices, not the looked-up copies: the only warning names the device // that was not chosen. - let warning = target.warning.map(|_| Warning::PreferredUnavailable { - preferred: other.clone(), + let warning = target.warning.map(|_| { + let preferred = other.clone(); + if not_chosen.lost { + Warning::PreferredLost { preferred } + } else { + Warning::PreferredUnavailable { preferred } + } }); + let mut device = configured.clone(); + if chosen.moved { + device.id.clone_from(&chosen.device.id); + } Ok(Outcome { - device: configured.clone(), + device, warning, + moved: [found1.new_id(), found2.new_id()], }) } -/// `device` with its id as Windows spells it, and whether it is active. An id Windows does not -/// know is kept as written and reported inactive. -fn as_windows_knows_it(audio: &AudioSystem, device: &DeviceRef) -> Result<(DeviceRef, bool)> { - let (id, active) = audio - .lookup(&device.id)? - .unwrap_or_else(|| (device.id.clone(), false)); - Ok(( - DeviceRef { - id, - name: device.name.clone(), +/// The configured devices with their current ids, for the settings dialog, which opens with the +/// right devices selected even when a saved id has stopped working. A device that cannot be found +/// is returned as configured. +/// +/// # Errors +/// +/// [`Error::Com`] when a lookup fails. +pub fn relocate( + audio: &AudioSystem, + device1: &DeviceRef, + device2: &DeviceRef, +) -> Result<(DeviceRef, DeviceRef)> { + let (found1, found2) = locate_both(audio, device1, device2)?; + let current = |found: Located, configured: &DeviceRef| { + if found.moved { + found.device + } else { + configured.clone() + } + }; + Ok((current(found1, device1), current(found2, device2))) +} + +/// Brings `config` up to date after a toggle and returns whether it changed. The caller saves it. +/// +/// `moved` is [`Outcome::moved`]: a device found under a new id gets that id, its current friendly +/// name and a fresh stable id. A device without a stable id gets one, or `""` when Windows has +/// none, which keeps this from running again on the next toggle. `name_of` and `stable_id_of` are +/// [`AudioSystem::name_of`] and [`AudioSystem::stable_id_of`]. +/// +/// On the normal path this costs two `Option` checks and calls neither. A read that fails changes +/// nothing, so the next toggle tries again: the switch has already happened, and these reads must +/// not turn it into an error. +pub fn update_config( + config: &mut Config, + moved: &[Option; 2], + mut name_of: impl FnMut(&str) -> Result>, + mut stable_id_of: impl FnMut(&str) -> Result>, +) -> bool { + let mut changed = false; + for (device, moved) in [&mut config.device1, &mut config.device2] + .into_iter() + .zip(moved) + { + if let Some(id) = moved { + device.id.clone_from(id); + device.stable_id = None; + if let Ok(Some(name)) = name_of(id) { + device.name = name; + } + changed = true; + } + if device.stable_id.is_none() { + if let Ok(stable_id) = stable_id_of(&device.id) { + device.stable_id = Some(stable_id.unwrap_or_default()); + changed = true; + } + } + } + changed +} + +/// A configured device as Windows knows it right now. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Located { + /// The configured device with its current id, as Windows spells it. The hot path has no use + /// for the stable id here, so it is not copied. + device: DeviceRef, + /// Whether that endpoint is active. + active: bool, + /// The saved id stopped working and `device.id` is the endpoint found in its place. + moved: bool, + /// Windows knows neither saved id, so `device.id` still is the saved id. + lost: bool, +} + +impl Located { + /// `device` found under `id`. + fn new(device: &DeviceRef, id: String, active: bool) -> Self { + Self { + device: DeviceRef { + id, + name: device.name.clone(), + stable_id: None, + }, + active, + moved: false, + lost: false, + } + } + + /// The id to record, when the device moved. + fn new_id(&self) -> Option { + self.moved.then(|| self.device.id.clone()) + } +} + +/// Finds both configured devices (steps 1 to 3 of the module documentation). +fn locate_both( + audio: &AudioSystem, + device1: &DeviceRef, + device2: &DeviceRef, +) -> Result<(Located, Located)> { + let mut found1 = locate(device1, |id| audio.lookup(id))?; + let mut found2 = locate(device2, |id| audio.lookup(id))?; + if found1.lost || found2.lost { + // Best effort: without the list the device simply stays lost, and the other configured + // device can still be switched to. + let endpoints = audio.list_active().unwrap_or_default(); + find_by_name(&mut found1, &found2.device.id, &endpoints); + find_by_name(&mut found2, &found1.device.id, &endpoints); + } + Ok((found1, found2)) +} + +/// Steps 1 and 2 of the module documentation. `lookup` is [`AudioSystem::lookup`]. +/// +/// An active endpoint at the saved id returns after that one lookup: this is the hot path. The +/// stable id is consulted for an inactive endpoint too, because an update can leave the old +/// endpoint behind as "not present" next to its replacement. Unplugged or disabled devices pay +/// for that with one lookup. +fn locate( + device: &DeviceRef, + mut lookup: impl FnMut(&str) -> Result>, +) -> Result { + let saved = match lookup(&device.id)? { + Some((id, true)) => return Ok(Located::new(device, id, true)), + saved => saved, + }; + let by_stable_id = match device.stable_id.as_deref() { + Some(stable_id) if !stable_id.is_empty() => lookup(stable_id)?, + _ => None, + }; + let moved = |id, active| Located { + moved: true, + ..Located::new(device, id, active) + }; + Ok(match (saved, by_stable_id) { + (Some((old, false)), Some((new, true))) if !same_endpoint_id(&old, &new) => { + moved(new, true) + } + (Some((id, active)), _) => Located::new(device, id, active), + (None, Some((id, active))) => moved(id, active), + (None, None) => Located { + lost: true, + ..Located::new(device, device.id.clone(), false) }, - active, - )) + }) +} + +/// Step 3 of the module documentation: a lost device takes over the one active endpoint that has +/// its saved name, unless that is `other_id`, the other configured device. Names are compared +/// exactly and no match or several matches leave it lost, because a wrong guess would switch audio +/// to the wrong device and save that. +fn find_by_name(found: &mut Located, other_id: &str, endpoints: &[Endpoint]) { + if !found.lost || found.device.name.is_empty() { + return; + } + let mut matches = endpoints.iter().filter(|endpoint| { + endpoint.name == found.device.name && !same_endpoint_id(&endpoint.id, other_id) + }); + if let (Some(endpoint), None) = (matches.next(), matches.next()) { + found.device.id.clone_from(&endpoint.id); + found.active = true; + found.moved = true; + found.lost = false; + } +} + +/// `error` with the devices Windows no longer knows moved from `unavailable` to `lost`, so the +/// message does not claim that they are disconnected. +fn separate_lost(error: Error, found: [&Located; 2]) -> Error { + match error { + Error::NoDeviceAvailable { + unavailable, + already_default, + lost: _, + } => { + let (lost, unavailable) = unavailable.into_iter().partition(|name| { + found + .iter() + .any(|found| found.lost && display_name(&found.device) == name) + }); + Error::NoDeviceAvailable { + unavailable, + already_default, + lost, + } + } + other => other, + } } /// The name to show for a configured device: its friendly name, or its id when the name is empty. @@ -210,7 +441,275 @@ mod tests { DeviceRef { id: id.to_owned(), name: name.to_owned(), + stable_id: None, + } + } + + fn stable(id: &str, name: &str, stable_id: &str) -> DeviceRef { + DeviceRef { + stable_id: Some(stable_id.to_owned()), + ..device(id, name) + } + } + + /// `locate` against a fake Windows: `known` lists `(accepted id, current id, active)`. + /// Returns the result and the ids that were looked up, in order. + fn locate_in(known: &[(&str, &str, bool)], device: &DeviceRef) -> (Located, Vec) { + let mut asked = Vec::new(); + let located = locate(device, |id| { + asked.push(id.to_owned()); + Ok(known + .iter() + .find(|(accepted, _, _)| *accepted == id) + .map(|&(_, current, active)| (current.to_owned(), active))) + }) + .unwrap(); + (located, asked) + } + + fn state(located: &Located) -> (&str, bool, bool, bool) { + ( + &located.device.id, + located.active, + located.moved, + located.lost, + ) + } + + const NEW: &str = "{0.0.0.00000000}.{c9f7cc37-c71e-486e-beee-5010ed8616b7}"; + const STABLE: &str = "{0.0.0.00000000}.{001.{31E3A048-D22C-4C75-853B-3DC39707E1A7}}"; + + #[test] + fn an_active_saved_id_costs_exactly_one_lookup() { + // The hot path: the stable id is never consulted. + let (found, asked) = locate_in(&[(A, A, true)], &stable(A, "n", STABLE)); + assert_eq!(state(&found), (A, true, false, false)); + assert_eq!(asked, [A]); + } + + #[test] + fn the_id_is_reported_as_windows_spells_it_without_counting_as_moved() { + let upper = A.to_uppercase(); + let (found, _) = locate_in(&[(&upper, A, true)], &device(&upper, "n")); + assert_eq!(state(&found), (A, true, false, false)); + assert_eq!(found.new_id(), None); + } + + #[test] + fn a_replaced_id_is_found_through_the_stable_id() { + let saved = stable(A, "n", STABLE); + let (found, asked) = locate_in(&[(STABLE, NEW, true)], &saved); + assert_eq!(state(&found), (NEW, true, true, false)); + assert_eq!(found.new_id().as_deref(), Some(NEW)); + assert_eq!(asked, [A, STABLE]); + + // The device is off right now: the new id is still the right one to remember, and a + // message about it is true. + let (found, _) = locate_in(&[(STABLE, NEW, false)], &saved); + assert_eq!(state(&found), (NEW, false, true, false)); + } + + #[test] + fn an_old_endpoint_left_behind_gives_way_to_its_replacement() { + let saved = stable(A, "n", STABLE); + let (found, _) = locate_in(&[(A, A, false), (STABLE, NEW, true)], &saved); + assert_eq!(state(&found), (NEW, true, true, false)); + } + + #[test] + fn an_unplugged_device_stays_where_it_is() { + // The stable id leads to the same endpoint. + let saved = stable(A, "n", STABLE); + let (found, asked) = locate_in(&[(A, A, false), (STABLE, A, false)], &saved); + assert_eq!(state(&found), (A, false, false, false)); + assert_eq!(asked, [A, STABLE]); + + // Case differences are not a move. + let upper = A.to_uppercase(); + let (found, _) = locate_in(&[(A, A, false), (STABLE, &upper, true)], &saved); + assert_eq!(state(&found), (A, false, false, false)); + + // A second inactive endpoint is no better than the saved one. + let (found, _) = locate_in(&[(A, A, false), (STABLE, NEW, false)], &saved); + assert_eq!(state(&found), (A, false, false, false)); + + // Without a stable id, known but inactive is not lost: the name is never tried. + let (found, _) = locate_in(&[(A, A, false)], &device(A, "n")); + assert_eq!(state(&found), (A, false, false, false)); + } + + #[test] + fn a_device_windows_does_not_know_is_lost() { + for saved in [device(A, "n"), stable(A, "n", ""), stable(A, "n", STABLE)] { + let (found, _) = locate_in(&[], &saved); + assert_eq!(state(&found), (A, false, false, true), "{saved:?}"); + } + // "" means Windows had none: it must not be passed to Windows. + let (_, asked) = locate_in(&[], &stable(A, "n", "")); + assert_eq!(asked, [A]); + } + + #[test] + fn lookup_errors_propagate() { + let failure = |_: &str| { + Err(Error::Com { + call: "IMMDeviceEnumerator::GetDevice", + hr: windows_core::HRESULT::from_win32(5), + }) + }; + let result = locate(&stable(A, "n", STABLE), failure); + assert!(matches!(result, Err(Error::Com { .. })), "{result:?}"); + } + + fn endpoint(id: &str, name: &str) -> Endpoint { + Endpoint { + id: id.to_owned(), + name: name.to_owned(), + } + } + + fn lost(id: &str, name: &str) -> Located { + Located { + lost: true, + ..Located::new(&device(id, name), id.to_owned(), false) + } + } + + #[test] + fn a_lost_device_takes_over_the_one_endpoint_with_its_exact_name() { + let mut found = lost(A, "PG42UQ"); + let endpoints = [endpoint(OTHER, "FiiO"), endpoint(NEW, "PG42UQ")]; + find_by_name(&mut found, B, &endpoints); + assert_eq!(state(&found), (NEW, true, true, false)); + } + + #[test] + fn the_name_never_guesses() { + let unchanged = |name: &str, other: &str, endpoints: &[Endpoint]| { + let mut found = lost(A, name); + find_by_name(&mut found, other, endpoints); + assert_eq!(state(&found), (A, false, false, true), "{name:?}"); + }; + // Nothing has that name, or only in another case. + unchanged("PG42UQ", B, &[endpoint(NEW, "FiiO")]); + unchanged("PG42UQ", B, &[endpoint(NEW, "pg42uq")]); + // Two endpoints have it. + unchanged( + "Speakers", + B, + &[endpoint(NEW, "Speakers"), endpoint(OTHER, "Speakers")], + ); + // The only match is the other configured device. + unchanged("Speakers", NEW, &[endpoint(NEW, "Speakers")]); + // No saved name: an endpoint without a name must not match it. + unchanged("", B, &[endpoint(NEW, "")]); + } + + #[test] + fn the_other_device_does_not_make_a_name_ambiguous() { + let mut found = lost(A, "Speakers"); + let endpoints = [endpoint(B, "Speakers"), endpoint(NEW, "Speakers")]; + find_by_name(&mut found, B, &endpoints); + assert_eq!(state(&found), (NEW, true, true, false)); + } + + #[test] + fn only_lost_devices_are_matched_by_name() { + let saved = device(A, "PG42UQ"); + let mut found = Located::new(&saved, A.to_owned(), false); + find_by_name(&mut found, B, &[endpoint(NEW, "PG42UQ")]); + assert_eq!(state(&found), (A, false, false, false)); + } + + #[test] + fn lost_devices_are_separated_from_unavailable_ones() { + let unplugged = Located::new(&device(A, "Unplugged"), A.to_owned(), false); + let gone = lost(B, "Gone"); + let error = Error::NoDeviceAvailable { + unavailable: vec!["Unplugged".to_owned(), "Gone".to_owned()], + already_default: None, + lost: Vec::new(), + }; + match separate_lost(error, [&unplugged, &gone]) { + Error::NoDeviceAvailable { + unavailable, lost, .. + } => { + assert_eq!(unavailable, ["Unplugged"]); + assert_eq!(lost, ["Gone"]); + } + other => panic!("unexpected error {other:?}"), } + let other = Error::NoDefaultDevice; + assert!(matches!( + separate_lost(other, [&unplugged, &gone]), + Error::NoDefaultDevice + )); + } + + /// `update_config`'s readers for a test that must not read anything. + fn no_read(id: &str) -> Result> { + panic!("unexpected read of {id}"); + } + + fn config_with(device1: DeviceRef, device2: DeviceRef) -> Config { + Config::new(device1, device2, true) + } + + #[test] + fn a_complete_configuration_is_left_alone_without_any_read() { + // The normal path: this is what every toggle after the first one runs. + let mut config = config_with(stable(A, "a", STABLE), stable(B, "b", "")); + let before = config.clone(); + assert!(!update_config(&mut config, &[None, None], no_read, no_read)); + assert_eq!(config, before); + } + + #[test] + fn a_moved_device_gets_its_new_id_name_and_stable_id() { + let mut config = config_with(stable(A, "old name", "old"), stable(B, "b", "kept")); + let changed = update_config( + &mut config, + &[Some(NEW.to_owned()), None], + |id| Ok(Some(format!("name of {id}"))), + |id| Ok(Some(format!("stable of {id}"))), + ); + assert!(changed); + assert_eq!(config.device1.id, NEW); + assert_eq!(config.device1.name, format!("name of {NEW}")); + assert_eq!(config.device1.stable_id, Some(format!("stable of {NEW}"))); + assert_eq!(config.device2, stable(B, "b", "kept")); + } + + #[test] + fn a_missing_stable_id_is_recorded_once() { + let mut config = config_with(device(A, "a"), device(B, "b")); + let changed = update_config(&mut config, &[None, None], no_read, |id| { + Ok((id == A).then(|| STABLE.to_owned())) + }); + assert!(changed); + assert_eq!(config.device1.stable_id.as_deref(), Some(STABLE)); + // Windows has none for Device 2: remembered as "", so the next toggle reads nothing. + assert_eq!(config.device2.stable_id.as_deref(), Some("")); + assert!(!update_config(&mut config, &[None, None], no_read, no_read)); + } + + #[test] + fn failed_reads_change_nothing_and_are_tried_again() { + let failure = |_: &str| { + Err(Error::Com { + call: "IPropertyStore::GetValue", + hr: windows_core::HRESULT::from_win32(5), + }) + }; + let mut config = config_with(device(A, "a"), stable(B, "b", "")); + assert!(!update_config(&mut config, &[None, None], no_read, failure)); + assert_eq!(config.device1, device(A, "a")); + + // A moved device keeps its saved name, and the stable id stays unknown. + let mut config = config_with(stable(A, "a", "old"), stable(B, "b", "")); + let moved = [Some(NEW.to_owned()), None]; + assert!(update_config(&mut config, &moved, failure, failure)); + assert_eq!(config.device1, device(NEW, "a")); } fn devices() -> (DeviceRef, DeviceRef) { @@ -272,9 +771,15 @@ mod tests { Expect::Fallback(B, A) } } + // `choose_target` only knows "not active"; `perform` tells lost devices apart. + Ok(Target { + warning: Some(warning), + .. + }) => panic!("unexpected warning {warning:?}"), Err(Error::NoDeviceAvailable { unavailable, already_default, + .. }) => match (unavailable.as_slice(), already_default) { ([missing], Some(default)) => { Expect::Blocked(id_named(missing), id_named(&default)) @@ -374,6 +879,7 @@ mod tests { Error::NoDeviceAvailable { unavailable, already_default: None, + .. } => assert_eq!(unavailable, [A, "Speakers"]), other => panic!("unexpected error {other:?}"), } @@ -382,6 +888,7 @@ mod tests { Error::NoDeviceAvailable { unavailable, already_default: Some(default), + .. } => { assert_eq!(unavailable, ["Speakers"]); assert_eq!(default, A); @@ -399,6 +906,14 @@ mod tests { warning.to_string(), "\"喇叭 (FiiO BTA30 PRO)\" is not connected; switched to the other device instead" ); + let warning = Warning::PreferredLost { + preferred: device(A, "喇叭 (FiiO BTA30 PRO)"), + }; + assert_eq!( + warning.to_string(), + "Windows no longer has the saved device \"喇叭 (FiiO BTA30 PRO)\"; switched to the \ + other device instead. Open Settings to choose a replacement" + ); } #[test] diff --git a/tests/real_device.rs b/tests/real_device.rs index 1534595..2512fe9 100644 --- a/tests/real_device.rs +++ b/tests/real_device.rs @@ -274,7 +274,11 @@ fn test_devices(audio: &AudioSystem) -> toggle_audio::Result toggle_audio::Result<()> { Ok(()) } +/// Every stable id Windows reports leads back to its endpoint. Read-only. +#[test] +#[ignore = "needs a Windows machine with an active playback device"] +fn stable_ids_resolve_to_their_endpoint() -> toggle_audio::Result<()> { + let _serial = serial(); + let audio = AudioSystem::new()?; + for endpoint in audio.list_active()? { + let Some(stable_id) = audio.stable_id_of(&endpoint.id)? else { + eprintln!("{} has no stable id", endpoint.name); + continue; + }; + let found = audio.lookup(&stable_id)?; + assert_eq!(found, Some((endpoint.id.clone(), true)), "{endpoint:?}"); + } + assert_eq!(audio.stable_id_of(UNKNOWN_ID)?, None); + Ok(()) +} + +/// The dead-id scenario this repair exists for, without touching any default device: the saved +/// ids no longer exist, as after a Windows update, and the devices are found again. +#[test] +#[ignore = "needs a Windows machine with an active playback device"] +fn dead_ids_are_relocated_by_stable_id_and_by_name() -> toggle_audio::Result<()> { + let _serial = serial(); + let audio = AudioSystem::new()?; + let default = console_default(&audio)?; + let spare = DeviceRef { + id: "{0.0.0.00000000}.{00000000-0000-0000-0000-000000000001}".to_owned(), + ..DeviceRef::default() + }; + + if let Some(stable_id) = audio.stable_id_of(&default.id)? { + let saved = DeviceRef { + id: UNKNOWN_ID.to_owned(), + name: "not the name, so only the stable id can find it".to_owned(), + stable_id: Some(stable_id), + }; + let (found, _) = toggle::relocate(&audio, &saved, &spare)?; + assert_eq!(found.id, default.id); + } + + let unique_name = audio + .list_active()? + .iter() + .filter(|endpoint| endpoint.name == default.name) + .count() + == 1; + if unique_name { + let saved = DeviceRef { + id: UNKNOWN_ID.to_owned(), + name: default.name.clone(), + stable_id: None, + }; + let (found, _) = toggle::relocate(&audio, &saved, &spare)?; + assert_eq!(found.id, default.id); + } + + let saved = DeviceRef { + id: UNKNOWN_ID.to_owned(), + name: "no endpoint has this name".to_owned(), + stable_id: None, + }; + let (found, _) = toggle::relocate(&audio, &saved, &spare)?; + assert_eq!(found, saved); + Ok(()) +} + #[test] #[ignore = "needs a Windows machine with an active playback device"] fn com_can_be_initialized_repeatedly_on_one_thread() -> toggle_audio::Result<()> {