fix(windows): uninstall removes the audio devices the host mints
ci / bun-nix (pull_request) Successful in 25s
ci / web (pull_request) Successful in 1m21s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m37s
android / android (pull_request) Successful in 6m12s
ci / rust (pull_request) Successful in 7m37s

The field report: uninstalling punktfunk left "Punktfunk Speakers",
"Punktfunk Microphone" and the per-pad "Wireless Controller" endpoints
sitting in Windows' Sound settings forever.

They have no installer payload behind them, which is why nothing in the
uninstall touched them. The host mints them at RUNTIME as extra devnodes
on Valve's streaming-audio drivers, and both providers deliberately
re-resolve their devnode across restarts instead of re-minting it — so
they persist by design. Persistent across restarts must not mean
permanent: the .iss even documented leaving them behind as a decision.

New `driver uninstall --audio` leg (a third Inno [UninstallRun] entry,
after the two driver legs and well after `service uninstall`, since a
live host re-mints on its next wiring pass):

* restores the default playback device first, if a host that died
  mid-stream left it parked on our loopback sink — otherwise Windows
  re-picks by its own ranking rather than giving the operator back the
  device they had;
* removes every MEDIA-class devnode carrying one of our three durable
  owner markers (pad slot, minted role, probe), phantoms included;
* deletes each endpoint's MMDevices record, resolved through the
  devnode link BEFORE the devnode goes.

Marker-matched, never name-matched: our instances are name-identical to
Steam's own, and Steam's devnodes, its drivers, and a VB-CABLE from the
era when we bundled one carry no marker and stay untouched. A ROOT\
enumeration guard means a marker-shaped value on a real sound card can
never cost the user their hardware.

The registry half is best-effort: those keys are SYSTEM-owned and the
uninstaller runs elevated but as a user, so on a stock box the record
survives as an inert NOTPRESENT entry that Sound settings only shows
behind "Show Disconnected Devices". The device itself is gone either
way, and seizing ownership of SYSTEM registry keys from an uninstaller
is a worse thing to ship than that scrap.
This commit is contained in:
2026-08-09 20:53:22 +02:00
parent 003ce8bea7
commit d87a8df28d
8 changed files with 325 additions and 21 deletions
+5
View File
@@ -199,6 +199,11 @@ pub(crate) mod audio_probe;
#[cfg(target_os = "windows")]
#[path = "audio/windows/minted.rs"]
pub(crate) mod minted;
// The uninstall sweep over every audio devnode the two providers above (and the probe) mint —
// pub(crate) for `driver uninstall --audio`, the installer's [UninstallRun] leg.
#[cfg(target_os = "windows")]
#[path = "audio/windows/devnode_cleanup.rs"]
pub(crate) mod devnode_cleanup;
#[cfg(target_os = "windows")]
#[path = "audio/windows/wasapi_cap.rs"]
mod wasapi_cap;
@@ -406,6 +406,34 @@ fn recover_orphaned_default() {
});
}
/// [`recover_orphaned_default`]'s uninstall-time twin: same "put the operator's device back if
/// the default is still parked on ours" rule, minus the `Once` gate (the uninstaller is a fresh
/// process that runs it exactly once) — and it always drops the marker file, because there is no
/// next host run to consume it.
///
/// Why the uninstaller needs this at all: the devnode sweep that follows deletes the endpoint the
/// default may still point at. Windows would then re-pick something on its own, but it re-picks by
/// its OWN ranking, not the device the operator had before we parked it. Restoring first means
/// uninstalling gives the box back exactly the default it came with.
///
/// Returns whether a device was actually put back — the caller only logs it.
pub(crate) fn unpark_default_for_uninstall() -> bool {
let path = park_marker_path();
let Ok(s) = std::fs::read_to_string(&path) else {
return false;
};
let _ = std::fs::remove_file(&path);
let mut lines = s.lines();
let (Some(prev), Some(set)) = (lines.next(), lines.next()) else {
return false;
};
// A default the operator changed by hand since the park wins, exactly as on the recovery path.
if default_render_id().as_deref() != Some(set) {
return false;
}
set_default_endpoint(prev).is_ok()
}
/// Make `id` the default playback device for the duration of the desktop-audio capture,
/// remembering the operator's current default (in memory + the crash marker) the FIRST time so
/// [`restore_default_playback`] can put it back. Nothing is remembered when `id` already is the
@@ -42,8 +42,10 @@ use windows::Win32::System::Registry::{
};
/// Marker value in a probe devnode's `Device Parameters` key — how `cleanup` finds what this
/// devtest minted (and nothing else).
const PROBE_MARKER: &str = "PunktfunkAudioProbe";
/// devtest minted (and nothing else). pub(crate): the uninstall sweep
/// ([`devnode_cleanup`](super::devnode_cleanup)) sweeps this family too, so a devtest run on an
/// operator's box cannot outlive the product.
pub(crate) const PROBE_MARKER: &str = "PunktfunkAudioProbe";
/// DeviceDesc for probe devnodes (visible in Device Manager until the INF install renames it).
const PROBE_DESC: &str = "Punktfunk Audio Probe";
/// How long to wait for audiosrv to register a minted endpoint.
@@ -0,0 +1,217 @@
//! Uninstall-time removal of every audio device punktfunk minted on this box — the
//! `punktfunk-host driver uninstall --audio` leg the installer's Inno `[UninstallRun]` calls.
//!
//! The field report this exists for: uninstalling punktfunk left "Punktfunk Speakers",
//! "Punktfunk Microphone" and the per-pad "Wireless Controller" endpoints sitting in Windows'
//! Sound settings forever. They are not files and no uninstaller deletes them by walking a
//! payload list — they are DEVNODES this host created at runtime, and they persist exactly
//! because they are designed to ([`minted`](super::minted) and
//! [`pad_endpoint`](super::pad_endpoint) both re-resolve their devnodes across host restarts
//! rather than re-minting them). Persistent across restarts must not mean permanent.
//!
//! What gets swept: every MEDIA-class devnode carrying one of the three durable owner markers
//! this product writes into `Device Parameters`, whatever minted it —
//!
//! * [`pad_endpoint::PAD_INDEX_VALUE`](super::pad_endpoint::PAD_INDEX_VALUE) — the per-pad
//! DualSense speaker endpoints,
//! * [`minted::ROLE_MARKER`](super::minted::ROLE_MARKER) — the Speakers/Microphone substrate,
//! * [`audio_probe::PROBE_MARKER`](super::audio_probe::PROBE_MARKER) — devtest leftovers, so a
//! probe run on an operator's box cannot outlive the product either.
//!
//! Marker-matched, never name-matched: our devnodes are instances of VALVE's streaming-audio
//! drivers and are name-identical to Steam's own (the same reason the wiring plan works by
//! recorded id). Steam's devnodes, its driver packages, and a VB-CABLE from the era when we
//! bundled one all carry no marker and are therefore untouchable here — uninstalling punktfunk
//! removes what punktfunk created, and nothing else.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{audio_control, audio_probe, minted, pad_endpoint as pe};
use anyhow::Result;
use windows::Win32::Devices::DeviceAndDriverInstallation::SetupDiEnumDeviceInfo;
/// The `Device Parameters` REG_DWORD each punktfunk-minted devnode family stamps on itself. The
/// VALUE is what differs per family; presence of the NAME is "this one is ours", which is all a
/// sweep needs.
const OWNER_MARKERS: [&str; 3] = [
pe::PAD_INDEX_VALUE,
minted::ROLE_MARKER,
audio_probe::PROBE_MARKER,
];
/// What one sweep removed. `endpoint_records` is counted separately from `devnodes` because the
/// registry half is best-effort by design — see [`delete_endpoint_record`].
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Removed {
pub devnodes: usize,
pub devnode_failures: usize,
pub endpoint_records: usize,
}
/// Restore the default playback device if we left it parked, then remove every audio devnode
/// this product minted, newest registry record and all.
///
/// Best-effort throughout, like the rest of the (un)install path: a devnode that refuses to go
/// is counted and reported, never fatal — a non-zero exit here would abort the whole uninstaller
/// over a virtual speaker.
pub(crate) fn purge() -> Result<Removed> {
// FIRST, before the sweep deletes the endpoint the default may still point at. A host that
// died mid-stream leaves the box's default playback parked on our loopback sink; Windows
// would re-pick on its own once the device vanishes, but by its own ranking rather than by
// what the operator had. Putting it back is the difference between "the box works again"
// and "the box works again, on the device it started with".
if audio_control::unpark_default_for_uninstall() {
println!("restored the default playback device this host had parked");
}
let mut out = Removed::default();
for inst in owned_devnodes()? {
// Resolve the endpoint records BEFORE the devnode goes. An endpoint's MMDevices key is
// tied to us only through its `{1}.<instance id>` devnode link — once the devnode is
// removed, nothing left in the store says the record was ever ours, and a sweep that
// guessed by NAME is exactly the mistake this module refuses to make.
let records: Vec<(&str, String)> = [
(pe::MMDEV_RENDER_PATH, pe::find_endpoint_for_devnode(&inst)),
(
pe::MMDEV_CAPTURE_PATH,
pe::find_capture_endpoint_for_devnode(&inst),
),
]
.into_iter()
.filter_map(|(path, found)| Some((path, found.ok().flatten()?)))
.collect();
if !remove_devnode(&inst) {
out.devnode_failures += 1;
// The device is still there, so its record still belongs to a live endpoint.
continue;
}
out.devnodes += 1;
for (path, endpoint) in records {
if delete_endpoint_record(path, &endpoint) {
out.endpoint_records += 1;
}
}
}
Ok(out)
}
/// Every MEDIA-class devnode carrying one of [`OWNER_MARKERS`]. Enumerated WITHOUT `DIGCF_PRESENT`
/// (that is what [`pe::media_class_devs`] gives us), so a phantom left by a crashed host is swept
/// too — the same "ghost in Device Manager forever" complaint the pad and vdisplay legs fixed.
fn owned_devnodes() -> Result<Vec<String>> {
let set = pe::media_class_devs()?;
let mut out = Vec::new();
for i in 0.. {
let mut did = pe::devinfo_data();
// SAFETY: live set; `did` is a live out-param with cbSize set.
if unsafe { SetupDiEnumDeviceInfo(set.0, i, &mut did) }.is_err() {
break; // ERROR_NO_MORE_ITEMS
}
let Some(inst) = pe::instance_id(&set, &did) else {
continue;
};
if !is_removable_instance(&inst) {
continue;
}
if OWNER_MARKERS
.iter()
.any(|m| pe::read_devparam_dword(&set, &did, m).is_some())
{
out.push(inst);
}
}
Ok(out)
}
/// A devnode this sweep is allowed to remove: ROOT-enumerated, i.e. software-created.
///
/// Every devnode we mint comes from `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)` on the MEDIA
/// class, which always yields `ROOT\MEDIA\NNNN`. Nothing else can be ours — so if a marker name
/// we own ever collides with a value some vendor writes under a REAL sound card's `Device
/// Parameters`, this guard is what stops an uninstall from taking the user's hardware with it.
fn is_removable_instance(instance_id: &str) -> bool {
instance_id.to_ascii_uppercase().starts_with("ROOT\\")
}
/// `pnputil /remove-device` — the same teardown `audio-probe cleanup` and the driver legs use.
/// Called by absolute path: an uninstaller must not depend on the invoking shell's `%PATH%`.
fn remove_devnode(instance_id: &str) -> bool {
let windir = std::env::var("WINDIR").unwrap_or_else(|_| r"C:\Windows".into());
match std::process::Command::new(format!(r"{windir}\System32\pnputil.exe"))
.args(["/remove-device", instance_id])
.output()
{
Ok(o) if o.status.success() => {
println!("removed audio devnode {instance_id}");
true
}
Ok(o) => {
eprintln!(
"warning: pnputil could not remove {instance_id} (status {:?}): {}",
o.status.code(),
String::from_utf8_lossy(&o.stderr).trim()
);
false
}
Err(e) => {
eprintln!("warning: could not run pnputil for {instance_id}: {e}");
false
}
}
}
/// Delete one endpoint's MMDevices record — the `{guid}` subkey holding its name, its stamped
/// formats and its per-endpoint volume/settings.
///
/// BEST-EFFORT ON PURPOSE, and quiet when it fails. These keys are owned by SYSTEM and grant
/// Administrators read only (the same ACL that forces the stamping path through
/// `grant_system_full_control`), while the uninstaller runs elevated but as a USER — so on a
/// stock box this is denied and the record stays. What stays is inert: with the devnode gone the
/// endpoint is NOTPRESENT, which Sound settings surface only behind "Show Disconnected Devices",
/// and nothing re-animates it without a devnode to link to. Buying that last cosmetic scrap would
/// mean an uninstaller seizing ownership of SYSTEM-owned registry keys — a worse thing to ship
/// than the leftover. The DEVICE, which is what the field report was about, is gone either way.
fn delete_endpoint_record(reg_path: &str, endpoint_id: &str) -> bool {
use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_ALL_ACCESS};
use winreg::RegKey;
let Ok(guid) = pe::endpoint_guid_part(endpoint_id) else {
return false;
};
let Ok(store) =
RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey_with_flags(reg_path, KEY_ALL_ACCESS)
else {
return false;
};
store.delete_subkey_all(guid).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_root_enumerated_devnodes_are_ours() {
assert!(is_removable_instance(r"ROOT\MEDIA\0003"));
// PnP casing is not guaranteed.
assert!(is_removable_instance(r"root\media\0004"));
// A real sound card, however it got a marker-shaped value written under it.
assert!(!is_removable_instance(
r"HDAUDIO\FUNC_01&VEN_10EC&DEV_0900\4&1c4a4e5&0&0001"
));
assert!(!is_removable_instance(r"USB\VID_046D&PID_0A38\ABCDEF"));
// Not a prefix match on the string "ROOT" appearing anywhere.
assert!(!is_removable_instance(r"SWD\ROOT\MEDIA\0003"));
}
#[test]
fn every_minted_family_is_swept() {
// The sweep is only as complete as this list — a new minted-devnode family that forgets
// to register here would ship the same leak again.
assert!(OWNER_MARKERS.contains(&"PunktfunkPadIndex"));
assert!(OWNER_MARKERS.contains(&"PunktfunkAudioRole"));
assert!(OWNER_MARKERS.contains(&"PunktfunkAudioProbe"));
}
}
@@ -32,8 +32,9 @@ use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::{Duration, Instant};
/// Durable role marker in a minted devnode's `Device Parameters` key.
const ROLE_MARKER: &str = "PunktfunkAudioRole";
/// Durable role marker in a minted devnode's `Device Parameters` key. pub(crate): the uninstall
/// sweep ([`devnode_cleanup`](super::devnode_cleanup)) matches devnodes on it.
pub(crate) const ROLE_MARKER: &str = "PunktfunkAudioRole";
/// How long to wait for audiosrv to register a freshly minted endpoint.
const ENDPOINT_WAIT: Duration = Duration::from_secs(15);
/// Minimum spacing between provisioning retries once the startup attempt failed
@@ -87,13 +87,15 @@ const DEVNODE_DESC: &str = "Punktfunk Pad Audio";
/// The multi-instancing Steam Remote Play render driver we ride on.
const SSS_HWID: &str = "ROOT\\SteamStreamingSpeakers";
/// Registry value under the devnode's `Device Parameters` key persisting which pad slot the
/// devnode serves (REG_DWORD).
const PAD_INDEX_VALUE: &str = "PunktfunkPadIndex";
/// devnode serves (REG_DWORD). pub(crate): the uninstall sweep
/// ([`devnode_cleanup`](super::devnode_cleanup)) matches devnodes on it.
pub(crate) const PAD_INDEX_VALUE: &str = "PunktfunkPadIndex";
/// The endpoint store for render endpoints (each subkey = one endpoint GUID).
const MMDEV_RENDER_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\MMDevices\Audio\Render";
pub(crate) const MMDEV_RENDER_PATH: &str =
r"SOFTWARE\Microsoft\Windows\CurrentVersion\MMDevices\Audio\Render";
/// The capture-direction sibling of [`MMDEV_RENDER_PATH`] — where a paired device's microphone
/// half registers (the `audio-probe` devtest's S3 lookup).
const MMDEV_CAPTURE_PATH: &str =
pub(crate) const MMDEV_CAPTURE_PATH: &str =
r"SOFTWARE\Microsoft\Windows\CurrentVersion\MMDevices\Audio\Capture";
/// WASAPI endpoint-id prefix for render endpoints (`{0.0.0.00000000}.{guid}`).
const ENDPOINT_ID_PREFIX: &str = "{0.0.0.00000000}.";
@@ -355,8 +357,9 @@ fn reg_registry_value(v: &StampValue) -> winreg::RegValue<'static> {
}
/// The per-endpoint GUID portion of a WASAPI endpoint id (`{0.0.0.00000000}.{guid}` →
/// `{guid}`) — the endpoint's MMDevices registry key name.
fn endpoint_guid_part(endpoint_id: &str) -> Result<&str> {
/// `{guid}`) — the endpoint's MMDevices registry key name. pub(crate): the uninstall sweep
/// deletes those keys by name.
pub(crate) fn endpoint_guid_part(endpoint_id: &str) -> Result<&str> {
endpoint_id
.rfind('{')
.map(|i| &endpoint_id[i..])
+50 -9
View File
@@ -46,14 +46,14 @@ fn run_capture(cmd: &str, args: &[&str]) -> String {
.unwrap_or_default()
}
// ── `driver install [--gamepad] --dir <stage>` / `driver uninstall [--gamepad]` ────────────────
// ── `driver install [--gamepad] --dir <stage>` / `driver uninstall [--gamepad|--audio]` ────────
pub fn driver_main(args: &[String]) -> Result<()> {
match args.first().map(String::as_str) {
Some("install") => driver_install(&args[1..]),
Some("uninstall") => driver_uninstall(&args[1..]),
_ => bail!(
"usage: punktfunk-host driver install --dir <stage> [--gamepad]\n\
\x20 punktfunk-host driver uninstall [--gamepad]"
\x20 punktfunk-host driver uninstall [--gamepad|--audio]"
),
}
}
@@ -409,16 +409,25 @@ fn remove_pad_devnodes() {
}
}
// ── `driver uninstall [--gamepad]` ──────────────────────────────────────────────────────────────
// ── `driver uninstall [--gamepad|--audio]` ──────────────────────────────────────────────────────
// The uninstaller's cleanup counterpart (Inno [UninstallRun]) — the field report was that our
// virtual-device drivers survived an uninstall. Removes the pf-vdisplay device node(s) + driver
// package, or (--gamepad) the pf-gamepad/pf-xusb driver packages (their devnodes are per-session
// SwDeviceCreate'd and are already gone once the service stopped). Locale-safe by construction: we
// never parse pnputil's localized LABELS — devices are matched on the un-localized VALUE side
// (instance IDs / device IDs), and driver packages are found by scanning %WINDIR%\INF\oem*.inf
// CONTENT for our driver names, then passed to pnputil by file name.
// virtual devices survived an uninstall. Removes the pf-vdisplay device node(s) + driver package,
// or (--gamepad) the pf-gamepad/pf-xusb driver packages (their devnodes are per-session
// SwDeviceCreate'd and are already gone once the service stopped), or (--audio) the audio devnodes
// the HOST mints at runtime — the same complaint one layer up, since those are created by the
// running host rather than by any driver payload the installer laid down. Locale-safe by
// construction: we never parse pnputil's localized LABELS — devices are matched on the
// un-localized VALUE side (instance IDs / device IDs / registry markers), and driver packages are
// found by scanning %WINDIR%\INF\oem*.inf CONTENT for our driver names, then passed to pnputil by
// file name.
fn driver_uninstall(args: &[String]) -> Result<()> {
// The audio leg touches no driver package and no certificate — it removes devnodes the host
// minted on Valve's drivers — so it returns before the cert purge below rather than making
// that purge run a third time per uninstall.
if flag_present(args, "--audio") {
return uninstall_audio_devices();
}
let gamepad = flag_present(args, "--gamepad");
let (what, res) = if gamepad {
("gamepad", uninstall_gamepad())
@@ -437,6 +446,38 @@ fn driver_uninstall(args: &[String]) -> Result<()> {
Ok(())
}
/// Remove the "Punktfunk Speakers"/"Punktfunk Microphone" endpoints and the per-pad DualSense
/// speaker endpoints the running host minted — the audio half of the surviving-virtual-device
/// complaint. Must run AFTER `service uninstall`: a live host re-mints them on its next wiring
/// pass, which would make this sweep look like it did nothing.
///
/// Never removes Steam's streaming-audio DRIVERS. Ours are extra devnodes riding on drivers that
/// belong to Steam and that the user's own Remote Play still needs; the sweep is marker-matched
/// (see `audio::devnode_cleanup`) precisely so it can tell the two apart.
fn uninstall_audio_devices() -> Result<()> {
match crate::audio::devnode_cleanup::purge() {
Ok(r) if r.devnodes == 0 && r.devnode_failures == 0 => {
println!("no punktfunk audio devices to remove")
}
Ok(r) => {
println!(
"removed {} punktfunk audio device(s), {} endpoint record(s)",
r.devnodes, r.endpoint_records
);
if r.devnode_failures > 0 {
eprintln!(
"warning: {} punktfunk audio device(s) could not be removed — they can be \
deleted from Device Manager (View Show hidden devices)",
r.devnode_failures
);
}
}
// Best-effort like every other leg: an enumeration that fails must not fail the uninstall.
Err(e) => eprintln!("warning: audio device cleanup: {e:#}"),
}
Ok(())
}
fn uninstall_pf_vdisplay() -> Result<()> {
// 1. Remove the ROOT device node(s) the installer created via nefconc (leaving them would keep
// a ghost "punktfunk virtual display" in Device Manager forever — the exact complaint).
+9 -2
View File
@@ -340,10 +340,17 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "service uninstall"; Flags: ru
; install laid down); `driver uninstall` is best-effort and no-ops when nothing is installed.
; A VB-CABLE from an OLDER punktfunk install (bundled until the audio-substrate change) is
; deliberately NOT removed: it is a third-party shared component the user may use elsewhere.
; The host's own minted audio devnodes ("Punktfunk Speakers/Microphone") are likewise left in
; place - they are plain instances of Steam's streaming drivers, inert without the host.
Filename: "{app}\punktfunk-host.exe"; Parameters: "driver uninstall"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkVdisplayDriverUninstall"
Filename: "{app}\punktfunk-host.exe"; Parameters: "driver uninstall --gamepad"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkGamepadDriverUninstall"
; ...and the audio devices the RUNNING HOST mints ("Punktfunk Speakers", "Punktfunk Microphone",
; the per-pad "Wireless Controller" endpoints). These have no installer payload behind them - the
; host creates them at runtime and re-resolves them across restarts by design - so nothing else in
; this uninstall would ever touch them, and the field report was that they sat in Sound settings
; forever after an uninstall. Marker-matched, so Steam's own streaming-audio devices and drivers
; (which our instances ride on, and which Remote Play still needs) are left alone. Runs after the
; two driver legs, and well after `service uninstall`: a live host re-mints them on its next
; wiring pass.
Filename: "{app}\punktfunk-host.exe"; Parameters: "driver uninstall --audio"; Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkAudioDeviceUninstall"
#ifdef WithWeb
; Remove the console's firewall rule + any LEGACY PunktfunkWeb task and stray listener (the
; service-supervised console itself died with `service uninstall` above, via its kill-on-close job;