Compare commits

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

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

Written as a bolded blockquote, the admonition style every other page uses. NOT a {{< callout >}}
shortcode — this site is Fumadocs/MDX, not Hugo, and no shortcode exists anywhere in it. Verified by
running the real build (bun install + vite build) rather than assuming: it completes clean, and the
backticked /proc/<pid>/exe follows the same pattern as the existing <pkg>/<token> in arch.md and
automation.md, which MDX leaves alone inside inline code.
2026-08-09 10:07:18 +02:00
200 changed files with 1688 additions and 19950 deletions
-15
View File
@@ -280,21 +280,6 @@ jobs:
done
echo "OK: $(echo "$DEPS" | grep -E '^libav|^libsw' | tr '\n' ' ')"
# 0.26.0-1 setcap'd `cap_sys_nice=ep` on the host from this package's .INSTALL scriptlet and
# killed desktop streaming on every KDE box — with a green board, because nothing here ever
# looked at what the built package would DO. The lesson recorded then was "verify the
# PACKAGE, never the board"; this is that, and pacman is the channel where it matters most,
# since capabilities live in the scriptlet rather than in package metadata.
#
# Host must carry NOTHING, the worker exactly cap_sys_nice=ep. `--self-test` runs first so a
# guard that has quietly lost the ability to fail takes the job down rather than approving a
# release. (Only the host package is checked: the client/web/scripting packages ship neither
# binary and the script skips them by itself.)
- name: Assert the capability matrix (Arch package)
run: |
bash scripts/ci/assert-cap-matrix.sh --self-test
bash scripts/ci/assert-cap-matrix.sh "$GITHUB_WORKSPACE"/dist/punktfunk-host-*.pkg.tar.zst
# The optional HDR gamescope companion (packaging/gamescope) — a separate pkgbase with a
# completely different dependency set, published into the same repo so `pacman -S
# punktfunk-gamescope` is all an Arch/SteamOS box needs for 10-bit BT.2020 PQ.
+1 -74
View File
@@ -310,14 +310,8 @@ jobs:
# with "there is no reactor running, must be called from the context of a Tokio 1.x runtime".
# It WAS listed here, which is why only the .deb shipped a crashing tray while the RPM and
# Arch packages — which already split it — were fine.
#
# punktfunk-encode-worker IS in this invocation: it is the capability-carrying PyroWave
# encode worker that ships next to the host in /usr/bin, and build-deb.sh only builds it
# if the artifact is missing — building it here keeps it on the same sccache pass as the
# host. Unlike the tray it shares the host's dependency graph by design (v1 accepts that
# the worker links the same FFmpeg), so feature unification here is harmless.
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
-p punktfunk-host -p punktfunk-encode-worker
-p punktfunk-host
- name: Build host .deb (FFmpeg bundled)
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
@@ -326,17 +320,6 @@ jobs:
run: |
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
# Read the capability matrix out of the BUILT .deb before it is published. dpkg carries no
# capability metadata — the postinst applies them — so this reads the postinst that will
# actually run on a user's box, plus the payload. 0.26.0-1 granted the host cap_sys_nice=ep
# from exactly that postinst and killed every KDE desktop session while every board stayed
# green: host must carry NOTHING, worker exactly cap_sys_nice=ep. `--self-test` first so a
# guard that can no longer fail takes the job down instead of waving the release through.
- name: Assert the capability matrix (host .deb)
run: |
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.
@@ -361,45 +344,10 @@ jobs:
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
@@ -409,7 +357,6 @@ jobs:
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
@@ -440,26 +387,6 @@ 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')
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[*]}"
# ---------------------------------------------------------------------------------------------
# The aarch64 CLIENT .deb. Cross-compiled on the ordinary amd64 runner in the
# punktfunk-rust-ci-arm64cross image (the rust-ci toolchain + an arm64 multiarch sysroot — see
+4 -38
View File
@@ -7,24 +7,10 @@
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
# run for an hour on every push:
#
# * eval — `nix flake check --no-build`: instantiates every package, app, check and devShell
# without building them. Catches the failures that actually happen to this flake — a
# renamed file, a callPackage argument that no longer exists, a syntax error, a package
# attribute dropped from packages.nix.
#
# ⚠ It does NOT, on its own, check the NixOS module. `nix flake check` handles
# `nixosModules` by forcing the value and asserting it is a lambda taking an open
# attribute set — nothing more (nix's own source: `// FIXME: if we have a 'nixpkgs'
# input, use it to check the module.`). MEASURED: a module setting a nonexistent
# OPTION, referencing a nonexistent `pkgs` attribute AND calling a nonexistent `lib`
# function passes clean, printing `checking NixOS module ... all checks passed!`. This
# header used to claim the module was covered here; it was not, for the module's whole
# life. It is covered NOW because `checks.<system>.nixos-module`
# (packaging/nix/module-check.nix) evaluates it against real nixpkgs and asserts on the
# rendered systemd units — and because those assertions are pure Nix, INSTANTIATING
# that check runs them, so `--no-build` is enough. Keep them pure: a shell script in
# the derivation body would only run under a full `nix flake check`, which builds the
# hour-long Rust packages.
# * eval — `nix flake check --no-build`: instantiates every package, app, check, devShell and
# the NixOS module without building them. Catches the failures that actually happen to
# this flake — a renamed file, a callPackage argument that no longer exists, a syntax
# error, a package attribute dropped from packages.nix.
# * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations
# whose inputs churn constantly (every dependency bump moves a lockfile) and they cost
# minutes, not hours, because neither compiles Rust. This is the end-to-end proof that
@@ -36,12 +22,6 @@
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
#
# ⚠ punktfunk-gamescope deserves the dispatch run more than it looks: `host.gamescopeHdr` DEFAULTS
# TRUE, so it is on the critical path of every `services.punktfunk.host.enable = true` build, while
# being the one package nothing here compiles. It patches whatever gamescope the pinned nixpkgs
# carries, so a nixpkgs bump — not a change of ours — is what breaks it, and the first person to
# find out would be an operator whose system rebuild fails. Run the dispatch after a flake.lock bump.
#
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
# ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's
@@ -86,10 +66,6 @@ on:
description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)"
type: boolean
default: false
build-gamescope:
description: "Also build punktfunk-gamescope (patched gamescope from source; run after a flake.lock bump)"
type: boolean
default: false
jobs:
flake:
@@ -189,13 +165,3 @@ jobs:
if: ${{ github.event.inputs.build-rust == 'true' }}
run: |
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
# The patched compositor. Separate from build-rust because its failure mode is different: it
# tracks nixpkgs' gamescope, not our Rust, so it wants a run after a flake.lock bump rather
# than after a code change. `gamescope.nix` fails loudly (an eval-time `throw` if nixpkgs no
# longer exposes a patchable derivation, a `+pfhdr` grep in installCheckPhase) — but only if
# something actually builds it.
- name: Build the patched gamescope (dispatch opt-in)
if: ${{ github.event.inputs.build-gamescope == 'true' }}
run: |
"$NIX" build --print-build-logs .#punktfunk-gamescope
+1 -97
View File
@@ -103,11 +103,7 @@ jobs:
# gamescope`.) Matches packaging/rpm/punktfunk.spec, which dropped its BuildRequires too.
dnf -y install gtk4-devel libadwaita-devel SDL3-devel
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
# libcap = setcap/getcap: the sysext is the ONLY place the image can acquire
# cap_sys_nice=ep on punktfunk-encode-worker (a merged /usr is read-only squashfs and no
# scriptlet ever runs), and it is also what the build's host-must-be-uncapped assertion
# and the capability-matrix CI leg read with. Without it the image ships the lever inert.
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted libcap
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
# Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The
# sysext folds in our punktfunk-gamescope and verifies it by executing `--version`, and
# on a cache hit (the common case) nothing else in this job would have pulled libavif /
@@ -159,20 +155,6 @@ jobs:
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
run: bash packaging/rpm/sign-rpms.sh
# Read the file-capability matrix out of the BUILT rpm, before anything is signed or
# published. 0.26.0-1 shipped `%caps(cap_sys_nice=ep)` on the host through this very spec —
# on Fedora and, via rpm-ostree layering, on Bazzite — and every board was green while every
# KDE desktop session died in the field. The lesson recorded then was "verify the PACKAGE,
# never the board"; this is that. Host must carry NOTHING; the worker must carry exactly
# cap_sys_nice=ep. `--self-test` first, so a guard that has quietly stopped being able to
# fail takes the job down instead of waving the release through.
- name: Assert the capability matrix (rpm)
run: |
bash scripts/ci/assert-cap-matrix.sh --self-test
# Only the main host package carries binaries; -debuginfo/-debugsource and the
# client/web/scripting subpackages ship neither and are skipped by the script itself.
bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-[0-9]*.rpm
- name: Publish to the Gitea RPM registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -224,26 +206,10 @@ jobs:
dnf -y install dnf-plugins-core meson ninja-build glslc || true
dnf builddep -y gamescope || true
dnf -y install xorg-x11-server-Xwayland-devel || true
# NOT best-effort: build-punktfunk-gamescope.sh appends `-static-libstdc++` to LDFLAGS
# (so the binary still starts on SteamOS's older libstdc++ — see its comment), and
# without the static library meson's very FIRST sanity check dies with
# "cannot find -lstdc++ / have you installed the static version", so nothing builds at
# all. That is what happened on the v0.26.0 tag: both Fedora bases warned and skipped,
# the job stayed green, and the release shipped with no gamescope RPM while the notes
# said it had one. A rename here must be LOUD, hence no `|| true`.
dnf -y install libstdc++-static
# The rest of the Arch package's makedepends that Fedora's older packaged gamescope does
# not necessarily pull. Best-effort: unlike the static runtime, meson finds fallbacks or
# does without, and a name that moves between Fedora releases should not fail the job.
dnf -y install wayland-protocols-devel glm-devel cmake libXcursor-devel || true
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 is the LAST step of this job. Failing here
# would skip the sysext build, the sysext feed, AND the release attach below, so a
# missing gamescope would also withhold the punktfunk RPMs and the .raw images that
# built perfectly well. deb.yml learned that the expensive way on v0.26.0.
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
fi
@@ -261,35 +227,9 @@ jobs:
--binary gs-cache/punktfunk-gamescope \
--release "$PF_RELEASE"
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 for f${{ matrix.fedver }} — skipping its RPM"
fi
# A SECOND signing pass, for this package only. The main "Sign RPMs" step ran back at build
# time, long before this RPM existed — the gamescope build sits behind its own ~10-minute
# cache and deliberately runs after the host RPMs are already published. So every
# punktfunk-gamescope RPM went to the registry UNSIGNED, and the repo file we tell users to
# install carries gpgcheck=1: `dnf install punktfunk-gamescope` failed with "The package is
# not signed" on every Fedora and Nobara box. The package was in the channel the whole time
# and could not be installed from it — which is worse than absent, because the release notes
# and the docs-site both say it is there.
#
# Same fail-closed rule as the first pass: sign-rpms.sh hard-fails on refs/tags/v* if the org
# secret is missing, rather than republishing something a user's dnf will reject.
- name: Sign punktfunk-gamescope
env:
RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }}
RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }}
run: |
shopt -s nullglob
rpms=(dist/punktfunk-gamescope-*.rpm)
# No RPM here is the best-effort skip above, already warned about — not a signing failure.
if [ "${#rpms[@]}" -eq 0 ]; then
echo "no punktfunk-gamescope RPM to sign (see the packaging step above)"
exit 0
fi
bash packaging/rpm/sign-rpms.sh "${rpms[@]}"
- name: Publish punktfunk-gamescope to the Gitea RPM registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -330,19 +270,6 @@ jobs:
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
# Read the capability matrix back OUT of the image that is about to be published — the one
# channel where getting it wrong is unrepairable, because a merged sysext's /usr is read-only
# squashfs and the only fix is a new image plus a feed republish. 0.26.0-1's Bazzite breakage
# was confirmed exactly this way, after the fact, by mounting the published .raw and running
# getcap on it. Doing it here means the .raw never reaches the feed.
#
# The script proves its own reader first (cap a file, squash it, unsquash it, read it back)
# so a runner that cannot see file capabilities FAILS the leg instead of blessing the image.
- name: Assert the capability matrix (sysext image)
run: |
bash scripts/ci/assert-cap-matrix.sh \
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
# The feed's SHA256SUMS is OpenPGP-signed with the same packages@unom.io key as the RPMs, and
# punktfunk-sysext(8) refuses a feed it can't verify — the checksums alone never proved
# anything, sitting on the same registry as the images they describe.
@@ -383,26 +310,3 @@ jobs:
for raw in dist-sysext/*.raw; do
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
done
# A release must not be able to make a claim its own CI silently dropped — v0.26.0's notes
# said the patched gamescope was dnf-installable while both Fedora bases had skipped it on a
# `::warning::` (missing libstdc++-static, which the -static-libstdc++ link needs).
#
# ⚠ LAST step on purpose, matching deb.yml: failing at the build step instead would skip the
# sysext image, the feed publish AND the attach above, withholding the punktfunk RPMs and
# .raw images that built perfectly well. Everything good ships first; the job goes red after.
- name: A stable tag must ship the gamescope RPM
if: startsWith(gitea.ref, 'refs/tags/v')
run: |
shopt -s nullglob
built=(dist/punktfunk-gamescope-*.rpm)
keep=()
for r in "${built[@]}"; do
case "$r" in *debuginfo*|*debugsource*) continue;; esac
keep+=("$r")
done
if [ ${#keep[@]} -eq 0 ]; then
echo "::error::no punktfunk-gamescope RPM was built for f${{ matrix.fedver }} — a stable tag must not ship without it (the release notes and docs-site say it is installable). Everything else in this job published normally; see the gamescope build step above for the meson error."
exit 1
fi
echo "gamescope RPM present: ${keep[*]}"
+1 -331
View File
@@ -12,339 +12,9 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## v0.27.0
87 commits since v0.26.0.
### Versions
| | v0.26.0 | v0.27.0 | Notes |
|---|---|---|---|
| Wire protocol | 2 | **2** | unchanged |
| C ABI | 17 | **18** | `punktfunk_connection_next_rumble_cmd2` **added**; nothing removed or changed |
| Workspace crate dirs | 26 | **27** | `crates/punktfunk-encode-worker` (39 members; two `tools/` crates deliberately *excluded*) |
| Virtual-display driver protocol | 6 | **6** | unchanged (minimum accepted still 3) |
| Windows virtual-gamepad channel | 3 | **3** | unchanged — three `device_type`s added additively |
| Plugin index schema | 1 | **1** | unchanged |
| `api/openapi.json` | 0.25.0 | **0.25.0** | unchanged — no management-API edits this release |
| gamescope patch level (`+pfhdrN`) | 4 | **5** | 6 patches → 7 (the PipeWire use-after-free); `pkgrel` resets 3 → 1 |
| `@punktfunk/host` (SDK) | 0.1.4 | **0.1.4** | unchanged |
| `@punktfunk/plugin-kit` | 0.4.0 | **0.4.0** | unchanged |
**`crates/pf-driver-proto` is no longer byte-identical to the previous release.** It was through
both v0.25.0 and v0.26.0, so if you ship the virtual-display driver or the gamepad channel and have
been skipping this crate, stop skipping it here. The change is purely additive — three `device_type`
constants, no field moved, no size changed.
### ⚠ Breaking changes
**None** for embedders or the wire. Every embedder, packager and plugin that works against v0.26.0
works against v0.27.0 unchanged; the C ABI moves, but by addition only (below).
Two things change shape for **packagers** and one **default** flips:
- **A second installed binary**, `punktfunk-encode-worker` — see the section below. It is the only
file that may carry `cap_sys_nice=ep`, and it must be a separate file.
- **`PUNKTFUNK_XBOX_BACKEND` now defaults to `hid`** on Windows, so an Xbox pad is built as a real
HID device rather than the XUSB companion. `=xusb` is the escape hatch.
- **NixOS `scripting.autoStart` now defaults ON**, matching every other packaging (detailed below).
### `punktfunk-encode-worker` — the GPU-priority capability moves off the host
0.26.0 left the PyroWave priority ladder wired and inert: it needs `CAP_SYS_NICE`, and 0.26.0-1
proved the host can never hold one — see **PyroWave on Linux — Wave 2**, PW1, under v0.26.0 below. A
capability-carrying process cannot be identified by KWin (`cap_ptrace_access_check` refuses
`/proc/<pid>/exe` to a reader whose effective set is not a superset of the target's **permitted**
set), so it never gets `zkde_screencast_unstable_v1` and every KDE desktop session dies. Neither
`prctl(PR_SET_DUMPABLE, 1)` nor systemd `AmbientCapabilities=` nor a NixOS `security.wrappers` entry
changes that — all three land the capability in the same permitted set.
The capability therefore moves to a process that fronts nothing. **`punktfunk-encode-worker`** is a
new workspace member and a new installed binary: it owns the priority-elevated Vulkan device for
PyroWave sessions, receives capture dmabufs over a `SOCK_SEQPACKET` pair from its parent, and returns
compressed access units. It connects to no compositor, no D-Bus and no network, so its
non-dumpability costs nothing and its blast radius is one socket to the host that spawned it.
🛑 **The invariant, for anyone packaging this:** the worker is a **separate file**. Never a hardlink
to `punktfunk-host` and never a subcommand of it — a shared inode shares the file capability, which
silently re-creates 0.26.0-1 on every KDE box. `punktfunk-host` carries no capability, on any
channel, ever.
- **The grants are re-targeted, not re-introduced.** Every channel that granted in 0.26.0-1 grants
again, at the worker: Arch `.install` (`post_install` **and** `post_upgrade` — a replaced binary is
a new inode), RPM `%caps(cap_sys_nice=ep)` in `%files` (never a `%post setcap`; this covers Fedora
and Bazzite layering), the Bazzite sysext staging tree pre-`mksquashfs` (which does record
`security.capability`), the deb `postinst`, the Deck installer, and NixOS
`security.wrappers.punktfunk-encode-worker`. Every #136 host-side removal stays verbatim, including
the sysext's host hard-fail.
- **The sysext assertion is amended, not removed** — host must be empty (hard fail), worker must
carry **exactly** `cap_sys_nice=ep`. A *missing* worker capability is not an error: the grant is
best-effort everywhere.
- **A new release-CI leg asserts the getcap matrix** on the built Arch package, the deb and the
mounted sysext raw. The 0.26.0-1 lesson was "verify the package, never the board"; this is that,
mechanized, and it is what would have caught the original break.
- **On NixOS the env override is load-bearing**, not a convenience: a file capability cannot live on
a read-only store path, so the module wraps the worker and sets `PUNKTFUNK_ENCODE_WORKER` to the
wrapper path in the unit. An ambient grant is fine *here* — the worker is not a KWin client. The
host's `ExecStart` stays on the plain store path (the #136 fix stands).
**Fallback ladder — no rung can kill a negotiated session.** Binary not found → spawn failure →
handshake timeout → protocol or workspace-version mismatch → socket EOF mid-session all fall back to
the **in-process encoder exactly as today**, at default priority, with one warning. Host and worker
are different files now, so the version check is load-bearing rather than decorative; they ship
lockstep in every channel. The in-process path stays compiled and tested — it is the floor, not dead
code. `PYROWAVE_QUEUE_PRIORITY` keeps its 0.26.0 grammar and is now forwarded **explicitly** in the
handshake rather than read from the worker's environment, which is sanitized at spawn; one env var
still means one thing on both platforms.
### NixOS — session detection, module defaults, and a CI gate that was never running
🛑 **The host could not detect any graphical session on NixOS, at all.** The live-session probe
matched `/proc/<pid>/comm` exactly against `kwin_wayland` / `gamescope` / `gnome-shell` /
`Hyprland`. `comm` is the kernel's name for the **executed file**, truncated to 15 bytes — not
`argv[0]` — and nixpkgs wraps essentially every graphical binary: `wrapProgram` moves the real ELF
aside to `.<name>-wrapped` and installs a wrapper that `exec -a "$0"`s it. So the kernel reports
`.kwin_wayland-w` while `ps` and `pgrep -a` show a perfectly ordinary `kwin_wayland`, because they
read argv. Every probe answered `ActiveKind::None` on a running desktop, and nothing downstream
could recover: `wayland` logged as `-`, a correct `WAYLAND_DISPLAY` changed nothing, `Auto` returned
the *detected* backend so a live KWin already in `available()` was never chosen, and a
`PUNKTFUNK_COMPOSITOR` pin turned the miss into a hard error through `pinned_at_a_dead_session`.
sway and river survived by accident — nixpkgs' wrapper execs a binary still called `sway`.
Names are now resolved through `/proc/<pid>/exe`, whose file name is untruncated, with the nixpkgs
decoration stripped. Stripping requires **both** the leading `.` and a trailing `-wrapped`, so
KWin's own real `kwin_wayland_wrapper` binary keeps its name instead of collapsing into
`kwin_wayland` and handing the probe the parent's PID. The `comm` fast path is unchanged for every
ordinary distro — one read, no readlink — and no name that matched before can stop matching. Also
applied to the foreign-gamescope probe, which had the same defect.
**Module changes** (`services.punktfunk`):
- **`host.desktopSession`** *(new, default `false`)* — binds the host to `graphical-session.target`,
the declarative form of the `punktfunk-host-desktop-session.conf` drop-in. Without it a
Plasma/GNOME restart leaves the host holding a Wayland socket and portal D-Bus connection that
died with the old compositor: it still listens, still answers, and every session after that fails
at capture. Off by default because an appliance may never reach that target and would be left
permanently stopped.
-**`scripting.autoStart` now defaults ON** *(behaviour change)*, matching the deb `postinst` and
RPM `%post`, which both `systemctl --global enable` the runner, and the sysext's baked-in
`default.target.wants` symlink. It was opt-in here on the reasoning that the runner is inert until
you add automation — untrue since the game-library scanners became plugins, so a NixOS host came
up with an empty library and no obvious cause. Opt out with `scripting.autoStart = false` or
`systemctl --user mask punktfunk-scripting`.
- **Three divergences from the shipped units, ported.** `punktfunk-web` gains
`StartLimitIntervalSec=0` (without it, 5 starts / 10 s against `RestartSec=2` gives up permanently
after ~10 s — exactly the window before the host's first `serve` writes the mgmt token, so a
console enabled before the host's first run stayed dead) and `Restart=always` rather than
`on-failure`. `punktfunk-scripting` gains the sandbox the deb/rpm unit has all along
(`NoNewPrivileges`, `ProtectSystem=strict`, `ReadWritePaths=%h /tmp`, restricted address families,
`PrivateTmp=no`) — it is the one unit that runs arbitrary operator TypeScript by design, and it
had been running strictly less confined on NixOS than anywhere else.
- A **warning** when the host is enabled and `xdg.portal.enable` is not.
🛑 **`nix flake check` does not check `nixosModules`** — worth knowing for anyone maintaining a
flake. It forces the value and asserts it is a lambda taking an open attribute set, and stops;
nix's source still carries `// FIXME: if we have a 'nixpkgs' input, use it to check the module.`
Measured: a module with a nonexistent option, a nonexistent `pkgs` attribute **and** a nonexistent
`lib` function passes, printing `checking NixOS module ... all checks passed!`. `nix.yml`'s header
claimed that leg covered the module; it never had. `checks.<system>.nixos-module`
(`packaging/nix/module-check.nix`) now evaluates it against real nixpkgs across four scenarios and
asserts on the rendered units, including a guard that the host's `ExecStart` stays on the plain
store path while the encode worker points at the wrapper. Its assertions are pure Nix, so
instantiation runs them and the existing `--no-build` leg is enough. `punktfunk-gamescope` gains a
`build-gamescope` dispatch input — it is on the critical path of every host build yet nothing
compiled it, and it tracks nixpkgs' gamescope, so a `flake.lock` bump is what breaks it.
### C ABI 17 → 18
**`punktfunk_connection_next_rumble_cmd2` is new.** The `0xCA` rumble plane carries the two Xbox
impulse-trigger motors (v3, below) and `punktfunk_connection_next_rumble_cmd`'s fixed out-params
have no room for them:
```c
PunktfunkStatus punktfunk_connection_next_rumble_cmd2(
PunktfunkConnection *c, uint16_t *pad, uint16_t *low, uint16_t *high,
uint16_t *left_trigger, uint16_t *right_trigger,
uint32_t *backstop_ms, uint32_t timeout_ms);
```
**Added, not widened.** `_cmd` keeps its signature *and* its values bit-identical for handle-only
traffic; all four rumble entry points remain exported. An exported parameter list is part of the
contract, and growing one in place breaks every out-of-tree embedder at once — with a
stack-corruption signature rather than a link error. This follows the existing
`next_rumble``next_rumble2` precedent.
**One behavioural delta on the old symbol**, documented in `abi.rs` and pinned by a test: against
a host driving the trigger motors, a `_cmd` caller now receives commands with `low == high == 0`
where the demux previously dropped the update entirely. They are idempotent handle stops — the
command as a whole is not silent, so redundant-stop suppression cannot fold them. Zero cost today:
nothing sources non-zero trigger levels yet.
**Render trigger levels only on a pad that has trigger motors.** Do not fold them into the handles —
impulse-trigger content is continuous, so folding it drones the handle motors flat-out. Query
`SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN` or `GCDeviceHaptics.supportedLocalities`.
🛑 **This delivery path is deliberately built ahead of its producer and nothing here claims
otherwise.** Exactly one backend can ever source these levels — the Windows HID Xbox pad's output
report `0x03` — because `XINPUT_VIBRATION` and evdev `FF_RUMBLE` both have two members. That
producer is reachable only through GameInput, which does not enumerate an `xinputhid`-promoted Xbox
pad at all (measured against a real Microsoft Elite, equally invisible there while classic XInput
reads it live). The wire, the engine and this entry point are exercised by synthetic levels only.
### Gamepads
- **`PUNKTFUNK_GAMEPAD_XBOXELITE = 11`** — a new `GamepadPref` wire byte, appended to
`Hello`/`Welcome`. The `Auto` sentinel in the round-trip test moved 11 → 12. An older peer
degrades an unknown byte to `Auto`, so this is graceful in both directions.
- **`XboxOne` is now a distinct HID identity on Windows** (`045E:02FD`, Bluetooth Xbox One S)
through the UMDF minidriver. It used to fold to `Xbox360` there, because the only Windows Xbox
backend was the XUSB companion, which presents one fixed 360 identity and cannot vary it.
- **Three new `pf_driver_proto::gamepad` device types**, contiguous and sharing one report
descriptor byte for byte (they are the same pad in HID terms; the descriptor is the report
*shape*, the identity is what the OS keys mappings off):
| const | value | identity |
|---|---|---|
| `DEVTYPE_XBOX` | 4 | `045E:0B13` Xbox Wireless Controller |
| `DEVTYPE_XBOX_ONE_S` | 5 | `045E:02FD` Xbox Wireless Controller (One S) |
| `DEVTYPE_XBOX_ELITE` | 6 | `045E:0B22` Xbox Elite Wireless Controller Series 2 |
⚠ The Xbox input report is **not** 64 bytes like its siblings — it is `XBOX_INPUT_REPORT_LEN`
(16). The driver serves per-identity report lengths, because hidclass sizes its buffer from the
descriptor and refuses an over-long source.
-**Elite paddles are not implemented.** `BTN_PADDLE1..4` still fold or drop exactly as on the
other Xbox classes. `DualSenseEdge` remains the only virtual pad with native back-button slots.
- **All three Xbox identities install `pfGamepadXbox`**, their own DDInstall section, which attaches
the `xinputhid` bus filter. Merging it back into the shared `pfGamepad` section is a one-line edit
that looks like tidying and would hand a DualSense, DualShock 4, Edge and Steam Deck to
Microsoft's Xbox translator. `only_the_xbox_identity_installs_the_xinputhid_section` asserts the
split in both directions.
**What actually promotes the pad — two registry values, and the pairing is the whole finding.**
`UpperFilters=xinputhid` is a `.HW` AddReg (hardware key); `DevicePropertyFlags=1` is a DDInstall
AddReg (software key). A one-value A/B on real hardware: removing `DevicePropertyFlags` alone
reverts everything — no `IG_00`, no XUSB interface, no XInput, no WGI entry — while `UpperFilters`
alone is completely inert. `1` = `BusDevice`, which Microsoft's own comment glosses as "a focused
bus filter driver for the IG_ problem". **This retracts an earlier in-tree conclusion that the
filter should never ship**: it was never broken, it had simply never been switched on.
⚠ Microsoft's allow-list contains `02D1, 02DD, 02E3, 02EA, 0B00, 0B0A, 0B13, 02FF` — neither `02FD`
nor `0B22` is on it, and promotion happens anyway, because it comes from our own AddReg.
### Wire (no version change)
**The `0xCA` rumble datagram gains a v3 form**, `PUNKTFUNK_RUMBLE_V3_LEN = 14`:
```
v1 7 B: [0xCA][u16 pad][u16 low][u16 high]
v2 10 B: … [u8 seq][u16 ttl_ms]
v3 14 B: … [u16 left_trigger][u16 right_trigger]
```
v3 is built *from* v2's bytes, so the prefix relationship is structural rather than a convention two
encoders must keep agreeing on, and every reader gates with `>=`. All four levels share one `seq`
and one TTL deliberately: they are one statement of the pad's feedback at one instant, so the entire
v2 apparatus — renewal cadence, stop burst, the client's seq gate, the lease clamp — governs the
triggers with no new code. The new `RumbleUpdate` fields are plain `u16`, not `Option`: on a
level-triggered plane "absent" must mean zero, because "absent → keep the previous value" is the
stuck-rumble bug in a new costume.
**The two trigger `enable`-mask bits remain conjecture.** Bits 2/3 (the handles) are measured;
bits 0/1 are inferred from field order and nothing else. No test asserts them. XInput cannot settle
this; it has two motors.
### Packaging
- **gamescope pin `8c676c39``5fb8dce4`** (3.16.25-1 → 3.16.25-11), all six patches rebased, plus
a **seventh**: the PipeWire use-after-free that aborted a session on every connect. The marker
moves `+pfhdr4`**`+pfhdr5`**, so `pkgrel` resets to 1.
- **Patch 0001 offers `xBGR_210LE` before `xRGB_210LE`.** ⚠ Deliberately *not* done by calling
upstream's `vulkan_get_rgb10_capture_format()` — that symbol landed after 3.16.25 and would break
`packaging/nix/gamescope.nix` with an opaque C++ error instead of a patch conflict.
- **Every `punktfunk-gamescope` RPM ever published was unsigned.** `Sign RPMs` runs right after
`Build RPM`, while the gamescope RPM is built ~90 steps later behind its own cache, so it missed
the signing pass entirely — and the repo file we ship carries `gpgcheck=1`. A second pass signs it
before publish, fail-closed on a tag.
-**The v0.26.0 gamescope gate failed the job at the *build* step**, which in `deb.yml` runs before
both the apt publish and the release attach — so a missing *extra* withheld the host `.deb` itself,
and the `.deb` published on v0.26.0 still carries the `CAP_SYS_NICE` grant. `rpm.yml` had the
identical latent bug. Both now warn at build/package time and gate as the **last** step of the job.
- **`driver uninstall --audio`** — a third Inno `[UninstallRun]` entry that removes the MEDIA-class
devnodes the host mints at runtime. Marker-matched, never name-matched: our instances are
name-identical to Steam's, and a `ROOT\` enumeration guard means a marker-shaped value on a real
sound card can never cost the user their hardware.
- **The sysext `post_merge` step re-runs when already current, plus a new `reapply` verb.** A sysext
upgrade is driven by the script from the **old** image, so a `post_merge` step added in a release
is executed by nobody, permanently, on exactly the installs that need it.
### Host
- **HDR capture offers `xBGR_210LE` before `xRGB_210LE`.** gamescope's capture textures are
mappable, hence linear-tiled, and NVIDIA does not implement linear-tiled STORAGE for
`A2R10G10B10_UNORM_PACK32` — so `imageStore` lands in XBGR order while the buffer is still
*labelled* `XRGB2101010`. Every mapping on both ends audits clean because the label was right and
only the content was wrong. Fixed host-side because the deployed gamescope cannot self-correct.
- **One NVENC open failure no longer kills every session on the box**, and the 10-bit capability
probe no longer wedges a direct-SDK host process-wide with `NV_ENC_ERR_INVALID_VERSION`.
- **`/api/v1/local/summary` reports the resolution the session actually got**, not the negotiated
one it was seeded with.
### Workspace
`crates/punktfunk-encode-worker` joins as a member (above). Two bring-your-own-hardware measurement
tools are added and **excluded** in the root manifest, so `cargo build --workspace` and CI never see
them: `tools/hid-descriptor-dump` (dumps and decodes a real HID report descriptor; pulls `hidapi`)
and `tools/win-input-matrix` (asks each Windows input API what it can see — ⚠ `wake_wgi()` is not
optional there: both WGI collections return a cache a console app has never started filling, so
without subscribing first they come back empty with real controllers attached).
### Host and client environment variables
- **`PUNKTFUNK_XBOX_BACKEND`** *(new, host, Windows)*`hid` (the new **default**) or `xusb` (the
escape hatch). The HID pad is now a superset of the XUSB companion: it keeps classic XInput while
gaining Steam, SDL, RawInput, DirectInput, `joy.cpl` and WGI, plus rumble, which XUSB could not
source at all. The escape hatch stays because promotion leans on Microsoft's inbox
`xinputhid.inf`; if a servicing update changes it, one env var restores the old behaviour with no
reinstall. An unrecognised value takes the **default**, not the opt-out, so a typo cannot silently
drop a user onto the path with no HID collection.
- **`PUNKTFUNK_GAMESCOPE_BIND`** *(new, host, Linux)* — unset = auto, `0` = never, `1` = force.
Governs whether the host binds the patched gamescope over the distribution's `/usr/bin/gamescope`
inside a session's mount namespace.
- **`PUNKTFUNK_ENCODE_WORKER`** *(new, host, Linux)* — where to find the encode worker. Resolution
order: this variable → alongside `/proc/self/exe``PATH`. `off` forces the in-process encoder,
the debug escape hatch that makes the A/B a one-line change. Load-bearing on NixOS (above).
- **`PYROWAVE_QUEUE_PRIORITY`** *(unchanged grammar, new consumer)* — the *intent*, forwarded to the
worker; the granted class comes back in the handshake and the host logs it centrally, so the
in-process INERT warning does not double-fire. When the worker is uncapped as well — an operator
stripped it, or the filesystem cannot store the capability — the same INERT wording fires, now
naming the worker binary rather than the host.
### Documentation
- `docs-site` **Running as a service → GPU scheduling priority** rewritten around the split: the
worker carries the capability, the host never does, and `setcap` on `punktfunk-host` is called out
as the thing an operator must never do, with the `zkde_screencast_unstable_v1` symptom spelled out
so anyone who already did it can self-diagnose. The anchor is unchanged, so existing links hold.
- `configuration.md` gains the `PUNKTFUNK_ENCODE_WORKER` row and rewrites `PYROWAVE_QUEUE_PRIORITY`
off "the packages deliberately do not grant this".
- The 0.26.0 user-facing notes describe a privilege that is deliberately not granted. That is the
record of what 0.26.0 shipped and is **not** rewritten; the new phrasing — granted to the worker,
never to the host — lives in `docs/releases/v0.27.0.md`.
- `install.md` **NixOS** documents `desktopSession`, and its `punktfunk-scripting` bullet no longer
claims the runner "ships disabled": that was true only of Arch and source installs — apt, dnf, the
Bazzite sysext and now the NixOS module all start it, because the library scanners are plugins.
`bazzite.md` carried the same stale claim and is corrected. **Running as a service → Restart the
host with your desktop** gains the NixOS one-liner beside the drop-in.
- `packaging/nix/README.md`: `desktopSession`, `gamescopeHdr`/`gamescopePackage` and the
`punktfunk` group added to the option tables; the "what the module configures" list gains the
`security.wrappers` entry, with the KWin-identification reasoning for why the capability is on the
worker and not the host; and a caveat recording that `nix flake check` does not check the module,
plus the two rules for editing `module-check.nix`.
---
## v0.26.0
52 commits since v0.25.0.
47 commits since v0.25.0.
### Versions
Generated
+35 -46
View File
@@ -994,7 +994,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"pf-capture",
@@ -1114,7 +1114,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
@@ -2358,7 +2358,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.27.0"
version = "0.26.0"
[[package]]
name = "lazy_static"
@@ -2463,7 +2463,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"bindgen",
"cmake",
@@ -2498,7 +2498,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"punktfunk-core",
]
@@ -2988,7 +2988,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"cros-codecs",
"tracing",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3017,7 +3017,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ash",
@@ -3052,7 +3052,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3070,7 +3070,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ash",
@@ -3091,7 +3091,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3101,7 +3101,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ash",
@@ -3118,8 +3118,6 @@ dependencies = [
"pf-zerocopy",
"punktfunk-core",
"pyrowave-sys",
"serde",
"serde_json",
"tracing",
"tracing-subscriber",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -3127,7 +3125,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"libc",
@@ -3139,7 +3137,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3153,11 +3151,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.27.0"
version = "0.26.0"
[[package]]
name = "pf-inject"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3186,14 +3184,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ash",
@@ -3208,7 +3206,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"serde",
"serde_json",
@@ -3216,7 +3214,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"base64",
@@ -3228,7 +3226,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3237,7 +3235,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ashpd",
@@ -3270,7 +3268,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"ash",
"cros-codecs",
@@ -3281,7 +3279,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"pf-paths",
@@ -3293,7 +3291,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ash",
@@ -3516,7 +3514,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3527,7 +3525,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"android_logger",
"jni",
@@ -3545,7 +3543,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3562,7 +3560,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3577,7 +3575,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3596,7 +3594,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -3626,18 +3624,9 @@ dependencies = [
"zeroize",
]
[[package]]
name = "punktfunk-encode-worker"
version = "0.27.0"
dependencies = [
"pf-encode",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "punktfunk-host"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3722,7 +3711,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3736,7 +3725,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"anyhow",
"ksni",
@@ -3759,7 +3748,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.27.0"
version = "0.26.0"
dependencies = [
"bindgen",
"cmake",
+1 -9
View File
@@ -4,9 +4,6 @@ members = [
"crates/punktfunk-core",
"crates/punktfunk-host",
"crates/punktfunk-host/vendor/usbip-sim",
# The capability-carrying PyroWave encode worker. A SEPARATE binary by design — never a
# hardlink of, or a subcommand of, punktfunk-host (design/gpu-priority-capability-worker.md).
"crates/punktfunk-encode-worker",
"crates/punktfunk-tray",
"crates/pf-bitstream",
"crates/pf-bitstream/vendor/cros-codecs",
@@ -49,11 +46,6 @@ members = [
exclude = [
"packaging/linux/steam-deck-gadget/usbip-poc",
"clients/android/native/vendor/ndk",
# Bring-your-own-hardware measurement tools. `hid-descriptor-dump` pulls `hidapi`, a C library
# wanting libudev on Linux; `win-input-matrix` is Windows-only and asks the live input stacks
# what they can see. Neither belongs in `cargo build --workspace` or on a CI leg with no pad.
"tools/hid-descriptor-dump",
"tools/win-input-matrix",
]
# ndk 0.9.0 verbatim from crates.io plus ONE visibility change (and two warning fixes — an
@@ -65,7 +57,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.27.0"
version = "0.26.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
@@ -84,11 +84,7 @@ class GamepadPalette(
// pure black too: the calm mix on the form screens lifts toward nothing. What is
// left is a faint indigo→violet ember in the bright corner. The accent stays the
// brand violet — focus has to be findable on black.
// Named for the look, not the panel technology — black with a thin violet corona
// belongs beside Nebula and Abyss. ⚠ The ID stays "oled": it is the stored
// `ui_palette` value and the cross-client key, so renaming it would orphan saved
// choices and desync the clients.
"oled", "Eclipse",
"oled", "OLED",
listOf(
Triple(0.000, 0.000, 0.000), Triple(0.000, 0.000, 0.000),
Triple(0.010, 0.020, 0.100), Triple(0.045, 0.016, 0.115),
-13
View File
@@ -1,13 +0,0 @@
{
"pins" : [
{
"identity" : "glur",
"kind" : "remoteSourceControl",
"location" : "https://github.com/joogps/Glur.git",
"state" : {
"revision" : "ba4f05d3c9a608ec773b9305f2af6089390de68a"
}
}
],
"version" : 2
}
+1 -17
View File
@@ -16,17 +16,6 @@ let package = Package(
.library(name: "PunktfunkShared", targets: ["PunktfunkShared"]),
.executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]),
],
dependencies: [
// Progressive (gradient) backdrop blur for the form screens' trays a real blur with no
// material tint stage (see GamepadTrayBlur). Pinned by REVISION, not `from:`: the
// GlurBackdrop product exists only on main no release carries it (the newest tag,
// `1.1`, predates it, and is not three-component semver anyway, so version-based
// resolution stops at 1.0.4). The revision is main's head at adoption time; a revision
// pin stays reproducible when the branch moves.
.package(
url: "https://github.com/joogps/Glur.git",
revision: "ba4f05d3c9a608ec773b9305f2af6089390de68a"),
],
targets: [
.binaryTarget(name: "PunktfunkCore", path: "PunktfunkCore.xcframework"),
// No dependencies by design an extension process links this alone.
@@ -62,12 +51,7 @@ let package = Package(
// (The tvOS slide-transition package is referenced by the Xcode PROJECT only
// its manifest breaks SwiftPM whole-graph validation on macOS, and only the
// Punktfunk-tvOS target links it; the #if os(tvOS) import never compiles here.)
.executableTarget(
name: "PunktfunkClient",
dependencies: [
"PunktfunkKit",
.product(name: "GlurBackdrop", package: "Glur"),
]),
.executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]),
// PunktfunkCore is a direct dep too so the wire tests can name the C ABI's
// `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout.
.testTarget(
@@ -11,12 +11,6 @@
BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; };
CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; };
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */ = {isa = PBXBuildFile; productRef = DD0000000000000000000002 /* SwiftUINavigationTransitions */; };
EE0000000000000000000012 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000002 /* Glur */; };
EE0000000000000000000013 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000003 /* GlurBackdrop */; };
EE0000000000000000000014 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000004 /* Glur */; };
EE0000000000000000000015 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000005 /* GlurBackdrop */; };
EE0000000000000000000016 /* Glur in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000006 /* Glur */; };
EE0000000000000000000017 /* GlurBackdrop in Frameworks */ = {isa = PBXBuildFile; productRef = EE0000000000000000000007 /* GlurBackdrop */; };
E295569A300948B9009F939C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2955699300948B9009F939C /* WidgetKit.framework */; };
E295569C300948B9009F939C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E295569B300948B9009F939C /* SwiftUI.framework */; };
E29556A9300948BA009F939C /* PunktfunkWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E2955697300948B9009F939C /* PunktfunkWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -94,8 +88,6 @@
buildActionMask = 2147483647;
files = (
AA0000000000000000000005 /* PunktfunkKit in Frameworks */,
EE0000000000000000000012 /* Glur in Frameworks */,
EE0000000000000000000013 /* GlurBackdrop in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -104,8 +96,6 @@
buildActionMask = 2147483647;
files = (
BB0000000000000000000005 /* PunktfunkKit in Frameworks */,
EE0000000000000000000014 /* Glur in Frameworks */,
EE0000000000000000000015 /* GlurBackdrop in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -115,8 +105,6 @@
files = (
CC0000000000000000000005 /* PunktfunkKit in Frameworks */,
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */,
EE0000000000000000000016 /* Glur in Frameworks */,
EE0000000000000000000017 /* GlurBackdrop in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -187,8 +175,6 @@
name = Punktfunk;
packageProductDependencies = (
AA0000000000000000000006 /* PunktfunkKit */,
EE0000000000000000000002 /* Glur */,
EE0000000000000000000003 /* GlurBackdrop */,
);
productName = Punktfunk;
productReference = AA0000000000000000000001 /* Punktfunk.app */;
@@ -215,8 +201,6 @@
name = "Punktfunk-iOS";
packageProductDependencies = (
BB0000000000000000000006 /* PunktfunkKit */,
EE0000000000000000000004 /* Glur */,
EE0000000000000000000005 /* GlurBackdrop */,
);
productName = "Punktfunk-iOS";
productReference = BB0000000000000000000001 /* Punktfunk-iOS.app */;
@@ -242,8 +226,6 @@
packageProductDependencies = (
CC0000000000000000000006 /* PunktfunkKit */,
DD0000000000000000000002 /* SwiftUINavigationTransitions */,
EE0000000000000000000006 /* Glur */,
EE0000000000000000000007 /* GlurBackdrop */,
);
productName = "Punktfunk-tvOS";
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
@@ -301,7 +283,6 @@
packageReferences = (
AA000000000000000000000F /* XCLocalSwiftPackageReference "." */,
DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */,
EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = AA0000000000000000000008 /* Products */;
@@ -867,14 +848,6 @@
minimumVersion = 0.18.0;
};
};
EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/joogps/Glur.git";
requirement = {
kind = revision;
revision = ba4f05d3c9a608ec773b9305f2af6089390de68a;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -895,36 +868,6 @@
package = DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */;
productName = SwiftUINavigationTransitions;
};
EE0000000000000000000002 /* Glur */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = Glur;
};
EE0000000000000000000003 /* GlurBackdrop */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = GlurBackdrop;
};
EE0000000000000000000004 /* Glur */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = Glur;
};
EE0000000000000000000005 /* GlurBackdrop */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = GlurBackdrop;
};
EE0000000000000000000006 /* Glur */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = Glur;
};
EE0000000000000000000007 /* GlurBackdrop */ = {
isa = XCSwiftPackageProductDependency;
package = EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */;
productName = GlurBackdrop;
};
E2CAFE000000000000000002 /* PunktfunkShared */ = {
isa = XCSwiftPackageProductDependency;
productName = PunktfunkShared;
@@ -1,14 +1,6 @@
{
"originHash" : "bb1ce9bc6042f166bd0aad78a15081e673781d3a90fa52fd8ec8a08875878ef6",
"originHash" : "5d17a752eb57d190a90cbd663718ff44034b24fe0ae1baafea7677db2d49da6f",
"pins" : [
{
"identity" : "glur",
"kind" : "remoteSourceControl",
"location" : "https://github.com/joogps/Glur.git",
"state" : {
"revision" : "ba4f05d3c9a608ec773b9305f2af6089390de68a"
}
},
{
"identity" : "objc-runtime-tools",
"kind" : "remoteSourceControl",
@@ -1,202 +0,0 @@
// Configurable Home-Screen / Lock-Screen library widget (kind "PunktfunkLibrary"). The user picks
// a saved host in the widget's configuration (long-press Edit Widget the picker is
// `HostEntity`'s query over the shared App-Group store, running in this extension process); a tap
// deep-links into that host's game library via `punktfunk://browse/<uuid>` the app's onOpenURL
// routes it to the same library presentation every internal surface drives. No session starts
// until a title is picked there.
//
// Unconfigured, it follows the most recently connected host (the same order the hosts widget
// leads with). A configured host that no longer exists shows the empty state rather than silently
// following a different host a widget that says "Studio" must never open someone else's library.
//
// Timeline is a single `.never` entry the app pushes reloads on store changes (HostStore
// WidgetCenter.reloadTimelines), exactly like the hosts widget.
import AppIntents
import SwiftUI
import WidgetKit
import PunktfunkShared
// MARK: - Configuration intent
/// The widget's per-instance configuration. Executes in the EXTENSION process which is why
/// `HostEntity` and its query live in PunktfunkShared, not the app.
struct LibraryWidgetConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "Choose Host"
static let description = IntentDescription("Pick whose game library this widget opens.")
@Parameter(title: "Host", description: "Leave empty to follow your most recent host.")
var host: HostEntity?
}
// MARK: - Timeline
struct LibraryEntry: TimelineEntry {
let date: Date
/// The resolved target: the configured host if it still exists, the most recent one when
/// unconfigured, nil when there's nothing to open (empty store, or a removed configured host).
let host: StoredHost?
}
struct LibraryProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> LibraryEntry {
LibraryEntry(date: .now, host: nil)
}
func snapshot(for configuration: LibraryWidgetConfigIntent, in context: Context) async
-> LibraryEntry {
LibraryEntry(date: .now, host: Self.resolve(configuration.host))
}
func timeline(for configuration: LibraryWidgetConfigIntent, in context: Context) async
-> Timeline<LibraryEntry> {
// Single entry, never auto-refresh: the app reloads this timeline on every store change.
Timeline(entries: [LibraryEntry(date: .now, host: Self.resolve(configuration.host))],
policy: .never)
}
/// The configured host by id nil (NOT a fallback) when it's gone; most-recent when nothing
/// was configured.
static func resolve(_ configured: HostEntity?) -> StoredHost? {
let hosts = HostsProvider.loadHosts() // shared-suite JSON, most-recent first
guard let configured else { return hosts.first }
return hosts.first { $0.id == configured.id }
}
}
// MARK: - Widget
struct LibraryWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: "PunktfunkLibrary", intent: LibraryWidgetConfigIntent.self,
provider: LibraryProvider()
) { entry in
LibraryWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Game Library")
.description("Jump straight into a host's game library.")
.supportedFamilies([.systemSmall, .accessoryCircular, .accessoryRectangular])
}
}
// MARK: - Views
/// Deep link that opens a stored host's library.
private func browseURL(_ host: StoredHost) -> URL {
DeepLink.browse(host: host.id).url
}
struct LibraryWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: LibraryEntry
var body: some View {
switch family {
case .accessoryCircular:
CircularLibraryView(host: entry.host)
case .accessoryRectangular:
RectangularLibraryView(host: entry.host)
default: // systemSmall + fallback
SmallLibraryView(host: entry.host)
}
}
}
private struct SmallLibraryView: View {
let host: StoredHost?
var body: some View {
if let host {
VStack(alignment: .leading, spacing: 6) {
Image(systemName: "square.grid.2x2.fill")
.font(.title2)
.foregroundStyle(Color.brand)
Spacer(minLength: 0)
Text(host.displayName)
.font(.headline)
.lineLimit(2)
Text("Game Library")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.widgetURL(browseURL(host))
} else {
EmptyLibraryView()
}
}
}
private struct CircularLibraryView: View {
let host: StoredHost?
var body: some View {
ZStack {
AccessoryWidgetBackground()
Image(systemName: "square.grid.2x2.fill")
}
.widgetURL(host.map(browseURL))
}
}
private struct RectangularLibraryView: View {
let host: StoredHost?
var body: some View {
HStack {
Image(systemName: "square.grid.2x2.fill")
VStack(alignment: .leading) {
Text(host?.displayName ?? "Punktfunk")
.lineLimit(1)
Text("Library")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.widgetURL(host.map(browseURL))
}
}
private struct EmptyLibraryView: View {
var body: some View {
VStack(spacing: 6) {
Image(systemName: "square.grid.2x2")
.font(.title2)
.foregroundStyle(.secondary)
Text("Open Punktfunk to pick a host.")
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Previews (Xcode canvas)
//
// Same pattern as the hosts widget: `#Preview(as:widget:timeline:)` feeds sample entries directly,
// so the canvas works without a paired device or saved hosts. The small preview's second entry
// shows the empty state one timeline click away.
private let previewHost = StoredHost(
name: "Studio", address: "192.168.1.20",
lastConnected: .now.addingTimeInterval(-40 * 60))
#Preview("Small", as: .systemSmall) {
LibraryWidget()
} timeline: {
LibraryEntry(date: .now, host: previewHost)
LibraryEntry(date: .now, host: nil)
}
#Preview("Lock Screen circular", as: .accessoryCircular) {
LibraryWidget()
} timeline: {
LibraryEntry(date: .now, host: previewHost)
}
#Preview("Lock Screen rectangular", as: .accessoryRectangular) {
LibraryWidget()
} timeline: {
LibraryEntry(date: .now, host: previewHost)
}
@@ -15,7 +15,6 @@ import WidgetKit
struct PunktfunkWidgetBundle: WidgetBundle {
var body: some Widget {
HostsWidget()
LibraryWidget()
PunktfunkSessionLiveActivity()
}
}
@@ -56,12 +56,6 @@ struct ContentView: View {
/// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from
/// the session model's published state below; iPhone/iPad only.
@State private var liveActivity = SessionActivityController()
/// The window's bottom safe-area inset (the home-indicator strip), reported by
/// DisplayBottomInsetProbe from UIKit's own callbacks and published as
/// `\.displayBottomInset` for the screens that pin a legend to the display's corner. Held
/// HERE and read through the environment because asking UIKit for it during a body severs
/// the asking view's updates on device (see the probe).
@State private var displayBottomInset: CGFloat = 0
#endif
@State private var pairingTarget: StoredHost?
/// A fresh `pair=required`/unknown host the user tapped: drives the choice between no-PIN
@@ -121,23 +115,6 @@ struct ContentView: View {
/// scenePhase drives the keep-alive: use THIS, not the willResignActive observers resign-active
/// also fires for Control Center / app-switcher peeks, where the disconnect timer must not start.
@Environment(\.scenePhase) private var scenePhase
#if os(iOS)
@Environment(\.horizontalSizeClass) private var hSizeClass
@Environment(\.verticalSizeClass) private var vSizeClass
#endif
/// The gamepad UI's form-metric tier for this window, published from HERE the app's root.
/// A screen that applies `gamepadPaletteInk` itself sits ABOVE its own copy of the environment,
/// so its `@Environment` resolves against its parent; publishing at the root is what makes
/// every one of them (including the ones presented as sheets and covers, which inherit the
/// environment) read its own window's tier instead of the bare default.
private var gamepadMetrics: GamepadFormMetrics {
#if os(iOS)
.forWindow(h: hSizeClass, v: vSizeClass)
#else
.platformDefault
#endif
}
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
@@ -204,36 +181,6 @@ struct ContentView: View {
}
private var driven: some View {
drivenBase
.environment(\.gamepadMetrics, gamepadMetrics)
#if os(iOS)
.environment(\.displayBottomInset, displayBottomInset)
// The probe is UIKit's, not any screen's: mounted once here as a background so the
// legend-pinning screens can READ the inset from the environment without ever asking
// UIKit during their own body (which severs their updates see the probe).
.background {
DisplayBottomInsetProbe { displayBottomInset = $0 }
}
#endif
#if os(iOS) || os(macOS)
// The console's own modal, over WHICHEVER screen is up. Not attached to `home`, which
// renders only while `model.connection == nil`: a connection exists through the
// pair-required and approval handshakes, which is precisely when these prompts fire.
// It sits above the connect takeover too the delegated-approval wait is raised
// DURING a dial and owns the only Cancel for it. (The takeover draws nothing in that
// state: `connectingOverlayName` is nil while `awaitingApproval` is set, so the two
// never poll the pad at once.)
.overlay {
if let prompt = consolePrompt {
GamepadPromptView(prompt: prompt)
.gamepadPaletteInk()
.transition(.opacity)
}
}
#endif
}
private var drivenBase: some View {
Group {
// The stream view's structural identity MUST be stable across the
// awaiting-trust streaming transition: recreating it restarts the pump,
@@ -421,21 +368,8 @@ struct ContentView: View {
// (the "Pair with PIN instead" path disconnects first the host's accept loop
// is sequential, a pairing connection would queue behind the live session).
#if !os(tvOS)
// macOS presents BOTH pairing UIs from here, picking by mode (the console UI's screen is
// gamepad-navigable; PairSheet's Form is not). iOS hides this sheet in gamepad mode
// instead there the pair screen is one of the shell's in-place layers, exactly like
// settings and add-host (see `touchPairingTarget`).
.sheet(item: touchPairingTarget) { host in
#if os(macOS)
if gamepadUIActive {
GamepadPairView(host: host, onPaired: { handlePaired(host, fingerprint: $0) })
.frame(width: 660, height: 620)
} else {
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
}
#else
.sheet(item: $pairingTarget) { host in
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
#endif
}
.sheet(item: $speedTestTarget) { host in
SpeedTestSheet(host: host)
@@ -475,102 +409,9 @@ struct ContentView: View {
// budget (inline, they tip SwiftUI's per-expression limit see the split sections idiom).
private var deepLinkNoticePresented: Binding<Bool> {
Binding(
get: { deepLinkNotice != nil && !consolePromptShowing },
set: { if !$0 { deepLinkNotice = nil } })
Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } })
}
/// True while the console prompt owns the modal state (see `consolePrompt`). Always false on
/// tvOS, whose alerts the focus engine drives natively.
private var consolePromptShowing: Bool {
#if os(iOS) || os(macOS)
consolePrompt != nil
#else
false
#endif
}
#if os(iOS) || os(macOS)
/// The modal state the console UI should present ITSELF, as a pad-navigable prompt, instead of
/// letting a system alert take it. `.alert`/`.confirmationDialog` are UIKit/AppKit surfaces a
/// controller cannot navigate, and these are not incidental prompts: "Pairing required" is the
/// FIRST thing an unpaired host shows, "Connection failed" strands the console UI behind a
/// modal only a finger can dismiss, and "Waiting for approval" owns the only Cancel for a
/// connect that may never complete. One at a time, most-urgent first a system alert stack
/// would layer these, but a console shows one screen.
///
/// Gated on not STREAMING, not on `model.connection == nil`: a connection object exists well
/// before a stream does, through exactly the handshakes these prompts belong to. Streaming is
/// the one case that must stay with the system alert there the pad belongs to
/// `GamepadCapture` and is being forwarded to the host.
private var consolePrompt: GamepadPrompt? {
guard gamepadUIActive, model.phase != .streaming else { return nil }
if let req = approvalChoice {
return GamepadPrompt(
id: "pairing-required",
title: "Pairing required",
message: "\(req.host.displayName) requires pairing. Request access and approve "
+ "this device in the host's web console (port 47992 → Pairing) — no PIN "
+ "needed. Or pair with the 4-digit PIN it can display.",
actions: [
// The follow-on presentation is deferred a tick exactly as the system dialog
// does it, so this prompt is fully torn down before the next screen mounts
// two controller pollers overlapping for a frame is how one A press reaches
// both.
GamepadPromptAction(id: "request", title: "Request Access", isPrimary: true) {
approvalChoice = nil
DispatchQueue.main.async { requestAccess(req) }
},
GamepadPromptAction(id: "pin", title: "Pair with PIN…") {
approvalChoice = nil
DispatchQueue.main.async { pairingTarget = req.host }
},
GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) {
approvalChoice = nil
},
])
}
if let req = awaitingApproval {
return GamepadPrompt(
id: "awaiting-approval",
title: "Waiting for approval",
message: "Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s "
+ "web console (port 47992 → Pairing). This device connects automatically "
+ "once you approve it — no need to reconnect.",
actions: [
GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) {
awaitingApproval = nil
model.disconnect()
},
],
busy: true)
}
if connectionErrorReady {
return GamepadPrompt(
id: "connection-failed",
title: "Connection failed",
message: model.errorMessage ?? "",
actions: [
GamepadPromptAction(id: "ok", title: "OK", isCancel: true) {
model.errorMessage = nil
},
])
}
if let notice = deepLinkNotice {
return GamepadPrompt(
id: "cant-open",
title: "Can't open",
message: notice,
actions: [
GamepadPromptAction(id: "ok", title: "OK", isCancel: true) {
deepLinkNotice = nil
},
])
}
return nil
}
#endif
/// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents
/// the library in place (see the cover's comment).
private var touchLibraryTarget: Binding<StoredHost?> {
@@ -579,37 +420,19 @@ struct ContentView: View {
set: { libraryTarget = $0 })
}
/// The pairing sheet's item. On iOS it hides while the gamepad shell presents the pair screen
/// in place the same proxy the library uses, and for the same reason: every writer keeps
/// writing `pairingTarget`, and whichever presentation the current mode owns picks it up.
/// macOS has no shell, so the sheet stays and switches its CONTENT by mode instead.
private var touchPairingTarget: Binding<StoredHost?> {
#if os(macOS)
Binding(get: { pairingTarget }, set: { pairingTarget = $0 })
#else
Binding(
get: { gamepadUIActive ? nil : pairingTarget },
set: { pairingTarget = $0 })
#endif
}
private var approvalChoicePresented: Binding<Bool> {
Binding(
get: { approvalChoice != nil && !consolePromptShowing },
set: { if !$0 { approvalChoice = nil } })
Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } })
}
private var awaitingApprovalPresented: Binding<Bool> {
Binding(
get: { awaitingApproval != nil && !consolePromptShowing },
set: { if !$0 { awaitingApproval = nil } })
Binding(get: { awaitingApproval != nil }, set: { if !$0 { awaitingApproval = nil } })
}
/// Whether the "Connection failed" state is ready to be shown at all shared by the system
/// alert and the console prompt so the two can never disagree about the macOS deferral below.
private var connectionErrorReady: Bool {
guard model.errorMessage != nil else { return false }
#if os(macOS)
private var connectionErrorPresented: Binding<Bool> {
Binding(
get: {
guard model.errorMessage != nil else { return false }
#if os(macOS)
// Defer the alert while a forced-fullscreen exit is still pending: a sheet
// attached to a fullscreen window makes AppKit drop `-toggleFullScreen:`, so
// presenting it now strands the window fullscreen on the home screen after a
@@ -618,14 +441,10 @@ struct ContentView: View {
// once the window leaves fullscreen and `isFullscreen` flips, the alert shows
// over the windowed home UI. Not gated when fullscreen is the user's own manual
// choice (opt-out setting) nothing is auto-exiting there to conflict with.
if fullscreenForSession && isFullscreen { return false }
#endif
return true
}
private var connectionErrorPresented: Binding<Bool> {
Binding(
get: { connectionErrorReady && !consolePromptShowing },
if fullscreenForSession && isFullscreen { return false }
#endif
return true
},
set: { if !$0 { model.errorMessage = nil } })
}
@@ -668,20 +487,10 @@ struct ContentView: View {
?? "That link is malformed and was ignored."
return
}
switch link.route {
case .connect:
break
case .browse:
// The reserved library route, now real: open the host's game library without starting
// a session. `launch=`/`profile=` are meaningless on a browse (nothing streams until a
// title is picked, and that connect resolves its own profile) ignored, not refused,
// per the unknown-parameter rule.
openLibrary(from: link)
return
case .wake:
// Still reserved: saying so beats silently connecting instead. (Shortcuts users have
// the Wake Host intent, which never round-trips through a URL.)
deepLinkNotice = "Punktfunk links can't do “wake” yet."
guard link.route == .connect else {
// `wake` and `browse` are reserved in the grammar and parse today; this build routes
// neither, and saying so beats silently connecting instead.
deepLinkNotice = "Punktfunk links can't do “\(link.route.rawValue)” yet."
return
}
// Resolve the one-off profile BEFORE anything happens: an unknown or ambiguous reference
@@ -735,38 +544,6 @@ struct ContentView: View {
}
}
/// `punktfunk://browse/<host-ref>` jump into a host's game library. Drives the SAME
/// `libraryTarget` every internal surface writes, so the link lands in whichever presentation
/// the current mode owns: the gamepad console's in-place library screen, the touch cover, the
/// macOS sheet, or tvOS's cover. Connect's posture minus the connect itself: a pin conflict
/// refuses, a live session is never preempted, and an unsaved host can't be browsed the
/// library fetch rides the paired mTLS identity, so there is nothing to show before the host
/// is saved (the notice says what to do instead).
private func openLibrary(from link: DeepLink) {
switch link.resolveHost(in: store.hosts) {
case .known(let host):
guard !link.pinConflict(with: host) else {
deepLinkNotice = "That link's fingerprint doesn't match the identity saved for "
+ "\(host.displayName). It's out of date, or it isn't pointing where it says."
return
}
guard model.phase == .idle else {
let current = model.activeHost?.displayName ?? "a host"
deepLinkNotice = "Already streaming \(current). End that session first."
return
}
libraryTarget = host
case .unknown(let address, _, let name, _):
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
+ "Add it with the + button first — a library can only be browsed on a saved host."
case .ambiguous:
deepLinkNotice = "More than one saved host is called “\(link.hostRef)”. "
+ "Rename one, or link to it by its address."
case .unresolvable:
deepLinkNotice = "That host isn't saved on this device."
}
}
private var home: some View {
// The full-screen connect takeover rides over BOTH home UIs (and the pre-connect window is
// still `home`, so it covers the whole dial wake online connect sequence): instant
@@ -799,11 +576,9 @@ struct ContentView: View {
if gamepadUIActive {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, pairingTarget: $pairingTarget,
onPaired: handlePaired, waker: waker,
libraryTarget: $libraryTarget, waker: waker,
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
launchTitle: launchTitle,
promptActive: consolePromptShowing)
launchTitle: launchTitle)
} else {
HomeView(
store: store, model: model, discovery: discovery,
@@ -818,11 +593,9 @@ struct ContentView: View {
if gamepadUIActive {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, pairingTarget: $pairingTarget,
onPaired: handlePaired, waker: waker,
libraryTarget: $libraryTarget, waker: waker,
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
launchTitle: launchTitle,
promptActive: consolePromptShowing)
launchTitle: launchTitle)
// On tvOS pairing/library normally present from HomeView's navigationDestinations
// which aren't mounted while the gamepad launcher is up. Give the launcher its
// own presenters (exactly one of the two homes is mounted at a time, so these can
@@ -13,8 +13,6 @@ import SwiftUI
struct GamepadAddHostView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.dismiss) private var dismiss
@Environment(\.gamepadHostedInShell) private var hostedInShell
let onAdd: (StoredHost) -> Void
@@ -50,7 +48,7 @@ struct GamepadAddHostView: View {
isActive: controllerActive && editing == nil
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: metrics.rowMaxWidth)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
@@ -63,28 +61,25 @@ struct GamepadAddHostView: View {
if !compact {
Text("Hosts on this network appear automatically — add one by address "
+ "for everything else.")
.font(.geist(metrics.detailFont, relativeTo: .caption))
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.multilineTextAlignment(.leading)
.frame(maxWidth: metrics.rowMaxWidth * 0.72, alignment: .leading)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72, alignment: .leading)
}
}
.padding(.horizontal, 24)
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayBlur(edge: .top) }
.background { GamepadTrayScrim(edge: .top) }
}
.safeAreaInset(edge: .bottom, spacing: 0) {
bottomTray
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
.padding(.horizontal, compact ? 12 : 18)
.padding(
.bottom,
gamepadLegendBottomPadding(
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
.padding(.bottom, compact ? 12 : 18)
.padding(.top, compact ? 6 : 10)
.background { GamepadTrayBlur(edge: .bottom) }
.background { GamepadTrayScrim(edge: .bottom) }
}
// No aurora the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
// Hosted in the shell, the field is the shell's (see GamepadSettingsView's twin).
@@ -153,28 +148,17 @@ struct GamepadAddHostView: View {
// binding on appear new identity forces a rewire to the new field.
.id(editing)
GamepadHintBar(hints: [
// "Type" names what A does to the key under the keyboard's cursor. There is
// no tap equivalent a touch user types by tapping the keycap itself so
// this one cell stays a label.
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
.init(
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
action: { backspace(editing) }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
action: { closeKeyboard() }),
.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
])
.frame(maxWidth: .infinity, alignment: .leading)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
} else {
GamepadHintBar(hints: [
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
action: { if let focusID { activate(id: focusID) } }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel",
action: { performClose() }),
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel"),
])
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -207,7 +191,7 @@ struct GamepadAddHostView: View {
}
private func rowView(_ row: Row, focused: Bool) -> some View {
let m = metrics
let m = GamepadFormMetrics.self
return HStack(spacing: 14) {
if row.isAction {
Label("Add Host", systemImage: "plus.circle.fill")
@@ -284,15 +268,6 @@ struct GamepadAddHostView: View {
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = nil }
}
/// The legend's Delete cell (iOS/macOS). Applied to the field's binding rather than routed
/// into `GamepadKeyboard`: the keyboard's X does exactly this to the same binding, and
/// reaching into its state to trigger it would need a whole callback channel for one edit.
private func backspace(_ id: String) {
let binding = editingBinding(id)
guard !binding.wrappedValue.isEmpty else { return }
binding.wrappedValue.removeLast()
}
private func editingBinding(_ id: String) -> Binding<String> {
switch id {
case "name": return $name
@@ -191,16 +191,6 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
.sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
#if os(iOS) || os(macOS)
// A hardware keyboard drives the same cursor as the pad arrows step, Return activates,
// Esc backs out (iPad on a Magic Keyboard, couch Mac). tvOS routes arrows through the
// focus engine instead, which owns navigation there.
.gamepadKeyNavigation(
active: isActive,
onMove: { move($0) },
onConfirm: { activate() },
onBack: onBack)
#endif
.onAppear {
reconcile()
wire()
@@ -442,8 +432,8 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
/// one, so the strip FANS OPEN from the cursor rather than sweeping past it; the anchor card
/// itself only grows, since it is already facing you. Each card carries its own delay (see
/// `entrance(_:)`) that stagger is what makes the strip read as one gesture instead of a
/// simultaneous flash, and it is the same hinge language the coverflow's own recede speaks, so the
/// arrival and the scrolling feel like one object.
/// simultaneous flash, and it is the same hinge/perspective language the coverflow's own recede
/// speaks, so the arrival and the scrolling feel like one object.
///
/// APPLY THIS UNDERNEATH THE CARD'S OWN `.scrollTransition`, never around it. A scroll
/// transition derives its phase from the geometry of the view it wraps, so an entrance layered
@@ -452,23 +442,6 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
/// collapsed into its focused look as the entrance ended arriving as a jump. Underneath, the
/// transition measures a card that never moves and simply composes its own scale/rotation on top.
///
/// NO `rotation3DEffect` HERE, however much the drum language invites one. It was the cause of
/// the strip's "flash as the cards settle": a real 3D transform renders the card through an
/// offscreen layer, and a card carries translucent glass, which resolves differently in there
/// so every card sat at the wrong fill for as long as the master animation ran and then snapped
/// to its true one in a SINGLE frame the moment SwiftUI dropped that layer.
///
/// Measured on an iPad Pro 13": the centred tile held #4a3d87 for twelve frames in which nothing
/// moved, then stepped to #423970 (23 blue) in one. It is the ANIMATION ending, not the motion:
/// stretching the timeline from 1.02 s to 2.82 s moved the step from 0.70 s to 2.50 s after the
/// launcher appeared the same 0.32 s before the end both times. Removing the rotation removed
/// the step outright; `compositingGroup()` above or below the transforms did nothing.
///
/// So the turn is PROJECTED instead: `cos(angle)` as a horizontal squeeze is exactly the
/// orthographic projection of a Y-axis rotation, hinged on the edge the card fans from. Affine,
/// so no offscreen pass and no layer to drop and it reads as the same gesture, losing only the
/// perspective trapezoid, which at these card sizes was never what sold the motion.
///
/// Transforms only nothing here touches layout, so the scroll view's snapping and the tvOS
/// focus engine are untouched either. Reduce Motion drops every bit of travel for a plain,
/// unstaggered cross-fade.
@@ -512,15 +485,14 @@ struct CardEntrance: ViewModifier, Animatable {
// leading edge), so the arrival deepens the turn the card wears at rest and unwinds into
// it instead of swinging the opposite way.
let away = reduceMotion ? 0 : 1 - travel
// The turn, projected rather than rendered in 3D see the type's note on the flash.
// `cos` of the angle IS the orthographic projection of a Y-axis rotation, and hinging it
// on the edge the card fans from restores the direction that the rotation's sign carried
// (cos is even, so the sign alone would read the same both ways).
let turn = cos(Angle.degrees(64 * away).radians)
return content
.opacity(reduceMotion ? raw : fade)
.scaleEffect(1 - 0.26 * away)
.scaleEffect(x: turn, y: 1, anchor: side < 0 ? .trailing : .leading)
.rotation3DEffect(
.degrees(side * -64 * away),
axis: (x: 0, y: 1, z: 0),
anchor: .center,
perspective: 0.65)
.offset(y: 34 * away)
}
@@ -5,36 +5,20 @@
// iOS/iPadOS, macOS (the couch Mac-mini case), and tvOS where the same screens are driven by
// the native focus engine instead of the controller poll (see GamepadCarousel/GamepadMenuList).
import Glur
import GlurBackdrop
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
import GameController
/// The glyph a button wears in a legend: the ACTIVE controller's own (Xbox "A", DualSense , )
/// via `sfSymbolsName` while one is attached, else the glyph of the last pad this device ever saw
/// (`GamepadManager.lastKnownKind` `GamepadGlyphs`), else the caller's generic fallback.
///
/// The middle rung is the whole point. `active` is nil whenever the pad sleeps, disconnects or
/// runs flat and permanently under `gamepadUIMode == "always"`, which puts the console UI up
/// with no pad by design and the fallbacks are letter glyphs, so a DualSense user's / legends
/// used to turn into A/B the moment the controller dozed off. The remembered kind keeps the
/// legends speaking the pad the user actually owns. The `fallback` still covers the genuinely
/// unknown case: a fresh install that has never seen a controller, and any button outside the six
/// `GamepadButtonRole` names.
///
/// The active controller's real glyph for a button (Xbox "A", DualSense , ) via
/// `sfSymbolsName`; a generic fallback before a controller profile resolves.
/// @MainActor: GamepadManager is main-actor-bound (inside a View body this was implicit).
@MainActor
func buttonGlyph(
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
) -> String {
let manager = GamepadManager.shared
if let live = manager.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName {
return live
}
guard let role = GamepadButtonRole(keyPath: button) else { return fallback }
return GamepadGlyphs.symbol(role, for: manager.lastKnownKind)
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
?? fallback
}
/// Top padding for a gamepad screen's pinned title. macOS gets extra clearance the launcher
@@ -84,251 +68,43 @@ func gamepadTitleSize(compact: Bool) -> CGFloat {
}
/// Metrics shared by the gamepad form screens' glass rows (GamepadSettingsView,
/// GamepadAddHostView) one set of numbers so the screens read as the same surface, at the size
/// the screen they are on calls for.
///
/// Three tiers, not two. The phone numbers used to serve every non-TV device, so an iPad Pro drew
/// a settings list at iPhone scale in the middle of a 13" display the field verdict was that the
/// sizing "does not adapt to larger screens". `pad` sits between the in-hand and 10-foot sets.
///
/// Chosen from the SIZE CLASSES rather than the device idiom, so an iPad running a narrow Stage
/// Manager or Split View window correctly gets the in-hand numbers the window is what the user
/// is reading, not the panel it sits on.
struct GamepadFormMetrics {
/// Which set this is, for the few things that are a KIND of layout rather than a number.
enum Tier { case phone, pad, tv }
let tier: Tier
let headerFont: CGFloat
let labelFont: CGFloat
let valueFont: CGFloat
let iconFont: CGFloat
let iconWidth: CGFloat
let chevronFont: CGFloat
let rowHPad: CGFloat
let rowVPad: CGFloat
let rowCorner: CGFloat
let rowMaxWidth: CGFloat
let detailFont: CGFloat
/// GamepadAddHostView) one set of numbers so the two screens read as the same surface,
/// sized for the couch on tvOS and for the hand elsewhere.
enum GamepadFormMetrics {
#if os(tvOS)
static let headerFont: CGFloat = 17
static let labelFont: CGFloat = 23
static let valueFont: CGFloat = 21
static let iconFont: CGFloat = 24
static let iconWidth: CGFloat = 40
static let chevronFont: CGFloat = 16
static let rowHPad: CGFloat = 24
static let rowVPad: CGFloat = 19
static let rowCorner: CGFloat = 18
static let rowMaxWidth: CGFloat = 920
static let detailFont: CGFloat = 19
static let bandWidth: CGFloat = 380
#else
static let headerFont: CGFloat = 12
static let labelFont: CGFloat = 16
static let valueFont: CGFloat = 15
static let iconFont: CGFloat = 17
static let iconWidth: CGFloat = 28
static let chevronFont: CGFloat = 12
static let rowHPad: CGFloat = 16
static let rowVPad: CGFloat = 13
static let rowCorner: CGFloat = 14
static let rowMaxWidth: CGFloat = 620
static let detailFont: CGFloat = 13
/// The option band's (GamepadOptionBand) fixed stage inside a choice row.
let bandWidth: CGFloat
/// The settings screen's section-tab pills.
let tabFont: CGFloat
/// The pinned controls legend (GamepadHintBar).
let hintGlyphFont: CGFloat
let hintTextFont: CGFloat
let hintPad: CGFloat
/// In-hand: a phone, or any window narrow enough to read like one.
static let phone = GamepadFormMetrics(
tier: .phone,
headerFont: 12, labelFont: 16, valueFont: 15, iconFont: 17, iconWidth: 28,
chevronFont: 12, rowHPad: 16, rowVPad: 13, rowCorner: 14, rowMaxWidth: 620,
detailFont: 13, bandWidth: 240,
tabFont: 13, hintGlyphFont: 19, hintTextFont: 14, hintPad: 13)
/// A tablet-sized window an arm's length away rather than in the palm.
static let pad = GamepadFormMetrics(
tier: .pad,
headerFont: 14, labelFont: 20, valueFont: 19, iconFont: 21, iconWidth: 34,
chevronFont: 14, rowHPad: 20, rowVPad: 16, rowCorner: 16, rowMaxWidth: 820,
detailFont: 16, bandWidth: 320,
tabFont: 16, hintGlyphFont: 23, hintTextFont: 17, hintPad: 15)
/// 10-foot.
static let tv = GamepadFormMetrics(
tier: .tv,
headerFont: 17, labelFont: 23, valueFont: 21, iconFont: 24, iconWidth: 40,
chevronFont: 16, rowHPad: 24, rowVPad: 19, rowCorner: 18, rowMaxWidth: 920,
detailFont: 19, bandWidth: 380,
tabFont: 17, hintGlyphFont: 27, hintTextFont: 20, hintPad: 18)
/// What a screen gets before anything publishes a tier and the only tier tvOS and macOS ever
/// use (an Apple TV is always 10-foot; a Mac window is read at desk distance).
static var platformDefault: GamepadFormMetrics {
#if os(tvOS)
tv
#else
phone
#endif
}
#if os(iOS)
/// The tier a window's size classes call for. REGULAR on both axes is the tablet case.
static func forWindow(
h: UserInterfaceSizeClass?, v: UserInterfaceSizeClass?
) -> GamepadFormMetrics {
h == .regular && v == .regular ? .pad : .phone
}
static let bandWidth: CGFloat = 240
#endif
}
private struct GamepadMetricsKey: EnvironmentKey {
static let defaultValue = GamepadFormMetrics.platformDefault
}
extension EnvironmentValues {
/// The form metrics for the screen currently drawing. Published from ContentView the app
/// ROOT rather than only from `gamepadPaletteInk`, because a screen that applies that
/// modifier itself sits ABOVE its own copy: its `@Environment` resolves against its parent, so
/// it would read the bare default instead of its own window's tier.
var gamepadMetrics: GamepadFormMetrics {
get { self[GamepadMetricsKey.self] }
set { self[GamepadMetricsKey.self] = newValue }
}
}
private struct DisplayBottomInsetKey: EnvironmentKey {
static let defaultValue: CGFloat = 0
}
extension EnvironmentValues {
/// The display's bottom safe-area inset the home-indicator strip measured by
/// `DisplayBottomInsetProbe` and published from ContentView. 0 until UIKit's first callback
/// lands (the legend keeps its plain margin for that first frame) and always 0 on
/// macOS/tvOS, where nothing publishes it.
var displayBottomInset: CGFloat {
get { self[DisplayBottomInsetKey.self] }
set { self[DisplayBottomInsetKey.self] = newValue }
}
}
#if os(iOS)
/// Reports the hosting window's bottom safe-area inset from UIKit's OWN callbacks never
/// during a SwiftUI render.
///
/// This number has a history of wrong spellings, each failing silently:
/// - a `GeometryReader` carrying `.ignoresSafeArea()` a proxy reports NO insets for an edge it
/// has been told to ignore, so that spelling can only ever answer 0;
/// - `.ignoresSafeArea(.container, edges: .bottom)` on `safeAreaInset` CONTENT, which does not
/// move content the inset mechanism itself placed; and
/// - asking UIKit for the key window (`UIApplication.shared.connectedScenes`) DURING body,
/// which answered correctly and then KILLED the calling view: on an iPad (never the
/// simulator) the walk re-enters UIKit layout mid-render and the view's update graph is
/// silently severed every later `@State` write lands in storage without ever re-running
/// `body` again, which is how Settings and Add Host stopped opening while their triggers
/// kept firing. No AttributeGraph warning, no log line; found by bisecting builds on glass.
/// So: UIKit tells THIS view when the window or its insets change, on UIKit's schedule, and the
/// answer hops out of the current update before anyone in SwiftUI reads it.
struct DisplayBottomInsetProbe: UIViewRepresentable {
let onChange: (CGFloat) -> Void
func makeUIView(context: Context) -> ProbeView {
let view = ProbeView()
view.onChange = onChange
// Mounted as a full-size `.background`; it must never eat a touch meant for the UI.
view.isUserInteractionEnabled = false
return view
}
func updateUIView(_ view: ProbeView, context: Context) {
view.onChange = onChange
}
final class ProbeView: UIView {
var onChange: ((CGFloat) -> Void)?
private var last: CGFloat?
override func didMoveToWindow() {
super.didMoveToWindow()
report()
}
override func safeAreaInsetsDidChange() {
super.safeAreaInsetsDidChange()
report()
}
// Rotation reshuffles the window's insets without necessarily touching this view's own.
override func layoutSubviews() {
super.layoutSubviews()
report()
}
private func report() {
// The WINDOW's inset, not this view's: the probe sits inside the safe area, so its
// own inset is 0 the number the legend needs is the strip the window reserves.
guard let bottom = window?.safeAreaInsets.bottom, bottom != last else { return }
last = bottom
let onChange = onChange
// Out of the current UIKit/SwiftUI update before any state write.
DispatchQueue.main.async { onChange?(bottom) }
}
}
}
#endif
/// The bottom padding that puts a pinned legend the same distance from the bottom of the DISPLAY
/// as it sits from the leading edge so it lands on the diagonal of the display's rounded corner,
/// which is what the corner asks for.
///
/// A `safeAreaInset` places its content INSIDE the safe area, so a plain margin stacks on top of
/// the device's own bottom inset and the pill ends up two to three times further from the bottom
/// than from the left. On a tablet this therefore goes NEGATIVE, pulling the pill back down
/// through the home-indicator strip; the pill is left-aligned and an iPad's indicator is a short
/// bar in the middle, so the two never meet.
///
/// Phones keep the plain margin. Their inset is the taller indicator bar and their legend runs
/// most of the width, so sitting it that low would cross the indicator rather than tuck beside it.
///
/// `displayBottom` is `\.displayBottomInset` measured by `DisplayBottomInsetProbe`, NEVER asked
/// of UIKit here: this runs during body, and a key-window walk mid-render severs the calling
/// view's updates (see the probe's comment). Pure arithmetic only.
func gamepadLegendBottomPadding(
_ margin: CGFloat, tier: GamepadFormMetrics.Tier, displayBottom: CGFloat
) -> CGFloat {
guard tier == .pad else { return margin }
// Floored at -inset: at worst the pill sits flush with the physical edge, never past it.
return max(-displayBottom, margin - displayBottom)
}
/// The tray gradient blur, back as a real progressive BACKDROP blur this time.
///
/// GamepadTrayScrim did this with `.ultraThinMaterial`, and a material by definition lifts and
/// tints whatever it blurs: it read grey over the aurora, and washed with the palette's ground it
/// read coloured, which is why 2590238b deleted it. Glur's `GlurView` blurs the backdrop through
/// a gradient with NO material stage on top, so the rows soften as they slide under the pinned
/// title and legend and nothing carries a colour. It is the library's PRIVATE-API product
/// (`GlurBackdrop`) the public `.glur()` modifier is a shader on a view's own content and
/// silently no-ops over platform-backed views like ScrollView, so it cannot reach a backdrop at
/// all. Hit testing is disabled inside GlurView; the band never eats a touch.
///
/// Mounted exactly where the scrim was: `.background` of each form screen's safe-area tray.
struct GamepadTrayBlur: View {
let edge: VerticalEdge
var body: some View {
// offset 0 puts the ramp's LITERAL ZERO exactly at the band's content edge, so nothing
// in the open field is touched which is why, unlike the scrim, this band takes NO
// content-side overhang. The scrim's -44/-72 runway existed because a material carries
// body at every alpha and had to dissolve OUTSIDE the tray; carrying those numbers over
// here blurred fully-visible rows at rest (field verdict on the first cut). Full
// strength lands at 60% of the band, so the tray's own text always sits on the strong
// region while the ramp still reads as a gradient, not an edge.
GlurView(
radius: 14, offset: 0, interpolation: 0.6,
direction: edge == .top ? .up : .down)
// Full-bleed by LAYOUT, not `.ignoresSafeArea()`: safe-area expansion resolves a
// beat after insertion (outside any geometry group and outside this view's own
// transaction), which reads as a visible pop. 80 pt clears every inset on every
// device, and backgrounds never clip the overhang simply draws.
.padding(edge == .top ? .top : .bottom, -80)
.padding(.horizontal, -80)
// And the shape must NEVER animate: mounted inside a pushed shell layer, any late
// geometry would ride the push's transaction and visibly grow into place. The
// layer's own fade/slide still carries the band; only its SHAPE is pinned.
.transaction { $0.animation = nil }
}
}
/// One glyph + label cell in a hint bar.
struct GamepadHint: Identifiable {
let glyph: String
let text: String
/// What tapping/clicking this cell does the same thing its button does. Optional because a
/// few legend cells NAME an input rather than an action (" Adjust" is the stick itself;
/// there is no single thing a tap on it could mean), and those stay inert labels.
var action: (() -> Void)? = nil
var id: String { glyph + text }
}
@@ -338,75 +114,39 @@ struct GamepadHint: Identifiable {
/// the backdrop instead of dissolving into it.
struct GamepadHintBar: View {
@Environment(\.gamepadInk) private var ink
/// Sized with the screen it pins to a legend at phone scale on a 13" iPad is the same
/// mismatch the form rows had (see GamepadFormMetrics).
@Environment(\.gamepadMetrics) private var metrics
let hints: [GamepadHint]
// 10-foot legend on tvOS, in-hand sizes elsewhere.
#if os(tvOS)
private static let glyphFont: CGFloat = 27
private static let textFont: CGFloat = 20
private static let pad: CGFloat = 18
#else
private static let glyphFont: CGFloat = 19
private static let textFont: CGFloat = 14
private static let pad: CGFloat = 13
#endif
var body: some View {
HStack(spacing: 18) {
ForEach(hints) { hint in
cell(hint)
HStack(spacing: 7) {
Image(systemName: hint.glyph)
.font(.system(size: Self.glyphFont))
.foregroundStyle(ink.fg)
Text(hint.text)
}
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
}
}
.font(.geist(metrics.hintTextFont, .semibold, relativeTo: .subheadline))
.font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
.foregroundStyle(ink.fg(0.85))
.padding(metrics.hintPad)
.padding(Self.pad)
.consoleGlass(Capsule())
// The hairline is DECORATION and sits on top of the cells, so it must never take a touch.
// Spelled out rather than left to defaults, because a swallowed touch in this bar is
// invisible the legend simply stops doing anything.
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1).allowsHitTesting(false))
}
/// A cell is a button where it has somewhere to go, and a plain label otherwise (see the type
/// comment for why tvOS is always the latter).
@ViewBuilder private func cell(_ hint: GamepadHint) -> some View {
#if os(tvOS)
label(hint)
#else
if let action = hint.action {
Button(action: action) { label(hint) }
.buttonStyle(HintCellStyle())
.accessibilityLabel(hint.text)
} else {
label(hint)
}
#endif
}
private func label(_ hint: GamepadHint) -> some View {
HStack(spacing: 7) {
Image(systemName: hint.glyph)
.font(.system(size: metrics.hintGlyphFont))
.foregroundStyle(ink.fg)
Text(hint.text)
}
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
// The tappable area covers the gap between glyph and label, not just their painted
// pixels a legend cell is small enough already.
.contentShape(Rectangle())
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
}
}
#if !os(tvOS)
/// Press feedback for a legend cell. Deliberately quiet the bar is chrome, and a cell that lit
/// up like a primary button would pull the eye off the content it describes.
///
/// `contentShape` sits BELOW the scale so the hit region stays the unscaled layout bounds: a press
/// animation that shrinks the artwork must never move the target out from under a resting finger,
/// or the touch-up lands outside and SwiftUI discards the tap.
private struct HintCellStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.opacity(configuration.isPressed ? 0.55 : 1)
.scaleEffect(configuration.isPressed ? 0.94 : 1)
.animation(.smooth(duration: 0.14), value: configuration.isPressed)
.contentShape(Rectangle())
}
}
#endif
/// The console backdrop: a living aurora drifting slowly over black so it reads as ambience behind
/// the cards, never as content. On iOS 18 / macOS 15+ it's an animated `MeshGradient` a continuous
/// silk of colour whose control points wander on slow, out-of-phase sinusoids finished with an
@@ -480,20 +220,14 @@ struct GamepadScreenBackground: View {
colorField(at: t, palette: palette)
// ±8° over ~5 min the whole field very slowly warms and cools.
.hueRotation(.degrees(sin(t * 0.021) * 8))
// Calm = col·0.6 + ground·0.4. Over the OPAQUE ground beneath, `.opacity` already
// lerps toward it, so this layer alone IS the whole calm mix.
// Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply
.opacity(1 - 0.4 * calmMix)
// A further plusLighter wash of the ground, which lets a DARK palette's bright pools
// come down to meet its ground rather than merely fading toward it.
//
// Suppressed on a pale palette (the factor goes to 0), because there it was destroying
// the setting: a pale ground is near-white, so ADDING 0.4 of it on top of a field
// already mixed 0.4 toward that same ground saturated the form screens to flat white
// the field ask was "in bright mode the sub-screens are basically just white". Written
// as a factor rather than an `if` so the layer stays mounted and the calm chase keeps
// animating instead of popping when a screen is pushed.
// and a plusLighter wash of the palette's own ground IS the add. Chosen so the
// ground lands exactly where it was and the bright pools come down to meet it.
// Mounted unconditionally at opacity 0 a plusLighter layer contributes nothing,
// and an always-present layer is what lets the mix animate instead of popping.
Self.color(palette.ground)
.opacity(0.4 * calmMix * (palette.light ? 0 : 1))
.opacity(0.4 * calmMix)
.blendMode(.plusLighter)
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the
// pooled light. Soft (extends past the frame) so the corners deepen rather than
@@ -629,6 +363,59 @@ private struct LegacyBlobField: View {
}
}
/// A blur gradient behind a pinned tray (a screen title, the hints/detail bar, the keyboard tray):
/// scrollable rows pass beneath those insets, so without this the tray text and the row underneath
/// render interleaved. Pure blur a dark material faded out by a gradient mask, no dark tint so
/// the tray's text sits on a softly blurred backdrop that dissolves into the rows.
struct GamepadTrayScrim: View {
let edge: VerticalEdge
@Environment(\.gamepadInk) private var ink
var body: some View {
let fromEdge: UnitPoint = edge == .top ? .top : .bottom
let toContent: UnitPoint = edge == .top ? .bottom : .top
Rectangle()
.fill(.ultraThinMaterial)
// Force the frost to match the PALETTE, not the system appearance: the tray exists
// to keep the pinned title legible, so it has to frost dark under white ink and
// light under dark ink.
.environment(\.colorScheme, ink.isLight ? .light : .dark)
// Sink the material's grey luminance lift toward the palette's shade (black on a
// dark field field ask: the frost read GREY over the aurora). Inside the mask, so
// the tint dissolves with the blur.
.overlay(ink.shade(0.35))
// Fade the whole blur out toward the content so it dissolves rather than ending on a
// line. The strong region sits deep (0.65) because the first stretch of the gradient
// now runs over the fixed 80 pt outer overhang below.
.mask {
LinearGradient(
stops: [
.init(color: .black, location: 0),
.init(color: .black.opacity(0.92), location: 0.65),
.init(color: .clear, location: 1),
],
startPoint: fromEdge, endPoint: toContent)
}
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds the tray's own
// text always sits on the strong part, rows blur out before they reach it. The bottom
// gets the longer runway: its tray sits over SCROLLING rows plus the detail line, and
// the field verdict on the short reach was rows colliding visibly with the legend.
.padding(edge == .top ? .bottom : .top, edge == .top ? -44 : -72)
// Full-bleed by LAYOUT, not by `.ignoresSafeArea()`: safe-area expansion resolves a
// beat after insertion (outside any geometry group and outside this view's own
// transaction), which is exactly the pop the field kept seeing vertically first,
// then, once the vertical runway became padding, on the X axis alone (the landscape
// side insets). 80 pt clears every inset on every device; backgrounds never clip,
// so the overhang simply draws.
.padding(edge == .top ? .top : .bottom, -80)
.padding(.horizontal, -80)
// And the shape must NEVER animate: mounted inside a pushed shell layer, any late
// geometry would ride the push's transaction and visibly grow into place. The
// layer's own fade/slide still carries the scrim; only its SHAPE is pinned.
.transaction { $0.animation = nil }
}
}
/// The backdrop for the gamepad UI's form screens (settings, add-host). It used to be a STILL pair
/// of glows over a deep indigo base deliberately not near-black, because Liquid Glass refracts
/// whatever sits behind it and over black the rows turn invisible. It is now the launcher's own
@@ -65,24 +65,10 @@ private struct HomeTile: Identifiable {
struct GamepadHomeView: View {
@Environment(\.gamepadInk) private var ink
/// Published by ContentView at the app ROOT, so this reads its own window's tier this screen
/// applies `gamepadPaletteInk` itself and so sits above its own copy of the environment.
@Environment(\.gamepadMetrics) private var metrics
/// The home-indicator strip's height, measured by DisplayBottomInsetProbe and published from
/// ContentView an environment READ is safe in body; asking UIKit for it here is not (see
/// the probe's comment: a key-window walk mid-render severed this very view's updates).
@Environment(\.displayBottomInset) private var displayBottomInset
@ObservedObject var store: HostStore
@ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery
@Binding var libraryTarget: StoredHost?
/// The host awaiting a PIN ceremony, if any. Owned by ContentView (a connect attempt sets it,
/// as does the trust card's "Pair with PIN instead"), presented here as a shell screen
/// PairSheet's `Form` is unreachable with a controller on iOS/macOS, which made pairing the
/// one thing a console-UI user simply could not do. See GamepadPairView.
@Binding var pairingTarget: StoredHost?
/// Pin the verified fingerprint and connect ContentView's `handlePaired`.
let onPaired: (StoredHost, Data) -> Void
/// Wake-and-wait driver gates the carousel while its overlay is up, and the carousel's
/// activate routes an offline+wakeable host through it (see ContentView.startSession).
@ObservedObject var waker: HostWaker
@@ -91,11 +77,6 @@ struct GamepadHomeView: View {
/// Launch a library title on a host the in-place library layer's activate path (iOS; the
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
let launchTitle: (StoredHost, String) -> Void
/// A console prompt (GamepadPromptView) is up over the home it polls the same controller, so
/// this screen must stand down for as long as it is. Same handoff contract as the connect
/// takeover and the shell's own layers; without it the carousel keeps scrolling underneath the
/// modal and a single A press reaches both.
var promptActive = false
/// The profile catalog pinned host+profile combos render as their own tiles here, which is
/// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4).
@@ -232,35 +213,19 @@ struct GamepadHomeView: View {
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
legend
GamepadHintBar(hints: hints)
// Equal distance from the left and bottom edges the pill's corner inset was the
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
.padding(.leading, compact ? 12 : 18)
.padding(.bottom, compact ? 12 : 18)
.padding(.top, compact ? 4 : 8)
}
}
/// The pinned controls legend, sitting the SAME distance from the leading and bottom edges of
/// the DISPLAY see `gamepadLegendBottomPadding` for why the bottom number is not simply the
/// margin, and why measuring the inset (rather than trying to opt out of it) is what finally
/// worked.
private var legend: some View {
GamepadHintBar(hints: hints)
.padding(.leading, legendMargin)
.padding(
.bottom,
gamepadLegendBottomPadding(
legendMargin, tier: metrics.tier, displayBottom: displayBottomInset))
.padding(.top, compact ? 4 : 8)
}
/// The legend pill's distance from the screen's leading and bottom edges.
private var legendMargin: CGFloat { compact ? 12 : 18 }
#if os(iOS)
/// The screen the shell shows over the launcher derived from the same triggers every
/// platform sets, so `returnToLibrary`, the tiles, X and Y all keep writing what they wrote.
private var topScreen: GamepadScreen? {
// Pairing leads: it is a ceremony blocking a connect the user already asked for, and it
// can be raised from ON TOP of the library (launching a title on an unpaired host), where
// it has to win. Backing out of it reveals whatever it interrupted.
if let host = pairingTarget { return .pair(host) }
if showSettings { return .settings }
if showAddHost { return .addHost }
if let host = libraryTarget { return .library(host) }
@@ -283,12 +248,6 @@ struct GamepadHomeView: View {
onAdd: { store.add($0) },
close: { if !transitioning { showAddHost = false } },
controllerActive: active)
case .pair(let host):
GamepadPairView(
host: host,
onPaired: { onPaired(host, $0) },
close: { if !transitioning { pairingTarget = nil } },
controllerActive: active)
case .library(let host):
GamepadLibraryScreen(
store: store, host: host,
@@ -335,14 +294,11 @@ struct GamepadHomeView: View {
/// transition's input drop, during which NOBODY polls.
private var homeOwnsController: Bool {
#if os(iOS)
topScreen == nil && !transitioning && !promptActive
topScreen == nil && !transitioning
&& waker.waking == nil && model.phase != .connecting
#else
// `pairingTarget` too: macOS presents the pair screen as a sheet and tvOS as a cover, and
// either way the launcher underneath must stop consuming the pad the pair screen's own
// list is polling the same controller.
libraryTarget == nil && pairingTarget == nil && !showSettings && !showAddHost
&& !promptActive && waker.waking == nil && model.phase != .connecting
libraryTarget == nil && !showSettings && !showAddHost
&& waker.waking == nil && model.phase != .connecting
#endif
}
@@ -456,22 +412,13 @@ struct GamepadHomeView: View {
case .rescan: "Rescan"
default: nil
}
// Every cell's action re-resolves the selection when it FIRES rather than closing over the
// one this render saw: the legend is rebuilt on selection changes, but a tap landing in
// the same frame as a carousel move would otherwise activate the tile that was selected a
// moment ago the one failure mode a launcher cannot afford.
var hints = [GamepadHint(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"),
action: { tiles.first { $0.id == selection }?.activate() })]
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
if libraryEnabled, selected?.hasLibrary == true {
hints.append(.init(
glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library",
action: { openLibraryForSelected() }))
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
}
hints.append(.init(
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings",
action: { showSettings = true }))
hints.append(.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings"))
return hints
}
@@ -626,21 +573,11 @@ private struct GamepadHostTile: View {
}
.padding(Self.pad)
.frame(width: size.width, height: size.height, alignment: .leading)
// Console tile a brand wash marks a saved host as primary; discovered / Add-Host tiles
// stay neutral with a dashed edge. The surface clips to the shape itself.
//
// `forceMaterial`: these tiles are the one console surface that gets TRANSFORMED while it
// animates `CardEntrance` swings each card in on a `rotation3DEffect` under an opacity
// ramp, and the carousel's `.scrollTransition` keeps scaling and rotating the neighbours
// forever after. Liquid Glass samples the backdrop through its own layer and cannot do
// that under a 3D transform, so it drew one way through the swing and snapped to another
// as the card landed on glass it read as the tiles being swapped out for different ones
// at the end of their entrance. A material composites flat, so the card looks the same at
// every frame of the travel. (tvOS already takes this path for its own reasons.)
// Liquid Glass console tile a brand wash marks a saved host as primary; discovered /
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
.consoleGlass(
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
tint: tile.filled ? ink.accent(0.20) : nil,
forceMaterial: true)
tint: tile.filled ? ink.accent(0.20) : nil)
.overlay {
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
.strokeBorder(
@@ -1,79 +0,0 @@
// Hardware-keyboard navigation for the gamepad UI (iOS/iPadOS/macOS): arrows move, Return/Space
// activate, Esc backs out.
//
// Asked for by a field user on an iPad ("select games with keyboard arrows, enter to launch"). An
// iPad on a Magic Keyboard and a couch Mac are the same situation the console layout was built
// for a screen driven from a distance with a fixed set of directional inputs and the whole
// navigation model (a cursor, a confirm, a back) already exists here for the controller. A
// keyboard is just a third input onto it, alongside the pad poll and touch.
//
// tvOS is excluded: the focus engine already routes hardware-keyboard arrows into focus moves
// there, and these screens hand it navigation authority on purpose.
//
// The view must be FOCUSED to receive key presses, so this takes focus on appear. That is safe on
// exactly these screens because the gamepad UI has no system text fields to steal it from
// GamepadKeyboard is a custom grid of keycaps, not a `TextField`.
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS)
extension View {
/// Route arrows / Return / Esc into the same handlers the controller poll drives.
///
/// `active` mirrors the caller's `isActive` controller gate: a screen that has handed the pad
/// to something on top must not keep eating key presses either, or a covered launcher
/// navigates behind the screen in front of it.
func gamepadKeyNavigation(
active: Bool = true,
onMove: @escaping (GamepadMenuInput.Direction) -> Void,
onConfirm: @escaping () -> Void,
onBack: (() -> Void)? = nil
) -> some View {
modifier(GamepadKeyNav(active: active, onMove: onMove, onConfirm: onConfirm, onBack: onBack))
}
}
private struct GamepadKeyNav: ViewModifier {
let active: Bool
let onMove: (GamepadMenuInput.Direction) -> Void
let onConfirm: () -> Void
let onBack: (() -> Void)?
@FocusState private var focused: Bool
func body(content: Content) -> some View {
content
.focusable(active)
// No focus ring: these screens draw their own cursor (the centred card, the focused
// row), and a system ring around the whole scroll view on top of it reads as a bug.
.focusEffectDisabled()
.focused($focused)
// Claim focus on appear, and re-claim it whenever this screen becomes the active one
// again a pushed screen popping off leaves the one underneath unfocused.
.onAppear { focused = active }
.onChange(of: active) { _, nowActive in
if nowActive { focused = true }
}
.onKeyPress(.upArrow) { handle { onMove(.up) } }
.onKeyPress(.downArrow) { handle { onMove(.down) } }
.onKeyPress(.leftArrow) { handle { onMove(.left) } }
.onKeyPress(.rightArrow) { handle { onMove(.right) } }
.onKeyPress(.return) { handle(onConfirm) }
.onKeyPress(.space) { handle(onConfirm) }
.onKeyPress(.escape) {
guard let onBack else { return .ignored }
return handle(onBack)
}
}
/// Run a handler only while this screen owns input, and report back whether the press was
/// consumed. `.ignored` matters: an unhandled Esc still has to reach the `.cancelAction`
/// shortcut that closes a macOS sheet (see GamepadAddHostView's hidden Cancel button).
private func handle(_ action: () -> Void) -> KeyPress.Result {
guard active else { return .ignored }
action()
return .handled
}
}
#endif
@@ -38,7 +38,7 @@ struct GamepadLibraryScreen: View {
.padding(.horizontal, 24)
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.background { GamepadTrayBlur(edge: .top) }
.background { GamepadTrayScrim(edge: .top) }
}
// A hardware keyboard's Esc still closes, without chrome.
.background {
@@ -119,22 +119,6 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
.sensoryFeedback(.selection, trigger: adjustTick)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
#if os(iOS) || os(macOS)
// Hardware keyboard: up/down step the focus bar, left/right adjust the focused row's
// value (exactly what the stick does), Return activates, Esc backs out.
.gamepadKeyNavigation(
active: isActive,
onMove: { direction in
switch direction {
case .up: step(by: -1)
case .down: step(by: 1)
case .left: adjust(by: -1)
case .right: adjust(by: 1)
}
},
onConfirm: { activate() },
onBack: onBack)
#endif
.onAppear {
reconcile()
wire()
@@ -1,230 +0,0 @@
// The gamepad UI's answer to a system alert / confirmation dialog (iOS/iPadOS/macOS).
//
// `.alert` and `.confirmationDialog` are UIKit/AppKit surfaces. A game controller cannot move
// through their buttons or press one so on iOS/macOS every prompt in the connect path was a dead
// end for a pad-only user, and they are not incidental prompts:
//
// - "Pairing required" (Request Access / Pair with PIN) is the FIRST thing an unpaired host
// shows. Pairing was unreachable before it even got to the PIN.
// - "Connection failed" strands the console UI behind a modal only a finger can dismiss.
// - "Waiting for approval" owns the only Cancel for a connect that may never complete.
//
// tvOS keeps the system alerts: the focus engine drives them natively there, which is the whole
// reason this gap was tvOS-invisible.
//
// Deliberately NOT built on GamepadMenuList: that is a ScrollView (right for a settings screen of
// unknown length, wrong for two buttons in a card, where it would need an invented height and
// could clip). A prompt has two or three actions, so it owns a plain VStack and a cursor.
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS)
/// One choice in a console prompt.
struct GamepadPromptAction: Identifiable {
let id: String
let title: String
/// This is the action B (and Esc) performs, and the one the cursor opens on. Exactly one
/// action should carry it `GamepadPrompt` falls back to the LAST action when none does,
/// which matches how a system alert treats its cancel role.
var isCancel = false
/// Drawn as the primary, accent-tinted row. At most one.
var isPrimary = false
let run: () -> Void
}
/// A prompt to show over the console UI: what happened, and what can be done about it.
struct GamepadPrompt: Identifiable {
let id: String
let title: String
let message: String
let actions: [GamepadPromptAction]
/// A wait with no outcome yet (the delegated-approval hold) shows a spinner beside the title
/// the prompt is the UI for something still in flight, not a report that it finished.
var busy = false
}
/// The prompt, worn as the console's own modal: a dimmed field, a glass card, a focus list of
/// actions, and the same legend every other gamepad screen carries.
struct GamepadPromptView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.gamepadMetrics) private var metrics
let prompt: GamepadPrompt
@State private var cursor = 0
@State private var input = GamepadMenuInput(manager: .shared)
@State private var haptics = MenuHaptics(manager: .shared)
/// `.sensoryFeedback` counters device ticks for confirm and for a refused move at an end.
@State private var activateTick = 0
@State private var boundaryTick = 0
#if os(iOS)
@Environment(\.verticalSizeClass) private var vSizeClass
private var compact: Bool { vSizeClass == .compact }
#else
private let compact = false
#endif
var body: some View {
ZStack {
// Swallows touch to the launcher behind it, which is also gated out of the controller
// poll for as long as this is up (ContentView's `promptActive`).
Rectangle()
.fill(.black.opacity(0.55))
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture {}
card
}
.sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
// A prompt is exactly where a keyboard user gets stuck, so it takes arrows/Return/Esc too.
.gamepadKeyNavigation(
onMove: { direction in
switch direction {
case .up: step(by: -1)
case .down: step(by: 1)
case .left, .right: break
}
},
onConfirm: { activate() },
onBack: { back() })
.onAppear {
cursor = prompt.actions.firstIndex(where: \.isCancel) ?? max(prompt.actions.count - 1, 0)
wire()
input.start()
}
// The prompt's identity is stable across a message change (same `id`), so re-wire rather
// than rely on a remount: the stored closures captured the OLD actions array.
.onChange(of: prompt.actions.map(\.id)) { _, _ in
cursor = min(cursor, max(prompt.actions.count - 1, 0))
wire()
}
.onDisappear {
input.stop()
haptics.stop()
}
}
private var card: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 10) {
if prompt.busy {
ProgressView().controlSize(.small).tint(ink.fg(0.8))
}
Text(prompt.title)
.font(.geist(compact ? 19 : 22, .bold, relativeTo: .title3))
.foregroundStyle(ink.fg)
}
Text(prompt.message)
.font(.geist(metrics.detailFont, relativeTo: .callout))
.foregroundStyle(ink.fg(0.62))
.fixedSize(horizontal: false, vertical: true)
VStack(spacing: 6) {
ForEach(Array(prompt.actions.enumerated()), id: \.element.id) { idx, action in
actionRow(action, focused: idx == cursor)
.contentShape(Rectangle())
.onTapGesture { tap(idx) }
}
}
.padding(.top, 2)
GamepadHintBar(hints: hints)
}
.padding(compact ? 20 : 26)
.frame(maxWidth: 460)
.consoleGlass(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(ink.fg(0.12), lineWidth: 1)
}
.padding(24)
}
private func actionRow(_ action: GamepadPromptAction, focused: Bool) -> some View {
let m = metrics
return Text(action.title)
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(action.isPrimary ? ink.accent : ink.fg)
.frame(maxWidth: .infinity)
.padding(.horizontal, m.rowHPad)
.padding(.vertical, m.rowVPad)
.consoleGlass(
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: focused ? ink.accent(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(ink.fg(focused ? 0.28 : 0.06), lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
.animation(.smooth(duration: 0.18), value: focused)
}
private var hints: [GamepadHint] {
var hints: [GamepadHint] = [.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
action: { activate() })]
// Only where B has somewhere to go: a one-action prompt ("OK") is dismissed by that
// action, and B does it too naming it twice would just be noise.
if prompt.actions.count > 1, let cancel = prompt.actions.first(where: \.isCancel) {
hints.append(.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: cancel.title,
action: { back() }))
}
return hints
}
// MARK: - Input
private func wire() {
input.onMove = { direction in
switch direction {
case .up: step(by: -1)
case .down: step(by: 1)
// A prompt's actions are a vertical list; left/right have nothing to mean here, and
// silently treating them as up/down would make a nudged stick pick a different button.
case .left, .right: break
}
}
input.onConfirm = { activate() }
input.onBack = { back() }
}
private func step(by delta: Int) {
let target = cursor + delta
guard target >= 0, target < prompt.actions.count else {
boundaryTick &+= 1
haptics.boundary()
return
}
cursor = target
haptics.move()
}
private func activate() {
guard cursor >= 0, cursor < prompt.actions.count else { return }
activateTick &+= 1
haptics.confirm()
prompt.actions[cursor].run()
}
/// B: the cancel action, else the last one the same fallback a system alert applies when
/// nothing carries the cancel role, so B always has a way out rather than doing nothing.
private func back() {
guard let action = prompt.actions.first(where: \.isCancel) ?? prompt.actions.last
else { return }
activateTick &+= 1
haptics.confirm()
action.run()
}
/// Touch fallback matching the rest of the gamepad UI: a tap focuses AND activates.
private func tap(_ idx: Int) {
guard idx >= 0, idx < prompt.actions.count else { return }
cursor = idx
activate()
}
}
#endif
@@ -21,14 +21,12 @@ import SwiftUI
enum GamepadScreen: Identifiable {
case settings
case addHost
case pair(StoredHost)
case library(StoredHost)
var id: String {
switch self {
case .settings: return "settings"
case .addHost: return "addHost"
case .pair(let host): return "pair-\(host.id.uuidString)"
case .library(let host): return "library-\(host.id.uuidString)"
}
}
@@ -37,7 +35,7 @@ enum GamepadScreen: Identifiable {
/// (`Bg::Form` in the console); the library keeps the launcher's full aurora.
var isForm: Bool {
switch self {
case .settings, .addHost, .pair: return true
case .settings, .addHost: return true
case .library: return false
}
}
@@ -204,19 +204,13 @@ struct LibraryCoverflowView: View {
private var hints: [GamepadHint] {
var hints: [GamepadHint] = []
if let onLaunch {
if onLaunch != nil {
// You *open* a launcher and *launch* a game the hint follows the focused entry.
let opens = games.first { $0.id == selection }?.isLauncher == true
hints.append(.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch",
// Reads `selection` when it fires, not when the legend was built (see the
// launcher's twin) and does nothing with no title centred, which is exactly
// what A does.
action: { if let id = selection { onLaunch(id) } }))
hints.append(
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch"))
}
hints.append(.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close",
action: { onDismiss?() }))
hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close"))
return hints
}
}
@@ -27,13 +27,6 @@ struct LibraryView: View {
/// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across
/// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear.
@State private var artLoader: LibraryArtLoader?
#if os(iOS) || os(macOS)
/// The plain grid's hardware-keyboard cursor (a game id), and the grid width the column count
/// is derived from. nil until the first arrow press, so a touch user never sees a selection
/// they didn't ask for.
@State private var keyCursor: String?
@State private var gridWidth: CGFloat = 0
#endif
#if os(iOS) || os(macOS) || os(tvOS)
// Gamepad-driven browsing see ContentView's identical gate. With no controller (or the
// setting off) every platform keeps the plain-grid presentation of this same view.
@@ -127,103 +120,34 @@ struct LibraryView: View {
let launchers = games.filter(\.isLauncher)
let titles = games.filter { !$0.isLauncher }
let both = !launchers.isEmpty && !titles.isEmpty
return ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 18) {
if !launchers.isEmpty {
if both { sectionHeader("Launchers") }
tiles(launchers)
}
if !titles.isEmpty {
if both { sectionHeader("Games") }
tiles(titles)
}
return ScrollView {
VStack(alignment: .leading, spacing: 18) {
if !launchers.isEmpty {
if both { sectionHeader("Launchers") }
tiles(launchers)
}
.padding()
#if os(iOS) || os(macOS)
// The grid's own width, reported without affecting layout a GeometryReader
// SIBLING inside a ScrollView would claim the whole viewport. It's what tells the
// keyboard cursor how many columns `.adaptive` actually produced, so it is only
// measured where that cursor exists.
.background {
GeometryReader { geo in
Color.clear
.onAppear { gridWidth = geo.size.width }
.onChange(of: geo.size.width) { _, w in gridWidth = w }
}
if !titles.isEmpty {
if both { sectionHeader("Games") }
tiles(titles)
}
#endif
}
#if os(iOS) || os(macOS)
// Hardware keyboard: arrows pick a title, Return launches it a field ask from an
// iPad user on a Magic Keyboard. The gamepad UI's coverflow has had this via the
// controller all along; this is the same thing for the plain grid, which is what an
// iPad with a keyboard and NO pad actually sees.
.gamepadKeyNavigation(
active: onLaunch != nil,
onMove: { direction in
guard let next = gridNav(launchers: launchers, titles: titles)
.move(from: keyCursor, direction) else { return }
keyCursor = next
withAnimation(.easeOut(duration: 0.18)) { proxy.scrollTo(next, anchor: .center) }
},
onConfirm: {
guard let onLaunch, let id = keyCursor else { return }
onLaunch(id)
})
#endif
.padding()
}
}
#if os(iOS) || os(macOS)
/// The keyboard cursor's model over the two grid sections. Rebuilt per press from the live
/// sections so it can never point into a stale list.
private func gridNav(launchers: [GameEntry], titles: [GameEntry]) -> LibraryGridNav {
LibraryGridNav(
sections: [launchers, titles].filter { !$0.isEmpty }.map { $0.map(\.id) },
columns: columnCount)
}
/// How many columns `.adaptive(minimum:spacing:)` fits into the measured width the same
/// arithmetic the layout does, so up/down move exactly one visual row rather than a guess.
/// Falls back to one column before the first measurement lands.
private var columnCount: Int {
let minimum: CGFloat = 130 // matches `columns` below on iOS/macOS
let spacing: CGFloat = 18
// The VStack's `.padding()` is inside the measured width, so take it back off.
let usable = gridWidth - 32
guard usable > 0 else { return 1 }
return max(1, Int((usable + spacing) / (minimum + spacing)))
}
#endif
private func tiles(_ entries: [GameEntry]) -> some View {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(entries) { game in
if let onLaunch {
Button { onLaunch(game.id) } label: {
GameCard(game: game, artLoader: artLoader, selected: isKeyCursor(game))
}
.buttonStyle(.plain)
.id(game.id)
Button { onLaunch(game.id) } label: { GameCard(game: game, artLoader: artLoader) }
.buttonStyle(.plain)
} else {
GameCard(game: game, artLoader: artLoader, selected: isKeyCursor(game))
.id(game.id)
GameCard(game: game, artLoader: artLoader)
}
}
}
}
/// Whether the keyboard cursor is on this tile (always false where there is no keyboard
/// navigation to have moved it).
private func isKeyCursor(_ game: GameEntry) -> Bool {
#if os(iOS) || os(macOS)
keyCursor == game.id
#else
false
#endif
}
private func sectionHeader(_ text: String) -> some View {
Text(text)
.font(.geist(12, .semibold, relativeTo: .caption))
@@ -340,9 +264,6 @@ private struct LibraryBackCatcher: View {
private struct GameCard: View {
let game: GameEntry
let artLoader: LibraryArtLoader?
/// The hardware-keyboard cursor is on this tile drawn as an accent ring, since the plain
/// grid has no other way to say "Return launches THIS one".
var selected = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
@@ -350,12 +271,6 @@ private struct GameCard: View {
.aspectRatio(2.0 / 3.0, contentMode: .fit)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
if selected {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(.tint, lineWidth: 3)
}
}
.overlay(alignment: .topLeading) {
StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher)
}
@@ -1,8 +1,7 @@
// Siri / Shortcuts / Spotlight surface (design §M4, extended by client-deep-links.md §6).
// Deliberately thin: every action already has an internal entry point the deep-link router
// (connect / connect-and-launch / connect-with-a-profile, and the `browse` route into a host's
// library), the in-process end-session hook, and the existing Wake-on-LAN path so these
// intents only wrap them.
// (connect / connect-and-launch / connect-with-a-profile), the in-process end-session hook, and
// the existing Wake-on-LAN path so these intents only wrap them.
//
// Connect and Wake compile on macOS and tvOS too: AppIntents is genuinely available there
// (macOS 13+ / tvOS 16+), and "Stream Desktop with Work" from Spotlight on a Mac is part of the
@@ -52,28 +51,6 @@ struct ConnectToHostIntent: AppIntent {
}
}
/// Jump straight into a host's game library no session. Foregrounds the app and routes the
/// `browse` route through the same `.onOpenURL` path a widget tap uses, which drives the one
/// `libraryTarget` every surface shares so the shortcut lands in whichever library presentation
/// the current mode owns: the gamepad console's library screen when the gamepad UI is active, the
/// touch/desktop library otherwise. A session starts only when a title is picked there.
struct OpenLibraryIntent: AppIntent {
static let title: LocalizedStringResource = "Open Game Library"
static let description = IntentDescription(
"Open a host's game library in Punktfunk, without starting a stream.")
static let openAppWhenRun = true
@Parameter(title: "Host") var host: HostEntity
func perform() async throws -> some IntentResult {
let url = DeepLink.browse(host: host.id).url
await MainActor.run {
NotificationCenter.default.post(name: .punktfunkOpenDeepLink, object: url)
}
return .result()
}
}
/// Wake a sleeping host (magic packet). No `openAppWhenRun` usable in automations ("when I get
/// home, wake the tower") without foregrounding the app.
struct WakeHostIntent: AppIntent {
@@ -120,13 +97,6 @@ struct PunktfunkShortcuts: AppShortcutsProvider {
"Stream \(\.$host) with \(.applicationName)",
],
shortTitle: "Connect", systemImageName: "play.tv.fill")
AppShortcut(
intent: OpenLibraryIntent(),
phrases: [
"Open \(\.$host) library in \(.applicationName)",
"Show \(\.$host) games in \(.applicationName)",
],
shortTitle: "Game Library", systemImageName: "square.grid.2x2.fill")
AppShortcut(
intent: WakeHostIntent(),
phrases: [
@@ -43,27 +43,10 @@ enum ScreenshotMode {
/// readiness ping for the capture script.
struct ScreenshotHostView: View {
let scene: ShotScene
#if os(iOS)
@Environment(\.horizontalSizeClass) private var hSizeClass
@Environment(\.verticalSizeClass) private var vSizeClass
#endif
/// The gamepad UI's form-metric tier, published here for the same reason ContentView does it:
/// this harness mounts those screens DIRECTLY, with no ContentView in the tree, so without it
/// an iPad capture renders every gamepad screen at iPhone scale a capture that doesn't look
/// like the app.
private var gamepadMetrics: GamepadFormMetrics {
#if os(iOS)
.forWindow(h: hSizeClass, v: vSizeClass)
#else
.platformDefault
#endif
}
var body: some View {
scene.make()
.environment(\.colorScheme, scene.colorScheme)
.environment(\.gamepadMetrics, gamepadMetrics)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
// here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate
@@ -242,8 +242,7 @@ private struct ShotGamepadHome: View {
var body: some View {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
onPaired: { _, _ in }, waker: waker,
libraryTarget: .constant(nil), waker: waker,
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
}
}
@@ -301,8 +300,7 @@ private struct ShotConnect: View {
if gamepadUI {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
onPaired: { _, _ in }, waker: waker,
libraryTarget: .constant(nil), waker: waker,
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
} else {
ShotHome()
@@ -54,15 +54,6 @@ struct AcknowledgementsView: View {
Divider()
Text("Swift packages")
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
Text("Punktfunk uses Glur (progressive backdrop blur), "
+ "© 2023 João Gabriel, under the MIT License.")
.font(.geist(Self.captionFont, relativeTo: .caption))
.foregroundStyle(.secondary)
Divider()
Text("Third-party software")
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
Text(
@@ -66,20 +66,22 @@ struct GamepadOptionBand: View {
rotation: drumPosition,
target: drumPosition,
// Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge.
radius: width * 0.72,
width: width)
radius: width * 0.72)
}
}
.frame(width: width)
.clipped()
// NO `.mask` here. The soft edges used to be a gradient mask over the whole band, and a
// mask RASTERISES what it covers which flattens `rotation3DEffect`'s perspective, so the
// drum was being composited as a flat sideways slide rather than a turning cylinder. That
// is the "3D effect isn't what it should be" the field kept seeing: the geometry was
// always right, and the mask was throwing the projection away every frame.
//
// The same soft edge is folded into each option's own opacity instead (see `Drum.option`),
// which costs nothing and leaves the projection intact.
// Soft edges: the drum dissolves before it reaches the chevrons instead of ending on a cut.
.mask {
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: .black, location: 0.12),
.init(color: .black, location: 0.88),
.init(color: .clear, location: 1),
],
startPoint: .leading, endPoint: .trailing)
}
.onChange(of: selection) { old, new in step(from: old, to: new) }
// The options list itself can mutate under the drum (a custom resolution appears, a
// controller connects, the buffer options re-derive from a new refresh rate) re-seat
@@ -129,9 +131,6 @@ private struct Drum: View, Animatable {
let target: Double
/// Drum radius in points (from the band width see the caller).
let radius: Double
/// The band's own width the stage the options turn on, and what the edge fade is measured
/// against now that the container no longer carries a mask.
let width: Double
var animatableData: Double {
get { rotation }
@@ -141,17 +140,6 @@ private struct Drum: View, Animatable {
/// Angular pitch between adjacent options on the drum.
private static let stepAngle = 34.0 * .pi / 180.0
// Neighbours exist only while the drum is MOVING, and that is not a compromise it is the
// documented field fix this file was written around. Showing them at rest was tried (to make a
// settled row look more like a cylinder) and immediately reproduced the original defect: on the
// simulator, "This device · 2752 × 2064" rendered with "280 ×" sitting on top of it, and
// "Automatic" with "10 Mbps" through it. A long value and its neighbour occupy the same
// pixels, and no opacity low enough to fix that is high enough to be worth drawing.
//
// The cylinder is meant to be READ WHILE IT TURNS. What was actually broken is fixed above:
// the band used to mask itself, and the mask rasterised the drum and threw its perspective
// away every frame, so the turn never looked like a turn.
var body: some View {
let flight = min(1, abs(rotation - target) * 3)
let content = ZStack {
@@ -159,18 +147,14 @@ private struct Drum: View, Animatable {
// Plain signed distance the band is linear, so option i has ONE home and the
// ends are the ends (nothing waits beyond the last option).
let d = Double(i) - rotation
// Only the facing option at rest; its neighbours join it for the travel (see the
// note on `restingNeighbour`'s removal above).
if abs(d) < 0.5 || (flight > 0.001 && abs(d) <= 2.5) {
option(i, distance: d, gate: flight)
}
}
}
#if os(tvOS)
// Flatten the transform stack the 10-foot GPU already made these rows drop Liquid
// Glass, and several projected texts per step is the same class of cost. It costs the
// projection (a rasterised layer has no perspective), which is the trade tvOS already
// makes elsewhere on this screen.
// Flatten the transform stack while travelling the 10-foot GPU already made these
// rows drop Liquid Glass, and five projected texts per step is the same class of cost.
content.drawingGroup()
#else
content
@@ -180,30 +164,17 @@ private struct Drum: View, Animatable {
@ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View {
let angle = d * Self.stepAngle
let depth = cos(angle)
let x = radius * sin(angle)
// The facing option never gates: a resting row still shows its value.
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate) * edgeFade(x)
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate)
Text(options[i])
.lineLimit(1)
.fixedSize() // never let a turning label re-wrap to the band's width mid-flight
.scaleEffect(0.70 + 0.30 * depth)
// Foreshorten the label as it turns away this is what sells the cylinder.
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.55)
.offset(x: x)
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.4)
.offset(x: radius * sin(angle))
.opacity(alpha)
.zIndex(depth)
}
/// The soft edge, per option, replacing the container mask that used to flatten the
/// projection: full strength through the middle of the band, dissolving to nothing by the
/// time an option reaches its rim, so the drum never ends on a cut.
private func edgeFade(_ x: Double) -> Double {
let halfWidth = width / 2
guard halfWidth > 0 else { return 1 }
let fadeStart = halfWidth * 0.55
guard abs(x) > fadeStart else { return 1 }
return max(0, min(1, (halfWidth - abs(x)) / (halfWidth - fadeStart)))
}
}
#endif
@@ -46,8 +46,6 @@ enum GpSettingsTab: String, CaseIterable, Hashable {
struct GamepadSettingsView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.dismiss) private var dismiss
@Environment(\.gamepadHostedInShell) private var hostedInShell
/// The saved-host store the pin picker writes `setPinned` through it and the profile rows
@@ -144,7 +142,7 @@ struct GamepadSettingsView: View {
isActive: controllerActive
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: metrics.rowMaxWidth)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
@@ -163,12 +161,12 @@ struct GamepadSettingsView: View {
}
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.background { GamepadTrayBlur(edge: .top) }
.background { GamepadTrayScrim(edge: .top) }
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text(focusedDetail)
.font(.geist(metrics.detailFont, relativeTo: .caption))
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.lineLimit(2, reservesSpace: true)
.animation(.smooth(duration: 0.2), value: focusID)
@@ -177,13 +175,10 @@ struct GamepadSettingsView: View {
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
.padding(.leading, compact ? 12 : 18)
.padding(.trailing, 22)
.padding(
.bottom,
gamepadLegendBottomPadding(
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
.padding(.bottom, compact ? 12 : 18)
.padding(.top, compact ? 6 : 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayBlur(edge: .bottom) }
.background { GamepadTrayScrim(edge: .bottom) }
}
// The launcher's living field, calmed (GamepadFormBackground) the glass rows keep real
// colour and luminance to lens without the launcher's contrast, and the palette setting
@@ -259,18 +254,10 @@ struct GamepadSettingsView: View {
private func pill(_ t: GpSettingsTab) -> some View {
let selected = t == tab
return Text(t.rawValue)
.font(.geist(compact ? 12 : metrics.tabFont, .semibold, relativeTo: .footnote))
// `onAccent`, not `fg` the selected pill is FILLED with the palette accent, and
// `onAccent` is the colour picked (by the accent's own luminance) to read on top of
// it; its doc calls out "a filled pill's label" for exactly this surface. Using the
// foreground meant white-on-white wherever a palette's accent is pale: Graphite's is
// a light grey (luma 0.80), so its selected tab was unreadable.
.foregroundStyle(selected ? ink.onAccent : ink.fg(0.55))
// Proportional to the row metrics rather than fixed, so the strip grows with the
// fields under it a tab bar at phone scale above iPad-scale rows was half the
// "does not adapt to larger screens" complaint.
.padding(.horizontal, metrics.rowHPad * 0.8)
.padding(.vertical, metrics.rowVPad * 0.55)
.font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote))
.foregroundStyle(selected ? ink.fg : ink.fg(0.55))
.padding(.horizontal, 13)
.padding(.vertical, 7)
.background {
// One shared capsule that MOVES between pills, rather than one per pill fading
// in and out the highlight travels the way the press did. A Liquid Glass
@@ -341,39 +328,26 @@ struct GamepadSettingsView: View {
// shoulders exist at all (see `showsSectionHint`).
let sections: [GamepadHint] = showsSectionHint
? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
text: "Section", action: { step(tabBy: 1) })]
text: "Section")]
: []
// A dimmed row takes neither, so offering them would be the same lie the row itself
// used to tell only Done remains, and the detail line says what to turn on first.
guard rows.first(where: { $0.id == focusID })?.enabled ?? true else {
return sections
+ [.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
action: { back() })]
+ [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")]
}
return sections + [
// The stick itself, not an action nothing to tap (see GamepadHint.action).
.init(glyph: "arrow.left.and.right", text: "Adjust"),
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change",
action: { if let focusID { activate(id: focusID) } }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
action: { back() }),
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
]
}
guard !store.hosts.isEmpty else {
return [.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
action: { back() })]
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")]
}
return [
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin",
action: { if let focusID { activate(id: focusID) } }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
action: { back() }),
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"),
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"),
]
}
@@ -391,7 +365,7 @@ struct GamepadSettingsView: View {
// MARK: - Row rendering
private func rowView(_ row: Row, focused: Bool) -> some View {
let m = metrics
let m = GamepadFormMetrics.self
// No section header: the tab strip names the section now, and repeating it above the
// first row of every tab was just a second label saying the same word.
return VStack(alignment: .leading, spacing: 6) {
@@ -469,9 +443,9 @@ struct GamepadSettingsView: View {
/// narrows the stage.
private var bandWidth: CGFloat {
#if os(iOS)
hSizeClass == .compact && vSizeClass == .regular ? 170 : metrics.bandWidth
hSizeClass == .compact && vSizeClass == .regular ? 170 : GamepadFormMetrics.bandWidth
#else
metrics.bandWidth
GamepadFormMetrics.bandWidth
#endif
}
@@ -200,16 +200,15 @@ final class HostStore: ObservableObject {
if let data = try? JSONEncoder().encode(hosts) {
defaults.set(data, forKey: Self.key)
}
reloadHostsWidget() // the widgets read this store; any change refreshes their timelines
reloadHostsWidget() // the widget reads this store; any change refreshes its timeline
}
/// Ask WidgetKit to rebuild the launcher widgets' timelines after any store change (add/remove/
/// pin/last-connected). iOS-only and a no-op where WidgetKit is absent; both widgets use
/// `.never`-refresh entries and rely on this push.
/// Ask WidgetKit to rebuild the hosts widget's timeline after any store change (add/remove/pin/
/// last-connected). iOS-only and a no-op where WidgetKit is absent; the widget uses
/// `.never`-refresh entries and relies on this push.
private func reloadHostsWidget() {
#if canImport(WidgetKit) && os(iOS)
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkHosts")
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkLibrary")
#endif
}
}
@@ -85,12 +85,6 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
let shape: S
var tint: Color?
var interactive = false
/// Take the MATERIAL path even where real Liquid Glass is available. For surfaces that get
/// transformed while they animate: glass samples the backdrop through its own layer, and under
/// a `rotation3DEffect` / `opacity` it cannot, so it renders one way mid-animation and snaps to
/// another the instant the transform ends on glass that reads as the tile being SWAPPED for a
/// different one as it lands. A material is a flat composite and looks identical throughout.
var forceMaterial = false
/// The console surface follows the background palette: a PALE field needs the material to
/// frost light and the glass to read as white, or the dark ink on top of it disappears.
/// Defaults to the dark ink, so every non-gamepad caller is unchanged.
@@ -123,18 +117,8 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
}
.environment(\.colorScheme, scheme)
#else
if #available(iOS 26, macOS 26, *), !forceMaterial {
content
// The caller's tint rides HERE, not in `Glass.tint`, so it can ANIMATE. A Glass
// value is opaque to SwiftUI's animation system: changing its tint swaps one
// effect for another, which is why a focused row's accent used to appear (and,
// worse, disappear a beat late) as a hard jump while the row's scale animated
// smoothly beside it. A plain fill interpolates, so `.animation(value: focused)`
// at the call site now covers the whole row. Sits between the glass and the
// content: `.background` is behind the label, `glassEffect` behind both.
.background { shape.fill(tint ?? .clear) }
.glassEffect(glass, in: shape)
.environment(\.colorScheme, scheme)
if #available(iOS 26, macOS 26, *) {
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
} else {
content
.background {
@@ -153,21 +137,13 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
#if !os(tvOS)
@available(iOS 26, macOS 26, *)
private var glass: Glass {
// The glass carries the PALETTE wash only the caller's focus tint is an animatable fill
// above it now (see `body`).
//
// A pale palette gets `.clear` glass, not `.regular`. Its `ink.glass` is literal white, so
// over `.regular` which is already a bright, high-body material even a light white
// wash lands as a flat white slab: the refraction and the blurred field behind stop
// reading entirely, which is the "opaque fully white bg" on every row, pill and legend.
// Lowering the tint alone did NOT fix it, because the opacity was coming from the glass
// BODY rather than from the tint. `.clear` is the variant meant for exactly this a
// surface over content that must stay visible through it and a small white wash on top
// of it is enough to keep the dark ink legible without closing the surface up.
let wash = ink.glass(ink.isLight ? 0.18 : 0.45)
// Spelled out rather than `.clear`/`.regular`: a ternary between two leading-dot members
// gives the compiler no base type to infer from.
var g: Glass = (ink.isLight ? Glass.clear : Glass.regular).tint(wash)
// Liquid Glass has ONE tint channel, so the palette wash and the caller's tint share
// it: mixed 60 % toward the caller's (the focused row must still read accented on
// every palette) over the palette base. If device QA finds the mixed focus wash too
// weak, the escape hatch is `tint ?? wash` today's focused look, bit for bit.
let wash = ink.glass(ink.isLight ? 0.60 : 0.45)
var g: Glass = .regular.tint(
tint.map { wash.mix(with: $0, by: 0.6) } ?? wash)
if interactive { g = g.interactive() }
return g
}
@@ -178,13 +154,8 @@ extension View {
/// Liquid Glass for a console surface (a host tile / settings row), or `.ultraThinMaterial`
/// pre-26 both washed with the palette's own glass colour, both frosting to the palette's
/// scheme. Pass the surface's shape explicitly glass defaults to a Capsule.
///
/// `forceMaterial` opts a TRANSFORMED surface out of live glass; see the property.
func consoleGlass<S: Shape>(
_ shape: S, tint: Color? = nil, interactive: Bool = false, forceMaterial: Bool = false
) -> some View {
modifier(ConsoleGlass(
shape: shape, tint: tint, interactive: interactive, forceMaterial: forceMaterial))
func consoleGlass<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = false) -> some View {
modifier(ConsoleGlass(shape: shape, tint: tint, interactive: interactive))
}
}
@@ -1,328 +0,0 @@
// The gamepad-driven PIN pairing screen (iOS/iPadOS/macOS) the controller counterpart of
// PairSheet, and the reason a console-UI user can pair at all.
//
// PairSheet is a `Form` with two `TextField`s. On tvOS the focus engine drives those natively, but
// on iOS/macOS a controller cannot reach a text field, type into it, or press the button
// underneath so for anyone in the console UI, pairing (the ONE thing standing between a fresh
// install and a first stream) ended at "now touch the screen". This screen is the same ceremony
// wearing the gamepad UI's own vocabulary: the vertical focus list from the settings/add-host
// screens, A on a field to open GamepadKeyboard in a bottom tray, B to peel one layer.
//
// Structure deliberately mirrors GamepadAddHostView field for field the two screens are the same
// interaction (a short form, typed with a pad, committed by an action row) and a user who has
// added a host should recognise this immediately. The ceremony itself is shared with PairSheet
// (`PairCeremony`), so the two presentations can never disagree about what a wrong PIN means.
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS)
struct GamepadPairView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.displayBottomInset) private var displayBottomInset
@Environment(\.dismiss) private var dismiss
@Environment(\.gamepadHostedInShell) private var hostedInShell
let host: StoredHost
/// Called with the verified host fingerprint after a successful ceremony the caller pins it
/// and connects (ContentView's `handlePaired`).
let onPaired: (Data) -> Void
/// How the in-place shell (iOS) closes this screen; nil (the macOS sheet) falls back to the
/// environment dismiss.
var close: (() -> Void)?
/// Whether this screen owns the controller false while the shell is mid-transition or the
/// connect takeover is up (see GamepadAddHostView's twin).
var controllerActive = true
#if os(iOS)
/// `.compact` in a landscape phone window tighter chrome so the keyboard tray still fits.
@Environment(\.verticalSizeClass) private var vSizeClass
private var compact: Bool { vSizeClass == .compact }
#else
private let compact = false // no size classes on macOS; the sheet is sized to fit the tray
#endif
@StateObject private var ceremony = PairCeremony()
@State private var pin = ""
#if os(macOS)
@State private var clientName = Host.current().localizedName ?? "Mac"
#else
@State private var clientName = UIDevice.current.name
#endif
@State private var focusID: String?
/// The field row the keyboard tray is editing; nil the row list owns the controller.
@State private var editing: String?
var body: some View {
GamepadMenuList(
items: rows,
focusID: $focusID,
onActivate: { activate(id: $0.id) },
onBack: { performClose() },
// A ceremony in flight also takes the list out of the loop: `pair()` blocks on a
// background thread and its result rewrites this screen, so letting B peel a layer
// or A fire a second ceremony underneath it would race the completion.
isActive: controllerActive && editing == nil && !ceremony.busy
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: metrics.rowMaxWidth)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
header
.padding(.horizontal, 24)
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayBlur(edge: .top) }
}
.safeAreaInset(edge: .bottom, spacing: 0) {
bottomTray
// Equal distance from the left and bottom edges for the legend pill (see
// GamepadHomeView).
.padding(.horizontal, compact ? 12 : 18)
.padding(
.bottom,
gamepadLegendBottomPadding(
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
.padding(.top, compact ? 6 : 10)
.background { GamepadTrayBlur(edge: .bottom) }
}
// Hosted in the shell, the field is the shell's own (see GamepadAddHostView's twin).
.background {
if !hostedInShell { GamepadFormBackground() }
}
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
// A PIN is short; cap it so the row can't grow absurd on a stuck key.
.onChange(of: pin) { _, value in
if value.count > Self.maxPINLength { pin = String(value.prefix(Self.maxPINLength)) }
}
// Any dismissal path abandons an in-flight ceremony a late success must not pin and
// connect to a host the user backed out of.
.onDisappear { ceremony.abandon() }
// The visible close is gone (a gamepad UI exits with B) this keeps a hardware
// keyboard's Esc and the macOS sheet's cancel working without chrome.
.background {
Button("Cancel") { performClose() }
.keyboardShortcut(.cancelAction)
.buttonStyle(.plain)
.frame(width: 0, height: 0)
.opacity(0)
.accessibilityHidden(true)
}
}
/// Generous next to the host's 4 digits: the PIN length is the HOST's business (a future one
/// may well be longer), so this is a runaway guard, not a validator. Rejecting a correct PIN
/// locally would be a far worse failure than sending a wrong one, which the host just refuses.
private static let maxPINLength = 12
private var header: some View {
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
// Leading, like every gamepad heading and no close chrome (B is the exit).
Text("Pair with \(host.displayName)")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.lineLimit(1)
.minimumScaleFactor(0.7)
if !compact {
Text("The PIN is shown in the host's web console (port 47992 → Pairing). "
+ "Pairing verifies both sides at once — no fingerprint comparison needed.")
.font(.geist(metrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.multilineTextAlignment(.leading)
.frame(maxWidth: metrics.rowMaxWidth * 0.72, alignment: .leading)
}
}
}
/// The keyboard tray while editing, the status line + controls legend otherwise.
@ViewBuilder private var bottomTray: some View {
if let editing {
VStack(spacing: 10) {
GamepadKeyboard(
text: editingBinding(editing),
allowed: allowedCharacters(editing),
onDone: { closeKeyboard() })
// Fresh keyboard per field (see GamepadAddHostView) the tray's input wiring
// captured the previous binding on appear.
.id(editing)
GamepadHintBar(hints: [
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
.init(
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
action: { backspace(editing) }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
action: { closeKeyboard() }),
])
.frame(maxWidth: .infinity, alignment: .leading)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
} else {
VStack(alignment: .leading, spacing: 8) {
statusLine
GamepadHintBar(hints: [
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
action: { if let focusID { activate(id: focusID) } }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel",
action: { performClose() }),
])
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// What the ceremony is doing, in the slot the settings screen gives its detail line. Reserves
/// its space so the legend never jumps when a failure arrives.
@ViewBuilder private var statusLine: some View {
Group {
if ceremony.busy {
HStack(spacing: 8) {
ProgressView().controlSize(.small).tint(ink.fg(0.7))
Text("Pairing with \(host.displayName)").foregroundStyle(ink.fg(0.7))
}
} else if let error = ceremony.errorText {
Text(error).foregroundStyle(.red)
} else {
// Placeholder keeps the reserved height honest under `lineLimit(2)`.
Text(" ").foregroundStyle(.clear)
}
}
.font(.geist(metrics.detailFont, relativeTo: .caption))
.lineLimit(2, reservesSpace: true)
.multilineTextAlignment(.leading)
.frame(maxWidth: metrics.rowMaxWidth, alignment: .leading)
.animation(.smooth(duration: 0.2), value: ceremony.errorText)
}
/// Close this screen through whichever mechanism presents it: the shell's layer pop on iOS,
/// the environment dismiss under a macOS sheet.
private func performClose() {
ceremony.abandon()
if let close { close() } else { dismiss() }
}
// MARK: - Rows
private struct Row: Identifiable {
let id: String
let label: String
var value = ""
var placeholder = ""
var isAction = false
}
private var rows: [Row] {
[
Row(id: "pin", label: "PIN", value: pin, placeholder: "Shown in the web console"),
Row(
id: "name", label: "Device name", value: clientName,
placeholder: "How the host lists this device"),
Row(id: "pair", label: "Pair & Connect", isAction: true),
]
}
private func rowView(_ row: Row, focused: Bool) -> some View {
let m = metrics
return HStack(spacing: 14) {
if row.isAction {
Label("Pair & Connect", systemImage: "lock.shield")
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(canPair ? ink.accent : ink.fg(0.35))
.frame(maxWidth: .infinity)
} else {
Text(row.label)
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(ink.fg)
Spacer(minLength: 12)
Text(row.value.isEmpty ? row.placeholder : row.value)
.font(.geistFixed(m.valueFont, .medium))
.foregroundStyle(row.value.isEmpty ? ink.fg(0.35) : ink.fg)
.lineLimit(1)
.truncationMode(.head) // keep the end of a long name visible while typing
if editing == row.id {
// The live-edit caret: this row is what the keyboard tray is typing into.
Rectangle()
.fill(ink.accent)
.frame(width: 2, height: m.labelFont + 2)
}
}
}
.padding(.horizontal, m.rowHPad)
.padding(.vertical, m.rowVPad)
.consoleGlass(
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: (focused || editing == row.id) ? ink.accent(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(
editing == row.id ? ink.accent(0.7) : ink.fg(focused ? 0.28 : 0.06),
lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
.animation(.smooth(duration: 0.18), value: focused)
}
// MARK: - Actions
private func activate(id: String) {
guard !ceremony.busy else { return }
switch id {
case "pair":
guard canPair else {
// Not pairable yet jump straight to what's missing instead of a dead press,
// matching the add-host screen's Add row.
focusID = "pin"
openKeyboard("pin")
return
}
ceremony.run(host: host.address, port: host.port, pin: pin, clientName: clientName) {
fingerprint in
onPaired(fingerprint)
// NOT `performClose()`: that abandons the ceremony, and this IS the ceremony's
// success. Closing is all that's left to do.
if let close { close() } else { dismiss() }
}
default:
openKeyboard(id)
}
}
private var canPair: Bool {
!pin.trimmingCharacters(in: .whitespaces).isEmpty && !ceremony.busy
}
private func openKeyboard(_ id: String) {
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = id }
}
private func closeKeyboard() {
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = nil }
}
private func editingBinding(_ id: String) -> Binding<String> {
id == "pin" ? $pin : $clientName
}
/// The legend's Delete cell see GamepadAddHostView's twin for why this edits the binding
/// rather than reaching into the keyboard.
private func backspace(_ id: String) {
let binding = editingBinding(id)
guard !binding.wrappedValue.isEmpty else { return }
binding.wrappedValue.removeLast()
}
/// What the keyboard may type per field: a PIN is digits; a device name is free-form.
private func allowedCharacters(_ id: String) -> CharacterSet? {
id == "pin" ? CharacterSet(charactersIn: "0123456789") : nil
}
}
#endif
@@ -1,86 +0,0 @@
// The SPAKE2 PIN ceremony itself, with no opinion about how it's presented. Two screens run it:
// `PairSheet` (the touch/desktop Form, and tvOS's focus-engine layout) and `GamepadPairView` (the
// controller-driven console screen). The ceremony is the part that must not diverge between them
// it decides what counts as a wrong PIN, what a rejection means, and which failures are worth
// telling the user apart so it lives here once rather than being copied into the second caller.
//
// Threading: `pair()` and the identity load both BLOCK, so they run off the main actor; every
// published mutation lands back on it.
import Foundation
import PunktfunkKit
import SwiftUI
@MainActor
final class PairCeremony: ObservableObject {
/// A ceremony is in flight callers disable their commit action and show a spinner.
@Published private(set) var busy = false
/// The last failure, in user-facing terms; cleared when a new attempt starts.
@Published var errorText: String?
/// Dismissing the presenting screen must abandon an in-flight ceremony: the blocking `pair()`
/// call can't be interrupted, so its completion checks this token and self-discards a late
/// success must NOT pin and auto-connect to a host the user cancelled out of. A fresh token
/// per attempt, so abandoning one attempt can't silence the next.
private var token = Token()
private final class Token: @unchecked Sendable {
var cancelled = false
}
/// Run the ceremony. `onPaired` receives the host's now-VERIFIED fingerprint the caller pins
/// it and connects; no manual fingerprint comparison is needed, because the host proved itself
/// with the same PIN.
func run(
host address: String, port: UInt16, pin rawPIN: String, clientName rawName: String,
onPaired: @escaping (Data) -> Void
) {
busy = true
errorText = nil
let pin = rawPIN.trimmingCharacters(in: .whitespaces)
let name = rawName.trimmingCharacters(in: .whitespaces)
token = Token()
let token = token
Task.detached(priority: .userInitiated) {
// Identity load + the ceremony both block keep them off the main actor.
// loadForPairing is the strict variant: the host durably trusts this
// identity, so it must have made it into the Keychain.
let result = Result {
let identity = try ClientIdentityStore.shared.loadForPairing()
return try PunktfunkKit.pair(
host: address, port: port, identity: identity,
pin: pin, name: name.isEmpty ? "Mac" : name)
}
await MainActor.run {
guard !token.cancelled else { return } // screen dismissed mid-ceremony
self.busy = false
switch result {
case .success(let fingerprint):
onPaired(fingerprint)
case .failure(PunktfunkClientError.wrongPIN):
self.errorText = "Wrong PIN — check the host's web console (port 47992) "
+ "and try again."
case .failure(PunktfunkClientError.rejected(let rejection)):
// The host answered and said why (not armed / rate-limited / armed for
// another device) show that instead of the guessing-game fallback.
self.errorText = rejection.userMessage
case .failure(is ClientIdentityStore.IdentityError):
self.errorText = "Can't store this Mac's identity in the Keychain, so the "
+ "pairing would not survive a relaunch. Unlock the login "
+ "keychain and try again."
case .failure:
self.errorText = "Pairing failed — the host didn't answer. Is it running, "
+ "and is this device on the same network (no VPN, no guest-Wi-Fi "
+ "isolation)?"
}
}
}
}
/// The presenting screen went away discard whatever is still in flight. Called from every
/// dismissal path (an explicit Cancel, a swipe, B on a controller), which is why it is safe to
/// call when nothing is running.
func abandon() {
token.cancelled = true
}
}
@@ -5,15 +5,19 @@
// host rate-limits ceremonies to one per 2 s). Success returns the host's now-VERIFIED
// fingerprint: the caller pins it, no manual comparison needed, and the host stores this
// client's identity in return.
//
// This is the TOUCH/desktop presentation (and tvOS's, where the focus engine drives the same
// fields). A controller can't reach a `Form`'s text fields on iOS/macOS, so the console UI
// presents `GamepadPairView` instead same ceremony, via the shared `PairCeremony`.
import Foundation
import PunktfunkKit
import SwiftUI
/// Dismissing the sheet must abandon an in-flight ceremony: the blocking pair() call
/// can't be interrupted, so its completion checks this flag and self-discards a late
/// success must NOT pin and auto-connect to a host the user cancelled out of. Only
/// touched on the main actor.
private final class CeremonyToken: @unchecked Sendable {
var cancelled = false
}
struct PairSheet: View {
@Environment(\.dismiss) private var dismiss
let host: StoredHost
@@ -26,10 +30,9 @@ struct PairSheet: View {
#else
@State private var clientName = UIDevice.current.name
#endif
@StateObject private var ceremony = PairCeremony()
private var busy: Bool { ceremony.busy }
private var errorText: String? { ceremony.errorText }
@State private var busy = false
@State private var errorText: String?
@State private var token = CeremonyToken()
#if os(tvOS)
private enum EditField: String, Identifiable {
case pin, clientName
@@ -61,7 +64,7 @@ struct PairSheet: View {
}
HStack(spacing: 32) {
Button("Cancel", role: .cancel) {
ceremony.abandon()
token.cancelled = true
dismiss()
}
if busy {
@@ -75,7 +78,7 @@ struct PairSheet: View {
.frame(maxWidth: 1000)
.padding(60)
.navigationTitle("Pair with \(host.displayName)")
.onDisappear { ceremony.abandon() }
.onDisappear { token.cancelled = true }
.fullScreenCover(item: $editing) { field in
switch field {
case .pin:
@@ -139,7 +142,7 @@ struct PairSheet: View {
#endif
HStack {
Button("Cancel", role: .cancel) {
ceremony.abandon()
token.cancelled = true
dismiss()
}
#if !os(tvOS)
@@ -177,7 +180,7 @@ struct PairSheet: View {
.presentationDragIndicator(busy ? .hidden : .visible)
#endif
.interactiveDismissDisabled(busy)
.onDisappear { ceremony.abandon() } // any other dismissal path
.onDisappear { token.cancelled = true } // any other dismissal path
#endif
}
@@ -192,11 +195,47 @@ struct PairSheet: View {
}
private func runCeremony() {
ceremony.run(
host: host.address, port: host.port, pin: pin, clientName: clientName
) { fingerprint in
onPaired(fingerprint)
dismiss()
busy = true
errorText = nil
let pin = pin.trimmingCharacters(in: .whitespaces)
let name = clientName.trimmingCharacters(in: .whitespaces)
let address = host.address
let port = host.port
let token = token
Task.detached(priority: .userInitiated) {
// Identity load + the ceremony both block keep them off the main actor.
// loadForPairing is the strict variant: the host durably trusts this
// identity, so it must have made it into the Keychain.
let result = Result {
let identity = try ClientIdentityStore.shared.loadForPairing()
return try PunktfunkKit.pair(
host: address, port: port, identity: identity,
pin: pin, name: name.isEmpty ? "Mac" : name)
}
await MainActor.run {
guard !token.cancelled else { return } // sheet dismissed mid-ceremony
busy = false
switch result {
case .success(let fingerprint):
onPaired(fingerprint)
dismiss()
case .failure(PunktfunkClientError.wrongPIN):
errorText = "Wrong PIN — check the host's web console (port 47992) "
+ "and try again."
case .failure(PunktfunkClientError.rejected(let rejection)):
// The host answered and said why (not armed / rate-limited / armed for
// another device) show that instead of the guessing-game fallback.
errorText = rejection.userMessage
case .failure(is ClientIdentityStore.IdentityError):
errorText = "Can't store this Mac's identity in the Keychain, so the "
+ "pairing would not survive a relaunch. Unlock the login "
+ "keychain and try again."
case .failure:
errorText = "Pairing failed — the host didn't answer. Is it running, "
+ "and is this device on the same network (no VPN, no guest-Wi-Fi "
+ "isolation)?"
}
}
}
}
}
@@ -1,12 +1,6 @@
// Trust-on-first-use prompt: shown over the live-but-blurred stream when connecting to an
// unpinned host. The user compares the fingerprint with the one the host logged at startup,
// or drops this and runs the PIN pairing ceremony instead.
//
// Controller-drivable on iOS/macOS (A trust, B cancel, X pair instead). It had no controller
// wiring at all, which made it a dead end for a pad-only user at the worst possible moment: the
// card appears mid-connect with capture disabled (ContentView blurs the stream and stops
// forwarding), so the pad in their hands genuinely did nothing and the only way past was to reach
// for the screen. tvOS needs none of this the focus engine drives the buttons natively.
import Foundation
import PunktfunkKit
@@ -19,12 +13,6 @@ struct TrustCardView: View {
let onTrust: () -> Void
let onPairInstead: () -> Void
#if os(iOS) || os(macOS)
/// Observed so the legend appears the moment a pad wakes up mid-prompt and so it stays
/// absent for the mouse/touch users this card is otherwise for.
@ObservedObject private var gamepads = GamepadManager.shared
#endif
var body: some View {
VStack(spacing: 14) {
Image(systemName: "lock.shield")
@@ -72,35 +60,12 @@ struct TrustCardView: View {
.buttonStyle(.borderless)
#endif
.font(.geist(16, relativeTo: .callout))
#if os(iOS) || os(macOS)
// Only with a pad attached: controller glyphs in front of a trackpad user would be
// naming buttons they don't have.
if gamepads.active != nil {
GamepadHintBar(hints: [
.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Trust",
action: onTrust),
.init(
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Pair with PIN",
action: onPairInstead),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel",
action: onCancel),
])
.padding(.top, 2)
}
#endif
}
.padding(28)
.frame(maxWidth: 440)
// Floating trust card over the blurred stream Liquid Glass on 26+, .regularMaterial
// fallback below. The inner fingerprint box stays .quaternary (content, not glass).
.glassBackground(RoundedRectangle(cornerRadius: 18))
#if os(iOS) || os(macOS)
.background {
TrustControllerInput(onTrust: onTrust, onCancel: onCancel, onPairInstead: onPairInstead)
}
#endif
}
/// 64 hex chars four groups per line, two lines easy to eyeball against the log.
@@ -115,35 +80,6 @@ struct TrustCardView: View {
}
}
#if os(iOS) || os(macOS)
/// Controller binding for the trust prompt: A trusts, B cancels, X runs the PIN ceremony instead.
/// The same zero-size-backing-view shape as `ConnectOverlay`'s `ConnectControllerInput` mounted
/// for exactly as long as the card is up, and `GamepadMenuInput`'s snapshot-on-start swallows
/// whatever button was still held when it appeared (the A press that started the connect is
/// usually still down).
///
/// Nothing else is polling the pad here: capture is off for the duration of the prompt, and the
/// home screens are unmounted behind the session view.
private struct TrustControllerInput: View {
let onTrust: () -> Void
let onCancel: () -> Void
let onPairInstead: () -> Void
@State private var input = GamepadMenuInput(manager: .shared)
var body: some View {
Color.clear
.frame(width: 0, height: 0)
.onAppear {
input.onConfirm = onTrust
input.onBack = onCancel
input.onTertiary = onPairInstead
input.start()
}
.onDisappear { input.stop() }
}
}
#endif
private extension Array {
func chunks(of size: Int) -> [[Element]] {
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
@@ -1,129 +0,0 @@
// "The audio output moved under us" the one signal `SessionAudio` needs to survive a device
// change, and the one piece of it that can be tested without a stream.
//
// Split out of SessionAudio deliberately. An end-to-end test of the recovery needs a live session,
// which needs a host, and punktfunk-host does not build on macOS so the wiring that matters most
// (is the observer actually installed? does the identity check let the notification through?) would
// otherwise ship unverified, and a silent failure in it costs the session ALL of its audio. On its
// own this can be pointed at the real hardware from a unit test: see AudioDeviceWatcherTests.
//
// What it does NOT own: anything with session semantics. The iOS route-change steer and the
// media-services-reset re-activation stay in SessionAudio, next to the AVAudioSession they act on.
import AVFoundation
import os
#if os(macOS)
import CoreAudio
#endif
private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio")
final class AudioDeviceWatcher {
/// Why the owner is being told. Only for the log line every reason leads to the same
/// question, "is playback still on the device it should be on".
enum Reason: String {
/// An engine stopped itself because its IO hardware changed underneath it.
case engineConfiguration = "the audio hardware configuration changed"
/// The system's default output device moved (macOS).
case defaultOutputDevice = "the default output device changed"
}
/// Does this configuration change belong to an engine the session still owns? A retired engine
/// posts one last change as it is torn down, and other AVAudioEngines in the process are not
/// ours to restart.
private let isOurs: (AnyObject?) -> Bool
/// Delivered on the main queue.
private let onChange: (Reason) -> Void
private let lock = NSLock()
private var configObserver: NSObjectProtocol?
#if os(macOS)
private var defaultOutputListener: AudioObjectPropertyListenerBlock?
#endif
init(isOurs: @escaping (AnyObject?) -> Bool, onChange: @escaping (Reason) -> Void) {
self.isOurs = isOurs
self.onChange = onChange
}
deinit { stop() }
/// Idempotent.
func start() {
lock.lock()
let already = configObserver != nil
lock.unlock()
guard !already else { return }
let token = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange, object: nil, queue: nil
) { [weak self] note in
// Posted from whatever thread the IO unit noticed on. The engine is the notification's
// object; it is only ever compared by identity, never resurrected.
let posted = note.object as AnyObject?
DispatchQueue.main.async {
guard let self, self.isOurs(posted) else { return }
self.onChange(.engineConfiguration)
}
}
lock.lock()
configObserver = token
lock.unlock()
#if os(macOS)
// The engine notification is the direct signal, but it is delivered BY an engine useless
// in the two places it is needed most: after a rebuild that could not start (no engine left
// to notify anyone) and on an engine topology whose notification behaviour is unverified
// (the voice-processing engine, which is the DEFAULT macOS configuration and which no Mac
// here can even initialize). The HAL is told either way.
let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
self?.onChange(.defaultOutputDevice) // on the main queue registered against it below
}
var address = Self.defaultOutputAddress()
let status = AudioObjectAddPropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, block)
guard status == noErr else {
log.warning("""
could not watch the default output device (\(status)) an output device change \
mid-stream may need a reconnect
""")
return
}
lock.lock()
defaultOutputListener = block
lock.unlock()
#endif
}
/// Idempotent, and safe from any thread. After it returns, no further `onChange` is delivered
/// except one already in flight on the main queue which the owner's own stopped-flag catches.
func stop() {
lock.lock()
let token = configObserver
configObserver = nil
#if os(macOS)
let listener = defaultOutputListener
defaultOutputListener = nil
#endif
lock.unlock()
if let token { NotificationCenter.default.removeObserver(token) }
#if os(macOS)
guard let listener else { return }
var address = Self.defaultOutputAddress()
AudioObjectRemovePropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, listener)
#endif
}
#if os(macOS)
/// Freshly built per call rather than held in a mutable static: the HAL takes the address
/// `inout` and copies it, so there is nothing to share and a shared one would only be a
/// mutable global.
private static func defaultOutputAddress() -> AudioObjectPropertyAddress {
AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
}
#endif
}
@@ -43,21 +43,8 @@ public enum AudioDevices {
}
private static func defaultInputDevice() -> AudioDeviceID? {
systemDevice(kAudioHardwarePropertyDefaultInputDevice)
}
/// The device the system is currently playing to what an engine with no pinned speaker UID
/// follows, and so what `SessionAudio` compares its live output device against when the
/// default moves (AirPods in or out, a headset unplugged).
static func defaultOutputDevice() -> AudioDeviceID? {
systemDevice(kAudioHardwarePropertyDefaultOutputDevice)
}
private static func systemDevice(
_ selector: AudioObjectPropertySelector
) -> AudioDeviceID? {
var address = AudioObjectPropertyAddress(
mSelector: selector,
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var dev = AudioDeviceID(0)
@@ -21,10 +21,6 @@
//
// Devices are chosen by UID ("" = system default: the engine is then never pinned to a
// concrete device and follows default-device changes).
//
// Surviving the hardware. An AVAudioEngine does NOT follow the audio hardware: when the output
// device changes underneath a running engine, the engine stops itself and stays stopped. The
// session therefore watches for that and rebuilds its engines see "Device changes" below.
import AVFoundation
import os
@@ -83,53 +79,14 @@ public final class SessionAudio {
/// session's activate.
private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session")
#endif
#if !os(macOS)
/// Token for the route-change observer: it revives an engine the route change stopped, and on
/// iOS re-applies the earpiece steer (see `installRouteObserver`). Guarded by `stateLock`.
#if os(iOS)
/// Live only for a `.playAndRecord` session: the token for the route-change observer that
/// keeps the BUILT-IN output on the speaker rather than the earpiece (see
/// `steerBuiltInOutputToSpeaker`). A `.playback` session already prefers the speaker and
/// never needs steering, so the mic-off path installs nothing. Guarded by `stateLock`.
private var routeObserver: NSObjectProtocol?
/// Token for the media-services-reset observer the audio server restarting takes the
/// session's configuration and every engine with it. Guarded by `stateLock`.
private var mediaResetObserver: NSObjectProtocol?
/// Token for the interruption observer a phone call or a non-mixable app stops the engines,
/// and ending the interruption restarts nothing by itself (see
/// `installInterruptionObserver`). Guarded by `stateLock`.
private var interruptionObserver: NSObjectProtocol?
#endif
// MARK: - Device changes (see `installDeviceChangeRecovery`)
/// What `start()` was asked for, so a rebuild can put back the SAME topology the session was
/// started with. Main-thread confined, like the start paths that read it.
private var startConfig: StartConfig?
private struct StartConfig {
let speakerUID: String
let micUID: String
let micChannel: Int
let micEnabled: Bool
let echoCancel: Bool
}
/// Watches the hardware for us (see `AudioDeviceWatcher`). Guarded by `stateLock`.
private var deviceWatcher: AudioDeviceWatcher?
/// Whether the engines have been built at least once. Distinguishes "not started yet" (iOS
/// starts asynchronously) from "started and dead", which is what the recovery may act on.
/// Main-thread confined.
private var enginesAttempted = false
/// A rebuild is already on the main queue one device switch produces a burst of triggers
/// and they must collapse into one restart. Main-thread confined.
private var rebuildQueued = false
/// `systemUptime` of the last rebuild, so a device that renegotiates in a loop cannot spin
/// the session. Main-thread confined.
private var lastRebuildAt: TimeInterval = 0
/// Let the burst of triggers from one switch land before rebuilding.
private static let rebuildDebounce: TimeInterval = 0.15
/// Floor between two rebuilds.
private static let rebuildFloor: TimeInterval = 0.5
/// Retries when a rebuild's `start()` loses the race with a device that is still going away
/// (0.3 s, 0.6 s, 1.2 s). A failed rebuild leaves no engine to post the next notification,
/// so this ladder and, on macOS, the HAL listener is all that stands between a mistimed
/// switch and a silent session.
private static let rebuildAttempts = 3
public init(connection: PunktfunkConnection) {
self.connection = connection
}
@@ -139,14 +96,10 @@ public final class SessionAudio {
/// Engine teardown still belongs to stop().
deinit {
flag.stop()
// The observers only hold self weakly, so we can be deinited with them still registered;
// drop them here too rather than leaking them when an owner skips stop().
deviceWatcher?.stop()
#if !os(macOS)
#if os(iOS)
// The observer only holds self weakly, so we can be deinited with it still registered;
// drop the token here too rather than leaking it when an owner skips stop().
if let routeObserver { NotificationCenter.default.removeObserver(routeObserver) }
if let mediaResetObserver {
NotificationCenter.default.removeObserver(mediaResetObserver)
}
#endif
}
@@ -167,12 +120,6 @@ public final class SessionAudio {
videoLatency: LatencyMeter? = nil
) {
self.videoLatency = videoLatency
// Before any engine exists: the recovery watches the hardware, not the engines, and the
// config it rebuilds from has to be recorded whether or not this start succeeds.
startConfig = StartConfig(
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
micEnabled: micEnabled, echoCancel: echoCancel)
installDeviceChangeRecovery(micEnabled: micEnabled)
#if os(macOS)
// No AVAudioSession on macOS start the engines directly (caller's thread, as before).
startEngines(
@@ -223,17 +170,9 @@ public final class SessionAudio {
// make a headset's MIC usable, but it buys that by dragging the whole route onto
// HFP/SCO and collapsing game audio to narrowband. High-quality A2DP output plus
// the built-in mic is the better trade for a game-streaming client.
// `.mixWithOthers`, both branches: without it this session is EXCLUSIVE merely
// activating it paused the user's Music at connect, and Music's RESUME took the
// session right back, which read as "stream audio stops when I resume Music"
// (field report; the interruption observer below is the other half of that fix).
// A game stream mixing over someone's playlist is the behavior a console has,
// and what this client's peers do. The trade is real but right: a mixable
// session is nobody's Now Playing app, so the lock screen shows the music, not
// the stream which is exactly how it should read.
try session.setCategory(
.playAndRecord, mode: .default,
options: [.allowBluetoothA2DP, .mixWithOthers])
options: [.allowBluetoothA2DP])
// Uplink latency: ask for 5 ms IO quanta at the wire rate (the default ~10-23 ms
// quantum is most of the mic path's burst latency). Best-effort the hardware
// has the final word (a Bluetooth route will ignore both), and whatever quantum
@@ -241,19 +180,19 @@ public final class SessionAudio {
try? session.setPreferredIOBufferDuration(0.005)
try? session.setPreferredSampleRate(48_000)
} else {
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
try session.setCategory(.playback, mode: .default)
}
#else // tvOS no app-accessible mic
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
try session.setCategory(.playback, mode: .default)
#endif
try session.setActive(true)
#if os(iOS)
// Only the `.playAndRecord` session can land on the earpiece, and only it accepts an
// output override so the mic-off (`.playback`) path deliberately does neither.
// (The route OBSERVER that re-applies this per route is installed by
// `installDeviceChangeRecovery`, for every session a `.playback` session steers
// nothing but still has engines a route change can stop.)
if micEnabled { steerBuiltInOutputToSpeaker(session) }
if micEnabled {
steerBuiltInOutputToSpeaker(session)
installRouteObserver()
}
#endif
} catch {
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
@@ -281,20 +220,11 @@ public final class SessionAudio {
}
}
#endif
#if !os(macOS)
/// Routes change under a live session: a headset connects mid-stream, or disconnects and hands
/// the stream back to the built-in output. Two things follow from that.
///
/// iOS drops an output override whenever the route changes which is what lets a newly-
/// connected headset win so the earpiece steer is a property of the CURRENT route and has to
/// be re-applied per route. Without it, dropping Bluetooth mid-stream lands the game on the
/// earpiece.
///
/// And on every platform a route change can take the engines down with it (see
/// `installDeviceChangeRecovery`), which is why this is installed for `.playback` sessions and
/// on tvOS too, where there is no earpiece to steer away from.
/// the stream back to the built-in output. iOS drops an output override whenever the route
/// changes which is what lets a newly-connected headset win so the earpiece steer is a
/// property of the CURRENT route and has to be re-applied per route. Without this, dropping
/// Bluetooth mid-stream would land the game on the earpiece.
private func installRouteObserver() {
let observer = NotificationCenter.default.addObserver(
forName: AVAudioSession.routeChangeNotification,
@@ -305,10 +235,7 @@ public final class SessionAudio {
// other call into it.
SessionAudio.sessionQueue.async {
guard let self, !self.flag.isStopped else { return }
#if os(iOS)
self.steerBuiltInOutputToSpeaker(AVAudioSession.sharedInstance())
#endif
DispatchQueue.main.async { self.reviveStoppedEngines("the audio route changed") }
}
}
stateLock.lock()
@@ -325,7 +252,6 @@ public final class SessionAudio {
private func startEngines(
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool
) {
enginesAttempted = true // even if every path below fails see `reviveStoppedEngines`
#if os(tvOS)
// No app-accessible microphone input on tvOS playback only.
startPlayback(speakerUID: speakerUID)
@@ -399,34 +325,35 @@ public final class SessionAudio {
public func stop() {
flag.stop() // before taking the engines see stateLock's comment
stateLock.lock()
let capture = captureEngine
captureEngine = nil
let playback = playbackEngine
playbackEngine = nil
let combined = combinedEngine
combinedEngine = nil
let wasDraining = drainStarted
drainStarted = false
let watcher = deviceWatcher
deviceWatcher = nil
#if !os(macOS)
#if os(iOS)
let route = routeObserver
routeObserver = nil
let mediaReset = mediaResetObserver
mediaResetObserver = nil
let interruption = interruptionObserver
interruptionObserver = nil
#endif
stateLock.unlock()
// Every watcher goes before the engines do: a device change landing during teardown must
// not schedule a rebuild of a session we are in the middle of releasing. (`flag` already
// guards that, but not arming the trigger is better than catching it.) On iOS this is
// also ahead of the deactivate below, so a route change cannot re-steer a dying session.
watcher?.stop()
#if !os(macOS)
#if os(iOS)
// Before the deactivate below, so a route change during teardown can't re-steer a session
// we are in the middle of releasing.
if let route { NotificationCenter.default.removeObserver(route) }
if let mediaReset { NotificationCenter.default.removeObserver(mediaReset) }
if let interruption { NotificationCenter.default.removeObserver(interruption) }
#endif
tearDownEngines()
if let capture {
capture.inputNode.removeTap(onBus: 0)
capture.stop()
}
playback?.stop()
if let combined {
combined.inputNode.removeTap(onBus: 0)
combined.stop()
}
#if !os(macOS)
// Release the session. (A mixable session interrupts nobody, so the resume cue below is
// now a courtesy for the edge where an OLD non-mixable install interrupted something
// harmless either way, and deactivating promptly is still what orders a reconnect.) Like
// Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like
// activation, setActive is synchronous/blocking run it on the shared serial session queue
// (off the main thread). Enqueued HERE engines already stopped, and BEFORE the drain wait
// below so across a reconnect it lands ahead of the next session's activate on the shared
@@ -445,267 +372,6 @@ public final class SessionAudio {
}
}
/// Stop and release every engine we own, leaving the ring, the drain thread, the observers and
/// the audio session alone the teardown half shared by `stop()` and a rebuild. Safe from any
/// thread; the engines are taken under the lock before any of them is touched.
private func tearDownEngines() {
stateLock.lock()
let capture = captureEngine
captureEngine = nil
let playback = playbackEngine
playbackEngine = nil
let combined = combinedEngine
combinedEngine = nil
stateLock.unlock()
if let capture {
capture.inputNode.removeTap(onBus: 0)
capture.stop()
}
playback?.stop()
if let combined {
combined.inputNode.removeTap(onBus: 0)
combined.stop()
}
}
// MARK: - Device changes
/// An AVAudioEngine does not follow the audio hardware. When the output device changes under a
/// running engine AirPods taken out of an ear, a headset unplugged, the default switched in
/// System Settings the engine's IO unit sees the new hardware, THE ENGINE STOPS ITSELF, and
/// it posts `AVAudioEngineConfigurationChange`. It stays stopped until somebody starts it
/// again. Nothing here ever did, so from that moment the session rendered silence: no audio on
/// the speakers the stream had just moved to, and none in the AirPods when they went back in
/// (that is a second stop, not a recovery), until the whole stream was restarted. Measured on
/// this exact topology: render callbacks go from ~94/s to zero the instant the default output
/// device changes, and both restarting the same engine and building a fresh one resume them.
///
/// Three triggers feed one rebuild, because no single one of them covers the ground:
///
/// - the engine notification, everywhere the direct signal, but only an engine that still
/// EXISTS can post it, so it cannot report a rebuild that failed to start;
/// - the HAL default-output-device listener, macOS independent of any engine and of the
/// engine's topology. It is what makes the recovery work for the voice-processing engine
/// (mic + echo cancellation, the DEFAULT macOS configuration) without having to assume that
/// a VPIO engine posts the notification the plain one demonstrably does;
/// - the route-change and media-services-reset notifications, iOS/tvOS, where the session and
/// not the device is what moves.
///
/// `micEnabled` only decides whether the mic-bearing session observers are worth installing.
/// Main thread.
private func installDeviceChangeRecovery(micEnabled: Bool) {
stateLock.lock()
let already = deviceWatcher != nil
stateLock.unlock()
guard !already else { return } // a second start() on one SessionAudio: keep the first set
let watcher = AudioDeviceWatcher(
isOurs: { [weak self] posted in self?.ownsEngine(posted) ?? false },
onChange: { [weak self] reason in self?.hardwareMoved(reason) })
stateLock.lock()
deviceWatcher = watcher
stateLock.unlock()
watcher.start()
#if !os(macOS)
installRouteObserver()
installMediaResetObserver(micEnabled: micEnabled)
installInterruptionObserver(micEnabled: micEnabled)
#endif
}
/// Is `posted` one of the engines this session currently owns? A retired engine posts one last
/// configuration change as it is torn down, and another AVAudioEngine in the process is none of
/// our business identity only, the object is never resurrected.
private func ownsEngine(_ posted: AnyObject?) -> Bool {
stateLock.lock()
defer { stateLock.unlock() }
return posted === playbackEngine || posted === captureEngine || posted === combinedEngine
}
/// The hardware moved (main queue, from `AudioDeviceWatcher`). Both reasons ask the same
/// question is playback still where it should be but they answer it differently: an engine
/// that told us it stopped is definitive, while the default device moving might not concern us
/// at all.
private func hardwareMoved(_ reason: AudioDeviceWatcher.Reason) {
guard !flag.isStopped else { return }
switch reason {
case .engineConfiguration:
scheduleEngineRebuild(reason: reason.rawValue)
case .defaultOutputDevice:
#if os(macOS)
defaultOutputChanged()
#else
break // the watcher only raises this one on macOS
#endif
}
}
/// Restart the engines if and only if playback is down. The conservative trigger: it is
/// what a route change (iOS/tvOS) and the macOS backstop get to do, since a HEALTHY engine
/// that followed the change on its own must not be interrupted for it.
///
/// Gated on a start having been ATTEMPTED rather than on an engine existing, which is the
/// difference between recovering a session whose very first `startPlayback` failed no
/// output device at the moment it connected and leaving it silent for good. On iOS the same
/// flag keeps this from racing the asynchronous start, where no engine yet is normal.
private func reviveStoppedEngines(_ reason: String) {
guard !flag.isStopped, enginesAttempted, !playbackIsLive else { return }
scheduleEngineRebuild(reason: "playback is stopped and \(reason)")
}
/// Is the render side actually running? Both engines can carry it (`combinedEngine` when the
/// voice processor is engaged, `playbackEngine` otherwise). Taken out from under `stateLock`
/// before asking AVAudioEngine anything the lock guards our handles, not the framework.
private var playbackIsLive: Bool {
stateLock.lock()
let playback = playbackEngine
let combined = combinedEngine
stateLock.unlock()
return (playback?.isRunning ?? false) || (combined?.isRunning ?? false)
}
/// Coalesce: one device switch produces a burst the old device leaving, the default moving,
/// the new device settling, and each engine we own posting its own change and one rebuild
/// serves all of it. The floor between rebuilds keeps a device that renegotiates in a loop
/// from spinning the session. Main thread.
private func scheduleEngineRebuild(reason: String) {
guard !rebuildQueued else { return }
rebuildQueued = true
let since = ProcessInfo.processInfo.systemUptime - lastRebuildAt
let delay = max(Self.rebuildDebounce, Self.rebuildFloor - since)
log.info("\(reason) — restarting the audio engines in \(Int(delay * 1000)) ms")
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.rebuildEngines(attempt: 0)
}
}
/// Put back the topology this session was started with, on whatever hardware is there now.
///
/// A full rebuild rather than a `start()` on the stopped engine, because the mic side has to
/// follow too: `installMicTap` reads the input's live format, and the voice processor
/// renegotiates its own. The RING is deliberately not touched it is the one thing carried
/// across (`makePlaybackChain` reuses it, `startDrain` is idempotent), so the drain thread
/// keeps decoding right through the switch and its overflow policy has already dropped
/// everything that went stale while the engine was down.
private func rebuildEngines(attempt: Int) {
rebuildQueued = false
guard !flag.isStopped, let config = startConfig else { return }
lastRebuildAt = ProcessInfo.processInfo.systemUptime
tearDownEngines()
startEngines(
speakerUID: config.speakerUID, micUID: config.micUID, micChannel: config.micChannel,
micEnabled: config.micEnabled, echoCancel: config.echoCancel)
// Did playback actually come back? A device caught mid-transition can refuse to start, and
// a rebuild that fails leaves no engine to post the next notification so this is the one
// path that must not just give up. (`startEngines` has logged the reason already.)
if playbackIsLive {
log.info("audio engines restarted on the current device")
return
}
guard attempt < Self.rebuildAttempts else {
#if os(macOS)
log.error("""
audio did not come back after the device change the default-output watcher will \
try again when a device appears
""")
#else
log.error("audio did not come back after the route change")
#endif
return
}
rebuildQueued = true // holds off a trigger that would only race this ladder
let delay = Self.rebuildDebounce * Double(1 << (attempt + 1))
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.rebuildEngines(attempt: attempt + 1)
}
}
#if os(macOS)
/// The system's output device moved. Rebuild only when it actually concerns this session: the
/// engine is gone or stopped, or it is playing to a device that is no longer the one we should
/// be on. Somebody changing the default while we are pinned to a named speaker is none of our
/// business, and rebuilding for it would cost an audible gap for nothing. Main queue (the
/// listener block is registered against it).
private func defaultOutputChanged() {
guard !flag.isStopped, let config = startConfig else { return }
stateLock.lock()
let engine = combinedEngine ?? playbackEngine
stateLock.unlock()
guard let engine, engine.isRunning, let unit = engine.outputNode.audioUnit,
let playingOn = Self.currentDevice(of: unit)
else {
// Nothing is playing. If an engine was expected at all, this is the backstop firing.
reviveStoppedEngines("the default output device moved")
return
}
// Empty UID = follow the system default; a pinned UID only moves if that device itself
// came or went, which `deviceID(forUID:)` reports by resolving to a different ID or none.
let shouldBeOn = config.speakerUID.isEmpty
? AudioDevices.defaultOutputDevice()
: AudioDevices.deviceID(forUID: config.speakerUID)
guard let shouldBeOn, shouldBeOn != playingOn else { return }
scheduleEngineRebuild(reason: "the output device changed under the session")
}
#endif
#if !os(macOS)
/// The audio server can die and restart. It takes the session's configuration and every engine
/// with it, and the documented recovery is to build all of it again the same rebuild a route
/// change uses, with the session activation back in front of it.
private func installMediaResetObserver(micEnabled: Bool) {
let observer = NotificationCenter.default.addObserver(
forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: nil
) { [weak self] _ in
SessionAudio.sessionQueue.async {
guard let self, !self.flag.isStopped else { return }
self.activateAudioSession(micEnabled: micEnabled)
DispatchQueue.main.async {
self.scheduleEngineRebuild(reason: "the audio services were reset")
}
}
}
stateLock.lock()
let stale = mediaResetObserver
mediaResetObserver = observer
stateLock.unlock()
if let stale { NotificationCenter.default.removeObserver(stale) }
}
/// Interruptions still happen to a mixable session a phone call, Siri, an app that claims
/// a NON-mixable session of its own. iOS stops the engines, and when the interruption ends it
/// restarts NOTHING by itself; before this observer the stream just stayed silent (under the
/// old exclusive category, Music itself was such an interrupter, which is how "resume Music,
/// lose the stream" was ever possible). Reactivate and revive on `.ended` unconditionally,
/// not only when iOS hints `.shouldResume`: a live stream is the one case where the user's
/// intent to keep hearing it is not in doubt, and `reviveStoppedEngines` already declines
/// when playback never went down.
private func installInterruptionObserver(micEnabled: Bool) {
let observer = NotificationCenter.default.addObserver(
forName: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance(), queue: nil
) { [weak self] note in
guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
AVAudioSession.InterruptionType(rawValue: raw) == .ended else { return }
SessionAudio.sessionQueue.async {
guard let self, !self.flag.isStopped else { return }
// The full activation, not a bare `setActive`: an interruption can drop the
// category configuration too, and on iOS the earpiece steer is per-route.
self.activateAudioSession(micEnabled: micEnabled)
DispatchQueue.main.async {
self.reviveStoppedEngines("an audio interruption ended")
}
}
}
stateLock.lock()
let stale = interruptionObserver
interruptionObserver = observer
stateLock.unlock()
if let stale { NotificationCenter.default.removeObserver(stale) }
}
#endif
/// Silence the mic uplink (no room audio leaves the device) or restore it. THE one muting
/// mechanism: the owner composes its reasons the user's in-stream mute and the background
/// keep-alive's privacy mute into one effective state and passes that here, so neither can
@@ -771,21 +437,6 @@ public final class SessionAudio {
return Stats(bufferMS: s.bufferedMS, avOffsetMS: s.avOffsetMS)
}
#if os(macOS)
/// Whether playback is rendering, and the device it is rendering to. The device-change
/// recovery has exactly one observable signature from outside "running again, on the device
/// the system just moved to" and nothing else here could tell the two halves apart: a
/// stopped engine can still name the old device, and a retargeted one can still be stopped.
/// Used by `AudioDeviceSwitchTests`.
var playbackState: (running: Bool, device: AudioDeviceID?) {
stateLock.lock()
let engine = combinedEngine ?? playbackEngine
stateLock.unlock()
guard let engine else { return (false, nil) }
return (engine.isRunning, engine.outputNode.audioUnit.flatMap(Self.currentDevice(of:)))
}
#endif
// MARK: - Playback (host speaker)
/// The playback jitter ring + the source node draining it shared by the plain playback
@@ -1002,34 +1002,22 @@ public final class PunktfunkConnection {
/// Pull the next EFFECTIVE rumble command from the core's shared rumble policy engine the
/// uniform replacement for per-platform rumble policy. The engine owns every decision
/// (v2 lease expiry, legacy-host staleness at a uniform 1 s, connection-close drain zeros),
/// so apply commands verbatim: all-zero = stop now, non-zero = run at this level.
/// so apply commands verbatim: `(0, 0)` = stop now, non-zero = run at this level.
/// `backstopMs` is a safety-net duration for duration-parameterized platform APIs the
/// CoreHaptics renderer ignores it (its finite segment ceiling is the equivalent net).
/// Drain from the (single) feedback thread, alongside `nextHidOutput`.
///
/// A command carries FOUR motor levels: the two handles plus the two Xbox impulse-trigger
/// motors (`leftTrigger`/`rightTrigger`, same 0...0xFFFF scale), which arrive on the 0xCA
/// plane's v3 tail. This calls the core's `_cmd2` entry point `_cmd` is the frozen
/// two-handle form kept for out-of-tree embedders, and there is no reason for this client to
/// stay on it: a pad that reports no `GCHapticsLocality.leftTrigger`/`.rightTrigger` simply
/// has no engine for those levels and they go nowhere, which is the normal case.
public func nextRumbleCommand(timeoutMs: UInt32 = 0) throws
-> (
pad: UInt16, low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16,
backstopMs: UInt32
)?
-> (pad: UInt16, low: UInt16, high: UInt16, backstopMs: UInt32)?
{
feedbackLock.lock()
defer { feedbackLock.unlock() }
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0, backstop: UInt32 = 0
var lt: UInt16 = 0, rt: UInt16 = 0
let rc = punktfunk_connection_next_rumble_cmd2(
h, &pad, &low, &high, &lt, &rt, &backstop, timeoutMs)
let rc = punktfunk_connection_next_rumble_cmd(h, &pad, &low, &high, &backstop, timeoutMs)
switch rc {
case statusOK:
return (pad, low, high, lt, rt, backstop)
return (pad, low, high, backstop)
case statusNoFrame:
return nil
case statusClosed:
@@ -172,8 +172,7 @@ public final class GamepadFeedback {
while rumbleBurst < 64, !flag.isStopped,
let c = try connection.nextRumbleCommand(timeoutMs: 0) {
self?.routeRumble(
pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high,
leftTrigger: c.leftTrigger, rightTrigger: c.rightTrigger)
pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high)
rumbleBurst += 1
}
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
@@ -226,21 +225,12 @@ public final class GamepadFeedback {
/// Route one engine command to its pad's renderer (drain thread). A command for a pad with no
/// live renderer one that just left the forwarded set is dropped.
private func routeRumble(
pad: UInt8, low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16
) {
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16) {
let renderer = withRouting { rumbleByPad[pad] }
renderer?.apply(low: low, high: high, leftTrigger: leftTrigger, rightTrigger: rightTrigger)
renderer?.apply(low: low, high: high)
// The opt-in device mirror follows controller 1 unconditionally the pads it exists for
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
//
// HANDLES ONLY, deliberately. A phone body is one actuator with no trigger analogue, so
// the trigger levels would have to be folded to arrive at all and folding continuous
// impulse-trigger content (a racing title's engine RPM / tyre slip) onto the one motor
// this mirror has would buzz the phone flat-out for the whole race at a level the game
// never requested. Dropping them matches the core engine's policy for every pad without
// trigger motors.
if pad == 0 { deviceRumble?.apply(low: low, high: high) }
}
@@ -1,109 +0,0 @@
// Button glyphs for the gamepad UI's legends, for a controller that ISN'T currently attached.
//
// While a pad is connected the truth is GameController's own `sfSymbolsName` on the live element
// nothing here competes with that. The problem this file solves is the other half of the time: the
// instant `GamepadManager.active` goes nil (the pad slept, its battery died, it was unplugged, or
// `gamepadUIMode == "always"` put the console UI up with no pad at all) there is no element left to
// ask, and every legend fell back to the generic letter glyphs which read as an Xbox pad. A
// DualSense user watched their / legends turn into A/B the moment the controller dozed off.
//
// So: `GamepadManager` remembers the KIND of the last controller that was actually attached
// (`DefaultsKey.lastGamepadKind`, never cleared on disconnect) and the legends resolve through this
// table instead. Deliberately NOT a user-facing setting a "glyph style" picker is one more row in
// a settings screen to answer a question the app can answer itself, and the remembered pad is right
// essentially always: people own the controller they last plugged in.
//
// Positional, not nominal. `GCExtendedGamepad`'s buttonA/B/X/Y are POSITIONS (A = bottom, B =
// right, X = left, Y = top), so each family maps its own labels onto those positions which is why
// the Nintendo column looks transposed: a Switch pad's bottom button is B and its right one is A.
import Foundation
import GameController
/// A face/shoulder button by POSITION, which is what `GCExtendedGamepad` exposes and what a legend
/// actually means ("press the bottom button"). The label drawn for it is the family's business.
public enum GamepadButtonRole: Sendable {
/// Bottom face button Xbox A, PlayStation , Nintendo B.
case a
/// Right face button Xbox B, PlayStation , Nintendo A.
case b
/// Left face button Xbox X, PlayStation , Nintendo Y.
case x
/// Top face button Xbox Y, PlayStation , Nintendo X.
case y
case leftShoulder
case rightShoulder
/// The role a `GCExtendedGamepad` key path names, so a caller that already spells its buttons
/// as key paths (every legend in the gamepad UI does it reads `sfSymbolsName` off the live
/// element through one) can reach this table without restating itself. nil for any other
/// button: the legends only ever name these six, and a role invented for, say, the menu button
/// would have no honest glyph on half the families.
///
/// Compared with `==` rather than matched with `switch`: key paths are reference-typed and
/// their pattern-matching goes through the generic `Equatable` `~=`, which is easy to send to
/// an unintended overload. This spelling has exactly one meaning.
public init?(keyPath: KeyPath<GCExtendedGamepad, GCControllerButtonInput>) {
if keyPath == \GCExtendedGamepad.buttonA { self = .a }
else if keyPath == \GCExtendedGamepad.buttonB { self = .b }
else if keyPath == \GCExtendedGamepad.buttonX { self = .x }
else if keyPath == \GCExtendedGamepad.buttonY { self = .y }
else if keyPath == \GCExtendedGamepad.leftShoulder { self = .leftShoulder }
else if keyPath == \GCExtendedGamepad.rightShoulder { self = .rightShoulder }
else { return nil }
}
}
public enum GamepadGlyphs {
/// The SF Symbol a `role` wears on a `kind` of pad. Every name here is asserted to resolve on
/// the running OS by `GamepadGlyphTests` a symbol name that doesn't exist renders as NOTHING
/// (SwiftUI draws an empty image rather than failing), so a typo would silently blank a legend
/// on real hardware and never show up in a build.
public static func symbol(_ role: GamepadButtonRole, for kind: PunktfunkConnection.GamepadType)
-> String {
switch role {
case .leftShoulder: return "l1.rectangle.roundedbottom"
case .rightShoulder: return "r1.rectangle.roundedbottom"
case .a, .b, .x, .y: return faceSymbol(role, for: kind)
}
}
private static func faceSymbol(
_ role: GamepadButtonRole, for kind: PunktfunkConnection.GamepadType
) -> String {
switch kind {
// PlayStation shapes. is the BOTTOM button, so it belongs to role `.a` the mapping
// people mean when they say "the PlayStation glyphs".
case .dualSense, .dualSenseEdge, .dualShock4:
switch role {
case .a: return "xmark.circle"
case .b: return "circle.circle"
case .x: return "square.circle"
case .y: return "triangle.circle"
default: return "circle.circle"
}
// Nintendo's labels sit transposed on the same positions (bottom = B, right = A,
// left = Y, top = X) printing Xbox letters on a Switch pad would name the wrong
// physical button, which is worse than a generic glyph.
case .switchPro:
switch role {
case .a: return "b.circle"
case .b: return "a.circle"
case .x: return "y.circle"
case .y: return "x.circle"
default: return "a.circle"
}
// Xbox, the Steam pads (Deck included its ABXY is the Xbox layout), and `.auto`, which
// is what a client with no remembered pad has. Xbox letters double as the neutral default
// because they ARE the positional names in `GCExtendedGamepad`.
case .auto, .xbox360, .xboxOne, .steamController, .steamDeck, .steamController2:
switch role {
case .a: return "a.circle"
case .b: return "b.circle"
case .x: return "x.circle"
case .y: return "y.circle"
default: return "a.circle"
}
}
}
}
@@ -87,17 +87,6 @@ public final class GamepadManager: ObservableObject {
/// `lowest_free_index`). Recomputed by `assignPadIndices` whenever `forwarded` changes.
private var padIndexByController: [ObjectIdentifier: UInt8] = [:]
/// The kind of the last controller that was actually attached persisted under
/// `DefaultsKey.lastGamepadKind` and deliberately NEVER cleared on disconnect. The gamepad
/// UI's legends read it (through `GamepadGlyphs`) whenever `active` is nil, so a DualSense
/// user's / hints don't turn into A/B the moment the pad sleeps, and so the legends are
/// right at all under `gamepadUIMode == "always"`, which puts the console UI up with no pad
/// attached by design. `.auto` = nothing has ever been seen on this device ( neutral glyphs).
///
/// @Published so the legends re-render when a pad of a different family arrives; the screens
/// already observe this object for `active`.
@Published public private(set) var lastKnownKind: PunktfunkConnection.GamepadType
/// The user's pinned controller fingerprint ("" = automatic). Persisted; updating it
/// reselects immediately, so a Settings Picker can bind straight to this.
@Published public var preferredID: String {
@@ -108,19 +97,12 @@ public final class GamepadManager: ObservableObject {
}
private static let preferredKey = DefaultsKey.gamepadID
private static let lastKindKey = DefaultsKey.lastGamepadKind
/// Connect order (identity-keyed) drives both twin de-dup suffixes and auto-pick.
private var connectOrder: [ObjectIdentifier] = []
private var observers: [NSObjectProtocol] = []
private init() {
preferredID = UserDefaults.standard.string(forKey: Self.preferredKey) ?? ""
// Stored as an Int (what UserDefaults round-trips losslessly) and validated back into a
// real case: a value written by a NEWER client a pad family this build has no case for
// must fall back to the neutral glyphs, not trap on an invalid raw value.
lastKnownKind = (UserDefaults.standard.object(forKey: Self.lastKindKey) as? Int)
.flatMap { UInt32(exactly: $0) }
.flatMap(PunktfunkConnection.GamepadType.init(rawValue:)) ?? .auto
observers.append(NotificationCenter.default.addObserver(
forName: .GCControllerDidConnect, object: nil, queue: .main
) { [weak self] n in
@@ -230,13 +212,6 @@ public final class GamepadManager: ObservableObject {
// (list is in connect order). A stale pin falls back to automatic.
let pinned = candidates.last { $0.id == preferredID }
active = pinned ?? candidates.last
// Remember the family for the legends (see `lastKnownKind`). Only ever WRITTEN, never
// cleared: `active` going nil is precisely the moment the memory has to survive, and a
// pad whose `kind` is genuinely unknown never becomes active in the first place.
if let active, active.kind != lastKnownKind {
lastKnownKind = active.kind
UserDefaults.standard.set(Int(active.kind.rawValue), forKey: Self.lastKindKey)
}
// Forwarded set (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad
// (explicit single-player); Automatic forwards every extended controller in connect order
// (oldestnewest), so a game's player numbers are stable across hot-plug churn.
@@ -1,74 +0,0 @@
// Where the arrow keys go in the library's plain poster grid (LibraryView's touch layout on
// iOS/iPadOS/macOS) the model behind "select games with keyboard arrows, enter to launch".
//
// The grid is up to TWO sections (launcher entries above titles), each rendered as its own
// `LazyVGrid`. A single flat index across both would step by the wrong amount at the boundary
// whenever the first section's last row is partial up from the second section's first row would
// land in the middle of the first section rather than on the row above. So moves happen WITHIN a
// section, with an explicit hand-off at its edges that preserves the column.
//
// Lives in PunktfunkKit rather than beside the view because this is arithmetic with edge cases
// partial rows, section hand-offs, empty sections and PunktfunkKit is the target the tests can
// reach (the app is an executable target). Pure values in, pure value out: no SwiftUI.
import Foundation
public struct LibraryGridNav {
/// Game ids per RENDERED section, in display order. Callers drop empty sections before
/// constructing this, so `sections` never contains one.
public let sections: [[String]]
/// How many columns the grid actually laid out the caller derives it from the measured
/// width using `.adaptive`'s own fitting rule, so a vertical move is exactly one visual row.
public let columns: Int
public init(sections: [[String]], columns: Int) {
self.sections = sections
// A zero or negative count would divide by zero below; one column is the degenerate grid.
self.columns = max(1, columns)
}
/// The id `direction` leads to from `current`, or nil when there is nowhere to go (so the
/// caller leaves the cursor where it is). A nil `current` nothing selected yet lands on
/// the very first tile, so the first arrow press always produces a visible cursor rather than
/// appearing to do nothing.
public func move(from current: String?, _ direction: GamepadMenuInput.Direction) -> String? {
guard !sections.isEmpty else { return nil }
guard let (s, i) = locate(current) else { return sections[0].first }
switch direction {
case .left:
if i > 0 { return sections[s][i - 1] }
return s > 0 ? sections[s - 1].last : nil
case .right:
if i + 1 < sections[s].count { return sections[s][i + 1] }
return s + 1 < sections.count ? sections[s + 1].first : nil
case .up:
if i >= columns { return sections[s][i - columns] }
// Off the top of this section: the section above, same column, its LAST row
// clamped, because that row may be partial.
guard s > 0 else { return nil }
let above = sections[s - 1]
let lastRowStart = ((above.count - 1) / columns) * columns
return above[min(lastRowStart + (i % columns), above.count - 1)]
case .down:
if i + columns < sections[s].count { return sections[s][i + columns] }
// Off the bottom: the section below, same column, its first row.
if s + 1 < sections.count {
let below = sections[s + 1]
return below[min(i % columns, below.count - 1)]
}
// Nothing below. A press from a full row above the last (partial) one still settles
// on the final tile rather than refusing the row IS down from here, just short.
let lastRowStart = ((sections[s].count - 1) / columns) * columns
return i < lastRowStart ? sections[s].last : nil
}
}
/// (section, index within it) for an id, or nil when it isn't in the grid any more.
private func locate(_ id: String?) -> (Int, Int)? {
guard let id else { return nil }
for (s, section) in sections.enumerated() {
if let i = section.firstIndex(of: id) { return (s, i) }
}
return nil
}
}
@@ -36,9 +36,7 @@ enum RumbleTuning {
/// classic Xbox ERM rotor ignores it. On split-handle pads the wire's two motors render at
/// distinct frequencies mirroring the real hardware they emulate low/left the heavy
/// low-frequency rotor, high/right the light buzzer; a single combined actuator keeps the
/// proven mid value. The impulse-trigger motors are small and light the same character as
/// the high/right buzzer so they reuse `sharpnessHigh` rather than introduce a number
/// nobody has measured on real trigger hardware.
/// proven mid value.
static let sharpnessLow: Float = 0.3
static let sharpnessHigh: Float = 0.7
static let sharpnessCombined: Float = 0.5
@@ -142,21 +140,9 @@ final class RumbleRenderer: @unchecked Sendable {
private var controller: GCController?
private var low: Motor?
private var high: Motor?
/// The two Xbox impulse-trigger motors, when the pad offers
/// `GCHapticsLocality.leftTrigger`/`.rightTrigger`. **Nil is the normal case** every pad but
/// an Xbox One/Series/Elite has no such actuator, and the tree has already observed Xbox pads
/// on Apple exposing no haptics engine at all so their absence is never logged and never
/// counts as a setup failure. Independent of the handle split: a pad may offer trigger
/// localities with or without split handles, and losing one does not implicate the other.
private var leftTrigger: Motor?
private var rightTrigger: Motor?
/// Wire-truth target (raw wire units) the engine command's four levels, applied verbatim;
/// the core policy engine owns when it ends (explicit zero commands), so no deadline lives
/// here. The trigger levels are only ever non-zero against a Windows HID Xbox host pad; every
/// other backend on every OS lacks the channel entirely (XInput's `XINPUT_VIBRATION` and
/// evdev's `FF_RUMBLE` each carry exactly two magnitudes).
private var target: (low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16) =
(0, 0, 0, 0)
/// Wire-truth target (raw wire units) the engine command's level, applied verbatim; the
/// core policy engine owns when it ends (explicit zero commands), so no deadline lives here.
private var target: (low: UInt16, high: UInt16) = (0, 0)
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
/// so an idle controller costs no timer wakeups and no radio traffic.
@@ -230,28 +216,22 @@ final class RumbleRenderer: @unchecked Sendable {
}
}
/// Set the wire-truth target: one policy-engine command's four motor levels, applied verbatim.
/// Called with every 0xCA state the host sends level changes AND renewals and the core
/// engine owns when a level ends (it emits explicit zero commands), so nothing here decides.
///
/// `leftTrigger`/`rightTrigger` are the Xbox impulse-trigger motors. They default to zero so
/// handle-only callers (the debug test panel, the tuning tests) read unchanged, which is also
/// the wire's own rule: on a level-triggered plane an absent level is off, never "keep what
/// you had".
func apply(
low lowAmp: UInt16, high highAmp: UInt16, leftTrigger ltAmp: UInt16 = 0,
rightTrigger rtAmp: UInt16 = 0
) {
/// Set the wire-truth target. Called with every 0xCA state the host sends level changes AND
/// renewals (v2) / 500 ms refreshes (legacy); both stamp liveness and, for v2, refresh the
/// self-termination deadline. `ttlMs` is the envelope lease in ms, or [`RumbleTuning.noTTL`]
/// against a legacy host (no lease the staleness watchdog is the backstop). Renewals at an
/// unchanged level extend the deadline before the idempotence guard, so a held rumble never
/// lapses mid-effect.
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
queue.async {
let next = (lowAmp, highAmp, ltAmp, rtAmp)
let active = next != (0, 0, 0, 0)
let active = lowAmp != 0 || highAmp != 0
if active != self.wasActive {
self.wasActive = active
log.debug(
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public) lt=\(ltAmp, privacy: .public) rt=\(rtAmp, privacy: .public)")
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
}
guard next != self.target else { return }
self.target = next
guard (lowAmp, highAmp) != self.target else { return }
self.target = (lowAmp, highAmp)
self.render()
}
}
@@ -261,7 +241,7 @@ final class RumbleRenderer: @unchecked Sendable {
queue.sync {
self.ticker?.cancel()
self.ticker = nil
self.target = (0, 0, 0, 0)
self.target = (0, 0)
self.wasActive = false
self.teardown()
self.closeHID()
@@ -276,7 +256,7 @@ final class RumbleRenderer: @unchecked Sendable {
defer { updateTicker() }
if renderHID() { return }
guard !broken else { return }
let audible = target != (0, 0, 0, 0)
let audible = target.low != 0 || target.high != 0
if audible, low == nil, high == nil, DispatchTime.now() >= retryAfter {
setup()
}
@@ -294,18 +274,6 @@ final class RumbleRenderer: @unchecked Sendable {
let mixed = RumbleTuning.combined(low: target.low, high: target.high)
ok = reconcile(&low, to: RumbleTuning.amplitude(mixed))
}
// Impulse triggers: rendered ONLY where the hardware has the actuators, never folded into
// the handles. `reconcile` on a nil slot is a no-op returning true, so a pad without them
// silently drops the levels which is the correct degrade and the common case.
//
// Their outcome is deliberately kept OUT of `ok`: a trigger engine erroring must not tear
// down the handle engines (which are what the pad's rumble mostly is) nor flip
// `preferCombined`, which is a statement about the handle split and nothing else. Nothing
// is orphaned by that a failed reconcile leaves the slot's Motor in place, so the next
// tick simply retries it, and an engine that is genuinely dead fires its
// stopped/reset handler, which tears down all four slots for a lazy rebuild.
_ = reconcile(&leftTrigger, to: RumbleTuning.amplitude(target.leftTrigger))
_ = reconcile(&rightTrigger, to: RumbleTuning.amplitude(target.rightTrigger))
if !ok {
let wasSplit = high != nil
teardown()
@@ -442,11 +410,9 @@ final class RumbleRenderer: @unchecked Sendable {
/// The ticker runs only while something needs tending any nonzero target (watchdog,
/// throttle catch-up, HID keepalive, post-reset engine rebuild) or segments still alive.
private func updateTicker() {
let needed = target != (0, 0, 0, 0)
let needed = target != (0, 0)
|| low?.current != nil || low?.retiring != nil
|| high?.current != nil || high?.retiring != nil
|| leftTrigger?.current != nil || leftTrigger?.retiring != nil
|| rightTrigger?.current != nil || rightTrigger?.retiring != nil
if needed, ticker == nil {
let t = DispatchSource.makeTimerSource(queue: queue)
t.schedule(
@@ -511,26 +477,6 @@ final class RumbleRenderer: @unchecked Sendable {
preferCombined = true
log.info("rumble: split-handle engines failing — will retry with one combined engine")
}
// Return before the trigger engines: the retry path re-enters setup() on the same
// `low == nil, high == nil` condition, so building them here would leak a fresh pair
// on every attempt (teardown() only runs on the failure paths above, and this is not
// one of them).
return
}
// Impulse-trigger motors, built last and best-effort. Independent of the handle split
// the localities are separate and a pad can offer either, both or neither and NOT part
// of the failure test above: nil here is the ordinary state of every pad that is not an
// Xbox One/Series/Elite, so it must not read as "engine setup failed", back off the handle
// engines, or produce a log line on a path that runs per controller attach.
//
// Whether a given pad + OS pair actually reports these localities is UNVERIFIED on glass.
// The degrade needs no code: `createEngine(withLocality:)` returns nil, the slots stay nil,
// and `reconcile` no-ops on them.
if localities.contains(.leftTrigger) {
leftTrigger = makeMotor(haptics, .leftTrigger, sharpness: RumbleTuning.sharpnessHigh)
}
if localities.contains(.rightTrigger) {
rightTrigger = makeMotor(haptics, .rightTrigger, sharpness: RumbleTuning.sharpnessHigh)
}
}
@@ -617,7 +563,7 @@ final class RumbleRenderer: @unchecked Sendable {
}
private func teardown() {
for m in [low, high, leftTrigger, rightTrigger].compactMap({ $0 }) {
for m in [low, high].compactMap({ $0 }) {
// Disarm the handlers before stopping so stop() can't re-enter teardown via them.
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
m.engine.stoppedHandler = { _ in }
@@ -631,8 +577,6 @@ final class RumbleRenderer: @unchecked Sendable {
}
low = nil
high = nil
leftTrigger = nil
rightTrigger = nil
}
private func seconds(since t: DispatchTime) -> TimeInterval {
@@ -680,16 +624,6 @@ final class RumbleRenderer: @unchecked Sendable {
/// Write the target to the DualSense over HID if that's the active backend; false not a
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
///
/// **The impulse-trigger levels are deliberately dropped here, and there is no mapping to
/// invent.** A DualSense has *adaptive* triggers force resistance on a trigger you press,
/// driven by the separate 0xCD `HidOutput.Trigger` plane and no trigger *motors*. The two
/// features are unrelated hardware that only share a word: an Xbox Series pad has trigger
/// motors and no adaptive triggers, a DualSense has the reverse. Routing wire trigger rumble
/// into either the DS5 rumble bytes (which are the two handles) or the adaptive-trigger
/// parameter block would fabricate feedback the game never asked for. This path returning
/// `true` also means a macOS DualSense never reaches the CoreHaptics trigger localities above,
/// which is correct for the same reason.
private func renderHID() -> Bool {
#if os(macOS)
guard let hid = dualSenseHID else { return false }
@@ -191,13 +191,6 @@ public struct DeepLink: Equatable, Sendable {
profile: (profile?.isEmpty ?? true) ? nil : profile)
}
/// A library link for a saved host the shape the library widget and the Open Library intent
/// emit. Opens the host's game library without starting a session; a session begins only when
/// the user picks a title there, through the normal connect path.
public static func browse(host: UUID) -> DeepLink {
DeepLink(route: .browse, hostRef: host.uuidString)
}
/// The self-emitted form for a saved host: id first (address-independent), with the address
/// and pin alongside so the link degrades to a confirmation sheet instead of a dead click when
/// the record is gone ("Copy link", and any shortcut written from a card).
@@ -32,15 +32,6 @@ public enum DefaultsKey {
public static let compositor = "punktfunk.compositor"
public static let gamepadType = "punktfunk.gamepadType"
public static let gamepadID = "punktfunk.gamepadID"
/// The `PunktfunkConnection.GamepadType` raw value of the last controller that was actually
/// attached written by `GamepadManager` whenever one becomes active, never cleared on
/// disconnect. It exists so the gamepad UI's button legends keep speaking the pad the user
/// owns: the live controller's own `sfSymbolsName` is authoritative while it's connected, but
/// the moment it sleeps or disconnects there is nothing left to ask, and the legends used to
/// snap back to generic letter glyphs (i.e. Xbox) under a DualSense user's hands. Also what
/// makes the legends right at all under `gamepadUIMode == "always"`, where the console UI is
/// up with no pad attached by design. See `GamepadGlyphs`.
public static let lastGamepadKind = "punktfunk.lastGamepadKind"
/// Forward this device's controllers to the host at all (default true). Off is for a
/// couch whose controller reaches the host another way USB passthrough such as
/// VirtualHere, or a pad plugged into the host where forwarding as well would give the
@@ -79,13 +79,7 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable {
// too: the calm mix on the form screens lifts toward nothing. What is left is a
// faint indigoviolet ember in the bright corner. The accent stays the brand violet
// focus has to be findable on black.
//
// Named for the look, not the panel technology: black with a thin violet corona is an
// eclipse, and it belongs beside Nebula and Abyss rather than reading as a spec sheet.
// The ID stays "oled" it is the stored `ui_palette` value AND the cross-client key
// (pf-console-ui's library.rs, the Android GamepadPalette.kt), so renaming it would
// orphan every saved choice and desync the three clients. Only the label moved.
id: "oled", name: "Eclipse",
id: "oled", name: "OLED",
stops: [SIMD3(0.000, 0.000, 0.000), SIMD3(0.000, 0.000, 0.000),
SIMD3(0.010, 0.020, 0.100), SIMD3(0.045, 0.016, 0.115),
SIMD3(0.120, 0.024, 0.130)],
@@ -1,102 +0,0 @@
// The device-switch regression, end to end against a real session.
//
// An AVAudioEngine does not follow the audio hardware: when the output device changes under a
// running engine it STOPS ITSELF and stays stopped. Nothing restarted it, so a stream whose
// output moved mid-session AirPods taken out of an ear, a headset unplugged, the default
// changed in System Settings played silence from that moment on: nothing on the speakers the
// system had just moved to, and nothing in the AirPods when they went back in, since that is a
// second stop rather than a recovery. Only restarting the whole stream brought audio back.
//
// This drives the real `SessionAudio` against the loopback host and moves the system's default
// output device out from under it, twice out and back, the exact shape of the field report.
// Playback-only (mic off): it is the render side that died, and a mic would drag the microphone
// permission and the voice processor into a test that is about neither.
//
// Driven by clients/apple/test-loopback.sh, like its LoopbackIntegrationTests siblings.
#if os(macOS)
import AVFoundation
import CoreAudio
import XCTest
@testable import PunktfunkKit
final class AudioDeviceSwitchTests: XCTestCase {
/// Set the system default output device. Test-local on purpose: nothing in the app ever
/// changes the user's device, it only follows it.
private func setDefaultOutput(_ id: AudioDeviceID) -> OSStatus {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var dev = id
return AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil,
UInt32(MemoryLayout<AudioDeviceID>.size), &dev)
}
/// Pump the MAIN runloop until playback is running on `device`, or the deadline passes. The
/// recovery lands on the main queue (a debounced hop, then possibly a retry ladder), so a
/// sleeping test would block the very thing it is waiting for.
private func waitForPlayback(
_ audio: SessionAudio, on device: AudioDeviceID, timeout: TimeInterval
) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
let state = audio.playbackState
if state.running, state.device == device { return true }
}
return false
}
func testPlaybackFollowsAnOutputDeviceChange() throws {
guard let portStr = ProcessInfo.processInfo.environment["PUNKTFUNK_LOOPBACK_PORT"],
let port = UInt16(portStr)
else {
throw XCTSkip("needs a running punktfunk1-host — use clients/apple/test-loopback.sh")
}
guard let original = AudioDevices.defaultOutputDevice() else {
throw XCTSkip("no default output device")
}
let others = AudioDevices.outputs()
.compactMap { AudioDevices.deviceID(forUID: $0.uid) }
.filter { $0 != original }
guard let target = others.first else {
throw XCTSkip("needs a second output device to switch to")
}
let conn = try PunktfunkConnection(
host: "127.0.0.1", port: port, width: 1280, height: 720, refreshHz: 60,
bitrateKbps: 50_000)
let audio = SessionAudio(connection: conn)
// "" speaker UID = follow the system default, which is what the report was running and
// the only configuration a default-device change is supposed to move.
audio.start(
speakerUID: "", micUID: "", micChannel: 0, micEnabled: false, echoCancel: false)
defer {
audio.stop()
_ = setDefaultOutput(original)
}
XCTAssertTrue(
waitForPlayback(audio, on: original, timeout: 5),
"playback never started on the current default output device")
// Out: the device the stream was playing to goes away underneath it.
XCTAssertEqual(setDefaultOutput(target), noErr)
XCTAssertTrue(
waitForPlayback(audio, on: target, timeout: 10),
"playback did not come back after the output device changed — this is the field "
+ "report: no sound on the device the system moved to, until the stream is "
+ "restarted")
// And back: the second half of the report, where putting the AirPods back in produced a
// second stop rather than a recovery.
XCTAssertEqual(setDefaultOutput(original), noErr)
XCTAssertTrue(
waitForPlayback(audio, on: original, timeout: 10),
"playback did not come back after the output device changed back")
}
}
#endif
@@ -1,121 +0,0 @@
// The trigger half of surviving a device change: does the session actually get TOLD?
//
// An AVAudioEngine stops itself when its output hardware changes and never restarts on its own, so
// everything downstream of these notifications is dead code if the notification never arrives. The
// rebuild itself needs a live session to exercise (and so a host, which does not build on macOS),
// but the wiring does not and the wiring is where a silent failure costs a session all of its
// audio, which is exactly the shape of the bug this watcher exists to fix.
import AVFoundation
import XCTest
#if os(macOS)
import CoreAudio
#endif
@testable import PunktfunkKit
final class AudioDeviceWatcherTests: XCTestCase {
/// The callbacks land on the main queue, so a test that slept would block the thing it waits
/// for. Pumps until `predicate` holds or the deadline passes.
private func pump(until predicate: () -> Bool, timeout: TimeInterval = 2) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if predicate() { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.02))
}
return predicate()
}
/// The identity gate is the one line that could swallow every notification silently: get it
/// wrong and the recovery compiles, installs, runs and never fires.
func testAConfigurationChangeFromOurEngineReachesTheOwner() {
let engine = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
watcher.start()
defer { watcher.stop() }
NotificationCenter.default.post(
name: .AVAudioEngineConfigurationChange, object: engine)
XCTAssertTrue(
pump(until: { reasons.contains(.engineConfiguration) }),
"the session was never told its engine's configuration changed")
}
/// A retired engine posts one last change as it is torn down, and other AVAudioEngines in the
/// process are not ours to restart rebuilding for either would interrupt healthy playback.
func testAConfigurationChangeFromAForeignEngineIsIgnored() {
let ours = AVAudioEngine()
let stranger = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === ours }, onChange: { reasons.append($0) })
watcher.start()
defer { watcher.stop() }
NotificationCenter.default.post(
name: .AVAudioEngineConfigurationChange, object: stranger)
// Give it the same grace the positive case gets, then require silence.
_ = pump(until: { !reasons.isEmpty }, timeout: 0.5)
XCTAssertTrue(reasons.isEmpty, "a foreign engine's change was taken for ours")
}
func testStopSilencesTheWatcher() {
let engine = AVAudioEngine()
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(
isOurs: { $0 === engine }, onChange: { reasons.append($0) })
watcher.start()
watcher.stop()
NotificationCenter.default.post(
name: .AVAudioEngineConfigurationChange, object: engine)
_ = pump(until: { !reasons.isEmpty }, timeout: 0.5)
XCTAssertTrue(reasons.isEmpty, "a stopped watcher still reported")
}
#if os(macOS)
/// The backstop, against the real HAL: move the system's default output device the thing that
/// happens when AirPods come out of an ear and require that the session hears about it. This
/// is the trigger the recovery leans on for the voice-processing engine, whose own notification
/// behaviour cannot be verified here (no Mac in this project's fleet can initialize VPIO).
func testTheDefaultOutputDeviceMovingReachesTheOwner() throws {
guard let original = AudioDevices.defaultOutputDevice() else {
throw XCTSkip("no default output device")
}
let others = AudioDevices.outputs()
.compactMap { AudioDevices.deviceID(forUID: $0.uid) }
.filter { $0 != original }
guard let target = others.first else {
throw XCTSkip("needs a second output device to switch to")
}
var reasons: [AudioDeviceWatcher.Reason] = []
let watcher = AudioDeviceWatcher(isOurs: { _ in false }, onChange: { reasons.append($0) })
watcher.start()
defer {
_ = Self.setDefaultOutput(original)
watcher.stop()
}
XCTAssertEqual(Self.setDefaultOutput(target), noErr)
XCTAssertTrue(
pump(until: { reasons.contains(.defaultOutputDevice) }, timeout: 5),
"the session was never told the default output device moved")
}
/// Test-local on purpose: nothing in the app ever changes the user's device, it only follows it.
private static func setDefaultOutput(_ id: AudioDeviceID) -> OSStatus {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var dev = id
return AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil,
UInt32(MemoryLayout<AudioDeviceID>.size), &dev)
}
#endif
}
@@ -1,89 +0,0 @@
// The remembered-controller glyph table.
//
// The load-bearing assertion here is that every SF Symbol name RESOLVES. `Image(systemName:)`
// renders a name the OS doesn't know as NOTHING at all no crash, no log, no red build so a
// typo in the table would silently blank a legend cell on real hardware and be invisible until
// someone looked at a device. This test is the only thing standing between that and a release.
import GameController
import XCTest
@testable import PunktfunkKit
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
final class GamepadGlyphTests: XCTestCase {
private let roles: [GamepadButtonRole] = [.a, .b, .x, .y, .leftShoulder, .rightShoulder]
/// Does the running OS actually have this symbol?
private func symbolExists(_ name: String) -> Bool {
#if canImport(UIKit)
return UIImage(systemName: name) != nil
#elseif canImport(AppKit)
return NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil
#else
return true
#endif
}
func testEveryGlyphNameResolvesOnThisOS() {
for kind in PunktfunkConnection.GamepadType.allCases {
for role in roles {
let name = GamepadGlyphs.symbol(role, for: kind)
XCTAssertTrue(
symbolExists(name),
"SF Symbol \"\(name)\" (\(role), \(kind)) does not resolve — the legend cell "
+ "would render blank on device")
}
}
}
/// is the BOTTOM button on a PlayStation pad, which is `GCExtendedGamepad.buttonA` the
/// whole point of the table being positional. Getting this backwards would print where the
/// user has to press .
func testPlayStationFaceButtonsAreShapesInPositionalOrder() {
for kind in [PunktfunkConnection.GamepadType.dualSense, .dualSenseEdge, .dualShock4] {
XCTAssertEqual(GamepadGlyphs.symbol(.a, for: kind), "xmark.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.b, for: kind), "circle.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.x, for: kind), "square.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.y, for: kind), "triangle.circle")
}
}
/// Nintendo's labels sit transposed on the same physical positions: the bottom button (role
/// `.a`) is labelled B, and the right one (role `.b`) is labelled A.
func testSwitchFaceButtonsAreTransposed() {
XCTAssertEqual(GamepadGlyphs.symbol(.a, for: .switchPro), "b.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.b, for: .switchPro), "a.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.x, for: .switchPro), "y.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.y, for: .switchPro), "x.circle")
}
/// `.auto` is what a device that has never seen a controller reports, and Xbox letters are the
/// neutral default they are also the positional names `GCExtendedGamepad` itself uses.
func testUnknownAndXboxFamiliesUseLetters() {
for kind in [PunktfunkConnection.GamepadType.auto, .xbox360, .xboxOne, .steamDeck] {
XCTAssertEqual(GamepadGlyphs.symbol(.a, for: kind), "a.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.b, for: kind), "b.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.x, for: kind), "x.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.y, for: kind), "y.circle")
}
}
/// The key-path bridge the legends reach this table through (`buttonGlyph` spells its buttons
/// as key paths). A wrong mapping here would print the wrong button on every family at once.
func testRolesResolveFromExtendedGamepadKeyPaths() {
XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonA), .a)
XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonB), .b)
XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonX), .x)
XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonY), .y)
XCTAssertEqual(GamepadButtonRole(keyPath: \.leftShoulder), .leftShoulder)
XCTAssertEqual(GamepadButtonRole(keyPath: \.rightShoulder), .rightShoulder)
// A button outside the six the legends name has no honest glyph on every family, so it
// falls through to the caller's own fallback rather than guessing.
XCTAssertNil(GamepadButtonRole(keyPath: \.leftTrigger))
}
}
@@ -1,93 +0,0 @@
// Arrow-key navigation over the library's two-section poster grid. The cases that matter are the
// ones a flat index gets wrong: a PARTIAL last row, and the hand-off between the launcher section
// and the titles below it.
import XCTest
@testable import PunktfunkKit
final class LibraryGridNavTests: XCTestCase {
/// Two sections, 3 columns:
/// launchers L0 L1 (one partial row)
/// titles T0 T1 T2
/// T3 T4
private let nav = LibraryGridNav(
sections: [["L0", "L1"], ["T0", "T1", "T2", "T3", "T4"]], columns: 3)
func testFirstPressSelectsTheFirstTile() {
XCTAssertEqual(nav.move(from: nil, .right), "L0")
XCTAssertEqual(nav.move(from: nil, .down), "L0")
}
func testHorizontalMovesWithinARow() {
XCTAssertEqual(nav.move(from: "T0", .right), "T1")
XCTAssertEqual(nav.move(from: "T1", .left), "T0")
}
/// Left/right run through the whole grid in display order, crossing the section boundary
/// the launchers are simply the first tiles.
func testHorizontalCrossesTheSectionBoundary() {
XCTAssertEqual(nav.move(from: "L1", .right), "T0")
XCTAssertEqual(nav.move(from: "T0", .left), "L1")
}
func testVerticalMovesOneRowWithinASection() {
XCTAssertEqual(nav.move(from: "T0", .down), "T3")
XCTAssertEqual(nav.move(from: "T3", .up), "T0")
}
/// Down from the launcher row lands in the titles' first row at the SAME column this is the
/// move a flat index gets wrong, because the launcher row is partial.
func testDownFromLaunchersKeepsTheColumn() {
XCTAssertEqual(nav.move(from: "L0", .down), "T0")
XCTAssertEqual(nav.move(from: "L1", .down), "T1")
}
/// Up out of the titles' first row lands in the launcher row, clamped to what is actually
/// there: column 2 has no launcher above it, so it settles on the last one rather than
/// running off the end.
func testUpIntoAPartialLauncherRowClamps() {
XCTAssertEqual(nav.move(from: "T0", .up), "L0")
XCTAssertEqual(nav.move(from: "T1", .up), "L1")
XCTAssertEqual(nav.move(from: "T2", .up), "L1")
}
/// Down from a full row into a SHORTER last row still moves landing on the final tile but
/// there is nothing below the last row itself.
func testDownIntoAPartialLastRow() {
XCTAssertEqual(nav.move(from: "T2", .down), "T4") // column 2 has no T5
XCTAssertNil(nav.move(from: "T4", .down))
}
func testEdgesRefuseRatherThanWrap() {
XCTAssertNil(nav.move(from: "L0", .left))
XCTAssertNil(nav.move(from: "L0", .up))
XCTAssertNil(nav.move(from: "T4", .right))
}
/// A library with no launcher entries renders ONE section the common case, and it must
/// behave like a plain grid.
func testSingleSectionGrid() {
let single = LibraryGridNav(sections: [["A", "B", "C", "D"]], columns: 2)
XCTAssertEqual(single.move(from: "A", .down), "C")
XCTAssertEqual(single.move(from: "D", .up), "B")
XCTAssertNil(single.move(from: "A", .up))
}
/// An id that is no longer in the grid (the list reloaded under the cursor) re-seeds rather
/// than returning nil forever.
func testStaleCursorReseeds() {
XCTAssertEqual(nav.move(from: "gone", .down), "L0")
}
func testEmptyGridHasNowhereToGo() {
let empty = LibraryGridNav(sections: [], columns: 3)
XCTAssertNil(empty.move(from: nil, .down))
}
/// A degenerate column count must not divide by zero.
func testZeroColumnsIsClampedToOne() {
let single = LibraryGridNav(sections: [["A", "B"]], columns: 0)
XCTAssertEqual(single.columns, 1)
XCTAssertEqual(single.move(from: "A", .down), "B")
}
}
@@ -178,18 +178,6 @@ final class SharedFoundationTests: XCTestCase {
XCTAssertEqual(try DeepLink(url: profiled.url).profile, "a1b2c3d4e5f6")
}
/// The library widget's and the Open Library intent's emitter the reserved `browse` route
/// with a bare UUID path. Same backward-compatibility stakes as connect: a Home-Screen widget
/// keeps sending yesterday's URL.
func testDeepLinkBrowseRoundTrips() throws {
let id = UUID(uuidString: "11111111-2222-4333-8444-555555555555")!
let link = DeepLink.browse(host: id)
XCTAssertEqual(link.route, .browse)
XCTAssertEqual(
link.urlString, "punktfunk://browse/11111111-2222-4333-8444-555555555555")
XCTAssertEqual(try DeepLink(url: link.url), link)
}
/// Self-emitted links ("Copy link", a shortcut) carry all three references, so they survive
/// both a re-addressed host and a wiped store.
func testDeepLinkForHostCarriesIDAddressAndPin() throws {
+2 -5
View File
@@ -26,11 +26,8 @@ mkdir -p "$CFG/open" "$CFG/paired" "$CFG/guess"
trap 'kill "${HOST_PID:-}" "${PAIR_PID:-}" "${GUESS_PID:-}" 2>/dev/null || true' EXIT
# The open host also scripts a feedback burst (rumble + DualSense hidout) right after the
# handshake, so the Swift test can assert the host→client feedback planes end to end.
# The open host outlives the others on purpose: AudioDeviceSwitchTests connects to it and then
# spends tens of seconds moving the system's output device around, long after the 300 frames the
# round-trip test needs.
HOME="$CFG/open" XDG_CONFIG_HOME="$CFG/open/.config" PUNKTFUNK_TEST_FEEDBACK=1 \
target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 12000 \
target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 300 \
--allow-tofu &
HOST_PID=$!
HOME="$CFG/paired" XDG_CONFIG_HOME="$CFG/paired/.config" \
@@ -64,4 +61,4 @@ cd clients/apple
PUNKTFUNK_LOOPBACK_PORT="$PORT" PUNKTFUNK_PAIRING_PORT="$PAIR_PORT" PUNKTFUNK_PAIRING_PIN="$PIN" \
PUNKTFUNK_GUESS_PORT="$GUESS_PORT" PUNKTFUNK_GUESS_PIN="$GUESS_PIN" \
PUNKTFUNK_TEST_FEEDBACK=1 \
swift test --filter 'LoopbackIntegrationTests|AudioDeviceSwitchTests'
swift test --filter LoopbackIntegrationTests
+1 -6
View File
@@ -1274,18 +1274,13 @@ async fn session(args: Args) -> Result<()> {
}
} else if let Some(u) = punktfunk_core::quic::decode_rumble_envelope(&d) {
// Log the first rumble so a loopback test can see the self-terminating v2
// envelope tail (seq + TTL) arrived, not just the level. `lt`/`rt` are the v3
// impulse-trigger levels: printed beside the envelope because the wire-leg
// check for trigger rumble is exactly "non-zero lt/rt AND the envelope still
// present" — i.e. the trigger tail did not displace the seq/TTL tail.
// envelope tail (seq + TTL) arrived, not just the level.
if !rumble_logged {
rumble_logged = true;
tracing::info!(
pad = u.pad,
low = u.low,
high = u.high,
lt = u.left_trigger,
rt = u.right_trigger,
envelope = ?u.envelope,
"rumble (0xCA)"
);
+5 -8
View File
@@ -4,7 +4,6 @@ use super::pw_cursor::{composite_cursor, update_cursor_meta, CursorState};
use super::pw_pods::{
build_cursor_meta_param, build_default_format_obj, build_dmabuf_buffers, build_dmabuf_format,
build_hdr_dmabuf_format, build_mappable_buffers, build_shm_only_buffers, serialize_pod,
HDR_FORMAT_ORDER,
};
use super::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
use anyhow::{Context, Result};
@@ -1851,15 +1850,13 @@ pub fn pipewire_thread(
// negotiation-timeout path latches the process-wide SDR downgrade if nothing matches.
let format_pods: Vec<Vec<u8>> = if want_hdr {
tracing::info!(
"HDR capture: offering xBGR_210LE/xRGB_210LE LINEAR dmabufs with MANDATORY \
"HDR capture: offering xRGB_210LE/xBGR_210LE LINEAR dmabufs with MANDATORY \
BT.2020 + SMPTE-2084 (PQ) colorimetry (GNOME 50+ monitor stream)"
);
// ⚠ Order is the whole fix — see the NVIDIA note on `HDR_FORMAT_ORDER`. The first
// compatible consumer pod wins, so this is what a gamescope session actually lands on.
HDR_FORMAT_ORDER
.iter()
.map(|fmt| build_hdr_dmabuf_format(*fmt, preferred))
.collect::<Result<Vec<_>>>()?
vec![
build_hdr_dmabuf_format(VideoFormat::xRGB_210LE, preferred)?,
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, preferred)?,
]
} else if want_dmabuf {
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 2 } else { 1 });
if prefer_native_nv12 {
-65
View File
@@ -121,38 +121,6 @@ pub(super) fn build_dmabuf_format(
/// SDR — the same outcome as not offering HDR.
const SPA_VIDEO_TRANSFER_SMPTE2084: u32 = 14;
/// The two 10-bit PQ formats an HDR session offers, **in negotiation order**. The order is not a
/// style choice — on NVIDIA it is the difference between correct colour and red/blue swapped.
///
/// `xBGR_210LE` (DRM `XBGR2101010`, Vulkan `A2B10G10R10_UNORM_PACK32`) comes FIRST because the
/// first compatible consumer pod wins, and it is the only one gamescope fills correctly on every
/// vendor:
///
/// * `A2R10G10B10_UNORM_PACK32` **linear-tiled storage** is an optional Vulkan feature that
/// NVIDIA does not implement. gamescope's capture textures are mappable, hence linear, so on
/// NVIDIA its composite `imageStore` into that image lands in XBGR order — the bytes come out
/// byte-reversed while the buffer is still LABELLED `XRGB2101010`.
/// * The host believes the label: `xRGB_210LE → PixelFormat::X2Rgb10 →`
/// `NV_ENC_BUFFER_FORMAT_ARGB10`. Every mapping in that chain is individually correct, which is
/// exactly why the bug is invisible from this side — the *content* is what's wrong.
/// * Upstream gamescope hit the same wall and fixed it with `vulkan_get_rgb10_capture_format()`,
/// which probes `linearTilingFeatures` for STORAGE+SAMPLED and falls back to `XBGR2101010`.
/// That landed AFTER 3.16.25, so the pinned `punktfunk-gamescope` (3.16.25-7-g60561e2 +pfhdr4)
/// predates it and cannot self-correct — hence fixing the preference host-side, where it ships
/// in the host binary with no gamescope rebuild.
///
/// Preferring xBGR costs nothing anywhere else: `A2B10G10R10_UNORM_PACK32` is the universally
/// supported packed-10 format (it is the standard HDR10 swapchain format), it is what upstream
/// falls back to, and `X2Bgr10` has a first-class encoder path (NVENC `ABGR10`, VAAPI
/// `X2BGR10LE`). `xRGB_210LE` stays as the second pod so a producer that somehow offers only it
/// can still negotiate HDR rather than falling off to the SDR downgrade.
///
/// ⚠ The real fix belongs upstream in the patch set: `spa_format_to_drm()` should offer only the
/// format `vulkan_get_rgb10_capture_format()` reports. Until the gamescope pin moves past that
/// commit, THIS ORDER is what keeps NVIDIA HDR sessions correct — do not "tidy" it.
pub(super) const HDR_FORMAT_ORDER: [VideoFormat; 2] =
[VideoFormat::xBGR_210LE, VideoFormat::xRGB_210LE];
pub(super) fn build_hdr_dmabuf_format(
format: VideoFormat,
preferred: Option<(u32, u32, u32)>,
@@ -628,37 +596,4 @@ mod tests {
// The minimum must not exceed what producers already serve, or the ask becomes a demand.
const { assert!(POOL_MIN <= 2) };
}
/// xBGR_210LE must be offered FIRST, and this is a correctness test, not a style one.
///
/// The first compatible consumer pod wins the negotiation. Leading with `xRGB_210LE` makes an
/// NVIDIA gamescope session land on `XRGB2101010`, whose linear-tiled `A2R10G10B10` storage
/// NVIDIA does not support — gamescope's composite `imageStore` writes XBGR bytes under an
/// XRGB label and the whole stream comes out with red and blue swapped. Every format mapping
/// on the host side is individually correct, so nothing downstream can detect it.
///
/// Field-confirmed 2026-08-09 on the RTX 5070 Ti Bazzite host with 0.26.0. See the
/// [`HDR_FORMAT_ORDER`] docs for the upstream fix this predates.
#[test]
fn hdr_offers_xbgr_before_xrgb() {
assert_eq!(
HDR_FORMAT_ORDER[0],
VideoFormat::xBGR_210LE,
"xBGR_210LE must be offered first — leading with xRGB_210LE swaps red and blue on \
every NVIDIA gamescope HDR session"
);
assert_eq!(
HDR_FORMAT_ORDER[1],
VideoFormat::xRGB_210LE,
"xRGB_210LE stays as the fallback pod so a producer offering only it can still \
negotiate HDR instead of dropping to the SDR downgrade"
);
// Both must still build: the order is a preference, never a removal.
for fmt in HDR_FORMAT_ORDER {
assert!(
!build_hdr_dmabuf_format(fmt, None).unwrap().is_empty(),
"{fmt:?} must still produce a format pod"
);
}
}
}
-3
View File
@@ -276,9 +276,6 @@ impl PadInfo {
GamepadPref::DualSenseEdge => "DualSense Edge",
GamepadPref::DualShock4 => "DualShock 4",
GamepadPref::XboxOne => "Xbox One",
// Unreachable from `pref_for_type` today — SDL has no Elite `GamepadType` — but a
// pinned setting can carry it, and an empty label there reads as a plain Xbox pad.
GamepadPref::XboxElite => "Xbox Elite Series 2",
GamepadPref::SteamDeck => "Steam Deck",
GamepadPref::SteamController => "Steam Controller",
GamepadPref::SteamController2 => "Steam Controller 2",
+1 -4
View File
@@ -267,10 +267,7 @@ pub const PALETTES: [Palette; 13] = [
// luminance while keeping the backdrop a field with somewhere to go rather than a
// dead rectangle. The accent stays the brand violet — focus has to be findable on
// black.
// Named for the look, not the panel technology — black with a thin violet corona belongs
// beside Nebula and Abyss. ⚠ The ID stays "oled": it is the stored `ui_palette` value and
// the cross-client key, so renaming it would orphan saved choices and desync the clients.
id: "oled", name: "Eclipse",
id: "oled", name: "OLED",
stops: Some(&[
(0.000, 0.000, 0.000), (0.000, 0.000, 0.000), (0.010, 0.020, 0.100),
(0.045, 0.016, 0.115), (0.120, 0.024, 0.130),
-31
View File
@@ -791,37 +791,6 @@ pub mod gamepad {
/// Steam Input on Windows when the devnode's synthesized USB hardware ids carry `&MI_02`
/// (the wired controller interface — the N4-spike finding).
pub const DEVTYPE_STEAMDECK: u8 = 3;
/// `device_type` = Xbox Wireless Controller (`VID_045E&PID_0B13` HID identity — a Bluetooth
/// Xbox pad, which unlike the wired `045E:028E`/`045E:02EA` ids IS a real HID device).
///
/// This exists because the OTHER Windows Xbox backend, `pf-xusb`, registers only
/// `GUID_DEVINTERFACE_XUSB` and has no HID collection — so Steam, WGI, GameInput, DirectInput
/// and `joy.cpl` cannot enumerate it at all, and only classic `XInputGetState` ever sees it
/// (field 2026-08-09). Routing an Xbox pad through this identity instead puts it on the same
/// HID footing the PlayStation pads have always had.
///
/// ⚠️ Unlike its siblings the Xbox input report is NOT 64 bytes — it is
/// `XBOX_INPUT_REPORT_LEN` (16). The driver serves per-identity report lengths because
/// hidclass sizes its buffer from the descriptor and refuses an over-long source.
pub const DEVTYPE_XBOX: u8 = 4;
/// `device_type` = Xbox One S controller over Bluetooth (`VID_045E&PID_02FD`).
///
/// ⭐ **Shares [`DEVTYPE_XBOX`]'s report descriptor, byte for byte.** All three Xbox identities
/// are the same pad in HID terms — same axes, same trigger pair, same hat, same 15 buttons,
/// same rumble output report — and differ ONLY in VID/PID, product string and INF model line.
/// The descriptor is the report SHAPE; the identity is what the OS keys mappings off. Giving
/// each identity its own hand-written descriptor would triple a debt that has already cost
/// three separate bugs (see the `XBOX_RDESC` provenance block in the driver).
pub const DEVTYPE_XBOX_ONE_S: u8 = 5;
/// `device_type` = Xbox Elite Wireless Controller Series 2 (`VID_045E&PID_0B22`) — the
/// hardware `tools/hid-descriptor-dump` captured on `.173`.
///
/// ⚠️ The four paddles are NOT in this identity's report yet. See [`DEVTYPE_XBOX_ONE_S`] for
/// why the descriptor is shared, and `design/xbox-pad-windows-handoff.md` §4 WP-C for the
/// unresolved tension: once the pad is promoted, `xinputhid` claims the HID collection
/// exclusively, so extra buttons declared here may be invisible to every consumer anyway.
/// That needs measuring before it is built.
pub const DEVTYPE_XBOX_ELITE: u8 = 6;
/// The value a gamepad driver writes into its section's `driver_proto` field once it attaches —
/// the host's positive "driver is alive on this section" signal (health check + version audit).
-8
View File
@@ -34,15 +34,7 @@ pf-capture = { path = "../pf-capture" }
# Software H.264 (openh264, BSD-2) — the GPU-less encode path on both platforms.
openh264 = "0.9"
[target.'cfg(target_os = "linux")'.dev-dependencies]
# The encode-worker protocol tests measure what an AU costs as a serde_json body — the reason the
# access units ride a memfd instead (enc/linux/worker.rs).
serde_json = "1"
[target.'cfg(target_os = "linux")'.dependencies]
# The `punktfunk-encode-worker` protocol (enc/linux/worker.rs). The framing is pf-zerocopy's
# `ipc`, which is generic over the serde body; the message enums live here and version separately.
serde = { version = "1", features = ["derive"] }
# libavcodec (NVENC libav + VAAPI backends). `ffmpeg-sys-next` auto-detects the FFmpeg version, so
# this pin tracks the crate's own major (which shadows FFmpeg's): 9 = FFmpeg 9 (libavcodec 63,
# libavutil 61). Arch shipped FFmpeg 9 on 2026-08-08 and every soname moved with it; the packaged
+3 -40
View File
@@ -525,32 +525,7 @@ impl NvencEncoder {
None => {}
}
// libav's OWN failure path can take the whole host down with it. When NVENC init fails,
// `ff_nvenc_encode_init` calls `ff_cuda_check`, which hands `av_log` an `err_name`/
// `err_string` pair it did not initialize when the CUDA error lookup does not fill them —
// and glibc then walks that pointer in `strlen` inside `av_vbprintf`. Measured twice on
// home-nobara-1 (fc44, libavcodec 62), identical stack both times:
//
// __strlen_evex <- av_vbprintf <- format_line <- av_log_default_callback
// <- ff_cuda_check <- ff_nvenc_encode_init <- avcodec_open2 <- NvencEncoder::open
//
// once as an outright SIGSEGV mid-session, and once as a thread wedged in that stack so
// the service never answered SIGTERM and systemd SIGABRT'd it. Either way one encoder
// open failure kills every session on the box.
//
// We cannot fix the distro's FFmpeg, so deny it the chance to format: these messages are
// AV_LOG_ERROR, and `av_log_default_callback` returns on the level check before
// `format_line` when the level is AV_LOG_FATAL. The failure is NOT swallowed — it comes
// back as `Err(e)` below and is reported with our own context.
//
// Scoped to the call ALONE, deliberately: the ENOSYS arm below recurses into `Self::open`,
// and `QuietLibavLog` takes a non-reentrant global mutex — holding it across the match
// would deadlock the retry.
let opened = {
let _quiet = QuietLibavLog::new();
video.open_with(opts)
};
let enc = match opened {
let enc = match video.open_with(opts) {
Ok(enc) => enc,
// The GPU lacks NV_ENC_CAPS_SUPPORT_INTRA_REFRESH — ffmpeg fails the open with
// ENOSYS ("Function not implemented"). Latch it (skip the doomed attempt on later
@@ -585,21 +560,9 @@ impl NvencEncoder {
);
}
Err(e) => {
// libav's own message for this failure was suppressed on purpose (see above), so
// say so — otherwise the next person debugging an NVENC open wonders why the
// journal has our error and none of FFmpeg's. There is no env switch to get it
// back (the guard is unconditional, and it outranks PUNKTFUNK_FFMPEG_DEBUG for the
// duration of the call): to read libav's text, drop the guard in a local build.
// What it costs is one line of the shape
// [hevc_nvenc @ ..] cuInit(0) failed -> CUDA_ERROR_NO_DEVICE: no CUDA-capable ..
// and the AVERROR itself still travels in `e`.
return Err(e).with_context(|| {
format!(
"open {name} ({width}x{height}@{fps}, {bitrate_bps} bps) — libav's own \
diagnostic is silenced across this call because its CUDA error formatter \
can fault the process"
)
});
format!("open {name} ({width}x{height}@{fps}, {bitrate_bps} bps)")
})
}
};
if intra_refresh {
+2 -52
View File
@@ -194,16 +194,6 @@ pub(crate) struct ProbedSupport {
/// full-chroma 4:4:4 HEVC. `false` when unanswered (fail CLOSED, unlike `codecs`: the honest
/// downgrade is a 4:2:0 session, not a dead one).
pub hevc_444: bool,
/// `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` per listed codec — HEVC Main10 / AV1 10-bit. Rides this
/// same session for exactly the reason 4:4:4 does: the alternative, `linux::probe_can_encode_10bit`,
/// answers by opening an ffmpeg `hevc_nvenc`, and one ffmpeg NVENC open in a direct-SDK process
/// is the LOG-3 field bug that wedges every later open process-wide with
/// `NV_ENC_ERR_INVALID_VERSION`. That probe was left on ffmpeg when the 4:4:4 one was moved off
/// it, on the reading that "Linux HDR rides the libav P010 path" — no longer true for a CUDA
/// capture, which `open_video` sends to the direct backend (`bit_depth` passes straight through,
/// and `is_ten_bit_input` accepts packed 10-bit RGB). `false` when unanswered — fail CLOSED, an
/// 8-bit session beats a wedged one.
pub ten_bit: crate::CodecSupport,
}
/// The cached [`probe_support_uncached`] answer — one throwaway session per process lifetime.
@@ -244,11 +234,6 @@ fn probe_support_uncached() -> ProbedSupport {
av1: false,
},
hevc_444: false,
ten_bit: crate::CodecSupport {
h264: false,
h265: false,
av1: false,
},
};
let Ok(api) = try_api() else {
return unknown;
@@ -314,33 +299,6 @@ fn probe_support_uncached() -> ProbedSupport {
.is_ok()
&& val != 0;
}
// The 10-bit cap, per codec, on the SAME still-open session — same reason 4:4:4 rides it.
// Only queried against a listed GUID (a cap query for an absent codec is undefined).
let mut ten_bit = crate::CodecSupport {
h264: false,
h265: false,
av1: false,
};
if listed {
for (guid, slot) in [
(nv::NV_ENC_CODEC_HEVC_GUID, &mut ten_bit.h265),
(nv::NV_ENC_CODEC_AV1_GUID, &mut ten_bit.av1),
] {
if !guids.contains(&guid) {
continue;
}
let mut param = nv::NV_ENC_CAPS_PARAM {
version: nv::NV_ENC_CAPS_PARAM_VER,
capsToQuery: nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE,
reserved: [0; 62],
};
let mut val: core::ffi::c_int = 0;
*slot = (api.get_encode_caps)(enc, guid, &mut param, &mut val)
.nv_ok()
.is_ok()
&& val != 0;
}
}
let _ = (api.destroy_encoder)(enc);
if !listed {
tracing::warn!(
@@ -355,7 +313,6 @@ fn probe_support_uncached() -> ProbedSupport {
av1: guids.contains(&nv::NV_ENC_CODEC_AV1_GUID),
},
hevc_444,
ten_bit,
}
}
}
@@ -747,15 +704,8 @@ pub struct NvencCudaEncoder {
fps: u32,
bitrate_bps: u64,
buffer_fmt: nv::NV_ENC_BUFFER_FORMAT,
/// Encoded bit depth. **10 is live on Linux** — this used to say "8 until Phase 5.1 lands a P010
/// capture path", which the code has since outrun: the gamescope HDR capture patches offer
/// 10-bit BT.2020/PQ formats, `nvenc_fmt` maps `X2Rgb10`/`X2Bgr10` to `ARGB10`/`ABGR10`, and
/// [`is_ten_bit_input`] flips this field and `hdr` from the negotiated input. A 10-bit frame
/// deliberately takes NEITHER the NV12 nor the YUV444 convert (both compute CSCs write 8-bit
/// planes) and rides packed RGB to the encoder, which does its own BT.2020 CSC —
/// `pf-capture/src/linux/pipewire.rs` owns that gate. So Main10 needed no P010 path to arrive.
/// P010 remains worth having later purely as a perf win (pre-convert so NVENC skips its internal
/// RGB→YUV CSC, as NV12 already does for SDR) — it is not what makes 10-bit work.
/// Encoded bit depth (8 on Linux until Phase 5.1 lands a P010 capture path). Kept for parity with
/// the Windows Main10 config, which is ported but inert until a 10-bit input exists.
bit_depth: u8,
/// Full-chroma 4:4:4 (HEVC Range Extensions) — set when the capturer delivers a planar-YUV444
/// `DeviceBuffer` on an HEVC session and the GPU supports YUV444 encode.
+10 -123
View File
@@ -599,15 +599,6 @@ pub struct PyroWaveEncoder {
/// Session-fixed negotiated chroma: 4:4:4 = full-res RG8 chroma plane + per-pixel CSC
/// (`rgb2yuv444.comp`) + `Chroma444` pyrowave objects.
chroma444: bool,
/// What the global-priority ladder in `open_inner` actually produced, kept so it can be
/// REPORTED rather than only logged. `punktfunk-encode-worker` sends it back to the host in
/// its handshake, which is the process that owns the log pipeline and knows which binary to
/// name — see [`super::worker::PriorityOutcome`].
priority: super::worker::PriorityOutcome,
/// `VkPhysicalDeviceProperties::deviceName` of the device this encoder opened. Sanity for the
/// same handshake: on a multi-GPU host, "which GPU is the worker on" is otherwise invisible
/// from the host process.
device_name: String,
/// Per-frame bitstream budget (hard CBR): `bitrate / (8 * fps)`.
frame_budget: usize,
/// `PUNKTFUNK_PERF`: the synchronous encode's own duration, which is the quantity the
@@ -688,18 +679,6 @@ impl PyroWaveEncoder {
);
}
/// What the global-priority ladder produced for this encoder — the quantity
/// `punktfunk-encode-worker` reports back so the host can log the grant (or the INERT refusal)
/// once, naming the right binary.
pub(crate) fn priority_outcome(&self) -> super::worker::PriorityOutcome {
self.priority
}
/// The Vulkan device this encoder opened on.
pub(crate) fn device_name(&self) -> &str {
&self.device_name
}
pub fn open(
width: u32,
height: u32,
@@ -707,54 +686,7 @@ impl PyroWaveEncoder {
bitrate_bps: u64,
chroma: crate::ChromaFormat,
) -> Result<Self> {
// The in-process path reads the intent from ITS OWN environment, exactly as it always
// has, and owns the INERT warn. (`punktfunk-encode-worker` takes both from its parent —
// see `open_in_worker`.)
let intent = std::env::var("PYROWAVE_QUEUE_PRIORITY").ok();
Self::open_checked(
width,
height,
fps,
bitrate_bps,
chroma.is_444(),
intent.as_deref(),
true,
)
}
/// [`Self::open`] as `punktfunk-encode-worker` runs it.
///
/// Two things differ, and only two — the encoder itself is opened by the identical code path,
/// which is what keeps the worker/in-process A/B honest:
///
/// * `intent` arrives **explicitly** from the host's handshake rather than from this process's
/// environment (which the worker strips of `PYROWAVE_QUEUE_PRIORITY` at startup), so one
/// operator knob cannot come to mean two different things across the process boundary;
/// * the INERT warn is **left to the host**. It is the process with the log pipeline, and its
/// wording has to name the worker binary — the historical text says "CAP_SYS_NICE on the
/// host binary", which after 0.26.0-1 would send an operator to do the one thing that
/// breaks every KDE session.
pub(crate) fn open_in_worker(
width: u32,
height: u32,
fps: u32,
bitrate_bps: u64,
chroma444: bool,
intent: Option<&str>,
) -> Result<Self> {
Self::open_checked(width, height, fps, bitrate_bps, chroma444, intent, false)
}
fn open_checked(
width: u32,
height: u32,
fps: u32,
bitrate_bps: u64,
chroma444: bool,
intent: Option<&str>,
warn_inert: bool,
) -> Result<Self> {
if !chroma444 && (width % 2 != 0 || height % 2 != 0) {
if !chroma.is_444() && (width % 2 != 0 || height % 2 != 0) {
bail!("pyrowave 4:2:0 needs even dimensions (got {width}x{height})");
}
// Checked against the chroma actually being opened, NOT hardcoded 4:4:4. The 4:2:0 block
@@ -765,11 +697,11 @@ impl PyroWaveEncoder {
// (its own bounds `assert` is compiled out by the Release vendored build).
// `validate_dimensions` rejects the impossible-at-any-chroma modes earlier; this is the
// 4:4:4-specific half plus defence in depth for the lab override.
if !crate::pyrowave_mode_fits_rdo(width, height, chroma444) {
if !crate::pyrowave_mode_fits_rdo(width, height, chroma.is_444()) {
bail!(
"pyrowave {} at {width}x{height} exceeds the rate controller's 16-bit block \
index (see pyrowave-sys patches/0002 note) lower the resolution",
if chroma444 { "4:4:4" } else { "4:2:0" }
if chroma.is_444() { "4:4:4" } else { "4:2:0" }
);
}
// SAFETY: `open_inner` only issues Vulkan/pyrowave calls whose preconditions it
@@ -781,27 +713,12 @@ impl PyroWaveEncoder {
height,
fps.max(1),
bitrate_bps.max(1_000_000),
chroma444,
intent,
warn_inert,
chroma.is_444(),
)
}
}
/// `intent` is the raw `PYROWAVE_QUEUE_PRIORITY` value (`None` = unset ⇒ the default ladder),
/// resolved by the CALLER: in-process from this process's environment, in the worker from the
/// host's handshake. `warn_inert` decides whether THIS process emits the "every class refused"
/// warning — see [`Self::open_in_worker`].
#[allow(clippy::too_many_arguments)]
unsafe fn open_inner(
w: u32,
h: u32,
fps: u32,
bitrate: u64,
chroma444: bool,
intent: Option<&str>,
warn_inert: bool,
) -> Result<Self> {
unsafe fn open_inner(w: u32, h: u32, fps: u32, bitrate: u64, chroma444: bool) -> Result<Self> {
let entry = ash::Entry::load().context("load vulkan loader")?;
let mut hold = DeviceHold {
@@ -943,7 +860,8 @@ impl PyroWaveEncoder {
// Granite takes the inherit branch and the patch has never done anything here. This
// is the Linux half. Must be pushed BEFORE the count/as_ptr wiring below, exactly
// like queue_family_foreign above.
let gp_candidates = queue_priority_candidates(intent);
let gp_candidates =
queue_priority_candidates(std::env::var("PYROWAVE_QUEUE_PRIORITY").ok().as_deref());
// Enable whichever alias the driver advertises (KHR = the promoted name), mirroring
// pf-zerocopy's VkBridge probe so the two can never disagree about the spelling.
let gp_ext =
@@ -1025,18 +943,13 @@ impl PyroWaveEncoder {
// created with. (The extension itself stays enabled and that is correct: it
// IS enabled on the device, it just carries no request.)
hold._queue_ci[0].p_next = std::ptr::null();
if !gp_candidates.is_empty() && gp.is_some() && warn_inert {
if !gp_candidates.is_empty() && gp.is_some() {
// MEASURED on .21 (RTX 5070 Ti, NVIDIA 610.43.02, 2026-08-08), and it is
// not a vendor quirk: an unprivileged host is refused EVERY class, and the
// same binary with `cap_sys_nice+ep` is granted REALTIME on the first
// attempt. So this arm is the normal state of a packaged host today, the
// lever is inert until the capability ships, and the message has to say
// which capability rather than leave an operator guessing.
//
// `warn_inert` is false in `punktfunk-encode-worker`: it reports the
// outcome to its parent, which logs the same sentence naming the WORKER
// binary. Sending an operator to `setcap` the host — which this wording
// does — is precisely the 0.26.0-1 incident.
tracing::warn!(
"pyrowave: every global queue priority class was refused — encoding \
at default priority. The GPU-preemption lever is INERT without \
@@ -1049,33 +962,9 @@ impl PyroWaveEncoder {
.context("create device")?
}
};
// The ladder's outcome, made reportable. `queue_priority_candidates` only ever yields
// REALTIME or HIGH, so the `Some(_)` arm is exact rather than a fallback (ash models
// the class as a newtype, not a Rust enum, so this cannot be a `match` on constants).
let priority = match chosen {
Some(c) if c == vk::QueueGlobalPriorityKHR::REALTIME => {
super::worker::PriorityOutcome::Granted(super::worker::GrantedClass::Realtime)
}
Some(_) => {
super::worker::PriorityOutcome::Granted(super::worker::GrantedClass::High)
}
// Exactly the condition the INERT warn above fires on: something was asked for,
// the extension was there, and every class came back refused.
None if !gp_candidates.is_empty() && gp.is_some() => {
super::worker::PriorityOutcome::Refused
}
None => super::worker::PriorityOutcome::NotRequested,
};
let device_name = instance
.get_physical_device_properties(pd)
.device_name_as_c_str()
.ok()
.and_then(|s| s.to_str().ok())
.unwrap_or("unknown")
.to_string();
Ok((pd, family, device, foreign_qfi, priority, device_name))
Ok((pd, family, device, foreign_qfi))
})();
let (pd, family, device, foreign_qfi, priority, device_name) = match selected {
let (pd, family, device, foreign_qfi) = match selected {
Ok(v) => v,
Err(e) => {
instance.destroy_instance(None);
@@ -1124,8 +1013,6 @@ impl PyroWaveEncoder {
height: h,
fps,
chroma444,
priority,
device_name,
frame_budget: budget_for(bitrate, fps),
perf_us: Vec::new(),
perf_logged_at: None,
File diff suppressed because it is too large Load Diff
-971
View File
@@ -1,971 +0,0 @@
//! `punktfunk-encode-worker` — the vocabulary both halves speak, and the worker half itself
//! (design: `design/gpu-priority-capability-worker.md` §3; plan §2/WP1). The host half is
//! [`super::pyrowave_remote`].
//!
//! **Why this process exists at all.** PyroWave encodes on the same shader cores a game
//! saturates, and the only lever that preempts it is an elevated `VK_KHR_global_priority` queue —
//! which the driver grants only to a process holding `CAP_SYS_NICE`. `punktfunk-host` may never
//! hold one: KWin identifies a client by `readlink /proc/<pid>/exe`, the kernel refuses that
//! readlink to a reader whose effective set is not a superset of the target's PERMITTED set, and
//! a capped host is therefore unidentifiable — 0.26.0-1 killed every KDE desktop session that
//! way. So the capability lives here, in a leaf that fronts nothing: no Wayland, no D-Bus, no
//! network, one socket to its parent.
//!
//! 🛑 This worker is a **separate executable file**, never a hardlink of the host and never a
//! subcommand of it (unlike the zerocopy worker, which deliberately re-execs the host image). A
//! shared inode shares the file capability, which silently re-creates 0.26.0-1.
//!
//! ## Shape
//!
//! One worker per PyroWave session, spawned at encoder open on the shared [`ipc`] rails (SEQPACKET
//! framing, `SCM_RIGHTS`, fd-3 inheritance, pinned-exe spawn, the zombie sweep). Strict
//! request/response: **every** host→worker message gets exactly one reply, so the two sides can
//! never desync into "whose turn is it".
//!
//! ## Where the bytes go (and why they are not in the JSON)
//!
//! [`ipc::MAX_MSG`] is 64 KiB and the bodies are serde_json, which renders a `Vec<u8>` as one
//! decimal number per byte. A PyroWave AU is `bitrate / (8 × fps)` — 83 KB at 1080p60/40 Mb/s,
//! ~830 KB at 4K — so an inline `bytes` field is not a slow path, it is *unrepresentable*, and
//! base64 would still need ~17 datagrams and ~0.8 ms of codec per frame against a +1.0 ms
//! whole-IPC-hop budget (plan §4 R1). The AU therefore rides a **memfd** the worker creates once
//! and `pwrite`s at offset 0 every frame; the host `pread`s exactly `len` bytes out of it. The fd
//! crosses once, in [`FromWorker::Ready`]. A memfd grows on write, so there is no capacity
//! negotiation and no regrow protocol — a bitrate retarget is invisible to it.
//!
//! Cursor bitmaps take the same route for the same reason (256×256 RGBA = 256 KiB > `MAX_MSG`),
//! except they are rare enough (only when the pointer *image* changes) that a fresh memfd rides
//! along with the frame instead of a persistent one.
//!
//! Frame pixels never cross at all: the dmabuf fd is passed on first sight of its `key` and the
//! worker caches it, so the steady state passes **zero** descriptors (the PipeWire pool recycles a
//! small buffer set).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{Context, Result};
use pf_frame::{CapturedFrame, CursorOverlay, DmabufFrame, FramePayload, PixelFormat};
use pf_zerocopy::ipc;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::ffi::CStr;
use std::fs::File;
use std::io;
use std::os::fd::{AsFd, BorrowedFd, FromRawFd, OwnedFd};
use std::os::unix::fs::FileExt;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Bumped on any wire change. Unlike the zerocopy worker — the same binary as its host by
/// construction — host and worker are **different files** here, so this check is load-bearing:
/// a package that shipped them out of lockstep must degrade to the in-process encoder, never to
/// a dead session.
pub(crate) const PROTO_VERSION: u32 = 1;
/// The workspace version this half was compiled from. A protocol can be unchanged while the
/// *encoder* moves (a vendored-codec bump, a CSC shader change), and the two halves must still be
/// one build — so the handshake compares this too. `env!` resolves at compile time of THIS crate,
/// so a stale worker binary carries its own older string even though both link the same source.
pub(crate) const WORKSPACE_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Cached dmabuf fds. PipeWire pools are ≤ ~16 buffers; the cap only matters if a producer churns
/// buffers without a renegotiation, and an eviction is recoverable ([`FromWorker::NeedFd`]).
const FD_CACHE_CAP: usize = 64;
/// The largest cursor bitmap that can matter: the encoder clamps to a 256×256 RGBA texture
/// (`pyrowave.rs::CURSOR_MAX`), so uploading more would be bytes the blend cannot read.
const CURSOR_UPLOAD_MAX: usize = 256 * 256 * 4;
// ---------------------------------------------------------------------------
// Vocabulary
// ---------------------------------------------------------------------------
/// What the `VK_KHR_global_priority` ladder produced — i.e. whether the capability is doing
/// anything. Reported to the host so exactly ONE process logs it: the worker's own
/// `tracing` goes to inherited stderr, but the host is the process with the log pipeline (the
/// ring the web console serves), and the in-process INERT warn's wording ("CAP_SYS_NICE on the
/// host binary") would now actively mislead — the capability belongs on the worker.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PriorityOutcome {
/// A class was granted — the lever is live.
Granted(GrantedClass),
/// A class was requested, the extension is there, and every class was refused: the lever is
/// INERT. This is the normal state of an *uncapped* worker.
Refused,
/// Nothing was asked for (`PYROWAVE_QUEUE_PRIORITY=off`) or the device advertises no
/// global-priority extension — not a problem, and never warned about.
NotRequested,
}
/// The granted `VkQueueGlobalPriorityKHR` class, wire-side.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GrantedClass {
Realtime,
High,
}
/// [`pf_frame::PixelFormat`] on the wire. A hand-written mirror rather than a serde derive on the
/// original: pf-frame carries no serde dependency, and the exhaustive `match` in both directions
/// makes a new capture format a COMPILE error here instead of a silently mis-described frame.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WireFormat {
Bgrx,
Rgbx,
Bgra,
Rgba,
Rgb,
Bgr,
Rgb10a2,
Nv12,
P010,
Yuv444,
X2Rgb10,
X2Bgr10,
}
impl From<PixelFormat> for WireFormat {
fn from(f: PixelFormat) -> WireFormat {
match f {
PixelFormat::Bgrx => WireFormat::Bgrx,
PixelFormat::Rgbx => WireFormat::Rgbx,
PixelFormat::Bgra => WireFormat::Bgra,
PixelFormat::Rgba => WireFormat::Rgba,
PixelFormat::Rgb => WireFormat::Rgb,
PixelFormat::Bgr => WireFormat::Bgr,
PixelFormat::Rgb10a2 => WireFormat::Rgb10a2,
PixelFormat::Nv12 => WireFormat::Nv12,
PixelFormat::P010 => WireFormat::P010,
PixelFormat::Yuv444 => WireFormat::Yuv444,
PixelFormat::X2Rgb10 => WireFormat::X2Rgb10,
PixelFormat::X2Bgr10 => WireFormat::X2Bgr10,
}
}
}
impl From<WireFormat> for PixelFormat {
fn from(f: WireFormat) -> PixelFormat {
match f {
WireFormat::Bgrx => PixelFormat::Bgrx,
WireFormat::Rgbx => PixelFormat::Rgbx,
WireFormat::Bgra => PixelFormat::Bgra,
WireFormat::Rgba => PixelFormat::Rgba,
WireFormat::Rgb => PixelFormat::Rgb,
WireFormat::Bgr => PixelFormat::Bgr,
WireFormat::Rgb10a2 => PixelFormat::Rgb10a2,
WireFormat::Nv12 => PixelFormat::Nv12,
WireFormat::P010 => PixelFormat::P010,
WireFormat::Yuv444 => PixelFormat::Yuv444,
WireFormat::X2Rgb10 => PixelFormat::X2Rgb10,
WireFormat::X2Bgr10 => PixelFormat::X2Bgr10,
}
}
}
/// [`pf_frame::CursorOverlay`] minus its pixels — cursor-as-metadata, the way the CSC consumes it.
/// `upload` is the pixel channel: `Some(len)` means a fresh memfd carrying `len` bytes of straight
/// -alpha RGBA rides with this frame (the bitmap `serial` changed); `None` means "reuse the bitmap
/// you cached for `serial`", which is every frame of a pointer that is merely moving.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub(crate) struct WireCursor {
pub x: i32,
pub y: i32,
pub w: u32,
pub h: u32,
pub serial: u64,
pub hot_x: u32,
pub hot_y: u32,
pub visible: bool,
pub upload: Option<usize>,
}
/// host → worker. Every variant has exactly one reply.
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub(crate) enum ToWorker {
/// Open the encoder. Answered with [`FromWorker::Ready`] (which carries the AU memfd) or
/// [`FromWorker::InitErr`].
///
/// `priority_intent` is the raw `PYROWAVE_QUEUE_PRIORITY` value as the HOST resolved it
/// (`None` = unset ⇒ the default REALTIME→HIGH ladder). Forwarded **explicitly** rather than
/// read from the worker's environment, and the worker strips the variable from its own env
/// before opening, so one knob cannot mean two things across the process boundary.
Hello {
proto: u32,
workspace_version: String,
/// The host's `PUNKTFUNK_RENDER_NODE` (`None` = unset). Log-only, exactly as it is
/// in-process: the device selection deliberately ignores every node anchor (see
/// `pyrowave.rs::select_physical_device` — two "fixes" were withdrawn). Carried so a
/// wrong-device field report shows the host's anchor beside the worker's pick.
drm_node: Option<String>,
width: u32,
height: u32,
fps: u32,
bitrate_bps: u64,
chroma444: bool,
priority_intent: Option<String>,
},
/// Encode one frame. The dmabuf fd rides as `SCM_RIGHTS` only on first sight of `key`
/// (`has_fd`); a cursor upload, when present, is the fd AFTER it. Answered with
/// [`FromWorker::Au`], [`FromWorker::NeedFd`] or [`FromWorker::EncodeErr`].
Frame {
key: u64,
has_fd: bool,
fourcc: u32,
modifier: u64,
offset: u32,
stride: u32,
plane1: Option<(u32, u32)>,
width: u32,
height: u32,
pts_ns: u64,
format: WireFormat,
cursor: Option<WireCursor>,
},
/// `Encoder::set_wire_chunking` — the datagram-aligned packetization boundary (plan §4.4).
/// This has to cross: it changes the AU BYTES (the windowed `build_au` framing) and the rate
/// budget, not just how the host hands them out. The streamed-AU *cutting* stays host-side.
SetWireChunking { shard_payload: usize },
/// `Encoder::reconfigure_bitrate` — an in-place rate retarget.
Reconfigure { bitrate_bps: u64 },
/// `Encoder::reset` — the stall watchdog's in-place rebuild, run INSIDE the worker so the
/// priority-elevated device survives it (see [`super::pyrowave_remote::RemotePyroWave::reset`]
/// for why this is a message and not a respawn).
Reset,
}
/// worker → host.
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub(crate) enum FromWorker {
/// The encoder is open. Carries the AU memfd as its single `SCM_RIGHTS` descriptor.
///
/// ⚠ `proto` and `workspace_version` are the first two fields and must never be renamed: they
/// are how a version-skewed pair diagnoses itself instead of failing obscurely.
Ready {
proto: u32,
workspace_version: String,
priority: PriorityOutcome,
device: String,
/// The chroma the encoder REALLY opened, and whether it blends the cursor — i.e.
/// `EncoderCaps` as only the opened encoder knows it. The proxy must not guess: a
/// hardcoded default mis-reports a 4:4:4 open and fires the session glue's spurious
/// "chroma disagrees with the negotiated Welcome" warn.
chroma444: bool,
blends_cursor: bool,
},
/// The open failed (no Vulkan 1.3 device, missing features, …) — an ANSWER, not a crash: the
/// host falls back to the in-process encoder, which will fail the same way if the cause is
/// real and succeed if the cause was the worker's own environment.
InitErr { message: String },
/// One access unit, complete, at offset 0 of the AU memfd. **Doubles as the buffer-release
/// signal**: it maps 1:1 onto `Encoder::submit`'s lifetime contract (the caller already holds
/// the frame alive until its AU comes back from `poll`), so the host loop needs no change.
Au {
key: u64,
len: usize,
pts_ns: u64,
keyframe: bool,
chunk_aligned: bool,
encode_us: u32,
},
/// No cached fd for this `key` (evicted, or the caches diverged) — the host forgets its
/// "already sent" note and retries the frame once, with the fd.
NeedFd,
/// This frame failed but the worker is alive.
EncodeErr { message: String },
/// Reply to [`ToWorker::SetWireChunking`] / [`ToWorker::Reconfigure`] / [`ToWorker::Reset`].
Ack { ok: bool },
}
// ---------------------------------------------------------------------------
// Framing helpers — EINTR, and the deadline it must not defeat
// ---------------------------------------------------------------------------
/// [`ipc::recv_fds`] that survives a signal.
///
/// With `SO_RCVTIMEO` armed the kernel returns **EINTR**, not `ERESTARTSYS`, so `SA_RESTART` does
/// not save the caller — any signal delivered to a thread blocked in `recv` surfaces as an error.
/// pf-zerocopy's importer maps *any* recv error to "the worker died", which is right for a
/// once-per-capture handshake and wrong for a per-frame AU: one stray signal would drop a healthy
/// session to the in-process fallback. So retry here.
///
/// The retry re-arms with the REMAINING budget rather than the full one — a signal arriving every
/// 100 ms would otherwise reset the clock forever and a real hang would never time out.
/// `budget = None` means "block until the host speaks or closes" (the worker's own serve loop).
pub(crate) fn recv_eintr<T: DeserializeOwned>(
sock: BorrowedFd,
buf: &mut Vec<u8>,
budget: Option<Duration>,
) -> io::Result<(T, Vec<OwnedFd>)> {
let deadline = budget.map(|d| Instant::now() + d);
loop {
if let Some(deadline) = deadline {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"encode worker did not answer within its budget",
));
}
ipc::set_recv_timeout(sock, Some(left))?;
}
match ipc::recv_fds::<T>(sock, buf) {
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
other => return other,
}
}
}
/// [`ipc::send_fds`] that survives a signal. A small body on a socket whose peer is actively
/// reading does not block, so this normally retries never; it exists so that "normally" is not
/// load-bearing.
pub(crate) fn send_eintr<T: Serialize>(
sock: BorrowedFd,
msg: &T,
fds: &[BorrowedFd],
) -> io::Result<()> {
loop {
match ipc::send_fds(sock, msg, fds) {
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
other => return other,
}
}
}
/// An anonymous RAM-backed file for the bulk channels. Grows on `pwrite`, so callers never size it.
fn memfd(name: &CStr) -> io::Result<File> {
// SAFETY: `memfd_create` reads a NUL-terminated name (a live `CStr` for the duration of the
// call) and returns a fresh descriptor or -1; it retains no pointer. The result is checked
// before use, and the returned fd is owned by nobody else, so `File::from_raw_fd` takes sole
// ownership and closes it exactly once.
let fd = unsafe { libc::memfd_create(name.as_ptr(), libc::MFD_CLOEXEC) };
if fd < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: `fd` is the fresh, valid descriptor just created and checked above.
Ok(unsafe { File::from_raw_fd(fd) })
}
/// Build the memfd carrying one cursor bitmap, clamped to what the blend can actually sample.
pub(crate) fn cursor_upload(rgba: &[u8]) -> io::Result<(File, usize)> {
let n = rgba.len().min(CURSOR_UPLOAD_MAX);
let f = memfd(c"pf-encode-cursor")?;
f.write_all_at(&rgba[..n], 0)?;
Ok((f, n))
}
// ---------------------------------------------------------------------------
// The worker half
// ---------------------------------------------------------------------------
/// `punktfunk-encode-worker` entry point. `args` are the process's own arguments after argv[0]
/// (`--fd N`, default 3 — the socket end the spawning host `dup2`'d in).
pub fn run_from_args(args: &[String]) -> Result<()> {
// Core dumps ON, and FIRST — the opposite of the host's posture, deliberately. `PR_SET_DUMPABLE`
// is cleared by the kernel whenever a process gains a file capability, which also suppresses
// core dumps and makes `/proc/<pid>/environ` unreadable. This process fronts nothing (no
// Wayland, no D-Bus, no network), so nothing is protected by that suppression and a crash in a
// GPU driver is exactly what we want a core for. It does NOT make us identifiable to KWin —
// the 0.26.0-1 matrix measured that dumpable is not the gate, the PERMITTED set is — and it
// does not need to: this process never speaks Wayland.
// SAFETY: `prctl(PR_SET_DUMPABLE, 1)` takes integers by value, touches no Rust memory and
// affects only this process.
unsafe {
libc::prctl(libc::PR_SET_DUMPABLE, 1);
}
// The host execs us via a pinned `/proc/self/fd/<n>`, so the kernel derives our comm from a
// meaningless fd number. Rename so `top`/`pkill`/a coredump path see the worker.
// SAFETY: `PR_SET_NAME` copies at most 16 bytes from the given pointer; the C-string literal is
// valid, NUL-terminated and short enough, and no pointer is retained past the call.
unsafe {
libc::prctl(libc::PR_SET_NAME, c"pf-encode-wk".as_ptr());
}
sanitize_env();
// Real teeth, and the second half of what the capability buys: `setpriority` is a silent no-op
// without `CAP_SYS_NICE`/`RLIMIT_NICE`, which is why the in-host encode thread's nice(-10) has
// never actually applied on a packaged Linux host. Here it applies. The worker is
// single-threaded, so this IS the encode thread.
pf_frame::thread_qos::boost_thread_priority(true);
let fd: i32 = args
.iter()
.skip_while(|a| *a != "--fd")
.nth(1)
.map(|s| s.parse())
.transpose()
.context("parse --fd")?
.unwrap_or(3);
// Refuse anything that cannot be the spawning host's socket: a negative fd is UB inside
// `OwnedFd` (its niche), and 02 would make the worker close one of its own stdio streams on
// exit. Then confirm the number really holds a socket — this binary is installed and runnable
// by hand, and adopting an arbitrary inherited fd would close it behind its real owner.
anyhow::ensure!(fd >= 3, "--fd must be >= 3 (got {fd})");
// SAFETY: `libc::stat` is plain-old-data for which all-zero is a valid value, so `mem::zeroed`
// is a sound initializer; `fstat` writes into the live, correctly-sized `&mut st` and only
// reads `fd`. `st_mode` is read only after the return value is checked.
let is_socket = unsafe {
let mut st: libc::stat = std::mem::zeroed();
libc::fstat(fd, &mut st) == 0 && (st.st_mode & libc::S_IFMT) == libc::S_IFSOCK
};
anyhow::ensure!(
is_socket,
"--fd {fd} is not an open socket (this binary is spawned by punktfunk-host, not run by hand)"
);
// SAFETY: the spawning host `dup2`'d its socketpair end onto exactly this fd number before
// exec (the worker's contract, just verified to be an open socket ≥ 3) and nothing else in
// this fresh process owns it, so `OwnedFd` takes sole ownership and closes it once at exit.
let sock = unsafe { OwnedFd::from_raw_fd(fd) };
run(sock)
}
/// Drop the environment variables this process must not act on.
///
/// Deliberately a DENYLIST, not an allowlist. The obvious "clear everything but a handful of
/// names" is wrong here: the Vulkan loader discovers its ICDs through the environment
/// (`VK_ICD_FILENAMES`/`VK_DRIVER_FILES`/`XDG_DATA_DIRS`), so a strict allowlist would leave the
/// worker with no GPU exactly on NixOS — the one channel where this worker's env override is
/// load-bearing. What must go is the punktfunk state that would make one knob mean two things:
/// the priority intent (it arrives explicitly in `Hello`) and the worker path itself (nothing here
/// spawns a worker, and a stale value in a core dump is just noise).
fn sanitize_env() {
// Single-threaded — this runs before anything in this process creates a thread, which is the
// one situation where mutating the environment is sound (the `getenv` race the house rule
// about `set_var` is about needs a second thread).
for k in ["PYROWAVE_QUEUE_PRIORITY", "PUNKTFUNK_ENCODE_WORKER"] {
std::env::remove_var(k);
}
}
/// Handshake, then serve until the host goes away.
fn run(sock: OwnedFd) -> Result<()> {
let mut buf = Vec::new();
// No timeout on the worker's own receives: the host owns the clock (it arms `SO_RCVTIMEO` on
// its end), and a worker that gave up on its own would look exactly like a crash.
let (hello, _) = recv_eintr::<ToWorker>(sock.as_fd(), &mut buf, None).context("recv Hello")?;
let ToWorker::Hello {
proto,
workspace_version,
drm_node,
width,
height,
fps,
bitrate_bps,
chroma444,
priority_intent,
} = hello
else {
anyhow::bail!("first message was not Hello");
};
if proto != PROTO_VERSION || workspace_version != WORKSPACE_VERSION {
// Answer, don't crash: the host prints one warn naming both builds and encodes in-process.
let _ = send_eintr(
sock.as_fd(),
&FromWorker::InitErr {
message: format!(
"version skew: worker proto {PROTO_VERSION} v{WORKSPACE_VERSION}, \
host proto {proto} v{workspace_version}"
),
},
&[],
);
return Ok(());
}
let enc = match super::pyrowave::PyroWaveEncoder::open_in_worker(
width,
height,
fps,
bitrate_bps,
chroma444,
priority_intent.as_deref(),
) {
Ok(e) => e,
Err(e) => {
let _ = send_eintr(
sock.as_fd(),
&FromWorker::InitErr {
message: format!("{e:#}"),
},
&[],
);
return Ok(());
}
};
let au_buf = memfd(c"pf-encode-au").context("create the AU return buffer")?;
let caps = crate::Encoder::caps(&enc);
let ready = FromWorker::Ready {
proto: PROTO_VERSION,
workspace_version: WORKSPACE_VERSION.to_string(),
priority: enc.priority_outcome(),
device: enc.device_name().to_string(),
chroma444: caps.chroma_444,
blends_cursor: caps.blends_cursor,
};
send_eintr(sock.as_fd(), &ready, &[au_buf.as_fd()]).context("send Ready")?;
tracing::info!(
pid = std::process::id(),
device = %enc.device_name(),
priority = ?enc.priority_outcome(),
host_render_node = ?drm_node,
"punktfunk-encode-worker ready"
);
serve(&sock, enc, &au_buf)
}
/// The request loop. `Ok(())` on host EOF (normal end-of-life — the host dropped its proxy);
/// any other socket error propagates and the process exits, which the host reads as a death,
/// because it is one.
fn serve(sock: &OwnedFd, mut enc: super::pyrowave::PyroWaveEncoder, au_buf: &File) -> Result<()> {
use crate::Encoder as _;
let mut buf = Vec::new();
let mut fds: HashMap<u64, OwnedFd> = HashMap::new();
// Insertion order, for the eviction the cap implies.
let mut fd_order: VecDeque<u64> = VecDeque::new();
// The cursor bitmap the host last uploaded, by `serial` — a moving pointer re-sends only its
// position, exactly like the in-process path re-uses its uploaded texture.
let mut cursor_rgba: Option<(u64, Arc<Vec<u8>>)> = None;
loop {
let (msg, got) = match recv_eintr::<ToWorker>(sock.as_fd(), &mut buf, None) {
Ok(v) => v,
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(()),
Err(e) => return Err(e).context("worker recv"),
};
let reply = match msg {
ToWorker::Hello { .. } => FromWorker::EncodeErr {
message: "duplicate Hello".into(),
},
ToWorker::SetWireChunking { shard_payload } => {
enc.set_wire_chunking(shard_payload);
FromWorker::Ack { ok: true }
}
ToWorker::Reconfigure { bitrate_bps } => FromWorker::Ack {
ok: enc.reconfigure_bitrate(bitrate_bps),
},
ToWorker::Reset => FromWorker::Ack { ok: enc.reset() },
ToWorker::Frame {
key,
has_fd,
fourcc,
modifier,
offset,
stride,
plane1,
width,
height,
pts_ns,
format,
cursor,
} => {
// Descriptor order is the sender's: the dmabuf (iff `has_fd`), then the cursor
// upload (iff the bitmap changed). Taken before any early return so an unexpected
// extra descriptor is closed with the `Vec` rather than leaked.
let mut got = got.into_iter();
let dmabuf = if has_fd { got.next() } else { None };
let cursor_fd = cursor.as_ref().and_then(|c| c.upload.map(|_| got.next()));
if let Some(fd) = dmabuf {
if fds.insert(key, fd).is_none() {
fd_order.push_back(key);
}
while fd_order.len() > FD_CACHE_CAP {
if let Some(old) = fd_order.pop_front() {
fds.remove(&old);
}
}
}
match encode_one(
&mut enc,
au_buf,
&fds,
&mut cursor_rgba,
FrameReq {
key,
fourcc,
modifier,
offset,
stride,
plane1,
width,
height,
pts_ns,
format,
cursor,
},
cursor_fd.flatten(),
) {
Ok(reply) => reply,
Err(e) => FromWorker::EncodeErr {
message: format!("{e:#}"),
},
}
}
};
match send_eintr(sock.as_fd(), &reply, &[]) {
Ok(()) => {}
// The host vanished between our recv and our send — the same end-of-life as EOF.
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => return Ok(()),
Err(e) => return Err(e).context("worker send"),
}
}
}
/// [`ToWorker::Frame`] minus the descriptors, so [`encode_one`] takes one argument per concept.
struct FrameReq {
key: u64,
fourcc: u32,
modifier: u64,
offset: u32,
stride: u32,
plane1: Option<(u32, u32)>,
width: u32,
height: u32,
pts_ns: u64,
format: WireFormat,
cursor: Option<WireCursor>,
}
/// Rebuild the `CapturedFrame`, encode it synchronously, and write the AU into `au_buf`.
fn encode_one(
enc: &mut super::pyrowave::PyroWaveEncoder,
au_buf: &File,
fds: &HashMap<u64, OwnedFd>,
cursor_rgba: &mut Option<(u64, Arc<Vec<u8>>)>,
req: FrameReq,
cursor_fd: Option<OwnedFd>,
) -> Result<FromWorker> {
use crate::Encoder as _;
let Some(cached) = fds.get(&req.key) else {
return Ok(FromWorker::NeedFd);
};
// A dup per frame, not a borrow: `DmabufFrame` owns its fd (the encoder's import path dups it
// again for Vulkan and drops the rest), while the cache must keep holding the original so the
// steady state passes no descriptors at all. One `dup`/`close` pair per frame is µs.
let fd = cached.try_clone().context("dup the cached dmabuf fd")?;
let cursor = match req.cursor {
Some(c) => {
match (c.upload, cursor_fd) {
(Some(len), Some(f)) => {
let mut px = vec![0u8; len];
File::from(f)
.read_exact_at(&mut px, 0)
.context("read the cursor upload")?;
*cursor_rgba = Some((c.serial, Arc::new(px)));
}
// An announced upload whose descriptor did not arrive. Rare (it takes a kernel
// refusal of the `SCM_RIGHTS`), and the reason it is an ERROR rather than a
// shrug: the host marks the serial "sent" on a successful AU, so blending
// nothing here would leave the pointer INVISIBLE for the rest of that bitmap's
// life, silently. Failing the frame drops the session onto the in-process
// encoder instead, which is a rung with a warning attached.
(Some(_), None) => anyhow::bail!("cursor upload announced but no descriptor came"),
(None, _) => {}
}
// Likewise a serial we hold no pixels for: the host only omits the upload for a serial
// it has seen acknowledged, so a miss is a desync, not a frame to guess at.
let Some(rgba) = cursor_rgba
.as_ref()
.filter(|(serial, _)| *serial == c.serial)
.map(|(_, px)| px.clone())
else {
anyhow::bail!("no cursor bitmap cached for serial {}", c.serial);
};
Some(CursorOverlay {
x: c.x,
y: c.y,
w: c.w,
h: c.h,
rgba,
serial: c.serial,
hot_x: c.hot_x,
hot_y: c.hot_y,
visible: c.visible,
})
}
None => None,
};
let frame = CapturedFrame {
width: req.width,
height: req.height,
pts_ns: req.pts_ns,
format: req.format.into(),
payload: FramePayload::Dmabuf(DmabufFrame {
fd,
fourcc: req.fourcc,
modifier: req.modifier,
plane1: req.plane1,
offset: req.offset,
stride: req.stride,
}),
cursor,
};
// submit→poll in one breath: this backend's encode is synchronous at depth 1, so the AU is
// ready when `poll` returns and `frame` (with its fd) is alive across both halves — the
// trait's lifetime contract, honored on this side of the socket too.
let t0 = Instant::now();
enc.submit(&frame)?;
let Some(au) = enc.poll()? else {
anyhow::bail!("encoder returned no AU for a submitted frame");
};
let encode_us = t0.elapsed().as_micros() as u32;
au_buf
.write_all_at(&au.data, 0)
.context("write the AU into the return buffer")?;
Ok(FromWorker::Au {
key: req.key,
len: au.data.len(),
pts_ns: au.pts_ns,
keyframe: au.keyframe,
chunk_aligned: au.chunk_aligned,
encode_us,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::fd::AsFd;
fn hello() -> ToWorker {
ToWorker::Hello {
proto: PROTO_VERSION,
workspace_version: WORKSPACE_VERSION.to_string(),
drm_node: Some("/dev/dri/renderD128".into()),
width: 3840,
height: 2160,
fps: 60,
bitrate_bps: 400_000_000,
chroma444: true,
priority_intent: Some("realtime".into()),
}
}
/// The vocabulary survives the wire in both directions, descriptors included. (The framing —
/// EOF, timeouts, the descriptor cap — is pf-zerocopy's `ipc` tests' job; this pins the
/// message types and the fd ORDER the frame path depends on.)
#[test]
fn proto_round_trip_both_directions() {
let (a, b) = ipc::socketpair_seqpacket().unwrap();
let mut buf = Vec::new();
ipc::send(a.as_fd(), &hello(), None).unwrap();
let (got, fds) = ipc::recv_fds::<ToWorker>(b.as_fd(), &mut buf).unwrap();
assert_eq!(got, hello());
assert!(fds.is_empty());
let frame = ToWorker::Frame {
key: 0xdead_beef,
has_fd: true,
fourcc: 0x3432_5258,
modifier: 0x0300_0000_0000_1234,
offset: 0,
stride: 3840 * 4,
plane1: None,
width: 3840,
height: 2160,
pts_ns: 1_234_567_890,
format: WireFormat::Bgrx,
cursor: Some(WireCursor {
x: 10,
y: 20,
w: 32,
h: 32,
serial: 7,
hot_x: 1,
hot_y: 2,
visible: true,
upload: Some(32 * 32 * 4),
}),
};
// Two descriptors, in the order the receiver destructures them: dmabuf, then cursor.
let (dma, cur) = (memfd(c"t-dma").unwrap(), memfd(c"t-cur").unwrap());
ipc::send_fds(a.as_fd(), &frame, &[dma.as_fd(), cur.as_fd()]).unwrap();
let (got, fds) = ipc::recv_fds::<ToWorker>(b.as_fd(), &mut buf).unwrap();
assert_eq!(got, frame);
assert_eq!(fds.len(), 2);
let ready = FromWorker::Ready {
proto: PROTO_VERSION,
workspace_version: WORKSPACE_VERSION.to_string(),
priority: PriorityOutcome::Granted(GrantedClass::Realtime),
device: "NVIDIA GeForce RTX 5070 Ti".into(),
chroma444: true,
blends_cursor: true,
};
ipc::send(b.as_fd(), &ready, Some(dma.as_fd())).unwrap();
let (got, fd) = ipc::recv::<FromWorker>(a.as_fd(), &mut buf).unwrap();
assert_eq!(got, ready);
assert!(fd.is_some(), "Ready carries the AU return buffer");
for reply in [
FromWorker::Au {
key: 1,
len: 830_000,
pts_ns: 5,
keyframe: true,
chunk_aligned: true,
encode_us: 4400,
},
FromWorker::NeedFd,
FromWorker::Ack { ok: true },
FromWorker::EncodeErr {
message: "boom".into(),
},
] {
ipc::send(b.as_fd(), &reply, None).unwrap();
let (got, _) = ipc::recv::<FromWorker>(a.as_fd(), &mut buf).unwrap();
assert_eq!(got, reply);
}
}
/// An AU never rides in the JSON body, and this is why: the smallest per-frame budget the
/// encoder will ever use is already `MAX_MSG`, and serde_json renders a byte as up to four
/// characters. Pinned as a test so nobody "simplifies" the memfd away.
#[test]
fn an_inline_au_would_not_fit_a_message() {
// 1080p60 at a modest 40 Mb/s — well inside the shipped range.
let au = vec![0xABu8; 40_000_000 / (8 * 60)];
let body = serde_json::to_vec(&au).unwrap();
assert!(
body.len() > ipc::MAX_MSG,
"a {}-byte AU serialized to {} bytes, which would (wrongly) fit MAX_MSG {}",
au.len(),
body.len(),
ipc::MAX_MSG
);
}
/// A body over [`ipc::MAX_MSG`] is refused at the sender rather than truncated on the wire —
/// the property the memfd channel exists to respect.
#[test]
fn oversized_messages_are_refused_not_truncated() {
let (a, _b) = ipc::socketpair_seqpacket().unwrap();
let ToWorker::Hello {
proto,
drm_node,
width,
height,
fps,
bitrate_bps,
chroma444,
priority_intent,
..
} = hello()
else {
unreachable!("hello() builds a Hello");
};
let huge = ToWorker::Hello {
proto,
// Over `MAX_MSG` on its own — enum variants take no functional-update syntax, so the
// rest is destructured above rather than `..hello()`.
workspace_version: "x".repeat(ipc::MAX_MSG),
drm_node,
width,
height,
fps,
bitrate_bps,
chroma444,
priority_intent,
};
let err = ipc::send(a.as_fd(), &huge, None).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
/// Every `PixelFormat` maps to a wire tag and back unchanged. The `match`es are exhaustive, so
/// a new capture format is a compile error; this catches a mis-typed ARM in either direction.
#[test]
fn pixel_formats_round_trip() {
for f in [
PixelFormat::Bgrx,
PixelFormat::Rgbx,
PixelFormat::Bgra,
PixelFormat::Rgba,
PixelFormat::Rgb,
PixelFormat::Bgr,
PixelFormat::Rgb10a2,
PixelFormat::Nv12,
PixelFormat::P010,
PixelFormat::Yuv444,
PixelFormat::X2Rgb10,
PixelFormat::X2Bgr10,
] {
assert_eq!(PixelFormat::from(WireFormat::from(f)), f);
}
}
/// The bulk channel: a memfd written by one holder of the descriptor is readable at offset 0
/// by another, and it grows on write with no explicit sizing. That is the whole mechanism the
/// AU return depends on.
#[test]
fn memfd_round_trips_bytes_across_a_descriptor() {
let f = memfd(c"pf-encode-test").unwrap();
let au = vec![0x5Au8; 900_000];
f.write_all_at(&au, 0).unwrap();
let (a, b) = ipc::socketpair_seqpacket().unwrap();
let mut buf = Vec::new();
ipc::send(a.as_fd(), &FromWorker::NeedFd, Some(f.as_fd())).unwrap();
let (_, fd) = ipc::recv::<FromWorker>(b.as_fd(), &mut buf).unwrap();
let mut back = vec![0u8; au.len()];
File::from(fd.unwrap()).read_exact_at(&mut back, 0).unwrap();
assert_eq!(back, au);
}
/// A cursor bitmap is clamped to what the 256×256 blend texture can sample — a larger one is
/// truncated, exactly as `prep_cursor`'s `bytes.min(rgba.len())` copy already truncates it.
#[test]
fn cursor_upload_clamps_to_the_blend_texture() {
let (_, n) = cursor_upload(&vec![0u8; CURSOR_UPLOAD_MAX * 4]).unwrap();
assert_eq!(n, CURSOR_UPLOAD_MAX);
let (_, n) = cursor_upload(&vec![0u8; 64 * 64 * 4]).unwrap();
assert_eq!(n, 64 * 64 * 4);
}
/// EINTR must not read as a dead worker. A `SIGURG` (default-ignored, so the test process
/// survives it) delivered to a thread parked in `recv` with `SO_RCVTIMEO` armed returns EINTR
/// — `SA_RESTART` does not apply to a timeout-armed socket — and the retry must swallow it and
/// still deliver the message that arrives afterwards.
#[test]
fn recv_survives_a_signal() {
let (a, b) = ipc::socketpair_seqpacket().unwrap();
let b = std::sync::Arc::new(b);
let waiter = {
let b = b.clone();
std::thread::spawn(move || {
let mut buf = Vec::new();
recv_eintr::<FromWorker>(b.as_fd(), &mut buf, Some(Duration::from_secs(10)))
})
};
// Give the thread time to park in `recvmsg`, then interrupt it repeatedly while the
// message is still not there.
std::thread::sleep(Duration::from_millis(50));
for _ in 0..5 {
// SAFETY: `pthread_kill` takes the live thread's id by value and a signal number;
// SIGURG's default disposition is "ignore", so delivery cannot kill the process.
unsafe {
libc::pthread_kill(
std::os::unix::thread::JoinHandleExt::as_pthread_t(&waiter),
libc::SIGURG,
);
}
std::thread::sleep(Duration::from_millis(10));
}
ipc::send(a.as_fd(), &FromWorker::Ack { ok: true }, None).unwrap();
let (got, _) = waiter.join().unwrap().expect("EINTR must not surface");
assert_eq!(got, FromWorker::Ack { ok: true });
}
/// …and the retry must not defeat the deadline: a socket nobody ever writes to still times
/// out, signals or no signals.
#[test]
fn recv_still_times_out() {
let (a, _b) = ipc::socketpair_seqpacket().unwrap();
let mut buf = Vec::new();
let err = recv_eintr::<FromWorker>(a.as_fd(), &mut buf, Some(Duration::from_millis(80)))
.unwrap_err();
assert!(
matches!(
err.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
),
"unexpected error kind: {err:?}"
);
}
}
+5 -62
View File
@@ -358,17 +358,8 @@ fn open_video_backend_linux(
if codec == Codec::PyroWave {
#[cfg(feature = "pyrowave")]
{
// Through the worker seam, not straight at the encoder: PyroWave's GPU-priority lever
// needs CAP_SYS_NICE, which only `punktfunk-encode-worker` may carry. Every rung of
// that ladder ends at this exact in-process open — see `pyrowave_remote`.
return pyrowave_remote::open_preferring_worker(
width,
height,
fps,
bitrate_bps,
chroma,
)
.map(|e| (e, "pyrowave"));
return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps, chroma)
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"));
}
#[cfg(not(feature = "pyrowave"))]
anyhow::bail!(
@@ -526,16 +517,14 @@ fn open_video_backend_linux(
// The lab override forces the wavelet stream onto a session negotiated for
// another codec — that session's chroma may be HEVC-4:4:4, which the
// pyrowave encoder doesn't do yet, so pin the override to 4:2:0.
// Same worker seam as the negotiated arm above: the lab override is where the
// A/B is measured, so it must not be the one path that skips the worker.
pyrowave_remote::open_preferring_worker(
pyrowave::PyroWaveEncoder::open(
width,
height,
fps,
bitrate_bps,
ChromaFormat::Yuv420,
)
.map(|e| (e, "pyrowave"))
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"))
}
#[cfg(not(feature = "pyrowave"))]
{
@@ -1600,41 +1589,7 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
};
vulkan10 || vaapi::probe_can_encode_10bit(codec)
} else {
// NVIDIA. Same rule the 4:4:4 arm above already follows, and for the same field
// bug: on a direct-SDK host the answer comes from the driver's own
// `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` over the direct SDK, NOT from opening an
// ffmpeg `hevc_nvenc`. One ffmpeg NVENC open in a direct-SDK process wedges every
// later open process-wide with `NV_ENC_ERR_INVALID_VERSION` until the host
// restarts (LOG-3). This probe was the LAST ffmpeg-NVENC use left on a default
// host — it was kept on the reading that "Linux HDR rides the libav P010 path",
// which `open_video` contradicts: a CUDA payload goes to the direct backend at
// whatever `bit_depth` was resolved, and `is_ten_bit_input` accepts the packed
// 10-bit RGB (`X2Bgr10`) a gamescope HDR capture negotiates. Reproduced on
// home-nobara-1 2026-08-10: HDR on → probe opens ffmpeg → the live direct-SDK
// session's caps probe returns INVALID_VERSION → repeated in-place rebuilds →
// "encoder did not recover" and the session dies. With the ffmpeg probe out of the
// process the same HDR session streams clean.
//
// Only a host that will REALLY serve the session over libav keeps the ffmpeg probe
// (PUNKTFUNK_NVENC_DIRECT=0, or a build without `--features nvenc`): there it
// validates the actual path, and ffmpeg's NVENC client runs in that process anyway.
#[cfg(feature = "nvenc")]
{
if nvenc_direct_enabled() {
let t = nvenc_cuda::probe_support().ten_bit;
match codec {
Codec::H265 => t.h265,
Codec::Av1 => t.av1,
_ => false,
}
} else {
linux::probe_can_encode_10bit(codec)
}
}
#[cfg(not(feature = "nvenc"))]
{
linux::probe_can_encode_10bit(codec)
}
linux::probe_can_encode_10bit(codec)
}
}
#[cfg(target_os = "windows")]
@@ -2080,18 +2035,6 @@ mod vk_util;
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
#[path = "enc/linux/pyrowave.rs"]
mod pyrowave;
// `punktfunk-encode-worker` (design/gpu-priority-capability-worker.md): the capability-carrying
// process that owns the priority-elevated PyroWave device, because `punktfunk-host` may never hold
// a file capability (0.26.0-1 — a capped host is unidentifiable to KWin and loses desktop
// streaming). `worker` is the vocabulary plus the worker's own run loop, and it is `pub` for
// exactly one caller: the ~30-line `main` of the separate `punktfunk-encode-worker` binary.
// `pyrowave_remote` is the host-side proxy and its fallback ladder.
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
#[path = "enc/linux/pyrowave_remote.rs"]
mod pyrowave_remote;
#[cfg(all(target_os = "linux", feature = "pyrowave"))]
#[path = "enc/linux/worker.rs"]
pub mod worker;
// The Windows PyroWave encoder — NV12 zero-copy D3D11→Vulkan via pyrowave's own compat device
// (design/pyrowave-windows-host-zerocopy.md). Same module name as the Linux one (per-platform
// `#[path]`, mutually-exclusive cfg) so `crate::pyrowave::*` is flat on both.
-25
View File
@@ -260,27 +260,6 @@ pub struct HostConfig {
/// encode, so this is the knob that decides how bright "white" looks on the client's panel.
/// `None` = leave gamescope's own default.
pub gamescope_sdr_nits: Option<u32>,
/// `PUNKTFUNK_GAMESCOPE_BIND` — may the host bind the patched gamescope over
/// `/usr/bin/gamescope` inside the session unit's mount namespace? That redirect is the ONLY
/// lever left on a distro whose `gamescope-session-plus` hardcodes that absolute path and
/// reads `GAMESCOPE_BIN` nowhere (Nobara) — see `pf-vdisplay`'s `gamescope.rs`.
///
/// **Three-valued**, because the mechanism is not free and the default has to be the careful
/// one. A mount namespace in a systemd **user** unit necessarily comes with a **user**
/// namespace, which maps only this uid — so every root-owned path the session inspects reads
/// as `nobody`, and that is what made gamescope's Xwayland refuse `/tmp/.X11-unix` and killed
/// Game Mode outright in 0.26.0-canary.
///
/// * `None` (unset — the default): AUTO. The host reads the box's session script and arms the
/// redirect only where nothing else can reach gamescope. Every other distro gets no mount
/// namespace at all.
/// * `Some(false)` (`=0`): never. The session runs the distro's stock gamescope — no HDR, no
/// in-node cursor, games see gamescope's 60 Hz headless default — degraded, but it starts.
/// * `Some(true)` (`=1`): force. Arm it even where the script looks like it honours
/// `GAMESCOPE_BIN` — for the case that lever is defeated somewhere the host cannot see (a
/// `sessions.d` fragment presetting `GAMESCOPECMD`). It does NOT override the runtime
/// backstop: a session that fails with the redirect armed still disarms it.
pub gamescope_bind: Option<bool>,
/// `PUNKTFUNK_GAMESCOPE_REFRESH_RATES` — extra refresh rates (Hz, comma-separated) a gamescope
/// session offers its clients on top of the one it runs at, e.g. `60,90,120`.
///
@@ -412,10 +391,6 @@ impl HostConfig {
gamescope_sdr_nits: val("PUNKTFUNK_GAMESCOPE_SDR_NITS")
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|n| (1..=10_000).contains(n)),
// Deliberately NOT `unwrap_or`: unset is its own answer here (auto — the host decides
// per box), `=0` is the retreat to a stock-gamescope session, and `=1` is the force
// for a box whose `GAMESCOPE_BIN` is defeated somewhere the host cannot read.
gamescope_bind: env_on("PUNKTFUNK_GAMESCOPE_BIND"),
// Unparseable entries are DROPPED rather than failing the host: this only ever widens a
// menu, and the session's own rate is added back unconditionally, so the worst a typo
// can cost is the extra option the operator wanted — never the session.
@@ -299,8 +299,7 @@ impl PadProto for DsLinuxProto {
fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback {
let fb = pad.service(idx);
PadFeedback {
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
rumble: fb.rumble,
hidout: fb.hidout,
// Rumble-plane liveness (arms the shared abandoned-rumble force-off). evdev-FF games
// going through hid-playstation get their stops surfaced reliably, but Steam Input
@@ -402,8 +401,7 @@ impl PadProto for DsEdgeLinuxProto {
fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback {
let fb = pad.service(idx);
PadFeedback {
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
rumble: fb.rumble,
hidout: fb.hidout,
// Rumble-plane liveness (arms the shared abandoned-rumble force-off). evdev-FF games
// going through hid-playstation get their stops surfaced reliably, but Steam Input
@@ -314,8 +314,7 @@ impl PadProto for Ds4LinuxProto {
fn service(&self, pad: &mut DualShock4Pad, idx: u8) -> PadFeedback {
let fb = pad.service(idx);
PadFeedback {
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
rumble: fb.rumble,
hidout: fb
.led
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
+4 -9
View File
@@ -705,14 +705,9 @@ impl GamepadManager {
.ensure(idx, |i| VirtualPad::create(i as usize, identity));
}
/// Service every pad's FF protocol; `send(index, low, high, left_trigger, right_trigger)` is
/// invoked for each pad whose mixed rumble level changed. Call frequently (games block in
/// `EVIOCSFF` until answered).
///
/// The two trigger levels are always zero here and always will be: evdev's `FF_RUMBLE` effect
/// is `{ u16 strong_magnitude, u16 weak_magnitude }` and has no third field, so impulse-trigger
/// rumble is unreachable through this backend no matter what the client can render.
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16, u16, u16)) {
/// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose
/// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered).
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
// Finish any unplug whose removal frame only armed the grace — the producer sends that
// frame once, so without this the uinput node would outlive the controller. The swept
// mask is discarded because this manager keeps no per-index sibling state (the pads mix
@@ -720,7 +715,7 @@ impl GamepadManager {
self.slots.reap();
for (i, pad) in self.slots.iter_mut() {
if let Some((low, high)) = pad.pump_ff() {
send(i as u16, low, high, 0, 0);
send(i as u16, low, high);
}
}
}
@@ -440,8 +440,7 @@ impl PadProto for SteamProto {
fn service(&self, pad: &mut DeckTransport, _idx: u8) -> PadFeedback {
let rumble = pad.service();
PadFeedback {
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: rumble.map(|(low, high)| (low, high, 0, 0)),
rumble,
hidout: Vec::new(),
// Rumble-plane liveness: a `0xEB` rumble command this poll. Steam Input drives this
// pad over hidraw (the same abandonment semantics as the Windows Deck backend), so
@@ -571,8 +570,7 @@ impl PadProto for ScProto {
fn service(&self, pad: &mut SteamDeckPad, _idx: u8) -> PadFeedback {
let rumble = pad.service();
PadFeedback {
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: rumble.map(|(low, high)| (low, high, 0, 0)),
rumble,
hidout: Vec::new(),
// Rumble-plane liveness: the kernel registers no FF device for the classic SC, so
// rumble only ever arrives from a hidraw writer (`0xEB`) — which is exactly the
@@ -369,8 +369,7 @@ impl PadProto for TritonProto {
})
.collect();
PadFeedback {
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: rumble.map(|(low, high)| (low, high, 0, 0)),
rumble,
hidout,
// Rumble-plane liveness: Steam is a hidraw writer here too, so the shared
// abandoned-rumble force-off applies (the raw 0xCD passthrough plane is unaffected).
@@ -173,8 +173,7 @@ impl SwitchProPad {
let _ = self.write_report(&build_usb_ack(cmd));
}
Some(SwitchOutput::Subcmd { id, args, rumble }) => {
// No trigger motors on this protocol — see `PadFeedback::rumble`.
fb.rumble = Some((rumble.0, rumble.1, 0, 0));
fb.rumble = Some(rumble);
if id == 0x30 {
// Player lights ride the subcommand itself; still ack it.
if let Some(&arg) = args.first() {
@@ -186,7 +185,7 @@ impl SwitchProPad {
}
self.answer_subcmd(id, &args);
}
Some(SwitchOutput::Rumble(r)) => fb.rumble = Some((r.0, r.1, 0, 0)),
Some(SwitchOutput::Rumble(r)) => fb.rumble = Some(r),
None => {}
}
}
+1 -185
View File
@@ -16,78 +16,6 @@ const _: () = assert!(MAX_PADS <= 16);
/// quiet.
const SWEEP_GRACE: Duration = Duration::from_millis(300);
/// A create failure whose CAUSE the backend was able to identify, attached to the `anyhow` error
/// it returns (`err.context(PadCreateFault::…)`) so [`PadSlots::ensure`] can print the matching
/// remedy instead of the backend's default one.
///
/// Why this exists. The create-failure line's remedy is a per-backend constant (`PadSlots`'s
/// `hint`, from [`PadSlots::new`]), and on Windows that constant says "install/repair: punktfunk-host.exe
/// driver install --gamepad", because a pad create that fails there has nearly always failed for
/// want of the UMDF driver package. Nearly. On 2026-08-09 a `.173` devtest hit a create that
/// failed for the opposite reason — the drivers were fine and a LIVE SIBLING PROCESS already owned
/// the pad index's OS-level name — and the line told the operator to repair a driver that was
/// working. Worse, the run carried on: the retry could not succeed while the other process held
/// the index, and everything measured afterwards was that other process's pad (a frozen XInput
/// packet count read as a real measurement). A wrong remedy is worse than no remedy, so a backend
/// that can name the cause now says so and the line follows it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PadCreateFault {
/// The OS-level name this pad index needs — on Windows the `Global\pf…-boot-<index>` bootstrap
/// mailbox — is already held by another LIVE process.
///
/// Retrying stays right and is deliberately left alone: the name frees itself the moment the
/// owner releases it (a session ending, a service restart), and that is exactly how the field
/// case recovered. What retrying can never do is *hurry* it, and no driver install affects it
/// at all — which is the whole content of [`Self::hint`].
IndexOwnedElsewhere,
}
impl PadCreateFault {
/// Short tag for the structured `fault` log field — greppable; the prose lives in
/// [`Self::hint`].
pub fn as_str(self) -> &'static str {
match self {
PadCreateFault::IndexOwnedElsewhere => "index-owned-elsewhere",
}
}
/// The remedy this fault gets INSTEAD of the backend's default hint.
pub fn hint(self) -> &'static str {
match self {
PadCreateFault::IndexOwnedElsewhere => {
" — this pad index is already owned by another LIVE process (on a Windows host \
that is the LocalSystem PunktfunkHost service, whose session still holds the \
pad). The drivers are not the problem and reinstalling them will not help: the \
retry succeeds on its own once that process releases the index (end its session, \
or Restart-Service PunktfunkHost), or run against a pad index it does not hold."
}
}
}
}
impl std::fmt::Display for PadCreateFault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PadCreateFault::IndexOwnedElsewhere => f.write_str(
"the OS name this pad index needs is already owned by another live process",
),
}
}
}
/// The fault a backend attached to a create error, if any.
///
/// An `anyhow` context downcast, which is what makes this usable from a backend: the fault is
/// found however many further `.context()` layers were wrapped around it on the way up, so a
/// backend can attach it at the exact call that failed and still describe the failure in its own
/// words afterwards. Split out of [`PadSlots::ensure`] so the choice is testable without standing
/// up a tracing subscriber — and so the downcast-through-context behaviour this depends on is
/// pinned by a test rather than assumed (the attaching code is `cfg(windows)` and cannot be
/// compiled, let alone run, on a developer machine).
fn create_fault(err: &anyhow::Error) -> Option<PadCreateFault> {
err.downcast_ref::<PadCreateFault>().copied()
}
/// What one [`PadSlots::sweep`] changed, as bitmasks over the wire pad indices.
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
pub struct Sweep {
@@ -244,24 +172,11 @@ impl<P> PadSlots<P> {
true
}
Err(e) => {
// Which remedy to print. The backend's default `hint` assumes the failure is the
// one that dominates the field — on Windows an absent or stale driver package —
// and sends the operator to reinstall. For a create that failed because a live
// sibling owns this index that advice is not merely useless, it is a wrong lead
// that costs a debugging session (2026-08-09, `.173`), so a named fault overrides
// it. Anonymous failures keep the previous wording byte for byte.
//
// `index` is new and unconditional: the line used to name the backend and the
// device but never the SLOT, so a multi-pad session's failure could not be told
// from any other pad's.
let fault = create_fault(&e);
tracing::error!(
index = idx,
error = %format!("{e:#}"),
fault = fault.map_or("unclassified", PadCreateFault::as_str),
"virtual {} creation failed — retrying with backoff{}",
self.device,
fault.map_or(self.hint, PadCreateFault::hint)
self.hint
);
self.gate.on_failure(Instant::now());
false
@@ -269,18 +184,6 @@ impl<P> PadSlots<P> {
}
}
/// How many pads this table currently holds.
///
/// The question a bring-up harness has to ask before it believes anything it measures: a
/// create that failed leaves the slot empty and [`Self::ensure`] only logs, so a devtest that
/// pushes frames regardless is measuring whatever OTHER process's pad is answering on that
/// index — which is exactly how a stale pad's frozen packet count was once read as a result
/// (2026-08-09). Not `len` (and so not paired with `is_empty`): it counts LIVE pads, not the
/// fixed [`MAX_PADS`] slots the table always has.
pub fn live(&self) -> usize {
self.pads.iter().flatten().count()
}
/// The live pad at `idx`, if any (out-of-range → `None`).
pub fn get(&self, idx: usize) -> Option<&P> {
self.pads.get(idx).and_then(|s| s.as_ref())
@@ -447,93 +350,6 @@ mod tests {
assert_eq!(s.get(1), Some(&7), "the glitch never reached the drop");
}
/// The mechanism the Windows backend's diagnosis rests on, and the one thing about it that
/// could quietly stop working: [`create_fault`] must find the fault through however many
/// `.context()` layers wrapped it. The real chain is built in `gamepad_raii::create_named`
/// (`cfg(windows)`, so neither compiled nor run here) and has exactly this shape — the OS
/// error at the bottom, the fault, then the human sentence on top — so reproduce it verbatim.
#[test]
fn a_named_fault_survives_the_context_layers_wrapped_around_it() {
let err = anyhow::Error::msg("Zugriff verweigert (0x80070005)")
.context(PadCreateFault::IndexOwnedElsewhere)
.context("bootstrap mailbox Global\\pfds-boot-0 already exists");
assert_eq!(
create_fault(&err),
Some(PadCreateFault::IndexOwnedElsewhere)
);
// …and the operator-facing rendering still carries every layer, newest first, so the
// underlying OS error is never traded away for the diagnosis.
let shown = format!("{err:#}");
assert!(shown.contains("Global\\pfds-boot-0"), "{shown}");
assert!(
shown.contains("already owned by another live process"),
"{shown}"
);
assert!(shown.contains("0x80070005"), "{shown}");
}
#[test]
fn an_unclassified_failure_carries_no_fault() {
// Every other backend failure — a missing driver, a wedged PnP, an EBUSY on /dev/uinput —
// must keep the backend's own hint, so the absence of a fault has to read as absence.
assert_eq!(
create_fault(&anyhow::Error::msg("SwDeviceCreate failed")),
None
);
}
/// THE regression this classification exists for: the contended remedy must not send an
/// operator to reinstall a driver that is working fine, and must name what actually has to
/// happen. Asserted on the text because the text is the whole deliverable.
#[test]
fn the_contended_hint_never_tells_the_operator_to_reinstall_drivers() {
let hint = PadCreateFault::IndexOwnedElsewhere.hint();
assert!(
!hint.contains("driver install"),
"the contended hint must not repeat the driver-repair advice: {hint}"
);
assert!(hint.contains("already owned"), "{hint}");
assert!(hint.contains("Restart-Service"), "{hint}");
}
/// A named fault must not turn the create into a permanent latch — that latch is the exact
/// `broken: bool` behaviour [`PadGate`] was built to remove, and the field case healed by
/// itself precisely because the retry was still running when the owning service restarted.
#[test]
fn a_contended_create_still_backs_off_and_retries_rather_than_latching() {
let mut s = slots();
let contended = || {
Err(anyhow::Error::msg("Zugriff verweigert")
.context(PadCreateFault::IndexOwnedElsewhere))
};
assert!(!s.ensure(0, |_| contended()));
assert_eq!(s.live(), 0);
// Backed off, not latched: once the window elapses the closure runs again. `ensure` reads
// the wall clock, so clear the backoff directly rather than sleeping through it — the
// window's own arithmetic is pinned by `pad_gate`'s tests.
s.gate.on_success();
let mut ran = false;
assert!(s.ensure(0, |i| {
ran = true;
Ok(i as u32)
}));
assert!(ran, "the create was never re-attempted");
assert_eq!(s.live(), 1);
}
#[test]
fn live_counts_built_pads_not_slots() {
let mut s = slots();
assert_eq!(s.live(), 0, "an empty table has no pads, only slots");
assert!(s.ensure(0, |_| Ok(0)));
assert!(s.ensure(4, |_| Ok(4)));
assert_eq!(s.live(), 2);
let t0 = Instant::now();
s.sweep_at(0, t0);
s.sweep_at(0, t0 + SWEEP_GRACE);
assert_eq!(s.live(), 0);
}
#[test]
fn create_failure_arms_the_gate_and_success_heals_it() {
let mut s = slots();
@@ -1,386 +0,0 @@
//! Xbox Wireless Controller HID codec — the byte-exact input report the `pf-gamepad` driver serves
//! under `device_type = 4` ([`pf_driver_proto::gamepad::DEVTYPE_XBOX`]).
//!
//! **Why an Xbox pad speaks HID at all.** The other Windows Xbox backend, `pf-xusb`, registers only
//! `GUID_DEVINTERFACE_XUSB` and exposes no HID collection, so Steam's hidapi enumeration,
//! DirectInput, `joy.cpl` and WGI/GameInput cannot see it — only classic `XInputGetState` via
//! xinput1_4's interface walk ever does. A field report (2026-08-09) burned two weeks on a dead
//! controller for exactly that reason, and switching the client to DualSense — a real HID pad
//! through the same UMDF driver — fixed it instantly. This codec puts the Xbox pad on that footing.
//!
//! **The report is the descriptor's mirror image.** `pf-gamepad`'s `XBOX_RDESC` declares, in order:
//! two 16-bit stick pairs (`X`/`Y`, then `Rx`/`Ry`, logical 0..65535), two 16-bit triggers on the
//! Simulation page (`Brake`/`Accelerator`, logical 0..1023), a 4-bit null-state hat plus 4 bits of
//! padding, and 15 buttons plus 1 bit of padding. [`serialize_xbox_state`] writes exactly that, and
//! [`tests`] pins every field position — change one side and the tests fail.
//!
//! ⚠️⚠️ **The button numbering below is the REAL Xbox-Bluetooth layout, gaps included, and that is
//! load-bearing.** We enumerate as a genuine Microsoft `045E:0B13`, and SDL / Steam / Windows all
//! carry built-in mappings keyed off that VID/PID. Renumber these to something "tidier" and every
//! consumer with a stock mapping silently lands each control on the wrong action — the exact class
//! of bug this module exists to end. The reserved slots (3, 6, 9, 10) are Microsoft's; leave them
//! empty.
//!
//! ⚠️ **Never validated against real hardware.** No Windows box was reachable when this was written
//! (`punktfunk-field-windows-pad-dead-0260`), so the layout is from the documented Xbox One S / Series
//! Bluetooth report and has not been diffed against a capture. Do that before shipping: dump a real
//! pad's descriptor + a few reports and compare against `XBOX_RDESC` and the tests here.
use punktfunk_core::input::gamepad as gs;
/// Bytes an Xbox input report occupies on the wire, report id included. Must equal the driver's
/// `XBOX_INPUT_REPORT_LEN` — hidclass sizes its READ_REPORT buffer from the descriptor and the
/// driver's `copy_to_output` refuses a longer source rather than truncating.
pub const XBOX_REPORT_LEN: usize = 16;
/// The report id the descriptor declares for the input report.
const REPORT_ID: u8 = 0x01;
/// Stick centre on the descriptor's 0..65535 axis.
const STICK_CENTRE: u16 = 0x8000;
/// Trigger full scale on the descriptor's 0..1023 (10-bit) axis.
const TRIGGER_MAX: u32 = 1023;
// ---- Button bit positions, LSB-first across report bytes 14..16 ----
//
// HID button N lands on bit (N-1). These are the REAL Xbox-Bluetooth assignments; slots 3, 6, 9
// and 10 are reserved by Microsoft and stay empty (see the module note).
const BIT_A: u8 = 0; // button 1
const BIT_B: u8 = 1; // button 2
const BIT_X: u8 = 3; // button 4
const BIT_Y: u8 = 4; // button 5
const BIT_LB: u8 = 6; // button 7
const BIT_RB: u8 = 7; // button 8
const BIT_VIEW: u8 = 10; // button 11 (Back/Select)
const BIT_MENU: u8 = 11; // button 12 (Start)
const BIT_GUIDE: u8 = 12; // button 13 (Xbox button)
const BIT_LS: u8 = 13; // button 14 (left stick click)
const BIT_RS: u8 = 14; // button 15 (right stick click)
/// One Xbox pad's state, in the wire's own conventions (sticks 32768..32767 with **+y = up**,
/// triggers 0..255, buttons the [`gs`] `BTN_*` bitmask) — converted to the HID report's
/// conventions by [`serialize_xbox_state`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct XboxState {
pub buttons: u32,
pub left_trigger: u8,
pub right_trigger: u8,
pub ls_x: i16,
pub ls_y: i16,
pub rs_x: i16,
pub rs_y: i16,
}
impl XboxState {
/// Build from the wire's per-pad frame fields (`punktfunk_core::input::GamepadFrame`).
#[allow(clippy::too_many_arguments)]
pub fn from_gamepad(
buttons: u32,
left_trigger: u8,
right_trigger: u8,
ls_x: i16,
ls_y: i16,
rs_x: i16,
rs_y: i16,
) -> XboxState {
XboxState {
buttons,
left_trigger,
right_trigger,
ls_x,
ls_y,
rs_x,
rs_y,
}
}
}
/// Wire stick axis (32768..32767) → the descriptor's unsigned 0..65535 X/Rx axis.
fn axis_x(v: i16) -> u16 {
(v as i32 + 32768) as u16
}
/// Wire stick axis → the descriptor's 0..65535 Y/Ry axis, **inverted**.
///
/// The wire follows the XInput/Moonlight convention where **+y is UP**; HID's `Y`/`Ry` grow
/// DOWNWARD. Forwarding the wire value unconverted is how a pad ends up with an inverted look
/// stick that nobody notices until they aim.
///
/// ⚠️ A signed 16-bit range has no exact midpoint, so the inverted axis centres one unit lower
/// than the upright one: `axis_x(0)` is 32768 and `axis_y(0)` is 32767. Both endpoints are exact
/// (full up → 0, full down → 65535), which is what matters; the 1/65536 offset at rest is below
/// any deadzone. Do NOT "fix" it by centring on 32768 — that costs an endpoint instead.
fn axis_y(v: i16) -> u16 {
65535 - axis_x(v)
}
/// Wire trigger (0..255) → the descriptor's 10-bit 0..1023 axis, rounded rather than truncated so
/// a fully-held trigger reads exactly full scale.
fn trigger(v: u8) -> u16 {
((v as u32 * TRIGGER_MAX + 127) / 255) as u16
}
/// The d-pad bits → the descriptor's hat value: `0` is the NULL state (the logical range starts at
/// 1), then 1..8 clockwise from North. Opposing presses cancel, matching a physical hat.
fn hat(buttons: u32) -> u8 {
let up = buttons & gs::BTN_DPAD_UP != 0;
let down = buttons & gs::BTN_DPAD_DOWN != 0;
let left = buttons & gs::BTN_DPAD_LEFT != 0;
let right = buttons & gs::BTN_DPAD_RIGHT != 0;
// Cancel opposing pairs first so up+down reads centred rather than picking one.
let (up, down) = if up && down {
(false, false)
} else {
(up, down)
};
let (left, right) = if left && right {
(false, false)
} else {
(left, right)
};
match (up, right, down, left) {
(true, false, false, false) => 1, // N
(true, true, false, false) => 2, // NE
(false, true, false, false) => 3, // E
(false, true, true, false) => 4, // SE
(false, false, true, false) => 5, // S
(false, false, true, true) => 6, // SW
(false, false, false, true) => 7, // W
(true, false, false, true) => 8, // NW
_ => 0, // nothing held → NULL
}
}
/// The 15 face/shoulder/system buttons packed into the report's last two bytes.
fn button_bits(buttons: u32) -> (u8, u8) {
let mut bits: u16 = 0;
for (mask, bit) in [
(gs::BTN_A, BIT_A),
(gs::BTN_B, BIT_B),
(gs::BTN_X, BIT_X),
(gs::BTN_Y, BIT_Y),
(gs::BTN_LB, BIT_LB),
(gs::BTN_RB, BIT_RB),
(gs::BTN_BACK, BIT_VIEW),
(gs::BTN_START, BIT_MENU),
(gs::BTN_GUIDE, BIT_GUIDE),
(gs::BTN_LS_CLICK, BIT_LS),
(gs::BTN_RS_CLICK, BIT_RS),
] {
if buttons & mask != 0 {
bits |= 1 << bit;
}
}
(bits as u8, (bits >> 8) as u8)
}
/// Serialize one [`XboxState`] into the driver's input report.
pub fn serialize_xbox_state(s: &XboxState) -> [u8; XBOX_REPORT_LEN] {
let mut r = [0u8; XBOX_REPORT_LEN];
r[0] = REPORT_ID;
r[1..3].copy_from_slice(&axis_x(s.ls_x).to_le_bytes());
r[3..5].copy_from_slice(&axis_y(s.ls_y).to_le_bytes());
r[5..7].copy_from_slice(&axis_x(s.rs_x).to_le_bytes());
r[7..9].copy_from_slice(&axis_y(s.rs_y).to_le_bytes());
r[9..11].copy_from_slice(&trigger(s.left_trigger).to_le_bytes());
r[11..13].copy_from_slice(&trigger(s.right_trigger).to_le_bytes());
r[13] = hat(s.buttons); // low nibble; the high nibble is descriptor padding
let (lo, hi) = button_bits(s.buttons);
r[14] = lo;
r[15] = hi;
r
}
/// The at-rest report: sticks centred, triggers released, hat NULL, nothing held. Must agree with
/// the driver's `XBOX_NEUTRAL_REPORT` — [`tests::neutral_matches_a_zeroed_state`] pins that.
pub fn neutral_xbox_report() -> [u8; XBOX_REPORT_LEN] {
serialize_xbox_state(&XboxState::default())
}
#[cfg(test)]
mod tests {
use super::*;
fn le(b: &[u8]) -> u16 {
u16::from_le_bytes([b[0], b[1]])
}
/// The field offsets the driver's `XBOX_RDESC` declares. If this fails, one side moved.
#[test]
fn the_report_matches_the_descriptor_layout() {
let s = XboxState::from_gamepad(0, 0, 0, 0, 0, 0, 0);
let r = serialize_xbox_state(&s);
assert_eq!(r.len(), XBOX_REPORT_LEN, "16 bytes: id + 8 + 4 + 1 + 2");
assert_eq!(r[0], 0x01, "report id");
}
#[test]
fn sticks_span_the_full_unsigned_axis() {
let full = XboxState::from_gamepad(0, 0, 0, i16::MIN, i16::MIN, i16::MAX, i16::MAX);
let r = serialize_xbox_state(&full);
assert_eq!(le(&r[1..3]), 0, "LX at hard left = 0");
assert_eq!(le(&r[5..7]), 65535, "RX at hard right = 65535");
}
/// +y is UP on the wire and DOWN in HID — the conversion has to flip, or aiming is inverted.
#[test]
fn the_y_axes_are_inverted_into_hid_convention() {
let up = XboxState::from_gamepad(0, 0, 0, 0, i16::MAX, 0, i16::MAX);
let r = serialize_xbox_state(&up);
assert_eq!(le(&r[3..5]), 0, "stick fully UP is 0 in HID");
assert_eq!(le(&r[7..9]), 0, "right stick too");
let down = XboxState::from_gamepad(0, 0, 0, 0, i16::MIN, 0, i16::MIN);
let r = serialize_xbox_state(&down);
assert_eq!(le(&r[3..5]), 65535, "stick fully DOWN is full scale");
assert_eq!(le(&r[7..9]), 65535);
}
/// At rest the upright axes sit on `STICK_CENTRE` and the inverted ones one unit below — the
/// unavoidable consequence of mirroring a range with an even number of steps (see `axis_y`).
#[test]
fn a_centred_stick_reads_centred() {
let r = neutral_xbox_report();
assert_eq!(le(&r[1..3]), STICK_CENTRE, "LX");
assert_eq!(le(&r[5..7]), STICK_CENTRE, "RX");
assert_eq!(le(&r[3..5]), STICK_CENTRE - 1, "LY (inverted)");
assert_eq!(le(&r[7..9]), STICK_CENTRE - 1, "RY (inverted)");
}
/// A fully-held trigger must reach exactly full scale — truncating division stops at 1020 and
/// games with a "trigger fully pressed" threshold never fire.
#[test]
fn triggers_scale_to_full_ten_bit_range() {
let none = serialize_xbox_state(&XboxState::default());
assert_eq!(le(&none[9..11]), 0);
assert_eq!(le(&none[11..13]), 0);
let held = XboxState::from_gamepad(0, 255, 255, 0, 0, 0, 0);
let r = serialize_xbox_state(&held);
assert_eq!(le(&r[9..11]), 1023, "LT fully held = full scale");
assert_eq!(le(&r[11..13]), 1023, "RT fully held = full scale");
let half = XboxState::from_gamepad(0, 128, 0, 0, 0, 0, 0);
let r = serialize_xbox_state(&half);
assert_eq!(le(&r[9..11]), 514, "128/255 rounds to 514, not 513");
}
#[test]
fn the_hat_walks_clockwise_from_north() {
let cases = [
(0, 0u8),
(gs::BTN_DPAD_UP, 1),
(gs::BTN_DPAD_UP | gs::BTN_DPAD_RIGHT, 2),
(gs::BTN_DPAD_RIGHT, 3),
(gs::BTN_DPAD_RIGHT | gs::BTN_DPAD_DOWN, 4),
(gs::BTN_DPAD_DOWN, 5),
(gs::BTN_DPAD_DOWN | gs::BTN_DPAD_LEFT, 6),
(gs::BTN_DPAD_LEFT, 7),
(gs::BTN_DPAD_UP | gs::BTN_DPAD_LEFT, 8),
];
for (buttons, want) in cases {
let r = serialize_xbox_state(&XboxState::from_gamepad(buttons, 0, 0, 0, 0, 0, 0));
assert_eq!(r[13] & 0x0F, want, "buttons {buttons:#x}");
}
}
/// Opposing presses cancel to NULL rather than resolving to one direction — a physical hat
/// cannot report both, and a game that sees "up" while the player holds up+down drifts.
#[test]
fn opposing_dpad_presses_cancel() {
let ud = gs::BTN_DPAD_UP | gs::BTN_DPAD_DOWN;
let r = serialize_xbox_state(&XboxState::from_gamepad(ud, 0, 0, 0, 0, 0, 0));
assert_eq!(r[13] & 0x0F, 0);
let lr = gs::BTN_DPAD_LEFT | gs::BTN_DPAD_RIGHT;
let r = serialize_xbox_state(&XboxState::from_gamepad(lr, 0, 0, 0, 0, 0, 0));
assert_eq!(r[13] & 0x0F, 0);
}
/// The real Xbox-Bluetooth button numbering, gaps included. SDL/Steam/Windows key their stock
/// mappings off our claimed `045E:0B13`, so these positions are a compatibility contract.
#[test]
fn buttons_land_on_the_real_xbox_bluetooth_positions() {
let cases: [(u32, usize, u8); 11] = [
(gs::BTN_A, 14, 0),
(gs::BTN_B, 14, 1),
(gs::BTN_X, 14, 3),
(gs::BTN_Y, 14, 4),
(gs::BTN_LB, 14, 6),
(gs::BTN_RB, 14, 7),
(gs::BTN_BACK, 15, 2),
(gs::BTN_START, 15, 3),
(gs::BTN_GUIDE, 15, 4),
(gs::BTN_LS_CLICK, 15, 5),
(gs::BTN_RS_CLICK, 15, 6),
];
for (mask, byte, bit) in cases {
let r = serialize_xbox_state(&XboxState::from_gamepad(mask, 0, 0, 0, 0, 0, 0));
assert_eq!(
r[byte] & (1u8 << bit),
1u8 << bit,
"mask {mask:#x} should set byte {byte} bit {bit}"
);
// and nothing else in the button bytes
let shift = bit as u16 + (byte as u16 - 14) * 8;
let others = (r[14] as u16 | (r[15] as u16) << 8) & !(1u16 << shift);
assert_eq!(others, 0, "mask {mask:#x} set a second button bit");
}
}
/// Microsoft's reserved slots (buttons 3, 6, 9, 10) and the descriptor's trailing pad bit must
/// stay clear — a stray bit there reads as a button the real pad does not have.
#[test]
fn reserved_button_slots_stay_empty() {
let all = gs::BTN_A
| gs::BTN_B
| gs::BTN_X
| gs::BTN_Y
| gs::BTN_LB
| gs::BTN_RB
| gs::BTN_BACK
| gs::BTN_START
| gs::BTN_GUIDE
| gs::BTN_LS_CLICK
| gs::BTN_RS_CLICK;
let r = serialize_xbox_state(&XboxState::from_gamepad(all, 0, 0, 0, 0, 0, 0));
let bits = r[14] as u16 | (r[15] as u16) << 8;
for reserved_bit in [2u8, 5, 8, 9, 15] {
assert_eq!(
bits & (1 << reserved_bit),
0,
"bit {reserved_bit} is reserved/padding and must stay clear"
);
}
}
/// Extended wire buttons the Xbox HID profile has no slot for (touchpad, capture, paddles) must
/// be dropped silently rather than colliding with a real button.
#[test]
fn unmappable_wire_buttons_are_dropped() {
let extra = gs::BTN_TOUCHPAD | gs::BTN_MISC1 | gs::BTN_PADDLE1;
let r = serialize_xbox_state(&XboxState::from_gamepad(extra, 0, 0, 0, 0, 0, 0));
assert_eq!(r[14], 0);
assert_eq!(r[15], 0);
assert_eq!(r[13] & 0x0F, 0);
}
#[test]
fn neutral_matches_a_zeroed_state() {
assert_eq!(
neutral_xbox_report(),
serialize_xbox_state(&XboxState::default())
);
let r = neutral_xbox_report();
assert_eq!(r[13], 0, "hat NULL");
assert_eq!(r[14], 0);
assert_eq!(r[15], 0);
// Mirrors the driver's XBOX_NEUTRAL_REPORT byte for byte — if these drift, a game reads a
// different at-rest pose before the host's first frame lands than after it.
assert_eq!(r[0], 0x01);
assert_eq!([r[1], r[2]], [0x00, 0x80], "LX = 0x8000");
assert_eq!([r[3], r[4]], [0xFF, 0x7F], "LY = 0x7FFF (inverted centre)");
assert_eq!([r[5], r[6]], [0x00, 0x80], "RX = 0x8000");
assert_eq!([r[7], r[8]], [0xFF, 0x7F], "RY = 0x7FFF (inverted centre)");
}
}
+45 -120
View File
@@ -18,21 +18,13 @@ use std::time::{Duration, Instant};
/// 0xCD feedback events (lightbar / player LEDs / adaptive triggers), deduped via [`HidoutDedup`].
#[derive(Default)]
pub struct PadFeedback {
/// `(low, high, left_trigger, right_trigger)` motor levels, if the pass saw a rumble report.
/// `(low, high)` motor levels, if the pass saw a rumble report.
///
/// Range is `0..=0xFFFF` — this said `0..=0xFF00`, which is only true of the backends that
/// widen the device's 8-bit motor byte by `<< 8` (the UHID/DualSense path). The Windows
/// backend widens by `× 257` and does reach 0xFFFF, and this type carries both. Neither is a
/// defect: consumers narrow with `>> 8`, and 0xFF00 and 0xFFFF both narrow back to 255.
///
/// The two trailing fields are the Xbox impulse-trigger motors, which ride the 0xCA plane's
/// v3 tail (design/trigger-rumble-plane.md). **Exactly one backend can ever set them non-zero**
/// — the Windows HID Xbox pad, whose output report `0x03` has fields for them. Every other
/// backend reports `(low, high, 0, 0)` because the packet it parses has nowhere to carry them:
/// XUSB's `SET_STATE` is `rumble_large`/`rumble_small`, evdev's `FF_RUMBLE` is strong/weak,
/// and a DualSense's trigger actuators are *adaptive* (force resistance, on the 0xCD plane)
/// rather than motors. That is a permanent property of those protocols, not a gap to fill.
pub rumble: Option<(u16, u16, u16, u16)>,
pub rumble: Option<(u16, u16)>,
pub hidout: Vec<HidOutput>,
/// Whether the game drove this pad's RUMBLE plane this poll — at least one output report
/// asserted the vibration fields (valid-flag set, including an explicit zero), not merely any
@@ -127,11 +119,8 @@ pub struct UhidManager<B: PadProto> {
slots: PadSlots<B::Pad>,
/// Each pad's current full report — buttons/sticks merged with persisted rich-plane fields.
state: Vec<B::State>,
/// Last rumble forwarded per pad, so a report that only changes rich feedback doesn't re-send
/// it. All FOUR levels, deliberately: dedup on the handle pair alone would swallow a
/// trigger-only change — a racing title's impulse-trigger stream against silent handles — and
/// the pad would never rumble, with nothing logged anywhere.
last_rumble: Vec<(u16, u16, u16, u16)>,
/// Last rumble forwarded per pad, so a report that only changes rich feedback doesn't re-send it.
last_rumble: Vec<(u16, u16)>,
/// Last rich feedback forwarded per pad, so an output report that only changed the rumble
/// doesn't re-send unchanged lightbar/LED/trigger state.
hidout_dedup: Vec<HidoutDedup>,
@@ -265,7 +254,7 @@ impl<B: PadProto> UhidManager<B> {
backend,
slots: PadSlots::new(B::LABEL, B::DEVICE, B::CREATE_HINT),
state,
last_rumble: vec![(0, 0, 0, 0); MAX_PADS],
last_rumble: vec![(0, 0); MAX_PADS],
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
last_write: vec![Instant::now(); MAX_PADS],
last_active: vec![Instant::now(); MAX_PADS],
@@ -274,19 +263,6 @@ impl<B: PadProto> UhidManager<B> {
}
}
/// How many virtual pads this manager has actually BUILT
/// ([`PadSlots::live`](crate::pad_slots::PadSlots::live)).
///
/// For bring-up harnesses, which are the only callers that can act on it: a create failure
/// leaves the slot empty and only logs, so a harness that pushes frames regardless still
/// "works" — it just drives nothing, while whatever OTHER process owns that pad index keeps
/// answering every probe the operator then runs. That is how a stale pad's frozen XInput
/// packet count was once read as a measurement (2026-08-09, `.173`). A session has no use for
/// this: its pads come and go with the client's `active_mask` and zero is a normal state.
pub fn live_pads(&self) -> usize {
self.slots.live()
}
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
pub fn handle(&mut self, ev: &GamepadEvent) {
match ev {
@@ -363,14 +339,13 @@ impl<B: PadProto> UhidManager<B> {
}
/// Service every pad: answer any pending driver/kernel handshake and route a game's feedback
/// back out. `rumble` is invoked `(index, low, high, left_trigger, right_trigger)` only when
/// the motor level *changes* (the universal 0xCA plane — the trigger pair is non-zero only on
/// the Windows HID Xbox pad, see [`PadFeedback::rumble`]); `hidout` is invoked per rich
/// feedback event that isn't an exact repeat of the last-forwarded value (the 0xCD plane).
/// Call frequently — kernel/driver init handshakes block until answered.
/// back out. `rumble` is invoked `(index, low, high)` only when the motor level *changes* (the
/// universal 0xCA plane); `hidout` is invoked per rich feedback event that isn't an exact
/// repeat of the last-forwarded value (the 0xCD plane). Call frequently — kernel/driver init
/// handshakes block until answered.
pub fn pump(
&mut self,
mut rumble: impl FnMut(u16, u16, u16, u16, u16),
mut rumble: impl FnMut(u16, u16, u16),
mut hidout: impl FnMut(HidOutput),
) {
let now = Instant::now();
@@ -394,9 +369,9 @@ impl<B: PadProto> UhidManager<B> {
// the next LED/trigger state re-forwards. WARN through the per-pad rate limiter —
// a storm overflows every poll and the raw line once flooded a whole log export.
self.overflow_warn[i].note(now, B::LABEL, i);
if self.last_rumble[i] != (0, 0, 0, 0) {
self.last_rumble[i] = (0, 0, 0, 0);
rumble(i as u16, 0, 0, 0, 0);
if self.last_rumble[i] != (0, 0) {
self.last_rumble[i] = (0, 0);
rumble(i as u16, 0, 0);
}
self.hidout_dedup[i] = HidoutDedup::default();
}
@@ -410,9 +385,9 @@ impl<B: PadProto> UhidManager<B> {
if let Some(r) = fb.rumble {
if self.last_rumble[i] != r {
self.last_rumble[i] = r;
rumble(i as u16, r.0, r.1, r.2, r.3);
rumble(i as u16, r.0, r.1);
}
} else if self.last_rumble[i] != (0, 0, 0, 0)
} else if self.last_rumble[i] != (0, 0)
&& rumble_idle_timeout()
.is_some_and(|t| now.duration_since(self.last_active[i]) >= t)
{
@@ -425,12 +400,10 @@ impl<B: PadProto> UhidManager<B> {
index = i,
prev_low = self.last_rumble[i].0,
prev_high = self.last_rumble[i].1,
prev_lt = self.last_rumble[i].2,
prev_rt = self.last_rumble[i].3,
"rumble: stale residual (game stopped driving the rumble plane) — forcing off"
);
self.last_rumble[i] = (0, 0, 0, 0);
rumble(i as u16, 0, 0, 0, 0);
self.last_rumble[i] = (0, 0);
rumble(i as u16, 0, 0);
}
for h in fb.hidout {
// Skip rich feedback that repeats the last-forwarded value (a game's output report
@@ -496,7 +469,7 @@ impl<B: PadProto> UhidManager<B> {
/// (re)connect starts from scratch and is always forwarded.
fn reset_pad(&mut self, idx: usize) {
self.state[idx] = self.backend.neutral();
self.last_rumble[idx] = (0, 0, 0, 0);
self.last_rumble[idx] = (0, 0);
self.hidout_dedup[idx].clear();
self.last_write[idx] = Instant::now();
self.last_active[idx] = Instant::now();
@@ -760,14 +733,14 @@ mod tests {
m.handle(&frame(1, 0b00, 0));
assert!(m.slots.get(1).is_some(), "inside the grace — not yet swept");
// A tick inside the grace must NOT flap the devnode (pad_slots::SWEEP_GRACE).
m.pump(|_, _, _, _, _| {}, |_| {});
m.pump(|_, _, _| {}, |_| {});
assert!(
m.slots.get(1).is_some(),
"a tick inside the grace dropped it"
);
// Grace elapsed: the next tick completes the unplug, with no further frame.
m.slots.expire_grace();
m.pump(|_, _, _, _, _| {}, |_| {});
m.pump(|_, _, _| {}, |_| {});
assert!(
m.slots.get(1).is_none(),
"the pump tick never completed the unplug"
@@ -810,10 +783,7 @@ mod tests {
m.handle(&frame(0, 0b1, 0));
let collect = |m: &mut UhidManager<MockProto>| {
let out = RefCell::new(Vec::new());
m.pump(
|i, lo, hi, lt, rt| out.borrow_mut().push((i, lo, hi, lt, rt)),
|_| {},
);
m.pump(|i, lo, hi| out.borrow_mut().push((i, lo, hi)), |_| {});
out.into_inner()
};
let rumble = |r| PadFeedback {
@@ -822,16 +792,12 @@ mod tests {
rumble_drove: Some(true),
resync: false,
};
*m.backend.feedback.borrow_mut() = vec![
rumble((100, 0, 0, 0)),
rumble((100, 0, 0, 0)),
rumble((7, 7, 0, 0)),
];
assert_eq!(collect(&mut m), vec![(0, 100, 0, 0, 0)]); // first value forwards
*m.backend.feedback.borrow_mut() = vec![rumble((100, 0)), rumble((100, 0)), rumble((7, 7))];
assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards
assert_eq!(collect(&mut m), vec![]); // exact repeat deduped
assert_eq!(collect(&mut m), vec![(0, 7, 7, 0, 0)]); // change forwards
// Unplug + recreate re-arms the dedup: the same level forwards again. The unplug completes
// on a PUMP tick, not on a second frame — that is all production ever sends.
assert_eq!(collect(&mut m), vec![(0, 7, 7)]); // change forwards
// Unplug + recreate re-arms the dedup: the same level forwards again. The unplug completes
// on a PUMP tick, not on a second frame — that is all production ever sends.
m.handle(&frame(0, 0b0, 0)); // the one removal frame — arms the grace
m.slots.expire_grace();
assert_eq!(collect(&mut m), vec![]); // this tick reaps; nothing queued to forward
@@ -840,43 +806,8 @@ mod tests {
"the pump tick completed the unplug"
);
m.handle(&frame(0, 0b1, 0));
*m.backend.feedback.borrow_mut() = vec![rumble((7, 7, 0, 0))];
assert_eq!(collect(&mut m), vec![(0, 7, 7, 0, 0)]);
}
/// The dedup compares all FOUR levels. Comparing only the handle pair would swallow a
/// trigger-only change — which is the *normal* shape of impulse-trigger content, since racing
/// titles drive the triggers continuously against near-silent handles — and the pad would
/// simply never rumble, with nothing logged and nothing on the wire to look at.
#[test]
fn a_trigger_only_change_is_forwarded_not_deduped_away() {
let mut m = mgr();
m.handle(&frame(0, 0b1, 0));
let collect = |m: &mut UhidManager<MockProto>| {
let out = RefCell::new(Vec::new());
m.pump(
|i, lo, hi, lt, rt| out.borrow_mut().push((i, lo, hi, lt, rt)),
|_| {},
);
out.into_inner()
};
let rumble = |r| PadFeedback {
rumble: Some(r),
hidout: Vec::new(),
rumble_drove: Some(true),
resync: false,
};
// Handles silent throughout; only the trigger motors move.
*m.backend.feedback.borrow_mut() = vec![
rumble((0, 0, 0x8000, 0)),
rumble((0, 0, 0x8000, 0)),
rumble((0, 0, 0x8000, 0x4000)),
rumble((0, 0, 0, 0)),
];
assert_eq!(collect(&mut m), vec![(0, 0, 0, 0x8000, 0)]);
assert_eq!(collect(&mut m), vec![], "exact repeat still dedups");
assert_eq!(collect(&mut m), vec![(0, 0, 0, 0x8000, 0x4000)]);
assert_eq!(collect(&mut m), vec![(0, 0, 0, 0, 0)], "the stop forwards");
*m.backend.feedback.borrow_mut() = vec![rumble((7, 7))];
assert_eq!(collect(&mut m), vec![(0, 7, 7)]);
}
#[test]
@@ -885,20 +816,17 @@ mod tests {
m.handle(&frame(0, 0b1, 0));
let collect = |m: &mut UhidManager<MockProto>| {
let out = RefCell::new(Vec::new());
m.pump(
|i, lo, hi, lt, rt| out.borrow_mut().push((i, lo, hi, lt, rt)),
|_| {},
);
m.pump(|i, lo, hi| out.borrow_mut().push((i, lo, hi)), |_| {});
out.into_inner()
};
// The game latches a non-zero rumble (a fresh report drove the pad).
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: Some((200, 0, 0, 0)),
rumble: Some((200, 0)),
hidout: Vec::new(),
rumble_drove: Some(true),
resync: false,
}];
assert_eq!(collect(&mut m), vec![(0, 200, 0, 0, 0)]);
assert_eq!(collect(&mut m), vec![(0, 200, 0)]);
// The game stops driving the RUMBLE plane — no output report at all, or (equivalently, the
// confirmed stuck-ON case) a stream of LED/adaptive-trigger reports that never assert the
@@ -917,7 +845,7 @@ mod tests {
// exactly once, then stays off (no repeated zero spam).
m.last_active[0] = Instant::now() - (RUMBLE_IDLE_TIMEOUT + Duration::from_millis(50));
*m.backend.feedback.borrow_mut() = vec![idle(), idle()];
assert_eq!(collect(&mut m), vec![(0, 0, 0, 0, 0)]); // forced off
assert_eq!(collect(&mut m), vec![(0, 0, 0)]); // forced off
assert_eq!(collect(&mut m), vec![]); // already zero — no repeat
}
@@ -927,19 +855,16 @@ mod tests {
m.handle(&frame(0, 0b1, 0));
let collect = |m: &mut UhidManager<MockProto>| {
let out = RefCell::new(Vec::new());
m.pump(
|i, lo, hi, lt, rt| out.borrow_mut().push((i, lo, hi, lt, rt)),
|_| {},
);
m.pump(|i, lo, hi| out.borrow_mut().push((i, lo, hi)), |_| {});
out.into_inner()
};
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: Some((200, 0, 0, 0)),
rumble: Some((200, 0)),
hidout: Vec::new(),
rumble_drove: Some(true),
resync: false,
}];
assert_eq!(collect(&mut m), vec![(0, 200, 0, 0, 0)]);
assert_eq!(collect(&mut m), vec![(0, 200, 0)]);
// Even with a stale clock, a poll where the game drove the rumble plane refreshes
// activity, so the held rumble is NOT cut. Backends report that as
@@ -947,7 +872,7 @@ mod tests {
// the manager also honors the bare `rumble_drove: Some(true)` shape defensively.
m.last_active[0] = Instant::now() - (RUMBLE_IDLE_TIMEOUT + Duration::from_millis(50));
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: Some((200, 0, 0, 0)),
rumble: Some((200, 0)),
hidout: Vec::new(),
rumble_drove: Some(true),
resync: false,
@@ -981,7 +906,7 @@ mod tests {
}];
let out = RefCell::new(0u32);
m.pump(
|_, _, _, _, _| {},
|_, _, _| {},
|_| {
*out.borrow_mut() += 1;
},
@@ -1051,7 +976,7 @@ mod tests {
let rumbles = RefCell::new(Vec::new());
let hidouts = RefCell::new(0u32);
m.pump(
|i, lo, hi, lt, rt| rumbles.borrow_mut().push((i, lo, hi, lt, rt)),
|i, lo, hi| rumbles.borrow_mut().push((i, lo, hi)),
|_| *hidouts.borrow_mut() += 1,
);
(rumbles.into_inner(), hidouts.into_inner())
@@ -1059,12 +984,12 @@ mod tests {
// Latch a rumble + an LED.
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: Some((100, 0, 0, 0)),
rumble: Some((100, 0)),
hidout: vec![led(10)],
rumble_drove: Some(true),
resync: false,
}];
assert_eq!(collect(&mut m), (vec![(0, 100, 0, 0, 0)], 1));
assert_eq!(collect(&mut m), (vec![(0, 100, 0)], 1));
// Overflow poll: no reports survived, resync flagged → forced stop, exactly once.
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
@@ -1073,22 +998,22 @@ mod tests {
rumble_drove: Some(false),
resync: true,
}];
assert_eq!(collect(&mut m), (vec![(0, 0, 0, 0, 0)], 0));
assert_eq!(collect(&mut m), (vec![(0, 0, 0)], 0));
// The game re-asserts the SAME rumble + LED state: both must re-forward (the rumble
// because the forced stop reset `last_rumble`, the LED because the dedup was re-armed).
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
rumble: Some((100, 0, 0, 0)),
rumble: Some((100, 0)),
hidout: vec![led(10)],
rumble_drove: Some(true),
resync: false,
}];
assert_eq!(collect(&mut m), (vec![(0, 100, 0, 0, 0)], 1));
assert_eq!(collect(&mut m), (vec![(0, 100, 0)], 1));
// A resync with nothing latched forwards no spurious stop.
*m.backend.feedback.borrow_mut() = vec![
PadFeedback {
rumble: Some((0, 0, 0, 0)),
rumble: Some((0, 0)),
hidout: Vec::new(),
rumble_drove: Some(true),
resync: false,
@@ -1100,7 +1025,7 @@ mod tests {
resync: true,
},
];
assert_eq!(collect(&mut m), (vec![(0, 0, 0, 0, 0)], 0)); // the explicit stop
assert_eq!(collect(&mut m), (vec![(0, 0, 0)], 0)); // the explicit stop
assert_eq!(collect(&mut m), (vec![], 0)); // resync at zero — silent
}
}
@@ -85,8 +85,7 @@ impl PadProto for DsEdgeWinProto {
fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback {
let fb = pad.service(idx);
PadFeedback {
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
rumble: fb.rumble,
hidout: fb.hidout,
// Rumble-plane liveness, not any-report liveness — see the plain DualSense backend.
rumble_drove: Some(fb.rumble.is_some()),
@@ -686,8 +686,7 @@ impl PadProto for DsWinProto {
// feed the abandoned-rumble force-off's activity clock (the historical unbounded
// stuck-ON path, now doubly closed by the lossless report ring).
rumble_drove: Some(fb.rumble.is_some()),
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
rumble: fb.rumble,
hidout: fb.hidout,
resync: fb.resync,
}
@@ -996,26 +995,14 @@ mod drain_tests {
"/../../packaging/windows/drivers/pf-gamepad/pf_gamepad.inx"
);
let inf = std::fs::read_to_string(inx).expect("read pf_gamepad.inx");
// The [Models] lines: `%DeviceDesc…%=<InstallSection>, <hwid>[, <hwid>…]`.
//
// ⚠️ Match the install section by PREFIX, not by the exact string `pfGamepad,`. The Xbox
// line installs `pfGamepadXbox` — a section of its own, because that identity additionally
// attaches the `xinputhid` bus filter and the four PlayStation/Deck identities must not get
// it. An exact match silently stopped seeing the Xbox ids the moment that split happened,
// which is precisely the "this test went vacuous" failure the assert below guards against,
// except it failed loudly instead. Keep this tolerant of further per-identity sections.
// The [Models] lines: `%DeviceDesc…%=pfGamepad, <hwid>[, <hwid>…]`.
let declared: Vec<String> = inf
.lines()
.map(str::trim)
.filter(|l| !l.starts_with(';'))
.filter_map(|l| l.split_once('='))
.filter(|(_, rhs)| rhs.trim_start().starts_with("pfGamepad"))
.flat_map(|(_, rhs)| {
// `pfGamepad[Suffix], <hwid>[, <hwid>…]` — drop the section name, keep the ids.
// `AddReg=pfGamepadXbox_HW_AddReg` reaches here too and contributes nothing,
// because it has no comma.
rhs.split(',')
.skip(1)
.filter_map(|l| l.split_once("=pfGamepad,"))
.flat_map(|(_, ids)| {
ids.split(',')
.map(|id| id.trim().to_ascii_lowercase())
.collect::<Vec<_>>()
})
@@ -1031,15 +1018,7 @@ mod drain_tests {
WinDsIdentity::dualsense_edge().hwid,
super::super::dualshock4_windows::DS4_HWID,
super::super::steam_deck_windows::DECK_HWID,
]
.into_iter()
// Every Xbox identity, not just the first — a new one added to the table without its INF
// model line is exactly the "pad exists, never starts, never answers a proof" failure.
.chain(
super::super::xbox_windows::XBOX_IDENTITIES
.iter()
.map(|i| i.hwid),
) {
] {
let want = hwid.to_ascii_lowercase();
let rooted = format!("root\\{want}");
assert!(
@@ -1053,81 +1032,6 @@ mod drain_tests {
}
}
/// EVERY Xbox identity must install its OWN section, and the PlayStation/Deck identities must
/// not install that one.
///
/// `pfGamepadXbox` attaches Microsoft's `xinputhid` as an upper filter and sets
/// `DevicePropertyFlags=1` (`BusDevice`), which is what makes Windows promote our Xbox pad —
/// it mints the `IG_00` token, registers an XUSB interface, and lets classic XInput and rumble
/// through. Applied to a DualSense, DualShock 4, Edge or Steam Deck it would hand a
/// PlayStation pad to Microsoft's **Xbox** translator, which claims the HID collection
/// exclusively and would take a working pad away from Steam and SDL.
///
/// Merging the two sections back together is a one-line edit that looks like tidying and is
/// not, so assert the split rather than trusting a comment to survive. Both directions matter,
/// and so does the count: a new Xbox identity whose model line was pasted from a PlayStation
/// one installs `pfGamepad`, enumerates perfectly, and is simply never promoted — a silent
/// half-failure that reads on glass as "XInput doesn't see it", the original field symptom.
#[test]
fn only_the_xbox_identity_installs_the_xinputhid_section() {
let inx = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../packaging/windows/drivers/pf-gamepad/pf_gamepad.inx"
);
let inf = std::fs::read_to_string(inx).expect("read pf_gamepad.inx");
let xbox: Vec<String> = super::super::xbox_windows::XBOX_IDENTITIES
.iter()
.map(|i| i.hwid.to_ascii_lowercase())
.collect();
let mut seen: Vec<&str> = Vec::new();
for line in inf.lines().map(str::trim).filter(|l| !l.starts_with(';')) {
let Some((_, rhs)) = line.split_once('=') else {
continue;
};
let rhs = rhs.trim_start();
let Some((section, ids)) = rhs.split_once(',') else {
continue;
};
if !section.starts_with("pfGamepad") {
continue;
}
let ids: Vec<String> = ids
.split(',')
.map(|i| i.trim().to_ascii_lowercase())
.collect();
// `contains`, not `==`: the model lines carry both the bare id and its `root\` twin.
let matched: Vec<&str> = xbox
.iter()
.filter(|x| ids.iter().any(|i| i.contains(x.as_str())))
.map(String::as_str)
.collect();
if matched.is_empty() {
assert_eq!(
section, "pfGamepad",
"a non-Xbox model line ({ids:?}) installs {section:?}; if that section carries \
the xinputhid filter, this pad is about to be handed to Microsoft's Xbox \
translator"
);
} else {
seen.extend(matched);
assert_ne!(
section, "pfGamepad",
"an Xbox model line ({ids:?}) installs the SHARED section, so either the \
xinputhid filter would be attached to every PlayStation and Deck pad too, or \
this Xbox pad silently never gets promoted"
);
}
}
for want in &xbox {
assert!(
seen.contains(&want.as_str()),
"no [Models] line mentions {want:?} — either the identity has no INF line at all, \
or the parse went vacuous; fix that rather than deleting the assert"
);
}
}
/// The driver reads its HID identity back off the same hardware id — that mapping is what
/// decides which report descriptor and which VID/PID a pad enumerates with, and it is settled
/// at `EvtDeviceAdd`, before the sealed channel can possibly say anything (its delivery goes
@@ -1167,7 +1071,7 @@ mod drain_tests {
.collect();
assert_eq!(
entries.len(),
7,
4,
"parsed {entries:?} out of the driver's table — the shape changed and this test went \
vacuous; fix the parse rather than deleting the assert"
);
@@ -1194,16 +1098,7 @@ mod drain_tests {
super::super::steam_deck_windows::DECK_HWID,
pf_driver_proto::gamepad::DEVTYPE_STEAMDECK,
),
]
.into_iter()
// All three Xbox identities: they share a report descriptor, so a hwid→devtype slip does
// NOT show up as a mangled report the way the Deck's did — it shows up as the wrong PID and
// the wrong product string, i.e. an Elite that Steam maps as a Series X|S pad.
.chain(
super::super::xbox_windows::XBOX_IDENTITIES
.iter()
.map(|i| (i.hwid, i.devtype)),
) {
] {
let want = hwid.to_ascii_lowercase();
let got = entries.iter().find(|(id, _)| *id == want);
assert_eq!(
@@ -232,8 +232,7 @@ impl PadProto for Ds4WinProto {
fn service(&self, pad: &mut Ds4WinPad, idx: u8) -> PadFeedback {
let fb = pad.service();
PadFeedback {
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
rumble: fb.rumble,
hidout: fb
.led
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
@@ -53,8 +53,7 @@
use super::channel_proof;
/// Re-exported so a pad backend needs only one `use` to wire up its channel.
pub(super) use super::channel_proof::ProofTransport;
use crate::pad_slots::PadCreateFault;
use anyhow::{anyhow, Context, Result};
use anyhow::{anyhow, bail, Context, Result};
use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION};
use std::ffi::c_void;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
@@ -68,17 +67,16 @@ use windows::Win32::Devices::DeviceAndDriverInstallation::{
};
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
use windows::Win32::Foundation::{
CloseHandle, DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS,
ERROR_ACCESS_DENIED, ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WAIT_OBJECT_0,
WIN32_ERROR,
DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS,
ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WAIT_OBJECT_0, WIN32_ERROR,
};
use windows::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
};
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
use windows::Win32::System::Memory::{
CreateFileMappingW, MapViewOfFile, OpenFileMappingW, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
FILE_MAP_READ, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
};
use windows::Win32::System::Threading::{
GetCurrentProcess, OpenProcess, SetEvent, WaitForSingleObject, PROCESS_DUP_HANDLE,
@@ -173,16 +171,6 @@ impl Shm {
/// don't control — we close and retry briefly (our own driver holds the name for microseconds per
/// poll tick), then fail loudly rather than run the handshake through an attacker-owned (or
/// another host instance's) mailbox.
///
/// ⚠️ That squat check only ever sees the collisions we are ALLOWED to see. `CreateFileMappingW`
/// opens a pre-existing object with full access, so a caller the incumbent's DACL excludes is
/// refused with `ERROR_ACCESS_DENIED` and never reaches the `ERROR_ALREADY_EXISTS` branch at
/// all — and that is the collision the field actually produces, because this SDDL grants SYSTEM
/// and LocalService only, while the host service runs as LocalSystem and a hand-run devtest
/// runs as an elevated Administrator. So the "another punktfunk-host instance is serving this
/// pad index" diagnosis below was unreachable for the one pairing that happens: on `.173`
/// (2026-08-09) it surfaced as a bare `Zugriff verweigert (0x80070005)` under a line telling the
/// operator to reinstall the drivers. [`classify_named_create_failure`] is what restores it.
pub(super) fn create_named(name: &HSTRING, size: usize) -> Result<Shm> {
// Build the descriptor ONCE and reuse it across the squat-retry loop — it (and the OS
// allocation it owns) lives to the end of this fn, so it outlives every create below.
@@ -195,10 +183,8 @@ impl Shm {
}
// SAFETY: clearing the thread error slot so ERROR_ALREADY_EXISTS below is unambiguous.
unsafe { SetLastError(WIN32_ERROR(0)) };
let shm = match Self::create_inner(&sa.sa, PCWSTR(name.as_ptr()), size) {
Ok(shm) => shm,
Err(e) => return Err(classify_named_create_failure(name, e)),
};
let shm = Self::create_inner(&sa.sa, PCWSTR(name.as_ptr()), size)
.with_context(|| format!("create gamepad bootstrap mailbox {name}"))?;
// SAFETY: read immediately after the create; windows-rs only touches the error slot on
// failure, so a success here preserves CreateFileMappingW's ALREADY_EXISTS signal.
if unsafe { GetLastError() } != ERROR_ALREADY_EXISTS {
@@ -206,16 +192,11 @@ impl Shm {
}
// `shm` drops here → unmap + close our handle to the foreign object, then retry.
}
// Reached only when we COULD open the incumbent (same account — two hosts both as SYSTEM,
// or a LocalService squatter). The cross-account case exits through
// `classify_named_create_failure` above; both carry the same fault, because to everything
// downstream they are the same event: this index is taken.
Err(anyhow!(
bail!(
"bootstrap mailbox {name} already exists and stayed alive across retries — another \
punktfunk-host instance is serving this pad index, or a local service is squatting the \
name (gamepad DoS attempt?)"
)
.context(PadCreateFault::IndexOwnedElsewhere))
);
}
fn create_inner(sa: &SECURITY_ATTRIBUTES, name: PCWSTR, size: usize) -> Result<Shm> {
@@ -269,76 +250,6 @@ impl Drop for Shm {
}
}
/// Turn a failed NAMED-section create into an error that names the cause, because
/// `CreateFileMappingW` collapses two OPPOSITE situations into one `ERROR_ACCESS_DENIED`
/// (`0x80070005`, and on a German box the entirely unsearchable "Zugriff verweigert" the field
/// report carried):
///
/// * **the name is TAKEN, by someone whose object we may not open.** Creating over an existing name
/// is really an open, and an open is access-checked against the incumbent's DACL. The mailbox
/// SDDL grants SYSTEM + LocalService only, so the exact pairing that occurs on a dev box — the
/// LocalSystem host service holding pad 0 for a live session while an operator runs
/// `punktfunk-host.exe dualsense-windows-test` from an elevated Administrator console — is
/// refused here rather than reported as the squat it is.
/// * **the name is FREE and we may not create it.** `Global\` names need `SeCreateGlobalPrivilege`,
/// which SYSTEM and services hold and an ordinary (even elevated) user token does not.
///
/// `OpenFileMappingW` separates them, because the object-manager lookup happens BEFORE the access
/// check: an absent name is `ERROR_FILE_NOT_FOUND`, a present one we are not in the DACL of is
/// `ERROR_ACCESS_DENIED`. Everything else keeps the original wording.
///
/// The contended case additionally carries a [`PadCreateFault`], which is what stops the pad
/// manager's failure line from telling the operator to reinstall a driver that is working
/// perfectly (see [`crate::pad_slots::PadCreateFault`]).
fn classify_named_create_failure(name: &HSTRING, e: anyhow::Error) -> anyhow::Error {
let denied = e
.downcast_ref::<windows::core::Error>()
.is_some_and(|w| w.code() == HRESULT::from_win32(ERROR_ACCESS_DENIED.0));
if !denied {
return e.context(format!("create gamepad bootstrap mailbox {name}"));
}
if named_section_exists(name) {
return e
.context(PadCreateFault::IndexOwnedElsewhere)
.context(format!(
"bootstrap mailbox {name} exists and belongs to a process this one may not open — a \
live session's pad, held by the LocalSystem host service (its mailboxes grant SYSTEM \
+ LocalService only, so an Administrator console sees ACCESS_DENIED, not \
ALREADY_EXISTS). Nothing is wrong with the drivers"
));
}
e.context(format!(
"create gamepad bootstrap mailbox {name}: access denied although the name is FREE — this \
process may not create Global\\ objects at all (that needs SeCreateGlobalPrivilege, which \
SYSTEM and services hold and a user token does not)"
))
}
/// Whether a section with this name exists right now, as seen from THIS process — the
/// disambiguation [`classify_named_create_failure`] runs on. `true` also when the object is there
/// but closed to us, which is the case that matters: ACCESS_DENIED from an OPEN means the name
/// resolved and only the access check failed.
///
/// Deliberately not a security decision — a hostile squatter can make this say either thing. It
/// only ever chooses which sentence to print.
fn named_section_exists(name: &HSTRING) -> bool {
// SAFETY: `name` is a live NUL-terminated UTF-16 string for the duration of the call. Ask for
// the least access there is (`FILE_MAP_READ`): the handle is closed immediately and never
// mapped — we want the lookup's verdict, not the object.
let opened = unsafe { OpenFileMappingW(FILE_MAP_READ.0, false, PCWSTR(name.as_ptr())) };
match opened {
Ok(h) => {
// SAFETY: `h` is the handle just opened here and referenced nowhere else.
unsafe {
let _ = CloseHandle(h);
}
true
}
// ERROR_FILE_NOT_FOUND (and anything else) reads as absent; ACCESS_DENIED is presence.
Err(e) => e.code() == HRESULT::from_win32(ERROR_ACCESS_DENIED.0),
}
}
// ── The sealed-channel bootstrap broker ─────────────────────────────────────────────────────────
/// Global delivery sequence for [`PadBootstrap::handle_seq`] — host-wide monotonic and never 0, so two
@@ -296,13 +296,6 @@ impl GamepadManager {
}
}
/// How many virtual pads this manager has actually BUILT — the bring-up harness's
/// "did the create happen?" check; see [`crate::uhid_manager::UhidManager::live_pads`] for why
/// only a harness should ask.
pub fn live_pads(&self) -> usize {
self.slots.live()
}
fn ensure(&mut self, idx: usize) {
if self.slots.ensure(idx, XusbWinPad::open) {
tracing::info!(
@@ -363,12 +356,7 @@ impl GamepadManager {
/// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries
/// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small`
/// (high-frequency) → `high` — matching the other backends.
///
/// The two trigger levels `send` also takes are always zero here and always will be: the XUSB
/// `SET_STATE` packet this backend parses carries `rumble_large`/`rumble_small` and nothing
/// else, mirroring `XINPUT_VIBRATION`'s two members. Impulse-trigger rumble is only reachable
/// through the HID-visible Xbox identity (WGI / GameInput), never through the XUSB companion.
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16, u16, u16)) {
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
// Finish any unplug whose removal frame only armed the grace — the producer sends that
// frame once, so without this the XUSB devnode would outlive the controller.
let swept = self.slots.reap();
@@ -381,7 +369,7 @@ impl GamepadManager {
self.last_active[i] = Instant::now();
if self.last_rumble[i] != (large, small) {
self.last_rumble[i] = (large, small);
send(i as u16, large as u16 * 257, small as u16 * 257, 0, 0);
send(i as u16, large as u16 * 257, small as u16 * 257);
}
} else if self.last_rumble[i] != (0, 0)
&& crate::uhid_manager::rumble_idle_timeout()
@@ -398,7 +386,7 @@ impl GamepadManager {
"rumble: stale residual (game stopped driving the pad) — forcing off"
);
self.last_rumble[i] = (0, 0);
send(i as u16, 0, 0, 0, 0);
send(i as u16, 0, 0);
}
}
}
@@ -231,8 +231,7 @@ impl PadProto for DeckWinProto {
// presence is the rumble-plane activity signal, even at an unchanged level.
let (rumble, resync) = pad.service();
PadFeedback {
// No trigger motors on this protocol — see `PadFeedback::rumble`.
rumble: rumble.map(|(low, high)| (low, high, 0, 0)),
rumble,
hidout: Vec::new(),
rumble_drove: Some(rumble.is_some()),
resync,
@@ -1,463 +0,0 @@
//! Virtual Xbox pads on Windows via the UMDF HID minidriver — Xbox Wireless (device-type 4),
//! Xbox One S (5) and Xbox Elite Series 2 (6), the HID-visible alternative to
//! [`super::gamepad_windows`]'s XUSB companion.
//!
//! **Why this exists.** `pf-xusb` registers only `GUID_DEVINTERFACE_XUSB` and exposes no HID
//! collection, so Steam's hidapi enumeration, DirectInput, `joy.cpl` and WGI/GameInput cannot see
//! the pad at all — only classic `XInputGetState` via xinput1_4's interface walk ever does. A field
//! report (2026-08-09) spent two weeks on a dead controller for exactly that reason; switching the
//! client to DualSense — a real HID pad through this very driver — fixed it in seconds. This
//! backend gives the Xbox pad the same footing, reusing the driver, sealed channel, INF, signing
//! and install path the PlayStation pads already ship on.
//!
//! Transport is identical to the PS/Deck pads: a `SwDeviceCreate` devnode plus the sealed
//! shared-memory channel, with the identity's `device_type` stamped before the magic so the driver
//! resolves it before hidclass asks for descriptors. The codec is
//! [`super::xbox_proto`]; the report it writes mirrors the driver's `XBOX_RDESC` byte for byte —
//! **one descriptor, all three identities** (see `WinXboxIdentity` below).
//!
//! ⚠️ **Every synthesized USB identity here is a BLUETOOTH Xbox pad on purpose.** The wired ids the
//! rest of the tree uses (`045E:028E` X-Box 360, `045E:02EA` Xbox One S USB) are vendor-class
//! XUSB/GIP devices that expose no HID interface on real hardware — a HID child claiming one is a
//! device that has never existed, and Windows' inbox promotion would have nothing to match. The
//! Bluetooth ids (`0B13` / `02FD` / `0B22`) are the Xbox pads that genuinely ARE HID.
//!
//! ⚠️ **No rich plane.** An Xbox pad has no touchpad, no lightbar, no adaptive triggers and no
//! IMU in its HID contract, so `apply_rich` / `clear_rich` / `neutralize_gyro` are deliberately
//! no-ops — same shape as the Linux xpad backend. Motion sent toward this backend is decoded and
//! dropped, which is what `GamepadPref::motion_reaches` already tells clients.
use super::dualsense_windows::{
create_swdevice, publish_input, OutputDrain, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO,
OFF_INPUT, OFF_OUT_RING_VER, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
};
use super::gamepad_raii::PadChannel;
use super::xbox_proto::{neutral_xbox_report, serialize_xbox_state, XboxState, XBOX_REPORT_LEN};
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::Result;
use punktfunk_core::quic::RichInput;
use std::time::Duration;
/// One of the Xbox identities this backend can present. Mirrors `WinDsIdentity`
/// (`super::dualsense_windows`) for the PlayStation family: the whole transport (section layout,
/// report codec, output parse, INF install section) is shared, and only the PnP identity plus the
/// `device_type` stamp differ.
///
/// ⭐ **All three share ONE report descriptor in the driver** — `XBOX_RDESC`. In HID terms they are
/// the same pad: same stick pairs, same trigger pair, same hat, same 15 buttons, same rumble output
/// report. A descriptor is the report SHAPE; the identity is what SDL/Steam/Windows key their stock
/// mappings off, and that travels in the VID/PID below. See the `XBOX_RDESC` provenance block in
/// `packaging/windows/drivers/pf-gamepad/src/lib.rs` for why inventing two more hand-written
/// descriptors would be a net loss.
pub(super) struct WinXboxIdentity {
/// `device_type` stamped into the section — the driver picks its VID/PID and product string
/// off it, before hidclass asks anything.
pub devtype: u8,
/// PnP instance-id prefix — distinct namespaces per identity, so two Xbox models never reuse
/// the same devnode shell.
pub instance_prefix: &'static str,
/// The INF-matched hardware id. Must be one `pf_gamepad.inx` declares, on a model line that
/// installs `pfGamepadXbox` — a package rename must never touch it
/// (`dualsense_windows::tests::hwid_matches_inf` and
/// `only_the_xbox_identity_installs_the_xinputhid_section` enforce both halves).
pub hwid: &'static str,
/// The USB VID&PID token synthesized onto the devnode so hidclass derives the real-pad HID
/// child ids (`HID\VID_045E&PID_xxxx`) — the identity SDL/RawInput/WGI read, and the one
/// Windows' own Xbox INFs match when they decide whether to promote a HID gamepad.
pub usb_vid_pid: &'static str,
/// Device Manager description.
pub description: &'static str,
}
impl WinXboxIdentity {
/// Xbox Wireless Controller (Series X|S) over Bluetooth, `045E:0B13` — the default.
///
/// ⭐ Its PID is on Microsoft's `xinputhid.inf` allow-list twice (measured on `.173`,
/// 2026-08-09). That is not what promotes OUR pad — a software devnode matches no allow-list
/// entry, so `pfGamepadXbox`'s `AddReg` writes the two registry values those sections would
/// have written — but it is why this identity, not one of the two below, stays the default.
pub(super) const fn wireless() -> WinXboxIdentity {
WinXboxIdentity {
devtype: pf_driver_proto::gamepad::DEVTYPE_XBOX,
instance_prefix: "pf_xbox",
hwid: "pf_xboxwireless",
usb_vid_pid: "VID_045E&PID_0B13",
description: "Punktfunk Virtual Xbox Wireless Controller",
}
}
/// Xbox One S controller over Bluetooth, `045E:02FD`.
///
/// ⚠️ `02FD` appears in `xinputhid.inf` only as a `BTHENUM` (classic-BT bus) id — it has **no**
/// stage-2 `HID\…&IG_00` model line, unlike `0B13`. Promotion here rides entirely on our own
/// `AddReg`, so it should behave identically; but if a servicing update ever makes promotion
/// depend on Microsoft's list again, this is the identity that loses it first. UNVERIFIED on
/// glass.
pub(super) const fn one_s() -> WinXboxIdentity {
WinXboxIdentity {
devtype: pf_driver_proto::gamepad::DEVTYPE_XBOX_ONE_S,
instance_prefix: "pf_xbox_ones",
hwid: "pf_xboxones",
usb_vid_pid: "VID_045E&PID_02FD",
description: "Punktfunk Virtual Xbox One S Controller",
}
}
/// Xbox Elite Wireless Controller Series 2, `045E:0B22` — the pad
/// `tools/hid-descriptor-dump` captured on `.173`, so the one identity here whose real hardware
/// has been measured directly.
///
/// ⚠️ **No paddles yet.** `BTN_PADDLE1..4` still fold/drop for this identity exactly as for the
/// other two; the Elite is merely the first Xbox pad that *could* carry them natively. Adding
/// them is blocked on a measurement, not on effort — once `xinputhid` promotes the pad it
/// claims the HID collection exclusively, so extra buttons declared in the descriptor may be
/// invisible to every consumer anyway (handoff §3.6). Measure before building.
pub(super) const fn elite() -> WinXboxIdentity {
WinXboxIdentity {
devtype: pf_driver_proto::gamepad::DEVTYPE_XBOX_ELITE,
instance_prefix: "pf_xbox_elite",
hwid: "pf_xboxelite",
usb_vid_pid: "VID_045E&PID_0B22",
description: "Punktfunk Virtual Xbox Elite Wireless Controller Series 2",
}
}
}
/// Every Xbox identity this backend can build, in wire order (`device_type` 4, 5, 6).
///
/// A table rather than three loose constructors because the INF tests sweep it: each entry's
/// `hwid` must appear in `pf_gamepad.inx` **on a `pfGamepadXbox` model line**, and every non-Xbox
/// model line must NOT be on that section. A new identity added here without its INF line fails
/// those tests instead of failing on a user's box.
///
/// `static`, not `const`, on purpose: [`XboxWinProto`] holds a `&'static WinXboxIdentity`, and a
/// `const` is inlined at each use site — `&CONST[i]` would depend on rvalue static promotion to
/// come out `'static` at all.
pub(super) static XBOX_IDENTITIES: [WinXboxIdentity; 3] = [
WinXboxIdentity::wireless(),
WinXboxIdentity::one_s(),
WinXboxIdentity::elite(),
];
/// A single virtual Xbox pad: the `SwDeviceCreate`'d `pf_xbox_<index>` devnode plus the sealed
/// shared-memory channel. Dropping it removes the devnode and closes both sections.
pub struct XboxWinPad {
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
_sw: Option<super::gamepad_raii::SwDevice>,
/// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
channel: PadChannel,
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
attach: super::gamepad_raii::DriverAttach,
/// This pad's v2.3 input-seqlock generation — see `publish_input`.
input_gen: u32,
/// Output-plane cursor: ring drain (v2.1+ driver) or legacy latest-slot seq (old driver).
drain: OutputDrain,
}
impl XboxWinPad {
/// Create the sealed channel, stamp `device_type` FIRST + the pad index + the neutral report +
/// the magic LAST, then spawn the devnode under `id`'s Bluetooth Xbox identity.
fn open(index: u8, id: &WinXboxIdentity) -> Result<XboxWinPad> {
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
let base = channel.data_base();
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. The
// device_type MUST land before the magic — the driver reads it the moment it attaches, and
// a late stamp enumerates the pad with the default DualSense identity (the Deck's bug).
unsafe {
*base.add(OFF_DEVTYPE) = id.devtype;
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
// Ring capability `2` = "this host drains the v2.2 long ring" (see the DualSense open).
std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 2);
std::ptr::write_unaligned(
base.add(OFF_INPUT) as *mut [u8; XBOX_REPORT_LEN],
neutral_xbox_report(),
);
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
}
let inst = format!("{}_{index}", id.instance_prefix);
let (hsw, instance_id) = create_swdevice(&SwDeviceProfile {
instance: &inst,
// Per-FAMILY tag, like "PFDS" for the whole PlayStation family: the three Xbox
// identities share it because only one of them can ever hold a given pad index (the
// router keeps a live device in its owning manager), so their containers never collide.
container_tag: 0x5046_5842, // "PFXB"
container_index: index,
hwid: id.hwid,
usb_vid_pid: id.usb_vid_pid,
// A Bluetooth pad is not a USB composite device, so there is no interface number to
// synthesize — unlike the Deck, whose Steam promotion gate needs `&MI_02`.
usb_mi: None,
description: id.description,
})?; // Propagate — swallowing latched the slot to a pad with no devnode (see the DS4 twin).
channel.bind_devnode(
index as u32,
instance_id.clone(),
super::gamepad_raii::ProofTransport::HidFeatureReport,
);
let _sw = Some(super::gamepad_raii::SwDevice::new(hsw));
// Bounded eager delivery — the driver must read the `device_type` stamp before hidclass
// asks it for descriptors, or the pad enumerates as a DualSense.
channel.deliver_eager(Duration::from_millis(1500));
Ok(XboxWinPad {
_sw,
channel,
attach: super::gamepad_raii::DriverAttach::new(
id.hwid,
"pf_gamepad.inf", // one driver package serves every identity
"C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pf_gamepad-driver.log",
boot_name,
instance_id,
),
input_gen: 0,
drain: OutputDrain::new(),
})
}
/// Serialize `st` and publish it to the section's input slot under the v2.3 seqlock, so a
/// driver read can never land mid-copy.
fn write_state(&mut self, st: &XboxState) {
let r = serialize_xbox_state(st);
// SAFETY: `data_base()` points at a live SHM_SIZE-byte section and `r` is the codec's
// fixed-size report.
unsafe { publish_input(self.channel.data_base(), &mut self.input_gen, &r) };
}
/// Poll the section's output slot for a game's rumble, tick the sealed-channel delivery and
/// feed the driver-attach health watcher.
fn service(&mut self) -> (Option<(u16, u16, u16, u16)>, bool) {
self.channel.pump();
// SAFETY: base points at SHM_SIZE bytes.
let proto = unsafe {
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
};
self.attach.observe(proto);
let mut rumble = None;
let base = self.channel.data_base();
let resync = self.drain.drain(base, |bytes| {
if let Some(r) = parse_xbox_output(bytes) {
rumble = Some(r); // oldest → newest: the last rumble-carrying report wins
}
});
(rumble, resync)
}
}
/// Parse an Xbox output report into `(low, high, left_trigger, right_trigger)` motor levels on the
/// wire's 0..65535 scale.
///
/// The Bluetooth Xbox rumble report is id `0x03`: `[id, enable, left_trigger, right_trigger,
/// left, right, duration, delay, loop]`, with magnitudes on a **0..100** scale (not 0..255 — a
/// detail that silently costs 60 % of the rumble range if you assume otherwise). The `enable`
/// mask picks which motors the values apply to; bit 2 is the left (low-frequency) motor and bit 3
/// the right (high-frequency) one, matching how the wire's `low`/`high` pair is used elsewhere.
///
/// Bytes 2/3 are the two impulse-trigger motors, which ride the 0xCA plane's v3 tail
/// (design/trigger-rumble-plane.md). This pad is the only backend in the tree that can ever source
/// them: XUSB's `SET_STATE` carries `rumble_large`/`rumble_small` and evdev's `FF_RUMBLE` carries
/// strong/weak, so neither packet has a field to lose. They are scaled by the same 0..100 closure
/// as the handles rather than a copy of it — assuming 0..255 here would read a full-scale `100` as
/// ~39 %, which on a real pad reads as "trigger rumble works but is weirdly weak", the hardest
/// class of bug to attribute.
///
/// ⚠️ Never seen a real report — this shape is from the documented protocol, not a capture.
///
/// ⚠️ **The two TRIGGER `enable` bits are conjecture, not measurement.** Bits 2/3 = left/right
/// handle are known; bit 0 = left trigger and bit 1 = right trigger are inferred from the report's
/// field order (triggers first, handles second) and from nothing else. A live capture (design WP0)
/// settles it. Getting it wrong yields "the triggers buzz when the game asked for the handles",
/// so nothing downstream may treat this assignment as established — and the tests below are
/// deliberately written with mask vectors that hold whichever bits turn out to be right.
fn parse_xbox_output(bytes: &[u8]) -> Option<(u16, u16, u16, u16)> {
// The driver republishes output reports report-id-prefixed, like the PS backends.
if bytes.len() < 6 || bytes[0] != 0x03 {
return None;
}
let enable = bytes[1];
let scale = |v: u8| -> u16 { (v.min(100) as u32 * 65535 / 100) as u16 };
let gated = |bit: u8, v: u8| if enable & bit != 0 { scale(v) } else { 0 };
Some((
gated(0x04, bytes[4]),
gated(0x08, bytes[5]),
// UNVERIFIED bit assignment — see the second ⚠️ above before trusting either of these.
gated(0x01, bytes[2]),
gated(0x02, bytes[3]),
))
}
/// The Windows-Xbox half of the shared stateful manager (see [`PadProto`]). Lifecycle (slot table,
/// unplug sweep, heartbeat, rumble dedup) lives in [`UhidManager`], exactly as for the PS pads.
///
/// The identity is a field rather than three separate proto types because nothing else about the
/// backend varies: same codec, same output parse, same rumble plane. `Default` is the Xbox Wireless
/// Controller, so `XboxWindowsManager::new()` keeps its previous meaning exactly.
pub struct XboxWinProto {
identity: &'static WinXboxIdentity,
}
impl Default for XboxWinProto {
fn default() -> XboxWinProto {
XboxWinProto {
identity: &XBOX_IDENTITIES[0],
}
}
}
impl XboxWinProto {
/// The Xbox One S identity (`045E:02FD`) — `UhidManager::with_backend(XboxWinProto::one_s())`.
pub fn one_s() -> XboxWinProto {
XboxWinProto {
identity: &XBOX_IDENTITIES[1],
}
}
/// The Xbox Elite Series 2 identity (`045E:0B22`).
pub fn elite() -> XboxWinProto {
XboxWinProto {
identity: &XBOX_IDENTITIES[2],
}
}
}
impl PadProto for XboxWinProto {
type Pad = XboxWinPad;
type State = XboxState;
const LABEL: &'static str = "Xbox Wireless/Windows";
const DEVICE: &'static str = "Xbox Wireless Controller";
const CREATE_HINT: &'static str =
" (install/repair: punktfunk-host.exe driver install --gamepad)";
fn open(&mut self, idx: u8) -> Result<XboxWinPad> {
let p = XboxWinPad::open(idx, self.identity)?;
tracing::info!(
index = idx,
identity = self.identity.usb_vid_pid,
description = self.identity.description,
"virtual Xbox pad created (Windows UMDF HID)"
);
Ok(p)
}
fn neutral(&self) -> XboxState {
XboxState::default()
}
/// Every control this pad has arrives in the frame, so a frame fully replaces the state —
/// there are no rich-plane fields to preserve (contrast the Deck's trackpads/motion).
fn merge_frame(&self, _prev: &XboxState, f: &punktfunk_core::input::GamepadFrame) -> XboxState {
XboxState::from_gamepad(
f.buttons,
f.left_trigger,
f.right_trigger,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
)
}
/// No rich plane on an Xbox pad — see the module note.
fn apply_rich(&self, _st: &mut XboxState, _rich: RichInput) {}
/// No motion plane, so there is never stale gyro to neutralize.
fn neutralize_gyro(&self, _st: &mut XboxState) -> bool {
false
}
fn clear_rich(&self, _st: &mut XboxState) {}
fn write_state(&self, pad: &mut XboxWinPad, st: &XboxState) {
pad.write_state(st);
}
/// Motor rumble on the universal 0xCA plane. No rich host→client feedback (no lightbar or
/// adaptive triggers), so `hidout` stays empty — parity with the Linux xpad backend.
fn service(&self, pad: &mut XboxWinPad, _idx: u8) -> PadFeedback {
let (rumble, resync) = pad.service();
PadFeedback {
rumble,
hidout: Vec::new(),
rumble_drove: Some(rumble.is_some()),
resync,
}
}
}
/// All virtual Xbox pads of a Windows session, with the same method surface (via the shared
/// [`UhidManager`]) as the other Windows pad managers.
pub type XboxWindowsManager = UhidManager<XboxWinProto>;
#[cfg(test)]
mod tests {
use super::*;
// Every `enable` vector in this module is chosen so its assertion holds whichever bits the
// TRIGGER actuators turn out to use — the assignment is conjecture (see the ⚠️ on
// `parse_xbox_output`) and a test asserting it would pin a guess as if it were the contract.
// The safe masks: `0xFF` enables everything that exists, `0x00` enables nothing, and
// `0x0C` / `0xF3` split the two MEASURED handle bits from every other bit. No vector below
// names a trigger enable bit.
/// Both handle motors at full scale, with the triggers idle.
#[test]
fn rumble_scales_off_the_zero_to_hundred_protocol_range() {
let full = [0x03, 0x0F, 0, 0, 100, 100, 0, 0, 1];
assert_eq!(parse_xbox_output(&full), Some((65535, 65535, 0, 0)));
// Half on the left motor only.
let half = [0x03, 0x04, 0, 0, 50, 100, 0, 0, 1];
assert_eq!(parse_xbox_output(&half), Some((32767, 0, 0, 0)));
}
/// The trigger magnitudes are on the SAME 0..100 protocol range as the handles, so a
/// full-scale `100` is `65535` — not `25700`, which is what reading them as 0..255 would give
/// and which reads on a real pad as "trigger rumble works but is weirdly weak". Named for the
/// regression so it cannot be "fixed" the wrong way later.
#[test]
fn trigger_magnitudes_are_not_a_zero_to_255_range() {
let full = [0x03, 0xFF, 100, 100, 0, 0, 0, 0, 1];
assert_eq!(parse_xbox_output(&full), Some((0, 0, 65535, 65535)));
let half = [0x03, 0xFF, 50, 25, 0, 0, 0, 0, 1];
assert_eq!(parse_xbox_output(&half), Some((0, 0, 32767, 16383)));
}
/// A value above the protocol's 0..100 range must clamp, not wrap past full scale — on all
/// four actuators, since the triggers reuse the handles' scale closure.
#[test]
fn out_of_range_magnitudes_clamp() {
let over = [0x03, 0xFF, 255, 255, 255, 255, 0, 0, 1];
assert_eq!(parse_xbox_output(&over), Some((65535, 65535, 65535, 65535)));
}
/// The enable mask gates each motor independently — a report that enables nothing is a stop.
#[test]
fn the_enable_mask_gates_each_motor() {
let none = [0x03, 0x00, 100, 100, 100, 100, 0, 0, 1];
assert_eq!(parse_xbox_output(&none), Some((0, 0, 0, 0)));
let right_only = [0x03, 0x08, 0, 0, 100, 100, 0, 0, 1];
assert_eq!(parse_xbox_output(&right_only), Some((0, 65535, 0, 0)));
}
/// The case the whole trigger-rumble plane exists for, and the one nothing else in the tree
/// can produce: a racing title driving the impulse triggers hard while the handles stay
/// silent. `0x0C` is the two measured handle bits; `0xF3` is every OTHER bit, so this pair
/// isolates the handles from the triggers without claiming which bits the triggers are.
#[test]
fn a_trigger_only_report_leaves_the_handles_silent() {
let triggers_only = [0x03, 0xF3, 100, 40, 100, 100, 0, 0, 1];
assert_eq!(
parse_xbox_output(&triggers_only),
Some((0, 0, 65535, 26214))
);
let handles_only = [0x03, 0x0C, 100, 100, 100, 100, 0, 0, 1];
assert_eq!(parse_xbox_output(&handles_only), Some((65535, 65535, 0, 0)));
}
/// Anything that is not the rumble report — or is truncated — is ignored rather than parsed
/// out of whatever bytes happen to be there.
#[test]
fn non_rumble_reports_are_ignored() {
assert_eq!(parse_xbox_output(&[0x01, 0x0F, 0, 0, 100, 100]), None);
assert_eq!(parse_xbox_output(&[0x03, 0x0F, 0]), None);
assert_eq!(parse_xbox_output(&[]), None);
}
}
+3 -31
View File
@@ -389,22 +389,13 @@ pub mod mouse_windows;
/// Shared virtual-pad creation-retry policy ([`pad_gate::PadGate`]), driven by [`pad_slots`] for
/// every backend manager — replaces the per-backend permanent `broken` latch with capped-backoff
/// retry.
///
/// Built on every target, not just the two that have pad backends: it is pure timing arithmetic
/// over `std::time`, and gating it meant its tests — and [`pad_slots`]', which need it — could not
/// run on a developer machine at all. See [`pad_slots`].
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/pad_gate.rs"]
pub mod pad_gate;
/// Shared virtual-pad slot table + creation lifecycle ([`pad_slots::PadSlots`]) — the
/// `Vec<Option<Pad>>` table, `active_mask` unplug sweep, and gate-checked create every backend
/// manager used to copy-paste (G12).
///
/// Built on every target for the same reason as [`pad_gate`]: nothing in it touches an OS pad API
/// (the backend supplies the pad type and the `open` closure), so the platform gate bought
/// nothing and cost the ability to run the table's tests off a host box. That matters most for
/// [`pad_slots::PadCreateFault`], whose whole job is to describe a `cfg(windows)` failure that
/// only a Windows box can produce — the classification either has tests that run everywhere, or
/// it has none that anyone runs.
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/pad_slots.rs"]
pub mod pad_slots;
/// The `sensor_timestamp` every virtual Sony pad stamps into its input reports
@@ -483,25 +474,6 @@ pub mod uhid_abi;
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/uhid_manager.rs"]
pub mod uhid_manager;
/// Transport-independent Xbox HID codec — the report the `pf-gamepad` UMDF driver serves under
/// device-types 4, 5 and 6 (Xbox Wireless / One S / Elite Series 2, which share one descriptor and
/// differ only in VID/PID), giving an Xbox pad the HID footing `pf-xusb` never had
/// (Steam / WGI / GameInput / DirectInput cannot see an XUSB-interface-only device).
///
/// Deliberately NOT cfg-gated to linux/windows like its siblings: it is pure byte-packing with no
/// OS surface, so its layout tests compile and run on any host — including the macOS dev machines
/// where the Windows backends cannot be built at all. That is the only automated check this codec
/// has until a Windows box is reachable.
#[path = "inject/proto/xbox_proto.rs"]
pub mod xbox_proto;
/// Windows: virtual Xbox pads via the same UMDF minidriver — Xbox Wireless (device-type 4),
/// Xbox One S (5) and Xbox Elite Series 2 (6), the HID-visible alternative to
/// [`gamepad_windows`]'s XUSB companion, which Steam / WGI / GameInput / DirectInput cannot
/// enumerate at all because it registers only the XUSB device interface. The three identities
/// share one report descriptor and differ only in VID/PID, product string and INF model line.
#[cfg(target_os = "windows")]
#[path = "inject/windows/xbox_windows.rs"]
pub mod xbox_windows;
/// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere.
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub mod gamepad {
@@ -512,7 +484,7 @@ pub mod gamepad {
GamepadManager
}
pub fn handle(&mut self, _ev: &punktfunk_core::input::GamepadEvent) {}
pub fn pump_rumble(&mut self, _send: impl FnMut(u16, u16, u16, u16, u16)) {}
pub fn pump_rumble(&mut self, _send: impl FnMut(u16, u16, u16)) {}
}
}
/// Linux: the "Punktfunk Pen" uinput virtual tablet (design/pen-tablet-input.md §5) — the
+3 -4
View File
@@ -87,10 +87,9 @@ pub use session::{session_epoch, try_recover_session};
#[path = "vdisplay/routing.rs"]
pub(crate) mod routing;
pub use routing::{
apply_input_env, managed_session_available, preflight_takeover_privilege,
release_autologin_mask, resolve_gamescope_route, restore_managed_session, restore_takeover_now,
restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session,
GamescopeRoute,
apply_input_env, managed_session_available, resolve_gamescope_route, restore_managed_session,
restore_takeover_now, restore_takeover_on_startup, start_restore_worker,
wants_dedicated_game_session, GamescopeRoute,
};
#[cfg(target_os = "linux")]
pub use routing::{
File diff suppressed because it is too large Load Diff
@@ -524,22 +524,6 @@ fn parse_patch_level(banner: &str) -> u32 {
.unwrap_or(0)
}
/// The upstream `X.Y.Z` a specific gamescope binary reports, or `None` if it cannot be run/parsed.
///
/// Split from [`check_gamescope_version`] (which only ever probes the RESOLVED binary) because the
/// WSI-layer check has to compare TWO binaries — ours and the distro's — and a `None` there means
/// "leave the layer alone", not "assume old".
pub(super) fn gamescope_version_of(bin: &std::path::Path) -> Option<(u32, u32, u32)> {
let out = Command::new(bin).arg("--version").output().ok()?;
// Same stdout/stderr split as the version gate: builds disagree on where the banner goes.
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
parse_version(&text)
}
/// Minimum gamescope that captures reliably: below 3.16.22, headless PipeWire capture deadlocks
/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon.
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
+2 -35
View File
@@ -1093,19 +1093,6 @@ fn capability_denial_hint() -> String {
let permitted = std::fs::read_to_string("/proc/self/status")
.ok()
.and_then(|status| permitted_caps_from_status(&status));
capability_denial_hint_for(permitted)
}
/// The message half of [`capability_denial_hint`], split from the `/proc/self/status` read so it is
/// testable against a *given* mask instead of whatever the test process happens to hold.
///
/// That distinction is not academic: the first version of this asserted the empty case by calling
/// the real thing and trusting the test process to be uncapped. That holds on a dev box and is
/// false in CI, where the runner container is root with a full permitted set
/// (`CapPrm=0x000001ffffffffff`) — so the hint fired, correctly, and the test failed on a machine
/// where nothing was wrong. A check whose answer depends on the ambient environment tests the
/// environment, not the code.
fn capability_denial_hint_for(permitted: Option<u64>) -> String {
match permitted {
Some(caps) if caps != 0 => format!(
" — NOTE: this process carries capabilities (CapPrm={caps:#018x}), which is enough on \
@@ -1148,30 +1135,10 @@ mod capability_hint_tests {
/// A capability-free host must not append the hint — the message it decorates is also printed
/// on genuinely missing `.desktop` files, and a spurious "you have capabilities" line would
/// send the reader chasing a setcap that was never there.
///
/// Driven off an explicit mask rather than the test process's own: see
/// [`capability_denial_hint_for`] for why calling the real reader here fails in CI.
/// send the reader chasing a setcap that was never there. The test process has no capabilities.
#[test]
fn silent_without_capabilities() {
assert_eq!(
capability_denial_hint_for(permitted_caps_from_status(CLEAN)),
""
);
// Absent or unparseable field: also silent, never a panic and never a spurious hint.
assert_eq!(capability_denial_hint_for(None), "");
}
/// ...and the case that matters actually speaks, naming the mask and the repair. Without this
/// the test above passes just as well against a function that returns `""` unconditionally.
#[test]
fn names_the_mask_and_the_repair_when_capped() {
let hint = capability_denial_hint_for(permitted_caps_from_status(CAPPED));
assert!(
hint.contains("0x0000000000800000"),
"names the mask: {hint}"
);
assert!(hint.contains("setcap -r"), "names the repair: {hint}");
assert_eq!(capability_denial_hint(), "");
}
}
-258
View File
@@ -111,74 +111,6 @@ pub(crate) fn current_uid() -> u32 {
unsafe { libc::getuid() }
}
/// The longest `/proc/<pid>/comm` the kernel will report: `TASK_COMM_LEN` is 16 *including* the
/// NUL, so a name of exactly this many bytes may be a truncation of a longer one.
#[cfg(target_os = "linux")]
const COMM_MAX: usize = 15;
/// The executable name to identify a process by, with nixpkgs wrapper decoration undone.
///
/// `comm` is the kernel's name for the **executed file**, truncated to [`COMM_MAX`] bytes — it is
/// not `argv[0]` and not the command line. nixpkgs wraps essentially every graphical binary:
/// `wrapProgram` moves the real ELF aside to `.<name>-wrapped` and installs a shell wrapper under
/// the original name, and that wrapper `exec -a "$0"`s the hidden file. So `ps`/`pgrep -a` show a
/// perfectly ordinary `kwin_wayland` (they read argv) while the kernel reports `.kwin_wayland-w`
/// — 15 bytes of `.kwin_wayland-wrapped`, which can never equal `kwin_wayland`.
///
/// That is not a KDE-only detail. On NixOS `kwin_wayland`, `gamescope`, `gnome-shell` and
/// `Hyprland` are all wrapped, so an exact `comm` comparison made [`super::session`]'s probe
/// answer [`crate::ActiveKind::None`] on a visibly running desktop — and because the probe is the
/// *only* input to that decision, no environment variable could reach it: `WAYLAND_DISPLAY` was
/// correct, capture worked the moment detection was satisfied, and a `PUNKTFUNK_COMPOSITOR` pin
/// turned the miss into a hard error via `pinned_at_a_dead_session`. (sway survives by accident —
/// nixpkgs' wrapper execs a real binary that is itself still called `sway`.)
///
/// The `comm` fast path is kept for every ordinary distro: one read, no readlink. Only a name that
/// *could* be decorated or truncated — it starts with `.`, or it is exactly [`COMM_MAX`] bytes —
/// is re-resolved through `/proc/<pid>/exe`, which carries the full, untruncated file name.
///
/// `pid_path` is a `/proc/<pid>` directory. `None` when the process vanished mid-scan.
#[cfg(target_os = "linux")]
pub(crate) fn match_name(pid_path: &std::path::Path) -> Option<String> {
let comm = std::fs::read_to_string(pid_path.join("comm")).ok()?;
let comm = comm.trim();
// An undecorated name short enough to be complete is already the answer.
if !comm.starts_with('.') && comm.len() < COMM_MAX {
return Some(comm.to_string());
}
// Reading our OWN uid's `/proc/<pid>/exe` needs no privilege (every caller filters on uid
// first), but it is still absent for a kernel thread and for a process exiting under us —
// in which case the truncated `comm` is the best that exists.
match std::fs::read_link(pid_path.join("exe"))
.ok()
.as_deref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
{
Some(full) => Some(undecorate(full).to_string()),
None => Some(comm.to_string()),
}
}
/// Strip nixpkgs `wrapProgram` decoration: `.<name>-wrapped`, plus the `_` suffixes make-wrapper
/// appends when that hidden name is already taken (a doubly-wrapped app — Qt *and* GApps).
///
/// **Both** halves are required, and that is the load-bearing part rather than pedantry: KWin
/// ships its own real binary called `kwin_wayland_wrapper` (the session's parent process), so a
/// rule that merely stripped a `wrapper`-ish suffix would rewrite it into `kwin_wayland` and hand
/// the session probe the wrong PID. Demanding the leading `.` as well keeps it — and any genuine
/// `foo-wrapped` — under its real name.
#[cfg(target_os = "linux")]
fn undecorate(name: &str) -> &str {
let Some(rest) = name.strip_prefix('.') else {
return name;
};
match rest.trim_end_matches('_').strip_suffix("-wrapped") {
Some(real) if !real.is_empty() => real,
_ => name,
}
}
/// Ending the *tree* the helper started, not just the process we spawned.
///
/// [`std::process::Child::kill`] is one `TerminateProcess` / one `SIGKILL`: it ends exactly the
@@ -348,196 +280,6 @@ mod tests {
}
}
/// The `comm`-vs-real-name resolution ([`match_name`]). Linux-only, because the trap it exists for
/// is a Linux kernel detail (`comm` names the executed FILE, truncated to 15 bytes) crossed with a
/// nixpkgs packaging convention.
///
/// Driven against **fixture** `/proc/<pid>` directories rather than spawned processes, for the same
/// reason the `/proc` matcher in `punktfunk-host` learned the hard way: a stand-in has to be a real
/// ELF that tolerates being *renamed*, and `/bin/sleep` is not one. Modern coreutils (uutils on
/// Ubuntu 25.10+, busybox elsewhere) is a MULTI-CALL binary — copied to `.kwin_wayland-wrapped` it
/// prints "unknown program" and exits before `/proc` can be read, and restoring `argv[0]` does not
/// save it. That reads exactly like this resolver being broken. The truncation the fixtures encode
/// is not guessed: the strings below were measured from a live kernel (`.kwin_wayland-w`,
/// `.kwin_wayland_w`, `.gamescope-wrap` — all 15 bytes) against binaries installed and exec'd the
/// way nixpkgs does it.
#[cfg(all(test, target_os = "linux"))]
mod name_tests {
use super::*;
use std::path::{Path, PathBuf};
/// A fake `/proc/<pid>` directory: a `comm` file and, optionally, the `exe` symlink. Removed on
/// drop.
struct FakePid {
dir: PathBuf,
}
impl FakePid {
/// `comm` is written exactly as the kernel would report it — i.e. already truncated.
fn new(tag: &str, comm: &str, exe: Option<&str>) -> FakePid {
let dir = std::env::temp_dir().join(format!("pf-vd-name-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("fixture dir");
std::fs::write(dir.join("comm"), format!("{comm}\n")).expect("comm");
if let Some(exe) = exe {
// The target need not exist: `read_link` reports the link's contents, and a real
// `/proc/<pid>/exe` routinely points at a path that has since been replaced.
std::os::unix::fs::symlink(
format!("/nix/store/eeee-kwin-6.5.0/bin/{exe}"),
dir.join("exe"),
)
.expect("exe symlink");
}
FakePid { dir }
}
fn path(&self) -> &Path {
&self.dir
}
}
impl Drop for FakePid {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
/// The decoration table. The `kwin_wayland_wrapper` rows are the ones that earn their keep: it
/// is a REAL KWin binary (the session's parent process), so the rule must leave it under its own
/// name in both its plain and its wrapped form rather than collapsing either into
/// `kwin_wayland` and handing the session probe the wrong PID.
#[test]
fn undecorate_strips_only_a_real_nixpkgs_wrapper() {
for (raw, want) in [
(".kwin_wayland-wrapped", "kwin_wayland"),
(".gamescope-wrapped", "gamescope"),
(".gnome-shell-wrapped", "gnome-shell"),
(".Hyprland-wrapped", "Hyprland"),
// make-wrapper appends `_`s when the hidden name is already taken (a Qt + GApps
// double-wrap), so the underscores come off before the suffix does.
(".kwin_wayland-wrapped_", "kwin_wayland"),
(".kwin_wayland-wrapped__", "kwin_wayland"),
// Not decoration — every one of these keeps its exact name.
("kwin_wayland", "kwin_wayland"),
("kwin_wayland_wrapper", "kwin_wayland_wrapper"),
(".kwin_wayland_wrapper-wrapped", "kwin_wayland_wrapper"),
("foo-wrapped", "foo-wrapped"),
(".hidden", ".hidden"),
(".-wrapped", ".-wrapped"),
] {
assert_eq!(undecorate(raw), want, "undecorate({raw:?})");
}
}
/// The whole bug. Every compositor the session probe matches on is wrapped by nixpkgs, so the
/// kernel reports a truncated, decorated `comm` that can never equal the name being compared —
/// which is why `detect_active_session` answered `ActiveKind::None` on a *running* KDE desktop
/// and every connect died "no usable compositor".
#[test]
fn a_nixpkgs_wrapped_compositor_resolves_to_its_real_name() {
for (tag, comm, exe, want) in [
(
"kwin",
".kwin_wayland-w",
".kwin_wayland-wrapped",
"kwin_wayland",
),
(
"gamescope",
".gamescope-wrap",
".gamescope-wrapped",
"gamescope",
),
(
"gnome",
".gnome-shell-wr",
".gnome-shell-wrapped",
"gnome-shell",
),
("hypr", ".Hyprland-wrapp", ".Hyprland-wrapped", "Hyprland"),
] {
let p = FakePid::new(tag, comm, Some(exe));
assert_eq!(
match_name(p.path()).as_deref(),
Some(want),
"a nixpkgs-wrapped {want} must resolve to the name the session probe matches"
);
}
}
/// KWin's own `kwin_wayland_wrapper` is a real binary that runs *alongside* `kwin_wayland`, and
/// its wrapped `comm` (`.kwin_wayland_w`) differs from the compositor's by a single byte. It
/// must NOT resolve to `kwin_wayland`: the probe would then match the parent process and carry
/// its PID as the compositor identity, which drives restart detection.
#[test]
fn kwins_own_wrapper_binary_does_not_masquerade_as_the_compositor() {
let p = FakePid::new(
"kwrap",
".kwin_wayland_w",
Some(".kwin_wayland_wrapper-wrapped"),
);
assert_eq!(
match_name(p.path()).as_deref(),
Some("kwin_wayland_wrapper")
);
}
/// The other half of the 15-byte limit, with no nix involved: a long name is truncated too, and
/// has to be recovered from `exe` rather than matched short.
#[test]
fn a_long_name_is_recovered_untruncated() {
let p = FakePid::new(
"long",
"a-very-long-com",
Some("a-very-long-compositor-name"),
);
assert_eq!(
match_name(p.path()).as_deref(),
Some("a-very-long-compositor-name")
);
}
/// The fast path answers without consulting `exe` at all — which is what keeps this probe at one
/// read per process on every ordinary distro, and what lets it answer for a process whose `exe`
/// is unreadable in the first place.
#[test]
fn an_ordinary_short_name_never_needs_the_exe_link() {
let p = FakePid::new("plain", "kwin_wayland", None);
assert_eq!(match_name(p.path()).as_deref(), Some("kwin_wayland"));
}
/// A decorated-or-truncated name whose `exe` cannot be read (a kernel thread, or a process
/// exiting under the scan) degrades to the truncated `comm` instead of failing the whole entry.
#[test]
fn an_unreadable_exe_falls_back_to_comm() {
let p = FakePid::new("noexe", ".kwin_wayland-w", None);
assert_eq!(match_name(p.path()).as_deref(), Some(".kwin_wayland-w"));
}
/// A pid directory that does not exist yields `None`, not a bogus name — the scans `continue`.
#[test]
fn a_vanished_process_yields_none() {
assert_eq!(match_name(Path::new("/proc/0")), None);
}
/// The one thing a fixture cannot establish: that reading `/proc/<pid>/exe` is actually
/// *permitted* for a process of our own uid, which the whole resolver depends on. Checked
/// against the only such process guaranteed to be running — this one.
#[test]
fn our_own_exe_link_is_readable() {
let me = Path::new("/proc/self");
let exe = std::fs::read_link(me.join("exe"))
.expect("/proc/self/exe must be readable for our own uid");
let name = exe.file_name().and_then(|n| n.to_str()).expect("exe name");
let got = match_name(me).expect("our own name");
// Whichever rung answered, it must agree with the real binary: the fast path returns the
// (short, undecorated) comm, which is a prefix of it; the exe path returns it outright.
assert!(
name.starts_with(got.as_str()) || got == name,
"resolved {got:?} disagrees with our real binary {name:?}"
);
}
}
/// The same two cases through `cmd /c`, so the budget logic is covered on the platform whose
/// process model differs most (job objects, no `SIGKILL`). `ping -n` is the standard Windows
/// no-extra-tooling sleep.
@@ -371,19 +371,6 @@ pub fn restore_takeover_on_startup() {
#[cfg(not(target_os = "linux"))]
pub fn restore_takeover_on_startup() {}
/// Warn ONCE, at startup, when this box will need the managed gamescope takeover but its user is
/// not in the `punktfunk` group the packaged privilege helper gates on — the one takeover
/// prerequisite that fails silently mid-stream instead of at setup time. Gated so a box that will
/// never attempt a takeover stays quiet; see [`gamescope::preflight_takeover_privilege`] for the
/// exact conditions. Call once at `serve` startup, alongside [`restore_takeover_on_startup`].
#[cfg(target_os = "linux")]
pub fn preflight_takeover_privilege() {
gamescope::preflight_takeover_privilege();
}
#[cfg(not(target_os = "linux"))]
pub fn preflight_takeover_privilege() {}
/// Give the box its own session back **now**, synchronously, because the host is exiting. Blocks
/// (it shells out to `systemctl`), so call it off the async runtime. Call from the host's shutdown
/// path — a takeover that outlives the host leaves the box with no display manager and nobody left
@@ -396,22 +383,6 @@ pub fn restore_takeover_now() {
#[cfg(not(target_os = "linux"))]
pub fn restore_takeover_now() {}
/// Tell the takeover that the box switched to `switched_to` mid-stream. A managed takeover
/// runtime-masks the box's own autologin gaming unit so its session supervisor cannot restart it
/// underneath us — but that mask is only sound while our managed session actually holds the box.
/// Once the user has switched the box to a desktop session mid-stream, the mask defends nothing and
/// bars the way back: the distro's session script starts that very unit, so "Return to Gaming Mode"
/// fails instantly and Steam sits on its "Switch to Desktop…" modal until a reboot clears the mask.
/// Call from the mid-stream session watcher on every switch it confirms; no-op when no takeover
/// masked anything, and when the switch does not end the mask's window.
#[cfg(target_os = "linux")]
pub fn release_autologin_mask(switched_to: crate::ActiveKind) {
gamescope::release_autologin_mask(switched_to);
}
#[cfg(not(target_os = "linux"))]
pub fn release_autologin_mask(_switched_to: crate::ActiveKind) {}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
+4 -7
View File
@@ -311,11 +311,8 @@ pub fn detect_active_session() -> ActiveSession {
let dbus = default_bus(&env, &xdg_runtime_dir);
// Process probe: the running graphical compositor of THIS uid decides the kind. Priority lets
// a real desktop (kwin/gnome/sway) win over a leftover gamescope child. Names are matched
// exactly, `pkill -x` style — but resolved through [`crate::proc::match_name`], NOT a raw
// `comm` read: on NixOS every one of these binaries is a nixpkgs wrapper whose real ELF is
// `.<name>-wrapped`, so a raw `comm` says `.kwin_wayland-w` and this whole probe answered
// `None` on a running KDE desktop.
// a real desktop (kwin/gnome/sway) win over a leftover gamescope child. comm names mirror the
// `pkill -x` discipline (exact, ≤15 chars so untruncated).
let mut kind = ActiveKind::None;
let mut best = 0u8;
// The winning compositor's PID — kept so a same-kind compositor RESTART (a new PID) bumps the
@@ -335,10 +332,10 @@ pub fn detect_active_session() -> ActiveSession {
if md.uid() != uid {
continue;
}
let Some(comm) = crate::proc::match_name(&pid_path) else {
let Ok(comm) = std::fs::read_to_string(pid_path.join("comm")) else {
continue;
};
let (k, prio) = match comm.as_str() {
let (k, prio) = match comm.trim() {
"gamescope" | "gamescope-wl" => (ActiveKind::Gaming, 1),
"kwin_wayland" => (ActiveKind::DesktopKde, 4),
"gnome-shell" => (ActiveKind::DesktopGnome, 4),
+211 -47
View File
@@ -1,27 +1,27 @@
//! Host side of the isolated zero-copy GPU import (design:
//! `design/zerocopy-worker-isolation.md`): spawns the `zerocopy-worker` subprocess on the shared
//! [`super::ipc`] rails, mirrors the [`super::egl::EglImporter`] entry points over the
//! [`super::proto`] vocabulary, and materializes the worker's pooled CUDA buffers in this process
//! via CUDA IPC (each buffer's handles are opened exactly once and reused as the pool recycles).
//! A worker death — the whole point of the isolation — surfaces as an `Err` with
//! [`RemoteImporter::dead`] set, never as a host fault.
//! `design/zerocopy-worker-isolation.md`): spawns the `zerocopy-worker` subprocess, mirrors the
//! [`super::egl::EglImporter`] entry points over the [`super::proto`] socket, and materializes
//! the worker's pooled CUDA buffers in this process via CUDA IPC (each buffer's handles are
//! opened exactly once and reused as the pool recycles). A worker death — the whole point of the
//! isolation — surfaces as an `Err` with [`RemoteImporter::dead`] set, never as a host fault.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::cuda::{self, CUdeviceptr, DeviceBuffer, CU_IPC_HANDLE_SIZE};
use super::egl::DmabufPlane;
use super::ipc;
use super::proto::{BufferDesc, ImportKind, Reply, Request, PROTO_VERSION};
use super::proto::{self, BufferDesc, ImportKind, Reply, Request};
use anyhow::{bail, Context, Result};
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io;
use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
use std::path::Path;
use std::process::Child;
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
/// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds.
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20);
@@ -79,6 +79,110 @@ fn close_mapping(m: &Mapping) {
}
}
/// Children whose worker hasn't exited yet at `RemoteImporter` drop time (it exits on socket
/// EOF, i.e. after the last in-flight frame drops). Swept on every spawn and every drop so
/// workers don't linger as zombies for more than one capture generation.
static REAPER: Mutex<Vec<(Child, Instant)>> = Mutex::new(Vec::new());
/// How long past `REPLY_TIMEOUT` a parked worker may linger before it is force-killed. A worker
/// wedged INSIDE a driver call never observes socket EOF, so `try_wait` alone would keep it (and
/// its CUcontext + BufferPool — order hundreds of MB of VRAM) forever.
const REAPER_KILL_DEADLINE: Duration = Duration::from_secs(20);
fn sweep_reaper() {
// Partition under the lock; kill/reap OUTSIDE it. A worker wedged inside a driver ioctl sits
// in D state and ignores SIGKILL — the old blocking `wait()` under the global mutex would
// then park every later `spawn()` and `drop()` behind a process that may never die.
let mut expired: Vec<Child> = Vec::new();
{
let mut list = REAPER.lock().unwrap();
let now = Instant::now();
let mut i = 0;
while i < list.len() {
if matches!(list[i].0.try_wait(), Ok(Some(_))) {
list.swap_remove(i); // exited on its own → reaped
} else if now.duration_since(list[i].1) > REAPER_KILL_DEADLINE {
expired.push(list.swap_remove(i).0);
} else {
i += 1;
}
}
}
for mut c in expired {
let _ = c.kill();
// Bounded reap (~100 ms of polls): a SIGKILL'd process reaps near-instantly unless it is
// in D state — then park it again (re-killing later is harmless) so a future sweep reaps
// it once the driver unwedges, instead of blocking anyone here forever.
let mut reaped = false;
for _ in 0..10 {
if matches!(c.try_wait(), Ok(Some(_))) {
reaped = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
if !reaped {
tracing::warn!(
pid = c.id(),
"zerocopy worker ignored SIGKILL (likely wedged in a driver call, D state) — \
parked for a later sweep"
);
REAPER.lock().unwrap().push((c, Instant::now()));
}
}
}
/// Fd pinned to this process's own executable image, opened (once, lazily) via the
/// `/proc/self/exe` magic link. The link names the running image's *inode*, not its path, so it
/// resolves even after the installed binary was replaced or deleted — and exec'ing the fd (via
/// [`fd_exec_path`]) then still runs byte-for-byte the build this process is. `current_exe()`
/// instead readlinks to a path: after a package upgrade under a running host that path is
/// "<path> (deleted)" and spawning it fails ENOENT — every capture then silently fell back to
/// the CPU copy — and even while the path exists it may hold a newer build whose worker
/// protocol mismatches this process.
static SELF_EXE: OnceLock<Option<File>> = OnceLock::new();
fn self_exe() -> Option<BorrowedFd<'static>> {
SELF_EXE
.get_or_init(|| {
let f = match File::open("/proc/self/exe") {
Ok(f) => f,
Err(e) => {
tracing::warn!(
error = %e,
"cannot pin /proc/self/exe — worker spawns use the current_exe() path, \
which breaks if this binary is replaced on disk"
);
return None;
}
};
if f.as_raw_fd() != 3 {
return Some(f);
}
// Fd 3 is the slot the spawn hands the worker its socket on (the `dup2` in
// `spawn_exe`) — pinned there, the child would clobber it before exec resolves
// `/proc/self/fd/3`. Re-number: 3 stays occupied by `f` during the clone, so the
// duplicate cannot land on it.
match f.try_clone() {
Ok(clone) => Some(clone),
Err(e) => {
tracing::warn!(error = %e, "re-numbering the pinned exe fd off fd 3 failed");
None
}
}
})
.as_ref()
.map(|f| f.as_fd())
}
/// `/proc/self/fd/<n>` — an exec'able path to `fd`'s inode. The kernel resolves it at exec time
/// inside the forked child, whose fd table is a copy of ours (close-on-exec applies only once
/// the exec succeeds), so it names the pinned inode no matter what sits at the file's original
/// path by then.
fn fd_exec_path(fd: BorrowedFd<'_>) -> PathBuf {
PathBuf::from(format!("/proc/self/fd/{}", fd.as_raw_fd()))
}
/// The remote (isolated) importer — one per capture. Method-for-method mirror of the in-process
/// [`super::egl::EglImporter`] surface the capture thread uses.
pub struct RemoteImporter {
@@ -92,17 +196,13 @@ pub struct RemoteImporter {
impl RemoteImporter {
/// Spawn the worker from this host binary and complete the readiness handshake. The worker
/// is exec'd through the pinned [`ipc::self_exe`] fd, so it is always the exact image this
/// is exec'd through the pinned `SELF_EXE` fd, so it is always the exact image this
/// process runs — even after the installed binary was replaced mid-flight. An `Err` here
/// means "no isolated zero-copy available" — callers fall back to the CPU path, exactly like
/// an in-process `EglImporter::new()` failure.
///
/// Self-exec is right *here* — host and worker are the same build by construction, so the
/// version check is a formality. It is the one thing the capability-carrying encode worker
/// must NOT copy: a shared inode shares the file capability.
pub fn spawn() -> Result<RemoteImporter> {
match ipc::self_exe() {
Some(exe) => Self::spawn_exe(&exe.exec_path()),
match self_exe() {
Some(fd) => Self::spawn_exe(&fd_exec_path(fd)),
None => Self::spawn_exe(
&std::env::current_exe().context("resolve /proc/self/exe for the worker")?,
),
@@ -111,10 +211,45 @@ impl RemoteImporter {
/// [`Self::spawn`] with an explicit executable (separated for tests).
fn spawn_exe(exe: &Path) -> Result<RemoteImporter> {
// `exe` is normally an opaque `/proc/self/fd/<n>` — the argv[0] keeps `ps` meaningful.
let (host_end, child) =
ipc::spawn_worker(exe, "punktfunk-host", &["zerocopy-worker", "--fd", "3"])
.context("spawn zerocopy-worker")?;
sweep_reaper();
let (host_end, worker_end) = proto::socketpair_seqpacket().context("worker socketpair")?;
let mut cmd = Command::new(exe);
// `exe` is normally an opaque `/proc/self/fd/<n>` — keep `ps` output meaningful.
cmd.arg0("punktfunk-host");
cmd.arg("zerocopy-worker").arg("--fd").arg("3");
let raw = worker_end.as_raw_fd();
let parent = std::process::id() as libc::pid_t;
// SAFETY: `pre_exec` runs between fork and exec, so only async-signal-safe calls are
// allowed — `prctl`, `getppid`, `dup2` and `fcntl` all are, and the closure captures only
// `Copy` ints (no allocation, no locks; the error paths use `from_raw_os_error`, which
// does not allocate). PR_SET_PDEATHSIG makes the kernel SIGKILL the worker when the host
// dies — without it a crashed host left the worker holding its CUcontext + BufferPool
// (order hundreds of MB of VRAM) indefinitely. The `getppid` check closes the standard
// race: if the host died between fork and the prctl, the signal is never delivered, so
// refuse to exec instead. `dup2(raw, 3)` installs the socket at the fd number the
// subcommand expects and clears CLOEXEC on the copy; if the parent's fd already IS 3,
// `dup2(3,3)` would preserve CLOEXEC, so that case clears the flag explicitly instead.
unsafe {
cmd.pre_exec(move || {
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 {
return Err(io::Error::last_os_error());
}
if libc::getppid() != parent {
return Err(io::Error::from_raw_os_error(libc::ESRCH));
}
if raw == 3 {
let flags = libc::fcntl(3, libc::F_GETFD);
if flags < 0 || libc::fcntl(3, libc::F_SETFD, flags & !libc::FD_CLOEXEC) < 0 {
return Err(io::Error::last_os_error());
}
} else if libc::dup2(raw, 3) < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
let child = cmd.spawn().context("spawn zerocopy-worker")?;
drop(worker_end); // the child holds its own copy now
Self::from_socket(host_end, Some(child))
}
@@ -131,11 +266,11 @@ impl RemoteImporter {
rbuf: Vec::new(),
sent_keys: HashSet::new(),
};
ipc::set_recv_timeout(importer.shared.sock.as_fd(), Some(HANDSHAKE_TIMEOUT))?;
let ready = ipc::recv::<Reply>(importer.shared.sock.as_fd(), &mut importer.rbuf);
ipc::set_recv_timeout(importer.shared.sock.as_fd(), Some(REPLY_TIMEOUT))?;
proto::set_recv_timeout(importer.shared.sock.as_fd(), Some(HANDSHAKE_TIMEOUT))?;
let ready = proto::recv::<Reply>(importer.shared.sock.as_fd(), &mut importer.rbuf);
proto::set_recv_timeout(importer.shared.sock.as_fd(), Some(REPLY_TIMEOUT))?;
match ready {
Ok((Reply::Ready { version }, _)) if version == PROTO_VERSION => {
Ok((Reply::Ready { version }, _)) if version == proto::PROTO_VERSION => {
tracing::info!(
pid = importer.child.as_ref().map(|c| c.id()),
"zero-copy GPU import isolated in a worker process"
@@ -146,7 +281,7 @@ impl RemoteImporter {
importer.mark_dead();
bail!(
"zerocopy worker protocol mismatch (worker v{version}, host v{})",
PROTO_VERSION
proto::PROTO_VERSION
)
}
Ok((Reply::InitErr { message }, _)) => {
@@ -180,7 +315,7 @@ impl RemoteImporter {
if self.dead() {
return Vec::new();
}
if let Err(e) = ipc::send(
if let Err(e) = proto::send(
self.shared.sock.as_fd(),
&Request::Modifiers { fourcc },
None,
@@ -189,7 +324,7 @@ impl RemoteImporter {
self.mark_dead();
return Vec::new();
}
match ipc::recv::<Reply>(self.shared.sock.as_fd(), &mut self.rbuf) {
match proto::recv::<Reply>(self.shared.sock.as_fd(), &mut self.rbuf) {
Ok((Reply::Modifiers { modifiers }, _)) => modifiers,
Ok((other, _)) => {
tracing::warn!(?other, "unexpected zerocopy worker reply to Modifiers");
@@ -304,11 +439,11 @@ impl RemoteImporter {
stride: plane.stride,
has_fd,
};
if let Err(e) = ipc::send(self.shared.sock.as_fd(), &req, pass) {
if let Err(e) = proto::send(self.shared.sock.as_fd(), &req, pass) {
self.mark_dead();
return Err(e).context("zerocopy worker died (send)");
}
let reply = match ipc::recv::<Reply>(self.shared.sock.as_fd(), &mut self.rbuf) {
let reply = match proto::recv::<Reply>(self.shared.sock.as_fd(), &mut self.rbuf) {
Ok((reply, _)) => reply,
Err(e) => {
self.mark_dead();
@@ -368,7 +503,7 @@ impl RemoteImporter {
// captured `shared` Arc is what keeps the mapping + socket alive until
// the last frame drops. A retired mapping (its generation renegotiated
// away) closes here with its last reference.
let _ = ipc::send(shared.sock.as_fd(), &Request::Release { id }, None);
let _ = proto::send(shared.sock.as_fd(), &Request::Release { id }, None);
let mut g = shared.mappings.lock().unwrap();
if let Some(entry) = g.get_mut(&id) {
entry.refs = entry.refs.saturating_sub(1);
@@ -407,7 +542,7 @@ impl RemoteImporter {
});
}
if !self.dead() {
if let Err(e) = ipc::send(self.shared.sock.as_fd(), &Request::ClearCache, None) {
if let Err(e) = proto::send(self.shared.sock.as_fd(), &Request::ClearCache, None) {
tracing::warn!(error = %e, "zerocopy worker ClearCache failed");
self.mark_dead();
}
@@ -422,10 +557,10 @@ impl Drop for RemoteImporter {
// gone; park the rest for the next sweep.
if let Some(mut child) = self.child.take() {
if !matches!(child.try_wait(), Ok(Some(_))) {
ipc::park_child(child);
REAPER.lock().unwrap().push((child, Instant::now()));
}
}
ipc::sweep_reaper();
sweep_reaper();
}
}
@@ -484,12 +619,11 @@ fn open_mapping(desc: &BufferDesc) -> Result<Mapping> {
#[cfg(test)]
mod tests {
use super::*;
use std::os::fd::AsRawFd;
use std::thread;
fn handshake_server(reply: Reply) -> OwnedFd {
let (host, worker) = ipc::socketpair_seqpacket().unwrap();
ipc::send(worker.as_fd(), &reply, None).unwrap();
let (host, worker) = proto::socketpair_seqpacket().unwrap();
proto::send(worker.as_fd(), &reply, None).unwrap();
// Keep the worker end alive alongside the host end for the test's duration by leaking it
// into the reply thread below? Not needed: the handshake reply is already queued in the
// socket buffer, so the worker end may drop — recv still delivers queued data first.
@@ -500,7 +634,7 @@ mod tests {
#[test]
fn handshake_ready_and_version_gate() {
let host = handshake_server(Reply::Ready {
version: PROTO_VERSION,
version: proto::PROTO_VERSION,
});
let imp = RemoteImporter::from_socket(host, None).unwrap();
assert!(!imp.dead());
@@ -522,7 +656,7 @@ mod tests {
#[test]
fn handshake_eof_is_an_error() {
let (host, worker) = ipc::socketpair_seqpacket().unwrap();
let (host, worker) = proto::socketpair_seqpacket().unwrap();
drop(worker);
assert!(RemoteImporter::from_socket(host, None).is_err());
}
@@ -549,6 +683,36 @@ mod tests {
assert!(format!("{err:#}").contains("handshake"), "{err:#}");
}
#[test]
fn pinned_fd_exec_survives_on_disk_replacement() {
// The 2026-07-10 canary regression: a package upgrade replaced the installed binary and
// every worker spawn ENOENT'd (`current_exe()` readlinked to "<path> (deleted)"). The
// pinned-fd mechanism must keep exec'ing the original image after the file is gone: pin
// a copy of /bin/sh, delete it, then run it through the fd path.
let copy = std::env::temp_dir().join(format!("pf-zerocopy-exe-pin-{}", std::process::id()));
std::fs::copy("/bin/sh", &copy).unwrap();
let pinned = File::open(&copy).unwrap();
std::fs::remove_file(&copy).unwrap();
// Retry ETXTBSY: `fs::copy`'s write fd leaks into other tests' concurrently-forked
// children until their execs clear it (CLOEXEC applies only at exec), and exec'ing a
// file someone holds open for writing is refused. A harness artifact of copy-then-exec,
// not the mechanism under test — production pins a read-only fd on a binary nobody
// write-opens.
let status = loop {
match Command::new(fd_exec_path(pinned.as_fd()))
.arg("-c")
.arg("exit 42")
.status()
{
Err(e) if e.raw_os_error() == Some(libc::ETXTBSY) => {
std::thread::sleep(Duration::from_millis(10))
}
other => break other.expect("exec via /proc/self/fd of a deleted file"),
}
};
assert_eq!(status.code(), Some(42));
}
/// A request as the scripted peer saw it, paired with the identity (`st_ino`) of the
/// descriptor that actually arrived via SCM_RIGHTS — the `has_fd` boolean in the JSON body is
/// a *claim*; the received fd is the mechanism the whole worker design rests on, so tests
@@ -559,11 +723,11 @@ mod tests {
fn scripted_server(
replies: Vec<Reply>,
) -> (RemoteImporter, thread::JoinHandle<Vec<SeenRequest>>) {
let (host, worker) = ipc::socketpair_seqpacket().unwrap();
ipc::send(
let (host, worker) = proto::socketpair_seqpacket().unwrap();
proto::send(
worker.as_fd(),
&Reply::Ready {
version: PROTO_VERSION,
version: proto::PROTO_VERSION,
},
None,
)
@@ -572,7 +736,7 @@ mod tests {
let mut buf = Vec::new();
let mut seen = Vec::new();
let mut replies = replies.into_iter();
while let Ok((req, fd)) = ipc::recv::<Request>(worker.as_fd(), &mut buf) {
while let Ok((req, fd)) = proto::recv::<Request>(worker.as_fd(), &mut buf) {
let needs_reply = matches!(req, Request::Modifiers { .. } | Request::Import { .. });
let ino = fd
.as_ref()
@@ -580,7 +744,7 @@ mod tests {
seen.push((req, ino));
if needs_reply {
match replies.next() {
Some(r) => ipc::send(worker.as_fd(), &r, None).unwrap(),
Some(r) => proto::send(worker.as_fd(), &r, None).unwrap(),
None => break, // close → client sees a dead worker
}
}
-680
View File
@@ -1,680 +0,0 @@
//! Worker-IPC rails, shared by every punktfunk worker subprocess (design:
//! `design/zerocopy-worker-isolation.md`, generalized in
//! `design/gpu-priority-capability-worker-implementation-plan.md` §2/WP0). Two halves, both
//! deliberately free of any worker's *vocabulary* — the message enums live with their worker and
//! stay independently versioned:
//!
//! - **Framing.** A `SOCK_SEQPACKET` unix socketpair — reliable, ordered, message-framed (one
//! `sendmsg` = one message) — carrying serde bodies, with descriptors riding as `SCM_RIGHTS`
//! control data. Zero-length messages are reserved: `recvmsg` returning 0 on a SEQPACKET socket
//! is EOF (the peer died/closed), and a serialized message is never empty, so the two can't be
//! confused.
//! - **Process rails.** Spawn a worker on a *pinned* executable with its socket end on fd 3, kill
//! the host's children with it (`PR_SET_PDEATHSIG`), and reap them without ever blocking a
//! caller behind a process wedged in a driver ioctl.
//!
//! The zerocopy worker execs this process's own image ([`self_exe`]); the encode worker
//! (`design/gpu-priority-capability-worker.md`) is a **separate file** — it must never share an
//! inode with `punktfunk-host`, because a shared inode shares the file capability — so it passes
//! its own resolved path to [`spawn_worker`] instead.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::fs::File;
use std::io;
use std::os::fd::{AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
/// Upper bound for one serialized message (the largest real message — a modifier list — is far
/// below this). A message reported truncated at this size is a protocol error.
pub const MAX_MSG: usize = 64 * 1024;
/// Descriptors one message may carry. Four, because a multi-planar dmabuf can hold one fd per
/// plane; the single-fd case ([`send`]/[`recv`]) is the hot path and stays allocation-free.
pub const MAX_FDS: usize = 4;
/// Backing store for the `SCM_RIGHTS` control data, `u64` so it carries the 8-byte alignment
/// `cmsghdr` requires. `MAX_FDS` fds need `CMSG_SPACE(4 * 4) = 32` bytes on 64-bit Linux (a
/// 16-byte `cmsghdr` + 16 bytes of fds); 64 is double that, so the slack absorbs any platform
/// whose header is larger — the `cmsg_store_is_large_enough` test asserts it for real.
type CmsgStore = [u64; 8];
/// Control bytes the kernel may use for `n` descriptors. (`CMSG_SPACE` is pure size arithmetic —
/// the `unsafe` is libc's signature, not a contract.)
fn cmsg_space(n: usize) -> usize {
// SAFETY: `CMSG_SPACE` performs alignment arithmetic on its argument and touches no memory.
unsafe { libc::CMSG_SPACE((n * std::mem::size_of::<RawFd>()) as u32) as usize }
}
/// A CLOEXEC `SOCK_SEQPACKET` socketpair — `(host_end, worker_end)`.
pub fn socketpair_seqpacket() -> io::Result<(OwnedFd, OwnedFd)> {
let mut fds = [0i32; 2];
// SAFETY: `socketpair` writes two fds into `fds`, a live 2-element stack array matching the
// API contract; it reads no other Rust memory. The result is checked before the fds are used,
// and each returned fd is fresh (owned by no other wrapper), so the two `OwnedFd::from_raw_fd`
// each take sole ownership of a distinct, valid descriptor — no alias, no double-close.
unsafe {
if libc::socketpair(
libc::AF_UNIX,
libc::SOCK_SEQPACKET | libc::SOCK_CLOEXEC,
0,
fds.as_mut_ptr(),
) != 0
{
return Err(io::Error::last_os_error());
}
Ok((OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])))
}
}
/// Set (or clear) the receive timeout: a blocked [`recv`] then fails with
/// `ErrorKind::WouldBlock`. Used by the host so a hung worker can't wedge the calling thread.
pub fn set_recv_timeout(sock: BorrowedFd, timeout: Option<Duration>) -> io::Result<()> {
let tv = match timeout {
Some(d) => libc::timeval {
tv_sec: d.as_secs() as libc::time_t,
tv_usec: d.subsec_micros() as libc::suseconds_t,
},
None => libc::timeval {
tv_sec: 0,
tv_usec: 0,
},
};
// SAFETY: `setsockopt(SO_RCVTIMEO)` reads `size_of::<timeval>()` bytes from `&tv`, a live
// stack `timeval` that outlives this synchronous call; `sock` is the caller's live socket fd.
// Nothing is retained or written through Rust pointers.
let r = unsafe {
libc::setsockopt(
sock.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_RCVTIMEO,
&tv as *const libc::timeval as *const libc::c_void,
std::mem::size_of::<libc::timeval>() as libc::socklen_t,
)
};
if r != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
/// Send one message (+ optionally one fd as `SCM_RIGHTS`) as a single SEQPACKET datagram — the
/// single-descriptor fast path over [`send_fds`] (a borrowed one-element slice; no fd list is
/// built).
pub fn send<T: Serialize>(
sock: BorrowedFd,
msg: &T,
pass_fd: Option<BorrowedFd>,
) -> io::Result<()> {
match pass_fd {
Some(fd) => send_fds(sock, msg, &[fd]),
None => send_fds(sock, msg, &[]),
}
}
/// Send one message plus up to [`MAX_FDS`] descriptors as a single SEQPACKET datagram. Atomic per
/// message, so concurrent senders on the same socket (e.g. the capture thread's imports and the
/// encode thread's releases) need no lock. `MSG_NOSIGNAL` turns a dead peer into `EPIPE` instead
/// of `SIGPIPE`. More than [`MAX_FDS`] is a caller bug, refused like an over-long body rather than
/// panicking.
pub fn send_fds<T: Serialize>(sock: BorrowedFd, msg: &T, fds: &[BorrowedFd]) -> io::Result<()> {
if fds.len() > MAX_FDS {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"worker ipc: {} fds in one message (max {MAX_FDS})",
fds.len()
),
));
}
let body =
serde_json::to_vec(msg).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
debug_assert!(
!body.is_empty(),
"zero-length messages are reserved for EOF"
);
if body.len() > MAX_MSG {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"worker ipc message too large",
));
}
let mut iov = libc::iovec {
iov_base: body.as_ptr() as *mut libc::c_void,
iov_len: body.len(),
};
let mut cmsg_store: CmsgStore = [0; 8];
// SAFETY: `mhdr` is a plain-old-data C struct for which all-zero is a valid value.
let mut mhdr: libc::msghdr = unsafe { std::mem::zeroed() };
mhdr.msg_iov = &mut iov;
mhdr.msg_iovlen = 1;
if !fds.is_empty() {
let bytes = (fds.len() * std::mem::size_of::<RawFd>()) as u32;
debug_assert!(cmsg_space(fds.len()) <= std::mem::size_of_val(&cmsg_store));
mhdr.msg_control = cmsg_store.as_mut_ptr() as *mut libc::c_void;
// SAFETY: `CMSG_SPACE`/`CMSG_LEN` are pure size computations (no memory access).
// `CMSG_FIRSTHDR(&mhdr)` returns a pointer into `cmsg_store` (non-null: msg_controllen
// ≥ one cmsghdr), which is live, 8-aligned, and large enough — the store holds
// `CMSG_SPACE(MAX_FDS * 4)` and `fds.len() <= MAX_FDS` was checked above — for the header
// fields plus one 4-byte fd per element written through `CMSG_DATA`; `write_unaligned`
// handles the data area's byte alignment. All writes stay within `cmsg_store`, which
// outlives the synchronous `sendmsg` below.
unsafe {
mhdr.msg_controllen = libc::CMSG_SPACE(bytes) as _;
let c = libc::CMSG_FIRSTHDR(&mhdr);
(*c).cmsg_level = libc::SOL_SOCKET;
(*c).cmsg_type = libc::SCM_RIGHTS;
(*c).cmsg_len = libc::CMSG_LEN(bytes) as _;
let data = libc::CMSG_DATA(c) as *mut RawFd;
for (i, fd) in fds.iter().enumerate() {
std::ptr::write_unaligned(data.add(i), fd.as_raw_fd());
}
}
}
// SAFETY: `sock` is the caller's live socket; `mhdr` points at the live `iov` (over `body`,
// which outlives the call) and — when fds are passed — at `cmsg_store` (ditto). `sendmsg`
// only reads these buffers. The kernel dups the fds into the message; our `BorrowedFd`s stay
// owned by the caller.
let n = unsafe { libc::sendmsg(sock.as_raw_fd(), &mhdr, libc::MSG_NOSIGNAL) };
if n < 0 {
return Err(io::Error::last_os_error());
}
if n as usize != body.len() {
return Err(io::Error::new(
io::ErrorKind::WriteZero,
"short sendmsg on SEQPACKET socket",
));
}
Ok(())
}
/// Receive one message (+ up to one `SCM_RIGHTS` fd) — the single-descriptor fast path over
/// [`recv_fds`]. Any further descriptors the peer attached are dropped, i.e. closed, so a
/// protocol mix-up cannot leak fds into a caller that only knows about one.
pub fn recv<T: DeserializeOwned>(
sock: BorrowedFd,
buf: &mut Vec<u8>,
) -> io::Result<(T, Option<OwnedFd>)> {
let (msg, fds) = recv_fds(sock, buf)?;
Ok((msg, fds.into_iter().next()))
}
/// Receive one message plus its `SCM_RIGHTS` descriptors (up to [`MAX_FDS`], in the order the
/// sender listed them). `buf` is a caller-owned scratch buffer (grown to [`MAX_MSG`] once, then
/// reused message to message); the returned `Vec` does not allocate when no fd arrived, which is
/// the steady state under an fd-identity cache. Errors: `UnexpectedEof` = the peer is gone;
/// `WouldBlock` = the [`set_recv_timeout`] expired; `InvalidData` = a truncated body or more
/// descriptors than [`MAX_FDS`] (the kernel drops the excess and flags `MSG_CTRUNC`).
pub fn recv_fds<T: DeserializeOwned>(
sock: BorrowedFd,
buf: &mut Vec<u8>,
) -> io::Result<(T, Vec<OwnedFd>)> {
buf.resize(MAX_MSG, 0);
let mut iov = libc::iovec {
iov_base: buf.as_mut_ptr() as *mut libc::c_void,
iov_len: buf.len(),
};
let mut cmsg_store: CmsgStore = [0; 8];
// SAFETY: `mhdr` is a plain-old-data C struct for which all-zero is a valid value.
let mut mhdr: libc::msghdr = unsafe { std::mem::zeroed() };
mhdr.msg_iov = &mut iov;
mhdr.msg_iovlen = 1;
mhdr.msg_control = cmsg_store.as_mut_ptr() as *mut libc::c_void;
// Exactly MAX_FDS worth of control space (not the store's full size): a peer that attaches
// more gets its excess dropped by the kernel and the message flagged `MSG_CTRUNC`, which is
// checked below — the cap is enforced by the kernel rather than trusted from the peer.
debug_assert!(cmsg_space(MAX_FDS) <= std::mem::size_of_val(&cmsg_store));
mhdr.msg_controllen = cmsg_space(MAX_FDS) as _;
// SAFETY: `sock` is the caller's live socket. `recvmsg` writes at most `iov_len` bytes into
// `buf` (live for the call) and at most `msg_controllen` control bytes into `cmsg_store`
// (live, 8-aligned, and at least that large — asserted above). `MSG_CMSG_CLOEXEC` makes any
// received fd CLOEXEC atomically.
let n = unsafe { libc::recvmsg(sock.as_raw_fd(), &mut mhdr, libc::MSG_CMSG_CLOEXEC) };
if n < 0 {
return Err(io::Error::last_os_error());
}
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"worker ipc peer closed",
));
}
// Collect the passed fds (if any) BEFORE any early return below, so they can't leak — an
// error path drops the `Vec`, which closes every one of them.
let mut got: Vec<OwnedFd> = Vec::new();
// SAFETY: `CMSG_FIRSTHDR`/`CMSG_NXTHDR` walk the control area the kernel just wrote inside
// `cmsg_store` (bounded by the updated `mhdr.msg_controllen`), returning either null or a
// pointer to a complete `cmsghdr` within it — each dereference reads kernel-initialized
// fields in bounds. For an `SCM_RIGHTS` cmsg the data area holds whole `RawFd`s, `cmsg_len -
// CMSG_LEN(0)` bytes of them, all inside that same complete cmsg, so every `read_unaligned`
// at index < that count is in bounds. The kernel gave us ownership of each fd (they are fresh
// descriptors in our table), so each `OwnedFd::from_raw_fd` takes sole ownership of a distinct
// descriptor — no alias, no double-close, and nothing dropped on the floor even with multiple
// cmsgs or multiple fds per cmsg.
unsafe {
let mut c = libc::CMSG_FIRSTHDR(&mhdr);
while !c.is_null() {
if (*c).cmsg_level == libc::SOL_SOCKET && (*c).cmsg_type == libc::SCM_RIGHTS {
let payload = ((*c).cmsg_len as usize).saturating_sub(libc::CMSG_LEN(0) as usize);
let data = libc::CMSG_DATA(c) as *const RawFd;
for i in 0..payload / std::mem::size_of::<RawFd>() {
let fd = std::ptr::read_unaligned(data.add(i));
if fd >= 0 {
got.push(OwnedFd::from_raw_fd(fd));
}
}
}
c = libc::CMSG_NXTHDR(&mhdr, c);
}
}
if mhdr.msg_flags & libc::MSG_CTRUNC != 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("worker ipc message carried more than {MAX_FDS} descriptors"),
));
}
if mhdr.msg_flags & libc::MSG_TRUNC != 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"worker ipc message truncated",
));
}
let msg = serde_json::from_slice(&buf[..n as usize])
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok((msg, got))
}
/// An executable pinned by an open fd, exec'able through [`PinnedExe::exec_path`]. The fd names
/// the file's *inode*, not its path, so it resolves even after the binary was replaced or deleted
/// — and exec'ing it then still runs byte-for-byte the build that was pinned. `current_exe()`
/// instead readlinks to a path: after a package upgrade under a running host that path is
/// `"<path> (deleted)"` and spawning it fails ENOENT — every capture then silently fell back to the
/// CPU copy (the 2026-07-10 canary regression) — and even while the path exists it may hold a
/// newer build whose worker protocol mismatches this process.
pub struct PinnedExe(File);
impl PinnedExe {
/// Pin the executable at `path`.
pub fn open(path: &Path) -> io::Result<PinnedExe> {
let f = File::open(path)?;
if f.as_raw_fd() != 3 {
return Ok(PinnedExe(f));
}
// Fd 3 is the slot the spawn hands the worker its socket on (the `dup2` in
// [`spawn_worker`]) — pinned there, the child would clobber it before exec resolves
// `/proc/self/fd/3`. Re-number: 3 stays occupied by `f` during the clone, so the
// duplicate cannot land on it.
let clone = f.try_clone().map_err(|e| {
io::Error::new(
e.kind(),
format!("re-numbering the pinned exe fd off fd 3 failed: {e}"),
)
})?;
Ok(PinnedExe(clone))
}
/// `/proc/self/fd/<n>` — an exec'able path to the pinned inode. The kernel resolves it at exec
/// time inside the forked child, whose fd table is a copy of ours (close-on-exec applies only
/// once the exec succeeds), so it names the pinned inode no matter what sits at the file's
/// original path by then.
pub fn exec_path(&self) -> PathBuf {
PathBuf::from(format!("/proc/self/fd/{}", self.0.as_raw_fd()))
}
}
/// This process's own executable image, pinned once (lazily) via the `/proc/self/exe` magic link
/// — see [`PinnedExe`] for why the fd and not the path. `None` when it could not be opened, in
/// which case callers fall back to `current_exe()` and inherit that trap.
///
/// Only for a worker that is the same file as its host by construction (the zerocopy worker
/// re-execs this binary). The encode worker must stay a **separate file** — it carries a file
/// capability the host must never have — so it pins its own resolved path with [`PinnedExe::open`].
pub fn self_exe() -> Option<&'static PinnedExe> {
static SELF_EXE: OnceLock<Option<PinnedExe>> = OnceLock::new();
SELF_EXE
.get_or_init(|| match PinnedExe::open(Path::new("/proc/self/exe")) {
Ok(p) => Some(p),
Err(e) => {
tracing::warn!(
error = %e,
"cannot pin /proc/self/exe — worker spawns use the current_exe() path, \
which breaks if this binary is replaced on disk"
);
None
}
})
.as_ref()
}
/// Spawn a worker process on `exe` (normally a [`PinnedExe::exec_path`]) and hand it one end of a
/// fresh SEQPACKET socketpair on **fd 3**; the host end is returned alongside the child.
///
/// `argv0` is what `ps` shows — worth setting, because `exe` is normally an opaque
/// `/proc/self/fd/<n>`. `args` follow it.
pub fn spawn_worker(exe: &Path, argv0: &str, args: &[&str]) -> io::Result<(OwnedFd, Child)> {
sweep_reaper();
let (host_end, worker_end) = socketpair_seqpacket()?;
let mut cmd = Command::new(exe);
cmd.arg0(argv0);
cmd.args(args);
let raw = worker_end.as_raw_fd();
let parent = std::process::id() as libc::pid_t;
// SAFETY: `pre_exec` runs between fork and exec, so only async-signal-safe calls are
// allowed — `prctl`, `getppid`, `dup2` and `fcntl` all are, and the closure captures only
// `Copy` ints (no allocation, no locks; the error paths use `from_raw_os_error`, which
// does not allocate). PR_SET_PDEATHSIG makes the kernel SIGKILL the worker when the host
// dies — without it a crashed host left the worker holding its driver state (order hundreds
// of MB of VRAM) indefinitely. The `getppid` check closes the standard race: if the host died
// between fork and the prctl, the signal is never delivered, so refuse to exec instead.
// `dup2(raw, 3)` installs the socket at the fd number the worker expects and clears CLOEXEC
// on the copy; if the parent's fd already IS 3, `dup2(3,3)` would preserve CLOEXEC, so that
// case clears the flag explicitly instead.
unsafe {
cmd.pre_exec(move || {
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 {
return Err(io::Error::last_os_error());
}
if libc::getppid() != parent {
return Err(io::Error::from_raw_os_error(libc::ESRCH));
}
if raw == 3 {
let flags = libc::fcntl(3, libc::F_GETFD);
if flags < 0 || libc::fcntl(3, libc::F_SETFD, flags & !libc::FD_CLOEXEC) < 0 {
return Err(io::Error::last_os_error());
}
} else if libc::dup2(raw, 3) < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
let child = cmd.spawn()?;
drop(worker_end); // the child holds its own copy now
Ok((host_end, child))
}
/// Children whose worker hasn't exited yet at teardown time (a worker exits on socket EOF, i.e.
/// after the last in-flight frame drops). Swept on every spawn and every drop so workers don't
/// linger as zombies for more than one generation.
static REAPER: Mutex<Vec<(Child, Instant)>> = Mutex::new(Vec::new());
/// How long past the caller's reply timeout a parked worker may linger before it is force-killed.
/// A worker wedged INSIDE a driver call never observes socket EOF, so `try_wait` alone would keep
/// it (and its driver state — order hundreds of MB of VRAM) forever.
const REAPER_KILL_DEADLINE: Duration = Duration::from_secs(20);
/// Hand a still-running worker to the reaper. Call from a `Drop` after the socket is (about to
/// be) closed: the worker sees EOF and exits, and the next [`sweep_reaper`] collects it.
pub fn park_child(child: Child) {
REAPER.lock().unwrap().push((child, Instant::now()));
}
/// Reap exited workers; force-kill the ones parked past the kill deadline (20 s).
pub fn sweep_reaper() {
// Partition under the lock; kill/reap OUTSIDE it. A worker wedged inside a driver ioctl sits
// in D state and ignores SIGKILL — the old blocking `wait()` under the global mutex would
// then park every later spawn and drop behind a process that may never die.
let mut expired: Vec<Child> = Vec::new();
{
let mut list = REAPER.lock().unwrap();
let now = Instant::now();
let mut i = 0;
while i < list.len() {
if matches!(list[i].0.try_wait(), Ok(Some(_))) {
list.swap_remove(i); // exited on its own → reaped
} else if now.duration_since(list[i].1) > REAPER_KILL_DEADLINE {
expired.push(list.swap_remove(i).0);
} else {
i += 1;
}
}
}
for mut c in expired {
let _ = c.kill();
// Bounded reap (~100 ms of polls): a SIGKILL'd process reaps near-instantly unless it is
// in D state — then park it again (re-killing later is harmless) so a future sweep reaps
// it once the driver unwedges, instead of blocking anyone here forever.
let mut reaped = false;
for _ in 0..10 {
if matches!(c.try_wait(), Ok(Some(_))) {
reaped = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
if !reaped {
tracing::warn!(
pid = c.id(),
"worker ignored SIGKILL (likely wedged in a driver call, D state) — \
parked for a later sweep"
);
park_child(c);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
use std::io::{Read, Write};
use std::os::fd::AsFd;
/// A stand-in payload: the transport is generic over the serde body, and the workers' own
/// message enums live with the workers — nothing here may depend on one.
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct Msg {
tag: String,
n: u32,
}
fn msg(tag: &str) -> Msg {
Msg {
tag: tag.into(),
n: 7,
}
}
/// A pipe whose read end is passed over the socket, carrying `payload` for the receiver to
/// read back — the only way to prove the *descriptor* crossed, not just the claim that it did.
fn marked_pipe(payload: &[u8]) -> (std::io::PipeReader, std::io::PipeWriter) {
let (pr, mut pw) = std::io::pipe().unwrap();
pw.write_all(payload).unwrap();
(pr, pw)
}
#[test]
fn cmsg_store_is_large_enough() {
assert!(
cmsg_space(MAX_FDS) <= std::mem::size_of::<CmsgStore>(),
"CMSG_SPACE({MAX_FDS} fds) = {} > store {}",
cmsg_space(MAX_FDS),
std::mem::size_of::<CmsgStore>()
);
}
#[test]
fn round_trip_no_fd() {
let (a, b) = socketpair_seqpacket().unwrap();
let mut buf = Vec::new();
send(a.as_fd(), &msg("hello"), None).unwrap();
let (got, fd) = recv::<Msg>(b.as_fd(), &mut buf).unwrap();
assert_eq!(got, msg("hello"));
assert!(fd.is_none());
}
#[test]
fn passes_an_fd() {
let (a, b) = socketpair_seqpacket().unwrap();
let mut buf = Vec::new();
// A pipe stands in for a dmabuf: pass the read end, write through the original write end,
// and read the bytes back through the RECEIVED fd.
let (mut pr, mut pw) = std::io::pipe().unwrap();
send(a.as_fd(), &msg("one"), Some(pr.as_fd())).unwrap();
let (got, fd) = recv::<Msg>(b.as_fd(), &mut buf).unwrap();
assert_eq!(got, msg("one"));
let fd = fd.expect("fd should have been passed");
pw.write_all(b"hello").unwrap();
drop(pw);
let mut file = File::from(fd);
let mut s = String::new();
file.read_to_string(&mut s).unwrap();
assert_eq!(s, "hello");
// The original read end still works independently of the passed dup.
let mut nothing = [0u8; 1];
assert_eq!(pr.read(&mut nothing).unwrap(), 0);
}
#[test]
fn round_trip_three_fds() {
// The multi-planar case (WP0): one message carrying one fd per plane. Each pipe is
// pre-loaded with a distinct byte, so reading through the RECEIVED descriptors proves
// both that all three crossed and that they arrived in the sender's order.
let (a, b) = socketpair_seqpacket().unwrap();
let mut buf = Vec::new();
let planes = [marked_pipe(b"Y"), marked_pipe(b"U"), marked_pipe(b"V")];
{
let fds: Vec<BorrowedFd> = planes.iter().map(|(pr, _)| pr.as_fd()).collect();
send_fds(a.as_fd(), &msg("planes"), &fds).unwrap();
}
let (got, fds) = recv_fds::<Msg>(b.as_fd(), &mut buf).unwrap();
assert_eq!(got, msg("planes"));
assert_eq!(fds.len(), 3, "all three descriptors must cross");
// Drop the write ends so each read sees EOF after its byte.
drop(planes);
let read_back: Vec<String> = fds
.into_iter()
.map(|fd| {
let mut s = String::new();
File::from(fd).read_to_string(&mut s).unwrap();
s
})
.collect();
assert_eq!(read_back, vec!["Y", "U", "V"]);
}
#[test]
fn max_fds_round_trip_and_one_more_is_refused() {
let (a, b) = socketpair_seqpacket().unwrap();
let mut buf = Vec::new();
let pipes: Vec<_> = (0..MAX_FDS + 1).map(|_| std::io::pipe().unwrap()).collect();
let fds: Vec<BorrowedFd> = pipes.iter().map(|(pr, _)| pr.as_fd()).collect();
send_fds(a.as_fd(), &msg("full"), &fds[..MAX_FDS]).unwrap();
let (_, got) = recv_fds::<Msg>(b.as_fd(), &mut buf).unwrap();
assert_eq!(got.len(), MAX_FDS);
// One over the cap is refused at the sender (a caller bug), not panicked on.
let err = send_fds(a.as_fd(), &msg("over"), &fds).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn recv_keeps_the_first_fd_and_closes_the_rest() {
// A peer that attaches more descriptors than the single-fd caller expects must not leak
// them: `recv` returns the first and drops (closes) the others.
let (a, b) = socketpair_seqpacket().unwrap();
let mut buf = Vec::new();
let (first, second) = (marked_pipe(b"1"), marked_pipe(b"2"));
send_fds(a.as_fd(), &msg("two"), &[first.0.as_fd(), second.0.as_fd()]).unwrap();
let (_, fd) = recv::<Msg>(b.as_fd(), &mut buf).unwrap();
let fd = fd.expect("the first fd is returned");
drop(first);
let mut s = String::new();
File::from(fd).read_to_string(&mut s).unwrap();
assert_eq!(s, "1");
// The second pipe's receiver-side dup was closed with the returned Vec's tail, so the
// writer sees EPIPE once the local read end goes too.
drop(second.0);
let mut pw = second.1;
assert_eq!(
pw.write_all(b"x").unwrap_err().kind(),
io::ErrorKind::BrokenPipe
);
}
#[test]
fn eof_when_peer_closes() {
let (a, b) = socketpair_seqpacket().unwrap();
drop(a);
let mut buf = Vec::new();
let err = recv::<Msg>(b.as_fd(), &mut buf).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
}
#[test]
fn send_to_dead_peer_is_epipe_not_sigpipe() {
let (a, b) = socketpair_seqpacket().unwrap();
drop(b);
let err = send(a.as_fd(), &msg("gone"), None).unwrap_err();
// MSG_NOSIGNAL: a dead peer surfaces as EPIPE (BrokenPipe), never a process-killing signal.
assert_eq!(err.kind(), io::ErrorKind::BrokenPipe);
}
#[test]
fn recv_timeout_fires() {
let (a, _b) = socketpair_seqpacket().unwrap();
set_recv_timeout(a.as_fd(), Some(Duration::from_millis(50))).unwrap();
let mut buf = Vec::new();
let err = recv::<Msg>(a.as_fd(), &mut buf).unwrap_err();
assert!(
matches!(
err.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
),
"unexpected error kind: {err:?}"
);
}
#[test]
fn pinned_fd_exec_survives_on_disk_replacement() {
// The 2026-07-10 canary regression: a package upgrade replaced the installed binary and
// every worker spawn ENOENT'd (`current_exe()` readlinked to "<path> (deleted)"). The
// pinned-fd mechanism must keep exec'ing the original image after the file is gone: pin
// a copy of /bin/sh, delete it, then run it through the fd path.
let copy = std::env::temp_dir().join(format!("pf-zerocopy-exe-pin-{}", std::process::id()));
std::fs::copy("/bin/sh", &copy).unwrap();
let pinned = PinnedExe::open(&copy).unwrap();
std::fs::remove_file(&copy).unwrap();
// Retry ETXTBSY: `fs::copy`'s write fd leaks into other tests' concurrently-forked
// children until their execs clear it (CLOEXEC applies only at exec), and exec'ing a
// file someone holds open for writing is refused. A harness artifact of copy-then-exec,
// not the mechanism under test — production pins a read-only fd on a binary nobody
// write-opens.
let status = loop {
match Command::new(pinned.exec_path())
.arg("-c")
.arg("exit 42")
.status()
{
Err(e) if e.raw_os_error() == Some(libc::ETXTBSY) => {
std::thread::sleep(Duration::from_millis(10))
}
other => break other.expect("exec via /proc/self/fd of a deleted file"),
}
};
assert_eq!(status.code(), Some(42));
}
#[test]
fn spawn_worker_hands_the_socket_on_fd_3() {
// `sh` echoes back through fd 3 — the inheritance slot every worker reads its socket
// from. Receiving it here proves the dup2 landed and CLOEXEC was cleared on the copy.
let (host, mut child) = spawn_worker(
Path::new("/bin/sh"),
"pf-test-worker",
&["-c", r#"printf '"pong"' >&3"#],
)
.unwrap();
let mut buf = Vec::new();
let (got, fds) = recv_fds::<String>(host.as_fd(), &mut buf).unwrap();
assert_eq!(got, "pong");
assert!(fds.is_empty());
child.wait().unwrap();
}
}
-5
View File
@@ -14,11 +14,6 @@
pub mod client;
pub mod cuda;
pub mod egl;
// Worker-subprocess IPC rails (SEQPACKET framing ± `SCM_RIGHTS`, pinned-exe spawn, reaping),
// generic over the message body so every punktfunk worker shares them — the zerocopy one here,
// the capability-carrying encode worker in `pf-encode`. Vocabulary stays per-worker (`proto` is
// only this one's).
pub mod ipc;
pub mod proto;
pub mod vkslot;
pub mod vulkan;
+263 -16
View File
@@ -1,20 +1,32 @@
//! Wire *vocabulary* between the PipeWire capture thread and the isolated zero-copy GPU-import
//! Wire protocol between the PipeWire capture thread and the isolated zero-copy GPU-import
//! worker process (`punktfunk-host zerocopy-worker`; design:
//! `design/zerocopy-worker-isolation.md`) — the message types and this protocol's version, and
//! nothing else. The transport they ride on ([`super::ipc`]: SEQPACKET framing, `SCM_RIGHTS`,
//! spawn/reap) is shared with the other workers and is deliberately generic over the body type;
//! each worker's vocabulary stays its own and versions independently.
//! `design/zerocopy-worker-isolation.md`). Transport is a `SOCK_SEQPACKET` unix socketpair —
//! reliable, ordered, message-framed (one `sendmsg` = one message) — with dmabuf fds riding as
//! `SCM_RIGHTS` control data. Bodies are small serde_json blobs (~200 B/frame); pixels never
//! cross the socket (they move GPU-side via CUDA IPC, see [`super::cuda::ipc_export`]).
//!
//! Bodies are small serde_json blobs (~200 B/frame); pixels never cross the socket (they move
//! GPU-side via CUDA IPC, see [`super::cuda::ipc_export`]).
//! Zero-length messages are reserved: `recvmsg` returning 0 on a SEQPACKET socket is EOF (the
//! peer died/closed), and every serialized message here is non-empty JSON, so the two can't be
//! confused.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::io;
use std::os::fd::{AsRawFd, BorrowedFd, FromRawFd, OwnedFd};
use std::time::Duration;
/// Bumped on any wire change; the worker echoes it in [`Reply::Ready`] and the host refuses a
/// mismatch. Host and worker are the same binary (`/proc/self/exe`), so this only ever trips on
/// exotic deployment mistakes (a stale binary re-exec'd across an upgrade).
pub const PROTO_VERSION: u32 = 1;
/// Upper bound for one serialized message (the largest real message — a modifier list — is far
/// below this). A message reported truncated at this size is a protocol error.
pub const MAX_MSG: usize = 64 * 1024;
/// How a dmabuf should be imported — mirrors the `EglImporter` entry points.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportKind {
@@ -106,17 +118,197 @@ pub struct BufferDesc {
pub uv: Option<(Vec<u8>, usize)>,
}
/// A CLOEXEC `SOCK_SEQPACKET` socketpair — `(host_end, worker_end)`.
pub fn socketpair_seqpacket() -> io::Result<(OwnedFd, OwnedFd)> {
let mut fds = [0i32; 2];
// SAFETY: `socketpair` writes two fds into `fds`, a live 2-element stack array matching the
// API contract; it reads no other Rust memory. The result is checked before the fds are used,
// and each returned fd is fresh (owned by no other wrapper), so the two `OwnedFd::from_raw_fd`
// each take sole ownership of a distinct, valid descriptor — no alias, no double-close.
unsafe {
if libc::socketpair(
libc::AF_UNIX,
libc::SOCK_SEQPACKET | libc::SOCK_CLOEXEC,
0,
fds.as_mut_ptr(),
) != 0
{
return Err(io::Error::last_os_error());
}
Ok((OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])))
}
}
/// Set (or clear) the receive timeout: a blocked [`recv`] then fails with
/// `ErrorKind::WouldBlock`. Used by the host so a hung worker can't wedge the capture thread.
pub fn set_recv_timeout(sock: BorrowedFd, timeout: Option<Duration>) -> io::Result<()> {
let tv = match timeout {
Some(d) => libc::timeval {
tv_sec: d.as_secs() as libc::time_t,
tv_usec: d.subsec_micros() as libc::suseconds_t,
},
None => libc::timeval {
tv_sec: 0,
tv_usec: 0,
},
};
// SAFETY: `setsockopt(SO_RCVTIMEO)` reads `size_of::<timeval>()` bytes from `&tv`, a live
// stack `timeval` that outlives this synchronous call; `sock` is the caller's live socket fd.
// Nothing is retained or written through Rust pointers.
let r = unsafe {
libc::setsockopt(
sock.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_RCVTIMEO,
&tv as *const libc::timeval as *const libc::c_void,
std::mem::size_of::<libc::timeval>() as libc::socklen_t,
)
};
if r != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
/// Send one message (+ optionally one fd as `SCM_RIGHTS`) as a single SEQPACKET datagram.
/// Atomic per message, so concurrent senders on the same socket (the capture thread's imports,
/// the encode thread's releases) need no lock. `MSG_NOSIGNAL` turns a dead peer into `EPIPE`
/// instead of `SIGPIPE`.
pub fn send<T: Serialize>(
sock: BorrowedFd,
msg: &T,
pass_fd: Option<BorrowedFd>,
) -> io::Result<()> {
let body =
serde_json::to_vec(msg).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
debug_assert!(
!body.is_empty(),
"zero-length messages are reserved for EOF"
);
if body.len() > MAX_MSG {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"zerocopy proto message too large",
));
}
let mut iov = libc::iovec {
iov_base: body.as_ptr() as *mut libc::c_void,
iov_len: body.len(),
};
// Control buffer for one fd: CMSG_SPACE(4) = 24 bytes on 64-bit; [u64; 4] gives 32 bytes at
// the 8-byte alignment `cmsghdr` requires.
let mut cmsg_store = [0u64; 4];
// SAFETY: `mhdr` is a plain-old-data C struct for which all-zero is a valid value.
let mut mhdr: libc::msghdr = unsafe { std::mem::zeroed() };
mhdr.msg_iov = &mut iov;
mhdr.msg_iovlen = 1;
if let Some(fd) = pass_fd {
mhdr.msg_control = cmsg_store.as_mut_ptr() as *mut libc::c_void;
// SAFETY: `CMSG_SPACE`/`CMSG_LEN` are pure size computations (no memory access).
// `CMSG_FIRSTHDR(&mhdr)` returns a pointer into `cmsg_store` (non-null: msg_controllen
// ≥ one cmsghdr), which is live, 8-aligned, and large enough (32 ≥ CMSG_SPACE(4) = 24)
// for the header fields and the 4-byte fd written via `CMSG_DATA`; `write_unaligned`
// handles the data area's byte alignment. All writes stay within `cmsg_store`, which
// outlives the synchronous `sendmsg` below.
unsafe {
mhdr.msg_controllen = libc::CMSG_SPACE(4) as _;
let c = libc::CMSG_FIRSTHDR(&mhdr);
(*c).cmsg_level = libc::SOL_SOCKET;
(*c).cmsg_type = libc::SCM_RIGHTS;
(*c).cmsg_len = libc::CMSG_LEN(4) as _;
std::ptr::write_unaligned(libc::CMSG_DATA(c) as *mut i32, fd.as_raw_fd());
}
}
// SAFETY: `sock` is the caller's live socket; `mhdr` points at the live `iov` (over `body`,
// which outlives the call) and — when an fd is passed — at `cmsg_store` (ditto). `sendmsg`
// only reads these buffers. The kernel dups the fd into the message; our `BorrowedFd` stays
// owned by the caller.
let n = unsafe { libc::sendmsg(sock.as_raw_fd(), &mhdr, libc::MSG_NOSIGNAL) };
if n < 0 {
return Err(io::Error::last_os_error());
}
if n as usize != body.len() {
return Err(io::Error::new(
io::ErrorKind::WriteZero,
"short sendmsg on SEQPACKET socket",
));
}
Ok(())
}
/// Receive one message (+ up to one `SCM_RIGHTS` fd). `buf` is a caller-owned scratch buffer
/// (grown to [`MAX_MSG`] once, then reused frame to frame). Errors:
/// `UnexpectedEof` = the peer is gone; `WouldBlock` = the [`set_recv_timeout`] expired.
pub fn recv<T: DeserializeOwned>(
sock: BorrowedFd,
buf: &mut Vec<u8>,
) -> io::Result<(T, Option<OwnedFd>)> {
buf.resize(MAX_MSG, 0);
let mut iov = libc::iovec {
iov_base: buf.as_mut_ptr() as *mut libc::c_void,
iov_len: buf.len(),
};
let mut cmsg_store = [0u64; 4];
// SAFETY: `mhdr` is a plain-old-data C struct for which all-zero is a valid value.
let mut mhdr: libc::msghdr = unsafe { std::mem::zeroed() };
mhdr.msg_iov = &mut iov;
mhdr.msg_iovlen = 1;
mhdr.msg_control = cmsg_store.as_mut_ptr() as *mut libc::c_void;
mhdr.msg_controllen = std::mem::size_of_val(&cmsg_store) as _;
// SAFETY: `sock` is the caller's live socket. `recvmsg` writes at most `iov_len` bytes into
// `buf` (live for the call) and at most `msg_controllen` control bytes into `cmsg_store`
// (live, 8-aligned). `MSG_CMSG_CLOEXEC` makes any received fd CLOEXEC atomically.
let n = unsafe { libc::recvmsg(sock.as_raw_fd(), &mut mhdr, libc::MSG_CMSG_CLOEXEC) };
if n < 0 {
return Err(io::Error::last_os_error());
}
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"zerocopy proto peer closed",
));
}
// Collect a passed fd (if any) BEFORE any early return below, so it can't leak.
let mut got_fd: Option<OwnedFd> = None;
// SAFETY: `CMSG_FIRSTHDR`/`CMSG_NXTHDR` walk the control area the kernel just wrote inside
// `cmsg_store` (bounded by the updated `mhdr.msg_controllen`), returning either null or a
// pointer to a complete `cmsghdr` within it — each dereference reads kernel-initialized
// fields in bounds. For an `SCM_RIGHTS` cmsg the data area holds whole `i32` fds; we read the
// first via `read_unaligned`. The kernel gave us ownership of that fd (it is a fresh
// descriptor in our table), so `OwnedFd::from_raw_fd` takes sole ownership — any previously
// collected `got_fd` is dropped (closed) first, so nothing leaks even with multiple cmsgs.
unsafe {
let mut c = libc::CMSG_FIRSTHDR(&mhdr);
while !c.is_null() {
if (*c).cmsg_level == libc::SOL_SOCKET && (*c).cmsg_type == libc::SCM_RIGHTS {
let fd = std::ptr::read_unaligned(libc::CMSG_DATA(c) as *const i32);
if fd >= 0 {
got_fd = Some(OwnedFd::from_raw_fd(fd));
}
}
c = libc::CMSG_NXTHDR(&mhdr, c);
}
}
if mhdr.msg_flags & libc::MSG_TRUNC != 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"zerocopy proto message truncated",
));
}
let msg = serde_json::from_slice(&buf[..n as usize])
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok((msg, got_fd))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::imp::ipc;
use std::io::{Read, Write};
use std::os::fd::AsFd;
/// The vocabulary survives the wire in both directions. (The framing itself — fds, EOF,
/// timeouts, the descriptor cap — is exercised in [`ipc`]; this only pins the message types.)
#[test]
fn round_trip_both_directions() {
let (a, b) = ipc::socketpair_seqpacket().unwrap();
fn round_trip_no_fd() {
let (a, b) = socketpair_seqpacket().unwrap();
let mut buf = Vec::new();
let req = Request::Import {
key: 0xdead_beef_u64,
@@ -129,8 +321,8 @@ mod tests {
stride: 5120 * 4,
has_fd: false,
};
ipc::send(a.as_fd(), &req, None).unwrap();
let (got, fd) = ipc::recv::<Request>(b.as_fd(), &mut buf).unwrap();
send(a.as_fd(), &req, None).unwrap();
let (got, fd) = recv::<Request>(b.as_fd(), &mut buf).unwrap();
assert_eq!(got, req);
assert!(fd.is_none());
@@ -144,9 +336,64 @@ mod tests {
uv: Some((vec![2u8; 64], 5632)),
}),
};
ipc::send(b.as_fd(), &reply, None).unwrap();
let (got, fd) = ipc::recv::<Reply>(a.as_fd(), &mut buf).unwrap();
send(b.as_fd(), &reply, None).unwrap();
let (got, fd) = recv::<Reply>(a.as_fd(), &mut buf).unwrap();
assert_eq!(got, reply);
assert!(fd.is_none());
}
#[test]
fn passes_an_fd() {
let (a, b) = socketpair_seqpacket().unwrap();
let mut buf = Vec::new();
// A pipe stands in for a dmabuf: pass the read end, write through the original write end,
// and read the bytes back through the RECEIVED fd.
let (mut pr, mut pw) = std::io::pipe().unwrap();
send(a.as_fd(), &Request::ClearCache, Some(pr.as_fd())).unwrap();
let (got, fd) = recv::<Request>(b.as_fd(), &mut buf).unwrap();
assert_eq!(got, Request::ClearCache);
let fd = fd.expect("fd should have been passed");
pw.write_all(b"hello").unwrap();
drop(pw);
let mut file = std::fs::File::from(fd);
let mut s = String::new();
file.read_to_string(&mut s).unwrap();
assert_eq!(s, "hello");
// The original read end still works independently of the passed dup.
let mut nothing = [0u8; 1];
assert_eq!(pr.read(&mut nothing).unwrap(), 0);
}
#[test]
fn eof_when_peer_closes() {
let (a, b) = socketpair_seqpacket().unwrap();
drop(a);
let mut buf = Vec::new();
let err = recv::<Reply>(b.as_fd(), &mut buf).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
}
#[test]
fn send_to_dead_peer_is_epipe_not_sigpipe() {
let (a, b) = socketpair_seqpacket().unwrap();
drop(b);
let err = send(a.as_fd(), &Request::ClearCache, None).unwrap_err();
// MSG_NOSIGNAL: a dead peer surfaces as EPIPE (BrokenPipe), never a process-killing signal.
assert_eq!(err.kind(), io::ErrorKind::BrokenPipe);
}
#[test]
fn recv_timeout_fires() {
let (a, _b) = socketpair_seqpacket().unwrap();
set_recv_timeout(a.as_fd(), Some(Duration::from_millis(50))).unwrap();
let mut buf = Vec::new();
let err = recv::<Reply>(a.as_fd(), &mut buf).unwrap_err();
assert!(
matches!(
err.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
),
"unexpected error kind: {err:?}"
);
}
}
+22 -23
View File
@@ -14,8 +14,7 @@
use super::cuda::{self, CUdeviceptr, DeviceBuffer};
use super::egl::{DmabufPlane, EglImporter};
use super::ipc;
use super::proto::{BufferDesc, ImportKind, Reply, Request, PROTO_VERSION};
use super::proto::{self, BufferDesc, ImportKind, Reply, Request};
use anyhow::{bail, Context, Result};
use std::collections::{HashMap, VecDeque};
use std::io;
@@ -28,7 +27,7 @@ const FD_CACHE_CAP: usize = 64;
/// Entry point for the hidden `zerocopy-worker` subcommand. `args` are the subcommand's own
/// arguments (`--fd N`, default 3 — the socket end the spawning host `dup2`'d in).
pub fn run_from_args(args: &[String]) -> Result<()> {
// The host execs this worker through its pinned exe fd (`ipc::self_exe`), so the kernel
// The host execs this worker through its pinned exe fd (`client::self_exe`), so the kernel
// derives our comm from the exec path's basename — a meaningless fd number. Rename so
// `top`/`pkill` see the worker.
// SAFETY: `PR_SET_NAME` copies at most 16 bytes from the given pointer; the C-string literal
@@ -73,7 +72,7 @@ fn run(sock: OwnedFd) -> Result<()> {
Err(e) => {
// Init failure is an ANSWER, not a crash: the host falls back to the CPU path,
// exactly like an in-process `EglImporter::new()` failure.
let _ = ipc::send(
let _ = proto::send(
sock.as_fd(),
&Reply::InitErr {
message: format!("{e:#}"),
@@ -83,10 +82,10 @@ fn run(sock: OwnedFd) -> Result<()> {
return Ok(());
}
};
ipc::send(
proto::send(
sock.as_fd(),
&Reply::Ready {
version: PROTO_VERSION,
version: proto::PROTO_VERSION,
},
None,
)
@@ -125,7 +124,7 @@ pub(crate) struct ImportReq {
pub(crate) fn serve(sock: &OwnedFd, backend: &mut dyn ImportBackend) -> Result<()> {
let mut buf = Vec::new();
loop {
let (req, fd) = match ipc::recv::<Request>(sock.as_fd(), &mut buf) {
let (req, fd) = match proto::recv::<Request>(sock.as_fd(), &mut buf) {
Ok(v) => v,
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(()),
Err(e) => return Err(e).context("worker recv"),
@@ -174,7 +173,7 @@ pub(crate) fn serve(sock: &OwnedFd, backend: &mut dyn ImportBackend) -> Result<(
/// Send a reply; `Ok(true)` means the host is gone (EPIPE) and the loop should end quietly.
fn send_or_eof(sock: &OwnedFd, reply: &Reply) -> Result<bool> {
match ipc::send(sock.as_fd(), reply, None) {
match proto::send(sock.as_fd(), reply, None) {
Ok(()) => Ok(false),
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(true),
Err(e) => Err(e).context("worker send"),
@@ -435,7 +434,7 @@ mod tests {
mpsc::Receiver<String>,
std::thread::JoinHandle<Result<()>>,
) {
let (host, worker) = ipc::socketpair_seqpacket().unwrap();
let (host, worker) = proto::socketpair_seqpacket().unwrap();
let (tx, rx) = mpsc::channel();
let join = std::thread::spawn(move || {
let mut backend = MockBackend { calls: tx, next: 0 };
@@ -463,8 +462,8 @@ mod tests {
let (host, rx, join) = start_server();
let mut buf = Vec::new();
ipc::send(host.as_fd(), &Request::Modifiers { fourcc: 42 }, None).unwrap();
let (reply, _) = ipc::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
proto::send(host.as_fd(), &Request::Modifiers { fourcc: 42 }, None).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
assert_eq!(
reply,
Reply::Modifiers {
@@ -473,8 +472,8 @@ mod tests {
);
// First import delivers the desc; the second (same mock id sequence continues) doesn't.
ipc::send(host.as_fd(), &import_req(1, false), None).unwrap();
let (reply, _) = ipc::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
proto::send(host.as_fd(), &import_req(1, false), None).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
match reply {
Reply::Frame {
id: 0,
@@ -482,8 +481,8 @@ mod tests {
} => {}
other => panic!("unexpected reply {other:?}"),
}
ipc::send(host.as_fd(), &import_req(1, false), None).unwrap();
let (reply, _) = ipc::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
proto::send(host.as_fd(), &import_req(1, false), None).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
assert_eq!(reply, Reply::Frame { id: 1, desc: None });
// The descriptor itself must cross the socket: an import WITH an fd rides SCM_RIGHTS and
@@ -491,26 +490,26 @@ mod tests {
// received fd (e.g. `backend.import(&req, None)`) would only be caught here.
let (pr, _pw) = std::io::pipe().unwrap();
let sent_ino = fd_ino(pr.as_fd().as_raw_fd());
ipc::send(host.as_fd(), &import_req(3, true), Some(pr.as_fd())).unwrap();
let (reply, _) = ipc::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
proto::send(host.as_fd(), &import_req(3, true), Some(pr.as_fd())).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
assert_eq!(reply, Reply::Frame { id: 2, desc: None });
// A missing worker-side fd is a NeedFd reply (host resends), not a failure.
ipc::send(host.as_fd(), &import_req(0xfeed, false), None).unwrap();
let (reply, _) = ipc::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
proto::send(host.as_fd(), &import_req(0xfeed, false), None).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
assert_eq!(reply, Reply::NeedFd);
// A failed import is an Err reply, not a dead worker.
ipc::send(host.as_fd(), &import_req(0xbad, false), None).unwrap();
let (reply, _) = ipc::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
proto::send(host.as_fd(), &import_req(0xbad, false), None).unwrap();
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
match reply {
Reply::Err { message } => assert!(message.contains("scripted failure")),
other => panic!("unexpected reply {other:?}"),
}
// Fire-and-forget ops reach the backend without replies.
ipc::send(host.as_fd(), &Request::Release { id: 0 }, None).unwrap();
ipc::send(host.as_fd(), &Request::ClearCache, None).unwrap();
proto::send(host.as_fd(), &Request::Release { id: 0 }, None).unwrap();
proto::send(host.as_fd(), &Request::ClearCache, None).unwrap();
// Closing the host end terminates serve() cleanly.
drop(host);
-1
View File
@@ -204,7 +204,6 @@ include = ["PunktfunkEndReason"]
"RICH_INPUT_MAGIC" = "PUNKTFUNK_RICH_INPUT_MAGIC"
"RUMBLE_V1_LEN" = "PUNKTFUNK_RUMBLE_V1_LEN"
"RUMBLE_V2_LEN" = "PUNKTFUNK_RUMBLE_V2_LEN"
"RUMBLE_V3_LEN" = "PUNKTFUNK_RUMBLE_V3_LEN"
"SETUP_FAILED_CLOSE_CODE" = "PUNKTFUNK_SETUP_FAILED_CLOSE_CODE"
"TAG_LEN" = "PUNKTFUNK_TAG_LEN"
"TRIGGER_EFFECT_MAX" = "PUNKTFUNK_TRIGGER_EFFECT_MAX"

Some files were not shown because too many files have changed in this diff Show More