Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85980b425e | ||
|
|
107fa3472d | ||
|
|
5d8682d7b7 | ||
|
|
030bc8a1c2 | ||
|
|
346385bad8 | ||
|
|
124cb66324 | ||
|
|
6774c4e7a2 | ||
|
|
f06b84be63 | ||
|
|
d6dbb391d6 | ||
|
|
907080f92b | ||
|
|
9425c6d40a | ||
|
|
ab8c7ec37c | ||
|
|
64e2af17c5 | ||
|
|
72189b29ec | ||
|
|
339a1d70f9 | ||
|
|
79dba7f95a | ||
|
|
d7430fe2bd | ||
|
|
6f81ec24ba | ||
|
|
539236de91 | ||
|
|
118758ff0b | ||
|
|
dcde856178 | ||
|
|
77918674c3 | ||
|
|
faefbae830 | ||
|
|
44fa12a298 | ||
|
|
55a3d8b919 | ||
|
|
a02014ec19 | ||
|
|
5f55b820bc |
+170
-99
@@ -1,6 +1,6 @@
|
||||
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
|
||||
# boxes get new builds via `apt update && apt upgrade`. Three jobs, all publishing to the same
|
||||
# apt distribution/component:
|
||||
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Debian and
|
||||
# Ubuntu boxes get new builds via `apt update && apt upgrade`. Five jobs; the four builders all
|
||||
# publish to the same apt distribution/component, and the fifth checks the result:
|
||||
#
|
||||
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
|
||||
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
|
||||
@@ -11,8 +11,17 @@
|
||||
# build-publish-host — the HOST, on the Ubuntu 24.04 rust-ci-noble image with a from-source
|
||||
# FFmpeg 8 BUNDLED into the .deb. This lowers the host's glibc floor to 2.39
|
||||
# and removes the hard `Depends: libavcodec62`, so the ONE host .deb installs
|
||||
# on Ubuntu 24.04 LTS through 26.04. (A 26.04-built host .deb is uninstallable
|
||||
# on 24.04 — the reason this job exists; see packaging/debian/README.md.)
|
||||
# on Ubuntu 24.04 LTS through 26.04 — and, for free, on Debian 13.
|
||||
# (A 26.04-built host .deb is uninstallable on 24.04 — the reason this job
|
||||
# exists; see packaging/debian/README.md.)
|
||||
# build-publish-gamescope
|
||||
# — the patched `punktfunk-gamescope`, on DEBIAN 13. It lived in the host job
|
||||
# until 2026-08 and never once succeeded there: noble's wayland is 1.22.0
|
||||
# and the vendored wlroots floors it at 1.23.1, so v0.26.0 and v0.27.0 both
|
||||
# shipped without the package while the docs told apt users to install it.
|
||||
# smoke-install — installs what was just published, from the registry, in pristine
|
||||
# ubuntu:24.04 / ubuntu:26.04 / debian:trixie images. Nothing here used to
|
||||
# install a package it built, which is how both of the above survived.
|
||||
#
|
||||
# Both compute VERSION identically (scripts/ci/pf-version.sh is deterministic per commit), so the
|
||||
# host and client packages always share a version line. The release-attach helpers are race-safe
|
||||
@@ -337,82 +346,6 @@ jobs:
|
||||
bash scripts/ci/assert-cap-matrix.sh --self-test
|
||||
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-host_*.deb
|
||||
|
||||
# punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged
|
||||
# build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a
|
||||
# stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz.
|
||||
#
|
||||
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
|
||||
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
|
||||
- uses: actions/cache@v4
|
||||
id: gamescope
|
||||
with:
|
||||
path: gs-cache
|
||||
key: punktfunk-gamescope-noble-${{ hashFiles('packaging/gamescope/**') }}
|
||||
|
||||
- name: Build the patched gamescope
|
||||
if: steps.gamescope.outputs.cache-hit != 'true'
|
||||
# Best-effort, exactly like rpm.yml: the host packages above are the primary delivery and
|
||||
# work without this binary, so a hiccup building an unrelated tree must not fail the job.
|
||||
# `build-dep gamescope` resolves the distro's much older packaged version, so it can come up
|
||||
# short — that is what the `|| true`s absorb, and the marker check downstream is what makes
|
||||
# a half-built result impossible to ship.
|
||||
run: |
|
||||
set -x
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends meson ninja-build glslc git || true
|
||||
apt-get build-dep -y gamescope || true
|
||||
# NOT best-effort. `build-dep gamescope` resolves the distro's much older packaged
|
||||
# gamescope — where noble has one at all — so it misses what the master tree needs, and
|
||||
# wayland-protocols is the gap that actually stops the build: meson dies in
|
||||
# protocol/meson.build with "Neither a subproject directory nor a wayland-protocols.wrap
|
||||
# file was found", because the tree has no wrap fallback for it. That is what happened on
|
||||
# the v0.26.0 tag: the step warned and skipped, the job stayed green, and the release
|
||||
# shipped with no gamescope .deb while the notes said it had one.
|
||||
apt-get install -y --no-install-recommends wayland-protocols
|
||||
# The remaining Arch makedepends the older packaged gamescope does not necessarily pull.
|
||||
# Best-effort: meson falls back or does without, and a name that moves between Ubuntu
|
||||
# releases should not fail the job. (No libstdc++ static package is needed here — g++
|
||||
# ships libstdc++.a, which is why only Fedora tripped the sanity check.)
|
||||
# `build-dep gamescope` gives noble almost nothing — the distro has no comparable package
|
||||
# — so the tree's real dependency set has to be named outright. One `apt-get` per name on
|
||||
# purpose: a single transaction aborts wholesale on one unknown package, which would
|
||||
# install NOTHING and hide the real gap behind a name typo. Best-effort per package, with
|
||||
# the missing one named; the end-of-job gate below is what actually decides.
|
||||
for p in libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
|
||||
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
|
||||
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
|
||||
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
|
||||
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
|
||||
glslang-tools libvulkan-dev libwayland-dev libxcb1-dev libxcb-composite0-dev \
|
||||
libxcb-xfixes0-dev libxcb-res0-dev libxcb-ewmh-dev libxcb-icccm4-dev \
|
||||
libxcb-errors-dev libpixman-1-dev libdisplay-info-dev libgbm-dev libegl-dev \
|
||||
cmake xwayland; do
|
||||
apt-get install -y --no-install-recommends "$p" \
|
||||
|| echo "::warning::no such noble package: $p (gamescope may still build without it)"
|
||||
done
|
||||
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
|
||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||
else
|
||||
# Warn only, even on a tag. The hard gate moved to the END of this job: failing HERE
|
||||
# skips the host .deb's own publish + release-attach steps below, which is how the
|
||||
# v0.26.0 release ended up still carrying the pre-CAP_SYS_NICE host .deb from an
|
||||
# earlier tag commit — a KDE-breaking artifact withheld from replacement by a gate
|
||||
# meant to protect the release. Never let a missing EXTRA stop a good artifact
|
||||
# shipping; go red afterwards instead.
|
||||
echo "::warning::punktfunk-gamescope failed to build on noble — no .deb this run (gamescope sessions stay SDR)"
|
||||
fi
|
||||
|
||||
- name: Build punktfunk-gamescope .deb
|
||||
# Picked up by the publish loop below, which globs dist/*.deb.
|
||||
run: |
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
|
||||
else
|
||||
# Warn only — see the note on the build step. The gate is the last step of this job.
|
||||
echo "::warning::no usable punktfunk-gamescope — skipping its .deb"
|
||||
fi
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
@@ -440,25 +373,109 @@ jobs:
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
# A release must not be able to make a claim its own CI silently dropped: v0.26.0's notes and
|
||||
# docs-site said the patched gamescope was apt-installable while no .deb had ever been built,
|
||||
# because every failure on this path was a `::warning::` that returned 0.
|
||||
#
|
||||
# ⚠ LAST step on purpose. The first version of this gate failed at the build step instead, and
|
||||
# that skipped the host .deb's own publish + attach below — so the release kept the PREVIOUS
|
||||
# tag commit's host .deb, which still carried the CAP_SYS_NICE postinst that breaks KDE. A
|
||||
# gate protecting the release withheld the fix for it. Everything good ships first; the job
|
||||
# goes red afterwards.
|
||||
- name: A stable tag must ship the gamescope .deb
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# `punktfunk-gamescope` for apt — the patched build that gives a gamescope session HDR, a cursor,
|
||||
# and the client's real refresh rate instead of a hardcoded 60 Hz. Same reasoning as the RPM leg
|
||||
# in rpm.yml: without a package, a Debian/Ubuntu box has no route to it except compiling by hand.
|
||||
#
|
||||
# ⚠ THIS IS ITS OWN JOB, ON DEBIAN 13, BECAUSE THE NOBLE BUILD COULD NEVER HAVE WORKED.
|
||||
# It used to be a few best-effort steps inside build-publish-host (Ubuntu 24.04), where it failed
|
||||
# every single time:
|
||||
# wlroots| Dependency wayland-server found: NO found 1.22.0 but need: '>=1.23.1'
|
||||
# Our pin vendors wlroots 0.19.3, which floors wayland-server at 1.23.1; noble ships 1.22.0 and
|
||||
# always will. v0.26.0 AND v0.27.0 both shipped with no gamescope .deb — while the release notes
|
||||
# and docs-site told apt users to install it — because every rung of that path was a `::warning::`
|
||||
# that returned 0, and the one hard gate ran last by design (so good artifacts still publish) and
|
||||
# was simply never acted on. Moving it to trixie makes the build possible; making it a job of its
|
||||
# own makes its failure visible instead of a footnote in someone else's log.
|
||||
#
|
||||
# Debian 13 is the OLDEST apt distro the tree configures on (wayland 1.23.1, libxcb-errors,
|
||||
# libdisplay-info 0.2). The binary it produces needs GLIBC_2.38 and links no libstdc++, so what
|
||||
# actually bounds it is wayland: Debian 13 and Ubuntu 26.04 YES, Ubuntu 24.04 NO — and 24.04
|
||||
# could not run this binary however it was built, so nothing was lost by leaving noble.
|
||||
build-publish-gamescope:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-gamescope-trixie:latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Byte-identical to the other jobs' version step (pf-version.sh is deterministic per commit)
|
||||
# — but only DISTRIBUTION is used here. The package version is the gamescope upstream
|
||||
# version + our patch level, which build-gamescope-deb.sh derives itself; it deliberately
|
||||
# does NOT follow the punktfunk version line, because this package moves on its own cadence.
|
||||
- name: Channel
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
built=(dist/punktfunk-gamescope_*.deb)
|
||||
if [ ${#built[@]} -eq 0 ]; then
|
||||
echo "::error::no punktfunk-gamescope .deb was built — a stable tag must not ship without it (the release notes and docs-site say it is apt-installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
|
||||
exit 1
|
||||
fi
|
||||
echo "gamescope .deb present: ${built[*]}"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) DIST=stable ;;
|
||||
*) DIST=canary ;;
|
||||
esac
|
||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
echo "gamescope -> apt distribution '$DIST'"
|
||||
|
||||
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
|
||||
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
|
||||
# Keyed `-trixie-` so the noble cache entries (which only ever held misses) can't be hit.
|
||||
- uses: actions/cache@v4
|
||||
id: gamescope
|
||||
with:
|
||||
path: gs-cache
|
||||
key: punktfunk-gamescope-trixie-${{ hashFiles('packaging/gamescope/**') }}
|
||||
|
||||
# NOT best-effort, unlike the noble version of this step. Every dependency now comes from the
|
||||
# image (which asserts the wayland floor at build time), so a failure here is a real
|
||||
# regression in the tree or the pin — exactly the thing the previous arrangement hid.
|
||||
- name: Build the patched gamescope
|
||||
if: steps.gamescope.outputs.cache-hit != 'true'
|
||||
# `--extra-fallback libdisplay-info` is what makes ONE .deb serve both Debian 13 and
|
||||
# Ubuntu 26.04. Built against the distro's copy, the package picks up
|
||||
# `Depends: libdisplay-info2 (>= 0.2.0)` on trixie — and Ubuntu 26.04 carries
|
||||
# libdisplay-info **3** (0.3.0), so apt refuses it there ("Depends libdisplay-info2 …" —
|
||||
# measured, not predicted). gamescope vendors the library as a submodule, so linking the
|
||||
# vendored copy drops the dependency entirely. Same reasoning the build script already
|
||||
# applies to wlroots: a binary we SHIP must not follow the build host's shared libraries.
|
||||
run: |
|
||||
bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)" \
|
||||
--extra-fallback libdisplay-info
|
||||
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||
|
||||
# The binary must RUN, not merely link: `--version` is what the old job used as its ship
|
||||
# gate, and it is the cheapest proof that the static-libstdc++ trick and the vendored wlroots
|
||||
# actually produced a working compositor.
|
||||
- name: Build the .deb
|
||||
run: |
|
||||
gs-cache/punktfunk-gamescope --version
|
||||
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
|
||||
|
||||
- name: Publish to the Gitea apt registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
for DEB in dist/punktfunk-gamescope_*.deb; do
|
||||
echo "uploading $DEB"
|
||||
NAME=$(dpkg-deb -f "$DEB" Package)
|
||||
VER=$(dpkg-deb -f "$DEB" Version)
|
||||
ARCH=$(dpkg-deb -f "$DEB" Architecture)
|
||||
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
|
||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
|
||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||
done
|
||||
echo "published gamescope to $OWNER/debian $DISTRIBUTION/$COMPONENT"
|
||||
|
||||
- name: Attach the gamescope .deb to the Gitea release (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
. scripts/ci/gitea-release.sh
|
||||
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||
for DEB in dist/punktfunk-gamescope_*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
|
||||
@@ -560,3 +577,57 @@ jobs:
|
||||
for DEB in dist/*.deb; do
|
||||
upsert_asset "$RID" "$DEB"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Does the thing we just published actually INSTALL? Nothing in this repo asked that before, and
|
||||
# the cost of not asking was two independent, long-lived facts nobody knew:
|
||||
# * `punktfunk-host` has installed cleanly on Debian 13 for a long time — while docs-site said
|
||||
# "Debian isn't a supported target … nobody has verified it".
|
||||
# * `punktfunk-gamescope` was missing from apt entirely across two releases.
|
||||
# Both are exactly what a five-minute install check catches, so it is now a job.
|
||||
#
|
||||
# It runs on the RUNNER (no `container:`) and drives docker directly — the same access
|
||||
# docker.yml's image builds use — because the check must happen in a pristine distro image, not
|
||||
# in a builder image with the deps already present.
|
||||
#
|
||||
# It installs FROM THE REGISTRY, after the publish jobs, rather than from a local .deb: that
|
||||
# exercises the real path a user takes (repo key, apt distribution, dependency resolution against
|
||||
# the distro's own package set), and it matches this workflow's established order — publish the
|
||||
# good artifacts first, go red afterwards, never let a gate withhold a shipping fix.
|
||||
smoke-install:
|
||||
needs: [build-publish, build-publish-host, build-publish-gamescope]
|
||||
# `needs` for ORDER only — this must still run when a builder went red, or the failure that
|
||||
# matters most (a package that did not publish) is exactly the one that skips its own check and
|
||||
# leaves the run looking merely "partly red" instead of saying what a user would hit.
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Recomputes the SAME version string the builder jobs stamped — pf-version.sh is
|
||||
# deterministic per commit and GITHUB_RUN_NUMBER is shared across a run's jobs — so the check
|
||||
# below can insist the registry is serving THIS run's build. Without that, a smoke job that
|
||||
# beats the index regeneration installs the previous build, passes, and proves nothing.
|
||||
- name: Channel + expected version
|
||||
run: |
|
||||
git config --global --add safe.directory "$PWD"
|
||||
eval "$(bash scripts/ci/pf-version.sh)"
|
||||
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||
esac
|
||||
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||
echo "EXPECT_VERSION=$V" >> "$GITHUB_ENV"
|
||||
echo "expecting $V in apt distribution '$DIST'"
|
||||
|
||||
# The support matrix, asserted rather than asserted-in-prose. Each row names the packages
|
||||
# that MUST install on that distro; anything absent from the row is expected not to and is
|
||||
# not checked here (the client's glibc 2.43 floor keeps it off 24.04 and Debian 13 —
|
||||
# see docs-site/content/docs/debian.md).
|
||||
- name: Install from the apt registry on every supported distro
|
||||
run: bash scripts/ci/deb-install-smoke.sh
|
||||
env:
|
||||
PF_APT_DISTRIBUTION: ${{ env.DISTRIBUTION }}
|
||||
PF_EXPECT_VERSION: ${{ env.EXPECT_VERSION }}
|
||||
|
||||
@@ -96,6 +96,12 @@ jobs:
|
||||
# (rust-ci's 26.04 build is uninstallable there). Consumed by deb.yml's build-publish-host job.
|
||||
- image: punktfunk-rust-ci-noble
|
||||
dockerfile: ci/rust-ci-noble.Dockerfile
|
||||
# Debian 13 gamescope builder. The gamescope .deb used to be built in the noble image
|
||||
# and NEVER once succeeded there — noble's wayland is 1.22.0 and the vendored wlroots
|
||||
# 0.19.3 floors it at 1.23.1, so two releases shipped without the package. trixie is the
|
||||
# oldest apt distro the tree configures on. Consumed by deb.yml's build-publish-gamescope.
|
||||
- image: punktfunk-gamescope-trixie
|
||||
dockerfile: ci/gamescope-trixie.Dockerfile
|
||||
- image: punktfunk-fedora-rpm
|
||||
dockerfile: ci/fedora-rpm.Dockerfile
|
||||
# Fedora 44 builder (Fedora KDE spin): same Dockerfile, newer base → libavcodec.so.62.
|
||||
|
||||
@@ -198,6 +198,64 @@ Streaming sessions still hold the box awake through their own `PowerRequest` ass
|
||||
before. New knob: `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a
|
||||
third-party virtual audio driver misbehaves while its render side is paused.
|
||||
|
||||
### Windows host — audio no longer costs local-game frame time
|
||||
|
||||
🛑 **The host could tank a locally-played game's frame lows** (field-reported 2026-08-12:
|
||||
Helldivers 2 at 1% lows of 2–5 FPS, cured by uninstalling). Two mechanisms, both fixed:
|
||||
|
||||
- **The minted-endpoint retry storm.** The virtual-mic resolve ran a FULL provisioning pass on
|
||||
every reopen with no cooldown, no in-flight guard, and no give-up — and the pass reached
|
||||
`UpdateDriverForPlugAndPlayDevicesW` even over an already-existing devnode. On a box where
|
||||
minting cannot converge, the pump's reopen backoff (capped 60 s) turned that into a SetupAPI
|
||||
sweep + PnP driver re-bind + default-device writes roughly once a minute, forever — each
|
||||
raising the system-wide device-change broadcast games service by rebuilding their audio
|
||||
graphs. Provisioning now short-circuits to a no-PnP fast path while the minted devices are
|
||||
healthy, waits on an in-flight pass instead of racing a second one, honours the 60 s retry
|
||||
cooldown from the blocking path too, and stops for the host lifetime after five unlatched
|
||||
passes (a service restart re-arms minting).
|
||||
- **Session tuning never reverted.** The first streaming session put the whole host process at
|
||||
HIGH priority class with a 1 ms global timer (`timeBeginPeriod`) and DWM MMCSS, documented as
|
||||
"reverts at process exit" — but the host is a 24/7 service, so after one stream it competed
|
||||
at HIGH priority against whatever the user played locally, forever. The process-wide tuning
|
||||
is now refcounted across the hot stream threads and reverts when the last one exits
|
||||
(= session teardown), the same lifetime the per-thread MMCSS effects already ride.
|
||||
|
||||
### Debian 13 is a supported target, and `punktfunk-gamescope` reaches apt for the first time
|
||||
|
||||
🛑 **The `punktfunk-gamescope` .deb had never been published — not once, in any release.** It was
|
||||
built inside the host job's Ubuntu 24.04 image, where it cannot build: our pin vendors wlroots
|
||||
0.19.3, which floors `wayland-server` at 1.23.1, and noble ships 1.22.0 (it also has no
|
||||
`libxcb-errors-dev` and only libdisplay-info 0.1.1). Every rung of that path was a `::warning::`
|
||||
returning 0, and the one hard gate ran last by design so good artifacts still shipped — so
|
||||
**v0.26.0 and v0.27.0 both released with the package missing** while the release notes and
|
||||
docs-site told Debian/Ubuntu users to `apt install` it. The same tag shipped it fine for Arch,
|
||||
Fedora 44 and Bazzite; apt was the only platform affected.
|
||||
|
||||
It now has its own job on **Debian 13** (`ci/gamescope-trixie.Dockerfile`), the oldest apt base the
|
||||
tree configures on. One package serves Debian 13 **and** Ubuntu 26.04 — verified by installing and
|
||||
running it on both — because the build additionally vendors libdisplay-info
|
||||
(`build-punktfunk-gamescope.sh --extra-fallback libdisplay-info`, opt-in so the Arch/Fedora/nix
|
||||
outputs are unchanged): linked against the distro copy it would demand `libdisplay-info2` on trixie,
|
||||
which Ubuntu 26.04 does not have (it carries `libdisplay-info3`). **Ubuntu 24.04 gets no gamescope
|
||||
package** — its wayland is too old to run one, however it is built.
|
||||
|
||||
⭐ **Debian 13 is now a documented, CI-tested host target** ([docs](https://docs.punktfunk.unom.io/docs/debian)).
|
||||
It required no packaging change: the host .deb's glibc-2.39 floor and bundled FFmpeg already made
|
||||
it installable, and it had been working for a long time while docs-site said Debian was unsupported
|
||||
and unverified. The desktop **client** remains Ubuntu-26.04-only (built there, floors at
|
||||
`libc6 >= 2.43`; Debian 13 has 2.41).
|
||||
|
||||
⚠ **Cinnamon (Linux Mint, LMDE) cannot host a virtual display**, and compositor detection now says
|
||||
so instead of advising a `PUNKTFUNK_COMPOSITOR` value that cannot help. Muffin forked from Mutter
|
||||
3.36: `org.cinnamon.Muffin.ScreenCast` has only `RecordMonitor`/`RecordWindow`, never
|
||||
`RecordVirtual`, and `xdg-desktop-portal-xapp` implements no ScreenCast at all. The error names the
|
||||
route that does work on those boxes — a headless gamescope, which needs no desktop compositor.
|
||||
|
||||
New CI job **`smoke-install`** installs every published package from the registry in pristine
|
||||
`ubuntu:24.04`, `ubuntu:26.04` and `debian:trixie` images and asserts the version served is the one
|
||||
the run just built. Nothing in `deb.yml` had ever installed a package it produced, which is how
|
||||
both facts above survived for so long.
|
||||
|
||||
## v0.27.0
|
||||
|
||||
87 commits since v0.26.0.
|
||||
|
||||
@@ -100,7 +100,7 @@ installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
|
||||
| Platform | Install | Guide |
|
||||
|--------|---------|-------|
|
||||
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu / Debian](https://docs.punktfunk.unom.io/docs/ubuntu) · [packaging/debian](packaging/debian/README.md) |
|
||||
| **Ubuntu 26.04+ / Debian 13+** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu](https://docs.punktfunk.unom.io/docs/ubuntu) · [Debian](https://docs.punktfunk.unom.io/docs/debian) · [packaging/debian](packaging/debian/README.md) |
|
||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Fedora** (dnf) | `sudo dnf install punktfunk` *(after adding the repo; the console comes with it)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
|
||||
| **Arch / CachyOS** (pacman) | `sudo pacman -Syu punktfunk-host` *(binary repo — always a full `-Syu`)* | [Arch Linux](https://docs.punktfunk.unom.io/docs/arch) · [packaging/arch](packaging/arch/README.md) |
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Builder for the `punktfunk-gamescope` .deb — Debian 13 (trixie).
|
||||
#
|
||||
# WHY THIS EXISTS, AND WHY IT IS NOT THE NOBLE IMAGE:
|
||||
# The gamescope .deb was built in the host job's Ubuntu 24.04 (noble) image, and it has NEVER once
|
||||
# succeeded there — v0.26.0 and v0.27.0 both shipped with no gamescope package while the release
|
||||
# notes and docs-site said it was apt-installable. The failure is structural, not a flaky dep:
|
||||
#
|
||||
# wlroots| Dependency wayland-server found: NO found 1.22.0 but need: '>=1.23.1'
|
||||
# subprojects/wlroots/meson.build:96:17: ERROR: Dependency 'wayland-server' is required but not found
|
||||
#
|
||||
# Our gamescope pin vendors wlroots 0.19.3, which floors wayland-server at 1.23.1. Noble ships
|
||||
# 1.22.0 and will never ship more — so no amount of `apt-get install` in that image can fix it.
|
||||
# Noble also has no `libxcb-errors-dev` at all and only libdisplay-info 0.1.1 (the tree wants 0.2).
|
||||
#
|
||||
# Debian 13 ships wayland 1.23.1 exactly, libxcb-errors 1.0.1 and libdisplay-info 0.2.0 — the
|
||||
# oldest apt distro the tree actually builds on. Building HERE rather than on Ubuntu 26.04
|
||||
# (wayland 1.24, libdisplay-info 0.3) is deliberate twice over: it keeps the glibc floor low, and
|
||||
# it stays on the libdisplay-info 0.2 line the pin was developed against.
|
||||
#
|
||||
# WHAT THE RESULTING BINARY RUNS ON — verified by building it and reading the ELF:
|
||||
# * glibc floor GLIBC_2.38 (the C++ runtime is linked statically by
|
||||
# build-punktfunk-gamescope.sh, so libstdc++ never enters the NEEDED list)
|
||||
# * NEEDED libwayland-server.so.0 / libwayland-client.so.0 — wlroots 0.19 calls symbols
|
||||
# added in 1.23.1, so THAT, not glibc, is the real floor.
|
||||
# ⇒ Debian 13 (1.23.1) and Ubuntu 26.04 (1.24.0) YES; Ubuntu 24.04 (1.22.0) NO — and 24.04
|
||||
# could not run this binary however it was built, so nothing is lost by moving off noble.
|
||||
#
|
||||
# Rebuilt+pushed by .gitea/workflows/docker.yml (matrix: punktfunk-gamescope-trixie); consumed by
|
||||
# the `build-publish-gamescope` job in .gitea/workflows/deb.yml. Bootstrap: like rust-ci-noble, the
|
||||
# first deb.yml run after this image is added needs the image to already exist — seed it once by
|
||||
# hand (docker build -f ci/gamescope-trixie.Dockerfile -t <registry>/punktfunk-gamescope-trixie:latest ci
|
||||
# && docker push …) before that job can run.
|
||||
FROM debian:trixie
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# nodejs is not optional: the Gitea runner executes the JS actions (checkout/cache) INSIDE this
|
||||
# container, so an image without it fails before the first `run:` step ever starts.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config cmake meson ninja-build git curl ca-certificates nodejs \
|
||||
# .deb assembly (dpkg-shlibdeps computes the runtime Depends from the built binary)
|
||||
dpkg-dev \
|
||||
# shader compilers gamescope's meson looks for
|
||||
glslc glslang-tools \
|
||||
# wayland + protocols. libwayland-dev 1.23.1 is the whole reason this image is Debian.
|
||||
libwayland-dev wayland-protocols \
|
||||
# gamescope's own dependency set. `apt-get build-dep gamescope` is useless here — Debian has
|
||||
# no gamescope package to derive it from — so the tree's needs are named outright, exactly as
|
||||
# the noble job had to. Kept as ONE transaction on purpose: in an image build a missing name
|
||||
# SHOULD fail loudly at build time, unlike the workflow's per-package best-effort loop where a
|
||||
# rename would have silently dropped a dep into a warning nobody reads.
|
||||
libxdamage-dev libxcomposite-dev libxrender-dev libxext-dev libxxf86vm-dev \
|
||||
libxtst-dev libx11-dev libxres-dev libxmu-dev libxcursor-dev libxi-dev \
|
||||
libxfixes-dev libxkbcommon-dev libxkbcommon-x11-dev libcap-dev libdrm-dev \
|
||||
libinput-dev libudev-dev libpipewire-0.3-dev libseat-dev libsdl2-dev \
|
||||
libluajit-5.1-dev libavif-dev libdecor-0-dev hwdata libglm-dev libbenchmark-dev \
|
||||
libvulkan-dev libxcb1-dev libxcb-composite0-dev libxcb-xfixes0-dev libxcb-res0-dev \
|
||||
libxcb-ewmh-dev libxcb-icccm4-dev libxcb-errors-dev libxcb-shape0-dev \
|
||||
libpixman-1-dev libdisplay-info-dev libgbm-dev libegl-dev xwayland \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Assert the ONE version that decides whether this image can do its job, so a future Debian base
|
||||
# bump that regressed it fails HERE (loudly, at image build) instead of in a deb.yml run whose
|
||||
# gamescope failure has historically been a `::warning::` nobody saw.
|
||||
RUN set -eux; \
|
||||
have="$(pkg-config --modversion wayland-server)"; \
|
||||
pkg-config --atleast-version=1.23.1 wayland-server \
|
||||
|| { echo "wayland-server $have < 1.23.1 — the vendored wlroots will not configure" >&2; exit 1; }; \
|
||||
echo "wayland-server $have — OK"
|
||||
@@ -215,9 +215,10 @@ export function useHosts() {
|
||||
const [views, setViews] = useState<HostView[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
// Why the list is empty, when it is empty for a reason other than an empty LAN. Rendering
|
||||
// either of these as "No hosts yet" would blame the user's network for the plugin's problem:
|
||||
// any of these as "No hosts yet" would blame the user's network for the plugin's problem:
|
||||
// "client-outdated" — the installed client predates `punktfunk discover`
|
||||
// "client-unavailable" — there is no client installed at all
|
||||
// "list-failed" — the refresh itself blew up (backend down, call threw)
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -236,7 +237,11 @@ export function useHosts() {
|
||||
);
|
||||
setViews(mergeHosts(s.hosts ?? [], d.hosts ?? []));
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` });
|
||||
// Inline, not a toast: the panel remounts (and refreshes) on every QAM open, so while
|
||||
// the backend is unhappy a toast here nagged on each open. The panel row also sits next
|
||||
// to the Refresh button that retries it, which is where the eyes already are.
|
||||
console.warn("punktfunk: host list refresh failed", e);
|
||||
setProblem("list-failed");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
@@ -454,9 +459,12 @@ export async function startStream(
|
||||
): Promise<void> {
|
||||
try {
|
||||
await launchStream(v.ref, opts);
|
||||
// No success toast: the user just pressed the button that names this host/card, the QAM
|
||||
// closes, and Steam's own launch UI takes over — a toast here fired on EVERY launch and
|
||||
// then sat on top of the starting stream. Failure still toasts (the QAM may already be
|
||||
// closed, so inline error state would go unseen).
|
||||
Navigation.CloseSideMenus();
|
||||
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${v.name}` });
|
||||
} catch (e) {
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
|
||||
toaster.toast({ title: "Punktfunk", body: `Launch failed${label ? ` (${label})` : ""}: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,15 +46,23 @@ import { OsMark } from "./os-icon";
|
||||
import { ensureGamepadUiShortcut, launchGamepadUi, recreateShortcuts, stopStream } from "./steam";
|
||||
import { TrustSheet } from "./trust";
|
||||
|
||||
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut.
|
||||
// Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut
|
||||
// and sweeps duplicate entries (the piles a boot race used to mint, one per Steam start).
|
||||
// Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's
|
||||
// CEF localStorage that self-heal fixes on the next mount, but this gives an in-session button
|
||||
// that works even without a reload. Always ends in a toast so the tap has feedback.
|
||||
async function recreatePunktfunkShortcut(): Promise<void> {
|
||||
const appId = await recreateShortcuts();
|
||||
const { appId, removedDuplicates } = await recreateShortcuts();
|
||||
toaster.toast({
|
||||
title: "Punktfunk",
|
||||
body: appId != null ? "Shortcut restored to your library" : "Couldn't create the shortcut",
|
||||
body:
|
||||
appId == null
|
||||
? "Couldn't create the shortcut"
|
||||
: removedDuplicates > 0
|
||||
? `Shortcut restored — removed ${removedDuplicates} duplicate ${
|
||||
removedDuplicates === 1 ? "entry" : "entries"
|
||||
}`
|
||||
: "Shortcut restored to your library",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -222,12 +230,16 @@ const QamPanel: FC = () => {
|
||||
label={
|
||||
problem === "client-unavailable"
|
||||
? "Punktfunk isn’t installed"
|
||||
: "Update the Punktfunk client"
|
||||
: problem === "list-failed"
|
||||
? "Couldn’t scan for hosts"
|
||||
: "Update the Punktfunk client"
|
||||
}
|
||||
description={
|
||||
problem === "client-unavailable"
|
||||
? "This panel launches the Punktfunk app, which isn’t on this Deck yet. Install it in Desktop Mode."
|
||||
: "This client is too old to find hosts on your network. Saved hosts still work."
|
||||
: problem === "list-failed"
|
||||
? "Something went wrong while scanning — Refresh tries again."
|
||||
: "This client is too old to find hosts on your network. Saved hosts still work."
|
||||
}
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
@@ -313,7 +325,7 @@ const QamPanel: FC = () => {
|
||||
<PanelSectionRow>
|
||||
<ButtonItem
|
||||
layout="below"
|
||||
description="Missing the Punktfunk entry in your library? This puts it back."
|
||||
description="Missing the Punktfunk entry in your library, or seeing several? This puts one back and removes the rest."
|
||||
onClick={() => void recreatePunktfunkShortcut()}
|
||||
>
|
||||
<FaPlus style={{ marginRight: "0.5em" }} />
|
||||
|
||||
+220
-39
@@ -44,6 +44,7 @@ declare const SteamClient: {
|
||||
): Promise<unknown>;
|
||||
RunGame(gameId: string, _unused: string, _i: number, _j: number): void;
|
||||
TerminateApp(gameId: string, _b: boolean): void;
|
||||
RemoveShortcut(appId: number): void;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,29 +63,114 @@ declare const collectionStore:
|
||||
// that the reuse path below silently repoints (SetShortcut* on a dead id is a no-op), and the
|
||||
// entry never comes back.
|
||||
declare const appStore:
|
||||
| { GetAppOverviewByAppID?: (appId: number) => unknown | null }
|
||||
| {
|
||||
GetAppOverviewByAppID?: (appId: number) => unknown | null;
|
||||
allApps?: SteamAppOverviewLike[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
/** True if a remembered appId still maps to a live Steam shortcut. When appStore is unavailable
|
||||
* we can't tell, so assume it exists — better to keep reusing than risk a duplicate library
|
||||
* entry from a false "missing". A confident null means the shortcut was deleted → recreate. */
|
||||
function shortcutStillExists(appId: number): boolean {
|
||||
// The overview surface we read when scanning the library — Steam internals, so everything is
|
||||
// optional and accessed defensively.
|
||||
interface SteamAppOverviewLike {
|
||||
appid?: number;
|
||||
display_name?: string;
|
||||
BIsShortcut?: () => boolean;
|
||||
}
|
||||
|
||||
// Steam-injected global whose WaitForServicesInitialized resolves once the client's app
|
||||
// services are up (the MoonDeck-verified readiness signal). Services-init alone doesn't
|
||||
// guarantee the overview map is populated, so it's paired with the hydration witness below.
|
||||
declare const App:
|
||||
| { WaitForServicesInitialized?: () => Promise<boolean> }
|
||||
| undefined;
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
let servicesInitialized: Promise<void> | undefined;
|
||||
function waitForServicesInitialized(): Promise<void> {
|
||||
servicesInitialized ??= (async () => {
|
||||
try {
|
||||
if (typeof App !== "undefined" && App?.WaitForServicesInitialized) {
|
||||
await App.WaitForServicesInitialized();
|
||||
}
|
||||
} catch {
|
||||
/* no signal — the hydration witness still gates the verdict */
|
||||
}
|
||||
})();
|
||||
return servicesInitialized;
|
||||
}
|
||||
|
||||
/** Has appStore demonstrably finished its initial load? An empty `allApps` means "not yet":
|
||||
* any account that ever had our shortcut has at least one app, so a populated map is the
|
||||
* witness that a null overview lookup is an ANSWER rather than a not-loaded-yet. null =
|
||||
* can't tell (missing global, API drift). */
|
||||
function appStoreHydrated(): boolean | null {
|
||||
try {
|
||||
if (typeof appStore === "undefined" || !appStore) {
|
||||
return null;
|
||||
}
|
||||
const apps = appStore.allApps;
|
||||
return Array.isArray(apps) ? apps.length > 0 : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One overview lookup: true = live, false = absent, null = can't tell. */
|
||||
function queryShortcutAlive(appId: number): boolean | null {
|
||||
try {
|
||||
// Call it as a METHOD on appStore — NEVER as an extracted function. Its implementation
|
||||
// reads the store's own state (`this.m_mapApps`), so `const get = appStore.GetAppOverview…;
|
||||
// get(id)` throws on the lost `this`, and the catch below turns that into a permanent
|
||||
// "true". That is not a stale-data bug but a total one: the guard then answers "still
|
||||
// exists" for EVERY appId, so a dangling id is never dropped, the reuse path repoints a
|
||||
// dead shortcut (silent no-ops), and "recreate" reports success having done nothing.
|
||||
// `typeof` first: `appStore` is a Steam-injected global, and a bare reference to a missing
|
||||
// one is a ReferenceError that optional chaining does NOT prevent.
|
||||
// "can't tell". `typeof` first: `appStore` is a Steam-injected global, and a bare
|
||||
// reference to a missing one is a ReferenceError that optional chaining does NOT prevent.
|
||||
if (typeof appStore === "undefined" || !appStore?.GetAppOverviewByAppID) {
|
||||
return true; // no way to verify — preserve the reuse path
|
||||
return null;
|
||||
}
|
||||
return appStore.GetAppOverviewByAppID(appId) != null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// How long to wait for the app store before conceding liveness can't be verified. A Deck boot
|
||||
// hydrates the store within a few seconds of plugin mount; 30 s is comfortably past any real
|
||||
// boot, and the wait only burns on the absent/unverifiable paths — a live overview answers on
|
||||
// the first query. Overview registration can trail the bulk hydration by a beat, so a
|
||||
// "hydrated but absent" verdict gets one grace recheck before it counts as deleted.
|
||||
const STORE_WAIT_MS = 30_000;
|
||||
const STORE_POLL_MS = 1_000;
|
||||
const STORE_GRACE_MS = 2_000;
|
||||
|
||||
/** True if a remembered appId still maps to a live Steam shortcut.
|
||||
*
|
||||
* The dangerous verdict is FALSE — it sends the caller to AddShortcut, so a wrong "deleted"
|
||||
* mints a duplicate library entry. And a bare null-overview check gets it wrong on EVERY
|
||||
* boot: the plugin mounts while Steam is still starting up, before appStore has registered
|
||||
* its overviews, so the remembered (perfectly live) appId looks up as null and each boot
|
||||
* added another visible "Punktfunk" — the field-reported duplicate pile. Absent is therefore
|
||||
* only believed once the store is demonstrably hydrated; if that can't be established within
|
||||
* budget the answer is true, because a false "alive" merely no-ops Set-calls until the next
|
||||
* ask (and the recreate button re-asks when the store IS ready) while a false "dead"
|
||||
* duplicates forever. */
|
||||
async function shortcutStillExists(appId: number): Promise<boolean> {
|
||||
if (queryShortcutAlive(appId) === true) {
|
||||
return true;
|
||||
}
|
||||
// Race the init signal against the same budget the poll loop gets: a signal that never
|
||||
// resolves must not wedge the guard (the single-flight ensure would stay occupied forever).
|
||||
await Promise.race([waitForServicesInitialized(), sleep(STORE_WAIT_MS)]);
|
||||
for (let waited = 0; waited < STORE_WAIT_MS; waited += STORE_POLL_MS) {
|
||||
if (queryShortcutAlive(appId) === true) {
|
||||
return true;
|
||||
}
|
||||
if (appStoreHydrated() === true) {
|
||||
await sleep(STORE_GRACE_MS);
|
||||
return queryShortcutAlive(appId) !== false; // null = unverifiable → reuse
|
||||
}
|
||||
await sleep(STORE_POLL_MS);
|
||||
}
|
||||
return true; // store never became inspectable — reusing beats duplicating
|
||||
}
|
||||
|
||||
/** Set a shortcut's library visibility (best-effort, deferred — the overview registers a moment
|
||||
@@ -156,6 +242,67 @@ async function applyArtwork(appId: number, isRetry = false): Promise<void> {
|
||||
// share it so Steam keys them to the SAME controller config (configset key = lowercase name).
|
||||
const SHORTCUT_NAME = "Punktfunk";
|
||||
|
||||
/** Find an existing "Punktfunk" shortcut to ADOPT instead of minting a new library entry — the
|
||||
* healing path for a lost/wiped appId, and for the duplicate piles the boot race left behind
|
||||
* in the field: rebind one of the existing entries to the role rather than adding an N+1th.
|
||||
* (The caller rewrites exe/dir/opts/visibility anyway, so any of them serves.) Only overviews
|
||||
* Steam itself says are shortcuts qualify, and the other role's remembered id is excluded so
|
||||
* the two roles never collapse onto one shortcut. */
|
||||
function findAdoptableShortcut(excludeAppId: number | null): number | null {
|
||||
try {
|
||||
if (typeof appStore === "undefined" || !Array.isArray(appStore?.allApps)) {
|
||||
return null;
|
||||
}
|
||||
for (const app of appStore.allApps) {
|
||||
if (
|
||||
app?.display_name === SHORTCUT_NAME &&
|
||||
typeof app.appid === "number" &&
|
||||
app.appid !== excludeAppId &&
|
||||
app.BIsShortcut?.() === true
|
||||
) {
|
||||
return app.appid;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* Steam internals drifted — AddShortcut is the fallback */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Remove every "Punktfunk" shortcut beyond the two remembered role ids — the cleanup for
|
||||
* piles already minted by the boot race. Deliberately reachable ONLY from the user-pressed
|
||||
* recreate button, never from mount: automatic library deletion at boot is a bigger hazard
|
||||
* than the mess it would tidy. Returns how many entries were removed. */
|
||||
function removeDuplicateShortcuts(): number {
|
||||
let removed = 0;
|
||||
try {
|
||||
if (typeof appStore === "undefined" || !Array.isArray(appStore?.allApps)) {
|
||||
return 0;
|
||||
}
|
||||
const keep = [recall(STORAGE_KEY_STREAM), recall(STORAGE_KEY_UI)];
|
||||
// Snapshot before removing — RemoveShortcut mutates the store's list under the iteration.
|
||||
const surplus = appStore.allApps.filter(
|
||||
(app) =>
|
||||
app?.display_name === SHORTCUT_NAME &&
|
||||
typeof app.appid === "number" &&
|
||||
!keep.includes(app.appid) &&
|
||||
app.BIsShortcut?.() === true,
|
||||
);
|
||||
for (const app of surplus) {
|
||||
SteamClient.Apps.RemoveShortcut(app.appid as number);
|
||||
try {
|
||||
localStorage.removeItem(artKey(app.appid as number));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
removed++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("punktfunk: duplicate-shortcut sweep incomplete", e);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
|
||||
// preserving the exec bit, and ~/homebrew/plugins is root-owned so the unprivileged plugin
|
||||
// backend can't chmod it back on. Passing the script as an argument to the always-executable
|
||||
@@ -223,7 +370,7 @@ async function ensureControllerConfig(): Promise<void> {
|
||||
* the current runner path. Reuses/repoints the remembered shortcut (the plugin dir can change
|
||||
* across reinstalls, and pre-two-shortcut installs had this one visible).
|
||||
*/
|
||||
async function ensureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
|
||||
async function doEnsureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
throw new Error(`launch wrapper missing at ${info.runner}`);
|
||||
@@ -232,25 +379,38 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string;
|
||||
void ensureControllerConfig(); // fire-and-forget — never blocks the launch
|
||||
|
||||
// Reuse the remembered shortcut only if it still exists — a stale appId (shortcut deleted, key
|
||||
// outlived it across a reinstall) must fall through to AddShortcut, not be silently repointed.
|
||||
// outlived it across a reinstall) must fall through, not be silently repointed. On a lost id,
|
||||
// ADOPT an existing same-named shortcut before AddShortcut so a wiped key never duplicates.
|
||||
const remembered = recall(STORAGE_KEY_STREAM);
|
||||
if (remembered != null && shortcutStillExists(remembered)) {
|
||||
SteamClient.Apps.SetShortcutExe(remembered, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
|
||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
||||
setShortcutHidden(remembered, true); // migrate pre-two-shortcut installs (were visible)
|
||||
void applyArtwork(remembered);
|
||||
return { appId: remembered, runner: info.runner, clientBin: info.client_bin ?? "" };
|
||||
let appId =
|
||||
remembered != null && (await shortcutStillExists(remembered)) ? remembered : null;
|
||||
if (appId == null) {
|
||||
appId =
|
||||
findAdoptableShortcut(recall(STORAGE_KEY_UI)) ??
|
||||
(await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""));
|
||||
remember(STORAGE_KEY_STREAM, appId);
|
||||
}
|
||||
|
||||
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
setShortcutHidden(appId, true);
|
||||
setShortcutHidden(appId, true); // also migrates pre-two-shortcut installs (were visible)
|
||||
void applyArtwork(appId);
|
||||
remember(STORAGE_KEY_STREAM, appId);
|
||||
return { appId, runner: info.runner, clientBin: info.client_bin ?? "" };
|
||||
}
|
||||
|
||||
// Concurrent ensure calls share one run per role — two ensures racing past the liveness check
|
||||
// would each AddShortcut, which is exactly the duplicate class this file exists to prevent (and
|
||||
// the store-readiness wait makes the window real: mount's fire-and-forget ensure can be mid-wait
|
||||
// when a QAM press arrives). Sequential calls still re-run, so per-launch repointing is kept.
|
||||
let streamEnsureInFlight: Promise<{ appId: number; runner: string; clientBin: string }> | null =
|
||||
null;
|
||||
function ensureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
|
||||
streamEnsureInFlight ??= doEnsureStreamShortcut().finally(() => {
|
||||
streamEnsureInFlight = null;
|
||||
});
|
||||
return streamEnsureInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the GAMEPAD-UI shortcut (visible, stateless) — the library-facing "Punktfunk" entry
|
||||
* that opens the client's console home (bare `--browse`: host picker + pairing + settings).
|
||||
@@ -258,7 +418,7 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string;
|
||||
* kept VISIBLE. Idempotent — call on plugin mount so the library entry always exists and stays
|
||||
* repointed to the current plugin dir. Best-effort: returns null on any failure.
|
||||
*/
|
||||
export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
async function doEnsureGamepadUiShortcut(): Promise<number | null> {
|
||||
try {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
@@ -275,18 +435,20 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`;
|
||||
|
||||
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
|
||||
// localStorage key survived a plugin reinstall) falls through to AddShortcut so the visible
|
||||
// library entry actually comes back instead of repointing a dead id.
|
||||
// localStorage key survived a plugin reinstall) falls through so the visible library entry
|
||||
// actually comes back instead of repointing a dead id. On a lost id, ADOPT an existing
|
||||
// same-named shortcut (a boot-race duplicate, or the entry whose key was wiped) before
|
||||
// AddShortcut — creation is the last resort, never the response to a mere lookup miss.
|
||||
let appId = recall(STORAGE_KEY_UI);
|
||||
if (appId != null && shortcutStillExists(appId)) {
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
} else {
|
||||
appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
if (appId == null || !(await shortcutStillExists(appId))) {
|
||||
appId =
|
||||
findAdoptableShortcut(recall(STORAGE_KEY_STREAM)) ??
|
||||
(await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""));
|
||||
remember(STORAGE_KEY_UI, appId);
|
||||
}
|
||||
SteamClient.Apps.SetShortcutExe(appId, SHELL);
|
||||
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
|
||||
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
|
||||
SteamClient.Apps.SetAppLaunchOptions(appId, launchOpts);
|
||||
setShortcutHidden(appId, false); // the visible library entry
|
||||
void applyArtwork(appId);
|
||||
@@ -297,18 +459,32 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// Same single-flight rule as the stream role (see ensureStreamShortcut).
|
||||
let uiEnsureInFlight: Promise<number | null> | null = null;
|
||||
export function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
uiEnsureInFlight ??= doEnsureGamepadUiShortcut().finally(() => {
|
||||
uiEnsureInFlight = null;
|
||||
});
|
||||
return uiEnsureInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the visible "Punktfunk" library entry back into existence — the recovery button for
|
||||
* "my shortcut disappeared". Drops any remembered appId that no longer maps to a live shortcut
|
||||
* (so it can't shadow a fresh AddShortcut), then re-ensures. Safe to press anytime: a shortcut
|
||||
* that still exists is left in place (no duplicate); a missing one is recreated. Covers the case
|
||||
* self-heal-on-mount can't — deleting the shortcut WITHOUT reinstalling (no mount → no ensure).
|
||||
* Returns the (new or existing) visible appId, or null on failure.
|
||||
* Also sweeps surplus "Punktfunk" entries (the piles the boot race minted before the store-
|
||||
* readiness gate existed) — the button is where that cleanup lives, never mount. Returns the
|
||||
* (new or existing) visible appId (null on failure) plus how many duplicates were removed.
|
||||
*/
|
||||
export async function recreateShortcuts(): Promise<number | null> {
|
||||
export async function recreateShortcuts(): Promise<{
|
||||
appId: number | null;
|
||||
removedDuplicates: number;
|
||||
}> {
|
||||
for (const key of [STORAGE_KEY_STREAM, STORAGE_KEY_UI]) {
|
||||
const id = recall(key);
|
||||
if (id != null && !shortcutStillExists(id)) {
|
||||
if (id != null && !(await shortcutStillExists(id))) {
|
||||
try {
|
||||
localStorage.removeItem(artKey(id)); // stale art marker for the dead appId
|
||||
localStorage.removeItem(key);
|
||||
@@ -317,8 +493,13 @@ export async function recreateShortcuts(): Promise<number | null> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next launch.
|
||||
return ensureGamepadUiShortcut();
|
||||
// Recreate the visible entry now; the hidden stream shortcut re-registers lazily on next
|
||||
// launch. Sweep AFTER the ensure so the remembered ids are fresh — and only when the ensure
|
||||
// succeeded: on a failed ensure the "keep" list can't be trusted, and deleting candidates a
|
||||
// later ensure would adopt could leave the library with no entry at all.
|
||||
const appId = await ensureGamepadUiShortcut();
|
||||
const removedDuplicates = appId != null ? removeDuplicateShortcuts() : 0;
|
||||
return { appId, removedDuplicates };
|
||||
}
|
||||
|
||||
/** Launch the stateless gamepad-UI shortcut (console home) from the plugin, e.g. a QAM button. */
|
||||
|
||||
@@ -13,7 +13,13 @@
|
||||
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
|
||||
//! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end,
|
||||
//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed.
|
||||
#![forbid(unsafe_code)]
|
||||
// `deny`, not `forbid`: edition 2024 makes the std process-environment mutators unsafe
|
||||
// (WP20 — the env-mutation class made visible; named-API mentions here would count against
|
||||
// the unsafe-hygiene gate C baseline, which tracks this file's real call sites), and this
|
||||
// bin's three single-threaded-startup env writes carry documented SAFETY comments under
|
||||
// localized `#[allow(unsafe_code)]` (the pf-update idiom). A `forbid` cannot be overridden
|
||||
// at those sites and refuses the file.
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
||||
mod console;
|
||||
@@ -533,6 +539,7 @@ mod session_main {
|
||||
/// initialises, so a call placed after them leaves the triage tool describing a device
|
||||
/// that cannot decode while the streaming path decodes on it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(unsafe_code)] // the two SAFETY-commented single-threaded-startup env writes below
|
||||
fn enable_radv_video_decode() {
|
||||
const TOKEN: &str = "video_decode";
|
||||
match std::env::var("RADV_PERFTEST") {
|
||||
@@ -840,7 +847,10 @@ mod session_main {
|
||||
// SAFETY: still the single-threaded startup stretch of `run()` — the
|
||||
// early-exit probes above return out of the process, and everything that
|
||||
// spawns threads (the session, the console, SDL) only starts below.
|
||||
unsafe { std::env::set_var(var, value) };
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
std::env::set_var(var, value)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -856,7 +866,10 @@ mod session_main {
|
||||
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||
// SAFETY: as the settings block above — single-threaded startup, before SDL
|
||||
// (the reader of these variables) or any other thread exists.
|
||||
unsafe { std::env::remove_var(var) };
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
std::env::remove_var(var)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,14 +8,16 @@
|
||||
//!
|
||||
//! Raw C-ABI FFI (winmm/kernel32/dwmapi/avrt) rather than the `windows` crate so it builds without
|
||||
//! pulling new windows-rs features. No-op on non-Windows. Per-thread effects (MMCSS, execution
|
||||
//! state) auto-revert at thread exit (= session end); the process-wide bits revert at process exit.
|
||||
//! state) auto-revert at thread exit (= session end); the process-wide bits are refcounted over
|
||||
//! the hot threads and revert when the LAST one exits — the host must not keep HIGH priority and
|
||||
//! a 1 ms global timer while a local game runs and nobody streams (2026-08-12 field report).
|
||||
//! See `design/host-latency-plan.md` Tier 3A.
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod imp {
|
||||
#![allow(non_snake_case)]
|
||||
use std::ffi::c_void;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::Mutex;
|
||||
|
||||
type Handle = *mut c_void;
|
||||
type Bool = i32;
|
||||
@@ -23,6 +25,7 @@ mod imp {
|
||||
#[link(name = "winmm")]
|
||||
unsafe extern "system" {
|
||||
fn timeBeginPeriod(uPeriod: u32) -> u32;
|
||||
fn timeEndPeriod(uPeriod: u32) -> u32;
|
||||
}
|
||||
#[link(name = "kernel32")]
|
||||
unsafe extern "system" {
|
||||
@@ -55,6 +58,7 @@ mod imp {
|
||||
}
|
||||
|
||||
const HIGH_PRIORITY_CLASS: u32 = 0x0000_0080;
|
||||
const NORMAL_PRIORITY_CLASS: u32 = 0x0000_0020;
|
||||
const ES_CONTINUOUS: u32 = 0x8000_0000;
|
||||
const ES_SYSTEM_REQUIRED: u32 = 0x0000_0001;
|
||||
const ES_DISPLAY_REQUIRED: u32 = 0x0000_0002;
|
||||
@@ -114,16 +118,19 @@ mod imp {
|
||||
}
|
||||
}
|
||||
|
||||
static PROCESS_TUNED: OnceLock<()> = OnceLock::new();
|
||||
/// Live hot (session) threads. A Mutex, not an atomic: the 0↔1 transitions carry the
|
||||
/// apply/revert side effects, and an interleaved fetch_add/fetch_sub pair could otherwise
|
||||
/// finish with a running session untuned (transitions are rare — thread start/exit only).
|
||||
static HOT_THREADS: Mutex<usize> = Mutex::new(0);
|
||||
|
||||
/// Process-wide tuning, applied exactly once. Reverts at process exit. Best-effort: each call is
|
||||
/// independent and a failure is ignored (e.g. a non-elevated host may not get HIGH class).
|
||||
fn tune_process_once() {
|
||||
/// Process-wide tuning, applied when the FIRST hot thread registers. Best-effort: each call
|
||||
/// is independent and a failure is ignored (e.g. a non-elevated host may not get HIGH class).
|
||||
fn tune_process() {
|
||||
// SAFETY: each call is a C-ABI FFI into winmm/kernel32/dwmapi declared with a matching
|
||||
// `extern "system"` signature; every argument is a plain integer (no pointers/buffers escape),
|
||||
// and `GetCurrentProcess()` returns the current-process pseudo-handle (a constant, always valid,
|
||||
// never closed). The body runs inside `get_or_init`, so it executes exactly once per process.
|
||||
PROCESS_TUNED.get_or_init(|| unsafe {
|
||||
// never closed).
|
||||
unsafe {
|
||||
// 1 ms timer granularity (default ~15.6 ms) — the floor for precise frame pacing and the
|
||||
// encode|send split's sub-ms sleeps.
|
||||
timeBeginPeriod(1);
|
||||
@@ -134,16 +141,66 @@ mod imp {
|
||||
// control/capture/encode/send threads on the CPU (Apollo does the same).
|
||||
SetPriorityClass(GetCurrentProcess(), HIGH_PRIORITY_CLASS);
|
||||
tracing::info!("windows session tuning applied (timer 1ms, DWM MMCSS, HIGH priority)");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Call at the start of each capture/encode/send (hot stream) thread. Applies the process-wide
|
||||
/// tuning once, registers the calling thread with MMCSS ("Games"), and asserts the display/system
|
||||
/// must stay awake for as long as this thread lives. The MMCSS handle is intentionally leaked and
|
||||
/// the execution-state assertion is bound to this thread — both are reverted by the OS when the
|
||||
/// thread exits, so a session that ends tears them down without explicit bookkeeping.
|
||||
/// The mirror of [`tune_process`], run when the LAST hot thread exits. Leaving the tuning in
|
||||
/// place used to be the design ("reverts at process exit") — but the host is a 24/7 service,
|
||||
/// so after one stream it competed at HIGH class with a 1 ms global timer against whatever
|
||||
/// the user played locally, forever.
|
||||
fn untune_process() {
|
||||
// SAFETY: same FFI surface as `tune_process` — plain-integer arguments, constant
|
||||
// pseudo-handle, no pointers or buffers.
|
||||
unsafe {
|
||||
timeEndPeriod(1); // pairs the timeBeginPeriod(1)
|
||||
DwmEnableMMCSS(0);
|
||||
SetPriorityClass(GetCurrentProcess(), NORMAL_PRIORITY_CLASS);
|
||||
tracing::info!("windows session tuning reverted (timer, DWM MMCSS, NORMAL priority)");
|
||||
}
|
||||
}
|
||||
|
||||
/// One per hot thread, parked in TLS by [`on_hot_thread`]; its Drop runs at thread exit
|
||||
/// (= session teardown), the same lifetime the MMCSS/execution-state effects already ride.
|
||||
struct HotThreadGuard;
|
||||
|
||||
impl Drop for HotThreadGuard {
|
||||
fn drop(&mut self) {
|
||||
// A poisoned lock skips the revert (best-effort, like every call here) instead of
|
||||
// panicking inside a TLS destructor.
|
||||
if let Ok(mut n) = HOT_THREADS.lock() {
|
||||
*n -= 1;
|
||||
if *n == 0 {
|
||||
untune_process();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static HOT_THREAD: std::cell::OnceCell<HotThreadGuard> =
|
||||
const { std::cell::OnceCell::new() };
|
||||
}
|
||||
|
||||
/// Call at the start of each capture/encode/send (hot stream) thread. Registers the thread in
|
||||
/// the process-tuning refcount (first in applies, last out reverts), registers it with MMCSS
|
||||
/// ("Games"), and asserts the display/system must stay awake for as long as this thread lives.
|
||||
/// The MMCSS handle is intentionally leaked and the execution-state assertion is bound to this
|
||||
/// thread — both are reverted by the OS when the thread exits, and the refcount guard's TLS
|
||||
/// Drop runs there too, so a session that ends tears everything down without explicit
|
||||
/// bookkeeping.
|
||||
pub fn on_hot_thread() {
|
||||
tune_process_once();
|
||||
HOT_THREAD.with(|slot| {
|
||||
if slot.get().is_none() {
|
||||
{
|
||||
let mut n = HOT_THREADS.lock().unwrap();
|
||||
*n += 1;
|
||||
if *n == 1 {
|
||||
tune_process();
|
||||
}
|
||||
}
|
||||
let _ = slot.set(HotThreadGuard);
|
||||
}
|
||||
});
|
||||
// SAFETY: C-ABI FFI declared with matching `extern "system"` signatures. SetThreadExecutionState
|
||||
// takes only flag bits. `task` is a local NUL-terminated UTF-16 buffer ("Games\0") alive for the
|
||||
// whole block, so `task.as_ptr()` is a valid LPCWSTR for the call, and `&mut idx` is a live local
|
||||
|
||||
@@ -63,7 +63,17 @@ pub(crate) fn stamp_window_icon(window: &sdl3::video::Window) {
|
||||
let module = GetModuleHandleW(std::ptr::null());
|
||||
for (which, metric) in [(ICON_SMALL, SM_CXSMICON), (ICON_BIG, SM_CXICON)] {
|
||||
let px = GetSystemMetrics(metric);
|
||||
let icon = LoadImageW(module, 1 as *const u16, IMAGE_ICON, px, px, LR_DEFAULTCOLOR);
|
||||
// MAKEINTRESOURCE(1): an integer resource ordinal smuggled through the name
|
||||
// pointer, never dereferenced — `without_provenance` says exactly that (and
|
||||
// `1 as *const u16` reads as a dangling pointer to clippy 1.96).
|
||||
let icon = LoadImageW(
|
||||
module,
|
||||
std::ptr::without_provenance(1),
|
||||
IMAGE_ICON,
|
||||
px,
|
||||
px,
|
||||
LR_DEFAULTCOLOR,
|
||||
);
|
||||
if !icon.is_null() {
|
||||
SendMessageW(hwnd, WM_SETICON, which as WPARAM, icon as LPARAM);
|
||||
}
|
||||
|
||||
+141
-19
@@ -321,11 +321,7 @@ pub fn detect() -> Result<Compositor> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(v) = pf_host_config::config().compositor.as_deref() {
|
||||
return compositor_from_pin(v).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"unknown PUNKTFUNK_COMPOSITOR '{v}' (kwin|wlroots|hyprland|mutter|gamescope)"
|
||||
)
|
||||
});
|
||||
return compositor_from_pin(v).ok_or_else(|| unknown_pin_error(v));
|
||||
}
|
||||
if let Some(c) = compositor_for_kind(detect_active_session().kind) {
|
||||
return Ok(c);
|
||||
@@ -338,20 +334,78 @@ pub fn detect() -> Result<Compositor> {
|
||||
let desktop = with_env_lock(|| std::env::var("XDG_CURRENT_DESKTOP"))
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase();
|
||||
if desktop.contains("KDE") {
|
||||
Ok(Compositor::Kwin)
|
||||
} else if desktop.contains("GNOME") {
|
||||
Ok(Compositor::Mutter)
|
||||
} else if desktop.contains("HYPRLAND") {
|
||||
Ok(Compositor::Hyprland)
|
||||
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") {
|
||||
Ok(Compositor::Wlroots)
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"could not detect compositor: no live graphical session for this uid and \
|
||||
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
|
||||
)
|
||||
}
|
||||
compositor_from_xdg(&desktop)
|
||||
}
|
||||
}
|
||||
|
||||
/// The error for a `PUNKTFUNK_COMPOSITOR` value that names no backend.
|
||||
///
|
||||
/// `cinnamon`/`muffin` get their own answer rather than the bare list: it is the value a Mint or
|
||||
/// LMDE user reaches for first, and the plain list invites them to try the next-closest name
|
||||
/// (`mutter` — Muffin *is* a Mutter fork), which starts a session that then fails deep inside a
|
||||
/// `org.gnome.Mutter.ScreenCast` call Muffin does not serve. There is no working value; say so, and
|
||||
/// name the route that does work.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn unknown_pin_error(v: &str) -> anyhow::Error {
|
||||
const ACCEPTED: &str = "kwin|wlroots|hyprland|mutter|gamescope";
|
||||
if matches!(
|
||||
v.trim().to_ascii_lowercase().as_str(),
|
||||
"cinnamon" | "muffin"
|
||||
) {
|
||||
return anyhow::anyhow!(
|
||||
"PUNKTFUNK_COMPOSITOR='{v}' is not a backend and cannot become one: Cinnamon's \
|
||||
compositor Muffin has no virtual-output API (no `RecordVirtual`), so it cannot make a \
|
||||
screen for a client. Do NOT substitute 'mutter' — Muffin is a Mutter fork but serves \
|
||||
none of that interface. Use PUNKTFUNK_COMPOSITOR=gamescope to stream games through a \
|
||||
headless gamescope, which needs no desktop compositor. See \
|
||||
https://docs.punktfunk.unom.io/docs/debian#cinnamon-linux-mint-and-lmde"
|
||||
);
|
||||
}
|
||||
anyhow::anyhow!("unknown PUNKTFUNK_COMPOSITOR '{v}' ({ACCEPTED})")
|
||||
}
|
||||
|
||||
/// The last-resort `XDG_CURRENT_DESKTOP` sniff, as a **pure function of the (uppercased) value** so
|
||||
/// its branches — including the two that only ever produce an error — are testable without mutating
|
||||
/// process-global env. Called only by [`detect`], after both the operator pin and live-session
|
||||
/// detection have come up empty.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn compositor_from_xdg(desktop: &str) -> Result<Compositor> {
|
||||
// CINNAMON is tested FIRST, ahead of GNOME, and the order is load-bearing rather than
|
||||
// stylistic: Cinnamon is a GNOME derivative, so a session that advertises both (`X-Cinnamon`
|
||||
// alongside a GNOME-compatibility token) would otherwise match the GNOME arm and be handed the
|
||||
// Mutter backend — which then fails deep in a `org.gnome.Mutter.ScreenCast` call that Muffin
|
||||
// does not serve, i.e. an obscure D-Bus error instead of the explanation below. The more
|
||||
// specific desktop wins.
|
||||
if desktop.contains("CINNAMON") {
|
||||
// Linux Mint / LMDE report `X-Cinnamon`. Cinnamon is NOT a missing backend we could add —
|
||||
// its compositor (Muffin) exposes no virtual-output API at all: the fork base is Mutter
|
||||
// 3.36, and `org.cinnamon.Muffin.ScreenCast` carries only `RecordMonitor` / `RecordWindow`,
|
||||
// never Mutter 42+'s `RecordVirtual`. Its portal backend (xdg-desktop-portal-xapp)
|
||||
// implements no ScreenCast either, so the sway/Hyprland portal route is closed too. The
|
||||
// generic message below would send a Cinnamon user hunting for the setting that turns it
|
||||
// on; there isn't one. Name the ONE route that does work on that box — a headless
|
||||
// gamescope, which needs no desktop compositor at all — instead of a dead end.
|
||||
anyhow::bail!(
|
||||
"Cinnamon (XDG_CURRENT_DESKTOP='{desktop}') cannot host a virtual display: its \
|
||||
compositor Muffin has no virtual-output API, so Punktfunk cannot create a screen \
|
||||
for a client on it. Stream games instead by setting PUNKTFUNK_COMPOSITOR=gamescope \
|
||||
in host.env — the host then spawns its own headless gamescope per connect and needs \
|
||||
no desktop session. See \
|
||||
https://docs.punktfunk.unom.io/docs/debian#cinnamon-linux-mint-and-lmde"
|
||||
)
|
||||
} else if desktop.contains("KDE") {
|
||||
Ok(Compositor::Kwin)
|
||||
} else if desktop.contains("GNOME") {
|
||||
Ok(Compositor::Mutter)
|
||||
} else if desktop.contains("HYPRLAND") {
|
||||
Ok(Compositor::Hyprland)
|
||||
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") {
|
||||
Ok(Compositor::Wlroots)
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"could not detect compositor: no live graphical session for this uid and \
|
||||
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -811,6 +865,74 @@ mod wlroots;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The XDG sniff is the last thing standing between an unrecognized desktop and a useless
|
||||
/// error, and `mgmt/display.rs` puts that error VERBATIM in the console's `/display/monitors`
|
||||
/// response — so its exact wording is a user-facing surface, tested as one.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn xdg_sniff_maps_known_desktops() {
|
||||
// Real-world values, uppercased the way `detect` hands them over.
|
||||
assert_eq!(compositor_from_xdg("KDE").unwrap(), Compositor::Kwin);
|
||||
assert_eq!(compositor_from_xdg("GNOME").unwrap(), Compositor::Mutter);
|
||||
assert_eq!(
|
||||
compositor_from_xdg("UBUNTU:GNOME").unwrap(),
|
||||
Compositor::Mutter
|
||||
);
|
||||
assert_eq!(
|
||||
compositor_from_xdg("HYPRLAND").unwrap(),
|
||||
Compositor::Hyprland
|
||||
);
|
||||
assert_eq!(compositor_from_xdg("SWAY").unwrap(), Compositor::Wlroots);
|
||||
}
|
||||
|
||||
/// Cinnamon must NOT fall into the generic "set PUNKTFUNK_COMPOSITOR" arm: Muffin has no
|
||||
/// virtual-output API, so there is no value of that variable which makes a Cinnamon desktop
|
||||
/// host a virtual display. The error has to name gamescope — the one route that works on an
|
||||
/// LMDE/Mint box — or the user is sent hunting for a setting that does not exist.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn cinnamon_is_told_to_use_gamescope_not_to_pick_a_backend() {
|
||||
// `X-Cinnamon` is what Mint and LMDE actually set.
|
||||
for v in ["X-CINNAMON", "CINNAMON", "X-CINNAMON:GNOME-FLASHBACK"] {
|
||||
let err = compositor_from_xdg(v)
|
||||
.expect_err("Cinnamon cannot host a virtual display")
|
||||
.to_string();
|
||||
assert!(err.contains("gamescope"), "no gamescope route named: {err}");
|
||||
assert!(err.contains("Muffin"), "does not say why: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pinning `cinnamon` explicitly must not answer with the plain list of accepted values: the
|
||||
/// next thing a Mint user tries is `mutter` (Muffin is a Mutter fork), which fails much later
|
||||
/// and much less clearly. A typo'd pin still gets the ordinary list.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn pinning_cinnamon_explains_instead_of_listing_backends() {
|
||||
for v in ["cinnamon", "Cinnamon", "muffin", " MUFFIN "] {
|
||||
let err = unknown_pin_error(v).to_string();
|
||||
assert!(err.contains("gamescope"), "no working route named: {err}");
|
||||
assert!(
|
||||
err.contains("Muffin"),
|
||||
"does not explain why it cannot work: {err}"
|
||||
);
|
||||
}
|
||||
let typo = unknown_pin_error("kwim").to_string();
|
||||
assert!(
|
||||
typo.contains("kwin|wlroots|hyprland|mutter|gamescope"),
|
||||
"{typo}"
|
||||
);
|
||||
assert!(!typo.contains("Muffin"), "{typo}");
|
||||
}
|
||||
|
||||
/// An unknown desktop keeps the generic advice — the Cinnamon arm must not swallow it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn unknown_desktop_keeps_the_generic_error() {
|
||||
let err = compositor_from_xdg("XFCE").unwrap_err().to_string();
|
||||
assert!(err.contains("PUNKTFUNK_COMPOSITOR"), "{err}");
|
||||
assert!(!err.contains("Muffin"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_kind_maps_to_its_backend() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -46,6 +46,7 @@ use pf_bitstream::h264::PlanError;
|
||||
use pf_bitstream::h264::PlanWarning;
|
||||
use tracing::debug;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::caps::derive_caps;
|
||||
use crate::caps::query_h264_caps;
|
||||
@@ -685,6 +686,9 @@ pub struct VkH264Decoder {
|
||||
/// Session generation: bumped on every rebuild, stamped into frames.
|
||||
generation: u64,
|
||||
device_lost: bool,
|
||||
/// The over-declared-level warning has fired (once per decoder — the condition
|
||||
/// is a property of the stream's SPS, so repeating it per AU is noise).
|
||||
level_clamp_warned: bool,
|
||||
}
|
||||
|
||||
impl VkH264Decoder {
|
||||
@@ -723,6 +727,7 @@ impl VkH264Decoder {
|
||||
decoded: 0,
|
||||
generation: 0,
|
||||
device_lost: false,
|
||||
level_clamp_warned: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1365,18 +1370,26 @@ impl VkH264Decoder {
|
||||
unsafe { query_h264_caps(&self.dev, std_profile) }.map_err(VkDecodeError::from)?;
|
||||
self.caps = Some((std_profile, derive_caps(&raw)?));
|
||||
}
|
||||
// The level gate: a stream above the device's maxLevelIdc is refused up
|
||||
// front (within one codec the Std code points ascend with the level, so
|
||||
// the comparison is numeric), never submitted on a hope. The ceiling came
|
||||
// from an H.264 caps query, so it is compared against an H.264 code point
|
||||
// — the pairing MaxLevelIdc's tag exists to keep honest.
|
||||
// The declared level vs the device ceiling: a DECLARED level above
|
||||
// `maxLevelIdc` is NOT a refusal — encoders over-claim levels in the wild
|
||||
// (the H.265 twin carries the field evidence: AMF stamps the codec
|
||||
// maximum). The stream's REAL demands are enforced where they are
|
||||
// physical facts — coded extent and DPB depth, checked in
|
||||
// `rebuild_state` — and the session's parameter sets are clamped to the
|
||||
// ceiling (`SessionConfig::max_level_idc`) so the driver is never handed
|
||||
// a level above its caps. The comparison stays within one codec's Std
|
||||
// code space (`MaxLevelIdc`'s tag carries that argument).
|
||||
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
|
||||
let stream_level = level_to_std(plan.picture.level_idc);
|
||||
if stream_level > caps_max_level.code_point() {
|
||||
return Err(VkDecodeError::Unsupported(format!(
|
||||
"stream level (Std code point {stream_level}) above the device's \
|
||||
maxLevelIdc ({caps_max_level})"
|
||||
)));
|
||||
if stream_level > caps_max_level.code_point() && !self.level_clamp_warned {
|
||||
self.level_clamp_warned = true;
|
||||
warn!(
|
||||
stream_level,
|
||||
ceiling = %caps_max_level,
|
||||
"stream declares an H.264 level above the device ceiling — the \
|
||||
declared level is advisory (over-declared by some encoders); \
|
||||
proceeding with the parameter sets clamped to the ceiling"
|
||||
);
|
||||
}
|
||||
let coded = vk::Extent2D {
|
||||
width: plan.picture.coded_width,
|
||||
@@ -1488,6 +1501,7 @@ impl VkH264Decoder {
|
||||
max_dpb_slots: required_slots,
|
||||
max_active_references: (required_slots - 1).min(caps.max_active_references),
|
||||
std_profile_idc: std_profile,
|
||||
max_level_idc: caps.max_level_idc.code_point(),
|
||||
};
|
||||
let mut pool_plan = plan_pools(caps, required_slots);
|
||||
// TEST-ONLY readback hook: the GPU parity test (tests/gpu_parity.rs)
|
||||
|
||||
@@ -57,6 +57,7 @@ use pf_bitstream::h265::PlanError;
|
||||
use pf_bitstream::h265::PlanWarning;
|
||||
use tracing::debug;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::caps::DecodeCaps;
|
||||
use crate::caps::DecodeProfile;
|
||||
@@ -219,6 +220,9 @@ pub struct VkH265Decoder {
|
||||
/// Recovery owed after a failed AU whose planning had already advanced
|
||||
/// ([`RecoveryLatch`] docs for the whole argument).
|
||||
recovery: RecoveryLatch,
|
||||
/// The over-declared-level warning has fired (once per decoder — the condition
|
||||
/// is a property of the stream's SPS, so repeating it per AU is noise).
|
||||
level_clamp_warned: bool,
|
||||
}
|
||||
|
||||
impl VkH265Decoder {
|
||||
@@ -266,6 +270,7 @@ impl VkH265Decoder {
|
||||
generation: 0,
|
||||
device_lost: false,
|
||||
recovery: RecoveryLatch::default(),
|
||||
level_clamp_warned: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1004,18 +1009,28 @@ impl VkH265Decoder {
|
||||
let raw = unsafe { query_h265_caps(&self.dev, key) }.map_err(VkDecodeError::from)?;
|
||||
self.caps = Some((key, derive_caps_h265(&raw, wanted)?));
|
||||
}
|
||||
// The level gate: a stream above the device's maxLevelIdc is refused up
|
||||
// front (within one codec the Std code points ascend with the level, so
|
||||
// the comparison is numeric), never submitted on a hope. The ceiling came
|
||||
// from an H.265 caps query, so it is compared against an H.265 code point
|
||||
// — the pairing MaxLevelIdc's tag exists to keep honest.
|
||||
// The declared level vs the device ceiling: a DECLARED level above
|
||||
// `maxLevelIdc` is NOT a refusal. The level in an SPS is a claim, and
|
||||
// encoders over-claim in the wild — AMF stamps 6.2 (the codec maximum)
|
||||
// on 4K120 streams that need 5.2, which on an RTX 5060 (ceiling 6.1)
|
||||
// demoted every HEVC session to D3D11VA (2026-08-12 field report). The
|
||||
// stream's REAL demands are enforced where they are physical facts:
|
||||
// coded extent and DPB depth, checked in `rebuild_state`. The session's
|
||||
// parameter sets are clamped to the ceiling (`SessionConfigH265::
|
||||
// max_level_idc`) so the driver is never handed a level above its caps,
|
||||
// and the comparison stays within one codec's Std code space
|
||||
// (`MaxLevelIdc`'s tag carries that argument).
|
||||
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
|
||||
let stream_level = level_to_std_h265(plan.picture.level_idc);
|
||||
if stream_level > caps_max_level.code_point() {
|
||||
return Err(VkDecodeError::Unsupported(format!(
|
||||
"stream level (Std code point {stream_level}) above the device's \
|
||||
maxLevelIdc ({caps_max_level})"
|
||||
)));
|
||||
if stream_level > caps_max_level.code_point() && !self.level_clamp_warned {
|
||||
self.level_clamp_warned = true;
|
||||
warn!(
|
||||
stream_level,
|
||||
ceiling = %caps_max_level,
|
||||
"stream declares an H.265 level above the device ceiling — the \
|
||||
declared level is advisory (over-declared by some encoders); \
|
||||
proceeding with the parameter sets clamped to the ceiling"
|
||||
);
|
||||
}
|
||||
let coded = vk::Extent2D {
|
||||
width: plan.picture.coded_width,
|
||||
@@ -1108,6 +1123,7 @@ impl VkH265Decoder {
|
||||
max_dpb_slots: required_slots,
|
||||
max_active_references: (required_slots - 1).min(caps.max_active_references),
|
||||
profile: key,
|
||||
max_level_idc: caps.max_level_idc.code_point(),
|
||||
};
|
||||
let mut pool_plan = plan_pools(caps, required_slots);
|
||||
// TEST-ONLY readback hook, exactly as the H.264 decoder's: the parity
|
||||
|
||||
@@ -115,6 +115,17 @@ impl OwnedStdSps {
|
||||
pub fn std(&self) -> &hh::StdVideoH264SequenceParameterSet {
|
||||
&self.std
|
||||
}
|
||||
|
||||
/// Lower `level_idc` to `max` when the stream declares a higher one. The
|
||||
/// declared level is a claim encoders over-state in the wild, and a set above
|
||||
/// the device's `maxLevelIdc` is invalid usage; the stream's real demands are
|
||||
/// enforced by the session's coded extent and DPB depth. The "no mutation"
|
||||
/// contract above is about a LIVE object's blocks — this runs before handover.
|
||||
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH264LevelIdc) {
|
||||
if self.std.level_idc > max {
|
||||
self.std.level_idc = max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The converted PPS plus the scaling-list allocation its `pScalingLists` targets.
|
||||
@@ -831,4 +842,24 @@ mod tests {
|
||||
ParamsError::InvalidWeightedBipredIdc(3)
|
||||
);
|
||||
}
|
||||
|
||||
/// The over-declared-level clamp ([`OwnedStdSps::clamp_level`]): lowering
|
||||
/// writes the ceiling into the Std SPS; a ceiling at or above the declared
|
||||
/// level changes nothing.
|
||||
#[test]
|
||||
fn clamp_level_lowers_and_only_lowers() {
|
||||
let sps = full_sps();
|
||||
let declared = level_to_std(sps.level_idc);
|
||||
|
||||
let mut owned = sps_to_std(&sps).unwrap();
|
||||
assert_eq!(owned.std().level_idc, declared);
|
||||
// A ceiling above the declared level is a no-op.
|
||||
owned.clamp_level(hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_6_2);
|
||||
assert_eq!(owned.std().level_idc, declared);
|
||||
// A ceiling below it is written through.
|
||||
let ceiling = hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_3_1;
|
||||
assert!(ceiling < declared, "fixture declares above 3.1");
|
||||
owned.clamp_level(ceiling);
|
||||
assert_eq!(owned.std().level_idc, ceiling);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +202,19 @@ impl OwnedStdH265Vps {
|
||||
pub fn std(&self) -> &hh::StdVideoH265VideoParameterSet {
|
||||
&self.std
|
||||
}
|
||||
|
||||
/// Lower the profile/tier/level block's `general_level_idc` to `max` when the
|
||||
/// stream declares a higher one. The declared level is a CLAIM, and encoders
|
||||
/// over-claim in the wild (AMF stamps 6.2 — the codec maximum — on streams that
|
||||
/// need 5.2); handing the driver a level above its `maxLevelIdc` is invalid
|
||||
/// usage, while the stream's real demands are enforced by the session's coded
|
||||
/// extent and DPB depth. The "no mutation" ownership contract is about blocks a
|
||||
/// LIVE parameters object points at; this runs before the set is handed over.
|
||||
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH265LevelIdc) {
|
||||
if self._ptl_backing.general_level_idc > max {
|
||||
self._ptl_backing.general_level_idc = max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The converted SPS plus the heap allocations its embedded pointers target.
|
||||
@@ -229,6 +242,14 @@ impl OwnedStdH265Sps {
|
||||
pub fn std(&self) -> &hh::StdVideoH265SequenceParameterSet {
|
||||
&self.std
|
||||
}
|
||||
|
||||
/// Lower `general_level_idc` to the device ceiling — [`OwnedStdH265Vps::clamp_level`]
|
||||
/// carries the argument.
|
||||
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH265LevelIdc) {
|
||||
if self._ptl_backing.general_level_idc > max {
|
||||
self._ptl_backing.general_level_idc = max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The converted PPS plus the scaling-list allocation its `pScalingLists`
|
||||
@@ -2000,4 +2021,37 @@ mod tests {
|
||||
"the vector opens with VPS + SPS + PPS"
|
||||
);
|
||||
}
|
||||
|
||||
/// The over-declared-level clamp (the AMF 6.2-on-everything field case):
|
||||
/// lowering writes the ceiling into the PTL backing the driver will read;
|
||||
/// a ceiling at or above the declared level changes nothing.
|
||||
#[test]
|
||||
fn clamp_level_lowers_the_ptl_and_only_lowers() {
|
||||
let sps = full_sps();
|
||||
let declared = level_to_std(sps.profile_tier_level.general_level_idc);
|
||||
|
||||
let mut owned = sps_to_std_h265(&sps).unwrap();
|
||||
// SAFETY: pProfileTierLevel targets `owned`'s boxed backing.
|
||||
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
|
||||
assert_eq!(level, declared);
|
||||
// A ceiling above the declared level is a no-op.
|
||||
owned.clamp_level(hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_2);
|
||||
// SAFETY: as above.
|
||||
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
|
||||
assert_eq!(level, declared);
|
||||
// A ceiling below it is written through — and the pointer still targets
|
||||
// the wrapper's own backing (the clamp mutates in place, never re-points).
|
||||
let ceiling = hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_3_1;
|
||||
assert!(ceiling < declared, "fixture declares above 3.1");
|
||||
owned.clamp_level(ceiling);
|
||||
// SAFETY: as above.
|
||||
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
|
||||
assert_eq!(level, ceiling);
|
||||
|
||||
let mut owned_vps = fallback_vps_from_sps(&sps).unwrap();
|
||||
owned_vps.clamp_level(ceiling);
|
||||
// SAFETY: as above, the VPS wrapper's own backing.
|
||||
let vps_level = unsafe { (*owned_vps.std().pProfileTierLevel).general_level_idc };
|
||||
assert!(vps_level <= ceiling);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,10 @@ pub struct SessionConfig {
|
||||
/// The Std profile the session was created against (a profile change is a
|
||||
/// renegotiation too).
|
||||
pub std_profile_idc: hh::StdVideoH264ProfileIdc,
|
||||
/// The device's `maxLevelIdc` for this profile (Std code point). Every SPS
|
||||
/// handed to the parameters object has its declared level clamped to this —
|
||||
/// see `SessionConfigH265::max_level_idc` for the whole argument.
|
||||
pub max_level_idc: hh::StdVideoH264LevelIdc,
|
||||
}
|
||||
|
||||
/// Session creation/parameter failures the decoder maps into its error type.
|
||||
@@ -593,11 +597,14 @@ impl VideoSession {
|
||||
match action {
|
||||
ParamsAction::Current => Ok(()),
|
||||
ParamsAction::Add { add_sps, add_pps } => {
|
||||
let owned_sps = if add_sps {
|
||||
let mut owned_sps = if add_sps {
|
||||
Some(sps_to_std(sps)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(s) = owned_sps.as_mut() {
|
||||
s.clamp_level(self.config.max_level_idc);
|
||||
}
|
||||
let owned_pps = if add_pps {
|
||||
Some(pps_to_std(pps)?)
|
||||
} else {
|
||||
@@ -643,7 +650,8 @@ impl VideoSession {
|
||||
pps_id = pps.pic_parameter_set_id,
|
||||
"recreating session parameters (content change or capacity)"
|
||||
);
|
||||
let owned_sps = sps_to_std(sps)?;
|
||||
let mut owned_sps = sps_to_std(sps)?;
|
||||
owned_sps.clamp_level(self.config.max_level_idc);
|
||||
let owned_pps = pps_to_std(pps)?;
|
||||
// SAFETY: fn contract — live device + live session. The wrappers
|
||||
// are MOVED IN and come back owned by the fresh object, so they
|
||||
|
||||
@@ -260,6 +260,12 @@ pub struct SessionConfigH265 {
|
||||
/// format / bit depths, all four of which a stream can renegotiate (an SPS
|
||||
/// switching Main→Main 10 mid-stream is a session rebuild, not an update).
|
||||
pub profile: H265ProfileKey,
|
||||
/// The device's `maxLevelIdc` for this profile (Std code point). Every VPS/SPS
|
||||
/// handed to the parameters object has its declared level clamped to this —
|
||||
/// over-declared levels are common (AMF stamps 6.2 on 4K streams) and a set
|
||||
/// above the ceiling is invalid usage, while the stream's real demands are
|
||||
/// already enforced by `max_coded_extent` / `max_dpb_slots`.
|
||||
pub max_level_idc: hh::StdVideoH265LevelIdc,
|
||||
}
|
||||
|
||||
/// A live parameters object **and every Std parameter set it was given**, in one
|
||||
@@ -525,12 +531,18 @@ impl VideoSessionH265 {
|
||||
} => {
|
||||
// Every owned wrapper below stays alive until after the update
|
||||
// call: the Std structs embed pointers into their heap blocks.
|
||||
let owned_vps = if add_vps { Some(vps.to_std()?) } else { None };
|
||||
let owned_sps = if add_sps {
|
||||
let mut owned_vps = if add_vps { Some(vps.to_std()?) } else { None };
|
||||
let mut owned_sps = if add_sps {
|
||||
Some(sps_to_std_h265(sps)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(v) = owned_vps.as_mut() {
|
||||
v.clamp_level(self.config.max_level_idc);
|
||||
}
|
||||
if let Some(s) = owned_sps.as_mut() {
|
||||
s.clamp_level(self.config.max_level_idc);
|
||||
}
|
||||
let owned_pps = if add_pps {
|
||||
Some(pps_to_std_h265(pps)?)
|
||||
} else {
|
||||
@@ -582,8 +594,10 @@ impl VideoSessionH265 {
|
||||
pps_id = pps.pic_parameter_set_id,
|
||||
"recreating H.265 session parameters (content change or capacity)"
|
||||
);
|
||||
let owned_vps = vps.to_std()?;
|
||||
let owned_sps = sps_to_std_h265(sps)?;
|
||||
let mut owned_vps = vps.to_std()?;
|
||||
let mut owned_sps = sps_to_std_h265(sps)?;
|
||||
owned_vps.clamp_level(self.config.max_level_idc);
|
||||
owned_sps.clamp_level(self.config.max_level_idc);
|
||||
let owned_pps = pps_to_std_h265(pps)?;
|
||||
// SAFETY: fn contract — live device + live session. The wrappers
|
||||
// are MOVED IN and come back owned by the fresh object, so they
|
||||
|
||||
@@ -7,14 +7,22 @@
|
||||
//!
|
||||
//! Reliability (this is the whole point — a sleeping host has no ARP entry, so a plain unicast
|
||||
//! can't wake it, and `255.255.255.255` alone leaves only via the default route). For each
|
||||
//! known host MAC we send the 102-byte packet to:
|
||||
//! * every non-loopback IPv4 interface's **subnet-directed broadcast** (routes to that NIC's
|
||||
//! segment — this is what covers multi-homed clients on VPN/docker/multiple LANs), and
|
||||
//! * the **limited broadcast** `255.255.255.255`, and
|
||||
//! * optionally a **unicast** to the host's last-known IP (covers the brief window where the
|
||||
//! host is reachable but hasn't re-advertised, and NICs that wake on a directed unicast),
|
||||
//! known host MAC we send the 102-byte packet:
|
||||
//! * **out of every non-loopback IPv4 interface**, from a socket bound to that interface's own
|
||||
//! address, to both that NIC's **subnet-directed broadcast** and the **limited broadcast**
|
||||
//! `255.255.255.255` — binding the source is what forces the datagram onto that segment
|
||||
//! instead of whatever the default route happens to be (a VPN/mesh interface, typically), and
|
||||
//! * from an unbound socket to `255.255.255.255` and, when known, a **unicast** to the host's
|
||||
//! last-known IP (covers the brief window where the host is reachable but hasn't
|
||||
//! re-advertised, and NICs that wake on a directed unicast),
|
||||
//!
|
||||
//! on the two conventional WoL ports (9 and 7), repeated a few times to survive UDP loss.
|
||||
//!
|
||||
//! **Wi-Fi hosts (WoWLAN) ride the same path**, and the per-interface egress above is what makes
|
||||
//! them work: a station in WoWLAN sleep stays associated, and the AP buffers broadcast frames for
|
||||
//! its sleeping stations and flushes them on the next DTIM beacon — so the broadcast does reach
|
||||
//! the sleeping NIC, but only if the datagram actually leaves via the wireless interface. The
|
||||
//! host end of it (arming the NIC's magic-packet trigger) is `punktfunk-host`'s `wol` module.
|
||||
|
||||
use std::io;
|
||||
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
|
||||
@@ -64,41 +72,63 @@ pub fn build_magic_packet(mac: Mac) -> [u8; 102] {
|
||||
/// directed broadcast with no route) doesn't fail the whole wake. Errors only if no socket
|
||||
/// could be opened or nothing could be sent at all.
|
||||
pub fn send_magic_packet(macs: &[Mac], last_known_ip: Option<Ipv4Addr>) -> io::Result<()> {
|
||||
send_magic_packet_on(macs, last_known_ip, &WOL_PORTS)
|
||||
}
|
||||
|
||||
/// [`send_magic_packet`] with the destination ports spelled out. Private because the ports are
|
||||
/// not a caller's business — it exists so the tests can aim a real send at a port they're allowed
|
||||
/// to bind (9 and 7 are privileged) and assert the bytes that come off the wire.
|
||||
fn send_magic_packet_on(
|
||||
macs: &[Mac],
|
||||
last_known_ip: Option<Ipv4Addr>,
|
||||
ports: &[u16],
|
||||
) -> io::Result<()> {
|
||||
if macs.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"no MAC addresses",
|
||||
));
|
||||
}
|
||||
let packets: Vec<[u8; 102]> = macs.iter().map(|m| build_magic_packet(*m)).collect();
|
||||
|
||||
// Build the target IP set: each interface's directed broadcast, the limited broadcast, and
|
||||
// the optional last-known unicast. Dedup so a single-NIC client doesn't send twice.
|
||||
let mut targets = broadcast_addrs();
|
||||
targets.push(Ipv4Addr::BROADCAST); // 255.255.255.255
|
||||
// Targets that go out the default route (or wherever the routing table sends them): the
|
||||
// limited broadcast as a baseline, plus the optional unicast — destination routing picks the
|
||||
// right NIC for a unicast, so it doesn't need per-interface treatment.
|
||||
let mut routed: Vec<Ipv4Addr> = vec![Ipv4Addr::BROADCAST];
|
||||
if let Some(ip) = last_known_ip {
|
||||
targets.push(ip);
|
||||
routed.push(ip);
|
||||
}
|
||||
targets.sort_unstable();
|
||||
targets.dedup();
|
||||
|
||||
// One broadcast-enabled socket bound to all interfaces. Directed broadcasts route to the
|
||||
// matching NIC via the routing table; the limited broadcast leaves via the default route.
|
||||
let sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
|
||||
sock.set_broadcast(true)?;
|
||||
|
||||
let mut sent_any = false;
|
||||
for _ in 0..BURST {
|
||||
for mac in macs {
|
||||
let pkt = build_magic_packet(*mac);
|
||||
for ip in &targets {
|
||||
for port in WOL_PORTS {
|
||||
let dst = SocketAddr::V4(SocketAddrV4::new(*ip, port));
|
||||
if sock.send_to(&pkt, dst).is_ok() {
|
||||
sent_any = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-interface pass. One socket per non-loopback IPv4 address, bound to that address so the
|
||||
// datagram leaves on THAT segment: without this, `255.255.255.255` follows the default route
|
||||
// only (a VPN/mesh NIC on most of these machines) and never touches the LAN — or the Wi-Fi
|
||||
// segment the sleeping WoWLAN station is associated to.
|
||||
for (local, bcast) in local_v4_segments() {
|
||||
let Ok(sock) = UdpSocket::bind(SocketAddrV4::new(local, 0)) else {
|
||||
// Bind failed (address just went away, or the OS refuses it) — fall back to the
|
||||
// routed socket below, which still reaches this segment's directed broadcast.
|
||||
routed.push(bcast);
|
||||
continue;
|
||||
};
|
||||
if sock.set_broadcast(true).is_err() {
|
||||
routed.push(bcast);
|
||||
continue;
|
||||
}
|
||||
sent_any |= blast(&sock, &packets, &[bcast, Ipv4Addr::BROADCAST], ports);
|
||||
}
|
||||
|
||||
// Routed pass, and the only pass on a machine whose interfaces can't be enumerated.
|
||||
if let Ok(sock) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) {
|
||||
// A refused SO_BROADCAST doesn't abort the pass: the unicast target still goes out, and
|
||||
// the per-interface sockets above may already have carried the broadcast.
|
||||
let _ = sock.set_broadcast(true);
|
||||
routed.sort_unstable();
|
||||
routed.dedup();
|
||||
sent_any |= blast(&sock, &packets, &routed, ports);
|
||||
} else if !sent_any {
|
||||
return Err(io::Error::other("no socket could be opened for the wake"));
|
||||
}
|
||||
|
||||
if sent_any {
|
||||
@@ -108,10 +138,33 @@ pub fn send_magic_packet(macs: &[Mac], last_known_ip: Option<Ipv4Addr>) -> io::R
|
||||
}
|
||||
}
|
||||
|
||||
/// Subnet-directed broadcast address of every non-loopback IPv4 interface (`ip | !netmask`,
|
||||
/// or the OS-provided broadcast when present). Best-effort: interface enumeration failing
|
||||
/// (permissions, exotic platform) yields an empty list, and the limited broadcast still fires.
|
||||
fn broadcast_addrs() -> Vec<Ipv4Addr> {
|
||||
/// Send every packet to every target, on every port, [`BURST`] times. Returns whether any
|
||||
/// single datagram made it out — an unroutable target is expected and never fails the wake.
|
||||
fn blast(sock: &UdpSocket, packets: &[[u8; 102]], targets: &[Ipv4Addr], ports: &[u16]) -> bool {
|
||||
let mut sent_any = false;
|
||||
for _ in 0..BURST {
|
||||
for pkt in packets {
|
||||
for ip in targets {
|
||||
// A degenerate 0.0.0.0 (unconfigured NIC) is not a destination.
|
||||
if ip.is_unspecified() {
|
||||
continue;
|
||||
}
|
||||
for port in ports {
|
||||
let dst = SocketAddr::V4(SocketAddrV4::new(*ip, *port));
|
||||
if sock.send_to(pkt, dst).is_ok() {
|
||||
sent_any = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sent_any
|
||||
}
|
||||
|
||||
/// Every non-loopback IPv4 interface as `(its own address, its subnet-directed broadcast)`. The
|
||||
/// broadcast is the OS-provided one where present, else `ip | !netmask`. Best-effort: enumeration
|
||||
/// failing (permissions, exotic platform) yields an empty list and the routed pass still fires.
|
||||
fn local_v4_segments() -> Vec<(Ipv4Addr, Ipv4Addr)> {
|
||||
let mut out = Vec::new();
|
||||
let ifaces = match if_addrs::get_if_addrs() {
|
||||
Ok(i) => i,
|
||||
@@ -122,14 +175,13 @@ fn broadcast_addrs() -> Vec<Ipv4Addr> {
|
||||
continue;
|
||||
}
|
||||
if let if_addrs::IfAddr::V4(v4) = iface.addr {
|
||||
if v4.ip.is_unspecified() {
|
||||
continue; // nothing to bind to
|
||||
}
|
||||
let bcast = v4
|
||||
.broadcast
|
||||
.unwrap_or_else(|| Ipv4Addr::from(u32::from(v4.ip) | !u32::from(v4.netmask)));
|
||||
// Skip a degenerate 0.0.0.0 (unconfigured) and the all-ones limited broadcast we
|
||||
// already add unconditionally.
|
||||
if !bcast.is_unspecified() && bcast != Ipv4Addr::BROADCAST {
|
||||
out.push(bcast);
|
||||
}
|
||||
out.push((v4.ip, bcast));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -183,10 +235,47 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_addrs_never_contains_limited_or_unspecified() {
|
||||
for b in broadcast_addrs() {
|
||||
assert_ne!(b, Ipv4Addr::BROADCAST);
|
||||
assert!(!b.is_unspecified());
|
||||
fn local_segments_are_bindable_and_have_a_broadcast() {
|
||||
for (local, bcast) in local_v4_segments() {
|
||||
// The local address is what we bind the per-interface socket to, so it must be a
|
||||
// real address — and it must never be the loopback (filtered) or unspecified.
|
||||
assert!(!local.is_unspecified());
|
||||
assert!(!local.is_loopback());
|
||||
assert!(!bcast.is_unspecified());
|
||||
// Binding to an address the OS just reported must work; a failure here would mean
|
||||
// the per-interface pass silently degrades to the routed one.
|
||||
assert!(UdpSocket::bind(SocketAddrV4::new(local, 0)).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blast_reports_nothing_sent_for_an_empty_target_list() {
|
||||
let sock = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind loopback");
|
||||
let pkt = [build_magic_packet([1, 2, 3, 4, 5, 6])];
|
||||
assert!(!blast(&sock, &pkt, &[], &WOL_PORTS));
|
||||
// An unconfigured 0.0.0.0 target is skipped rather than sent to.
|
||||
assert!(!blast(&sock, &pkt, &[Ipv4Addr::UNSPECIFIED], &WOL_PORTS));
|
||||
// Loopback is a real destination — this one must go out.
|
||||
assert!(blast(&sock, &pkt, &[Ipv4Addr::LOCALHOST], &[9999]));
|
||||
}
|
||||
|
||||
/// The whole send path, end to end: a real receiver gets a real magic packet with the right
|
||||
/// bytes. Aimed at loopback on an unprivileged port (WoL's own 9 and 7 need root to bind),
|
||||
/// which exercises the routed pass's unicast leg — the one a WoWLAN host is woken by when
|
||||
/// the AP filters broadcast to sleeping stations.
|
||||
#[test]
|
||||
fn send_delivers_the_magic_packet_to_a_listener() {
|
||||
let rx = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind receiver");
|
||||
let port = rx.local_addr().expect("local addr").port();
|
||||
rx.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.expect("read timeout");
|
||||
|
||||
let mac: Mac = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02];
|
||||
send_magic_packet_on(&[mac], Some(Ipv4Addr::LOCALHOST), &[port]).expect("send");
|
||||
|
||||
let mut buf = [0u8; 256];
|
||||
let (n, _from) = rx.recv_from(&mut buf).expect("a magic packet must arrive");
|
||||
assert_eq!(n, 102);
|
||||
assert_eq!(buf[..102], build_magic_packet(mac));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +183,10 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
|
||||
mod audio_control;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
// DualSense pad-audio sink + capture, the Linux analogue of `pad_endpoint` below: the session
|
||||
// layer mints per-pad sinks and the CLI exposes the `pad-sink-test` devtest.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) use linux::pad_sink;
|
||||
// DualSense pad-audio endpoint provisioning + loopback capture (design: pad haptics/audio).
|
||||
// pub(crate): the session layer queries endpoints by pad index and the CLI exposes the
|
||||
// `pad-endpoint` devtest.
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
//! surround session can replace a stereo capturer without leaking a PipeWire consumer (see
|
||||
//! CLAUDE.md: a wedged link head-blocks the daemon).
|
||||
|
||||
pub(crate) mod pad_sink;
|
||||
mod stream_sink;
|
||||
|
||||
use super::{AudioCapturer, MicBackendStats, VirtualMic, SAMPLE_RATE};
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
//! Per-pad DualSense audio sink (Linux): one PipeWire `Audio/Sink` stream node per
|
||||
//! DualSense-family pad, wearing the identity DS5-native titles and GE-Proton's
|
||||
//! controller-audio routing match on — so a game that renders voice-coil haptics or pad-speaker
|
||||
//! audio finds "the controller's audio device" and plays into us. We own the sink, so the
|
||||
//! `process()` callback IS the capture: 4-ch F32 48 kHz (FL FR RL RR — front pair = speaker,
|
||||
//! back pair = voice coils, the same quad layout the Windows endpoint is stamped with) lands
|
||||
//! directly in the chunk channel that feeds the 0xD1 lanes (`native/pad_audio.rs`).
|
||||
//!
|
||||
//! Modeled on the stream-sink mode of [`super::PwAudioCapturer`] (same MainLoop-on-a-thread,
|
||||
//! Terminate channel, ready handshake, bounded lossy chunk hand-off) with two deliberate
|
||||
//! differences: **no default-sink claim** (nothing may auto-route here — games target it BY
|
||||
//! IDENTITY) and a low `priority.session` so WirePlumber never elects it against real hardware.
|
||||
//!
|
||||
//! **Identity** (design `dualsense-audio-haptics-and-speaker.md` §3/§5): GE-Proton 11-2+
|
||||
//! matches layered — pulse proplist (`device.bus == "usb"`, `device.vendor.id == 0x054c`,
|
||||
//! `device.product.id ∈ {0x0ce6, 0x0df2}`), then name substrings
|
||||
//! (`Sony_Interactive_Entertainment…Wireless_Controller`, `DualSense`); the community
|
||||
//! WirePlumber rule keys on the node-name substring and sets `node.description =
|
||||
//! "Wireless Controller"` (we mint it that way from the start). A pure PipeWire node cannot
|
||||
//! satisfy wine's ContainerId derivation (udev walk to a `usb_device` parent → `GUID_NULL`)
|
||||
//! nor GE's raw-ALSA fast path — both fall back to the Pulse-routed leg, which winepulse
|
||||
//! serves from exactly this node (it enumerates sinks). Every identity string has an env
|
||||
//! override for field debugging (`PUNKTFUNK_PAD_SINK_NAME` / `PUNKTFUNK_PAD_SINK_DESC`, with
|
||||
//! `{pad}` / `{mac}` placeholders).
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Message asking the PipeWire loop thread to quit (sent from `Drop`).
|
||||
struct Terminate;
|
||||
|
||||
/// The pad sink's fixed channel count — quad, mirroring the Windows endpoint stamp
|
||||
/// (`native/pad_audio.rs::CAP_CHANNELS` splits on the same layout).
|
||||
const PAD_CHANNELS: u32 = 4;
|
||||
|
||||
/// How many pad slots may carry a sink (`PUNKTFUNK_PAD_AUDIO_SLOTS`, default all 4 — a PipeWire
|
||||
/// stream node is cheap, unlike the Windows devnode mint whose default is 1).
|
||||
pub(crate) fn pad_audio_slots() -> u8 {
|
||||
std::env::var("PUNKTFUNK_PAD_AUDIO_SLOTS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u8>().ok())
|
||||
.unwrap_or(4)
|
||||
.clamp(1, 4)
|
||||
}
|
||||
|
||||
/// Whether a PipeWire daemon is plausibly reachable from this process — the Linux analogue of
|
||||
/// "startup provisioning published at least one endpoint" for [`host_cap`]'s existence leg
|
||||
/// (`native/pad_audio.rs`). A stat, not a connect: the handshake path runs per-Hello and must
|
||||
/// not block. `PIPEWIRE_REMOTE` names a non-default socket — trust it (the session capturer
|
||||
/// honors it via libpipewire, and a wrong value degrades to spawn-time failure, pad kept).
|
||||
pub(crate) fn pipewire_reachable() -> bool {
|
||||
if std::env::var_os("PIPEWIRE_REMOTE").is_some() {
|
||||
return true;
|
||||
}
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(|dir| std::path::Path::new(&dir).join("pipewire-0").exists())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The pad's virtual MAC as colon-separated display hex — [`ds_pairing_reply`]'s bytes 1..7
|
||||
/// are LSB-first (the report layout `hid-playstation` adopts as the HID `uniq` via `%pMR`,
|
||||
/// i.e. printed reversed), so the display form reverses them. Unique per pad (the low octet
|
||||
/// carries the pad index), which keeps multi-pad sinks distinct for the same reason the MAC
|
||||
/// itself must be: SDL/Steam and the matchers dedup by serial.
|
||||
///
|
||||
/// [`ds_pairing_reply`]: pf_inject::dualsense_proto::ds_pairing_reply
|
||||
fn pad_mac(pad: u8) -> String {
|
||||
let reply = crate::inject::dualsense_proto::ds_pairing_reply(pad);
|
||||
let m = &reply[1..7];
|
||||
format!(
|
||||
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
|
||||
m[5], m[4], m[3], m[2], m[1], m[0]
|
||||
)
|
||||
}
|
||||
|
||||
/// Expand the `{pad}` / `{mac}` placeholders of an identity template. Callers pass the MAC in
|
||||
/// the form the surrounding string wants: colon display form for proplist values, bare hex for
|
||||
/// the ALSA-style node name (udev serials carry no colons).
|
||||
fn expand(template: &str, pad: u8, mac: &str) -> String {
|
||||
template
|
||||
.replace("{pad}", &pad.to_string())
|
||||
.replace("{mac}", mac)
|
||||
}
|
||||
|
||||
/// The full identity a pad sink wears, resolved once at open.
|
||||
struct PadSinkIdentity {
|
||||
node_name: String,
|
||||
description: String,
|
||||
serial: String,
|
||||
product_id: &'static str,
|
||||
product_name: &'static str,
|
||||
}
|
||||
|
||||
impl PadSinkIdentity {
|
||||
fn new(pad: u8, edge: bool) -> PadSinkIdentity {
|
||||
let mac = pad_mac(pad);
|
||||
let mac_bare: String = mac.chars().filter(|c| *c != ':').collect();
|
||||
let (model, product_id, product_name) = if edge {
|
||||
(
|
||||
"DualSense_Edge",
|
||||
"0df2",
|
||||
"DualSense Edge Wireless Controller",
|
||||
)
|
||||
} else {
|
||||
("DualSense", "0ce6", "DualSense Wireless Controller")
|
||||
};
|
||||
// The ALSA-style name a REAL pad's card gets from udev (vendor_product_serial), which
|
||||
// is what every known name-substring matcher was written against. `-00.analog-surround-40`
|
||||
// = card profile suffix for the quad layout.
|
||||
let node_name = match std::env::var("PUNKTFUNK_PAD_SINK_NAME") {
|
||||
Ok(t) if !t.trim().is_empty() => expand(&t, pad, &mac_bare),
|
||||
_ => format!(
|
||||
"alsa_output.usb-Sony_Interactive_Entertainment_{model}_Wireless_Controller_{mac_bare}-00.analog-surround-40"
|
||||
),
|
||||
};
|
||||
// What the community WirePlumber rule renames real pads TO — minted that way directly.
|
||||
let description = match std::env::var("PUNKTFUNK_PAD_SINK_DESC") {
|
||||
Ok(t) if !t.trim().is_empty() => expand(&t, pad, &mac),
|
||||
_ => "Wireless Controller".to_string(),
|
||||
};
|
||||
PadSinkIdentity {
|
||||
node_name,
|
||||
description,
|
||||
serial: format!(
|
||||
"Sony_Interactive_Entertainment_{model}_Wireless_Controller_{mac_bare}"
|
||||
),
|
||||
product_id,
|
||||
product_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A live per-pad sink + its capture. Same next-chunk contract as every
|
||||
/// [`AudioCapturer`](crate::audio::AudioCapturer): empty chunk = quiet sink (keep me), `Err` =
|
||||
/// dead loop thread (reopen me). Dropping tears the sink node down promptly via the Terminate
|
||||
/// channel (a wedged PipeWire link head-blocks the daemon — see the session capturer's docs).
|
||||
pub struct PadSinkCapturer {
|
||||
chunks: Receiver<Vec<f32>>,
|
||||
quit: pipewire::channel::Sender<Terminate>,
|
||||
/// The minted node name, for logs and the devtest.
|
||||
pub node_name: String,
|
||||
}
|
||||
|
||||
impl PadSinkCapturer {
|
||||
/// Mint the sink for wire pad `pad` (`edge` = DualSense Edge identity) and start capturing.
|
||||
/// Fails if PipeWire is unreachable — the caller's reopen-with-backoff owns the retry.
|
||||
pub fn open(pad: u8, edge: bool) -> Result<PadSinkCapturer> {
|
||||
let identity = PadSinkIdentity::new(pad, edge);
|
||||
let node_name = identity.node_name.clone();
|
||||
let (tx, rx) = sync_channel::<Vec<f32>>(64);
|
||||
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
|
||||
// Bring-up handshake (the session capturer's discipline): a PipeWire that isn't running
|
||||
// must surface as an open ERROR, engaging the caller's backoff — not a zombie thread.
|
||||
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
|
||||
thread::Builder::new()
|
||||
.name(format!("punktfunk-pw-pad{pad}"))
|
||||
.spawn(move || {
|
||||
if let Err(e) = pad_sink_thread(tx, quit_rx, identity, ready_tx) {
|
||||
tracing::warn!(pad, error = %format!("{e:#}"), "pipewire pad-sink thread failed");
|
||||
}
|
||||
})
|
||||
.context("spawn pipewire pad-sink thread")?;
|
||||
match ready_rx.recv_timeout(Duration::from_secs(5)) {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => return Err(anyhow!("pipewire pad-sink init timed out")),
|
||||
}
|
||||
Ok(PadSinkCapturer {
|
||||
chunks: rx,
|
||||
quit: quit_tx,
|
||||
node_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PadSinkCapturer {
|
||||
fn drop(&mut self) {
|
||||
// A failed send means the loop thread already exited — nothing to tear down.
|
||||
let _ = self.quit.send(Terminate);
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::audio::AudioCapturer for PadSinkCapturer {
|
||||
fn next_chunk(&mut self) -> Result<Vec<f32>> {
|
||||
match self.chunks.recv_timeout(Duration::from_secs(5)) {
|
||||
Ok(c) => Ok(c),
|
||||
// A quiet pad sink (no game rendering pad audio — the common case) is NOT a
|
||||
// failure; the per-pad streamer keeps us and its silence gate stays closed.
|
||||
Err(RecvTimeoutError::Timeout) => Ok(Vec::new()),
|
||||
Err(RecvTimeoutError::Disconnected) => Err(anyhow!("pipewire pad-sink thread ended")),
|
||||
}
|
||||
}
|
||||
|
||||
fn channels(&self) -> u32 {
|
||||
PAD_CHANNELS
|
||||
}
|
||||
}
|
||||
|
||||
/// SPA channel positions for the pad quad: AUX0..AUX3 (`enum spa_audio_channel`:
|
||||
/// `SPA_AUDIO_CHANNEL_START_Aux` = 0x1000), NOT a positioned FL FR RL RR layout. This is the
|
||||
/// shape a REAL DualSense exposes on the PipeWire path GE-Proton's haptics were built and
|
||||
/// field-validated against: its `open_dualsense_haptic_pcm` targets the node through the
|
||||
/// bundled pipewire-alsa plugin with `aux_channels=1` — "the hidden PipeWire parent for a
|
||||
/// DualSense output exposes AUX0 through AUX3" (proton-ds5-haptic patch 0115) — and its pulse
|
||||
/// fallback forces a `PA_CHANNEL_POSITION_AUX0..3` map. On a real pad that shape is the card's
|
||||
/// Pro Audio profile (the community-reported requirement for GE ≥11-4). Aux positions carry no
|
||||
/// spatial meaning, so nothing in the graph position-remixes into (or out of) the sink —
|
||||
/// writers land by INDEX, exactly the raw quad the pad speaks: ch0/1 = speaker, ch2/3 = voice
|
||||
/// coils (the same order the Windows endpoint is stamped with and `split_quad` assumes).
|
||||
fn pad_positions() -> [u32; 64] {
|
||||
const AUX0: u32 = 0x1000;
|
||||
let mut pos = [0u32; 64];
|
||||
pos[..4].copy_from_slice(&[AUX0, AUX0 + 1, AUX0 + 2, AUX0 + 3]);
|
||||
pos
|
||||
}
|
||||
|
||||
/// The `!Send` MainLoop/Stream thread: mint the sink, hand capture chunks over, run until
|
||||
/// Terminate / daemon death. Mirrors the session capturer's `pw_thread` stream-sink arm minus
|
||||
/// the default-sink claim and the desktop-plane stats (the pad plane's observability lives in
|
||||
/// the streamer's gate/encode logs).
|
||||
fn pad_sink_thread(
|
||||
tx: std::sync::mpsc::SyncSender<Vec<f32>>,
|
||||
quit_rx: pipewire::channel::Receiver<Terminate>,
|
||||
identity: PadSinkIdentity,
|
||||
ready: std::sync::mpsc::SyncSender<Result<()>>,
|
||||
) -> Result<()> {
|
||||
use pipewire as pw;
|
||||
use pw::{properties::properties, spa};
|
||||
use spa::param::audio::{AudioFormat, AudioInfoRaw};
|
||||
use spa::pod::Pod;
|
||||
|
||||
let result = (|| -> Result<()> {
|
||||
pf_capture::pwinit::ensure_init();
|
||||
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw pad-sink MainLoop")?;
|
||||
let context =
|
||||
pw::context::ContextRc::new(&mainloop, None).context("pw pad-sink Context")?;
|
||||
let core = context
|
||||
.connect_rc(None)
|
||||
.context("pw pad-sink connect (is PipeWire running in this session?)")?;
|
||||
|
||||
let _quit_guard = quit_rx.attach(mainloop.loop_(), {
|
||||
let mainloop = mainloop.clone();
|
||||
move |_| mainloop.quit()
|
||||
});
|
||||
|
||||
// Daemon death ends this thread → the chunk channel disconnects → `next_chunk` errors →
|
||||
// the per-pad streamer reopens with backoff (the session capturer's zombie-thread fix).
|
||||
let _core_listener = core
|
||||
.add_listener_local()
|
||||
.error({
|
||||
let mainloop = mainloop.clone();
|
||||
move |id, _seq, res, message| {
|
||||
tracing::warn!(id, res, message, "pipewire core error — pad sink ends");
|
||||
mainloop.quit();
|
||||
}
|
||||
})
|
||||
.register();
|
||||
|
||||
let mut props = properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Audio",
|
||||
*pw::keys::MEDIA_CLASS => "Audio/Sink",
|
||||
// One Opus-haptics frame (~5 ms) per quantum, like the session sink — haptics are
|
||||
// felt latency; bursty delivery would ride through to the client's jitter buffer.
|
||||
*pw::keys::NODE_LATENCY => "240/48000",
|
||||
// Must NEVER win WirePlumber's default election against real hardware — games reach
|
||||
// this sink BY IDENTITY, nothing auto-routes here (no stream_sink claim either).
|
||||
"priority.session" => "50",
|
||||
// The pulse-proplist leg of GE-Proton's match (§3): bus + vendor/product ids, plus
|
||||
// the human-readable pair pavucontrol and the game view show.
|
||||
"device.bus" => "usb",
|
||||
"device.vendor.id" => "054c",
|
||||
"device.vendor.name" => "Sony Interactive Entertainment",
|
||||
"device.form_factor" => "gamepad",
|
||||
};
|
||||
props.insert(*pw::keys::NODE_NAME, identity.node_name.as_str());
|
||||
props.insert(*pw::keys::NODE_DESCRIPTION, identity.description.as_str());
|
||||
props.insert(*pw::keys::NODE_NICK, identity.description.as_str());
|
||||
props.insert("device.serial", identity.serial.as_str());
|
||||
props.insert("device.product.id", identity.product_id);
|
||||
props.insert("device.product.name", identity.product_name);
|
||||
let stream = pw::stream::StreamBox::new(&core, "punktfunk-pad-audio", props)
|
||||
.context("pw pad-sink Stream")?;
|
||||
|
||||
// Lossy-drop counter: a full channel means the 0xD1 encode thread stalled. Invisible
|
||||
// drops cost a field investigation on the desktop plane once — count and warn here too,
|
||||
// power-of-two throttled (this callback runs at the graph quantum).
|
||||
struct PadUd {
|
||||
tx: std::sync::mpsc::SyncSender<Vec<f32>>,
|
||||
dropped: u64,
|
||||
}
|
||||
let ud = PadUd { tx, dropped: 0 };
|
||||
let _listener = stream
|
||||
.add_local_listener_with_user_data(ud)
|
||||
.state_changed({
|
||||
let mainloop = mainloop.clone();
|
||||
move |_s, _ud, old, new| {
|
||||
tracing::debug!(?old, ?new, "pipewire pad-sink stream state");
|
||||
if matches!(new, pw::stream::StreamState::Error(_)) {
|
||||
mainloop.quit();
|
||||
}
|
||||
}
|
||||
})
|
||||
.param_changed(move |_stream, _ud, id, param| {
|
||||
let Some(param) = param else { return };
|
||||
if id != pw::spa::param::ParamType::Format.as_raw() {
|
||||
return;
|
||||
}
|
||||
let mut info = AudioInfoRaw::default();
|
||||
if info.parse(param).is_ok() {
|
||||
// We own the sink, so this IS the format games render into (nothing can
|
||||
// have narrowed it upstream — the same guarantee as stream-sink mode).
|
||||
tracing::info!(
|
||||
format = ?info.format(),
|
||||
rate = info.rate(),
|
||||
channels = info.channels(),
|
||||
"pad-sink format negotiated"
|
||||
);
|
||||
}
|
||||
})
|
||||
.process(|stream, ud| {
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||
return;
|
||||
};
|
||||
let datas = buffer.datas_mut();
|
||||
if datas.is_empty() {
|
||||
return;
|
||||
}
|
||||
let d = &mut datas[0];
|
||||
let (offset, size) = {
|
||||
let c = d.chunk();
|
||||
(c.offset() as usize, c.size() as usize)
|
||||
};
|
||||
let Some(buf) = d.data() else { return };
|
||||
if offset > buf.len() {
|
||||
return;
|
||||
}
|
||||
let region = &buf[offset..(offset + size).min(buf.len())];
|
||||
// Negotiated as F32LE; reinterpret the byte region as interleaved f32.
|
||||
let n = region.len() / 4;
|
||||
let mut samples = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let b = [
|
||||
region[i * 4],
|
||||
region[i * 4 + 1],
|
||||
region[i * 4 + 2],
|
||||
region[i * 4 + 3],
|
||||
];
|
||||
samples.push(f32::from_le_bytes(b));
|
||||
}
|
||||
if ud.tx.try_send(samples).is_err() {
|
||||
ud.dropped += 1;
|
||||
if ud.dropped.is_power_of_two() {
|
||||
tracing::warn!(
|
||||
dropped = ud.dropped,
|
||||
"pad-audio encode thread not keeping up — captured pad audio \
|
||||
dropped (haptics will click)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}));
|
||||
if outcome.is_err() {
|
||||
tracing::error!("panic in pipewire pad-sink callback — chunk dropped");
|
||||
}
|
||||
})
|
||||
.register()
|
||||
.context("register pad-sink stream listener")?;
|
||||
|
||||
let mut info = AudioInfoRaw::new();
|
||||
info.set_format(AudioFormat::F32LE);
|
||||
info.set_rate(crate::audio::SAMPLE_RATE);
|
||||
info.set_channels(PAD_CHANNELS);
|
||||
info.set_position(pad_positions());
|
||||
let obj = pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
||||
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
|
||||
properties: info.into(),
|
||||
};
|
||||
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
&pw::spa::pod::Value::Object(obj),
|
||||
)
|
||||
.context("serialize pad-sink format pod")?
|
||||
.0
|
||||
.into_inner();
|
||||
let mut params = [Pod::from_bytes(&values).context("pad-sink pod from bytes")?];
|
||||
|
||||
// RT_PROCESS for the same reason as every host-owned stream node here: the sink must be
|
||||
// a synchronous graph member that joins its producers' driver group, or `process()`
|
||||
// never fires on a busy graph (see the mic's connect comment in mod.rs).
|
||||
stream
|
||||
.connect(
|
||||
spa::utils::Direction::Input, // we CONSUME what games render into the sink
|
||||
None,
|
||||
pw::stream::StreamFlags::AUTOCONNECT
|
||||
| pw::stream::StreamFlags::MAP_BUFFERS
|
||||
| pw::stream::StreamFlags::RT_PROCESS,
|
||||
&mut params,
|
||||
)
|
||||
.context("pw pad-sink stream connect")?;
|
||||
|
||||
let _ = ready.send(Ok(()));
|
||||
mainloop.run();
|
||||
tracing::debug!("pipewire pad-sink loop exited (capturer dropped)");
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = &result {
|
||||
let _ = ready.send(Err(anyhow!("{e:#}")));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pad_mac_is_reversed_display_form_and_per_pad_unique() {
|
||||
// DS_FEATURE_PAIRING bytes 1..7 are 74 E7 D6 3A 53 35 LSB-first → display reverses.
|
||||
assert_eq!(pad_mac(0), "35:53:3A:D6:E7:74");
|
||||
// The pad index offsets the LOW octet — the LAST display octet.
|
||||
assert_eq!(pad_mac(1), "35:53:3A:D6:E7:75");
|
||||
assert_ne!(pad_mac(2), pad_mac(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_carries_every_match_surface() {
|
||||
let id = PadSinkIdentity::new(0, false);
|
||||
// The name-substring matchers (GE-Proton + the community WirePlumber rule).
|
||||
assert!(id.node_name.contains("Sony_Interactive_Entertainment"));
|
||||
assert!(id.node_name.contains("Wireless_Controller"));
|
||||
assert!(id.node_name.contains("DualSense"));
|
||||
assert!(id.node_name.ends_with("-00.analog-surround-40"));
|
||||
// No colons in a udev-style serial/name.
|
||||
assert!(!id.node_name.contains(':'));
|
||||
assert_eq!(id.description, "Wireless Controller");
|
||||
assert_eq!(id.product_id, "0ce6");
|
||||
let edge = PadSinkIdentity::new(1, true);
|
||||
assert!(edge.node_name.contains("DualSense_Edge"));
|
||||
assert_eq!(edge.product_id, "0df2");
|
||||
// Distinct pads mint distinct names (the serial octet).
|
||||
assert_ne!(id.node_name, PadSinkIdentity::new(1, false).node_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_expansion() {
|
||||
assert_eq!(expand("pad{pad}-{mac}", 2, "AABB"), "pad2-AABB");
|
||||
assert_eq!(expand("static", 0, "x"), "static");
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
use super::pad_endpoint as pe;
|
||||
use super::{audio_control, wiring_plan};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -40,6 +40,17 @@ const ENDPOINT_WAIT: Duration = Duration::from_secs(15);
|
||||
/// Minimum spacing between provisioning retries once the startup attempt failed
|
||||
/// ([`ensure_provisioned`] is called from wiring passes, which recur freely).
|
||||
const RETRY_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
/// Full passes that ended unlatched before minting gives up for this host lifetime (a service
|
||||
/// restart re-arms). An unlatched pass that reaches the PnP surface costs the whole BOX, not
|
||||
/// just us: the driver (re)bind raises a device-change broadcast every running app services,
|
||||
/// and games rebuild their audio graph on it — a box that cannot mint must not pay that on
|
||||
/// every retry forever (field-measured 2026-08-12 as Helldivers 2 hitching to 2–5 FPS 1% lows,
|
||||
/// one hitch per mic-pump reopen).
|
||||
const MAX_UNLATCHED_ATTEMPTS: u32 = 5;
|
||||
/// How long [`ensure_blocking`] waits on a pass another thread already runs before giving the
|
||||
/// wiring plan the unlatched answer (a full cold-boot pass worst-cases around two
|
||||
/// [`ENDPOINT_WAIT`]s plus the stamp settles).
|
||||
const BLOCKING_WAIT: Duration = Duration::from_secs(90);
|
||||
|
||||
/// The two minted roles. `value` is the persisted marker; the needles drive
|
||||
/// [`discover_driver`].
|
||||
@@ -107,6 +118,26 @@ static PROVISIONED: OnceLock<Arc<MintedAudio>> = OnceLock::new();
|
||||
static PROVISIONING: AtomicBool = AtomicBool::new(false);
|
||||
/// When the last attempt STARTED — the [`RETRY_COOLDOWN`] anchor.
|
||||
static LAST_ATTEMPT: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
/// Completed passes that did not latch, across the worker and the blocking path — the
|
||||
/// [`MAX_UNLATCHED_ATTEMPTS`] give-up counter.
|
||||
static UNLATCHED_ATTEMPTS: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// Count one finished-but-unlatched pass; the crossing attempt logs the give-up exactly once.
|
||||
fn record_unlatched_attempt() {
|
||||
let n = UNLATCHED_ATTEMPTS.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if n == MAX_UNLATCHED_ATTEMPTS {
|
||||
tracing::warn!(
|
||||
attempts = n,
|
||||
"minted-audio provisioning keeps failing — giving up for this host lifetime so \
|
||||
retries stop broadcasting device changes at the whole box; the wiring plan keeps \
|
||||
the name-based ladder, a service restart re-arms minting"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn gave_up() -> bool {
|
||||
UNLATCHED_ATTEMPTS.load(Ordering::SeqCst) >= MAX_UNLATCHED_ATTEMPTS
|
||||
}
|
||||
|
||||
/// The wiring plan's tier-0 input: the minted ids, or all-empty while nothing is provisioned.
|
||||
///
|
||||
@@ -135,7 +166,7 @@ pub(crate) fn provisioned() -> Option<Arc<MintedAudio>> {
|
||||
/// Spawn the provisioning worker (idempotent; returns immediately). Called at host start next
|
||||
/// to the pad provider, and again from [`ensure_provisioned`] on the retry path.
|
||||
pub(crate) fn provision_at_startup() {
|
||||
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() {
|
||||
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() || gave_up() {
|
||||
return;
|
||||
}
|
||||
if PROVISIONED.get().is_some() || PROVISIONING.swap(true, Ordering::SeqCst) {
|
||||
@@ -155,13 +186,19 @@ pub(crate) fn provision_at_startup() {
|
||||
);
|
||||
let _ = PROVISIONED.set(Arc::new(m));
|
||||
}
|
||||
Ok(_) => tracing::info!(
|
||||
"no minted audio endpoints (Steam's streaming drivers absent?) — the \
|
||||
wiring plan keeps the name-based ladder"
|
||||
),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"),
|
||||
"minted-audio provisioning failed — the wiring plan keeps the name-based \
|
||||
ladder and a later wiring pass retries"),
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
"no minted audio endpoints (Steam's streaming drivers absent?) — the \
|
||||
wiring plan keeps the name-based ladder"
|
||||
);
|
||||
record_unlatched_attempt();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"minted-audio provisioning failed — the wiring plan keeps the name-based \
|
||||
ladder and a later wiring pass retries");
|
||||
record_unlatched_attempt();
|
||||
}
|
||||
}
|
||||
PROVISIONING.store(false, Ordering::SeqCst);
|
||||
});
|
||||
@@ -175,7 +212,7 @@ pub(crate) fn provision_at_startup() {
|
||||
/// [`RETRY_COOLDOWN`] — a box where Steam arrives later mints on a later pass instead of at
|
||||
/// the next reboot.
|
||||
pub(crate) fn ensure_provisioned() {
|
||||
if PROVISIONED.get().is_some() {
|
||||
if PROVISIONED.get().is_some() || gave_up() {
|
||||
return;
|
||||
}
|
||||
{
|
||||
@@ -219,6 +256,19 @@ fn ensure_all() -> Result<MintedAudio> {
|
||||
/// back any default device the fresh endpoint grabbed (measured on the pad program: a newly
|
||||
/// registered endpoint can take either default).
|
||||
fn ensure_role(role: Role) -> Result<(String, String, Option<String>)> {
|
||||
// Steady state: a previous run's devnode with all endpoints live — resolve by marker and
|
||||
// return without touching PnP or the default-device policy. The full pass below (re)binds
|
||||
// the driver even over an existing devnode, and that bind raises a device-change broadcast
|
||||
// every running app services — right at first mint, ruinous from a retry path (each
|
||||
// broadcast makes games rebuild their audio graph; see [`MAX_UNLATCHED_ATTEMPTS`]).
|
||||
if let Some((devnode, render, capture)) = find_healthy_role(role)? {
|
||||
stamp_identity(&render, role, false);
|
||||
if let Some(cap) = capture.as_ref() {
|
||||
stamp_identity(cap, role, true);
|
||||
}
|
||||
return Ok((devnode, render, capture));
|
||||
}
|
||||
|
||||
let prev_render = audio_control::default_render_id();
|
||||
let prev_capture = audio_control::default_capture_id();
|
||||
|
||||
@@ -284,6 +334,27 @@ fn ensure_role(role: Role) -> Result<(String, String, Option<String>)> {
|
||||
Ok((devnode, render, capture))
|
||||
}
|
||||
|
||||
/// The role's marker devnode with EVERY endpoint the role owes already registered, or `None`
|
||||
/// (missing devnode, missing endpoint, or an enumeration error → the caller runs the full
|
||||
/// pass). Same endpoint resolvers [`wait_for`] polls, so "healthy" here is exactly the state
|
||||
/// the full pass would declare ready.
|
||||
fn find_healthy_role(role: Role) -> Result<Option<(String, String, Option<String>)>> {
|
||||
let Some(devnode) = find_role_devnode(role)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(render) = pe::find_endpoint_for_devnode(&devnode)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let capture = match role {
|
||||
Role::Mic => match pe::find_capture_endpoint_for_devnode(&devnode)? {
|
||||
Some(cap) => Some(cap),
|
||||
None => return Ok(None),
|
||||
},
|
||||
Role::Speakers => None,
|
||||
};
|
||||
Ok(Some((devnode, render, capture)))
|
||||
}
|
||||
|
||||
/// How many stamp/settle passes a name gets before we accept "stored but not yet served"
|
||||
/// (a settled endpoint takes the stamp on the first pass; a freshly minted one may need the
|
||||
/// audio stack to notice — it serves after the next Audiosrv restart/reboot at the latest).
|
||||
@@ -510,7 +581,6 @@ pub(crate) fn discover_driver(needle: &str, inf_name: &str) -> Result<(String, S
|
||||
)
|
||||
}
|
||||
|
||||
/// `audio-probe mint` devtest body: one synchronous provisioning pass, results printed.
|
||||
/// Synchronous provisioning — for the mic pump's resolve and the devtests.
|
||||
///
|
||||
/// The pump's FIRST open must not race the startup worker: measured on the target box, the
|
||||
@@ -520,15 +590,50 @@ pub(crate) fn discover_driver(needle: &str, inf_name: &str) -> Result<(String, S
|
||||
/// (existing marker devnodes re-resolve in milliseconds; a cold boot pays the one-time mint)
|
||||
/// keeps the pump's target and the plan's verdict the same thing. Latched calls return
|
||||
/// immediately; the opt-out env is honoured like everywhere else.
|
||||
///
|
||||
/// While UNLATCHED this is where the pump's reopen backoff (capped at 60 s) used to meet an
|
||||
/// unguarded full pass: one PnP rebind + device-change broadcast roughly every minute, forever,
|
||||
/// on any box where minting cannot converge (the 2026-08-12 Helldivers 2 field report). Now a
|
||||
/// pass someone else already runs is WAITED for instead of raced, a failed pass repeats at most
|
||||
/// every [`RETRY_COOLDOWN`], and [`MAX_UNLATCHED_ATTEMPTS`] failures stop retrying for the
|
||||
/// host lifetime.
|
||||
pub(crate) fn ensure_blocking() {
|
||||
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() || PROVISIONED.get().is_some() {
|
||||
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some()
|
||||
|| PROVISIONED.get().is_some()
|
||||
|| gave_up()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok(m) = ensure_all() {
|
||||
if m.any() {
|
||||
let _ = PROVISIONED.set(Arc::new(m));
|
||||
// A pass is in flight (the startup worker, or a concurrent resolve): wait for its verdict
|
||||
// rather than racing a second SetupAPI/PnP sweep against it — that race is how the pump
|
||||
// once ended up wired to the cable while the worker minted (the dead-mic-air deploy race).
|
||||
if PROVISIONING.swap(true, Ordering::SeqCst) {
|
||||
let deadline = Instant::now() + BLOCKING_WAIT;
|
||||
while PROVISIONING.load(Ordering::SeqCst) && Instant::now() < deadline {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// We own the slot. First-ever resolve runs unconditionally (the cold-boot mint the doc
|
||||
// above insists on); after a failed pass the cooldown answers instead of a re-run.
|
||||
let run = {
|
||||
let mut last = LAST_ATTEMPT.lock().unwrap();
|
||||
if last.is_some_and(|t| t.elapsed() < RETRY_COOLDOWN) {
|
||||
false
|
||||
} else {
|
||||
*last = Some(Instant::now());
|
||||
true
|
||||
}
|
||||
};
|
||||
if run {
|
||||
match ensure_all() {
|
||||
Ok(m) if m.any() => {
|
||||
let _ = PROVISIONED.set(Arc::new(m));
|
||||
}
|
||||
_ => record_unlatched_attempt(),
|
||||
}
|
||||
}
|
||||
PROVISIONING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub(crate) fn devtest_mint() -> Result<()> {
|
||||
|
||||
@@ -231,6 +231,66 @@ pub fn dualsense_test(args: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mint one pad-audio PipeWire sink (the Linux 0xD1 source, `audio::pad_sink`) and capture
|
||||
/// from it — the WP3 on-glass gate with no client involved. Verify the identity with
|
||||
/// `pactl list sinks` (name/description/proplist) and drive it with
|
||||
/// `pw-play --target <node.name> <file>` (or `paplay -d <node.name>`); captured chunks print
|
||||
/// a per-second summary here. `--pad N` (default 0), `--edge`, `--seconds N` (default 30).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn pad_sink_test(args: &[String]) -> Result<()> {
|
||||
use crate::audio::AudioCapturer as _;
|
||||
use std::time::{Duration, Instant};
|
||||
let secs: u64 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--seconds")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(30);
|
||||
let pad: u8 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--pad")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let edge = args.iter().any(|a| a == "--edge");
|
||||
let mut cap = crate::audio::pad_sink::PadSinkCapturer::open(pad, edge)
|
||||
.context("mint pad-audio sink (is PipeWire running in this session?)")?;
|
||||
println!(
|
||||
"pad sink minted: node.name = {}\n inspect: pactl list sinks | grep -A20 punktfunk-pad\n \
|
||||
drive it: pw-play --target '{}' <48k-file>\nCapturing for {secs}s…",
|
||||
cap.node_name, cap.node_name
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
let (mut chunks, mut samples) = (0u64, 0u64);
|
||||
// Per-pair peaks: ch0/1 = speaker, ch2/3 = voice coils — the split_quad contract. Proving
|
||||
// the pairs separately is the point of this devtest: a positional remix upstream would
|
||||
// smear or zero one pair while a global peak still looks healthy.
|
||||
let (mut peak_spk, mut peak_coil) = (0f32, 0f32);
|
||||
let mut last_report = Instant::now();
|
||||
while Instant::now() < deadline {
|
||||
let c = cap.next_chunk().context("pad sink capture")?;
|
||||
if !c.is_empty() {
|
||||
chunks += 1;
|
||||
samples += c.len() as u64;
|
||||
for f in c.chunks_exact(4) {
|
||||
peak_spk = peak_spk.max(f[0].abs()).max(f[1].abs());
|
||||
peak_coil = peak_coil.max(f[2].abs()).max(f[3].abs());
|
||||
}
|
||||
}
|
||||
if last_report.elapsed() >= Duration::from_secs(1) {
|
||||
last_report = Instant::now();
|
||||
println!(
|
||||
" chunks={chunks} samples={samples} (~{:.1}ms of 4ch audio) \
|
||||
peak_speaker={peak_spk:.4} peak_coils={peak_coil:.4}",
|
||||
samples as f64 / (4.0 * 48.0)
|
||||
);
|
||||
(chunks, samples, peak_spk, peak_coil) = (0, 0, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
println!("pad-sink-test: done");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no
|
||||
/// streaming session): answers the full hid-nintendo probe conversation, then cycles the
|
||||
/// A/B buttons (positionally swapped) + sweeps the left stick, printing rumble / player-
|
||||
|
||||
@@ -623,6 +623,9 @@ fn real_main() -> Result<()> {
|
||||
// Create a virtual DualSense via UHID and exercise it (validation, no streaming session).
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("dualsense-test") => devtest::dualsense_test(&args),
|
||||
// Mint one pad-audio PipeWire sink and capture from it — the Linux 0xD1 source gate.
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("pad-sink-test") => devtest::pad_sink_test(&args),
|
||||
// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no session).
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("switchpro-test") => devtest::switchpro_test(&args),
|
||||
|
||||
@@ -616,8 +616,10 @@ impl PadAudioSlots {
|
||||
|
||||
/// Idempotent spawn: same kinds → keep the running streamer; changed kinds → restart with
|
||||
/// the new mask; not running → spawn (a slot without an endpoint stays empty — bounded
|
||||
/// retries, since arrivals are only re-sent a few times per slot open).
|
||||
fn ensure(&mut self, conn: &quinn::Connection, pad: u8, kinds: u8) {
|
||||
/// retries, since arrivals are only re-sent a few times per slot open). `edge` picks the
|
||||
/// DualSense Edge identity for the Linux sink (ignored on Windows — endpoints are
|
||||
/// pre-stamped).
|
||||
fn ensure(&mut self, conn: &quinn::Connection, pad: u8, kinds: u8, edge: bool) {
|
||||
let idx = pad as usize;
|
||||
if idx >= MAX_WIRE_PADS {
|
||||
return;
|
||||
@@ -648,7 +650,7 @@ impl PadAudioSlots {
|
||||
self.stop(idx);
|
||||
}
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
if let Some(h) = pad_audio::spawn(conn.clone(), pad, kinds, stop) {
|
||||
if let Some(h) = pad_audio::spawn(conn.clone(), pad, kinds, edge, stop) {
|
||||
self.slots[idx] = Some((kinds, h));
|
||||
}
|
||||
}
|
||||
@@ -1087,7 +1089,12 @@ pub(super) fn input_thread(
|
||||
0
|
||||
};
|
||||
if want != 0 {
|
||||
pad_streams.ensure(&conn, pad, want);
|
||||
pad_streams.ensure(
|
||||
&conn,
|
||||
pad,
|
||||
want,
|
||||
matches!(kind, GamepadPref::DualSenseEdge),
|
||||
);
|
||||
} else {
|
||||
pad_streams.stop(idx);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Per-pad DualSense audio (the 0xD1 pad-audio plane): WASAPI loopback of a pre-provisioned pad
|
||||
//! endpoint ([`crate::audio::pad_endpoint`]) → 4-ch de-interleave into the speaker (front) and
|
||||
//! voice-coil haptics (back) pairs → per-kind silence gate → stereo Opus (48 kHz, CBR, LowDelay)
|
||||
//! Per-pad DualSense audio (the 0xD1 pad-audio plane): capture of the pad's own audio device —
|
||||
//! Windows: WASAPI loopback of a pre-provisioned endpoint ([`crate::audio::pad_endpoint`]);
|
||||
//! Linux: the per-pad PipeWire sink we mint (`crate::audio::pad_sink`) — → 4-ch de-interleave
|
||||
//! into the speaker (front) and voice-coil haptics (back) pairs → per-kind silence gate →
|
||||
//! stereo Opus (48 kHz, CBR, LowDelay)
|
||||
//! → [`PAD_AUDIO_MAGIC`](punktfunk_core::quic::PAD_AUDIO_MAGIC) datagrams. One thread per
|
||||
//! arriving pad, spawned/reaped by the input thread ([`super::input`]) as arrivals declare
|
||||
//! renderers and pads leave. Modeled on the session audio thread ([`super::audio`]): the same
|
||||
@@ -11,45 +13,45 @@ use super::*;
|
||||
|
||||
/// `kinds` bit for the haptics stream (bit N = wire kind N — the same packing the arrival's
|
||||
/// audio-caps bits use, see [`punktfunk_core::input::decode_gamepad_arrival`]).
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", test))]
|
||||
pub(super) const KIND_BIT_HAPTICS: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS;
|
||||
/// `kinds` bit for the speaker stream.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", test))]
|
||||
pub(super) const KIND_BIT_SPEAKER: u8 = 1 << punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER;
|
||||
|
||||
/// Haptics frames are 5 ms (the session-audio cadence — haptics are felt latency); speaker
|
||||
/// frames are 10 ms (speaker content tolerates the buffering for the coding efficiency). Both
|
||||
/// are the wire contract's cadences (`punktfunk_core::quic::PAD_AUDIO_KIND_*`).
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", test))]
|
||||
const HAPTICS_FRAME_MS: u32 = 5;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", test))]
|
||||
const SPEAKER_FRAME_MS: u32 = 10;
|
||||
/// Samples per frame (per channel) at 48 kHz: 240 / 480.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", test))]
|
||||
const HAPTICS_FRAME_SAMPLES: usize =
|
||||
crate::audio::SAMPLE_RATE as usize * HAPTICS_FRAME_MS as usize / 1000;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", test))]
|
||||
const SPEAKER_FRAME_SAMPLES: usize =
|
||||
crate::audio::SAMPLE_RATE as usize * SPEAKER_FRAME_MS as usize / 1000;
|
||||
/// The capture's channel count — the pad endpoint is stamped quad (FL FR BL BR: front pair =
|
||||
/// speaker, back pair = voice coils). Mirrors `pad_endpoint::PAD_CHANNELS` (Windows-gated, so
|
||||
/// the pure splitter logic keeps its own copy).
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", test))]
|
||||
const CAP_CHANNELS: usize = 4;
|
||||
|
||||
/// Peak (absolute sample) at or above which a frame counts as signal — the gate OPENS on that
|
||||
/// very frame (haptics are felt latency; the first active frame must ship). ≈ −60 dBFS.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", test))]
|
||||
const GATE_OPEN_PEAK: f32 = 1e-3;
|
||||
/// How long the gate keeps sending after the last signal frame before it CLOSES (hangover):
|
||||
/// long enough that a decaying haptic tail (and the client decoder's own tail) is never
|
||||
/// clipped, short enough that an idle pad costs nothing in steady state.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", test))]
|
||||
const GATE_HANGOVER_MS: u32 = 250;
|
||||
|
||||
/// Per-kind Opus bitrate — a stereo voice-coil / pad-speaker pair needs far less than the
|
||||
/// session plane's 128 kbps; 64 kbps CBR keeps every frame comfortably under one MTU.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
const PAD_AUDIO_BITRATE: i32 = 64_000;
|
||||
|
||||
/// The per-kind silence gate — the steady-state-cost feature: an idle pad endpoint (games
|
||||
@@ -57,7 +59,7 @@ const PAD_AUDIO_BITRATE: i32 = 64_000;
|
||||
/// stream of coded silence. Opens the instant a frame carries signal ([`GATE_OPEN_PEAK`]);
|
||||
/// closes only after [`GATE_HANGOVER_MS`] of continuous sub-threshold frames. Pure logic,
|
||||
/// unit-tested below.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", test))]
|
||||
struct SilenceGate {
|
||||
/// Consecutive sub-threshold frames that close the gate ([`GATE_HANGOVER_MS`] ÷ frame ms).
|
||||
hangover_frames: u32,
|
||||
@@ -67,7 +69,7 @@ struct SilenceGate {
|
||||
open: bool,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", test))]
|
||||
impl SilenceGate {
|
||||
fn new(frame_ms: u32) -> SilenceGate {
|
||||
SilenceGate {
|
||||
@@ -101,13 +103,13 @@ impl SilenceGate {
|
||||
/// loss by seq continuity (the mic-mute discipline, pf-client-core/src/audio.rs). It is also
|
||||
/// kept across capture reopens (the session audio thread's discipline, audio.rs): the client
|
||||
/// sees a gap, not a restart.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", test))]
|
||||
struct LaneCtl {
|
||||
gate: SilenceGate,
|
||||
seq: u32,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", test))]
|
||||
impl LaneCtl {
|
||||
fn new(frame_ms: u32) -> LaneCtl {
|
||||
LaneCtl {
|
||||
@@ -133,7 +135,7 @@ impl LaneCtl {
|
||||
/// speaker (channels 0/1), back = voice-coil haptics (channels 2/3). A ragged tail (not a
|
||||
/// multiple of 4 — the capturer only ever delivers whole frames) is dropped, never smeared
|
||||
/// across channels.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", test))]
|
||||
fn split_quad(block: &[f32]) -> (Vec<f32>, Vec<f32>) {
|
||||
let mut front = Vec::with_capacity(block.len() / 2);
|
||||
let mut back = Vec::with_capacity(block.len() / 2);
|
||||
@@ -148,7 +150,7 @@ fn split_quad(block: &[f32]) -> (Vec<f32>, Vec<f32>) {
|
||||
/// frames — haptics every 5 ms from the back pair, speaker every 10 ms from the front pair —
|
||||
/// emitting ONLY the kinds enabled in `kinds` (a disabled kind is never even split out, so it
|
||||
/// can never reach an encoder). Pure logic, unit-tested; the capture thread wraps it.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", test))]
|
||||
struct PadFramer {
|
||||
kinds: u8,
|
||||
/// Raw interleaved 4-ch accumulation, drained in 5 ms blocks.
|
||||
@@ -157,7 +159,7 @@ struct PadFramer {
|
||||
front: Vec<f32>,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", test))]
|
||||
impl PadFramer {
|
||||
fn new(kinds: u8) -> PadFramer {
|
||||
PadFramer {
|
||||
@@ -238,11 +240,12 @@ impl Drop for PadAudioHandle {
|
||||
|
||||
/// Whether this session's Welcome should advertise
|
||||
/// [`HOST_CAP_PAD_AUDIO`](punktfunk_core::quic::HOST_CAP_PAD_AUDIO): the client asked
|
||||
/// ([`CLIENT_CAP_PAD_AUDIO`](punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO)), this is a Windows
|
||||
/// host with the feature on (`PUNKTFUNK_PAD_AUDIO` != "0"), and startup provisioning published
|
||||
/// at least one endpoint (`pad_endpoint::provision_at_startup`). Still-running provisioning
|
||||
/// reads as "none yet": a session racing host startup simply negotiates without pad audio and
|
||||
/// picks it up on its next connect.
|
||||
/// ([`CLIENT_CAP_PAD_AUDIO`](punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO)), the feature is on
|
||||
/// (`PUNKTFUNK_PAD_AUDIO` != "0"), and the pad audio source exists — Windows: startup
|
||||
/// provisioning published at least one endpoint (`pad_endpoint::provision_at_startup`; a
|
||||
/// still-running provisioning reads as "none yet" and the next connect picks it up); Linux: a
|
||||
/// PipeWire daemon is reachable (the per-pad sinks are minted lazily at spawn, so reachability
|
||||
/// IS the existence question).
|
||||
pub(super) fn host_cap(client_caps: u8) -> bool {
|
||||
let asked = client_caps & punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO != 0;
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -257,9 +260,15 @@ pub(super) fn host_cap(client_caps: u8) -> bool {
|
||||
&& crate::audio::pad_endpoint::provisioned_endpoints()
|
||||
.is_some_and(|eps| !eps.is_empty())
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Only the Windows virtual DualSense exposes pad audio endpoints today.
|
||||
asked
|
||||
&& std::env::var_os("PUNKTFUNK_PAD_AUDIO").is_none_or(|v| v != "0")
|
||||
&& crate::audio::pad_sink::pipewire_reachable()
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
// No pad audio source on this host OS.
|
||||
let _ = asked;
|
||||
false
|
||||
}
|
||||
@@ -276,6 +285,7 @@ pub(super) fn spawn(
|
||||
conn: quinn::Connection,
|
||||
pad: u8,
|
||||
kinds: u8,
|
||||
_edge: bool,
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> Option<PadAudioHandle> {
|
||||
if kinds & (KIND_BIT_HAPTICS | KIND_BIT_SPEAKER) == 0 {
|
||||
@@ -310,10 +320,18 @@ pub(super) fn spawn(
|
||||
return None;
|
||||
}
|
||||
let stop_t = stop.clone();
|
||||
let endpoint_id = ep.endpoint_id;
|
||||
match std::thread::Builder::new()
|
||||
.name(format!("punktfunk1-pad{pad}"))
|
||||
.spawn(move || pad_audio_thread(conn, pad, kinds, ep.endpoint_id, stop_t))
|
||||
{
|
||||
.spawn(move || {
|
||||
pad_audio_thread(
|
||||
conn,
|
||||
pad,
|
||||
kinds,
|
||||
move || crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id),
|
||||
stop_t,
|
||||
)
|
||||
}) {
|
||||
Ok(join) => Some(PadAudioHandle {
|
||||
stop,
|
||||
join: Some(join),
|
||||
@@ -325,13 +343,60 @@ pub(super) fn spawn(
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub — pad endpoints exist only behind the Windows virtual DualSense; other hosts run pads
|
||||
/// without the audio side (and never advertise the cap, see [`host_cap`]).
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
/// Linux: mint the pad's PipeWire sink lazily inside the streamer thread (the same
|
||||
/// open-with-backoff loop the Windows capture rides — a PipeWire hiccup at arrival time starts
|
||||
/// pad audio late, not never). `edge` picks the DualSense Edge identity for the sink. `None`
|
||||
/// only for empty kinds, a slot past `PUNKTFUNK_PAD_AUDIO_SLOTS`, or a failed thread spawn;
|
||||
/// the pad itself keeps working either way, just without audio.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn spawn(
|
||||
conn: quinn::Connection,
|
||||
pad: u8,
|
||||
kinds: u8,
|
||||
edge: bool,
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> Option<PadAudioHandle> {
|
||||
if kinds & (KIND_BIT_HAPTICS | KIND_BIT_SPEAKER) == 0 {
|
||||
return None;
|
||||
}
|
||||
if pad >= crate::audio::pad_sink::pad_audio_slots() {
|
||||
tracing::debug!(
|
||||
pad,
|
||||
"pad-audio arrival past PUNKTFUNK_PAD_AUDIO_SLOTS — not streaming"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let stop_t = stop.clone();
|
||||
match std::thread::Builder::new()
|
||||
.name(format!("punktfunk1-pad{pad}"))
|
||||
.spawn(move || {
|
||||
pad_audio_thread(
|
||||
conn,
|
||||
pad,
|
||||
kinds,
|
||||
move || crate::audio::pad_sink::PadSinkCapturer::open(pad, edge),
|
||||
stop_t,
|
||||
)
|
||||
}) {
|
||||
Ok(join) => Some(PadAudioHandle {
|
||||
stop,
|
||||
join: Some(join),
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::warn!(pad, error = %e, "pad-audio thread spawn failed — pad streams without audio");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub — pad audio sources exist only behind the Windows and Linux virtual DualSense; other
|
||||
/// hosts run pads without the audio side (and never advertise the cap, see [`host_cap`]).
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
pub(super) fn spawn(
|
||||
_conn: quinn::Connection,
|
||||
_pad: u8,
|
||||
_kinds: u8,
|
||||
_edge: bool,
|
||||
_stop: Arc<AtomicBool>,
|
||||
) -> Option<PadAudioHandle> {
|
||||
None
|
||||
@@ -339,7 +404,7 @@ pub(super) fn spawn(
|
||||
|
||||
/// One enabled kind's encoder lane: admission/seq control + its stereo Opus encoder + the
|
||||
/// power-of-two warn throttle (a stuck encoder would otherwise fail ~200 times a second).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
struct Lane {
|
||||
kind: u8,
|
||||
ctl: LaneCtl,
|
||||
@@ -349,7 +414,7 @@ struct Lane {
|
||||
|
||||
/// Build one stereo encoder per enabled kind: 48 kHz LowDelay hard-CBR like the session audio
|
||||
/// plane ([`super::audio`]), at the pad plane's 64 kbps.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
fn build_lanes(kinds: u8) -> Result<Vec<Lane>, opus::Error> {
|
||||
let mut lanes = Vec::new();
|
||||
for (bit, kind, frame_ms) in [
|
||||
@@ -384,18 +449,19 @@ fn build_lanes(kinds: u8) -> Result<Vec<Lane>, opus::Error> {
|
||||
Ok(lanes)
|
||||
}
|
||||
|
||||
/// The per-pad streaming thread: loopback capture → framer → per-kind gate/encode → 0xD1
|
||||
/// datagrams. Capture death reopens with the session-audio backoff ([`INJECTOR_REOPEN_BACKOFF`],
|
||||
/// encoders + seq kept); a send error ends the thread (the connection — the session — is gone).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn pad_audio_thread(
|
||||
/// The per-pad streaming thread: capture of the pad's audio device (`open` builds the
|
||||
/// platform's capturer — Windows loopback / Linux minted sink) → framer → per-kind gate/encode
|
||||
/// → 0xD1 datagrams. Capture death reopens with the session-audio backoff
|
||||
/// ([`INJECTOR_REOPEN_BACKOFF`], encoders + seq kept); a send error ends the thread (the
|
||||
/// connection — the session — is gone).
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
fn pad_audio_thread<C: crate::audio::AudioCapturer>(
|
||||
conn: quinn::Connection,
|
||||
pad: u8,
|
||||
kinds: u8,
|
||||
endpoint_id: String,
|
||||
open: impl Fn() -> anyhow::Result<C>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
use crate::audio::AudioCapturer as _;
|
||||
let mut lanes = match build_lanes(kinds) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
@@ -413,7 +479,7 @@ fn pad_audio_thread(
|
||||
// Reopen-with-backoff (the audio.rs discipline): a capture death (endpoint invalidated,
|
||||
// audio-engine restart) reopens instead of muting the pad for the rest of the session. The
|
||||
// first open ALSO rides this loop, so an open lost to endpoint churn starts late, not never.
|
||||
let mut capturer: Option<crate::audio::pad_endpoint::PadLoopbackCapturer> = None;
|
||||
let mut capturer: Option<C> = None;
|
||||
let mut last_failed: Option<std::time::Instant> = None;
|
||||
tracing::info!(
|
||||
pad,
|
||||
@@ -427,7 +493,7 @@ fn pad_audio_thread(
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
continue;
|
||||
}
|
||||
match crate::audio::pad_endpoint::PadLoopbackCapturer::open(&endpoint_id) {
|
||||
match open() {
|
||||
Ok(c) => {
|
||||
if last_failed.take().is_some() {
|
||||
tracing::info!(pad, "pad-audio capture reopened");
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
//! Host-side Wake-on-LAN support.
|
||||
//! Host-side Wake-on-LAN / Wake-on-Wireless-LAN support.
|
||||
//!
|
||||
//! Two jobs, both best-effort (a failure here never affects streaming):
|
||||
//! 1. [`wake_macs`] — report the host's wake-capable NIC MAC(s) so a client can persist them
|
||||
//! (from the mDNS `mac` TXT record, [`crate::discovery`]) and wake this host later, once it's
|
||||
//! asleep and no longer advertising.
|
||||
//! asleep and no longer advertising. Wired and Wi-Fi NICs alike: a magic packet is the same
|
||||
//! packet either way, and an associated station in WoWLAN sleep receives the broadcast the
|
||||
//! AP buffers for it.
|
||||
//! 2. [`warn_if_not_armed`] — *detect & warn only* whether the NIC is actually armed to wake on a
|
||||
//! magic packet. We never change NIC settings (that's the user's call); we just surface the
|
||||
//! single most common reason WoL silently fails.
|
||||
//!
|
||||
//! Wired and wireless are armed through completely different interfaces, so the check follows the
|
||||
//! NIC: `ethtool <iface>` reports the wired `Wake-on: g` bit, while a Wi-Fi NIC's magic-packet
|
||||
//! trigger lives in nl80211's WoWLAN state and is read with `iw phy <phy> wowlan show`. Asking
|
||||
//! ethtool about a Wi-Fi NIC is what the previous version did, and it is actively misleading:
|
||||
//! most wireless drivers print `Wake-on: d` whether or not WoWLAN is armed, so an armed host got
|
||||
//! warned that it wasn't — with a fix command (`ethtool -s wlan0 wol g`) that its driver rejects.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
@@ -61,8 +70,8 @@ pub fn wake_macs(primary_ip: IpAddr) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// Log whether the host NIC bearing `primary_ip` is armed to wake on a magic packet. Detect &
|
||||
/// warn only — never modifies settings. Linux-only (reads `ethtool <iface>`); a no-op elsewhere
|
||||
/// and silent when it can't tell (no `ethtool`, insufficient privilege).
|
||||
/// warn only — never modifies settings. Linux-only (shells out to `iw`/`ethtool`); a no-op
|
||||
/// elsewhere and silent when it can't tell (tool missing, insufficient privilege).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn warn_if_not_armed(primary_ip: IpAddr) {
|
||||
let ifaces = if_addrs::get_if_addrs().unwrap_or_default();
|
||||
@@ -73,6 +82,41 @@ pub fn warn_if_not_armed(primary_ip: IpAddr) {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// A NIC with an nl80211 phy is wireless: ask nl80211 about WoWLAN, not ethtool about WoL.
|
||||
if let Some(phy) = wireless_phy(&iface) {
|
||||
match wowlan_has_magic(phy.as_deref(), &iface) {
|
||||
Some(true) => tracing::info!(
|
||||
iface = %iface,
|
||||
phy = phy.as_deref().unwrap_or("?"),
|
||||
"Wake-on-WLAN armed (magic packet) on host Wi-Fi NIC"
|
||||
),
|
||||
Some(false) => {
|
||||
let phy = phy.as_deref().unwrap_or("phy0");
|
||||
// A device the kernel won't arm can't wake on anything, so name that separately
|
||||
// — enabling a WoWLAN trigger alone would not fix it.
|
||||
let extra = if device_wakeup_enabled(&iface) == Some(false) {
|
||||
" The kernel also has wake-up switched off for this device \
|
||||
(/sys/class/net/<iface>/device/power/wakeup reads `disabled`), which blocks \
|
||||
a network wake by itself."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
tracing::warn!(
|
||||
iface = %iface,
|
||||
"Wake-on-WLAN is NOT armed on this host's Wi-Fi NIC — clients cannot wake it \
|
||||
from sleep. Enable it with: sudo iw phy {phy} wowlan enable magic-packet \
|
||||
(NetworkManager resets that on every re-connect; make it stick with: sudo \
|
||||
nmcli connection modify <connection> 802-11-wireless.wake-on-wlan magic). \
|
||||
The adapter must also stay powered and associated while the host sleeps, and \
|
||||
be allowed to wake the machine in BIOS/UEFI.{extra}",
|
||||
)
|
||||
}
|
||||
None => {} // couldn't determine — stay quiet rather than cry wolf
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match ethtool_wol_has_magic(&iface) {
|
||||
Some(true) => {
|
||||
tracing::info!(iface = %iface, "Wake-on-LAN armed (magic packet) on host NIC")
|
||||
@@ -81,7 +125,7 @@ pub fn warn_if_not_armed(primary_ip: IpAddr) {
|
||||
iface = %iface,
|
||||
"Wake-on-LAN is NOT armed on this host's NIC — clients cannot wake it from sleep. \
|
||||
Enable it with: sudo ethtool -s {iface} wol g (and turn on 'Wake on LAN'/'Wake on \
|
||||
PCIe' in BIOS). Wired Ethernet is required; Wi-Fi wake is unreliable.",
|
||||
PCIe' in BIOS).",
|
||||
),
|
||||
None => {} // couldn't determine — stay quiet rather than cry wolf
|
||||
}
|
||||
@@ -90,6 +134,80 @@ pub fn warn_if_not_armed(primary_ip: IpAddr) {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn warn_if_not_armed(_primary_ip: IpAddr) {}
|
||||
|
||||
/// Is `iface` a Wi-Fi NIC, and if so which nl80211 phy backs it? `Some(Some("phy0"))` = wireless
|
||||
/// and we know the phy (so we can query and name it); `Some(None)` = wireless but the phy name
|
||||
/// couldn't be read; `None` = wired (or sysfs is unavailable, which reads the same way — the
|
||||
/// ethtool path then applies, exactly as before).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn wireless_phy(iface: &str) -> Option<Option<String>> {
|
||||
let dir = format!("/sys/class/net/{iface}/phy80211");
|
||||
if !std::path::Path::new(&dir).exists() {
|
||||
return None;
|
||||
}
|
||||
let name = std::fs::read_to_string(format!("{dir}/name"))
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
Some(name)
|
||||
}
|
||||
|
||||
/// Whether a Wi-Fi NIC is armed for a magic-packet wake. `iw` is authoritative — it reads the
|
||||
/// live nl80211 WoWLAN state, which is where the trigger actually lives.
|
||||
///
|
||||
/// Two fallbacks for when `iw` can't answer (binary missing, driver without the WoWLAN command,
|
||||
/// no phy name, or a kernel that wants privilege we don't have — the host runs as a plain user
|
||||
/// service, so that last one is not hypothetical):
|
||||
/// * a *positive* ethtool reading counts, a negative one never does — a handful of drivers
|
||||
/// (brcmfmac and friends, i.e. most Raspberry Pi / SoC Wi-Fi) really do expose the
|
||||
/// magic-packet bit through ethtool, while the far more common `Wake-on: d` from a wireless
|
||||
/// driver means nothing at all;
|
||||
/// * failing that, sysfs `device/power/wakeup` — world-readable, and a `disabled` there is
|
||||
/// conclusive in the negative direction: the kernel will not arm this device to wake the
|
||||
/// machine, so whatever WoWLAN triggers the firmware holds can never fire.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn wowlan_has_magic(phy: Option<&str>, iface: &str) -> Option<bool> {
|
||||
if let Some(v) = phy.and_then(iw_wowlan_has_magic) {
|
||||
return Some(v);
|
||||
}
|
||||
if let Some(true) = ethtool_wol_has_magic(iface) {
|
||||
return Some(true);
|
||||
}
|
||||
// Only the negative is meaningful: `enabled` says the device may wake the machine, not that a
|
||||
// magic packet is one of the things that will do it.
|
||||
match device_wakeup_enabled(iface) {
|
||||
Some(false) => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// sysfs `/sys/class/net/<iface>/device/power/wakeup` — `enabled`/`disabled`, i.e. whether the
|
||||
/// kernel will arm this device to wake the system at all. `None` when the attribute isn't there
|
||||
/// (platform/SDIO devices often have none) or can't be read.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn device_wakeup_enabled(iface: &str) -> Option<bool> {
|
||||
let text =
|
||||
std::fs::read_to_string(format!("/sys/class/net/{iface}/device/power/wakeup")).ok()?;
|
||||
match text.trim() {
|
||||
"enabled" => Some(true),
|
||||
"disabled" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask nl80211 (via `iw phy <phy> wowlan show`) whether the magic-packet trigger is enabled.
|
||||
/// `None` if `iw` is missing or the driver doesn't implement WoWLAN.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn iw_wowlan_has_magic(phy: &str) -> Option<bool> {
|
||||
let out = std::process::Command::new("iw")
|
||||
.args(["phy", phy, "wowlan", "show"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_iw_wowlan(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// Parse `ethtool <iface>` for the *current* Wake-on setting and report whether it includes `g`
|
||||
/// (wake on MagicPacket). Returns `None` if ethtool is missing/failed or the field is absent.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -101,7 +219,13 @@ fn ethtool_wol_has_magic(iface: &str) -> Option<bool> {
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
parse_ethtool_wol(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// `ethtool <iface>` output → does the *current* Wake-on setting include `g` (MagicPacket)?
|
||||
/// `None` when the field is absent. Split out from the command so it can be unit-tested on any
|
||||
/// platform.
|
||||
fn parse_ethtool_wol(text: &str) -> Option<bool> {
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
// The current setting is "Wake-on: <flags>"; skip the "Supports Wake-on: ..." capability
|
||||
@@ -112,3 +236,88 @@ fn ethtool_wol_has_magic(iface: &str) -> Option<bool> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `iw phy <phy> wowlan show` output → is the magic-packet trigger enabled? The two shapes are
|
||||
///
|
||||
/// ```text
|
||||
/// WoWLAN is disabled
|
||||
/// ```
|
||||
/// ```text
|
||||
/// WoWLAN is enabled:
|
||||
/// * wake up on magic packet
|
||||
/// * wake up on pattern match, up to 20 patterns of 16 - 128 bytes
|
||||
/// ```
|
||||
///
|
||||
/// `* wake up on anything` (the nl80211 `any` trigger) counts too — that NIC wakes on every frame
|
||||
/// it receives, magic packets included. Enabled with only other triggers reads as NOT armed,
|
||||
/// which is the honest answer: a magic packet won't wake it. `None` when the output says nothing
|
||||
/// about WoWLAN at all. Split out from the command so it can be unit-tested on any platform.
|
||||
fn parse_iw_wowlan(text: &str) -> Option<bool> {
|
||||
let mut seen = false;
|
||||
let mut magic = false;
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if let Some(state) = t.strip_prefix("WoWLAN is ") {
|
||||
seen = true;
|
||||
if state
|
||||
.trim()
|
||||
.trim_end_matches(':')
|
||||
.eq_ignore_ascii_case("disabled")
|
||||
{
|
||||
return Some(false);
|
||||
}
|
||||
} else if seen && t.starts_with('*') {
|
||||
let l = t.to_ascii_lowercase();
|
||||
if l.contains("magic packet") || l.contains("anything") {
|
||||
magic = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
seen.then_some(magic)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_ethtool_wol, parse_iw_wowlan};
|
||||
|
||||
#[test]
|
||||
fn ethtool_current_setting_not_capability_line() {
|
||||
let armed =
|
||||
"Settings for enp5s0:\n\tSupports Wake-on: pumbg\n\tWake-on: g\n\tLink detected: yes\n";
|
||||
assert_eq!(parse_ethtool_wol(armed), Some(true));
|
||||
// "Supports Wake-on: ...g..." must NOT be read as the current setting.
|
||||
let off = "Settings for enp5s0:\n\tSupports Wake-on: pumbg\n\tWake-on: d\n";
|
||||
assert_eq!(parse_ethtool_wol(off), Some(false));
|
||||
assert_eq!(
|
||||
parse_ethtool_wol("Settings for lo:\n\tLink detected: yes\n"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iw_wowlan_states() {
|
||||
assert_eq!(parse_iw_wowlan("WoWLAN is disabled\n"), Some(false));
|
||||
assert_eq!(
|
||||
parse_iw_wowlan("WoWLAN is enabled:\n * wake up on magic packet\n"),
|
||||
Some(true)
|
||||
);
|
||||
// Enabled, but not for magic packets — a magic packet will not wake this NIC.
|
||||
assert_eq!(
|
||||
parse_iw_wowlan(
|
||||
"WoWLAN is enabled:\n * wake up on pattern match, up to 20 patterns of 16 - 128 bytes\n"
|
||||
),
|
||||
Some(false)
|
||||
);
|
||||
// The `any` trigger wakes on every received frame, magic packets included.
|
||||
assert_eq!(
|
||||
parse_iw_wowlan("WoWLAN is enabled:\n * wake up on anything (device continues operating normally)\n"),
|
||||
Some(true)
|
||||
);
|
||||
// Nothing to go on — the driver has no WoWLAN command.
|
||||
assert_eq!(parse_iw_wowlan(""), None);
|
||||
assert_eq!(
|
||||
parse_iw_wowlan("Wiphy phy0\n\tmax # scan SSIDs: 20\n"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,8 +144,9 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` · `steamcontroller2` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, `sc2`, `ibex`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. `steamcontroller2` (the 2026 Steam Controller) is passed through **as-is** — the host presents a real SC2 (`28DE:1302`) that Steam Input drives directly, mirroring the physical pad's raw reports (Linux only). DualSense (Edge)/DualShock 4 work on Linux (UHID) and Windows (UMDF); the Steam Deck pad too (Windows via the promoted UMDF identity); Switch Pro and the classic Steam Controller need Linux UHID. Unsupported choices fold to Xbox 360. |
|
||||
| `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
|
||||
| `PUNKTFUNK_PAD_AUDIO` | `1` · `0` *(default on)* | **(Windows)** Controller audio: what a game plays through the DualSense's built-in speaker and voice-coil haptics is streamed to the client's physical pad as its own low-latency plane. On by default and free while idle — silence is never encoded or sent; `0` turns it off host-wide. |
|
||||
| `PUNKTFUNK_PAD_AUDIO_SLOTS` | `1`–`4` *(default `1`)* | **(Windows)** How many controllers can have their own audio at once. Each slot is a pre-provisioned virtual endpoint, so the default stays at one; raise it for multi-pad sessions. |
|
||||
| `PUNKTFUNK_PAD_AUDIO` | `1` · `0` *(default on)* | Controller audio: what a game plays through the DualSense's built-in speaker and voice-coil haptics is streamed to the client's physical pad as its own low-latency plane. On by default and free while idle — silence is never encoded or sent; `0` turns it off host-wide. On Windows the pad's audio device is a pre-provisioned virtual endpoint; on Linux it is a per-pad PipeWire sink minted with the DualSense identity games match on. |
|
||||
| `PUNKTFUNK_PAD_AUDIO_SLOTS` | `1`–`4` *(default: Windows `1`, Linux `4`)* | How many controllers can have their own audio at once. On Windows each slot is a pre-provisioned virtual endpoint, so the default stays at one; a Linux sink is minted lazily and costs nothing idle, so every slot is on. |
|
||||
| `PUNKTFUNK_PAD_SINK_NAME` / `PUNKTFUNK_PAD_SINK_DESC` | templates | **(Linux, field debugging)** Override the minted pad sink's `node.name` / `node.description`. `{pad}` and `{mac}` expand per pad. Only for chasing a title whose device matcher wants different strings — the defaults carry every known match surface. |
|
||||
|
||||
## Audio / microphone
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
title: Debian
|
||||
description: Install the Punktfunk host on Debian 13 with apt — including LMDE and Linux Mint.
|
||||
---
|
||||
|
||||
Install a Punktfunk host on **Debian 13 ("trixie") or newer** from the apt registry. This page
|
||||
covers the distro-level setup — GPU driver, package, gamepad access. How the host creates its
|
||||
virtual display and injects input is desktop-specific, so pick your desktop on the
|
||||
[configure pages](#configure-your-desktop) afterward rather than here.
|
||||
|
||||
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
|
||||
> the machine, so keep it on a trusted LAN or VPN and require pairing.
|
||||
|
||||
> **Which releases.** The host package needs **glibc 2.39 or newer**; Debian 13 has 2.41, so it
|
||||
> installs and runs there. **Debian 12 (bookworm) has glibc 2.36 and cannot install it** — build
|
||||
> from source ([Ubuntu appendix](/docs/ubuntu#appendix--build-from-source), which applies here too)
|
||||
> or upgrade. Check yours with `ldd --version`.
|
||||
|
||||
> **The desktop client is not packaged for Debian yet.** `punktfunk-client` is built on Ubuntu 26.04
|
||||
> and floors at `libc6 >= 2.43` (Debian 13 has 2.41), on top of needing GTK4 ≥ 4.20. On a Debian
|
||||
> box, stream *to* it with a [different client](/docs/install-client) — the Flatpak, or a build from
|
||||
> source. The **host**, the **web console** and the **plugin runner** all install normally.
|
||||
|
||||
## What works on Debian 13
|
||||
|
||||
| Package | Debian 13 | What it is |
|
||||
|---|---|---|
|
||||
| `punktfunk-host` | ✅ | The streaming host |
|
||||
| `punktfunk-web` | ✅ | The browser management console |
|
||||
| `punktfunk-scripting` | ✅ | The plugin/script runner |
|
||||
| `punktfunk-gamescope` | ✅ | The patched gamescope (HDR + cursor + real refresh) |
|
||||
| `punktfunk-client` | ❌ | Desktop client — `libc6 >= 2.43`, see above |
|
||||
|
||||
## 1. GPU driver
|
||||
|
||||
On **NVIDIA**, the driver lives in Debian's `contrib` / `non-free` / `non-free-firmware`
|
||||
components, which a default install does not enable. Debian 13 keeps its sources in the deb822
|
||||
format, so add them there and refresh:
|
||||
|
||||
```sh
|
||||
sudo sed -i 's/^Components: .*/Components: main contrib non-free non-free-firmware/' \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
sudo apt update
|
||||
sudo apt install nvidia-driver firmware-misc-nonfree
|
||||
```
|
||||
|
||||
Debian 13 ships driver 550, comfortably above the [535 floor](/docs/requirements).
|
||||
|
||||
Reboot, then confirm the driver and KMS modeset — Wayland on NVIDIA needs `modeset=1`:
|
||||
|
||||
```sh
|
||||
nvidia-smi
|
||||
cat /sys/module/nvidia_drm/parameters/modeset # should print Y
|
||||
```
|
||||
|
||||
If modeset is not `Y`:
|
||||
|
||||
```sh
|
||||
echo 'options nvidia-drm modeset=1' | sudo tee /etc/modprobe.d/nvidia-drm.conf
|
||||
sudo update-initramfs -u && sudo reboot
|
||||
```
|
||||
|
||||
> **Secure Boot:** with Secure Boot enabled, Debian's DKMS-built NVIDIA module must be signed and
|
||||
> its key enrolled before it will load. If `nvidia-smi` can't talk to the driver, enrol the MOK
|
||||
> (`sudo mokutil --import /var/lib/dkms/mok.pub`, reboot, choose **Enrol MOK**) or disable Secure
|
||||
> Boot in firmware.
|
||||
|
||||
On **AMD/Intel** none of the NVIDIA steps apply. Encode runs on the Mesa stack: **Vulkan Video** for
|
||||
HEVC and AV1 (`mesa-vulkan-drivers`), with **VAAPI** for H.264 and as the fallback —
|
||||
`mesa-va-drivers` on AMD, `intel-media-va-driver` on Intel (the latter is in `non-free`).
|
||||
|
||||
## 2. Install the host (apt)
|
||||
|
||||
The registry is public — no auth needed, just trust its signing key:
|
||||
|
||||
```sh
|
||||
sudo install -d -m 0755 /etc/apt/keyrings
|
||||
curl -fsSL https://git.unom.io/api/packages/unom/debian/repository.key \
|
||||
| sudo tee /etc/apt/keyrings/punktfunk.asc >/dev/null
|
||||
|
||||
echo "deb [signed-by=/etc/apt/keyrings/punktfunk.asc] https://git.unom.io/api/packages/unom/debian stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/punktfunk.list
|
||||
|
||||
sudo apt update
|
||||
sudo apt install punktfunk-host
|
||||
```
|
||||
|
||||
`punktfunk-host` `Recommends` the browser console (`punktfunk-web`), so apt pulls it in by default.
|
||||
The NVIDIA driver is **not** a dependency — you installed it out of band in step 1. Later updates
|
||||
are `sudo apt update && sudo apt upgrade`; restart the running host afterwards so it picks up the
|
||||
new binary:
|
||||
|
||||
```sh
|
||||
systemctl --user restart punktfunk-host
|
||||
```
|
||||
|
||||
The `stable` component above is the stable channel. To track pre-release builds instead, see
|
||||
[Release Channels](/docs/channels).
|
||||
|
||||
## 3. Grant gamepad access
|
||||
|
||||
Virtual gamepads inject through `/dev/uinput`, gated by the `input` group. Add yourself and re-login:
|
||||
|
||||
```sh
|
||||
sudo usermod -aG input "$USER" # re-login to apply
|
||||
```
|
||||
|
||||
Also join `punktfunk` if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro) —
|
||||
it reaches games as a real USB device over usbip, which is what makes Steam Input adopt it. Join it
|
||||
only on a machine you trust: writing the usbip `attach` file can materialise arbitrary emulated USB
|
||||
hardware.
|
||||
|
||||
```sh
|
||||
sudo usermod -aG punktfunk "$USER" # re-login to apply
|
||||
```
|
||||
|
||||
## 4. Check it installed
|
||||
|
||||
```sh
|
||||
punktfunk-host --version # the binary is on PATH
|
||||
punktfunk-host detect-conflicts # exits 1 if Sunshine/Apollo is also installed
|
||||
```
|
||||
|
||||
Two hosts on one machine is the most common reason a clean install never streams — see
|
||||
[Troubleshooting](/docs/troubleshooting#another-streaming-host-sunshine-apollo--is-installed).
|
||||
|
||||
## 5. Open the firewall (if you have one)
|
||||
|
||||
**Debian ships no firewall enabled by default**, so out of the box there is nothing to open. If you
|
||||
run one, the package installs the openers:
|
||||
|
||||
```sh
|
||||
# ufw:
|
||||
sudo ufw allow punktfunk-native
|
||||
|
||||
# firewalld:
|
||||
sudo firewall-cmd --reload # load the installed definitions
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-native
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
Add `punktfunk-gamestream` for Moonlight compat and `punktfunk-web` (TCP 47992) to reach the console
|
||||
from another device. Full port lists are in
|
||||
[`packaging/debian/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md#firewall).
|
||||
|
||||
## Cinnamon, Linux Mint and LMDE
|
||||
|
||||
**A Cinnamon desktop cannot host a virtual display, and no setting changes that.** Punktfunk gives
|
||||
each client its own screen at that device's exact resolution by asking the compositor to create a
|
||||
virtual output. Cinnamon's compositor, **Muffin**, has no such API: it forked from Mutter 3.36, and
|
||||
its `org.cinnamon.Muffin.ScreenCast` interface offers only `RecordMonitor` and `RecordWindow` —
|
||||
never the `RecordVirtual` that Mutter gained in 42. Its portal backend
|
||||
(`xdg-desktop-portal-xapp`) implements no ScreenCast either, so the route that serves Sway and
|
||||
Hyprland is closed too. This is upstream's to fix, not a Punktfunk setting.
|
||||
|
||||
**Which Mint you run decides whether there is any route at all:**
|
||||
|
||||
| Edition | Base | Can it host? |
|
||||
|---|---|---|
|
||||
| **LMDE 7 "Gigi"** | Debian 13 | ✅ Yes — via gamescope (below) |
|
||||
| **Linux Mint 22.x** ("Wilma"…"Zena") | Ubuntu 24.04 | ❌ No — see [below](#linux-mint-22x-cannot-host-yet) |
|
||||
| **Linux Mint 23** | Ubuntu 26.04 | ✅ Expected — due December 2026 |
|
||||
|
||||
On **LMDE 7**, what works is **gamescope**: the host starts its own headless gamescope for each
|
||||
connecting client and runs the game inside it, so it needs no desktop compositor at all. Your
|
||||
Cinnamon session keeps running untouched; the stream is the game, not the desktop.
|
||||
|
||||
```sh
|
||||
sudo apt install punktfunk-gamescope # LMDE 7 / Debian 13 — not available on Mint 22.x
|
||||
echo 'PUNKTFUNK_COMPOSITOR=gamescope' >> ~/.config/punktfunk/host.env
|
||||
systemctl --user restart punktfunk-host
|
||||
```
|
||||
|
||||
The pin is required: auto-detection reads the live session, finds Cinnamon, and stops with an error
|
||||
rather than guessing. Set a game to launch with
|
||||
[`PUNKTFUNK_GAMESCOPE_APP`](/docs/gamescope) or per-session launch commands, then see
|
||||
[Steam / gamescope](/docs/gamescope) for the rest.
|
||||
|
||||
> **Install `punktfunk-gamescope`, not Debian's.** Debian ships **no** `gamescope` package at all,
|
||||
> and the patched build is what gives the stream HDR, a visible cursor, and the client's real
|
||||
> refresh rate instead of a hardcoded 60 Hz.
|
||||
|
||||
If you want to stream the **desktop** from an LMDE box, the answer today is to log into a GNOME or
|
||||
Sway session instead — Debian 13 ships GNOME 48.7 and sway 1.10, both above the
|
||||
[floors](/docs/requirements). (Debian 13's KDE is KWin **6.3.6**, below the 6.5.6 floor, so Plasma
|
||||
is not an option there yet.)
|
||||
|
||||
### Linux Mint 22.x cannot host yet
|
||||
|
||||
**On Linux Mint 22.x — the current mainstream release, and every version until Mint 23 in December
|
||||
2026 — there is no working configuration.** `punktfunk-host` will install, which makes this easy to
|
||||
miss, but nothing on the box can produce a stream:
|
||||
|
||||
- **Cinnamon** cannot host a virtual display (above).
|
||||
- **gamescope is not available and cannot be made available.** Ubuntu 24.04 packages no gamescope,
|
||||
and the patched `punktfunk-gamescope` cannot run there either: 24.04 is short of what the build
|
||||
needs on *five* libraries — wayland 1.22.0 (needs ≥ 1.23.1), libinput 1.25 (≥ 1.26), libavif
|
||||
1.0.4 (≥ 1.2.1), pixman 0.42 (≥ 0.44), and no `libdisplay-info2` or `libxcb-errors0` at all.
|
||||
- **Switching desktop does not rescue it.** Ubuntu 24.04 ships KWin **5.27** (floor 6.5.6) and GNOME
|
||||
Shell **46** (floor 48). Only `sway` 1.9 is even a candidate, and that means giving up Cinnamon.
|
||||
|
||||
If you want to run a host on Mint hardware today, use **LMDE 7** — it is the same desktop on a
|
||||
Debian 13 base, where gamescope works. Otherwise wait for **Mint 23** (Ubuntu 26.04 base), where
|
||||
both the patched gamescope and the newer compositors are available.
|
||||
|
||||
## Configure your desktop
|
||||
|
||||
How the host creates its virtual display and injects input depends on your desktop, not your distro:
|
||||
|
||||
- [KDE Plasma (KWin)](/docs/kde)
|
||||
- [GNOME (Mutter)](/docs/gnome)
|
||||
- [Steam / gamescope](/docs/gamescope)
|
||||
- [Hyprland](/docs/hyprland)
|
||||
- [Sway / wlroots](/docs/sway)
|
||||
|
||||
Then bring up [The Web Console](/docs/web-console) to arm pairing and connect your first
|
||||
[client](/docs/clients). To run the host at boot — including fully **headless** — see
|
||||
[Running as a Service](/docs/running-as-a-service).
|
||||
|
||||
## Next steps
|
||||
|
||||
- **Keep it current** — [Updating the Host](/docs/updating).
|
||||
- **Remove it again** — [Uninstalling](/docs/uninstall).
|
||||
- **Something not working?** — [Troubleshooting](/docs/troubleshooting).
|
||||
- **Build from source** (Debian 12, or tracking `main`) — the
|
||||
[Ubuntu appendix](/docs/ubuntu#appendix--build-from-source) applies unchanged; Debian 13's
|
||||
`libavcodec-dev` is new enough to build against.
|
||||
@@ -189,7 +189,11 @@ own name and does **not** replace your system gamescope — your Gaming Mode kee
|
||||
- **Bazzite / Fedora Atomic** — included in the Punktfunk sysext; `punktfunk-sysext update` gets it.
|
||||
- **Fedora, Nobara and other RPM boxes** — `sudo dnf install punktfunk-gamescope` from the same
|
||||
Punktfunk repo the host comes from.
|
||||
- **Debian / Ubuntu** — `sudo apt install punktfunk-gamescope` from the Punktfunk apt repo.
|
||||
- **Debian 13 and Ubuntu 26.04** — `sudo apt install punktfunk-gamescope` from the Punktfunk apt
|
||||
repo. It is the only gamescope those two have: Debian packages none at all, and Ubuntu 26.04's
|
||||
is 3.16.20 — below the [3.16.22 floor](/docs/requirements), so headless capture deadlocks
|
||||
against the PipeWire 1.6 that same release ships. **Ubuntu 24.04 cannot run it**: the build needs
|
||||
wayland ≥ 1.23.1 and noble has 1.22.0, so build from source or move to a newer release.
|
||||
- **Arch** — the `punktfunk-gamescope` package.
|
||||
- **SteamOS (Steam Deck installer)** — built and wired automatically by
|
||||
`scripts/steamdeck/install.sh` / `update.sh`.
|
||||
|
||||
@@ -36,7 +36,7 @@ stream and links out to the detail as you need it. The rest of these are for whe
|
||||
<Card title="Quick Start" href="/docs/quickstart" description="From nothing to streaming: set up a host and connect your first client." />
|
||||
<Card title="How It Works" href="/docs/how-it-works" description="The ideas behind Punktfunk in a few minutes — virtual displays, the two protocols, pairing." />
|
||||
<Card title="Support Matrix" href="/docs/support-matrix" description="What works where — every host desktop, GPU and client app, each cell read out of the code that decides it." />
|
||||
<Card title="Install the Host" href="/docs/install" description="Add the repo and install the package — Ubuntu, Fedora, Arch, Bazzite, SteamOS, NixOS, or Windows." />
|
||||
<Card title="Install the Host" href="/docs/install" description="Add the repo and install the package — Ubuntu, Debian, Fedora, Arch, Bazzite, SteamOS, NixOS, or Windows." />
|
||||
<Card title="Connect a Client" href="/docs/clients" description="Stream with the native app for your device — macOS, Linux, Windows, Android — or any Moonlight client." />
|
||||
<Card title="Your Game Library" href="/docs/game-library" description="The host finds your installed games by itself — browse a paired host and launch a title straight into the stream." />
|
||||
<Card title="API Reference" href="/api" description="Interactive OpenAPI reference for the host's management REST API — status, devices, pairing, library." />
|
||||
@@ -46,7 +46,7 @@ stream and links out to the detail as you need it. The rest of these are for whe
|
||||
|
||||
- A **host** with an NVIDIA, AMD, or Intel GPU (there's a software encoder to fall back on without
|
||||
one) — either a **Linux** machine running one of the [supported setups](/docs/requirements)
|
||||
(**Ubuntu**, **Fedora**, **Arch**, **Bazzite**, or **SteamOS**), or a
|
||||
(**Ubuntu**, **Debian**, **Fedora**, **Arch**, **Bazzite**, or **SteamOS**), or a
|
||||
**[Windows](/docs/windows-host) PC**. What each combination can actually do — codecs, HDR, 4:4:4,
|
||||
cursor, input — is in the [Support matrix](/docs/support-matrix).
|
||||
- A **client device** to stream to — there are native apps for **macOS, iOS/iPadOS, tvOS, Linux,
|
||||
|
||||
@@ -16,13 +16,20 @@ On **Windows**, the host ships as a signed installer instead — see [Windows](#
|
||||
|
||||
| Distro | Package manager | One-command happy path | Guide |
|
||||
|--------|-----------------|------------------------|-------|
|
||||
| **Ubuntu** | apt | `sudo apt install punktfunk-host` | [Ubuntu](/docs/ubuntu) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
|
||||
| **Ubuntu 26.04+** ¹ | apt | `sudo apt install punktfunk-host` | [Ubuntu](/docs/ubuntu) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
|
||||
| **Debian 13+** (incl. LMDE) | apt | `sudo apt install punktfunk-host` | [Debian](/docs/debian) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
|
||||
| **Bazzite / Fedora Atomic** | systemd-sysext | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` (no layering, no reboot) | [Bazzite](/docs/bazzite) · [packaging/bazzite](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/bazzite/README.md) |
|
||||
| **Fedora (dnf)** | dnf / rpm-ostree | `sudo dnf install punktfunk` | [Fedora](/docs/fedora) · [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) |
|
||||
| **Arch** | pacman | `sudo pacman -Syu punktfunk-host` (binary repo — always a full `-Syu`, never `-Sy`) | [Arch Linux](/docs/arch) · [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) |
|
||||
| **SteamOS (host)** | on-device script | clone the repo, then `bash ~/punktfunk/scripts/steamdeck/install.sh` (builds on-device) | [SteamOS (Host)](/docs/steamos-host) |
|
||||
| **NixOS / Nix** | nix flake | `nix run git+https://git.unom.io/unom/punktfunk#punktfunk-host -- serve --gamestream` | [NixOS](#nixos) · [packaging/nix](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/nix/README.md) |
|
||||
|
||||
> ¹ **Ubuntu 24.04 LTS installs the package but cannot host.** It ships no compositor that meets
|
||||
> the [version floors](/docs/requirements) — KWin 5.27 against 6.5.6, GNOME Shell 46 against 48 —
|
||||
> and no `gamescope`. Use 26.04 or newer. This is also why
|
||||
> [Linux Mint 22.x cannot host](/docs/debian#linux-mint-22x-cannot-host-yet); LMDE 7 (Debian 13)
|
||||
> can.
|
||||
|
||||
Each registry is public — no auth, you just trust the repo's signing key. Adding the repo is a
|
||||
one-time step covered in the linked guide; after that, normal `apt upgrade` / `dnf upgrade` /
|
||||
`pacman -Syu` (or `sudo punktfunk-sysext update` on Bazzite) tracks new builds. On **NixOS** there
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"requirements",
|
||||
"install",
|
||||
"ubuntu",
|
||||
"debian",
|
||||
"fedora",
|
||||
"arch",
|
||||
"bazzite",
|
||||
|
||||
@@ -13,9 +13,34 @@ is also available. Setup splits along two axes: you **install** the package per
|
||||
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
|
||||
> the machine, so keep it on a trusted LAN or VPN and require pairing.
|
||||
|
||||
## The floor for a working host
|
||||
|
||||
**On apt distros that means Ubuntu 26.04 or newer, or Debian 13 or newer.** Both are supported
|
||||
targets and both install from the same repository.
|
||||
|
||||
This floor is about the **desktop**, not the package. A host needs a compositor that can create a
|
||||
virtual display, and those have version floors of their own ([below](#desktop-session)). Older
|
||||
releases will happily install `punktfunk-host` and then have nothing that can produce a stream —
|
||||
so read this as the real requirement, not the package's:
|
||||
|
||||
| Release | Package installs | Can actually host |
|
||||
|---|---|---|
|
||||
| **Ubuntu 26.04+** | ✅ | ✅ KWin 6.5+, GNOME 48+, gamescope |
|
||||
| **Debian 13+** | ✅ | ✅ GNOME 48.7, sway 1.10, gamescope (its KWin 6.3.6 is below the floor) |
|
||||
| Ubuntu 24.04 LTS | ✅ | ❌ KWin 5.27 (floor 6.5.6), GNOME 46 (floor 48), no gamescope available |
|
||||
| Debian 12 | ❌ glibc 2.36 | ❌ |
|
||||
|
||||
Ubuntu 24.04 is called out because the package *does* install there — it is built on 24.04 precisely
|
||||
so one package spans the range — which makes the gap easy to mistake for a bug. It is not: 24.04
|
||||
ships no compositor new enough, and no `gamescope` (the patched
|
||||
[`punktfunk-gamescope`](/docs/gamescope) cannot run there either — 24.04 is too old on wayland,
|
||||
libinput, libavif and pixman). The same gap is why
|
||||
[Linux Mint 22.x cannot host](/docs/debian#linux-mint-22x-cannot-host-yet).
|
||||
|
||||
**Distros — install the package:**
|
||||
|
||||
- [Ubuntu](/docs/ubuntu)
|
||||
- [Ubuntu](/docs/ubuntu) — 26.04 or newer for a working host
|
||||
- [Debian](/docs/debian) — 13 or newer, including LMDE
|
||||
- [Fedora](/docs/fedora)
|
||||
- [Arch](/docs/arch)
|
||||
- [Bazzite](/docs/bazzite)
|
||||
|
||||
@@ -97,6 +97,7 @@ head-tracked remote spatial audio that no streaming stack does today.
|
||||
simply has no 4:4:4 path yet, and it waits on hardware that advertises a HEVC 4:4:4 encode
|
||||
entrypoint to build and validate against. On either vendor, [PyroWave](/docs/pyrowave) already
|
||||
carries full chroma today.
|
||||
- **DualSense voice-coil haptics.** Scoped and shelved — it rides the controller's USB audio
|
||||
interface and has near-zero game support on Linux. Rumble, adaptive triggers and the lightbar
|
||||
already work.
|
||||
- **DualSense voice-coil haptics over Bluetooth client pads.** The controller exposes no audio
|
||||
interface over Bluetooth, so the audio-haptics plane is USB-only on the client side — a BT
|
||||
DualSense keeps classic rumble. (Hosts stream pad audio on both Windows and Linux; rumble,
|
||||
adaptive triggers and the lightbar work everywhere regardless.)
|
||||
|
||||
@@ -71,6 +71,7 @@ mechanics. The one exception is a gamescope the host only *attaches* to, which k
|
||||
| gamescope (SteamOS · Bazzite) | ✅ ⁴ | ⚠️ ⁵ | ✅ ⁶ |
|
||||
| sway ⁹ | ✅ | ✅ | ⚠️ ⁷ |
|
||||
| Hyprland | ⚠️ ⁸ | ✅ | ⚠️ ⁷ |
|
||||
| Cinnamon (Mint · LMDE) | ❌ ¹⁰ | ❌ ¹⁰ | ❌ ¹⁰ |
|
||||
| macOS / anything else | ❌ | ❌ | ❌ |
|
||||
|
||||
1. Punktfunk's own IddCx display driver. It requires **Windows 11 22H2 (build 22621) or newer** —
|
||||
@@ -108,6 +109,12 @@ mechanics. The one exception is a gamescope the host only *attaches* to, which k
|
||||
(River, dwl, …) cannot host — the session fails at `swaymsg get_outputs`. Their input would work
|
||||
(they do have the wlroots virtual pointer and keyboard protocols), but with no video there is no
|
||||
stream. See [Sway / wlroots](/docs/sway).
|
||||
10. Cinnamon's compositor **Muffin** exposes no virtual-output API and no monitor-capture route we
|
||||
can reach: it forked from Mutter 3.36, so `org.cinnamon.Muffin.ScreenCast` has only
|
||||
`RecordMonitor` / `RecordWindow` and never Mutter 42's `RecordVirtual`, and its portal backend
|
||||
(`xdg-desktop-portal-xapp`) implements no ScreenCast at all. Nothing in Punktfunk can change
|
||||
this. A Mint or LMDE box can still stream **games** through a headless gamescope, which needs no
|
||||
desktop compositor — see [Debian → Cinnamon](/docs/debian#cinnamon-linux-mint-and-lmde).
|
||||
|
||||
### Input, cursor and HDR
|
||||
|
||||
|
||||
@@ -104,10 +104,11 @@ and capture/display glitches.
|
||||
Clients wake a saved host by themselves — auto-wake is on by default — but only once they have seen
|
||||
it awake, which is how they learn its MAC address, and only if the machine is armed to answer a magic
|
||||
packet. The arming is what's usually missing, and a **Linux** host tells you outright: search the web
|
||||
console's **Logs** page for `Wake-on-LAN`, and the line either confirms the card is armed or names
|
||||
the interface and the exact command to arm it. Windows and macOS hosts don't run that check, so go
|
||||
straight to the BIOS/UEFI and network-card steps in
|
||||
[Arming the machine](/docs/wake-on-lan#arming-the-machine).
|
||||
console's **Logs** page for `Wake-on-` — `Wake-on-LAN` for a wired card, `Wake-on-WLAN` for a Wi-Fi
|
||||
one — and the line either confirms the card is armed or names the interface and the exact command to
|
||||
arm it. A Wi-Fi card is armed by a different command than a wired one, and the log line gives the
|
||||
right one. Windows and macOS hosts don't run that check, so go straight to the BIOS/UEFI and
|
||||
network-card steps in [Arming the machine](/docs/wake-on-lan#arming-the-machine).
|
||||
|
||||
## Video is slow to start, or fails across subnets
|
||||
|
||||
|
||||
@@ -18,11 +18,18 @@ desktop on the [configure pages](#configure-your-desktop) afterward rather than
|
||||
> and needs GTK4 ≥ 4.20 and SDL3, so it installs on **26.04 or newer** only — the host has no such
|
||||
> limit.
|
||||
|
||||
> **Debian isn't a supported target.** The packages are built on Ubuntu images and their dependencies
|
||||
> are resolved against Ubuntu's package names, and nothing in CI builds or tests on Debian. Debian 12
|
||||
> (bookworm) is below the glibc floor and cannot install them at all. A newer Debian may work, but
|
||||
> it's untested — build from source ([appendix](#appendix--build-from-source)) if you want to try.
|
||||
> The `debian` in the repository URL below is the *package format*, not a supported distro.
|
||||
> ⚠ **On 24.04 LTS, the package installs but the distro gives it no compositor to drive.** The
|
||||
> host `.deb` is built for 24.04 and installs cleanly — but streaming needs a compositor that meets
|
||||
> the [version floors](/docs/requirements), and stock 24.04 meets none of them: KWin **5.27** (floor
|
||||
> 6.5.6), GNOME Shell **46** (floor 48), and no `gamescope` package at all (nor can the patched one
|
||||
> run there — 24.04 is too old on wayland, libinput, libavif and pixman). `sway` 1.9 is the only
|
||||
> candidate. **For a working host, use 26.04**, where the patched gamescope and current KDE/GNOME
|
||||
> are all available. This is why [Linux Mint 22.x cannot host](/docs/debian#linux-mint-22x-cannot-host-yet).
|
||||
|
||||
> **On Debian**, see [Debian](/docs/debian) — the host, console and plugin runner are supported and
|
||||
> CI-tested on **Debian 13**; the desktop client is not packaged for it yet. Debian 12 (bookworm) is
|
||||
> below the glibc floor and cannot install anything here. Note that the `debian` in the repository
|
||||
> URL below is the *package format*, and is the same URL for both distros.
|
||||
|
||||
## 1. GPU driver
|
||||
|
||||
|
||||
@@ -30,14 +30,35 @@ That ordering is the whole prerequisite:
|
||||
> says so rather than pretending. On every client but the Linux one you can also type the MAC in by
|
||||
> hand; see the table below.
|
||||
|
||||
The packet goes to every local interface's subnet broadcast address *and* to `255.255.255.255`, on
|
||||
The packet goes **out of every one of the client's network interfaces** — from a socket bound to
|
||||
that interface's own address, aimed at both its subnet broadcast address and `255.255.255.255` — on
|
||||
UDP ports 9 and 7, repeated three times, plus a unicast to the host's last known address. That
|
||||
spread is deliberate: a sleeping machine has no ARP entry, so a plain unicast cannot find it.
|
||||
spread is deliberate: a sleeping machine has no ARP entry, so a plain unicast cannot find it, and a
|
||||
broadcast sent without binding an interface leaves by the default route only, which on a machine
|
||||
running a VPN or a mesh network is not the LAN the host sleeps on.
|
||||
|
||||
Neither the advert nor a magic packet is authenticated. That is fine here — a wrong address only
|
||||
makes the wake fail, and the host's certificate fingerprint still gates the actual connection. See
|
||||
[Security](/docs/security).
|
||||
|
||||
### Over Wi-Fi
|
||||
|
||||
A host on Wi-Fi wakes from the same packet. The mechanism is **WoWLAN** (Wake on Wireless LAN):
|
||||
the adapter stays associated to your access point while the machine sleeps, the access point holds
|
||||
broadcast frames for its sleeping stations and releases them on the next beacon, and the adapter
|
||||
wakes the machine when one of them is a magic packet. Punktfunk publishes a Wi-Fi card's address
|
||||
exactly like a wired one, so there is nothing different to do on the client — but the card has to be
|
||||
armed for it, which is a different switch from the wired one. See
|
||||
[Linux (Wi-Fi)](#linux-wi-fi) and [Windows](#windows) below.
|
||||
|
||||
Two things can still stop it, and neither is visible from Punktfunk:
|
||||
|
||||
- Some access points and mesh systems drop or rate-limit broadcast traffic to sleeping stations
|
||||
(often as "multicast enhancement", "broadcast filtering" or IGMP snooping). If wired hosts wake
|
||||
and a Wi-Fi one never does, that is the first thing to turn off.
|
||||
- Some laptops and adapters cut power to the Wi-Fi card in deeper sleep states, which drops the
|
||||
association and with it any chance of a wake.
|
||||
|
||||
## Waking from a client
|
||||
|
||||
**Auto-wake on connect** is a client setting, and it is **on by default**. You find it in Settings,
|
||||
@@ -135,7 +156,7 @@ whether a machine may be woken off the network is yours to make.
|
||||
### Check the host log first
|
||||
|
||||
This is the fastest diagnosis. On **Linux**, the host inspects the card carrying the address it
|
||||
advertises, each time it starts advertising, and writes one of two lines:
|
||||
advertises, each time it starts advertising, and writes one line about it. A wired card:
|
||||
|
||||
```text
|
||||
Wake-on-LAN armed (magic packet) on host NIC
|
||||
@@ -145,18 +166,29 @@ Wake-on-LAN armed (magic packet) on host NIC
|
||||
Wake-on-LAN is NOT armed on this host's NIC — clients cannot wake it from sleep.
|
||||
```
|
||||
|
||||
A Wi-Fi card, which is armed through an entirely different mechanism and is asked about separately
|
||||
(`iw phy … wowlan show`, not `ethtool`):
|
||||
|
||||
```text
|
||||
Wake-on-WLAN armed (magic packet) on host Wi-Fi NIC
|
||||
```
|
||||
|
||||
```text
|
||||
Wake-on-WLAN is NOT armed on this host's Wi-Fi NIC — clients cannot wake it from sleep.
|
||||
```
|
||||
|
||||
The warning line goes on to name the interface and the exact command to fix it. The host only
|
||||
reports; it never changes the card's settings. It stays silent when it cannot tell — `ethtool`
|
||||
missing, or not enough privilege — rather than guessing, and it says nothing at all when mDNS
|
||||
adverts are switched off (`PUNKTFUNK_MDNS=0` or `--no-mdns`), because then no address is published
|
||||
either.
|
||||
reports; it never changes the card's settings. It stays silent when it cannot tell — `iw` or
|
||||
`ethtool` missing, a driver that doesn't answer, or not enough privilege — rather than guessing, and
|
||||
it says nothing at all when mDNS adverts are switched off (`PUNKTFUNK_MDNS=0` or `--no-mdns`),
|
||||
because then no address is published either.
|
||||
|
||||
Read the line on the web console's **Logs** page, or in the journal with
|
||||
`journalctl --user -u punktfunk-host`. See [Troubleshooting](/docs/troubleshooting#still-stuck).
|
||||
|
||||
**Windows and macOS hosts do not run this check**, so there is no log line to look for there.
|
||||
|
||||
### Linux
|
||||
### Linux (wired)
|
||||
|
||||
Ask the card what it is doing. `Supports Wake-on:` is the capability; `Wake-on:` is the current
|
||||
setting. `g` means magic packet, `d` means disabled.
|
||||
@@ -174,6 +206,42 @@ sudo ethtool -s enp5s0 wol g
|
||||
On many systems that does not survive a reboot. Re-run `ethtool enp5s0` after the next boot to check,
|
||||
and make it permanent through your distribution's network configuration if it reset.
|
||||
|
||||
### Linux (Wi-Fi)
|
||||
|
||||
`ethtool` is the wrong tool here — most wireless drivers report `Wake-on: d` whether or not they are
|
||||
armed, because the trigger lives in the wireless stack instead. Ask `iw`, using the *phy* behind the
|
||||
interface (`/sys/class/net/wlan0/phy80211/name`, usually `phy0`):
|
||||
|
||||
```bash
|
||||
iw phy phy0 wowlan show
|
||||
```
|
||||
|
||||
`WoWLAN is disabled` means no wake. Armed looks like this, and the `* wake up on magic packet` line
|
||||
is the one that matters:
|
||||
|
||||
```text
|
||||
WoWLAN is enabled:
|
||||
* wake up on magic packet
|
||||
```
|
||||
|
||||
Arm it:
|
||||
|
||||
```bash
|
||||
sudo iw phy phy0 wowlan enable magic-packet
|
||||
```
|
||||
|
||||
That setting is per-phy and NetworkManager re-applies its own on every connection, so on a
|
||||
NetworkManager system make it stick on the connection instead — this survives reboots and
|
||||
reconnects:
|
||||
|
||||
```bash
|
||||
sudo nmcli connection modify <connection> 802-11-wireless.wake-on-wlan magic
|
||||
```
|
||||
|
||||
`iw phy phy0 wowlan show` reporting `command failed: Operation not supported` means the driver has no
|
||||
WoWLAN support at all; that adapter cannot be woken over Wi-Fi. Check `iw list | grep -A5 "WoWLAN"`
|
||||
for what the hardware claims to support.
|
||||
|
||||
### Windows
|
||||
|
||||
Open **Device Manager**, find the network adapter under **Network adapters**, and open its
|
||||
@@ -181,10 +249,17 @@ properties. On the **Power Management** tab, allow the device to wake the comput
|
||||
**Advanced** tab, enable the adapter's magic-packet wake property if it has one. Exact wording
|
||||
depends on the driver.
|
||||
|
||||
Wi-Fi adapters use the same two tabs. The **Advanced** property is often called **Wake on Magic
|
||||
Packet** there too, sometimes **Wake on Wireless LAN**; many Wi-Fi drivers expose neither, and those
|
||||
cannot be woken over Wi-Fi. `powercfg /devicequery wake_armed` lists every device currently allowed
|
||||
to wake the machine — if the adapter is not in it, nothing on the network can wake this host.
|
||||
|
||||
## Limits
|
||||
|
||||
- **Wired Ethernet is what works.** Waking over Wi-Fi is unreliable and depends entirely on the
|
||||
adapter and the platform.
|
||||
- **Wired Ethernet is the sure thing; Wi-Fi works when the adapter supports WoWLAN.** Punktfunk
|
||||
sends the same packet either way and publishes a Wi-Fi card's address like any other, but whether
|
||||
a sleeping adapter is still listening is the adapter's and the access point's decision —
|
||||
see [Over Wi-Fi](#over-wi-fi).
|
||||
- **Connect once while the host is awake**, on the same local network, before you rely on waking it.
|
||||
A host you only ever added by address, on a network where mDNS never reached it, has no learned
|
||||
address — the CLI will tell you so, and the apps will not offer the wake action. Typing the MAC in
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# punktfunk-host — Debian/Ubuntu package (apt)
|
||||
|
||||
> **Which distros the published packages install on** — measured by installing them, not inferred
|
||||
> from the build image (`scripts/ci/deb-install-smoke.sh` asserts this on every run):
|
||||
>
|
||||
> | | Ubuntu 24.04 | Ubuntu 26.04 | Debian 13 | Debian 12 |
|
||||
> |---|---|---|---|---|
|
||||
> | `punktfunk-host` | ✅ | ✅ | ✅ | ❌ glibc 2.36 < 2.39 |
|
||||
> | `punktfunk-web` / `punktfunk-scripting` | ✅ | ✅ | ✅ | ✅ |
|
||||
> | `punktfunk-gamescope` | ❌ wayland 1.22 | ✅ | ✅ | ❌ |
|
||||
> | `punktfunk-client` | ❌ `libc6 >= 2.43` | ✅ | ❌ `libc6 >= 2.43` | ❌ |
|
||||
>
|
||||
> Debian 13 is a supported host target ([docs](https://docs.punktfunk.unom.io/docs/debian)); the
|
||||
> client is the one gap, since it is built on 26.04 and floors at that release's glibc.
|
||||
|
||||
`punktfunk-host` is published as a `.deb` to **Gitea's Debian package registry** in the public
|
||||
`unom` org, so the Ubuntu hosts update with plain `apt`. CI (`.gitea/workflows/deb.yml`) builds
|
||||
and publishes on every push to `main` (a rolling `<next-minor>~ciN.g<sha>` build — the base is
|
||||
@@ -36,6 +49,37 @@ dropped from `Depends`). The result is **one** host `.deb` that installs on **Ub
|
||||
on 26.04 (the native client needs SDL3 / GTK4 ≥ 4.20, absent on 24.04) — install the client on the box
|
||||
you stream *to*, which is independent of the host's distro.
|
||||
|
||||
## `punktfunk-gamescope` is built on Debian 13, not Ubuntu
|
||||
|
||||
The patched gamescope has its own job (`build-publish-gamescope`) in a **Debian 13** image
|
||||
(`ci/gamescope-trixie.Dockerfile`), and that is not a preference — it is the only apt distro the
|
||||
tree configures on. Built in the noble host image, as it was until 2026-08, it failed every single
|
||||
run:
|
||||
|
||||
```
|
||||
wlroots| Dependency wayland-server found: NO found 1.22.0 but need: '>=1.23.1'
|
||||
subprojects/wlroots/meson.build:96:17: ERROR: Dependency 'wayland-server' is required but not found
|
||||
```
|
||||
|
||||
Our pin vendors wlroots 0.19.3, which floors wayland-server at 1.23.1; noble ships 1.22.0 (and has
|
||||
no `libxcb-errors-dev`, and only libdisplay-info 0.1.1). Because every rung of that path was a
|
||||
`::warning::` returning 0, **v0.26.0 and v0.27.0 both shipped with no gamescope .deb** while the
|
||||
release notes and docs-site said it was apt-installable. Debian 13 has wayland 1.23.1 exactly —
|
||||
the oldest apt base that works.
|
||||
|
||||
Two things make the one package serve both Debian 13 and Ubuntu 26.04:
|
||||
|
||||
- **`--extra-fallback libdisplay-info`** (see `packaging/gamescope/build-punktfunk-gamescope.sh`).
|
||||
Linked against the distro copy, the package picks up `Depends: libdisplay-info2 (>= 0.2.0)` on
|
||||
trixie — and Ubuntu 26.04 carries libdisplay-info **3** (0.3.0), so apt refuses it there.
|
||||
gamescope vendors the library as a submodule, so the vendored build drops the dependency. Same
|
||||
reasoning the script already applies to wlroots: a binary we ship must not follow the build
|
||||
host's shared libraries.
|
||||
- The **static C++ runtime** the build script already forces, so `libstdc++` never appears in
|
||||
`NEEDED`. The binary asks only for `GLIBC_2.38`.
|
||||
|
||||
**Ubuntu 24.04 gets no gamescope package** and cannot: the wayland floor is a runtime one too.
|
||||
|
||||
## Install on a host (one-time)
|
||||
|
||||
The registry is public, so no apt auth is needed — just trust the repo's signing key:
|
||||
|
||||
@@ -28,7 +28,7 @@ set -euo pipefail
|
||||
GAMESCOPE_REV="5fb8dce4a09d0a68d097b9faf9513782106bc843"
|
||||
GAMESCOPE_REPO="https://github.com/ValveSoftware/gamescope.git"
|
||||
|
||||
REV="$GAMESCOPE_REV" PREFIX=/usr DESTDIR="" SRCDIR="" JOBS="" SETCAP=1
|
||||
REV="$GAMESCOPE_REV" PREFIX=/usr DESTDIR="" SRCDIR="" JOBS="" SETCAP=1 EXTRA_FALLBACK=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--rev) REV="${2:?}"; shift 2 ;;
|
||||
@@ -37,6 +37,15 @@ while [ $# -gt 0 ]; do
|
||||
--srcdir) SRCDIR="${2:?}"; shift 2 ;;
|
||||
--jobs) JOBS="${2:?}"; shift 2 ;;
|
||||
--no-setcap) SETCAP=0; shift ;;
|
||||
# Extra `force_fallback_for` entries, comma-separated, appended to the mandatory three below.
|
||||
# Exists for ONE package family: the .deb has to install on both Debian 13 and Ubuntu 26.04,
|
||||
# and those two disagree on the libdisplay-info SONAME (0.2.0 -> libdisplay-info2 vs 0.3.0 ->
|
||||
# libdisplay-info3), so a package built against either one is uninstallable on the other.
|
||||
# Vendoring it makes ONE .deb serve both. Opt-in rather than baked in, so the Arch/Fedora/nix
|
||||
# packages — which have no such split and are shipping fine — keep producing exactly the binary
|
||||
# they produce today. Its only caller is the `build-publish-gamescope` job in
|
||||
# .gitea/workflows/deb.yml, which passes `--extra-fallback libdisplay-info`.
|
||||
--extra-fallback) EXTRA_FALLBACK="${2:?}"; shift 2 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
@@ -111,7 +120,7 @@ export LDFLAGS="${LDFLAGS:-} -static-libstdc++ -static-libgcc"
|
||||
meson setup "$BUILD" "$SRCDIR" \
|
||||
--prefix="$PREFIX" \
|
||||
--buildtype=release \
|
||||
-Dforce_fallback_for=libliftoff,vkroots,wlroots \
|
||||
-Dforce_fallback_for="libliftoff,vkroots,wlroots${EXTRA_FALLBACK:+,$EXTRA_FALLBACK}" \
|
||||
-Dpipewire=enabled \
|
||||
-Denable_tests=false \
|
||||
-Denable_openvr_support=false \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/plugin-kit",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
|
||||
"type": "module",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
|
||||
Executable
+132
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the published .debs on every supported apt distro, in a pristine image, the way a user
|
||||
# would — repo key, sources.list line, `apt-get install`.
|
||||
#
|
||||
# WHY THIS EXISTS: nothing in deb.yml ever installed a package it built. Two facts survived a long
|
||||
# time in that blind spot, in opposite directions:
|
||||
# * `punktfunk-host` installed cleanly on Debian 13 for months while docs-site said Debian was
|
||||
# unsupported and unverified.
|
||||
# * `punktfunk-gamescope` was missing from the apt registry across two releases, while the docs
|
||||
# told Debian/Ubuntu users to `apt install` it.
|
||||
# Both are what a five-minute install check catches.
|
||||
#
|
||||
# Usage: bash scripts/ci/deb-install-smoke.sh
|
||||
# PF_APT_DISTRIBUTION stable | canary (default: stable)
|
||||
# PF_EXPECT_VERSION if set, `punktfunk-host --version` must contain it — proves the run is
|
||||
# testing the artifact THIS run published and not a leftover from an
|
||||
# earlier one (the failure mode that makes a green check meaningless).
|
||||
# PF_SMOKE_IMAGES override the image list, space-separated (local runs / bisecting).
|
||||
set -euo pipefail
|
||||
|
||||
DIST="${PF_APT_DISTRIBUTION:-stable}"
|
||||
EXPECT="${PF_EXPECT_VERSION:-}"
|
||||
REPO_URL="https://git.unom.io/api/packages/unom/debian"
|
||||
|
||||
# THE SUPPORT MATRIX, as an assertion instead of a paragraph. Each row is
|
||||
# "<image>|<packages that must install>". A package absent from a row is one we do NOT claim works
|
||||
# there, and it is deliberately NOT asserted absent — that would turn every future improvement into
|
||||
# a red build. The omissions and their reasons:
|
||||
# ubuntu:24.04 no client — built on 26.04, floors at libc6 >= 2.43 (noble has 2.39)
|
||||
# no gamescope — noble's wayland is 1.22.0; the vendored wlroots needs >= 1.23.1,
|
||||
# so the binary cannot even load there
|
||||
# debian:trixie no client — same libc6 >= 2.43 floor (trixie has 2.41), plus GTK4 >= 4.20
|
||||
# Debian 12 (bookworm) is absent entirely: glibc 2.36 is below the host's 2.39 floor, so nothing
|
||||
# we ship installs there and there is nothing to smoke-test.
|
||||
MATRIX=(
|
||||
"ubuntu:24.04|punktfunk-host punktfunk-web punktfunk-scripting"
|
||||
"ubuntu:26.04|punktfunk-host punktfunk-web punktfunk-scripting punktfunk-client punktfunk-gamescope"
|
||||
"debian:trixie|punktfunk-host punktfunk-web punktfunk-scripting punktfunk-gamescope"
|
||||
)
|
||||
|
||||
if [ -n "${PF_SMOKE_IMAGES:-}" ]; then
|
||||
FILTERED=()
|
||||
for row in "${MATRIX[@]}"; do
|
||||
for want in $PF_SMOKE_IMAGES; do
|
||||
[ "${row%%|*}" = "$want" ] && FILTERED+=("$row")
|
||||
done
|
||||
done
|
||||
MATRIX=("${FILTERED[@]}")
|
||||
fi
|
||||
|
||||
echo "smoke-installing from '$DIST'${EXPECT:+ (expecting host version $EXPECT)}"
|
||||
FAILED=()
|
||||
|
||||
for row in "${MATRIX[@]}"; do
|
||||
IMAGE="${row%%|*}"
|
||||
PACKAGES="${row#*|}"
|
||||
echo
|
||||
echo "==================== $IMAGE ===================="
|
||||
echo "packages: $PACKAGES"
|
||||
|
||||
# `docker run` inherits nothing from this shell — every value the container needs is passed
|
||||
# explicitly, so a typo here is a hard failure rather than a silently empty variable.
|
||||
if docker run --rm --platform linux/amd64 \
|
||||
-e DEBIAN_FRONTEND=noninteractive \
|
||||
-e DIST="$DIST" -e PACKAGES="$PACKAGES" -e EXPECT="$EXPECT" -e REPO_URL="$REPO_URL" \
|
||||
"$IMAGE" bash -euxc '
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends curl ca-certificates
|
||||
install -d -m 0755 /etc/apt/keyrings
|
||||
curl -fsSL --max-time 60 "$REPO_URL/repository.key" -o /etc/apt/keyrings/punktfunk.asc
|
||||
echo "deb [signed-by=/etc/apt/keyrings/punktfunk.asc] $REPO_URL $DIST main" \
|
||||
> /etc/apt/sources.list.d/punktfunk.list
|
||||
apt-get update -qq
|
||||
|
||||
# Wait for the index to actually carry the version this run published. Gitea regenerates
|
||||
# the apt Packages file after an upload, so a smoke job that starts immediately can see
|
||||
# the PREVIOUS build — install it, pass, and prove nothing about the new one. Bounded:
|
||||
# if it never appears, that is a real publishing failure and the job should say so.
|
||||
if [ -n "$EXPECT" ]; then
|
||||
for i in $(seq 1 10); do
|
||||
apt-cache policy punktfunk-host | grep -qF "$EXPECT" && break
|
||||
echo "index does not carry $EXPECT yet (attempt $i) — waiting"
|
||||
sleep 15
|
||||
apt-get update -qq
|
||||
done
|
||||
apt-cache policy punktfunk-host | grep -qF "$EXPECT" || {
|
||||
echo "the apt index never served $EXPECT — publish did not land"
|
||||
apt-cache policy punktfunk-host
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# The real thing: unpack + run every maintainer script, exactly as a user would.
|
||||
apt-get install -y $PACKAGES
|
||||
|
||||
# Installed is not the same as working. Assert every shipped binary RESOLVED its shared
|
||||
# libraries and, where it is safe to run headless, that it executes — an unsatisfied
|
||||
# soname is invisible to dpkg but fatal to the user, and it is exactly what a
|
||||
# distro-mismatched build produces.
|
||||
# (`if ldd | grep; then fail` rather than `grep && exit 1`: the latter leaves the block
|
||||
# returning grep NOT-found = 1, which under `set -e` fails the container on success.)
|
||||
for pkg in $PACKAGES; do
|
||||
for bin in $(dpkg -L "$pkg" | grep "^/usr/bin/" || true); do
|
||||
if ldd "$bin" 2>/dev/null | grep -F "not found"; then
|
||||
echo "UNRESOLVED SONAME in $bin (from $pkg)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
# --version is the cheapest proof of "actually runs". Only for binaries that answer it
|
||||
# without a session/GPU: the client opens GTK, the console is a bun bundle.
|
||||
if echo "$PACKAGES" | grep -q punktfunk-host; then
|
||||
punktfunk-host --version
|
||||
[ -z "$EXPECT" ] || punktfunk-host --version | grep -F "$EXPECT"
|
||||
fi
|
||||
if echo "$PACKAGES" | grep -q punktfunk-gamescope; then
|
||||
punktfunk-gamescope --version
|
||||
fi
|
||||
'; then
|
||||
echo "PASS: $IMAGE"
|
||||
else
|
||||
echo "::error::$IMAGE — the published packages do not install ($PACKAGES)"
|
||||
FAILED+=("$IMAGE")
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||
echo "install smoke FAILED on: ${FAILED[*]}"
|
||||
exit 1
|
||||
fi
|
||||
echo "install smoke passed on every supported distro"
|
||||
Reference in New Issue
Block a user