Cycle the Windows default audio output from a global hotkey

A single 306 KB GUI-subsystem binary with no tray icon and no settings
window: one hidden popup owns the hotkey, the device-change callbacks and
the flyout, and the process sleeps in GetMessageW at 0% CPU in between.

Windows exposes no public API for setting the default audio endpoint, so
this uses the undocumented IPolicyConfig COM interface, confined to
src/policy.rs. Verified working on build 26200. Everything else -
enumeration, IMMNotificationClient, RegisterHotKey, the flyout - is
documented Win32.

The flyout uses the DWM acrylic backdrop and rounded corners the shell
uses for its own flyouts, with text drawn via DrawThemeTextEx and
DTT_COMPOSITED into a 32-bpp DIB, which is what keeps GDI from leaving
black fringes on a composited surface.

Console and Multimedia roles move by default; Communications stays put so
voice chat keeps its headset. Config is a hand-parsed INI reloaded on the
next keypress, keeping the dependency list to the windows crate alone.

Built by cross-compiling from a devcontainer with mingw-w64, so nothing is
installed on the Windows host and the exe imports only system DLLs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 07:29:04 +02:00
commit 77be2c188d
18 changed files with 2135 additions and 0 deletions

2
.cargo/config.toml Normal file
View File

@@ -0,0 +1,2 @@
[build]
target = "x86_64-pc-windows-gnu"

7
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,7 @@
FROM mcr.microsoft.com/devcontainers/rust:1-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends mingw-w64 \
&& rm -rf /var/lib/apt/lists/*
RUN rustup target add x86_64-pc-windows-gnu

View File

@@ -0,0 +1,36 @@
{
"name": "Rust (Windows cross-compile)",
"build": {
"dockerfile": "Dockerfile"
},
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
"runArgs": ["--network=host"],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "cargo fetch",
// Configure tool-specific properties.
"customizations": {
"vscode": {
"settings": {
// Without these, every `windows` crate item is cfg-ed out on Linux
// and rust-analyzer reports the whole crate as errors.
"rust-analyzer.cargo.target": "x86_64-pc-windows-gnu",
"rust-analyzer.check.allTargets": false
},
"extensions": [
"rust-lang.rust-analyzer",
"tamasfe.even-better-toml"
]
}
}
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

156
Cargo.lock generated Normal file
View File

@@ -0,0 +1,156 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "audioswitch"
version = "0.1.0"
dependencies = [
"windows",
"windows-core",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab72a15cf68d77cb0987d3684aa8a45c5ef827e8cb49ee2f30bfd7ba2feb519f"
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]

44
Cargo.toml Normal file
View File

@@ -0,0 +1,44 @@
[package]
name = "audioswitch"
version = "0.1.0"
edition = "2021"
description = "Cycle the Windows default audio output from a global hotkey, with a native OSD flyout"
license = "MIT"
[dependencies]
# The `interface` and `implement` macros expand to `::windows_core::…` paths, so the
# crate has to be nameable directly. It is the same crate `windows` re-exports, so the
# COM types are identical.
windows-core = "0.62"
[dependencies.windows]
version = "0.62"
features = [
"Win32_Devices_FunctionDiscovery",
"Win32_Foundation",
"Win32_Graphics_Dwm",
"Win32_Graphics_Gdi",
"Win32_Media_Audio",
"Win32_Media_Audio_Endpoints",
"Win32_Security",
"Win32_System_Com",
"Win32_System_Com_StructuredStorage",
"Win32_System_Console",
"Win32_System_LibraryLoader",
"Win32_System_Registry",
"Win32_System_Threading",
"Win32_Storage_FileSystem",
"Win32_System_Variant",
"Win32_UI_Controls",
"Win32_UI_HiDpi",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_Shell_PropertiesSystem",
"Win32_UI_WindowsAndMessaging",
]
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true

132
README.md Normal file
View File

@@ -0,0 +1,132 @@
# win-audio-output-switcher
A global hotkey that cycles the Windows 11 default audio output, and a flyout that tells
you where you landed.
No tray icon, no window, no settings dialog. One 300 KB executable that sits in
`GetMessageW` at 0% CPU until you press the key.
```
Ctrl+Alt+A -> Casque (FiiO USB DAC)
Ctrl+Alt+A -> Haut-parleurs (Focusrite USB Audio)
Ctrl+Alt+A -> Haut-parleurs (Steam Streaming Speakers)
```
## Behaviour
- **One hotkey, forward through a list.** Configure which outputs are in the cycle and in
what order, or leave it unconfigured and cycle everything that is plugged in.
- **Console and Multimedia roles only.** Windows keeps three separate defaults; the
Communications one is left alone by default, so a headset stays pinned for voice chat
while media follows the hotkey. Change it with `roles =` if you disagree.
- **Stays in sync.** Switch outputs from the Windows volume flyout, or unplug a DAC, and
the next press continues from what Windows actually has selected.
- **Never steals focus.** The flyout is click-through and non-activating, so pressing the
hotkey mid-game or mid-sentence does not pull you out of what you were doing.
## Build
Everything happens in the devcontainer — nothing is installed on the Windows side, and the
resulting binary imports only system DLLs.
```bash
cargo build --release
# -> target/x86_64-pc-windows-gnu/release/audioswitch.exe
```
The container cross-compiles with mingw-w64 but cannot launch a Windows process, so
deploying is a host-side step. From a **WSL shell outside the container**:
```bash
scripts/deploy.sh # copies to %LOCALAPPDATA%\AudioSwitch and (re)starts it
```
## Use
`deploy.sh` puts the binary at `%LOCALAPPDATA%\AudioSwitch\audioswitch.exe`. It is **not
on `PATH`**, so out of the box every invocation is a full path. In PowerShell:
```powershell
& "$env:LOCALAPPDATA\AudioSwitch\audioswitch.exe" # run in the background
& "$env:LOCALAPPDATA\AudioSwitch\audioswitch.exe" --list # and so on
```
or from `cmd.exe`:
```bat
"%LOCALAPPDATA%\AudioSwitch\audioswitch.exe" --list
```
That gets old fast. Pick one of these once and the short commands below work as written:
```powershell
# A profile function - run `notepad $PROFILE` and add:
function audioswitch { & "$env:LOCALAPPDATA\AudioSwitch\audioswitch.exe" @args }
```
```powershell
# ...or put the folder on your user PATH (affects new terminals only):
$old = [Environment]::GetEnvironmentVariable('Path', 'User')
[Environment]::SetEnvironmentVariable('Path', "$old;$env:LOCALAPPDATA\AudioSwitch", 'User')
```
### Verbs
```
audioswitch Run in the background (single instance)
audioswitch --list List outputs and their endpoint IDs
audioswitch --config Create/open the config file
audioswitch --quit Stop the running instance
audioswitch --autostart on Start with Windows (off to undo)
```
`--autostart` records the full path of the exe as it was when you ran it, so it does not
care about `PATH` either way.
The verbs that print borrow the terminal they were launched from. Because this is a
GUI-subsystem binary, a shell may hand back the prompt before the output arrives; pipe it
(`audioswitch --list | Out-String`) if the interleaving bothers you.
## Config
`%APPDATA%\win-audio-output-switcher\config.ini`, created by `--config` already populated
with the outputs you have right now. Edits apply on the next hotkey press — no restart.
```ini
hotkey = ctrl+alt+a
roles = console,multimedia
osd_position = bottom-center ; top-center, center, bottom-right, top-right
osd_duration_ms = 1400
osd_fade = false
; Cycle order. Comment out a line to drop that output.
; Delete the whole section to cycle every active output instead.
[devices]
{0.0.0.00000000}.{a51fdbbf-...} = Casque (FiiO USB DAC-E07K)
{0.0.0.00000000}.{ff879682-...} = Haut-parleurs (Focusrite USB Audio)
```
`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.
## Known limitations
- **Elevated windows swallow the hotkey.** `RegisterHotKey` does not fire while a window
running as administrator has focus, unless this app is elevated too. Inherent to the API.
- **WASAPI exclusive-mode apps do not follow.** Some DAWs and games hold an endpoint open
exclusively and keep playing to the old device until restarted.
- **Exclusive-fullscreen games hide the flyout.** The switch still happens; a topmost
window simply cannot draw over an exclusive-fullscreen swap chain.
## The undocumented bit
Windows exposes no public API for setting the default audio endpoint. Like every other
tool in this space, this one uses the undocumented `IPolicyConfig` COM interface
(`CLSID {870af99c-…}`, `IID {f8679f50-…}`). It has worked from Vista through current
Windows 11 builds, but it is not contractual.
It is confined to [`src/policy.rs`](src/policy.rs) — about forty lines, all of it the
vtable declaration. Everything else (enumeration, change notifications, the hotkey, the
flyout) is documented Win32. If a Windows update ever breaks this tool, that is the file
to look at first.

36
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
#
# Copy the cross-compiled exe to a real Windows path and (re)start it.
#
# Run this on the WSL host, NOT inside the devcontainer: the container has no Windows
# interop, so it can build the binary but cannot launch it. Copying to %LOCALAPPDATA%
# rather than launching over \\wsl.localhost avoids zone-of-origin and file-locking
# quirks on the WSL share.
set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exe="$repo/target/x86_64-pc-windows-gnu/release/audioswitch.exe"
if [ ! -f "$exe" ]; then
echo "No binary. Build it first: cargo build --release" >&2
exit 1
fi
dest_win="$(cmd.exe /c 'echo %LOCALAPPDATA%' 2>/dev/null | tr -d '\r')\\AudioSwitch"
dest="$(wslpath -u "$dest_win")"
mkdir -p "$dest"
# Stop whatever is running, or the copy fails with the file locked.
if [ -f "$dest/audioswitch.exe" ]; then
"$dest/audioswitch.exe" --quit >/dev/null 2>&1 || true
sleep 0.5
fi
cp "$exe" "$dest/audioswitch.exe"
# `cmd.exe /c start` hangs forever under WSL interop here, so launch through PowerShell,
# which detaches cleanly. The cd avoids cmd/powershell complaining about a UNC cwd.
(cd /mnt/c && powershell.exe -NoProfile -Command \
"Start-Process -FilePath '${dest_win}\\audioswitch.exe'") </dev/null >/dev/null 2>&1
echo "Deployed and started: ${dest_win}\\audioswitch.exe"

106
src/audio.rs Normal file
View File

@@ -0,0 +1,106 @@
//! Enumerating render endpoints. All documented Core Audio APIs.
use core::ffi::c_void;
use windows::core::Result;
use windows::Win32::Devices::FunctionDiscovery::PKEY_Device_FriendlyName;
use windows::Win32::Foundation::PROPERTYKEY;
use windows::Win32::Media::Audio::{
eMultimedia, eRender, EndpointFormFactor, IMMDevice, IMMDeviceEnumerator, MMDeviceEnumerator,
UnknownFormFactor, DEVICE_STATE_ACTIVE, PKEY_AudioEndpoint_FormFactor,
};
use windows::Win32::System::Com::StructuredStorage::{PropVariantClear, PROPVARIANT};
use windows::Win32::System::Com::{CoCreateInstance, CoTaskMemFree, CLSCTX_ALL, STGM_READ};
use windows::Win32::System::Variant::{VT_LPWSTR, VT_UI4};
use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore;
/// One audio output, as far as this tool cares.
#[derive(Clone, Debug)]
pub struct Endpoint {
/// Stable endpoint ID, e.g. `{0.0.0.00000000}.{guid}`. This is what the config stores.
pub id: String,
/// e.g. `Headphones (WH-1000XM5)`.
pub name: String,
pub form: EndpointFormFactor,
}
pub fn enumerator() -> Result<IMMDeviceEnumerator> {
unsafe { CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL) }
}
/// Every currently active output, in the order Windows reports them.
pub fn outputs(enumerator: &IMMDeviceEnumerator) -> Result<Vec<Endpoint>> {
unsafe {
let collection = enumerator.EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE)?;
let count = collection.GetCount()?;
let mut endpoints = Vec::with_capacity(count as usize);
for i in 0..count {
// A device that disappears mid-enumeration is normal, not an error.
if let Ok(device) = collection.Item(i) {
if let Ok(endpoint) = describe(&device) {
endpoints.push(endpoint);
}
}
}
Ok(endpoints)
}
}
/// The current default output, or `None` when the machine has no audio output at all.
pub fn default_output(enumerator: &IMMDeviceEnumerator) -> Option<Endpoint> {
unsafe {
let device = enumerator.GetDefaultAudioEndpoint(eRender, eMultimedia).ok()?;
describe(&device).ok()
}
}
unsafe fn describe(device: &IMMDevice) -> Result<Endpoint> {
let id = {
let raw = device.GetId()?;
let owned = raw.to_string().unwrap_or_default();
CoTaskMemFree(Some(raw.0 as *const c_void));
owned
};
let store = device.OpenPropertyStore(STGM_READ)?;
let name = prop_string(&store, &PKEY_Device_FriendlyName)
.unwrap_or_else(|| "Unknown output".to_string());
let form = prop_u32(&store, &PKEY_AudioEndpoint_FormFactor)
.map(|v| EndpointFormFactor(v as i32))
.unwrap_or(UnknownFormFactor);
Ok(Endpoint { id, name, form })
}
/// `PROPVARIANT` has no `Drop` in the generated bindings, so ownership is made explicit.
struct OwnedPropVariant(PROPVARIANT);
impl Drop for OwnedPropVariant {
fn drop(&mut self) {
unsafe {
let _ = PropVariantClear(&mut self.0);
}
}
}
unsafe fn prop_string(store: &IPropertyStore, key: &PROPERTYKEY) -> Option<String> {
let value = OwnedPropVariant(store.GetValue(key).ok()?);
let inner = &value.0.Anonymous.Anonymous;
if inner.vt != VT_LPWSTR {
return None;
}
inner.Anonymous.pwszVal.to_string().ok()
}
unsafe fn prop_u32(store: &IPropertyStore, key: &PROPERTYKEY) -> Option<u32> {
let value = OwnedPropVariant(store.GetValue(key).ok()?);
let inner = &value.0.Anonymous.Anonymous;
if inner.vt != VT_UI4 {
return None;
}
Some(inner.Anonymous.ulVal)
}

252
src/cli.rs Normal file
View File

@@ -0,0 +1,252 @@
//! Command-line verbs.
//!
//! There is no tray icon, so these are the whole control surface: how you discover device
//! IDs, edit the config, stop the thing, and hook it to startup.
use windows::core::w;
use windows::Win32::Foundation::{ERROR_ALREADY_EXISTS, ERROR_SUCCESS, GENERIC_WRITE};
use windows::Win32::Storage::FileSystem::{
CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
};
use windows::Win32::System::Console::{
AttachConsole, GetStdHandle, SetStdHandle, ATTACH_PARENT_PROCESS, STD_ERROR_HANDLE,
STD_OUTPUT_HANDLE,
};
use windows::Win32::System::Registry::{
RegCloseKey, RegCreateKeyExW, RegDeleteValueW, RegSetValueExW, HKEY, HKEY_CURRENT_USER,
KEY_SET_VALUE, REG_OPTION_NON_VOLATILE, REG_SZ,
};
use windows::Win32::System::Threading::CreateMutexW;
use windows::Win32::UI::Shell::ShellExecuteW;
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");
/// True when another instance already holds the single-instance mutex.
///
/// The handle is deliberately leaked: it must live for as long as the process does, and
/// Windows releases it on exit.
pub fn already_running() -> bool {
unsafe {
if CreateMutexW(None, true, crate::mutex_name()).is_err() {
return false;
}
windows::Win32::Foundation::GetLastError() == ERROR_ALREADY_EXISTS
}
}
pub fn list() -> 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();
if outputs.is_empty() {
println!("No active audio outputs.");
return 0;
}
let current = audio::default_output(&enumerator).map(|output| output.id);
println!("Active audio outputs (* = current default):\n");
for output in &outputs {
let marker = if current.as_deref() == Some(output.id.as_str()) { '*' } else { ' ' };
println!("{marker} {}", output.name);
println!(" {}", output.id);
}
println!("\nConfig file: {}", config::path().display());
0
}
pub fn config() -> i32 {
attach_console();
// Seed the file with the IDs of whatever is plugged in right now, so the user edits
// a working config instead of transcribing GUIDs.
let outputs = audio::enumerator()
.ok()
.and_then(|enumerator| audio::outputs(&enumerator).ok())
.unwrap_or_default();
let path = match config::write_template(&outputs) {
Ok(path) => path,
Err(error) => {
eprintln!("Could not write the config file: {error}");
return 1;
}
};
println!("{}", path.display());
let wide: Vec<u16> = path
.to_string_lossy()
.encode_utf16()
.chain(core::iter::once(0))
.collect();
unsafe {
ShellExecuteW(
None,
w!("open"),
windows::core::PCWSTR(wide.as_ptr()),
None,
None,
SW_SHOWNORMAL,
);
}
0
}
pub fn quit() -> i32 {
attach_console();
if crate::signal_running_instance() {
0
} else {
eprintln!("Not running.");
1
}
}
pub fn autostart(value: Option<&str>) -> i32 {
attach_console();
let enable = match value {
Some("on") => true,
Some("off") => false,
_ => {
eprintln!("Usage: audioswitch --autostart on|off");
return 2;
}
};
match set_autostart(enable) {
Ok(()) => {
println!("Start with Windows: {}", if enable { "on" } else { "off" });
0
}
Err(error) => {
eprintln!("Could not update the Run key: {error}");
1
}
}
}
pub fn help() -> i32 {
attach_console();
print_usage();
0
}
pub fn unknown(argument: &str) -> i32 {
attach_console();
eprintln!("Unknown option: {argument}\n");
print_usage();
2
}
fn print_usage() {
// The exe is not on PATH, so echoing back the path it was actually launched from is
// more use than a bare "audioswitch".
let exe = std::env::current_exe()
.map(|path| path.display().to_string())
.unwrap_or_else(|_| "audioswitch.exe".to_string());
println!(
"win-audio-output-switcher - cycle the default audio output from a global hotkey\n\
\n\
Usage ({exe}):\n\
\x20 <exe> Run in the background (single instance)\n\
\x20 <exe> --list List outputs and their endpoint IDs\n\
\x20 <exe> --config Create/open the config file\n\
\x20 <exe> --quit Stop the running instance\n\
\x20 <exe> --autostart on Start with Windows (off to undo)\n\
\x20 <exe> --help This text\n\
\n\
PowerShell shortcut - add to your $PROFILE:\n\
\x20 function audioswitch {{ & \"{exe}\" @args }}"
);
}
fn set_autostart(enable: bool) -> Result<(), windows::core::Error> {
unsafe {
let mut key = HKEY::default();
let status = RegCreateKeyExW(
HKEY_CURRENT_USER,
RUN_KEY,
None,
None,
REG_OPTION_NON_VOLATILE,
KEY_SET_VALUE,
None,
&mut key,
None,
);
if status != ERROR_SUCCESS {
return Err(windows::core::Error::from(status.to_hresult()));
}
let status = if enable {
let exe = std::env::current_exe().unwrap_or_default();
let quoted = format!("\"{}\"", exe.display());
let wide: Vec<u16> = quoted.encode_utf16().chain(core::iter::once(0)).collect();
let bytes = core::slice::from_raw_parts(
wide.as_ptr() as *const u8,
core::mem::size_of_val(wide.as_slice()),
);
RegSetValueExW(key, RUN_VALUE, None, REG_SZ, Some(bytes))
} else {
RegDeleteValueW(key, RUN_VALUE)
};
let _ = RegCloseKey(key);
if status == ERROR_SUCCESS {
Ok(())
} else {
Err(windows::core::Error::from(status.to_hresult()))
}
}
}
/// This is a GUI-subsystem binary, so it has no console of its own. When it was launched
/// from a terminal, borrow that terminal and repoint the std handles at it so ordinary
/// `println!` reaches the user.
///
/// Whoever launched us may already have handed over a stdout - a shell redirect, a pipe,
/// or a console handle inherited through `STARTF_USESTDHANDLES`. Attaching in that case
/// would replace a perfectly good destination with `CONOUT$` and the output would vanish,
/// so an existing handle is always left alone.
fn attach_console() {
unsafe {
if let Ok(existing) = GetStdHandle(STD_OUTPUT_HANDLE) {
if !existing.is_invalid() {
return;
}
}
if AttachConsole(ATTACH_PARENT_PROCESS).is_err() {
return;
}
let Ok(handle) = CreateFileW(
w!("CONOUT$"),
GENERIC_WRITE.0,
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
FILE_FLAGS_AND_ATTRIBUTES(0),
None,
) else {
return;
};
let _ = SetStdHandle(STD_OUTPUT_HANDLE, handle);
let _ = SetStdHandle(STD_ERROR_HANDLE, handle);
}
}

185
src/config.rs Normal file
View File

@@ -0,0 +1,185 @@
//! The config file: `%APPDATA%\win-audio-output-switcher\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
//! contain `{`, `}`, `.` and `,`, all of which pass through a line-based parser untouched
//! but would need quoting in TOML.
use std::fs;
use std::path::PathBuf;
use std::time::SystemTime;
use windows::Win32::Media::Audio::{eCommunications, eConsole, eMultimedia, ERole};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Position {
BottomCenter,
TopCenter,
Center,
BottomRight,
TopRight,
}
#[derive(Clone, Debug)]
pub struct Config {
pub hotkey: String,
pub roles: Vec<ERole>,
pub position: Position,
pub duration_ms: u32,
/// Fade the flyout out instead of hiding it instantly.
///
/// Off by default: fading needs `WS_EX_LAYERED`, which on some builds suppresses the
/// acrylic backdrop that makes the flyout look native. Turn it on and see which you
/// prefer on your machine.
pub fade: bool,
/// Endpoint IDs in cycle order. Empty means "every active output".
pub devices: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
hotkey: "ctrl+alt+a".to_string(),
roles: vec![eConsole, eMultimedia],
position: Position::BottomCenter,
duration_ms: 1400,
fade: false,
devices: Vec::new(),
}
}
}
pub fn path() -> PathBuf {
let base = std::env::var("APPDATA").unwrap_or_default();
PathBuf::from(base)
.join("win-audio-output-switcher")
.join("config.ini")
}
/// Last write time of the config file, used to reload only when it actually changed.
pub fn modified() -> Option<SystemTime> {
fs::metadata(path()).ok()?.modified().ok()
}
/// Load the config, falling back to defaults for anything missing or unparseable.
///
/// A broken line must never stop the tool from running — a switcher that refuses to start
/// because of a typo is worse than one that ignores the typo.
pub fn load() -> Config {
let Ok(text) = fs::read_to_string(path()) else {
return Config::default();
};
parse(&text)
}
fn parse(text: &str) -> Config {
let mut config = Config::default();
let mut in_devices = false;
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
in_devices = line[1..line.len() - 1].trim().eq_ignore_ascii_case("devices");
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let (key, value) = (key.trim(), value.trim());
if in_devices {
// The label after `=` is for the reader's benefit; the ID is authoritative.
if !key.is_empty() {
config.devices.push(key.to_string());
}
continue;
}
match key.to_ascii_lowercase().as_str() {
"hotkey" => config.hotkey = value.to_string(),
"roles" => {
let roles: Vec<ERole> = value.split(',').filter_map(parse_role).collect();
if !roles.is_empty() {
config.roles = roles;
}
}
"osd_position" => {
if let Some(position) = parse_position(value) {
config.position = position;
}
}
"osd_duration_ms" => {
if let Ok(ms) = value.parse::<u32>() {
config.duration_ms = ms.clamp(200, 10_000);
}
}
"osd_fade" => config.fade = matches!(value.to_ascii_lowercase().as_str(), "true" | "1" | "yes"),
_ => {}
}
}
config
}
fn parse_role(value: &str) -> Option<ERole> {
match value.trim().to_ascii_lowercase().as_str() {
"console" => Some(eConsole),
"multimedia" => Some(eMultimedia),
"communications" | "comms" => Some(eCommunications),
_ => None,
}
}
fn parse_position(value: &str) -> Option<Position> {
match value.trim().to_ascii_lowercase().as_str() {
"bottom-center" | "bottom-centre" => Some(Position::BottomCenter),
"top-center" | "top-centre" => Some(Position::TopCenter),
"center" | "centre" => Some(Position::Center),
"bottom-right" => Some(Position::BottomRight),
"top-right" => Some(Position::TopRight),
_ => None,
}
}
/// Write a starter config listing the outputs present right now, so the user edits a file
/// that already has the right IDs in it rather than copying them by hand.
pub fn write_template(outputs: &[crate::audio::Endpoint]) -> std::io::Result<PathBuf> {
let path = path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
if path.exists() {
return Ok(path);
}
let mut text = String::from(
"; win-audio-output-switcher\n\
; Edits take effect on the next hotkey press - no restart needed.\n\
\n\
hotkey = ctrl+alt+a\n\
\n\
; Which of the three Windows default-device roles to move.\n\
; Leaving out `communications` keeps voice chat pinned to your headset.\n\
roles = console,multimedia\n\
\n\
osd_position = bottom-center\n\
osd_duration_ms = 1400\n\
osd_fade = false\n\
\n\
; Cycle order. Comment out a line to drop that output from the cycle.\n\
; Delete the whole section to cycle every active output instead.\n\
[devices]\n",
);
for output in outputs {
text.push_str(&format!("{} = {}\n", output.id, output.name));
}
fs::write(&path, text)?;
Ok(path)
}

38
src/cycle.rs Normal file
View File

@@ -0,0 +1,38 @@
//! Deciding which output the hotkey moves to next.
use crate::audio::Endpoint;
/// The outputs to cycle through, in order.
///
/// An empty `order` means "everything active", which is what a fresh install does before
/// the user has written a config. Configured IDs that are not present right now (an
/// unplugged DAC, a powered-off receiver) are dropped rather than skipped over later, so
/// the cycle only ever contains devices that can actually be selected.
pub fn candidates(outputs: &[Endpoint], order: &[String]) -> Vec<Endpoint> {
if order.is_empty() {
return outputs.to_vec();
}
order
.iter()
.filter_map(|id| outputs.iter().find(|output| &output.id == id))
.cloned()
.collect()
}
/// The next output after `current`, wrapping around.
///
/// When the current default is not in the cycle at all - the user picked something else
/// from the Windows flyout - this starts the cycle from the top rather than guessing.
pub fn next(candidates: &[Endpoint], current: Option<&str>) -> Option<Endpoint> {
if candidates.is_empty() {
return None;
}
let index = current
.and_then(|id| candidates.iter().position(|output| output.id == id))
.map(|position| (position + 1) % candidates.len())
.unwrap_or(0);
candidates.get(index).cloned()
}

92
src/hotkey.rs Normal file
View File

@@ -0,0 +1,92 @@
//! Parsing and registering the global shortcut.
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::Input::KeyboardAndMouse::{
RegisterHotKey, UnregisterHotKey, HOT_KEY_MODIFIERS, MOD_ALT, MOD_CONTROL, MOD_NOREPEAT,
MOD_SHIFT, MOD_WIN,
};
pub const HOTKEY_ID: i32 = 1;
#[derive(Clone, Copy, Debug)]
pub struct Hotkey {
pub modifiers: HOT_KEY_MODIFIERS,
pub vk: u32,
}
/// Parse a spec like `ctrl+alt+a` or `ctrl+shift+f12`.
///
/// `MOD_NOREPEAT` is always added: holding the key down should switch once, not spin
/// through every output at the keyboard repeat rate.
pub fn parse(spec: &str) -> Option<Hotkey> {
let mut modifiers = MOD_NOREPEAT;
let mut vk = None;
for part in spec.split('+') {
let part = part.trim().to_ascii_lowercase();
match part.as_str() {
"" => continue,
"ctrl" | "control" => modifiers |= MOD_CONTROL,
"alt" => modifiers |= MOD_ALT,
"shift" => modifiers |= MOD_SHIFT,
"win" | "super" | "meta" => modifiers |= MOD_WIN,
key => vk = Some(parse_key(key)?),
}
}
// A bare key with no modifier would swallow that key system-wide.
let vk = vk?;
if modifiers == MOD_NOREPEAT {
return None;
}
Some(Hotkey { modifiers, vk })
}
fn parse_key(key: &str) -> Option<u32> {
let bytes = key.as_bytes();
if bytes.len() == 1 {
return match bytes[0] {
b'a'..=b'z' => Some((bytes[0] - b'a') as u32 + 0x41),
b'0'..=b'9' => Some((bytes[0] - b'0') as u32 + 0x30),
_ => None,
};
}
if let Some(number) = key.strip_prefix('f') {
if let Ok(n) = number.parse::<u32>() {
if (1..=24).contains(&n) {
return Some(0x70 + n - 1);
}
}
}
match key {
"space" => Some(0x20),
"tab" => Some(0x09),
"enter" | "return" => Some(0x0D),
"esc" | "escape" => Some(0x1B),
"left" => Some(0x25),
"up" => Some(0x26),
"right" => Some(0x27),
"down" => Some(0x28),
"insert" => Some(0x2D),
"delete" | "del" => Some(0x2E),
"home" => Some(0x24),
"end" => Some(0x23),
"pageup" | "pgup" => Some(0x21),
"pagedown" | "pgdn" => Some(0x22),
_ => None,
}
}
pub fn register(hwnd: HWND, hotkey: Hotkey) -> bool {
unsafe { RegisterHotKey(Some(hwnd), HOTKEY_ID, hotkey.modifiers, hotkey.vk).is_ok() }
}
pub fn unregister(hwnd: HWND) {
unsafe {
let _ = UnregisterHotKey(Some(hwnd), HOTKEY_ID);
}
}

272
src/main.rs Normal file
View File

@@ -0,0 +1,272 @@
//! win-audio-output-switcher
//!
//! 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
//! is asleep in `GetMessageW` until a key is pressed or a device appears.
#![windows_subsystem = "windows"]
mod audio;
mod config;
mod cycle;
mod hotkey;
mod notify;
mod osd;
mod policy;
mod theme;
use std::cell::RefCell;
use std::time::SystemTime;
use windows::core::{w, PCWSTR};
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM};
use windows::Win32::Media::Audio::{IMMDeviceEnumerator, IMMNotificationClient};
use windows::Win32::System::Com::{
CoInitializeEx, CoUninitialize, COINIT_APARTMENTTHREADED,
};
use windows::Win32::UI::HiDpi::{
SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
};
use windows::Win32::UI::WindowsAndMessaging::{
DefWindowProcW, DispatchMessageW, FindWindowW, GetMessageW, PostMessageW, PostQuitMessage,
TranslateMessage, MSG, WM_APP, WM_DESTROY, WM_HOTKEY, WM_PAINT, WM_SETTINGCHANGE, WM_TIMER,
};
use audio::Endpoint;
use config::Config;
use osd::Osd;
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");
struct App {
enumerator: IMMDeviceEnumerator,
/// Held only to keep the callback registration alive.
_notifications: IMMNotificationClient,
config: Config,
config_stamp: Option<SystemTime>,
outputs: Vec<Endpoint>,
osd: Osd,
}
thread_local! {
static APP: RefCell<Option<App>> = const { RefCell::new(None) };
}
fn main() {
unsafe {
// Before any window exists, so every metric we ask for is real pixels.
let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
if CoInitializeEx(None, COINIT_APARTMENTTHREADED).is_err() {
return;
}
}
let args: Vec<String> = std::env::args().skip(1).collect();
let code = match args.first().map(String::as_str) {
Some("--list" | "-l") => cli::list(),
Some("--config" | "-c") => cli::config(),
Some("--quit" | "-q") => cli::quit(),
Some("--autostart") => cli::autostart(args.get(1).map(String::as_str)),
Some("--help" | "-h") => cli::help(),
Some(unknown) => cli::unknown(unknown),
None => run(),
};
unsafe { CoUninitialize() };
std::process::exit(code);
}
/// The normal path: register the hotkey and sit in the message loop.
fn run() -> i32 {
if cli::already_running() {
// Someone double-clicked the exe twice. Nothing to say about it.
return 0;
}
let Ok(enumerator) = audio::enumerator() else {
return 1;
};
let loaded = config::load();
let Ok(osd) = Osd::new(
Some(wndproc),
theme::is_dark(),
loaded.fade,
loaded.position,
loaded.duration_ms,
) else {
return 1;
};
let hwnd = osd.hwnd;
let Ok(notifications) = notify::register(&enumerator, hwnd) else {
return 1;
};
let outputs = audio::outputs(&enumerator).unwrap_or_default();
let registered = hotkey::parse(&loaded.hotkey)
.map(|parsed| hotkey::register(hwnd, parsed))
.unwrap_or(false);
let hotkey_spec = loaded.hotkey.clone();
APP.with(|slot| {
*slot.borrow_mut() = Some(App {
enumerator,
_notifications: notifications,
config: loaded,
config_stamp: config::modified(),
outputs,
osd,
})
});
if !registered {
// Failing silently would look like the exe never started.
with_app(|app| {
app.osd
.show(&format!("Hotkey {hotkey_spec} unavailable"), current_form(app))
});
}
unsafe {
let mut message = MSG::default();
while GetMessageW(&mut message, None, 0, 0).as_bool() {
let _ = TranslateMessage(&message);
DispatchMessageW(&message);
}
}
hotkey::unregister(hwnd);
APP.with(|slot| *slot.borrow_mut() = None);
0
}
unsafe extern "system" fn wndproc(
hwnd: HWND,
message: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
match message {
WM_HOTKEY if wparam.0 as i32 == hotkey::HOTKEY_ID => {
with_app(App::advance);
LRESULT(0)
}
WM_PAINT => {
with_app(|app| app.osd.on_paint());
LRESULT(0)
}
WM_TIMER => {
with_app(|app| {
app.osd.on_timer(wparam.0);
});
LRESULT(0)
}
notify::WM_DEVICES_CHANGED => {
with_app(App::refresh_outputs);
LRESULT(0)
}
WM_SETTINGCHANGE => {
with_app(|app| app.osd.refresh_theme(theme::is_dark()));
DefWindowProcW(hwnd, message, wparam, lparam)
}
WM_QUIT_REQUEST => {
PostQuitMessage(0);
LRESULT(0)
}
WM_DESTROY => {
PostQuitMessage(0);
LRESULT(0)
}
_ => DefWindowProcW(hwnd, message, wparam, lparam),
}
}
/// Window messages can nest, so a busy borrow is skipped rather than panicked on.
fn with_app<R>(action: impl FnOnce(&mut App) -> R) -> Option<R> {
APP.with(|slot| {
let mut borrowed = slot.try_borrow_mut().ok()?;
borrowed.as_mut().map(action)
})
}
fn current_form(app: &App) -> windows::Win32::Media::Audio::EndpointFormFactor {
audio::default_output(&app.enumerator)
.map(|output| output.form)
.unwrap_or(windows::Win32::Media::Audio::UnknownFormFactor)
}
impl App {
/// The hotkey press.
fn advance(&mut self) {
self.reload_config_if_changed();
self.refresh_outputs();
let current = audio::default_output(&self.enumerator);
let candidates = cycle::candidates(&self.outputs, &self.config.devices);
let Some(target) = cycle::next(&candidates, current.as_ref().map(|c| c.id.as_str())) else {
self.osd.show("No audio outputs", windows::Win32::Media::Audio::UnknownFormFactor);
return;
};
if policy::set_default(&target.id, &self.config.roles).is_err() {
self.osd.show(&format!("Couldn't switch to {}", target.name), target.form);
return;
}
// Report what Windows actually ended up with, not what we asked for.
let confirmed = audio::default_output(&self.enumerator).unwrap_or(target);
self.osd.show(&confirmed.name, confirmed.form);
}
fn refresh_outputs(&mut self) {
if let Ok(outputs) = audio::outputs(&self.enumerator) {
self.outputs = outputs;
}
}
/// Picks up edits to the config file without a restart, but only re-reads when the
/// file's timestamp actually moved.
fn reload_config_if_changed(&mut self) {
let stamp = config::modified();
if stamp == self.config_stamp {
return;
}
self.config_stamp = stamp;
let reloaded = config::load();
if reloaded.hotkey != self.config.hotkey {
hotkey::unregister(self.osd.hwnd);
if let Some(parsed) = hotkey::parse(&reloaded.hotkey) {
hotkey::register(self.osd.hwnd, parsed);
}
}
self.osd.reconfigure(reloaded.position, reloaded.duration_ms, reloaded.fade);
self.config = reloaded;
}
}
/// Post the quit request to an instance that is already running.
pub fn signal_running_instance() -> bool {
unsafe {
let Ok(hwnd) = FindWindowW(osd::CLASS_NAME, None) else {
return false;
};
PostMessageW(Some(hwnd), WM_QUIT_REQUEST, WPARAM(0), LPARAM(0)).is_ok()
}
}
pub fn mutex_name() -> PCWSTR {
MUTEX_NAME
}

71
src/notify.rs Normal file
View File

@@ -0,0 +1,71 @@
//! Keeping up with device changes made outside this tool.
//!
//! Callbacks arrive on an MTA worker thread, so they must not touch the window or any of
//! the app state directly. They only post a message; the UI thread does the real work.
use core::ffi::c_void;
use windows::core::{implement, Result, PCWSTR};
use windows::Win32::Foundation::{HWND, LPARAM, PROPERTYKEY, WPARAM};
use windows::Win32::Media::Audio::{
EDataFlow, ERole, IMMDeviceEnumerator, IMMNotificationClient, IMMNotificationClient_Impl,
DEVICE_STATE,
};
use windows::Win32::UI::WindowsAndMessaging::{PostMessageW, WM_APP};
pub const WM_DEVICES_CHANGED: u32 = WM_APP + 1;
#[implement(IMMNotificationClient)]
struct Notifier {
/// `HWND` is not `Send`; the handle is carried as a plain integer and rebuilt on use.
hwnd: isize,
}
impl Notifier {
fn poke(&self) {
unsafe {
let _ = PostMessageW(
Some(HWND(self.hwnd as *mut c_void)),
WM_DEVICES_CHANGED,
WPARAM(0),
LPARAM(0),
);
}
}
}
impl IMMNotificationClient_Impl for Notifier_Impl {
fn OnDeviceStateChanged(&self, _id: &PCWSTR, _state: DEVICE_STATE) -> Result<()> {
self.poke();
Ok(())
}
fn OnDeviceAdded(&self, _id: &PCWSTR) -> Result<()> {
self.poke();
Ok(())
}
fn OnDeviceRemoved(&self, _id: &PCWSTR) -> Result<()> {
self.poke();
Ok(())
}
fn OnDefaultDeviceChanged(&self, _flow: EDataFlow, _role: ERole, _id: &PCWSTR) -> Result<()> {
self.poke();
Ok(())
}
fn OnPropertyValueChanged(&self, _id: &PCWSTR, _key: &PROPERTYKEY) -> Result<()> {
// Friendly names can be renamed under us, but not often enough to rebuild for.
Ok(())
}
}
/// The returned client must be kept alive for as long as notifications are wanted.
pub fn register(enumerator: &IMMDeviceEnumerator, hwnd: HWND) -> Result<IMMNotificationClient> {
let client: IMMNotificationClient = Notifier { hwnd: hwnd.0 as isize }.into();
unsafe {
enumerator.RegisterEndpointNotificationCallback(&client)?;
}
Ok(client)
}

611
src/osd.rs Normal file
View File

@@ -0,0 +1,611 @@
//! The flyout.
//!
//! With no tray icon this is the entire user interface, so it carries the whole "feels
//! native" requirement: the acrylic material Windows 11 uses for its own flyouts, the
//! shell's rounded corners, the shell's UI font, and text drawn the documented way for a
//! DWM-composited surface.
use core::ffi::c_void;
use core::mem::size_of;
use windows::core::{w, Result, PCWSTR};
use windows::Win32::Foundation::{COLORREF, HWND, RECT, SIZE};
use windows::Win32::Graphics::Dwm::{
DwmExtendFrameIntoClientArea, DwmSetWindowAttribute, DWMSBT_TRANSIENTWINDOW,
DWMWA_SYSTEMBACKDROP_TYPE, DWMWA_USE_IMMERSIVE_DARK_MODE, DWMWA_WINDOW_CORNER_PREFERENCE,
DWMWCP_ROUND,
};
use windows::Win32::Graphics::Gdi::{
BeginPaint, BitBlt, CreateCompatibleDC, CreateDIBSection, CreateFontIndirectW, DeleteDC,
DeleteObject, EndPaint, GetGlyphIndicesW, GetMonitorInfoW, GetTextExtentPoint32W,
InvalidateRect, MonitorFromWindow, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB,
DIB_RGB_COLORS, DT_END_ELLIPSIS, DT_LEFT, DT_SINGLELINE, DT_VCENTER, GGI_MARK_NONEXISTING_GLYPHS,
HBITMAP, HDC, HFONT, HGDIOBJ, LOGFONTW, MONITORINFO, MONITOR_DEFAULTTOPRIMARY, PAINTSTRUCT,
SRCCOPY,
};
use windows::Win32::Graphics::Gdi::{CLEARTYPE_QUALITY, FW_NORMAL};
use windows::Win32::Media::Audio::{
DigitalAudioDisplayDevice, EndpointFormFactor, Headphones, Headset, Speakers, SPDIF,
};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::Controls::{
CloseThemeData, DrawThemeTextEx, OpenThemeData, DTTOPTS, DTT_COMPOSITED, DTT_TEXTCOLOR, HTHEME,
};
use windows::Win32::UI::HiDpi::{GetDpiForWindow, SystemParametersInfoForDpi};
use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, GetClientRect, GetForegroundWindow, GetWindowLongPtrW, KillTimer,
RegisterClassW, SetLayeredWindowAttributes, SetTimer, SetWindowLongPtrW, SetWindowPos,
ShowWindow, GWL_EXSTYLE, HWND_TOPMOST, LWA_ALPHA, NONCLIENTMETRICSW, SPI_GETNONCLIENTMETRICS,
SWP_NOACTIVATE, SWP_SHOWWINDOW, SW_HIDE, WNDCLASSW, WNDPROC, WS_EX_LAYERED, WS_EX_NOACTIVATE,
WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_EX_TRANSPARENT, WS_POPUP,
};
use crate::config::Position;
pub const CLASS_NAME: PCWSTR = w!("AudioSwitchOsdWindow");
pub const WINDOW_TITLE: PCWSTR = w!("Audio output");
pub const TIMER_HIDE: usize = 1;
pub const TIMER_FADE: usize = 2;
/// Fade steps of ~16 ms, i.e. roughly one per frame at 60 Hz.
const FADE_INTERVAL_MS: u32 = 16;
const FADE_STEP: i32 = 28;
/// Segoe Fluent Icons codepoints. `Volume` is the fallback because it is the one glyph
/// certain to exist in every version of the font.
const GLYPH_VOLUME: u16 = 0xE767;
const GLYPH_SPEAKERS: u16 = 0xE7F5;
const GLYPH_HEADPHONE: u16 = 0xE7F6;
const GLYPH_TV: u16 = 0xE7F4;
pub struct Osd {
pub hwnd: HWND,
label: Vec<u16>,
glyph: [u16; 1],
has_glyph: bool,
dark: bool,
fade: bool,
alpha: i32,
position: Position,
duration_ms: u32,
}
impl Osd {
/// Creates the window once, hidden. Showing it later is just a `SetWindowPos`.
pub fn new(wndproc: WNDPROC, dark: bool, fade: bool, position: Position, duration_ms: u32) -> Result<Self> {
let hwnd = unsafe { create_window(wndproc, fade)? };
let osd = Self {
hwnd,
label: Vec::new(),
glyph: [GLYPH_VOLUME],
has_glyph: true,
dark,
fade,
alpha: 255,
position,
duration_ms,
};
osd.apply_theme();
Ok(osd)
}
pub fn reconfigure(&mut self, position: Position, duration_ms: u32, fade: bool) {
self.position = position;
self.duration_ms = duration_ms;
self.set_fade(fade);
}
/// Fading needs `WS_EX_LAYERED`, which is a creation-time style everywhere except
/// that it can also be toggled afterwards - which is what lets `osd_fade` take effect
/// on a config reload instead of only on a restart. The window is always hidden at
/// this point (reload happens before the flyout is shown), so there is nothing to
/// repaint.
fn set_fade(&mut self, fade: bool) {
if fade == self.fade {
return;
}
self.fade = fade;
unsafe {
let current = GetWindowLongPtrW(self.hwnd, GWL_EXSTYLE) as u32;
let updated = if fade {
current | WS_EX_LAYERED.0
} else {
current & !WS_EX_LAYERED.0
};
SetWindowLongPtrW(self.hwnd, GWL_EXSTYLE, updated as isize);
if fade {
// A freshly layered window is invisible until its alpha is set once.
let _ = SetLayeredWindowAttributes(self.hwnd, COLORREF(0), 255, LWA_ALPHA);
}
}
}
/// Re-read the system theme. Called on `WM_SETTINGCHANGE`.
pub fn refresh_theme(&mut self, dark: bool) {
self.dark = dark;
self.apply_theme();
}
fn apply_theme(&self) {
let dark = windows::core::BOOL::from(self.dark);
unsafe {
let _ = DwmSetWindowAttribute(
self.hwnd,
DWMWA_USE_IMMERSIVE_DARK_MODE,
&dark as *const _ as *const c_void,
size_of::<windows::core::BOOL>() as u32,
);
}
}
pub fn show(&mut self, label: &str, form: EndpointFormFactor) {
self.label = label.encode_utf16().collect();
unsafe {
let dpi = GetDpiForWindow(self.hwnd).max(96);
let (glyph, has_glyph) = resolve_glyph(form, dpi);
self.glyph = [glyph];
self.has_glyph = has_glyph;
let (width, height) = self.measure(dpi);
let (x, y) = self.place(width, height, dpi);
// Restart the dwell timer rather than recreating anything: pressing the hotkey
// repeatedly should read as one flyout updating, not a stack of new ones.
let _ = KillTimer(Some(self.hwnd), TIMER_HIDE);
let _ = KillTimer(Some(self.hwnd), TIMER_FADE);
if self.fade {
self.alpha = 255;
let _ = SetLayeredWindowAttributes(self.hwnd, COLORREF(0), 255, LWA_ALPHA);
}
let _ = SetWindowPos(
self.hwnd,
Some(HWND_TOPMOST),
x,
y,
width,
height,
SWP_NOACTIVATE | SWP_SHOWWINDOW,
);
let _ = InvalidateRect(Some(self.hwnd), None, false);
SetTimer(Some(self.hwnd), TIMER_HIDE, self.duration_ms, None);
}
}
pub fn hide(&mut self) {
unsafe {
let _ = KillTimer(Some(self.hwnd), TIMER_HIDE);
let _ = KillTimer(Some(self.hwnd), TIMER_FADE);
let _ = ShowWindow(self.hwnd, SW_HIDE);
}
}
/// Returns true if the timer was one of ours.
pub fn on_timer(&mut self, id: usize) -> bool {
match id {
TIMER_HIDE => {
unsafe {
let _ = KillTimer(Some(self.hwnd), TIMER_HIDE);
}
if self.fade {
unsafe {
SetTimer(Some(self.hwnd), TIMER_FADE, FADE_INTERVAL_MS, None);
}
} else {
self.hide();
}
true
}
TIMER_FADE => {
self.alpha -= FADE_STEP;
if self.alpha <= 0 {
self.hide();
} else {
unsafe {
let _ = SetLayeredWindowAttributes(
self.hwnd,
COLORREF(0),
self.alpha as u8,
LWA_ALPHA,
);
}
}
true
}
_ => false,
}
}
pub fn on_paint(&self) {
unsafe { self.paint() }
}
// -- layout ----------------------------------------------------------------------
fn measure(&self, dpi: u32) -> (i32, i32) {
let pad = scale(18, dpi);
let gap = scale(14, dpi);
let glyph_box = if self.has_glyph { scale(22, dpi) + gap } else { 0 };
let height = scale(52, dpi);
let text_width = unsafe { measure_text(&self.label, dpi) }.min(scale(360, dpi));
(pad + glyph_box + text_width + pad, height)
}
fn place(&self, width: i32, height: i32, dpi: u32) -> (i32, i32) {
let work = unsafe { work_area() };
let margin = scale(48, dpi);
let x = match self.position {
Position::BottomRight | Position::TopRight => work.right - width - margin,
_ => work.left + (work.right - work.left - width) / 2,
};
let y = match self.position {
Position::TopCenter | Position::TopRight => work.top + margin,
Position::Center => work.top + (work.bottom - work.top - height) / 2,
_ => work.bottom - height - margin,
};
(x, y)
}
// -- painting --------------------------------------------------------------------
/// Text over a DWM backdrop has to be drawn into a 32-bpp DIB with `DrawThemeTextEx`
/// and `DTT_COMPOSITED`; a plain `DrawTextW` straight onto the window DC leaves black
/// fringes around every glyph because GDI does not maintain the alpha channel.
unsafe fn paint(&self) {
let mut ps = PAINTSTRUCT::default();
let hdc = BeginPaint(self.hwnd, &mut ps);
let mut client = RECT::default();
let _ = GetClientRect(self.hwnd, &mut client);
let (width, height) = (client.right, client.bottom);
if width > 0 && height > 0 {
if let Some(buffer) = Buffer::new(hdc, width, height) {
let dpi = GetDpiForWindow(self.hwnd).max(96);
let theme = OpenThemeData(Some(self.hwnd), w!("CompositedWindow::Window"));
if !theme.is_invalid() {
self.draw_contents(buffer.hdc, theme, dpi, height);
let _ = CloseThemeData(theme);
}
let _ = BitBlt(hdc, 0, 0, width, height, Some(buffer.hdc), 0, 0, SRCCOPY);
}
}
let _ = EndPaint(self.hwnd, &ps);
}
unsafe fn draw_contents(&self, hdc: HDC, theme: HTHEME, dpi: u32, height: i32) {
let color = if self.dark {
COLORREF(0x00FF_FFFF)
} else {
COLORREF(0x0020_2020)
};
let options = DTTOPTS {
dwSize: size_of::<DTTOPTS>() as u32,
dwFlags: DTT_COMPOSITED | DTT_TEXTCOLOR,
crText: color,
..Default::default()
};
let pad = scale(18, dpi);
let gap = scale(14, dpi);
let glyph_size = scale(22, dpi);
let mut x = pad;
if self.has_glyph {
let font = icon_font(glyph_size);
let previous = SelectObject(hdc, HGDIOBJ(font.0));
let mut rect = RECT {
left: x,
top: 0,
right: x + glyph_size,
bottom: height,
};
let _ = DrawThemeTextEx(
theme,
hdc,
0,
0,
&self.glyph,
DT_LEFT | DT_SINGLELINE | DT_VCENTER,
&mut rect,
Some(&options),
);
SelectObject(hdc, previous);
let _ = DeleteObject(HGDIOBJ(font.0));
x += glyph_size + gap;
}
let font = ui_font(dpi);
let previous = SelectObject(hdc, HGDIOBJ(font.0));
let mut rect = RECT {
left: x,
top: 0,
right: scale(360, dpi) + x,
bottom: height,
};
let _ = DrawThemeTextEx(
theme,
hdc,
0,
0,
&self.label,
DT_LEFT | DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS,
&mut rect,
Some(&options),
);
SelectObject(hdc, previous);
let _ = DeleteObject(HGDIOBJ(font.0));
}
}
// -- window creation -----------------------------------------------------------------
unsafe fn create_window(wndproc: WNDPROC, fade: bool) -> Result<HWND> {
let instance = GetModuleHandleW(None)?;
let class = WNDCLASSW {
lpfnWndProc: wndproc,
hInstance: instance.into(),
lpszClassName: CLASS_NAME,
..Default::default()
};
RegisterClassW(&class);
// NOACTIVATE + TRANSPARENT: never steals focus from the app the user is in, and never
// eats a click. TOOLWINDOW keeps it out of Alt+Tab.
let mut ex_style = WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_TRANSPARENT;
if fade {
ex_style |= WS_EX_LAYERED;
}
let hwnd = CreateWindowExW(
ex_style,
CLASS_NAME,
WINDOW_TITLE,
WS_POPUP,
0,
0,
0,
0,
None,
None,
Some(instance.into()),
None,
)?;
// The backdrop only shows through a client area that DWM considers glass.
let margins = windows::Win32::UI::Controls::MARGINS {
cxLeftWidth: -1,
cxRightWidth: -1,
cyTopHeight: -1,
cyBottomHeight: -1,
};
let _ = DwmExtendFrameIntoClientArea(hwnd, &margins);
let backdrop = DWMSBT_TRANSIENTWINDOW;
let _ = DwmSetWindowAttribute(
hwnd,
DWMWA_SYSTEMBACKDROP_TYPE,
&backdrop as *const _ as *const c_void,
size_of::<i32>() as u32,
);
let corners = DWMWCP_ROUND;
let _ = DwmSetWindowAttribute(
hwnd,
DWMWA_WINDOW_CORNER_PREFERENCE,
&corners as *const _ as *const c_void,
size_of::<i32>() as u32,
);
Ok(hwnd)
}
// -- helpers -------------------------------------------------------------------------
fn scale(dip: i32, dpi: u32) -> i32 {
dip * dpi as i32 / 96
}
/// A 32-bpp top-down DIB and the memory DC it is selected into, released together.
struct Buffer {
hdc: HDC,
bitmap: HBITMAP,
previous: HGDIOBJ,
}
impl Buffer {
unsafe fn new(reference: HDC, width: i32, height: i32) -> Option<Self> {
let info = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: size_of::<BITMAPINFOHEADER>() as u32,
biWidth: width,
// Negative height means top-down, which is what DrawThemeTextEx expects.
biHeight: -height,
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB.0,
..Default::default()
},
..Default::default()
};
let hdc = CreateCompatibleDC(Some(reference));
if hdc.is_invalid() {
return None;
}
let mut bits = core::ptr::null_mut();
let Ok(bitmap) = CreateDIBSection(Some(hdc), &info, DIB_RGB_COLORS, &mut bits, None, 0)
else {
let _ = DeleteDC(hdc);
return None;
};
let previous = SelectObject(hdc, HGDIOBJ(bitmap.0));
Some(Self { hdc, bitmap, previous })
}
}
impl Drop for Buffer {
fn drop(&mut self) {
unsafe {
SelectObject(self.hdc, self.previous);
let _ = DeleteObject(HGDIOBJ(self.bitmap.0));
let _ = DeleteDC(self.hdc);
}
}
}
unsafe fn work_area() -> RECT {
let foreground = GetForegroundWindow();
let monitor = MonitorFromWindow(foreground, MONITOR_DEFAULTTOPRIMARY);
let mut info = MONITORINFO {
cbSize: size_of::<MONITORINFO>() as u32,
..Default::default()
};
if GetMonitorInfoW(monitor, &mut info).as_bool() {
info.rcWork
} else {
RECT { left: 0, top: 0, right: 1920, bottom: 1080 }
}
}
/// The shell's own UI font, a little larger than dialog text — this is meant to be read
/// at a glance, from wherever the user's eyes happen to be.
unsafe fn ui_font(dpi: u32) -> HFONT {
let mut metrics = NONCLIENTMETRICSW {
cbSize: size_of::<NONCLIENTMETRICSW>() as u32,
..Default::default()
};
let ok = SystemParametersInfoForDpi(
SPI_GETNONCLIENTMETRICS.0,
metrics.cbSize,
Some(&mut metrics as *mut _ as *mut c_void),
0,
dpi,
)
.is_ok();
if ok {
let mut font = metrics.lfMessageFont;
// lfHeight is negative (character height), so scaling up makes it more negative.
font.lfHeight = (font.lfHeight as f32 * 1.2) as i32;
CreateFontIndirectW(&font)
} else {
let mut font = LOGFONTW {
lfHeight: -scale(16, dpi),
lfWeight: FW_NORMAL.0 as i32,
lfQuality: CLEARTYPE_QUALITY,
..Default::default()
};
set_face(&mut font.lfFaceName, "Segoe UI");
CreateFontIndirectW(&font)
}
}
unsafe fn icon_font(size: i32) -> HFONT {
let mut font = LOGFONTW {
lfHeight: -size,
lfWeight: FW_NORMAL.0 as i32,
lfQuality: CLEARTYPE_QUALITY,
..Default::default()
};
set_face(&mut font.lfFaceName, "Segoe Fluent Icons");
CreateFontIndirectW(&font)
}
fn set_face(target: &mut [u16; 32], name: &str) {
for (slot, ch) in target.iter_mut().zip(name.encode_utf16()) {
*slot = ch;
}
}
unsafe fn measure_text(text: &[u16], dpi: u32) -> i32 {
if text.is_empty() {
return 0;
}
let hdc = CreateCompatibleDC(None);
if hdc.is_invalid() {
return scale(200, dpi);
}
let font = ui_font(dpi);
let previous = SelectObject(hdc, HGDIOBJ(font.0));
let mut size = SIZE::default();
let width = if GetTextExtentPoint32W(hdc, text, &mut size).as_bool() {
size.cx
} else {
scale(200, dpi)
};
SelectObject(hdc, previous);
let _ = DeleteObject(HGDIOBJ(font.0));
let _ = DeleteDC(hdc);
width
}
/// Pick a glyph for the endpoint, and verify the font actually has it.
///
/// Guessing a codepoint wrong would put a tofu box in the only piece of UI this tool has,
/// so the choice is checked against the font and degrades: specific glyph, then the plain
/// volume glyph, then no glyph at all and text on its own.
unsafe fn resolve_glyph(form: EndpointFormFactor, dpi: u32) -> (u16, bool) {
let preferred = match form {
f if f == Headphones || f == Headset => GLYPH_HEADPHONE,
f if f == Speakers => GLYPH_SPEAKERS,
f if f == DigitalAudioDisplayDevice => GLYPH_TV,
f if f == SPDIF => GLYPH_VOLUME,
_ => GLYPH_VOLUME,
};
let hdc = CreateCompatibleDC(None);
if hdc.is_invalid() {
return (preferred, true);
}
let font = icon_font(scale(22, dpi));
let previous = SelectObject(hdc, HGDIOBJ(font.0));
let exists = |codepoint: u16| -> bool {
let mut index = 0u16;
let text = [codepoint];
let result = GetGlyphIndicesW(
hdc,
PCWSTR(text.as_ptr()),
1,
&mut index,
GGI_MARK_NONEXISTING_GLYPHS,
);
result != u32::MAX && index != 0xFFFF
};
let resolved = if exists(preferred) {
(preferred, true)
} else if exists(GLYPH_VOLUME) {
(GLYPH_VOLUME, true)
} else {
(GLYPH_VOLUME, false)
};
SelectObject(hdc, previous);
let _ = DeleteObject(HGDIOBJ(font.0));
let _ = DeleteDC(hdc);
resolved
}

60
src/policy.rs Normal file
View File

@@ -0,0 +1,60 @@
//! Setting the default audio endpoint.
//!
//! Windows exposes no public API for this. `IPolicyConfig` is undocumented, but it is
//! what every audio switcher has used since Vista. If a Windows update ever breaks this
//! tool, this is the file it breaks in.
#![allow(non_snake_case)]
use core::ffi::c_void;
use windows::core::{interface, IUnknown, IUnknown_Vtbl, Result, GUID, HRESULT, PCWSTR};
use windows::Win32::Media::Audio::ERole;
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
/// `CPolicyConfigClient`.
const CLSID_POLICY_CONFIG_CLIENT: GUID = GUID::from_u128(0x870af99c_171d_4f9e_af0d_e63df40c2bc9);
/// Only `SetDefaultEndpoint` is ever called, but every method preceding it in the vtable
/// has to be declared so the slot indices line up. The signatures of the unused ones are
/// irrelevant; their *order* is not — get it wrong and we call the wrong method.
#[interface("f8679f50-850a-41cf-9c72-430f290290c8")]
unsafe trait IPolicyConfig: IUnknown {
unsafe fn GetMixFormat(&self, id: PCWSTR, format: *mut *mut c_void) -> HRESULT;
unsafe fn GetDeviceFormat(&self, id: PCWSTR, def: i32, format: *mut *mut c_void) -> HRESULT;
unsafe fn ResetDeviceFormat(&self, id: PCWSTR) -> HRESULT;
unsafe fn SetDeviceFormat(&self, id: PCWSTR, end: *mut c_void, mix: *mut c_void) -> HRESULT;
unsafe fn GetProcessingPeriod(
&self,
id: PCWSTR,
def: i32,
default_period: *mut i64,
min_period: *mut i64,
) -> HRESULT;
unsafe fn SetProcessingPeriod(&self, id: PCWSTR, period: *mut i64) -> HRESULT;
unsafe fn GetShareMode(&self, id: PCWSTR, mode: *mut c_void) -> HRESULT;
unsafe fn SetShareMode(&self, id: PCWSTR, mode: *mut c_void) -> HRESULT;
unsafe fn GetPropertyValue(&self, id: PCWSTR, key: *const c_void, value: *mut c_void) -> HRESULT;
unsafe fn SetPropertyValue(&self, id: PCWSTR, key: *const c_void, value: *mut c_void) -> HRESULT;
unsafe fn SetDefaultEndpoint(&self, id: PCWSTR, role: ERole) -> HRESULT;
unsafe fn SetEndpointVisibility(&self, id: PCWSTR, visible: i32) -> HRESULT;
}
/// Make `device_id` the default render endpoint for each of `roles`.
///
/// Roles are applied in order and the first failure aborts — a half-applied switch is
/// more confusing than a clean error.
pub fn set_default(device_id: &str, roles: &[ERole]) -> Result<()> {
let wide: Vec<u16> = device_id.encode_utf16().chain(core::iter::once(0)).collect();
unsafe {
let policy: IPolicyConfig =
CoCreateInstance(&CLSID_POLICY_CONFIG_CLIENT, None, CLSCTX_ALL)?;
for role in roles {
policy.SetDefaultEndpoint(PCWSTR(wide.as_ptr()), *role).ok()?;
}
}
Ok(())
}

34
src/theme.rs Normal file
View File

@@ -0,0 +1,34 @@
//! Light/dark detection, so the flyout matches the rest of the shell.
use core::ffi::c_void;
use windows::core::w;
use windows::Win32::Foundation::ERROR_SUCCESS;
use windows::Win32::System::Registry::{RegGetValueW, HKEY_CURRENT_USER, RRF_RT_REG_DWORD};
/// True when Windows is in dark mode.
///
/// `AppsUseLightTheme` is the value the shell itself follows for app surfaces; the
/// separate `SystemUsesLightTheme` controls the taskbar, which is not what we are
/// matching. Missing value means dark, which is the Windows 11 default.
pub fn is_dark() -> bool {
let mut value = 0u32;
let mut size = core::mem::size_of::<u32>() as u32;
let status = unsafe {
RegGetValueW(
HKEY_CURRENT_USER,
w!(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"),
w!("AppsUseLightTheme"),
RRF_RT_REG_DWORD,
None,
Some(&mut value as *mut u32 as *mut c_void),
Some(&mut size),
)
};
if status != ERROR_SUCCESS {
return true;
}
value == 0
}