Merge origin/main into worktree-native-decode-m0
ci / web (pull_request) Successful in 1m19s
apple / swift (pull_request) Successful in 1m32s
ci / docs-site (pull_request) Successful in 1m23s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 25s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m8s
android / android (pull_request) Successful in 3m31s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m23s
ci / rust-arm64 (pull_request) Successful in 5m36s
nix / flake (pull_request) Failing after 11m59s
ci / rust (pull_request) Successful in 14m27s

main moved 93 commits while this branch ran. Two conflicts, both where main's new
work sat next to M10's excision:

packaging/flatpak/io.unom.Punktfunk.yml — main added the vendored gamescope WSI
layer (the only route to HDR on a Deck) and, before it, a vulkan-headers module.
Took both: this branch predates them and deletes neither. But the headers module's
stated consumer was pf-ffvk's bindgen over FFmpeg's hwcontext_vulkan.h, and M10
deleted pf-ffvk — so it now reads as dead weight to the next person. It is not:
the WSI layer IS a Vulkan layer, compiles against those headers, and builds after
it, so module order is the dependency. Rewrote the rationale to say so, including
why dropping it would be expensive to discover — flatpak.yml has no pull_request:
trigger, so a manifest break reaches main invisibly and a tag then ships no Linux
flatpak. Also recorded that the native decoder needs nothing from there: pf-vkdecode
reaches Vulkan through ash, which is pure Rust bindings, no bindgen, no C headers.

crates/pf-console-ui/src/screens/settings.rs — main restructured the gamepad
settings into TABS, which removed the per-row section headers; this branch had left
Some("Video") untouched from the merge base and added the pre-M10 decoder migration
next to it. Git could not tell those apart. Took main's structure (no header, its
deliberate change) with this branch's migration layered on: a stored `vulkan`,
`vaapi` or `d3d11va` names no preset in the tabbed list and would render as "—",
then silently rewrite the user's preference on the next save.

Gates on the merged tree, Linux container: fmt clean; cargo check --workspace
--all-targets clean; clippy --workspace --all-targets -D warnings clean; tests
green across pf-vkdecode (187), pf-client-core (163), pf-console-ui (58) and
punktfunk-host (447 of 448 — the one failure is the pre-existing
gamestream::stream::tests::sender_delivers_batches, a UDP-loopback EINTR under
qemu that fails identically on a pristine HEAD).
This commit is contained in:
2026-08-07 10:50:32 +02:00
279 changed files with 18681 additions and 2118 deletions
+13 -3
View File
@@ -14,9 +14,19 @@ KERNEL=="uhid", SUBSYSTEM=="misc", OPTIONS+="static_node=uhid", GROUP="input", M
# usbip vhci attach/detach for the virtual Steam Deck controller. Steam Input only
# adopts the virtual Deck when it arrives as a USB device (usbip/vhci or raw_gadget);
# the UHID fallback has no USB interface and Steam ignores it. The sysfs attach files
# are root-only by default while the host runs as a user service — grant the `input`
# group write when vhci_hcd appears (module autoload: modules-load.d/punktfunk.conf).
ACTION=="add", SUBSYSTEM=="platform", KERNEL=="vhci_hcd.*", RUN+="/bin/sh -c 'chgrp input /sys%p/attach /sys%p/detach && chmod 0660 /sys%p/attach /sys%p/detach'"
# are root-only by default while the host runs as a user service — grant the dedicated
# `punktfunk` group write when vhci_hcd appears (module autoload: modules-load.d/punktfunk.conf).
#
# ⚠ This is deliberately NOT the `input` group (2026-08-05 review M-4). Writing `attach` hands the
# kernel a caller-supplied socket fd and materialises an arbitrary, fully userspace-emulated USB
# device — a root-only kernel primitive. Every packaging scriptlet tells the user to
# `usermod -aG input $USER` as step 1, so putting it on `input` handed that primitive to a group
# people are routinely told to join: a member could present a HID keyboard and inject keystrokes
# into a root TTY or the lock screen, or drive any of hundreds of in-tree USB drivers from
# userspace, all without CAP_SYS_ADMIN. The uinput/uhid grants above are already systemwide input
# injection, but neither reaches kernel USB enumeration — this one does, so it gets its own group
# that nothing else asks users to join.
ACTION=="add", SUBSYSTEM=="platform", KERNEL=="vhci_hcd.*", RUN+="/bin/sh -c 'chgrp punktfunk /sys%p/attach /sys%p/detach && chmod 0660 /sys%p/attach /sys%p/detach'"
# hidraw access for the VIRTUAL pads this host creates. Steam/SDL drive a DualSense's rich
# feedback (adaptive triggers, lightbar, player LEDs) exclusively over hidraw — the kernel has no
+9
View File
@@ -53,6 +53,15 @@ if [[ -z "${DEVELOPER_DIR:-}" ]]; then
esac # a non-beta xcode-select default is fine as-is
fi
# Hermetic Opus: never let audiopus_sys link a Homebrew libopus via pkg-config. A brew lib
# is built for the RUNNING macOS (its objects carry that minos, tripping the version guard
# below) and only exists for the host arch — the other slice silently falls back to the
# vendored build, so the two slices ship different libopus builds. Force the vendored CMake
# build everywhere; the policy floor keeps modern CMake (≥4) accepting libopus's old
# `cmake_minimum_required`.
export OPUS_NO_PKG_CONFIG=1
export CMAKE_POLICY_VERSION_MINIMUM=3.5
# Deployment targets must match Package.swift's platforms, or every consumer link emits
# "object file was built for newer macOS version" warnings.
for t in "${TARGETS_MAC[@]}"; do
+181
View File
@@ -0,0 +1,181 @@
#!/bin/sh
# Drift gate for the generated bun2nix lockfile expressions (web/bun.nix, sdk/bun.nix).
#
# `bun.nix` is a DERIVED file: bun2nix is a pure function of `bun.lock` (it reads the lockfile text
# and emits one `fetchurl` per package, keyed by the lockfile's own integrity hashes — see
# packaging/nix/README.md). Nothing but the lockfile goes in, so any disagreement between the two
# committed files is drift, and it is always mechanically fixable.
#
# Why this exists: moving the bun packages to bun2nix (1db8f763) removed the *aggregate deps hash*
# that used to go stale, but not the second, quieter way a derived file rots. `bun.nix` regenerates
# only from a local `bun install` that runs lifecycle scripts (web's `postinstall`, the SDK's
# `prepare`). It does NOT regenerate on:
#
# * `bun install --ignore-scripts` — which is what EVERY bun install in CI uses (ci.yml,
# web-screenshots.yml, windows-host.yml, sdk-publish.yml), because web's `postinstall` shells
# out to a `bun` on PATH that CI's portable bun isn't;
# * a merge or rebase — git merges `bun.lock` and `bun.nix` as two unrelated files, so a branch
# that generated `bun.nix` before picking up someone else's lockfile change silently commits
# the pair out of step;
# * a lockfile edited or re-resolved by hand.
#
# That second case is not hypothetical: it is how `web/bun.nix` shipped on main carrying
# brace-expansion@5.0.7 (plus two nested entries the override had already collapsed) while
# `web/bun.lock` said 5.0.8 — the `^5.0.8` override from ec9aa415 landed in the lockfile, the
# bun2nix branch had generated `bun.nix` off the pre-override lock, and the merge kept both. The
# Nix build fetches node_modules strictly from `bun.nix`, so the offline `bun install` inside the
# derivation is then asked for a tarball the store cache does not contain and `punktfunk-web` fails
# to build — with a "package not found" that names npm, not the lockfile that actually drifted.
#
# The gate also enforces the version pin the flake and README only *state*: `bun.nix` has no schema
# stability across bun2nix releases, so the flake input ref and BOTH npm devDependencies must name
# the same exact version. Nothing checked that before; a half-moved pin regenerates the file with a
# generator the flake does not use.
#
# The list of packages to check is read out of packaging/nix/packages.nix (its `bunNix = src + …`
# lines) rather than hardcoded here, so a third bun package is covered the day it is added — and an
# empty list is a hard error, because a gate that checks nothing passes exactly like a clean tree.
#
# Usage:
# scripts/ci/check-bun-nix.sh # verify; non-zero on drift (CI)
# scripts/ci/check-bun-nix.sh --fix # regenerate the committed files in place
set -eu
FIX=0
if [ $# -gt 0 ]; then
case "$1" in
--fix) FIX=1 ;;
*) echo "usage: $0 [--fix]" >&2; exit 2 ;;
esac
fi
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)
PACKAGES_NIX="$ROOT/packaging/nix/packages.nix"
FLAKE="$ROOT/flake.nix"
command -v bun >/dev/null 2>&1 || {
echo "check-bun-nix: bun is not on PATH (needed to run bun2nix and to read package.json)" >&2
exit 1
}
[ -f "$PACKAGES_NIX" ] || { echo "check-bun-nix: no $PACKAGES_NIX" >&2; exit 1; }
[ -f "$FLAKE" ] || { echo "check-bun-nix: no $FLAKE" >&2; exit 1; }
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
# --- the pinned bun2nix version -------------------------------------------------------------------
# flake.nix: url = "github:nix-community/bun2nix?ref=2.1.2";
PINNED=$(sed -n 's/.*github:nix-community\/bun2nix?ref=\([^"]*\)".*/\1/p' "$FLAKE" | head -1)
[ -n "$PINNED" ] || {
echo "check-bun-nix: could not read the bun2nix input ref out of $FLAKE." >&2
echo "Expected a line like: url = \"github:nix-community/bun2nix?ref=<version>\";" >&2
exit 1
}
# --- which packages carry a generated bun.nix -----------------------------------------------------
# packages.nix: bunDeps = bun2nix.fetchBunDeps { bunNix = src + "/web/bun.nix"; };
sed -n 's/.*bunNix *= *src *+ *"\/\(.*\)\/bun\.nix".*/\1/p' "$PACKAGES_NIX" | sort -u > "$TMP/roots"
if [ ! -s "$TMP/roots" ]; then
echo "check-bun-nix: found no \`bunNix = src + \"/<dir>/bun.nix\"\` in $PACKAGES_NIX." >&2
echo "Either the bun packages were removed (delete this gate) or the expression changed shape" >&2
echo "and the gate silently stopped checking anything. Not passing vacuously." >&2
exit 1
fi
fail=0
checked=0
# --- version pin agreement ------------------------------------------------------------------------
# `bun.nix` has no schema stability across bun2nix versions, so the generator the flake builds with
# and the generator `bun install` runs must be the SAME exact version (packaging/nix/README.md).
while read -r dir; do
pkgjson="$ROOT/$dir/package.json"
[ -f "$pkgjson" ] || { echo "check-bun-nix: no $pkgjson" >&2; fail=1; continue; }
dev=$(bun -e "const d=require(process.argv[1]).devDependencies||{};console.log(d.bun2nix??'')" \
"$pkgjson")
if [ "$dev" != "$PINNED" ]; then
echo "check-bun-nix: bun2nix version pin disagrees." >&2
echo " flake.nix input ref : $PINNED" >&2
echo " $dir/package.json devDependency : ${dev:-<absent>}" >&2
echo "These must be the same exact version — bun.nix has no schema stability across" >&2
echo "bun2nix releases. Move both together, then rerun this script with --fix." >&2
fail=1
fi
done < "$TMP/roots"
# --- the generator ---------------------------------------------------------------------------------
# Prefer an already-installed bun2nix at the pinned version (fast, offline — the dev case); otherwise
# fetch exactly the pinned one, once, into $TMP. Never a floating `bunx bun2nix`: that would generate
# with whatever is newest, and `bun.nix` has no schema stability across releases.
BUN2NIX=""
while read -r dir; do
cand="$ROOT/$dir/node_modules/bun2nix/index.ts"
[ -f "$cand" ] || continue
have=$(bun -e "console.log(require(process.argv[1]).version??'')" \
"$ROOT/$dir/node_modules/bun2nix/package.json" 2>/dev/null || echo '')
if [ "$have" = "$PINNED" ]; then BUN2NIX="$cand"; break; fi
done < "$TMP/roots"
if [ -z "$BUN2NIX" ]; then
# Installed in its own scratch dir, so this never touches the repo's lockfiles or .npmrc.
mkdir -p "$TMP/gen"
if ! ( cd "$TMP/gen" && bun add --exact "bun2nix@$PINNED" ) > "$TMP/geninstall.log" 2>&1; then
echo "check-bun-nix: could not install bun2nix@$PINNED" >&2
cat "$TMP/geninstall.log" >&2
exit 1
fi
BUN2NIX="$TMP/gen/node_modules/bun2nix/index.ts"
[ -f "$BUN2NIX" ] || { echo "check-bun-nix: bun2nix@$PINNED installed but $BUN2NIX is absent" >&2; exit 1; }
fi
run_bun2nix() { # <lockfile> <outfile>
bun "$BUN2NIX" --lock-file "$1" --output-file "$2"
}
# --- regenerate + compare ---------------------------------------------------------------------------
while read -r dir; do
lock="$ROOT/$dir/bun.lock"
nix="$ROOT/$dir/bun.nix"
[ -f "$lock" ] || { echo "check-bun-nix: no $lock (packages.nix expects $dir/bun.nix)" >&2; fail=1; continue; }
out="$TMP/$(echo "$dir" | tr '/' '_').bun.nix"
run_bun2nix "$lock" "$out" >/dev/null
if [ "$FIX" -eq 1 ]; then
if [ ! -f "$nix" ] || ! cmp -s "$nix" "$out"; then
cp "$out" "$nix"
echo "check-bun-nix: regenerated $dir/bun.nix from $dir/bun.lock"
else
echo "check-bun-nix: $dir/bun.nix already in sync"
fi
checked=$((checked + 1))
continue
fi
if [ ! -f "$nix" ]; then
echo "check-bun-nix: $dir/bun.nix is MISSING — packages.nix fetches node_modules from it." >&2
fail=1
continue
fi
# Plain files, not `diff <(…) <(…)`: Gitea's runner executes a step's `run:` under `sh`, and
# dash has no process substitution — it would reject the script at parse time and the gate
# would never compare anything (exactly how the shader SPIR-V gate in ci.yml was lost).
if cmp -s "$nix" "$out"; then
echo "check-bun-nix: $dir/bun.nix matches $dir/bun.lock"
else
echo "check-bun-nix: $dir/bun.nix is STALE — it does not match $dir/bun.lock." >&2
echo "The Nix build fetches node_modules only from bun.nix, so punktfunk's bun packages" >&2
echo "would build against the wrong dependency set (or fail to fetch it at all)." >&2
echo "Regenerate and commit it: scripts/ci/check-bun-nix.sh --fix" >&2
echo "--- diff (committed -> regenerated from bun.lock) ---" >&2
diff -u "$nix" "$out" >&2 || true
fail=1
fi
checked=$((checked + 1))
done < "$TMP/roots"
[ "$checked" -gt 0 ] || { echo "check-bun-nix: checked nothing — refusing to report success" >&2; exit 1; }
if [ "$fail" -eq 0 ] && [ "$FIX" -eq 0 ]; then
echo "check-bun-nix: $checked bun package(s) in sync, bun2nix pinned at $PINNED everywhere"
fi
exit "$fail"
+27 -10
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env bash
# CI runner disk hygiene — invoked by docker-prune.service (every 30 min). Lives in a real script
# CI runner disk hygiene — invoked by docker-prune.service (every 2 min). Lives in a real script
# rather than inline ExecStart= lines because systemd does its OWN $-expansion on ExecStart and
# empties shell vars / $(...) before /bin/sh sees them (silently breaking the logic under `|| true`).
#
# See docker-prune.service for the full why. The headline: the act_runner cache server's blob store
# lives INSIDE the long-running runner container's writable layer, where `docker prune` can't reach
# it — left alone it grows to tens of GB and fills the disk on its own.
# See docker-prune.service for the full why. Sibling: docker-reclaim.sh (hourly) handles what
# act_runner *leaks* — per-job volumes, stale networks, old build cache. This one handles what
# CI legitimately *produces* and then abandons: per-SHA app tags and the layers they pin.
set -u
export PATH=/usr/bin:/bin:/usr/local/bin:$PATH
@@ -23,11 +23,26 @@ MIN_FREE_GB=${MIN_FREE_GB:-60} # ...or this little is left, whichever t
# 2026-07-29: zero burst clears fired in six hours
# while deb still died of ENOSPC between polls.
# 1) Routine: trim aged images / build cache / stopped containers. sha-<commit> tags aren't
# dangling, so -a is required. until=2h, not 6h: on a busy day every image is younger than six
# hours, so the filter matched nothing and a run reclaimed 0B while `docker system df` was
# reporting 20+ GB reclaimable. Two hours still protects a re-run of the push being worked on.
docker image prune -af --filter until=2h || true
# 1) Routine: retire aged per-SHA app tags, then sweep what untagging released.
# ⚠ NEVER `docker image prune -a` on this tick. `until=` filters on image CREATION time, so a
# CI *base* image (built days ago) that merely has no container this instant counts as "aged" —
# including one a job JUST PULLED whose container does not exist yet. Measured 2026-08-07:
# this tick ran 07:36:09:29 and a rust job's `docker create` failed at 07:36:29 with
# "No such image: …punktfunk-rust-ci:latest" — three sampled failures that morning, each
# coinciding with a prune run to the second — and every idle base image was re-pulled within
# minutes (47 GB each), churning the LAN registry for nothing.
# The only tag debris this host actually accretes is the per-SHA app tags (web/docs — their
# creation time IS the local build time, so a 2h age gate is exact), and a dangling-only prune
# cannot touch a tagged image, so neither step can race a starting job.
now=$(date +%s)
docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep ':sha-' | while read -r ref; do
created=$(docker image inspect -f '{{.Created}}' "$ref" 2>/dev/null) || continue
cts=$(date -d "$created" +%s 2>/dev/null) || continue
if [ $((now - cts)) -ge 7200 ]; then
docker rmi "$ref" >/dev/null 2>&1 || true
fi
done
docker image prune -f || true
docker builder prune -af --filter until=2h || true
docker buildx prune -af --filter until=2h || true
docker container prune -f --filter until=2h || true
@@ -44,7 +59,9 @@ docker network prune -f --filter until=2h || true
# what matters is absolute headroom for three concurrent target/ dirs, not a ratio — and the
# ratio moves whenever the disk is resized (it went 123 G -> 175 G on 2026-07-29) while the
# headroom three jobs need does not. In-use images are protected by the daemon, so a burst clear
# cannot pull the rug from a live job.
# cannot pull the rug from a live job — but the blanket `-a` prune below CAN race an image that
# is pulled-but-not-yet-created (the section 1 lesson). That narrow window is accepted HERE
# only: when the alternative is every concurrent job dying of ENOSPC, one job re-pulling loses.
PCT=$(df --output=pcent / | tr -dc '0-9')
FREE_GB=$(df --output=avail -BG / | tr -dc '0-9')
# Two flat tests into a flag rather than one multi-line `{ …; } || { …; }` condition: the brace-group
+20
View File
@@ -0,0 +1,20 @@
# Hourly reclaim of Docker resources act_runner LEAKS (per-job volumes, stale networks, old build
# cache). Sibling of docker-prune.service, which handles what CI legitimately produces and then
# abandons; the split matters because this one must stay conservative enough to run while jobs are
# live (dangling-only volumes, age-gated networks) — see docker-reclaim.sh for the full why.
#
# Install: see the header of docker-reclaim.sh (note the installed unit name is
# ci-docker-reclaim.service — existing fleet hosts already run it under that name).
[Unit]
Description=Reclaim disk leaked by Gitea act_runner (per-job volumes, networks, stale build cache)
Documentation=https://git.unom.io/unom/punktfunk
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/ci-docker-reclaim.sh
# Never let maintenance starve a running build.
Nice=10
IOSchedulingClass=idle
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Reclaim the disk that Gitea act_runner leaks on this host.
#
# Why this exists: act_runner creates a per-job network and a pair of named volumes, and leaks both
# when a job is killed or the runner restarts. By 2026-07-25 that had accumulated 252 unused volumes
# (11.7 GB) and 94 stale networks — some dating to task 5626 while current tasks were ~25233 — and
# concurrent builds then exhausted the disk, failing CI with "No space left on device" at both the
# cargo and the Docker/overlayfs layer. The stale networks are also what once broke the docs deploy
# by exhausting Docker's default address pool and swallowing the DMZ 192.168.50.0/24 range.
#
# This ran on home-runner-1 only, hand-installed; home-runner-2 went without it and by 2026-08-07
# had re-accumulated 176 leaked volumes (~60 GB) + 22 GB build cache and spent two days failing
# jobs at ENOSPC. Hence checked in: BOTH runner hosts install it, from here.
#
# Install on a runner host (root):
# install -m755 scripts/ci/docker-reclaim.sh /usr/local/sbin/ci-docker-reclaim.sh
# install -m644 scripts/ci/docker-reclaim.service /etc/systemd/system/ci-docker-reclaim.service
# install -m644 scripts/ci/docker-reclaim.timer /etc/systemd/system/ci-docker-reclaim.timer
# systemctl daemon-reload && systemctl enable --now ci-docker-reclaim.timer
#
# Deliberately NOT `docker volume prune -a`: that would also delete any intentional named volume
# that merely has no container attached at the moment the timer fires — e.g. the `docker-mirror`
# pull-through registry cache or the runner cache during a restart — silently destroying it. Only
# volumes act_runner named are removed here.
#
# Also deliberately NOT pruning images: on this host the per-SHA CI tags share all their layers with
# `:latest`, so removing them reclaims nothing while forcing re-pulls. `docker system df`'s
# "RECLAIMABLE" column counts shared layers once per image and overstates the win badly.
# (docker-prune.sh owns tag retirement — age-gated and never `image prune -a`, see its header.)
set -uo pipefail
log() { echo "ci-docker-reclaim: $*"; }
before_avail=$(df --output=avail -BM / | tail -1 | tr -dc '0-9')
# 1. Leaked per-job volumes — dangling AND named by act_runner. In-use volumes are never listed as
# dangling, so a running job's volumes cannot be hit.
mapfile -t stale_vols < <(docker volume ls -qf dangling=true 2>/dev/null | grep '^GITEA-ACTIONS-TASK-' || true)
if ((${#stale_vols[@]})); then
printf '%s\n' "${stale_vols[@]}" | xargs -r docker volume rm >/dev/null 2>&1
log "removed ${#stale_vols[@]} leaked act_runner volumes"
else
log "no leaked act_runner volumes"
fi
# 2. Unused networks older than 2h — never touches a live job's network (it is in use), and the age
# filter keeps a just-created one safe against a race with a starting job.
net_out=$(docker network prune -f --filter until=2h 2>&1 | grep -c '^GITEA-ACTIONS' || true)
log "removed ${net_out:-0} stale job networks"
# 3. Build cache older than 48h. Recent cache is what makes builds fast, so it is kept.
cache_freed=$(docker builder prune -f --filter until=48h 2>&1 | awk '/^Total:/ {print $2}')
log "build cache freed: ${cache_freed:-0B}"
after_avail=$(df --output=avail -BM / | tail -1 | tr -dc '0-9')
log "avail ${before_avail}M -> ${after_avail}M (reclaimed $((after_avail - before_avail))M)"
df -h / | tail -1 | sed 's/^/ci-docker-reclaim: /'
+16
View File
@@ -0,0 +1,16 @@
# Hourly is the right cadence for LEAKS: they only accrue when jobs die abnormally, and the
# per-tick docker-prune.timer (every 2 min) already carries the burst guard for genuine
# disk-pressure emergencies. Install: see the header of docker-reclaim.sh.
[Unit]
Description=Hourly reclaim of act_runner-leaked Docker disk
[Timer]
OnCalendar=hourly
# Catch up after a reboot rather than waiting for the next slot.
Persistent=true
# Spread it off the hour so it does not collide with scheduled CI.
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
+1 -1
View File
@@ -196,7 +196,7 @@ def main():
w("THIRD-PARTY SOFTWARE NOTICES")
w("=" * 76)
w("")
w("punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.")
w("Punktfunk (https://git.unom.io/unom/punktfunk) is licensed under MIT OR Apache-2.0.")
w("The binaries it ships statically/dynamically link the third-party Rust crates listed")
w("below. Each is distributed under its own permissive license; the full license texts")
w("follow the manifest. This file is generated by scripts/gen-third-party-notices.py")
+13 -1
View File
@@ -52,7 +52,19 @@ fi
# it `kwin_wayland --virtual` brings up NO X server at all (no display reserved), and those apps die
# with "Missing X Server or $DISPLAY". KWin starts Xwayland on demand but reserves + logs the X11
# display up front, which the detection below reads.
KWIN_LOG="${TMPDIR:-/tmp}/punktfunk-kwin.log"
# The log lives in the per-user 0700 XDG_RUNTIME_DIR, not at a fixed name in a world-writable
# /tmp. This file is not just a log: the DISPLAY detection below GREPS it for "Using public X11
# display :N" and exports the result, so at a predictable path in a shared directory any local user
# could pre-create it (or symlink it) and steer the DISPLAY of a shipped systemd service
# (2026-08-05 review L-15). `pf-vdisplay` already resolves XDG_RUNTIME_DIR for its own paths; this
# matches. Without a runtime dir, fall back to a private mktemp rather than a guessable name.
if [[ -n "${XDG_RUNTIME_DIR:-}" && -d "${XDG_RUNTIME_DIR}" ]]; then
KWIN_LOG="${XDG_RUNTIME_DIR}/punktfunk-kwin.log"
: >"$KWIN_LOG"
chmod 600 "$KWIN_LOG"
else
KWIN_LOG="$(mktemp -t punktfunk-kwin.XXXXXXXX.log)"
fi
kwin_wayland --virtual --xwayland --width "$W" --height "$H" --no-lockscreen \
--socket "$WAYLAND_DISPLAY" >"$KWIN_LOG" 2>&1 &
KWIN_PID=$!
+8 -1
View File
@@ -11,7 +11,14 @@
grant is scoped to the box's own local-seat session lifecycle — the same class of operation
these distros already authorize for their session switcher (e.g. Nobara's
os-session-select, allow_any). allow_any because the host commonly runs sessionless (a
lingering user unit, no polkit agent), where interactive auth can never be answered. -->
lingering user unit, no polkit agent), where interactive auth can never be answered.
⚠ These defaults authorize every local subject — including a seatless ssh session or a
service account — so they are NOT the whole authorization story (2026-08-05 review L-14).
They cannot be tightened without breaking the lingering-user-unit deployment, which polkit
classifies under allow_any precisely because it has no session. The actual gate is in the
helper: pf-dm-helper refuses any caller whose PKEXEC_UID is not in the `punktfunk` group.
Keep the two in step — loosening the helper's check makes these defaults load-bearing. -->
<action id="io.unom.punktfunk.dm-helper">
<description>Stop or restore the display manager for a Punktfunk stream</description>
<message>Authentication is required to switch the display manager for a Punktfunk stream</message>
+35 -7
View File
@@ -13,6 +13,38 @@
# local-seat operation, not arbitrary unit management.
set -eu
# The polkit action has to stay permissive (`allow_any=yes`): the host commonly runs as a LINGERING
# user unit, which has no logind session at all, so polkit classifies it under `allow_any` and any
# stricter default would make the takeover unauthorizable in its primary deployment. The cost of
# that is that polkit alone authorizes *every* local subject — a seatless ssh session, a service
# account — to run this as root (2026-08-05 review L-14).
#
# So the authorization decision is made HERE instead, where the caller is knowable: pkexec sets
# PKEXEC_UID from the authenticated caller, and only a member of the `punktfunk` group (created by
# the packages) may proceed. That keeps the sessionless host working while making membership of one
# explicit group — not merely "has a local uid" — the thing that grants these verbs.
require_authorized_caller() {
uid=${PKEXEC_UID:-}
[ -n "$uid" ] || {
echo "pf-dm-helper: no PKEXEC_UID in the environment — refusing to run unauthenticated" >&2
exit 1
}
user=$(getent passwd "$uid" | cut -d: -f1) || user=
[ -n "$user" ] || {
echo "pf-dm-helper: PKEXEC_UID $uid resolves to no local user — refusing" >&2
exit 1
}
# `id -nG` lists the primary group too, so a user whose primary group IS punktfunk also passes.
for g in $(id -nG "$user" 2>/dev/null); do
[ "$g" = punktfunk ] && return 0
done
echo "pf-dm-helper: user '$user' is not in the 'punktfunk' group — refusing." >&2
echo " Grant it with: sudo usermod -aG punktfunk $user (then re-login)" >&2
exit 1
}
require_authorized_caller
dm_unit() {
target=$(readlink /etc/systemd/system/display-manager.service) || {
echo "pf-dm-helper: no display-manager.service alias — no display manager to manage" >&2
@@ -40,13 +72,9 @@ case "${1-}" in
# what breaks that dependency (the setup docs already ask for it).
#
# The user is NEVER caller-named: PKEXEC_UID is set by pkexec from the authenticated caller,
# so this grant enables lingering for that caller alone.
uid=${PKEXEC_UID:-}
[ -n "$uid" ] || {
echo "pf-dm-helper: no PKEXEC_UID in the environment — refusing to guess a user" >&2
exit 1
}
exec loginctl enable-linger "$uid"
# so this grant enables lingering for that caller alone. (Its presence is already checked by
# `require_authorized_caller` above, which also proved the caller is in the punktfunk group.)
exec loginctl enable-linger "${PKEXEC_UID}"
;;
*)
echo "usage: pf-dm-helper stop|restore|linger" >&2
+10 -3
View File
@@ -6,9 +6,16 @@
# SIGTERM interrupts the whole tree STRUCTURALLY, so every plugin's scoped finalizers run before
# exit (clean deregister / preset release) — hence the generous stop timeout below.
#
# OPT-IN — unlike punktfunk-web, the package does NOT auto-enable this: the runner does nothing until
# you add scripts or install plugins. Turn it on once you have automation to run:
# systemctl --user enable --now punktfunk-scripting
# ON BY DEFAULT — the packages enable this for every user (`systemctl --global enable` from the
# .deb/.rpm scriptlets; a baked-in default.target.wants symlink in the sysext image). It used to be
# opt-in, on the reasoning that the runner does nothing until you add scripts or plugins. That
# stopped being true when the game-library scanners became plugins: the library is a flagship
# surface, and a host whose runner is off now comes up with an empty library and no obvious reason
# why (design/library-scanner-plugins.md D9).
#
# It remains opt-OUT, per user:
# systemctl --user mask punktfunk-scripting
# (`mask`, not `disable` — a plain disable cannot remove a symlink that lives in /etc or /usr.)
#
# Auto-wired like the console: a plugin's connect() reads the host's SCOPED plugin token + identity
# cert from ~/.config/punktfunk/{plugin-token,cert.pem} (written by the host's `serve`) — no env
+12 -1
View File
@@ -185,6 +185,11 @@ ok "plugin runner: ~/.local/bin/punktfunk-scripting"
# --- 3. config -------------------------------------------------------------
log "Configuration ($CONFIG)"
mkdir -p "$CONFIG"
# Owner-only: this directory holds web.env (console password + session secret), the mgmt token and
# the host key. A plain `mkdir -p` leaves it 0755 at the Deck's default umask, so the secrets below
# sat in a world-TRAVERSABLE directory (2026-08-05 review L-19). Matches what the host itself does
# via `pf_paths::create_private_dir`, and is idempotent on an existing dir.
chmod 700 "$CONFIG" 2>/dev/null || true
if [ ! -f "$CONFIG/host.env" ]; then
cat > "$CONFIG/host.env" <<'EOF'
# punktfunk Steam Deck host config (sourced by the punktfunk-host user service).
@@ -235,10 +240,16 @@ if [ "$WITH_WEB" = 1 ] && [ ! -f "$CONFIG/web.env" ]; then
# `|| true` swallows the SIGPIPE `tr` takes when `head` closes the pipe (pipefail would abort).
WEB_PW="$(LC_ALL=C tr -dc 'a-z0-9' </dev/urandom 2>/dev/null | head -c 12 || true)"
WEB_SECRET="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom 2>/dev/null | head -c 32 || true)"
cat > "$CONFIG/web.env" <<EOF
# `umask 077` around the redirect, not `chmod 600` after it: the heredoc CREATES the file at
# the ambient umask (0022 on a Deck ⇒ world-readable), so the console password and session
# secret existed group/world-readable for the window between the redirect and the chmod
# (2026-08-05 review L-19). Setting the mask first means the file is never readable at all.
# The chmod stays as the idempotent belt for a pre-existing file.
(umask 077; cat > "$CONFIG/web.env" <<EOF
PUNKTFUNK_UI_PASSWORD=$WEB_PW
PUNKTFUNK_UI_SECRET=$WEB_SECRET
EOF
)
chmod 600 "$CONFIG/web.env"
ok "wrote web.env (generated login password)"
else
+9 -2
View File
@@ -14,6 +14,13 @@ if [ ! -s "$PWFILE" ]; then
PW=$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | cut -c1-20)
(umask 077; printf 'PUNKTFUNK_UI_PASSWORD=%s\n' "$PW" > "$PWFILE")
chmod 600 "$PWFILE" 2>/dev/null || true
echo "punktfunk web console login password generated: $PW"
echo "(stored in $PWFILE — open https://<host-ip>:47992 and log in)"
# Do NOT echo the password itself. Anything this script prints is captured by systemd into
# the PERSISTENT journal, which on Debian/Ubuntu is readable by the `adm` and
# `systemd-journal` groups — so printing it published a 0600 secret to every member of them,
# permanently, and the .deb postinst then documented `journalctl` as the way to read it
# (2026-08-05 review L-18). Point at the file instead: it is the same one command, it is
# correctly 0600, and it stays readable only by the user who owns the console.
echo "punktfunk web console login password generated."
echo "Read it with: cut -d= -f2- $PWFILE"
echo "(then open https://<host-ip>:47992 and log in)"
fi