forked from unom/punktfunk
Compare commits
@@ -112,10 +112,48 @@ jobs:
|
||||
makepkg -f -d --holdver
|
||||
ls -lh "$GITHUB_WORKSPACE/dist"
|
||||
|
||||
# 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.
|
||||
#
|
||||
# CACHED on `packaging/gamescope/**`: it depends on nothing else in this repo, so a normal
|
||||
# push restores the built package instead of spending ~10 minutes on someone else's C++ tree.
|
||||
# Arch is rolling, so the cache is invalidated by our own patch changes only — a stale binary
|
||||
# against newer system libs is the same risk the distro's own package carries between rebuilds.
|
||||
- uses: actions/cache@v4
|
||||
id: gamescope
|
||||
with:
|
||||
path: dist-gamescope
|
||||
key: punktfunk-gamescope-arch-${{ hashFiles('packaging/gamescope/**') }}
|
||||
|
||||
- name: Build punktfunk-gamescope (makepkg)
|
||||
if: steps.gamescope.outputs.cache-hit != 'true'
|
||||
# Best-effort: punktfunk-host works without it (SDR on the gamescope backend), and a
|
||||
# failure building gamescope must not cost the packages this workflow exists to publish.
|
||||
run: |
|
||||
set -x
|
||||
pacman -S --noconfirm --needed \
|
||||
glslang libcap libdrm libinput libx11 libxcomposite libxdamage libxext \
|
||||
libxkbcommon libxmu libxrender libxres libxtst libxxf86vm libavif libdecor \
|
||||
hwdata luajit pipewire seatd sdl2-compat vulkan-icd-loader wayland \
|
||||
xcb-util-errors xcb-util-wm xorg-xwayland \
|
||||
meson cmake glm wayland-protocols benchmark libxcursor || true
|
||||
mkdir -p dist-gamescope && chown builder: dist-gamescope
|
||||
chown -R builder: packaging/gamescope
|
||||
if sudo -u builder env PKGDEST="$GITHUB_WORKSPACE/dist-gamescope" \
|
||||
bash -c 'cd packaging/gamescope && makepkg -f -d --holdver'; then
|
||||
ls -lh dist-gamescope
|
||||
else
|
||||
echo "::warning::punktfunk-gamescope failed to build — Arch boxes stay SDR on the gamescope backend this run"
|
||||
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
|
||||
fi
|
||||
|
||||
- name: Publish to the Gitea Arch registry
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
# The gamescope companion rides the same loop (same repo, same channel).
|
||||
cp -f dist-gamescope/*.pkg.tar.zst dist/ 2>/dev/null || true
|
||||
for pkg in dist/*.pkg.tar.zst; do
|
||||
echo "uploading $pkg"
|
||||
NAME=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p')
|
||||
|
||||
@@ -26,6 +26,30 @@ jobs:
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends libgtk-4-dev libadwaita-1-dev libsdl3-dev
|
||||
|
||||
# The committed pf-zerocopy SPIR-V blobs are pulled in with include_bytes! and rebuilt only
|
||||
# by hand — edit a .comp, forget the rebuild, and the OLD kernel ships with no compile error
|
||||
# or failing test. Recompile each shader and diff the disassembly. Filtering OpSourceExtension
|
||||
# (+ --no-header) is exactly what absorbs the shaderc-vs-glslang generator difference; every
|
||||
# instruction, ID and constant must match.
|
||||
#
|
||||
# Disassemble to FILES rather than `diff <(…) <(…)`: Gitea's runner executes a step's `run:`
|
||||
# under `sh -e`, not bash, and dash has no process substitution — the shell rejected the
|
||||
# script at PARSE time, so the gate never compared anything. Worse, it took the whole `rust`
|
||||
# job with it: Format, Clippy, Build, Test and every gate below were skipped on each of the
|
||||
# 35 commits between the gate landing (143a707f) and this fix. A gate that cannot run is
|
||||
# indistinguishable from one that passes, which is exactly the failure it exists to prevent.
|
||||
- name: Shader SPIR-V drift gate (pf-zerocopy)
|
||||
run: |
|
||||
apt-get install -y --no-install-recommends glslang-tools spirv-tools
|
||||
for s in rgb2nv12_buf cursor_blend; do
|
||||
d=crates/pf-zerocopy/src/imp
|
||||
glslangValidator -V "$d/$s.comp" -o "/tmp/$s.spv" >/dev/null
|
||||
spirv-dis --no-header "$d/$s.spv" | grep -v OpSourceExtension > "/tmp/$s.committed"
|
||||
spirv-dis --no-header "/tmp/$s.spv" | grep -v OpSourceExtension > "/tmp/$s.rebuilt"
|
||||
diff "/tmp/$s.committed" "/tmp/$s.rebuilt" \
|
||||
|| { echo "::error::$d/$s.spv is stale — rebuild it from $s.comp"; exit 1; }
|
||||
done
|
||||
|
||||
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock:
|
||||
# registry/git are download caches, target/ the incremental build. The target key
|
||||
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
|
||||
|
||||
@@ -234,11 +234,18 @@ jobs:
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Same features the old combined build used: --nvenc (direct-SDK NVENC, real RFI on NVIDIA;
|
||||
# NVENC/CUDA is dlopen'd — no link dep, so this image needs no libcuda stub) + --vulkan-encode
|
||||
# (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). punktfunk-tray also ships in the host
|
||||
# .deb (build-deb.sh builds+installs it). ffmpeg-sys-next links the image's bundled FFmpeg 8
|
||||
# via PKG_CONFIG_PATH (set in rust-ci-noble).
|
||||
# (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). ffmpeg-sys-next links the image's
|
||||
# bundled FFmpeg 8 via PKG_CONFIG_PATH (set in rust-ci-noble).
|
||||
#
|
||||
# punktfunk-tray is deliberately NOT in this invocation — build-deb.sh builds it separately,
|
||||
# and that split is load-bearing (see the identical note in the RPM spec / Arch PKGBUILD):
|
||||
# cargo unifies features across one build, so co-building the tray with the host pulls the
|
||||
# host's ashpd -> zbus/tokio onto the tray's shared zbus and the tray panics at every launch
|
||||
# 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.
|
||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||
-p punktfunk-host -p punktfunk-tray
|
||||
-p punktfunk-host
|
||||
|
||||
- name: Build host .deb (FFmpeg bundled)
|
||||
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
|
||||
|
||||
@@ -59,6 +59,11 @@ jobs:
|
||||
dnf -y install gtk4-devel libadwaita-devel SDL3-devel vulkan-headers
|
||||
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
|
||||
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
|
||||
# Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The
|
||||
# sysext folds in our punktfunk-gamescope and verifies it by executing `--version`, and
|
||||
# on a cache hit (the common case) nothing else in this job would have pulled libavif /
|
||||
# luajit / seatd / SDL2 in. Cheap, and it tracks gamescope's dep list for us.
|
||||
dnf -y install gamescope || true
|
||||
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
|
||||
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
|
||||
command -v bun >/dev/null || {
|
||||
@@ -124,13 +129,62 @@ jobs:
|
||||
done
|
||||
echo "published to $OWNER/rpm/$GROUP"
|
||||
|
||||
# The HDR-capable gamescope the sysext carries (packaging/gamescope) — what lets the
|
||||
# gamescope backend stream 10-bit BT.2020 PQ instead of 8-bit SDR.
|
||||
#
|
||||
# CACHED, and that is the whole reason this is affordable: it is a ~10-minute C++ meson build
|
||||
# of an entirely separate tree that depends on NOTHING in this repo except
|
||||
# `packaging/gamescope/**` (the patches and the upstream pin, which lives in the build
|
||||
# script). So the key is that directory's hash and a normal push restores a binary instead of
|
||||
# building one. Per-Fedora-major, because the binary is soname-coupled to its base exactly
|
||||
# like the RPM is — an f43 build does not start on f44 (libavutil.so.59 vs .60).
|
||||
- uses: actions/cache@v4
|
||||
id: gamescope
|
||||
with:
|
||||
path: gs-cache
|
||||
key: punktfunk-gamescope-f${{ matrix.fedver }}-${{ hashFiles('packaging/gamescope/**') }}
|
||||
|
||||
- name: Build the HDR gamescope
|
||||
if: steps.gamescope.outputs.cache-hit != 'true'
|
||||
# Best-effort ON PURPOSE. The sysext is the primary Bazzite delivery path and works without
|
||||
# this binary (the host just stays SDR on the gamescope backend, which is what every
|
||||
# release before this one did) — so a hiccup building someone else's tree must not cost the
|
||||
# whole image. It is loud, though: the warning below, and `--gamescope` silently absent
|
||||
# downstream is impossible because build-sysext.sh verifies the +pfhdr marker itself.
|
||||
run: |
|
||||
set -x
|
||||
# `dnf builddep` resolves Fedora's PACKAGED gamescope, which is older than the master we
|
||||
# pin, so it can come up short — xorg-x11-server-Xwayland-devel is the one that actually
|
||||
# bites (wlroots' configure dies on a missing xserver.wrap several minutes in).
|
||||
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
|
||||
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
|
||||
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
|
||||
fi
|
||||
|
||||
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
|
||||
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
|
||||
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
|
||||
- name: Build the sysext image
|
||||
run: |
|
||||
# Execute it here rather than only handing it over: build-sysext.sh treats an unusable
|
||||
# --version as fatal (rightly — it is how the +pfhdr marker is read), and a cached binary
|
||||
# whose runtime libs are missing from this container must cost the image its HDR, not the
|
||||
# image itself.
|
||||
gs=()
|
||||
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||
gs=(--gamescope gs-cache/punktfunk-gamescope)
|
||||
echo "folding in $(gs-cache/punktfunk-gamescope --version 2>&1 | head -1)"
|
||||
else
|
||||
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
|
||||
fi
|
||||
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
|
||||
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
|
||||
"${gs[@]}" \
|
||||
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
||||
|
||||
@@ -153,9 +153,9 @@ jobs:
|
||||
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
|
||||
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
|
||||
# toolchain-only probe crate and is excluded.)
|
||||
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
||||
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
|
||||
- name: cargo fmt --check the safe-layer + gamepad/mouse drivers
|
||||
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense -p pf-mouse --check
|
||||
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-gamepad -p pf-mouse --check
|
||||
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
|
||||
run: |
|
||||
# explicit --target (.cargo/config.toml) -> output under the triple subdir.
|
||||
|
||||
@@ -172,8 +172,8 @@ jobs:
|
||||
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
|
||||
# pf-vkhdr-layer's clippy below runs --release.
|
||||
#
|
||||
# pf-encode and pf-capture are linted SEPARATELY with --all-targets so their Windows
|
||||
# `#[cfg(test)]` modules are type-checked — pf-encode's AMF C-ABI layout assertions
|
||||
# pf-encode, pf-capture and pf-vdisplay are linted SEPARATELY with --all-targets so their
|
||||
# Windows `#[cfg(test)]` modules are type-checked — pf-encode's AMF C-ABI layout assertions
|
||||
# (`variant_layout_matches_c` and friends, which are the only guard on a hand-mirrored
|
||||
# vtable ABI), the QSV tests, the PyroWave-Windows smoke test; pf-capture's `StallWatch`
|
||||
# tests, the DXGI HDR self-tests and the cursor-conversion tables. The host lint above
|
||||
@@ -199,6 +199,7 @@ jobs:
|
||||
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||
cargo clippy --release -p pf-encode --all-targets --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "pf-encode clippy" }
|
||||
cargo clippy --release -p pf-capture --all-targets -- -D warnings; if ($LASTEXITCODE) { throw "pf-capture clippy" }
|
||||
cargo clippy --release -p pf-vdisplay --all-targets -- -D warnings; if ($LASTEXITCODE) { throw "pf-vdisplay clippy" }
|
||||
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||
|
||||
- name: Test (pf-capture, Windows)
|
||||
@@ -223,6 +224,28 @@ jobs:
|
||||
run: |
|
||||
cargo test --release -p pf-capture; if ($LASTEXITCODE) { throw "pf-capture tests" }
|
||||
|
||||
- name: Test (pf-vdisplay, Windows)
|
||||
shell: pwsh
|
||||
# pf-vdisplay's Windows half is ~3,400 lines (manager.rs, pf_vdisplay.rs, ddc.rs, the three
|
||||
# manager/ submodules) that NO other job compiles — the host lint above builds the crate as
|
||||
# a dependency, so its test targets were reaching no compiler anywhere. Worse, the only two
|
||||
# Windows `#[test]`s were `if env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() { return; }`
|
||||
# early-returns, so an unrun hardware test reported `ok`. They are `#[ignore]`d now and this
|
||||
# step reports them as `ignored`, which is the truth.
|
||||
#
|
||||
# The link objection recorded for the host and pf-encode above does NOT apply here, for the
|
||||
# same reason it does not apply to pf-capture: `pf-encode` is `default = []`, and nothing in
|
||||
# `-p pf-vdisplay`'s graph turns on `nvenc`/`amf-qsv`/`qsv`, so no nvidia/ffmpeg/libvpl
|
||||
# import libs are ever asked for. Settled empirically before this step was added, to the
|
||||
# same standard as pf-capture's: the exact two commands were run on this runner against a
|
||||
# checkout at C:\temp\pf-vd-check — clippy clean, then 46 passed / 2 ignored in 0.19 s.
|
||||
#
|
||||
# --release to reuse C:\t\release rather than spawning a debug tree (the C1069 trigger).
|
||||
# Note this DOES build a second, featureless pf-encode; it is small precisely because none
|
||||
# of the encoder features are on.
|
||||
run: |
|
||||
cargo test --release -p pf-vdisplay; if ($LASTEXITCODE) { throw "pf-vdisplay tests" }
|
||||
|
||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||
shell: pwsh
|
||||
# Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games
|
||||
@@ -436,5 +459,13 @@ jobs:
|
||||
| grep -q "${GITHUB_REF_NAME#v}" \
|
||||
|| { echo "served catalogue does not contain ${GITHUB_REF_NAME#v}"; exit 1; }
|
||||
echo "winget source serving ${GITHUB_REF_NAME#v}"
|
||||
# `env:` below populates the RUNNER's environment; this action runs `script` on the
|
||||
# REMOTE host, which inherits nothing from it. `envs:` is the action's OWN input —
|
||||
# it must live under `with:` (matching docker.yml/deploy-services.yml's REGISTRY_TOKEN
|
||||
# forwarding) — naming the variables to forward into the remote shell. A prior fix put
|
||||
# it as a step-level sibling of `with:`/`env:` instead: that key is not part of the
|
||||
# step schema, so appleboy/ssh-action never received it as an input and the step kept
|
||||
# failing ("GITHUB_REF_NAME: unbound variable") on every tag after 24d2f97e too.
|
||||
envs: GITHUB_REF_NAME
|
||||
env:
|
||||
GITHUB_REF_NAME: ${{ gitea.ref_name }}
|
||||
|
||||
Generated
+35
-27
@@ -1020,6 +1020,13 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.6"
|
||||
@@ -2194,7 +2201,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2299,7 +2306,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2334,7 +2341,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2823,7 +2830,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2844,7 +2851,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2856,6 +2863,7 @@ dependencies = [
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"pyrowave-sys",
|
||||
"rand 0.9.4",
|
||||
"rustls",
|
||||
"sdl3",
|
||||
"serde",
|
||||
@@ -2868,7 +2876,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2886,7 +2894,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2907,7 +2915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2931,7 +2939,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2940,7 +2948,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2952,7 +2960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2966,11 +2974,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2999,14 +3007,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3021,7 +3029,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3053,7 +3061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3065,7 +3073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3273,7 +3281,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3289,7 +3297,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3305,7 +3313,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3320,7 +3328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3339,7 +3347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3371,7 +3379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3455,7 +3463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3469,7 +3477,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3492,7 +3500,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+31
-1
@@ -28,6 +28,7 @@ members = [
|
||||
"clients/session",
|
||||
"clients/windows",
|
||||
"clients/android/native",
|
||||
"tools/display-disturb",
|
||||
"tools/latency-probe",
|
||||
"tools/loss-harness",
|
||||
]
|
||||
@@ -48,13 +49,42 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors = ["unom"]
|
||||
repository = "https://git.unom.io/unom/punktfunk"
|
||||
|
||||
# The `unsafe` discipline the `packaging/windows/drivers/*` crates already run, extended to the
|
||||
# workspace. `unsafe fn` marks a CONTRACT the caller must uphold; it is not a licence for the whole
|
||||
# body to skip checking. Without this lint an `unsafe fn` body is unchecked end to end, so a 600-line
|
||||
# function hides which handful of lines are actually the unsafe ones — exactly the reviewer-hostile
|
||||
# shape we are working down. (This is the Rust 2024 default; adopting it early also pays off the
|
||||
# edition migration.)
|
||||
#
|
||||
# `deny`, not `warn`. `warn` was never actually a softer setting: CI runs `cargo clippy … -D
|
||||
# warnings`, which promotes it to a hard error anyway — that is how adopting this lint turned main
|
||||
# red on every platform for a day without the level in this file ever saying `deny`. A level that
|
||||
# lies about its own severity is worse than a strict one, so this now states what CI already does,
|
||||
# and the exemptions are written down per file instead of hiding in a 689-warning wall nobody reads.
|
||||
#
|
||||
# THE EXEMPTIONS. Fourteen GPU/FFI backend files carry `#![allow(unsafe_op_in_unsafe_fn)]` with a
|
||||
# one-line reason each. They are not "not done yet" — they are where this lint stops paying:
|
||||
# their bodies are ash/CUDA/AMF/libav calls almost line for line (measured: 64% of the sites are a
|
||||
# single third-party FFI call, and of the 44 `unsafe fn`s in them only 4 have a body containing no
|
||||
# unsafe operation at all). Narrowing them means one `unsafe {}` per line plus, since pf-encode also
|
||||
# denies `clippy::undocumented_unsafe_blocks`, one hand-written SAFETY comment per line that could
|
||||
# only ever restate "an ash call on a live device" — the precise noise that made `unsafe` stop
|
||||
# meaning anything here before (see the header of `pf-win-display/src/win_display.rs`).
|
||||
#
|
||||
# Everything else in the workspace is at zero and enforced. Removing one of those allows, file by
|
||||
# file, is real work with a real payoff; blanket-narrowing all fourteen is not. Prefer DELETING an
|
||||
# `unsafe fn` marker over wrapping its body: keep the marker only where a caller can actually break
|
||||
# something (a raw pointer, a borrowed HANDLE, a GPU object that must not be in flight).
|
||||
[workspace.lints.rust]
|
||||
unsafe_op_in_unsafe_fn = "deny"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
|
||||
+154
-3
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.19.2"
|
||||
"version": "0.20.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -190,6 +190,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/display/monitors": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"display"
|
||||
],
|
||||
"summary": "Physical monitors",
|
||||
"description": "The heads this host actually has — for pinning capture at one (`PUNKTFUNK_CAPTURE_MONITOR`) and\nfor rendering a picker. Read-only: this never creates, moves or disables anything. Note these\nare *not* the managed virtual displays — those are `/display/state`. See\n`design/per-monitor-portal-capture.md` §5.1.",
|
||||
"operationId": "getDisplayMonitors",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The host's physical monitors",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MonitorsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/display/presets": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -3646,6 +3678,66 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiMonitorInfo": {
|
||||
"type": "object",
|
||||
"description": "One physical monitor this host has, as the compositor reports it.",
|
||||
"required": [
|
||||
"connector",
|
||||
"description",
|
||||
"mode",
|
||||
"x",
|
||||
"y",
|
||||
"scale",
|
||||
"primary",
|
||||
"enabled",
|
||||
"managed",
|
||||
"selected"
|
||||
],
|
||||
"properties": {
|
||||
"connector": {
|
||||
"type": "string",
|
||||
"description": "Connector name (`DP-1`, `HDMI-A-2`) — the value `PUNKTFUNK_CAPTURE_MONITOR` takes."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Human label for a picker (`make model`, else the connector)."
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Driven right now. A disabled head is still listed, so it can be explained rather than missing."
|
||||
},
|
||||
"managed": {
|
||||
"type": "boolean",
|
||||
"description": "Best-effort: this is one of OUR virtual displays, not a real head (reliable on KWin only)."
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"description": "`WIDTHxHEIGHT@HZ` of the current mode (size only when the refresh is unknown)."
|
||||
},
|
||||
"primary": {
|
||||
"type": "boolean",
|
||||
"description": "The compositor's primary/focused head."
|
||||
},
|
||||
"scale": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"description": "Logical scale factor."
|
||||
},
|
||||
"selected": {
|
||||
"type": "boolean",
|
||||
"description": "True when `PUNKTFUNK_CAPTURE_MONITOR` currently names this monitor."
|
||||
},
|
||||
"x": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Desktop-space top-left — what makes a head identifiable when two share a size."
|
||||
},
|
||||
"y": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiSelectedGpu": {
|
||||
"type": "object",
|
||||
"description": "The GPU the **next** session's pipeline will be created on, and why. (A preference change\napplies to the next session; a running session keeps the GPU it opened on.)",
|
||||
@@ -3820,11 +3912,19 @@
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"encoder_backend": {
|
||||
"type": "string",
|
||||
"description": "The encode backend that ACTUALLY opened for this session — `\"nvenc\"`, `\"vaapi\"`,\n`\"vulkan\"`, `\"amf\"`, `\"qsv\"`, `\"software\"`, … — and the GPU it runs on.\n\nRecorded because the stage split alone can't be read without them. A p50 `submit` of 10 ms\nmeans \"the GPU's CSC+encode throughput is the ceiling\" on one backend and something else\nentirely on another, and every fps-shortfall report so far has cost a round-trip asking\nwhich one it was. Both come from `pf_gpu::active()`, the record the encoder open itself\nwrites, so they name the branch that really opened rather than a re-derived guess.\n\n`\"\"` when nothing was streaming at registration (or on a build without the record)."
|
||||
},
|
||||
"fps": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
},
|
||||
"gpu": {
|
||||
"type": "string",
|
||||
"description": "Human-readable GPU name (`\"NVIDIA GeForce RTX 4090\"`, `\"CPU (openh264)\"`), or `\"\"`."
|
||||
},
|
||||
"height": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
@@ -4251,6 +4351,13 @@
|
||||
"type": "object",
|
||||
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
|
||||
"properties": {
|
||||
"capture_monitor": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "**Mirror a physical monitor instead of creating a virtual display**: the connector name\n(`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.\n\nOrthogonal to `preset`/lifecycle (like `game_session`): a preset change never clears it, and\n`#[serde(default)]` leaves existing `display-settings.json` files untouched. It is a\n**host-wide** setting, not per-client — the host-pinned decision of record in\n`design/per-monitor-portal-capture.md` §5.3. `PUNKTFUNK_CAPTURE_MONITOR` overrides it (see\n[`capture_monitor`]), so an appliance can pin in `host.env` without the console fighting it."
|
||||
},
|
||||
"ddc_power_off": {
|
||||
"type": "boolean",
|
||||
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
|
||||
@@ -5720,6 +5827,43 @@
|
||||
"reject"
|
||||
]
|
||||
},
|
||||
"MonitorsResponse": {
|
||||
"type": "object",
|
||||
"description": "The host's physical monitors + which one capture is pinned to.",
|
||||
"required": [
|
||||
"monitors"
|
||||
],
|
||||
"properties": {
|
||||
"compositor": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Compositor backend the enumeration came from (`kwin`, `mutter`, …), when one was resolved."
|
||||
},
|
||||
"error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Why the list is empty, when enumeration failed (compositor unreachable, unsupported\nplatform). `None` with an empty list means \"asked, and there are none\"."
|
||||
},
|
||||
"monitors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ApiMonitorInfo"
|
||||
},
|
||||
"description": "The heads, ordered left-to-right by desktop position."
|
||||
},
|
||||
"pinned": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing,\nso the console can show \"pinned to DP-2, which this host doesn't have\"."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NativeClient": {
|
||||
"type": "object",
|
||||
"description": "A paired native (punktfunk/1) client.",
|
||||
@@ -6219,6 +6363,7 @@
|
||||
"audio_streaming",
|
||||
"pin_pending",
|
||||
"paired_clients",
|
||||
"native_paired_clients",
|
||||
"active_sessions",
|
||||
"games"
|
||||
],
|
||||
@@ -6240,10 +6385,16 @@
|
||||
},
|
||||
"description": "Every launched game the host is tracking: one row per live session that launched a title, plus\nany game whose session has ended and which is waiting out its reconnect window before being\nended (`state: \"grace\"`). Empty when nothing was launched — a plain desktop stream has no game."
|
||||
},
|
||||
"native_paired_clients": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Number of paired native (punktfunk/1) devices — the default plane, so on a host that has\nnever been touched by Moonlight this is the only non-zero one of the pair.",
|
||||
"minimum": 0
|
||||
},
|
||||
"paired_clients": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"description": "Number of pinned (paired) client certificates.",
|
||||
"description": "Number of pinned (paired) GameStream client certificates. Native (punktfunk/1) devices pair\nagainst a separate store and are counted in `native_paired_clients` — sum the two for\n\"how many clients are paired with this host\".",
|
||||
"minimum": 0
|
||||
},
|
||||
"pin_pending": {
|
||||
@@ -6633,7 +6784,7 @@
|
||||
"mbps": {
|
||||
"type": "number",
|
||||
"format": "float",
|
||||
"description": "Transmit goodput (Mb/s)."
|
||||
"description": "Attempted sealed wire bytes/s (Mb/s): full UDP payloads at seal time — video AU bytes\nplus shard framing (header + AEAD) plus FEC parity, and for PyroWave's datagram-aligned\nmode the zero-padded window tails. NOT goodput, and NOT reduced by socket send drops."
|
||||
},
|
||||
"packets_dropped": {
|
||||
"type": "integer",
|
||||
|
||||
@@ -339,6 +339,17 @@ class MainActivity : ComponentActivity() {
|
||||
return true // consumed
|
||||
}
|
||||
}
|
||||
// A mouse's side buttons, when they arrive key-shaped, are X1/X2 — not navigation.
|
||||
// Resolved before the remote-pointer hook so pointer mode can't eat them as its own
|
||||
// BACK. See [mouseSideButton] for how a mouse's BACK is told from a remote's.
|
||||
mouseSideButton(event)?.let { back ->
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN ->
|
||||
if (event.repeatCount == 0) mouseForwarder?.sideButtonKey(back, true)
|
||||
KeyEvent.ACTION_UP -> mouseForwarder?.sideButtonKey(back, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
// TV remote-as-pointer sees non-gamepad keys first (SELECT long-press toggles it;
|
||||
// while active it owns the D-pad/SELECT/PLAY-PAUSE/BACK).
|
||||
if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
|
||||
@@ -355,13 +366,12 @@ class MainActivity : ComponentActivity() {
|
||||
return true
|
||||
}
|
||||
when (event.keyCode) {
|
||||
// A mouse's back/forward buttons already go over the wire as X1/X2 via their
|
||||
// BUTTON_* motion edges — but Android ALSO delivers them as key events: the input
|
||||
// reader synthesizes KEYCODE_BACK/FORWARD (stamped SOURCE_MOUSE) unconditionally,
|
||||
// and a view-level FALLBACK BACK appears when the BUTTON_* press goes unconsumed.
|
||||
// Swallow every such duplicate or it doubles as Android navigation and yanks the
|
||||
// user out of the stream. A remote/keyboard BACK is never mouse-sourced, so it
|
||||
// still falls through to the BackHandler and exits.
|
||||
// Whatever [mouseSideButton] didn't claim. A view-level FALLBACK BACK appears when
|
||||
// a BUTTON_* press goes unconsumed, and an air-mouse remote stamps its own BACK
|
||||
// SOURCE_MOUSE; both are duplicates of something already handled, and letting
|
||||
// either through doubles as Android navigation and yanks the user out of the
|
||||
// stream. A remote/keyboard BACK is never mouse-sourced, so it still falls through
|
||||
// to the BackHandler and exits.
|
||||
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_FORWARD ->
|
||||
if (event.isFromSource(InputDevice.SOURCE_MOUSE) ||
|
||||
event.flags and KeyEvent.FLAG_FALLBACK != 0
|
||||
@@ -431,6 +441,34 @@ class MainActivity : ComponentActivity() {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* `true` (back) / `false` (forward) when this key event is a MOUSE side button, null when it is
|
||||
* anything else — including a remote's or keyboard's BACK, which must keep exiting the stream.
|
||||
*
|
||||
* A mouse that carries its side buttons on the HID consumer page (AC Back / AC Forward) reaches
|
||||
* us only as `KEYCODE_BACK`/`KEYCODE_FORWARD`, with no `BUTTON_BACK`/`BUTTON_FORWARD` motion
|
||||
* edge behind it — on those, the motion path alone leaves the side buttons dead. The event may
|
||||
* even be stamped SOURCE_KEYBOARD rather than SOURCE_MOUSE, because the consumer-page collection
|
||||
* is a separate sub-device, so the DEVICE is what we ask: it has to be able to be a mouse.
|
||||
*
|
||||
* A D-pad-capable device is excluded even when it also reports a pointer: that is an air-mouse
|
||||
* remote, whose BACK is the couch user's way out of the stream and must stay navigation.
|
||||
* FLAG_FALLBACK events are excluded too — those are a duplicate the framework raises after an
|
||||
* unconsumed BUTTON_* press, i.e. one the motion path already forwarded.
|
||||
*/
|
||||
private fun mouseSideButton(event: KeyEvent): Boolean? {
|
||||
val back = when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_BACK -> true
|
||||
KeyEvent.KEYCODE_FORWARD -> false
|
||||
else -> return null
|
||||
}
|
||||
if (event.flags and KeyEvent.FLAG_FALLBACK != 0) return null
|
||||
val device = event.device ?: return null
|
||||
if (!device.supportsSource(InputDevice.SOURCE_MOUSE)) return null
|
||||
if (device.supportsSource(InputDevice.SOURCE_DPAD)) return null
|
||||
return back
|
||||
}
|
||||
|
||||
/** Last D-pad direction synthesised from a stick/HAT — edge detection (one focus move per push). */
|
||||
private var lastNavDir = 0
|
||||
|
||||
|
||||
@@ -180,6 +180,22 @@ class MouseForwarder(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A mouse side button that arrived as a KEY event rather than a BUTTON_* motion edge.
|
||||
*
|
||||
* Not every mouse reports its side buttons the same way. One that puts them on the HID button
|
||||
* page (BTN_SIDE/BTN_EXTRA) gets `BUTTON_BACK`/`BUTTON_FORWARD` in the motion button state and
|
||||
* lands in [button]. One that puts them on the consumer page (AC Back / AC Forward — common on
|
||||
* Bluetooth mice, and the shape Android TV boxes tend to see) produces ONLY synthesized
|
||||
* `KEYCODE_BACK`/`KEYCODE_FORWARD` key events, so [button] never fires and the side buttons are
|
||||
* dead on the wire. This is the key-shaped entry point for those.
|
||||
*
|
||||
* Devices that report BOTH send the key first and the motion edge second (that is the order the
|
||||
* input reader synthesizes them in), so both paths funnel into the same held-set and the
|
||||
* add/remove guard collapses the pair into a single wire press.
|
||||
*/
|
||||
fun sideButtonKey(back: Boolean, down: Boolean) = press(if (back) 4 else 5, down)
|
||||
|
||||
private fun button(actionButton: Int, down: Boolean) {
|
||||
val b = when (actionButton) {
|
||||
MotionEvent.BUTTON_PRIMARY -> 1
|
||||
@@ -189,9 +205,15 @@ class MouseForwarder(
|
||||
MotionEvent.BUTTON_FORWARD -> 5
|
||||
else -> return
|
||||
}
|
||||
press(b, down)
|
||||
}
|
||||
|
||||
private fun press(b: Int, down: Boolean) {
|
||||
if (down) {
|
||||
heldButtons.add(b)
|
||||
NativeBridge.nativeSendPointerButton(handle, b, true)
|
||||
// add() is false when the button is already held — the second delivery of a button
|
||||
// this device reports on two paths at once. Sending the down again would double-press
|
||||
// it on the host.
|
||||
if (heldButtons.add(b)) NativeBridge.nativeSendPointerButton(handle, b, true)
|
||||
} else if (heldButtons.remove(b)) {
|
||||
// Only release what we pressed — drops the release of a swallowed engaging click
|
||||
// and anything that raced a capture transition.
|
||||
|
||||
@@ -64,3 +64,6 @@ libc = "0.2"
|
||||
# host + Linux client use. audiopus_sys vendors libopus (pure C) and builds it static via cmake —
|
||||
# the cargo-ndk build sets LIBOPUS_STATIC=1/LIBOPUS_NO_PKG=1 so it links the bundled lib, not the host's.
|
||||
opus = "0.3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -86,6 +86,13 @@ public final class InputCapture {
|
||||
/// its Esc suppression need it in both states).
|
||||
private var cmdKeysDown: Set<UInt32> = []
|
||||
|
||||
#if !os(macOS)
|
||||
/// The key currently auto-repeating, and the timer driving it. iOS/tvOS only — see
|
||||
/// `startAutoRepeat`. Main-queue only, like every other field here.
|
||||
private var autoRepeatVK: UInt32?
|
||||
private var autoRepeatTimer: Timer?
|
||||
#endif
|
||||
|
||||
/// Physical Control/Option/Shift keys currently held (Windows VKs, both L/R sides). iPad only:
|
||||
/// the ⌃⌥⇧Q release chord is recognized from the HID stream here (iOS has no NSEvent monitor,
|
||||
/// like the ⌘⎋ toggle), so it needs the live modifier state — tracked in both forwarding states,
|
||||
@@ -322,6 +329,9 @@ public final class InputCapture {
|
||||
}
|
||||
mice.removeAll()
|
||||
keyboards.removeAll()
|
||||
#if !os(macOS)
|
||||
stopAutoRepeat()
|
||||
#endif
|
||||
}
|
||||
|
||||
deinit { stop() }
|
||||
@@ -330,6 +340,9 @@ public final class InputCapture {
|
||||
/// and modifier/latch tracking (GC delivers nothing while inactive, so a ⌘ released
|
||||
/// in another app would otherwise stay "held" here forever — hijacking Esc).
|
||||
private func releaseAll() {
|
||||
#if !os(macOS)
|
||||
stopAutoRepeat() // before the releases below, so the ticker can't outlive the key-up
|
||||
#endif
|
||||
cmdKeysDown.removeAll()
|
||||
chordModifiersDown.removeAll()
|
||||
suppressedVK = nil
|
||||
@@ -347,6 +360,58 @@ public final class InputCapture {
|
||||
residualScrollY = 0
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
/// Windows VKs that must never auto-repeat: the modifiers (a held Shift is a state, not a
|
||||
/// stream of presses) and the lock keys (each repeat would toggle the light again).
|
||||
private static let noAutoRepeatVKs: Set<UInt32> = [
|
||||
0x10, 0xA0, 0xA1, // Shift, LShift, RShift
|
||||
0x11, 0xA2, 0xA3, // Control, LControl, RControl
|
||||
0x12, 0xA4, 0xA5, // Alt, LAlt, RAlt
|
||||
0x5B, 0x5C, // LWin, RWin
|
||||
0x14, 0x90, 0x91, // CapsLock, NumLock, ScrollLock
|
||||
]
|
||||
|
||||
/// Start (or hand over) the held-key auto-repeat, matching a real keyboard's delay-then-rate.
|
||||
///
|
||||
/// GameController reports a key ONCE on press and once on release — it has no repeat channel —
|
||||
/// and the host injects exactly what it is told, so nothing downstream ever turns a held key
|
||||
/// into a stream of presses. Holding Backspace deleted a single character. macOS is unaffected:
|
||||
/// its NSEvent path already receives the window server's own repeats and forwards them.
|
||||
///
|
||||
/// Only the newest key repeats, which is what a hardware keyboard does — pressing a second key
|
||||
/// takes the repeat over from the first. Timings are the iOS/macOS defaults (~0.5 s delay,
|
||||
/// ~25 Hz); `.common` keeps it running while a scroll or gesture is tracking.
|
||||
private func startAutoRepeat(_ vk: UInt32) {
|
||||
guard !Self.noAutoRepeatVKs.contains(vk) else { return }
|
||||
stopAutoRepeat()
|
||||
autoRepeatVK = vk
|
||||
let timer = Timer(timeInterval: 0.5, repeats: false) { [weak self] _ in
|
||||
guard let self, self.autoRepeatVK == vk else { return }
|
||||
let ticker = Timer(timeInterval: 0.04, repeats: true) { [weak self] _ in
|
||||
// Stop the moment the key is no longer held — a release that raced the timer, or a
|
||||
// releaseAll from a blur, must not keep typing into the host.
|
||||
guard let self, self.autoRepeatVK == vk, self.forwarding,
|
||||
self.pressedVKs.contains(vk)
|
||||
else {
|
||||
self?.stopAutoRepeat()
|
||||
return
|
||||
}
|
||||
self.emitKey(vk, down: true)
|
||||
}
|
||||
self.autoRepeatTimer = ticker
|
||||
RunLoop.main.add(ticker, forMode: .common)
|
||||
}
|
||||
autoRepeatTimer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
|
||||
private func stopAutoRepeat() {
|
||||
autoRepeatTimer?.invalidate()
|
||||
autoRepeatTimer = nil
|
||||
autoRepeatVK = nil
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The single wire boundary for a key event. Every `.key` send funnels through here so the
|
||||
/// active location-based modifier layout is applied in exactly one place while all internal
|
||||
/// press/release bookkeeping (`pressedVKs`, `cmdKeysDown`, `resolveModifier`'s `isDown`) stays on
|
||||
@@ -534,16 +599,24 @@ public final class InputCapture {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scroll WHEEL (plain HID mice) while pointer-locked: GCMouse's scroll dpad reports
|
||||
// wheel deltas here, +y up / +x right — already the host's WHEEL convention, one unit
|
||||
// per notch → ×120 (WHEEL_DELTA), residual-accumulated by sendScroll. (Trackpad
|
||||
// two-finger scrolling is gesture-based and does NOT reach GameController — that
|
||||
// arrives via the stream view's scroll pan recognizer; on macOS, via scrollWheel.)
|
||||
// Scroll WHEEL, tvOS only. GCMouse's scroll dpad reports raw device deltas, +y up / +x
|
||||
// right — the host's WHEEL convention already, one unit per notch → ×120 (WHEEL_DELTA),
|
||||
// residual-accumulated by sendScroll.
|
||||
//
|
||||
// iOS deliberately installs no handler here and takes ALL scroll from the stream view's
|
||||
// pan recognizer instead — the same one that carries trackpad two-finger scrolling, which
|
||||
// is gesture-based and never reaches GameController. That recognizer sees a plain wheel
|
||||
// too, and unlike this raw axis its deltas already carry the system's Natural Scrolling
|
||||
// preference, so routing everything through it is what makes the setting apply under
|
||||
// pointer lock. Installing both would double-send every wheel notch. (macOS has its own
|
||||
// path: StreamLayerView.scrollWheel.)
|
||||
#if os(tvOS)
|
||||
input.scroll.valueChangedHandler = { [weak self] _, dx, dy in
|
||||
guard let self, self.forwarding, self.gcMouseForwarding else { return }
|
||||
self.sendScroll(dx: dx * 120, dy: dy * 120)
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Forward relative mouse motion (macOS). Fed by StreamLayerView's NSEvent monitor —
|
||||
@@ -676,6 +749,14 @@ public final class InputCapture {
|
||||
self.pressedVKs.remove(vk)
|
||||
}
|
||||
self.emitKey(vk, down: pressed)
|
||||
// GC has no repeat channel, so a held key becomes a repeat here (see startAutoRepeat).
|
||||
// A release only cancels the repeat if it is THIS key's — releasing an older key while
|
||||
// a newer one is still held must leave the newer one repeating.
|
||||
if pressed {
|
||||
self.startAutoRepeat(vk)
|
||||
} else if self.autoRepeatVK == vk {
|
||||
self.stopAutoRepeat()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -368,9 +368,14 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
guard self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
self.inputCapture?.sendMouseButton(button, pressed: down)
|
||||
}
|
||||
// Scroll is the ONE indirect channel that is NOT gated on the lock. The scroll pan keeps
|
||||
// firing while the scene is pointer-locked (it is the only way trackpad two-finger scrolling
|
||||
// ever arrives — GameController has no gesture channel), so gating it here dropped trackpad
|
||||
// scrolling entirely under lock. Nothing double-sends because iOS installs no GCMouse scroll
|
||||
// handler at all: this recognizer sees the wheel too, already carrying the system's Natural
|
||||
// Scrolling preference, which the raw GameController axis does not.
|
||||
streamView.onScroll = { [weak self] dx, dy in
|
||||
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
self.inputCapture?.sendScroll(dx: dx, dy: dy)
|
||||
self?.inputCapture?.sendScroll(dx: dx, dy: dy)
|
||||
}
|
||||
|
||||
let capture = InputCapture(connection: connection)
|
||||
@@ -920,14 +925,21 @@ final class StreamLayerUIView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Trackpad / wheel scroll (no lock) → host scroll deltas. The translation is consumed
|
||||
/// each callback so the next is a fresh delta. Sign/scale are tunable (≈ one notch per
|
||||
/// ~10 pt): finger up scrolls up (host +y), x passes through — the host WHEEL convention.
|
||||
/// Trackpad / wheel scroll → host scroll deltas. The translation is consumed each callback so
|
||||
/// the next is a fresh delta, and scales at ≈ one WHEEL notch per 10 pt of pan.
|
||||
///
|
||||
/// Both axes pass through with their sign intact, which is what makes the stream follow the
|
||||
/// system's Natural Scrolling switch: UIKit has already applied that preference by the time it
|
||||
/// hands us a translation (it is what makes every UIScrollView on the device turn the right
|
||||
/// way), so the sign we get IS the user's choice, and the host's WHEEL convention agrees with
|
||||
/// it — +y is a wheel-forward notch, the one that moves content down. Negating y here, as this
|
||||
/// did, pinned the stream to traditional scrolling and inverted the setting for everyone on the
|
||||
/// default. macOS passes `NSEvent.scrollingDeltaY` through for exactly the same reason.
|
||||
@objc private func handleScroll(_ g: UIPanGestureRecognizer) {
|
||||
guard g.state == .began || g.state == .changed else { return }
|
||||
let t = g.translation(in: self)
|
||||
g.setTranslation(.zero, in: self)
|
||||
onScroll?(Float(t.x) * 12, Float(-t.y) * 12)
|
||||
onScroll?(Float(t.x) * 12, Float(t.y) * 12)
|
||||
}
|
||||
|
||||
/// Map a view-space point through the aspect-fit letterbox into host-mode pixels; points
|
||||
@@ -944,9 +956,21 @@ final class StreamLayerUIView: UIView {
|
||||
return HostPoint(x: x, y: y, w: UInt32(hostMode.width), h: UInt32(hostMode.height))
|
||||
}
|
||||
|
||||
/// `.secondary` (right button / two-finger click) → GameStream right (3); else left (1).
|
||||
/// UIKit's button mask → the wire's GameStream button number.
|
||||
///
|
||||
/// The mask is 1-based over the HID button order — 1 primary, 2 secondary, 3 middle, 4/5 the
|
||||
/// side buttons — while the wire numbers middle and right the other way round (1 left,
|
||||
/// 2 middle, 3 right, 4 X1/back, 5 X2/forward), so only those two swap. Without the 3…5 arms
|
||||
/// every button past the first two fell into the `else` and clicked LEFT on the host.
|
||||
///
|
||||
/// `.primary`/`.secondary` are spelled out because they are the only two named cases; the rest
|
||||
/// come from `.button(_:)`, which takes the same 1-based number.
|
||||
private static func gsButton(for mask: UIEvent.ButtonMask) -> UInt32 {
|
||||
mask.contains(.secondary) ? 3 : 1
|
||||
if mask.contains(.secondary) { return 3 }
|
||||
if mask.contains(.button(3)) { return 2 }
|
||||
if mask.contains(.button(4)) { return 4 }
|
||||
if mask.contains(.button(5)) { return 5 }
|
||||
return 1
|
||||
}
|
||||
|
||||
private func nextFreeID() -> UInt32 {
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
# firing Wake-on-LAN so the connect survives the host's resume)
|
||||
# PF_APPID flatpak app id (default io.unom.Punktfunk)
|
||||
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
|
||||
# PF_CLIENT_BIN absolute path of a NATIVE client (optional; set by the plugin when it
|
||||
# resolved a non-flatpak install — then the client is exec'd directly and
|
||||
# PF_APPID/PF_FLATPAK are unused)
|
||||
#
|
||||
# Values are plain tokens (the plugin validates launch ids to space/quote-free ASCII before
|
||||
# they ever reach Steam launch options). An older flatpak without --launch/--browse ignores
|
||||
@@ -38,8 +41,23 @@ set -u
|
||||
APPID="${PF_APPID:-io.unom.Punktfunk}"
|
||||
FLATPAK="${PF_FLATPAK:-flatpak}"
|
||||
|
||||
# exec so the flatpak client IS the game process — when it exits, Steam ends the "game" and
|
||||
# Gaming Mode reclaims focus automatically (no manual refocus needed).
|
||||
# The client is not always the flatpak: a sysext, a .deb/.rpm, an AUR build or a nix profile
|
||||
# installs a native `punktfunk-client`, and the plugin passes its absolute path here when that
|
||||
# is what it resolved. Both kinds take the same argv and share ~/.config/punktfunk, so the only
|
||||
# difference is the prefix in front of it.
|
||||
#
|
||||
# exec so the client IS the game process — when it exits, Steam ends the "game" and Gaming Mode
|
||||
# reclaims focus automatically (no manual refocus needed).
|
||||
run_client() {
|
||||
if [ -n "${PF_CLIENT_BIN:-}" ]; then
|
||||
exec "$PF_CLIENT_BIN" "$@"
|
||||
fi
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" "$@"
|
||||
}
|
||||
|
||||
# What we are about to run, for the log line each branch prints.
|
||||
CLIENT_LABEL="${PF_CLIENT_BIN:-$APPID}"
|
||||
|
||||
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the
|
||||
# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it).
|
||||
if [ -n "${PF_BROWSE:-}" ]; then
|
||||
@@ -48,14 +66,14 @@ if [ -n "${PF_BROWSE:-}" ]; then
|
||||
# library shortcut launches. `--browse <host>` opens straight into that host's library (the
|
||||
# per-host "open on screen" action). A streams a game, session end returns here, B quits.
|
||||
if [ -z "${PF_HOST:-}" ]; then
|
||||
echo "punktfunkrun: gamepad UI $APPID --browse (console home)" >&2
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse --fullscreen
|
||||
echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2
|
||||
run_client --browse --fullscreen
|
||||
fi
|
||||
echo "punktfunkrun: library $APPID --browse $PF_HOST" >&2
|
||||
echo "punktfunkrun: library $CLIENT_LABEL --browse $PF_HOST" >&2
|
||||
if [ -n "${PF_MGMT:-}" ]; then
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
|
||||
run_client --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
|
||||
fi
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen
|
||||
run_client --browse "$PF_HOST" --fullscreen
|
||||
fi
|
||||
|
||||
# Streaming modes need a host (browse above is the only host-less path).
|
||||
@@ -72,8 +90,8 @@ if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then
|
||||
fi
|
||||
if [ -n "${PF_LAUNCH:-}" ]; then
|
||||
# A pinned game: the id rides the session Hello and the host launches that title.
|
||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
|
||||
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST --launch $PF_LAUNCH" >&2
|
||||
run_client --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@"
|
||||
fi
|
||||
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
|
||||
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" "$@"
|
||||
echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST" >&2
|
||||
run_client --connect "$PF_HOST" "$@"
|
||||
|
||||
+112
-24
@@ -328,10 +328,75 @@ def _flatpak() -> str | None:
|
||||
)
|
||||
|
||||
|
||||
# --- which client is installed -------------------------------------------------------------
|
||||
#
|
||||
# The flatpak is the Deck's usual client, but it is not the only one: a sysext, a .deb/.rpm, an
|
||||
# AUR build, a nix profile and a hand-built binary all install a NATIVE `punktfunk-client`, and
|
||||
# on those the plugin used to be dead in the water — every headless call went through
|
||||
# `flatpak run io.unom.Punktfunk` and simply failed. Both kinds keep identity, known-hosts and
|
||||
# settings in the same ~/.config/punktfunk (the flatpak's sandbox HOME resolves to the real
|
||||
# home), so nothing else in this file has to care which one answered.
|
||||
NATIVE_BIN = "punktfunk-client"
|
||||
|
||||
# Prefixes to try when PATH doesn't have it. The Decky backend runs with a minimal PATH, and
|
||||
# SteamOS's read-only /usr pushes native installs into a sysext or the user's own prefix.
|
||||
_NATIVE_PREFIXES = (
|
||||
"/usr/bin",
|
||||
"/usr/local/bin",
|
||||
"/run/host/usr/bin",
|
||||
"/var/lib/extensions/punktfunk/usr/bin",
|
||||
)
|
||||
|
||||
|
||||
def _native_client() -> str | None:
|
||||
"""Absolute path of a native (non-flatpak) client binary, or None."""
|
||||
found = shutil.which(NATIVE_BIN, path=os.environ.get("PATH", "") + ":" + ":".join(_NATIVE_PREFIXES))
|
||||
if found:
|
||||
return found
|
||||
for prefix in (str(Path(decky.DECKY_USER_HOME) / ".local" / "bin"),):
|
||||
candidate = Path(prefix) / NATIVE_BIN
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
return None
|
||||
|
||||
|
||||
def _flatpak_installed() -> bool:
|
||||
"""True when the flatpak APP is actually installed — not merely that `flatpak` exists.
|
||||
|
||||
Checked by the app's own exported directory rather than by shelling out to `flatpak info`,
|
||||
because this is on the path of every headless call and a subprocess per call would be absurd.
|
||||
Both scopes count: the Deck installs --user, a distro image may ship it system-wide.
|
||||
"""
|
||||
if not _flatpak():
|
||||
return False
|
||||
user = Path(decky.DECKY_USER_HOME) / ".local" / "share" / "flatpak" / "app" / APP_ID
|
||||
return user.exists() or Path("/var/lib/flatpak/app", APP_ID).exists()
|
||||
|
||||
|
||||
def _client_argv() -> list[str] | None:
|
||||
"""The argv PREFIX that runs the client headlessly, or None when no client is installed.
|
||||
|
||||
Flatpak wins when it is installed: it is the tested Deck path, so an existing install keeps
|
||||
behaving exactly as it did. A native binary is the fallback — and on a machine with no
|
||||
flatpak client, the thing that makes the plugin work at all. `PF_DECKY_CLIENT=native|flatpak`
|
||||
forces one when a machine has both.
|
||||
"""
|
||||
forced = os.environ.get("PF_DECKY_CLIENT", "").strip().lower()
|
||||
native = _native_client()
|
||||
if forced == "native":
|
||||
return [native] if native else None
|
||||
if forced != "flatpak" and not _flatpak_installed() and native:
|
||||
return [native]
|
||||
if _flatpak_installed():
|
||||
return [_flatpak(), "run", "--arch=x86_64", APP_ID]
|
||||
return [native] if native else None
|
||||
|
||||
|
||||
def _flatpak_env() -> dict:
|
||||
"""Environment for a headless ``flatpak run`` from the backend (no display needed for
|
||||
pairing). Reconstruct the user-session bits flatpak wants; the backend may not inherit
|
||||
them. Harmless if some are already set."""
|
||||
"""Environment for a headless client run from the backend (no display needed for pairing).
|
||||
Reconstruct the user-session bits flatpak wants; the backend may not inherit them. Harmless
|
||||
if some are already set — and correct for a NATIVE client too, which needs the same HOME and
|
||||
the same LD_LIBRARY_PATH repair below."""
|
||||
env = dict(os.environ)
|
||||
# Decky Loader is a PyInstaller binary: it prepends its bundled libs (an older libssl) to
|
||||
# LD_LIBRARY_PATH (its /tmp/_MEI* unpack dir), and that env leaks into our subprocess. The
|
||||
@@ -388,17 +453,19 @@ async def _flatpak_capture(args: list[str], timeout: float = 20.0) -> tuple[int,
|
||||
|
||||
|
||||
async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[int, str, str]:
|
||||
"""Run the flatpak CLIENT headlessly (``flatpak run … io.unom.Punktfunk <client_args>``) with
|
||||
the user-session env, returning ``(returncode, stdout, stderr)`` with SEPARATE pipes so a JSON
|
||||
payload on stdout stays clean of the client's log lines on stderr. ``(-1, "", "")`` when
|
||||
flatpak is missing or the call errors/times out. This is the single entry point for the
|
||||
headless host-store modes (``--list-hosts`` / ``--add-host`` / ``--set-host`` /
|
||||
``--forget-host`` / ``--reset`` / ``--reachable``), which mutate the SAME
|
||||
``client-known-hosts.json`` the desktop client reads — so state is shared, not duplicated."""
|
||||
flatpak = _flatpak()
|
||||
if not flatpak:
|
||||
"""Run the CLIENT headlessly with the user-session env, returning ``(returncode, stdout,
|
||||
stderr)`` with SEPARATE pipes so a JSON payload on stdout stays clean of the client's log
|
||||
lines on stderr. ``(-1, "", "")`` when no client is installed or the call errors/times out.
|
||||
|
||||
Whether that client is the flatpak or a native install is [_client_argv]'s business; both
|
||||
read and write the SAME ``client-known-hosts.json`` the desktop client uses. This is the
|
||||
single entry point for the headless host-store modes (``--list-hosts`` / ``--add-host`` /
|
||||
``--set-host`` / ``--forget-host`` / ``--reset`` / ``--reachable``), so state is shared, not
|
||||
duplicated."""
|
||||
prefix = _client_argv()
|
||||
if not prefix:
|
||||
return -1, "", ""
|
||||
argv = [flatpak, "run", "--arch=x86_64", APP_ID, *client_args]
|
||||
argv = [*prefix, *client_args]
|
||||
proc = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
@@ -885,9 +952,22 @@ class Plugin:
|
||||
"""The wrapper-script path + flatpak app id the frontend needs to create the Steam
|
||||
shortcut. The shortcut invokes the script through ``/bin/sh`` (see steam.ts), so no
|
||||
exec bit is needed — Decky's zip extraction drops it, and the root-owned plugins dir
|
||||
means this unprivileged backend couldn't chmod it back on anyway."""
|
||||
means this unprivileged backend couldn't chmod it back on anyway.
|
||||
|
||||
``client_bin`` is set only when the resolved client is a NATIVE install; the frontend
|
||||
passes it to the wrapper as ``PF_CLIENT_BIN`` so the launch execs the binary instead of
|
||||
``flatpak run``. Absent = the wrapper's flatpak default, i.e. every existing Deck
|
||||
install is unaffected."""
|
||||
path = _runner_path()
|
||||
return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()}
|
||||
prefix = _client_argv()
|
||||
native = bool(prefix) and prefix[0] != _flatpak()
|
||||
return {
|
||||
"runner": path,
|
||||
"app_id": APP_ID,
|
||||
"exists": Path(path).exists(),
|
||||
"client_kind": "native" if native else ("flatpak" if prefix else "none"),
|
||||
"client_bin": prefix[0] if native else "",
|
||||
}
|
||||
|
||||
async def get_settings(self) -> dict:
|
||||
"""Read the flatpak client's stream settings (resolution/bitrate/gamepad…)."""
|
||||
@@ -1032,28 +1112,36 @@ class Plugin:
|
||||
}
|
||||
|
||||
async def kill_stream(self) -> dict:
|
||||
"""Force-stop a wedged stream client (``flatpak kill``)."""
|
||||
flatpak = _flatpak()
|
||||
if not flatpak:
|
||||
return {"ok": False, "error": "flatpak-not-found"}
|
||||
"""Force-stop a wedged stream client — ``flatpak kill`` for the sandboxed one, a plain
|
||||
SIGTERM by name for a native install (which has no flatpak instance to kill)."""
|
||||
prefix = _client_argv()
|
||||
if not prefix:
|
||||
return {"ok": False, "error": "client-not-found"}
|
||||
if prefix[0] == _flatpak():
|
||||
argv = [prefix[0], "kill", APP_ID]
|
||||
else:
|
||||
# -x: whole-name match, so this can only ever hit the client itself.
|
||||
killer = shutil.which("pkill") or "/usr/bin/pkill"
|
||||
argv = [killer, "-x", NATIVE_BIN]
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
flatpak, "kill", APP_ID,
|
||||
*argv,
|
||||
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
|
||||
env=_flatpak_env(),
|
||||
)
|
||||
await asyncio.wait_for(proc.wait(), timeout=10.0)
|
||||
except Exception: # noqa: BLE001
|
||||
decky.logger.exception("flatpak kill failed")
|
||||
decky.logger.exception("kill_stream (%s) failed", argv[0])
|
||||
return {"ok": False}
|
||||
return {"ok": True}
|
||||
|
||||
async def update_client(self) -> dict:
|
||||
"""Update the flatpak **client** (io.unom.Punktfunk) in the USER installation — the scope a
|
||||
Steam Deck install lives in, which ``sudo flatpak update`` (system-scope) never reaches.
|
||||
Returns whether a new commit was actually pulled. Best-effort; non-fatal."""
|
||||
flatpak = _flatpak()
|
||||
if not flatpak:
|
||||
Returns whether a new commit was actually pulled. Best-effort; non-fatal. A NATIVE client
|
||||
is updated by whatever installed it (distro package manager, sysext, nix), never here —
|
||||
`check_update` reports no client update for one, so the UI never offers this."""
|
||||
if not _flatpak_installed():
|
||||
return {"ok": False, "updated": False, "error": "flatpak-not-found"}
|
||||
_, before = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
|
||||
before_commit = _field_from(before, "Commit")
|
||||
|
||||
@@ -90,6 +90,11 @@ export interface RunnerInfo {
|
||||
runner: string; // absolute path to bin/punktfunkrun.sh
|
||||
app_id: string; // flatpak app id
|
||||
exists: boolean;
|
||||
// Which client the backend resolved: the flatpak, a native install (.deb/rpm/sysext/AUR/nix),
|
||||
// or none at all. Older backends send neither field — hence optional.
|
||||
client_kind?: "flatpak" | "native" | "none";
|
||||
// Absolute path of the native binary; "" for flatpak. Passed to the wrapper as PF_CLIENT_BIN.
|
||||
client_bin?: string;
|
||||
}
|
||||
|
||||
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
|
||||
|
||||
@@ -214,7 +214,7 @@ async function ensureControllerConfig(): Promise<void> {
|
||||
* the current runner path. Reuses/repoints the remembered shortcut (the plugin dir can change
|
||||
* across reinstalls, and pre-two-shortcut installs had this one visible).
|
||||
*/
|
||||
async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }> {
|
||||
async function ensureStreamShortcut(): Promise<{ appId: number; runner: string; clientBin: string }> {
|
||||
const info = await runnerInfo();
|
||||
if (!info.exists) {
|
||||
throw new Error(`launch wrapper missing at ${info.runner}`);
|
||||
@@ -231,7 +231,7 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }
|
||||
SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
|
||||
setShortcutHidden(remembered, true); // migrate pre-two-shortcut installs (were visible)
|
||||
void applyArtwork(remembered);
|
||||
return { appId: remembered, runner: info.runner };
|
||||
return { appId: remembered, runner: info.runner, clientBin: info.client_bin ?? "" };
|
||||
}
|
||||
|
||||
const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
|
||||
@@ -239,7 +239,7 @@ async function ensureStreamShortcut(): Promise<{ appId: number; runner: string }
|
||||
setShortcutHidden(appId, true);
|
||||
void applyArtwork(appId);
|
||||
remember(STORAGE_KEY_STREAM, appId);
|
||||
return { appId, runner: info.runner };
|
||||
return { appId, runner: info.runner, clientBin: info.client_bin ?? "" };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,7 +259,10 @@ export async function ensureGamepadUiShortcut(): Promise<number | null> {
|
||||
void ensureControllerConfig();
|
||||
// Bare browse: PF_BROWSE with no PF_HOST → the wrapper runs `--browse --fullscreen` (console
|
||||
// home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg.
|
||||
const launchOpts = `PF_BROWSE=1 %command% "${info.runner}"`;
|
||||
// PF_CLIENT_BIN only when the backend resolved a NATIVE client — else the wrapper's flatpak
|
||||
// default stands and this shortcut is exactly what it always was.
|
||||
const clientBin = info.client_bin ? `PF_CLIENT_BIN=${info.client_bin} ` : "";
|
||||
const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`;
|
||||
|
||||
// Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose
|
||||
// localStorage key survived a plugin reinstall) falls through to AddShortcut so the visible
|
||||
@@ -357,9 +360,14 @@ export async function launchStream(
|
||||
// Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is
|
||||
// known), and the connect that follows has its own retry window, so a failure never blocks launch.
|
||||
const waking = wake(host, port).catch(() => ({ ok: false }));
|
||||
const [{ appId, runner }, woke] = await Promise.all([ensureStreamShortcut(), waking]);
|
||||
const [{ appId, runner, clientBin }, woke] = await Promise.all([ensureStreamShortcut(), waking]);
|
||||
const target = port && port !== 9777 ? `${host}:${port}` : host;
|
||||
const env = [`PF_HOST=${target}`];
|
||||
// Set only for a NATIVE client install; absent, the wrapper takes its flatpak default, so every
|
||||
// existing Deck install produces byte-identical launch options to before.
|
||||
if (clientBin) {
|
||||
env.push(`PF_CLIENT_BIN=${clientBin}`);
|
||||
}
|
||||
// A magic packet actually went out (a MAC was known), so the host may be mid-resume from
|
||||
// suspend — that takes far longer than the client's default 15 s connect budget. Stretch the
|
||||
// budget so the client's wake-tolerant dial keeps retrying across the resume; against an
|
||||
|
||||
@@ -32,3 +32,6 @@ anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
relm4 = { version = "0.11", features = ["libadwaita"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+271
-10
@@ -35,6 +35,11 @@ const CSS: &str = "
|
||||
.pf-pip { min-width: 8px; min-height: 8px; border-radius: 999px;
|
||||
background: alpha(currentColor, 0.35); }
|
||||
.pf-pip.pf-online { background: @success_color; }
|
||||
/* An overridden row in profile scope: an accent dot in the prefix, so which settings this
|
||||
profile changes is legible at a glance without reading every value. (Plain string literal
|
||||
-- a quote in here would end it.) */
|
||||
.pf-override-dot { min-width: 8px; min-height: 8px; border-radius: 999px;
|
||||
background: @accent_color; }
|
||||
/* Most-recent host: a full accent ring drawn as an inset outline so it follows the card's
|
||||
rounded corners (an `inset` box-shadow bar gets eaten by the 12px corner clip) and leaves
|
||||
the card's own elevation shadow intact. */
|
||||
@@ -76,6 +81,9 @@ pub struct AppModel {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AppMsg {
|
||||
/// A `punktfunk://` URL arrived (scheme handler, a shortcut, or a second invocation
|
||||
/// forwarded to this instance by GApplication) — design/client-deep-links.md §4.1.
|
||||
DeepLink(String),
|
||||
/// The trust gate in front of every connect (rules 1–3, see `update`).
|
||||
Connect(ConnectRequest),
|
||||
/// Connect to a saved host that isn't advertising but has a known MAC: fire a wake
|
||||
@@ -115,6 +123,9 @@ pub enum AppMsg {
|
||||
/// The speed-test dialog resolved (either way) — release `busy`.
|
||||
SpeedTestDone,
|
||||
ShowPreferences,
|
||||
/// Re-open Preferences editing a specific layer — the settings scope switcher's
|
||||
/// destination (design/client-settings-profiles.md §5.1).
|
||||
ShowPreferencesScoped(crate::ui_settings::Scope),
|
||||
ShowShortcuts,
|
||||
ShowAbout,
|
||||
ShowAddHost,
|
||||
@@ -219,6 +230,7 @@ impl SimpleComponent for AppModel {
|
||||
HostsOutput::Pair(req) => AppMsg::Pair(req),
|
||||
HostsOutput::SpeedTest(req) => AppMsg::SpeedTest(req),
|
||||
HostsOutput::Library(req, mgmt) => AppMsg::OpenLibrary(req, mgmt),
|
||||
HostsOutput::Toast(msg) => AppMsg::Toast(msg),
|
||||
});
|
||||
|
||||
let nav = adw::NavigationView::new();
|
||||
@@ -269,6 +281,13 @@ impl SimpleComponent for AppModel {
|
||||
}
|
||||
window.present();
|
||||
|
||||
// The deep-link seam is live from here: anything GApplication delivered during a cold
|
||||
// start has been parked, and everything from now on arrives as a message.
|
||||
LINK_TX.with_borrow_mut(|tx| *tx = Some(sender.input_sender().clone()));
|
||||
for url in PENDING_LINKS.with_borrow_mut(std::mem::take) {
|
||||
sender.input(AppMsg::DeepLink(url));
|
||||
}
|
||||
|
||||
ComponentParts {
|
||||
model,
|
||||
widgets: AppWidgets {},
|
||||
@@ -277,6 +296,7 @@ impl SimpleComponent for AppModel {
|
||||
|
||||
fn update(&mut self, msg: AppMsg, sender: ComponentSender<Self>) {
|
||||
match msg {
|
||||
AppMsg::DeepLink(url) => self.open_deep_link(&url, &sender),
|
||||
// The trust gate (the host is the policy authority — it advertises
|
||||
// `pair=optional` only when it accepts unpaired clients):
|
||||
// 1. PINNED RECONNECT — a stored fingerprint connects silently.
|
||||
@@ -478,15 +498,25 @@ impl SimpleComponent for AppModel {
|
||||
self.hosts.emit(HostsMsg::SetConnecting(None));
|
||||
self.toast("Cancelled — the request may still be pending on the host.");
|
||||
}
|
||||
AppMsg::ShowPreferences => {
|
||||
AppMsg::ShowPreferences => sender.input(AppMsg::ShowPreferencesScoped(
|
||||
crate::ui_settings::Scope::Defaults,
|
||||
)),
|
||||
AppMsg::ShowPreferencesScoped(scope) => {
|
||||
let hosts = self.hosts.sender().clone();
|
||||
crate::ui_settings::show(
|
||||
let reopen = sender.clone();
|
||||
crate::ui_settings::show_scoped(
|
||||
&self.window,
|
||||
self.settings.clone(),
|
||||
&self.gamepad,
|
||||
&self.probes.borrow(),
|
||||
scope,
|
||||
// The switcher closes the dialog to commit the layer it was editing, then
|
||||
// asks for it back in the new scope — so the app owns the re-open and the
|
||||
// dialog stays a pure view.
|
||||
move |next| reopen.input(AppMsg::ShowPreferencesScoped(next)),
|
||||
move || {
|
||||
// The library toggle changes the saved cards' menu — re-render.
|
||||
// The library toggle changes the saved cards' menu, and a profile edit
|
||||
// changes the chips — re-render either way.
|
||||
let _ = hosts.send(HostsMsg::Refresh);
|
||||
},
|
||||
);
|
||||
@@ -504,6 +534,86 @@ impl AppModel {
|
||||
self.toasts.add_toast(adw::Toast::new(msg));
|
||||
}
|
||||
|
||||
/// Route a `punktfunk://` URL (design/client-deep-links.md §4.1). Parsing, host/profile
|
||||
/// resolution and every refusal rule live in the shared brain (`plan_from_link`); this is
|
||||
/// only the GTK end of it — turn the outcome into the same messages a card click raises,
|
||||
/// so a link gets the identical wake, trust and error surfaces and NOT a second connect
|
||||
/// path of its own.
|
||||
fn open_deep_link(&mut self, url: &str, sender: &ComponentSender<AppModel>) {
|
||||
use pf_client_core::deeplink;
|
||||
use pf_client_core::orchestrate::{plan_from_link, PlanOutcome};
|
||||
use pf_client_core::profiles::ProfilesFile;
|
||||
|
||||
tracing::debug!(%url, "deep link");
|
||||
let link = match deeplink::parse(url) {
|
||||
Ok(l) => l,
|
||||
Err(e) => return self.toast(&e.message()),
|
||||
};
|
||||
let known = trust::KnownHosts::load();
|
||||
let outcome = plan_from_link(
|
||||
&link,
|
||||
&known,
|
||||
&ProfilesFile::load(),
|
||||
&self.settings.borrow(),
|
||||
);
|
||||
match outcome {
|
||||
Ok(PlanOutcome::Connect(plan)) => {
|
||||
// Rule 2 of §3: never preempt a live session. Only this layer knows one is
|
||||
// running, which is why the brain leaves the check here.
|
||||
if self.busy {
|
||||
return self.toast("A session is already running — end it first.");
|
||||
}
|
||||
let req = ConnectRequest {
|
||||
name: plan.host.name.clone(),
|
||||
addr: plan.host.addr.clone(),
|
||||
port: plan.host.port,
|
||||
fp_hex: plan.host.fp_hex.clone(),
|
||||
pair_optional: false,
|
||||
launch: plan.launch.clone().map(|id| (id.clone(), id)),
|
||||
mac: plan.host.mac.clone(),
|
||||
// `profile=` in a URL is a one-off, exactly like "Connect with ▸": it
|
||||
// shapes this session and leaves the host's binding alone.
|
||||
profile: plan.profile_override.clone(),
|
||||
};
|
||||
// A link is a launch like any other: with a MAC it takes the dial-first wake
|
||||
// path, so a sleeping host wakes instead of erroring.
|
||||
sender.input(if plan.wake {
|
||||
AppMsg::WakeConnect(req)
|
||||
} else {
|
||||
AppMsg::Connect(req)
|
||||
});
|
||||
}
|
||||
Ok(PlanOutcome::ConfirmUnknown(unknown)) => {
|
||||
// Known-but-unpinned, or not known at all: the link may not pair and may not
|
||||
// trust on its own, so it opens the ordinary ceremony under the user's eyes —
|
||||
// the PIN dialog, seeded with what the link claimed.
|
||||
if self.busy {
|
||||
return self.toast("A session is already running — end it first.");
|
||||
}
|
||||
let req = ConnectRequest {
|
||||
name: unknown.name.clone().unwrap_or_else(|| unknown.addr.clone()),
|
||||
addr: unknown.addr.clone(),
|
||||
port: unknown.port,
|
||||
fp_hex: unknown.fp.clone(),
|
||||
pair_optional: false,
|
||||
launch: unknown.launch.clone().map(|id| (id.clone(), id)),
|
||||
mac: Vec::new(),
|
||||
profile: None,
|
||||
};
|
||||
self.toast(&format!(
|
||||
"{} isn't paired with this device yet — pair it to continue.",
|
||||
req.name
|
||||
));
|
||||
crate::ui_trust::pin_dialog(&self.window, sender, self.identity.clone(), req);
|
||||
}
|
||||
Ok(PlanOutcome::Unsupported(route)) => self.toast(&format!(
|
||||
"Punktfunk can't open “{}” links yet.",
|
||||
route.as_str()
|
||||
)),
|
||||
Err(e) => self.toast(&e.message()),
|
||||
}
|
||||
}
|
||||
|
||||
fn close_waiting(&mut self) {
|
||||
if let Some(w) = self.waiting.borrow_mut().take() {
|
||||
w.close();
|
||||
@@ -520,7 +630,33 @@ impl AppModel {
|
||||
let status = gtk::Label::new(Some("Connecting…"));
|
||||
let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name));
|
||||
dialog.set_extra_child(Some(&status));
|
||||
dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]);
|
||||
// Where a measured bitrate belongs is "the layer this host actually resolves bitrate
|
||||
// from" (design/client-settings-profiles.md §5.3) — the long-standing wrong answer was
|
||||
// always the global, so measuring the slow retro box downstairs re-tuned the desktop
|
||||
// too. The target depends only on the host, so it is known before the result lands and
|
||||
// the button can say where it will write.
|
||||
let target = SpeedTestTarget::resolve(&req);
|
||||
match &target {
|
||||
SpeedTestTarget::Global => {
|
||||
dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]);
|
||||
}
|
||||
SpeedTestTarget::Profile(p) => {
|
||||
dialog.add_responses(&[
|
||||
("close", "Close"),
|
||||
("apply", &format!("Apply to “{}”", p.name)),
|
||||
]);
|
||||
}
|
||||
// A bound host whose profile doesn't override bitrate could legitimately mean
|
||||
// either: the user gets both, rather than us guessing which layer they meant.
|
||||
SpeedTestTarget::Ask(p) => {
|
||||
dialog.add_responses(&[
|
||||
("close", "Close"),
|
||||
("apply-global", "Set as default"),
|
||||
("apply", &format!("Set in “{}”", p.name)),
|
||||
]);
|
||||
dialog.set_response_enabled("apply-global", false);
|
||||
}
|
||||
}
|
||||
dialog.set_response_enabled("apply", false);
|
||||
dialog.set_close_response("close");
|
||||
dialog.present(Some(&self.window));
|
||||
@@ -590,13 +726,36 @@ impl AppModel {
|
||||
));
|
||||
dialog.set_response_enabled("apply", true);
|
||||
dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested);
|
||||
dialog.connect_response(Some("apply"), move |_, _| {
|
||||
if matches!(target, SpeedTestTarget::Ask(_)) {
|
||||
dialog.set_response_enabled("apply-global", true);
|
||||
}
|
||||
let mbit = f64::from(recommended_kbps) / 1000.0;
|
||||
{
|
||||
let (settings, toasts) = (settings.clone(), toasts.clone());
|
||||
dialog.connect_response(Some("apply"), move |_, _| {
|
||||
let where_to = match &target {
|
||||
SpeedTestTarget::Global => {
|
||||
let mut s = settings.borrow_mut();
|
||||
s.bitrate_kbps = recommended_kbps;
|
||||
s.save();
|
||||
"the default bitrate".to_string()
|
||||
}
|
||||
SpeedTestTarget::Profile(p) | SpeedTestTarget::Ask(p) => {
|
||||
write_profile_bitrate(&p.id, recommended_kbps);
|
||||
format!("“{}”", p.name)
|
||||
}
|
||||
};
|
||||
toasts.add_toast(adw::Toast::new(&format!(
|
||||
"{mbit:.0} Mbit/s set in {where_to}"
|
||||
)));
|
||||
});
|
||||
}
|
||||
dialog.connect_response(Some("apply-global"), move |_, _| {
|
||||
let mut s = settings.borrow_mut();
|
||||
s.bitrate_kbps = recommended_kbps;
|
||||
s.save();
|
||||
toasts.add_toast(adw::Toast::new(&format!(
|
||||
"Bitrate set to {:.0} Mbit/s",
|
||||
f64::from(recommended_kbps) / 1000.0
|
||||
"{mbit:.0} Mbit/s set in the default bitrate"
|
||||
)));
|
||||
});
|
||||
}
|
||||
@@ -607,6 +766,81 @@ impl AppModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which layer a measured bitrate should land in for the host that was tested
|
||||
/// (design/client-settings-profiles.md §5.3).
|
||||
enum SpeedTestTarget {
|
||||
/// No profile bound — the global default, i.e. what has always happened.
|
||||
Global,
|
||||
/// The bound profile already overrides bitrate, so that override is what this host reads.
|
||||
Profile(pf_client_core::profiles::StreamProfile),
|
||||
/// Bound, but the profile inherits bitrate: writing either layer is defensible, so ask.
|
||||
Ask(pf_client_core::profiles::StreamProfile),
|
||||
}
|
||||
|
||||
impl SpeedTestTarget {
|
||||
fn resolve(req: &crate::ui_hosts::ConnectRequest) -> SpeedTestTarget {
|
||||
// Resolved exactly the way a connect resolves it: the one-off pick this test was
|
||||
// started with (a pinned card carries one), else the host's binding.
|
||||
let bound = trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == req.addr && h.port == req.port)
|
||||
.and_then(|h| h.profile_id.clone());
|
||||
let reference = match req.profile.as_deref() {
|
||||
Some("") => return SpeedTestTarget::Global,
|
||||
Some(id) => Some(id.to_string()),
|
||||
None => bound,
|
||||
};
|
||||
let Some(reference) = reference else {
|
||||
return SpeedTestTarget::Global;
|
||||
};
|
||||
let catalog = pf_client_core::profiles::ProfilesFile::load();
|
||||
match catalog.resolve(&reference).0 {
|
||||
Some(p) if p.overrides.bitrate_kbps.is_some() => SpeedTestTarget::Profile(p.clone()),
|
||||
Some(p) => SpeedTestTarget::Ask(p.clone()),
|
||||
// A dangling binding resolves as no profile everywhere else; here too.
|
||||
None => SpeedTestTarget::Global,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a measured bitrate into one profile's overlay, leaving everything else alone.
|
||||
fn write_profile_bitrate(id: &str, kbps: u32) {
|
||||
let mut catalog = pf_client_core::profiles::ProfilesFile::load();
|
||||
let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) else {
|
||||
return; // deleted while the test ran — the toast still tells the truth about the test
|
||||
};
|
||||
p.overrides.bitrate_kbps = Some(kbps);
|
||||
if let Err(e) = catalog.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving the measured bitrate");
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Where a delivered URL goes once the window exists. Both ends of this live on the GTK
|
||||
/// main thread: `connect_open` fires there, and so does the model's `init`.
|
||||
static LINK_TX: std::cell::RefCell<Option<relm4::Sender<AppMsg>>> =
|
||||
const { std::cell::RefCell::new(None) };
|
||||
/// URLs that arrived before the model existed — the cold-start case, where GApplication
|
||||
/// runs `open` before `activate` builds the window. A dropped URL is the one outcome a
|
||||
/// link must never have, so they wait here instead.
|
||||
static PENDING_LINKS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Hand a URL to the running app, or park it until there is one.
|
||||
fn deliver_deep_link(url: String) {
|
||||
let queued = LINK_TX.with_borrow(|tx| match tx {
|
||||
Some(tx) => {
|
||||
let _ = tx.send(AppMsg::DeepLink(url.clone()));
|
||||
false
|
||||
}
|
||||
None => true,
|
||||
});
|
||||
if queued {
|
||||
PENDING_LINKS.with_borrow_mut(|q| q.push(url));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> glib::ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
@@ -649,16 +883,43 @@ pub fn run() -> glib::ExitCode {
|
||||
return crate::cli::exec_session();
|
||||
}
|
||||
|
||||
let mut builder = adw::Application::builder().application_id(APP_ID);
|
||||
// HANDLES_OPEN is what makes `Exec=punktfunk-client %u` work: GApplication turns the URI
|
||||
// into an `open` call, and — this is the part that matters — a SECOND invocation forwards
|
||||
// its URI to the already-running instance over D-Bus and exits, so clicking a link with
|
||||
// Punktfunk open reuses the window instead of racing a new one.
|
||||
let mut builder = adw::Application::builder()
|
||||
.application_id(APP_ID)
|
||||
.flags(gio::ApplicationFlags::HANDLES_OPEN);
|
||||
// Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps
|
||||
// each launch its own primary instance.
|
||||
if crate::cli::shot_scene().is_some() {
|
||||
builder = builder.flags(gio::ApplicationFlags::NON_UNIQUE);
|
||||
builder =
|
||||
builder.flags(gio::ApplicationFlags::NON_UNIQUE | gio::ApplicationFlags::HANDLES_OPEN);
|
||||
}
|
||||
let adw_app = builder.build();
|
||||
adw_app.connect_open(|app, files, _hint| {
|
||||
for f in files {
|
||||
deliver_deep_link(f.uri().to_string());
|
||||
}
|
||||
// `open` does not raise a window on its own; the model's activate handler does.
|
||||
app.activate();
|
||||
});
|
||||
// One SDL context for the whole process, started while single-threaded.
|
||||
let gamepad = crate::gamepad::GamepadService::start();
|
||||
let app = relm4::RelmApp::from_app(adw_app).with_args(Vec::new());
|
||||
// argv stays withheld from GApplication — except for a positional URL, which is exactly
|
||||
// what GIO's single-instance forwarding is for. Passing it through means the FIRST
|
||||
// instance's `open` fires locally and a later one's is delivered to the primary, with no
|
||||
// IPC of our own.
|
||||
let args: Vec<String> = match crate::cli::deep_link_arg() {
|
||||
Some(url) => vec![
|
||||
std::env::args()
|
||||
.next()
|
||||
.unwrap_or_else(|| "punktfunk-client".into()),
|
||||
url,
|
||||
],
|
||||
None => Vec::new(),
|
||||
};
|
||||
let app = relm4::RelmApp::from_app(adw_app).with_args(args);
|
||||
app.run::<AppModel>(AppInit { gamepad });
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
@@ -38,6 +38,18 @@ pub fn arg_flag(flag: &str) -> bool {
|
||||
std::env::args().any(|a| a == flag)
|
||||
}
|
||||
|
||||
/// A positional `punktfunk://` (or the `pf://` input alias) anywhere in argv — the deep-link
|
||||
/// door (design/client-deep-links.md §4.1). It is positional, not a flag, because that is what
|
||||
/// `Exec=punktfunk-client %u` hands us, what a `.desktop` shortcut embeds, and what a browser's
|
||||
/// "Open Punktfunk?" prompt ends up invoking. Validation happens later, in the shared parser —
|
||||
/// this only decides whether argv contains something addressed to us.
|
||||
pub fn deep_link_arg() -> Option<String> {
|
||||
std::env::args().skip(1).find(|a| {
|
||||
let lower = a.to_ascii_lowercase();
|
||||
lower.starts_with("punktfunk://") || lower.starts_with("pf://")
|
||||
})
|
||||
}
|
||||
|
||||
/// Fullscreen the shell — the Gaming-Mode fallback for a bare launch (streams and the
|
||||
/// console library exec the session binary, which handles its own fullscreen).
|
||||
pub fn fullscreen_mode() -> bool {
|
||||
@@ -336,11 +348,7 @@ pub fn headless_add_host(target: &str) -> glib::ExitCode {
|
||||
name,
|
||||
addr: addr.clone(),
|
||||
port,
|
||||
fp_hex: String::new(),
|
||||
paired: false,
|
||||
last_used: None,
|
||||
mac: Vec::new(),
|
||||
clipboard_sync: false,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
match known.save() {
|
||||
@@ -487,6 +495,7 @@ pub fn run_shot(ctx: &ShotCtx, scene: &str) {
|
||||
pair_optional: true,
|
||||
launch: None,
|
||||
mac: Vec::new(),
|
||||
profile: None,
|
||||
};
|
||||
let mock_advert =
|
||||
|key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost {
|
||||
@@ -536,11 +545,26 @@ pub fn run_shot(ctx: &ShotCtx, scene: &str) {
|
||||
speakers: vec![dev("alsa_output.mock-hdmi", "HDMI / DisplayPort Audio")],
|
||||
mics: vec![dev("alsa_input.mock-usb", "USB Microphone Analog Stereo")],
|
||||
};
|
||||
let dialog = crate::ui_settings::show(
|
||||
// `PUNKTFUNK_SHOT_SETTINGS_SCOPE=<profile id|name>` captures the dialog in
|
||||
// PROFILE scope — the second half of the settings surface (design
|
||||
// client-settings-profiles.md §5.1), where only profileable rows render.
|
||||
let scope = std::env::var("PUNKTFUNK_SHOT_SETTINGS_SCOPE")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.and_then(|reference| {
|
||||
pf_client_core::profiles::ProfilesFile::load()
|
||||
.resolve(&reference)
|
||||
.0
|
||||
.map(|p| crate::ui_settings::Scope::Profile(p.id.clone()))
|
||||
})
|
||||
.unwrap_or(crate::ui_settings::Scope::Defaults);
|
||||
let dialog = crate::ui_settings::show_scoped(
|
||||
&ctx.window,
|
||||
ctx.settings.clone(),
|
||||
&ctx.gamepad,
|
||||
&probes,
|
||||
scope,
|
||||
|_| {},
|
||||
|| {},
|
||||
);
|
||||
// Optional page for the capture (general/display/input/audio/controllers);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Hosts, pairing/trust, settings, and the desktop library page; every stream (and the
|
||||
//! console game library) runs in the spawned `punktfunk-session` Vulkan binary — the
|
||||
//! shell never touches video (punktfunk-planning `linux-client-rearchitecture.md`).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
// The UI-agnostic plumbing lives in `pf-client-core`, shared with the session binary.
|
||||
// Root re-exports keep every `crate::trust`-style path resolving unchanged.
|
||||
@@ -13,6 +14,9 @@ pub use pf_client_core::{discovery, gamepad, library, trust, video, wol};
|
||||
mod app;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod cli;
|
||||
// "Create shortcut…" — the desktop-entry writer (design/client-deep-links.md §5).
|
||||
#[cfg(target_os = "linux")]
|
||||
mod shortcuts;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod spawn;
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! "Create shortcut…" — a desktop entry that boots straight into one host (optionally with a
|
||||
//! profile or a game), design/client-deep-links.md §5.
|
||||
//!
|
||||
//! The shortcut is a **container for a URL**, not a second launch mechanism: it invokes the
|
||||
//! client with a positional `punktfunk://…`, which is the same door xdg-open and a browser
|
||||
//! prompt use. That is deliberate — it keeps working if scheme registration is lost or the
|
||||
//! host store changes, because the URL itself carries the stable id, the address and the pin.
|
||||
//!
|
||||
//! Under flatpak the sandbox cannot write `~/.local/share/applications`, so this offers the
|
||||
//! URL to copy instead. The `org.freedesktop.portal.DynamicLauncher` route (which exists for
|
||||
//! exactly this, with its own confirmation) is the intended upgrade there.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Are we inside the flatpak sandbox? `/.flatpak-info` is present in every flatpak run and
|
||||
/// nowhere else — the standard check, and the one the portal docs use.
|
||||
pub fn sandboxed() -> bool {
|
||||
std::path::Path::new("/.flatpak-info").exists()
|
||||
}
|
||||
|
||||
/// Write `~/.local/share/applications/punktfunk-<slug>.desktop` for this URL and return the
|
||||
/// path. Best-effort `update-desktop-database` afterwards: an entry nothing indexes still
|
||||
/// works from a file manager, it just won't show up in search straight away.
|
||||
pub fn write_desktop_entry(label: &str, url: &str) -> Result<PathBuf, String> {
|
||||
let home = std::env::var("HOME").map_err(|_| "HOME isn't set".to_string())?;
|
||||
let dir = PathBuf::from(home).join(".local/share/applications");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let path = dir.join(format!("punktfunk-{}.desktop", file_slug(label)));
|
||||
// Desktop-entry values are line-oriented: a newline in a host name would end the Name
|
||||
// key and turn the rest into an unparsable line (or, worse, another key).
|
||||
let name = one_line(label);
|
||||
let entry = format!(
|
||||
"[Desktop Entry]\n\
|
||||
Type=Application\n\
|
||||
Name={name}\n\
|
||||
Comment=Stream from this Punktfunk host\n\
|
||||
Exec=punktfunk-client \"{url}\"\n\
|
||||
Icon=io.unom.Punktfunk\n\
|
||||
Terminal=false\n\
|
||||
Categories=Game;Network;\n\
|
||||
StartupNotify=true\n"
|
||||
);
|
||||
std::fs::write(&path, entry).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
// Some desktops only offer a `.desktop` as a launchable icon when it is executable.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
let _ = std::process::Command::new("update-desktop-database")
|
||||
.arg(&dir)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// A filename-safe slug: ASCII alphanumerics and `-`, everything else collapsed to one `-`,
|
||||
/// capped so a long host+profile pair can't produce a name the filesystem rejects.
|
||||
fn file_slug(label: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in label.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else if !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
let trimmed = out.trim_matches('-');
|
||||
let capped: String = trimmed.chars().take(48).collect();
|
||||
if capped.is_empty() {
|
||||
"host".to_string()
|
||||
} else {
|
||||
capped
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten to one line — see [`write_desktop_entry`] on why a newline here is not cosmetic.
|
||||
fn one_line(s: &str) -> String {
|
||||
s.replace(['\n', '\r'], " ").trim().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Names become safe filenames, and a name that survives to `Name=` can't break the file.
|
||||
#[test]
|
||||
fn labels_are_sanitised_both_ways() {
|
||||
assert_eq!(file_slug("Living Room PC"), "living-room-pc");
|
||||
assert_eq!(file_slug("Büro · Mac"), "b-ro-mac");
|
||||
assert_eq!(file_slug("Desk · Work"), "desk-work");
|
||||
assert_eq!(file_slug("////"), "host");
|
||||
assert_eq!(file_slug(""), "host");
|
||||
assert!(file_slug(&"x".repeat(200)).len() <= 48);
|
||||
// The classic injection: a newline would end the Name key and start a new one.
|
||||
assert_eq!(
|
||||
one_line("Desk\nExec=rm -rf ~"),
|
||||
"Desk Exec=rm -rf ~".to_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
+66
-159
@@ -1,15 +1,20 @@
|
||||
//! The shell↔session handoff: every stream runs in the spawned `punktfunk-session`
|
||||
//! Vulkan binary (the legacy in-process presenter is gone — phase 5 of
|
||||
//! punktfunk-planning `linux-client-rearchitecture.md`). This module owns the child's
|
||||
//! lifecycle plumbing — its stdout contract parsed into typed [`AppMsg`]s the relm4 app
|
||||
//! consumes: spinner until `{"ready":true}`, banner from the `{"error"|"ended": …}`
|
||||
//! line, exit code 3 + `trust_rejected` routed to the re-pair PIN ceremony.
|
||||
//! punktfunk-planning `linux-client-rearchitecture.md`). What is left here is the
|
||||
//! TRANSLATION: a [`ConnectRequest`] becomes a `ConnectPlan`, and the session's typed
|
||||
//! lifecycle events become the [`AppMsg`]s the relm4 app consumes — spinner until
|
||||
//! `{"ready":true}`, banner from the `{"error"|"ended": …}` line, exit code 3 +
|
||||
//! `trust_rejected` routed to the re-pair PIN ceremony.
|
||||
//!
|
||||
//! Spawning, the argv, the stdout contract and the child handle live in
|
||||
//! `pf_client_core::orchestrate` (design/client-architecture-split.md §3) — the WinUI shell
|
||||
//! and the coming CLI spawn through the same code, so "what flags does a stream get" and
|
||||
//! "what does ready mean" have exactly one answer.
|
||||
|
||||
use crate::app::AppMsg;
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use std::io::BufRead as _;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use pf_client_core::orchestrate::{self, ConnectPlan, HostTarget, SessionEvent};
|
||||
use pf_client_core::trust::Settings;
|
||||
|
||||
/// Spawn tunables beyond a plain connect.
|
||||
#[derive(Debug, Default)]
|
||||
@@ -25,55 +30,7 @@ pub struct SpawnOpts {
|
||||
pub cancel: Option<CancelHandle>,
|
||||
}
|
||||
|
||||
/// Kills the spawned session child (the request-access Cancel button). Safe to call
|
||||
/// any time; a child that already exited is a no-op.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CancelHandle(Arc<Mutex<Option<Child>>>);
|
||||
|
||||
impl CancelHandle {
|
||||
pub fn kill(&self) {
|
||||
if let Some(child) = self.0.lock().unwrap().as_mut() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One parsed stdout line from the session child's contract.
|
||||
enum ChildEvent {
|
||||
Ready,
|
||||
Error { msg: String, trust_rejected: bool },
|
||||
Ended(String),
|
||||
}
|
||||
|
||||
/// Parse one stdout line of the session contract; `None` for anything else (stats…).
|
||||
fn parse_line(line: &str) -> Option<ChildEvent> {
|
||||
let v: serde_json::Value = serde_json::from_str(line).ok()?;
|
||||
if v.get("ready").and_then(|r| r.as_bool()) == Some(true) {
|
||||
return Some(ChildEvent::Ready);
|
||||
}
|
||||
if let Some(msg) = v.get("error").and_then(|m| m.as_str()) {
|
||||
return Some(ChildEvent::Error {
|
||||
msg: msg.to_string(),
|
||||
trust_rejected: v.get("trust_rejected").and_then(|t| t.as_bool()) == Some(true),
|
||||
});
|
||||
}
|
||||
if let Some(msg) = v.get("ended").and_then(|m| m.as_str()) {
|
||||
return Some(ChildEvent::Ended(msg.to_string()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The session binary: installed next to the shell, else `$PATH` (dev runs from
|
||||
/// `target/…` land on the sibling).
|
||||
pub fn session_binary() -> std::path::PathBuf {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let sibling = exe.with_file_name("punktfunk-session");
|
||||
if sibling.exists() {
|
||||
return sibling;
|
||||
}
|
||||
}
|
||||
"punktfunk-session".into()
|
||||
}
|
||||
pub use orchestrate::{session_binary, CancelHandle};
|
||||
|
||||
/// Spawn the session binary for a connect with `fp_hex` pinned and translate its
|
||||
/// lifecycle into [`AppMsg`]s. `tofu` = the fingerprint came from the host's advert
|
||||
@@ -90,114 +47,64 @@ pub fn spawn_session(
|
||||
fullscreen_on_stream: bool,
|
||||
opts: SpawnOpts,
|
||||
) -> Result<(), String> {
|
||||
let mut cmd = Command::new(session_binary());
|
||||
cmd.arg("--connect")
|
||||
.arg(format!("{}:{}", req.addr, req.port))
|
||||
.arg("--fp")
|
||||
.arg(&fp_hex)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit()); // session logs interleave with the shell's
|
||||
if let Some((id, _)) = &req.launch {
|
||||
cmd.arg("--launch").arg(id);
|
||||
}
|
||||
if let Some(secs) = opts.connect_timeout_secs {
|
||||
cmd.arg("--connect-timeout").arg(secs.to_string());
|
||||
}
|
||||
if fullscreen_on_stream {
|
||||
cmd.arg("--fullscreen");
|
||||
}
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
|
||||
tracing::info!(host = %req.addr, port = req.port, "session binary spawned");
|
||||
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
// Park the child where the cancel handle (and the reader, for the final reap) can
|
||||
// reach it.
|
||||
let slot = opts.cancel.clone().unwrap_or_default();
|
||||
*slot.0.lock().unwrap() = Some(child);
|
||||
// The plan this connect resolves to. A plain card click carries no `--profile`: it honors
|
||||
// the host's own binding, which the session resolves through the same helper this shell
|
||||
// would have used (design/client-settings-profiles.md §4.6) — passing it here would be a
|
||||
// second source of truth for one decision. Only a "Connect with ▸" pick (or a URL's
|
||||
// `profile=`) sets one, and it applies to this session alone.
|
||||
//
|
||||
// Two fields are deliberately not the shell's state yet, because nothing in the spawn path
|
||||
// reads them: `settings` carries only what the argv needs (the fullscreen flag), and `wake`
|
||||
// is false because this shell still runs its own dial-first wake fallback in `app.rs`. Both
|
||||
// become real when the GTK connect path moves onto `ConnectOrchestrator` (arch-split C0).
|
||||
let plan = ConnectPlan {
|
||||
host: HostTarget {
|
||||
name: req.name.clone(),
|
||||
addr: req.addr.clone(),
|
||||
port: req.port,
|
||||
fp_hex: Some(fp_hex.clone()),
|
||||
mac: req.mac.clone(),
|
||||
id: None,
|
||||
},
|
||||
launch: req.launch.as_ref().map(|(id, _)| id.clone()),
|
||||
profile: None,
|
||||
// A one-off pick rides the flag; without one the session resolves the host's own
|
||||
// binding through the same helper this shell would have used.
|
||||
profile_override: req.profile.clone(),
|
||||
settings: Settings {
|
||||
fullscreen_on_stream,
|
||||
..Default::default()
|
||||
},
|
||||
wake: false,
|
||||
connect_timeout_secs: opts.connect_timeout_secs,
|
||||
tofu,
|
||||
};
|
||||
|
||||
let persist_paired = opts.persist_paired;
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-session-io".into())
|
||||
.spawn(move || {
|
||||
let mut error: Option<(String, bool)> = None;
|
||||
let mut ended: Option<String> = None;
|
||||
for line in std::io::BufReader::new(stdout).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
match parse_line(&line) {
|
||||
Some(ChildEvent::Ready) => {
|
||||
let _ = sender.send(AppMsg::SessionReady {
|
||||
req: req.clone(),
|
||||
fp_hex: fp_hex.clone(),
|
||||
tofu,
|
||||
persist_paired,
|
||||
});
|
||||
}
|
||||
Some(ChildEvent::Error {
|
||||
msg,
|
||||
trust_rejected,
|
||||
}) => {
|
||||
error = Some((msg, trust_rejected));
|
||||
}
|
||||
Some(ChildEvent::Ended(msg)) => ended = Some(msg),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// EOF — reap the child (killed-by-cancel lands here too; -1 = signal).
|
||||
let code = slot
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.and_then(|mut c| c.wait().ok())
|
||||
.and_then(|s| s.code())
|
||||
.unwrap_or(-1);
|
||||
tracing::info!(code, "session binary exited");
|
||||
let (mut error, mut ended) = (None::<(String, bool)>, None::<String>);
|
||||
orchestrate::spawn_session(&plan, opts.cancel, move |ev| match ev {
|
||||
SessionEvent::Ready => {
|
||||
let _ = sender.send(AppMsg::SessionReady {
|
||||
req: req.clone(),
|
||||
fp_hex: fp_hex.clone(),
|
||||
tofu,
|
||||
persist_paired,
|
||||
});
|
||||
}
|
||||
SessionEvent::Error {
|
||||
msg,
|
||||
trust_rejected,
|
||||
} => error = Some((msg, trust_rejected)),
|
||||
SessionEvent::Ended(msg) => ended = Some(msg),
|
||||
SessionEvent::Exited(code) => {
|
||||
let _ = sender.send(AppMsg::SessionExited {
|
||||
req,
|
||||
req: req.clone(),
|
||||
code,
|
||||
error,
|
||||
ended,
|
||||
error: error.take(),
|
||||
ended: ended.take(),
|
||||
tofu,
|
||||
});
|
||||
})
|
||||
.map_err(|e| format!("session reader thread: {e}"))?;
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_the_stdout_contract() {
|
||||
assert!(matches!(
|
||||
parse_line("{\"ready\":true}"),
|
||||
Some(ChildEvent::Ready)
|
||||
));
|
||||
match parse_line("{\"error\":\"no route\",\"trust_rejected\":false}") {
|
||||
Some(ChildEvent::Error {
|
||||
msg,
|
||||
trust_rejected,
|
||||
}) => {
|
||||
assert_eq!(msg, "no route");
|
||||
assert!(!trust_rejected);
|
||||
}
|
||||
_ => panic!("error line"),
|
||||
}
|
||||
match parse_line("{\"error\":\"pin\",\"trust_rejected\":true}") {
|
||||
Some(ChildEvent::Error { trust_rejected, .. }) => assert!(trust_rejected),
|
||||
_ => panic!("trust line"),
|
||||
}
|
||||
match parse_line("{\"ended\":\"Host ended the session\"}") {
|
||||
Some(ChildEvent::Ended(m)) => assert_eq!(m, "Host ended the session"),
|
||||
_ => panic!("ended line"),
|
||||
}
|
||||
// Stats and stray output never become events.
|
||||
assert!(parse_line("stats: 1280×800@60 · 60 fps").is_none());
|
||||
assert!(parse_line("").is_none());
|
||||
assert!(parse_line("{\"other\":1}").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+456
-33
@@ -32,6 +32,11 @@ pub struct ConnectRequest {
|
||||
pub launch: Option<(String, String)>,
|
||||
/// Wake-on-LAN MAC(s) for this host. Empty when none is known.
|
||||
pub mac: Vec<String>,
|
||||
/// A ONE-OFF settings profile for this connect ("Connect with ▸ X"): `Some(id)` overrides
|
||||
/// the host's binding for this launch, `Some("")` forces the global defaults on a bound
|
||||
/// host, `None` honors the binding. It never rebinds anything — the host's default changes
|
||||
/// only through an explicit "Default profile" pick (design/client-settings-profiles.md §5.2).
|
||||
pub profile: Option<String>,
|
||||
}
|
||||
|
||||
impl ConnectRequest {
|
||||
@@ -62,6 +67,14 @@ enum CardKind {
|
||||
online: bool,
|
||||
recent: bool,
|
||||
library_enabled: bool,
|
||||
/// The profile catalog as `(id, name)`, for this card's menus and chip. Shared per
|
||||
/// refresh rather than re-read per card.
|
||||
profiles: Rc<Vec<(String, String)>>,
|
||||
/// `Some((id, name))` when this card is a PINNED host+profile pair rather than the
|
||||
/// host's primary card (design §5.2a): a one-click shortcut for a profile the user
|
||||
/// reaches for often. It is presentation state on the host record — never a second
|
||||
/// host entry, which would fork pairing, WoL and renames.
|
||||
pinned: Option<(String, String)>,
|
||||
},
|
||||
Discovered(DiscoveredHost),
|
||||
}
|
||||
@@ -69,19 +82,55 @@ enum CardKind {
|
||||
#[derive(Debug)]
|
||||
pub enum CardOutput {
|
||||
Connect(ConnectRequest),
|
||||
/// Set (or clear, with `None`) this host's DEFAULT settings profile — the explicit
|
||||
/// rebinding act; a one-off connect never does this.
|
||||
BindProfile {
|
||||
fp_hex: String,
|
||||
addr: String,
|
||||
port: u16,
|
||||
profile_id: Option<String>,
|
||||
},
|
||||
WakeConnect(ConnectRequest),
|
||||
Pair(ConnectRequest),
|
||||
SpeedTest(ConnectRequest),
|
||||
Library(ConnectRequest),
|
||||
Rename { fp_hex: String, name: String },
|
||||
Forget { fp_hex: String, name: String },
|
||||
Wake { mac: Vec<String>, addr: String },
|
||||
/// Open the host edit sheet (name, profile binding, clipboard).
|
||||
Edit {
|
||||
fp_hex: String,
|
||||
name: String,
|
||||
},
|
||||
Forget {
|
||||
fp_hex: String,
|
||||
name: String,
|
||||
},
|
||||
Wake {
|
||||
mac: Vec<String>,
|
||||
addr: String,
|
||||
},
|
||||
/// Put this card's `punktfunk://` URL on the clipboard.
|
||||
CopyLink(String),
|
||||
/// Write a desktop entry that launches this card's URL.
|
||||
CreateShortcut {
|
||||
label: String,
|
||||
url: String,
|
||||
},
|
||||
/// Add or remove a pinned host+profile card (design §5.2a). Presentation only — it never
|
||||
/// changes the host's default profile, and unpinning never touches the profile itself.
|
||||
TogglePin {
|
||||
fp_hex: String,
|
||||
addr: String,
|
||||
port: u16,
|
||||
profile_id: String,
|
||||
pin: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl HostCard {
|
||||
fn request(&self) -> ConnectRequest {
|
||||
match &self.kind {
|
||||
CardKind::Saved { host: k, .. } => ConnectRequest {
|
||||
CardKind::Saved {
|
||||
host: k, pinned, ..
|
||||
} => ConnectRequest {
|
||||
name: k.name.clone(),
|
||||
addr: k.addr.clone(),
|
||||
port: k.port,
|
||||
@@ -90,6 +139,9 @@ impl HostCard {
|
||||
pair_optional: false,
|
||||
launch: None,
|
||||
mac: k.mac.clone(),
|
||||
// A pinned card IS its profile: clicking it connects with that one, without
|
||||
// touching the host's default.
|
||||
profile: pinned.as_ref().map(|(id, _)| id.clone()),
|
||||
},
|
||||
CardKind::Discovered(a) => ConnectRequest {
|
||||
name: a.name.clone(),
|
||||
@@ -100,6 +152,7 @@ impl HostCard {
|
||||
pair_optional: a.pair == "optional",
|
||||
launch: None,
|
||||
mac: a.mac.clone(),
|
||||
profile: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -171,7 +224,11 @@ impl relm4::factory::FactoryComponent for HostCard {
|
||||
};
|
||||
match &self.kind {
|
||||
CardKind::Saved {
|
||||
host: k, online, ..
|
||||
host: k,
|
||||
online,
|
||||
profiles,
|
||||
pinned,
|
||||
..
|
||||
} => {
|
||||
// Presence pip + spelled-out state, then the trust pill.
|
||||
let pip = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||
@@ -190,6 +247,26 @@ impl relm4::factory::FactoryComponent for HostCard {
|
||||
} else {
|
||||
pill("Trusted", "pf-accent")
|
||||
});
|
||||
// The chip says what a plain click on THIS card will do: its own profile on a
|
||||
// pinned card, the host's binding on the primary one. A binding whose profile
|
||||
// was deleted shows nothing and resolves as the defaults, which is exactly
|
||||
// what will happen on connect (design §6).
|
||||
let chip = pinned.as_ref().map(|(_, name)| name.as_str()).or_else(|| {
|
||||
k.profile_id
|
||||
.as_ref()
|
||||
.and_then(|id| profiles.iter().find(|(pid, _)| pid == id))
|
||||
.map(|(_, name)| name.as_str())
|
||||
});
|
||||
if let Some(name) = chip {
|
||||
status.append(&pill(
|
||||
name,
|
||||
if pinned.is_some() {
|
||||
"pf-accent"
|
||||
} else {
|
||||
"pf-neutral"
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
CardKind::Discovered(_) => {
|
||||
status.append(&if req.pair_optional {
|
||||
@@ -214,6 +291,8 @@ impl relm4::factory::FactoryComponent for HostCard {
|
||||
online,
|
||||
recent,
|
||||
library_enabled,
|
||||
profiles,
|
||||
pinned,
|
||||
} => {
|
||||
if *recent {
|
||||
overlay.add_css_class("pf-recent");
|
||||
@@ -250,7 +329,7 @@ impl relm4::factory::FactoryComponent for HostCard {
|
||||
let (fp, name) = (k.fp_hex.clone(), k.name.clone());
|
||||
add(
|
||||
"rename",
|
||||
Box::new(move || CardOutput::Rename {
|
||||
Box::new(move || CardOutput::Edit {
|
||||
fp_hex: fp.clone(),
|
||||
name: name.clone(),
|
||||
}),
|
||||
@@ -276,21 +355,185 @@ impl relm4::factory::FactoryComponent for HostCard {
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Profiles: a ONE-OFF connect ("Connect with"), and the explicit rebinding
|
||||
// act ("Default profile"). They are separate menus on purpose — the whole
|
||||
// predictability rule is that connecting with a profile never changes what
|
||||
// the card will do next time (design §5.2).
|
||||
{
|
||||
let profile_action =
|
||||
|name: &str, out: Box<dyn Fn(Option<String>) -> CardOutput>| {
|
||||
let a = gio::SimpleAction::new(name, Some(glib::VariantTy::STRING));
|
||||
let sender = sender.clone();
|
||||
a.connect_activate(move |_, param| {
|
||||
// The empty string is "Default settings" — a real choice, not
|
||||
// an absent one, so it has to survive as a value.
|
||||
let id = param.and_then(|p| p.str()).unwrap_or("").to_string();
|
||||
let _ = sender.output(out(Some(id).filter(|s| !s.is_empty())));
|
||||
});
|
||||
actions.add_action(&a);
|
||||
};
|
||||
let req_for_connect = req.clone();
|
||||
profile_action(
|
||||
"connect-with",
|
||||
Box::new(move |id| {
|
||||
let mut req = req_for_connect.clone();
|
||||
// `Some("")` — not `None` — so a bound host really does connect
|
||||
// with the defaults when the user asks for them.
|
||||
req.profile = Some(id.unwrap_or_default());
|
||||
CardOutput::Connect(req)
|
||||
}),
|
||||
);
|
||||
let (fp, addr, port) = (k.fp_hex.clone(), k.addr.clone(), k.port);
|
||||
profile_action(
|
||||
"bind-profile",
|
||||
Box::new(move |id| CardOutput::BindProfile {
|
||||
fp_hex: fp.clone(),
|
||||
addr: addr.clone(),
|
||||
port,
|
||||
profile_id: id,
|
||||
}),
|
||||
);
|
||||
// "Copy link": the self-emitted URL for this card, which is the pairing
|
||||
// an external tool (a Playnite entry, a Stream Deck macro) is configured
|
||||
// with. It carries the stable id AND host+fp, so it still resolves after a
|
||||
// re-address or a reinstall (design/client-deep-links.md §2/§5).
|
||||
{
|
||||
let (host, profile) = (k.clone(), pinned.clone());
|
||||
let a = gio::SimpleAction::new("copy-link", None);
|
||||
let sender = sender.clone();
|
||||
a.connect_activate(move |_, _| {
|
||||
let url = pf_client_core::deeplink::DeepLink::for_host(
|
||||
&host,
|
||||
None,
|
||||
profile.as_ref().map(|(id, _)| id.as_str()),
|
||||
)
|
||||
.to_url();
|
||||
let _ = sender.output(CardOutput::CopyLink(url));
|
||||
});
|
||||
actions.add_action(&a);
|
||||
}
|
||||
// "Create shortcut…": the same URL as Copy link, wrapped in a desktop
|
||||
// entry so it is double-clickable from the app grid.
|
||||
{
|
||||
let (host, profile) = (k.clone(), pinned.clone());
|
||||
let a = gio::SimpleAction::new("shortcut", None);
|
||||
let sender = sender.clone();
|
||||
a.connect_activate(move |_, _| {
|
||||
let url = pf_client_core::deeplink::DeepLink::for_host(
|
||||
&host,
|
||||
None,
|
||||
profile.as_ref().map(|(id, _)| id.as_str()),
|
||||
)
|
||||
.to_url();
|
||||
let label = match &profile {
|
||||
Some((_, name)) => format!("{} \u{00b7} {name}", host.name),
|
||||
None => host.name.clone(),
|
||||
};
|
||||
let _ = sender.output(CardOutput::CreateShortcut { label, url });
|
||||
});
|
||||
actions.add_action(&a);
|
||||
}
|
||||
// The same action pins from a primary card and unpins from a pinned one —
|
||||
// which of the two this card is decides the direction.
|
||||
let (fp, addr, port) = (k.fp_hex.clone(), k.addr.clone(), k.port);
|
||||
let pinning = pinned.is_none();
|
||||
profile_action(
|
||||
"toggle-pin",
|
||||
Box::new(move |id| CardOutput::TogglePin {
|
||||
fp_hex: fp.clone(),
|
||||
addr: addr.clone(),
|
||||
port,
|
||||
profile_id: id.unwrap_or_default(),
|
||||
pin: pinning,
|
||||
}),
|
||||
);
|
||||
}
|
||||
overlay.insert_action_group("card", Some(&actions));
|
||||
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some("Pair with PIN…"), Some("card.pair"));
|
||||
menu.append(Some("Test network speed…"), Some("card.speed"));
|
||||
// An explicit wake only when offline and a MAC is known.
|
||||
if !online && !k.mac.is_empty() {
|
||||
menu.append(Some("Wake host"), Some("card.wake"));
|
||||
if let Some((pin_id, pin_name)) = pinned {
|
||||
// A pinned card is a shortcut, not a second host: it offers the one-offs
|
||||
// and its own removal, and deliberately NOT pair/rename/forget — those
|
||||
// belong to the host, and offering them here would blur what the card is.
|
||||
let with = gio::Menu::new();
|
||||
for (label, target) in std::iter::once(("Default settings", "")).chain(
|
||||
profiles
|
||||
.iter()
|
||||
.map(|(id, name)| (name.as_str(), id.as_str())),
|
||||
) {
|
||||
let item = gio::MenuItem::new(Some(label), None);
|
||||
item.set_action_and_target_value(
|
||||
Some("card.connect-with"),
|
||||
Some(&target.to_variant()),
|
||||
);
|
||||
with.append_item(&item);
|
||||
}
|
||||
menu.append_submenu(Some("Connect with"), &with);
|
||||
let unpin = gio::MenuItem::new(
|
||||
Some(&format!("Unpin \u{201c}{pin_name}\u{201d}")),
|
||||
None,
|
||||
);
|
||||
unpin.set_action_and_target_value(
|
||||
Some("card.toggle-pin"),
|
||||
Some(&pin_id.as_str().to_variant()),
|
||||
);
|
||||
menu.append_item(&unpin);
|
||||
menu.append(Some("Copy link"), Some("card.copy-link"));
|
||||
menu.append(Some("Create shortcut\u{2026}"), Some("card.shortcut"));
|
||||
} else {
|
||||
if !profiles.is_empty() {
|
||||
let with = gio::Menu::new();
|
||||
let bind = gio::Menu::new();
|
||||
let pin = gio::Menu::new();
|
||||
for (label, id) in std::iter::once(("Default settings", "")).chain(
|
||||
profiles
|
||||
.iter()
|
||||
.map(|(id, name)| (name.as_str(), id.as_str())),
|
||||
) {
|
||||
// A checkmark would be the natural cue for the current binding, but
|
||||
// a GMenu radio needs shared state per card; the bound profile is
|
||||
// named on the card's chip instead, visible without opening a menu.
|
||||
for (menu, action) in
|
||||
[(&with, "card.connect-with"), (&bind, "card.bind-profile")]
|
||||
{
|
||||
let item = gio::MenuItem::new(Some(label), None);
|
||||
item.set_action_and_target_value(
|
||||
Some(action),
|
||||
Some(&id.to_variant()),
|
||||
);
|
||||
menu.append_item(&item);
|
||||
}
|
||||
// "Default settings" is not pinnable — the primary card is that.
|
||||
if !id.is_empty() && !k.pinned_profiles.iter().any(|p| p == id) {
|
||||
let item = gio::MenuItem::new(Some(label), None);
|
||||
item.set_action_and_target_value(
|
||||
Some("card.toggle-pin"),
|
||||
Some(&id.to_variant()),
|
||||
);
|
||||
pin.append_item(&item);
|
||||
}
|
||||
}
|
||||
menu.append_submenu(Some("Connect with"), &with);
|
||||
menu.append_submenu(Some("Default profile"), &bind);
|
||||
if pin.n_items() > 0 {
|
||||
menu.append_submenu(Some("Pin as card"), &pin);
|
||||
}
|
||||
}
|
||||
menu.append(Some("Pair with PIN\u{2026}"), Some("card.pair"));
|
||||
menu.append(Some("Test network speed\u{2026}"), Some("card.speed"));
|
||||
// An explicit wake only when offline and a MAC is known.
|
||||
if !online && !k.mac.is_empty() {
|
||||
menu.append(Some("Wake host"), Some("card.wake"));
|
||||
}
|
||||
// Experimental (Preferences gate): browse the host's game library.
|
||||
if *library_enabled {
|
||||
menu.append(Some("Browse library\u{2026}"), Some("card.library"));
|
||||
}
|
||||
menu.append(Some("Copy link"), Some("card.copy-link"));
|
||||
menu.append(Some("Create shortcut\u{2026}"), Some("card.shortcut"));
|
||||
menu.append(Some("Edit\u{2026}"), Some("card.rename"));
|
||||
menu.append(Some("Forget"), Some("card.forget"));
|
||||
}
|
||||
// Experimental (Preferences gate): browse the host's game library.
|
||||
if *library_enabled {
|
||||
menu.append(Some("Browse library…"), Some("card.library"));
|
||||
}
|
||||
menu.append(Some("Rename…"), Some("card.rename"));
|
||||
menu.append(Some("Forget"), Some("card.forget"));
|
||||
let menu_btn = gtk::MenuButton::builder()
|
||||
.icon_name("view-more-symbolic")
|
||||
.menu_model(&menu)
|
||||
@@ -392,6 +635,8 @@ pub enum HostsMsg {
|
||||
#[derive(Debug)]
|
||||
pub enum HostsOutput {
|
||||
Connect(ConnectRequest),
|
||||
/// A one-line confirmation for the window's toast overlay.
|
||||
Toast(String),
|
||||
WakeConnect(ConnectRequest),
|
||||
Pair(ConnectRequest),
|
||||
SpeedTest(ConnectRequest),
|
||||
@@ -668,9 +913,61 @@ impl SimpleComponent for HostsPage {
|
||||
let mgmt = self.mgmt_port_for(&req);
|
||||
let _ = sender.output(HostsOutput::Library(req, mgmt));
|
||||
}
|
||||
CardOutput::Rename { fp_hex, name } => self.rename_dialog(&sender, &fp_hex, &name),
|
||||
CardOutput::Edit { fp_hex, name } => self.edit_host_dialog(&sender, &fp_hex, &name),
|
||||
CardOutput::Forget { fp_hex, name } => self.forget_dialog(&sender, &fp_hex, &name),
|
||||
CardOutput::Wake { mac, addr } => crate::wol::wake(&mac, addr.parse().ok()),
|
||||
CardOutput::BindProfile {
|
||||
fp_hex,
|
||||
addr,
|
||||
port,
|
||||
profile_id,
|
||||
} => {
|
||||
// Written straight onto the host record — the binding IS a field there, so
|
||||
// there is no map to keep in step (design §4.1). Matched by fingerprint
|
||||
// when there is one, else by address, like every other per-host lookup.
|
||||
let mut known = KnownHosts::load();
|
||||
if let Some(h) = known.hosts.iter_mut().find(|h| {
|
||||
(!fp_hex.is_empty() && h.fp_hex == fp_hex)
|
||||
|| (h.addr == addr && h.port == port)
|
||||
}) {
|
||||
h.profile_id = profile_id;
|
||||
if let Err(e) = known.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving the profile binding");
|
||||
}
|
||||
}
|
||||
self.rebuild(); // the chip follows immediately
|
||||
}
|
||||
CardOutput::CopyLink(url) => {
|
||||
if let Some(display) = gtk::gdk::Display::default() {
|
||||
display.clipboard().set_text(&url);
|
||||
}
|
||||
let _ = sender.output(HostsOutput::Toast("Link copied".into()));
|
||||
}
|
||||
CardOutput::CreateShortcut { label, url } => {
|
||||
self.shortcut_result(&sender, &label, &url);
|
||||
}
|
||||
CardOutput::TogglePin {
|
||||
fp_hex,
|
||||
addr,
|
||||
port,
|
||||
profile_id,
|
||||
pin,
|
||||
} => {
|
||||
let mut known = KnownHosts::load();
|
||||
if let Some(h) = known.hosts.iter_mut().find(|h| {
|
||||
(!fp_hex.is_empty() && h.fp_hex == fp_hex)
|
||||
|| (h.addr == addr && h.port == port)
|
||||
}) {
|
||||
h.pinned_profiles.retain(|p| p != &profile_id);
|
||||
if pin {
|
||||
h.pinned_profiles.push(profile_id);
|
||||
}
|
||||
if let Err(e) = known.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving the pinned cards");
|
||||
}
|
||||
}
|
||||
self.rebuild();
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -694,6 +991,14 @@ impl HostsPage {
|
||||
.max_by_key(|&(_, t)| t)
|
||||
.map(|(fp, _)| fp);
|
||||
let library_enabled = self.settings.borrow().library_enabled;
|
||||
// One catalog read per refresh, shared by every card's menus and chip.
|
||||
let profiles: Rc<Vec<(String, String)>> = Rc::new(
|
||||
pf_client_core::profiles::ProfilesFile::load()
|
||||
.profiles
|
||||
.into_iter()
|
||||
.map(|p| (p.id, p.name))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
{
|
||||
let mut saved = self.saved.guard();
|
||||
@@ -715,10 +1020,33 @@ impl HostsPage {
|
||||
kind: CardKind::Saved {
|
||||
host: k.clone(),
|
||||
online,
|
||||
profiles: profiles.clone(),
|
||||
recent: most_recent.as_deref() == Some(k.fp_hex.as_str()),
|
||||
library_enabled,
|
||||
pinned: None,
|
||||
},
|
||||
});
|
||||
// …then its pinned host+profile cards, in the order the user pinned them.
|
||||
// They share the host's live status because they read the same record, and a
|
||||
// pin whose profile is gone simply doesn't render (design §5.2a).
|
||||
for id in &k.pinned_profiles {
|
||||
let Some((id, name)) = profiles.iter().find(|(pid, _)| pid == id) else {
|
||||
continue;
|
||||
};
|
||||
saved.push_back(HostCard {
|
||||
// The spinner belongs to whichever card was clicked; a pin has its own
|
||||
// key so two cards for one host don't both spin.
|
||||
connecting: false,
|
||||
kind: CardKind::Saved {
|
||||
host: k.clone(),
|
||||
online,
|
||||
profiles: profiles.clone(),
|
||||
recent: false,
|
||||
library_enabled,
|
||||
pinned: Some((id.clone(), name.clone())),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -776,28 +1104,122 @@ impl HostsPage {
|
||||
}
|
||||
|
||||
/// Rename a saved host — an entry in an alert, then upsert + refresh.
|
||||
fn rename_dialog(&self, sender: &ComponentSender<Self>, fp_hex: &str, current: &str) {
|
||||
let entry = gtk::Entry::builder()
|
||||
.text(current)
|
||||
.activates_default(true)
|
||||
/// Write the shortcut, or — inside the flatpak sandbox, which cannot reach
|
||||
/// `~/.local/share/applications` — hand the user the URL to place themselves. The
|
||||
/// DynamicLauncher portal is the intended upgrade for that case (design §5); until then
|
||||
/// the fallback is the one the design already sanctions, not a dead end.
|
||||
fn shortcut_result(&self, sender: &ComponentSender<Self>, label: &str, url: &str) {
|
||||
if crate::shortcuts::sandboxed() {
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("Create Shortcut"),
|
||||
Some(
|
||||
"Punktfunk is sandboxed here, so it can't add the shortcut itself. Copy \
|
||||
this link and make a launcher for it \u{2014} it opens the same stream.",
|
||||
),
|
||||
);
|
||||
let entry = gtk::Entry::builder().text(url).editable(false).build();
|
||||
dialog.set_extra_child(Some(&entry));
|
||||
dialog.add_responses(&[("close", "Close"), ("copy", "Copy link")]);
|
||||
dialog.set_response_appearance("copy", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_close_response("close");
|
||||
{
|
||||
let url = url.to_string();
|
||||
dialog.connect_response(Some("copy"), move |_, _| {
|
||||
if let Some(display) = gtk::gdk::Display::default() {
|
||||
display.clipboard().set_text(&url);
|
||||
}
|
||||
});
|
||||
}
|
||||
dialog.present(Some(&self.widgets.stack));
|
||||
return;
|
||||
}
|
||||
let msg = match crate::shortcuts::write_desktop_entry(label, url) {
|
||||
Ok(_) => format!("Shortcut for \u{201c}{label}\u{201d} added to your applications"),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "writing the shortcut");
|
||||
format!("Couldn't create the shortcut \u{2014} {e}")
|
||||
}
|
||||
};
|
||||
let _ = sender.output(HostsOutput::Toast(msg));
|
||||
}
|
||||
|
||||
/// The host edit sheet — the per-host settings that are properties of the HOST, not of
|
||||
/// the stream: its name, whether this machine shares its clipboard with it, and which
|
||||
/// settings profile it defaults to.
|
||||
///
|
||||
/// Linux had only "Rename" until now; the clipboard toggle in particular existed in the
|
||||
/// store and on the Apple and Windows clients but had no Linux surface at all, so a Linux
|
||||
/// user could not turn on a feature they were already paying the storage for.
|
||||
fn edit_host_dialog(&self, sender: &ComponentSender<Self>, fp_hex: &str, current: &str) {
|
||||
let stored = KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.fp_hex == fp_hex)
|
||||
.cloned();
|
||||
let name_row = adw::EntryRow::builder().title("Name").build();
|
||||
name_row.set_text(current);
|
||||
let clipboard_row = adw::SwitchRow::builder()
|
||||
.title("Share clipboard")
|
||||
.subtitle(
|
||||
"Copy and paste between this machine and that host. Per host \u{2014} handing a \
|
||||
host your clipboard is a decision about that host.",
|
||||
)
|
||||
.build();
|
||||
let dialog = adw::AlertDialog::new(Some("Rename Host"), None);
|
||||
dialog.set_extra_child(Some(&entry));
|
||||
dialog.add_responses(&[("cancel", "Cancel"), ("rename", "Rename")]);
|
||||
dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("rename"));
|
||||
clipboard_row.set_active(stored.as_ref().is_some_and(|h| h.clipboard_sync));
|
||||
|
||||
// Profile picker: "Default settings" plus the catalog, seeded to the current binding.
|
||||
let catalog = pf_client_core::profiles::ProfilesFile::load();
|
||||
let mut labels = vec!["Default settings".to_string()];
|
||||
let mut ids: Vec<String> = vec![String::new()];
|
||||
for p in &catalog.profiles {
|
||||
labels.push(p.name.clone());
|
||||
ids.push(p.id.clone());
|
||||
}
|
||||
let bound = stored.as_ref().and_then(|h| h.profile_id.clone());
|
||||
// A binding whose profile is gone reads as Default settings and is cleaned up on save
|
||||
// — the same "dangling resolves as none" rule the connect path follows.
|
||||
let selected = bound
|
||||
.as_ref()
|
||||
.and_then(|id| ids.iter().position(|i| i == id))
|
||||
.unwrap_or(0);
|
||||
let profile_row = adw::ComboRow::builder()
|
||||
.title("Profile")
|
||||
.subtitle("The settings a plain click uses for this host")
|
||||
.model(>k::StringList::new(
|
||||
&labels.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
))
|
||||
.build();
|
||||
profile_row.set_selected(selected as u32);
|
||||
|
||||
let list = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(["boxed-list"])
|
||||
.build();
|
||||
list.append(&name_row);
|
||||
list.append(&profile_row);
|
||||
list.append(&clipboard_row);
|
||||
|
||||
let dialog = adw::AlertDialog::new(Some("Edit Host"), None);
|
||||
dialog.set_extra_child(Some(&list));
|
||||
dialog.add_responses(&[("cancel", "Cancel"), ("save", "Save")]);
|
||||
dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("save"));
|
||||
dialog.set_close_response("cancel");
|
||||
{
|
||||
let sender = sender.clone();
|
||||
let fp = fp_hex.to_string();
|
||||
dialog.connect_response(Some("rename"), move |_, _| {
|
||||
let name = entry.text().trim().to_string();
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
dialog.connect_response(Some("save"), move |_, _| {
|
||||
let name = name_row.text().trim().to_string();
|
||||
let mut known = KnownHosts::load();
|
||||
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
|
||||
h.name = name;
|
||||
if !name.is_empty() {
|
||||
h.name = name;
|
||||
}
|
||||
h.clipboard_sync = clipboard_row.is_active();
|
||||
h.profile_id = ids
|
||||
.get(profile_row.selected() as usize)
|
||||
.filter(|id| !id.is_empty())
|
||||
.cloned();
|
||||
let _ = known.save();
|
||||
}
|
||||
sender.input(HostsMsg::Refresh);
|
||||
@@ -886,6 +1308,7 @@ impl HostsPage {
|
||||
pair_optional: false,
|
||||
launch: None,
|
||||
mac: Vec::new(),
|
||||
profile: None,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,13 +4,49 @@
|
||||
//! dynamic where the meaning depends on the selection (touch mode). Written back to
|
||||
//! disk when the dialog closes. About stays in the primary menu (GNOME convention)
|
||||
//! rather than as a page.
|
||||
//!
|
||||
//! The same surface edits SETTINGS PROFILES (design/client-settings-profiles.md §5.1): a
|
||||
//! scope switcher at the top swaps the whole dialog between the global defaults and one
|
||||
//! profile's overrides. It is deliberately not a second editor — a parallel one would drift
|
||||
//! from this one field by field. In profile scope only profileable ("tier P") rows render,
|
||||
//! every row shows the EFFECTIVE value (the inherited global until you touch it), and the
|
||||
//! override is recorded on touch rather than by comparing values, so a profile can pin a
|
||||
//! value that happens to equal today's global and keep it when the global later moves.
|
||||
|
||||
use crate::trust::Settings;
|
||||
use adw::prelude::*;
|
||||
use pf_client_core::profiles::{ProfilesFile, SettingsOverlay, StreamProfile};
|
||||
use pf_client_core::trust::StatsVerbosity;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// Which layer the dialog is editing.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Scope {
|
||||
/// The global defaults every profile inherits from — the only scope before this feature.
|
||||
Defaults,
|
||||
/// One profile's overrides, by id.
|
||||
Profile(String),
|
||||
}
|
||||
|
||||
/// Which rows the user actually touched this session. The override model is explicit, not
|
||||
/// diffed: touching a control creates the override, and only an explicit reset removes it
|
||||
/// (design §4.1). Keys are the overlay's field names, with `resolution` covering the
|
||||
/// width/height/match-window tri-state that one row drives.
|
||||
#[derive(Clone, Default)]
|
||||
struct Touched(Rc<RefCell<HashSet<&'static str>>>);
|
||||
|
||||
impl Touched {
|
||||
fn mark(&self, key: &'static str) {
|
||||
self.0.borrow_mut().insert(key);
|
||||
}
|
||||
|
||||
fn has(&self, key: &str) -> bool {
|
||||
self.0.borrow().contains(key)
|
||||
}
|
||||
}
|
||||
|
||||
/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect.
|
||||
const RESOLUTIONS: &[(u32, u32)] = &[
|
||||
(0, 0),
|
||||
@@ -25,6 +61,358 @@ const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||||
/// `1.0` = Native. Applied at connect and each match-window resize.
|
||||
const RENDER_SCALES: &[f64] = &[0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
|
||||
/// The scope switcher, plus (in profile scope) that profile's management actions.
|
||||
///
|
||||
/// Switching scope does not swap the rows in place: it closes the dialog — which commits the
|
||||
/// layer being edited — and asks the app to re-open in the new scope. One code path builds
|
||||
/// the rows, and the commit ordering is unambiguous.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn scope_group(
|
||||
dialog: &adw::PreferencesDialog,
|
||||
inline: bool,
|
||||
scope: &Scope,
|
||||
catalog: &ProfilesFile,
|
||||
active: Option<&StreamProfile>,
|
||||
next_scope: &Rc<RefCell<Option<Scope>>>,
|
||||
parent: &impl IsA<gtk::Widget>,
|
||||
) -> adw::PreferencesGroup {
|
||||
let g = group(
|
||||
"",
|
||||
"A profile overrides only what you change here; everything else follows Default \
|
||||
settings.",
|
||||
);
|
||||
let mut labels: Vec<String> = vec!["Default settings".into()];
|
||||
labels.extend(catalog.profiles.iter().map(|p| p.name.clone()));
|
||||
labels.push("New profile…".into());
|
||||
let new_index = (labels.len() - 1) as u32;
|
||||
let current = match scope {
|
||||
Scope::Defaults => 0,
|
||||
Scope::Profile(id) => catalog
|
||||
.profiles
|
||||
.iter()
|
||||
.position(|p| &p.id == id)
|
||||
.map_or(0, |i| i as u32 + 1),
|
||||
};
|
||||
let row = ChoiceRow::new(
|
||||
dialog,
|
||||
inline,
|
||||
"Editing",
|
||||
"Which layer these settings belong to",
|
||||
&labels.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
row.set_selected(current);
|
||||
{
|
||||
let (dialog, next, parent) = (dialog.clone(), next_scope.clone(), parent.as_ref().clone());
|
||||
let ids: Vec<String> = catalog.profiles.iter().map(|p| p.id.clone()).collect();
|
||||
row.connect_changed(move |i| {
|
||||
if i == new_index {
|
||||
// Creation is the one branch that has to ask a question first; the switch
|
||||
// happens in its callback, so a cancelled prompt leaves the dialog put.
|
||||
let (dialog, next) = (dialog.clone(), next.clone());
|
||||
prompt_name(&parent, "New profile", "Create", "", move |name| {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
if catalog.name_taken(&name, None) {
|
||||
return; // the prompt already refuses these; belt and braces
|
||||
}
|
||||
let profile = StreamProfile::new(name);
|
||||
let id = profile.id.clone();
|
||||
catalog.profiles.push(profile);
|
||||
if catalog.save().is_ok() {
|
||||
*next.borrow_mut() = Some(Scope::Profile(id));
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
*next.borrow_mut() = Some(match i {
|
||||
0 => Scope::Defaults,
|
||||
n => match ids.get(n as usize - 1) {
|
||||
Some(id) => Scope::Profile(id.clone()),
|
||||
None => Scope::Defaults,
|
||||
},
|
||||
});
|
||||
dialog.close();
|
||||
});
|
||||
}
|
||||
g.add(row.widget());
|
||||
// Leaking the row keeps its handler alive for the dialog's lifetime — the ChoiceRow owns
|
||||
// the closure, and the widget alone doesn't keep it.
|
||||
std::mem::forget(row);
|
||||
|
||||
if let Some(active) = active {
|
||||
let actions = adw::ActionRow::builder()
|
||||
.title(&active.name)
|
||||
.subtitle("This profile")
|
||||
.use_markup(false)
|
||||
.build();
|
||||
let buttons = gtk::Box::builder()
|
||||
.spacing(6)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
for (label, action) in [
|
||||
("Rename…", ProfileAction::Rename),
|
||||
("Duplicate", ProfileAction::Duplicate),
|
||||
("Delete…", ProfileAction::Delete),
|
||||
] {
|
||||
let b = gtk::Button::builder().label(label).build();
|
||||
if matches!(action, ProfileAction::Delete) {
|
||||
b.add_css_class("destructive-action");
|
||||
}
|
||||
let (dialog, next, parent, id, name) = (
|
||||
dialog.clone(),
|
||||
next_scope.clone(),
|
||||
parent.as_ref().clone(),
|
||||
active.id.clone(),
|
||||
active.name.clone(),
|
||||
);
|
||||
b.connect_clicked(move |_| {
|
||||
run_profile_action(action, &parent, &dialog, &next, &id, &name)
|
||||
});
|
||||
buttons.append(&b);
|
||||
}
|
||||
actions.add_suffix(&buttons);
|
||||
g.add(&actions);
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ProfileAction {
|
||||
Rename,
|
||||
Duplicate,
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// Rename / duplicate / delete for the profile in scope. Each ends by closing the dialog so
|
||||
/// the edit and the re-render can't disagree about what the catalog holds.
|
||||
fn run_profile_action(
|
||||
action: ProfileAction,
|
||||
parent: >k::Widget,
|
||||
dialog: &adw::PreferencesDialog,
|
||||
next: &Rc<RefCell<Option<Scope>>>,
|
||||
id: &str,
|
||||
name: &str,
|
||||
) {
|
||||
let (dialog, next, id) = (dialog.clone(), next.clone(), id.to_string());
|
||||
match action {
|
||||
ProfileAction::Rename => {
|
||||
let keep = id.clone();
|
||||
prompt_name(parent, "Rename profile", "Rename", name, move |new_name| {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
if catalog.name_taken(&new_name, Some(&keep)) {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == keep) {
|
||||
p.name = new_name;
|
||||
}
|
||||
if catalog.save().is_ok() {
|
||||
*next.borrow_mut() = Some(Scope::Profile(keep.clone()));
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
ProfileAction::Duplicate => {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
let Some(source) = catalog.find_by_id(&id).cloned() else {
|
||||
return;
|
||||
};
|
||||
// "Work 2", "Work 3", … — the first name the catalog doesn't already hold.
|
||||
let copy_name = (2..)
|
||||
.map(|n| format!("{} {n}", source.name))
|
||||
.find(|n| !catalog.name_taken(n, None))
|
||||
.unwrap_or_else(|| source.name.clone());
|
||||
let mut copy = StreamProfile::new(copy_name);
|
||||
copy.overrides = source.overrides.clone();
|
||||
copy.accent = source.accent.clone();
|
||||
let new_id = copy.id.clone();
|
||||
catalog.profiles.push(copy);
|
||||
if catalog.save().is_ok() {
|
||||
*next.borrow_mut() = Some(Scope::Profile(new_id));
|
||||
dialog.close();
|
||||
}
|
||||
}
|
||||
ProfileAction::Delete => {
|
||||
// The warning counts what actually breaks: hosts that fall back to the defaults,
|
||||
// and pinned cards that disappear (design §6).
|
||||
let known = crate::trust::KnownHosts::load();
|
||||
let bound = known
|
||||
.hosts
|
||||
.iter()
|
||||
.filter(|h| h.profile_id.as_deref() == Some(id.as_str()))
|
||||
.count();
|
||||
let pinned = known
|
||||
.hosts
|
||||
.iter()
|
||||
.filter(|h| h.pinned_profiles.iter().any(|p| p == &id))
|
||||
.count();
|
||||
let mut body = format!("“{name}” will be removed.");
|
||||
if bound > 0 {
|
||||
body.push_str(&format!(
|
||||
"\n\n{bound} host{} will fall back to Default settings.",
|
||||
if bound == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
if pinned > 0 {
|
||||
body.push_str(&format!(
|
||||
"\n{pinned} pinned card{} will disappear.",
|
||||
if pinned == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
let confirm = adw::AlertDialog::new(Some("Delete profile?"), Some(&body));
|
||||
confirm.add_responses(&[("cancel", "Cancel"), ("delete", "Delete")]);
|
||||
confirm.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||
confirm.set_close_response("cancel");
|
||||
confirm.connect_response(Some("delete"), move |_, _| {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
catalog.profiles.retain(|p| p.id != id);
|
||||
if catalog.save().is_ok() {
|
||||
// Bindings and pins are left dangling on purpose: they resolve as "no
|
||||
// profile" everywhere, and rewriting every host record here would be a
|
||||
// second, racier source of truth.
|
||||
*next.borrow_mut() = Some(Scope::Defaults);
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
confirm.present(Some(parent));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-line name prompt (create/rename). Refuses empty and duplicate names in place —
|
||||
/// menus keyed by name are ambiguous otherwise (design §6) — rather than failing after the
|
||||
/// dialog is gone.
|
||||
fn prompt_name(
|
||||
parent: &impl IsA<gtk::Widget>,
|
||||
heading: &str,
|
||||
accept: &str,
|
||||
initial: &str,
|
||||
on_ok: impl Fn(String) + 'static,
|
||||
) {
|
||||
let dialog = adw::AlertDialog::new(Some(heading), None);
|
||||
let entry = adw::EntryRow::builder().title("Name").build();
|
||||
entry.set_text(initial);
|
||||
let list = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.css_classes(["boxed-list"])
|
||||
.build();
|
||||
list.append(&entry);
|
||||
dialog.set_extra_child(Some(&list));
|
||||
dialog.add_responses(&[("cancel", "Cancel"), ("ok", accept)]);
|
||||
dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("ok"));
|
||||
dialog.set_close_response("cancel");
|
||||
let taken_against = initial.to_string();
|
||||
let e = entry.clone();
|
||||
let d = dialog.clone();
|
||||
let validate = move || {
|
||||
let name = e.text().trim().to_string();
|
||||
let catalog = ProfilesFile::load();
|
||||
let dup = !name.eq_ignore_ascii_case(&taken_against) && catalog.name_taken(&name, None);
|
||||
d.set_response_enabled("ok", !name.is_empty() && !dup);
|
||||
e.set_title(if dup {
|
||||
"Name — already used by another profile"
|
||||
} else {
|
||||
"Name"
|
||||
});
|
||||
};
|
||||
validate();
|
||||
{
|
||||
let validate = validate.clone();
|
||||
entry.connect_changed(move |_| validate());
|
||||
}
|
||||
let e = entry.clone();
|
||||
dialog.connect_response(Some("ok"), move |_, _| {
|
||||
let name = e.text().trim().to_string();
|
||||
if !name.is_empty() {
|
||||
on_ok(name);
|
||||
}
|
||||
});
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
/// Write the rows the user touched into this profile's overlay and persist the catalog.
|
||||
///
|
||||
/// Only touched fields move: an untouched row leaves whatever the profile already had
|
||||
/// (an inherited `None`, or an existing override this build might not even render), which is
|
||||
/// what keeps an older client from erasing a newer one's values just by opening the dialog.
|
||||
/// The catalog is re-read here rather than reused, so a profile renamed in another window
|
||||
/// between opening and closing this one survives.
|
||||
fn commit_profile(
|
||||
active: &StreamProfile,
|
||||
touched: &Touched,
|
||||
cleared: &HashSet<&'static str>,
|
||||
values: &Settings,
|
||||
) {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
let Some(slot) = catalog.profiles.iter_mut().find(|p| p.id == active.id) else {
|
||||
return; // deleted from under us — nothing to write to, and nothing to complain about
|
||||
};
|
||||
let o: &mut SettingsOverlay = &mut slot.overrides;
|
||||
if touched.has("resolution") {
|
||||
// One row drives the tri-state, so all three fields move together.
|
||||
o.match_window = Some(values.match_window);
|
||||
o.width = Some(values.width);
|
||||
o.height = Some(values.height);
|
||||
}
|
||||
if touched.has("refresh_hz") {
|
||||
o.refresh_hz = Some(values.refresh_hz);
|
||||
}
|
||||
if touched.has("render_scale") {
|
||||
o.render_scale = Some(values.render_scale);
|
||||
}
|
||||
if touched.has("bitrate_kbps") {
|
||||
o.bitrate_kbps = Some(values.bitrate_kbps);
|
||||
}
|
||||
if touched.has("codec") {
|
||||
o.codec = Some(values.codec.clone());
|
||||
}
|
||||
if touched.has("hdr_enabled") {
|
||||
o.hdr_enabled = Some(values.hdr_enabled);
|
||||
}
|
||||
if touched.has("compositor") {
|
||||
o.compositor = Some(values.compositor.clone());
|
||||
}
|
||||
if touched.has("audio_channels") {
|
||||
o.audio_channels = Some(values.audio_channels);
|
||||
}
|
||||
if touched.has("mic_enabled") {
|
||||
o.mic_enabled = Some(values.mic_enabled);
|
||||
}
|
||||
if touched.has("touch_mode") {
|
||||
o.touch_mode = Some(values.touch_mode.clone());
|
||||
}
|
||||
if touched.has("mouse_mode") {
|
||||
o.mouse_mode = Some(values.mouse_mode.clone());
|
||||
}
|
||||
if touched.has("invert_scroll") {
|
||||
o.invert_scroll = Some(values.invert_scroll);
|
||||
}
|
||||
if touched.has("inhibit_shortcuts") {
|
||||
o.inhibit_shortcuts = Some(values.inhibit_shortcuts);
|
||||
}
|
||||
if touched.has("gamepad") {
|
||||
o.gamepad = Some(values.gamepad.clone());
|
||||
}
|
||||
if touched.has("stats_verbosity") {
|
||||
o.stats_verbosity = Some(values.stats_verbosity());
|
||||
}
|
||||
if touched.has("fullscreen_on_stream") {
|
||||
o.fullscreen_on_stream = Some(values.fullscreen_on_stream);
|
||||
}
|
||||
// Resets last: a row the user reset may also have fired its change handler on the way
|
||||
// (a ComboRow re-seeds), and "back to inheriting" is the later, explicit intent. The field
|
||||
// names are the overlay's own, so the list of what can be cleared lives in ONE place —
|
||||
// this used to be a second `match` here, which is exactly how the two drift.
|
||||
for key in cleared {
|
||||
if !o.clear(key) {
|
||||
tracing::warn!(field = key, "reset of an unknown overlay field");
|
||||
}
|
||||
}
|
||||
if let Err(e) = catalog.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving the profile catalog");
|
||||
}
|
||||
}
|
||||
|
||||
/// A compact label for a render-scale multiplier: "Native" / "1.5×" / "2× (supersample)".
|
||||
fn render_scale_label(scale: f64) -> String {
|
||||
if scale == 1.0 {
|
||||
@@ -130,7 +518,7 @@ fn gamescope_session() -> bool {
|
||||
|| std::env::var("GAMESCOPE_WAYLAND_DISPLAY").is_ok()
|
||||
}
|
||||
|
||||
type ChangedFn = Rc<RefCell<Option<Box<dyn Fn(u32)>>>>;
|
||||
type ChangedFn = Rc<RefCell<Vec<Box<dyn Fn(u32)>>>>;
|
||||
|
||||
/// A titled single-choice preference row. On a desktop this is a stock popover
|
||||
/// [`adw::ComboRow`]; under gamescope (see [`gamescope_session`]) it becomes an activatable
|
||||
@@ -138,8 +526,9 @@ type ChangedFn = Rc<RefCell<Option<Box<dyn Fn(u32)>>>>;
|
||||
struct ChoiceRow {
|
||||
row: adw::PreferencesRow,
|
||||
selected: Rc<Cell<u32>>,
|
||||
/// Fires on user changes only — [`connect_changed`](Self::connect_changed) is installed
|
||||
/// after seeding, so programmatic `set_selected` during setup never fires it.
|
||||
/// Fires on user changes only — handlers are installed after seeding, so programmatic
|
||||
/// `set_selected` during setup never fires them. A list, not one: a row can carry both a
|
||||
/// dynamic caption and (in profile scope) the override mark.
|
||||
changed: ChangedFn,
|
||||
/// Subpage mode only: the current value rendered as the row's suffix.
|
||||
value_label: Option<gtk::Label>,
|
||||
@@ -158,7 +547,7 @@ impl ChoiceRow {
|
||||
) -> ChoiceRow {
|
||||
let options: Rc<Vec<String>> = Rc::new(options.iter().map(|s| s.to_string()).collect());
|
||||
let selected = Rc::new(Cell::new(0u32));
|
||||
let changed: ChangedFn = Rc::new(RefCell::new(None));
|
||||
let changed: ChangedFn = Rc::new(RefCell::new(Vec::new()));
|
||||
|
||||
if !inline {
|
||||
let row = adw::ComboRow::builder()
|
||||
@@ -171,7 +560,7 @@ impl ChoiceRow {
|
||||
let (sel, chg) = (selected.clone(), changed.clone());
|
||||
row.connect_selected_notify(move |r| {
|
||||
if sel.replace(r.selected()) != r.selected() {
|
||||
if let Some(f) = chg.borrow().as_ref() {
|
||||
for f in chg.borrow().iter() {
|
||||
f(r.selected());
|
||||
}
|
||||
}
|
||||
@@ -227,7 +616,7 @@ impl ChoiceRow {
|
||||
let user_change = sel.replace(idx) != idx;
|
||||
value.set_text(&label);
|
||||
if user_change {
|
||||
if let Some(f) = chg.borrow().as_ref() {
|
||||
for f in chg.borrow().iter() {
|
||||
f(idx);
|
||||
}
|
||||
}
|
||||
@@ -291,7 +680,7 @@ impl ChoiceRow {
|
||||
}
|
||||
|
||||
fn connect_changed(&self, f: impl Fn(u32) + 'static) {
|
||||
*self.changed.borrow_mut() = Some(Box::new(f));
|
||||
self.changed.borrow_mut().push(Box::new(f));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,17 +746,40 @@ fn group(title: &str, description: &str) -> adw::PreferencesGroup {
|
||||
g
|
||||
}
|
||||
|
||||
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
|
||||
/// there so the library toggle takes effect without a nav round-trip). `probes` is the
|
||||
/// shell's startup device probe (`AppModel::probes`) — may still be empty. Returns the
|
||||
/// presented dialog so the screenshot harness can select a page; callers ignore it.
|
||||
pub fn show(
|
||||
/// The dialog in a given [`Scope`]. `on_scope` asks the app to re-open it in another one:
|
||||
/// switching scope closes this dialog first, so the layer being edited is committed before
|
||||
/// the next one is loaded, and there is exactly one place that builds the rows.
|
||||
pub fn show_scoped(
|
||||
parent: &impl IsA<gtk::Widget>,
|
||||
settings: Rc<RefCell<Settings>>,
|
||||
gamepads: &crate::gamepad::GamepadService,
|
||||
probes: &DeviceProbes,
|
||||
scope: Scope,
|
||||
on_scope: impl Fn(Scope) + 'static,
|
||||
on_closed: impl Fn() + 'static,
|
||||
) -> adw::PreferencesDialog {
|
||||
let catalog = ProfilesFile::load();
|
||||
// A scope pointing at a deleted profile degrades to the defaults rather than erroring —
|
||||
// the same rule a dangling host binding follows.
|
||||
let active: Option<StreamProfile> = match &scope {
|
||||
Scope::Profile(id) => catalog.find_by_id(id).cloned(),
|
||||
Scope::Defaults => None,
|
||||
};
|
||||
let profile_mode = active.is_some();
|
||||
// Rows always show the EFFECTIVE value: the global underneath, with this profile's
|
||||
// overrides on top. A row the profile doesn't override therefore reads as the live
|
||||
// global, which is what "inherit by default" has to look like.
|
||||
let seed: Settings = match &active {
|
||||
Some(p) => p.overrides.apply(&settings.borrow()),
|
||||
None => settings.borrow().clone(),
|
||||
};
|
||||
let touched = Touched::default();
|
||||
// Fields whose override a per-row reset dropped — applied at commit, after the touched
|
||||
// ones, so "reset" wins over a value the same row happens to be showing.
|
||||
let cleared: Rc<RefCell<HashSet<&'static str>>> = Rc::default();
|
||||
// Where a scope switch wants to go once this dialog has committed and closed.
|
||||
let next_scope: Rc<RefCell<Option<Scope>>> = Rc::default();
|
||||
|
||||
// The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
|
||||
// subpage onto it.
|
||||
let dialog = adw::PreferencesDialog::new();
|
||||
@@ -465,7 +877,7 @@ pub fn show(
|
||||
// GPU picker (multi-GPU boxes): the adapter name feeds the session's device pick
|
||||
// via `Settings::adapter` → PUNKTFUNK_VK_ADAPTER. Hidden when there's nothing to
|
||||
// pick; a saved adapter that's gone (eGPU unplugged) keeps a revertable entry.
|
||||
let saved_adapter = settings.borrow().adapter.clone();
|
||||
let saved_adapter = seed.adapter.clone();
|
||||
let mut gpu_names = vec!["Automatic".to_string()];
|
||||
let mut gpu_keys: Vec<String> = vec![String::new()];
|
||||
for a in &probes.adapters {
|
||||
@@ -709,9 +1121,9 @@ pub fn show(
|
||||
],
|
||||
);
|
||||
|
||||
// ---- Seed from the current settings ----
|
||||
// ---- Seed from the effective settings for this scope ----
|
||||
{
|
||||
let s = settings.borrow();
|
||||
let s = &seed;
|
||||
let res_i = if s.match_window {
|
||||
1
|
||||
} else {
|
||||
@@ -775,18 +1187,162 @@ pub fn show(
|
||||
set_row_subtitle(codec_row.widget(), codec_caption(codec_i as u32));
|
||||
}
|
||||
|
||||
// ---- Override tracking (profile scope) ----
|
||||
// Installed after the seed block on purpose: `set_selected`/`set_active` during setup must
|
||||
// not look like the user touching the control, or opening a profile would override every
|
||||
// row in it.
|
||||
if profile_mode {
|
||||
macro_rules! on_change {
|
||||
($row:expr, $key:literal) => {{
|
||||
let t = touched.clone();
|
||||
$row.connect_changed(move |_| t.mark($key));
|
||||
}};
|
||||
}
|
||||
macro_rules! on_toggle {
|
||||
($row:expr, $key:literal) => {{
|
||||
let t = touched.clone();
|
||||
$row.connect_active_notify(move |_| t.mark($key));
|
||||
}};
|
||||
}
|
||||
on_change!(res_row, "resolution");
|
||||
on_change!(hz_row, "refresh_hz");
|
||||
on_change!(scale_row, "render_scale");
|
||||
on_change!(codec_row, "codec");
|
||||
on_change!(compositor_row, "compositor");
|
||||
on_change!(stats_row, "stats_verbosity");
|
||||
on_change!(touch_row, "touch_mode");
|
||||
on_change!(mouse_row, "mouse_mode");
|
||||
on_change!(surround_row, "audio_channels");
|
||||
on_change!(pad_row, "gamepad");
|
||||
on_toggle!(hdr_row, "hdr_enabled");
|
||||
on_toggle!(fullscreen_row, "fullscreen_on_stream");
|
||||
on_toggle!(inhibit_row, "inhibit_shortcuts");
|
||||
on_toggle!(invert_row, "invert_scroll");
|
||||
on_toggle!(mic_row, "mic_enabled");
|
||||
let t = touched.clone();
|
||||
bitrate_row.connect_value_notify(move |_| t.mark("bitrate_kbps"));
|
||||
}
|
||||
|
||||
// ---- Override markers + per-row reset (profile scope) ----
|
||||
// Every overridden row says so — an accent dot — and carries the only way back to
|
||||
// inheriting: an explicit reset. Without this a profile is a one-way door, since the
|
||||
// override model deliberately never infers "not overridden" from a value comparison.
|
||||
if let Some(active) = &active {
|
||||
let o = &active.overrides;
|
||||
let mark = |row: &adw::PreferencesRow, key: &'static str, overridden: bool| {
|
||||
if !overridden {
|
||||
return;
|
||||
}
|
||||
let Some(row) = row.downcast_ref::<adw::ActionRow>() else {
|
||||
return;
|
||||
};
|
||||
let dot = gtk::Box::builder()
|
||||
.css_classes(["pf-override-dot"])
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
row.add_prefix(&dot);
|
||||
let reset = gtk::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.tooltip_text("Reset to Default settings")
|
||||
.valign(gtk::Align::Center)
|
||||
.css_classes(["flat"])
|
||||
.build();
|
||||
let (dialog, next, cleared, scope) = (
|
||||
dialog.clone(),
|
||||
next_scope.clone(),
|
||||
cleared.clone(),
|
||||
scope.clone(),
|
||||
);
|
||||
reset.connect_clicked(move |_| {
|
||||
// Queue the clear and re-open in the same scope: the close handler commits
|
||||
// whatever else was edited first, then drops this field, and the rebuilt rows
|
||||
// show the inherited value. One code path builds rows — including this one.
|
||||
cleared.borrow_mut().insert(key);
|
||||
*next.borrow_mut() = Some(scope.clone());
|
||||
dialog.close();
|
||||
});
|
||||
row.add_suffix(&reset);
|
||||
};
|
||||
mark(
|
||||
res_row.widget(),
|
||||
"resolution",
|
||||
o.width.is_some() || o.height.is_some() || o.match_window.is_some(),
|
||||
);
|
||||
mark(hz_row.widget(), "refresh_hz", o.refresh_hz.is_some());
|
||||
mark(scale_row.widget(), "render_scale", o.render_scale.is_some());
|
||||
mark(
|
||||
bitrate_row.upcast_ref(),
|
||||
"bitrate_kbps",
|
||||
o.bitrate_kbps.is_some(),
|
||||
);
|
||||
mark(codec_row.widget(), "codec", o.codec.is_some());
|
||||
mark(hdr_row.upcast_ref(), "hdr_enabled", o.hdr_enabled.is_some());
|
||||
mark(
|
||||
compositor_row.widget(),
|
||||
"compositor",
|
||||
o.compositor.is_some(),
|
||||
);
|
||||
mark(
|
||||
surround_row.widget(),
|
||||
"audio_channels",
|
||||
o.audio_channels.is_some(),
|
||||
);
|
||||
mark(mic_row.upcast_ref(), "mic_enabled", o.mic_enabled.is_some());
|
||||
mark(touch_row.widget(), "touch_mode", o.touch_mode.is_some());
|
||||
mark(mouse_row.widget(), "mouse_mode", o.mouse_mode.is_some());
|
||||
mark(
|
||||
invert_row.upcast_ref(),
|
||||
"invert_scroll",
|
||||
o.invert_scroll.is_some(),
|
||||
);
|
||||
mark(
|
||||
inhibit_row.upcast_ref(),
|
||||
"inhibit_shortcuts",
|
||||
o.inhibit_shortcuts.is_some(),
|
||||
);
|
||||
mark(pad_row.widget(), "gamepad", o.gamepad.is_some());
|
||||
mark(
|
||||
stats_row.widget(),
|
||||
"stats_verbosity",
|
||||
o.stats_verbosity.is_some(),
|
||||
);
|
||||
mark(
|
||||
fullscreen_row.upcast_ref(),
|
||||
"fullscreen_on_stream",
|
||||
o.fullscreen_on_stream.is_some(),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Assemble the category pages (the Apple revamp's map) ----
|
||||
let general = page("General", "preferences-system-symbolic");
|
||||
// The scope switcher heads the first page — the one row that is always about which layer
|
||||
// you are editing, not about the stream.
|
||||
general.add(&scope_group(
|
||||
&dialog,
|
||||
inline,
|
||||
&scope,
|
||||
&catalog,
|
||||
active.as_ref(),
|
||||
&next_scope,
|
||||
parent,
|
||||
));
|
||||
let session_group = group("Session", "");
|
||||
session_group.add(&fullscreen_row);
|
||||
session_group.add(&wake_row);
|
||||
// Auto-wake is a property of the host and this network, not of "Game vs Work" — it stays
|
||||
// global in v1 (design §3, tier H/G).
|
||||
if !profile_mode {
|
||||
session_group.add(&wake_row);
|
||||
}
|
||||
let stats_group = group("Statistics", "");
|
||||
stats_group.add(stats_row.widget());
|
||||
let library_group = group("Library", "");
|
||||
library_group.add(&library_row);
|
||||
general.add(&session_group);
|
||||
general.add(&stats_group);
|
||||
general.add(&library_group);
|
||||
// The library browser is an app-level toggle for this device, not a per-profile one.
|
||||
if !profile_mode {
|
||||
let library_group = group("Library", "");
|
||||
library_group.add(&library_row);
|
||||
general.add(&library_group);
|
||||
}
|
||||
|
||||
let display = page("Display", "video-display-symbolic");
|
||||
let resolution_group = group("Resolution", "");
|
||||
@@ -797,8 +1353,11 @@ pub fn show(
|
||||
quality_group.add(&bitrate_row);
|
||||
quality_group.add(codec_row.widget());
|
||||
quality_group.add(&hdr_row);
|
||||
quality_group.add(decoder_row.widget());
|
||||
if let Some(r) = &gpu_row {
|
||||
// Decoder and GPU are facts about THIS device's hardware — never per profile (tier G).
|
||||
if !profile_mode {
|
||||
quality_group.add(decoder_row.widget());
|
||||
}
|
||||
if let (Some(r), false) = (&gpu_row, profile_mode) {
|
||||
quality_group.add(r.widget());
|
||||
}
|
||||
// The one form-level note (deliberately not repeated on every row).
|
||||
@@ -825,11 +1384,14 @@ pub fn show(
|
||||
let audio = page("Audio", "audio-volume-high-symbolic");
|
||||
let audio_group = group("", "Applies from the next session.");
|
||||
audio_group.add(surround_row.widget());
|
||||
if let Some(r) = &speaker_row {
|
||||
// The speaker/mic endpoint pickers below are this device's audio routing (tier G) — they
|
||||
// render only in the defaults scope; the surround + mic-uplink rows above are profileable.
|
||||
|
||||
if let (Some(r), false) = (&speaker_row, profile_mode) {
|
||||
audio_group.add(r.widget());
|
||||
}
|
||||
audio_group.add(&mic_row);
|
||||
if let Some(r) = &micdev_row {
|
||||
if let (Some(r), false) = (&micdev_row, profile_mode) {
|
||||
audio_group.add(r.widget());
|
||||
}
|
||||
audio.add(&audio_group);
|
||||
@@ -837,8 +1399,12 @@ pub fn show(
|
||||
let controllers = page("Controllers", "input-gaming-symbolic");
|
||||
let controllers_group = group("", "");
|
||||
// The detected-pad list (mirrors the Apple Controllers section): informational rows
|
||||
// above the pickers, from the same snapshot that feeds the forwarding picker.
|
||||
if pads.is_empty() {
|
||||
// above the pickers, from the same snapshot that feeds the forwarding picker. It is
|
||||
// about the hardware plugged into THIS device, so profile scope shows only the
|
||||
// emulated-type picker below it.
|
||||
if profile_mode {
|
||||
// nothing — the pad inventory belongs to the device, not the profile
|
||||
} else if pads.is_empty() {
|
||||
let none = adw::ActionRow::builder()
|
||||
.title("No controllers detected")
|
||||
.css_classes(["dim-label"])
|
||||
@@ -861,7 +1427,9 @@ pub fn show(
|
||||
controllers_group.add(&row);
|
||||
}
|
||||
}
|
||||
controllers_group.add(forward_row.widget());
|
||||
if !profile_mode {
|
||||
controllers_group.add(forward_row.widget());
|
||||
}
|
||||
controllers_group.add(pad_row.widget());
|
||||
controllers.add(&controllers_group);
|
||||
|
||||
@@ -872,64 +1440,89 @@ pub fn show(
|
||||
dialog.add(&controllers);
|
||||
|
||||
dialog.connect_closed(move |_| {
|
||||
let mut s = settings.borrow_mut();
|
||||
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
|
||||
let res_i = (res_row.selected() as usize).min(RESOLUTIONS.len());
|
||||
s.match_window = res_i == 1;
|
||||
(s.width, s.height) = if res_i <= 1 {
|
||||
(0, 0)
|
||||
} else {
|
||||
RESOLUTIONS[res_i - 1]
|
||||
};
|
||||
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
|
||||
s.render_scale =
|
||||
RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
|
||||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||
// Keep a stored preference this table doesn't list (e.g. "switchpro" — valid to the
|
||||
// session, hand-edited or written by another client): it displays as "Automatic", and
|
||||
// writing that back would silently erase it just by opening + closing the dialog.
|
||||
// Persist the row only when the user picked a non-Auto entry or the stored value was
|
||||
// a listed one to begin with.
|
||||
let pad_sel = (pad_row.selected() as usize).min(GAMEPADS.len() - 1);
|
||||
if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
|
||||
s.gamepad = GAMEPADS[pad_sel].to_string();
|
||||
}
|
||||
s.touch_mode =
|
||||
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
||||
s.mouse_mode =
|
||||
MOUSE_MODES[(mouse_row.selected() as usize).min(MOUSE_MODES.len() - 1)].to_string();
|
||||
s.forward_pad = chosen_pin.borrow().clone();
|
||||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||||
// One reader for the rows, two destinations: the globals, or a profile's overrides.
|
||||
// Sharing it is what keeps the two scopes from interpreting the same controls
|
||||
// differently (the tri-state resolution row is the obvious trap).
|
||||
let apply_rows = |s: &mut Settings| {
|
||||
// Index 1 is the virtual "Match window" option; 0 = Native, 2.. = explicit.
|
||||
let res_i = (res_row.selected() as usize).min(RESOLUTIONS.len());
|
||||
s.match_window = res_i == 1;
|
||||
(s.width, s.height) = if res_i <= 1 {
|
||||
(0, 0)
|
||||
} else {
|
||||
RESOLUTIONS[res_i - 1]
|
||||
};
|
||||
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
|
||||
s.render_scale =
|
||||
RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
|
||||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||
// Keep a stored preference this table doesn't list (e.g. "switchpro" — valid to the
|
||||
// session, hand-edited or written by another client): it displays as "Automatic", and
|
||||
// writing that back would silently erase it just by opening + closing the dialog.
|
||||
// Persist the row only when the user picked a non-Auto entry or the stored value was
|
||||
// a listed one to begin with.
|
||||
let pad_sel = (pad_row.selected() as usize).min(GAMEPADS.len() - 1);
|
||||
if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) {
|
||||
s.gamepad = GAMEPADS[pad_sel].to_string();
|
||||
}
|
||||
s.touch_mode =
|
||||
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
||||
s.mouse_mode =
|
||||
MOUSE_MODES[(mouse_row.selected() as usize).min(MOUSE_MODES.len() - 1)].to_string();
|
||||
s.forward_pad = chosen_pin.borrow().clone();
|
||||
s.compositor = COMPOSITORS
|
||||
[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||||
.to_string();
|
||||
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
|
||||
if let Some(r) = &gpu_row {
|
||||
s.adapter = gpu_keys[(r.selected() as usize).min(gpu_keys.len() - 1)].clone();
|
||||
}
|
||||
if let Some(r) = &speaker_row {
|
||||
s.speaker_device =
|
||||
speaker_keys[(r.selected() as usize).min(speaker_keys.len() - 1)].clone();
|
||||
}
|
||||
if let Some(r) = &micdev_row {
|
||||
s.mic_device = micdev_keys[(r.selected() as usize).min(micdev_keys.len() - 1)].clone();
|
||||
}
|
||||
s.set_stats_verbosity(
|
||||
StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
|
||||
);
|
||||
s.fullscreen_on_stream = fullscreen_row.is_active();
|
||||
s.auto_wake = wake_row.is_active();
|
||||
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||
s.invert_scroll = invert_row.is_active();
|
||||
s.mic_enabled = mic_row.is_active();
|
||||
s.hdr_enabled = hdr_row.is_active();
|
||||
s.audio_channels = match surround_row.selected() {
|
||||
1 => 6,
|
||||
2 => 8,
|
||||
_ => 2,
|
||||
s.decoder =
|
||||
DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
|
||||
if let Some(r) = &gpu_row {
|
||||
s.adapter = gpu_keys[(r.selected() as usize).min(gpu_keys.len() - 1)].clone();
|
||||
}
|
||||
if let Some(r) = &speaker_row {
|
||||
s.speaker_device =
|
||||
speaker_keys[(r.selected() as usize).min(speaker_keys.len() - 1)].clone();
|
||||
}
|
||||
if let Some(r) = &micdev_row {
|
||||
s.mic_device =
|
||||
micdev_keys[(r.selected() as usize).min(micdev_keys.len() - 1)].clone();
|
||||
}
|
||||
s.set_stats_verbosity(
|
||||
StatsVerbosity::ALL
|
||||
[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
|
||||
);
|
||||
s.fullscreen_on_stream = fullscreen_row.is_active();
|
||||
s.auto_wake = wake_row.is_active();
|
||||
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||
s.invert_scroll = invert_row.is_active();
|
||||
s.mic_enabled = mic_row.is_active();
|
||||
s.hdr_enabled = hdr_row.is_active();
|
||||
s.audio_channels = match surround_row.selected() {
|
||||
1 => 6,
|
||||
2 => 8,
|
||||
_ => 2,
|
||||
};
|
||||
s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
|
||||
s.library_enabled = library_row.is_active();
|
||||
};
|
||||
s.codec = CODECS[(codec_row.selected() as usize).min(CODECS.len() - 1)].to_string();
|
||||
s.library_enabled = library_row.is_active();
|
||||
s.save();
|
||||
drop(s);
|
||||
|
||||
match &active {
|
||||
// Profile scope writes the touched rows into the catalog and leaves the globals
|
||||
// exactly as they were — the point of the whole feature.
|
||||
Some(active) => {
|
||||
let mut values = seed.clone();
|
||||
apply_rows(&mut values);
|
||||
commit_profile(active, &touched, &cleared.borrow(), &values);
|
||||
}
|
||||
None => {
|
||||
let mut s = settings.borrow_mut();
|
||||
apply_rows(&mut s);
|
||||
s.save();
|
||||
}
|
||||
}
|
||||
// A scope switch closed this dialog to commit first; now re-open in the new scope.
|
||||
if let Some(next) = next_scope.borrow_mut().take() {
|
||||
on_scope(next);
|
||||
}
|
||||
on_closed();
|
||||
});
|
||||
dialog.present(Some(parent));
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::trust;
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use pf_client_core::orchestrate::{WakeOutcome, WakeWait};
|
||||
use relm4::prelude::*;
|
||||
|
||||
/// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a
|
||||
@@ -16,7 +17,11 @@ use relm4::prelude::*;
|
||||
/// sent a magic packet, then we poll mDNS until it comes back online — re-sending every few
|
||||
/// seconds up to a timeout — and route back into the trust gate, **re-keying the saved
|
||||
/// record if the host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog
|
||||
/// lets the user cancel. Mirrors the Apple/Android `HostWaker` (90 s budget, resend every 6 s).
|
||||
/// lets the user cancel.
|
||||
///
|
||||
/// The cadence itself is [`WakeWait`] — the same state machine the WinUI shell drives, ported
|
||||
/// from Apple's `HostWaker` (design/client-architecture-split.md §3). What is left here is the
|
||||
/// GTK half: the dialog, the advert drain, the re-key, and the route back into the trust gate.
|
||||
pub fn wake_and_connect(
|
||||
window: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
@@ -40,23 +45,17 @@ pub fn wake_and_connect(
|
||||
|
||||
let sender = sender.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
let events = crate::discovery::browse();
|
||||
let started = Instant::now();
|
||||
let budget = Duration::from_secs(90);
|
||||
let resend = Duration::from_secs(6);
|
||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||
let mut last_wake = Instant::now();
|
||||
let mut wait = WakeWait::new();
|
||||
loop {
|
||||
if cancel.get() {
|
||||
waiting.close();
|
||||
return;
|
||||
}
|
||||
if last_wake.elapsed() >= resend {
|
||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||
last_wake = Instant::now();
|
||||
}
|
||||
// Drain resolved adverts; a match (fingerprint, else addr:port) = it's up.
|
||||
// Drain resolved adverts; a match (fingerprint, else addr:port) means it is up,
|
||||
// and carries the address it came back on.
|
||||
let mut seen: Option<(String, u16)> = None;
|
||||
while let Ok(ev) = events.try_recv() {
|
||||
let crate::discovery::DiscoveryEvent::Resolved(h) = ev else {
|
||||
continue;
|
||||
@@ -66,30 +65,42 @@ pub fn wake_and_connect(
|
||||
None => h.addr == req.addr && h.port == req.port,
|
||||
};
|
||||
if matched {
|
||||
seen = Some((h.addr, h.port));
|
||||
}
|
||||
}
|
||||
let tick = wait.tick(seen.is_some());
|
||||
if tick.send_packet {
|
||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||
}
|
||||
match tick.outcome {
|
||||
Some(WakeOutcome::Online) => {
|
||||
waiting.close();
|
||||
let mut req = req.clone();
|
||||
// Re-key on a new DHCP lease so this + future connects dial the
|
||||
// live address.
|
||||
if h.addr != req.addr || h.port != req.port {
|
||||
if let Some((addr, port)) =
|
||||
seen.filter(|(a, p)| *a != req.addr || *p != req.port)
|
||||
{
|
||||
if let Some(fp) = &req.fp_hex {
|
||||
trust::rekey_addr(fp, &h.addr, h.port);
|
||||
trust::rekey_addr(fp, &addr, port);
|
||||
}
|
||||
req.addr = h.addr;
|
||||
req.port = h.port;
|
||||
req.addr = addr;
|
||||
req.port = port;
|
||||
}
|
||||
sender.input(AppMsg::Connect(req));
|
||||
return;
|
||||
}
|
||||
Some(WakeOutcome::TimedOut) => {
|
||||
waiting.close();
|
||||
sender.input(AppMsg::Toast(format!(
|
||||
"Couldn't reach “{}” — is it powered and on the network?",
|
||||
req.name
|
||||
)));
|
||||
return;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if started.elapsed() >= budget {
|
||||
waiting.close();
|
||||
sender.input(AppMsg::Toast(format!(
|
||||
"Couldn't reach “{}” — is it powered and on the network?",
|
||||
req.name
|
||||
)));
|
||||
return;
|
||||
}
|
||||
glib::timeout_future(Duration::from_millis(500)).await;
|
||||
glib::timeout_future(Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,3 +22,6 @@ mdns-sd = "0.20"
|
||||
# encoder. libopus is already in the graph via `punktfunk-core`'s quic feature; this exposes the
|
||||
# name directly. Cross-platform (cmake-vendored), so the probe builds + validates everywhere.
|
||||
opus = "0.3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
//! [--input-test | --mic-test [--mic-burst] | --touch-test | --rich-input-test]
|
||||
//! [--pin HEX | --pair PIN] [--compositor NAME] [--gamepad NAME] | --discover [SECS]`
|
||||
//! Env: `PUNKTFUNK_CLIENT_10BIT=1` / `PUNKTFUNK_CLIENT_444=1` advertise the 10-bit / 4:4:4 caps.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
|
||||
@@ -45,3 +45,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
# the SDK); same pattern as clients/windows.
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -137,6 +137,13 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
// `{"ready":true}` and restores on exit) — plain CLI/gamescope runs stay silent.
|
||||
let json_status = arg_flag("--json-status");
|
||||
let settings_at_start = trust::Settings::load();
|
||||
// The console's window and its input models are built ONCE, from the global defaults, and
|
||||
// live across every launch — so the presentation-tier fields below (stats tier, touch and
|
||||
// mouse model, match-window, render scale) are latched here and a per-host profile cannot
|
||||
// move them in this mode. Everything the HOST is told (mode, bitrate, codec, audio, pad) is
|
||||
// re-resolved per launch and does honor the binding. Closing that gap means rebuilding the
|
||||
// presenter's models per launch — profiles P4 territory, not P0.
|
||||
let latched_mouse = settings_at_start.mouse_mode();
|
||||
|
||||
// Request-access hand-off: the launch handler stamps this when it starts a delegated-approval
|
||||
// connect; `on_connected` reads it once the host lets us in and persists the host as PAIRED,
|
||||
@@ -201,11 +208,16 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
tracing::info!(%addr, %title, request_access,
|
||||
launch = launch.as_deref().unwrap_or("desktop"),
|
||||
"launching from the console");
|
||||
// Settings re-load per launch: the console's own settings screen
|
||||
// may have changed them since the last stream.
|
||||
let settings = trust::Settings::load();
|
||||
// Settings re-resolve per launch: the console's own settings screen may
|
||||
// have changed the defaults since the last stream, and the host may carry
|
||||
// a profile binding. Console (and therefore Decky, which spawns this
|
||||
// binary) honors bindings with no console-side work — the resolver is the
|
||||
// same one `--connect` goes through. No one-off here: picking a profile is
|
||||
// a desktop-shell affordance in v1, pinned cards are the console's.
|
||||
let (settings, profile) = trust::effective_settings(&addr, port, None);
|
||||
let mut params = session_params(
|
||||
&settings,
|
||||
profile.map(|p| p.name),
|
||||
addr.clone(),
|
||||
port,
|
||||
pin,
|
||||
@@ -216,6 +228,13 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
force_software,
|
||||
vulkan,
|
||||
);
|
||||
// …with ONE field that must follow the latched model rather than this
|
||||
// launch's: the cursor-channel advertisement says "this client draws the
|
||||
// host cursor itself", which is only true while the presenter is in desktop
|
||||
// mouse mode. A profile that flips `mouse_mode` here would make the host
|
||||
// stop compositing the pointer into a presenter that isn't drawing one —
|
||||
// a stream with no visible cursor at all.
|
||||
params.cursor_forward = latched_mouse == trust::MouseMode::Desktop;
|
||||
if request_access {
|
||||
// The host PARKS the connect until the operator approves — outlast its
|
||||
// approval window (host `PENDING_APPROVAL_WAIT`), matching the desktop
|
||||
@@ -434,11 +453,7 @@ impl ServiceState {
|
||||
name: if name.is_empty() { addr.clone() } else { name },
|
||||
addr,
|
||||
port,
|
||||
fp_hex: String::new(),
|
||||
paired: false,
|
||||
last_used: None,
|
||||
mac: Vec::new(),
|
||||
clipboard_sync: false,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if let Err(e) = known.save() {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
|
||||
//! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end,
|
||||
//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
||||
mod console;
|
||||
@@ -124,6 +125,15 @@ mod session_main {
|
||||
}
|
||||
}
|
||||
|
||||
/// `--profile <id|name>` — the settings profile this one session runs with, overriding the
|
||||
/// host's own binding for this launch only (never rebinding it): the shells' "Connect
|
||||
/// with ▸ X" and a `punktfunk://…&profile=` link both land here. Absent = honor the host's
|
||||
/// binding; `--profile ""` (or a bare `--profile`) forces the global defaults, which is
|
||||
/// how "Connect with ▸ Default settings" reaches a bound host.
|
||||
fn profile_arg() -> Option<String> {
|
||||
arg_flag("--profile").then(|| arg_value("--profile").unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The connect budget: 15 s normally; `--connect-timeout SECS` overrides — the
|
||||
/// shell's request-access flow passes ~185 s because the host PARKS the connection
|
||||
/// until the operator clicks Approve.
|
||||
@@ -135,13 +145,20 @@ mod session_main {
|
||||
)
|
||||
}
|
||||
|
||||
/// One session's pump parameters from the Settings store — shared by `--connect`
|
||||
/// One session's pump parameters from the EFFECTIVE settings — shared by `--connect`
|
||||
/// and every `--browse` launch. Explicit settings, `0` fields resolved to the
|
||||
/// window's display (the GTK client reads the monitor under its window — same
|
||||
/// contract).
|
||||
///
|
||||
/// `settings` is what [`trust::effective_settings`] returned, never a raw
|
||||
/// `Settings::load()`: both callers resolve the host's profile first, so the two
|
||||
/// construction sites cannot drift (they historically did — touching one and not the
|
||||
/// other is a Windows-only build break). `profile` is that profile's name, for the
|
||||
/// stats overlay's first line.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn session_params(
|
||||
settings: &trust::Settings,
|
||||
profile: Option<String>,
|
||||
addr: String,
|
||||
port: u16,
|
||||
pin: [u8; 32],
|
||||
@@ -246,6 +263,7 @@ mod session_main {
|
||||
identity,
|
||||
connect_timeout: connect_timeout(),
|
||||
force_software,
|
||||
profile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,14 +449,16 @@ mod session_main {
|
||||
}
|
||||
let Some(target) = arg_value("--connect") else {
|
||||
eprintln!(
|
||||
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen]\n\
|
||||
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--profile REF] [--fullscreen]\n\
|
||||
\x20 punktfunk-session --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\
|
||||
\x20 punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]\n\
|
||||
\n\
|
||||
Streams from a paired punktfunk host in a Vulkan window. --browse opens the\n\
|
||||
gamepad console instead: bare --browse is the host list (discovery, PIN\n\
|
||||
pairing, settings, wake-on-LAN); with a target it opens that host's game\n\
|
||||
library. --connect never dials a host it has no pinned fingerprint for —\n\
|
||||
library. --profile picks a settings profile by id or name for this session\n\
|
||||
only (\"\" = the global defaults); without it the host's own profile applies.\n\
|
||||
--connect never dials a host it has no pinned fingerprint for —\n\
|
||||
enrol with --pair (no display needed), in the console, or from the desktop\n\
|
||||
client."
|
||||
);
|
||||
@@ -453,7 +473,13 @@ mod session_main {
|
||||
return EXIT_CONNECT_FAILED;
|
||||
}
|
||||
};
|
||||
let settings = trust::Settings::load();
|
||||
// Global defaults with this host's settings profile overlaid — the binding on the
|
||||
// host record, or `--profile <id|name>` for a one-off (`--profile ""` forces the
|
||||
// defaults). Resolved through the shared helper, exactly like the console's launches.
|
||||
let (settings, profile) = trust::effective_settings(&addr, port, profile_arg().as_deref());
|
||||
if let Some(p) = &profile {
|
||||
tracing::info!(profile = %p.name, id = %p.id, "streaming with a settings profile");
|
||||
}
|
||||
|
||||
// Trust follows the GTK client's `--connect` rules: a stored (or `--fp`) pin
|
||||
// connects silently; an unknown host is REFUSED — there is no dialog here, and a
|
||||
@@ -523,6 +549,7 @@ mod session_main {
|
||||
pf_presenter::run_session(opts, move |gamepad, native, force_software, vulkan| {
|
||||
session_params(
|
||||
&settings,
|
||||
profile.map(|p| p.name),
|
||||
addr,
|
||||
port,
|
||||
pin,
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
{
|
||||
"$comment": "Cross-language contract for the punktfunk:// grammar (design/client-deep-links.md §2). The Rust (crates/pf-client-core/src/deeplink.rs), Swift (PunktfunkShared/DeepLink.swift) and Kotlin parsers each run every case in their own test suite, so the three cannot drift into three different security postures. `expect` fields absent from a case must parse as absent; `emit` is the canonical URL the parsed link must serialise back to (note that pf:// never round-trips — it is an accepted input alias, never an output). `error` is the stable refusal code, not a message.",
|
||||
"version": 1,
|
||||
"cases": [
|
||||
{
|
||||
"name": "apple-shipped-uuid",
|
||||
"url": "punktfunk://connect/11111111-2222-4333-8444-555555555555",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "11111111-2222-4333-8444-555555555555"
|
||||
},
|
||||
"emit": "punktfunk://connect/11111111-2222-4333-8444-555555555555"
|
||||
},
|
||||
{
|
||||
"name": "apple-shipped-launch",
|
||||
"url": "punktfunk://connect/11111111-2222-4333-8444-555555555555?launch=steam:570",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "11111111-2222-4333-8444-555555555555",
|
||||
"launch": "steam:570"
|
||||
},
|
||||
"emit": "punktfunk://connect/11111111-2222-4333-8444-555555555555?launch=steam:570"
|
||||
},
|
||||
{
|
||||
"name": "bare-reference-without-a-route",
|
||||
"url": "punktfunk://11111111-2222-4333-8444-555555555555",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "11111111-2222-4333-8444-555555555555"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scheme-case-is-ignored",
|
||||
"url": "PunktFunk://CONNECT/Desk",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pf-alias-parses-but-is-never-emitted",
|
||||
"url": "pf://connect/Desk",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk"
|
||||
},
|
||||
"emit": "punktfunk://connect/Desk"
|
||||
},
|
||||
{
|
||||
"name": "trailing-slash-tolerated",
|
||||
"url": "punktfunk://connect/Desk/",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "address-reference-with-port",
|
||||
"url": "punktfunk://connect/192.168.1.50:9777",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "192.168.1.50:9777"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "percent-encoded-host-name",
|
||||
"url": "punktfunk://connect/Wohnzimmer%20PC",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Wohnzimmer PC"
|
||||
},
|
||||
"emit": "punktfunk://connect/Wohnzimmer%20PC"
|
||||
},
|
||||
{
|
||||
"name": "self-emitted-full-form",
|
||||
"url": "punktfunk://connect/11111111-2222-4333-8444-555555555555?fp=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc&host=192.168.1.50:7777&launch=steam:570&profile=aaaaaaaaaaaa",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "11111111-2222-4333-8444-555555555555",
|
||||
"fp": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"host_addr": "192.168.1.50",
|
||||
"host_port": 7777,
|
||||
"launch": "steam:570",
|
||||
"profile": "aaaaaaaaaaaa"
|
||||
},
|
||||
"emit": "punktfunk://connect/11111111-2222-4333-8444-555555555555?fp=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc&host=192.168.1.50:7777&launch=steam:570&profile=aaaaaaaaaaaa"
|
||||
},
|
||||
{
|
||||
"name": "fingerprint-is-normalised-to-lowercase",
|
||||
"url": "punktfunk://connect/Desk?fp=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk",
|
||||
"fp": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "host-parameter-defaults-to-9777",
|
||||
"url": "punktfunk://connect/Desk?host=192.168.1.50",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk",
|
||||
"host_addr": "192.168.1.50",
|
||||
"host_port": 9777
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "host-parameter-ipv6-bracketed",
|
||||
"url": "punktfunk://connect/Desk?host=%5B::1%5D:7777",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk",
|
||||
"host_addr": "::1",
|
||||
"host_port": 7777
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "wake-route-parses",
|
||||
"url": "punktfunk://wake/Desk",
|
||||
"expect": {
|
||||
"route": "wake",
|
||||
"host_ref": "Desk"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "browse-route-parses",
|
||||
"url": "punktfunk://browse/Desk",
|
||||
"expect": {
|
||||
"route": "browse",
|
||||
"host_ref": "Desk"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "profile-by-name",
|
||||
"url": "punktfunk://connect/Desk?profile=Work",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk",
|
||||
"profile": "Work"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unknown-parameters-are-ignored",
|
||||
"url": "punktfunk://connect/Desk?bogus=1&launch=steam:1",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk",
|
||||
"launch": "steam:1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "empty-parameter-is-absent-not-an-error",
|
||||
"url": "punktfunk://connect/Desk?launch=&profile=Work",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk",
|
||||
"profile": "Work"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "first-occurrence-of-a-parameter-wins",
|
||||
"url": "punktfunk://connect/Desk?fp=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&fp=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk",
|
||||
"fp": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "fragment-is-dropped",
|
||||
"url": "punktfunk://connect/Desk#whatever",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "a-query-hidden-in-the-fragment-is-not-parsed",
|
||||
"url": "punktfunk://connect/Desk#?launch=steam:570",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "non-ascii-label-survives-the-round-trip",
|
||||
"url": "punktfunk://connect/Desk?name=B%C3%BCro%20%C2%B7%20Mac",
|
||||
"expect": {
|
||||
"route": "connect",
|
||||
"host_ref": "Desk",
|
||||
"name": "Büro · Mac"
|
||||
},
|
||||
"emit": "punktfunk://connect/Desk?name=B%C3%BCro%20%C2%B7%20Mac"
|
||||
},
|
||||
{
|
||||
"name": "another-scheme-is-not-ours",
|
||||
"url": "https://example.com/connect/Desk",
|
||||
"error": "not-our-scheme"
|
||||
},
|
||||
{
|
||||
"name": "scheme-without-authority-is-not-ours",
|
||||
"url": "punktfunk:/connect/Desk",
|
||||
"error": "not-our-scheme"
|
||||
},
|
||||
{
|
||||
"name": "pairing-is-never-a-route",
|
||||
"url": "punktfunk://pair/1234",
|
||||
"error": "pair-refused"
|
||||
},
|
||||
{
|
||||
"name": "bare-pair-is-refused-too",
|
||||
"url": "punktfunk://pair",
|
||||
"error": "pair-refused"
|
||||
},
|
||||
{
|
||||
"name": "undefined-route-refuses",
|
||||
"url": "punktfunk://teardown/Desk",
|
||||
"error": "unknown-route"
|
||||
},
|
||||
{
|
||||
"name": "route-without-a-host-reference",
|
||||
"url": "punktfunk://connect/",
|
||||
"error": "missing-host-ref"
|
||||
},
|
||||
{
|
||||
"name": "bare-route-word-is-not-a-host-reference",
|
||||
"url": "punktfunk://connect",
|
||||
"error": "missing-host-ref"
|
||||
},
|
||||
{
|
||||
"name": "truncated-percent-escape",
|
||||
"url": "punktfunk://connect/Desk%2",
|
||||
"error": "bad-escape"
|
||||
},
|
||||
{
|
||||
"name": "non-hex-percent-escape",
|
||||
"url": "punktfunk://connect/De%zzsk",
|
||||
"error": "bad-escape"
|
||||
},
|
||||
{
|
||||
"name": "invalid-utf8-escape",
|
||||
"url": "punktfunk://connect/De%FFsk",
|
||||
"error": "bad-escape"
|
||||
},
|
||||
{
|
||||
"name": "newline-smuggled-into-the-reference",
|
||||
"url": "punktfunk://connect/Desk%0A",
|
||||
"error": "control-char"
|
||||
},
|
||||
{
|
||||
"name": "nul-byte-smuggled-into-the-reference",
|
||||
"url": "punktfunk://connect/Desk%00",
|
||||
"error": "control-char"
|
||||
},
|
||||
{
|
||||
"name": "fingerprint-too-short",
|
||||
"url": "punktfunk://connect/Desk?fp=abc",
|
||||
"error": "bad-fingerprint"
|
||||
},
|
||||
{
|
||||
"name": "fingerprint-not-hex",
|
||||
"url": "punktfunk://connect/Desk?fp=zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
|
||||
"error": "bad-fingerprint"
|
||||
},
|
||||
{
|
||||
"name": "host-parameter-with-an-unparsable-port",
|
||||
"url": "punktfunk://connect/Desk?host=box:notaport",
|
||||
"error": "bad-host-param"
|
||||
},
|
||||
{
|
||||
"name": "launch-id-with-a-space",
|
||||
"url": "punktfunk://connect/Desk?launch=steam%20570",
|
||||
"error": "bad-launch-id"
|
||||
},
|
||||
{
|
||||
"name": "launch-id-with-a-quote",
|
||||
"url": "punktfunk://connect/Desk?launch=%22evil%22",
|
||||
"error": "bad-launch-id"
|
||||
},
|
||||
{
|
||||
"name": "launch-id-with-a-shell-metacharacter",
|
||||
"url": "punktfunk://connect/Desk?launch=steam:570%60id%60",
|
||||
"error": "bad-launch-id"
|
||||
},
|
||||
{
|
||||
"name": "launch-id-over-the-cap",
|
||||
"url": "punktfunk://connect/Desk?launch=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"error": "param-too-long"
|
||||
},
|
||||
{
|
||||
"name": "profile-reference-over-the-cap",
|
||||
"url": "punktfunk://connect/Desk?profile=ppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp",
|
||||
"error": "param-too-long"
|
||||
},
|
||||
{
|
||||
"name": "name-over-the-cap",
|
||||
"url": "punktfunk://connect/Desk?name=nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn",
|
||||
"error": "param-too-long"
|
||||
},
|
||||
{
|
||||
"name": "host-reference-over-the-cap",
|
||||
"url": "punktfunk://connect/hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh",
|
||||
"error": "param-too-long"
|
||||
},
|
||||
{
|
||||
"name": "url-over-the-total-cap",
|
||||
"url": "punktfunk://connect/Desk?name=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"error": "too-long"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -95,3 +95,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
winresource = "0.1"
|
||||
windows-reactor-setup = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63c6bb7fc77a2affe57145be1d8c4f" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::style::*;
|
||||
use super::{AppCtx, Screen, Svc, Target};
|
||||
use crate::discovery::DiscoveredHost;
|
||||
use crate::trust::{self, KnownHost, KnownHosts};
|
||||
use pf_client_core::orchestrate::{WakeOutcome, WakeWait};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -272,9 +273,8 @@ fn connect_spawn(
|
||||
port: target.port,
|
||||
fp_hex: fp_hex.clone(),
|
||||
paired: persist_paired,
|
||||
last_used: None,
|
||||
mac: target.mac.clone(),
|
||||
clipboard_sync: false,
|
||||
..Default::default()
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
@@ -419,15 +419,19 @@ pub(crate) fn request_access(props: &Svc, target: &Target) {
|
||||
/// longer to POST/boot/re-advertise than a connect attempt will sit). On reappearance we dial it
|
||||
/// (re-keying the saved host when it came back on a new IP); on timeout or Cancel we return to
|
||||
/// the host list.
|
||||
///
|
||||
/// The cadence is [`WakeWait`], shared with the GTK shell and ported from Apple's `HostWaker`
|
||||
/// (design/client-architecture-split.md §3) — the comment this function used to carry ("mirrors
|
||||
/// the Apple HostWaker") is now literally true instead of aspirational.
|
||||
fn wake_and_connect(
|
||||
ctx: &Arc<AppCtx>,
|
||||
target: Target,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
set_status: &AsyncSetState<String>,
|
||||
) {
|
||||
// First packet now; the poll loop re-sends every RESEND_SECS (a single one can be missed, and
|
||||
// some NICs only wake on a fresh packet after dropping into a deeper sleep state).
|
||||
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||
// The packets are the wait's business: `WakeWait` asks for one on its first tick and every
|
||||
// 6 s after (a single one can be missed, and some NICs only wake on a fresh packet after
|
||||
// dropping into a deeper sleep state).
|
||||
// A fresh cancel flag per wake, installed where the "Waking…" screen's Cancel button reads it
|
||||
// back (the same shared channel as the request-access flow); the poll loop checks the same `Arc`.
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
@@ -439,12 +443,9 @@ fn wake_and_connect(
|
||||
|
||||
let (ctx, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
std::thread::spawn(move || {
|
||||
// Generous — a cold boot + service start can be a minute-plus; re-send periodically.
|
||||
const TIMEOUT_SECS: u64 = 90;
|
||||
const RESEND_SECS: u64 = 6;
|
||||
let rx = crate::discovery::browse();
|
||||
let mut seen: Vec<DiscoveredHost> = Vec::new();
|
||||
let mut elapsed: u64 = 0;
|
||||
let mut wait = WakeWait::new();
|
||||
loop {
|
||||
// Cancel already returned the UI to the host list — stop re-sending and tear down.
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
@@ -466,42 +467,46 @@ fn wake_and_connect(
|
||||
_ => h.addr == target.addr && h.port == target.port,
|
||||
})
|
||||
.map(|h| (h.addr.clone(), h.port));
|
||||
if let Some((addr, port)) = resolved {
|
||||
let mut target = target.clone();
|
||||
// Came back on a new IP (DHCP): dial the fresh address and re-key the saved host so
|
||||
// the pin stays reachable next time (keyed by fingerprint; addr/port overwritten,
|
||||
// `paired`/`mac` preserved by `upsert`).
|
||||
if addr != target.addr || port != target.port {
|
||||
target.addr = addr;
|
||||
target.port = port;
|
||||
if let Some(fp) = target.fp_hex.clone() {
|
||||
let mut k = KnownHosts::load();
|
||||
k.upsert(KnownHost {
|
||||
name: target.name.clone(),
|
||||
addr: target.addr.clone(),
|
||||
port: target.port,
|
||||
fp_hex: fp,
|
||||
paired: false,
|
||||
last_used: None,
|
||||
mac: target.mac.clone(),
|
||||
clipboard_sync: false,
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
}
|
||||
initiate(&ctx, target, &ss, &st);
|
||||
return;
|
||||
}
|
||||
if elapsed >= TIMEOUT_SECS {
|
||||
st.call("The host didn't come online.".to_string());
|
||||
ss.call(Screen::Hosts);
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
elapsed += 1;
|
||||
if elapsed % RESEND_SECS == 0 {
|
||||
|
||||
let tick = wait.tick(resolved.is_some());
|
||||
if tick.send_packet {
|
||||
crate::wol::wake(&target.mac, target.addr.parse().ok());
|
||||
}
|
||||
match tick.outcome {
|
||||
Some(WakeOutcome::Online) => {
|
||||
let mut target = target.clone();
|
||||
// Came back on a new IP (DHCP): dial the fresh address and re-key the saved
|
||||
// host so the pin stays reachable next time (keyed by fingerprint;
|
||||
// addr/port overwritten, `paired`/`mac` preserved by `upsert`).
|
||||
if let Some((addr, port)) =
|
||||
resolved.filter(|(a, p)| *a != target.addr || *p != target.port)
|
||||
{
|
||||
target.addr = addr;
|
||||
target.port = port;
|
||||
if let Some(fp) = target.fp_hex.clone() {
|
||||
let mut k = KnownHosts::load();
|
||||
k.upsert(KnownHost {
|
||||
name: target.name.clone(),
|
||||
addr: target.addr.clone(),
|
||||
port: target.port,
|
||||
fp_hex: fp,
|
||||
mac: target.mac.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
let _ = k.save();
|
||||
}
|
||||
}
|
||||
initiate(&ctx, target, &ss, &st);
|
||||
return;
|
||||
}
|
||||
Some(WakeOutcome::TimedOut) => {
|
||||
st.call("The host didn't come online.".to_string());
|
||||
ss.call(Screen::Hosts);
|
||||
return;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -243,6 +243,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
// Which Settings section the NavigationView shows (persists across visits this run).
|
||||
// Opens on General — the first sidebar item, matching the Apple client's landing category.
|
||||
let (settings_nav, set_settings_nav) = cx.use_async_state("general".to_string());
|
||||
// Which LAYER the settings screen edits: "" = the global defaults, else a profile id
|
||||
// (design/client-settings-profiles.md §5.1). Root state for the same reason as the section
|
||||
// above — the ComboBox's change handler is wired in the reactor backend.
|
||||
let (settings_scope, set_settings_scope) = cx.use_async_state(String::new());
|
||||
// The profile a Delete… click is asking about; `Some` renders the confirmation. Root state
|
||||
// because this page stays hook-free (its handlers are wired in the reactor backend).
|
||||
let (settings_delete, set_settings_delete) = cx.use_async_state(Option::<String>::None);
|
||||
// Connected-controller count, mirrored from the gamepad service by a poll thread
|
||||
// (thread-driven state must be root state — see the module docs). Drives the hosts
|
||||
// page's "Open console UI" hint; the compare in `call` makes the steady state free.
|
||||
@@ -489,6 +496,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
&set_screen,
|
||||
&settings_nav,
|
||||
&set_settings_nav,
|
||||
&settings_scope,
|
||||
&set_settings_scope,
|
||||
&settings_delete,
|
||||
&set_settings_delete,
|
||||
nav_progress,
|
||||
),
|
||||
Screen::Licenses => licenses::licenses_page(&set_screen),
|
||||
|
||||
@@ -57,9 +57,8 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
|
||||
port: target3.port,
|
||||
fp_hex: trust::hex(&fp),
|
||||
paired: true,
|
||||
last_used: None,
|
||||
mac: target3.mac.clone(),
|
||||
clipboard_sync: false,
|
||||
..Default::default()
|
||||
});
|
||||
let _ = k.save();
|
||||
connect(&ctx3, &target3, Some(fp), &ss, &st);
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
|
||||
use super::style::*;
|
||||
use super::{AppCtx, Screen};
|
||||
use crate::trust::Settings;
|
||||
use crate::trust::{KnownHosts, Settings};
|
||||
use pf_client_core::profiles::{ProfilesFile, StreamProfile};
|
||||
use pf_client_core::trust::StatsVerbosity;
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
use std::sync::Arc;
|
||||
@@ -110,22 +111,121 @@ const COMPOSITORS: &[(&str, &str)] = &[
|
||||
/// A `ComboBox` bound to one settings field: shows `names`, starts at `current`, and runs
|
||||
/// `apply(settings, picked_index)` under the settings lock, then saves. The index handed to
|
||||
/// `apply` is already clamped to `names`.
|
||||
/// The sentinel the scope ComboBox's last entry carries — "New profile…" is an action, not a
|
||||
/// layer, and a real id can never collide with it (ids are 12 hex chars).
|
||||
const NEW_PROFILE: &str = "\u{1}new";
|
||||
|
||||
/// Rename / duplicate / delete for the profile in scope. Rename is a text box rather than a
|
||||
/// modal because this toolkit's ContentDialog is text-only (the same constraint that put the
|
||||
/// host editor in a tile); it commits on change, which is how every other control here works.
|
||||
fn profile_actions(
|
||||
profile: &StreamProfile,
|
||||
set_scope: &AsyncSetState<String>,
|
||||
set_delete: &AsyncSetState<Option<String>>,
|
||||
) -> Element {
|
||||
let id = profile.id.clone();
|
||||
let name_box = {
|
||||
let id = id.clone();
|
||||
text_box(&profile.name)
|
||||
.placeholder_text("Profile name")
|
||||
.on_text_changed(move |t: String| {
|
||||
let name = t.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut catalog = ProfilesFile::load();
|
||||
// Names are unique case-insensitively — menus keyed by name are ambiguous
|
||||
// otherwise. A collision simply doesn't commit; the box keeps what was typed.
|
||||
if catalog.name_taken(&name, Some(&id)) {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) {
|
||||
p.name = name;
|
||||
let _ = catalog.save();
|
||||
}
|
||||
})
|
||||
};
|
||||
let duplicate = {
|
||||
let (id, set_scope) = (id.clone(), set_scope.clone());
|
||||
button("Duplicate").on_click(move || {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
let Some(source) = catalog.find_by_id(&id).cloned() else {
|
||||
return;
|
||||
};
|
||||
let name = (2..)
|
||||
.map(|n| format!("{} {n}", source.name))
|
||||
.find(|n| !catalog.name_taken(n, None))
|
||||
.unwrap_or_else(|| source.name.clone());
|
||||
let mut copy = StreamProfile::new(name);
|
||||
copy.overrides = source.overrides.clone();
|
||||
copy.accent = source.accent.clone();
|
||||
let new_id = copy.id.clone();
|
||||
catalog.profiles.push(copy);
|
||||
if catalog.save().is_ok() {
|
||||
set_scope.call(new_id);
|
||||
}
|
||||
})
|
||||
};
|
||||
let delete = {
|
||||
let (id, set_delete) = (id.clone(), set_delete.clone());
|
||||
button("Delete\u{2026}").on_click(move || set_delete.call(Some(id.clone())))
|
||||
};
|
||||
described(
|
||||
vstack((name_box, hstack((duplicate, delete)).spacing(8.0))).spacing(8.0),
|
||||
"Renaming applies as you type. Deleting leaves hosts that used it on Default settings.",
|
||||
)
|
||||
}
|
||||
|
||||
/// Persist one control's edit into the layer being edited.
|
||||
///
|
||||
/// This shell commits PER CONTROL (unlike the GTK one, which writes when its dialog closes),
|
||||
/// so it can't hand the profile a list of touched fields. It hands over the effective settings
|
||||
/// before and after instead, and [`SettingsOverlay::absorb`] records the field that moved —
|
||||
/// the comparison is against what the control was SHOWING, so picking a value that happens to
|
||||
/// equal the global still records an override (the pin the design asks for).
|
||||
fn commit(ctx: &Arc<AppCtx>, scope: &str, edit: impl FnOnce(&mut Settings)) {
|
||||
if scope.is_empty() {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
edit(&mut s);
|
||||
s.save();
|
||||
return;
|
||||
}
|
||||
let mut catalog = ProfilesFile::load();
|
||||
let base = ctx.settings.lock().unwrap().clone();
|
||||
let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == scope) else {
|
||||
return; // deleted from under us; the next render falls back to the defaults scope
|
||||
};
|
||||
let before = p.overrides.apply(&base);
|
||||
let mut after = before.clone();
|
||||
edit(&mut after);
|
||||
p.overrides.absorb(&before, &after);
|
||||
if let Err(e) = catalog.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving the profile catalog");
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer the settings screen is editing, resolved for display: `None` = the defaults.
|
||||
fn active_profile(scope: &str) -> Option<StreamProfile> {
|
||||
(!scope.is_empty())
|
||||
.then(|| ProfilesFile::load().find_by_id(scope).cloned())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn setting_combo(
|
||||
ctx: &Arc<AppCtx>,
|
||||
scope: &str,
|
||||
header: &str,
|
||||
names: Vec<String>,
|
||||
current: usize,
|
||||
apply: impl Fn(&mut Settings, usize) + 'static,
|
||||
) -> ComboBox {
|
||||
let ctx = ctx.clone();
|
||||
let (ctx, scope) = (ctx.clone(), scope.to_string());
|
||||
let max = names.len().saturating_sub(1);
|
||||
ComboBox::new(names)
|
||||
.header(header)
|
||||
.selected_index(current as i32)
|
||||
.on_selection_changed(move |i: i32| {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
apply(&mut s, (i.max(0) as usize).min(max));
|
||||
s.save();
|
||||
commit(&ctx, &scope, |s| apply(s, (i.max(0) as usize).min(max)));
|
||||
})
|
||||
}
|
||||
|
||||
@@ -139,19 +239,18 @@ fn presets<V>(table: &[(V, &str)], is_current: impl Fn(&V) -> bool) -> (Vec<Stri
|
||||
/// A `ToggleSwitch` bound to one boolean settings field.
|
||||
fn setting_toggle(
|
||||
ctx: &Arc<AppCtx>,
|
||||
scope: &str,
|
||||
header: &str,
|
||||
on: bool,
|
||||
apply: impl Fn(&mut Settings, bool) + 'static,
|
||||
) -> ToggleSwitch {
|
||||
let ctx = ctx.clone();
|
||||
let (ctx, scope) = (ctx.clone(), scope.to_string());
|
||||
ToggleSwitch::new(on)
|
||||
.header(header)
|
||||
.on_content("On")
|
||||
.off_content("Off")
|
||||
.on_toggled(move |v: bool| {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
apply(&mut s, v);
|
||||
s.save();
|
||||
commit(&ctx, &scope, |s| apply(s, v));
|
||||
})
|
||||
}
|
||||
|
||||
@@ -220,14 +319,35 @@ fn group(header: Option<&str>, fields: Vec<Element>, footer: Option<&str>) -> Ve
|
||||
/// state (this page stays hook-free): `on_selection_changed` is wired in the reactor backend, so
|
||||
/// only a root `AsyncSetState` reliably re-renders the new section in. `progress` is the
|
||||
/// section-switch entrance tween (0 → 1), mapped onto the content column's opacity + offset.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn settings_page(
|
||||
ctx: &Arc<AppCtx>,
|
||||
set_screen: &AsyncSetState<Screen>,
|
||||
section: &str,
|
||||
set_section: &AsyncSetState<String>,
|
||||
scope_id: &str,
|
||||
set_scope: &AsyncSetState<String>,
|
||||
delete_pending: &Option<String>,
|
||||
set_delete: &AsyncSetState<Option<String>>,
|
||||
progress: f64,
|
||||
) -> Element {
|
||||
let s = ctx.settings.lock().unwrap().clone();
|
||||
// The layer being edited. A scope pointing at a deleted profile degrades to the defaults,
|
||||
// the same rule a dangling host binding follows.
|
||||
let active = active_profile(scope_id);
|
||||
let scope: &str = match &active {
|
||||
Some(p) => &p.id,
|
||||
None => "",
|
||||
};
|
||||
let profile_mode = active.is_some();
|
||||
// Every control shows the EFFECTIVE value: the global underneath with this profile's
|
||||
// overrides on top, so a row the profile doesn't override reads as the live global.
|
||||
let s = {
|
||||
let base = ctx.settings.lock().unwrap().clone();
|
||||
match &active {
|
||||
Some(p) => p.overrides.apply(&base),
|
||||
None => base,
|
||||
}
|
||||
};
|
||||
|
||||
// --- Display ---------------------------------------------------------------------------
|
||||
// The D1 tri-state: Native, Match window (a virtual index 1, stored as the
|
||||
@@ -253,7 +373,7 @@ pub(crate) fn settings_page(
|
||||
};
|
||||
(names, i)
|
||||
};
|
||||
let res_combo = setting_combo(ctx, "Resolution", res_names, res_i, |s, i| {
|
||||
let res_combo = setting_combo(ctx, scope, "Resolution", res_names, res_i, |s, i| {
|
||||
s.match_window = i == 1;
|
||||
(s.width, s.height) = if i <= 1 { (0, 0) } else { RESOLUTIONS[i - 1] };
|
||||
});
|
||||
@@ -271,7 +391,7 @@ pub(crate) fn settings_page(
|
||||
let i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
|
||||
(names, i)
|
||||
};
|
||||
let hz_combo = setting_combo(ctx, "Refresh rate", hz_names, hz_i, |s, i| {
|
||||
let hz_combo = setting_combo(ctx, scope, "Refresh rate", hz_names, hz_i, |s, i| {
|
||||
s.refresh_hz = REFRESH[i];
|
||||
});
|
||||
let (scale_names, scale_i) = {
|
||||
@@ -285,18 +405,20 @@ pub(crate) fn settings_page(
|
||||
.unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap());
|
||||
(names, i)
|
||||
};
|
||||
let scale_combo = setting_combo(ctx, "Render scale", scale_names, scale_i, |s, i| {
|
||||
let scale_combo = setting_combo(ctx, scope, "Render scale", scale_names, scale_i, |s, i| {
|
||||
s.render_scale = RENDER_SCALES[i];
|
||||
});
|
||||
let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor);
|
||||
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
|
||||
let comp_combo = setting_combo(ctx, scope, "Host compositor", comp_names, comp_i, |s, i| {
|
||||
s.compositor = COMPOSITORS[i].0.to_string();
|
||||
});
|
||||
let auto_wake_toggle = setting_toggle(ctx, "Auto-wake on connect", s.auto_wake, |s, on| {
|
||||
s.auto_wake = on
|
||||
});
|
||||
let auto_wake_toggle =
|
||||
setting_toggle(ctx, scope, "Auto-wake on connect", s.auto_wake, |s, on| {
|
||||
s.auto_wake = on
|
||||
});
|
||||
let fullscreen_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
"Start streams fullscreen",
|
||||
s.fullscreen_on_stream,
|
||||
|s, on| s.fullscreen_on_stream = on,
|
||||
@@ -304,7 +426,7 @@ pub(crate) fn settings_page(
|
||||
|
||||
// --- Video -----------------------------------------------------------------------------
|
||||
let (dec_names, dec_i) = presets(DECODERS, |v| *v == s.decoder);
|
||||
let decoder_combo = setting_combo(ctx, "Video decoder", dec_names, dec_i, |s, i| {
|
||||
let decoder_combo = setting_combo(ctx, scope, "Video decoder", dec_names, dec_i, |s, i| {
|
||||
s.decoder = DECODERS[i].0.to_string();
|
||||
});
|
||||
// GPU picker, only on a multi-GPU box (hybrid laptop, eGPU): which adapter decodes + presents.
|
||||
@@ -318,7 +440,7 @@ pub(crate) fn settings_page(
|
||||
.position(|n| *n == s.adapter)
|
||||
.map_or(0, |i| i + 1);
|
||||
let gpus = gpus.clone();
|
||||
setting_combo(ctx, "GPU", names, current, move |s, i| {
|
||||
setting_combo(ctx, scope, "GPU", names, current, move |s, i| {
|
||||
s.adapter = if i == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
@@ -327,7 +449,7 @@ pub(crate) fn settings_page(
|
||||
})
|
||||
});
|
||||
let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec);
|
||||
let codec_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| {
|
||||
let codec_combo = setting_combo(ctx, scope, "Video codec", codec_names, codec_i, |s, i| {
|
||||
s.codec = CODECS[i].0.to_string();
|
||||
});
|
||||
// Free-form Mb/s (0 = host default) instead of presets, so a speed-test recommendation
|
||||
@@ -343,9 +465,13 @@ pub(crate) fn settings_page(
|
||||
s.save();
|
||||
})
|
||||
};
|
||||
let hdr_toggle = setting_toggle(ctx, "HDR (10-bit, BT.2020 PQ)", s.hdr_enabled, |s, on| {
|
||||
s.hdr_enabled = on
|
||||
});
|
||||
let hdr_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
"HDR (10-bit, BT.2020 PQ)",
|
||||
s.hdr_enabled,
|
||||
|s, on| s.hdr_enabled = on,
|
||||
);
|
||||
|
||||
// --- Input -----------------------------------------------------------------------------
|
||||
// Controller forwarding: Automatic forwards EVERY real controller, each as its own pad;
|
||||
@@ -394,23 +520,27 @@ pub(crate) fn settings_page(
|
||||
let (pad_names, pad_i) = presets(GAMEPADS, |v| {
|
||||
GamepadPref::from_name(v) == GamepadPref::from_name(&s.gamepad)
|
||||
});
|
||||
let pad_combo = setting_combo(ctx, "Gamepad type", pad_names, pad_i, |s, i| {
|
||||
let pad_combo = setting_combo(ctx, scope, "Gamepad type", pad_names, pad_i, |s, i| {
|
||||
s.gamepad = GAMEPADS[i].0.to_string();
|
||||
});
|
||||
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
|
||||
let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
|
||||
let touch_combo = setting_combo(ctx, scope, "Touch input", touch_names, touch_i, |s, i| {
|
||||
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
||||
});
|
||||
let (mouse_names, mouse_i) = presets(MOUSE_MODES, |v| *v == s.mouse_mode);
|
||||
let mouse_combo = setting_combo(ctx, "Mouse input", mouse_names, mouse_i, |s, i| {
|
||||
let mouse_combo = setting_combo(ctx, scope, "Mouse input", mouse_names, mouse_i, |s, i| {
|
||||
s.mouse_mode = MOUSE_MODES[i].0.to_string();
|
||||
});
|
||||
let invert_scroll_toggle =
|
||||
setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| {
|
||||
s.invert_scroll = on
|
||||
});
|
||||
let invert_scroll_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
"Invert scroll direction",
|
||||
s.invert_scroll,
|
||||
|s, on| s.invert_scroll = on,
|
||||
);
|
||||
let shortcuts_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
"Capture system shortcuts (Alt+Tab, Win, \u{2026})",
|
||||
s.inhibit_shortcuts,
|
||||
|s, on| s.inhibit_shortcuts = on,
|
||||
@@ -418,20 +548,28 @@ pub(crate) fn settings_page(
|
||||
|
||||
// --- Audio -----------------------------------------------------------------------------
|
||||
let (ac_names, ac_i) = presets(AUDIO_CHANNELS, |v| *v == s.audio_channels);
|
||||
let channels_combo = setting_combo(ctx, "Audio channels", ac_names, ac_i, |s, i| {
|
||||
let channels_combo = setting_combo(ctx, scope, "Audio channels", ac_names, ac_i, |s, i| {
|
||||
s.audio_channels = AUDIO_CHANNELS[i].0;
|
||||
});
|
||||
let mic_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
"Stream microphone to the host",
|
||||
s.mic_enabled,
|
||||
|s, on| s.mic_enabled = on,
|
||||
);
|
||||
|
||||
let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
|
||||
let hud_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
|
||||
s.set_stats_verbosity(STATS_TIERS[i].0);
|
||||
});
|
||||
let hud_combo = setting_combo(
|
||||
ctx,
|
||||
scope,
|
||||
"Stats overlay (HUD)",
|
||||
hud_names,
|
||||
hud_i,
|
||||
|s, i| {
|
||||
s.set_stats_verbosity(STATS_TIERS[i].0);
|
||||
},
|
||||
);
|
||||
|
||||
let licenses_button = {
|
||||
let ss = set_screen.clone();
|
||||
@@ -439,6 +577,7 @@ pub(crate) fn settings_page(
|
||||
};
|
||||
let library_toggle = setting_toggle(
|
||||
ctx,
|
||||
scope,
|
||||
"Show game library (experimental)",
|
||||
s.library_enabled,
|
||||
|s, on| s.library_enabled = on,
|
||||
@@ -506,9 +645,12 @@ pub(crate) fn settings_page(
|
||||
],
|
||||
None,
|
||||
));
|
||||
// Decoder and GPU are facts about THIS device's hardware — never per profile.
|
||||
out.extend(group(
|
||||
Some("Decoding"),
|
||||
{
|
||||
if profile_mode {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut fields = vec![described(
|
||||
decoder_combo,
|
||||
"Automatic picks the hardware path this GPU does best \u{2014} Direct3D \
|
||||
@@ -578,21 +720,28 @@ pub(crate) fn settings_page(
|
||||
"Controllers",
|
||||
group(
|
||||
None,
|
||||
vec![
|
||||
[
|
||||
// NOT Apple's wording: Apple forwards ONE pad as player 1, this client
|
||||
// forwards every controller as its own player. Same picker, different rule.
|
||||
described(
|
||||
// Which physical pad this device forwards is a device fact (tier G), so it
|
||||
// renders only in the defaults scope; the EMULATED type below is profileable.
|
||||
(!profile_mode).then(|| {
|
||||
described(
|
||||
forward_combo,
|
||||
"Every connected controller is forwarded, each as its own player. Pick \
|
||||
one to force single-player \u{2014} only it reaches the host.",
|
||||
),
|
||||
described(
|
||||
)
|
||||
}),
|
||||
Some(described(
|
||||
pad_combo,
|
||||
"The virtual pad created on the host. Automatic matches your controller \
|
||||
\u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \
|
||||
motion.",
|
||||
),
|
||||
],
|
||||
)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
Some("Applies from the next session."),
|
||||
),
|
||||
),
|
||||
@@ -626,19 +775,23 @@ pub(crate) fn settings_page(
|
||||
_ => {
|
||||
let mut out = group(
|
||||
Some("Session"),
|
||||
vec![
|
||||
described(
|
||||
fullscreen_toggle,
|
||||
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \
|
||||
vec![described(
|
||||
fullscreen_toggle,
|
||||
"Go fullscreen when a session starts; F11 or Alt+Enter switches back \
|
||||
live.",
|
||||
),
|
||||
)]
|
||||
.into_iter()
|
||||
// Auto-wake is about this host and this network, not about "Game vs Work" —
|
||||
// it stays global in v1 (design §3, tier H/G).
|
||||
.chain((!profile_mode).then(|| {
|
||||
described(
|
||||
auto_wake_toggle,
|
||||
"Connecting to a saved host that\u{2019}s offline sends Wake-on-LAN and \
|
||||
waits for it to boot. Turn off if hosts behind a VPN look offline when \
|
||||
they aren\u{2019}t.",
|
||||
),
|
||||
],
|
||||
)
|
||||
}))
|
||||
.collect(),
|
||||
None,
|
||||
);
|
||||
out.extend(group(
|
||||
@@ -651,13 +804,18 @@ pub(crate) fn settings_page(
|
||||
)],
|
||||
None,
|
||||
));
|
||||
// The library browser is an app-level toggle for this device, not a per-profile one.
|
||||
out.extend(group(
|
||||
Some("Library"),
|
||||
vec![described(
|
||||
if profile_mode {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![described(
|
||||
library_toggle,
|
||||
"Adds \u{201C}Browse library\u{2026}\u{201D} to paired hosts \u{2014} list \
|
||||
their Steam and custom games and launch one directly. No extra host setup.",
|
||||
)],
|
||||
)]
|
||||
},
|
||||
None,
|
||||
));
|
||||
("General", out)
|
||||
@@ -698,6 +856,55 @@ pub(crate) fn settings_page(
|
||||
// left inset belongs to WinUI's own template (a string prop is all we can set), so it
|
||||
// sat noticeably right of the cards under it. In the content column it shares the cards'
|
||||
// left edge by construction.
|
||||
// The scope switcher sits above the section title, so it reads as "which layer all of
|
||||
// this belongs to" rather than as one section's setting.
|
||||
let catalog = ProfilesFile::load();
|
||||
let mut scope_names = vec!["Default settings".to_string()];
|
||||
let mut scope_ids: Vec<String> = vec![String::new()];
|
||||
for p in &catalog.profiles {
|
||||
scope_names.push(p.name.clone());
|
||||
scope_ids.push(p.id.clone());
|
||||
}
|
||||
scope_names.push("New profile\u{2026}".to_string());
|
||||
scope_ids.push(NEW_PROFILE.to_string());
|
||||
let scope_i = scope_ids.iter().position(|i| i == scope).unwrap_or(0);
|
||||
let switcher = described(
|
||||
ComboBox::new(scope_names)
|
||||
.header("Editing")
|
||||
.selected_index(scope_i as i32)
|
||||
.on_selection_changed({
|
||||
let (set_scope, ids) = (set_scope.clone(), scope_ids.clone());
|
||||
move |i: i32| {
|
||||
let Some(id) = ids.get(i.max(0) as usize) else {
|
||||
return;
|
||||
};
|
||||
if id == NEW_PROFILE {
|
||||
// Creation needs no prompt here: WinUI's ContentDialog is text-only in
|
||||
// this toolkit, so a new profile takes an auto-numbered name and is
|
||||
// renamed from the row below — one fewer modal, same end state.
|
||||
let mut catalog = ProfilesFile::load();
|
||||
let name = (1..)
|
||||
.map(|n| format!("Profile {n}"))
|
||||
.find(|n| !catalog.name_taken(n, None))
|
||||
.unwrap_or_else(|| "Profile".to_string());
|
||||
let profile = StreamProfile::new(name);
|
||||
let new_id = profile.id.clone();
|
||||
catalog.profiles.push(profile);
|
||||
if catalog.save().is_ok() {
|
||||
set_scope.call(new_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
set_scope.call(id.clone());
|
||||
}
|
||||
}),
|
||||
"A profile overrides only what you change while it is selected; everything else \
|
||||
follows Default settings.",
|
||||
);
|
||||
let mut header_rows = vec![switcher];
|
||||
if let Some(p) = &active {
|
||||
header_rows.push(profile_actions(p, set_scope, set_delete));
|
||||
}
|
||||
let titled: Vec<Element> = std::iter::once(
|
||||
text_block(title)
|
||||
.font_size(28.0)
|
||||
@@ -706,6 +913,7 @@ pub(crate) fn settings_page(
|
||||
.margin(edges(0.0, 0.0, 0.0, 6.0))
|
||||
.into(),
|
||||
)
|
||||
.chain(group(None, header_rows, None))
|
||||
.chain(groups)
|
||||
.collect();
|
||||
// The keyed column MUST sit inside a panel's child list, not directly under the
|
||||
@@ -717,12 +925,74 @@ pub(crate) fn settings_page(
|
||||
// combos render blank until touched. A panel (vstack) takes the keyed path, so the key
|
||||
// remounts the whole column and every prop is applied fresh.
|
||||
let content = scroll_view(
|
||||
vstack(vec![vstack(titled).spacing(10.0).with_key(section).into()])
|
||||
.margin(edges(24.0, 20.0, 28.0, 40.0)),
|
||||
// ⚠️ Keyed on (scope, section), not section alone: switching SCOPE re-renders the same
|
||||
// section's controls with different values, and an in-place diff re-sets each reused
|
||||
// ComboBox's items (clearing WinUI's selection) while skipping `selected_index`
|
||||
// wherever the two scopes' values compare equal — the combo then renders blank. A
|
||||
// fresh mount applies every prop. Same reason the section key exists.
|
||||
vstack(vec![vstack(titled)
|
||||
.spacing(10.0)
|
||||
.with_key(format!("{scope}/{section}"))
|
||||
.into()])
|
||||
.margin(edges(24.0, 20.0, 28.0, 40.0)),
|
||||
)
|
||||
.opacity(progress)
|
||||
.margin(edges(0.0, (1.0 - progress) * 22.0, 0.0, 0.0));
|
||||
NavigationView::new(items, content)
|
||||
// The delete confirmation, when armed. Declarative, like every other dialog in this shell:
|
||||
// it is an element in the tree with `is_open`, not a call.
|
||||
let confirm: Option<Element> = delete_pending.as_ref().and_then(|id| {
|
||||
let p = ProfilesFile::load().find_by_id(id).cloned()?;
|
||||
// The warning counts what actually breaks: hosts that fall back to the defaults, and
|
||||
// pinned cards that disappear (design §6).
|
||||
let known = KnownHosts::load();
|
||||
let bound = known
|
||||
.hosts
|
||||
.iter()
|
||||
.filter(|h| h.profile_id.as_deref() == Some(p.id.as_str()))
|
||||
.count();
|
||||
let pinned = known
|
||||
.hosts
|
||||
.iter()
|
||||
.filter(|h| h.pinned_profiles.iter().any(|x| x == &p.id))
|
||||
.count();
|
||||
let mut body = format!("\u{201c}{}\u{201d} will be removed.", p.name);
|
||||
if bound > 0 {
|
||||
body.push_str(&format!(
|
||||
" {bound} host{} will fall back to Default settings.",
|
||||
if bound == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
if pinned > 0 {
|
||||
body.push_str(&format!(
|
||||
" {pinned} pinned card{} will disappear.",
|
||||
if pinned == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
let (id, set_scope, set_delete) = (p.id.clone(), set_scope.clone(), set_delete.clone());
|
||||
Some(
|
||||
ContentDialog::new("Delete profile?")
|
||||
.content(body)
|
||||
.primary_button_text("Delete")
|
||||
.close_button_text("Cancel")
|
||||
.is_open(true)
|
||||
.on_closed(move |r: ContentDialogResult| {
|
||||
set_delete.call(None);
|
||||
if r != ContentDialogResult::Primary {
|
||||
return;
|
||||
}
|
||||
let mut catalog = ProfilesFile::load();
|
||||
catalog.profiles.retain(|p| p.id != id);
|
||||
// Bindings and pins are left dangling on purpose: they resolve as "no
|
||||
// profile" everywhere, and rewriting every host record here would be a
|
||||
// second, racier source of truth.
|
||||
if catalog.save().is_ok() {
|
||||
set_scope.call(String::new());
|
||||
}
|
||||
})
|
||||
.into(),
|
||||
)
|
||||
});
|
||||
let nav = NavigationView::new(items, content)
|
||||
.pane_title("Settings")
|
||||
.selected_tag(section)
|
||||
.on_selection_changed({
|
||||
@@ -734,6 +1004,9 @@ pub(crate) fn settings_page(
|
||||
.on_back_requested({
|
||||
let ss = set_screen.clone();
|
||||
move || ss.call(Screen::Hosts)
|
||||
})
|
||||
.into()
|
||||
});
|
||||
match confirm {
|
||||
Some(dialog) => vstack(vec![nav.into(), dialog]).into(),
|
||||
None => nav.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,17 @@
|
||||
// console behind the couch UI looks like a crash.
|
||||
#![cfg_attr(windows, windows_subsystem = "windows")]
|
||||
|
||||
// The shared client log sink (std-only): a couch launch has no console, so the session's
|
||||
// stderr would otherwise evaporate — same reasoning as the shell's tee. This shim only
|
||||
// initializes + forwards; the module's subscriber-side surface stays unused here.
|
||||
#[cfg(windows)]
|
||||
#[path = "../logfile.rs"]
|
||||
#[allow(dead_code)]
|
||||
mod logfile;
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
logfile::init();
|
||||
// The session binary ships beside us in the package; fall back to PATH for a dev run.
|
||||
let session = std::env::current_exe()
|
||||
.ok()
|
||||
@@ -27,7 +36,14 @@ fn main() {
|
||||
if !std::env::args().any(|a| a == "--windowed") {
|
||||
cmd.arg("--fullscreen");
|
||||
}
|
||||
match cmd.status() {
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
let run = cmd.spawn().and_then(|mut child| {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
logfile::forward_child_stderr(stderr);
|
||||
}
|
||||
child.wait()
|
||||
});
|
||||
match run {
|
||||
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
|
||||
Err(_) => std::process::exit(1),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Persistent client log file: `%LOCALAPPDATA%\punktfunk\logs\client.log`.
|
||||
//!
|
||||
//! The shell is a `windows_subsystem` binary and spawns `punktfunk-session` with
|
||||
//! `CREATE_NO_WINDOW` — a normal GUI/MSIX launch has NO console, so before this module every
|
||||
//! log line (the shell's and, worse, the session's whole receive/decode/present forensic
|
||||
//! trail) evaporated exactly when a user hit a problem worth reporting. The 2026-07 PyroWave
|
||||
//! latency-sawtooth field report had to be triaged from host logs alone because the client
|
||||
//! side had nowhere to land.
|
||||
//!
|
||||
//! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over
|
||||
//! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is
|
||||
//! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
/// Rotate at the next start once the file exceeds this (the host's cap).
|
||||
const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
|
||||
|
||||
fn log_dir() -> Option<PathBuf> {
|
||||
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs"))
|
||||
}
|
||||
|
||||
/// The log file's path, for the "logs land here" startup line (and any future UI affordance).
|
||||
pub(crate) fn path() -> Option<PathBuf> {
|
||||
Some(log_dir()?.join("client.log"))
|
||||
}
|
||||
|
||||
/// Open (rotating first) and cache the sink. Called once at startup, before the tracing
|
||||
/// subscriber installs; every later [`tee`] shares the handle.
|
||||
pub(crate) fn init() {
|
||||
SINK.get_or_init(|| {
|
||||
let dir = log_dir()?;
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let path = dir.join("client.log");
|
||||
if std::fs::metadata(&path).is_ok_and(|m| m.len() > ROTATE_BYTES) {
|
||||
let old = dir.join("client.log.old");
|
||||
// Windows `rename` refuses an existing destination — drop the old generation first.
|
||||
let _ = std::fs::remove_file(&old);
|
||||
let _ = std::fs::rename(&path, &old);
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.ok()?;
|
||||
Some(Arc::new(Mutex::new(file)))
|
||||
});
|
||||
}
|
||||
|
||||
/// A writer that duplicates onto stderr (dev runs from a terminal keep their interleaved
|
||||
/// output) and the log file (GUI runs finally keep anything at all). The tracing subscriber's
|
||||
/// `with_writer` factory and the session-stderr forwarder both use it.
|
||||
pub(crate) struct Tee;
|
||||
|
||||
/// `with_writer` factory (`fn() -> Tee` satisfies `MakeWriter`).
|
||||
pub(crate) fn tee() -> Tee {
|
||||
Tee
|
||||
}
|
||||
|
||||
impl Write for Tee {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let _ = io::stderr().write_all(buf);
|
||||
if let Some(Some(f)) = SINK.get() {
|
||||
let _ = f.lock().unwrap().write_all(buf);
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
let _ = io::stderr().flush();
|
||||
if let Some(Some(f)) = SINK.get() {
|
||||
let _ = f.lock().unwrap().flush();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward a spawned child's stderr into the [`Tee`], line-buffered so its lines never
|
||||
/// interleave mid-line with the shell's own. Returns immediately; the thread dies with the
|
||||
/// pipe (child exit).
|
||||
pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("punktfunk-session-log".into())
|
||||
.spawn(move || {
|
||||
let mut reader = io::BufReader::new(stderr);
|
||||
let mut line = String::new();
|
||||
let mut tee = Tee;
|
||||
while matches!(reader.read_line(&mut line), Ok(n) if n > 0) {
|
||||
let _ = tee.write_all(line.as_bytes());
|
||||
line.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,8 @@ mod discovery;
|
||||
#[cfg(windows)]
|
||||
mod gpu;
|
||||
#[cfg(windows)]
|
||||
mod logfile;
|
||||
#[cfg(windows)]
|
||||
mod probe;
|
||||
#[cfg(windows)]
|
||||
mod shell_window;
|
||||
@@ -49,11 +51,20 @@ fn main() {
|
||||
}
|
||||
set_app_user_model_id();
|
||||
|
||||
// Everything logs to stderr AND `%LOCALAPPDATA%\punktfunk\logs\client.log` (see [`logfile`]):
|
||||
// a GUI/MSIX launch has no console, so without the file the client side of any field report
|
||||
// simply doesn't exist. ANSI off — the file is what users send, keep it grep-clean.
|
||||
logfile::init();
|
||||
tracing_subscriber::fmt()
|
||||
.with_ansi(false)
|
||||
.with_writer(logfile::tee)
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
if let Some(p) = logfile::path() {
|
||||
tracing::info!(path = %p.display(), "client log file (rotated at 10 MB, one .old kept)");
|
||||
}
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let flag = |name: &str| args.iter().any(|a| a == name);
|
||||
@@ -88,7 +99,16 @@ fn main() {
|
||||
if !flag("--windowed") {
|
||||
cmd.arg("--fullscreen");
|
||||
}
|
||||
match cmd.status() {
|
||||
// Spawn (not `status()`) so the session's stderr rides the log tee — a couch launch
|
||||
// (Start-menu tile, Steam shortcut) has no console to inherit either.
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
let run = cmd.spawn().and_then(|mut child| {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
logfile::forward_child_stderr(stderr);
|
||||
}
|
||||
child.wait()
|
||||
});
|
||||
match run {
|
||||
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
|
||||
Err(e) => {
|
||||
eprintln!("could not start the console UI: {e}");
|
||||
|
||||
@@ -169,13 +169,19 @@ fn spawn_with(
|
||||
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit()) // session logs interleave with the shell's (dev runs)
|
||||
// Piped through the log tee: dev-terminal runs keep the interleaved stderr they always
|
||||
// had, and GUI runs — which have no console — finally keep the session's whole
|
||||
// receive/decode/present log in the client log file.
|
||||
.stderr(Stdio::piped())
|
||||
.creation_flags(CREATE_NO_WINDOW);
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
|
||||
tracing::info!(host = %host_label, "session binary spawned");
|
||||
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
crate::logfile::forward_child_stderr(stderr);
|
||||
}
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
// Park the child where the kill handle (and the reader, for the final reap) reach it.
|
||||
*slot.0.lock().unwrap() = Some(child);
|
||||
|
||||
@@ -15,3 +15,6 @@ links = "vpl"
|
||||
cmake = "0.1"
|
||||
# Same bindgen configuration as pyrowave-sys (runtime = dlopen libclang).
|
||||
bindgen = { version = "0.72", features = ["runtime"], default-features = false }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -58,3 +58,6 @@ windows = { version = "0.62", features = [
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -134,8 +134,9 @@ pub trait Capturer: Send {
|
||||
// ---- Stream properties ------------------------------------------------------------------
|
||||
|
||||
/// The source's static HDR mastering metadata (SMPTE ST.2086 + content light level), when the
|
||||
/// capturer can read it from the output (Windows `IDXGIOutput6::GetDesc1`). `None` = unknown /
|
||||
/// SDR / a backend that doesn't expose it (the default — Linux capture has no HDR path yet).
|
||||
/// capturer can read it from the output (Windows `IDXGIOutput6::GetDesc1`), or a generic HDR10
|
||||
/// block once an HDR stream is negotiated (Linux — neither the portal nor gamescope exposes a
|
||||
/// real mastering volume). `None` = unknown / SDR / a backend that doesn't expose it.
|
||||
/// The stream loop forwards this to the encoder (in-band SEI) and the client (`0xCE` datagram),
|
||||
/// so the two stay a single source of truth. May change mid-session if the source is regraded.
|
||||
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||
@@ -362,6 +363,12 @@ pub struct ZeroCopyPolicy {
|
||||
/// resolved when the session encodes PyroWave (the passthrough advertises them so Mutter+NVIDIA,
|
||||
/// which allocates tiled-only, still negotiates zero-copy). Empty otherwise.
|
||||
pub pyrowave_modifiers: Vec<u64>,
|
||||
/// The resolved encoder can ingest a packed 10-bit PQ CUDA payload (`pf_encode::linux_hdr_cuda_ok`
|
||||
/// — direct-SDK NVENC only). An HDR capture builds the GPU importer ONLY when this holds:
|
||||
/// libav's HDR route wants a P010 hardware frame it swscales into, so a packed-2:10:10:10 CUDA
|
||||
/// buffer would land in a P010 surface as garbage. `false` ⇒ HDR takes the CPU path, exactly as
|
||||
/// it did before the direct backend learned 10-bit.
|
||||
pub hdr_cuda_ok: bool,
|
||||
}
|
||||
|
||||
/// Discovers gamescope's nested Xwayland cursor targets — `(DISPLAY, XAUTHORITY)`, one per
|
||||
@@ -387,15 +394,21 @@ pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
|
||||
}
|
||||
|
||||
/// Whether the **native-plane** capturer (a compositor virtual output) can deliver an HDR (10-bit
|
||||
/// PQ/BT.2020) source on this platform — the capture-side gate the punktfunk/1 handshake consults
|
||||
/// before negotiating 10-bit (mirroring [`capturer_supports_444`]).
|
||||
/// PQ/BT.2020) source **on this platform alone**, without knowing which compositor will be
|
||||
/// driven — the platform half of the gate the punktfunk/1 handshake consults before negotiating
|
||||
/// 10-bit (mirroring [`capturer_supports_444`]).
|
||||
///
|
||||
/// Linux: `false`. GNOME 50 added HDR **screen sharing** for *monitor* streams only — Mutter's
|
||||
/// `RecordVirtual` virtual-monitor streams advertise 8-bit BGRx/BGRA exclusively (still true on
|
||||
/// the GNOME 51 dev branch), and virtual outputs report no BT2020/PQ colour capabilities, so they
|
||||
/// can't be flipped into HDR mode via DisplayConfig either. The Linux HDR path that DOES exist —
|
||||
/// the GNOME 50+ portal **monitor mirror** (`open_portal_monitor` with `want_hdr`) — is gated
|
||||
/// separately by the GameStream plane (`host_hdr_capable` + the live monitor colour-mode probe).
|
||||
/// Linux: `false`, and this is NOT the whole Linux answer any more. It says only that no Linux
|
||||
/// virtual output is HDR-capable *by platform*: Mutter's `RecordVirtual` virtual-monitor streams
|
||||
/// advertise 8-bit BGRx/BGRA exclusively (still true on the GNOME 51 dev branch) and report no
|
||||
/// BT2020/PQ colour capabilities, and KWin/wlroots virtual outputs are the same. The one Linux
|
||||
/// virtual output that CAN be 10-bit — gamescope's PipeWire node, with our carried
|
||||
/// `pipewire-hdr` patch (`packaging/gamescope`) — depends on the resolved compositor **and** the
|
||||
/// resolved gamescope binary, neither of which this crate knows. The host resolves it in
|
||||
/// `capture::capturer_supports_hdr_for(compositor)`, which consults this for the platform floor;
|
||||
/// the other Linux HDR path (the GNOME 50+ portal **monitor mirror**, `open_portal_monitor` with
|
||||
/// `want_hdr`) is gated separately by the GameStream plane (`host_hdr_capable` + the live monitor
|
||||
/// colour-mode probe).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn capturer_supports_hdr() -> bool {
|
||||
false
|
||||
@@ -410,28 +423,67 @@ pub fn capturer_supports_hdr() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Process-wide latch: a `want_hdr` portal capture failed to negotiate the HDR (10-bit PQ) offer —
|
||||
/// the compositor never accepted it (monitor left HDR mode between the probe and the negotiation,
|
||||
/// NVIDIA EGL not listing LINEAR for XR30, a pre-50 Mutter…). Later sessions consult
|
||||
/// [`hdr_capture_failed`] and fall back to the SDR offer instead of re-running the same doomed
|
||||
/// 10-second negotiation timeout on every reconnect. Sticky until host restart (matching the
|
||||
/// zero-copy downgrade latches); the log line at latch time says so.
|
||||
/// Which HDR capture source a `want_hdr` negotiation failure belongs to.
|
||||
///
|
||||
/// The failure latch below is **per source**, because the two Linux HDR sources fail for
|
||||
/// completely unrelated reasons and share nothing but the word "HDR": the portal monitor mirror
|
||||
/// fails when the mirrored monitor leaves HDR mode (a live, box-state fact), a gamescope virtual
|
||||
/// output fails when the spawned binary has no 10-bit formats (a static, binary-identity fact).
|
||||
/// A single process-wide latch let either one disable the other until the host restarted.
|
||||
#[cfg(target_os = "linux")]
|
||||
static HDR_CAPTURE_FAILED: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HdrSource {
|
||||
/// The GNOME 50+ portal **monitor mirror** (`open_portal_monitor` with `want_hdr`) — the
|
||||
/// GameStream plane's HDR path.
|
||||
PortalMonitor,
|
||||
/// A compositor **virtual output** (`open_virtual_output` with `want_hdr`) — today only
|
||||
/// gamescope's PipeWire node, with the carried `pipewire-hdr` patch.
|
||||
VirtualOutput,
|
||||
}
|
||||
|
||||
/// Per-source latch: a `want_hdr` capture failed to negotiate the HDR (10-bit PQ) offer — the
|
||||
/// producer never accepted it (monitor left HDR mode between the probe and the negotiation,
|
||||
/// NVIDIA EGL not listing LINEAR for XR30, an unpatched gamescope…). Later sessions **on that
|
||||
/// same source** consult [`hdr_capture_failed`] and fall back to the SDR offer instead of
|
||||
/// re-running the same doomed 10-second negotiation timeout on every reconnect. Sticky until host
|
||||
/// restart (matching the zero-copy downgrade latches); the log line at latch time says so.
|
||||
/// Indexed by [`HdrSource`] — see its doc for why one shared latch was wrong.
|
||||
#[cfg(target_os = "linux")]
|
||||
static HDR_CAPTURE_FAILED: [std::sync::atomic::AtomicBool; 2] = [
|
||||
std::sync::atomic::AtomicBool::new(false),
|
||||
std::sync::atomic::AtomicBool::new(false),
|
||||
];
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn hdr_capture_failed() -> bool {
|
||||
HDR_CAPTURE_FAILED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
impl HdrSource {
|
||||
fn slot(self) -> usize {
|
||||
match self {
|
||||
HdrSource::PortalMonitor => 0,
|
||||
HdrSource::VirtualOutput => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn note_hdr_capture_failed() {
|
||||
if !HDR_CAPTURE_FAILED.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
tracing::warn!(
|
||||
"HDR capture negotiation failed — this host will offer SDR capture for the rest of \
|
||||
the process lifetime (restart the host after fixing the monitor's HDR mode to retry)"
|
||||
);
|
||||
pub fn hdr_capture_failed(source: HdrSource) -> bool {
|
||||
HDR_CAPTURE_FAILED[source.slot()].load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn note_hdr_capture_failed(source: HdrSource) {
|
||||
if !HDR_CAPTURE_FAILED[source.slot()].swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
match source {
|
||||
HdrSource::PortalMonitor => tracing::warn!(
|
||||
"HDR capture negotiation failed on the monitor mirror — this host will offer SDR \
|
||||
for that source for the rest of the process lifetime (restart the host after \
|
||||
fixing the monitor's HDR mode to retry)"
|
||||
),
|
||||
HdrSource::VirtualOutput => tracing::warn!(
|
||||
"HDR capture negotiation failed on the virtual output — this host will offer SDR \
|
||||
for that source for the rest of the process lifetime (is the spawned gamescope \
|
||||
the punktfunk build? see packaging/gamescope)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -543,7 +595,7 @@ pub fn open_portal_monitor(
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
linux::PortalCapturer::open(
|
||||
anchored,
|
||||
want_hdr && !hdr_capture_failed(),
|
||||
want_hdr && !hdr_capture_failed(HdrSource::PortalMonitor),
|
||||
want_metadata_cursor,
|
||||
policy,
|
||||
)
|
||||
@@ -553,7 +605,11 @@ pub fn open_portal_monitor(
|
||||
/// Open the Linux portal capturer bound to an already-created virtual output's PipeWire node. The
|
||||
/// caller (host facade) explodes its `VirtualOutput` into these primitives + owns nothing after —
|
||||
/// the capturer takes `keepalive`, so dropping it releases the output. `allow_zerocopy` mirrors
|
||||
/// `OutputFormat::gpu`; `want_444` selects the planar-YUV444 GPU convert.
|
||||
/// `OutputFormat::gpu`; `want_444` selects the planar-YUV444 GPU convert. `want_hdr` offers the
|
||||
/// 10-bit PQ/BT.2020 formats instead of the SDR set — pass it only when the output was actually
|
||||
/// brought up HDR (a gamescope spawned with `--hdr-enabled` off our `pipewire-hdr` build); the
|
||||
/// host resolves that in `capture::capturer_supports_hdr_for` **before** the Welcome, because a
|
||||
/// session that negotiated PQ cannot fall back to SDR afterwards.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open_virtual_output(
|
||||
@@ -563,6 +619,7 @@ pub fn open_virtual_output(
|
||||
keepalive: Box<dyn Send>,
|
||||
allow_zerocopy: bool,
|
||||
want_444: bool,
|
||||
want_hdr: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
expect_exact_dims: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
@@ -573,6 +630,7 @@ pub fn open_virtual_output(
|
||||
keepalive,
|
||||
allow_zerocopy,
|
||||
want_444,
|
||||
want_hdr && !hdr_capture_failed(HdrSource::VirtualOutput),
|
||||
policy,
|
||||
expect_exact_dims,
|
||||
)
|
||||
|
||||
@@ -104,6 +104,13 @@ struct CaptureSignals {
|
||||
/// Set once the stream negotiated one of the 10-bit PQ formats, i.e. frames really are
|
||||
/// PQ/BT.2020 — drives `hdr_meta`.
|
||||
hdr_negotiated: Arc<AtomicBool>,
|
||||
/// Set by the PipeWire thread once it ACTUALLY advertised the EGL→CUDA dmabuf-only offer
|
||||
/// (importer constructed, importable modifiers found, not the raw passthrough, not HDR).
|
||||
/// `plan.build_importer` alone cannot answer this — the importer may fail to construct (no
|
||||
/// CUDA on this box), in which case no dmabuf was offered and a negotiation timeout must NOT
|
||||
/// latch the GPU offer off. Read by `next_frame`'s timeout diagnosis, the EGL→CUDA twin of
|
||||
/// the raw-passthrough arm.
|
||||
gpu_dmabuf_offer: Arc<AtomicBool>,
|
||||
/// The LIVE cursor overlay, published from every buffer's `SPA_META_Cursor` — including the
|
||||
/// cursor-only "corrupted" buffers that never become frames — so the encode loop's forwarder
|
||||
/// tracks pointer-only motion on a static desktop (the frame-attached overlay alone goes stale
|
||||
@@ -123,6 +130,7 @@ impl CaptureSignals {
|
||||
streaming: Arc::new(AtomicBool::new(false)),
|
||||
broken: Arc::new(AtomicBool::new(false)),
|
||||
hdr_negotiated: Arc::new(AtomicBool::new(false)),
|
||||
gpu_dmabuf_offer: Arc::new(AtomicBool::new(false)),
|
||||
cursor_live: Arc::new(std::sync::Mutex::new(None)),
|
||||
frame_size: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
}
|
||||
@@ -159,6 +167,9 @@ pub struct PortalCapturer {
|
||||
/// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the
|
||||
/// process-wide SDR downgrade) and by [`hdr_meta`](Capturer::hdr_meta).
|
||||
hdr_offer: bool,
|
||||
/// Which HDR source this capturer is — the latch a failed [`hdr_offer`](Self::hdr_offer)
|
||||
/// belongs to. See [`super::HdrSource`] for why the latch is not one process-wide flag.
|
||||
hdr_source: super::HdrSource,
|
||||
/// The PipeWire node this capturer consumes — surfaced in error messages for diagnosis.
|
||||
node_id: u32,
|
||||
/// Stops the PipeWire loop on teardown (sent in `Drop`). Without it a dropped or failed
|
||||
@@ -293,7 +304,7 @@ impl PortalCapturer {
|
||||
},
|
||||
policy,
|
||||
)?
|
||||
.into_capturer(node_id, None, Some(portal)))
|
||||
.into_capturer(node_id, None, Some(portal), super::HdrSource::PortalMonitor))
|
||||
}
|
||||
|
||||
/// Build a capturer from an already-created virtual output's PipeWire node. The host facade
|
||||
@@ -304,6 +315,8 @@ impl PortalCapturer {
|
||||
/// [`OutputFormat::gpu`](pf_frame::OutputFormat): `false` forces the CPU mmap path, `true` keeps
|
||||
/// the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`. `want_444` (a 4:4:4 session) makes the
|
||||
/// zero-copy worker convert tiled dmabufs to planar YUV444 on the GPU instead of NV12/RGB.
|
||||
/// `want_hdr` runs the 10-bit PQ/BT.2020 offer instead of the SDR set — see
|
||||
/// [`crate::open_virtual_output`] for who is allowed to pass it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_virtual_output(
|
||||
remote_fd: Option<OwnedFd>,
|
||||
@@ -312,6 +325,7 @@ impl PortalCapturer {
|
||||
keepalive: Box<dyn Send>,
|
||||
allow_zerocopy: bool,
|
||||
want_444: bool,
|
||||
want_hdr: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
expect_exact_dims: bool,
|
||||
) -> Result<PortalCapturer> {
|
||||
@@ -319,11 +333,14 @@ impl PortalCapturer {
|
||||
node_id,
|
||||
allow_zerocopy,
|
||||
want_444,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
"connecting PipeWire to virtual output"
|
||||
);
|
||||
// Virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise 8-bit
|
||||
// BGRx/BGRA exclusively, GNOME 50 and 51-dev alike) — never run the HDR offer here.
|
||||
// Most virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise
|
||||
// 8-bit BGRx/BGRA exclusively, GNOME 50 and 51-dev alike; KWin/wlroots the same), so
|
||||
// `want_hdr` reaches here ONLY for a gamescope node off our `pipewire-hdr` build — the
|
||||
// host resolves that before the Welcome (`capture::capturer_supports_hdr_for`).
|
||||
Ok(spawn_pipewire(
|
||||
remote_fd,
|
||||
node_id,
|
||||
@@ -331,15 +348,19 @@ impl PortalCapturer {
|
||||
CaptureOpts {
|
||||
allow_zerocopy,
|
||||
want_444,
|
||||
// Virtual outputs are SDR-only upstream (see the comment above).
|
||||
want_hdr: false,
|
||||
want_hdr,
|
||||
expect_exact_dims,
|
||||
},
|
||||
policy,
|
||||
)?
|
||||
// No portal thread on this path: the node belongs to a virtual output the caller created,
|
||||
// and `keepalive` is what releases it.
|
||||
.into_capturer(node_id, Some(keepalive), None))
|
||||
.into_capturer(
|
||||
node_id,
|
||||
Some(keepalive),
|
||||
None,
|
||||
super::HdrSource::VirtualOutput,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +391,7 @@ impl PwHandles {
|
||||
node_id: u32,
|
||||
keepalive: Option<Box<dyn Send>>,
|
||||
portal: Option<PortalSession>,
|
||||
hdr_source: super::HdrSource,
|
||||
) -> PortalCapturer {
|
||||
PortalCapturer {
|
||||
slot: self.slot,
|
||||
@@ -378,6 +400,7 @@ impl PwHandles {
|
||||
stall_since: None,
|
||||
vaapi_dmabuf: self.vaapi_dmabuf,
|
||||
hdr_offer: self.hdr_offer,
|
||||
hdr_source,
|
||||
node_id,
|
||||
quit: Some(self.quit),
|
||||
join: Some(self.join),
|
||||
@@ -444,9 +467,11 @@ fn spawn_pipewire(
|
||||
native_nv12_session: policy.native_nv12_session,
|
||||
raw_dmabuf_import_disabled: pf_zerocopy::raw_dmabuf_import_disabled(),
|
||||
gpu_import_disabled: pf_zerocopy::gpu_import_disabled(),
|
||||
gpu_dmabuf_negotiation_failed: pf_zerocopy::gpu_dmabuf_negotiation_disabled(),
|
||||
// Default ON; `PUNKTFUNK_PIPEWIRE_NV12=0` (or any falsy spelling — the shared parser, not a
|
||||
// bare `!= "0"` string compare) restores the packed-RGB negotiation.
|
||||
native_nv12_env_on: pf_host_config::env_on("PUNKTFUNK_PIPEWIRE_NV12").unwrap_or(true),
|
||||
hdr_cuda_ok: policy.hdr_cuda_ok,
|
||||
});
|
||||
// The capturer's timeout diagnosis reads the SAME resolved bool the thread acts on.
|
||||
let vaapi_dmabuf = plan.vaapi_passthrough;
|
||||
@@ -624,11 +649,15 @@ impl Capturer for PortalCapturer {
|
||||
&& self.join.as_ref().is_some_and(|j| !j.is_finished())
|
||||
}
|
||||
|
||||
/// Generic HDR10 mastering metadata once the stream negotiated a 10-bit PQ format. Mutter
|
||||
/// exposes no per-monitor mastering volume through the screencast, so this is the standard
|
||||
/// HDR10 default block (BT.2020 primaries, D65 white, 1000 / 0.005 cd/m², CLL unknown) — the
|
||||
/// same fallback Windows uses when a display reports nothing. The native stream loop prefers
|
||||
/// the client display's own volume when the client sent one (`Hello::display_hdr`).
|
||||
/// Generic HDR10 mastering metadata once the stream negotiated a 10-bit PQ format — for
|
||||
/// BOTH Linux HDR sources (the portal monitor mirror and a gamescope virtual output).
|
||||
/// Neither producer exposes a mastering volume through the screencast: Mutter has no
|
||||
/// per-monitor one, and gamescope's PipeWire node carries no per-frame HDR metadata (the
|
||||
/// game's `VK_EXT_hdr_metadata` blob stops at the compositor — forwarding it needs the
|
||||
/// patch's v2 custom SPA meta). So this is the standard HDR10 default block (BT.2020
|
||||
/// primaries, D65 white, 1000 / 0.005 cd/m², CLL unknown) — the same fallback Windows uses
|
||||
/// when a display reports nothing. The native stream loop prefers the client display's own
|
||||
/// volume when the client sent one (`Hello::display_hdr`).
|
||||
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||
if !self.signals.hdr_negotiated.load(Ordering::Relaxed) {
|
||||
return None;
|
||||
@@ -713,7 +742,7 @@ impl PortalCapturer {
|
||||
// GNOME 50 HDR formats, or its allocator can't do LINEAR for XR30/XB30.
|
||||
// Latch the process-wide SDR downgrade so the next session (Moonlight
|
||||
// auto-reconnects) negotiates SDR instead of re-running this same timeout.
|
||||
super::note_hdr_capture_failed();
|
||||
super::note_hdr_capture_failed(self.hdr_source);
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within {within}s (node {}): the compositor never \
|
||||
accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer — is the mirrored \
|
||||
@@ -721,7 +750,7 @@ impl PortalCapturer {
|
||||
reconnect to stream SDR",
|
||||
self.node_id
|
||||
))
|
||||
} else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() {
|
||||
} else if self.vaapi_dmabuf && !pf_zerocopy::zerocopy_forced() {
|
||||
// The dmabuf-only raw-passthrough offer was never accepted. Latch the
|
||||
// downgrade so the encode loop's pipeline rebuild retries on the CPU offer
|
||||
// instead of failing this same negotiation forever. The latch is SCOPED to the
|
||||
@@ -738,6 +767,25 @@ impl PortalCapturer {
|
||||
rebuild will renegotiate without dmabuf",
|
||||
self.node_id
|
||||
))
|
||||
} else if self.signals.gpu_dmabuf_offer.load(Ordering::Relaxed)
|
||||
&& !pf_zerocopy::zerocopy_forced()
|
||||
{
|
||||
// The EGL→CUDA dmabuf-only offer was never accepted — the twin of the raw-
|
||||
// passthrough arm above (the offer the thread ACTUALLY made, per the signal
|
||||
// it set — see `CaptureSignals::gpu_dmabuf_offer`). One timeout is conclusive:
|
||||
// a compositor that allocates none of the importer's modifiers refuses them
|
||||
// identically on every retry, so latch the offer off and let the pipeline
|
||||
// rebuild renegotiate the CPU path instead of re-running this same 10 s
|
||||
// timeout on every reconnect. A forced PUNKTFUNK_ZEROCOPY=1 keeps erroring
|
||||
// loudly instead (same rule as the raw arm).
|
||||
pf_zerocopy::note_gpu_dmabuf_negotiation_failed();
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within {within}s (node {}): the compositor never \
|
||||
accepted the dmabuf-only offer (EGL→CUDA GPU import) — downgrading THIS \
|
||||
offer to the CPU path for the rest of the process; the pipeline rebuild \
|
||||
will renegotiate without dmabuf",
|
||||
self.node_id
|
||||
))
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within {within}s (node {}): format negotiation never \
|
||||
|
||||
@@ -127,8 +127,14 @@ pub(super) struct NegotiationInputs {
|
||||
pub raw_dmabuf_import_disabled: bool,
|
||||
/// `pf_zerocopy::gpu_import_disabled()` — repeated import-worker deaths.
|
||||
pub gpu_import_disabled: bool,
|
||||
/// `pf_zerocopy::gpu_dmabuf_negotiation_disabled()` — a previous EGL→CUDA dmabuf-only offer
|
||||
/// timed out (the compositor accepts none of the importer's modifiers).
|
||||
pub gpu_dmabuf_negotiation_failed: bool,
|
||||
/// `PUNKTFUNK_PIPEWIRE_NV12` (default ON) — allow the producer-side NV12 preference.
|
||||
pub native_nv12_env_on: bool,
|
||||
/// [`ZeroCopyPolicy::hdr_cuda_ok`] — the resolved encoder can ingest a packed 10-bit PQ CUDA
|
||||
/// payload. Only the direct-SDK NVENC backend can.
|
||||
pub hdr_cuda_ok: bool,
|
||||
}
|
||||
|
||||
/// The resolved zero-copy negotiation decision — **one resolver, consumed by the PipeWire thread
|
||||
@@ -154,8 +160,8 @@ pub(super) struct NegotiationPlan {
|
||||
/// Diagnostic: this capture WOULD have taken the raw passthrough, but its scoped latch is
|
||||
/// set (the encoder repeatedly failed to import, or a previous negotiation timed out).
|
||||
pub raw_dmabuf_latched: bool,
|
||||
/// Diagnostic: this capture WOULD have built the EGL→CUDA importer, but repeated
|
||||
/// import-worker deaths latched the GPU import off.
|
||||
/// Diagnostic: this capture WOULD have built the EGL→CUDA importer, but a latch fired —
|
||||
/// repeated import-worker deaths, or a previous dmabuf-offer negotiation timeout.
|
||||
pub gpu_import_latched: bool,
|
||||
}
|
||||
|
||||
@@ -163,8 +169,11 @@ pub(super) struct NegotiationPlan {
|
||||
///
|
||||
/// The four invariants this encodes were previously prose-only comments spread across the
|
||||
/// prologue; `negotiation_plan_invariants` in the tests below pins each one:
|
||||
/// 1. HDR never builds the EGL→CUDA importer (its de-tile blit is 8-bit RGBA8 → silent depth
|
||||
/// loss); the HDR consumers are the CPU mmap path and the raw passthrough.
|
||||
/// 1. HDR never takes the TILED EGL de-tile blit (it renders into an 8-bit `GL_RGBA8` texture
|
||||
/// → silent depth loss). It may still build the importer, because the HDR pod family
|
||||
/// advertises LINEAR only ([`build_hdr_dmabuf_format`]) — so an HDR dmabuf necessarily
|
||||
/// takes the Vulkan-bridge / CUDA-external-memory arm, which is byte-exact for any 4 Bpp
|
||||
/// packed format. The per-frame gate in `.process` enforces the tiled half.
|
||||
/// 2. 4:4:4 never prefers producer NV12 (a 4:4:4 session must not be subsampled).
|
||||
/// 3. Producer-native NV12 only on a `native_nv12_session` under an active raw passthrough
|
||||
/// (libav VAAPI would misread the two-plane buffer; the CUDA importer expects packed RGB).
|
||||
@@ -174,10 +183,22 @@ pub(super) fn negotiation_plan(i: NegotiationInputs) -> NegotiationPlan {
|
||||
// CSC) or a PyroWave session (the wavelet encoder's own Vulkan device, any vendor).
|
||||
let raw_passthrough = i.backend_is_vaapi || i.pyrowave_session;
|
||||
// Building the EGL→CUDA importer would waste a CUDA probe under a raw passthrough — or
|
||||
// worse, succeed and produce CUDA payloads only NVENC can consume. HDR is excluded by
|
||||
// invariant 1. `gpu_import_disabled` is the repeated-worker-death latch (a wedged GPU stack
|
||||
// must not crash-loop).
|
||||
let build_importer = i.zerocopy && !raw_passthrough && !i.want_hdr && !i.gpu_import_disabled;
|
||||
// worse, succeed and produce CUDA payloads only NVENC can consume. `gpu_import_disabled` is
|
||||
// the repeated-worker-death latch (a wedged GPU stack must not crash-loop);
|
||||
// `gpu_dmabuf_negotiation_failed` is the offer's own timeout latch (a compositor that accepts
|
||||
// none of the importer's modifiers refuses them identically on every retry, so the next
|
||||
// session negotiates the CPU path instead of re-paying the 10 s timeout).
|
||||
//
|
||||
// HDR is NOT excluded outright (invariant 1): its pods are LINEAR-only, so it lands on the
|
||||
// Vulkan-bridge arm, never the 8-bit de-tile blit. But it is excluded where the encoder cannot
|
||||
// take a packed 10-bit CUDA payload — the libav fallback's HDR route swscales into a P010
|
||||
// hardware frame, so it must keep getting CPU frames. Without that term a
|
||||
// `PUNKTFUNK_NVENC_DIRECT=0` host would stream garbage.
|
||||
let build_importer = i.zerocopy
|
||||
&& !raw_passthrough
|
||||
&& !i.gpu_import_disabled
|
||||
&& !i.gpu_dmabuf_negotiation_failed
|
||||
&& (!i.want_hdr || i.hdr_cuda_ok);
|
||||
// Note there is no `importer.is_none()` term, unlike the expression this replaces: it was
|
||||
// redundant (`build_importer` already excludes `raw_passthrough`, so the importer is
|
||||
// necessarily absent here) and it is what made the decision look impure — the reason
|
||||
@@ -200,7 +221,12 @@ pub(super) fn negotiation_plan(i: NegotiationInputs) -> NegotiationPlan {
|
||||
&& !i.force_shm
|
||||
&& raw_passthrough
|
||||
&& i.raw_dmabuf_import_disabled,
|
||||
gpu_import_latched: i.zerocopy && !raw_passthrough && !i.want_hdr && i.gpu_import_disabled,
|
||||
// Every `build_importer` term EXCEPT the two latches, and then either latch — i.e. exactly
|
||||
// "this capture would have built the importer, but a latch stopped it".
|
||||
gpu_import_latched: i.zerocopy
|
||||
&& !raw_passthrough
|
||||
&& (!i.want_hdr || i.hdr_cuda_ok)
|
||||
&& (i.gpu_import_disabled || i.gpu_dmabuf_negotiation_failed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,14 +347,15 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
// attach no fence. Covers both the GPU import and the CPU mmap read below.
|
||||
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
||||
match pf_zerocopy::dmabuf_fence::wait_read_ready(datas[0].fd(), 100) {
|
||||
Ok(waited) => {
|
||||
Ok(outcome) => {
|
||||
static F1: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
|
||||
if F1.swap(false, Ordering::Relaxed) {
|
||||
tracing::info!(
|
||||
waited,
|
||||
"dmabuf implicit-fence sync active (waited=true → driver fences \
|
||||
the render, race closed; false → no implicit fence, zero-copy \
|
||||
may still show stale frames)"
|
||||
?outcome,
|
||||
"dmabuf implicit-fence sync active (Signaled → driver fences the \
|
||||
render, race closed; NoFence → no implicit fence, zero-copy may \
|
||||
still show stale frames; TimedOut → fence pending past 100ms, \
|
||||
proceeded anyway)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -450,10 +477,20 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
// through to the shm de-pad copy below.
|
||||
let mut gpu_import_broken = false;
|
||||
if let (Some(importer), Some(fmt)) = (ud.importer.as_mut(), ud.format) {
|
||||
// Defense-in-depth: the 10-bit PQ formats must never enter the EGL→CUDA import (its
|
||||
// de-tile blit is 8-bit RGBA8 — silent depth loss). An HDR offer never builds the
|
||||
// importer, so this gate only matters if those invariants ever drift apart.
|
||||
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf && !fmt.is_hdr_rgb10() {
|
||||
// Invariant 1's teeth: a 10-bit PQ frame may take the LINEAR (Vulkan-bridge → CUDA
|
||||
// external memory) arm, which moves 4 Bpp words verbatim, but must NEVER take the TILED
|
||||
// EGL de-tile blit — that renders into an 8-bit `GL_RGBA8` texture and would crush the
|
||||
// depth silently. The HDR pods advertise LINEAR only, so a tiled modifier here means the
|
||||
// producer ignored the offer; drop to the CPU path rather than trust it.
|
||||
let hdr_tiled = fmt.is_hdr_rgb10() && ud.modifier != 0;
|
||||
if hdr_tiled {
|
||||
warn_once(
|
||||
"HDR frame arrived with a tiled modifier — the GPU de-tile blit is 8-bit, so \
|
||||
this stream falls back to the CPU path (the producer ignored our LINEAR-only \
|
||||
HDR offer)",
|
||||
);
|
||||
}
|
||||
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf && !hdr_tiled {
|
||||
let plane = pf_zerocopy::DmabufPlane {
|
||||
fd: datas[0].fd(),
|
||||
offset: datas[0].chunk().offset(),
|
||||
@@ -473,8 +510,13 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
// stays RGB, falling to the encoder's clear-error path (`want_444` with an
|
||||
// RGB CUDA payload) rather than silently subsampling. A LINEAR NV12 convert
|
||||
// failure latches RGB for the stream (mid-frame fallback, no drop).
|
||||
let yuv444 = ud.yuv444 && modifier.is_some();
|
||||
let mut nv12 = ud.nv12 && !ud.yuv444;
|
||||
// A 10-bit frame takes NEITHER convert: both the GL and the Vulkan compute CSCs
|
||||
// write 8-bit planes, and NVENC ingests the packed 10-bit RGB natively
|
||||
// (`ARGB10`/`ABGR10`) with its own BT.2020 CSC. So HDR stays packed RGB all the
|
||||
// way to the encoder — no depth loss, no extra pass.
|
||||
let ten_bit = fmt.is_hdr_rgb10();
|
||||
let yuv444 = ud.yuv444 && modifier.is_some() && !ten_bit;
|
||||
let mut nv12 = ud.nv12 && !ud.yuv444 && !ten_bit;
|
||||
let imported = if let Some(m) = modifier {
|
||||
if yuv444 {
|
||||
importer.import_yuv444(&plane, w as u32, h as u32, fourcc, Some(m))
|
||||
@@ -805,11 +847,12 @@ pub fn pipewire_thread(
|
||||
// (design/zerocopy-worker-isolation.md), so a driver fault on a dying compositor's dmabuf
|
||||
// kills the worker, not this host. If construction fails, log and fall back to the CPU path
|
||||
// (we simply won't request dmabuf below); `plan.build_importer` already encodes WHEN to try
|
||||
// at all (not under a raw passthrough, not for HDR, not once repeated worker deaths latched
|
||||
// the import off — see `negotiation_plan`).
|
||||
// at all (not under a raw passthrough, not once repeated worker deaths latched the import
|
||||
// off — see `negotiation_plan`).
|
||||
if plan.gpu_import_latched {
|
||||
tracing::warn!(
|
||||
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
|
||||
"zero-copy GPU import disabled for this host process (repeated import-worker deaths, \
|
||||
or a previous dmabuf negotiation timeout) — using CPU path"
|
||||
);
|
||||
}
|
||||
let mut importer = if plan.build_importer {
|
||||
@@ -861,6 +904,13 @@ pub fn pipewire_thread(
|
||||
// The one runtime-dependent half of the decision (see `NegotiationPlan::want_dmabuf`): it
|
||||
// needs the modifier list the importer's construction actually yielded.
|
||||
let want_dmabuf = plan.want_dmabuf(importer.is_some(), &modifiers);
|
||||
// Record whether THIS capture really advertises the EGL→CUDA dmabuf-only offer, for the
|
||||
// capturer's negotiation-timeout diagnosis (its latch must fire only for an offer that was
|
||||
// actually made — `plan.build_importer` alone can't know the importer constructed).
|
||||
signals.gpu_dmabuf_offer.store(
|
||||
want_dmabuf && !vaapi_passthrough && !want_hdr,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
if force_shm {
|
||||
tracing::info!(
|
||||
"capture: PUNKTFUNK_FORCE_SHM — race-free SHM download path (no dmabuf, no zero-copy)"
|
||||
@@ -1397,7 +1447,9 @@ mod tests {
|
||||
native_nv12_session: false,
|
||||
raw_dmabuf_import_disabled: false,
|
||||
gpu_import_disabled: false,
|
||||
gpu_dmabuf_negotiation_failed: false,
|
||||
native_nv12_env_on: true,
|
||||
hdr_cuda_ok: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1413,17 +1465,19 @@ mod tests {
|
||||
/// The four invariants that were prose-only comments in `pipewire_thread`'s prologue.
|
||||
#[test]
|
||||
fn negotiation_plan_invariants() {
|
||||
// 1. HDR NEVER builds the EGL→CUDA importer — its de-tile blit is 8-bit RGBA8, so an
|
||||
// importer here would silently crush the 10-bit depth.
|
||||
// 1. HDR builds the importer on the NVENC path — its pods are LINEAR-only, so the frames
|
||||
// take the Vulkan-bridge/CUDA arm (byte-exact for 4 Bpp), never the 8-bit de-tile
|
||||
// blit. (The tiled half of the invariant is enforced per frame in `.process`, which
|
||||
// sees the negotiated modifier this plan cannot.)
|
||||
for want_444 in [false, true] {
|
||||
let p = negotiation_plan(NegotiationInputs {
|
||||
want_hdr: true,
|
||||
want_444,
|
||||
..nvenc()
|
||||
});
|
||||
assert!(!p.build_importer, "HDR must not build the importer");
|
||||
assert!(p.build_importer, "HDR on NVENC keeps zero-copy");
|
||||
}
|
||||
// …on every backend, including the ones that take the raw passthrough.
|
||||
// …but never under a raw passthrough (VAAPI/PyroWave import the dmabuf themselves).
|
||||
assert!(
|
||||
!negotiation_plan(NegotiationInputs {
|
||||
want_hdr: true,
|
||||
@@ -1431,6 +1485,26 @@ mod tests {
|
||||
})
|
||||
.build_importer
|
||||
);
|
||||
// …and never when the resolved encoder can't take a packed 10-bit CUDA payload (libav
|
||||
// NVENC: its HDR route swscales into a P010 hardware frame). SDR is unaffected — the term
|
||||
// is HDR-only, so a libav host keeps its 8-bit zero-copy.
|
||||
assert!(
|
||||
!negotiation_plan(NegotiationInputs {
|
||||
want_hdr: true,
|
||||
hdr_cuda_ok: false,
|
||||
..nvenc()
|
||||
})
|
||||
.build_importer,
|
||||
"HDR must stay on the CPU path where the encoder can't ingest 10-bit CUDA"
|
||||
);
|
||||
assert!(
|
||||
negotiation_plan(NegotiationInputs {
|
||||
hdr_cuda_ok: false,
|
||||
..nvenc()
|
||||
})
|
||||
.build_importer,
|
||||
"the HDR-only guard must not touch an SDR session"
|
||||
);
|
||||
|
||||
// 2. 4:4:4 never prefers producer NV12 (a 4:4:4 session must not be subsampled).
|
||||
let p = negotiation_plan(NegotiationInputs {
|
||||
@@ -1511,6 +1585,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The EGL→CUDA offer's own negotiation-timeout latch gates `build_importer` — the twin of
|
||||
/// the raw passthrough's latch, so a compositor that accepts none of the importer's modifiers
|
||||
/// stops being asked (previously it re-ran the same 10 s timeout on every session, forever).
|
||||
/// The raw passthrough is untouched by it.
|
||||
#[test]
|
||||
fn gpu_dmabuf_negotiation_latch_gates_only_the_importer() {
|
||||
let p = negotiation_plan(NegotiationInputs {
|
||||
gpu_dmabuf_negotiation_failed: true,
|
||||
..nvenc()
|
||||
});
|
||||
assert!(!p.build_importer, "latched offer must not be re-made");
|
||||
assert!(p.gpu_import_latched, "the downgrade must be diagnosable");
|
||||
let p = negotiation_plan(NegotiationInputs {
|
||||
gpu_dmabuf_negotiation_failed: true,
|
||||
..vaapi_native_nv12()
|
||||
});
|
||||
assert!(
|
||||
p.vaapi_passthrough,
|
||||
"the raw passthrough has its own latch — this one must not touch it"
|
||||
);
|
||||
assert!(!p.gpu_import_latched, "no importer was ever wanted here");
|
||||
}
|
||||
|
||||
/// A PyroWave session on an NVIDIA box takes the raw passthrough (the wavelet encoder's own
|
||||
/// Vulkan device imports dmabufs on any vendor) and therefore must NOT also build the
|
||||
/// EGL→CUDA importer — whose payloads only NVENC can consume.
|
||||
@@ -1569,16 +1666,18 @@ mod tests {
|
||||
});
|
||||
assert!(!p.build_importer);
|
||||
assert!(p.gpu_import_latched);
|
||||
// It is NOT reported for a capture that would never have built one anyway (HDR, or a
|
||||
// raw passthrough) — that would misdirect the operator.
|
||||
// Reported for an HDR capture too — HDR takes the same importer (LINEAR/Vulkan-bridge
|
||||
// arm), so the latch really did cost it zero-copy.
|
||||
assert!(
|
||||
!negotiation_plan(NegotiationInputs {
|
||||
negotiation_plan(NegotiationInputs {
|
||||
gpu_import_disabled: true,
|
||||
want_hdr: true,
|
||||
..nvenc()
|
||||
})
|
||||
.gpu_import_latched
|
||||
);
|
||||
// It is NOT reported for a capture that would never have built one anyway (a raw
|
||||
// passthrough) — that would misdirect the operator.
|
||||
assert!(
|
||||
!negotiation_plan(NegotiationInputs {
|
||||
gpu_import_disabled: true,
|
||||
|
||||
@@ -10,13 +10,18 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::os::fd::OwnedFd;
|
||||
|
||||
/// Whether any monitor of the live GNOME session is currently in BT.2100 (HDR) colour mode — the
|
||||
/// Whether the monitor this host would mirror is currently in BT.2100 (HDR) colour mode — the
|
||||
/// precondition for Mutter's monitor screencast advertising the 10-bit PQ formats (GNOME 50+;
|
||||
/// Mutter only appends the HDR formats while the mirrored monitor's colour state is BT.2020+PQ).
|
||||
/// Queried over the session bus: `DisplayConfig.GetCurrentState`, monitor property
|
||||
/// `"color-mode" == 1` (`META_COLOR_MODE_BT2100`). `false` on any error — not GNOME, a pre-48
|
||||
/// Mutter without colour modes, no monitors — so callers fall back to the honest SDR offer.
|
||||
/// Blocking (one D-Bus round-trip on a fresh connection); call from control-plane threads only.
|
||||
///
|
||||
/// **Scoped to `PUNKTFUNK_CAPTURE_MONITOR` when it is set** (`design/per-monitor-portal-capture.md`
|
||||
/// §7.4). Without a pin this asks "is ANY monitor in HDR mode", which was a fair heuristic while the
|
||||
/// capture path took whatever monitor it was handed — but once the operator names the head, an
|
||||
/// HDR-capable *neighbour* must not talk this host into offering PQ formats for an SDR panel.
|
||||
pub fn gnome_hdr_monitor_active() -> bool {
|
||||
use ashpd::zbus;
|
||||
// GetCurrentState reply: (serial, monitors, logical_monitors, properties); each monitor is
|
||||
@@ -74,12 +79,21 @@ pub fn gnome_hdr_monitor_active() -> bool {
|
||||
.body()
|
||||
.deserialize()
|
||||
.context("parse GetCurrentState")?;
|
||||
Ok(monitors.iter().any(|(_spec, _modes, props)| {
|
||||
props
|
||||
.get("color-mode")
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.is_some_and(|mode| mode == 1) // META_COLOR_MODE_BT2100
|
||||
}))
|
||||
// `spec.0` is the connector; "color-mode" 1 is META_COLOR_MODE_BT2100.
|
||||
let heads: Vec<(&str, bool)> = monitors
|
||||
.iter()
|
||||
.map(|(spec, _modes, props)| {
|
||||
let hdr = props
|
||||
.get("color-mode")
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.is_some_and(|mode| mode == 1);
|
||||
(spec.0.as_str(), hdr)
|
||||
})
|
||||
.collect();
|
||||
Ok(hdr_offer_for(
|
||||
&heads,
|
||||
pf_host_config::config().capture_monitor.as_deref(),
|
||||
))
|
||||
})
|
||||
};
|
||||
match probe() {
|
||||
@@ -91,6 +105,24 @@ pub fn gnome_hdr_monitor_active() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Should this host offer the HDR (10-bit PQ) formats, given each head as `(connector, is_bt2100)`
|
||||
/// and the `PUNKTFUNK_CAPTURE_MONITOR` pin?
|
||||
///
|
||||
/// Pinned: only that head's colour mode counts — an HDR-capable neighbour must not talk the host
|
||||
/// into offering PQ for the SDR panel it is actually streaming. A pin naming no live head reports
|
||||
/// SDR rather than falling back to "any": the session is about to fail on that same missing
|
||||
/// monitor, and an over-claimed HDR offer would be a second, quieter wrong answer.
|
||||
/// Unpinned: the pre-existing "any monitor is in HDR mode" heuristic, unchanged.
|
||||
fn hdr_offer_for(heads: &[(&str, bool)], pinned: Option<&str>) -> bool {
|
||||
match pinned {
|
||||
Some(want) => heads
|
||||
.iter()
|
||||
.find(|(connector, _)| connector.eq_ignore_ascii_case(want))
|
||||
.is_some_and(|(_, hdr)| *hdr),
|
||||
None => heads.iter().any(|(_, hdr)| *hdr),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`).
|
||||
/// With `want_metadata` the ladder prefers **cursor-as-metadata**: the compositor keeps its cheap
|
||||
/// hardware cursor plane and ships the pointer as PipeWire `SPA_META_Cursor` metadata (position +
|
||||
@@ -355,3 +387,33 @@ pub(super) fn portal_thread_remote_desktop(
|
||||
// See `portal_thread`: drop the runtime before the caller's completion signal.
|
||||
drop(rt);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hdr_offer_tests {
|
||||
use super::hdr_offer_for;
|
||||
|
||||
#[test]
|
||||
fn unpinned_keeps_the_any_monitor_heuristic() {
|
||||
assert!(hdr_offer_for(&[("DP-1", false), ("HDMI-A-1", true)], None));
|
||||
assert!(!hdr_offer_for(&[("DP-1", false)], None));
|
||||
}
|
||||
|
||||
/// The regression this exists to prevent: an HDR TV on HDMI while the pinned head is an SDR
|
||||
/// desk monitor. Before scoping, the host offered PQ formats for a panel that can't show them.
|
||||
#[test]
|
||||
fn a_pin_ignores_an_hdr_neighbour() {
|
||||
let heads = [("DP-1", false), ("HDMI-A-1", true)];
|
||||
assert!(!hdr_offer_for(&heads, Some("DP-1")));
|
||||
assert!(hdr_offer_for(&heads, Some("HDMI-A-1")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pin_matches_case_insensitively_like_the_resolver() {
|
||||
assert!(hdr_offer_for(&[("HDMI-A-1", true)], Some("hdmi-a-1")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pin_naming_no_live_head_reports_sdr() {
|
||||
assert!(!hdr_offer_for(&[("DP-1", true)], Some("DP-9")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,10 +245,31 @@ impl Drop for KeyedMutexGuard<'_> {
|
||||
/// broker duplicates sensitive handles into it. The pid is driver-reported (the frame channel's
|
||||
/// [`control::AddReply::wudf_pid`], or the gamepad bootstrap's `driver_pid`); a spoofed devnode / a
|
||||
/// tampered mailbox could name an arbitrary process to receive the channel, so this is the
|
||||
/// confused-deputy gate. Best-effort image-path identity is proportionate: a fully-compromised REAL
|
||||
/// driver is already a channel endpoint, and any *other* process (attacker exe, a non-driver pid)
|
||||
/// fails this WUDFHost image check. `what` names the channel in the error (e.g. `"frame-channel"`);
|
||||
/// shared with the gamepad sealed channel (`inject/windows/gamepad_raii.rs`).
|
||||
/// confused-deputy gate. `what` names the channel in the error (e.g. `"frame-channel"`); shared with
|
||||
/// the gamepad sealed channel (`inject/windows/gamepad_raii.rs`).
|
||||
///
|
||||
/// # What this does and does NOT prove (security-review 2026-07-28)
|
||||
///
|
||||
/// It proves the target's image is the system WUDFHost binary. It does **not** prove the target is
|
||||
/// *the* WUDFHost hosting our devnode, and it is not an authorization check: `WUDFHost.exe` is
|
||||
/// world-executable, so anyone who can name a pid to a broker can first spawn their own copy (e.g.
|
||||
/// `CREATE_SUSPENDED`, which parks it indefinitely with the right image path) and pass this check.
|
||||
/// It therefore only screens out *non-WUDFHost* pids — an attacker exe, a stale/wrong devnode.
|
||||
///
|
||||
/// Whether that is sufficient depends entirely on who can name the pid, so it must be judged at each
|
||||
/// caller, NOT here:
|
||||
/// - **Frame channel** — sufficient. The pid is `AddReply::wudf_pid`, which the driver fills with its
|
||||
/// own `GetCurrentProcessId()` and returns over the pf-vdisplay control device, whose DACL is
|
||||
/// `D:P(A;;GA;;;SY)(A;;GA;;;BA)`. Only SYSTEM/Administrators can speak on that channel, and both are
|
||||
/// out of scope by SECURITY.md.
|
||||
/// - **Gamepad/mouse channel** — NOT sufficient on its own. The pid comes from a `Global\` mailbox
|
||||
/// that LocalService can write, so this check does not identify the caller; see the corrected
|
||||
/// analysis and the one-delivery rule in `pf-inject/src/inject/windows/gamepad_raii.rs`.
|
||||
///
|
||||
/// Do not add a token/session/account check here expecting it to fix the gamepad case: a genuine
|
||||
/// UMDF host and a LocalService attacker's spawned WUDFHost are both session 0 and both LocalService,
|
||||
/// so such checks discriminate nothing while risking a false negative that would silently kill
|
||||
/// display capture and every virtual pad.
|
||||
///
|
||||
/// # Safety
|
||||
/// `process` must be a live process handle carrying `PROCESS_QUERY_LIMITED_INFORMATION`.
|
||||
@@ -732,6 +753,18 @@ impl IddPushCapturer {
|
||||
return; // no new sample since last consume
|
||||
}
|
||||
self.desc_seq = seq;
|
||||
// A topology reassert is in flight (the exclusive watchdog announced it): every sample in
|
||||
// this window is potentially the TRANSIENT eviction state, and acting on one recreates
|
||||
// the ring at a mode the reassert's recovery chain (`recreate_ring_in_place`, keyed off
|
||||
// the reassert generation) is about to undo — the field hdr=true→false→true double
|
||||
// recreate. Consume the sample, disarm the debounce, act on nothing; a REAL change that
|
||||
// races the window survives it (the descriptor still differs once the hold clears, and
|
||||
// the poller re-samples in ~250 ms). This also keeps the negotiated-depth pin-back below
|
||||
// from issuing a CCD write mid-eviction, where it would fight the reassert itself.
|
||||
if pf_win_display::topology_churn::held() {
|
||||
self.pending_desc = None;
|
||||
return;
|
||||
}
|
||||
// Two cases re-assert the NEGOTIATED depth instead of following a mid-session "Use HDR"
|
||||
// flip — flip the display back and treat the descriptor as the negotiated state (so the ring
|
||||
// is never recreated at the wrong format):
|
||||
@@ -758,13 +791,8 @@ impl IddPushCapturer {
|
||||
// Reading back immediately can catch a flip that has not settled yet; that costs one
|
||||
// debounce cycle (the poller re-samples in ~250 ms) and never a wrong ring, which is why
|
||||
// this does not block the frame path on a settle poll the way `open_on` does.
|
||||
// SAFETY: both are `unsafe fn`s over CCD DisplayConfig; each takes a copy of the plain
|
||||
// `u32` target id (plus a `bool`), forms no lasting borrow, and returns an owned value.
|
||||
let requested =
|
||||
unsafe { pf_win_display::win_display::set_advanced_color(self.target_id, want) };
|
||||
// SAFETY: as above — a read-only CCD query over a copy of the plain `u32` target id.
|
||||
let observed =
|
||||
unsafe { pf_win_display::win_display::advanced_color_enabled(self.target_id) };
|
||||
let requested = pf_win_display::win_display::set_advanced_color(self.target_id, want);
|
||||
let observed = pf_win_display::win_display::advanced_color_enabled(self.target_id);
|
||||
// A failed READ is not evidence of a failed flip — keep the poller's sample then.
|
||||
now.hdr = observed.unwrap_or(now.hdr);
|
||||
if now.hdr != want && !self.hdr_pin_warned {
|
||||
@@ -1096,9 +1124,7 @@ impl IddPushCapturer {
|
||||
if !self.display_hdr {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `sdr_white_level_scale` is an `unsafe fn` running a read-only CCD query over owned
|
||||
// local buffers; the `Copy` target id crosses by value and it returns an owned value.
|
||||
let queried = unsafe { pf_win_display::win_display::sdr_white_level_scale(self.target_id) };
|
||||
let queried = pf_win_display::win_display::sdr_white_level_scale(self.target_id);
|
||||
self.sdr_white_scale = queried.unwrap_or(self.sdr_white_scale);
|
||||
tracing::info!(
|
||||
target_id = self.target_id,
|
||||
@@ -1797,9 +1823,7 @@ impl Capturer for IddPushCapturer {
|
||||
// driver's stash (measured: new_fps=0 forever after the re-attach). CDS_RESET forces a
|
||||
// real mode-set at the CURRENT mode — the same lever bring-up's ADD path relies on —
|
||||
// and the ring recreate below then re-attaches after that churn, not before it.
|
||||
// SAFETY: `resolve_gdi_name` runs the CCD query FFI over a `Copy` target id (owned
|
||||
// return) — same contract as every sibling call in this file.
|
||||
match unsafe { pf_win_display::win_display::resolve_gdi_name(self.target_id) } {
|
||||
match pf_win_display::win_display::resolve_gdi_name(self.target_id) {
|
||||
Some(gdi) => {
|
||||
if !pf_win_display::win_display::force_mode_reset(&gdi) {
|
||||
tracing::warn!(
|
||||
|
||||
@@ -69,17 +69,13 @@ pub(super) fn kick_dwm_compose(target_id: u32) {
|
||||
let mut pos = POINT::default();
|
||||
// SAFETY: plain FFI; `pos` is a valid out-param for this synchronous call.
|
||||
let have_pos = unsafe { GetCursorPos(&mut pos) }.is_ok();
|
||||
// SAFETY: `source_desktop_rect` only runs the CCD QueryDisplayConfig FFI over owned local
|
||||
// buffers; the `Copy` target id crosses by value.
|
||||
let rect = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
|
||||
let rect = pf_win_display::win_display::source_desktop_rect(target_id);
|
||||
// HID-first (see the doc comment): the registered virtual-mouse kick works from any
|
||||
// session/desktop and wakes an off display. Both geometries come from CCD (global database),
|
||||
// NOT per-session GDI metrics, so the aim is right even from a non-console session. Fall
|
||||
// through to SendInput only when the hook isn't registered / the mouse isn't up.
|
||||
if let (Some(kick), Some(rect)) = (crate::HID_COMPOSE_KICK.get(), rect) {
|
||||
// SAFETY: `desktop_bounds` only runs the CCD QueryDisplayConfig FFI over owned local
|
||||
// buffers.
|
||||
let bounds = unsafe { pf_win_display::win_display::desktop_bounds() };
|
||||
let bounds = pf_win_display::win_display::desktop_bounds();
|
||||
if let Some(bounds) = bounds {
|
||||
if kick(rect, bounds) {
|
||||
return;
|
||||
|
||||
@@ -72,9 +72,7 @@ impl CursorShared {
|
||||
MappedSection { handle: map, view }
|
||||
};
|
||||
// Desktop origin of this monitor's source — for the desktop→frame coordinate shift.
|
||||
// SAFETY: `source_desktop_rect` only runs the CCD QueryDisplayConfig FFI over owned
|
||||
// locals (same call the compose-kick path makes).
|
||||
let rect = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
|
||||
let rect = pf_win_display::win_display::source_desktop_rect(target_id);
|
||||
let origin = rect.map(|(x, y, _w, _h)| (x, y)).unwrap_or((0, 0));
|
||||
Ok(CursorShared {
|
||||
section,
|
||||
|
||||
@@ -187,10 +187,7 @@ fn run(
|
||||
// transient CCD failure must not park the pointer at a `(0, 0, 0, 0)` rect, which would
|
||||
// report every position invisible.
|
||||
//
|
||||
// SAFETY: `source_desktop_rect` is an `unsafe fn` running the read-only CCD
|
||||
// `QueryDisplayConfig` over owned local buffers; the `Copy` target id crosses by value
|
||||
// and it returns owned `(x, y, w, h)` values, borrowing nothing.
|
||||
let fresh = unsafe { pf_win_display::win_display::source_desktop_rect(target_id) };
|
||||
let fresh = pf_win_display::win_display::source_desktop_rect(target_id);
|
||||
if let Some(fresh) = fresh {
|
||||
if fresh != rect {
|
||||
tracing::info!(
|
||||
|
||||
@@ -59,14 +59,10 @@ impl DescriptorPoller {
|
||||
let mut last_slow_log: Option<Instant> = None;
|
||||
while !stop_t.load(Ordering::Relaxed) {
|
||||
let t = Instant::now();
|
||||
// SAFETY: both are read-only CCD queries taking only a copy of the plain `u32`
|
||||
// target id (see their own SAFETY docs); nothing is borrowed across the calls.
|
||||
let (hdr, res) = unsafe {
|
||||
(
|
||||
let (hdr, res) = (
|
||||
pf_win_display::win_display::advanced_color_enabled(target_id),
|
||||
pf_win_display::win_display::active_resolution(target_id),
|
||||
)
|
||||
};
|
||||
);
|
||||
let took = t.elapsed();
|
||||
if took >= Self::SLOW
|
||||
&& last_slow_log.is_none_or(|t| t.elapsed() >= Duration::from_secs(10))
|
||||
|
||||
@@ -281,11 +281,8 @@ impl IddPushCapturer {
|
||||
// a fullscreen game can hold the virtual display at a different mode (esp. across a reconnect), so
|
||||
// matching the actual mode lets the first frame flow instead of being dropped (game-capture bug
|
||||
// GB1). Falls back to the negotiated mode when the CCD read is unavailable.
|
||||
// SAFETY: `active_resolution` is an `unsafe fn` (Win32 CCD `QueryDisplayConfig`) that takes only a
|
||||
// copy of the plain `u32` CCD target id and returns owned `(w, h)` values; it forms no borrows from
|
||||
// us and validates the id internally, returning `None` on any failure (handled by `unwrap_or`).
|
||||
let (w, h) = unsafe { pf_win_display::win_display::active_resolution(target.target_id) }
|
||||
.unwrap_or((pw, ph));
|
||||
let (w, h) =
|
||||
pf_win_display::win_display::active_resolution(target.target_id).unwrap_or((pw, ph));
|
||||
if (w, h) != (pw, ph) {
|
||||
tracing::info!(
|
||||
target_id = target.target_id,
|
||||
|
||||
@@ -115,7 +115,7 @@ impl StallWatch {
|
||||
delivered no frame for the gap; the present path stalled below capture"
|
||||
);
|
||||
if let Some(period) = stall.metronomic {
|
||||
let suspects = pf_win_display::display_events::connected_inactive_externals();
|
||||
let suspects = pf_win_display::display_events::connected_inactive_physicals();
|
||||
let suspects = if suspects.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
@@ -151,10 +151,13 @@ impl StallWatch {
|
||||
display-poller software (the SteelSeries-GG/SignalRGB class — \
|
||||
correlate 'slow display-descriptor poll' lines), or the DWM present \
|
||||
clock (try a different refresh rate). If connected_inactive lists a \
|
||||
display, its standby probing is the prime suspect: unplug it at the \
|
||||
GPU, disable its OSD auto input scan (TVs: instant-on/quick-start + \
|
||||
CEC off), use an HPD-holding adapter/dummy, or keep it active while \
|
||||
streaming"
|
||||
display, its standby servicing is the prime suspect. For a LAPTOP \
|
||||
PANEL (the exclusive isolate deactivated it — the dark-but-connected \
|
||||
head is itself the disturbance on hybrid laptops): keep it active \
|
||||
with `topology: primary`, or try the `pnp_disable_monitors` axis. \
|
||||
For an external display: unplug it at the GPU, disable its OSD auto \
|
||||
input scan (TVs: instant-on/quick-start + CEC off), use an \
|
||||
HPD-holding adapter/dummy, or keep it active while streaming"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
# Stable ids for profiles and host records (profiles.rs) — the OS RNG only, same version the
|
||||
# workspace already resolves for punktfunk-core. No uuid crate: the v4 layout is four lines.
|
||||
rand = "0.9"
|
||||
|
||||
# Gamepads: capture + feedback (full DualSense fidelity — touchpad/motion/triggers/LEDs
|
||||
# need the hidapi driver). Linux links the system SDL3; Windows builds it from source
|
||||
@@ -76,3 +79,6 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
|
||||
# still strictly per-session opt-in (Settings codec pick / PUNKTFUNK_PREFER_PYROWAVE=1).
|
||||
default = ["pyrowave"]
|
||||
pyrowave = ["dep:pyrowave-sys", "dep:ash"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,754 @@
|
||||
//! The `punktfunk://` URL grammar — one parser/emitter for Linux, Windows, the session and
|
||||
//! the CLI (design/client-deep-links.md §2). Swift (`PunktfunkShared/DeepLink.swift`) and
|
||||
//! Kotlin keep their own ports; all three are held together by the shared vector file
|
||||
//! `clients/shared/deeplink-vectors.json`, which this module's tests consume verbatim.
|
||||
//!
|
||||
//! ```text
|
||||
//! punktfunk://connect/<host-ref>[?fp=<64-hex>][&host=<addr[:port]>][&launch=<id>]
|
||||
//! [&profile=<ref>][&name=<label>]
|
||||
//! ```
|
||||
//!
|
||||
//! The invariant the grammar exists to keep: **a URL may only ever do what a click on an
|
||||
//! existing card could do, minus trust decisions.** So it carries *references* to things that
|
||||
//! already exist on this device — a host record, a settings profile, a library id — and never
|
||||
//! values: no resolution, no bitrate, no codec. A web page must not be able to shape a
|
||||
//! session beyond picking among the user's own configurations. `pair` is deliberately not a
|
||||
//! route and never will be; pairing stays an interactive ceremony.
|
||||
//!
|
||||
//! `pf://` parses as an alias so a hand-typed or legacy link still works, but nothing ever
|
||||
//! *emits* or registers it (§2: claiming a two-letter scheme on MSIX/Apple is unconditional
|
||||
//! squatting, and a link that resolves on one platform only is a trap).
|
||||
|
||||
use crate::trust::{KnownHost, KnownHosts};
|
||||
|
||||
/// Hostile-input caps (§8). The total is generous for a real link and small enough that a
|
||||
/// pasted megabyte never reaches the decoder.
|
||||
pub const MAX_URL_LEN: usize = 2048;
|
||||
pub const MAX_HOST_REF_LEN: usize = 128;
|
||||
pub const MAX_LAUNCH_LEN: usize = 128;
|
||||
pub const MAX_PROFILE_LEN: usize = 64;
|
||||
pub const MAX_NAME_LEN: usize = 64;
|
||||
|
||||
/// The default native port, as everywhere else in the clients.
|
||||
pub const DEFAULT_PORT: u16 = 9777;
|
||||
|
||||
/// What the URL asks for. `Wake`/`Browse` are reserved in the grammar and parse today; a
|
||||
/// front-end that hasn't implemented them refuses with a notice rather than silently
|
||||
/// connecting — the grammar is the contract, per-platform support is not.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Route {
|
||||
/// The default, and the only route an emitter builds today.
|
||||
#[default]
|
||||
Connect,
|
||||
Wake,
|
||||
Browse,
|
||||
}
|
||||
|
||||
impl Route {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Route::Connect => "connect",
|
||||
Route::Wake => "wake",
|
||||
Route::Browse => "browse",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed, validated link. Every field is already length- and charset-checked, so a
|
||||
/// consumer never has to re-validate hostile input; what it still has to do is *resolve*
|
||||
/// (§3): the references may name things that don't exist here.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub struct DeepLink {
|
||||
pub route: Route,
|
||||
/// The host reference as written: a stable record id, a host name, or `addr[:port]`.
|
||||
pub host_ref: String,
|
||||
/// Expected host certificate fingerprint, lowercase hex (64 chars).
|
||||
pub fp: Option<String>,
|
||||
/// Recovery address for a stable id that no longer resolves (store wiped, reinstall).
|
||||
pub host: Option<(String, u16)>,
|
||||
/// A store-qualified library id (`steam:570`) for the host to launch on arrival.
|
||||
pub launch: Option<String>,
|
||||
/// A settings-profile reference (id, or a unique name) — one-off, never rebinding.
|
||||
pub profile: Option<String>,
|
||||
/// Display label for the unknown-host confirmation sheet (external emitters).
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// Why a URL was rejected. The `code` strings are the cross-language contract (the vector
|
||||
/// file names them) — Swift and Kotlin report the same code for the same input, which is what
|
||||
/// keeps three parsers from drifting into three different security postures.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ParseError {
|
||||
/// Not a `punktfunk://` (or `pf://`) URL at all — the caller should ignore it, not warn.
|
||||
NotOurScheme,
|
||||
TooLong,
|
||||
/// A route this grammar doesn't define.
|
||||
UnknownRoute(String),
|
||||
/// `punktfunk://pair/…` — pairing is an interactive ceremony, never a link (§2).
|
||||
PairRefused,
|
||||
MissingHostRef,
|
||||
/// A `%` escape that isn't two hex digits, or a decode that isn't UTF-8.
|
||||
BadEscape,
|
||||
/// A control character survived decoding — no legitimate field contains one.
|
||||
ControlChar,
|
||||
/// A parameter past its cap; carries the parameter name.
|
||||
ParamTooLong(&'static str),
|
||||
/// `fp=` that isn't 64 hex characters.
|
||||
BadFingerprint,
|
||||
/// `host=` that isn't `addr[:port]` with a parsable port.
|
||||
BadHostParam,
|
||||
/// `launch=` outside the printable, shell-safe id charset the host and Decky agree on.
|
||||
BadLaunchId,
|
||||
}
|
||||
|
||||
impl ParseError {
|
||||
/// The stable code shared with the Swift/Kotlin ports and the vector file.
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
ParseError::NotOurScheme => "not-our-scheme",
|
||||
ParseError::TooLong => "too-long",
|
||||
ParseError::UnknownRoute(_) => "unknown-route",
|
||||
ParseError::PairRefused => "pair-refused",
|
||||
ParseError::MissingHostRef => "missing-host-ref",
|
||||
ParseError::BadEscape => "bad-escape",
|
||||
ParseError::ControlChar => "control-char",
|
||||
ParseError::ParamTooLong(_) => "param-too-long",
|
||||
ParseError::BadFingerprint => "bad-fingerprint",
|
||||
ParseError::BadHostParam => "bad-host-param",
|
||||
ParseError::BadLaunchId => "bad-launch-id",
|
||||
}
|
||||
}
|
||||
|
||||
/// A sentence for the notice a refusing front-end shows. Deliberately names the failing
|
||||
/// reference: "a shortcut that can't honor its profile says so instead of streaming with
|
||||
/// the wrong settings" (§10.6) applies to every refusal here.
|
||||
pub fn message(&self) -> String {
|
||||
match self {
|
||||
ParseError::NotOurScheme => "That isn't a Punktfunk link.".into(),
|
||||
ParseError::TooLong => "That link is too long to be genuine.".into(),
|
||||
ParseError::UnknownRoute(r) => format!("Punktfunk links can't do \"{r}\"."),
|
||||
ParseError::PairRefused => {
|
||||
"Pairing can't be done from a link — pair the host in Punktfunk first.".into()
|
||||
}
|
||||
ParseError::MissingHostRef => "That link doesn't say which host to use.".into(),
|
||||
ParseError::BadEscape | ParseError::ControlChar => {
|
||||
"That link is malformed and was ignored.".into()
|
||||
}
|
||||
ParseError::ParamTooLong(p) => format!("That link's \"{p}\" value is too long."),
|
||||
ParseError::BadFingerprint => "That link's host fingerprint isn't a valid one.".into(),
|
||||
ParseError::BadHostParam => "That link's host address isn't valid.".into(),
|
||||
ParseError::BadLaunchId => "That link's game id isn't a valid one.".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `punktfunk://` (or `pf://`) URL. Everything hostile is rejected here, once, for
|
||||
/// every front-end: over-long input, malformed escapes, control characters, out-of-charset
|
||||
/// launch ids and fingerprints that aren't fingerprints.
|
||||
pub fn parse(url: &str) -> Result<DeepLink, ParseError> {
|
||||
if url.len() > MAX_URL_LEN {
|
||||
return Err(ParseError::TooLong);
|
||||
}
|
||||
let (scheme, rest) = url.split_once("://").ok_or(ParseError::NotOurScheme)?;
|
||||
if !scheme.eq_ignore_ascii_case("punktfunk") && !scheme.eq_ignore_ascii_case("pf") {
|
||||
return Err(ParseError::NotOurScheme);
|
||||
}
|
||||
// A fragment is never part of this grammar; drop it rather than folding it into the last
|
||||
// parameter (where it would smuggle unvalidated text past the caps).
|
||||
let rest = rest.split('#').next().unwrap_or("");
|
||||
let (path, query) = match rest.split_once('?') {
|
||||
Some((p, q)) => (p, q),
|
||||
None => (rest, ""),
|
||||
};
|
||||
|
||||
let path = path.trim_end_matches('/');
|
||||
let (route_word, host_ref_raw) = match path.split_once('/') {
|
||||
Some((r, h)) => (r, h),
|
||||
// A single segment: Apple's shipped links are always `connect/<uuid>`, but a bare
|
||||
// reference is unambiguous as long as it isn't one of the route words — those stay
|
||||
// routes (with a missing reference), so `punktfunk://pair` refuses instead of hunting
|
||||
// for a host called "pair".
|
||||
None if is_route_word(path) => (path, ""),
|
||||
None => ("connect", path),
|
||||
};
|
||||
let route = match route_word.to_ascii_lowercase().as_str() {
|
||||
"connect" => Route::Connect,
|
||||
"wake" => Route::Wake,
|
||||
"browse" => Route::Browse,
|
||||
"pair" => return Err(ParseError::PairRefused),
|
||||
other => return Err(ParseError::UnknownRoute(other.to_string())),
|
||||
};
|
||||
|
||||
let host_ref = decode(host_ref_raw)?;
|
||||
if host_ref.is_empty() {
|
||||
return Err(ParseError::MissingHostRef);
|
||||
}
|
||||
if host_ref.chars().count() > MAX_HOST_REF_LEN {
|
||||
return Err(ParseError::ParamTooLong("host-ref"));
|
||||
}
|
||||
|
||||
let mut link = DeepLink {
|
||||
route,
|
||||
host_ref,
|
||||
..Default::default()
|
||||
};
|
||||
for pair in query.split('&').filter(|s| !s.is_empty()) {
|
||||
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
|
||||
let key = decode(key)?.to_ascii_lowercase();
|
||||
let value = decode(value)?;
|
||||
if value.is_empty() {
|
||||
continue; // `?launch=` with nothing after it is "not given", not an error.
|
||||
}
|
||||
// First occurrence wins, and unknown keys are ignored: a newer emitter's parameter
|
||||
// must not turn an otherwise valid link into a refusal, and appending a second `fp=`
|
||||
// must not be able to override the first.
|
||||
match key.as_str() {
|
||||
"fp" if link.fp.is_none() => {
|
||||
let fp = value.to_ascii_lowercase();
|
||||
if fp.len() != 64 || !fp.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(ParseError::BadFingerprint);
|
||||
}
|
||||
link.fp = Some(fp);
|
||||
}
|
||||
"host" if link.host.is_none() => {
|
||||
link.host = Some(parse_addr_port(&value).ok_or(ParseError::BadHostParam)?);
|
||||
}
|
||||
"launch" if link.launch.is_none() => {
|
||||
if value.len() > MAX_LAUNCH_LEN {
|
||||
return Err(ParseError::ParamTooLong("launch"));
|
||||
}
|
||||
if !is_safe_launch_id(&value) {
|
||||
return Err(ParseError::BadLaunchId);
|
||||
}
|
||||
link.launch = Some(value);
|
||||
}
|
||||
"profile" if link.profile.is_none() => {
|
||||
if value.chars().count() > MAX_PROFILE_LEN {
|
||||
return Err(ParseError::ParamTooLong("profile"));
|
||||
}
|
||||
link.profile = Some(value);
|
||||
}
|
||||
"name" if link.name.is_none() => {
|
||||
if value.chars().count() > MAX_NAME_LEN {
|
||||
return Err(ParseError::ParamTooLong("name"));
|
||||
}
|
||||
link.name = Some(value);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
impl DeepLink {
|
||||
/// The canonical URL for this link — always `punktfunk://`, never the `pf://` alias.
|
||||
/// Self-emitted links carry the stable id AND `host`+`fp`, so a shortcut written today
|
||||
/// still resolves after a reinstall wipes the store (§2, §5).
|
||||
pub fn to_url(&self) -> String {
|
||||
let mut s = format!(
|
||||
"punktfunk://{}/{}",
|
||||
self.route.as_str(),
|
||||
encode(&self.host_ref)
|
||||
);
|
||||
let mut sep = '?';
|
||||
let mut push = |s: &mut String, key: &str, value: &str| {
|
||||
s.push(sep);
|
||||
sep = '&';
|
||||
s.push_str(key);
|
||||
s.push('=');
|
||||
s.push_str(&encode(value));
|
||||
};
|
||||
if let Some(fp) = &self.fp {
|
||||
push(&mut s, "fp", fp);
|
||||
}
|
||||
if let Some((addr, port)) = &self.host {
|
||||
let host = if *port == DEFAULT_PORT {
|
||||
addr.clone()
|
||||
} else if addr.contains(':') {
|
||||
format!("[{addr}]:{port}") // literal IPv6 needs its brackets back
|
||||
} else {
|
||||
format!("{addr}:{port}")
|
||||
};
|
||||
push(&mut s, "host", &host);
|
||||
}
|
||||
if let Some(launch) = &self.launch {
|
||||
push(&mut s, "launch", launch);
|
||||
}
|
||||
if let Some(profile) = &self.profile {
|
||||
push(&mut s, "profile", profile);
|
||||
}
|
||||
if let Some(name) = &self.name {
|
||||
push(&mut s, "name", name);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn for_host(host: &KnownHost, launch: Option<&str>, profile: Option<&str>) -> DeepLink {
|
||||
DeepLink {
|
||||
route: Route::Connect,
|
||||
host_ref: host
|
||||
.id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}:{}", host.addr, host.port)),
|
||||
fp: (!host.fp_hex.is_empty()).then(|| host.fp_hex.clone()),
|
||||
host: Some((host.addr.clone(), host.port)),
|
||||
launch: launch.map(str::to_string),
|
||||
profile: profile.map(str::to_string),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this link's `fp` contradicts what we have pinned for that host — the link is
|
||||
/// stale or lying, and the only safe answer is a hard refusal (§3.1).
|
||||
pub fn pin_conflict(&self, host: &KnownHost) -> bool {
|
||||
match (&self.fp, host.fp_hex.is_empty()) {
|
||||
(Some(fp), false) => !fp.eq_ignore_ascii_case(&host.fp_hex),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the local host store made of a link's references.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum HostResolution {
|
||||
/// Index into `KnownHosts::hosts` — a record we already trust (subject to
|
||||
/// [`DeepLink::pin_conflict`]).
|
||||
Known(usize),
|
||||
/// No record, but the link says where to dial: the confirmation sheet's input, from which
|
||||
/// the normal pairing/TOFU flow proceeds under the user's eyes. Never an auto-connect.
|
||||
Unknown {
|
||||
addr: String,
|
||||
port: u16,
|
||||
name: Option<String>,
|
||||
fp: Option<String>,
|
||||
},
|
||||
/// The name matched more than one saved host — refuse with a notice, never guess (§8).
|
||||
Ambiguous,
|
||||
/// A reference that resolves to nothing and carries no address to fall back on.
|
||||
Unresolvable,
|
||||
}
|
||||
|
||||
/// Resolve a link's host reference against the local store, in the documented order: stable
|
||||
/// record id → unique case-insensitive name → `addr[:port]` literal. The `host=` parameter is
|
||||
/// the recovery path — a self-emitted shortcut that outlived the record it was written from
|
||||
/// still lands on the right box (degraded to the confirmation sheet).
|
||||
///
|
||||
/// Returns an index rather than a borrow so callers can keep mutating the store (rekey,
|
||||
/// touch-last-used) without fighting the borrow checker.
|
||||
pub fn resolve_host(link: &DeepLink, known: &KnownHosts) -> HostResolution {
|
||||
if let Some(i) = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| h.id.as_deref().is_some_and(|id| id == link.host_ref))
|
||||
{
|
||||
return HostResolution::Known(i);
|
||||
}
|
||||
let by_name: Vec<usize> = known
|
||||
.hosts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, h)| h.name.eq_ignore_ascii_case(&link.host_ref))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
match by_name.len() {
|
||||
1 => return HostResolution::Known(by_name[0]),
|
||||
0 => {}
|
||||
_ => return HostResolution::Ambiguous,
|
||||
}
|
||||
// `addr[:port]` literal, then the `host=` recovery parameter — both matched the way every
|
||||
// other per-host lookup in the client matches (addr + port). The literal is only
|
||||
// considered when the reference could BE an address: a stale record id must fall through
|
||||
// to `host=` (or to a refusal), never be offered as a box to dial.
|
||||
let literal = looks_like_address(&link.host_ref)
|
||||
.then(|| parse_addr_port(&link.host_ref))
|
||||
.flatten();
|
||||
for candidate in [literal.clone(), link.host.clone()].into_iter().flatten() {
|
||||
if let Some(i) = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| h.addr == candidate.0 && h.port == candidate.1)
|
||||
{
|
||||
return HostResolution::Known(i);
|
||||
}
|
||||
}
|
||||
match literal.or_else(|| link.host.clone()) {
|
||||
Some((addr, port)) => HostResolution::Unknown {
|
||||
addr,
|
||||
port,
|
||||
name: link.name.clone(),
|
||||
fp: link.fp.clone(),
|
||||
},
|
||||
None => HostResolution::Unresolvable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Could this reference be a network address (an IP literal or a host name) rather than a
|
||||
/// record id or a display name? Only then may an unmatched reference become "an unknown host
|
||||
/// at this address" for the confirmation sheet. A stable id that no longer resolves is NOT an
|
||||
/// address: offering to dial a UUID as a hostname would turn a wiped store into a confusing
|
||||
/// dead end instead of the `host=`-driven recovery §2 specifies.
|
||||
fn looks_like_address(s: &str) -> bool {
|
||||
let uuid_shaped = s.len() == 36
|
||||
&& s.char_indices().all(|(i, c)| match i {
|
||||
8 | 13 | 18 | 23 => c == '-',
|
||||
_ => c.is_ascii_hexdigit(),
|
||||
});
|
||||
!uuid_shaped
|
||||
&& !s.is_empty()
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '[' | ']'))
|
||||
}
|
||||
|
||||
/// The reserved first path segments — everything the grammar routes on, plus `pair`, which is
|
||||
/// reserved precisely so it can be refused rather than mistaken for a host name.
|
||||
fn is_route_word(s: &str) -> bool {
|
||||
matches!(
|
||||
s.to_ascii_lowercase().as_str(),
|
||||
"connect" | "wake" | "browse" | "pair"
|
||||
)
|
||||
}
|
||||
|
||||
/// `addr`, `addr:port`, `[v6]`, `[v6]:port` — `None` when the port isn't a number. A bare
|
||||
/// IPv6 literal (`::1`) keeps its colons and takes the default port; anything else splits at
|
||||
/// the last colon, like every other host-parsing site in the clients.
|
||||
fn parse_addr_port(s: &str) -> Option<(String, u16)> {
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(rest) = s.strip_prefix('[') {
|
||||
let (addr, tail) = rest.split_once(']')?;
|
||||
if addr.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return match tail {
|
||||
"" => Some((addr.to_string(), DEFAULT_PORT)),
|
||||
t => Some((addr.to_string(), t.strip_prefix(':')?.parse().ok()?)),
|
||||
};
|
||||
}
|
||||
match s.rsplit_once(':') {
|
||||
// `::1` and friends: the head still has a colon, so this isn't a port separator.
|
||||
Some((head, _)) if head.contains(':') => Some((s.to_string(), DEFAULT_PORT)),
|
||||
Some((addr, port)) if !addr.is_empty() => Some((addr.to_string(), port.parse().ok()?)),
|
||||
Some(_) => None,
|
||||
None => Some((s.to_string(), DEFAULT_PORT)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The launch-id charset the whole product already agrees on: printable, non-space ASCII with
|
||||
/// no shell metacharacters (Decky rides ids through Steam launch options as an env token, so
|
||||
/// a quote or a backtick genuinely breaks something downstream). Validation only — the id is
|
||||
/// opaque and the host matches it verbatim against its own library.
|
||||
fn is_safe_launch_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| (0x21..=0x7e).contains(&b) && !br#""'\$`"#.contains(&b))
|
||||
}
|
||||
|
||||
/// Strict percent-decoding: `%` must be followed by exactly two hex digits, the result must be
|
||||
/// UTF-8, and no control character may survive. Lenient decoders are how `%00`, a stray `\n`
|
||||
/// or a half-escape end up inside a filename or a log line.
|
||||
fn decode(s: &str) -> Result<String, ParseError> {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'%' => {
|
||||
let hex = bytes.get(i + 1..i + 3).ok_or(ParseError::BadEscape)?;
|
||||
let hi = (hex[0] as char).to_digit(16).ok_or(ParseError::BadEscape)?;
|
||||
let lo = (hex[1] as char).to_digit(16).ok_or(ParseError::BadEscape)?;
|
||||
out.push((hi * 16 + lo) as u8);
|
||||
i += 3;
|
||||
}
|
||||
b => {
|
||||
out.push(b);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let text = String::from_utf8(out).map_err(|_| ParseError::BadEscape)?;
|
||||
if text.chars().any(|c| c.is_control()) {
|
||||
return Err(ParseError::ControlChar);
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Percent-encode for emission: unreserved characters plus `:` (legal in a query value and
|
||||
/// left alone by Apple's `URLComponents`, so the three emitters agree on `steam:570`).
|
||||
fn encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b':' => {
|
||||
out.push(b as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::trust::KnownHost;
|
||||
|
||||
fn host(name: &str, addr: &str, id: &str, fp: &str) -> KnownHost {
|
||||
KnownHost {
|
||||
name: name.into(),
|
||||
addr: addr.into(),
|
||||
port: DEFAULT_PORT,
|
||||
fp_hex: fp.into(),
|
||||
paired: true,
|
||||
id: Some(id.into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every case in the cross-language vector file, which the Swift and Kotlin ports consume
|
||||
/// too — this is what keeps three parsers from drifting into three security postures.
|
||||
#[test]
|
||||
fn shared_vectors() {
|
||||
let raw = include_str!("../../../clients/shared/deeplink-vectors.json");
|
||||
let file: serde_json::Value = serde_json::from_str(raw).expect("vector file parses");
|
||||
let cases = file["cases"].as_array().expect("cases array");
|
||||
assert!(
|
||||
cases.len() > 20,
|
||||
"the vector file is the contract; keep it rich"
|
||||
);
|
||||
for case in cases {
|
||||
let name = case["name"].as_str().unwrap();
|
||||
let url = case["url"].as_str().unwrap();
|
||||
let got = parse(url);
|
||||
match case.get("error").and_then(|e| e.as_str()) {
|
||||
Some(code) => {
|
||||
let err = got.expect_err(&format!("{name}: expected {code}, parsed ok"));
|
||||
assert_eq!(err.code(), code, "{name}");
|
||||
}
|
||||
None => {
|
||||
let link = got.unwrap_or_else(|e| panic!("{name}: {e:?}"));
|
||||
let want = &case["expect"];
|
||||
assert_eq!(
|
||||
link.route.as_str(),
|
||||
want["route"].as_str().unwrap(),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(link.host_ref, want["host_ref"].as_str().unwrap(), "{name}");
|
||||
let opt = |k: &str| want.get(k).and_then(|v| v.as_str()).map(str::to_string);
|
||||
assert_eq!(link.fp, opt("fp"), "{name} fp");
|
||||
assert_eq!(link.launch, opt("launch"), "{name} launch");
|
||||
assert_eq!(link.profile, opt("profile"), "{name} profile");
|
||||
assert_eq!(link.name, opt("name"), "{name} name");
|
||||
let (addr, port) = match &link.host {
|
||||
Some((a, p)) => (Some(a.clone()), Some(u64::from(*p))),
|
||||
None => (None, None),
|
||||
};
|
||||
assert_eq!(addr, opt("host_addr"), "{name} host_addr");
|
||||
assert_eq!(
|
||||
port,
|
||||
want.get("host_port").and_then(|v| v.as_u64()),
|
||||
"{name} host_port"
|
||||
);
|
||||
if let Some(emit) = case.get("emit").and_then(|v| v.as_str()) {
|
||||
assert_eq!(link.to_url(), emit, "{name} emit");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Result` from the parser is not enough on its own: the codes are the shared
|
||||
/// vocabulary, so they must be exactly what the vector file (and the ports) name.
|
||||
#[test]
|
||||
fn refusals_are_specific() {
|
||||
assert_eq!(parse("https://example.com/"), Err(ParseError::NotOurScheme));
|
||||
assert_eq!(parse("punktfunk:/connect/x"), Err(ParseError::NotOurScheme));
|
||||
assert_eq!(
|
||||
parse(&format!("punktfunk://connect/{}", "a".repeat(MAX_URL_LEN))),
|
||||
Err(ParseError::TooLong)
|
||||
);
|
||||
assert_eq!(parse("punktfunk://pair/1234"), Err(ParseError::PairRefused));
|
||||
assert_eq!(
|
||||
parse("punktfunk://teardown/host"),
|
||||
Err(ParseError::UnknownRoute("teardown".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse("punktfunk://connect/"),
|
||||
Err(ParseError::MissingHostRef)
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&format!(
|
||||
"punktfunk://connect/{}",
|
||||
"n".repeat(MAX_HOST_REF_LEN + 1)
|
||||
)),
|
||||
Err(ParseError::ParamTooLong("host-ref"))
|
||||
);
|
||||
}
|
||||
|
||||
/// The one-click contract in resolution form: an id beats a name beats an address, an
|
||||
/// ambiguous name refuses, and a link whose record is gone still lands on the
|
||||
/// confirmation sheet via `host=`+`fp=` instead of dying.
|
||||
#[test]
|
||||
fn host_resolution_order_and_recovery() {
|
||||
let fp = "a".repeat(64);
|
||||
let known = KnownHosts {
|
||||
hosts: vec![
|
||||
host(
|
||||
"Desk",
|
||||
"192.168.1.50",
|
||||
"11111111-2222-4333-8444-555555555555",
|
||||
&fp,
|
||||
),
|
||||
host(
|
||||
"Couch",
|
||||
"192.168.1.60",
|
||||
"66666666-7777-4888-8999-aaaaaaaaaaaa",
|
||||
"",
|
||||
),
|
||||
host(
|
||||
"Couch",
|
||||
"192.168.1.61",
|
||||
"bbbbbbbb-cccc-4ddd-8eee-ffffffffffff",
|
||||
"",
|
||||
),
|
||||
],
|
||||
};
|
||||
let r = |url: &str| resolve_host(&parse(url).unwrap(), &known);
|
||||
|
||||
assert_eq!(
|
||||
r("punktfunk://connect/11111111-2222-4333-8444-555555555555"),
|
||||
HostResolution::Known(0)
|
||||
);
|
||||
assert_eq!(r("punktfunk://connect/desk"), HostResolution::Known(0));
|
||||
assert_eq!(r("punktfunk://connect/couch"), HostResolution::Ambiguous);
|
||||
assert_eq!(
|
||||
r("punktfunk://connect/192.168.1.50"),
|
||||
HostResolution::Known(0)
|
||||
);
|
||||
assert_eq!(
|
||||
r("punktfunk://connect/192.168.1.50:9777"),
|
||||
HostResolution::Known(0)
|
||||
);
|
||||
// A stale id with the recovery parameters: the address finds the record anyway.
|
||||
assert_eq!(
|
||||
r("punktfunk://connect/00000000-0000-4000-8000-000000000000?host=192.168.1.50"),
|
||||
HostResolution::Known(0)
|
||||
);
|
||||
// Nothing local matches: the sheet gets the address, the claimed name and the pin —
|
||||
// which is what makes the first connect verified rather than blind TOFU.
|
||||
assert_eq!(
|
||||
r(&format!(
|
||||
"punktfunk://connect/10.0.0.9:7000?name=Studio&fp={fp}"
|
||||
)),
|
||||
HostResolution::Unknown {
|
||||
addr: "10.0.0.9".into(),
|
||||
port: 7000,
|
||||
name: Some("Studio".into()),
|
||||
fp: Some(fp.clone()),
|
||||
}
|
||||
);
|
||||
// An unmatched reference that could be an address (an mDNS/DNS name, a new IP) is
|
||||
// offered as an unknown host — the sheet, never an auto-connect.
|
||||
assert_eq!(
|
||||
r("punktfunk://connect/nas.local"),
|
||||
HostResolution::Unknown {
|
||||
addr: "nas.local".into(),
|
||||
port: DEFAULT_PORT,
|
||||
name: None,
|
||||
fp: None,
|
||||
}
|
||||
);
|
||||
// But a stale record id is not a hostname: without `host=` there is nothing to dial,
|
||||
// and dialing "11111111-…" would be a confusing dead end rather than a recovery.
|
||||
assert_eq!(
|
||||
r("punktfunk://connect/00000000-0000-4000-8000-000000000000"),
|
||||
HostResolution::Unresolvable
|
||||
);
|
||||
// Neither is a display name that can't be an address.
|
||||
assert_eq!(
|
||||
r("punktfunk://connect/Basement%20PC"),
|
||||
HostResolution::Unresolvable
|
||||
);
|
||||
|
||||
// A pin that contradicts the stored one is the link lying — the caller hard-refuses.
|
||||
let link = parse(&format!("punktfunk://connect/desk?fp={}", "b".repeat(64))).unwrap();
|
||||
assert!(link.pin_conflict(&known.hosts[0]));
|
||||
assert!(!parse(&format!("punktfunk://connect/desk?fp={fp}"))
|
||||
.unwrap()
|
||||
.pin_conflict(&known.hosts[0]));
|
||||
// No pin stored (an address-only record) → nothing to contradict; the trust flow runs.
|
||||
assert!(!link.pin_conflict(&known.hosts[1]));
|
||||
}
|
||||
|
||||
/// Self-emitted links round-trip and carry all three references, so they survive both a
|
||||
/// re-addressed host and a wiped store.
|
||||
#[test]
|
||||
fn self_emitted_links_round_trip() {
|
||||
let fp = "c".repeat(64);
|
||||
let mut h = host(
|
||||
"Desk",
|
||||
"192.168.1.50",
|
||||
"11111111-2222-4333-8444-555555555555",
|
||||
&fp,
|
||||
);
|
||||
h.port = 7777;
|
||||
let link = DeepLink::for_host(&h, Some("steam:570"), Some("aaaaaaaaaaaa"));
|
||||
let url = link.to_url();
|
||||
assert_eq!(
|
||||
url,
|
||||
"punktfunk://connect/11111111-2222-4333-8444-555555555555\
|
||||
?fp=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\
|
||||
&host=192.168.1.50:7777&launch=steam:570&profile=aaaaaaaaaaaa"
|
||||
);
|
||||
assert_eq!(parse(&url).unwrap(), link);
|
||||
|
||||
// A record with no id yet (pre-migration store) still emits something resolvable.
|
||||
let mut plain = h.clone();
|
||||
plain.id = None;
|
||||
assert_eq!(
|
||||
DeepLink::for_host(&plain, None, None).host_ref,
|
||||
"192.168.1.50:7777"
|
||||
);
|
||||
|
||||
// Names with spaces and non-ASCII survive the round trip.
|
||||
let link = DeepLink {
|
||||
host_ref: "Wohnzimmer PC".into(),
|
||||
name: Some("Büro · Mac".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(link
|
||||
.to_url()
|
||||
.starts_with("punktfunk://connect/Wohnzimmer%20PC?"));
|
||||
assert_eq!(parse(&link.to_url()).unwrap(), link);
|
||||
}
|
||||
|
||||
/// `addr[:port]` parsing, including the bracketed IPv6 forms a link can carry.
|
||||
#[test]
|
||||
fn addr_port_forms() {
|
||||
assert_eq!(
|
||||
parse_addr_port("192.168.1.5"),
|
||||
Some(("192.168.1.5".into(), 9777))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_addr_port("192.168.1.5:1234"),
|
||||
Some(("192.168.1.5".into(), 1234))
|
||||
);
|
||||
assert_eq!(parse_addr_port("::1"), Some(("::1".into(), 9777)));
|
||||
assert_eq!(parse_addr_port("[::1]"), Some(("::1".into(), 9777)));
|
||||
assert_eq!(parse_addr_port("[::1]:1234"), Some(("::1".into(), 1234)));
|
||||
assert_eq!(parse_addr_port("host:notaport"), None);
|
||||
assert_eq!(parse_addr_port("[::1]junk"), None);
|
||||
assert_eq!(parse_addr_port(""), None);
|
||||
// An emitted IPv6 host parameter comes back bracketed so it parses again.
|
||||
let link = DeepLink {
|
||||
host_ref: "x".into(),
|
||||
host: Some(("::1".into(), 1234)),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(parse(&link.to_url()).unwrap().host, link.host);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,19 @@ pub mod gamepad;
|
||||
pub mod keymap;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod library;
|
||||
// The `punktfunk://` grammar (design/client-deep-links.md §2): one parser/emitter for the
|
||||
// shells, the session and the CLI, held to the Swift/Kotlin ports by a shared vector file.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod deeplink;
|
||||
// The brain layer (design/client-architecture-split.md §3): what a connect is, the wake
|
||||
// state machine every front-end drives, and the session spawn + stdout contract.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod orchestrate;
|
||||
// Client settings profiles: the override catalog + the one connect-time resolver
|
||||
// (design/client-settings-profiles.md §4). Sits beside `trust`, which owns the host records
|
||||
// the bindings live on.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod profiles;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod session;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
@@ -37,6 +50,9 @@ pub mod video;
|
||||
mod video_color;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod video_software;
|
||||
// libav ownership helpers shared by the hardware decoders below (`AvBuffer`).
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod video_libav;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod video_vaapi;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
|
||||
@@ -0,0 +1,840 @@
|
||||
//! The brain layer: what a connect *is*, and the one implementation of how it runs
|
||||
//! (design/client-architecture-split.md §3).
|
||||
//!
|
||||
//! Wake-then-connect exists three times today — GTK's `WakeConnect`/`wake_fallback`, the
|
||||
//! WinUI shell's `wake_and_connect`, Apple's `HostWaker` (whose comment in the Windows copy
|
||||
//! literally says "mirrors the Apple HostWaker") — and the deep-link and profile work would
|
||||
//! have made it five. This module is where that collapses: a [`ConnectPlan`] is built from a
|
||||
//! card click, a CLI verb or a URL (one constructor each, one type out), and the orchestrator
|
||||
//! runs it. Front-ends render; they don't decide.
|
||||
//!
|
||||
//! The split that keeps this honest is [`UiDelegate`]: prompts, progress and error surfaces
|
||||
//! stay in the front-end, because a GTK dialog, a WinUI page, a Skia console screen and a
|
||||
//! terminal prompt genuinely are different things — but *when* to prompt, *how long* to wait
|
||||
//! for a sleeping box and *what counts as a refusal* are decided here, once.
|
||||
//!
|
||||
//! Wake timings are Apple's `HostWaker` verbatim, because it is the implementation that got
|
||||
//! them right: a magic packet is fire-and-forget and a cold box takes 20–60 s to POST, boot
|
||||
//! and re-advertise — far longer than any dial will sit — so the packet is re-sent every 6 s
|
||||
//! (a single one gets missed, and some NICs only wake on a fresh packet after dropping into a
|
||||
//! deeper sleep state), presence is polled once a second, and the whole wait is bounded at
|
||||
//! 90 s, after which it PARKS for retry rather than erroring out from under the user.
|
||||
|
||||
use crate::deeplink::{DeepLink, HostResolution, Route};
|
||||
use crate::profiles::{ProfilesFile, Resolution, StreamProfile};
|
||||
use crate::trust::{effective_settings, KnownHost, KnownHosts, Settings};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// The host a plan dials, flattened out of whichever record or reference produced it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct HostTarget {
|
||||
pub name: String,
|
||||
pub addr: String,
|
||||
pub port: u16,
|
||||
/// The pinned fingerprint. `None` = no pin, which the session binary refuses by design —
|
||||
/// a plan without one may only exist after the front-end's trust ceremony.
|
||||
pub fp_hex: Option<String>,
|
||||
pub mac: Vec<String>,
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&KnownHost> for HostTarget {
|
||||
fn from(h: &KnownHost) -> HostTarget {
|
||||
HostTarget {
|
||||
name: h.name.clone(),
|
||||
addr: h.addr.clone(),
|
||||
port: h.port,
|
||||
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
||||
mac: h.mac.clone(),
|
||||
id: h.id.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A resolved intent: everything needed to start one session, with every policy question
|
||||
/// already answered. Built once, then executed — front-ends don't re-decide any of it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ConnectPlan {
|
||||
pub host: HostTarget,
|
||||
/// Library id for the host to launch on arrival.
|
||||
pub launch: Option<String>,
|
||||
/// The settings profile this connect resolved with (display + the stats overlay).
|
||||
pub profile: Option<StreamProfile>,
|
||||
/// The one-off profile reference to hand the session, if this connect overrides the host's
|
||||
/// binding: `Some(id)` for "Connect with ▸ X", `Some("")` to force the defaults, `None` to
|
||||
/// let the session resolve the host's own binding (the two paths call the same resolver,
|
||||
/// so they cannot disagree).
|
||||
pub profile_override: Option<String>,
|
||||
/// Effective settings — global defaults with the profile overlaid. What the front-end
|
||||
/// reads for anything it needs to know up front (fullscreen, match-window…).
|
||||
pub settings: Settings,
|
||||
/// Send a magic packet up front and fall back to wake-and-wait if the dial fails. Off for
|
||||
/// hosts with no MAC, and when the user turned auto-wake off (VPN hosts look offline when
|
||||
/// they aren't, and the wake+wait only adds delay).
|
||||
pub wake: bool,
|
||||
/// Handshake budget override — the request-access flow passes ~185 s because the host
|
||||
/// PARKS the connection until an operator approves.
|
||||
pub connect_timeout_secs: Option<u64>,
|
||||
/// The pin came from an advert rather than the store: persist it once the session reports
|
||||
/// ready (ready proves the host really holds that identity).
|
||||
pub tofu: bool,
|
||||
}
|
||||
|
||||
impl ConnectPlan {
|
||||
/// The plain card-click plan: this host, its binding (or a one-off profile), its wake
|
||||
/// policy. `one_off_profile` is the "Connect with ▸" pick — `Some("")` forces the global
|
||||
/// defaults on a bound host, `None` honors the binding. Loads the stores; the pure form is
|
||||
/// [`ConnectPlan::resolve`], which a front-end that already holds them should use.
|
||||
pub fn for_host(
|
||||
host: &KnownHost,
|
||||
launch: Option<&str>,
|
||||
one_off_profile: Option<&str>,
|
||||
) -> ConnectPlan {
|
||||
let (settings, profile) = effective_settings(&host.addr, host.port, one_off_profile);
|
||||
ConnectPlan {
|
||||
host: HostTarget::from(host),
|
||||
launch: launch.map(str::to_string),
|
||||
profile,
|
||||
profile_override: one_off_profile.map(str::to_string),
|
||||
wake: settings.auto_wake && !host.mac.is_empty(),
|
||||
settings,
|
||||
connect_timeout_secs: None,
|
||||
tofu: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same plan, built from stores the caller already has — no disk, no clock, no
|
||||
/// environment. This is the form the URL router uses: a front-end loads the three stores
|
||||
/// once per event and every decision below is a pure function of them.
|
||||
///
|
||||
/// Profile precedence is the design's, unchanged: the one-off pick, else the host's
|
||||
/// binding, else nothing; `Some("")` forces the defaults; anything dangling resolves as
|
||||
/// no profile rather than an error.
|
||||
pub fn resolve(
|
||||
host: &KnownHost,
|
||||
launch: Option<&str>,
|
||||
one_off_profile: Option<&str>,
|
||||
catalog: &ProfilesFile,
|
||||
base: &Settings,
|
||||
) -> ConnectPlan {
|
||||
let profile = match one_off_profile {
|
||||
Some("") => None,
|
||||
Some(reference) => catalog.resolve(reference).0.cloned(),
|
||||
None => host
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.and_then(|id| catalog.find_by_id(id))
|
||||
.cloned(),
|
||||
};
|
||||
let settings = match &profile {
|
||||
Some(p) => p.overrides.apply(base),
|
||||
None => base.clone(),
|
||||
};
|
||||
ConnectPlan {
|
||||
host: HostTarget::from(host),
|
||||
launch: launch.map(str::to_string),
|
||||
profile,
|
||||
profile_override: one_off_profile.map(str::to_string),
|
||||
wake: settings.auto_wake && !host.mac.is_empty(),
|
||||
settings,
|
||||
connect_timeout_secs: None,
|
||||
tofu: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The session binary's argv for this plan — the one place the flags are assembled, so a
|
||||
/// shell, the CLI and a URL launch cannot spawn subtly different sessions.
|
||||
pub fn session_args(&self) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"--connect".into(),
|
||||
format!("{}:{}", self.host.addr, self.host.port),
|
||||
];
|
||||
if let Some(fp) = &self.host.fp_hex {
|
||||
args.push("--fp".into());
|
||||
args.push(fp.clone());
|
||||
}
|
||||
if let Some(launch) = &self.launch {
|
||||
args.push("--launch".into());
|
||||
args.push(launch.clone());
|
||||
}
|
||||
// Only a one-off rides the flag: without it the session resolves the host's own
|
||||
// binding through the same helper this plan used.
|
||||
if let Some(profile) = &self.profile_override {
|
||||
args.push("--profile".into());
|
||||
args.push(profile.clone());
|
||||
}
|
||||
if let Some(secs) = self.connect_timeout_secs {
|
||||
args.push("--connect-timeout".into());
|
||||
args.push(secs.to_string());
|
||||
}
|
||||
if self.settings.fullscreen_on_stream {
|
||||
args.push("--fullscreen".into());
|
||||
}
|
||||
args
|
||||
}
|
||||
}
|
||||
|
||||
/// What a URL turned into. Everything a front-end must not decide for itself lives in this
|
||||
/// enum: an unknown host is a *prompt*, never a connect, and a route this build doesn't do is
|
||||
/// a notice, never a silent no-op.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PlanOutcome {
|
||||
Connect(Box<ConnectPlan>),
|
||||
/// The link resolved to no local record. The front-end shows the confirmation sheet with
|
||||
/// exactly this, and the normal pairing/TOFU flow proceeds under the user's eyes (§3.1).
|
||||
ConfirmUnknown(Box<UnknownHost>),
|
||||
/// A route the grammar defines but this front-end hasn't implemented yet.
|
||||
Unsupported(Route),
|
||||
}
|
||||
|
||||
/// The confirmation sheet's contents for a link to a host we don't know.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UnknownHost {
|
||||
pub addr: String,
|
||||
pub port: u16,
|
||||
/// The label the link claimed — shown as *claimed*, never trusted.
|
||||
pub name: Option<String>,
|
||||
/// The fingerprint the link expects; pre-fills the sheet's pin so the first connect is
|
||||
/// verified rather than blind trust-on-first-use.
|
||||
pub fp: Option<String>,
|
||||
pub launch: Option<String>,
|
||||
pub profile: Option<String>,
|
||||
}
|
||||
|
||||
/// Why a link can't become a plan. Each of these is a *notice*, never a degraded connect:
|
||||
/// predictability over best-effort — a shortcut that silently streams with the wrong settings
|
||||
/// or to the wrong box is worse than one that explains itself (design/client-deep-links.md §8).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PlanError {
|
||||
/// The host name matched more than one saved host.
|
||||
AmbiguousHost(String),
|
||||
/// Nothing local matched and the link carries no address to fall back on.
|
||||
UnresolvableHost(String),
|
||||
/// The link's `fp` contradicts the pin we hold — the link is stale or lying.
|
||||
PinConflict {
|
||||
host: String,
|
||||
},
|
||||
UnknownProfile(String),
|
||||
AmbiguousProfile(String),
|
||||
}
|
||||
|
||||
impl PlanError {
|
||||
/// The notice text. Every one of these names the reference that failed, because "it didn't
|
||||
/// work" on a shortcut double-click is unactionable.
|
||||
pub fn message(&self) -> String {
|
||||
match self {
|
||||
PlanError::AmbiguousHost(r) => {
|
||||
format!("More than one saved host is called \"{r}\" — open Punktfunk and pick one.")
|
||||
}
|
||||
PlanError::UnresolvableHost(r) => {
|
||||
format!("No saved host matches \"{r}\".")
|
||||
}
|
||||
PlanError::PinConflict { host } => format!(
|
||||
"That link's fingerprint doesn't match the one saved for {host} — it's out of \
|
||||
date, or it isn't that host. Nothing was connected."
|
||||
),
|
||||
PlanError::UnknownProfile(p) => {
|
||||
format!("That link asks for a settings profile called \"{p}\", which doesn't exist here.")
|
||||
}
|
||||
PlanError::AmbiguousProfile(p) => {
|
||||
format!("More than one settings profile is called \"{p}\" — rename one, or use its id in the link.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a plan from a `punktfunk://` link against this device's stores — the shared half of
|
||||
/// every platform's URL router (§4). The security rules of §3 live here, not in the shells:
|
||||
/// no pairing, no silent trust, references resolved or refused.
|
||||
///
|
||||
/// Preempting a live session is the one rule that stays with the caller: only the front-end
|
||||
/// knows whether a session is running, and the answer ("focus it" / "end that one first")
|
||||
/// is UI, not policy.
|
||||
pub fn plan_from_link(
|
||||
link: &DeepLink,
|
||||
known: &KnownHosts,
|
||||
catalog: &ProfilesFile,
|
||||
base: &Settings,
|
||||
) -> Result<PlanOutcome, PlanError> {
|
||||
if link.route != Route::Connect {
|
||||
return Ok(PlanOutcome::Unsupported(link.route));
|
||||
}
|
||||
// The profile is resolved BEFORE anything is dialled: a link that can't honor its profile
|
||||
// must say so instead of streaming with the wrong settings.
|
||||
if let Some(reference) = &link.profile {
|
||||
match catalog.resolve(reference) {
|
||||
(Some(_), _) => {}
|
||||
(_, Resolution::Ambiguous) => {
|
||||
return Err(PlanError::AmbiguousProfile(reference.clone()))
|
||||
}
|
||||
_ => return Err(PlanError::UnknownProfile(reference.clone())),
|
||||
}
|
||||
}
|
||||
match crate::deeplink::resolve_host(link, known) {
|
||||
HostResolution::Known(i) => {
|
||||
let host = &known.hosts[i];
|
||||
if link.pin_conflict(host) {
|
||||
return Err(PlanError::PinConflict {
|
||||
host: host.name.clone(),
|
||||
});
|
||||
}
|
||||
// A link with no `profile=` honors the host's binding, exactly like a card
|
||||
// click — the URL adds nothing there, so it changes nothing.
|
||||
let mut plan = ConnectPlan::resolve(
|
||||
host,
|
||||
link.launch.as_deref(),
|
||||
link.profile.as_deref(),
|
||||
catalog,
|
||||
base,
|
||||
);
|
||||
// A record we know but never pinned (added by address, never paired) is not a
|
||||
// silent connect either: the session refuses without a pin, and the front-end
|
||||
// should run its trust flow. Hand it back as the confirmation case.
|
||||
if plan.host.fp_hex.is_none() {
|
||||
return Ok(PlanOutcome::ConfirmUnknown(Box::new(UnknownHost {
|
||||
addr: plan.host.addr,
|
||||
port: plan.host.port,
|
||||
name: Some(plan.host.name),
|
||||
fp: link.fp.clone(),
|
||||
launch: link.launch.clone(),
|
||||
profile: link.profile.clone(),
|
||||
})));
|
||||
}
|
||||
if plan.host.name.is_empty() {
|
||||
// An address-only record has no label; the link's claimed one is fine for a
|
||||
// window title (it names nothing that is trusted).
|
||||
plan.host.name = link.name.clone().unwrap_or_else(|| plan.host.addr.clone());
|
||||
}
|
||||
Ok(PlanOutcome::Connect(Box::new(plan)))
|
||||
}
|
||||
HostResolution::Unknown {
|
||||
addr,
|
||||
port,
|
||||
name,
|
||||
fp,
|
||||
} => Ok(PlanOutcome::ConfirmUnknown(Box::new(UnknownHost {
|
||||
addr,
|
||||
port,
|
||||
name,
|
||||
fp,
|
||||
launch: link.launch.clone(),
|
||||
profile: link.profile.clone(),
|
||||
}))),
|
||||
HostResolution::Ambiguous => Err(PlanError::AmbiguousHost(link.host_ref.clone())),
|
||||
HostResolution::Unresolvable => Err(PlanError::UnresolvableHost(link.host_ref.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Wake-and-wait — the reference state machine, ported from Apple's `HostWaker`.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// How long to wait for a woken host to come back. Generous on purpose: a cold boot plus
|
||||
/// service start is routinely a minute-plus.
|
||||
pub const WAKE_TIMEOUT_SECS: u64 = 90;
|
||||
/// How often to re-send the magic packet while waiting.
|
||||
pub const WAKE_RESEND_SECS: u64 = 6;
|
||||
|
||||
/// The wake-and-wait loop as a pure step function, so every front-end drives it from its own
|
||||
/// loop (relm4 messages, a WinUI thread, the console's service tick, a CLI's sleep) and they
|
||||
/// all still agree on the timings — and so the behavior is testable without waiting 90 s.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WakeWait {
|
||||
elapsed_secs: u64,
|
||||
timeout_secs: u64,
|
||||
resend_secs: u64,
|
||||
}
|
||||
|
||||
/// What the caller should do for this one-second step.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct WakeTick {
|
||||
/// Send (or re-send) the magic packet now.
|
||||
pub send_packet: bool,
|
||||
/// Seconds waited so far — the "Waking… 12s" line.
|
||||
pub seconds: u64,
|
||||
/// `None` = keep waiting (sleep a second, then tick again).
|
||||
pub outcome: Option<WakeOutcome>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WakeOutcome {
|
||||
/// The host answered — proceed with the connect.
|
||||
Online,
|
||||
/// The budget ran out. The UI PARKS here (Try again / Cancel); it does not error out
|
||||
/// from under the user, because "it didn't wake in 90 s" is often "give it 10 more".
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
impl Default for WakeWait {
|
||||
fn default() -> WakeWait {
|
||||
WakeWait {
|
||||
elapsed_secs: 0,
|
||||
timeout_secs: WAKE_TIMEOUT_SECS,
|
||||
resend_secs: WAKE_RESEND_SECS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WakeWait {
|
||||
/// A wait with the shipped timings.
|
||||
pub fn new() -> WakeWait {
|
||||
WakeWait::default()
|
||||
}
|
||||
|
||||
/// One second of the wait. `online` is this tick's presence reading (an mDNS advert, a
|
||||
/// reachability probe — whichever the front-end has; both are "did it answer").
|
||||
///
|
||||
/// Order matters and matches the reference: the packet goes out *before* the presence
|
||||
/// check, so an already-awake host costs one wasted packet rather than a lost second, and
|
||||
/// the timeout is checked after it — a host that appears on the last tick still wins.
|
||||
pub fn tick(&mut self, online: bool) -> WakeTick {
|
||||
let send_packet = self.elapsed_secs % self.resend_secs == 0;
|
||||
let seconds = self.elapsed_secs;
|
||||
let outcome = if online {
|
||||
Some(WakeOutcome::Online)
|
||||
} else if self.elapsed_secs >= self.timeout_secs {
|
||||
Some(WakeOutcome::TimedOut)
|
||||
} else {
|
||||
self.elapsed_secs += 1;
|
||||
None
|
||||
};
|
||||
WakeTick {
|
||||
send_packet,
|
||||
seconds,
|
||||
outcome,
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart the same wait — "Try again" after a timeout replays it exactly (the reference's
|
||||
/// captured `replay` closure, minus the closure).
|
||||
pub fn restart(&mut self) {
|
||||
self.elapsed_secs = 0;
|
||||
}
|
||||
|
||||
pub fn seconds(&self) -> u64 {
|
||||
self.elapsed_secs
|
||||
}
|
||||
}
|
||||
|
||||
/// The front-end's obligations. Everything here is presentation; nothing here decides policy.
|
||||
pub trait UiDelegate {
|
||||
/// A link or a card points at a host we don't know (or never pinned). Return true to
|
||||
/// proceed into the trust flow. A non-interactive front-end returns false — refusing is
|
||||
/// always safe, and the CLI reports it as "needs interaction" rather than pairing blind.
|
||||
fn confirm_unknown_host(&mut self, host: &UnknownHost) -> bool;
|
||||
/// Render "Waking <host>… 12s" / the timed-out park state.
|
||||
fn wake_progress(&mut self, host: &HostTarget, tick: WakeTick);
|
||||
/// The session ended, one way or another.
|
||||
fn report(&mut self, outcome: &ConnectOutcome);
|
||||
}
|
||||
|
||||
/// How a connect finished — the typed outcome every front-end maps onto its own surface.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ConnectOutcome {
|
||||
/// The stream ran and ended cleanly; `Some` carries the host's stated reason.
|
||||
Ended(Option<String>),
|
||||
/// The dial failed (and, where applicable, the wake wait did too).
|
||||
ConnectFailed(String),
|
||||
/// Trust rejected: no pin, or the pin no longer matches. Never retried silently.
|
||||
TrustRejected(String),
|
||||
/// The session binary itself failed to start or died abnormally.
|
||||
RendererFailed(String),
|
||||
/// The user cancelled.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Session spawn + the stdout contract.
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// One event from the session child's stdout contract (`{"ready":true}`, `{"error":…}`,
|
||||
/// `{"ended":…}`, then EOF and an exit code). Parsed in one place so a shell, the console and
|
||||
/// the CLI cannot disagree about what "ready" or "trust rejected" means.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SessionEvent {
|
||||
/// First frame presented — the stream is up.
|
||||
Ready,
|
||||
Error {
|
||||
msg: String,
|
||||
trust_rejected: bool,
|
||||
},
|
||||
Ended(String),
|
||||
/// EOF: the child is gone. `-1` = killed by a signal.
|
||||
Exited(i32),
|
||||
}
|
||||
|
||||
/// Parse one stdout line of the session contract; `None` for anything else (`stats:` lines,
|
||||
/// stray output).
|
||||
pub fn parse_session_line(line: &str) -> Option<SessionEvent> {
|
||||
let v: serde_json::Value = serde_json::from_str(line).ok()?;
|
||||
if v.get("ready").and_then(|r| r.as_bool()) == Some(true) {
|
||||
return Some(SessionEvent::Ready);
|
||||
}
|
||||
if let Some(msg) = v.get("error").and_then(|m| m.as_str()) {
|
||||
return Some(SessionEvent::Error {
|
||||
msg: msg.to_string(),
|
||||
trust_rejected: v.get("trust_rejected").and_then(|t| t.as_bool()) == Some(true),
|
||||
});
|
||||
}
|
||||
if let Some(msg) = v.get("ended").and_then(|m| m.as_str()) {
|
||||
return Some(SessionEvent::Ended(msg.to_string()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The session binary: installed next to this executable, else `$PATH` (a dev run out of
|
||||
/// `target/…` lands on the sibling).
|
||||
pub fn session_binary() -> std::path::PathBuf {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let sibling = exe.with_file_name(SESSION_BIN);
|
||||
if sibling.exists() {
|
||||
return sibling;
|
||||
}
|
||||
}
|
||||
SESSION_BIN.into()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
const SESSION_BIN: &str = "punktfunk-session.exe";
|
||||
#[cfg(not(windows))]
|
||||
const SESSION_BIN: &str = "punktfunk-session";
|
||||
|
||||
/// Kills the spawned session child — the Cancel button of a parked request-access connect,
|
||||
/// and the CLI's Ctrl-C path. Safe any time; a child that already exited is a no-op.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CancelHandle(Arc<Mutex<Option<Child>>>);
|
||||
|
||||
impl CancelHandle {
|
||||
pub fn kill(&self) {
|
||||
if let Some(child) = self.0.lock().unwrap().as_mut() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the session for this plan and supervise its stdout contract on a reader thread,
|
||||
/// handing each event to `on_event` (which every front-end maps onto its own messages). The
|
||||
/// final [`SessionEvent::Exited`] always arrives, so a caller can release its busy flag in
|
||||
/// exactly one place.
|
||||
/// `cancel` lets a front-end hold the abort handle BEFORE the child exists (a request-access
|
||||
/// dialog arms its Cancel button first, then spawns); pass `None` to get a fresh one back.
|
||||
pub fn spawn_session(
|
||||
plan: &ConnectPlan,
|
||||
cancel: Option<CancelHandle>,
|
||||
on_event: impl FnMut(SessionEvent) + Send + 'static,
|
||||
) -> Result<CancelHandle, String> {
|
||||
let mut cmd = Command::new(session_binary());
|
||||
cmd.args(plan.session_args())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit()); // the session's logs interleave with the front-end's
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("couldn't start {}: {e}", SESSION_BIN))?;
|
||||
tracing::info!(
|
||||
host = %plan.host.addr, port = plan.host.port,
|
||||
profile = plan.profile.as_ref().map(|p| p.name.as_str()).unwrap_or("-"),
|
||||
"session binary spawned"
|
||||
);
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
let slot = cancel.unwrap_or_default();
|
||||
*slot.0.lock().unwrap() = Some(child);
|
||||
|
||||
let reader_slot = slot.clone();
|
||||
let mut on_event = on_event;
|
||||
std::thread::Builder::new()
|
||||
.name("pf-session-io".into())
|
||||
.spawn(move || {
|
||||
use std::io::BufRead as _;
|
||||
for line in std::io::BufReader::new(stdout).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if let Some(ev) = parse_session_line(&line) {
|
||||
on_event(ev);
|
||||
}
|
||||
}
|
||||
// EOF — reap (a cancel-killed child lands here too; -1 = died on a signal).
|
||||
let code = reader_slot
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.and_then(|mut c| c.wait().ok())
|
||||
.and_then(|s| s.code())
|
||||
.unwrap_or(-1);
|
||||
tracing::info!(code, "session binary exited");
|
||||
on_event(SessionEvent::Exited(code));
|
||||
})
|
||||
.map_err(|e| format!("session reader thread: {e}"))?;
|
||||
Ok(slot)
|
||||
}
|
||||
|
||||
/// Become the session process (`--exec`): the CLI's gamescope-wrapper mode, where the launched
|
||||
/// process identity must be the streaming one — a supervising parent would break focus and
|
||||
/// lifecycle under gamescope. Never returns on success. Windows has no `exec`, so there this
|
||||
/// runs the child to completion and exits with its code, which is the same contract minus the
|
||||
/// pid.
|
||||
pub fn exec_session(plan: &ConnectPlan) -> std::io::Error {
|
||||
let mut cmd = Command::new(session_binary());
|
||||
cmd.args(plan.session_args());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt as _;
|
||||
cmd.exec()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
match cmd.status() {
|
||||
Ok(s) => std::process::exit(s.code().unwrap_or(1)),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::deeplink;
|
||||
|
||||
fn host(name: &str, addr: &str, id: &str, fp: &str) -> KnownHost {
|
||||
KnownHost {
|
||||
name: name.into(),
|
||||
addr: addr.into(),
|
||||
port: 9777,
|
||||
fp_hex: fp.into(),
|
||||
paired: true,
|
||||
mac: vec!["aa:bb:cc:dd:ee:ff".into()],
|
||||
id: Some(id.into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The wait is Apple's `HostWaker` second for second: a packet at 0 and every 6 s after,
|
||||
/// a presence check each second, 90 s of budget, and a park (not an error) at the end.
|
||||
#[test]
|
||||
fn wake_wait_matches_the_reference_cadence() {
|
||||
let mut w = WakeWait::new();
|
||||
// t=0: packet goes out before the first presence check.
|
||||
let t = w.tick(false);
|
||||
assert!(t.send_packet);
|
||||
assert_eq!(t.seconds, 0);
|
||||
assert_eq!(t.outcome, None);
|
||||
// t=1..5 wait quietly, t=6 re-sends.
|
||||
for s in 1..6 {
|
||||
let t = w.tick(false);
|
||||
assert!(!t.send_packet, "no packet at {s}s");
|
||||
assert_eq!(t.seconds, s);
|
||||
}
|
||||
assert!(w.tick(false).send_packet); // t=6
|
||||
assert_eq!(w.seconds(), 7);
|
||||
|
||||
// A host that answers ends the wait immediately, whatever second it is.
|
||||
let mut w = WakeWait::new();
|
||||
w.tick(false);
|
||||
let t = w.tick(true);
|
||||
assert_eq!(t.outcome, Some(WakeOutcome::Online));
|
||||
|
||||
// The budget: still waiting at 90 s of elapsed time, timed out on the tick after.
|
||||
let mut w = WakeWait::new();
|
||||
for _ in 0..WAKE_TIMEOUT_SECS {
|
||||
assert_eq!(w.tick(false).outcome, None);
|
||||
}
|
||||
assert_eq!(w.seconds(), WAKE_TIMEOUT_SECS);
|
||||
let t = w.tick(false);
|
||||
assert_eq!(t.outcome, Some(WakeOutcome::TimedOut));
|
||||
// A timed-out wait doesn't advance — it parks, and stays parked until asked again.
|
||||
assert_eq!(w.tick(false).outcome, Some(WakeOutcome::TimedOut));
|
||||
// …and a host that comes back while parked still wins ("Try again" isn't required).
|
||||
assert_eq!(w.tick(true).outcome, Some(WakeOutcome::Online));
|
||||
// Retry replays the identical wait.
|
||||
w.restart();
|
||||
assert_eq!(w.seconds(), 0);
|
||||
assert!(w.tick(false).send_packet);
|
||||
}
|
||||
|
||||
/// The argv every door spawns through. A one-off profile rides the flag; a host BINDING
|
||||
/// deliberately doesn't — the session resolves it with the same helper, so passing it
|
||||
/// would be a second source of truth.
|
||||
#[test]
|
||||
fn session_args_are_assembled_in_one_place() {
|
||||
let h = host(
|
||||
"Desk",
|
||||
"192.168.1.50",
|
||||
"11111111-2222-4333-8444-555555555555",
|
||||
&"a".repeat(64),
|
||||
);
|
||||
let mut plan = ConnectPlan {
|
||||
host: HostTarget::from(&h),
|
||||
launch: Some("steam:570".into()),
|
||||
profile: None,
|
||||
profile_override: None,
|
||||
settings: Settings {
|
||||
fullscreen_on_stream: false,
|
||||
..Default::default()
|
||||
},
|
||||
wake: true,
|
||||
connect_timeout_secs: None,
|
||||
tofu: false,
|
||||
};
|
||||
assert_eq!(
|
||||
plan.session_args(),
|
||||
vec![
|
||||
"--connect",
|
||||
"192.168.1.50:9777",
|
||||
"--fp",
|
||||
&"a".repeat(64),
|
||||
"--launch",
|
||||
"steam:570"
|
||||
]
|
||||
);
|
||||
|
||||
plan.profile_override = Some("aaaaaaaaaaaa".into());
|
||||
plan.connect_timeout_secs = Some(185);
|
||||
plan.settings.fullscreen_on_stream = true;
|
||||
let args = plan.session_args();
|
||||
assert!(args.windows(2).any(|w| w == ["--profile", "aaaaaaaaaaaa"]));
|
||||
assert!(args.windows(2).any(|w| w == ["--connect-timeout", "185"]));
|
||||
assert!(args.contains(&"--fullscreen".to_string()));
|
||||
|
||||
// "Connect with ▸ Default settings" on a bound host is an EMPTY override, which is
|
||||
// not the same as no override — it has to survive as a flag.
|
||||
plan.profile_override = Some(String::new());
|
||||
let args = plan.session_args();
|
||||
let i = args.iter().position(|a| a == "--profile").unwrap();
|
||||
assert_eq!(args[i + 1], "");
|
||||
}
|
||||
|
||||
/// The §3 security rules, in the layer that owns them: an unknown host is a prompt, a
|
||||
/// contradicted pin is a refusal, an unhonorable profile is a refusal, and an ambiguous
|
||||
/// reference is never guessed at.
|
||||
#[test]
|
||||
fn link_plans_refuse_rather_than_degrade() {
|
||||
let fp = "a".repeat(64);
|
||||
let known = KnownHosts {
|
||||
hosts: vec![
|
||||
host(
|
||||
"Desk",
|
||||
"192.168.1.50",
|
||||
"11111111-2222-4333-8444-555555555555",
|
||||
&fp,
|
||||
),
|
||||
host(
|
||||
"Couch",
|
||||
"192.168.1.60",
|
||||
"22222222-3333-4444-8555-666666666666",
|
||||
"",
|
||||
),
|
||||
host(
|
||||
"Couch",
|
||||
"192.168.1.61",
|
||||
"33333333-4444-4555-8666-777777777777",
|
||||
"",
|
||||
),
|
||||
],
|
||||
};
|
||||
// Pure inputs — the test never touches the config directory.
|
||||
let catalog = ProfilesFile::default();
|
||||
let base = Settings::default();
|
||||
let plan =
|
||||
|url: &str| plan_from_link(&deeplink::parse(url).unwrap(), &known, &catalog, &base);
|
||||
|
||||
// A known, pinned host with a matching (or absent) fp: a plain connect.
|
||||
let out = plan("punktfunk://connect/Desk").unwrap();
|
||||
match out {
|
||||
PlanOutcome::Connect(p) => {
|
||||
assert_eq!(p.host.addr, "192.168.1.50");
|
||||
assert_eq!(p.profile_override, None);
|
||||
assert!(p.host.fp_hex.is_some());
|
||||
}
|
||||
other => panic!("expected a connect, got {other:?}"),
|
||||
}
|
||||
|
||||
// A lying/stale fingerprint never connects, and says which host it was about.
|
||||
assert_eq!(
|
||||
plan(&format!("punktfunk://connect/Desk?fp={}", "b".repeat(64))),
|
||||
Err(PlanError::PinConflict {
|
||||
host: "Desk".into()
|
||||
})
|
||||
);
|
||||
// Ambiguity is reported, never resolved by picking the first.
|
||||
assert_eq!(
|
||||
plan("punktfunk://connect/Couch"),
|
||||
Err(PlanError::AmbiguousHost("Couch".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
plan("punktfunk://connect/00000000-0000-4000-8000-000000000000"),
|
||||
Err(PlanError::UnresolvableHost(
|
||||
"00000000-0000-4000-8000-000000000000".into()
|
||||
))
|
||||
);
|
||||
// A profile the catalog can't honor refuses BEFORE anything is dialled.
|
||||
assert_eq!(
|
||||
plan("punktfunk://connect/Desk?profile=NoSuchProfile"),
|
||||
Err(PlanError::UnknownProfile("NoSuchProfile".into()))
|
||||
);
|
||||
// An unknown address is a confirmation sheet, never an auto-connect — and it carries
|
||||
// the claimed name and the expected pin so the first connect is verified, not TOFU.
|
||||
match plan(&format!(
|
||||
"punktfunk://connect/10.0.0.9:7000?name=Studio&fp={fp}"
|
||||
))
|
||||
.unwrap()
|
||||
{
|
||||
PlanOutcome::ConfirmUnknown(u) => assert_eq!(
|
||||
*u,
|
||||
UnknownHost {
|
||||
addr: "10.0.0.9".into(),
|
||||
port: 7000,
|
||||
name: Some("Studio".into()),
|
||||
fp: Some(fp.clone()),
|
||||
launch: None,
|
||||
profile: None,
|
||||
}
|
||||
),
|
||||
other => panic!("expected a confirmation, got {other:?}"),
|
||||
}
|
||||
// A saved host we never pinned is the same case: known ≠ trusted.
|
||||
match plan("punktfunk://connect/192.168.1.60").unwrap() {
|
||||
PlanOutcome::ConfirmUnknown(u) => {
|
||||
assert_eq!(u.addr, "192.168.1.60");
|
||||
assert_eq!(u.name.as_deref(), Some("Couch"));
|
||||
}
|
||||
other => panic!("expected a confirmation, got {other:?}"),
|
||||
}
|
||||
// Routes that parse but aren't implemented here are a notice, not a silent drop.
|
||||
assert!(matches!(
|
||||
plan("punktfunk://wake/Desk").unwrap(),
|
||||
PlanOutcome::Unsupported(Route::Wake)
|
||||
));
|
||||
}
|
||||
|
||||
/// The stdout contract, parsed once for every front-end.
|
||||
#[test]
|
||||
fn session_contract_lines() {
|
||||
assert_eq!(
|
||||
parse_session_line(r#"{"ready":true}"#),
|
||||
Some(SessionEvent::Ready)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_session_line(r#"{"error":"no route","trust_rejected":false}"#),
|
||||
Some(SessionEvent::Error {
|
||||
msg: "no route".into(),
|
||||
trust_rejected: false
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_session_line(r#"{"error":"pin","trust_rejected":true}"#),
|
||||
Some(SessionEvent::Error {
|
||||
msg: "pin".into(),
|
||||
trust_rejected: true
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_session_line(r#"{"ended":"Host ended the session"}"#),
|
||||
Some(SessionEvent::Ended("Host ended the session".into()))
|
||||
);
|
||||
// Stats lines and stray output are never events.
|
||||
assert_eq!(parse_session_line("stats: 1280×800@60 · 60 fps"), None);
|
||||
assert_eq!(parse_session_line(""), None);
|
||||
assert_eq!(parse_session_line(r#"{"other":1}"#), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
//! Client settings profiles — named bundles of setting overrides applied on top of the
|
||||
//! global [`Settings`] (design/client-settings-profiles.md §4).
|
||||
//!
|
||||
//! A profile overrides only the fields the user touched; everything else keeps following the
|
||||
//! global defaults *live*, so fixing a global once fixes it everywhere. That is why an overlay
|
||||
//! is sparse `Option`s rather than a snapshot copy, and why `Some(x)` is written on touch and
|
||||
//! `None` on an explicit "reset to default" — never by diffing against the current global (a
|
||||
//! `Some` equal to today's global is a legitimate *pin*: the profile keeps `x` when the global
|
||||
//! later moves).
|
||||
//!
|
||||
//! The catalog lives in its own `client-profiles.json` beside the settings file, deliberately
|
||||
//! NOT inside it: the settings file has five whole-file load-modify-save writers (two shells,
|
||||
//! the console settings screen, the session's resize callback, Decky) with no merge, so a
|
||||
//! profile written by one would be dropped by the next. This file is touched only by
|
||||
//! profile-aware code, and written temp+rename.
|
||||
//!
|
||||
//! Which host uses which profile is a field on the host record ([`crate::trust::KnownHost`]),
|
||||
//! not a map keyed here — see §4.1 of the design for why the catalog owns no host keys.
|
||||
|
||||
use crate::trust::{config_dir, write_atomic, Settings, StatsVerbosity};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The catalog file's schema version. Bumped only for a breaking shape change — additive
|
||||
/// fields ride the unknown-key preservation below.
|
||||
pub const PROFILES_VERSION: u32 = 1;
|
||||
|
||||
/// Every profileable ("tier P") setting, as `Option<T>`: `None` = inherit the global value,
|
||||
/// live. Tier-H fields (host properties like `clipboard_sync`) and tier-G ones (this
|
||||
/// device's hardware/endpoints) are deliberately absent — see the design's §3 curation.
|
||||
///
|
||||
/// `extra` preserves keys this build doesn't know (a newer client's tier-P field): the
|
||||
/// don't-clobber rule that already governs the GTK `ChoiceRow` pickers extends to the whole
|
||||
/// overlay — opening and saving a profile on an older client must not erase what a newer one
|
||||
/// stored.
|
||||
#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SettingsOverlay {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub width: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub height: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub refresh_hz: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub match_window: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bitrate_kbps: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub render_scale: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub codec: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hdr_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub compositor: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub audio_channels: Option<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mic_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub touch_mode: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mouse_mode: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub invert_scroll: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub inhibit_shortcuts: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gamepad: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stats_verbosity: Option<StatsVerbosity>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fullscreen_on_stream: Option<bool>,
|
||||
/// Overlay keys a newer client wrote and this one doesn't model — carried through a
|
||||
/// load→save round-trip untouched.
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl SettingsOverlay {
|
||||
/// The one resolution seam: this overlay on top of `base`. Pure — no store reads, no
|
||||
/// clock, no environment — so it is fully testable field by field.
|
||||
pub fn apply(&self, base: &Settings) -> Settings {
|
||||
let mut s = base.clone();
|
||||
if let Some(v) = self.width {
|
||||
s.width = v;
|
||||
}
|
||||
if let Some(v) = self.height {
|
||||
s.height = v;
|
||||
}
|
||||
if let Some(v) = self.refresh_hz {
|
||||
s.refresh_hz = v;
|
||||
}
|
||||
if let Some(v) = self.match_window {
|
||||
s.match_window = v;
|
||||
}
|
||||
if let Some(v) = self.bitrate_kbps {
|
||||
s.bitrate_kbps = v;
|
||||
}
|
||||
if let Some(v) = self.render_scale {
|
||||
s.render_scale = v;
|
||||
}
|
||||
if let Some(v) = &self.codec {
|
||||
s.codec = v.clone();
|
||||
}
|
||||
if let Some(v) = self.hdr_enabled {
|
||||
s.hdr_enabled = v;
|
||||
}
|
||||
if let Some(v) = &self.compositor {
|
||||
s.compositor = v.clone();
|
||||
}
|
||||
if let Some(v) = self.audio_channels {
|
||||
s.audio_channels = v;
|
||||
}
|
||||
if let Some(v) = self.mic_enabled {
|
||||
s.mic_enabled = v;
|
||||
}
|
||||
if let Some(v) = &self.touch_mode {
|
||||
s.touch_mode = v.clone();
|
||||
}
|
||||
if let Some(v) = &self.mouse_mode {
|
||||
s.mouse_mode = v.clone();
|
||||
}
|
||||
if let Some(v) = self.invert_scroll {
|
||||
s.invert_scroll = v;
|
||||
}
|
||||
if let Some(v) = self.inhibit_shortcuts {
|
||||
s.inhibit_shortcuts = v;
|
||||
}
|
||||
if let Some(v) = &self.gamepad {
|
||||
s.gamepad = v.clone();
|
||||
}
|
||||
if let Some(v) = self.stats_verbosity {
|
||||
// Through the setter so the legacy `show_stats` bool stays coherent for
|
||||
// pre-tier binaries reading the same settings file.
|
||||
s.set_stats_verbosity(v);
|
||||
}
|
||||
if let Some(v) = self.fullscreen_on_stream {
|
||||
s.fullscreen_on_stream = v;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Record, as overrides, every tier-P field that differs between two settings snapshots.
|
||||
///
|
||||
/// This is for front-ends that commit PER CONTROL rather than per dialog (the WinUI shell
|
||||
/// writes on every change; the GTK one writes once on close). They can't hand over a list
|
||||
/// of touched fields, so they hand over "the effective settings before this control fired"
|
||||
/// and "after": the only field that can differ is the one the user just touched.
|
||||
///
|
||||
/// That is not the diff-on-save this design rejects. The comparison is against the
|
||||
/// EFFECTIVE settings — what the control was showing — not against the globals, so setting
|
||||
/// a value back to what the global happens to be still records an override, which is the
|
||||
/// pin the design asks for. It only ever adds overrides; removing one is an explicit
|
||||
/// reset, which is a different operation.
|
||||
pub fn absorb(&mut self, before: &Settings, after: &Settings) {
|
||||
if after.width != before.width {
|
||||
self.width = Some(after.width);
|
||||
}
|
||||
if after.height != before.height {
|
||||
self.height = Some(after.height);
|
||||
}
|
||||
if after.refresh_hz != before.refresh_hz {
|
||||
self.refresh_hz = Some(after.refresh_hz);
|
||||
}
|
||||
if after.match_window != before.match_window {
|
||||
self.match_window = Some(after.match_window);
|
||||
}
|
||||
if after.bitrate_kbps != before.bitrate_kbps {
|
||||
self.bitrate_kbps = Some(after.bitrate_kbps);
|
||||
}
|
||||
if after.render_scale != before.render_scale {
|
||||
self.render_scale = Some(after.render_scale);
|
||||
}
|
||||
if after.codec != before.codec {
|
||||
self.codec = Some(after.codec.clone());
|
||||
}
|
||||
if after.hdr_enabled != before.hdr_enabled {
|
||||
self.hdr_enabled = Some(after.hdr_enabled);
|
||||
}
|
||||
if after.compositor != before.compositor {
|
||||
self.compositor = Some(after.compositor.clone());
|
||||
}
|
||||
if after.audio_channels != before.audio_channels {
|
||||
self.audio_channels = Some(after.audio_channels);
|
||||
}
|
||||
if after.mic_enabled != before.mic_enabled {
|
||||
self.mic_enabled = Some(after.mic_enabled);
|
||||
}
|
||||
if after.touch_mode != before.touch_mode {
|
||||
self.touch_mode = Some(after.touch_mode.clone());
|
||||
}
|
||||
if after.mouse_mode != before.mouse_mode {
|
||||
self.mouse_mode = Some(after.mouse_mode.clone());
|
||||
}
|
||||
if after.invert_scroll != before.invert_scroll {
|
||||
self.invert_scroll = Some(after.invert_scroll);
|
||||
}
|
||||
if after.inhibit_shortcuts != before.inhibit_shortcuts {
|
||||
self.inhibit_shortcuts = Some(after.inhibit_shortcuts);
|
||||
}
|
||||
if after.gamepad != before.gamepad {
|
||||
self.gamepad = Some(after.gamepad.clone());
|
||||
}
|
||||
if after.stats_verbosity() != before.stats_verbosity() {
|
||||
self.stats_verbosity = Some(after.stats_verbosity());
|
||||
}
|
||||
if after.fullscreen_on_stream != before.fullscreen_on_stream {
|
||||
self.fullscreen_on_stream = Some(after.fullscreen_on_stream);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop one override by its overlay field name, putting the row back to inheriting. The
|
||||
/// names are the serialised ones, so a UI can carry them as plain strings; `resolution`
|
||||
/// is the one alias, covering the width/height/match-window tri-state a single control
|
||||
/// drives on every client. Returns false for a name this build doesn't know.
|
||||
pub fn clear(&mut self, field: &str) -> bool {
|
||||
match field {
|
||||
"resolution" => {
|
||||
self.width = None;
|
||||
self.height = None;
|
||||
self.match_window = None;
|
||||
}
|
||||
"width" => self.width = None,
|
||||
"height" => self.height = None,
|
||||
"refresh_hz" => self.refresh_hz = None,
|
||||
"match_window" => self.match_window = None,
|
||||
"bitrate_kbps" => self.bitrate_kbps = None,
|
||||
"render_scale" => self.render_scale = None,
|
||||
"codec" => self.codec = None,
|
||||
"hdr_enabled" => self.hdr_enabled = None,
|
||||
"compositor" => self.compositor = None,
|
||||
"audio_channels" => self.audio_channels = None,
|
||||
"mic_enabled" => self.mic_enabled = None,
|
||||
"touch_mode" => self.touch_mode = None,
|
||||
"mouse_mode" => self.mouse_mode = None,
|
||||
"invert_scroll" => self.invert_scroll = None,
|
||||
"inhibit_shortcuts" => self.inhibit_shortcuts = None,
|
||||
"gamepad" => self.gamepad = None,
|
||||
"stats_verbosity" => self.stats_verbosity = None,
|
||||
"fullscreen_on_stream" => self.fullscreen_on_stream = None,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// True when the profile overrides nothing — "inherits everything", the state a freshly
|
||||
/// created profile starts in. Unknown-key carry-through counts: a profile that only holds
|
||||
/// a newer client's field is not empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == SettingsOverlay::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// One named bundle of overrides. `id` is stable across renames — bindings, pins and deep
|
||||
/// links all point at it, never at the name.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StreamProfile {
|
||||
pub id: String,
|
||||
/// User-facing and editable; unique case-insensitively (menus are ambiguous otherwise —
|
||||
/// enforced by the editing UIs via [`ProfilesFile::name_taken`]).
|
||||
pub name: String,
|
||||
/// `#RRGGBB` chip color. The UI may ignore it; the schema reserves it (pinned cards use
|
||||
/// it to tint their subtitle).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub accent: Option<String>,
|
||||
#[serde(default)]
|
||||
pub overrides: SettingsOverlay,
|
||||
/// Profile keys a newer client wrote — preserved across a load→save round-trip.
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl StreamProfile {
|
||||
/// A new, empty profile: inherits everything (the right creation default under
|
||||
/// inherit-by-exception — "Duplicate" covers starting from another profile).
|
||||
pub fn new(name: impl Into<String>) -> StreamProfile {
|
||||
StreamProfile {
|
||||
id: new_profile_id(),
|
||||
name: name.into(),
|
||||
accent: None,
|
||||
overrides: SettingsOverlay::default(),
|
||||
extra: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a `profile=` / `--profile` reference resolved to. Ambiguity is reported rather than
|
||||
/// guessed: a link or flag naming two profiles must refuse, not pick one (design
|
||||
/// client-deep-links.md §8).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Resolution {
|
||||
Found,
|
||||
NotFound,
|
||||
/// More than one profile carries this name (case-insensitively).
|
||||
Ambiguous,
|
||||
}
|
||||
|
||||
/// The profile catalog — client-wide, not per host: "Work" applied to three hosts is one
|
||||
/// profile, and the per-host part is only the binding on the host record.
|
||||
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ProfilesFile {
|
||||
#[serde(default)]
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub profiles: Vec<StreamProfile>,
|
||||
}
|
||||
|
||||
impl ProfilesFile {
|
||||
pub fn path() -> anyhow::Result<PathBuf> {
|
||||
Ok(config_dir()?.join("client-profiles.json"))
|
||||
}
|
||||
|
||||
/// The stored catalog, or an empty one — a missing or unreadable file is "no profiles",
|
||||
/// never an error: nothing about streaming may hinge on this file existing.
|
||||
pub fn load() -> ProfilesFile {
|
||||
Self::path()
|
||||
.and_then(|p| Ok(std::fs::read_to_string(p)?))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Persist temp+rename, so a crash or a full disk mid-write leaves the previous catalog
|
||||
/// intact instead of a truncated one.
|
||||
pub fn save(&mut self) -> anyhow::Result<()> {
|
||||
self.version = PROFILES_VERSION;
|
||||
let p = Self::path()?;
|
||||
std::fs::create_dir_all(p.parent().unwrap())?;
|
||||
write_atomic(&p, &serde_json::to_vec_pretty(self)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find_by_id(&self, id: &str) -> Option<&StreamProfile> {
|
||||
self.profiles.iter().find(|p| p.id == id)
|
||||
}
|
||||
|
||||
/// Resolve a reference the way every surface must: exact id first, then a unique
|
||||
/// case-insensitive name. Ambiguous names resolve to [`Resolution::Ambiguous`], never to
|
||||
/// the first match.
|
||||
pub fn resolve(&self, reference: &str) -> (Option<&StreamProfile>, Resolution) {
|
||||
if let Some(p) = self.find_by_id(reference) {
|
||||
return (Some(p), Resolution::Found);
|
||||
}
|
||||
let mut hits = self
|
||||
.profiles
|
||||
.iter()
|
||||
.filter(|p| p.name.eq_ignore_ascii_case(reference));
|
||||
match (hits.next(), hits.next()) {
|
||||
(Some(p), None) => (Some(p), Resolution::Found),
|
||||
(Some(_), Some(_)) => (None, Resolution::Ambiguous),
|
||||
_ => (None, Resolution::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this name already used (case-insensitively) by a *different* profile? The
|
||||
/// create/rename guard — `except` is the profile being renamed, so renaming "Work" to
|
||||
/// "work" is allowed.
|
||||
pub fn name_taken(&self, name: &str, except: Option<&str>) -> bool {
|
||||
self.profiles
|
||||
.iter()
|
||||
.any(|p| p.name.eq_ignore_ascii_case(name) && Some(p.id.as_str()) != except)
|
||||
}
|
||||
}
|
||||
|
||||
/// 12 lowercase hex chars — the `library::new_id` shape, minted from the OS RNG (no uuid
|
||||
/// dependency, no collision in any realistic catalog).
|
||||
pub fn new_profile_id() -> String {
|
||||
let b: [u8; 6] = rand::random();
|
||||
hex_lower(&b)
|
||||
}
|
||||
|
||||
/// A random UUID-v4 in the canonical 8-4-4-4-12 form — the stable host-record identity
|
||||
/// (design §4.5). Matches the shape Apple's `StoredHost.id` already has, so a deep link's
|
||||
/// host-ref grammar is one format on every platform.
|
||||
pub fn new_record_uuid() -> String {
|
||||
let mut b: [u8; 16] = rand::random();
|
||||
b[6] = (b[6] & 0x0f) | 0x40; // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant
|
||||
let h = hex_lower(&b);
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&h[0..8],
|
||||
&h[8..12],
|
||||
&h[12..16],
|
||||
&h[16..20],
|
||||
&h[20..32]
|
||||
)
|
||||
}
|
||||
|
||||
fn hex_lower(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The overlay applies field by field: a `Some` wins, a `None` keeps the base's live
|
||||
/// value — including values that happen to equal the base (an explicit pin).
|
||||
#[test]
|
||||
fn overlay_applies_only_what_it_overrides() {
|
||||
let base = Settings {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
bitrate_kbps: 20000,
|
||||
codec: "hevc".into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let empty = SettingsOverlay::default();
|
||||
let out = empty.apply(&base);
|
||||
assert_eq!((out.width, out.height), (1920, 1080));
|
||||
assert_eq!(out.bitrate_kbps, 20000);
|
||||
assert_eq!(out.codec, "hevc");
|
||||
assert!(empty.is_empty());
|
||||
|
||||
let overlay = SettingsOverlay {
|
||||
width: Some(3840),
|
||||
height: Some(2160),
|
||||
refresh_hz: Some(120),
|
||||
bitrate_kbps: Some(80000),
|
||||
render_scale: Some(1.5),
|
||||
codec: Some("av1".into()),
|
||||
hdr_enabled: Some(false),
|
||||
compositor: Some("gamescope".into()),
|
||||
audio_channels: Some(6),
|
||||
mic_enabled: Some(true),
|
||||
touch_mode: Some("pointer".into()),
|
||||
mouse_mode: Some("desktop".into()),
|
||||
invert_scroll: Some(true),
|
||||
inhibit_shortcuts: Some(false),
|
||||
gamepad: Some("dualsense".into()),
|
||||
match_window: Some(true),
|
||||
fullscreen_on_stream: Some(false),
|
||||
stats_verbosity: Some(StatsVerbosity::Detailed),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!overlay.is_empty());
|
||||
let out = overlay.apply(&base);
|
||||
assert_eq!((out.width, out.height, out.refresh_hz), (3840, 2160, 120));
|
||||
assert_eq!(out.bitrate_kbps, 80000);
|
||||
assert_eq!(out.render_scale, 1.5);
|
||||
assert_eq!(out.codec, "av1");
|
||||
assert!(!out.hdr_enabled);
|
||||
assert_eq!(out.compositor, "gamescope");
|
||||
assert_eq!(out.audio_channels, 6);
|
||||
assert!(out.mic_enabled);
|
||||
assert_eq!(out.touch_mode, "pointer");
|
||||
assert_eq!(out.mouse_mode, "desktop");
|
||||
assert!(out.invert_scroll);
|
||||
assert!(!out.inhibit_shortcuts);
|
||||
assert_eq!(out.gamepad, "dualsense");
|
||||
assert!(out.match_window);
|
||||
assert!(!out.fullscreen_on_stream);
|
||||
assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed);
|
||||
// The tier goes through the setter, so the legacy bool a pre-tier binary reads
|
||||
// stays coherent with it.
|
||||
assert!(out.show_stats);
|
||||
// Tier-G/H fields are not in the overlay at all — the device's decoder pick, its
|
||||
// audio endpoints and the per-host clipboard decision survive any profile.
|
||||
assert_eq!(out.decoder, base.decoder);
|
||||
assert_eq!(out.speaker_device, base.speaker_device);
|
||||
|
||||
// An overlay that only carries a value equal to the base is still an override: the
|
||||
// profile pins it, so a later global change doesn't move it.
|
||||
let pin = SettingsOverlay {
|
||||
bitrate_kbps: Some(20000),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!pin.is_empty());
|
||||
let mut moved = base.clone();
|
||||
moved.bitrate_kbps = 50000;
|
||||
assert_eq!(pin.apply(&moved).bitrate_kbps, 20000);
|
||||
}
|
||||
|
||||
/// `absorb` records exactly the field a control changed, compares against the EFFECTIVE
|
||||
/// settings (so a value equal to the global is still a pin), and never removes anything.
|
||||
#[test]
|
||||
fn absorb_records_the_touched_field_only() {
|
||||
let base = Settings {
|
||||
bitrate_kbps: 20000,
|
||||
codec: "hevc".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut o = SettingsOverlay::default();
|
||||
|
||||
// One control fires: before = what it was showing, after = what the user picked.
|
||||
let before = o.apply(&base);
|
||||
let mut after = before.clone();
|
||||
after.codec = "av1".into();
|
||||
o.absorb(&before, &after);
|
||||
assert_eq!(o.codec.as_deref(), Some("av1"));
|
||||
assert_eq!(o.bitrate_kbps, None, "nothing else may be recorded");
|
||||
|
||||
// Setting it BACK to the global's value is still an override — the pin case. This is
|
||||
// what makes absorb different from diffing against the globals at save time.
|
||||
let before = o.apply(&base);
|
||||
let mut after = before.clone();
|
||||
after.codec = "hevc".into();
|
||||
o.absorb(&before, &after);
|
||||
assert_eq!(o.codec.as_deref(), Some("hevc"));
|
||||
let mut moved = base.clone();
|
||||
moved.codec = "h264".into();
|
||||
assert_eq!(o.apply(&moved).codec, "hevc");
|
||||
|
||||
// The stats tier goes through the resolver, not the legacy bool.
|
||||
let before = o.apply(&base);
|
||||
let mut after = before.clone();
|
||||
after.set_stats_verbosity(StatsVerbosity::Detailed);
|
||||
o.absorb(&before, &after);
|
||||
assert_eq!(o.stats_verbosity, Some(StatsVerbosity::Detailed));
|
||||
|
||||
// Identical snapshots record nothing.
|
||||
let before = o.apply(&base);
|
||||
let mut o2 = o.clone();
|
||||
o2.absorb(&before, &before);
|
||||
assert_eq!(o2, o);
|
||||
}
|
||||
|
||||
/// `clear` is the explicit way back to inheriting, including the resolution tri-state.
|
||||
#[test]
|
||||
fn clear_drops_one_override() {
|
||||
let mut o = SettingsOverlay {
|
||||
width: Some(3840),
|
||||
height: Some(2160),
|
||||
match_window: Some(false),
|
||||
codec: Some("av1".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(o.clear("codec"));
|
||||
assert_eq!(o.codec, None);
|
||||
assert!(o.clear("resolution"));
|
||||
assert_eq!((o.width, o.height, o.match_window), (None, None, None));
|
||||
assert!(o.is_empty());
|
||||
assert!(!o.clear("no_such_field"));
|
||||
}
|
||||
|
||||
/// Stats verbosity Off must survive `apply` — it is a legitimate override, and going
|
||||
/// through `set_stats_verbosity` keeps `show_stats` in sync in that direction too.
|
||||
#[test]
|
||||
fn overlay_can_turn_the_stats_overlay_off() {
|
||||
let mut base = Settings::default();
|
||||
base.set_stats_verbosity(StatsVerbosity::Detailed);
|
||||
let overlay = SettingsOverlay {
|
||||
stats_verbosity: Some(StatsVerbosity::Off),
|
||||
..Default::default()
|
||||
};
|
||||
let out = overlay.apply(&base);
|
||||
assert_eq!(out.stats_verbosity(), StatsVerbosity::Off);
|
||||
assert!(!out.show_stats);
|
||||
}
|
||||
|
||||
/// A catalog round-trips, and values this build can't represent survive it: an unknown
|
||||
/// codec string (a newer client's option) stays as written, and an unknown overlay KEY
|
||||
/// is carried through untouched rather than erased — the don't-clobber rule.
|
||||
#[test]
|
||||
fn catalog_round_trips_and_preserves_what_it_cannot_represent() {
|
||||
// `r##` — the accent value below contains a `"#` pair that would close an `r#` literal.
|
||||
let stored = r##"{
|
||||
"version": 1,
|
||||
"profiles": [
|
||||
{
|
||||
"id": "a1b2c3d4e5f6",
|
||||
"name": "Game",
|
||||
"accent": "#ff8800",
|
||||
"overrides": {
|
||||
"width": 3840, "height": 2160, "refresh_hz": 120,
|
||||
"codec": "vvc-from-the-future",
|
||||
"some_new_axis": {"nested": true},
|
||||
"stats_verbosity": "compact"
|
||||
},
|
||||
"future_profile_key": 7
|
||||
},
|
||||
{ "id": "0f0f0f0f0f0f", "name": "Work" }
|
||||
]
|
||||
}"##;
|
||||
let file: ProfilesFile = serde_json::from_str(stored).unwrap();
|
||||
assert_eq!(file.profiles.len(), 2);
|
||||
let game = file.find_by_id("a1b2c3d4e5f6").unwrap();
|
||||
assert_eq!(game.accent.as_deref(), Some("#ff8800"));
|
||||
assert_eq!(game.overrides.codec.as_deref(), Some("vvc-from-the-future"));
|
||||
assert_eq!(
|
||||
game.overrides.stats_verbosity,
|
||||
Some(StatsVerbosity::Compact)
|
||||
);
|
||||
// A profile with no `overrides` key at all is the empty (inherit-everything) one.
|
||||
assert!(file
|
||||
.find_by_id("0f0f0f0f0f0f")
|
||||
.unwrap()
|
||||
.overrides
|
||||
.is_empty());
|
||||
|
||||
let text = serde_json::to_string(&file).unwrap();
|
||||
assert!(text.contains("vvc-from-the-future"));
|
||||
assert!(text.contains("some_new_axis"));
|
||||
assert!(text.contains("future_profile_key"));
|
||||
// Absent overrides serialize away entirely — the file stays readable.
|
||||
assert!(!text.contains("null"));
|
||||
let round: ProfilesFile = serde_json::from_str(&text).unwrap();
|
||||
let game = round.find_by_id("a1b2c3d4e5f6").unwrap();
|
||||
assert_eq!(game.overrides.width, Some(3840));
|
||||
assert_eq!(game.overrides.extra.len(), 1);
|
||||
assert_eq!(game.extra.len(), 1);
|
||||
|
||||
// The unknown value still applies: the session hands the string to the host, which
|
||||
// is the component that decides what it can encode.
|
||||
let applied = game.overrides.apply(&Settings::default());
|
||||
assert_eq!(applied.codec, "vvc-from-the-future");
|
||||
}
|
||||
|
||||
/// Resolution order: id first, then a unique case-insensitive name; two profiles sharing
|
||||
/// a name resolve to Ambiguous (the caller refuses) rather than to whichever came first.
|
||||
#[test]
|
||||
fn resolve_prefers_ids_and_refuses_ambiguity() {
|
||||
let file = ProfilesFile {
|
||||
version: 1,
|
||||
profiles: vec![
|
||||
StreamProfile {
|
||||
id: "111111111111".into(),
|
||||
name: "Work".into(),
|
||||
..StreamProfile::new("")
|
||||
},
|
||||
StreamProfile {
|
||||
id: "222222222222".into(),
|
||||
name: "work".into(),
|
||||
..StreamProfile::new("")
|
||||
},
|
||||
StreamProfile {
|
||||
id: "333333333333".into(),
|
||||
name: "Game".into(),
|
||||
..StreamProfile::new("")
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(file.resolve("111111111111").1, Resolution::Found);
|
||||
assert_eq!(file.resolve("Work").1, Resolution::Ambiguous);
|
||||
assert_eq!(file.resolve("game").1, Resolution::Found);
|
||||
assert_eq!(file.resolve("GAME").0.unwrap().id, "333333333333");
|
||||
assert_eq!(file.resolve("nope").1, Resolution::NotFound);
|
||||
assert_eq!(file.resolve("").1, Resolution::NotFound);
|
||||
|
||||
// Duplicate-name guard, and the rename-in-place exemption.
|
||||
assert!(file.name_taken("GAME", None));
|
||||
assert!(!file.name_taken("GAME", Some("333333333333")));
|
||||
assert!(file.name_taken("GAME", Some("111111111111")));
|
||||
assert!(!file.name_taken("Travel", None));
|
||||
}
|
||||
|
||||
/// Minted ids have the documented shapes and don't repeat.
|
||||
#[test]
|
||||
fn minted_ids_are_well_formed() {
|
||||
let a = new_profile_id();
|
||||
assert_eq!(a.len(), 12);
|
||||
assert!(a
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()));
|
||||
assert_ne!(a, new_profile_id());
|
||||
|
||||
let u = new_record_uuid();
|
||||
assert_eq!(u.len(), 36);
|
||||
let parts: Vec<&str> = u.split('-').collect();
|
||||
assert_eq!(
|
||||
parts.iter().map(|p| p.len()).collect::<Vec<_>>(),
|
||||
vec![8, 4, 4, 4, 12]
|
||||
);
|
||||
assert!(u.chars().all(|c| c == '-' || c.is_ascii_hexdigit()));
|
||||
assert_eq!(parts[2].as_bytes()[0], b'4'); // version nibble
|
||||
assert!(matches!(parts[3].as_bytes()[0], b'8' | b'9' | b'a' | b'b'));
|
||||
assert_ne!(u, new_record_uuid());
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,11 @@ pub struct SessionParams {
|
||||
/// re-requests a keyframe. Decode itself succeeds in that state, so nothing else
|
||||
/// would recover — without this the stream stays black.
|
||||
pub force_software: Arc<AtomicBool>,
|
||||
/// Name of the settings profile these params were resolved with (`None` = the global
|
||||
/// defaults). Display only — every value it influenced is already baked into the fields
|
||||
/// above; it rides along so the stats overlay can answer "which profile am I on?" without
|
||||
/// re-reading any store (design/client-settings-profiles.md §5.2).
|
||||
pub profile: Option<String>,
|
||||
}
|
||||
|
||||
/// The session pump's share of the unified stats window (design/stats-unification.md):
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
//! shell stays the settings file's only writer (the session only reads). Pre-unification
|
||||
//! shell files (≤ 0.8.4: `show_hud`, `engine`) still load — see the migration test below.
|
||||
|
||||
use crate::profiles::{ProfilesFile, Resolution, StreamProfile};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::quic::endpoint;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn config_dir() -> Result<PathBuf> {
|
||||
#[cfg(windows)]
|
||||
@@ -89,6 +90,25 @@ fn lock_identity_perms(dir: &std::path::Path, key: &std::path::Path) {
|
||||
let _ = std::fs::set_permissions(key, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
/// Write a config file the safe way: a sibling temp file, then a rename over the target. A
|
||||
/// plain `fs::write` truncates first, so a crash, a full disk or a power cut between truncate
|
||||
/// and the last byte leaves an empty/half file — and these stores are what a client needs to
|
||||
/// find its hosts at all. Rename is atomic within a directory on both Unix and Windows
|
||||
/// (`MoveFileEx` with replace), so a reader ever sees the old file or the new one, never a
|
||||
/// torn one. Same discipline as the host's `session_settings.rs`.
|
||||
pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
match std::fs::rename(&tmp, path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
// Don't leave the temp behind to confuse the next writer (or a backup tool).
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hex(fp: &[u8; 32]) -> String {
|
||||
fp.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
@@ -130,6 +150,63 @@ pub struct KnownHost {
|
||||
/// also advertise `HOST_CAP_CLIPBOARD` and have its own policy enabled.
|
||||
#[serde(default)]
|
||||
pub clipboard_sync: bool,
|
||||
/// This host's default settings profile (design/client-settings-profiles.md §4.1) — the
|
||||
/// one a plain click uses. `None`, or an id whose profile was deleted, means the global
|
||||
/// defaults, i.e. exactly today's behavior; a dangling binding never errors and never
|
||||
/// blocks a connect.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile_id: Option<String>,
|
||||
/// Profiles pinned as extra cards for this host (design §5.2a); order = card order.
|
||||
/// Presentation only — NOT the default (that's `profile_id`) — and duplicates/dangling
|
||||
/// ids are dropped when the list is resolved against the catalog.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub pinned_profiles: Vec<String>,
|
||||
/// Stable record identity (design §4.5): minted lazily for records that predate it, never
|
||||
/// changed afterwards, so a deep link or a future cross-reference has something to point
|
||||
/// at that survives a rename or a new DHCP lease. **No lookup in this crate is keyed by
|
||||
/// it** — `fp_hex`/`addr:port` stay the lookup keys; this is groundwork.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for KnownHost {
|
||||
/// A blank record with a fresh stable id — the base every construction site builds on
|
||||
/// (`KnownHost { name, addr, port, ..Default::default() }`), so adding a field here can't
|
||||
/// silently produce records that lack it. That is not hypothetical: `clipboard_sync`
|
||||
/// survives today only because [`KnownHosts::upsert`] happens to skip it.
|
||||
fn default() -> KnownHost {
|
||||
KnownHost {
|
||||
name: String::new(),
|
||||
addr: String::new(),
|
||||
port: 9777,
|
||||
fp_hex: String::new(),
|
||||
paired: false,
|
||||
last_used: None,
|
||||
mac: Vec::new(),
|
||||
clipboard_sync: false,
|
||||
profile_id: None,
|
||||
pinned_profiles: Vec::new(),
|
||||
id: Some(crate::profiles::new_record_uuid()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KnownHost {
|
||||
/// This host's pinned profiles that still exist, in card order, without duplicates — what
|
||||
/// a grid renders. Dangling pins (the profile was deleted) simply disappear, per design
|
||||
/// §5.2a: a pin is presentation state, never a reason to show an error.
|
||||
pub fn resolved_pins<'a>(&self, catalog: &'a ProfilesFile) -> Vec<&'a StreamProfile> {
|
||||
let mut out: Vec<&StreamProfile> = Vec::new();
|
||||
for id in &self.pinned_profiles {
|
||||
if out.iter().any(|p| p.id == *id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(p) = catalog.find_by_id(id) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
@@ -142,18 +219,42 @@ impl KnownHosts {
|
||||
Ok(config_dir()?.join("client-known-hosts.json"))
|
||||
}
|
||||
|
||||
/// The store, with any pre-[`KnownHost::id`] records given one. The mint is written back
|
||||
/// best-effort right here rather than "on the next save" so the id a caller sees is the
|
||||
/// id that is on disk — an identity that changed every load would be worse than none.
|
||||
/// A read-only config dir just keeps re-minting in memory, which harms nothing: no lookup
|
||||
/// is keyed by the id yet (design §4.5).
|
||||
pub fn load() -> KnownHosts {
|
||||
Self::path()
|
||||
let mut k: KnownHosts = Self::path()
|
||||
.and_then(|p| Ok(std::fs::read_to_string(p)?))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
if k.mint_missing_ids() {
|
||||
let _ = k.save();
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// Give every record still missing one a stable id; returns true if anything changed
|
||||
/// (i.e. whether this needs persisting). Idempotent — a store that has been through it
|
||||
/// once is left byte-identical.
|
||||
pub fn mint_missing_ids(&mut self) -> bool {
|
||||
let mut minted = false;
|
||||
for h in &mut self.hosts {
|
||||
if h.id.as_deref().is_none_or(str::is_empty) {
|
||||
h.id = Some(crate::profiles::new_record_uuid());
|
||||
minted = true;
|
||||
}
|
||||
}
|
||||
minted
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let p = Self::path()?;
|
||||
std::fs::create_dir_all(p.parent().unwrap())?;
|
||||
std::fs::write(&p, serde_json::to_string_pretty(self)?)?;
|
||||
// Temp+rename: losing this file to a torn write costs the user every pairing.
|
||||
write_atomic(&p, serde_json::to_string_pretty(self)?.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -189,6 +290,24 @@ impl KnownHosts {
|
||||
if !entry.mac.is_empty() {
|
||||
h.mac = entry.mac;
|
||||
}
|
||||
// Everything below is state the user set ON this record, which a refresh (a
|
||||
// reconnect, a re-pair, a rediscovery) never carries and therefore must never
|
||||
// clear: the per-host clipboard decision — which survives today only because this
|
||||
// function happens not to mention it — plus the profile binding, its pinned
|
||||
// cards, and the stable id. Only an upsert that actually carries a value moves
|
||||
// one of them.
|
||||
if entry.clipboard_sync {
|
||||
h.clipboard_sync = true;
|
||||
}
|
||||
if entry.profile_id.is_some() {
|
||||
h.profile_id = entry.profile_id;
|
||||
}
|
||||
if !entry.pinned_profiles.is_empty() {
|
||||
h.pinned_profiles = entry.pinned_profiles;
|
||||
}
|
||||
if h.id.as_deref().is_none_or(str::is_empty) {
|
||||
h.id = entry.id;
|
||||
}
|
||||
} else {
|
||||
self.hosts.push(entry);
|
||||
}
|
||||
@@ -199,15 +318,17 @@ impl KnownHosts {
|
||||
/// ceremony, delegated approval, headless pairing) ends in.
|
||||
pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: bool) {
|
||||
let mut known = KnownHosts::load();
|
||||
// `..Default::default()` deliberately: this builds a record from a trust decision only,
|
||||
// so every user-set field (clipboard, profile binding, pins) must arrive as "not carried"
|
||||
// — `upsert` then leaves an existing host's own settings alone. A hand-written literal
|
||||
// here is how those fields would get silently reset on the next re-pair.
|
||||
known.upsert(KnownHost {
|
||||
name: name.to_string(),
|
||||
addr: addr.to_string(),
|
||||
port,
|
||||
fp_hex: fp_hex.to_string(),
|
||||
paired,
|
||||
last_used: None,
|
||||
mac: Vec::new(),
|
||||
clipboard_sync: false,
|
||||
..Default::default()
|
||||
});
|
||||
let _ = known.save();
|
||||
}
|
||||
@@ -539,7 +660,7 @@ impl MouseMode {
|
||||
|
||||
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
/// Stream mode; `0` = the native size/refresh of the monitor the window is on,
|
||||
@@ -769,15 +890,82 @@ impl Settings {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Fire-and-forget by design (a failed settings write must never take a stream down),
|
||||
/// but temp+rename: this file has five whole-file writers, and a torn one loads as
|
||||
/// `Default` — i.e. silently resets every setting the user has.
|
||||
pub fn save(&self) {
|
||||
let Ok(p) = Self::path() else { return };
|
||||
let _ = std::fs::create_dir_all(p.parent().unwrap());
|
||||
if let Ok(s) = serde_json::to_string_pretty(self) {
|
||||
let _ = std::fs::write(&p, s);
|
||||
let _ = write_atomic(&p, s.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The one settings resolver every front-end and the session binary go through
|
||||
/// (design/client-settings-profiles.md §4.4/§4.6): global defaults, with the profile this
|
||||
/// connect uses overlaid.
|
||||
///
|
||||
/// ```text
|
||||
/// effective = overlay(profile).apply(global)
|
||||
/// profile = one-off override ?? host binding ?? none
|
||||
/// ```
|
||||
///
|
||||
/// `one_off` is the "Connect with ▸ X" / `--profile` / `profile=` pick, by id or unique name;
|
||||
/// `Some("")` forces the global defaults on a bound host. It never rebinds anything — the
|
||||
/// host's default is changed only by an explicit act in the UI.
|
||||
///
|
||||
/// Nothing here fails: an unknown one-off falls back to the *defaults* (not to the host's
|
||||
/// binding — a connect that was explicitly asked for "Work" must not silently run "Game"),
|
||||
/// and a dangling binding resolves as none, exactly today's behavior. The host is looked up
|
||||
/// by `addr:port`, the same match the per-host clipboard decision has always used —
|
||||
/// consistency with the shipped precedent beats purity here (§4.6).
|
||||
pub fn effective_settings(
|
||||
addr: &str,
|
||||
port: u16,
|
||||
one_off: Option<&str>,
|
||||
) -> (Settings, Option<StreamProfile>) {
|
||||
let base = Settings::load();
|
||||
let catalog = ProfilesFile::load();
|
||||
let bound = KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr && h.port == port)
|
||||
.and_then(|h| h.profile_id.clone());
|
||||
|
||||
match resolve_profile(&catalog, bound.as_deref(), one_off) {
|
||||
Some(p) => (p.overrides.apply(&base), Some(p)),
|
||||
None => (base, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The profile half of [`effective_settings`], split out so the precedence rules are testable
|
||||
/// without touching the config directory: one-off pick ?? host binding ?? none.
|
||||
fn resolve_profile(
|
||||
catalog: &ProfilesFile,
|
||||
bound: Option<&str>,
|
||||
one_off: Option<&str>,
|
||||
) -> Option<StreamProfile> {
|
||||
match one_off {
|
||||
// `--profile ""` — "Connect with ▸ Default settings" on a bound host.
|
||||
Some("") => None,
|
||||
Some(reference) => match catalog.resolve(reference) {
|
||||
(Some(p), _) => Some(p.clone()),
|
||||
(_, res) => {
|
||||
tracing::warn!(
|
||||
profile = %reference,
|
||||
ambiguous = res == Resolution::Ambiguous,
|
||||
"no such settings profile — streaming with the default settings"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
// A binding is an id, never a name: it was written by a picker, and resolving it by
|
||||
// name would let renaming another profile hijack it. Dangling → the defaults.
|
||||
None => bound.and_then(|id| catalog.find_by_id(id).cloned()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -882,4 +1070,223 @@ mod tests {
|
||||
assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]);
|
||||
assert!(parse_hex32(&h.fp_hex).is_some());
|
||||
}
|
||||
|
||||
/// A pre-profiles known-hosts file loads unchanged — no binding, no pins — and its
|
||||
/// records serialize back without the new keys, so an older client reading the same file
|
||||
/// sees exactly what it wrote. The id is minted only when `load()` runs (the migration
|
||||
/// step), not by deserialization.
|
||||
#[test]
|
||||
fn known_hosts_migration_is_a_no_op_on_a_pre_profiles_store() {
|
||||
let old = r#"{"hosts":[{
|
||||
"name": "Gaming PC", "addr": "192.168.1.50", "port": 9777,
|
||||
"fp_hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"paired": true, "clipboard_sync": true
|
||||
}]}"#;
|
||||
let mut k: KnownHosts = serde_json::from_str(old).unwrap();
|
||||
let h = &k.hosts[0];
|
||||
assert_eq!(h.profile_id, None);
|
||||
assert!(h.pinned_profiles.is_empty());
|
||||
assert_eq!(h.id, None);
|
||||
assert!(h.clipboard_sync);
|
||||
let text = serde_json::to_string(&k).unwrap();
|
||||
assert!(!text.contains("profile_id"));
|
||||
assert!(!text.contains("pinned_profiles"));
|
||||
assert!(!text.contains("\"id\""));
|
||||
|
||||
// Minting is idempotent: the second pass reports nothing to persist and leaves the
|
||||
// id it handed out alone.
|
||||
assert!(k.mint_missing_ids());
|
||||
let minted = k.hosts[0].id.clone().unwrap();
|
||||
assert_eq!(minted.len(), 36);
|
||||
assert!(!k.mint_missing_ids());
|
||||
assert_eq!(k.hosts[0].id.as_deref(), Some(minted.as_str()));
|
||||
// An empty-string id (a hand-edited store) counts as missing, not as an identity.
|
||||
k.hosts[0].id = Some(String::new());
|
||||
assert!(k.mint_missing_ids());
|
||||
assert_ne!(k.hosts[0].id.as_deref(), Some(""));
|
||||
}
|
||||
|
||||
/// `upsert` refreshes what a reconnect actually knows and preserves what the user set:
|
||||
/// the profile binding, the pinned cards, the clipboard decision and the stable id all
|
||||
/// survive a trust-decision upsert that carries none of them (the bug `clipboard_sync`
|
||||
/// only ever avoided by accident).
|
||||
#[test]
|
||||
fn upsert_preserves_user_set_host_state() {
|
||||
let fp = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let mut k = KnownHosts {
|
||||
hosts: vec![KnownHost {
|
||||
name: "Desk".into(),
|
||||
addr: "192.168.1.50".into(),
|
||||
port: 9777,
|
||||
fp_hex: fp.into(),
|
||||
paired: true,
|
||||
last_used: Some(1000),
|
||||
mac: vec!["aa:bb:cc:dd:ee:ff".into()],
|
||||
clipboard_sync: true,
|
||||
profile_id: Some("aaaaaaaaaaaa".into()),
|
||||
pinned_profiles: vec!["bbbbbbbbbbbb".into()],
|
||||
id: Some("11111111-2222-4333-8444-555555555555".into()),
|
||||
}],
|
||||
};
|
||||
// What `persist_host` builds: a trust decision, nothing else.
|
||||
k.upsert(KnownHost {
|
||||
name: "Desk".into(),
|
||||
addr: "192.168.1.51".into(), // new lease
|
||||
port: 9777,
|
||||
fp_hex: fp.into(),
|
||||
paired: false, // must not demote
|
||||
..Default::default()
|
||||
});
|
||||
let h = &k.hosts[0];
|
||||
assert_eq!(k.hosts.len(), 1);
|
||||
assert_eq!(h.addr, "192.168.1.51");
|
||||
assert!(h.paired);
|
||||
assert_eq!(h.last_used, Some(1000));
|
||||
assert_eq!(h.mac, vec!["aa:bb:cc:dd:ee:ff".to_string()]);
|
||||
assert!(h.clipboard_sync);
|
||||
assert_eq!(h.profile_id.as_deref(), Some("aaaaaaaaaaaa"));
|
||||
assert_eq!(h.pinned_profiles, vec!["bbbbbbbbbbbb".to_string()]);
|
||||
assert_eq!(
|
||||
h.id.as_deref(),
|
||||
Some("11111111-2222-4333-8444-555555555555")
|
||||
);
|
||||
|
||||
// A carried value does move the binding (that is how the UI rebinds through upsert).
|
||||
k.upsert(KnownHost {
|
||||
fp_hex: fp.into(),
|
||||
profile_id: Some("cccccccccccc".into()),
|
||||
pinned_profiles: vec!["dddddddddddd".into()],
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(k.hosts[0].profile_id.as_deref(), Some("cccccccccccc"));
|
||||
assert_eq!(k.hosts[0].pinned_profiles, vec!["dddddddddddd".to_string()]);
|
||||
}
|
||||
|
||||
/// Pins render in card order, deduplicated, with deleted profiles simply gone — a pin is
|
||||
/// presentation state, so a dangling one is never an error surface.
|
||||
#[test]
|
||||
fn resolved_pins_drop_duplicates_and_dangling_ids() {
|
||||
use crate::profiles::{ProfilesFile, StreamProfile};
|
||||
let catalog = ProfilesFile {
|
||||
version: 1,
|
||||
profiles: vec![
|
||||
StreamProfile {
|
||||
id: "aaaaaaaaaaaa".into(),
|
||||
name: "Work".into(),
|
||||
..StreamProfile::new("")
|
||||
},
|
||||
StreamProfile {
|
||||
id: "bbbbbbbbbbbb".into(),
|
||||
name: "Game".into(),
|
||||
..StreamProfile::new("")
|
||||
},
|
||||
],
|
||||
};
|
||||
let h = KnownHost {
|
||||
pinned_profiles: vec![
|
||||
"bbbbbbbbbbbb".into(),
|
||||
"deleted00000".into(),
|
||||
"bbbbbbbbbbbb".into(),
|
||||
"aaaaaaaaaaaa".into(),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let names: Vec<&str> = h
|
||||
.resolved_pins(&catalog)
|
||||
.iter()
|
||||
.map(|p| p.name.as_str())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["Game", "Work"]);
|
||||
assert!(KnownHost::default().resolved_pins(&catalog).is_empty());
|
||||
}
|
||||
|
||||
/// The connect-time precedence: a one-off pick beats the host's binding, `""` forces the
|
||||
/// defaults, a dangling binding resolves as none, and a one-off that can't be honored
|
||||
/// falls back to the DEFAULTS rather than to the host's own profile — "connect with Work"
|
||||
/// must never quietly run "Game".
|
||||
#[test]
|
||||
fn profile_resolution_precedence() {
|
||||
use crate::profiles::{ProfilesFile, StreamProfile};
|
||||
let catalog = ProfilesFile {
|
||||
version: 1,
|
||||
profiles: vec![
|
||||
StreamProfile {
|
||||
id: "aaaaaaaaaaaa".into(),
|
||||
name: "Game".into(),
|
||||
..StreamProfile::new("")
|
||||
},
|
||||
StreamProfile {
|
||||
id: "bbbbbbbbbbbb".into(),
|
||||
name: "Work".into(),
|
||||
..StreamProfile::new("")
|
||||
},
|
||||
StreamProfile {
|
||||
id: "cccccccccccc".into(),
|
||||
name: "work".into(),
|
||||
..StreamProfile::new("")
|
||||
},
|
||||
],
|
||||
};
|
||||
let name_of = |p: Option<StreamProfile>| p.map(|p| p.name);
|
||||
|
||||
// No binding, no pick: today's behavior.
|
||||
assert_eq!(resolve_profile(&catalog, None, None), None);
|
||||
// The binding drives a plain connect…
|
||||
assert_eq!(
|
||||
name_of(resolve_profile(&catalog, Some("aaaaaaaaaaaa"), None)),
|
||||
Some("Game".into())
|
||||
);
|
||||
// …a one-off overrides it, by id or by unique name…
|
||||
assert_eq!(
|
||||
name_of(resolve_profile(
|
||||
&catalog,
|
||||
Some("aaaaaaaaaaaa"),
|
||||
Some("bbbbbbbbbbbb")
|
||||
)),
|
||||
Some("Work".into())
|
||||
);
|
||||
assert_eq!(
|
||||
name_of(resolve_profile(&catalog, None, Some("GAME"))),
|
||||
Some("Game".into())
|
||||
);
|
||||
// …and `""` forces the defaults on a bound host.
|
||||
assert_eq!(
|
||||
resolve_profile(&catalog, Some("aaaaaaaaaaaa"), Some("")),
|
||||
None
|
||||
);
|
||||
// A deleted binding is not an error, it is "no profile".
|
||||
assert_eq!(resolve_profile(&catalog, Some("deleted00000"), None), None);
|
||||
// Unknown and ambiguous one-offs fall back to the defaults, NOT to the binding.
|
||||
assert_eq!(
|
||||
resolve_profile(&catalog, Some("aaaaaaaaaaaa"), Some("nope")),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_profile(&catalog, Some("aaaaaaaaaaaa"), Some("work")),
|
||||
None
|
||||
);
|
||||
// A binding resolves by id only — a profile NAMED like the bound id doesn't hijack it.
|
||||
assert_eq!(resolve_profile(&catalog, Some("Game"), None), None);
|
||||
}
|
||||
|
||||
/// The atomic write replaces the target in one step and leaves no temp behind — the
|
||||
/// discipline all three client stores now share.
|
||||
#[test]
|
||||
fn write_atomic_replaces_and_cleans_up() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pf-client-core-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("store.json");
|
||||
write_atomic(&p, b"{\"a\":1}").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":1}");
|
||||
write_atomic(&p, b"{\"a\":2}").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":2}");
|
||||
assert!(!p.with_extension("json.tmp").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
//! LUID) so the shared textures never cross GPUs on a multi-adapter box.
|
||||
|
||||
use crate::video::ColorDesc;
|
||||
use crate::video_libav::AvBuffer;
|
||||
use anyhow::{anyhow, bail, Context as _, Result};
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use std::ffi::c_void;
|
||||
@@ -225,11 +226,13 @@ fn decode_profile_supported(device: &ID3D11Device, codec_id: ffmpeg::codec::Id)
|
||||
unsafe fn d3d11va_decode_supported(hw_device: *mut ffmpeg::ffi::AVBufferRef) -> bool {
|
||||
use ffmpeg::ffi::*;
|
||||
unsafe {
|
||||
let frames_ref = av_hwframe_ctx_alloc(hw_device);
|
||||
if frames_ref.is_null() {
|
||||
// Scope-bound: this probe owns the frames ctx for the length of the check and the drop
|
||||
// below releases it on BOTH exits, instead of the early return relying on the null case and
|
||||
// the success path unref'ing by hand.
|
||||
let Some(frames_ref) = AvBuffer::from_raw(av_hwframe_ctx_alloc(hw_device)) else {
|
||||
return false;
|
||||
}
|
||||
let frames = (*frames_ref).data as *mut AVHWFramesContext;
|
||||
};
|
||||
let frames = (*frames_ref.as_ptr()).data as *mut AVHWFramesContext;
|
||||
(*frames).format = AVPixelFormat::AV_PIX_FMT_D3D11;
|
||||
(*frames).sw_format = AVPixelFormat::AV_PIX_FMT_NV12;
|
||||
(*frames).width = 1920;
|
||||
@@ -237,9 +240,7 @@ unsafe fn d3d11va_decode_supported(hw_device: *mut ffmpeg::ffi::AVBufferRef) ->
|
||||
(*frames).initial_pool_size = DECODE_POOL_SIZE;
|
||||
let fhw = (*frames).hwctx as *mut AVD3D11VAFramesContext;
|
||||
(*fhw).bind_flags = BIND_DECODER;
|
||||
let r = av_hwframe_ctx_init(frames_ref);
|
||||
let mut fr = frames_ref;
|
||||
av_buffer_unref(&mut fr);
|
||||
let r = av_hwframe_ctx_init(frames_ref.as_ptr());
|
||||
r >= 0
|
||||
}
|
||||
}
|
||||
@@ -467,7 +468,14 @@ impl SharedRing {
|
||||
|
||||
pub(crate) struct D3d11vaDecoder {
|
||||
ctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||
hw_device: *mut ffmpeg::ffi::AVBufferRef,
|
||||
/// The D3D11VA hwdevice, owned. Nothing reads this field after construction — the codec context
|
||||
/// took its own ref via `av_buffer_ref` — it exists so the device outlives the decoder and is
|
||||
/// unref'd exactly once when it drops. Declared after `ctx` so it still releases AFTER the
|
||||
/// `Drop` below frees packet/frame/context, which is the order the hand-written unref had.
|
||||
/// `dead_code` is answered here rather than by removing the field (that would free the device
|
||||
/// early) or by an underscore name (that would hide what it is).
|
||||
#[allow(dead_code)]
|
||||
hw_device: AvBuffer,
|
||||
packet: *mut ffmpeg::ffi::AVPacket,
|
||||
frame: *mut ffmpeg::ffi::AVFrame,
|
||||
device: ID3D11Device,
|
||||
@@ -525,20 +533,19 @@ impl D3d11vaDecoder {
|
||||
ffi::av_buffer_unref(&mut hw);
|
||||
bail!("av_hwdevice_ctx_init: {}", ffmpeg::Error::from(r));
|
||||
}
|
||||
// Owned from here: every `bail!` below drops it, so none of them unref by hand.
|
||||
let hw_device = AvBuffer::from_raw(hw_device)
|
||||
.context("av_hwdevice_ctx_alloc(D3D11VA) gave no device")?;
|
||||
// Up-front viability probe (see `d3d11va_decode_supported`).
|
||||
if !d3d11va_decode_supported(hw_device) {
|
||||
let mut hw = hw_device;
|
||||
ffi::av_buffer_unref(&mut hw);
|
||||
if !d3d11va_decode_supported(hw_device.as_ptr()) {
|
||||
bail!("GPU can't create the D3D11VA decode surface pool");
|
||||
}
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
let mut hw = hw_device;
|
||||
ffi::av_buffer_unref(&mut hw);
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
|
||||
(*ctx).get_format = Some(get_format_d3d11);
|
||||
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
||||
(*ctx).thread_count = 1; // hwaccel: threads only add latency
|
||||
@@ -549,8 +556,6 @@ impl D3d11vaDecoder {
|
||||
if r < 0 {
|
||||
let mut ctx = ctx;
|
||||
ffi::avcodec_free_context(&mut ctx);
|
||||
let mut hw = hw_device;
|
||||
ffi::av_buffer_unref(&mut hw);
|
||||
bail!("avcodec_open2 (D3D11VA): {}", ffmpeg::Error::from(r));
|
||||
}
|
||||
Ok(D3d11vaDecoder {
|
||||
@@ -789,7 +794,7 @@ impl Drop for D3d11vaDecoder {
|
||||
ffi::av_packet_free(&mut self.packet);
|
||||
ffi::av_frame_free(&mut self.frame);
|
||||
ffi::avcodec_free_context(&mut self.ctx);
|
||||
ffi::av_buffer_unref(&mut self.hw_device);
|
||||
// `hw_device` is an `AvBuffer` and unrefs itself when the field drops, right after this.
|
||||
}
|
||||
// `ring` drops after the codec: no decode can be in flight past avcodec_free_context,
|
||||
// and the slots' CloseHandle only closes OUR handle — a presenter-side import that is
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Shared libav ownership helpers for the hardware decoders (`video_vaapi`, `video_vulkan`,
|
||||
//! `video_d3d11`).
|
||||
//!
|
||||
//! The host has its own copy of this in `pf-encode`'s `enc/libav.rs`. The two crates do not depend
|
||||
//! on each other — host encode and client decode share no code path — and the client's copy needs
|
||||
//! something the host's does not ([`AvBuffer::into_raw`], for the decoder contexts that take
|
||||
//! ownership of our ref), so a shared crate would have to carry an ffmpeg dependency and both sets
|
||||
//! of semantics to save ~20 lines. It is not worth the edge.
|
||||
|
||||
use ffmpeg_next::ffi;
|
||||
|
||||
/// An owned `AVBufferRef`, unref'd exactly once when it drops.
|
||||
///
|
||||
/// Each decoder constructor creates a hwdevice and then does several more fallible things with it
|
||||
/// — find the codec, alloc a context, open it — and every one of those failure branches used to
|
||||
/// unref the device by hand, alongside a `Drop` doing it once more. Miss a branch and a decoder
|
||||
/// device leaks per failed negotiation; double it up and the process aborts. Ownership lives here
|
||||
/// instead, so an early `bail!` releases whatever exists and the branches carry no cleanup.
|
||||
pub(crate) struct AvBuffer(*mut ffi::AVBufferRef);
|
||||
|
||||
impl AvBuffer {
|
||||
/// Take ownership of a freshly-created `AVBufferRef`, rejecting the null an ffmpeg allocator
|
||||
/// returns on failure.
|
||||
///
|
||||
/// # Safety
|
||||
/// `p` must be null, or a live `AVBufferRef` whose ownership passes to the returned value —
|
||||
/// nothing else may unref it.
|
||||
pub(crate) unsafe fn from_raw(p: *mut ffi::AVBufferRef) -> Option<Self> {
|
||||
(!p.is_null()).then_some(AvBuffer(p))
|
||||
}
|
||||
|
||||
/// The borrowed pointer, for calls that read the ref without taking it (e.g. `av_buffer_ref`,
|
||||
/// which makes its own).
|
||||
pub(crate) fn as_ptr(&self) -> *mut ffi::AVBufferRef {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Give up ownership: the caller becomes responsible for the unref.
|
||||
///
|
||||
/// This exists for the `get_format` callbacks, which hand a frames context to the codec —
|
||||
/// `(*ctx).hw_frames_ctx = fr` means *the codec owns our ref now*, and it unrefs it when the
|
||||
/// context closes. Dropping an `AvBuffer` there as well would be the double-unref this type is
|
||||
/// meant to prevent, so the transfer is made explicit rather than left implicit.
|
||||
pub(crate) fn into_raw(self) -> *mut ffi::AVBufferRef {
|
||||
let p = self.0;
|
||||
std::mem::forget(self);
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AvBuffer {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the non-null ref `from_raw` took ownership of, and this type is its
|
||||
// sole owner (neither `Clone` nor `Copy`; `as_ptr` only lends, and `into_raw` forgets
|
||||
// instead of dropping), so this runs exactly once for that reference. `av_buffer_unref`
|
||||
// drops the one reference and nulls the pointer through the `&mut`.
|
||||
unsafe { ffi::av_buffer_unref(&mut self.0) };
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,13 @@
|
||||
//! rings are retired, not destroyed — the presenter may still hold their views (see
|
||||
//! [`RETIRE_HANDOVERS`]).
|
||||
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is `pyrowave-sys` C-API and ash/Vulkan compute calls almost line for line;
|
||||
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
|
||||
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
|
||||
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
use crate::video::{ColorDesc, VulkanDecodeDevice};
|
||||
use anyhow::{bail, Context as _, Result};
|
||||
use ash::vk;
|
||||
|
||||
@@ -5,7 +5,8 @@ use crate::video::{
|
||||
AVERROR_EAGAIN,
|
||||
};
|
||||
use crate::video_color::ColorDesc;
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use crate::video_libav::AvBuffer;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use std::ptr;
|
||||
|
||||
@@ -32,7 +33,14 @@ unsafe extern "C" fn pick_vaapi(
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) struct VaapiDecoder {
|
||||
ctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||
hw_device: *mut ffmpeg::ffi::AVBufferRef,
|
||||
/// The VAAPI hwdevice, owned. Nothing reads this field after construction — the codec context
|
||||
/// took its own ref via `av_buffer_ref` — it exists so the device outlives the decoder and is
|
||||
/// unref'd exactly once when it drops. Declared after `ctx` so it still releases AFTER the
|
||||
/// `Drop` below frees packet/frame/context, which is the order the hand-written unref had.
|
||||
/// `dead_code` is answered here rather than by removing the field (that would free the device
|
||||
/// early) or by an underscore name (that would hide what it is).
|
||||
#[allow(dead_code)]
|
||||
hw_device: AvBuffer,
|
||||
packet: *mut ffmpeg::ffi::AVPacket,
|
||||
frame: *mut ffmpeg::ffi::AVFrame,
|
||||
}
|
||||
@@ -57,14 +65,16 @@ impl VaapiDecoder {
|
||||
if r < 0 {
|
||||
bail!("no VAAPI device ({})", ffmpeg::Error::from(r));
|
||||
}
|
||||
// Owned from here: every `bail!` below drops it, so none of them unref by hand.
|
||||
let hw_device = AvBuffer::from_raw(hw_device)
|
||||
.context("av_hwdevice_ctx_create(VAAPI) gave no device")?;
|
||||
// The negotiated codec's decoder id (av_codec_id maps 1:1 from ffmpeg::codec::Id).
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
ffi::av_buffer_unref(&mut hw_device);
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
|
||||
(*ctx).get_format = Some(pick_vaapi);
|
||||
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
||||
(*ctx).thread_count = 1; // hwaccel: threads only add latency
|
||||
@@ -80,8 +90,6 @@ impl VaapiDecoder {
|
||||
if r < 0 {
|
||||
let mut ctx = ctx;
|
||||
ffi::avcodec_free_context(&mut ctx);
|
||||
let mut hw_device = hw_device;
|
||||
ffi::av_buffer_unref(&mut hw_device);
|
||||
bail!("avcodec_open2: {}", ffmpeg::Error::from(r));
|
||||
}
|
||||
Ok(VaapiDecoder {
|
||||
@@ -237,7 +245,7 @@ impl Drop for VaapiDecoder {
|
||||
ffi::av_packet_free(&mut self.packet);
|
||||
ffi::av_frame_free(&mut self.frame);
|
||||
ffi::avcodec_free_context(&mut self.ctx);
|
||||
ffi::av_buffer_unref(&mut self.hw_device);
|
||||
// `hw_device` is an `AvBuffer` and unrefs itself when the field drops, right after this.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
//! FFmpeg Vulkan Video decode over the presenter's own VkDevice (zero-copy VkImage).
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw libav + ash calls on the presenter's VkDevice almost line for line;
|
||||
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
|
||||
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
|
||||
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
#![allow(clippy::unnecessary_cast)]
|
||||
|
||||
use crate::video::{
|
||||
@@ -6,7 +12,8 @@ use crate::video::{
|
||||
AVERROR_EAGAIN,
|
||||
};
|
||||
use crate::video_color::ColorDesc;
|
||||
use anyhow::{bail, Result};
|
||||
use crate::video_libav::AvBuffer;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use ffmpeg_next as ffmpeg;
|
||||
use std::ptr;
|
||||
|
||||
@@ -18,7 +25,14 @@ use std::ptr;
|
||||
/// frames are `AVVkFrame`s whose VkImage the presenter feeds straight to its CSC pass.
|
||||
pub(crate) struct VulkanDecoder {
|
||||
ctx: *mut ffmpeg::ffi::AVCodecContext,
|
||||
hw_device: *mut ffmpeg::ffi::AVBufferRef,
|
||||
/// The Vulkan hwdevice, owned. Nothing reads this field after construction — the codec context
|
||||
/// took its own ref via `av_buffer_ref` — it exists so the device outlives the decoder and is
|
||||
/// unref'd exactly once when it drops. Declared after `ctx` so it still releases AFTER the
|
||||
/// `Drop` below frees packet/frame/context, which is the order the hand-written unref had.
|
||||
/// `dead_code` is answered here rather than by removing the field (that would free the device
|
||||
/// early) or by an underscore name (that would hide what it is).
|
||||
#[allow(dead_code)]
|
||||
hw_device: AvBuffer,
|
||||
packet: *mut ffmpeg::ffi::AVPacket,
|
||||
frame: *mut ffmpeg::ffi::AVFrame,
|
||||
/// `vkWaitSemaphores` on the shared device — the decode-complete measurement
|
||||
@@ -187,6 +201,9 @@ impl VulkanDecoder {
|
||||
ffi::av_buffer_unref(&mut hw_device);
|
||||
return Err(averr("av_hwdevice_ctx_init(VULKAN)", r));
|
||||
}
|
||||
// Owned from here: every failure path below drops it instead of unref'ing by hand.
|
||||
let hw_device = AvBuffer::from_raw(hw_device)
|
||||
.context("av_hwdevice_ctx_alloc(VULKAN) gave no device")?;
|
||||
|
||||
// vkWaitSemaphores for the pump's decode-complete stat: loader →
|
||||
// vkGetDeviceProcAddr → device fn (core 1.2, guaranteed by our gate).
|
||||
@@ -201,18 +218,16 @@ impl VulkanDecoder {
|
||||
c"vkWaitSemaphores".as_ptr(),
|
||||
));
|
||||
if wait_semaphores.is_none() {
|
||||
ffi::av_buffer_unref(&mut hw_device);
|
||||
bail!("vkWaitSemaphores unresolvable on this device");
|
||||
}
|
||||
let vk_device = (*hwctx).act_dev;
|
||||
|
||||
let codec = ffi::avcodec_find_decoder(codec_id.into());
|
||||
if codec.is_null() {
|
||||
ffi::av_buffer_unref(&mut hw_device);
|
||||
bail!("no {codec_id:?} decoder");
|
||||
}
|
||||
let ctx = ffi::avcodec_alloc_context3(codec);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device);
|
||||
(*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr());
|
||||
(*ctx).get_format = Some(pick_vulkan);
|
||||
(*ctx).flags |= ffi::AV_CODEC_FLAG_LOW_DELAY as i32;
|
||||
(*ctx).thread_count = 1; // hwaccel: threads only add latency
|
||||
@@ -223,7 +238,6 @@ impl VulkanDecoder {
|
||||
if r < 0 {
|
||||
let mut ctx = ctx;
|
||||
ffi::avcodec_free_context(&mut ctx);
|
||||
ffi::av_buffer_unref(&mut hw_device);
|
||||
return Err(averr("avcodec_open2 (vulkan)", r));
|
||||
}
|
||||
Ok(VulkanDecoder {
|
||||
@@ -358,7 +372,7 @@ impl Drop for VulkanDecoder {
|
||||
ffi::av_packet_free(&mut self.packet);
|
||||
ffi::av_frame_free(&mut self.frame);
|
||||
ffi::avcodec_free_context(&mut self.ctx);
|
||||
ffi::av_buffer_unref(&mut self.hw_device);
|
||||
// `hw_device` is an `AvBuffer` and unrefs itself when the field drops, right after this.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -396,24 +410,29 @@ unsafe extern "C" fn pick_vulkan(
|
||||
tracing::warn!(code = r, "avcodec_get_hw_frames_parameters(VULKAN) failed");
|
||||
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||
}
|
||||
let fc = (*fr).data as *mut ffi::AVHWFramesContext;
|
||||
// Owned until the codec takes it at the bottom: the init-failure path below just returns
|
||||
// and the drop releases it.
|
||||
let Some(fr) = AvBuffer::from_raw(fr) else {
|
||||
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||
};
|
||||
let fc = (*fr.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
||||
let vkfc = (*fc).hwctx as *mut pf_ffvk::AVVulkanFramesContext;
|
||||
// MUTABLE_FORMAT: per-plane views (spec requirement); ALIAS is FFmpeg's default.
|
||||
// (`as _`: the FlagBits constants are i32 under MSVC, the img_flags field u32.)
|
||||
(*vkfc).img_flags = (pf_ffvk::VkImageCreateFlagBits_VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT
|
||||
| pf_ffvk::VkImageCreateFlagBits_VK_IMAGE_CREATE_ALIAS_BIT)
|
||||
as _;
|
||||
let r = ffi::av_hwframe_ctx_init(fr);
|
||||
let r = ffi::av_hwframe_ctx_init(fr.as_ptr());
|
||||
if r < 0 {
|
||||
tracing::warn!(code = r, "av_hwframe_ctx_init(VULKAN) failed");
|
||||
let mut fr = fr;
|
||||
ffi::av_buffer_unref(&mut fr);
|
||||
return ffi::AVPixelFormat::AV_PIX_FMT_NONE;
|
||||
}
|
||||
if !(*ctx).hw_frames_ctx.is_null() {
|
||||
ffi::av_buffer_unref(&mut (*ctx).hw_frames_ctx);
|
||||
}
|
||||
(*ctx).hw_frames_ctx = fr; // the codec owns our ref now
|
||||
// Ownership TRANSFERS to the codec here, so hand over the raw pointer and forget the
|
||||
// wrapper — dropping it as well would be the double-unref `AvBuffer` exists to prevent.
|
||||
(*ctx).hw_frames_ctx = fr.into_raw();
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_VULKAN
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,3 +52,6 @@ windows = { version = "0.62", features = [
|
||||
"Win32_System_Ole",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -33,3 +33,6 @@ sdl3 = { version = "0.18", features = ["hidapi", "ash"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
sdl3 = { version = "0.18", features = ["hidapi", "ash", "build-from-source"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -48,6 +48,10 @@ struct Drawn {
|
||||
height: u32,
|
||||
stats: Option<String>,
|
||||
hint: Option<String>,
|
||||
/// The UI scale this was drawn at, in percent — part of the damage key so dragging the window
|
||||
/// to a differently-scaled monitor re-renders the chrome at the new size instead of keeping
|
||||
/// the stale one (the text is identical, so nothing else here would notice).
|
||||
scale_pct: u16,
|
||||
/// The start banner's alpha, quantized — a fade step is a redraw, steady is not.
|
||||
banner_step: u8,
|
||||
/// The resize scrim's spinner phase, quantized — a nonzero, ever-changing step while a
|
||||
@@ -56,6 +60,26 @@ struct Drawn {
|
||||
resize_step: u16,
|
||||
}
|
||||
|
||||
/// The stream chrome's base metrics, in pixels at 100 % scale (96 dpi). Everything here is
|
||||
/// multiplied by `FrameCtx::scale` before it is drawn: the overlay composites into the swapchain
|
||||
/// 1:1 in PHYSICAL pixels, so on a 4K panel at 200 % an unscaled 14 px OSD renders at half its
|
||||
/// intended physical size — legible on a 1080p monitor, a squint on a HiDPI laptop.
|
||||
mod base {
|
||||
/// The monospace OSD/hint/label size. Also the size the shared `Font` is built at, so the
|
||||
/// scale factor below is exactly the multiplier applied to it.
|
||||
pub const FONT_PX: f32 = 14.0;
|
||||
/// Top-left inset of the stats panel.
|
||||
pub const OSD_MARGIN: f32 = 12.0;
|
||||
/// Stats-panel inner padding and corner radius.
|
||||
pub const OSD_PAD_X: f32 = 10.0;
|
||||
pub const OSD_PAD_Y: f32 = 8.0;
|
||||
pub const OSD_RADIUS: f32 = 8.0;
|
||||
/// Hint/banner pill padding and its gap from the bottom edge.
|
||||
pub const PILL_PAD_X: f32 = 14.0;
|
||||
pub const PILL_PAD_Y: f32 = 8.0;
|
||||
pub const PILL_BOTTOM: f32 = 24.0;
|
||||
}
|
||||
|
||||
/// Where the console starts (the session binary's `--browse` forms).
|
||||
pub enum ConsoleEntry {
|
||||
/// The host list (bare `--browse`).
|
||||
@@ -221,7 +245,7 @@ impl Overlay for SkiaOverlay {
|
||||
skia_safe::FontStyle::normal(),
|
||||
)
|
||||
.context("no monospace typeface (fontconfig alias or system family)")?;
|
||||
self.font = Some(Font::new(typeface, 14.0));
|
||||
self.font = Some(Font::new(typeface, base::FONT_PX));
|
||||
self.fonts = Some(crate::theme::build_fonts()?);
|
||||
|
||||
self.gpu = Some(Gpu {
|
||||
@@ -360,11 +384,15 @@ impl Overlay for SkiaOverlay {
|
||||
self.drawn = Drawn::default(); // forget content so re-show re-renders
|
||||
return Ok(None);
|
||||
}
|
||||
// 1 % granularity: fine enough that no real display scale is rounded into another, coarse
|
||||
// enough that float noise on the same monitor can't churn the damage gate every frame.
|
||||
let scale = ctx.scale.clamp(0.5, 4.0);
|
||||
let want = Drawn {
|
||||
width: ctx.width,
|
||||
height: ctx.height,
|
||||
stats: ctx.stats.map(str::to_owned),
|
||||
hint: ctx.hint.map(str::to_owned),
|
||||
scale_pct: (scale * 100.0).round() as u16,
|
||||
banner_step,
|
||||
resize_step,
|
||||
};
|
||||
@@ -387,16 +415,20 @@ impl Overlay for SkiaOverlay {
|
||||
|
||||
let canvas = slot.surface.canvas();
|
||||
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
|
||||
// Each drawer re-derives the face at its own (fit-clamped) size rather than the canvas
|
||||
// being transformed: Skia hints and rasterizes glyphs at the requested size, so this
|
||||
// stays crisp where a magnified 14 px bitmap would be mush. Only on a damage redraw —
|
||||
// a steady stream re-renders nothing at all.
|
||||
let font = self.font.as_ref().expect("init ran");
|
||||
// The resize scrim sits UNDER the OSD/hint so those stay legible over it.
|
||||
if let Some(phase) = resize_phase {
|
||||
draw_resize_scrim(canvas, font, ctx.width, ctx.height, phase);
|
||||
draw_resize_scrim(canvas, font, ctx.width, ctx.height, phase, scale);
|
||||
}
|
||||
if let Some(stats) = &want.stats {
|
||||
draw_osd_panel(canvas, font, stats, 12.0, 12.0);
|
||||
draw_osd_panel(canvas, font, stats, ctx.width, scale);
|
||||
}
|
||||
if let Some(hint) = &want.hint {
|
||||
draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0);
|
||||
draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0, scale);
|
||||
} else if banner_step > 0 {
|
||||
// The start banner: the leave/stats shortcuts, fading out on its own —
|
||||
// discoverable without the stats overlay, gone before it annoys.
|
||||
@@ -408,6 +440,7 @@ impl Overlay for SkiaOverlay {
|
||||
ctx.width,
|
||||
ctx.height,
|
||||
banner_alpha as f32,
|
||||
scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -543,25 +576,61 @@ impl SkiaOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
/// The stats OSD: a translucent rounded panel, one text line per `\n` (the GTK OSD's
|
||||
/// look, minus the toolkit).
|
||||
fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
|
||||
/// The chrome face at `scale`. `with_size` only fails on a nonsensical size (the caller clamps),
|
||||
/// in which case the unscaled face is still better than no text.
|
||||
fn chrome_font(font: &Font, scale: f32) -> Font {
|
||||
font.with_size(base::FONT_PX * scale)
|
||||
.unwrap_or_else(|| font.clone())
|
||||
}
|
||||
|
||||
/// Shrink `scale` until a box of `width_at_scale` (which must be linear in the scale — every
|
||||
/// chrome metric is) fits in `budget`. Scaling text up by the display's DPI is only an
|
||||
/// improvement while the result still fits the window: the capture hint is a ~150-character line
|
||||
/// that already spans most of a 1280 px window at 100 %, so at 200 % it would run off both edges
|
||||
/// and lose its ends. Fitting keeps it whole, just smaller than the nominal scale.
|
||||
fn fit_scale(scale: f32, width_at_scale: f32, budget: f32) -> f32 {
|
||||
if width_at_scale > budget && width_at_scale > 0.0 {
|
||||
(scale * budget / width_at_scale).max(0.1)
|
||||
} else {
|
||||
scale
|
||||
}
|
||||
}
|
||||
|
||||
/// The stats OSD: a translucent rounded panel in the top-left, one text line per `\n` (the GTK
|
||||
/// OSD's look, minus the toolkit), sized for the display's UI `scale`.
|
||||
fn draw_osd_panel(canvas: &Canvas, base_font: &Font, text: &str, width: u32, scale: f32) {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
// Panel width is linear in the scale, so measuring once at the requested scale is enough to
|
||||
// solve for the scale that keeps the Detailed tier's long lines inside the window instead of
|
||||
// running them past the right edge on a HiDPI display.
|
||||
let width_at = |s: f32| {
|
||||
let font = chrome_font(base_font, s);
|
||||
let widest = lines
|
||||
.iter()
|
||||
.map(|l| font.measure_str(l, None).0)
|
||||
.fold(0.0f32, f32::max);
|
||||
widest + 2.0 * (base::OSD_PAD_X + base::OSD_MARGIN) * s
|
||||
};
|
||||
let scale = fit_scale(scale, width_at(scale), width as f32);
|
||||
let font = chrome_font(base_font, scale);
|
||||
|
||||
let (_, metrics) = font.metrics();
|
||||
let line_h = metrics.descent - metrics.ascent + metrics.leading;
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let widest = lines
|
||||
.iter()
|
||||
.map(|l| font.measure_str(l, None).0)
|
||||
.fold(0.0f32, f32::max);
|
||||
let (pad_x, pad_y) = (10.0, 8.0);
|
||||
let (pad_x, pad_y) = (base::OSD_PAD_X * scale, base::OSD_PAD_Y * scale);
|
||||
let (x, y) = (base::OSD_MARGIN * scale, base::OSD_MARGIN * scale);
|
||||
let panel = Rect::from_xywh(
|
||||
x,
|
||||
y,
|
||||
widest + 2.0 * pad_x,
|
||||
line_h * lines.len() as f32 + 2.0 * pad_y,
|
||||
);
|
||||
let radius = base::OSD_RADIUS * scale;
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(panel, 8.0, 8.0),
|
||||
RRect::new_rect_xy(panel, radius, radius),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None),
|
||||
);
|
||||
let text_paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None);
|
||||
@@ -569,7 +638,7 @@ fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
|
||||
canvas.draw_str(
|
||||
line,
|
||||
Point::new(x + pad_x, y + pad_y - metrics.ascent + line_h * i as f32),
|
||||
font,
|
||||
&font,
|
||||
&text_paint,
|
||||
);
|
||||
}
|
||||
@@ -581,7 +650,16 @@ fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
|
||||
/// window. This is the presenter's analog of the Apple client's blur overlay: the overlay
|
||||
/// composites its own RGBA quad and cannot sample the video to blur it, so an opaque scrim
|
||||
/// hides the stretched in-between frame instead (same intent, one draw).
|
||||
fn draw_resize_scrim(canvas: &Canvas, font: &Font, width: u32, height: u32, phase: f64) {
|
||||
fn draw_resize_scrim(
|
||||
canvas: &Canvas,
|
||||
base_font: &Font,
|
||||
width: u32,
|
||||
height: u32,
|
||||
phase: f64,
|
||||
scale: f32,
|
||||
) {
|
||||
// Short, centered label — it always fits, so it just takes the display scale as-is.
|
||||
let font = &chrome_font(base_font, scale);
|
||||
let (wf, hf) = (width as f32, height as f32);
|
||||
canvas.draw_rect(
|
||||
Rect::from_wh(wf, hf),
|
||||
@@ -602,16 +680,33 @@ fn draw_resize_scrim(canvas: &Canvas, font: &Font, width: u32, height: u32, phas
|
||||
);
|
||||
}
|
||||
|
||||
/// The capture hint / start banner: a centered pill near the bottom edge.
|
||||
fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32, alpha: f32) {
|
||||
/// The capture hint / start banner: a centered pill near the bottom edge. `scale` = the display's
|
||||
/// UI scale (the text size already rides in `font`).
|
||||
fn draw_hint_pill(
|
||||
canvas: &Canvas,
|
||||
base_font: &Font,
|
||||
text: &str,
|
||||
width: u32,
|
||||
height: u32,
|
||||
alpha: f32,
|
||||
scale: f32,
|
||||
) {
|
||||
// The capture hint is one long line that already fills most of a 1280 px window at 100 %;
|
||||
// scaled by a 2× display it would overrun both edges, so fit it to the window (a 4 % gutter
|
||||
// keeps it off the very edge).
|
||||
let pill_w =
|
||||
|s: f32| chrome_font(base_font, s).measure_str(text, None).0 + 2.0 * base::PILL_PAD_X * s;
|
||||
let scale = fit_scale(scale, pill_w(scale), width as f32 * 0.96);
|
||||
let font = &chrome_font(base_font, scale);
|
||||
|
||||
let (_, metrics) = font.metrics();
|
||||
let line_h = metrics.descent - metrics.ascent;
|
||||
let text_w = font.measure_str(text, None).0;
|
||||
let (pad_x, pad_y) = (14.0, 8.0);
|
||||
let (pad_x, pad_y) = (base::PILL_PAD_X * scale, base::PILL_PAD_Y * scale);
|
||||
let w = text_w + 2.0 * pad_x;
|
||||
let h = line_h + 2.0 * pad_y;
|
||||
let x = (width as f32 - w) / 2.0;
|
||||
let y = height as f32 - h - 24.0;
|
||||
let y = height as f32 - h - base::PILL_BOTTOM * scale;
|
||||
canvas.draw_rrect(
|
||||
RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0),
|
||||
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62 * alpha), None),
|
||||
|
||||
@@ -18,3 +18,6 @@ publish = false
|
||||
[dependencies]
|
||||
# `min_const_generics`: Pod/Zeroable for `[u8; N]` of any N (the gamepad SHM reserved tails are >32).
|
||||
bytemuck = { version = "1.19", features = ["derive", "min_const_generics"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
//!
|
||||
//! The GUID and LUID are carried as plain integers; the host converts to `windows::core::GUID` /
|
||||
//! `windows::Win32::Foundation::LUID` and the driver to its own bindgen types via the same constants.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(not(test), no_std)]
|
||||
|
||||
extern crate alloc;
|
||||
@@ -681,7 +681,7 @@ pub mod frame {
|
||||
};
|
||||
}
|
||||
|
||||
/// Gamepad shared-memory layouts (host ↔ the UMDF gamepad drivers `pf_xusb` / `pf_dualsense`).
|
||||
/// Gamepad shared-memory layouts (host ↔ the UMDF gamepad drivers `pf_xusb` / `pf_gamepad`).
|
||||
///
|
||||
/// These were hand-duplicated as `OFF_*`/`SHM_*` constants in `inject/{gamepad,dualsense}_windows.rs`
|
||||
/// and (as bare literals — `*view.add(140)`) in the standalone `xusb-driver`/`dualsense-driver`
|
||||
@@ -699,12 +699,12 @@ pub mod gamepad {
|
||||
|
||||
/// XUSB section magic — the exact u32 the shipped host + `pf_xusb` driver compare (loosely "PFXU").
|
||||
pub const XUSB_MAGIC: u32 = 0x5558_4650;
|
||||
/// Pad section magic — the exact u32 the shipped host + `pf_dualsense` driver compare (loosely
|
||||
/// Pad section magic — the exact u32 the shipped host + `pf_gamepad` driver compare (loosely
|
||||
/// "PFDS"). (Note: the two magics happen to use opposite byte-order mnemonics in the legacy code;
|
||||
/// only the u32 value is the contract.)
|
||||
pub const PAD_MAGIC: u32 = 0x5046_4453;
|
||||
|
||||
/// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is
|
||||
/// `device_type` selector the `pf_gamepad` driver reads to pick its HID identity. The section is
|
||||
/// zeroed, so `0` = DualSense is the default; one driver serves every identity.
|
||||
pub const DEVTYPE_DUALSENSE: u8 = 0;
|
||||
/// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity).
|
||||
@@ -730,7 +730,235 @@ pub mod gamepad {
|
||||
/// gained `pad_index` (carved from reserved space) so the driver rejects a cross-pad delivery.
|
||||
/// A v1 driver opens `Global\pf…-shm-<i>` (which no longer exists) and a v1 host never creates
|
||||
/// the mailbox a v2 driver polls, so a mixed pairing fails closed either way.
|
||||
pub const GAMEPAD_PROTO_VERSION: u32 = 2;
|
||||
///
|
||||
/// v3: the **channel proof** ([`ChannelProof`]) — the host stopped trusting the mailbox's
|
||||
/// `driver_pid` and now learns the duplication target over the DEVICE STACK instead. A v2 driver
|
||||
/// answers no proof, so a v3 host refuses to deliver to it; a v2 host never asks, and a v3 driver
|
||||
/// refuses the v2 handshake on the `host_proto` check. Mixed pairings fail closed both ways, with
|
||||
/// the existing "update host + drivers together" diagnostic.
|
||||
pub const GAMEPAD_PROTO_VERSION: u32 = 3;
|
||||
|
||||
// ── the channel proof (v3): who to hand the DATA section to ──────────────────────────────────
|
||||
//
|
||||
// WHY THIS EXISTS. Through v2 the host took the duplication target from the mailbox's
|
||||
// `driver_pid`. The mailbox has to be openable by LocalService (that is what the driver's own
|
||||
// WUDFHost runs as), and the delivery gate — `verify_is_wudfhost` — only checks that the named
|
||||
// process's IMAGE is `%SystemRoot%\System32\WUDFHost.exe`, which is world-executable. So any
|
||||
// LocalService principal, notably the deliberately de-privileged plugin runner, could spawn its
|
||||
// own WUDFHost, publish that pid, and be handed the pad's DATA section: forged HID input into the
|
||||
// interactive desktop (the mouse section drives a real absolute pointer) and a read of the remote
|
||||
// user's controller state (security-review 2026-07-28).
|
||||
//
|
||||
// WHY IT HAS TO COME FROM THE DEVICE STACK. That race cannot be closed on the host side alone.
|
||||
// Everything the real driver can read at LocalService — the devnode's Location, its
|
||||
// `Device Parameters` key, the object namespace — an attacker at LocalService can read too, so no
|
||||
// host-published secret tells the two apart. The ONE thing an attacker cannot forge is *being the
|
||||
// driver bound to our devnode*: only that process answers I/O sent to the device the host itself
|
||||
// created (and the host looks the device up by the instance id `SwDeviceCreate` handed back, so a
|
||||
// planted look-alike devnode is not in the running). Asking the devnode "which process are you?"
|
||||
// therefore yields a pid the host can trust, and the mailbox is demoted to what it always should
|
||||
// have been: a rendezvous for a handle VALUE that is meaningless anywhere but in that process.
|
||||
//
|
||||
// Two transports, because the drivers are two different shapes:
|
||||
// * `pf_xusb` is a plain UMDF2 driver that owns `GUID_DEVINTERFACE_XUSB` and dispatches its own
|
||||
// IOCTLs -> [`IOCTL_PF_XUSB_GET_CHANNEL_PROOF`].
|
||||
// * `pf_gamepad` / `pf_mouse` are HID minidrivers with no control device (hidclass owns the
|
||||
// stack, and UMDF has no control-device objects), so the reachable read path is a HID string
|
||||
// -> [`HID_STRING_INDEX_CHANNEL_PROOF`], which needs no report-descriptor change. That
|
||||
// matters: the pads' descriptors, VID/PID and serials are what Steam and SDL fingerprint,
|
||||
// and a new feature report there would risk the identity work this driver exists to get right.
|
||||
|
||||
/// Proof magic ("PFCP" — punktfunk channel proof), and the `PFCP` prefix of the text form.
|
||||
pub const PROOF_MAGIC: u32 = 0x5043_4650;
|
||||
|
||||
/// Reserved HID string index the `pf_gamepad` / `pf_mouse` minidrivers answer with their
|
||||
/// [`ChannelProof`], fetched by the host with `HidD_GetIndexedString`.
|
||||
///
|
||||
/// ⚠️ MEASURED UNUSABLE on .173 (Win11 26200): hidclass does not carry an arbitrary indexed-string
|
||||
/// request to a UMDF HID minidriver — `HidD_GetIndexedString` failed for EVERY index, including
|
||||
/// ones the driver demonstrably serves through the named wrappers. Kept because it costs one
|
||||
/// failed IOCTL and is the right thing to ask first if a later Windows starts forwarding it; the
|
||||
/// transports that actually work are [`PF_PAD_CONTROL_INTERFACE_GUID_U128`] (if hidclass lets it
|
||||
/// through) and, for `pf_mouse`, the serial string ([`proof_is_serial_string`]).
|
||||
///
|
||||
/// 16-bit on purpose: both `IOCTL_HID_GET_INDEXED_STRING` and `IOCTL_HID_GET_STRING` pack their
|
||||
/// argument as `(language_id << 16) | string_index`, so only the low word survives the trip and
|
||||
/// both drivers mask before comparing. `0x5046` ("PF") is still far outside the 1..=255 range a
|
||||
/// real USB/HID string-descriptor index can occupy, so it cannot collide with a string the OS, a
|
||||
/// game, or Steam asks for.
|
||||
pub const HID_STRING_INDEX_CHANNEL_PROOF: u32 = 0x5046;
|
||||
|
||||
// ❌ A private device interface (`WdfDeviceCreateDeviceInterface`) was tried here as a
|
||||
// hidclass-independent transport for the HID minidrivers and MEASURED DEAD on .173 (Win11
|
||||
// 26200): it registers and enumerates, but `CreateFile` on it is refused (ERROR_GEN_FAILURE)
|
||||
// because hidclass owns `IRP_MJ_CREATE` on a devnode it is the FDO for. Do not re-try it for
|
||||
// `pf_gamepad`/`pf_mouse`; see `pf_umdf_util::hid` for the full measurement.
|
||||
|
||||
/// The proof question itself, on whichever interface carries it.
|
||||
/// `CTL_CODE(0x8000, 0x0FE0, METHOD_BUFFERED, FILE_ANY_ACCESS)`: a function code no xusb22 IOCTL
|
||||
/// uses, `METHOD_BUFFERED` so the answer is a plain buffer copy, and `FILE_ANY_ACCESS` so the host
|
||||
/// can ask over a `CreateFile` handle opened with NO access rights (the same way it must open a
|
||||
/// HID collection). Answering it leaks nothing — a pid is not a secret, and the proof is only
|
||||
/// worth anything to a process that can already duplicate handles.
|
||||
pub const IOCTL_PF_GET_CHANNEL_PROOF: u32 = 0x8000_3F80;
|
||||
|
||||
/// Whether a driver serves its channel proof AS its HID serial-number string.
|
||||
///
|
||||
/// The one transport measured to work against a UMDF HID minidriver today: on .173,
|
||||
/// `HidD_GetSerialNumberString` succeeds on a zero-access handle and returns the driver's own
|
||||
/// text, so a proof placed there reaches the host. Enabled for **`pf_mouse` only** — its serial
|
||||
/// (`PFMOUSE00`) is inert, whereas the pads' serials are what SDL and Steam dedup controllers on,
|
||||
/// and Steam is already known to mangle a pad's displayed name over serial FORMAT alone.
|
||||
///
|
||||
/// `pf_mouse` is also the one that matters most: its section drives a real absolute pointer, so a
|
||||
/// hijacked mouse channel is desktop control, where a hijacked pad channel is gamepad input.
|
||||
pub const fn proof_is_serial_string(pad_kind_is_mouse: bool) -> bool {
|
||||
pad_kind_is_mouse
|
||||
}
|
||||
|
||||
/// The feature report the **PS pad identities** (DualSense / DualShock 4 / Edge) answer the
|
||||
/// channel proof on.
|
||||
///
|
||||
/// `0x85` is already DECLARED as a Feature report in all three captured descriptors and was
|
||||
/// previously unserved — the driver failed it with `STATUS_INVALID_PARAMETER`. That is what makes
|
||||
/// this transport free: **no report-descriptor change**, so the VID/PID, report layout, serial and
|
||||
/// product strings that Steam and SDL fingerprint are untouched, and `HidD_GetFeature` is allowed
|
||||
/// through by hidclass because the id is in the descriptor. Nothing can have depended on the old
|
||||
/// failure: SDL reads `0x05`/`0x09`/`0x20` (DualSense) and `0x02`/`0x12`/`0xA3` (DS4); `0x85` is
|
||||
/// one of Sony's vendor reports neither it nor Steam asks for.
|
||||
pub const HID_FEATURE_REPORT_CHANNEL_PROOF: u8 = 0x85;
|
||||
|
||||
/// The Steam Deck identity's private proof command.
|
||||
///
|
||||
/// The Deck descriptor declares ONE unnumbered feature report and Steam drives it as a
|
||||
/// command/response protocol (`0x83` GET_ATTRIBUTES, `0xAE` GET_STRING_ATTRIBUTE); the driver
|
||||
/// echoes commands it doesn't know. So the proof rides that same contract — SET_FEATURE this
|
||||
/// command, then GET_FEATURE the reply — again with no descriptor change. TWO bytes, not one, so
|
||||
/// a Steam command byte we haven't catalogued can never be mistaken for it.
|
||||
pub const DECK_PROOF_CMD: [u8; 2] = [0xF9, 0x50];
|
||||
|
||||
/// What a driver answers when the host asks, over the device stack, who it is.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||
pub struct ChannelProof {
|
||||
/// [`PROOF_MAGIC`].
|
||||
pub magic: u32,
|
||||
/// The driver's [`GAMEPAD_PROTO_VERSION`].
|
||||
pub proto: u32,
|
||||
/// The pad index the driver read from its devnode Location — cross-checked against the pad
|
||||
/// the host is delivering, so a mis-resolved devnode can't cross-wire two pads.
|
||||
pub pad_index: u32,
|
||||
/// `GetCurrentProcessId()` of the driver's WUDFHost: the duplication target.
|
||||
pub wudf_pid: u32,
|
||||
}
|
||||
|
||||
impl ChannelProof {
|
||||
/// This driver's answer. `pad_index` comes from the devnode Location, `wudf_pid` from
|
||||
/// `GetCurrentProcessId()`.
|
||||
pub fn new(pad_index: u32, wudf_pid: u32) -> ChannelProof {
|
||||
ChannelProof {
|
||||
magic: PROOF_MAGIC,
|
||||
proto: GAMEPAD_PROTO_VERSION,
|
||||
pad_index,
|
||||
wudf_pid,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an answer against the pad the host is actually delivering, yielding the pid to
|
||||
/// duplicate into. `Err` carries the operator-facing reason — every rejection is a refusal to
|
||||
/// deliver, so the host must be able to say precisely which check failed rather than falling
|
||||
/// back to a pid it cannot trust.
|
||||
pub fn check(&self, expect_pad_index: u32) -> Result<u32, &'static str> {
|
||||
if self.magic != PROOF_MAGIC {
|
||||
return Err(
|
||||
"the devnode's answer is not a punktfunk channel proof (bad magic) — \
|
||||
some other driver is bound to this device",
|
||||
);
|
||||
}
|
||||
if self.proto != GAMEPAD_PROTO_VERSION {
|
||||
return Err(
|
||||
"the driver bound to this devnode speaks a different gamepad protocol \
|
||||
— update the host and the drivers together",
|
||||
);
|
||||
}
|
||||
if self.pad_index != expect_pad_index {
|
||||
return Err(
|
||||
"the devnode answered for a DIFFERENT pad index — the interface lookup \
|
||||
resolved the wrong device",
|
||||
);
|
||||
}
|
||||
if self.wudf_pid == 0 {
|
||||
return Err("the driver reported pid 0");
|
||||
}
|
||||
Ok(self.wudf_pid)
|
||||
}
|
||||
|
||||
/// The 16 wire bytes of the `pf_xusb` IOCTL answer. Offered here (rather than leaving each
|
||||
/// side to reach for `bytemuck`) so the driver crates need no extra dependency and both
|
||||
/// sides go through one length-checked pair with [`from_bytes`](Self::from_bytes).
|
||||
pub fn to_bytes(self) -> [u8; 16] {
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(bytemuck::bytes_of(&self));
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse [`to_bytes`](Self::to_bytes). `None` if the device returned fewer bytes than a whole
|
||||
/// proof — a short read must refuse the delivery, never be zero-extended into a pid.
|
||||
///
|
||||
/// `pod_read_unaligned`, NOT `from_bytes`: the feature-report form offsets the proof by one
|
||||
/// byte (the report id sits at 0), so the slice is not 4-aligned and `from_bytes` panics on
|
||||
/// it. Device I/O buffers carry no alignment guarantee either.
|
||||
pub fn from_bytes(b: &[u8]) -> Option<ChannelProof> {
|
||||
(b.len() >= 16).then(|| bytemuck::pod_read_unaligned::<ChannelProof>(&b[..16]))
|
||||
}
|
||||
|
||||
/// The proof as a HID **feature report** of exactly `len` bytes: `[report_id, proof(16), 0…]`.
|
||||
/// A HID feature reply carries its report id in byte 0 and is sized by the descriptor, so the
|
||||
/// driver pads to whatever length the caller's buffer declares. `None` if `len` cannot hold
|
||||
/// the id plus a whole proof.
|
||||
pub fn to_feature_report(self, report_id: u8, len: usize) -> Option<alloc::vec::Vec<u8>> {
|
||||
if len < 17 {
|
||||
return None;
|
||||
}
|
||||
let mut out = alloc::vec![0u8; len];
|
||||
out[0] = report_id;
|
||||
out[1..17].copy_from_slice(&self.to_bytes());
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Parse [`to_feature_report`](Self::to_feature_report) — skips the leading report id.
|
||||
pub fn from_feature_report(b: &[u8]) -> Option<ChannelProof> {
|
||||
Self::from_bytes(b.get(1..)?)
|
||||
}
|
||||
|
||||
/// Render as the ASCII text a HID indexed-string answer carries:
|
||||
/// `PFCP:<proto>:<pad_index>:<wudf_pid>`. Text rather than the raw struct because
|
||||
/// `HidD_GetIndexedString` is a string channel, and because a human reading a driver log or
|
||||
/// poking the device with a HID inspector should be able to see what the pad answered.
|
||||
pub fn to_hid_string(self) -> String {
|
||||
alloc::format!("PFCP:{}:{}:{}", self.proto, self.pad_index, self.wudf_pid)
|
||||
}
|
||||
|
||||
/// Parse [`to_hid_string`](Self::to_hid_string). `None` on ANY deviation — a foreign string
|
||||
/// index answered, a truncated read, a non-decimal field, trailing junk — so a host that
|
||||
/// cannot read a well-formed proof refuses to deliver instead of guessing at a pid.
|
||||
pub fn from_hid_string(s: &str) -> Option<ChannelProof> {
|
||||
let rest = s.strip_prefix("PFCP:")?;
|
||||
let mut it = rest.split(':');
|
||||
let proto = it.next()?.parse::<u32>().ok()?;
|
||||
let pad_index = it.next()?.parse::<u32>().ok()?;
|
||||
let wudf_pid = it.next()?.parse::<u32>().ok()?;
|
||||
if it.next().is_some() {
|
||||
return None; // trailing field: not a shape we minted
|
||||
}
|
||||
Some(ChannelProof {
|
||||
magic: PROOF_MAGIC,
|
||||
proto,
|
||||
pad_index,
|
||||
wudf_pid,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Bootstrap-mailbox magic (`"PFBT"` LE) — the host stamps it LAST (after `host_proto`), so a
|
||||
/// driver only trusts a fully-initialized mailbox.
|
||||
@@ -754,16 +982,22 @@ pub mod gamepad {
|
||||
/// 1. host creates it (zeroed), stamps `host_proto` then `magic` (in that order);
|
||||
/// 2. driver opens it by name (pad index from `pszDeviceLocation`), writes `driver_proto`, and —
|
||||
/// iff `host_proto` matches its own version — publishes `driver_pid`;
|
||||
/// 3. host polls `driver_pid`, verifies the pid is a genuine WUDFHost, duplicates the unnamed DATA
|
||||
/// section into it, then writes `data_handle` + `handle_pid` and bumps `handle_seq` LAST;
|
||||
/// 3. host asks the DEVNODE who the driver is ([`ChannelProof`]) — **not** the mailbox — verifies
|
||||
/// that pid is a genuine WUDFHost, duplicates the unnamed DATA section into it, then writes
|
||||
/// `data_handle` + `handle_pid` and bumps `handle_seq` LAST;
|
||||
/// 4. driver sees a fresh `handle_seq` addressed to its own pid, maps `data_handle`, and validates
|
||||
/// the mapped section's magic + `pad_index` before use.
|
||||
///
|
||||
/// Deliberately safe to leave named + LS-openable: it carries only pids (not sensitive) and a
|
||||
/// handle VALUE (meaningless outside the target WUDFHost's handle table). A sibling LocalService
|
||||
/// that tampers with it can at worst mis-route a delivery — a gamepad DoS, never a read or an
|
||||
/// injection (it cannot place a valid section handle in the WUDFHost, and the driver's
|
||||
/// magic+`pad_index` validation rejects any handle that doesn't resolve to this pad's section).
|
||||
/// **Trust boundary (v3).** This mailbox is writable by LocalService — it has to be, since that is
|
||||
/// what the driver's own WUDFHost runs as — so NOTHING in it may decide where the DATA section
|
||||
/// goes. Through v2 `driver_pid` did decide that, which was the security-review 2026-07-28 hole
|
||||
/// (see [`ChannelProof`]); step 3 now sources the pid from the device stack and `driver_pid` is
|
||||
/// advisory only — a liveness/diagnostic hint. What is left here is a handle VALUE, meaningless
|
||||
/// outside the one process it was minted for, plus pids and version numbers, none of them secret.
|
||||
/// A LocalService tamperer can therefore still deny a pad (overwrite the fields, squat the name)
|
||||
/// but can no longer read or inject: it cannot place a valid section handle in the WUDFHost, the
|
||||
/// driver's magic + `pad_index` validation rejects any handle that does not resolve to this pad's
|
||||
/// section, and the delivery target is no longer its to choose.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
|
||||
pub struct PadBootstrap {
|
||||
@@ -942,6 +1176,12 @@ pub mod gamepad {
|
||||
assert!(offset_of!(PadShm, out_ring) == PAD_SHM_LEGACY_SIZE);
|
||||
assert!(size_of::<OutSlot>() == 68);
|
||||
|
||||
assert!(size_of::<ChannelProof>() == 16);
|
||||
assert!(offset_of!(ChannelProof, magic) == 0);
|
||||
assert!(offset_of!(ChannelProof, proto) == 4);
|
||||
assert!(offset_of!(ChannelProof, pad_index) == 8);
|
||||
assert!(offset_of!(ChannelProof, wudf_pid) == 12);
|
||||
|
||||
assert!(size_of::<PadBootstrap>() == 32);
|
||||
assert!(offset_of!(PadBootstrap, magic) == 0);
|
||||
assert!(offset_of!(PadBootstrap, host_proto) == 4);
|
||||
@@ -1486,4 +1726,111 @@ mod tests {
|
||||
const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D;
|
||||
assert_ne!(PF_VDISPLAY_INTERFACE_GUID_U128, SUDOVDA);
|
||||
}
|
||||
|
||||
/// The channel proof is what the host trusts INSTEAD of the mailbox's `driver_pid`
|
||||
/// (security-review 2026-07-28), so both wire forms — the `pf_xusb` IOCTL struct and the HID
|
||||
/// indexed-string text the two minidrivers answer — have to survive a round trip byte-for-byte,
|
||||
/// and every malformed shape has to be rejected rather than half-parsed into a pid the host
|
||||
/// would then duplicate a live input section into.
|
||||
#[test]
|
||||
fn channel_proof_round_trips_in_both_wire_forms() {
|
||||
use gamepad::*;
|
||||
let proof = ChannelProof::new(2, 4242);
|
||||
assert_eq!(proof.magic, PROOF_MAGIC);
|
||||
assert_eq!(PROOF_MAGIC, u32::from_le_bytes(*b"PFCP"));
|
||||
assert_eq!(proof.proto, GAMEPAD_PROTO_VERSION);
|
||||
|
||||
// XUSB IOCTL form: the raw 16-byte struct.
|
||||
let bytes = bytemuck::bytes_of(&proof);
|
||||
assert_eq!(bytes.len(), 16);
|
||||
assert_eq!(*bytemuck::from_bytes::<ChannelProof>(bytes), proof);
|
||||
|
||||
// HID indexed-string form: the same four fields as text.
|
||||
let s = proof.to_hid_string();
|
||||
assert_eq!(s, alloc::format!("PFCP:{GAMEPAD_PROTO_VERSION}:2:4242"));
|
||||
assert_eq!(ChannelProof::from_hid_string(&s), Some(proof));
|
||||
|
||||
// Every malformed shape parses to None — the host then refuses to deliver.
|
||||
for bad in [
|
||||
"",
|
||||
"PFCP",
|
||||
"PFCP:",
|
||||
"PFCP:3:0", // truncated read
|
||||
"PFCP:3:0:4242:9", // trailing field we never mint
|
||||
"PFCP:3:0:-1", // not a u32
|
||||
"PFCP:3:0:0x10", // not decimal
|
||||
"PFCP:3:0: 4242", // whitespace is not trimmed away into a valid pid
|
||||
"NOPE:3:0:4242", // another driver answered this string index
|
||||
"pfcp:3:0:4242", // prefix is case-sensitive
|
||||
] {
|
||||
assert_eq!(
|
||||
ChannelProof::from_hid_string(bad),
|
||||
None,
|
||||
"malformed proof {bad:?} must not parse"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `check` is the gate that decides whether a pid is allowed to receive a pad's whole input and
|
||||
/// rumble surface, so pin each refusal: a foreign driver, a version skew, and — the one that
|
||||
/// would silently cross-wire two live pads — an answer from the wrong devnode.
|
||||
#[test]
|
||||
fn channel_proof_check_refuses_everything_it_should() {
|
||||
use gamepad::*;
|
||||
assert_eq!(ChannelProof::new(0, 1234).check(0), Ok(1234));
|
||||
assert_eq!(ChannelProof::new(3, 1234).check(3), Ok(1234));
|
||||
|
||||
// Right shape, WRONG pad: the interface lookup resolved another pad's devnode.
|
||||
assert!(ChannelProof::new(1, 1234).check(0).is_err());
|
||||
// A driver that isn't ours answered the reserved string index / IOCTL.
|
||||
let mut foreign = ChannelProof::new(0, 1234);
|
||||
foreign.magic = 0xDEAD_BEEF;
|
||||
assert!(foreign.check(0).is_err());
|
||||
// Version skew must fail closed, not "probably compatible".
|
||||
let mut old = ChannelProof::new(0, 1234);
|
||||
old.proto = GAMEPAD_PROTO_VERSION - 1;
|
||||
assert!(old.check(0).is_err());
|
||||
// pid 0 is never a duplication target.
|
||||
assert!(ChannelProof::new(0, 0).check(0).is_err());
|
||||
}
|
||||
|
||||
/// A v2 driver answers no proof at all and a v2 host never asks, so the version must have moved
|
||||
/// — this is the tripwire that stops the two halves shipping out of step.
|
||||
#[test]
|
||||
fn gamepad_proto_is_at_the_channel_proof_version() {
|
||||
assert_eq!(gamepad::GAMEPAD_PROTO_VERSION, 3);
|
||||
}
|
||||
|
||||
/// The pad identities carry the proof in a HID FEATURE report — the transport chosen because
|
||||
/// `0x85` is already declared in the captured descriptors, so nothing about the device's
|
||||
/// Steam/SDL-visible identity changes. Pin the framing (report id in byte 0, proof in 1..17,
|
||||
/// zero padding to the descriptor's length) and the short-read refusal.
|
||||
#[test]
|
||||
fn channel_proof_feature_report_round_trips_and_refuses_short_reads() {
|
||||
use gamepad::*;
|
||||
let proof = ChannelProof::new(1, 4242);
|
||||
let rep = proof
|
||||
.to_feature_report(HID_FEATURE_REPORT_CHANNEL_PROOF, 64)
|
||||
.expect("64 bytes is plenty");
|
||||
assert_eq!(rep.len(), 64);
|
||||
assert_eq!(
|
||||
rep[0], 0x85,
|
||||
"byte 0 is the report id, as every HID feature reply is"
|
||||
);
|
||||
assert!(rep[17..].iter().all(|&b| b == 0), "tail is zero padding");
|
||||
assert_eq!(ChannelProof::from_feature_report(&rep), Some(proof));
|
||||
|
||||
// Exactly big enough, and one byte too small.
|
||||
assert!(proof.to_feature_report(0x85, 17).is_some());
|
||||
assert!(proof.to_feature_report(0x85, 16).is_none());
|
||||
// A truncated read must NOT be zero-extended into a pid.
|
||||
assert_eq!(ChannelProof::from_feature_report(&rep[..16]), None);
|
||||
assert_eq!(ChannelProof::from_feature_report(&[]), None);
|
||||
|
||||
// The Deck's private command is two bytes so a stray Steam command can't collide, and is
|
||||
// distinct from the commands the driver already serves.
|
||||
assert_eq!(DECK_PROOF_CMD.len(), 2);
|
||||
assert!(!DECK_PROOF_CMD.starts_with(&[0x83]) && !DECK_PROOF_CMD.starts_with(&[0xAE]));
|
||||
assert!(!DECK_PROOF_CMD.starts_with(&[0xEB]) && !DECK_PROOF_CMD.starts_with(&[0x8F]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,3 +92,6 @@ pyrowave = ["dep:pyrowave-sys"]
|
||||
# (design/native-qsv-encoder.md). ⚠ Like `nvenc`: hand builds need this feature or
|
||||
# Intel boxes fall through to the ffmpeg path / software.
|
||||
qsv = ["dep:libvpl-sys"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -26,6 +26,97 @@ pub(crate) fn pixel_to_av(p: Pixel) -> ffi::AVPixelFormat {
|
||||
ffi::AVPixelFormat::from(p)
|
||||
}
|
||||
|
||||
/// An owned `AVBufferRef` — unref'd exactly once, when it drops.
|
||||
///
|
||||
/// The hwdevice/hwframes constructors used to unref by hand on *every* failure branch (three in the
|
||||
/// CUDA path, two in VAAPI) and then once more in a hand-written `Drop`. That shape leaks the moment
|
||||
/// somebody adds a branch and forgets the cleanup, and double-unrefs the moment two of them run —
|
||||
/// and neither mistake is visible at the call site or catchable by the compiler. Ownership lives in
|
||||
/// this type instead: an early `?` drops whatever was built so far, in reverse construction order,
|
||||
/// with no cleanup code at the call site at all.
|
||||
///
|
||||
/// **Drop order** matters to the callers holding two of these. A frames context internally holds its
|
||||
/// own reference on its device, and the code this replaced deliberately unref'd frames *before*
|
||||
/// device. Rust drops struct fields in DECLARATION order, so a struct holding both must declare
|
||||
/// frames before device to keep that. Refcounting makes either order sound in principle —
|
||||
/// the device cannot die while a frames ctx still references it — but the observable order is kept
|
||||
/// exactly as it shipped rather than quietly inverted by a field reorder.
|
||||
pub(crate) struct AvBuffer(*mut ffi::AVBufferRef);
|
||||
|
||||
impl AvBuffer {
|
||||
/// Take ownership of a freshly-created `AVBufferRef`, rejecting the null that an ffmpeg
|
||||
/// allocator returns on failure (so the `is_null` check every caller used to open-code happens
|
||||
/// once, here).
|
||||
///
|
||||
/// # Safety
|
||||
/// `p` must be null, or a live `AVBufferRef` whose ownership passes to the returned value —
|
||||
/// nothing else may unref it.
|
||||
pub(crate) unsafe fn from_raw(p: *mut ffi::AVBufferRef) -> Option<Self> {
|
||||
(!p.is_null()).then_some(AvBuffer(p))
|
||||
}
|
||||
|
||||
/// The borrowed pointer, for the ffmpeg calls that read a ref without consuming it. Borrowed
|
||||
/// only — the `AvBuffer` stays the owner, so callers must not unref what this returns.
|
||||
pub(crate) fn as_ptr(&self) -> *mut ffi::AVBufferRef {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AvBuffer {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the non-null ref `from_raw` took ownership of, and this type is its
|
||||
// sole owner (it is neither `Clone` nor `Copy`, and `as_ptr` only lends), so this runs
|
||||
// exactly once for that reference. `av_buffer_unref` drops the one reference and nulls the
|
||||
// pointer through the `&mut`.
|
||||
unsafe { ffi::av_buffer_unref(&mut self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
/// An owned `AVFilterGraph`, freed exactly once when it drops.
|
||||
///
|
||||
/// The dmabuf path built its graph beside three `AvBuffer`s and unwound all four by hand on each
|
||||
/// of eight failure branches — a four-line cleanup block copied eight times, once inside a macro.
|
||||
/// Freeing the graph is the same ownership question as unref'ing a buffer, so it gets the same
|
||||
/// answer.
|
||||
///
|
||||
/// Linux-only: the VAAPI dmabuf path is the sole filter-graph user in this crate. The Windows
|
||||
/// AMF/QSV backends feed the encoder directly and build no graph, so on Windows this type would be
|
||||
/// dead code — cfg'd out rather than `allow`ed, because "nothing here uses it" is the honest
|
||||
/// statement and an `allow` would keep it compiling after it stopped being true anywhere.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) struct AvFilterGraph(*mut ffi::AVFilterGraph);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl AvFilterGraph {
|
||||
/// Allocate a filter graph, rejecting the null `avfilter_graph_alloc` returns on OOM.
|
||||
///
|
||||
/// Safe: the call takes no arguments and has no precondition a caller could violate — the only
|
||||
/// contract is what happens to the result, and that is exactly what this type owns.
|
||||
pub(crate) fn alloc() -> Option<Self> {
|
||||
// SAFETY: parameterless allocator; it returns either a fresh graph whose ownership passes
|
||||
// to the value returned here, or null (rejected below).
|
||||
let g = unsafe { ffi::avfilter_graph_alloc() };
|
||||
(!g.is_null()).then_some(AvFilterGraph(g))
|
||||
}
|
||||
|
||||
/// The borrowed pointer, for the `avfilter_*` calls that build into the graph without taking
|
||||
/// ownership of it.
|
||||
pub(crate) fn as_ptr(&self) -> *mut ffi::AVFilterGraph {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Drop for AvFilterGraph {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the non-null graph `alloc` took ownership of, and this type is its
|
||||
// sole owner (neither `Clone` nor `Copy`; `as_ptr` only lends), so this runs exactly once.
|
||||
// `avfilter_graph_free` frees the graph together with the filter contexts and per-filter
|
||||
// device refs it owns, and nulls the pointer through the `&mut`.
|
||||
unsafe { ffi::avfilter_graph_free(&mut self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
/// One `receive_packet` attempt, with the not-ready states kept distinct so a blocking drain can
|
||||
/// tell "still encoding" (retry) from "stream over" (stop). The Linux NVENC/VAAPI polls collapse
|
||||
/// `Again`/`Eof` to `None`; the Windows AMF/QSV path keeps them apart for its deadline-driven loop.
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
//! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math).
|
||||
//! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on
|
||||
//! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs.
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw libav (`ffmpeg-sys-next`) hwcontext calls almost line for line;
|
||||
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
|
||||
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
|
||||
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
@@ -22,7 +28,8 @@ use std::os::raw::c_int;
|
||||
use std::ptr;
|
||||
|
||||
use super::libav::{
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT,
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, PollOutcome, SWS_CS_ITU709,
|
||||
SWS_POINT,
|
||||
};
|
||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||
|
||||
@@ -60,8 +67,11 @@ struct AVCUDADeviceContext {
|
||||
/// CUDA hardware-frame contexts that wrap our shared `CUcontext`, so `hevc_nvenc` reads the
|
||||
/// imported device buffer directly. Owns two `AVBufferRef`s, unref'd on drop.
|
||||
struct CudaHw {
|
||||
device_ref: *mut ffi::AVBufferRef,
|
||||
frames_ref: *mut ffi::AVBufferRef,
|
||||
// Declared frames-BEFORE-device on purpose: these drop in declaration order, and that
|
||||
// reproduces exactly what the hand-written `Drop` this replaced did (the frames ctx holds its
|
||||
// own reference on the device). Do not reorder these two fields.
|
||||
frames_ref: AvBuffer,
|
||||
device_ref: AvBuffer,
|
||||
}
|
||||
|
||||
impl CudaHw {
|
||||
@@ -72,57 +82,42 @@ impl CudaHw {
|
||||
/// (`nvenc_open_einval`), and a hwdevice/hwframes EINVAL is a config error no bitrate can
|
||||
/// fix — enrolling it would burn ~10 doomed encoder opens before surfacing the real failure.
|
||||
unsafe fn new(cu_ctx: *mut std::ffi::c_void, sw_format: Pixel, w: u32, h: u32) -> Result<Self> {
|
||||
let mut device_ref = ffi::av_hwdevice_ctx_alloc(ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA);
|
||||
if device_ref.is_null() {
|
||||
bail!("av_hwdevice_ctx_alloc(CUDA) failed");
|
||||
}
|
||||
let dev_ctx = (*device_ref).data as *mut ffi::AVHWDeviceContext;
|
||||
// Each `?`/`bail!` below drops whatever has been built so far — `AvBuffer`'s `Drop` is the
|
||||
// single unref path, so the failure branches carry no cleanup of their own.
|
||||
let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
|
||||
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
|
||||
))
|
||||
.context("av_hwdevice_ctx_alloc(CUDA) failed")?;
|
||||
let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext;
|
||||
let cu = (*dev_ctx).hwctx as *mut AVCUDADeviceContext;
|
||||
(*cu).cuda_ctx = cu_ctx; // share the importer's context
|
||||
let r = ffi::av_hwdevice_ctx_init(device_ref);
|
||||
let r = ffi::av_hwdevice_ctx_init(device_ref.as_ptr());
|
||||
if r < 0 {
|
||||
ffi::av_buffer_unref(&mut device_ref);
|
||||
bail!("av_hwdevice_ctx_init failed ({r})");
|
||||
}
|
||||
|
||||
let mut frames_ref = ffi::av_hwframe_ctx_alloc(device_ref);
|
||||
if frames_ref.is_null() {
|
||||
ffi::av_buffer_unref(&mut device_ref);
|
||||
bail!("av_hwframe_ctx_alloc failed");
|
||||
}
|
||||
let fc = (*frames_ref).data as *mut ffi::AVHWFramesContext;
|
||||
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
|
||||
.context("av_hwframe_ctx_alloc failed")?;
|
||||
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
||||
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_CUDA;
|
||||
(*fc).sw_format = pixel_to_av(sw_format);
|
||||
(*fc).width = w as c_int;
|
||||
(*fc).height = h as c_int;
|
||||
(*fc).initial_pool_size = 0; // we supply the device pointers
|
||||
let r = ffi::av_hwframe_ctx_init(frames_ref);
|
||||
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
|
||||
if r < 0 {
|
||||
ffi::av_buffer_unref(&mut frames_ref);
|
||||
ffi::av_buffer_unref(&mut device_ref);
|
||||
bail!("av_hwframe_ctx_init failed ({r})");
|
||||
}
|
||||
Ok(CudaHw {
|
||||
device_ref,
|
||||
frames_ref,
|
||||
device_ref,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CudaHw {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `CudaHw::new` created
|
||||
// (it bails before returning `Self` if either alloc fails, so a live `CudaHw` always holds
|
||||
// both). `av_buffer_unref` drops one reference and nulls the pointer through the `&mut`. This
|
||||
// `Drop` runs exactly once and `CudaHw` owns these refs exclusively → no double-free /
|
||||
// use-after-free. Frames are unref'd before the device (the frames ctx internally refs the
|
||||
// device; refcounted, so the order is sound regardless).
|
||||
unsafe {
|
||||
ffi::av_buffer_unref(&mut self.frames_ref);
|
||||
ffi::av_buffer_unref(&mut self.device_ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
// No `Drop` for `CudaHw`: each `AvBuffer` field unrefs itself, in declaration order (frames, then
|
||||
// device — see the field comment). The hand-written unref pair this replaced had to be kept in sync
|
||||
// with every failure branch in `new`; now there is exactly one unref path and it cannot be skipped.
|
||||
|
||||
/// Map a captured layout to the NVENC input pixel format, and whether a 3→4 byte expand is
|
||||
/// needed (packed RGB/BGR have no padding byte; the NVENC `*0` formats do).
|
||||
@@ -345,11 +340,23 @@ impl NvencEncoder {
|
||||
};
|
||||
}
|
||||
|
||||
// NV12 / 4:4:4 paths: we do the RGB→YUV conversion ourselves as BT.709 (swscale), so
|
||||
// signal that in the bitstream VUI (colorspace/range/primaries/transfer) — otherwise the
|
||||
// client decoder assumes a default and the picture comes out washed-out / wrong-contrast.
|
||||
// The RGB-input 4:2:0 path leaves these unset (NVENC's internal CSC writes its own VUI).
|
||||
// Matches the Windows NV12 path's BT.709 limited-range signalling.
|
||||
// Colour signalling, written for EVERY session (colorspace/range/primaries/transfer) —
|
||||
// otherwise the client decoder assumes a default and the picture comes out washed-out /
|
||||
// wrong-contrast. Matches the Windows NV12 path's BT.709 limited-range signalling.
|
||||
//
|
||||
// The packed-RGB 4:2:0 path used to be excluded, on the belief that "NVENC's internal CSC
|
||||
// writes its own VUI". It does not: libavcodec's nvenc wrapper derives
|
||||
// `colourDescriptionPresentFlag` from these very AVCodecContext fields, so leaving them
|
||||
// UNSPECIFIED produced a stream with NO colour description at all. Every punktfunk client
|
||||
// then falls back to BT.709 (`csc_rows`) and looks fine, but vendor TV decoders guess from
|
||||
// RESOLUTION — an LG webOS panel reads a 4K SDR stream as BT.2020 and washes it out.
|
||||
// BT.709 limited is the honest answer for that path too: NVENC's internal RGB→YUV is the
|
||||
// same conversion both direct-SDK backends feed from an ARGB surface
|
||||
// (`nvenc_cuda.rs`/`windows/nvenc.rs`), and `nvenc_core.rs` already stamps 709-limited on
|
||||
// those unconditionally. This only makes the libav sibling consistent with them.
|
||||
//
|
||||
// Reachable whenever the direct-SDK path is not: a CPU/dmabuf (non-CUDA) capture, a build
|
||||
// without `--features nvenc`, or PUNKTFUNK_NVENC_DIRECT=0.
|
||||
//
|
||||
// PUNKTFUNK_444_FULLRANGE=1 (experimental, 4:4:4-only): convert AND signal FULL range —
|
||||
// recovers the ~12% of code space limited-range quantization gives up, for the exact
|
||||
@@ -372,7 +379,7 @@ impl NvencEncoder {
|
||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT2020;
|
||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
|
||||
}
|
||||
} else if matches!(format, PixelFormat::Nv12) || want_444 {
|
||||
} else {
|
||||
// SAFETY: same `video` builder — `raw = video.as_mut_ptr()` is the non-null, properly-
|
||||
// aligned, sole-owned, not-yet-opened `AVCodecContext`. We set its four VUI colour enum
|
||||
// fields to valid `AVColorSpace`/`AVColorRange`/`AVColorPrimaries`/`AVColorTransfer-
|
||||
@@ -409,8 +416,8 @@ impl NvencEncoder {
|
||||
unsafe {
|
||||
let raw = video.as_mut_ptr();
|
||||
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_CUDA;
|
||||
(*raw).hw_device_ctx = ffi::av_buffer_ref(hw.device_ref);
|
||||
(*raw).hw_frames_ctx = ffi::av_buffer_ref(hw.frames_ref);
|
||||
(*raw).hw_device_ctx = ffi::av_buffer_ref(hw.device_ref.as_ptr());
|
||||
(*raw).hw_frames_ctx = ffi::av_buffer_ref(hw.frames_ref.as_ptr());
|
||||
}
|
||||
Some(hw)
|
||||
} else {
|
||||
@@ -835,7 +842,8 @@ impl NvencEncoder {
|
||||
.cuda
|
||||
.as_ref()
|
||||
.context("CUDA hw context missing (encoder opened in CPU mode)")?
|
||||
.frames_ref;
|
||||
.frames_ref
|
||||
.as_ptr();
|
||||
// The device→device copy below uses our shared context directly; make it current on the
|
||||
// encode thread (ffmpeg pushes its own around the pool alloc, so order is fine).
|
||||
pf_zerocopy::cuda::make_current().context("CUDA context current (encode thread)")?;
|
||||
@@ -984,6 +992,12 @@ impl Drop for QuietLibavLog {
|
||||
/// takes for a live 4:4:4 stream — and reports whether it succeeded. HEVC-only; the result is cached
|
||||
/// by the caller ([`crate::can_encode_444`]). A GPU/driver/ffmpeg without RExt 4:4:4 fails
|
||||
/// the open here, so the host resolves the session to 4:2:0 before the Welcome (honest downgrade).
|
||||
///
|
||||
/// ⚠️ Only consulted when libav will really serve the session (`PUNKTFUNK_NVENC_DIRECT=0`, or a
|
||||
/// build without `--features nvenc`). A direct-SDK host answers from the driver's caps bit instead
|
||||
/// (`nvenc_cuda::probe_support`) — running THIS probe there mixes ffmpeg's NVENC client into a
|
||||
/// direct-SDK process, which is the LOG-3 field bug: one successful `hevc_nvenc` FREXT open+close
|
||||
/// wedged every later NVENC open process-wide (`NV_ENC_ERR_INVALID_VERSION`) until a host restart.
|
||||
pub fn probe_can_encode_444(codec: Codec) -> bool {
|
||||
if codec != Codec::H265 {
|
||||
return false;
|
||||
@@ -1037,6 +1051,37 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cuda_hw_tests {
|
||||
use super::*;
|
||||
|
||||
/// `CudaHw` owns its two `AVBufferRef`s through `AvBuffer`, so *construct and drop* is the
|
||||
/// entire contract: a missed unref leaks, a doubled one aborts inside glibc. Nothing else in
|
||||
/// the suite covers it — the NVENC smoke tests take the CPU path and never build one, and the
|
||||
/// VAAPI twin's tests need AMD/Intel silicon. Looping the cycle is the point: a double-unref
|
||||
/// shows up as an abort, and a leak shows as the allocator growing across iterations.
|
||||
///
|
||||
/// `#[ignore]`d (needs a real CUDA device):
|
||||
/// `cargo test -p pf-encode cuda_hw_alloc_drop_cycles -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore = "needs a real CUDA device (run on an NVIDIA host, not the build box)"]
|
||||
fn cuda_hw_alloc_drop_cycles() {
|
||||
ffmpeg::init().expect("libav init");
|
||||
let cu_ctx = pf_zerocopy::cuda::context().expect("shared CUDA context");
|
||||
for i in 0..8 {
|
||||
// SAFETY: `CudaHw::new` requires libav initialized (asserted above) and a valid
|
||||
// `CUcontext` — `cu_ctx` is the live shared context from `pf_zerocopy`. NV12 at
|
||||
// 640x480 are a valid format and positive dims. The handle drops at the end of each
|
||||
// iteration, which is precisely the unref path under test.
|
||||
let hw = unsafe { CudaHw::new(cu_ctx.cast(), Pixel::NV12, 640, 480) }
|
||||
.unwrap_or_else(|e| panic!("CudaHw::new failed on iteration {i}: {e:#}"));
|
||||
assert!(!hw.device_ref.as_ptr().is_null(), "device ref went null");
|
||||
assert!(!hw.frames_ref.as_ptr().is_null(), "frames ref went null");
|
||||
}
|
||||
eprintln!("8 CudaHw alloc/drop cycles completed without abort");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hdr_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -57,6 +57,12 @@
|
||||
//! starts driver-less (the `.so` resolves at runtime — on an AMD/Intel box [`try_api`] fails cleanly
|
||||
//! and the VAAPI/software backends carry the session).
|
||||
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw CUDA driver + `nvEncodeAPI` entry-table calls almost line for line;
|
||||
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
|
||||
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
|
||||
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
@@ -107,6 +113,12 @@ struct EncodeApi {
|
||||
*mut nv::NV_ENC_CAPS_PARAM,
|
||||
*mut core::ffi::c_int,
|
||||
) -> nv::NVENCSTATUS,
|
||||
// The two entry points behind [`probe_support`] — the driver's own list of encode GUIDs
|
||||
// this chip exposes. Mandatory like every other entry: both have existed since NVENC 1.0, so a
|
||||
// driver missing them is broken in ways the rest of this table would not survive either.
|
||||
get_encode_guid_count: unsafe extern "C" fn(*mut c_void, *mut u32) -> nv::NVENCSTATUS,
|
||||
get_encode_guids:
|
||||
unsafe extern "C" fn(*mut c_void, *mut nv::GUID, u32, *mut u32) -> nv::NVENCSTATUS,
|
||||
get_encode_preset_config_ex: unsafe extern "C" fn(
|
||||
*mut c_void,
|
||||
nv::GUID,
|
||||
@@ -168,6 +180,141 @@ fn api() -> &'static EncodeApi {
|
||||
try_api().expect("NVENC call before a successful try_api() gate")
|
||||
}
|
||||
|
||||
/// Everything the host advertisement asks of this GPU's NVENC, answered by the driver itself on
|
||||
/// ONE throwaway session: the encode-GUID list (which codecs exist at all) and the HEVC 4:4:4 cap.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ProbedSupport {
|
||||
/// Which codecs this chip's NVENC encodes (`nvEncGetEncodeGUIDs`). All-`false` = the probe
|
||||
/// could not answer — [`crate::CodecSupport::wire_mask`] turns that into `None` so the caller
|
||||
/// keeps the static superset (fail open).
|
||||
pub codecs: crate::CodecSupport,
|
||||
/// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` for the HEVC GUID — whether this chip can encode
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// The cached [`probe_support_uncached`] answer — one throwaway session per process lifetime.
|
||||
pub(crate) fn probe_support() -> ProbedSupport {
|
||||
static CACHE: std::sync::OnceLock<ProbedSupport> = std::sync::OnceLock::new();
|
||||
*CACHE.get_or_init(probe_support_uncached)
|
||||
}
|
||||
|
||||
/// Which codecs **this GPU's** NVENC can actually encode — and whether HEVC can go 4:4:4 — asked
|
||||
/// of the driver itself (`nvEncGetEncodeGUIDs` + `nvEncGetEncodeCaps`) instead of assumed from the
|
||||
/// SDK version.
|
||||
///
|
||||
/// Why this exists: the host used to advertise a static `H.264 | HEVC | AV1` superset for every
|
||||
/// NVIDIA box, so a chip without HEVC NVENC (1st-gen Maxwell, e.g. GTX 960M — HEVC needs 2nd-gen
|
||||
/// Maxwell+, AV1 needs Ada+) still offered HEVC. A client reasonably negotiated H265 and got a dead
|
||||
/// session: `hevc_nvenc` "No capable devices found", eight pipeline retries, ~15 s of blank video,
|
||||
/// then a disconnect. The GUID list is a property of the chip+driver, so it is equally right for
|
||||
/// the direct-SDK backend and the libav `*_nvenc` one.
|
||||
///
|
||||
/// ⚠️ Deliberately NOT the VAAPI probe's shape (open a tiny libav encoder per codec). That would run
|
||||
/// ffmpeg's NVENC client, and mixing it with this direct-SDK client in one process is the prime
|
||||
/// suspect for the open bug where one `probe_can_encode_444` open wedges NVENC **process-wide**
|
||||
/// (`NV_ENC_ERR_INVALID_VERSION` on every later session until a host restart — LOG-3, Droff,
|
||||
/// 0.19.2). This asks the SAME client, on the SAME shared CUDA context, that real sessions use —
|
||||
/// one extra session open of a kind the encoder already performs per open (`query_caps`), cached
|
||||
/// once per process by [`probe_support`]. The 4:4:4 cap rides the same session for the same
|
||||
/// reason: it used to be its own libav `hevc_nvenc` FREXT open — the exact open LOG-3 caught
|
||||
/// wedging NVENC — and the direct backend re-checks the same cap at session open anyway
|
||||
/// (`query_caps` → `yuv444_supported`), so the caps bit is the answer the live session will obey.
|
||||
///
|
||||
/// Every failure path returns "nothing probed" (see the [`ProbedSupport`] field docs for the
|
||||
/// per-field fail direction).
|
||||
fn probe_support_uncached() -> ProbedSupport {
|
||||
let unknown = ProbedSupport {
|
||||
codecs: crate::CodecSupport {
|
||||
h264: false,
|
||||
h265: false,
|
||||
av1: false,
|
||||
},
|
||||
hevc_444: false,
|
||||
};
|
||||
let Ok(api) = try_api() else {
|
||||
return unknown;
|
||||
};
|
||||
let cu_ctx = match cuda::context() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "NVENC codec probe: no CUDA context");
|
||||
return unknown;
|
||||
}
|
||||
};
|
||||
// SAFETY: `try_api()` returned Ok, so every fn pointer below is a live entry point from the
|
||||
// driver's own function list. `params`/`enc`/`count`/`written` are live locals that outlive
|
||||
// their synchronous calls; `device` is the process-shared CUDA context (`cuda::context()`
|
||||
// returned Ok), the same handle `query_caps` passes. `guids` is sized to the count the driver
|
||||
// just reported and its pointer is valid for that many `GUID`s, matching the
|
||||
// `guidArraySize` argument. The session is destroyed on every path out — including the failed
|
||||
// open, which the NVENC docs still require (the driver may have taken the slot before
|
||||
// erroring; skipping it leaks toward the concurrent-session cap).
|
||||
unsafe {
|
||||
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
|
||||
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
|
||||
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_CUDA,
|
||||
device: cu_ctx,
|
||||
apiVersion: nv::NVENCAPI_VERSION,
|
||||
..Default::default()
|
||||
};
|
||||
let mut enc: *mut c_void = ptr::null_mut();
|
||||
if let Err(e) = (api.open_encode_session_ex)(&mut params, &mut enc).nv_ok() {
|
||||
if !enc.is_null() {
|
||||
let _ = (api.destroy_encoder)(enc);
|
||||
}
|
||||
tracing::warn!(
|
||||
error = %format!("{:#}", nvenc_status::call_err("open_encode_session_ex (codec probe)", e)),
|
||||
"NVENC codec probe failed — keeping the static codec advertisement"
|
||||
);
|
||||
return unknown;
|
||||
}
|
||||
// The handshake with the kernel module succeeded (same latch `query_caps` sets).
|
||||
nvenc_status::note_session_opened();
|
||||
let mut count = 0u32;
|
||||
let counted = (api.get_encode_guid_count)(enc, &mut count).nv_ok().is_ok();
|
||||
let mut guids = vec![nv::GUID::default(); count as usize];
|
||||
let mut written = 0u32;
|
||||
let listed = counted
|
||||
&& count > 0
|
||||
&& (api.get_encode_guids)(enc, guids.as_mut_ptr(), count, &mut written)
|
||||
.nv_ok()
|
||||
.is_ok();
|
||||
guids.truncate(written as usize);
|
||||
// The 4:4:4 cap needs the session that is still open — query it before the destroy. Only
|
||||
// meaningful against a listed HEVC GUID (a cap query for an absent codec is undefined).
|
||||
let mut hevc_444 = false;
|
||||
if listed && guids.contains(&nv::NV_ENC_CODEC_HEVC_GUID) {
|
||||
let mut param = nv::NV_ENC_CAPS_PARAM {
|
||||
version: nv::NV_ENC_CAPS_PARAM_VER,
|
||||
capsToQuery: nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE,
|
||||
reserved: [0; 62],
|
||||
};
|
||||
let mut val: core::ffi::c_int = 0;
|
||||
hevc_444 = (api.get_encode_caps)(enc, nv::NV_ENC_CODEC_HEVC_GUID, &mut param, &mut val)
|
||||
.nv_ok()
|
||||
.is_ok()
|
||||
&& val != 0;
|
||||
}
|
||||
let _ = (api.destroy_encoder)(enc);
|
||||
if !listed {
|
||||
tracing::warn!(
|
||||
"NVENC codec probe: driver listed no encode GUIDs — keeping the static advertisement"
|
||||
);
|
||||
return unknown;
|
||||
}
|
||||
ProbedSupport {
|
||||
codecs: crate::CodecSupport {
|
||||
h264: guids.contains(&nv::NV_ENC_CODEC_H264_GUID),
|
||||
h265: guids.contains(&nv::NV_ENC_CODEC_HEVC_GUID),
|
||||
av1: guids.contains(&nv::NV_ENC_CODEC_AV1_GUID),
|
||||
},
|
||||
hevc_444,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_api() -> std::result::Result<EncodeApi, String> {
|
||||
// SAFETY: `Library::new` runs `libnvidia-encode.so.1`'s initializers — the trusted NVIDIA driver
|
||||
// library, so loading has no unexpected effects; `map_err` handles its absence (AMD/Intel/no
|
||||
@@ -223,6 +370,8 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
|
||||
reconfigure_encoder: list.nvEncReconfigureEncoder.ok_or(MISSING)?,
|
||||
destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?,
|
||||
get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?,
|
||||
get_encode_guid_count: list.nvEncGetEncodeGUIDCount.ok_or(MISSING)?,
|
||||
get_encode_guids: list.nvEncGetEncodeGUIDs.ok_or(MISSING)?,
|
||||
get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?,
|
||||
create_bitstream_buffer: list.nvEncCreateBitstreamBuffer.ok_or(MISSING)?,
|
||||
destroy_bitstream_buffer: list.nvEncDestroyBitstreamBuffer.ok_or(MISSING)?,
|
||||
@@ -415,21 +564,46 @@ fn retrieve_loop(
|
||||
}
|
||||
}
|
||||
|
||||
/// The NVENC input buffer format for a captured `DeviceBuffer`'s layout. NV12/YUV444 are the zero-
|
||||
/// copy worker's convert outputs; packed RGB (`ABGR`) is the fallback where NVENC does the internal
|
||||
/// CSC. 10-bit is never produced on Linux today (Phase 5.1), so everything is 8-bit.
|
||||
fn buffer_format(buf: &cuda::DeviceBuffer) -> nv::NV_ENC_BUFFER_FORMAT {
|
||||
/// The NVENC input buffer format for a captured frame. NV12/YUV444 are the zero-copy worker's
|
||||
/// convert outputs and are recognised from the `DeviceBuffer`'s layout; the packed formats are 4
|
||||
/// bytes per pixel either way, so their DEPTH and channel order can only come from the capture
|
||||
/// format — which is why `fmt` is a parameter and not something derived from `buf`.
|
||||
///
|
||||
/// Packed RGB lets NVENC do the CSC internally, which is exactly what an HDR gamescope session
|
||||
/// wants: the frame is already PQ-encoded BT.2020 RGB, and NVENC's internal conversion follows the
|
||||
/// configured VUI matrix (BT.2020 NCL for HDR — see `apply_low_latency_config`), so there is no
|
||||
/// host-side CSC pass and no depth loss anywhere on the path.
|
||||
fn buffer_format(buf: &cuda::DeviceBuffer, fmt: pf_frame::PixelFormat) -> nv::NV_ENC_BUFFER_FORMAT {
|
||||
if buf.yuv444 {
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444
|
||||
} else if buf.is_nv12() {
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12
|
||||
} else {
|
||||
// Packed 4-byte BGRA-order (the `copy_device_to_device` fallback path); NVENC's `ARGB`
|
||||
// ingests this layout + does the internal CSC, matching the proven Windows RGB-input path.
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
|
||||
match fmt {
|
||||
// `x:R:G:B` 2:10:10:10 LE — NVENC's `ARGB10` is the same word layout (B in the low
|
||||
// 10 bits, R in bits 20-29).
|
||||
pf_frame::PixelFormat::X2Rgb10 => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB10,
|
||||
// `x:B:G:R` 2:10:10:10 LE — NVENC's `ABGR10` (R in the low 10 bits).
|
||||
pf_frame::PixelFormat::X2Bgr10 => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10,
|
||||
// Packed 4-byte BGRA-order (the `copy_device_to_device` fallback path); NVENC's `ARGB`
|
||||
// ingests this layout + does the internal CSC, matching the proven Windows RGB-input
|
||||
// path.
|
||||
_ => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `fmt` one of NVENC's packed 10-bit RGB inputs? Decides the session's effective bit depth and
|
||||
/// HDR flag — the input format is the only honest source for both (a 10-bit-negotiated session
|
||||
/// whose capture came back 8-bit must encode, and label, 8-bit).
|
||||
fn is_ten_bit_input(fmt: nv::NV_ENC_BUFFER_FORMAT) -> bool {
|
||||
matches!(
|
||||
fmt,
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB10
|
||||
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
|
||||
)
|
||||
}
|
||||
|
||||
/// One encoder-owned input surface + its NVENC registration. The surface is copied into each
|
||||
/// use (device→device) and the registration is created once at session init, unregistered at teardown.
|
||||
struct RingSlot {
|
||||
@@ -453,6 +627,10 @@ fn slot_fmt_of(fmt: nv::NV_ENC_BUFFER_FORMAT) -> SlotFormat {
|
||||
match fmt {
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444 => SlotFormat::Yuv444,
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12 => SlotFormat::Nv12,
|
||||
// Still 4 bytes per pixel, so the slot GEOMETRY matches `Argb` — but the cursor blend
|
||||
// must unpack 10-bit channels instead of bytes, hence a separate mode per channel order.
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB10 => SlotFormat::X2Rgb10,
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10 => SlotFormat::X2Bgr10,
|
||||
_ => SlotFormat::Argb,
|
||||
}
|
||||
}
|
||||
@@ -653,9 +831,10 @@ unsafe impl Send for NvencCudaEncoder {}
|
||||
impl NvencCudaEncoder {
|
||||
/// Signature mirrors `super::NvencEncoder::open` so the Linux dispatcher fork is a one-line swap.
|
||||
/// `format`/`cuda` are advisory: the session's real input format is derived from the first
|
||||
/// captured `DeviceBuffer`'s layout (lazy init in `submit`), and this backend only accepts CUDA
|
||||
/// frames (a CPU/dmabuf payload `bail`s). `bit_depth` is pinned to 8 on Linux (Phase 5.1 will
|
||||
/// lift it once P010 capture exists).
|
||||
/// captured frame (lazy init in `submit`), and this backend only accepts CUDA frames (a
|
||||
/// CPU/dmabuf payload `bail`s). The effective `bit_depth`/`hdr` are derived from that same
|
||||
/// input format rather than trusted from the negotiation — a 10-bit session whose capture came
|
||||
/// back 8-bit must encode 8-bit AND say so, never mislabel.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open(
|
||||
codec: Codec,
|
||||
@@ -672,16 +851,7 @@ impl NvencCudaEncoder {
|
||||
// The runtime `.so` load is the real "is NVENC possible here" gate: fail the open with a
|
||||
// clear reason instead of an opaque session error on the first frame.
|
||||
try_api().map_err(|e| anyhow!("NVENC (Linux direct) unavailable: {e}"))?;
|
||||
if bit_depth >= 10 {
|
||||
// An HDR (GNOME 50 portal) session never reaches this backend: its X2RGB10 frames ride
|
||||
// the CPU/dmabuf paths (no CUDA import for the 10-bit formats yet), so the dispatcher
|
||||
// opens the libav P010 path instead. Reaching here 10-bit means a CUDA capture payload
|
||||
// on a 10-bit session — not wired; encode 8-bit rather than mislabel.
|
||||
tracing::warn!(
|
||||
"Linux direct-NVENC: 10-bit requested but the CUDA capture path has no 10-bit \
|
||||
import yet (HDR rides the libav P010 path) — encoding 8-bit SDR"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
encoder: ptr::null_mut(),
|
||||
cu_ctx: ptr::null_mut(),
|
||||
@@ -692,7 +862,9 @@ impl NvencCudaEncoder {
|
||||
fps,
|
||||
bitrate_bps,
|
||||
buffer_fmt: nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12,
|
||||
bit_depth: 8,
|
||||
// Provisional until the first frame names the real input format (see `submit`'s init
|
||||
// block, which sets both from `buffer_fmt`).
|
||||
bit_depth,
|
||||
// 4:4:4 is HEVC-only; confirmed against the frame layout + GPU support at init.
|
||||
chroma_444: chroma.is_444() && codec == Codec::H265,
|
||||
yuv444_supported: false,
|
||||
@@ -1021,8 +1193,8 @@ impl NvencCudaEncoder {
|
||||
let mut cfg = preset.presetCfg;
|
||||
|
||||
// Steps 3-7 (RC/VBV, tier+level, chroma+bit-depth, colour VUI, RFI DPB) are the shared
|
||||
// low-latency contract. On Linux the full-chroma input is a YUV444 surface and the input is
|
||||
// 8-bit today, so AV1's input-depth is 0.
|
||||
// low-latency contract. On Linux the full-chroma input is a YUV444 surface; AV1's
|
||||
// input-depth follows the surface format (10-bit for a packed PQ/BT.2020 HDR capture).
|
||||
let yuv444_input = matches!(
|
||||
self.buffer_fmt,
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV444
|
||||
@@ -1037,7 +1209,11 @@ impl NvencCudaEncoder {
|
||||
chroma_444: self.chroma_444,
|
||||
full_chroma_input: yuv444_input,
|
||||
bit_depth: self.bit_depth,
|
||||
av1_input_depth_minus8: 0,
|
||||
av1_input_depth_minus8: if is_ten_bit_input(self.buffer_fmt) {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
},
|
||||
hdr: self.hdr,
|
||||
rfi_supported: self.rfi_supported,
|
||||
slices: self.slices,
|
||||
@@ -1533,7 +1709,7 @@ impl Encoder for NvencCudaEncoder {
|
||||
self.maybe_disengage_async();
|
||||
// Re-init on a size change (the capturer can return at a different resolution after a mode
|
||||
// switch). Format changes (NV12↔YUV444) likewise re-init.
|
||||
let new_fmt = buffer_format(buf);
|
||||
let new_fmt = buffer_format(buf, captured.format);
|
||||
let size_changed =
|
||||
self.inited && (self.width != captured.width || self.height != captured.height);
|
||||
let fmt_changed = self.inited && self.buffer_fmt != new_fmt;
|
||||
@@ -1554,6 +1730,21 @@ impl Encoder for NvencCudaEncoder {
|
||||
self.width = captured.width;
|
||||
self.height = captured.height;
|
||||
self.buffer_fmt = new_fmt;
|
||||
// Depth + HDR follow the INPUT, like the Windows backend: a packed 10-bit PQ/BT.2020
|
||||
// capture (an HDR gamescope output) selects Main10 / AV1 10-bit and the BT.2020 PQ
|
||||
// colour signalling; anything else is 8-bit SDR. Deriving it here rather than
|
||||
// trusting the negotiated depth is what keeps the label and the bitstream in step
|
||||
// when capture and negotiation disagree.
|
||||
let ten_bit_in = is_ten_bit_input(new_fmt);
|
||||
if self.bit_depth >= 10 && !ten_bit_in {
|
||||
tracing::warn!(
|
||||
format = ?captured.format,
|
||||
"Linux direct-NVENC: 10-bit negotiated but the capture delivered an 8-bit \
|
||||
format — encoding 8-bit SDR (the stream is labelled to match)"
|
||||
);
|
||||
}
|
||||
self.bit_depth = if ten_bit_in { 10 } else { 8 };
|
||||
self.hdr = ten_bit_in;
|
||||
// 4:4:4 honesty: engage FREXT only on a genuine YUV444 input; a subsampled NV12/RGB input
|
||||
// can't reconstruct full chroma, so clear the flag so `caps().chroma_444` is truthful.
|
||||
self.chroma_444 = self.chroma_444 && buf.yuv444;
|
||||
@@ -2264,6 +2455,32 @@ mod tests {
|
||||
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||
use pf_zerocopy::cuda::DeviceBuffer;
|
||||
|
||||
/// The 10-bit input mapping is load-bearing in a way a smoke test can't reach: pick the wrong
|
||||
/// NVENC format for a packed 2:10:10:10 capture and the encoder reads the words as 8-bit
|
||||
/// `ARGB` — a picture that decodes, looks *almost* right, and is silently 8-bit with the
|
||||
/// channels shifted. These are the two tables that decide it.
|
||||
#[test]
|
||||
fn ten_bit_rgb_maps_to_the_matching_nvenc_format_and_blend_mode() {
|
||||
use nv::NV_ENC_BUFFER_FORMAT as F;
|
||||
// `x:R:G:B` (B in the low bits) is NVENC's ARGB10; `x:B:G:R` is ABGR10.
|
||||
assert!(is_ten_bit_input(F::NV_ENC_BUFFER_FORMAT_ARGB10));
|
||||
assert!(is_ten_bit_input(F::NV_ENC_BUFFER_FORMAT_ABGR10));
|
||||
assert!(!is_ten_bit_input(F::NV_ENC_BUFFER_FORMAT_ARGB));
|
||||
assert!(!is_ten_bit_input(F::NV_ENC_BUFFER_FORMAT_NV12));
|
||||
assert!(!is_ten_bit_input(F::NV_ENC_BUFFER_FORMAT_YUV444));
|
||||
// …and each gets the cursor-blend mode that unpacks ITS channel order. Swapping these
|
||||
// would tint the pointer (R and B exchanged) with nothing else out of place.
|
||||
assert_eq!(
|
||||
slot_fmt_of(F::NV_ENC_BUFFER_FORMAT_ARGB10),
|
||||
SlotFormat::X2Rgb10
|
||||
);
|
||||
assert_eq!(
|
||||
slot_fmt_of(F::NV_ENC_BUFFER_FORMAT_ABGR10),
|
||||
SlotFormat::X2Bgr10
|
||||
);
|
||||
assert_eq!(slot_fmt_of(F::NV_ENC_BUFFER_FORMAT_ARGB), SlotFormat::Argb);
|
||||
}
|
||||
|
||||
fn nv12_frame(w: u32, h: u32, i: u32) -> CapturedFrame {
|
||||
// Content is uninitialized device memory — NVENC encodes it fine; this smoke test asserts the
|
||||
// session/registration/encode/RFI machinery, not picture fidelity (that's the on-glass A/B).
|
||||
@@ -2284,6 +2501,46 @@ mod tests {
|
||||
/// and assert the next AU carries the recovery-anchor tag (the F2 fix) and that `caps()`
|
||||
/// advertises RFI. Needs an NVIDIA GPU + driver. Run:
|
||||
/// cargo test -p punktfunk-host --features nvenc -- --ignored nvenc_cuda_smoke --nocapture
|
||||
/// ON-HARDWARE: the codec/4:4:4 advertisement probe against the real driver. Asserts the two
|
||||
/// invariants that matter for what the host advertises — every NVENC-capable GPU ever made can
|
||||
/// encode H.264, so a probe that comes back with `h264 = false` while NVENC is otherwise
|
||||
/// working means the enumeration itself is broken (and would silently narrow the host's
|
||||
/// advertisement); and the answer must be stable across calls (asserted on the UNCACHED fn —
|
||||
/// the cached [`probe_support`] would make it vacuous), since one cached answer drives every
|
||||
/// negotiation. Prints the mask so a run on an OLD card (Maxwell GM107 = h264 only, no 4:4:4 —
|
||||
/// the GPU this probe exists for) is self-documenting. Run:
|
||||
/// cargo test -p pf-encode --features nvenc -- --ignored nvenc_codec_probe --nocapture
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on an NVIDIA box"]
|
||||
fn nvenc_codec_probe_reports_real_gpu_support() {
|
||||
let probed = probe_support_uncached();
|
||||
let caps = probed.codecs;
|
||||
eprintln!(
|
||||
"NVENC probe: h264={} h265={} av1={} hevc_444={}",
|
||||
caps.h264, caps.h265, caps.av1, probed.hevc_444
|
||||
);
|
||||
assert!(
|
||||
caps.h264,
|
||||
"every NVENC generation encodes H.264 — a false here means the GUID enumeration \
|
||||
failed, which would narrow the host's codec advertisement"
|
||||
);
|
||||
assert!(
|
||||
!probed.hevc_444 || caps.h265,
|
||||
"a 4:4:4-capable HEVC that is not in the GUID list is contradictory"
|
||||
);
|
||||
let again = probe_support_uncached();
|
||||
assert_eq!(
|
||||
(caps.h264, caps.h265, caps.av1, probed.hevc_444),
|
||||
(
|
||||
again.codecs.h264,
|
||||
again.codecs.h265,
|
||||
again.codecs.av1,
|
||||
again.hevc_444
|
||||
),
|
||||
"the probe must be stable — it is cached once and drives every later negotiation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||
fn nvenc_cuda_smoke_rfi_anchor() {
|
||||
@@ -2358,6 +2615,172 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A packed 2:10:10:10 (`X2Rgb10`) CUDA frame — the layout an HDR gamescope capture imports
|
||||
/// through the Vulkan bridge, and what NVENC ingests as `ARGB10` with no host CSC at all.
|
||||
/// The device memory is uninitialised: this smoke asserts the session/registration/encode
|
||||
/// machinery, not picture fidelity (that is the AMD round-trip's job — the CSC here is
|
||||
/// NVENC's own ASIC, not our shader).
|
||||
fn rgb10_frame(w: u32, h: u32, i: u32) -> CapturedFrame {
|
||||
let buf = DeviceBuffer::alloc(w, h).expect("alloc packed RGB device buffer");
|
||||
CapturedFrame {
|
||||
width: w,
|
||||
height: h,
|
||||
pts_ns: i as u64 * 16_666_667,
|
||||
format: PixelFormat::X2Rgb10,
|
||||
payload: FramePayload::Cuda(buf),
|
||||
cursor: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ON-HARDWARE: the HDR path — a packed 10-bit PQ/BT.2020 CUDA payload straight into NVENC as
|
||||
/// `ARGB10`, which is what makes an NVIDIA HDR session zero-copy AND host-CSC-free: NVENC does
|
||||
/// the BT.2020 conversion in the ASIC, following the VUI this session configures.
|
||||
///
|
||||
/// The load-bearing assertions are the ones that would catch a mislabelled stream: the encoder
|
||||
/// must have DERIVED 10-bit from the input format (not merely been asked for it), and it must
|
||||
/// report HDR — that pair is what selects Main10 / AV1-10 and the BT.2020 PQ signalling.
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver with 10-bit encode"]
|
||||
fn nvenc_cuda_hdr10_packed_rgb() {
|
||||
for codec in [Codec::H265, Codec::Av1] {
|
||||
const W: u32 = 1280;
|
||||
const H: u32 = 720;
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
codec,
|
||||
PixelFormat::X2Rgb10,
|
||||
W,
|
||||
H,
|
||||
60,
|
||||
20_000_000,
|
||||
true,
|
||||
10,
|
||||
ChromaFormat::Yuv420,
|
||||
false,
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
|
||||
let mut aus = 0usize;
|
||||
let mut first_key = false;
|
||||
let mut stream: Vec<u8> = Vec::new();
|
||||
for i in 0..4u32 {
|
||||
enc.submit_indexed(&rgb10_frame(W, H, i), i)
|
||||
.expect("submit");
|
||||
while let Some(au) = enc.poll().expect("poll") {
|
||||
if aus == 0 {
|
||||
first_key = au.keyframe;
|
||||
}
|
||||
assert!(!au.data.is_empty(), "empty AU");
|
||||
stream.extend_from_slice(&au.data);
|
||||
aus += 1;
|
||||
}
|
||||
}
|
||||
enc.flush().ok();
|
||||
// Dumped for the out-of-band ffprobe check. In-tree we can assert the encoder's OWN
|
||||
// view of the config; only a decoder confirms the BITSTREAM says Main10 / BT.2020 /
|
||||
// PQ, which is what a client actually reads.
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let ext = if codec == Codec::Av1 { "obu" } else { "h265" };
|
||||
let path = format!("{home}/nvenc-hdr10.{ext}");
|
||||
if std::fs::write(&path, &stream).is_ok() {
|
||||
println!(
|
||||
"nvenc_cuda HDR10 {codec:?}: wrote {path} ({} bytes)",
|
||||
stream.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(aus > 0, "{codec:?}: no AUs produced");
|
||||
assert!(first_key, "{codec:?}: first AU must be the session IDR");
|
||||
// The whole point: depth + HDR came from the INPUT format, so the bitstream's profile
|
||||
// and colour signalling describe what was actually encoded.
|
||||
assert_eq!(enc.bit_depth, 10, "{codec:?}: must have derived 10-bit");
|
||||
assert!(
|
||||
enc.hdr,
|
||||
"{codec:?}: must have derived HDR from the PQ format"
|
||||
);
|
||||
assert_eq!(
|
||||
enc.buffer_fmt,
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB10,
|
||||
"{codec:?}: X2Rgb10 must ingest as ARGB10"
|
||||
);
|
||||
println!("nvenc_cuda HDR10 {codec:?}: {aus} AUs, ARGB10 in, 10-bit derived");
|
||||
}
|
||||
}
|
||||
|
||||
/// ON-HARDWARE: the cursor blended into a **10-bit** input surface — `cursor_blend.comp`'s
|
||||
/// MODE 3/4, which unpack 2:10:10:10 channels instead of bytes. New shader code, and the only
|
||||
/// way a gamescope pointer reaches an HDR NVIDIA stream: the packed-RGB slot is what NVENC
|
||||
/// ingests, so the blend has to happen in that layout rather than in a YUV plane.
|
||||
///
|
||||
/// Asserts the machinery — AUs come out, and the blend targets the 10-bit slot layout rather
|
||||
/// than silently falling back to the 8-bit one, which would tint the pointer and shift its
|
||||
/// channels. Blend CORRECTNESS is display-referred by design (see the shader), so it is
|
||||
/// judged by eye on a dump, not here.
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver with 10-bit encode"]
|
||||
fn nvenc_cuda_hdr10_cursor_blend() {
|
||||
const W: u32 = 1280;
|
||||
const H: u32 = 720;
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
if !stream_ordered_requested() || async_retrieve_requested() {
|
||||
println!("skipped: stream-ordered submit disabled by env");
|
||||
return;
|
||||
}
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::X2Rgb10,
|
||||
W,
|
||||
H,
|
||||
60,
|
||||
8_000_000,
|
||||
true,
|
||||
10,
|
||||
ChromaFormat::Yuv420,
|
||||
true, // cursor_blend: bring up the Vulkan slot ring + the 10-bit blend
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
let cursor = |serial: u64, x: i32, y: i32| pf_frame::CursorOverlay {
|
||||
x,
|
||||
y,
|
||||
w: 32,
|
||||
h: 32,
|
||||
rgba: std::sync::Arc::new(vec![0xFF; 32 * 32 * 4]),
|
||||
serial,
|
||||
hot_x: 0,
|
||||
hot_y: 0,
|
||||
visible: true,
|
||||
};
|
||||
let mut aus = 0usize;
|
||||
for i in 0..6u32 {
|
||||
let mut frame = rgb10_frame(W, H, i);
|
||||
// Bitmap serial flips at frame 3 (upload quiesce over in-flight ordered blends); the
|
||||
// position moves every frame (push-constant path) — same shape as the 8-bit twin.
|
||||
frame.cursor = Some(cursor(
|
||||
if i < 3 { 1 } else { 2 },
|
||||
40 + i as i32 * 9,
|
||||
60 + i as i32 * 5,
|
||||
));
|
||||
enc.submit_indexed(&frame, i).expect("submit");
|
||||
while let Some(au) = enc.poll().expect("poll") {
|
||||
assert!(!au.data.is_empty(), "empty AU");
|
||||
aus += 1;
|
||||
}
|
||||
}
|
||||
enc.flush().ok();
|
||||
assert!(aus > 0, "no AUs produced");
|
||||
assert_eq!(enc.bit_depth, 10, "must be a 10-bit session");
|
||||
assert_eq!(
|
||||
slot_fmt_of(enc.buffer_fmt),
|
||||
SlotFormat::X2Rgb10,
|
||||
"the blend must target the 10-bit packed slot layout, not the 8-bit one"
|
||||
);
|
||||
assert!(
|
||||
enc.caps().blends_cursor,
|
||||
"the direct-SDK path must still report a cursor blend at 10-bit"
|
||||
);
|
||||
println!("nvenc_cuda HDR10 cursor blend: {aus} AUs, slot fmt X2Rgb10");
|
||||
}
|
||||
|
||||
/// ON-HARDWARE (RTX box `.21`): the 4:4:4 path — a planar-YUV444 `DeviceBuffer` through an HEVC
|
||||
/// FREXT (chromaFormatIDC=3) session, exercising the stacked-plane input surface + copy that NV12
|
||||
/// doesn't. Asserts AUs come out and `caps().chroma_444` reports true (the GPU supports it). Run:
|
||||
|
||||
@@ -21,6 +21,13 @@
|
||||
//! on every AU. NOTE: until Phase 2 lands `CODEC_PYROWAVE` negotiation + a client decoder,
|
||||
//! no shipping client can decode this — the backend is reachable only via an explicit
|
||||
//! `PUNKTFUNK_ENCODER=pyrowave` and logs that loudly.
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is `pyrowave-sys` C-API and ash/Vulkan compute calls almost line for line;
|
||||
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
|
||||
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
|
||||
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
// Every unsafe block in this module carries a `// SAFETY:` proof (parent module enforces it).
|
||||
|
||||
use super::vk_util::{
|
||||
@@ -395,6 +402,9 @@ pub struct PyroWaveEncoder {
|
||||
/// packet to it, so each wire shard carries whole self-delimiting packets. `None` =
|
||||
/// one packet per AU (the dense MVP shape).
|
||||
wire_chunk: Option<usize>,
|
||||
/// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the
|
||||
/// WIRE, not just the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
|
||||
wire_budget: crate::pyrowave_wire::WireBudget,
|
||||
bitstream: Vec<u8>,
|
||||
pending: VecDeque<EncodedFrame>,
|
||||
frame_count: u64,
|
||||
@@ -653,6 +663,7 @@ impl PyroWaveEncoder {
|
||||
chroma444,
|
||||
frame_budget: budget_for(bitrate, fps),
|
||||
wire_chunk: None,
|
||||
wire_budget: crate::pyrowave_wire::WireBudget::new(),
|
||||
bitstream: Vec::new(),
|
||||
pending: VecDeque::new(),
|
||||
frame_count: 0,
|
||||
@@ -1120,6 +1131,16 @@ impl PyroWaveEncoder {
|
||||
Ok(self.cpu_img.unwrap().2)
|
||||
}
|
||||
|
||||
/// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the
|
||||
/// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is
|
||||
/// a promise about the wire, not the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
|
||||
fn rate_budget(&self) -> usize {
|
||||
match self.wire_chunk {
|
||||
Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024),
|
||||
None => self.frame_budget,
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame, synchronously: ingest → CSC → pyrowave encode (recorded into our command
|
||||
/// buffer) → submit + fence wait (sub-ms) → packetize into an `EncodedFrame`.
|
||||
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
|
||||
@@ -1175,6 +1196,8 @@ impl PyroWaveEncoder {
|
||||
// paths propagate untouched and the recovery (`reset()`/`Drop`) `device_wait_idle()`s
|
||||
// before anything touches `cmd`; a buffer that completed its one-time submit is INVALID,
|
||||
// which the next `begin` may implicitly reset.
|
||||
// Resolved before the closure (which borrows `self` mutably for the recording calls).
|
||||
let rate_budget = self.rate_budget();
|
||||
let record_and_submit = (|| -> Result<()> {
|
||||
dev.begin_command_buffer(
|
||||
self.cmd,
|
||||
@@ -1396,7 +1419,7 @@ impl PyroWaveEncoder {
|
||||
],
|
||||
};
|
||||
let rc = pw::pyrowave_rate_control {
|
||||
maximum_bitstream_size: self.frame_budget,
|
||||
maximum_bitstream_size: rate_budget,
|
||||
};
|
||||
pw::pyrowave_device_set_command_buffer(
|
||||
self.pw_dev,
|
||||
@@ -1476,6 +1499,10 @@ impl PyroWaveEncoder {
|
||||
// single packet, or the datagram-aligned windowed AU (§4.4).
|
||||
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
|
||||
let au = crate::pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
|
||||
if self.wire_chunk.is_some() {
|
||||
let raw: usize = pkts.iter().map(|&(_, s)| s).sum();
|
||||
self.wire_budget.observe(raw, au.len());
|
||||
}
|
||||
self.frame_count += 1;
|
||||
self.pending.push_back(EncodedFrame {
|
||||
data: au,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#version 450
|
||||
// The 10-bit HDR twin of `rgb2yuv.comp`: packed 2:10:10:10 RGB -> 10-bit 4:2:0 (BT.2020 NCL,
|
||||
// limited range), for an HEVC Main10 session off a gamescope HDR capture.
|
||||
//
|
||||
// The source samples are ALREADY PQ-encoded BT.2020 R'G'B' (gamescope composites into the PQ
|
||||
// container; see packaging/gamescope), so this is a pure 3x3 matrix on the code values — there is
|
||||
// no transfer function to apply here, and applying one would be wrong. That is the whole
|
||||
// difference from the SDR shader besides the coefficients and the store width.
|
||||
//
|
||||
// STORE LAYOUT. The scratch planes are `r16`/`rg16` and get `vkCmdCopyImage`d into the
|
||||
// `G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16` picture, whose plane texels put the 10-bit value in
|
||||
// the HIGH bits of a 16-bit word (`X6` = 6 unused low bits). So a written normalized value `f`
|
||||
// lands as `round(f * 65535)` and must equal `code10 << 6` — hence the `CODE10` factor below.
|
||||
//
|
||||
// What actually goes wrong if you change it: the `<< 6` PLACEMENT is the load-bearing part —
|
||||
// dropping it (writing `code10 / 65535.0`) is 64x too dark, i.e. a black picture, which is at
|
||||
// least obvious. Writing `code10 / 1023.0` instead is only ~0.1% high (65472 vs 65535 full
|
||||
// scale) and is genuinely harmless; it is wrong, not dangerous. Verified by round-trip on RADV
|
||||
// 2026-07-28: fed (160,160,800) 10-bit codes, decoded back (159,158,796).
|
||||
//
|
||||
// Rebuild: glslangValidator -V rgb2yuv10.comp -o rgb2yuv10.spv (vendored beside this file)
|
||||
layout(local_size_x = 8, local_size_y = 8) in;
|
||||
layout(binding = 0) uniform sampler2D rgb; // packed 2:10:10:10 input (sampled)
|
||||
layout(binding = 1, r16) uniform writeonly image2D yImg; // full-res Y (10-bit in the high bits)
|
||||
layout(binding = 2, rg16) uniform writeonly image2D uvImg; // half-res UV (interleaved)
|
||||
layout(binding = 3) uniform sampler2D cursorTex; // straight-alpha RGBA cursor (top-left)
|
||||
|
||||
layout(push_constant) uniform Push {
|
||||
ivec2 curOrigin; // top-left of the cursor in frame pixels (position - hotspot)
|
||||
ivec2 curSize; // cursor w,h in pixels; x <= 0 => disabled
|
||||
} pc;
|
||||
|
||||
// 10-bit code value -> the normalized float that stores it in the high bits of a 16-bit texel.
|
||||
const float CODE10 = 64.0 / 65535.0;
|
||||
|
||||
// BT.2020 non-constant-luminance, limited range, expressed directly in 10-bit code values:
|
||||
// Y' = 64 + 876*(0.2627R + 0.6780G + 0.0593B)
|
||||
// Cb = 512 + 896*(B' - Y')/1.8814
|
||||
// Cr = 512 + 896*(R' - Y')/1.4746
|
||||
float lumaY(vec3 c) { return 64.0 + 230.1252*c.r + 593.9280*c.g + 51.9468*c.b; }
|
||||
|
||||
// Blend the cursor over `col` at frame pixel `p`, when `p` falls inside the cursor rectangle.
|
||||
// The cursor bitmap is 8-bit sRGB and the frame is PQ, so this is a display-referred
|
||||
// approximation — the same one the CPU path (`pw_cursor.rs::composite_cursor_rgb10`) and the
|
||||
// CUDA path (`cursor_blend.comp` MODE 3/4) already make. Fine for a pointer.
|
||||
vec3 withCursor(ivec2 p, vec3 col) {
|
||||
if (pc.curSize.x <= 0) return col;
|
||||
ivec2 cp = p - pc.curOrigin;
|
||||
if (cp.x < 0 || cp.y < 0 || cp.x >= pc.curSize.x || cp.y >= pc.curSize.y) return col;
|
||||
vec4 c = texelFetch(cursorTex, cp, 0);
|
||||
return mix(col, c.rgb, c.a);
|
||||
}
|
||||
|
||||
// Source may be SMALLER than the coded (16-aligned) Y plane — clamp every fetch to the source
|
||||
// edge so the alignment-padding rows duplicate the last real row (see the SDR shader).
|
||||
void main() {
|
||||
ivec2 sz = imageSize(yImg);
|
||||
ivec2 rmax = textureSize(rgb, 0) - 1;
|
||||
ivec2 uvc = ivec2(gl_GlobalInvocationID.xy);
|
||||
ivec2 p = uvc * 2;
|
||||
if (p.x >= sz.x || p.y >= sz.y) return;
|
||||
vec3 c00 = withCursor(p, texelFetch(rgb, min(p, rmax), 0).rgb);
|
||||
vec3 c10 = withCursor(p + ivec2(1, 0), texelFetch(rgb, min(p + ivec2(1, 0), rmax), 0).rgb);
|
||||
vec3 c01 = withCursor(p + ivec2(0, 1), texelFetch(rgb, min(p + ivec2(0, 1), rmax), 0).rgb);
|
||||
vec3 c11 = withCursor(p + ivec2(1, 1), texelFetch(rgb, min(p + ivec2(1, 1), rmax), 0).rgb);
|
||||
imageStore(yImg, p, vec4(lumaY(c00) * CODE10, 0, 0, 1));
|
||||
imageStore(yImg, p + ivec2(1, 0), vec4(lumaY(c10) * CODE10, 0, 0, 1));
|
||||
imageStore(yImg, p + ivec2(0, 1), vec4(lumaY(c01) * CODE10, 0, 0, 1));
|
||||
imageStore(yImg, p + ivec2(1, 1), vec4(lumaY(c11) * CODE10, 0, 0, 1));
|
||||
vec3 a = (c00 + c10 + c01 + c11) * 0.25;
|
||||
float U = 512.0 - 125.1085*a.r - 322.8915*a.g + 448.0000*a.b;
|
||||
float V = 512.0 + 448.0000*a.r - 411.9680*a.g - 36.0320*a.b;
|
||||
imageStore(uvImg, uvc, vec4(U * CODE10, V * CODE10, 0, 1));
|
||||
}
|
||||
Binary file not shown.
@@ -19,6 +19,13 @@
|
||||
//! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`),
|
||||
//! as the CUDA encode path and the clients' decode paths already do. The encoder is opened
|
||||
//! *without* a global header, so VPS/SPS/PPS are in-band on every IDR.
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw libav (`ffmpeg-sys-next`) calls on borrowed AVFrame/AVBuffer
|
||||
// pointers almost line for line; narrowing it would add one `unsafe {}` plus one SAFETY comment per
|
||||
// call that could only restate the signature. Clearing this file means DELETING the markers that
|
||||
// carry no caller contract, not wrapping the calls — until then the lint is off HERE and enforced
|
||||
// everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
@@ -36,7 +43,8 @@ use std::ptr;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::libav::{
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_ITU709, SWS_POINT,
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, AvFilterGraph, PollOutcome,
|
||||
SWS_CS_ITU709, SWS_POINT,
|
||||
};
|
||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||
|
||||
@@ -396,8 +404,8 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
||||
480,
|
||||
30,
|
||||
2_000_000,
|
||||
hw.device_ref,
|
||||
hw.frames_ref,
|
||||
hw.device_ref.as_ptr(),
|
||||
hw.frames_ref.as_ptr(),
|
||||
false,
|
||||
)
|
||||
.is_ok(),
|
||||
@@ -435,8 +443,8 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
480,
|
||||
30,
|
||||
2_000_000,
|
||||
hw.device_ref,
|
||||
hw.frames_ref,
|
||||
hw.device_ref.as_ptr(),
|
||||
hw.frames_ref.as_ptr(),
|
||||
true,
|
||||
)
|
||||
.is_ok(),
|
||||
@@ -463,8 +471,11 @@ pub fn probe_can_encode_444(_codec: Codec) -> bool {
|
||||
|
||||
/// VAAPI device + NV12 frames pool (the encoder's input surfaces for the CPU path).
|
||||
struct VaapiHw {
|
||||
device_ref: *mut ffi::AVBufferRef,
|
||||
frames_ref: *mut ffi::AVBufferRef,
|
||||
// Declared frames-BEFORE-device on purpose: these drop in declaration order, which reproduces
|
||||
// exactly what the hand-written `Drop` this replaced did (the frames ctx holds its own
|
||||
// reference on the device). Do not reorder these two fields.
|
||||
frames_ref: AvBuffer,
|
||||
device_ref: AvBuffer,
|
||||
}
|
||||
|
||||
impl VaapiHw {
|
||||
@@ -481,44 +492,32 @@ impl VaapiHw {
|
||||
if r < 0 {
|
||||
bail!("no VAAPI device ({:?}): {}", node, ffmpeg::Error::from(r));
|
||||
}
|
||||
let mut frames_ref = ffi::av_hwframe_ctx_alloc(device_ref);
|
||||
if frames_ref.is_null() {
|
||||
ffi::av_buffer_unref(&mut device_ref);
|
||||
bail!("av_hwframe_ctx_alloc(VAAPI) failed");
|
||||
}
|
||||
let fc = (*frames_ref).data as *mut ffi::AVHWFramesContext;
|
||||
// `av_hwdevice_ctx_create` wrote an owned ref into `device_ref`; take ownership of it here so
|
||||
// every `bail!` below drops it (and the frames ref, once built) without cleanup of its own.
|
||||
let device_ref = AvBuffer::from_raw(device_ref)
|
||||
.context("av_hwdevice_ctx_create(VAAPI) gave no device")?;
|
||||
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
|
||||
.context("av_hwframe_ctx_alloc(VAAPI) failed")?;
|
||||
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
||||
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_VAAPI;
|
||||
(*fc).sw_format = sw_format;
|
||||
(*fc).width = w as c_int;
|
||||
(*fc).height = h as c_int;
|
||||
(*fc).initial_pool_size = pool;
|
||||
let r = ffi::av_hwframe_ctx_init(frames_ref);
|
||||
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
|
||||
if r < 0 {
|
||||
ffi::av_buffer_unref(&mut frames_ref);
|
||||
ffi::av_buffer_unref(&mut device_ref);
|
||||
bail!("av_hwframe_ctx_init(VAAPI) failed ({r})");
|
||||
}
|
||||
Ok(VaapiHw {
|
||||
device_ref,
|
||||
frames_ref,
|
||||
device_ref,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VaapiHw {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `VaapiHw::new`
|
||||
// created (it bails before constructing `Self` if either alloc fails, so a live `VaapiHw`
|
||||
// always holds both). `av_buffer_unref` drops one reference and nulls the pointer through the
|
||||
// `&mut`. This `Drop` runs exactly once and `VaapiHw` owns these refs exclusively, so there
|
||||
// is no double-free / use-after-free. Frames are unref'd before the device because the frames
|
||||
// ctx internally holds a ref on the device (refcounted, so the order is sound either way).
|
||||
unsafe {
|
||||
ffi::av_buffer_unref(&mut self.frames_ref);
|
||||
ffi::av_buffer_unref(&mut self.device_ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
// No `Drop` for `VaapiHw`: each `AvBuffer` field unrefs itself, in declaration order (frames, then
|
||||
// device — see the field comment). The hand-written unref pair this replaced had to stay in sync
|
||||
// with every failure branch in `new`; now there is one unref path and it cannot be skipped.
|
||||
|
||||
struct CpuInner {
|
||||
enc: encoder::video::Encoder,
|
||||
@@ -567,8 +566,8 @@ impl CpuInner {
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
hw.device_ref,
|
||||
hw.frames_ref,
|
||||
hw.device_ref.as_ptr(),
|
||||
hw.frames_ref.as_ptr(),
|
||||
ten_bit,
|
||||
)?
|
||||
};
|
||||
@@ -698,7 +697,7 @@ impl CpuInner {
|
||||
if hwf.is_null() {
|
||||
bail!("av_frame_alloc(hw) failed");
|
||||
}
|
||||
if ffi::av_hwframe_get_buffer(self.hw.frames_ref, hwf, 0) < 0 {
|
||||
if ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), hwf, 0) < 0 {
|
||||
ffi::av_frame_free(&mut hwf);
|
||||
bail!("av_hwframe_get_buffer(VAAPI) failed");
|
||||
}
|
||||
@@ -746,14 +745,30 @@ impl Drop for CpuInner {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
struct DmabufInner {
|
||||
enc: encoder::video::Encoder,
|
||||
// FIELD ORDER IS LOAD-BEARING. These drop in declaration order, and this order reproduces
|
||||
// exactly what the hand-written `Drop` this replaced did: graph, then frames, then the derived
|
||||
// VAAPI device, then DRM — and `enc` last of all, since the old `Drop` ran before any field and
|
||||
// so freed all four ahead of ffmpeg-next dropping the encoder. Every one of these holds its own
|
||||
// reference, so refcounting makes any order sound; the point is that a field reorder must not
|
||||
// silently change what ships. Do not move `enc`, and do not reorder the four below it.
|
||||
/// The filter graph. Owner-only: `src`/`sink` below are borrowed filter contexts the graph owns,
|
||||
/// and everything per-frame goes through those, so nothing reads this field again — it exists so
|
||||
/// the graph outlives them and is freed exactly once. (`dead_code` answered here rather than by
|
||||
/// removing the field, which would free the graph while `src`/`sink` still point into it.)
|
||||
#[allow(dead_code)]
|
||||
graph: AvFilterGraph,
|
||||
/// DRM-PRIME frames context for the imported dmabufs (buffersrc input). Read per frame by
|
||||
/// `submit`, which tags each imported `AVFrame` with a new ref of it.
|
||||
drm_frames: AvBuffer,
|
||||
/// VAAPI device driving `hwmap`/`scale_vaapi`/the encoder. Owner-only: the two filters and the
|
||||
/// encoder each took their own ref at open, so nothing reads it again — it keeps the device
|
||||
/// alive behind them.
|
||||
#[allow(dead_code)]
|
||||
vaapi_device: AvBuffer,
|
||||
/// DRM device the source dmabuf frames reference (the buffersrc's `hw_frames_ctx` device).
|
||||
drm_device: *mut ffi::AVBufferRef,
|
||||
/// VAAPI device driving `hwmap`/`scale_vaapi`/the encoder.
|
||||
vaapi_device: *mut ffi::AVBufferRef,
|
||||
/// DRM-PRIME frames context for the imported dmabufs (buffersrc input).
|
||||
drm_frames: *mut ffi::AVBufferRef,
|
||||
graph: *mut ffi::AVFilterGraph,
|
||||
/// Owner-only for the same reason: `drm_frames` holds its own ref on it.
|
||||
#[allow(dead_code)]
|
||||
drm_device: AvBuffer,
|
||||
src: *mut ffi::AVFilterContext,
|
||||
sink: *mut ffi::AVFilterContext,
|
||||
width: u32,
|
||||
@@ -763,6 +778,10 @@ struct DmabufInner {
|
||||
/// submit (import+push vs CSC pull vs encoder send), the stage that dominates AMD/Intel
|
||||
/// host latency (7.9 ms p50 at 1440p on the 780M).
|
||||
frames: u64,
|
||||
/// Declared LAST on purpose — see the field-order note at the top of this struct. The encoder
|
||||
/// holds its own device ref and must drop after the graph and the three buffers, which is what
|
||||
/// the hand-written `Drop` used to guarantee by running ahead of every field.
|
||||
enc: encoder::video::Encoder,
|
||||
}
|
||||
|
||||
impl DmabufInner {
|
||||
@@ -829,70 +848,58 @@ impl DmabufInner {
|
||||
ffmpeg::Error::from(r)
|
||||
);
|
||||
}
|
||||
// Own each handle the moment it exists: from here every `bail!` drops whatever has been
|
||||
// built so far, in reverse order, so not one failure branch below carries cleanup code.
|
||||
let drm_device = AvBuffer::from_raw(drm_device)
|
||||
.context("av_hwdevice_ctx_create(DRM) gave no device")?;
|
||||
let mut vaapi_device: *mut ffi::AVBufferRef = ptr::null_mut();
|
||||
let r = ffi::av_hwdevice_ctx_create_derived(
|
||||
&mut vaapi_device,
|
||||
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
|
||||
drm_device,
|
||||
drm_device.as_ptr(),
|
||||
0,
|
||||
);
|
||||
if r < 0 {
|
||||
ffi::av_buffer_unref(&mut drm_device);
|
||||
bail!("derive VAAPI from DRM: {}", ffmpeg::Error::from(r));
|
||||
}
|
||||
let vaapi_device = AvBuffer::from_raw(vaapi_device)
|
||||
.context("av_hwdevice_ctx_create_derived(VAAPI) gave no device")?;
|
||||
|
||||
// DRM-PRIME frames context for the imported dmabufs.
|
||||
let mut drm_frames = ffi::av_hwframe_ctx_alloc(drm_device);
|
||||
if drm_frames.is_null() {
|
||||
ffi::av_buffer_unref(&mut vaapi_device);
|
||||
ffi::av_buffer_unref(&mut drm_device);
|
||||
bail!("av_hwframe_ctx_alloc(DRM) failed");
|
||||
}
|
||||
let fc = (*drm_frames).data as *mut ffi::AVHWFramesContext;
|
||||
let drm_frames = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(drm_device.as_ptr()))
|
||||
.context("av_hwframe_ctx_alloc(DRM) failed")?;
|
||||
let fc = (*drm_frames.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
||||
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME;
|
||||
(*fc).sw_format = sw_format; // packed XR24 RGB plane, or XR30/XB30 for HDR
|
||||
(*fc).width = width as c_int;
|
||||
(*fc).height = height as c_int;
|
||||
if ffi::av_hwframe_ctx_init(drm_frames) < 0 {
|
||||
ffi::av_buffer_unref(&mut drm_frames);
|
||||
ffi::av_buffer_unref(&mut vaapi_device);
|
||||
ffi::av_buffer_unref(&mut drm_device);
|
||||
if ffi::av_hwframe_ctx_init(drm_frames.as_ptr()) < 0 {
|
||||
bail!("av_hwframe_ctx_init(DRM) failed");
|
||||
}
|
||||
|
||||
// Filter graph: buffer(drm_prime) → hwmap=derive_device=vaapi:mode=read →
|
||||
// scale_vaapi=format=nv12 → buffersink.
|
||||
let mut graph = ffi::avfilter_graph_alloc();
|
||||
if graph.is_null() {
|
||||
ffi::av_buffer_unref(&mut drm_frames);
|
||||
ffi::av_buffer_unref(&mut vaapi_device);
|
||||
ffi::av_buffer_unref(&mut drm_device);
|
||||
bail!("avfilter_graph_alloc failed");
|
||||
}
|
||||
let graph = AvFilterGraph::alloc().context("avfilter_graph_alloc failed")?;
|
||||
|
||||
let mk = |name: &CStr, inst: &CStr| -> *mut ffi::AVFilterContext {
|
||||
let f = ffi::avfilter_get_by_name(name.as_ptr());
|
||||
if f.is_null() {
|
||||
return ptr::null_mut();
|
||||
}
|
||||
ffi::avfilter_graph_alloc_filter(graph, f, inst.as_ptr())
|
||||
ffi::avfilter_graph_alloc_filter(graph.as_ptr(), f, inst.as_ptr())
|
||||
};
|
||||
let src = mk(c"buffer", c"in");
|
||||
let hwmap = mk(c"hwmap", c"map");
|
||||
let scale = mk(c"scale_vaapi", c"csc");
|
||||
let sink = mk(c"buffersink", c"out");
|
||||
if src.is_null() || hwmap.is_null() || scale.is_null() || sink.is_null() {
|
||||
ffi::avfilter_graph_free(&mut graph);
|
||||
ffi::av_buffer_unref(&mut drm_frames);
|
||||
ffi::av_buffer_unref(&mut vaapi_device);
|
||||
ffi::av_buffer_unref(&mut drm_device);
|
||||
bail!("a VAAPI filter (buffer/hwmap/scale_vaapi/buffersink) is missing");
|
||||
}
|
||||
// hwmap maps the DRM-PRIME input onto THIS vaapi device; scale_vaapi runs the CSC on
|
||||
// it. Giving both our device (rather than `hwmap=derive_device`) keeps every surface —
|
||||
// and the sink's output frames ctx the encoder adopts — on one VADisplay.
|
||||
(*hwmap).hw_device_ctx = ffi::av_buffer_ref(vaapi_device);
|
||||
(*scale).hw_device_ctx = ffi::av_buffer_ref(vaapi_device);
|
||||
(*hwmap).hw_device_ctx = ffi::av_buffer_ref(vaapi_device.as_ptr());
|
||||
(*scale).hw_device_ctx = ffi::av_buffer_ref(vaapi_device.as_ptr());
|
||||
|
||||
// buffersrc params: DRM-PRIME frames, the drm_frames ctx.
|
||||
let par = ffi::av_buffersrc_parameters_alloc();
|
||||
@@ -913,24 +920,16 @@ impl DmabufInner {
|
||||
// the struct, not the ref. Our single owned `drm_frames` ref is retained, lives in
|
||||
// `DmabufInner`, and is unref'd in `Drop`. Wrapping it in `av_buffer_ref` here would leak
|
||||
// that extra ref every session (the persistent listener would accumulate them).
|
||||
(*par).hw_frames_ctx = drm_frames;
|
||||
(*par).hw_frames_ctx = drm_frames.as_ptr();
|
||||
let r = ffi::av_buffersrc_parameters_set(src, par);
|
||||
ffi::av_free(par as *mut _);
|
||||
if r < 0 {
|
||||
ffi::avfilter_graph_free(&mut graph);
|
||||
ffi::av_buffer_unref(&mut drm_frames);
|
||||
ffi::av_buffer_unref(&mut vaapi_device);
|
||||
ffi::av_buffer_unref(&mut drm_device);
|
||||
bail!("av_buffersrc_parameters_set failed ({r})");
|
||||
}
|
||||
macro_rules! init {
|
||||
($ctx:expr, $args:expr, $what:literal) => {{
|
||||
let r = ffi::avfilter_init_str($ctx, $args);
|
||||
if r < 0 {
|
||||
ffi::avfilter_graph_free(&mut graph);
|
||||
ffi::av_buffer_unref(&mut drm_frames);
|
||||
ffi::av_buffer_unref(&mut vaapi_device);
|
||||
ffi::av_buffer_unref(&mut drm_device);
|
||||
bail!(concat!("init ", $what, " failed ({})"), r);
|
||||
}
|
||||
}};
|
||||
@@ -944,28 +943,16 @@ impl DmabufInner {
|
||||
ffi::avfilter_link(a, 0, b, 0)
|
||||
};
|
||||
if link(src, hwmap) < 0 || link(hwmap, scale) < 0 || link(scale, sink) < 0 {
|
||||
ffi::avfilter_graph_free(&mut graph);
|
||||
ffi::av_buffer_unref(&mut drm_frames);
|
||||
ffi::av_buffer_unref(&mut vaapi_device);
|
||||
ffi::av_buffer_unref(&mut drm_device);
|
||||
bail!("avfilter_link failed");
|
||||
}
|
||||
let r = ffi::avfilter_graph_config(graph, ptr::null_mut());
|
||||
let r = ffi::avfilter_graph_config(graph.as_ptr(), ptr::null_mut());
|
||||
if r < 0 {
|
||||
ffi::avfilter_graph_free(&mut graph);
|
||||
ffi::av_buffer_unref(&mut drm_frames);
|
||||
ffi::av_buffer_unref(&mut vaapi_device);
|
||||
ffi::av_buffer_unref(&mut drm_device);
|
||||
bail!("avfilter_graph_config failed ({r})");
|
||||
}
|
||||
|
||||
// The encoder takes NV12 surfaces from the sink's output frames context.
|
||||
let nv12_ctx = ffi::av_buffersink_get_hw_frames_ctx(sink);
|
||||
if nv12_ctx.is_null() {
|
||||
ffi::avfilter_graph_free(&mut graph);
|
||||
ffi::av_buffer_unref(&mut drm_frames);
|
||||
ffi::av_buffer_unref(&mut vaapi_device);
|
||||
ffi::av_buffer_unref(&mut drm_device);
|
||||
bail!("filter sink has no VAAPI frames context");
|
||||
}
|
||||
// On encoder-open failure, free the graph + our owned buffer refs before bailing (matching
|
||||
@@ -979,16 +966,12 @@ impl DmabufInner {
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
vaapi_device,
|
||||
vaapi_device.as_ptr(),
|
||||
nv12_ctx,
|
||||
ten_bit,
|
||||
) {
|
||||
Ok(enc) => enc,
|
||||
Err(e) => {
|
||||
ffi::avfilter_graph_free(&mut graph);
|
||||
ffi::av_buffer_unref(&mut drm_frames);
|
||||
ffi::av_buffer_unref(&mut vaapi_device);
|
||||
ffi::av_buffer_unref(&mut drm_device);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
@@ -999,17 +982,17 @@ impl DmabufInner {
|
||||
if ten_bit { "P010 (HDR10)" } else { "NV12" }
|
||||
);
|
||||
Ok(DmabufInner {
|
||||
enc,
|
||||
drm_device,
|
||||
vaapi_device,
|
||||
drm_frames,
|
||||
graph,
|
||||
drm_frames,
|
||||
vaapi_device,
|
||||
drm_device,
|
||||
src,
|
||||
sink,
|
||||
width,
|
||||
height,
|
||||
fourcc: drm_fourcc,
|
||||
frames: 0,
|
||||
enc,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1084,7 +1067,7 @@ impl DmabufInner {
|
||||
// Mesa's is BT.601, contradicting the BT.709-limited VUI the encoder signals).
|
||||
(*drm).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
|
||||
(*drm).colorspace = ffi::AVColorSpace::AVCOL_SPC_RGB;
|
||||
(*drm).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames);
|
||||
(*drm).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames.as_ptr());
|
||||
(*drm).data[0] = Box::into_raw(desc) as *mut u8;
|
||||
// Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame,
|
||||
// which outlives this call — the graph reads the surface before submit returns).
|
||||
@@ -1164,23 +1147,11 @@ impl DmabufInner {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DmabufInner {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `graph`/`drm_frames`/`vaapi_device`/`drm_device` are the non-null objects
|
||||
// `DmabufInner::open` built and moved into `self` (open bails before constructing `Self` if any
|
||||
// alloc fails). `avfilter_graph_free` frees the graph (and the per-filter device refs it owns);
|
||||
// each `av_buffer_unref` drops one ref and nulls the pointer via `&mut`. `DmabufInner` owns all
|
||||
// four exclusively and `Drop` runs once → no double-free/use-after-free. The graph is freed
|
||||
// first (it holds refs on the devices), then frames, then the derived VAAPI device, then DRM.
|
||||
// (`self.enc` drops via ffmpeg-next afterward, holding its own refs.)
|
||||
unsafe {
|
||||
ffi::avfilter_graph_free(&mut self.graph);
|
||||
ffi::av_buffer_unref(&mut self.drm_frames);
|
||||
ffi::av_buffer_unref(&mut self.vaapi_device);
|
||||
ffi::av_buffer_unref(&mut self.drm_device);
|
||||
}
|
||||
}
|
||||
}
|
||||
// No `Drop` for `DmabufInner`: `AvFilterGraph`/`AvBuffer` each free themselves, in field-declaration
|
||||
// order — graph, frames, derived VAAPI device, DRM device, then `enc` — which is exactly the order
|
||||
// the hand-written `Drop` this replaced used. That `Drop` had to be kept in step with eight separate
|
||||
// failure branches in `open`, each repeating the same four-line unwind; there is now one release
|
||||
// path per handle and no branch can skip it.
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -1395,6 +1366,42 @@ impl Encoder for VaapiEncoder {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Construct/drop `DmabufInner` repeatedly on real VAAPI silicon.
|
||||
///
|
||||
/// This is the heaviest ownership change in the crate and the one with no other coverage.
|
||||
/// `open` builds four owned objects — a DRM device, a VAAPI device derived from it, a DRM-PRIME
|
||||
/// frames context, and a filter graph — and each of its eight failure branches used to unwind
|
||||
/// them by hand, the same four-line block copied eight times (once inside a macro) plus a ninth
|
||||
/// copy in `Drop`. All of that is now `AvBuffer`/`AvFilterGraph` field drops in declaration
|
||||
/// order, so *construct and drop* is the entire contract. Looping is what separates the
|
||||
/// outcomes: a double-free trips glibc, a missed one leaks a VAAPI device, a DRM device and a
|
||||
/// whole filter graph per iteration.
|
||||
///
|
||||
/// `vaapi_cpu_encode_smoke` does NOT cover this — it drives the swscale/CPU-upload path, which
|
||||
/// uses `VaapiHw` and never builds a graph.
|
||||
///
|
||||
/// `#[ignore]`d (needs a real VAAPI device):
|
||||
/// `cargo test -p pf-encode dmabuf_inner_alloc_drop_cycles -- --ignored --nocapture`
|
||||
/// ⚠️ Inside a distrobox on an immutable host, also set
|
||||
/// `LIBVA_DRIVERS_PATH=/run/host/usr/lib64/dri` — the container's own mesa is typically older
|
||||
/// than the host kernel's amdgpu and fails every encoder open with a bare ENOSYS.
|
||||
#[test]
|
||||
#[ignore = "needs a real VAAPI device (run on an AMD/Intel host, not the build box)"]
|
||||
fn dmabuf_inner_alloc_drop_cycles() {
|
||||
for i in 0..8 {
|
||||
let inner = DmabufInner::open(Codec::H264, PixelFormat::Bgrx, 640, 480, 30, 8_000_000)
|
||||
.unwrap_or_else(|e| panic!("DmabufInner::open failed on iteration {i}: {e:#}"));
|
||||
assert!(!inner.graph.as_ptr().is_null(), "graph went null");
|
||||
assert!(!inner.drm_frames.as_ptr().is_null(), "drm_frames went null");
|
||||
assert!(
|
||||
!inner.vaapi_device.as_ptr().is_null(),
|
||||
"vaapi_device went null"
|
||||
);
|
||||
assert!(!inner.drm_device.as_ptr().is_null(), "drm_device went null");
|
||||
}
|
||||
eprintln!("8 DmabufInner alloc/drop cycles completed without abort");
|
||||
}
|
||||
|
||||
/// The operator pin tries exactly one mode; a cached resolution ([`LP_MODE`]) tries only the
|
||||
/// mode that worked; anything else runs the full ladder, full-feature FIRST — AMD's first-try
|
||||
/// open must stay byte-for-byte unchanged.
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
//! visibility churn, and ~800 lines of construction `unsafe` get their own review surface.
|
||||
//! Steady-state encode logic stays in the parent.
|
||||
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw ash/Vulkan object construction and bitstream writing almost line
|
||||
// for line; narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only
|
||||
// restate the signature. Clearing this file means DELETING the markers that carry no caller
|
||||
// contract, not wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
// The parent's whole item namespace (Frame, the consts, sibling helpers) — the point of the
|
||||
// child-module shape. External imports are this file's own; `vk_util` is a crate-root sibling,
|
||||
// so the path is `crate::`, not the parent-relative `super::` the parent uses.
|
||||
@@ -20,17 +27,25 @@ pub(super) fn align_up(v: u64, a: u64) -> u64 {
|
||||
}
|
||||
|
||||
/// Probe for the RGB-direct encode source (design/vulkan-rgb-direct-encode.md): can this device
|
||||
/// take the captured RGB dmabuf directly, with the VCN EFC front-end doing the 709-narrow CSC,
|
||||
/// via `VK_VALVE_video_encode_rgb_conversion` (RADV since Mesa 26.0, gated on EFC hardware)?
|
||||
/// take the captured RGB dmabuf directly, with the VCN EFC front-end doing the CSC, via
|
||||
/// `VK_VALVE_video_encode_rgb_conversion` (RADV since Mesa 26.0, gated on EFC hardware)?
|
||||
/// `Ok((x_offset, y_offset))` carries the chroma-siting bits a session must be created with
|
||||
/// (the preferred available bit per axis); `Err` is the first missing requirement, logged as
|
||||
/// the open-time verdict.
|
||||
///
|
||||
/// `ten_bit` + `src_fmt` describe the session being planned: an HDR one needs the EFC to advertise
|
||||
/// the BT.2020 model (not 709) and the 10-bit packed-RGB `src_fmt` as an encode-source format.
|
||||
/// Both are hardware facts, so an EFC that cannot do HDR simply reports `Err` and the session
|
||||
/// takes the compute CSC — no fallback all the way out to VAAPI.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) unsafe fn probe_rgb_direct(
|
||||
instance: &ash::Instance,
|
||||
vq_inst: &ash::khr::video_queue::Instance,
|
||||
pd: vk::PhysicalDevice,
|
||||
codec_op: vk::VideoCodecOperationFlagsKHR,
|
||||
av1: bool,
|
||||
ten_bit: bool,
|
||||
src_fmt: vk::Format,
|
||||
) -> Result<(u32, u32), &'static str> {
|
||||
use crate::vk_av1_encode as av1b;
|
||||
use crate::vk_valve_rgb as vrgb;
|
||||
@@ -58,10 +73,11 @@ pub(super) unsafe fn probe_rgb_direct(
|
||||
if feat.video_encode_rgb_conversion == vk::FALSE {
|
||||
return Err("no-feature");
|
||||
}
|
||||
// 3. Capabilities under the rgb-chained profile — the conversion must cover the compute
|
||||
// CSC's colour math (rgb2yuv.comp: BT.709, narrow range; chroma siting is looser, see
|
||||
// below). The profile chain is the same one every rgb-direct consumer presents.
|
||||
let mut ps = RgbProfileStack::new(codec_op);
|
||||
// 3. Capabilities under the rgb-chained profile — the conversion must cover the colour math
|
||||
// the compute CSC would otherwise do (`rgb2yuv.comp`: BT.709 narrow; `rgb2yuv10.comp`:
|
||||
// BT.2020 narrow), at this session's depth. Chroma siting is looser, see below. The
|
||||
// profile chain is the same one every rgb-direct consumer presents.
|
||||
let mut ps = RgbProfileStack::new(codec_op, ten_bit);
|
||||
let profile = *ps.wire(av1);
|
||||
let mut rgb_caps = vrgb::VideoEncodeRgbConversionCapabilitiesVALVE {
|
||||
s_type: vrgb::stype(vrgb::ST_CAPABILITIES),
|
||||
@@ -103,10 +119,13 @@ pub(super) unsafe fn probe_rgb_direct(
|
||||
None
|
||||
}
|
||||
};
|
||||
if rgb_caps.rgb_models & vrgb::MODEL_YCBCR_709 == 0
|
||||
|| rgb_caps.rgb_ranges & vrgb::RANGE_NARROW == 0
|
||||
{
|
||||
return Err("no-709-narrow");
|
||||
let want_model = rgb_model_for(ten_bit);
|
||||
if rgb_caps.rgb_models & want_model == 0 || rgb_caps.rgb_ranges & vrgb::RANGE_NARROW == 0 {
|
||||
return Err(if ten_bit {
|
||||
"no-2020-narrow"
|
||||
} else {
|
||||
"no-709-narrow"
|
||||
});
|
||||
}
|
||||
let (Some(x_offset), Some(y_offset)) = (
|
||||
pick(rgb_caps.x_chroma_offsets),
|
||||
@@ -114,8 +133,10 @@ pub(super) unsafe fn probe_rgb_direct(
|
||||
) else {
|
||||
return Err("no-chroma-siting");
|
||||
};
|
||||
// 4. The encode-src format set under this profile must offer BGRA with DRM-modifier tiling —
|
||||
// the capture hands LINEAR BGRx dmabufs (fourcc XR24), which import as B8G8R8A8_UNORM.
|
||||
// 4. The encode-src format set under this profile must offer the CAPTURED format with
|
||||
// DRM-modifier tiling — LINEAR BGRx dmabufs (fourcc XR24) import as B8G8R8A8_UNORM, and a
|
||||
// 10-bit PQ capture (XR30/XB30) as one of the packed 2:10:10:10 formats. A device whose
|
||||
// EFC handles 8-bit RGB but not 10-bit lands here rather than at the session create.
|
||||
let profile_arr = [profile];
|
||||
let plist = vk::VideoProfileListInfoKHR::default().profiles(&profile_arr);
|
||||
let mut fmt_info = vk::PhysicalDeviceVideoFormatInfoKHR::default()
|
||||
@@ -132,15 +153,31 @@ pub(super) unsafe fn probe_rgb_direct(
|
||||
if r != vk::Result::SUCCESS && r != vk::Result::INCOMPLETE {
|
||||
return Err("no-rgb-format");
|
||||
}
|
||||
if !props[..count as usize].iter().any(|p| {
|
||||
p.format == vk::Format::B8G8R8A8_UNORM
|
||||
&& p.image_tiling == vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT
|
||||
}) {
|
||||
return Err("no-bgra-modifier-tiling");
|
||||
if !props[..count as usize]
|
||||
.iter()
|
||||
.any(|p| p.format == src_fmt && p.image_tiling == vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT)
|
||||
{
|
||||
return Err(if ten_bit {
|
||||
"no-rgb10-modifier-tiling"
|
||||
} else {
|
||||
"no-bgra-modifier-tiling"
|
||||
});
|
||||
}
|
||||
Ok((x_offset, y_offset))
|
||||
}
|
||||
|
||||
/// The EFC colour model a session of this depth needs: BT.709 for SDR, BT.2020 for HDR — the same
|
||||
/// matrices the two compute-CSC shaders implement, so the encode source is interchangeable and the
|
||||
/// SPS/sequence-header colour signalling is correct either way.
|
||||
pub(super) fn rgb_model_for(ten_bit: bool) -> u32 {
|
||||
use crate::vk_valve_rgb as vrgb;
|
||||
if ten_bit {
|
||||
vrgb::MODEL_YCBCR_2020
|
||||
} else {
|
||||
vrgb::MODEL_YCBCR_709
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) unsafe fn make_video_image(
|
||||
device: &ash::Device,
|
||||
mp: &vk::PhysicalDeviceMemoryProperties,
|
||||
@@ -225,6 +262,7 @@ pub(super) unsafe fn make_frame(
|
||||
with_ts: bool,
|
||||
csc: bool,
|
||||
pad_fmt: Option<vk::Format>,
|
||||
hdr: bool,
|
||||
f: &mut Frame,
|
||||
) -> Result<()> {
|
||||
// "no cursor uploaded yet" sentinel — a real serial may be 0 (see `prep_cursor`).
|
||||
@@ -263,6 +301,7 @@ pub(super) unsafe fn make_frame(
|
||||
csc_dsl,
|
||||
csc_pool,
|
||||
sampler,
|
||||
hdr,
|
||||
f,
|
||||
)?;
|
||||
}
|
||||
@@ -291,13 +330,15 @@ unsafe fn make_frame_csc(
|
||||
csc_dsl: vk::DescriptorSetLayout,
|
||||
csc_pool: vk::DescriptorPool,
|
||||
sampler: vk::Sampler,
|
||||
hdr: bool,
|
||||
f: &mut Frame,
|
||||
) -> Result<()> {
|
||||
// NV12 encode-src (filled by the CSC copy) — concurrent compute+encode.
|
||||
// 4:2:0 encode-src (filled by the CSC copy) — concurrent compute+encode.
|
||||
let pic = yuv_format(hdr);
|
||||
(f.nv12_src, f.nv12_mem) = make_video_image(
|
||||
device,
|
||||
mem_props,
|
||||
NV12,
|
||||
pic,
|
||||
w,
|
||||
h,
|
||||
1,
|
||||
@@ -305,12 +346,22 @@ unsafe fn make_frame_csc(
|
||||
profile_list,
|
||||
fams,
|
||||
)?;
|
||||
f.nv12_view = make_view(device, f.nv12_src, NV12, 0)?;
|
||||
// CSC scratch (Y R8 full-res, UV RG8 half-res).
|
||||
f.nv12_view = make_view(device, f.nv12_src, pic, 0)?;
|
||||
// CSC scratch: Y full-res + UV half-res, in a single-plane format the shader can declare as a
|
||||
// storage image AND that is SIZE-COMPATIBLE with the picture's planes (`vkCmdCopyImage`
|
||||
// between differing formats requires equal texel-block size). 8-bit: R8/RG8 vs the NV12
|
||||
// planes' 1/2 bytes. 10-bit: R16/RG16 vs the 3PACK16 planes' 2/4 bytes — the 10-bit ycbcr
|
||||
// plane formats themselves are not storage-image formats, which is why the scratch is 16-bit
|
||||
// and `rgb2yuv10.comp` writes the value into the high bits by hand.
|
||||
let (y_fmt, uv_fmt) = if hdr {
|
||||
(vk::Format::R16_UNORM, vk::Format::R16G16_UNORM)
|
||||
} else {
|
||||
(vk::Format::R8_UNORM, vk::Format::R8G8_UNORM)
|
||||
};
|
||||
(f.y_img, f.y_mem, f.y_view) = make_plain_image(
|
||||
device,
|
||||
mem_props,
|
||||
vk::Format::R8_UNORM,
|
||||
y_fmt,
|
||||
w,
|
||||
h,
|
||||
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC,
|
||||
@@ -318,7 +369,7 @@ unsafe fn make_frame_csc(
|
||||
(f.uv_img, f.uv_mem, f.uv_view) = make_plain_image(
|
||||
device,
|
||||
mem_props,
|
||||
vk::Format::R8G8_UNORM,
|
||||
uv_fmt,
|
||||
w / 2,
|
||||
h / 2,
|
||||
vk::ImageUsageFlags::STORAGE | vk::ImageUsageFlags::TRANSFER_SRC,
|
||||
@@ -479,12 +530,20 @@ pub(super) unsafe fn build_parameters_h265(
|
||||
rw: u32,
|
||||
rh: u32,
|
||||
quality_level: u32,
|
||||
// 10-bit HDR session: Main10 + BT.2020/PQ colour signalling. Must agree with the video
|
||||
// profile the session was CREATED with (`open_inner`'s `ten_bit`) — a Main SPS on a Main10
|
||||
// session is a bitstream that says one thing and carries another.
|
||||
ten_bit: bool,
|
||||
) -> Result<(vk::VideoSessionParametersKHR, Vec<u8>)> {
|
||||
use ash::vk::native as hh;
|
||||
let mut ptl: hh::StdVideoH265ProfileTierLevel = std::mem::zeroed();
|
||||
ptl.flags.set_general_progressive_source_flag(1);
|
||||
ptl.flags.set_general_frame_only_constraint_flag(1);
|
||||
ptl.general_profile_idc = hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN;
|
||||
ptl.general_profile_idc = if ten_bit {
|
||||
hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN_10
|
||||
} else {
|
||||
hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN
|
||||
};
|
||||
ptl.general_level_idc = hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_0;
|
||||
|
||||
let mut dpbm: hh::StdVideoH265DecPicBufMgr = std::mem::zeroed();
|
||||
@@ -505,6 +564,11 @@ pub(super) unsafe fn build_parameters_h265(
|
||||
sps.pic_width_in_luma_samples = w;
|
||||
sps.pic_height_in_luma_samples = h;
|
||||
sps.log2_max_pic_order_cnt_lsb_minus4 = 4;
|
||||
// Main10's `bit_depth_*_minus8 = 2`. Zeroed (= 8-bit) for Main, as before.
|
||||
if ten_bit {
|
||||
sps.bit_depth_luma_minus8 = 2;
|
||||
sps.bit_depth_chroma_minus8 = 2;
|
||||
}
|
||||
sps.log2_diff_max_min_luma_coding_block_size = 3;
|
||||
sps.log2_diff_max_min_luma_transform_block_size = 3;
|
||||
sps.max_transform_hierarchy_depth_inter = 4;
|
||||
@@ -517,6 +581,29 @@ pub(super) unsafe fn build_parameters_h265(
|
||||
sps.conf_win_bottom_offset = (h - rh) / 2; // 4:2:0 SubHeightC = 2
|
||||
}
|
||||
|
||||
// Colour signalling, exactly what this session's CSC produced: `rgb2yuv.comp` is BT.709
|
||||
// limited 8-bit, `rgb2yuv10.comp` is BT.2020 NCL limited 10-bit with a PQ transfer (the
|
||||
// samples arrive PQ-encoded from the compositor and the matrix does not touch the transfer).
|
||||
// Without the VUI the stream is "unspecified" and each decoder applies its own default: the
|
||||
// punktfunk clients fall back to BT.709 (`pf_client_core::video_color::csc_rows`), but vendor
|
||||
// TV decoders guess from RESOLUTION — an LG webOS panel reads a 4K SDR stream as BT.2020 and
|
||||
// renders it visibly washed out.
|
||||
//
|
||||
// `vui` must outlive `create_video_session_parameters_khr` below — it does, `sps_arr` only
|
||||
// copies the pointer and both live to the end of this function.
|
||||
let mut vui: hh::StdVideoH265SequenceParameterSetVui = std::mem::zeroed();
|
||||
vui.flags.set_video_signal_type_present_flag(1);
|
||||
vui.flags.set_video_full_range_flag(0); // limited/studio swing
|
||||
vui.flags.set_colour_description_present_flag(1);
|
||||
vui.video_format = 5; // unspecified — the CICP triplet below is what matters
|
||||
// CICP code points: 1 = BT.709, 9 = BT.2020 primaries / BT.2020 NCL matrix, 16 = SMPTE 2084.
|
||||
let (prim, trc, mat) = if ten_bit { (9, 16, 9) } else { (1, 1, 1) };
|
||||
vui.colour_primaries = prim;
|
||||
vui.transfer_characteristics = trc;
|
||||
vui.matrix_coeffs = mat;
|
||||
sps.flags.set_vui_parameters_present_flag(1);
|
||||
sps.pSequenceParameterSetVui = &vui;
|
||||
|
||||
let mut pps: hh::StdVideoH265PictureParameterSet = std::mem::zeroed();
|
||||
pps.flags.set_cu_qp_delta_enabled_flag(1);
|
||||
pps.flags.set_pps_loop_filter_across_slices_enabled_flag(1);
|
||||
@@ -658,9 +745,10 @@ fn leb128(mut v: u64) -> Vec<u8> {
|
||||
|
||||
/// Bit-pack a `sequence_header_obu` (AV1 spec §5.5) into a size-delimited OBU. The field values here
|
||||
/// MUST mirror the `StdVideoAV1SequenceHeader` handed to the driver in `build_parameters_av1` so the
|
||||
/// driver-emitted frame OBUs parse against this header. Single operating point, 8-bit 4:2:0,
|
||||
/// order-hint on, CDEF+restoration+filter-intra allowed, everything exotic (compound/warp/superres)
|
||||
/// disabled — the profile our single-reference P-frame encoder actually uses.
|
||||
/// driver-emitted frame OBUs parse against this header. Single operating point, 4:2:0 at 8 or 10
|
||||
/// bits, order-hint on, CDEF+restoration+filter-intra allowed, everything exotic
|
||||
/// (compound/warp/superres) disabled — the profile our single-reference P-frame encoder uses.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn av1_sequence_header_obu(
|
||||
sb128: bool,
|
||||
fwb: u32,
|
||||
@@ -669,6 +757,7 @@ fn av1_sequence_header_obu(
|
||||
max_h_m1: u32,
|
||||
order_hint_bits_minus_1: u32,
|
||||
seq_level_idx: u32,
|
||||
ten_bit: bool,
|
||||
) -> Vec<u8> {
|
||||
let mut w = Av1BitWriter::new();
|
||||
w.put(0, 3); // seq_profile = MAIN
|
||||
@@ -703,10 +792,28 @@ fn av1_sequence_header_obu(
|
||||
w.bit(0); // enable_superres
|
||||
w.bit(0); // enable_cdef
|
||||
w.bit(0); // enable_restoration
|
||||
// color_config(): 8-bit 4:2:0, unspecified primaries/transfer/matrix, limited range
|
||||
w.bit(0); // high_bitdepth
|
||||
// color_config() (AV1 spec §5.5.2): 4:2:0 at the session's depth, limited range,
|
||||
// carrying the CSC this backend actually performed — BT.709 for `rgb2yuv.comp`,
|
||||
// BT.2020 + PQ for `rgb2yuv10.comp` (or the EFC's equivalent). AV1 has no VUI, so
|
||||
// the CICP triplet lives here; omitting it (color_description_present_flag = 0)
|
||||
// left the stream "unspecified" and vendor TV decoders guess colorimetry from
|
||||
// resolution. Neither triplet hits the spec's sRGB special case (which would force
|
||||
// color_range = 1 and drop the explicit range bit), so the field order below is the
|
||||
// same as the unspecified form plus the three CICP bytes.
|
||||
//
|
||||
// `high_bitdepth` alone encodes 10-bit here: `twelve_bit` follows it ONLY for
|
||||
// seq_profile 2, and ours is MAIN (0).
|
||||
w.bit(ten_bit as u32); // high_bitdepth -> BitDepth = 10
|
||||
w.bit(0); // mono_chrome
|
||||
w.bit(0); // color_description_present_flag
|
||||
w.bit(1); // color_description_present_flag
|
||||
let (prim, trc, mat) = if ten_bit {
|
||||
(9u32, 16u32, 9u32)
|
||||
} else {
|
||||
(1, 1, 1)
|
||||
};
|
||||
w.put(prim, 8); // color_primaries (1 = BT.709, 9 = BT.2020)
|
||||
w.put(trc, 8); // transfer_characteristics (1 = BT.709, 16 = SMPTE 2084)
|
||||
w.put(mat, 8); // matrix_coefficients (1 = BT.709, 9 = BT.2020 NCL)
|
||||
w.bit(0); // color_range (studio/limited)
|
||||
w.put(0, 2); // chroma_sample_position = CSP_UNKNOWN (subsampling_x==subsampling_y==1 for profile 0)
|
||||
w.bit(0); // separate_uv_delta_q
|
||||
@@ -737,6 +844,9 @@ pub(super) unsafe fn build_parameters_av1(
|
||||
max_level: ash::vk::native::StdVideoAV1Level,
|
||||
sb128: bool,
|
||||
quality_level: u32,
|
||||
// 10-bit HDR session — must agree with the video profile the session was CREATED with
|
||||
// (`open_inner`'s `ten_bit`) and with the OBU packed below.
|
||||
ten_bit: bool,
|
||||
) -> Result<(vk::VideoSessionParametersKHR, Vec<u8>, Vec<u8>)> {
|
||||
use crate::vk_av1_encode as av1;
|
||||
use ash::vk::native as hh;
|
||||
@@ -747,18 +857,33 @@ pub(super) unsafe fn build_parameters_av1(
|
||||
let seq_level_idx = max_level; // StdVideoAV1Level's numeric value IS the AV1 seq_level_idx
|
||||
|
||||
// ---- Std sequence header (must match the OBU packed below) ----
|
||||
// Limited range at the session's depth, mirroring the `color_config()` bits
|
||||
// `av1_sequence_header_obu` packs — the two MUST stay identical or the driver's frame OBUs
|
||||
// parse against a header we didn't write. `color_range` stays 0 (studio swing).
|
||||
let mut cc_flags: hh::StdVideoAV1ColorConfigFlags = std::mem::zeroed();
|
||||
let _ = &mut cc_flags; // all zero: mono_chrome/color_range/description/separate_uv_delta_q = 0
|
||||
cc_flags.set_color_description_present_flag(1);
|
||||
let mut cc: hh::StdVideoAV1ColorConfig = std::mem::zeroed();
|
||||
cc.flags = cc_flags;
|
||||
cc.BitDepth = 8;
|
||||
// The Std struct carries the DEPTH; the driver derives the OBU's `high_bitdepth` from it.
|
||||
cc.BitDepth = if ten_bit { 10 } else { 8 };
|
||||
cc.subsampling_x = 1;
|
||||
cc.subsampling_y = 1;
|
||||
cc.color_primaries = hh::StdVideoAV1ColorPrimaries_STD_VIDEO_AV1_COLOR_PRIMARIES_BT_UNSPECIFIED;
|
||||
cc.transfer_characteristics =
|
||||
hh::StdVideoAV1TransferCharacteristics_STD_VIDEO_AV1_TRANSFER_CHARACTERISTICS_UNSPECIFIED;
|
||||
cc.matrix_coefficients =
|
||||
hh::StdVideoAV1MatrixCoefficients_STD_VIDEO_AV1_MATRIX_COEFFICIENTS_UNSPECIFIED;
|
||||
let (prim, trc, mat) = if ten_bit {
|
||||
(
|
||||
hh::StdVideoAV1ColorPrimaries_STD_VIDEO_AV1_COLOR_PRIMARIES_BT_2020,
|
||||
hh::StdVideoAV1TransferCharacteristics_STD_VIDEO_AV1_TRANSFER_CHARACTERISTICS_SMPTE_2084,
|
||||
hh::StdVideoAV1MatrixCoefficients_STD_VIDEO_AV1_MATRIX_COEFFICIENTS_BT_2020_NCL,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
hh::StdVideoAV1ColorPrimaries_STD_VIDEO_AV1_COLOR_PRIMARIES_BT_709,
|
||||
hh::StdVideoAV1TransferCharacteristics_STD_VIDEO_AV1_TRANSFER_CHARACTERISTICS_BT_709,
|
||||
hh::StdVideoAV1MatrixCoefficients_STD_VIDEO_AV1_MATRIX_COEFFICIENTS_BT_709,
|
||||
)
|
||||
};
|
||||
cc.color_primaries = prim;
|
||||
cc.transfer_characteristics = trc;
|
||||
cc.matrix_coefficients = mat;
|
||||
cc.chroma_sample_position =
|
||||
hh::StdVideoAV1ChromaSamplePosition_STD_VIDEO_AV1_CHROMA_SAMPLE_POSITION_UNKNOWN;
|
||||
|
||||
@@ -829,8 +954,151 @@ pub(super) unsafe fn build_parameters_av1(
|
||||
h - 1,
|
||||
order_hint_bits_minus_1,
|
||||
seq_level_idx,
|
||||
ten_bit,
|
||||
);
|
||||
let mut keyframe_prefix = td.clone();
|
||||
keyframe_prefix.extend_from_slice(&seq_obu);
|
||||
Ok((params, keyframe_prefix, td))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Walks a bit-packed AV1 sequence header field-by-field (spec §5.5.1 order, for the fixed
|
||||
/// configuration `av1_sequence_header_obu` emits) and returns the `color_config()` values.
|
||||
/// Deliberately an INDEPENDENT walk rather than a mirror of the writer: it is the only thing
|
||||
/// that catches a field width or ordering change upstream of `color_config`, which would leave
|
||||
/// the colour bits parsing at the wrong offset — the exact desync the module doc warns about.
|
||||
fn read_color_config(
|
||||
obu: &[u8],
|
||||
fwb: u32,
|
||||
fhb: u32,
|
||||
seq_level_idx: u32,
|
||||
) -> (u8, u8, u8, u8, u8, u8) {
|
||||
// obu_header (1 byte) + leb128 size — the payload starts after both.
|
||||
assert_eq!(
|
||||
obu[0], 0x0a,
|
||||
"obu_header: OBU_SEQUENCE_HEADER + has_size_field"
|
||||
);
|
||||
let mut i = 1;
|
||||
while obu[i] & 0x80 != 0 {
|
||||
i += 1;
|
||||
}
|
||||
let payload = &obu[i + 1..];
|
||||
|
||||
let mut pos = 0usize;
|
||||
let mut take = |bits: u32| -> u32 {
|
||||
let mut v = 0u32;
|
||||
for _ in 0..bits {
|
||||
let byte = payload[pos / 8];
|
||||
v = (v << 1) | u32::from((byte >> (7 - (pos % 8))) & 1);
|
||||
pos += 1;
|
||||
}
|
||||
v
|
||||
};
|
||||
|
||||
assert_eq!(take(3), 0, "seq_profile = MAIN");
|
||||
take(1); // still_picture
|
||||
assert_eq!(take(1), 0, "reduced_still_picture_header");
|
||||
assert_eq!(take(1), 0, "timing_info_present_flag");
|
||||
assert_eq!(take(1), 0, "initial_display_delay_present_flag");
|
||||
assert_eq!(take(5), 0, "operating_points_cnt_minus_1");
|
||||
take(12); // operating_point_idc[0]
|
||||
assert_eq!(take(5), seq_level_idx, "seq_level_idx[0]");
|
||||
if seq_level_idx > 7 {
|
||||
take(1); // seq_tier[0]
|
||||
}
|
||||
assert_eq!(take(4), fwb, "frame_width_bits_minus_1");
|
||||
assert_eq!(take(4), fhb, "frame_height_bits_minus_1");
|
||||
take(fwb + 1); // max_frame_width_minus_1
|
||||
take(fhb + 1); // max_frame_height_minus_1
|
||||
take(1); // frame_id_numbers_present_flag
|
||||
take(1); // use_128x128_superblock
|
||||
take(1); // enable_filter_intra
|
||||
take(1); // enable_intra_edge_filter
|
||||
take(1); // enable_interintra_compound
|
||||
take(1); // enable_masked_compound
|
||||
take(1); // enable_warped_motion
|
||||
take(1); // enable_dual_filter
|
||||
let order_hint = take(1); // enable_order_hint
|
||||
assert_eq!(
|
||||
order_hint, 1,
|
||||
"enable_order_hint (our single-ref P-frame config)"
|
||||
);
|
||||
take(1); // enable_jnt_comp
|
||||
take(1); // enable_ref_frame_mvs
|
||||
assert_eq!(take(1), 1, "seq_choose_screen_content_tools = SELECT");
|
||||
// seq_force_screen_content_tools = SELECT (> 0), so seq_choose_integer_mv is present.
|
||||
assert_eq!(take(1), 1, "seq_choose_integer_mv = SELECT");
|
||||
take(3); // order_hint_bits_minus_1
|
||||
take(1); // enable_superres
|
||||
take(1); // enable_cdef
|
||||
take(1); // enable_restoration
|
||||
|
||||
// color_config(). `high_bitdepth` is returned rather than asserted — the 10-bit case
|
||||
// below is the whole point of reading it.
|
||||
let high_bitdepth = take(1) as u8;
|
||||
assert_eq!(take(1), 0, "mono_chrome");
|
||||
let described = take(1) as u8;
|
||||
let (cp, tc, mc) = if described == 1 {
|
||||
(take(8) as u8, take(8) as u8, take(8) as u8)
|
||||
} else {
|
||||
(2, 2, 2) // CICP "unspecified"
|
||||
};
|
||||
let range = take(1) as u8;
|
||||
take(2); // chroma_sample_position
|
||||
assert_eq!(take(1), 0, "separate_uv_delta_q");
|
||||
assert_eq!(take(1), 0, "film_grain_params_present");
|
||||
assert_eq!(take(1), 1, "trailing_one_bit");
|
||||
(high_bitdepth, described, cp, tc, mc, range)
|
||||
}
|
||||
|
||||
/// The sequence header must SIGNAL BT.709 limited — the CSC `rgb2yuv.comp` actually performs.
|
||||
/// An unsignalled ("unspecified") AV1 stream makes vendor TV decoders guess colorimetry from
|
||||
/// resolution: an LG webOS panel reads 4K SDR as BT.2020 and renders it washed out.
|
||||
///
|
||||
/// The values here must equal the `StdVideoAV1ColorConfig` in `build_parameters_av1` — the
|
||||
/// driver packs its frame OBUs against that struct while clients parse this header, so a
|
||||
/// mismatch desyncs every inter frame.
|
||||
#[test]
|
||||
fn av1_sequence_header_signals_bt709_limited() {
|
||||
// 1920x1080: av_log2 gives 10/10 frame-size bits; level 4.0 (seq_level_idx 8) exercises
|
||||
// the seq_tier branch, and sb128 both ways since it sits above color_config.
|
||||
for (sb128, level) in [(false, 8u32), (true, 5u32)] {
|
||||
let obu = av1_sequence_header_obu(sb128, 10, 10, 1919, 1079, 7, level, false);
|
||||
let (depth10, described, cp, tc, mc, range) = read_color_config(&obu, 10, 10, level);
|
||||
assert_eq!(depth10, 0, "high_bitdepth (8-bit session)");
|
||||
assert_eq!(
|
||||
described, 1,
|
||||
"color_description_present_flag (sb128={sb128})"
|
||||
);
|
||||
assert_eq!(
|
||||
(cp, tc, mc),
|
||||
(1, 1, 1),
|
||||
"CICP BT.709 primaries/transfer/matrix"
|
||||
);
|
||||
assert_eq!(range, 0, "color_range = studio/limited swing");
|
||||
}
|
||||
}
|
||||
|
||||
/// …and a 10-bit session must signal BT.2020 + PQ with `high_bitdepth` set. Same reason the
|
||||
/// 8-bit twin exists, plus one that is specific to AV1: `high_bitdepth` sits BEFORE the CICP
|
||||
/// bytes in `color_config()`, so getting it wrong does not just mislabel the depth — every
|
||||
/// field after it parses one bit out of phase.
|
||||
#[test]
|
||||
fn av1_sequence_header_signals_bt2020_pq_at_10_bit() {
|
||||
for (sb128, level) in [(false, 8u32), (true, 5u32)] {
|
||||
let obu = av1_sequence_header_obu(sb128, 10, 10, 1919, 1079, 7, level, true);
|
||||
let (depth10, described, cp, tc, mc, range) = read_color_config(&obu, 10, 10, level);
|
||||
assert_eq!(depth10, 1, "high_bitdepth (sb128={sb128})");
|
||||
assert_eq!(described, 1, "color_description_present_flag");
|
||||
assert_eq!(
|
||||
(cp, tc, mc),
|
||||
(9, 16, 9),
|
||||
"CICP BT.2020 primaries / SMPTE 2084 transfer / BT.2020-NCL matrix"
|
||||
);
|
||||
assert_eq!(range, 0, "color_range = studio/limited swing");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
//! Small ash/Vulkan leaf helpers shared by the Linux Vulkan encode backends
|
||||
//! (`vulkan_video.rs`, `pyrowave.rs`) — extracted verbatim from `vulkan_video.rs`
|
||||
//! when the PyroWave backend arrived so the two don't fork copies.
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw ash/Vulkan object construction almost line for line; narrowing it
|
||||
// would add one `unsafe {}` plus one SAFETY comment per call that could only restate the signature.
|
||||
// Clearing this file means DELETING the markers that carry no caller contract, not wrapping the
|
||||
// calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
// Every unsafe block carries a `// SAFETY:` proof (parent module enforces it).
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -50,9 +57,23 @@ pub(crate) fn fourcc_to_vk(fourcc: u32) -> Option<vk::Format> {
|
||||
const XB24: u32 = 0x3432_4258; // XBGR8888
|
||||
const AB24: u32 = 0x3432_4241; // ABGR8888
|
||||
const NV12: u32 = 0x3231_564e; // DRM_FORMAT_NV12
|
||||
// The 10-bit HDR capture formats. DRM packs these as a little-endian 32-bit word — XR30 is
|
||||
// x:R:G:B with B in bits 0-9 — which is exactly Vulkan's `A2R10G10B10_UNORM_PACK32` bit
|
||||
// layout, so the mapping is an identity on the word and not a byte swizzle like the 8-bit
|
||||
// pair above.
|
||||
//
|
||||
// ⚠ Only `A2B10G10R10_UNORM_PACK32` is a Vulkan-mandatory SAMPLED format; `A2R10G10B10` is
|
||||
// optional (widely supported on RADV/ANV, and we only ever `texelFetch` it — no filtering).
|
||||
// Both are offered to the producer, so which one a session lands on is the producer's pick;
|
||||
// if a device ever rejects the XR30 import, the fix is to drop that format from the capture
|
||||
// offer rather than to convert here.
|
||||
const XR30: u32 = 0x3033_5258; // DRM_FORMAT_XRGB2101010
|
||||
const XB30: u32 = 0x3033_4258; // DRM_FORMAT_XBGR2101010
|
||||
match fourcc {
|
||||
XR24 | AR24 => Some(vk::Format::B8G8R8A8_UNORM),
|
||||
XB24 | AB24 => Some(vk::Format::R8G8B8A8_UNORM),
|
||||
XR30 => Some(vk::Format::A2R10G10B10_UNORM_PACK32),
|
||||
XB30 => Some(vk::Format::A2B10G10R10_UNORM_PACK32),
|
||||
NV12 => Some(vk::Format::G8_B8R8_2PLANE_420_UNORM),
|
||||
_ => None,
|
||||
}
|
||||
@@ -62,6 +83,10 @@ pub(crate) fn pixel_to_vk(fmt: PixelFormat) -> Option<vk::Format> {
|
||||
match fmt {
|
||||
PixelFormat::Bgrx | PixelFormat::Bgra => Some(vk::Format::B8G8R8A8_UNORM),
|
||||
PixelFormat::Rgbx | PixelFormat::Rgba => Some(vk::Format::R8G8B8A8_UNORM),
|
||||
// The packed 10-bit PQ/BT.2020 capture formats (an HDR gamescope output). Sampling one
|
||||
// yields the PQ code values normalized to [0,1] — which is what `rgb2yuv10.comp` wants.
|
||||
PixelFormat::X2Rgb10 => Some(vk::Format::A2R10G10B10_UNORM_PACK32),
|
||||
PixelFormat::X2Bgr10 => Some(vk::Format::A2B10G10R10_UNORM_PACK32),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,18 @@
|
||||
//! slot (no IDR): HEVC via an explicit short-term RPS, AV1 via `ref_frame_idx` + a
|
||||
//! `primary_ref_frame = NONE` recovery anchor that also breaks the CDF chain.
|
||||
//!
|
||||
//! Capture delivers packed RGB (dmabuf/CPU); this backend imports it, runs an on-GPU RGB→NV12
|
||||
//! BT.709 compute CSC, then encodes. Proven end-to-end in `punktfunk-planning/design/vkenc-probe-harness`.
|
||||
//! Capture delivers packed RGB (dmabuf/CPU); this backend imports it, runs an on-GPU RGB→4:2:0
|
||||
//! compute CSC, then encodes — 8-bit BT.709 for an SDR session (`rgb2yuv.comp`), 10-bit BT.2020
|
||||
//! for an HDR one (`rgb2yuv10.comp` + an HEVC Main10 session; a 10-bit AV1 session is routed to
|
||||
//! VAAPI instead). Proven end-to-end in `punktfunk-planning/design/vkenc-probe-harness`.
|
||||
//! Opt-in via `PUNKTFUNK_VULKAN_ENCODE`; gated to HEVC/AV1 + a device that advertises the encode op.
|
||||
//! The AV1 encode structs our pinned `ash 0.38` predates are vendored in `vk_av1_encode.rs`.
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw ash/Vulkan Video calls against an app-owned DPB almost line for
|
||||
// line; narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only
|
||||
// restate the signature. Clearing this file means DELETING the markers that carry no caller
|
||||
// contract, not wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
|
||||
use super::vk_util::{
|
||||
@@ -23,12 +31,47 @@ use std::ffi::c_void;
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
const NV12: vk::Format = vk::Format::G8_B8R8_2PLANE_420_UNORM;
|
||||
/// The 10-bit 4:2:0 picture/DPB format an HDR (HEVC Main10) session encodes from. `3PACK16`
|
||||
/// stores each 10-bit sample in the HIGH bits of a 16-bit word — see `rgb2yuv10.comp`, whose
|
||||
/// scratch planes are the size-compatible `R16`/`RG16` this gets `vkCmdCopyImage`d from.
|
||||
const P010: vk::Format = vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16;
|
||||
|
||||
/// The session's 4:2:0 picture + DPB format for its bit depth. One function so the session
|
||||
/// create-info, the DPB image, its views and the per-frame encode source cannot drift apart —
|
||||
/// they must all name the SAME format or session creation fails.
|
||||
const fn component_depth(ten_bit: bool) -> vk::VideoComponentBitDepthFlagsKHR {
|
||||
if ten_bit {
|
||||
vk::VideoComponentBitDepthFlagsKHR::TYPE_10
|
||||
} else {
|
||||
vk::VideoComponentBitDepthFlagsKHR::TYPE_8
|
||||
}
|
||||
}
|
||||
|
||||
const fn h265_profile_idc(ten_bit: bool) -> vk::native::StdVideoH265ProfileIdc {
|
||||
if ten_bit {
|
||||
vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN_10
|
||||
} else {
|
||||
vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN
|
||||
}
|
||||
}
|
||||
|
||||
const fn yuv_format(hdr: bool) -> vk::Format {
|
||||
if hdr {
|
||||
P010
|
||||
} else {
|
||||
NV12
|
||||
}
|
||||
}
|
||||
/// Max resident dmabuf imports (comfortably above any PipeWire pool depth; imports alias existing
|
||||
/// buffers so this holds handles, not new allocations).
|
||||
const IMPORT_CACHE_CAP: usize = 16;
|
||||
// Prebuilt SPIR-V for the RGB→NV12 BT.709 compute CSC. Source is `rgb2yuv.comp` beside this file;
|
||||
// regenerate with `glslangValidator -V rgb2yuv.comp -o rgb2yuv.spv` after editing the shader.
|
||||
const CSC_SPV: &[u8] = include_bytes!("rgb2yuv.spv");
|
||||
/// The 10-bit HDR twin (`rgb2yuv10.comp`): packed 2:10:10:10 PQ/BT.2020 RGB → 10-bit 4:2:0,
|
||||
/// BT.2020 NCL limited. Separate module rather than a spec constant because a shader's storage
|
||||
/// image FORMAT (`r8`/`rg8` vs `r16`/`rg16`) is part of its layout, not specializable.
|
||||
const CSC10_SPV: &[u8] = include_bytes!("rgb2yuv10.spv");
|
||||
/// Fixed cursor-overlay texture size (px). Larger than any real pointer; the actual `w×h` uploads
|
||||
/// into the top-left and the shader's push constant bounds sampling, so one allocation fits every
|
||||
/// cursor and no per-size recreation is needed. See the CSC shader's `cursorTex`/push constant.
|
||||
@@ -147,7 +190,7 @@ struct RgbProfileStack {
|
||||
}
|
||||
|
||||
impl RgbProfileStack {
|
||||
fn new(codec_op: vk::VideoCodecOperationFlagsKHR) -> Self {
|
||||
fn new(codec_op: vk::VideoCodecOperationFlagsKHR, ten_bit: bool) -> Self {
|
||||
use super::vk_av1_encode as av1b;
|
||||
use super::vk_valve_rgb as vrgb;
|
||||
Self {
|
||||
@@ -160,9 +203,8 @@ impl RgbProfileStack {
|
||||
.video_usage_hints(vk::VideoEncodeUsageFlagsKHR::STREAMING)
|
||||
.video_content_hints(vk::VideoEncodeContentFlagsKHR::RENDERED)
|
||||
.tuning_mode(vk::VideoEncodeTuningModeKHR::ULTRA_LOW_LATENCY),
|
||||
h265: vk::VideoEncodeH265ProfileInfoKHR::default().std_profile_idc(
|
||||
vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN,
|
||||
),
|
||||
h265: vk::VideoEncodeH265ProfileInfoKHR::default()
|
||||
.std_profile_idc(h265_profile_idc(ten_bit)),
|
||||
av1: av1b::VideoEncodeAV1ProfileInfoKHR {
|
||||
s_type: av1b::stype(av1b::ST_PROFILE_INFO),
|
||||
p_next: std::ptr::null(),
|
||||
@@ -171,8 +213,8 @@ impl RgbProfileStack {
|
||||
profile: vk::VideoProfileInfoKHR::default()
|
||||
.video_codec_operation(codec_op)
|
||||
.chroma_subsampling(vk::VideoChromaSubsamplingFlagsKHR::TYPE_420)
|
||||
.luma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8)
|
||||
.chroma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8),
|
||||
.luma_bit_depth(component_depth(ten_bit))
|
||||
.chroma_bit_depth(component_depth(ten_bit)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,26 +242,26 @@ struct NativeProfileStack {
|
||||
}
|
||||
|
||||
impl NativeProfileStack {
|
||||
fn new(codec_op: vk::VideoCodecOperationFlagsKHR) -> Self {
|
||||
fn new(codec_op: vk::VideoCodecOperationFlagsKHR, ten_bit: bool) -> Self {
|
||||
use super::vk_av1_encode as av1b;
|
||||
Self {
|
||||
usage: vk::VideoEncodeUsageInfoKHR::default()
|
||||
.video_usage_hints(vk::VideoEncodeUsageFlagsKHR::STREAMING)
|
||||
.video_content_hints(vk::VideoEncodeContentFlagsKHR::RENDERED)
|
||||
.tuning_mode(vk::VideoEncodeTuningModeKHR::ULTRA_LOW_LATENCY),
|
||||
h265: vk::VideoEncodeH265ProfileInfoKHR::default().std_profile_idc(
|
||||
vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN,
|
||||
),
|
||||
h265: vk::VideoEncodeH265ProfileInfoKHR::default()
|
||||
.std_profile_idc(h265_profile_idc(ten_bit)),
|
||||
av1: av1b::VideoEncodeAV1ProfileInfoKHR {
|
||||
s_type: av1b::stype(av1b::ST_PROFILE_INFO),
|
||||
p_next: std::ptr::null(),
|
||||
// AV1 Main covers 8 AND 10 bits — the depth rides `VideoProfileInfoKHR` alone.
|
||||
std_profile: vk::native::StdVideoAV1Profile_STD_VIDEO_AV1_PROFILE_MAIN,
|
||||
},
|
||||
profile: vk::VideoProfileInfoKHR::default()
|
||||
.video_codec_operation(codec_op)
|
||||
.chroma_subsampling(vk::VideoChromaSubsamplingFlagsKHR::TYPE_420)
|
||||
.luma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8)
|
||||
.chroma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8),
|
||||
.luma_bit_depth(component_depth(ten_bit))
|
||||
.chroma_bit_depth(component_depth(ten_bit)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +281,7 @@ impl NativeProfileStack {
|
||||
/// profile rebuilds — the two must agree, profile identity is by value).
|
||||
/// The physical device + encode queue family a session runs on: the FIRST device exposing a
|
||||
/// `VIDEO_ENCODE` queue family that advertises `codec_op` (llvmpipe advertises none, so it drops
|
||||
/// out implicitly). Shared by [`VulkanVideoEncoder::open_inner`] and [`probe_encode_support`].
|
||||
/// out implicitly). Shared by [`VulkanVideoEncoder::open_inner`] and [`probe_encode_caps`].
|
||||
///
|
||||
/// # Safety
|
||||
/// `instance` must be a live `ash::Instance` and `devices` handles enumerated from it.
|
||||
@@ -270,59 +312,104 @@ unsafe fn find_encode_device(
|
||||
None
|
||||
}
|
||||
|
||||
/// Can this GPU + driver open a Vulkan Video **encode** session for `codec` at all?
|
||||
/// What this device's Vulkan Video **encode** stack can actually do for one codec — the caps
|
||||
/// probe the negotiation stands on, so a session is never planned around a guess.
|
||||
///
|
||||
/// This is the verdict the native-NV12 capture negotiation needs BEFORE it commits
|
||||
/// ([`crate::linux_native_nv12_ok`]): once the producer hands over two-plane NV12 there is no VAAPI
|
||||
/// fallback — libav would import it as packed RGB — so [`crate::open_video`] deliberately makes
|
||||
/// that open-failure fatal, and a Mesa built without `VK_KHR_video_encode_h265` therefore kills the
|
||||
/// session at its first frame instead of streaming on VAAPI.
|
||||
/// Two questions, and they are genuinely independent: an encode queue for the codec operation may
|
||||
/// exist while the silicon declines the 10-bit profile (older VCN, an Intel generation without
|
||||
/// Main10 encode). Answering only the first is what used to push every HDR session to libav VAAPI
|
||||
/// — losing real RFI recovery and the compute CSC's cursor blend for nothing on hardware that
|
||||
/// could do it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct VulkanEncodeCaps {
|
||||
/// An encode queue advertises this codec's operation.
|
||||
pub supported: bool,
|
||||
/// The device accepts an 8-bit 4:2:0 profile for it (the ordinary SDR session).
|
||||
pub eight_bit: bool,
|
||||
/// …and a 10-bit one: HEVC Main10 / AV1 Main at 10 bits, the HDR session's profile.
|
||||
pub ten_bit: bool,
|
||||
}
|
||||
|
||||
/// Probe [`VulkanEncodeCaps`] for `codec`. Uncached — [`crate::vulkan_encode_caps`] owns the
|
||||
/// per-(GPU, codec) cache, exactly as it did for the old boolean.
|
||||
///
|
||||
/// Deliberately the FIRST check `open_inner` performs and hard-fails on, and nothing more: the same
|
||||
/// [`find_encode_device`] scan, run against the same codec op. That makes it provably no stricter
|
||||
/// than the open, so a failure here can only ever name a session that would have died anyway — it
|
||||
/// can never talk a working host out of the fast path. It is also cheap: one instance plus
|
||||
/// physical-device queries, no logical device, no video session, no VRAM. The later stages
|
||||
/// (`create_device`, `create_video_session`, the capability query) can still fail for reasons this
|
||||
/// does not model; those keep the session on the packed-RGB negotiation the ordinary way, because
|
||||
/// the capture format is only committed once this said yes.
|
||||
///
|
||||
/// Probed PER CODEC, not once: `codec_op_for` selects a different queue-family bit for AV1, and
|
||||
/// HEVC-encode-without-AV1-encode is the common VCN/ANV configuration.
|
||||
pub(crate) fn probe_encode_support(codec: Codec) -> Result<(), &'static str> {
|
||||
/// The depth answers come from `vkGetPhysicalDeviceVideoCapabilitiesKHR` against a profile built
|
||||
/// at that depth, which is the same query the session open makes: a `true` here means the open
|
||||
/// will get past the profile gate, and a `false` means it would have failed. No second source of
|
||||
/// truth to drift.
|
||||
pub(crate) fn probe_encode_caps(codec: Codec) -> VulkanEncodeCaps {
|
||||
if !matches!(codec, Codec::H265 | Codec::Av1) {
|
||||
return Err("the Vulkan Video backend encodes HEVC + AV1 only");
|
||||
return VulkanEncodeCaps::default();
|
||||
}
|
||||
let codec_op = codec_op_for(codec == Codec::Av1);
|
||||
let av1 = codec == Codec::Av1;
|
||||
let codec_op = codec_op_for(av1);
|
||||
// SAFETY: creates one Vulkan instance and issues only physical-device queries against it, then
|
||||
// destroys it on EVERY path below before returning — no handle derived from it escapes, and
|
||||
// nothing outside this call observes it. `Entry::load` only dlopens the loader (a missing
|
||||
// libvulkan returns `Err`), touching no process state the rest of the crate relies on.
|
||||
// `find_encode_device` gets a live instance and handles enumerated from it, as it requires.
|
||||
// `find_encode_device` gets a live instance and handles enumerated from it, as it requires;
|
||||
// `depth_supported` gets that same live instance plus the physical device it returned.
|
||||
unsafe {
|
||||
let Ok(entry) = ash::Entry::load() else {
|
||||
return Err("no Vulkan loader");
|
||||
return VulkanEncodeCaps::default();
|
||||
};
|
||||
let app = vk::ApplicationInfo::default().api_version(vk::API_VERSION_1_3);
|
||||
let Ok(instance) = entry.create_instance(
|
||||
&vk::InstanceCreateInfo::default().application_info(&app),
|
||||
None,
|
||||
) else {
|
||||
return Err("vkCreateInstance failed");
|
||||
return VulkanEncodeCaps::default();
|
||||
};
|
||||
let found = match instance.enumerate_physical_devices() {
|
||||
Ok(devices) => find_encode_device(&instance, &devices, codec_op).is_some(),
|
||||
Err(_) => false,
|
||||
Ok(devices) => find_encode_device(&instance, &devices, codec_op).map(|(pd, _)| pd),
|
||||
Err(_) => None,
|
||||
};
|
||||
let caps = match found {
|
||||
Some(pd) => {
|
||||
let vq_inst = ash::khr::video_queue::Instance::new(&entry, &instance);
|
||||
VulkanEncodeCaps {
|
||||
supported: true,
|
||||
eight_bit: depth_supported(&vq_inst, pd, codec_op, av1, false),
|
||||
ten_bit: depth_supported(&vq_inst, pd, codec_op, av1, true),
|
||||
}
|
||||
}
|
||||
None => VulkanEncodeCaps::default(),
|
||||
};
|
||||
instance.destroy_instance(None);
|
||||
if found {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("no VK_KHR_video_encode queue for this codec on any device")
|
||||
}
|
||||
caps
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `pd` accept an encode profile for `codec_op` at this bit depth? The profile chain is
|
||||
/// byte-identical to the one [`VulkanVideoEncoder::open_inner`] builds — that is the point.
|
||||
///
|
||||
/// # Safety
|
||||
/// `vq_inst` must wrap the live instance `pd` was enumerated from.
|
||||
unsafe fn depth_supported(
|
||||
vq_inst: &ash::khr::video_queue::Instance,
|
||||
pd: vk::PhysicalDevice,
|
||||
codec_op: vk::VideoCodecOperationFlagsKHR,
|
||||
av1: bool,
|
||||
ten_bit: bool,
|
||||
) -> bool {
|
||||
let mut ps = NativeProfileStack::new(codec_op, ten_bit);
|
||||
let profile = *ps.wire(av1);
|
||||
let mut h265_caps = vk::VideoEncodeH265CapabilitiesKHR::default();
|
||||
let mut av1_caps: super::vk_av1_encode::VideoEncodeAV1CapabilitiesKHR = std::mem::zeroed();
|
||||
av1_caps.s_type = super::vk_av1_encode::stype(super::vk_av1_encode::ST_CAPABILITIES);
|
||||
let mut enc_caps = vk::VideoEncodeCapabilitiesKHR::default();
|
||||
let mut caps = vk::VideoCapabilitiesKHR::default();
|
||||
if av1 {
|
||||
av1_caps.p_next = &mut enc_caps as *mut _ as *mut c_void;
|
||||
caps.p_next = &mut av1_caps as *mut _ as *mut c_void;
|
||||
} else {
|
||||
h265_caps.p_next = &mut enc_caps as *mut _ as *mut c_void;
|
||||
caps.p_next = &mut h265_caps as *mut _ as *mut c_void;
|
||||
}
|
||||
(vq_inst.fp().get_physical_device_video_capabilities_khr)(pd, &profile, &mut caps)
|
||||
== vk::Result::SUCCESS
|
||||
}
|
||||
|
||||
fn codec_op_for(av1: bool) -> vk::VideoCodecOperationFlagsKHR {
|
||||
if av1 {
|
||||
vk::VideoCodecOperationFlagsKHR::from_raw(
|
||||
@@ -463,6 +550,9 @@ struct Frame {
|
||||
uv_img: vk::Image,
|
||||
uv_mem: vk::DeviceMemory,
|
||||
uv_view: vk::ImageView,
|
||||
/// The CSC's output picture and this frame's encode source: NV12, or the 10-bit
|
||||
/// `P010`/3PACK16 twin on an HDR session ([`yuv_format`]). The name predates the second
|
||||
/// depth; every use goes through the format the session was created with.
|
||||
nv12_src: vk::Image,
|
||||
nv12_mem: vk::DeviceMemory,
|
||||
nv12_view: vk::ImageView,
|
||||
@@ -615,6 +705,11 @@ pub struct VulkanVideoEncoder {
|
||||
/// aligned and an undersized direct source is the OOB-read class behind the 2026-07-20 field
|
||||
/// GPU reset (those paths keep their aligned-size sources/staging).
|
||||
native_nv12: bool,
|
||||
/// This is a 10-bit (HDR) session: Main10 / AV1-10 profile, `P010` picture + DPB, and either
|
||||
/// the BT.2020 compute CSC or the EFC's BT.2020 conversion. Every profile chain rebuilt after
|
||||
/// `open` (the per-buffer dmabuf imports) must present the SAME depth, so it is carried here
|
||||
/// rather than re-derived.
|
||||
ten_bit: bool,
|
||||
|
||||
/// A [`reconfigure_bitrate`](Encoder::reconfigure_bitrate) rate not yet installed in the video
|
||||
/// session. The next `record_submit` emits an `ENCODE_RATE_CONTROL` control command carrying it
|
||||
@@ -669,6 +764,16 @@ impl VulkanVideoEncoder {
|
||||
cursor_blend: bool,
|
||||
) -> Result<Self> {
|
||||
let native_nv12 = format == PixelFormat::Nv12;
|
||||
// HDR: a packed 10-bit PQ/BT.2020 capture (a gamescope output off our `pipewire-hdr`
|
||||
// build, or the GNOME 50+ portal monitor mirror). BOTH codecs and BOTH encode sources
|
||||
// serve it — which of them this device can actually do is `probe_encode_caps`' answer,
|
||||
// consulted by the dispatcher before we get here and re-checked by the profile query
|
||||
// inside the open.
|
||||
let ten_bit = format.is_hdr_rgb10();
|
||||
// The RGB-direct (EFC) source needs the captured format as the session's picture format.
|
||||
// `pixel_to_vk` covers every format the capture can hand a GPU session; the BGRA default
|
||||
// only preserves the old behaviour for the CPU-only layouts, which never reach that arm.
|
||||
let src_rgb_fmt = pixel_to_vk(format).unwrap_or(vk::Format::B8G8R8A8_UNORM);
|
||||
// A cursor-blend session must keep the compute-CSC path — the only arm with the cursor
|
||||
// blend — so it outranks an explicit RGB-direct pin (EFC cannot composite; the
|
||||
// negotiation promised the client a composited pointer).
|
||||
@@ -687,6 +792,8 @@ impl VulkanVideoEncoder {
|
||||
bitrate_bps,
|
||||
want_rgb,
|
||||
native_nv12,
|
||||
ten_bit,
|
||||
src_rgb_fmt,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -704,9 +811,42 @@ impl VulkanVideoEncoder {
|
||||
bitrate_bps: u64,
|
||||
want_rgb: bool,
|
||||
) -> Result<Self> {
|
||||
Self::open_opts_inner(codec, width, height, fps, bitrate_bps, want_rgb, false)
|
||||
Self::open_opts_depth(codec, width, height, fps, bitrate_bps, want_rgb, false)
|
||||
}
|
||||
|
||||
/// [`open_opts`](Self::open_opts) with the bit depth explicit — the 10-bit smokes' entry
|
||||
/// point. A 10-bit session takes the packed 2:10:10:10 source format the HDR capture
|
||||
/// negotiates (`xRGB_210LE` → `A2R10G10B10_UNORM_PACK32`; that is the one gamescope's node
|
||||
/// actually fixates, verified on RADV 2026-07-28), so the smoke drives the same formats a
|
||||
/// real session does.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn open_opts_depth(
|
||||
codec: Codec,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
want_rgb: bool,
|
||||
ten_bit: bool,
|
||||
) -> Result<Self> {
|
||||
Self::open_opts_inner(
|
||||
codec,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
want_rgb,
|
||||
false,
|
||||
ten_bit,
|
||||
if ten_bit {
|
||||
vk::Format::A2R10G10B10_UNORM_PACK32
|
||||
} else {
|
||||
vk::Format::B8G8R8A8_UNORM
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open_opts_inner(
|
||||
codec: Codec,
|
||||
width: u32,
|
||||
@@ -715,6 +855,8 @@ impl VulkanVideoEncoder {
|
||||
bitrate_bps: u64,
|
||||
want_rgb: bool,
|
||||
native_nv12: bool,
|
||||
ten_bit: bool,
|
||||
src_rgb_fmt: vk::Format,
|
||||
) -> Result<Self> {
|
||||
if !matches!(codec, Codec::H265 | Codec::Av1) {
|
||||
bail!("vulkan-encode backend supports HEVC + AV1 only (got {codec:?})");
|
||||
@@ -737,6 +879,8 @@ impl VulkanVideoEncoder {
|
||||
bitrate_bps.max(1_000_000),
|
||||
want_rgb,
|
||||
native_nv12,
|
||||
ten_bit,
|
||||
src_rgb_fmt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -752,6 +896,9 @@ impl VulkanVideoEncoder {
|
||||
bitrate: u64,
|
||||
want_rgb: bool,
|
||||
native_nv12: bool,
|
||||
// NOT `hdr`: this fn already binds that name to the parameter-set HEADER bytes below.
|
||||
ten_bit: bool,
|
||||
src_rgb_fmt: vk::Format,
|
||||
) -> Result<Self> {
|
||||
use super::vk_av1_encode as av1b;
|
||||
use super::vk_valve_rgb as vrgb;
|
||||
@@ -810,7 +957,7 @@ impl VulkanVideoEncoder {
|
||||
let rgb_probe = if native_nv12 {
|
||||
Err("not-probed(native NV12 source selected)")
|
||||
} else {
|
||||
probe_rgb_direct(&instance, &vq_inst, pd, codec_op, av1)
|
||||
probe_rgb_direct(&instance, &vq_inst, pd, codec_op, av1, ten_bit, src_rgb_fmt)
|
||||
};
|
||||
let rgb_cfg: Option<RgbDirect> = match (&rgb_probe, want_rgb) {
|
||||
(Ok((x, y)), true) => {
|
||||
@@ -871,8 +1018,8 @@ impl VulkanVideoEncoder {
|
||||
p_next: std::ptr::null(),
|
||||
perform_encode_rgb_conversion: vk::TRUE,
|
||||
};
|
||||
let mut h265_profile = vk::VideoEncodeH265ProfileInfoKHR::default()
|
||||
.std_profile_idc(vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN);
|
||||
let mut h265_profile =
|
||||
vk::VideoEncodeH265ProfileInfoKHR::default().std_profile_idc(h265_profile_idc(ten_bit));
|
||||
let mut av1_profile = av1b::VideoEncodeAV1ProfileInfoKHR {
|
||||
s_type: av1b::stype(av1b::ST_PROFILE_INFO),
|
||||
p_next: std::ptr::null(),
|
||||
@@ -885,11 +1032,16 @@ impl VulkanVideoEncoder {
|
||||
if rgb_cfg.is_some() {
|
||||
usage.p_next = &rgb_info as *const _ as *const c_void;
|
||||
}
|
||||
// A device that cannot encode 10-bit fails `get_physical_device_video_capabilities`
|
||||
// below with VIDEO_PROFILE_FORMAT_NOT_SUPPORTED, which fails the open — and a failed
|
||||
// Vulkan open falls back to libav VAAPI in `open_amd_intel`. That is the whole 10-bit
|
||||
// capability gate: no separate probe, and no way to reach a half-configured session.
|
||||
let depth = component_depth(ten_bit);
|
||||
let mut profile = vk::VideoProfileInfoKHR::default()
|
||||
.video_codec_operation(codec_op)
|
||||
.chroma_subsampling(vk::VideoChromaSubsamplingFlagsKHR::TYPE_420)
|
||||
.luma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8)
|
||||
.chroma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8);
|
||||
.luma_bit_depth(depth)
|
||||
.chroma_bit_depth(depth);
|
||||
if av1 {
|
||||
av1_profile.p_next = &usage as *const _ as *const c_void;
|
||||
profile.p_next = &av1_profile as *const _ as *const c_void;
|
||||
@@ -1109,7 +1261,7 @@ impl VulkanVideoEncoder {
|
||||
let mut rgb_sci = vrgb::VideoEncodeSessionRgbConversionCreateInfoVALVE {
|
||||
s_type: vrgb::stype(vrgb::ST_SESSION_CREATE_INFO),
|
||||
p_next: std::ptr::null(),
|
||||
rgb_model: vrgb::MODEL_YCBCR_709,
|
||||
rgb_model: rgb_model_for(ten_bit),
|
||||
rgb_range: vrgb::RANGE_NARROW,
|
||||
x_chroma_offset: rgb_cfg.as_ref().map_or(0, |c| c.x_offset),
|
||||
y_chroma_offset: rgb_cfg.as_ref().map_or(0, |c| c.y_offset),
|
||||
@@ -1118,15 +1270,15 @@ impl VulkanVideoEncoder {
|
||||
.queue_family_index(encode_family)
|
||||
.video_profile(&profile)
|
||||
.picture_format(if rgb_cfg.is_some() {
|
||||
vk::Format::B8G8R8A8_UNORM
|
||||
src_rgb_fmt
|
||||
} else {
|
||||
NV12
|
||||
yuv_format(ten_bit)
|
||||
})
|
||||
.max_coded_extent(vk::Extent2D {
|
||||
width: w,
|
||||
height: h,
|
||||
})
|
||||
.reference_picture_format(NV12)
|
||||
.reference_picture_format(yuv_format(ten_bit))
|
||||
.max_dpb_slots(DPB_SLOTS + 1)
|
||||
.max_active_reference_pictures(1)
|
||||
.std_header_version(&std_hdr);
|
||||
@@ -1224,6 +1376,7 @@ impl VulkanVideoEncoder {
|
||||
av1_caps.max_level,
|
||||
av1_superblock128,
|
||||
quality_level,
|
||||
ten_bit,
|
||||
)?
|
||||
} else {
|
||||
let (p, hdr) = build_parameters_h265(
|
||||
@@ -1236,6 +1389,7 @@ impl VulkanVideoEncoder {
|
||||
rw,
|
||||
rh,
|
||||
quality_level,
|
||||
ten_bit,
|
||||
)?;
|
||||
(p, hdr, Vec::new())
|
||||
};
|
||||
@@ -1247,7 +1401,7 @@ impl VulkanVideoEncoder {
|
||||
let (dpb_image, dpb_mem) = make_video_image(
|
||||
&device,
|
||||
&mem_props,
|
||||
NV12,
|
||||
yuv_format(ten_bit),
|
||||
w,
|
||||
h,
|
||||
DPB_SLOTS,
|
||||
@@ -1260,7 +1414,7 @@ impl VulkanVideoEncoder {
|
||||
for slot in 0..DPB_SLOTS {
|
||||
guard
|
||||
.dpb_views
|
||||
.push(make_view(&device, dpb_image, NV12, slot)?);
|
||||
.push(make_view(&device, dpb_image, yuv_format(ten_bit), slot)?);
|
||||
}
|
||||
|
||||
// NV12 encode-src, CSC scratch (Y/UV), bitstream, query and command buffers are all per
|
||||
@@ -1281,7 +1435,11 @@ impl VulkanVideoEncoder {
|
||||
None,
|
||||
)?;
|
||||
guard.sampler = sampler;
|
||||
let spv = ash::util::read_spv(&mut std::io::Cursor::new(CSC_SPV))?;
|
||||
let spv = ash::util::read_spv(&mut std::io::Cursor::new(if ten_bit {
|
||||
CSC10_SPV
|
||||
} else {
|
||||
CSC_SPV
|
||||
}))?;
|
||||
let shader =
|
||||
device.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spv), None)?;
|
||||
guard.shader = shader;
|
||||
@@ -1395,7 +1553,8 @@ impl VulkanVideoEncoder {
|
||||
rgb_cfg
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.padded)
|
||||
.then_some(vk::Format::B8G8R8A8_UNORM),
|
||||
.then_some(src_rgb_fmt),
|
||||
ten_bit,
|
||||
guard.frames.last_mut().expect("frame just pushed"),
|
||||
)?;
|
||||
}
|
||||
@@ -1455,6 +1614,7 @@ impl VulkanVideoEncoder {
|
||||
cpu_expand: Vec::new(),
|
||||
rgb: rgb_cfg,
|
||||
native_nv12,
|
||||
ten_bit,
|
||||
pending_bitrate: None,
|
||||
width: w,
|
||||
height: h,
|
||||
@@ -1615,7 +1775,8 @@ impl VulkanVideoEncoder {
|
||||
ch: u32,
|
||||
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
||||
if self.native_nv12 {
|
||||
let mut ps = NativeProfileStack::new(codec_op_for(self.codec == Codec::Av1));
|
||||
let mut ps =
|
||||
NativeProfileStack::new(codec_op_for(self.codec == Codec::Av1), self.ten_bit);
|
||||
let profile = *ps.wire(self.codec == Codec::Av1);
|
||||
let arr = [profile];
|
||||
let mut plist = vk::VideoProfileListInfoKHR::default().profiles(&arr);
|
||||
@@ -1644,7 +1805,7 @@ impl VulkanVideoEncoder {
|
||||
None,
|
||||
)
|
||||
} else if self.rgb.is_some() {
|
||||
let mut ps = RgbProfileStack::new(codec_op_for(self.codec == Codec::Av1));
|
||||
let mut ps = RgbProfileStack::new(codec_op_for(self.codec == Codec::Av1), self.ten_bit);
|
||||
let profile = *ps.wire(self.codec == Codec::Av1);
|
||||
let arr = [profile];
|
||||
let mut plist = vk::VideoProfileListInfoKHR::default().profiles(&arr);
|
||||
@@ -1801,7 +1962,7 @@ impl VulkanVideoEncoder {
|
||||
// usage, and shared with the encode queue (the compute queue only copies into
|
||||
// it; the semaphore orders the hand-off, CONCURRENT avoids a QFOT).
|
||||
let av1 = self.codec == Codec::Av1;
|
||||
let mut ps = RgbProfileStack::new(codec_op_for(av1));
|
||||
let mut ps = RgbProfileStack::new(codec_op_for(av1), self.ten_bit);
|
||||
let profile = *ps.wire(av1);
|
||||
let arr = [profile];
|
||||
let mut plist = vk::VideoProfileListInfoKHR::default().profiles(&arr);
|
||||
@@ -4160,7 +4321,7 @@ impl Drop for VulkanVideoEncoder {
|
||||
mod build;
|
||||
use self::build::{
|
||||
align_up, build_parameters_av1, build_parameters_h265, make_frame, make_video_image,
|
||||
probe_rgb_direct,
|
||||
probe_rgb_direct, rgb_model_for,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -4385,6 +4546,124 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A packed 2:10:10:10 (`xRGB_210LE` / `PixelFormat::X2Rgb10`) CPU frame — the layout a
|
||||
/// gamescope HDR capture delivers, and the one its node actually fixates. Channels are given
|
||||
/// as 10-bit code values so the test can speak in the units the PQ container uses.
|
||||
fn cpu_frame_rgb10(w: u32, h: u32, pts_ns: u64, rgb10: [u16; 3]) -> CapturedFrame {
|
||||
// x:R:G:B 2:10:10:10 little-endian — B in bits 0-9, G in 10-19, R in 20-29.
|
||||
let word = ((rgb10[0] as u32 & 0x3FF) << 20)
|
||||
| ((rgb10[1] as u32 & 0x3FF) << 10)
|
||||
| (rgb10[2] as u32 & 0x3FF);
|
||||
let mut buf = vec![0u8; (w * h * 4) as usize];
|
||||
for px in buf.chunks_exact_mut(4) {
|
||||
px.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
CapturedFrame {
|
||||
width: w,
|
||||
height: h,
|
||||
pts_ns,
|
||||
format: PixelFormat::X2Rgb10,
|
||||
payload: FramePayload::Cpu(buf),
|
||||
cursor: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The 10-bit twin of [`run_smoke_opts`]: a full HDR session end to end — Main10 / AV1-at-10
|
||||
/// profile, `G10X6…3PACK16` picture + DPB, `rgb2yuv10.comp` (or the EFC's BT.2020 conversion
|
||||
/// when `rgb`), and headers carrying the depth + BT.2020/PQ signalling.
|
||||
///
|
||||
/// This is the least-exercised path in the backend — nothing had ever created a 10-bit video
|
||||
/// session here — so the assertions stay where a smoke test can be honest: every submit
|
||||
/// encodes, the AU count matches, and the depth the encoder settled on is the one asked for.
|
||||
/// Colour truth is out of band (the `dump_smoke` + ffmpeg recipe), because a shader that
|
||||
/// wrote the 10 bits into the wrong end of the word still produces a decodable stream.
|
||||
///
|
||||
/// `None` = the device declined the 10-bit profile (or, with `rgb`, the BT.2020 EFC
|
||||
/// conversion): a soft skip, not a failure — that is the same verdict `open_amd_intel`
|
||||
/// consults to route such a session to VAAPI.
|
||||
fn run_smoke_10bit(codec: Codec, rgb: bool) -> Option<Vec<crate::EncodedFrame>> {
|
||||
let env_dim = |k: &str, d: u32| {
|
||||
std::env::var(k)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(d)
|
||||
};
|
||||
let (w, h) = (env_dim("PF_SMOKE_W", 256), env_dim("PF_SMOKE_H", 256));
|
||||
let mut enc =
|
||||
match VulkanVideoEncoder::open_opts_depth(codec, w, h, 60, 10_000_000, rgb, true) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("run_smoke_10bit({codec:?}, rgb={rgb}): open declined — {e:#}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if rgb && enc.rgb.is_none() {
|
||||
eprintln!("run_smoke_10bit: BT.2020 RGB-direct unavailable on this driver — skipping");
|
||||
return None;
|
||||
}
|
||||
assert!(enc.ten_bit, "a 10-bit session must report 10-bit");
|
||||
|
||||
// 10-bit code values spanning the range, so a wrong shift shows up as a wildly wrong
|
||||
// luminance in the dump rather than a plausible-looking picture.
|
||||
let colors: [[u16; 3]; 8] = [
|
||||
[160, 160, 800],
|
||||
[160, 800, 160],
|
||||
[800, 160, 160],
|
||||
[800, 800, 160],
|
||||
[160, 800, 800],
|
||||
[800, 160, 800],
|
||||
[480, 800, 320],
|
||||
[320, 480, 800],
|
||||
];
|
||||
let mut aus: Vec<crate::EncodedFrame> = Vec::new();
|
||||
for (i, c) in colors.iter().enumerate() {
|
||||
enc.submit_indexed(&cpu_frame_rgb10(w, h, i as u64 * 16_666_667, *c), i as u32)
|
||||
.expect("submit");
|
||||
while let Some(au) = enc.poll().expect("poll") {
|
||||
aus.push(au);
|
||||
}
|
||||
}
|
||||
enc.flush().expect("flush");
|
||||
while let Some(au) = enc.poll().expect("poll") {
|
||||
aus.push(au);
|
||||
}
|
||||
assert_eq!(aus.len(), colors.len(), "one AU per submitted frame");
|
||||
assert!(aus[0].keyframe, "frame 0 must be IDR");
|
||||
for (i, au) in aus.iter().enumerate() {
|
||||
assert!(!au.data.is_empty(), "AU {i} empty");
|
||||
}
|
||||
Some(aus)
|
||||
}
|
||||
|
||||
/// HEVC **Main10** through the compute CSC — the AMD/Intel HDR path a gamescope session takes.
|
||||
#[test]
|
||||
#[ignore = "needs a real VK_KHR_video_encode_h265 device with a 10-bit profile"]
|
||||
fn vulkan_smoke_10bit() {
|
||||
if let Some(aus) = run_smoke_10bit(Codec::H265, false) {
|
||||
dump_smoke(&aus, "10bit.h265");
|
||||
}
|
||||
}
|
||||
|
||||
/// AV1 at 10 bits — the codec whose driver coverage is thinner, hence its own smoke.
|
||||
#[test]
|
||||
#[ignore = "needs a real VK_KHR_video_encode_av1 device with a 10-bit profile"]
|
||||
fn vulkan_smoke_10bit_av1() {
|
||||
if let Some(aus) = run_smoke_10bit(Codec::Av1, false) {
|
||||
dump_smoke(&aus, "10bit.obu");
|
||||
}
|
||||
}
|
||||
|
||||
/// HDR with **no host CSC at all**: the EFC does the BT.2020 conversion off the packed 10-bit
|
||||
/// source. Soft-skips where the hardware advertises no `MODEL_YCBCR_2020`, which is the one
|
||||
/// capability bit in this whole path that has never been seen reported by a real device.
|
||||
#[test]
|
||||
#[ignore = "needs VK_VALVE_video_encode_rgb_conversion with the BT.2020 model at 10-bit"]
|
||||
fn vulkan_smoke_rgb_10bit() {
|
||||
if let Some(aus) = run_smoke_10bit(Codec::H265, true) {
|
||||
dump_smoke(&aus, "rgb.10bit.h265");
|
||||
}
|
||||
}
|
||||
|
||||
/// 24-bpp packed CPU frame (`PixelFormat::Rgb`/`Bgr` — the PipeWire portal's CPU
|
||||
/// negotiation). `rgb` is (r, g, b) regardless of `fmt`'s byte order.
|
||||
fn cpu_frame_24(w: u32, h: u32, pts_ns: u64, rgb: [u8; 3], fmt: PixelFormat) -> CapturedFrame {
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
//! `libloading`), the device binding (D3D11 vs CUDA), input-surface registration, and the
|
||||
//! Windows-only async retrieve — stay in their backends. Sibling of [`super::nvenc_status`].
|
||||
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw `nvEncodeAPI` entry-table calls almost line for line; narrowing it
|
||||
// would add one `unsafe {}` plus one SAFETY comment per call that could only restate the signature.
|
||||
// Clearing this file means DELETING the markers that carry no caller contract, not wrapping the
|
||||
// calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
use super::Codec;
|
||||
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
|
||||
|
||||
|
||||
@@ -81,6 +81,61 @@ pub(crate) fn block_count_32x32(width: u32, height: u32, chroma444: bool) -> u32
|
||||
count
|
||||
}
|
||||
|
||||
/// Wire-aware deflation of the per-frame rate budget for the datagram-aligned mode.
|
||||
///
|
||||
/// [`build_au`]'s windowing inflates the codec bitstream on its way to the wire: greedy packing
|
||||
/// of pyrowave's few-hundred-byte atomic block packets into `chunk`-sized windows leaves the
|
||||
/// tail of most windows zero-padded, plus the 4-byte prefixes and FRAG-chain tails. At
|
||||
/// 1440p/~850 KiB frames that is ×1.2–1.3 — the 2026-07 field report's "Automatic" 407 Mb/s
|
||||
/// pin put 550 Mb/s on a 1 GbE link. The pin is a promise about the LINK, so the codec budget
|
||||
/// must absorb the framing: this tracker measures the real AU/bitstream ratio per frame and
|
||||
/// deflates the budget handed to pyrowave's rate control by its EMA. Sealed-datagram framing
|
||||
/// (packet header + AEAD tag) and FEC parity are deliberately NOT compensated — H.26x sessions
|
||||
/// carry those on top of the configured bitrate too, and the pin must mean the same thing for
|
||||
/// every codec.
|
||||
pub(crate) struct WireBudget {
|
||||
/// EMA of `built AU bytes / packetized bitstream bytes`, ×1024 fixed point.
|
||||
scale_x1024: u32,
|
||||
}
|
||||
|
||||
impl WireBudget {
|
||||
/// Startup prior (×1024 ≈ 1.25 — the 1440p field measurement's midpoint); the EMA
|
||||
/// converges onto the session's real ratio within ~a second of frames.
|
||||
const PRIOR_X1024: u32 = 1280;
|
||||
/// EMA weight 1/8: content-driven per-frame wobble smooths out; a mode/bitrate change
|
||||
/// re-converges in ~16 frames.
|
||||
const EMA_SHIFT: u32 = 3;
|
||||
/// Sanity clamp on the applied scale: never inflate the budget (×1.0 floor), never
|
||||
/// deflate below half (×2.0 cap — tiny explicit bitrates window very coarsely).
|
||||
const MIN_X1024: u32 = 1024;
|
||||
const MAX_X1024: u32 = 2048;
|
||||
|
||||
pub(crate) fn new() -> WireBudget {
|
||||
WireBudget {
|
||||
scale_x1024: Self::PRIOR_X1024,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one frame's measured inflation (`bitstream_len` = the packetized codec bytes the
|
||||
/// rate controller budgeted; `au_len` = the windowed AU that actually reaches the wire).
|
||||
pub(crate) fn observe(&mut self, bitstream_len: usize, au_len: usize) {
|
||||
if bitstream_len == 0 {
|
||||
return;
|
||||
}
|
||||
let sample = ((au_len as u64 * 1024) / bitstream_len as u64)
|
||||
.clamp(Self::MIN_X1024 as u64, Self::MAX_X1024 as u64) as u32;
|
||||
let ema = self.scale_x1024 as i64;
|
||||
self.scale_x1024 = (ema + ((sample as i64 - ema) >> Self::EMA_SHIFT)) as u32;
|
||||
}
|
||||
|
||||
/// The rate-control budget that makes the WIRE hit `budget` bytes/frame under the
|
||||
/// currently-measured inflation.
|
||||
pub(crate) fn deflate(&self, budget: usize) -> usize {
|
||||
let scale = self.scale_x1024.clamp(Self::MIN_X1024, Self::MAX_X1024) as u64;
|
||||
((budget as u64 * 1024) / scale) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame pyrowave's `packets` (each an `(offset, size)` into `bitstream`) into the wire AU.
|
||||
/// `wire_chunk = None` copies the single dense packet; `Some(chunk)` produces the windowed
|
||||
/// datagram-aligned AU (a whole number of `chunk`-sized windows).
|
||||
@@ -259,6 +314,37 @@ mod tests {
|
||||
assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32);
|
||||
}
|
||||
|
||||
/// The wire-budget tracker: converges its EMA onto the measured AU/bitstream inflation,
|
||||
/// deflates the budget by exactly that ratio, and clamps runaway samples.
|
||||
#[test]
|
||||
fn wire_budget_converges_and_deflates() {
|
||||
let mut wb = WireBudget::new();
|
||||
// Prior ≈ ×1.25 (1280/1024): the first deflation is already conservative.
|
||||
assert_eq!(wb.deflate(1_024_000), 819_200);
|
||||
// Feed a steady ×1.30 inflation; the EMA must converge onto it.
|
||||
for _ in 0..64 {
|
||||
wb.observe(1000, 1300);
|
||||
}
|
||||
let b = wb.deflate(1_024_000);
|
||||
let expect = 1_024_000_u64 * 1000 / 1300;
|
||||
assert!(
|
||||
(b as i64 - expect as i64).unsigned_abs() < 8_000,
|
||||
"budget {b} should approach {expect}"
|
||||
);
|
||||
// A dense-ish run (×1.0) walks it back down to no deflation.
|
||||
for _ in 0..64 {
|
||||
wb.observe(1000, 1000);
|
||||
}
|
||||
assert_eq!(wb.deflate(1_024_000), 1_024_000);
|
||||
// Garbage samples are clamped: an absurd ratio can at most halve the budget…
|
||||
for _ in 0..256 {
|
||||
wb.observe(10, 1000);
|
||||
}
|
||||
assert!(wb.deflate(1_024_000) >= 512_000);
|
||||
// …and a zero-length observation is ignored, never a division by zero.
|
||||
wb.observe(0, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_color_bits_sets_range_and_hdr_bits() {
|
||||
let mut bs = vec![0u8; 16];
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
//! no B-frames (Baseline), bitrate rate-control, in-band SPS/PPS each IDR.
|
||||
//! Synchronous: `submit` encodes immediately and stashes the AU for `poll` (no internal queue).
|
||||
//!
|
||||
//! The RGB→YUV conversion is OURS, BT.709 limited range: openh264 writes no colour description
|
||||
//! into the VUI (unspecified), so decoders fall back to their default — BT.709 limited on every
|
||||
//! punktfunk client — and the pixels must match that default. The crate's own `YUVBuffer`
|
||||
//! converter is BT.601 (0.2578/0.5039/0.0977 + 16), which decoded-as-709 is a constant hue
|
||||
//! error; that's why it is NOT used here.
|
||||
//! The RGB→YUV conversion is OURS, BT.709 limited range, and the SPS VUI says so
|
||||
//! ([`VuiConfig::bt709`], applied in `open`). The crate's own `YUVBuffer` converter is BT.601
|
||||
//! (0.2578/0.5039/0.0977 + 16), which decoded-as-709 is a constant hue error; that's why it is
|
||||
//! NOT used here.
|
||||
//!
|
||||
//! Signalling is not optional. This used to leave the VUI unwritten and lean on decoders
|
||||
//! defaulting to BT.709 limited — true of every punktfunk client (`csc_rows` falls back to 709 on
|
||||
//! "unspecified"), but NOT of vendor TV decoders, which guess colorimetry from RESOLUTION: an LG
|
||||
//! webOS panel reads a 4K SDR stream as BT.2020 and renders it visibly washed out.
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
@@ -15,7 +19,7 @@ use super::{EncodedFrame, Encoder};
|
||||
use anyhow::{bail, ensure, Context, Result};
|
||||
use openh264::encoder::{
|
||||
BitRate, Complexity, Encoder as Oh264, EncoderConfig, FrameRate, FrameType, IntraFramePeriod,
|
||||
Profile, RateControlMode, SpsPpsStrategy, UsageType,
|
||||
Profile, RateControlMode, SpsPpsStrategy, UsageType, VuiConfig,
|
||||
};
|
||||
use openh264::formats::YUVSlices;
|
||||
use openh264::OpenH264API;
|
||||
@@ -100,7 +104,10 @@ impl OpenH264Encoder {
|
||||
.scene_change_detect(false) // no surprise IDRs (bitrate spikes / freeze)
|
||||
.adaptive_quantization(true)
|
||||
.complexity(Complexity::Low) // latency over BD-rate
|
||||
.profile(Profile::Baseline); // no B-frames; the VUI carries no colour description
|
||||
.profile(Profile::Baseline) // no B-frames
|
||||
// video_signal_type + colour_description in the SPS VUI: BT.709 primaries/transfer/
|
||||
// matrix, video_full_range_flag = 0 — exactly what `convert_bt709` below produces.
|
||||
.vui(VuiConfig::bt709());
|
||||
let api = OpenH264API::from_source(); // statically-bundled build (default `source` feature)
|
||||
let enc = Oh264::with_api_config(api, cfg).context("openh264 Encoder::with_api_config")?;
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
@@ -364,6 +371,144 @@ mod tests {
|
||||
assert!(has_sps, "IDR must carry an SPS NAL (type 7)");
|
||||
}
|
||||
|
||||
/// Strip Annex-B framing + emulation-prevention bytes from the first SPS NAL in `au`.
|
||||
fn sps_rbsp(au: &[u8]) -> Vec<u8> {
|
||||
let start = au
|
||||
.windows(5)
|
||||
.position(|w| w[..4] == [0, 0, 0, 1] && (w[4] & 0x1f) == 7)
|
||||
.map(|p| p + 5)
|
||||
.expect("an SPS NAL");
|
||||
let end = au[start..]
|
||||
.windows(4)
|
||||
.position(|w| w[..3] == [0, 0, 1] || w == [0, 0, 0, 1])
|
||||
.map_or(au.len(), |p| start + p);
|
||||
let mut rbsp = Vec::new();
|
||||
let nal = &au[start..end];
|
||||
let mut i = 0;
|
||||
while i < nal.len() {
|
||||
// 00 00 03 -> the 03 is an emulation-prevention byte, not payload.
|
||||
if i + 2 < nal.len() && nal[i] == 0 && nal[i + 1] == 0 && nal[i + 2] == 3 {
|
||||
rbsp.extend_from_slice(&[0, 0]);
|
||||
i += 3;
|
||||
} else {
|
||||
rbsp.push(nal[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
rbsp
|
||||
}
|
||||
|
||||
/// The colour signalling the SPS actually carries, walked per ITU-T H.264 §7.3.2.1.1: returns
|
||||
/// `(video_full_range_flag, colour_primaries, transfer_characteristics, matrix_coefficients)`.
|
||||
/// `None` when the stream is unsignalled — which is what this module used to emit.
|
||||
fn sps_colour(rbsp: &[u8]) -> Option<(u8, u8, u8, u8)> {
|
||||
// Exp-Golomb ue(v): count leading zeros, then read that many trailing bits.
|
||||
fn ue(u: &mut dyn FnMut(u32) -> u32) -> u32 {
|
||||
let mut lz = 0;
|
||||
while u(1) == 0 {
|
||||
lz += 1;
|
||||
assert!(lz < 32, "malformed Exp-Golomb");
|
||||
}
|
||||
if lz == 0 {
|
||||
0
|
||||
} else {
|
||||
(1 << lz) - 1 + u(lz)
|
||||
}
|
||||
}
|
||||
let mut pos = 0usize;
|
||||
let mut u = |bits: u32| -> u32 {
|
||||
let mut v = 0;
|
||||
for _ in 0..bits {
|
||||
v = (v << 1) | u32::from((rbsp[pos / 8] >> (7 - (pos % 8))) & 1);
|
||||
pos += 1;
|
||||
}
|
||||
v
|
||||
};
|
||||
let profile_idc = u(8);
|
||||
u(8); // constraint_set flags + reserved
|
||||
u(8); // level_idc
|
||||
ue(&mut u); // seq_parameter_set_id
|
||||
assert_eq!(
|
||||
profile_idc, 66,
|
||||
"this encoder is pinned to Baseline — a profile change adds the chroma_format_idc \
|
||||
block this walk deliberately omits"
|
||||
);
|
||||
ue(&mut u); // log2_max_frame_num_minus4
|
||||
let poc_type = ue(&mut u);
|
||||
match poc_type {
|
||||
0 => {
|
||||
ue(&mut u);
|
||||
} // log2_max_pic_order_cnt_lsb_minus4
|
||||
1 => panic!("pic_order_cnt_type 1 unhandled — openh264 emits 0 or 2"),
|
||||
_ => {}
|
||||
}
|
||||
ue(&mut u); // max_num_ref_frames
|
||||
u(1); // gaps_in_frame_num_value_allowed_flag
|
||||
ue(&mut u); // pic_width_in_mbs_minus1
|
||||
ue(&mut u); // pic_height_in_map_units_minus1
|
||||
if u(1) == 0 {
|
||||
u(1); // mb_adaptive_frame_field_flag
|
||||
}
|
||||
u(1); // direct_8x8_inference_flag
|
||||
if u(1) == 1 {
|
||||
for _ in 0..4 {
|
||||
ue(&mut u); // frame_crop_*_offset
|
||||
}
|
||||
}
|
||||
if u(1) == 0 {
|
||||
return None; // vui_parameters_present_flag
|
||||
}
|
||||
if u(1) == 1 {
|
||||
// aspect_ratio_info_present_flag
|
||||
if u(8) == 255 {
|
||||
u(16);
|
||||
u(16);
|
||||
}
|
||||
}
|
||||
if u(1) == 1 {
|
||||
u(1); // overscan_info_present_flag -> overscan_appropriate_flag
|
||||
}
|
||||
if u(1) == 0 {
|
||||
return None; // video_signal_type_present_flag
|
||||
}
|
||||
u(3); // video_format
|
||||
let full_range = u(1) as u8;
|
||||
if u(1) == 0 {
|
||||
return None; // colour_description_present_flag
|
||||
}
|
||||
Some((full_range, u(8) as u8, u(8) as u8, u(8) as u8))
|
||||
}
|
||||
|
||||
/// The SPS must SIGNAL BT.709 limited, not merely be encoded that way. `VuiConfig::bt709()`
|
||||
/// is a request to a C library; this asserts it lands in the emitted bitstream.
|
||||
///
|
||||
/// Unsignalled was the old behaviour and it looks fine on every punktfunk client (`csc_rows`
|
||||
/// defaults to BT.709 on "unspecified"), so nothing in our own stack catches a regression
|
||||
/// here — but vendor TV decoders guess colorimetry from RESOLUTION, and an LG webOS panel
|
||||
/// reads a 4K SDR stream as BT.2020 and renders it visibly washed out.
|
||||
#[test]
|
||||
fn sps_signals_bt709_limited() {
|
||||
let (w, h, fps) = (1280u32, 720u32, 60u32);
|
||||
let mut enc =
|
||||
OpenH264Encoder::open(PixelFormat::Bgrx, w, h, fps, 8_000_000).expect("open openh264");
|
||||
let frame = CapturedFrame {
|
||||
width: w,
|
||||
height: h,
|
||||
pts_ns: 0,
|
||||
format: PixelFormat::Bgrx,
|
||||
payload: FramePayload::Cpu(vec![0x80u8; (w * h * 4) as usize]),
|
||||
cursor: None,
|
||||
};
|
||||
enc.submit(&frame).expect("submit");
|
||||
let au = enc.poll().expect("poll").expect("an AU");
|
||||
let colour = sps_colour(&sps_rbsp(&au.data)).expect(
|
||||
"the SPS must carry video_signal_type + colour_description — \
|
||||
see EncoderConfig::vui in `open`",
|
||||
);
|
||||
// (video_full_range_flag, colour_primaries, transfer, matrix) — 0 = limited, 1 = BT.709.
|
||||
assert_eq!(colour, (0, 1, 1, 1), "expected BT.709 limited signalling");
|
||||
}
|
||||
|
||||
/// The modes the software encoder can actually serve — including the portrait orientation,
|
||||
/// which a naive per-axis `w <= 3840 && h <= 2160` would wrongly reject.
|
||||
#[test]
|
||||
|
||||
@@ -43,6 +43,12 @@
|
||||
//! fallback + `PUNKTFUNK_AMF_FFMPEG` hatch are gone). 4:4:4 is **permanently** out: VCN hardware
|
||||
//! does not encode 4:4:4.
|
||||
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw AMF COM-style vtable calls through `*mut AmfComponent` almost line
|
||||
// for line; narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only
|
||||
// restate the signature. Clearing this file means DELETING the markers that carry no caller
|
||||
// contract, not wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
|
||||
@@ -37,6 +37,13 @@
|
||||
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
|
||||
//! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't
|
||||
//! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`.
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw libav (`ffmpeg-sys-next`) calls on borrowed hwcontext pointers
|
||||
// almost line for line; narrowing it would add one `unsafe {}` plus one SAFETY comment per call
|
||||
// that could only restate the signature. Clearing this file means DELETING the markers that carry
|
||||
// no caller contract, not wrapping the calls — until then the lint is off HERE and enforced
|
||||
// everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
@@ -50,10 +57,10 @@ use std::os::raw::{c_int, c_uint, c_void};
|
||||
use std::ptr;
|
||||
use windows::core::Interface;
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
ID3D11Device, ID3D11DeviceContext, ID3D11Resource, ID3D11Texture2D, D3D11_BIND_DECODER,
|
||||
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_BIND_VIDEO_ENCODER,
|
||||
D3D11_CPU_ACCESS_READ, D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ, D3D11_TEXTURE2D_DESC,
|
||||
D3D11_USAGE_STAGING,
|
||||
ID3D11Device, ID3D11DeviceContext, ID3D11Multithread, ID3D11Resource, ID3D11Texture2D,
|
||||
D3D11_BIND_DECODER, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE,
|
||||
D3D11_BIND_VIDEO_ENCODER, D3D11_CPU_ACCESS_READ, D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ,
|
||||
D3D11_TEXTURE2D_DESC, D3D11_USAGE_STAGING,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010,
|
||||
@@ -61,8 +68,8 @@ use windows::Win32::Graphics::Dxgi::Common::{
|
||||
};
|
||||
|
||||
use super::libav::{
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, PollOutcome, SWS_CS_BT2020, SWS_CS_ITU709,
|
||||
SWS_POINT,
|
||||
apply_low_latency_rc, pixel_to_av, poll_encoder, AvBuffer, PollOutcome, SWS_CS_BT2020,
|
||||
SWS_CS_ITU709, SWS_POINT,
|
||||
};
|
||||
use ffmpeg::ffi; // = ffmpeg_sys_next
|
||||
|
||||
@@ -857,8 +864,11 @@ impl Drop for SystemInner {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
struct D3d11Hw {
|
||||
device_ref: *mut ffi::AVBufferRef,
|
||||
frames_ref: *mut ffi::AVBufferRef,
|
||||
// Declared frames-BEFORE-device on purpose: these drop in declaration order, reproducing what
|
||||
// the hand-written `Drop` this replaced did (the frames ctx holds its own ref on the device).
|
||||
// Do not reorder these two fields.
|
||||
frames_ref: AvBuffer,
|
||||
device_ref: AvBuffer,
|
||||
}
|
||||
|
||||
impl D3d11Hw {
|
||||
@@ -871,30 +881,65 @@ impl D3d11Hw {
|
||||
h: u32,
|
||||
pool: c_int,
|
||||
) -> Result<Self> {
|
||||
let mut device_ref =
|
||||
ffi::av_hwdevice_ctx_alloc(ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA);
|
||||
if device_ref.is_null() {
|
||||
bail!("av_hwdevice_ctx_alloc(D3D11VA) failed");
|
||||
}
|
||||
let dev_ctx = (*device_ref).data as *mut ffi::AVHWDeviceContext;
|
||||
// Owned from the moment it exists: each `bail!` below drops what was built so far, so none
|
||||
// of the failure branches carry cleanup of their own.
|
||||
let device_ref = AvBuffer::from_raw(ffi::av_hwdevice_ctx_alloc(
|
||||
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA,
|
||||
))
|
||||
.context("av_hwdevice_ctx_alloc(D3D11VA) failed")?;
|
||||
let dev_ctx = (*device_ref.as_ptr()).data as *mut ffi::AVHWDeviceContext;
|
||||
let d11 = (*dev_ctx).hwctx as *mut AVD3D11VADeviceContext;
|
||||
|
||||
// Turn on D3D11 multithread protection before libav sees the device.
|
||||
//
|
||||
// libav does this itself in `d3d11va_device_create` — but only there. We take the OTHER
|
||||
// path (`d3d11va_device_init`, because we supply the capturer's device), which does not,
|
||||
// and the omission bites twice:
|
||||
//
|
||||
// * QSV. `av_hwdevice_ctx_create_derived(QSV <- D3D11VA)` ends in
|
||||
// `MFXVideoCORE_SetHandle`, and MFX rejects a device that is not multithread-protected
|
||||
// with `MFX_ERR_UNDEFINED_BEHAVIOR (-16)` — logged only as "Error setting child device
|
||||
// handle", which names neither the cause nor the cure. Measured on Intel UHD 750 /
|
||||
// FFmpeg 7.1.5+libvpl: identical device, protection off -> derive fails; protection on
|
||||
// -> derive succeeds. `D3D11_CREATE_DEVICE_VIDEO_SUPPORT` makes no difference either way.
|
||||
//
|
||||
// * AMF, which is the DEFAULT path and was already shipping. We deliberately leave
|
||||
// `lock`/`unlock` null so libav installs its `d3d11va_default_lock`, and that lock is
|
||||
// `ID3D11Multithread::Enter`/`Leave` — which are documented no-ops while protection is
|
||||
// off. So the lock libav installs to serialise our capture thread against its encode
|
||||
// thread has been doing nothing at all. It only starts working from here.
|
||||
//
|
||||
// Idempotent, and safe to apply to a device the capturer owns: it only enables the
|
||||
// device's internal critical section (`was` is the previous state, reported once at debug).
|
||||
match device.cast::<ID3D11Multithread>() {
|
||||
Ok(mt) => {
|
||||
let was = mt.SetMultithreadProtected(true);
|
||||
tracing::debug!(
|
||||
previously_protected = was.as_bool(),
|
||||
"D3D11 multithread protection enabled for the libav hwdevice"
|
||||
);
|
||||
}
|
||||
// Pre-11.1 runtimes have no ID3D11Multithread. Nothing to enable, so carry on and let
|
||||
// the QSV derive fail with its own message rather than failing capture here.
|
||||
Err(e) => tracing::warn!(
|
||||
error = %e,
|
||||
"no ID3D11Multithread on this device — QSV zero-copy will not derive"
|
||||
),
|
||||
}
|
||||
|
||||
// Share the capture device. FFmpeg's d3d11va teardown Releases `device`, so hand it an owned
|
||||
// reference (clone = AddRef, forget = don't Release ours). init() fills
|
||||
// device_context / video_device / video_context / the default lock from a non-null device.
|
||||
std::mem::forget(device.clone());
|
||||
(*d11).device = device.as_raw();
|
||||
let r = ffi::av_hwdevice_ctx_init(device_ref);
|
||||
let r = ffi::av_hwdevice_ctx_init(device_ref.as_ptr());
|
||||
if r < 0 {
|
||||
ffi::av_buffer_unref(&mut device_ref);
|
||||
bail!("av_hwdevice_ctx_init(D3D11VA) failed ({r})");
|
||||
}
|
||||
|
||||
let mut frames_ref = ffi::av_hwframe_ctx_alloc(device_ref);
|
||||
if frames_ref.is_null() {
|
||||
ffi::av_buffer_unref(&mut device_ref);
|
||||
bail!("av_hwframe_ctx_alloc(D3D11VA) failed");
|
||||
}
|
||||
let fc = (*frames_ref).data as *mut ffi::AVHWFramesContext;
|
||||
let frames_ref = AvBuffer::from_raw(ffi::av_hwframe_ctx_alloc(device_ref.as_ptr()))
|
||||
.context("av_hwframe_ctx_alloc(D3D11VA) failed")?;
|
||||
let fc = (*frames_ref.as_ptr()).data as *mut ffi::AVHWFramesContext;
|
||||
(*fc).format = ffi::AVPixelFormat::AV_PIX_FMT_D3D11;
|
||||
(*fc).sw_format = sw_format;
|
||||
(*fc).width = w as c_int;
|
||||
@@ -902,42 +947,42 @@ impl D3d11Hw {
|
||||
(*fc).initial_pool_size = pool;
|
||||
let f11 = (*fc).hwctx as *mut AVD3D11VAFramesContext;
|
||||
(*f11).bind_flags = bind_flags;
|
||||
let r = ffi::av_hwframe_ctx_init(frames_ref);
|
||||
let r = ffi::av_hwframe_ctx_init(frames_ref.as_ptr());
|
||||
if r < 0 {
|
||||
ffi::av_buffer_unref(&mut frames_ref);
|
||||
ffi::av_buffer_unref(&mut device_ref);
|
||||
bail!("av_hwframe_ctx_init(D3D11VA) failed ({r})");
|
||||
}
|
||||
Ok(D3d11Hw {
|
||||
device_ref,
|
||||
frames_ref,
|
||||
device_ref,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for D3d11Hw {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `D3d11Hw::new` created
|
||||
// (it bails before constructing `Self` if either alloc/init fails, so a live `D3d11Hw` always
|
||||
// holds both). `av_buffer_unref` drops one reference and nulls the pointer through the `&mut`.
|
||||
// This `Drop` runs exactly once and `D3d11Hw` owns these refs exclusively → no double-free /
|
||||
// use-after-free. Frames are unref'd before the device because the frames ctx internally holds
|
||||
// a ref on the device (refcounted, so the order is sound either way).
|
||||
unsafe {
|
||||
ffi::av_buffer_unref(&mut self.frames_ref);
|
||||
ffi::av_buffer_unref(&mut self.device_ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
// No `Drop` for `D3d11Hw`: each `AvBuffer` field unrefs itself, in declaration order (frames, then
|
||||
// device — see the field comment). The hand-written unref pair this replaced had to be kept in sync
|
||||
// with every failure branch in `new`; now there is one unref path per ref and none can skip it.
|
||||
|
||||
struct ZeroCopyInner {
|
||||
vendor: WinVendor,
|
||||
/// QSV only: the QSV device + frames ctx derived from the D3D11VA ones (the encoder's real
|
||||
/// input). `None` for AMF, which takes the D3D11 frames directly — the nullable raw pointers
|
||||
/// these replaced said the same thing, but only in a comment.
|
||||
///
|
||||
/// FIELD ORDER IS LOAD-BEARING: frames before device, and BOTH before `enc`/`hw`. Fields drop
|
||||
/// in declaration order, and that sequence reproduces the hand-written `Drop` this replaced,
|
||||
/// which ran ahead of every field and so released the derived QSV pair before the encoder and
|
||||
/// the D3D11 refs. Everything holds its own reference, so refcounting makes any order sound;
|
||||
/// the ordering is pinned so a reorder cannot quietly change what ships.
|
||||
qsv_frames: Option<AvBuffer>,
|
||||
/// Unlike `qsv_frames` (which the send path reads to tag each mapped frame), this one is held
|
||||
/// purely as an owner: the frames ctx and the encoder each took their own ref, so nothing reads
|
||||
/// it again — it exists so the QSV device outlives both and is unref'd exactly once. Same
|
||||
/// reasoning as the decoders' `hw_device`: removing the field would free the device early, and
|
||||
/// an underscore name would hide what it holds.
|
||||
#[allow(dead_code)]
|
||||
qsv_device: Option<AvBuffer>,
|
||||
enc: encoder::video::Encoder,
|
||||
hw: D3d11Hw,
|
||||
/// QSV only: the QSV device + frames ctx derived from the D3D11VA ones (the encoder's real
|
||||
/// input). `None` for AMF (which takes the D3D11 frames directly).
|
||||
qsv_device: *mut ffi::AVBufferRef,
|
||||
qsv_frames: *mut ffi::AVBufferRef,
|
||||
ctx: ID3D11DeviceContext,
|
||||
/// The pool's fixed sw_format (NV12 8-bit / P010 10-bit). A captured frame whose format differs
|
||||
/// (the capturer's video-processor fell back to Bgra/Rgb10a2) cannot be CopySubresourceRegion'd
|
||||
@@ -971,28 +1016,27 @@ impl ZeroCopyInner {
|
||||
};
|
||||
let bind_flags = pool_bind_flags(vendor);
|
||||
const POOL: c_int = 8;
|
||||
// SAFETY: `D3d11Hw::new` wraps the capturer's `device` as a D3D11VA hwdevice (handing FFmpeg an
|
||||
// owned AddRef of it, balanced by FFmpeg's teardown Release) and builds an owned
|
||||
// device_ref/frames_ref pair freed by `D3d11Hw::Drop`; `hw` is a local, so it is dropped (and
|
||||
// both refs freed) on every early `return Err`. For QSV, `av_hwdevice_ctx_create_derived` and
|
||||
// `av_hwframe_ctx_create_derived` fill the null-initialised `qsv_device`/`qsv_frames` out-params
|
||||
// only on success (`r >= 0` checked); on the frames-derive failure we unref the already-created
|
||||
// `qsv_device` before bailing. `open_win_encoder` internally `av_buffer_ref`s the dev/frames
|
||||
// refs it is given (so ownership of `hw`'s and the derived refs stays here), and on its failure
|
||||
// we unref the still-owned derived `qsv_frames`/`qsv_device` (null for AMF → skipped) and return
|
||||
// — `hw` then drops its D3D11 refs. On success the derived refs are moved into `ZeroCopyInner`
|
||||
// (freed in its `Drop`) and the encoder holds its own AddRef'd copies. Every `AVBufferRef` is
|
||||
// unref'd exactly once across all paths — no leak, no double-free.
|
||||
// SAFETY: `D3d11Hw::new` wraps the capturer's `device` as a D3D11VA hwdevice (handing FFmpeg
|
||||
// an owned AddRef of it, balanced by FFmpeg's teardown Release) and returns an owned
|
||||
// frames_ref/device_ref pair. For QSV, `av_hwdevice_ctx_create_derived` /
|
||||
// `av_hwframe_ctx_create_derived` fill their null-initialised out-params only on success
|
||||
// (`r >= 0` checked) and each result is taken into an `AvBuffer` immediately. From there
|
||||
// every handle in this function is owned by a local, so each early `bail!`/`?` releases
|
||||
// exactly what exists at that point, in reverse order — there is no cleanup code on any
|
||||
// failure branch to keep in step. `open_win_encoder` takes its OWN refs of the dev/frames
|
||||
// pointers it is handed, so lending them via `as_ptr()` transfers nothing; on success the
|
||||
// owners move into `ZeroCopyInner` and are released by its field drops. Every `AVBufferRef`
|
||||
// is still unref'd exactly once on every path — the difference is that it is now the type
|
||||
// system enforcing it rather than a comment.
|
||||
unsafe {
|
||||
let hw = D3d11Hw::new(device, sw_av, bind_flags, width, height, POOL)?;
|
||||
let (pix_fmt, dev_ref, frames_ref, mut qsv_device, mut qsv_frames) = match vendor {
|
||||
WinVendor::Amf => (
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_D3D11,
|
||||
hw.device_ref,
|
||||
hw.frames_ref,
|
||||
ptr::null_mut(),
|
||||
ptr::null_mut(),
|
||||
),
|
||||
// Own the derived QSV pair (or nothing, on AMF). Keeping ownership here and deriving the
|
||||
// encoder's pointers from it below is the whole point: the tuple this replaced handed
|
||||
// the SAME two pointers out twice — once as the encoder's dev/frames args and once as
|
||||
// the pair moved into `Self` — which is fine for raw pointers and would be two owners
|
||||
// for `AvBuffer`.
|
||||
let (qsv_frames, qsv_device) = match vendor {
|
||||
WinVendor::Amf => (None, None),
|
||||
WinVendor::Qsv => {
|
||||
// Derive a QSV device that SHARES the D3D11 device, and a QSV frames ctx derived
|
||||
// from the D3D11 frames pool (auto-mapped 1:1). The encoder takes AV_PIX_FMT_QSV.
|
||||
@@ -1000,34 +1044,45 @@ impl ZeroCopyInner {
|
||||
let r = ffi::av_hwdevice_ctx_create_derived(
|
||||
&mut qsv_device,
|
||||
ffi::AVHWDeviceType::AV_HWDEVICE_TYPE_QSV,
|
||||
hw.device_ref,
|
||||
hw.device_ref.as_ptr(),
|
||||
0,
|
||||
);
|
||||
if r < 0 {
|
||||
bail!("derive QSV device from D3D11VA: {}", ffmpeg::Error::from(r));
|
||||
}
|
||||
let qsv_device = AvBuffer::from_raw(qsv_device)
|
||||
.context("av_hwdevice_ctx_create_derived(QSV) gave no device")?;
|
||||
let mut qsv_frames: *mut ffi::AVBufferRef = ptr::null_mut();
|
||||
let r = ffi::av_hwframe_ctx_create_derived(
|
||||
&mut qsv_frames,
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_QSV,
|
||||
qsv_device,
|
||||
hw.frames_ref,
|
||||
qsv_device.as_ptr(),
|
||||
hw.frames_ref.as_ptr(),
|
||||
ffi::AV_HWFRAME_MAP_DIRECT as c_int,
|
||||
);
|
||||
if r < 0 {
|
||||
ffi::av_buffer_unref(&mut qsv_device);
|
||||
// `qsv_device` drops here — the hand-written unref this replaced was the
|
||||
// single easiest line in the function to forget.
|
||||
bail!("derive QSV frames from D3D11VA: {}", ffmpeg::Error::from(r));
|
||||
}
|
||||
(
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_QSV,
|
||||
qsv_device,
|
||||
qsv_frames,
|
||||
qsv_device,
|
||||
qsv_frames,
|
||||
)
|
||||
let qsv_frames = AvBuffer::from_raw(qsv_frames)
|
||||
.context("av_hwframe_ctx_create_derived(QSV) gave no frames ctx")?;
|
||||
(Some(qsv_frames), Some(qsv_device))
|
||||
}
|
||||
};
|
||||
let enc = match open_win_encoder(
|
||||
// BORROWED views for the encoder — `open_win_encoder` takes its own refs of whatever it
|
||||
// is handed, so ownership stays with `qsv_*`/`hw` either way.
|
||||
let (pix_fmt, dev_ref, frames_ref) = match (&qsv_device, &qsv_frames) {
|
||||
(Some(d), Some(f)) => (ffi::AVPixelFormat::AV_PIX_FMT_QSV, d.as_ptr(), f.as_ptr()),
|
||||
_ => (
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_D3D11,
|
||||
hw.device_ref.as_ptr(),
|
||||
hw.frames_ref.as_ptr(),
|
||||
),
|
||||
};
|
||||
// `?` is enough now: on failure `qsv_frames`/`qsv_device` and `hw` all drop on the way
|
||||
// out, which is what the hand-written null-checked unref pair here used to do.
|
||||
let enc = open_win_encoder(
|
||||
vendor,
|
||||
codec,
|
||||
width,
|
||||
@@ -1039,18 +1094,7 @@ impl ZeroCopyInner {
|
||||
ten_bit,
|
||||
dev_ref,
|
||||
frames_ref,
|
||||
) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
if !qsv_frames.is_null() {
|
||||
ffi::av_buffer_unref(&mut qsv_frames);
|
||||
}
|
||||
if !qsv_device.is_null() {
|
||||
ffi::av_buffer_unref(&mut qsv_device);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
)?;
|
||||
tracing::info!(
|
||||
encoder = vendor.encoder_name(codec),
|
||||
"{} encode active ({width}x{height}@{fps}, zero-copy D3D11 {} path)",
|
||||
@@ -1059,10 +1103,10 @@ impl ZeroCopyInner {
|
||||
);
|
||||
Ok(ZeroCopyInner {
|
||||
vendor,
|
||||
qsv_frames,
|
||||
qsv_device,
|
||||
enc,
|
||||
hw,
|
||||
qsv_device,
|
||||
qsv_frames,
|
||||
ctx: immediate_context(device),
|
||||
pool_format,
|
||||
})
|
||||
@@ -1089,7 +1133,7 @@ impl ZeroCopyInner {
|
||||
if d3d.is_null() {
|
||||
bail!("av_frame_alloc(d3d11) failed");
|
||||
}
|
||||
let r = ffi::av_hwframe_get_buffer(self.hw.frames_ref, d3d, 0);
|
||||
let r = ffi::av_hwframe_get_buffer(self.hw.frames_ref.as_ptr(), d3d, 0);
|
||||
if r < 0 {
|
||||
ffi::av_frame_free(&mut d3d);
|
||||
bail!("av_hwframe_get_buffer(D3D11) failed ({r})");
|
||||
@@ -1121,8 +1165,17 @@ impl ZeroCopyInner {
|
||||
ffi::av_frame_free(&mut d3d);
|
||||
bail!("av_frame_alloc(qsv) failed");
|
||||
}
|
||||
// Always `Some` on this arm — `open` fills the pair for `WinVendor::Qsv` and
|
||||
// leaves it `None` only for AMF — but say so with a bail rather than an unwrap,
|
||||
// matching the null check above it. The `Option` is what the raw pointer's
|
||||
// "null means AMF" convention was already encoding.
|
||||
let Some(qsv_frames) = self.qsv_frames.as_ref() else {
|
||||
ffi::av_frame_free(&mut qsv);
|
||||
ffi::av_frame_free(&mut d3d);
|
||||
bail!("QSV send path without a derived QSV frames context");
|
||||
};
|
||||
(*qsv).format = ffi::AVPixelFormat::AV_PIX_FMT_QSV as c_int;
|
||||
(*qsv).hw_frames_ctx = ffi::av_buffer_ref(self.qsv_frames);
|
||||
(*qsv).hw_frames_ctx = ffi::av_buffer_ref(qsv_frames.as_ptr());
|
||||
// The map flags are a bindgen enum (no BitOr) — cast each to int before OR-ing.
|
||||
let r = ffi::av_hwframe_map(
|
||||
qsv,
|
||||
@@ -1153,23 +1206,10 @@ impl ZeroCopyInner {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ZeroCopyInner {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `qsv_frames`/`qsv_device` are the derived QSV `AVBufferRef`s (or null for AMF); each
|
||||
// is `av_buffer_unref`'d once here (nulling the pointer through the `&mut`) — `ZeroCopyInner`
|
||||
// owns these handles exclusively and this `Drop` runs once, so no double-free. The `enc` and
|
||||
// `hw` fields free the encoder's AddRef'd copies and the D3D11 device/frames refs through their
|
||||
// own `Drop`, so all references stay balanced.
|
||||
unsafe {
|
||||
if !self.qsv_frames.is_null() {
|
||||
ffi::av_buffer_unref(&mut self.qsv_frames);
|
||||
}
|
||||
if !self.qsv_device.is_null() {
|
||||
ffi::av_buffer_unref(&mut self.qsv_device);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No `Drop` for `ZeroCopyInner`: the two `Option<AvBuffer>`s unref themselves when present and do
|
||||
// nothing when `None` (AMF), which is what the hand-written null checks amounted to. Field order
|
||||
// (see the struct) keeps the release sequence identical: QSV frames, QSV device, then the encoder's
|
||||
// AddRef'd copies via `enc`, then the D3D11 pair via `hw`.
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -1448,6 +1488,128 @@ impl Encoder for FfmpegWinEncoder {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Pick the Intel adapter, or any hardware D3D11 adapter, for the probes below.
|
||||
///
|
||||
/// Not `EnumAdapters1(0)`: a punktfunk host box also enumerates our own virtual-display adapter,
|
||||
/// and "Microsoft Basic Render Driver" (vendor 0x1414) is the software rasterizer, which has no
|
||||
/// video engine at all.
|
||||
///
|
||||
/// # Safety
|
||||
/// Calls the DXGI enumeration FFI and `make_device`; every result is checked before use and no
|
||||
/// alias to the adapter outlives the call.
|
||||
#[cfg(test)]
|
||||
unsafe fn test_hw_device(prefer_vendor: u32) -> Option<ID3D11Device> {
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIFactory1};
|
||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
|
||||
let mut preferred = None;
|
||||
let mut fallback = None;
|
||||
for i in 0.. {
|
||||
let Ok(adapter) = factory.EnumAdapters1(i) else {
|
||||
break; // DXGI_ERROR_NOT_FOUND — end of the list
|
||||
};
|
||||
let Ok(desc) = adapter.GetDesc1() else {
|
||||
continue;
|
||||
};
|
||||
let name = String::from_utf16_lossy(&desc.Description)
|
||||
.trim_end_matches('\0')
|
||||
.to_string();
|
||||
eprintln!("adapter {i}: vendor={:#06x} {name}", desc.VendorId);
|
||||
if desc.VendorId == prefer_vendor && preferred.is_none() {
|
||||
preferred = Some(adapter);
|
||||
} else if desc.VendorId != 0x1414 && fallback.is_none() {
|
||||
fallback = Some(adapter);
|
||||
}
|
||||
}
|
||||
let adapter = preferred.or(fallback)?;
|
||||
pf_frame::dxgi::make_device(&adapter).ok().map(|(d, _c)| d)
|
||||
}
|
||||
|
||||
/// Construct/drop `D3d11Hw` repeatedly on real silicon — the D3D11VA half of the RAII change,
|
||||
/// and the half that IS reachable on any GPU.
|
||||
///
|
||||
/// `D3d11Hw` owns a hwdevice + frames-pool pair through `AvBuffer`, and it is shared by both
|
||||
/// Windows zero-copy vendors, so this covers the AMF path's ownership too without needing AMD
|
||||
/// hardware. Looping is the point, as with `cuda_hw_alloc_drop_cycles`: a double-unref aborts in
|
||||
/// the CRT, a missed one leaks a device and a pool of eight surfaces per iteration.
|
||||
///
|
||||
/// `#[ignore]`d (needs a real D3D11 GPU):
|
||||
/// `cargo test -p pf-encode --features amf-qsv d3d11hw_alloc_drop_cycles -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore = "needs a real D3D11 GPU (run on a GPU host, not the build box)"]
|
||||
fn d3d11hw_alloc_drop_cycles() {
|
||||
// SAFETY: see `test_hw_device`; the returned device outlives every `D3d11Hw` built below.
|
||||
let device = unsafe { test_hw_device(0x8086) }.expect("a hardware D3D11 adapter");
|
||||
for i in 0..8 {
|
||||
// SAFETY: `D3d11Hw::new` needs libav initialised (it is, statically, by the ffmpeg-next
|
||||
// crate's first use here) and a live `ID3D11Device`, which `device` is for the whole
|
||||
// loop. NV12 at 640x480 with an 8-surface pool are valid pool parameters. The handle
|
||||
// drops at the end of each iteration — that release is what is under test.
|
||||
let hw = unsafe {
|
||||
D3d11Hw::new(
|
||||
&device,
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
pool_bind_flags(WinVendor::Amf),
|
||||
640,
|
||||
480,
|
||||
8,
|
||||
)
|
||||
}
|
||||
.unwrap_or_else(|e| panic!("D3d11Hw::new failed on iteration {i}: {e:#}"));
|
||||
assert!(!hw.device_ref.as_ptr().is_null(), "device ref went null");
|
||||
assert!(!hw.frames_ref.as_ptr().is_null(), "frames ref went null");
|
||||
}
|
||||
eprintln!("8 D3d11Hw alloc/drop cycles completed without abort");
|
||||
}
|
||||
|
||||
/// Construct/drop `ZeroCopyInner` on the QSV path, repeatedly, on real Intel silicon.
|
||||
///
|
||||
/// This is the one path in the crate where ownership was genuinely ambiguous: `open` builds a
|
||||
/// `D3d11Hw` (D3D11VA device + frames pair) and then DERIVES a QSV device + frames ctx from it,
|
||||
/// and the tuple it used to return handed those two derived pointers out **twice** — once as
|
||||
/// the encoder's `dev_ref`/`frames_ref`, once as the pair moved into `Self`. Harmless while they
|
||||
/// were raw pointers; two owners once they are `AvBuffer`. Looping construct/drop is what tells
|
||||
/// the difference apart: a double-unref aborts inside the CRT, a missed one leaks an Intel
|
||||
/// device per session.
|
||||
///
|
||||
/// Nothing else reaches it. `zerocopy_enabled` defaults QSV **off**, so the normal encode path
|
||||
/// never builds one on Intel, and the native VPL backend (`enc/windows/qsv.rs`) supersedes this
|
||||
/// whole file unless `PUNKTFUNK_QSV_FFMPEG=1`. Calling `open` directly sidesteps both gates so
|
||||
/// the ownership itself is what gets exercised.
|
||||
///
|
||||
/// This test is also the regression guard for the D3D11 multithread-protection fix in
|
||||
/// `D3d11Hw::new`. Without it the QSV derive dies in `MFXVideoCORE_SetHandle` with
|
||||
/// `MFX_ERR_UNDEFINED_BEHAVIOR (-16)`, surfacing only as libav's "Error setting child device
|
||||
/// handle" — a message that names neither the cause nor the cure. If this starts failing that
|
||||
/// way again, look there first.
|
||||
///
|
||||
/// `#[ignore]`d (needs a real Intel QSV device):
|
||||
/// `cargo test -p pf-encode --features amf-qsv zerocopy_qsv_alloc_drop_cycles -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore = "needs a real Intel QSV device (run on an Intel host, not the build box)"]
|
||||
fn zerocopy_qsv_alloc_drop_cycles() {
|
||||
// SAFETY: see `test_hw_device`; the device outlives every `ZeroCopyInner` built below.
|
||||
let device = unsafe { test_hw_device(0x8086) }.expect("an Intel D3D11 adapter");
|
||||
for i in 0..8 {
|
||||
let zc = ZeroCopyInner::open(
|
||||
WinVendor::Qsv,
|
||||
Codec::H264,
|
||||
PixelFormat::Bgrx,
|
||||
640,
|
||||
480,
|
||||
30,
|
||||
8_000_000,
|
||||
8,
|
||||
&device,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("ZeroCopyInner::open(QSV) failed on iteration {i}: {e:#}"));
|
||||
// The QSV arm must have derived BOTH halves — an `Option` that came back `None` here
|
||||
// would mean the AMF branch was taken and the derived-pair ownership never ran.
|
||||
assert!(zc.qsv_frames.is_some(), "QSV path derived no frames ctx");
|
||||
assert!(zc.qsv_device.is_some(), "QSV path derived no device");
|
||||
}
|
||||
eprintln!("8 ZeroCopyInner(QSV) alloc/drop cycles completed without abort");
|
||||
}
|
||||
|
||||
/// Zero-copy default matrix: the operator override wins in both directions; unset resolves
|
||||
/// AMF on (on-glass validated) and QSV off (opt-in until validated on Intel glass — the
|
||||
/// probe-never-assume rule).
|
||||
|
||||
@@ -33,6 +33,12 @@
|
||||
//! AU completes within the same tick and `poll` picks it up); under contention completed frames
|
||||
//! queue instead of stalling capture — throughput recovers up to the scheduler-granted share.
|
||||
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is raw `nvEncodeAPI` entry-table + D3D11 calls almost line for line;
|
||||
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
|
||||
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
|
||||
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
@@ -89,6 +95,12 @@ struct EncodeApi {
|
||||
*mut nv::NV_ENC_CAPS_PARAM,
|
||||
*mut core::ffi::c_int,
|
||||
) -> nv::NVENCSTATUS,
|
||||
// The two entry points behind [`probe_codec_support`] — the driver's own list of encode GUIDs
|
||||
// this chip exposes. Mandatory like every other entry: both have existed since NVENC 1.0, so a
|
||||
// driver missing them is broken in ways the rest of this table would not survive either.
|
||||
get_encode_guid_count: unsafe extern "C" fn(*mut c_void, *mut u32) -> nv::NVENCSTATUS,
|
||||
get_encode_guids:
|
||||
unsafe extern "C" fn(*mut c_void, *mut nv::GUID, u32, *mut u32) -> nv::NVENCSTATUS,
|
||||
get_encode_preset_config_ex: unsafe extern "C" fn(
|
||||
*mut c_void,
|
||||
nv::GUID,
|
||||
@@ -203,6 +215,8 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
|
||||
reconfigure_encoder: list.nvEncReconfigureEncoder.ok_or(MISSING)?,
|
||||
destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?,
|
||||
get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?,
|
||||
get_encode_guid_count: list.nvEncGetEncodeGUIDCount.ok_or(MISSING)?,
|
||||
get_encode_guids: list.nvEncGetEncodeGUIDs.ok_or(MISSING)?,
|
||||
get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?,
|
||||
create_bitstream_buffer: list.nvEncCreateBitstreamBuffer.ok_or(MISSING)?,
|
||||
destroy_bitstream_buffer: list.nvEncDestroyBitstreamBuffer.ok_or(MISSING)?,
|
||||
@@ -1965,11 +1979,85 @@ pub fn probe_can_encode_10bit(codec: Codec) -> bool {
|
||||
probe_encode_cap(codec, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE)
|
||||
}
|
||||
|
||||
/// Query ONE NVENC capability for `codec`: creates a throwaway hardware D3D11 device + NVENC
|
||||
/// session on the **selected render adapter**, reads the cap, and tears everything down. `false`
|
||||
/// on any failure (no loadable NVENC, no device, failed open) — the honest answer for a
|
||||
/// Query ONE NVENC capability for `codec` on a throwaway session (see [`with_probe_session`]).
|
||||
/// `false` on any failure (no loadable NVENC, no device, failed open) — the honest answer for a
|
||||
/// capability that couldn't be confirmed.
|
||||
fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
|
||||
with_probe_session(|enc| {
|
||||
let mut param = nv::NV_ENC_CAPS_PARAM {
|
||||
version: nv::NV_ENC_CAPS_PARAM_VER,
|
||||
capsToQuery: cap,
|
||||
reserved: [0; 62],
|
||||
};
|
||||
let mut val: i32 = 0;
|
||||
// SAFETY: `get_encode_caps` reads one scalar cap into `val` (live locals) for the live
|
||||
// session `enc` via the loaded API table (`with_probe_session` sits past `try_api`).
|
||||
unsafe {
|
||||
(api().get_encode_caps)(enc, codec_guid(codec), &mut param, &mut val)
|
||||
.nv_ok()
|
||||
.is_ok()
|
||||
&& val != 0
|
||||
}
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Which codecs **this GPU's** NVENC can actually encode, asked of the driver itself
|
||||
/// (`nvEncGetEncodeGUIDs`) on a throwaway session — the Windows twin of the Linux
|
||||
/// `nvenc_cuda::probe_support` codec half, probing the **selected render adapter** (the GPU the
|
||||
/// session will really encode on) rather than CUDA device 0. Same field bug on both OSes: the
|
||||
/// static `H.264 | HEVC | AV1` superset advertised HEVC on a 1st-gen Maxwell, and a client that
|
||||
/// negotiated it got a dead session instead of a stream.
|
||||
///
|
||||
/// Every failure path returns "nothing probed", which [`crate::CodecSupport::wire_mask`] turns
|
||||
/// into `None` so the caller keeps the old static superset — a broken probe must never be able to
|
||||
/// narrow an NVIDIA host's advertisement to nothing. Cached per selected GPU by the caller
|
||||
/// ([`crate::windows_codec_support`]).
|
||||
pub(crate) fn probe_codec_support() -> crate::CodecSupport {
|
||||
let unknown = crate::CodecSupport {
|
||||
h264: false,
|
||||
h265: false,
|
||||
av1: false,
|
||||
};
|
||||
with_probe_session(|enc| {
|
||||
// SAFETY: all NVENC calls go through the loaded API table against the live session `enc`;
|
||||
// `count`/`written` are live locals, and `guids` is sized to the count the driver just
|
||||
// reported, its pointer valid for that many `GUID`s (matching `guidArraySize`).
|
||||
unsafe {
|
||||
let mut count = 0u32;
|
||||
let counted = (api().get_encode_guid_count)(enc, &mut count)
|
||||
.nv_ok()
|
||||
.is_ok();
|
||||
let mut guids = vec![nv::GUID::default(); count as usize];
|
||||
let mut written = 0u32;
|
||||
let listed = counted
|
||||
&& count > 0
|
||||
&& (api().get_encode_guids)(enc, guids.as_mut_ptr(), count, &mut written)
|
||||
.nv_ok()
|
||||
.is_ok();
|
||||
if !listed {
|
||||
tracing::warn!(
|
||||
"NVENC codec probe: driver listed no encode GUIDs — keeping the static \
|
||||
advertisement"
|
||||
);
|
||||
return unknown;
|
||||
}
|
||||
guids.truncate(written as usize);
|
||||
crate::CodecSupport {
|
||||
h264: guids.contains(&codec_guid(Codec::H264)),
|
||||
h265: guids.contains(&codec_guid(Codec::H265)),
|
||||
av1: guids.contains(&codec_guid(Codec::Av1)),
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap_or(unknown)
|
||||
}
|
||||
|
||||
/// Open a throwaway NVENC session on a fresh hardware D3D11 device, hand it to `f`, and tear
|
||||
/// everything down. `None` = no loadable NVENC / no device / failed open — the caller supplies
|
||||
/// the honest "couldn't confirm" answer. Shared by [`probe_encode_cap`] and
|
||||
/// [`probe_codec_support`], so every advertisement probe opens sessions exactly one way.
|
||||
fn with_probe_session<T>(f: impl FnOnce(*mut c_void) -> T) -> Option<T> {
|
||||
// Same exclusion as `init_session`: this opens a real (throwaway) session, so it must never
|
||||
// overlap a zombie reap that could be destroying the very address the driver hands us.
|
||||
let _gate = DRIVER_SESSION_GATE
|
||||
@@ -1983,20 +2071,20 @@ fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
|
||||
D3D11CreateDevice, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
||||
// No loadable NVENC on this box (non-NVIDIA / no driver) → the honest 4:4:4 answer is "no".
|
||||
// This is also the `api()` gate for every NVENC call below.
|
||||
// No loadable NVENC on this box (non-NVIDIA / no driver) → nothing to confirm.
|
||||
// This is also the `api()` gate for every NVENC call below and inside `f`.
|
||||
if try_api().is_err() {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
// SAFETY: a self-contained probe owning every handle it creates. `CreateDXGIFactory1`/
|
||||
// `EnumAdapterByLuid` return owned COM objects or err (→ default-adapter fallback).
|
||||
// `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE)
|
||||
// fills `device` or returns Err (→ false). `open_encode_session_ex` opens an NVENC session
|
||||
// against that device's raw pointer (valid while `device` is held) or errors (→ false, after
|
||||
// fills `device` or returns Err (→ None). `open_encode_session_ex` opens an NVENC session
|
||||
// against that device's raw pointer (valid while `device` is held) or errors (→ None, after
|
||||
// destroying any residue session the failed open left — the docs require it).
|
||||
// `get_encode_caps` reads one scalar cap into `val` via the loaded API table.
|
||||
// `destroy_encoder` frees the session exactly once; `device`/its context drop with the COM
|
||||
// wrappers. No handle escapes this call and nothing runs concurrently.
|
||||
// `destroy_encoder` frees the session exactly once, after `f` returns (so `enc` is live for
|
||||
// the whole closure call); `device`/its context drop with the COM wrappers. No handle
|
||||
// escapes this call and nothing runs concurrently (the gate above).
|
||||
unsafe {
|
||||
// Probe on the SELECTED render adapter — the GPU the session will actually encode on
|
||||
// (web-console preference / PUNKTFUNK_RENDER_ADAPTER / max VRAM). The OS default adapter
|
||||
@@ -2032,9 +2120,9 @@ fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
|
||||
),
|
||||
};
|
||||
if created.is_err() {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
let Some(device) = device else { return false };
|
||||
let device = device?;
|
||||
let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
|
||||
version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
|
||||
deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_DIRECTX,
|
||||
@@ -2051,23 +2139,14 @@ fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
|
||||
if !enc.is_null() {
|
||||
let _ = (api().destroy_encoder)(enc);
|
||||
}
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
// Availability probe, but a real session open all the same: it proves the driver accepted
|
||||
// this build's version word, which is what rules a skew out later (see `nvenc_status`).
|
||||
nvenc_status::note_session_opened();
|
||||
let mut param = nv::NV_ENC_CAPS_PARAM {
|
||||
version: nv::NV_ENC_CAPS_PARAM_VER,
|
||||
capsToQuery: cap,
|
||||
reserved: [0; 62],
|
||||
};
|
||||
let mut val: i32 = 0;
|
||||
let ok = (api().get_encode_caps)(enc, codec_guid(codec), &mut param, &mut val)
|
||||
.nv_ok()
|
||||
.is_ok()
|
||||
&& val != 0;
|
||||
let out = f(enc);
|
||||
let _ = (api().destroy_encoder)(enc);
|
||||
ok
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2356,4 +2435,36 @@ mod tests {
|
||||
"C:\\Users\\Public\\nvenc420_probe.h265",
|
||||
);
|
||||
}
|
||||
|
||||
/// ON-HARDWARE: the codec-advertisement probe against the real driver — the Windows twin of
|
||||
/// the Linux `nvenc_codec_probe_reports_real_gpu_support` test, same invariants: every
|
||||
/// NVENC-capable GPU ever made encodes H.264 (a `false` means the enumeration is broken and
|
||||
/// would silently narrow the advertisement), and the answer must be stable across calls since
|
||||
/// one cached answer drives every negotiation. Prints the mask so a run on an OLD card
|
||||
/// (Maxwell GM107 = h264 only, the GPU this probe exists for) is self-documenting. Run:
|
||||
/// cargo test -p pf-encode --features nvenc --release -- --ignored nvenc_codec_probe --nocapture
|
||||
/// (`--release` is REQUIRED on Windows, and pre-dates this test: the debug lib-test link
|
||||
/// fails LNK2019 because the sdk crate's unused lazy loader references the NvEncodeAPI
|
||||
/// imports that runtime loading deliberately avoids — debug `/OPT:NOREF` keeps the dead
|
||||
/// COMDAT, release strips it. Windows CI gates pf-encode with clippy for the same reason.)
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.173)"]
|
||||
fn nvenc_codec_probe_reports_real_gpu_support() {
|
||||
let caps = probe_codec_support();
|
||||
eprintln!(
|
||||
"NVENC (Windows) probe: h264={} h265={} av1={}",
|
||||
caps.h264, caps.h265, caps.av1
|
||||
);
|
||||
assert!(
|
||||
caps.h264,
|
||||
"every NVENC generation encodes H.264 — a false here means the GUID enumeration \
|
||||
failed, which would narrow the host's codec advertisement"
|
||||
);
|
||||
let again = probe_codec_support();
|
||||
assert_eq!(
|
||||
(caps.h264, caps.h265, caps.av1),
|
||||
(again.h264, again.h265, again.av1),
|
||||
"the probe must be stable — it is cached once and drives every later negotiation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
//! The capture side (a BGRA→YUV CSC into two shareable plane textures + a shared fence, gated on the
|
||||
//! pyrowave session flag) lives in `pf-capture` (`windows/idd_push.rs`); the CbCr plane + fence ride
|
||||
//! the frame on [`pf_frame::dxgi::D3d11Frame::pyro`], the Y plane on `D3d11Frame::texture`.
|
||||
// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
|
||||
// Cargo.toml). This body is `pyrowave-sys` C-API plus D3D11/Vulkan interop calls almost line for
|
||||
// line; narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only
|
||||
// restate the signature. Clearing this file means DELETING the markers that carry no caller
|
||||
// contract, not wrapping the calls — until then the lint is off HERE and enforced everywhere else.
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
// Every `unsafe` block in this module carries a `// SAFETY:` proof (the crate root enforces it).
|
||||
|
||||
use crate::pyrowave_wire;
|
||||
@@ -159,6 +166,9 @@ pub struct PyroWaveEncoder {
|
||||
frame_budget: usize,
|
||||
/// Datagram-aligned mode (plan §4.4): packetize at this boundary. `None` = one dense packet/AU.
|
||||
wire_chunk: Option<usize>,
|
||||
/// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the
|
||||
/// WIRE, not just the raw bitstream (see [`pyrowave_wire::WireBudget`]).
|
||||
wire_budget: pyrowave_wire::WireBudget,
|
||||
bitstream: Vec<u8>,
|
||||
pending: VecDeque<EncodedFrame>,
|
||||
}
|
||||
@@ -288,6 +298,7 @@ impl PyroWaveEncoder {
|
||||
hdr16,
|
||||
frame_budget,
|
||||
wire_chunk: None,
|
||||
wire_budget: pyrowave_wire::WireBudget::new(),
|
||||
bitstream: Vec::new(),
|
||||
pending: VecDeque::new(),
|
||||
})
|
||||
@@ -441,6 +452,16 @@ impl PyroWaveEncoder {
|
||||
///
|
||||
/// # Safety
|
||||
/// Runs on the single encode thread; all pyrowave calls take handles this struct owns.
|
||||
/// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the
|
||||
/// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is
|
||||
/// a promise about the wire, not the raw bitstream (see [`pyrowave_wire::WireBudget`]).
|
||||
fn rate_budget(&self) -> usize {
|
||||
match self.wire_chunk {
|
||||
Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024),
|
||||
None => self.frame_budget,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
|
||||
// A failed `reset()` leaves the encoder destroyed and null — fail cleanly rather than
|
||||
// handing null to pyrowave (see the Linux twin).
|
||||
@@ -616,7 +637,7 @@ impl PyroWaveEncoder {
|
||||
sync: std::mem::zeroed(),
|
||||
};
|
||||
let rc = pw::pyrowave_rate_control {
|
||||
maximum_bitstream_size: self.frame_budget,
|
||||
maximum_bitstream_size: self.rate_budget(),
|
||||
};
|
||||
pw_check(
|
||||
pw::pyrowave_encoder_encode_gpu_synchronous(
|
||||
@@ -664,6 +685,10 @@ impl PyroWaveEncoder {
|
||||
}
|
||||
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
|
||||
let au = pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
|
||||
if self.wire_chunk.is_some() {
|
||||
let raw: usize = pkts.iter().map(|&(_, s)| s).sum();
|
||||
self.wire_budget.observe(raw, au.len());
|
||||
}
|
||||
self.pending.push_back(EncodedFrame {
|
||||
data: au,
|
||||
pts_ns: frame.pts_ns,
|
||||
|
||||
+233
-98
@@ -108,7 +108,21 @@ impl Codec {
|
||||
return m & pref_ceiling;
|
||||
}
|
||||
}
|
||||
// NVENC (static superset, like GameStream) — or an empty VAAPI probe (see above).
|
||||
// NVENC: ask the DRIVER which codecs this chip's encoder exposes, the same way the
|
||||
// VAAPI arm above does — a static superset advertised HEVC on a 1st-gen Maxwell
|
||||
// (HEVC needs 2nd-gen Maxwell+, AV1 needs Ada+), and a client that believed it got
|
||||
// ~15 s of blank video and a disconnect instead of a stream. Fails OPEN: a probe
|
||||
// that can't answer (no direct-SDK build, no CUDA, an old driver) yields `None` and
|
||||
// leaves the historical superset standing, so this can only ever narrow the
|
||||
// advertisement to something the GPU really encodes.
|
||||
#[cfg(feature = "nvenc")]
|
||||
if backend == LinuxBackend::Nvenc {
|
||||
if let Some(m) = nvenc_codec_support().wire_mask() {
|
||||
return m & pref_ceiling;
|
||||
}
|
||||
}
|
||||
// NVENC without the probe (no `nvenc` feature / probe declined) — or an empty VAAPI
|
||||
// probe (see above): the static superset, like GameStream.
|
||||
GPU_SUPERSET & pref_ceiling
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -121,7 +135,8 @@ impl Codec {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
// NVENC (static superset, like GameStream) — or an empty AMF/QSV probe (see above).
|
||||
// An unprobed backend (NVENC without the `nvenc` feature) — or an empty probe
|
||||
// (see above): the static superset, like GameStream.
|
||||
GPU_SUPERSET
|
||||
}
|
||||
// The macOS dev/test host has no GPU encode backend — keep the pre-probe advertisement.
|
||||
@@ -342,18 +357,27 @@ fn open_video_backend_linux(
|
||||
// Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
|
||||
// its errors crisply instead of silently trying the other).
|
||||
// AMD/Intel opener. Default = libav VAAPI. With `--features vulkan-encode` +
|
||||
// PUNKTFUNK_VULKAN_ENCODE, an HEVC session instead opens the raw Vulkan Video backend (real
|
||||
// RFI loss recovery the VAAPI path can't express); a failed open falls back to VAAPI so the
|
||||
// stream never dies over the new path. `format`/`bit_depth`/`chroma` only matter to VAAPI —
|
||||
// the Vulkan backend imports the dmabuf and does its own 8-bit 4:2:0 CSC.
|
||||
// PUNKTFUNK_VULKAN_ENCODE, an HEVC/AV1 session instead opens the raw Vulkan Video backend
|
||||
// (real RFI loss recovery the VAAPI path can't express); a failed open falls back to VAAPI so
|
||||
// the stream never dies over the new path. `format`/`bit_depth`/`chroma` only matter to VAAPI
|
||||
// — the Vulkan backend imports the dmabuf and does its own CSC.
|
||||
let open_amd_intel = || -> Result<(Box<dyn Encoder>, &'static str)> {
|
||||
// An HDR session (10-bit + a PQ/BT.2020 capture format) must skip the Vulkan Video
|
||||
// backend — it hardcodes an 8-bit 4:2:0 BT.709 CSC — and take the libav VAAPI path,
|
||||
// which has the P010/Main10/PQ wiring. SDR sessions keep the Vulkan default.
|
||||
// HDR (10-bit + a PQ/BT.2020 capture format) keeps this backend for EITHER codec, as
|
||||
// long as the device says it can: the compute CSC has a BT.2020 10-bit variant, the EFC
|
||||
// has a BT.2020 conversion, and the session opens Main10 / AV1-at-10. That keeps the two
|
||||
// things an HDR session would otherwise lose — real RFI recovery, and the compute CSC's
|
||||
// cursor blend, which is the only way a gamescope pointer reaches the stream (gamescope
|
||||
// has no embedded-cursor mode to fall back to).
|
||||
//
|
||||
// `vulkan_encode_available_at` is the device's own answer to the same profile query the
|
||||
// open will make, so this is a prediction that cannot disagree with reality — and a `no`
|
||||
// routes to libav VAAPI here rather than burning a failed session open first.
|
||||
#[cfg(feature = "vulkan-encode")]
|
||||
let ten_bit_session = bit_depth == 10 && format.is_hdr_rgb10();
|
||||
#[cfg(feature = "vulkan-encode")]
|
||||
if matches!(codec, Codec::H265 | Codec::Av1)
|
||||
&& vulkan_encode_enabled()
|
||||
&& !(bit_depth == 10 && format.is_hdr_rgb10())
|
||||
&& vulkan_encode_available_at(codec, ten_bit_session)
|
||||
{
|
||||
match vulkan_video::VulkanVideoEncoder::open(
|
||||
codec,
|
||||
@@ -987,6 +1011,33 @@ pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Can this host's encode path ingest a **packed 10-bit PQ/BT.2020 CUDA payload** — i.e. may an
|
||||
/// HDR capture stay zero-copy on NVIDIA?
|
||||
///
|
||||
/// Only the direct-SDK NVENC backend can: it registers the buffer as an `ARGB10`/`ABGR10` input
|
||||
/// surface and does the BT.2020 CSC in the encoder itself. The libav fallback cannot — its HDR
|
||||
/// route builds a **P010** hardware frames context and swscales the RGB into it, so handing it a
|
||||
/// packed-10-bit CUDA buffer would copy 2:10:10:10 words into a P010 surface and stream garbage.
|
||||
/// So when the direct path is compiled out or vetoed (`PUNKTFUNK_NVENC_DIRECT=0`), the capturer
|
||||
/// must NOT build the importer for an HDR session and the frames take the CPU path instead — the
|
||||
/// same route HDR took before the direct path learned 10-bit.
|
||||
///
|
||||
/// Resolved by the host facade into [`pf_capture::ZeroCopyPolicy`], like every other
|
||||
/// encode-backend fact capture is allowed to know (the one-way capture→encode edge).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn linux_hdr_cuda_ok() -> bool {
|
||||
#[cfg(feature = "nvenc")]
|
||||
{
|
||||
// Same two terms `open_nvenc_probed` uses to take the direct arm — minus `cuda`, which is
|
||||
// the very thing the caller is deciding.
|
||||
nvenc_direct_enabled() && !linux_zero_copy_is_vaapi()
|
||||
}
|
||||
#[cfg(not(feature = "nvenc"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the encode backend this session will resolve to composites [`CapturedFrame::cursor`]
|
||||
/// ([`EncoderCaps::blends_cursor`]) — answered BEFORE capture opens, so the host plans cursor
|
||||
/// delivery honestly instead of discovering a cursorless stream after the fact (the
|
||||
@@ -997,8 +1048,9 @@ pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
||||
///
|
||||
/// `cuda_planned` is the caller's prediction of a CUDA capture payload (NVIDIA + zero-copy — the
|
||||
/// prediction `SessionPlan` already makes); `ten_bit` the negotiated depth. Both shift the
|
||||
/// dispatch: a CPU payload keeps NVIDIA on libav NVENC (no blend), and a 10-bit HDR session
|
||||
/// skips Vulkan Video for libav VAAPI's P010/Main10 wiring (no blend).
|
||||
/// dispatch: a CPU payload keeps NVIDIA on libav NVENC (no blend), and a 10-bit session keeps
|
||||
/// Vulkan Video (which blends) only where the device advertises a 10-bit profile for that codec —
|
||||
/// `vulkan_encode_available_at`, the same query the open makes.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) -> bool {
|
||||
// A negotiated PyroWave session routes to that backend before the pref is consulted
|
||||
@@ -1021,12 +1073,15 @@ pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) ->
|
||||
// the device probe runs last (it opens a Vulkan instance, cached per GPU+codec).
|
||||
#[cfg(feature = "vulkan-encode")]
|
||||
{
|
||||
// Mirrors `open_amd_intel` exactly, depth included — the device answers whether a
|
||||
// 10-bit profile for this codec exists, so the prediction and the open agree.
|
||||
matches!(codec, Codec::H265 | Codec::Av1)
|
||||
&& vulkan_encode_enabled()
|
||||
&& vulkan_encode_available(codec)
|
||||
&& vulkan_encode_available_at(codec, ten_bit)
|
||||
}
|
||||
#[cfg(not(feature = "vulkan-encode"))]
|
||||
{
|
||||
let _ = ten_bit; // the depth only ever narrows the Vulkan arm
|
||||
false
|
||||
}
|
||||
};
|
||||
@@ -1035,7 +1090,7 @@ pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) ->
|
||||
linux_auto_is_vaapi,
|
||||
cuda_planned,
|
||||
);
|
||||
cursor_blend_capable_for(backend, cuda_planned, ten_bit, direct_nvenc, vulkan_csc)
|
||||
cursor_blend_capable_for(backend, cuda_planned, direct_nvenc, vulkan_csc)
|
||||
}
|
||||
|
||||
/// The dispatch-mirroring core of [`cursor_blend_capable`], device-free for the unit tests.
|
||||
@@ -1046,7 +1101,6 @@ pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) ->
|
||||
fn cursor_blend_capable_for(
|
||||
backend: Option<LinuxBackend>,
|
||||
cuda_planned: bool,
|
||||
ten_bit: bool,
|
||||
direct_nvenc: bool,
|
||||
vulkan_csc: bool,
|
||||
) -> bool {
|
||||
@@ -1056,11 +1110,11 @@ fn cursor_blend_capable_for(
|
||||
// Only the direct-SDK arm blends (VkSlotBlend), and it only takes CUDA payloads —
|
||||
// a CPU-payload session stays on libav NVENC, which cannot blend.
|
||||
Some(LinuxBackend::Nvenc) => cuda_planned && direct_nvenc,
|
||||
// The Vulkan Video compute-CSC path blends; a 10-bit HDR session skips it for libav
|
||||
// VAAPI (no blend). The session plan keeps a cursor-blend session off the native-NV12
|
||||
// and RGB-direct shapes (`SessionPlan::output_format` / `VulkanVideoEncoder::open`),
|
||||
// so CSC eligibility IS the answer.
|
||||
Some(LinuxBackend::AmdIntel) | Some(LinuxBackend::Vulkan) => !ten_bit && vulkan_csc,
|
||||
// The Vulkan Video compute-CSC path blends, at either depth. The session plan keeps a
|
||||
// cursor-blend session off the native-NV12 and RGB-direct shapes
|
||||
// (`SessionPlan::output_format` / `VulkanVideoEncoder::open`), so CSC eligibility IS the
|
||||
// answer — including the depth term, which `vulkan_csc` already carries.
|
||||
Some(LinuxBackend::AmdIntel) | Some(LinuxBackend::Vulkan) => vulkan_csc,
|
||||
// CPU frames: the capturer composites the metadata cursor inline before the encoder
|
||||
// runs, but the ENCODER blends nothing — the cursor channel's on-demand composite
|
||||
// contract can't be honored. Report the encoder's truth.
|
||||
@@ -1078,30 +1132,64 @@ fn cursor_blend_capable_for(
|
||||
/// reports what actually did.
|
||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||
fn vulkan_encode_available(codec: Codec) -> bool {
|
||||
vulkan_encode_caps(codec).supported
|
||||
}
|
||||
|
||||
/// Can the Vulkan Video backend encode `codec` at this depth on the selected GPU? The gate that
|
||||
/// decides whether an HDR session gets the good path (real RFI + the compute CSC's cursor blend)
|
||||
/// or falls back to libav VAAPI — asked per codec, because a device can advertise HEVC Main10 and
|
||||
/// still decline 10-bit AV1.
|
||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||
fn vulkan_encode_available_at(codec: Codec, ten_bit: bool) -> bool {
|
||||
let caps = vulkan_encode_caps(codec);
|
||||
caps.supported
|
||||
&& if ten_bit {
|
||||
caps.ten_bit
|
||||
} else {
|
||||
caps.eight_bit
|
||||
}
|
||||
}
|
||||
|
||||
/// The device's Vulkan Video encode capabilities for `codec`, cached per (selected GPU, codec) —
|
||||
/// a web-console GPU change re-probes on the new adapter before the next Welcome, mirroring
|
||||
/// `can_encode_444`/`can_encode_10bit`. The probe creates and destroys its own Vulkan instance,
|
||||
/// so it is worth caching but safe to call from anywhere.
|
||||
///
|
||||
/// The `vulkan-encode` half of the cfg is not decoration: the return type comes from the
|
||||
/// `vulkan_video` module, which only exists under that feature. Every caller is already gated;
|
||||
/// leaving these two definitions on bare `target_os = "linux"` is what broke a featureless Linux
|
||||
/// build (caught on Fedora 44, 2026-07-28).
|
||||
#[cfg(all(target_os = "linux", feature = "vulkan-encode"))]
|
||||
fn vulkan_encode_caps(codec: Codec) -> vulkan_video::VulkanEncodeCaps {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
|
||||
#[allow(clippy::type_complexity)]
|
||||
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), vulkan_video::VulkanEncodeCaps>>> =
|
||||
OnceLock::new();
|
||||
let key = (pf_gpu::selection_key(), codec.label());
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some(v) = cache.lock().unwrap().get(&key) {
|
||||
return *v;
|
||||
}
|
||||
let verdict = vulkan_video::probe_encode_support(codec);
|
||||
let ok = verdict.is_ok();
|
||||
match &verdict {
|
||||
Ok(()) => tracing::info!(
|
||||
let caps = vulkan_video::probe_encode_caps(codec);
|
||||
if caps.supported {
|
||||
tracing::info!(
|
||||
?codec,
|
||||
"Vulkan Video encode probed OK — producer-native NV12 capture is eligible"
|
||||
),
|
||||
Err(why) => tracing::info!(
|
||||
eight_bit = caps.eight_bit,
|
||||
ten_bit = caps.ten_bit,
|
||||
"Vulkan Video encode probed — producer-native NV12 capture is eligible, and a 10-bit \
|
||||
session keeps this backend (real RFI + the compute CSC's cursor blend) where the \
|
||||
device accepts the profile"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
?codec,
|
||||
why = *why,
|
||||
"Vulkan Video encode unavailable — keeping the packed-RGB capture negotiation \
|
||||
(the native-NV12 path has no VAAPI fallback)"
|
||||
),
|
||||
"Vulkan Video encode unavailable (no encode queue for this codec) — keeping the \
|
||||
packed-RGB capture negotiation (the native-NV12 path has no VAAPI fallback)"
|
||||
);
|
||||
}
|
||||
cache.lock().unwrap().insert(key, ok);
|
||||
ok
|
||||
cache.lock().unwrap().insert(key, caps);
|
||||
caps
|
||||
}
|
||||
|
||||
/// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA
|
||||
@@ -1263,9 +1351,31 @@ impl CodecSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the active NVIDIA GPU for its encodable codecs (cached once per process). Asks the driver
|
||||
/// for this chip's encode-GUID list over the direct SDK — see [`nvenc_cuda::probe_support`] for
|
||||
/// why it is not shaped like the VAAPI probe below, and for the fail-open contract. Process-wide
|
||||
/// (not per selected GPU) on purpose: the direct backend opens on the shared `cuda::context()`,
|
||||
/// i.e. CUDA device 0, so that is the chip whose answer applies.
|
||||
#[cfg(all(target_os = "linux", feature = "nvenc"))]
|
||||
pub fn nvenc_codec_support() -> CodecSupport {
|
||||
use std::sync::OnceLock;
|
||||
static LOGGED: OnceLock<()> = OnceLock::new();
|
||||
let probed = nvenc_cuda::probe_support();
|
||||
LOGGED.get_or_init(|| {
|
||||
tracing::info!(
|
||||
h264 = probed.codecs.h264,
|
||||
h265 = probed.codecs.h265,
|
||||
av1 = probed.codecs.av1,
|
||||
hevc_444 = probed.hevc_444,
|
||||
"NVENC encode capabilities probed"
|
||||
);
|
||||
});
|
||||
probed.codecs
|
||||
}
|
||||
|
||||
/// Probe the active Linux GPU backend for its encodable codecs (cached; opens a tiny encoder per
|
||||
/// codec, once). Only the VAAPI (AMD/Intel) backend is probed — NVENC keeps its Moonlight-validated
|
||||
/// static advertisement (callers gate on [`linux_zero_copy_is_vaapi`]).
|
||||
/// codec, once). The AMD/Intel backend — NVIDIA has [`nvenc_codec_support`] (callers gate on
|
||||
/// [`linux_zero_copy_is_vaapi`]).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn vaapi_codec_support() -> CodecSupport {
|
||||
use std::sync::OnceLock;
|
||||
@@ -1320,7 +1430,29 @@ pub fn can_encode_444(codec: Codec) -> bool {
|
||||
if linux_zero_copy_is_vaapi() {
|
||||
vaapi::probe_can_encode_444(codec)
|
||||
} else {
|
||||
linux::probe_can_encode_444(codec)
|
||||
// NVIDIA. On a direct-SDK host the answer comes from the driver's caps bit over
|
||||
// the direct SDK ([`nvenc_cuda::probe_support`]) — the same
|
||||
// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` the live session re-checks at open
|
||||
// (`query_caps` → `yuv444_supported`), and the same shape the Windows NVENC arm
|
||||
// uses (`nvenc::probe_can_encode_444`). It must NOT fall back to the libav
|
||||
// open-probe: one ffmpeg `hevc_nvenc` FREXT open in a direct-SDK process is the
|
||||
// LOG-3 field bug — it wedged every later NVENC open process-wide
|
||||
// (`NV_ENC_ERR_INVALID_VERSION`) until a host restart. Only a host that will
|
||||
// really serve the session over libav (PUNKTFUNK_NVENC_DIRECT=0, or a build
|
||||
// without `--features nvenc`) keeps the ffmpeg probe — there it validates the
|
||||
// actual session path, and ffmpeg's NVENC client runs in that process anyway.
|
||||
#[cfg(feature = "nvenc")]
|
||||
{
|
||||
if nvenc_direct_enabled() {
|
||||
nvenc_cuda::probe_support().hevc_444
|
||||
} else {
|
||||
linux::probe_can_encode_444(codec)
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "nvenc"))]
|
||||
{
|
||||
linux::probe_can_encode_444(codec)
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1403,13 +1535,16 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
|
||||
let supported = {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// NVENC (libav, the HDR P010 swscale path) or VAAPI (P010 upload / dmabuf graph),
|
||||
// probed by opening a tiny real Main10 encoder — the same honesty contract as
|
||||
// `can_encode_444`. Vulkan-video and the direct-SDK CUDA path stay 8-bit; a 10-bit
|
||||
// session routes around them (see `open_video_backend`). NOTE: encode capability is
|
||||
// only half the Linux gate — the capture side (GNOME 50+ portal monitor in HDR mode)
|
||||
// is resolved separately by the host (`capturer_supports_hdr` / the GameStream RTSP
|
||||
// honor), since this probe can't know what the compositor will negotiate.
|
||||
// NVENC (libav, the HDR P010 swscale path — the direct-SDK CUDA path encodes the
|
||||
// same GPU's Main10, so this is a truthful proxy for it too) or, on AMD/Intel, VAAPI
|
||||
// OR Vulkan Video. Both of the latter are asked because either can serve the session:
|
||||
// `open_amd_intel` tries Vulkan first and falls back to VAAPI, so 10-bit is available
|
||||
// if EITHER says yes, and answering `false` when only one does would strand a
|
||||
// perfectly encodable HDR session at 8 bits. NOTE: encode capability is only half the
|
||||
// Linux gate — the capture side (a gamescope HDR output, or a GNOME 50+ portal
|
||||
// monitor in HDR mode) is resolved separately by the host
|
||||
// (`capture::capturer_supports_hdr_for` / the GameStream RTSP honor), since this
|
||||
// probe can't know what the compositor will negotiate.
|
||||
// Resolve through the SAME helper `can_encode_444` uses (and which mirrors
|
||||
// `open_video`'s dispatch): `linux_auto_is_vaapi` ignores `encoder_pref`, so on a box
|
||||
// that forces a backend — e.g. `encoder_pref = "vaapi"` on an NVIDIA host — this probe
|
||||
@@ -1417,7 +1552,17 @@ pub fn can_encode_10bit(codec: Codec) -> bool {
|
||||
// depth (plus the HDR/SDR colour label derived from it) would describe a backend that
|
||||
// never runs. That is exactly the dishonesty this probe exists to prevent.
|
||||
if linux_zero_copy_is_vaapi() {
|
||||
vaapi::probe_can_encode_10bit(codec)
|
||||
let vulkan10 = {
|
||||
#[cfg(feature = "vulkan-encode")]
|
||||
{
|
||||
vulkan_encode_enabled() && vulkan_encode_available_at(codec, true)
|
||||
}
|
||||
#[cfg(not(feature = "vulkan-encode"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
};
|
||||
vulkan10 || vaapi::probe_can_encode_10bit(codec)
|
||||
} else {
|
||||
linux::probe_can_encode_10bit(codec)
|
||||
}
|
||||
@@ -1624,17 +1769,20 @@ pub fn resolved_backend_ingests_rgb_444() -> bool {
|
||||
}
|
||||
|
||||
/// True if the active Windows backend's codec advertisement comes from a **real GPU probe**
|
||||
/// ([`windows_codec_support`]) rather than the NVENC static superset. AMF always qualifies — the
|
||||
/// ([`windows_codec_support`]) rather than the static superset. AMF always qualifies — the
|
||||
/// native factory probe (`amf::probe_can_encode`) needs no build feature — while QSV qualifies
|
||||
/// with either the native VPL build (`qsv`, the authoritative Query probe) or the `amf-qsv`
|
||||
/// (libavcodec) build. Formerly `windows_backend_is_ffmpeg`, renamed when the
|
||||
/// native AMF probe replaced the ffmpeg open-probe (design/native-amf-encoder.md §4, Phase 2).
|
||||
/// (libavcodec) build, and NVENC with the `nvenc` build (the direct-SDK GUID-list probe,
|
||||
/// `nvenc::probe_codec_support` — the Windows twin of the Linux probe that ended the
|
||||
/// advertise-HEVC-on-a-GM107 dead sessions). Formerly `windows_backend_is_ffmpeg`, renamed when
|
||||
/// the native AMF probe replaced the ffmpeg open-probe (design/native-amf-encoder.md §4, Phase 2).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn windows_backend_is_probed() -> bool {
|
||||
match windows_resolved_backend() {
|
||||
WindowsBackend::Amf => true,
|
||||
WindowsBackend::Qsv => cfg!(feature = "qsv") || cfg!(feature = "amf-qsv"),
|
||||
WindowsBackend::Nvenc | WindowsBackend::Software => false,
|
||||
WindowsBackend::Nvenc => cfg!(feature = "nvenc"),
|
||||
WindowsBackend::Software => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1661,18 +1809,22 @@ fn windows_gpu_vendor() -> Option<GpuVendor> {
|
||||
.or_else(|| pf_gpu::enumerate().iter().find_map(|g| by_id(g.vendor_id)))
|
||||
}
|
||||
|
||||
/// Probe the active Windows AMF/QSV backend for its encodable codecs (cached **per (backend,
|
||||
/// Probe the active Windows GPU backend for its encodable codecs (cached **per (backend,
|
||||
/// selected GPU)** — a web-console preference change re-probes on the newly selected adapter
|
||||
/// instead of serving the old GPU's answer for the process lifetime). Mirrors
|
||||
/// [`vaapi_codec_support`]; called only when [`windows_backend_is_probed`] is true. AV1 is narrow
|
||||
/// (AMD RDNA3+, Intel Arc/Xe2+), so it must be probed, not assumed.
|
||||
/// [`vaapi_codec_support`]/[`nvenc_codec_support`]; called only when [`windows_backend_is_probed`]
|
||||
/// is true. AV1 is narrow (NVIDIA Ada+, AMD RDNA3+, Intel Arc/Xe2+) and HEVC needs 2nd-gen
|
||||
/// Maxwell+ on NVIDIA, so both must be probed, not assumed.
|
||||
///
|
||||
/// Mirrors the session dispatch (design/native-amf-encoder.md Phase 3): **AMD advertises from the
|
||||
/// native AMF factory probe alone** (`amf::probe_can_encode`, on the selected adapter — the same
|
||||
/// path the session opens, so the advertisement can never claim a codec the session can't emit);
|
||||
/// **Intel/QSV advertises from the native VPL Query probe** (`qsv::probe_can_encode`,
|
||||
/// design/native-qsv-encoder.md §4), falling back to the libavcodec probe on builds without the
|
||||
/// `qsv` feature (all-`false` without either feature, matching a build that cannot open QSV).
|
||||
/// `qsv` feature (all-`false` without either feature, matching a build that cannot open QSV);
|
||||
/// **NVIDIA advertises from the driver's own encode-GUID list** (`nvenc::probe_codec_support`,
|
||||
/// one throwaway direct-SDK session on the selected adapter — NEVER an ffmpeg open-probe, see the
|
||||
/// Linux `nvenc_cuda::probe_support` doc for why).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn windows_codec_support() -> CodecSupport {
|
||||
use std::collections::HashMap;
|
||||
@@ -1706,22 +1858,31 @@ pub fn windows_codec_support() -> CodecSupport {
|
||||
false
|
||||
}
|
||||
}
|
||||
// Callers gate on `windows_backend_is_probed` — defensively answer "nothing probed"
|
||||
// (the advertisement then falls back to the static superset).
|
||||
// NVENC answers below from ONE GUID-list session, not per-codec probes; Software is
|
||||
// never probed. Callers gate on `windows_backend_is_probed` — defensively answer
|
||||
// "nothing probed" (the advertisement then falls back to the static superset).
|
||||
WindowsBackend::Nvenc | WindowsBackend::Software => false,
|
||||
}
|
||||
};
|
||||
let caps = CodecSupport {
|
||||
h264: probe_one(Codec::H264),
|
||||
h265: probe_one(Codec::H265),
|
||||
av1: probe_one(Codec::Av1),
|
||||
let caps = match backend {
|
||||
// NVIDIA: one throwaway session lists every encode GUID at once — no reason to open
|
||||
// three sessions through `probe_one`. Featureless builds fall through to `probe_one`'s
|
||||
// defensive all-false (= "nothing probed" → static superset), matching
|
||||
// `windows_backend_is_probed`.
|
||||
#[cfg(feature = "nvenc")]
|
||||
WindowsBackend::Nvenc => nvenc::probe_codec_support(),
|
||||
_ => CodecSupport {
|
||||
h264: probe_one(Codec::H264),
|
||||
h265: probe_one(Codec::H265),
|
||||
av1: probe_one(Codec::Av1),
|
||||
},
|
||||
};
|
||||
tracing::info!(
|
||||
?backend,
|
||||
h264 = caps.h264,
|
||||
h265 = caps.h265,
|
||||
av1 = caps.av1,
|
||||
"Windows AMF/QSV encode capabilities probed"
|
||||
"Windows encode capabilities probed"
|
||||
);
|
||||
// A concurrent first call may double-probe; both arrive at the same answer, last insert wins.
|
||||
cache.lock().unwrap().insert(key, caps);
|
||||
@@ -1973,58 +2134,32 @@ mod tests {
|
||||
Some(Pyrowave),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
));
|
||||
// NVIDIA: only the direct-SDK arm blends (VkSlotBlend), and only for CUDA payloads.
|
||||
assert!(cursor_blend_capable_for(
|
||||
Some(Nvenc),
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
));
|
||||
assert!(cursor_blend_capable_for(Some(Nvenc), true, true, false));
|
||||
assert!(
|
||||
!cursor_blend_capable_for(Some(Nvenc), false, false, true, false),
|
||||
!cursor_blend_capable_for(Some(Nvenc), false, true, false),
|
||||
"a CPU payload stays on libav NVENC, which cannot blend"
|
||||
);
|
||||
assert!(
|
||||
!cursor_blend_capable_for(Some(Nvenc), true, false, false, false),
|
||||
!cursor_blend_capable_for(Some(Nvenc), true, false, false),
|
||||
"PUNKTFUNK_NVENC_DIRECT=0 (or a build without the feature) is the libav path"
|
||||
);
|
||||
// AMD/Intel: the Vulkan Video compute-CSC arm blends; VAAPI never does.
|
||||
assert!(cursor_blend_capable_for(
|
||||
Some(AmdIntel),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
));
|
||||
// AMD/Intel: the Vulkan Video compute-CSC arm blends — at 8 AND 10 bits, since its CSC
|
||||
// has a BT.2020 Main10 variant. VAAPI never does. The depth term lives in `vulkan_csc`
|
||||
// (10-bit AV1 is the one combination that still falls through to VAAPI), so this arm is
|
||||
// now exactly "did an eligible CSC arm resolve".
|
||||
assert!(cursor_blend_capable_for(Some(AmdIntel), false, false, true));
|
||||
assert!(
|
||||
!cursor_blend_capable_for(Some(AmdIntel), false, false, false, false),
|
||||
!cursor_blend_capable_for(Some(AmdIntel), false, false, false),
|
||||
"no eligible Vulkan CSC arm (H.264, PUNKTFUNK_VULKAN_ENCODE=0, unsupported \
|
||||
device) resolves to libav VAAPI, which cannot blend"
|
||||
);
|
||||
assert!(
|
||||
!cursor_blend_capable_for(Some(AmdIntel), false, true, false, true),
|
||||
"a 10-bit HDR session skips Vulkan Video for VAAPI's P010 wiring — no blend"
|
||||
);
|
||||
assert!(cursor_blend_capable_for(
|
||||
Some(Vulkan),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
));
|
||||
assert!(cursor_blend_capable_for(Some(Vulkan), false, false, true));
|
||||
// Software / unknown pref: CPU frames; the encoder blends nothing.
|
||||
assert!(!cursor_blend_capable_for(
|
||||
Some(Software),
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!cursor_blend_capable_for(None, false, false, true, true));
|
||||
assert!(!cursor_blend_capable_for(Some(Software), false, true, true));
|
||||
assert!(!cursor_blend_capable_for(None, false, true, true));
|
||||
}
|
||||
|
||||
/// WP7.7 guard (the cheap half): every `Encoder` trait method must be explicitly forwarded by
|
||||
|
||||
@@ -20,3 +20,6 @@ ash = { version = "0.38", features = ["loaded"] }
|
||||
# Same bindgen configuration as ffmpeg-sys-next (runtime = dlopen libclang).
|
||||
bindgen = { version = "0.72", features = ["runtime"], default-features = false }
|
||||
pkg-config = "0.3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -35,3 +35,6 @@ windows = { version = "0.62", features = [
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+110
-63
@@ -101,17 +101,21 @@ pub fn pack_luid(luid: LUID) -> i64 {
|
||||
pub unsafe fn make_device(adapter: &IDXGIAdapter1) -> Result<(ID3D11Device, ID3D11DeviceContext)> {
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
D3D11CreateDevice(
|
||||
adapter,
|
||||
D3D_DRIVER_TYPE_UNKNOWN,
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)
|
||||
// SAFETY: `adapter` is live for the call (caller contract). The two out-params are local
|
||||
// `Option`s the callee only writes, and both are checked below before use.
|
||||
unsafe {
|
||||
D3D11CreateDevice(
|
||||
adapter,
|
||||
D3D_DRIVER_TYPE_UNKNOWN,
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)
|
||||
}
|
||||
.context("D3D11CreateDevice")?;
|
||||
let device = device.context("null D3D11 device")?;
|
||||
let context = context.context("null D3D11 context")?;
|
||||
@@ -127,14 +131,20 @@ pub unsafe fn make_device(adapter: &IDXGIAdapter1) -> Result<(ID3D11Device, ID3D
|
||||
elevate_process_gpu_priority();
|
||||
if let Ok(dxgi_dev) = device.cast::<IDXGIDevice>() {
|
||||
// The absolute max GPU thread priority (0x4000001E; the same value Sunshine/Apollo use); fall back to relative +7.
|
||||
if dxgi_dev.SetGPUThreadPriority(0x4000_001E).is_err()
|
||||
&& dxgi_dev.SetGPUThreadPriority(7).is_err()
|
||||
// SAFETY: `dxgi_dev` is a live interface just obtained by a checked `cast`; both calls take
|
||||
// a scalar and only report failure through their return value.
|
||||
if unsafe { dxgi_dev.SetGPUThreadPriority(0x4000_001E) }.is_err()
|
||||
// SAFETY: same live interface, same scalar-in/HRESULT-out contract as the call above.
|
||||
// Deliberately its own block rather than one around the whole chain, which would
|
||||
// destroy the short-circuit and always issue the relative-priority call too.
|
||||
&& unsafe { dxgi_dev.SetGPUThreadPriority(7) }.is_err()
|
||||
{
|
||||
tracing::warn!("SetGPUThreadPriority failed (run as admin/SYSTEM for GPU priority)");
|
||||
}
|
||||
}
|
||||
if let Ok(dxgi1) = device.cast::<IDXGIDevice1>() {
|
||||
let _ = dxgi1.SetMaximumFrameLatency(1);
|
||||
// SAFETY: `dxgi1` is a live interface from a checked `cast`; the arg is a scalar.
|
||||
let _ = unsafe { dxgi1.SetMaximumFrameLatency(1) };
|
||||
}
|
||||
// REALTIME auto-gate (gpu-contention §5.C / latency plan T2.3) — needs the device's adapter,
|
||||
// so it runs here, after creation; internally once-per-process.
|
||||
@@ -177,7 +187,7 @@ fn configured_gpu_priority_mode() -> PrioMode {
|
||||
/// HIGH/REALTIME GPU scheduling-priority bump on it. Held by SYSTEM/Administrators; a UAC-FILTERED
|
||||
/// token does NOT have it, which is why `elevate_process_gpu_priority` may silently no-op in a
|
||||
/// restricted service context.
|
||||
unsafe fn enable_inc_base_priority() {
|
||||
fn enable_inc_base_priority() {
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
|
||||
use windows::Win32::Security::{
|
||||
@@ -187,15 +197,24 @@ unsafe fn enable_inc_base_priority() {
|
||||
};
|
||||
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||
let mut token = HANDLE::default();
|
||||
if OpenProcessToken(
|
||||
GetCurrentProcess(),
|
||||
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
|
||||
&mut token,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
// SAFETY: `GetCurrentProcess` returns the current-process pseudo-handle, always valid and never
|
||||
// closed; `token` is a local the callee only writes, and it is only used below if this succeeded.
|
||||
let opened = unsafe {
|
||||
OpenProcessToken(
|
||||
GetCurrentProcess(),
|
||||
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
|
||||
&mut token,
|
||||
)
|
||||
}
|
||||
.is_ok();
|
||||
if opened {
|
||||
let mut luid = LUID::default();
|
||||
if LookupPrivilegeValueW(PCWSTR::null(), SE_INC_BASE_PRIORITY_NAME, &mut luid).is_ok() {
|
||||
// SAFETY: a null system name means "local system"; `SE_INC_BASE_PRIORITY_NAME` is a static
|
||||
// NUL-terminated constant, and `luid` is a local the callee only writes.
|
||||
let found =
|
||||
unsafe { LookupPrivilegeValueW(PCWSTR::null(), SE_INC_BASE_PRIORITY_NAME, &mut luid) }
|
||||
.is_ok();
|
||||
if found {
|
||||
let tp = TOKEN_PRIVILEGES {
|
||||
PrivilegeCount: 1,
|
||||
Privileges: [LUID_AND_ATTRIBUTES {
|
||||
@@ -203,20 +222,25 @@ unsafe fn enable_inc_base_priority() {
|
||||
Attributes: SE_PRIVILEGE_ENABLED,
|
||||
}],
|
||||
};
|
||||
if AdjustTokenPrivileges(
|
||||
token,
|
||||
false,
|
||||
Some(&tp as *const TOKEN_PRIVILEGES),
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
// SAFETY: `token` is the live handle opened above; `tp` is a correctly sized local
|
||||
// `TOKEN_PRIVILEGES` whose `PrivilegeCount` matches its one-element array, borrowed only
|
||||
// for the duration of the call.
|
||||
let adjusted = unsafe {
|
||||
AdjustTokenPrivileges(
|
||||
token,
|
||||
false,
|
||||
Some(&tp as *const TOKEN_PRIVILEGES),
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
};
|
||||
if adjusted.is_err() {
|
||||
tracing::warn!("could not enable SE_INC_BASE_PRIORITY for GPU priority");
|
||||
}
|
||||
}
|
||||
let _ = CloseHandle(token);
|
||||
// SAFETY: `token` was opened above, is owned here, and is closed exactly once on this path.
|
||||
let _ = unsafe { CloseHandle(token) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,11 +255,22 @@ unsafe fn d3dkmt_set_scheduling_priority_class(
|
||||
use windows::core::s;
|
||||
use windows::Win32::Foundation::HANDLE;
|
||||
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
|
||||
let gdi32 = LoadLibraryA(s!("gdi32.dll")).ok()?;
|
||||
let p = GetProcAddress(gdi32, s!("D3DKMTSetProcessSchedulingPriorityClass"))?;
|
||||
// SAFETY: both take static NUL-terminated literals; `LoadLibraryA` returns a module handle the
|
||||
// process keeps for its lifetime (gdi32 is never unloaded here), and `GetProcAddress` is passed
|
||||
// that live handle. Both results are checked by `?` before use.
|
||||
let gdi32 = unsafe { LoadLibraryA(s!("gdi32.dll")) }.ok()?;
|
||||
// SAFETY: `gdi32` is the live module handle the checked `LoadLibraryA` just returned, and the
|
||||
// export name is a static NUL-terminated literal; the result is checked by `?` before use.
|
||||
let p = unsafe { GetProcAddress(gdi32, s!("D3DKMTSetProcessSchedulingPriorityClass")) }?;
|
||||
type SetPrio = unsafe extern "system" fn(HANDLE, i32) -> i32;
|
||||
let f: SetPrio = std::mem::transmute(p);
|
||||
Some(f(process, prio))
|
||||
// SAFETY: `p` is the non-null export just resolved, and `SetPrio` is its documented signature
|
||||
// (`NTSTATUS D3DKMTSetProcessSchedulingPriorityClass(HANDLE, D3DKMT_SCHEDULINGPRIORITYCLASS)`,
|
||||
// both arguments 4/8-byte scalars). `process` is a valid handle by this fn's own contract.
|
||||
let f: SetPrio = unsafe { std::mem::transmute(p) };
|
||||
// SAFETY: `f` is that export transmuted to its documented signature directly above; `process`
|
||||
// is a valid handle by this fn's own contract and `prio` is a plain scalar. The call returns an
|
||||
// NTSTATUS and retains nothing.
|
||||
Some(unsafe { f(process, prio) })
|
||||
}
|
||||
|
||||
/// GPU scheduling-priority hardening — the same approach as Sunshine/Apollo, independently
|
||||
@@ -254,13 +289,7 @@ unsafe fn d3dkmt_set_scheduling_priority_class(
|
||||
fn elevate_process_gpu_priority() {
|
||||
use std::sync::Once;
|
||||
static ONCE: Once = Once::new();
|
||||
// SAFETY: the closure calls two of this module's `unsafe fn`s — `enable_inc_base_priority`
|
||||
// (adjusts the current-process token; it has no caller precondition and builds all its FFI args
|
||||
// locally) and `d3dkmt_set_scheduling_priority_class` (loads gdi32 by name and calls the export).
|
||||
// The latter requires `process` to be a valid process handle; `GetCurrentProcess()` returns the
|
||||
// current-process pseudo-handle, which is always valid and needs no close. Runs once via
|
||||
// `Once::call_once`; no raw pointers are dereferenced here.
|
||||
ONCE.call_once(|| unsafe {
|
||||
ONCE.call_once(|| {
|
||||
use windows::Win32::System::Threading::GetCurrentProcess;
|
||||
let prio = match configured_gpu_priority_mode() {
|
||||
PrioMode::Off => {
|
||||
@@ -273,7 +302,10 @@ fn elevate_process_gpu_priority() {
|
||||
PrioMode::Auto => 4,
|
||||
};
|
||||
enable_inc_base_priority();
|
||||
match d3dkmt_set_scheduling_priority_class(GetCurrentProcess(), prio) {
|
||||
// SAFETY: `d3dkmt_set_scheduling_priority_class` requires a valid process handle;
|
||||
// `GetCurrentProcess()` returns the current-process pseudo-handle, which is always valid and
|
||||
// needs no close.
|
||||
match unsafe { d3dkmt_set_scheduling_priority_class(GetCurrentProcess(), prio) } {
|
||||
Some(0) => tracing::info!(
|
||||
priority_class = prio,
|
||||
"GPU process scheduling priority class set (2=normal 4=high 5=realtime)"
|
||||
@@ -316,10 +348,10 @@ const KMTQAITYPE_WDDM_2_7_CAPS: u32 = 70;
|
||||
/// setter). `None` = could not determine (missing exports / query failed) — the caller treats
|
||||
/// unknown as "assume the hazard exists".
|
||||
///
|
||||
/// # Safety
|
||||
/// Calls gdi32 exports through by-name transmuted pointers with locally built, correctly sized
|
||||
/// `repr(C)` argument structs; the adapter handle is closed before returning on every path.
|
||||
unsafe fn hags_enabled(luid: LUID) -> Option<bool> {
|
||||
/// Safe: `luid` is a plain value (no pointer a caller could get wrong), every gdi32 argument struct
|
||||
/// is built locally here, and the adapter handle is closed before returning on every path — so there
|
||||
/// is no precondition left for a caller to uphold. The `unsafe` that remains is internal.
|
||||
fn hags_enabled(luid: LUID) -> Option<bool> {
|
||||
use windows::core::s;
|
||||
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
|
||||
#[repr(C)]
|
||||
@@ -338,19 +370,33 @@ unsafe fn hags_enabled(luid: LUID) -> Option<bool> {
|
||||
private_data: *mut std::ffi::c_void,
|
||||
private_data_size: u32,
|
||||
}
|
||||
let gdi32 = LoadLibraryA(s!("gdi32.dll")).ok()?;
|
||||
let open = GetProcAddress(gdi32, s!("D3DKMTOpenAdapterFromLuid"))?;
|
||||
let query = GetProcAddress(gdi32, s!("D3DKMTQueryAdapterInfo"))?;
|
||||
let close = GetProcAddress(gdi32, s!("D3DKMTCloseAdapter"))?;
|
||||
// SAFETY: static NUL-terminated literals; gdi32 stays loaded for the process lifetime, and each
|
||||
// result is checked by `?`/`.ok()?` before the next call uses it.
|
||||
let gdi32 = unsafe { LoadLibraryA(s!("gdi32.dll")) }.ok()?;
|
||||
// SAFETY: `gdi32` is the live handle from the `.ok()?`-checked load above; static literal name;
|
||||
// `?` checks the result.
|
||||
let open = unsafe { GetProcAddress(gdi32, s!("D3DKMTOpenAdapterFromLuid")) }?;
|
||||
// SAFETY: same live `gdi32` handle and static-literal name; `?` checks the result.
|
||||
let query = unsafe { GetProcAddress(gdi32, s!("D3DKMTQueryAdapterInfo")) }?;
|
||||
// SAFETY: same live `gdi32` handle and static-literal name; `?` checks the result.
|
||||
let close = unsafe { GetProcAddress(gdi32, s!("D3DKMTCloseAdapter")) }?;
|
||||
type OpenFn = unsafe extern "system" fn(*mut OpenFromLuid) -> i32;
|
||||
type QueryFn = unsafe extern "system" fn(*mut QueryInfo) -> i32;
|
||||
type CloseFn = unsafe extern "system" fn(*mut CloseAdapter) -> i32;
|
||||
let open: OpenFn = std::mem::transmute(open);
|
||||
let query: QueryFn = std::mem::transmute(query);
|
||||
let close: CloseFn = std::mem::transmute(close);
|
||||
// SAFETY: each pointer is the non-null export resolved just above, and each fn type mirrors that
|
||||
// export's documented signature — one `*mut` to the matching `repr(C)` struct declared here,
|
||||
// returning NTSTATUS.
|
||||
let open: OpenFn = unsafe { std::mem::transmute(open) };
|
||||
// SAFETY: `query` is the non-null export resolved above and `QueryFn` mirrors its documented
|
||||
// signature — one `*mut` to the `repr(C)` struct declared here, returning NTSTATUS.
|
||||
let query: QueryFn = unsafe { std::mem::transmute(query) };
|
||||
// SAFETY: `close` is the non-null export resolved above and `CloseFn` mirrors its documented
|
||||
// signature — one `*mut` to the `repr(C)` struct declared here, returning NTSTATUS.
|
||||
let close: CloseFn = unsafe { std::mem::transmute(close) };
|
||||
|
||||
let mut oa = OpenFromLuid { luid, h_adapter: 0 };
|
||||
if open(&mut oa) != 0 {
|
||||
// SAFETY: `oa` is a live local of exactly the type the export expects; it borrows nothing.
|
||||
if unsafe { open(&mut oa) } != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut caps: u32 = 0;
|
||||
@@ -360,11 +406,14 @@ unsafe fn hags_enabled(luid: LUID) -> Option<bool> {
|
||||
private_data: (&mut caps as *mut u32).cast(),
|
||||
private_data_size: std::mem::size_of::<u32>() as u32,
|
||||
};
|
||||
let st = query(&mut qi);
|
||||
// SAFETY: `qi` is a live local; `private_data` points at `caps`, which outlives the call, and
|
||||
// `private_data_size` is that variable's exact size.
|
||||
let st = unsafe { query(&mut qi) };
|
||||
let mut ca = CloseAdapter {
|
||||
h_adapter: oa.h_adapter,
|
||||
};
|
||||
let _ = close(&mut ca);
|
||||
// SAFETY: `ca` is a live local holding the adapter `open` returned; closed exactly once.
|
||||
let _ = unsafe { close(&mut ca) };
|
||||
if st != 0 {
|
||||
return None; // pre-WDDM-2.7 driver: the query type doesn't exist ⇒ HAGS can't be on
|
||||
}
|
||||
@@ -400,9 +449,7 @@ fn auto_priority_gate(device: &ID3D11Device) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
// SAFETY: `hags_enabled` builds all its FFI arguments locally and closes the adapter
|
||||
// handle before returning (see its own contract); `luid` is a plain value.
|
||||
let hags = unsafe { hags_enabled(luid) };
|
||||
let hags = hags_enabled(luid);
|
||||
match hags {
|
||||
Some(false) => {
|
||||
// No HAGS ⇒ the NVENC-hang hazard cannot occur: take REALTIME outright.
|
||||
|
||||
@@ -26,3 +26,6 @@ windows = { version = "0.62", features = [
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -10,3 +10,6 @@ description = "Process-wide punktfunk host configuration (env-parsed HostConfig
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
//! = off, anything else = on, so the old presence-style `=1` keeps working). The Linux `zerocopy`
|
||||
//! module keeps its own *truthy* parser (`1|true|yes|on`) — the two are independent features that
|
||||
//! share a name; do NOT conflate them.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -61,6 +62,12 @@ pub fn env_on(name: &str) -> Option<bool> {
|
||||
/// derived `Debug` impl, so the parser can stay a single platform-neutral function.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HostConfig {
|
||||
/// `PUNKTFUNK_HOST_NAME` — the name this host shows up under in Moonlight (the serverinfo
|
||||
/// `<hostname>` element) and in Punktfunk's own clients (the mDNS service *instance* name both
|
||||
/// adverts carry). Unset/blank = the machine's own hostname, which is what it always was. Free
|
||||
/// text ("Living Room PC"); the DNS-level `<label>.local.` target keeps using a sanitized
|
||||
/// machine-safe label, so a spacey display name can't produce an invalid mDNS record.
|
||||
pub host_name: Option<String>,
|
||||
/// `PUNKTFUNK_ENCODER` — explicit encoder-backend override (lowercased; empty = auto-detect by GPU vendor).
|
||||
pub encoder_pref: String,
|
||||
/// `PUNKTFUNK_RENDER_ADAPTER` — discrete render-GPU pin by description substring (`Some` even when empty:
|
||||
@@ -96,6 +103,14 @@ pub struct HostConfig {
|
||||
pub perf: bool,
|
||||
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
|
||||
pub video_source: Option<String>,
|
||||
/// `PUNKTFUNK_CAPTURE_MONITOR` — pin capture at a NAMED physical monitor (`DP-1`, `HDMI-A-2`),
|
||||
/// instead of creating a virtual display or taking whichever head the portal hands back. The
|
||||
/// point of the knob is an unattended host: a background `systemd --user` service has nobody to
|
||||
/// answer a chooser dialog, so the monitor has to be config, not a prompt. A name that matches
|
||||
/// no head is a hard error at session open (never a silent fall-back to a different screen —
|
||||
/// showing the wrong monitor is worse than showing none). Linux-only today; see
|
||||
/// `design/per-monitor-portal-capture.md`.
|
||||
pub capture_monitor: Option<String>,
|
||||
/// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected
|
||||
/// session — this one is a constant operator knob; `apply_session_env` never writes it.
|
||||
pub compositor: Option<String>,
|
||||
@@ -123,6 +138,22 @@ pub struct HostConfig {
|
||||
/// starts over (the "fresh gamescope output never delivers frames" field failure). Default ON;
|
||||
/// explicit-off grammar (`=0` disables, the on-glass A/B + emergency escape hatch).
|
||||
pub gamescope_splash: bool,
|
||||
/// `PUNKTFUNK_GAMESCOPE_HDR` — allow HDR (10-bit BT.2020 PQ) sessions on the gamescope
|
||||
/// backend. Needs the punktfunk gamescope build (`packaging/gamescope`), which teaches
|
||||
/// gamescope's PipeWire node the 10-bit PQ capture formats; the host probes for it and stays
|
||||
/// SDR when it isn't installed, so this knob only decides whether HDR is *attempted*.
|
||||
///
|
||||
/// Default OFF for the canary release, then default-on (matching `PUNKTFUNK_10BIT`'s
|
||||
/// explicit-off grammar). It gates the whole feature — spawn flags included — so an operator
|
||||
/// who hits a bad interaction can turn the gamescope backend back into exactly today's 8-bit
|
||||
/// path with one env var and no downgrade path to trip over.
|
||||
pub gamescope_hdr: bool,
|
||||
/// `PUNKTFUNK_GAMESCOPE_SDR_NITS` — the luminance SDR content is mapped to inside the PQ
|
||||
/// container of an HDR gamescope session (gamescope's `--hdr-sdr-content-nits`, default 400).
|
||||
/// An HDR stream carries the desktop, the Steam overlay and any SDR game through the same PQ
|
||||
/// 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_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO
|
||||
/// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell
|
||||
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up
|
||||
@@ -138,6 +169,38 @@ pub struct HostConfig {
|
||||
/// `PUNKTFUNK_ON_DISCONNECT_CMD` — the `client.disconnected` sibling of
|
||||
/// [`Self::on_connect_cmd`].
|
||||
pub on_disconnect_cmd: Option<String>,
|
||||
/// `PUNKTFUNK_MAX_FPS` — frame limiter for the GAME. `None` (unset, `0`, or unparseable) =
|
||||
/// no limit, the default and what every existing host does.
|
||||
///
|
||||
/// This caps how fast the compositor lets the game render; it does **not** touch the session.
|
||||
/// The client still negotiates and receives its full rate — a 120 Hz session over a game
|
||||
/// limited to 60 sends 120 frames a second, 60 of them repeats of an unchanged picture, which
|
||||
/// costs an almost-empty P-frame. That split is the whole point: the game stops rendering
|
||||
/// frames nobody asked for, and the GPU time it gives up goes to capture and encode instead
|
||||
/// (and, on a laptop or handheld, to heat and battery).
|
||||
///
|
||||
/// Capping the STREAM instead would be a different and mostly unwanted feature — it hands the
|
||||
/// client fewer frames than it asked for and saves the game's GPU nothing.
|
||||
///
|
||||
/// Enforced by the compositor, so its reach is whatever that compositor offers. **gamescope**
|
||||
/// takes it as `--nested-refresh`, the rate it clamps the game to; note that is the nested
|
||||
/// output's rate, so everything gamescope composites moves at it, not the game alone — under
|
||||
/// gamescope there is only the one output. Values are clamped into 1..=240.
|
||||
pub max_fps: Option<u32>,
|
||||
/// `PUNKTFUNK_VDISPLAY_HZ_MULT` — run the VIRTUAL DISPLAY at this multiple of the session's
|
||||
/// frame rate while the stream stays paced at the session rate. Default 1 (off); 2 is the
|
||||
/// interesting one, hence the name this shipped under.
|
||||
///
|
||||
/// A compositor only paints on its own vblank, so at 1× a frame can be finished just after
|
||||
/// the capture sampled and then waits nearly a whole interval to be picked up — up to
|
||||
/// ~16 ms of pure age at 60 Hz, and it is the jittery part of the latency, not the steady
|
||||
/// part. Driving the display at 2× halves that worst case without sending a single extra
|
||||
/// frame: the pacing clamp below keeps the wire at exactly the rate the client negotiated.
|
||||
///
|
||||
/// It is not free — the compositor and the GPU do the extra composites — so it stays opt-in.
|
||||
/// Clamped to 1..=4; a backend that cannot honor the multiplied rate simply reports what it
|
||||
/// achieved and the pacing follows that, exactly as it does for any other refusal.
|
||||
pub vdisplay_hz_mult: u32,
|
||||
}
|
||||
|
||||
impl HostConfig {
|
||||
@@ -151,6 +214,9 @@ impl HostConfig {
|
||||
// (`PUNKTFUNK_IDD_PUSH` was removed: IDD-push is the sole Windows capture path, so the knob
|
||||
// only split dispatch — capture ignored it while the vdisplay manager obeyed it, and `=0`
|
||||
// produced dead-swap-chain reuse on reconnect. A stale setting in an old host.env is ignored.)
|
||||
host_name: val("PUNKTFUNK_HOST_NAME")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
encoder_pref: std::env::var("PUNKTFUNK_ENCODER")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase(),
|
||||
@@ -170,6 +236,11 @@ impl HostConfig {
|
||||
chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true),
|
||||
perf: flag("PUNKTFUNK_PERF"),
|
||||
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
|
||||
// Trimmed + emptied-to-None: `PUNKTFUNK_CAPTURE_MONITOR=` in a host.env means "not
|
||||
// set", not "match the monitor named empty string".
|
||||
capture_monitor: val("PUNKTFUNK_CAPTURE_MONITOR")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||
gamepad: val("PUNKTFUNK_GAMEPAD"),
|
||||
vdisplay: val("PUNKTFUNK_VDISPLAY"),
|
||||
@@ -188,10 +259,40 @@ impl HostConfig {
|
||||
// Default ON, explicit-off grammar: the splash is what makes a fresh bare spawn deliver
|
||||
// its first frames at all; `=0` is the A/B + escape hatch.
|
||||
gamescope_splash: env_on("PUNKTFUNK_GAMESCOPE_SPLASH").unwrap_or(true),
|
||||
// Default OFF for one canary release (design §4 rollout), then flip the `unwrap_or`.
|
||||
gamescope_hdr: env_on("PUNKTFUNK_GAMESCOPE_HDR").unwrap_or(false),
|
||||
gamescope_sdr_nits: val("PUNKTFUNK_GAMESCOPE_SDR_NITS")
|
||||
.and_then(|s| s.trim().parse::<u32>().ok())
|
||||
.filter(|n| (1..=10_000).contains(n)),
|
||||
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
|
||||
.filter(|s| !s.trim().is_empty()),
|
||||
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
|
||||
on_disconnect_cmd: val("PUNKTFUNK_ON_DISCONNECT_CMD").filter(|s| !s.trim().is_empty()),
|
||||
// 0 means "no limit" rather than "stream nothing" — it is the natural way to spell
|
||||
// "off" in a config file, and a 0 fps session is not a thing anyone wants.
|
||||
max_fps: val("PUNKTFUNK_MAX_FPS")
|
||||
.and_then(|s| s.trim().parse::<u32>().ok())
|
||||
.filter(|&f| f > 0)
|
||||
.map(|f| f.clamp(1, 240)),
|
||||
vdisplay_hz_mult: val("PUNKTFUNK_VDISPLAY_HZ_MULT")
|
||||
.and_then(|s| s.trim().parse::<u32>().ok())
|
||||
.unwrap_or(1)
|
||||
.clamp(1, 4),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HostConfig {
|
||||
/// The rate to hand the compositor as the GAME's refresh: the session's rate, capped by
|
||||
/// [`Self::max_fps`]. Only the compositor's game-facing rate goes through here — the session's
|
||||
/// own mode, the encoder and the wire never do (see the field docs for why).
|
||||
///
|
||||
/// `0` in means `0` out. A zero rate is rejected upstream, and quietly turning it into a real
|
||||
/// one here would hide that.
|
||||
pub fn game_fps(&self, session_hz: u32) -> u32 {
|
||||
match self.max_fps {
|
||||
Some(cap) if session_hz > cap => cap,
|
||||
_ => session_hz,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,3 +302,32 @@ pub fn config() -> &'static HostConfig {
|
||||
static CFG: OnceLock<HostConfig> = OnceLock::new();
|
||||
CFG.get_or_init(HostConfig::from_env)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg(max_fps: Option<u32>) -> HostConfig {
|
||||
HostConfig {
|
||||
max_fps,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_fps_caps_only_above_the_limit() {
|
||||
// Unset: every session rate passes through untouched — the default, and every existing
|
||||
// host. The game keeps rendering at the session's rate, exactly as it always did.
|
||||
for hz in [24, 30, 60, 120, 144, 240] {
|
||||
assert_eq!(cfg(None).game_fps(hz), hz);
|
||||
}
|
||||
// Set: capped above, exact at, untouched below. A session BELOW the limit keeps its own
|
||||
// rate — the knob is a ceiling on the game, not a target to render up to.
|
||||
let c = cfg(Some(60));
|
||||
assert_eq!(c.game_fps(120), 60);
|
||||
assert_eq!(c.game_fps(60), 60);
|
||||
assert_eq!(c.game_fps(30), 30);
|
||||
// An invalid rate stays invalid rather than being laundered into a real one.
|
||||
assert_eq!(c.game_fps(0), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ windows = { version = "0.62", features = [
|
||||
"Win32_Devices_Enumeration_Pnp",
|
||||
# SwDeviceCreate's SW_DEVICE_CREATE_INFO references DEVPROPKEY (Properties).
|
||||
"Win32_Devices_Properties",
|
||||
# The channel proof: HidD_GetIndexedString + GUID_DEVINTERFACE_HID (the pads/mouse leg) and
|
||||
# CreateFileW to open the device interface the proof is asked over.
|
||||
"Win32_Devices_HumanInterfaceDevice",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_StationsAndDesktops",
|
||||
@@ -71,3 +75,6 @@ windows = { version = "0.62", features = [
|
||||
"Win32_UI_Input_Pointer",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
//! key events) is enough.
|
||||
|
||||
use super::{gs_button_to_evdev, vk_to_evdev, InputInjector};
|
||||
use crate::AbsoluteAnchor;
|
||||
use anyhow::{anyhow, Result};
|
||||
use ashpd::desktop::{
|
||||
remote_desktop::{
|
||||
@@ -374,25 +375,98 @@ async fn connect_socket_file(file: &std::path::Path) -> Result<(UnixStream, Opti
|
||||
}
|
||||
|
||||
/// One EI device and its emulation state.
|
||||
/// Pick the region to map absolute coordinates into: the one whose logical size matches the
|
||||
/// streamed mode (the session's virtual output). The device advertises one region per logical
|
||||
/// monitor, and blindly taking `first()` next to a physical monitor put the pointer — and every
|
||||
/// Pick the region to map absolute coordinates into. The device advertises one region per logical
|
||||
/// monitor and blindly taking `first()` next to a physical monitor put the pointer — and every
|
||||
/// click — on whichever output the compositor happened to announce first (on-glass: GNOME with a
|
||||
/// dummy HDMI beside the virtual primary; the seat cursor never entered the streamed monitor, so
|
||||
/// neither embedded nor metadata cursor capture could see it). Size is the only key available
|
||||
/// today: regions carry no output name, and matching the screencast stream's `mapping_id` needs
|
||||
/// the stream id plumbed across crates (follow-up). Two same-sized monitors stay ambiguous.
|
||||
fn region_for_mode(
|
||||
regions: &[reis::event::Region],
|
||||
/// neither embedded nor metadata cursor capture could see it).
|
||||
///
|
||||
/// The ladder, most identifying first:
|
||||
///
|
||||
/// 1. **`mapping_id`** from the session's [`AbsoluteAnchor`] — the protocol's own key for
|
||||
/// correlating a region with a video stream.
|
||||
/// 2. **origin** from the anchor — two outputs can share a size, never a top-left. This is what
|
||||
/// makes a *mirrored physical monitor* land correctly (`design/per-monitor-portal-capture.md`
|
||||
/// §7.2): its region is not the client's size, so the size rung can't find it.
|
||||
/// 3. **size** — the streamed mode. Correct for a client-sized virtual output, ambiguous the
|
||||
/// moment two heads share a mode, which is exactly why the rungs above exist.
|
||||
/// 4. `first()`.
|
||||
///
|
||||
/// An anchor that matches nothing falls through rather than failing: the region set is the truth
|
||||
/// and the anchor is our belief about it. The caller logs that miss ([`anchor_missed`]).
|
||||
fn region_for_mode<'a>(
|
||||
regions: &'a [reis::event::Region],
|
||||
w: f32,
|
||||
h: f32,
|
||||
) -> Option<&reis::event::Region> {
|
||||
anchor: Option<&AbsoluteAnchor>,
|
||||
) -> Option<&'a reis::event::Region> {
|
||||
if let Some(a) = anchor {
|
||||
if let Some(id) = a.mapping_id.as_deref() {
|
||||
if let Some(r) = regions.iter().find(|r| r.mapping_id.as_deref() == Some(id)) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
if let Some((x, y)) = a.origin {
|
||||
// EI region offsets are unsigned; a compositor places every output at a non-negative
|
||||
// origin in its own global space, so a negative anchor simply matches nothing.
|
||||
if x >= 0 && y >= 0 {
|
||||
if let Some(r) = regions.iter().find(|r| r.x == x as u32 && r.y == y as u32) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
regions
|
||||
.iter()
|
||||
.find(|r| r.width as f32 == w && r.height as f32 == h)
|
||||
.or_else(|| regions.first())
|
||||
}
|
||||
|
||||
/// Report which region absolute coordinates actually landed in, once per distinct answer.
|
||||
///
|
||||
/// The ladder above is only *observable* through where the pointer ends up, which on a two-head box
|
||||
/// is precisely the thing that is hard to see and easy to get wrong — the anchor exists because it
|
||||
/// already resolved wrong on-glass once, silently. `warn_anchor_miss` covers the anchor naming
|
||||
/// nothing; this covers the other half, an anchor that matched *something*, by saying which. Once
|
||||
/// per distinct region so a live session logs one line, not one per motion event.
|
||||
fn note_abs_region(region: &reis::event::Region, anchor: Option<&AbsoluteAnchor>) {
|
||||
static LAST: std::sync::Mutex<Option<(u32, u32, u32, u32)>> = std::sync::Mutex::new(None);
|
||||
let key = (region.x, region.y, region.width, region.height);
|
||||
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if *last == Some(key) {
|
||||
return;
|
||||
}
|
||||
*last = Some(key);
|
||||
tracing::info!(
|
||||
region = %format!("{}x{}+{}+{}", region.width, region.height, region.x, region.y),
|
||||
mapping_id = ?region.mapping_id,
|
||||
anchor_origin = ?anchor.and_then(|a| a.origin),
|
||||
anchor_mapping_id = ?anchor.and_then(|a| a.mapping_id.clone()),
|
||||
"libei: absolute input maps into this output"
|
||||
);
|
||||
}
|
||||
|
||||
/// Did an anchor name an output this region set doesn't have? Drives the one-shot warning — a
|
||||
/// silently mis-mapped pointer is the failure this whole ladder exists to prevent, so when the
|
||||
/// anchor can't be honored that has to be visible in the log rather than inferred from "clicks land
|
||||
/// on the wrong screen".
|
||||
fn anchor_missed(regions: &[reis::event::Region], anchor: Option<&AbsoluteAnchor>) -> bool {
|
||||
let Some(a) = anchor else {
|
||||
return false;
|
||||
};
|
||||
if let Some(id) = a.mapping_id.as_deref() {
|
||||
if regions.iter().any(|r| r.mapping_id.as_deref() == Some(id)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some((x, y)) = a.origin {
|
||||
if x >= 0 && y >= 0 && regions.iter().any(|r| r.x == x as u32 && r.y == y as u32) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
struct DeviceSlot {
|
||||
device: reis::event::Device,
|
||||
/// The device is resumed (allowed to emit). Devices arrive paused and may pause again.
|
||||
@@ -430,6 +504,30 @@ struct EiState {
|
||||
output_hint: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// The anchor whose miss we last warned about, so a persistently unmatchable anchor logs once per
|
||||
/// *change* instead of once per pointer sample (absolute motion arrives at client frame rate).
|
||||
static LAST_WARNED_ANCHOR: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Warn — once per distinct anchor — that the session's anchor names an output this EIS doesn't
|
||||
/// advertise, so absolute coordinates fell back to size matching. See [`region_for_mode`].
|
||||
fn warn_anchor_miss(anchor: &AbsoluteAnchor, regions: &[reis::event::Region]) {
|
||||
let key = format!("{anchor:?}");
|
||||
let mut last = LAST_WARNED_ANCHOR.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.as_deref() == Some(key.as_str()) {
|
||||
return;
|
||||
}
|
||||
*last = Some(key);
|
||||
tracing::warn!(
|
||||
?anchor,
|
||||
regions = ?regions
|
||||
.iter()
|
||||
.map(|r| (r.x, r.y, r.width, r.height, r.mapping_id.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
"libei: the session's absolute-coordinate anchor matches no EIS region — falling back to \
|
||||
size matching, so the pointer may land on the wrong monitor"
|
||||
);
|
||||
}
|
||||
|
||||
/// Is this EIS region a plausible OUTPUT geometry — something to map normalized coordinates
|
||||
/// into? gamescope advertises a degenerate `(0,0,INT32_MAX,INT32_MAX)` "everything" region on
|
||||
/// its virtual input device, meaning "absolute coordinates are raw"; normalizing into it
|
||||
@@ -763,13 +861,23 @@ impl EiState {
|
||||
// raw client pixels as the last resort.
|
||||
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
|
||||
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
|
||||
let (x, y) = match region_for_mode(slot.regions(), w, h)
|
||||
let anchor = crate::absolute_anchor();
|
||||
if let Some(a) = anchor
|
||||
.as_ref()
|
||||
.filter(|a| anchor_missed(slot.regions(), Some(a)))
|
||||
{
|
||||
warn_anchor_miss(a, slot.regions());
|
||||
}
|
||||
let (x, y) = match region_for_mode(slot.regions(), w, h, anchor.as_ref())
|
||||
.filter(|r| sane_region(r))
|
||||
{
|
||||
Some(region) => (
|
||||
region.x as f32 + nx * region.width as f32,
|
||||
region.y as f32 + ny * region.height as f32,
|
||||
),
|
||||
Some(region) => {
|
||||
note_abs_region(region, anchor.as_ref());
|
||||
(
|
||||
region.x as f32 + nx * region.width as f32,
|
||||
region.y as f32 + ny * region.height as f32,
|
||||
)
|
||||
}
|
||||
// Degenerate/absent region: scale into the relay-file output hint
|
||||
// (correct even when the client streams at a different resolution
|
||||
// than the session runs); raw client pixels as the last resort.
|
||||
@@ -847,14 +955,20 @@ impl EiState {
|
||||
Some(t) if w > 0.0 && h > 0.0 => {
|
||||
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
|
||||
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
|
||||
// Same region-selection + degenerate fallback ladder as MouseMoveAbs.
|
||||
let (x, y) = match region_for_mode(slot.regions(), w, h)
|
||||
// Same region-selection + degenerate fallback ladder as MouseMoveAbs
|
||||
// (including the session anchor — touch must land on the same monitor the
|
||||
// pointer does, or a mirrored session's taps go to a different screen).
|
||||
let anchor = crate::absolute_anchor();
|
||||
let (x, y) = match region_for_mode(slot.regions(), w, h, anchor.as_ref())
|
||||
.filter(|r| sane_region(r))
|
||||
{
|
||||
Some(region) => (
|
||||
region.x as f32 + nx * region.width as f32,
|
||||
region.y as f32 + ny * region.height as f32,
|
||||
),
|
||||
Some(region) => {
|
||||
note_abs_region(region, anchor.as_ref());
|
||||
(
|
||||
region.x as f32 + nx * region.width as f32,
|
||||
region.y as f32 + ny * region.height as f32,
|
||||
)
|
||||
}
|
||||
None => match self.output_hint {
|
||||
Some((ow, oh)) => (nx * ow as f32, ny * oh as f32),
|
||||
None => (ev.x as f32, ev.y as f32),
|
||||
@@ -913,3 +1027,122 @@ impl EiState {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn region(x: u32, y: u32, w: u32, h: u32, mapping_id: Option<&str>) -> reis::event::Region {
|
||||
reis::event::Region {
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
scale: 1.0,
|
||||
mapping_id: mapping_id.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// The case the anchor exists for: two heads at the SAME size, so the size rung is a coin
|
||||
/// flip. The origin picks the right one.
|
||||
#[test]
|
||||
fn the_origin_disambiguates_two_same_size_monitors() {
|
||||
let regions = [
|
||||
region(0, 0, 1920, 1080, None),
|
||||
region(1920, 0, 1920, 1080, None),
|
||||
];
|
||||
let anchor = AbsoluteAnchor {
|
||||
origin: Some((1920, 0)),
|
||||
mapping_id: None,
|
||||
};
|
||||
let picked = region_for_mode(®ions, 1920.0, 1080.0, Some(&anchor)).unwrap();
|
||||
assert_eq!((picked.x, picked.y), (1920, 0));
|
||||
// Without the anchor the same call takes the first same-sized region — the old behavior,
|
||||
// preserved deliberately for the client-sized virtual-output path.
|
||||
let picked = region_for_mode(®ions, 1920.0, 1080.0, None).unwrap();
|
||||
assert_eq!((picked.x, picked.y), (0, 0));
|
||||
}
|
||||
|
||||
/// `mapping_id` outranks the origin: it is the protocol's own stream↔region correlation, so a
|
||||
/// stale/rounded origin can't override it.
|
||||
#[test]
|
||||
fn mapping_id_outranks_the_origin() {
|
||||
let regions = [
|
||||
region(0, 0, 1920, 1080, Some("head-a")),
|
||||
region(1920, 0, 1920, 1080, Some("head-b")),
|
||||
];
|
||||
let anchor = AbsoluteAnchor {
|
||||
origin: Some((0, 0)),
|
||||
mapping_id: Some("head-b".into()),
|
||||
};
|
||||
let picked = region_for_mode(®ions, 1920.0, 1080.0, Some(&anchor)).unwrap();
|
||||
assert_eq!(picked.mapping_id.as_deref(), Some("head-b"));
|
||||
}
|
||||
|
||||
/// A mirrored monitor's region is NOT the client's streamed size, so the size rung would miss
|
||||
/// it entirely — the origin is what makes this land.
|
||||
#[test]
|
||||
fn the_anchor_finds_a_monitor_the_streamed_size_does_not_match() {
|
||||
let regions = [
|
||||
region(0, 0, 1920, 1080, None),
|
||||
region(1920, 0, 3840, 2160, None),
|
||||
];
|
||||
// Client streams 1280x720 of a 4K head parked to the right.
|
||||
let anchor = AbsoluteAnchor {
|
||||
origin: Some((1920, 0)),
|
||||
mapping_id: None,
|
||||
};
|
||||
let picked = region_for_mode(®ions, 1280.0, 720.0, Some(&anchor)).unwrap();
|
||||
assert_eq!((picked.width, picked.height), (3840, 2160));
|
||||
}
|
||||
|
||||
/// An anchor that names nothing present must not strand input: fall back down the ladder
|
||||
/// (size, then first) — and say so, which `anchor_missed` drives.
|
||||
#[test]
|
||||
fn an_unmatched_anchor_falls_back_and_is_reported() {
|
||||
let regions = [region(0, 0, 1920, 1080, None)];
|
||||
let anchor = AbsoluteAnchor {
|
||||
origin: Some((5000, 5000)),
|
||||
mapping_id: None,
|
||||
};
|
||||
assert!(anchor_missed(®ions, Some(&anchor)));
|
||||
let picked = region_for_mode(®ions, 1920.0, 1080.0, Some(&anchor)).unwrap();
|
||||
assert_eq!((picked.x, picked.y), (0, 0), "fell back to the size match");
|
||||
// A matched anchor is never reported as missed.
|
||||
let ok = AbsoluteAnchor {
|
||||
origin: Some((0, 0)),
|
||||
mapping_id: None,
|
||||
};
|
||||
assert!(!anchor_missed(®ions, Some(&ok)));
|
||||
assert!(!anchor_missed(®ions, None), "no anchor is not a miss");
|
||||
}
|
||||
|
||||
/// EI region offsets are unsigned; a negative anchor origin can only ever match nothing, and
|
||||
/// must not be cast into a huge u32 that accidentally matches something.
|
||||
#[test]
|
||||
fn a_negative_origin_matches_nothing_rather_than_wrapping() {
|
||||
let regions = [region(0, 0, 1920, 1080, None)];
|
||||
let anchor = AbsoluteAnchor {
|
||||
origin: Some((-1920, 0)),
|
||||
mapping_id: None,
|
||||
};
|
||||
assert!(anchor_missed(®ions, Some(&anchor)));
|
||||
let picked = region_for_mode(®ions, 1920.0, 1080.0, Some(&anchor)).unwrap();
|
||||
assert_eq!((picked.x, picked.y), (0, 0));
|
||||
}
|
||||
|
||||
/// An empty anchor is the same as no anchor — so a caller can build one unconditionally and
|
||||
/// let the setter drop it.
|
||||
#[test]
|
||||
fn an_empty_anchor_is_dropped_by_the_setter() {
|
||||
crate::set_absolute_anchor(Some(AbsoluteAnchor::default()));
|
||||
assert_eq!(crate::absolute_anchor(), None);
|
||||
crate::set_absolute_anchor(Some(AbsoluteAnchor {
|
||||
origin: Some((1920, 0)),
|
||||
mapping_id: None,
|
||||
}));
|
||||
assert_eq!(crate::absolute_anchor().unwrap().origin, Some((1920, 0)));
|
||||
crate::set_absolute_anchor(None);
|
||||
assert_eq!(crate::absolute_anchor(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,7 +551,7 @@ pub fn deck_unit_id(index: u8) -> u32 {
|
||||
/// serial passes, so we keep the PunktFunk marker one slot in (`"FVPF"`) — still distinct from a
|
||||
/// real Deck's `"FVZZ"` for the self-detection below while satisfying Steam's format check.
|
||||
/// Derived from [`deck_unit_id`] so the `0xAE` serial reply and the `0x83` unit-id attrs stay
|
||||
/// consistent. (The Windows UMDF driver mirrors this exact format — see pf-dualsense lib.rs.)
|
||||
/// consistent. (The Windows UMDF driver mirrors this exact format — see pf-gamepad lib.rs.)
|
||||
pub fn deck_serial(index: u8) -> String {
|
||||
format!("FVPF{:08X}", deck_unit_id(index))
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user