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:
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[build]
|
||||
target = "x86_64-pc-windows-gnu"
|
||||
7
.devcontainer/Dockerfile
Normal file
7
.devcontainer/Dockerfile
Normal 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
|
||||
36
.devcontainer/devcontainer.json
Normal file
36
.devcontainer/devcontainer.json
Normal 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
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
156
Cargo.lock
generated
Normal file
156
Cargo.lock
generated
Normal 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
44
Cargo.toml
Normal 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
132
README.md
Normal 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
36
scripts/deploy.sh
Executable 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
106
src/audio.rs
Normal 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
252
src/cli.rs
Normal 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
185
src/config.rs
Normal 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
38
src/cycle.rs
Normal 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
92
src/hotkey.rs
Normal 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
272
src/main.rs
Normal 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
71
src/notify.rs
Normal 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
611
src/osd.rs
Normal 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
60
src/policy.rs
Normal 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
34
src/theme.rs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user