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