From 70494e96bc1d03a6db6662f65647c0e46f54e321 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sat, 19 Sep 2026 13:43:55 +0000 Subject: [PATCH] Add --refresh to repair stale device IDs, and rename to audioswitch Windows gives a device a new endpoint ID when it reappears as another instance, typically a DAC moved to a different USB port, which also gains a "2- " name prefix. The old config line then silently dropped out of the cycle. --refresh re-points each stale line at the present output with the same name (ignoring that prefix), keeps order and comments, adds unmentioned outputs as commented-out lines, and keeps a .bak of the previous file. Also replace every remaining "win-audio-output-switcher" with "audioswitch": the config now lives in %APPDATA%\audioswitch, and the autostart Run value and single-instance mutex use the new name. Co-Authored-By: Claude Opus 5 --- README.md | 12 ++++- src/cli.rs | 58 +++++++++++++++++++- src/config.rs | 143 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 5 +- 4 files changed, 209 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4257777..e79c53a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# win-audio-output-switcher +# audioswitch A global hotkey that cycles the Windows 11 default audio output, and a flyout that tells you where you landed. @@ -76,6 +76,7 @@ $old = [Environment]::GetEnvironmentVariable('Path', 'User') audioswitch Run in the background (single instance) audioswitch --list List outputs and their endpoint IDs audioswitch --config Create/open the config file +audioswitch --refresh Update config device IDs to match current outputs audioswitch --quit Stop the running instance audioswitch --autostart on Start with Windows (off to undo) ``` @@ -89,7 +90,7 @@ GUI-subsystem binary, a shell may hand back the prompt before the output arrives ## Config -`%APPDATA%\win-audio-output-switcher\config.ini`, created by `--config` already populated +`%APPDATA%\audioswitch\config.ini`, created by `--config` already populated with the outputs you have right now. Edits apply on the next hotkey press — no restart. ```ini @@ -106,6 +107,13 @@ osd_fade = false {0.0.0.00000000}.{ff879682-...} = Haut-parleurs (Focusrite USB Audio) ``` +Windows sometimes gives a device a new endpoint ID. Plugging a DAC into a different USB +port is the usual cause, and its name gains a `2- ` prefix as well. The old line then +silently drops out of the cycle. `audioswitch --refresh` fixes this. It moves each stale +line to the present output with the same name (ignoring that prefix), keeps your order and +comments, and adds outputs the file doesn't mention yet as commented-out lines. The +previous file is kept as `config.ini.bak`. + `osd_fade` is off by default. Fading needs `WS_EX_LAYERED`, which on some builds suppresses the acrylic backdrop that makes the flyout look like part of the shell. Turn it on and see which you prefer on your machine. diff --git a/src/cli.rs b/src/cli.rs index dddb43f..77dba25 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -23,7 +23,7 @@ use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; use crate::{audio, config}; const RUN_KEY: windows::core::PCWSTR = w!(r"Software\Microsoft\Windows\CurrentVersion\Run"); -const RUN_VALUE: windows::core::PCWSTR = w!("win-audio-output-switcher"); +const RUN_VALUE: windows::core::PCWSTR = w!("audioswitch"); /// True when another instance already holds the single-instance mutex. /// @@ -102,6 +102,59 @@ pub fn config() -> i32 { 0 } +pub fn refresh() -> i32 { + attach_console(); + + let Ok(enumerator) = audio::enumerator() else { + eprintln!("Could not reach the audio device enumerator."); + return 1; + }; + let outputs = audio::outputs(&enumerator).unwrap_or_default(); + + let path = config::path(); + let Ok(text) = std::fs::read_to_string(&path) else { + // Nothing to refresh, but a fresh template is exactly what a refresh would produce. + return match config::write_template(&outputs) { + Ok(path) => { + println!("No config yet - created {}", path.display()); + 0 + } + Err(error) => { + eprintln!("Could not write the config file: {error}"); + 1 + } + }; + }; + + let pairs: Vec<(&str, &str)> = outputs + .iter() + .map(|output| (output.id.as_str(), output.name.as_str())) + .collect(); + let Some(refreshed) = config::refresh(&text, &pairs) else { + println!("No [devices] section - every active output is already in the cycle."); + return 0; + }; + + for change in &refreshed.changes { + println!(" {change}"); + } + if refreshed.text == text { + println!("Config already up to date: {}", path.display()); + return 0; + } + + // One level of undo, in case a name match picked the wrong device. + let backup = path.with_extension("ini.bak"); + if let Err(error) = std::fs::write(&backup, &text) + .and_then(|()| std::fs::write(&path, &refreshed.text)) + { + eprintln!("Could not update the config file: {error}"); + return 1; + } + println!("\nUpdated {} (previous version in {})", path.display(), backup.display()); + 0 +} + pub fn quit() -> i32 { attach_console(); @@ -158,12 +211,13 @@ fn print_usage() { .unwrap_or_else(|_| "audioswitch.exe".to_string()); println!( - "win-audio-output-switcher - cycle the default audio output from a global hotkey\n\ + "audioswitch - cycle the default audio output from a global hotkey\n\ \n\ Usage ({exe}):\n\ \x20 Run in the background (single instance)\n\ \x20 --list List outputs and their endpoint IDs\n\ \x20 --config Create/open the config file\n\ + \x20 --refresh Update config device IDs to match current outputs\n\ \x20 --quit Stop the running instance\n\ \x20 --autostart on Start with Windows (off to undo)\n\ \x20 --help This text\n\ diff --git a/src/config.rs b/src/config.rs index 41230eb..400706a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -//! The config file: `%APPDATA%\win-audio-output-switcher\config.ini`. +//! The config file: `%APPDATA%\audioswitch\config.ini`. //! //! Deliberately hand-parsed. The whole file is a handful of `key = value` lines plus one //! `[devices]` section, which is not worth a serde + toml dependency — and endpoint IDs @@ -52,7 +52,7 @@ impl Default for Config { pub fn path() -> PathBuf { let base = std::env::var("APPDATA").unwrap_or_default(); PathBuf::from(base) - .join("win-audio-output-switcher") + .join("audioswitch") .join("config.ini") } @@ -158,7 +158,7 @@ pub fn write_template(outputs: &[crate::audio::Endpoint]) -> std::io::Result std::io::Result, +} + +/// Rewrite the `[devices]` section against the outputs present right now. +/// +/// Windows hands a device a new endpoint ID when it reappears as a different instance - +/// typically the same DAC in another USB port, which also gains a `2- ` in its name. The +/// old ID then silently drops out of the cycle. This re-points each stale line at the +/// present output with the same name, keeping the user's order and comments, and lists +/// outputs the file does not mention yet as commented-out lines. +/// +/// `outputs` is `(id, name)` pairs. Returns `None` when there is no `[devices]` section, +/// since then every output is already cycled and there is nothing to refresh. +pub fn refresh(text: &str, outputs: &[(&str, &str)]) -> Option { + struct Entry { + line: usize, + /// Everything before the ID: empty, or the comment marker that disabled the line. + prefix: String, + id: String, + label: String, + } + + let mut lines: Vec = text.lines().map(str::to_string).collect(); + let mut section: Option<(usize, usize)> = None; + let mut entries = Vec::new(); + + for (index, raw) in lines.iter().enumerate() { + let line = raw.trim(); + if line.starts_with('[') && line.ends_with(']') { + if let Some((_, end)) = section.as_mut() { + if *end == usize::MAX { + *end = index; + } + } + if line[1..line.len() - 1].trim().eq_ignore_ascii_case("devices") { + section = Some((index, usize::MAX)); + } + continue; + } + if !matches!(section, Some((_, usize::MAX))) { + continue; + } + + // A disabled device line is still a device line: remap it too, or it would be + // listed again below as a new output. + let body = line.trim_start_matches([';', '#']).trim_start(); + let commented = body.len() != line.len(); + let Some((key, value)) = body.split_once('=') else { + continue; + }; + let key = key.trim(); + if key.is_empty() || (commented && !key.starts_with('{')) { + continue; + } + entries.push(Entry { + line: index, + prefix: line[..line.len() - body.len()].to_string(), + id: key.to_string(), + label: value.trim().to_string(), + }); + } + + let (header, end) = section?; + let end = end.min(lines.len()); + + let present = |id: &str| outputs.iter().find(|(output, _)| *output == id); + let mut claimed: Vec<&str> = entries + .iter() + .filter_map(|entry| present(&entry.id).map(|(id, _)| *id)) + .collect(); + let mut changes = Vec::new(); + + for entry in &entries { + let (id, name) = match present(&entry.id) { + Some(&(id, name)) => (id, name), + None => { + let wanted = normalize(&entry.label); + let found = outputs + .iter() + .find(|(id, name)| !claimed.contains(id) && normalize(name) == wanted); + let Some(&(id, name)) = found else { + changes.push(format!("not present, left as is: {}", entry.label)); + continue; + }; + claimed.push(id); + changes.push(format!("new ID for {name}: {} -> {id}", entry.id)); + (id, name) + } + }; + + let rewritten = format!("{}{id} = {name}", entry.prefix); + if lines[entry.line] != rewritten { + if entry.id == id && entry.label != name { + changes.push(format!("renamed: {} -> {name}", entry.label)); + } + lines[entry.line] = rewritten; + } + } + + // New outputs go at the end of the section, disabled, so refreshing never changes + // what the hotkey cycles through beyond fixing stale IDs. + let insert_at = (header + 1..end) + .rev() + .find(|&index| !lines[index].trim().is_empty()) + .map_or(header + 1, |index| index + 1); + let added: Vec = outputs + .iter() + .filter(|(id, _)| !claimed.contains(id)) + .map(|(id, name)| { + changes.push(format!("added (commented out): {name}")); + format!("; {id} = {name}") + }) + .collect(); + lines.splice(insert_at..insert_at, added); + + let newline = if text.contains("\r\n") { "\r\n" } else { "\n" }; + let mut text = lines.join(newline); + text.push_str(newline); + Some(Refresh { text, changes }) +} + +/// A device name without the instance number Windows inserts for a second copy of the +/// same hardware: `Casque (2- FiiO USB DAC-E07K)` -> `casque (fiio usb dac-e07k)`. +fn normalize(name: &str) -> String { + if let Some(open) = name.find('(') { + let rest = &name[open + 1..]; + let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len(); + if digits > 0 && rest[digits..].starts_with("- ") { + return format!("{}{}", &name[..=open], &rest[digits + 2..]).to_lowercase(); + } + } + name.to_lowercase() +} diff --git a/src/main.rs b/src/main.rs index 94f606f..6c290ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -//! win-audio-output-switcher +//! audioswitch //! //! A global hotkey that cycles the Windows default audio output, and a flyout that says //! which one you landed on. One hidden window, one message loop, no polling: the process @@ -41,7 +41,7 @@ mod cli; /// Asks a running instance to exit. const WM_QUIT_REQUEST: u32 = WM_APP + 2; -const MUTEX_NAME: PCWSTR = w!("Local\\win-audio-output-switcher"); +const MUTEX_NAME: PCWSTR = w!("Local\\audioswitch"); struct App { enumerator: IMMDeviceEnumerator, @@ -71,6 +71,7 @@ fn main() { let code = match args.first().map(String::as_str) { Some("--list" | "-l") => cli::list(), Some("--config" | "-c") => cli::config(), + Some("--refresh" | "-r") => cli::refresh(), Some("--quit" | "-q") => cli::quit(), Some("--autostart") => cli::autostart(args.get(1).map(String::as_str)), Some("--help" | "-h") => cli::help(),