Client-initiated launches (Hello.launch / GameStream applist) were only
wired to gamescope's bare-spawn path via the process-global
PUNKTFUNK_GAMESCOPE_APP env — which leaked across sessions, was never
read by kwin/mutter/wlroots (launch was a silent no-op there), and was
unreachable on gamescope anyway because apply_input_env unconditionally
defaulted to the managed session (which bails on non-Bazzite/SteamOS
boxes and ignores the launch command in all its modes).
- Thread the launch per-session: resolve the library id at handshake,
carry it on SessionContext (Windows: id; else: resolved command), and
hand it to the backend instance via set_launch_command — the global
env write is gone (the env stays as an operator fallback in spawn).
- Gamescope sub-mode ladder (pick_gamescope_mode, pure + unit-tested):
managed only when session-plus/SteamOS infra exists, attach for an
explicit request or a foreign (non-host-descendant) gamescope, else
bare spawn — which nests the launch and is now reachable on plain
distros instead of the guaranteed managed-mode bail.
- launch_session_command: one launch entry point for both planes once
capture is live — desktop compositors plain-spawn into the retargeted
session (the virtual output is primary); managed/attached gamescope
spawns with the live session's DISPLAY/GAMESCOPE_WAYLAND_DISPLAY
discovered from /proc (steam:// URIs also forward over Steam's own
pipe). launch_is_nested gates bare spawn against double-launching.
- GameStream unified onto the same dispatch; also nests library-id
picks into gamescope (previously only apps.json cmd was nested).
Validated live on the dev box up to the missing-GPU wall: handshake
resolution, Spawn sub-mode on plain Ubuntu, gamescope spawned with the
command nested. On-glass validation (kwin spawn on the streamed output,
Bazzite/Deck managed forward) pending GPU reattach.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remediations from design/security-review-2026-06-28.md verified on Linux
(cargo check/clippy/test green; Windows-gated paths verify in CI):
- S1 [HIGH]: bump quinn-proto 0.11.14 -> 0.11.15 (RUSTSEC-2026-0185,
pre-auth out-of-order STREAM reassembly memory exhaustion on the
always-on default QUIC listener).
- #1 [HIGH]: remove the unauthenticated nvhttp `GET /pin` endpoint; the
GameStream PIN is delivered ONLY via the bearer-gated mgmt API, so a
network client can no longer submit its own displayed PIN and self-pair.
- #4 [HIGH->MED]: gate the unauthenticated RTSP/UDP media plane on a paired
`/launch` and bind it to the launching client's source IP (threaded
through the HTTPS handler), so an unpaired peer can neither start capture
on an idle host nor ride a paired client's active launch.
- #12: bound concurrent parked pairing waiters (MAX_PARKED_WAITERS) so a
pre-auth peer can't pin unbounded 300s handshakes. +regression test.
- #10: throttle the per-packet ENet control GCM-decrypt-failed warn
(exponential backoff) so a junk flood can't spam the log.
- #7 [MED->LOW]: serialize all process-global env mutation on the
session-setup path under a new vdisplay::ENV_LOCK (apply_session_env /
apply_input_env / the launch-cmd set_var / the gamescope env read), so
concurrent native sessions can't race set_var/getenv (data-race UB ->
host-wide DoS). Full per-session SessionContext threading remains a
follow-up for cross-session value confusion.
- #6 [MED]: move the gamescope EIS socket relay from world-writable /tmp to
$XDG_RUNTIME_DIR (per-user 0700) and reject a symlinked relay file, so a
local user can't intercept (keylog) or deny the remote session's input.
- S2: a malformed client Opus mic frame now drops that frame instead of
tearing down the shared host-lifetime virtual mic (cross-session DoS).
- S3: track held buttons/keys in capped HashSets (was unbounded Vec with
O(n) scans) so a paired client can't grow per-session input state.
- S5: reject fps==0/absurd at the open_video chokepoint (covers Hello,
ANNOUNCE, Reconfigure) so the encoder time_base/pts math can't div-by-0.
- S6: bound the shared mic mpsc (drop-newest when full).
- S4: cap Epic launcher-cache reads (catcache.bin/.item) so a planted giant
can't OOM the host during library enumeration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make Steam game mode work on a display-less streaming host and stream it at the
client's resolution:
* Ship /etc/gamescope-session-plus/sessions.d/steam (packaging/bazzite/
gamescope-headless-session, installed by the RPM + Arch PKGBUILD): fall back to
gamescope's headless backend when no display is connected, so "Switch to Game
Mode" boots offscreen instead of crashing on the missing panel (and 5-striking
back to desktop). No-op on display-attached boxes; only sets unset values so
the host's per-client mode still wins.
* Default Bazzite/SteamOS to ATTACH (PUNKTFUNK_GAMESCOPE_ATTACH=1 in host.env):
the box owns its session (Desktop<->Game, persistent), the host follows +
captures it and never tears it down — so switching is rock-solid and a
disconnect leaves the box in its mode (reconnect returns there).
* Resize-on-attach (gamescope.rs): on connect, ensure the box's own game-mode
session runs at the CLIENT's resolution — reuse it when already matching (fast
path, no restart), else reconfigure + restart the box's own autologin
gamescope-session-plus@<client> at the client mode (cooperative: no competing
unit, so no autologin-respawn fight). Detect the live gamescope's -W/-H via
argv[0] in /proc (its /proc/<pid>/exe is unreadable for that process).
Validated live on a headless bazzite-deck-nvidia box: game mode boots headless +
stable (0 strikes); the host attaches + streams video/audio/EIS input; a
5120x1440 client reuses the matching session and streams at 5120x1440.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Streaming the KDE *Desktop* (KWin) session failed on a real interactive
Plasma session with "KWin does not expose zkde_screencast_unstable_v1":
KWin treats the screencast/virtual-output and fake_input globals as
restricted and advertises them only to a client whose installed .desktop
lists them under X-KDE-Wayland-Interfaces (matched by /proc/<pid>/exe ->
Exec, and cached per-executable on first connect). The host shipped no
.desktop, so it was permanently denied; it only ever worked on the
headless dev box via KWIN_WAYLAND_NO_PERMISSION_CHECKS=1.
Ship packaging/linux/io.unom.Punktfunk.Host.desktop (least-privilege:
only the host, only zkde_screencast_unstable_v1 + org_kde_kwin_fake_input)
and install it from the RPM/.deb/Arch host packaging so it is present
before the host first connects. Drop the blunt session-wide
NO_PERMISSION_CHECKS hack from kde-desktop-setup.sh (it now only seeds the
RemoteDesktop input grant) and fix the now-misleading kwin.rs docs/errors.
Validated live on a Bazzite Kinoite box (KWin 6.6.4): probe-compositor +
spike --source kwin-virtual succeed against a KWin running WITHOUT the
permission bypass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Continues the structural unsafe-proof program (every unsafe carries a documented
proof of soundness; the file gains #![deny(clippy::undocumented_unsafe_blocks)]
so it stays proven). This batch covers all 10 remaining pure-Linux files
(104 blocks), each proof stating the REAL invariant — not boilerplate:
zerocopy/cuda.rs (26) leaked process-lifetime libcuda fn-ptr table; opaque
CUcontext never dereferenced; free-exactly-once via the
Arc<Mutex<PoolInner>> ownership graph; dmabuf fd take/close split
zerocopy/egl.rs (18) eglGetProcAddress'd procs with the GL context current;
EGLImage liveness; the two-call modifier-query bounds
zerocopy/vulkan.rs (4) copy-bounds arithmetic (src_size>=span); Send = thread
confinement to the punktfunk-pipewire thread
dmabuf_fence.rs (4) poll/ioctl/close fd liveness + ownership
capture/linux/mod.rs (16) spa_data repr(transparent) cast; null-checked spa
derefs; single-loop-thread buffer ownership until requeue
inject/linux/gamepad.rs (10) uinput ioctl request-number ↔ struct-size match
(static-asserted); InputEventRaw no-padding for the byte cast
encode/linux/vaapi.rs (15) + encode/linux/mod.rs (9) ffmpeg object ownership/
free ladders; VAAPI/DRM graph; Send = single-thread transfer
inject/linux/wlr.rs (2), vdisplay/linux/kwin.rs (1)
No memory-unsafety SUSPECT blocks were found — the unsafe is sound. The vaapi
agent did flag two real AVBufferRef *leaks* (not UB) in DmabufInner::open; marked
inline with NOTE(leak) and addressed in a follow-up.
Verified: cargo clippy -p punktfunk-host --all-targets -- -D warnings is clean
(each file's deny gate hard-errors on any undocumented block).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move 36 platform-specific files into per-module `windows/` and `linux/` subfolders (and the
shared HID codecs into `inject/proto/`):
capture/{windows,linux}/ encode/{windows,linux}/ inject/{windows,linux,proto}/
audio/{windows,linux}/ vdisplay/{windows,linux}/
src/windows/ (service, wgc_helper, win_adapter, win_display)
src/linux/ (dmabuf_fence, drm_sync, zerocopy/)
Done with `#[path]`, NOT a module rename: every file moves into its folder while the
`crate::*::*` module names stay FLAT, so all caller paths and every internal `super::`/`crate::`
reference are unchanged — only the parent `mod` decls gained `#[path = "..."]`. This is the
codebase's existing pattern (inject's gamepad_windows) and makes the move byte-identical in
behaviour with ZERO reference churn, far lower risk than collapsing to a single
`crate::capture::windows::` namespace (that deeper rename is an optional follow-on; this delivers
the cfg-sprawl folder confinement the stage is about). Done LAST, after the semantic stages, so
the path churn didn't fight them.
Verified: Linux cargo check + clippy (-D warnings) clean; my mod-decl changes fmt-clean (the 3
remaining fmt diffs are pre-existing local-rustfmt-version skew that moved with their files); all
36 `#[path]` targets exist; no internal `#[path]`/`include!`/file-child-mod in any moved file
(the inline `mod X {` blocks are self-contained). Box build to follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>