Compare commits

..
Author SHA1 Message Date
enricobuehler 1a8fa2282f fix(apple): engine starts leave the main thread — input never waits on the audio server
ci / rust (pull_request) Successful in 5m26s
apple / swift (pull_request) Successful in 1m57s
apple / screenshots (pull_request) Skipped
apple / distribute (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 5m7s
ci / bun-nix (pull_request) Successful in 2m11s
ci / web (pull_request) Successful in 3m32s
ci / docs-site (pull_request) Successful in 3m32s
An AVAudioEngine start can block on the audio server for seconds (~1.9 s
per attempt in the 2026-08-14 field case), and macOS captures and sends the
stream's input from the main thread — so every device-change rebuild, loop
or no loop, froze the stream's input for the length of the rebuild, and a
mic-on session start stalled the UI at connect.

All engine lifecycle work (start/startEngines and below, teardown, rebuild)
now runs on a per-session serial engineQueue; the main queue keeps only the
trigger bookkeeping — debounce, backoff, and the retry ladder — which is
cheap by construction. The rebuild path splits accordingly: rebuildFire
(main: bookkeeping, reads the config) → performRebuild (engineQueue: the
actual teardown + start) → rebuildFailed (main: ladder scheduling; a fresh
trigger already queued wins over a retry).

Confinement moves with the work: ring, startConfig and enginesAttempted go
under the existing stateLock (start paths write on engineQueue, stats and
the revive gate read elsewhere); combinedGate is engineQueue-confined; the
permission-grant continuation lands on engineQueue instead of main. The
engines were already lock-guarded and stopped cross-thread by stop(), and
every start path already re-checks the stop flag after publishing, so the
in-flight-start-vs-stop race keeps its existing resolution.

Embedder-visible edge: SessionAudio.start() is now asynchronous on macOS
too (it always was on iOS/tvOS) — playback is live shortly after the call,
not on return; stats is safe from any thread.

Gates: swift build + 295 tests 0 failures (macOS), full-package
arm64-apple-ios17.0 typecheck.
2026-08-14 12:49:48 +02:00
enricobuehler e0c10bad85 fix(apple): the macOS device-change recovery answered itself — mic-on streams cut audio and input every ~2.5 s
ci / bun-nix (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 1m15s
ci / rust-arm64 (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 1m25s
apple / swift (pull_request) Successful in 2m6s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 4m33s
The voice-processing engine cannot start on some input devices (field case:
a 6-channel interface — 'combined engine failed to start', every time). The
device-change recovery re-tried it on every rebuild, and the failed attempt's
HAL churn (VPIO builds and tears down an aggregate device) stopped the healthy
fallback engines, which posted the AVAudioEngineConfigurationChange that
scheduled the next rebuild: a self-sustaining ~2.5 s loop for the session's
whole life. Each ~1.9 s rebuild runs on the main thread — where macOS input
capture and sending live — so the stream's INPUT cut out on the same beat,
while video (own socket, own threads) ran untouched; the wire signature
matched network loss and the host's METRONOMIC heuristic pointed at the
display stack, which is what made the field report so misleading.

Three defenses, layered because no single one covers every feedback shape:
a VPIO start failure latches per input device (CombinedTopologyGate — a
rebuild goes straight to the split topology; a different default input earns
exactly one fresh attempt); a configuration change posted by an engine that
is RUNNING is the rebuild's own echo and is ignored (an engine stops itself
before posting, so a live poster was already restarted); and rebuilds that
chain anyway back off exponentially (RebuildBackoff, 0.5 s floor doubling to
a 30 s cap, reset by 10 s of quiet) with a WARN that names the condition.

Both policies extracted to AudioRebuildPolicy.swift where a unit test can
reach them: 7 new tests, the loop test plant-the-defect verified (the shipped
flat floor produces 800 rebuilds in the 10-minute sim; the ladder ≤ 25, and
responsiveness after quiet is asserted). iOS/tvOS semantics untouched.

Gates: swift build + 295 tests 0 failures (macOS), full-package
arm64-apple-ios17.0 typecheck.
2026-08-14 12:40:51 +02:00
enricobuehler ceb081f045 Merge pull request '0.28.1' (#219) from worktree-release-0281 into main
audit / bun-audit (web) (push) Successful in 48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 33s
audit / c-abi-asan (push) Successful in 7m51s
docker / deploy-docs (push) Successful in 33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 46s
apple / distribute (push) Successful in 11m17s
audit / pnpm-audit (push) Successful in 1m49s
deb / build-publish (push) Successful in 4m36s
deb / build-publish-host (push) Successful in 5m11s
deb / build-publish-client-arm64 (push) Successful in 3m22s
apple / screenshots (push) Successful in 6m51s
docker / builders-arm64cross (push) Successful in 19s
arch / build-publish (push) Successful in 8m11s
deb / build-publish-gamescope (push) Failing after 1m41s
ci / rust-arm64 (push) Successful in 1m50s
audit / miri (push) Successful in 5m53s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
deb / smoke-install (push) Successful in 2m16s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m47s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
ci / web (push) Successful in 1m6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m6s
ci / bun-nix (push) Successful in 35s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 1m20s
windows-host / package (push) Successful in 13m7s
windows-host / winget-source (push) Skipped
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 1m23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m23s
audit / cargo-audit (push) Successful in 1m37s
android / android (push) Successful in 11m50s
windows-host / canary-manifest (push) Successful in 28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m21s
ci / docs-site (push) Successful in 1m22s
flatpak / build-publish (push) Successful in 10m34s
apple / swift (push) Successful in 1m56s
audit / bun-audit (plugin-kit) (push) Successful in 1m12s
audit / license-gate (push) Successful in 7m31s
ci / rust (push) Successful in 20m19s
audit / bun-audit (sdk) (push) Successful in 1m9s
nix / flake (push) Failing after 16m12s
audit / docs-site-audit (push) Successful in 20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m27s
Reviewed-on: #219
2026-08-14 09:24:20 +00:00
enricobuehler 0870f81148 release: 0.28.1 — version bump, notes, CHANGELOG, Play notes
ci / bun-nix (pull_request) Successful in 58s
ci / web (pull_request) Successful in 6m9s
ci / docs-site (pull_request) Successful in 6m22s
ci / rust (pull_request) Successful in 14m57s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m39s
nix / flake (pull_request) Successful in 15m55s
ci / rust-arm64 (pull_request) Successful in 9m42s
apple / swift (pull_request) Successful in 2m0s
apple / screenshots (pull_request) Skipped
apple / distribute (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m6s
android / android (pull_request) Successful in 5m48s
50 commits since v0.28.0 (32 non-merge). Cut from origin/main f8361f3e.

THE NUMBER: 0.28.1 is defensible but not free. Three `feat(...)` commits landed
since the tag — the "unpair all" button and its two endpoints, the Apple
gamepad-UI host menu, and the tvOS present-floor levers. That is not the shape
of v0.28.0's cut (17 feats, a packager-visible default flip, an MSRV rise and a
deletion that empties the library grid), and none of the three changes a
contract: every one is additive, and the version table is unchanged on every row
an embedder, packager or driver author reads. `scripts/ci/pf-version.sh` derives
the canary base as latest-stable + one minor, so 0.28.1 and 0.29.0 both leave
canary on 0.29.x and neither collides.

NOTHING BREAKS, and this was measured rather than assumed, twice — before and
after the four late PRs. `include/` is byte-identical to the v0.28.0 tag, so the
C ABI stays 19; `crates/pf-driver-proto`, `plugin-kit/package.json` and `sdk/`
show no diff against the tag at all. The one Rust-visible change is an addition:
`punktfunk_core::client::FLUSH_COOLDOWN` went `pub(crate)` -> `pub`, so the host
can compare against the constant instead of a copy of the number.

ONE DEFECT FOUND AND FIXED WHILE PREPARING:

`docs-site/public/openapi.json` had drifted for the THIRD time in two release
cycles. It was still stamped 0.27.0 and missing both new collection deletes,
while `api/openapi.json` sits at 0.28.0. v0.28.0 fixed this once (it was five
releases stale at 0.21.0) and it drifted again inside that same cycle. Re-synced;
the two files are byte-identical again, and re-checked after the rebase. The copy
is a documented manual step (CONTRIBUTING.md) that nothing in CI enforces — three
drifts is the argument for gating it, and that gate is not in this commit.

CHANGELOG: the in-development section carried four topics and the late PRs
brought four more of their own; the remaining twenty-one commits had none. Added
the version table (every row measured, not copied forward), an explicit empty
breaking-changes verdict, and sections for the management API's two collection
deletes, the Hyprland/Sway cursor-mode negotiation, the gamescope WSI layer we
now ship ourselves, the 203-nit SDR anchor, the Apple stats/colour faults, the
Skia loader-version regression, the AV1 level sentinel, the stats stage-line
partition, the two host warnings that named the wrong subsystem, and the
docs-site openapi drift.

NOTES: `docs/releases/v0.28.1.md` follows the post-v0.25.0 split — user-facing
only, TL;DR first, internals left to the CHANGELOG link, which points at the
v0.28.1 TAG rather than main. The two Windows headliners lead it: the Steam
add-on publishing nothing (a 0.28.0 regression that emptied the grid) and an idle
host wrecking a locally played game. `Before you update` carries the two
Sound-settings changes an operator will see and could read as defects, plus the
0.27-and-older pointer at v0.28.0's action items.

luxus is credited three times: in the lead-in the Discord embed shows, inline on
the fix itself, and in a new `## Thanks` section — the linger crash was his find,
his patch and his on-glass proof, and it ships as he wrote it. The CHANGELOG
keeps its own credit with the overlay#9 link.

Play notes are 436 characters against the 500 cap and cover only what changed in
the Android app, which this release is still one commit of.

GATES, all green on this tree after the rebase: `cargo fmt --all --check` clean;
`cargo metadata --locked` consistent; Cargo.lock diff is versions-only, 36/36
lines, zero non-version lines against the new base; `cargo test -p punktfunk-core`
210 passed; the C ABI harness passes printing abi_version=19 (needs
`LIBRARY_PATH=/opt/homebrew/opt/opus/lib` on macOS — a link path, not a defect);
the repo pre-push hook exits 0.
2026-08-14 11:22:08 +02:00
enricobuehler f8361f3e6f Merge pull request 'The NixOS module started a second host in root's systemd, which stole the ports from the real one' (#218) from worktree-nixos-module-user-scoping into main
docker / deploy-docs (push) Canceled after 3m33s
ci / web (push) Successful in 1m10s
docker / builders-arm64cross (push) Successful in 9s
ci / rust-arm64 (push) Successful in 1m25s
nix / flake (push) Canceled after 4m8s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
ci / bun-nix (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
ci / rust (push) Canceled after 6m29s
ci / docs-site (push) Canceled after 5m54s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
Reviewed-on: #218
2026-08-14 09:17:54 +00:00
enricobuehler e8bc10bf0c Merge remote-tracking branch 'origin/main' into worktree-nixos-module-user-scoping
ci / rust-arm64 (pull_request) Successful in 3m45s
ci / docs-site (pull_request) Successful in 3m49s
ci / bun-nix (pull_request) Successful in 24s
ci / rust (pull_request) Successful in 18m12s
nix / flake (pull_request) Successful in 15m35s
ci / web (pull_request) Successful in 1m1s
# Conflicts:
#	CHANGELOG.md
2026-08-14 11:16:49 +02:00
enricobuehler 4499313749 fix(nix): the module started a second host in root's systemd, stealing the ports from the real one
ci / docs-site (pull_request) Successful in 1m17s
ci / bun-nix (pull_request) Successful in 1m33s
nix / flake (pull_request) Failing after 1m23s
ci / rust (pull_request) Canceled after 1m54s
ci / rust-arm64 (pull_request) Canceled after 2m3s
ci / web (pull_request) Successful in 1m8s
`systemd.user.*` has no per-user form in NixOS — it installs units into every
user's manager. With `host.autoStart` adding them to `default.target`, that
included root, whose `user@0.service` exists the moment anybody SSHes in as
root. Root's host won the race for the fixed ports and the desktop user's copy
crash-looped forever on `bind RTSP 48010: Address already in use`.

Every other listener binds first and logs success, so the log reads like a
clash with an unrelated program; a second copy of itself running as root is the
last thing you look for. `host.users` did not help — it only granted
input/punktfunk group membership and never scoped the units.

Render `ConditionUser=` on all four user units from `host.users`. Entries are
written `|user`: the pipe makes each a triggering condition, which systemd ORs,
where plain repeated `ConditionUser=` lines are ANDed and would match nobody.
With `host.users` empty, fall back to `!@system` — still keeps root out while
leaving the manual `systemctl --user enable --now` route working for a login.

module-check.nix gains three assertions covering both branches and web-init
keeping its non-triggering ConditionPathExists alongside the new condition.
They run in nix.yml's eval leg, and were confirmed to fail against the unfixed
module (2 of 23) before being committed. Verified on the box that found this:
root force-starting the host now yields ConditionResult=no.
2026-08-14 11:00:43 +02:00
enricobuehler 784f880fbf Merge pull request 'Helldivers 2 tanked on an IDLE host: the pad DualSense speaker stayed visible and the recording default was parked forever' (#217) from worktree-hd2-idle-recording-default into main
deb / smoke-install (push) Successful in 9m33s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
ci / rust (push) Successful in 18m45s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m52s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
deb / build-publish (push) Successful in 4m49s
ci / rust-arm64 (push) Successful in 1m28s
deb / build-publish-host (push) Successful in 5m18s
ci / bun-nix (push) Successful in 27s
ci / web (push) Successful in 1m45s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m3s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m11s
arch / build-publish (push) Failing after 2m47s
ci / docs-site (push) Successful in 1m55s
deb / build-publish-client-arm64 (push) Successful in 5m23s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 1m39s
deb / build-publish-gamescope (push) Failing after 1m48s
docker / builders-arm64cross (push) Successful in 33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
windows-host / package (push) Successful in 13m9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
windows-host / winget-source (push) Skipped
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
windows-host / canary-manifest (push) Successful in 20s
android / android (push) Successful in 13m47s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
docker / deploy-docs (push) Failing after 6m58s
2026-08-14 08:41:12 +00:00
enricobuehler 8ca4c6eb0e Merge pull request 'Hyprland/Sway black client — the wlr-family backends asserted a cursor mode instead of negotiating it' (#216) from worktree-hyprland-cursor-mode-negotiation into main
deb / build-publish (push) Canceled after 1m17s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 37s
deb / build-publish-gamescope (push) Canceled after 19s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 1s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
windows-host / package (push) Canceled after 1m53s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
arch / build-publish (push) Failing after 33s
ci / web (push) Successful in 1m11s
ci / rust-arm64 (push) Successful in 1m21s
android / android (push) Canceled after 1m50s
ci / rust (push) Canceled after 1m37s
ci / docs-site (push) Canceled after 1m42s
ci / bun-nix (push) Canceled after 1m42s
2026-08-14 08:39:18 +00:00
enricobuehler 13aa59c575 fix(vdisplay): the wlr-family backends asserted a cursor mode instead of negotiating it, so the portal refused the call
ci / bun-nix (pull_request) Successful in 24s
ci / docs-site (pull_request) Successful in 1m13s
ci / web (pull_request) Successful in 3m27s
android / android (pull_request) Successful in 4m2s
ci / rust-arm64 (pull_request) Successful in 4m40s
ci / rust (pull_request) Successful in 10m47s
Hyprland and wlroots both hardcoded portal `CursorMode::Metadata` whenever the
session had negotiated the cursor channel, and never asked the backend what it
supports. That is not a soft failure: xdg-desktop-portal's FRONTEND validates the
requested mode against the backend's `AvailableCursorModes` and fails the call
with `"Unavailable cursor mode %x"` before the backend ever sees it.

So a cursor-forward session (desktop mouse mode) died at `select_sources`,
surfacing as "pipeline build failed" and a black client, with
`unavailable cursor mode 4` in the portal log. Field report 2026-08-14.

MEASURED on .21 the same day, and it is worse than the report suggested: against
a LIVE Hyprland 0.56.2 with xdg-desktop-portal-hyprland 1.4.1 and
xdg-desktop-portal 1.22.1 — all current — `AvailableCursorModes` reads **3**
(Hidden|Embedded) on both the backend impl interface and the frontend. xdph does
not offer the metadata cursor at all, so this broke EVERY cursor-forward session
on current Hyprland, not merely on old installs. Updating the portal would not
have helped. xdpw is the same from the other end: its screencast.c refuses
METADATA outright.

pf-capture's own portal path has always negotiated (`choose_cursor_mode`); this
restates that ladder in pf-vdisplay, which may not depend on pf-capture. The
downgrade is graceful rather than merely survivable: with the portal on Embedded
no `SPA_META_Cursor` arrives, so the host feeds the cursor channel nothing and a
cursor-forward client draws nothing of its own — one pointer, not two.

`PUNKTFUNK_PORTAL_CURSOR_MODE=auto|hidden|embedded|metadata` pins the preference
for a backend that advertises a mode it implements badly, which negotiation
cannot detect. It is a preference only: pins run the same ladder, so no value can
re-create the refused request.

The module is declared unconditionally so its ladder tests run on every CI leg
rather than only the one that compiles `mod hyprland` — including a Linux-only
test pinning our bit values against ashpd's enum, verified non-vacuous by
planting a wrong discriminant (ashpd answers 4 for Metadata, the number in the
report). The regression test uses 3, the bitfield measured on glass. Linux: 225
tests pass, clippy --all-targets -D warnings clean.
2026-08-14 10:27:14 +02:00
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
34 changed files with 2423 additions and 245 deletions
+522 -1
View File
@@ -12,7 +12,243 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## v0.28.1 — in development
## v0.28.1
50 commits since v0.28.0.
A patch release in the strict sense: **nothing on the wire, in the C ABI, in the driver protocol or
in the plugin contract moves.** Every host, client, driver and plugin built against v0.28.0 keeps
working against v0.28.1 and vice versa, in both directions and with no re-pairing.
### Versions
| | v0.28.0 | v0.28.1 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged |
| C ABI | 19 | **19** | unchanged — `include/punktfunk_core.h` is byte-identical to the v0.28.0 tag |
| Rust edition | 2024 | **2024** | unchanged |
| MSRV (`rust-version`) | 1.85 | **1.85** | unchanged |
| Workspace crate dirs | 27 | **27** | unchanged |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) |
| Windows virtual-gamepad channel | 3 | **3** | unchanged |
| Plugin index schema | 1 | **1** | unchanged |
| `api/openapi.json` | 0.27.0 | **0.28.0** | the management API **did** change (two collection deletes, below); the file carries the stamp it was regenerated under, not `0.28.1` |
| gamescope patch level (`+pfhdrN`) | 6 | **7** | 8 patches → 9 (the linger crash); no new capability |
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged |
| `@punktfunk/plugin-kit` | 0.4.1 | **0.4.1** | unchanged |
**The `api/openapi.json` stamp is not a per-release counter** and should not be read as one. The
drift test (`openapi_document_is_complete_and_checked_in`) normalizes `info.version` on both sides,
so only the *surface* is gated and a version bump alone never invalidates the snapshot. The table
row says what the file actually says. Regenerating it needs a Linux or Windows host build —
`punktfunk-host` does not compile on macOS.
### ⚠ Breaking changes
**None.** No wire change, no C ABI change, no driver-protocol change, no plugin-contract change.
Three things are worth an embedder's or packager's attention anyway, none of which break a build:
- **The Rust crate gained one public constant.** `punktfunk_core::client::FLUSH_COOLDOWN` was
`pub(crate)`; the host now compares against it rather than against a copy of the number (see the
keyframe-cadence fix below). Addition only.
- **`NativeBridge.nativeStartAudio` takes a third argument** on Android — `isTv`. Detail in the
Android section; this is a JNI signature change, so an out-of-tree caller must pass it.
- **Every Linux packaging channel now ships a second gamescope artifact**, the Vulkan WSI layer,
and a package that carries the compositor without it is *fatal* rather than degraded. If you
repackage `punktfunk-gamescope` downstream, read the gamescope section before rebuilding.
### The management API gains two collection deletes — "unpair all"
Clearing a host's trust store meant one row-level delete per device, each with its own
confirmation. Two new endpoints, one per pairing plane:
```
DELETE /api/v1/clients -> {"unpaired": N}
DELETE /api/v1/native/clients -> {"unpaired": N}
```
They are **not** a loop over the per-fingerprint deletes. Each empties its store in ONE persisted
write, because N deletes would rewrite and atomically rename the store N times and a failure
partway leaves a half-emptied store with nothing saying which half. The two planes are separate
endpoints because they own separate trust stores with separate persistence and separate revocation
duties.
Being collection deletes, they carry the single delete's revocation guarantees across the whole
set: a live session owned by any removed certificate is ended, and on the GameStream side the ENet
control port (UDP 47999) closes, because no pairing is left to hold it open.
**200 with a count, not the single delete's 204/404.** "Unpair everything" is idempotent — an
already-empty store satisfies it — and the count still distinguishes three devices from none.
**Both are admin-token only.** The route-classification gates match on (method, path), so the
roster's plugin-readable `GET` does not carry over to emptying it; both new routes have explicit
rows in the table, like every other pairing-administration route. The native endpoint answers
**503** on a host built without that plane, which is why the console calls only the planes that
actually have a row.
`UnpairAllResult` is the one new schema. `api/openapi.json` is regenerated;
`docs-site/public/openapi.json` is re-synced from it (see **Documentation** at the end).
### 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 NixOS module started a second host in root's systemd, which stole the ports from the real one
Found on the first real deployment of `packaging/nix/nixos-module.nix` (NixOS 26.05, punktfunk
0.28.0-nix). The host crash-looped forever on one line:
```
ERROR punktfunk_host: start RTSP server: bind RTSP 48010: Address already in use (os error 98)
```
`systemd.user.*` has no per-user form in NixOS: it installs units into **every** user's systemd
manager. `host.autoStart` then adds them to `default.target` — for every user, including **root**,
whose `user@0.service` springs into existence the moment anybody so much as SSHes in as root. Root's
copy of the host won the race for the fixed ports, and the desktop user's copy could never bind.
The failure is nastier than it sounds because every *other* listener binds first and logs success —
the version banner, mDNS on 47989, the GameStream warning all print normally — so the log reads like
a conflict with some unrelated program. A second copy of *itself*, running as root, is the last
thing anyone looks for. `host.users` did not help: that option only granted `input`/`punktfunk`
group membership and never scoped the units.
Fixed by rendering `ConditionUser=` on all four user units (`punktfunk-host`, `punktfunk-web`,
`punktfunk-web-init`, `punktfunk-scripting`) from `host.users`. Each entry is written `|user` — the
pipe makes it a *triggering* condition, which systemd ORs; plain repeated `ConditionUser=` lines are
ANDed and would have matched nobody. With `host.users` empty the units fall back to
`ConditionUser=!@system`, which still keeps root out while leaving a normal login free to run the
host by hand, as the module header documents.
`packaging/nix/module-check.nix` gained three assertions covering both branches and the fact that
`punktfunk-web-init` keeps its pre-existing (non-triggering) `ConditionPathExists` alongside the new
condition. They run in the `eval` leg of `nix.yml`, and were verified to fail against the unfixed
module before being committed.
### 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).
### Hyprland/Sway — the wlr-family backends asserted a cursor mode instead of negotiating it
🛑 **Every cursor-forward session on current Hyprland died at `select_sources`** — "pipeline build
failed" and a black client, with `unavailable cursor mode 4` in the portal log.
Hyprland and wlroots both hardcoded portal `CursorMode::Metadata` whenever the session had
negotiated the cursor channel, and never asked the backend what it supports. That is **not** a soft
failure: xdg-desktop-portal's **frontend** validates the requested mode against the backend's
`AvailableCursorModes` and fails the call with `"Unavailable cursor mode %x"` before the backend
ever sees it.
**Measured on glass 2026-08-14, and worse than the report suggested.** Against a live Hyprland
0.56.2 with xdg-desktop-portal-hyprland 1.4.1 and xdg-desktop-portal 1.22.1 — all current —
`AvailableCursorModes` reads **3** (`Hidden|Embedded`) on both the backend impl interface and the
frontend. **xdph does not offer the metadata cursor at all**, so this broke every cursor-forward
session on current Hyprland, not merely on old installs, and **updating the portal would not have
helped.** xdpw is the same from the other end: its `screencast.c` refuses `METADATA` outright.
`pf-capture`'s own portal path has always negotiated (`choose_cursor_mode`); this restates that
ladder in `pf-vdisplay`, which may not depend on `pf-capture`. The downgrade is graceful rather than
merely survivable: with the portal on `Embedded` no `SPA_META_Cursor` arrives, so the host feeds the
cursor channel nothing and a cursor-forward client draws nothing of its own — **one pointer, not
two.**
**`PUNKTFUNK_PORTAL_CURSOR_MODE=auto|hidden|embedded|metadata`** pins the preference for a backend
that advertises a mode it implements badly, which negotiation cannot detect. It is a preference
only: a pin runs the same ladder, so no value can re-create the refused request.
⚠ The module is declared **unconditionally**, so its ladder tests run on every CI leg rather than
only the one that compiles `mod hyprland` — including a Linux-only test pinning our bit values
against ashpd's enum (ashpd answers 4 for `Metadata`, the number in the report), verified
non-vacuous by planting a wrong discriminant.
### Android — the audio plane trusted AAudio, and a TV box that opened a stream it never played was silent for the session
@@ -64,6 +300,84 @@ existing `debug.punktfunk.no_av_sync`: `debug.punktfunk.audio_sharing` (`exclusi
old give-up-on-disconnect behaviour). A stream that stops taking samples after it started now says
so at `error` level instead of looking exactly like an app with no sound.
### gamescope — we ship our own Vulkan WSI layer, so a game can reach an HDR10 swapchain (⚠ packager-visible)
🛑 **On essentially every box running a distro gamescope, no game could render HDR at all** — and
nothing said so.
A game nested under gamescope gets an HDR10 swapchain from the FROG WSI layer and from nothing
else: gamescope advertises no runtime colour-management protocol a Mesa/NVIDIA WSI could negotiate
through. That layer speaks `gamescope_swapchain` to the compositor, and when the two disagree the
compositor rejects the client's `swapchain_feedback` and **every Vulkan client dies on a black
screen** with sound and input intact and no error anywhere.
We shipped our own compositor and *not* a layer, on the recorded grounds that the layer is
"version-independent of the compositor binary". It is not — `wsi_layer_matches_our_gamescope()`
exists precisely because it is not — so the host was left guessing from version triples, and that
guess is wrong in both directions. A distro at the same upstream tag that patched the protocol
compares EQUAL and keeps a layer that will black-screen every game; a distro at a different tag
with a byte-identical protocol compares unequal and loses HDR for nothing. **Since we pin a rev,
the second case is the normal one.**
We now build the layer from the same tree at the same rev as the compositor and ship it, so the two
cannot drift and the guess stops being load-bearing. It installs under **our own** name
(`VK_LAYER_PUNKTFUNK_gamescope_wsi`), at our own path, with our own enable/disable variables, so it
coexists with the distro's rather than colliding — the Vulkan loader keys implicit layers on that
name — and the host switches the two independently within one session.
`WsiPlan` resolves three states once per launch (the fallback spawns `--version` probes):
| state | condition | action |
|---|---|---|
| `Ours` | our layer is installed | enable ours, force the distro's off — **both halves, or it is a bug** |
| `DistroKept` | no layer of ours, distro's looks compatible | touch nothing |
| `DistroDisabled` | no layer of ours, distro's untrusted | v0.28.0's behaviour |
That last arm is the fail-safe: a host newer than its gamescope package behaves exactly as it did,
rather than enabling a layer that is not there.
**What packagers must know.** The layer manifest carries an **absolute** `library_path` baked in
at build time, so every channel installs the `.so` at exactly that path: literal
`/usr/lib/punktfunk`**not** `%{_libdir}` (which is `/usr/lib64` on Fedora) and not a Debian
multiarch triplet. Nothing links it by soname (the loader `dlopen`s it by that path), so multilib
has no claim. rpm and nix read the path back **out of the manifest** and fail if it names a file the
package does not install, because a manifest pointing at nothing is the silent shape of this bug.
A missing layer is **fatal in every channel**, not best-effort: a package carrying the compositor
without it looks completely healthy and then silently denies every game an HDR10 swapchain.
The packaging scripts now take `--stage` (the DESTDIR the gamescope build script wrote) instead of
a path to one binary, and CI caches the whole staged tree; the `gs-cache` key already hashes
`packaging/gamescope/**`, so stale caches in the old single-file shape cannot be restored into the
new layout. The manifest rewrite lives in `packaging/gamescope/rewrite-wsi-layer-manifest.py`
rather than a heredoc, because the FHS builds and the Nix store both need it and must rename the
layer identically. **NixOS has no `/usr`**, so the layer lives inside the gamescope derivation and
the host's path is overridable with **`PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR`**, which the module sets
— the same posture as `PUNKTFUNK_GAMESCOPE_BIN`.
### gamescope — HDR sessions anchored SDR white a stop bright, and never said game HDR was unreachable
🛑 **Field report: Steam's Big Picture UI glaring and over-saturated while HDR game content looked
washed out, on the same stream.** Those are one error.
gamescope maps everything that is not an HDR game — the desktop, the Steam overlay, an SDR title —
into the session's PQ container at `--hdr-sdr-content-nits`, and we passed that flag **only** when
an operator had set `PUNKTFUNK_GAMESCOPE_SDR_NITS`. Unset, gamescope used its own default of
**400**, while every first-party client anchors diffuse white at **203** (BT.2408 reference white;
the Apple presenter hands exactly that to `CAEDRMetadata.hdr10`'s `opticalOutputScale`). The two
ends sat nearly a stop apart, so the UI landed above SDR white and the client's tone-mapper worked
from a reference point the host had never used, flattening the content around it.
**The flag is now always passed, defaulting to 203.** `PUNKTFUNK_GAMESCOPE_SDR_NITS` still
overrides it for anyone who wants a brighter or dimmer desktop — it is the anchor, not a taste
knob. ⭐ Because it is an env var, a field A/B needs **no rebuild**.
Separately, and visible in the same log: the two HDR decisions in a gamescope session were made
independently. `hdr_args()` never consulted `wsi_layer_matches_our_gamescope()`, so when the layer
check fired the session launched **advertising HDR while having made an HDR10 swapchain
unreachable for every game in it** — a title told to render HDR rendered it into an SDR swapchain
and looked washed out, with nothing anywhere saying why. It now warns. The behaviour of the check
itself is deliberately unchanged; the section above is the real fix.
### punktfunk-gamescope `+pfhdr7` — a lingered session no longer dies of its own capture teardown
🛑 **On client disconnect the host keeps the headless gamescope alive so a reconnect resumes the
@@ -83,6 +397,200 @@ four coredumps on 4K60 HDR + composited cursor, zero after; disconnect/reconnect
lingered session. Banner `+pfhdr6``+pfhdr7` (no new capability — but "reconnect lost my game"
triage must be able to read a box's exposure off its banner, the same rule as `+pfhdr5`/`6`).
### Apple — the stats overlay lied three ways, and every host-anchored number with it
🛑 **Two sessions minutes apart on the same wire read `hostnet_p50` 1721 ms, then a physically
impossible 4.4 ms** — host-side encode alone is ~4.7. Three independent defects, all of which
corrupt any measurement taken against a host clock:
- **A frozen clock-offset.** The client consumed the **connect-time** skew offset and cached it —
in a `Stage2Pipeline` field, in a `StreamPump` `let`, and in a `ContentView` closure **capture
list** feeding the hostnet meter and the host/network splitter. The core keeps a *live* estimate
(`punktfunk_connection_clock_offset_now_ns`, ABI v10, re-synced every 60 s and on suspected
wall-clock steps) whose own doc says the connect-time value "silently corrupts every
capture-clock comparison" after an NTP step — **and a VM host steps.**
`PunktfunkConnection.clockOffsetNs` is now the live read (an atomic load behind the FFI), read at
use: per record, per AU, per enqueue. The Swift audio plane's AvSync observation takes the same
live value.
- **Silently trimmed impossible samples.** `LatencyMeter`'s guard (≤ 0 after offset correction)
dropped samples without counting them, so a wrong offset did not invalidate a window — it trimmed
the impossible half of the shifted distribution and presented the surviving tail as a plausible
small number. That is the origin of the historical "0 ms network / 0 ms e2e" readings. Refusals
are now counted and drained **separately from `Stats`** — deliberately, because a fully-poisoned
window drains to `nil` and a count inside `Stats` would vanish with it. The HUD shows an orange
**`clock offset suspect`** line and the stats line grew **`skew_trim=N`**; nonzero means
disregard `e2e`/`hostnet` for that window.
- **`-1` fallbacks printing as `NaN`.** In a `CVarArg` context `cond ? someDouble : -1` does **not**
unify to `Double` — the literal goes in as `Int`, and `%f` reads `Int64(-1)`'s all-ones bit
pattern, which is a quiet NaN. Latent since the 1 Hz stats line existed. All fallbacks are now
typed `-1.0`.
**Any client-side e2e or hostnet figure recorded before this release is suspect** and worth
re-measuring rather than trusted as a baseline.
Two new levers ship with the tvOS present-floor investigation, both env-only:
**`PUNKTFUNK_FRAME_LATENCY`** (float 0…4, default 1) makes the `preferredFrameLatency` ask
adjustable, so an on-device ladder can establish whether the property does anything on tvOS — the
previous "immovable two-refresh floor" verdict rested on a **readback** of a plain read-write
float, which is not a grant. **`PUNKTFUNK_PRESENTER=stage1` now resolves on Release builds** (the
persisted picker stays DEBUG-gated; an env var takes a `devicectl`/Xcode launch to exist, so it is
never a leftover). Stage-1 presents on the hardware video plane rather than through the GPU
compositor — the one rung that can dodge the two-refresh regime — and the field A/B that concluded
otherwise had silently run stage-4, because the gate keyed on build config.
### Apple — two colour faults: an SDR stream shipped untagged, and it forced the TV into HDR10
- **The SDR layer was never tagged.** `configure(hdr:)` guards on `hdr != hdrActive` and
`hdrActive` starts `false`, so a session that is SDR from its first frame matched the initial
state, fell through the guard, and `configureColor` never ran once — the layer kept `make()`'s
bare configuration, which assigns no colour space. An untagged `CAMetalLayer` gets no colour
matching: a BT.709 stream is drawn in the display's native space. Mild oversaturation on a P3 Mac
or iPad; on a tvOS display composited for HDR it also lifts the black floor. ⚠ It also made
`PUNKTFUNK_SDR_COLORSPACE` **dead code on exactly the sessions it exists to fix**, so a field A/B
of that knob would have shown no change.
- **An SDR stream drove an HDR-capable TV into PQ output.** `applyDisplayCriteriaIfNeeded` builds a
synthetic format description hardcoding BT.2020 primaries, ST.2084 and the BT.2020 matrix, then
hands it to `AVDisplayManager` — and its guard checked only that no criteria had been set and that
the user's HDR *setting* was on, never that **the stream** was HDR. That setting defaults to true.
The Apple TV switches HDMI to limited range in its HDR modes, so a set configured for full range
renders code 16 as grey rather than black. Now gated on `connection.isHDR` as well; layout re-runs
it, so a session that flips to HDR mid-stream still picks the mode up.
### Apple — the macOS device-change recovery could answer itself forever (mic on)
**Streaming from a Mac with the microphone enabled cut audio AND input on a ~2.5 s metronome
while video ran untouched** (field, 2026-08-14: a Mac Studio whose default input is a 6-channel
device). The chain: the voice-processing engine cannot start on that mic, every rebuild re-tried
it, and the failed attempt's HAL churn (VPIO builds and tears down an aggregate device) stopped
the healthy fallback engines — which posted the `AVAudioEngineConfigurationChange` that scheduled
the next rebuild. Each ~1.9 s rebuild runs on the main thread, where macOS input capture and
sending live, so input froze on the same beat — and since audio, input and mic share the QUIC
datagram plane while video rides its own socket, the wire signature read as a network fault and
the host's METRONOMIC heuristic pointed at the display stack. Three defenses, layered because no
single one covers every feedback shape:
- **A voice-processing start failure latches per input device** (`CombinedTopologyGate`): a
rebuild goes straight to the split topology instead of re-running a failure that is a property
of the device. A different default input earns exactly one fresh attempt.
- **A configuration change posted by an engine that is RUNNING is the rebuild's own echo, and is
ignored**: an engine stops itself before posting, so a live poster was already restarted.
- **Rebuilds that chain anyway back off exponentially** (`RebuildBackoff`: 0.5 s floor doubling
to a 30 s cap, reset by 10 s of quiet) — an unforeseen loop costs one blip per half-minute
instead of a metronome, and the chaining itself logs a WARN that names the condition.
iOS/tvOS behaviour is untouched (routes are session-managed there; nothing is latched). Until a
client carries this, the field workaround is turning the client microphone off.
**And the engines no longer start on the main thread at all.** An engine start can block on the
audio server for seconds (~1.9 s per attempt in the field case) and macOS captures and sends the
stream's input from the main thread — so even a single legitimate device switch froze input for
the length of the rebuild, loop or no loop. All engine build/start/teardown now runs on a
per-session serial `engineQueue`; the main queue keeps only the trigger bookkeeping (debounce,
backoff, retry ladder), which is cheap by construction. ⚠ Embedder-visible edge:
`SessionAudio.start()` is now asynchronous on macOS too (it always was on iOS/tvOS) — playback is
live shortly after the call, not on return, and `stats` is safe from any thread.
### Apple gamepad UI — a host menu, and About becomes a page
**UP on a saved tile opens Wake / Copy link / Edit… / Forget pairing / Remove.** The desktop and
Android consoles have had this for a while; this is the Apple port, so the three consoles are
learned once. Wiring UP takes the whole vertical axis away from scrolling (down goes inert) — a
horizontal carousel has no vertical travel to spend, and one meaning per direction is what makes
the gesture learnable. **Remove arms on the first press and fires on the second**, disarming if
focus wanders off the row: the touch grid gets a system confirmation dialog, and a thumbstick from
across a room deserves at least as much. Edit reuses `GamepadAddHostView` seeded from the record and
writes a **copy** back through `HostStore.update`, so the fingerprint, MACs, pins and binding the
form never shows survive a rename; it **replaces** the menu rather than stacking on it, keeping the
shell's "depth ≤ 1 by construction" true. A pinned profile card offers only Unpin — it is a
shortcut, not a second host.
**The start-of-stream shortcut banner is retired.** Telling someone the controls for six seconds,
over the stream they just connected to, answers the question at the one moment nobody is asking it
— and it put a composited overlay above the stream to do it. The words are now a catalogue rendered
in an About page you can open, which is also its own section rather than the last row of Interface.
Its remaining fixes: the identity card became a version line under the rows, a zero-radius clip is
still a clip (it cropped the TV's wide icon), and the card ignored the row column.
**Apple console screens read the ink they publish.** A SwiftUI screen cannot read the environment
value it publishes in the same view — so a pale palette stayed white-on-white on Apple TV. Fixed
across every console screen.
### Console UI — Skia sized its function table to the loader, not to what we promised
🛑 **On a Steam Deck the console home died on update**, and in a stream the same failure quietly
cost the stats OSD and capture HUD.
The skia-safe 0.87 → 0.99 move swapped `BackendContext::new` for `new_builder(…, None)` and
recorded the `None` as "byte-for-byte what the removed constructor did". True of the **value**,
false of the **behaviour**: `None` leaves Skia's `fMaxAPIVersion` at its `0` sentinel, and the newer
Skia acts on that sentinel by falling back to **`vkEnumerateInstanceVersion()` — the loader's
ceiling, not ours.** The presenter declares 1.3; a current Mesa answers 1.4 (1.4.321 on SteamOS
3.7, host and inside the flatpak sandbox alike). Skia then validates a 1.4 function table against an
instance that only promised 1.3, `vkGetDeviceProcAddr` returns null for the entry points in
between, and `make_vulkan` hands back `None`. At 0.87 the sentinel was inert because that Skia knew
nothing of Vulkan 1.4 — **which is why this surfaced the moment v0.28.0 landed.**
`run.rs` makes an overlay that cannot init fatal for `--browse`, so the Decky panel's button and the
gamepad-UI library shortcut both failed to open. The presenter now publishes
`SharedDevice::api_version``min(what we declared, what the loader reports)` — and
`SkiaOverlay::init` passes it instead of `None`. ⚠ `pf-presenter`'s `vk` module is
`cfg(any(linux, windows))`, so this was never Deck-specific.
### pf-vkdecode — AV1's "maximum parameters" level is not a level above the ceiling
🛑 **Every AV1 session demoted to D3D11VA** with `stream level (seq_level_idx 31) above the device's
maxLevel (AV1 Std level 23)` — on hardware decoding the stream trivially on the rung it fell
through to.
`seq_level_idx` is a 5-bit field: Annex A defines 0…23 (levels 2.0…7.3), reserves 24…30, and makes
**31 the "maximum parameters" level — the spec's own way of saying the bitstream is not constrained
to a level.** `StdVideoAV1Level` stops at 7.3 = 23, so 31 has no Std code point and the index-coded
comparison that holds across 0…23 says nothing: `31 > 23` is true even of a device that decodes
everything AV1 can name, which is what makes it useless as a capability test. We write no AV1 level
on any host encode path, so whichever sentinel the vendor's encoder defaults to is what the client
must accept. This is the AV1 half of the same defect fixed for H.264/H.265 in v0.28.0, which was
left alone on the premise that no over-declaration had been seen in the field — the reporter's log
from that same day already showed otherwise.
### Client stats — the stage line is a partition again
A field reader added up `host 5.4 · net 0.3 · decode 6.6 · display 1.4` against `e2e 8.1` and asked
why the parts did not sum. Fair question: they sum **without** `decode`.
The stages *are* a per-frame partition of e2e — pts →(host+net)→ received →(decode)→ decoded
→(display)→ displayed — for as long as the `decoded` stamp is a **completion** stamp. On the
synchronous rungs it is. On the **native-Vulkan** rung `receive_frame` returns at *submission*
(~0.1 ms) and the stamp is taken there, so `display` is measured from submit and the GPU decode
happens **inside** it. `host+net` and `display` already tile e2e; the `decode` figure (received →
fence-complete) re-counts the GPU work `display` contains — two figures with one overlap, printed
as though they tiled.
On that rung `decode` now leaves the stage line and gets its own, carrying the two caveats a reader
needs: it is **one sample per window** there, not the p50 every other figure on that line is, and it
is already inside `display`, so adding it double-counts. The synchronous rungs are untouched.
**Deliberately not changed:** the one-sample-per-window design. A per-frame fence wait serialises
the decode pipeline (an APU's 19 ms decode capping a 5120×1440 stream at ~51 fps) and polling
quantises every sample up by a frame interval. The reporting was the defect, not the sampling.
### Host — two warnings that named the wrong subsystem
Both fired in the same 2026-08-13 field log, and both sent an investigation somewhere innocent:
- **"Client keyframe recoveries are METRONOMIC — a periodic host/display disturbance … is the
likely cause"**, at `period_s=2.0`, naming three host subsystems. **2.0 s is the *client's*
`FLUSH_COOLDOWN`.** The receive-backlog guard sheds a standing queue with a flush plus a keyframe
request, rate-limited to one per cooldown, so a client that cannot sustain the stream asks for a
keyframe at exactly that spacing for as long as it stays behind. **Perfect periodicity is the
signature of a fixed software cooldown, not of a physical disturbance.** The host now compares
against `punktfunk_core::client::FLUSH_COOLDOWN` itself rather than a copy of the number, so the
two cannot drift.
- **"The audio encode thread could not keep up — captured audio was DROPPED"**, worst case
`dropped_chunks=11251`. Not one sample anybody wanted was lost. PipeWire negotiated a 128-frame
quantum, so the plane produces 48000/128 = 375 chunks/s and a 30 s window holds exactly 11250 —
a 100 % drop rate at `peak_db=-120.0`, digital silence. Every one of the ten warnings straddled a
**session boundary**, and `dropped_chunks/375` matches the seconds with *no live session* in that
window to within a fraction of a second. The warning no longer fires for idle seconds.
### NixOS — the plugin runner was installed, running, and reported missing
🛑 **On NixOS every plugin *package* op failed with "the plugin runner isn't installed", on a box
@@ -121,6 +629,19 @@ NixOS ships only `sh` in `/bin`, so `gamelease`'s hand-off test and `pyrowave_re
handshake-rung test failed there for reasons unrelated to the code under test. Both now resolve a
real binary rather than assuming an FHS path.
### Documentation
**`docs-site/public/openapi.json` was stale again, and by the same mechanism as last release.**
v0.28.0 fixed it once (it was five releases behind at `0.21.0`); the scanner-removal regen then
updated `api/openapi.json` alone and it drifted a second time inside that same cycle. It has now
drifted a third time, across the unpair-all endpoints — the docs-site copy was still stamped
`0.27.0` and missing both collection deletes. Re-synced; the two files are byte-identical again.
⚠ **The copy is a documented manual step (`cp api/openapi.json docs-site/public/openapi.json`,
CONTRIBUTING.md) and nothing in CI enforces it.** Three drifts in two release cycles is the
argument for gating it; until something does, **treat the copy as part of regenerating, not as a
follow-up.**
---
## v0.28.0
Generated
+36 -36
View File
@@ -1090,7 +1090,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"pf-capture",
@@ -1222,7 +1222,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"pf-win-display",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -2343,7 +2343,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.28.0"
version = "0.28.1"
[[package]]
name = "lazy_static"
@@ -2446,7 +2446,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"bindgen",
"cmake",
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"punktfunk-core",
]
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"cros-codecs",
"tracing",
@@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ashpd",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ash",
@@ -3031,7 +3031,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ashpd",
@@ -3049,7 +3049,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ash",
@@ -3071,7 +3071,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3081,7 +3081,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ash",
@@ -3107,7 +3107,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"libc",
@@ -3119,7 +3119,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3133,11 +3133,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.28.0"
version = "0.28.1"
[[package]]
name = "pf-inject"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ashpd",
@@ -3166,14 +3166,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ash",
@@ -3188,7 +3188,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"serde",
"serde_json",
@@ -3196,7 +3196,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"aws-lc-rs",
@@ -3208,7 +3208,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3217,7 +3217,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ashpd",
@@ -3250,7 +3250,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"ash",
"cros-codecs",
@@ -3261,7 +3261,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"pf-paths",
"punktfunk-core",
@@ -3272,7 +3272,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ash",
@@ -3484,7 +3484,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3494,7 +3494,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"android_logger",
"jni",
@@ -3512,7 +3512,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"async-channel",
@@ -3529,7 +3529,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"pf-client-core",
"pf-console-ui",
@@ -3543,7 +3543,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3561,7 +3561,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"aes-gcm",
"cbindgen",
@@ -3593,7 +3593,7 @@ dependencies = [
[[package]]
name = "punktfunk-encode-worker"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"pf-encode",
"tracing",
@@ -3602,7 +3602,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"aes",
"aes-gcm",
@@ -3672,7 +3672,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3686,7 +3686,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"anyhow",
"ksni",
@@ -3709,7 +3709,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.28.0"
version = "0.28.1"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -65,7 +65,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.28.0"
version = "0.28.1"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
@@ -32,8 +32,11 @@ final class AudioDeviceWatcher {
/// posts one last change as it is torn down, and other AVAudioEngines in the process are not
/// ours to restart.
private let isOurs: (AnyObject?) -> Bool
/// Delivered on the main queue.
private let onChange: (Reason) -> Void
/// Delivered on the main queue. The second argument is the engine that posted the change
/// (`.engineConfiguration` only; nil for the HAL listener) the owner needs the OBJECT, not
/// just the reason, because an engine that is RUNNING when the notification lands is one the
/// owner already restarted: acting on that echo is how a rebuild loop starts.
private let onChange: (Reason, AnyObject?) -> Void
private let lock = NSLock()
private var configObserver: NSObjectProtocol?
@@ -41,7 +44,7 @@ final class AudioDeviceWatcher {
private var defaultOutputListener: AudioObjectPropertyListenerBlock?
#endif
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason) -> Void) {
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason, AnyObject?) -> Void) {
self.isOurs = isOurs
self.onChange = onChange
}
@@ -63,7 +66,7 @@ final class AudioDeviceWatcher {
let posted = note.object as AnyObject?
DispatchQueue.main.async {
guard let self, self.isOurs(posted) else { return }
self.onChange(.engineConfiguration)
self.onChange(.engineConfiguration, posted)
}
}
lock.lock()
@@ -77,7 +80,8 @@ final class AudioDeviceWatcher {
// (the voice-processing engine, which is the DEFAULT macOS configuration and which no Mac
// here can even initialize). The HAL is told either way.
let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
self?.onChange(.defaultOutputDevice) // on the main queue registered against it below
// On the main queue registered against it below. No engine posted this, so nil.
self?.onChange(.defaultOutputDevice, nil)
}
var address = Self.defaultOutputAddress()
let status = AudioObjectAddPropertyListenerBlock(
@@ -42,7 +42,10 @@ public enum AudioDevices {
return channelCount(id, scope: kAudioObjectPropertyScopeInput)
}
private static func defaultInputDevice() -> AudioDeviceID? {
/// The device the system is currently capturing from the key `SessionAudio`'s
/// voice-processing gate latches a start failure against (the failure is a property of the
/// input device, so a new device earns a fresh attempt).
static func defaultInputDevice() -> AudioDeviceID? {
systemDevice(kAudioHardwarePropertyDefaultInputDevice)
}
@@ -0,0 +1,89 @@
// The two policy decisions of the device-change recovery, extracted where a unit test can reach
// them. Both exist because of one field incident (2026-08-14, Mac Studio): the voice-processing
// engine could not start on a 6-channel input device, every rebuild re-tried it, and the failed
// attempt's HAL churn (VPIO builds and tears down an aggregate device) re-stopped the fallback
// engines which posted the configuration change that scheduled the next rebuild. A ~2.5 s
// metronome of audio gaps, forever, with each rebuild also stalling the main thread (where macOS
// input capture lives), so the stream's INPUT cut out on the same beat. The session-side wiring
// lives in `SessionAudio`; the decisions live here because the loop shipped precisely because
// they could not be tested without a mic and a session.
#if os(macOS)
import CoreAudio
#endif
import Foundation
#if os(macOS)
/// Should a rebuild try the combined (voice-processing) topology again?
///
/// A VPIO start failure is a property of the INPUT DEVICE (its channel count and format), not of
/// the moment: retrying it on the same device fails the same way, and the attempt is not free
/// engaging and abandoning the voice processor churns the HAL hard enough to stop the healthy
/// fallback engines. So a failure latches until the default input actually changes; a new device
/// earns exactly one fresh attempt (it may well support VPIO), and its own failure latches again.
struct CombinedTopologyGate {
private var failed = false
/// The default input device the failure was observed on nil is a real value here ("failed
/// with no resolvable input device"), which is why `failed` is tracked separately.
private var failedInput: AudioDeviceID?
/// The combined topology failed with `input` as the default input device.
mutating func noteFailure(input: AudioDeviceID?) {
failed = true
failedInput = input
}
/// True when the combined topology is worth attempting with `input` as the default input
/// device. A device change clears the latch the answer is about the CURRENT hardware, and
/// coming back to a device that failed before earns a fresh attempt too (the failure may have
/// been the mid-transition kind, and one attempt per device change cannot loop).
mutating func shouldTry(input: AudioDeviceID?) -> Bool {
guard failed else { return true }
guard input == failedInput else {
failed = false
failedInput = nil
return true
}
return false
}
}
#endif
/// The delay before the next engine rebuild the base debounce/floor behaviour, plus an
/// escalating floor when rebuilds CHAIN (each one retriggered by its predecessor's own fallout).
///
/// One device switch produces one rebuild: its trigger burst is coalesced upstream, so the next
/// trigger normally arrives minutes later and gets the base floor. A trigger that arrives hard on
/// the heels of the last rebuild, again and again, is a rebuild answering itself and since the
/// recovery cannot always identify its own echo, the backstop is to keep answering but at a
/// doubling floor, so an unforeseen feedback shape costs one audio blip per half-minute instead
/// of a metronome. A quiet stretch resets the ladder to full responsiveness.
struct RebuildBackoff {
/// Let the burst of triggers from one switch land before rebuilding.
static let debounce: TimeInterval = 0.15
/// Floor between two rebuilds.
static let floor: TimeInterval = 0.5
/// The escalated floor's cap: looping recoveries settle at one attempt per this interval.
static let floorCap: TimeInterval = 30
/// A trigger this long after the last rebuild is unrelated to it the chain resets.
static let chainWindow: TimeInterval = 10
/// Consecutive rebuilds whose trigger arrived within `chainWindow` of the previous rebuild.
private(set) var chain = 0
private var lastRebuildAt: TimeInterval = -.infinity
/// The delay to schedule the next rebuild with, for a trigger arriving at `now`
/// (`systemUptime`). Mutates the chain accounting: call once per SCHEDULED rebuild, not per
/// coalesced trigger.
mutating func delay(now: TimeInterval) -> TimeInterval {
let since = now - lastRebuildAt
chain = since < Self.chainWindow ? chain + 1 : 0
let floor = min(Self.floor * pow(2, Double(min(chain, 6))), Self.floorCap)
return max(Self.debounce, floor - since)
}
/// The rebuild actually ran at `now` the reference the next trigger's `delay` measures from.
mutating func noteRebuild(at now: TimeInterval) {
lastRebuildAt = now
}
}
@@ -63,15 +63,24 @@ public final class SessionAudio {
private var micMuted = false
/// The playback jitter ring created by whichever engine starts playback first and KEPT
/// across an engine rebuild (the permission-grant upgrade in `startEngines` swaps engines,
/// not the ring, so the drain thread never has to be re-pointed). Main-thread confined,
/// like every start path.
/// not the ring, so the drain thread never has to be re-pointed). Guarded by `stateLock`:
/// the start paths run on `engineQueue`, while `stats` reads from the main thread.
private var ring: AudioRing?
/// Every engine build, start, stop and rebuild runs here, serially and NOT on the main
/// thread. macOS captures and sends input from the main thread, so the seconds a
/// voice-processing start can take (~1.9 s measured in the 2026-08-14 field loop) would
/// freeze the stream's input for exactly that long the recovery must never make the main
/// thread wait on the audio server. The main queue keeps only the trigger bookkeeping
/// (debounce, backoff, retry ladder), which is cheap by construction.
private let engineQueue = DispatchQueue(
label: "io.unom.punktfunk.audio.engines", qos: .userInitiated)
/// The video plane's end-to-end meter (captureon-glass), if the owner wired one the
/// reference the A/V sync loop steers the ring against. `nil` leaves the loop inert and the
/// ring exactly as it was before sync existed, which is also what the stage-1 fallback
/// presenter gets: it decodes and presents inside the layer with no per-frame stamp, so it can
/// offer no reference, and a loop with no reference must not invent one. Main-thread confined,
/// like `ring`; the meter itself is internally locked and read from the drain thread.
/// offer no reference, and a loop with no reference must not invent one. Written ONCE in
/// `start()` before anything is dispatched (the queue hop orders it for `startDrain`); the
/// meter itself is internally locked and read from the drain thread.
private var videoLatency: LatencyMeter?
#if !os(macOS)
/// AVAudioSession `setCategory`/`setActive` are synchronous and block on the audio server, so
@@ -99,7 +108,8 @@ public final class SessionAudio {
// MARK: - Device changes (see `installDeviceChangeRecovery`)
/// What `start()` was asked for, so a rebuild can put back the SAME topology the session was
/// started with. Main-thread confined, like the start paths that read it.
/// started with. Guarded by `stateLock` (written on the caller's thread, read when a rebuild
/// fires on the main queue).
private var startConfig: StartConfig?
private struct StartConfig {
let speakerUID: String
@@ -110,20 +120,23 @@ public final class SessionAudio {
}
/// Watches the hardware for us (see `AudioDeviceWatcher`). Guarded by `stateLock`.
private var deviceWatcher: AudioDeviceWatcher?
/// Whether the engines have been built at least once. Distinguishes "not started yet" (iOS
/// starts asynchronously) from "started and dead", which is what the recovery may act on.
/// Main-thread confined.
/// Whether the engines have been built at least once. Distinguishes "not started yet" (every
/// platform starts asynchronously now) from "started and dead", which is what the recovery
/// may act on. Guarded by `stateLock` (set on `engineQueue`, read on the main queue).
private var enginesAttempted = false
/// A rebuild is already on the main queue one device switch produces a burst of triggers
/// and they must collapse into one restart. Main-thread confined.
private var rebuildQueued = false
/// `systemUptime` of the last rebuild, so a device that renegotiates in a loop cannot spin
/// the session. Main-thread confined.
private var lastRebuildAt: TimeInterval = 0
/// Let the burst of triggers from one switch land before rebuilding.
private static let rebuildDebounce: TimeInterval = 0.15
/// Floor between two rebuilds.
private static let rebuildFloor: TimeInterval = 0.5
/// Debounce/floor for the next rebuild, with an escalating floor when rebuilds chain (each
/// retriggered by its predecessor see `RebuildBackoff`). Main-thread confined.
private var rebuildBackoff = RebuildBackoff()
#if os(macOS)
/// Latches a voice-processing start failure per input device, so a rebuild never re-attempts
/// a topology that deterministically fails the retry is what turned one failure into a
/// rebuild loop (see `CombinedTopologyGate` and the note on `installDeviceChangeRecovery`).
/// `engineQueue`-confined, like the start paths that consult and feed it.
private var combinedGate = CombinedTopologyGate()
#endif
/// Retries when a rebuild's `start()` loses the race with a device that is still going away
/// (0.3 s, 0.6 s, 1.2 s). A failed rebuild leaves no engine to post the next notification,
/// so this ladder and, on macOS, the HAL listener is all that stands between a mistimed
@@ -151,11 +164,12 @@ public final class SessionAudio {
}
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default
/// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed). On macOS
/// the engines start synchronously on the caller's (main) thread. On iOS/tvOS start() is
/// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on
/// a later main-queue hop (gated by `!flag.isStopped`) so playback is live shortly after, not
/// on return. The mic may start later still if the permission prompt is pending.
/// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed).
/// ASYNCHRONOUS on every platform: the engines start on `engineQueue` (iOS/tvOS activate the
/// AVAudioSession off the main thread first), gated by `!flag.isStopped` so playback is
/// live shortly after, not on return. An engine start can block on the audio server for
/// seconds, and the caller's (main) thread is where macOS input capture lives it must
/// never wait. The mic may start later still if the permission prompt is pending.
/// `echoCancel` picks the engine topology see the header note and `wantsCombined`.
///
/// `videoLatency` is the session's END-TO-END latency meter (captureon-glass). Pass it to arm
@@ -166,26 +180,33 @@ public final class SessionAudio {
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool,
videoLatency: LatencyMeter? = nil
) {
self.videoLatency = videoLatency
self.videoLatency = videoLatency // before any dispatch below startDrain reads it
// Before any engine exists: the recovery watches the hardware, not the engines, and the
// config it rebuilds from has to be recorded whether or not this start succeeds.
stateLock.lock()
startConfig = StartConfig(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
micEnabled: micEnabled, echoCancel: echoCancel)
stateLock.unlock()
installDeviceChangeRecovery(micEnabled: micEnabled)
#if os(macOS)
// No AVAudioSession on macOS start the engines directly (caller's thread, as before).
startEngines(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
micEnabled: micEnabled, echoCancel: echoCancel)
// No AVAudioSession on macOS but the engines start on `engineQueue`, never the
// caller's (main) thread: a voice-processing start can block on the audio server for
// seconds, and the main thread is where input capture lives.
engineQueue.async { [weak self] in
guard let self, !self.flag.isStopped else { return }
self.startEngines(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
micEnabled: micEnabled, echoCancel: echoCancel)
}
#else
// Configure + activate the session OFF the main thread (it blocks on the audio server),
// then start the engines back on the main thread once it's active engine routing/format
// then start the engines on `engineQueue` once it's active engine routing/format
// depend on the active session. A stop() racing in between is caught by the flag guard.
Self.sessionQueue.async { [weak self] in
guard let self else { return }
self.activateAudioSession(micEnabled: micEnabled)
DispatchQueue.main.async { [weak self] in
self.engineQueue.async { [weak self] in
guard let self, !self.flag.isStopped else { return }
self.startEngines(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
@@ -342,12 +363,15 @@ public final class SessionAudio {
#endif
/// Build + start the engines combined (voice-processed) or split, per `wantsCombined`
/// with the mic uplink only when enabled + authorized. Main thread (engine setup); on
/// iOS/tvOS the session is already active by the time this runs.
/// with the mic uplink only when enabled + authorized. Runs on `engineQueue` (a start can
/// block on the audio server for seconds never the main thread); on iOS/tvOS the session
/// is already active by the time this runs.
private func startEngines(
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
) {
stateLock.lock()
enginesAttempted = true // even if every path below fails see `reviveStoppedEngines`
stateLock.unlock()
#if os(tvOS)
// No app-accessible microphone input on tvOS playback only.
startPlayback(speakerUID: speakerUID)
@@ -356,9 +380,25 @@ public final class SessionAudio {
startPlayback(speakerUID: speakerUID)
return
}
#if os(macOS)
// A rebuild must not re-attempt a voice-processing start that already failed on this
// input device: the failure repeats, and the failed attempt's HAL churn stops the healthy
// fallback engines the 2026-08-14 rebuild loop (see `CombinedTopologyGate`).
var combined = wantsCombined(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
echoCancel: echoCancel)
if combined, !combinedGate.shouldTry(input: AudioDevices.defaultInputDevice()) {
log.info("""
voice processing already failed on this input device split engines, no echo \
cancellation
""")
combined = false
}
#else
let combined = wantsCombined(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
echoCancel: echoCancel)
#endif
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
if combined {
@@ -374,7 +414,8 @@ public final class SessionAudio {
// drain thread carry over see `makePlaybackChain`).
startPlayback(speakerUID: speakerUID)
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
DispatchQueue.main.async {
guard let self else { return }
self.engineQueue.async { [weak self] in
guard let self, granted, !self.flag.isStopped else { return }
if combined {
self.stateLock.lock()
@@ -513,6 +554,17 @@ public final class SessionAudio {
/// - the route-change and media-services-reset notifications, iOS/tvOS, where the session and
/// not the device is what moves.
///
/// And three defenses keep the recovery from ANSWERING ITSELF a rebuild is not a silent
/// act (a voice-processing start builds and tears down HAL aggregates, and every fresh engine
/// renegotiates its IO), so its own fallout can retrigger it. The 2026-08-14 field loop was
/// exactly that: VPIO failed on a 6-channel mic, every rebuild re-tried it, and the failure's
/// churn stopped the fallback engines audio and (via the main thread) INPUT cutting out
/// every ~2.5 s for the whole session. The defenses: a configuration change from an engine
/// that is RUNNING is a rebuild's echo and is ignored (`hardwareMoved`); a VPIO failure is
/// latched per input device and never re-attempted on it (`CombinedTopologyGate`); and
/// rebuilds that chain anyway back off exponentially instead of metronoming
/// (`RebuildBackoff`).
///
/// `micEnabled` only decides whether the mic-bearing session observers are worth installing.
/// Main thread.
private func installDeviceChangeRecovery(micEnabled: Bool) {
@@ -523,7 +575,7 @@ public final class SessionAudio {
let watcher = AudioDeviceWatcher(
isOurs: { [weak self] posted in self?.ownsEngine(posted) ?? false },
onChange: { [weak self] reason in self?.hardwareMoved(reason) })
onChange: { [weak self] reason, posted in self?.hardwareMoved(reason, posted: posted) })
stateLock.lock()
deviceWatcher = watcher
stateLock.unlock()
@@ -549,10 +601,17 @@ public final class SessionAudio {
/// question is playback still where it should be but they answer it differently: an engine
/// that told us it stopped is definitive, while the default device moving might not concern us
/// at all.
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason) {
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason, posted: AnyObject?) {
guard !flag.isStopped else { return }
switch reason {
case .engineConfiguration:
// The engine stops itself BEFORE posting this so an engine that is RUNNING when the
// notification lands on the main queue is one a rebuild already replaced or restarted:
// the notification is the rebuild's own echo, and answering it is how the recovery
// loops. A change that stops the engine again after this posts again, and the HAL
// backstop checks placement independently, so ignoring a live engine's echo can never
// strand a stopped one.
if let engine = posted as? AVAudioEngine, engine.isRunning { return }
scheduleEngineRebuild(reason: reason.rawValue)
case .defaultOutputDevice:
#if os(macOS)
@@ -572,7 +631,10 @@ public final class SessionAudio {
/// output device at the moment it connected and leaving it silent for good. On iOS the same
/// flag keeps this from racing the asynchronous start, where no engine yet is normal.
private func reviveStoppedEngines(_ reason: String) {
guard !flag.isStopped, enginesAttempted, !playbackIsLive else { return }
stateLock.lock()
let attempted = enginesAttempted
stateLock.unlock()
guard !flag.isStopped, attempted, !playbackIsLive else { return }
scheduleEngineRebuild(reason: "playback is stopped and \(reason)")
}
@@ -594,15 +656,43 @@ public final class SessionAudio {
private func scheduleEngineRebuild(reason: String) {
guard !rebuildQueued else { return }
rebuildQueued = true
let since = ProcessInfo.processInfo.systemUptime - lastRebuildAt
let delay = max(Self.rebuildDebounce, Self.rebuildFloor - since)
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
let delay = rebuildBackoff.delay(now: ProcessInfo.processInfo.systemUptime)
if rebuildBackoff.chain >= 2 {
// Each rebuild is retriggering the next a feedback shape the echo guard and the
// topology gate did not identify. Keep answering (a real recovery must not be
// abandoned), but say what is happening: this line repeating IS the diagnosis.
log.warning("""
audio engine rebuilds are chaining (\(self.rebuildBackoff.chain) in a row \
\(reason)); backing off \(Int(delay * 1000)) ms
""")
} else {
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.rebuildEngines(attempt: 0)
self?.rebuildFire(attempt: 0)
}
}
/// The scheduled rebuild came due (main queue): close out the bookkeeping and hand the
/// actual engine work to `engineQueue` the teardown + start can block on the audio server
/// for seconds, and the main thread is where macOS captures and sends the stream's input.
/// A trigger arriving while the work is in flight schedules a fresh rebuild rather than
/// being swallowed; `engineQueue` is serial, so the two never interleave.
private func rebuildFire(attempt: Int) {
rebuildQueued = false
guard !flag.isStopped else { return }
stateLock.lock()
let config = startConfig
stateLock.unlock()
guard let config else { return }
rebuildBackoff.noteRebuild(at: ProcessInfo.processInfo.systemUptime)
engineQueue.async { [weak self] in
self?.performRebuild(config: config, attempt: attempt)
}
}
/// Put back the topology this session was started with, on whatever hardware is there now.
/// Runs on `engineQueue`.
///
/// A full rebuild rather than a `start()` on the stopped engine, because the mic side has to
/// follow too: `installMicTap` reads the input's live format, and the voice processor
@@ -610,10 +700,8 @@ public final class SessionAudio {
/// across (`makePlaybackChain` reuses it, `startDrain` is idempotent), so the drain thread
/// keeps decoding right through the switch and its overflow policy has already dropped
/// everything that went stale while the engine was down.
private func rebuildEngines(attempt: Int) {
rebuildQueued = false
guard !flag.isStopped, let config = startConfig else { return }
lastRebuildAt = ProcessInfo.processInfo.systemUptime
private func performRebuild(config: StartConfig, attempt: Int) {
guard !flag.isStopped else { return }
tearDownEngines()
startEngines(
speakerUID: config.speakerUID, micUID: config.micUID, micChannel: config.micChannel,
@@ -626,6 +714,18 @@ public final class SessionAudio {
log.info("audio engines restarted on the current device")
return
}
DispatchQueue.main.async { [weak self] in
self?.rebuildFailed(attempt: attempt)
}
}
/// A rebuild's playback did not come back (main queue) walk the retry ladder. Retries
/// when a rebuild's `start()` loses the race with a device that is still going away
/// (0.3 s, 0.6 s, 1.2 s): a failed rebuild leaves no engine to post the next notification,
/// so this ladder and, on macOS, the HAL listener is all that stands between a mistimed
/// switch and a silent session.
private func rebuildFailed(attempt: Int) {
guard !flag.isStopped else { return }
guard attempt < Self.rebuildAttempts else {
#if os(macOS)
log.error("""
@@ -637,10 +737,11 @@ public final class SessionAudio {
#endif
return
}
guard !rebuildQueued else { return } // a fresh trigger already queued a full rebuild
rebuildQueued = true // holds off a trigger that would only race this ladder
let delay = Self.rebuildDebounce * Double(1 << (attempt + 1))
let delay = RebuildBackoff.debounce * Double(1 << (attempt + 1))
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.rebuildEngines(attempt: attempt + 1)
self?.rebuildFire(attempt: attempt + 1)
}
}
@@ -786,9 +887,13 @@ public final class SessionAudio {
public let avOffsetMS: Int
}
/// A snapshot of `Stats`, or nil before playback starts. Main thread (`ring` is main-confined;
/// the ring's own numbers are taken under its lock, so they describe one instant).
/// A snapshot of `Stats`, or nil before playback starts. Safe from any thread (the handle is
/// taken under `stateLock`; the ring's own numbers are taken under its lock, so they
/// describe one instant).
public var stats: Stats? {
stateLock.lock()
let ring = self.ring
stateLock.unlock()
guard let s = ring?.stats else { return nil }
return Stats(bufferMS: s.bufferedMS, avOffsetMS: s.avOffsetMS)
}
@@ -813,7 +918,7 @@ public final class SessionAudio {
/// The playback jitter ring + the source node draining it shared by the plain playback
/// engine and the combined voice-processing engine, and REUSED across an engine rebuild
/// (same session, same ring: the drain thread keeps writing right through the swap). nil
/// when the host's channel layout can't be expressed (already logged). Main thread.
/// when the host's channel layout can't be expressed (already logged). Runs on `engineQueue`.
private func makePlaybackChain()
-> (ring: AudioRing, source: AVAudioSourceNode, format: AVAudioFormat)?
{
@@ -823,8 +928,10 @@ public final class SessionAudio {
// 1 s interleaved capacity, scaled by the channel count. The de-jitter depth itself is
// the ring's own business now (`AudioRing.targetMS`, mirroring `JitterTuning::COREAUDIO`)
// rather than a prefill passed in here.
stateLock.lock()
let ring = self.ring ?? AudioRing(capacity: 48_000 * channels, channels: channels)
self.ring = ring
stateLock.unlock()
// Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround
// uses an explicit wire-order channel layout; the mixer downmixes to the output device when
@@ -983,6 +1090,17 @@ public final class SessionAudio {
// MARK: - Mic (mic host)
#if !os(tvOS)
/// The combined topology failed to come up. On macOS, latch the input device it failed on so
/// a rebuild goes straight to the split topology instead of re-running the failure the
/// failed attempt is what churns the HAL and retriggers the recovery (see
/// `CombinedTopologyGate`). On iOS routes are session-managed and a VPIO failure is the
/// transient route-transition kind, so nothing is latched there.
private func noteCombinedFailure() {
#if os(macOS)
combinedGate.noteFailure(input: AudioDevices.defaultInputDevice())
#endif
}
/// One engine, both directions: engage the system voice processor on the shared IO unit
/// (AEC + noise suppression + AGC), hang the playback source off its render side and the
/// mic tap off its capture side. Every failure falls back to a WORKING configuration
@@ -1001,6 +1119,7 @@ public final class SessionAudio {
voice processing unavailable (\(error.localizedDescription)) separate \
engines, no echo cancellation
""")
noteCombinedFailure()
startPlayback(speakerUID: speakerUID)
startCapture(micUID: micUID, micChannel: micChannel)
return
@@ -1054,6 +1173,7 @@ public final class SessionAudio {
// processor won't engage at all, already does exactly this; this arm used to give up
// on the mic instead, which is how a whole session could go silent uplink-only.)
engine.stop()
noteCombinedFailure()
startPlayback(speakerUID: speakerUID)
startCapture(micUID: micUID, micChannel: micChannel)
return
@@ -1064,6 +1184,7 @@ public final class SessionAudio {
log.error("combined engine failed to start: \(error.localizedDescription)")
engine.inputNode.removeTap(onBus: 0)
engine.stop()
noteCombinedFailure()
// Same rule: a working mic without echo cancellation beats no mic at all.
startPlayback(speakerUID: speakerUID)
startCapture(micUID: micUID, micChannel: micChannel)
@@ -32,7 +32,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
let engine = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
isOurs: { $0 === engine }, onChange: { reason, _ in reasons.append(reason) })
watcher.start()
defer { watcher.stop() }
@@ -51,7 +51,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
let stranger = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === ours }, onChange: { reasons.append($0) })
isOurs: { $0 === ours }, onChange: { reason, _ in reasons.append(reason) })
watcher.start()
defer { watcher.stop() }
@@ -66,7 +66,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
let engine = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
isOurs: { $0 === engine }, onChange: { reason, _ in reasons.append(reason) })
watcher.start()
watcher.stop()
@@ -93,7 +93,7 @@ final class AudioDeviceWatcherTests: XCTestCase {
}
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(isOurs: { _ in false }, onChange: { reasons.append($0) })
let watcher = AudioDeviceWatcher(isOurs: { _ in false }, onChange: { reason, _ in reasons.append(reason) })
watcher.start()
defer {
_ = Self.setDefaultOutput(original)
@@ -0,0 +1,110 @@
// The two decisions that ended the 2026-08-14 rebuild loop, driven with a synthetic clock.
//
// The loop's shape, for the plant-the-defect cases below: the voice-processing engine fails to
// start (~1.9 s spent trying), the fallback comes up, and its own HAL fallout retriggers the
// recovery ~0.6 s later forever. Restore either defect (retry the failed topology, or keep the
// flat 0.5 s floor) and the session pays an audio gap every ~2.5 s for as long as it lives.
import XCTest
@testable import PunktfunkKit
final class AudioRebuildPolicyTests: XCTestCase {
// MARK: - RebuildBackoff
/// The first trigger of a session keeps the old behaviour: the burst-coalescing debounce.
func testFirstTriggerWaitsOnlyTheDebounce() {
var backoff = RebuildBackoff()
XCTAssertEqual(backoff.delay(now: 1000), RebuildBackoff.debounce)
}
/// One rebuild, then quiet: the next real device switch minutes later is answered at full
/// responsiveness the ladder must never make a HEALTHY recovery sluggish.
func testAnIsolatedSwitchLongAfterTheLastRebuildResetsTheChain() {
var backoff = RebuildBackoff()
_ = backoff.delay(now: 1000)
backoff.noteRebuild(at: 1000.2)
// Chained once (a second switch soon after legitimate, e.g. AirPods out then back in).
_ = backoff.delay(now: 1001)
backoff.noteRebuild(at: 1002)
// Minutes of quiet, then a fresh switch: base debounce again, chain forgotten.
XCTAssertEqual(backoff.delay(now: 1300), RebuildBackoff.debounce)
XCTAssertEqual(backoff.chain, 0)
}
/// THE FIELD LOOP, against the real constants: a trigger 0.6 s after every rebuild, ten
/// minutes long. The flat 0.5 s floor produced a rebuild every ~2.5 s ~240 audio gaps.
/// The ladder must cut that by an order of magnitude and settle at the floor cap.
func testAChainedLoopBacksOffToTheFloorCap() {
var backoff = RebuildBackoff()
var now: TimeInterval = 0
var rebuilds = 0
var lastDelay: TimeInterval = 0
let end: TimeInterval = 600
while now < end {
lastDelay = backoff.delay(now: now)
now += lastDelay // the scheduled rebuild fires...
backoff.noteRebuild(at: now)
rebuilds += 1
now += 0.6 // ...and its fallout retriggers the recovery 0.6 s later.
}
XCTAssertEqual(
lastDelay, RebuildBackoff.floorCap - 0.6, accuracy: 0.01,
"a persistent loop should settle at one rebuild per floorCap")
XCTAssertLessThanOrEqual(
rebuilds, 30,
"\(rebuilds) rebuilds in 10 min — the ladder is not escalating (the shipped flat "
+ "floor produced ~240)")
// And the loop's END must restore responsiveness: quiet, then a real switch.
XCTAssertEqual(backoff.delay(now: now + 120), RebuildBackoff.debounce)
}
/// The ladder's exponent is clamped a loop that runs for hours must neither overflow nor
/// push the interval past the cap.
func testTheFloorNeverExceedsTheCap() {
var backoff = RebuildBackoff()
var now: TimeInterval = 0
for _ in 0..<1000 {
let delay = backoff.delay(now: now)
XCTAssertLessThanOrEqual(delay, RebuildBackoff.floorCap)
now += delay
backoff.noteRebuild(at: now)
now += 0.1
}
}
#if os(macOS)
// MARK: - CombinedTopologyGate
/// The loop's fuel: re-attempting the voice-processing start that just failed. Same input
/// device never again.
func testAFailureLatchesForTheDeviceItFailedOn() {
var gate = CombinedTopologyGate()
XCTAssertTrue(gate.shouldTry(input: 42), "an unfailed gate must allow the attempt")
gate.noteFailure(input: 42)
XCTAssertFalse(gate.shouldTry(input: 42))
XCTAssertFalse(gate.shouldTry(input: 42), "the latch must hold across rebuilds")
}
/// The failure is a property of the DEVICE: a different default input earns a fresh attempt,
/// and its own failure latches again one attempt per device change can never loop.
func testADifferentInputDeviceEarnsOneFreshAttempt() {
var gate = CombinedTopologyGate()
gate.noteFailure(input: 42)
XCTAssertTrue(gate.shouldTry(input: 7))
gate.noteFailure(input: 7)
XCTAssertFalse(gate.shouldTry(input: 7))
// Back to the first device: the earlier failure may have been mid-transition one fresh
// attempt again, not a permanent ban.
XCTAssertTrue(gate.shouldTry(input: 42))
}
/// "No resolvable input device" is a real failure key too, distinct from "never failed".
func testFailingWithNoInputDeviceLatchesForNoInputDevice() {
var gate = CombinedTopologyGate()
gate.noteFailure(input: nil)
XCTAssertFalse(gate.shouldTry(input: nil))
XCTAssertTrue(gate.shouldTry(input: 42), "a device appearing is a device change")
}
#endif
}
+15
View File
@@ -214,6 +214,15 @@ pub struct HostConfig {
/// showing the wrong monitor is worse than showing none). Linux-only today; see
/// `design/per-monitor-portal-capture.md`.
pub capture_monitor: Option<String>,
/// `PUNKTFUNK_PORTAL_CURSOR_MODE` — `auto` (default) · `hidden` · `embedded` · `metadata`.
/// Pin the ScreenCast cursor mode the Linux portal backends PREFER, instead of the one the
/// session negotiates (`metadata` when the client draws the pointer itself, `embedded`
/// otherwise). The pin is a preference, not a command: it still runs through
/// `portal_cursor::pick`, so it can never ask a backend for a mode the backend does not
/// advertise — that closes the session rather than degrading, which is the failure this knob
/// sits next to. Exists for the backend that advertises a mode it implements badly, where
/// negotiation has nothing to go on; `embedded` is the safe answer there.
pub portal_cursor_mode: Option<String>,
/// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected
/// session — this one is a constant operator knob; `apply_session_env` never writes it.
pub compositor: Option<String>,
@@ -401,6 +410,12 @@ impl HostConfig {
capture_monitor: val("PUNKTFUNK_CAPTURE_MONITOR")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
// Same emptied-to-None rule: a bare `PUNKTFUNK_PORTAL_CURSOR_MODE=` left in a host.env
// means "not set", not an unrecognised value to warn about. The spellings are parsed
// (and warned about) at the use site, `pf-vdisplay`'s `portal_cursor::want`.
portal_cursor_mode: val("PUNKTFUNK_PORTAL_CURSOR_MODE")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
compositor: val("PUNKTFUNK_COMPOSITOR"),
gamepad: val("PUNKTFUNK_GAMEPAD"),
vdisplay: val("PUNKTFUNK_VDISPLAY"),
+9
View File
@@ -824,6 +824,15 @@ pub mod admission;
#[path = "vdisplay/linux/portal_config.rs"]
mod portal_config;
/// Which ScreenCast cursor mode to REQUEST — negotiated against `AvailableCursorModes` instead of
/// hardcoded, because a mode the backend does not advertise closes the session outright.
///
/// Declared unconditionally for the same reason as `portal_config` above: the ladder is pure
/// integer work whose tests are the only place its behaviour is observable without a compositor,
/// so they should run on every platform's CI rather than only where the callers compile.
#[path = "vdisplay/linux/portal_cursor.rs"]
mod portal_cursor;
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/hyprland.rs"]
mod hyprland;
@@ -115,12 +115,21 @@ fn output_owner_pid(name: &str) -> Option<u32> {
/// The Hyprland virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) adds one
/// named headless output and spins up a portal thread owning the cast on it.
pub struct HyprlandDisplay {
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): portal
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): PREFER portal
/// `CursorMode::Metadata` — shapes/positions ride `SPA_META_Cursor` for the channel + the
/// composite blend. Off (every non-channel session): `Embedded` — the compositor paints the
/// pointer into frames, zero host-side cursor work (the pre-channel default this backend
/// always had). ⚠️ Metadata is UNTESTED on-glass for this backend (Phase B wired it so the
/// channel isn't silently dead here; KWin/Mutter are the validated legs).
/// composite blend. Off (every non-channel session): prefer `Embedded` — the compositor paints
/// the pointer into frames, zero host-side cursor work (the pre-channel default this backend
/// always had).
///
/// Both are only a PREFERENCE: [`crate::portal_cursor`] settles it against what xdph actually
/// advertises, because requesting an unadvertised mode makes xdg-desktop-portal fail the call.
/// This used to be asserted instead, which is exactly how a cursor-forward session here became
/// a black client.
///
/// ⚠️ On current xdph the metadata arm is UNREACHABLE, not merely untested: measured on .21
/// 2026-08-14 (Hyprland 0.56.2, xdph 1.4.1) `AvailableCursorModes` = 3 — `Hidden|Embedded`
/// only. Every session on this backend therefore resolves to `Embedded` today; KWin/Mutter
/// remain the legs where the metadata channel is actually exercised.
hw_cursor: bool,
}
@@ -788,13 +797,7 @@ fn portal_thread(
stop: Arc<AtomicBool>,
hw_cursor: bool,
) {
// Portal cursor mode per the session's channel negotiation (see the struct doc).
let cursor_mode = if hw_cursor {
CursorMode::Metadata
} else {
CursorMode::Embedded
};
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::PersistMode;
use ashpd::enumflags2::BitFlags;
@@ -818,6 +821,14 @@ fn portal_thread(
let proxy = Screencast::new().await.context(
"connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)",
)?;
// NEGOTIATED against what xdph advertises, never asserted from `hw_cursor` alone: a
// cursor mode the backend does not offer does not degrade — xdg-desktop-portal's
// FRONTEND fails the call ("Unavailable cursor mode %x") before xdph sees it.
// MEASURED on .21 2026-08-14, Hyprland 0.56.2 + xdph 1.4.1 (both current):
// `AvailableCursorModes` = 3 (Hidden|Embedded) — metadata is NOT offered. So the old
// hardcode killed EVERY cursor-forward session here, on today's packages, not just on
// old installs: `unavailable cursor mode 4`, "pipeline build failed", black client.
let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdph").await;
let session = proxy
.create_session(Default::default())
.await
@@ -0,0 +1,376 @@
//! Which ScreenCast cursor mode to ASK the portal for — negotiated against what the backend
//! advertises, rather than asserted.
//!
//! The portal spec is unforgiving here: `SelectSources` with a cursor mode that is absent from
//! `AvailableCursorModes` does not quietly degrade — **xdg-desktop-portal itself rejects the call**
//! (`"Unavailable cursor mode %x"`, an `INVALID_ARGUMENT` from the FRONTEND, which validates the
//! request against the backend's advertised bitfield before the backend ever sees it). Both
//! wlr-family backends used to hardcode `Metadata` whenever the session had negotiated the cursor
//! channel, so every cursor-forward session died at `select_sources` — `unavailable cursor mode 4`
//! (4 being `Metadata`'s bit) and a client left on a black screen behind "pipeline build failed".
//! Field report 2026-08-14.
//!
//! ⚠️ This is NOT a stale-portal problem, and not Hyprland-specific. MEASURED on .21 2026-08-14 on
//! fully current packages — Hyprland **0.56.2**, xdg-desktop-portal-hyprland **1.4.1**,
//! xdg-desktop-portal **1.22.1** — with a live session and xdph attached (`[screencopy] init
//! successful`): `AvailableCursorModes` reads **3** (`Hidden|Embedded`) on both the backend impl
//! interface and the frontend. **Metadata is simply not offered by xdph today.** xdpw is the same
//! story from the other end: its `screencast.c` refuses `METADATA` outright. So the hardcode broke
//! every cursor-forward session on the entire wlr family, on current software — not only on old
//! installs. (xdph 1.4.1 would itself fall back — its binary carries
//! `"[screencopy] unsupported cursor_mode {}, fallback to {}"` — but it never gets the chance,
//! because the frontend fails the call first.)
//!
//! `pf-capture`'s own portal path has always negotiated (`portal::choose_cursor_mode`) — this is
//! that ladder, restated in the crate that owns the virtual-display backends. pf-vdisplay must not
//! depend on pf-capture (see this crate's Cargo.toml: "never on capture/inject or the
//! orchestrator"), so the two copies are deliberate; keep the ladders in step.
//!
//! Declared unconditionally although only the Linux backends call it: the ladder is pure integer
//! work, and its tests are the whole point of the module — this is a decision that leaves no trace
//! anyone can check without a compositor in front of them — so they run on every platform's CI
//! rather than on the one leg that compiles `mod hyprland`.
/// A ScreenCast cursor mode, valued as the portal's own wire bits — which is what a backend prints
/// when it rejects one, so `Metadata`'s `4` is literally the number in the field report.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Mode {
/// No pointer in the cast at all.
Hidden = 1,
/// The compositor paints the pointer into the frames it hands us.
Embedded = 2,
/// The pointer rides `SPA_META_Cursor` metadata beside the frames: the compositor keeps its
/// cheap hardware cursor plane, and the consumer either composites the shape itself or
/// forwards it to a client that draws its own.
Metadata = 4,
}
impl Mode {
/// The portal's bit for this mode.
pub(crate) const fn bit(self) -> u32 {
self as u32
}
/// The spelling used in logs and in `PUNKTFUNK_PORTAL_CURSOR_MODE`.
pub(crate) const fn name(self) -> &'static str {
match self {
Mode::Hidden => "hidden",
Mode::Embedded => "embedded",
Mode::Metadata => "metadata",
}
}
/// What to ask for instead, best first, when this mode is not advertised.
const fn fallbacks(self) -> [Mode; 2] {
match self {
// The session wanted out-of-band shapes and cannot have them. `Embedded` still puts a
// pointer on the client's screen (the compositor's, burnt in) — and because no
// `SPA_META_Cursor` then arrives, the host feeds the cursor channel nothing and a
// cursor-forward client draws nothing of its own, so this is one pointer, not two.
// `Hidden` is last: it streams a desktop nobody can point at.
Mode::Metadata => [Mode::Embedded, Mode::Hidden],
// Embedded wanted but not offered. Metadata still beats Hidden: the CPU capture path
// composites `SPA_META_Cursor` inline, so part of the matrix keeps a pointer.
Mode::Embedded => [Mode::Metadata, Mode::Hidden],
// A deliberate request for no pointer that the backend will not honour. Either
// remaining mode shows one; prefer the cheap burnt-in pointer over metadata nothing on
// this path is set up to draw.
Mode::Hidden => [Mode::Embedded, Mode::Metadata],
}
}
}
/// The outcome of the ladder: what to request, and what the session actually wanted if those
/// differ (the caller logs the gap — a silently downgraded cursor is how this class of bug hides).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Choice {
/// The mode to put in `SelectSources`. Advertised, unless the backend advertised nothing.
pub(crate) mode: Mode,
/// Set only when `mode` is a downgrade: the mode the session asked for and could not have.
pub(crate) wanted: Option<Mode>,
}
/// Pick the cursor mode to request, given the backend's `AvailableCursorModes` bitfield.
///
/// Never returns a mode outside `advertised` unless `advertised` names none we know — see the tail
/// comment, which is the one case with no right answer.
pub(crate) fn pick(advertised: u32, want: Mode) -> Choice {
if advertised & want.bit() != 0 {
return Choice {
mode: want,
wanted: None,
};
}
for alt in want.fallbacks() {
if advertised & alt.bit() != 0 {
return Choice {
mode: alt,
wanted: Some(want),
};
}
}
// The backend advertised no mode this build knows — 0, or only bits from a spec revision newer
// than us. Every request is then a coin flip against a session-closing rejection; `Hidden` is
// both the most universally implemented and the only one that cannot end up drawing two
// pointers. The caller warns: whatever this backend is doing, we are guessing.
Choice {
mode: Mode::Hidden,
wanted: Some(want),
}
}
/// A parsed `PUNKTFUNK_PORTAL_CURSOR_MODE`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Pin {
/// Unset or `auto` — the session's own negotiation decides.
Auto,
/// Prefer this mode instead of what the session negotiated. Still runs the ladder, so a pin
/// can never re-create the session-killing request this module exists to prevent.
Mode(Mode),
/// Set to something we do not recognise. Treated as `Auto`, but the caller says so out loud —
/// a typo'd escape hatch that silently does nothing is worse than no escape hatch.
Unrecognised,
}
/// Parse the `PUNKTFUNK_PORTAL_CURSOR_MODE` value.
pub(crate) fn parse_pin(raw: &str) -> Pin {
match raw.trim().to_ascii_lowercase().as_str() {
"" | "auto" => Pin::Auto,
"hidden" | "none" => Pin::Mode(Mode::Hidden),
"embedded" | "composited" => Pin::Mode(Mode::Embedded),
"metadata" | "meta" => Pin::Mode(Mode::Metadata),
_ => Pin::Unrecognised,
}
}
/// The mode this session wants before the backend gets a say: `Metadata` when the cursor channel
/// was negotiated (`set_hw_cursor` — the client draws the pointer, so the compositor must not burn
/// it in), `Embedded` otherwise. `PUNKTFUNK_PORTAL_CURSOR_MODE` overrides both.
///
/// `backend` names the portal implementation for the log line only (`xdph`, `xdpw`).
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn want(hw_cursor: bool, backend: &str) -> Mode {
let negotiated = if hw_cursor {
Mode::Metadata
} else {
Mode::Embedded
};
let raw = match pf_host_config::config().portal_cursor_mode.as_deref() {
Some(raw) => raw,
None => return negotiated,
};
match parse_pin(raw) {
Pin::Auto => negotiated,
Pin::Mode(pinned) => {
tracing::info!(
backend,
pinned = pinned.name(),
negotiated = negotiated.name(),
"ScreenCast: cursor mode pinned by PUNKTFUNK_PORTAL_CURSOR_MODE"
);
pinned
}
Pin::Unrecognised => {
tracing::warn!(
backend,
value = raw,
negotiated = negotiated.name(),
"ScreenCast: unrecognised PUNKTFUNK_PORTAL_CURSOR_MODE (want auto|hidden|embedded|\
metadata) ignoring"
);
negotiated
}
}
}
#[cfg(target_os = "linux")]
impl Mode {
fn to_ashpd(self) -> ashpd::desktop::screencast::CursorMode {
use ashpd::desktop::screencast::CursorMode;
match self {
Mode::Hidden => CursorMode::Hidden,
Mode::Embedded => CursorMode::Embedded,
Mode::Metadata => CursorMode::Metadata,
}
}
}
/// Ask the portal what it supports, run the ladder, and hand back the mode to put in
/// `SelectSources`. Infallible by construction: a backend we cannot interrogate gets `Embedded`,
/// the mode that predates the property and that every implementation has always had.
#[cfg(target_os = "linux")]
pub(crate) async fn negotiate(
proxy: &ashpd::desktop::screencast::Screencast,
hw_cursor: bool,
backend: &str,
) -> ashpd::desktop::screencast::CursorMode {
let want = want(hw_cursor, backend);
let advertised = match proxy.available_cursor_modes().await {
Ok(avail) => avail.bits(),
Err(e) => {
// `AvailableCursorModes` is a versioned property (ScreenCast v2); a portal too old to
// publish it is also too old to have metadata, and `Embedded` is what this backend
// requested for its whole life before the cursor channel existed.
tracing::warn!(
backend,
error = %e,
"ScreenCast: AvailableCursorModes query failed — requesting Embedded cursor"
);
return Mode::Embedded.to_ashpd();
}
};
let choice = pick(advertised, want);
match choice.wanted {
None => tracing::info!(
backend,
advertised = format_args!("{advertised:#05b}"),
mode = choice.mode.name(),
"ScreenCast: cursor mode negotiated"
),
// The downgrade path — and the one that used to be a dead session. Loud, because a stream
// whose pointer quietly changed hands is exactly what nobody thinks to check.
Some(wanted) => tracing::warn!(
backend,
advertised = format_args!("{advertised:#05b}"),
wanted = wanted.name(),
mode = choice.mode.name(),
"ScreenCast: requested cursor mode is not advertised by this portal — downgrading \
(requesting it anyway would close the session)"
),
}
choice.mode.to_ashpd()
}
#[cfg(test)]
mod tests {
use super::*;
/// The portal's wire values. These are ABI — a backend rejecting our request prints the
/// number, and `4` is the one in the field report that started this module.
#[test]
fn mode_bits_are_the_portal_wire_values() {
assert_eq!(Mode::Hidden.bit(), 1);
assert_eq!(Mode::Embedded.bit(), 2);
assert_eq!(Mode::Metadata.bit(), 4);
}
/// Our `Mode` is a restatement of ashpd's `CursorMode`, whose bits enumflags2 assigns from
/// declaration order — so a reordering upstream would silently repoint every mode. Pin it
/// where ashpd is actually compiled.
#[cfg(target_os = "linux")]
#[test]
fn mode_bits_match_ashpd() {
use ashpd::desktop::screencast::CursorMode;
use ashpd::enumflags2::BitFlags;
for m in [Mode::Hidden, Mode::Embedded, Mode::Metadata] {
assert_eq!(
BitFlags::from_flag(m.to_ashpd()).bits(),
m.bit(),
"{} drifted from ashpd",
m.name()
);
}
assert_eq!(BitFlags::from_flag(CursorMode::Metadata).bits(), 4);
}
/// THE REGRESSION, with the real number: `3` is what xdph actually advertises — measured on
/// .21 2026-08-14 against a live Hyprland 0.56.2 + xdph 1.4.1, both current. A cursor-forward
/// session wants metadata; asking for it made xdg-desktop-portal fail the call, and the client
/// got a black screen behind "pipeline build failed" / "unavailable cursor mode 4".
#[test]
fn metadata_wanted_but_unadvertised_downgrades_to_embedded() {
// Exactly the bitfield the portal reported on glass.
assert_eq!(Mode::Hidden.bit() | Mode::Embedded.bit(), 3);
let c = pick(3, Mode::Metadata);
assert_eq!(c.mode, Mode::Embedded);
assert_eq!(c.wanted, Some(Mode::Metadata));
}
/// The same portal, a session with no cursor channel: already asking for what exists, so the
/// fix must not perturb it.
#[test]
fn embedded_wanted_and_advertised_is_untouched() {
let c = pick(Mode::Hidden.bit() | Mode::Embedded.bit(), Mode::Embedded);
assert_eq!(c.mode, Mode::Embedded);
assert_eq!(c.wanted, None);
}
/// A portal that does support metadata (KWin, Mutter, xdph ≥ #366) still gets it — the point
/// is to stop asserting, not to stop using it.
#[test]
fn metadata_is_used_where_advertised() {
let all = Mode::Hidden.bit() | Mode::Embedded.bit() | Mode::Metadata.bit();
let c = pick(all, Mode::Metadata);
assert_eq!(c.mode, Mode::Metadata);
assert_eq!(c.wanted, None);
}
/// Embedded wanted, only metadata offered: the CPU capture path composites it, so a pointer
/// survives. (Mirrors `pf-capture`'s ladder.)
#[test]
fn embedded_unadvertised_falls_to_metadata_not_hidden() {
let c = pick(Mode::Hidden.bit() | Mode::Metadata.bit(), Mode::Embedded);
assert_eq!(c.mode, Mode::Metadata);
assert_eq!(c.wanted, Some(Mode::Embedded));
}
/// A backend offering only `Hidden`: a cursorless stream beats a closed session.
#[test]
fn hidden_only_backend_yields_hidden() {
let c = pick(Mode::Hidden.bit(), Mode::Metadata);
assert_eq!(c.mode, Mode::Hidden);
assert_eq!(c.wanted, Some(Mode::Metadata));
}
/// Advertises nothing we know — no right answer, but it must still be a legal enum and flagged
/// as a downgrade so the warn fires.
#[test]
fn unknown_advertisement_guesses_hidden_and_reports_a_downgrade() {
for advertised in [0, 0b1000_0000] {
let c = pick(advertised, Mode::Metadata);
assert_eq!(c.mode, Mode::Hidden);
assert_eq!(c.wanted, Some(Mode::Metadata));
}
}
/// Whatever the ladder returns must be a mode the backend named — the invariant the old
/// hardcode broke. Exhaustive over every advertisement × every want.
#[test]
fn never_requests_an_unadvertised_mode() {
let modes = [Mode::Hidden, Mode::Embedded, Mode::Metadata];
for advertised in 1u32..=0b111 {
for want in modes {
let c = pick(advertised, want);
assert!(
advertised & c.mode.bit() != 0,
"picked {} from advertised {advertised:#05b} (want {})",
c.mode.name(),
want.name()
);
// A downgrade is reported exactly when one happened.
assert_eq!(c.wanted.is_some(), c.mode != want);
}
}
}
#[test]
fn pin_parses_the_spellings_we_document() {
assert_eq!(parse_pin(""), Pin::Auto);
assert_eq!(parse_pin("auto"), Pin::Auto);
assert_eq!(parse_pin(" AUTO "), Pin::Auto);
assert_eq!(parse_pin("embedded"), Pin::Mode(Mode::Embedded));
assert_eq!(parse_pin("Embedded"), Pin::Mode(Mode::Embedded));
assert_eq!(parse_pin("metadata"), Pin::Mode(Mode::Metadata));
assert_eq!(parse_pin("hidden"), Pin::Mode(Mode::Hidden));
assert_eq!(parse_pin("2"), Pin::Unrecognised);
assert_eq!(parse_pin("yes"), Pin::Unrecognised);
}
/// The hatch pins a PREFERENCE, not the request: pinning metadata at a portal without it must
/// still come out embedded rather than re-closing the session.
#[test]
fn a_pin_still_runs_the_ladder() {
let c = pick(Mode::Hidden.bit() | Mode::Embedded.bit(), Mode::Metadata);
assert_eq!(c.mode, Mode::Embedded);
}
}
@@ -55,12 +55,17 @@ fn chooser_cmd() -> String {
/// The wlroots/Sway virtual-display driver. Stateless — each [`create`](VirtualDisplay::create)
/// adds one headless output and spins up a portal thread owning the cast on it.
pub struct WlrootsDisplay {
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): portal
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): PREFER portal
/// `CursorMode::Metadata` — shapes/positions ride `SPA_META_Cursor` for the channel + the
/// composite blend. Off (every non-channel session): `Embedded` — the compositor paints the
/// pointer into frames, zero host-side cursor work (the pre-channel default this backend
/// always had). ⚠️ Metadata is UNTESTED on-glass for this backend (Phase B wired it so the
/// channel isn't silently dead here; KWin/Mutter are the validated legs).
/// composite blend. Off (every non-channel session): prefer `Embedded` — the compositor paints
/// the pointer into frames, zero host-side cursor work (the pre-channel default this backend
/// always had).
///
/// Both are only a PREFERENCE: [`crate::portal_cursor`] settles it against what xdpw actually
/// advertises, because requesting an unadvertised mode closes the session outright. xdpw
/// refuses metadata by construction (see the portal thread), so on this backend the channel can
/// never be served out-of-band: it now degrades to `Embedded` and streams, where it used to
/// cancel the cast and hand the client a black screen.
hw_cursor: bool,
}
@@ -512,13 +517,7 @@ fn portal_thread(
stop: Arc<AtomicBool>,
hw_cursor: bool,
) {
// Portal cursor mode per the session's channel negotiation (see the struct doc).
let cursor_mode = if hw_cursor {
CursorMode::Metadata
} else {
CursorMode::Embedded
};
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::PersistMode;
use ashpd::enumflags2::BitFlags;
@@ -542,6 +541,14 @@ fn portal_thread(
let proxy = Screencast::new().await.context(
"connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)",
)?;
// NEGOTIATED against what xdpw advertises, never asserted from `hw_cursor` alone — see
// the xdph copy in `hyprland.rs` for the incident. xdpw is the sharper case: its
// screencast.c refuses the mode outright —
// if (sess->screencast_data.cursor_mode & METADATA) {
// logprint(ERROR, "dbus: unsupported cursor mode requested, cancelling");
// — so EVERY cursor-forward session on this backend asked for a mode that cancelled the
// cast. Different wording from xdph's "unavailable cursor mode 4", same dead session.
let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdpw").await;
let session = proxy
.create_session(Default::default())
.await
@@ -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,
+2 -1
View File
@@ -115,6 +115,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|---|---|---|
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` | `1` | Make the streamed per-session output the sole desktop so plasmashell + windows render on it (not on the headless bootstrap output). Set by the KDE appliance `host.env`. Superseded by the console's **Topology** setting. |
| `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | `1` | GNOME/Mutter equivalent of the above. |
| `PUNKTFUNK_PORTAL_CURSOR_MODE` | `auto` *(default)* · `embedded` · `metadata` · `hidden` | **Hyprland / wlroots only, and a troubleshooting knob** — which ScreenCast cursor mode the host asks the portal for. Unset, the host asks for `metadata` when the client draws the pointer itself and `embedded` otherwise, then settles that against the modes your portal advertises; it never requests one your portal lacks. Set `embedded` if the pointer misbehaves on a portal that *claims* metadata support but implements it poorly — that is the one case the automatic negotiation cannot detect. A pin is still only a preference: it is checked against the advertised modes like any other. |
## Session recovery (Linux)
@@ -218,7 +219,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
+25
View File
@@ -84,6 +84,31 @@ and fails the session with a clear error rather than streaming a blank surface.
capture the Hyprland log (`hyprctl` instance dir → `hyprland.log`) and check your GPU's GBM support;
running Hyprland as a real session (not nested) is the supported configuration.
## Troubleshooting: black client + "unavailable cursor mode 4"
A black client, `pipeline build failed` in the host log, and **`unavailable cursor mode 4`** from
xdph are one failure, not three.
`4` is the ScreenCast portal's *metadata* cursor mode, which the host prefers when the client draws
the pointer locally (desktop mouse mode). xdg-desktop-portal-hyprland **does not offer that mode**
on a current stack (Hyprland 0.56.2, xdph 1.4.1) its `AvailableCursorModes` is `3`, meaning hidden
and embedded only. Asking for a mode the backend does not advertise is not a soft failure:
`xdg-desktop-portal` rejects the call outright, so the cast died during setup and the client had
nothing to show.
Updating xdph does **not** fix this — the mode is absent on current versions, not just old ones.
Hosts from this release check what your portal advertises and use an embedded cursor instead, so the
session streams. If you are on an older host, switch the client to **game mouse mode**: that stops
it asking for the metadata cursor at all.
If the pointer misbehaves on an xdph that *does* advertise metadata support, pin the mode:
```sh
PUNKTFUNK_PORTAL_CURSOR_MODE=embedded
```
See [Configuration](/docs/configuration#compositor-specific-linux).
## Permission system
Hyprland's permission system (`ecosystem.enforce_permissions`, 0.49+, **off by default**) can deny
+14
View File
@@ -82,6 +82,20 @@ For how long the virtual output lives, and extend-vs-exclusive topology, see
Then `systemctl --user restart xdg-desktop-portal`. On a box with only xdpw installed there is
nothing to choose between, so you can skip this.
## Troubleshooting: black client + "unsupported cursor mode requested"
A black client with `pipeline build failed` in the host log and **`dbus: unsupported cursor mode
requested, cancelling`** from xdpw is one failure, not two.
xdpw refuses the ScreenCast *metadata* cursor mode and cancels the cast, and the portal spec makes
that fatal rather than a fallback. Hosts before this release asked for it whenever the client drew
the pointer itself (desktop mouse mode), so those sessions never produced a frame. Hosts from this
release check what xdpw advertises first and use an embedded cursor instead, so the session streams.
On an older host, switch the client to **game mouse mode** — it stops asking for the metadata cursor
and the stream comes up. The same failure on Hyprland reads `unavailable cursor mode 4`; see
[Hyprland](/docs/hyprland).
## Start the host
With the backend selected, start the host from **inside your Sway session**:
+97 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.27.0"
"version": "0.28.0"
},
"paths": {
"/api/v1/clients": {
@@ -45,6 +45,36 @@
}
}
}
},
"delete": {
"tags": [
"clients"
],
"summary": "Unpair every client",
"description": "The collection form of [`unpair_client`]: empties the pairing store in ONE persisted write,\ncarrying the same revocation guarantees across the whole set. A LIVE GameStream session is\nended (its owning certificate is necessarily one of those just removed), and the ENet control\nport (UDP 47999) closes, because no pairing is left to hold it open.\n\nIdempotent, and so a 200 rather than the single unpair's 204/404 pair: \"unpair everything\" is\nsatisfied by an already-empty store, and the operator still wants to know whether that meant\nthree devices or none.",
"operationId": "unpairAllClients",
"responses": {
"200": {
"description": "Every client unpaired (possibly none)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnpairAllResult"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/clients/{fingerprint}": {
@@ -1767,6 +1797,56 @@
}
}
}
},
"delete": {
"tags": [
"native"
],
"summary": "Unpair every native client",
"description": "The collection form of [`unpair_native_client`]: empties the punktfunk/1 trust store in ONE\npersisted write (not a loop of them — a failure partway would leave a half-emptied store), and\nends every live native session the removed clients own.\n\nIdempotent, hence a 200 rather than the single unpair's 204/404: an already-empty store\nsatisfies the request, and the count still tells the operator what it meant.",
"operationId": "unpairAllNativeClients",
"responses": {
"200": {
"description": "Every native client unpaired (possibly none)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnpairAllResult"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the trust store",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Native host not enabled",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/native/clients/{fingerprint}": {
@@ -7687,6 +7767,22 @@
}
}
},
"UnpairAllResult": {
"type": "object",
"description": "What a bulk unpair removed. Shared by the two collection DELETEs (`/clients` and\n`/native/clients`) so the console sees one schema across both pairing planes.\n\nA count rather than 204: \"unpair everything\" is idempotent, so an empty store is a success, and\nthe operator still wants to be told whether that meant three devices or none.",
"required": [
"unpaired"
],
"properties": {
"unpaired": {
"type": "integer",
"format": "int32",
"description": "Clients removed from the trust store — 0 when nothing was paired.",
"example": 3,
"minimum": 0
}
}
},
"UpdateJobInfo": {
"type": "object",
"description": "A running apply job (or a spawned installer that hasn't resolved yet).",
+64
View File
@@ -0,0 +1,64 @@
Wire-compatible with 0.28.0 — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host, or the other way round, streams exactly as it does today.
This is a fix release, and most of it is about things that were wrong in ways nothing announced. On Windows, your Steam library could stay completely empty after the last update, and a Punktfunk host sitting idle — nothing connected, nothing streaming — could wreck the frame rate of a game you were playing on that same machine. An Android TV box could be silent for a whole session; a Steam Deck's Punktfunk home would not open; games could not reach high dynamic range (HDR) at all on Linux machines running our own compositor, while the Steam interface in front of them glared; and an Apple TV was being pushed into HDR mode by streams that were not HDR at all, lifting blacks to grey.
Several of these came from people who reported them carefully enough to be found. One of them, the crash that lost your game on reconnect, was tracked down, fixed and proven on his own hardware by **luxus** — that fix is in this release as he wrote it.
## TL;DR
- **On Windows, your Steam games went missing after updating to 0.28.0.** The Steam add-on could publish nothing, because Steam keeps its artwork in Program Files and the host was not allowed to read it. Fixed — and one unreadable cover no longer throws away the whole library with it.
- **An idle Punktfunk host could ruin a game you were playing on that same machine**, down to 25 frames per second at worst, with nothing connected and no stream running. Two separate causes, both fixed, both reported from Helldivers 2.
- **No sound at all on some Android TV boxes** — an NVIDIA Shield in particular — is fixed, and the app now recovers when your TV or receiver changes mode mid-stream instead of staying silent for the rest of the session.
- **The Punktfunk home would not open on a Steam Deck after updating to 0.28.0.** That is fixed, along with the missing statistics overlay in streams that came from the same fault.
- **Games can finally use high dynamic range on Linux hosts running Punktfunk's compositor.** They could not before, on essentially any machine, and nothing said so.
- **HDR streams looked washed out while the Steam interface looked glaring** — one brightness mistake causing both, now corrected.
- **On an Apple TV, ordinary streams were switching the television into HDR** and lifting blacks to grey — and those same streams were being drawn with no colour information at all.
- **If your Apple device's statistics overlay ever showed impossibly good numbers, that is why** — three separate faults made those figures unreliable. Old measurements are worth re-taking.
- **Streaming your desktop with the mouse pointer forwarded failed outright on Hyprland and Sway**, giving you a black picture. It affected every current install, not only old ones.
## Before you update
- **On Windows, two things in your Sound settings will look different, and both are deliberate.** "Speakers (Wireless Controller)" now shows as *disabled* whenever no controller is connected through Punktfunk — that is it parked out of the way of your local games, not a fault, and it comes back on its own. And Punktfunk no longer keeps your default microphone: it borrows it for the length of a stream and gives it back. If you actually want apps to record the client's microphone while nothing is streaming, select "Punktfunk Microphone" yourself — your choice will now be left alone.
- **Only if you are coming from 0.27 or older:** read [the 0.28.0 notes](https://git.unom.io/unom/punktfunk/releases/tag/v0.28.0) first. That release has two things that need you to act — your game library now comes from add-ons you install per store (with none installed, your library is empty), and Moonlight-compatible connections became something you switch on. Neither applies if you are already on 0.28.0.
## New
- **Your saved hosts have a menu when you use a controller on Apple devices.** Press up on a host tile for Wake, Copy link, Edit, Forget pairing and Remove — the same menu the desktop and Android consoles already had, so all three work the same way. A renamed machine or a mistyped address used to stay wrong forever unless you switched to touch. Removing asks twice, deliberately, because a thumbstick from across the room deserves at least as much care as a dialog.
- **The shortcut list is now a page you can open.** It used to appear as a banner over the stream for six seconds right after connecting — which is the one moment nobody is asking what the controls are. It lives in an About section you can visit whenever you want it.
- **Unpair every device at once.** Clearing a host's paired devices meant deleting them one at a time, confirming each. There is now a single action for it on the Paired devices card, and it ends any live session belonging to a device it removes.
## Improved
- **High dynamic range brightness matches between host and client.** Anything that is not an HDR game — your desktop, the Steam overlay, an ordinary title — was being mapped nearly a stop brighter by the host than every Punktfunk client expects. That is what made the Steam interface look glaring and over-saturated while actual HDR game content looked flat and washed out on the same stream. Both come from the same mistake and both are fixed.
- **Your host tells you when high dynamic range cannot work.** A session could start advertising HDR while having made it unreachable for every game inside it, with nothing anywhere explaining the washed-out result. It now says so.
- **The on-screen statistics no longer double-count decoding.** On machines using the fastest decoding path, the stage breakdown listed a figure that was already included in another one, so the parts did not add up to the total and a reader who checked was right to be confused. It is now reported separately, labelled as the single sample it is.
## Fixed
- **On Windows, the Steam add-on synced nothing and your grid stayed empty.** Steam installs to Program Files and keeps its artwork there, and the host's list of places it is allowed to read art from covered the launchers that install per-user — but not Steam. Every cover the add-on offered was rejected, and because one bad cover failed the whole submission, that meant every Steam game rather than one thumbnail. Both halves are fixed: Steam's own install locations are now allowed (including on a second drive), and a cover that cannot be served is dropped so the rest of your library still arrives. This only affected Windows — every Steam layout on Linux was already covered — and it arrived with 0.28.0, when the built-in Steam scanner was replaced by the add-on.
- **A Punktfunk host that was doing nothing at all could wreck a game running on the same Windows machine** — 25 frames per second at worst, with no client connected and no stream ever started. Two independent causes, both from the same set of reports: the host presents a controller speaker that looks exactly like a real DualSense's, and a game that finds it engages its DualSense audio path against a device nothing is feeding; and the host was claiming your default microphone permanently, so any game holding an open voice input stalled on a virtual mic that only runs during a stream. The controller speaker is now hidden except while a controller is actually attached, and your microphone is only borrowed for the length of a stream. Machines already stuck repair themselves on the next start.
- **Streaming your desktop with the pointer forwarded gave a black picture on Hyprland and Sway.** Punktfunk asked the desktop's screen-sharing service for the pointer in a form neither compositor offers, and the request was refused before it ever reached them — so the whole capture failed rather than losing just the pointer. It now asks what is actually available and takes the best on offer. Measured against fully up-to-date Hyprland and portal versions: this was every current install, and updating them would not have helped. You will now see a single pointer drawn by the host instead of your client drawing its own.
- **An Android TV box could have no audio for an entire session.** Reported on an NVIDIA Shield with a host and settings that played fine everywhere else, and video unaffected. The app asked Android for an audio device and then trusted the answer: a device that accepted the request but played nothing was never noticed, a device that refused to start disabled sound for the rest of the session, and — most common on a television — a device that went away during an HDMI mode change was never reopened. It now proves that sound is actually being taken before it accepts a device, and reopens automatically when your TV or receiver changes mode. TV boxes now use the ordinary shared audio path rather than the exclusive low-latency one, which was never buying them the latency it cost.
- **The Punktfunk home would not open on a Steam Deck after updating to 0.28.0.** Both the Decky button and the library shortcut failed. The same fault silently removed the statistics and capture overlays inside streams on other machines. It came from a graphics library sizing itself to what the machine's graphics driver supported rather than to what Punktfunk had asked for, which only became a problem once drivers moved ahead of us.
- **Games could not use high dynamic range on Linux hosts running Punktfunk's compositor** — on essentially every machine, not a rare configuration. Reaching HDR requires a small graphics component that has to match the compositor exactly, and we shipped the compositor without it, leaving the host guessing whether the one your distribution provided was compatible. Because we build our own compositor, that guess was almost always wrong in the direction of switching HDR off. We now ship the matching component ourselves, on every Linux packaging channel.
- **Reconnecting after a disconnect could lose your game** on Linux hosts with an NVIDIA card. The host keeps your session alive when a client leaves so reconnecting resumes it, and the compositor could crash in exactly that window — so the session you came back to was a brand-new one with your game gone. Found, fixed and proven in the field by **luxus**, over an evening of Baldur's Gate 3 at 4K60 HDR that had produced four crashes before the fix and none after.
- **A stream that was not HDR pushed an Apple TV into HDR mode anyway**, which switches the HDMI output to limited range — so on a television set for full range, black came out grey. The switch was triggered by your HDR *setting* being on rather than by the stream actually being HDR, and that setting is on by default.
- **Ordinary streams on Apple devices were drawn with no colour information at all**, leaving them mildly over-saturated on a Mac or iPad with a wide-colour screen, and lifting blacks on an Apple TV. A stream that was not HDR from its very first frame skipped colour setup entirely.
- **The statistics overlay on Apple devices reported numbers that could not be true** — a network figure lower than the host's own encoding time, and historically readings of zero. Three faults: the clock difference with the host was measured once at connect and never updated (it drifts, and on a virtual-machine host it jumps), impossible samples were being discarded silently rather than invalidating the reading, and several unavailable values printed as "NaN". The overlay now warns you when a reading is suspect instead of showing a plausible wrong number.
- **AV1 streams fell back to slower decoding** on hardware that decodes them perfectly well. Some encoders mark a stream as "not restricted to any quality level", and that marker was being read as a level so high that no device could claim to support it.
- **On Apple TV, console text could be white on a pale background**, making screens unreadable with the light appearance.
- **Punktfunk add-ons could not be installed on NixOS**, where the component that manages them was reported missing on machines where it was installed, enabled and running.
- **On NixOS the host could crash-loop forever, unable to claim its own ports.** A second copy was being started under the root account — which springs into life the moment anyone signs in as root — and it won the race for the ports the real one needed. The log blamed some other program, because a second copy of itself is the last thing anyone looks for. The service is now scoped to the accounts you list.
- **Two host warnings blamed the wrong thing.** One announced a "metronomic" disturbance from your display hardware when the regular timing was in fact a client's own recovery interval; the other reported catastrophic audio loss between sessions, when what it had counted was silence during the seconds no one was connected. Neither ever indicated a real fault.
## Thanks
**luxus** found the crash that lost your game on reconnect, wrote the fix, and proved it on his own machine — an evening of Baldur's Gate 3 at 4K60 HDR that had produced four crashes before it and none after. It ships here as he wrote it.
Most of the rest of this release exists because people took the trouble to report a fault precisely: the silent Android TV box, the empty Steam library, the game stuttering next to an idle host, the washed-out HDR, the black picture on Hyprland. Thank you — that detail is what makes a fault findable.
## For developers
Protocol, ABI, driver and embedder detail — including the version table and the (empty) breaking-changes list — is in [CHANGELOG.md](https://git.unom.io/unom/punktfunk/src/tag/v0.28.1/CHANGELOG.md).
Nothing breaks: the wire protocol, the C ABI, the virtual-display driver protocol and the plugin contract are all unchanged from v0.28.0. Two things are worth reading if they apply to you: every Linux packaging channel now ships a second gamescope artifact and a package without it fails to build rather than degrading quietly, and Android's `nativeStartAudio` takes one more argument.
+3
View File
@@ -0,0 +1,3 @@
• Fixes having no sound at all on some Android TV boxes, the NVIDIA Shield among them. The app now checks that your TV or receiver is really taking the audio, instead of trusting that it will.
• Sound returns on its own when your TV or receiver changes mode during a stream. Before, it could stay silent for the rest of the session.
• A stream that loses audio now says so, rather than looking exactly like an app with nothing to play.
+9 -1
View File
@@ -64,7 +64,7 @@ Add the flake and enable the host and/or client:
({ ... }: {
services.punktfunk.host = {
enable = true;
users = [ "alice" ]; # → added to the `input` group for virtual gamepads
users = [ "alice" ]; # → `input` group for gamepads, AND scopes the units to alice
openFirewall = true; # native + GameStream ports
desktopSession = true; # a machine you log into — restart the host with the desktop
settings = {
@@ -236,6 +236,14 @@ services.punktfunk.host = {
users.users.streamer.linger = true;
```
**Set `users` whenever `autoStart` is on.** `systemd.user.*` installs into *every* user's systemd
manager — root's included, and root gets one the moment anybody logs in as root. Without `users` to
scope them, `autoStart` therefore starts a *second* host in root's manager, which wins the race for
the fixed ports and leaves the real one restarting forever on `bind RTSP 48010: Address already in
use` (every other listener having bound fine, so it reads like a clash with an unrelated program).
The module renders `ConditionUser=` from this list to prevent that; with the list empty it falls
back to refusing system users, which keeps root out but cannot tell two logins apart.
Leave `desktopSession` off here — an appliance starts its own compositor and may never reach
`graphical-session.target`, which would leave the host permanently stopped. `gamescopeHdr` (on by
default) already puts the patched `punktfunk-gamescope` on the service PATH, so the gamescope
+33
View File
@@ -143,6 +143,39 @@ let
ok = failedAssertions clientOnly == [ ];
}
# --- user scoping: the second-copy-steals-the-ports trap -----------------------------------
# `systemd.user.*` installs into EVERY user's manager, root's included (user@0.service exists
# as soon as anyone logs in as root), and `autoStart` puts these in default.target. Root's host
# then wins the fixed ports and the desktop user's restarts forever on
# `bind RTSP 48010: Address already in use` — every other listener in its log having bound
# fine, so it reads like an unrelated program. MEASURED on a real box before this was fixed.
{
# `|` = TRIGGERING condition, which systemd ORs. Plain repeated ConditionUser= lines are
# ANDed and would match nobody — the whole reason the prefix is there.
name = "host.users scopes every user unit to those users, OR-ed";
ok =
let
scoped = name: has desktop name "ConditionUser=|alice";
in
scoped "punktfunk-host" && scoped "punktfunk-web" && scoped "punktfunk-scripting";
}
{
# web-init already carried a ConditionPathExists. That one is NON-triggering, so systemd
# requires it AND at least one triggering user condition — adding ours must not drop it.
name = "web-init keeps its path condition alongside the user scope";
ok =
has desktop "punktfunk-web-init" "ConditionUser=|alice"
&& has desktop "punktfunk-web-init" "ConditionPathExists=!%h/.config/punktfunk/web-password";
}
{
# With no host.users to name, still keep SYSTEM users (root) out, while leaving the module
# header's manual `systemctl --user enable --now punktfunk-host` working for a normal login.
name = "with no host.users, the units still refuse system users (root)";
ok =
has appliance "punktfunk-host" "ConditionUser=!@system"
&& !(has appliance "punktfunk-host" "ConditionUser=|");
}
# --- the KWin identification trap (packaging/arch/punktfunk-host.install) -------------------
# The host MUST exec the plain store path. A capability wrapper here would put CAP_SYS_NICE in
# the process's permitted set, and the kernel then refuses KWin the /proc/<pid>/exe readlink it
+47 -1
View File
@@ -55,6 +55,27 @@ let
hostSettingsFile = pkgs.writeText "punktfunk-host.env" (renderEnv cfg.host.settings);
# WHICH users' `systemd --user` instances may run these units — and why they need saying at all.
#
# `systemd.user.*` installs into EVERY user's manager; there is no per-user form of it in NixOS.
# Combined with `autoStart` putting the units in `default.target`, that includes **root**, whose
# `user@0.service` springs into existence the moment anybody so much as SSHes in as root. Root's
# copy of the host then wins the race for the fixed ports and the desktop user's copy fails
# forever on `bind RTSP 48010: Address already in use` — with every other listener in its log
# having bound fine, so it reads like a clash with some unrelated program rather than a second
# copy of itself. MEASURED 2026-08-14 on a fresh NixOS 26.05 box.
#
# `host.users` is already documented as "the host runs as these users' systemd --user service",
# so it is the right scope. When it is empty we cannot name the intended user, so fall back to
# excluding system users — which is precisely what keeps root out — and leave the module header's
# manual `systemctl --user enable --now punktfunk-host` route working for any normal login.
#
# ⚠ The `|` prefix is load-bearing: it makes each entry a TRIGGERING condition, and systemd ORs
# those. Plain repeated `ConditionUser=` lines are ANDed, so a two-user list would match NOBODY.
# Non-triggering conditions on the same unit (punktfunk-web-init's ConditionPathExists) still
# have to hold, which is the behaviour we want.
userScope = if cfg.host.users == [ ] then [ "!@system" ] else map (u: "|${u}") cfg.host.users;
# Native punktfunk/1 ports (control plane + discovery + mgmt API). The media data plane is an
# ephemeral per-session UDP port the host hole-punches, so nothing fixed to open (see
# packaging/linux/punktfunk.ufw).
@@ -106,6 +127,10 @@ in
Start the host automatically in every user's graphical session (adds it to the user
`default.target`). For a login-less appliance, also enable lingering for the host user
(`users.users.<name>.linger = true`) so the user service comes up at boot.
"Every user" is bounded by `host.users` via `ConditionUser=` without that bound this
option also starts a host in ROOT's user manager the moment anybody logs in as root, and
that copy takes the ports from the real one. Set `host.users` on a multi-user box.
'';
};
@@ -147,6 +172,15 @@ in
usbip/vhci nodes the virtual Steam Deck pad attaches through. The second is separate on
purpose it can emulate arbitrary USB hardware, so only list users you would trust with
that. The host runs as these users' `systemd --user` service.
This list ALSO scopes the units themselves: they carry a `ConditionUser=` for these
users, so no other user's `systemd --user` instance can start them. That matters because
`systemd.user.*` installs into every user's manager including root's, which exists as
soon as anyone logs in as root and a second host silently wins the race for the fixed
ports, leaving the real one restarting forever on "Address already in use".
Left empty, the units are merely refused to SYSTEM users (`ConditionUser=!@system`), so
any normal login can still run the host by hand and root still cannot.
'';
};
@@ -466,6 +500,9 @@ in
systemd.user.services.punktfunk-host = {
description = "punktfunk GameStream + punktfunk/1 streaming host";
documentation = [ "https://git.unom.io/unom/punktfunk" ];
# Keep root (and every other system user) from starting a second host that steals the
# fixed ports from the desktop user's — see `userScope`.
unitConfig.ConditionUser = userScope;
# Soft ordering: the host listens immediately and only touches the compositor per session.
after = [ "pipewire.service" ] ++ optional cfg.host.desktopSession "graphical-session.target";
wants = [ "pipewire.service" ];
@@ -573,7 +610,12 @@ in
systemd.user.services.punktfunk-web-init = {
description = "punktfunk web console first-run setup (login password)";
documentation = [ "https://git.unom.io/unom/punktfunk" ];
unitConfig.ConditionPathExists = "!%h/.config/punktfunk/web-password";
# ⚠ ConditionUser here is TRIGGERING (`|`) and ConditionPathExists is not, so systemd
# requires the path condition AND at least one user condition — which is the intent.
unitConfig = {
ConditionPathExists = "!%h/.config/punktfunk/web-password";
ConditionUser = userScope;
};
path = [ pkgs.coreutils ];
serviceConfig = {
Type = "oneshot";
@@ -589,6 +631,8 @@ in
systemd.user.services.punktfunk-web = {
description = "punktfunk management web console";
documentation = [ "https://git.unom.io/unom/punktfunk" ];
# Same scoping as the host: root's instance would take 47992 from the real one.
unitConfig.ConditionUser = userScope;
after = [
"punktfunk-web-init.service"
"punktfunk-host.service"
@@ -640,6 +684,8 @@ in
systemd.user.services.punktfunk-scripting = {
description = "punktfunk plugin/script runner";
documentation = [ "https://git.unom.io/unom/punktfunk" ];
# Same scoping as the host: a root-side runner would talk to the wrong session's mgmt API.
unitConfig.ConditionUser = userScope;
# Plugins talk to the host's loopback mgmt API; order after it (soft — the runner backs off
# and retries per unit, so this is ordering only, not a hard requirement).
after = [ "punktfunk-host.service" ];
+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