forked from unom/punktfunk
Compare commits
206
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
|
||||
|
||||
@@ -99,3 +99,41 @@ jobs:
|
||||
mkdir -p ~/unom-flatpak/site/repo
|
||||
cd ~/unom-flatpak
|
||||
docker compose -f compose.production.yml up -d
|
||||
|
||||
winget:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync winget source compose + server
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
# Land all three flat in ~/unom-winget/ (drop the packaging/winget/server/ prefix).
|
||||
source: "packaging/winget/server/compose.production.yml,packaging/winget/server/server.mjs,packaging/winget/server/handler.mjs"
|
||||
target: "~/unom-winget"
|
||||
strip_components: 3
|
||||
overwrite: true
|
||||
|
||||
- name: Start winget REST source
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
# ./data/data.json is NOT shipped by this workflow — windows-host.yml rsyncs it on each
|
||||
# stable tag (same content/config split as the flatpak repo). Ensure the bind-mount
|
||||
# source exists so the container starts; it serves 503 until the first catalogue lands.
|
||||
mkdir -p ~/unom-winget/data
|
||||
cd ~/unom-winget
|
||||
docker compose -f compose.production.yml up -d
|
||||
# Surface a missing catalogue here rather than letting winget report "no package found".
|
||||
sleep 3
|
||||
curl -fsS http://127.0.0.1:3240/healthz || echo "NOTE: no catalogue yet - publish a stable tag to populate it"
|
||||
|
||||
@@ -39,6 +39,25 @@ jobs:
|
||||
working-directory: plugin-kit
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
|
||||
# bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking each
|
||||
# top-level FILE to itself — `node_modules/@punktfunk/host/package.json -> package.json`, a
|
||||
# dangling self-reference. `dist/` therefore arrives intact while the manifest that points at
|
||||
# it does not, so module resolution dies at the first step and every `@punktfunk/host` import
|
||||
# reads as "cannot find module". Replacing the tree with a real copy is the whole fix; drop
|
||||
# this step once bun links `file:` deps correctly again.
|
||||
- name: "Repair the file: dependency (bun 1.3 self-symlink)"
|
||||
working-directory: plugin-kit
|
||||
run: |
|
||||
# -f follows the link, so this is true only when the manifest actually resolves.
|
||||
if test -f node_modules/@punktfunk/host/package.json; then
|
||||
echo "bun linked it correctly — this step can go"
|
||||
else
|
||||
rm -rf node_modules/@punktfunk/host
|
||||
cp -R ../sdk node_modules/@punktfunk/host
|
||||
fi
|
||||
test -f node_modules/@punktfunk/host/package.json
|
||||
test -f node_modules/@punktfunk/host/dist/index.d.ts
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: plugin-kit
|
||||
run: bun run typecheck
|
||||
|
||||
@@ -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,24 +172,80 @@ jobs:
|
||||
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
|
||||
# pf-vkhdr-layer's clippy below runs --release.
|
||||
#
|
||||
# pf-encode is linted SEPARATELY with --all-targets so its Windows `#[cfg(test)]` modules
|
||||
# are type-checked — the 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, and the
|
||||
# PyroWave-Windows smoke test. The host lint above cannot cover them: `-p punktfunk-host`
|
||||
# only builds pf-encode as a dependency, so its test targets are never compiled, and that
|
||||
# blind spot is what let the Linux twin's tests rot to the wrong arity unnoticed.
|
||||
# NOTE: clippy (a check, no link step) is deliberately the vehicle here — `cargo test`
|
||||
# with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk link-imports
|
||||
# NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve only against
|
||||
# the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can and does
|
||||
# run the tests there.) Running them here would need an `--features amf-qsv,qsv` build
|
||||
# without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 — not
|
||||
# worth it while ci.yml executes the same tests.
|
||||
# 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
|
||||
# cannot cover them: `-p punktfunk-host` only builds those crates as dependencies, so their
|
||||
# test targets are never compiled anywhere, and that blind spot is what let the Linux twin's
|
||||
# tests rot to the wrong arity unnoticed. pf-capture has no cargo features, so it needs no
|
||||
# feature juggling and pulls in no extra dep tree.
|
||||
# NOTE: for the HOST and pf-encode, clippy (a check, no link step) is deliberately the
|
||||
# vehicle — `cargo test` with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk
|
||||
# link-imports NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve
|
||||
# only against the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can
|
||||
# and does run the tests there.) Running them here would need an `--features amf-qsv,qsv`
|
||||
# build without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 —
|
||||
# not worth it while ci.yml executes the same tests.
|
||||
#
|
||||
# That reasoning does NOT extend to pf-capture: it has no encoder dependency at all
|
||||
# (`cargo tree -p pf-capture` lists no nvidia/ffmpeg/libvpl/pyrowave), so its test binary
|
||||
# links against nothing this runner lacks, and it reuses the release artifacts the steps
|
||||
# above already built. Its Windows `#[test]`s — StallWatch, the f16 conversions, the cursor
|
||||
# truth table, the IDD generation masking — are Windows-only code that NO other job can
|
||||
# execute, so linting them was leaving real coverage on the table. See the run step below.
|
||||
run: |
|
||||
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)
|
||||
shell: pwsh
|
||||
# The only Rust tests that RUN on Windows CI. pf-capture's `#[cfg(target_os = "windows")]`
|
||||
# test modules cover code no Linux job compiles, let alone executes: 19 declared, of which
|
||||
# 18 execute here — the IDD-push StallWatch state machine and ring-generation masking
|
||||
# (idd_push.rs), the cursor shape→wire truth table (idd_push/cursor_poll.rs), and
|
||||
# `f32_to_f16` including the rounding-carry / saturation edges the HDR P010 path depends on
|
||||
# (dxgi/selftest.rs). All 18 are pure — no Win32, no device, no desktop. The 19th,
|
||||
# `hdr_p010_selftest_intel_1080_live`, is `#[ignore]`d because it needs a real Intel
|
||||
# adapter; it stays a manual `-- --ignored` run on the validation boxes. Until this step
|
||||
# the whole set was type-checked by the clippy line above and nothing more.
|
||||
#
|
||||
# --release for the same reason as the clippy step: it reuses C:\t\release instead of
|
||||
# spawning a second debug dep tree (the C1069 disk-exhaustion trigger). If this step ever
|
||||
# starts tripping C1069 anyway, record THAT here rather than quietly dropping the step.
|
||||
#
|
||||
# The link question this step turns on was settled empirically before it was added: the same
|
||||
# command was run on a Windows dev box against a workspace checkout and linked + executed
|
||||
# cleanly, building in ~51 s off an existing release target dir.
|
||||
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
|
||||
@@ -238,7 +294,15 @@ jobs:
|
||||
Add-Content -Path $rc -Value "//git.unom.io/api/packages/unom/npm/:_authToken=$env:REGISTRY_TOKEN"
|
||||
}
|
||||
Push-Location web
|
||||
& $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
|
||||
# `--ignore-scripts` like every other web install in CI (ci.yml, web-screenshots.yml,
|
||||
# sdk/plugin-kit-publish, and the SDK install further down this same file). This step was
|
||||
# the one site that ran lifecycle scripts, and web's `postinstall` is `bun2nix -o bun.nix`
|
||||
# — a NIX codegen step that shells out to `bun` on PATH. CI runs a fetched PORTABLE bun by
|
||||
# absolute path (`$env:BUN_EXE`), so PATH has none, and bun2nix aborted the install:
|
||||
# error: bun is not installed in %PATH% ... postinstall script exited with 255
|
||||
# Nothing here needs those scripts — `build` re-runs its own `prebuild` codegen — and
|
||||
# bun.nix is a Nix artifact this job neither consumes nor commits.
|
||||
& $bun install --frozen-lockfile --ignore-scripts; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
|
||||
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
|
||||
if (-not (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet)) {
|
||||
throw "web build is not a bun bundle - need the 'bun' preset + custom entry"
|
||||
@@ -326,3 +390,90 @@ jobs:
|
||||
foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
|
||||
# winget manifests for the release just attached above. Runs AFTER the attach step so the
|
||||
# InstallerUrl the manifest pins is already live — winget validates the URL + hash, and a
|
||||
# manifest published ahead of its artifact is a hard 404 for every client that picks it up.
|
||||
# Stable tags only: winget pins one immutable artifact per version, so the rolling `canary/`
|
||||
# alias has nothing it could point at.
|
||||
- name: Emit + attach winget manifests (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
& scripts/ci/winget-manifest.ps1 `
|
||||
-Version $env:HOST_VERSION -InstallerPath $env:HOST_SETUP_PATH -OutDir C:\t\out\winget
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in (Get-ChildItem C:\t\out\winget -Filter *.yaml)) {
|
||||
Upsert-GiteaAsset -ReleaseId $rid -File $f.FullName
|
||||
}
|
||||
|
||||
# Republish the winget REST source on unom-1 once the release above carries its manifests.
|
||||
#
|
||||
# A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do
|
||||
# not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data
|
||||
# reads the manifests from the release, so it must not run before they are attached.
|
||||
winget-source:
|
||||
needs: package
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# build-data re-derives the WHOLE catalogue from the releases rather than appending this one,
|
||||
# so the result cannot drift and re-running any tag reproduces it byte for byte.
|
||||
- name: Build + test the source catalogue
|
||||
working-directory: packaging/winget/server
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm install --no-audit --no-fund
|
||||
node build-data.mjs --out data/data.json
|
||||
# A wrong response SHAPE does not fail loudly — winget just reports "no package found".
|
||||
# Gate on the suite before anything reaches the box.
|
||||
node test.mjs
|
||||
|
||||
# Content only. server.mjs/handler.mjs/compose land via deploy-services.yml, matching how the
|
||||
# flatpak repo's content and config deploy on separate paths.
|
||||
- name: Ship the catalogue to unom-1
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
source: "packaging/winget/server/data/data.json"
|
||||
target: "~/unom-winget/data"
|
||||
strip_components: 4
|
||||
overwrite: true
|
||||
|
||||
# No restart: server.mjs reloads on mtime change. This only proves the new catalogue is the
|
||||
# one actually being served, and fails the release if it is not.
|
||||
- name: Verify the served catalogue
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
curl -fsS http://127.0.0.1:3240/healthz
|
||||
echo
|
||||
curl -fsS -X POST http://127.0.0.1:3240/manifestSearch \
|
||||
-H 'content-type: application/json' -d '{"FetchAllManifests":true}' \
|
||||
| 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
+37
-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.19.2"
|
||||
version = "0.21.0"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2299,7 +2306,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2334,7 +2341,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2823,7 +2830,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2844,7 +2851,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.19.2"
|
||||
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.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2886,7 +2894,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2907,7 +2915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2931,7 +2939,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2940,7 +2948,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2952,7 +2960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2966,11 +2974,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2999,14 +3007,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3021,7 +3029,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3043,16 +3051,18 @@ dependencies = [
|
||||
"sha2",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"utoipa",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3064,7 +3074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3272,7 +3282,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3288,7 +3298,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3304,7 +3314,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3319,7 +3329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3338,7 +3348,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3370,7 +3380,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3454,7 +3464,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3468,7 +3478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.19.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3491,7 +3501,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.19.2"
|
||||
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.19.2"
|
||||
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"
|
||||
|
||||
@@ -96,7 +96,7 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
|
||||
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
||||
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
|
||||
|
||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
|
||||
|
||||
+847
-138
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -990,7 +990,9 @@ struct ContentView: View {
|
||||
rawValue: UInt32(clamping: gamepadType)) ?? .auto)
|
||||
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"],
|
||||
let g = PunktfunkConnection.GamepadType(name: name) {
|
||||
pad = g
|
||||
// Back through resolveType so the lever is adopted as the session's setting: the
|
||||
// per-pad arrivals declare it too, which is what the host actually builds from.
|
||||
pad = GamepadManager.shared.resolveType(setting: g)
|
||||
}
|
||||
var bitrate = UInt32(clamping: bitrateKbps)
|
||||
if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"],
|
||||
|
||||
@@ -55,7 +55,13 @@ public final class GamepadCapture {
|
||||
/// Wire pad index (GamepadManager's stable lowest-free assignment), threaded onto every
|
||||
/// event this controller sends — the low byte of `flags`.
|
||||
let pad: UInt32
|
||||
/// The controller KIND declared to the host (GamepadArrival) when the slot opened.
|
||||
/// The controller KIND declared to the host (GamepadArrival) when the slot opened — the
|
||||
/// user's explicit "Controller type" setting when they picked one, else the detected
|
||||
/// kind (`GamepadManager.declaredKind(for:)`). NOT the physical pad's kind: local feedback
|
||||
/// keys off the live `GCController` subclass instead, so whatever the host DOES send is
|
||||
/// applied natively to the pad in the user's hands. What the host sends is bounded by the
|
||||
/// emulated type, though — a virtual DualShock 4 has no adaptive-trigger reports in its
|
||||
/// protocol, so emulating one gives those up by construction (rumble + lightbar remain).
|
||||
let pref: PunktfunkConnection.GamepadType
|
||||
var buttons: UInt32 = 0
|
||||
var axes: [Int32] = [0, 0, 0, 0, 0, 0]
|
||||
@@ -166,7 +172,7 @@ public final class GamepadCapture {
|
||||
private func openSlot(_ dc: GamepadManager.DiscoveredController) {
|
||||
guard let pad = manager.padIndex(for: dc), let ext = dc.controller.extendedGamepad else { return }
|
||||
let c = dc.controller
|
||||
let slot = Slot(controller: c, pad: UInt32(pad), pref: dc.kind)
|
||||
let slot = Slot(controller: c, pad: UInt32(pad), pref: manager.declaredKind(for: dc))
|
||||
slots.append(slot)
|
||||
|
||||
ext.valueChangedHandler = { [weak self, weak slot] g, _ in
|
||||
@@ -192,9 +198,12 @@ public final class GamepadCapture {
|
||||
}
|
||||
}
|
||||
// Declare this pad's controller KIND before any of its input, so the host builds a
|
||||
// matching virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core
|
||||
// re-sends it a few times against datagram loss; an older host ignores it and uses the
|
||||
// session-default kind. Then wake the host pad (pads are created lazily from the first
|
||||
// matching virtual device — the user's chosen type when they picked one, else per-pad
|
||||
// detection (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). This declaration is
|
||||
// what the host actually builds from, so it MUST carry an explicit setting; the
|
||||
// handshake's session default is only the fallback for a pad that never declares. The
|
||||
// core re-sends it a few times against datagram loss; an older host ignores it and uses
|
||||
// the session-default kind. Then wake the host pad (pads are created lazily from the first
|
||||
// event; a DualSense's UHID handshake + initial lightbar write only start then).
|
||||
connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad))
|
||||
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad))
|
||||
|
||||
@@ -131,13 +131,40 @@ public final class GamepadManager: ObservableObject {
|
||||
GCController.stopWirelessControllerDiscovery()
|
||||
}
|
||||
|
||||
/// The user's controller-type choice AS CHOSEN (not resolved) for the session being dialed —
|
||||
/// adopted by `resolveType` and read back by `declaredKind(for:)`. `.auto` = detect per pad.
|
||||
public private(set) var typeSetting: PunktfunkConnection.GamepadType = .auto
|
||||
|
||||
/// The kind to DECLARE to the host for one forwarded controller (its `GamepadArrival`).
|
||||
/// An explicit setting wins for every pad — the handshake's session default alone does NOT
|
||||
/// stick, because a current host honors the per-pad arrival over it (punktfunk-host's
|
||||
/// `Pads::set_kind`), so a client that declared only the detected kind here would silently
|
||||
/// undo the user's choice. `.auto` keeps per-pad detection, which is what makes a mixed
|
||||
/// session (pad 0 a DualSense, pad 1 an Xbox pad) honest.
|
||||
public func declaredKind(
|
||||
for controller: DiscoveredController
|
||||
) -> PunktfunkConnection.GamepadType {
|
||||
Self.declaredKind(setting: typeSetting, detected: controller.kind)
|
||||
}
|
||||
|
||||
/// The pure fold behind `declaredKind(for:)` (pf-client-core's `declared_kind`).
|
||||
nonisolated static func declaredKind(
|
||||
setting: PunktfunkConnection.GamepadType,
|
||||
detected: PunktfunkConnection.GamepadType
|
||||
) -> PunktfunkConnection.GamepadType {
|
||||
setting == .auto ? detected : setting
|
||||
}
|
||||
|
||||
/// Connect-time resolution of the user's controller-type setting: an explicit choice
|
||||
/// wins; `.auto` matches the virtual pad to the active physical controller (DualSense →
|
||||
/// DualSense, DualShock 4 → DualShock 4, an Xbox pad → Xbox One, anything else → Xbox
|
||||
/// 360); no controller at all defers to the host.
|
||||
/// 360); no controller at all defers to the host. Called once per dial with the RAW setting,
|
||||
/// which it also adopts for `declaredKind(for:)` so the handshake default and every pad's
|
||||
/// arrival can never disagree about an explicit choice.
|
||||
public func resolveType(
|
||||
setting: PunktfunkConnection.GamepadType
|
||||
) -> PunktfunkConnection.GamepadType {
|
||||
typeSetting = setting
|
||||
guard setting == .auto else { return setting }
|
||||
// Refresh from the LIVE controller list first. `active` is otherwise only populated by the
|
||||
// async `.GCControllerDidConnect` notification, so at connect time it can still be nil even
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -236,6 +236,13 @@ public final class StreamLayerView: NSView {
|
||||
let hotY: Int
|
||||
}
|
||||
private var hostCursors: [UInt32: HostCursorShape] = [:]
|
||||
/// The last shape actually worn. State (`0xD0`, a per-frame datagram) announces a new serial the
|
||||
/// moment the host QUEUES its bitmap on the reliable control stream, so the client routinely
|
||||
/// knows a serial before it holds the pixels — and the shape ring drops the NEWEST under burst
|
||||
/// (`CURSOR_SHAPE_QUEUE`), which the host never re-sends because it only sends on a serial
|
||||
/// CHANGE. Both leave `hostCursors[serial]` empty; wearing the previous pointer through that
|
||||
/// gap degrades it to a briefly-stale shape instead of blinking the pointer out of existence.
|
||||
private var lastWornShape: HostCursorShape?
|
||||
private var cursorState: PunktfunkConnection.CursorStateEvent?
|
||||
/// Last `CursorRenderMode.clientDraws` told to the host (the §8 mid-stream render flip);
|
||||
/// nil = nothing sent yet. Edge-detected by [`reconcileCursorRender`] from the live mouse
|
||||
@@ -509,10 +516,19 @@ public final class StreamLayerView: NSView {
|
||||
override public func resetCursorRects() {
|
||||
if captured && desktopMouse {
|
||||
// Cursor channel active: wear the HOST's pointer shape (it is no longer in the
|
||||
// video); hidden host pointer (or no shape yet) = invisible. Without the channel,
|
||||
// M1 behavior: invisible local cursor, the composited host cursor is the visible one.
|
||||
// video); a HIDDEN host pointer (or nothing seen yet at all) = invisible. Without the
|
||||
// channel, M1 behavior: invisible local cursor, the composited host cursor is the
|
||||
// visible one.
|
||||
//
|
||||
// A visible pointer whose announced serial has no bitmap yet falls back to the last
|
||||
// worn shape (see `lastWornShape`) rather than to `invisibleCursor`. That case is
|
||||
// routine, not degenerate — state outruns its bitmap on every single shape change —
|
||||
// and treating it as "hide the pointer" made the pointer VANISH over anything whose
|
||||
// shape arrived late or got dropped, with no recovery until the next change. Only
|
||||
// `st.visible == false` may hide the pointer; a missing bitmap may not.
|
||||
if cursorChannelActive, let st = cursorState, st.visible,
|
||||
let shape = hostCursors[st.serial] {
|
||||
let shape = hostCursors[st.serial] ?? lastWornShape {
|
||||
lastWornShape = shape
|
||||
addCursorRect(bounds, cursor: scaledCursor(shape))
|
||||
} else {
|
||||
addCursorRect(bounds, cursor: Self.invisibleCursor)
|
||||
@@ -583,6 +599,8 @@ public final class StreamLayerView: NSView {
|
||||
|
||||
private func applyCursorShape(_ ev: PunktfunkConnection.CursorShapeEvent) {
|
||||
guard let shape = Self.makeShape(ev) else {
|
||||
// Truthful only because `resetCursorRects` falls back to `lastWornShape`: before that,
|
||||
// a rejection here left the announced serial with no bitmap and HID the pointer.
|
||||
streamInputLog.warning("cursor shape rejected (\(ev.width)x\(ev.height)) — keeping the previous cursor")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -119,6 +119,25 @@ final class GamepadWireTests: XCTestCase {
|
||||
XCTAssertEqual(GamepadWire.maxPads, 16)
|
||||
}
|
||||
|
||||
func testAnExplicitControllerTypeIsWhatEveryPadDeclares() {
|
||||
// The regression this pins: the "Controller type" setting reached the Hello only, and each
|
||||
// pad's arrival then re-declared the DETECTED kind — which the host honors over the
|
||||
// session default, so "emulate my DualSense as a DualShock 4" produced a DualSense.
|
||||
// (pf-client-core's `declared_kind` test is the same table.)
|
||||
XCTAssertEqual(
|
||||
GamepadManager.declaredKind(setting: .dualShock4, detected: .dualSense), .dualShock4)
|
||||
// Every physical pad in a mixed session follows the one explicit choice.
|
||||
for detected: PunktfunkConnection.GamepadType in [
|
||||
.dualSense, .xbox360, .switchPro, .dualSenseEdge,
|
||||
] {
|
||||
XCTAssertEqual(
|
||||
GamepadManager.declaredKind(setting: .xbox360, detected: detected), .xbox360)
|
||||
}
|
||||
// Automatic keeps per-pad detection — otherwise a mixed session collapses to one type.
|
||||
XCTAssertEqual(GamepadManager.declaredKind(setting: .auto, detected: .dualSense), .dualSense)
|
||||
XCTAssertEqual(GamepadManager.declaredKind(setting: .auto, detected: .switchPro), .switchPro)
|
||||
}
|
||||
|
||||
func testTouchpadConversionCorners() {
|
||||
// GC ±1 with +y up → wire 0...65535 with origin top-left, +y down.
|
||||
let topLeft = GamepadWire.touchpad(x: -1, y: 1)
|
||||
|
||||
@@ -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);
|
||||
@@ -620,6 +644,7 @@ fn mock_library() -> (
|
||||
store: store.to_string(),
|
||||
title: title.to_string(),
|
||||
art: crate::library::Artwork::default(),
|
||||
platform: None,
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
|
||||
@@ -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],
|
||||
@@ -204,9 +221,17 @@ mod session_main {
|
||||
mode,
|
||||
compositor: CompositorPref::from_name(&settings.compositor)
|
||||
.unwrap_or(CompositorPref::Auto),
|
||||
gamepad: match GamepadPref::from_name(&settings.gamepad) {
|
||||
Some(GamepadPref::Auto) | None => gamepad.auto_pref(),
|
||||
Some(explicit) => explicit,
|
||||
gamepad: {
|
||||
// The setting AS CHOSEN goes to the pad service too, not just the Hello: the host
|
||||
// builds each virtual pad from that pad's arrival and only falls back to this
|
||||
// session default for a pad that never declares one, so an explicit choice that
|
||||
// stopped here would be undone the moment a controller connected.
|
||||
let chosen = GamepadPref::from_name(&settings.gamepad).unwrap_or(GamepadPref::Auto);
|
||||
gamepad.set_kind_override(chosen);
|
||||
match chosen {
|
||||
GamepadPref::Auto => gamepad.auto_pref(),
|
||||
explicit => explicit,
|
||||
}
|
||||
},
|
||||
bitrate_kbps: settings.bitrate_kbps,
|
||||
audio_channels: settings.audio_channels,
|
||||
@@ -238,6 +263,7 @@ mod session_main {
|
||||
identity,
|
||||
connect_timeout: connect_timeout(),
|
||||
force_software,
|
||||
profile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,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."
|
||||
);
|
||||
@@ -445,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
|
||||
@@ -515,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;
|
||||
@@ -238,6 +239,7 @@ fn connect_spawn(
|
||||
let target = target.clone();
|
||||
// The closure owns `target`/`fp_hex`; the call itself borrows copies.
|
||||
let (addr, port, fp_arg) = (target.addr.clone(), target.port, fp_hex.clone());
|
||||
let profile_arg = target.profile.clone();
|
||||
let spawned = crate::spawn::spawn_session(
|
||||
&addr,
|
||||
port,
|
||||
@@ -245,6 +247,7 @@ fn connect_spawn(
|
||||
opts.connect_timeout.as_secs(),
|
||||
fullscreen,
|
||||
opts.launch.as_deref(),
|
||||
profile_arg.as_deref(),
|
||||
child,
|
||||
move |event| {
|
||||
use crate::spawn::SpawnEvent;
|
||||
@@ -272,9 +275,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 +421,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 +445,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 +469,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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ const MENU_WAKE: &str = "Wake host";
|
||||
/// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones
|
||||
/// that matter.
|
||||
const MENU_EDIT: &str = "Edit\u{2026}";
|
||||
/// Dynamic menu-item prefixes: this flyout has no submenus, so the profile entries are flat
|
||||
/// items matched by prefix. The trailing space keeps them readable AND keeps a profile named
|
||||
/// e.g. "Copy link" from colliding with a fixed entry.
|
||||
const MENU_WITH: &str = "Connect with: ";
|
||||
const MENU_PIN: &str = "Pin as card: ";
|
||||
const MENU_UNPIN: &str = "Unpin card: ";
|
||||
const MENU_COPY_LINK: &str = "Copy link";
|
||||
const MENU_FORGET: &str = "Forget\u{2026}";
|
||||
|
||||
/// Whether the console (gamepad) UI is available in this build: the session binary ships
|
||||
@@ -163,6 +170,19 @@ pub(crate) struct Hover {
|
||||
|
||||
/// The status row at the bottom of a tile: presence dot + Online/Offline, plus the trust chip.
|
||||
fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
|
||||
status_row_with(online, badge, kind, None)
|
||||
}
|
||||
|
||||
/// [`status_row`] plus the profile chip: what a plain click on THIS tile will use — its own
|
||||
/// profile on a pinned tile, the host's binding on the primary one. A binding whose profile
|
||||
/// was deleted shows nothing and resolves as the defaults, which is what will happen on
|
||||
/// connect (design §6).
|
||||
fn status_row_with(
|
||||
online: Option<bool>,
|
||||
badge: &str,
|
||||
kind: Pill,
|
||||
profile: Option<(&str, Pill)>,
|
||||
) -> Element {
|
||||
let mut items: Vec<Element> = Vec::new();
|
||||
if let Some(online) = online {
|
||||
items.push(
|
||||
@@ -183,6 +203,13 @@ fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.into(),
|
||||
);
|
||||
if let Some((name, kind)) = profile {
|
||||
items.push(
|
||||
pill(name, kind)
|
||||
.vertical_alignment(VerticalAlignment::Center)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
hstack(items)
|
||||
.spacing(6.0)
|
||||
.margin(edges(0.0, 12.0, 0.0, 0.0))
|
||||
@@ -250,6 +277,43 @@ fn edit_editor(
|
||||
se.call(None);
|
||||
}
|
||||
};
|
||||
// The profile binding: what a plain click on this tile will use. It commits on change
|
||||
// rather than at Save — it is a picker with no draft ref, and the rest of the sheet's
|
||||
// fields are text boxes that genuinely need one.
|
||||
let profile_picker = {
|
||||
let catalog = pf_client_core::profiles::ProfilesFile::load();
|
||||
let stored = KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.fp_hex == fp)
|
||||
.and_then(|h| h.profile_id.clone());
|
||||
let mut names = vec!["Default settings".to_string()];
|
||||
let mut ids: Vec<String> = vec![String::new()];
|
||||
for p in &catalog.profiles {
|
||||
names.push(p.name.clone());
|
||||
ids.push(p.id.clone());
|
||||
}
|
||||
// A binding whose profile is gone reads as Default settings — the same "dangling
|
||||
// resolves as none" rule the connect path follows — and is cleaned up on the next pick.
|
||||
let current = stored
|
||||
.as_ref()
|
||||
.and_then(|id| ids.iter().position(|i| i == id))
|
||||
.unwrap_or(0);
|
||||
let fp = fp.to_string();
|
||||
ComboBox::new(names)
|
||||
.header("Profile")
|
||||
.selected_index(current as i32)
|
||||
.on_selection_changed(move |i: i32| {
|
||||
let Some(id) = ids.get(i.max(0) as usize) else {
|
||||
return;
|
||||
};
|
||||
let mut known = KnownHosts::load();
|
||||
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
|
||||
h.profile_id = (!id.is_empty()).then(|| id.clone());
|
||||
let _ = known.save();
|
||||
}
|
||||
})
|
||||
};
|
||||
let field = |label: &str, value: String, placeholder: &str, draft: HookRef<String>| {
|
||||
vstack((
|
||||
text_block(label)
|
||||
@@ -281,6 +345,18 @@ fn edit_editor(
|
||||
"auto-filled when known",
|
||||
mac_draft,
|
||||
),
|
||||
vstack((
|
||||
profile_picker,
|
||||
text_block(
|
||||
"The settings a plain click on this host uses. \u{201c}Connect with\u{201d} \
|
||||
in the tile\u{2019}s menu overrides it for one session without changing it.",
|
||||
)
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::SecondaryText)
|
||||
.wrap()
|
||||
.horizontal_alignment(HorizontalAlignment::Left),
|
||||
))
|
||||
.spacing(4.0),
|
||||
vstack((
|
||||
ToggleSwitch::new(clip0)
|
||||
.header("Share clipboard with this host")
|
||||
@@ -481,6 +557,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
if !known.hosts.is_empty() {
|
||||
body.push(section("SAVED HOSTS"));
|
||||
let mut tiles: Vec<Element> = Vec::new();
|
||||
// One catalog read per render, shared by every tile's menu and chip.
|
||||
let profiles: Vec<(String, String)> = pf_client_core::profiles::ProfilesFile::load()
|
||||
.profiles
|
||||
.into_iter()
|
||||
.map(|p| (p.id, p.name))
|
||||
.collect();
|
||||
for k in &known.hosts {
|
||||
// Rust 2021 (no let-chains): match the "this tile is being edited" case explicitly.
|
||||
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
|
||||
@@ -504,6 +586,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
fp_hex: Some(k.fp_hex.clone()),
|
||||
pair_optional: false,
|
||||
mac: k.mac.clone(),
|
||||
profile: None,
|
||||
};
|
||||
// Online = advertising on mDNS OR proven reachable by the last probe sweep (the latter
|
||||
// covers a routed/Tailscale host that never advertises — the display companion to
|
||||
@@ -525,6 +608,8 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
let (svc, target) = (props.svc.clone(), target.clone());
|
||||
let (sf, sr) = (set_forget.clone(), set_rename.clone());
|
||||
let (fp, name) = (k.fp_hex.clone(), k.name.clone());
|
||||
let menu_profiles = profiles.clone();
|
||||
let (link_host, link_profile) = (k.clone(), None::<String>);
|
||||
button("")
|
||||
.icon(Symbol::More)
|
||||
.subtle()
|
||||
@@ -532,6 +617,24 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
.automation_name("More options")
|
||||
.menu_flyout({
|
||||
let mut items = vec![menu_item(MENU_CONNECT)];
|
||||
// One-off connects, flat: this flyout has no submenus, so each
|
||||
// profile is its own item. "Connect with" NEVER rebinds — the default
|
||||
// is changed in the host editor (design §5.2).
|
||||
for (_, name) in profiles.iter() {
|
||||
items.push(menu_item(format!("{MENU_WITH}{name}")));
|
||||
}
|
||||
if !profiles.is_empty() {
|
||||
items.push(menu_item(format!("{MENU_WITH}Default settings")));
|
||||
for (id, name) in profiles.iter() {
|
||||
let label = if k.pinned_profiles.iter().any(|p| p == id) {
|
||||
format!("{MENU_UNPIN}{name}")
|
||||
} else {
|
||||
format!("{MENU_PIN}{name}")
|
||||
};
|
||||
items.push(menu_item(label));
|
||||
}
|
||||
}
|
||||
items.push(menu_item(MENU_COPY_LINK));
|
||||
// The library surfaces — mouse/KB page and the gamepad console UI —
|
||||
// for paired hosts only (the mgmt API needs the paired identity);
|
||||
// the page additionally sits behind the experimental toggle, the
|
||||
@@ -550,6 +653,52 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
items
|
||||
})
|
||||
.on_item_clicked(move |item: String| match item.as_str() {
|
||||
// The profile items are dynamic, so they are matched by prefix before
|
||||
// the fixed ones.
|
||||
_ if item.starts_with(MENU_WITH) => {
|
||||
let name = item.trim_start_matches(MENU_WITH);
|
||||
let mut target = target.clone();
|
||||
// `Some("")` — not `None` — so "Default settings" really does
|
||||
// override a bound host for this one connect.
|
||||
target.profile = Some(
|
||||
menu_profiles
|
||||
.iter()
|
||||
.find(|(_, n)| n == name)
|
||||
.map(|(id, _)| id.clone())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
initiate(&svc.ctx, target, &svc.set_screen, &svc.set_status)
|
||||
}
|
||||
_ if item.starts_with(MENU_PIN) || item.starts_with(MENU_UNPIN) => {
|
||||
let pin = item.starts_with(MENU_PIN);
|
||||
let name = item
|
||||
.trim_start_matches(MENU_PIN)
|
||||
.trim_start_matches(MENU_UNPIN);
|
||||
let Some((id, _)) =
|
||||
menu_profiles.iter().find(|(_, n)| n == name).cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut known = KnownHosts::load();
|
||||
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
|
||||
h.pinned_profiles.retain(|p| p != &id);
|
||||
if pin {
|
||||
h.pinned_profiles.push(id);
|
||||
}
|
||||
let _ = known.save();
|
||||
}
|
||||
// The tile list re-reads the store on the next render; nudge it.
|
||||
sr.call(None);
|
||||
}
|
||||
MENU_COPY_LINK => {
|
||||
let url = pf_client_core::deeplink::DeepLink::for_host(
|
||||
&link_host,
|
||||
None,
|
||||
link_profile.as_deref(),
|
||||
)
|
||||
.to_url();
|
||||
pf_client_core::clipboard::set_text(&url);
|
||||
}
|
||||
MENU_CONNECT => {
|
||||
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
|
||||
}
|
||||
@@ -575,15 +724,20 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
})
|
||||
};
|
||||
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
let pinned_base = target.clone();
|
||||
tiles.push(host_tile(
|
||||
&k.fp_hex,
|
||||
&hover,
|
||||
&k.name,
|
||||
&format!("{}:{}", k.addr, k.port),
|
||||
status_row(
|
||||
status_row_with(
|
||||
Some(online),
|
||||
if k.paired { "Paired" } else { "Trusted" },
|
||||
if k.paired { Pill::Good } else { Pill::Info },
|
||||
k.profile_id
|
||||
.as_ref()
|
||||
.and_then(|id| profiles.iter().find(|(pid, _)| pid == id))
|
||||
.map(|(_, name)| (name.as_str(), Pill::Neutral)),
|
||||
),
|
||||
Some(menu),
|
||||
Some(Box::new(move || {
|
||||
@@ -598,6 +752,41 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
}
|
||||
})),
|
||||
));
|
||||
|
||||
// …then this host's pinned host+profile tiles, in the order they were pinned
|
||||
// (design §5.2a). They share the host's live status because they read the same
|
||||
// record, and a pin whose profile is gone simply doesn't render. No menu of their
|
||||
// own: a pinned tile is a shortcut, not a second host, and pin/unpin already live
|
||||
// on the primary tile's menu — the one place you decide it.
|
||||
for id in &k.pinned_profiles {
|
||||
let Some((id, name)) = profiles.iter().find(|(pid, _)| pid == id) else {
|
||||
continue;
|
||||
};
|
||||
let (ctx3, ss3, st3) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
let mut pinned_target = pinned_base.clone();
|
||||
pinned_target.profile = Some(id.clone());
|
||||
tiles.push(host_tile(
|
||||
// Its own hover key: two tiles for one host must not light up together.
|
||||
&format!("{}#{id}", k.fp_hex),
|
||||
&hover,
|
||||
&k.name,
|
||||
&format!("{}:{}", k.addr, k.port),
|
||||
status_row_with(
|
||||
Some(online),
|
||||
if k.paired { "Paired" } else { "Trusted" },
|
||||
if k.paired { Pill::Good } else { Pill::Info },
|
||||
Some((name.as_str(), Pill::Info)),
|
||||
),
|
||||
None,
|
||||
Some(Box::new(move || {
|
||||
if can_wake {
|
||||
initiate_waking(&ctx3, pinned_target.clone(), &ss3, &st3);
|
||||
} else {
|
||||
initiate(&ctx3, pinned_target.clone(), &ss3, &st3);
|
||||
}
|
||||
})),
|
||||
));
|
||||
}
|
||||
}
|
||||
body.push(tile_grid(tiles, cols, TILE_GAP));
|
||||
}
|
||||
@@ -634,6 +823,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
|
||||
pair_optional: h.pair == "optional",
|
||||
mac: h.mac.clone(),
|
||||
profile: None,
|
||||
};
|
||||
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
let (badge, kind) = if h.pair == "required" {
|
||||
@@ -716,6 +906,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
fp_hex: None,
|
||||
pair_optional: false,
|
||||
mac: Vec::new(),
|
||||
profile: None,
|
||||
},
|
||||
&ss,
|
||||
&st,
|
||||
|
||||
@@ -80,6 +80,11 @@ pub(crate) struct Target {
|
||||
/// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert) — used to send a
|
||||
/// magic packet before connecting to an offline host. Empty when none is known.
|
||||
pub(crate) mac: Vec<String>,
|
||||
/// A ONE-OFF settings profile for this connect ("Connect with"): `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 default changes only through
|
||||
/// the picker in the host editor (design/client-settings-profiles.md §5.2).
|
||||
pub(crate) profile: Option<String>,
|
||||
}
|
||||
|
||||
/// Stable app services handed to the page components as props. Each routed screen that uses
|
||||
@@ -243,6 +248,17 @@ 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);
|
||||
// Bumped when a settings edit changes what the page should SHOW without changing any state
|
||||
// it already reads — resetting an override, which rewrites the catalog behind the controls.
|
||||
// Root state comparison makes same-value calls free, so a counter is what forces the pass.
|
||||
let (settings_rev, set_settings_rev) = cx.use_async_state(0u64);
|
||||
// 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 +505,12 @@ 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,
|
||||
settings_rev,
|
||||
&set_settings_rev,
|
||||
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,171 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
/// Which tier-P rows the profile in scope overrides. Plain bools rather than a lookup so the
|
||||
/// call sites read as `over.codec` — the row and its flag stay visibly paired.
|
||||
#[derive(Default)]
|
||||
struct OverrideFlags {
|
||||
resolution: bool,
|
||||
refresh_hz: bool,
|
||||
render_scale: bool,
|
||||
bitrate_kbps: bool,
|
||||
codec: bool,
|
||||
hdr_enabled: bool,
|
||||
compositor: bool,
|
||||
audio_channels: bool,
|
||||
mic_enabled: bool,
|
||||
touch_mode: bool,
|
||||
mouse_mode: bool,
|
||||
invert_scroll: bool,
|
||||
inhibit_shortcuts: bool,
|
||||
gamepad: bool,
|
||||
stats_verbosity: bool,
|
||||
fullscreen_on_stream: bool,
|
||||
}
|
||||
|
||||
impl OverrideFlags {
|
||||
fn of(profile: Option<&StreamProfile>) -> OverrideFlags {
|
||||
let Some(o) = profile.map(|p| &p.overrides) else {
|
||||
return OverrideFlags::default();
|
||||
};
|
||||
OverrideFlags {
|
||||
// One control drives the width/height/match-window tri-state, so any of the three
|
||||
// marks the row.
|
||||
resolution: o.width.is_some() || o.height.is_some() || o.match_window.is_some(),
|
||||
refresh_hz: o.refresh_hz.is_some(),
|
||||
render_scale: o.render_scale.is_some(),
|
||||
bitrate_kbps: o.bitrate_kbps.is_some(),
|
||||
codec: o.codec.is_some(),
|
||||
hdr_enabled: o.hdr_enabled.is_some(),
|
||||
compositor: o.compositor.is_some(),
|
||||
audio_channels: o.audio_channels.is_some(),
|
||||
mic_enabled: o.mic_enabled.is_some(),
|
||||
touch_mode: o.touch_mode.is_some(),
|
||||
mouse_mode: o.mouse_mode.is_some(),
|
||||
invert_scroll: o.invert_scroll.is_some(),
|
||||
inhibit_shortcuts: o.inhibit_shortcuts.is_some(),
|
||||
gamepad: o.gamepad.is_some(),
|
||||
stats_verbosity: o.stats_verbosity.is_some(),
|
||||
fullscreen_on_stream: o.fullscreen_on_stream.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +289,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));
|
||||
})
|
||||
}
|
||||
|
||||
@@ -162,6 +311,52 @@ fn setting_toggle(
|
||||
/// but a caption under it reads as a caption, which is how every Windows Settings page and
|
||||
/// the Apple client both do it. Width-capped for the same reason Apple caps at 360pt: a
|
||||
/// full-width caption runs into the control column and the whole cell reads as one block.
|
||||
/// [`described`], plus the override marker and reset a profile-scope row carries: the caption
|
||||
/// says the profile changes this one, and the button is the only way back to inheriting —
|
||||
/// overrides are recorded on touch and never inferred from a value comparison, so "not
|
||||
/// overridden" needs an explicit act.
|
||||
fn described_overridable(
|
||||
rev: (u64, &AsyncSetState<u64>),
|
||||
scope: &str,
|
||||
field: &'static str,
|
||||
overridden: bool,
|
||||
control: impl Into<Element>,
|
||||
caption: &str,
|
||||
) -> Element {
|
||||
if scope.is_empty() || !overridden {
|
||||
return described(control, caption);
|
||||
}
|
||||
let (rev, set_rev) = (rev.0, rev.1.clone());
|
||||
let scope = scope.to_string();
|
||||
vstack((
|
||||
control.into(),
|
||||
hstack((
|
||||
text_block(format!("\u{25cf} Overridden here \u{00b7} {caption}"))
|
||||
.font_size(12.0)
|
||||
.foreground(ThemeRef::AccentText)
|
||||
.wrap()
|
||||
.max_width(360.0)
|
||||
.horizontal_alignment(HorizontalAlignment::Left),
|
||||
button("Reset").on_click(move || {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
if let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == scope) {
|
||||
p.overrides.clear(field);
|
||||
if let Err(e) = catalog.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "clearing an override");
|
||||
}
|
||||
}
|
||||
// The catalog changed behind the controls, and nothing the page reads as
|
||||
// state did — bump the revision so the row re-renders showing the inherited
|
||||
// value again.
|
||||
set_rev.call(rev + 1);
|
||||
}),
|
||||
))
|
||||
.spacing(8.0),
|
||||
))
|
||||
.spacing(5.0)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn described(control: impl Into<Element>, caption: &str) -> Element {
|
||||
vstack((
|
||||
control.into(),
|
||||
@@ -220,14 +415,41 @@ 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>>,
|
||||
rev: u64,
|
||||
set_rev: &AsyncSetState<u64>,
|
||||
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();
|
||||
// Which rows this profile overrides — the marker + reset each of them carries. In the
|
||||
// defaults scope nothing is marked, and `described_overridable` degrades to `described`.
|
||||
let over = OverrideFlags::of(active.as_ref());
|
||||
let _ = rev; // read via the closures below; the value itself only forces the re-render
|
||||
// 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 +475,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 +493,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 +507,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 +528,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 +542,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 +551,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 +567,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 +622,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 +650,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 +679,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,
|
||||
@@ -463,14 +704,22 @@ pub(crate) fn settings_page(
|
||||
let mut out = group(
|
||||
Some("Resolution"),
|
||||
vec![
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"resolution",
|
||||
over.resolution,
|
||||
res_combo,
|
||||
"The host drives a real virtual output at exactly this size \u{2014} true \
|
||||
pixels, no scaling. \u{201C}Native display\u{201D} follows the monitor this \
|
||||
window is on; \u{201C}Match window\u{201D} keeps the picture pixel-exact \
|
||||
(1:1) through every resize.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"refresh_hz",
|
||||
over.refresh_hz,
|
||||
hz_combo,
|
||||
"\u{201C}Native\u{201D} resolves to this display\u{2019}s refresh rate at \
|
||||
connect.",
|
||||
@@ -481,24 +730,40 @@ pub(crate) fn settings_page(
|
||||
out.extend(group(
|
||||
Some("Quality"),
|
||||
vec![
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"render_scale",
|
||||
over.render_scale,
|
||||
scale_combo,
|
||||
"Above native supersamples for sharpness; below renders lighter on the \
|
||||
host and the link. This device resamples the result to the window.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"bitrate_kbps",
|
||||
over.bitrate_kbps,
|
||||
bitrate_box,
|
||||
"0 lets the host decide (its default, clamped to what it supports). A \
|
||||
host card\u{2019}s context menu has a network speed test.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"codec",
|
||||
over.codec,
|
||||
codec_combo,
|
||||
"A preference \u{2014} the host falls back if it can\u{2019}t encode it. \
|
||||
PyroWave is the low-latency wavelet codec for a WIRED link: it trades \
|
||||
bitrate (hundreds of Mb/s) for near-zero decode time, so it wants \
|
||||
gigabit Ethernet.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"hdr_enabled",
|
||||
over.hdr_enabled,
|
||||
hdr_toggle,
|
||||
"HDR10, when the host has HDR content and this display supports it. \
|
||||
HEVC only; otherwise the stream stays SDR.",
|
||||
@@ -506,9 +771,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 \
|
||||
@@ -528,7 +796,11 @@ pub(crate) fn settings_page(
|
||||
));
|
||||
out.extend(group(
|
||||
Some("Host output"),
|
||||
vec![described(
|
||||
vec![described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"compositor",
|
||||
over.compositor,
|
||||
comp_combo,
|
||||
"The backend the host uses for its virtual output (Linux hosts only). A \
|
||||
specific choice falls back to auto-detection when that backend \
|
||||
@@ -542,7 +814,11 @@ pub(crate) fn settings_page(
|
||||
"input" => {
|
||||
let mut out = group(
|
||||
Some("Touch & pointer"),
|
||||
vec![described(
|
||||
vec![described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"touch_mode",
|
||||
over.touch_mode,
|
||||
touch_combo,
|
||||
"How a touchscreen drives the host: Trackpad moves the host cursor like a \
|
||||
laptop trackpad (tap to click), Direct pointer jumps the cursor to wherever \
|
||||
@@ -553,19 +829,31 @@ pub(crate) fn settings_page(
|
||||
out.extend(group(
|
||||
Some("Keyboard & mouse"),
|
||||
vec![
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"mouse_mode",
|
||||
over.mouse_mode,
|
||||
mouse_combo,
|
||||
"Capture locks the pointer to the stream and sends relative motion — \
|
||||
best for games. Desktop leaves the pointer free to enter and leave \
|
||||
the stream and sends absolute positions — best for remote desktop \
|
||||
work. Ctrl+Alt+Shift+M switches live.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"inhibit_shortcuts",
|
||||
over.inhibit_shortcuts,
|
||||
shortcuts_toggle,
|
||||
"Alt+Tab, the Windows key and friends reach the host while the stream \
|
||||
has input captured. Off, they act on this machine instead.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"invert_scroll",
|
||||
over.invert_scroll,
|
||||
invert_scroll_toggle,
|
||||
"Reverses the wheel and trackpad scroll direction sent to the host.",
|
||||
),
|
||||
@@ -578,21 +866,32 @@ 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_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"gamepad",
|
||||
over.gamepad,
|
||||
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."),
|
||||
),
|
||||
),
|
||||
@@ -601,12 +900,20 @@ pub(crate) fn settings_page(
|
||||
group(
|
||||
None,
|
||||
vec![
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"audio_channels",
|
||||
over.audio_channels,
|
||||
channels_combo,
|
||||
"The speaker layout requested from the host. It downmixes if its own \
|
||||
output has fewer channels.",
|
||||
),
|
||||
described(
|
||||
described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"mic_enabled",
|
||||
over.mic_enabled,
|
||||
mic_toggle,
|
||||
"This device\u{2019}s microphone feeds the host\u{2019}s virtual mic.",
|
||||
),
|
||||
@@ -626,24 +933,36 @@ 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_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"fullscreen_on_stream",
|
||||
over.fullscreen_on_stream,
|
||||
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(
|
||||
Some("Statistics"),
|
||||
vec![described(
|
||||
vec![described_overridable(
|
||||
(rev, set_rev),
|
||||
scope,
|
||||
"stats_verbosity",
|
||||
over.stats_verbosity,
|
||||
hud_combo,
|
||||
"Live session stats in a corner overlay \u{2014} Compact is a one-line pill, \
|
||||
Detailed adds the latency stage breakdown. Ctrl+Alt+Shift+S cycles the \
|
||||
@@ -651,13 +970,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 +1022,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 +1079,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 +1091,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 +1170,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
use super::style::*;
|
||||
use super::{Screen, Svc};
|
||||
use crate::probe::run_speed_probe;
|
||||
use crate::trust::KnownHosts;
|
||||
use pf_client_core::profiles::ProfilesFile;
|
||||
use windows_reactor::*;
|
||||
|
||||
/// Speed-test lifecycle. Held as ROOT state (the probe worker completes it via
|
||||
@@ -122,17 +124,54 @@ pub(crate) fn speed_page(props: &SpeedProps, cx: &mut RenderCx) -> Element {
|
||||
recommended_kbps,
|
||||
} => {
|
||||
let recommended_mbps = f64::from(*recommended_kbps) / 1000.0;
|
||||
// A measured bitrate belongs in the layer the TESTED host actually reads it from
|
||||
// (design/client-settings-profiles.md §5.3) — writing the global here is what made
|
||||
// measuring one host re-tune every other one. Resolved the way a connect resolves
|
||||
// it: the one-off this test was started with, else the host's binding.
|
||||
let target = ctx.shared.target.lock().unwrap().clone();
|
||||
let bound = KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == target.addr && h.port == target.port)
|
||||
.and_then(|h| h.profile_id.clone());
|
||||
let profile = match target.profile.as_deref() {
|
||||
Some("") => None,
|
||||
Some(id) => Some(id.to_string()),
|
||||
None => bound,
|
||||
}
|
||||
.and_then(|reference| ProfilesFile::load().resolve(&reference).0.cloned());
|
||||
let apply_btn = {
|
||||
let (ctx, ss, kbps) = (ctx.clone(), set_screen.clone(), *recommended_kbps);
|
||||
button(format!("Use {recommended_mbps:.0} Mb/s"))
|
||||
.accent()
|
||||
.icon(Symbol::Accept)
|
||||
.on_click(move || {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
s.bitrate_kbps = kbps;
|
||||
s.save();
|
||||
ss.call(Screen::Hosts);
|
||||
})
|
||||
let profile = profile.clone();
|
||||
button(match &profile {
|
||||
Some(p) => format!(
|
||||
"Set {recommended_mbps:.0} Mb/s in \u{201c}{}\u{201d}",
|
||||
p.name
|
||||
),
|
||||
None => format!("Use {recommended_mbps:.0} Mb/s"),
|
||||
})
|
||||
.accent()
|
||||
.icon(Symbol::Accept)
|
||||
.on_click(move || {
|
||||
match &profile {
|
||||
Some(p) => {
|
||||
let mut catalog = ProfilesFile::load();
|
||||
if let Some(slot) = catalog.profiles.iter_mut().find(|x| x.id == p.id) {
|
||||
slot.overrides.bitrate_kbps = Some(kbps);
|
||||
if let Err(e) = catalog.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"),
|
||||
"saving the measured bitrate");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let mut s = ctx.settings.lock().unwrap();
|
||||
s.bitrate_kbps = kbps;
|
||||
s.save();
|
||||
}
|
||||
}
|
||||
ss.call(Screen::Hosts);
|
||||
})
|
||||
};
|
||||
let results = card(
|
||||
vstack((
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -101,6 +101,7 @@ pub(crate) fn spawn_session(
|
||||
connect_timeout_secs: u64,
|
||||
fullscreen: bool,
|
||||
launch: Option<&str>,
|
||||
profile: Option<&str>,
|
||||
slot: SessionChild,
|
||||
on_event: impl FnMut(SpawnEvent) + Send + 'static,
|
||||
) -> Result<(), String> {
|
||||
@@ -117,6 +118,11 @@ pub(crate) fn spawn_session(
|
||||
if let Some(id) = launch {
|
||||
cmd.arg("--launch").arg(id);
|
||||
}
|
||||
// Only a ONE-OFF pick rides the flag: without it the session resolves the host's own
|
||||
// binding through the same helper this shell would have used, so the two can't disagree.
|
||||
if let Some(reference) = profile {
|
||||
cmd.arg("--profile").arg(reference);
|
||||
}
|
||||
add_window_pos(&mut cmd);
|
||||
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
|
||||
}
|
||||
@@ -169,13 +175,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
|
||||
|
||||
+201
-51
@@ -7,10 +7,12 @@
|
||||
//! [`FrameChannelSender`] closure, so this crate reaches neither the encoder nor the host
|
||||
//! orchestrator).
|
||||
|
||||
// Scaffold: trait defaults + synthetic sources are defined ahead of the backends that use them.
|
||||
#![allow(dead_code)]
|
||||
// Every unsafe block in this crate carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
// …and that program only covers a whole `unsafe fn` body once the body needs its own block: in
|
||||
// edition 2021 `unsafe_op_in_unsafe_fn` is allow-by-default, which exempted the crate's hardest FFI
|
||||
// (the ring/slot construction, the channel broker, every D3D converter ctor) from the deny above.
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
use anyhow::Result;
|
||||
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||
@@ -19,9 +21,16 @@ use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||
#[cfg(target_os = "linux")]
|
||||
use pf_frame::DmabufFrame;
|
||||
|
||||
/// Produces frames from a captured output. Lives on its own thread, feeding the encoder
|
||||
/// over a bounded drop-oldest channel (never block the compositor).
|
||||
/// Produces frames from a captured output. Lives on its own thread, handing frames over without
|
||||
/// ever blocking the compositor — the Linux portal publishes into a one-deep OVERWRITING slot
|
||||
/// (drop-oldest), so a stalled consumer costs the intermediate frames and is still handed the
|
||||
/// freshest one.
|
||||
pub trait Capturer: Send {
|
||||
// ---- Frames -----------------------------------------------------------------------------
|
||||
// `next_frame` blocks for one; `try_latest` is the steady-state non-blocking read;
|
||||
// `wait_arrival` + `supports_arrival_wait` are the frame-driven trigger that replaces a
|
||||
// free-running tick.
|
||||
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame>;
|
||||
|
||||
/// [`next_frame`](Self::next_frame) with a caller-chosen first-frame budget instead of the
|
||||
@@ -52,23 +61,47 @@ pub trait Capturer: Send {
|
||||
/// Block until a FRESH frame is available via [`try_latest`](Self::try_latest) or
|
||||
/// `deadline` passes — the encode loop's frame-driven wait (latency plan T1.1): waking on
|
||||
/// the compositor's publish instead of sampling at a free-running tick deletes the
|
||||
/// sample-and-hold (~half a frame interval on average). Must NOT consume the frame (the
|
||||
/// loop's `try_latest` call does); backends buffer internally where the arrival channel
|
||||
/// can't be peeked. Only called when [`supports_arrival_wait`](Self::supports_arrival_wait)
|
||||
/// is `true`; errors surface at the following `try_latest`.
|
||||
/// sample-and-hold (~half a frame interval on average). Must NOT consume the frame — the
|
||||
/// loop's `try_latest` call does that — so a backend implements this by waiting on a wakeup
|
||||
/// and then PEEKING its hand-off slot. Only called when
|
||||
/// [`supports_arrival_wait`](Self::supports_arrival_wait) is `true`; errors surface at the
|
||||
/// following `try_latest`.
|
||||
fn wait_arrival(&mut self, _deadline: std::time::Instant) {}
|
||||
|
||||
// ---- Lifecycle --------------------------------------------------------------------------
|
||||
// Whether the capturer is being used right now, and whether it can still be used at all.
|
||||
|
||||
/// Gate expensive per-frame work so the capturer can be kept alive (reused) between
|
||||
/// streams without burning CPU. The portal capturer skips the de-pad copy while inactive;
|
||||
/// the default is a no-op (synthetic sources are produced on demand). Set `true` for the
|
||||
/// duration of a stream, `false` when it ends.
|
||||
fn set_active(&self, _active: bool) {}
|
||||
/// streams without burning CPU. The portal capturer skips the de-pad copy while inactive and
|
||||
/// flushes its frame mailbox on `false`; the default is a no-op (synthetic sources are produced
|
||||
/// on demand). Set `true` for the duration of a stream, `false` when it ends.
|
||||
///
|
||||
/// `&mut self`: it mutates capturer state, and every caller owns the capturer. It took `&self`
|
||||
/// only because the flag happened to be an `Arc<AtomicBool>` — an implementation detail leaking
|
||||
/// into the contract, and one the mailbox flush this now also does would not have shared.
|
||||
fn set_active(&mut self, _active: bool) {}
|
||||
|
||||
/// Whether this capturer can still produce frames — the gate a caller that POOLS capturers
|
||||
/// across streams must consult before reusing one.
|
||||
///
|
||||
/// Some backends have TERMINAL states that are only observable by trying to consume a frame:
|
||||
/// the Linux portal capturer's zero-copy poison flag, a dead PipeWire thread, and a source that
|
||||
/// never returns to `Streaming` are all sticky, and each makes every subsequent
|
||||
/// [`next_frame`](Self::next_frame) / [`try_latest`](Self::try_latest) fail — for that backend
|
||||
/// an `Err` from either is terminal, never transient. A pool that re-admits such a capturer
|
||||
/// wedges the next session permanently (it re-fails at the same point, every reconnect), which
|
||||
/// is why this predicate exists rather than leaving callers to infer liveness from an error they
|
||||
/// have often already discarded.
|
||||
///
|
||||
/// `true` (the default) for backends with no such state: the synthetic sources, and the Windows
|
||||
/// IDD-push capturer, whose failures already end the session through its own rebuild path.
|
||||
fn is_alive(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// ---- Cursor -----------------------------------------------------------------------------
|
||||
// The out-of-band pointer: where it is, who draws it, and (Linux/gamescope) where to read it.
|
||||
|
||||
/// 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).
|
||||
/// 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.
|
||||
/// The capture source's LIVE cursor state, when it arrives out-of-band from the frames
|
||||
/// (the Windows IddCx hardware-cursor channel). Polled by the encode loop every tick and
|
||||
/// preferred over `CapturedFrame::cursor` — with a hardware cursor, pointer-only moves
|
||||
@@ -90,13 +123,22 @@ pub trait Capturer: Send {
|
||||
|
||||
/// Attach a gamescope cursor source (remote-desktop-sweep Phase C). gamescope paints no
|
||||
/// `SPA_META_Cursor`, so [`cursor`](Self::cursor)'s slot stays empty — this hands the Linux
|
||||
/// portal capturer gamescope's nested Xwayland `(DISPLAY, XAUTHORITY)` targets (it may run
|
||||
/// several — one per `--xwayland-count`) so it reads the pointer shape/position over X11
|
||||
/// (XFixes + QueryPointer), following whichever display is focused, and publishes it into that
|
||||
/// same slot. Called once, after the capturer is built, only for gamescope sessions. Default
|
||||
/// no-op: every non-gamescope capturer already has a cursor source.
|
||||
fn attach_gamescope_cursor(&mut self, _targets: Vec<(String, Option<String>)>) {}
|
||||
/// portal capturer a way to reach gamescope's nested Xwaylands (it may run several — one per
|
||||
/// `--xwayland-count`) so it reads the pointer shape/position over X11 (XFixes +
|
||||
/// QueryPointer), following whichever display is focused, and publishes it into that same slot.
|
||||
/// Called once, after the capturer is built, only for gamescope sessions. Default no-op: every
|
||||
/// non-gamescope capturer already has a cursor source.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn attach_gamescope_cursor(&mut self, _targets: GamescopeCursorTargets) {}
|
||||
|
||||
// ---- 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`), 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> {
|
||||
None
|
||||
}
|
||||
@@ -111,10 +153,18 @@ pub trait Capturer: Send {
|
||||
1
|
||||
}
|
||||
|
||||
// ---- Host-initiated resize --------------------------------------------------------------
|
||||
// These two are ONE operation split in half and must be implemented together: a backend that
|
||||
// returns `Some` from `capture_target_id` is promising `resize_output` works, and one that
|
||||
// implements `resize_output` without the identity leaves the caller no way to check that the
|
||||
// display it just reconfigured is still this capturer's. Both defaults decline.
|
||||
|
||||
/// The OS display-target id this capturer is bound to (Windows IDD-push), so the resize path
|
||||
/// can verify the display it just reconfigured is STILL the one this capturer serves (an
|
||||
/// in-place resize keeps the target; a re-arrival fallback mints a new one, which needs a
|
||||
/// fresh capturer). `None` = the backend has no such identity (every non-IDD backend).
|
||||
///
|
||||
/// PAIRED with [`resize_output`](Self::resize_output) — see the cluster note above.
|
||||
fn capture_target_id(&self) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
@@ -125,9 +175,25 @@ pub trait Capturer: Send {
|
||||
/// EXTERNAL changes only) and no teardown: the capture pipeline and its send thread survive;
|
||||
/// only the encoder is swapped by the caller once the first new-size frame arrives. Returns
|
||||
/// `true` when handled; `false` (the default) routes the caller to the full-rebuild path.
|
||||
///
|
||||
/// PAIRED with [`capture_target_id`](Self::capture_target_id) — see the cluster note above.
|
||||
fn resize_output(&mut self, _width: u32, _height: u32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Recreate the delivery ring at the CURRENT mode and re-run the driver attach handshake —
|
||||
/// the recovery half of a swap-chain bounce the descriptor poller cannot see: an
|
||||
/// exclusive-topology eviction (the vdisplay re-assert watchdog) is a real topology change,
|
||||
/// so the OS drives COMMIT_MODES on the live virtual display too and the driver's swap-chain
|
||||
/// is recreated while this capturer keeps waiting on the old ring attachment — frames stop
|
||||
/// with an unchanged descriptor (same mode, same HDR), so the two-strike debounce never
|
||||
/// trips. Arms the same recover-or-drop window as a real resize, so a driver that cannot
|
||||
/// re-attach still fails the session cleanly. Returns `true` when handled; `false` (the
|
||||
/// default) means the backend has no in-place ring recovery and the caller should treat the
|
||||
/// pipeline as unrecoverable in place.
|
||||
fn recreate_ring_in_place(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
|
||||
@@ -297,23 +363,52 @@ 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
|
||||
/// `--xwayland-count` — for [`Capturer::attach_gamescope_cursor`].
|
||||
///
|
||||
/// A CLOSURE, not the `Vec` it used to be, and re-run on a slow cadence by the cursor worker. The
|
||||
/// snapshot was taken once, before the game launched: gamescope creates a second Xwayland for the
|
||||
/// game but only advertises the FIRST in any child's environ, so the game's display was invisible to
|
||||
/// discovery — and when the connected (Big Picture) display then reported "gamescope is not drawing
|
||||
/// the pointer here", the source blanked the cursor for the whole game session, which is the exact
|
||||
/// regression the module doc says it fixed. A provider also lets the worker retry a display that
|
||||
/// died, and lets a stream that starts BEFORE the game converge instead of staying cursorless.
|
||||
///
|
||||
/// Built by the host facade (it wraps `pf_vdisplay::gamescope_xwayland_cursor_targets`), exactly
|
||||
/// like [`FrameChannelSender`] — so the capture→host edge stays one-way.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub type GamescopeCursorTargets =
|
||||
std::sync::Arc<dyn Fn() -> Vec<(String, Option<String>)> + Send + Sync>;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn capturer_supports_444(_encoder_ingests_rgb_444: bool) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -328,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")]
|
||||
@@ -447,21 +581,35 @@ pub mod synthetic_nv12;
|
||||
/// `want_hdr` offers the GNOME 50+ HDR formats (10-bit PQ/BT.2020 dmabufs) instead of the SDR
|
||||
/// set — pass it only when the mirrored monitor is actually in HDR mode (the host probes
|
||||
/// DisplayConfig) or the negotiation runs into its 10 s timeout and latches the SDR downgrade.
|
||||
/// The [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts (the one-way edge).
|
||||
/// `want_metadata_cursor` asks for cursor-as-metadata (`SPA_META_Cursor`) — pass it only when
|
||||
/// the session's encode path composites `CapturedFrame::cursor` (the host consults
|
||||
/// `pf-encode`'s `cursor_blend_capable`); otherwise the portal EMBEDS the pointer so it is
|
||||
/// never silently lost. The [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts
|
||||
/// (the one-way edge).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn open_portal_monitor(
|
||||
anchored: bool,
|
||||
want_hdr: bool,
|
||||
want_metadata_cursor: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
linux::PortalCapturer::open(anchored, want_hdr && !hdr_capture_failed(), policy)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
linux::PortalCapturer::open(
|
||||
anchored,
|
||||
want_hdr && !hdr_capture_failed(HdrSource::PortalMonitor),
|
||||
want_metadata_cursor,
|
||||
policy,
|
||||
)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
/// 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(
|
||||
@@ -471,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>> {
|
||||
@@ -481,6 +630,7 @@ pub fn open_virtual_output(
|
||||
keepalive,
|
||||
allow_zerocopy,
|
||||
want_444,
|
||||
want_hdr && !hdr_capture_failed(HdrSource::VirtualOutput),
|
||||
policy,
|
||||
expect_exact_dims,
|
||||
)
|
||||
|
||||
+452
-2390
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,419 @@
|
||||
//! The portal CONTROL PLANE: the xdg ScreenCast / RemoteDesktop handshake (async, `ashpd` over
|
||||
//! zbus, on its own tokio runtime), the cursor-mode choice, and GNOME's BT.2100 colour-mode probe.
|
||||
//!
|
||||
//! Split out of `linux/mod.rs` (sweep Phase 5.3) to separate the async control plane from the
|
||||
//! realtime half: nothing here runs per frame — the handshake happens once, then the thread parks
|
||||
//! until `PortalSession`'s `Drop` (in the parent) releases it, and DROPPING the zbus
|
||||
//! connection is what ends the compositor's cast. The probe is likewise a one-shot D-Bus round-trip
|
||||
//! for a control-plane caller.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::os::fd::OwnedFd;
|
||||
|
||||
/// 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
|
||||
// (spec(ssss), modes a(siiddada{sv}), properties a{sv}) — "color-mode" lives in the monitor
|
||||
// properties.
|
||||
type Mode = (
|
||||
String,
|
||||
i32,
|
||||
i32,
|
||||
f64,
|
||||
f64,
|
||||
Vec<f64>,
|
||||
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
);
|
||||
type Monitor = (
|
||||
(String, String, String, String),
|
||||
Vec<Mode>,
|
||||
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
);
|
||||
type LogicalMonitor = (
|
||||
i32,
|
||||
i32,
|
||||
f64,
|
||||
u32,
|
||||
bool,
|
||||
Vec<(String, String, String, String)>,
|
||||
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
);
|
||||
type State = (
|
||||
u32,
|
||||
Vec<Monitor>,
|
||||
Vec<LogicalMonitor>,
|
||||
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
);
|
||||
let probe = || -> Result<bool> {
|
||||
// zbus is built async-only here (ashpd's tokio integration) — run the one round-trip on
|
||||
// a throwaway current-thread runtime; this is a control-plane call, never per-frame.
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("build tokio runtime")?;
|
||||
rt.block_on(async {
|
||||
let conn = zbus::Connection::session().await.context("session bus")?;
|
||||
let reply = conn
|
||||
.call_method(
|
||||
Some("org.gnome.Mutter.DisplayConfig"),
|
||||
"/org/gnome/Mutter/DisplayConfig",
|
||||
Some("org.gnome.Mutter.DisplayConfig"),
|
||||
"GetCurrentState",
|
||||
&(),
|
||||
)
|
||||
.await
|
||||
.context("DisplayConfig.GetCurrentState")?;
|
||||
let (_serial, monitors, _logical, _props): State = reply
|
||||
.body()
|
||||
.deserialize()
|
||||
.context("parse GetCurrentState")?;
|
||||
// `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() {
|
||||
Ok(hdr) => hdr,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %format!("{e:#}"), "GNOME HDR colour-mode probe failed — SDR");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +
|
||||
/// an occasional bitmap), which the consumer composites itself — avoiding the producer burning the
|
||||
/// cursor into every frame (`Embedded`), which on gamescope would defeat its HW cursor plane.
|
||||
/// Without it — the session's encode path has no compositing stage for a metadata cursor
|
||||
/// (`pf-encode`'s `cursor_blend_capable` said the resolved backend can't blend) — the ladder
|
||||
/// prefers `Embedded`, so the pointer is in the pixels instead of in metadata nothing would draw.
|
||||
/// Both ladders fall through to the other mode, then `Hidden`; a failed property query (an older
|
||||
/// portal) keeps the prior `Embedded` behavior so the cursor is never silently lost.
|
||||
async fn choose_cursor_mode(
|
||||
proxy: &ashpd::desktop::screencast::Screencast,
|
||||
want_metadata: bool,
|
||||
) -> ashpd::desktop::screencast::CursorMode {
|
||||
use ashpd::desktop::screencast::CursorMode;
|
||||
match proxy.available_cursor_modes().await {
|
||||
Ok(avail) if want_metadata && avail.contains(CursorMode::Metadata) => {
|
||||
tracing::info!(
|
||||
?avail,
|
||||
"ScreenCast: requesting cursor-as-metadata (SPA_META_Cursor)"
|
||||
);
|
||||
CursorMode::Metadata
|
||||
}
|
||||
Ok(avail) if avail.contains(CursorMode::Embedded) => {
|
||||
if want_metadata {
|
||||
tracing::info!(
|
||||
?avail,
|
||||
"ScreenCast: cursor metadata unavailable — requesting Embedded cursor"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
?avail,
|
||||
"ScreenCast: requesting Embedded cursor (this session's encoder does not \
|
||||
composite a metadata cursor)"
|
||||
);
|
||||
}
|
||||
CursorMode::Embedded
|
||||
}
|
||||
Ok(avail) if avail.contains(CursorMode::Metadata) => {
|
||||
// Embedded wanted but not offered. Metadata still beats Hidden: the CPU capture
|
||||
// path composites `SPA_META_Cursor` inline, so part of the matrix keeps a pointer.
|
||||
tracing::warn!(
|
||||
?avail,
|
||||
"ScreenCast: Embedded cursor not advertised — requesting cursor-as-metadata \
|
||||
(only CPU-path frames will composite it)"
|
||||
);
|
||||
CursorMode::Metadata
|
||||
}
|
||||
Ok(avail) => {
|
||||
tracing::warn!(
|
||||
?avail,
|
||||
"ScreenCast: neither Metadata nor Embedded cursor advertised — cursor will be hidden"
|
||||
);
|
||||
CursorMode::Hidden
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"ScreenCast: AvailableCursorModes query failed — defaulting to Embedded cursor"
|
||||
);
|
||||
CursorMode::Embedded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The portal handshake: connect ScreenCast, select a single monitor, start, open the
|
||||
/// PipeWire remote, hand the fd + node id back, then keep the session alive until `quit_rx`
|
||||
/// resolves (the capturer's `Drop` — see [`PortalSession`]).
|
||||
pub(super) fn portal_thread(
|
||||
setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>,
|
||||
quit_rx: tokio::sync::oneshot::Receiver<()>,
|
||||
want_metadata_cursor: bool,
|
||||
) {
|
||||
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
// Multi-thread runtime: the zbus connection's background reader must be pumped
|
||||
// continuously across the create_session → select_sources → start handshake, or the
|
||||
// portal reports "Invalid session". (A current-thread runtime starves it.)
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let err_tx = setup_tx.clone();
|
||||
|
||||
rt.block_on(async move {
|
||||
let result: Result<()> = async {
|
||||
let proxy = Screencast::new()
|
||||
.await
|
||||
.context("connect ScreenCast portal")?;
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
let cursor_mode = choose_cursor_mode(&proxy, want_metadata_cursor).await;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode)
|
||||
// Only MONITOR is offered by the wlroots backend
|
||||
// (AvailableSourceTypes=1); requesting unsupported types
|
||||
// invalidates the session.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected (unsupported source type / cursor mode?)")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context("start response (chooser cancelled? portal misconfigured?)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
|
||||
setup_tx
|
||||
.send(Ok((fd, node_id)))
|
||||
.map_err(|_| anyhow!("capturer dropped before setup completed"))?;
|
||||
|
||||
// Keep `proxy` + `session` (and the underlying zbus connection) alive for the
|
||||
// capture; the cast is torn down when the connection drops (ashpd's `Session`
|
||||
// has no `Drop`) — which now happens when this park returns, not at process exit.
|
||||
let _keep_alive = (&proxy, &session);
|
||||
let _ = quit_rx.await;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = err_tx.send(Err(format!("{e:#}")));
|
||||
}
|
||||
});
|
||||
// Drop the runtime HERE, before the caller signals completion: shutting the 2 workers down is
|
||||
// what finishes releasing the zbus connection, so a `done` signal sent after this means the
|
||||
// compositor-side session is really gone (see `PortalSession::drop`).
|
||||
drop(rt);
|
||||
}
|
||||
|
||||
/// Combined RemoteDesktop+ScreenCast portal setup (KWin/GNOME). ScreenCast sources are selected
|
||||
/// on a session created via RemoteDesktop, so a single RemoteDesktop `start` grant —
|
||||
/// pre-authorized headlessly via the `kde-authorized` permission, exactly like the libei input
|
||||
/// path — also covers screen capture, with no separate ScreenCast dialog (which has no such
|
||||
/// bypass). Yields the same PipeWire fd + node id as the standalone path; the consumer is
|
||||
/// identical, as is the `quit_rx` teardown park (see [`PortalSession`]).
|
||||
pub(super) fn portal_thread_remote_desktop(
|
||||
setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>,
|
||||
quit_rx: tokio::sync::oneshot::Receiver<()>,
|
||||
want_metadata_cursor: bool,
|
||||
) {
|
||||
use ashpd::desktop::remote_desktop::{DeviceType, RemoteDesktop, SelectDevicesOptions};
|
||||
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let err_tx = setup_tx.clone();
|
||||
|
||||
rt.block_on(async move {
|
||||
let result: Result<()> = async {
|
||||
let remote = RemoteDesktop::new()
|
||||
.await
|
||||
.context("connect RemoteDesktop portal")?;
|
||||
let screencast = Screencast::new()
|
||||
.await
|
||||
.context("connect ScreenCast portal")?;
|
||||
let session = remote
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create RemoteDesktop session")?;
|
||||
// RemoteDesktop requires a device selection; we never connect_to_eis on this session
|
||||
// (input injection runs its own), but selecting devices is what makes `start` the
|
||||
// RemoteDesktop grant the kde-authorized bypass covers.
|
||||
remote
|
||||
.select_devices(
|
||||
&session,
|
||||
SelectDevicesOptions::default()
|
||||
.set_devices(DeviceType::Keyboard | DeviceType::Pointer)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_devices")?
|
||||
.response()
|
||||
.context("select_devices rejected")?;
|
||||
let cursor_mode = choose_cursor_mode(&screencast, want_metadata_cursor).await;
|
||||
screencast
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(cursor_mode)
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected (unsupported source type?)")?;
|
||||
let streams = remote
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start RemoteDesktop+ScreenCast")?
|
||||
.response()
|
||||
.context("start response (grant not pre-authorized / headless dialog?)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no screencast streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = screencast
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
|
||||
setup_tx
|
||||
.send(Ok((fd, node_id)))
|
||||
.map_err(|_| anyhow!("capturer dropped before setup completed"))?;
|
||||
|
||||
// Keep the proxies + session (and their zbus connection) alive for the capture, until
|
||||
// the capturer's `Drop` fires the quit channel.
|
||||
let _keep_alive = (&remote, &screencast, &session);
|
||||
let _ = quit_rx.await;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = err_tx.send(Err(format!("{e:#}")));
|
||||
}
|
||||
});
|
||||
// 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")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
//! Cursor-as-metadata: the `SPA_META_Cursor` parser and the CPU-path composite blits.
|
||||
//!
|
||||
//! Split out of `linux/pipewire.rs` (sweep Phase 5.2). Both halves are producer-driven and
|
||||
//! bounds-critical: [`update_cursor_meta`] reads a bitmap at offsets the COMPOSITOR chose (its own
|
||||
//! SAFETY proof notes that a missing bound SIGSEGVs inside the PipeWire `.process` callback, where
|
||||
//! `catch_unwind` cannot help), and the `composite_cursor*` blits clip a caller-positioned bitmap
|
||||
//! into a frame buffer. Separating them from the stream machinery is what makes them testable
|
||||
//! without a compositor.
|
||||
|
||||
use super::PixelFormat;
|
||||
use pipewire as pw;
|
||||
use pw::spa;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Latest cursor state parsed from `SPA_META_Cursor` (cursor-as-metadata mode). Position is
|
||||
/// refreshed every buffer that carries the meta (including Mutter's cursor-only "corrupted"
|
||||
/// buffers we otherwise skip for their stale frame); the RGBA bitmap is cached and only
|
||||
/// replaced when the compositor sends a fresh one (`bitmap_offset != 0`).
|
||||
#[derive(Default)]
|
||||
pub(super) struct CursorState {
|
||||
/// True when the compositor reports a visible pointer (`spa_meta_cursor.id != 0`).
|
||||
visible: bool,
|
||||
/// Top-left where the bitmap is drawn = reported position − hotspot.
|
||||
x: i32,
|
||||
y: i32,
|
||||
/// Cached straight-alpha RGBA pixels (`bw*bh*4`, bytes R,G,B,A). `Arc` so the overlay handed
|
||||
/// to each GPU frame is a refcount bump, not a copy. Empty until the first bitmap arrives.
|
||||
rgba: Arc<Vec<u8>>,
|
||||
bw: u32,
|
||||
bh: u32,
|
||||
/// Bumps whenever the bitmap (`rgba`/`bw`/`bh`) changes — stable across position-only moves,
|
||||
/// so the GPU encoder re-uploads its cursor texture only on change.
|
||||
serial: u64,
|
||||
/// The compositor-reported hotspot — carried on the overlay for the cursor-forward
|
||||
/// channel (the blend path uses the pre-adjusted `x`/`y` and never reads it).
|
||||
hot_x: i32,
|
||||
hot_y: i32,
|
||||
}
|
||||
|
||||
impl CursorState {
|
||||
/// A shareable overlay for the encode/forward paths, or `None` before the first bitmap
|
||||
/// arrived. A HIDDEN pointer still yields `Some` (with `visible: false`): the
|
||||
/// cursor-forward channel needs "known but hidden" — an app grabbed the pointer, the
|
||||
/// client's relative-mode hint (M3) — which is a different fact from "no cursor yet".
|
||||
/// The encode loop strips invisible overlays before any blend path sees the frame.
|
||||
/// Cheap: clones an `Arc` + a few scalars.
|
||||
pub(super) fn overlay(&self) -> Option<pf_frame::CursorOverlay> {
|
||||
if self.rgba.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(pf_frame::CursorOverlay {
|
||||
x: self.x,
|
||||
y: self.y,
|
||||
w: self.bw,
|
||||
h: self.bh,
|
||||
rgba: self.rgba.clone(),
|
||||
serial: self.serial,
|
||||
hot_x: self.hot_x.max(0) as u32,
|
||||
hot_y: self.hot_y.max(0) as u32,
|
||||
visible: self.visible,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract straight (R,G,B,A) from one 4-byte cursor-bitmap pixel, honoring the bitmap's SPA
|
||||
/// video format (portals emit RGBA or BGRA; ARGB/ABGR handled for completeness). Unknown
|
||||
/// 4-byte formats are read as RGBA.
|
||||
pub(super) fn decode_bitmap_pixel(vfmt: u32, s: &[u8]) -> (u8, u8, u8, u8) {
|
||||
match vfmt {
|
||||
x if x == spa::sys::SPA_VIDEO_FORMAT_RGBA => (s[0], s[1], s[2], s[3]),
|
||||
x if x == spa::sys::SPA_VIDEO_FORMAT_BGRA => (s[2], s[1], s[0], s[3]),
|
||||
x if x == spa::sys::SPA_VIDEO_FORMAT_ARGB => (s[1], s[2], s[3], s[0]),
|
||||
x if x == spa::sys::SPA_VIDEO_FORMAT_ABGR => (s[3], s[2], s[1], s[0]),
|
||||
_ => (s[0], s[1], s[2], s[3]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update `cursor` from the newest buffer's `SPA_META_Cursor` (no-op when the buffer carries no
|
||||
/// cursor meta — producer doesn't support it, or the portal isn't in Metadata cursor mode).
|
||||
/// Called for EVERY dequeued buffer, before the stale-frame skip, so pointer-only movements
|
||||
/// (which Mutter delivers as metadata-only "corrupted" buffers) still refresh the position.
|
||||
pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
// SAFETY: `spa_buf` is the live buffer we still hold (dequeued, not yet requeued).
|
||||
// `spa_buffer_find_meta` returns the `spa_meta` (type + byte `size` + `data` pointer) for
|
||||
// `SPA_META_Cursor`, or null. We take `find_meta` rather than `find_meta_data` specifically
|
||||
// to obtain the region's real `size`: the bitmap offset, pixel offset and stride read below
|
||||
// are ALL producer-written, and without a bound against the actual region they drive
|
||||
// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read
|
||||
// that SIGSEGVs inside the PipeWire `.process` callback (a segfault `catch_unwind` cannot
|
||||
// catch). Every offset below is validated against `region_size` with checked arithmetic,
|
||||
// mirroring the fd-length guard the main frame path already applies to xdg-desktop-portal-wlr.
|
||||
let meta = unsafe { spa::sys::spa_buffer_find_meta(spa_buf, spa::sys::SPA_META_Cursor) };
|
||||
if meta.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `meta` is non-null and points into the held buffer's metadata array.
|
||||
let (region_size, data) = unsafe { ((*meta).size as usize, (*meta).data as *const u8) };
|
||||
if data.is_null() || region_size < std::mem::size_of::<spa::sys::spa_meta_cursor>() {
|
||||
return;
|
||||
}
|
||||
let cur = data as *const spa::sys::spa_meta_cursor;
|
||||
// SAFETY: `region_size >= size_of::<spa_meta_cursor>()` checked above, so every field is in bounds.
|
||||
let (id, pos_x, pos_y, hot_x, hot_y, bmp_off) = unsafe {
|
||||
(
|
||||
(*cur).id,
|
||||
(*cur).position.x,
|
||||
(*cur).position.y,
|
||||
(*cur).hotspot.x,
|
||||
(*cur).hotspot.y,
|
||||
(*cur).bitmap_offset,
|
||||
)
|
||||
};
|
||||
if id == 0 {
|
||||
// SPA contract: id 0 = "no cursor information", NOT "cursor hidden". Mutter only
|
||||
// REWRITES a buffer's meta region when the cursor changed, so recycled buffers
|
||||
// between damage frames carry a stale id-0 meta — treating that as hidden flickered
|
||||
// the cursor off between hovers (on-glass round 5). Keep the last-known state; a
|
||||
// pointer that really left/hid simply stops producing updates. (The M3 hidden hint
|
||||
// loses its Mutter signal — Windows has its own CURSOR_SUPPRESSED source.)
|
||||
return;
|
||||
}
|
||||
cursor.visible = true;
|
||||
cursor.x = pos_x - hot_x;
|
||||
cursor.y = pos_y - hot_y;
|
||||
cursor.hot_x = hot_x;
|
||||
cursor.hot_y = hot_y;
|
||||
if bmp_off == 0 {
|
||||
// Position-only update — keep the cached bitmap.
|
||||
return;
|
||||
}
|
||||
let bmp_off = bmp_off as usize;
|
||||
// The `spa_meta_bitmap` header must fit entirely inside the region before we read it —
|
||||
// `bitmap_offset` is producer-controlled and otherwise reads past the metadata.
|
||||
match bmp_off.checked_add(std::mem::size_of::<spa::sys::spa_meta_bitmap>()) {
|
||||
Some(end) if end <= region_size => {}
|
||||
_ => return,
|
||||
}
|
||||
// SAFETY: `bmp_off + size_of::<spa_meta_bitmap>() <= region_size` (checked directly above),
|
||||
// so the header is fully in bounds for a read of that many bytes. `read_unaligned` is
|
||||
// REQUIRED, not defensive: `bmp_off` is producer-written and nothing in the SPA contract or
|
||||
// in this function establishes that `data + bmp_off` meets `spa_meta_bitmap`'s alignment —
|
||||
// the previous field reads through an aligned `*const` asserted an invariant the code never
|
||||
// proved. The struct is `Copy` POD, so one unaligned read yields an owned, aligned local.
|
||||
let bmp = unsafe { (data.add(bmp_off) as *const spa::sys::spa_meta_bitmap).read_unaligned() };
|
||||
let (vfmt, bw, bh, stride, pix_off) = (
|
||||
bmp.format,
|
||||
bmp.size.width,
|
||||
bmp.size.height,
|
||||
bmp.stride.max(0) as usize,
|
||||
bmp.offset as usize,
|
||||
);
|
||||
// Ignore empty or implausibly large bitmaps (the meta-size request covers <= 1024×1024;
|
||||
// real cursors are ≤96px — the cursor channel downscales >120px for the wire anyway).
|
||||
if bw == 0 || bh == 0 || bw > 1024 || bh > 1024 {
|
||||
return;
|
||||
}
|
||||
let row = bw as usize * 4;
|
||||
let stride = if stride < row { row } else { stride };
|
||||
let Some(extent) = bitmap_extent(bmp_off, pix_off, stride, row, bh as usize, region_size)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// SAFETY: `bitmap_extent` returned `Some`, which means (see its contract) the whole range
|
||||
// `[bmp_off + pix_off, +len)` lies inside `region_size` and `len` is EXACTLY the extent the
|
||||
// strided loop below reads. `data` is the producer's meta-region base, live for this callback.
|
||||
let src = unsafe { std::slice::from_raw_parts(data.add(extent.start), extent.len()) };
|
||||
let mut rgba = vec![0u8; bw as usize * bh as usize * 4];
|
||||
for y in 0..bh as usize {
|
||||
for x in 0..bw as usize {
|
||||
let so = y * stride + x * 4;
|
||||
let (r, g, b, a) = decode_bitmap_pixel(vfmt, &src[so..so + 4]);
|
||||
let d = (y * bw as usize + x) * 4;
|
||||
rgba[d] = r;
|
||||
rgba[d + 1] = g;
|
||||
rgba[d + 2] = b;
|
||||
rgba[d + 3] = a;
|
||||
}
|
||||
}
|
||||
cursor.rgba = Arc::new(rgba);
|
||||
cursor.bw = bw;
|
||||
cursor.bh = bh;
|
||||
cursor.serial = cursor.serial.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// The byte range inside the producer's cursor-meta region that a `bh`-row, `row`-wide,
|
||||
/// `stride`-strided bitmap at `bmp_off + pix_off` occupies — or `None` when it does not fit, or when
|
||||
/// any of the arithmetic overflows.
|
||||
///
|
||||
/// THE bound on `update_cursor_meta`. Every input except `region_size` is producer-written, and
|
||||
/// `region_size` is the real byte size of the meta region libspa handed us (which is why the caller
|
||||
/// takes `spa_buffer_find_meta` rather than `find_meta_data`). Without this check the offsets drive
|
||||
/// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read that
|
||||
/// SIGSEGVs inside the PipeWire `.process` callback, where `catch_unwind` cannot help. A stride near
|
||||
/// `i32::MAX` is enough to overflow the multiply on its own, so every step is checked.
|
||||
///
|
||||
/// `len()` of the returned range is EXACTLY `stride·(bh−1) + row`: the last row contributes only its
|
||||
/// `row` visible bytes, not a full stride, so a bitmap that ends flush against the region's end is
|
||||
/// accepted rather than rejected by a padding byte that is never read.
|
||||
///
|
||||
/// Extracted (sweep Phase 6.1) purely so it can be tested — the caller is unreachable without a live
|
||||
/// compositor.
|
||||
fn bitmap_extent(
|
||||
bmp_off: usize,
|
||||
pix_off: usize,
|
||||
stride: usize,
|
||||
row: usize,
|
||||
bh: usize,
|
||||
region_size: usize,
|
||||
) -> Option<std::ops::Range<usize>> {
|
||||
if bh == 0 || row == 0 || stride < row {
|
||||
return None;
|
||||
}
|
||||
let span = stride.checked_mul(bh - 1)?.checked_add(row)?;
|
||||
let start = bmp_off.checked_add(pix_off)?;
|
||||
let end = start.checked_add(span)?;
|
||||
(end <= region_size).then_some(start..end)
|
||||
}
|
||||
|
||||
/// Destination channel byte offsets (R,G,B) and bytes-per-pixel for a packed-RGB `PixelFormat`,
|
||||
/// or `None` for a layout the CPU cursor blit doesn't handle (YUV/10-bit — those never reach
|
||||
/// the CPU de-pad path anyway).
|
||||
pub(super) fn dst_offsets(fmt: PixelFormat) -> Option<(usize, usize, usize, usize)> {
|
||||
Some(match fmt {
|
||||
PixelFormat::Bgrx | PixelFormat::Bgra => (2, 1, 0, 4),
|
||||
PixelFormat::Rgbx | PixelFormat::Rgba => (0, 1, 2, 4),
|
||||
PixelFormat::Rgb => (0, 1, 2, 3),
|
||||
PixelFormat::Bgr => (2, 1, 0, 3),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Alpha-blend the cached cursor bitmap into a packed 10-bit (`X2Rgb10`/`X2Bgr10`) CPU frame:
|
||||
/// unpack each u32, blend the 8-bit cursor channels scaled to 10 bits (`v<<2 | v>>6`), repack.
|
||||
/// The frame samples are PQ-encoded, so like the 8-bit gamma-space blend this is a display-
|
||||
/// referred approximation — fine for a cursor. `r_shift` is the R channel's bit offset (20 for
|
||||
/// x:R:G:B, 0 for x:B:G:R); G is always at 10 and B mirrors R.
|
||||
pub(super) fn composite_cursor_rgb10(
|
||||
tight: &mut [u8],
|
||||
w: usize,
|
||||
h: usize,
|
||||
r_shift: u32,
|
||||
cursor: &CursorState,
|
||||
) {
|
||||
let b_shift = 20 - r_shift; // 0 or 20 — the opposite end from R
|
||||
let (bw, bh) = (cursor.bw as i32, cursor.bh as i32);
|
||||
for cy in 0..bh {
|
||||
let dy = cursor.y + cy;
|
||||
if dy < 0 || dy as usize >= h {
|
||||
continue;
|
||||
}
|
||||
for cx in 0..bw {
|
||||
let dx = cursor.x + cx;
|
||||
if dx < 0 || dx as usize >= w {
|
||||
continue;
|
||||
}
|
||||
let s = ((cy * bw + cx) as usize) * 4;
|
||||
let a = cursor.rgba[s + 3] as u32;
|
||||
if a == 0 {
|
||||
continue;
|
||||
}
|
||||
// 8-bit cursor channel → 10-bit (replicate the top bits into the bottom).
|
||||
let up10 = |v: u8| ((v as u32) << 2) | ((v as u32) >> 6);
|
||||
let (sr, sg, sb) = (
|
||||
up10(cursor.rgba[s]),
|
||||
up10(cursor.rgba[s + 1]),
|
||||
up10(cursor.rgba[s + 2]),
|
||||
);
|
||||
let di = (dy as usize * w + dx as usize) * 4;
|
||||
let px = u32::from_le_bytes(tight[di..di + 4].try_into().unwrap());
|
||||
let blend = |dst: u32, src: u32| (src * a + dst * (255 - a)) / 255;
|
||||
let dr = blend((px >> r_shift) & 0x3ff, sr);
|
||||
let dg = blend((px >> 10) & 0x3ff, sg);
|
||||
let db = blend((px >> b_shift) & 0x3ff, sb);
|
||||
let out = (px & 0xc000_0000) | (dr << r_shift) | (dg << 10) | (db << b_shift);
|
||||
tight[di..di + 4].copy_from_slice(&out.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alpha-blend the cached cursor bitmap into the tightly-packed CPU frame at its latched
|
||||
/// position. Cheap: a straight-alpha blit over at most ~256×256 pixels, clipped to the frame —
|
||||
/// the whole point of cursor-as-metadata (no forced full-frame composite on the producer).
|
||||
pub(super) fn composite_cursor(
|
||||
tight: &mut [u8],
|
||||
w: usize,
|
||||
h: usize,
|
||||
fmt: PixelFormat,
|
||||
cursor: &CursorState,
|
||||
) {
|
||||
if !cursor.visible || cursor.rgba.is_empty() {
|
||||
return;
|
||||
}
|
||||
// The packed 10-bit HDR layouts blend via bit unpack/repack, not byte offsets.
|
||||
match fmt {
|
||||
PixelFormat::X2Rgb10 => return composite_cursor_rgb10(tight, w, h, 20, cursor),
|
||||
PixelFormat::X2Bgr10 => return composite_cursor_rgb10(tight, w, h, 0, cursor),
|
||||
_ => {}
|
||||
}
|
||||
let Some((ri, gi, bi, bpp)) = dst_offsets(fmt) else {
|
||||
return;
|
||||
};
|
||||
let (bw, bh) = (cursor.bw as i32, cursor.bh as i32);
|
||||
for cy in 0..bh {
|
||||
let dy = cursor.y + cy;
|
||||
if dy < 0 || dy as usize >= h {
|
||||
continue;
|
||||
}
|
||||
for cx in 0..bw {
|
||||
let dx = cursor.x + cx;
|
||||
if dx < 0 || dx as usize >= w {
|
||||
continue;
|
||||
}
|
||||
let s = ((cy * bw + cx) as usize) * 4;
|
||||
let a = cursor.rgba[s + 3] as u32;
|
||||
if a == 0 {
|
||||
continue;
|
||||
}
|
||||
let (sr, sg, sb) = (
|
||||
cursor.rgba[s] as u32,
|
||||
cursor.rgba[s + 1] as u32,
|
||||
cursor.rgba[s + 2] as u32,
|
||||
);
|
||||
let di = (dy as usize * w + dx as usize) * bpp;
|
||||
let blend = |dst: u8, src: u32| ((src * a + dst as u32 * (255 - a)) / 255) as u8;
|
||||
tight[di + ri] = blend(tight[di + ri], sr);
|
||||
tight[di + gi] = blend(tight[di + gi], sg);
|
||||
tight[di + bi] = blend(tight[di + bi], sb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A solid-colour cursor state at `(x, y)`, `w`×`h`, alpha `a`.
|
||||
fn cursor(x: i32, y: i32, w: u32, h: u32, rgb: (u8, u8, u8), a: u8) -> CursorState {
|
||||
let mut px = Vec::with_capacity((w * h * 4) as usize);
|
||||
for _ in 0..w * h {
|
||||
px.extend_from_slice(&[rgb.0, rgb.1, rgb.2, a]);
|
||||
}
|
||||
CursorState {
|
||||
visible: true,
|
||||
x,
|
||||
y,
|
||||
rgba: Arc::new(px),
|
||||
bw: w,
|
||||
bh: h,
|
||||
serial: 1,
|
||||
hot_x: 0,
|
||||
hot_y: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- bitmap_extent: the guard whose absence SIGSEGVs uncatchably -------------------------
|
||||
|
||||
#[test]
|
||||
fn bitmap_extent_accepts_a_bitmap_that_fits() {
|
||||
// 4×2 RGBA, tightly packed: 32 bytes at offset 0.
|
||||
assert_eq!(bitmap_extent(0, 0, 16, 16, 2, 32), Some(0..32));
|
||||
// …and the same bitmap behind a header + pixel offset.
|
||||
assert_eq!(bitmap_extent(24, 8, 16, 16, 2, 64), Some(32..64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_extent_charges_the_last_row_only_its_visible_bytes() {
|
||||
// stride 32, row 16, 3 rows ⇒ 32*2 + 16 = 80, NOT 96. A bitmap ending flush against the
|
||||
// region must be accepted: the trailing stride padding is never read.
|
||||
assert_eq!(bitmap_extent(0, 0, 32, 16, 3, 80), Some(0..80));
|
||||
assert_eq!(bitmap_extent(0, 0, 32, 16, 3, 79), None, "one byte short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_extent_rejects_anything_past_the_region() {
|
||||
assert_eq!(bitmap_extent(0, 0, 16, 16, 2, 31), None);
|
||||
// An offset alone can push it out.
|
||||
assert_eq!(bitmap_extent(1, 0, 16, 16, 2, 32), None);
|
||||
assert_eq!(bitmap_extent(0, 1, 16, 16, 2, 32), None);
|
||||
// A region of zero accepts nothing.
|
||||
assert_eq!(bitmap_extent(0, 0, 16, 16, 1, 0), None);
|
||||
}
|
||||
|
||||
/// The producer picks `stride` and both offsets, so each is an overflow vector on its own.
|
||||
#[test]
|
||||
fn bitmap_extent_survives_hostile_arithmetic() {
|
||||
// stride × (bh-1) overflows.
|
||||
assert_eq!(bitmap_extent(0, 0, usize::MAX, 16, 3, usize::MAX), None);
|
||||
// span + row overflows. Needs ≥2 rows so `stride·(bh−1)` is already at the ceiling: with a
|
||||
// SINGLE row `stride·0 == 0`, and even a `usize::MAX`-wide row is then arithmetically in
|
||||
// range — which is correct rather than a miss, since the caller has already capped `bw` at
|
||||
// 1024 and `row` is therefore ≤ 4096.
|
||||
assert_eq!(
|
||||
bitmap_extent(0, 0, usize::MAX, usize::MAX, 2, usize::MAX),
|
||||
None
|
||||
);
|
||||
// bmp_off + pix_off overflows.
|
||||
assert_eq!(bitmap_extent(usize::MAX, 1, 16, 16, 1, usize::MAX), None);
|
||||
// start + span overflows.
|
||||
assert_eq!(
|
||||
bitmap_extent(usize::MAX - 8, 0, 16, 16, 1, usize::MAX),
|
||||
None
|
||||
);
|
||||
// A near-i32::MAX stride — the case the SAFETY comment calls out — must not wrap.
|
||||
assert_eq!(bitmap_extent(0, 0, i32::MAX as usize, 16, 1024, 4096), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitmap_extent_rejects_degenerate_geometry() {
|
||||
assert_eq!(bitmap_extent(0, 0, 16, 16, 0, 4096), None, "zero rows");
|
||||
assert_eq!(bitmap_extent(0, 0, 16, 0, 2, 4096), None, "zero-width row");
|
||||
assert_eq!(bitmap_extent(0, 0, 8, 16, 2, 4096), None, "stride < row");
|
||||
}
|
||||
|
||||
// ---- composite_cursor: clipping, alpha, and every layout --------------------------------
|
||||
|
||||
/// Read pixel `(x, y)`'s (R, G, B) out of a packed frame, honouring the layout's byte order.
|
||||
fn px_rgb(buf: &[u8], w: usize, x: usize, y: usize, fmt: PixelFormat) -> (u8, u8, u8) {
|
||||
let (ri, gi, bi, bpp) = dst_offsets(fmt).expect("packed layout");
|
||||
let i = (y * w + x) * bpp;
|
||||
(buf[i + ri], buf[i + gi], buf[i + bi])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_packed_layout_lands_the_colour_in_its_own_channels() {
|
||||
for fmt in [
|
||||
PixelFormat::Bgrx,
|
||||
PixelFormat::Bgra,
|
||||
PixelFormat::Rgbx,
|
||||
PixelFormat::Rgba,
|
||||
PixelFormat::Rgb,
|
||||
PixelFormat::Bgr,
|
||||
] {
|
||||
let bpp = dst_offsets(fmt).unwrap().3;
|
||||
let (w, h) = (4usize, 4usize);
|
||||
let mut buf = vec![0u8; w * h * bpp];
|
||||
// Opaque pure red at (1, 1).
|
||||
composite_cursor(&mut buf, w, h, fmt, &cursor(1, 1, 1, 1, (255, 0, 0), 255));
|
||||
assert_eq!(px_rgb(&buf, w, 1, 1, fmt), (255, 0, 0), "{fmt:?}");
|
||||
// Nothing else moved.
|
||||
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (0, 0, 0), "{fmt:?}");
|
||||
assert_eq!(px_rgb(&buf, w, 2, 1, fmt), (0, 0, 0), "{fmt:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cursor_hanging_off_every_edge_is_clipped_not_wrapped() {
|
||||
let (w, h, fmt) = (4usize, 4usize, PixelFormat::Bgrx);
|
||||
// Top-left: only the bottom-right quarter of a 2×2 lands, at (0, 0).
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
fmt,
|
||||
&cursor(-1, -1, 2, 2, (10, 20, 30), 255),
|
||||
);
|
||||
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (10, 20, 30));
|
||||
assert_eq!(px_rgb(&buf, w, 1, 0, fmt), (0, 0, 0));
|
||||
assert_eq!(px_rgb(&buf, w, 0, 1, fmt), (0, 0, 0));
|
||||
// Bottom-right: only the top-left quarter lands, at (3, 3).
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(&mut buf, w, h, fmt, &cursor(3, 3, 2, 2, (10, 20, 30), 255));
|
||||
assert_eq!(px_rgb(&buf, w, 3, 3, fmt), (10, 20, 30));
|
||||
assert_eq!(px_rgb(&buf, w, 2, 3, fmt), (0, 0, 0));
|
||||
// Fully outside in each direction: the frame is untouched.
|
||||
for pos in [(-2, 0), (0, -2), (4, 0), (0, 4), (-9, -9), (99, 99)] {
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
fmt,
|
||||
&cursor(pos.0, pos.1, 2, 2, (255, 255, 255), 255),
|
||||
);
|
||||
assert!(buf.iter().all(|&b| b == 0), "drew something at {pos:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_and_hidden_cursors_draw_nothing() {
|
||||
let (w, h, fmt) = (2usize, 2usize, PixelFormat::Bgrx);
|
||||
// Alpha 0 — every pixel skipped.
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(&mut buf, w, h, fmt, &cursor(0, 0, 2, 2, (255, 255, 255), 0));
|
||||
assert!(buf.iter().all(|&b| b == 0));
|
||||
// `visible: false` — the whole blit is skipped.
|
||||
let mut c = cursor(0, 0, 2, 2, (255, 255, 255), 255);
|
||||
c.visible = false;
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(&mut buf, w, h, fmt, &c);
|
||||
assert!(buf.iter().all(|&b| b == 0));
|
||||
// No bitmap yet — likewise.
|
||||
let mut c = cursor(0, 0, 2, 2, (255, 255, 255), 255);
|
||||
c.rgba = Arc::new(Vec::new());
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(&mut buf, w, h, fmt, &c);
|
||||
assert!(buf.iter().all(|&b| b == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_alpha_blends_toward_the_destination() {
|
||||
let (w, h, fmt) = (1usize, 1usize, PixelFormat::Bgrx);
|
||||
// dst = white, src = black at 50% ⇒ mid grey (integer blend: (0*128 + 255*127)/255 = 127).
|
||||
let mut buf = vec![255u8; w * h * 4];
|
||||
composite_cursor(&mut buf, w, h, fmt, &cursor(0, 0, 1, 1, (0, 0, 0), 128));
|
||||
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (127, 127, 127));
|
||||
}
|
||||
|
||||
/// A layout the CPU blit cannot address must be declined, not mis-blitted.
|
||||
#[test]
|
||||
fn unsupported_layouts_are_declined() {
|
||||
assert!(dst_offsets(PixelFormat::Nv12).is_none());
|
||||
assert!(dst_offsets(PixelFormat::Yuv444).is_none());
|
||||
let (w, h) = (2usize, 2usize);
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
PixelFormat::Nv12,
|
||||
&cursor(0, 0, 2, 2, (255, 255, 255), 255),
|
||||
);
|
||||
assert!(buf.iter().all(|&b| b == 0), "NV12 must not be blitted");
|
||||
}
|
||||
|
||||
// ---- composite_cursor_rgb10: the 10-bit unpack/repack round trip -------------------------
|
||||
|
||||
/// Pack an `x:R:G:B` (`X2Rgb10`) pixel — R at bit 20, G at 10, B at 0 — the way a producer does.
|
||||
fn pack_x2rgb10(r: u32, g: u32, b: u32) -> [u8; 4] {
|
||||
(0xC000_0000 | (r << 20) | (g << 10) | b).to_le_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_10bit_path_round_trips_an_untouched_pixel() {
|
||||
// Alpha 0 ⇒ the blend is skipped entirely, so the packed pixel must come back bit-identical
|
||||
// (including the top two bits, which are alpha and must survive the repack).
|
||||
for (r, g, b) in [(0, 0, 0), (1023, 1023, 1023), (940, 64, 512), (1, 2, 3)] {
|
||||
let src = pack_x2rgb10(r, g, b);
|
||||
let mut buf = src.to_vec();
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
1,
|
||||
1,
|
||||
PixelFormat::X2Rgb10,
|
||||
&cursor(0, 0, 1, 1, (255, 255, 255), 0),
|
||||
);
|
||||
assert_eq!(buf, src, "({r},{g},{b}) was modified by a zero-alpha blend");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_10bit_path_writes_the_right_channel_at_the_right_shift() {
|
||||
// Opaque pure red, 8-bit 255 → 10-bit 1023 (the `v<<2 | v>>6` expansion).
|
||||
let mut buf = pack_x2rgb10(0, 0, 0).to_vec();
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
1,
|
||||
1,
|
||||
PixelFormat::X2Rgb10,
|
||||
&cursor(0, 0, 1, 1, (255, 0, 0), 255),
|
||||
);
|
||||
let v = u32::from_le_bytes(buf[..4].try_into().unwrap());
|
||||
assert_eq!((v >> 20) & 0x3ff, 1023, "R");
|
||||
assert_eq!((v >> 10) & 0x3ff, 0, "G");
|
||||
assert_eq!(v & 0x3ff, 0, "B");
|
||||
assert_eq!(v & 0xc000_0000, 0xc000_0000, "alpha bits preserved");
|
||||
|
||||
// X2Bgr10 puts R at bit 0 and B at 20 — the SAME cursor must land in the other end.
|
||||
let mut buf = pack_x2rgb10(0, 0, 0).to_vec();
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
1,
|
||||
1,
|
||||
PixelFormat::X2Bgr10,
|
||||
&cursor(0, 0, 1, 1, (255, 0, 0), 255),
|
||||
);
|
||||
let v = u32::from_le_bytes(buf[..4].try_into().unwrap());
|
||||
assert_eq!(v & 0x3ff, 1023, "R at bit 0 for x:B:G:R");
|
||||
assert_eq!((v >> 20) & 0x3ff, 0, "B untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_10bit_path_clips_like_the_8bit_one() {
|
||||
let (w, h) = (2usize, 2usize);
|
||||
let mut buf: Vec<u8> = (0..w * h).flat_map(|_| pack_x2rgb10(0, 0, 0)).collect();
|
||||
let before = buf.clone();
|
||||
// Entirely off-frame.
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
PixelFormat::X2Rgb10,
|
||||
&cursor(-5, -5, 2, 2, (255, 255, 255), 255),
|
||||
);
|
||||
assert_eq!(buf, before);
|
||||
// Straddling the top-left corner: only (0, 0) is written.
|
||||
composite_cursor(
|
||||
&mut buf,
|
||||
w,
|
||||
h,
|
||||
PixelFormat::X2Rgb10,
|
||||
&cursor(-1, -1, 2, 2, (255, 255, 255), 255),
|
||||
);
|
||||
let p0 = u32::from_le_bytes(buf[0..4].try_into().unwrap());
|
||||
let p1 = u32::from_le_bytes(buf[4..8].try_into().unwrap());
|
||||
assert_eq!((p0 >> 20) & 0x3ff, 1023);
|
||||
assert_eq!((p1 >> 20) & 0x3ff, 0);
|
||||
}
|
||||
|
||||
// ---- decode_bitmap_pixel: the producer's byte order ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn each_bitmap_format_is_decoded_to_straight_rgba() {
|
||||
let s = [1u8, 2, 3, 4];
|
||||
assert_eq!(
|
||||
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_RGBA, &s),
|
||||
(1, 2, 3, 4)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_BGRA, &s),
|
||||
(3, 2, 1, 4)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_ARGB, &s),
|
||||
(2, 3, 4, 1)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_ABGR, &s),
|
||||
(4, 3, 2, 1)
|
||||
);
|
||||
// An unknown 4-byte format reads as RGBA rather than being rejected — documented behaviour.
|
||||
assert_eq!(decode_bitmap_pixel(0xdead_beef, &s), (1, 2, 3, 4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
//! The PipeWire `EnumFormat` / `Buffers` / `Meta` param pods the capture stream offers, and the
|
||||
//! `Pod` serializer they all end in.
|
||||
//!
|
||||
//! Split out of `linux/pipewire.rs` (sweep Phase 5.2) because it is the crate's WIRE surface: what
|
||||
//! these builders put in a pod is what the compositor intersects against, and a missing property is
|
||||
//! not a compile error but a link that silently stalls in `negotiating`. Nothing here touches the
|
||||
//! stream, the buffers or the frames — every function is a pure `facts -> Vec<u8>`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use pipewire as pw;
|
||||
use pw::spa;
|
||||
use spa::param::video::VideoFormat;
|
||||
|
||||
pub(super) fn serialize_pod(obj: pw::spa::pod::Object) -> Result<Vec<u8>> {
|
||||
Ok(pw::spa::pod::serialize::PodSerializer::serialize(
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
&pw::spa::pod::Value::Object(obj),
|
||||
)
|
||||
.context("serialize pod")?
|
||||
.0
|
||||
.into_inner())
|
||||
}
|
||||
|
||||
/// Build a LINEAR/modifier DMA-BUF `EnumFormat` pod. Packed BGRx is the existing import path;
|
||||
/// NV12 is gamescope's producer-side RGB→YUV path (opt-in during bring-up).
|
||||
pub(super) fn build_dmabuf_format(
|
||||
format: VideoFormat,
|
||||
modifiers: &[u64],
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
|
||||
use pw::spa::param::format::{FormatProperties, MediaSubtype, MediaType};
|
||||
let mut obj = pw::spa::pod::object!(
|
||||
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
||||
pw::spa::param::ParamType::EnumFormat,
|
||||
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
|
||||
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
|
||||
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
|
||||
pw::spa::pod::property!(
|
||||
FormatProperties::VideoSize,
|
||||
Choice,
|
||||
Range,
|
||||
Rectangle,
|
||||
pw::spa::utils::Rectangle {
|
||||
width: dw,
|
||||
height: dh
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 1,
|
||||
height: 1
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 8192,
|
||||
height: 8192
|
||||
}
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
FormatProperties::VideoFramerate,
|
||||
Choice,
|
||||
Range,
|
||||
Fraction,
|
||||
pw::spa::utils::Fraction { num: dhz, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 0, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||
),
|
||||
);
|
||||
if format == VideoFormat::NV12 {
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorMatrix,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
||||
pw::spa::sys::SPA_VIDEO_COLOR_MATRIX_BT709,
|
||||
)),
|
||||
});
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorRange,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
||||
pw::spa::sys::SPA_VIDEO_COLOR_RANGE_16_235,
|
||||
)),
|
||||
});
|
||||
}
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Long(
|
||||
pw::spa::utils::Choice(
|
||||
pw::spa::utils::ChoiceFlags::empty(),
|
||||
pw::spa::utils::ChoiceEnum::Enum {
|
||||
default: modifiers[0] as i64,
|
||||
alternatives: modifiers.iter().map(|&m| m as i64).collect(),
|
||||
},
|
||||
),
|
||||
)),
|
||||
});
|
||||
serialize_pod(obj)
|
||||
}
|
||||
|
||||
/// Build one GNOME 50+ HDR format pod: `format` (xRGB_210LE / xBGR_210LE) as a LINEAR-only
|
||||
/// dmabuf with **MANDATORY** BT.2020 primaries + SMPTE ST.2084 (PQ) transfer-function props —
|
||||
/// the exact colorimetry Mutter's monitor stream advertises while the mirrored monitor is in
|
||||
/// HDR mode (its HDR pods carry the same props MANDATORY, so both sides must speak them for
|
||||
/// the intersection to exist; an SDR or pre-50 producer can never match this pod).
|
||||
///
|
||||
/// LINEAR-only because every 10-bit consumer we have reads the buffer without a de-tile pass:
|
||||
/// the CPU path mmaps it, and the VAAPI passthrough imports it into a VA surface. The tiled
|
||||
/// EGL de-tile blit renders into an 8-bit `GL_RGBA8` texture — it would silently crush the
|
||||
/// depth — so tiled modifiers are deliberately NOT advertised (a zero-copy 10-bit de-tile is
|
||||
/// the follow-up). SHM is excluded entirely: Mutter's SHM record path paints 8-bit ARGB32
|
||||
/// regardless of the negotiated format.
|
||||
/// `SPA_VIDEO_TRANSFER_SMPTE2084` (PQ) — spelled out rather than taken from `pw::spa::sys`
|
||||
/// because libspa only grew the constant with the BT2020_10/SMPTE2084/ARIB_STD_B67 block, and
|
||||
/// the distro builders (Ubuntu 24.04 noble for the .deb) ship headers predating it — bindgen
|
||||
/// then emits no such constant and the host fails to compile there, even though the code never
|
||||
/// runs on those systems (the HDR path needs GNOME 50+).
|
||||
///
|
||||
/// 14 is the enum's position in `spa/param/video/color.h` and is wire ABI, not a private
|
||||
/// detail: SPA mirrors GStreamer's `GstVideoTransferFunction`, where that block was added
|
||||
/// together, so the value is identical on every libspa that has the symbol at all. On one that
|
||||
/// doesn't, PipeWire simply fails to intersect this format offer and the session negotiates
|
||||
/// SDR — the same outcome as not offering HDR.
|
||||
const SPA_VIDEO_TRANSFER_SMPTE2084: u32 = 14;
|
||||
|
||||
pub(super) fn build_hdr_dmabuf_format(
|
||||
format: VideoFormat,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
|
||||
use pw::spa::param::format::{FormatProperties, MediaSubtype, MediaType};
|
||||
let mut obj = pw::spa::pod::object!(
|
||||
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
||||
pw::spa::param::ParamType::EnumFormat,
|
||||
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
|
||||
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
|
||||
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
|
||||
pw::spa::pod::property!(
|
||||
FormatProperties::VideoSize,
|
||||
Choice,
|
||||
Range,
|
||||
Rectangle,
|
||||
pw::spa::utils::Rectangle {
|
||||
width: dw,
|
||||
height: dh
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 1,
|
||||
height: 1
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 8192,
|
||||
height: 8192
|
||||
}
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
FormatProperties::VideoFramerate,
|
||||
Choice,
|
||||
Range,
|
||||
Fraction,
|
||||
pw::spa::utils::Fraction { num: dhz, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 0, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||
),
|
||||
);
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Long(0), // DRM_FORMAT_MOD_LINEAR
|
||||
});
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_transferFunction,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(SPA_VIDEO_TRANSFER_SMPTE2084)),
|
||||
});
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorPrimaries,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
||||
pw::spa::sys::SPA_VIDEO_COLOR_PRIMARIES_BT2020,
|
||||
)),
|
||||
});
|
||||
serialize_pod(obj)
|
||||
}
|
||||
|
||||
/// The default (shm/CPU-path) format offer: raw video in any encoder-mappable layout, any
|
||||
/// size, any framerate (0/1 = variable allowed — gamescope fixates exactly that).
|
||||
pub(super) fn build_default_format_obj(preferred: Option<(u32, u32, u32)>) -> pw::spa::pod::Object {
|
||||
let (dw, dh, dhz) = preferred.unwrap_or((1920, 1080, 60));
|
||||
pw::spa::pod::object!(
|
||||
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
||||
pw::spa::param::ParamType::EnumFormat,
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::MediaType,
|
||||
Id,
|
||||
pw::spa::param::format::MediaType::Video
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::MediaSubtype,
|
||||
Id,
|
||||
pw::spa::param::format::MediaSubtype::Raw
|
||||
),
|
||||
// Offer the layouts the encoder can map to an NVENC input format. wlroots
|
||||
// commonly fixates packed RGB (3 bpp); other compositors offer 4 bpp. Only
|
||||
// these are requested, so negotiation fails loudly rather than handing us a
|
||||
// format we'd misinterpret.
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::VideoFormat,
|
||||
Choice,
|
||||
Enum,
|
||||
Id,
|
||||
VideoFormat::RGB,
|
||||
VideoFormat::RGB,
|
||||
VideoFormat::BGR,
|
||||
VideoFormat::RGBx,
|
||||
VideoFormat::BGRx,
|
||||
VideoFormat::RGBA,
|
||||
VideoFormat::BGRA,
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::VideoSize,
|
||||
Choice,
|
||||
Range,
|
||||
Rectangle,
|
||||
pw::spa::utils::Rectangle {
|
||||
width: dw,
|
||||
height: dh
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 1,
|
||||
height: 1
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 8192,
|
||||
height: 8192
|
||||
}
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::VideoFramerate,
|
||||
Choice,
|
||||
Range,
|
||||
Fraction,
|
||||
pw::spa::utils::Fraction { num: dhz, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 0, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a Buffers param for the CPU path accepting anything mappable: MemPtr, MemFd, and
|
||||
/// DmaBuf. The DmaBuf bit matters for producers like gamescope whose format intersection
|
||||
/// lands on their modifier-bearing (LINEAR) pod: they then offer *only* DmaBuf buffers, and
|
||||
/// without this bit the buffer-type intersection is empty and the link silently stalls in
|
||||
/// "negotiating". A LINEAR dmabuf is mmap-able by MAP_BUFFERS, so the CPU de-pad copy works.
|
||||
pub(super) fn build_mappable_buffers() -> Result<Vec<u8>> {
|
||||
serialize_pod(pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
||||
properties: vec![pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Int(
|
||||
(1i32 << pw::spa::sys::SPA_DATA_MemPtr)
|
||||
| (1i32 << pw::spa::sys::SPA_DATA_MemFd)
|
||||
| (1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
|
||||
),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a Buffers param for a TRUE SHM path: MemPtr + MemFd only, NO DmaBuf. Forces the
|
||||
/// producer to download into mappable memory (Mutter's `glReadPixels`), which orders against its
|
||||
/// render — so the frame is complete and current by construction. This is the only race-free
|
||||
/// capture of Mutter's virtual monitor on NVIDIA: the compositor renders straight into the buffer
|
||||
/// pool, NVIDIA attaches no implicit dmabuf fence (verified: `EXPORT_SYNC_FILE` waited=false) and
|
||||
/// can't produce an explicit sync_fd, so any dmabuf read (zero-copy OR mmap) races the render and
|
||||
/// flashes the buffer's previous frame. Excluding DmaBuf is what makes the difference vs.
|
||||
/// `build_mappable_buffers` (which still let Mutter hand dmabufs).
|
||||
pub(super) fn build_shm_only_buffers() -> Result<Vec<u8>> {
|
||||
serialize_pod(pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
||||
properties: vec![pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Int(
|
||||
(1i32 << pw::spa::sys::SPA_DATA_MemPtr) | (1i32 << pw::spa::sys::SPA_DATA_MemFd),
|
||||
),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a Buffers param requesting dmabuf-only buffers.
|
||||
pub(super) fn build_dmabuf_buffers() -> Result<Vec<u8>> {
|
||||
serialize_pod(pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
||||
properties: vec![pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Int(1i32 << pw::spa::sys::SPA_DATA_DmaBuf),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/// Request the compositor attach `SPA_META_Cursor` to each buffer, so the pointer travels as
|
||||
/// metadata (position + an occasional bitmap) instead of being burned into the frame. Paired
|
||||
/// with the portal's `CursorMode::Metadata`; producers that don't support it simply don't
|
||||
/// attach it (harmless). Size is a range up to a 256×256 bitmap — bigger than any real cursor.
|
||||
pub(super) fn build_cursor_meta_param() -> Result<Vec<u8>> {
|
||||
fn meta_size(w: u32, h: u32) -> i32 {
|
||||
(std::mem::size_of::<spa::sys::spa_meta_cursor>()
|
||||
+ std::mem::size_of::<spa::sys::spa_meta_bitmap>()
|
||||
+ (w as usize * h as usize * 4)) as i32
|
||||
}
|
||||
serialize_pod(pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamMeta.as_raw(),
|
||||
id: pw::spa::param::ParamType::Meta.as_raw(),
|
||||
properties: vec![
|
||||
pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_META_type,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(spa::sys::SPA_META_Cursor)),
|
||||
},
|
||||
pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_PARAM_META_size,
|
||||
flags: pw::spa::pod::PropertyFlags::empty(),
|
||||
value: pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
|
||||
pw::spa::utils::Choice(
|
||||
pw::spa::utils::ChoiceFlags::empty(),
|
||||
// The max must cover the producer's offer or the Meta param silently
|
||||
// fails to negotiate and NO buffer ever carries the meta region:
|
||||
// Mutter offers a FIXED `SPA_POD_Int(CURSOR_META_SIZE(384, 384))`
|
||||
// (meta-screen-cast-stream-src.c, GNOME 50) — a 256² max made the
|
||||
// intersection empty, which cost the whole Linux cursor channel
|
||||
// on-glass. 1024² is headroom, not an allocation: the negotiated
|
||||
// region follows the producer's value.
|
||||
pw::spa::utils::ChoiceEnum::Range {
|
||||
default: meta_size(64, 64),
|
||||
min: meta_size(1, 1),
|
||||
max: meta_size(1024, 1024),
|
||||
},
|
||||
),
|
||||
)),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The `SPA_PARAM_BUFFERS_dataType` bitmask a serialized Buffers pod carries.
|
||||
///
|
||||
/// A deliberately literal SPA reader rather than a heuristic scan: an object property is
|
||||
/// `{ key: u32, flags: u32, value: spa_pod }` and a `spa_pod` is `{ size: u32, type: u32, body }`,
|
||||
/// so the `i32` sits exactly 16 bytes past the key — and the intervening `size` word is itself
|
||||
/// `4`, which is why "find the first plausible-looking int" reads the wrong field.
|
||||
fn buffers_data_type(pod: &[u8]) -> i32 {
|
||||
let key = spa::sys::SPA_PARAM_BUFFERS_dataType.to_ne_bytes();
|
||||
let at = pod
|
||||
.windows(4)
|
||||
.position(|w| w == key)
|
||||
.expect("dataType key present in the Buffers pod");
|
||||
let word = |off: usize| u32::from_ne_bytes(pod[off..off + 4].try_into().unwrap());
|
||||
assert_eq!(word(at + 8), 4, "dataType's value pod should be 4 bytes");
|
||||
assert_eq!(
|
||||
word(at + 12),
|
||||
spa::sys::SPA_TYPE_Int,
|
||||
"dataType's value pod should be an Int"
|
||||
);
|
||||
i32::from_ne_bytes(pod[at + 16..at + 20].try_into().unwrap())
|
||||
}
|
||||
|
||||
const MEM_PTR: i32 = 1 << spa::sys::SPA_DATA_MemPtr;
|
||||
const MEM_FD: i32 = 1 << spa::sys::SPA_DATA_MemFd;
|
||||
const DMABUF: i32 = 1 << spa::sys::SPA_DATA_DmaBuf;
|
||||
|
||||
/// The three Buffers pods differ ONLY in this bitmask, and each bit is load-bearing:
|
||||
/// `build_mappable_buffers` must include DmaBuf or gamescope's modifier-bearing pod wins the
|
||||
/// format intersection and the BUFFER intersection is then empty (a link stuck in
|
||||
/// "negotiating"); `build_shm_only_buffers` must EXCLUDE it or Mutter hands dmabufs and the
|
||||
/// race-free download path is not race-free; `build_dmabuf_buffers` must exclude the mappable
|
||||
/// types or an HDR session can be handed a MemFd buffer, which Mutter paints 8-bit ARGB32
|
||||
/// regardless of the negotiated 10-bit format.
|
||||
#[test]
|
||||
fn each_buffers_pod_requests_exactly_its_own_data_types() {
|
||||
assert_eq!(
|
||||
buffers_data_type(&build_mappable_buffers().unwrap()),
|
||||
MEM_PTR | MEM_FD | DMABUF,
|
||||
"the CPU path must accept mappable dmabufs too"
|
||||
);
|
||||
assert_eq!(
|
||||
buffers_data_type(&build_shm_only_buffers().unwrap()),
|
||||
MEM_PTR | MEM_FD,
|
||||
"PUNKTFUNK_FORCE_SHM must exclude DmaBuf"
|
||||
);
|
||||
assert_eq!(
|
||||
buffers_data_type(&build_dmabuf_buffers().unwrap()),
|
||||
DMABUF,
|
||||
"the zero-copy/HDR path must exclude SHM"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every pod builder must produce a pod libspa will accept back — a serializer that silently
|
||||
/// emitted a malformed object would fail only at negotiation, on a live compositor.
|
||||
#[test]
|
||||
fn every_pod_round_trips_through_pod_from_bytes() {
|
||||
let mut pods: Vec<(&str, Vec<u8>)> = vec![
|
||||
("mappable buffers", build_mappable_buffers().unwrap()),
|
||||
("shm-only buffers", build_shm_only_buffers().unwrap()),
|
||||
("dmabuf buffers", build_dmabuf_buffers().unwrap()),
|
||||
("cursor meta", build_cursor_meta_param().unwrap()),
|
||||
(
|
||||
"default format",
|
||||
serialize_pod(build_default_format_obj(None)).unwrap(),
|
||||
),
|
||||
(
|
||||
"dmabuf BGRx",
|
||||
build_dmabuf_format(VideoFormat::BGRx, &[0, 1, 2], Some((1920, 1080, 60))).unwrap(),
|
||||
),
|
||||
(
|
||||
"dmabuf NV12",
|
||||
build_dmabuf_format(VideoFormat::NV12, &[0], Some((1280, 720, 60))).unwrap(),
|
||||
),
|
||||
(
|
||||
"hdr xRGB",
|
||||
build_hdr_dmabuf_format(VideoFormat::xRGB_210LE, None).unwrap(),
|
||||
),
|
||||
(
|
||||
"hdr xBGR",
|
||||
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, Some((3840, 2160, 120))).unwrap(),
|
||||
),
|
||||
];
|
||||
for (name, bytes) in &mut pods {
|
||||
assert!(!bytes.is_empty(), "{name} serialized to nothing");
|
||||
assert_eq!(bytes.len() % 8, 0, "{name} is not 8-byte aligned/padded");
|
||||
assert!(
|
||||
spa::pod::Pod::from_bytes(bytes).is_some(),
|
||||
"{name} did not parse back as a pod"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The HDR pods carry BOTH colorimetry properties MANDATORY — Mutter's HDR pods do the same, so
|
||||
/// the intersection only exists if we speak them. Dropping either would negotiate an SDR-labelled
|
||||
/// 10-bit stream (or nothing at all).
|
||||
#[test]
|
||||
fn the_hdr_pods_carry_mandatory_pq_and_bt2020() {
|
||||
for fmt in [VideoFormat::xRGB_210LE, VideoFormat::xBGR_210LE] {
|
||||
let pod = build_hdr_dmabuf_format(fmt, None).unwrap();
|
||||
for (name, key) in [
|
||||
(
|
||||
"transferFunction",
|
||||
spa::sys::SPA_FORMAT_VIDEO_transferFunction,
|
||||
),
|
||||
("colorPrimaries", spa::sys::SPA_FORMAT_VIDEO_colorPrimaries),
|
||||
("modifier", spa::sys::SPA_FORMAT_VIDEO_modifier),
|
||||
] {
|
||||
assert!(
|
||||
pod.windows(4).any(|w| w == key.to_ne_bytes()),
|
||||
"{fmt:?} pod is missing {name}"
|
||||
);
|
||||
}
|
||||
// The PQ id and BT.2020 id must both appear as values.
|
||||
assert!(
|
||||
pod.windows(4)
|
||||
.any(|w| w == SPA_VIDEO_TRANSFER_SMPTE2084.to_ne_bytes()),
|
||||
"{fmt:?} pod does not carry the PQ transfer id"
|
||||
);
|
||||
assert!(
|
||||
pod.windows(4)
|
||||
.any(|w| w == spa::sys::SPA_VIDEO_COLOR_PRIMARIES_BT2020.to_ne_bytes()),
|
||||
"{fmt:?} pod does not carry BT.2020 primaries"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An NV12 offer pins BT.709 limited so gamescope's producer-side RGB→YUV shader matches OUR
|
||||
/// bitstream colorimetry; the packed-RGB offer must NOT carry those (it is not YUV).
|
||||
#[test]
|
||||
fn only_the_nv12_offer_pins_the_colour_matrix() {
|
||||
let nv12 = build_dmabuf_format(VideoFormat::NV12, &[0], None).unwrap();
|
||||
let bgrx = build_dmabuf_format(VideoFormat::BGRx, &[0], None).unwrap();
|
||||
for (name, key) in [
|
||||
("colorMatrix", spa::sys::SPA_FORMAT_VIDEO_colorMatrix),
|
||||
("colorRange", spa::sys::SPA_FORMAT_VIDEO_colorRange),
|
||||
] {
|
||||
assert!(
|
||||
nv12.windows(4).any(|w| w == key.to_ne_bytes()),
|
||||
"NV12 offer is missing {name}"
|
||||
);
|
||||
assert!(
|
||||
!bgrx.windows(4).any(|w| w == key.to_ne_bytes()),
|
||||
"packed-RGB offer should not pin {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pin our hand-written PQ transfer id against the real libspa binding. We can't take the
|
||||
/// constant from `pw::spa::sys` directly (older distro headers don't export it — see
|
||||
/// [`super::SPA_VIDEO_TRANSFER_SMPTE2084`]), so assert the two agree wherever the symbol
|
||||
/// DOES exist. Any libspa that renumbers the enum fails this instead of silently tagging
|
||||
/// the HDR offer with the wrong transfer function.
|
||||
///
|
||||
/// Only builds where tests are compiled — the .deb/.rpm builders run plain `cargo build`,
|
||||
/// so this never reintroduces the compile failure it exists to prevent.
|
||||
#[test]
|
||||
fn pq_transfer_id_matches_libspa() {
|
||||
assert_eq!(
|
||||
super::SPA_VIDEO_TRANSFER_SMPTE2084,
|
||||
super::pw::spa::sys::SPA_VIDEO_TRANSFER_SMPTE2084,
|
||||
"libspa renumbered spa_video_transfer_function — update the hardcoded PQ id"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@
|
||||
//! honouring it also gives the stream gamescope's own idle auto-hide, which this source never had.
|
||||
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Arc, Mutex,
|
||||
};
|
||||
use std::time::Duration;
|
||||
@@ -51,12 +51,29 @@ use x11rb::protocol::xproto::{
|
||||
Window,
|
||||
};
|
||||
use x11rb::protocol::Event;
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
use x11rb::rust_connection::{DefaultStream, RustConnection};
|
||||
|
||||
/// Serializes the brief `XAUTHORITY` env swap around a connect (the var is process-global). Only
|
||||
/// ever contended if two gamescope sessions start at once — rare, and the swap is microseconds.
|
||||
use crate::GamescopeCursorTargets;
|
||||
|
||||
/// Serializes the `XAUTHORITY` env swap of the LEGACY connect fallback (the var is process-global).
|
||||
///
|
||||
/// The fallback is a last resort now — see [`connect_conn`]. It serialises this source against
|
||||
/// itself and nothing else: `getenv` needs no lock to be racy, so every OTHER thread's read (libspa
|
||||
/// plugin load, EGL/CUDA init — concurrent by construction, since `attach_gamescope_cursor` runs
|
||||
/// while the PipeWire thread is starting) could still observe the swapped value or a torn
|
||||
/// environ. That is why the primary path parses the cookie itself and never touches the
|
||||
/// environment.
|
||||
static XAUTH_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// The `MIT-MAGIC-COOKIE-1` auth-protocol name, as it appears in an `.Xauthority` entry.
|
||||
const MIT_MAGIC_COOKIE_1: &[u8] = b"MIT-MAGIC-COOKIE-1";
|
||||
|
||||
/// Re-run the targets provider this often: adopt Xwaylands that appeared after the stream started
|
||||
/// (gamescope spawns the game's on launch and advertises only the first in any child's environ) and
|
||||
/// retry ones whose connection died. Two seconds is far below a human's tolerance for a missing
|
||||
/// pointer and costs one cheap socket probe per known display.
|
||||
const REDISCOVER: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Position out-paces the stream fps (`POLL`); shape rides `CursorNotify` events drained each tick.
|
||||
/// 4 ms ≈ 250 Hz matches the Windows GDI poller — the polled position IS the composited position
|
||||
/// and must out-run a 240 fps session or the pointer stutters.
|
||||
@@ -73,62 +90,64 @@ const GS_CURSOR_FEEDBACK: &str = "GAMESCOPE_CURSOR_VISIBLE_FEEDBACK";
|
||||
/// `GetProperty` per display per interval is nothing next to the 250 Hz pointer poll.
|
||||
const FEEDBACK_RESYNC: Duration = Duration::from_millis(250);
|
||||
|
||||
/// A running XFixes cursor reader. Dropping it stops the worker thread and joins it, releasing the
|
||||
/// X connections — so it lives exactly as long as the capturer that owns it.
|
||||
/// A running XFixes cursor reader. Dropping it stops the worker thread and waits — bounded — for it
|
||||
/// to release the X connections, so it lives (at most) as long as the capturer that owns it.
|
||||
pub(super) struct XFixesCursorSource {
|
||||
stop: Arc<AtomicBool>,
|
||||
/// Signalled by the worker just before it returns — lets `Drop` bound its wait (see there).
|
||||
done: std::sync::mpsc::Receiver<()>,
|
||||
join: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl XFixesCursorSource {
|
||||
/// Connect to every gamescope nested Xwayland in `targets` (`(DISPLAY, XAUTHORITY)`) and start
|
||||
/// publishing cursor overlays into `slot`, following the focused display's pointer. Returns
|
||||
/// `None` — and logs — if NONE can be used (no X connection / no XFixes), so the caller
|
||||
/// degrades to no gamescope cursor (today's behaviour) instead of failing the session.
|
||||
/// Start publishing cursor overlays into `slot` from whichever of gamescope's nested Xwaylands
|
||||
/// it is drawing the pointer on. `targets` is re-run every [`REDISCOVER`] to adopt Xwaylands
|
||||
/// that appear later and retry dead ones; `frame_size` is the negotiated capture size, packed
|
||||
/// `(w << 32) | h`, `0` until the first negotiation (see [`scale_to_frame`]).
|
||||
///
|
||||
/// Returns `None` only if the thread cannot be spawned. An empty or entirely-unusable target
|
||||
/// list is NOT a failure: the worker idles and keeps re-running the provider, so a stream that
|
||||
/// starts before the game converges instead of being cursorless for the session.
|
||||
pub(super) fn spawn(
|
||||
targets: Vec<(String, Option<String>)>,
|
||||
targets: GamescopeCursorTargets,
|
||||
slot: Arc<Mutex<Option<CursorOverlay>>>,
|
||||
frame_size: Arc<AtomicU64>,
|
||||
) -> Option<Self> {
|
||||
// Connect on the caller's thread so failures degrade cleanly and the displays are validated
|
||||
// before we commit a thread.
|
||||
// First pass on the caller's thread: the common case connects here, so the log line below
|
||||
// reports the real state instead of "starting…".
|
||||
let mut displays = Vec::new();
|
||||
for (dpy, xauth) in targets {
|
||||
match connect(&dpy, xauth.as_deref()) {
|
||||
Ok((conn, root, feedback)) => {
|
||||
displays.push(XDisplay::new(dpy, conn, root, feedback))
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
dpy = %dpy,
|
||||
error = %e,
|
||||
"gamescope cursor: skipping a nested Xwayland we can't use"
|
||||
),
|
||||
}
|
||||
}
|
||||
if displays.is_empty() {
|
||||
tracing::warn!(
|
||||
"gamescope cursor: no usable nested Xwayland — no in-video pointer this session \
|
||||
(falls back to today's cursorless gamescope stream)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
rediscover(&mut displays, &targets, true);
|
||||
let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect();
|
||||
let feedback = displays.iter().any(|d| d.gs_visible.is_some());
|
||||
tracing::info!(
|
||||
displays = ?names,
|
||||
cursor_feedback = feedback,
|
||||
"gamescope cursor: XFixes source live — following the Xwayland gamescope draws the \
|
||||
pointer on (cursor_feedback=false ⇒ this gamescope publishes no \
|
||||
GAMESCOPE_CURSOR_VISIBLE_FEEDBACK, degrading to the pointer-motion heuristic)"
|
||||
);
|
||||
if displays.is_empty() {
|
||||
tracing::warn!(
|
||||
"gamescope cursor: no usable nested Xwayland yet — retrying every {}s (a game's \
|
||||
Xwayland appears when it launches)",
|
||||
REDISCOVER.as_secs()
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
displays = ?names,
|
||||
cursor_feedback = feedback,
|
||||
"gamescope cursor: XFixes source live — following the Xwayland gamescope draws the \
|
||||
pointer on (cursor_feedback=false ⇒ this gamescope publishes no \
|
||||
GAMESCOPE_CURSOR_VISIBLE_FEEDBACK, degrading to the pointer-motion heuristic)"
|
||||
);
|
||||
}
|
||||
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_worker = Arc::clone(&stop);
|
||||
let (done_tx, done_rx) = std::sync::mpsc::sync_channel::<()>(1);
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-gs-cursor".into())
|
||||
.spawn(move || run(displays, slot, stop_worker))
|
||||
.spawn(move || {
|
||||
run(displays, slot, stop_worker, targets, frame_size);
|
||||
let _ = done_tx.send(());
|
||||
})
|
||||
.ok()?;
|
||||
Some(XFixesCursorSource {
|
||||
stop,
|
||||
done: done_rx,
|
||||
join: Some(join),
|
||||
})
|
||||
}
|
||||
@@ -137,35 +156,69 @@ impl XFixesCursorSource {
|
||||
impl Drop for XFixesCursorSource {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
// BOUNDED join. The worker blocks in `RustConnection` replies with no read timeout, so a
|
||||
// peer that stops answering while keeping its socket open (a hung Xwayland) would hang
|
||||
// capturer teardown — and teardown runs on the session path. On timeout we detach: the
|
||||
// thread only ever touches its own X connections and an `Arc`'d slot, so leaving it to
|
||||
// finish on its own is safe (it observes `stop` and exits the moment its reply lands).
|
||||
let joinable = self.done.recv_timeout(Duration::from_millis(250)).is_ok();
|
||||
if let Some(j) = self.join.take() {
|
||||
let _ = j.join();
|
||||
if joinable {
|
||||
let _ = j.join(); // returns at once: `done` already fired
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"gamescope cursor: worker did not stop within 250ms (blocked on an X reply?) — \
|
||||
detaching it"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-run the targets provider and reconcile `displays` with it: connect to any target we do not
|
||||
/// track yet, and re-connect one whose display died. Existing healthy displays are left alone, so
|
||||
/// their shape cache and last position survive.
|
||||
fn rediscover(displays: &mut Vec<XDisplay>, targets: &GamescopeCursorTargets, first: bool) {
|
||||
for (dpy, xauth) in targets() {
|
||||
let existing = displays.iter().position(|d| d.name == dpy);
|
||||
if existing.is_some_and(|i| !displays[i].dead) {
|
||||
continue;
|
||||
}
|
||||
match connect(&dpy, xauth.as_deref()) {
|
||||
Ok((conn, root, root_size, feedback)) => {
|
||||
let d = XDisplay::new(dpy.clone(), conn, root, root_size, feedback);
|
||||
match existing {
|
||||
Some(i) => {
|
||||
tracing::info!(dpy = %dpy, "gamescope cursor: reconnected a nested Xwayland");
|
||||
displays[i] = d;
|
||||
}
|
||||
None => {
|
||||
if !first {
|
||||
tracing::info!(dpy = %dpy, "gamescope cursor: adopted a new nested Xwayland");
|
||||
}
|
||||
displays.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Debug, not warn: with a 2 s retry a warn would flood for every Xwayland a
|
||||
// `--xwayland-count` reports but that never comes up.
|
||||
Err(e) if first => tracing::warn!(
|
||||
dpy = %dpy, error = %e,
|
||||
"gamescope cursor: skipping a nested Xwayland we can't use (will retry)"
|
||||
),
|
||||
Err(e) => tracing::debug!(dpy = %dpy, error = %e, "gamescope cursor: retry failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the X connection, negotiate XFixes, and select cursor-change + root-property events —
|
||||
/// returning the connection, root window and this display's initial
|
||||
/// [`GAMESCOPE_CURSOR_FEEDBACK`](GS_CURSOR_FEEDBACK) reading (`(atom, value)`; the value is `None`
|
||||
/// when gamescope publishes no such property here). `RustConnection` reads `XAUTHORITY` from the
|
||||
/// env at connect time only, so set it under the lock (the host isn't a gamescope child), connect,
|
||||
/// then restore.
|
||||
type Connected = (RustConnection, Window, (Atom, Option<bool>));
|
||||
/// returning the connection, root window, the root's pixel size (for [`scale_to_frame`]) and this
|
||||
/// display's initial [`GAMESCOPE_CURSOR_FEEDBACK`](GS_CURSOR_FEEDBACK) reading (`(atom, value)`;
|
||||
/// the value is `None` when gamescope publishes no such property here).
|
||||
type Connected = (RustConnection, Window, (u16, u16), (Atom, Option<bool>));
|
||||
|
||||
fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
|
||||
let (conn, screen_num) = {
|
||||
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let prev = std::env::var_os("XAUTHORITY");
|
||||
if let Some(x) = xauthority {
|
||||
std::env::set_var("XAUTHORITY", x);
|
||||
}
|
||||
let out = RustConnection::connect(Some(dpy));
|
||||
match (&prev, xauthority) {
|
||||
(Some(p), _) => std::env::set_var("XAUTHORITY", p),
|
||||
(None, Some(_)) => std::env::remove_var("XAUTHORITY"),
|
||||
(None, None) => {}
|
||||
}
|
||||
out.map_err(|e| format!("connect: {e}"))?
|
||||
};
|
||||
let (conn, screen_num) = connect_conn(dpy, xauthority)?;
|
||||
|
||||
// XFixes ≥ 1 gives GetCursorImage / SelectCursorInput; ask for a modern minor, take what we get.
|
||||
conn.xfixes_query_version(5, 0)
|
||||
@@ -173,12 +226,16 @@ fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
|
||||
.and_then(|c| c.reply())
|
||||
.map_err(|e| format!("XFixes unavailable: {e}"))?;
|
||||
|
||||
let root = conn
|
||||
let screen = conn
|
||||
.setup()
|
||||
.roots
|
||||
.get(screen_num)
|
||||
.ok_or_else(|| format!("no X screen {screen_num}"))?
|
||||
.root;
|
||||
.ok_or_else(|| format!("no X screen {screen_num}"))?;
|
||||
let root = screen.root;
|
||||
// gamescope's nested root can be a DIFFERENT size from the output/PipeWire node it publishes
|
||||
// (`-w/-h` vs `-W/-H` are independent knobs), and this is the space `QueryPointer` answers in.
|
||||
// Free here — the setup reply is already parsed.
|
||||
let root_size = (screen.width_in_pixels, screen.height_in_pixels);
|
||||
|
||||
// Wake the worker's event drain whenever the cursor shape changes (incl. hide/show).
|
||||
conn.xfixes_select_cursor_input(root, xfixes::CursorNotifyMask::DISPLAY_CURSOR)
|
||||
@@ -203,7 +260,143 @@ fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
|
||||
}
|
||||
let _ = conn.flush();
|
||||
let feedback = read_cursor_feedback(&conn, root, feedback_atom);
|
||||
Ok((conn, root, (feedback_atom, feedback)))
|
||||
Ok((conn, root, root_size, (feedback_atom, feedback)))
|
||||
}
|
||||
|
||||
/// Establish the X connection for `dpy` using `xauthority`'s cookie, WITHOUT touching this process's
|
||||
/// environment.
|
||||
///
|
||||
/// `RustConnection::connect` reads `XAUTHORITY` from the env, so the original implementation
|
||||
/// `set_var`'d it around each connect under [`XAUTH_LOCK`]. That is unsound from a live
|
||||
/// multithreaded host: the lock serialises this source against itself, but `getenv` takes no lock,
|
||||
/// so any concurrent reader (libspa's plugin load, EGL/CUDA init — running at exactly this moment,
|
||||
/// since the PipeWire thread is starting up) could read the swapped value or race the environ
|
||||
/// rewrite outright. The project already has a process-wide env-lock discipline elsewhere, but
|
||||
/// sharing it would be the wrong layer AND would still not fix `getenv`.
|
||||
///
|
||||
/// So: parse the MIT-MAGIC-COOKIE-1 entry out of the file ourselves and hand it to
|
||||
/// `connect_to_stream_with_auth_info`, which is what `RustConnection::connect` does internally with
|
||||
/// the cookie IT found. The env swap survives only as a fallback for a file we cannot parse (an
|
||||
/// unexpected layout, or an auth family whose entry we decline to guess at).
|
||||
fn connect_conn(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, usize), String> {
|
||||
let Some(path) = xauthority else {
|
||||
// No per-display cookie file to inject: the ambient environment is already what this
|
||||
// connect should use, so there is nothing to swap and nothing to parse.
|
||||
return RustConnection::connect(Some(dpy)).map_err(|e| format!("connect: {e}"));
|
||||
};
|
||||
match mit_magic_cookie(path, dpy) {
|
||||
Some((name, data)) => match connect_with_cookie(dpy, name, data) {
|
||||
Ok(v) => return Ok(v),
|
||||
Err(e) => tracing::debug!(
|
||||
dpy = %dpy, xauthority = %path, error = %e,
|
||||
"gamescope cursor: cookie connect failed — falling back to the XAUTHORITY env swap"
|
||||
),
|
||||
},
|
||||
None => tracing::debug!(
|
||||
dpy = %dpy, xauthority = %path,
|
||||
"gamescope cursor: no MIT-MAGIC-COOKIE-1 entry for this display — falling back to the \
|
||||
XAUTHORITY env swap"
|
||||
),
|
||||
}
|
||||
connect_via_env_swap(dpy, path)
|
||||
}
|
||||
|
||||
/// Connect to `dpy` and complete the setup handshake with an explicit cookie — the same two steps
|
||||
/// `RustConnection::connect` performs, minus its env-derived auth lookup.
|
||||
fn connect_with_cookie(
|
||||
dpy: &str,
|
||||
auth_name: Vec<u8>,
|
||||
auth_data: Vec<u8>,
|
||||
) -> Result<(RustConnection, usize), String> {
|
||||
let parsed = x11rb::reexports::x11rb_protocol::parse_display::parse_display(Some(dpy))
|
||||
.map_err(|e| format!("parse display {dpy}: {e}"))?;
|
||||
let screen = usize::from(parsed.screen);
|
||||
let mut stream = None;
|
||||
let mut last_err = None;
|
||||
for addr in parsed.connect_instruction() {
|
||||
match DefaultStream::connect(&addr) {
|
||||
Ok((s, _peer)) => {
|
||||
stream = Some(s);
|
||||
break;
|
||||
}
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
let stream = stream.ok_or_else(|| match last_err {
|
||||
Some(e) => format!("connect: {e}"),
|
||||
None => "connect: no usable address".to_string(),
|
||||
})?;
|
||||
RustConnection::connect_to_stream_with_auth_info(stream, screen, auth_name, auth_data)
|
||||
.map(|c| (c, screen))
|
||||
.map_err(|e| format!("setup: {e}"))
|
||||
}
|
||||
|
||||
/// LEGACY fallback (see [`connect_conn`]): swap `XAUTHORITY`, connect, restore. Serialised against
|
||||
/// this source's own concurrent connects, but NOT against other threads' `getenv` — which is why it
|
||||
/// is a fallback and not the path taken.
|
||||
fn connect_via_env_swap(dpy: &str, xauthority: &str) -> Result<(RustConnection, usize), String> {
|
||||
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let prev = std::env::var_os("XAUTHORITY");
|
||||
std::env::set_var("XAUTHORITY", xauthority);
|
||||
let out = RustConnection::connect(Some(dpy));
|
||||
match prev {
|
||||
Some(p) => std::env::set_var("XAUTHORITY", p),
|
||||
None => std::env::remove_var("XAUTHORITY"),
|
||||
}
|
||||
out.map_err(|e| format!("connect: {e}"))
|
||||
}
|
||||
|
||||
/// The `MIT-MAGIC-COOKIE-1` `(name, data)` for `dpy` from the `.Xauthority`-format file at `path`.
|
||||
///
|
||||
/// Entry layout, all integers big-endian: `u16 family`, then four length-prefixed byte strings —
|
||||
/// `address`, `number` (the display number in ASCII), `name`, `data`. An entry with an empty
|
||||
/// `number` matches any display.
|
||||
///
|
||||
/// Deliberately does NOT match on family/address, unlike libxcb's own lookup. A gamescope Xwayland
|
||||
/// gets its own single-entry cookie file, so there is nothing to disambiguate; and a wrong pick
|
||||
/// cannot do damage — the server rejects the cookie, and [`connect_conn`] falls back to the env
|
||||
/// path, which does the full match. Guessing the peer address for a `LOCAL`-family unix socket, on
|
||||
/// the other hand, is exactly the sort of detail that would rot.
|
||||
fn mit_magic_cookie(path: &str, dpy: &str) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let want = display_number(dpy);
|
||||
// An entry whose `number` is empty matches any display — only used if no exact match is found.
|
||||
let mut wildcard = None;
|
||||
let mut p = 0usize;
|
||||
'entries: while p + 2 <= bytes.len() {
|
||||
p += 2; // family
|
||||
let mut fields: [&[u8]; 4] = [&[]; 4];
|
||||
for f in fields.iter_mut() {
|
||||
let Some(lb) = bytes.get(p..p + 2) else {
|
||||
break 'entries; // truncated — keep whatever we already found
|
||||
};
|
||||
let len = usize::from(u16::from_be_bytes([lb[0], lb[1]]));
|
||||
p += 2;
|
||||
let Some(v) = bytes.get(p..p + len) else {
|
||||
break 'entries;
|
||||
};
|
||||
*f = v;
|
||||
p += len;
|
||||
}
|
||||
let [_address, number, name, data] = fields;
|
||||
if name != MIT_MAGIC_COOKIE_1 {
|
||||
continue;
|
||||
}
|
||||
if want.as_deref().is_some_and(|w| number == w) {
|
||||
return Some((name.to_vec(), data.to_vec()));
|
||||
}
|
||||
if number.is_empty() && wildcard.is_none() {
|
||||
wildcard = Some((name.to_vec(), data.to_vec()));
|
||||
}
|
||||
}
|
||||
wildcard
|
||||
}
|
||||
|
||||
/// The display NUMBER of an X display string as ASCII bytes: `":2"`, `"host:2"` and `":2.0"` all
|
||||
/// yield `b"2"`. `None` when there is no numeric display component to match on.
|
||||
fn display_number(dpy: &str) -> Option<Vec<u8>> {
|
||||
let num = dpy.rsplit_once(':')?.1.split('.').next()?;
|
||||
(!num.is_empty() && num.bytes().all(|b| b.is_ascii_digit())).then(|| num.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// Read `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` off `root`. `None` = the property is absent or
|
||||
@@ -227,6 +420,9 @@ struct XDisplay {
|
||||
name: String,
|
||||
conn: RustConnection,
|
||||
root: Window,
|
||||
/// The nested root's size in pixels — the space `QueryPointer` reports in, which is NOT
|
||||
/// necessarily the captured frame's (see [`scale_to_frame`]).
|
||||
root_size: (u16, u16),
|
||||
/// Last polled pointer position — a change since the previous tick marks this display FOCUSED.
|
||||
last_pos: Option<(i32, i32)>,
|
||||
/// Cached cursor shape, refreshed only after this display's XFixes `CursorNotify`.
|
||||
@@ -247,12 +443,14 @@ impl XDisplay {
|
||||
name: String,
|
||||
conn: RustConnection,
|
||||
root: Window,
|
||||
root_size: (u16, u16),
|
||||
(feedback_atom, gs_visible): (Atom, Option<bool>),
|
||||
) -> Self {
|
||||
XDisplay {
|
||||
name,
|
||||
conn,
|
||||
root,
|
||||
root_size,
|
||||
last_pos: None,
|
||||
shape: Shape::default(),
|
||||
need_shape: true,
|
||||
@@ -288,11 +486,39 @@ struct Shape {
|
||||
visible: bool,
|
||||
}
|
||||
|
||||
/// Map a root-space pointer position into FRAME space.
|
||||
///
|
||||
/// `QueryPointer` answers in the nested root's coordinates, but `CursorOverlay::x/y`'s contract is
|
||||
/// FRAME pixels — and gamescope's `-w/-h` (nested root) and `-W/-H` (output + PipeWire node) are
|
||||
/// independent knobs. Measured on this box with gamescope 3.16.23 at `-W 1280 -H 720 -w 640 -h 360`
|
||||
/// the two spaces differ by 2×, so publishing root coordinates verbatim drew the pointer at a
|
||||
/// fraction of its real position. `frame` is `(0, 0)` until the PipeWire format negotiates, in which
|
||||
/// case the position passes through unscaled — the same as before, and correct for the common case
|
||||
/// where the two spaces agree.
|
||||
///
|
||||
/// The cursor BITMAP is not scaled: it stays at the shape's native size, so on a mismatched session
|
||||
/// the pointer lands in the right place but is drawn at root scale.
|
||||
fn scale_to_frame((x, y): (i32, i32), root: (u16, u16), frame: (u32, u32)) -> (i32, i32) {
|
||||
let (rw, rh) = (u32::from(root.0), u32::from(root.1));
|
||||
let (fw, fh) = frame;
|
||||
if rw == 0 || rh == 0 || fw == 0 || fh == 0 || (rw, rh) == (fw, fh) {
|
||||
return (x, y);
|
||||
}
|
||||
(
|
||||
((i64::from(x) * i64::from(fw)) / i64::from(rw)) as i32,
|
||||
((i64::from(y) * i64::from(fh)) / i64::from(rh)) as i32,
|
||||
)
|
||||
}
|
||||
|
||||
fn run(
|
||||
mut displays: Vec<XDisplay>,
|
||||
slot: Arc<Mutex<Option<CursorOverlay>>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
targets: GamescopeCursorTargets,
|
||||
frame_size: Arc<AtomicU64>,
|
||||
) {
|
||||
let mut last_discover = std::time::Instant::now();
|
||||
let mut warned_scale = false;
|
||||
let mut active = 0usize;
|
||||
// The overlay serial must bump whenever the DRAWN cursor changes — either the active display's
|
||||
// shape OR which display is active (per-display XFixes serials aren't comparable across
|
||||
@@ -309,6 +535,11 @@ fn run(
|
||||
if resync {
|
||||
last_resync = std::time::Instant::now();
|
||||
}
|
||||
// Adopt Xwaylands that appeared since we started (a game's, above all) and retry dead ones.
|
||||
if last_discover.elapsed() >= REDISCOVER {
|
||||
last_discover = std::time::Instant::now();
|
||||
rediscover(&mut displays, &targets, false);
|
||||
}
|
||||
|
||||
// 1) Poll every display's pointer; note which moved since last tick (the fallback focus
|
||||
// signal, used only when this gamescope publishes no cursor verdict).
|
||||
@@ -395,8 +626,27 @@ fn run(
|
||||
// a `None` here would leave the last visible overlay standing on repeat frames.
|
||||
let d = &displays[active];
|
||||
let drawn = d.shape.visible && !hidden_by_gamescope;
|
||||
// Root space → frame space (see `scale_to_frame`). The negotiated size arrives from the
|
||||
// PipeWire thread's `param_changed`, packed `(w << 32) | h`; `0` = not negotiated yet.
|
||||
let packed = frame_size.load(Ordering::Relaxed);
|
||||
let frame = ((packed >> 32) as u32, packed as u32);
|
||||
if !warned_scale
|
||||
&& frame.0 != 0
|
||||
&& (u32::from(d.root_size.0), u32::from(d.root_size.1)) != frame
|
||||
{
|
||||
warned_scale = true;
|
||||
tracing::warn!(
|
||||
dpy = %d.name,
|
||||
root = %format!("{}x{}", d.root_size.0, d.root_size.1),
|
||||
negotiated = %format!("{}x{}", frame.0, frame.1),
|
||||
"gamescope cursor: the nested root and the captured frame are different sizes \
|
||||
(gamescope -w/-h vs -W/-H) — scaling the pointer POSITION into frame space; the \
|
||||
cursor bitmap stays at root scale"
|
||||
);
|
||||
}
|
||||
let overlay = match (d.last_pos, d.shape.rgba.is_empty()) {
|
||||
(Some((px, py)), false) => {
|
||||
(Some(pos), false) => {
|
||||
let (px, py) = scale_to_frame(pos, d.root_size, frame);
|
||||
let key = (active, d.shape.serial);
|
||||
if key != last_key {
|
||||
out_serial += 1;
|
||||
@@ -511,7 +761,147 @@ fn argb_premul_to_straight_rgba(argb: &[u32]) -> Vec<u8> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::pick_active;
|
||||
use super::{
|
||||
display_number, mit_magic_cookie, pick_active, scale_to_frame, MIT_MAGIC_COOKIE_1,
|
||||
};
|
||||
|
||||
/// One `.Xauthority` entry in wire form: `u16 family` + four length-prefixed byte strings.
|
||||
fn entry(family: u16, address: &[u8], number: &[u8], name: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut v = family.to_be_bytes().to_vec();
|
||||
for f in [address, number, name, data] {
|
||||
v.extend_from_slice(&(f.len() as u16).to_be_bytes());
|
||||
v.extend_from_slice(f);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn write_xauth(bytes: &[u8]) -> std::path::PathBuf {
|
||||
// The scratch file lives beside the test binary's temp dir; unique per call via the address
|
||||
// of a local (no rand dependency, and the tests do not run concurrently on one path).
|
||||
let mut p = std::env::temp_dir();
|
||||
let uniq = format!(
|
||||
"pf-xauth-test-{}-{:p}",
|
||||
std::process::id(),
|
||||
bytes as *const [u8]
|
||||
);
|
||||
p.push(uniq);
|
||||
std::fs::write(&p, bytes).expect("write scratch xauth");
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_number_handles_every_display_spelling() {
|
||||
assert_eq!(display_number(":2").as_deref(), Some(&b"2"[..]));
|
||||
assert_eq!(display_number(":2.0").as_deref(), Some(&b"2"[..]));
|
||||
assert_eq!(display_number("host:13.1").as_deref(), Some(&b"13"[..]));
|
||||
// Nothing to match on — the caller then accepts only a wildcard entry.
|
||||
assert_eq!(display_number("bogus"), None);
|
||||
assert_eq!(display_number(":"), None);
|
||||
assert_eq!(display_number(":abc"), None);
|
||||
}
|
||||
|
||||
/// The cookie reader is the one PARSER in this file fed a file we did not write, so it gets the
|
||||
/// hostile-input treatment: exact-match, wildcard fallback, skipping other auth protocols, and
|
||||
/// truncation.
|
||||
#[test]
|
||||
fn mit_magic_cookie_picks_the_matching_entry() {
|
||||
let mut file = Vec::new();
|
||||
// A different protocol on the display we want — must be skipped, not returned.
|
||||
file.extend(entry(256, b"host", b"2", b"XDM-AUTHORIZATION-1", b"nope"));
|
||||
// Another display's cookie.
|
||||
file.extend(entry(
|
||||
256,
|
||||
b"host",
|
||||
b"7",
|
||||
MIT_MAGIC_COOKIE_1,
|
||||
b"other-display",
|
||||
));
|
||||
// Ours.
|
||||
file.extend(entry(256, b"host", b"2", MIT_MAGIC_COOKIE_1, b"the-cookie"));
|
||||
let p = write_xauth(&file);
|
||||
let got = mit_magic_cookie(p.to_str().unwrap(), ":2");
|
||||
assert_eq!(
|
||||
got,
|
||||
Some((MIT_MAGIC_COOKIE_1.to_vec(), b"the-cookie".to_vec()))
|
||||
);
|
||||
// A display with no entry at all yields nothing (⇒ the caller falls back to the env path).
|
||||
assert_eq!(mit_magic_cookie(p.to_str().unwrap(), ":9"), None);
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mit_magic_cookie_falls_back_to_a_wildcard_entry() {
|
||||
// An empty `number` matches any display — but an exact match still wins.
|
||||
let mut file = entry(256, b"host", b"", MIT_MAGIC_COOKIE_1, b"wildcard");
|
||||
file.extend(entry(256, b"host", b"3", MIT_MAGIC_COOKIE_1, b"exact"));
|
||||
let p = write_xauth(&file);
|
||||
let path = p.to_str().unwrap();
|
||||
assert_eq!(
|
||||
mit_magic_cookie(path, ":3").map(|(_, d)| d),
|
||||
Some(b"exact".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
mit_magic_cookie(path, ":4").map(|(_, d)| d),
|
||||
Some(b"wildcard".to_vec())
|
||||
);
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_xauthority_keeps_what_it_already_parsed() {
|
||||
let mut file = entry(256, b"host", b"", MIT_MAGIC_COOKIE_1, b"wildcard");
|
||||
// A second entry cut off mid-length-prefix.
|
||||
file.extend(entry(256, b"host", b"5", MIT_MAGIC_COOKIE_1, b"truncated"));
|
||||
file.truncate(file.len() - 4);
|
||||
let p = write_xauth(&file);
|
||||
// No panic, no over-read: the wildcard found before the truncation still serves.
|
||||
assert_eq!(
|
||||
mit_magic_cookie(p.to_str().unwrap(), ":5").map(|(_, d)| d),
|
||||
Some(b"wildcard".to_vec())
|
||||
);
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_garbage_xauthority_is_declined_not_fatal() {
|
||||
// A length prefix that claims far more than the file holds.
|
||||
let p = write_xauth(&[0, 0, 0xff, 0xff, 1, 2, 3]);
|
||||
assert_eq!(mit_magic_cookie(p.to_str().unwrap(), ":0"), None);
|
||||
let _ = std::fs::remove_file(p);
|
||||
// A missing file is simply "no cookie".
|
||||
assert_eq!(mit_magic_cookie("/nonexistent/pf-xauth", ":0"), None);
|
||||
}
|
||||
|
||||
/// L6: gamescope's `-w/-h` (nested root, the space `QueryPointer` answers in) and `-W/-H`
|
||||
/// (output + PipeWire node, the space `CursorOverlay` is contracted in) are independent.
|
||||
#[test]
|
||||
fn pointer_positions_are_mapped_into_frame_space() {
|
||||
// The measured case: root 640x360 inside a 1280x720 output — 2x.
|
||||
assert_eq!(
|
||||
scale_to_frame((320, 180), (640, 360), (1280, 720)),
|
||||
(640, 360)
|
||||
);
|
||||
assert_eq!(scale_to_frame((0, 0), (640, 360), (1280, 720)), (0, 0));
|
||||
// Down-scaling works the same way.
|
||||
assert_eq!(
|
||||
scale_to_frame((1280, 720), (1280, 720), (640, 360)),
|
||||
(640, 360)
|
||||
);
|
||||
// Equal spaces are a pass-through (the common case — no rounding drift).
|
||||
assert_eq!(scale_to_frame((7, 9), (1920, 1080), (1920, 1080)), (7, 9));
|
||||
// Not negotiated yet, or a degenerate root: pass through rather than divide by zero.
|
||||
assert_eq!(scale_to_frame((7, 9), (1920, 1080), (0, 0)), (7, 9));
|
||||
assert_eq!(scale_to_frame((7, 9), (0, 0), (1920, 1080)), (7, 9));
|
||||
}
|
||||
|
||||
/// A 5K frame times a 5K coordinate overflows `i32` — the scale must be computed wide.
|
||||
#[test]
|
||||
fn scaling_does_not_overflow_at_5k() {
|
||||
assert_eq!(
|
||||
scale_to_frame((2879, 1619), (2880, 1620), (5120, 2880)),
|
||||
(5118, 2878)
|
||||
);
|
||||
}
|
||||
|
||||
/// Steam Gaming Mode shape: display 0 = Big Picture's Xwayland, 1 = the game's.
|
||||
const BPM: usize = 0;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,648 @@
|
||||
//! The P010 colour SELF-TEST and its helpers — the `hdr-p010-selftest` subcommand's whole
|
||||
//! implementation, plus the f64 reference math it compares against and the f16 encoder it uploads
|
||||
//! with.
|
||||
//!
|
||||
//! Split out of `windows/dxgi.rs` in sweep Phase 5.5: it was ~560 of that file's 1,374 lines and
|
||||
//! none of it runs in a session. What remains in the parent is the production path (the win32u hook,
|
||||
//! the shader sources, the three converters); this is the validation path.
|
||||
//!
|
||||
//! `hdr_p010_selftest_at` and `hdr_p010_convert_bars_on_luid` are re-exported by the parent, so
|
||||
//! every existing `crate::capture::dxgi::…` / `pf_capture::dxgi::…` path keeps resolving.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// f64 reference for the P010 colour math — the EXACT analogue of the HLSL in [`HDR_P010_COMMON`].
|
||||
/// Input is one scRGB pixel (linear, Rec.709 primaries, 1.0 = 80 nits, may be >1 for HDR). Output is
|
||||
/// the 10-bit studio-range (Y, Cb, Cr) codes the shader should produce for a flat (constant) block.
|
||||
/// Used by [`hdr_p010_selftest`].
|
||||
#[cfg(target_os = "windows")]
|
||||
fn p010_reference(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
|
||||
fn pq_oetf(l: f64) -> f64 {
|
||||
let l = l.clamp(0.0, 1.0);
|
||||
let m1 = 0.1593017578125;
|
||||
let m2 = 78.84375;
|
||||
let c1 = 0.8359375;
|
||||
let c2 = 18.8515625;
|
||||
let c3 = 18.6875;
|
||||
let lp = l.powf(m1);
|
||||
((c1 + c2 * lp) / (1.0 + c3 * lp)).powf(m2)
|
||||
}
|
||||
// scRGB -> nits -> BT.2020 linear (row-major matrix, mul(M, v)).
|
||||
let (r, g, b) = (r.max(0.0) * 80.0, g.max(0.0) * 80.0, b.max(0.0) * 80.0);
|
||||
let m = [
|
||||
[0.627403914, 0.329283038, 0.043313048],
|
||||
[0.069097292, 0.919540405, 0.011362303],
|
||||
[0.016391439, 0.088013308, 0.895595253],
|
||||
];
|
||||
let lr = m[0][0] * r + m[0][1] * g + m[0][2] * b;
|
||||
let lg = m[1][0] * r + m[1][1] * g + m[1][2] * b;
|
||||
let lb = m[2][0] * r + m[2][1] * g + m[2][2] * b;
|
||||
// PQ encode (normalize to 10k nits).
|
||||
let pr = pq_oetf(lr / 10000.0);
|
||||
let pg = pq_oetf(lg / 10000.0);
|
||||
let pb = pq_oetf(lb / 10000.0);
|
||||
// BT.2020 non-constant-luminance, limited 10-bit.
|
||||
let (kr, kg, kb) = (0.2627, 0.6780, 0.0593);
|
||||
let y = kr * pr + kg * pg + kb * pb;
|
||||
let cb = (pb - y) / 1.8814;
|
||||
let cr = (pr - y) / 1.4746;
|
||||
let yc = (64.0 + 876.0 * y).clamp(64.0, 940.0);
|
||||
let cbc = (512.0 + 896.0 * cb).clamp(64.0, 960.0);
|
||||
let crc = (512.0 + 896.0 * cr).clamp(64.0, 960.0);
|
||||
(yc, cbc, crc)
|
||||
}
|
||||
|
||||
/// Test support (used by pf-encode's live e2e): the 8 sRGB colour bars (white/yellow/cyan/green/
|
||||
/// magenta/red/blue/black, sRGB 1.0 = scRGB 1.0 = 80 nits) as a w×h FP16 scRGB texture on the
|
||||
/// adapter with `luid`, converted through the REAL [`HdrP010Converter`] into a P010 texture with
|
||||
/// **`BIND_RENDER_TARGET` only, `MiscFlags` 0 — the exact bind profile of the IDD out-ring** (the
|
||||
/// CPU-upload encoder tests can't use that profile, so only this path exercises "RTV-written P010
|
||||
/// → encoder ingest copy"). Returns `(device, p010)`; expected decoded codes per bar are the
|
||||
/// bars_pq2020 fixture's: (490,512,512) (478,423,518) (464,525,473) (450,432,476) (350,584,585)
|
||||
/// (325,448,598) (226,650,535) (64,512,512).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[doc(hidden)]
|
||||
pub fn hdr_p010_convert_bars_on_luid(
|
||||
luid: [u8; 8],
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Result<(ID3D11Device, ID3D11Texture2D)> {
|
||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
||||
|
||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
||||
bail!("bars pattern needs even non-zero dimensions, got {w}x{h}");
|
||||
}
|
||||
// sRGB primaries at full/zero channels: sRGB EOTF(1.0)=1.0, (0)=0 → the scRGB pattern is
|
||||
// pure 0/1 floats and the PQ/BT.2020 reference codes above are exact.
|
||||
const BARS: [(f32, f32, f32); 8] = [
|
||||
(1.0, 1.0, 1.0),
|
||||
(1.0, 1.0, 0.0),
|
||||
(0.0, 1.0, 1.0),
|
||||
(0.0, 1.0, 0.0),
|
||||
(1.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
(0.0, 0.0, 0.0),
|
||||
];
|
||||
let bar_w = (w / 8).max(1) as usize;
|
||||
let mut fp16 = vec![0u16; (w * h * 4) as usize];
|
||||
for y in 0..h as usize {
|
||||
for x in 0..w as usize {
|
||||
let (r, g, b) = BARS[(x / bar_w).min(7)];
|
||||
let i = (y * w as usize + x) * 4;
|
||||
fp16[i] = f32_to_f16(r);
|
||||
fp16[i + 1] = f32_to_f16(g);
|
||||
fp16[i + 2] = f32_to_f16(b);
|
||||
fp16[i + 3] = f32_to_f16(1.0);
|
||||
}
|
||||
}
|
||||
// SAFETY: same single-device/single-thread contract as `hdr_p010_selftest_at`; the FP16
|
||||
// initial-data Vec outlives the synchronous CreateTexture2D; the returned COM handles own
|
||||
// their references.
|
||||
unsafe {
|
||||
let luid = windows::Win32::Foundation::LUID {
|
||||
LowPart: u32::from_le_bytes(luid[..4].try_into().unwrap()),
|
||||
HighPart: i32::from_le_bytes(luid[4..].try_into().unwrap()),
|
||||
};
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("dxgi factory")?;
|
||||
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).context("adapter by luid")?;
|
||||
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),
|
||||
)
|
||||
.context("D3D11CreateDevice(luid) for bars convert")?;
|
||||
let device = device.context("null device")?;
|
||||
let context = context.context("null context")?;
|
||||
|
||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let init = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: fp16.as_ptr() as *const c_void,
|
||||
SysMemPitch: w * 8,
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut src_tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
||||
.context("CreateTexture2D(fp16 bars)")?;
|
||||
let src_tex = src_tex.context("null src tex")?;
|
||||
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
||||
.context("CreateShaderResourceView(fp16 bars)")?;
|
||||
let src_srv = src_srv.context("null src srv")?;
|
||||
|
||||
// The IDD out-ring's exact profile: P010, RENDER_TARGET only, MiscFlags 0.
|
||||
let p010_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_P010,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut p010: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
||||
.context("CreateTexture2D(P010 bars dst)")?;
|
||||
let p010 = p010.context("null p010 tex")?;
|
||||
|
||||
let conv = HdrP010Converter::new(&device, w, h)?;
|
||||
let y_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16_UNORM)?;
|
||||
let uv_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16G16_UNORM)?;
|
||||
conv.convert(&context, &src_srv, &y_rtv, &uv_rtv, w, h)?;
|
||||
Ok((device, p010))
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour self-test for [`HdrP010Converter`] (the `hdr-p010-selftest` subcommand): create a hardware
|
||||
/// D3D11 device, upload a known scRGB FP16 pattern, run the P010 shader passes, read the Y (plane 0)
|
||||
/// and UV (plane 1) planes back from a staging copy, and compare against the [`p010_reference`] f64
|
||||
/// math. The ONLY validation we have without green-screening a live HDR stream. PASS if max abs error
|
||||
/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL.
|
||||
///
|
||||
/// `w`/`h` must be even and non-zero. Run it at the FIELD capture size, not a toy one: sessions run
|
||||
/// at resolutions whose height is not 16-aligned (1080 → the encoder's align16 pool seam) and a
|
||||
/// driver may treat the planar RTVs differently at real sizes. `vendor` pins the adapter by PCI
|
||||
/// vendor id (`0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD) — it matters on dual-GPU boxes where
|
||||
/// the default adapter is not the one the session encodes on. The chosen adapter is always printed,
|
||||
/// because a PASS only means anything for the GPU it actually ran on.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
|
||||
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN};
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
|
||||
|
||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
||||
bail!("hdr-p010-selftest needs even non-zero dimensions, got {w}x{h}");
|
||||
}
|
||||
// A grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform → exact chroma
|
||||
// comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus a couple
|
||||
// of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
|
||||
#[allow(non_snake_case)]
|
||||
let (W, H) = (w, h);
|
||||
const BLK: u32 = 16;
|
||||
// (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values.
|
||||
let named: [(&str, f32, f32, f32); 8] = [
|
||||
("red1.0", 1.0, 0.0, 0.0),
|
||||
("green0.5", 0.0, 0.5, 0.0),
|
||||
("blue4.0", 0.0, 0.0, 4.0),
|
||||
("white1.0", 1.0, 1.0, 1.0),
|
||||
("black", 0.0, 0.0, 0.0),
|
||||
("gray0.5", 0.5, 0.5, 0.5),
|
||||
("white4.0", 4.0, 4.0, 4.0),
|
||||
("amber2.0", 2.0, 1.0, 0.0),
|
||||
];
|
||||
|
||||
let grid_cols = W / BLK; // 4
|
||||
let pixel_rgb = |x: u32, y: u32| -> (f32, f32, f32, bool) {
|
||||
let idx = ((y / BLK) * grid_cols + (x / BLK)) as usize;
|
||||
if idx < named.len() {
|
||||
let (_, r, g, b) = named[idx];
|
||||
(r, g, b, true)
|
||||
} else {
|
||||
// Gradient (distinct per pixel; Y-only compare), within HDR scRGB range.
|
||||
let r = (x as f32 / W as f32) * 3.0;
|
||||
let g = (y as f32 / H as f32) * 3.0;
|
||||
let b = ((x + y) as f32 / (W + H) as f32) * 3.0;
|
||||
(r, g, b, false)
|
||||
}
|
||||
};
|
||||
|
||||
// Build the scRGB FP16 (R16G16B16A16_FLOAT) source as f16 bits.
|
||||
let mut fp16 = vec![0u16; (W * H * 4) as usize];
|
||||
let mut flat = vec![false; (W * H) as usize];
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let (r, g, b, is_flat) = pixel_rgb(x, y);
|
||||
let i = ((y * W + x) * 4) as usize;
|
||||
fp16[i] = f32_to_f16(r);
|
||||
fp16[i + 1] = f32_to_f16(g);
|
||||
fp16[i + 2] = f32_to_f16(b);
|
||||
fp16[i + 3] = f32_to_f16(1.0);
|
||||
flat[(y * W + x) as usize] = is_flat;
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: this self-test creates its own D3D11 device + immediate context (`D3D11CreateDevice`,
|
||||
// both checked non-null) and uses ONLY that device for the rest of the block: every
|
||||
// `CreateTexture2D`/`CreateShaderResourceView`/`HdrP010Converter::{new,convert}`/`CopyResource`/
|
||||
// `Map` is invoked on that device or its context, so all resources share one device and run on this
|
||||
// single thread. The source texture's `D3D11_SUBRESOURCE_DATA` points at `fp16`, a live
|
||||
// `Vec<u16>` of `W*H*4` samples with `SysMemPitch = W*8`, matching the W×H R16G16B16A16 texture;
|
||||
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
|
||||
// proven individually at the `read_u16` closure below.
|
||||
unsafe {
|
||||
// Device on the requested vendor's adapter (dual-GPU boxes encode on a specific one), else
|
||||
// the default hardware GPU. Always says which adapter ran — a PASS is only meaningful for
|
||||
// the GPU it actually tested.
|
||||
let adapter: Option<IDXGIAdapter> = match vendor {
|
||||
None => None,
|
||||
Some(want) => {
|
||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().context("dxgi factory")?;
|
||||
let mut found = None;
|
||||
for i in 0.. {
|
||||
let Ok(a) = factory.EnumAdapters(i) else {
|
||||
break;
|
||||
};
|
||||
let desc = a.GetDesc().context("adapter desc")?;
|
||||
if desc.VendorId == want {
|
||||
found = Some(a);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(found.with_context(|| format!("no adapter with vendor id {want:#x}"))?)
|
||||
}
|
||||
};
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
D3D11CreateDevice(
|
||||
adapter.as_ref(),
|
||||
if adapter.is_some() {
|
||||
D3D_DRIVER_TYPE_UNKNOWN
|
||||
} else {
|
||||
D3D_DRIVER_TYPE_HARDWARE
|
||||
},
|
||||
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(hardware) for hdr-p010-selftest")?;
|
||||
let device = device.context("null device")?;
|
||||
let context = context.context("null context")?;
|
||||
{
|
||||
let dxgi: windows::Win32::Graphics::Dxgi::IDXGIDevice =
|
||||
device.cast().context("device -> IDXGIDevice")?;
|
||||
let desc = dxgi.GetAdapter().context("GetAdapter")?.GetDesc()?;
|
||||
let name = String::from_utf16_lossy(
|
||||
&desc.Description[..desc
|
||||
.Description
|
||||
.iter()
|
||||
.position(|&c| c == 0)
|
||||
.unwrap_or(desc.Description.len())],
|
||||
);
|
||||
println!(
|
||||
"adapter: {name} (vendor {:#06x}, luid {:08x}:{:08x})",
|
||||
desc.VendorId, desc.AdapterLuid.HighPart, desc.AdapterLuid.LowPart
|
||||
);
|
||||
}
|
||||
|
||||
// Source FP16 texture (initialized) + SRV.
|
||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: W,
|
||||
Height: H,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let init = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: fp16.as_ptr() as *const c_void,
|
||||
SysMemPitch: W * 8, // 4 channels * 2 bytes
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut src_tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
||||
.context("CreateTexture2D(fp16 src)")?;
|
||||
let src_tex = src_tex.context("null src tex")?;
|
||||
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
||||
.context("CreateShaderResourceView(fp16 src)")?;
|
||||
let src_srv = src_srv.context("null src srv")?;
|
||||
|
||||
// P010 destination texture (render-target bindable).
|
||||
let p010_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: W,
|
||||
Height: H,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_P010,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut p010: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
||||
.context("CreateTexture2D(P010 dst)")?;
|
||||
let p010 = p010.context("null p010 tex")?;
|
||||
|
||||
let conv = HdrP010Converter::new(&device, W, H)?;
|
||||
let y_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16_UNORM)?;
|
||||
let uv_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16G16_UNORM)?;
|
||||
conv.convert(&context, &src_srv, &y_rtv, &uv_rtv, W, H)?;
|
||||
|
||||
// Staging copy of the whole P010 texture (both planes), MAP_READ.
|
||||
let stage_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: W,
|
||||
Height: H,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_P010,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_STAGING,
|
||||
BindFlags: 0,
|
||||
CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut staging: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&stage_desc, None, Some(&mut staging))
|
||||
.context("CreateTexture2D(P010 staging)")?;
|
||||
let staging = staging.context("null staging")?;
|
||||
context.CopyResource(&staging, &p010);
|
||||
|
||||
let mut map = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
context
|
||||
.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut map))
|
||||
.context("Map(P010 staging)")?;
|
||||
let row_pitch = map.RowPitch as usize; // bytes per luma row (in 16-bit samples: /2)
|
||||
let base = map.pData as *const u8;
|
||||
// DIAGNOSTIC (the uncertain layout spot — verify on the box if chroma is wrong): the mapped
|
||||
// P010 plane offsets. Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2
|
||||
// *interleaved* (Cb,Cr) u16 pairs. P010 packs plane 1 after plane 0 at the SAME row pitch; the
|
||||
// chroma plane begins at byte offset RowPitch * (luma height). For a STAGING texture that
|
||||
// height is the created H (no inter-plane alignment). DepthPitch (total mapped size) lets us
|
||||
// sanity-check: it should be ~ RowPitch * H * 3/2. If chroma reads garbage on the box, print
|
||||
// these and adjust `chroma_base` (e.g. an aligned luma height).
|
||||
tracing::info!(
|
||||
row_pitch,
|
||||
depth_pitch = map.DepthPitch,
|
||||
expected_chroma_base = row_pitch * H as usize,
|
||||
expected_total = row_pitch * H as usize * 3 / 2,
|
||||
"hdr-p010-selftest: mapped P010 layout (verify chroma plane offset here if chroma is wrong)"
|
||||
);
|
||||
// Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2 *interleaved* (Cb,Cr)
|
||||
// u16 pairs, i.e. W u16 per chroma row. P010 packs plane 1 immediately after plane 0 at the
|
||||
// SAME row pitch; per spec the chroma plane begins at an allocation offset of
|
||||
// RowPitch * Height (luma rows). We read it from there. (DepthPitch is the full surface size;
|
||||
// not all drivers report the chroma offset, so RowPitch*Height is the portable choice.)
|
||||
let read_u16 = |byte_off: usize| -> u16 {
|
||||
// SAFETY: `base` is the mapped staging pointer; all offsets are within the P010 surface
|
||||
// (luma H*RowPitch + chroma (H/2)*RowPitch ≤ DepthPitch). Already in the fn's unsafe scope.
|
||||
let p = base.add(byte_off) as *const u16;
|
||||
p.read_unaligned()
|
||||
};
|
||||
// Luma codes: stored u16 in the high 10 bits -> code10 = stored >> 6.
|
||||
let mut y_codes = vec![0u16; (W * H) as usize];
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let off = (y as usize) * row_pitch + (x as usize) * 2;
|
||||
y_codes[(y * W + x) as usize] = read_u16(off) >> 6;
|
||||
}
|
||||
}
|
||||
let cw = W / 2;
|
||||
let ch = H / 2;
|
||||
let chroma_base = row_pitch * H as usize; // plane 1 offset
|
||||
let mut cb_codes = vec![0u16; (cw * ch) as usize];
|
||||
let mut cr_codes = vec![0u16; (cw * ch) as usize];
|
||||
for cy in 0..ch {
|
||||
for cx in 0..cw {
|
||||
// Interleaved (Cb, Cr) per chroma sample → 2 u16 = 4 bytes per sample.
|
||||
let off = chroma_base + (cy as usize) * row_pitch + (cx as usize) * 4;
|
||||
cb_codes[(cy * cw + cx) as usize] = read_u16(off) >> 6;
|
||||
cr_codes[(cy * cw + cx) as usize] = read_u16(off + 2) >> 6;
|
||||
}
|
||||
}
|
||||
context.Unmap(&staging, 0);
|
||||
|
||||
// Compare Y over every pixel.
|
||||
let mut max_y_err = 0.0f64;
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let (r, g, b, _) = pixel_rgb(x, y);
|
||||
let (ry, _, _) = p010_reference(r as f64, g as f64, b as f64);
|
||||
let got = y_codes[(y * W + x) as usize] as f64;
|
||||
max_y_err = max_y_err.max((got - ry).abs());
|
||||
}
|
||||
}
|
||||
// Compare Cb/Cr over flat blocks only (uniform 2x2 footprint → exact reference).
|
||||
let mut max_u_err = 0.0f64;
|
||||
let mut max_v_err = 0.0f64;
|
||||
for cy in 0..ch {
|
||||
for cx in 0..cw {
|
||||
let (sx, sy) = (cx * 2, cy * 2);
|
||||
let all_flat =
|
||||
(0..2).all(|dy| (0..2).all(|dx| flat[((sy + dy) * W + (sx + dx)) as usize]));
|
||||
if !all_flat {
|
||||
continue;
|
||||
}
|
||||
let (r, g, b, _) = pixel_rgb(sx, sy);
|
||||
let (_, rcb, rcr) = p010_reference(r as f64, g as f64, b as f64);
|
||||
let gu = cb_codes[(cy * cw + cx) as usize] as f64;
|
||||
let gv = cr_codes[(cy * cw + cx) as usize] as f64;
|
||||
max_u_err = max_u_err.max((gu - rcb).abs());
|
||||
max_v_err = max_v_err.max((gv - rcr).abs());
|
||||
}
|
||||
}
|
||||
|
||||
// Per-colour table.
|
||||
println!("HDR P010 self-test ({W}x{H}, BT.2020 PQ, 10-bit limited range)");
|
||||
println!(
|
||||
" {:<10} {:>14} {:>14} {:>14}",
|
||||
"color", "Y exp/got", "Cb exp/got", "Cr exp/got"
|
||||
);
|
||||
for (idx, (name, r, g, b)) in named.iter().enumerate() {
|
||||
let bx = (idx as u32 % grid_cols) * BLK + BLK / 2;
|
||||
let by = (idx as u32 / grid_cols) * BLK + BLK / 2;
|
||||
let (ey, ecb, ecr) = p010_reference(*r as f64, *g as f64, *b as f64);
|
||||
let gy = y_codes[(by * W + bx) as usize] as f64;
|
||||
let (ccx, ccy) = (bx / 2, by / 2);
|
||||
let gu = cb_codes[(ccy * cw + ccx) as usize] as f64;
|
||||
let gv = cr_codes[(ccy * cw + ccx) as usize] as f64;
|
||||
println!(
|
||||
" {:<10} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0}",
|
||||
name, ey, gy, ecb, gu, ecr, gv
|
||||
);
|
||||
}
|
||||
println!(
|
||||
" max abs error: Y={max_y_err:.2} (≤4) Cb={max_u_err:.2} (≤5) Cr={max_v_err:.2} (≤5)"
|
||||
);
|
||||
|
||||
if max_y_err <= 4.0 && max_u_err <= 5.0 && max_v_err <= 5.0 {
|
||||
println!("PASS");
|
||||
Ok(())
|
||||
} else {
|
||||
println!("FAIL");
|
||||
bail!(
|
||||
"HDR P010 self-test FAILED (Y={max_y_err:.2} Cb={max_u_err:.2} Cr={max_v_err:.2})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal f32 → IEEE-754 half (f16) bit pattern, for uploading the FP16 scRGB self-test pattern. Not
|
||||
/// on any hot path; handles normals, subnormals, and the 1.0/0.0 constants we feed. (round-to-nearest)
|
||||
#[cfg(target_os = "windows")]
|
||||
fn f32_to_f16(v: f32) -> u16 {
|
||||
let bits = v.to_bits();
|
||||
let sign = ((bits >> 16) & 0x8000) as u16;
|
||||
let exp = ((bits >> 23) & 0xff) as i32 - 127 + 15;
|
||||
let mant = bits & 0x007f_ffff;
|
||||
if exp <= 0 {
|
||||
// Subnormal / zero in half precision.
|
||||
if exp < -10 {
|
||||
return sign; // too small → ±0
|
||||
}
|
||||
let mant = mant | 0x0080_0000; // implicit 1
|
||||
let shift = (14 - exp) as u32;
|
||||
let half_mant = (mant >> shift) as u16;
|
||||
// Round to nearest.
|
||||
let round = ((mant >> (shift - 1)) & 1) as u16;
|
||||
sign | (half_mant + round)
|
||||
} else if exp >= 0x1f {
|
||||
sign | 0x7c00 // Inf/NaN → Inf (our inputs never hit this)
|
||||
} else {
|
||||
let half_exp = (exp as u16) << 10;
|
||||
let half_mant = (mant >> 13) as u16;
|
||||
let round = ((mant >> 12) & 1) as u16;
|
||||
// ADD, never OR. `half_mant + round` can carry out of the 10-bit mantissa (all ones, then
|
||||
// rounded up), and that carry must INCREMENT the exponent — which is exactly what an
|
||||
// IEEE-754 round-to-nearest overflow means. `sign | half_exp | (…)` instead ORed it into bit
|
||||
// 10, so for every ODD biased exponent (bit 10 already set) the carry vanished and the
|
||||
// result came back a factor of ~2 low: `f32_to_f16(1.9998779) → 0x3C00 = 1.0`,
|
||||
// `0.49996948 → 0.25`. Only values one ULP below a power of two are affected — which is
|
||||
// precisely what a gradient test pattern is full of, so this made `hdr-p010-selftest` FAIL a
|
||||
// correct shader. The subnormal branch above was already additive.
|
||||
sign | (half_exp + half_mant + round)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod f16_tests {
|
||||
use super::f32_to_f16;
|
||||
|
||||
/// Round-trip through the reference conversion the rest of the test uses as an oracle.
|
||||
fn f16_to_f32(h: u16) -> f32 {
|
||||
let sign = if h & 0x8000 != 0 { -1.0f32 } else { 1.0 };
|
||||
let exp = ((h >> 10) & 0x1f) as i32;
|
||||
let mant = (h & 0x3ff) as f32;
|
||||
match exp {
|
||||
0 => sign * mant * 2f32.powi(-24), // subnormal
|
||||
31 => sign * f32::INFINITY, // our encoder never emits NaN
|
||||
e => sign * (1.0 + mant / 1024.0) * 2f32.powi(e - 15),
|
||||
}
|
||||
}
|
||||
|
||||
/// W7: the rounding carry out of the mantissa must INCREMENT the exponent. The composition used
|
||||
/// `sign | half_exp | (half_mant + round)`, which swallowed that carry for every odd biased
|
||||
/// exponent — a silent factor-of-2 error on exactly the values a gradient test pattern is full
|
||||
/// of, which made `hdr-p010-selftest` fail a correct shader.
|
||||
#[test]
|
||||
fn a_rounding_carry_increments_the_exponent() {
|
||||
// The plan's canonical case: biased exponent 127 (2^0) with a mantissa that rounds up out
|
||||
// of 10 bits ⇒ 2.0 = 0x4000, NOT 1.0 = 0x3C00.
|
||||
assert_eq!(f32_to_f16(f32::from_bits((127 << 23) | 0x7FF000)), 0x4000);
|
||||
// The two measured regressions, by value.
|
||||
assert_eq!(
|
||||
f32_to_f16(1.9998779),
|
||||
0x4000,
|
||||
"1.9998779 must not read as 1.0"
|
||||
);
|
||||
assert_eq!(
|
||||
f32_to_f16(0.49996948),
|
||||
0x3800,
|
||||
"0.49996948 must not read as 0.25"
|
||||
);
|
||||
// …and an EVEN biased exponent, where the bug happened to be invisible (bit 10 clear), so
|
||||
// the fix must not change it.
|
||||
assert_eq!(f32_to_f16(f32::from_bits((128 << 23) | 0x7FF000)), 0x4400); // → 4.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_constants_the_selftest_uploads_are_exact() {
|
||||
assert_eq!(f32_to_f16(0.0), 0x0000);
|
||||
assert_eq!(f32_to_f16(-0.0), 0x8000);
|
||||
assert_eq!(f32_to_f16(1.0), 0x3C00);
|
||||
assert_eq!(f32_to_f16(-1.0), 0xBC00);
|
||||
assert_eq!(f32_to_f16(0.5), 0x3800);
|
||||
assert_eq!(f32_to_f16(2.0), 0x4000);
|
||||
assert_eq!(f32_to_f16(4.0), 0x4400);
|
||||
}
|
||||
|
||||
/// Every HDR scRGB value the self-test patterns use must survive the round trip to within one
|
||||
/// f16 ULP — the property the P010 comparison actually depends on.
|
||||
#[test]
|
||||
fn hdr_scrgb_values_round_trip_within_one_ulp() {
|
||||
for &v in &[
|
||||
0.0f32, 0.25, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 0.1, 0.3, 0.7, 1.9998779, 0.49996948, 2.5,
|
||||
3.999, 0.001,
|
||||
] {
|
||||
let back = f16_to_f32(f32_to_f16(v));
|
||||
// One ULP at this magnitude: f16 carries 11 significand bits.
|
||||
let ulp = (v.abs() / 1024.0).max(2f32.powi(-24));
|
||||
assert!(
|
||||
(back - v).abs() <= ulp,
|
||||
"{v} round-tripped to {back} (ulp {ulp})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_magnitudes_saturate_rather_than_wrap() {
|
||||
// Above f16's max finite (65504) our encoder reports Inf; below its subnormal floor, ±0.
|
||||
assert_eq!(f32_to_f16(1.0e30), 0x7C00);
|
||||
assert_eq!(f32_to_f16(-1.0e30), 0xFC00);
|
||||
assert_eq!(f32_to_f16(1.0e-30), 0x0000);
|
||||
assert_eq!(f32_to_f16(-1.0e-30), 0x8000);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hdr_selftests {
|
||||
/// LIVE (needs the GPU): [`super::hdr_p010_selftest_at`] at the field capture size — 1080 is
|
||||
/// NOT 16-aligned, and the planar-RTV write path is driver-specific per vendor. Pinned to the
|
||||
/// Intel adapter (`0x8086`), so it runs on the Intel validation boxes and errors out cleanly
|
||||
/// ("no adapter") elsewhere. `cargo test -p pf-capture -- --ignored hdr_p010 --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn hdr_p010_selftest_intel_1080_live() {
|
||||
super::hdr_p010_selftest_at(1920, 1080, Some(0x8086)).expect("hdr p010 selftest @1080");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
//! The LAST-RESORT DWM compose kick — synthetic pointer input that dirties a specific virtual
|
||||
//! display so DWM presents it.
|
||||
//!
|
||||
//! Split out of `idd_push.rs` in sweep Phase 5.4. It is self-contained (one function plus a
|
||||
//! process-global throttle) and it is the one piece of the capture path that reaches for synthetic
|
||||
//! INPUT, which is worth keeping visibly separate from the frame machinery: it is unreliable by
|
||||
//! nature, user-visible in the sibling-display case, and only ever a fallback for the driver's own
|
||||
//! `FrameStash` republish.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// LAST-RESORT fallback: nudge DWM into composing THE TARGET virtual display. DWM presents a
|
||||
/// display only when something DIRTIES it — an idle desktop never does, so a freshly-attached ring
|
||||
/// (session open, or a mid-session ring recreate) can sit at E_PENDING with no first frame even
|
||||
/// though everything is healthy.
|
||||
///
|
||||
/// The PRIMARY first-frame mechanism is the driver's `FrameStash` (frame_transport.rs): the driver
|
||||
/// retains the last composed frame and republishes it into every freshly-attached ring, so with a
|
||||
/// stash-capable driver the first frame lands milliseconds after the channel delivery and this kick
|
||||
/// never fires. It remains for pre-stash drivers and for the empty-stash cold start (a monitor that
|
||||
/// has NEVER composed — normally the activation compose covers that). Synthetic input is inherently
|
||||
/// unreliable — blocked on the secure desktop, defeated by a fullscreen game's ClipCursor, and
|
||||
/// user-visible in the sibling-display case — which is exactly why it was demoted to fallback.
|
||||
///
|
||||
/// pf-vdisplay implements no hardware-cursor plane, so a cursor move is composited into
|
||||
/// the frame — a guaranteed real present onto the IDD swap-chain (empirically what
|
||||
/// `punktfunk-probe --input-test` always relied on).
|
||||
///
|
||||
/// The cursor only dirties the display it is ON — proven on-glass in the Stage-W3 two-display
|
||||
/// validation: display B's session-open kicks wiggled the cursor on display A and B never composed
|
||||
/// a first frame. So the kick is per-TARGET: when the cursor already sits inside `target_id`'s
|
||||
/// desktop region (always true single-display), two net-zero 1 px relative moves (the historical
|
||||
/// behavior, pointer ends exactly where it started); when it sits on a SIBLING display, jump the
|
||||
/// cursor to the target's center and straight back (`SetCursorPos` ×2 — each absolute move dirties
|
||||
/// the cursor layer of the display it lands on, so the target composes at least one frame).
|
||||
/// Best-effort — injection can be unavailable on the secure desktop, where a fresh compose just
|
||||
/// happened anyway.
|
||||
///
|
||||
/// **COST:** the sibling-display branch SLEEPS 35 ms on the calling thread between the two
|
||||
/// `SetCursorPos`es. The dwell is load-bearing (see the comment at that branch: a sub-tick
|
||||
/// jump-and-return never dirties anything), but the caller is the capture/encode thread, so a kick
|
||||
/// on that branch costs ~2 frames of latency at 60 Hz. Every call site is a first-frame or
|
||||
/// post-recreate recovery window where no frames are flowing anyway, and the global 50 ms throttle
|
||||
/// plus the callers' own 600–800 ms schedules bound how often it can happen.
|
||||
///
|
||||
/// **HID-first**: when the host has registered [`HID_COMPOSE_KICK`] (the resident pf-mouse virtual
|
||||
/// HID pointer), the kick goes through it INSTEAD of the `SendInput` paths below. A report from a
|
||||
/// HID device is real input to win32k — delivered regardless of this process's session or the
|
||||
/// active desktop, it wakes a powered-off display subsystem (lid-closed laptop / display idle-off /
|
||||
/// modern standby) and counts as user presence — every condition under which `SendInput` is
|
||||
/// silently impotent (wrong session → wrong input queue; secure desktop → blocked; display off →
|
||||
/// nothing composes at all). That set is exactly the lid-closed field-report state.
|
||||
pub(super) fn kick_dwm_compose(target_id: u32) {
|
||||
// Process-GLOBAL throttle (Stage W3): with N parallel capturers each nudging on its own
|
||||
// schedule, DWM needs only one dirty per composition window — and the nudge is synthetic INPUT
|
||||
// (global, user-visible pointer state), so it must not multiply with capturer count. 50 ms
|
||||
// covers every composition interval we ship (≥ 60 Hz) while staying far under the callers' own
|
||||
// 600–800 ms per-capturer schedules.
|
||||
static LAST_KICK: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
{
|
||||
let mut last = LAST_KICK.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
if last.is_some_and(|t| now.duration_since(t) < Duration::from_millis(50)) {
|
||||
return;
|
||||
}
|
||||
*last = Some(now);
|
||||
}
|
||||
// Where is the cursor, and where does the target display live in desktop space?
|
||||
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();
|
||||
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) {
|
||||
let bounds = pf_win_display::win_display::desktop_bounds();
|
||||
if let Some(bounds) = bounds {
|
||||
if kick(rect, bounds) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (true, Some((x, y, w, h))) = (have_pos, rect) {
|
||||
let inside = pos.x >= x && pos.x < x + w.max(1) && pos.y >= y && pos.y < y + h.max(1);
|
||||
if !inside {
|
||||
// The cursor is on a sibling display — a wiggle there dirties the WRONG display. Jump
|
||||
// to the target's center, DWELL one composition interval, then restore. The dwell is
|
||||
// load-bearing (proven on-glass, Stage W3): DWM computes dirty state from the CURRENT
|
||||
// cursor position at the next vsync tick, so a sub-tick jump-and-return is invisible
|
||||
// and the target never composes — 35 ms covers a 30 Hz tick with margin. The cursor
|
||||
// visibly leaves the sibling display for those ~2 frames; kicks only fire during THIS
|
||||
// display's session-open / recovery windows (throttled), so the blip is rare and brief.
|
||||
// SAFETY: plain FFI; coordinates are plain ints, and the second call restores the
|
||||
// observed original position.
|
||||
unsafe {
|
||||
let _ = SetCursorPos(x + w / 2, y + h / 2);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(35));
|
||||
// SAFETY: as above.
|
||||
unsafe {
|
||||
let _ = SetCursorPos(pos.x, pos.y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mk = |dx: i32| INPUT {
|
||||
r#type: INPUT_MOUSE,
|
||||
Anonymous: INPUT_0 {
|
||||
mi: MOUSEINPUT {
|
||||
dx,
|
||||
dy: 0,
|
||||
mouseData: 0,
|
||||
dwFlags: MOUSEEVENTF_MOVE,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
// SAFETY: plain FFI; the input slice is valid, fully-initialized local data for this synchronous
|
||||
// call, and `cbsize` is the true element size.
|
||||
unsafe {
|
||||
let _ = SendInput(&[mk(1), mk(-1)], std::mem::size_of::<INPUT>() as i32);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -56,102 +56,109 @@ pub(super) struct CursorBlendPass {
|
||||
}
|
||||
|
||||
impl CursorBlendPass {
|
||||
pub(super) unsafe fn new(device: &ID3D11Device) -> Result<Self> {
|
||||
let vsb = crate::dxgi::compile_shader(crate::dxgi::HDR_VS, s!("main"), s!("vs_5_0"))?;
|
||||
let psb = crate::dxgi::compile_shader(CURSOR_PS, s!("main"), s!("ps_5_0"))?;
|
||||
let mut vs = None;
|
||||
device.CreateVertexShader(&vsb, None, Some(&mut vs))?;
|
||||
let mut ps = None;
|
||||
device.CreatePixelShader(&psb, None, Some(&mut ps))?;
|
||||
let sd = D3D11_SAMPLER_DESC {
|
||||
// LINEAR: the quad is drawn 1:1 in frame pixels, so this only matters at the
|
||||
// half-texel edges; linear keeps them soft instead of ringing.
|
||||
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
|
||||
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
ComparisonFunc: D3D11_COMPARISON_NEVER,
|
||||
MaxLOD: f32::MAX,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sampler = None;
|
||||
device.CreateSamplerState(&sd, Some(&mut sampler))?;
|
||||
// Straight-alpha over: dst.rgb = src.rgb*a + dst.rgb*(1-a); keep dst alpha.
|
||||
let mut bd = D3D11_BLEND_DESC::default();
|
||||
bd.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC {
|
||||
BlendEnable: true.into(),
|
||||
SrcBlend: D3D11_BLEND_SRC_ALPHA,
|
||||
DestBlend: D3D11_BLEND_INV_SRC_ALPHA,
|
||||
BlendOp: D3D11_BLEND_OP_ADD,
|
||||
SrcBlendAlpha: D3D11_BLEND_ONE,
|
||||
DestBlendAlpha: D3D11_BLEND_ONE,
|
||||
BlendOpAlpha: D3D11_BLEND_OP_ADD,
|
||||
RenderTargetWriteMask: 0x0F,
|
||||
};
|
||||
let mut blend = None;
|
||||
device.CreateBlendState(&bd, Some(&mut blend))?;
|
||||
let cbd = D3D11_BUFFER_DESC {
|
||||
ByteWidth: 16, // float to_linear + float3 pad
|
||||
Usage: D3D11_USAGE_DYNAMIC,
|
||||
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
|
||||
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut cbuf = None;
|
||||
device.CreateBuffer(&cbd, None, Some(&mut cbuf))?;
|
||||
Ok(Self {
|
||||
vs: vs.context("cursor blend vs")?,
|
||||
ps: ps.context("cursor blend ps")?,
|
||||
sampler: sampler.context("cursor blend sampler")?,
|
||||
blend: blend.context("cursor blend state")?,
|
||||
cbuf: cbuf.context("cursor blend cbuf")?,
|
||||
cbuf_scale: None,
|
||||
shape: None,
|
||||
})
|
||||
pub(super) fn new(device: &ID3D11Device) -> Result<Self> {
|
||||
// SAFETY: `?`-checked D3D11 resource creation on the live `device` borrow, over
|
||||
// fully-initialized stack descriptors and live out-params; `compile_shader` receives `s!()`
|
||||
// literals (its contract).
|
||||
unsafe {
|
||||
let vsb = crate::dxgi::compile_shader(crate::dxgi::HDR_VS, s!("main"), s!("vs_5_0"))?;
|
||||
let psb = crate::dxgi::compile_shader(CURSOR_PS, s!("main"), s!("ps_5_0"))?;
|
||||
let mut vs = None;
|
||||
device.CreateVertexShader(&vsb, None, Some(&mut vs))?;
|
||||
let mut ps = None;
|
||||
device.CreatePixelShader(&psb, None, Some(&mut ps))?;
|
||||
let sd = D3D11_SAMPLER_DESC {
|
||||
// LINEAR: the quad is drawn 1:1 in frame pixels, so this only matters at the
|
||||
// half-texel edges; linear keeps them soft instead of ringing.
|
||||
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
|
||||
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||
ComparisonFunc: D3D11_COMPARISON_NEVER,
|
||||
MaxLOD: f32::MAX,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sampler = None;
|
||||
device.CreateSamplerState(&sd, Some(&mut sampler))?;
|
||||
// Straight-alpha over: dst.rgb = src.rgb*a + dst.rgb*(1-a); keep dst alpha.
|
||||
let mut bd = D3D11_BLEND_DESC::default();
|
||||
bd.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC {
|
||||
BlendEnable: true.into(),
|
||||
SrcBlend: D3D11_BLEND_SRC_ALPHA,
|
||||
DestBlend: D3D11_BLEND_INV_SRC_ALPHA,
|
||||
BlendOp: D3D11_BLEND_OP_ADD,
|
||||
SrcBlendAlpha: D3D11_BLEND_ONE,
|
||||
DestBlendAlpha: D3D11_BLEND_ONE,
|
||||
BlendOpAlpha: D3D11_BLEND_OP_ADD,
|
||||
RenderTargetWriteMask: 0x0F,
|
||||
};
|
||||
let mut blend = None;
|
||||
device.CreateBlendState(&bd, Some(&mut blend))?;
|
||||
let cbd = D3D11_BUFFER_DESC {
|
||||
ByteWidth: 16, // float to_linear + float3 pad
|
||||
Usage: D3D11_USAGE_DYNAMIC,
|
||||
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
|
||||
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut cbuf = None;
|
||||
device.CreateBuffer(&cbd, None, Some(&mut cbuf))?;
|
||||
Ok(Self {
|
||||
vs: vs.context("cursor blend vs")?,
|
||||
ps: ps.context("cursor blend ps")?,
|
||||
sampler: sampler.context("cursor blend sampler")?,
|
||||
blend: blend.context("cursor blend state")?,
|
||||
cbuf: cbuf.context("cursor blend cbuf")?,
|
||||
cbuf_scale: None,
|
||||
shape: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload `ov`'s bitmap if its serial is new; reuse the cached SRV otherwise.
|
||||
unsafe fn ensure_shape(
|
||||
&mut self,
|
||||
device: &ID3D11Device,
|
||||
ov: &pf_frame::CursorOverlay,
|
||||
) -> Result<()> {
|
||||
if self.shape.as_ref().is_some_and(|(s, ..)| *s == ov.serial) {
|
||||
return Ok(());
|
||||
fn ensure_shape(&mut self, device: &ID3D11Device, ov: &pf_frame::CursorOverlay) -> Result<()> {
|
||||
// SAFETY: `CreateTexture2D`/`CreateShaderResourceView` are `?`-checked calls on the live
|
||||
// `device` borrow. `init.pSysMem` points into `ov.rgba`, which the length check above proves
|
||||
// holds at least `ov.w * ov.h * 4` bytes for the declared `SysMemPitch = ov.w * 4`, and which
|
||||
// outlives the synchronous upload.
|
||||
unsafe {
|
||||
if self.shape.as_ref().is_some_and(|(s, ..)| *s == ov.serial) {
|
||||
return Ok(());
|
||||
}
|
||||
if ov.rgba.len() < (ov.w as usize) * (ov.h as usize) * 4 || ov.w == 0 || ov.h == 0 {
|
||||
bail!("malformed cursor overlay ({}x{})", ov.w, ov.h);
|
||||
}
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: ov.w,
|
||||
Height: ov.h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_R8G8B8A8_UNORM,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let init = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: ov.rgba.as_ptr().cast(),
|
||||
SysMemPitch: ov.w * 4,
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&desc, Some(&init), Some(&mut tex))
|
||||
.context("CreateTexture2D(cursor shape)")?;
|
||||
let tex = tex.context("null cursor shape texture")?;
|
||||
let mut srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
.CreateShaderResourceView(&tex, None, Some(&mut srv))
|
||||
.context("CreateShaderResourceView(cursor shape)")?;
|
||||
self.shape = Some((ov.serial, srv.context("null cursor shape srv")?, ov.w, ov.h));
|
||||
Ok(())
|
||||
}
|
||||
if ov.rgba.len() < (ov.w as usize) * (ov.h as usize) * 4 || ov.w == 0 || ov.h == 0 {
|
||||
bail!("malformed cursor overlay ({}x{})", ov.w, ov.h);
|
||||
}
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: ov.w,
|
||||
Height: ov.h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_R8G8B8A8_UNORM,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let init = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: ov.rgba.as_ptr().cast(),
|
||||
SysMemPitch: ov.w * 4,
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&desc, Some(&init), Some(&mut tex))
|
||||
.context("CreateTexture2D(cursor shape)")?;
|
||||
let tex = tex.context("null cursor shape texture")?;
|
||||
let mut srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
.CreateShaderResourceView(&tex, None, Some(&mut srv))
|
||||
.context("CreateShaderResourceView(cursor shape)")?;
|
||||
self.shape = Some((ov.serial, srv.context("null cursor shape srv")?, ov.w, ov.h));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Alpha-blend `ov` onto `dst` (frame-sized, RENDER_TARGET-capable — the blend scratch).
|
||||
@@ -159,7 +166,7 @@ impl CursorBlendPass {
|
||||
/// composition) — linearize and scale to the target's SDR white. The quad is placed purely
|
||||
/// via the viewport (the fullscreen-triangle VS fills whatever viewport is set), clipped by
|
||||
/// the target automatically.
|
||||
pub(super) unsafe fn blend(
|
||||
pub(super) fn blend(
|
||||
&mut self,
|
||||
device: &ID3D11Device,
|
||||
ctx: &ID3D11DeviceContext,
|
||||
@@ -167,50 +174,63 @@ impl CursorBlendPass {
|
||||
ov: &pf_frame::CursorOverlay,
|
||||
linear_scale: f32,
|
||||
) -> Result<()> {
|
||||
self.ensure_shape(device, ov)?;
|
||||
let (_, srv, w, h) = self.shape.as_ref().expect("shape just ensured");
|
||||
if self.cbuf_scale != Some(linear_scale) {
|
||||
let cb: [f32; 4] = [linear_scale, 0.0, 0.0, 0.0];
|
||||
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
if ctx
|
||||
.Map(&self.cbuf, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))
|
||||
.is_ok()
|
||||
{
|
||||
std::ptr::copy_nonoverlapping(cb.as_ptr(), mapped.pData as *mut f32, cb.len());
|
||||
ctx.Unmap(&self.cbuf, 0);
|
||||
// SAFETY: all D3D11 work on the caller's live `device`/`ctx` borrows. The
|
||||
// `copy_nonoverlapping` writes `cb.len()` `f32`s into the pointer the immediately preceding
|
||||
// `Map` of `self.cbuf` (16 bytes = 4×`f32`, DYNAMIC/WRITE_DISCARD) returned, inside the
|
||||
// `is_ok()` arm and before the paired `Unmap`. `ensure_shape` forwards this fn's `device`
|
||||
// borrow, and every `*Set*`/`Draw` takes borrowed slices of live locals or clones of live COM
|
||||
// interfaces.
|
||||
unsafe {
|
||||
self.ensure_shape(device, ov)?;
|
||||
let (_, srv, w, h) = self.shape.as_ref().expect("shape just ensured");
|
||||
if self.cbuf_scale != Some(linear_scale) {
|
||||
let cb: [f32; 4] = [linear_scale, 0.0, 0.0, 0.0];
|
||||
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||
if ctx
|
||||
.Map(&self.cbuf, 0, D3D11_MAP_WRITE_DISCARD, 0, Some(&mut mapped))
|
||||
.is_ok()
|
||||
{
|
||||
std::ptr::copy_nonoverlapping(cb.as_ptr(), mapped.pData as *mut f32, cb.len());
|
||||
ctx.Unmap(&self.cbuf, 0);
|
||||
// Cache ONLY on a successful upload. Caching unconditionally meant one transient
|
||||
// `Map` failure wedged the HDR/SDR cursor scale for the rest of the session: the
|
||||
// buffer still held the OLD value while this believed it held the new one, and
|
||||
// no later call would retry. On failure the cache is left alone and the next
|
||||
// blend tries again — a stale scale for a frame instead of forever.
|
||||
self.cbuf_scale = Some(linear_scale);
|
||||
}
|
||||
}
|
||||
self.cbuf_scale = Some(linear_scale);
|
||||
}
|
||||
let mut rtv: Option<ID3D11RenderTargetView> = None;
|
||||
device
|
||||
.CreateRenderTargetView(dst, None, Some(&mut rtv))
|
||||
.context("CreateRenderTargetView(cursor blend scratch)")?;
|
||||
let rtv = rtv.context("null cursor blend rtv")?;
|
||||
let mut rtv: Option<ID3D11RenderTargetView> = None;
|
||||
device
|
||||
.CreateRenderTargetView(dst, None, Some(&mut rtv))
|
||||
.context("CreateRenderTargetView(cursor blend scratch)")?;
|
||||
let rtv = rtv.context("null cursor blend rtv")?;
|
||||
|
||||
ctx.OMSetRenderTargets(Some(&[Some(rtv)]), None);
|
||||
ctx.OMSetBlendState(&self.blend, None, 0xffff_ffff);
|
||||
ctx.VSSetShader(&self.vs, None);
|
||||
ctx.PSSetShader(&self.ps, None);
|
||||
ctx.PSSetShaderResources(0, Some(&[Some(srv.clone())]));
|
||||
ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
|
||||
ctx.PSSetConstantBuffers(0, Some(&[Some(self.cbuf.clone())]));
|
||||
ctx.IASetInputLayout(None);
|
||||
ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
|
||||
// Placement IS the viewport: the VS fills it, the OS clips it to the target.
|
||||
let vp = D3D11_VIEWPORT {
|
||||
TopLeftX: ov.x as f32,
|
||||
TopLeftY: ov.y as f32,
|
||||
Width: *w as f32,
|
||||
Height: *h as f32,
|
||||
MinDepth: 0.0,
|
||||
MaxDepth: 1.0,
|
||||
};
|
||||
ctx.RSSetViewports(Some(&[vp]));
|
||||
ctx.Draw(3, 0);
|
||||
// Unbind so the scratch can be bound as a conversion INPUT without a hazard warning.
|
||||
ctx.OMSetRenderTargets(None, None);
|
||||
let none_srv: [Option<ID3D11ShaderResourceView>; 1] = [None];
|
||||
ctx.PSSetShaderResources(0, Some(&none_srv));
|
||||
Ok(())
|
||||
ctx.OMSetRenderTargets(Some(&[Some(rtv)]), None);
|
||||
ctx.OMSetBlendState(&self.blend, None, 0xffff_ffff);
|
||||
ctx.VSSetShader(&self.vs, None);
|
||||
ctx.PSSetShader(&self.ps, None);
|
||||
ctx.PSSetShaderResources(0, Some(&[Some(srv.clone())]));
|
||||
ctx.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
|
||||
ctx.PSSetConstantBuffers(0, Some(&[Some(self.cbuf.clone())]));
|
||||
ctx.IASetInputLayout(None);
|
||||
ctx.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
|
||||
// Placement IS the viewport: the VS fills it, the OS clips it to the target.
|
||||
let vp = D3D11_VIEWPORT {
|
||||
TopLeftX: ov.x as f32,
|
||||
TopLeftY: ov.y as f32,
|
||||
Width: *w as f32,
|
||||
Height: *h as f32,
|
||||
MinDepth: 0.0,
|
||||
MaxDepth: 1.0,
|
||||
};
|
||||
ctx.RSSetViewports(Some(&[vp]));
|
||||
ctx.Draw(3, 0);
|
||||
// Unbind so the scratch can be bound as a conversion INPUT without a hazard warning.
|
||||
ctx.OMSetRenderTargets(None, None);
|
||||
let none_srv: [Option<ID3D11ShaderResourceView>; 1] = [None];
|
||||
ctx.PSSetShaderResources(0, Some(&none_srv));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,11 +85,23 @@ impl CursorPoller {
|
||||
/// stand-down) — a 2 s freeze at every UAC prompt is user-visible, ~4 `OpenInputDesktop`
|
||||
/// syscalls/s are not.
|
||||
const REATTACH: Duration = Duration::from_millis(250);
|
||||
/// Cadence of the same-handle extent re-probe (see the [`run`] loop). Display-scale changes are
|
||||
/// human/OS-timescale events and the probe reads dimensions only — no pixel copy — so 4 Hz is
|
||||
/// both ample and negligible, and a ≤250 ms lag on the pointer's size is imperceptible.
|
||||
const EXTENT_PROBE: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Spawn the poller for the virtual display `target_id`. `rect` = the target's desktop rect
|
||||
/// Spawn the poller for the virtual display `target_id`. `rect` SEEDS the target's desktop rect
|
||||
/// (`source_desktop_rect` order: x, y, w, h) — cursor positions are desktop-global; the
|
||||
/// overlay wants frame-relative, and a pointer outside the rect reports `visible: false`
|
||||
/// (per-output semantics, matching the driver shm path and the Linux portal).
|
||||
///
|
||||
/// A SEED, not the value: the poll thread re-queries the rect on its [`Self::REATTACH`] cadence.
|
||||
/// It used to be captured once here and used forever for BOTH the desktop→frame offset and the
|
||||
/// `in_rect` test, while both mid-session mode-change paths (`resize_output` and
|
||||
/// `poll_display_hdr` → `recreate_ring`) keep the same poller — so after an in-place resize the
|
||||
/// pointer was clipped to the OLD rect and offset by a stale origin. Re-querying on the poll
|
||||
/// thread is what keeps the CCD call off the capture/encode thread, which is the whole reason
|
||||
/// this poller exists (see `DescriptorPoller`).
|
||||
pub(super) fn spawn(target_id: u32, rect: (i32, i32, i32, i32)) -> Self {
|
||||
let slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>> = Arc::new(Mutex::new(None));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
@@ -139,7 +151,7 @@ impl Drop for CursorPoller {
|
||||
/// The poll loop. Owns the thread's input-desktop binding and the shape cache.
|
||||
fn run(
|
||||
target_id: u32,
|
||||
rect: (i32, i32, i32, i32),
|
||||
mut rect: (i32, i32, i32, i32),
|
||||
slot: &Mutex<Option<pf_frame::CursorOverlay>>,
|
||||
stop: &AtomicBool,
|
||||
secure: &AtomicBool,
|
||||
@@ -161,12 +173,32 @@ fn run(
|
||||
let mut failed_handle: isize = 0; // don't re-rasterise a failing handle every tick
|
||||
let mut serial: u64 = 0;
|
||||
let mut logged_live = false;
|
||||
let mut last_extent = Instant::now();
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(CursorPoller::INTERVAL);
|
||||
if last_attach.elapsed() >= CursorPoller::REATTACH {
|
||||
last_attach = Instant::now();
|
||||
publish_secure(secure, desktop.reattach());
|
||||
// …and re-read the target's desktop rect on the same cadence: a mid-session resize (or
|
||||
// an HDR recreate, or the user moving this display in the desktop arrangement) changes
|
||||
// BOTH the origin the position is made relative to and the extent `in_rect` tests
|
||||
// against, and this poller outlives all of them. `None` keeps the last good value — a
|
||||
// transient CCD failure must not park the pointer at a `(0, 0, 0, 0)` rect, which would
|
||||
// report every position invisible.
|
||||
//
|
||||
let fresh = pf_win_display::win_display::source_desktop_rect(target_id);
|
||||
if let Some(fresh) = fresh {
|
||||
if fresh != rect {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
from = ?rect,
|
||||
to = ?fresh,
|
||||
"cursor poller: target desktop rect changed — re-basing pointer positions"
|
||||
);
|
||||
rect = fresh;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut ci = CURSORINFO {
|
||||
@@ -191,6 +223,42 @@ fn run(
|
||||
// bitmap to have been seen. v1: animated cursors publish their first frame (the OBS
|
||||
// behavior); frame cycling via DrawIconEx istep is a known follow-up.
|
||||
let handle = ci.hCursor.0 as isize;
|
||||
|
||||
// …but the handle alone CANNOT see a re-render. Windows rebuilds the system cursors at a
|
||||
// new size whenever the scale under the pointer changes — crossing to a differently-scaled
|
||||
// monitor, or a monitor's own scale settling after a mode change (a fresh virtual display
|
||||
// is created at the RECOMMENDED scale and gets the client's saved `PerMonitorSettings`
|
||||
// override a beat later) — while the SHARED handle stays put for the session's life (the
|
||||
// arrow is 0x10003 throughout). Keyed on the handle alone the cache latched whatever size
|
||||
// the pointer happened to have when the poller started and never let go: a session that
|
||||
// sampled inside that pre-settle window forwarded — and composited — a 96 px pointer over
|
||||
// a 100 % desktop until it ended, which is exactly the "cursor is 3× too big while
|
||||
// everything else is fine" report. Re-read the bitmap's EXTENT on a slow cadence
|
||||
// (dimensions only, no pixel copy) and drop the cache when it moved.
|
||||
if showing && handle != 0 && handle == cached_handle {
|
||||
if last_extent.elapsed() >= CursorPoller::EXTENT_PROBE {
|
||||
last_extent = Instant::now();
|
||||
if let (Some(now), Some(s)) = (cursor_extent(ci.hCursor), shape.as_ref()) {
|
||||
if now != (s.w, s.h) {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
"cursor: the pointer bitmap resized under a stable handle \
|
||||
({}x{} -> {}x{}) — re-rasterising (the scale under the pointer moved)",
|
||||
s.w,
|
||||
s.h,
|
||||
now.0,
|
||||
now.1
|
||||
);
|
||||
cached_handle = 0; // re-rasterise below, on this same tick
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// A handle change re-rasterises on its own — hold the probe off so it can't fire on
|
||||
// the very next tick against a shape that is current by construction.
|
||||
last_extent = Instant::now();
|
||||
}
|
||||
|
||||
if showing && handle != 0 && handle != cached_handle && handle != failed_handle {
|
||||
match rasterize(ci.hCursor) {
|
||||
Some((rgba, w, h, hot_x, hot_y)) => {
|
||||
@@ -355,6 +423,57 @@ fn rasterize(hcursor: windows::Win32::UI::WindowsAndMessaging::HCURSOR) -> Raste
|
||||
|
||||
type RasterOut = Option<(Vec<u8>, u32, u32, u32, u32)>;
|
||||
|
||||
/// The CURRENT bitmap extent of `hcursor` — exactly the `(w, h)` [`convert`] would derive, without
|
||||
/// the pixel read. Feeds the poll loop's staleness check (a re-render keeps the handle, see there).
|
||||
/// `None` on any failure, which the caller reads as "no verdict" and keeps its cached shape.
|
||||
fn cursor_extent(hcursor: windows::Win32::UI::WindowsAndMessaging::HCURSOR) -> Option<(u32, u32)> {
|
||||
// CopyIcon first, for the reason `rasterize` does it: the owning process can destroy its
|
||||
// HCURSOR between GetCursorInfo and the reads below; the copy is ours.
|
||||
// SAFETY: `HICON(hcursor.0)` reinterprets the cursor handle as an icon handle (cursors ARE
|
||||
// icons in user32); CopyIcon yields an owned HICON destroyed below.
|
||||
let icon = unsafe { CopyIcon(HICON(hcursor.0)) }.ok()?;
|
||||
let mut ii = ICONINFO::default();
|
||||
// SAFETY: `ii` is a live out-param. On Ok it hands us COPIES of the mask/color bitmaps — both
|
||||
// deleted below (GDI-handle leak otherwise).
|
||||
let got = unsafe { GetIconInfo(icon, &mut ii) };
|
||||
// Mirrors `convert`'s two families: a color cursor's extent is its color bitmap's; a
|
||||
// monochrome one's mask carries the AND plane OVER the XOR plane, so its height is doubled.
|
||||
let extent = got.is_ok().then_some(()).and_then(|()| {
|
||||
if !ii.hbmColor.is_invalid() {
|
||||
bitmap_extent(ii.hbmColor)
|
||||
} else {
|
||||
let (w, h) = bitmap_extent(ii.hbmMask)?;
|
||||
(h >= 2 && h % 2 == 0).then_some((w, h / 2))
|
||||
}
|
||||
});
|
||||
// SAFETY: deleting the two bitmap copies GetIconInfo returned (null-safe: DeleteObject on a
|
||||
// null HGDIOBJ fails harmlessly) and the icon copy — each exactly once.
|
||||
unsafe {
|
||||
let _ = DeleteObject(ii.hbmColor.into());
|
||||
let _ = DeleteObject(ii.hbmMask.into());
|
||||
let _ = DestroyIcon(icon);
|
||||
}
|
||||
extent
|
||||
}
|
||||
|
||||
/// A GDI bitmap's dimensions, under [`read_bitmap_32`]'s sanity caps so the two agree on what a
|
||||
/// plausible cursor is — a bitmap `rasterize` would reject must not read here as a size CHANGE.
|
||||
fn bitmap_extent(hbm: HBITMAP) -> Option<(u32, u32)> {
|
||||
let mut bm = BITMAP::default();
|
||||
// SAFETY: `bm` is a live out-param sized exactly as passed; GetObjectW only writes into it.
|
||||
let n = unsafe {
|
||||
GetObjectW(
|
||||
hbm.into(),
|
||||
std::mem::size_of::<BITMAP>() as i32,
|
||||
Some((&mut bm as *mut BITMAP).cast()),
|
||||
)
|
||||
};
|
||||
if n == 0 || bm.bmWidth <= 0 || bm.bmHeight <= 0 || bm.bmWidth > 512 || bm.bmHeight > 1024 {
|
||||
return None;
|
||||
}
|
||||
Some((bm.bmWidth as u32, bm.bmHeight as u32))
|
||||
}
|
||||
|
||||
/// Convert the ICONINFO bitmaps to straight RGBA. Two families:
|
||||
/// - color (`hbmColor` set): 32bpp BGRA; if the alpha channel is entirely empty (old-style
|
||||
/// cursors) the AND mask supplies it (mask bit 1 = transparent).
|
||||
@@ -372,15 +491,13 @@ fn convert(ii: &ICONINFO) -> Option<(Vec<u8>, u32, u32)> {
|
||||
let color = read_bitmap_32(dc, ii.hbmColor)?;
|
||||
let (w, h) = (color.w as u32, color.h as u32);
|
||||
let mut rgba = bgra_to_rgba(&color.bgra);
|
||||
if rgba.chunks_exact(4).all(|p| p[3] == 0) {
|
||||
if alpha_is_empty(&rgba) {
|
||||
// Alpha-less color cursor: transparency lives in the AND mask.
|
||||
let mask = read_bitmap_32(dc, ii.hbmMask)?;
|
||||
if mask.w != color.w || mask.h < color.h {
|
||||
return None;
|
||||
}
|
||||
for (px, m) in rgba.chunks_exact_mut(4).zip(mask.bgra.chunks_exact(4)) {
|
||||
px[3] = if m[0] != 0 { 0 } else { 0xFF }; // mask white (AND=1) = transparent
|
||||
}
|
||||
apply_and_mask_alpha(&mut rgba, &mask.bgra);
|
||||
}
|
||||
Some((rgba, w, h))
|
||||
} else {
|
||||
@@ -389,42 +506,8 @@ fn convert(ii: &ICONINFO) -> Option<(Vec<u8>, u32, u32)> {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (mask.w as usize, (mask.h / 2) as usize);
|
||||
let row = w * 4;
|
||||
let (and_plane, xor_plane) = mask.bgra.split_at(h * row);
|
||||
let mut rgba = vec![0u8; w * h * 4];
|
||||
let mut invert = vec![false; w * h];
|
||||
for i in 0..w * h {
|
||||
let (a, x) = (and_plane[i * 4] != 0, xor_plane[i * 4] != 0);
|
||||
let px = &mut rgba[i * 4..i * 4 + 4];
|
||||
match (a, x) {
|
||||
(false, false) => px.copy_from_slice(&[0, 0, 0, 0xFF]),
|
||||
(false, true) => px.copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]),
|
||||
(true, false) => {} // transparent (already zeroed)
|
||||
(true, true) => {
|
||||
px.copy_from_slice(&[0, 0, 0, 0xFF]);
|
||||
invert[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// White outline around invert regions so the (now black) shape survives dark
|
||||
// backgrounds: any transparent 8-neighbor of an invert pixel turns opaque white.
|
||||
for y in 0..h as i32 {
|
||||
for x in 0..w as i32 {
|
||||
if !invert[(y * w as i32 + x) as usize] {
|
||||
continue;
|
||||
}
|
||||
for (dx, dy) in NEIGHBORS {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
|
||||
continue;
|
||||
}
|
||||
let o = (ny * w as i32 + nx) as usize * 4;
|
||||
if rgba[o + 3] == 0 {
|
||||
rgba[o..o + 4].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (and_plane, xor_plane) = mask.bgra.split_at(h * w * 4);
|
||||
let rgba = mono_planes_to_rgba(and_plane, xor_plane, w, h);
|
||||
Some((rgba, w as u32, h as u32))
|
||||
}
|
||||
})();
|
||||
@@ -506,3 +589,204 @@ fn bgra_to_rgba(bgra: &[u8]) -> Vec<u8> {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a 32bpp RGBA buffer's alpha channel is entirely zero — the "old-style cursor with no
|
||||
/// alpha" test, whose transparency lives in the AND mask instead ([`apply_and_mask_alpha`]).
|
||||
fn alpha_is_empty(rgba: &[u8]) -> bool {
|
||||
rgba.chunks_exact(4).all(|p| p[3] == 0)
|
||||
}
|
||||
|
||||
/// Take alpha from an expanded AND mask: mask WHITE (AND bit 1) means transparent, black opaque.
|
||||
/// `mask_bgra` is the 32bpp expansion `GetDIBits` produces from the 1bpp mask, so any non-zero
|
||||
/// channel byte is "set".
|
||||
fn apply_and_mask_alpha(rgba: &mut [u8], mask_bgra: &[u8]) {
|
||||
for (px, m) in rgba.chunks_exact_mut(4).zip(mask_bgra.chunks_exact(4)) {
|
||||
px[3] = if m[0] != 0 { 0 } else { 0xFF };
|
||||
}
|
||||
}
|
||||
|
||||
/// The monochrome-cursor truth table, plus the white outline that makes an INVERT region legible.
|
||||
///
|
||||
/// A monochrome `HCURSOR` has no colour bitmap: `hbmMask` is DOUBLE height — the AND plane over the
|
||||
/// XOR plane — and the pair encodes four states (the WebRTC/Chromium table):
|
||||
///
|
||||
/// | AND | XOR | meaning | straight-alpha result |
|
||||
/// |-----|-----|-------------|------------------------------------------|
|
||||
/// | 0 | 0 | black | opaque black |
|
||||
/// | 0 | 1 | white | opaque white |
|
||||
/// | 1 | 0 | transparent | fully transparent |
|
||||
/// | 1 | 1 | INVERT dst | opaque black + a grown white outline |
|
||||
///
|
||||
/// INVERT is unrepresentable in straight alpha (it is a per-pixel XOR against whatever is behind
|
||||
/// it), so it becomes opaque black and every TRANSPARENT 8-neighbour of an invert pixel is turned
|
||||
/// opaque white. That outline is what keeps the text I-beam — which is almost entirely invert
|
||||
/// pixels — legible over dark content; the earlier translucent-grey stand-in did not.
|
||||
///
|
||||
/// Extracted from `convert`'s GDI plumbing (sweep Phase 6.6) so the table is testable: the caller
|
||||
/// needs a live `HCURSOR` and a screen DC, this needs two byte slices.
|
||||
fn mono_planes_to_rgba(and_plane: &[u8], xor_plane: &[u8], w: usize, h: usize) -> Vec<u8> {
|
||||
let mut rgba = vec![0u8; w * h * 4];
|
||||
let mut invert = vec![false; w * h];
|
||||
for i in 0..w * h {
|
||||
let (a, x) = (and_plane[i * 4] != 0, xor_plane[i * 4] != 0);
|
||||
let px = &mut rgba[i * 4..i * 4 + 4];
|
||||
match (a, x) {
|
||||
(false, false) => px.copy_from_slice(&[0, 0, 0, 0xFF]),
|
||||
(false, true) => px.copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]),
|
||||
(true, false) => {} // transparent (already zeroed)
|
||||
(true, true) => {
|
||||
px.copy_from_slice(&[0, 0, 0, 0xFF]);
|
||||
invert[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for y in 0..h as i32 {
|
||||
for x in 0..w as i32 {
|
||||
if !invert[(y * w as i32 + x) as usize] {
|
||||
continue;
|
||||
}
|
||||
for (dx, dy) in NEIGHBORS {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
|
||||
continue;
|
||||
}
|
||||
let o = (ny * w as i32 + nx) as usize * 4;
|
||||
if rgba[o + 3] == 0 {
|
||||
rgba[o..o + 4].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rgba
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Expand a 1-bit-per-pixel plane (as `GetDIBits` does) into the 32bpp form the converters read:
|
||||
/// any non-zero channel byte means "bit set".
|
||||
fn plane(bits: &[u8]) -> Vec<u8> {
|
||||
bits.iter()
|
||||
.flat_map(|&b| {
|
||||
let v = if b != 0 { 0xFF } else { 0 };
|
||||
[v, v, v, 0]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn px(rgba: &[u8], i: usize) -> [u8; 4] {
|
||||
rgba[i * 4..i * 4 + 4].try_into().unwrap()
|
||||
}
|
||||
|
||||
const OPAQUE_BLACK: [u8; 4] = [0, 0, 0, 0xFF];
|
||||
const OPAQUE_WHITE: [u8; 4] = [0xFF, 0xFF, 0xFF, 0xFF];
|
||||
const TRANSPARENT: [u8; 4] = [0, 0, 0, 0];
|
||||
|
||||
/// All four AND/XOR states, in one 4×1 row — the table `mono_planes_to_rgba` documents.
|
||||
#[test]
|
||||
fn the_monochrome_truth_table_is_exact() {
|
||||
// (0,0) black (0,1) white (1,0) transparent (1,1) invert
|
||||
let and = plane(&[0, 0, 1, 1]);
|
||||
let xor = plane(&[0, 1, 0, 1]);
|
||||
let out = mono_planes_to_rgba(&and, &xor, 4, 1);
|
||||
assert_eq!(px(&out, 0), OPAQUE_BLACK, "AND=0 XOR=0 ⇒ black");
|
||||
assert_eq!(px(&out, 1), OPAQUE_WHITE, "AND=0 XOR=1 ⇒ white");
|
||||
// Pixel 2 is transparent by the table, but it is an 8-neighbour of the invert pixel at 3,
|
||||
// so the outline claims it — that IS the documented behaviour.
|
||||
assert_eq!(
|
||||
px(&out, 2),
|
||||
OPAQUE_WHITE,
|
||||
"outline grows into adjacent transparency"
|
||||
);
|
||||
assert_eq!(px(&out, 3), OPAQUE_BLACK, "AND=1 XOR=1 ⇒ black + outline");
|
||||
}
|
||||
|
||||
/// Transparency survives when there is no invert pixel next to it.
|
||||
#[test]
|
||||
fn transparent_pixels_stay_transparent_without_an_invert_neighbour() {
|
||||
let and = plane(&[1, 1, 1, 1]);
|
||||
let xor = plane(&[0, 0, 0, 0]);
|
||||
let out = mono_planes_to_rgba(&and, &xor, 4, 1);
|
||||
for i in 0..4 {
|
||||
assert_eq!(px(&out, i), TRANSPARENT, "pixel {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline grows into all eight neighbours, and only into TRANSPARENT ones — it must not
|
||||
/// repaint a black or white shape pixel.
|
||||
#[test]
|
||||
fn the_invert_outline_covers_eight_neighbours_and_overwrites_nothing() {
|
||||
// 3×3, invert at the centre, everything else transparent.
|
||||
let and = plane(&[1, 1, 1, 1, 1, 1, 1, 1, 1]);
|
||||
let mut xor = plane(&[0; 9]);
|
||||
for b in &mut xor[4 * 4..4 * 4 + 3] {
|
||||
*b = 0xFF; // centre pixel's XOR bit
|
||||
}
|
||||
let out = mono_planes_to_rgba(&and, &xor, 3, 3);
|
||||
assert_eq!(px(&out, 4), OPAQUE_BLACK, "the invert pixel itself");
|
||||
for i in [0, 1, 2, 3, 5, 6, 7, 8] {
|
||||
assert_eq!(px(&out, i), OPAQUE_WHITE, "neighbour {i} outlined");
|
||||
}
|
||||
|
||||
// Now surround it with BLACK shape pixels (AND=0, XOR=0): the outline must leave them alone.
|
||||
let and = plane(&[0, 0, 0, 0, 1, 0, 0, 0, 0]);
|
||||
let out = mono_planes_to_rgba(&and, &xor, 3, 3);
|
||||
for i in [0, 1, 2, 3, 5, 6, 7, 8] {
|
||||
assert_eq!(
|
||||
px(&out, i),
|
||||
OPAQUE_BLACK,
|
||||
"neighbour {i} must not be repainted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline must clip at the bitmap edges rather than wrap to the opposite side.
|
||||
#[test]
|
||||
fn the_outline_clips_at_the_edges() {
|
||||
// 2×2 with the invert at (0, 0): only (1,0), (0,1) and (1,1) can be outlined.
|
||||
let and = plane(&[1, 1, 1, 1]);
|
||||
let mut xor = plane(&[0; 4]);
|
||||
for b in &mut xor[0..3] {
|
||||
*b = 0xFF;
|
||||
}
|
||||
let out = mono_planes_to_rgba(&and, &xor, 2, 2);
|
||||
assert_eq!(px(&out, 0), OPAQUE_BLACK);
|
||||
for i in [1, 2, 3] {
|
||||
assert_eq!(px(&out, i), OPAQUE_WHITE, "in-bounds neighbour {i}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the alpha-less colour path ---------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn an_empty_alpha_channel_is_detected() {
|
||||
assert!(alpha_is_empty(&[1, 2, 3, 0, 4, 5, 6, 0]));
|
||||
assert!(!alpha_is_empty(&[1, 2, 3, 0, 4, 5, 6, 1]));
|
||||
assert!(alpha_is_empty(&[]), "no pixels ⇒ vacuously empty");
|
||||
}
|
||||
|
||||
/// Mask WHITE (AND bit 1) = transparent, black = opaque — and the colour bytes are untouched.
|
||||
#[test]
|
||||
fn the_and_mask_supplies_alpha_for_an_alpha_less_cursor() {
|
||||
let mut rgba = vec![
|
||||
10, 20, 30, 0, // pixel 0
|
||||
40, 50, 60, 0, // pixel 1
|
||||
];
|
||||
let mask = plane(&[1, 0]); // pixel 0 masked out, pixel 1 kept
|
||||
apply_and_mask_alpha(&mut rgba, &mask);
|
||||
assert_eq!(px(&rgba, 0), [10, 20, 30, 0], "masked ⇒ transparent");
|
||||
assert_eq!(px(&rgba, 1), [40, 50, 60, 0xFF], "unmasked ⇒ opaque");
|
||||
}
|
||||
|
||||
/// A mask with FEWER pixels than the colour bitmap must not panic — `zip` stops at the shorter
|
||||
/// side, leaving the tail at whatever alpha it had (the caller has already required
|
||||
/// `mask.h >= color.h`, so this is the belt).
|
||||
#[test]
|
||||
fn a_short_mask_does_not_panic() {
|
||||
let mut rgba = vec![1, 2, 3, 0, 4, 5, 6, 0, 7, 8, 9, 0];
|
||||
apply_and_mask_alpha(&mut rgba, &plane(&[0]));
|
||||
assert_eq!(px(&rgba, 0), [1, 2, 3, 0xFF]);
|
||||
assert_eq!(px(&rgba, 1), [4, 5, 6, 0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,863 @@
|
||||
//! IDD-push CONSTRUCTION: everything that runs once, before frames flow.
|
||||
//!
|
||||
//! The sealed channel's whole bring-up — the render-adapter resolution and its one TEX_FAIL rebind,
|
||||
//! the advanced-colour (HDR) negotiation, the shared header + ring + event creation, the channel
|
||||
//! delivery, the cursor-channel/poller opt-in, and the bounded first-frame gate — plus the two types
|
||||
//! that exist only for it ([`SharedObjectSa`], [`AttachTexFail`]).
|
||||
//!
|
||||
//! Split out of `idd_push.rs` in sweep Phase 5.4. The steady state (`try_consume`, `repeat_last`,
|
||||
//! the pollers, the `Capturer` impl) deliberately stays with the parent: this file is the part you
|
||||
//! read when a session will not START, and the parent is the part you read when one stops flowing.
|
||||
//! A `#[path]` child sees the parent's private items through `use super::*`, so nothing had to be
|
||||
//! made more visible to move here.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM only** — `D:P(A;;GA;;;SY)`, protected
|
||||
/// (no inherited ACEs), `bInheritHandle: false`. The sealed channel makes this the strictly-minimal
|
||||
/// DACL: the objects are UNNAMED and the driver reaches them via **duplicated handles** (which carry the
|
||||
/// source handle's access — `OpenSharedResourceByName`/`OpenSharedResource1` on a handle does not
|
||||
/// re-check the object DACL against the opener), so the pf_vdisplay WUDFHost (LocalService) no longer
|
||||
/// needs a DACL ACE. Dropping the `LS` ACE removes the last theoretical surface where a leaked handle or
|
||||
/// a name-grown-by-accident could be opened by the (many-service-shared) LocalService SID. Empirically
|
||||
/// confirmed unreachable regardless: a LocalService token is DACL-denied `OpenProcess` on the WUDFHost
|
||||
/// (`PROCESS_DUP_HANDLE`/`VM_READ`/even `QUERY_LIMITED` → ACCESS_DENIED, tested on the RTX box
|
||||
/// 2026-07-03), so it cannot dup the handles out either. History: `Global\`-named + world-openable
|
||||
/// (`WD`, security-review 2026-06-28 #5) → SY+LS-scoped → nameless → now SY-only. See
|
||||
/// `design/idd-push-security.md`.
|
||||
///
|
||||
/// RAII, because the descriptor is a `LocalAlloc` the caller must `LocalFree` and the previous
|
||||
/// `(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)` tuple never did — leaking it twice per open (once
|
||||
/// for the header section, once for the frame-ready event) and again on every ring recreate. Pairing
|
||||
/// them in one owner also enforces the "descriptor must outlive the attributes" rule structurally:
|
||||
/// `sa.lpSecurityDescriptor` points at the allocation and [`as_ptr`](Self::as_ptr) only lends a
|
||||
/// borrow, so the attributes cannot escape this value's lifetime. Moving the struct is fine — the
|
||||
/// pointer targets the heap allocation, not a field.
|
||||
struct SharedObjectSa {
|
||||
sa: SECURITY_ATTRIBUTES,
|
||||
psd: PSECURITY_DESCRIPTOR,
|
||||
}
|
||||
|
||||
impl SharedObjectSa {
|
||||
fn new() -> Result<Self> {
|
||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||
// SAFETY: `ConvertStringSecurityDescriptorToSecurityDescriptorW` reads the `w!()` literal and
|
||||
// writes the descriptor it allocates into the live local `psd`; `?` rejects a failure before
|
||||
// `psd` is read.
|
||||
unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
w!("D:P(A;;GA;;;SY)"),
|
||||
SDDL_REVISION_1,
|
||||
&mut psd,
|
||||
None,
|
||||
)
|
||||
.context("build SDDL for IDD-push shared objects")?;
|
||||
}
|
||||
Ok(Self {
|
||||
sa: SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: psd.0,
|
||||
bInheritHandle: false.into(),
|
||||
},
|
||||
psd,
|
||||
})
|
||||
}
|
||||
|
||||
/// The `SECURITY_ATTRIBUTES` to hand a create call, borrowed from this owner.
|
||||
fn as_ptr(&self) -> *const SECURITY_ATTRIBUTES {
|
||||
&self.sa
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SharedObjectSa {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `psd` is the descriptor `ConvertStringSecurityDescriptorToSecurityDescriptorW`
|
||||
// allocated for this value and nothing else owns it; `LocalFree` releases it exactly once
|
||||
// (this `Drop` runs once, and `as_ptr` only ever lends a borrow of `sa`).
|
||||
unsafe {
|
||||
let _ = LocalFree(Some(HLOCAL(self.psd.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IddPushCapturer {
|
||||
/// Create the `RING_LEN` shared keyed-mutex textures for one ring generation, at `format` (matched
|
||||
/// to the display's composition format — FP16 in HDR, BGRA in SDR). Each is shared through an
|
||||
/// UNNAMED NT handle (nothing to open by name — the sealed channel); the driver reaches it only via
|
||||
/// the duplicate the [`ChannelBroker`] sends after the ring is published.
|
||||
pub(super) fn create_ring_slots(
|
||||
device: &ID3D11Device,
|
||||
w: u32,
|
||||
h: u32,
|
||||
format: DXGI_FORMAT,
|
||||
) -> Result<Vec<HostSlot>> {
|
||||
// SAFETY: every D3D11/DXGI call is `?`-checked on the live `device` borrow, over
|
||||
// fully-initialized stack descriptors and live out-params; `&sa` stays valid for the whole loop
|
||||
// because `_psd`, the security descriptor backing it, is held in scope alongside.
|
||||
// `OwnedHandle::from_raw_handle` adopts the handle `CreateSharedHandle` JUST minted for this
|
||||
// slot — a unique, still-open NT handle owned by this process — making the slot its sole owner.
|
||||
unsafe {
|
||||
let sa = SharedObjectSa::new()?;
|
||||
let mut slots = Vec::new();
|
||||
for _ in 0..RING_LEN {
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
// Match the OS-composed swap-chain surfaces so the driver's CopyResource into the slot +
|
||||
// its format-guard both succeed.
|
||||
Format: format,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: (D3D11_RESOURCE_MISC_SHARED_NTHANDLE.0
|
||||
| D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX.0)
|
||||
as u32,
|
||||
};
|
||||
let mut tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&desc, None, Some(&mut tex))
|
||||
.context("CreateTexture2D(IDD-push ring slot)")?;
|
||||
let tex = tex.context("null ring texture")?;
|
||||
let res1: IDXGIResource1 = tex.cast()?;
|
||||
let shared = res1
|
||||
.CreateSharedHandle(
|
||||
Some(sa.as_ptr()),
|
||||
DXGI_SHARED_RESOURCE_RW,
|
||||
PCWSTR::null(), // UNNAMED — reachable only through the broker's duplicate
|
||||
)
|
||||
.context("CreateSharedHandle(IDD-push ring slot)")?;
|
||||
// Own the shared handle so the slot's `Drop` closes it via RAII (was a manual `CloseHandle`).
|
||||
let shared = OwnedHandle::from_raw_handle(shared.0 as _);
|
||||
let mutex: IDXGIKeyedMutex = tex.cast()?;
|
||||
let mut srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
.CreateShaderResourceView(&tex, None, Some(&mut srv))
|
||||
.context("CreateShaderResourceView(IDD-push ring slot)")?;
|
||||
let srv = srv.context("null slot srv")?;
|
||||
slots.push(HostSlot {
|
||||
tex,
|
||||
mutex,
|
||||
shared,
|
||||
srv,
|
||||
});
|
||||
}
|
||||
Ok(slots)
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the IDD-push capturer. On success the caller's `keepalive` is attached (the capturer owns the
|
||||
/// virtual display); on FAILURE the keepalive is handed BACK so the caller can fall back to DDA
|
||||
/// instead of tearing the display down (audit §5.1 — no more 20 s black bail). "Failure" includes the
|
||||
/// driver not attaching to the ring within a few seconds (e.g. a hybrid-GPU render mismatch).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
keepalive: Box<dyn Send>,
|
||||
sender: crate::FrameChannelSender,
|
||||
cursor_sender: Option<crate::CursorChannelSender>,
|
||||
cursor_forward: Option<crate::CursorForwardSender>,
|
||||
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
|
||||
// The stall-attribution listener (idempotent): started with the first IDD-push capturer so
|
||||
// the stall log can correlate DWM holes with OS display events for the session's lifetime.
|
||||
pf_win_display::display_events::spawn_once();
|
||||
match Self::open_inner(
|
||||
target,
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_444,
|
||||
pyrowave,
|
||||
sender,
|
||||
cursor_sender,
|
||||
cursor_forward,
|
||||
) {
|
||||
Ok(mut me) => {
|
||||
me._keepalive = keepalive;
|
||||
Ok(me)
|
||||
}
|
||||
Err(e) => Err((e, keepalive)),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open_inner(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
sender: crate::FrameChannelSender,
|
||||
cursor_sender: Option<crate::CursorChannelSender>,
|
||||
cursor_forward: Option<crate::CursorForwardSender>,
|
||||
) -> Result<Self> {
|
||||
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
|
||||
// selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor
|
||||
// ADD, so on a healthy box they agree, and NVENC gets a device on a real GPU adapter.
|
||||
// (`target.adapter_luid` is NOT that adapter: the ADD reply carries
|
||||
// `IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` = the IddCx DISPLAY adapter — verified
|
||||
// on-glass; it stays a last-resort fallback for a pickerless box only.) When the pick and
|
||||
// the driver HAVE drifted — identical twin GPUs whose max-VRAM tie moved between ADD and
|
||||
// this open, or a stale kept monitor across an adapter re-init — the driver reports
|
||||
// TEX_FAIL plus the adapter it actually renders on, and the rebind below reopens on that.
|
||||
let luid = pf_gpu::resolve_render_adapter_luid().unwrap_or(LUID {
|
||||
LowPart: (target.adapter_luid & 0xffff_ffff) as u32,
|
||||
HighPart: (target.adapter_luid >> 32) as i32,
|
||||
});
|
||||
match Self::open_on(
|
||||
target.clone(),
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_444,
|
||||
pyrowave,
|
||||
luid,
|
||||
sender.clone(),
|
||||
cursor_sender.clone(),
|
||||
cursor_forward.clone(),
|
||||
) {
|
||||
Ok(me) => Ok(me),
|
||||
Err(e) => {
|
||||
// Self-heal a render-adapter mismatch ONCE: on TEX_FAIL the driver has reported the
|
||||
// adapter its swap-chain ACTUALLY renders on (a stale monitor across an adapter
|
||||
// re-init, or a driver that ignored SET_RENDER_ADAPTER). Rebinding the ring to that
|
||||
// adapter beats failing the session — the outer pipeline retries would repeat the
|
||||
// exact same mismatch.
|
||||
let driver_luid = e
|
||||
.downcast_ref::<AttachTexFail>()
|
||||
.map(|tf| tf.driver_luid)
|
||||
.filter(|d| *d != 0 && *d != crate::dxgi::pack_luid(luid));
|
||||
let Some(packed) = driver_luid else {
|
||||
return Err(e);
|
||||
};
|
||||
let drv = LUID {
|
||||
LowPart: (packed & 0xffff_ffff) as u32,
|
||||
HighPart: (packed >> 32) as i32,
|
||||
};
|
||||
tracing::warn!(
|
||||
ring_adapter = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
driver_adapter = format!("{:08x}:{:08x}", drv.HighPart, drv.LowPart),
|
||||
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
|
||||
driver's reported adapter"
|
||||
);
|
||||
Self::open_on(
|
||||
target,
|
||||
preferred,
|
||||
client_10bit,
|
||||
want_444,
|
||||
pyrowave,
|
||||
drv,
|
||||
sender,
|
||||
cursor_sender,
|
||||
cursor_forward,
|
||||
)
|
||||
.context("IDD-push rebind to the driver's reported render adapter")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open_on(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
want_444: bool,
|
||||
pyrowave: bool,
|
||||
luid: LUID,
|
||||
sender: crate::FrameChannelSender,
|
||||
cursor_sender: Option<crate::CursorChannelSender>,
|
||||
cursor_forward: Option<crate::CursorForwardSender>,
|
||||
) -> Result<Self> {
|
||||
let (pw, ph, _hz) = preferred
|
||||
.context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
|
||||
// Size the ring to the display's ACTUAL current resolution if it differs from the negotiated mode:
|
||||
// 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.
|
||||
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,
|
||||
negotiated = format!("{pw}x{ph}"),
|
||||
actual = format!("{w}x{h}"),
|
||||
"IDD push: sizing the ring to the display's actual mode (differs from negotiated)"
|
||||
);
|
||||
}
|
||||
// The driver composes the virtual display in FP16 (R16G16B16A16_FLOAT scRGB) when the display is
|
||||
// in advanced-color (HDR) mode, and 8-bit BGRA otherwise (per swap_chain_processor.rs + the
|
||||
// COMMIT_MODES2 colorspace/rgb_bpc log). For a 10-bit-capable client we PROACTIVELY enable
|
||||
// advanced color so HDR streams without the user toggling anything, then TRACK the display's
|
||||
// actual mode (a mid-session "Use HDR" flip; the driver's format-guard drops a mismatch), polling
|
||||
// the live state here and on every recreate. An SDR-only client instead forces advanced color OFF
|
||||
// and is PINNED there (below + the descriptor poller), so the SDR negotiation is honored and the
|
||||
// encoder never emits the in-band PQ upgrade to a client that asked for SDR.
|
||||
// SAFETY: one block over the whole ring setup; every operation in it is sound:
|
||||
// - `set_advanced_color`/`advanced_color_enabled` are `unsafe fn`s taking only a copy of the plain
|
||||
// `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing.
|
||||
// - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `SharedObjectSa::new`,
|
||||
// `CreateFileMappingW`, `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all
|
||||
// `?`-checked, so every returned interface/handle/view is non-error before use;
|
||||
// `sa.as_ptr()`/`&adapter`/`&device` are live borrows that outlive each synchronous call, and
|
||||
// `sa.lpSecurityDescriptor` stays valid because the owning `SharedObjectSa` is held in scope
|
||||
// for the whole block (and frees the descriptor on the way out).
|
||||
// - The header mapping is created AND viewed at `bytes == size_of::<SharedHeader>().max(64)`; the
|
||||
// view's null is checked (`bail!` on failure, after which the owned `map` closes the mapping). The
|
||||
// OS view base is page-aligned, so `section.ptr::<SharedHeader>()` is suitably aligned for a
|
||||
// `SharedHeader`, and `write_bytes(.., 0, bytes)` plus the `(*header).field = ..` writes all stay
|
||||
// within those `bytes` and write THROUGH the raw pointer without forming any `&mut`.
|
||||
// - The `magic` publish stores through `addr_of!((*header).magic) as *const AtomicU32`: `addr_of!`
|
||||
// takes the field address without a reference; the field is a 4-aligned `u32` (valid for
|
||||
// `AtomicU32`), and the `Release` store after the `Release` fence is the cross-process handshake
|
||||
// that orders all preceding writes before the driver may observe `MAGIC`.
|
||||
// - `broker.send` requires live `header`/`event` handles of this process: both borrow the just-
|
||||
// created owned section/event for the duration of that synchronous call.
|
||||
// - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section`
|
||||
// into `me` leaves it valid (see the `MappedSection` doc comment).
|
||||
unsafe {
|
||||
// An SDR-NEGOTIATED session (either codec) must run on an SDR (BGRA) composition, so
|
||||
// actively turn advanced color OFF — undoing any leftover HDR state from a prior 10-bit
|
||||
// session on a reused/lingering monitor, the driver's default, or the host's global
|
||||
// "Use HDR" — and settle before sizing the ring. Non-optional for two reasons:
|
||||
// - PyroWave: its CSC reads 8-bit BGRA and the NVIDIA D3D11 VideoProcessor can't ingest
|
||||
// the FP16 ring at all.
|
||||
// - H.26x: off an HDR composition the capturer emits P010 and the encoder stamps
|
||||
// Main10 + BT.2020 PQ from the pixel format alone (the in-band HDR upgrade), sending a
|
||||
// 10-bit PQ stream to a client that advertised SDR-only ("HDR off = never send me
|
||||
// 10-bit"). On a client whose monitor is HDR-capable but has "Use HDR" off, that PQ
|
||||
// lands on an SDR desktop and blows out — the composition must honor the negotiation.
|
||||
// An HDR-negotiated (10-bit) session instead enables HDR below and rides the FP16 scRGB
|
||||
// ring (design/pyrowave-444-hdr.md Phase 3 for PyroWave; the H.26x P010 path otherwise).
|
||||
if !client_10bit {
|
||||
let _ = pf_win_display::win_display::set_advanced_color(target.target_id, false);
|
||||
let settle = Instant::now();
|
||||
while settle.elapsed() < Duration::from_millis(250) {
|
||||
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||
== Some(false)
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||
== Some(true)
|
||||
{
|
||||
tracing::error!(
|
||||
target = target.target_id,
|
||||
pyrowave,
|
||||
"IDD push: SDR session but advanced color (HDR) could NOT be turned off on the \
|
||||
virtual display (a physical display forcing HDR?) — PyroWave will likely fail \
|
||||
its first frame; H.26x would emit PQ the SDR-only client never asked for"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
target = target.target_id,
|
||||
pyrowave,
|
||||
settle_ms = settle.elapsed().as_millis() as u64,
|
||||
"IDD push: SDR-negotiated session — advanced color forced OFF (SDR/BGRA composition)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// If we ENABLE advanced color for a 10-bit client, trust it (the driver will compose FP16) and
|
||||
// size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
|
||||
// settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format
|
||||
// mismatch → an immediate ring recreate + dropped first frames (audit §5.4).
|
||||
let enabled_hdr = client_10bit
|
||||
&& pf_win_display::win_display::set_advanced_color(target.target_id, true);
|
||||
if enabled_hdr {
|
||||
// Let the colorspace change settle before the driver composes + we size the ring:
|
||||
// poll the CCD advanced-color state instead of a fixed sleep (latency plan P0.4),
|
||||
// ceiling = the old 250 ms. A read that never flips within the ceiling proceeds
|
||||
// exactly like the fixed sleep did — the ring is sized FP16 from `enabled_hdr`
|
||||
// either way (the set succeeded; only the driver's compose flip may lag, which the
|
||||
// stash/format-guard machinery absorbs).
|
||||
let hdr_settle = Instant::now();
|
||||
while hdr_settle.elapsed() < Duration::from_millis(250) {
|
||||
if pf_win_display::win_display::advanced_color_enabled(target.target_id)
|
||||
== Some(true)
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
tracing::debug!(
|
||||
target_id = target.target_id,
|
||||
settle_ms = hdr_settle.elapsed().as_millis() as u64,
|
||||
"IDD push: advanced-color (HDR) enable settle"
|
||||
);
|
||||
}
|
||||
// A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) —
|
||||
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
|
||||
// An SDR-negotiated session (either codec) forced advanced color OFF above and composes
|
||||
// SDR unconditionally: `client_10bit` gates HDR so a client that advertised SDR-only is
|
||||
// never handed a PQ stream, even if a physical display forces HDR on (the descriptor
|
||||
// poller re-asserts OFF; PyroWave's format guard/stash absorbs any lingering FP16 compose).
|
||||
// Keep the raw observation so Downgrade point D below can say whether the read reported
|
||||
// OFF or failed outright — "we asked, it said no" and "we could not tell" have different
|
||||
// causes and different fixes.
|
||||
let observed_hdr =
|
||||
pf_win_display::win_display::advanced_color_enabled(target.target_id);
|
||||
let display_hdr = client_10bit && (enabled_hdr || observed_hdr.unwrap_or(false));
|
||||
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
|
||||
// NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display
|
||||
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
|
||||
// BT.709, so the client's label overstates the stream until the descriptor poller sees
|
||||
// HDR come on. Loud, because every frame of this session is affected.
|
||||
if client_10bit && !display_hdr {
|
||||
tracing::error!(
|
||||
target = target.target_id,
|
||||
want_hdr = true,
|
||||
set_advanced_color_returned = enabled_hdr,
|
||||
observed_hdr = ?observed_hdr,
|
||||
"IDD push: 10-bit HDR was negotiated but enabling advanced color on the \
|
||||
virtual display FAILED — encoding 8-bit SDR while the client was told HDR \
|
||||
(check the display driver / Windows HDR support on this box). \
|
||||
observed_hdr=Some(false) ⇒ the display reports advanced colour OFF after the \
|
||||
set; None ⇒ the CCD read itself failed"
|
||||
);
|
||||
}
|
||||
let ring_fmt = if display_hdr {
|
||||
DXGI_FORMAT_R16G16B16A16_FLOAT
|
||||
} else {
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM
|
||||
};
|
||||
// Our device (ring + zero-copy NVENC) lives on `luid` — the selected render GPU per
|
||||
// `open_inner`; the driver must render the swap-chain on the SAME adapter for the
|
||||
// shared textures to open (it reports its actual render LUID into the header, which
|
||||
// `open_inner` uses to rebind once if this mismatches).
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
|
||||
let adapter: IDXGIAdapter1 = factory
|
||||
.EnumAdapterByLuid(luid)
|
||||
.context("EnumAdapterByLuid(render adapter) for IDD push")?;
|
||||
let (device, context) = make_device(&adapter).context("make_device for IDD push")?;
|
||||
|
||||
let sa = SharedObjectSa::new()?;
|
||||
let bytes = std::mem::size_of::<SharedHeader>().max(64);
|
||||
|
||||
// Header — UNNAMED (the sealed channel: the driver gets a duplicated handle, not a name).
|
||||
let map = CreateFileMappingW(
|
||||
INVALID_HANDLE_VALUE,
|
||||
Some(sa.as_ptr()),
|
||||
PAGE_READWRITE,
|
||||
0,
|
||||
bytes as u32,
|
||||
PCWSTR::null(),
|
||||
)
|
||||
.context("CreateFileMapping(IDD-push header)")?;
|
||||
// Own the mapping handle so it (and its view) free via `MappedSection` RAII even on bail.
|
||||
let map = OwnedHandle::from_raw_handle(map.0 as _);
|
||||
let view = MapViewOfFile(
|
||||
HANDLE(map.as_raw_handle()),
|
||||
FILE_MAP_ALL_ACCESS,
|
||||
0,
|
||||
0,
|
||||
bytes,
|
||||
);
|
||||
if view.Value.is_null() {
|
||||
bail!("MapViewOfFile failed for IDD-push header"); // `map` drops → mapping closed
|
||||
}
|
||||
let section = MappedSection { handle: map, view };
|
||||
let generation = next_generation();
|
||||
let header = section.ptr::<SharedHeader>();
|
||||
std::ptr::write_bytes(header.cast::<u8>(), 0, bytes);
|
||||
(*header).version = VERSION;
|
||||
(*header).generation = generation;
|
||||
(*header).ring_len = RING_LEN;
|
||||
(*header).width = w;
|
||||
(*header).height = h;
|
||||
// Ring format = the display's composition format (FP16 in HDR, BGRA in SDR). The driver
|
||||
// reads this into its `ring_format` and drops any surface that doesn't match.
|
||||
(*header).dxgi_format = ring_fmt.0 as u32;
|
||||
// The ring NAMES its monitor (proto v3, `design/idd-push-security.md` invariant #10) —
|
||||
// stamped before the magic (below), never changed for the ring's life (a mid-session
|
||||
// recreate reuses this mapping). The driver refuses to attach a ring naming a different
|
||||
// monitor, so a stash cross-wire fails closed instead of leaking frames cross-client
|
||||
// (fail-closed refusal VALIDATED on-glass 2026-07-10 via a fault-injected build: driver
|
||||
// DRV_STATUS_BIND_FAIL + loud host open failure + sibling stream undisturbed).
|
||||
(*header).target_id = target.target_id;
|
||||
|
||||
// Frame-ready event (auto-reset) — UNNAMED, like everything on this channel.
|
||||
let event = CreateEventW(Some(sa.as_ptr()), false, false, PCWSTR::null())
|
||||
.context("CreateEvent(IDD-push)")?;
|
||||
let event = OwnedHandle::from_raw_handle(event.0 as _);
|
||||
|
||||
// Ring of shared keyed-mutex textures, format matched to the display's current mode.
|
||||
let slots = Self::create_ring_slots(&device, w, h, ring_fmt)?;
|
||||
|
||||
// Publish: magic LAST (Release) — the ring must be fully initialized before the driver
|
||||
// (which receives the channel strictly afterwards) can observe MAGIC.
|
||||
std::sync::atomic::fence(Ordering::Release);
|
||||
(*(std::ptr::addr_of!((*header).magic) as *const AtomicU32))
|
||||
.store(MAGIC, Ordering::Release);
|
||||
|
||||
// Deliver the sealed channel: duplicate header + event + every slot texture into the
|
||||
// driver's WUDFHost and hand it the values over the control device. All-or-nothing (the
|
||||
// broker reaps its remote duplicates on failure), and a failure fails the open — without
|
||||
// the delivery the driver can never attach.
|
||||
let broker = ChannelBroker::open(target.wudf_pid, sender)?;
|
||||
broker
|
||||
.send(
|
||||
target.target_id,
|
||||
generation,
|
||||
HANDLE(section.handle.as_raw_handle()),
|
||||
HANDLE(event.as_raw_handle()),
|
||||
&slots,
|
||||
)
|
||||
.context("deliver IDD-push frame channel to the driver")?;
|
||||
|
||||
// v5 hardware-cursor channel (M2c): create + deliver the CursorShm section. Failure
|
||||
// is NON-fatal — the driver never declares the hardware cursor without this delivery,
|
||||
// so the session degrades to today's composited pointer (and the forwarder simply
|
||||
// never sees a live overlay).
|
||||
let cursor_shared = cursor_sender.as_ref().and_then(|send_cursor| {
|
||||
match cursor::CursorShared::create(target.target_id) {
|
||||
Ok(cs) => {
|
||||
// Deliver via the shared helper (also used for RE-delivery after a
|
||||
// driver-side monitor re-arrival destroyed the worker).
|
||||
deliver_cursor_channel(&broker, target.target_id, &cs, send_cursor)
|
||||
.then_some(cs)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"cursor section creation failed — the driver will not declare a \
|
||||
hardware cursor, so this session cannot forward the pointer: {e:#}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
// No LIVE channel this session, but the target's sticky declare (an EARLIER session's —
|
||||
// irrevocable, §8.6) keeps DWM's frames pointer-free with no client drawing either:
|
||||
// the only visible pointer is the one composited here, so force composite mode on.
|
||||
//
|
||||
// Gated on `cursor_shared`, NOT on `cursor_sender`. §8.6's rationale is "this session has
|
||||
// no cursor CHANNEL", and the delivery just above is explicitly allowed to fail
|
||||
// non-fatally — which is precisely the state that needs this rescue, yet the
|
||||
// `cursor_sender.is_none()` test was the one state that skipped it: the host negotiated a
|
||||
// channel, failed to create or deliver it, and then declined to composite, leaving a
|
||||
// cursor-excluded target with NO pointer at all.
|
||||
let composite_forced = target.cursor_excluded && cursor_shared.is_none();
|
||||
if composite_forced {
|
||||
tracing::info!(
|
||||
target_id = target.target_id,
|
||||
negotiated_channel = cursor_sender.is_some(),
|
||||
"target carries an irrevocable hardware-cursor declare from an earlier \
|
||||
desktop-mode session and this session has no LIVE cursor channel — the host \
|
||||
composites the pointer into frames (forced, for the session's life). \
|
||||
negotiated_channel=true ⇒ one was negotiated but its creation/delivery failed"
|
||||
);
|
||||
}
|
||||
// The GDI shape poller rides the SAME gate as the delivered channel: with the driver's
|
||||
// hardware cursor keeping the frame cursor-free, the poller supplies the full-fidelity
|
||||
// shape (masked/monochrome included — the IddCx query can't; see cursor_poll.rs).
|
||||
// Forced-composite sessions need it too — it is their only shape/position source.
|
||||
let cursor_poll = (cursor_shared.is_some() || composite_forced).then(|| {
|
||||
// Safety of the CCD call: read-only QueryDisplayConfig over owned locals (same
|
||||
// call CursorShared::create makes) — already inside open_on's unsafe region.
|
||||
let rect = pf_win_display::win_display::source_desktop_rect(target.target_id)
|
||||
.unwrap_or((0, 0, i32::MAX, i32::MAX));
|
||||
cursor_poll::CursorPoller::spawn(target.target_id, rect)
|
||||
});
|
||||
// Heal the driver's persisted cursor-forward state: a session that died on the
|
||||
// secure desktop (client drops at the lock screen — the common case) leaves the
|
||||
// per-target desired state `false`, and the NEXT session's channel delivery would
|
||||
// adopt UNdeclared (the exact cross-session composite trap of §8.6). A fresh
|
||||
// session always starts declared; the secure-desktop guard re-disables if the
|
||||
// secure desktop is (still) up, via its first `poll_secure_desktop` edge.
|
||||
if let (Some(_), Some(fwd)) = (cursor_shared.as_ref(), cursor_forward.as_ref()) {
|
||||
if let Err(e) = fwd(true) {
|
||||
tracing::debug!("cursor-forward reset at open failed (pre-v6 driver?): {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target_id = target.target_id,
|
||||
wudf_pid = target.wudf_pid,
|
||||
render_luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
mode = format!("{w}x{h}"),
|
||||
display_hdr,
|
||||
client_10bit,
|
||||
want_444,
|
||||
ring_fp16 = display_hdr,
|
||||
// Whether DXGI ever reached the win32u GPU-preference hook. By this point the
|
||||
// factory + `EnumAdapterByLuid` + `make_device` above have exercised DXGI, so a
|
||||
// 0 here means the hook is inert on this build — the first thing to check if a
|
||||
// hybrid-GPU box keeps reporting TEX_FAIL render-adapter mismatches
|
||||
// (`dxgi::install_gpu_pref_hook`).
|
||||
hybrid_hook_hits = crate::dxgi::hybrid_hook_hits(),
|
||||
"IDD push(host): created sealed ring + delivered the channel; waiting for the driver \
|
||||
to attach + publish"
|
||||
);
|
||||
let mut me = Self {
|
||||
device,
|
||||
context,
|
||||
target_id: target.target_id,
|
||||
section,
|
||||
header,
|
||||
event,
|
||||
broker,
|
||||
width: w,
|
||||
height: h,
|
||||
slots,
|
||||
generation,
|
||||
client_10bit,
|
||||
display_hdr,
|
||||
hdr_pin_warned: false,
|
||||
want_444,
|
||||
pyrowave,
|
||||
pyro_fence: None,
|
||||
pyro_fence_handle: None,
|
||||
pyro_fence_value: 0,
|
||||
pyro_ring: Vec::new(),
|
||||
pyro_conv: None,
|
||||
pyro_last: None,
|
||||
desc_poller: DescriptorPoller::spawn(
|
||||
target.target_id,
|
||||
DisplayDescriptor {
|
||||
hdr: display_hdr,
|
||||
width: w,
|
||||
height: h,
|
||||
},
|
||||
),
|
||||
desc_seq: 0,
|
||||
pending_desc: None,
|
||||
recovering_since: None,
|
||||
last_fresh: Instant::now(),
|
||||
last_liveness: Instant::now(),
|
||||
last_kick: Instant::now(),
|
||||
stall_watch: StallWatch::new(),
|
||||
out_ring: Vec::new(),
|
||||
out_idx: 0,
|
||||
video_conv: None,
|
||||
hdr_p010_conv: None,
|
||||
last_seq: 0,
|
||||
last_present: None,
|
||||
status_logged: false,
|
||||
cursor_shared,
|
||||
cursor_poll,
|
||||
cursor_sender,
|
||||
cursor_forward,
|
||||
secure_active: false,
|
||||
composite_cursor: composite_forced,
|
||||
composite_forced,
|
||||
cursor_blend: None,
|
||||
cursor_blend_failed: false,
|
||||
cursor_shm_latched: false,
|
||||
blend_scratch: None,
|
||||
last_blend_key: None,
|
||||
last_slot: None,
|
||||
sdr_white_scale: 1.0,
|
||||
// Held from BEFORE the first-frame gate (the display must not idle off while we
|
||||
// wait for the first compose) until the capturer drops with the session.
|
||||
_display_wake: pf_frame::session_tuning::DisplayWakeRequest::new(),
|
||||
// Placeholder; `open()` attaches the real keepalive on success, so a FAILED open can hand
|
||||
// it back to the caller for the DDA fallback (audit §5.1).
|
||||
_keepalive: Box::new(()),
|
||||
};
|
||||
// The HDR SDR-white reference for the composited cursor, queried ONCE here rather than
|
||||
// from the blend (which holds the ring slot's keyed mutex — see
|
||||
// `refresh_sdr_white_scale`). No-op on an SDR composition.
|
||||
me.refresh_sdr_white_scale();
|
||||
// Bounded wait for the driver to ATTACH to the ring AND publish a first frame. An attach
|
||||
// failure (DRV_STATUS_TEX_FAIL) or an attach-but-no-frames (a game left the display in a
|
||||
// format/size the ring can't match) becomes an open failure the caller falls back from (→ DDA),
|
||||
// instead of next_frame's 20 s black-then-bail.
|
||||
me.wait_for_attach()?;
|
||||
Ok(me)
|
||||
}
|
||||
}
|
||||
|
||||
/// Block (bounded) until the driver has ATTACHED to the host ring (`DRV_STATUS_OPENED`) **and published
|
||||
/// a first frame**, else fail so the caller can fall back to DDA (audit §5.1 +
|
||||
/// `design/windows-host-rewrite.md` §2.5 — the GB1 game-capture fix).
|
||||
///
|
||||
/// Requiring the first frame — not just the attach — catches the *reconnect-into-a-broken-state* case:
|
||||
/// a fullscreen game can leave the virtual display in a format/size that the driver's `publish()` guard
|
||||
/// rejects, so the driver ATTACHES but silently drops every frame; without this the host sails past
|
||||
/// `open()` and only dies on `next_frame`'s 20 s deadline (the "reconnect = black + audio" symptom).
|
||||
/// A stash-capable driver republishes its retained desktop frame the moment it attaches (the
|
||||
/// first-frame guarantee — `FrameStash`, driver frame_transport.rs), so the normal case clears this
|
||||
/// gate in milliseconds even on an idle desktop; failing that, at session open the OS activates the
|
||||
/// virtual display → DWM composites it → a frame arrives within ~1 s, plus the compose-kick fallback
|
||||
/// below — no frame within the window = genuinely broken.
|
||||
fn wait_for_attach(&self) -> Result<()> {
|
||||
// Symmetric host-side binding sanity (proto v3 §3.2): OUR header must still name OUR
|
||||
// monitor. The stamp is ours and nothing legitimate rewrites it, so a mismatch means a
|
||||
// host-side bug (a stash/capturer cross-wire) — the exact class the driver-side check
|
||||
// catches from the other end; failing here names the culprit in the same release.
|
||||
// SAFETY: in-bounds, aligned u32 read of the live, owned shared-header mapping (same access
|
||||
// pattern as the `driver_status` read below); no reference into the shared region is formed.
|
||||
let stamped = unsafe { (*self.header).target_id };
|
||||
if stamped != self.target_id {
|
||||
bail!(
|
||||
"IDD-push: our ring header names target {stamped} but this capturer serves target \
|
||||
{} — host-side ring↔monitor cross-wire (bug); failing the open",
|
||||
self.target_id
|
||||
);
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(4);
|
||||
// First-frame expectation: a stash-capable driver republishes its retained desktop frame
|
||||
// the moment it attaches (`FrameStash`, frame_transport.rs), so on a healthy pairing the
|
||||
// gate below clears in milliseconds even on a perfectly idle desktop. The compose-kick
|
||||
// schedule is the FALLBACK for pre-stash drivers / an empty stash (a display that has
|
||||
// never composed): DWM only presents a display something DIRTIED, so on an idle desktop
|
||||
// an attach would otherwise sit at E_PENDING forever and fail this gate — the
|
||||
// "idle desktop → no frames" gotcha. Give the natural post-activate compose (and the
|
||||
// stash republish) a moment, then nudge; log when we do, so field logs show whether the
|
||||
// stash path is working.
|
||||
let mut next_kick = Instant::now() + Duration::from_millis(600);
|
||||
loop {
|
||||
// SAFETY: `self.header` points into the live shared-header mapping this capturer owns (sized
|
||||
// `>= size_of::<SharedHeader>()`, page-aligned), so the field read is in-bounds + aligned, and
|
||||
// no reference into the shared region is formed. Plain read: the driver writes this `u32`
|
||||
// cross-process, but an aligned `u32` read can't tear and `driver_status` is best-effort
|
||||
// diagnostics — the real handshake is the atomic `magic`/`latest` (same access as
|
||||
// log_driver_status_once).
|
||||
let st = unsafe { (*self.header).driver_status };
|
||||
if st == DRV_STATUS_TEX_FAIL {
|
||||
// The driver wrote its render LUID BEFORE attempting the texture opens
|
||||
// (frame_transport.rs step 2), so it is valid here.
|
||||
let (_, detail, lo, hi) = self.driver_diag();
|
||||
// Typed so `open_inner` can rebind the ring to the driver's adapter once.
|
||||
return Err(anyhow::Error::new(AttachTexFail {
|
||||
detail,
|
||||
driver_luid: ((hi as i64) << 32) | (lo as i64 & 0xffff_ffff),
|
||||
}));
|
||||
}
|
||||
if st == DRV_STATUS_NO_DEVICE1 {
|
||||
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
|
||||
// through the owned, live header mapping; no reference into the shared region is formed.
|
||||
let detail = unsafe { (*self.header).driver_status_detail };
|
||||
bail!(
|
||||
"IDD-push driver failed to attach (driver_status={st} detail=0x{detail:08x} — \
|
||||
the driver has no ID3D11Device1 to open shared resources)"
|
||||
);
|
||||
}
|
||||
if st == DRV_STATUS_BIND_FAIL {
|
||||
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
|
||||
// through the owned, live header mapping; no reference into the shared region is formed.
|
||||
let claimed = unsafe { (*self.header).driver_status_detail };
|
||||
bail!(
|
||||
"IDD-push driver REFUSED the ring↔monitor binding (DRV_STATUS_BIND_FAIL: the \
|
||||
delivered ring names target {claimed}, the monitor is {}) — host \
|
||||
stash/delivery cross-wire (bug); failing the open loudly (proto v3 §3.2)",
|
||||
self.target_id
|
||||
);
|
||||
}
|
||||
// Attached AND a frame has been published — the publish token's seq advances past 0.
|
||||
if st == DRV_STATUS_OPENED && frame::FrameToken::unpack(self.latest()).seq != 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= next_kick {
|
||||
// Reaching a kick at all means the driver did NOT republish a retained frame
|
||||
// (pre-stash driver, or a never-composed display) — worth a line in the field log.
|
||||
tracing::debug!(
|
||||
target_id = self.target_id,
|
||||
driver_status = st,
|
||||
"IDD push: no first frame after attach delivery — falling back to a synthetic \
|
||||
compose kick (stash-capable drivers republish instantly; old driver?)"
|
||||
);
|
||||
// May BLOCK this thread ~35 ms (the cursor-on-a-sibling-display branch — see
|
||||
// `kick_dwm_compose`'s COST note). Fine here: we are inside the open-time
|
||||
// first-frame gate, so no frames are flowing yet.
|
||||
kick_dwm_compose(self.target_id);
|
||||
next_kick = Instant::now() + Duration::from_millis(800);
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
bail!(
|
||||
"IDD-push: no frame published within 4s (despite compose kicks) — {}; \
|
||||
falling back",
|
||||
self.no_first_frame_diagnosis(st)
|
||||
);
|
||||
}
|
||||
// Event-driven wait (latency plan P0.6): the driver signals the frame-ready event on
|
||||
// every publish, so wake on it instead of a blind sleep — the 20 ms timeout keeps the
|
||||
// driver_status polls above live (status writes don't signal the event). Consuming a
|
||||
// signal here is fine: `next_frame` re-checks the atomic `latest` token, never the
|
||||
// event, for truth.
|
||||
// SAFETY: `self.event` is this capturer's owned, live auto-reset event handle;
|
||||
// `WaitForSingleObject` only reads the handle and the 20 ms timeout bounds the wait.
|
||||
let _ = unsafe { WaitForSingleObject(HANDLE(self.event.as_raw_handle()), 20) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Name a first-frame timeout from the driver's own evidence — `driver_status` plus the live
|
||||
/// OPENED detail word (proto `pack_opened_detail`) — instead of guessing. The three no-frames
|
||||
/// states look identical from the host side but have disjoint causes and fixes; the lid-closed
|
||||
/// field report burned days for lack of exactly this line. Appends a console-session hint when
|
||||
/// the host itself is in the wrong session (display writes + input kicks can't work from there).
|
||||
fn no_first_frame_diagnosis(&self, st: u32) -> String {
|
||||
let what = match st {
|
||||
// The delivery was never consumed: no swap-chain worker ran for this monitor at all.
|
||||
DRV_STATUS_NONE => "the driver never attached — the channel delivery was never \
|
||||
consumed, so the OS ran no swap-chain worker for this monitor (display not \
|
||||
composed at all: console display-off / modern standby, or the mode commit \
|
||||
never reached the adapter)"
|
||||
.to_string(),
|
||||
DRV_STATUS_OPENED => {
|
||||
// SAFETY: in-bounds, aligned u32 read of the live, owned shared-header mapping
|
||||
// (same best-effort diagnostic access as the `driver_status` read in the caller);
|
||||
// no reference into the shared region is formed.
|
||||
let detail = unsafe { (*self.header).driver_status_detail };
|
||||
match unpack_opened_detail(detail) {
|
||||
Some((0, _)) => "driver attached with a live swap-chain, but DWM composed \
|
||||
ZERO frames — an undamaged or powered-off desktop, and the compose \
|
||||
kicks didn't bite (synthetic input is blocked on the secure desktop)"
|
||||
.to_string(),
|
||||
Some((offered, mismatched)) => format!(
|
||||
"driver attached and DWM composed {offered} frame(s), but none matched \
|
||||
the ring — {mismatched} dropped for a size/format mismatch (the \
|
||||
display's actual mode differs from what the host sized the ring to: \
|
||||
a mid-open mode-set, a fullscreen game, or a stale GDI view)"
|
||||
),
|
||||
// A pre-detail driver never stamps the live bit — say so rather than guess.
|
||||
None => "driver attached but published nothing; this pf-vdisplay build \
|
||||
predates attach diagnostics, so the cause can't be named — update the \
|
||||
driver for a precise line here"
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
other => format!("driver_status={other} (unexpected at this point)"),
|
||||
};
|
||||
match pf_win_display::console_session_mismatch() {
|
||||
Some((own, console)) => format!(
|
||||
"{what} [host is in session {own} but the console is session {console} — display \
|
||||
writes and input kicks cannot work from a non-console session; reconnect the \
|
||||
console or run via the installed service]"
|
||||
),
|
||||
None => what,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `wait_for_attach`'s DRV_STATUS_TEX_FAIL as a typed error: the driver could not open the ring
|
||||
/// textures, and `driver_luid` (packed, from the shared header) is the adapter its swap-chain
|
||||
/// ACTUALLY renders on — `open_inner` downcasts to this to rebind the ring there once.
|
||||
#[derive(Debug)]
|
||||
struct AttachTexFail {
|
||||
detail: u32,
|
||||
driver_luid: i64,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AttachTexFail {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"IDD-push driver failed to attach (driver_status={DRV_STATUS_TEX_FAIL} \
|
||||
detail=0x{:08x}): it could not open the ring textures — its swap-chain renders on \
|
||||
adapter {:08x}:{:08x}, not the ring's (render-adapter mismatch)",
|
||||
self.detail,
|
||||
(self.driver_luid >> 32) as i32,
|
||||
(self.driver_luid & 0xffff_ffff) as u32,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AttachTexFail {}
|
||||
@@ -31,6 +31,10 @@ pub(super) struct StallWatch {
|
||||
/// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
|
||||
recent: std::collections::VecDeque<Instant>,
|
||||
cadence: pf_frame::metronome::Metronome,
|
||||
/// Stalls seen this session, and how many had a coinciding OS display event — the discriminator
|
||||
/// [`Self::report`] uses. They were capturer fields that nothing outside the report touched.
|
||||
seen: u32,
|
||||
with_os_events: u32,
|
||||
}
|
||||
|
||||
impl StallWatch {
|
||||
@@ -48,6 +52,8 @@ impl StallWatch {
|
||||
Self {
|
||||
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
|
||||
cadence: pf_frame::metronome::Metronome::new(),
|
||||
seen: 0,
|
||||
with_os_events: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,4 +85,81 @@ impl StallWatch {
|
||||
metronomic: self.cadence.note(now),
|
||||
})
|
||||
}
|
||||
/// Log a detected stall, correlate it against OS display events, and — once the cadence turns
|
||||
/// metronomic — name the class of disturbance and its cures.
|
||||
///
|
||||
/// Lives here rather than in `try_consume` (sweep Phase 5.4): it is ~65 lines of log prose plus
|
||||
/// a running tally, all of it about stalls and none of it about consuming a frame, in a function
|
||||
/// that runs per frame. `now` is the instant of the frame that ENDED the stall — the same one
|
||||
/// passed to [`Self::note_fresh`] — which is what bounds the event-correlation window.
|
||||
pub(super) fn report(&mut self, stall: &Stall, now: Instant) {
|
||||
// OS display events inside the gap (plus a lead-in margin: the event that CAUSED the
|
||||
// hole lands just before DWM stops delivering) — the attribution that turns "DWM
|
||||
// stopped composing" into "…because Windows re-enumerated SAMSUNG on HDMI".
|
||||
let window = stall.gap + Duration::from_millis(300);
|
||||
let events = now
|
||||
.checked_sub(window)
|
||||
.map(|from| pf_win_display::display_events::events_between(from, now))
|
||||
.unwrap_or_default();
|
||||
self.seen = self.seen.saturating_add(1);
|
||||
if !events.is_empty() {
|
||||
self.with_os_events = self.with_os_events.saturating_add(1);
|
||||
}
|
||||
// debug (not warn): a single hole also happens when content legitimately pauses;
|
||||
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
||||
// at debug level, and the web-console debug ring captures these.
|
||||
tracing::debug!(
|
||||
gap_ms = stall.gap.as_millis() as u64,
|
||||
os_display_events = %pf_win_display::display_events::summarize(&events),
|
||||
"IDD-push capture stall — the desktop was composing at speed, then DWM \
|
||||
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_physicals();
|
||||
let suspects = if suspects.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
suspects.join(", ")
|
||||
};
|
||||
let correlated = format!("{}/{}", self.with_os_events, self.seen);
|
||||
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
|
||||
// cascade is OS-visible; otherwise the disturbance never surfaces above the
|
||||
// driver. Different classes, different cures — say which one this box has.
|
||||
if self.with_os_events * 2 >= self.seen {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
os_correlated = correlated,
|
||||
connected_inactive = %suspects,
|
||||
"capture stalls are METRONOMIC and coincide with Windows monitor \
|
||||
hot-plug/re-enumeration events — a connected display (or its \
|
||||
cable/switch/AVR) re-probes the link on a timer and Windows re-reacts \
|
||||
each time. Cures, best-first: that display's OSD 'auto input \
|
||||
scan/detect' OFF (and on TVs: instant-on/quick-start + CEC off), \
|
||||
unplug its cable at the GPU, an HPD-holding adapter/dummy plug, or \
|
||||
keep it active while streaming; the pnp_disable_monitors policy axis \
|
||||
suppresses the Windows-side reaction (see connected_inactive for the \
|
||||
suspects)"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
os_correlated = correlated,
|
||||
connected_inactive = %suspects,
|
||||
"capture stalls are METRONOMIC with NO coinciding OS display event — \
|
||||
the disturbance is BELOW Windows: the GPU driver servicing a \
|
||||
connected-but-asleep sink (standby HPD/DDC/link probing), \
|
||||
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 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,23 +137,29 @@ impl Capturer for SyntheticNv12Capturer {
|
||||
/// Resolve the same render adapter the encoder will pick (`PUNKTFUNK_RENDER_ADAPTER` / preference /
|
||||
/// max-VRAM LUID), falling back to adapter 0.
|
||||
///
|
||||
/// # Safety
|
||||
/// Calls DXGI factory/adapter enumeration; returns owned COM objects or an error.
|
||||
unsafe fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
|
||||
if let Some(luid) = pf_gpu::resolve_render_adapter_luid() {
|
||||
if let Ok(a) = factory.EnumAdapterByLuid::<IDXGIAdapter1>(luid) {
|
||||
return Ok(a);
|
||||
/// Safe: it takes no arguments, so there is nothing a caller could get wrong — it creates the
|
||||
/// factory itself and returns an owned adapter. The `# Safety` section it used to carry ("calls
|
||||
/// DXGI enumeration; returns owned COM objects") described the body, which is not a contract.
|
||||
fn resolve_render_adapter() -> Result<IDXGIAdapter1> {
|
||||
// SAFETY: three `?`/`Ok`-checked DXGI enumeration calls over owned locals — the factory is
|
||||
// created here and the adapters it returns own their own COM references. No raw pointers.
|
||||
unsafe {
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
|
||||
if let Some(luid) = pf_gpu::resolve_render_adapter_luid() {
|
||||
if let Ok(a) = factory.EnumAdapterByLuid::<IDXGIAdapter1>(luid) {
|
||||
return Ok(a);
|
||||
}
|
||||
}
|
||||
factory.EnumAdapters1(0).context("EnumAdapters1(0)")
|
||||
}
|
||||
factory.EnumAdapters1(0).context("EnumAdapters1(0)")
|
||||
}
|
||||
|
||||
/// Create an NV12 `Texture2D` with the given usage/CPU-access/bind flags.
|
||||
///
|
||||
/// # Safety
|
||||
/// `device` must be a live D3D11 device; the returned texture is owned by the caller.
|
||||
unsafe fn create_nv12(
|
||||
/// Safe: its old `# Safety` asked for "a live D3D11 device", which `&ID3D11Device` — a borrowed,
|
||||
/// reference-counted COM wrapper — already guarantees; the rest ("the returned texture is owned by
|
||||
/// the caller") is an ownership note, not a soundness obligation. Every flag is a plain scalar.
|
||||
fn create_nv12(
|
||||
device: &ID3D11Device,
|
||||
width: u32,
|
||||
height: u32,
|
||||
@@ -177,8 +183,12 @@ unsafe fn create_nv12(
|
||||
..Default::default()
|
||||
};
|
||||
let mut tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&desc, None, Some(&mut tex))
|
||||
.context("CreateTexture2D(NV12)")?;
|
||||
// SAFETY: one `?`-checked `CreateTexture2D` on the `&ID3D11Device` borrow, which the borrow
|
||||
// itself keeps live, with a fully-initialized stack descriptor and a live `Option` out-param.
|
||||
unsafe {
|
||||
device
|
||||
.CreateTexture2D(&desc, None, Some(&mut tex))
|
||||
.context("CreateTexture2D(NV12)")?;
|
||||
}
|
||||
tex.context("CreateTexture2D returned a null NV12 texture")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -260,6 +260,16 @@ fn handle_event(client: &NativeClient, state: &mut State, ev: ClipEventCore) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Put plain text on this machine's clipboard — the "Copy link" affordance, which has nothing
|
||||
/// to do with the streaming bridge above but wants the same OS plumbing. Best-effort: a
|
||||
/// clipboard another process is holding open is a transient nuisance, never worth failing a
|
||||
/// user action over. A no-op where this build has no OS clipboard.
|
||||
pub fn set_text(text: &str) {
|
||||
if let Err(e) = os::set(MIME_TEXT, text.as_bytes()) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "copying to the clipboard");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod os {
|
||||
//! The Win32 clipboard seam. Every entry point opens the clipboard, does one thing and
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -299,6 +299,23 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind a slot DECLARES to the host ([`InputKind::GamepadArrival`]) given the user's
|
||||
/// controller-type `setting` and the pad's `physical` kind: an explicit setting emulates that pad
|
||||
/// for every slot, `Auto` keeps per-pad detection (what makes a mixed session honest).
|
||||
///
|
||||
/// This has to be applied per pad and not just in the Hello: the host builds each virtual device
|
||||
/// from that pad's arrival and only falls back to the session default for a pad that never
|
||||
/// declares one, so a client that always declared the detected kind would silently undo the
|
||||
/// setting the moment a controller connected. The physical kind is still what the LOCAL feedback
|
||||
/// paths use (DualSense raw effects, the Deck rumble keep-alive) — those talk to the controller in
|
||||
/// the user's hands, not the one the host is pretending to have.
|
||||
fn declared_kind(setting: GamepadPref, physical: GamepadPref) -> GamepadPref {
|
||||
match setting {
|
||||
GamepadPref::Auto => physical,
|
||||
explicit => explicit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort "this machine is a Steam Deck". The Gaming-Mode env short-circuits; desktop
|
||||
/// mode falls back to DMI (Valve board, Jupiter = LCD / Galileo = OLED — readable inside the
|
||||
/// flatpak sandbox). Cached: the answer can't change while we run.
|
||||
@@ -318,6 +335,7 @@ enum Ctl {
|
||||
Attach(Arc<NativeClient>),
|
||||
Detach,
|
||||
Pin(Option<String>),
|
||||
KindOverride(GamepadPref),
|
||||
MenuMode(bool),
|
||||
MenuRumble(MenuPulse),
|
||||
}
|
||||
@@ -452,6 +470,18 @@ impl GamepadService {
|
||||
let _ = self.ctl.send(Ctl::Pin(key));
|
||||
}
|
||||
|
||||
/// Adopt the user's explicit controller-type setting for the session about to start
|
||||
/// (`GamepadPref::Auto` = detect per pad, the default).
|
||||
///
|
||||
/// This is NOT redundant with the session default in the Hello: a current host honors a pad's
|
||||
/// [`InputKind::GamepadArrival`] over the session default, so a client that declared only the
|
||||
/// detected kind would silently undo the setting the moment a controller connected. Call it
|
||||
/// before [`Self::attach`] — slots declare their kind at open time and the host does not
|
||||
/// hot-swap a device that already exists.
|
||||
pub fn set_kind_override(&self, pref: GamepadPref) {
|
||||
let _ = self.ctl.send(Ctl::KindOverride(pref));
|
||||
}
|
||||
|
||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||
let _ = self.ctl.send(Ctl::Attach(connector));
|
||||
}
|
||||
@@ -691,6 +721,10 @@ struct Worker {
|
||||
/// connected pads, so it survives restarts and disconnects. A pin forwards ONLY that pad
|
||||
/// (an explicit single-player choice); Automatic forwards every real controller.
|
||||
pinned: Option<String>,
|
||||
/// The user's explicit "controller type" setting ([`GamepadService::set_kind_override`]);
|
||||
/// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never
|
||||
/// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad.
|
||||
kind_override: GamepadPref,
|
||||
attached: Option<Arc<NativeClient>>,
|
||||
/// Raises the UI escape signal; the escape chord fires it once per press.
|
||||
escape_tx: async_channel::Sender<()>,
|
||||
@@ -886,6 +920,7 @@ impl Worker {
|
||||
Some(p) => p.pref,
|
||||
None => GamepadPref::Xbox360,
|
||||
};
|
||||
let declared = declared_kind(self.kind_override, pref);
|
||||
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
|
||||
Ok(pad) => {
|
||||
let mut slot = Slot::new(id, index, pref, pad);
|
||||
@@ -895,7 +930,13 @@ impl Worker {
|
||||
// re-sends it a few times against datagram loss; an older host ignores it and
|
||||
// uses the session-default kind.
|
||||
if let Some(c) = &self.attached {
|
||||
send(c, InputKind::GamepadArrival, pref.to_u8() as u32, 0, index);
|
||||
send(
|
||||
c,
|
||||
InputKind::GamepadArrival,
|
||||
declared.to_u8() as u32,
|
||||
0,
|
||||
index,
|
||||
);
|
||||
// Declare the actuator's quirks to the shared rumble policy engine. ALWAYS
|
||||
// set (defaults for a well-behaved pad): wire indices are reused within a
|
||||
// connection, so a Deck slot that closes must not leave its keepalive quirk
|
||||
@@ -911,7 +952,13 @@ impl Worker {
|
||||
};
|
||||
c.set_rumble_quirks(index as u16, quirks);
|
||||
}
|
||||
tracing::info!(id, index, pref = ?pref, "gamepad forwarding (slot opened)");
|
||||
tracing::info!(
|
||||
id,
|
||||
index,
|
||||
pref = ?pref,
|
||||
declared = ?declared,
|
||||
"gamepad forwarding (slot opened)"
|
||||
);
|
||||
self.slots.push(slot);
|
||||
}
|
||||
Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"),
|
||||
@@ -1219,6 +1266,7 @@ impl Worker {
|
||||
self.pinned = key;
|
||||
self.refresh_active();
|
||||
}
|
||||
Ok(Ctl::KindOverride(pref)) => self.kind_override = pref,
|
||||
Ok(Ctl::MenuMode(on)) => {
|
||||
self.menu_mode = on;
|
||||
if on {
|
||||
@@ -1558,6 +1606,7 @@ impl Worker {
|
||||
menu_open: None,
|
||||
order: Vec::new(),
|
||||
pinned: None,
|
||||
kind_override: GamepadPref::Auto,
|
||||
attached: None,
|
||||
escape_tx,
|
||||
disconnect_tx,
|
||||
@@ -1823,6 +1872,38 @@ mod slot_tests {
|
||||
assert_eq!(lowest_free_index(&but_seven), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_setting_is_what_every_pad_declares() {
|
||||
// The regression this pins: the setting used to reach the Hello only, and each pad's
|
||||
// arrival then re-declared the DETECTED kind — which the host honors over the session
|
||||
// default, so "emulate my DualSense as a DualShock 4" produced a DualSense.
|
||||
assert_eq!(
|
||||
declared_kind(GamepadPref::DualShock4, GamepadPref::DualSense),
|
||||
GamepadPref::DualShock4
|
||||
);
|
||||
// Every physical pad in a mixed session follows the one explicit choice.
|
||||
for physical in [
|
||||
GamepadPref::DualSense,
|
||||
GamepadPref::Xbox360,
|
||||
GamepadPref::SwitchPro,
|
||||
GamepadPref::SteamDeck,
|
||||
] {
|
||||
assert_eq!(
|
||||
declared_kind(GamepadPref::Xbox360, physical),
|
||||
GamepadPref::Xbox360
|
||||
);
|
||||
}
|
||||
// Automatic keeps per-pad detection — otherwise a mixed session collapses to one type.
|
||||
assert_eq!(
|
||||
declared_kind(GamepadPref::Auto, GamepadPref::DualSense),
|
||||
GamepadPref::DualSense
|
||||
);
|
||||
assert_eq!(
|
||||
declared_kind(GamepadPref::Auto, GamepadPref::SteamDeck),
|
||||
GamepadPref::SteamDeck
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidout_pad_reads_every_variant() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -62,6 +62,10 @@ pub struct GameEntry {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub art: Artwork,
|
||||
/// The system the title runs on (`"PC"`, `"PS2"`, …) — free-form display string from the
|
||||
/// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it.
|
||||
#[serde(default)]
|
||||
pub platform: Option<String>,
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
@@ -280,7 +284,7 @@ mod tests {
|
||||
fn game_entry_decodes_the_wire_shape() {
|
||||
// The exact shape mgmt.rs serializes (optional art fields omitted, launch ignored).
|
||||
let json = r#"[
|
||||
{"id":"steam:570","store":"steam","title":"Dota 2",
|
||||
{"id":"steam:570","store":"steam","title":"Dota 2","platform":"PC",
|
||||
"art":{"portrait":"/api/v1/library/art/steam:570/portrait"},
|
||||
"launch":{"kind":"steam_appid","value":"570"}},
|
||||
{"id":"custom:abc","store":"custom","title":"My Emu","art":{}}
|
||||
@@ -288,7 +292,12 @@ mod tests {
|
||||
let games: Vec<GameEntry> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(games.len(), 2);
|
||||
assert_eq!(games[0].id, "steam:570");
|
||||
assert_eq!(games[0].platform.as_deref(), Some("PC"));
|
||||
assert!(games[1].art.portrait.is_none());
|
||||
assert!(
|
||||
games[1].platform.is_none(),
|
||||
"pre-metadata hosts still parse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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 {
|
||||
@@ -605,7 +610,7 @@ impl D3d11vaDecoder {
|
||||
/// BGRA8) under its keyed mutex and describe the hand-off. The mutex acquire also
|
||||
/// back-pressures against the presenter still reading this slot (only possible if the
|
||||
/// stream runs `RING_SLOTS` ahead of present).
|
||||
unsafe fn lift(&mut self) -> Result<D3d11Frame> {
|
||||
fn lift(&mut self) -> Result<D3d11Frame> {
|
||||
use ffmpeg::ffi;
|
||||
unsafe {
|
||||
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 {
|
||||
@@ -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 {
|
||||
@@ -131,7 +139,7 @@ impl VaapiDecoder {
|
||||
/// single-plane texture with the chroma dropped, painting the screen green. The fix:
|
||||
/// derive the COMBINED fourcc from the decoder's software pixel format (NV12 →
|
||||
/// `DRM_FORMAT_NV12`) and flatten every plane across every layer in order (Y then UV).
|
||||
unsafe fn map_dmabuf(&mut self) -> Result<DmabufFrame> {
|
||||
fn map_dmabuf(&mut self) -> Result<DmabufFrame> {
|
||||
use ffmpeg::ffi;
|
||||
unsafe {
|
||||
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VAAPI as i32 {
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,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 +19,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
|
||||
@@ -53,25 +61,45 @@ struct VkCtxStorage {
|
||||
/// [`VkCtxStorage`], which outlives the context). Replaces FFmpeg's internal default,
|
||||
/// which only serializes FFmpeg against itself — the presenter submits to the same
|
||||
/// graphics queue from another thread and holds this same lock around its calls.
|
||||
///
|
||||
/// # Safety
|
||||
/// FFmpeg calls this with the `AVHWDeviceContext` it owns, whose `user_opaque` we set to a
|
||||
/// `*const QueueLock` before handing the context over.
|
||||
unsafe extern "C" fn ffvk_lock_queue(
|
||||
ctx: *mut pf_ffvk::AVHWDeviceContext,
|
||||
_queue_family: u32,
|
||||
_index: u32,
|
||||
) {
|
||||
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
|
||||
let lock = (*dev).user_opaque as *const QueueLock;
|
||||
(*lock).lock();
|
||||
// SAFETY: `ctx` is the live context FFmpeg passes to its own callback, and the two
|
||||
// `AVHWDeviceContext` declarations (pf_ffvk's and ffmpeg-sys's) describe the same C struct, so
|
||||
// the cast reads the same `user_opaque` field. That field holds the pointer we stored, which
|
||||
// borrows `VkCtxStorage::_queue_lock` — an `Arc<QueueLock>` the storage keeps alive for as long
|
||||
// as the hw device context can fire this trampoline (see its field doc), so the lock outlives
|
||||
// every call FFmpeg can make.
|
||||
unsafe {
|
||||
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
|
||||
let lock = (*dev).user_opaque as *const QueueLock;
|
||||
(*lock).lock();
|
||||
}
|
||||
}
|
||||
|
||||
/// The matching `unlock_queue` trampoline — see [`ffvk_lock_queue`].
|
||||
///
|
||||
/// # Safety
|
||||
/// As [`ffvk_lock_queue`]; additionally, FFmpeg only calls this after a matching `lock_queue`, so
|
||||
/// the lock it releases is one this pair took.
|
||||
unsafe extern "C" fn ffvk_unlock_queue(
|
||||
ctx: *mut pf_ffvk::AVHWDeviceContext,
|
||||
_queue_family: u32,
|
||||
_index: u32,
|
||||
) {
|
||||
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
|
||||
let lock = (*dev).user_opaque as *const QueueLock;
|
||||
(*lock).unlock();
|
||||
// SAFETY: as `ffvk_lock_queue` — same live context from FFmpeg, same `user_opaque` pointer into
|
||||
// the `Arc<QueueLock>` that `VkCtxStorage` keeps alive for the context's whole lifetime.
|
||||
unsafe {
|
||||
let dev = ctx as *mut ffmpeg::ffi::AVHWDeviceContext;
|
||||
let lock = (*dev).user_opaque as *const QueueLock;
|
||||
(*lock).unlock();
|
||||
}
|
||||
}
|
||||
|
||||
impl VulkanDecoder {
|
||||
@@ -187,6 +215,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 +232,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 +252,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 {
|
||||
@@ -292,7 +320,7 @@ impl VulkanDecoder {
|
||||
/// guard — keeps the image + frames context alive through present) and ship the
|
||||
/// POINTERS; the presenter reads the live sync state under the frames-context lock
|
||||
/// at its own submit time.
|
||||
unsafe fn extract(&mut self) -> Result<VkVideoFrame> {
|
||||
fn extract(&mut self) -> Result<VkVideoFrame> {
|
||||
use ffmpeg::ffi;
|
||||
unsafe {
|
||||
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_VULKAN as i32 {
|
||||
@@ -358,7 +386,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 +424,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
|
||||
|
||||
@@ -249,9 +249,16 @@ impl Codec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and HDR
|
||||
/// plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap `Copy`; fixed
|
||||
/// for the session (an HDR toggle re-initialises the encoder — re-query if that matters).
|
||||
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and
|
||||
/// cursor plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap
|
||||
/// `Copy`; fixed for the session (an HDR toggle re-initialises the encoder — re-query if that
|
||||
/// matters).
|
||||
///
|
||||
/// (There is deliberately NO `supports_hdr_metadata` cap: in-band HDR SEI/OBU embedding needs no
|
||||
/// host-side routing — every first-party client reads the static grade exclusively out-of-band
|
||||
/// (the native 0xCE datagram / the GameStream 0x010e control message), both planes send it
|
||||
/// unconditionally, and the in-band grade is a decoder-side bonus for stock clients. A cap field
|
||||
/// nothing reads is a contract nobody honors; it was deleted after shipping write-only.)
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EncoderCaps {
|
||||
/// The encoder can perform real reference-frame invalidation — i.e.
|
||||
@@ -261,10 +268,6 @@ pub struct EncoderCaps {
|
||||
/// AMF (user-LTR force-reference, when the driver accepted the LTR slots at open). The
|
||||
/// libavcodec paths (Linux NVENC, VAAPI, QSV) can't express it and always keyframe.
|
||||
pub supports_rfi: bool,
|
||||
/// The encoder emits in-band HDR mastering/CLL SEI from [`set_hdr_meta`](Encoder::set_hdr_meta).
|
||||
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
|
||||
/// Windows direct-NVENC path attaches it today.
|
||||
pub supports_hdr_metadata: bool,
|
||||
/// The opened encoder is actually producing a full-chroma 4:4:4 (`chroma_format_idc = 3`) stream.
|
||||
/// `false` on every 4:2:0 session (the default) and on a backend that declined 4:4:4. Set by the
|
||||
/// NVENC backends (Linux + Windows). The chroma is committed to the wire (`Welcome::chroma_format`)
|
||||
@@ -304,8 +307,11 @@ pub struct EncoderCaps {
|
||||
///
|
||||
/// This makes the answer queryable instead of assumed. It is deliberately a plain fact about the
|
||||
/// encoder, not a policy: what to DO when a session wants blending and the backend cannot is the
|
||||
/// host's call, since only the host can re-plan capture (fall back to capturer-side compositing).
|
||||
/// `open_video` can only warn, which it does.
|
||||
/// host's call, since only the host can re-plan capture. That call is wired now — the
|
||||
/// negotiation consults the pre-open mirror ([`cursor_blend_capable`](crate::cursor_blend_capable))
|
||||
/// to gate the cursor channel and to keep capture on embedded-cursor / CSC-capable shapes for
|
||||
/// any backend that can't blend; `open_video`'s post-open check remains as the backstop for
|
||||
/// open-time fallbacks the plan can't see.
|
||||
pub blends_cursor: bool,
|
||||
}
|
||||
|
||||
@@ -333,11 +339,10 @@ pub trait Encoder: Send {
|
||||
let _ = wire_index;
|
||||
self.submit(frame)
|
||||
}
|
||||
/// This encoder's static [capabilities](EncoderCaps) (RFI, HDR SEI), so the session glue can
|
||||
/// route by query rather than rely on the no-op/`false` defaults of
|
||||
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) / [`set_hdr_meta`](Self::set_hdr_meta).
|
||||
/// Default: no optional capabilities (the SDR / libavcodec backends) — only the direct-NVENC
|
||||
/// path overrides it.
|
||||
/// This encoder's static [capabilities](EncoderCaps) (RFI, intra-refresh, chroma, cursor
|
||||
/// blending), so the session glue can route by query rather than rely on the no-op/`false`
|
||||
/// defaults of methods like [`invalidate_ref_frames`](Self::invalidate_ref_frames).
|
||||
/// Default: no optional capabilities (the software / libavcodec backends).
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
EncoderCaps::default()
|
||||
}
|
||||
@@ -345,10 +350,12 @@ pub trait Encoder: Send {
|
||||
/// reference-frame-invalidation request). Default: no-op.
|
||||
fn request_keyframe(&mut self) {}
|
||||
/// Set the source's static HDR mastering metadata (from the capturer). An HDR encoder emits it
|
||||
/// as in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each
|
||||
/// keyframe so any decoder — including stock Moonlight — tone-maps from the source's real grade.
|
||||
/// Default: no-op (SDR encoders / libavcodec paths that don't attach it yet). Cheap to call
|
||||
/// every frame; only the direct-NVENC path consumes it.
|
||||
/// in-band (HEVC/H.264 `mastering_display_colour_volume` + `content_light_level_info` SEI, or
|
||||
/// AV1 metadata OBUs) on keyframes so a stock decoder — e.g. stock Moonlight — tone-maps from
|
||||
/// the source's real grade. Default: no-op (SDR encoders / paths that don't attach it).
|
||||
/// Cheap to call every frame; consumed by Windows direct-NVENC, native AMF, and native QSV.
|
||||
/// Every first-party client reads the grade out-of-band (the 0xCE datagram) regardless, so
|
||||
/// this is a bonus for stock decoders, never the primary channel.
|
||||
fn set_hdr_meta(&mut self, _meta: Option<punktfunk_core::quic::HdrMeta>) {}
|
||||
/// Invalidate a contiguous range of previously-encoded reference frames (client frame numbers
|
||||
/// — WIRE frame indexes, the domain [`submit_indexed`](Self::submit_indexed) pins the encoder's
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -22,7 +22,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 +61,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 +76,61 @@ 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;
|
||||
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);
|
||||
if r < 0 {
|
||||
ffi::av_buffer_unref(&mut device_ref);
|
||||
bail!("av_hwdevice_ctx_init failed ({r})");
|
||||
}
|
||||
// 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 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;
|
||||
(*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);
|
||||
if r < 0 {
|
||||
ffi::av_buffer_unref(&mut frames_ref);
|
||||
ffi::av_buffer_unref(&mut device_ref);
|
||||
bail!("av_hwframe_ctx_init failed ({r})");
|
||||
}
|
||||
// SAFETY: `av_hwdevice_ctx_alloc` returns either null — which `AvBuffer::from_raw` rejects,
|
||||
// so the `?` returns before anything below runs — or a fresh ref whose `data` libav has
|
||||
// already initialized as an `AVHWDeviceContext`. For a CUDA device that context's `hwctx`
|
||||
// is an `AVCUDADeviceContext` (our repr(C) mirror of libav's layout), so writing
|
||||
// `cuda_ctx` is an in-bounds field store on a live allocation, and `cu_ctx` is a valid
|
||||
// `CUcontext` by this fn's contract. `av_hwdevice_ctx_init` then takes the same live ref;
|
||||
// it must see `cuda_ctx` already set, which is why the store precedes it.
|
||||
let device_ref = unsafe {
|
||||
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.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("av_hwdevice_ctx_init failed ({r})");
|
||||
}
|
||||
device_ref
|
||||
};
|
||||
|
||||
// SAFETY: the same shape one level up — `av_hwframe_ctx_alloc` is handed the live,
|
||||
// now-initialized device ref and returns null (rejected by `from_raw`, so the `?` leaves
|
||||
// before the writes) or a ref whose `data` is a live `AVHWFramesContext`. Every store below
|
||||
// is an in-bounds field write on that allocation, all plain scalars, done before
|
||||
// `av_hwframe_ctx_init` reads them.
|
||||
let frames_ref = unsafe {
|
||||
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.as_ptr());
|
||||
if r < 0 {
|
||||
bail!("av_hwframe_ctx_init failed ({r})");
|
||||
}
|
||||
frames_ref
|
||||
};
|
||||
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 +353,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 +392,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 +429,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 +855,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 +1005,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 +1064,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;
|
||||
@@ -1613,14 +1804,22 @@ impl Encoder for NvencCudaEncoder {
|
||||
// `async_rt` must be absent too: in two-thread mode the frame may be recycled right after
|
||||
// submit returns while the stream still holds its copy (belt-and-braces — an escalated
|
||||
// session was rebuilt without the binding, so `stream_ordered` is false there anyway).
|
||||
// Cursor-bearing frames additionally force the CPU-synced path: the Vulkan blend sits
|
||||
// between the CUDA copy and the encode, and its cross-API ordering is fence/CPU-
|
||||
// established, not stream-ordered. Frames without a cursor (games hide it; client-draws
|
||||
// sessions strip it) keep the stream-ordered fast path untouched.
|
||||
let ordered = self.stream_ordered
|
||||
&& self.async_rt.is_none()
|
||||
&& self.pending.is_empty()
|
||||
&& captured.cursor.is_none();
|
||||
let base_ordered =
|
||||
self.stream_ordered && self.async_rt.is_none() && self.pending.is_empty();
|
||||
// Cursor-bearing frames stay on the fast path when the blend itself can be stream-
|
||||
// ordered: the Vulkan dispatch waits/advances a timeline semaphore CUDA also holds, so
|
||||
// copy→blend→encode orders entirely on-device (`VkSlotBlend::blend_ref_ordered`). Where
|
||||
// that isn't available (no timeline export, or the ring fell back to plain CUDA slots)
|
||||
// a cursor forces the CPU-synced path: the blend's cross-API ordering is then fence/CPU-
|
||||
// established, sitting between the copy and the encode. That slow path is why cursor
|
||||
// frames USED to be gated out entirely — under gamescope the compositor re-attaches the
|
||||
// live pointer to EVERY frame, and the per-frame CPU syncs (exposed to the running
|
||||
// game's GPU load) capped a 120 fps session near 80 (submit p50 ~10 ms).
|
||||
let cursor_ordered = base_ordered
|
||||
&& captured.cursor.is_some()
|
||||
&& matches!(self.ring[slot].surface, SlotSurface::Vk(_))
|
||||
&& self.vk_blend.as_ref().is_some_and(|vk| vk.ordered_ready());
|
||||
let ordered = base_ordered && (captured.cursor.is_none() || cursor_ordered);
|
||||
let t0 = std::time::Instant::now();
|
||||
|
||||
// Copy the captured buffer into this slot's input surface before encoding it.
|
||||
@@ -1629,29 +1828,45 @@ impl Encoder for NvencCudaEncoder {
|
||||
|
||||
// Cursor-as-metadata: blend the overlay into this slot's OWNED input surface via the
|
||||
// SPIR-V compute pass (a dispatch over the cursor's rect — never the compositor's
|
||||
// dmabuf). Cursor-bearing frames forced `ordered = false` above, so the CUDA copy has
|
||||
// completed before the Vulkan dispatch and the fence-waited dispatch completes before
|
||||
// the encode below — the cross-API ordering is CPU-established. Any failure degrades to
|
||||
// no cursor, never a dropped frame.
|
||||
// dmabuf). On the `cursor_ordered` path the enqueued copy, the dispatch, and the encode
|
||||
// are ordered on-device through the timeline semaphore (no CPU sync — see the gate
|
||||
// above). Otherwise `ordered` is false: the CUDA copy completed before the Vulkan
|
||||
// dispatch and the fence-waited dispatch completes before the encode below — the
|
||||
// cross-API ordering is CPU-established. Any failure degrades to no cursor, never a
|
||||
// dropped frame (a failed ordered blend leaves the copy→encode stream ordering intact).
|
||||
if let Some(ov) = &captured.cursor {
|
||||
if let (Some(vk), SlotSurface::Vk(vref)) =
|
||||
(self.vk_blend.as_mut(), &self.ring[slot].surface)
|
||||
{
|
||||
if self.cursor_serial != ov.serial {
|
||||
// Quiesces any in-flight ordered blend internally before touching the
|
||||
// staging buffer (bitmap changes are rare — shape flips).
|
||||
vk.upload_cursor(ov.rgba.as_slice(), ov.w, ov.h);
|
||||
self.cursor_serial = ov.serial;
|
||||
}
|
||||
// surfW = content width; the blend derives plane strides from the slot's luma
|
||||
// height. Cursor pixels past the content land in cropped padding rows — harmless.
|
||||
let r = vk.blend_ref(
|
||||
vref,
|
||||
slot_fmt_of(self.buffer_fmt),
|
||||
self.width,
|
||||
ov.w,
|
||||
ov.h,
|
||||
ov.x,
|
||||
ov.y,
|
||||
);
|
||||
let r = if cursor_ordered {
|
||||
vk.blend_ref_ordered(
|
||||
vref,
|
||||
slot_fmt_of(self.buffer_fmt),
|
||||
self.width,
|
||||
ov.w,
|
||||
ov.h,
|
||||
ov.x,
|
||||
ov.y,
|
||||
)
|
||||
} else {
|
||||
vk.blend_ref(
|
||||
vref,
|
||||
slot_fmt_of(self.buffer_fmt),
|
||||
self.width,
|
||||
ov.w,
|
||||
ov.h,
|
||||
ov.x,
|
||||
ov.y,
|
||||
)
|
||||
};
|
||||
if let Err(e) = r {
|
||||
if !self.cursor_blend_warned {
|
||||
self.cursor_blend_warned = true;
|
||||
@@ -1686,7 +1901,9 @@ impl Encoder for NvencCudaEncoder {
|
||||
// stack-local and outlives the synchronous `encode_picture`. The input surface for `slot` was
|
||||
// just filled by the device→device copy — either synchronized (blocking mode) or ordered
|
||||
// before this encode by the session's IO-stream binding (`ordered` — same stream, see the
|
||||
// gate above) — and is not overwritten until this slot is reused POOL submits later, by
|
||||
// gate above; on the `cursor_ordered` path the blend's writes are likewise ordered before
|
||||
// the encode, via the timeline-semaphore wait `blend_ref_ordered` enqueued on that same
|
||||
// stream) — and is not overwritten until this slot is reused POOL submits later, by
|
||||
// which time this encode was polled (POOL ≥ in-flight depth; in ordered mode the poll's
|
||||
// blocking lock additionally proves the enqueued copy completed).
|
||||
unsafe {
|
||||
@@ -1850,7 +2067,6 @@ impl Encoder for NvencCudaEncoder {
|
||||
// Composites `frame.cursor` via the SPIR-V blend over the Vulkan-allocated input slot.
|
||||
blends_cursor: true,
|
||||
supports_rfi: self.rfi_supported,
|
||||
supports_hdr_metadata: self.hdr,
|
||||
chroma_444: self.chroma_444,
|
||||
intra_refresh: false,
|
||||
intra_refresh_recovery: false,
|
||||
@@ -2239,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).
|
||||
@@ -2259,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() {
|
||||
@@ -2333,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:
|
||||
@@ -2674,6 +3122,85 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// ON-HARDWARE (RTX box `.21`): cursor-bearing frames must KEEP the stream-ordered fast
|
||||
/// path — the gamescope 80-fps-on-a-120-session fix. With the timeline-semaphore blend
|
||||
/// available, `submit` takes `blend_ref_ordered` (the ticket advances by 2 per frame)
|
||||
/// instead of the CPU-synced fence-wait blend, and AUs keep flowing — including across a
|
||||
/// cursor-bitmap change (exercises the upload quiesce) and per-frame position moves.
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||
fn nvenc_cuda_cursor_blend_stream_ordered() {
|
||||
const W: u32 = 1280;
|
||||
const H: u32 = 720;
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
// Respect an explicit operator opt-out (or two-thread mode) rather than fail.
|
||||
if !stream_ordered_requested() || async_retrieve_requested() {
|
||||
println!("skipped: stream-ordered submit disabled by env");
|
||||
return;
|
||||
}
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
W,
|
||||
H,
|
||||
60,
|
||||
8_000_000,
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
true, // cursor_blend: bring up the Vulkan slot ring + 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 = nv12_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).
|
||||
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 cursor frame");
|
||||
while enc.poll().expect("poll").is_some() {
|
||||
aus += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(aus, 6, "every cursor frame must deliver an AU");
|
||||
assert!(
|
||||
enc.stream_ordered,
|
||||
"IO-stream binding must arm on a default-env session"
|
||||
);
|
||||
let vk = enc
|
||||
.vk_blend
|
||||
.as_ref()
|
||||
.expect("Vulkan slot blend must come up on an RTX box");
|
||||
assert!(
|
||||
vk.ordered_ready(),
|
||||
"timeline semaphore must export to CUDA on this driver"
|
||||
);
|
||||
assert_eq!(
|
||||
vk.ordered_ticket(),
|
||||
12,
|
||||
"all 6 cursor blends must take the ordered path (2 timeline values each)"
|
||||
);
|
||||
println!(
|
||||
"nvenc_cuda cursor stream-ordered: 6 cursor AUs, ticket={}",
|
||||
vk.ordered_ticket()
|
||||
);
|
||||
}
|
||||
|
||||
/// ON-HARDWARE (RTX box `.21`): the §7 LN3 pipelined-retrieve escalation —
|
||||
/// `set_pipelined(true)` on a live sync session must rebuild it without the IO-stream
|
||||
/// binding, spawn the retrieve thread on the re-open, and keep delivering AUs (the first
|
||||
|
||||
@@ -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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user