Compare commits

..
Author SHA1 Message Date
enricobuehler e0464e7407 docs(bazzite): warn on the user-facing page that 0.26.0-1 cannot stream the Desktop
packaging/bazzite/README.md already carries this, but that file ships inside the repo — the page a
field user actually lands on is /docs/bazzite, and it said only that the virtual output 'needs no
config'. On 0.26.0-1 that reads as a lie: the image granted the host CAP_SYS_NICE, so KWin cannot
read the /proc/<pid>/exe it identifies clients by, the shipped .desktop can never match, and every
Desktop session dies with 'KWin does not expose zkde_screencast_unstable_v1 to this client' —
looking exactly like the setup on that page was done wrong.

Says so, and gives the only repair that works: a merged sysext's /usr is read-only, so it is the
next image (punktfunk-sysext update), not a setcap -r. Notes Gaming Mode is unaffected.

Written as a bolded blockquote, the admonition style every other page uses. NOT a {{< callout >}}
shortcode — this site is Fumadocs/MDX, not Hugo, and no shortcode exists anywhere in it. Verified by
running the real build (bun install + vite build) rather than assuming: it completes clean, and the
backticked /proc/<pid>/exe follows the same pattern as the existing <pkg>/<token> in arch.md and
automation.md, which MDX leaves alone inside inline code.
2026-08-09 10:07:18 +02:00
enricobuehler 4d383811c0 fix(packaging): the same CAP_SYS_NICE broke KDE on FIVE channels, not one — Bazzite included
ci / bun-nix (pull_request) Successful in 17s
ci / web (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m38s
ci / rust-arm64 (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m46s
android / android (pull_request) Successful in 5m31s
ci / rust (pull_request) Failing after 9m2s
nix / flake (pull_request) Successful in 12m24s
The Arch fix in the previous commit was incomplete. 0.26.0-1 granted the host CAP_SYS_NICE through
every Linux channel we ship, and each one breaks KWin identification the same way:

  * packaging/rpm/punktfunk.spec .......... %caps(cap_sys_nice=ep) in %files  <- Fedora AND Bazzite
                                            via rpm-ostree layering
  * packaging/bazzite/build-sysext.sh ..... setcap on the staging tree, recorded by mksquashfs
  * packaging/debian/build-deb.sh ......... setcap in the postinst
  * packaging/nix/nixos-module.nix ........ security.wrappers with capabilities = "cap_sys_nice=ep"
  * scripts/steamdeck/install.sh .......... setcap on $BIN, six lines after writing the .desktop
                                            whose Exec= it thereby voids

Bazzite was NOT a separate fault, as first reported here — it is this one. Verified by mounting the
published punktfunk-0.26.0-1-x86-64.raw: `getcap usr/bin/punktfunk-host` reports cap_sys_nice=ep,
stored as security.capability in the squashfs. The claim in packaging/arch/build-sysext.sh that
"file capabilities don't survive this squashfs path" is false and is corrected here; mksquashfs
records them, which is exactly why the image shipped one.

NixOS deserves its own note: a security.wrappers entry does not dodge the problem. The wrapper
raises the capability into its AMBIENT set before exec'ing the store binary, precisely so it
survives — which lands CAP_SYS_NICE in the exec'd process's permitted set and fails the readlink
identically to a file capability. ExecStart now points at the store path directly, which is also the
path packages.nix substitutes into the .desktop's Exec=, so the two finally agree.

Measured blast radius of holding a capability, same-uid reader, CachyOS kernel 7.1.6:

    /proc/PID/exe ....... EPERM   <- KWin's identification. Desktop sessions die.
    /proc/PID/root/* .... EPERM   <- xdg-desktop-portal reads .flatpak-info here to resolve an
                                     app id; the wlroots and Hyprland backends go through it
    /proc/PID/environ ... EPERM
    /proc/PID/cgroup .... OK
    /proc/PID/status .... OK
    /proc/PID/cmdline ... OK

Compositor backends, by exposure: KWin is broken outright (proven, field-confirmed). gamescope has
no identity gate and was never affected, which matches the field — only Desktop mode was reported.
Mutter drives Mutter's own D-Bus API, not the portal, and looks unaffected. wlroots and Hyprland go
through the ScreenCast portal, whose app-id resolution reads a path the capability blocks — a real
exposure, not something I reproduced end to end.

The sysext build now HARD-FAILS if a capability is staged, rather than trusting that the RPM payload
never carries one: a merged sysext's /usr is read-only squashfs, so a bad image cannot be repaired
on the box, and the spec was one %caps() away from baking one in again.

Docs corrected, because they advertised the capability as a feature:
  * docs-site running-as-a-service "GPU scheduling priority" — rewritten: the host carries no
    capability, why it must not, and how to clear a 0.26.0-1 install (Bazzite needs a new image)
  * docs-site configuration.md — the PYROWAVE_QUEUE_PRIORITY row no longer claims the packages grant it
  * packaging/bazzite/README.md — §6.5 still described the kde-desktop-setup.sh behaviour from
    before it stopped writing KWIN_WAYLAND_NO_PERMISSION_CHECKS and started REMOVING it; plus a
    note that 0.26.0-1 Desktop mode cannot be repaired in place
  * packaging/arch/README.md — the false "capabilities don't survive the sysext" line
  * CHANGELOG v0.26.0 PW1 — annotated with the 0.26.0-2 correction rather than rewritten, and the
    owed PyroWave-under-load A/B now says it needs a gamescope-only box

Verified: bash -n on all five changed shell files; nix-instantiate --parse on nixos-module.nix and
packages.nix; the published 0.26.0-1 sysext mounted and its capability read; getcap on an uncapped
file exits 0 with empty output, so the new build assertion cannot false-positive.
2026-08-09 09:56:36 +02:00
enricobuehler 42ee6c5628 fix(packaging): the host's CAP_SYS_NICE made it invisible to KWin, killing every KDE session
0.26.0-1 setcap'd `cap_sys_nice=ep` on /usr/bin/punktfunk-host so the encoder could open an
elevated global-priority Vulkan queue. On every KDE box that ended desktop streaming outright:

    KWin virtual output failed: KWin does not expose zkde_screencast_unstable_v1 to this client

reported from CachyOS on NVIDIA and on AMD, surviving a clean reinstall of host and client, and
worked around only by KWIN_WAYLAND_NO_PERMISSION_CHECKS=1.

The two cannot coexist. KWin hands out its restricted protocols — zkde_screencast_unstable_v1,
which mints our virtual output, and org_kde_kwin_fake_input, which injects input — only to a client
it can IDENTIFY, by resolving that client's /proc/<pid>/exe and matching it against an installed
.desktop's Exec=. The kernel refuses that readlink to any reader whose effective set is not a
superset of the target's PERMITTED set (cap_ptrace_access_check), and KWin holds no capabilities.
So the instant the binary carries one, KWin's executablePath() is empty, nothing matches, and the
global is never advertised — presenting exactly as a missing or mis-installed .desktop file.

Measured on CachyOS (kernel 7.1.6), same-uid reader, cap_sys_nice=ep on the target:

    no capability .............................. readlink /proc/<pid>/exe OK
    capability ................................. EPERM
    capability + prctl(PR_SET_DUMPABLE, 1) ..... EPERM   <- dumpable is NOT the gate
    capability dropped + PR_SET_DUMPABLE(1) .... OK      <- only an uncapped process works

The third row also rules out the reflex fix of moving the grant to systemd AmbientCapabilities=,
which lands CAP_SYS_NICE in the very same permitted set. Nothing short of not holding the
capability restores identification, so the host does not get one.

The cost is pacing only. pf-zerocopy's device create already walks REALTIME -> HIGH -> default when
a priority class is refused, and pf-frame's thread nice is a documented best-effort no-op without
the capability — so this is 0.25.0's behaviour exactly, which is the behaviour that worked.

  * packaging/arch/punktfunk-host.install: grant -> revoke. post_upgrade strips the capability from
    boxes that already ran 0.26.0-1's scriptlet. A pacman upgrade writes a new inode and file
    capabilities do not survive that, so this is belt-and-braces for reinstall/downgrade paths.
  * pf-vdisplay kwin.rs: all three "KWin does not expose zkde_screencast" errors now read
    /proc/self/status and, if this process holds ANY capability, name it with its CapPrm mask and
    the `setcap -r` that repairs it. The failure stays impossible to diagnose from the Wayland side
    otherwise, and it is not unique to our own packaging — a hand-rolled setcap does it too.

Verified on 192.168.1.21 (CachyOS): the capability/dumpable matrix above; cargo check and
cargo clippy --all-targets -- -D warnings clean for pf-vdisplay; both new unit tests pass; and the
hint itself exercised end-to-end, silent uncapped and firing with CapPrm=0x0000000000800000 under
cap_sys_nice=ep. The shipped punktfunk-host-0.26.0-1-x86_64.pkg.tar.zst was unpacked to confirm its
.INSTALL carries the setcap on both post_install and post_upgrade.

Ships as 0.26.0-2 — packaging plus one crate, no version bump.
2026-08-09 09:37:30 +02:00
enricobuehler 55f361cb92 Merge pull request 'The v0.26.0 tag went red on Windows — a Linux-only reader tripped dead_code' (#134) from worktree-pyrowave-wire-dead-code into main
apple / swift (push) Successful in 1m33s
ci / rust-arm64 (push) Successful in 4m54s
ci / web (push) Successful in 1m47s
release / apple (push) Successful in 10m42s
ci / rust (push) Successful in 8m51s
ci / docs-site (push) Successful in 1m44s
ci / bun-nix (push) Successful in 32s
apple / screenshots (push) Successful in 5m55s
android-screenshots / screenshots (push) Successful in 2m16s
deb / build-publish (push) Successful in 3m56s
decky / build-publish (push) Successful in 23s
deb / build-publish-client-arm64 (push) Successful in 2m39s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m32s
deb / build-publish-host (push) Successful in 6m49s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m0s
linux-client-screenshots / screenshots (push) Successful in 2m55s
android / android (push) Successful in 11m36s
arch / build-publish (push) Successful in 13m24s
flatpak / build-publish (push) Successful in 8m4s
windows-host / winget-source (push) Successful in 35s
windows-host / package (push) Successful in 11m45s
windows-host / canary-manifest (push) Skipped
sbom / sbom (push) Successful in 35s
docker / deploy-docs (push) Failing after 6m10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 57s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m57s
web-screenshots / screenshots (push) Successful in 4m48s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m38s
2026-08-08 23:44:44 +00:00
enricobuehler 2079411f4f fix(pf-encode): the Windows host could not compile — a Linux-only reader tripped dead_code
apple / swift (pull_request) Successful in 1m35s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m24s
android / android (pull_request) Successful in 5m55s
ci / rust-arm64 (pull_request) Successful in 4m5s
ci / bun-nix (pull_request) Successful in 33s
ci / docs-site (pull_request) Successful in 1m32s
ci / rust (pull_request) Successful in 21m44s
The v0.26.0 tag went red on windows-host at the clippy step, after a clean
build:

  error: function `wire_sequence` is never used
    --> crates\pf-encode\src\enc\pyrowave_wire.rs:68:15
     = note: `-D dead-code` implied by `-D warnings`

`pyrowave_wire` is cfg'd for linux OR windows and is genuinely shared —
`packet_boundary` and `stamp_color_bits` each have callers on both backends.
`wire_sequence` does not: every call site is in `enc/linux/pyrowave.rs`, which
is `#[cfg(all(target_os = "linux", feature = "pyrowave"))]`. Alternating
encoder handles are a Linux-side concern (PW5); the Windows backend drives
pyrowave's compat device with a single handle and never needs the counter. The
module's own `#[cfg(test)]` block does not reference it either, so on Windows
the item has zero callers in every target and dead_code is correct — it is the
`-D warnings` promotion to a hard error that stops the lib compiling.

Scoped to the one item rather than the file, and expressed as
`cfg_attr(not(target_os = "linux"), ...)` rather than a bare `allow`, so
dead_code stays LIVE on Linux — where the caller lives, and where this function
quietly losing its last caller would be a real finding rather than noise.

⚠ Not reproducible off a Windows box: cross-compiling to
x86_64-pc-windows-msvc from macOS dies in openh264-sys2's build script
(clang++ rejects `-fPIC` for that target) long before the lint stage. The
mechanism is nonetheless exact — one item, one cfg, zero callers behind it —
and the windows-host and windows-msix legs are the proof.

No behaviour change on any platform: this adds a lint attribute and eight
lines of comment.
2026-08-09 01:43:54 +02:00
enricobuehler 4d1a1348c0 Merge pull request 'chore(release): bump workspace version to 0.26.0' (#133) from worktree-release-0260 into main
apple / swift (push) Successful in 1m41s
audit / bun-audit (plugin-kit) (push) Successful in 1m1s
audit / bun-audit (sdk) (push) Successful in 33s
audit / bun-audit (web) (push) Failing after 36s
audit / docs-site-audit (push) Successful in 27s
audit / pnpm-audit (push) Successful in 30s
ci / web (push) Successful in 2m25s
audit / license-gate (push) Successful in 4m21s
ci / bun-nix (push) Successful in 24s
ci / docs-site (push) Successful in 1m26s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 6m20s
android-screenshots / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 24s
deb / build-publish-client-arm64 (push) Canceled after 17s
decky / build-publish (push) Canceled after 5s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
linux-client-screenshots / screenshots (push) Canceled after 0s
release / apple (push) Canceled after 1m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
sbom / sbom (push) Canceled after 0s
web-screenshots / screenshots (push) Canceled after 1s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m21s
audit / cargo-audit (push) Successful in 2m17s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m48s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m6s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m13s
nix / flake (push) Successful in 20m20s
flatpak / build-publish (push) Successful in 21m23s
2026-08-08 23:29:38 +00:00
15 changed files with 311 additions and 132 deletions
+16 -2
View File
@@ -290,7 +290,19 @@ same shader cores a game saturates; NVENC is immune because it has its own ASIC.
ladder REALTIME → HIGH → no-priority, stepping only on refusal; a refused class can never fail the ladder REALTIME → HIGH → no-priority, stepping only on refusal; a refused class can never fail the
open. The extension probe reuses the `dev_ext_props` already fetched for `queue_family_foreign` and open. The extension probe reuses the `dev_ext_props` already fetched for `queue_family_foreign` and
takes KHR or the EXT alias — the same spelling pf-zerocopy probes, so the two cannot disagree. takes KHR or the EXT alias — the same spelling pf-zerocopy probes, so the two cannot disagree.
**Needs `CAP_SYS_NICE`**, which the packaging now grants; without it the lever does nothing. **Needs `CAP_SYS_NICE`**, which the packaging granted in `0.26.0-1`; without it the lever does
nothing.
🛑 **Corrected in `0.26.0-2`: the packaging no longer grants it, and must not.** Every channel that
did (Arch `.install`, RPM `%caps()`, the Bazzite sysext image, the deb postinst, the NixOS
`security.wrappers` entry) broke desktop streaming on KDE outright — field-reported on CachyOS and
Bazzite as `KWin does not expose zkde_screencast_unstable_v1 to this client`. KWin identifies a
client by resolving its `/proc/<pid>/exe` against an installed `.desktop`, and the kernel refuses
that readlink to any reader whose effective set is not a superset of the target's **permitted**
set (`cap_ptrace_access_check`) — KWin has no capabilities, so a capability-carrying host is
unidentifiable and the restricted globals are never advertised. Neither `prctl(PR_SET_DUMPABLE, 1)`
nor systemd `AmbientCapabilities=` rescues it; only an uncapped process is identifiable. The lever
therefore stays wired but unexercised on a stock install (the ladder degrades to default priority),
and is opt-in for gamescope-only hosts, which have no such identity check.
- **PW5 — two encoder handles.** `Encoder::Impl` owns exactly one each of `wavelet_img_high_res`, - **PW5 — two encoder handles.** `Encoder::Impl` owns exactly one each of `wavelet_img_high_res`,
`bucket_buffer`, `meta_buffer`, `block_stat_buffer`, `payload_data`, `quant_buffer`, and `bucket_buffer`, `meta_buffer`, `block_stat_buffer`, `payload_data`, `quant_buffer`, and
`Impl::encode` *opens* by discarding them (an image barrier with `VK_IMAGE_LAYOUT_UNDEFINED` as the `Impl::encode` *opens* by discarding them (an image barrier with `VK_IMAGE_LAYOUT_UNDEFINED` as the
@@ -414,7 +426,9 @@ emulator itself would land it outside both.
**Owed on glass:** iPhone + Bluetooth listen, Apple TV stats overlay, MacBook audio listen, the **Owed on glass:** iPhone + Bluetooth listen, Apple TV stats overlay, MacBook audio listen, the
Deck HEVC/4:4:4 retest, a Windows wake-from-sleep cycle, and the PyroWave-under-game-load A/B on a Deck HEVC/4:4:4 retest, a Windows wake-from-sleep cycle, and the PyroWave-under-game-load A/B on a
Linux host with `CAP_SYS_NICE` actually granted — the number this whole wave is aimed at. Linux host with `CAP_SYS_NICE` actually granted — the number this whole wave is aimed at. ⚠ That
last one now needs a **gamescope-only** host, or a hand-granted capability on a box you are not
streaming the KDE desktop from: see the `0.26.0-2` correction under PW1 above.
--- ---
@@ -65,6 +65,14 @@ pub(crate) fn stamp_color_bits(bitstream: &mut [u8], seq_offset: usize, bt2020_p
/// repeated value is read as more blocks of the same frame. That is why PW5's alternating encoder /// repeated value is read as more blocks of the same frame. That is why PW5's alternating encoder
/// handles need `pyrowave_encoder_set_next_sequence`, and why a test asserts this reader sees /// handles need `pyrowave_encoder_set_next_sequence`, and why a test asserts this reader sees
/// +1 mod 8 across the pair. /// +1 mod 8 across the pair.
///
/// Its only caller is the Linux backend — alternating encoder handles are a Linux-side concern, and
/// the Windows backend drives pyrowave's compat device with a single handle. The rest of this module
/// really is shared (`packet_boundary` and `stamp_color_bits` have callers on both), so the exemption
/// is scoped to this one item rather than the file: `dead_code` stays live on Linux, where the caller
/// lives and where its disappearing would be a real finding. Windows builds with `-D warnings`, so
/// without this the host and tray clippy legs fail to compile the lib at all.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn wire_sequence(bitstream: &[u8], packet_offset: usize) -> Option<u8> { pub(crate) fn wire_sequence(bitstream: &[u8], packet_offset: usize) -> Option<u8> {
let lo = *bitstream.get(packet_offset + 2)?; let lo = *bitstream.get(packet_offset + 2)?;
let hi = *bitstream.get(packet_offset + 3)?; let hi = *bitstream.get(packet_offset + 3)?;
+81 -5
View File
@@ -13,8 +13,11 @@
//! So an interactive Plasma session does NOT hand it to a bare client — the host packages ship //! So an interactive Plasma session does NOT hand it to a bare client — the host packages ship
//! `io.unom.Punktfunk.Host.desktop` (`Exec=/usr/bin/punktfunk-host`, //! `io.unom.Punktfunk.Host.desktop` (`Exec=/usr/bin/punktfunk-host`,
//! `X-KDE-Wayland-Interfaces=zkde_screencast_unstable_v1,…`) so it is present before the host first //! `X-KDE-Wayland-Interfaces=zkde_screencast_unstable_v1,…`) so it is present before the host first
//! connects. The headless test path instead exposes it to bare clients via //! connects. That identification is also why **the host binary must carry no file capability**: a
//! `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`. The compositor backend must implement //! process holding capabilities KWin lacks is one the kernel will not let KWin resolve
//! `/proc/<pid>/exe` for, so it can never be matched to a `.desktop` no matter how correctly the
//! file is installed (see [`capability_denial_hint`]). The headless test path instead exposes it to
//! bare clients via `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`. The compositor backend must implement
//! `createVirtualOutput`: the **DRM backend** (any version) or the **VirtualBackend since KWin //! `createVirtualOutput`: the **DRM backend** (any version) or the **VirtualBackend since KWin
//! 6.5.6** (`kwin_wayland --virtual`); on `--virtual` < 6.5.6 the request fails with //! 6.5.6** (`kwin_wayland --virtual`); on `--virtual` < 6.5.6 the request fails with
//! "Could not find output". We talk raw Wayland on `$WAYLAND_DISPLAY`, so the host must run inside //! "Could not find output". We talk raw Wayland on `$WAYLAND_DISPLAY`, so the host must run inside
@@ -1071,6 +1074,74 @@ impl Drop for StopOnDrop {
} }
} }
/// Extra sentence appended to every "KWin never advertised the screencast global" error when this
/// process carries capabilities — the one cause that is completely invisible from the Wayland side.
///
/// KWin authorizes a restricted interface by resolving the *client's* `/proc/<pid>/exe` and
/// matching it against an installed `.desktop`. The kernel refuses that readlink to any reader
/// whose effective set is not a superset of the target's **permitted** set
/// (`cap_ptrace_access_check`), and KWin has no capabilities at all. So a host binary carrying any
/// file capability is simply unidentifiable: `executablePath()` comes back empty, no `.desktop` can
/// match, and the global is never advertised — indistinguishable, from here, from a missing
/// `.desktop`. Neither half of the obvious workaround helps: `prctl(PR_SET_DUMPABLE, 1)` leaves the
/// permitted-set check failing, and moving the grant to systemd `AmbientCapabilities=` lands the
/// capability in the same permitted set. Only an uncapped binary is identifiable.
///
/// This is not hypothetical: 0.26.0-1 setcap'd `cap_sys_nice` on the host for the GPU-priority
/// lever and took out desktop streaming on every KDE box until the capability was removed again.
fn capability_denial_hint() -> String {
let permitted = std::fs::read_to_string("/proc/self/status")
.ok()
.and_then(|status| permitted_caps_from_status(&status));
match permitted {
Some(caps) if caps != 0 => format!(
" — NOTE: this process carries capabilities (CapPrm={caps:#018x}), which is enough on \
its own to cause this: the kernel then refuses KWin the /proc/<pid>/exe read it \
identifies clients by, so no .desktop can match however correctly it is installed. \
Clear them with `sudo setcap -r /usr/bin/punktfunk-host` and restart the host"
),
_ => String::new(),
}
}
/// The permitted-capability mask out of a `/proc/<pid>/status` body, or `None` if the field is
/// absent/unparseable. The kernel prints it as a tab-separated 16-digit hex word with no `0x`
/// (`CapPrm:\t0000000000800000` = CAP_SYS_NICE), which is what the split-and-radix-16 parse below
/// expects — split out from [`capability_denial_hint`] purely so that shape is testable without a
/// capability-carrying process to point at.
fn permitted_caps_from_status(status: &str) -> Option<u64> {
let field = status.lines().find(|l| l.starts_with("CapPrm:"))?;
u64::from_str_radix(field.split_whitespace().nth(1)?, 16).ok()
}
#[cfg(test)]
mod capability_hint_tests {
use super::*;
/// Verbatim from a `cap_sys_nice=ep` process on CachyOS — the case that broke 0.26.0-1.
const CAPPED: &str = "Name:\tpunktfunk-host\nUid:\t1000\t1000\t1000\t1000\nCapPrm:\t0000000000800000\nCapEff:\t0000000000800000\n";
/// ...and from the same binary with no capability, where the hint must stay silent.
const CLEAN: &str = "Name:\tpunktfunk-host\nUid:\t1000\t1000\t1000\t1000\nCapPrm:\t0000000000000000\nCapEff:\t0000000000000000\n";
#[test]
fn parses_the_kernels_permitted_mask() {
assert_eq!(permitted_caps_from_status(CAPPED), Some(0x0080_0000));
assert_eq!(permitted_caps_from_status(CLEAN), Some(0));
// CapPrm is not guaranteed present (older/again-different kernels): stay quiet, never panic.
assert_eq!(permitted_caps_from_status("Name:\tx\n"), None);
assert_eq!(permitted_caps_from_status("CapPrm:\tzzzz\n"), None);
assert_eq!(permitted_caps_from_status("CapPrm:\n"), None);
}
/// A capability-free host must not append the hint — the message it decorates is also printed
/// on genuinely missing `.desktop` files, and a spurious "you have capabilities" line would
/// send the reader chasing a setcap that was never there. The test process has no capabilities.
#[test]
fn silent_without_capabilities() {
assert_eq!(capability_denial_hint(), "");
}
}
/// Readiness probe: connect to the KWin Wayland socket, roundtrip the registry, and confirm /// Readiness probe: connect to the KWin Wayland socket, roundtrip the registry, and confirm
/// the privileged `zkde_screencast` global is actually advertised. This is exactly what /// the privileged `zkde_screencast` global is actually advertised. This is exactly what
/// [`run`] needs before it can create a virtual output, so a session-bringup script can poll /// [`run`] needs before it can create a virtual output, so a session-bringup script can poll
@@ -1090,7 +1161,8 @@ pub fn probe() -> Result<()> {
it on the host's .desktop X-KDE-Wayland-Interfaces (install \ it on the host's .desktop X-KDE-Wayland-Interfaces (install \
io.unom.Punktfunk.Host.desktop with Exec=/usr/bin/punktfunk-host, then re-login so KWin \ io.unom.Punktfunk.Host.desktop with Exec=/usr/bin/punktfunk-host, then re-login so KWin \
re-reads it the grant is cached per-exe on first connect), or set \ re-reads it the grant is cached per-exe on first connect), or set \
KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 for the headless test; needs KWin 6.5.6" KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 for the headless test; needs KWin 6.5.6{}",
capability_denial_hint()
); );
} }
Ok(()) Ok(())
@@ -1134,7 +1206,9 @@ fn run_existing(
anyhow!( anyhow!(
"KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \ "KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \ .desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless test)" KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless \
test){}",
capability_denial_hint()
) )
})?; })?;
@@ -1223,7 +1297,9 @@ fn run(
anyhow!( anyhow!(
"KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \ "KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \ .desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless test)" KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless \
test){}",
capability_denial_hint()
) )
})?; })?;
+10
View File
@@ -195,6 +195,16 @@ bash /usr/share/punktfunk/bazzite/kde-desktop-setup.sh
Gaming Mode needs none of this — it auto-attaches. Gaming Mode needs none of this — it auto-attaches.
> **Version 0.26.0-1 cannot stream the Desktop, and nothing on this page fixes it.** Every session
> ends with `KWin does not expose zkde_screencast_unstable_v1 to this client`. That image gave the
> host binary the `CAP_SYS_NICE` capability, and Linux will not let KWin read the `/proc/<pid>/exe`
> it uses to work out which program is asking — so the grant above can never match, however
> correctly it is installed. Reinstalling does not help: it looks like a missing `.desktop` file and
> is not one. The image's `/usr` is read-only, so it cannot be repaired on the box — take the next
> image with `sudo punktfunk-sysext update`, then
> `systemctl --user restart punktfunk-host`. Gaming Mode is unaffected; gamescope has no such
> identity check.
## Run as an always-on host ## Run as an always-on host
Bazzite hosts are typically headless. Enable the host service and linger so it starts at boot — see Bazzite hosts are typically headless. Enable the host service and linger so it starts at boot — see
+1 -1
View File
@@ -241,7 +241,7 @@ notes for context.
| `PUNKTFUNK_NVENC_SPLIT_ARBITRATE` | `1` | Opt-in: let the host change its split-encode decision **live**, mid-session, as the pixel rate moves, instead of only choosing once at session start. Currently wired on the Linux direct-NVENC path. Only interesting alongside `PUNKTFUNK_SPLIT_ENCODE=auto` at very high pixel rates. | | `PUNKTFUNK_NVENC_SPLIT_ARBITRATE` | `1` | Opt-in: let the host change its split-encode decision **live**, mid-session, as the pixel rate moves, instead of only choosing once at session start. Currently wired on the Linux direct-NVENC path. Only interesting alongside `PUNKTFUNK_SPLIT_ENCODE=auto` at very high pixel rates. |
| `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` · `auto` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `auto` (starts `high`, upgrades to `realtime` when it's safe — e.g. HAGS off); `high` pins the static pre-gate behaviour; `realtime` is the strongest lever but can freeze NVENC on some setups. | | `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` · `auto` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `auto` (starts `high`, upgrades to `realtime` when it's safe — e.g. HAGS off); `high` pins the static pre-gate behaviour; `realtime` is the strongest lever but can freeze NVENC on some setups. |
| `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. | | `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. |
| `PYROWAVE_QUEUE_PRIORITY` | `realtime` *(default)* · `high` · `off` | [PyroWave](/docs/pyrowave) sessions only. PyroWave encodes on the same GPU shader cores a game uses, so a demanding game can starve it and the frame rate drops. This asks the driver to schedule the encode ahead of the game. `realtime` tries the strongest class and falls back to `high`; `high` asks only for the middle one; `off` disables the request. A driver that refuses simply encodes at normal priority — it can never stop a session starting. The packages grant the host the `CAP_SYS_NICE` capability this needs; on a host built or installed by hand it will be refused, and the host says so once at session start. Set `off` if you see the desktop stutter while streaming. | | `PYROWAVE_QUEUE_PRIORITY` | `realtime` *(default)* · `high` · `off` | [PyroWave](/docs/pyrowave) sessions only. PyroWave encodes on the same GPU shader cores a game uses, so a demanding game can starve it and the frame rate drops. This asks the driver to schedule the encode ahead of the game. `realtime` tries the strongest class and falls back to `high`; `high` asks only for the middle one; `off` disables the request. A driver that refuses simply encodes at normal priority — it can never stop a session starting. This needs the `CAP_SYS_NICE` capability, which the packages deliberately do **not** grant: a host holding a capability cannot be identified by KWin and loses desktop streaming entirely (see [Running as a service](/docs/running-as-a-service#gpu-scheduling-priority)). The request is therefore refused on a stock install and the host says so once at session start. Set `off` if you see the desktop stutter while streaming. |
## Diagnostics ## Diagnostics
+26 -18
View File
@@ -207,33 +207,41 @@ a Windows host, run `punktfunk-host service status` from an elevated prompt on t
## GPU scheduling priority ## GPU scheduling priority
The Linux packages give the host binary one Linux capability, `CAP_SYS_NICE`, and it is worth The host binary carries **no Linux capability**, and on a KDE desktop it must not.
knowing why it is there and how to take it away.
The [PyroWave](/docs/pyrowave) codec encodes on the same GPU shader cores your game is using, so a The [PyroWave](/docs/pyrowave) codec encodes on the same GPU shader cores your game is using, so a
demanding game can crowd it out and the stream's frame rate drops with it. The fix is to ask the demanding game can crowd it out and the stream's frame rate drops with it. The fix is to ask the
driver to schedule the encode ahead of the game, and every driver we tested gates that request on driver to schedule the encode ahead of the game, and every driver we tested gates that request on
this capability: without it the request is simply refused and nothing changes. The other codecs use `CAP_SYS_NICE`. Version 0.26.0-1 granted it for that reason — and it broke desktop streaming on
a separate video engine on the GPU and are unaffected either way. every KDE box, so 0.26.0-2 takes it away again. The other codecs use a separate video engine on the
GPU and were never affected.
`CAP_SYS_NICE` lets a process raise its own scheduling priority. It grants no access to files, The two cannot coexist. To hand the host its virtual display, KWin first has to work out *which*
the network or other users' processes, and it is **not** the same as running as root — the host program is asking, which it does by reading the connecting process's `/proc/<pid>/exe` and matching
still runs as you, under your user session. it against the `.desktop` file the packages install. Linux refuses that read for any process holding
a capability the reader does not also hold — and KWin holds none. So a host with `CAP_SYS_NICE` is a
host KWin cannot identify, and every session fails with:
To check, or to take it away: ```
KWin virtual output failed: KWin does not expose zkde_screencast_unstable_v1 to this client
```sh
getcap /usr/bin/punktfunk-host # shows cap_sys_nice=ep when granted
sudo setcap -r /usr/bin/punktfunk-host # remove it; streaming still works
``` ```
Removing it costs you nothing unless you stream PyroWave, and you can also just set which looks exactly like a missing `.desktop` file and cannot be fixed by reinstalling. Moving the
`PYROWAVE_QUEUE_PRIORITY=off` to stop the host asking. Note that a package update replaces the grant into the systemd unit does not help either — same capability, same refused read.
binary and re-applies the capability.
Two side effects, if you are debugging the host: a binary carrying a capability is treated as If you are on 0.26.0-1, update. On the Bazzite image the `/usr` is read-only, so the only repair is
security-sensitive by the dynamic loader, so `LD_LIBRARY_PATH` and `LD_PRELOAD` are ignored for it, the next image (`sudo punktfunk-sysext update`). Elsewhere you can clear it by hand:
and it does not write core dumps by default.
```sh
getcap /usr/bin/punktfunk-host # prints nothing when correct
sudo setcap -r /usr/bin/punktfunk-host # clear it, then restart the host
```
Losing the capability costs frame pacing under a GPU-bound game and nothing else — the host asks for
the elevated priority, is refused, and encodes at the normal one. `PYROWAVE_QUEUE_PRIORITY=off`
stops it asking at all. If you stream only with gamescope (Steam Gaming Mode) you can grant the
capability yourself and keep the pacing, at the cost of desktop streaming; gamescope has no such
identity check.
## Stopping and removing ## Stopping and removing
+4 -2
View File
@@ -173,8 +173,10 @@ systemctl --user enable --now punktfunk-host # the user unit is now under /u
``` ```
The udev rule, sysctl, and systemd **user** unit all live under `/usr/lib`, so the merged sysext The udev rule, sysctl, and systemd **user** unit all live under `/usr/lib`, so the merged sysext
exposes them. `systemd-sysext refresh` re-merges after a reboot. (One HDR nuance of the sysext exposes them. `systemd-sysext refresh` re-merges after a reboot. (One HDR nuance of the sysext
path: file capabilities don't survive it, so gamescope runs without `CAP_SYS_NICE` — everything path: the image ships gamescope without `CAP_SYS_NICE`, so its frame pacing is marginally worse —
works, frame pacing is marginally worse than the pacman install, whose `.install` sets the cap.) everything works. Note the host binary carries no capability on *either* path, deliberately: one
would make the host unidentifiable to KWin and break desktop streaming, see
[Running as a service](https://punktfunk.io/docs/running-as-a-service#gpu-scheduling-priority).)
## Steam Deck — the client (what the Decky plugin launches) ## Steam Deck — the client (what the Decky plugin launches)
+11 -3
View File
@@ -14,9 +14,17 @@
# instead of 8-bit SDR (the host prefers that name on PATH and attempts HDR by default). Mirrors # instead of 8-bit SDR (the host prefers that name on PATH and attempts HDR by default). Mirrors
# the Bazzite image's fold-in, including the honesty check: the binary is verified by executing # the Bazzite image's fold-in, including the honesty check: the binary is verified by executing
# its `+pfhdr` banner, never trusted by filename. Omit it and the image is exactly what it was — # its `+pfhdr` banner, never trusted by filename. Omit it and the image is exactly what it was —
# the host then stays SDR on that backend, by design. (No CAP_SYS_NICE inside the image: file # the host then stays SDR on that backend, by design.
# capabilities don't survive this squashfs path — gamescope runs without it, pacing slightly #
# worse, same as the Bazzite sysext.) # No CAP_SYS_NICE inside the image, for either binary. ⚠ NOT because capabilities are lost on the
# way in — that was this comment's earlier claim and it is false: mksquashfs records
# security.capability, and the published Bazzite 0.26.0-1 image really did carry `cap_sys_nice=ep`
# on usr/bin/punktfunk-host. It is left out on purpose. A capability on the HOST binary makes it
# unidentifiable to KWin (which resolves a client's /proc/<pid>/exe to match it against a .desktop,
# and cannot read it for a capability-carrying process) and kills every Desktop-mode session — see
# packaging/bazzite/build-sysext.sh, which now hard-fails if one is staged. `punktfunk-gamescope`
# is a compositor, not a KWin client, so it is unaffected by that rule and simply runs without the
# capability here, pacing slightly worse.
set -euo pipefail set -euo pipefail
GAMESCOPE="" GAMESCOPE=""
+35 -20
View File
@@ -12,33 +12,48 @@ _ensure_punktfunk_group() {
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || true getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || true
} }
# CAP_SYS_NICE on the host binary — the GPU-scheduling grant. # NO capability on the host binary — and an active removal of the one 0.26.0-1 granted.
# #
# WHY: PyroWave encodes on the GPU's shader cores, so a GPU-bound game starves it (measured: the # 0.26.0-1 ran `setcap cap_sys_nice=ep` here, to let the encoder open an elevated global-priority
# encode dispatch goes from ~2 ms to 15-18 ms at 95 % game load). The fix is an elevated # Vulkan queue (PyroWave shares the GPU's shader cores with the game; measured 2026-08-08 on an
# global-priority Vulkan queue, which the driver gates on CAP_SYS_NICE — measured 2026-08-08 on an # RTX 5070 Ti, the encode dispatch goes ~2 ms -> 15-18 ms at 95 % game load without it). That grant
# RTX 5070 Ti: WITHOUT the capability every priority class is refused, WITH it the encoder is # BROKE DESKTOP STREAMING ON EVERY KDE BOX, and it cannot be made to work — the two are mutually
# granted REALTIME on the first attempt. RADV is the same. Without this line the knob exists and # exclusive at the kernel level:
# does nothing. Same capability, same mechanism, as our gamescope package sets on its own binary.
# #
# NARROW: CAP_SYS_NICE only permits raising scheduling priority (nice/ioprio/affinity/RT class). It # KWin hands out its restricted Wayland protocols (zkde_screencast_unstable_v1, which mints our
# grants no filesystem, network or user-switching privilege, and it is NOT setuid. # virtual output, and org_kde_kwin_fake_input, which injects input) only to a client it can
# IDENTIFY, by resolving that client's /proc/<pid>/exe and matching it against an installed
# .desktop's Exec= (ours is io.unom.Punktfunk.Host.desktop). The kernel refuses that readlink to
# any reader whose effective set is not a superset of the target's PERMITTED set
# (cap_ptrace_access_check), and KWin holds no capabilities. So the moment this binary carries a
# capability it becomes unidentifiable: KWin's executablePath() is empty, nothing matches, the
# globals are never advertised, and every session dies with
# "KWin does not expose zkde_screencast_unstable_v1 to this client" after 8 retries — while
# looking exactly like a missing or wrong .desktop file.
# #
# TWO CONSEQUENCES worth knowing before you debug something odd on this host: # Verified on CachyOS (kernel 7.1.6), same-uid reader, cap_sys_nice=ep on the target:
# * a file capability makes the process AT_SECURE, so the dynamic loader IGNORES LD_LIBRARY_PATH # no capability .............................. readlink /proc/<pid>/exe OK
# and LD_PRELOAD for it. A library-path shim that used to work will silently stop. # capability ................................. EPERM
# * core dumps are suppressed for capability-carrying binaries by default (fs.suid_dumpable). # capability + prctl(PR_SET_DUMPABLE, 1) ..... EPERM <- dumpable is NOT the gate
# capability dropped + PR_SET_DUMPABLE(1) .... OK <- only a capability-free process works
# #
# Never fails the install: a box without libcap, or a filesystem that cannot store capabilities # The third row also rules out the obvious "move it to the systemd unit": AmbientCapabilities= puts
# (some overlay/NFS setups), just runs at default priority exactly as before. # CAP_SYS_NICE in exactly the same permitted set and fails identically. Nothing short of not having
_grant_sched_capability() { # the capability restores identification, so the host does not get one. The encoder already walks
setcap 'cap_sys_nice=ep' usr/bin/punktfunk-host 2>/dev/null || true # REALTIME -> HIGH -> default when the class is refused (pf-zerocopy vulkan.rs), so this costs
# pacing under a GPU-bound game and nothing else — 0.25.0's behaviour exactly.
#
# The removal below heals boxes that ran 0.26.0-1's scriptlet. A pacman upgrade writes a new inode
# and file capabilities do not survive that, so this is belt-and-braces for reinstall/downgrade
# paths — cheap, and the failure it prevents is an 8-retry session death with a misleading message.
_revoke_sched_capability() {
setcap -r usr/bin/punktfunk-host 2>/dev/null || true
} }
post_install() { post_install() {
_ensure_update_group _ensure_update_group
_ensure_punktfunk_group _ensure_punktfunk_group
_grant_sched_capability _revoke_sched_capability
udevadm control --reload-rules 2>/dev/null || true udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=misc 2>/dev/null || true udevadm trigger --subsystem-match=misc 2>/dev/null || true
# Apply the UDP socket-buffer tuning now (also auto-applied at boot by systemd-sysctl). # Apply the UDP socket-buffer tuning now (also auto-applied at boot by systemd-sysctl).
@@ -97,8 +112,8 @@ post_upgrade() {
# root-only, and the virtual Steam Deck pad silently unable to attach. groupadd is idempotent, so # root-only, and the virtual Steam Deck pad silently unable to attach. groupadd is idempotent, so
# this is a no-op on boxes that installed fresh. # this is a no-op on boxes that installed fresh.
_ensure_punktfunk_group _ensure_punktfunk_group
# A replaced binary is a NEW inode — file capabilities do not survive the upgrade, so re-grant. # Strip the cap_sys_nice 0.26.0-1 granted: it makes the host unidentifiable to KWin (see above).
_grant_sched_capability _revoke_sched_capability
udevadm control --reload-rules 2>/dev/null || true udevadm control --reload-rules 2>/dev/null || true
sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
_warn_stale_firewall_ports _warn_stale_firewall_ports
+17 -5
View File
@@ -421,11 +421,23 @@ bash /usr/share/punktfunk/bazzite/kde-desktop-setup.sh
# then log out + back into the KDE Desktop session once (or reboot) so KWin restarts with the flag # then log out + back into the KDE Desktop session once (or reboot) so KWin restarts with the flag
``` ```
That writes `~/.config/environment.d/10-punktfunk-kwin.conf` That seeds the `kde-authorized` RemoteDesktop grant into `~/.local/share/flatpak/db/` — the input
(`KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`) and seeds the `kde-authorized` RemoteDesktop grant into half. The **video** half needs no session-wide override: the image ships
`~/.local/share/flatpak/db/`. Gaming Mode is unaffected. To connect from Desktop Mode, switch to it `io.unom.Punktfunk.Host.desktop`, whose `X-KDE-Wayland-Interfaces` grants the host KWin's
(Steam → Power → Switch to Desktop), then connect the client; switching **mid-stream** requires a `zkde_screencast` protocol on a normal Plasma login (least-privilege — only this binary, only that
reconnect (the host resolves the backend per connect). interface). Older versions of the script wrote a session-wide
`KWIN_WAYLAND_NO_PERMISSION_CHECKS=1` into `~/.config/environment.d/10-punktfunk-kwin.conf`; it now
*removes* that file as an over-broad leftover. Gaming Mode is unaffected. To connect from Desktop
Mode, switch to it (Steam → Power → Switch to Desktop), then connect the client; switching
**mid-stream** requires a reconnect (the host resolves the backend per connect).
> **On 0.26.0-1 specifically, Desktop mode is broken and no amount of this setup fixes it.** That
> image shipped `cap_sys_nice=ep` on `/usr/bin/punktfunk-host`, and a capability-carrying process is
> one KWin cannot identify (it resolves `/proc/<pid>/exe` to match the `.desktop`, and the kernel
> refuses that read), so the session dies with `KWin does not expose zkde_screencast_unstable_v1 to
> this client`. A merged sysext's `/usr` is read-only, so it cannot be repaired in place — take the
> next image (`sudo punktfunk-sysext update`). `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1` works around it
> meanwhile by disabling the check that needs the identification.
--- ---
+27 -20
View File
@@ -130,28 +130,35 @@ SYSEXT_VERSION_ID=$PF_VR
EXTENSION_RELOAD_MANAGER=1 EXTENSION_RELOAD_MANAGER=1
EOF EOF
# CAP_SYS_NICE on the host binary — the GPU-scheduling grant. PyroWave encodes on the GPU shader # NO CAP_SYS_NICE in the image — and an assertion that none crept back in.
# cores a game saturates, and the driver gates the elevated global-priority Vulkan queue that fixes
# it on this capability (measured 2026-08-08 on an RTX 5070 Ti: refused without it, granted REALTIME
# with it; RADV the same). Narrow — scheduling priority only, no filesystem/network privilege, not
# setuid.
# #
# It has to be applied HERE, not in the merge hook: a merged sysext's /usr is a read-only squashfs, # 0.26.0-1 setcap'd the staged binary here for the GPU-priority lever. mksquashfs records
# so nothing can setcap it afterwards. And it cannot ride in from the RPM either — the spec declares # security.capability, so the capability really did ship: verified by mounting the published
# it with %caps, but rpm stores capabilities in its own header and `rpm2cpio | cpio` carries only # punktfunk-0.26.0-1-x86-64.raw, where `getcap usr/bin/punktfunk-host` reports `cap_sys_nice=ep`.
# the payload, so the staged file arrives with no capability at all. mksquashfs DOES record # That broke desktop streaming on every Bazzite KDE box, field-reported as
# security.capability (only security.selinux is excluded below), so a setcap on the staging tree is # "KWin does not expose zkde_screencast_unstable_v1 to this client".
# what ends up in the image.
# #
# Needs CAP_SETFCAP, i.e. root (or fakeroot) — a plain-user CI build cannot do it. That is not fatal: # KWin advertises its restricted protocols (zkde_screencast_unstable_v1 for the virtual output,
# the image just ships as it does today and the encode runs at default GPU priority, so warn and # org_kde_kwin_fake_input for input) only to a client it can IDENTIFY, by resolving that client's
# carry on rather than fail a release build over a performance lever. # /proc/<pid>/exe and matching it against an installed .desktop's Exec= — the image ships
if [ -f "$STAGE/usr/bin/punktfunk-host" ]; then # usr/share/applications/io.unom.Punktfunk.Host.desktop for exactly that. The kernel refuses that
if setcap 'cap_sys_nice=ep' "$STAGE/usr/bin/punktfunk-host" 2>/dev/null; then # readlink to any reader whose effective set is not a superset of the target's PERMITTED set
echo "granted CAP_SYS_NICE to usr/bin/punktfunk-host (GPU-priority lever active)" # (cap_ptrace_access_check), and KWin holds no capabilities. So a capability in this image makes the
else # host unidentifiable and every Desktop-mode session dies. Full matrix, including why neither
echo "WARNING: could not setcap CAP_SYS_NICE (need root/CAP_SETFCAP) — the image will ship" >&2 # prctl(PR_SET_DUMPABLE, 1) nor systemd AmbientCapabilities= rescues it, in
echo " without it and PyroWave will encode at default GPU priority." >&2 # packaging/arch/punktfunk-host.install.
#
# A merged sysext's /usr is a read-only squashfs, so this cannot be repaired on the box — the image
# is the only place it can be got right. Assert it rather than trust it: the RPM payload arrives via
# `rpm2cpio | cpio`, which carries no capabilities today, but the spec is one `%caps()` away from
# changing that and this build would silently bake it in.
if [ -f "$STAGE/usr/bin/punktfunk-host" ] && command -v getcap >/dev/null 2>&1; then
staged_caps="$(getcap "$STAGE/usr/bin/punktfunk-host" 2>/dev/null || true)"
if [ -n "$staged_caps" ]; then
echo "ERROR: staged usr/bin/punktfunk-host carries capabilities: $staged_caps" >&2
echo " A capability makes the host unidentifiable to KWin and breaks every Desktop-mode" >&2
echo " session on a merged image, which cannot be repaired on the box (read-only /usr)." >&2
exit 1
fi fi
fi fi
+17 -10
View File
@@ -294,16 +294,23 @@ if [ "$1" = "configure" ]; then
# primitive that must not ride on the group users are told to join for gamepads # primitive that must not ride on the group users are told to join for gamepads
# (security-review 2026-08-05 M-4). # (security-review 2026-08-05 M-4).
getent group punktfunk >/dev/null 2>&1 || addgroup --system punktfunk 2>/dev/null || true getent group punktfunk >/dev/null 2>&1 || addgroup --system punktfunk 2>/dev/null || true
# CAP_SYS_NICE — the GPU-scheduling grant. PyroWave encodes on the shader cores a game # NO capability on the host binary — and an active removal of the one 0.26.0-1 granted here.
# saturates, and the driver gates the elevated global-priority Vulkan queue that fixes it on #
# this capability: measured 2026-08-08 on an RTX 5070 Ti, WITHOUT it every priority class is # 0.26.0-1 ran `setcap cap_sys_nice=ep` at this point for the GPU-priority lever, and that broke
# refused and WITH it the encoder is granted REALTIME first try (RADV behaves the same). # desktop streaming on every KDE box. KWin advertises its restricted protocols
# Without this line the knob exists and does nothing. Narrow: it permits raising scheduling # (zkde_screencast_unstable_v1 for the virtual output, org_kde_kwin_fake_input for input) only
# priority only — no filesystem, network or user-switching privilege, and no setuid. Note a # to a client it can IDENTIFY, by resolving that client's /proc/<pid>/exe and matching it
# capability-carrying binary is AT_SECURE, so the loader ignores LD_LIBRARY_PATH/LD_PRELOAD for # against an installed .desktop's Exec=. The kernel refuses that readlink to any reader whose
# it and core dumps are suppressed by default. Best-effort: a box without libcap, or a # effective set is not a superset of the target's PERMITTED set (cap_ptrace_access_check), and
# filesystem that cannot store capabilities, just runs at default priority as before. # KWin holds no capabilities — so a capability here makes the host unidentifiable and the
setcap 'cap_sys_nice=ep' /usr/bin/punktfunk-host 2>/dev/null || true # session dies with "KWin does not expose zkde_screencast_unstable_v1 to this client". Full
# matrix (and why PR_SET_DUMPABLE and AmbientCapabilities= both fail to rescue it) in
# packaging/arch/punktfunk-host.install.
#
# Costs pacing only: pf-zerocopy walks REALTIME -> HIGH -> default when a class is refused.
# postinst runs on upgrade too, so this heals boxes that installed 0.26.0-1. `setcap -r` exits
# non-zero on a file that has no capability, hence the redirect and `|| true`.
setcap -r /usr/bin/punktfunk-host 2>/dev/null || true
# Pick up the /dev/uinput rule without a reboot (best-effort, no-op in containers). # Pick up the /dev/uinput rule without a reboot (best-effort, no-op in containers).
udevadm control --reload-rules 2>/dev/null || true udevadm control --reload-rules 2>/dev/null || true
udevadm trigger --subsystem-match=misc 2>/dev/null || true udevadm trigger --subsystem-match=misc 2>/dev/null || true
+22 -22
View File
@@ -356,25 +356,25 @@ in
allowedUDPPorts = nativeUDP ++ optionals cfg.host.gamestream gamestreamUDP; allowedUDPPorts = nativeUDP ++ optionals cfg.host.gamestream gamestreamUDP;
}; };
# CAP_SYS_NICE — the GPU-scheduling grant. PyroWave encodes on the GPU shader cores a game # NO CAP_SYS_NICE wrapper here — deliberately. 0.26.0-1 gave the host a
# saturates; the elevated global-priority Vulkan queue that fixes it is gated on this # `security.wrappers.punktfunk-host` carrying `cap_sys_nice=ep` for the GPU-priority lever,
# capability (measured 2026-08-08, RTX 5070 Ti: without it EVERY priority class is refused, # and that broke desktop streaming on every KDE box.
# with it the encoder gets REALTIME on the first attempt; RADV behaves the same).
# #
# NixOS cannot `setcap` a store path — it is read-only and shared — so this goes through # KWin advertises its restricted Wayland protocols (zkde_screencast_unstable_v1 for the
# `security.wrappers`, which builds a small setcap'd wrapper in /run/wrappers/bin. The unit's # virtual output, org_kde_kwin_fake_input for input) only to a client it can IDENTIFY, by
# ExecStart points at the wrapper below; everything else about the host is unchanged. # resolving that client's /proc/<pid>/exe and matching it against an installed .desktop's
# Exec= (packages.nix substitutes ours to the store path). The kernel refuses that readlink to
# any reader whose effective set is not a superset of the target's PERMITTED set
# (cap_ptrace_access_check), and KWin holds no capabilities.
# #
# Narrow: CAP_SYS_NICE permits raising scheduling priority only — no filesystem, network or # A NixOS wrapper does not dodge this. It raises the capability into its AMBIENT set before
# user-switching privilege, and the wrapper is capability-based, NOT setuid. Two side effects # exec'ing the store binary, precisely so the capability survives — which lands CAP_SYS_NICE
# to know: the wrapped binary is AT_SECURE (the loader ignores LD_LIBRARY_PATH/LD_PRELOAD for # in the exec'd process's permitted set and fails the readlink identically. Measured: an
# it) and core dumps are suppressed by default. # ambient-only grant (dumpable=1, CapPrm set) is refused exactly like a file capability. See
security.wrappers.punktfunk-host = { # packaging/arch/punktfunk-host.install for the full matrix.
source = "${cfg.host.package}/bin/punktfunk-host"; #
capabilities = "cap_sys_nice=ep"; # Costs pacing only: pf-zerocopy walks REALTIME -> HIGH -> default when a priority class is
owner = "root"; # refused, and pf-frame's thread nice is a best-effort no-op — 0.25.0's behaviour exactly.
group = "root";
};
systemd.user.services.punktfunk-host = { systemd.user.services.punktfunk-host = {
description = "punktfunk GameStream + punktfunk/1 streaming host"; description = "punktfunk GameStream + punktfunk/1 streaming host";
@@ -394,12 +394,12 @@ in
# PUNKTFUNK_GAMESCOPE_BIN so an operator's own override of that env still wins. # PUNKTFUNK_GAMESCOPE_BIN so an operator's own override of that env still wins.
++ optional cfg.host.gamescopeHdr cfg.host.gamescopePackage; ++ optional cfg.host.gamescopeHdr cfg.host.gamescopePackage;
serviceConfig = { serviceConfig = {
# Through the wrapper (see `security.wrappers.punktfunk-host` above), NOT the store path # The store path DIRECTLY — not a capability wrapper. /proc/<pid>/exe then resolves to the
# directly — the store path carries no capability and the GPU-priority lever would be # very path packages.nix substituted into io.unom.Punktfunk.Host.desktop's Exec=, which is
# inert. `config.security.wrapperDir` rather than a hard-coded /run/wrappers/bin so an # what lets KWin identify the host and grant it the screencast/fake-input protocols (see
# operator who has moved it is still correct. # the note above the firewall block).
ExecStart = ExecStart =
"${config.security.wrapperDir}/punktfunk-host serve" + optionalString cfg.host.gamestream " --gamestream"; "${cfg.host.package}/bin/punktfunk-host serve" + optionalString cfg.host.gamestream " --gamestream";
Restart = "on-failure"; Restart = "on-failure";
RestartSec = 2; RestartSec = 2;
EnvironmentFile = EnvironmentFile =
+22 -9
View File
@@ -477,15 +477,28 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
%files %files
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt %license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
%doc README.md packaging/README.md %doc README.md packaging/README.md
# CAP_SYS_NICE — the GPU-scheduling grant, declared the RPM-native way so rpm applies it at # NO %caps() on the host binary. 0.26.0-1 declared `%caps(cap_sys_nice=ep)` here for the
# install, restores it on upgrade, and VERIFIES it (a plain %post setcap does none of those). # GPU-priority lever and that BROKE DESKTOP STREAMING ON EVERY KDE BOX — on Fedora and, via
# PyroWave encodes on the shader cores a game saturates; the elevated global-priority Vulkan queue # rpm-ostree layering, on Bazzite, where it was field-reported as
# that fixes it is gated on this capability. Measured 2026-08-08 on an RTX 5070 Ti: without it # "KWin does not expose zkde_screencast_unstable_v1 to this client".
# every priority class is refused, with it the encoder gets REALTIME first try (RADV the same). #
# Narrow — scheduling priority only, no filesystem/network/user-switching privilege, not setuid. # KWin hands out its restricted Wayland protocols (zkde_screencast_unstable_v1 for the virtual
# Consequences: the binary becomes AT_SECURE, so the loader ignores LD_LIBRARY_PATH/LD_PRELOAD for # output, org_kde_kwin_fake_input for input) only to a client it can IDENTIFY, by resolving that
# it, and core dumps are suppressed by default. # client's /proc/<pid>/exe and matching it against an installed .desktop's Exec= — ours is the
%caps(cap_sys_nice=ep) %{_bindir}/punktfunk-host # io.unom.Punktfunk.Host.desktop installed below. The kernel refuses that readlink to any reader
# whose effective set is not a superset of the target's PERMITTED set (cap_ptrace_access_check),
# and KWin holds no capabilities. So a capability here makes the host unidentifiable: KWin's
# executablePath() is empty, no .desktop can match, and the globals are never advertised.
# Measured on kernel 7.1.6 — see packaging/arch/punktfunk-host.install for the full matrix, incl.
# why neither prctl(PR_SET_DUMPABLE, 1) nor systemd AmbientCapabilities= rescues it.
#
# The cost of not having it is pacing only: pf-zerocopy walks REALTIME -> HIGH -> default when a
# priority class is refused, and pf-frame's thread nice is a best-effort no-op. That is exactly
# how 0.25.0 behaved, which is the behaviour that worked.
#
# rpm applies file capabilities from package metadata, so a package built WITHOUT %caps() installs
# the binary with none and an upgrade from 0.26.0-1 clears it — no scriptlet needed.
%{_bindir}/punktfunk-host
%{_bindir}/punktfunk-tray %{_bindir}/punktfunk-tray
%{_udevrulesdir}/60-punktfunk.rules %{_udevrulesdir}/60-punktfunk.rules
%dir %{_libexecdir}/punktfunk %dir %{_libexecdir}/punktfunk
+14 -15
View File
@@ -344,24 +344,23 @@ if [ "$SUDO_OK" = 1 ]; then
warn "(everything else works; the pad arrives as a generic Xbox 360 controller). By hand:" warn "(everything else works; the pad arrives as a generic Xbox 360 controller). By hand:"
warn " sudo groupadd --system punktfunk; sudo usermod -aG punktfunk $USER" warn " sudo groupadd --system punktfunk; sudo usermod -aG punktfunk $USER"
fi fi
# CAP_SYS_NICE on the host binary — the GPU-scheduling grant, and the Deck is the box that # NO CAP_SYS_NICE on the host binary — and a removal of the one 0.26.0-1 granted here.
# needs it most: a Van Gogh APU shares one small GPU between the game and PyroWave's encode
# dispatch. The driver gates the elevated global-priority Vulkan queue on this capability
# (measured 2026-08-08 on an RTX 5070 Ti: refused without it, granted REALTIME with it; RADV
# behaves the same), so without this the knob exists and does nothing.
# #
# The binary lives under $HOME, not /usr — so unlike the /etc drop-ins above this survives a # 0.26.0-1 setcap'd this binary for the GPU-priority lever, which on a Van Gogh APU is a real
# SteamOS A/B update on its own and needs no atomic-keep entry. It DOES need re-applying after # win. It also broke Desktop-mode streaming outright. Just above, this installer writes
# every rebuild, because a fresh binary is a new inode; re-running this installer does that. # ~/.local/share/applications/io.unom.Punktfunk.Host.desktop with Exec=$BIN so KWin will grant
# the host its restricted protocols — and KWin makes that grant by resolving the client's
# /proc/<pid>/exe and matching it against that Exec=. The kernel refuses that readlink to any
# reader whose effective set is not a superset of the target's PERMITTED set
# (cap_ptrace_access_check), and KWin holds no capabilities. So the capability silently voided
# the .desktop written six lines earlier, and every Desktop-mode session died with
# "KWin does not expose zkde_screencast_unstable_v1 to this client". Gaming Mode (gamescope) is
# unaffected — it has no such gate. Full matrix in packaging/arch/punktfunk-host.install.
# #
# Narrow (scheduling priority only, no filesystem/network privilege, not setuid) and # Costs pacing only: pf-zerocopy walks REALTIME -> HIGH -> default when a class is refused.
# best-effort — a failure just means the encode runs at default priority as it does today. # `setcap -r` exits non-zero on a file that has no capability, hence the redirect.
if [ -x "$BIN" ]; then if [ -x "$BIN" ]; then
if sudo setcap 'cap_sys_nice=ep' "$BIN" 2>/dev/null; then sudo setcap -r "$BIN" 2>/dev/null || true
ok "granted CAP_SYS_NICE (PyroWave encode can outrank a GPU-bound game)"
else
warn "could not grant CAP_SYS_NICE to $BIN — PyroWave encode stays at default GPU priority"
fi
fi fi
# SteamOS A/B updates rebuild /etc and DROP everything not on Valve's keep list — verified # SteamOS A/B updates rebuild /etc and DROP everything not on Valve's keep list — verified
# live: an OS update stripped the udev rule + vhci autoload + UDP sysctl (gamepads silently # live: an OS update stripped the udev rule + vhci autoload + UDP sysctl (gamepads silently