Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bde4276632 | ||
|
|
147bc82130 | ||
|
|
30c47eb691 | ||
|
|
bd26949e0a | ||
|
|
61dfc3dadc | ||
|
|
1befa8a2c4 | ||
|
|
f8cde0adaf | ||
|
|
e93947969f | ||
|
|
159bbdbfc2 | ||
|
|
002702bcec | ||
|
|
88c1e94d94 | ||
|
|
657e82cd29 | ||
|
|
e283f17ab4 | ||
|
|
1fc184516a | ||
|
|
7a8f63e906 | ||
|
|
5ca0dfdcd2 | ||
|
|
a14b000c9b | ||
|
|
35b5ee6a36 | ||
|
|
548eb4fa14 | ||
|
|
84faeb1bf1 | ||
|
|
ad63994cb9 | ||
|
|
bac63059a9 | ||
|
|
3daead7d71 | ||
|
|
18d0009c35 | ||
|
|
8ef5350431 | ||
|
|
522ac7bd49 | ||
|
|
8ab4918923 | ||
|
|
09bac99090 | ||
|
|
5db3b3c4fd | ||
|
|
a23c028492 | ||
|
|
5b3ea6e8db | ||
|
|
ffb1ecfebe | ||
|
|
08e462fee9 | ||
|
|
2590238b8f | ||
|
|
beb639f067 | ||
|
|
90450ff1f6 | ||
|
|
890b67a863 | ||
|
|
9cdbfabd4d | ||
|
|
fdef4c90ce | ||
|
|
8508f8f3c3 | ||
|
|
6695300b67 | ||
|
|
73d435b967 | ||
|
|
c7df7b45af | ||
|
|
5d7091bf87 | ||
|
|
e19f11bb0d | ||
|
|
7f1f7ba87c | ||
|
|
d39843a858 | ||
|
|
fb309e0262 | ||
|
|
94c2f62490 | ||
|
|
2b1843ed1c | ||
|
|
4f9071b980 | ||
|
|
1317901122 | ||
|
|
620f017d9a | ||
|
|
854b14a52e | ||
|
|
bd5735b803 | ||
|
|
7a9fa4501c | ||
|
|
d87a8df28d | ||
|
|
46390739d8 | ||
|
|
77f0a25d18 | ||
|
|
3500e95660 | ||
|
|
f9fe496dbc | ||
|
|
13438b1287 | ||
|
|
ae35e8b4d7 | ||
|
|
f34acf1d73 | ||
|
|
bc9201d136 | ||
|
|
003ce8bea7 | ||
|
|
235b8e55d4 | ||
|
|
d2a2bcc25d | ||
|
|
0b252403cd | ||
|
|
0ab17ee81d | ||
|
|
4e04c2bbf8 | ||
|
|
31aef4b09f | ||
|
|
97928516a0 | ||
|
|
d498ff4a60 | ||
|
|
bbc0513f0c | ||
|
|
d13d253c2f | ||
|
|
2de604ecab | ||
|
|
f26d21125d | ||
|
|
4f8cce6751 | ||
|
|
4a4118e3ce | ||
|
|
dcfba07803 | ||
|
|
99f2130b28 | ||
|
|
f266636392 | ||
|
|
516a295432 | ||
|
|
2c190b27b4 | ||
|
|
3cfa5ca194 | ||
|
|
bf913c5706 | ||
|
|
5bd92dac5d | ||
|
|
e8a4f54c07 | ||
|
|
f80636f901 | ||
|
|
0f79587dd6 | ||
|
|
651a7a82a1 | ||
|
|
4d383811c0 | ||
|
|
42ee6c5628 | ||
|
|
08eaf337e8 | ||
|
|
39869031be | ||
|
|
55f361cb92 | ||
|
|
2079411f4f | ||
|
|
4d1a1348c0 |
@@ -280,6 +280,21 @@ 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.
|
||||
|
||||
@@ -310,8 +310,14 @@ 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-host -p punktfunk-encode-worker
|
||||
|
||||
- name: Build host .deb (FFmpeg bundled)
|
||||
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
|
||||
@@ -320,6 +326,17 @@ 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.
|
||||
@@ -344,10 +361,45 @@ 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
|
||||
|
||||
@@ -357,6 +409,7 @@ 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
|
||||
|
||||
@@ -387,6 +440,26 @@ 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
|
||||
|
||||
@@ -7,10 +7,24 @@
|
||||
# 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, 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.
|
||||
# * 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.
|
||||
# * 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
|
||||
@@ -22,6 +36,12 @@
|
||||
# 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
|
||||
@@ -66,6 +86,10 @@ 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:
|
||||
@@ -165,3 +189,13 @@ 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
|
||||
|
||||
@@ -103,7 +103,11 @@ 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.
|
||||
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
|
||||
# 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
|
||||
# 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 /
|
||||
@@ -155,6 +159,20 @@ 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 }}
|
||||
@@ -206,10 +224,26 @@ 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
|
||||
|
||||
@@ -227,9 +261,35 @@ 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 }}
|
||||
@@ -270,6 +330,19 @@ 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.
|
||||
@@ -310,3 +383,26 @@ 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[*]}"
|
||||
|
||||
+347
-3
@@ -12,9 +12,339 @@ 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
|
||||
|
||||
47 commits since v0.25.0.
|
||||
52 commits since v0.25.0.
|
||||
|
||||
### Versions
|
||||
|
||||
@@ -290,7 +620,19 @@ same shader cores a game saturates; NVENC is immune because it has its own ASIC.
|
||||
ladder REALTIME → HIGH → no-priority, stepping only on refusal; a refused class can never fail the
|
||||
open. The extension probe reuses the `dev_ext_props` already fetched for `queue_family_foreign` and
|
||||
takes KHR or the EXT alias — the same spelling pf-zerocopy probes, so the two cannot disagree.
|
||||
⭐ **Needs `CAP_SYS_NICE`**, which the packaging now grants; without it the lever does nothing.
|
||||
⭐ **Needs `CAP_SYS_NICE`**, which the packaging granted in `0.26.0-1`; without it the lever does
|
||||
nothing.
|
||||
🛑 **Corrected in `0.26.0-2`: the packaging no longer grants it, and must not.** Every channel that
|
||||
did (Arch `.install`, RPM `%caps()`, the Bazzite sysext image, the deb postinst, the NixOS
|
||||
`security.wrappers` entry) broke desktop streaming on KDE outright — field-reported on CachyOS and
|
||||
Bazzite as `KWin does not expose zkde_screencast_unstable_v1 to this client`. KWin identifies a
|
||||
client by resolving its `/proc/<pid>/exe` against an installed `.desktop`, and the kernel refuses
|
||||
that readlink to any reader whose effective set is not a superset of the target's **permitted**
|
||||
set (`cap_ptrace_access_check`) — KWin has no capabilities, so a capability-carrying host is
|
||||
unidentifiable and the restricted globals are never advertised. Neither `prctl(PR_SET_DUMPABLE, 1)`
|
||||
nor systemd `AmbientCapabilities=` rescues it; only an uncapped process is identifiable. The lever
|
||||
therefore stays wired but unexercised on a stock install (the ladder degrades to default priority),
|
||||
and is opt-in for gamescope-only hosts, which have no such identity check.
|
||||
- **PW5 — two encoder handles.** `Encoder::Impl` owns exactly one each of `wavelet_img_high_res`,
|
||||
`bucket_buffer`, `meta_buffer`, `block_stat_buffer`, `payload_data`, `quant_buffer`, and
|
||||
`Impl::encode` *opens* by discarding them (an image barrier with `VK_IMAGE_LAYOUT_UNDEFINED` as the
|
||||
@@ -414,7 +756,9 @@ emulator itself would land it outside both.
|
||||
|
||||
⏳ **Owed on glass:** iPhone + Bluetooth listen, Apple TV stats overlay, MacBook audio listen, the
|
||||
Deck HEVC/4:4:4 retest, a Windows wake-from-sleep cycle, and the PyroWave-under-game-load A/B on a
|
||||
Linux host with `CAP_SYS_NICE` actually granted — the number this whole wave is aimed at.
|
||||
Linux host with `CAP_SYS_NICE` actually granted — the number this whole wave is aimed at. ⚠ That
|
||||
last one now needs a **gamescope-only** host, or a hand-granted capability on a box you are not
|
||||
streaming the KDE desktop from: see the `0.26.0-2` correction under PW1 above.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+46
-35
@@ -994,7 +994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
@@ -1114,7 +1114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.26.0"
|
||||
version = "0.27.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.26.0"
|
||||
version = "0.27.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2463,7 +2463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2498,7 +2498,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2988,7 +2988,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-bitstream"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"tracing",
|
||||
@@ -2996,7 +2996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3017,7 +3017,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3052,7 +3052,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3070,7 +3070,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3091,7 +3091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-dxvadec"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3101,7 +3101,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3118,6 +3118,8 @@ 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)",
|
||||
@@ -3125,7 +3127,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -3137,7 +3139,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -3151,11 +3153,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3184,14 +3186,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3206,7 +3208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3214,7 +3216,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-update-check"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -3226,7 +3228,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vaadec"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"cros-codecs",
|
||||
"pf-bitstream",
|
||||
@@ -3235,7 +3237,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3268,7 +3270,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vkdecode"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"cros-codecs",
|
||||
@@ -3279,7 +3281,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3291,7 +3293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3514,7 +3516,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3525,7 +3527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3543,7 +3545,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3560,7 +3562,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3575,7 +3577,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"mdns-sd",
|
||||
@@ -3594,7 +3596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3624,9 +3626,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-encode-worker"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"pf-encode",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3711,7 +3722,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3725,7 +3736,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3748,7 +3759,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+9
-1
@@ -4,6 +4,9 @@ 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",
|
||||
@@ -46,6 +49,11 @@ 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
|
||||
@@ -57,7 +65,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.26.0"
|
||||
version = "0.27.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -84,7 +84,11 @@ 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.
|
||||
"oled", "OLED",
|
||||
// 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",
|
||||
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),
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "glur",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/joogps/Glur.git",
|
||||
"state" : {
|
||||
"revision" : "ba4f05d3c9a608ec773b9305f2af6089390de68a"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -16,6 +16,17 @@ 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.
|
||||
@@ -51,7 +62,12 @@ 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"]),
|
||||
.executableTarget(
|
||||
name: "PunktfunkClient",
|
||||
dependencies: [
|
||||
"PunktfunkKit",
|
||||
.product(name: "GlurBackdrop", package: "Glur"),
|
||||
]),
|
||||
// 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,6 +11,12 @@
|
||||
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, ); }; };
|
||||
@@ -88,6 +94,8 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AA0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
||||
EE0000000000000000000012 /* Glur in Frameworks */,
|
||||
EE0000000000000000000013 /* GlurBackdrop in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -96,6 +104,8 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BB0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
||||
EE0000000000000000000014 /* Glur in Frameworks */,
|
||||
EE0000000000000000000015 /* GlurBackdrop in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -105,6 +115,8 @@
|
||||
files = (
|
||||
CC0000000000000000000005 /* PunktfunkKit in Frameworks */,
|
||||
DD0000000000000000000003 /* SwiftUINavigationTransitions in Frameworks */,
|
||||
EE0000000000000000000016 /* Glur in Frameworks */,
|
||||
EE0000000000000000000017 /* GlurBackdrop in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -175,6 +187,8 @@
|
||||
name = Punktfunk;
|
||||
packageProductDependencies = (
|
||||
AA0000000000000000000006 /* PunktfunkKit */,
|
||||
EE0000000000000000000002 /* Glur */,
|
||||
EE0000000000000000000003 /* GlurBackdrop */,
|
||||
);
|
||||
productName = Punktfunk;
|
||||
productReference = AA0000000000000000000001 /* Punktfunk.app */;
|
||||
@@ -201,6 +215,8 @@
|
||||
name = "Punktfunk-iOS";
|
||||
packageProductDependencies = (
|
||||
BB0000000000000000000006 /* PunktfunkKit */,
|
||||
EE0000000000000000000004 /* Glur */,
|
||||
EE0000000000000000000005 /* GlurBackdrop */,
|
||||
);
|
||||
productName = "Punktfunk-iOS";
|
||||
productReference = BB0000000000000000000001 /* Punktfunk-iOS.app */;
|
||||
@@ -226,6 +242,8 @@
|
||||
packageProductDependencies = (
|
||||
CC0000000000000000000006 /* PunktfunkKit */,
|
||||
DD0000000000000000000002 /* SwiftUINavigationTransitions */,
|
||||
EE0000000000000000000006 /* Glur */,
|
||||
EE0000000000000000000007 /* GlurBackdrop */,
|
||||
);
|
||||
productName = "Punktfunk-tvOS";
|
||||
productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */;
|
||||
@@ -283,6 +301,7 @@
|
||||
packageReferences = (
|
||||
AA000000000000000000000F /* XCLocalSwiftPackageReference "." */,
|
||||
DD0000000000000000000001 /* XCRemoteSwiftPackageReference "swiftui-navigation-transitions" */,
|
||||
EE0000000000000000000001 /* XCRemoteSwiftPackageReference "Glur" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = AA0000000000000000000008 /* Products */;
|
||||
@@ -848,6 +867,14 @@
|
||||
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 */
|
||||
@@ -868,6 +895,36 @@
|
||||
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;
|
||||
|
||||
+9
-1
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"originHash" : "5d17a752eb57d190a90cbd663718ff44034b24fe0ae1baafea7677db2d49da6f",
|
||||
"originHash" : "bb1ce9bc6042f166bd0aad78a15081e673781d3a90fa52fd8ec8a08875878ef6",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "glur",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/joogps/Glur.git",
|
||||
"state" : {
|
||||
"revision" : "ba4f05d3c9a608ec773b9305f2af6089390de68a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "objc-runtime-tools",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// 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,6 +15,7 @@ import WidgetKit
|
||||
struct PunktfunkWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
HostsWidget()
|
||||
LibraryWidget()
|
||||
PunktfunkSessionLiveActivity()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,12 @@ 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
|
||||
@@ -115,6 +121,23 @@ 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,
|
||||
@@ -181,6 +204,36 @@ 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,
|
||||
@@ -368,8 +421,21 @@ 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)
|
||||
.sheet(item: $pairingTarget) { host in
|
||||
// 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
|
||||
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
||||
#endif
|
||||
}
|
||||
.sheet(item: $speedTestTarget) { host in
|
||||
SpeedTestSheet(host: host)
|
||||
@@ -409,9 +475,102 @@ 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 }, set: { if !$0 { deepLinkNotice = nil } })
|
||||
Binding(
|
||||
get: { deepLinkNotice != nil && !consolePromptShowing },
|
||||
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?> {
|
||||
@@ -420,19 +579,37 @@ 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 }, set: { if !$0 { approvalChoice = nil } })
|
||||
Binding(
|
||||
get: { approvalChoice != nil && !consolePromptShowing },
|
||||
set: { if !$0 { approvalChoice = nil } })
|
||||
}
|
||||
|
||||
private var awaitingApprovalPresented: Binding<Bool> {
|
||||
Binding(get: { awaitingApproval != nil }, set: { if !$0 { awaitingApproval = nil } })
|
||||
Binding(
|
||||
get: { awaitingApproval != nil && !consolePromptShowing },
|
||||
set: { if !$0 { awaitingApproval = nil } })
|
||||
}
|
||||
|
||||
private var connectionErrorPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: {
|
||||
guard model.errorMessage != nil else { return false }
|
||||
#if os(macOS)
|
||||
/// 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)
|
||||
// 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
|
||||
@@ -441,10 +618,14 @@ 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
|
||||
},
|
||||
if fullscreenForSession && isFullscreen { return false }
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
private var connectionErrorPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { connectionErrorReady && !consolePromptShowing },
|
||||
set: { if !$0 { model.errorMessage = nil } })
|
||||
}
|
||||
|
||||
@@ -487,10 +668,20 @@ struct ContentView: View {
|
||||
?? "That link is malformed and was ignored."
|
||||
return
|
||||
}
|
||||
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."
|
||||
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."
|
||||
return
|
||||
}
|
||||
// Resolve the one-off profile BEFORE anything happens: an unknown or ambiguous reference
|
||||
@@ -544,6 +735,38 @@ 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
|
||||
@@ -576,9 +799,11 @@ struct ContentView: View {
|
||||
if gamepadUIActive {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget, waker: waker,
|
||||
libraryTarget: $libraryTarget, pairingTarget: $pairingTarget,
|
||||
onPaired: handlePaired, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle)
|
||||
launchTitle: launchTitle,
|
||||
promptActive: consolePromptShowing)
|
||||
} else {
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
@@ -593,9 +818,11 @@ struct ContentView: View {
|
||||
if gamepadUIActive {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: $libraryTarget, waker: waker,
|
||||
libraryTarget: $libraryTarget, pairingTarget: $pairingTarget,
|
||||
onPaired: handlePaired, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle)
|
||||
launchTitle: launchTitle,
|
||||
promptActive: consolePromptShowing)
|
||||
// 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,6 +13,8 @@ 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
|
||||
@@ -48,7 +50,7 @@ struct GamepadAddHostView: View {
|
||||
isActive: controllerActive && editing == nil
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
.frame(maxWidth: metrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -61,25 +63,28 @@ struct GamepadAddHostView: View {
|
||||
if !compact {
|
||||
Text("Hosts on this network appear automatically — add one by address "
|
||||
+ "for everything else.")
|
||||
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72, alignment: .leading)
|
||||
.frame(maxWidth: metrics.rowMaxWidth * 0.72, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
.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, compact ? 12 : 18)
|
||||
.padding(
|
||||
.bottom,
|
||||
gamepadLegendBottomPadding(
|
||||
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
|
||||
.padding(.top, compact ? 6 : 10)
|
||||
.background { GamepadTrayScrim(edge: .bottom) }
|
||||
.background { GamepadTrayBlur(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).
|
||||
@@ -148,17 +153,28 @@ 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"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
.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 {
|
||||
GamepadHintBar(hints: [
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel"),
|
||||
.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)
|
||||
}
|
||||
@@ -191,7 +207,7 @@ struct GamepadAddHostView: View {
|
||||
}
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
let m = GamepadFormMetrics.self
|
||||
let m = metrics
|
||||
return HStack(spacing: 14) {
|
||||
if row.isAction {
|
||||
Label("Add Host", systemImage: "plus.circle.fill")
|
||||
@@ -268,6 +284,15 @@ 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,6 +191,16 @@ 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()
|
||||
@@ -432,8 +442,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/perspective 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 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
|
||||
@@ -442,6 +452,23 @@ 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.
|
||||
@@ -485,14 +512,15 @@ 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)
|
||||
.rotation3DEffect(
|
||||
.degrees(side * -64 * away),
|
||||
axis: (x: 0, y: 1, z: 0),
|
||||
anchor: .center,
|
||||
perspective: 0.65)
|
||||
.scaleEffect(x: turn, y: 1, anchor: side < 0 ? .trailing : .leading)
|
||||
.offset(y: 34 * away)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,20 +5,36 @@
|
||||
// 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 active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via
|
||||
/// `sfSymbolsName`; a generic fallback before a controller profile resolves.
|
||||
/// 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.
|
||||
///
|
||||
/// @MainActor: GamepadManager is main-actor-bound (inside a View body this was implicit).
|
||||
@MainActor
|
||||
func buttonGlyph(
|
||||
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, fallback: String
|
||||
) -> String {
|
||||
GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName
|
||||
?? fallback
|
||||
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)
|
||||
}
|
||||
|
||||
/// Top padding for a gamepad screen's pinned title. macOS gets extra clearance — the launcher
|
||||
@@ -68,43 +84,251 @@ func gamepadTitleSize(compact: Bool) -> CGFloat {
|
||||
}
|
||||
|
||||
/// Metrics shared by the gamepad form screens' glass rows (GamepadSettingsView,
|
||||
/// 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
|
||||
/// 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
|
||||
/// The option band's (GamepadOptionBand) fixed stage inside a choice row.
|
||||
static let bandWidth: CGFloat = 240
|
||||
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
|
||||
}
|
||||
#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 }
|
||||
}
|
||||
|
||||
@@ -114,39 +338,75 @@ 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
|
||||
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
|
||||
cell(hint)
|
||||
}
|
||||
}
|
||||
.font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
|
||||
.font(.geist(metrics.hintTextFont, .semibold, relativeTo: .subheadline))
|
||||
.foregroundStyle(ink.fg(0.85))
|
||||
.padding(Self.pad)
|
||||
.padding(metrics.hintPad)
|
||||
.consoleGlass(Capsule())
|
||||
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
|
||||
#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
|
||||
@@ -220,14 +480,20 @@ 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 ground, `.opacity` IS the multiply…
|
||||
// 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.
|
||||
.opacity(1 - 0.4 * calmMix)
|
||||
// …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.
|
||||
// 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.
|
||||
Self.color(palette.ground)
|
||||
.opacity(0.4 * calmMix)
|
||||
.opacity(0.4 * calmMix * (palette.light ? 0 : 1))
|
||||
.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
|
||||
@@ -363,59 +629,6 @@ 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,10 +65,24 @@ 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
|
||||
@@ -77,6 +91,11 @@ 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).
|
||||
@@ -213,19 +232,35 @@ struct GamepadHomeView: View {
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
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)
|
||||
legend
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) }
|
||||
@@ -248,6 +283,12 @@ 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,
|
||||
@@ -294,11 +335,14 @@ struct GamepadHomeView: View {
|
||||
/// transition's input drop, during which NOBODY polls.
|
||||
private var homeOwnsController: Bool {
|
||||
#if os(iOS)
|
||||
topScreen == nil && !transitioning
|
||||
topScreen == nil && !transitioning && !promptActive
|
||||
&& waker.waking == nil && model.phase != .connecting
|
||||
#else
|
||||
libraryTarget == nil && !showSettings && !showAddHost
|
||||
&& waker.waking == nil && model.phase != .connecting
|
||||
// `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
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -412,13 +456,22 @@ 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"))]
|
||||
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"),
|
||||
action: { tiles.first { $0.id == selection }?.activate() })]
|
||||
if libraryEnabled, selected?.hasLibrary == true {
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library",
|
||||
action: { openLibraryForSelected() }))
|
||||
}
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings"))
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings",
|
||||
action: { showSettings = true }))
|
||||
return hints
|
||||
}
|
||||
|
||||
@@ -573,11 +626,21 @@ private struct GamepadHostTile: View {
|
||||
}
|
||||
.padding(Self.pad)
|
||||
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||
// 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.
|
||||
// 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.)
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
|
||||
tint: tile.filled ? ink.accent(0.20) : nil)
|
||||
tint: tile.filled ? ink.accent(0.20) : nil,
|
||||
forceMaterial: true)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
|
||||
.strokeBorder(
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// 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 { GamepadTrayScrim(edge: .top) }
|
||||
.background { GamepadTrayBlur(edge: .top) }
|
||||
}
|
||||
// A hardware keyboard's Esc still closes, without chrome.
|
||||
.background {
|
||||
|
||||
@@ -119,6 +119,22 @@ 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()
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// 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,12 +21,14 @@ 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)"
|
||||
}
|
||||
}
|
||||
@@ -35,7 +37,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: return true
|
||||
case .settings, .addHost, .pair: return true
|
||||
case .library: return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,13 +204,19 @@ struct LibraryCoverflowView: View {
|
||||
|
||||
private var hints: [GamepadHint] {
|
||||
var hints: [GamepadHint] = []
|
||||
if onLaunch != nil {
|
||||
if let onLaunch {
|
||||
// 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"))
|
||||
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(\.buttonB, fallback: "b.circle"), text: "Close"))
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close",
|
||||
action: { onDismiss?() }))
|
||||
return hints
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@ 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.
|
||||
@@ -120,34 +127,103 @@ struct LibraryView: View {
|
||||
let launchers = games.filter(\.isLauncher)
|
||||
let titles = games.filter { !$0.isLauncher }
|
||||
let both = !launchers.isEmpty && !titles.isEmpty
|
||||
return ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if !launchers.isEmpty {
|
||||
if both { sectionHeader("Launchers") }
|
||||
tiles(launchers)
|
||||
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)
|
||||
}
|
||||
}
|
||||
if !titles.isEmpty {
|
||||
if both { sectionHeader("Games") }
|
||||
tiles(titles)
|
||||
.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 }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.padding()
|
||||
#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
|
||||
}
|
||||
}
|
||||
|
||||
#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) }
|
||||
.buttonStyle(.plain)
|
||||
Button { onLaunch(game.id) } label: {
|
||||
GameCard(game: game, artLoader: artLoader, selected: isKeyCursor(game))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.id(game.id)
|
||||
} else {
|
||||
GameCard(game: game, artLoader: artLoader)
|
||||
GameCard(game: game, artLoader: artLoader, selected: isKeyCursor(game))
|
||||
.id(game.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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))
|
||||
@@ -264,6 +340,9 @@ 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) {
|
||||
@@ -271,6 +350,12 @@ 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,7 +1,8 @@
|
||||
// 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), 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, 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 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
|
||||
@@ -51,6 +52,28 @@ 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 {
|
||||
@@ -97,6 +120,13 @@ 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,10 +43,27 @@ 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,7 +242,8 @@ private struct ShotGamepadHome: View {
|
||||
var body: some View {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
|
||||
onPaired: { _, _ in }, waker: waker,
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||
}
|
||||
}
|
||||
@@ -300,7 +301,8 @@ private struct ShotConnect: View {
|
||||
if gamepadUI {
|
||||
GamepadHomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
libraryTarget: .constant(nil), waker: waker,
|
||||
libraryTarget: .constant(nil), pairingTarget: .constant(nil),
|
||||
onPaired: { _, _ in }, waker: waker,
|
||||
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
|
||||
} else {
|
||||
ShotHome()
|
||||
|
||||
@@ -54,6 +54,15 @@ 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,22 +66,20 @@ 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)
|
||||
radius: width * 0.72,
|
||||
width: width)
|
||||
}
|
||||
}
|
||||
.frame(width: width)
|
||||
.clipped()
|
||||
// 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)
|
||||
}
|
||||
// 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.
|
||||
.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
|
||||
@@ -131,6 +129,9 @@ 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 }
|
||||
@@ -140,6 +141,17 @@ 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 {
|
||||
@@ -147,14 +159,18 @@ 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 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.
|
||||
// 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.
|
||||
content.drawingGroup()
|
||||
#else
|
||||
content
|
||||
@@ -164,17 +180,30 @@ 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)
|
||||
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate) * edgeFade(x)
|
||||
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.4)
|
||||
.offset(x: radius * sin(angle))
|
||||
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.55)
|
||||
.offset(x: x)
|
||||
.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,6 +46,8 @@ 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
|
||||
@@ -142,7 +144,7 @@ struct GamepadSettingsView: View {
|
||||
isActive: controllerActive
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
.frame(maxWidth: metrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -161,12 +163,12 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
.background { GamepadTrayBlur(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(focusedDetail)
|
||||
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.animation(.smooth(duration: 0.2), value: focusID)
|
||||
@@ -175,10 +177,13 @@ 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, compact ? 12 : 18)
|
||||
.padding(
|
||||
.bottom,
|
||||
gamepadLegendBottomPadding(
|
||||
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
|
||||
.padding(.top, compact ? 6 : 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayScrim(edge: .bottom) }
|
||||
.background { GamepadTrayBlur(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
|
||||
@@ -254,10 +259,18 @@ struct GamepadSettingsView: View {
|
||||
private func pill(_ t: GpSettingsTab) -> some View {
|
||||
let selected = t == tab
|
||||
return Text(t.rawValue)
|
||||
.font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote))
|
||||
.foregroundStyle(selected ? ink.fg : ink.fg(0.55))
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 7)
|
||||
.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)
|
||||
.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
|
||||
@@ -328,26 +341,39 @@ struct GamepadSettingsView: View {
|
||||
// shoulders exist at all (see `showsSectionHint`).
|
||||
let sections: [GamepadHint] = showsSectionHint
|
||||
? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
|
||||
text: "Section")]
|
||||
text: "Section", action: { step(tabBy: 1) })]
|
||||
: []
|
||||
// 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")]
|
||||
+ [.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||
action: { back() })]
|
||||
}
|
||||
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"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
.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() }),
|
||||
]
|
||||
}
|
||||
guard !store.hosts.isEmpty else {
|
||||
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")]
|
||||
return [.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
|
||||
action: { back() })]
|
||||
}
|
||||
return [
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"),
|
||||
.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() }),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -365,7 +391,7 @@ struct GamepadSettingsView: View {
|
||||
// MARK: - Row rendering
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
let m = GamepadFormMetrics.self
|
||||
let m = metrics
|
||||
// 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) {
|
||||
@@ -443,9 +469,9 @@ struct GamepadSettingsView: View {
|
||||
/// narrows the stage.
|
||||
private var bandWidth: CGFloat {
|
||||
#if os(iOS)
|
||||
hSizeClass == .compact && vSizeClass == .regular ? 170 : GamepadFormMetrics.bandWidth
|
||||
hSizeClass == .compact && vSizeClass == .regular ? 170 : metrics.bandWidth
|
||||
#else
|
||||
GamepadFormMetrics.bandWidth
|
||||
metrics.bandWidth
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -200,15 +200,16 @@ final class HostStore: ObservableObject {
|
||||
if let data = try? JSONEncoder().encode(hosts) {
|
||||
defaults.set(data, forKey: Self.key)
|
||||
}
|
||||
reloadHostsWidget() // the widget reads this store; any change refreshes its timeline
|
||||
reloadHostsWidget() // the widgets read this store; any change refreshes their timelines
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
private func reloadHostsWidget() {
|
||||
#if canImport(WidgetKit) && os(iOS)
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkHosts")
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkLibrary")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,12 @@ 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.
|
||||
@@ -117,8 +123,18 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
}
|
||||
.environment(\.colorScheme, scheme)
|
||||
#else
|
||||
if #available(iOS 26, macOS 26, *) {
|
||||
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
|
||||
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)
|
||||
} else {
|
||||
content
|
||||
.background {
|
||||
@@ -137,13 +153,21 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
||||
#if !os(tvOS)
|
||||
@available(iOS 26, macOS 26, *)
|
||||
private var glass: Glass {
|
||||
// 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)
|
||||
// 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)
|
||||
if interactive { g = g.interactive() }
|
||||
return g
|
||||
}
|
||||
@@ -154,8 +178,13 @@ 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.
|
||||
func consoleGlass<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = false) -> some View {
|
||||
modifier(ConsoleGlass(shape: shape, tint: tint, interactive: interactive))
|
||||
///
|
||||
/// `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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
// 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
|
||||
@@ -0,0 +1,86 @@
|
||||
// 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,19 +5,15 @@
|
||||
// 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
|
||||
@@ -30,9 +26,10 @@ struct PairSheet: View {
|
||||
#else
|
||||
@State private var clientName = UIDevice.current.name
|
||||
#endif
|
||||
@State private var busy = false
|
||||
@State private var errorText: String?
|
||||
@State private var token = CeremonyToken()
|
||||
@StateObject private var ceremony = PairCeremony()
|
||||
|
||||
private var busy: Bool { ceremony.busy }
|
||||
private var errorText: String? { ceremony.errorText }
|
||||
#if os(tvOS)
|
||||
private enum EditField: String, Identifiable {
|
||||
case pin, clientName
|
||||
@@ -64,7 +61,7 @@ struct PairSheet: View {
|
||||
}
|
||||
HStack(spacing: 32) {
|
||||
Button("Cancel", role: .cancel) {
|
||||
token.cancelled = true
|
||||
ceremony.abandon()
|
||||
dismiss()
|
||||
}
|
||||
if busy {
|
||||
@@ -78,7 +75,7 @@ struct PairSheet: View {
|
||||
.frame(maxWidth: 1000)
|
||||
.padding(60)
|
||||
.navigationTitle("Pair with \(host.displayName)")
|
||||
.onDisappear { token.cancelled = true }
|
||||
.onDisappear { ceremony.abandon() }
|
||||
.fullScreenCover(item: $editing) { field in
|
||||
switch field {
|
||||
case .pin:
|
||||
@@ -142,7 +139,7 @@ struct PairSheet: View {
|
||||
#endif
|
||||
HStack {
|
||||
Button("Cancel", role: .cancel) {
|
||||
token.cancelled = true
|
||||
ceremony.abandon()
|
||||
dismiss()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
@@ -180,7 +177,7 @@ struct PairSheet: View {
|
||||
.presentationDragIndicator(busy ? .hidden : .visible)
|
||||
#endif
|
||||
.interactiveDismissDisabled(busy)
|
||||
.onDisappear { token.cancelled = true } // any other dismissal path
|
||||
.onDisappear { ceremony.abandon() } // any other dismissal path
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -195,47 +192,11 @@ struct PairSheet: View {
|
||||
}
|
||||
|
||||
private func runCeremony() {
|
||||
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)?"
|
||||
}
|
||||
}
|
||||
ceremony.run(
|
||||
host: host.address, port: host.port, pin: pin, clientName: clientName
|
||||
) { fingerprint in
|
||||
onPaired(fingerprint)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// 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
|
||||
@@ -13,6 +19,12 @@ 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")
|
||||
@@ -60,12 +72,35 @@ 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.
|
||||
@@ -80,6 +115,35 @@ 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)]) }
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// "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,8 +43,21 @@ 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: kAudioHardwarePropertyDefaultInputDevice,
|
||||
mSelector: selector,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var dev = AudioDeviceID(0)
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
//
|
||||
// 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
|
||||
@@ -79,14 +83,53 @@ public final class SessionAudio {
|
||||
/// session's activate.
|
||||
private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session")
|
||||
#endif
|
||||
#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`.
|
||||
#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`.
|
||||
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
|
||||
}
|
||||
@@ -96,10 +139,14 @@ public final class SessionAudio {
|
||||
/// Engine teardown still belongs to stop().
|
||||
deinit {
|
||||
flag.stop()
|
||||
#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().
|
||||
// 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 let routeObserver { NotificationCenter.default.removeObserver(routeObserver) }
|
||||
if let mediaResetObserver {
|
||||
NotificationCenter.default.removeObserver(mediaResetObserver)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -120,6 +167,12 @@ 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(
|
||||
@@ -170,9 +223,17 @@ 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])
|
||||
options: [.allowBluetoothA2DP, .mixWithOthers])
|
||||
// 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
|
||||
@@ -180,19 +241,19 @@ public final class SessionAudio {
|
||||
try? session.setPreferredIOBufferDuration(0.005)
|
||||
try? session.setPreferredSampleRate(48_000)
|
||||
} else {
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||||
}
|
||||
#else // tvOS — no app-accessible mic
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
|
||||
#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.
|
||||
if micEnabled {
|
||||
steerBuiltInOutputToSpeaker(session)
|
||||
installRouteObserver()
|
||||
}
|
||||
// (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) }
|
||||
#endif
|
||||
} catch {
|
||||
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
||||
@@ -220,11 +281,20 @@ 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. 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.
|
||||
/// 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.
|
||||
private func installRouteObserver() {
|
||||
let observer = NotificationCenter.default.addObserver(
|
||||
forName: AVAudioSession.routeChangeNotification,
|
||||
@@ -235,7 +305,10 @@ 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()
|
||||
@@ -252,6 +325,7 @@ 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)
|
||||
@@ -325,35 +399,34 @@ 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
|
||||
#if os(iOS)
|
||||
let watcher = deviceWatcher
|
||||
deviceWatcher = nil
|
||||
#if !os(macOS)
|
||||
let route = routeObserver
|
||||
routeObserver = nil
|
||||
let mediaReset = mediaResetObserver
|
||||
mediaResetObserver = nil
|
||||
let interruption = interruptionObserver
|
||||
interruptionObserver = nil
|
||||
#endif
|
||||
stateLock.unlock()
|
||||
#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) }
|
||||
#endif
|
||||
if let capture {
|
||||
capture.inputNode.removeTap(onBus: 0)
|
||||
capture.stop()
|
||||
}
|
||||
playback?.stop()
|
||||
if let combined {
|
||||
combined.inputNode.removeTap(onBus: 0)
|
||||
combined.stop()
|
||||
}
|
||||
// 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)
|
||||
// Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like
|
||||
if let route { NotificationCenter.default.removeObserver(route) }
|
||||
if let mediaReset { NotificationCenter.default.removeObserver(mediaReset) }
|
||||
if let interruption { NotificationCenter.default.removeObserver(interruption) }
|
||||
#endif
|
||||
tearDownEngines()
|
||||
#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
|
||||
// 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
|
||||
@@ -372,6 +445,267 @@ 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
|
||||
@@ -437,6 +771,21 @@ 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,22 +1002,34 @@ 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: `(0, 0)` = stop now, non-zero = run at this level.
|
||||
/// so apply commands verbatim: all-zero = 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, backstopMs: UInt32)?
|
||||
-> (
|
||||
pad: UInt16, low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: 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
|
||||
let rc = punktfunk_connection_next_rumble_cmd(h, &pad, &low, &high, &backstop, timeoutMs)
|
||||
var lt: UInt16 = 0, rt: UInt16 = 0
|
||||
let rc = punktfunk_connection_next_rumble_cmd2(
|
||||
h, &pad, &low, &high, <, &rt, &backstop, timeoutMs)
|
||||
switch rc {
|
||||
case statusOK:
|
||||
return (pad, low, high, backstop)
|
||||
return (pad, low, high, lt, rt, backstop)
|
||||
case statusNoFrame:
|
||||
return nil
|
||||
case statusClosed:
|
||||
|
||||
@@ -172,7 +172,8 @@ 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)
|
||||
pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high,
|
||||
leftTrigger: c.leftTrigger, rightTrigger: c.rightTrigger)
|
||||
rumbleBurst += 1
|
||||
}
|
||||
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
|
||||
@@ -225,12 +226,21 @@ 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) {
|
||||
private func routeRumble(
|
||||
pad: UInt8, low: UInt16, high: UInt16, leftTrigger: UInt16, rightTrigger: UInt16
|
||||
) {
|
||||
let renderer = withRouting { rumbleByPad[pad] }
|
||||
renderer?.apply(low: low, high: high)
|
||||
renderer?.apply(low: low, high: high, leftTrigger: leftTrigger, rightTrigger: rightTrigger)
|
||||
// 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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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,6 +87,17 @@ 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 {
|
||||
@@ -97,12 +108,19 @@ 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
|
||||
@@ -212,6 +230,13 @@ 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
|
||||
// (oldest→newest), so a game's player numbers are stable across hot-plug churn.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// 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,7 +36,9 @@ 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.
|
||||
/// 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.
|
||||
static let sharpnessLow: Float = 0.3
|
||||
static let sharpnessHigh: Float = 0.7
|
||||
static let sharpnessCombined: Float = 0.5
|
||||
@@ -140,9 +142,21 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
private var controller: GCController?
|
||||
private var low: Motor?
|
||||
private var high: Motor?
|
||||
/// 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)
|
||||
/// 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)
|
||||
/// 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.
|
||||
@@ -216,22 +230,28 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// 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
|
||||
) {
|
||||
queue.async {
|
||||
let active = lowAmp != 0 || highAmp != 0
|
||||
let next = (lowAmp, highAmp, ltAmp, rtAmp)
|
||||
let active = next != (0, 0, 0, 0)
|
||||
if active != self.wasActive {
|
||||
self.wasActive = active
|
||||
log.debug(
|
||||
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
|
||||
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public) lt=\(ltAmp, privacy: .public) rt=\(rtAmp, privacy: .public)")
|
||||
}
|
||||
guard (lowAmp, highAmp) != self.target else { return }
|
||||
self.target = (lowAmp, highAmp)
|
||||
guard next != self.target else { return }
|
||||
self.target = next
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
@@ -241,7 +261,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
queue.sync {
|
||||
self.ticker?.cancel()
|
||||
self.ticker = nil
|
||||
self.target = (0, 0)
|
||||
self.target = (0, 0, 0, 0)
|
||||
self.wasActive = false
|
||||
self.teardown()
|
||||
self.closeHID()
|
||||
@@ -256,7 +276,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
defer { updateTicker() }
|
||||
if renderHID() { return }
|
||||
guard !broken else { return }
|
||||
let audible = target.low != 0 || target.high != 0
|
||||
let audible = target != (0, 0, 0, 0)
|
||||
if audible, low == nil, high == nil, DispatchTime.now() >= retryAfter {
|
||||
setup()
|
||||
}
|
||||
@@ -274,6 +294,18 @@ 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()
|
||||
@@ -410,9 +442,11 @@ 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)
|
||||
let needed = target != (0, 0, 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(
|
||||
@@ -477,6 +511,26 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,7 +617,7 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func teardown() {
|
||||
for m in [low, high].compactMap({ $0 }) {
|
||||
for m in [low, high, leftTrigger, rightTrigger].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 }
|
||||
@@ -577,6 +631,8 @@ final class RumbleRenderer: @unchecked Sendable {
|
||||
}
|
||||
low = nil
|
||||
high = nil
|
||||
leftTrigger = nil
|
||||
rightTrigger = nil
|
||||
}
|
||||
|
||||
private func seconds(since t: DispatchTime) -> TimeInterval {
|
||||
@@ -624,6 +680,16 @@ 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,6 +191,13 @@ 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,6 +32,15 @@ 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,7 +79,13 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable {
|
||||
// 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.
|
||||
id: "oled", name: "OLED",
|
||||
//
|
||||
// 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",
|
||||
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)],
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// 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
|
||||
@@ -0,0 +1,121 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// 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,6 +178,18 @@ 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 {
|
||||
|
||||
@@ -26,8 +26,11 @@ 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 300 \
|
||||
target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 12000 \
|
||||
--allow-tofu &
|
||||
HOST_PID=$!
|
||||
HOME="$CFG/paired" XDG_CONFIG_HOME="$CFG/paired/.config" \
|
||||
@@ -61,4 +64,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
|
||||
swift test --filter 'LoopbackIntegrationTests|AudioDeviceSwitchTests'
|
||||
|
||||
@@ -1274,13 +1274,18 @@ 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.
|
||||
// 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.
|
||||
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)"
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ 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};
|
||||
@@ -1850,13 +1851,15 @@ 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 xRGB_210LE/xBGR_210LE LINEAR dmabufs with MANDATORY \
|
||||
"HDR capture: offering xBGR_210LE/xRGB_210LE LINEAR dmabufs with MANDATORY \
|
||||
BT.2020 + SMPTE-2084 (PQ) colorimetry (GNOME 50+ monitor stream)"
|
||||
);
|
||||
vec![
|
||||
build_hdr_dmabuf_format(VideoFormat::xRGB_210LE, preferred)?,
|
||||
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, preferred)?,
|
||||
]
|
||||
// ⚠ 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<_>>>()?
|
||||
} else if want_dmabuf {
|
||||
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 2 } else { 1 });
|
||||
if prefer_native_nv12 {
|
||||
|
||||
@@ -121,6 +121,38 @@ 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)>,
|
||||
@@ -596,4 +628,37 @@ 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +276,9 @@ 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",
|
||||
|
||||
@@ -267,7 +267,10 @@ 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.
|
||||
id: "oled", name: "OLED",
|
||||
// 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",
|
||||
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),
|
||||
|
||||
@@ -791,6 +791,37 @@ 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).
|
||||
|
||||
@@ -34,7 +34,15 @@ 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
|
||||
|
||||
@@ -525,7 +525,32 @@ impl NvencEncoder {
|
||||
None => {}
|
||||
}
|
||||
|
||||
let enc = match video.open_with(opts) {
|
||||
// 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 {
|
||||
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
|
||||
@@ -560,9 +585,21 @@ 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)")
|
||||
})
|
||||
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"
|
||||
)
|
||||
});
|
||||
}
|
||||
};
|
||||
if intra_refresh {
|
||||
|
||||
@@ -194,6 +194,16 @@ 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.
|
||||
@@ -234,6 +244,11 @@ 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;
|
||||
@@ -299,6 +314,33 @@ 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!(
|
||||
@@ -313,6 +355,7 @@ fn probe_support_uncached() -> ProbedSupport {
|
||||
av1: guids.contains(&nv::NV_ENC_CODEC_AV1_GUID),
|
||||
},
|
||||
hevc_444,
|
||||
ten_bit,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -704,8 +747,15 @@ pub struct NvencCudaEncoder {
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
buffer_fmt: nv::NV_ENC_BUFFER_FORMAT,
|
||||
/// 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.
|
||||
/// 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.
|
||||
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.
|
||||
|
||||
@@ -599,6 +599,15 @@ 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
|
||||
@@ -679,6 +688,18 @@ 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,
|
||||
@@ -686,7 +707,54 @@ impl PyroWaveEncoder {
|
||||
bitrate_bps: u64,
|
||||
chroma: crate::ChromaFormat,
|
||||
) -> Result<Self> {
|
||||
if !chroma.is_444() && (width % 2 != 0 || height % 2 != 0) {
|
||||
// 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) {
|
||||
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
|
||||
@@ -697,11 +765,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, chroma.is_444()) {
|
||||
if !crate::pyrowave_mode_fits_rdo(width, height, chroma444) {
|
||||
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 chroma.is_444() { "4:4:4" } else { "4:2:0" }
|
||||
if chroma444 { "4:4:4" } else { "4:2:0" }
|
||||
);
|
||||
}
|
||||
// SAFETY: `open_inner` only issues Vulkan/pyrowave calls whose preconditions it
|
||||
@@ -713,12 +781,27 @@ impl PyroWaveEncoder {
|
||||
height,
|
||||
fps.max(1),
|
||||
bitrate_bps.max(1_000_000),
|
||||
chroma.is_444(),
|
||||
chroma444,
|
||||
intent,
|
||||
warn_inert,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn open_inner(w: u32, h: u32, fps: u32, bitrate: u64, chroma444: bool) -> Result<Self> {
|
||||
/// `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> {
|
||||
let entry = ash::Entry::load().context("load vulkan loader")?;
|
||||
|
||||
let mut hold = DeviceHold {
|
||||
@@ -860,8 +943,7 @@ 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(std::env::var("PYROWAVE_QUEUE_PRIORITY").ok().as_deref());
|
||||
let gp_candidates = queue_priority_candidates(intent);
|
||||
// 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 =
|
||||
@@ -943,13 +1025,18 @@ 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() {
|
||||
if !gp_candidates.is_empty() && gp.is_some() && warn_inert {
|
||||
// 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 \
|
||||
@@ -962,9 +1049,33 @@ impl PyroWaveEncoder {
|
||||
.context("create device")?
|
||||
}
|
||||
};
|
||||
Ok((pd, family, device, foreign_qfi))
|
||||
// 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))
|
||||
})();
|
||||
let (pd, family, device, foreign_qfi) = match selected {
|
||||
let (pd, family, device, foreign_qfi, priority, device_name) = match selected {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
instance.destroy_instance(None);
|
||||
@@ -1013,6 +1124,8 @@ 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
@@ -0,0 +1,971 @@
|
||||
//! `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 0–2 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,14 @@ pub(crate) fn stamp_color_bits(bitstream: &mut [u8], seq_offset: usize, bt2020_p
|
||||
/// repeated value is read as more blocks of the same frame. That is why PW5's alternating encoder
|
||||
/// handles need `pyrowave_encoder_set_next_sequence`, and why a test asserts this reader sees
|
||||
/// +1 mod 8 across the pair.
|
||||
///
|
||||
/// Its only caller is the Linux backend — alternating encoder handles are a Linux-side concern, and
|
||||
/// the Windows backend drives pyrowave's compat device with a single handle. The rest of this module
|
||||
/// really is shared (`packet_boundary` and `stamp_color_bits` have callers on both), so the exemption
|
||||
/// is scoped to this one item rather than the file: `dead_code` stays live on Linux, where the caller
|
||||
/// lives and where its disappearing would be a real finding. Windows builds with `-D warnings`, so
|
||||
/// without this the host and tray clippy legs fail to compile the lib at all.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub(crate) fn wire_sequence(bitstream: &[u8], packet_offset: usize) -> Option<u8> {
|
||||
let lo = *bitstream.get(packet_offset + 2)?;
|
||||
let hi = *bitstream.get(packet_offset + 3)?;
|
||||
|
||||
@@ -358,8 +358,17 @@ fn open_video_backend_linux(
|
||||
if codec == Codec::PyroWave {
|
||||
#[cfg(feature = "pyrowave")]
|
||||
{
|
||||
return pyrowave::PyroWaveEncoder::open(width, height, fps, bitrate_bps, chroma)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "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"));
|
||||
}
|
||||
#[cfg(not(feature = "pyrowave"))]
|
||||
anyhow::bail!(
|
||||
@@ -517,14 +526,16 @@ 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.
|
||||
pyrowave::PyroWaveEncoder::open(
|
||||
// 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(
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.map(|e| (Box::new(e) as Box<dyn Encoder>, "pyrowave"))
|
||||
.map(|e| (e, "pyrowave"))
|
||||
}
|
||||
#[cfg(not(feature = "pyrowave"))]
|
||||
{
|
||||
@@ -1589,7 +1600,41 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
|
||||
};
|
||||
vulkan10 || vaapi::probe_can_encode_10bit(codec)
|
||||
} else {
|
||||
linux::probe_can_encode_10bit(codec)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -2035,6 +2080,18 @@ 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.
|
||||
|
||||
@@ -260,6 +260,27 @@ 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`.
|
||||
///
|
||||
@@ -391,6 +412,10 @@ 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,7 +299,8 @@ impl PadProto for DsLinuxProto {
|
||||
fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service(idx);
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
// No trigger motors on this protocol — see `PadFeedback::rumble`.
|
||||
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
|
||||
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
|
||||
@@ -401,7 +402,8 @@ impl PadProto for DsEdgeLinuxProto {
|
||||
fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service(idx);
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
// No trigger motors on this protocol — see `PadFeedback::rumble`.
|
||||
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
|
||||
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,7 +314,8 @@ impl PadProto for Ds4LinuxProto {
|
||||
fn service(&self, pad: &mut DualShock4Pad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service(idx);
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
// No trigger motors on this protocol — see `PadFeedback::rumble`.
|
||||
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
|
||||
hidout: fb
|
||||
.led
|
||||
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
|
||||
|
||||
@@ -705,9 +705,14 @@ impl GamepadManager {
|
||||
.ensure(idx, |i| VirtualPad::create(i as usize, identity));
|
||||
}
|
||||
|
||||
/// 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)) {
|
||||
/// 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)) {
|
||||
// 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
|
||||
@@ -715,7 +720,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);
|
||||
send(i as u16, low, high, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,7 +440,8 @@ impl PadProto for SteamProto {
|
||||
fn service(&self, pad: &mut DeckTransport, _idx: u8) -> PadFeedback {
|
||||
let rumble = pad.service();
|
||||
PadFeedback {
|
||||
rumble,
|
||||
// No trigger motors on this protocol — see `PadFeedback::rumble`.
|
||||
rumble: rumble.map(|(low, high)| (low, high, 0, 0)),
|
||||
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
|
||||
@@ -570,7 +571,8 @@ impl PadProto for ScProto {
|
||||
fn service(&self, pad: &mut SteamDeckPad, _idx: u8) -> PadFeedback {
|
||||
let rumble = pad.service();
|
||||
PadFeedback {
|
||||
rumble,
|
||||
// No trigger motors on this protocol — see `PadFeedback::rumble`.
|
||||
rumble: rumble.map(|(low, high)| (low, high, 0, 0)),
|
||||
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,7 +369,8 @@ impl PadProto for TritonProto {
|
||||
})
|
||||
.collect();
|
||||
PadFeedback {
|
||||
rumble,
|
||||
// No trigger motors on this protocol — see `PadFeedback::rumble`.
|
||||
rumble: rumble.map(|(low, high)| (low, high, 0, 0)),
|
||||
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,7 +173,8 @@ impl SwitchProPad {
|
||||
let _ = self.write_report(&build_usb_ack(cmd));
|
||||
}
|
||||
Some(SwitchOutput::Subcmd { id, args, rumble }) => {
|
||||
fb.rumble = Some(rumble);
|
||||
// No trigger motors on this protocol — see `PadFeedback::rumble`.
|
||||
fb.rumble = Some((rumble.0, rumble.1, 0, 0));
|
||||
if id == 0x30 {
|
||||
// Player lights ride the subcommand itself; still ack it.
|
||||
if let Some(&arg) = args.first() {
|
||||
@@ -185,7 +186,7 @@ impl SwitchProPad {
|
||||
}
|
||||
self.answer_subcmd(id, &args);
|
||||
}
|
||||
Some(SwitchOutput::Rumble(r)) => fb.rumble = Some(r),
|
||||
Some(SwitchOutput::Rumble(r)) => fb.rumble = Some((r.0, r.1, 0, 0)),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,78 @@ 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 {
|
||||
@@ -172,11 +244,24 @@ 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,
|
||||
self.hint
|
||||
fault.map_or(self.hint, PadCreateFault::hint)
|
||||
);
|
||||
self.gate.on_failure(Instant::now());
|
||||
false
|
||||
@@ -184,6 +269,18 @@ 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())
|
||||
@@ -350,6 +447,93 @@ 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();
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
//! 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)");
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,21 @@ use std::time::{Duration, Instant};
|
||||
/// 0xCD feedback events (lightbar / player LEDs / adaptive triggers), deduped via [`HidoutDedup`].
|
||||
#[derive(Default)]
|
||||
pub struct PadFeedback {
|
||||
/// `(low, high)` motor levels, if the pass saw a rumble report.
|
||||
/// `(low, high, left_trigger, right_trigger)` 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.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
///
|
||||
/// 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 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
|
||||
@@ -119,8 +127,11 @@ 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.
|
||||
last_rumble: Vec<(u16, u16)>,
|
||||
/// 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 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>,
|
||||
@@ -254,7 +265,7 @@ impl<B: PadProto> UhidManager<B> {
|
||||
backend,
|
||||
slots: PadSlots::new(B::LABEL, B::DEVICE, B::CREATE_HINT),
|
||||
state,
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
last_rumble: vec![(0, 0, 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],
|
||||
@@ -263,6 +274,19 @@ 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 {
|
||||
@@ -339,13 +363,14 @@ 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)` 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.
|
||||
/// 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.
|
||||
pub fn pump(
|
||||
&mut self,
|
||||
mut rumble: impl FnMut(u16, u16, u16),
|
||||
mut rumble: impl FnMut(u16, u16, u16, u16, u16),
|
||||
mut hidout: impl FnMut(HidOutput),
|
||||
) {
|
||||
let now = Instant::now();
|
||||
@@ -369,9 +394,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) {
|
||||
self.last_rumble[i] = (0, 0);
|
||||
rumble(i as u16, 0, 0);
|
||||
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);
|
||||
}
|
||||
self.hidout_dedup[i] = HidoutDedup::default();
|
||||
}
|
||||
@@ -385,9 +410,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);
|
||||
rumble(i as u16, r.0, r.1, r.2, r.3);
|
||||
}
|
||||
} else if self.last_rumble[i] != (0, 0)
|
||||
} else if self.last_rumble[i] != (0, 0, 0, 0)
|
||||
&& rumble_idle_timeout()
|
||||
.is_some_and(|t| now.duration_since(self.last_active[i]) >= t)
|
||||
{
|
||||
@@ -400,10 +425,12 @@ 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);
|
||||
rumble(i as u16, 0, 0);
|
||||
self.last_rumble[i] = (0, 0, 0, 0);
|
||||
rumble(i as u16, 0, 0, 0, 0);
|
||||
}
|
||||
for h in fb.hidout {
|
||||
// Skip rich feedback that repeats the last-forwarded value (a game's output report
|
||||
@@ -469,7 +496,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);
|
||||
self.last_rumble[idx] = (0, 0, 0, 0);
|
||||
self.hidout_dedup[idx].clear();
|
||||
self.last_write[idx] = Instant::now();
|
||||
self.last_active[idx] = Instant::now();
|
||||
@@ -733,14 +760,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"
|
||||
@@ -783,7 +810,10 @@ 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| out.borrow_mut().push((i, lo, hi)), |_| {});
|
||||
m.pump(
|
||||
|i, lo, hi, lt, rt| out.borrow_mut().push((i, lo, hi, lt, rt)),
|
||||
|_| {},
|
||||
);
|
||||
out.into_inner()
|
||||
};
|
||||
let rumble = |r| PadFeedback {
|
||||
@@ -792,12 +822,16 @@ mod tests {
|
||||
rumble_drove: Some(true),
|
||||
resync: false,
|
||||
};
|
||||
*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
|
||||
*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
|
||||
assert_eq!(collect(&mut m), vec![]); // exact repeat deduped
|
||||
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.
|
||||
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.
|
||||
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
|
||||
@@ -806,8 +840,43 @@ mod tests {
|
||||
"the pump tick completed the unplug"
|
||||
);
|
||||
m.handle(&frame(0, 0b1, 0));
|
||||
*m.backend.feedback.borrow_mut() = vec![rumble((7, 7))];
|
||||
assert_eq!(collect(&mut m), vec![(0, 7, 7)]);
|
||||
*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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -816,17 +885,20 @@ 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| out.borrow_mut().push((i, lo, hi)), |_| {});
|
||||
m.pump(
|
||||
|i, lo, hi, lt, rt| out.borrow_mut().push((i, lo, hi, lt, rt)),
|
||||
|_| {},
|
||||
);
|
||||
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)),
|
||||
rumble: Some((200, 0, 0, 0)),
|
||||
hidout: Vec::new(),
|
||||
rumble_drove: Some(true),
|
||||
resync: false,
|
||||
}];
|
||||
assert_eq!(collect(&mut m), vec![(0, 200, 0)]);
|
||||
assert_eq!(collect(&mut m), vec![(0, 200, 0, 0, 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
|
||||
@@ -845,7 +917,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)]); // forced off
|
||||
assert_eq!(collect(&mut m), vec![(0, 0, 0, 0, 0)]); // forced off
|
||||
assert_eq!(collect(&mut m), vec![]); // already zero — no repeat
|
||||
}
|
||||
|
||||
@@ -855,16 +927,19 @@ 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| out.borrow_mut().push((i, lo, hi)), |_| {});
|
||||
m.pump(
|
||||
|i, lo, hi, lt, rt| out.borrow_mut().push((i, lo, hi, lt, rt)),
|
||||
|_| {},
|
||||
);
|
||||
out.into_inner()
|
||||
};
|
||||
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
|
||||
rumble: Some((200, 0)),
|
||||
rumble: Some((200, 0, 0, 0)),
|
||||
hidout: Vec::new(),
|
||||
rumble_drove: Some(true),
|
||||
resync: false,
|
||||
}];
|
||||
assert_eq!(collect(&mut m), vec![(0, 200, 0)]);
|
||||
assert_eq!(collect(&mut m), vec![(0, 200, 0, 0, 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
|
||||
@@ -872,7 +947,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)),
|
||||
rumble: Some((200, 0, 0, 0)),
|
||||
hidout: Vec::new(),
|
||||
rumble_drove: Some(true),
|
||||
resync: false,
|
||||
@@ -906,7 +981,7 @@ mod tests {
|
||||
}];
|
||||
let out = RefCell::new(0u32);
|
||||
m.pump(
|
||||
|_, _, _| {},
|
||||
|_, _, _, _, _| {},
|
||||
|_| {
|
||||
*out.borrow_mut() += 1;
|
||||
},
|
||||
@@ -976,7 +1051,7 @@ mod tests {
|
||||
let rumbles = RefCell::new(Vec::new());
|
||||
let hidouts = RefCell::new(0u32);
|
||||
m.pump(
|
||||
|i, lo, hi| rumbles.borrow_mut().push((i, lo, hi)),
|
||||
|i, lo, hi, lt, rt| rumbles.borrow_mut().push((i, lo, hi, lt, rt)),
|
||||
|_| *hidouts.borrow_mut() += 1,
|
||||
);
|
||||
(rumbles.into_inner(), hidouts.into_inner())
|
||||
@@ -984,12 +1059,12 @@ mod tests {
|
||||
|
||||
// Latch a rumble + an LED.
|
||||
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
|
||||
rumble: Some((100, 0)),
|
||||
rumble: Some((100, 0, 0, 0)),
|
||||
hidout: vec![led(10)],
|
||||
rumble_drove: Some(true),
|
||||
resync: false,
|
||||
}];
|
||||
assert_eq!(collect(&mut m), (vec![(0, 100, 0)], 1));
|
||||
assert_eq!(collect(&mut m), (vec![(0, 100, 0, 0, 0)], 1));
|
||||
|
||||
// Overflow poll: no reports survived, resync flagged → forced stop, exactly once.
|
||||
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
|
||||
@@ -998,22 +1073,22 @@ mod tests {
|
||||
rumble_drove: Some(false),
|
||||
resync: true,
|
||||
}];
|
||||
assert_eq!(collect(&mut m), (vec![(0, 0, 0)], 0));
|
||||
assert_eq!(collect(&mut m), (vec![(0, 0, 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)),
|
||||
rumble: Some((100, 0, 0, 0)),
|
||||
hidout: vec![led(10)],
|
||||
rumble_drove: Some(true),
|
||||
resync: false,
|
||||
}];
|
||||
assert_eq!(collect(&mut m), (vec![(0, 100, 0)], 1));
|
||||
assert_eq!(collect(&mut m), (vec![(0, 100, 0, 0, 0)], 1));
|
||||
|
||||
// A resync with nothing latched forwards no spurious stop.
|
||||
*m.backend.feedback.borrow_mut() = vec![
|
||||
PadFeedback {
|
||||
rumble: Some((0, 0)),
|
||||
rumble: Some((0, 0, 0, 0)),
|
||||
hidout: Vec::new(),
|
||||
rumble_drove: Some(true),
|
||||
resync: false,
|
||||
@@ -1025,7 +1100,7 @@ mod tests {
|
||||
resync: true,
|
||||
},
|
||||
];
|
||||
assert_eq!(collect(&mut m), (vec![(0, 0, 0)], 0)); // the explicit stop
|
||||
assert_eq!(collect(&mut m), (vec![(0, 0, 0, 0, 0)], 0)); // the explicit stop
|
||||
assert_eq!(collect(&mut m), (vec![], 0)); // resync at zero — silent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,8 @@ impl PadProto for DsEdgeWinProto {
|
||||
fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service(idx);
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
// No trigger motors on this protocol — see `PadFeedback::rumble`.
|
||||
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
|
||||
hidout: fb.hidout,
|
||||
// Rumble-plane liveness, not any-report liveness — see the plain DualSense backend.
|
||||
rumble_drove: Some(fb.rumble.is_some()),
|
||||
|
||||
@@ -686,7 +686,8 @@ 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()),
|
||||
rumble: fb.rumble,
|
||||
// No trigger motors on this protocol — see `PadFeedback::rumble`.
|
||||
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
|
||||
hidout: fb.hidout,
|
||||
resync: fb.resync,
|
||||
}
|
||||
@@ -995,14 +996,26 @@ 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…%=pfGamepad, <hwid>[, <hwid>…]`.
|
||||
// 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.
|
||||
let declared: Vec<String> = inf
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.starts_with(';'))
|
||||
.filter_map(|l| l.split_once("=pfGamepad,"))
|
||||
.flat_map(|(_, ids)| {
|
||||
ids.split(',')
|
||||
.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)
|
||||
.map(|id| id.trim().to_ascii_lowercase())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
@@ -1018,7 +1031,15 @@ 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!(
|
||||
@@ -1032,6 +1053,81 @@ 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
|
||||
@@ -1071,7 +1167,7 @@ mod drain_tests {
|
||||
.collect();
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
4,
|
||||
7,
|
||||
"parsed {entries:?} out of the driver's table — the shape changed and this test went \
|
||||
vacuous; fix the parse rather than deleting the assert"
|
||||
);
|
||||
@@ -1098,7 +1194,16 @@ 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,7 +232,8 @@ impl PadProto for Ds4WinProto {
|
||||
fn service(&self, pad: &mut Ds4WinPad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service();
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
// No trigger motors on this protocol — see `PadFeedback::rumble`.
|
||||
rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)),
|
||||
hidout: fb
|
||||
.led
|
||||
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
|
||||
|
||||
@@ -53,7 +53,8 @@
|
||||
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 anyhow::{anyhow, bail, Context, Result};
|
||||
use crate::pad_slots::PadCreateFault;
|
||||
use anyhow::{anyhow, 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};
|
||||
@@ -67,16 +68,17 @@ use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
|
||||
use windows::Win32::Foundation::{
|
||||
DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS,
|
||||
ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WAIT_OBJECT_0, WIN32_ERROR,
|
||||
CloseHandle, DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS,
|
||||
ERROR_ACCESS_DENIED, 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, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||
CreateFileMappingW, MapViewOfFile, OpenFileMappingW, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||
FILE_MAP_READ, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||
};
|
||||
use windows::Win32::System::Threading::{
|
||||
GetCurrentProcess, OpenProcess, SetEvent, WaitForSingleObject, PROCESS_DUP_HANDLE,
|
||||
@@ -171,6 +173,16 @@ 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.
|
||||
@@ -183,8 +195,10 @@ impl Shm {
|
||||
}
|
||||
// SAFETY: clearing the thread error slot so ERROR_ALREADY_EXISTS below is unambiguous.
|
||||
unsafe { SetLastError(WIN32_ERROR(0)) };
|
||||
let shm = Self::create_inner(&sa.sa, PCWSTR(name.as_ptr()), size)
|
||||
.with_context(|| format!("create gamepad bootstrap mailbox {name}"))?;
|
||||
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)),
|
||||
};
|
||||
// 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 {
|
||||
@@ -192,11 +206,16 @@ impl Shm {
|
||||
}
|
||||
// `shm` drops here → unmap + close our handle to the foreign object, then retry.
|
||||
}
|
||||
bail!(
|
||||
// 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!(
|
||||
"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> {
|
||||
@@ -250,6 +269,76 @@ 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,6 +296,13 @@ 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!(
|
||||
@@ -356,7 +363,12 @@ 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.
|
||||
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
|
||||
///
|
||||
/// 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)) {
|
||||
// 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();
|
||||
@@ -369,7 +381,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);
|
||||
send(i as u16, large as u16 * 257, small as u16 * 257, 0, 0);
|
||||
}
|
||||
} else if self.last_rumble[i] != (0, 0)
|
||||
&& crate::uhid_manager::rumble_idle_timeout()
|
||||
@@ -386,7 +398,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);
|
||||
send(i as u16, 0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +231,8 @@ impl PadProto for DeckWinProto {
|
||||
// presence is the rumble-plane activity signal, even at an unchanged level.
|
||||
let (rumble, resync) = pad.service();
|
||||
PadFeedback {
|
||||
rumble,
|
||||
// No trigger motors on this protocol — see `PadFeedback::rumble`.
|
||||
rumble: rumble.map(|(low, high)| (low, high, 0, 0)),
|
||||
hidout: Vec::new(),
|
||||
rumble_drove: Some(rumble.is_some()),
|
||||
resync,
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -389,13 +389,22 @@ 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.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
///
|
||||
/// 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`].
|
||||
#[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).
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
///
|
||||
/// 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.
|
||||
#[path = "inject/pad_slots.rs"]
|
||||
pub mod pad_slots;
|
||||
/// The `sensor_timestamp` every virtual Sony pad stamps into its input reports
|
||||
@@ -474,6 +483,25 @@ 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 {
|
||||
@@ -484,7 +512,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)) {}
|
||||
pub fn pump_rumble(&mut self, _send: impl FnMut(u16, u16, u16, u16, u16)) {}
|
||||
}
|
||||
}
|
||||
/// Linux: the "Punktfunk Pen" uinput virtual tablet (design/pen-tablet-input.md §5) — the
|
||||
|
||||
@@ -87,9 +87,10 @@ 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, 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, 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,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use routing::{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -524,6 +524,22 @@ 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);
|
||||
|
||||
@@ -13,8 +13,11 @@
|
||||
//! So an interactive Plasma session does NOT hand it to a bare client — the host packages ship
|
||||
//! `io.unom.Punktfunk.Host.desktop` (`Exec=/usr/bin/punktfunk-host`,
|
||||
//! `X-KDE-Wayland-Interfaces=zkde_screencast_unstable_v1,…`) so it is present before the host first
|
||||
//! connects. The headless test path instead exposes it to bare clients via
|
||||
//! `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`. The compositor backend must implement
|
||||
//! connects. That identification is also why **the host binary must carry no file capability**: a
|
||||
//! process holding capabilities KWin lacks is one the kernel will not let KWin resolve
|
||||
//! `/proc/<pid>/exe` for, so it can never be matched to a `.desktop` no matter how correctly the
|
||||
//! file is installed (see [`capability_denial_hint`]). The headless test path instead exposes it to
|
||||
//! bare clients via `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`. The compositor backend must implement
|
||||
//! `createVirtualOutput`: the **DRM backend** (any version) or the **VirtualBackend since KWin
|
||||
//! 6.5.6** (`kwin_wayland --virtual`); on `--virtual` < 6.5.6 the request fails with
|
||||
//! "Could not find output". We talk raw Wayland on `$WAYLAND_DISPLAY`, so the host must run inside
|
||||
@@ -1071,6 +1074,107 @@ impl Drop for StopOnDrop {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra sentence appended to every "KWin never advertised the screencast global" error when this
|
||||
/// process carries capabilities — the one cause that is completely invisible from the Wayland side.
|
||||
///
|
||||
/// KWin authorizes a restricted interface by resolving the *client's* `/proc/<pid>/exe` and
|
||||
/// matching it against an installed `.desktop`. The kernel refuses that readlink to any reader
|
||||
/// whose effective set is not a superset of the target's **permitted** set
|
||||
/// (`cap_ptrace_access_check`), and KWin has no capabilities at all. So a host binary carrying any
|
||||
/// file capability is simply unidentifiable: `executablePath()` comes back empty, no `.desktop` can
|
||||
/// match, and the global is never advertised — indistinguishable, from here, from a missing
|
||||
/// `.desktop`. Neither half of the obvious workaround helps: `prctl(PR_SET_DUMPABLE, 1)` leaves the
|
||||
/// permitted-set check failing, and moving the grant to systemd `AmbientCapabilities=` lands the
|
||||
/// capability in the same permitted set. Only an uncapped binary is identifiable.
|
||||
///
|
||||
/// This is not hypothetical: 0.26.0-1 setcap'd `cap_sys_nice` on the host for the GPU-priority
|
||||
/// lever and took out desktop streaming on every KDE box until the capability was removed again.
|
||||
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 \
|
||||
its own to cause this: the kernel then refuses KWin the /proc/<pid>/exe read it \
|
||||
identifies clients by, so no .desktop can match however correctly it is installed. \
|
||||
Clear them with `sudo setcap -r /usr/bin/punktfunk-host` and restart the host"
|
||||
),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The permitted-capability mask out of a `/proc/<pid>/status` body, or `None` if the field is
|
||||
/// absent/unparseable. The kernel prints it as a tab-separated 16-digit hex word with no `0x`
|
||||
/// (`CapPrm:\t0000000000800000` = CAP_SYS_NICE), which is what the split-and-radix-16 parse below
|
||||
/// expects — split out from [`capability_denial_hint`] purely so that shape is testable without a
|
||||
/// capability-carrying process to point at.
|
||||
fn permitted_caps_from_status(status: &str) -> Option<u64> {
|
||||
let field = status.lines().find(|l| l.starts_with("CapPrm:"))?;
|
||||
u64::from_str_radix(field.split_whitespace().nth(1)?, 16).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod capability_hint_tests {
|
||||
use super::*;
|
||||
|
||||
/// Verbatim from a `cap_sys_nice=ep` process on CachyOS — the case that broke 0.26.0-1.
|
||||
const CAPPED: &str = "Name:\tpunktfunk-host\nUid:\t1000\t1000\t1000\t1000\nCapPrm:\t0000000000800000\nCapEff:\t0000000000800000\n";
|
||||
/// ...and from the same binary with no capability, where the hint must stay silent.
|
||||
const CLEAN: &str = "Name:\tpunktfunk-host\nUid:\t1000\t1000\t1000\t1000\nCapPrm:\t0000000000000000\nCapEff:\t0000000000000000\n";
|
||||
|
||||
#[test]
|
||||
fn parses_the_kernels_permitted_mask() {
|
||||
assert_eq!(permitted_caps_from_status(CAPPED), Some(0x0080_0000));
|
||||
assert_eq!(permitted_caps_from_status(CLEAN), Some(0));
|
||||
// CapPrm is not guaranteed present (older/again-different kernels): stay quiet, never panic.
|
||||
assert_eq!(permitted_caps_from_status("Name:\tx\n"), None);
|
||||
assert_eq!(permitted_caps_from_status("CapPrm:\tzzzz\n"), None);
|
||||
assert_eq!(permitted_caps_from_status("CapPrm:\n"), None);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Readiness probe: connect to the KWin Wayland socket, roundtrip the registry, and confirm
|
||||
/// the privileged `zkde_screencast` global is actually advertised. This is exactly what
|
||||
/// [`run`] needs before it can create a virtual output, so a session-bringup script can poll
|
||||
@@ -1090,7 +1194,8 @@ pub fn probe() -> Result<()> {
|
||||
it on the host's .desktop X-KDE-Wayland-Interfaces (install \
|
||||
io.unom.Punktfunk.Host.desktop with Exec=/usr/bin/punktfunk-host, then re-login so KWin \
|
||||
re-reads it — the grant is cached per-exe on first connect), or set \
|
||||
KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 for the headless test; needs KWin ≥ 6.5.6"
|
||||
KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 for the headless test; needs KWin ≥ 6.5.6{}",
|
||||
capability_denial_hint()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
@@ -1134,7 +1239,9 @@ fn run_existing(
|
||||
anyhow!(
|
||||
"KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \
|
||||
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
|
||||
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless test)"
|
||||
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless \
|
||||
test){}",
|
||||
capability_denial_hint()
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -1223,7 +1330,9 @@ fn run(
|
||||
anyhow!(
|
||||
"KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \
|
||||
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
|
||||
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless test)"
|
||||
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless \
|
||||
test){}",
|
||||
capability_denial_hint()
|
||||
)
|
||||
})?;
|
||||
|
||||
|
||||
@@ -111,6 +111,74 @@ 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
|
||||
@@ -280,6 +348,196 @@ 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,6 +371,19 @@ 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
|
||||
@@ -383,6 +396,22 @@ 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::*;
|
||||
|
||||
@@ -311,8 +311,11 @@ 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. comm names mirror the
|
||||
// `pkill -x` discipline (exact, ≤15 chars so untruncated).
|
||||
// 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.
|
||||
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
|
||||
@@ -332,10 +335,10 @@ pub fn detect_active_session() -> ActiveSession {
|
||||
if md.uid() != uid {
|
||||
continue;
|
||||
}
|
||||
let Ok(comm) = std::fs::read_to_string(pid_path.join("comm")) else {
|
||||
let Some(comm) = crate::proc::match_name(&pid_path) else {
|
||||
continue;
|
||||
};
|
||||
let (k, prio) = match comm.trim() {
|
||||
let (k, prio) = match comm.as_str() {
|
||||
"gamescope" | "gamescope-wl" => (ActiveKind::Gaming, 1),
|
||||
"kwin_wayland" => (ActiveKind::DesktopKde, 4),
|
||||
"gnome-shell" => (ActiveKind::DesktopGnome, 4),
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
//! Host side of the isolated zero-copy GPU import (design:
|
||||
//! `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.
|
||||
//! `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.
|
||||
|
||||
// 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::proto::{self, BufferDesc, ImportKind, Reply, Request};
|
||||
use super::ipc;
|
||||
use super::proto::{BufferDesc, ImportKind, Reply, Request, PROTO_VERSION};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
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::os::fd::{AsFd, BorrowedFd, OwnedFd};
|
||||
use std::path::Path;
|
||||
use std::process::Child;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// 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,110 +79,6 @@ 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 {
|
||||
@@ -196,13 +92,17 @@ 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 `SELF_EXE` fd, so it is always the exact image this
|
||||
/// is exec'd through the pinned [`ipc::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 self_exe() {
|
||||
Some(fd) => Self::spawn_exe(&fd_exec_path(fd)),
|
||||
match ipc::self_exe() {
|
||||
Some(exe) => Self::spawn_exe(&exe.exec_path()),
|
||||
None => Self::spawn_exe(
|
||||
&std::env::current_exe().context("resolve /proc/self/exe for the worker")?,
|
||||
),
|
||||
@@ -211,45 +111,10 @@ impl RemoteImporter {
|
||||
|
||||
/// [`Self::spawn`] with an explicit executable (separated for tests).
|
||||
fn spawn_exe(exe: &Path) -> Result<RemoteImporter> {
|
||||
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
|
||||
// `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")?;
|
||||
Self::from_socket(host_end, Some(child))
|
||||
}
|
||||
|
||||
@@ -266,11 +131,11 @@ impl RemoteImporter {
|
||||
rbuf: Vec::new(),
|
||||
sent_keys: HashSet::new(),
|
||||
};
|
||||
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))?;
|
||||
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))?;
|
||||
match ready {
|
||||
Ok((Reply::Ready { version }, _)) if version == proto::PROTO_VERSION => {
|
||||
Ok((Reply::Ready { version }, _)) if version == PROTO_VERSION => {
|
||||
tracing::info!(
|
||||
pid = importer.child.as_ref().map(|c| c.id()),
|
||||
"zero-copy GPU import isolated in a worker process"
|
||||
@@ -281,7 +146,7 @@ impl RemoteImporter {
|
||||
importer.mark_dead();
|
||||
bail!(
|
||||
"zerocopy worker protocol mismatch (worker v{version}, host v{})",
|
||||
proto::PROTO_VERSION
|
||||
PROTO_VERSION
|
||||
)
|
||||
}
|
||||
Ok((Reply::InitErr { message }, _)) => {
|
||||
@@ -315,7 +180,7 @@ impl RemoteImporter {
|
||||
if self.dead() {
|
||||
return Vec::new();
|
||||
}
|
||||
if let Err(e) = proto::send(
|
||||
if let Err(e) = ipc::send(
|
||||
self.shared.sock.as_fd(),
|
||||
&Request::Modifiers { fourcc },
|
||||
None,
|
||||
@@ -324,7 +189,7 @@ impl RemoteImporter {
|
||||
self.mark_dead();
|
||||
return Vec::new();
|
||||
}
|
||||
match proto::recv::<Reply>(self.shared.sock.as_fd(), &mut self.rbuf) {
|
||||
match ipc::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");
|
||||
@@ -439,11 +304,11 @@ impl RemoteImporter {
|
||||
stride: plane.stride,
|
||||
has_fd,
|
||||
};
|
||||
if let Err(e) = proto::send(self.shared.sock.as_fd(), &req, pass) {
|
||||
if let Err(e) = ipc::send(self.shared.sock.as_fd(), &req, pass) {
|
||||
self.mark_dead();
|
||||
return Err(e).context("zerocopy worker died (send)");
|
||||
}
|
||||
let reply = match proto::recv::<Reply>(self.shared.sock.as_fd(), &mut self.rbuf) {
|
||||
let reply = match ipc::recv::<Reply>(self.shared.sock.as_fd(), &mut self.rbuf) {
|
||||
Ok((reply, _)) => reply,
|
||||
Err(e) => {
|
||||
self.mark_dead();
|
||||
@@ -503,7 +368,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 _ = proto::send(shared.sock.as_fd(), &Request::Release { id }, None);
|
||||
let _ = ipc::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);
|
||||
@@ -542,7 +407,7 @@ impl RemoteImporter {
|
||||
});
|
||||
}
|
||||
if !self.dead() {
|
||||
if let Err(e) = proto::send(self.shared.sock.as_fd(), &Request::ClearCache, None) {
|
||||
if let Err(e) = ipc::send(self.shared.sock.as_fd(), &Request::ClearCache, None) {
|
||||
tracing::warn!(error = %e, "zerocopy worker ClearCache failed");
|
||||
self.mark_dead();
|
||||
}
|
||||
@@ -557,10 +422,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(_))) {
|
||||
REAPER.lock().unwrap().push((child, Instant::now()));
|
||||
ipc::park_child(child);
|
||||
}
|
||||
}
|
||||
sweep_reaper();
|
||||
ipc::sweep_reaper();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,11 +484,12 @@ 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) = proto::socketpair_seqpacket().unwrap();
|
||||
proto::send(worker.as_fd(), &reply, None).unwrap();
|
||||
let (host, worker) = ipc::socketpair_seqpacket().unwrap();
|
||||
ipc::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.
|
||||
@@ -634,7 +500,7 @@ mod tests {
|
||||
#[test]
|
||||
fn handshake_ready_and_version_gate() {
|
||||
let host = handshake_server(Reply::Ready {
|
||||
version: proto::PROTO_VERSION,
|
||||
version: PROTO_VERSION,
|
||||
});
|
||||
let imp = RemoteImporter::from_socket(host, None).unwrap();
|
||||
assert!(!imp.dead());
|
||||
@@ -656,7 +522,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn handshake_eof_is_an_error() {
|
||||
let (host, worker) = proto::socketpair_seqpacket().unwrap();
|
||||
let (host, worker) = ipc::socketpair_seqpacket().unwrap();
|
||||
drop(worker);
|
||||
assert!(RemoteImporter::from_socket(host, None).is_err());
|
||||
}
|
||||
@@ -683,36 +549,6 @@ 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", ©).unwrap();
|
||||
let pinned = File::open(©).unwrap();
|
||||
std::fs::remove_file(©).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
|
||||
@@ -723,11 +559,11 @@ mod tests {
|
||||
fn scripted_server(
|
||||
replies: Vec<Reply>,
|
||||
) -> (RemoteImporter, thread::JoinHandle<Vec<SeenRequest>>) {
|
||||
let (host, worker) = proto::socketpair_seqpacket().unwrap();
|
||||
proto::send(
|
||||
let (host, worker) = ipc::socketpair_seqpacket().unwrap();
|
||||
ipc::send(
|
||||
worker.as_fd(),
|
||||
&Reply::Ready {
|
||||
version: proto::PROTO_VERSION,
|
||||
version: PROTO_VERSION,
|
||||
},
|
||||
None,
|
||||
)
|
||||
@@ -736,7 +572,7 @@ mod tests {
|
||||
let mut buf = Vec::new();
|
||||
let mut seen = Vec::new();
|
||||
let mut replies = replies.into_iter();
|
||||
while let Ok((req, fd)) = proto::recv::<Request>(worker.as_fd(), &mut buf) {
|
||||
while let Ok((req, fd)) = ipc::recv::<Request>(worker.as_fd(), &mut buf) {
|
||||
let needs_reply = matches!(req, Request::Modifiers { .. } | Request::Import { .. });
|
||||
let ino = fd
|
||||
.as_ref()
|
||||
@@ -744,7 +580,7 @@ mod tests {
|
||||
seen.push((req, ino));
|
||||
if needs_reply {
|
||||
match replies.next() {
|
||||
Some(r) => proto::send(worker.as_fd(), &r, None).unwrap(),
|
||||
Some(r) => ipc::send(worker.as_fd(), &r, None).unwrap(),
|
||||
None => break, // close → client sees a dead worker
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
//! 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", ©).unwrap();
|
||||
let pinned = PinnedExe::open(©).unwrap();
|
||||
std::fs::remove_file(©).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();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,11 @@
|
||||
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;
|
||||
|
||||
@@ -1,32 +1,20 @@
|
||||
//! Wire protocol between the PipeWire capture thread and the isolated zero-copy GPU-import
|
||||
//! Wire *vocabulary* between the PipeWire capture thread and the isolated zero-copy GPU-import
|
||||
//! worker process (`punktfunk-host zerocopy-worker`; design:
|
||||
//! `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`]).
|
||||
//! `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.
|
||||
//!
|
||||
//! 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.
|
||||
//! 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`]).
|
||||
|
||||
// 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 {
|
||||
@@ -118,197 +106,17 @@ 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 std::io::{Read, Write};
|
||||
use crate::imp::ipc;
|
||||
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_no_fd() {
|
||||
let (a, b) = socketpair_seqpacket().unwrap();
|
||||
fn round_trip_both_directions() {
|
||||
let (a, b) = ipc::socketpair_seqpacket().unwrap();
|
||||
let mut buf = Vec::new();
|
||||
let req = Request::Import {
|
||||
key: 0xdead_beef_u64,
|
||||
@@ -321,8 +129,8 @@ mod tests {
|
||||
stride: 5120 * 4,
|
||||
has_fd: false,
|
||||
};
|
||||
send(a.as_fd(), &req, None).unwrap();
|
||||
let (got, fd) = recv::<Request>(b.as_fd(), &mut buf).unwrap();
|
||||
ipc::send(a.as_fd(), &req, None).unwrap();
|
||||
let (got, fd) = ipc::recv::<Request>(b.as_fd(), &mut buf).unwrap();
|
||||
assert_eq!(got, req);
|
||||
assert!(fd.is_none());
|
||||
|
||||
@@ -336,64 +144,9 @@ mod tests {
|
||||
uv: Some((vec![2u8; 64], 5632)),
|
||||
}),
|
||||
};
|
||||
send(b.as_fd(), &reply, None).unwrap();
|
||||
let (got, fd) = recv::<Reply>(a.as_fd(), &mut buf).unwrap();
|
||||
ipc::send(b.as_fd(), &reply, None).unwrap();
|
||||
let (got, fd) = ipc::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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
|
||||
use super::cuda::{self, CUdeviceptr, DeviceBuffer};
|
||||
use super::egl::{DmabufPlane, EglImporter};
|
||||
use super::proto::{self, BufferDesc, ImportKind, Reply, Request};
|
||||
use super::ipc;
|
||||
use super::proto::{BufferDesc, ImportKind, Reply, Request, PROTO_VERSION};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::io;
|
||||
@@ -27,7 +28,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 (`client::self_exe`), so the kernel
|
||||
// The host execs this worker through its pinned exe fd (`ipc::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
|
||||
@@ -72,7 +73,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 _ = proto::send(
|
||||
let _ = ipc::send(
|
||||
sock.as_fd(),
|
||||
&Reply::InitErr {
|
||||
message: format!("{e:#}"),
|
||||
@@ -82,10 +83,10 @@ fn run(sock: OwnedFd) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
proto::send(
|
||||
ipc::send(
|
||||
sock.as_fd(),
|
||||
&Reply::Ready {
|
||||
version: proto::PROTO_VERSION,
|
||||
version: PROTO_VERSION,
|
||||
},
|
||||
None,
|
||||
)
|
||||
@@ -124,7 +125,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 proto::recv::<Request>(sock.as_fd(), &mut buf) {
|
||||
let (req, fd) = match ipc::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"),
|
||||
@@ -173,7 +174,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 proto::send(sock.as_fd(), reply, None) {
|
||||
match ipc::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"),
|
||||
@@ -434,7 +435,7 @@ mod tests {
|
||||
mpsc::Receiver<String>,
|
||||
std::thread::JoinHandle<Result<()>>,
|
||||
) {
|
||||
let (host, worker) = proto::socketpair_seqpacket().unwrap();
|
||||
let (host, worker) = ipc::socketpair_seqpacket().unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let join = std::thread::spawn(move || {
|
||||
let mut backend = MockBackend { calls: tx, next: 0 };
|
||||
@@ -462,8 +463,8 @@ mod tests {
|
||||
let (host, rx, join) = start_server();
|
||||
let mut buf = Vec::new();
|
||||
|
||||
proto::send(host.as_fd(), &Request::Modifiers { fourcc: 42 }, None).unwrap();
|
||||
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
|
||||
ipc::send(host.as_fd(), &Request::Modifiers { fourcc: 42 }, None).unwrap();
|
||||
let (reply, _) = ipc::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
|
||||
assert_eq!(
|
||||
reply,
|
||||
Reply::Modifiers {
|
||||
@@ -472,8 +473,8 @@ mod tests {
|
||||
);
|
||||
|
||||
// First import delivers the desc; the second (same mock id sequence continues) doesn't.
|
||||
proto::send(host.as_fd(), &import_req(1, false), None).unwrap();
|
||||
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
|
||||
ipc::send(host.as_fd(), &import_req(1, false), None).unwrap();
|
||||
let (reply, _) = ipc::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
|
||||
match reply {
|
||||
Reply::Frame {
|
||||
id: 0,
|
||||
@@ -481,8 +482,8 @@ mod tests {
|
||||
} => {}
|
||||
other => panic!("unexpected reply {other:?}"),
|
||||
}
|
||||
proto::send(host.as_fd(), &import_req(1, false), None).unwrap();
|
||||
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
|
||||
ipc::send(host.as_fd(), &import_req(1, false), None).unwrap();
|
||||
let (reply, _) = ipc::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
|
||||
@@ -490,26 +491,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());
|
||||
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();
|
||||
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();
|
||||
assert_eq!(reply, Reply::Frame { id: 2, desc: None });
|
||||
|
||||
// A missing worker-side fd is a NeedFd reply (host resends), not a failure.
|
||||
proto::send(host.as_fd(), &import_req(0xfeed, false), None).unwrap();
|
||||
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
|
||||
ipc::send(host.as_fd(), &import_req(0xfeed, false), None).unwrap();
|
||||
let (reply, _) = ipc::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
|
||||
assert_eq!(reply, Reply::NeedFd);
|
||||
|
||||
// A failed import is an Err reply, not a dead worker.
|
||||
proto::send(host.as_fd(), &import_req(0xbad, false), None).unwrap();
|
||||
let (reply, _) = proto::recv::<Reply>(host.as_fd(), &mut buf).unwrap();
|
||||
ipc::send(host.as_fd(), &import_req(0xbad, false), None).unwrap();
|
||||
let (reply, _) = ipc::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.
|
||||
proto::send(host.as_fd(), &Request::Release { id: 0 }, None).unwrap();
|
||||
proto::send(host.as_fd(), &Request::ClearCache, None).unwrap();
|
||||
ipc::send(host.as_fd(), &Request::Release { id: 0 }, None).unwrap();
|
||||
ipc::send(host.as_fd(), &Request::ClearCache, None).unwrap();
|
||||
|
||||
// Closing the host end terminates serve() cleanly.
|
||||
drop(host);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user