fix(packaging,host): a fresh Linux install can start, and the comments stop lying

Fallout from the documentation sweep: verifying doc claims against the code
turned up defects in the code and the shipped templates. Mostly comments that
describe behaviour we no longer have — which is how the docs went wrong in the
first place, since someone reads the comment and writes the page.

The one that mattered: a fresh deb/RPM/Arch install could not start the host at
all. The unit's `EnvironmentFile=` had no `-`, making host.env mandatory, and no
package creates it — all three ship only the templates under /usr/share and the
postinst merely prints the copy command. So `systemctl --user enable --now
punktfunk-host` died on "Failed to load environment files". Every field in
HostConfig::from_env resolves through unwrap_or/filter/None, so absent means all
defaults, exactly like a hand-run `serve`; the Nix module already wrote it as
`-${environmentFile}`. The Deck installer's own generated unit gets the same
prefix for the case where an operator later removes the file.

Shipped templates: PUNKTFUNK_SECURE_DDA is read by nothing (DDA/WGC are gone;
IDD-push is the sole Windows capture path and the secure desktop is
unconditional), so it stops being written into a fresh host.env;
PUNKTFUNK_INPUT_BACKEND offered a `uinput` value that does not exist and omitted
`kwin`, which is what a KDE session actually resolves to; PUNKTFUNK_RENDER_ADAPTER
no longer claims to pick a "Desktop-Duplication" GPU.

Comments corrected rather than deleted, since each explains a real why:
PUNKTFUNK_10BIT is default-on with explicit-off grammar, not an operator opt-in;
GNOME reaches EIS through Mutter's direct RemoteDesktop API, so it needs no portal
approval and is headless-capable; and the host does not run in session 0 — the
service is the session-0 supervisor and the host runs as SYSTEM in the interactive
console session, which is why game_term has to bind the input desktop at all.

packaging/bazzite/update-punktfunk.sh is now installed to /usr/share/punktfunk/,
so the command the docs promised exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 18:46:38 +02:00
co-authored by Claude Opus 5
parent 5807d3a8a6
commit 020306b5ac
14 changed files with 76 additions and 36 deletions
+6 -4
View File
@@ -58,7 +58,8 @@ pub enum Backend {
/// KWin `org_kde_kwin_fake_input` — direct injection, no RemoteDesktop portal / approval dialog
/// (authorized by the host's `.desktop`). The headless KDE-Desktop path; what krdpserver uses.
KwinFakeInput,
/// libei via `reis` — Wayland-native (RemoteDesktop portal).
/// libei via `reis` — Wayland-native. Reaches EIS through the RemoteDesktop portal, or on
/// GNOME through Mutter's direct RemoteDesktop API (see `libei_ei_source`).
Libei,
/// libei directly against gamescope's own EIS socket (no portal): input lands in the
/// nested game — the SteamOS-like session.
@@ -173,9 +174,10 @@ pub fn absolute_anchor() -> Option<AbsoluteAnchor> {
/// portal), so a gamescope session injects directly into it. wlroots/Sway only implements the
/// ScreenCast portal (no RemoteDesktop), so libei can't run there — use the wlr virtual-input
/// protocols. **KWin** exposes `org_kde_kwin_fake_input` (direct injection, no portal / approval
/// dialog — the only headless-capable path; what krdpserver uses), so prefer it there. **GNOME**
/// has neither fake_input nor the wlr protocols, so it uses libei via the RemoteDesktop portal
/// (which needs a user to approve, or a pre-seeded grant — not truly headless).
/// dialog — authorized by the host's `.desktop`; what krdpserver uses), so prefer it there.
/// **GNOME** has neither fake_input nor the wlr protocols, so it uses libei — reaching EIS through
/// Mutter's *direct* `org.gnome.Mutter.RemoteDesktop` API rather than the portal
/// (`libei_ei_source`), so it is headless-capable too: no interactive approval to answer.
/// `PUNKTFUNK_INPUT_BACKEND=wlr|kwin|libei|gamescope` overrides the auto-detection.
#[cfg(target_os = "linux")]
pub fn default_backend() -> Backend {
+4 -2
View File
@@ -212,8 +212,10 @@ pub fn capture_virtual_output(
},
);
// IDD direct-push is the sole Windows capture path: consume frames straight from the pf-vdisplay
// driver's shared ring (in-process, Session 0 — it captures the secure desktop too; no Desktop
// Duplication, no WGC helper). A FRESH monitor + ring is created per session. `want.hdr`
// driver's shared ring (in-process — no Desktop Duplication, no WGC helper). The host itself runs
// as SYSTEM in the active interactive console session (1+), spawned there by the session-0 SCM
// supervisor (`windows/service.rs`), which is what lets it capture the secure desktop too.
// A FRESH monitor + ring is created per session. `want.hdr`
// proactively enables advanced color and selects the per-frame conversion. There is NO fallback:
// if it can't open or the driver doesn't attach, the session fails cleanly and the client
// reconnects.
+5 -4
View File
@@ -52,8 +52,8 @@ pub const SCM_AV1_MAIN10: u32 = 0x0002_0000;
/// The **SDR baseline** codec mask: H.264, HEVC Main, AV1 Main 8-bit (= 65793). HEVC Main10 (HDR) is
/// layered on top of this at runtime by `serverinfo::codec_mode_support` when — and only when — the
/// host can actually deliver it ([`host_hdr_capable`]); it is never a static claim, because a non-HDR
/// host (a host without the `PUNKTFUNK_10BIT` opt-in, or a Linux host whose video source / encoder
/// can't do Main10) must not invite a client into an HDR mode it can't produce. (The previous placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 +
/// host (a host where `PUNKTFUNK_10BIT` was explicitly turned off, or a Linux host whose video
/// source / encoder can't do Main10) must not invite a client into an HDR mode it can't produce. (The previous placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 +
/// 4:4:4 and *no* AV1.) 4:4:4 stays off entirely on GameStream: stock Moonlight is 4:2:0 —
/// full-chroma is a punktfunk/1-native negotiation only (`crate::capture::capturer_supports_444`).
pub const SERVER_CODEC_MODE_SUPPORT: u32 = SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8;
@@ -62,8 +62,9 @@ pub const SERVER_CODEC_MODE_SUPPORT: u32 = SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8;
/// `IsHdrSupported` per app, for layering the 10-bit codec bits in serverinfo, and (together with
/// the live capture-side check and the session's own codec at RTSP time) for honoring a client's
/// `dynamicRangeMode` request. Host-wide and codec-agnostic on purpose: the per-codec depth
/// question belongs to whoever knows which codec is in play. Behind the operator's `PUNKTFUNK_10BIT` opt-in — the same policy gate the native
/// punktfunk/1 plane honors — on both OSes.
/// question belongs to whoever knows which codec is in play. Behind the host's `PUNKTFUNK_10BIT`
/// policy gate — **default ON**, explicit-off grammar (`=0`/`false`/`off`/`no` disables), the same
/// gate the native punktfunk/1 plane honors — on both OSes.
///
/// **Windows**: the IDD-push capturer streams HEVC Main10 PQ whenever the desktop is HDR, and a
/// client HDR request proactively enables advanced color on the per-session virtual display so PQ
+6 -3
View File
@@ -26,7 +26,8 @@ pub enum CaptureBackend {
/// Linux: the xdg ScreenCast portal → PipeWire (the only Linux capture path).
Portal,
/// Windows: IDD direct-push — frames pulled straight from the pf-vdisplay driver's shared ring
/// (in-process, Session 0; captures the secure desktop too). The sole Windows capture path —
/// (in-process; the host runs as SYSTEM in the interactive console session, so it captures the
/// secure desktop too). The sole Windows capture path —
/// DXGI Desktop Duplication (DDA) and the WGC two-process relay were removed.
IddPush,
}
@@ -55,7 +56,8 @@ impl CaptureBackend {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SessionTopology {
/// One process captures + encodes. The only topology: Linux (portal) and Windows (in-process
/// IDD-push in Session 0). The SYSTEM-host + user-session WGC relay was removed with DDA/WGC.
/// IDD-push, in the host's SYSTEM process in the interactive console session). The SYSTEM-host
/// + user-session WGC relay was removed with DDA/WGC.
SingleProcess,
}
@@ -237,7 +239,8 @@ impl SessionPlan {
}
/// Process topology. Single-process is the only topology now: Linux (portal) and Windows (in-process
/// IDD-push in Session 0). The Windows SYSTEM-host + user-session WGC relay was removed with DDA/WGC.
/// IDD-push, in the host's SYSTEM process in the interactive console session). The Windows
/// SYSTEM-host + user-session WGC relay was removed with DDA/WGC.
pub(crate) fn resolve_topology() -> SessionTopology {
SessionTopology::SingleProcess
}
@@ -7,8 +7,10 @@
//! ### Why the desktop dance
//!
//! The polite half is the reason this module is not three lines. `EnumWindows` enumerates the windows
//! of the **calling thread's desktop** — and the host runs as SYSTEM in session 0, whose desktop has
//! none of the interactive user's windows on it. Without first binding the thread to the input desktop
//! of the **calling thread's desktop** — and the host runs as SYSTEM. It is in the right *session*
//! (the supervisor launches it into the active interactive console session — see [`super::service`]),
//! but a SYSTEM process does not start on the interactive user's *desktop* within it, so the
//! enumeration sees none of their windows. Without first binding the thread to the input desktop
//! the enumeration comes back empty and the ladder silently degrades to killing every game outright,
//! which is exactly the unsaved-progress outcome the grace window exists to avoid.
//!
+2 -2
View File
@@ -861,9 +861,9 @@ fn ensure_default_host_env() -> Result<()> {
PUNKTFUNK_ENCODER=auto\n\
PUNKTFUNK_VIDEO_SOURCE=virtual\n\
# Virtual display = the bundled pf-vdisplay driver; capture is IDD-push from its shared ring\n\
# (the sole capture path — zero-copy, includes the secure desktop; DDA/WGC were removed).\n\
# (the sole capture path — zero-copy; DDA/WGC were removed). The secure desktop (UAC / lock /\n\
# login) is always captured — there is no setting for it.\n\
PUNKTFUNK_VDISPLAY=pf\n\
PUNKTFUNK_SECURE_DDA=1\n\
RUST_LOG=info\n\
\n\
# The host subcommand the service launches (default: serve --gamestream = native + Moonlight\n\
+2 -2
View File
@@ -168,8 +168,8 @@ the host needs a clipboard backend underneath. Both, and what a greyed-out toggl
Capture of the **secure desktop** — UAC prompts, the lock screen, the login screen — is always on
and has no setting: the host reads the pf-vdisplay driver's ring directly, and those surfaces are in
it. The service still writes a `PUNKTFUNK_SECURE_DDA=1` line into a freshly generated `host.env` and
nothing reads it — leave it or delete it, it makes no difference.
it. If an older `host.env` on your machine still carries a `PUNKTFUNK_SECURE_DDA` line, nothing reads
it — leave it or delete it, it makes no difference.
| Setting | Values | Meaning |
|---|---|---|
+16 -5
View File
@@ -148,11 +148,20 @@ sudo rpm-ostree update \
systemctl reboot
```
Or just run the helper, which detects what's layered and does the above:
Or just run the helper, which detects what's layered and does the above. The `punktfunk` RPM
installs it, so on a layered box it's already there — no checkout needed (it's the same command the
web console's update hint prints):
```sh
sudo bash packaging/bazzite/update-punktfunk.sh # stage; reboot when ready
sudo bash packaging/bazzite/update-punktfunk.sh --reboot # stage + reboot now
sudo /usr/share/punktfunk/update-punktfunk.sh # stage; reboot when ready
sudo /usr/share/punktfunk/update-punktfunk.sh --reboot # stage + reboot now
```
From a repo checkout (e.g. before the first install, or to run a newer helper than the layered RPM
carries) the same script runs directly — it only shells out to `rpm-ostree`/`rpm`/`systemctl`:
```sh
sudo bash packaging/bazzite/update-punktfunk.sh --reboot
```
> **Channel gotcha:** the re-resolve picks the highest version across **every enabled**
@@ -231,8 +240,10 @@ KERNEL=="hidraw*", KERNELS=="*054C:0CE6*", GROUP="input", MODE="0660", TAG+="uac
## 4. Configure `host.env`
The systemd user unit reads its environment from **`~/.config/punktfunk/host.env`**
(`EnvironmentFile=%h/.config/punktfunk/host.env` in `scripts/punktfunk-host.service`). The RPM
ships a Bazzite-tuned template at `/usr/share/punktfunk/host.env.bazzite`. Copy it into place:
(`EnvironmentFile=-%h/.config/punktfunk/host.env` in `scripts/punktfunk-host.service` — the `-`
makes it optional, since no package creates the file; absent just means every knob at its default).
The RPM ships a Bazzite-tuned template at `/usr/share/punktfunk/host.env.bazzite`. Copy it into
place:
```sh
mkdir -p ~/.config/punktfunk
+1 -1
View File
@@ -39,7 +39,7 @@ if [[ ${#layered[@]} -eq 0 ]]; then
mapfile -t layered < <(rpm -qa --qf '%{NAME}\n' 'punktfunk' 'punktfunk-web' 2>/dev/null | sort -u)
fi
if [[ ${#layered[@]} -eq 0 ]]; then
echo "no punktfunk packages are layered — install first (see packaging/bazzite/README.md)" >&2
echo "no punktfunk packages are layered — install first (see https://docs.punktfunk.unom.io/docs/bazzite)" >&2
exit 1
fi
echo "layered punktfunk packages: ${layered[*]}"
+9
View File
@@ -393,6 +393,15 @@ install -Dm0644 packaging/kde/host.env %{buildroot}%{_datadir}/%
# screencast/virtual-output grant ships as io.unom.Punktfunk.Host.desktop, installed above).
install -d %{buildroot}%{_datadir}/%{name}/bazzite
install -Dm0755 packaging/bazzite/kde-desktop-setup.sh %{buildroot}%{_datadir}/%{name}/bazzite/kde-desktop-setup.sh
# Layered-update helper for rpm-ostree hosts: `rpm-ostree upgrade` only re-resolves layered
# packages when the BASE changes, so a frozen Bazzite base pins punktfunk forever. The script
# forces a re-resolve of just this layer (--uninstall + --install of the same names in one
# transaction). It is exactly the command pf-update-check hands an rpm-ostree host
# (`sudo /usr/share/punktfunk/update-punktfunk.sh`, crates/pf-update-check/src/detect.rs), so it
# has to exist at that path — an ostree box has no repo checkout to run it from. It only shells
# out to rpm-ostree/rpm/systemctl, so the installed copy is self-contained. Top level, not
# bazzite/, because the hint (and any Fedora-Atomic host) names that path.
install -Dm0755 packaging/bazzite/update-punktfunk.sh %{buildroot}%{_datadir}/%{name}/update-punktfunk.sh
# Headless GAME-mode fix: a gamescope-session-plus sessions.d drop-in that falls back to gamescope's
# headless backend when no display is connected (so "Switch to Game Mode" works on a display-less
# streaming host instead of crashing + 5-striking back to desktop). No-op on display-attached boxes.
+4 -1
View File
@@ -55,7 +55,10 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
# mid-stream. Fine for a dedicated headless appliance (punktfunk-kde-session.service, a pure
# gamescope box) or a CI run; wrong for any box that switches sessions.
#PUNKTFUNK_COMPOSITOR=kwin # kwin | mutter | gamescope | wlroots | hyprland
#PUNKTFUNK_INPUT_BACKEND=libei # wlr | libei | gamescope | uinput (auto-routed per connect)
#PUNKTFUNK_INPUT_BACKEND=kwin # wlr | kwin | libei | gamescope — anything else logs "unknown
# PUNKTFUNK_INPUT_BACKEND" and auto-detects instead:
# PUNKTFUNK_COMPOSITOR above wins (except mutter), else
# XDG_CURRENT_DESKTOP — KDE -> kwin, GNOME -> libei, else wlr
#WAYLAND_DISPLAY=wayland-kde # headless-KDE appliance socket; retargeted per connect otherwise
#XDG_CURRENT_DESKTOP=KDE
+7 -1
View File
@@ -45,7 +45,13 @@ After=pipewire.service punktfunk-kde-session.service
PartOf=punktfunk-kde-session.service
[Service]
EnvironmentFile=%h/.config/punktfunk/host.env
# '-' optional: NO package creates this file — deb/RPM/Arch ship only host.env.* templates under
# /usr/share and the postinst merely prints the copy command — so a mandatory read would fail a
# fresh install outright ("Failed to load environment files"), before the host ever ran. Absent
# just means every knob at its default (pf-host-config has one for each), which is exactly what
# `punktfunk-host serve` by hand already does. Contrast punktfunk-web.service, which marks
# mgmt-token mandatory ON PURPOSE so Restart waits for the host to write it — this is not that.
EnvironmentFile=-%h/.config/punktfunk/host.env
ExecStart=%h/punktfunk/target/release/punktfunk-host serve --gamestream
Restart=on-failure
RestartSec=2
+1 -1
View File
@@ -319,7 +319,7 @@ Description=punktfunk host (GameStream + punktfunk/1)
After=pipewire.service
[Service]
EnvironmentFile=%h/.config/punktfunk/host.env
EnvironmentFile=-%h/.config/punktfunk/host.env
Environment=XDG_RUNTIME_DIR=$XRD
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=$XRD/bus
ExecStart=$BIN $SERVE_ARGS
+9 -8
View File
@@ -23,12 +23,10 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
# backend now (the legacy SudoVDA backend was removed). This is informational; leave it as `pf`.
PUNKTFUNK_VDISPLAY=pf
# Capture is IDD-push: straight from the pf-vdisplay driver's shared ring — zero-copy, includes the
# secure desktop. It is the SOLE capture path (DDA/WGC were removed; the former PUNKTFUNK_IDD_PUSH
# knob is gone — a stale setting is ignored).
# Capture the secure desktop (UAC / lock / login) so the stream survives those transitions.
PUNKTFUNK_SECURE_DDA=1
# Capture is IDD-push: straight from the pf-vdisplay driver's shared ring — zero-copy, and it always
# includes the secure desktop (UAC / lock / login), so the stream survives those transitions. It is
# the SOLE capture path (DDA/WGC were removed; the former PUNKTFUNK_IDD_PUSH and PUNKTFUNK_SECURE_DDA
# knobs are gone — secure-desktop capture has no setting, and a stale line is ignored).
# The name this host shows up under in Moonlight and the Punktfunk clients. Defaults to the
# machine's computer name; set it to give the box a friendly name without renaming Windows.
@@ -48,8 +46,11 @@ RUST_LOG=info
# custom value you write here by hand is never overwritten by a reinstall/upgrade.
#PUNKTFUNK_HOST_CMD=serve --gamestream
# Multi-GPU boxes only: force the NVENC/Desktop-Duplication GPU by Description substring. Leave
# unset on single-GPU machines (the default auto-picks the discrete adapter).
# Multi-GPU boxes only: force the render GPU by adapter Description substring. One pick drives the
# whole pipeline — pf-vdisplay's IddCx render adapter, the IDD-push capture ring, and the encoder
# (NVENC/AMF/QSV) that reads it. A manual GPU preference in the web console outranks this; with
# neither set the host auto-picks the adapter with the most dedicated VRAM. Leave unset on
# single-GPU machines.
#PUNKTFUNK_RENDER_ADAPTER=4090
# Keep a per-client virtual display alive briefly after disconnect so a quick reconnect reuses it