Compare commits

..
Author SHA1 Message Date
enricobuehler 652de8b5e0 docs(changelog): the pad-audio DualSense speaker hides while no client pad is attached
ci / bun-nix (pull_request) Successful in 33s
ci / rust-arm64 (pull_request) Successful in 1m21s
ci / docs-site (pull_request) Successful in 1m14s
android / android (pull_request) Successful in 4m21s
ci / rust (pull_request) Successful in 8m45s
ci / web (pull_request) Successful in 6m32s
2026-08-14 10:26:16 +02:00
enricobuehler ec36597058 fix(audio/windows): the pad-audio DualSense speaker hides while no client pad is attached — idle libScePad titles stalled on it
The per-pad endpoint is stamped to be indistinguishable from a real
DualSense speaker — that is the feature during a pad session (libScePad
titles route haptics audio at it) and a trap the rest of the time: the
endpoint is pre-provisioned at EVERY host start and stayed visible
forever, so an idle Helldivers 2 found it by identity, engaged its
DualSense-haptics path against a device nothing services, and dropped to
2–5 FPS 1% lows — host idle, no controller plugged in, no session ever
run (field-confirmed 2026-08-14: the reporter isolated the 'DualSense
speaker' and disabling it in mmsys.cpl restored full performance).

That manual remedy is now automatic: the endpoint parks HIDDEN
(DEVICE_STATE_DISABLED, IPolicyConfig::SetEndpointVisibility — the call
behind mmsys.cpl's own Disable, vtable slot pinned next to the
SetDefaultEndpoint we already bind) whenever no client pad is attached.
Provisioning hides it at startup, a PUNKTFUNK_PAD_AUDIO=0 host hides
leftovers from earlier runs, and the per-pad streamer shows it for
exactly the pad's lifetime — to a game, a DualSense arriving and
leaving. The devnode, driver binding and stamps stay put (registry-based
resolution finds a disabled endpoint at the next boot), so the flips
raise no PnP traffic and the expensive provisioning still happens once
at boot — the #185 lesson holds.

Devtest: pad-endpoint grew show/hide verbs; tone/capture need a show
first on a parked box.
2026-08-14 10:26:13 +02:00
enricobuehler e5c0d6b4eb docs(changelog): an idle Windows host no longer owns the box's default microphone 2026-08-14 10:12:00 +02:00
enricobuehler 0bba8d7f8c fix(audio/windows): the default recording device is session-scoped now — an idle host parked every game's voice input on a dead virtual mic
The wiring pass asserted 'default recording = virtual mic capture' on EVERY
pass — including the mic pump's eager boot pass — so an idle box permanently
held the Windows default recording device (and, since SetDefaultEndpoint
covers eCommunications, every game's voice input) on a virtual microphone
whose render feeder is idle-stopped, with no restore path at all: not at
session end, not at service stop. Field-measured 2026-08-14: Helldivers 2
(Wwise + always-on voice) played LOCALLY on an idle host tanks to 2–5 FPS 1%
lows, and mmsys.cpl's own Recording tab goes unresponsive polling the same
endpoint; the reporter's Sound settings showed 'Punktfunk Microphone —
Dispositivo predefinito' with the host idle.

The recording default now follows the exact discipline the playback default
has always had — parked only while a desktop-audio capture is open, with the
operator's device remembered (in memory + an on-disk crash marker,
audio-default-rec.prev), restored on capture close, recovered after a crash
on the next boot's first wiring pass, and unparked by the uninstaller. A
game launched during a stream still binds the client's mic (the park runs
before the session's game does); one launched before the stream keeps the
operator's own microphone — the honest answer.

Because earlier builds recorded nothing to restore, an upgraded box would
have stayed wedged on the virtual mic forever: an idle-pass hygiene now
moves a default found sitting on the plan's mic capture back to the first
REAL microphone (pure picker wiring_plan::real_capture, unit-tested against
the field box's exact recording-tab inventory). Session passes are exempt,
and a box with no real microphone is left alone.

Also folded in: the mid-idle drift re-assert is gone with the gating, so a
mic-pump reopen no longer stomps a recording device the operator chose
themselves.
2026-08-14 10:11:58 +02:00
enricobuehler 0ead084838 Merge pull request 'Steam's art lives in Program Files, which was never an allowed art root' (#215) from worktree-steam-art-root-windows into main
arch / build-publish (push) Failing after 32s
ci / web (push) Successful in 1m20s
ci / rust-arm64 (push) Successful in 1m43s
ci / bun-nix (push) Successful in 1m48s
apple / swift (push) Successful in 2m2s
decky / build-publish (push) Failing after 41s
deb / build-publish-host (push) Failing after 2m13s
deb / build-publish-gamescope (push) Failing after 1m46s
deb / build-publish-client-arm64 (push) Successful in 1m26s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 27s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / docs-site (push) Successful in 3m26s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 21s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 21s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
docker / builders-arm64cross (push) Successful in 46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m11s
ci / rust (push) Successful in 8m11s
android / android (push) Successful in 9m34s
docker / deploy-docs (push) Failing after 6m25s
windows-host / package (push) Successful in 13m53s
windows-host / winget-source (push) Skipped
apple / distribute (push) Successful in 12m19s
deb / build-publish (push) Failing after 14m16s
windows-host / canary-manifest (push) Successful in 28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m40s
apple / screenshots (push) Successful in 6m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m21s
deb / smoke-install (push) Failing after 9m33s
2026-08-14 07:51:30 +00:00
enricobuehler 0f9ccfa8b6 fix(ci): funnel the art tests' env overrides through one RAII guard
ci / bun-nix (pull_request) Successful in 24s
apple / swift (pull_request) Successful in 2m8s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m59s
ci / web (pull_request) Successful in 5m47s
ci / docs-site (pull_request) Successful in 5m51s
ci / rust-arm64 (pull_request) Successful in 5m54s
ci / rust (pull_request) Successful in 18m16s
CI gate C (unsafe hygiene) failed on the previous commit: `library/art.rs`
went from 4 process-global-API mentions to 10, because the two new tests each
hand-rolled a set/restore pair the way the two existing ones already did.

The gate says fix the call sites rather than raise the baseline, and it is
right to here — the hand-rolled pattern was also leaking. Each test set
`PUNKTFUNK_LIBRARY_ART_ROOTS` and unset it at the end, so any assertion
firing between the two halves left the override installed for every later
test in the process, turning one real failure into a cascade.

`ArtRootsEnv` now holds the lock and the saved values and restores them on
drop, which runs on an unwind too. `write_env` is the single write point, so
the gate has exactly one pair of call sites to judge: the count drops to 2,
below the old baseline of 4, and stays flat however many tests are added.
Baseline lowered to 2 in the same commit, as the ratchet's policy requires.

⚠ The gate greps for the API names in COMMENTS as well as code, so the SAFETY
comments here deliberately describe the calls instead of naming them.

Re-verified after the refactor: .25 493/493 + clippy clean, .133 12/12 art
tests + clippy clean, `check-unsafe-hygiene.sh` clean locally.
2026-08-14 09:32:43 +02:00
enricobuehler 8d60f1cec0 Merge remote-tracking branch 'origin/main' into worktree-steam-art-root-windows
ci / web (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m34s
ci / bun-nix (pull_request) Successful in 1m51s
android / android (pull_request) Successful in 3m57s
ci / docs-site (pull_request) Successful in 3m8s
ci / rust (pull_request) Failing after 6m35s
# Conflicts:
#	CHANGELOG.md
2026-08-14 09:12:39 +02:00
enricobuehler 6dd4add11b fix(library): Steam's art lives in Program Files, which was never an allowed art root
ci / bun-nix (pull_request) Successful in 20s
android / android (pull_request) Canceled after 1m22s
ci / rust-arm64 (pull_request) Successful in 1m17s
ci / rust (pull_request) Canceled after 1m22s
ci / web (pull_request) Canceled after 1m18s
ci / docs-site (pull_request) Canceled after 1m18s
A field report: the Steam plugin installed, the grid stayed empty, and the
only clue was one warn per sync — `art.hero: local art must be an image file
… inside an allowed art root`.

Two defects, both here.

The art roots defaulted to the users base (`C:\Users`, from `%PUBLIC%`'s
parent). That covers the launchers that install per-user, but not Steam,
which installs to `C:\Program Files (x86)\Steam` and keeps both the things
the plugin publishes there — `appcache\librarycache\<appid>\<hash>\` and each
account's `userdata\<id>\config\grid\`. So every cover was out of root. It is
a v0.28.0 regression: the built-in scanner the plugin replaced served covers
through the legacy `steam:` art-proxy branch, which never passed through the
H-2 confinement, so deleting the scanner routed that art through a gate it
had never been measured against. `art_roots()` now also carries every Steam
install it can find, from the three Program Files vars and from HKLM
`Valve\Steam\InstallPath` so a Steam on another drive counts too. POSIX needs
no equivalent — native and Flatpak Steam are both already under `$HOME`.

The confinement is not weakened. It exists to stop the host (SYSTEM) reading
what the plugin lane (LocalService) cannot reach itself; the Steam directory
is readable by LocalService already, so nothing there is reachable *because*
the host is privileged, and the extension, regular-file, magic-byte and
config-dir gates still apply on top. Tested: `config.vdf` is not servable
from an art root, nor is a non-image wearing `.png`.

Second, and the reason this cost a whole library rather than a thumbnail: the
provider reconcile validated art per entry and 400'd the WHOLE payload on the
first bad value. A path mismatch therefore deleted every game from that
store, and the plugin — which only ever sees `HostRequestError` — could not
say which. A reconcile now strips unservable local art and syncs the rest,
logging one aggregated warn with the count, an example path and the env var.
The invariant the 400 held is unchanged: no unservable path is persisted. The
operator's own single-entry writes keep the hard 400, because there the path
was typed by hand and silence would be the wrong answer.

Verified on Linux (.25: 493/493, clippy clean) and Windows (.133: 12/12 art
tests, clippy clean). The new Windows test is hermetic — it repoints
`%ProgramFiles(x86)%` at a synthetic Steam tree rather than asserting over
whatever Steam the box happens to have, since the vacuous version of that
test is what would have let this ship. Confirmed non-vacuous by disabling the
fix: it fails on "the DEFAULT art roots must include it".
2026-08-14 08:38:43 +02:00
enricobuehler b6cc76c472 Merge pull request 'The Android audio plane trusted AAudio, so a TV that opened a dead stream was silent all session' (#214) from worktree-android-aaudio-shield-silence into main
ci / bun-nix (push) Successful in 1m10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 23s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
ci / docs-site (push) Successful in 2m3s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 55s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 3m23s
ci / web (push) Successful in 3m52s
docker / builders-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 37s
ci / rust-arm64 (push) Successful in 4m25s
ci / rust (push) Successful in 4m8s
android / android (push) Successful in 12m7s
2026-08-14 06:22:33 +00:00
13 changed files with 842 additions and 124 deletions
+96
View File
@@ -14,6 +14,102 @@ with the version table of the release you are moving to, then read **Breaking ch
## v0.28.1 — in development
### The pad-audio "Wireless Controller" speaker hides while no client pad is attached
Field-confirmed (2026-08-14, the same Helldivers 2 reports as below): the per-pad audio endpoint
the Windows host mints — a Steam-Streaming-Speakers instance stamped with a DualSense's name,
container and 4 ch/48 kHz formats, **pre-provisioned at every host start** — is deliberately
indistinguishable from a real DualSense speaker. That disguise is the feature during a pad
session (libScePad titles route haptics audio at it) and a trap the rest of the time: an idle
Helldivers 2 finds the endpoint by identity, engages its DualSense-haptics path against a device
nothing services, and drops to 25 FPS 1% lows — with the host completely idle, no controller
plugged in, and no session ever run. The reporter isolating "the DualSense speaker" and disabling
it in mmsys.cpl restored full performance; that manual remedy is now automatic.
The endpoint now parks **hidden** (`DEVICE_STATE_DISABLED`, via `IPolicyConfig::
SetEndpointVisibility` — the exact call behind mmsys.cpl's Disable) whenever no client pad is
attached: provisioning hides it at startup (and a `PUNKTFUNK_PAD_AUDIO=0` host hides leftovers
from earlier runs), the per-pad streamer shows it for exactly the pad's lifetime — to a game,
indistinguishable from a DualSense arriving and leaving. The devnode, driver binding and stamps
stay put, so the flips raise no PnP traffic and the expensive provisioning still happens once at
boot.
**Operator-visible:** "Speakers (Wireless Controller)" now shows as *disabled* in the Sound
control panel while no client pad is connected — that is the parked state, not a defect. The
`pad-endpoint` devtest grew `show`/`hide` verbs; `tone`/`capture` need a `show` first.
### An idle Windows host no longer owns the box's default microphone
Field report (the second Helldivers 2 one — the first led to v0.28.0's mint-retry fix): with the
host **idle**, a locally played Helldivers 2 tanks to 25 FPS 1% lows, and Windows' own Sound
settings Recording tab goes unresponsive. Root cause: the audio wiring pass asserted *default
recording = the virtual mic's capture side* on **every** pass, including the mic pump's eager
boot pass — and `SetDefaultEndpoint` covers eCommunications, so every game's voice input bound a
virtual microphone whose feeder only runs during a stream. Nothing ever restored it: not session
end, not service stop. Games that hold an always-open voice capture (Helldivers 2 is Wwise +
in-game voice — its own wiki calls the game "finicky with audio devices") stall on that dead
endpoint.
The recording default is now **session-scoped**, exactly like the playback default has always
been: parked on the virtual mic only while a desktop-audio capture is open, the operator's device
remembered (plus an on-disk crash marker, `audio-default-rec.prev`), restored when the capture
closes, recovered at next boot after a crash, and unparked by the uninstaller. A game launched
*during* a stream still records the client's mic; one launched before the stream keeps the
operator's own microphone.
Boxes wedged by earlier builds (which recorded nothing to restore) heal themselves: an idle
wiring pass that finds the default recording sitting on the plan's mic capture moves it back to
the first real microphone.
**Operator-visible:** outside a stream, the default recording device is now whatever you set —
Punktfunk only takes it for the duration of a stream. If you *want* apps to record the client mic
while idle, select "Punktfunk Microphone" manually; the host no longer re-asserts it (idle
re-assertion used to stomp a manual choice within one mic-pump reopen).
### The Steam plugin synced nothing on Windows: its art is in Program Files, the art roots were not
Field report — the plugin installed, the grid stayed empty, and the only clue was one host warn per
sync:
```
plugin:steam sync (fs-change) failed: HostRequestError: PUT /library/provider/steam?store=steam
failed: art.hero: local art must be an image file (…) inside an allowed art root
```
Two independent defects, both fixed here.
**1. Steam's art was never inside an allowed root on Windows.** `art_roots()` defaulted to the users
base (`C:\Users`, from `%PUBLIC%`'s parent), which covers the launchers that install per-user —
Playnite under `%APPDATA%`, Heroic under `%APPDATA%` — but *not* Steam, which installs to
`C:\Program Files (x86)\Steam` and keeps both the art the plugin publishes there:
`appcache\librarycache\<appid>\<hash>\` and each account's `userdata\<id>\config\grid\` overrides.
Every cover the plugin emitted was out of root. This is a v0.28.0 regression: the built-in scanner
the plugin replaced served its covers through the legacy `steam:` art-proxy branch, which never
passed through the H-2 confinement — deleting the scanner routed that art through a gate it had
never been measured against. `art_roots()` now also includes every Steam install root it can find,
from `%ProgramFiles(x86)%` / `%ProgramFiles%` / `%ProgramW6432%` and from HKLM
`Valve\Steam\InstallPath` (so a Steam on another drive is covered too). POSIX needed no equivalent —
every Steam layout there, native and Flatpak, is already under `$HOME`.
This does not weaken the confinement. It exists to stop the host (SYSTEM) reading files the plugin
lane (LocalService) cannot reach itself; the Steam directory is readable by LocalService already, so
nothing there is reachable *because* the host is privileged. The extension, regular-file, magic-byte
and config-dir gates all still apply, so Steam's own `config.vdf` and `ssfn*` credential blobs are
not servable from it — there is a test.
**2. One unservable cover threw away the entire library.** `PUT /library/provider/{p}` validated art
per entry and returned 400 for the whole payload on the first bad value, so a path mismatch cost the
operator *every game from that store*, not a thumbnail — and the plugin, which only ever sees
`HostRequestError`, could not say which. A provider reconcile now **strips** unservable local art and
syncs the rest (`sanitize_art_paths`), logging one aggregated warn naming the count, an example path
and the env var. The invariant the 400 held is unchanged: no unservable path is ever persisted. The
operator's own single-entry custom writes keep the hard 400 — there the path was typed by hand, and
silence would be the wrong answer.
**Operator-visible:** an art-root mismatch no longer fails a sync. If covers are blank where you
expect art, the cue is the host log's `dropped local art the proxy may not serve` line, and the knob
is `PUNKTFUNK_LIBRARY_ART_ROOTS` (which **replaces** the defaults — list every root you need).
### Android — the audio plane trusted AAudio, and a TV box that opened a stream it never played was silent for the session
🛑 **Reported from the field: no audio at all on an NVIDIA Shield Android TV, stereo, with the same
@@ -26,13 +26,22 @@
//! mixing mono or at 24 kHz) loses to real hardware; see [`super::wiring_plan`]. **Never** the
//! Steam Streaming Speakers, whose loopback is silent — validated live;
//! * default **RECORDING** → the mic target's capture endpoint (VB-Cable "CABLE Output") so host apps
//! record the client's mic by default.
//! record the client's mic by default — applied, like the playback default, ONLY while a
//! desktop-audio capture is open. It used to be asserted on EVERY wiring pass, mic pump at boot
//! included, which left an IDLE box's default recording/communication device parked on a virtual
//! microphone nothing feeds — and games bind the default microphone at launch (`SetDefaultEndpoint`
//! covers eCommunications, so in-game voice binds it too). The 2026-08 Helldivers 2 field reports
//! measured that as 1% lows of 25 FPS in a LOCALLY played game while the host sat idle (HD2 is
//! Wwise + always-on voice, exactly the "finicky with audio devices" case its own wiki warns
//! about). An idle host must leave the box's audio defaults exactly as the operator set them.
//!
//! Because the playback default is *parked* on a silent sink during a stream, it is remembered
//! ([`park_default_playback`], plus an on-disk crash marker) and put back when the capture closes
//! ([`restore_default_playback`]) or, after a crash, on the next process's first wiring pass — an
//! operator must never be stranded with silent speakers. A default the operator changed themselves
//! mid-stream is respected (no restore over their choice).
//! Because both defaults are *parked* during a stream — playback on a silent sink, recording on the
//! virtual mic — the operator's devices are remembered ([`park_default_playback`] /
//! [`park_default_recording`], plus on-disk crash markers) and put back when the capture closes
//! ([`restore_default_playback`] / [`restore_default_recording`]) or, after a crash, on the next
//! process's first wiring pass — an operator must never be stranded with silent speakers or a dead
//! mic. A default the operator changed themselves mid-stream is respected (no restore over their
//! choice).
//!
//! The assignment rules are the PURE [`wiring_plan`](super::wiring_plan) module (unit-tested on every
//! platform); this module only enumerates endpoints, applies the plan, and logs. [`wire_now`] runs on
@@ -142,8 +151,8 @@ pub(crate) fn endpoint_fingerprint() -> u64 {
}
/// [`wire_now_full`] for callers that only need the assignment (the mic paths).
pub(crate) fn wire_now(set_playback: bool) -> Wiring {
wire_now_full(set_playback).wiring
pub(crate) fn wire_now(park_defaults: bool) -> Wiring {
wire_now_full(park_defaults).wiring
}
/// The most recent wiring verdict, as the LAST wiring pass computed it (the mic pump wires
@@ -170,13 +179,15 @@ fn pad_render_ids(renders: &[Endpoint]) -> Vec<String> {
/// Enumerate endpoints, compute the assignment, apply the default-device changes (unless
/// `PUNKTFUNK_KEEP_DEFAULT`), and return the plan for the caller to act on (mic target / loopback
/// echo guard). `set_playback` — true only from the desktop-audio capture open — additionally
/// parks the default PLAYBACK device on the plan's loopback endpoint for the capture's lifetime
/// (the mic pump passes false: it runs while the host is idle and must not silence the box).
/// Must run on a COM-initialized thread (the WASAPI worker threads all `initialize_mta` first).
/// Logged only when the assignment changes, so per-open recomputation stays quiet in the steady
/// state.
pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
/// echo guard). `park_defaults` — true only from the desktop-audio capture open — additionally
/// parks the default PLAYBACK device on the plan's loopback endpoint and the default RECORDING
/// device on the virtual mic's capture side, both for the capture's lifetime (the mic pump passes
/// false: it runs while the host is idle and must neither silence the box nor hold its default
/// microphone — the idle-parked recording default is the 2026-08 Helldivers 2 tank, see the
/// module docs). Must run on a COM-initialized thread (the WASAPI worker threads all
/// `initialize_mta` first). Logged only when the assignment changes, so per-open recomputation
/// stays quiet in the steady state.
pub(crate) fn wire_now_full(park_defaults: bool) -> WiredPlan {
recover_orphaned_default();
let renders = list_endpoints(Direction::Render);
let captures = list_endpoints(Direction::Capture);
@@ -188,11 +199,11 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
// them out of every role. Identity is platform data (stamped container / devnode marker),
// so it is collected HERE and passed in, like the candidate lists themselves.
let pad_ids = pad_render_ids(&renders);
// Mix formats are read only when we are actually going to park the playback default (i.e. a
// Mix formats are read only when we are actually going to park the defaults (i.e. a
// desktop-audio capture is opening). The mic pump wires on every open while the host is idle
// and does not care which loopback endpoint wins, so it must not pay an IAudioClient
// activation per render endpoint on every pass.
let probe: &dyn Fn(&Endpoint) -> Option<MixFormat> = if set_playback {
let probe: &dyn Fn(&Endpoint) -> Option<MixFormat> = if park_defaults {
&mix_format_of
} else {
&wiring_plan::no_formats
@@ -311,30 +322,44 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan {
}
}
}
if set_playback {
// Recording-default hygiene, IDLE passes only: builds before 2026-08-14 parked the default
// recording on the virtual mic on EVERY wiring pass (boot included) and recorded nothing to
// restore — so an upgraded box would otherwise sit wedged on a microphone nothing feeds
// until the operator noticed (the Helldivers 2 idle tank; the session-scoped park below
// can't heal it either: it remembers a previous default only when the default isn't already
// ours). While nothing is parked, a default found sitting on the plan's mic capture moves to
// the first real microphone. Session passes own the default and are exempt; a box with no
// real microphone is left alone.
if !park_defaults && PARKED_REC.lock().unwrap().is_none() {
if let Some((mic_name, mic_id)) = &wiring.mic_capture {
if default_capture_id().as_deref() == Some(mic_id.as_str()) {
if let Some((name, id)) =
wiring_plan::real_capture(&captures, Some(mic_id.as_str()))
{
match set_default_endpoint(id) {
Ok(()) => tracing::info!(from = %mic_name, device = %name,
"default recording was left on the virtual mic outside a stream — \
moved it back to a real microphone"),
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
"failed to move the default recording off the virtual mic"),
}
}
}
}
}
if park_defaults {
if let Some((name, id)) = &wiring.loopback_render {
let mic_id = wiring.mic_render.as_ref().map(|(_, m)| m.as_str());
park_default_playback(name, id, changed, mic_id);
}
}
if let Some((name, id)) = &wiring.mic_capture {
// `set_default_endpoint` is NOT a no-op on an unchanged default: it unconditionally
// fires SetDefaultEndpoint for all three roles (an audio-policy write plus a
// device-graph notification, each). Re-asserting on every wiring pass therefore both
// churned the policy store AND silently stomped an operator's own recording-device
// choice within one reopen cycle — write only when the plan changed or the default
// actually drifted off the target.
if changed || default_capture_id().as_deref() != Some(id.as_str()) {
match set_default_endpoint(id) {
Ok(()) => {
if changed {
tracing::info!(device = %name,
"audio wiring: default recording = virtual mic (apps record the client's mic)");
}
}
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
"audio wiring: failed to set the default recording device"),
}
// The recording default is SESSION-SCOPED like the playback default, and for the same
// reason inverted: parking it while idle handed the box's default microphone (and, via
// eCommunications, every game's voice input) to a virtual mic nothing feeds — the
// 2026-08 Helldivers 2 idle tank (see the module docs). A game launched DURING the
// stream still binds the client's mic (this runs before the session's game does);
// one launched before the stream keeps the operator's mic, which is the honest answer.
if let Some((name, id)) = &wiring.mic_capture {
park_default_recording(name, id, changed);
}
}
done(wiring)
@@ -350,6 +375,26 @@ fn park_marker_path() -> std::path::PathBuf {
pf_paths::config_dir().join("audio-default.prev")
}
/// The operator's default recording endpoint while we have it parked on the virtual mic:
/// `(previous_id, id_we_set)` — the recording-side twin of [`PARKED`].
static PARKED_REC: Mutex<Option<(String, String)>> = Mutex::new(None);
/// On-disk crash marker mirroring [`PARKED_REC`] (two lines: previous id, set id).
fn rec_marker_path() -> std::path::PathBuf {
pf_paths::config_dir().join("audio-default-rec.prev")
}
/// Consume a park marker file: returns the PREVIOUS default's id when the marker existed AND the
/// current default still is the endpoint we set — a default the operator changed since wins, like
/// on every other restore path. The file is removed either way (it describes a park that is over).
fn take_marker(path: &std::path::Path, current_default: Option<String>) -> Option<String> {
let s = std::fs::read_to_string(path).ok()?;
let _ = std::fs::remove_file(path);
let mut lines = s.lines();
let (prev, set) = (lines.next()?, lines.next()?);
(current_default.as_deref() == Some(set)).then(|| prev.to_string())
}
/// The current default RENDER endpoint id, if any. pub(crate): the pad-endpoint provisioning
/// uses it for its default-device guard (a freshly minted pad endpoint must never stay the
/// default playback device).
@@ -374,31 +419,28 @@ pub(crate) fn default_capture_id() -> Option<String> {
.ok()
}
/// Once per process: if a crash marker from a previous run exists, the host died while the
/// playback default was parked — put the operator's device back, but only if the default still
/// IS the endpoint we set (a manual change since the crash wins). Runs on the first wiring pass
/// (the mic pump wires eagerly at host start, so this fires at boot, not at the first stream).
/// Once per process: if a crash marker from a previous run exists, the host died while a default
/// (playback and/or recording) was parked — put the operator's device back, but only if the
/// default still IS the endpoint we set (a manual change since the crash wins). Runs on the first
/// wiring pass (the mic pump wires eagerly at host start, so this fires at boot, not at the first
/// stream).
fn recover_orphaned_default() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let path = park_marker_path();
let Ok(s) = std::fs::read_to_string(&path) else {
return;
};
let _ = std::fs::remove_file(&path);
let mut lines = s.lines();
let (Some(prev), Some(set)) = (lines.next(), lines.next()) else {
return;
};
if default_render_id().as_deref() != Some(set) {
return;
}
match set_default_endpoint(prev) {
Ok(()) => tracing::info!(
"restored the default playback device a previous host run left parked"
),
Err(e) => tracing::warn!(error = %format!("{e:#}"),
"failed to restore the default playback device left by a previous run"),
for (path, current, what) in [
(park_marker_path(), default_render_id(), "playback"),
(rec_marker_path(), default_capture_id(), "recording"),
] {
let Some(prev) = take_marker(&path, current) else {
continue;
};
match set_default_endpoint(&prev) {
Ok(()) => tracing::info!(
"restored the default {what} device a previous host run left parked"
),
Err(e) => tracing::warn!(error = %format!("{e:#}"),
"failed to restore the default {what} device left by a previous run"),
}
}
});
}
@@ -415,20 +457,18 @@ fn recover_orphaned_default() {
///
/// 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;
let mut restored = false;
for (path, current) in [
(park_marker_path(), default_render_id()),
(rec_marker_path(), default_capture_id()),
] {
// A default the operator changed by hand since the park wins, exactly as on the
// recovery path (`take_marker` answers None then).
if let Some(prev) = take_marker(&path, current) {
restored |= set_default_endpoint(&prev).is_ok();
}
}
set_default_endpoint(prev).is_ok()
restored
}
/// Make `id` the default playback device for the duration of the desktop-audio capture,
@@ -469,6 +509,48 @@ fn park_default_playback(name: &str, id: &str, changed: bool, mic_id: Option<&st
}
}
/// Make `id` the default recording device for the duration of the desktop-audio capture —
/// [`park_default_playback`]'s recording twin, remembering the operator's current default (in
/// memory + the crash marker) the FIRST time so [`restore_default_recording`] can put it back.
/// Nothing is remembered when `id` already is the default — there is nothing to restore.
fn park_default_recording(name: &str, id: &str, changed: bool) {
let cur = default_capture_id();
if cur.as_deref() != Some(id) {
let mut parked = PARKED_REC.lock().unwrap();
match parked.as_mut() {
None => {
if let Some(prev) = cur.clone() {
let _ = std::fs::write(rec_marker_path(), format!("{prev}\n{id}"));
*parked = Some((prev, id.to_string()));
}
}
// Re-park onto a different endpoint mid-stream (plan changed): keep the ORIGINAL
// previous default, update what we set.
Some((prev, set)) if set != id => {
let _ = std::fs::write(rec_marker_path(), format!("{prev}\n{id}"));
*set = id.to_string();
}
Some(_) => {}
}
}
// `set_default_endpoint` is NOT a no-op on an unchanged default: it unconditionally fires
// SetDefaultEndpoint for all three roles (an audio-policy write plus a device-graph
// notification, each) — write only when the plan changed or the default actually drifted
// off the target, or the policy store churns on every reopen.
if changed || cur.as_deref() != Some(id) {
match set_default_endpoint(id) {
Ok(()) => {
if changed {
tracing::info!(device = %name,
"audio wiring: default recording = virtual mic (apps record the client's mic)");
}
}
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
"audio wiring: failed to set the default recording device"),
}
}
}
/// Put the default playback device back on the endpoint we are already capturing, WITHOUT a
/// wiring pass (WP2.4).
///
@@ -507,6 +589,25 @@ pub(crate) fn restore_default_playback() {
}
}
/// Put the operator's default recording device back after streaming — the inverse of
/// [`park_default_recording`], with [`restore_default_playback`]'s exact rules: no-op if we never
/// parked it, and a default the operator changed themselves mid-stream is left alone. Must run on
/// a COM-initialized thread (called from the capture thread's exit path).
pub(crate) fn restore_default_recording() {
let Some((prev, set)) = PARKED_REC.lock().unwrap().take() else {
return;
};
let _ = std::fs::remove_file(rec_marker_path());
if default_capture_id().as_deref() != Some(set.as_str()) {
return;
}
match set_default_endpoint(&prev) {
Ok(()) => tracing::info!("default recording device restored after streaming"),
Err(e) => tracing::warn!(error = %format!("{e:#}"),
"failed to restore the default recording device after streaming"),
}
}
/// Open a device by endpoint id, with a name for error context.
///
/// Resolves through [`super::pad_endpoint::open_wasapi_device`] rather than the `wasapi` crate's
@@ -518,10 +619,11 @@ pub(crate) fn open_endpoint(ep: &Endpoint) -> Result<wasapi::Device> {
.map_err(|e| anyhow!("open endpoint {:?}: {e:#}", ep.0))
}
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
// --- IPolicyConfig (undocumented): default-endpoint and endpoint-visibility writes. ---
/// The `IPolicyConfig` vtable. Only `SetDefaultEndpoint` is called; the 10 methods between `Release`
/// and it (`GetMixFormat` … `SetPropertyValue`) are placeholders so the slot offset is correct.
/// The `IPolicyConfig` vtable. Only `SetDefaultEndpoint` and `SetEndpointVisibility` are called;
/// the 10 methods between `Release` and them (`GetMixFormat` … `SetPropertyValue`) are
/// placeholders so the slot offsets are correct.
#[repr(C)]
struct IPolicyConfigVtbl {
query_interface: unsafe extern "system" fn(
@@ -537,7 +639,11 @@ struct IPolicyConfigVtbl {
windows::core::PCWSTR,
u32,
) -> windows::core::HRESULT,
// SetEndpointVisibility follows — unused.
set_endpoint_visibility: unsafe extern "system" fn(
*mut c_void,
windows::core::PCWSTR,
i32,
) -> windows::core::HRESULT,
}
// This mirrors the vtable of the UNDOCUMENTED `IPolicyConfig` COM interface, so there is no header
@@ -546,18 +652,21 @@ struct IPolicyConfigVtbl {
// table" — so a field added, removed or resized above it does not fail to compile: it silently calls
// a DIFFERENT function through a mismatched signature, which is arbitrary-code territory rather
// than a wrong answer. The `_reserved` gap is what makes that easy to get wrong, since its ten slots
// carry no names to anchor a review. These assertions pin the two things the call actually depends
// on: the slot index of `set_default_endpoint`, and the size of the table up to it.
// carry no names to anchor a review. These assertions pin the things the calls actually depend
// on: the slot indexes of `set_default_endpoint` and `set_endpoint_visibility`, and the size of
// the table up to them.
const _: () = {
use std::mem::{offset_of, size_of};
type P = *const c_void;
// 3 IUnknown slots + 10 reserved = `set_default_endpoint` is slot 13 (0-based).
// 3 IUnknown slots + 10 reserved = `set_default_endpoint` is slot 13 (0-based),
// `set_endpoint_visibility` the slot after.
assert!(offset_of!(IPolicyConfigVtbl, query_interface) == 0);
assert!(offset_of!(IPolicyConfigVtbl, add_ref) == size_of::<P>());
assert!(offset_of!(IPolicyConfigVtbl, release) == 2 * size_of::<P>());
assert!(offset_of!(IPolicyConfigVtbl, _reserved) == 3 * size_of::<P>());
assert!(offset_of!(IPolicyConfigVtbl, set_default_endpoint) == 13 * size_of::<P>());
assert!(size_of::<IPolicyConfigVtbl>() == 14 * size_of::<P>());
assert!(offset_of!(IPolicyConfigVtbl, set_endpoint_visibility) == 14 * size_of::<P>());
assert!(size_of::<IPolicyConfigVtbl>() == 15 * size_of::<P>());
};
/// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the
@@ -603,3 +712,41 @@ pub(crate) fn set_default_endpoint(device_id: &str) -> Result<()> {
result
}
}
/// Show or hide an audio endpoint via the undocumented `IPolicyConfig::SetEndpointVisibility` —
/// the exact call behind mmsys.cpl's "Disable"/"Enable" device menu. A hidden endpoint drops to
/// `DEVICE_STATE_DISABLED`: it vanishes from every ACTIVE enumeration and cannot be opened, but
/// its devnode, driver binding and stamped identity all stay put — showing it again is instant
/// and raises no PnP traffic. pub(crate): the pad-endpoint provider parks its "Wireless
/// Controller" speaker hidden while no client pad is attached (a visible idle pad speaker makes
/// libScePad titles engage their DualSense-haptics path against an endpoint nothing services —
/// the 2026-08-14 Helldivers 2 field confirmation).
pub(crate) fn set_endpoint_visibility(device_id: &str, visible: bool) -> Result<()> {
use windows::core::{IUnknown, Interface, GUID, PCWSTR};
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
const CLSID_POLICY_CONFIG: GUID = GUID::from_u128(0x870af99c_171d_4f9e_af0d_e63df40c2bc9);
const IID_IPOLICY_CONFIG: GUID = GUID::from_u128(0xf8679f50_850a_41cf_9c72_430f290290c8);
let wide: Vec<u16> = device_id.encode_utf16().chain(std::iter::once(0)).collect();
// SAFETY: same contract as `set_default_endpoint` — owned IUnknown from CoCreateInstance,
// QI'd pointer checked non-null, the call goes through the assertion-pinned vtable slot with
// a NUL-terminated UTF-16 id and an INT bool, and the QI'd pointer is Released before return.
unsafe {
let unk: IUnknown = CoCreateInstance(&CLSID_POLICY_CONFIG, None, CLSCTX_ALL)
.map_err(|e| anyhow!("CoCreateInstance(PolicyConfig): {e}"))?;
let mut raw: *mut c_void = std::ptr::null_mut();
unk.query(&IID_IPOLICY_CONFIG, &mut raw)
.ok()
.map_err(|e| anyhow!("QueryInterface(IPolicyConfig): {e}"))?;
if raw.is_null() {
bail!("IPolicyConfig QueryInterface returned null");
}
let vtbl = *(raw as *const *const IPolicyConfigVtbl);
let hr = ((*vtbl).set_endpoint_visibility)(raw, PCWSTR(wide.as_ptr()), visible as i32);
((*vtbl).release)(raw);
hr.ok()
.map_err(|e| anyhow!("SetEndpointVisibility({visible}): {e}"))
}
}
@@ -46,8 +46,8 @@ pub(crate) struct Removed {
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.
/// Restore the default playback/recording devices if we left them 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
@@ -59,7 +59,7 @@ pub(crate) fn purge() -> Result<Removed> {
// 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");
println!("restored the default audio device(s) this host had parked");
}
let mut out = Removed::default();
@@ -25,6 +25,12 @@
//! behind the measured MMDevices ACL repair (see [`grant_system_full_control`]).
//! 3. **Capture**: sessions loopback-capture the endpoint ([`PadLoopbackCapturer`], 4 ch f32
//! interleaved) and ship the PCM to the client's pad speaker/haptics.
//! 4. **Visibility** ([`set_visibility`]): the endpoint parks HIDDEN (`DEVICE_STATE_DISABLED`)
//! whenever no client pad is attached — provisioning hides it at startup, the per-pad
//! streamer shows it for exactly the pad's lifetime. The DualSense disguise that makes games
//! route haptics at it during a session makes idle libScePad titles STALL on it otherwise
//! (Helldivers 2, field-confirmed 2026-08-14: 25 FPS 1% lows with the host idle). The
//! devnode, driver binding and stamps stay put, so flips raise no PnP traffic.
//!
//! The wiring plan must never route desktop audio or the virtual mic onto these endpoints —
//! [`audio_control`](super::audio_control) collects the exclusion ids via
@@ -1484,6 +1490,10 @@ static PROVISIONING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBo
pub(crate) fn provision_at_startup() {
if !pad_audio_enabled() {
tracing::info!("pad audio disabled (PUNKTFUNK_PAD_AUDIO=0)");
// Endpoints a previous run provisioned persist and stay VISIBLE — and a visible idle
// pad speaker is exactly what libScePad titles stall on (see [`set_visibility`]).
// Turning the feature off must also park the leftovers.
hide_leftover_endpoints();
return;
}
if PROVISIONED.get().is_some() {
@@ -1531,6 +1541,17 @@ pub(crate) fn provision_at_startup() {
stored-but-not-served until the next reboot"),
}
}
// Park every provisioned endpoint HIDDEN until a client pad actually attaches. The
// expensive work (devnode, driver bind, stamps, the AEB kick above) stays at boot —
// the #185 lesson: no PnP traffic at session boundaries — but the ENDPOINT must not
// sit visible on an idle box: libScePad titles (Helldivers 2, field-confirmed
// 2026-08-14) find the "Wireless Controller" speaker BY IDENTITY, engage their
// DualSense-haptics path against it, and stall on an endpoint nothing services —
// 1% lows of 25 FPS with the host completely idle. The per-pad streamer shows it
// for exactly the pad's lifetime, like a real DualSense arriving.
for pe in &eps {
set_visibility(&pe.endpoint_id, pe.pad_index, false);
}
// R5: latch the result ONLY if we actually provisioned something. This used to store
// whatever `eps` held even when the loop broke on the first error — an empty vec —
// and `OnceLock` made that permanent: one transient failure (a busy audio stack, a
@@ -1570,6 +1591,54 @@ pub(crate) fn ensure_provisioned() {
}
}
/// Show or hide a pad endpoint (best-effort, logged). Hidden = `DEVICE_STATE_DISABLED` via
/// [`audio_control::set_endpoint_visibility`] — the endpoint keeps its devnode, driver binding
/// and DualSense stamps, but vanishes from every ACTIVE enumeration and cannot be opened.
///
/// WHY pad endpoints park hidden: the stamp set exists so libScePad titles read the endpoint as
/// a real DualSense speaker and route haptics audio at it — during a pad session that is the
/// feature, on an idle box it is a trap. Helldivers 2 (field-confirmed 2026-08-14) finds the
/// idle "Wireless Controller" speaker, engages its DualSense-haptics path against an endpoint
/// nothing services, and drops to 25 FPS 1% lows with the host completely idle; the manual
/// community remedy is disabling the device in mmsys.cpl — this is that remedy, automated and
/// scoped to "no pad attached". Visibility flips raise no PnP traffic (the #185 lesson), only
/// an endpoint state notification — the same event a real pad's arrival/departure raises.
pub(crate) fn set_visibility(endpoint_id: &str, pad_index: u8, visible: bool) {
match audio_control::set_endpoint_visibility(endpoint_id, visible) {
Ok(()) => tracing::info!(pad = pad_index, endpoint = %endpoint_id,
state = if visible { "shown (client pad attached)" } else { "hidden (no pad attached)" },
"pad-audio endpoint visibility"),
Err(e) => tracing::warn!(pad = pad_index, endpoint = %endpoint_id, visible,
error = %format!("{e:#}"),
"pad-audio endpoint visibility change failed — an idle visible pad speaker can \
stall libScePad titles (disable it in mmsys.cpl as a manual fallback)"),
}
}
/// Hide any pad endpoints a previous run left behind — the `PUNKTFUNK_PAD_AUDIO=0` path, where
/// the provisioning worker never runs but persisted endpoints would otherwise stay visible (and
/// stall idle libScePad titles) forever.
fn hide_leftover_endpoints() {
let spawned = thread::Builder::new()
.name("punktfunk-pad-audio-hide".into())
.spawn(|| {
if wasapi::initialize_mta().ok().is_err() {
return;
}
for idx in 0..4u8 {
match find(idx) {
Ok(Some(pe)) if !pe.endpoint_id.is_empty() => {
set_visibility(&pe.endpoint_id, idx, false);
}
_ => {}
}
}
});
if let Err(e) = spawned {
tracing::warn!(error = %e, "could not spawn the pad-endpoint hide sweep");
}
}
/// The provisioned endpoint for one pad slot — what a session queries when a client pad with
/// speaker support arrives, to attach a [`PadLoopbackCapturer`].
#[allow(dead_code)]
@@ -24,8 +24,8 @@
//! the set changes — the thread says why once, then parks on a cheap fingerprint poll and
//! re-plans the instant the set moves (the 2026-08 field case hammered a full wiring pass —
//! IPolicyConfig writes included — every 2 s for 8+ minutes without ever being able to
//! succeed). On thread exit (capturer dropped at stream end) the parked default playback
//! device is restored.
//! succeed). On thread exit (capturer dropped at stream end) the parked default playback AND
//! recording devices are restored — both defaults are strictly session-scoped.
use super::capture_policy::{CaptureStats, FightDamper, FIGHT_BACKOFF, STATS_EVERY};
use super::{audio_control, wiring_plan, AudioCapturer, SAMPLE_RATE};
@@ -290,9 +290,13 @@ fn capture_thread(
}
}
}
// Hand the default playback device back to the operator (no-op if we never parked it, or if
// they changed it themselves mid-stream). COM is initialized on this thread.
// Hand the default playback AND recording devices back to the operator (no-ops if we never
// parked them, or if they changed them themselves mid-stream). COM is initialized on this
// thread. The recording restore is what keeps the parked default session-scoped — an idle
// box holding the default microphone on a virtual mic nothing feeds is the 2026-08
// Helldivers 2 tank (see `audio_control`'s module docs).
audio_control::restore_default_playback();
audio_control::restore_default_recording();
Ok(())
}
@@ -261,8 +261,10 @@ fn resolve_target() -> Result<(wasapi::Device, String)> {
// on the cable while later plans paired the default recording with the minted microphone
// nothing wrote into (see `minted::ensure_blocking`). Instant once latched.
super::minted::ensure_blocking();
// set_playback=false: the mic pump runs while the host is idle — only the desktop-audio
// capture may park the playback default (on the silent sink) for a stream's lifetime.
// park_defaults=false: the mic pump runs while the host is idle — only the desktop-audio
// capture may park the box's defaults (playback on the silent sink, recording on the virtual
// mic) for a stream's lifetime. An idle box must keep the operator's own devices default —
// an idle-parked recording default is the 2026-08 Helldivers 2 tank (`audio_control` docs).
let mut wiring = audio_control::wire_now(false);
if wiring.mic_render.is_none() && !wiring.mic_withheld {
// A WITHHELD mic skips the install attempt: the Streaming Microphone exists — the plan
@@ -241,6 +241,30 @@ pub(crate) fn silent_sink(lname: &str) -> bool {
lname.contains("steam streaming microphone")
}
/// A capture endpoint that surfaces a VIRTUAL device's audio (cables, streaming mics, mixer
/// strips, the host's own minted "Punktfunk" microphone) rather than a real microphone. The
/// recording-default hygiene pass must never move the box's default onto one of these.
pub(crate) fn virtual_capture(lname: &str) -> bool {
lname.contains("cable output")
|| lname.contains("steam streaming")
|| lname.contains("voicemeeter")
|| lname.contains("virtual")
|| lname.contains("punktfunk")
}
/// The first REAL capture endpoint (skipping `avoid_id` and every [`virtual_capture`]) — where
/// the recording-default hygiene sends a default an earlier build left parked on the virtual mic
/// while the host is idle. `None` on a box with no real microphone: nothing sane to move to, so
/// the default is left alone.
pub(crate) fn real_capture<'a>(
captures: &'a [Endpoint],
avoid_id: Option<&str>,
) -> Option<&'a Endpoint> {
captures
.iter()
.find(|(n, id)| Some(id.as_str()) != avoid_id && !virtual_capture(&n.to_lowercase()))
}
/// A known-virtual device (cables/streaming endpoints). A render WITHOUT these markers is real
/// hardware — the best loopback source (apps render there by default and the operator can also
/// hear it).
@@ -1137,6 +1161,29 @@ mod tests {
assert!(both.contains("16000") && both.contains("channel"), "{both}");
}
/// The recording-default hygiene picker: skips every virtual capture (cable, streaming mic,
/// the minted "Punktfunk" pair, VoiceMeeter) and lands on the real microphone — the exact
/// recording-tab zoo of the 2026-08-14 Helldivers 2 field box.
#[test]
fn recording_hygiene_picks_the_real_microphone() {
let captures = [
ep("Microphone (2- Punktfunk)"),
ep("CABLE Output (VB-Audio Virtual Cable)"),
ep("Microphone (Steam Streaming Microphone)"),
ep("VoiceMeeter Output (VB-Audio VoiceMeeter VAIO)"),
ep("Desktop Microphone (2- Microsoft LifeCam HD-3000)"),
];
assert_eq!(
real_capture(&captures, None).unwrap().0,
"Desktop Microphone (2- Microsoft LifeCam HD-3000)"
);
// `avoid_id` guards the plan's own mic capture even when its name would pass the
// virtual test; with nothing else real, the answer is honestly None.
let only = [ep("Desk Mic (USB)")];
assert!(real_capture(&only, Some("id-desk mic (usb)")).is_none());
assert!(real_capture(&[], None).is_none());
}
/// Operator override beats the candidate order.
#[test]
fn env_override_wins() {
+28 -3
View File
@@ -623,12 +623,15 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
Ok(())
}
/// Windows: pad-audio endpoint provisioning — `pad-endpoint ensure|remove|status [--index N]`.
/// Windows: pad-audio endpoint provisioning — `pad-endpoint
/// ensure|remove|status|tone|capture|show|hide [--index N]`.
/// `ensure` runs the idempotent startup path (reuse-or-create the devnode, bind the Steam
/// Streaming Speakers driver, stamp the DualSense identity + 4ch/48k formats, report whether
/// the stamps are SERVED); `status` prints the devnode/endpoint and per-stamp stored vs served
/// state without changing anything; `remove` deletes the devnode via pnputil — the escape
/// hatch only, endpoints are persistent by design. Stamping needs SYSTEM (the MMDevices ACL);
/// hatch only, endpoints are persistent by design; `show`/`hide` flip the endpoint's
/// visibility (the host parks it hidden while no client pad is attached — show it before
/// `tone`/`capture`). Stamping needs SYSTEM (the MMDevices ACL);
/// run `ensure` under the service account or PsExec when the property-store route is denied.
/// Windows: the audio-substrate toolbox (`windows-audio-endpoints-and-vbcable.md`) —
/// `audio-probe ssm|sink|sss-primary|mint|plan|cleanup [--keep]`. The S1S3 spikes (`ssm` =
@@ -744,7 +747,29 @@ pub fn pad_endpoint(args: &[String]) -> Result<()> {
pe::capture_probe(&endpoint_id, secs)
}
Some("status") => pe::print_status(idx),
_ => anyhow::bail!("usage: punktfunk-host pad-endpoint <ensure|remove|status> [--index N]"),
// `show`/`hide` — flip the endpoint's visibility (DEVICE_STATE_DISABLED). The host parks
// pad endpoints hidden while no client pad is attached (idle libScePad titles stall on a
// visible one — the 2026-08-14 Helldivers 2 field case); `tone`/`capture` need the
// endpoint SHOWN first, and `hide` puts the box back to the idle-safe state after.
Some(verb @ ("show" | "hide")) => {
let endpoint_id = match endpoint_override {
Some(id) => id,
None => match pe::find(idx)? {
Some(ep) if !ep.endpoint_id.is_empty() => ep.endpoint_id,
_ => {
println!("pad-endpoint {verb}: pad {idx} has no endpoint — run `ensure`");
return Ok(());
}
},
};
pe::set_visibility(&endpoint_id, idx, verb == "show");
println!("pad-endpoint {verb}: {endpoint_id}");
Ok(())
}
_ => anyhow::bail!(
"usage: punktfunk-host pad-endpoint \
<ensure|remove|status|tone|capture|show|hide> [--index N]"
),
}
}
+291 -22
View File
@@ -150,12 +150,13 @@ fn percent_decode(s: &str) -> String {
/// H-2): `mgmt-token`, `key.pem`, the SAM hive. So the value is confined here, at the one place
/// bytes are read, rather than trusted because of where it was written.
///
/// Default: the users base (`C:\Users`), which is where every launcher keeps its art cache
/// Playnite, the only local-art provider, stores covers under `%APPDATA%\Playnite`. Derived from
/// Default: the users base (`C:\Users`), where the launchers that install per-user keep their art
/// Playnite stores covers under `%APPDATA%\Playnite`, Heroic under `%APPDATA%\heroic`. Derived from
/// `%PUBLIC%`'s parent because the host runs as SYSTEM, whose own `%USERPROFILE%` is
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live.
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` (`;`-separated) replaces the default for an operator whose library
/// is on another drive.
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live. Plus
/// the Steam install root ([`steam_art_roots`]), which is the one launcher that does NOT live under
/// the users base. `PUNKTFUNK_LIBRARY_ART_ROOTS` (`;`-separated) replaces the whole default for an
/// operator whose library is somewhere else again.
fn art_roots() -> Vec<PathBuf> {
if let Some(configured) = std::env::var_os("PUNKTFUNK_LIBRARY_ART_ROOTS") {
return std::env::split_paths(&configured)
@@ -174,6 +175,8 @@ fn art_roots() -> Vec<PathBuf> {
roots.push(PathBuf::from(drive).join("Users"));
}
}
#[cfg(windows)]
roots.extend(steam_art_roots());
// POSIX: the user's home, which is the exact analogue of the Windows users base above — and
// where every launcher this host reads art from actually keeps it. Steam's
// `appcache/librarycache` and `userdata/<id>/config/grid`, Lutris's `coverart`/`banners` (both
@@ -200,6 +203,54 @@ fn art_roots() -> Vec<PathBuf> {
roots
}
/// Windows: every Steam install root that exists on this box.
///
/// Steam is the one launcher whose art is NOT under the users base: it installs to
/// `C:\Program Files (x86)\Steam`, and both places the `steam` library plugin publishes covers from
/// — `appcache\librarycache\<appid>\…` and each account's `userdata\<id>\config\grid\` overrides —
/// live under that root. Without this the users base rejected every one of them, and because an
/// unservable path used to fail the WHOLE reconcile payload the plugin synced NO GAMES AT ALL, not
/// merely no art. That is a v0.28.0 regression: the built-in scanner this plugin replaced served its
/// covers through the legacy `steam:` art-proxy branch, which never passed through this confinement.
/// (POSIX needs no equivalent — every Steam layout there, native and Flatpak, is already under
/// `$HOME`.)
///
/// This does not widen what the host can be *tricked* into reading. The confinement exists to close
/// one asymmetry: the host reads as SYSTEM, while the plugin lane that supplies the path is the far
/// weaker LocalService (2026-08-05 review H-2). The Steam directory is readable by LocalService
/// already, so nothing reachable through it is reachable *because* the host is privileged. The
/// extension, regular-file, magic-byte and config-dir gates all still apply on top, so Steam's own
/// `config.vdf` and `ssfn*` credential blobs are not servable from it either.
#[cfg(windows)]
fn steam_art_roots() -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
let mut push = |p: PathBuf| {
// `is_dir` before dedup: `%ProgramFiles%` and `%ProgramW6432%` are the same directory on a
// 64-bit host, and the registry commonly repeats whichever of the two Steam sits in.
if p.is_dir() && !out.contains(&p) {
out.push(p);
}
};
for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] {
if let Some(pf) = std::env::var_os(var) {
push(PathBuf::from(pf).join("Steam"));
}
}
// A Steam installed off the default path — a second drive is common — is only discoverable from
// the registry. HKLM and not HKCU, for the same reason the plugin reads HKLM: the host is
// SYSTEM, whose own hive knows nothing about where the operator installed anything.
for key in [r"SOFTWARE\WOW6432Node\Valve\Steam", r"SOFTWARE\Valve\Steam"] {
if let Some(p) = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE)
.open_subkey(key)
.ok()
.and_then(|k| k.get_value::<String, _>("InstallPath").ok())
{
push(PathBuf::from(p));
}
}
out
}
/// Whether `path` resolves inside one of [`art_roots`] and outside the host config dir.
///
/// Canonicalizes first, so a junction/symlink pointing out of the root is resolved before the
@@ -317,6 +368,43 @@ pub fn validate_art_paths(art: &Artwork) -> Result<(), String> {
Ok(())
}
/// Strip every **local-file** art value the proxy would refuse to serve, returning the
/// `(field, value)` pairs dropped. URLs and already-proxied paths are left alone.
///
/// The provider-reconcile counterpart to [`validate_art_paths`]. Both enforce the same invariant —
/// an unservable path never reaches `library.json` — and differ only on what the REST of the payload
/// is worth. An operator writing one custom entry typed that path by hand, so a hard 400 is the
/// feedback they need. A plugin reconciling its whole entry set did not: it publishes hundreds of
/// covers it resolved from disk, and refusing the payload over one of them costs the operator their
/// entire library for that store.
///
/// That is not hypothetical. A default Windows Steam install put every cover outside the art roots,
/// so `PUT /library/provider/steam` 400'd, the plugin could only report `HostRequestError`, and the
/// grid stayed empty with no indication that the games themselves were fine. [`steam_art_roots`]
/// fixes that specific mismatch; this makes the NEXT one cost a cover instead of a library.
///
/// Dropping rather than rewriting is deliberate: `None` is exactly what an entry with no art
/// carries, and every client already renders that.
pub fn sanitize_art_paths(art: &mut Artwork) -> Vec<(&'static str, String)> {
let mut dropped = Vec::new();
for (field, value) in [
("portrait", &mut art.portrait),
("hero", &mut art.hero),
("logo", &mut art.logo),
("header", &mut art.header),
] {
let unservable = value
.as_deref()
.is_some_and(|v| is_local_art_path(v) && !art_path_is_servable(v));
if unservable {
if let Some(v) = value.take() {
dropped.push((field, v));
}
}
}
dropped
}
/// Read a local image file into `(bytes, content-type)` for the art proxy. `None` if it isn't an
/// existing regular file, is empty, exceeds 16 MiB (a cover never approaches that; the cap bounds
/// host memory), resolves outside the allowed art roots ([`art_path_is_confined`]), or does not
@@ -542,15 +630,67 @@ mod tests {
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` is process-global while cargo runs tests as threads, so the
/// tests that repoint it must not overlap — one clearing the variable mid-flight makes the
/// other's temp root stop being a root, which fails as a confinement bug that isn't there.
/// The variables the art roots derive from are process-global while cargo runs tests as threads,
/// so the tests that repoint them must not overlap — one clearing a variable mid-flight makes
/// another's temp root stop being a root, which fails as a confinement bug that isn't there.
/// Poisoning is recovered rather than propagated: a panic in one test should report ITS
/// failure, not cascade into an unrelated `PoisonError`.
static ART_ROOTS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn lock_art_roots() -> std::sync::MutexGuard<'static, ()> {
ART_ROOTS_LOCK.lock().unwrap_or_else(|e| e.into_inner())
/// Holds `ART_ROOTS_LOCK` and the overrides one test needs, restoring the previous values on
/// drop. **The only place these tests touch the process environment** — which is what keeps the
/// unsafe-hygiene gate's count flat as tests are added, and what makes the restore run on an
/// unwind (the hand-rolled set/restore this replaced leaked its override to every later test
/// whenever an assertion fired between the two halves).
struct ArtRootsEnv {
_lock: std::sync::MutexGuard<'static, ()>,
saved: Vec<(&'static str, Option<std::ffi::OsString>)>,
}
impl ArtRootsEnv {
/// `None` unsets the variable for the test's duration.
fn set(vars: &[(&'static str, Option<&Path>)]) -> Self {
let _lock = ART_ROOTS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut saved = Vec::new();
for (key, value) in vars {
saved.push((*key, std::env::var_os(key)));
// SAFETY: `_lock` is held for this guard's whole lifetime, and this type is the
// only writer of these variables in the binary — so no other thread is reading
// them while they change.
unsafe { write_env(key, value.map(|p| p.as_os_str())) };
}
Self { _lock, saved }
}
}
impl Drop for ArtRootsEnv {
fn drop(&mut self) {
for (key, value) in &self.saved {
// SAFETY: still under `_lock`, which outlives this loop — same argument as `set`.
unsafe { write_env(key, value.as_deref()) };
}
}
}
/// The single write point, so the hygiene gate has exactly one pair of call sites to judge.
///
/// # Safety
/// The caller must hold `ART_ROOTS_LOCK`; the process environment is global and unsound to
/// mutate while another thread reads it.
unsafe fn write_env(key: &str, value: Option<&std::ffi::OsStr>) {
match value {
// SAFETY: the caller holds `ART_ROOTS_LOCK` (this function's documented contract), and
// `ArtRootsEnv` is the only writer in the binary — so no other thread is reading the
// environment while it changes.
Some(v) => unsafe { std::env::set_var(key, v) },
// SAFETY: as above — the caller's lock is what makes this sound.
None => unsafe { std::env::remove_var(key) },
}
}
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` pointed at one directory — what most of these tests want.
fn confine_art_to(dir: &Path) -> ArtRootsEnv {
ArtRootsEnv::set(&[("PUNKTFUNK_LIBRARY_ART_ROOTS", Some(dir))])
}
/// The art proxy reads bytes in the HOST process (LocalSystem on Windows) from a path the
@@ -558,15 +698,12 @@ mod tests {
/// (2026-08-05 review H-2). Confinement, extension, and content are all load-bearing.
#[test]
fn local_art_bytes_is_confined_and_image_only() {
let _guard = lock_art_roots();
let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id()));
let outside = std::env::temp_dir().join(format!("pf-art-out-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
// Confine the proxy to `dir` for the duration of this test.
// SAFETY: `_guard` holds ART_ROOTS_LOCK (`lock_art_roots`), which serializes every test
// that writes or reads this variable in the binary.
unsafe { std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir) };
let _env = confine_art_to(&dir);
// A real image inside the root: served, with the content type SNIFFED from the bytes.
let cover = dir.join("cover.png");
@@ -642,8 +779,6 @@ mod tests {
// A UNC path is refused outright (outbound SMB auth coercion), before any filesystem hit.
assert!(!art_path_is_servable(r"\\attacker\share\a.png"));
// SAFETY: still under `_guard` — the same ART_ROOTS_LOCK serialization as the set.
unsafe { std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS") };
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
@@ -706,14 +841,11 @@ mod tests {
/// readable together is the point: either alone passes with the bug present.
#[test]
fn file_url_art_is_accepted_at_write_time_exactly_as_at_read_time() {
let _guard = lock_art_roots();
let dir = std::env::temp_dir().join(format!("pf-art-wr-{}", std::process::id()));
let outside = std::env::temp_dir().join(format!("pf-art-wr-out-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
// SAFETY: `_guard` holds ART_ROOTS_LOCK (`lock_art_roots`), which serializes every test
// that writes or reads this variable in the binary.
unsafe { std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir) };
let _env = confine_art_to(&dir);
let cover = dir.join("cover.png");
std::fs::write(&cover, PNG).unwrap();
@@ -765,12 +897,149 @@ mod tests {
"an out-of-root file:// cover is still refused"
);
// SAFETY: still under `_guard` — the same ART_ROOTS_LOCK serialization as the set.
unsafe { std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS") };
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
/// A reconcile keeps its entries when a cover is unservable — it drops the cover.
///
/// Regression for the report that opened this: on a default Windows Steam install every
/// `appcache\librarycache` path fell outside the users base, `validate_art_paths` refused the
/// whole `PUT /library/provider/steam` payload, and the operator's grid stayed EMPTY. The games
/// were never the problem. Asserting the survivors matters as much as the drop: a sanitizer that
/// cleared the whole struct would also "pass" a drop-only test.
#[test]
fn sanitize_drops_only_the_unservable_local_art() {
let dir = std::env::temp_dir().join(format!("pf-art-san-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let _env = confine_art_to(&dir);
let cover = dir.join("cover.png");
std::fs::write(&cover, PNG).unwrap();
let cover_url = file_url(&cover);
let outside = if cfg!(windows) {
r"C:\Program Files (x86)\Steam\appcache\librarycache\570\a\library_hero.jpg".to_string()
} else {
"/opt/steam/appcache/librarycache/570/a/library_hero.jpg".to_string()
};
let mut art = Artwork {
portrait: Some(cover_url.clone()),
hero: Some(outside.clone()),
logo: Some("https://cdn/l.png".into()),
header: Some("/api/v1/library/art/steam:570/header".into()),
};
let dropped = sanitize_art_paths(&mut art);
assert_eq!(
dropped,
vec![("hero", outside)],
"only the out-of-root local path is dropped, and it is reported"
);
assert!(art.hero.is_none(), "the unservable value is gone, not kept");
// A servable local cover, a remote URL and an already-proxied path all survive untouched —
// the entry still renders everything it legitimately can.
assert_eq!(art.portrait.as_deref(), Some(cover_url.as_str()));
assert_eq!(art.logo.as_deref(), Some("https://cdn/l.png"));
assert_eq!(
art.header.as_deref(),
Some("/api/v1/library/art/steam:570/header")
);
// Idempotent: what survived one pass survives the next, and nothing new is reported.
assert!(sanitize_art_paths(&mut art).is_empty());
// The invariant the hard 400 used to hold is still held — nothing the write gate would
// refuse comes out the other side.
assert!(validate_art_paths(&art).is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
/// Windows only, and the actual bug report: a Steam cover under Program Files is servable with
/// NO `PUNKTFUNK_LIBRARY_ART_ROOTS` set.
///
/// Drives the whole chain the `steam` plugin's payload traverses — Program Files probe →
/// [`steam_art_roots`] → [`art_roots`] → confinement → [`art_path_is_servable`] →
/// [`local_art_bytes`] — against a synthetic Steam tree, by repointing `%ProgramFiles(x86)%` at
/// a temp dir. Hermetic on purpose: asserting over whatever Steam this box happens to have would
/// pass vacuously on every CI runner, which is exactly the shape of test that let this ship.
#[cfg(windows)]
#[test]
fn steam_librarycache_cover_is_servable_without_configuration() {
let base = std::env::temp_dir().join(format!("pf-art-steam-{}", std::process::id()));
// `appcache\librarycache\<appid>\<hash>\library_hero.jpg` — the exact shape the plugin
// publishes, and the exact field the reported failure named.
let hero = base
.join("Steam")
.join("appcache")
.join("librarycache")
.join("570")
.join("abcdef")
.join("library_hero.jpg");
std::fs::create_dir_all(hero.parent().unwrap()).unwrap();
std::fs::write(&hero, PNG).unwrap();
// No configured roots (that is the claim under test), and the Program Files probe pointed
// at the synthetic tree. Both restored on drop — `%ProgramFiles(x86)%` is a real variable
// on this box that later tests in the same process may legitimately read.
let _env = ArtRootsEnv::set(&[
("PUNKTFUNK_LIBRARY_ART_ROOTS", None),
("ProgramFiles(x86)", Some(&base)),
]);
let steam_root = base.join("Steam");
assert!(
steam_art_roots().contains(&steam_root),
"the Program Files probe must find the Steam install"
);
assert!(
art_roots().contains(&steam_root),
"the DEFAULT art roots must include it — the whole point is that no env var is needed"
);
// The plugin sends `file://`, so that is what has to be accepted; before the fix this was
// false and `validate_art_paths` 400'd the entire reconcile.
let url = file_url(&hero);
assert!(art_path_is_servable(&url), "{url} must be servable");
assert!(
validate_art_paths(&Artwork {
hero: Some(url.clone()),
..Default::default()
})
.is_ok(),
"a Steam-shaped payload must reconcile"
);
assert!(
sanitize_art_paths(&mut Artwork {
hero: Some(url.clone()),
..Default::default()
})
.is_empty(),
"and nothing about it is dropped"
);
assert_eq!(
local_art_bytes(&url).expect("read time serves it too").0,
PNG
);
// The confinement did not go slack on the way: a secret next door is still not servable,
// and neither is a non-image that merely wears the extension.
let secret = base.join("Steam").join("config").join("config.vdf");
std::fs::create_dir_all(secret.parent().unwrap()).unwrap();
std::fs::write(&secret, b"\"Accounts\"\n{\n\"user\" \"token\"\n}\n").unwrap();
assert!(
local_art_bytes(secret.to_str().unwrap()).is_none(),
"Steam's own credential blob must not be servable from an art root"
);
let disguised = base.join("Steam").join("config.png");
std::fs::write(&disguised, b"\"Accounts\" { \"user\" \"token\" }").unwrap();
assert!(
local_art_bytes(disguised.to_str().unwrap()).is_none(),
"an image extension is still not enough — the bytes must BE an image"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn sniff_image_type_recognizes_containers_and_rejects_secrets() {
assert_eq!(sniff_image_type(PNG), Some("image/png"));
+56 -7
View File
@@ -9,6 +9,10 @@ use axum::Extension;
/// Refuse a write whose payload carries an operator-privileged field to a lane that may not set one
/// (2026-08-05 review H-1), and refuse any local art path the proxy would not serve back (H-2).
///
/// The **single-entry writes** — the operator creating or editing one custom entry. The provider
/// reconcile takes [`check_privileged_fields`] and sanitizes art instead; the split is the whole
/// point, and [`crate::library::sanitize_art_paths`] carries the reasoning.
///
/// Both checks belong here rather than in the route gate: `PUT /library/provider/{p}` is a route a
/// provider plugin must be able to call — reconciling its own entry set is the whole point of a
/// scanner plugin — while `prep` / `launch.kind = "command"` inside that payload are the operator's
@@ -22,14 +26,32 @@ use axum::Extension;
/// `reason` is the caller's log line. It exists because these are TWO different refusals — an
/// operator-privileged field (403) and an unservable art path (400) — and logging both as "carries
/// a field this lane may not set" sent the Lutris/Steam `file://` art rejection looking like an
/// auth problem. The plugin only ever sees `HostRequestError`, so this log line is the sole
/// diagnosis surface for whoever has to explain why a scanner syncs nothing.
/// auth problem.
fn check_entry_fields(
lane: AuthLane,
art: &crate::library::Artwork,
launch: Option<&crate::library::LaunchSpec>,
prep: &[crate::hooks::PrepCmd],
icon: Option<&str>,
) -> Option<(String, Response)> {
check_privileged_fields(lane, launch, prep, icon).or_else(|| {
crate::library::validate_art_paths(art)
.err()
.map(|e| (e.clone(), api_error(StatusCode::BAD_REQUEST, &e)))
})
}
/// The half of [`check_entry_fields`] that is about *authority* rather than about art: an
/// operator-privileged field this lane may not set (403), or an unrepresentable icon token (400).
///
/// Split out for the provider reconcile, which must apply exactly these two and NOT the art check —
/// it sanitizes unservable covers instead of refusing the payload
/// ([`crate::library::sanitize_art_paths`] explains why the two callers want different answers).
fn check_privileged_fields(
lane: AuthLane,
launch: Option<&crate::library::LaunchSpec>,
prep: &[crate::hooks::PrepCmd],
icon: Option<&str>,
) -> Option<(String, Response)> {
if !lane.may_set_privileged_fields() {
if let Some(field) = crate::library::privileged_field(launch, prep) {
@@ -55,9 +77,7 @@ fn check_entry_fields(
if let Err(e) = crate::library::validate_icon(icon) {
return Some((e.clone(), api_error(StatusCode::BAD_REQUEST, &e)));
}
crate::library::validate_art_paths(art)
.err()
.map(|e| (e.clone(), api_error(StatusCode::BAD_REQUEST, &e)))
None
}
#[derive(Deserialize)]
@@ -468,7 +488,7 @@ pub(crate) async fn reconcile_provider_entries(
Extension(lane): Extension<AuthLane>,
Path(provider): Path<String>,
Query(q): Query<ReconcileQuery>,
ApiJson(inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
ApiJson(mut inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
) -> Response {
if let Err(e) = crate::library::validate_provider_name(&provider) {
return api_error(StatusCode::BAD_REQUEST, &e);
@@ -484,9 +504,15 @@ pub(crate) async fn reconcile_provider_entries(
}
// Every entry in the payload, not just the first — a reconcile replaces a whole entry set, so
// one privileged field anywhere in it is one command execution.
//
// Art is deliberately NOT part of this refusal. A privileged field is the plugin overreaching
// and must fail the write; an unservable cover is a path mismatch between where a launcher keeps
// its art and where the host is allowed to read, and failing the payload over one of those threw
// away a working library to save a thumbnail. Those covers are stripped below instead, which
// holds the same "no unservable path is ever persisted" invariant.
for (i, e) in inputs.iter().enumerate() {
if let Some((reason, denied)) =
check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep, e.icon.as_deref())
check_privileged_fields(lane, e.launch.as_ref(), &e.prep, e.icon.as_deref())
{
tracing::warn!(
provider,
@@ -498,6 +524,29 @@ pub(crate) async fn reconcile_provider_entries(
return denied;
}
}
// One aggregated line, not one per entry: a root mismatch misses EVERY cover in the payload, and
// a per-entry warn would bury the rest of the log under a thousand copies of one fact.
let mut dropped_art = 0usize;
let mut first_dropped: Option<(String, &'static str, String)> = None;
for e in inputs.iter_mut() {
for (field, value) in crate::library::sanitize_art_paths(&mut e.art) {
dropped_art += 1;
first_dropped.get_or_insert_with(|| (e.title.clone(), field, value));
}
}
if let Some((title, field, path)) = first_dropped {
tracing::warn!(
provider,
dropped = dropped_art,
example_title = %title,
example_field = field,
example_path = %path,
"library reconcile: dropped local art the proxy may not serve — these entries still \
sync, but their covers will be blank. The path must be an image file (jpg/png/webp/\
gif/bmp/ico/tga) inside an allowed art root; set PUNKTFUNK_LIBRARY_ART_ROOTS if this \
library's art lives outside the defaults"
);
}
match crate::library::reconcile_provider(&provider, store.as_deref(), inputs) {
Ok(crate::library::MutateOutcome::Done(entries)) => {
tracing::info!(
+11 -1
View File
@@ -321,16 +321,26 @@ pub(super) fn spawn(
}
let stop_t = stop.clone();
let endpoint_id = ep.endpoint_id;
let vis_id = endpoint_id.clone();
match std::thread::Builder::new()
.name(format!("punktfunk1-pad{pad}"))
.spawn(move || {
// COM for the visibility flips (the capturer's opens run on their own thread).
let _ = wasapi::initialize_mta();
// The endpoint parks HIDDEN while no pad is attached — an idle visible "Wireless
// Controller" speaker makes libScePad titles engage their DualSense-haptics path
// against an endpoint nothing services (the 2026-08-14 Helldivers 2 field tank).
// Show it for exactly this pad's lifetime, like a real DualSense arriving; the
// capturer's open/backoff loop absorbs the moment audiosrv takes to re-activate.
crate::audio::pad_endpoint::set_visibility(&vis_id, pad, true);
pad_audio_thread(
conn,
pad,
kinds,
move || crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id),
stop_t,
)
);
crate::audio::pad_endpoint::set_visibility(&vis_id, pad, false);
}) {
Ok(join) => Some(PadAudioHandle {
stop,
+1 -1
View File
@@ -218,7 +218,7 @@ it — leave it or delete it, it makes no difference.
| `PUNKTFUNK_PLUGIN_TOKEN` | token | The scoped token the [plugin/scripting runner](/docs/plugins) uses — a narrower credential than `PUNKTFUNK_MGMT_TOKEN`, never full admin. Same precedence: if unset it's generated and persisted to `~/.config/punktfunk/plugin-token`. Set only to pin a specific token. |
| `PUNKTFUNK_CONFIG_DIR` | path | Override the config directory (default `~/.config/punktfunk`) — pairing state, certs, apps.json, captures. |
| `PUNKTFUNK_UI_PLUGIN_PORT` | port *(default: console port + 1)* | The separate port [plugin](/docs/plugins) UIs are served from. They get their own origin on purpose — a plugin page can never act as *you* on the console. If the console log says this port couldn't be opened (plugin UIs then stay disabled rather than sharing the console's origin), point it at a free port and restart. |
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, `;`-separated | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots (your home directory on Linux/macOS); set it when box art lives elsewhere — a second drive, a network mount. The host log's "not under an allowed art root" line is this knob's cue. |
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, separated like `PATH` (`;` on Windows, `:` on Linux/macOS) | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots: your home directory on Linux/macOS, and on Windows the users base (`C:\Users`) plus your Steam install, wherever it is. Set it when box art lives somewhere else again — a second drive, a network mount, or a launcher installed outside all of those. Setting it **replaces** the defaults, so list every root you need. The host log's "dropped local art the proxy may not serve" line is this knob's cue: those entries still appear in your library, but their covers stay blank until the root is allowed. |
## Updates
+1 -1
View File
@@ -182,7 +182,7 @@ crates/pf-vkdecode/tests/gpu_parity.rs:5
crates/pf-win-display/src/win_display.rs:2
crates/punktfunk-core/src/quic/endpoint.rs:2
crates/punktfunk-host/src/identity.rs:3
crates/punktfunk-host/src/library/art.rs:4
crates/punktfunk-host/src/library/art.rs:2
crates/punktfunk-host/src/mgmt/tests.rs:3
crates/punktfunk-host/src/native.rs:4
crates/punktfunk-host/src/windows/service.rs:1