Files
audioswitch/src/policy.rs
Vincent Lannurien 77be2c188d 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>
2026-09-17 07:29:04 +02:00

61 lines
2.7 KiB
Rust

//! 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(())
}