From 77be2c188d0607249eec124ffd51eb9bc9a5c4c8 Mon Sep 17 00:00:00 2001 From: Vincent Lannurien Date: Thu, 17 Sep 2026 07:29:04 +0200 Subject: [PATCH] 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 --- .cargo/config.toml | 2 + .devcontainer/Dockerfile | 7 + .devcontainer/devcontainer.json | 36 ++ .gitignore | 1 + Cargo.lock | 156 ++++++++ Cargo.toml | 44 +++ README.md | 132 +++++++ scripts/deploy.sh | 36 ++ src/audio.rs | 106 ++++++ src/cli.rs | 252 +++++++++++++ src/config.rs | 185 ++++++++++ src/cycle.rs | 38 ++ src/hotkey.rs | 92 +++++ src/main.rs | 272 ++++++++++++++ src/notify.rs | 71 ++++ src/osd.rs | 611 ++++++++++++++++++++++++++++++++ src/policy.rs | 60 ++++ src/theme.rs | 34 ++ 18 files changed, 2135 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100755 scripts/deploy.sh create mode 100644 src/audio.rs create mode 100644 src/cli.rs create mode 100644 src/config.rs create mode 100644 src/cycle.rs create mode 100644 src/hotkey.rs create mode 100644 src/main.rs create mode 100644 src/notify.rs create mode 100644 src/osd.rs create mode 100644 src/policy.rs create mode 100644 src/theme.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..ba54493 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "x86_64-pc-windows-gnu" diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..c1c8e99 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -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 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..b8c8498 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..8815315 --- /dev/null +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8ec4218 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4257777 --- /dev/null +++ b/README.md @@ -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. diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..6a5b595 --- /dev/null +++ b/scripts/deploy.sh @@ -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 2>&1 + +echo "Deployed and started: ${dest_win}\\audioswitch.exe" diff --git a/src/audio.rs b/src/audio.rs new file mode 100644 index 0000000..6fe10e8 --- /dev/null +++ b/src/audio.rs @@ -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 { + unsafe { CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL) } +} + +/// Every currently active output, in the order Windows reports them. +pub fn outputs(enumerator: &IMMDeviceEnumerator) -> Result> { + 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 { + unsafe { + let device = enumerator.GetDefaultAudioEndpoint(eRender, eMultimedia).ok()?; + describe(&device).ok() + } +} + +unsafe fn describe(device: &IMMDevice) -> Result { + 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 { + 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 { + let value = OwnedPropVariant(store.GetValue(key).ok()?); + let inner = &value.0.Anonymous.Anonymous; + + if inner.vt != VT_UI4 { + return None; + } + Some(inner.Anonymous.ulVal) +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..dddb43f --- /dev/null +++ b/src/cli.rs @@ -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 = 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 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 --quit Stop the running instance\n\ + \x20 --autostart on Start with Windows (off to undo)\n\ + \x20 --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 = 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); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..41230eb --- /dev/null +++ b/src/config.rs @@ -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, + 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, +} + +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 { + 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 = 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::() { + 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 { + 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 { + 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 { + 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) +} diff --git a/src/cycle.rs b/src/cycle.rs new file mode 100644 index 0000000..2e9dd64 --- /dev/null +++ b/src/cycle.rs @@ -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 { + 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 { + 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() +} diff --git a/src/hotkey.rs b/src/hotkey.rs new file mode 100644 index 0000000..cc00954 --- /dev/null +++ b/src/hotkey.rs @@ -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 { + 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 { + 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::() { + 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); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..5274c04 --- /dev/null +++ b/src/main.rs @@ -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, + outputs: Vec, + osd: Osd, +} + +thread_local! { + static APP: RefCell> = 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 = 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(action: impl FnOnce(&mut App) -> R) -> Option { + 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 +} diff --git a/src/notify.rs b/src/notify.rs new file mode 100644 index 0000000..72d6dfd --- /dev/null +++ b/src/notify.rs @@ -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 { + let client: IMMNotificationClient = Notifier { hwnd: hwnd.0 as isize }.into(); + unsafe { + enumerator.RegisterEndpointNotificationCallback(&client)?; + } + Ok(client) +} diff --git a/src/osd.rs b/src/osd.rs new file mode 100644 index 0000000..bff1abf --- /dev/null +++ b/src/osd.rs @@ -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, + 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 { + 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::() 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::() 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 { + 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::() as u32, + ); + + let corners = DWMWCP_ROUND; + let _ = DwmSetWindowAttribute( + hwnd, + DWMWA_WINDOW_CORNER_PREFERENCE, + &corners as *const _ as *const c_void, + size_of::() 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 { + let info = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: size_of::() 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::() 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::() 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 +} diff --git a/src/policy.rs b/src/policy.rs new file mode 100644 index 0000000..02ba43f --- /dev/null +++ b/src/policy.rs @@ -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 = 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(()) +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..7b24b17 --- /dev/null +++ b/src/theme.rs @@ -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::() 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 +}